feat(editor): footnotes (reference + definitions, collab-safe) #18

Merged
Ghost merged 6 commits from feat/footnotes into develop 2026-06-20 22:21:37 +03:00
36 changed files with 4168 additions and 153 deletions

View File

@@ -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": "Блок формулы",

View File

@@ -0,0 +1,48 @@
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { useTranslation } from "react-i18next";
import { getFootnoteNumber } 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;
// 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();
editor.commands.scrollToReference(id);
};
return (
<NodeViewWrapper
data-footnote-def=""
data-id={id}
className={classes.definition}
style={{ ["--footnote-number" as any]: `"${number}"` }}
>
<span className={classes.definitionMarker} contentEditable={false}>
{number}.
</span>
<NodeViewContent className={classes.definitionContent} />
<span
className={classes.backLink}
contentEditable={false}
onClick={handleBack}
role="button"
aria-label={t("Back to reference")}
title={t("Back to reference")}
>
</span>
</NodeViewWrapper>
);
}

View File

@@ -0,0 +1,146 @@
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,
getFootnoteNumber,
} 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<HTMLElement | null>(null);
const popoverRef = useRef<HTMLDivElement | null>(null);
const [open, setOpen] = useState(false);
// 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(() => {
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 (
<NodeViewWrapper as="span" style={{ display: "inline" }}>
<sup
ref={(el) => (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(
<div
ref={popoverRef}
className={classes.popover}
role="tooltip"
onMouseLeave={() => setOpen(false)}
>
<div className={classes.popoverHeader}>
<span className={classes.popoverNumber}>
{t("Footnote {{number}}", { number })}
</span>
<ActionIcon
variant="subtle"
size="sm"
color="gray"
onClick={handleGoTo}
aria-label={t("Go to footnote")}
>
<IconArrowDown size={16} />
</ActionIcon>
</div>
<div className={classes.popoverBody}>
{defText || t("Empty footnote")}
</div>
</div>,
document.body,
)}
</NodeViewWrapper>
);
}

View File

@@ -0,0 +1,111 @@
/* 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;
/* 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;
}
.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;
}

View File

@@ -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 (
<NodeViewWrapper>
<div className={classes.list} contentEditable={false}>
<div className={classes.listHeading}>{t("Footnotes")}</div>
</div>
<NodeViewContent />
</NodeViewWrapper>
);
}

View File

@@ -28,6 +28,7 @@ import {
IconTag,
IconMoodSmile,
IconRotate2,
IconSuperscript,
IconArrowsMaximize,
} from "@tabler/icons-react";
import { PAGE_EMBED_PICKER_EVENT } from "@/features/editor/components/page-embed/page-embed-picker";
@@ -368,6 +369,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.",

View File

@@ -63,6 +63,9 @@ import {
TransclusionReference,
PageEmbed,
TableView,
FootnoteReference,
FootnotesList,
FootnoteDefinition,
} from "@docmost/editor-ext";
import {
randomElement,
@@ -94,6 +97,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 PageEmbedView from "@/features/editor/components/page-embed/page-embed-view.tsx";
import { common, createLowlight } from "lowlight";
import plaintext from "highlight.js/lib/languages/plaintext";
@@ -392,6 +398,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,
}),
PageEmbed.configure({
view: PageEmbedView,
}),

View File

@@ -48,8 +48,15 @@ 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 [

View File

@@ -45,6 +45,9 @@ import {
htmlToMarkdown,
TransclusionSource,
TransclusionReference,
FootnoteReference,
FootnotesList,
FootnoteDefinition,
PageEmbed,
} from '@docmost/editor-ext';
import { generateText, getSchema, JSONContent } from '@tiptap/core';
@@ -115,6 +118,9 @@ export const tiptapExtensions = [
Status,
TransclusionSource,
TransclusionReference,
FootnoteReference,
FootnotesList,
FootnoteDefinition,
PageEmbed,
] as any;

View File

@@ -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 `<sup>` elements. In the server
* `tiptapExtensions` list, Superscript is registered BEFORE the footnote nodes,
* so without the priority guard a `<sup data-footnote-ref>` would be parsed as
* an (empty) superscript mark and the footnote reference would be lost.
*/
describe('footnote reference vs superscript mark (server schema round-trip)', () => {
const HTML =
'<p>Water' +
'<sup data-footnote-ref data-id="fn1"></sup>' +
' here.</p>' +
'<section data-footnotes>' +
'<div data-footnote-def data-id="fn1"><p>First note.</p></div>' +
'</section>';
it('parses <sup data-footnote-ref> into a footnoteReference NODE (not a superscript mark)', () => {
const json = htmlToJson(HTML);
const ref = findFirst(json, 'footnoteReference');
expect(ref).toBeDefined();
expect(ref.attrs.id).toBe('fn1');
// It must NOT have been swallowed as a superscript mark on text.
const superscriptText = JSON.stringify(json).includes('"superscript"');
expect(superscriptText).toBe(false);
// The matching definition survives too.
const def = findFirst(json, 'footnoteDefinition');
expect(def).toBeDefined();
expect(def.attrs.id).toBe('fn1');
});
it('round-trips an empty footnoteReference back to <sup data-footnote-ref>', () => {
const json = htmlToJson(HTML);
const html = jsonToHtml(json);
expect(html).toContain('data-footnote-ref');
expect(html).toContain('data-id="fn1"');
// And a second parse still yields the node (stable round-trip).
const json2 = htmlToJson(html);
const ref2 = findFirst(json2, 'footnoteReference');
expect(ref2).toBeDefined();
expect(ref2.attrs.id).toBe('fn1');
});
});

View File

@@ -35,4 +35,5 @@ export * from "./lib/status";
export * from "./lib/pdf";
export * from "./lib/page-break";
export * from "./lib/resizable-nodeview";
export * from "./lib/footnote";

View File

@@ -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<string, any>;
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<FootnoteDefinitionOptions>({
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);
},
});

View File

@@ -0,0 +1,140 @@
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 =
`<p>Water<sup data-footnote-ref data-id="fn1"></sup> and clay<sup data-footnote-ref data-id="fn2"></sup>.</p>` +
`<section data-footnotes>` +
`<div data-footnote-def data-id="fn1"><p>First note.</p></div>` +
`<div data-footnote-def data-id="fn2"><p>Second note.</p></div>` +
`</section>`;
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");
});
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");
});
});

View File

@@ -0,0 +1,119 @@
import { EditorState, 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<FootnoteNumberingState>(
"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<string, number>;
/** Decorations rendering those numbers (refs + definitions). */
decorations: DecorationSet;
}
/**
* 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 {
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[] = [];
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 { 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);
}
/**
* 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 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 buildFootnoteNumberingState(tr.doc);
},
},
props: {
decorations(state) {
return footnoteNumberingPluginKey.getState(state)?.decorations;
},
},
});
}

View File

@@ -0,0 +1,331 @@
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, footnotePastePlugin } from "./footnote-sync";
export interface FootnoteReferenceOptions {
HTMLAttributes: Record<string, any>;
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<ReturnType> {
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<FootnoteReferenceOptions>({
name: FOOTNOTE_REFERENCE_NAME,
// Higher than the default (100) so its parse rule is considered before the
// Superscript mark's <sup> 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));
// Regenerate colliding footnote ids on paste so a pasted reference+
// definition pair never clobbers/merges with an existing footnote.
plugins.push(footnotePastePlugin());
}
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 <sup>) 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;
},
};
},
});

View File

@@ -0,0 +1,634 @@
import { Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
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 {
/**
* 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 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) refOccurrences.push({ pos, id, node });
}
if (node.type.name === FOOTNOTE_DEFINITION_NAME) {
const id = node.attrs.id;
if (id) defOccurrences.push({ pos, id, node });
}
if (node.type.name === FOOTNOTES_LIST_NAME) {
lists.push({ pos, node });
}
});
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<string, ProseMirrorNode>;
/**
* 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<string, ProseMirrorNode>();
const refReids: Array<{
pos: number;
node: ProseMirrorNode;
newId: string;
}> = [];
const referenceIds: string[] = [];
const seenRefIds = new Set<string>();
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<string>();
for (const occ of scan.refOccurrences) taken.add(occ.id);
for (const occ of scan.defOccurrences) taken.add(occ.id);
const occurrenceOf = new Map<string, number>();
// 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<string, DefOccurrence[]>();
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<DefOccurrence>();
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<string>();
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 };
}
/**
* 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 ...).
*
* 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,
): 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);
// 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;
// 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);
// 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<ProseMirrorNode, string>();
for (const [id, node] of plan.definitions) finalIdByNode.set(node, id);
const isEmptyParagraph = (node: ProseMirrorNode) =>
node.type === paragraphType && node.content.size === 0;
// 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<string>();
// 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);
}
// 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) => {
if (childPos >= listEnd && child !== listNode) {
if (!isEmptyParagraph(child)) ok = false;
}
return false; // do not descend
});
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);
// 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));
// 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;
// 6a) Re-id colliding references (inline atoms: attr-only, size-stable).
for (const reid of plan.refReids) {
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,
});
}
// 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 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;
// 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;
tr.setMeta(SYNC_META, true);
tr.setMeta("addToHistory", false);
return tr;
},
});
}
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<string>();
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<string, string>();
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);
},
},
});
}

View File

@@ -0,0 +1,132 @@
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)
);
}
/**
* 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<string> | ReadonlySet<string>,
): 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
* 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<string, number> {
const numbers = new Map<string, number>();
let n = 0;
for (const id of collectReferenceIds(doc)) {
if (!numbers.has(id)) {
numbers.set(id, ++n);
}
}
return numbers;
}

View File

@@ -0,0 +1,948 @@
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 { 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,
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("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",
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();
});
});
/**
* 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<string, PMNode> {
const m = new Map<string, PMNode>();
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();
});
});

View File

@@ -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<string, any>;
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<FootnotesListOptions>({
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);
},
});

View File

@@ -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";

View File

@@ -0,0 +1,170 @@
import { marked } from "marked";
import { deriveFootnoteId } from "../../footnote/footnote-util";
/**
* Pandoc/GFM footnote support for the marked (Markdown -> HTML) pipeline.
*
* Two pieces:
* - an INLINE tokenizer for `[^id]` references -> <sup data-footnote-ref
* data-id="id"> (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 <section data-footnotes> with one <div data-footnote-def> 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 `<sup data-footnote-ref data-id="${escapeAttr(token.id)}"></sup>`;
},
};
function escapeAttr(value: string): string {
return String(value).replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
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 <section data-footnotes> (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: "" };
}
// 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<string>(definitions.map((d) => d.id));
const seenDefIds = new Map<string, number>(); // 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
// a footnote survive the round-trip; wrap in a paragraph (the node's
// content is paragraph+).
const inner = marked.parseInline(d.text || "");
return `<div data-footnote-def data-id="${escapeAttr(
d.id,
)}"><p>${inner}</p></div>`;
})
.join("");
return {
body: dedupedBody,
section: `<section data-footnotes>${defsHtml}</section>`,
};
}

View File

@@ -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";
import { htmlEmbedExtension } from "./html-embed.marked";
marked.use({
@@ -39,6 +43,7 @@ marked.use({
calloutExtension,
mathBlockExtension,
mathInlineExtension,
footnoteReferenceExtension,
htmlEmbedExtension,
],
});
@@ -54,5 +59,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 <section data-footnotes> 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);
}

View File

@@ -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 <sup data-footnote-ref> 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 <sup data-footnote-ref> 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(
/<sup\b([^>]*\bdata-footnote-ref\b[^>]*)>\s*<\/sup>/gi,
(_m, attrs) => `<sup${attrs}>​</sup>`,
);
}
export function htmlToMarkdown(html: string): string {
const turndownService = new TurndownService({
headingStyle: 'atx',
codeBlockStyle: 'fenced',
hr: '---',
bulletListMarker: '-',
isVoid: isVoidNode,
});
turndownService.use([
@@ -35,8 +67,12 @@ export function htmlToMarkdown(html: string): string {
htmlEmbed,
image,
video,
footnoteReference,
footnotesList,
]);
return turndownService.turndown(html).replaceAll('<br>', ' ');
return turndownService
.turndown(fillEmptyFootnoteRefs(html))
.replaceAll('<br>', ' ');
}
/**
@@ -230,6 +266,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) {

View File

@@ -1,12 +1,8 @@
import { defineConfig } from "vitest/config";
// Minimal vitest setup for @docmost/editor-ext (mirrors apps/client's config,
// trimmed to what the markdown/html-embed round-trip tests need). The markdown
// utils run in plain Node (marked + turndown), so no jsdom/react plugin is
// required here.
export default defineConfig({
test: {
environment: "node",
environment: "jsdom",
globals: true,
include: ["src/**/*.{test,spec}.ts"],
},

View File

@@ -263,10 +263,145 @@ function bridgeTaskLists(html) {
}
return document.body.innerHTML;
}
// Mirror of packages/editor-ext footnote markdown handling. A `[^id]` inline
// marker becomes <sup data-footnote-ref data-id="id">, and `[^id]: text`
// definition lines are collected into a single <section data-footnotes>.
const FOOTNOTE_DEF_RE = /^\[\^([^\]\s]+)\]:[ \t]*(.*)$/;
const FOOTNOTE_REF_RE = /\[\^([^\]\s]+)\]/;
function escapeFootnoteAttr(value) {
return String(value).replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
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",
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 `<sup data-footnote-ref data-id="${escapeFootnoteAttr(token.id)}"></sup>`;
},
};
marked.use({ extensions: [footnoteRefMarkedExtension] });
/**
* Pull `[^id]: text` definition lines out of the body and render a single
* <section data-footnotes> 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: "" };
// 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) => `<div data-footnote-def data-id="${escapeFootnoteAttr(d.id)}"><p>${marked.parseInline(d.text || "")}</p></div>`)
.join("");
return {
body: dedupedBody,
section: `<section data-footnotes>${inner}</section>`,
};
}
/** 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);
}

View File

@@ -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) {

View File

@@ -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 (<sup data-footnote-ref>);
* - FootnotesList: a single bottom container (<section data-footnotes>);
* - FootnoteDefinition: one editable note keyed by id (<div data-footnote-def>).
* The visible number is not stored; it is derived from reference order.
*
* priority 101 so this node's <sup> parse rule beats the Superscript mark's
* <sup> 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,

View File

@@ -388,6 +388,27 @@ export function convertProseMirrorToMarkdown(content) {
// carry the real values), so escape it for the text context, not attrs.
return `<span ${parts.join(" ")}>@${escapeHtmlText(mentionLabel)}</span>`;
}
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

View File

@@ -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<i>\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<i>\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`);
visitInlineArrays(top2[i]);
}
newNotes.push(note);
// 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 {
newNotes.push(noteByPh.get(`\u0000FN${phIdx}\u0000`));
top2.splice(oldListIndex, 1);
}
return `[${++seq}]`;
});
});
}
// 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);
// Sync the disclaimer callout range to the new note count.
const synced = setCalloutRange(working, definitions.length);
return { doc: synced.doc, consumed };
}

View File

@@ -296,12 +296,165 @@ function bridgeTaskLists(html: string): string {
return document.body.innerHTML;
}
// Mirror of packages/editor-ext footnote markdown handling. A `[^id]` inline
// marker becomes <sup data-footnote-ref data-id="id">, and `[^id]: text`
// definition lines are collected into a single <section data-footnotes>.
const FOOTNOTE_DEF_RE = /^\[\^([^\]\s]+)\]:[ \t]*(.*)$/;
const FOOTNOTE_REF_RE = /\[\^([^\]\s]+)\]/;
function escapeFootnoteAttr(value: string): string {
return String(value).replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
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>,
): 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,
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 `<sup data-footnote-ref data-id="${escapeFootnoteAttr(
token.id,
)}"></sup>`;
},
};
marked.use({ extensions: [footnoteRefMarkedExtension] });
/**
* Pull `[^id]: text` definition lines out of the body and render a single
* <section data-footnotes> 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: "" };
// 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<string>(defs.map((d) => d.id));
const seenDefIds = new Map<string, number>();
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) =>
`<div data-footnote-def data-id="${escapeFootnoteAttr(
d.id,
)}"><p>${marked.parseInline(d.text || "")}</p></div>`,
)
.join("");
return {
body: dedupedBody,
section: `<section data-footnotes>${inner}</section>`,
};
}
/** Convert markdown to a ProseMirror doc using the full Docmost schema. */
export async function markdownToProseMirror(
markdownContent: string,
): Promise<any> {
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);
}

View File

@@ -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) {

View File

@@ -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 (<sup data-footnote-ref>);
* - FootnotesList: a single bottom container (<section data-footnotes>);
* - FootnoteDefinition: one editable note keyed by id (<div data-footnote-def>).
* The visible number is not stored; it is derived from reference order.
*
* priority 101 so this node's <sup> parse rule beats the Superscript mark's
* <sup> 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<string, any>) =>
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<string, any>) =>
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,

View File

@@ -430,6 +430,30 @@ export function convertProseMirrorToMarkdown(content: any): string {
return `<span ${parts.join(" ")}>@${escapeHtmlText(mentionLabel)}</span>`;
}
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

View File

@@ -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<i>\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<string, any>();
const noteInlineByPh = new Map<string, any[]>();
(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<i>\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 };
}

View File

@@ -0,0 +1,153 @@
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("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
// 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/);
});

View File

@@ -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");
});