- footnote-sync: remove the now-dead `refReids` (CollisionPlan field, local, return, the 6a consumer loop) — references are never re-id'd under reuse, so it was dead structure on the hot reconciliation path. Rewrite the stale comments (plugin header, step 0, refOccurrences field) that still described the old "duplicates re-id'd so both survive" model to the reuse model. - Shared footnote lexer: new packages/mcp/src/lib/footnote-lex.ts (lexFootnoteLines + forEachFootnoteReference). extractFootnotes (collaboration) and analyzeFootnotes now consume the SAME fence-aware lexer, so "the analyzer sees exactly what the importer keeps/strips" is structural, not comment-kept. Removed the duplicated DEF_RE/fence machine from both consumers. - Tests: new mock test for the footnoteWarnings plumbing on createPage (problems -> field present; clean -> omitted); new paste-reuse case for TWO colliding pasted definitions (reservation -> distinct ids). Updated the derive-id golden test header (no MCP copy / parity test anymore). - CHANGELOG: [Unreleased] entries for footnote reuse (Changed, supersedes 0.93.0) and footnoteWarnings (Added). editor-ext 129, MCP 301, server roundtrip 2; client+server tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
227 lines
7.7 KiB
TypeScript
227 lines
7.7 KiB
TypeScript
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();
|
|
});
|
|
});
|