feat(editor): footnotes (reference + definitions, collab-safe) #18
Reference in New Issue
Block a user
Delete Branch "feat/footnotes"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Implements
docs/footnotes-plan.md— Variant B (reference + definitions), chosen over the inline-atom-with-sub-editor model specifically for collaboration safety.What
Footnotes: a superscript marker in the text linked to an editable definition in a "Footnotes" section at the end of the page, with automatic numbering and a read-only hover popover. Survives the Markdown round-trip and the MCP path.
How
editor-ext (
packages/editor-ext/src/lib/footnote/): three plain nodes —footnoteReference(inline atom,id),footnotesList(block, trailing),footnoteDefinition(paragraph+,id).renderHTML/parseHTMLtosup[data-footnote-ref]/section[data-footnotes]/div[data-footnote-def]; a parse-rule priority makes the empty reference win over the Superscript mark.appendTransactionon remote steps).footnotesList(merging duplicates), creates missing definitions, drops orphans, and is TrailingNode-aware. Disabled in read-only.setFootnote(one tx: reference + definition inserted at the matching index + focus),removeFootnote(cascade, single undo),scrollTo*; slash/footnote.client: superscript NodeView + floating-ui read-only popover; bottom-list + definition NodeViews; registered in
mainExtensions. server: the three nodes registered intiptapExtensionsso collab/save/export keep them. markdown: turndown/marked round-trip to pandoc/GFM[^id]. MCP mirror: schema + converter +commentsToFootnotesrewritten to real footnote nodes + diff marker counting.Reasoning / decisions
Review findings & fixes (two rounds)
Static review found 2 blockers: raw NUL bytes in
transforms.tssource (formatters could silently corrupt the sentinel) → rewritten as\u0000escapes (verified 0 raw NULs in src + build); and the "single trailing footnotesList" invariant wasn't enforced (two lists survived, dropping def ids) → added an idempotent merge/canonicalize pass. Also: a Markdown code-fence guard (so[^id]:inside a code block isn't extracted), the sync plugin disabled in read-only, a misleading comment, and the server round-trip regression test.Then browser verification caught a hard freeze on insert — an infinite
appendTransactionping-pong between the sync plugin and theTrailingNodeextension (each kept repositioning the list), enabled by an un-tagged transaction. Fixed: the sync plugin now does one always-tagged single-pass rebuild and treats the list as correctly placed when only empty paragraphs follow it. Added a real-editor regression test (full Tiptap Editor incl. Superscript + TrailingNode, with a 50-round loop guard) that fails on the old code and passes on the new.Verification
pnpm --filter @docmost/editor-ext build+server+@docmost/mcp+client— all clean.commentsToFootnotes→nodes, NUL-sentinel collision); server footnote round-trip-with-Superscript 2 pass./footnoteinserts WITHOUT freezing (responsiveness probe passed); marker + bottom definition appear; hover popover shows the definition; two footnotes number 1/2 in document order; after a reload both markers AND both definitions survive (not stripped on the server save); cascade-deleting a marker removes its definition and renumbers. No real app errors. Screenshots captured.v2 follow-ups (per plan)
Definition reordering when a reference is moved (cut/paste), id-collision regeneration on paste, multiple references to one footnote.
🤖 Generated with Claude Code
The footnote definition number ('1.') sat ~19px from its text because two spacings stacked: the 1.5em (24px) marker min-width box (wider than the ~15px glyph) plus a 10px flex gap. Reduce the flex gap to 0.4em (about one space) and right-align the number within the 1.5em column so the period sits next to the text and multi-digit numbers (10, 11, ...) stay aligned. Reads like '1. text'. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>