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>
62 lines
2.2 KiB
TypeScript
62 lines
2.2 KiB
TypeScript
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');
|
|
});
|
|
});
|