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:
@@ -44,6 +44,9 @@ import {
|
||||
htmlToMarkdown,
|
||||
TransclusionSource,
|
||||
TransclusionReference,
|
||||
FootnoteReference,
|
||||
FootnotesList,
|
||||
FootnoteDefinition,
|
||||
} from '@docmost/editor-ext';
|
||||
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
||||
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
||||
@@ -109,6 +112,9 @@ export const tiptapExtensions = [
|
||||
Status,
|
||||
TransclusionSource,
|
||||
TransclusionReference,
|
||||
FootnoteReference,
|
||||
FootnotesList,
|
||||
FootnoteDefinition,
|
||||
] as any;
|
||||
|
||||
export function jsonToHtml(tiptapJson: any) {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { htmlToJson, jsonToHtml } from './collaboration.util';
|
||||
|
||||
const findFirst = (json: any, type: string): any | undefined => {
|
||||
if (!json || typeof json !== 'object') return undefined;
|
||||
if (json.type === type) return json;
|
||||
if (Array.isArray(json.content)) {
|
||||
for (const child of json.content) {
|
||||
const found = findFirst(child, type);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Guards the fragile parse-priority approach that lets a `footnoteReference`
|
||||
* NODE win over the `Superscript` MARK for `<sup>` elements. In the server
|
||||
* `tiptapExtensions` list, Superscript is registered BEFORE the footnote nodes,
|
||||
* so without the priority guard a `<sup data-footnote-ref>` would be parsed as
|
||||
* an (empty) superscript mark and the footnote reference would be lost.
|
||||
*/
|
||||
describe('footnote reference vs superscript mark (server schema round-trip)', () => {
|
||||
const HTML =
|
||||
'<p>Water' +
|
||||
'<sup data-footnote-ref data-id="fn1"></sup>' +
|
||||
' here.</p>' +
|
||||
'<section data-footnotes>' +
|
||||
'<div data-footnote-def data-id="fn1"><p>First note.</p></div>' +
|
||||
'</section>';
|
||||
|
||||
it('parses <sup data-footnote-ref> into a footnoteReference NODE (not a superscript mark)', () => {
|
||||
const json = htmlToJson(HTML);
|
||||
|
||||
const ref = findFirst(json, 'footnoteReference');
|
||||
expect(ref).toBeDefined();
|
||||
expect(ref.attrs.id).toBe('fn1');
|
||||
|
||||
// It must NOT have been swallowed as a superscript mark on text.
|
||||
const superscriptText = JSON.stringify(json).includes('"superscript"');
|
||||
expect(superscriptText).toBe(false);
|
||||
|
||||
// The matching definition survives too.
|
||||
const def = findFirst(json, 'footnoteDefinition');
|
||||
expect(def).toBeDefined();
|
||||
expect(def.attrs.id).toBe('fn1');
|
||||
});
|
||||
|
||||
it('round-trips an empty footnoteReference back to <sup data-footnote-ref>', () => {
|
||||
const json = htmlToJson(HTML);
|
||||
const html = jsonToHtml(json);
|
||||
|
||||
expect(html).toContain('data-footnote-ref');
|
||||
expect(html).toContain('data-id="fn1"');
|
||||
|
||||
// And a second parse still yields the node (stable round-trip).
|
||||
const json2 = htmlToJson(html);
|
||||
const ref2 = findFirst(json2, 'footnoteReference');
|
||||
expect(ref2).toBeDefined();
|
||||
expect(ref2.attrs.id).toBe('fn1');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user