perf: the document's fields answer a pass in one round
Every field resolving against the settled layout — each index's page numbers, every cross-reference — registers with scheduleFieldRound instead of taking a frame of its own. A round measures for all of them before any of them writes, since a write between two measurements lays the whole document out again, and their cached results ride one transaction: a dispatch of its own costs a view update over the document, 55 ms per index on a 460-page file. That file settles about 220 ms earlier, with every spacer, page number and reference exactly where it was on five documents.
This commit is contained in:
1 parent
50c7ec5236
commit
7e687b6ccb
5 files changed
+82
-44
No files matched your search
@@ -3,7 +3,7 @@ import type { Editor } from '@tiptap/core';
|
||||
import type { Node as PMNode } from '@tiptap/pm/model';
|
||||
import type { EditorView } from '@tiptap/pm/view';
|
||||
import { bookmarks, findBookmark, type BookmarkRef } from './bookmark';
|
||||
import { readVerticalMargins, pageOfElement, type PageGrid } from './pageBreaks';
|
||||
import { readVerticalMargins, pageOfElement, scheduleFieldRound, type FieldWrite, type PageGrid } from './pageBreaks';
|
||||
|
||||
// A cross-reference: an inline atom showing either the text of a bookmark or the page it
|
||||
// sits on, kept live by the node view the way the TOC keeps its page numbers. Round-trips
|
||||
@@ -100,11 +100,10 @@ function resolveText(view: EditorView, node: PMNode, target: BookmarkRef | undef
|
||||
}
|
||||
|
||||
// Every reference of one editor resolves in one go, on each pagination settle
|
||||
// (pm-pagecount) and on a change to any of them: one bookmark scan, every page read
|
||||
// before any text is written, and one transaction for the texts that changed.
|
||||
// (pm-pagecount) and on a change to any of them: one bookmark scan, and every page read
|
||||
// in the field round's measuring phase, before any text of the round is written.
|
||||
class CrossRefBatch {
|
||||
private views = new Set<CrossRefView>();
|
||||
private scheduled = false;
|
||||
private listening = false;
|
||||
|
||||
constructor(private editor: Editor) {}
|
||||
@@ -119,15 +118,11 @@ class CrossRefBatch {
|
||||
}
|
||||
|
||||
schedule(): void {
|
||||
if (this.scheduled) return;
|
||||
this.scheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
this.scheduled = false;
|
||||
this.run();
|
||||
});
|
||||
if (this.editor.isDestroyed) return;
|
||||
scheduleFieldRound(this.editor.view, this, () => this.measure());
|
||||
}
|
||||
|
||||
private run(): void {
|
||||
private measure(): FieldWrite | void {
|
||||
const { editor } = this;
|
||||
if (editor.isDestroyed) return;
|
||||
const view = editor.view;
|
||||
@@ -146,15 +141,12 @@ class CrossRefBatch {
|
||||
if (pos === null || !node || node.type.name !== 'crossRef' || !ref.dom.isConnected) continue;
|
||||
jobs.push({ ref, node, pos, text: resolveText(view, node, targets.get(String(node.attrs.name ?? '')), gridOf) });
|
||||
}
|
||||
for (const j of jobs) j.ref.paint(j.node, j.text);
|
||||
const tr = editor.state.tr.setMeta('addToHistory', false);
|
||||
let changed = false;
|
||||
for (const j of jobs) {
|
||||
if (j.text === j.node.attrs.text) continue;
|
||||
tr.setNodeAttribute(j.pos, 'text', j.text);
|
||||
changed = true;
|
||||
}
|
||||
if (changed) view.dispatch(tr);
|
||||
return (tr) => {
|
||||
for (const j of jobs) j.ref.paint(j.node, j.text);
|
||||
for (const j of jobs) {
|
||||
if (j.text !== j.node.attrs.text) tr.setNodeAttribute(j.pos, 'text', j.text);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -329,6 +329,41 @@ export const pageBreakKey = new PluginKey<PageBreakState>('pageBreaks');
|
||||
// pagination recompute even though the document content is unchanged.
|
||||
export const FORCE_PAGE_RECALC = 'forcePageBreakRecalc';
|
||||
|
||||
// A field that reads the settled layout — an index's page numbers, a cross-reference's.
|
||||
// It measures, and what it returns writes: its own DOM, and its cached result on the
|
||||
// round's transaction. A write may return one more phase, run after every other write.
|
||||
export type FieldWrite = (tr: Transaction) => FieldWrite | void;
|
||||
export type FieldRead = () => FieldWrite | void;
|
||||
|
||||
type FieldRound = { jobs: Map<object, FieldRead>; raf: number | null };
|
||||
const fieldRounds = new WeakMap<EditorView, FieldRound>();
|
||||
|
||||
// Every field of one editor answers a pagination pass together: all measure before any
|
||||
// writes, or each write lays the whole document out again for the next reader — and one
|
||||
// transaction carries the results, a dispatch costing a view update over the document.
|
||||
export function scheduleFieldRound(view: EditorView, owner: object, read: FieldRead): void {
|
||||
let round = fieldRounds.get(view);
|
||||
if (!round) fieldRounds.set(view, (round = { jobs: new Map(), raf: null }));
|
||||
round.jobs.set(owner, read);
|
||||
if (round.raf === null) round.raf = requestAnimationFrame(() => runFieldRound(view));
|
||||
}
|
||||
|
||||
function runFieldRound(view: EditorView): void {
|
||||
const round = fieldRounds.get(view);
|
||||
if (!round) return;
|
||||
round.raf = null;
|
||||
let phase = Array.from(round.jobs.values(), (read) => read());
|
||||
round.jobs.clear();
|
||||
const tr = view.state.tr.setMeta('addToHistory', false);
|
||||
// Bounded: measure, write, and the one read-back a write can ask for.
|
||||
for (let i = 0; i < 2 && phase.some(Boolean); i++) {
|
||||
phase = phase.map((write) => (write ? write(tr) : undefined));
|
||||
}
|
||||
// A field that only moved its own rows carries no document change, and still needs the
|
||||
// pass to measure the height it just changed.
|
||||
if (tr.docChanged || tr.getMeta(FORCE_PAGE_RECALC)) view.dispatch(tr);
|
||||
}
|
||||
|
||||
// A split view's second pane (Editor.svelte marks its host). Both panes are the same
|
||||
// width, so it renders the decorations the first pane's pass produced; a layout pass of
|
||||
// its own would measure the same DOM twice and fight over the shared result.
|
||||
|
||||
@@ -9,7 +9,8 @@ import { seqCategoryOf, sequenceFieldText, type SeqCategory } from './caption';
|
||||
import { indexEntries, indexRows } from './indexEntry';
|
||||
import { bibliographyEntries, bibliographyRows } from './bibliographyEntry';
|
||||
import { isCitationStyle, type CitationStyle } from '../../utils/citationStyle';
|
||||
import { readVerticalMargins, pageOfElement, topInEditor, FORCE_PAGE_RECALC, type PageGrid } from './pageBreaks';
|
||||
import type { Transaction } from '@tiptap/pm/state';
|
||||
import { readVerticalMargins, pageOfElement, topInEditor, scheduleFieldRound, FORCE_PAGE_RECALC, type FieldWrite, type PageGrid } from './pageBreaks';
|
||||
|
||||
// A generated index: a block atom listing every source with its live page number — the
|
||||
// headings for a table of contents, the captions of one category for a list of figures
|
||||
@@ -199,13 +200,13 @@ function inCellOrItem(doc: PMNode, pos: number): boolean {
|
||||
}
|
||||
|
||||
// Node view: renders the title + one clickable row per heading. Recomputes on each
|
||||
// pagination settle (pm-pagecount, caught on the .paper ancestor) and on doc change,
|
||||
// writing entries back to the node attr — guarded by a serialized key against a loop.
|
||||
// pagination settle (pm-pagecount, caught on the .paper ancestor) and on doc change, in
|
||||
// the field round: it measures with the other fields, and writes its entries back on the
|
||||
// round's transaction — guarded by a serialized key against a loop.
|
||||
class TocView {
|
||||
dom: HTMLElement;
|
||||
private editor: Editor;
|
||||
private getPos: () => number;
|
||||
private scheduled = false;
|
||||
private lastKey = '';
|
||||
private lastLook = '';
|
||||
private wasPaginated = false;
|
||||
@@ -225,17 +226,13 @@ class TocView {
|
||||
requestAnimationFrame(() => {
|
||||
this.paper = this.dom.closest('.paper') as HTMLElement | null;
|
||||
this.paper?.addEventListener('pm-pagecount', this.onPageCount);
|
||||
this.render();
|
||||
this.schedule();
|
||||
});
|
||||
}
|
||||
|
||||
private schedule(): void {
|
||||
if (this.scheduled) return;
|
||||
this.scheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
this.scheduled = false;
|
||||
this.render();
|
||||
});
|
||||
if (this.editor.isDestroyed) return;
|
||||
scheduleFieldRound(this.editor.view, this, () => this.measure());
|
||||
}
|
||||
|
||||
// What the index lists, in document order: the headings down to its own level, or —
|
||||
@@ -308,7 +305,7 @@ class TocView {
|
||||
return pageOfElement(this.editor.view, el, grid);
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
private measure(): FieldWrite | void {
|
||||
if (this.editor.isDestroyed || !this.dom.isConnected) return;
|
||||
const grid = this.grid();
|
||||
let heads = this.sources();
|
||||
@@ -329,18 +326,23 @@ class TocView {
|
||||
const { entries: _cached, ...look } = this.node()?.attrs ?? {};
|
||||
this.lastLook = JSON.stringify(look);
|
||||
const key = JSON.stringify([entries, look]);
|
||||
if (key !== this.lastKey) {
|
||||
this.lastKey = key;
|
||||
this.paint(entries, heads);
|
||||
this.syncAttr(entries);
|
||||
}
|
||||
this.paginate();
|
||||
const stale = key !== this.lastKey;
|
||||
this.lastKey = key;
|
||||
return (tr) => {
|
||||
if (stale) {
|
||||
this.paint(entries, heads);
|
||||
this.syncAttr(entries, tr);
|
||||
}
|
||||
// The rows carry their page numbers now, so where they fall is read once every
|
||||
// field of the round has written.
|
||||
return (last) => this.paginate(last);
|
||||
};
|
||||
}
|
||||
|
||||
// The index is a block atom: it has no inner document positions for pagination to put
|
||||
// a spacer at, so one longer than a page breaks itself — the row that would cross the
|
||||
// boundary takes the gap to the next page's content top as its margin.
|
||||
private paginate(): void {
|
||||
private paginate(tr: Transaction): void {
|
||||
const rows = Array.from(this.dom.querySelectorAll<HTMLElement>('.toc-entry'));
|
||||
if (!rows.length) return;
|
||||
const view = this.editor.view;
|
||||
@@ -364,7 +366,7 @@ class TocView {
|
||||
// The index just changed height, and pagination measured the old one.
|
||||
if (moved !== this.wasPaginated) {
|
||||
this.wasPaginated = moved;
|
||||
view.dispatch(view.state.tr.setMeta('addToHistory', false).setMeta(FORCE_PAGE_RECALC, true));
|
||||
tr.setMeta(FORCE_PAGE_RECALC, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,14 +478,13 @@ class TocView {
|
||||
return typeof pos === 'number' ? this.editor.state.doc.nodeAt(pos) : null;
|
||||
}
|
||||
|
||||
private syncAttr(entries: TocEntry[]): void {
|
||||
private syncAttr(entries: TocEntry[], tr: Transaction): void {
|
||||
const pos = this.getPos();
|
||||
if (typeof pos !== 'number') return;
|
||||
const node = this.editor.state.doc.nodeAt(pos);
|
||||
if (!node || node.type.name !== 'tableOfContents') return;
|
||||
if (JSON.stringify(node.attrs.entries ?? []) === JSON.stringify(entries)) return;
|
||||
const tr = this.editor.state.tr.setNodeAttribute(pos, 'entries', entries).setMeta('addToHistory', false);
|
||||
this.editor.view.dispatch(tr);
|
||||
tr.setNodeAttribute(pos, 'entries', entries);
|
||||
}
|
||||
|
||||
update(node: PMNode): boolean {
|
||||
|
||||
Reference in new issue
Block a user