diff --git a/packages/zettel-lexical/src/lexical.test.ts b/packages/zettel-lexical/src/lexical.test.ts index 9e84022..8146aa2 100644 --- a/packages/zettel-lexical/src/lexical.test.ts +++ b/packages/zettel-lexical/src/lexical.test.ts @@ -93,10 +93,7 @@ describe("Zettel Lexical state", () => { const document = fixture(); loadDocument(editor, document); editor.update(() => { - const root = editor.getEditorState()._nodeMap.get("root") as any; - const block = root.getFirstChild(); - const span = block.getFirstChild(); - span.setTextContent("Edited "); + $getRoot().getAllTextNodes()[0]!.setTextContent("Edited "); }, { discrete: true }); expect((exportDocument(editor).blocks[0] as any).children[0].text).toBe("Edited "); }); diff --git a/packages/zettel-lexical/src/markdown-shortcuts.test.ts b/packages/zettel-lexical/src/markdown-shortcuts.test.ts index c22fcac..1e90e08 100644 --- a/packages/zettel-lexical/src/markdown-shortcuts.test.ts +++ b/packages/zettel-lexical/src/markdown-shortcuts.test.ts @@ -155,6 +155,28 @@ it('Mod+K asks the application for a link and applies it', async () => { expect(spans(first(editor))).toEqual([['read the ', []], ['docs', ['link:https://docs.test']]]); }); +it('does not apply a delayed link result to the current selection after its original target is removed', async () => { + let resolveHref!: (href: string | null | undefined) => void; + const editor = setup('', { onLinkShortcut: () => new Promise(resolve => { resolveHref = resolve; }) }); + loadDocument(editor, { _type: 'zettel_doc', blocks: [ + { _type: 'zettel_block', _key: 'original', style: 'normal', markDefs: [], children: [{ _type: 'zettel_span', _key: 'original-text', text: 'original', marks: [] }] }, + { _type: 'zettel_block', _key: 'replacement', style: 'normal', markDefs: [], children: [{ _type: 'zettel_span', _key: 'replacement-text', text: 'replacement', marks: [] }] }, + ] } as any); + editor.update(() => { $getRoot().getAllTextNodes()[0]!.select(0, 8); }, { discrete: true }); + const event = new KeyboardEvent('keydown', { key: 'k', cancelable: true, ...(/Mac/.test(navigator.platform) ? { metaKey: true } : { ctrlKey: true }) }); + editor.update(() => { editor.dispatchCommand(KEY_DOWN_COMMAND, event); }, { discrete: true }); + expect(event.defaultPrevented).toBe(true); + + loadDocument(editor, { _type: 'zettel_doc', blocks: [ + { _type: 'zettel_block', _key: 'new', style: 'normal', markDefs: [], children: [{ _type: 'zettel_span', _key: 'new-text', text: 'new selection', marks: [] }] }, + ] } as any); + editor.update(() => { $getRoot().getAllTextNodes()[0]!.select(0, 3); }, { discrete: true }); + resolveHref('https://stale.test'); + await flush(); + + expect(spans(first(editor))).toEqual([['new selection', []]]); +}); + it('is off unless the plugin is registered with markdownShortcuts', async () => { const editor = setup('', {}); await type(editor, '**bold** - x https://example.com '); diff --git a/packages/zettel-lexical/src/markdown-shortcuts.ts b/packages/zettel-lexical/src/markdown-shortcuts.ts index b5ec352..5ada2b3 100644 --- a/packages/zettel-lexical/src/markdown-shortcuts.ts +++ b/packages/zettel-lexical/src/markdown-shortcuts.ts @@ -90,36 +90,59 @@ const TRAILING_URL = /(^|\s)(https?:\/\/[^\s<>"]+)$/i; /** The caret moved one character right in the node that changed: a character was typed. */ function typedCharacter(editorState: EditorState, prevEditorState: EditorState, dirtyLeaves: Set): { key: string; offset: number } | null { - const selection = editorState._selection; - const previous = prevEditorState._selection; - if (!$isRangeSelection(selection) || !$isRangeSelection(previous) || !selection.isCollapsed() || !previous.isCollapsed()) return null; - if (selection.anchor.type !== "text" || !dirtyLeaves.has(selection.anchor.key)) return null; - const offset = selection.anchor.offset; - const sameNode = previous.anchor.key === selection.anchor.key && previous.anchor.offset + 1 === offset; + const current = editorState.read(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection) || !selection.isCollapsed() || selection.anchor.type !== "text") return null; + const key = selection.anchor.key; + if (!dirtyLeaves.has(key)) return null; + const node = $getNodeByKey(key); + if (!(node instanceof ZettelSpanNode)) return null; + return { key, offset: selection.anchor.offset, length: node.getTextContentSize() }; + }); + if (!current) return null; + const previous = prevEditorState.read(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection) || !selection.isCollapsed()) return null; + const node = $getNodeByKey(current.key); + return { + key: selection.anchor.key, + offset: selection.anchor.offset, + length: node instanceof ZettelSpanNode ? node.getTextContentSize() : 0, + }; + }); + if (!previous) return null; + const offset = current.offset; + const sameNode = previous.key === current.key && previous.offset + 1 === offset; if (!sameNode && offset !== 1) return null; - const node = editorState._nodeMap.get(selection.anchor.key); - if (!(node instanceof ZettelSpanNode)) return null; // Only a one-character insertion, not a paste or a replacement. - const before = prevEditorState._nodeMap.get(selection.anchor.key); - const length = editorState.read(() => node.getTextContentSize()); - const previousLength = before instanceof ZettelSpanNode ? prevEditorState.read(() => before.getTextContentSize()) : 0; - if (length !== previousLength + 1) return null; - return { key: selection.anchor.key, offset }; + if (current.length !== previous.length + 1) return null; + return { key: current.key, offset }; } /** Return created a new text block and put the caret at its start. */ function splitBlock(editorState: EditorState, prevEditorState: EditorState): string | null { - const selection = editorState._selection; - if (!$isRangeSelection(selection) || !selection.isCollapsed() || selection.anchor.offset !== 0) return null; - return editorState.read(() => { + const current = editorState.read(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection) || !selection.isCollapsed() || selection.anchor.offset !== 0) return null; const block = nearestTextBlock(selection.anchor.getNode()); - if (!block || prevEditorState._nodeMap.has(block.getKey())) return null; + if (!block) return null; let previous: LexicalNode | null = block.getPreviousSibling(); const item = block.getParent(); const previousItem = item instanceof ZettelListItemNode ? item.getPreviousSibling() : null; if (!previous && previousItem instanceof ZettelListItemNode) previous = previousItem.getLastChild(); - return previous instanceof ZettelTextBlockNode && prevEditorState._nodeMap.has(previous.getKey()) ? previous.getKey() : null; + return { + blockKey: block.getKey(), + previousBlockKey: previous instanceof ZettelTextBlockNode ? previous.getKey() : null, + }; }); + if (!current?.previousBlockKey) return null; + const before = prevEditorState.read(() => ({ + block: $getNodeByKey(current.blockKey), + previousBlock: $getNodeByKey(current.previousBlockKey!), + })); + return !before.block && before.previousBlock instanceof ZettelTextBlockNode + ? current.previousBlockKey + : null; } function nearestTextBlock(node: LexicalNode): ZettelTextBlockNode | null { diff --git a/packages/zettel-lexical/src/nodes/index.ts b/packages/zettel-lexical/src/nodes/index.ts index 59652e5..f352721 100644 --- a/packages/zettel-lexical/src/nodes/index.ts +++ b/packages/zettel-lexical/src/nodes/index.ts @@ -162,6 +162,8 @@ export class ZettelSpanNode extends TextNode { private zettelMarks: string[]; private source?: Span; private sourceText?: string; + // This Lexical-state cache lets links survive generic node moves. It is not + // part of the Zettel AST; the authoritative link definition stays in markDefs. private linkHref?: string; constructor(data: Partial & { text?: string } = {}, key?: NodeKey) { @@ -182,12 +184,14 @@ export class ZettelSpanNode extends TextNode { return result; } static importJSON(node: SerializedZettelNode): ZettelSpanNode { - return new ZettelSpanNode({ + const result = new ZettelSpanNode({ _type: "zettel_span", _key: typeof node._key === "string" ? node._key : undefined, text: typeof node.text === "string" ? node.text : "", marks: Array.isArray(node.marks) ? node.marks.filter((mark): mark is string => typeof mark === "string") : [], }); + result.linkHref = typeof node.linkHref === "string" ? node.linkHref : undefined; + return result; } override splitText(...offsets: number[]): TextNode[] { const marks = this.toZettel().marks; @@ -220,7 +224,10 @@ export class ZettelSpanNode extends TextNode { if (this.source && this.sourceText === this.getTextContent() && marks.join("\u0000") === this.source.marks.join("\u0000")) return this.source; return { _type: "zettel_span", _key: this._key, text: this.getTextContent(), marks }; } - override exportJSON(): any { return jsonFor(this, this.toZettel() as unknown as Record); } + override exportJSON(): any { + const linkHref = this.resolvedLinkHref(); + return jsonFor(this, { ...this.toZettel(), ...(linkHref === undefined ? {} : { linkHref }) }); + } override createDOM(_config: EditorConfig): HTMLElement { const marks = this.toZettel().marks; const decorator = decoratorTag(marks); @@ -330,7 +337,7 @@ export class ZettelImageNode extends ZettelElementNode { title?: string; marks: string[]; private linkHref?: string; - constructor(data: Partial = {}, key?: NodeKey) { super(key); this._key = data._key ?? generateKey(); this.src = data.src ?? ""; this.alt = data.alt ?? ""; this.title = data.title; this.marks = [...(data.marks ?? [])]; } + constructor(data: Partial & { linkHref?: string } = {}, key?: NodeKey) { super(key); this._key = data._key ?? generateKey(); this.src = data.src ?? ""; this.alt = data.alt ?? ""; this.title = data.title; this.marks = [...(data.marks ?? [])]; this.linkHref = data.linkHref; } override updateDOM(previous: this): boolean { return previous.src !== this.src || previous.alt !== this.alt || previous.title !== this.title || previous.linkHref !== this.linkHref; } static getType(): string { return "zettel_image"; } static clone(node: ZettelImageNode): ZettelImageNode { const result = new ZettelImageNode(node.toZettel(), node.__key); result.linkHref = node.linkHref; return result; } @@ -339,7 +346,7 @@ export class ZettelImageNode extends ZettelElementNode { setLinkHref(href: string | undefined): this { const writable = this.getWritable(); writable.linkHref = href; return writable; } getLinkHref(): string | undefined { return this.getLatest().linkHref; } toZettel(): Image { const image: Image = { _type: "zettel_image", _key: this._key, src: this.src, alt: this.alt, marks: [...this.marks] }; if (this.title !== undefined) image.title = this.title; return image; } - override exportJSON(): any { return jsonFor(this, this.toZettel() as unknown as Record); } + override exportJSON(): any { return jsonFor(this, { ...this.toZettel(), ...(this.linkHref === undefined ? {} : { linkHref: this.linkHref }) }); } override createDOM(_config: EditorConfig): HTMLElement { const wrapper = element(this.linkHref ? "a" : "span", "zettel_image", this._key); if (this.linkHref) wrapper.setAttribute("href", safeLinkUrl(this.linkHref)); const image = document.createElement("img"); image.src = safeImageUrl(this.src); image.alt = this.alt; if (this.title !== undefined) image.title = this.title; wrapper.append(image); return wrapper; } override exportDOM(_editor: LexicalEditor): DOMExportOutput { return { element: this.createDOM({} as EditorConfig) }; } } diff --git a/packages/zettel-lexical/src/plugin.ts b/packages/zettel-lexical/src/plugin.ts index cf28f0d..ba9b473 100644 --- a/packages/zettel-lexical/src/plugin.ts +++ b/packages/zettel-lexical/src/plugin.ts @@ -1,5 +1,6 @@ import { $getSelection, + $getNodeByKey, createCommand, $isRangeSelection, $isTextNode, @@ -23,7 +24,6 @@ import { REMOVE_TEXT_COMMAND, SELECT_ALL_COMMAND, SELECTION_CHANGE_COMMAND, - $getEditor, $getRoot, $isDecoratorNode, $isElementNode, @@ -208,7 +208,8 @@ export function registerZettelLexicalPlugin(editor: LexicalEditor, options: Zett const apply = (result: string | null | undefined) => { if (result === undefined) return; editor.update(() => { - if (saved.getNodes().every((node) => node.isAttached())) $setSelection(saved.clone()); + if (!$selectionPointsAttached(saved)) return; + $setSelection(saved.clone()); $setZettelLink(result); }); }; @@ -479,7 +480,7 @@ function $resolveLinkMarks(block: ZettelTextBlockNode | ZettelTableCellNode): vo const unresolved = marks.filter((mark) => !DECORATOR_MARKS.has(mark) && !markDefs.some((definition) => definition._key === mark)); if (!unresolved.length) continue; const next = marks.filter((mark) => !unresolved.includes(mark)); - const href = (child instanceof ZettelSpanNode || child instanceof ZettelImageNode ? child.getLinkHref() : undefined) ?? $previousLinkHref(child, unresolved); + const href = child instanceof ZettelSpanNode || child instanceof ZettelImageNode ? child.getLinkHref() : undefined; if (href && !next.some((mark) => markDefs.some((definition) => definition._key === mark))) { let definition = markDefs.find((candidate) => candidate.href === href); if (!definition) { @@ -696,17 +697,14 @@ function currentLinkHref(selection: RangeSelection): string | undefined { return undefined; } -/** - * The href a node's link mark resolved to before this update, in the block - * that held it then. Nodes restored from serialized Lexical state do not - * carry their href, only the key of a definition in their old block. - */ -function $previousLinkHref(node: LexicalNode, marks: string[]): string | undefined { - const previous = $getEditor().getEditorState()._nodeMap; - const parentKey = previous.get(node.getKey())?.__parent; - const parent = parentKey ? previous.get(parentKey) : undefined; - if (!(parent instanceof ZettelTextBlockNode || parent instanceof ZettelTableCellNode)) return undefined; - return marks.map((mark) => parent.markDefs.find((definition) => definition._key === mark)?.href).find(Boolean); +function $selectionPointsAttached(selection: RangeSelection): boolean { + const pointIsAttached = (point: PointType): boolean => { + const node = $getNodeByKey(point.key); + if (!node) return false; + if (point.type === "text") return $isTextNode(node) && point.offset <= node.getTextContentSize(); + return $isElementNode(node) && point.offset <= node.getChildrenSize(); + }; + return pointIsAttached(selection.anchor) && pointIsAttached(selection.focus); } function onChecklistChange(event: Event): void {