diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json
index 0d4926cd..336e8688 100644
--- a/apps/client/public/locales/ru-RU/translation.json
+++ b/apps/client/public/locales/ru-RU/translation.json
@@ -405,6 +405,8 @@
"Footnote {{number}}": "Сноска {{number}}",
"Go to footnote": "Перейти к сноске",
"Back to reference": "Вернуться к ссылке",
+ "Back to references": "Вернуться к ссылкам",
+ "Back to reference {{label}}": "Вернуться к ссылке {{label}}",
"Empty footnote": "Пустая сноска",
"Math inline": "Строчная формула",
"Insert inline math equation.": "Вставить математическое выражение в строку.",
diff --git a/apps/client/src/features/editor/components/footnote/footnote-definition-view.tsx b/apps/client/src/features/editor/components/footnote/footnote-definition-view.tsx
index e3e0522a..7f6cc7b3 100644
--- a/apps/client/src/features/editor/components/footnote/footnote-definition-view.tsx
+++ b/apps/client/src/features/editor/components/footnote/footnote-definition-view.tsx
@@ -1,25 +1,45 @@
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { useTranslation } from "react-i18next";
-import { getFootnoteNumber } from "@docmost/editor-ext";
+import { getFootnoteNumber, getFootnoteRefCount } from "@docmost/editor-ext";
import classes from "./footnote.module.css";
+/**
+ * A 0-based backlink index -> its lowercase letter label (0 -> "a", 25 -> "z",
+ * 26 -> "aa", ...), matching the Pandoc/Wikipedia "↩ a b c" convention.
+ */
+function backlinkLabel(index: number): string {
+ let out = "";
+ let x = index;
+ while (x >= 0) {
+ out = String.fromCharCode(97 + (x % 26)) + out;
+ x = Math.floor(x / 26) - 1;
+ }
+ return out;
+}
+
/**
* 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).
+ *
+ * After #166 a footnote can be referenced more than once (one number, one
+ * definition, N forward links). When it is, the back-link becomes a row of
+ * per-occurrence links — ↩ a b c … — each scrolling to its own reference (#168);
+ * a single-reference footnote keeps the plain ↩.
*/
export default function FootnoteDefinitionView(props: NodeViewProps) {
const { node, editor } = props;
const { t } = useTranslation();
const id = node.attrs.id as string;
- // Read the cached number from the numbering plugin (computed once per doc
- // change) rather than recomputing the whole map on every render.
+ // Read the cached number/ref-count from the numbering plugin (computed once
+ // per doc change) rather than recomputing the whole map on every render.
const number = getFootnoteNumber(editor.state, id) ?? "?";
+ const refCount = getFootnoteRefCount(editor.state, id);
- const handleBack = (e: React.MouseEvent) => {
+ const jumpTo = (e: React.MouseEvent, index: number) => {
e.preventDefault();
- editor.commands.scrollToReference(id);
+ editor.commands.scrollToReference(id, index);
};
return (
@@ -42,16 +62,47 @@ export default function FootnoteDefinitionView(props: NodeViewProps) {
>
{number}.
-
- ↩
-
+ {refCount > 1 ? (
+ // Multiple references -> ↩ followed by one lettered link per occurrence.
+
+
+ ↩
+
+ {Array.from({ length: refCount }, (_, i) => (
+ jumpTo(e, i)}
+ role="button"
+ aria-label={t("Back to reference {{label}}", {
+ label: backlinkLabel(i),
+ })}
+ title={t("Back to reference {{label}}", {
+ label: backlinkLabel(i),
+ })}
+ >
+ {backlinkLabel(i)}
+
+ ))}
+
+ ) : (
+ // Single reference -> the plain ↩ (unchanged behavior).
+ jumpTo(e, 0)}
+ role="button"
+ aria-label={t("Back to reference")}
+ title={t("Back to reference")}
+ >
+ ↩
+
+ )}
);
}
diff --git a/apps/client/src/features/editor/components/footnote/footnote-views.structure.test.tsx b/apps/client/src/features/editor/components/footnote/footnote-views.structure.test.tsx
index 3e28493d..e6cd46a6 100644
--- a/apps/client/src/features/editor/components/footnote/footnote-views.structure.test.tsx
+++ b/apps/client/src/features/editor/components/footnote/footnote-views.structure.test.tsx
@@ -1,5 +1,5 @@
-import { describe, it, expect, vi } from "vitest";
-import { render } from "@testing-library/react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+import { render, fireEvent } from "@testing-library/react";
/**
* Structural regression guard for #146 (PR #147).
@@ -36,10 +36,14 @@ vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
-// footnote-definition-view reads a cached number from the numbering plugin;
-// stub it so we don't need a live ProseMirror state.
+// footnote-definition-view reads a cached number + reference count from the
+// numbering plugin; stub them so we don't need a live ProseMirror state. The
+// ref-count is a hoisted mutable so a test can drive the single-vs-multi
+// backlink branch (#168). Default 1 = single reference (the #146 cases).
+const { mockRefCount } = vi.hoisted(() => ({ mockRefCount: { value: 1 } }));
vi.mock("@docmost/editor-ext", () => ({
getFootnoteNumber: () => 1,
+ getFootnoteRefCount: () => mockRefCount.value,
}));
// Mocks so CodeBlockView renders cheaply (no MantineProvider, no matchMedia).
@@ -59,7 +63,8 @@ vi.mock("@mantine/core", () => ({
),
}));
vi.mock("@/components/common/copy-button", () => ({
- CopyButton: ({ children }: any) => children({ copied: false, copy: () => {} }),
+ CopyButton: ({ children }: any) =>
+ children({ copied: false, copy: () => {} }),
}));
vi.mock("@tabler/icons-react", () => ({
IconCheck: () => null,
@@ -141,3 +146,71 @@ describe("#146 editable NodeView contentDOM-first invariant", () => {
},
);
});
+
+// #168: a footnote referenced more than once shows one lettered backlink per
+// occurrence (↩ a b c), each scrolling to its own reference; a single-reference
+// footnote keeps the plain ↩.
+describe("#168 footnote definition multi-backlinks", () => {
+ afterEach(() => {
+ // Reset the shared ref-count mock so other tests see a single reference.
+ mockRefCount.value = 1;
+ });
+
+ const makeProps = () =>
+ ({
+ node: { attrs: { id: "fn-1" }, textContent: "" },
+ editor: {
+ state: {},
+ isEditable: true,
+ commands: { scrollToReference: vi.fn() },
+ },
+ getPos: () => 0,
+ updateAttributes: () => {},
+ deleteNode: () => {},
+ }) as any;
+
+ it("renders one lettered backlink per reference (a, b, c) plus the ↩ arrow", () => {
+ mockRefCount.value = 3;
+ const { getByTestId } = render();
+ const wrapper = getByTestId("nvw");
+
+ const links = wrapper.querySelectorAll('[role="button"]');
+ expect(Array.from(links).map((l) => l.textContent)).toEqual([
+ "a",
+ "b",
+ "c",
+ ]);
+ // The ↩ arrow is present (as decorative chrome, not a button).
+ expect(wrapper.textContent).toContain("↩");
+ });
+
+ it("clicking the n-th backlink scrolls to the n-th occurrence (0-based)", () => {
+ mockRefCount.value = 3;
+ const props = makeProps();
+ const { getByTestId } = render();
+ const links = getByTestId("nvw").querySelectorAll('[role="button"]');
+
+ fireEvent.click(links[1]); // "b"
+ expect(props.editor.commands.scrollToReference).toHaveBeenCalledWith(
+ "fn-1",
+ 1,
+ );
+ });
+
+ it("a single-reference footnote renders just one ↩ (no letters)", () => {
+ mockRefCount.value = 1;
+ const props = makeProps();
+ const { getByTestId } = render();
+ const wrapper = getByTestId("nvw");
+
+ const links = wrapper.querySelectorAll('[role="button"]');
+ expect(links.length).toBe(1);
+ expect(links[0].textContent).toBe("↩");
+
+ fireEvent.click(links[0]);
+ expect(props.editor.commands.scrollToReference).toHaveBeenCalledWith(
+ "fn-1",
+ 0,
+ );
+ });
+});
diff --git a/apps/client/src/features/editor/components/footnote/footnote.module.css b/apps/client/src/features/editor/components/footnote/footnote.module.css
index 8f1ba9e7..fb21fc03 100644
--- a/apps/client/src/features/editor/components/footnote/footnote.module.css
+++ b/apps/client/src/features/editor/components/footnote/footnote.module.css
@@ -115,3 +115,18 @@
.backLink:hover {
text-decoration: underline;
}
+
+/* Multi-backlink row (#168): ↩ a b c — one lettered link per reference
+ occurrence. Sits on the right, after the content, like the single ↩. */
+.backLinks {
+ flex: 0 0 auto;
+ display: inline-flex;
+ align-items: baseline;
+ gap: 0.3em;
+ user-select: none;
+}
+
+.backLinkArrow {
+ color: var(--mantine-color-dimmed);
+ font-size: 0.9em;
+}
diff --git a/packages/editor-ext/src/lib/footnote/footnote-numbering.ts b/packages/editor-ext/src/lib/footnote/footnote-numbering.ts
index 8a487b1f..3a0950a4 100644
--- a/packages/editor-ext/src/lib/footnote/footnote-numbering.ts
+++ b/packages/editor-ext/src/lib/footnote/footnote-numbering.ts
@@ -1,14 +1,15 @@
-import { EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
-import { Decoration, DecorationSet } from "@tiptap/pm/view";
-import { Node as ProseMirrorNode } from "@tiptap/pm/model";
+import { EditorState, Plugin, PluginKey } from '@tiptap/pm/state';
+import { Decoration, DecorationSet } from '@tiptap/pm/view';
+import { Node as ProseMirrorNode } from '@tiptap/pm/model';
import {
FOOTNOTE_DEFINITION_NAME,
FOOTNOTE_REFERENCE_NAME,
computeFootnoteNumbers,
-} from "./footnote-util";
+ computeFootnoteRefCounts,
+} from './footnote-util';
export const footnoteNumberingPluginKey = new PluginKey(
- "footnoteNumbering",
+ 'footnoteNumbering',
);
/**
@@ -21,6 +22,9 @@ export const footnoteNumberingPluginKey = new PluginKey(
interface FootnoteNumberingState {
/** referenceId -> 1-based display number, for the current doc. */
numbers: Map;
+ /** referenceId -> number of reference occurrences (>= 1), for the definition's
+ * multi-backlink UI (#168). */
+ refCounts: Map;
/** Decorations rendering those numbers (refs + definitions). */
decorations: DecorationSet;
}
@@ -46,6 +50,7 @@ function buildFootnoteNumberingState(
doc: ProseMirrorNode,
): FootnoteNumberingState {
const numbers = computeFootnoteNumbers(doc);
+ const refCounts = computeFootnoteRefCounts(doc);
const decorations: Decoration[] = [];
doc.descendants((node, pos) => {
@@ -54,7 +59,7 @@ function buildFootnoteNumberingState(
if (num != null) {
decorations.push(
Decoration.node(pos, pos + node.nodeSize, {
- "data-footnote-number": String(num),
+ 'data-footnote-number': String(num),
style: `--footnote-number: "${num}";`,
}),
);
@@ -65,7 +70,7 @@ function buildFootnoteNumberingState(
if (num != null) {
decorations.push(
Decoration.node(pos, pos + node.nodeSize, {
- "data-footnote-number": String(num),
+ 'data-footnote-number': String(num),
style: `--footnote-number: "${num}";`,
}),
);
@@ -73,7 +78,11 @@ function buildFootnoteNumberingState(
}
});
- return { numbers, decorations: DecorationSet.create(doc, decorations) };
+ return {
+ numbers,
+ refCounts,
+ decorations: DecorationSet.create(doc, decorations),
+ };
}
/**
@@ -90,6 +99,16 @@ export function getFootnoteNumber(
return footnoteNumberingPluginKey.getState(state)?.numbers.get(id);
}
+/**
+ * Read the cached reference-occurrence count for `id` (how many `[^id]` links
+ * point at this definition). Drives the definition's multi-backlink UI (#168):
+ * `> 1` renders ↩ a b c …, each scrolling to its own occurrence. Returns 0 when
+ * the plugin is not installed or the id is unknown (caller treats as single).
+ */
+export function getFootnoteRefCount(state: EditorState, id: string): number {
+ return footnoteNumberingPluginKey.getState(state)?.refCounts.get(id) ?? 0;
+}
+
/**
* ProseMirror plugin that renders footnote numbers as decorations. It never
* mutates the document (safe in read-only / share and in collaboration) — it
diff --git a/packages/editor-ext/src/lib/footnote/footnote-reference.ts b/packages/editor-ext/src/lib/footnote/footnote-reference.ts
index 7b47617d..751d8664 100644
--- a/packages/editor-ext/src/lib/footnote/footnote-reference.ts
+++ b/packages/editor-ext/src/lib/footnote/footnote-reference.ts
@@ -1,14 +1,14 @@
-import { mergeAttributes, Node } from "@tiptap/core";
-import { TextSelection, Transaction } from "@tiptap/pm/state";
-import { ReactNodeViewRenderer } from "@tiptap/react";
+import { mergeAttributes, Node } from '@tiptap/core';
+import { TextSelection, Transaction } from '@tiptap/pm/state';
+import { ReactNodeViewRenderer } from '@tiptap/react';
import {
FOOTNOTE_DEFINITION_NAME,
FOOTNOTE_REFERENCE_NAME,
FOOTNOTES_LIST_NAME,
generateFootnoteId,
-} from "./footnote-util";
-import { footnoteNumberingPlugin } from "./footnote-numbering";
-import { footnoteSyncPlugin, footnotePastePlugin } from "./footnote-sync";
+} from './footnote-util';
+import { footnoteNumberingPlugin } from './footnote-numbering';
+import { footnoteSyncPlugin, footnotePastePlugin } from './footnote-sync';
export interface FootnoteReferenceOptions {
HTMLAttributes: Record;
@@ -27,7 +27,7 @@ export interface FootnoteReferenceOptions {
enableSync?: boolean;
}
-declare module "@tiptap/core" {
+declare module '@tiptap/core' {
interface Commands {
footnote: {
/**
@@ -42,8 +42,11 @@ declare module "@tiptap/core" {
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;
+ /** Scroll to a footnote reference by id. `index` selects WHICH occurrence
+ * to scroll to when the id is referenced more than once (reuse, #166):
+ * 0-based, defaults to the first. Used by the definition's multi-backlink
+ * UI (#168). */
+ scrollToReference: (id: string, index?: number) => ReturnType;
};
}
}
@@ -66,7 +69,7 @@ export const FootnoteReference = Node.create({
// Superscript mark's rule.
priority: 101,
- group: "inline",
+ group: 'inline',
inline: true,
atom: true,
selectable: true,
@@ -99,10 +102,10 @@ export const FootnoteReference = Node.create({
return {
id: {
default: null,
- parseHTML: (element) => element.getAttribute("data-id"),
+ parseHTML: (element) => element.getAttribute('data-id'),
renderHTML: (attributes) => {
if (!attributes.id) return {};
- return { "data-id": attributes.id };
+ return { 'data-id': attributes.id };
},
},
};
@@ -113,7 +116,7 @@ export const FootnoteReference = Node.create({
{
// High priority so the Superscript mark (which also matches ) does
// not claim a footnote reference and drop it as empty content.
- tag: "sup[data-footnote-ref]",
+ tag: 'sup[data-footnote-ref]',
priority: 100,
},
];
@@ -121,9 +124,9 @@ export const FootnoteReference = Node.create({
renderHTML({ HTMLAttributes }) {
return [
- "sup",
+ 'sup',
mergeAttributes(
- { "data-footnote-ref": "", class: "footnote-ref" },
+ { 'data-footnote-ref': '', class: 'footnote-ref' },
this.options.HTMLAttributes,
HTMLAttributes,
),
@@ -132,7 +135,7 @@ export const FootnoteReference = Node.create({
// Plain-text representation (used by generateText / markdown text fallbacks).
renderText({ node }) {
- return `[^${node.attrs.id ?? ""}]`;
+ return `[^${node.attrs.id ?? ''}]`;
},
addNodeView() {
@@ -170,8 +173,10 @@ export const FootnoteReference = Node.create({
// Make sure the parent accepts an inline atom here.
const insertPos = selection.from;
- if (!$from.parent.type.spec.content?.includes("inline") &&
- !$from.parent.isTextblock) {
+ if (
+ !$from.parent.type.spec.content?.includes('inline') &&
+ !$from.parent.isTextblock
+ ) {
return false;
}
@@ -311,19 +316,23 @@ export const FootnoteReference = Node.create({
`[data-footnote-def][data-id="${id}"]`,
) as HTMLElement | null;
if (!dom) return false;
- dom.scrollIntoView({ behavior: "smooth", block: "center" });
+ dom.scrollIntoView({ behavior: 'smooth', block: 'center' });
return true;
},
scrollToReference:
- (id: string) =>
+ (id: string, index = 0) =>
({ editor }) => {
if (!id) return false;
- const dom = editor.view.dom.querySelector(
+ // querySelectorAll returns the occurrences in document order, so the
+ // index maps 1:1 to the definition's a/b/c backlink (#168). Fall back
+ // to the first match for an out-of-range index.
+ const matches = editor.view.dom.querySelectorAll(
`sup[data-footnote-ref][data-id="${id}"]`,
- ) as HTMLElement | null;
+ );
+ const dom = (matches[index] ?? matches[0]) as HTMLElement | undefined;
if (!dom) return false;
- dom.scrollIntoView({ behavior: "smooth", block: "center" });
+ dom.scrollIntoView({ behavior: 'smooth', block: 'center' });
return true;
},
};
diff --git a/packages/editor-ext/src/lib/footnote/footnote-util.ts b/packages/editor-ext/src/lib/footnote/footnote-util.ts
index 56813288..d27c9685 100644
--- a/packages/editor-ext/src/lib/footnote/footnote-util.ts
+++ b/packages/editor-ext/src/lib/footnote/footnote-util.ts
@@ -1,12 +1,12 @@
-import { Node as ProseMirrorNode } from "@tiptap/pm/model";
+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";
+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
@@ -15,10 +15,10 @@ export const FOOTNOTE_DEFINITION_NAME = "footnoteDefinition";
*/
export function generateFootnoteId(): string {
const now = Date.now();
- const timeHex = now.toString(16).padStart(12, "0");
+ const timeHex = now.toString(16).padStart(12, '0');
const rand = (length: number) => {
- let out = "";
+ let out = '';
for (let i = 0; i < length; i++) {
out += Math.floor(Math.random() * 16).toString(16);
}
@@ -26,19 +26,19 @@ export function generateFootnoteId(): string {
};
// version 7 nibble, then variant (8..b) nibble.
- const versioned = "7" + rand(3);
+ 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)
);
}
@@ -89,7 +89,7 @@ export function deriveFootnoteId(
* Purely deterministic.
*/
function suffix(n: number): string {
- let out = "";
+ let out = '';
let x = n;
while (x > 0) {
const rem = (x - 1) % 25;
@@ -131,3 +131,19 @@ export function computeFootnoteNumbers(
}
return numbers;
}
+
+/**
+ * Build a map of `referenceId -> number of reference occurrences` (>= 1) from
+ * document order. After #166 the same id may be referenced multiple times
+ * (reuse: one number, one definition, N forward links); this count drives the
+ * definition's multi-backlink UI (↩ a b c …, #168). Pure function of the doc.
+ */
+export function computeFootnoteRefCounts(
+ doc: ProseMirrorNode,
+): Map {
+ const counts = new Map();
+ for (const id of collectReferenceIds(doc)) {
+ counts.set(id, (counts.get(id) ?? 0) + 1);
+ }
+ return counts;
+}
diff --git a/packages/editor-ext/src/lib/footnote/footnote.test.ts b/packages/editor-ext/src/lib/footnote/footnote.test.ts
index ff4e1625..11c868f6 100644
--- a/packages/editor-ext/src/lib/footnote/footnote.test.ts
+++ b/packages/editor-ext/src/lib/footnote/footnote.test.ts
@@ -1,25 +1,26 @@
-import { describe, it, expect } from "vitest";
-import { Editor, Extension, getSchema } from "@tiptap/core";
-import { Document } from "@tiptap/extension-document";
-import { Paragraph } from "@tiptap/extension-paragraph";
-import { Text } from "@tiptap/extension-text";
-import { Superscript } from "@tiptap/extension-superscript";
-import { Plugin, PluginKey } from "@tiptap/pm/state";
-import { Node as PMNode } from "@tiptap/pm/model";
-import { EditorState } from "@tiptap/pm/state";
-import { FootnoteReference } from "./footnote-reference";
-import { FootnotesList } from "./footnotes-list";
-import { FootnoteDefinition } from "./footnote-definition";
-import { TrailingNode } from "../trailing-node";
-import { footnoteSyncPlugin } from "./footnote-sync";
-import { getFootnoteNumber } from "./footnote-numbering";
+import { describe, it, expect } from 'vitest';
+import { Editor, Extension, getSchema } from '@tiptap/core';
+import { Document } from '@tiptap/extension-document';
+import { Paragraph } from '@tiptap/extension-paragraph';
+import { Text } from '@tiptap/extension-text';
+import { Superscript } from '@tiptap/extension-superscript';
+import { Plugin, PluginKey } from '@tiptap/pm/state';
+import { Node as PMNode } from '@tiptap/pm/model';
+import { EditorState } from '@tiptap/pm/state';
+import { FootnoteReference } from './footnote-reference';
+import { FootnotesList } from './footnotes-list';
+import { FootnoteDefinition } from './footnote-definition';
+import { TrailingNode } from '../trailing-node';
+import { footnoteSyncPlugin } from './footnote-sync';
+import { getFootnoteNumber, getFootnoteRefCount } from './footnote-numbering';
import {
computeFootnoteNumbers,
+ computeFootnoteRefCounts,
collectReferenceIds,
FOOTNOTE_REFERENCE_NAME,
FOOTNOTES_LIST_NAME,
FOOTNOTE_DEFINITION_NAME,
-} from "./footnote-util";
+} from './footnote-util';
const extensions = [
Document,
@@ -33,7 +34,7 @@ const extensions = [
function makeEditor(content?: any) {
return new Editor({
extensions,
- content: content ?? { type: "doc", content: [{ type: "paragraph" }] },
+ content: content ?? { type: 'doc', content: [{ type: 'paragraph' }] },
});
}
@@ -45,19 +46,19 @@ function countType(doc: PMNode, name: string): number {
return n;
}
-describe("footnote numbering (pure function)", () => {
- it("numbers references in document order", () => {
+describe('footnote numbering (pure function)', () => {
+ it('numbers references in document order', () => {
const schema = getSchema(extensions);
const doc = PMNode.fromJSON(schema, {
- type: "doc",
+ type: 'doc',
content: [
{
- type: "paragraph",
+ 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: 'text', text: 'a' },
+ { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: 'x' } },
+ { type: 'text', text: 'b' },
+ { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: 'y' } },
],
},
{
@@ -65,32 +66,110 @@ describe("footnote numbering (pure function)", () => {
content: [
{
type: FOOTNOTE_DEFINITION_NAME,
- attrs: { id: "x" },
- content: [{ type: "paragraph" }],
+ attrs: { id: 'x' },
+ content: [{ type: 'paragraph' }],
},
{
type: FOOTNOTE_DEFINITION_NAME,
- attrs: { id: "y" },
- content: [{ type: "paragraph" }],
+ attrs: { id: 'y' },
+ content: [{ type: 'paragraph' }],
},
],
},
],
});
- expect(collectReferenceIds(doc)).toEqual(["x", "y"]);
+ expect(collectReferenceIds(doc)).toEqual(['x', 'y']);
const numbers = computeFootnoteNumbers(doc);
- expect(numbers.get("x")).toBe(1);
- expect(numbers.get("y")).toBe(2);
+ expect(numbers.get('x')).toBe(1);
+ expect(numbers.get('y')).toBe(2);
+ });
+
+ it('counts reference occurrences per id (reuse), one number per id (#168)', () => {
+ const schema = getSchema(extensions);
+ // `a` is referenced 3 times, `b` once. Reuse: one number each, 3 vs 1 links.
+ const doc = PMNode.fromJSON(schema, {
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: 'a' } },
+ { type: 'text', text: ' x ' },
+ { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: 'b' } },
+ { type: 'text', text: ' y ' },
+ { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: 'a' } },
+ { type: 'text', text: ' z ' },
+ { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: 'a' } },
+ ],
+ },
+ {
+ type: FOOTNOTES_LIST_NAME,
+ content: [
+ {
+ type: FOOTNOTE_DEFINITION_NAME,
+ attrs: { id: 'a' },
+ content: [{ type: 'paragraph' }],
+ },
+ {
+ type: FOOTNOTE_DEFINITION_NAME,
+ attrs: { id: 'b' },
+ content: [{ type: 'paragraph' }],
+ },
+ ],
+ },
+ ],
+ });
+
+ const numbers = computeFootnoteNumbers(doc);
+ expect(numbers.get('a')).toBe(1);
+ expect(numbers.get('b')).toBe(2);
+
+ const counts = computeFootnoteRefCounts(doc);
+ expect(counts.get('a')).toBe(3);
+ expect(counts.get('b')).toBe(1);
+ expect(counts.get('missing')).toBeUndefined();
});
});
-describe("setFootnote command", () => {
- it("inserts a reference and a matching definition in the footnotes list", () => {
+describe('getFootnoteRefCount (cached, live editor)', () => {
+ it('returns the live occurrence count and 0 for an unknown id', () => {
const editor = makeEditor({
- type: "doc",
+ type: 'doc',
content: [
- { type: "paragraph", content: [{ type: "text", text: "Hello" }] },
+ {
+ type: 'paragraph',
+ content: [
+ { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: 'a' } },
+ { type: 'text', text: ' and ' },
+ { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: 'a' } },
+ ],
+ },
+ {
+ type: FOOTNOTES_LIST_NAME,
+ content: [
+ {
+ type: FOOTNOTE_DEFINITION_NAME,
+ attrs: { id: 'a' },
+ content: [{ type: 'paragraph' }],
+ },
+ ],
+ },
+ ],
+ });
+
+ expect(getFootnoteRefCount(editor.state, 'a')).toBe(2);
+ expect(getFootnoteRefCount(editor.state, 'nope')).toBe(0);
+ editor.destroy();
+ });
+});
+
+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.
@@ -115,12 +194,12 @@ describe("setFootnote command", () => {
editor.destroy();
});
- it("inserts the definition at the correct position matching reference order", () => {
+ it('inserts the definition at the correct position matching reference order', () => {
const editor = makeEditor({
- type: "doc",
+ type: 'doc',
content: [
- { type: "paragraph", content: [{ type: "text", text: "AAAA" }] },
- { type: "paragraph", content: [{ type: "text", text: "BBBB" }] },
+ { type: 'paragraph', content: [{ type: 'text', text: 'AAAA' }] },
+ { type: 'paragraph', content: [{ type: 'text', text: 'BBBB' }] },
],
});
@@ -150,12 +229,12 @@ describe("setFootnote command", () => {
});
});
-describe("removeFootnote command (cascade)", () => {
- it("removes both the reference and its definition, and drops the empty list", () => {
+describe('removeFootnote command (cascade)', () => {
+ it('removes both the reference and its definition, and drops the empty list', () => {
const editor = makeEditor({
- type: "doc",
+ type: 'doc',
content: [
- { type: "paragraph", content: [{ type: "text", text: "Hello" }] },
+ { type: 'paragraph', content: [{ type: 'text', text: 'Hello' }] },
],
});
editor.commands.setTextSelection(6);
@@ -178,29 +257,29 @@ describe("removeFootnote command (cascade)", () => {
});
});
-describe("footnote sync plugin (orphans)", () => {
- it("creates an empty definition for a reference pasted without one", () => {
+describe('footnote sync plugin (orphans)', () => {
+ it('creates an empty definition for a reference pasted without one', () => {
const editor = makeEditor({
- type: "doc",
+ type: 'doc',
content: [
{
- type: "paragraph",
+ type: 'paragraph',
content: [
- { type: "text", text: "x" },
- { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "orphan-ref" } },
+ { type: 'text', text: 'x' },
+ { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: 'orphan-ref' } },
],
},
],
});
// Trigger a doc change so appendTransaction runs.
- editor.commands.insertContentAt(1, " ");
+ 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"
+ node.attrs.id === 'orphan-ref'
) {
defFound = true;
}
@@ -209,17 +288,17 @@ describe("footnote sync plugin (orphans)", () => {
editor.destroy();
});
- it("merges multiple footnotesList nodes into one, preserving all definitions, as the last child", () => {
+ it('merges multiple footnotesList nodes into one, preserving all definitions, as the last child', () => {
const editor = makeEditor({
- type: "doc",
+ type: 'doc',
content: [
{
- type: "paragraph",
+ 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: '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.
@@ -228,27 +307,37 @@ describe("footnote sync plugin (orphans)", () => {
content: [
{
type: FOOTNOTE_DEFINITION_NAME,
- attrs: { id: "x" },
- content: [{ type: "paragraph", content: [{ type: "text", text: "X note" }] }],
+ attrs: { id: 'x' },
+ content: [
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'X note' }],
+ },
+ ],
},
],
},
- { type: "paragraph", content: [{ type: "text", text: "tail" }] },
+ { 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" }] }],
+ attrs: { id: 'y' },
+ content: [
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'Y note' }],
+ },
+ ],
},
],
},
],
});
// Trigger a local doc change so appendTransaction runs.
- editor.commands.insertContentAt(1, " ");
+ editor.commands.insertContentAt(1, ' ');
const doc = editor.state.doc;
// Converged to exactly ONE list.
@@ -256,24 +345,25 @@ describe("footnote sync plugin (orphans)", () => {
// Both definitions preserved (no tracking lost).
const defIds: string[] = [];
doc.descendants((node) => {
- if (node.type.name === FOOTNOTE_DEFINITION_NAME) defIds.push(node.attrs.id);
+ if (node.type.name === FOOTNOTE_DEFINITION_NAME)
+ defIds.push(node.attrs.id);
});
- expect(defIds.sort()).toEqual(["x", "y"]);
+ 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", () => {
+ it('leaves a correct doc (single trailing list) unchanged — no merge loop', () => {
const editor = makeEditor({
- type: "doc",
+ type: 'doc',
content: [
{
- type: "paragraph",
+ type: 'paragraph',
content: [
- { type: "text", text: "a" },
- { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "x" } },
+ { type: 'text', text: 'a' },
+ { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: 'x' } },
],
},
{
@@ -281,8 +371,13 @@ describe("footnote sync plugin (orphans)", () => {
content: [
{
type: FOOTNOTE_DEFINITION_NAME,
- attrs: { id: "x" },
- content: [{ type: "paragraph", content: [{ type: "text", text: "X note" }] }],
+ attrs: { id: 'x' },
+ content: [
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'X note' }],
+ },
+ ],
},
],
},
@@ -290,7 +385,7 @@ describe("footnote sync plugin (orphans)", () => {
});
const before = editor.state.doc.toJSON();
// A change that doesn't touch footnote structure.
- editor.commands.insertContentAt(1, "z");
+ 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);
@@ -307,22 +402,22 @@ describe("footnote sync plugin (orphans)", () => {
editor.destroy();
});
- it("repeated references REUSE one footnote; a duplicate definition is dropped (first-wins)", () => {
+ it('repeated references REUSE one footnote; a duplicate definition is dropped (first-wins)', () => {
// Reuse semantics (#166): two references with id "d" are the SAME footnote
// (one number, shared definition) — they are NEVER re-id'd. Two definitions
// sharing id "d" are first-wins: the first keeps "d", the second is re-id'd
// to a deterministic orphan id and then dropped by the orphan policy (it has
// no matching reference). So the result is ONE reused footnote on "first".
const editor = makeEditor({
- type: "doc",
+ type: 'doc',
content: [
{
- type: "paragraph",
+ type: 'paragraph',
content: [
- { type: "text", text: "a" },
- { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "d" } },
- { type: "text", text: "b" },
- { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "d" } },
+ { type: 'text', text: 'a' },
+ { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: 'd' } },
+ { type: 'text', text: 'b' },
+ { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: 'd' } },
],
},
{
@@ -330,16 +425,22 @@ describe("footnote sync plugin (orphans)", () => {
content: [
{
type: FOOTNOTE_DEFINITION_NAME,
- attrs: { id: "d" },
+ attrs: { id: 'd' },
content: [
- { type: "paragraph", content: [{ type: "text", text: "first" }] },
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'first' }],
+ },
],
},
{
type: FOOTNOTE_DEFINITION_NAME,
- attrs: { id: "d" },
+ attrs: { id: 'd' },
content: [
- { type: "paragraph", content: [{ type: "text", text: "second" }] },
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'second' }],
+ },
],
},
],
@@ -347,7 +448,7 @@ describe("footnote sync plugin (orphans)", () => {
],
});
// The first local keystroke fires the sync plugin's appendTransaction.
- editor.commands.insertContentAt(1, " ");
+ editor.commands.insertContentAt(1, ' ');
const doc = editor.state.doc;
// One shared definition survives (first-wins); the duplicate is dropped.
@@ -360,35 +461,36 @@ describe("footnote sync plugin (orphans)", () => {
defTexts.push(node.textContent);
}
});
- expect(defTexts).toEqual(["first"]);
- expect(defIds).toEqual(["d"]);
+ expect(defTexts).toEqual(['first']);
+ expect(defIds).toEqual(['d']);
// Both references keep id "d" (reuse — not re-id'd).
const refIds: string[] = [];
doc.descendants((node) => {
- if (node.type.name === FOOTNOTE_REFERENCE_NAME) refIds.push(node.attrs.id);
+ if (node.type.name === FOOTNOTE_REFERENCE_NAME)
+ refIds.push(node.attrs.id);
});
- expect(refIds).toEqual(["d", "d"]);
+ expect(refIds).toEqual(['d', 'd']);
editor.destroy();
});
- it("reuse outcome is DETERMINISTIC across clients (Yjs convergence)", () => {
+ it('reuse outcome is DETERMINISTIC across clients (Yjs convergence)', () => {
// Cross-client determinism guard. Two collaborating clients each see the
// SAME document and make a local edit; the sync plugin runs identically, so
// the resolved state MUST be identical (else they diverge over Yjs). Under
// reuse the three "d" references collapse to one footnote and the duplicate
// definitions are dropped (first-wins) — deterministically on every client.
const duplicateDoc = {
- type: "doc",
+ type: 'doc',
content: [
{
- type: "paragraph",
+ type: 'paragraph',
content: [
- { type: "text", text: "a" },
- { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "d" } },
- { type: "text", text: "b" },
- { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "d" } },
- { type: "text", text: "c" },
- { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "d" } },
+ { type: 'text', text: 'a' },
+ { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: 'd' } },
+ { type: 'text', text: 'b' },
+ { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: 'd' } },
+ { type: 'text', text: 'c' },
+ { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: 'd' } },
],
},
{
@@ -396,25 +498,25 @@ describe("footnote sync plugin (orphans)", () => {
content: [
{
type: FOOTNOTE_DEFINITION_NAME,
- attrs: { id: "d" },
+ attrs: { id: 'd' },
content: [
- { type: "paragraph", content: [{ type: "text", text: "one" }] },
+ { type: 'paragraph', content: [{ type: 'text', text: 'one' }] },
],
},
{
type: FOOTNOTE_DEFINITION_NAME,
- attrs: { id: "d" },
+ attrs: { id: 'd' },
content: [
- { type: "paragraph", content: [{ type: "text", text: "two" }] },
+ { type: 'paragraph', content: [{ type: 'text', text: 'two' }] },
],
},
{
type: FOOTNOTE_DEFINITION_NAME,
- attrs: { id: "d" },
+ attrs: { id: 'd' },
content: [
{
- type: "paragraph",
- content: [{ type: "text", text: "three" }],
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'three' }],
},
],
},
@@ -427,7 +529,7 @@ describe("footnote sync plugin (orphans)", () => {
// A fresh editor instance = an independent "client" running the same
// plugin pipeline on the same starting document.
const editor = makeEditor(structuredClone(duplicateDoc));
- editor.commands.insertContentAt(1, " "); // local keystroke -> sync runs
+ editor.commands.insertContentAt(1, ' '); // local keystroke -> sync runs
const refIds: string[] = [];
const defIds: string[] = [];
const defTexts: string[] = [];
@@ -449,29 +551,29 @@ describe("footnote sync plugin (orphans)", () => {
// Both clients resolved to IDENTICAL state (the Yjs-convergence property).
expect(clientA).toEqual(clientB);
// Reuse: the three references stay "d"; one definition survives (first-wins).
- expect(clientA.refIds).toEqual(["d", "d", "d"]);
- expect(clientA.defIds).toEqual(["d"]);
- expect(clientA.defTexts).toEqual(["one"]);
+ expect(clientA.refIds).toEqual(['d', 'd', 'd']);
+ expect(clientA.defIds).toEqual(['d']);
+ expect(clientA.defTexts).toEqual(['one']);
});
- it("removes an orphan definition with no matching reference", () => {
+ it('removes an orphan definition with no matching reference', () => {
const editor = makeEditor({
- type: "doc",
+ type: 'doc',
content: [
- { type: "paragraph", content: [{ type: "text", text: "x" }] },
+ { type: 'paragraph', content: [{ type: 'text', text: 'x' }] },
{
type: FOOTNOTES_LIST_NAME,
content: [
{
type: FOOTNOTE_DEFINITION_NAME,
- attrs: { id: "orphan-def" },
- content: [{ type: "paragraph" }],
+ attrs: { id: 'orphan-def' },
+ content: [{ type: 'paragraph' }],
},
],
},
],
});
- editor.commands.insertContentAt(1, "y");
+ editor.commands.insertContentAt(1, 'y');
const doc = editor.state.doc;
expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(0);
@@ -493,7 +595,7 @@ describe("footnote sync plugin (orphans)", () => {
* 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)", () => {
+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.
@@ -508,13 +610,13 @@ describe("footnote sync plugin (no infinite loop — live editor)", () => {
// 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",
+ name: 'footnoteLoopGuard',
// Run last so it observes every other plugin's appended transaction.
priority: -1000,
addProseMirrorPlugins() {
return [
new Plugin({
- key: new PluginKey("footnoteLoopGuard"),
+ key: new PluginKey('footnoteLoopGuard'),
appendTransaction(transactions) {
if (transactions.some((t) => t.docChanged)) {
rounds += 1;
@@ -543,7 +645,7 @@ describe("footnote sync plugin (no infinite loop — live editor)", () => {
FootnotesList,
FootnoteDefinition,
],
- content: content ?? { type: "doc", content: [{ type: "paragraph" }] },
+ content: content ?? { type: 'doc', content: [{ type: 'paragraph' }] },
});
return { editor, getRounds: () => rounds, resetRounds: () => (rounds = 0) };
}
@@ -558,17 +660,17 @@ describe("footnote sync plugin (no infinite loop — live editor)", () => {
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)) {
+ 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", () => {
+ 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" }] }],
+ type: 'doc',
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hi' }] }],
});
editor.commands.setTextSelection(3);
const ok = editor.commands.setFootnote();
@@ -582,10 +684,10 @@ describe("footnote sync plugin (no infinite loop — live editor)", () => {
editor.destroy();
});
- it("a second setFootnote() does not hang: two refs + two defs in one list", () => {
+ 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" }] }],
+ type: 'doc',
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hi' }] }],
});
editor.commands.setTextSelection(3);
editor.commands.setFootnote();
@@ -600,10 +702,10 @@ describe("footnote sync plugin (no infinite loop — live editor)", () => {
editor.destroy();
});
- it("converges and stabilizes: an unrelated edit does not keep producing transactions", () => {
+ 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" }] }],
+ type: 'doc',
+ content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Hi' }] }],
});
editor.commands.setTextSelection(3);
editor.commands.setFootnote();
@@ -612,14 +714,14 @@ describe("footnote sync plugin (no infinite loop — live editor)", () => {
// 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");
+ 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");
+ editor.commands.insertContentAt(2, 'Y');
const afterSecond = editor.state.doc.toJSON();
const listOf = (json: any) =>
@@ -629,17 +731,17 @@ describe("footnote sync plugin (no infinite loop — live editor)", () => {
editor.destroy();
});
- it("two footnotesList nodes converge to one (merge) without looping", () => {
+ it('two footnotesList nodes converge to one (merge) without looping', () => {
const { editor } = makeLiveEditor({
- type: "doc",
+ type: 'doc',
content: [
{
- type: "paragraph",
+ 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: 'text', text: 'a' },
+ { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: 'x' } },
+ { type: 'text', text: 'b' },
+ { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: 'y' } },
],
},
{
@@ -647,22 +749,22 @@ describe("footnote sync plugin (no infinite loop — live editor)", () => {
content: [
{
type: FOOTNOTE_DEFINITION_NAME,
- attrs: { id: "x" },
+ attrs: { id: 'x' },
content: [
- { type: "paragraph", content: [{ type: "text", text: "X" }] },
+ { type: 'paragraph', content: [{ type: 'text', text: 'X' }] },
],
},
],
},
- { type: "paragraph", content: [{ type: "text", text: "tail" }] },
+ { type: 'paragraph', content: [{ type: 'text', text: 'tail' }] },
{
type: FOOTNOTES_LIST_NAME,
content: [
{
type: FOOTNOTE_DEFINITION_NAME,
- attrs: { id: "y" },
+ attrs: { id: 'y' },
content: [
- { type: "paragraph", content: [{ type: "text", text: "Y" }] },
+ { type: 'paragraph', content: [{ type: 'text', text: 'Y' }] },
],
},
],
@@ -670,7 +772,7 @@ describe("footnote sync plugin (no infinite loop — live editor)", () => {
],
});
// Trigger a local doc change so appendTransaction runs (must not hang).
- editor.commands.insertContentAt(1, " ");
+ editor.commands.insertContentAt(1, ' ');
const doc = editor.state.doc;
expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(1);
@@ -679,7 +781,7 @@ describe("footnote sync plugin (no infinite loop — live editor)", () => {
if (node.type.name === FOOTNOTE_DEFINITION_NAME)
defIds.push(node.attrs.id);
});
- expect(defIds.sort()).toEqual(["x", "y"]);
+ expect(defIds.sort()).toEqual(['x', 'y']);
expect(lastFootnotesListIsTrailing(doc)).toBe(true);
editor.destroy();
});
@@ -697,7 +799,7 @@ describe("footnote sync plugin (no infinite loop — live editor)", () => {
* existing definition NODE INSTANCES are preserved (identity-equal) after the
* sync pass, AND the derived numbers follow the new reference order.
*/
-describe("footnote sync plugin (no rebuild on reorder — data-loss guard)", () => {
+describe('footnote sync plugin (no rebuild on reorder — data-loss guard)', () => {
function reorderedDoc() {
// The "out of order" end-state of a reorder: references occur as [b, a] but
// the bottom list still physically holds definitions in [a, b] order. This
@@ -706,15 +808,15 @@ describe("footnote sync plugin (no rebuild on reorder — data-loss guard)", ()
// the definition subtrees). The sync plugin must leave the definitions
// ALONE here — no delete/recreate of any definition subtree.
return {
- type: "doc",
+ type: 'doc',
content: [
{
- type: "paragraph",
+ type: 'paragraph',
content: [
- { type: "text", text: "p" },
- { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "b" } },
- { type: "text", text: "q" },
- { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "a" } },
+ { type: 'text', text: 'p' },
+ { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: 'b' } },
+ { type: 'text', text: 'q' },
+ { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: 'a' } },
],
},
{
@@ -722,16 +824,16 @@ describe("footnote sync plugin (no rebuild on reorder — data-loss guard)", ()
content: [
{
type: FOOTNOTE_DEFINITION_NAME,
- attrs: { id: "a" },
+ attrs: { id: 'a' },
content: [
- { type: "paragraph", content: [{ type: "text", text: "A" }] },
+ { type: 'paragraph', content: [{ type: 'text', text: 'A' }] },
],
},
{
type: FOOTNOTE_DEFINITION_NAME,
- attrs: { id: "b" },
+ attrs: { id: 'b' },
content: [
- { type: "paragraph", content: [{ type: "text", text: "B" }] },
+ { type: 'paragraph', content: [{ type: 'text', text: 'B' }] },
],
},
],
@@ -743,32 +845,33 @@ describe("footnote sync plugin (no rebuild on reorder — data-loss guard)", ()
function getDefNodesById(doc: PMNode): Map {
const m = new Map();
doc.descendants((node) => {
- if (node.type.name === FOOTNOTE_DEFINITION_NAME) m.set(node.attrs.id, node);
+ if (node.type.name === FOOTNOTE_DEFINITION_NAME)
+ m.set(node.attrs.id, node);
});
return m;
}
- it("does NOT delete/recreate existing definition subtrees for an out-of-order list (numbers still correct)", () => {
+ it('does NOT delete/recreate existing definition subtrees for an out-of-order list (numbers still correct)', () => {
const editor = makeEditor(reorderedDoc());
// Capture the exact definition NODE INSTANCES before any sync pass.
const before = getDefNodesById(editor.state.doc);
// Sanity: both carry their content right now.
- expect(before.get("a")!.textContent).toBe("A");
- expect(before.get("b")!.textContent).toBe("B");
+ expect(before.get('a')!.textContent).toBe('A');
+ expect(before.get('b')!.textContent).toBe('B');
// Trigger a local edit elsewhere in the body so the sync plugin runs.
- editor.commands.insertContentAt(1, "z");
+ editor.commands.insertContentAt(1, 'z');
const doc = editor.state.doc;
// Reference order is [b, a]; the displayed numbers follow reference order
// (decoration-only numbering): b -> 1, a -> 2 — regardless of physical list
// order.
- expect(collectReferenceIds(doc)).toEqual(["b", "a"]);
+ expect(collectReferenceIds(doc)).toEqual(['b', 'a']);
const numbers = computeFootnoteNumbers(doc);
- expect(numbers.get("b")).toBe(1);
- expect(numbers.get("a")).toBe(2);
+ expect(numbers.get('b')).toBe(1);
+ expect(numbers.get('a')).toBe(2);
// CRITICAL regression guard: both definitions still exist and are the SAME
// node instances as before the edit — the plugin did NOT delete/recreate the
@@ -776,11 +879,11 @@ describe("footnote sync plugin (no rebuild on reorder — data-loss guard)", ()
// concurrent-edit data-loss window). Identity equality proves the subtree
// was preserved verbatim.
const after = getDefNodesById(doc);
- expect(after.get("a")).toBe(before.get("a"));
- expect(after.get("b")).toBe(before.get("b"));
+ expect(after.get('a')).toBe(before.get('a'));
+ expect(after.get('b')).toBe(before.get('b'));
// Content intact, exactly one list, both definitions present.
- expect(after.get("a")!.textContent).toBe("A");
- expect(after.get("b")!.textContent).toBe("B");
+ expect(after.get('a')!.textContent).toBe('A');
+ expect(after.get('b')!.textContent).toBe('B');
expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(1);
expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(2);
@@ -792,19 +895,19 @@ describe("footnote sync plugin (no rebuild on reorder — data-loss guard)", ()
* Sync-plugin guard paths that are awkward to exercise through a live editor:
* the remote-transaction skip and the enableSync:false (read-only) mode.
*/
-describe("footnote sync plugin (guards)", () => {
+describe('footnote sync plugin (guards)', () => {
// Build a non-canonical document (an orphan reference with no definition) so a
// sync pass would normally append a transaction.
function nonCanonicalState() {
const schema = getSchema(extensions);
const doc = PMNode.fromJSON(schema, {
- type: "doc",
+ type: 'doc',
content: [
{
- type: "paragraph",
+ type: 'paragraph',
content: [
- { type: "text", text: "x" },
- { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "orphan" } },
+ { type: 'text', text: 'x' },
+ { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: 'orphan' } },
],
},
],
@@ -812,7 +915,7 @@ describe("footnote sync plugin (guards)", () => {
return EditorState.create({ schema, doc });
}
- it("isRemoteTransaction => true: appendTransaction returns null (no rebuild on remote txns)", () => {
+ it('isRemoteTransaction => true: appendTransaction returns null (no rebuild on remote txns)', () => {
// The sync plugin must SKIP remote/collab transactions so orphan cleanup and
// structural rewrites only ever run on local edits.
const plugin = footnoteSyncPlugin(() => true);
@@ -820,30 +923,26 @@ describe("footnote sync plugin (guards)", () => {
// Produce a doc-changing transaction (insert a space) and feed it to the
// plugin's appendTransaction exactly as ProseMirror would.
- const tr = state.tr.insertText(" ", 1);
+ const tr = state.tr.insertText(' ', 1);
const newState = state.apply(tr);
- const result = plugin.spec.appendTransaction!(
- [tr],
- state,
- newState,
- );
+ const result = plugin.spec.appendTransaction!([tr], state, newState);
expect(result).toBeNull();
});
- it("isRemoteTransaction => false: appendTransaction DOES rebuild (sanity)", () => {
+ it('isRemoteTransaction => false: appendTransaction DOES rebuild (sanity)', () => {
// Control: with a local (non-remote) transaction the same non-canonical doc
// triggers a sync transaction, proving the null above is the remote guard
// and not a no-op everywhere.
const plugin = footnoteSyncPlugin(() => false);
const state = nonCanonicalState();
- const tr = state.tr.insertText(" ", 1);
+ const tr = state.tr.insertText(' ', 1);
const newState = state.apply(tr);
const result = plugin.spec.appendTransaction!([tr], state, newState);
expect(result).not.toBeNull();
expect(result!.docChanged).toBe(true);
});
- it("enableSync:false: the plugin never mutates the doc (read-only viewer)", () => {
+ it('enableSync:false: the plugin never mutates the doc (read-only viewer)', () => {
// Build an editor with sync disabled. An orphan reference (no definition)
// must NOT trigger a definition insertion — the document is left untouched.
const editor = new Editor({
@@ -856,27 +955,27 @@ describe("footnote sync plugin (guards)", () => {
FootnoteDefinition,
],
content: {
- type: "doc",
+ type: 'doc',
content: [
{
- type: "paragraph",
+ type: 'paragraph',
content: [
- { type: "text", text: "x" },
- { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: "orphan" } },
+ { type: 'text', text: 'x' },
+ { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: 'orphan' } },
],
},
],
},
});
// A local edit that would normally trigger orphan-definition synthesis.
- editor.commands.insertContentAt(1, "y");
+ editor.commands.insertContentAt(1, 'y');
const doc = editor.state.doc;
// No definition (and no list) was ever created — sync is disabled.
expect(countType(doc, FOOTNOTE_DEFINITION_NAME)).toBe(0);
expect(countType(doc, FOOTNOTES_LIST_NAME)).toBe(0);
// Numbering decorations still work: the reference is numbered 1.
- expect(getFootnoteNumber(editor.state, "orphan")).toBe(1);
+ expect(getFootnoteNumber(editor.state, 'orphan')).toBe(1);
editor.destroy();
});
});
@@ -887,18 +986,18 @@ describe("footnote sync plugin (guards)", () => {
* recomputing the whole map per render. We assert the cache exists, is correct,
* and stays current across edits.
*/
-describe("footnote numbering cache", () => {
- it("exposes correct numbers via getFootnoteNumber and updates on edits", () => {
+describe('footnote numbering cache', () => {
+ it('exposes correct numbers via getFootnoteNumber and updates on edits', () => {
const editor = makeEditor({
- type: "doc",
+ type: 'doc',
content: [
{
- type: "paragraph",
+ 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: 'text', text: 'a' },
+ { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: 'x' } },
+ { type: 'text', text: 'b' },
+ { type: FOOTNOTE_REFERENCE_NAME, attrs: { id: 'y' } },
],
},
{
@@ -906,13 +1005,13 @@ describe("footnote numbering cache", () => {
content: [
{
type: FOOTNOTE_DEFINITION_NAME,
- attrs: { id: "x" },
- content: [{ type: "paragraph" }],
+ attrs: { id: 'x' },
+ content: [{ type: 'paragraph' }],
},
{
type: FOOTNOTE_DEFINITION_NAME,
- attrs: { id: "y" },
- content: [{ type: "paragraph" }],
+ attrs: { id: 'y' },
+ content: [{ type: 'paragraph' }],
},
],
},
@@ -920,22 +1019,22 @@ describe("footnote numbering cache", () => {
});
// The cache mirrors computeFootnoteNumbers — but is read in O(1) per id.
- expect(getFootnoteNumber(editor.state, "x")).toBe(1);
- expect(getFootnoteNumber(editor.state, "y")).toBe(2);
+ expect(getFootnoteNumber(editor.state, 'x')).toBe(1);
+ expect(getFootnoteNumber(editor.state, 'y')).toBe(2);
// The cached map is the SAME values a fresh full computation would yield.
const fresh = computeFootnoteNumbers(editor.state.doc);
- expect(getFootnoteNumber(editor.state, "x")).toBe(fresh.get("x"));
- expect(getFootnoteNumber(editor.state, "y")).toBe(fresh.get("y"));
+ expect(getFootnoteNumber(editor.state, 'x')).toBe(fresh.get('x'));
+ expect(getFootnoteNumber(editor.state, 'y')).toBe(fresh.get('y'));
// After inserting a new earlier reference, the cache updates so the numbers
// shift (decoration-only numbering follows reference order).
editor.commands.insertContentAt(1, {
type: FOOTNOTE_REFERENCE_NAME,
- attrs: { id: "z" },
+ attrs: { id: 'z' },
});
- expect(getFootnoteNumber(editor.state, "z")).toBe(1);
- expect(getFootnoteNumber(editor.state, "x")).toBe(2);
- expect(getFootnoteNumber(editor.state, "y")).toBe(3);
+ expect(getFootnoteNumber(editor.state, 'z')).toBe(1);
+ expect(getFootnoteNumber(editor.state, 'x')).toBe(2);
+ expect(getFootnoteNumber(editor.state, 'y')).toBe(3);
editor.destroy();
});
});