feat: Add toolbar option to create new note from selection in a Super note (#2896) (skip e2e)
This commit is contained in:
@@ -111,7 +111,7 @@ export class NoteViewController implements ItemViewControllerInterface {
|
|||||||
|
|
||||||
this.needsInit = false
|
this.needsInit = false
|
||||||
|
|
||||||
const addTagHierarchy = this.preferences.getValue(PrefKey.NoteAddToParentFolders, true)
|
const shouldAddTagHierarchy = this.preferences.getValue(PrefKey.NoteAddToParentFolders, true)
|
||||||
|
|
||||||
if (!this.item) {
|
if (!this.item) {
|
||||||
log(LoggingDomain.NoteView, 'Initializing as template note')
|
log(LoggingDomain.NoteView, 'Initializing as template note')
|
||||||
@@ -141,7 +141,7 @@ export class NoteViewController implements ItemViewControllerInterface {
|
|||||||
|
|
||||||
if (this.defaultTagUuid) {
|
if (this.defaultTagUuid) {
|
||||||
const tag = this.items.findItem(this.defaultTagUuid) as SNTag
|
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)
|
this.notifyObservers(this.item, PayloadEmitSource.InitialObserverRegistrationPush)
|
||||||
|
|||||||
@@ -800,6 +800,10 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
triggerSyncOnAction = () => {
|
triggerSyncOnAction = () => {
|
||||||
|
if (!this.controller) {
|
||||||
|
// component might've already unmounted
|
||||||
|
return
|
||||||
|
}
|
||||||
this.controller.syncNow()
|
this.controller.syncNow()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -77,6 +77,7 @@ import { ElementIds } from '@/Constants/ElementIDs'
|
|||||||
import { $isDecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode'
|
import { $isDecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode'
|
||||||
import LinkViewer from './LinkViewer'
|
import LinkViewer from './LinkViewer'
|
||||||
import { OPEN_FILE_UPLOAD_MODAL_COMMAND } from '../EncryptedFilePlugin/FilePlugin'
|
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')
|
const TOGGLE_LINK_AND_EDIT_COMMAND = createCommand<string | null>('TOGGLE_LINK_AND_EDIT_COMMAND')
|
||||||
|
|
||||||
@@ -110,8 +111,8 @@ const blockTypeToIconName = {
|
|||||||
quote: 'quote',
|
quote: 'quote',
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ToolbarButtonProps extends ComponentPropsWithoutRef<'button'> {
|
interface ToolbarButtonProps extends Omit<ComponentPropsWithoutRef<'button'>, 'name'> {
|
||||||
name: string
|
name: NonNullable<ReactNode>
|
||||||
active?: boolean
|
active?: boolean
|
||||||
iconName?: string
|
iconName?: string
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
@@ -222,6 +223,8 @@ const ToolbarPlugin = () => {
|
|||||||
const [isCode, setIsCode] = useState(false)
|
const [isCode, setIsCode] = useState(false)
|
||||||
const [isHighlight, setIsHighlight] = useState(false)
|
const [isHighlight, setIsHighlight] = useState(false)
|
||||||
|
|
||||||
|
const [hasNonCollapsedSelection, setHasNonCollapsedSelection] = useState(false)
|
||||||
|
|
||||||
const [linkNode, setLinkNode] = useState<LinkNode | null>(null)
|
const [linkNode, setLinkNode] = useState<LinkNode | null>(null)
|
||||||
const [linkTextNode, setLinkTextNode] = useState<TextNode | null>(null)
|
const [linkTextNode, setLinkTextNode] = useState<TextNode | null>(null)
|
||||||
const [isEditingLink, setIsEditingLink] = useState(false)
|
const [isEditingLink, setIsEditingLink] = useState(false)
|
||||||
@@ -312,6 +315,8 @@ const ToolbarPlugin = () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setHasNonCollapsedSelection(!selection.isCollapsed())
|
||||||
|
|
||||||
const anchorNode = selection.anchor.getNode()
|
const anchorNode = selection.anchor.getNode()
|
||||||
const focusNode = selection.focus.getNode()
|
const focusNode = selection.focus.getNode()
|
||||||
const isAnchorSameAsFocus = anchorNode === focusNode
|
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" />
|
<Icon type="chevron-down" size="custom" className="ml-2 h-4 w-4 md:h-3.5 md:w-3.5" />
|
||||||
</ToolbarButton>
|
</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>
|
</Toolbar>
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { useLocalPreference } from '@/Hooks/usePreference'
|
|||||||
import BlockPickerMenuPlugin from './Plugins/BlockPickerPlugin/BlockPickerPlugin'
|
import BlockPickerMenuPlugin from './Plugins/BlockPickerPlugin/BlockPickerPlugin'
|
||||||
import { EditorEventSource } from '@/Types/EditorEventSource'
|
import { EditorEventSource } from '@/Types/EditorEventSource'
|
||||||
import { ElementIds } from '@/Constants/ElementIDs'
|
import { ElementIds } from '@/Constants/ElementIDs'
|
||||||
|
import { NoteFromSelectionPlugin } from './Plugins/NoteFromSelectionPlugin'
|
||||||
|
|
||||||
export const SuperNotePreviewCharLimit = 160
|
export const SuperNotePreviewCharLimit = 160
|
||||||
|
|
||||||
@@ -171,6 +172,7 @@ export const SuperEditor: FunctionComponent<Props> = ({
|
|||||||
(itemUuid: string) => {
|
(itemUuid: string) => {
|
||||||
const item = application.items.findItem(itemUuid)
|
const item = application.items.findItem(itemUuid)
|
||||||
if (item) {
|
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)
|
linkingController.unlinkItemFromSelectedItem(item).catch(console.error)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -272,6 +274,7 @@ export const SuperEditor: FunctionComponent<Props> = ({
|
|||||||
{readonly === undefined && <ReadonlyPlugin note={note.current} />}
|
{readonly === undefined && <ReadonlyPlugin note={note.current} />}
|
||||||
<AutoFocusPlugin isEnabled={controller.isTemplateNote} />
|
<AutoFocusPlugin isEnabled={controller.isTemplateNote} />
|
||||||
<BlockPickerMenuPlugin />
|
<BlockPickerMenuPlugin />
|
||||||
|
<NoteFromSelectionPlugin currentNote={note.current} />
|
||||||
</BlocksEditor>
|
</BlocksEditor>
|
||||||
</BlocksEditorComposer>
|
</BlocksEditorComposer>
|
||||||
</FilesControllerProvider>
|
</FilesControllerProvider>
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { createHeadlessEditor } from '@lexical/headless'
|
import { createHeadlessEditor } from '@lexical/headless'
|
||||||
import { FileItem, PrefKey, PrefValue, SuperConverterServiceInterface } from '@standardnotes/snjs'
|
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 BlocksEditorTheme from '../Lexical/Theme/Theme'
|
||||||
import { BlockEditorNodes, SuperExportNodes } from '../Lexical/Nodes/AllNodes'
|
import { BlockEditorNodes, SuperExportNodes } from '../Lexical/Nodes/AllNodes'
|
||||||
import { MarkdownTransformers } from '../MarkdownTransformers'
|
import { MarkdownTransformers } from '../MarkdownTransformers'
|
||||||
@@ -12,6 +20,7 @@ import { $convertToMarkdownString } from '../Lexical/Utils/MarkdownExport'
|
|||||||
import { parseFileName } from '@standardnotes/utils'
|
import { parseFileName } from '@standardnotes/utils'
|
||||||
import { $dfs } from '@lexical/utils'
|
import { $dfs } from '@lexical/utils'
|
||||||
import { $isFileNode } from '../Plugins/EncryptedFilePlugin/Nodes/FileUtils'
|
import { $isFileNode } from '../Plugins/EncryptedFilePlugin/Nodes/FileUtils'
|
||||||
|
import { $generateNodesFromSerializedNodes, $insertGeneratedNodes } from '@lexical/clipboard'
|
||||||
|
|
||||||
export class HeadlessSuperConverter implements SuperConverterServiceInterface {
|
export class HeadlessSuperConverter implements SuperConverterServiceInterface {
|
||||||
private importEditor: LexicalEditor
|
private importEditor: LexicalEditor
|
||||||
@@ -295,4 +304,28 @@ export class HeadlessSuperConverter implements SuperConverterServiceInterface {
|
|||||||
|
|
||||||
return ids
|
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())
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ import {
|
|||||||
AlertService,
|
AlertService,
|
||||||
ProtectionsClientInterface,
|
ProtectionsClientInterface,
|
||||||
LocalPrefKey,
|
LocalPrefKey,
|
||||||
|
NoteContent,
|
||||||
|
noteTypeForEditorIdentifier,
|
||||||
|
ContentReference,
|
||||||
} from '@standardnotes/snjs'
|
} from '@standardnotes/snjs'
|
||||||
import { makeObservable, observable, action, computed, runInAction } from 'mobx'
|
import { makeObservable, observable, action, computed, runInAction } from 'mobx'
|
||||||
import { AbstractViewController } from '../Abstract/AbstractViewController'
|
import { AbstractViewController } from '../Abstract/AbstractViewController'
|
||||||
@@ -408,4 +411,27 @@ export class NotesController
|
|||||||
private getSelectedNotesList(): SNNote[] {
|
private getSelectedNotesList(): SNNote[] {
|
||||||
return Object.values(this.selectedNotes)
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user