import { describe, it, expect } from "vitest"; import { Editor } from "@tiptap/core"; import { Document } from "@tiptap/extension-document"; import { Paragraph } from "@tiptap/extension-paragraph"; import { Text } from "@tiptap/extension-text"; import { Node as PMNode, Fragment, Slice } from "@tiptap/pm/model"; import { FootnoteReference } from "./footnote-reference"; import { FootnotesList } from "./footnotes-list"; import { FootnoteDefinition } from "./footnote-definition"; import { footnotePastePlugin } from "./footnote-sync"; import { FOOTNOTE_REFERENCE_NAME, FOOTNOTE_DEFINITION_NAME, FOOTNOTES_LIST_NAME, } from "./footnote-util"; // transformPasted reuse semantics (#166): a pasted reference to an id that // already exists must KEEP the id (reuse → resolves to the existing footnote); // only a pasted DEFINITION that collides is re-id'd (it would otherwise clobber // the existing definition's text), and its paired references follow it. const extensions = [ Document, Paragraph, Text, FootnoteReference, FootnotesList, FootnoteDefinition, ]; /** An editor whose doc already contains footnote "a" (ref + definition). */ function makeEditorWithFootnoteA() { return new Editor({ extensions, content: { type: "doc", content: [ { type: "paragraph", content: [ { type: "text", text: "x" }, { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "a" } }, ], }, { type: FOOTNOTES_LIST_NAME, content: [ { type: FOOTNOTE_DEFINITION_NAME, attrs: { id: "a" }, content: [ { type: "paragraph", content: [{ type: "text", text: "note A" }] }, ], }, ], }, ], }, }); } /** Run footnotePastePlugin's transformPasted against the editor's current doc. */ function paste(editor: Editor, slice: Slice): Slice { const plugin = footnotePastePlugin(); return plugin.props!.transformPasted!(slice, editor.view); } /** Collect the ids of footnote refs/defs in a slice, in order (single DFS). */ function sliceFootnoteIds(slice: Slice): Array<{ kind: string; id: string }> { const out: Array<{ kind: string; id: string }> = []; const walk = (frag: Fragment) => { frag.forEach((node: PMNode) => { if (node.type.name === FOOTNOTE_REFERENCE_NAME) out.push({ kind: "ref", id: node.attrs.id }); if (node.type.name === FOOTNOTE_DEFINITION_NAME) out.push({ kind: "def", id: node.attrs.id }); walk(node.content); }); }; walk(slice.content); return out; } describe("footnotePastePlugin — reuse-aware id remap", () => { it("keeps a pasted lone reference to an existing id (reuse, no remap)", () => { const editor = makeEditorWithFootnoteA(); const { schema } = editor; // Paste: a paragraph containing only a reference to the existing id "a". const slice = new Slice( Fragment.from( schema.nodes.paragraph.create(null, [ schema.text("see "), schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "a" }), ]), ), 0, 0, ); const out = paste(editor, slice); // The reference keeps id "a" so it reuses the existing footnote. expect(sliceFootnoteIds(out)).toEqual([{ kind: "ref", id: "a" }]); editor.destroy(); }); it("re-ids a pasted DEFINITION (and its paired reference) that collides", () => { const editor = makeEditorWithFootnoteA(); const { schema } = editor; // Paste: a reference AND a definition both carrying the existing id "a". The // definition would clobber the existing one, so both are remapped together. const slice = new Slice( Fragment.fromArray([ schema.nodes.paragraph.create(null, [ schema.text("dup "), schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "a" }), ]), schema.nodes[FOOTNOTES_LIST_NAME].create(null, [ schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "a" }, [ schema.nodes.paragraph.create(null, [schema.text("pasted note")]), ]), ]), ]), 0, 0, ); const out = paste(editor, slice); const ids = sliceFootnoteIds(out); // Both the pasted ref and def were remapped to the SAME fresh id (paired), // and it is the deterministic derived id (not "a"). const remappedIds = new Set(ids.map((x) => x.id)); expect(remappedIds.size).toBe(1); expect(remappedIds.has("a")).toBe(false); expect([...remappedIds][0]).toBe("a__2"); editor.destroy(); }); it("re-ids TWO colliding pasted definitions to DISTINCT ids (reservation works)", () => { // Existing doc has footnotes "a" and "b". Paste a slice that defines BOTH — // each must get its own fresh id; the reservation (existing.add(newId)) keeps // the second from deriving onto the first's new id. const editor = new Editor({ extensions, content: { type: "doc", content: [ { type: "paragraph", content: [ { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "a" } }, { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "b" } }, ], }, { type: FOOTNOTES_LIST_NAME, content: [ { type: FOOTNOTE_DEFINITION_NAME, attrs: { id: "a" }, content: [{ type: "paragraph", content: [{ type: "text", text: "A" }] }], }, { type: FOOTNOTE_DEFINITION_NAME, attrs: { id: "b" }, content: [{ type: "paragraph", content: [{ type: "text", text: "B" }] }], }, ], }, ], }, }); const { schema } = editor; const slice = new Slice( Fragment.fromArray([ schema.nodes.paragraph.create(null, [ schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "a" }), schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "b" }), ]), schema.nodes[FOOTNOTES_LIST_NAME].create(null, [ schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "a" }, [ schema.nodes.paragraph.create(null, [schema.text("pasted A")]), ]), schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "b" }, [ schema.nodes.paragraph.create(null, [schema.text("pasted B")]), ]), ]), ]), 0, 0, ); const out = paste(editor, slice); const ids = sliceFootnoteIds(out); const distinct = new Set(ids.map((x) => x.id)); // Two ids, both remapped off the originals, and distinct from each other. expect(distinct.size).toBe(2); expect(distinct.has("a")).toBe(false); expect(distinct.has("b")).toBe(false); expect([...distinct].sort()).toEqual(["a__2", "b__2"]); editor.destroy(); }); it("leaves the slice untouched when no pasted definition collides", () => { const editor = makeEditorWithFootnoteA(); const { schema } = editor; // A pasted reference+definition for a BRAND-NEW id "b" — no collision. const slice = new Slice( Fragment.fromArray([ schema.nodes.paragraph.create(null, [ schema.text("new "), schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "b" }), ]), schema.nodes[FOOTNOTES_LIST_NAME].create(null, [ schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "b" }, [ schema.nodes.paragraph.create(null, [schema.text("note B")]), ]), ]), ]), 0, 0, ); const out = paste(editor, slice); expect(sliceFootnoteIds(out)).toEqual([ { kind: "ref", id: "b" }, { kind: "def", id: "b" }, ]); editor.destroy(); }); });