feat(footnotes): multi-backlinks — definition returns to ALL its references (#168)

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) <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-25 04:27:29 +03:00
parent 1cfad1f6fb
commit 47a2ae420b
8 changed files with 558 additions and 274 deletions

View File

@@ -405,6 +405,8 @@
"Footnote {{number}}": "Сноска {{number}}", "Footnote {{number}}": "Сноска {{number}}",
"Go to footnote": "Перейти к сноске", "Go to footnote": "Перейти к сноске",
"Back to reference": "Вернуться к ссылке", "Back to reference": "Вернуться к ссылке",
"Back to references": "Вернуться к ссылкам",
"Back to reference {{label}}": "Вернуться к ссылке {{label}}",
"Empty footnote": "Пустая сноска", "Empty footnote": "Пустая сноска",
"Math inline": "Строчная формула", "Math inline": "Строчная формула",
"Insert inline math equation.": "Вставить математическое выражение в строку.", "Insert inline math equation.": "Вставить математическое выражение в строку.",

View File

@@ -1,25 +1,45 @@
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react"; import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getFootnoteNumber } from "@docmost/editor-ext"; import { getFootnoteNumber, getFootnoteRefCount } from "@docmost/editor-ext";
import classes from "./footnote.module.css"; 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 * NodeView for a single footnote definition: a decorative number marker, the
* editable content (NodeViewContent), and a "↩" back-link to its reference. * editable content (NodeViewContent), and a "↩" back-link to its reference.
* The number is derived from the document (not stored). * 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) { export default function FootnoteDefinitionView(props: NodeViewProps) {
const { node, editor } = props; const { node, editor } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const id = node.attrs.id as string; const id = node.attrs.id as string;
// Read the cached number from the numbering plugin (computed once per doc // Read the cached number/ref-count from the numbering plugin (computed once
// change) rather than recomputing the whole map on every render. // per doc change) rather than recomputing the whole map on every render.
const number = getFootnoteNumber(editor.state, id) ?? "?"; 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(); e.preventDefault();
editor.commands.scrollToReference(id); editor.commands.scrollToReference(id, index);
}; };
return ( return (
@@ -42,16 +62,47 @@ export default function FootnoteDefinitionView(props: NodeViewProps) {
> >
{number}. {number}.
</span> </span>
{refCount > 1 ? (
// Multiple references -> ↩ followed by one lettered link per occurrence.
<span
className={classes.backLinks}
contentEditable={false}
role="group"
aria-label={t("Back to references")}
>
<span className={classes.backLinkArrow} aria-hidden="true">
</span>
{Array.from({ length: refCount }, (_, i) => (
<span
key={i}
className={classes.backLink}
onClick={(e) => 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)}
</span>
))}
</span>
) : (
// Single reference -> the plain ↩ (unchanged behavior).
<span <span
className={classes.backLink} className={classes.backLink}
contentEditable={false} contentEditable={false}
onClick={handleBack} onClick={(e) => jumpTo(e, 0)}
role="button" role="button"
aria-label={t("Back to reference")} aria-label={t("Back to reference")}
title={t("Back to reference")} title={t("Back to reference")}
> >
</span> </span>
)}
</NodeViewWrapper> </NodeViewWrapper>
); );
} }

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi, afterEach } from "vitest";
import { render } from "@testing-library/react"; import { render, fireEvent } from "@testing-library/react";
/** /**
* Structural regression guard for #146 (PR #147). * Structural regression guard for #146 (PR #147).
@@ -36,10 +36,14 @@ vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }), useTranslation: () => ({ t: (key: string) => key }),
})); }));
// footnote-definition-view reads a cached number from the numbering plugin; // footnote-definition-view reads a cached number + reference count from the
// stub it so we don't need a live ProseMirror state. // 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", () => ({ vi.mock("@docmost/editor-ext", () => ({
getFootnoteNumber: () => 1, getFootnoteNumber: () => 1,
getFootnoteRefCount: () => mockRefCount.value,
})); }));
// Mocks so CodeBlockView renders cheaply (no MantineProvider, no matchMedia). // Mocks so CodeBlockView renders cheaply (no MantineProvider, no matchMedia).
@@ -59,7 +63,8 @@ vi.mock("@mantine/core", () => ({
), ),
})); }));
vi.mock("@/components/common/copy-button", () => ({ 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", () => ({ vi.mock("@tabler/icons-react", () => ({
IconCheck: () => null, 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(<FootnoteDefinitionView {...makeProps()} />);
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(<FootnoteDefinitionView {...props} />);
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(<FootnoteDefinitionView {...props} />);
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,
);
});
});

View File

@@ -115,3 +115,18 @@
.backLink:hover { .backLink:hover {
text-decoration: underline; 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;
}

View File

@@ -1,14 +1,15 @@
import { EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; import { EditorState, Plugin, PluginKey } from '@tiptap/pm/state';
import { Decoration, DecorationSet } from "@tiptap/pm/view"; import { Decoration, DecorationSet } from '@tiptap/pm/view';
import { Node as ProseMirrorNode } from "@tiptap/pm/model"; import { Node as ProseMirrorNode } from '@tiptap/pm/model';
import { import {
FOOTNOTE_DEFINITION_NAME, FOOTNOTE_DEFINITION_NAME,
FOOTNOTE_REFERENCE_NAME, FOOTNOTE_REFERENCE_NAME,
computeFootnoteNumbers, computeFootnoteNumbers,
} from "./footnote-util"; computeFootnoteRefCounts,
} from './footnote-util';
export const footnoteNumberingPluginKey = new PluginKey<FootnoteNumberingState>( export const footnoteNumberingPluginKey = new PluginKey<FootnoteNumberingState>(
"footnoteNumbering", 'footnoteNumbering',
); );
/** /**
@@ -21,6 +22,9 @@ export const footnoteNumberingPluginKey = new PluginKey<FootnoteNumberingState>(
interface FootnoteNumberingState { interface FootnoteNumberingState {
/** referenceId -> 1-based display number, for the current doc. */ /** referenceId -> 1-based display number, for the current doc. */
numbers: Map<string, number>; numbers: Map<string, number>;
/** referenceId -> number of reference occurrences (>= 1), for the definition's
* multi-backlink UI (#168). */
refCounts: Map<string, number>;
/** Decorations rendering those numbers (refs + definitions). */ /** Decorations rendering those numbers (refs + definitions). */
decorations: DecorationSet; decorations: DecorationSet;
} }
@@ -46,6 +50,7 @@ function buildFootnoteNumberingState(
doc: ProseMirrorNode, doc: ProseMirrorNode,
): FootnoteNumberingState { ): FootnoteNumberingState {
const numbers = computeFootnoteNumbers(doc); const numbers = computeFootnoteNumbers(doc);
const refCounts = computeFootnoteRefCounts(doc);
const decorations: Decoration[] = []; const decorations: Decoration[] = [];
doc.descendants((node, pos) => { doc.descendants((node, pos) => {
@@ -54,7 +59,7 @@ function buildFootnoteNumberingState(
if (num != null) { if (num != null) {
decorations.push( decorations.push(
Decoration.node(pos, pos + node.nodeSize, { Decoration.node(pos, pos + node.nodeSize, {
"data-footnote-number": String(num), 'data-footnote-number': String(num),
style: `--footnote-number: "${num}";`, style: `--footnote-number: "${num}";`,
}), }),
); );
@@ -65,7 +70,7 @@ function buildFootnoteNumberingState(
if (num != null) { if (num != null) {
decorations.push( decorations.push(
Decoration.node(pos, pos + node.nodeSize, { Decoration.node(pos, pos + node.nodeSize, {
"data-footnote-number": String(num), 'data-footnote-number': String(num),
style: `--footnote-number: "${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); 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 * ProseMirror plugin that renders footnote numbers as decorations. It never
* mutates the document (safe in read-only / share and in collaboration) — it * mutates the document (safe in read-only / share and in collaboration) — it

View File

@@ -1,14 +1,14 @@
import { mergeAttributes, Node } from "@tiptap/core"; import { mergeAttributes, Node } from '@tiptap/core';
import { TextSelection, Transaction } from "@tiptap/pm/state"; import { TextSelection, Transaction } from '@tiptap/pm/state';
import { ReactNodeViewRenderer } from "@tiptap/react"; import { ReactNodeViewRenderer } from '@tiptap/react';
import { import {
FOOTNOTE_DEFINITION_NAME, FOOTNOTE_DEFINITION_NAME,
FOOTNOTE_REFERENCE_NAME, FOOTNOTE_REFERENCE_NAME,
FOOTNOTES_LIST_NAME, FOOTNOTES_LIST_NAME,
generateFootnoteId, generateFootnoteId,
} from "./footnote-util"; } from './footnote-util';
import { footnoteNumberingPlugin } from "./footnote-numbering"; import { footnoteNumberingPlugin } from './footnote-numbering';
import { footnoteSyncPlugin, footnotePastePlugin } from "./footnote-sync"; import { footnoteSyncPlugin, footnotePastePlugin } from './footnote-sync';
export interface FootnoteReferenceOptions { export interface FootnoteReferenceOptions {
HTMLAttributes: Record<string, any>; HTMLAttributes: Record<string, any>;
@@ -27,7 +27,7 @@ export interface FootnoteReferenceOptions {
enableSync?: boolean; enableSync?: boolean;
} }
declare module "@tiptap/core" { declare module '@tiptap/core' {
interface Commands<ReturnType> { interface Commands<ReturnType> {
footnote: { footnote: {
/** /**
@@ -42,8 +42,11 @@ declare module "@tiptap/core" {
removeFootnote: (id: string) => ReturnType; removeFootnote: (id: string) => ReturnType;
/** Scroll to (and focus) a footnote definition by id. */ /** Scroll to (and focus) a footnote definition by id. */
scrollToFootnote: (id: string) => ReturnType; scrollToFootnote: (id: string) => ReturnType;
/** Scroll to (and select) a footnote reference by id. */ /** Scroll to a footnote reference by id. `index` selects WHICH occurrence
scrollToReference: (id: string) => ReturnType; * 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<FootnoteReferenceOptions>({
// Superscript mark's <sup> rule. // Superscript mark's <sup> rule.
priority: 101, priority: 101,
group: "inline", group: 'inline',
inline: true, inline: true,
atom: true, atom: true,
selectable: true, selectable: true,
@@ -99,10 +102,10 @@ export const FootnoteReference = Node.create<FootnoteReferenceOptions>({
return { return {
id: { id: {
default: null, default: null,
parseHTML: (element) => element.getAttribute("data-id"), parseHTML: (element) => element.getAttribute('data-id'),
renderHTML: (attributes) => { renderHTML: (attributes) => {
if (!attributes.id) return {}; if (!attributes.id) return {};
return { "data-id": attributes.id }; return { 'data-id': attributes.id };
}, },
}, },
}; };
@@ -113,7 +116,7 @@ export const FootnoteReference = Node.create<FootnoteReferenceOptions>({
{ {
// High priority so the Superscript mark (which also matches <sup>) does // High priority so the Superscript mark (which also matches <sup>) does
// not claim a footnote reference and drop it as empty content. // not claim a footnote reference and drop it as empty content.
tag: "sup[data-footnote-ref]", tag: 'sup[data-footnote-ref]',
priority: 100, priority: 100,
}, },
]; ];
@@ -121,9 +124,9 @@ export const FootnoteReference = Node.create<FootnoteReferenceOptions>({
renderHTML({ HTMLAttributes }) { renderHTML({ HTMLAttributes }) {
return [ return [
"sup", 'sup',
mergeAttributes( mergeAttributes(
{ "data-footnote-ref": "", class: "footnote-ref" }, { 'data-footnote-ref': '', class: 'footnote-ref' },
this.options.HTMLAttributes, this.options.HTMLAttributes,
HTMLAttributes, HTMLAttributes,
), ),
@@ -132,7 +135,7 @@ export const FootnoteReference = Node.create<FootnoteReferenceOptions>({
// Plain-text representation (used by generateText / markdown text fallbacks). // Plain-text representation (used by generateText / markdown text fallbacks).
renderText({ node }) { renderText({ node }) {
return `[^${node.attrs.id ?? ""}]`; return `[^${node.attrs.id ?? ''}]`;
}, },
addNodeView() { addNodeView() {
@@ -170,8 +173,10 @@ export const FootnoteReference = Node.create<FootnoteReferenceOptions>({
// Make sure the parent accepts an inline atom here. // Make sure the parent accepts an inline atom here.
const insertPos = selection.from; const insertPos = selection.from;
if (!$from.parent.type.spec.content?.includes("inline") && if (
!$from.parent.isTextblock) { !$from.parent.type.spec.content?.includes('inline') &&
!$from.parent.isTextblock
) {
return false; return false;
} }
@@ -311,19 +316,23 @@ export const FootnoteReference = Node.create<FootnoteReferenceOptions>({
`[data-footnote-def][data-id="${id}"]`, `[data-footnote-def][data-id="${id}"]`,
) as HTMLElement | null; ) as HTMLElement | null;
if (!dom) return false; if (!dom) return false;
dom.scrollIntoView({ behavior: "smooth", block: "center" }); dom.scrollIntoView({ behavior: 'smooth', block: 'center' });
return true; return true;
}, },
scrollToReference: scrollToReference:
(id: string) => (id: string, index = 0) =>
({ editor }) => { ({ editor }) => {
if (!id) return false; 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}"]`, `sup[data-footnote-ref][data-id="${id}"]`,
) as HTMLElement | null; );
const dom = (matches[index] ?? matches[0]) as HTMLElement | undefined;
if (!dom) return false; if (!dom) return false;
dom.scrollIntoView({ behavior: "smooth", block: "center" }); dom.scrollIntoView({ behavior: 'smooth', block: 'center' });
return true; return true;
}, },
}; };

View File

@@ -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 * Node type names for the footnote feature. Centralized so every part of the
* feature (nodes, plugins, commands) references the same string. * feature (nodes, plugins, commands) references the same string.
*/ */
export const FOOTNOTE_REFERENCE_NAME = "footnoteReference"; export const FOOTNOTE_REFERENCE_NAME = 'footnoteReference';
export const FOOTNOTES_LIST_NAME = "footnotesList"; export const FOOTNOTES_LIST_NAME = 'footnotesList';
export const FOOTNOTE_DEFINITION_NAME = "footnoteDefinition"; export const FOOTNOTE_DEFINITION_NAME = 'footnoteDefinition';
/** /**
* Generate a uuidv7-style id (time-ordered). Implemented locally so editor-ext * 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 { export function generateFootnoteId(): string {
const now = Date.now(); const now = Date.now();
const timeHex = now.toString(16).padStart(12, "0"); const timeHex = now.toString(16).padStart(12, '0');
const rand = (length: number) => { const rand = (length: number) => {
let out = ""; let out = '';
for (let i = 0; i < length; i++) { for (let i = 0; i < length; i++) {
out += Math.floor(Math.random() * 16).toString(16); out += Math.floor(Math.random() * 16).toString(16);
} }
@@ -26,19 +26,19 @@ export function generateFootnoteId(): string {
}; };
// version 7 nibble, then variant (8..b) nibble. // 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 variantNibble = (8 + Math.floor(Math.random() * 4)).toString(16);
const variant = variantNibble + rand(3); const variant = variantNibble + rand(3);
return ( return (
timeHex.slice(0, 8) + timeHex.slice(0, 8) +
"-" + '-' +
timeHex.slice(8, 12) + timeHex.slice(8, 12) +
"-" + '-' +
versioned + versioned +
"-" + '-' +
variant + variant +
"-" + '-' +
rand(12) rand(12)
); );
} }
@@ -89,7 +89,7 @@ export function deriveFootnoteId(
* Purely deterministic. * Purely deterministic.
*/ */
function suffix(n: number): string { function suffix(n: number): string {
let out = ""; let out = '';
let x = n; let x = n;
while (x > 0) { while (x > 0) {
const rem = (x - 1) % 25; const rem = (x - 1) % 25;
@@ -131,3 +131,19 @@ export function computeFootnoteNumbers(
} }
return numbers; 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<string, number> {
const counts = new Map<string, number>();
for (const id of collectReferenceIds(doc)) {
counts.set(id, (counts.get(id) ?? 0) + 1);
}
return counts;
}

File diff suppressed because it is too large Load Diff