fix(footnotes): canonicalize footnotes on server import + markdown paste (#228)
The footnote canonicalizer was wired into the MCP and editor-ext write paths but NOT into the server's user-facing markdown/HTML import paths, so importing or pasting markdown with out-of-order, reused, or orphan footnotes did not canonicalize -- the exact trigger bug #228 fixes was still reproduced on import. markdownToHtml -> htmlToJson builds ProseMirror JSON directly and never runs the editor's footnoteSyncPlugin, and that plugin does not reorder an existing list, so the stored footnotes kept the source's physical definition order, retained orphans, and did not collapse reused references. Wire canonicalizeFootnotes (already exported from @docmost/editor-ext) into every server markdown/HTML -> page-JSON seam, before persisting: - ImportService.importPage (REST single-file .md/.html import) - FileImportTaskService (zip import worker) - PageService.parseProsemirrorContent (API createPage / updatePageContent) Also hook the client markdown paste: handlePaste applies a manual transaction (returns true), bypassing transformPasted/footnoteSyncPlugin, so a pasted out-of-order markdown footnote block would persist out of order. canonicalizePastedFootnotes reorders a self-contained pasted block (one that carries its own footnotesList) to reference order, deduped and orphan-free; it is deliberately scoped to whole-block pastes so a reference-only paste that reuses a footnote already defined in the target doc is left untouched. canonicalizeFootnotes is pure, idempotent and shape-safe (a doc with no footnotes is unchanged), so it is safe on every write path. Residual: when a pasted block merges into a doc that already has footnotes, ordering relative to the pre-existing footnotes is still governed by the live sync plugin (which does not reorder across the boundary). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
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,
|
||||
FootnotesList,
|
||||
FootnoteDefinition,
|
||||
FOOTNOTE_REFERENCE_NAME,
|
||||
FOOTNOTE_DEFINITION_NAME,
|
||||
FOOTNOTES_LIST_NAME,
|
||||
} from "@docmost/editor-ext";
|
||||
import { canonicalizePastedFootnotes } from "./markdown-clipboard";
|
||||
|
||||
/**
|
||||
* A markdown paste builds its ProseMirror fragment via DOM -> parseSlice and is
|
||||
* applied with a manual transaction (handlePaste returns true), so it bypasses
|
||||
* the editor's footnoteSyncPlugin — which never reorders an existing list. These
|
||||
* tests pin canonicalizePastedFootnotes, the focused hook that makes a pasted
|
||||
* out-of-order markdown footnote block come out canonical (issue #228).
|
||||
*/
|
||||
|
||||
const extensions = [
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
FootnoteReference,
|
||||
FootnotesList,
|
||||
FootnoteDefinition,
|
||||
];
|
||||
|
||||
function makeSchema() {
|
||||
const editor = new Editor({ extensions, content: { type: "doc", content: [] } });
|
||||
const { schema } = editor;
|
||||
return { editor, schema };
|
||||
}
|
||||
|
||||
/** List footnote def ids of the (single) footnotesList in a slice, in order. */
|
||||
function listIds(slice: Slice): string[] {
|
||||
const out: string[] = [];
|
||||
slice.content.forEach((node: PMNode) => {
|
||||
if (node.type.name === FOOTNOTES_LIST_NAME) {
|
||||
node.content.forEach((def: PMNode) => {
|
||||
if (def.type.name === FOOTNOTE_DEFINITION_NAME) out.push(def.attrs.id);
|
||||
});
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function hasList(slice: Slice): boolean {
|
||||
let found = false;
|
||||
slice.content.forEach((n: PMNode) => {
|
||||
if (n.type.name === FOOTNOTES_LIST_NAME) found = true;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
describe("canonicalizePastedFootnotes", () => {
|
||||
it("reorders a pasted block to reference order, dedups reuse, drops orphans", () => {
|
||||
const { editor, schema } = makeSchema();
|
||||
// Body references c, a, b (and again a => reuse); definitions a, b, c, z
|
||||
// (z is an orphan) — the exact shape a markdown paste produces.
|
||||
const slice = new Slice(
|
||||
Fragment.fromArray([
|
||||
schema.nodes.paragraph.create(null, [
|
||||
schema.text("body "),
|
||||
schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "c" }),
|
||||
schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "a" }),
|
||||
schema.nodes[FOOTNOTE_REFERENCE_NAME].create({ id: "b" }),
|
||||
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("note A")]),
|
||||
]),
|
||||
schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "b" }, [
|
||||
schema.nodes.paragraph.create(null, [schema.text("note B")]),
|
||||
]),
|
||||
schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "c" }, [
|
||||
schema.nodes.paragraph.create(null, [schema.text("note C")]),
|
||||
]),
|
||||
schema.nodes[FOOTNOTE_DEFINITION_NAME].create({ id: "z" }, [
|
||||
schema.nodes.paragraph.create(null, [schema.text("orphan")]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
0,
|
||||
0,
|
||||
);
|
||||
|
||||
const out = canonicalizePastedFootnotes(slice, schema);
|
||||
// Reference order, orphan z dropped, reused a appears once.
|
||||
expect(listIds(out)).toEqual(["c", "a", "b"]);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("leaves a reference-ONLY paste untouched (no synthesized definitions)", () => {
|
||||
// A paste that reuses an id defined in the TARGET doc must NOT gain a
|
||||
// synthesized empty definition here — it carries no footnotesList of its own.
|
||||
const { editor, schema } = makeSchema();
|
||||
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 = canonicalizePastedFootnotes(slice, schema);
|
||||
expect(hasList(out)).toBe(false);
|
||||
expect(out).toBe(slice); // returned unchanged (same reference)
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("leaves an open (partial) slice untouched even if it carries a list", () => {
|
||||
// An open slice (openStart/openEnd > 0) is a partial selection, not a
|
||||
// standalone block, so it is returned as-is BEFORE any footnote handling.
|
||||
const { editor, schema } = makeSchema();
|
||||
const slice = new Slice(
|
||||
Fragment.fromArray([
|
||||
schema.nodes.paragraph.create(null, [
|
||||
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("A")]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
1,
|
||||
1,
|
||||
);
|
||||
const out = canonicalizePastedFootnotes(slice, schema);
|
||||
expect(out).toBe(slice);
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,13 @@ import { Extension } from "@tiptap/core";
|
||||
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
|
||||
import { DOMParser, DOMSerializer, Fragment, Slice } from "@tiptap/pm/model";
|
||||
import { find } from "linkifyjs";
|
||||
import { markdownToHtml, htmlToMarkdown } from "@docmost/editor-ext";
|
||||
import {
|
||||
markdownToHtml,
|
||||
htmlToMarkdown,
|
||||
canonicalizeFootnotes,
|
||||
FOOTNOTES_LIST_NAME,
|
||||
} from "@docmost/editor-ext";
|
||||
import type { Schema } from "@tiptap/pm/model";
|
||||
|
||||
export const MarkdownClipboard = Extension.create({
|
||||
name: "markdownClipboard",
|
||||
@@ -83,12 +89,25 @@ export const MarkdownClipboard = Extension.create({
|
||||
const body = elementFromString(parsed);
|
||||
normalizeTableColumnWidths(body);
|
||||
|
||||
const contentNodes = DOMParser.fromSchema(
|
||||
const parsedSlice = DOMParser.fromSchema(
|
||||
this.editor.schema,
|
||||
).parseSlice(body, {
|
||||
preserveWhitespace: true,
|
||||
});
|
||||
|
||||
// A markdown paste builds its ProseMirror fragment directly (DOM ->
|
||||
// parseSlice), bypassing the editor's footnoteSyncPlugin, which never
|
||||
// reorders an existing list. So a pasted markdown block whose footnote
|
||||
// definitions are out of order (or contains orphan defs) would be
|
||||
// stored out of order. Canonicalize the self-contained pasted block so
|
||||
// its footnotes come out reference-ordered, deduped and orphan-free
|
||||
// (issue #228). See canonicalizePastedFootnotes for why this is scoped
|
||||
// to whole-block pastes that carry their own footnotesList.
|
||||
const contentNodes = canonicalizePastedFootnotes(
|
||||
parsedSlice,
|
||||
this.editor.schema,
|
||||
);
|
||||
|
||||
tr.replaceRange(from, to, contentNodes);
|
||||
const insertEnd = tr.mapping.map(from, 1);
|
||||
tr.setSelection(TextSelection.near(tr.doc.resolve(Math.max(from, insertEnd - 2)), -1));
|
||||
@@ -133,6 +152,39 @@ export const MarkdownClipboard = Extension.create({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Reorder/dedup the footnotes of a SELF-CONTAINED pasted markdown block to the
|
||||
* canonical invariant (the live footnoteSyncPlugin never reorders an existing
|
||||
* list, so an out-of-order pasted block would otherwise persist out of order).
|
||||
*
|
||||
* Scoped deliberately to whole-block pastes (openStart/openEnd === 0) that carry
|
||||
* their OWN footnotesList: canonicalizeFootnotes would synthesize empty
|
||||
* definitions for any reference lacking a definition, which is correct for a
|
||||
* standalone block but would be wrong for a reference-only paste that REUSES a
|
||||
* footnote already defined in the target document — so those are left untouched
|
||||
* for the paste/sync plugins to merge. Residual: when the pasted block is merged
|
||||
* into a doc that already has footnotes, ordering RELATIVE to the pre-existing
|
||||
* footnotes is still governed by the sync plugin (which does not reorder).
|
||||
*/
|
||||
export function canonicalizePastedFootnotes(slice: Slice, schema: Schema): Slice {
|
||||
if (slice.openStart !== 0 || slice.openEnd !== 0) return slice;
|
||||
|
||||
let hasFootnotesList = false;
|
||||
slice.content.forEach((node) => {
|
||||
if (node.type.name === FOOTNOTES_LIST_NAME) hasFootnotesList = true;
|
||||
});
|
||||
if (!hasFootnotesList) return slice;
|
||||
|
||||
const content = slice.content.toJSON();
|
||||
if (!Array.isArray(content)) return slice;
|
||||
|
||||
const canonical = canonicalizeFootnotes({ type: "doc", content }) as {
|
||||
content?: unknown[];
|
||||
};
|
||||
const fragment = Fragment.fromJSON(schema, canonical.content ?? []);
|
||||
return new Slice(fragment, 0, 0);
|
||||
}
|
||||
|
||||
function elementFromString(value) {
|
||||
// add a wrapper to preserve leading and trailing whitespace
|
||||
const wrappedValue = `<body>${value}</body>`;
|
||||
|
||||
Reference in New Issue
Block a user