Files
gitmost/packages/editor-ext/src/lib/footnote/footnote-numbering.ts
T
agent_coder a6ff7623db perf(editor): cut per-keystroke work on the typing hot path (#343)
The editor lagged while typing (worse with doc size, and under collaboration the
same cost is paid for every REMOTE keystroke). ProseMirror itself was fine — the
overhead was the surrounding work done on every transaction. Behavior is 1:1;
only WHEN work runs changed.

- getJSON() off the keystroke path: `onUpdate` no longer serializes the whole doc
  synchronously — the serialization now runs inside a 3s debounce (new hook
  use-page-content-cache.ts), flushed on unmount so the last snapshot isn't lost.
- footnote numbering: merged 3 per-docChanged O(n) doc walks into one, and
  short-circuit the whole-doc renumber when the doc has no footnotes and the
  transaction didn't insert one (step-slice scan — covers typing/paste/collab).
- toolbar: replaced per-keystroke `editor.can().undo()/.redo()` dry-runs with
  cheap history-depth reads (Yjs undoManager stack length / pm-history depth).
- render side-effect bug: `remote.attach()` moved out of the render body into a
  useEffect.
- debounced the TOC all-headings rescan and memoized the slash-command suggestion
  build (was rebuilt twice per keystroke).
- node menus (image/video/audio/pdf/callout/subpages): the per-transaction
  selectors early-return a cheap isActive check instead of running getAttributes +
  multiple alignment probes while their node type is inactive (shouldShow still
  controls display — appears exactly when it did).
- code blocks: the global selectionUpdate listener is now added only for mermaid
  blocks (the only consumer of the selected state), eliminating N listeners +
  N setStates per caret move for normal code blocks.

Deferred (documented, collab hot-path risk): full conditional menu MOUNTING
(menu-less-frame risk on same-tx context switch) and code-block re-tokenization
debounce / language-persist (self-dispatching meta tx + node-attr writes interact
with collab/undo). The route split from #342 already keeps lowlight off startup.

Gate: editor-ext build + 252/252 tests, client editor tests pass, tsc --noEmit 0,
client build ok. New tests: footnote no-footnote-doc → 0 traversals + numbering
unchanged; page-content-cache onUpdate-no-sync-getJSON + flush-on-unmount.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 04:10:04 +03:00

209 lines
8.0 KiB
TypeScript

import { EditorState, Plugin, PluginKey, Transaction } from '@tiptap/pm/state';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
import { Node as ProseMirrorNode, Slice } from '@tiptap/pm/model';
import {
FOOTNOTE_DEFINITION_NAME,
FOOTNOTE_REFERENCE_NAME,
} from './footnote-util';
export const footnoteNumberingPluginKey = new PluginKey<FootnoteNumberingState>(
'footnoteNumbering',
);
/**
* Cached state of the numbering plugin. Both the displayed-number map and the
* decoration set are computed ONCE per doc-changing transaction (in `apply`) and
* cached here, so NodeViews can read a footnote's number by id without walking
* the whole document on every React render (which was O(n^2) per keystroke in
* large docs).
*/
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;
/** Whether the document contains ANY footnote reference/definition node.
* Cached so `apply` can skip the whole-doc walk on every keystroke in the
* common case (documents with no footnotes), recomputing only once a
* transaction actually inserts a footnote node (#343, PART 5). */
hasFootnotes: boolean;
}
/** Reusable empty state for footnote-free documents — avoids reallocating an
* empty map/decoration set on every keystroke while there are no footnotes. */
const EMPTY_STATE: FootnoteNumberingState = {
numbers: new Map(),
refCounts: new Map(),
decorations: DecorationSet.empty,
hasFootnotes: false,
};
/**
* Build the decoration set for footnote numbers. Pure function of the document:
* walk references in document order, assign 1-based numbers, then attach a
* node decoration (carrying the number via a CSS variable + data attribute) to
* every reference and to every matching definition. Because it is deterministic
* from the document alone, all collaborating clients compute identical numbers
* with no document mutation.
*/
export function buildFootnoteDecorations(doc: ProseMirrorNode): DecorationSet {
return buildFootnoteNumberingState(doc).decorations;
}
function numberDecoration(pos: number, nodeSize: number, num: number): Decoration {
return Decoration.node(pos, pos + nodeSize, {
'data-footnote-number': String(num),
style: `--footnote-number: "${num}";`,
});
}
/**
* Compute the number map, reference counts AND the decorations for `doc` in a
* SINGLE document walk (previously three separate O(n) traversals per
* docChanged — computeFootnoteNumbers + computeFootnoteRefCounts + a decoration
* pass, #343 PART 5). The plugin caches the result so NodeViews can read numbers
* without recomputing.
*
* References are numbered and decorated as they are encountered (document
* order). Definition positions are collected during the same walk and decorated
* afterwards from the completed number map — so a definition that appears before
* its reference in document order still resolves to the correct number, and the
* output is identical to the previous three-pass implementation. (Decoration
* insertion order does not matter: DecorationSet.create indexes by position.)
*/
function buildFootnoteNumberingState(
doc: ProseMirrorNode,
): FootnoteNumberingState {
const numbers = new Map<string, number>();
const refCounts = new Map<string, number>();
const decorations: Decoration[] = [];
const definitions: { id: string; pos: number; nodeSize: number }[] = [];
let n = 0;
let hasFootnotes = false;
doc.descendants((node, pos) => {
const typeName = node.type.name;
if (typeName === FOOTNOTE_REFERENCE_NAME) {
hasFootnotes = true;
const id = node.attrs.id;
if (id) {
if (!numbers.has(id)) numbers.set(id, ++n);
refCounts.set(id, (refCounts.get(id) ?? 0) + 1);
decorations.push(numberDecoration(pos, node.nodeSize, numbers.get(id)!));
}
} else if (typeName === FOOTNOTE_DEFINITION_NAME) {
hasFootnotes = true;
const id = node.attrs.id;
if (id != null) definitions.push({ id, pos, nodeSize: node.nodeSize });
}
});
if (!hasFootnotes) return EMPTY_STATE;
for (const def of definitions) {
const num = numbers.get(def.id);
if (num != null) {
decorations.push(numberDecoration(def.pos, def.nodeSize, num));
}
}
return {
numbers,
refCounts,
decorations: DecorationSet.create(doc, decorations),
hasFootnotes: true,
};
}
/**
* Cheap check: does any of a transaction's inserted content contain a footnote
* reference/definition node? Footnote nodes can only ENTER the document through
* replace steps (ReplaceStep / ReplaceAroundStep both expose a `.slice`), so
* scanning only the inserted slices — O(change size), not O(doc) — is sufficient
* to detect a newly-added footnote. Mark/attr steps never introduce nodes.
* Lets `apply` keep skipping the whole-doc walk until a footnote first appears.
*/
function transactionInsertsFootnote(tr: Transaction): boolean {
for (const step of tr.steps) {
const slice = (step as unknown as { slice?: Slice }).slice;
if (!slice || slice.content.size === 0) continue;
let found = false;
slice.content.descendants((node) => {
if (found) return false;
const typeName = node.type.name;
if (
typeName === FOOTNOTE_REFERENCE_NAME ||
typeName === FOOTNOTE_DEFINITION_NAME
) {
found = true;
return false;
}
return true;
});
if (found) return true;
}
return false;
}
/**
* Read the cached footnote number for `id` from the numbering plugin's state.
* This is the source NodeViews should use instead of calling
* computeFootnoteNumbers() on every render (that walked the whole doc per
* NodeView per render = O(n^2) per keystroke). Returns undefined if the plugin
* is not installed or the id has no number yet.
*/
export function getFootnoteNumber(
state: EditorState,
id: string,
): number | undefined {
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
* only recomputes decorations from the current doc on each transaction.
*/
export function footnoteNumberingPlugin(): Plugin {
return new Plugin({
key: footnoteNumberingPluginKey,
state: {
init(_, { doc }) {
return buildFootnoteNumberingState(doc);
},
apply(tr, old) {
// Recompute (and re-cache) only when the document actually changed, so
// the number map NodeViews read stays current on every edit while
// non-doc transactions (selection, etc.) reuse the cache for free.
if (!tr.docChanged) return old;
// Short-circuit the whole-doc walk while the document has no footnotes:
// if there were none and this transaction did not INSERT one, there is
// still nothing to number, so reuse the empty state (#343, PART 5). Once
// a footnote exists we always rebuild (covers renumbering/deletion).
if (!old.hasFootnotes && !transactionInsertsFootnote(tr)) {
return old;
}
return buildFootnoteNumberingState(tr.doc);
},
},
props: {
decorations(state) {
return footnoteNumberingPluginKey.getState(state)?.decorations;
},
},
});
}