feat: Add toolbar option to create new note from selection in a Super note (#2896) (skip e2e)

This commit is contained in:
Aman Harwara
2025-04-23 17:29:18 +05:30
committed by GitHub
parent d6db45773c
commit b32a866f33
7 changed files with 145 additions and 5 deletions

View File

@@ -111,7 +111,7 @@ export class NoteViewController implements ItemViewControllerInterface {
this.needsInit = false
const addTagHierarchy = this.preferences.getValue(PrefKey.NoteAddToParentFolders, true)
const shouldAddTagHierarchy = this.preferences.getValue(PrefKey.NoteAddToParentFolders, true)
if (!this.item) {
log(LoggingDomain.NoteView, 'Initializing as template note')
@@ -141,7 +141,7 @@ export class NoteViewController implements ItemViewControllerInterface {
if (this.defaultTagUuid) {
const tag = this.items.findItem(this.defaultTagUuid) as SNTag
await this.mutator.addTagToNote(note, tag, addTagHierarchy)
await this.mutator.addTagToNote(note, tag, shouldAddTagHierarchy)
}
this.notifyObservers(this.item, PayloadEmitSource.InitialObserverRegistrationPush)

View File

@@ -800,6 +800,10 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
}
triggerSyncOnAction = () => {
if (!this.controller) {
// component might've already unmounted
return
}
this.controller.syncNow()
}

View File

@@ -0,0 +1,52 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { $getSelection, $isRangeSelection, COMMAND_PRIORITY_EDITOR, createCommand } from 'lexical'
import { useEffect } from 'react'
import { useApplication } from '../../ApplicationProvider'
import { NativeFeatureIdentifier, SNNote } from '@standardnotes/snjs'
import { $generateJSONFromSelectedNodes } from '@lexical/clipboard'
import { HeadlessSuperConverter } from '../Tools/HeadlessSuperConverter'
import { INSERT_BUBBLE_COMMAND } from './Commands'
export const CREATE_NOTE_FROM_SELECTION_COMMAND = createCommand<void>('CREATE_NOTE_FROM_SELECTION_COMMAND')
export function NoteFromSelectionPlugin({ currentNote }: { currentNote: SNNote }) {
const application = useApplication()
const [editor] = useLexicalComposerContext()
useEffect(() => {
async function insertAndLinkNewNoteFromJSON(json: string) {
editor.setEditable(false)
try {
const insertedNote = await application.notesController.createNoteWithContent(
NativeFeatureIdentifier.TYPES.SuperEditor,
application.itemListController.titleForNewNote(),
json,
)
await application.linkingController.linkItems(currentNote, insertedNote)
editor.dispatchCommand(INSERT_BUBBLE_COMMAND, insertedNote.uuid)
} catch (error) {
console.error(error)
} finally {
editor.setEditable(true)
}
}
return editor.registerCommand(
CREATE_NOTE_FROM_SELECTION_COMMAND,
function createNoteFromSelection() {
const selection = $getSelection()
if (!$isRangeSelection(selection)) {
return true
}
const { nodes } = $generateJSONFromSelectedNodes(editor, selection)
const converter = new HeadlessSuperConverter()
const json = converter.getStringifiedJSONFromSerializedNodes(nodes)
insertAndLinkNewNoteFromJSON(json).catch(console.error)
return true
},
COMMAND_PRIORITY_EDITOR,
)
}, [application.itemListController, application.linkingController, application.notesController, currentNote, editor])
return null
}

View File

@@ -77,6 +77,7 @@ import { ElementIds } from '@/Constants/ElementIDs'
import { $isDecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode'
import LinkViewer from './LinkViewer'
import { OPEN_FILE_UPLOAD_MODAL_COMMAND } from '../EncryptedFilePlugin/FilePlugin'
import { CREATE_NOTE_FROM_SELECTION_COMMAND } from '../NoteFromSelectionPlugin'
const TOGGLE_LINK_AND_EDIT_COMMAND = createCommand<string | null>('TOGGLE_LINK_AND_EDIT_COMMAND')
@@ -110,8 +111,8 @@ const blockTypeToIconName = {
quote: 'quote',
}
interface ToolbarButtonProps extends ComponentPropsWithoutRef<'button'> {
name: string
interface ToolbarButtonProps extends Omit<ComponentPropsWithoutRef<'button'>, 'name'> {
name: NonNullable<ReactNode>
active?: boolean
iconName?: string
children?: ReactNode
@@ -222,6 +223,8 @@ const ToolbarPlugin = () => {
const [isCode, setIsCode] = useState(false)
const [isHighlight, setIsHighlight] = useState(false)
const [hasNonCollapsedSelection, setHasNonCollapsedSelection] = useState(false)
const [linkNode, setLinkNode] = useState<LinkNode | null>(null)
const [linkTextNode, setLinkTextNode] = useState<TextNode | null>(null)
const [isEditingLink, setIsEditingLink] = useState(false)
@@ -312,6 +315,8 @@ const ToolbarPlugin = () => {
return
}
setHasNonCollapsedSelection(!selection.isCollapsed())
const anchorNode = selection.anchor.getNode()
const focusNode = selection.focus.getNode()
const isAnchorSameAsFocus = anchorNode === focusNode
@@ -796,6 +801,23 @@ const ToolbarPlugin = () => {
<Icon type="chevron-down" size="custom" className="ml-2 h-4 w-4 md:h-3.5 md:w-3.5" />
</ToolbarButton>
)}
{hasNonCollapsedSelection && (
<ToolbarButton
name={
<>
<div className="mb-1 font-semibold">Create new note from selection</div>
<div className="max-w-[35ch] text-xs">
Creates a new note containing the current selection and replaces the selection with a link to the
new note.
</div>
</>
}
iconName="notes"
onSelect={() => {
editor.dispatchCommand(CREATE_NOTE_FROM_SELECTION_COMMAND, undefined)
}}
/>
)}
</Toolbar>
{isMobile && (
<button

View File

@@ -40,6 +40,7 @@ import { useLocalPreference } from '@/Hooks/usePreference'
import BlockPickerMenuPlugin from './Plugins/BlockPickerPlugin/BlockPickerPlugin'
import { EditorEventSource } from '@/Types/EditorEventSource'
import { ElementIds } from '@/Constants/ElementIDs'
import { NoteFromSelectionPlugin } from './Plugins/NoteFromSelectionPlugin'
export const SuperNotePreviewCharLimit = 160
@@ -171,6 +172,7 @@ export const SuperEditor: FunctionComponent<Props> = ({
(itemUuid: string) => {
const item = application.items.findItem(itemUuid)
if (item) {
// TODO: We should only unlink item if all link bubbles to that item have been removed from the note
linkingController.unlinkItemFromSelectedItem(item).catch(console.error)
}
},
@@ -272,6 +274,7 @@ export const SuperEditor: FunctionComponent<Props> = ({
{readonly === undefined && <ReadonlyPlugin note={note.current} />}
<AutoFocusPlugin isEnabled={controller.isTemplateNote} />
<BlockPickerMenuPlugin />
<NoteFromSelectionPlugin currentNote={note.current} />
</BlocksEditor>
</BlocksEditorComposer>
</FilesControllerProvider>

View File

@@ -1,6 +1,14 @@
import { createHeadlessEditor } from '@lexical/headless'
import { FileItem, PrefKey, PrefValue, SuperConverterServiceInterface } from '@standardnotes/snjs'
import { $createParagraphNode, $getRoot, $insertNodes, $isParagraphNode, LexicalEditor, LexicalNode } from 'lexical'
import {
$createParagraphNode,
$getRoot,
$insertNodes,
$isParagraphNode,
LexicalEditor,
LexicalNode,
SerializedLexicalNode,
} from 'lexical'
import BlocksEditorTheme from '../Lexical/Theme/Theme'
import { BlockEditorNodes, SuperExportNodes } from '../Lexical/Nodes/AllNodes'
import { MarkdownTransformers } from '../MarkdownTransformers'
@@ -12,6 +20,7 @@ import { $convertToMarkdownString } from '../Lexical/Utils/MarkdownExport'
import { parseFileName } from '@standardnotes/utils'
import { $dfs } from '@lexical/utils'
import { $isFileNode } from '../Plugins/EncryptedFilePlugin/Nodes/FileUtils'
import { $generateNodesFromSerializedNodes, $insertGeneratedNodes } from '@lexical/clipboard'
export class HeadlessSuperConverter implements SuperConverterServiceInterface {
private importEditor: LexicalEditor
@@ -295,4 +304,28 @@ export class HeadlessSuperConverter implements SuperConverterServiceInterface {
return ids
}
/**
* Serialized nodes (usually generated by `$generateJSONFromSelectedNodes`) cannot be imported into
* Lexical if they were directly stringified. This function handles the process of generating actual
* Lexical nodes from the serialized ones, inserting them into an empty editor and then exporting the
* editor state of that as a JSON, which can then be used to create a new note.
*/
getStringifiedJSONFromSerializedNodes(serializedNodes: SerializedLexicalNode[]) {
this.exportEditor.update(
() => {
const root = $getRoot()
root.clear()
const selection = root.selectEnd()
const generatedNodes = $generateNodesFromSerializedNodes(serializedNodes)
$insertGeneratedNodes(this.exportEditor, generatedNodes, selection)
},
{
discrete: true,
},
)
return this.exportEditor.read(() => {
return JSON.stringify(this.exportEditor.getEditorState().toJSON())
})
}
}

View File

@@ -28,6 +28,9 @@ import {
AlertService,
ProtectionsClientInterface,
LocalPrefKey,
NoteContent,
noteTypeForEditorIdentifier,
ContentReference,
} from '@standardnotes/snjs'
import { makeObservable, observable, action, computed, runInAction } from 'mobx'
import { AbstractViewController } from '../Abstract/AbstractViewController'
@@ -408,4 +411,27 @@ export class NotesController
private getSelectedNotesList(): SNNote[] {
return Object.values(this.selectedNotes)
}
async createNoteWithContent(
editorIdentifier: string,
title: string,
text: string,
references: ContentReference[] = [],
): Promise<SNNote> {
const noteType = noteTypeForEditorIdentifier(editorIdentifier)
const selectedTag = this.navigationController.selected
const templateNote = this.items.createTemplateItem<NoteContent, SNNote>(ContentType.TYPES.Note, {
title,
text,
references,
noteType,
editorIdentifier,
})
const note = await this.mutator.insertItem<SNNote>(templateNote)
if (selectedTag instanceof SNTag) {
const shouldAddTagHierarchy = this.preferences.getValue(PrefKey.NoteAddToParentFolders, true)
await this.mutator.addTagToNote(templateNote, selectedTag, shouldAddTagHierarchy)
}
return note
}
}