feat(editor): footnotes (reference + definitions model)
Adds footnotes: a superscript marker in the text linked to an editable definition in a Footnotes section at the end of the page, with auto-numbering and a read-only hover popover. Chose the reference+definitions model (3 plain nodes) over an inline atom with a sub-editor specifically for collaboration safety. editor-ext (packages/editor-ext/src/lib/footnote/): - footnoteReference (inline atom, id), footnotesList (block, last child), footnoteDefinition (paragraph+, id). renderHTML emits sup[data-footnote-ref] / section[data-footnotes] / div[data-footnote-def]; parse-rule priority makes the empty reference win over the Superscript mark (else it is dropped on the server save). - numbering: a decoration-only plugin (pure function of doc order) -> every client computes identical numbers, no document mutation, Yjs-safe. - sync plugin: single-pass, always SYNC_META-tagged and skipping remote txns (terminates, no loop), idempotent; canonicalizes to one trailing footnotesList (merging duplicates), creates missing definitions, drops orphans, and coexists with TrailingNode. Disabled in read-only. - commands setFootnote (one tx: reference + definition at the matching index + focus) / removeFootnote (cascade, one undo) / scrollTo*. slash /footnote. client: superscript NodeView + floating-ui read-only popover; bottom-list and definition NodeViews; registered in mainExtensions. server: the three nodes registered in tiptapExtensions so collab/save/export keep them. Round-trip regression spec guards the Superscript parse-priority. markdown: turndown/marked round-trip to pandoc/GFM [^id] (+ a code-fence guard so footnote-like lines inside code blocks are not extracted). MCP mirror: schema + markdown-converter + commentsToFootnotes rewritten to real footnote nodes + diff marker counting; NUL sentinels written as \u0000 escapes. v2 follow-ups (per plan): definition reordering on reference move, id-collision regeneration on paste, multiple references to one footnote. Implements docs/footnotes-plan.md (variant B). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
/* Superscript reference marker. The visible number comes from the numbering
|
||||
plugin decoration which sets the --footnote-number CSS variable. */
|
||||
.reference {
|
||||
cursor: pointer;
|
||||
color: var(--mantine-color-blue-6);
|
||||
font-weight: 500;
|
||||
vertical-align: super;
|
||||
font-size: 0.75em;
|
||||
line-height: 0;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reference::after {
|
||||
content: var(--footnote-number, "");
|
||||
}
|
||||
|
||||
.reference:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.reference.selected {
|
||||
background-color: var(--mantine-color-blue-1);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Read-only popover shown on hover/click of a reference. */
|
||||
.popover {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
max-width: 360px;
|
||||
padding: var(--mantine-spacing-sm);
|
||||
background: var(--mantine-color-body);
|
||||
color: var(--mantine-color-default-color);
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
box-shadow: var(--mantine-shadow-md);
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.popoverHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--mantine-spacing-xs);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.popoverNumber {
|
||||
font-weight: 600;
|
||||
color: var(--mantine-color-dimmed);
|
||||
}
|
||||
|
||||
.popoverBody {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Bottom footnotes container. */
|
||||
.list {
|
||||
margin-top: var(--mantine-spacing-lg);
|
||||
padding-top: var(--mantine-spacing-md);
|
||||
border-top: 1px solid var(--mantine-color-default-border);
|
||||
}
|
||||
|
||||
.listHeading {
|
||||
font-weight: 600;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: var(--mantine-color-dimmed);
|
||||
margin-bottom: var(--mantine-spacing-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.definition {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--mantine-spacing-xs);
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.definitionMarker {
|
||||
flex: 0 0 auto;
|
||||
min-width: 1.5em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--mantine-color-dimmed);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.definitionContent {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.backLink {
|
||||
flex: 0 0 auto;
|
||||
cursor: pointer;
|
||||
color: var(--mantine-color-blue-6);
|
||||
user-select: none;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.backLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
Reference in New Issue
Block a user