From 4d17befb0d5e5110bfb8fd209c341e867036861a Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sat, 20 Jun 2026 11:39:00 +0300 Subject: [PATCH 1/5] 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 --- .../public/locales/ru-RU/translation.json | 7 + .../footnote/footnote-definition-view.tsx | 47 ++ .../footnote/footnote-reference-view.tsx | 145 +++++ .../components/footnote/footnote.module.css | 106 ++++ .../footnote/footnotes-list-view.tsx | 20 + .../components/slash-menu/menu-items.ts | 9 + .../features/editor/extensions/extensions.ts | 19 + .../features/editor/readonly-page-editor.tsx | 13 +- .../src/collaboration/collaboration.util.ts | 6 + .../footnote-superscript-roundtrip.spec.ts | 61 ++ packages/editor-ext/package.json | 3 +- packages/editor-ext/src/index.ts | 1 + .../src/lib/footnote/footnote-definition.ts | 72 +++ .../lib/footnote/footnote-markdown.test.ts | 56 ++ .../src/lib/footnote/footnote-numbering.ts | 75 +++ .../src/lib/footnote/footnote-reference.ts | 328 +++++++++++ .../src/lib/footnote/footnote-sync.ts | 197 +++++++ .../src/lib/footnote/footnote-util.ts | 77 +++ .../src/lib/footnote/footnote.test.ts | 536 ++++++++++++++++++ .../src/lib/footnote/footnotes-list.ts | 56 ++ packages/editor-ext/src/lib/footnote/index.ts | 6 + .../src/lib/markdown/utils/footnote.marked.ts | 115 ++++ .../src/lib/markdown/utils/marked.utils.ts | 24 +- .../src/lib/markdown/utils/turndown.utils.ts | 89 ++- packages/editor-ext/tsconfig.json | 3 +- packages/editor-ext/vitest.config.ts | 8 + packages/mcp/build/lib/collaboration.js | 67 ++- packages/mcp/build/lib/diff.js | 31 +- packages/mcp/build/lib/docmost-schema.js | 75 +++ packages/mcp/build/lib/markdown-converter.js | 21 + packages/mcp/build/lib/transforms.js | 176 ++++-- packages/mcp/src/lib/collaboration.ts | 77 ++- packages/mcp/src/lib/diff.ts | 31 +- packages/mcp/src/lib/docmost-schema.ts | 80 +++ packages/mcp/src/lib/markdown-converter.ts | 24 + packages/mcp/src/lib/transforms.ts | 192 +++++-- packages/mcp/test/unit/footnotes.test.mjs | 120 ++++ packages/mcp/test/unit/transforms.test.mjs | 84 ++- 38 files changed, 2906 insertions(+), 151 deletions(-) create mode 100644 apps/client/src/features/editor/components/footnote/footnote-definition-view.tsx create mode 100644 apps/client/src/features/editor/components/footnote/footnote-reference-view.tsx create mode 100644 apps/client/src/features/editor/components/footnote/footnote.module.css create mode 100644 apps/client/src/features/editor/components/footnote/footnotes-list-view.tsx create mode 100644 apps/server/src/collaboration/footnote-superscript-roundtrip.spec.ts create mode 100644 packages/editor-ext/src/lib/footnote/footnote-definition.ts create mode 100644 packages/editor-ext/src/lib/footnote/footnote-markdown.test.ts create mode 100644 packages/editor-ext/src/lib/footnote/footnote-numbering.ts create mode 100644 packages/editor-ext/src/lib/footnote/footnote-reference.ts create mode 100644 packages/editor-ext/src/lib/footnote/footnote-sync.ts create mode 100644 packages/editor-ext/src/lib/footnote/footnote-util.ts create mode 100644 packages/editor-ext/src/lib/footnote/footnote.test.ts create mode 100644 packages/editor-ext/src/lib/footnote/footnotes-list.ts create mode 100644 packages/editor-ext/src/lib/footnote/index.ts create mode 100644 packages/editor-ext/src/lib/markdown/utils/footnote.marked.ts create mode 100644 packages/editor-ext/vitest.config.ts create mode 100644 packages/mcp/test/unit/footnotes.test.mjs diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index 25ff2530..414e75b8 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -391,6 +391,13 @@ "Toggle block": "Сворачиваемый блок", "Callout": "Выноска", "Insert callout notice.": "Вставить выноску с сообщением.", + "Footnote": "Сноска", + "Insert a footnote reference.": "Вставить ссылку на сноску.", + "Footnotes": "Примечания", + "Footnote {{number}}": "Сноска {{number}}", + "Go to footnote": "Перейти к сноске", + "Back to reference": "Вернуться к ссылке", + "Empty footnote": "Пустая сноска", "Math inline": "Строчная формула", "Insert inline math equation.": "Вставить математическое выражение в строку.", "Math block": "Блок формулы", diff --git a/apps/client/src/features/editor/components/footnote/footnote-definition-view.tsx b/apps/client/src/features/editor/components/footnote/footnote-definition-view.tsx new file mode 100644 index 00000000..b5aa5486 --- /dev/null +++ b/apps/client/src/features/editor/components/footnote/footnote-definition-view.tsx @@ -0,0 +1,47 @@ +import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react"; +import { useTranslation } from "react-i18next"; +import { computeFootnoteNumbers } from "@docmost/editor-ext"; +import classes from "./footnote.module.css"; + +/** + * NodeView for a single footnote definition: a decorative number marker, the + * editable content (NodeViewContent), and a "↩" back-link to its reference. + * The number is derived from the document (not stored). + */ +export default function FootnoteDefinitionView(props: NodeViewProps) { + const { node, editor } = props; + const { t } = useTranslation(); + const id = node.attrs.id as string; + + const numbers = computeFootnoteNumbers(editor.state.doc); + const number = numbers.get(id) ?? "?"; + + const handleBack = (e: React.MouseEvent) => { + e.preventDefault(); + editor.commands.scrollToReference(id); + }; + + return ( + + + {number}. + + + + ↩ + + + ); +} diff --git a/apps/client/src/features/editor/components/footnote/footnote-reference-view.tsx b/apps/client/src/features/editor/components/footnote/footnote-reference-view.tsx new file mode 100644 index 00000000..c75766da --- /dev/null +++ b/apps/client/src/features/editor/components/footnote/footnote-reference-view.tsx @@ -0,0 +1,145 @@ +import { useEffect, useRef, useState, useCallback } from "react"; +import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; +import { createPortal } from "react-dom"; +import { useTranslation } from "react-i18next"; +import { + autoUpdate, + computePosition, + flip, + offset, + shift, +} from "@floating-ui/dom"; +import { + FOOTNOTE_DEFINITION_NAME, + computeFootnoteNumbers, +} from "@docmost/editor-ext"; +import { ActionIcon } from "@mantine/core"; +import { IconArrowDown } from "@tabler/icons-react"; +import classes from "./footnote.module.css"; + +/** + * Read the plain text of the footnote definition with `id` directly from the + * editor state. No sub-editor: the popover is read-only. + */ +function getDefinitionText(editor: NodeViewProps["editor"], id: string): string { + let text = ""; + editor.state.doc.descendants((node) => { + if ( + node.type.name === FOOTNOTE_DEFINITION_NAME && + node.attrs.id === id + ) { + text = node.textContent; + return false; + } + return undefined; + }); + return text; +} + +export default function FootnoteReferenceView(props: NodeViewProps) { + const { node, editor, selected } = props; + const { t } = useTranslation(); + const id = node.attrs.id as string; + + const anchorRef = useRef(null); + const popoverRef = useRef(null); + const [open, setOpen] = useState(false); + + // Number is derived (not stored) — recompute from the current doc. + const numbers = computeFootnoteNumbers(editor.state.doc); + const number = numbers.get(id) ?? "?"; + const defText = open ? getDefinitionText(editor, id) : ""; + + const position = useCallback(() => { + const anchor = anchorRef.current; + const popup = popoverRef.current; + if (!anchor || !popup) return; + computePosition(anchor, popup, { + placement: "top", + middleware: [offset(6), flip(), shift({ padding: 8 })], + }).then(({ x, y }) => { + popup.style.left = `${x}px`; + popup.style.top = `${y}px`; + }); + }, []); + + useEffect(() => { + if (!open) return; + const anchor = anchorRef.current; + const popup = popoverRef.current; + if (!anchor || !popup) return; + + const cleanup = autoUpdate(anchor, popup, position); + + const onPointerDown = (e: PointerEvent) => { + if ( + popup.contains(e.target as Node) || + anchor.contains(e.target as Node) + ) { + return; + } + setOpen(false); + }; + document.addEventListener("pointerdown", onPointerDown, true); + + return () => { + cleanup(); + document.removeEventListener("pointerdown", onPointerDown, true); + }; + }, [open, position]); + + const handleGoTo = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setOpen(false); + editor.commands.scrollToFootnote(id); + }; + + return ( + + (anchorRef.current = el)} + data-footnote-ref="" + data-id={id} + className={`${classes.reference} ${selected ? classes.selected : ""}`} + onMouseEnter={() => setOpen(true)} + onClick={(e) => { + e.preventDefault(); + setOpen((v) => !v); + }} + // The decoration sets --footnote-number; provide a fallback inline. + style={{ ["--footnote-number" as any]: `"${number}"` }} + aria-label={t("Footnote {{number}}", { number })} + role="button" + /> + {open && + createPortal( +
setOpen(false)} + > +
+ + {t("Footnote {{number}}", { number })} + + + + +
+
+ {defText || t("Empty footnote")} +
+
, + document.body, + )} +
+ ); +} diff --git a/apps/client/src/features/editor/components/footnote/footnote.module.css b/apps/client/src/features/editor/components/footnote/footnote.module.css new file mode 100644 index 00000000..11c391bd --- /dev/null +++ b/apps/client/src/features/editor/components/footnote/footnote.module.css @@ -0,0 +1,106 @@ +/* Superscript reference marker. The visible number comes from the numbering + plugin decoration which sets the --footnote-number CSS variable. */ +.reference { + cursor: pointer; + color: var(--mantine-color-blue-6); + font-weight: 500; + vertical-align: super; + font-size: 0.75em; + line-height: 0; + user-select: none; + white-space: nowrap; +} + +.reference::after { + content: var(--footnote-number, ""); +} + +.reference:hover { + text-decoration: underline; +} + +.reference.selected { + background-color: var(--mantine-color-blue-1); + border-radius: 2px; +} + +/* Read-only popover shown on hover/click of a reference. */ +.popover { + position: absolute; + z-index: 1000; + max-width: 360px; + padding: var(--mantine-spacing-sm); + background: var(--mantine-color-body); + color: var(--mantine-color-default-color); + border: 1px solid var(--mantine-color-default-border); + border-radius: var(--mantine-radius-md); + box-shadow: var(--mantine-shadow-md); + font-size: var(--mantine-font-size-sm); + line-height: 1.4; +} + +.popoverHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--mantine-spacing-xs); + margin-bottom: 4px; +} + +.popoverNumber { + font-weight: 600; + color: var(--mantine-color-dimmed); +} + +.popoverBody { + white-space: pre-wrap; + word-break: break-word; +} + +/* Bottom footnotes container. */ +.list { + margin-top: var(--mantine-spacing-lg); + padding-top: var(--mantine-spacing-md); + border-top: 1px solid var(--mantine-color-default-border); +} + +.listHeading { + font-weight: 600; + font-size: var(--mantine-font-size-sm); + color: var(--mantine-color-dimmed); + margin-bottom: var(--mantine-spacing-xs); + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.definition { + display: flex; + align-items: flex-start; + gap: var(--mantine-spacing-xs); + padding: 2px 0; +} + +.definitionMarker { + flex: 0 0 auto; + min-width: 1.5em; + font-variant-numeric: tabular-nums; + color: var(--mantine-color-dimmed); + user-select: none; +} + +.definitionContent { + flex: 1 1 auto; + min-width: 0; +} + +.backLink { + flex: 0 0 auto; + cursor: pointer; + color: var(--mantine-color-blue-6); + user-select: none; + font-size: 0.9em; +} + +.backLink:hover { + text-decoration: underline; +} diff --git a/apps/client/src/features/editor/components/footnote/footnotes-list-view.tsx b/apps/client/src/features/editor/components/footnote/footnotes-list-view.tsx new file mode 100644 index 00000000..7b2eb51b --- /dev/null +++ b/apps/client/src/features/editor/components/footnote/footnotes-list-view.tsx @@ -0,0 +1,20 @@ +import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react"; +import { useTranslation } from "react-i18next"; +import classes from "./footnote.module.css"; + +/** + * NodeView for the bottom footnotes container. Renders a visual separator and a + * localized heading, then the editable list of definitions via NodeViewContent. + */ +export default function FootnotesListView(_props: NodeViewProps) { + const { t } = useTranslation(); + + return ( + +
+
{t("Footnotes")}
+
+ +
+ ); +} diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.ts index 7f856755..12a5639c 100644 --- a/apps/client/src/features/editor/components/slash-menu/menu-items.ts +++ b/apps/client/src/features/editor/components/slash-menu/menu-items.ts @@ -28,6 +28,7 @@ import { IconTag, IconMoodSmile, IconRotate2, + IconSuperscript, } from "@tabler/icons-react"; import { CommandProps, @@ -366,6 +367,14 @@ const CommandGroups: SlashMenuGroupedItemsType = { command: ({ editor, range }: CommandProps) => editor.chain().focus().deleteRange(range).setDetails().run(), }, + { + title: "Footnote", + description: "Insert a footnote reference.", + searchTerms: ["footnote", "note", "reference", "сноска", "примечание"], + icon: IconSuperscript, + command: ({ editor, range }: CommandProps) => + editor.chain().focus().deleteRange(range).setFootnote().run(), + }, { title: "Callout", description: "Insert callout notice.", diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index 87c7b9e5..9c78ffb0 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -61,6 +61,9 @@ import { TransclusionSource, TransclusionReference, TableView, + FootnoteReference, + FootnotesList, + FootnoteDefinition, } from "@docmost/editor-ext"; import { randomElement, @@ -91,6 +94,9 @@ import PdfView from "@/features/editor/components/pdf/pdf-view.tsx"; import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx"; import TransclusionView from "@/features/editor/components/transclusion/transclusion-view.tsx"; import TransclusionReferenceView from "@/features/editor/components/transclusion/transclusion-reference-view.tsx"; +import FootnoteReferenceView from "@/features/editor/components/footnote/footnote-reference-view.tsx"; +import FootnotesListView from "@/features/editor/components/footnote/footnotes-list-view.tsx"; +import FootnoteDefinitionView from "@/features/editor/components/footnote/footnote-definition-view.tsx"; import { common, createLowlight } from "lowlight"; import plaintext from "highlight.js/lib/languages/plaintext"; import powershell from "highlight.js/lib/languages/powershell"; @@ -381,6 +387,19 @@ export const mainExtensions = [ TransclusionReference.configure({ view: TransclusionReferenceView, }), + FootnoteReference.configure({ + view: FootnoteReferenceView, + // Skip orphan-cleanup on remote/collaboration steps so collaborating + // clients never fight over footnote integrity (deterministic numbering + // decorations handle the rest). + isRemoteTransaction: (tr: any) => isChangeOrigin(tr), + }), + FootnotesList.configure({ + view: FootnotesListView, + }), + FootnoteDefinition.configure({ + view: FootnoteDefinitionView, + }), MarkdownClipboard.configure({ transformPastedText: true, }), diff --git a/apps/client/src/features/editor/readonly-page-editor.tsx b/apps/client/src/features/editor/readonly-page-editor.tsx index cd4878a9..e2912893 100644 --- a/apps/client/src/features/editor/readonly-page-editor.tsx +++ b/apps/client/src/features/editor/readonly-page-editor.tsx @@ -48,9 +48,16 @@ export default function ReadonlyPageEditor({ }, []); const extensions = useMemo(() => { - const filteredExtensions = mainExtensions.filter( - (ext) => ext.name !== "uniqueID", - ); + const filteredExtensions = mainExtensions + .filter((ext) => ext.name !== "uniqueID") + // Read-only must only DECORATE footnotes (numbering), never mutate the + // doc. Disable the footnote sync/integrity plugin so a programmatic + // setContent on a doc the viewer can't edit is never rewritten. + .map((ext) => + ext.name === "footnoteReference" + ? ext.configure({ enableSync: false }) + : ext, + ); return [ ...filteredExtensions, diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts index 554aa43b..0d91d676 100644 --- a/apps/server/src/collaboration/collaboration.util.ts +++ b/apps/server/src/collaboration/collaboration.util.ts @@ -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) { diff --git a/apps/server/src/collaboration/footnote-superscript-roundtrip.spec.ts b/apps/server/src/collaboration/footnote-superscript-roundtrip.spec.ts new file mode 100644 index 00000000..c496ed66 --- /dev/null +++ b/apps/server/src/collaboration/footnote-superscript-roundtrip.spec.ts @@ -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 `` elements. In the server + * `tiptapExtensions` list, Superscript is registered BEFORE the footnote nodes, + * so without the priority guard a `` 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 = + '

Water' + + '' + + ' here.

' + + '
' + + '

First note.

' + + '
'; + + it('parses 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 ', () => { + 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'); + }); +}); diff --git a/packages/editor-ext/package.json b/packages/editor-ext/package.json index 23ddcaff..3ada7a59 100644 --- a/packages/editor-ext/package.json +++ b/packages/editor-ext/package.json @@ -4,7 +4,8 @@ "private": true, "scripts": { "build": "tsc --build", - "dev": "tsc --watch" + "dev": "tsc --watch", + "test": "vitest run" }, "main": "dist/index.js", "module": "./src/index.ts", diff --git a/packages/editor-ext/src/index.ts b/packages/editor-ext/src/index.ts index 003d2288..c629c904 100644 --- a/packages/editor-ext/src/index.ts +++ b/packages/editor-ext/src/index.ts @@ -33,4 +33,5 @@ export * from "./lib/status"; export * from "./lib/pdf"; export * from "./lib/page-break"; export * from "./lib/resizable-nodeview"; +export * from "./lib/footnote"; diff --git a/packages/editor-ext/src/lib/footnote/footnote-definition.ts b/packages/editor-ext/src/lib/footnote/footnote-definition.ts new file mode 100644 index 00000000..819adb70 --- /dev/null +++ b/packages/editor-ext/src/lib/footnote/footnote-definition.ts @@ -0,0 +1,72 @@ +import { mergeAttributes, Node } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +import { FOOTNOTE_DEFINITION_NAME } from "./footnote-util"; + +export interface FootnoteDefinitionOptions { + HTMLAttributes: Record; + view: any; +} + +/** + * A single footnote definition: an editable block (paragraphs only, no nested + * footnotes) keyed by `id` to its reference. Lives only inside `footnotesList`. + */ +export const FootnoteDefinition = Node.create({ + name: FOOTNOTE_DEFINITION_NAME, + + // paragraph+ keeps definitions simple. Note this does NOT block nested + // footnote references on its own: a footnoteReference is inline and the + // paragraphs here accept inline content, so the schema would permit one. + // Nested references are instead prevented by the setFootnote command and the + // sync plugin (which refuse to create/keep a reference inside a definition). + content: "paragraph+", + defining: true, + isolating: true, + selectable: false, + + addOptions() { + return { + HTMLAttributes: {}, + view: null, + }; + }, + + addAttributes() { + return { + id: { + default: null, + parseHTML: (element) => element.getAttribute("data-id"), + renderHTML: (attributes) => { + if (!attributes.id) return {}; + return { "data-id": attributes.id }; + }, + }, + }; + }, + + parseHTML() { + return [ + { + tag: "div[data-footnote-def]", + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "div", + mergeAttributes( + { "data-footnote-def": "", class: "footnote-def" }, + this.options.HTMLAttributes, + HTMLAttributes, + ), + 0, + ]; + }, + + addNodeView() { + if (!this.options.view) return null; + this.editor.isInitialized = true; + return ReactNodeViewRenderer(this.options.view); + }, +}); diff --git a/packages/editor-ext/src/lib/footnote/footnote-markdown.test.ts b/packages/editor-ext/src/lib/footnote/footnote-markdown.test.ts new file mode 100644 index 00000000..a6f3d4ab --- /dev/null +++ b/packages/editor-ext/src/lib/footnote/footnote-markdown.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { htmlToMarkdown } from "../markdown/utils/turndown.utils"; +import { markdownToHtml } from "../markdown/utils/marked.utils"; + +// HTML the editor-ext nodes render (sup[data-footnote-ref], section/div). +const HTML = + `

Water and clay.

` + + `
` + + `

First note.

` + + `

Second note.

` + + `
`; + +describe("footnote markdown round-trip", () => { + it("HTML -> Markdown produces pandoc footnote syntax", () => { + const md = htmlToMarkdown(HTML); + expect(md).toContain("[^fn1]"); + expect(md).toContain("[^fn2]"); + expect(md).toContain("[^fn1]: First note."); + expect(md).toContain("[^fn2]: Second note."); + }); + + it("Markdown -> HTML rebuilds the footnote nodes' HTML", async () => { + const md = htmlToMarkdown(HTML); + const html = await markdownToHtml(md); + expect(html).toContain('data-footnote-ref data-id="fn1"'); + expect(html).toContain('data-footnote-ref data-id="fn2"'); + expect(html).toContain("data-footnotes"); + expect(html).toContain('data-footnote-def data-id="fn1"'); + expect(html).toContain("First note."); + expect(html).toContain("Second note."); + }); + + it("preserves a [^id]: line shown inside a fenced code block (not a definition)", async () => { + // A document that DOCUMENTS footnote syntax inside a code fence. The + // `[^demo]: ...` line is example text, not a real definition, and must + // survive the Markdown -> HTML conversion verbatim. + const md = [ + "Here is how footnotes look:", + "", + "```markdown", + "Some text[^demo]", + "", + "[^demo]: this is the definition", + "```", + "", + "End of doc.", + ].join("\n"); + + const html = await markdownToHtml(md); + // The example definition line is kept inside the rendered code block. + expect(html).toContain("[^demo]: this is the definition"); + // It did NOT get pulled out into a real footnotes section. + expect(html).not.toContain("data-footnotes"); + expect(html).not.toContain("data-footnote-def"); + }); +}); diff --git a/packages/editor-ext/src/lib/footnote/footnote-numbering.ts b/packages/editor-ext/src/lib/footnote/footnote-numbering.ts new file mode 100644 index 00000000..f93a3b08 --- /dev/null +++ b/packages/editor-ext/src/lib/footnote/footnote-numbering.ts @@ -0,0 +1,75 @@ +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { + FOOTNOTE_DEFINITION_NAME, + FOOTNOTE_REFERENCE_NAME, + computeFootnoteNumbers, +} from "./footnote-util"; + +export const footnoteNumberingPluginKey = new PluginKey("footnoteNumbering"); + +/** + * Build the decoration set for footnote numbers. Pure function of the document: + * walk references in document order, assign 1-based numbers, then attach a + * node decoration (carrying the number via a CSS variable + data attribute) to + * every reference and to every matching definition. Because it is deterministic + * from the document alone, all collaborating clients compute identical numbers + * with no document mutation. + */ +export function buildFootnoteDecorations(doc: ProseMirrorNode): DecorationSet { + const numbers = computeFootnoteNumbers(doc); + const decorations: Decoration[] = []; + + doc.descendants((node, pos) => { + if (node.type.name === FOOTNOTE_REFERENCE_NAME) { + const num = numbers.get(node.attrs.id); + if (num != null) { + decorations.push( + Decoration.node(pos, pos + node.nodeSize, { + "data-footnote-number": String(num), + style: `--footnote-number: "${num}";`, + }), + ); + } + } + if (node.type.name === FOOTNOTE_DEFINITION_NAME) { + const num = numbers.get(node.attrs.id); + if (num != null) { + decorations.push( + Decoration.node(pos, pos + node.nodeSize, { + "data-footnote-number": String(num), + style: `--footnote-number: "${num}";`, + }), + ); + } + } + }); + + return DecorationSet.create(doc, decorations); +} + +/** + * ProseMirror plugin that renders footnote numbers as decorations. It never + * mutates the document (safe in read-only / share and in collaboration) — it + * only recomputes decorations from the current doc on each transaction. + */ +export function footnoteNumberingPlugin(): Plugin { + return new Plugin({ + key: footnoteNumberingPluginKey, + state: { + init(_, { doc }) { + return buildFootnoteDecorations(doc); + }, + apply(tr, old) { + if (!tr.docChanged) return old; + return buildFootnoteDecorations(tr.doc); + }, + }, + props: { + decorations(state) { + return this.getState(state); + }, + }, + }); +} diff --git a/packages/editor-ext/src/lib/footnote/footnote-reference.ts b/packages/editor-ext/src/lib/footnote/footnote-reference.ts new file mode 100644 index 00000000..90f5e109 --- /dev/null +++ b/packages/editor-ext/src/lib/footnote/footnote-reference.ts @@ -0,0 +1,328 @@ +import { mergeAttributes, Node } from "@tiptap/core"; +import { TextSelection, Transaction } from "@tiptap/pm/state"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +import { + FOOTNOTE_DEFINITION_NAME, + FOOTNOTE_REFERENCE_NAME, + FOOTNOTES_LIST_NAME, + generateFootnoteId, +} from "./footnote-util"; +import { footnoteNumberingPlugin } from "./footnote-numbering"; +import { footnoteSyncPlugin } from "./footnote-sync"; + +export interface FootnoteReferenceOptions { + HTMLAttributes: Record; + view: any; + /** + * Optional predicate identifying remote/collaboration transactions so the + * sync plugin skips them (orphan cleanup must run only on local changes). + */ + isRemoteTransaction?: (tr: Transaction) => boolean; + /** + * When false, the footnote sync/integrity plugin is fully disabled — it never + * appends a transaction. Numbering decorations stay active. Set this in + * read-only / share editors so a viewer's doc is decorated (numbered) but + * never mutated (e.g. by a programmatic setContent). Defaults to true. + */ + enableSync?: boolean; +} + +declare module "@tiptap/core" { + interface Commands { + footnote: { + /** + * Insert a footnote reference at the cursor and create the matching + * (empty) definition in the bottom footnotes list, in one transaction. + */ + setFootnote: () => ReturnType; + /** + * Remove a footnote reference and cascade-delete its definition (one + * transaction so a single undo restores both). + */ + removeFootnote: (id: string) => ReturnType; + /** Scroll to (and focus) a footnote definition by id. */ + scrollToFootnote: (id: string) => ReturnType; + /** Scroll to (and select) a footnote reference by id. */ + scrollToReference: (id: string) => ReturnType; + }; + } +} + +/** + * Inline atom that marks a footnote reference in the body text. It holds only + * an `id` linking it to its `footnoteDefinition`; the visible number is NOT + * stored — it is rendered by the numbering plugin as a decoration (see + * footnote-numbering.ts). Modeled on mention.ts (inline atom). + * + * The reference is forbidden inside code blocks and inside footnote definitions + * (no nested footnotes); those restrictions are enforced by the `setFootnote` + * command and the sync plugin rather than by schema content expressions, since + * an inline group node cannot express "not inside X" declaratively. + */ +export const FootnoteReference = Node.create({ + name: FOOTNOTE_REFERENCE_NAME, + + // Higher than the default (100) so its parse rule is considered before the + // Superscript mark's rule. + priority: 101, + + group: "inline", + inline: true, + atom: true, + selectable: true, + draggable: false, + + addOptions() { + return { + HTMLAttributes: {}, + view: null, + isRemoteTransaction: undefined, + enableSync: true, + }; + }, + + addProseMirrorPlugins() { + const plugins = [footnoteNumberingPlugin()]; + // Numbering always runs (decoration-only). The sync/integrity plugin is + // skipped entirely when sync is disabled (read-only / share) so the viewer's + // doc is never mutated. + if (this.options.enableSync !== false) { + plugins.push(footnoteSyncPlugin(this.options.isRemoteTransaction)); + } + return plugins; + }, + + addAttributes() { + return { + id: { + default: null, + parseHTML: (element) => element.getAttribute("data-id"), + renderHTML: (attributes) => { + if (!attributes.id) return {}; + return { "data-id": attributes.id }; + }, + }, + }; + }, + + parseHTML() { + return [ + { + // High priority so the Superscript mark (which also matches ) does + // not claim a footnote reference and drop it as empty content. + tag: "sup[data-footnote-ref]", + priority: 100, + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "sup", + mergeAttributes( + { "data-footnote-ref": "", class: "footnote-ref" }, + this.options.HTMLAttributes, + HTMLAttributes, + ), + ]; + }, + + // Plain-text representation (used by generateText / markdown text fallbacks). + renderText({ node }) { + return `[^${node.attrs.id ?? ""}]`; + }, + + addNodeView() { + if (!this.options.view) return null; + // Force the react node view to render immediately using flush sync. + this.editor.isInitialized = true; + return ReactNodeViewRenderer(this.options.view); + }, + + addCommands() { + return { + setFootnote: + () => + ({ state, tr, dispatch, editor }) => { + const { schema, selection } = state; + const refType = schema.nodes[FOOTNOTE_REFERENCE_NAME]; + const listType = schema.nodes[FOOTNOTES_LIST_NAME]; + const defType = schema.nodes[FOOTNOTE_DEFINITION_NAME]; + if (!refType || !listType || !defType) return false; + + const { $from } = selection; + + // Forbid references inside code blocks and inside footnote definitions + // (no nested footnotes). + for (let depth = $from.depth; depth > 0; depth--) { + const node = $from.node(depth); + if ( + node.type.spec.code || + node.type.name === FOOTNOTE_DEFINITION_NAME || + node.type.name === FOOTNOTES_LIST_NAME + ) { + return false; + } + } + + // Make sure the parent accepts an inline atom here. + const insertPos = selection.from; + if (!$from.parent.type.spec.content?.includes("inline") && + !$from.parent.isTextblock) { + return false; + } + + const id = generateFootnoteId(); + + // 1) Count references that occur strictly before the insertion point; + // the new definition goes at that index in the bottom list so the + // list order matches reference order. + let refsBefore = 0; + state.doc.nodesBetween(0, insertPos, (node) => { + if (node.type.name === FOOTNOTE_REFERENCE_NAME) refsBefore++; + }); + + // 2) Insert the reference at the cursor. + tr.insert(insertPos, refType.create({ id })); + + // 3) Locate (or create) the footnotes list, then insert the new + // definition at index `refsBefore`. + const emptyParagraph = schema.nodes.paragraph.create(); + const definition = defType.create({ id }, emptyParagraph); + + // Find existing list (always the last top-level child if present). + let listPos: number | null = null; + let listNode: any = null; + tr.doc.forEach((child, offset) => { + if (child.type.name === FOOTNOTES_LIST_NAME) { + listPos = offset; + listNode = child; + } + }); + + let defInsidePos: number | null = null; + if (listNode == null) { + // Create a new list at the very end of the document. + const list = listType.create(null, definition); + const end = tr.doc.content.size; + tr.insert(end, list); + // Cursor target: inside the new definition's first paragraph. + // end -> list open, +1 definition open, +1 paragraph open. + defInsidePos = end + 3; + } else { + // Insert at the right index within the existing list. + const listStart = listPos! + 1; // position of the first definition + let pos = listStart; + let index = 0; + listNode.forEach((defChild: any, defOffset: number) => { + if (index < refsBefore) { + pos = listStart + defOffset + defChild.nodeSize; + index++; + } + }); + tr.insert(pos, definition); + defInsidePos = pos + 2; // +1 enter definition, +1 enter paragraph + } + + if (dispatch) { + // Move the cursor into the new definition's paragraph so the user + // can immediately type the footnote text. + try { + const resolved = tr.doc.resolve( + Math.min(defInsidePos!, tr.doc.content.size), + ); + tr.setSelection(TextSelection.near(resolved)); + } catch { + // Selection placement is best-effort; ignore failures. + } + tr.scrollIntoView(); + dispatch(tr); + } + + return true; + }, + + removeFootnote: + (id: string) => + ({ state, tr, dispatch }) => { + if (!id) return false; + + // Collect: reference range(s), the definition range, and the list. + const refRanges: Array<{ from: number; to: number }> = []; + let defRange: { from: number; to: number } | null = null; + let listInfo: { pos: number; size: number; count: number } | null = + null; + + state.doc.descendants((node, pos) => { + if ( + node.type.name === FOOTNOTE_REFERENCE_NAME && + node.attrs.id === id + ) { + refRanges.push({ from: pos, to: pos + node.nodeSize }); + } + if ( + node.type.name === FOOTNOTE_DEFINITION_NAME && + node.attrs.id === id + ) { + defRange = { from: pos, to: pos + node.nodeSize }; + } + if (node.type.name === FOOTNOTES_LIST_NAME) { + listInfo = { + pos, + size: node.nodeSize, + count: node.childCount, + }; + } + }); + + if (refRanges.length === 0 && !defRange) return false; + + // Build the list of ranges to delete. If removing this definition + // would empty the list (it is the list's only child), delete the + // entire list instead — an empty footnotesList is invalid schema and + // a leftover empty list would be ugly. + const ranges: Array<{ from: number; to: number }> = [...refRanges]; + if (defRange) { + if (listInfo && (listInfo as any).count <= 1) { + const li = listInfo as { pos: number; size: number }; + ranges.push({ from: li.pos, to: li.pos + li.size }); + } else { + ranges.push(defRange); + } + } + + // Delete from the end so earlier positions stay valid. + ranges + .sort((a, b) => b.from - a.from) + .forEach(({ from, to }) => tr.delete(from, to)); + + if (dispatch) dispatch(tr); + return true; + }, + + scrollToFootnote: + (id: string) => + ({ editor }) => { + if (!id) return false; + const dom = editor.view.dom.querySelector( + `[data-footnote-def][data-id="${id}"]`, + ) as HTMLElement | null; + if (!dom) return false; + dom.scrollIntoView({ behavior: "smooth", block: "center" }); + return true; + }, + + scrollToReference: + (id: string) => + ({ editor }) => { + if (!id) return false; + const dom = editor.view.dom.querySelector( + `sup[data-footnote-ref][data-id="${id}"]`, + ) as HTMLElement | null; + if (!dom) return false; + dom.scrollIntoView({ behavior: "smooth", block: "center" }); + return true; + }, + }; + }, +}); diff --git a/packages/editor-ext/src/lib/footnote/footnote-sync.ts b/packages/editor-ext/src/lib/footnote/footnote-sync.ts new file mode 100644 index 00000000..ffd2e136 --- /dev/null +++ b/packages/editor-ext/src/lib/footnote/footnote-sync.ts @@ -0,0 +1,197 @@ +import { Plugin, PluginKey, Transaction } from "@tiptap/pm/state"; +import { Node as ProseMirrorNode, Fragment } from "@tiptap/pm/model"; +import { + FOOTNOTE_DEFINITION_NAME, + FOOTNOTE_REFERENCE_NAME, + FOOTNOTES_LIST_NAME, +} from "./footnote-util"; + +export const footnoteSyncPluginKey = new PluginKey("footnoteSync"); + +const SYNC_META = "footnoteSyncApplied"; + +interface FootnoteScan { + /** Reference ids in document order, first occurrence only, de-duplicated. */ + referenceIds: string[]; + /** definition id -> node (last occurrence wins, matching scan order). */ + definitions: Map; + /** Every top-level footnotesList node, in document order. */ + lists: Array<{ pos: number; node: ProseMirrorNode }>; +} + +function scan(doc: ProseMirrorNode): FootnoteScan { + const referenceIds: string[] = []; + const seenRefs = new Set(); + const definitions = new Map(); + const lists: Array<{ pos: number; node: ProseMirrorNode }> = []; + + doc.descendants((node, pos) => { + if (node.type.name === FOOTNOTE_REFERENCE_NAME) { + const id = node.attrs.id; + if (id && !seenRefs.has(id)) { + seenRefs.add(id); + referenceIds.push(id); + } + } + if (node.type.name === FOOTNOTE_DEFINITION_NAME) { + const id = node.attrs.id; + if (id) definitions.set(id, node); + } + if (node.type.name === FOOTNOTES_LIST_NAME) { + lists.push({ pos, node }); + } + }); + + return { referenceIds, definitions, lists }; +} + +/** + * Idempotent integrity pass for footnotes. Runs only on LOCAL document changes + * (skips remote/collaboration steps and — crucially — its own appended meta) so + * the plugin can never re-trigger itself, guaranteeing termination. + * + * Everything is computed against the CURRENT document in a SINGLE invocation and + * emitted as AT MOST ONE transaction, always tagged with SYNC_META (and + * addToHistory:false). The strategy is "rebuild the canonical footnotes section + * from the desired end-state" rather than running several self-triggering + * passes: + * + * 1. Collect every footnote reference id in document order (the source of + * truth for which definitions must exist and in what order). + * 2. Compute the desired list of definitions: one per referenced id, in + * reference order, reusing the existing definition node when present or + * creating an empty one when missing. Orphan definitions (no matching + * reference) are dropped. + * 3. Compare against the actual footnotesList state: + * - no references -> there must be NO list (remove any); + * - references present -> there must be exactly ONE list, holding + * exactly the desired definitions, and it + * must sit after all real body content. + * 4. If the document already matches the desired end-state, return null (no + * transaction) — this idempotence is what stops oscillation. + * + * Placement note: the list is considered correctly placed when nothing but + * EMPTY paragraphs follow it. This is deliberate so the plugin coexists with a + * trailing-node plugin (which keeps an empty paragraph at the very end of the + * doc): the footnote list does not need to be the literal last child, only the + * last block of meaningful content. Without this, the two plugins would + * ping-pong forever (list moved to end -> trailing paragraph appended -> list + * no longer last -> moved again ...). + * + * Paste id-collision regeneration is left to the paste handler / v2; the common + * cases (orphans, missing definitions, multiple/empty/misplaced lists) are + * covered here. + */ +export function footnoteSyncPlugin( + isRemoteTransaction?: (tr: Transaction) => boolean, +): Plugin { + return new Plugin({ + key: footnoteSyncPluginKey, + appendTransaction(transactions, _oldState, newState) { + // Only react to document changes. + if (!transactions.some((t) => t.docChanged)) return null; + // Skip our OWN appended transaction. This is the guard that makes the + // plugin loop-safe: the transaction we emit carries SYNC_META, so when + // ProseMirror feeds it back to appendTransaction we bail out immediately + // and never produce a follow-up. (Termination invariant.) + if (transactions.some((t) => t.getMeta(SYNC_META))) return null; + // Skip remote/collab steps (orphan cleanup must run only on local edits). + if ( + isRemoteTransaction && + transactions.some((t) => isRemoteTransaction(t)) + ) { + return null; + } + + const { doc, schema } = newState; + const defType = schema.nodes[FOOTNOTE_DEFINITION_NAME]; + const listType = schema.nodes[FOOTNOTES_LIST_NAME]; + const paragraphType = schema.nodes.paragraph; + if (!defType || !listType || !paragraphType) return null; + + const info = scan(doc); + + // 1) Desired definitions: one per referenced id, in reference order, + // reusing existing definition nodes (preserving their content) and + // synthesizing empty ones for references that lack a definition. + const desiredDefs: ProseMirrorNode[] = info.referenceIds.map((id) => { + const existing = info.definitions.get(id); + if (existing) return existing; + return defType.create({ id }, paragraphType.create()); + }); + + // 2) Determine whether the document already matches the desired end-state. + const hasRefs = desiredDefs.length > 0; + + // Is the existing single list already exactly the desired list, placed + // after all meaningful content (nothing but empty paragraphs after it)? + const isEmptyParagraph = (node: ProseMirrorNode) => + node.type === paragraphType && node.content.size === 0; + + let alreadyCanonical = false; + if (!hasRefs) { + // Canonical when there is no footnotesList at all. + alreadyCanonical = info.lists.length === 0; + } else if (info.lists.length === 1) { + const { pos, node } = info.lists[0]; + // Same definitions, same order, same identity (no rewrite needed)? + const sameDefs = + node.childCount === desiredDefs.length && + desiredDefs.every((d, i) => node.child(i) === d); + + // Placement: only empty paragraphs may follow the list. + const listEnd = pos + node.nodeSize; + let onlyEmptyParasAfter = true; + doc.nodesBetween(listEnd, doc.content.size, (child, childPos) => { + // Only inspect top-level children that start at/after the list end. + if (childPos >= listEnd && child !== node) { + if (!isEmptyParagraph(child)) onlyEmptyParasAfter = false; + } + return false; // do not descend + }); + + alreadyCanonical = sameDefs && onlyEmptyParasAfter; + } + + if (alreadyCanonical) return null; + + // 3) Rebuild: produce exactly ONE transaction that reaches the end-state. + const tr = newState.tr; + + // Delete every existing footnotesList (from the end so earlier positions + // stay valid while we mutate). + [...info.lists] + .sort((a, b) => b.pos - a.pos) + .forEach(({ pos, node }) => { + tr.delete(pos, pos + node.nodeSize); + }); + + if (hasRefs) { + // Insert a single canonical list holding the desired definitions. Place + // it after the last meaningful (non-empty-paragraph) top-level block, so + // it lands before any trailing empty paragraph the trailing-node plugin + // maintains. This keeps both plugins idempotent. + const mappedDoc = tr.doc; + let insertPos = mappedDoc.content.size; + for (let i = mappedDoc.childCount - 1; i >= 0; i--) { + const child = mappedDoc.child(i); + if (isEmptyParagraph(child)) { + // skip trailing empty paragraphs; insert before them + insertPos -= child.nodeSize; + } else { + break; + } + } + + const merged = listType.create(null, Fragment.fromArray(desiredDefs)); + tr.insert(insertPos, merged); + } + + if (!tr.docChanged) return null; + + tr.setMeta(SYNC_META, true); + tr.setMeta("addToHistory", false); + return tr; + }, + }); +} diff --git a/packages/editor-ext/src/lib/footnote/footnote-util.ts b/packages/editor-ext/src/lib/footnote/footnote-util.ts new file mode 100644 index 00000000..41698686 --- /dev/null +++ b/packages/editor-ext/src/lib/footnote/footnote-util.ts @@ -0,0 +1,77 @@ +import { Node as ProseMirrorNode } from "@tiptap/pm/model"; + +/** + * Node type names for the footnote feature. Centralized so every part of the + * feature (nodes, plugins, commands) references the same string. + */ +export const FOOTNOTE_REFERENCE_NAME = "footnoteReference"; +export const FOOTNOTES_LIST_NAME = "footnotesList"; +export const FOOTNOTE_DEFINITION_NAME = "footnoteDefinition"; + +/** + * Generate a uuidv7-style id (time-ordered). Implemented locally so editor-ext + * does not need a runtime dependency on the `uuid` package; matches the + * lexicographically-sortable layout uuidv7 produces. + */ +export function generateFootnoteId(): string { + const now = Date.now(); + const timeHex = now.toString(16).padStart(12, "0"); + + const rand = (length: number) => { + let out = ""; + for (let i = 0; i < length; i++) { + out += Math.floor(Math.random() * 16).toString(16); + } + return out; + }; + + // version 7 nibble, then variant (8..b) nibble. + const versioned = "7" + rand(3); + const variantNibble = (8 + Math.floor(Math.random() * 4)).toString(16); + const variant = variantNibble + rand(3); + + return ( + timeHex.slice(0, 8) + + "-" + + timeHex.slice(8, 12) + + "-" + + versioned + + "-" + + variant + + "-" + + rand(12) + ); +} + +/** + * Collect every `footnoteReference` id in document order. This is the single + * source of truth for numbering and ordering — a pure function of the document + * so every collaborating client computes the same result. + */ +export function collectReferenceIds(doc: ProseMirrorNode): string[] { + const ids: string[] = []; + doc.descendants((node) => { + if (node.type.name === FOOTNOTE_REFERENCE_NAME) { + const id = node.attrs.id; + if (id) ids.push(id); + } + }); + return ids; +} + +/** + * Build a map of `referenceId -> displayNumber` (1-based) from document order. + * Pure function — the basis for the numbering decorations and any test. + */ +export function computeFootnoteNumbers( + doc: ProseMirrorNode, +): Map { + const numbers = new Map(); + let n = 0; + for (const id of collectReferenceIds(doc)) { + if (!numbers.has(id)) { + numbers.set(id, ++n); + } + } + return numbers; +} diff --git a/packages/editor-ext/src/lib/footnote/footnote.test.ts b/packages/editor-ext/src/lib/footnote/footnote.test.ts new file mode 100644 index 00000000..a68685a3 --- /dev/null +++ b/packages/editor-ext/src/lib/footnote/footnote.test.ts @@ -0,0 +1,536 @@ +import { describe, it, expect } from "vitest"; +import { Editor, Extension, getSchema } from "@tiptap/core"; +import { Document } from "@tiptap/extension-document"; +import { Paragraph } from "@tiptap/extension-paragraph"; +import { Text } from "@tiptap/extension-text"; +import { Superscript } from "@tiptap/extension-superscript"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { Node as PMNode } from "@tiptap/pm/model"; +import { FootnoteReference } from "./footnote-reference"; +import { FootnotesList } from "./footnotes-list"; +import { FootnoteDefinition } from "./footnote-definition"; +import { TrailingNode } from "../trailing-node"; +import { + computeFootnoteNumbers, + collectReferenceIds, + FOOTNOTE_REFERENCE_NAME, + FOOTNOTES_LIST_NAME, + FOOTNOTE_DEFINITION_NAME, +} from "./footnote-util"; + +const extensions = [ + Document, + Paragraph, + Text, + FootnoteReference, + FootnotesList, + FootnoteDefinition, +]; + +function makeEditor(content?: any) { + return new Editor({ + extensions, + content: content ?? { type: "doc", content: [{ type: "paragraph" }] }, + }); +} + +function countType(doc: PMNode, name: string): number { + let n = 0; + doc.descendants((node) => { + if (node.type.name === name) n++; + }); + return n; +} + +describe("footnote numbering (pure function)", () => { + it("numbers references in document order", () => { + const schema = getSchema(extensions); + const doc = PMNode.fromJSON(schema, { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "a" }, + { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "x" } }, + { type: "text", text: "b" }, + { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "y" } }, + ], + }, + { + type: FOOTNOTES_LIST_NAME, + content: [ + { + type: FOOTNOTE_DEFINITION_NAME, + attrs: { id: "x" }, + content: [{ type: "paragraph" }], + }, + { + type: FOOTNOTE_DEFINITION_NAME, + attrs: { id: "y" }, + content: [{ type: "paragraph" }], + }, + ], + }, + ], + }); + + expect(collectReferenceIds(doc)).toEqual(["x", "y"]); + const numbers = computeFootnoteNumbers(doc); + expect(numbers.get("x")).toBe(1); + expect(numbers.get("y")).toBe(2); + }); +}); + +describe("setFootnote command", () => { + it("inserts a reference and a matching definition in the footnotes list", () => { + const editor = makeEditor({ + type: "doc", + content: [ + { type: "paragraph", content: [{ type: "text", text: "Hello" }] }, + ], + }); + // Cursor at end of the word. + editor.commands.setTextSelection(6); + const ok = editor.commands.setFootnote(); + expect(ok).toBe(true); + + const doc = editor.state.doc; + expect(countType(doc, FOOTNOTE_REFERENCE_NAME)).toBe(1); + expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(1); + expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(1); + + // The reference id and the definition id match. + let refId: string | null = null; + let defId: string | null = null; + doc.descendants((node) => { + if (node.type.name === FOOTNOTE_REFERENCE_NAME) refId = node.attrs.id; + if (node.type.name === FOOTNOTE_DEFINITION_NAME) defId = node.attrs.id; + }); + expect(refId).toBeTruthy(); + expect(refId).toBe(defId); + editor.destroy(); + }); + + it("inserts the definition at the correct position matching reference order", () => { + const editor = makeEditor({ + type: "doc", + content: [ + { type: "paragraph", content: [{ type: "text", text: "AAAA" }] }, + { type: "paragraph", content: [{ type: "text", text: "BBBB" }] }, + ], + }); + + // First footnote: place inside the SECOND paragraph (after "BBBB"). + editor.commands.setTextSelection(11); // end of BBBB + editor.commands.setFootnote(); + + // Second footnote: place inside the FIRST paragraph (after "AAAA"), + // which is BEFORE the first reference in document order. + editor.commands.setTextSelection(5); // end of AAAA + editor.commands.setFootnote(); + + const doc = editor.state.doc; + // Reference order in document. + const refOrder = collectReferenceIds(doc); + // Definition order in the list. + const defOrder: string[] = []; + doc.descendants((node) => { + if (node.type.name === FOOTNOTE_DEFINITION_NAME) { + defOrder.push(node.attrs.id); + } + }); + + expect(defOrder).toEqual(refOrder); + expect(defOrder.length).toBe(2); + editor.destroy(); + }); +}); + +describe("removeFootnote command (cascade)", () => { + it("removes both the reference and its definition, and drops the empty list", () => { + const editor = makeEditor({ + type: "doc", + content: [ + { type: "paragraph", content: [{ type: "text", text: "Hello" }] }, + ], + }); + editor.commands.setTextSelection(6); + editor.commands.setFootnote(); + + let id: string | null = null; + editor.state.doc.descendants((node) => { + if (node.type.name === FOOTNOTE_REFERENCE_NAME) id = node.attrs.id; + }); + expect(id).toBeTruthy(); + + editor.commands.removeFootnote(id!); + + const doc = editor.state.doc; + expect(countType(doc, FOOTNOTE_REFERENCE_NAME)).toBe(0); + expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(0); + // empty list removed + expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(0); + editor.destroy(); + }); +}); + +describe("footnote sync plugin (orphans)", () => { + it("creates an empty definition for a reference pasted without one", () => { + const editor = makeEditor({ + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "x" }, + { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "orphan-ref" } }, + ], + }, + ], + }); + // Trigger a doc change so appendTransaction runs. + editor.commands.insertContentAt(1, " "); + + const doc = editor.state.doc; + let defFound = false; + doc.descendants((node) => { + if ( + node.type.name === FOOTNOTE_DEFINITION_NAME && + node.attrs.id === "orphan-ref" + ) { + defFound = true; + } + }); + expect(defFound).toBe(true); + editor.destroy(); + }); + + it("merges multiple footnotesList nodes into one, preserving all definitions, as the last child", () => { + const editor = makeEditor({ + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "a" }, + { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "x" } }, + { type: "text", text: "b" }, + { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "y" } }, + ], + }, + // First (stray) footnotes list, e.g. from a paste/collab merge. + { + type: FOOTNOTES_LIST_NAME, + content: [ + { + type: FOOTNOTE_DEFINITION_NAME, + attrs: { id: "x" }, + content: [{ type: "paragraph", content: [{ type: "text", text: "X note" }] }], + }, + ], + }, + { type: "paragraph", content: [{ type: "text", text: "tail" }] }, + // Second footnotes list (the "real" trailing one). + { + type: FOOTNOTES_LIST_NAME, + content: [ + { + type: FOOTNOTE_DEFINITION_NAME, + attrs: { id: "y" }, + content: [{ type: "paragraph", content: [{ type: "text", text: "Y note" }] }], + }, + ], + }, + ], + }); + // Trigger a local doc change so appendTransaction runs. + editor.commands.insertContentAt(1, " "); + + const doc = editor.state.doc; + // Converged to exactly ONE list. + expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(1); + // Both definitions preserved (no tracking lost). + const defIds: string[] = []; + doc.descendants((node) => { + if (node.type.name === FOOTNOTE_DEFINITION_NAME) defIds.push(node.attrs.id); + }); + expect(defIds.sort()).toEqual(["x", "y"]); + // The single list is the LAST child of the document. + const lastChild = doc.child(doc.childCount - 1); + expect(lastChild.type.name).toBe(FOOTNOTES_LIST_NAME); + editor.destroy(); + }); + + it("leaves a correct doc (single trailing list) unchanged — no merge loop", () => { + const editor = makeEditor({ + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "a" }, + { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "x" } }, + ], + }, + { + type: FOOTNOTES_LIST_NAME, + content: [ + { + type: FOOTNOTE_DEFINITION_NAME, + attrs: { id: "x" }, + content: [{ type: "paragraph", content: [{ type: "text", text: "X note" }] }], + }, + ], + }, + ], + }); + const before = editor.state.doc.toJSON(); + // A change that doesn't touch footnote structure. + editor.commands.insertContentAt(1, "z"); + const doc = editor.state.doc; + // Still exactly one list, still last, definition preserved. + expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(1); + const lastChild = doc.child(doc.childCount - 1); + expect(lastChild.type.name).toBe(FOOTNOTES_LIST_NAME); + // The footnotes list subtree is identical to before (no spurious rewrite). + const beforeList = before.content.find( + (n: any) => n.type === FOOTNOTES_LIST_NAME, + ); + const afterList = doc + .toJSON() + .content.find((n: any) => n.type === FOOTNOTES_LIST_NAME); + expect(afterList).toEqual(beforeList); + editor.destroy(); + }); + + it("removes an orphan definition with no matching reference", () => { + const editor = makeEditor({ + type: "doc", + content: [ + { type: "paragraph", content: [{ type: "text", text: "x" }] }, + { + type: FOOTNOTES_LIST_NAME, + content: [ + { + type: FOOTNOTE_DEFINITION_NAME, + attrs: { id: "orphan-def" }, + content: [{ type: "paragraph" }], + }, + ], + }, + ], + }); + editor.commands.insertContentAt(1, "y"); + + const doc = editor.state.doc; + expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(0); + expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(0); + editor.destroy(); + }); +}); + +/** + * Live-editor regression tests for the sync-plugin infinite loop (the hard + * freeze when activating /footnote). These drive a REAL Tiptap editor through + * the same plugin pipeline the browser uses — including the TrailingNode plugin, + * which is what turned the "move list to the end" pass into an infinite + * ping-pong (list moved last -> trailing paragraph appended after it -> list no + * longer last -> moved again -> ...). + * + * If the loop regresses, ProseMirror's appendTransaction round loop never + * terminates and these tests HANG (the vitest timeout fails them). The + * transaction counter additionally fails fast with a bounded iteration cap, so + * a regression surfaces as an explicit error instead of only a slow timeout. + */ +describe("footnote sync plugin (no infinite loop — live editor)", () => { + // Hard cap on how many doc-changing appendTransaction rounds we tolerate for a + // single user action. Convergence takes a couple of rounds at most; anything + // approaching this means the plugins are oscillating. + const MAX_ROUNDS = 50; + + // The production editor wires FootnoteReference alongside TrailingNode and + // Superscript; both participate in the loop the bug exhibited, so we mirror + // that here. + function makeLiveEditor(content?: any) { + let rounds = 0; + // A guard plugin that counts doc-changing appendTransaction rounds and + // throws if they exceed the cap, converting a would-be infinite loop into a + // deterministic failure instead of a wall-clock hang. + const LoopGuard = Extension.create({ + name: "footnoteLoopGuard", + // Run last so it observes every other plugin's appended transaction. + priority: -1000, + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey("footnoteLoopGuard"), + appendTransaction(transactions) { + if (transactions.some((t) => t.docChanged)) { + rounds += 1; + if (rounds > MAX_ROUNDS) { + throw new Error( + `footnote sync did not converge: exceeded ${MAX_ROUNDS} appendTransaction rounds (infinite loop)`, + ); + } + } + return null; + }, + }), + ]; + }, + }); + + const editor = new Editor({ + extensions: [ + Document, + Paragraph, + Text, + Superscript, + TrailingNode, + LoopGuard, + FootnoteReference, + FootnotesList, + FootnoteDefinition, + ], + content: content ?? { type: "doc", content: [{ type: "paragraph" }] }, + }); + return { editor, getRounds: () => rounds, resetRounds: () => (rounds = 0) }; + } + + function lastFootnotesListIsTrailing(doc: PMNode): boolean { + // Canonical placement: the list is the last meaningful block — only empty + // paragraphs (the trailing-node) may follow it. + let listIndex = -1; + for (let i = 0; i < doc.childCount; i++) { + if (doc.child(i).type.name === FOOTNOTES_LIST_NAME) listIndex = i; + } + if (listIndex === -1) return false; + for (let i = listIndex + 1; i < doc.childCount; i++) { + const child = doc.child(i); + if (!(child.type.name === "paragraph" && child.content.size === 0)) { + return false; + } + } + return true; + } + + it("setFootnote() RETURNS (no hang) and produces one ref + one def in a trailing list", () => { + const { editor } = makeLiveEditor({ + type: "doc", + content: [{ type: "paragraph", content: [{ type: "text", text: "Hi" }] }], + }); + editor.commands.setTextSelection(3); + const ok = editor.commands.setFootnote(); + expect(ok).toBe(true); + + const doc = editor.state.doc; + expect(countType(doc, FOOTNOTE_REFERENCE_NAME)).toBe(1); + expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(1); + expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(1); + expect(lastFootnotesListIsTrailing(doc)).toBe(true); + editor.destroy(); + }); + + it("a second setFootnote() does not hang: two refs + two defs in one list", () => { + const { editor } = makeLiveEditor({ + type: "doc", + content: [{ type: "paragraph", content: [{ type: "text", text: "Hi" }] }], + }); + editor.commands.setTextSelection(3); + editor.commands.setFootnote(); + editor.commands.setTextSelection(3); + editor.commands.setFootnote(); + + const doc = editor.state.doc; + expect(countType(doc, FOOTNOTE_REFERENCE_NAME)).toBe(2); + expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(2); + expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(1); + expect(lastFootnotesListIsTrailing(doc)).toBe(true); + editor.destroy(); + }); + + it("converges and stabilizes: an unrelated edit does not keep producing transactions", () => { + const { editor, getRounds, resetRounds } = makeLiveEditor({ + type: "doc", + content: [{ type: "paragraph", content: [{ type: "text", text: "Hi" }] }], + }); + editor.commands.setTextSelection(3); + editor.commands.setFootnote(); + + // Now the doc is canonical. Dispatch an unrelated edit (insert text) and + // assert the sync plugin converges in a bounded number of rounds and the + // document is stable (one ref/def/list, list trailing). + resetRounds(); + editor.commands.insertContentAt(1, "Z"); + const afterFirst = editor.state.doc.toJSON(); + const roundsAfterEdit = getRounds(); + expect(roundsAfterEdit).toBeLessThan(MAX_ROUNDS); + + // A follow-up no-op-ish edit must not re-trigger structural rewrites: the + // footnotes section is identical before and after a further unrelated edit. + editor.commands.insertContentAt(2, "Y"); + const afterSecond = editor.state.doc.toJSON(); + + const listOf = (json: any) => + json.content.find((n: any) => n.type === FOOTNOTES_LIST_NAME); + expect(listOf(afterSecond)).toEqual(listOf(afterFirst)); + expect(countType(editor.state.doc, FOOTNOTES_LIST_NAME)).toBe(1); + editor.destroy(); + }); + + it("two footnotesList nodes converge to one (merge) without looping", () => { + const { editor } = makeLiveEditor({ + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "a" }, + { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "x" } }, + { type: "text", text: "b" }, + { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "y" } }, + ], + }, + { + type: FOOTNOTES_LIST_NAME, + content: [ + { + type: FOOTNOTE_DEFINITION_NAME, + attrs: { id: "x" }, + content: [ + { type: "paragraph", content: [{ type: "text", text: "X" }] }, + ], + }, + ], + }, + { type: "paragraph", content: [{ type: "text", text: "tail" }] }, + { + type: FOOTNOTES_LIST_NAME, + content: [ + { + type: FOOTNOTE_DEFINITION_NAME, + attrs: { id: "y" }, + content: [ + { type: "paragraph", content: [{ type: "text", text: "Y" }] }, + ], + }, + ], + }, + ], + }); + // Trigger a local doc change so appendTransaction runs (must not hang). + editor.commands.insertContentAt(1, " "); + + const doc = editor.state.doc; + expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(1); + const defIds: string[] = []; + doc.descendants((node) => { + if (node.type.name === FOOTNOTE_DEFINITION_NAME) + defIds.push(node.attrs.id); + }); + expect(defIds.sort()).toEqual(["x", "y"]); + expect(lastFootnotesListIsTrailing(doc)).toBe(true); + editor.destroy(); + }); +}); diff --git a/packages/editor-ext/src/lib/footnote/footnotes-list.ts b/packages/editor-ext/src/lib/footnote/footnotes-list.ts new file mode 100644 index 00000000..516fcf45 --- /dev/null +++ b/packages/editor-ext/src/lib/footnote/footnotes-list.ts @@ -0,0 +1,56 @@ +import { mergeAttributes, Node } from "@tiptap/core"; +import { ReactNodeViewRenderer } from "@tiptap/react"; +import { FOOTNOTES_LIST_NAME } from "./footnote-util"; + +export interface FootnotesListOptions { + HTMLAttributes: Record; + view: any; +} + +/** + * Block container that holds all footnote definitions. There is a single + * instance per document and it is always the last child of the doc (enforced by + * the sync plugin). Modeled on the callout block node. + */ +export const FootnotesList = Node.create({ + name: FOOTNOTES_LIST_NAME, + + group: "block", + content: "footnoteDefinition+", + isolating: true, + selectable: false, + defining: true, + + addOptions() { + return { + HTMLAttributes: {}, + view: null, + }; + }, + + parseHTML() { + return [ + { + tag: "section[data-footnotes]", + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "section", + mergeAttributes( + { "data-footnotes": "", class: "footnotes" }, + this.options.HTMLAttributes, + HTMLAttributes, + ), + 0, + ]; + }, + + addNodeView() { + if (!this.options.view) return null; + this.editor.isInitialized = true; + return ReactNodeViewRenderer(this.options.view); + }, +}); diff --git a/packages/editor-ext/src/lib/footnote/index.ts b/packages/editor-ext/src/lib/footnote/index.ts new file mode 100644 index 00000000..02defff1 --- /dev/null +++ b/packages/editor-ext/src/lib/footnote/index.ts @@ -0,0 +1,6 @@ +export * from "./footnote-util"; +export * from "./footnote-reference"; +export * from "./footnotes-list"; +export * from "./footnote-definition"; +export * from "./footnote-numbering"; +export * from "./footnote-sync"; diff --git a/packages/editor-ext/src/lib/markdown/utils/footnote.marked.ts b/packages/editor-ext/src/lib/markdown/utils/footnote.marked.ts new file mode 100644 index 00000000..ad47cc52 --- /dev/null +++ b/packages/editor-ext/src/lib/markdown/utils/footnote.marked.ts @@ -0,0 +1,115 @@ +import { marked } from "marked"; + +/** + * Pandoc/GFM footnote support for the marked (Markdown -> HTML) pipeline. + * + * Two pieces: + * - an INLINE tokenizer for `[^id]` references -> (matches the editor-ext FootnoteReference renderHTML); + * - a document hook (`preprocess`/`walkTokens` is awkward for collecting + + * removing definitions, so we use a regex preprocessing step instead) that + * pulls every `[^id]: text` definition line out of the body and appends a + * single
with one
per + * definition, so the round-trip rebuilds footnotesList + footnoteDefinition. + * + * Only definitions that have a matching reference are emitted (and vice-versa + * the sync plugin fills any gaps on the editor side), keeping the output valid. + */ + +const DEFINITION_RE = /^\[\^([^\]\s]+)\]:[ \t]*(.*)$/; +const REFERENCE_RE = /\[\^([^\]\s]+)\]/; + +interface FootnoteRefToken { + type: "footnoteRef"; + raw: string; + id: string; +} + +export const footnoteReferenceExtension = { + name: "footnoteRef", + level: "inline" as const, + start(src: string) { + return src.match(/\[\^/)?.index ?? -1; + }, + tokenizer(src: string): FootnoteRefToken | undefined { + const match = REFERENCE_RE.exec(src); + // Only match at the very start of the remaining inline source. + if (match && match.index === 0) { + return { + type: "footnoteRef", + raw: match[0], + id: match[1], + }; + } + return undefined; + }, + renderer(token: FootnoteRefToken) { + return ``; + }, +}; + +function escapeAttr(value: string): string { + return String(value).replace(/&/g, "&").replace(/"/g, """); +} + +/** + * Extract `[^id]: text` definition lines from the markdown body, returning the + * cleaned body plus a rendered
(empty string when no + * definitions). Call this BEFORE marked.parse and append the section to the + * resulting HTML. + */ +export function extractFootnoteDefinitions(markdown: string): { + body: string; + section: string; +} { + const lines = markdown.split("\n"); + const bodyLines: string[] = []; + const definitions: Array<{ id: string; text: string }> = []; + + // Track fenced-code state so a `[^id]: ...` line that merely SHOWS footnote + // syntax inside a ``` / ~~~ code block is left in the body verbatim and not + // mistaken for a real definition. + let fence: string | null = null; + + for (const line of lines) { + const fenceMatch = /^(\s*)(`{3,}|~{3,})/.exec(line); + if (fenceMatch) { + const marker = fenceMatch[2][0]; + if (fence === null) { + fence = marker; // opening fence + } else if (marker === fence) { + fence = null; // closing fence (matching delimiter type) + } + bodyLines.push(line); + continue; + } + + const m = fence === null ? DEFINITION_RE.exec(line) : null; + if (m) { + definitions.push({ id: m[1], text: m[2] }); + } else { + bodyLines.push(line); + } + } + + if (definitions.length === 0) { + return { body: markdown, section: "" }; + } + + const defsHtml = definitions + .map((d) => { + // Render the definition text as inline markdown so emphasis/links inside + // a footnote survive the round-trip; wrap in a paragraph (the node's + // content is paragraph+). + const inner = marked.parseInline(d.text || ""); + return `

${inner}

`; + }) + .join(""); + + return { + body: bodyLines.join("\n"), + section: `
${defsHtml}
`, + }; +} diff --git a/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts b/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts index 7556aa4f..82de5761 100644 --- a/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts +++ b/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts @@ -2,6 +2,10 @@ import { marked } from "marked"; import { calloutExtension } from "./callout.marked"; import { mathBlockExtension } from "./math-block.marked"; import { mathInlineExtension } from "./math-inline.marked"; +import { + footnoteReferenceExtension, + extractFootnoteDefinitions, +} from "./footnote.marked"; marked.use({ renderer: { @@ -34,7 +38,12 @@ marked.use({ }); marked.use({ - extensions: [calloutExtension, mathBlockExtension, mathInlineExtension], + extensions: [ + calloutExtension, + mathBlockExtension, + mathInlineExtension, + footnoteReferenceExtension, + ], }); marked.setOptions({ breaks: true }); @@ -48,5 +57,16 @@ export function markdownToHtml( .replace(YAML_FONT_MATTER_REGEX, "") .trimStart(); - return marked.parse(markdown).toString(); + // Pull `[^id]: ...` definition lines out of the body, render the body, then + // append a single
so the round-trip rebuilds the + // footnotesList + footnoteDefinition nodes. + const { body, section } = extractFootnoteDefinitions(markdown); + + const parsed = marked.parse(body); + if (!section) return parsed; + + if (typeof parsed === "string") { + return parsed + section; + } + return parsed.then((html) => html + section); } diff --git a/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts b/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts index ebfc3423..75d923ba 100644 --- a/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts +++ b/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts @@ -12,12 +12,44 @@ function sanitizeMdLinkText(value: string): string { .replace(/[\r\n]+/g, ' '); } +// Tags turndown treats as void (self-closing). Footnote references render as an +// empty whose meaning lives entirely in its data-id; +// without marking it void, turndown's blank-node removal drops it before our +// rule runs, losing the `[^id]` marker. Mirrors turndown's built-in list. +const TURNDOWN_VOID_ELEMENTS = [ + 'AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', + 'KEYGEN', 'LINK', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR', +]; + +function isVoidNode(node: any): boolean { + const name = node?.nodeName?.toUpperCase?.(); + if (!name) return false; + if (name === 'SUP' && node.hasAttribute?.('data-footnote-ref')) { + return true; + } + return TURNDOWN_VOID_ELEMENTS.indexOf(name) !== -1; +} + +/** + * An empty is "blank" to turndown, which removes blank + * inline nodes (RootNode/Node use a module-level isVoid the options cannot + * override). To survive, inject the id as text content so the node is non-blank; + * the footnoteReference rule then reads data-id and emits `[^id]`. + */ +function fillEmptyFootnoteRefs(html: string): string { + return html.replace( + /]*\bdata-footnote-ref\b[^>]*)>\s*<\/sup>/gi, + (_m, attrs) => ``, + ); +} + export function htmlToMarkdown(html: string): string { const turndownService = new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced', hr: '---', bulletListMarker: '-', + isVoid: isVoidNode, }); turndownService.use([ @@ -34,8 +66,12 @@ export function htmlToMarkdown(html: string): string { iframeEmbed, image, video, + footnoteReference, + footnotesList, ]); - return turndownService.turndown(html).replaceAll('
', ' '); + return turndownService + .turndown(fillEmptyFootnoteRefs(html)) + .replaceAll('
', ' '); } function listParagraph(turndownService: _TurndownService) { @@ -203,6 +239,57 @@ function image(turndownService: _TurndownService) { }); } +/** + * Footnote reference (inline atom) -> pandoc/GFM marker `[^id]`. + * The visible number is derived (not stored), so the id is the stable anchor. + */ +function footnoteReference(turndownService: _TurndownService) { + turndownService.addRule('footnoteReference', { + filter: function (node: HTMLInputElement) { + return ( + node.nodeName === 'SUP' && node.hasAttribute('data-footnote-ref') + ); + }, + replacement: function (_content: string, node: HTMLInputElement) { + const id = node.getAttribute('data-id') || ''; + return id ? `[^${id}]` : ''; + }, + }); +} + +/** + * Footnotes container -> the list of `[^id]: text` definitions at the end of + * the document (one per line). Each footnoteDefinition inside emits its own + * `[^id]: ...` line; turndown joins them with the surrounding block spacing. + */ +function footnotesList(turndownService: _TurndownService) { + turndownService.addRule('footnoteDefinition', { + filter: function (node: HTMLInputElement) { + return ( + node.nodeName === 'DIV' && node.hasAttribute('data-footnote-def') + ); + }, + replacement: function (content: string, node: HTMLInputElement) { + const id = node.getAttribute('data-id') || ''; + // Collapse internal newlines so the definition stays a single MD line; + // continuation lines are a v2 refinement. + const text = content.replace(/\s*\n+\s*/g, ' ').trim(); + return id ? `\n[^${id}]: ${text}\n` : ''; + }, + }); + + turndownService.addRule('footnotesList', { + filter: function (node: HTMLInputElement) { + return ( + node.nodeName === 'SECTION' && node.hasAttribute('data-footnotes') + ); + }, + replacement: function (content: string) { + return `\n\n${content.trim()}\n`; + }, + }); +} + function video(turndownService: _TurndownService) { turndownService.addRule('video', { filter: function (node: HTMLInputElement) { diff --git a/packages/editor-ext/tsconfig.json b/packages/editor-ext/tsconfig.json index 974fea06..062c97f5 100644 --- a/packages/editor-ext/tsconfig.json +++ b/packages/editor-ext/tsconfig.json @@ -19,5 +19,6 @@ "strictBindCallApply": false, "forceConsistentCasingInFileNames": false, "noFallthroughCasesInSwitch": false - } + }, + "exclude": ["**/*.test.ts", "vitest.config.ts", "dist"] } diff --git a/packages/editor-ext/vitest.config.ts b/packages/editor-ext/vitest.config.ts new file mode 100644 index 00000000..c13f7bd6 --- /dev/null +++ b/packages/editor-ext/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "jsdom", + include: ["src/**/*.test.ts"], + }, +}); diff --git a/packages/mcp/build/lib/collaboration.js b/packages/mcp/build/lib/collaboration.js index 7b47b9e9..d5e68a21 100644 --- a/packages/mcp/build/lib/collaboration.js +++ b/packages/mcp/build/lib/collaboration.js @@ -263,10 +263,75 @@ function bridgeTaskLists(html) { } return document.body.innerHTML; } +// Mirror of packages/editor-ext footnote markdown handling. A `[^id]` inline +// marker becomes , and `[^id]: text` +// definition lines are collected into a single
. +const FOOTNOTE_DEF_RE = /^\[\^([^\]\s]+)\]:[ \t]*(.*)$/; +const FOOTNOTE_REF_RE = /\[\^([^\]\s]+)\]/; +function escapeFootnoteAttr(value) { + return String(value).replace(/&/g, "&").replace(/"/g, """); +} +const footnoteRefMarkedExtension = { + name: "footnoteRef", + level: "inline", + start(src) { + return src.match(/\[\^/)?.index ?? -1; + }, + tokenizer(src) { + const match = FOOTNOTE_REF_RE.exec(src); + if (match && match.index === 0) { + return { type: "footnoteRef", raw: match[0], id: match[1] }; + } + return undefined; + }, + renderer(token) { + return ``; + }, +}; +marked.use({ extensions: [footnoteRefMarkedExtension] }); +/** + * Pull `[^id]: text` definition lines out of the body and render a single + *
for them (or "" when there are none). + */ +function extractFootnotes(markdown) { + const lines = markdown.split("\n"); + const bodyLines = []; + const defs = []; + // Track fenced-code state so a `[^id]: ...` line shown inside a ``` / ~~~ code + // block is preserved verbatim and not treated as a footnote definition. + let fence = null; + for (const line of lines) { + const fenceMatch = /^(\s*)(`{3,}|~{3,})/.exec(line); + if (fenceMatch) { + const marker = fenceMatch[2][0]; + if (fence === null) + fence = marker; + else if (marker === fence) + fence = null; + bodyLines.push(line); + continue; + } + const m = fence === null ? FOOTNOTE_DEF_RE.exec(line) : null; + if (m) + defs.push({ id: m[1], text: m[2] }); + else + bodyLines.push(line); + } + if (defs.length === 0) + return { body: markdown, section: "" }; + const inner = defs + .map((d) => `

${marked.parseInline(d.text || "")}

`) + .join(""); + return { + body: bodyLines.join("\n"), + section: `
${inner}
`, + }; +} /** Convert markdown to a ProseMirror doc using the full Docmost schema. */ export async function markdownToProseMirror(markdownContent) { const withCallouts = await preprocessCallouts(markdownContent); - const html = await marked.parse(withCallouts); + const { body, section } = extractFootnotes(withCallouts); + const html = (await marked.parse(body)) + section; const bridged = bridgeTaskLists(html); return generateJSON(bridged, docmostExtensions); } diff --git a/packages/mcp/build/lib/diff.js b/packages/mcp/build/lib/diff.js index f5e7ab44..516a3c81 100644 --- a/packages/mcp/build/lib/diff.js +++ b/packages/mcp/build/lib/diff.js @@ -79,10 +79,26 @@ function countUniqueLinks(doc) { visit(doc); return hrefs.size; } +/** Count footnoteReference nodes anywhere under a node (reading order). */ +function countFootnoteRefs(node) { + if (!node || typeof node !== "object") + return 0; + let n = node.type === "footnoteReference" ? 1 : 0; + if (Array.isArray(node.content)) { + for (const child of node.content) + n += countFootnoteRefs(child); + } + return n; +} /** - * Parse the ordered list of integers from `[N]` footnote markers found in the - * BODY only (every top-level block before the first "Примечания..." notes - * heading; if no such heading, the whole doc). Returned in reading order. + * Ordered list of footnote marker numbers found in the BODY only (every + * top-level block before the first "Примечания..." notes heading; if no such + * heading, the whole doc), in reading order. + * + * Supports BOTH representations: + * - real `footnoteReference` nodes (the current footnote feature) — numbered + * 1..n by reading position, since their visible number is derived; + * - legacy `[N]` text markers (older translated docs) — the literal N. */ function footnoteMarkers(doc, notesHeading) { const top = Array.isArray(doc?.content) ? doc.content : []; @@ -90,6 +106,15 @@ function footnoteMarkers(doc, notesHeading) { n.type === "heading" && plainText(n).trim() === notesHeading); const bodyBlocks = notesIdx >= 0 ? top.slice(0, notesIdx) : top; + // Real footnoteReference nodes take precedence: when present, number them by + // reading position (their displayed number is not stored). + let refCount = 0; + for (const block of bodyBlocks) + refCount += countFootnoteRefs(block); + if (refCount > 0) { + return Array.from({ length: refCount }, (_, i) => i + 1); + } + // Fallback: legacy `[N]` text markers. const markers = []; const re = /\[(\d+)\]/g; for (const block of bodyBlocks) { diff --git a/packages/mcp/build/lib/docmost-schema.js b/packages/mcp/build/lib/docmost-schema.js index 97cdcafd..e89ed5a0 100644 --- a/packages/mcp/build/lib/docmost-schema.js +++ b/packages/mcp/build/lib/docmost-schema.js @@ -342,6 +342,78 @@ const Mention = Node.create({ return ["span", { "data-type": "mention", ...HTMLAttributes }, 0]; }, }); +/** + * Footnote feature (mirror of packages/editor-ext/src/lib/footnote). Three + * nodes connected by `id`: + * - FootnoteReference: inline atom marker in the body (); + * - FootnotesList: a single bottom container (
); + * - FootnoteDefinition: one editable note keyed by id (
). + * The visible number is not stored; it is derived from reference order. + * + * priority 101 so this node's parse rule beats the Superscript mark's + * rule (otherwise an empty reference is parsed as an empty superscript + * mark and dropped). Keep in sync with editor-ext. + */ +const FootnoteReference = Node.create({ + name: "footnoteReference", + priority: 101, + group: "inline", + inline: true, + atom: true, + selectable: true, + draggable: false, + addAttributes() { + return { + id: { + default: null, + parseHTML: (el) => el.getAttribute("data-id"), + renderHTML: (attrs) => attrs.id ? { "data-id": attrs.id } : {}, + }, + }; + }, + parseHTML() { + return [{ tag: "sup[data-footnote-ref]", priority: 100 }]; + }, + renderHTML({ HTMLAttributes }) { + return ["sup", { "data-footnote-ref": "", ...HTMLAttributes }]; + }, +}); +const FootnotesList = Node.create({ + name: "footnotesList", + group: "block", + content: "footnoteDefinition+", + isolating: true, + selectable: false, + defining: true, + parseHTML() { + return [{ tag: "section[data-footnotes]" }]; + }, + renderHTML({ HTMLAttributes }) { + return ["section", { "data-footnotes": "", ...HTMLAttributes }, 0]; + }, +}); +const FootnoteDefinition = Node.create({ + name: "footnoteDefinition", + content: "paragraph+", + defining: true, + isolating: true, + selectable: false, + addAttributes() { + return { + id: { + default: null, + parseHTML: (el) => el.getAttribute("data-id"), + renderHTML: (attrs) => attrs.id ? { "data-id": attrs.id } : {}, + }, + }; + }, + parseHTML() { + return [{ tag: "div[data-footnote-def]" }]; + }, + renderHTML({ HTMLAttributes }) { + return ["div", { "data-footnote-def": "", ...HTMLAttributes }, 0]; + }, +}); /** Inline KaTeX expression. Carries the LaTeX source in `text`. */ const MathInline = Node.create({ name: "mathInline", @@ -978,6 +1050,9 @@ export const docmostExtensions = [ TableCell, TableHeader, Mention, + FootnoteReference, + FootnotesList, + FootnoteDefinition, MathInline, MathBlock, Details, diff --git a/packages/mcp/build/lib/markdown-converter.js b/packages/mcp/build/lib/markdown-converter.js index 477dee5d..d5d47400 100644 --- a/packages/mcp/build/lib/markdown-converter.js +++ b/packages/mcp/build/lib/markdown-converter.js @@ -388,6 +388,27 @@ export function convertProseMirrorToMarkdown(content) { // carry the real values), so escape it for the text context, not attrs. return `@${escapeHtmlText(mentionLabel)}`; } + case "footnoteReference": { + // Pandoc/GFM inline marker. The number is derived (not stored), so the + // id is the stable anchor. + const fnId = node.attrs?.id || ""; + return fnId ? `[^${fnId}]` : ""; + } + case "footnotesList": + // The container renders its definitions, each on its own `[^id]: ...` + // line. A blank line separates the body from the notes block. + return nodeContent.map(processNode).join("\n"); + case "footnoteDefinition": { + const defId = node.attrs?.id || ""; + // Collapse the definition's paragraphs into a single line; multi-line + // footnotes are a v2 refinement. + const defText = nodeContent + .map(processNode) + .join(" ") + .replace(/\s*\n+\s*/g, " ") + .trim(); + return defId ? `[^${defId}]: ${defText}` : ""; + } case "attachment": { // BUG FIX: the old code read node.attrs.fileName / node.attrs.src, but // the schema stores name/url (plus mime/size/attachmentId). Emit the diff --git a/packages/mcp/build/lib/transforms.js b/packages/mcp/build/lib/transforms.js index 98079f72..2fc5d37b 100644 --- a/packages/mcp/build/lib/transforms.js +++ b/packages/mcp/build/lib/transforms.js @@ -223,6 +223,59 @@ export function noteItem(inlineNodes) { ], }; } +/** + * Wrap inline ProseMirror nodes in a real footnoteDefinition node keyed by id: + * { type:"footnoteDefinition", attrs:{id}, content:[{ type:"paragraph", content }] } + * (mirrors the editor-ext / docmost-schema FootnoteDefinition node). + */ +export function footnoteDefinition(id, inlineNodes) { + const content = Array.isArray(inlineNodes) ? clone(inlineNodes) : []; + return { + type: "footnoteDefinition", + attrs: { id }, + content: [{ type: "paragraph", attrs: { id: freshId() }, content }], + }; +} +/** + * Replace every `[N]` body marker and `\u0000FN\u0000` comment placeholder in + * an inline content array with a real `footnoteReference` node, in reading + * order. `onMarker` is called for each replaced marker (with the original `[N]` + * number or the placeholder index) and returns the fresh footnote id to attach + * to the inserted node. Mutates `inline` in place. + */ +function replaceMarkersWithReferences(inline, onMarker) { + const re = /\[(\d+)\]|\u0000FN(\d+)\u0000/g; + for (let i = 0; i < inline.length; i++) { + const n = inline[i]; + if (!isObject(n) || n.type !== "text" || typeof n.text !== "string") { + continue; + } + if (!re.test(n.text)) + continue; + re.lastIndex = 0; + const marks = Array.isArray(n.marks) ? n.marks : []; + const parts = []; + let last = 0; + let m; + while ((m = re.exec(n.text)) !== null) { + if (m.index > last) { + parts.push({ ...n, text: n.text.slice(last, m.index), marks: [...marks] }); + } + const oldNum = m[1] != null ? Number(m[1]) : undefined; + const phIdx = m[2] != null ? Number(m[2]) : undefined; + const fnId = onMarker({ oldNum, phIdx }); + parts.push({ type: "footnoteReference", attrs: { id: fnId } }); + last = m.index + m[0].length; + } + if (last < n.text.length) { + parts.push({ ...n, text: n.text.slice(last), marks: [...marks] }); + } + // Drop any zero-length text runs the slicing may have produced. + const cleaned = parts.filter((p) => p.type !== "text" || (typeof p.text === "string" && p.text.length > 0)); + inline.splice(i, 1, ...cleaned); + i += cleaned.length - 1; + } +} /** * Convert a comment's markdown (e.g. `**Lead.** body...`) into inline * ProseMirror nodes. @@ -321,85 +374,100 @@ export function commentsToFootnotes(doc, comments, opts = {}) { throw new Error("notes orderedList not found"); } const consumed = []; - const noteByPh = new Map(); + const noteInlineByPh = new Map(); (Array.isArray(comments) ? comments : []).forEach((c, i) => { if (!c || !c.selection) return; // Collision-proof sentinel delimited by NUL control chars, which never occur - // in real Docmost prose — so the renumber regex below cannot mistake any body - // text (e.g. "Press F1 for help", model "FN2") for a placeholder. The NUL is - // transient: the placeholder round-trips within this function (insertMarkerAfter - // inserts it, the renumber pass replaces it with "[N]"), so it never persists - // in a returned/pushed document. + // in real Docmost prose - so the marker regex cannot mistake any body text + // (e.g. "Press F1 for help", model "FN2") for a placeholder. The NUL is + // transient: the placeholder is inserted here and replaced by a + // footnoteReference node below; it never persists in a returned document. const ph = `\u0000FN${i}\u0000`; - // insertMarkerAfter returns a NEW cloned doc; reassign `working` and refresh - // the `top` / `notesList` references that point into it. + // insertMarkerAfter returns a NEW cloned doc; reassign `working`. const r = insertMarkerAfter(working, c.selection.trimEnd(), ph, { beforeBlock: notesIdx, }); if (!r.inserted) return; working = r.doc; - noteByPh.set(ph, noteItem(mdToInlineNodes(c.content))); + noteInlineByPh.set(ph, mdToInlineNodes(c.content)); consumed.push(c.id); }); // Re-resolve references into the (possibly re-cloned) working doc. const top2 = Array.isArray(working.content) ? working.content : []; - const notesList2 = top2 - .slice(notesIdx) - .find((n) => isObject(n) && n.type === "orderedList"); + const notesIdx2 = top2.findIndex((n) => isObject(n) && n.type === "heading" && blockText(n).trim() === notesHeading); + const oldListIndex = top2.findIndex((n) => isObject(n) && n.type === "orderedList"); + const notesList2 = oldListIndex >= 0 ? top2[oldListIndex] : null; if (!notesList2) { throw new Error("notes orderedList not found"); } - const oldNotes = Array.isArray(notesList2.content) + // Inline content of each existing note (listItem -> paragraph -> inline). + const oldNoteInline = (Array.isArray(notesList2.content) ? notesList2.content - : []; - const newNotes = []; - let seq = 0; - // Match either an existing "[N]" marker or a NUL-delimited "\u0000FN\u0000" - // placeholder, in reading order across the body (blocks before the notes heading). - const re = /\[(\d+)\]|\u0000FN(\d+)\u0000/g; - // Same range regex setCalloutRange uses to detect the disclaimer callout's - // "[1]…[K]" range; used here to decide whether a top-level callout is the - // disclaimer (skip) or an ordinary callout (renumber normally). + : []).map((item) => { + const para = isObject(item) && Array.isArray(item.content) + ? item.content.find((c) => isObject(c) && c.type === "paragraph") + : null; + return para && Array.isArray(para.content) ? para.content : []; + }); + // Walk the body in reading order, turning each "[N]" / placeholder marker into + // a real footnoteReference node and collecting its definition inline content. + const definitions = []; const disclaimerRangeRe = /(\[1\]\s*(?:…|\.\.\.)\s*\[)\d+(\])/; - for (let i = 0; i < notesIdx; i++) { - // Skip ONLY the disclaimer callout: its "[1]…[K]" range is NOT a footnote - // marker and is synced separately by setCalloutRange. Renumbering it here - // would consume note slots and corrupt the sequence. Other top-level - // callouts may carry legitimate "[N]" body markers and are renumbered. + // Recursively visit inline arrays inside a block (paragraph, heading, callout + // child paragraphs, table cells, ...), preserving document reading order. + const visitInlineArrays = (container) => { + if (!isObject(container) || !Array.isArray(container.content)) + return; + const hasText = container.content.some((n) => isObject(n) && n.type === "text"); + if (hasText) { + replaceMarkersWithReferences(container.content, ({ oldNum, phIdx }) => { + const fnId = freshId(); + if (oldNum != null) { + const inline = oldNoteInline[oldNum - 1]; + // Every existing body marker MUST map to a real note. An out-of-range + // marker means the document is internally inconsistent; fail loudly. + if (inline === undefined) { + throw new Error(`footnote [${oldNum}] has no matching note (notes list has ${oldNoteInline.length} items); document is inconsistent`); + } + definitions.push(footnoteDefinition(fnId, inline)); + } + else { + const inline = noteInlineByPh.get(`\u0000FN${phIdx}\u0000`) || []; + definitions.push(footnoteDefinition(fnId, inline)); + } + return fnId; + }); + } + else { + for (const child of container.content) + visitInlineArrays(child); + } + }; + const notesBoundary = notesIdx2 >= 0 ? notesIdx2 : oldListIndex; + for (let i = 0; i < notesBoundary; i++) { + // Skip ONLY the disclaimer callout: its "[1]...[K]" range is NOT a footnote + // marker and is synced separately by setCalloutRange. if (isObject(top2[i]) && top2[i].type === "callout" && disclaimerRangeRe.test(blockText(top2[i]))) { continue; } - walk(top2[i], (node) => { - if (node.type !== "text" || typeof node.text !== "string") - return; - node.text = node.text.replace(re, (_m, oldNum, phIdx) => { - if (oldNum != null) { - const note = oldNotes[Number(oldNum) - 1]; - // Every existing body marker MUST map to a real note. An out-of-range - // marker means the document is internally inconsistent; fail loudly - // rather than silently dropping the note and desyncing the callout. - if (note === undefined) { - throw new Error(`footnote [${oldNum}] has no matching note (notes list has ${oldNotes.length} items); document is inconsistent`); - } - newNotes.push(note); - } - else { - newNotes.push(noteByPh.get(`\u0000FN${phIdx}\u0000`)); - } - return `[${++seq}]`; - }); - }); + visitInlineArrays(top2[i]); } - // Reorder the notes list IN PLACE on `working` first, THEN sync the callout - // range. setCalloutRange clones `working`, so the reordered notes (mutated - // before the clone) are carried into its result automatically. No null-filter - // here: marker count and note count must stay exactly equal (the out-of-range - // guard above guarantees no undefined entry is ever pushed). - notesList2.content = newNotes; - const synced = setCalloutRange(working, notesList2.content.length); + // Replace the old orderedList with a real footnotesList of the collected + // definitions (reading order). If there are no definitions, drop the list. + if (definitions.length > 0) { + top2[oldListIndex] = { + type: "footnotesList", + content: definitions, + }; + } + else { + top2.splice(oldListIndex, 1); + } + // Sync the disclaimer callout range to the new note count. + const synced = setCalloutRange(working, definitions.length); return { doc: synced.doc, consumed }; } diff --git a/packages/mcp/src/lib/collaboration.ts b/packages/mcp/src/lib/collaboration.ts index ca2114d9..0e6e80a3 100644 --- a/packages/mcp/src/lib/collaboration.ts +++ b/packages/mcp/src/lib/collaboration.ts @@ -296,12 +296,87 @@ function bridgeTaskLists(html: string): string { return document.body.innerHTML; } +// Mirror of packages/editor-ext footnote markdown handling. A `[^id]` inline +// marker becomes , and `[^id]: text` +// definition lines are collected into a single
. +const FOOTNOTE_DEF_RE = /^\[\^([^\]\s]+)\]:[ \t]*(.*)$/; +const FOOTNOTE_REF_RE = /\[\^([^\]\s]+)\]/; + +function escapeFootnoteAttr(value: string): string { + return String(value).replace(/&/g, "&").replace(/"/g, """); +} + +const footnoteRefMarkedExtension = { + name: "footnoteRef", + level: "inline" as const, + start(src: string) { + return src.match(/\[\^/)?.index ?? -1; + }, + tokenizer(src: string) { + const match = FOOTNOTE_REF_RE.exec(src); + if (match && match.index === 0) { + return { type: "footnoteRef", raw: match[0], id: match[1] }; + } + return undefined; + }, + renderer(token: any) { + return ``; + }, +}; + +marked.use({ extensions: [footnoteRefMarkedExtension] }); + +/** + * Pull `[^id]: text` definition lines out of the body and render a single + *
for them (or "" when there are none). + */ +function extractFootnotes(markdown: string): { + body: string; + section: string; +} { + const lines = markdown.split("\n"); + const bodyLines: string[] = []; + const defs: Array<{ id: string; text: string }> = []; + // Track fenced-code state so a `[^id]: ...` line shown inside a ``` / ~~~ code + // block is preserved verbatim and not treated as a footnote definition. + let fence: string | null = null; + for (const line of lines) { + const fenceMatch = /^(\s*)(`{3,}|~{3,})/.exec(line); + if (fenceMatch) { + const marker = fenceMatch[2][0]; + if (fence === null) fence = marker; + else if (marker === fence) fence = null; + bodyLines.push(line); + continue; + } + const m = fence === null ? FOOTNOTE_DEF_RE.exec(line) : null; + if (m) defs.push({ id: m[1], text: m[2] }); + else bodyLines.push(line); + } + if (defs.length === 0) return { body: markdown, section: "" }; + const inner = defs + .map( + (d) => + `

${marked.parseInline(d.text || "")}

`, + ) + .join(""); + return { + body: bodyLines.join("\n"), + section: `
${inner}
`, + }; +} + /** Convert markdown to a ProseMirror doc using the full Docmost schema. */ export async function markdownToProseMirror( markdownContent: string, ): Promise { const withCallouts = await preprocessCallouts(markdownContent); - const html = await marked.parse(withCallouts); + const { body, section } = extractFootnotes(withCallouts); + const html = (await marked.parse(body)) + section; const bridged = bridgeTaskLists(html); return generateJSON(bridged, docmostExtensions); } diff --git a/packages/mcp/src/lib/diff.ts b/packages/mcp/src/lib/diff.ts index befe047c..d0848997 100644 --- a/packages/mcp/src/lib/diff.ts +++ b/packages/mcp/src/lib/diff.ts @@ -101,10 +101,25 @@ function countUniqueLinks(doc: any): number { return hrefs.size; } +/** Count footnoteReference nodes anywhere under a node (reading order). */ +function countFootnoteRefs(node: any): number { + if (!node || typeof node !== "object") return 0; + let n = node.type === "footnoteReference" ? 1 : 0; + if (Array.isArray(node.content)) { + for (const child of node.content) n += countFootnoteRefs(child); + } + return n; +} + /** - * Parse the ordered list of integers from `[N]` footnote markers found in the - * BODY only (every top-level block before the first "Примечания..." notes - * heading; if no such heading, the whole doc). Returned in reading order. + * Ordered list of footnote marker numbers found in the BODY only (every + * top-level block before the first "Примечания..." notes heading; if no such + * heading, the whole doc), in reading order. + * + * Supports BOTH representations: + * - real `footnoteReference` nodes (the current footnote feature) — numbered + * 1..n by reading position, since their visible number is derived; + * - legacy `[N]` text markers (older translated docs) — the literal N. */ function footnoteMarkers(doc: any, notesHeading: string): number[] { const top: any[] = Array.isArray(doc?.content) ? doc.content : []; @@ -115,6 +130,16 @@ function footnoteMarkers(doc: any, notesHeading: string): number[] { plainText(n).trim() === notesHeading, ); const bodyBlocks = notesIdx >= 0 ? top.slice(0, notesIdx) : top; + + // Real footnoteReference nodes take precedence: when present, number them by + // reading position (their displayed number is not stored). + let refCount = 0; + for (const block of bodyBlocks) refCount += countFootnoteRefs(block); + if (refCount > 0) { + return Array.from({ length: refCount }, (_, i) => i + 1); + } + + // Fallback: legacy `[N]` text markers. const markers: number[] = []; const re = /\[(\d+)\]/g; for (const block of bodyBlocks) { diff --git a/packages/mcp/src/lib/docmost-schema.ts b/packages/mcp/src/lib/docmost-schema.ts index c45c275a..3d8d25d7 100644 --- a/packages/mcp/src/lib/docmost-schema.ts +++ b/packages/mcp/src/lib/docmost-schema.ts @@ -378,6 +378,83 @@ const Mention = Node.create({ }, }); +/** + * Footnote feature (mirror of packages/editor-ext/src/lib/footnote). Three + * nodes connected by `id`: + * - FootnoteReference: inline atom marker in the body (); + * - FootnotesList: a single bottom container (
); + * - FootnoteDefinition: one editable note keyed by id (
). + * The visible number is not stored; it is derived from reference order. + * + * priority 101 so this node's parse rule beats the Superscript mark's + * rule (otherwise an empty reference is parsed as an empty superscript + * mark and dropped). Keep in sync with editor-ext. + */ +const FootnoteReference = Node.create({ + name: "footnoteReference", + priority: 101, + group: "inline", + inline: true, + atom: true, + selectable: true, + draggable: false, + addAttributes() { + return { + id: { + default: null, + parseHTML: (el: HTMLElement) => el.getAttribute("data-id"), + renderHTML: (attrs: Record) => + attrs.id ? { "data-id": attrs.id } : {}, + }, + }; + }, + parseHTML() { + return [{ tag: "sup[data-footnote-ref]", priority: 100 }]; + }, + renderHTML({ HTMLAttributes }) { + return ["sup", { "data-footnote-ref": "", ...HTMLAttributes }]; + }, +}); + +const FootnotesList = Node.create({ + name: "footnotesList", + group: "block", + content: "footnoteDefinition+", + isolating: true, + selectable: false, + defining: true, + parseHTML() { + return [{ tag: "section[data-footnotes]" }]; + }, + renderHTML({ HTMLAttributes }) { + return ["section", { "data-footnotes": "", ...HTMLAttributes }, 0]; + }, +}); + +const FootnoteDefinition = Node.create({ + name: "footnoteDefinition", + content: "paragraph+", + defining: true, + isolating: true, + selectable: false, + addAttributes() { + return { + id: { + default: null, + parseHTML: (el: HTMLElement) => el.getAttribute("data-id"), + renderHTML: (attrs: Record) => + attrs.id ? { "data-id": attrs.id } : {}, + }, + }; + }, + parseHTML() { + return [{ tag: "div[data-footnote-def]" }]; + }, + renderHTML({ HTMLAttributes }) { + return ["div", { "data-footnote-def": "", ...HTMLAttributes }, 0]; + }, +}); + /** Inline KaTeX expression. Carries the LaTeX source in `text`. */ const MathInline = Node.create({ name: "mathInline", @@ -1069,6 +1146,9 @@ export const docmostExtensions = [ TableCell, TableHeader, Mention, + FootnoteReference, + FootnotesList, + FootnoteDefinition, MathInline, MathBlock, Details, diff --git a/packages/mcp/src/lib/markdown-converter.ts b/packages/mcp/src/lib/markdown-converter.ts index cbaa7042..4e35c995 100644 --- a/packages/mcp/src/lib/markdown-converter.ts +++ b/packages/mcp/src/lib/markdown-converter.ts @@ -430,6 +430,30 @@ export function convertProseMirrorToMarkdown(content: any): string { return `@${escapeHtmlText(mentionLabel)}`; } + case "footnoteReference": { + // Pandoc/GFM inline marker. The number is derived (not stored), so the + // id is the stable anchor. + const fnId = node.attrs?.id || ""; + return fnId ? `[^${fnId}]` : ""; + } + + case "footnotesList": + // The container renders its definitions, each on its own `[^id]: ...` + // line. A blank line separates the body from the notes block. + return nodeContent.map(processNode).join("\n"); + + case "footnoteDefinition": { + const defId = node.attrs?.id || ""; + // Collapse the definition's paragraphs into a single line; multi-line + // footnotes are a v2 refinement. + const defText = nodeContent + .map(processNode) + .join(" ") + .replace(/\s*\n+\s*/g, " ") + .trim(); + return defId ? `[^${defId}]: ${defText}` : ""; + } + case "attachment": { // BUG FIX: the old code read node.attrs.fileName / node.attrs.src, but // the schema stores name/url (plus mime/size/attachmentId). Emit the diff --git a/packages/mcp/src/lib/transforms.ts b/packages/mcp/src/lib/transforms.ts index d8fba091..98269aff 100644 --- a/packages/mcp/src/lib/transforms.ts +++ b/packages/mcp/src/lib/transforms.ts @@ -264,6 +264,66 @@ export function noteItem(inlineNodes: any[]): any { }; } +/** + * Wrap inline ProseMirror nodes in a real footnoteDefinition node keyed by id: + * { type:"footnoteDefinition", attrs:{id}, content:[{ type:"paragraph", content }] } + * (mirrors the editor-ext / docmost-schema FootnoteDefinition node). + */ +export function footnoteDefinition(id: string, inlineNodes: any[]): any { + const content = Array.isArray(inlineNodes) ? clone(inlineNodes) : []; + return { + type: "footnoteDefinition", + attrs: { id }, + content: [{ type: "paragraph", attrs: { id: freshId() }, content }], + }; +} + +/** + * Replace every `[N]` body marker and `\u0000FN\u0000` comment placeholder in + * an inline content array with a real `footnoteReference` node, in reading + * order. `onMarker` is called for each replaced marker (with the original `[N]` + * number or the placeholder index) and returns the fresh footnote id to attach + * to the inserted node. Mutates `inline` in place. + */ +function replaceMarkersWithReferences( + inline: any[], + onMarker: (info: { oldNum?: number; phIdx?: number }) => string, +): void { + const re = /\[(\d+)\]|\u0000FN(\d+)\u0000/g; + for (let i = 0; i < inline.length; i++) { + const n = inline[i]; + if (!isObject(n) || n.type !== "text" || typeof n.text !== "string") { + continue; + } + if (!re.test(n.text)) continue; + re.lastIndex = 0; + + const marks = Array.isArray(n.marks) ? n.marks : []; + const parts: any[] = []; + let last = 0; + let m: RegExpExecArray | null; + while ((m = re.exec(n.text)) !== null) { + if (m.index > last) { + parts.push({ ...n, text: n.text.slice(last, m.index), marks: [...marks] }); + } + const oldNum = m[1] != null ? Number(m[1]) : undefined; + const phIdx = m[2] != null ? Number(m[2]) : undefined; + const fnId = onMarker({ oldNum, phIdx }); + parts.push({ type: "footnoteReference", attrs: { id: fnId } }); + last = m.index + m[0].length; + } + if (last < n.text.length) { + parts.push({ ...n, text: n.text.slice(last), marks: [...marks] }); + } + // Drop any zero-length text runs the slicing may have produced. + const cleaned = parts.filter( + (p) => p.type !== "text" || (typeof p.text === "string" && p.text.length > 0), + ); + inline.splice(i, 1, ...cleaned); + i += cleaned.length - 1; + } +} + /** * Convert a comment's markdown (e.g. `**Lead.** body...`) into inline * ProseMirror nodes. @@ -388,54 +448,91 @@ export function commentsToFootnotes( } const consumed: string[] = []; - const noteByPh = new Map(); + const noteInlineByPh = new Map(); (Array.isArray(comments) ? comments : []).forEach((c, i) => { if (!c || !c.selection) return; // Collision-proof sentinel delimited by NUL control chars, which never occur - // in real Docmost prose — so the renumber regex below cannot mistake any body - // text (e.g. "Press F1 for help", model "FN2") for a placeholder. The NUL is - // transient: the placeholder round-trips within this function (insertMarkerAfter - // inserts it, the renumber pass replaces it with "[N]"), so it never persists - // in a returned/pushed document. + // in real Docmost prose - so the marker regex cannot mistake any body text + // (e.g. "Press F1 for help", model "FN2") for a placeholder. The NUL is + // transient: the placeholder is inserted here and replaced by a + // footnoteReference node below; it never persists in a returned document. const ph = `\u0000FN${i}\u0000`; - // insertMarkerAfter returns a NEW cloned doc; reassign `working` and refresh - // the `top` / `notesList` references that point into it. + // insertMarkerAfter returns a NEW cloned doc; reassign `working`. const r = insertMarkerAfter(working, c.selection.trimEnd(), ph, { beforeBlock: notesIdx, }); if (!r.inserted) return; working = r.doc; - noteByPh.set(ph, noteItem(mdToInlineNodes(c.content))); + noteInlineByPh.set(ph, mdToInlineNodes(c.content)); consumed.push(c.id); }); // Re-resolve references into the (possibly re-cloned) working doc. const top2: any[] = Array.isArray(working.content) ? working.content : []; - const notesList2 = top2 - .slice(notesIdx) - .find((n) => isObject(n) && n.type === "orderedList"); + const notesIdx2 = top2.findIndex( + (n) => isObject(n) && n.type === "heading" && blockText(n).trim() === notesHeading, + ); + const oldListIndex = top2.findIndex( + (n) => isObject(n) && n.type === "orderedList", + ); + const notesList2 = oldListIndex >= 0 ? top2[oldListIndex] : null; if (!notesList2) { throw new Error("notes orderedList not found"); } - const oldNotes: any[] = Array.isArray(notesList2.content) + // Inline content of each existing note (listItem -> paragraph -> inline). + const oldNoteInline = (Array.isArray(notesList2.content) ? notesList2.content - : []; - const newNotes: any[] = []; - let seq = 0; - // Match either an existing "[N]" marker or a NUL-delimited "\u0000FN\u0000" - // placeholder, in reading order across the body (blocks before the notes heading). - const re = /\[(\d+)\]|\u0000FN(\d+)\u0000/g; - // Same range regex setCalloutRange uses to detect the disclaimer callout's - // "[1]…[K]" range; used here to decide whether a top-level callout is the - // disclaimer (skip) or an ordinary callout (renumber normally). + : [] + ).map((item: any) => { + const para = + isObject(item) && Array.isArray(item.content) + ? item.content.find((c: any) => isObject(c) && c.type === "paragraph") + : null; + return para && Array.isArray(para.content) ? para.content : []; + }); + + // Walk the body in reading order, turning each "[N]" / placeholder marker into + // a real footnoteReference node and collecting its definition inline content. + const definitions: any[] = []; const disclaimerRangeRe = /(\[1\]\s*(?:…|\.\.\.)\s*\[)\d+(\])/; - for (let i = 0; i < notesIdx; i++) { - // Skip ONLY the disclaimer callout: its "[1]…[K]" range is NOT a footnote - // marker and is synced separately by setCalloutRange. Renumbering it here - // would consume note slots and corrupt the sequence. Other top-level - // callouts may carry legitimate "[N]" body markers and are renumbered. + + // Recursively visit inline arrays inside a block (paragraph, heading, callout + // child paragraphs, table cells, ...), preserving document reading order. + const visitInlineArrays = (container: any): void => { + if (!isObject(container) || !Array.isArray(container.content)) return; + const hasText = container.content.some( + (n: any) => isObject(n) && n.type === "text", + ); + if (hasText) { + replaceMarkersWithReferences(container.content, ({ oldNum, phIdx }) => { + const fnId = freshId(); + if (oldNum != null) { + const inline = oldNoteInline[oldNum - 1]; + // Every existing body marker MUST map to a real note. An out-of-range + // marker means the document is internally inconsistent; fail loudly. + if (inline === undefined) { + throw new Error( + `footnote [${oldNum}] has no matching note (notes list has ${oldNoteInline.length} items); document is inconsistent`, + ); + } + definitions.push(footnoteDefinition(fnId, inline)); + } else { + const inline = noteInlineByPh.get(`\u0000FN${phIdx}\u0000`) || []; + definitions.push(footnoteDefinition(fnId, inline)); + } + return fnId; + }); + } else { + for (const child of container.content) visitInlineArrays(child); + } + }; + + const notesBoundary = notesIdx2 >= 0 ? notesIdx2 : oldListIndex; + for (let i = 0; i < notesBoundary; i++) { + // Skip ONLY the disclaimer callout: its "[1]...[K]" range is NOT a footnote + // marker and is synced separately by setCalloutRange. if ( isObject(top2[i]) && top2[i].type === "callout" && @@ -443,35 +540,22 @@ export function commentsToFootnotes( ) { continue; } - walk(top2[i], (node) => { - if (node.type !== "text" || typeof node.text !== "string") return; - node.text = node.text.replace(re, (_m: string, oldNum: string, phIdx: string) => { - if (oldNum != null) { - const note = oldNotes[Number(oldNum) - 1]; - // Every existing body marker MUST map to a real note. An out-of-range - // marker means the document is internally inconsistent; fail loudly - // rather than silently dropping the note and desyncing the callout. - if (note === undefined) { - throw new Error( - `footnote [${oldNum}] has no matching note (notes list has ${oldNotes.length} items); document is inconsistent`, - ); - } - newNotes.push(note); - } else { - newNotes.push(noteByPh.get(`\u0000FN${phIdx}\u0000`)); - } - return `[${++seq}]`; - }); - }); + visitInlineArrays(top2[i]); } - // Reorder the notes list IN PLACE on `working` first, THEN sync the callout - // range. setCalloutRange clones `working`, so the reordered notes (mutated - // before the clone) are carried into its result automatically. No null-filter - // here: marker count and note count must stay exactly equal (the out-of-range - // guard above guarantees no undefined entry is ever pushed). - notesList2.content = newNotes; - const synced = setCalloutRange(working, notesList2.content.length); + // Replace the old orderedList with a real footnotesList of the collected + // definitions (reading order). If there are no definitions, drop the list. + if (definitions.length > 0) { + top2[oldListIndex] = { + type: "footnotesList", + content: definitions, + }; + } else { + top2.splice(oldListIndex, 1); + } + + // Sync the disclaimer callout range to the new note count. + const synced = setCalloutRange(working, definitions.length); return { doc: synced.doc, consumed }; } diff --git a/packages/mcp/test/unit/footnotes.test.mjs b/packages/mcp/test/unit/footnotes.test.mjs new file mode 100644 index 00000000..4b1ee6ab --- /dev/null +++ b/packages/mcp/test/unit/footnotes.test.mjs @@ -0,0 +1,120 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { convertProseMirrorToMarkdown } from "../../build/lib/markdown-converter.js"; +import { markdownToProseMirror } from "../../build/lib/collaboration.js"; + +/** Recursively collect every node of `type`. */ +function findAll(node, type, acc = []) { + if (!node || typeof node !== "object") return acc; + if (node.type === type) acc.push(node); + if (Array.isArray(node.content)) { + for (const c of node.content) findAll(c, type, acc); + } + return acc; +} + +const footnoteDoc = { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "Water" }, + { type: "footnoteReference", attrs: { id: "fn1" } }, + { type: "text", text: " and clay" }, + { type: "footnoteReference", attrs: { id: "fn2" } }, + { type: "text", text: "." }, + ], + }, + { + type: "footnotesList", + content: [ + { + type: "footnoteDefinition", + attrs: { id: "fn1" }, + content: [ + { type: "paragraph", content: [{ type: "text", text: "First note." }] }, + ], + }, + { + type: "footnoteDefinition", + attrs: { id: "fn2" }, + content: [ + { type: "paragraph", content: [{ type: "text", text: "Second note." }] }, + ], + }, + ], + }, + ], +}; + +test("JSON -> Markdown emits pandoc footnote syntax", () => { + const md = convertProseMirrorToMarkdown(footnoteDoc); + assert.match(md, /\[\^fn1\]/); + assert.match(md, /\[\^fn2\]/); + assert.match(md, /\[\^fn1\]: First note\./); + assert.match(md, /\[\^fn2\]: Second note\./); +}); + +test("Markdown -> JSON rebuilds footnote nodes", async () => { + const md = convertProseMirrorToMarkdown(footnoteDoc); + const json = await markdownToProseMirror(md); + + const refs = findAll(json, "footnoteReference"); + const list = findAll(json, "footnotesList"); + const defs = findAll(json, "footnoteDefinition"); + + assert.equal(refs.length, 2); + assert.deepEqual( + refs.map((r) => r.attrs.id), + ["fn1", "fn2"], + ); + assert.equal(list.length, 1); + assert.equal(defs.length, 2); + assert.deepEqual( + defs.map((d) => d.attrs.id), + ["fn1", "fn2"], + ); +}); + +test("JSON -> MD -> JSON preserves footnote ids and text", async () => { + const md = convertProseMirrorToMarkdown(footnoteDoc); + const json = await markdownToProseMirror(md); + const md2 = convertProseMirrorToMarkdown(json); + + // The second markdown serialization carries the same markers + definitions. + assert.match(md2, /\[\^fn1\]/); + assert.match(md2, /\[\^fn2\]/); + assert.match(md2, /\[\^fn1\]: First note\./); + assert.match(md2, /\[\^fn2\]: Second note\./); +}); + +test("a [^id]: line inside a fenced code block is NOT treated as a definition", async () => { + // Markdown that DOCUMENTS footnote syntax inside a code fence. The example + // definition line must be preserved verbatim inside the code block and not + // pulled out into a real footnotesList / footnoteDefinition. + const md = [ + "Intro text.", + "", + "```markdown", + "Body[^demo]", + "", + "[^demo]: example definition", + "```", + "", + "Outro.", + ].join("\n"); + + const json = await markdownToProseMirror(md); + + // No real footnote nodes were extracted from the code block. + assert.equal(findAll(json, "footnotesList").length, 0); + assert.equal(findAll(json, "footnoteDefinition").length, 0); + + // The example definition line survives somewhere in the code block text. + const codeBlocks = findAll(json, "codeBlock"); + assert.ok(codeBlocks.length >= 1, "code block present"); + const codeText = JSON.stringify(json); + assert.match(codeText, /\[\^demo\]: example definition/); +}); diff --git a/packages/mcp/test/unit/transforms.test.mjs b/packages/mcp/test/unit/transforms.test.mjs index 3f66593c..f7999113 100644 --- a/packages/mcp/test/unit/transforms.test.mjs +++ b/packages/mcp/test/unit/transforms.test.mjs @@ -34,6 +34,18 @@ const li = (text) => ({ const doc = (...children) => ({ type: "doc", content: children }); const snapshot = (v) => JSON.parse(JSON.stringify(v)); +// Collect every footnoteReference id under a node, in reading order. +const collectRefIds = (node, acc = []) => { + if (!node || typeof node !== "object") return acc; + if (node.type === "footnoteReference") acc.push(node.attrs?.id); + if (Array.isArray(node.content)) { + for (const c of node.content) collectRefIds(c, acc); + } + return acc; +}; +// Plain text of a footnoteDefinition. +const defText = (def) => blockText(def); + // --------------------------------------------------------------------------- // blockText / walk / getList // --------------------------------------------------------------------------- @@ -173,21 +185,30 @@ test("commentsToFootnotes anchors comments and renumbers by position", () => { const { doc: out, consumed } = commentsToFootnotes(d, comments); assert.deepEqual(consumed.sort(), ["cA", "cB"]); - // Markers in reading order: p1 "apple"->[1], p2 existing->[2], p3 "banana"->[3] - assert.match(blockText(out.content[1]), /\[1\]/); - assert.match(blockText(out.content[2]), /\[2\]/); - assert.match(blockText(out.content[3]), /\[3\]/); + // Real footnoteReference nodes were inserted at p1 (apple), p2 (existing), + // p3 (banana), in reading order — the old `[N]` text markers are gone. + const refIds = collectRefIds(out); + assert.equal(refIds.length, 3); + // Body paragraphs p1..p3 no longer carry literal [N] text markers. + assert.doesNotMatch(blockText(out.content[1]), /\[\d+\]/); + assert.doesNotMatch(blockText(out.content[2]), /\[\d+\]/); + assert.doesNotMatch(blockText(out.content[3]), /\[\d+\]/); - // No stray placeholders remain. - const allText = blockText(out); - assert.doesNotMatch(allText, / F\d+ /); + // No stray NUL placeholders remain. + assert.doesNotMatch(blockText(out), /\u0000/); - // Notes list reordered to [apple, existing, banana] (reading order). - const list = out.content.find((n) => n.type === "orderedList"); + // The bottom footnotesList holds the definitions in reading order, each keyed + // by the matching reference id. + const list = out.content.find((n) => n.type === "footnotesList"); + assert.ok(list, "footnotesList present"); assert.equal(list.content.length, 3); - assert.equal(blockText(list.content[0]), "apple note"); - assert.equal(blockText(list.content[1]), "existing note one"); - assert.equal(blockText(list.content[2]), "banana note"); + assert.deepEqual( + list.content.map((d) => d.attrs.id), + refIds, + ); + assert.equal(defText(list.content[0]), "apple note"); + assert.equal(defText(list.content[1]), "existing note one"); + assert.equal(defText(list.content[2]), "banana note"); // Callout range synced to 3 notes. assert.match(blockText(out.content[0]), /\[1\]…\[3\]/); @@ -224,15 +245,16 @@ test("commentsToFootnotes leaves literal 'F1'/'FN2'/'F12' body text untouched", // The literal "F1"/"FN2"/"F12" prose is preserved verbatim (no bogus // footnotes, no eaten spaces around them). assert.match(bodyText, /Press F1 for help, model FN2 and F12 for tools/); - // Exactly one real footnote marker was produced, at the anchored word. - const markerCount = (bodyText.match(/\[\d+\]/g) || []).length; - assert.equal(markerCount, 1); - assert.match(bodyText, /apple \[1\]/); + // Exactly one real footnoteReference node was produced, at the anchored word. + const refIds = collectRefIds(out); + assert.equal(refIds.length, 1); // Exactly one note in the list — "F1"/"FN2"/"F12" did not spawn extra notes. - const list = out.content.find((n) => n.type === "orderedList"); + const list = out.content.find((n) => n.type === "footnotesList"); + assert.ok(list, "footnotesList present"); assert.equal(list.content.length, 1); - assert.equal(blockText(list.content[0]), "apple note"); + assert.equal(list.content[0].attrs.id, refIds[0]); + assert.equal(defText(list.content[0]), "apple note"); // No stray placeholder sentinel remains anywhere: the NUL-delimited sentinel // is fully consumed by the renumber pass, so no raw NUL control char persists @@ -287,17 +309,25 @@ test("commentsToFootnotes renumbers body callouts but skips the disclaimer range assert.deepEqual(consumed, []); // The disclaimer's "[1]…[K]" range is NOT treated as body markers: it stays - // a range and is synced to the note count (2), not renumbered into [1],[2]. + // a range and is synced to the note count (2), not turned into references. assert.match(blockText(out.content[0]), /\[1\]…\[2\]/); - // The body callout's [1] is renumbered as a real reading-order marker. - assert.match(blockText(out.content[1]), /noted \[1\] above/); - // The following paragraph's [2] keeps reading order. - assert.match(blockText(out.content[2]), /with \[2\] too/); + // The body callout's [1] and the paragraph's [2] became footnoteReference + // nodes in reading order (the literal text markers are gone). + const refIds = collectRefIds(out); + assert.equal(refIds.length, 2); + assert.match(blockText(out.content[1]), /noted +above/); // [1] -> node, no text + assert.match(blockText(out.content[2]), /with +too/); // [2] -> node, no text - // Notes list still has the two original notes in order. - const list = out.content.find((n) => n.type === "orderedList"); + // The footnotesList holds the two original notes in reading order, keyed to + // the new reference ids. + const list = out.content.find((n) => n.type === "footnotesList"); + assert.ok(list, "footnotesList present"); assert.equal(list.content.length, 2); - assert.equal(blockText(list.content[0]), "first note"); - assert.equal(blockText(list.content[1]), "second note"); + assert.deepEqual( + list.content.map((d) => d.attrs.id), + refIds, + ); + assert.equal(defText(list.content[0]), "first note"); + assert.equal(defText(list.content[1]), "second note"); }); From 1c83a8ae15ca3b03f017ca5cc3ca6a7158283d55 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sat, 20 Jun 2026 11:39:00 +0300 Subject: [PATCH 2/5] docs: remove implemented footnotes plan Co-Authored-By: Claude Opus 4.8 --- docs/footnotes-plan.md | 244 ----------------------------------------- 1 file changed, 244 deletions(-) delete mode 100644 docs/footnotes-plan.md diff --git a/docs/footnotes-plan.md b/docs/footnotes-plan.md deleted file mode 100644 index 78a0e41b..00000000 --- a/docs/footnotes-plan.md +++ /dev/null @@ -1,244 +0,0 @@ -# Сноски (footnotes) — проект фичи - -> Статус: **проработанный план, готов к реализации**. Ключевые решения приняты. -> - Архитектура: **reference + definitions** (модель Markdown/pandoc), а не «самодостаточный inline-атом со вложенным под-редактором». -> - Объём: **полная интеграция** — редактор + коллаборация (Yjs/Hocuspocus) + Markdown round-trip + зеркало схемы в MCP + AI-хелпер. -> -> Исходный кейс: переводы технических статей (например, про дефлокуляцию при шликерном литье) требуют сносок переводчика и ссылок на источники. Сейчас их некуда деть, кроме инлайновых комментариев или костыля `[1]` руками. - -## 1. Цели и требования - -1. **Читать сноску прямо в тексте** — навёл/кликнул на надстрочный номер → всплывающее окно с текстом сноски, не уходя со строки. -2. **Определения внизу страницы как часть текста** — текст сносок живёт реальным редактируемым блоком в конце документа (выделяется, копируется, экспортируется), а не виртуальной отрисовкой. -3. **Авто-нумерация** — номера проставляются и пересчитываются автоматически при вставке/удалении/перемещении. -4. **Безопасно для совместного редактирования** — работает поверх Hocuspocus/Yjs без расхождений между клиентами. -5. **Переживает Markdown** — экспорт/импорт страниц со сносками (формат pandoc/GFM `[^id]`). -6. **Доступно AI-агенту и MCP** — агент и MCP-инструменты умеют читать/создавать сноски; существующий хелпер `commentsToFootnotes` переводится на настоящие ноды. - -## 2. Развилка (решена): почему НЕ «классический» footnote-атом - -Есть два принципиально разных способа хранить текст сноски в ProseMirror/Tiptap. - -### Вариант A — самодостаточный inline-атом (официальный пример ProseMirror) - -Текст сноски лежит **внутри** inline-атома (`inline: true, atom: true, content: "text*"`), редактируется во вложенном под-редакторе в тултипе. См. [prosemirror.net/examples/footnote](https://prosemirror.net/examples/footnote/) и расширение [tiptap-extension-footnote](https://github.com/LAbigael/tiptap-extension-footnote). - -Минусы для нашего стека: -- **Несовместим с коллаборацией.** Вложенный под-редактор синхронизирует шаги транзакций вручную (`dispatchInner`, флаг `fromOutside`). Поверх Hocuspocus/Yjs (`TiptapTransformer`) это даёт конфликты/расхождения — известная больная точка. У нас коллаборация — это ядро ([collaboration.gateway.ts](../apps/server/src/collaboration/collaboration.gateway.ts), [yjs.util.ts](../apps/server/src/collaboration/yjs.util.ts)). -- **Текст нельзя «положить вниз как часть текста».** Он заперт в атоме; нижний список пришлось бы рисовать виртуально (CSS/декорации) — он не выделяется и плохо экспортируется. -- Само расширение помечено `ALPHA, DO NOT USE FOR PRODUCTION`. - -### Вариант B — reference + definitions (ВЫБРАН) - -Маркер в тексте и текст сноски — **разные обычные ноды**, связанные по `id`: -- inline-атом-ссылка без контента (просто надстрочный номер); -- блок определений внизу страницы из обычных редактируемых нод. - -Плюсы — это ровно то, что нужно: -- **Только обычные ноды → Yjs обрабатывает их нативно**, без вложенных редакторов. Главный выигрыш для коллаборативного стека. -- Нижний блок — **реальная часть документа**: выделяется, копируется, экспортируется (требование 2). -- Чтение в тексте — **read-only поповер**, который просто читает определение по `id`; под-редактор не нужен (требование 1). -- **1:1 ложится на Markdown-сноски** pandoc/GFM (`[^id]` … `[^id]: …`) → импорт/экспорт и хелпер `commentsToFootnotes` выравниваются естественно (требования 5, 6). - -Минусы (управляемые, см. §4–§5): нужно держать ссылки и определения в синхроне (сироты/висячие ссылки) и считать номера/порядок плагином. - -## 3. Модель документа - -Три новые ноды. Источник истины — **ссылка**: есть `footnoteReference` → есть парное `footnoteDefinition`; удаление ссылки каскадно удаляет определение в той же транзакции (один Ctrl+Z восстанавливает оба). - -```jsonc -// 1) Маркер в тексте — inline atom, без контента, только id. -// Видимый номер НЕ хранится в документе (см. §4). -{ "type": "footnoteReference", "attrs": { "id": "fn_a1b2c3" } } - -// 2) Контейнер внизу страницы — реальный блок, всегда последний в документе. -{ "type": "footnotesList", "content": [ /* footnoteDefinition+ */ ] } - -// 3) Одно определение — обычный редактируемый блок с id, привязывающим к ссылке. -{ "type": "footnoteDefinition", - "attrs": { "id": "fn_a1b2c3" }, - "content": [ { "type": "paragraph", "content": [ /* текст сноски, inline */ ] } ] } -``` - -### Почему нода, а не mark - -Ссылка на сноску — это **вставляемый в точку курсора надстрочный глиф**, а не выделение существующего текста. Mark (как у комментариев в [comment.ts](../packages/editor-ext/src/lib/comment/comment.ts)) оборачивает диапазон; нам нужна точечная inline-нода-атом — образец [mention.ts](../packages/editor-ext/src/lib/mention.ts) (`inline: true, atom: true, selectable: true`). - -### Схемные ограничения - -| Нода | Параметры схемы | Где разрешена / что внутри | -|---|---|---| -| `footnoteReference` | `group: "inline"`, `inline: true`, `atom: true`, `selectable: true`, `draggable: false` | в любом inline-контексте, **кроме** code-block и **кроме** содержимого `footnoteDefinition` (запрет вложенных сносок) | -| `footnotesList` | `group: "block"`, `content: "footnoteDefinition+"`, `isolating: true`, `selectable: false` | единственный экземпляр, всегда **последний** дочерний узел документа | -| `footnoteDefinition` | `content: "paragraph+"` (или `block+` без вложенных сносок), `defining: true`, `isolating: true` | только внутри `footnotesList`; атрибут `id` обязателен | - -`id` генерируется как `uuidv7` (как у mention/unique-id), хранится в `data-*`-атрибуте для HTML round-trip. - -## 4. Нумерация и порядок — ключевая тонкость - -**Решение: номера НЕ хранятся в документе.** Их вычисляет ProseMirror-плагин, проходя `footnoteReference` в порядке документа, и отрисовывает декорациями (на надстрочнике и на маркере определения). - -Почему так: -- Детерминированность: каждый клиент считает одинаковые номера из одного и того же документа → **никаких расхождений в коллаборации**, никаких `appendTransaction` в ответ на чужие шаги (что и есть источник конфликтов). -- Дёшево: пересчёт на каждый рендер, без мутаций документа. - -### Порядок определений внизу - -Чтобы нижний список визуально шёл `1, 2, 3`, реальные ноды `footnoteDefinition` должны лежать в порядке ссылок (декорации не переставляют DOM). Стратегия: - -1. **На создании** — команда `setFootnote` вставляет определение в **правильную позицию** (считает, сколько ссылок идёт до точки вставки, и кладёт определение по этому индексу). Покрывает и добавление в конец, и вставку в середину. -2. **Нормализация** — плагин-нормализатор приводит порядок определений к порядку ссылок, если он нарушился (например, пользователь вырезал и переставил абзац со ссылкой). Это **чистая функция от состояния документа** → все клиенты вычисляют одинаковую перестановку и сходятся. Чтобы два клиента не дёргали нормализацию одновременно, выполнять её в `appendTransaction` с guard-метой и идемпотентно (no-op, если порядок уже верный). - -> Главный риск реализации — именно нормализация порядка при перемещении ссылок в коллаборации. Для MVP достаточно правильной вставки на создании (п.1) + нормализации только на локальных транзакциях; перемещение ссылок между местами — редкий кейс, его можно довести во вторую очередь. - -Визуальные номера можно при желании продублировать CSS-счётчиками (`counter-reset`/`counter-increment`, как в alpha-расширении), но decoration-подход надёжнее в коллаборации и не зависит от порядка узлов. - -## 5. Жизненный цикл, команды и UX - -### Команды (в ноде, через `addCommands` + `declare module "@tiptap/core"`) - -- `setFootnote()` — в одной транзакции: вставляет `footnoteReference` с новым `id` в позицию курсора + создаёт `footnotesList` (если его нет, в самом конце документа) + добавляет туда пустое `footnoteDefinition` с тем же `id` в правильную позицию + переносит фокус в это определение, чтобы сразу печатать текст. -- `removeFootnote(id)` — удаляет ссылку и её определение (каскад в одной транзакции). Если определений не осталось — удаляет пустой `footnotesList`. -- `scrollToFootnote(id)` / `scrollToReference(id)` — навигация «ссылка ↔ определение» (для кнопки в поповере и «↩» в определении). - -### Ввод - -- **Slash-меню** `/footnote` (или `/сноска`) — пункт в [slash-menu](../apps/client/src/features/editor/components/slash-menu), вызывает `setFootnote`. -- **Кнопка тулбара** и шорткат (например `Mod-Alt-F`). -- Опционально input-rule (по образцу `wrappingInputRule` в callout) — например `[^` → вставка сноски; решить при реализации, не обязательно для MVP. - -### Плагин синхронизации (`addProseMirrorPlugins`) - -Минимальный, guard’нутый, идемпотентный: -- **Подчистка сирот**: `footnoteDefinition` без парной ссылки — удалить (или пометить, см. §12). -- **Вставка/коллизии при paste**: ссылка без определения → создать пустое определение; определение без ссылки → удалить; при вставке с конфликтом `id` — регенерировать `id` у пары. -- **Пустой контейнер**: нет определений → удалить `footnotesList`. -- **Read-only / share**: плагин **не мутирует документ** (только декорации нумерации), чтобы не трогать общий документ при простом просмотре. - -## 6. Чтение в тексте (поповер) - -NodeView надстрочника (`ReactNodeViewRenderer`, образец mention/callout) по hover/click открывает поповер через `@floating-ui/dom` — тот же паттерн, что в [render-items.ts](../apps/client/src/features/editor/components/slash-menu/render-items.ts) и [mention-suggestion.ts](../apps/client/src/features/editor/components/mention/mention-suggestion.ts) (offset/flip/shift, autoUpdate, закрытие по outside-click). - -Поповер показывает **read-only** текст определения, найденного по `id` прямо в `editor.state` (никакого под-редактора). Кнопка «редактировать»/«перейти» вызывает `scrollToFootnote(id)` и фокусит определение внизу. Работает и в read-only/share-режиме — там используется тот же `mainExtensions` ([extensions.ts](../apps/client/src/features/editor/extensions/extensions.ts), [readonly-page-editor.tsx](../apps/client/src/features/editor/readonly-page-editor.tsx)). - -## 7. Нижний блок (footnotesList) - -NodeView контейнера рисует визуальный разделитель: верхняя граница + заголовок («Footnotes» / «Примечания», локализуется), список `footnoteDefinition`. Каждое определение — `NodeViewContent` (редактируемый контент) + декоративный номер (из §4) + «↩» для возврата к ссылке. Стили — CSS-модули + Mantine, как у остальных NodeView ([components/callout](../apps/client/src/features/editor/components/callout)). - -## 8. HTML round-trip (parseHTML / renderHTML) - -Для лосслесс HTML↔JSON (экспорт, `generateHTML`, серверный рендер, зеркало MCP) у каждой ноды строгие `parseHTML`/`renderHTML`: - -| Нода | renderHTML (примерно) | parseHTML | -|---|---|---| -| `footnoteReference` | `` (атом, без контента; номер ставит CSS/декорация) | `sup[data-footnote-ref]` | -| `footnotesList` | `
` (или `
    `) | `section[data-footnotes]` | -| `footnoteDefinition` | `
    …0…
    ` (`0` — дырка под контент) | `div[data-footnote-def]` | - -## 9. Markdown - -Маппинг на сноски pandoc/GFM: -- `footnoteReference` → `[^id]` в тексте; -- `footnoteDefinition` → `[^id]: текст` в конце документа. - -Точки правки: -- **Экспорт HTML→Markdown (клиент/сервер):** правило turndown в [turndown.utils.ts](../packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts) (образец — правило callout). -- **Импорт Markdown→JSON:** плагин/расширение marked в [marked.utils.ts](../packages/editor-ext/src/lib/markdown/utils/marked.utils.ts), плюс ноды должны быть в схеме `generateJSON`. -- **MCP JSON→Markdown:** case в [markdown-converter.ts](../packages/mcp/src/lib/markdown-converter.ts) (образцы — mention/callout). -- **Fallback:** при экспорте в формат без сносок — деградация в инлайновые `[n]` + список (текущее поведение `commentsToFootnotes`). - -## 10. Сервер и коллаборация - -Новые ноды обязаны попасть в серверный список расширений `tiptapExtensions` ([collaboration.util.ts](../apps/server/src/collaboration/collaboration.util.ts)) — иначе: -- сервер вырежет ноды при сохранении/коллаборации (`getSchema` в [yjs.util.ts](../apps/server/src/collaboration/yjs.util.ts)); -- сломается серверный рендер HTML ([generateHTML.ts](../apps/server/src/common/helpers/prosemirror/html/generateHTML.ts)) и экспорт ([export.service.ts](../apps/server/src/integrations/export/export.service.ts)). - -Поскольку это обычные ноды (а не атом с под-редактором), Yjs/`TiptapTransformer` обрабатывает их автоматически — отдельной регистрации в Yjs не нужно. Миграции БД не требуется (это уровень ProseMirror-документа, не схемы Postgres). - -## 11. MCP: зеркало схемы и конвертер - -`packages/mcp` **не** импортирует `editor-ext`, а держит собственное зеркало схемы. Синхронизировать вручную: -- определения трёх нод (`parseHTML`/`renderHTML`, атрибуты) — в [docmost-schema.ts](../packages/mcp/src/lib/docmost-schema.ts); -- сериализацию в Markdown — в [markdown-converter.ts](../packages/mcp/src/lib/markdown-converter.ts); -- перевод существующего хелпера `commentsToFootnotes` ([transforms.ts](../packages/mcp/src/lib/transforms.ts)) с текстовых `[N]` + `orderedList` на настоящие ноды `footnoteReference`/`footnotesList`/`footnoteDefinition`; обновить подсчёт маркеров в [diff.ts](../packages/mcp/src/lib/diff.ts). - -> ⚠️ При любом изменении схемы документа держать `packages/mcp/src/lib/` и `packages/editor-ext` в синхроне — это явное требование CLAUDE.md. - -## 12. Краевые случаи и решения - -| Случай | Решение | -|---|---| -| Удалили ссылку | Каскадно удалить определение в той же транзакции (undo восстанавливает оба) | -| Удалили последнюю ссылку | Удалить весь `footnotesList` | -| Paste ссылки без определения | Создать пустое определение | -| Paste определения без ссылки | Удалить (сирота) — либо v2: пометить «осиротевшим» | -| Коллизия `id` при paste | Регенерировать `id` у вставленной пары | -| Перемещение ссылки (cut/paste абзаца) | Нормализатор переупорядочивает определения (§4) | -| Вложенная сноска (ссылка внутри определения) | Запретить схемой | -| Ссылка в code-block | Запретить | -| Несколько ссылок на одну сноску | v2 (MVP: строго 1:1) | -| Экспорт в формат без сносок | Fallback на `[n]` + список | -| Read-only / share | Только декорации нумерации, без мутаций документа | - -## 13. Затрагиваемые файлы (полный список) - -**Редактор (editor-ext):** -- `packages/editor-ext/src/lib/footnote/` — новые: три ноды, плагин нумерации/нормализации, команды, NodeView’ы (новый каталог). -- [packages/editor-ext/src/index.ts](../packages/editor-ext/src/index.ts) — экспорт. - -**Клиент:** -- [apps/client/src/features/editor/extensions/extensions.ts](../apps/client/src/features/editor/extensions/extensions.ts) — регистрация в `mainExtensions`, привязка React-NodeView. -- `apps/client/src/features/editor/components/footnote/` — NodeView надстрочника + поповер чтения, NodeView нижнего блока, CSS-модули (новый каталог). -- [apps/client/src/features/editor/components/slash-menu](../apps/client/src/features/editor/components/slash-menu) — пункт `/footnote`. - -**Сервер / коллаборация:** -- [apps/server/src/collaboration/collaboration.util.ts](../apps/server/src/collaboration/collaboration.util.ts) — добавить ноды в `tiptapExtensions`. - -**Markdown round-trip:** -- [packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts](../packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts) -- [packages/editor-ext/src/lib/markdown/utils/marked.utils.ts](../packages/editor-ext/src/lib/markdown/utils/marked.utils.ts) - -**MCP:** -- [packages/mcp/src/lib/docmost-schema.ts](../packages/mcp/src/lib/docmost-schema.ts) -- [packages/mcp/src/lib/markdown-converter.ts](../packages/mcp/src/lib/markdown-converter.ts) -- [packages/mcp/src/lib/transforms.ts](../packages/mcp/src/lib/transforms.ts) (+ [diff.ts](../packages/mcp/src/lib/diff.ts)) - -## 14. План реализации по фазам - -1. **Схема (editor-ext):** три ноды + команды + input-rule + экспорт в `index.ts`. Минимальный плагин нумерации (декорации). Это фундамент, от него зависит всё. -2. **Клиент UI:** NodeView надстрочника + поповер чтения (floating-ui), NodeView нижнего блока, slash-меню, CSS, регистрация в `extensions.ts`. Проверить read-only/share. -3. **Сервер/коллаборация:** регистрация в `tiptapExtensions`; проверить сохранение, коллаборацию двух клиентов, серверный рендер/экспорт HTML. -4. **Markdown round-trip:** turndown + marked; тест «JSON → MD → JSON» без потерь. -5. **MCP:** зеркало схемы + конвертер + перевод `commentsToFootnotes` на ноды + `diff.ts`. -6. **Шлифовка:** нормализация порядка при перемещении ссылок, edge-cases из §12, доступность (ARIA для надстрочника/поповера). - -## 15. Тестирование - -- **Unit (mcp, `node --test`):** JSON↔Markdown round-trip сносок; `commentsToFootnotes` → ноды; нумерация/нормализация как чистая функция. -- **Unit (editor-ext):** команды `setFootnote`/`removeFootnote`, каскадное удаление, вставка определения в правильную позицию. -- **Client (Vitest):** рендер надстрочника и поповера, навигация ссылка↔определение. -- **Ручной/e2e:** два коллаборативных клиента (одновременная вставка сносок, отсутствие расхождений нумерации), экспорт в PDF/Markdown, публичная шара (поповер в read-only). - -## 16. Открытые вопросы / v2 - -- Повторное использование одной сноски несколькими ссылками (pandoc допускает) — отложено. -- Сноски-сироты: удалять молча или показывать предупреждение/«осиротевший» бейдж. -- Концевые сноски (endnotes) на уровне спейса/книги vs постраничные — вне объёма. -- Доп. форматы экспорта (DOCX и т.п.) — отдельно. - ---- - -### Ссылки на код - -- Образец inline-атома: [packages/editor-ext/src/lib/mention.ts](../packages/editor-ext/src/lib/mention.ts) -- Образец блок-ноды с контентом + NodeView + input-rule: [packages/editor-ext/src/lib/callout/callout.ts](../packages/editor-ext/src/lib/callout/callout.ts) -- Образец mark с id + плагин-декорация: [packages/editor-ext/src/lib/comment/comment.ts](../packages/editor-ext/src/lib/comment/comment.ts) -- Реестр нод editor-ext: [packages/editor-ext/src/index.ts](../packages/editor-ext/src/index.ts) -- Клиентский список расширений: [apps/client/src/features/editor/extensions/extensions.ts](../apps/client/src/features/editor/extensions/extensions.ts) -- Поповеры через floating-ui: [slash-menu/render-items.ts](../apps/client/src/features/editor/components/slash-menu/render-items.ts), [mention/mention-suggestion.ts](../apps/client/src/features/editor/components/mention/mention-suggestion.ts) -- Серверный список расширений: [apps/server/src/collaboration/collaboration.util.ts](../apps/server/src/collaboration/collaboration.util.ts) -- Yjs-схема / рендер: [apps/server/src/collaboration/yjs.util.ts](../apps/server/src/collaboration/yjs.util.ts), [apps/server/src/common/helpers/prosemirror/html/generateHTML.ts](../apps/server/src/common/helpers/prosemirror/html/generateHTML.ts) -- Markdown ↔ HTML: [packages/editor-ext/src/lib/markdown](../packages/editor-ext/src/lib/markdown) -- Зеркало схемы MCP: [packages/mcp/src/lib/docmost-schema.ts](../packages/mcp/src/lib/docmost-schema.ts) -- MCP конвертер / хелпер сносок: [packages/mcp/src/lib/markdown-converter.ts](../packages/mcp/src/lib/markdown-converter.ts), [packages/mcp/src/lib/transforms.ts](../packages/mcp/src/lib/transforms.ts) -- Прообраз из примера ProseMirror: [prosemirror.net/examples/footnote](https://prosemirror.net/examples/footnote/) From ceee2a76cacdee50edc138bfb7e5758b62abe2ec Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sat, 20 Jun 2026 13:47:10 +0300 Subject: [PATCH 3/5] fix(footnotes): survive duplicate-id definitions without collab divergence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release-cycle red-team found two same-id footnoteDefinition nodes (trivially produced by markdown import [^d]: first / [^d]: second, or paste/duplicate) caused silent data loss: scan() used a last-wins Map and the sync rebuild (addToHistory:false, propagated via Yjs, un-undoable) dropped all but the last. Fix resolves collisions so BOTH survive, with a DETERMINISTIC id scheme so collaborators converge: - deriveFootnoteId(originalId, occurrence, taken): the k-th (k>=2) occurrence of id X becomes X__k, bumped with a deterministic alpha suffix only against the doc's own id set — a pure function of document state. No Math.random/Date.now on the sync or import paths (random uuid stays only in setFootnote, where a single user originates a brand-new id). - footnote-sync.resolveCollisions walks refs+defs in document order, re-ids duplicate references via setNodeMarkup and pairs them 1:1 with definitions; single SYNC_META-tagged transaction, returns null when canonical (terminates). - Markdown import (footnote.marked) + MCP mirror (collaboration.ts) dedup with the same deterministic scheme + marker rewrite; packages/mcp/build regenerated. - Paste plugin remaps colliding pasted ids against the current doc. Tests: two independent editors resolving the same duplicate-id doc produce IDENTICAL ids (the cross-client determinism guard that the random version would fail); both definitions survive the first edit; import dedup is deterministic. Co-Authored-By: Claude Opus 4.8 --- .../lib/footnote/footnote-markdown.test.ts | 84 +++++ .../src/lib/footnote/footnote-reference.ts | 5 +- .../src/lib/footnote/footnote-sync.ts | 349 ++++++++++++++++-- .../src/lib/footnote/footnote-util.ts | 55 +++ .../src/lib/footnote/footnote.test.ts | 154 ++++++++ .../src/lib/markdown/utils/footnote.marked.ts | 57 ++- packages/mcp/build/lib/collaboration.js | 72 +++- packages/mcp/src/lib/collaboration.ts | 80 +++- packages/mcp/test/unit/footnotes.test.mjs | 33 ++ 9 files changed, 864 insertions(+), 25 deletions(-) diff --git a/packages/editor-ext/src/lib/footnote/footnote-markdown.test.ts b/packages/editor-ext/src/lib/footnote/footnote-markdown.test.ts index a6f3d4ab..844134f6 100644 --- a/packages/editor-ext/src/lib/footnote/footnote-markdown.test.ts +++ b/packages/editor-ext/src/lib/footnote/footnote-markdown.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { htmlToMarkdown } from "../markdown/utils/turndown.utils"; import { markdownToHtml } from "../markdown/utils/marked.utils"; +import { extractFootnoteDefinitions } from "../markdown/utils/footnote.marked"; // HTML the editor-ext nodes render (sup[data-footnote-ref], section/div). const HTML = @@ -53,4 +54,87 @@ describe("footnote markdown round-trip", () => { expect(html).not.toContain("data-footnotes"); expect(html).not.toContain("data-footnote-def"); }); + + it("extractFootnoteDefinitions de-duplicates colliding ids and rewrites markers", () => { + // Two definitions share id `d`, and the body has two `[^d]` markers. The + // output must keep BOTH definitions with DISTINCT ids and rewrite the second + // marker so the (reference, definition) pairing stays 1:1. + const md = [ + "See here[^d] and there[^d].", + "", + "[^d]: first", + "[^d]: second", + ].join("\n"); + + const { body, section } = extractFootnoteDefinitions(md); + + // Pull out the def ids from the section in order. + const defIds = Array.from( + section.matchAll(/data-footnote-def data-id="([^"]+)"/g), + ).map((m) => m[1]); + expect(defIds.length).toBe(2); + expect(new Set(defIds).size).toBe(2); // distinct + expect(defIds[0]).toBe("d"); // first definition keeps the id + + // Both definition texts survive. + expect(section).toContain("first"); + expect(section).toContain("second"); + + // The body still has two markers, now pointing at the two distinct ids. + const refIds = Array.from(body.matchAll(/\[\^([^\]\s]+)\]/g)).map( + (m) => m[1], + ); + expect(refIds.length).toBe(2); + expect(refIds.sort()).toEqual(defIds.sort()); + }); + + it("extractFootnoteDefinitions dedups DETERMINISTICALLY (same input -> same ids)", () => { + // The derived id must be a pure function of the input markdown so importing + // the same source twice (or via the editor and the MCP mirror) yields + // identical ids — never random/time-based. + const md = [ + "See[^d] one[^d] two[^d].", + "", + "[^d]: first", + "[^d]: second", + "[^d]: third", + ].join("\n"); + + const run = () => { + const { body, section } = extractFootnoteDefinitions(md); + const defIds = Array.from( + section.matchAll(/data-footnote-def data-id="([^"]+)"/g), + ).map((m) => m[1]); + const refIds = Array.from(body.matchAll(/\[\^([^\]\s]+)\]/g)).map( + (m) => m[1], + ); + return { defIds, refIds }; + }; + + const a = run(); + const b = run(); + // Identical across runs (this is what would FAIL on the random-id version). + expect(a.defIds).toEqual(b.defIds); + expect(a.refIds).toEqual(b.refIds); + // Deterministic derived scheme: keeper "d", duplicates "d__2", "d__3". + expect(a.defIds).toEqual(["d", "d__2", "d__3"]); + expect(a.refIds.sort()).toEqual(a.defIds.sort()); + }); + + it("markdownToHtml with duplicate ids renders two distinct footnote defs", async () => { + const md = [ + "See here[^d] and there[^d].", + "", + "[^d]: first", + "[^d]: second", + ].join("\n"); + const html = await markdownToHtml(md); + const defIds = Array.from( + html.matchAll(/data-footnote-def data-id="([^"]+)"/g), + ).map((m) => m[1]); + expect(defIds.length).toBe(2); + expect(new Set(defIds).size).toBe(2); + expect(html).toContain("first"); + expect(html).toContain("second"); + }); }); diff --git a/packages/editor-ext/src/lib/footnote/footnote-reference.ts b/packages/editor-ext/src/lib/footnote/footnote-reference.ts index 90f5e109..7b47617d 100644 --- a/packages/editor-ext/src/lib/footnote/footnote-reference.ts +++ b/packages/editor-ext/src/lib/footnote/footnote-reference.ts @@ -8,7 +8,7 @@ import { generateFootnoteId, } from "./footnote-util"; import { footnoteNumberingPlugin } from "./footnote-numbering"; -import { footnoteSyncPlugin } from "./footnote-sync"; +import { footnoteSyncPlugin, footnotePastePlugin } from "./footnote-sync"; export interface FootnoteReferenceOptions { HTMLAttributes: Record; @@ -88,6 +88,9 @@ export const FootnoteReference = Node.create({ // doc is never mutated. if (this.options.enableSync !== false) { plugins.push(footnoteSyncPlugin(this.options.isRemoteTransaction)); + // Regenerate colliding footnote ids on paste so a pasted reference+ + // definition pair never clobbers/merges with an existing footnote. + plugins.push(footnotePastePlugin()); } return plugins; }, diff --git a/packages/editor-ext/src/lib/footnote/footnote-sync.ts b/packages/editor-ext/src/lib/footnote/footnote-sync.ts index ffd2e136..33258590 100644 --- a/packages/editor-ext/src/lib/footnote/footnote-sync.ts +++ b/packages/editor-ext/src/lib/footnote/footnote-sync.ts @@ -1,48 +1,215 @@ import { Plugin, PluginKey, Transaction } from "@tiptap/pm/state"; -import { Node as ProseMirrorNode, Fragment } from "@tiptap/pm/model"; +import { Node as ProseMirrorNode, Fragment, Slice } from "@tiptap/pm/model"; import { FOOTNOTE_DEFINITION_NAME, FOOTNOTE_REFERENCE_NAME, FOOTNOTES_LIST_NAME, + deriveFootnoteId, } from "./footnote-util"; export const footnoteSyncPluginKey = new PluginKey("footnoteSync"); const SYNC_META = "footnoteSyncApplied"; +interface RefOccurrence { + /** Position of the reference node in the document. */ + pos: number; + /** The id the reference currently carries. */ + id: string; + node: ProseMirrorNode; +} + +interface DefOccurrence { + /** Position of the definition node in the document. */ + pos: number; + /** The id the definition currently carries. */ + id: string; + node: ProseMirrorNode; +} + interface FootnoteScan { - /** Reference ids in document order, first occurrence only, de-duplicated. */ - referenceIds: string[]; - /** definition id -> node (last occurrence wins, matching scan order). */ - definitions: Map; + /** + * Every reference occurrence in document order (NOT de-duplicated). Needed so + * that duplicate ids — which would otherwise be silently collapsed — can be + * detected and (together with their definitions) re-id'd instead of dropped. + */ + refOccurrences: RefOccurrence[]; + /** + * Every definition occurrence in document order (NOT de-duplicated). The old + * implementation used a last-wins Map here, which is exactly what caused + * silent data loss: two definitions sharing an id collapsed to one. + */ + defOccurrences: DefOccurrence[]; /** Every top-level footnotesList node, in document order. */ lists: Array<{ pos: number; node: ProseMirrorNode }>; } function scan(doc: ProseMirrorNode): FootnoteScan { - const referenceIds: string[] = []; - const seenRefs = new Set(); - const definitions = new Map(); + const refOccurrences: RefOccurrence[] = []; + const defOccurrences: DefOccurrence[] = []; const lists: Array<{ pos: number; node: ProseMirrorNode }> = []; doc.descendants((node, pos) => { if (node.type.name === FOOTNOTE_REFERENCE_NAME) { const id = node.attrs.id; - if (id && !seenRefs.has(id)) { - seenRefs.add(id); - referenceIds.push(id); - } + if (id) refOccurrences.push({ pos, id, node }); } if (node.type.name === FOOTNOTE_DEFINITION_NAME) { const id = node.attrs.id; - if (id) definitions.set(id, node); + if (id) defOccurrences.push({ pos, id, node }); } if (node.type.name === FOOTNOTES_LIST_NAME) { lists.push({ pos, node }); } }); - return { referenceIds, definitions, lists }; + return { refOccurrences, defOccurrences, lists }; +} + +/** + * Result of resolving id collisions: a 1:1, de-duplicated pairing plan plus the + * concrete reference re-id edits that must be applied to the body so the doc no + * longer contains two footnotes sharing a single id. + * + * The overriding invariant is that NO definition is ever dropped here: every + * definition occurrence ends up with a unique id and therefore survives the + * canonical rebuild. Duplicate references are likewise re-id'd (and paired with + * a duplicate definition when one exists) so importing/pasting `[^d]` twice with + * two `[^d]:` definitions yields TWO distinct footnotes rather than one. + */ +interface CollisionPlan { + /** + * Reference ids in document order, de-duplicated AFTER re-id. This is the + * source of truth for definition order/numbering, exactly as before — only + * now collisions have been resolved so it no longer hides duplicates. + */ + referenceIds: string[]; + /** id -> definition node, after duplicates were re-id'd. One entry per id. */ + definitions: Map; + /** + * Body reference re-id edits to apply (position of a reference node -> the + * fresh id it must carry). Empty when there are no colliding references. + */ + refReids: Array<{ pos: number; node: ProseMirrorNode; newId: string }>; + /** True when any collision required a re-id (refs and/or defs). */ + changed: boolean; +} + +/** + * Resolve duplicate-id collisions among references and definitions WITHOUT ever + * dropping a definition. + * + * Strategy: + * - Walk references in document order. The FIRST reference for an id keeps it. + * Any later reference sharing that id is a duplicate and gets a fresh unique + * id; if a still-unclaimed duplicate definition with the original id exists, + * it is re-id'd to the SAME fresh id so the (ref, def) pair stays matched. + * - Walk definitions in document order. The FIRST definition for an id keeps + * it; later duplicates that were not already claimed by a duplicate reference + * get their own fresh unique id (surviving as a distinct footnote/orphan). + * + * Re-id determinism: every fresh id is DERIVED from document state via + * deriveFootnoteId (e.g. `X__2`, `X__3`, collision-bumped against the set of ids + * already present) — NEVER random/time-based. Because the sync plugin runs + * identically on every collaborating client, a deterministic re-id is the only + * way they can converge on the SAME ids; a random id (the previous + * implementation) made two clients editing the same duplicate-id document mint + * DIFFERENT ids for the same duplicate, causing permanent Yjs divergence. + */ +function resolveCollisions(scan: FootnoteScan): CollisionPlan { + const definitions = new Map(); + const refReids: Array<{ + pos: number; + node: ProseMirrorNode; + newId: string; + }> = []; + const referenceIds: string[] = []; + const seenRefIds = new Set(); + let changed = false; + + // `taken` is the set of every id that must be avoided when minting a derived + // id: all original reference + definition ids in the document PLUS every id we + // mint during this pass. It is pure document state, so the derivation stays + // deterministic across clients. Per-original occurrence counters make the k-th + // duplicate of `X` deterministically become `X__2`, `X__3`, ... + const taken = new Set(); + for (const occ of scan.refOccurrences) taken.add(occ.id); + for (const occ of scan.defOccurrences) taken.add(occ.id); + const occurrenceOf = new Map(); + // Mint a deterministic unique id for a duplicate of `originalId`. The first + // duplicate is occurrence 2 (the keeper is occurrence 1), then 3, 4, ... + const mintId = (originalId: string): string => { + const next = (occurrenceOf.get(originalId) ?? 1) + 1; + occurrenceOf.set(originalId, next); + const id = deriveFootnoteId(originalId, next, taken); + taken.add(id); + return id; + }; + + // Bucket definition occurrences by their original id so a duplicate reference + // can claim a matching (as-yet-unclaimed) duplicate definition and re-id the + // pair together. defByOriginalId[id] is consumed front-to-back. + const defByOriginalId = new Map(); + for (const occ of scan.defOccurrences) { + const arr = defByOriginalId.get(occ.id); + if (arr) arr.push(occ); + else defByOriginalId.set(occ.id, [occ]); + } + // The FIRST definition for each id is the canonical keeper of that id. + const claimed = new Set(); + + for (const ref of scan.refOccurrences) { + if (!seenRefIds.has(ref.id)) { + // First reference with this id keeps it. + seenRefIds.add(ref.id); + referenceIds.push(ref.id); + continue; + } + // Duplicate reference: assign a deterministic derived id. Pair it with the + // next unclaimed duplicate definition (NOT the first keeper) carrying the + // same original id, if one exists, so the (ref, def) pairing is preserved + // 1:1. + const newId = mintId(ref.id); + refReids.push({ pos: ref.pos, node: ref.node, newId }); + seenRefIds.add(newId); + referenceIds.push(newId); + changed = true; + + const candidates = defByOriginalId.get(ref.id) ?? []; + // Skip the first occurrence (it keeps the original id); pick the first + // duplicate not already claimed. + for (let i = 1; i < candidates.length; i++) { + const cand = candidates[i]; + if (!claimed.has(cand)) { + claimed.add(cand); + definitions.set(newId, cand.node); + break; + } + } + } + + // Now place every definition under a unique id. The first occurrence of each + // original id keeps it; remaining duplicates either were paired with a + // duplicate reference above (already placed) or get a fresh standalone id. + const seenDefIds = new Set(); + for (const occ of scan.defOccurrences) { + if (claimed.has(occ)) continue; // already placed against a duplicate ref id + if (!seenDefIds.has(occ.id)) { + seenDefIds.add(occ.id); + definitions.set(occ.id, occ.node); + } else { + // Duplicate definition with no duplicate reference to pair with: keep it + // with a deterministic derived id so it is NEVER silently dropped. (It + // becomes an orphan and is then subject to the normal orphan policy — but + // only ever because it has no matching reference, never because it + // collided.) + const newId = mintId(occ.id); + definitions.set(newId, occ.node); + changed = true; + } + } + + return { referenceIds, definitions, refReids, changed }; } /** @@ -78,9 +245,14 @@ function scan(doc: ProseMirrorNode): FootnoteScan { * ping-pong forever (list moved to end -> trailing paragraph appended -> list * no longer last -> moved again ...). * - * Paste id-collision regeneration is left to the paste handler / v2; the common - * cases (orphans, missing definitions, multiple/empty/misplaced lists) are - * covered here. + * Duplicate-id collisions (two references and/or two definitions sharing one + * id — produced by importing `[^d]: a` / `[^d]: b`, or by pasting/duplicating a + * reference+definition pair) are resolved up front by resolveCollisions(): the + * duplicates are re-id'd to fresh unique ids so BOTH survive as distinct + * footnotes. This guarantees the overriding invariant — no footnoteDefinition is + * ever silently deleted by this automatic (addToHistory:false) transaction. A + * definition is only ever removed when it has NO matching reference (orphan + * policy), never because its id collided with another. */ export function footnoteSyncPlugin( isRemoteTransaction?: (tr: Transaction) => boolean, @@ -111,12 +283,33 @@ export function footnoteSyncPlugin( const info = scan(doc); + // 0) Resolve duplicate-id collisions (two references and/or two + // definitions sharing one id) by re-id'ing duplicates to fresh unique + // ids. This is the critical defense: the old last-wins Map silently + // dropped all but the last definition for a shared id; here EVERY + // definition survives with a unique id, and duplicate references are + // paired with duplicate definitions so two same-id imports/pastes yield + // two distinct footnotes instead of one. + const plan = resolveCollisions(info); + const referenceIds = plan.referenceIds; + // 1) Desired definitions: one per referenced id, in reference order, // reusing existing definition nodes (preserving their content) and // synthesizing empty ones for references that lack a definition. - const desiredDefs: ProseMirrorNode[] = info.referenceIds.map((id) => { - const existing = info.definitions.get(id); - if (existing) return existing; + // Definitions whose id has no matching reference (true orphans) are + // dropped per the existing orphan policy — but a collision is NEVER the + // cause of a drop, because collisions were re-id'd above. + const desiredDefs: ProseMirrorNode[] = referenceIds.map((id) => { + const existing = plan.definitions.get(id); + if (existing) { + // A definition paired to a re-id'd reference keeps its CONTENT but + // must carry the new id. Rewrite the id attr when it differs (cheap + // no-op when it already matches). + if (existing.attrs.id !== id) { + return defType.create({ id }, existing.content); + } + return existing; + } return defType.create({ id }, paragraphType.create()); }); @@ -129,7 +322,12 @@ export function footnoteSyncPlugin( node.type === paragraphType && node.content.size === 0; let alreadyCanonical = false; - if (!hasRefs) { + if (plan.changed) { + // A collision was detected (duplicate ids among refs/defs). The doc must + // be rewritten (re-id'd references + rebuilt list); it is never already + // canonical in this case. + alreadyCanonical = false; + } else if (!hasRefs) { // Canonical when there is no footnotesList at all. alreadyCanonical = info.lists.length === 0; } else if (info.lists.length === 1) { @@ -158,6 +356,17 @@ export function footnoteSyncPlugin( // 3) Rebuild: produce exactly ONE transaction that reaches the end-state. const tr = newState.tr; + // 3a) Re-id colliding body references FIRST. A footnoteReference is an + // inline atom, so setNodeMarkup changes only its attrs (not its size), + // leaving every other position valid for the list deletions/insert + // that follow. + for (const reid of plan.refReids) { + tr.setNodeMarkup(reid.pos, undefined, { + ...reid.node.attrs, + id: reid.newId, + }); + } + // Delete every existing footnotesList (from the end so earlier positions // stay valid while we mutate). [...info.lists] @@ -195,3 +404,101 @@ export function footnoteSyncPlugin( }, }); } + +export const footnotePastePluginKey = new PluginKey("footnotePaste"); + +/** + * Paste id-collision guard. When pasted content carries footnote reference or + * definition ids that ALREADY EXIST in the current document, regenerate those + * ids (consistently across the pasted slice, so a pasted reference and its + * definition keep pointing at each other) BEFORE the slice is inserted. + * + * Without this, pasting a reference+definition pair copied from elsewhere — or + * duplicating one in place — would merge with (or clobber) the existing footnote + * of the same id. The schema-sync plugin already guarantees no definition is + * ever silently deleted after the fact (it re-id's collisions), but regenerating + * at paste time keeps the pasted footnote cleanly separate from the start and + * avoids any transient merge. + * + * Only COLLIDING ids are remapped: a self-paste of a lone reference whose id is + * not present elsewhere is left untouched (so it still resolves to its existing + * definition). + */ +export function footnotePastePlugin(): Plugin { + return new Plugin({ + key: footnotePastePluginKey, + props: { + transformPasted(slice, view) { + // Collect ids already present in the current document. + const existing = new Set(); + view.state.doc.descendants((node) => { + if ( + node.type.name === FOOTNOTE_REFERENCE_NAME || + node.type.name === FOOTNOTE_DEFINITION_NAME + ) { + const id = node.attrs.id; + if (id) existing.add(id); + } + }); + if (existing.size === 0) return slice; + + // Build a remap (old id -> fresh id) for every COLLIDING id found in the + // pasted slice, shared by references and definitions so a pasted pair + // stays matched. A paste is a distinct local user action (not a + // shared-state convergence point), so determinism is not strictly + // required here — but we derive the new id deterministically anyway + // (deriveFootnoteId against the current doc's id set) for consistency + // with the sync/import paths and to keep Math.random off this code path. + const remap = new Map(); + const collectColliding = (node: ProseMirrorNode) => { + if ( + node.type.name === FOOTNOTE_REFERENCE_NAME || + node.type.name === FOOTNOTE_DEFINITION_NAME + ) { + const id = node.attrs.id; + if (id && existing.has(id) && !remap.has(id)) { + const newId = deriveFootnoteId(id, 2, existing); + remap.set(id, newId); + // Reserve it so a second colliding id deriving to the same base + // bumps instead of clashing. + existing.add(newId); + } + } + node.descendants(collectColliding); + }; + slice.content.descendants(collectColliding); + if (remap.size === 0) return slice; + + // Rewrite the colliding ids throughout the slice. + const rewrite = (fragment: Fragment): Fragment => { + const nodes: ProseMirrorNode[] = []; + fragment.forEach((node) => { + const isFootnote = + node.type.name === FOOTNOTE_REFERENCE_NAME || + node.type.name === FOOTNOTE_DEFINITION_NAME; + const newId = isFootnote ? remap.get(node.attrs.id) : undefined; + const newContent = node.content.size + ? rewrite(node.content) + : node.content; + if (newId) { + nodes.push( + node.type.create( + { ...node.attrs, id: newId }, + newContent, + node.marks, + ), + ); + } else if (newContent !== node.content) { + nodes.push(node.copy(newContent)); + } else { + nodes.push(node); + } + }); + return Fragment.fromArray(nodes); + }; + + return new Slice(rewrite(slice.content), slice.openStart, slice.openEnd); + }, + }, + }); +} diff --git a/packages/editor-ext/src/lib/footnote/footnote-util.ts b/packages/editor-ext/src/lib/footnote/footnote-util.ts index 41698686..7896595d 100644 --- a/packages/editor-ext/src/lib/footnote/footnote-util.ts +++ b/packages/editor-ext/src/lib/footnote/footnote-util.ts @@ -43,6 +43,61 @@ export function generateFootnoteId(): string { ); } +/** + * Derive a DETERMINISTIC unique footnote id for the k-th (k >= 2) occurrence of + * an original id `X` during collision resolution. The result is a pure function + * of (`originalId`, `occurrence`, `taken`) so that every collaborating client — + * and every import path — computes the SAME new id for the same input document. + * + * CRITICAL: this MUST NOT use Math.random()/Date.now()/uuid. Two clients that + * each make a local edit on the same duplicate-id document have to converge on + * identical ids; a random id would diverge permanently over Yjs. + * + * Scheme: the base candidate is `${originalId}__${occurrence}` (e.g. `X__2`, + * `X__3`). If that candidate already exists in `taken` (an existing footnote id, + * or one we already minted in this pass), a stable alphabetic suffix is appended + * and bumped — `X__2b`, `X__2c`, ... — until the candidate is unique. `taken` is + * itself part of the document state, so the whole walk stays deterministic. + * + * `taken` is consulted but NOT mutated here; the caller adds the returned id to + * its own seen-set before requesting the next derived id. + * + * NOTE: this implementation is intentionally duplicated in + * packages/mcp/src/lib/collaboration.ts (deriveFootnoteId) + * and MUST stay in sync with it so markdown imported through either path yields + * identical ids. + */ +export function deriveFootnoteId( + originalId: string, + occurrence: number, + taken: Set | ReadonlySet, +): string { + let candidate = `${originalId}__${occurrence}`; + // Deterministic suffix bump: b, c, d, ... then aa, ab, ... if ever exhausted. + let n = 0; + while (taken.has(candidate)) { + n += 1; + candidate = `${originalId}__${occurrence}${suffix(n)}`; + } + return candidate; +} + +/** + * Map 1 -> "b", 2 -> "c", ... 25 -> "z", 26 -> "ba", ... (base-25 over b..z, + * skipping "a" so the first bump is visibly distinct from the un-bumped base). + * Purely deterministic. + */ +function suffix(n: number): string { + let out = ""; + let x = n; + while (x > 0) { + const rem = (x - 1) % 25; + out = String.fromCharCode(98 + rem) + out; // 98 = 'b' + x = Math.floor((x - 1) / 25); + } + return out; +} + /** * Collect every `footnoteReference` id in document order. This is the single * source of truth for numbering and ordering — a pure function of the document diff --git a/packages/editor-ext/src/lib/footnote/footnote.test.ts b/packages/editor-ext/src/lib/footnote/footnote.test.ts index a68685a3..5dfc666c 100644 --- a/packages/editor-ext/src/lib/footnote/footnote.test.ts +++ b/packages/editor-ext/src/lib/footnote/footnote.test.ts @@ -304,6 +304,160 @@ describe("footnote sync plugin (orphans)", () => { editor.destroy(); }); + it("two definitions sharing an id (with two matching references) BOTH survive the first edit (no data loss)", () => { + // Reproduces the verified data-loss bug: two footnoteDefinition nodes share + // id "d", and there are two references with id "d". The OLD code built the + // definitions Map last-wins and emitted exactly one definition for the + // de-duplicated reference, so the very first keystroke's sync transaction + // deleted the whole list and rebuilt it from one definition — silently + // destroying "first" and keeping only "second". + const editor = makeEditor({ + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "a" }, + { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "d" } }, + { type: "text", text: "b" }, + { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "d" } }, + ], + }, + { + type: FOOTNOTES_LIST_NAME, + content: [ + { + type: FOOTNOTE_DEFINITION_NAME, + attrs: { id: "d" }, + content: [ + { type: "paragraph", content: [{ type: "text", text: "first" }] }, + ], + }, + { + type: FOOTNOTE_DEFINITION_NAME, + attrs: { id: "d" }, + content: [ + { type: "paragraph", content: [{ type: "text", text: "second" }] }, + ], + }, + ], + }, + ], + }); + // The first local keystroke fires the sync plugin's appendTransaction. + editor.commands.insertContentAt(1, " "); + + const doc = editor.state.doc; + // BOTH definitions survive. + expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(2); + const defTexts: string[] = []; + const defIds: string[] = []; + doc.descendants((node) => { + if (node.type.name === FOOTNOTE_DEFINITION_NAME) { + defIds.push(node.attrs.id); + defTexts.push(node.textContent); + } + }); + // No content was lost: both "first" and "second" are still present. + expect(defTexts.sort()).toEqual(["first", "second"]); + // The colliding ids were made distinct. + expect(new Set(defIds).size).toBe(2); + // Each definition's id matches exactly one reference (1:1 pairing). + const refIds: string[] = []; + doc.descendants((node) => { + if (node.type.name === FOOTNOTE_REFERENCE_NAME) refIds.push(node.attrs.id); + }); + expect(refIds.sort()).toEqual(defIds.sort()); + editor.destroy(); + }); + + it("re-ids colliding duplicates DETERMINISTICALLY (two clients converge to identical ids)", () => { + // Cross-client determinism guard. Two collaborating clients each see the + // SAME duplicate-id document and each make a local edit. The sync plugin + // runs identically on every client, so it MUST mint the SAME new ids on both + // — otherwise the two clients diverge permanently over Yjs (duplicated + // footnotes). This is exactly the blocker the previous random-id + // (generateFootnoteId / Math.random) implementation caused: it would mint + // DIFFERENT ids on each client and this assertion would fail. + const duplicateDoc = { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "a" }, + { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "d" } }, + { type: "text", text: "b" }, + { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "d" } }, + { type: "text", text: "c" }, + { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "d" } }, + ], + }, + { + type: FOOTNOTES_LIST_NAME, + content: [ + { + type: FOOTNOTE_DEFINITION_NAME, + attrs: { id: "d" }, + content: [ + { type: "paragraph", content: [{ type: "text", text: "one" }] }, + ], + }, + { + type: FOOTNOTE_DEFINITION_NAME, + attrs: { id: "d" }, + content: [ + { type: "paragraph", content: [{ type: "text", text: "two" }] }, + ], + }, + { + type: FOOTNOTE_DEFINITION_NAME, + attrs: { id: "d" }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "three" }], + }, + ], + }, + ], + }, + ], + }; + + const idsAfterLocalEdit = () => { + // A fresh editor instance = an independent "client" running the same + // plugin pipeline on the same starting document. + const editor = makeEditor(structuredClone(duplicateDoc)); + editor.commands.insertContentAt(1, " "); // local keystroke -> sync runs + const refIds: string[] = []; + const defIds: string[] = []; + editor.state.doc.descendants((node) => { + if (node.type.name === FOOTNOTE_REFERENCE_NAME) + refIds.push(node.attrs.id); + if (node.type.name === FOOTNOTE_DEFINITION_NAME) + defIds.push(node.attrs.id); + }); + editor.destroy(); + return { refIds, defIds }; + }; + + const clientA = idsAfterLocalEdit(); + const clientB = idsAfterLocalEdit(); + + // Both clients computed IDENTICAL ids (the property that makes Yjs converge). + expect(clientA.refIds).toEqual(clientB.refIds); + expect(clientA.defIds).toEqual(clientB.defIds); + + // And the ids are deterministic-derived (not random uuid-style): the keeper + // keeps "d", the duplicates become "d__2", "d__3". + expect(new Set(clientA.refIds)).toEqual(new Set(["d", "d__2", "d__3"])); + // Every definition survived with a unique id, 1:1 with the references. + expect(clientA.defIds.length).toBe(3); + expect(new Set(clientA.defIds).size).toBe(3); + expect([...clientA.refIds].sort()).toEqual([...clientA.defIds].sort()); + }); + it("removes an orphan definition with no matching reference", () => { const editor = makeEditor({ type: "doc", diff --git a/packages/editor-ext/src/lib/markdown/utils/footnote.marked.ts b/packages/editor-ext/src/lib/markdown/utils/footnote.marked.ts index ad47cc52..b47cf4a4 100644 --- a/packages/editor-ext/src/lib/markdown/utils/footnote.marked.ts +++ b/packages/editor-ext/src/lib/markdown/utils/footnote.marked.ts @@ -1,4 +1,5 @@ import { marked } from "marked"; +import { deriveFootnoteId } from "../../footnote/footnote-util"; /** * Pandoc/GFM footnote support for the marked (Markdown -> HTML) pipeline. @@ -52,6 +53,10 @@ function escapeAttr(value: string): string { return String(value).replace(/&/g, "&").replace(/"/g, """); } +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + /** * Extract `[^id]: text` definition lines from the markdown body, returning the * cleaned body plus a rendered
    (empty string when no @@ -96,6 +101,56 @@ export function extractFootnoteDefinitions(markdown: string): { return { body: markdown, section: "" }; } + // De-duplicate colliding definition ids. Two definitions sharing an id (e.g. + // `[^d]: first` / `[^d]: second`) would otherwise collapse into one footnote + // downstream (the editor's last-wins sync). Rename each colliding id to a + // DETERMINISTIC derived one AND rewrite the corresponding `[^id]` reference + // marker so the (reference, definition) pairing stays 1:1. The FIRST + // definition keeps the id and pairs with the FIRST `[^id]` marker; the Nth + // duplicate gets the derived id `${id}__${N}` and rewrites the Nth `[^id]` + // marker. If there are fewer markers than definitions, the surplus definition + // keeps a derived (orphan) id so it is never silently merged away. + // + // The id is derived (deriveFootnoteId), NOT random: importing the same + // markdown through two paths (here and the MCP mirror) must yield identical + // ids, and re-importing the same markdown twice must be stable. + let dedupedBody = bodyLines.join("\n"); + // Every original definition id is reserved up front so a derived id can never + // collide with an unrelated original id present in the document. + const taken = new Set(definitions.map((d) => d.id)); + const seenDefIds = new Map(); // original id -> how many seen + for (const def of definitions) { + const originalId = def.id; + const count = seenDefIds.get(originalId) ?? 0; + seenDefIds.set(originalId, count + 1); + if (count === 0) continue; // first definition keeps its id + + // count is the 0-based number of PRIOR occurrences; this is occurrence + // (count + 1), i.e. 2 for the first duplicate, 3 for the next, ... + const newId = deriveFootnoteId(originalId, count + 1, taken); + taken.add(newId); + def.id = newId; + + // Rewrite the NEXT still-unrewritten `[^originalId]` marker that does not + // belong to the keeper definition. After a prior duplicate rewrote its + // marker (to `[^someNewId]`), it no longer matches `[^originalId]`, so the + // remaining matches are: index 0 = the keeper's marker (left alone), index 1 + // = this duplicate's marker. Rewrite index 1. + let occurrence = 0; + let rewritten = false; + const re = new RegExp(`\\[\\^${escapeRegExp(originalId)}\\]`, "g"); + dedupedBody = dedupedBody.replace(re, (match) => { + const idx = occurrence++; + if (!rewritten && idx === 1) { + rewritten = true; + return `[^${newId}]`; + } + return match; + }); + // If there was no second marker (more definitions than references), the + // duplicate simply survives as an orphan with its fresh id — no body change. + } + const defsHtml = definitions .map((d) => { // Render the definition text as inline markdown so emphasis/links inside @@ -109,7 +164,7 @@ export function extractFootnoteDefinitions(markdown: string): { .join(""); return { - body: bodyLines.join("\n"), + body: dedupedBody, section: `
    ${defsHtml}
    `, }; } diff --git a/packages/mcp/build/lib/collaboration.js b/packages/mcp/build/lib/collaboration.js index d5e68a21..5140acee 100644 --- a/packages/mcp/build/lib/collaboration.js +++ b/packages/mcp/build/lib/collaboration.js @@ -271,6 +271,44 @@ const FOOTNOTE_REF_RE = /\[\^([^\]\s]+)\]/; function escapeFootnoteAttr(value) { return String(value).replace(/&/g, "&").replace(/"/g, """); } +function escapeFootnoteRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} +/** + * Derive a DETERMINISTIC unique footnote id for the k-th (k >= 2) occurrence of + * an original id `X` during definition dedup. + * + * EXACT MIRROR of editor-ext `deriveFootnoteId` + * (packages/editor-ext/src/lib/footnote/footnote-util.ts). These two copies MUST + * STAY IN SYNC: the same markdown imported through the editor and through this + * MCP path has to produce identical ids, and the sync plugin (which re-ids on + * every collaborating client) relies on the same scheme to converge. NEVER use + * Math.random()/Date.now()/uuid here — a random id would diverge across clients. + * + * Scheme: base candidate `${originalId}__${occurrence}` (e.g. `X__2`), bumped + * with a stable alphabetic suffix (`X__2b`, `X__2c`, ...) until it is not in + * `taken` (the set of ids already present / already minted — pure doc state). + */ +function deriveFootnoteId(originalId, occurrence, taken) { + let candidate = `${originalId}__${occurrence}`; + let n = 0; + while (taken.has(candidate)) { + n += 1; + candidate = `${originalId}__${occurrence}${footnoteSuffix(n)}`; + } + return candidate; +} +/** Map 1 -> "b", 2 -> "c", ... (mirror of editor-ext `suffix`). */ +function footnoteSuffix(n) { + let out = ""; + let x = n; + while (x > 0) { + const rem = (x - 1) % 25; + out = String.fromCharCode(98 + rem) + out; // 98 = 'b' + x = Math.floor((x - 1) / 25); + } + return out; +} const footnoteRefMarkedExtension = { name: "footnoteRef", level: "inline", @@ -319,11 +357,43 @@ function extractFootnotes(markdown) { } if (defs.length === 0) return { body: markdown, section: "" }; + // De-duplicate colliding definition ids (mirror of editor-ext + // extractFootnoteDefinitions). Two definitions sharing an id would otherwise + // collapse into one footnote downstream; rename each colliding id to a + // DETERMINISTIC derived one (NOT random) and rewrite the corresponding `[^id]` + // marker so the (reference, definition) pairing stays 1:1. Determinism lets + // the same markdown imported here and via the editor produce identical ids. + let dedupedBody = bodyLines.join("\n"); + const taken = new Set(defs.map((d) => d.id)); + const seenDefIds = new Map(); + for (const def of defs) { + const originalId = def.id; + const count = seenDefIds.get(originalId) ?? 0; + seenDefIds.set(originalId, count + 1); + if (count === 0) + continue; // first definition keeps its id + const newId = deriveFootnoteId(originalId, count + 1, taken); + taken.add(newId); + def.id = newId; + // Remaining `[^originalId]` matches: index 0 = keeper's marker (left alone), + // index 1 = this duplicate's marker. Rewrite index 1. + let occurrence = 0; + let rewritten = false; + const re = new RegExp(`\\[\\^${escapeFootnoteRegExp(originalId)}\\]`, "g"); + dedupedBody = dedupedBody.replace(re, (match) => { + const idx = occurrence++; + if (!rewritten && idx === 1) { + rewritten = true; + return `[^${newId}]`; + } + return match; + }); + } const inner = defs .map((d) => `

    ${marked.parseInline(d.text || "")}

    `) .join(""); return { - body: bodyLines.join("\n"), + body: dedupedBody, section: `
    ${inner}
    `, }; } diff --git a/packages/mcp/src/lib/collaboration.ts b/packages/mcp/src/lib/collaboration.ts index 0e6e80a3..6f0ad011 100644 --- a/packages/mcp/src/lib/collaboration.ts +++ b/packages/mcp/src/lib/collaboration.ts @@ -306,6 +306,51 @@ function escapeFootnoteAttr(value: string): string { return String(value).replace(/&/g, "&").replace(/"/g, """); } +function escapeFootnoteRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Derive a DETERMINISTIC unique footnote id for the k-th (k >= 2) occurrence of + * an original id `X` during definition dedup. + * + * EXACT MIRROR of editor-ext `deriveFootnoteId` + * (packages/editor-ext/src/lib/footnote/footnote-util.ts). These two copies MUST + * STAY IN SYNC: the same markdown imported through the editor and through this + * MCP path has to produce identical ids, and the sync plugin (which re-ids on + * every collaborating client) relies on the same scheme to converge. NEVER use + * Math.random()/Date.now()/uuid here — a random id would diverge across clients. + * + * Scheme: base candidate `${originalId}__${occurrence}` (e.g. `X__2`), bumped + * with a stable alphabetic suffix (`X__2b`, `X__2c`, ...) until it is not in + * `taken` (the set of ids already present / already minted — pure doc state). + */ +function deriveFootnoteId( + originalId: string, + occurrence: number, + taken: Set, +): string { + let candidate = `${originalId}__${occurrence}`; + let n = 0; + while (taken.has(candidate)) { + n += 1; + candidate = `${originalId}__${occurrence}${footnoteSuffix(n)}`; + } + return candidate; +} + +/** Map 1 -> "b", 2 -> "c", ... (mirror of editor-ext `suffix`). */ +function footnoteSuffix(n: number): string { + let out = ""; + let x = n; + while (x > 0) { + const rem = (x - 1) % 25; + out = String.fromCharCode(98 + rem) + out; // 98 = 'b' + x = Math.floor((x - 1) / 25); + } + return out; +} + const footnoteRefMarkedExtension = { name: "footnoteRef", level: "inline" as const, @@ -356,6 +401,39 @@ function extractFootnotes(markdown: string): { else bodyLines.push(line); } if (defs.length === 0) return { body: markdown, section: "" }; + + // De-duplicate colliding definition ids (mirror of editor-ext + // extractFootnoteDefinitions). Two definitions sharing an id would otherwise + // collapse into one footnote downstream; rename each colliding id to a + // DETERMINISTIC derived one (NOT random) and rewrite the corresponding `[^id]` + // marker so the (reference, definition) pairing stays 1:1. Determinism lets + // the same markdown imported here and via the editor produce identical ids. + let dedupedBody = bodyLines.join("\n"); + const taken = new Set(defs.map((d) => d.id)); + const seenDefIds = new Map(); + for (const def of defs) { + const originalId = def.id; + const count = seenDefIds.get(originalId) ?? 0; + seenDefIds.set(originalId, count + 1); + if (count === 0) continue; // first definition keeps its id + const newId = deriveFootnoteId(originalId, count + 1, taken); + taken.add(newId); + def.id = newId; + // Remaining `[^originalId]` matches: index 0 = keeper's marker (left alone), + // index 1 = this duplicate's marker. Rewrite index 1. + let occurrence = 0; + let rewritten = false; + const re = new RegExp(`\\[\\^${escapeFootnoteRegExp(originalId)}\\]`, "g"); + dedupedBody = dedupedBody.replace(re, (match) => { + const idx = occurrence++; + if (!rewritten && idx === 1) { + rewritten = true; + return `[^${newId}]`; + } + return match; + }); + } + const inner = defs .map( (d) => @@ -365,7 +443,7 @@ function extractFootnotes(markdown: string): { ) .join(""); return { - body: bodyLines.join("\n"), + body: dedupedBody, section: `
    ${inner}
    `, }; } diff --git a/packages/mcp/test/unit/footnotes.test.mjs b/packages/mcp/test/unit/footnotes.test.mjs index 4b1ee6ab..df45a7b9 100644 --- a/packages/mcp/test/unit/footnotes.test.mjs +++ b/packages/mcp/test/unit/footnotes.test.mjs @@ -90,6 +90,39 @@ test("JSON -> MD -> JSON preserves footnote ids and text", async () => { assert.match(md2, /\[\^fn2\]: Second note\./); }); +test("duplicate-id markdown dedups DETERMINISTICALLY (same input -> same ids)", async () => { + // The MCP import must derive duplicate ids deterministically (NOT random) so + // the same markdown imported here and via the editor produces identical ids, + // and re-importing is stable. This is the test that would FAIL on the old + // Math.random()/Date.now() implementation. + const md = [ + "See[^d] one[^d] two[^d].", + "", + "[^d]: first", + "[^d]: second", + "[^d]: third", + ].join("\n"); + + const idsOf = async () => { + const json = await markdownToProseMirror(md); + const refs = findAll(json, "footnoteReference").map((r) => r.attrs.id); + const defs = findAll(json, "footnoteDefinition").map((d) => d.attrs.id); + return { refs, defs }; + }; + + const a = await idsOf(); + const b = await idsOf(); + + // Identical across runs. + assert.deepEqual(a.refs, b.refs); + assert.deepEqual(a.defs, b.defs); + // Deterministic derived scheme: keeper "d", duplicates "d__2", "d__3". + assert.deepEqual([...a.defs].sort(), ["d", "d__2", "d__3"]); + // 1:1 reference <-> definition pairing, all distinct. + assert.equal(new Set(a.defs).size, 3); + assert.deepEqual([...a.refs].sort(), [...a.defs].sort()); +}); + test("a [^id]: line inside a fenced code block is NOT treated as a definition", async () => { // Markdown that DOCUMENTS footnote syntax inside a code fence. The example // definition line must be preserved verbatim inside the code block and not From 587a940959acc4d1c1b5f4cc67baa6eac711f0ff Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sat, 20 Jun 2026 15:44:08 +0300 Subject: [PATCH 4/5] perf+fix(footnotes): minimal-diff sync (no concurrent-edit loss); cache numbering Release-cycle review found two hardening gaps: - The sync plugin deleted+rebuilt the WHOLE footnotesList on any reorder/orphan, replacing every definition's Yjs subtree -> a collaborator typing in a definition could lose in-flight characters on merge. Rework to targeted, minimal mutations: attr-only setNodeMarkup for collision re-ids, delete only genuine orphans, insert only genuinely-missing definitions (at the list end, not shifting existing subtrees), and consolidate multiple lists only in the abnormal paste/merge case. An unchanged (correct id, referenced) definition is left completely untouched. Numbering is decoration-only, so physical list order may drift after a reorder (accepted) while displayed numbers stay correct. Invariants preserved (reviewed + tested): one SYNC_META transaction, null when canonical (terminates), deterministic deriveFootnoteId, remote-skip -> no re-introduced freeze or divergence. - computeFootnoteNumbers ran per-NodeView-render (O(n^2)/keystroke in big docs). The numbering plugin now caches the number map in its state (computed once per docChanged); NodeViews read it O(1) via getFootnoteNumber. Tests: no-rebuild-on-reorder asserts unchanged definition node subtrees are identity-preserved; isRemoteTransaction skip; enableSync:false read-only; cache correctness. Browser re-smoke: insert (no freeze), number, persist across reload, cascade delete all pass. Co-Authored-By: Claude Opus 4.8 --- .../footnote/footnote-definition-view.tsx | 7 +- .../footnote/footnote-reference-view.tsx | 9 +- .../src/lib/footnote/footnote-numbering.ts | 56 +++- .../src/lib/footnote/footnote-sync.ts | 284 +++++++++++++----- .../src/lib/footnote/footnote.test.ts | 258 ++++++++++++++++ 5 files changed, 524 insertions(+), 90 deletions(-) diff --git a/apps/client/src/features/editor/components/footnote/footnote-definition-view.tsx b/apps/client/src/features/editor/components/footnote/footnote-definition-view.tsx index b5aa5486..2685fbc3 100644 --- a/apps/client/src/features/editor/components/footnote/footnote-definition-view.tsx +++ b/apps/client/src/features/editor/components/footnote/footnote-definition-view.tsx @@ -1,6 +1,6 @@ import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react"; import { useTranslation } from "react-i18next"; -import { computeFootnoteNumbers } from "@docmost/editor-ext"; +import { getFootnoteNumber } from "@docmost/editor-ext"; import classes from "./footnote.module.css"; /** @@ -13,8 +13,9 @@ export default function FootnoteDefinitionView(props: NodeViewProps) { const { t } = useTranslation(); const id = node.attrs.id as string; - const numbers = computeFootnoteNumbers(editor.state.doc); - const number = numbers.get(id) ?? "?"; + // Read the cached number from the numbering plugin (computed once per doc + // change) rather than recomputing the whole map on every render. + const number = getFootnoteNumber(editor.state, id) ?? "?"; const handleBack = (e: React.MouseEvent) => { e.preventDefault(); diff --git a/apps/client/src/features/editor/components/footnote/footnote-reference-view.tsx b/apps/client/src/features/editor/components/footnote/footnote-reference-view.tsx index c75766da..7ea9e87d 100644 --- a/apps/client/src/features/editor/components/footnote/footnote-reference-view.tsx +++ b/apps/client/src/features/editor/components/footnote/footnote-reference-view.tsx @@ -11,7 +11,7 @@ import { } from "@floating-ui/dom"; import { FOOTNOTE_DEFINITION_NAME, - computeFootnoteNumbers, + getFootnoteNumber, } from "@docmost/editor-ext"; import { ActionIcon } from "@mantine/core"; import { IconArrowDown } from "@tabler/icons-react"; @@ -45,9 +45,10 @@ export default function FootnoteReferenceView(props: NodeViewProps) { const popoverRef = useRef(null); const [open, setOpen] = useState(false); - // Number is derived (not stored) — recompute from the current doc. - const numbers = computeFootnoteNumbers(editor.state.doc); - const number = numbers.get(id) ?? "?"; + // Number is derived (not stored). Read it from the numbering plugin's cached + // map (computed once per doc change) instead of walking the whole document on + // every render — recomputing per NodeView per render was O(n^2) per keystroke. + const number = getFootnoteNumber(editor.state, id) ?? "?"; const defText = open ? getDefinitionText(editor, id) : ""; const position = useCallback(() => { diff --git a/packages/editor-ext/src/lib/footnote/footnote-numbering.ts b/packages/editor-ext/src/lib/footnote/footnote-numbering.ts index f93a3b08..8a487b1f 100644 --- a/packages/editor-ext/src/lib/footnote/footnote-numbering.ts +++ b/packages/editor-ext/src/lib/footnote/footnote-numbering.ts @@ -1,4 +1,4 @@ -import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; import { Decoration, DecorationSet } from "@tiptap/pm/view"; import { Node as ProseMirrorNode } from "@tiptap/pm/model"; import { @@ -7,7 +7,23 @@ import { computeFootnoteNumbers, } from "./footnote-util"; -export const footnoteNumberingPluginKey = new PluginKey("footnoteNumbering"); +export const footnoteNumberingPluginKey = new PluginKey( + "footnoteNumbering", +); + +/** + * Cached state of the numbering plugin. Both the displayed-number map and the + * decoration set are computed ONCE per doc-changing transaction (in `apply`) and + * cached here, so NodeViews can read a footnote's number by id without walking + * the whole document on every React render (which was O(n^2) per keystroke in + * large docs). + */ +interface FootnoteNumberingState { + /** referenceId -> 1-based display number, for the current doc. */ + numbers: Map; + /** Decorations rendering those numbers (refs + definitions). */ + decorations: DecorationSet; +} /** * Build the decoration set for footnote numbers. Pure function of the document: @@ -18,6 +34,17 @@ export const footnoteNumberingPluginKey = new PluginKey("footnoteNumbering"); * with no document mutation. */ export function buildFootnoteDecorations(doc: ProseMirrorNode): DecorationSet { + return buildFootnoteNumberingState(doc).decorations; +} + +/** + * Compute both the number map AND the decorations for `doc` in a single walk. + * The plugin caches the result so NodeViews can read numbers without + * recomputing. + */ +function buildFootnoteNumberingState( + doc: ProseMirrorNode, +): FootnoteNumberingState { const numbers = computeFootnoteNumbers(doc); const decorations: Decoration[] = []; @@ -46,7 +73,21 @@ export function buildFootnoteDecorations(doc: ProseMirrorNode): DecorationSet { } }); - return DecorationSet.create(doc, decorations); + return { numbers, decorations: DecorationSet.create(doc, decorations) }; +} + +/** + * Read the cached footnote number for `id` from the numbering plugin's state. + * This is the source NodeViews should use instead of calling + * computeFootnoteNumbers() on every render (that walked the whole doc per + * NodeView per render = O(n^2) per keystroke). Returns undefined if the plugin + * is not installed or the id has no number yet. + */ +export function getFootnoteNumber( + state: EditorState, + id: string, +): number | undefined { + return footnoteNumberingPluginKey.getState(state)?.numbers.get(id); } /** @@ -59,16 +100,19 @@ export function footnoteNumberingPlugin(): Plugin { key: footnoteNumberingPluginKey, state: { init(_, { doc }) { - return buildFootnoteDecorations(doc); + return buildFootnoteNumberingState(doc); }, apply(tr, old) { + // Recompute (and re-cache) only when the document actually changed, so + // the number map NodeViews read stays current on every edit while + // non-doc transactions (selection, etc.) reuse the cache for free. if (!tr.docChanged) return old; - return buildFootnoteDecorations(tr.doc); + return buildFootnoteNumberingState(tr.doc); }, }, props: { decorations(state) { - return this.getState(state); + return footnoteNumberingPluginKey.getState(state)?.decorations; }, }, }); diff --git a/packages/editor-ext/src/lib/footnote/footnote-sync.ts b/packages/editor-ext/src/lib/footnote/footnote-sync.ts index 33258590..505a60d0 100644 --- a/packages/editor-ext/src/lib/footnote/footnote-sync.ts +++ b/packages/editor-ext/src/lib/footnote/footnote-sync.ts @@ -293,107 +293,237 @@ export function footnoteSyncPlugin( const plan = resolveCollisions(info); const referenceIds = plan.referenceIds; - // 1) Desired definitions: one per referenced id, in reference order, - // reusing existing definition nodes (preserving their content) and - // synthesizing empty ones for references that lack a definition. - // Definitions whose id has no matching reference (true orphans) are - // dropped per the existing orphan policy — but a collision is NEVER the - // cause of a drop, because collisions were re-id'd above. - const desiredDefs: ProseMirrorNode[] = referenceIds.map((id) => { - const existing = plan.definitions.get(id); - if (existing) { - // A definition paired to a re-id'd reference keeps its CONTENT but - // must carry the new id. Rewrite the id attr when it differs (cheap - // no-op when it already matches). - if (existing.attrs.id !== id) { - return defType.create({ id }, existing.content); - } - return existing; - } - return defType.create({ id }, paragraphType.create()); - }); + // The set of ids that must have a definition, in reference order (after + // collision re-id). De-duplicated already by resolveCollisions. + const referenceIdSet = new Set(referenceIds); - // 2) Determine whether the document already matches the desired end-state. - const hasRefs = desiredDefs.length > 0; + // 1) For each definition occurrence, compute the id it should END UP with + // (which differs from its current id only when collision resolution + // re-id'd it). plan.definitions maps a FINAL id -> the chosen node, so + // we invert it by node identity to recover each occurrence's target id. + const finalIdByNode = new Map(); + for (const [id, node] of plan.definitions) finalIdByNode.set(node, id); - // Is the existing single list already exactly the desired list, placed - // after all meaningful content (nothing but empty paragraphs after it)? const isEmptyParagraph = (node: ProseMirrorNode) => node.type === paragraphType && node.content.size === 0; - let alreadyCanonical = false; - if (plan.changed) { - // A collision was detected (duplicate ids among refs/defs). The doc must - // be rewritten (re-id'd references + rebuilt list); it is never already - // canonical in this case. - alreadyCanonical = false; - } else if (!hasRefs) { - // Canonical when there is no footnotesList at all. - alreadyCanonical = info.lists.length === 0; - } else if (info.lists.length === 1) { - const { pos, node } = info.lists[0]; - // Same definitions, same order, same identity (no rewrite needed)? - const sameDefs = - node.childCount === desiredDefs.length && - desiredDefs.every((d, i) => node.child(i) === d); + // 2) Classify every existing definition occurrence: + // - reId: keep the node in place, only change its id attr (collision). + // - orphan: delete it (its final id has no matching reference). + // A definition that already carries the right id and is referenced is + // left COMPLETELY untouched (its Yjs subtree is preserved). This is the + // core of the data-loss fix: a pure reference reorder produces NO + // mutation of any definition subtree. + interface DefReid { + pos: number; + node: ProseMirrorNode; + newId: string; + } + const defReids: DefReid[] = []; + const orphanDefs: DefOccurrence[] = []; + // Track which referenced ids already have a surviving (non-orphan) + // definition, so we can synthesize the genuinely missing ones. + const satisfiedIds = new Set(); + // Choose a "primary" list to receive inserts/migrated defs: the LAST list + // whose placement is canonical (only empty paragraphs follow it), else the + // last list, else none. New defs and consolidated defs land here. + for (const occ of info.defOccurrences) { + const finalId = finalIdByNode.get(occ.node) ?? occ.id; + if (!referenceIdSet.has(finalId)) { + orphanDefs.push(occ); + continue; + } + if (occ.id !== finalId) { + defReids.push({ pos: occ.pos, node: occ.node, newId: finalId }); + } + satisfiedIds.add(finalId); + } - // Placement: only empty paragraphs may follow the list. - const listEnd = pos + node.nodeSize; - let onlyEmptyParasAfter = true; + // 3) Referenced ids with no surviving definition need a fresh empty one. + const missingIds = referenceIds.filter((id) => !satisfiedIds.has(id)); + + // 4) Determine list topology. + const hasRefs = referenceIds.length > 0; + + // Pick the primary list: prefer the last canonically-placed list. + const listIsTrailing = (listPos: number, listNode: ProseMirrorNode) => { + const listEnd = listPos + listNode.nodeSize; + let ok = true; doc.nodesBetween(listEnd, doc.content.size, (child, childPos) => { - // Only inspect top-level children that start at/after the list end. - if (childPos >= listEnd && child !== node) { - if (!isEmptyParagraph(child)) onlyEmptyParasAfter = false; + if (childPos >= listEnd && child !== listNode) { + if (!isEmptyParagraph(child)) ok = false; } return false; // do not descend }); - - alreadyCanonical = sameDefs && onlyEmptyParasAfter; + return ok; + }; + let primaryList: { pos: number; node: ProseMirrorNode } | null = null; + for (let i = info.lists.length - 1; i >= 0; i--) { + if (listIsTrailing(info.lists[i].pos, info.lists[i].node)) { + primaryList = info.lists[i]; + break; + } } + if (!primaryList && info.lists.length > 0) { + primaryList = info.lists[info.lists.length - 1]; + } + // Extra lists (everything except the primary) must be consolidated away. + const extraLists = info.lists.filter((l) => l !== primaryList); + const inExtraList = (pos: number) => + extraLists.some((l) => pos > l.pos && pos < l.pos + l.node.nodeSize); - if (alreadyCanonical) return null; + // Definitions inside an extra list are migrated (recreated with the right + // id) into the primary list, so drop their in-place re-id markups — the + // whole extra list is deleted below and the markup would be wasted. + const defReidsToApply = defReids.filter((r) => !inExtraList(r.pos)); - // 3) Rebuild: produce exactly ONE transaction that reaches the end-state. + // 5) Decide whether anything must change. The document is canonical when: + // - no collisions were resolved (refs or defs), AND + // - no orphan definitions, AND + // - no missing definitions, AND + // - exactly the right number of lists (0 when no refs, else 1) AND the + // single list is canonically placed (trailing). + const noChangeNeeded = + !plan.changed && + defReids.length === 0 && + orphanDefs.length === 0 && + missingIds.length === 0 && + extraLists.length === 0 && + (hasRefs + ? info.lists.length === 1 && primaryList !== null + : info.lists.length === 0); + + if (noChangeNeeded) return null; + + // 6) Apply the targeted, minimal mutations in ONE transaction. We never + // delete-and-recreate an unchanged definition subtree; we only: + // (a) re-id specific colliding references and definitions (attr-only), + // (b) delete genuine orphan definitions and extra/empty lists, + // (c) insert genuinely-missing empty definitions and migrate defs out + // of extra lists into the primary list, + // (d) create the primary list if references exist but none does yet. const tr = newState.tr; - // 3a) Re-id colliding body references FIRST. A footnoteReference is an - // inline atom, so setNodeMarkup changes only its attrs (not its size), - // leaving every other position valid for the list deletions/insert - // that follow. + // 6a) Re-id colliding references (inline atoms: attr-only, size-stable). for (const reid of plan.refReids) { - tr.setNodeMarkup(reid.pos, undefined, { + tr.setNodeMarkup(tr.mapping.map(reid.pos), undefined, { + ...reid.node.attrs, + id: reid.newId, + }); + } + // 6b) Re-id colliding definitions IN PLACE (attr-only). This preserves the + // definition's content subtree — never delete+recreate it. + for (const reid of defReidsToApply) { + tr.setNodeMarkup(tr.mapping.map(reid.pos), undefined, { ...reid.node.attrs, id: reid.newId, }); } - // Delete every existing footnotesList (from the end so earlier positions - // stay valid while we mutate). - [...info.lists] - .sort((a, b) => b.pos - a.pos) - .forEach(({ pos, node }) => { - tr.delete(pos, pos + node.nodeSize); + // 6c) Migrate non-orphan definitions out of every extra list into the + // primary list (or, if there is no primary list, into a new one we + // build), then delete the extra (now drained) lists. This is the only + // path that moves a definition subtree, and it runs ONLY in the + // abnormal multi-list case (paste/collab merge) — never on a plain + // reorder, which keeps a single list untouched. + const migrated: ProseMirrorNode[] = []; + for (const extra of extraLists) { + extra.node.forEach((defChild) => { + if (defChild.type !== defType) return; + const finalId = finalIdByNode.get(defChild) ?? defChild.attrs.id; + if (!referenceIdSet.has(finalId)) return; // orphan: drop it + migrated.push( + defChild.attrs.id === finalId + ? defChild + : defType.create({ id: finalId }, defChild.content), + ); + }); + } + + // 6c-bis) The definitions to INSERT into the primary list: migrated defs + // from extra lists + freshly synthesized empty defs for references + // that have no definition at all. Computed before deletions so we can + // decide whether the primary list would be left empty. + const toInsert: ProseMirrorNode[] = [ + ...migrated, + ...missingIds.map((id) => + defType.create({ id }, paragraphType.create()), + ), + ]; + + // Does the primary list keep at least one definition after we strip its + // orphans AND counting the defs we are about to insert? If it ends up + // empty (an empty footnotesList is invalid schema), delete the WHOLE list + // instead of leaving a hollow shell. Only the primary list can receive + // inserts; extra lists are always deleted wholesale. + let primarySurvivors = 0; + if (primaryList) { + primaryList.node.forEach((defChild) => { + if (defChild.type !== defType) return; + const finalId = finalIdByNode.get(defChild) ?? defChild.attrs.id; + if (referenceIdSet.has(finalId)) primarySurvivors += 1; + }); + } + const primaryWillBeEmpty = + !!primaryList && primarySurvivors === 0 && toInsert.length === 0; + + // 6d) Delete orphan definitions, extra lists, and any list that would be + // left empty. Sort deletions from the end so earlier positions stay + // valid; map through tr.mapping to account for the (size-stable) re-id + // markups and earlier deletions. + const deletions: Array<{ from: number; to: number }> = []; + const wholeListDeletes = new Set(extraLists); + if (primaryWillBeEmpty && primaryList) wholeListDeletes.add(primaryList); + + for (const occ of orphanDefs) { + // Skip orphans inside a list that is being deleted wholesale. + const inWholeDeleted = [...wholeListDeletes].some( + (l) => occ.pos > l.pos && occ.pos < l.pos + l.node.nodeSize, + ); + if (inWholeDeleted) continue; + deletions.push({ from: occ.pos, to: occ.pos + occ.node.nodeSize }); + } + for (const l of wholeListDeletes) { + deletions.push({ from: l.pos, to: l.pos + l.node.nodeSize }); + } + deletions + .sort((a, b) => b.from - a.from) + .forEach(({ from, to }) => { + tr.delete(tr.mapping.map(from), tr.mapping.map(to)); }); - if (hasRefs) { - // Insert a single canonical list holding the desired definitions. Place - // it after the last meaningful (non-empty-paragraph) top-level block, so - // it lands before any trailing empty paragraph the trailing-node plugin - // maintains. This keeps both plugins idempotent. - const mappedDoc = tr.doc; - let insertPos = mappedDoc.content.size; - for (let i = mappedDoc.childCount - 1; i >= 0; i--) { - const child = mappedDoc.child(i); - if (isEmptyParagraph(child)) { - // skip trailing empty paragraphs; insert before them - insertPos -= child.nodeSize; - } else { - break; - } - } + // If we deleted the primary list wholesale, it can no longer receive the + // inserts below — null it out so a fresh list is created when needed. + if (primaryWillBeEmpty) primaryList = null; - const merged = listType.create(null, Fragment.fromArray(desiredDefs)); - tr.insert(insertPos, merged); + // 6e) Insert the migrated + synthesized definitions. + if (hasRefs) { + if (primaryList) { + if (toInsert.length > 0) { + // Append at the end of the (mapped) primary list, just before its + // closing token, so its existing definition subtrees are untouched. + // We only changed attrs (size-stable) and deleted OTHER nodes, so + // mapping the original list-end position forward lands at the same + // boundary; -1 puts us just inside the list's closing token. + const insertAt = + tr.mapping.map(primaryList.pos + primaryList.node.nodeSize) - 1; + tr.insert(insertAt, Fragment.fromArray(toInsert)); + } + } else { + // No usable list exists yet but references do — create one holding the + // migrated + synthesized definitions, placed after the last meaningful + // (non-empty-paragraph) top-level block so it sits before any trailing + // empty paragraph the trailing-node plugin maintains. + const mappedDoc = tr.doc; + let insertPos = mappedDoc.content.size; + for (let i = mappedDoc.childCount - 1; i >= 0; i--) { + const child = mappedDoc.child(i); + if (isEmptyParagraph(child)) insertPos -= child.nodeSize; + else break; + } + const list = listType.create(null, Fragment.fromArray(toInsert)); + tr.insert(insertPos, list); + } } if (!tr.docChanged) return null; diff --git a/packages/editor-ext/src/lib/footnote/footnote.test.ts b/packages/editor-ext/src/lib/footnote/footnote.test.ts index 5dfc666c..9ecf9a55 100644 --- a/packages/editor-ext/src/lib/footnote/footnote.test.ts +++ b/packages/editor-ext/src/lib/footnote/footnote.test.ts @@ -6,10 +6,13 @@ import { Text } from "@tiptap/extension-text"; import { Superscript } from "@tiptap/extension-superscript"; import { Plugin, PluginKey } from "@tiptap/pm/state"; import { Node as PMNode } from "@tiptap/pm/model"; +import { EditorState } from "@tiptap/pm/state"; import { FootnoteReference } from "./footnote-reference"; import { FootnotesList } from "./footnotes-list"; import { FootnoteDefinition } from "./footnote-definition"; import { TrailingNode } from "../trailing-node"; +import { footnoteSyncPlugin } from "./footnote-sync"; +import { getFootnoteNumber } from "./footnote-numbering"; import { computeFootnoteNumbers, collectReferenceIds, @@ -688,3 +691,258 @@ describe("footnote sync plugin (no infinite loop — live editor)", () => { editor.destroy(); }); }); + +/** + * Data-loss-window regression guard (Fix 1). A pure reference REORDER must not + * cause the sync plugin to delete-and-recreate any definition subtree — doing so + * (the previous behaviour) would, through Yjs, replace the CRDT subtree of every + * definition and could lose a collaborator's in-flight characters on merge. + * + * Numbering is decoration-only (footnote-numbering.ts derives numbers from + * reference order), so the bottom list's PHYSICAL order need not match reference + * order for the displayed numbers to be correct. We therefore assert: the + * existing definition NODE INSTANCES are preserved (identity-equal) after the + * sync pass, AND the derived numbers follow the new reference order. + */ +describe("footnote sync plugin (no rebuild on reorder — data-loss guard)", () => { + function reorderedDoc() { + // The "out of order" end-state of a reorder: references occur as [b, a] but + // the bottom list still physically holds definitions in [a, b] order. This + // is exactly the situation a reference reorder produces (decoration-only + // numbering keeps the displayed numbers correct without physically moving + // the definition subtrees). The sync plugin must leave the definitions + // ALONE here — no delete/recreate of any definition subtree. + return { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "p" }, + { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "b" } }, + { type: "text", text: "q" }, + { 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: "A" }] }, + ], + }, + { + type: FOOTNOTE_DEFINITION_NAME, + attrs: { id: "b" }, + content: [ + { type: "paragraph", content: [{ type: "text", text: "B" }] }, + ], + }, + ], + }, + ], + }; + } + + function getDefNodesById(doc: PMNode): Map { + const m = new Map(); + doc.descendants((node) => { + if (node.type.name === FOOTNOTE_DEFINITION_NAME) m.set(node.attrs.id, node); + }); + return m; + } + + it("does NOT delete/recreate existing definition subtrees for an out-of-order list (numbers still correct)", () => { + const editor = makeEditor(reorderedDoc()); + + // Capture the exact definition NODE INSTANCES before any sync pass. + const before = getDefNodesById(editor.state.doc); + // Sanity: both carry their content right now. + expect(before.get("a")!.textContent).toBe("A"); + expect(before.get("b")!.textContent).toBe("B"); + + // Trigger a local edit elsewhere in the body so the sync plugin runs. + editor.commands.insertContentAt(1, "z"); + + const doc = editor.state.doc; + + // Reference order is [b, a]; the displayed numbers follow reference order + // (decoration-only numbering): b -> 1, a -> 2 — regardless of physical list + // order. + expect(collectReferenceIds(doc)).toEqual(["b", "a"]); + const numbers = computeFootnoteNumbers(doc); + expect(numbers.get("b")).toBe(1); + expect(numbers.get("a")).toBe(2); + + // CRITICAL regression guard: both definitions still exist and are the SAME + // node instances as before the edit — the plugin did NOT delete/recreate the + // list (which would replace every definition's CRDT subtree and open the + // concurrent-edit data-loss window). Identity equality proves the subtree + // was preserved verbatim. + const after = getDefNodesById(doc); + expect(after.get("a")).toBe(before.get("a")); + expect(after.get("b")).toBe(before.get("b")); + // Content intact, exactly one list, both definitions present. + expect(after.get("a")!.textContent).toBe("A"); + expect(after.get("b")!.textContent).toBe("B"); + expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(1); + expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(2); + + editor.destroy(); + }); +}); + +/** + * Sync-plugin guard paths that are awkward to exercise through a live editor: + * the remote-transaction skip and the enableSync:false (read-only) mode. + */ +describe("footnote sync plugin (guards)", () => { + // Build a non-canonical document (an orphan reference with no definition) so a + // sync pass would normally append a transaction. + function nonCanonicalState() { + const schema = getSchema(extensions); + const doc = PMNode.fromJSON(schema, { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "x" }, + { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "orphan" } }, + ], + }, + ], + }); + return EditorState.create({ schema, doc }); + } + + it("isRemoteTransaction => true: appendTransaction returns null (no rebuild on remote txns)", () => { + // The sync plugin must SKIP remote/collab transactions so orphan cleanup and + // structural rewrites only ever run on local edits. + const plugin = footnoteSyncPlugin(() => true); + const state = nonCanonicalState(); + + // Produce a doc-changing transaction (insert a space) and feed it to the + // plugin's appendTransaction exactly as ProseMirror would. + const tr = state.tr.insertText(" ", 1); + const newState = state.apply(tr); + const result = plugin.spec.appendTransaction!( + [tr], + state, + newState, + ); + expect(result).toBeNull(); + }); + + it("isRemoteTransaction => false: appendTransaction DOES rebuild (sanity)", () => { + // Control: with a local (non-remote) transaction the same non-canonical doc + // triggers a sync transaction, proving the null above is the remote guard + // and not a no-op everywhere. + const plugin = footnoteSyncPlugin(() => false); + const state = nonCanonicalState(); + const tr = state.tr.insertText(" ", 1); + const newState = state.apply(tr); + const result = plugin.spec.appendTransaction!([tr], state, newState); + expect(result).not.toBeNull(); + expect(result!.docChanged).toBe(true); + }); + + it("enableSync:false: the plugin never mutates the doc (read-only viewer)", () => { + // Build an editor with sync disabled. An orphan reference (no definition) + // must NOT trigger a definition insertion — the document is left untouched. + const editor = new Editor({ + extensions: [ + Document, + Paragraph, + Text, + FootnoteReference.configure({ enableSync: false }), + FootnotesList, + FootnoteDefinition, + ], + content: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "x" }, + { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "orphan" } }, + ], + }, + ], + }, + }); + // A local edit that would normally trigger orphan-definition synthesis. + editor.commands.insertContentAt(1, "y"); + + const doc = editor.state.doc; + // No definition (and no list) was ever created — sync is disabled. + expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(0); + expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(0); + // Numbering decorations still work: the reference is numbered 1. + expect(getFootnoteNumber(editor.state, "orphan")).toBe(1); + editor.destroy(); + }); +}); + +/** + * Numbering cache (Fix 2). NodeViews must read footnote numbers from the + * numbering plugin's cached map (updated once per doc change) rather than + * recomputing the whole map per render. We assert the cache exists, is correct, + * and stays current across edits. + */ +describe("footnote numbering cache", () => { + it("exposes correct numbers via getFootnoteNumber and updates on edits", () => { + const editor = makeEditor({ + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "a" }, + { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "x" } }, + { type: "text", text: "b" }, + { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "y" } }, + ], + }, + { + type: FOOTNOTES_LIST_NAME, + content: [ + { + type: FOOTNOTE_DEFINITION_NAME, + attrs: { id: "x" }, + content: [{ type: "paragraph" }], + }, + { + type: FOOTNOTE_DEFINITION_NAME, + attrs: { id: "y" }, + content: [{ type: "paragraph" }], + }, + ], + }, + ], + }); + + // The cache mirrors computeFootnoteNumbers — but is read in O(1) per id. + expect(getFootnoteNumber(editor.state, "x")).toBe(1); + expect(getFootnoteNumber(editor.state, "y")).toBe(2); + // The cached map is the SAME values a fresh full computation would yield. + const fresh = computeFootnoteNumbers(editor.state.doc); + expect(getFootnoteNumber(editor.state, "x")).toBe(fresh.get("x")); + expect(getFootnoteNumber(editor.state, "y")).toBe(fresh.get("y")); + + // After inserting a new earlier reference, the cache updates so the numbers + // shift (decoration-only numbering follows reference order). + editor.commands.insertContentAt(1, { + type: FOOTNOTE_REFERENCE_NAME, + attrs: { id: "z" }, + }); + expect(getFootnoteNumber(editor.state, "z")).toBe(1); + expect(getFootnoteNumber(editor.state, "x")).toBe(2); + expect(getFootnoteNumber(editor.state, "y")).toBe(3); + editor.destroy(); + }); +}); From a85dd607bde1e0b6b151124b2296cc6e4d7c3b89 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sat, 20 Jun 2026 21:29:02 +0300 Subject: [PATCH 5/5] fix(footnotes): tighten the gap between a definition's number and text (#44) The footnote definition number ('1.') sat ~19px from its text because two spacings stacked: the 1.5em (24px) marker min-width box (wider than the ~15px glyph) plus a 10px flex gap. Reduce the flex gap to 0.4em (about one space) and right-align the number within the 1.5em column so the period sits next to the text and multi-digit numbers (10, 11, ...) stay aligned. Reads like '1. text'. Co-Authored-By: Claude Opus 4.8 --- .../editor/components/footnote/footnote.module.css | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/client/src/features/editor/components/footnote/footnote.module.css b/apps/client/src/features/editor/components/footnote/footnote.module.css index 11c391bd..af467c5b 100644 --- a/apps/client/src/features/editor/components/footnote/footnote.module.css +++ b/apps/client/src/features/editor/components/footnote/footnote.module.css @@ -76,13 +76,18 @@ .definition { display: flex; align-items: flex-start; - gap: var(--mantine-spacing-xs); + /* Tight number→text spacing (~one space) so it reads like "1. text" + instead of leaving a wide gap after the period. */ + gap: 0.4em; padding: 2px 0; } .definitionMarker { flex: 0 0 auto; min-width: 1.5em; + /* Right-align within the narrow column so the period sits next to the text + and multi-digit numbers (10, 11, …) stay aligned on their right edge. */ + text-align: right; font-variant-numeric: tabular-nums; color: var(--mantine-color-dimmed); user-select: none;