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:
@@ -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<FootnoteNumberingState>(
|
||||
"footnoteNumbering",
|
||||
'footnoteNumbering',
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -21,6 +22,9 @@ export const footnoteNumberingPluginKey = new PluginKey<FootnoteNumberingState>(
|
||||
interface FootnoteNumberingState {
|
||||
/** referenceId -> 1-based display number, for the current doc. */
|
||||
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: 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
|
||||
|
||||
Reference in New Issue
Block a user