feat(ai-chat): auto-collapse chat window on page focus (#42) #50
@@ -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,47 @@
|
||||
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { computeFootnoteNumbers } from "@docmost/editor-ext";
|
||||
import classes from "./footnote.module.css";
|
||||
|
||||
/**
|
||||
* NodeView for a single footnote definition: a decorative number marker, the
|
||||
* editable content (NodeViewContent), and a "↩" back-link to its reference.
|
||||
* The number is derived from the document (not stored).
|
||||
*/
|
||||
export default function FootnoteDefinitionView(props: NodeViewProps) {
|
||||
const { node, editor } = props;
|
||||
const { t } = useTranslation();
|
||||
const id = node.attrs.id as string;
|
||||
|
||||
const numbers = computeFootnoteNumbers(editor.state.doc);
|
||||
const number = numbers.get(id) ?? "?";
|
||||
|
||||
const handleBack = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
editor.commands.scrollToReference(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<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,145 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
autoUpdate,
|
||||
computePosition,
|
||||
flip,
|
||||
offset,
|
||||
shift,
|
||||
} from "@floating-ui/dom";
|
||||
import {
|
||||
FOOTNOTE_DEFINITION_NAME,
|
||||
computeFootnoteNumbers,
|
||||
} from "@docmost/editor-ext";
|
||||
import { ActionIcon } from "@mantine/core";
|
||||
import { IconArrowDown } from "@tabler/icons-react";
|
||||
import classes from "./footnote.module.css";
|
||||
|
||||
/**
|
||||
* Read the plain text of the footnote definition with `id` directly from the
|
||||
* editor state. No sub-editor: the popover is read-only.
|
||||
*/
|
||||
function getDefinitionText(editor: NodeViewProps["editor"], id: string): string {
|
||||
let text = "";
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (
|
||||
node.type.name === FOOTNOTE_DEFINITION_NAME &&
|
||||
node.attrs.id === id
|
||||
) {
|
||||
text = node.textContent;
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
return text;
|
||||
}
|
||||
|
||||
export default function FootnoteReferenceView(props: NodeViewProps) {
|
||||
const { node, editor, selected } = props;
|
||||
const { t } = useTranslation();
|
||||
const id = node.attrs.id as string;
|
||||
|
||||
const anchorRef = useRef<HTMLElement | null>(null);
|
||||
const popoverRef = useRef<HTMLDivElement | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Number is derived (not stored) — recompute from the current doc.
|
||||
const numbers = computeFootnoteNumbers(editor.state.doc);
|
||||
const number = numbers.get(id) ?? "?";
|
||||
const defText = open ? getDefinitionText(editor, id) : "";
|
||||
|
||||
const position = useCallback(() => {
|
||||
const anchor = anchorRef.current;
|
||||
const popup = popoverRef.current;
|
||||
if (!anchor || !popup) return;
|
||||
computePosition(anchor, popup, {
|
||||
placement: "top",
|
||||
middleware: [offset(6), flip(), shift({ padding: 8 })],
|
||||
}).then(({ x, y }) => {
|
||||
popup.style.left = `${x}px`;
|
||||
popup.style.top = `${y}px`;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const anchor = anchorRef.current;
|
||||
const popup = popoverRef.current;
|
||||
if (!anchor || !popup) return;
|
||||
|
||||
const cleanup = autoUpdate(anchor, popup, position);
|
||||
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
if (
|
||||
popup.contains(e.target as Node) ||
|
||||
anchor.contains(e.target as Node)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
document.addEventListener("pointerdown", onPointerDown, true);
|
||||
|
||||
return () => {
|
||||
cleanup();
|
||||
document.removeEventListener("pointerdown", onPointerDown, true);
|
||||
};
|
||||
}, [open, position]);
|
||||
|
||||
const handleGoTo = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
editor.commands.scrollToFootnote(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<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,106 @@
|
||||
/* Superscript reference marker. The visible number comes from the numbering
|
||||
plugin decoration which sets the --footnote-number CSS variable. */
|
||||
.reference {
|
||||
cursor: pointer;
|
||||
color: var(--mantine-color-blue-6);
|
||||
font-weight: 500;
|
||||
vertical-align: super;
|
||||
font-size: 0.75em;
|
||||
line-height: 0;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reference::after {
|
||||
content: var(--footnote-number, "");
|
||||
}
|
||||
|
||||
.reference:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.reference.selected {
|
||||
background-color: var(--mantine-color-blue-1);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Read-only popover shown on hover/click of a reference. */
|
||||
.popover {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
max-width: 360px;
|
||||
padding: var(--mantine-spacing-sm);
|
||||
background: var(--mantine-color-body);
|
||||
color: var(--mantine-color-default-color);
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
box-shadow: var(--mantine-shadow-md);
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.popoverHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--mantine-spacing-xs);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.popoverNumber {
|
||||
font-weight: 600;
|
||||
color: var(--mantine-color-dimmed);
|
||||
}
|
||||
|
||||
.popoverBody {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Bottom footnotes container. */
|
||||
.list {
|
||||
margin-top: var(--mantine-spacing-lg);
|
||||
padding-top: var(--mantine-spacing-md);
|
||||
border-top: 1px solid var(--mantine-color-default-border);
|
||||
}
|
||||
|
||||
.listHeading {
|
||||
font-weight: 600;
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
color: var(--mantine-color-dimmed);
|
||||
margin-bottom: var(--mantine-spacing-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.definition {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--mantine-spacing-xs);
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.definitionMarker {
|
||||
flex: 0 0 auto;
|
||||
min-width: 1.5em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--mantine-color-dimmed);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.definitionContent {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.backLink {
|
||||
flex: 0 0 auto;
|
||||
cursor: pointer;
|
||||
color: var(--mantine-color-blue-6);
|
||||
user-select: none;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.backLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -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,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
CommandProps,
|
||||
@@ -366,6 +367,14 @@ const CommandGroups: SlashMenuGroupedItemsType = {
|
||||
command: ({ editor, range }: CommandProps) =>
|
||||
editor.chain().focus().deleteRange(range).setDetails().run(),
|
||||
},
|
||||
{
|
||||
title: "Footnote",
|
||||
description: "Insert a footnote reference.",
|
||||
searchTerms: ["footnote", "note", "reference", "сноска", "примечание"],
|
||||
icon: IconSuperscript,
|
||||
command: ({ editor, range }: CommandProps) =>
|
||||
editor.chain().focus().deleteRange(range).setFootnote().run(),
|
||||
},
|
||||
{
|
||||
title: "Callout",
|
||||
description: "Insert callout notice.",
|
||||
|
||||
@@ -61,6 +61,9 @@ import {
|
||||
TransclusionSource,
|
||||
TransclusionReference,
|
||||
TableView,
|
||||
FootnoteReference,
|
||||
FootnotesList,
|
||||
FootnoteDefinition,
|
||||
} from "@docmost/editor-ext";
|
||||
import {
|
||||
randomElement,
|
||||
@@ -91,6 +94,9 @@ import PdfView from "@/features/editor/components/pdf/pdf-view.tsx";
|
||||
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
|
||||
import TransclusionView from "@/features/editor/components/transclusion/transclusion-view.tsx";
|
||||
import TransclusionReferenceView from "@/features/editor/components/transclusion/transclusion-reference-view.tsx";
|
||||
import FootnoteReferenceView from "@/features/editor/components/footnote/footnote-reference-view.tsx";
|
||||
import FootnotesListView from "@/features/editor/components/footnote/footnotes-list-view.tsx";
|
||||
import FootnoteDefinitionView from "@/features/editor/components/footnote/footnote-definition-view.tsx";
|
||||
import { common, createLowlight } from "lowlight";
|
||||
import plaintext from "highlight.js/lib/languages/plaintext";
|
||||
import powershell from "highlight.js/lib/languages/powershell";
|
||||
@@ -381,6 +387,19 @@ export const mainExtensions = [
|
||||
TransclusionReference.configure({
|
||||
view: TransclusionReferenceView,
|
||||
}),
|
||||
FootnoteReference.configure({
|
||||
view: FootnoteReferenceView,
|
||||
// Skip orphan-cleanup on remote/collaboration steps so collaborating
|
||||
// clients never fight over footnote integrity (deterministic numbering
|
||||
// decorations handle the rest).
|
||||
isRemoteTransaction: (tr: any) => isChangeOrigin(tr),
|
||||
}),
|
||||
FootnotesList.configure({
|
||||
view: FootnotesListView,
|
||||
}),
|
||||
FootnoteDefinition.configure({
|
||||
view: FootnoteDefinitionView,
|
||||
}),
|
||||
MarkdownClipboard.configure({
|
||||
transformPastedText: true,
|
||||
}),
|
||||
|
||||
@@ -48,9 +48,16 @@ export default function ReadonlyPageEditor({
|
||||
}, []);
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
const filteredExtensions = mainExtensions.filter(
|
||||
(ext) => ext.name !== "uniqueID",
|
||||
);
|
||||
const filteredExtensions = mainExtensions
|
||||
.filter((ext) => ext.name !== "uniqueID")
|
||||
// Read-only must only DECORATE footnotes (numbering), never mutate the
|
||||
// doc. Disable the footnote sync/integrity plugin so a programmatic
|
||||
// setContent on a doc the viewer can't edit is never rewritten.
|
||||
.map((ext) =>
|
||||
ext.name === "footnoteReference"
|
||||
? ext.configure({ enableSync: false })
|
||||
: ext,
|
||||
);
|
||||
|
||||
return [
|
||||
...filteredExtensions,
|
||||
|
||||
@@ -44,6 +44,9 @@ import {
|
||||
htmlToMarkdown,
|
||||
TransclusionSource,
|
||||
TransclusionReference,
|
||||
FootnoteReference,
|
||||
FootnotesList,
|
||||
FootnoteDefinition,
|
||||
} from '@docmost/editor-ext';
|
||||
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
||||
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
||||
@@ -109,6 +112,9 @@ export const tiptapExtensions = [
|
||||
Status,
|
||||
TransclusionSource,
|
||||
TransclusionReference,
|
||||
FootnoteReference,
|
||||
FootnotesList,
|
||||
FootnoteDefinition,
|
||||
] as any;
|
||||
|
||||
export function jsonToHtml(tiptapJson: any) {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,8 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
"dev": "tsc --watch"
|
||||
"dev": "tsc --watch",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"module": "./src/index.ts",
|
||||
|
||||
@@ -33,4 +33,5 @@ export * from "./lib/status";
|
||||
export * from "./lib/pdf";
|
||||
export * from "./lib/page-break";
|
||||
export * from "./lib/resizable-nodeview";
|
||||
export * from "./lib/footnote";
|
||||
|
||||
|
||||
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);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { htmlToMarkdown } from "../markdown/utils/turndown.utils";
|
||||
import { markdownToHtml } from "../markdown/utils/marked.utils";
|
||||
|
||||
// HTML the editor-ext nodes render (sup[data-footnote-ref], section/div).
|
||||
const HTML =
|
||||
`<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");
|
||||
});
|
||||
});
|
||||
75
packages/editor-ext/src/lib/footnote/footnote-numbering.ts
Normal file
75
packages/editor-ext/src/lib/footnote/footnote-numbering.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import {
|
||||
FOOTNOTE_DEFINITION_NAME,
|
||||
FOOTNOTE_REFERENCE_NAME,
|
||||
computeFootnoteNumbers,
|
||||
} from "./footnote-util";
|
||||
|
||||
export const footnoteNumberingPluginKey = new PluginKey("footnoteNumbering");
|
||||
|
||||
/**
|
||||
* Build the decoration set for footnote numbers. Pure function of the document:
|
||||
* walk references in document order, assign 1-based numbers, then attach a
|
||||
* node decoration (carrying the number via a CSS variable + data attribute) to
|
||||
* every reference and to every matching definition. Because it is deterministic
|
||||
* from the document alone, all collaborating clients compute identical numbers
|
||||
* with no document mutation.
|
||||
*/
|
||||
export function buildFootnoteDecorations(doc: ProseMirrorNode): DecorationSet {
|
||||
const numbers = computeFootnoteNumbers(doc);
|
||||
const decorations: Decoration[] = [];
|
||||
|
||||
doc.descendants((node, pos) => {
|
||||
if (node.type.name === FOOTNOTE_REFERENCE_NAME) {
|
||||
const num = numbers.get(node.attrs.id);
|
||||
if (num != null) {
|
||||
decorations.push(
|
||||
Decoration.node(pos, pos + node.nodeSize, {
|
||||
"data-footnote-number": String(num),
|
||||
style: `--footnote-number: "${num}";`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (node.type.name === FOOTNOTE_DEFINITION_NAME) {
|
||||
const num = numbers.get(node.attrs.id);
|
||||
if (num != null) {
|
||||
decorations.push(
|
||||
Decoration.node(pos, pos + node.nodeSize, {
|
||||
"data-footnote-number": String(num),
|
||||
style: `--footnote-number: "${num}";`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return DecorationSet.create(doc, decorations);
|
||||
}
|
||||
|
||||
/**
|
||||
* ProseMirror plugin that renders footnote numbers as decorations. It never
|
||||
* mutates the document (safe in read-only / share and in collaboration) — it
|
||||
* only recomputes decorations from the current doc on each transaction.
|
||||
*/
|
||||
export function footnoteNumberingPlugin(): Plugin {
|
||||
return new Plugin({
|
||||
key: footnoteNumberingPluginKey,
|
||||
state: {
|
||||
init(_, { doc }) {
|
||||
return buildFootnoteDecorations(doc);
|
||||
},
|
||||
apply(tr, old) {
|
||||
if (!tr.docChanged) return old;
|
||||
return buildFootnoteDecorations(tr.doc);
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return this.getState(state);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
328
packages/editor-ext/src/lib/footnote/footnote-reference.ts
Normal file
328
packages/editor-ext/src/lib/footnote/footnote-reference.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
import { TextSelection, Transaction } from "@tiptap/pm/state";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import {
|
||||
FOOTNOTE_DEFINITION_NAME,
|
||||
FOOTNOTE_REFERENCE_NAME,
|
||||
FOOTNOTES_LIST_NAME,
|
||||
generateFootnoteId,
|
||||
} from "./footnote-util";
|
||||
import { footnoteNumberingPlugin } from "./footnote-numbering";
|
||||
import { footnoteSyncPlugin } from "./footnote-sync";
|
||||
|
||||
export interface FootnoteReferenceOptions {
|
||||
HTMLAttributes: Record<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));
|
||||
}
|
||||
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;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
197
packages/editor-ext/src/lib/footnote/footnote-sync.ts
Normal file
197
packages/editor-ext/src/lib/footnote/footnote-sync.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
|
||||
import { Node as ProseMirrorNode, Fragment } from "@tiptap/pm/model";
|
||||
import {
|
||||
FOOTNOTE_DEFINITION_NAME,
|
||||
FOOTNOTE_REFERENCE_NAME,
|
||||
FOOTNOTES_LIST_NAME,
|
||||
} from "./footnote-util";
|
||||
|
||||
export const footnoteSyncPluginKey = new PluginKey("footnoteSync");
|
||||
|
||||
const SYNC_META = "footnoteSyncApplied";
|
||||
|
||||
interface FootnoteScan {
|
||||
/** Reference ids in document order, first occurrence only, de-duplicated. */
|
||||
referenceIds: string[];
|
||||
/** definition id -> node (last occurrence wins, matching scan order). */
|
||||
definitions: Map<string, ProseMirrorNode>;
|
||||
/** Every top-level footnotesList node, in document order. */
|
||||
lists: Array<{ pos: number; node: ProseMirrorNode }>;
|
||||
}
|
||||
|
||||
function scan(doc: ProseMirrorNode): FootnoteScan {
|
||||
const referenceIds: string[] = [];
|
||||
const seenRefs = new Set<string>();
|
||||
const definitions = new Map<string, ProseMirrorNode>();
|
||||
const lists: Array<{ pos: number; node: ProseMirrorNode }> = [];
|
||||
|
||||
doc.descendants((node, pos) => {
|
||||
if (node.type.name === FOOTNOTE_REFERENCE_NAME) {
|
||||
const id = node.attrs.id;
|
||||
if (id && !seenRefs.has(id)) {
|
||||
seenRefs.add(id);
|
||||
referenceIds.push(id);
|
||||
}
|
||||
}
|
||||
if (node.type.name === FOOTNOTE_DEFINITION_NAME) {
|
||||
const id = node.attrs.id;
|
||||
if (id) definitions.set(id, node);
|
||||
}
|
||||
if (node.type.name === FOOTNOTES_LIST_NAME) {
|
||||
lists.push({ pos, node });
|
||||
}
|
||||
});
|
||||
|
||||
return { referenceIds, definitions, lists };
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotent integrity pass for footnotes. Runs only on LOCAL document changes
|
||||
* (skips remote/collaboration steps and — crucially — its own appended meta) so
|
||||
* the plugin can never re-trigger itself, guaranteeing termination.
|
||||
*
|
||||
* Everything is computed against the CURRENT document in a SINGLE invocation and
|
||||
* emitted as AT MOST ONE transaction, always tagged with SYNC_META (and
|
||||
* addToHistory:false). The strategy is "rebuild the canonical footnotes section
|
||||
* from the desired end-state" rather than running several self-triggering
|
||||
* passes:
|
||||
*
|
||||
* 1. Collect every footnote reference id in document order (the source of
|
||||
* truth for which definitions must exist and in what order).
|
||||
* 2. Compute the desired list of definitions: one per referenced id, in
|
||||
* reference order, reusing the existing definition node when present or
|
||||
* creating an empty one when missing. Orphan definitions (no matching
|
||||
* reference) are dropped.
|
||||
* 3. Compare against the actual footnotesList state:
|
||||
* - no references -> there must be NO list (remove any);
|
||||
* - references present -> there must be exactly ONE list, holding
|
||||
* exactly the desired definitions, and it
|
||||
* must sit after all real body content.
|
||||
* 4. If the document already matches the desired end-state, return null (no
|
||||
* transaction) — this idempotence is what stops oscillation.
|
||||
*
|
||||
* Placement note: the list is considered correctly placed when nothing but
|
||||
* EMPTY paragraphs follow it. This is deliberate so the plugin coexists with a
|
||||
* trailing-node plugin (which keeps an empty paragraph at the very end of the
|
||||
* doc): the footnote list does not need to be the literal last child, only the
|
||||
* last block of meaningful content. Without this, the two plugins would
|
||||
* ping-pong forever (list moved to end -> trailing paragraph appended -> list
|
||||
* no longer last -> moved again ...).
|
||||
*
|
||||
* Paste id-collision regeneration is left to the paste handler / v2; the common
|
||||
* cases (orphans, missing definitions, multiple/empty/misplaced lists) are
|
||||
* covered here.
|
||||
*/
|
||||
export function footnoteSyncPlugin(
|
||||
isRemoteTransaction?: (tr: Transaction) => boolean,
|
||||
): Plugin {
|
||||
return new Plugin({
|
||||
key: footnoteSyncPluginKey,
|
||||
appendTransaction(transactions, _oldState, newState) {
|
||||
// Only react to document changes.
|
||||
if (!transactions.some((t) => t.docChanged)) return null;
|
||||
// Skip our OWN appended transaction. This is the guard that makes the
|
||||
// plugin loop-safe: the transaction we emit carries SYNC_META, so when
|
||||
// ProseMirror feeds it back to appendTransaction we bail out immediately
|
||||
// and never produce a follow-up. (Termination invariant.)
|
||||
if (transactions.some((t) => t.getMeta(SYNC_META))) return null;
|
||||
// Skip remote/collab steps (orphan cleanup must run only on local edits).
|
||||
if (
|
||||
isRemoteTransaction &&
|
||||
transactions.some((t) => isRemoteTransaction(t))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { doc, schema } = newState;
|
||||
const defType = schema.nodes[FOOTNOTE_DEFINITION_NAME];
|
||||
const listType = schema.nodes[FOOTNOTES_LIST_NAME];
|
||||
const paragraphType = schema.nodes.paragraph;
|
||||
if (!defType || !listType || !paragraphType) return null;
|
||||
|
||||
const info = scan(doc);
|
||||
|
||||
// 1) Desired definitions: one per referenced id, in reference order,
|
||||
// reusing existing definition nodes (preserving their content) and
|
||||
// synthesizing empty ones for references that lack a definition.
|
||||
const desiredDefs: ProseMirrorNode[] = info.referenceIds.map((id) => {
|
||||
const existing = info.definitions.get(id);
|
||||
if (existing) return existing;
|
||||
return defType.create({ id }, paragraphType.create());
|
||||
});
|
||||
|
||||
// 2) Determine whether the document already matches the desired end-state.
|
||||
const hasRefs = desiredDefs.length > 0;
|
||||
|
||||
// Is the existing single list already exactly the desired list, placed
|
||||
// after all meaningful content (nothing but empty paragraphs after it)?
|
||||
const isEmptyParagraph = (node: ProseMirrorNode) =>
|
||||
node.type === paragraphType && node.content.size === 0;
|
||||
|
||||
let alreadyCanonical = false;
|
||||
if (!hasRefs) {
|
||||
// Canonical when there is no footnotesList at all.
|
||||
alreadyCanonical = info.lists.length === 0;
|
||||
} else if (info.lists.length === 1) {
|
||||
const { pos, node } = info.lists[0];
|
||||
// Same definitions, same order, same identity (no rewrite needed)?
|
||||
const sameDefs =
|
||||
node.childCount === desiredDefs.length &&
|
||||
desiredDefs.every((d, i) => node.child(i) === d);
|
||||
|
||||
// Placement: only empty paragraphs may follow the list.
|
||||
const listEnd = pos + node.nodeSize;
|
||||
let onlyEmptyParasAfter = true;
|
||||
doc.nodesBetween(listEnd, doc.content.size, (child, childPos) => {
|
||||
// Only inspect top-level children that start at/after the list end.
|
||||
if (childPos >= listEnd && child !== node) {
|
||||
if (!isEmptyParagraph(child)) onlyEmptyParasAfter = false;
|
||||
}
|
||||
return false; // do not descend
|
||||
});
|
||||
|
||||
alreadyCanonical = sameDefs && onlyEmptyParasAfter;
|
||||
}
|
||||
|
||||
if (alreadyCanonical) return null;
|
||||
|
||||
// 3) Rebuild: produce exactly ONE transaction that reaches the end-state.
|
||||
const tr = newState.tr;
|
||||
|
||||
// Delete every existing footnotesList (from the end so earlier positions
|
||||
// stay valid while we mutate).
|
||||
[...info.lists]
|
||||
.sort((a, b) => b.pos - a.pos)
|
||||
.forEach(({ pos, node }) => {
|
||||
tr.delete(pos, pos + node.nodeSize);
|
||||
});
|
||||
|
||||
if (hasRefs) {
|
||||
// Insert a single canonical list holding the desired definitions. Place
|
||||
// it after the last meaningful (non-empty-paragraph) top-level block, so
|
||||
// it lands before any trailing empty paragraph the trailing-node plugin
|
||||
// maintains. This keeps both plugins idempotent.
|
||||
const mappedDoc = tr.doc;
|
||||
let insertPos = mappedDoc.content.size;
|
||||
for (let i = mappedDoc.childCount - 1; i >= 0; i--) {
|
||||
const child = mappedDoc.child(i);
|
||||
if (isEmptyParagraph(child)) {
|
||||
// skip trailing empty paragraphs; insert before them
|
||||
insertPos -= child.nodeSize;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const merged = listType.create(null, Fragment.fromArray(desiredDefs));
|
||||
tr.insert(insertPos, merged);
|
||||
}
|
||||
|
||||
if (!tr.docChanged) return null;
|
||||
|
||||
tr.setMeta(SYNC_META, true);
|
||||
tr.setMeta("addToHistory", false);
|
||||
return tr;
|
||||
},
|
||||
});
|
||||
}
|
||||
77
packages/editor-ext/src/lib/footnote/footnote-util.ts
Normal file
77
packages/editor-ext/src/lib/footnote/footnote-util.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
|
||||
/**
|
||||
* Node type names for the footnote feature. Centralized so every part of the
|
||||
* feature (nodes, plugins, commands) references the same string.
|
||||
*/
|
||||
export const FOOTNOTE_REFERENCE_NAME = "footnoteReference";
|
||||
export const FOOTNOTES_LIST_NAME = "footnotesList";
|
||||
export const FOOTNOTE_DEFINITION_NAME = "footnoteDefinition";
|
||||
|
||||
/**
|
||||
* Generate a uuidv7-style id (time-ordered). Implemented locally so editor-ext
|
||||
* does not need a runtime dependency on the `uuid` package; matches the
|
||||
* lexicographically-sortable layout uuidv7 produces.
|
||||
*/
|
||||
export function generateFootnoteId(): string {
|
||||
const now = Date.now();
|
||||
const timeHex = now.toString(16).padStart(12, "0");
|
||||
|
||||
const rand = (length: number) => {
|
||||
let out = "";
|
||||
for (let i = 0; i < length; i++) {
|
||||
out += Math.floor(Math.random() * 16).toString(16);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// version 7 nibble, then variant (8..b) nibble.
|
||||
const versioned = "7" + rand(3);
|
||||
const variantNibble = (8 + Math.floor(Math.random() * 4)).toString(16);
|
||||
const variant = variantNibble + rand(3);
|
||||
|
||||
return (
|
||||
timeHex.slice(0, 8) +
|
||||
"-" +
|
||||
timeHex.slice(8, 12) +
|
||||
"-" +
|
||||
versioned +
|
||||
"-" +
|
||||
variant +
|
||||
"-" +
|
||||
rand(12)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect every `footnoteReference` id in document order. This is the single
|
||||
* source of truth for numbering and ordering — a pure function of the document
|
||||
* so every collaborating client computes the same result.
|
||||
*/
|
||||
export function collectReferenceIds(doc: ProseMirrorNode): string[] {
|
||||
const ids: string[] = [];
|
||||
doc.descendants((node) => {
|
||||
if (node.type.name === FOOTNOTE_REFERENCE_NAME) {
|
||||
const id = node.attrs.id;
|
||||
if (id) ids.push(id);
|
||||
}
|
||||
});
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a map of `referenceId -> displayNumber` (1-based) from document order.
|
||||
* Pure function — the basis for the numbering decorations and any test.
|
||||
*/
|
||||
export function computeFootnoteNumbers(
|
||||
doc: ProseMirrorNode,
|
||||
): Map<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;
|
||||
}
|
||||
536
packages/editor-ext/src/lib/footnote/footnote.test.ts
Normal file
536
packages/editor-ext/src/lib/footnote/footnote.test.ts
Normal file
@@ -0,0 +1,536 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Editor, Extension, getSchema } from "@tiptap/core";
|
||||
import { Document } from "@tiptap/extension-document";
|
||||
import { Paragraph } from "@tiptap/extension-paragraph";
|
||||
import { Text } from "@tiptap/extension-text";
|
||||
import { Superscript } from "@tiptap/extension-superscript";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { Node as PMNode } from "@tiptap/pm/model";
|
||||
import { FootnoteReference } from "./footnote-reference";
|
||||
import { FootnotesList } from "./footnotes-list";
|
||||
import { FootnoteDefinition } from "./footnote-definition";
|
||||
import { TrailingNode } from "../trailing-node";
|
||||
import {
|
||||
computeFootnoteNumbers,
|
||||
collectReferenceIds,
|
||||
FOOTNOTE_REFERENCE_NAME,
|
||||
FOOTNOTES_LIST_NAME,
|
||||
FOOTNOTE_DEFINITION_NAME,
|
||||
} from "./footnote-util";
|
||||
|
||||
const extensions = [
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
FootnoteReference,
|
||||
FootnotesList,
|
||||
FootnoteDefinition,
|
||||
];
|
||||
|
||||
function makeEditor(content?: any) {
|
||||
return new Editor({
|
||||
extensions,
|
||||
content: content ?? { type: "doc", content: [{ type: "paragraph" }] },
|
||||
});
|
||||
}
|
||||
|
||||
function countType(doc: PMNode, name: string): number {
|
||||
let n = 0;
|
||||
doc.descendants((node) => {
|
||||
if (node.type.name === name) n++;
|
||||
});
|
||||
return n;
|
||||
}
|
||||
|
||||
describe("footnote numbering (pure function)", () => {
|
||||
it("numbers references in document order", () => {
|
||||
const schema = getSchema(extensions);
|
||||
const doc = PMNode.fromJSON(schema, {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "text", text: "a" },
|
||||
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "x" } },
|
||||
{ type: "text", text: "b" },
|
||||
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "y" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: FOOTNOTES_LIST_NAME,
|
||||
content: [
|
||||
{
|
||||
type: FOOTNOTE_DEFINITION_NAME,
|
||||
attrs: { id: "x" },
|
||||
content: [{ type: "paragraph" }],
|
||||
},
|
||||
{
|
||||
type: FOOTNOTE_DEFINITION_NAME,
|
||||
attrs: { id: "y" },
|
||||
content: [{ type: "paragraph" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(collectReferenceIds(doc)).toEqual(["x", "y"]);
|
||||
const numbers = computeFootnoteNumbers(doc);
|
||||
expect(numbers.get("x")).toBe(1);
|
||||
expect(numbers.get("y")).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setFootnote command", () => {
|
||||
it("inserts a reference and a matching definition in the footnotes list", () => {
|
||||
const editor = makeEditor({
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "Hello" }] },
|
||||
],
|
||||
});
|
||||
// Cursor at end of the word.
|
||||
editor.commands.setTextSelection(6);
|
||||
const ok = editor.commands.setFootnote();
|
||||
expect(ok).toBe(true);
|
||||
|
||||
const doc = editor.state.doc;
|
||||
expect(countType(doc, FOOTNOTE_REFERENCE_NAME)).toBe(1);
|
||||
expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(1);
|
||||
expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(1);
|
||||
|
||||
// The reference id and the definition id match.
|
||||
let refId: string | null = null;
|
||||
let defId: string | null = null;
|
||||
doc.descendants((node) => {
|
||||
if (node.type.name === FOOTNOTE_REFERENCE_NAME) refId = node.attrs.id;
|
||||
if (node.type.name === FOOTNOTE_DEFINITION_NAME) defId = node.attrs.id;
|
||||
});
|
||||
expect(refId).toBeTruthy();
|
||||
expect(refId).toBe(defId);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("inserts the definition at the correct position matching reference order", () => {
|
||||
const editor = makeEditor({
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "AAAA" }] },
|
||||
{ type: "paragraph", content: [{ type: "text", text: "BBBB" }] },
|
||||
],
|
||||
});
|
||||
|
||||
// First footnote: place inside the SECOND paragraph (after "BBBB").
|
||||
editor.commands.setTextSelection(11); // end of BBBB
|
||||
editor.commands.setFootnote();
|
||||
|
||||
// Second footnote: place inside the FIRST paragraph (after "AAAA"),
|
||||
// which is BEFORE the first reference in document order.
|
||||
editor.commands.setTextSelection(5); // end of AAAA
|
||||
editor.commands.setFootnote();
|
||||
|
||||
const doc = editor.state.doc;
|
||||
// Reference order in document.
|
||||
const refOrder = collectReferenceIds(doc);
|
||||
// Definition order in the list.
|
||||
const defOrder: string[] = [];
|
||||
doc.descendants((node) => {
|
||||
if (node.type.name === FOOTNOTE_DEFINITION_NAME) {
|
||||
defOrder.push(node.attrs.id);
|
||||
}
|
||||
});
|
||||
|
||||
expect(defOrder).toEqual(refOrder);
|
||||
expect(defOrder.length).toBe(2);
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeFootnote command (cascade)", () => {
|
||||
it("removes both the reference and its definition, and drops the empty list", () => {
|
||||
const editor = makeEditor({
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "Hello" }] },
|
||||
],
|
||||
});
|
||||
editor.commands.setTextSelection(6);
|
||||
editor.commands.setFootnote();
|
||||
|
||||
let id: string | null = null;
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === FOOTNOTE_REFERENCE_NAME) id = node.attrs.id;
|
||||
});
|
||||
expect(id).toBeTruthy();
|
||||
|
||||
editor.commands.removeFootnote(id!);
|
||||
|
||||
const doc = editor.state.doc;
|
||||
expect(countType(doc, FOOTNOTE_REFERENCE_NAME)).toBe(0);
|
||||
expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(0);
|
||||
// empty list removed
|
||||
expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(0);
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("footnote sync plugin (orphans)", () => {
|
||||
it("creates an empty definition for a reference pasted without one", () => {
|
||||
const editor = makeEditor({
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "text", text: "x" },
|
||||
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "orphan-ref" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
// Trigger a doc change so appendTransaction runs.
|
||||
editor.commands.insertContentAt(1, " ");
|
||||
|
||||
const doc = editor.state.doc;
|
||||
let defFound = false;
|
||||
doc.descendants((node) => {
|
||||
if (
|
||||
node.type.name === FOOTNOTE_DEFINITION_NAME &&
|
||||
node.attrs.id === "orphan-ref"
|
||||
) {
|
||||
defFound = true;
|
||||
}
|
||||
});
|
||||
expect(defFound).toBe(true);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("merges multiple footnotesList nodes into one, preserving all definitions, as the last child", () => {
|
||||
const editor = makeEditor({
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "text", text: "a" },
|
||||
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "x" } },
|
||||
{ type: "text", text: "b" },
|
||||
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "y" } },
|
||||
],
|
||||
},
|
||||
// First (stray) footnotes list, e.g. from a paste/collab merge.
|
||||
{
|
||||
type: FOOTNOTES_LIST_NAME,
|
||||
content: [
|
||||
{
|
||||
type: FOOTNOTE_DEFINITION_NAME,
|
||||
attrs: { id: "x" },
|
||||
content: [{ type: "paragraph", content: [{ type: "text", text: "X note" }] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: "paragraph", content: [{ type: "text", text: "tail" }] },
|
||||
// Second footnotes list (the "real" trailing one).
|
||||
{
|
||||
type: FOOTNOTES_LIST_NAME,
|
||||
content: [
|
||||
{
|
||||
type: FOOTNOTE_DEFINITION_NAME,
|
||||
attrs: { id: "y" },
|
||||
content: [{ type: "paragraph", content: [{ type: "text", text: "Y note" }] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
// Trigger a local doc change so appendTransaction runs.
|
||||
editor.commands.insertContentAt(1, " ");
|
||||
|
||||
const doc = editor.state.doc;
|
||||
// Converged to exactly ONE list.
|
||||
expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(1);
|
||||
// Both definitions preserved (no tracking lost).
|
||||
const defIds: string[] = [];
|
||||
doc.descendants((node) => {
|
||||
if (node.type.name === FOOTNOTE_DEFINITION_NAME) defIds.push(node.attrs.id);
|
||||
});
|
||||
expect(defIds.sort()).toEqual(["x", "y"]);
|
||||
// The single list is the LAST child of the document.
|
||||
const lastChild = doc.child(doc.childCount - 1);
|
||||
expect(lastChild.type.name).toBe(FOOTNOTES_LIST_NAME);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("leaves a correct doc (single trailing list) unchanged — no merge loop", () => {
|
||||
const editor = makeEditor({
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "text", text: "a" },
|
||||
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "x" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: FOOTNOTES_LIST_NAME,
|
||||
content: [
|
||||
{
|
||||
type: FOOTNOTE_DEFINITION_NAME,
|
||||
attrs: { id: "x" },
|
||||
content: [{ type: "paragraph", content: [{ type: "text", text: "X note" }] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const before = editor.state.doc.toJSON();
|
||||
// A change that doesn't touch footnote structure.
|
||||
editor.commands.insertContentAt(1, "z");
|
||||
const doc = editor.state.doc;
|
||||
// Still exactly one list, still last, definition preserved.
|
||||
expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(1);
|
||||
const lastChild = doc.child(doc.childCount - 1);
|
||||
expect(lastChild.type.name).toBe(FOOTNOTES_LIST_NAME);
|
||||
// The footnotes list subtree is identical to before (no spurious rewrite).
|
||||
const beforeList = before.content.find(
|
||||
(n: any) => n.type === FOOTNOTES_LIST_NAME,
|
||||
);
|
||||
const afterList = doc
|
||||
.toJSON()
|
||||
.content.find((n: any) => n.type === FOOTNOTES_LIST_NAME);
|
||||
expect(afterList).toEqual(beforeList);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("removes an orphan definition with no matching reference", () => {
|
||||
const editor = makeEditor({
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "x" }] },
|
||||
{
|
||||
type: FOOTNOTES_LIST_NAME,
|
||||
content: [
|
||||
{
|
||||
type: FOOTNOTE_DEFINITION_NAME,
|
||||
attrs: { id: "orphan-def" },
|
||||
content: [{ type: "paragraph" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
editor.commands.insertContentAt(1, "y");
|
||||
|
||||
const doc = editor.state.doc;
|
||||
expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(0);
|
||||
expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(0);
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Live-editor regression tests for the sync-plugin infinite loop (the hard
|
||||
* freeze when activating /footnote). These drive a REAL Tiptap editor through
|
||||
* the same plugin pipeline the browser uses — including the TrailingNode plugin,
|
||||
* which is what turned the "move list to the end" pass into an infinite
|
||||
* ping-pong (list moved last -> trailing paragraph appended after it -> list no
|
||||
* longer last -> moved again -> ...).
|
||||
*
|
||||
* If the loop regresses, ProseMirror's appendTransaction round loop never
|
||||
* terminates and these tests HANG (the vitest timeout fails them). The
|
||||
* transaction counter additionally fails fast with a bounded iteration cap, so
|
||||
* a regression surfaces as an explicit error instead of only a slow timeout.
|
||||
*/
|
||||
describe("footnote sync plugin (no infinite loop — live editor)", () => {
|
||||
// Hard cap on how many doc-changing appendTransaction rounds we tolerate for a
|
||||
// single user action. Convergence takes a couple of rounds at most; anything
|
||||
// approaching this means the plugins are oscillating.
|
||||
const MAX_ROUNDS = 50;
|
||||
|
||||
// The production editor wires FootnoteReference alongside TrailingNode and
|
||||
// Superscript; both participate in the loop the bug exhibited, so we mirror
|
||||
// that here.
|
||||
function makeLiveEditor(content?: any) {
|
||||
let rounds = 0;
|
||||
// A guard plugin that counts doc-changing appendTransaction rounds and
|
||||
// throws if they exceed the cap, converting a would-be infinite loop into a
|
||||
// deterministic failure instead of a wall-clock hang.
|
||||
const LoopGuard = Extension.create({
|
||||
name: "footnoteLoopGuard",
|
||||
// Run last so it observes every other plugin's appended transaction.
|
||||
priority: -1000,
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("footnoteLoopGuard"),
|
||||
appendTransaction(transactions) {
|
||||
if (transactions.some((t) => t.docChanged)) {
|
||||
rounds += 1;
|
||||
if (rounds > MAX_ROUNDS) {
|
||||
throw new Error(
|
||||
`footnote sync did not converge: exceeded ${MAX_ROUNDS} appendTransaction rounds (infinite loop)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
const editor = new Editor({
|
||||
extensions: [
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
Superscript,
|
||||
TrailingNode,
|
||||
LoopGuard,
|
||||
FootnoteReference,
|
||||
FootnotesList,
|
||||
FootnoteDefinition,
|
||||
],
|
||||
content: content ?? { type: "doc", content: [{ type: "paragraph" }] },
|
||||
});
|
||||
return { editor, getRounds: () => rounds, resetRounds: () => (rounds = 0) };
|
||||
}
|
||||
|
||||
function lastFootnotesListIsTrailing(doc: PMNode): boolean {
|
||||
// Canonical placement: the list is the last meaningful block — only empty
|
||||
// paragraphs (the trailing-node) may follow it.
|
||||
let listIndex = -1;
|
||||
for (let i = 0; i < doc.childCount; i++) {
|
||||
if (doc.child(i).type.name === FOOTNOTES_LIST_NAME) listIndex = i;
|
||||
}
|
||||
if (listIndex === -1) return false;
|
||||
for (let i = listIndex + 1; i < doc.childCount; i++) {
|
||||
const child = doc.child(i);
|
||||
if (!(child.type.name === "paragraph" && child.content.size === 0)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
it("setFootnote() RETURNS (no hang) and produces one ref + one def in a trailing list", () => {
|
||||
const { editor } = makeLiveEditor({
|
||||
type: "doc",
|
||||
content: [{ type: "paragraph", content: [{ type: "text", text: "Hi" }] }],
|
||||
});
|
||||
editor.commands.setTextSelection(3);
|
||||
const ok = editor.commands.setFootnote();
|
||||
expect(ok).toBe(true);
|
||||
|
||||
const doc = editor.state.doc;
|
||||
expect(countType(doc, FOOTNOTE_REFERENCE_NAME)).toBe(1);
|
||||
expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(1);
|
||||
expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(1);
|
||||
expect(lastFootnotesListIsTrailing(doc)).toBe(true);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("a second setFootnote() does not hang: two refs + two defs in one list", () => {
|
||||
const { editor } = makeLiveEditor({
|
||||
type: "doc",
|
||||
content: [{ type: "paragraph", content: [{ type: "text", text: "Hi" }] }],
|
||||
});
|
||||
editor.commands.setTextSelection(3);
|
||||
editor.commands.setFootnote();
|
||||
editor.commands.setTextSelection(3);
|
||||
editor.commands.setFootnote();
|
||||
|
||||
const doc = editor.state.doc;
|
||||
expect(countType(doc, FOOTNOTE_REFERENCE_NAME)).toBe(2);
|
||||
expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(2);
|
||||
expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(1);
|
||||
expect(lastFootnotesListIsTrailing(doc)).toBe(true);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("converges and stabilizes: an unrelated edit does not keep producing transactions", () => {
|
||||
const { editor, getRounds, resetRounds } = makeLiveEditor({
|
||||
type: "doc",
|
||||
content: [{ type: "paragraph", content: [{ type: "text", text: "Hi" }] }],
|
||||
});
|
||||
editor.commands.setTextSelection(3);
|
||||
editor.commands.setFootnote();
|
||||
|
||||
// Now the doc is canonical. Dispatch an unrelated edit (insert text) and
|
||||
// assert the sync plugin converges in a bounded number of rounds and the
|
||||
// document is stable (one ref/def/list, list trailing).
|
||||
resetRounds();
|
||||
editor.commands.insertContentAt(1, "Z");
|
||||
const afterFirst = editor.state.doc.toJSON();
|
||||
const roundsAfterEdit = getRounds();
|
||||
expect(roundsAfterEdit).toBeLessThan(MAX_ROUNDS);
|
||||
|
||||
// A follow-up no-op-ish edit must not re-trigger structural rewrites: the
|
||||
// footnotes section is identical before and after a further unrelated edit.
|
||||
editor.commands.insertContentAt(2, "Y");
|
||||
const afterSecond = editor.state.doc.toJSON();
|
||||
|
||||
const listOf = (json: any) =>
|
||||
json.content.find((n: any) => n.type === FOOTNOTES_LIST_NAME);
|
||||
expect(listOf(afterSecond)).toEqual(listOf(afterFirst));
|
||||
expect(countType(editor.state.doc, FOOTNOTES_LIST_NAME)).toBe(1);
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("two footnotesList nodes converge to one (merge) without looping", () => {
|
||||
const { editor } = makeLiveEditor({
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "text", text: "a" },
|
||||
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "x" } },
|
||||
{ type: "text", text: "b" },
|
||||
{ type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "y" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: FOOTNOTES_LIST_NAME,
|
||||
content: [
|
||||
{
|
||||
type: FOOTNOTE_DEFINITION_NAME,
|
||||
attrs: { id: "x" },
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "X" }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ type: "paragraph", content: [{ type: "text", text: "tail" }] },
|
||||
{
|
||||
type: FOOTNOTES_LIST_NAME,
|
||||
content: [
|
||||
{
|
||||
type: FOOTNOTE_DEFINITION_NAME,
|
||||
attrs: { id: "y" },
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "Y" }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
// Trigger a local doc change so appendTransaction runs (must not hang).
|
||||
editor.commands.insertContentAt(1, " ");
|
||||
|
||||
const doc = editor.state.doc;
|
||||
expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(1);
|
||||
const defIds: string[] = [];
|
||||
doc.descendants((node) => {
|
||||
if (node.type.name === FOOTNOTE_DEFINITION_NAME)
|
||||
defIds.push(node.attrs.id);
|
||||
});
|
||||
expect(defIds.sort()).toEqual(["x", "y"]);
|
||||
expect(lastFootnotesListIsTrailing(doc)).toBe(true);
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
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";
|
||||
115
packages/editor-ext/src/lib/markdown/utils/footnote.marked.ts
Normal file
115
packages/editor-ext/src/lib/markdown/utils/footnote.marked.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { marked } from "marked";
|
||||
|
||||
/**
|
||||
* Pandoc/GFM footnote support for the marked (Markdown -> HTML) pipeline.
|
||||
*
|
||||
* Two pieces:
|
||||
* - an INLINE tokenizer for `[^id]` references -> <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, """);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: "" };
|
||||
}
|
||||
|
||||
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: bodyLines.join("\n"),
|
||||
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";
|
||||
|
||||
marked.use({
|
||||
renderer: {
|
||||
@@ -34,7 +38,12 @@ marked.use({
|
||||
});
|
||||
|
||||
marked.use({
|
||||
extensions: [calloutExtension, mathBlockExtension, mathInlineExtension],
|
||||
extensions: [
|
||||
calloutExtension,
|
||||
mathBlockExtension,
|
||||
mathInlineExtension,
|
||||
footnoteReferenceExtension,
|
||||
],
|
||||
});
|
||||
|
||||
marked.setOptions({ breaks: true });
|
||||
@@ -48,5 +57,16 @@ export function markdownToHtml(
|
||||
.replace(YAML_FONT_MATTER_REGEX, "")
|
||||
.trimStart();
|
||||
|
||||
return marked.parse(markdown).toString();
|
||||
// Pull `[^id]: ...` definition lines out of the body, render the body, then
|
||||
// append a single <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([
|
||||
@@ -34,8 +66,12 @@ export function htmlToMarkdown(html: string): string {
|
||||
iframeEmbed,
|
||||
image,
|
||||
video,
|
||||
footnoteReference,
|
||||
footnotesList,
|
||||
]);
|
||||
return turndownService.turndown(html).replaceAll('<br>', ' ');
|
||||
return turndownService
|
||||
.turndown(fillEmptyFootnoteRefs(html))
|
||||
.replaceAll('<br>', ' ');
|
||||
}
|
||||
|
||||
function listParagraph(turndownService: _TurndownService) {
|
||||
@@ -203,6 +239,57 @@ function image(turndownService: _TurndownService) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Footnote reference (inline atom) -> pandoc/GFM marker `[^id]`.
|
||||
* The visible number is derived (not stored), so the id is the stable anchor.
|
||||
*/
|
||||
function footnoteReference(turndownService: _TurndownService) {
|
||||
turndownService.addRule('footnoteReference', {
|
||||
filter: function (node: HTMLInputElement) {
|
||||
return (
|
||||
node.nodeName === 'SUP' && node.hasAttribute('data-footnote-ref')
|
||||
);
|
||||
},
|
||||
replacement: function (_content: string, node: HTMLInputElement) {
|
||||
const id = node.getAttribute('data-id') || '';
|
||||
return id ? `[^${id}]` : '';
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Footnotes container -> the list of `[^id]: text` definitions at the end of
|
||||
* the document (one per line). Each footnoteDefinition inside emits its own
|
||||
* `[^id]: ...` line; turndown joins them with the surrounding block spacing.
|
||||
*/
|
||||
function footnotesList(turndownService: _TurndownService) {
|
||||
turndownService.addRule('footnoteDefinition', {
|
||||
filter: function (node: HTMLInputElement) {
|
||||
return (
|
||||
node.nodeName === 'DIV' && node.hasAttribute('data-footnote-def')
|
||||
);
|
||||
},
|
||||
replacement: function (content: string, node: HTMLInputElement) {
|
||||
const id = node.getAttribute('data-id') || '';
|
||||
// Collapse internal newlines so the definition stays a single MD line;
|
||||
// continuation lines are a v2 refinement.
|
||||
const text = content.replace(/\s*\n+\s*/g, ' ').trim();
|
||||
return id ? `\n[^${id}]: ${text}\n` : '';
|
||||
},
|
||||
});
|
||||
|
||||
turndownService.addRule('footnotesList', {
|
||||
filter: function (node: HTMLInputElement) {
|
||||
return (
|
||||
node.nodeName === 'SECTION' && node.hasAttribute('data-footnotes')
|
||||
);
|
||||
},
|
||||
replacement: function (content: string) {
|
||||
return `\n\n${content.trim()}\n`;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function video(turndownService: _TurndownService) {
|
||||
turndownService.addRule('video', {
|
||||
filter: function (node: HTMLInputElement) {
|
||||
|
||||
@@ -19,5 +19,6 @@
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false
|
||||
}
|
||||
},
|
||||
"exclude": ["**/*.test.ts", "vitest.config.ts", "dist"]
|
||||
}
|
||||
|
||||
8
packages/editor-ext/vitest.config.ts
Normal file
8
packages/editor-ext/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
@@ -263,10 +263,75 @@ 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, """);
|
||||
}
|
||||
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: "" };
|
||||
const inner = defs
|
||||
.map((d) => `<div data-footnote-def data-id="${escapeFootnoteAttr(d.id)}"><p>${marked.parseInline(d.text || "")}</p></div>`)
|
||||
.join("");
|
||||
return {
|
||||
body: bodyLines.join("\n"),
|
||||
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`);
|
||||
}
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -296,12 +296,87 @@ function bridgeTaskLists(html: string): string {
|
||||
return document.body.innerHTML;
|
||||
}
|
||||
|
||||
// Mirror of packages/editor-ext footnote markdown handling. A `[^id]` inline
|
||||
// marker becomes <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, """);
|
||||
}
|
||||
|
||||
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: "" };
|
||||
const inner = defs
|
||||
.map(
|
||||
(d) =>
|
||||
`<div data-footnote-def data-id="${escapeFootnoteAttr(
|
||||
d.id,
|
||||
)}"><p>${marked.parseInline(d.text || "")}</p></div>`,
|
||||
)
|
||||
.join("");
|
||||
return {
|
||||
body: bodyLines.join("\n"),
|
||||
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 };
|
||||
}
|
||||
|
||||
120
packages/mcp/test/unit/footnotes.test.mjs
Normal file
120
packages/mcp/test/unit/footnotes.test.mjs
Normal file
@@ -0,0 +1,120 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { convertProseMirrorToMarkdown } from "../../build/lib/markdown-converter.js";
|
||||
import { markdownToProseMirror } from "../../build/lib/collaboration.js";
|
||||
|
||||
/** Recursively collect every node of `type`. */
|
||||
function findAll(node, type, acc = []) {
|
||||
if (!node || typeof node !== "object") return acc;
|
||||
if (node.type === type) acc.push(node);
|
||||
if (Array.isArray(node.content)) {
|
||||
for (const c of node.content) findAll(c, type, acc);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
const footnoteDoc = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "text", text: "Water" },
|
||||
{ type: "footnoteReference", attrs: { id: "fn1" } },
|
||||
{ type: "text", text: " and clay" },
|
||||
{ type: "footnoteReference", attrs: { id: "fn2" } },
|
||||
{ type: "text", text: "." },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "footnotesList",
|
||||
content: [
|
||||
{
|
||||
type: "footnoteDefinition",
|
||||
attrs: { id: "fn1" },
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "First note." }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "footnoteDefinition",
|
||||
attrs: { id: "fn2" },
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "Second note." }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
test("JSON -> Markdown emits pandoc footnote syntax", () => {
|
||||
const md = convertProseMirrorToMarkdown(footnoteDoc);
|
||||
assert.match(md, /\[\^fn1\]/);
|
||||
assert.match(md, /\[\^fn2\]/);
|
||||
assert.match(md, /\[\^fn1\]: First note\./);
|
||||
assert.match(md, /\[\^fn2\]: Second note\./);
|
||||
});
|
||||
|
||||
test("Markdown -> JSON rebuilds footnote nodes", async () => {
|
||||
const md = convertProseMirrorToMarkdown(footnoteDoc);
|
||||
const json = await markdownToProseMirror(md);
|
||||
|
||||
const refs = findAll(json, "footnoteReference");
|
||||
const list = findAll(json, "footnotesList");
|
||||
const defs = findAll(json, "footnoteDefinition");
|
||||
|
||||
assert.equal(refs.length, 2);
|
||||
assert.deepEqual(
|
||||
refs.map((r) => r.attrs.id),
|
||||
["fn1", "fn2"],
|
||||
);
|
||||
assert.equal(list.length, 1);
|
||||
assert.equal(defs.length, 2);
|
||||
assert.deepEqual(
|
||||
defs.map((d) => d.attrs.id),
|
||||
["fn1", "fn2"],
|
||||
);
|
||||
});
|
||||
|
||||
test("JSON -> MD -> JSON preserves footnote ids and text", async () => {
|
||||
const md = convertProseMirrorToMarkdown(footnoteDoc);
|
||||
const json = await markdownToProseMirror(md);
|
||||
const md2 = convertProseMirrorToMarkdown(json);
|
||||
|
||||
// The second markdown serialization carries the same markers + definitions.
|
||||
assert.match(md2, /\[\^fn1\]/);
|
||||
assert.match(md2, /\[\^fn2\]/);
|
||||
assert.match(md2, /\[\^fn1\]: First note\./);
|
||||
assert.match(md2, /\[\^fn2\]: Second note\./);
|
||||
});
|
||||
|
||||
test("a [^id]: line inside a fenced code block is NOT treated as a definition", async () => {
|
||||
// Markdown that DOCUMENTS footnote syntax inside a code fence. The example
|
||||
// definition line must be preserved verbatim inside the code block and not
|
||||
// pulled out into a real footnotesList / footnoteDefinition.
|
||||
const md = [
|
||||
"Intro text.",
|
||||
"",
|
||||
"```markdown",
|
||||
"Body[^demo]",
|
||||
"",
|
||||
"[^demo]: example definition",
|
||||
"```",
|
||||
"",
|
||||
"Outro.",
|
||||
].join("\n");
|
||||
|
||||
const json = await markdownToProseMirror(md);
|
||||
|
||||
// No real footnote nodes were extracted from the code block.
|
||||
assert.equal(findAll(json, "footnotesList").length, 0);
|
||||
assert.equal(findAll(json, "footnoteDefinition").length, 0);
|
||||
|
||||
// The example definition line survives somewhere in the code block text.
|
||||
const codeBlocks = findAll(json, "codeBlock");
|
||||
assert.ok(codeBlocks.length >= 1, "code block present");
|
||||
const codeText = JSON.stringify(json);
|
||||
assert.match(codeText, /\[\^demo\]: example definition/);
|
||||
});
|
||||
@@ -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