feat(editor): footnotes (reference + definitions, collab-safe) #18
@@ -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": "Блок формулы",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
|
||||
72
packages/editor-ext/src/lib/footnote/footnote-definition.ts
Normal file
72
packages/editor-ext/src/lib/footnote/footnote-definition.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
140
packages/editor-ext/src/lib/footnote/footnote-markdown.test.ts
Normal file
140
packages/editor-ext/src/lib/footnote/footnote-markdown.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
119
packages/editor-ext/src/lib/footnote/footnote-numbering.ts
Normal file
119
packages/editor-ext/src/lib/footnote/footnote-numbering.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
331
packages/editor-ext/src/lib/footnote/footnote-reference.ts
Normal file
331
packages/editor-ext/src/lib/footnote/footnote-reference.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
634
packages/editor-ext/src/lib/footnote/footnote-sync.ts
Normal file
634
packages/editor-ext/src/lib/footnote/footnote-sync.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
132
packages/editor-ext/src/lib/footnote/footnote-util.ts
Normal file
132
packages/editor-ext/src/lib/footnote/footnote-util.ts
Normal 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;
|
||||
}
|
||||
948
packages/editor-ext/src/lib/footnote/footnote.test.ts
Normal file
948
packages/editor-ext/src/lib/footnote/footnote.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
56
packages/editor-ext/src/lib/footnote/footnotes-list.ts
Normal file
56
packages/editor-ext/src/lib/footnote/footnotes-list.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
6
packages/editor-ext/src/lib/footnote/index.ts
Normal file
6
packages/editor-ext/src/lib/footnote/index.ts
Normal 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";
|
||||
170
packages/editor-ext/src/lib/markdown/utils/footnote.marked.ts
Normal file
170
packages/editor-ext/src/lib/markdown/utils/footnote.marked.ts
Normal 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, "&").replace(/"/g, """);
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract `[^id]: text` definition lines from the markdown body, returning the
|
||||
* cleaned body plus a rendered <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>`,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
|
||||
@@ -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, "&").replace(/"/g, """);
|
||||
}
|
||||
function escapeFootnoteRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
/**
|
||||
* Derive a DETERMINISTIC unique footnote id for the k-th (k >= 2) occurrence of
|
||||
* an original id `X` during definition dedup.
|
||||
*
|
||||
* EXACT MIRROR of editor-ext `deriveFootnoteId`
|
||||
* (packages/editor-ext/src/lib/footnote/footnote-util.ts). These two copies MUST
|
||||
* STAY IN SYNC: the same markdown imported through the editor and through this
|
||||
* MCP path has to produce identical ids, and the sync plugin (which re-ids on
|
||||
* every collaborating client) relies on the same scheme to converge. NEVER use
|
||||
* Math.random()/Date.now()/uuid here — a random id would diverge across clients.
|
||||
*
|
||||
* Scheme: base candidate `${originalId}__${occurrence}` (e.g. `X__2`), bumped
|
||||
* with a stable alphabetic suffix (`X__2b`, `X__2c`, ...) until it is not in
|
||||
* `taken` (the set of ids already present / already minted — pure doc state).
|
||||
*/
|
||||
function deriveFootnoteId(originalId, occurrence, taken) {
|
||||
let candidate = `${originalId}__${occurrence}`;
|
||||
let n = 0;
|
||||
while (taken.has(candidate)) {
|
||||
n += 1;
|
||||
candidate = `${originalId}__${occurrence}${footnoteSuffix(n)}`;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
/** Map 1 -> "b", 2 -> "c", ... (mirror of editor-ext `suffix`). */
|
||||
function footnoteSuffix(n) {
|
||||
let out = "";
|
||||
let x = n;
|
||||
while (x > 0) {
|
||||
const rem = (x - 1) % 25;
|
||||
out = String.fromCharCode(98 + rem) + out; // 98 = 'b'
|
||||
x = Math.floor((x - 1) / 25);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const footnoteRefMarkedExtension = {
|
||||
name: "footnoteRef",
|
||||
level: "inline",
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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, "&").replace(/"/g, """);
|
||||
}
|
||||
|
||||
function escapeFootnoteRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a DETERMINISTIC unique footnote id for the k-th (k >= 2) occurrence of
|
||||
* an original id `X` during definition dedup.
|
||||
*
|
||||
* EXACT MIRROR of editor-ext `deriveFootnoteId`
|
||||
* (packages/editor-ext/src/lib/footnote/footnote-util.ts). These two copies MUST
|
||||
* STAY IN SYNC: the same markdown imported through the editor and through this
|
||||
* MCP path has to produce identical ids, and the sync plugin (which re-ids on
|
||||
* every collaborating client) relies on the same scheme to converge. NEVER use
|
||||
* Math.random()/Date.now()/uuid here — a random id would diverge across clients.
|
||||
*
|
||||
* Scheme: base candidate `${originalId}__${occurrence}` (e.g. `X__2`), bumped
|
||||
* with a stable alphabetic suffix (`X__2b`, `X__2c`, ...) until it is not in
|
||||
* `taken` (the set of ids already present / already minted — pure doc state).
|
||||
*/
|
||||
function deriveFootnoteId(
|
||||
originalId: string,
|
||||
occurrence: number,
|
||||
taken: Set<string>,
|
||||
): 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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
153
packages/mcp/test/unit/footnotes.test.mjs
Normal file
153
packages/mcp/test/unit/footnotes.test.mjs
Normal 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/);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user