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 c79e7e549d
commit 4ba8991bca
8 changed files with 558 additions and 274 deletions

View File

@@ -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

View File

@@ -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<string, any>;
@@ -27,7 +27,7 @@ export interface FootnoteReferenceOptions {
enableSync?: boolean;
}
declare module "@tiptap/core" {
declare module '@tiptap/core' {
interface Commands<ReturnType> {
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<FootnoteReferenceOptions>({
// Superscript mark's <sup> rule.
priority: 101,
group: "inline",
group: 'inline',
inline: true,
atom: true,
selectable: true,
@@ -99,10 +102,10 @@ export const FootnoteReference = Node.create<FootnoteReferenceOptions>({
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<FootnoteReferenceOptions>({
{
// High priority so the Superscript mark (which also matches <sup>) does
// not claim a footnote reference and drop it as empty content.
tag: "sup[data-footnote-ref]",
tag: 'sup[data-footnote-ref]',
priority: 100,
},
];
@@ -121,9 +124,9 @@ export const FootnoteReference = Node.create<FootnoteReferenceOptions>({
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<FootnoteReferenceOptions>({
// 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<FootnoteReferenceOptions>({
// 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<FootnoteReferenceOptions>({
`[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;
},
};

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
* 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<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