Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions packages/zettel-lexical/src/lexical.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ");
});
Expand Down
22 changes: 22 additions & 0 deletions packages/zettel-lexical/src/markdown-shortcuts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ');
Expand Down
59 changes: 41 additions & 18 deletions packages/zettel-lexical/src/markdown-shortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>): { 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 {
Expand Down
15 changes: 11 additions & 4 deletions packages/zettel-lexical/src/nodes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Span> & { text?: string } = {}, key?: NodeKey) {
Expand All @@ -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;
Expand Down Expand Up @@ -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<string, unknown>); }
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);
Expand Down Expand Up @@ -330,7 +337,7 @@ export class ZettelImageNode extends ZettelElementNode {
title?: string;
marks: string[];
private linkHref?: string;
constructor(data: Partial<Image> = {}, 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<Image> & { 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; }
Expand All @@ -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<string, unknown>); }
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) }; }
}
Expand Down
26 changes: 12 additions & 14 deletions packages/zettel-lexical/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
$getSelection,
$getNodeByKey,
createCommand,
$isRangeSelection,
$isTextNode,
Expand All @@ -23,7 +24,6 @@ import {
REMOVE_TEXT_COMMAND,
SELECT_ALL_COMMAND,
SELECTION_CHANGE_COMMAND,
$getEditor,
$getRoot,
$isDecoratorNode,
$isElementNode,
Expand Down Expand Up @@ -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);
});
};
Expand Down Expand Up @@ -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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve link targets when the href cache is absent

When a block join moves linked content whose node has no cached linkHref, this now treats the link as unresolvable and removes its mark. This affects ZettelBreakNode and ZettelInlineHtmlNode, which may legally carry link marks but never receive the new cache, as well as spans and images restored from Lexical JSON saved before this commit. Deleting a boundary after loading such content therefore silently strips the link; retain a previous-state lookup fallback or migrate/cache hrefs for every linkable inline node.

Useful? React with 👍 / 👎.

if (href && !next.some((mark) => markDefs.some((definition) => definition._key === mark))) {
let definition = markDefs.find((candidate) => candidate.href === href);
if (!definition) {
Expand Down Expand Up @@ -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 {
Expand Down
Loading