Repository navigation
Keep links when Backspace or Delete joins two blocks - #24
Conversation
Zettel text blocks keep their link definitions in markDefs. When Lexical joins two blocks it moves the second block's spans into the first, but the definitions stay behind in the removed block, so the moved spans reference marks that no longer resolve: the links disappear and exportDocument throws "Unresolved mark". A node transform on text blocks and table cells now gives a moved linked node a definition in its new block (reusing one with the same href), taking the href from the node or, for nodes restored from Lexical JSON, from the block that held it before the update. Marks that cannot be resolved are dropped, so no edit can leave a document exportDocument rejects. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 579eff1984
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| 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); | ||
| if (href && !next.some((mark) => markDefs.some((definition) => definition._key === mark))) { |
There was a problem hiding this comment.
Accept empty hrefs when rebuilding link definitions
When a valid link definition has href: ""—for example, Markdown [text]()—the cached href reaches this branch as an empty string, so the truthiness check skips recreating the definition and the transform removes the unresolved mark. Joining the blocks therefore silently converts the link to plain text; test for href !== undefined instead.
Useful? React with 👍 / 👎.
| let definition = markDefs.find((candidate) => candidate.href === href); | ||
| if (!definition) { | ||
| definition = { _type: "zettel_link", _key: generateKey(), href }; |
There was a problem hiding this comment.
Preserve link titles when recreating definitions
When the moved link definition includes the supported optional title, this reconstruction retains only its href, so a block join silently drops title metadata from the exported document. Reusing a destination definition solely by href can likewise replace a source title with a different one; the source definition's title needs to participate in reconstruction and matching.
Useful? React with 👍 / 👎.
Problem
In
@opral/zettel-lexical, Backspace at the start of a block or Delete at the end of one joins the two blocks, and the links of the block that is merged in are lost.exportDocumentthen throwsUnresolved mark '<key>'. Deleting a range from one block into a link in the next one does the same. This reproduces on Lexical 0.30 (main) and 0.51.Zettel text blocks keep their link definitions in the block's
markDefs(zettel_linkdefs keyed by_key), and spans point at them throughmarks. When Lexical joins two blocks it moves the second block's children into the first. The spans keep their mark keys, but the definitions stay behind in the removed block.Fix
A node transform on
ZettelTextBlockNodeandZettelTableCellNode,$resolveLinkMarks, runs before the update commits. For each inline node whose link mark does not resolve in its block:_key._keys are unique across the whole document, so a copied key would be invalid.exportDocumentrejects.This is the helper from #20 (
$resolveLinkMarks, same name, same registration and sameZettelSpanNode.getLinkHref()). On top of that version this one also handles:setEditorState(parseEditorState(toLexicalState(doc)))). Leave and remove lists from the keyboard; never commit an invalid list #20's version drops the link there.Whichever PR merges second only has to keep this version of the function.
Tests
New
link-join.test.ts(jsdom, Lexical headless) has 12 cases. 11 fail onmain; the undo case is a regression guard.Each case checks
assertDocument, a lossless export → load → export round trip, uniquemarkDefskeys, and where relevant the rendered<a href>.The cross-block range delete does not hit the Lexical crash that Upgrade Lexical to 0.51 #23 works around on this path, so it is covered here.
New Chromium playground check: real Backspace and Delete keypresses join two linked paragraphs. It fails on
mainwithUnresolved mark.pnpm run cipasses (lexical: 47 tests). The playground reports 27/27 checks passing.🤖 Generated with Claude Code