From 3ebe24bee238cae2a32a090da23efdd31efa6cdb Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Thu, 25 Jun 2026 04:27:29 +0300 Subject: [PATCH] =?UTF-8?q?feat(footnotes):=20multi-backlinks=20=E2=80=94?= =?UTF-8?q?=20definition=20returns=20to=20ALL=20its=20references=20(#168)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After #166 a repeated `[^a]` is one footnote (reuse): one number, one definition, N forward links. But the definition's ↩ only returned to the FIRST reference. Now a definition with N references shows ↩ a b c …, each backlink scrolling to its own occurrence (Pandoc/Wikipedia convention); a single-reference footnote keeps the plain ↩ unchanged. - editor-ext: `computeFootnoteRefCounts(doc)` (id -> occurrence count) cached alongside the number map in the numbering plugin state; `getFootnoteRefCount` getter (O(1), no per-render doc walk). `scrollToReference(id, index?)` picks the index-th `sup[data-footnote-ref][data-id]` occurrence (document order), falling back to the first. - client: FootnoteDefinitionView renders one lettered link (a, b, c, … aa …) per occurrence when refCount > 1; the chrome stays after the contentDOM so the #146 caret invariant holds. i18n keys (ru) added. Tests: computeFootnoteRefCounts + getFootnoteRefCount (reuse counts, unknown id => 0); structure test gains 3 cases (N lettered links render, click jumps to the n-th occorrence, single ref => one ↩). NOTE: the visual layout of the backlink row needs a real browser to verify (jsdom can't); the structural and behavioral contract is covered headless. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../public/locales/ru-RU/translation.json | 2 + .../footnote/footnote-definition-view.tsx | 81 ++- .../footnote-views.structure.test.tsx | 83 ++- .../components/footnote/footnote.module.css | 15 + .../src/lib/footnote/footnote-numbering.ts | 35 +- .../src/lib/footnote/footnote-reference.ts | 55 +- .../src/lib/footnote/footnote-util.ts | 40 +- .../src/lib/footnote/footnote.test.ts | 521 +++++++++++------- 8 files changed, 558 insertions(+), 274 deletions(-) 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(); }); });