From 2f51292587c8f89d5b1e024d33b64fa6182f032c Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Wed, 9 Sep 2026 09:01:29 +0200 Subject: [PATCH] refactor: a field round reads the page grid once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both indexes and the cross-references each read it for themselves, twice per round in the index's case: 59 readings of it over a load where 37 do. The round hands one to every reader instead, so they all resolve their pages against the same grid, and the lazy holders they kept go with it. No measurable time — the style pass a reading forces is owed to the geometry read that follows it either way. --- docs/architecture/pagination.md | 3 ++- src/lib/editor/extensions/crossReference.ts | 9 ++++----- src/lib/editor/extensions/pageBreaks.ts | 7 +++++-- src/lib/editor/extensions/tableOfContents.ts | 17 ++++++----------- 4 files changed, 17 insertions(+), 19 deletions(-) diff --git a/docs/architecture/pagination.md b/docs/architecture/pagination.md index fb90d17..7b2531e 100644 --- a/docs/architecture/pagination.md +++ b/docs/architecture/pagination.md @@ -149,7 +149,8 @@ Opening a 460-page document ran five rounds of that where four answer everything **The fields of one answer read together.** Every field that resolves against the settled layout — each index's page numbers, every cross-reference — registers with `scheduleFieldRound(view, owner, read)` (`pageBreaks.ts`) instead of scheduling a frame of -its own. The round runs every reader's measurement first and only then their writes, since +its own. The round reads the page grid once and hands it to every reader — they all resolve +against the same one — and runs every reader's measurement first and only then their writes, since a write between two measurements makes the browser lay the whole document out again; a write may return one more phase (the index reads back where its rows now fall), run after every other write. Their cached results — the index `entries`, a reference's `text` — ride diff --git a/src/lib/editor/extensions/crossReference.ts b/src/lib/editor/extensions/crossReference.ts index 38b56c2..3a9269c 100644 --- a/src/lib/editor/extensions/crossReference.ts +++ b/src/lib/editor/extensions/crossReference.ts @@ -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, scheduleFieldRound, type FieldWrite, type PageGrid } from './pageBreaks'; +import { pageOfElement, scheduleFieldRound, type FieldWrite, type PageGrid, type VMargins } 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 @@ -119,10 +119,10 @@ class CrossRefBatch { schedule(): void { if (this.editor.isDestroyed) return; - scheduleFieldRound(this.editor.view, this, () => this.measure()); + scheduleFieldRound(this.editor.view, this, (vm) => this.measure(vm)); } - private measure(): FieldWrite | void { + private measure(vm: VMargins): FieldWrite | void { const { editor } = this; if (editor.isDestroyed) return; const view = editor.view; @@ -132,8 +132,7 @@ class CrossRefBatch { } const targets = new Map(); for (const b of bookmarks(editor.state.doc)) if (!targets.has(b.name)) targets.set(b.name, b); - let grid: PageGrid | null = null; - const gridOf = () => (grid ??= readVerticalMargins(view.dom as HTMLElement).grid); + const gridOf = () => vm.grid; const jobs: { ref: CrossRefView; node: PMNode; pos: number; text: string }[] = []; for (const ref of this.views) { const pos = ref.pos(); diff --git a/src/lib/editor/extensions/pageBreaks.ts b/src/lib/editor/extensions/pageBreaks.ts index 0301d03..e7ba97e 100644 --- a/src/lib/editor/extensions/pageBreaks.ts +++ b/src/lib/editor/extensions/pageBreaks.ts @@ -333,7 +333,7 @@ export const FORCE_PAGE_RECALC = 'forcePageBreakRecalc'; // 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; +export type FieldRead = (vm: VMargins) => FieldWrite | void; type FieldRound = { jobs: Map; raf: number | null }; const fieldRounds = new WeakMap(); @@ -352,7 +352,10 @@ 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()); + // One reading of the page grid for the round: getComputedStyle is a style pass over + // the whole document, and every field resolves its pages against the same grid. + const vm = readVerticalMargins(view.dom as HTMLElement); + let phase = Array.from(round.jobs.values(), (read) => read(vm)); round.jobs.clear(); const tr = view.state.tr.setMeta('addToHistory', false); // Bounded: measure, write, and the one read-back a write can ask for. diff --git a/src/lib/editor/extensions/tableOfContents.ts b/src/lib/editor/extensions/tableOfContents.ts index 6c112cf..a074487 100644 --- a/src/lib/editor/extensions/tableOfContents.ts +++ b/src/lib/editor/extensions/tableOfContents.ts @@ -10,7 +10,7 @@ import { indexEntries, indexRows } from './indexEntry'; import { bibliographyEntries, bibliographyRows } from './bibliographyEntry'; import { isCitationStyle, type CitationStyle } from '../../utils/citationStyle'; import type { Transaction } from '@tiptap/pm/state'; -import { readVerticalMargins, pageOfElement, topInEditor, scheduleFieldRound, FORCE_PAGE_RECALC, type FieldWrite, type PageGrid } from './pageBreaks'; +import { pageOfElement, topInEditor, scheduleFieldRound, FORCE_PAGE_RECALC, type FieldWrite, type PageGrid, type VMargins } 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 @@ -232,7 +232,7 @@ class TocView { private schedule(): void { if (this.editor.isDestroyed) return; - scheduleFieldRound(this.editor.view, this, () => this.measure()); + scheduleFieldRound(this.editor.view, this, (vm) => this.measure(vm)); } // What the index lists, in document order: the headings down to its own level, or — @@ -295,19 +295,15 @@ class TocView { return out; } - private grid(): PageGrid { - return readVerticalMargins(this.editor.view.dom as HTMLElement).grid; - } - private pageOf(pos: number, grid: PageGrid): number { const el = this.editor.view.nodeDOM(pos) as HTMLElement | null; if (!el || el.nodeType !== 1) return 1; return pageOfElement(this.editor.view, el, grid); } - private measure(): FieldWrite | void { + private measure(vm: VMargins): FieldWrite | void { if (this.editor.isDestroyed || !this.dom.isConnected) return; - const grid = this.grid(); + const grid = vm.grid; let heads = this.sources(); let entries: TocEntry[]; if (indexKindOf(this.node()?.attrs?.index) === 'alphabetical') { @@ -335,18 +331,17 @@ class TocView { } // 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); + return (last) => this.paginate(last, vm); }; } // 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(tr: Transaction): void { + private paginate(tr: Transaction, vm: VMargins): void { const rows = Array.from(this.dom.querySelectorAll('.toc-entry')); if (!rows.length) return; const view = this.editor.view; - const vm = readVerticalMargins(view.dom as HTMLElement); for (const row of rows) row.style.marginTop = ''; // Read every natural top and height first: applying a gap moves each row below it, // and one reflow for the whole index beats one per row.