From a68fd69586801698003dd59acdf6cfd04dfd7b46 Mon Sep 17 00:00:00 2001 From: Christopher Loverich <1010084+cloverich@users.noreply.github.com> Date: Sat, 24 Aug 2024 21:56:59 -0700 Subject: [PATCH] wip: note linking --- package.json | 1 + .../transformers/mdast-to-slate.ts | 34 +- .../transformers/slate-to-mdast.ts | 22 ++ src/views/edit/PlateContainer.tsx | 57 ++- .../edit/editor/components/InlineCombobox.tsx | 368 ++++++++++++++++++ .../note-linking/NoteLinkDropdown.tsx | 147 +++++++ .../features/note-linking/NoteLinkElement.tsx | 38 ++ .../createNoteLinkDropdownPlugin.tsx | 93 +++++ .../createNoteLinkElementPlugin.tsx | 9 + .../editor/features/note-linking/index.ts | 6 + .../plugins/createInlineEscapePlugin.ts | 68 ++++ .../createResetNodePlugin.ts | 101 +++++ .../plugins/createResetNodePlugin/index.ts | 1 + .../onKeyDownResetNode.ts | 52 +++ .../plugins/createResetNodePlugin/types.ts | 26 ++ src/views/edit/index.tsx | 15 +- src/views/edit/loading.tsx | 4 +- src/views/edit/useEditableDocument.ts | 46 ++- yarn.lock | 39 ++ 19 files changed, 1076 insertions(+), 51 deletions(-) create mode 100644 src/views/edit/editor/components/InlineCombobox.tsx create mode 100644 src/views/edit/editor/features/note-linking/NoteLinkDropdown.tsx create mode 100644 src/views/edit/editor/features/note-linking/NoteLinkElement.tsx create mode 100644 src/views/edit/editor/features/note-linking/createNoteLinkDropdownPlugin.tsx create mode 100644 src/views/edit/editor/features/note-linking/createNoteLinkElementPlugin.tsx create mode 100644 src/views/edit/editor/features/note-linking/index.ts create mode 100644 src/views/edit/editor/plugins/createInlineEscapePlugin.ts create mode 100644 src/views/edit/editor/plugins/createResetNodePlugin/createResetNodePlugin.ts create mode 100644 src/views/edit/editor/plugins/createResetNodePlugin/index.ts create mode 100644 src/views/edit/editor/plugins/createResetNodePlugin/onKeyDownResetNode.ts create mode 100644 src/views/edit/editor/plugins/createResetNodePlugin/types.ts diff --git a/package.json b/package.json index 0333fab..70f07fb 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "test": "mocha 'src/**/*.test.bundle.js'" }, "dependencies": { + "@ariakit/react": "^0.4.8", "ajv": "^8.6.2", "ajv-formats": "^2.1.0", "better-sqlite3": "^9.2.2", diff --git a/src/markdown/remark-slate-transformer/transformers/mdast-to-slate.ts b/src/markdown/remark-slate-transformer/transformers/mdast-to-slate.ts index 307aaab..01c702a 100644 --- a/src/markdown/remark-slate-transformer/transformers/mdast-to-slate.ts +++ b/src/markdown/remark-slate-transformer/transformers/mdast-to-slate.ts @@ -364,14 +364,36 @@ function createBreak(node: mdast.Break) { export type Link = ReturnType; +const noteLinkRegex = /^\.\/(?:(.+)\/)?([a-zA-Z0-9-]+)\.md$/; + +function checkNoteLink(url: string) { + if (!url) return { noteId: null, journalName: null }; + + const match = url.match(noteLinkRegex); + const journalName = match ? match[1] : null; + const noteId = match ? match[2] : null; + return { noteId, journalName }; +} + function createLink(node: mdast.Link, deco: Decoration) { const { type, children, url, title } = node; - return { - type: "a", // NOTE: Default plate link component uses "a" - children: convertNodes(children, deco), - url, - title, - }; + const res = checkNoteLink(url); + + if (res.noteId) { + return { + type: "noteLinkElement", + children: convertNodes(children, deco), + title: "", + ...res, + }; + } else { + return { + type: "a", // NOTE: Default plate link component uses "a" + children: convertNodes(children, deco), + url, + title, + }; + } } export type Image = { diff --git a/src/markdown/remark-slate-transformer/transformers/slate-to-mdast.ts b/src/markdown/remark-slate-transformer/transformers/slate-to-mdast.ts index dbb6f07..b675c4b 100644 --- a/src/markdown/remark-slate-transformer/transformers/slate-to-mdast.ts +++ b/src/markdown/remark-slate-transformer/transformers/slate-to-mdast.ts @@ -238,6 +238,8 @@ function createMdastNode( return createImage(node); case "linkReference": return createLinkReference(node); + case "noteLinkElement": + return createNoteLinkReference(node); case "imageReference": return createImageReference(node); case "footnote": @@ -468,6 +470,26 @@ function createLink(node: SlateNodes.Link): mdast.Link { } as any; } +interface SlateNoteLink { + noteId: string; // ex: "019141a8-dc3e-7d8e-b74c-c05d654f3b5e" + type: "noteLinkElement"; + title: string; + journalName: string; + children: any; +} + +function createNoteLinkReference(node: SlateNoteLink): mdast.Link { + const { type, title, noteId, journalName, children } = node; + const url = `./${journalName}/${noteId}.md`; + + return { + type: "link", // note: changes from type to type: "link" so it can accept "a", see the switch statement + url, // note: converted, "as any" added because mdast.Link thinks its url and not link? + title, + children: convertNodes(children) as any as mdast.Link["children"], + } as any; +} + function createImage(node: SlateNodes.Image | SlateNodes.Video): mdast.Image { const { type, url, title, alt } = node; return { diff --git a/src/views/edit/PlateContainer.tsx b/src/views/edit/PlateContainer.tsx index 6d01f04..ef45eb0 100644 --- a/src/views/edit/PlateContainer.tsx +++ b/src/views/edit/PlateContainer.tsx @@ -1,7 +1,7 @@ -import React from "react"; +import React, { useContext } from "react"; import { withProps } from "@udecode/cn"; import { observer } from "mobx-react-lite"; -import { Node as SNode } from "slate"; +import { Editor, Node as SNode } from "slate"; import { Plate, PlateContent, @@ -11,6 +11,7 @@ import { createHistoryPlugin, isBlockAboveEmpty, isSelectionAtBlockStart, + isSelectionAtBlockEnd, PlateLeaf, PlateElement, } from "@udecode/plate-common"; @@ -73,7 +74,6 @@ import { createLinkPlugin, createSoftBreakPlugin, createExitBreakPlugin, - createResetNodePlugin, createListPlugin, } from "@udecode/plate"; @@ -96,6 +96,17 @@ import { import { autoformatRules } from "./editor/plugins/autoformat/autoformatRules"; import { createCodeBlockNormalizationPlugin } from "./editor/plugins/createCodeBlockNormalizationPlugin"; +import { createResetNodePlugin } from "./editor/plugins/createResetNodePlugin"; +import { createInlineEscapePlugin } from "./editor/plugins/createInlineEscapePlugin"; +import { + ELEMENT_MENTION, + ELEMENT_NOTE_LINK, + createNoteLinkDropdownPlugin, + NoteLinkDropdownElement, + NoteLinkElement, +} from "./editor/features/note-linking"; + +import { createNoteLinkElementPlugin } from "./editor/features/note-linking/createNoteLinkElementPlugin"; import { ELEMENT_VIDEO, @@ -121,8 +132,16 @@ export interface Props { setSelectedEditorMode: (s: EditorMode) => any; } +import { JournalsStoreContext } from "../../hooks/useJournalsLoader"; +import useClient from "../../hooks/useClient"; +import { SearchStore } from "../documents/SearchStore"; + export default observer( ({ children, saving, value, setValue }: React.PropsWithChildren) => { + const jstore = useContext(JournalsStoreContext); + const client = useClient(); + const store = new SearchStore(client, jstore!, () => {}, []); + const plugins = createPlugins( [ createCodeBlockNormalizationPlugin(), @@ -166,6 +185,8 @@ export default observer( // dropped video files and this won't be called. createVideoPlugin(), createFilesPlugin(), + createNoteLinkDropdownPlugin({ options: { store } } as any), + createNoteLinkElementPlugin(), // Backspacing into an element selects the block before deleting it. createSelectOnBackspacePlugin({ @@ -256,6 +277,11 @@ export default observer( }, }), + // When editing "inline" elements, allow space at the end to "escape" from the element. + // ex: link or note link editing. + // See plugin comments for links and details; this is + createInlineEscapePlugin(), + // Set text block indentation for differentiating structural // elements or emphasizing certain content sections. // https://platejs.org/docs/indent @@ -286,7 +312,8 @@ export default observer( // being confused about how to exit an e.g. code block to add more content. createTrailingBlockPlugin({ type: ELEMENT_PARAGRAPH }), - // e.g. # -> h1, ``` -> code block, etc + // convert markdown to wysiwyg sa you type: + // # -> h1, ``` -> code block, etc createAutoformatPlugin({ options: { rules: autoformatRules, @@ -302,7 +329,6 @@ export default observer( [ELEMENT_CODE_LINE]: CodeLineElement, [ELEMENT_CODE_SYNTAX]: CodeSyntaxLeaf, [MARK_CODE]: CodeLeaf, - // [ELEMENT_HR]: HrElement, [ELEMENT_H1]: withProps(HeadingElement, { variant: "h1" }), [ELEMENT_H2]: withProps(HeadingElement, { variant: "h2" }), [ELEMENT_H3]: withProps(HeadingElement, { variant: "h3" }), @@ -311,31 +337,22 @@ export default observer( [ELEMENT_H6]: withProps(HeadingElement, { variant: "h6" }), [ELEMENT_IMAGE]: ImageElement, [ELEMENT_LINK]: LinkElement, - // todo: need more plugins to make these truly usable. - // [ELEMENT_MEDIA_EMBED]: MediaEmbedElement, - // [ELEMENT_MENTION]: MentionElement, - // [ELEMENT_MENTION_INPUT]: MentionInputElement, + + // NoteRefDropdown provides the dropdown when typing `@`; NoteLinkElement + // is the actual element that gets inserted when you select a note. + [ELEMENT_MENTION]: NoteLinkDropdownElement, + [ELEMENT_NOTE_LINK]: NoteLinkElement, + [ELEMENT_UL]: withProps(ListElement, { variant: "ul" }), [ELEMENT_LI]: withProps(PlateElement, { as: "li" }), [ELEMENT_OL]: withProps(ListElement, { variant: "ol" }), [ELEMENT_PARAGRAPH]: ParagraphElement, - // [ELEMENT_TABLE]: TableElement, - // [ELEMENT_TD]: TableCellElement, - // [ELEMENT_TH]: TableCellHeaderElement, - // [ELEMENT_TODO_LI]: TodoListElement, - // [ELEMENT_TR]: TableRowElement, - // [ELEMENT_EXCALIDRAW]: ExcalidrawElement, [MARK_BOLD]: withProps(PlateLeaf, { as: "strong" }), - - // Unsure about these: - // [MARK_HIGHLIGHT]: HighlightLeaf, [MARK_ITALIC]: withProps(PlateLeaf, { as: "em" }), - // [MARK_KBD]: KbdLeaf, [MARK_STRIKETHROUGH]: withProps(PlateLeaf, { as: "s" }), [MARK_SUBSCRIPT]: withProps(PlateLeaf, { as: "sub" }), [MARK_SUPERSCRIPT]: withProps(PlateLeaf, { as: "sup" }), [MARK_UNDERLINE]: withProps(PlateLeaf, { as: "u" }), - // [MARK_COMMENT]: CommentLeaf, }, }, ); diff --git a/src/views/edit/editor/components/InlineCombobox.tsx b/src/views/edit/editor/components/InlineCombobox.tsx new file mode 100644 index 0000000..7b73178 --- /dev/null +++ b/src/views/edit/editor/components/InlineCombobox.tsx @@ -0,0 +1,368 @@ +import React, { + type HTMLAttributes, + type ReactNode, + type RefObject, + createContext, + forwardRef, + startTransition, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; + +import type { PointRef } from "slate"; + +import { + Combobox, + ComboboxItem, + type ComboboxItemProps, + ComboboxPopover, + ComboboxProvider, + Portal, + useComboboxContext, + useComboboxStore, +} from "@ariakit/react"; +import { cn } from "@udecode/cn"; +import { + type UseComboboxInputResult, + filterWords, + useComboboxInput, + useHTMLInputCursorState, +} from "@udecode/plate-combobox"; +import { + type TElement, + createPointRef, + findNodePath, + getPointBefore, + insertText, + moveSelection, + useComposedRef, + useEditorRef, +} from "@udecode/plate-common"; +import { cva } from "class-variance-authority"; + +type FilterFn = ( + item: { keywords?: string[]; value: string }, + search: string, +) => boolean; + +interface InlineComboboxContextValue { + filter: FilterFn | false; + inputProps: UseComboboxInputResult["props"]; + inputRef: RefObject; + removeInput: UseComboboxInputResult["removeInput"]; + setHasEmpty: (hasEmpty: boolean) => void; + showTrigger: boolean; + trigger: string; +} + +const InlineComboboxContext = createContext( + null as any, +); + +export const defaultFilter: FilterFn = ({ keywords = [], value }, search) => + [value, ...keywords].some((keyword) => filterWords(keyword, search)); + +interface InlineComboboxProps { + children: ReactNode; + element: TElement; + trigger: string; + filter?: FilterFn | false; + hideWhenNoValue?: boolean; + setValue?: (value: string) => void; + showTrigger?: boolean; + value?: string; +} + +const InlineCombobox = ({ + children, + element, + filter = defaultFilter, + hideWhenNoValue = false, + setValue: setValueProp, + showTrigger = true, + trigger, + value: valueProp, +}: InlineComboboxProps) => { + const editor = useEditorRef(); + const inputRef = React.useRef(null); + const cursorState = useHTMLInputCursorState(inputRef); + + const [valueState, setValueState] = useState(""); + const hasValueProp = valueProp !== undefined; + const value = hasValueProp ? valueProp : valueState; + + const setValue = useCallback( + (newValue: string) => { + setValueProp?.(newValue); + + if (!hasValueProp) { + setValueState(newValue); + } + }, + [setValueProp, hasValueProp], + ); + + /** + * Track the point just before the input element so we know where to + * insertText if the combobox closes due to a selection change. + */ + const [insertPoint, setInsertPoint] = useState(null); + + useEffect(() => { + const path = findNodePath(editor, element); + + if (!path) return; + + const point = getPointBefore(editor, path); + + if (!point) return; + + const pointRef = createPointRef(editor, point); + setInsertPoint(pointRef); + + return () => { + pointRef.unref(); + }; + }, [editor, element]); + + const { props: inputProps, removeInput } = useComboboxInput({ + cancelInputOnBlur: false, + cursorState, + onCancelInput: (cause) => { + if (cause !== "backspace") { + insertText(editor, trigger + value, { + at: insertPoint?.current ?? undefined, + }); + } + if (cause === "arrowLeft" || cause === "arrowRight") { + moveSelection(editor, { + distance: 1, + reverse: cause === "arrowLeft", + }); + } + }, + ref: inputRef, + }); + + const [hasEmpty, setHasEmpty] = useState(false); + + const contextValue: InlineComboboxContextValue = useMemo( + () => ({ + filter, + inputProps, + inputRef, + removeInput, + setHasEmpty, + showTrigger, + trigger, + }), + [ + trigger, + showTrigger, + filter, + inputRef, + inputProps, + removeInput, + setHasEmpty, + ], + ); + + const store = useComboboxStore({ + // open: , + setValue: (newValue) => startTransition(() => setValue(newValue)), + }); + + const items = store.useState("items"); + + useEffect; + + /** + * If there is no active ID and the list of items changes, select the first + * item. + */ + useEffect(() => { + if (!store.getState().activeId) { + store.setActiveId(store.first()); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [items, store]); + + return ( + + 0 || hasEmpty) && + (!hideWhenNoValue || value.length > 0) + } + store={store} + > + + {children} + + + + ); +}; + +const InlineComboboxInput = forwardRef< + HTMLInputElement, + HTMLAttributes +>(({ className, ...props }, propRef) => { + const { + inputProps, + inputRef: contextRef, + showTrigger, + trigger, + } = useContext(InlineComboboxContext); + + const store = useComboboxContext()!; + const value = store.useState("value"); + + const ref = useComposedRef(propRef, contextRef); + + /** + * To create an auto-resizing input, we render a visually hidden span + * containing the input value and position the input element on top of it. + * This works well for all cases except when input exceeds the width of the + * container. + */ + + return ( + <> + {showTrigger && trigger} + + + + + + + + ); +}); + +InlineComboboxInput.displayName = "InlineComboboxInput"; + +const InlineComboboxContent: typeof ComboboxPopover = ({ + className, + ...props +}) => { + // Portal prevents CSS from leaking into popover + return ( + + + + ); +}; + +const comboboxItemVariants = cva( + "relative flex h-9 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none", + { + defaultVariants: { + interactive: true, + }, + variants: { + interactive: { + false: "", + true: "cursor-pointer transition-colors hover:bg-accent hover:text-accent-foreground data-[active-item=true]:bg-accent data-[active-item=true]:text-accent-foreground", + }, + }, + }, +); + +export type InlineComboboxItemProps = { + keywords?: string[]; +} & ComboboxItemProps & + Required>; + +const InlineComboboxItem = ({ + className, + keywords, + onClick, + ...props +}: InlineComboboxItemProps) => { + const { value } = props; + + const { filter, removeInput } = useContext(InlineComboboxContext); + + const store = useComboboxContext()!; + + // Optimization: Do not subscribe to value if filter is false + const search = filter && store.useState("value"); + + const visible = useMemo( + () => !filter || filter({ keywords, value }, search as string), + [filter, value, keywords, search], + ); + + if (!visible) return null; + + return ( + { + removeInput(true); + onClick?.(event); + }} + {...props} + /> + ); +}; + +const InlineComboboxEmpty = ({ + children, + className, +}: HTMLAttributes) => { + const { setHasEmpty } = useContext(InlineComboboxContext); + const store = useComboboxContext()!; + const items = store.useState("items"); + + useEffect(() => { + setHasEmpty(true); + + return () => { + setHasEmpty(false); + }; + }, [setHasEmpty]); + + if (items.length > 0) return null; + + return ( +
+ {children} +
+ ); +}; + +export { + InlineCombobox, + InlineComboboxContent, + InlineComboboxEmpty, + InlineComboboxInput, + InlineComboboxItem, +}; diff --git a/src/views/edit/editor/features/note-linking/NoteLinkDropdown.tsx b/src/views/edit/editor/features/note-linking/NoteLinkDropdown.tsx new file mode 100644 index 0000000..dda7b36 --- /dev/null +++ b/src/views/edit/editor/features/note-linking/NoteLinkDropdown.tsx @@ -0,0 +1,147 @@ +import React, { useState } from "react"; + +import { cn, withRef } from "@udecode/cn"; +import { PlateElement, PlateEditor } from "@udecode/plate-common"; + +import { + InlineCombobox, + InlineComboboxContent, + InlineComboboxEmpty, + InlineComboboxInput, + InlineComboboxItem, +} from "../../components/InlineCombobox"; +import { NOTE_LINK } from "./createNoteLinkDropdownPlugin"; +import { ELEMENT_NOTE_LINK, INoteLinkElement } from "./NoteLinkElement"; + +import { + TMentionItemBase, + MentionOnSelectItem, + MentionPlugin, + TMentionElement, +} from "@udecode/plate"; + +import { + getPlugin, + insertNodes, + moveSelection, + getBlockAbove, + isEndPoint, + insertText, +} from "@udecode/plate-common"; +import { SearchItem, SearchStore } from "../../../../documents/SearchStore"; + +type Option = Pick; + +/** + * When selecting an item, insert a (todo) NoteReference link + */ +const onSelect = (editor: PlateEditor, item: Option) => { + // Get the parent createNoteRefPlugin + const { + options: { insertSpaceAfterMention }, + type, + } = getPlugin(editor as any, NOTE_LINK); + + // Inlined (and de-paramaterized) from createMentionNode + const props = { + title: item.title, + noteId: item.noteId, + journalName: item.journalName, + }; + + // Commented out and replaced with insertText to get it working; + // next: Replace with a version that inserts a note link -- which ideally + // is just a react-router link to the note! + insertNodes(editor, { + children: [{ text: item.title }], + type: ELEMENT_NOTE_LINK, + ...props, + } as INoteLinkElement); + // insertText(editor, props.value as string); + + // move the selection after the element + moveSelection(editor, { unit: "offset" }); + + const pathAbove = getBlockAbove(editor)?.[1]; + + const isBlockEnd = + editor.selection && + pathAbove && + isEndPoint(editor, editor.selection.anchor, pathAbove); + + if (isBlockEnd && insertSpaceAfterMention) { + insertText(editor, " "); + } +}; + +// Convert document response objects into options for the dropdown +function toOptions( + docs: SearchItem[], +): Pick[] { + return docs.slice(0, 10).map((d) => ({ + noteId: d.id, + title: d.title || d.id, + journalName: d.journalId, + })); +} + +/** + * A dropdown element, triggered by the "@" character, that searches and selects a note reference. + * On select, insert a link ("note link") to the note. + */ +export const NoteLinkDropdownElement = withRef( + ({ className, ...props }, ref) => { + const { children, editor, element, store } = props as typeof props & { + store: SearchStore; + }; + + const [search, setSearch] = useState(""); + + React.useEffect(() => { + // todo: leading debounce; build into the store itself + store.setSearch([`title:${search}`]); + }, [search]); + + return ( + + + + + + + + No results found + + {toOptions(store.docs).map((item) => ( + onSelect(editor, item)} + value={item.title} + > + {item.title} + + ))} + + + + {children} + + ); + }, +); diff --git a/src/views/edit/editor/features/note-linking/NoteLinkElement.tsx b/src/views/edit/editor/features/note-linking/NoteLinkElement.tsx new file mode 100644 index 0000000..cc28530 --- /dev/null +++ b/src/views/edit/editor/features/note-linking/NoteLinkElement.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import { cn, withRef } from "@udecode/cn"; +import { useElement, PlateElement, TElement } from "@udecode/plate-common"; +import { useNavigate } from "react-router-dom"; + +export const ELEMENT_NOTE_LINK = "noteLinkElement"; + +export interface INoteLinkElement extends TElement { + title: string; + noteId: string; + journalName: string; +} + +export const NoteLinkElement = withRef( + ({ className, children, ...props }, ref) => { + const navigate = useNavigate(); + const element = useElement(); + + function edit(e: React.MouseEvent) { + e.preventDefault(); + navigate(`/documents/edit/${element.noteId}`); + } + + return ( + + {children} + + ); + }, +); diff --git a/src/views/edit/editor/features/note-linking/createNoteLinkDropdownPlugin.tsx b/src/views/edit/editor/features/note-linking/createNoteLinkDropdownPlugin.tsx new file mode 100644 index 0000000..cb0a745 --- /dev/null +++ b/src/views/edit/editor/features/note-linking/createNoteLinkDropdownPlugin.tsx @@ -0,0 +1,93 @@ +import { createPluginFactory } from "@udecode/plate-core"; +import { + type PlateEditor, + type TElement, + getEditorString, + getPointBefore, + getRange, + getPluginOptions, +} from "@udecode/plate-common"; + +export const NOTE_LINK = "noteLinkingPlugin"; + +interface NoteLinkPlugin { + triggerPreviousCharPattern?: RegExp; + triggerQuery?: (editor: PlateEditor) => boolean; +} + +const TRIGGER = "@"; +const TRIGGER_PRIOR_PATTERN = /^\s?$/; // whitespace or beginning of line + +// Previously, trigger was an option, and could be a regex, string, or array of strings. +const matchesTrigger = (text: string) => { + return text === TRIGGER; +}; + +/** + * Insert a note linking dropdown when "@" is typed. + * + * This plugin handles detecting the "@" character, inserting the dropdown, + * and injecting the search store into the dropdown. + */ +export const createNoteLinkDropdownPlugin = createPluginFactory( + { + key: NOTE_LINK, + + isVoid: true, + isInline: true, + // If false, when typing `@` neither the `@` symbol nor the dropdown will appear. + isElement: true, + + // Can't use props directly, because we won't have a store reference + // props: ... + // Instead we can do this: + then: (editor, { options }: any) => { + // Docs suggest this, but it was not working :shrug: + // const options = getPluginOptions(editor, NOTE_LINK); + + // Store is injected to this plugin in ; + // Forward to dropdown by injecting into options here. + // Maybe using context would be better? + return { + props: { + store: options.store, + }, + }; + }, + + // Adapated from MentionInput plugin and TriggerCombobox + // https://github.com/udecode/plate/blob/main/packages/combobox/src/withTriggerCombobox.ts + withOverrides: (editor) => { + const { insertText } = editor; + + editor.insertText = (text) => { + // Previously also included: (triggerQuery && !triggerQuery(editor as PlateEditor)) + if (!editor.selection || !matchesTrigger(text)) { + return insertText(text); + } + + // Only insert the dropdown if the previous character is a space or beginning of line + const previousChar = getEditorString( + editor, + getRange( + editor, + editor.selection, + getPointBefore(editor, editor.selection), + ), + ); + + if (TRIGGER_PRIOR_PATTERN?.test(previousChar)) { + return editor.insertNode({ + type: NOTE_LINK, + trigger: text, + children: [{ text: "" }], + }); + } + + return insertText(text); + }; + + return editor; + }, + }, +); diff --git a/src/views/edit/editor/features/note-linking/createNoteLinkElementPlugin.tsx b/src/views/edit/editor/features/note-linking/createNoteLinkElementPlugin.tsx new file mode 100644 index 0000000..f384f24 --- /dev/null +++ b/src/views/edit/editor/features/note-linking/createNoteLinkElementPlugin.tsx @@ -0,0 +1,9 @@ +import { createPluginFactory } from "@udecode/plate-common"; + +import { ELEMENT_NOTE_LINK } from "./NoteLinkElement"; + +export const createNoteLinkElementPlugin = createPluginFactory({ + isElement: true, + isInline: true, + key: ELEMENT_NOTE_LINK, +}); diff --git a/src/views/edit/editor/features/note-linking/index.ts b/src/views/edit/editor/features/note-linking/index.ts new file mode 100644 index 0000000..929846a --- /dev/null +++ b/src/views/edit/editor/features/note-linking/index.ts @@ -0,0 +1,6 @@ +export { NoteLinkDropdownElement } from "./NoteLinkDropdown"; +export { + createNoteLinkDropdownPlugin, + NOTE_LINK as ELEMENT_MENTION, +} from "./createNoteLinkDropdownPlugin"; +export { NoteLinkElement, ELEMENT_NOTE_LINK } from "./NoteLinkElement"; diff --git a/src/views/edit/editor/plugins/createInlineEscapePlugin.ts b/src/views/edit/editor/plugins/createInlineEscapePlugin.ts new file mode 100644 index 0000000..74688b3 --- /dev/null +++ b/src/views/edit/editor/plugins/createInlineEscapePlugin.ts @@ -0,0 +1,68 @@ +import { createPluginFactory } from "@udecode/plate-core"; + +// These are all effectively overrides of Slate Editor.blah methods, sometimes re-named, +// usually handling Plate types and doing inline null checking +import { + isInline, + getAboveNode, + isEndPoint, + getPointAfter, + setSelection, +} from "@udecode/plate-common"; +import { Editor, Range, Transforms } from "slate"; + +const KEY_INLINE_ESCAPE = "inline-escape"; + +/** + * Escape an inline element (ex: Link editing) when the cursor is at the end. + * + * This is required to escape from link or note link editing when at the end of the "inline" element. + * It does mean if you want to edit the end of text, or add spaces, that you have to move the cursor + * inisde the element, edit as needed, then delete the end, but this is common in many editors. + * + * It may make sense to do something more sophisticated eventually, see the below for context: + * + * Built in to Slate: https://github.com/ianstormtaylor/slate/pull/3260 + * Removed from Slate (reverts ^): https://github.com/ianstormtaylor/slate/pull/4578 + * Advanced example: https://github.com/ianstormtaylor/slate/pull/4615 + * (Active issue) strongly related to ^: https://github.com/ianstormtaylor/slate/issues/4704 + */ +export const createInlineEscapePlugin = createPluginFactory({ + key: KEY_INLINE_ESCAPE, + isVoid: true, + withOverrides: (editor) => { + const { insertText } = editor; + + editor.insertText = (text) => { + const { selection } = editor; + + if (selection) { + const { anchor } = selection; + + // Check if the selection is collapsed and at the end of an inline + if (Range.isCollapsed(selection)) { + const inline = getAboveNode(editor, { + match: (n) => isInline(editor, n), + }); + + if (inline) { + const [, inlinePath] = inline; + + // If the cursor is at the end of the inline, move it outside + if (isEndPoint(editor, anchor, inlinePath)) { + const point = getPointAfter(editor, inlinePath); + if (point) { + setSelection(editor, { anchor: point, focus: point }); + } + } + } + } + } + + // Call the original insertText method + insertText(text); + }; + + return editor; + }, +}); diff --git a/src/views/edit/editor/plugins/createResetNodePlugin/createResetNodePlugin.ts b/src/views/edit/editor/plugins/createResetNodePlugin/createResetNodePlugin.ts new file mode 100644 index 0000000..9b83ca4 --- /dev/null +++ b/src/views/edit/editor/plugins/createResetNodePlugin/createResetNodePlugin.ts @@ -0,0 +1,101 @@ +import { + type TElement, + createPluginFactory, + getEndPoint, + getNode, + getNodeProps, + getStartPoint, + isCollapsed, + resetEditorChildren, + setNodes, + unsetNodes, + withoutNormalizing, +} from "@udecode/plate-common"; +import { Point } from "slate"; + +import type { ResetNodePlugin } from "./types"; + +import { onKeyDownResetNode } from "./onKeyDownResetNode"; + +export const KEY_RESET_NODE = "resetNode"; + +/** + * Support resetting block types. + * + * Copied from Plate (MIT) to support some logging / debugging; does not work as expected on + * block quotes; so leaving here so we can modify and click-to-source code at will; assuming + * we will modify this at some point. If not, we can remove it. + */ +export const createResetNodePlugin = createPluginFactory({ + handlers: { + onKeyDown: onKeyDownResetNode, + }, + key: KEY_RESET_NODE, + options: { + rules: [], + }, + withOverrides: (editor, { options }) => { + const { deleteBackward, deleteFragment } = editor; + + if (!options.disableEditorReset) { + const deleteFragmentPlugin = () => { + const { selection } = editor; + + if (!selection) return; + + const start = getStartPoint(editor, []); + const end = getEndPoint(editor, []); + + if ( + (Point.equals(selection.anchor, start) && + Point.equals(selection.focus, end)) || + (Point.equals(selection.focus, start) && + Point.equals(selection.anchor, end)) + ) { + resetEditorChildren(editor, { + insertOptions: { select: true }, + }); + + return true; + } + }; + + editor.deleteFragment = (direction) => { + if (deleteFragmentPlugin()) return; + + deleteFragment(direction); + }; + } + if (!options.disableFirstBlockReset) { + editor.deleteBackward = (unit) => { + const { selection } = editor; + + if (selection && isCollapsed(selection)) { + const start = getStartPoint(editor, []); + + if (Point.equals(selection.anchor, start)) { + const node = getNode(editor, [0])!; + + const { children, ...props } = editor.blockFactory({}, [0]); + + // replace props + withoutNormalizing(editor, () => { + // unsetNodes(editor, Object.keys(getNodeProps(node)), { at: [0] }); + // missing id will cause block selection not working and other issues + const { id, ...nodeProps } = getNodeProps(node); + + unsetNodes(editor, Object.keys(nodeProps), { at: [0] }); + setNodes(editor, props, { at: [0] }); + }); + + return; + } + } + + deleteBackward(unit); + }; + } + + return editor; + }, +}); diff --git a/src/views/edit/editor/plugins/createResetNodePlugin/index.ts b/src/views/edit/editor/plugins/createResetNodePlugin/index.ts new file mode 100644 index 0000000..f62e9ef --- /dev/null +++ b/src/views/edit/editor/plugins/createResetNodePlugin/index.ts @@ -0,0 +1 @@ +export * from "./createResetNodePlugin"; diff --git a/src/views/edit/editor/plugins/createResetNodePlugin/onKeyDownResetNode.ts b/src/views/edit/editor/plugins/createResetNodePlugin/onKeyDownResetNode.ts new file mode 100644 index 0000000..382c8cb --- /dev/null +++ b/src/views/edit/editor/plugins/createResetNodePlugin/onKeyDownResetNode.ts @@ -0,0 +1,52 @@ +import { + type KeyboardHandlerReturnType, + type PlateEditor, + type Value, + type WithPlatePlugin, + isCollapsed, + isHotkey, + setElements, + someNode, +} from "@udecode/plate-common"; + +import type { ResetNodePlugin } from "./types"; + +export const SIMULATE_BACKSPACE: any = { + key: "", + which: 8, +}; + +export const onKeyDownResetNode = + = PlateEditor>( + editor: E, + { options: { rules } }: WithPlatePlugin, + ): KeyboardHandlerReturnType => + (event) => { + if (event.defaultPrevented) return; + + let reset; + + if (!editor.selection) return; + if (isCollapsed(editor.selection)) { + rules!.forEach(({ defaultType, hotkey, onReset, predicate, types }) => { + if ( + hotkey && + isHotkey(hotkey, event as any) && + predicate(editor as any) && + someNode(editor, { match: { type: types } }) + ) { + event.preventDefault?.(); + + setElements(editor, { type: defaultType }); + + if (onReset) { + onReset(editor as any); + } + + reset = true; + } + }); + } + + return reset; + }; diff --git a/src/views/edit/editor/plugins/createResetNodePlugin/types.ts b/src/views/edit/editor/plugins/createResetNodePlugin/types.ts new file mode 100644 index 0000000..e8a1df2 --- /dev/null +++ b/src/views/edit/editor/plugins/createResetNodePlugin/types.ts @@ -0,0 +1,26 @@ +import type { HotkeyPlugin, PlateEditor, Value } from "@udecode/plate-common"; + +export interface ResetNodePluginRule< + V extends Value = Value, + E extends PlateEditor = PlateEditor, +> extends HotkeyPlugin { + /** Additional condition to the rule. */ + predicate: (editor: E) => boolean; + + /** Node types where the rule applies. */ + types: string[]; + + defaultType?: string; + + /** Callback called when resetting. */ + onReset?: (editor: E) => void; +} + +export interface ResetNodePlugin< + V extends Value = Value, + E extends PlateEditor = PlateEditor, +> { + disableEditorReset?: boolean; + disableFirstBlockReset?: boolean; + rules?: ResetNodePluginRule[]; +} diff --git a/src/views/edit/index.tsx b/src/views/edit/index.tsx index 640c856..18a046a 100644 --- a/src/views/edit/index.tsx +++ b/src/views/edit/index.tsx @@ -19,13 +19,17 @@ import { Separator } from "./editor/components/Separator"; import * as Base from "../layout"; // Loads document, with loading and error placeholders -function DocumentLoadingContainer() { +const DocumentLoadingContainer = observer(() => { const journalsStore = useContext(JournalsStoreContext)!; const { document: documentId } = useParams(); // todo: handle missing or invalid documentId; loadingError may be fine for this, but // haven't done any UX / design thinking around it. - const { document, loadingError } = useEditableDocument(documentId!); + const { + document, + error: loadingError, + loading, + } = useEditableDocument(documentId!); // Filter journals to non-archived ones, but must also add // the current document's journal if its archived @@ -45,17 +49,16 @@ function DocumentLoadingContainer() { setJournals(journals); }, [document, loadingError]); - // todo: I don't hit this error when going back and forth to a deleted document, why doesn't this happen? if (loadingError) { return ; } - if (!document || !journals) { + if (!document || !journals || loading) { return ; } return ; -} +}); interface DocumentEditProps { document: EditableDocument; @@ -168,4 +171,4 @@ const DocumentEditView = observer((props: DocumentEditProps) => { ); }); -export default observer(DocumentLoadingContainer); +export default DocumentLoadingContainer; diff --git a/src/views/edit/loading.tsx b/src/views/edit/loading.tsx index 3130ac4..c937191 100644 --- a/src/views/edit/loading.tsx +++ b/src/views/edit/loading.tsx @@ -32,9 +32,7 @@ export const EditLoadingComponent = observer((props: LoadingComponentProps) => { - -

Coming soon...

-
+
diff --git a/src/views/edit/useEditableDocument.ts b/src/views/edit/useEditableDocument.ts index e09cc8d..2607a47 100644 --- a/src/views/edit/useEditableDocument.ts +++ b/src/views/edit/useEditableDocument.ts @@ -1,51 +1,65 @@ import React from "react"; import useClient from "../../hooks/useClient"; import { EditableDocument } from "./EditableDocument"; +import { observable } from "mobx"; + +interface LoodingState { + document: EditableDocument | null; + loading: boolean; + error: Error | null; +} /** * Load a new or existing document into a view model */ export function useEditableDocument(documentId: string) { - const [document, setDocument] = React.useState(null); - const [loadingError, setLoadingError] = React.useState(null); const client = useClient(); + const [state, _] = React.useState(() => { + return observable({ + document: null, + loading: true, + error: null, + }); + }); // (Re)load document based on documentId React.useEffect(() => { + state.loading = true; + let isEffectMounted = true; async function load() { - setLoadingError(null); + state.error = null; if (!documentId) { // Fail safe; this shuldn't happen. If scenarios come up where it could; maybe toast and navigate // to documents list instead? - setLoadingError( - new Error( - "Called useEditableDocument without a documentId, unable to load document", - ), + state.error = new Error( + "Called useEditableDocument without a documentId, unable to load document", ); + return; } try { const doc = await client.documents.findById({ id: documentId }); if (!isEffectMounted) return; - setDocument(new EditableDocument(client, doc)); + state.document = new EditableDocument(client, doc); + + // Loading is instantaneous and the loading = true | false transition which the edit/view depends on never + // happens; insert an artifical delay (hack). + setTimeout(() => { + state.loading = false; + }); } catch (err) { - if (!isEffectMounted) return; - setLoadingError(err as Error); + state.error = err as Error; } } load(); return () => { - isEffectMounted = false; - if (document?.teardown) document.teardown(); + if (state.document?.teardown) state.document.teardown(); }; }, [documentId]); - return { - document, - loadingError: loadingError, - }; + return state; } diff --git a/yarn.lock b/yarn.lock index 9c87b6b..15368df 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7,6 +7,27 @@ resolved "https://registry.yarnpkg.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz#7bf68b20c0a350f936915fcae06f58e32007ce30" integrity sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw== +"@ariakit/core@0.4.8": + version "0.4.8" + resolved "https://registry.yarnpkg.com/@ariakit/core/-/core-0.4.8.tgz#83e8576e89baacf769cab506c63a672b3a4581d4" + integrity sha512-HQS+9CI7pMqqVlAt5bPGenT0/e65UxXY+PKtgU7Y+0UToBDBRolO5S9+UUSDm8OmJHSnq24owEGm1Mv28l5XCQ== + +"@ariakit/react-core@0.4.8": + version "0.4.8" + resolved "https://registry.yarnpkg.com/@ariakit/react-core/-/react-core-0.4.8.tgz#448b0567b43a7ebabb892aaf0283ff1d074e8be8" + integrity sha512-TzsddUWQwWYhrEVWHA/Gf7KCGx8rwFohAHfuljjqidKeZi2kUmuRAImCTG9oga34FWHFf4AdXQbBKclMNt0nrQ== + dependencies: + "@ariakit/core" "0.4.8" + "@floating-ui/dom" "^1.0.0" + use-sync-external-store "^1.2.0" + +"@ariakit/react@^0.4.8": + version "0.4.8" + resolved "https://registry.yarnpkg.com/@ariakit/react/-/react-0.4.8.tgz#ede99942d69c7234b9f9f98ba47b252ea256c29a" + integrity sha512-Bb1vOrp0X52hxi1wE9TEHjjZ/Y08tVq2ZH+RFDwRQB3g04uVwrrhnTccHepC6rsObrDpAOV3/YlJCi4k/lSUaQ== + dependencies: + "@ariakit/react-core" "0.4.8" + "@babel/code-frame@^7.23.5": version "7.23.5" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.23.5.tgz#9009b69a8c602293476ad598ff53e4562e15c244" @@ -470,6 +491,14 @@ dependencies: "@floating-ui/utils" "^0.2.1" +"@floating-ui/dom@^1.0.0": + version "1.6.10" + resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.10.tgz#b74c32f34a50336c86dcf1f1c845cf3a39e26d6f" + integrity sha512-fskgCFv8J8OamCmyun8MfjB1Olfn+uZKjOKZ0vhYF3gRmEUXcGOjxWL8bBr7i4kIuPZ2KD2S3EUIOxnjC8kl2A== + dependencies: + "@floating-ui/core" "^1.6.0" + "@floating-ui/utils" "^0.2.7" + "@floating-ui/dom@^1.2.1": version "1.6.5" resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.5.tgz#323f065c003f1d3ecf0ff16d2c2c4d38979f4cb9" @@ -519,6 +548,11 @@ resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.1.tgz#16308cea045f0fc777b6ff20a9f25474dd8293d2" integrity sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q== +"@floating-ui/utils@^0.2.7": + version "0.2.7" + resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.7.tgz#d0ece53ce99ab5a8e37ebdfe5e32452a2bfc073e" + integrity sha512-X8R8Oj771YRl/w+c1HqAC1szL8zWQRwFvgDwT129k9ACdBoud/+/rX9V0qiMl6LWUdP9voC2nDVZYPMQQsb6eA== + "@gar/promisify@^1.1.3": version "1.1.3" resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6" @@ -6757,6 +6791,11 @@ use-sync-external-store@1.2.0: resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA== +use-sync-external-store@^1.2.0: + version "1.2.2" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz#c3b6390f3a30eba13200d2302dcdf1e7b57b2ef9" + integrity sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw== + util-deprecate@^1.0.1, util-deprecate@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"