From 7e687b6ccb05b74a328e480573efec8cd0737c1f Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Wed, 9 Sep 2026 00:51:54 +0200 Subject: [PATCH] perf: the document's fields answer a pass in one round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/architecture/formatting.md | 2 +- docs/architecture/pagination.md | 10 +++++ src/lib/editor/extensions/crossReference.ts | 32 +++++-------- src/lib/editor/extensions/pageBreaks.ts | 35 +++++++++++++++ src/lib/editor/extensions/tableOfContents.ts | 47 ++++++++++---------- 5 files changed, 82 insertions(+), 44 deletions(-) diff --git a/docs/architecture/formatting.md b/docs/architecture/formatting.md index ba5d75c..dbd29fe 100644 --- a/docs/architecture/formatting.md +++ b/docs/architecture/formatting.md @@ -11,4 +11,4 @@ list markers, paragraph boxes, date/time fields and cross-references. A **run of tabs wider than the line** continues on the next one with the grid starting over, as LibreOffice lays it out; Chromium instead hangs the leftover tabs and starts the next line at the margin. `runBreaks` walks the run analytically from the pen *before* it — the one thing the fix can't move, so the answer is the same whether or not the break is already in place — and emits a `
` widget where the line runs out. Only runs of two or more are considered: reading a block's geometry forces a reflow, and doing it for every tab in a 48-page document slowed pagination enough to skew the parity harness. - **`listMarker.ts`** (`ListMarker`) — the marker's **family, weight, slant, size and color**, taken from the item's **first text portion** (`markerFormat`): a mark lives inside the item's paragraph and never reaches `::marker`, which only inherits the `li`'s own font, so a bold numbered heading would keep a light number. A **character style** on that portion is resolved through the registry (`charStyleProps`, handed in by the caller: the plugin from its `sheet` option, the exporters from their `exportSheet`); what a **named paragraph style** provides reaches the marker through `styleCss`'s `li:has(> …)` rule instead. First portion, not the whole line, is LibreOffice's own numbering rule (verified by probe: bold-then-plain gets a bold number, plain-then-bold does not; Word instead takes the paragraph mark). **Rendering**: a node decoration puts `--marker-family/-weight/-style/-size/-color` on **every** `listItem` — they inherit, so an item without its own format resets them to `initial` (the guaranteed-invalid value, so the `var(…, inherit)` in `editor.css` falls back; `inherit` would pull the parent item's value in), and `editor.css` has one rule reading them on the item block's `::before` — which is what **draws** the marker: `::marker` cannot be positioned, and Chromium sets it flush against the text, ~4mm right of the level's hanging indent (`LIST_HANGING_CM` 0.635cm, what both exports write). The pseudo is **floated** into that indent (`min-width` the indent, negative `margin-left`): in flow it grew every list line by a pixel, and absolutely positioned a label wider than the indent — `1.1.1.`, `viii.` — printed over the text instead of pushing it. Word and LibreOffice push too, on to the next tab stop; the float stops flush after the label. `list-style: none` drops only the marker box, so the `list-item` counter and `
    ` keep numbering, and `content: counter(list-item, …)`/`counters(list-item, '.')` per `data-eff-list-style` replaces `list-style-type`; a `bulletChar` rides `--bullet` (an `attr()` inside the pseudo would read the block, not the list). `export/pdf.ts` switches the pseudo off and injects the same marker as a real span, since html2canvas cannot paint counters. The bullet shim keeps the symbol font first and takes `--marker-family` as its fallback. The pass also re-runs on `FORCE_PAGE_RECALC`, so editing a character style repaints the markers. **Export**: both formats carry marker formatting **per level**, so `listMarkerFormat` returns one only when *every* item of the list agrees — ODF mints a **named** `style:family="text"` style **in styles.xml** and points the level definition at it via `text:style-name` (`collectListMarkerFormats`/`applyListMarkerFormats`) — LibreOffice ignores the reference when it resolves to an automatic style in content.xml (probed), DOCX writes the level's `w:rPr` (`markerRunProps` → `ILevelsOptions.style.run`). A **mixed** list gets no level format on purpose: LibreOffice then applies its first-portion rule and matches the editor, Word falls back to the paragraph mark (which only carries the size). Import needs nothing — the runs already carry the formatting. Nested lists that odf-kit restyles (`applyNestedListTypes`) and in-cell lists keep a plain marker. A character style **inside a list item** is handled by an ODF pre-pass (`bakeListCharStyles`) instead: odf-kit's list builder reads marks itself and knows nothing about `charStyle`, and `applyRuns` never sees a list paragraph, so the pre-pass does both of its jobs — bake the resolved props into direct marks (which is what gets the run a `` at all) and prefix the `CST` sentinel, so `applyCharacterStyles` re-points that span at the named style like everywhere else. Formatting and name both round-trip. `tests/unit/list-marker.test.ts` covers the four LibreOffice cases, the reset, and both export legs. Both this set and `listStyle.ts`’s are rebuilt only for a transaction reaching into a list (`touchesList` — a step by its own `from`/`to` or `pos`, since a mark step’s position map is empty, and by what its map moved) or for the stylesheet’s own signal (`SHEET_CHANGED`, dispatched by `Editor.svelte`’s stylesheet effect); anything else maps the old decorations forward, so an edit anywhere in a long document no longer walks it whole. `tests/unit/list-marker-rebuild.test.ts` covers which transactions count. **Label alignment** (`markerAlign` on the list node, Word's `w:lvlJc`, ODF's `fo:text-align` on the level properties): `right` sets the label's *far* end against the hanging indent, so a wide number — the Roman numerals Word's built-in numberings use it for — grows into the margin instead of over the text. `tests/unit/marker-align.test.ts` round-trips it. - **`dateTimeField.ts`** (`DateTimeField`) — inline atom for an inserted date/time field (Word's "Insert Date and Time"). Attrs: `kind` (`'date'|'time'`), `format` (a catalog key), `fixed`, `value` (ISO local datetime captured at insert). A **fixed** field renders its stored `value`; an **auto** field renders the *current* moment on every load (`renderHTML`/`renderText` via `dateTimeFieldText`) — no plugin, Word/LibreOffice likewise only refresh on open. As an inline atom with no children it can't inherit the surrounding run's font from a sibling span, so it **carries that run's marks** on the node: insert adopts the cursor's stored marks, and both importers attach the field run's resolved marks (else it falls back to the editor default font). Command `insertDateTimeField({kind,format,fixed})`. Formats live in `utils/dateTime.ts` as a **token model** (one `Token[]` per format) so the three consumers stay in agreement: `renderFormat` (locale display via `Intl`), `odfNumberStyle` (``/`` body), `docxPicture` (Word field switch). `DateTimePicker.svelte` (a dropdown next to `SpecialCharPicker` in `ToolbarExpanded.svelte`) lists live samples + an "update automatically" checkbox. Not in the header/footer schema (there a ``/DATE field stays plain text). **ODF**: `replaceDateTimeFields`/`applyDateTimeFields` (sentinel U+E00A, mirrors images) emit ``/`` + a minted per-format number style (language from the export doc language; `ensureNumberNamespace` adds `xmlns:number`); import matches the referenced number style back to a format via `parseNumberStyleTokens` + `matchFormat` (unknown ⇒ keep the shown text). **DOCX**: auto → `SimpleField` `DATE`/`TIME \@ "picture"` with a cached run; fixed → a plain text run (Word has no fixed-date field); import maps a `DATE`/`TIME` `fldSimple`/complex field's picture back to a format (`dateTimeFieldFromInstr`). -- **`bookmark.ts`** (`Bookmark`) + **`crossReference.ts`** (`CrossReference`) — a named range of text and the references pointing at it. A bookmark is a **mark**, not a point node: both formats store a range, and only a range lets a `REF` recompute its text. It carries one `name` (`data-bookmark`), is `inclusive: false`, and names are sanitized to Word's rules on insert (`sanitizeBookmarkName`: `[A-Za-z0-9_.]`, no leading digit, 40 chars) — which also keeps the ODF sentinel pass free of XML escaping. A mark type excludes itself, so **overlapping ranges collapse to the outermost** and both importers keep the first. Word's `_GoBack` and every point bookmark (ODF ``, a zero-length Word range) are dropped — there is no range to mark. `bookmarks(doc)`/`findBookmark`/`bookmarkNames` are the shared lookups (dialogs, node view, `Editor.svelte`'s click handler). A cross-reference is an inline atom with `name`, `format` (`'text'|'page'`) and `text`, the last being the **cached display** exactly as a Word field caches its result. Every reference of an editor resolves in one batch (`CrossRefBatch`) on each `pm-pagecount` and on a change to any of them — one bookmark scan, every page read before any text is written, one transaction for what changed; the bookmark's own text, or its page via `pageOfElement`/`readVerticalMargins` (both from `pageBreaks.ts`, shared with the TOC) — and **leaves the cached text alone when the target is gone**, which is what a reference to a deleted bookmark shows. Ctrl/Cmd+click on a reference or on an internal `#name` link jumps to the bookmark and selects it. Not in the header/footer schema. **ODF**: `replaceBookmarks` splices the `BMS`/`BME` sentinels (U+E013/U+E014) into the run text and swaps each `crossRef` for an `XRF` run (U+E015) — splicing into the text, not into a run emitter, is what makes them ride every odf-kit path, cells and custom paragraphs included; `applyBookmarks` rewrites all three to ``, `` and ``. **DOCX**: the `docx` package covers both natively — `Bookmark` around the marked runs, `SimpleField` `REF`/`PAGEREF … \h` with the cached result, `InternalHyperlink` for a `#name` href. A range spanning paragraphs is emitted per paragraph (Word tolerates the repeated name). Import tracks open ranges in `Ctx` across both the block and the inline walk (Word writes a `w:bookmarkStart` beside paragraphs as well as beside runs) and reads `REF`/`PAGEREF` out of the `fldChar` state machine and `w:fldSimple` alike, including a field Word never resolved (no `w:separate`, so no cached result — the node view fills it in). +- **`bookmark.ts`** (`Bookmark`) + **`crossReference.ts`** (`CrossReference`) — a named range of text and the references pointing at it. A bookmark is a **mark**, not a point node: both formats store a range, and only a range lets a `REF` recompute its text. It carries one `name` (`data-bookmark`), is `inclusive: false`, and names are sanitized to Word's rules on insert (`sanitizeBookmarkName`: `[A-Za-z0-9_.]`, no leading digit, 40 chars) — which also keeps the ODF sentinel pass free of XML escaping. A mark type excludes itself, so **overlapping ranges collapse to the outermost** and both importers keep the first. Word's `_GoBack` and every point bookmark (ODF ``, a zero-length Word range) are dropped — there is no range to mark. `bookmarks(doc)`/`findBookmark`/`bookmarkNames` are the shared lookups (dialogs, node view, `Editor.svelte`'s click handler). A cross-reference is an inline atom with `name`, `format` (`'text'|'page'`) and `text`, the last being the **cached display** exactly as a Word field caches its result. Every reference of an editor resolves in one batch (`CrossRefBatch`) on each `pm-pagecount` and on a change to any of them — one bookmark scan, and every page read in the shared field round's measuring phase (`scheduleFieldRound`, `docs/architecture/pagination.md`), before any text of the round is written and on the round's own transaction; the bookmark's own text, or its page via `pageOfElement`/`readVerticalMargins` (both from `pageBreaks.ts`, shared with the TOC) — and **leaves the cached text alone when the target is gone**, which is what a reference to a deleted bookmark shows. Ctrl/Cmd+click on a reference or on an internal `#name` link jumps to the bookmark and selects it. Not in the header/footer schema. **ODF**: `replaceBookmarks` splices the `BMS`/`BME` sentinels (U+E013/U+E014) into the run text and swaps each `crossRef` for an `XRF` run (U+E015) — splicing into the text, not into a run emitter, is what makes them ride every odf-kit path, cells and custom paragraphs included; `applyBookmarks` rewrites all three to ``, `` and ``. **DOCX**: the `docx` package covers both natively — `Bookmark` around the marked runs, `SimpleField` `REF`/`PAGEREF … \h` with the cached result, `InternalHyperlink` for a `#name` href. A range spanning paragraphs is emitted per paragraph (Word tolerates the repeated name). Import tracks open ranges in `Ctx` across both the block and the inline walk (Word writes a `w:bookmarkStart` beside paragraphs as well as beside runs) and reads `REF`/`PAGEREF` out of the `fldChar` state machine and `w:fldSimple` alike, including a field Word never resolved (no `w:separate`, so no cached result — the node view fills it in). diff --git a/docs/architecture/pagination.md b/docs/architecture/pagination.md index 34d3862..fb90d17 100644 --- a/docs/architecture/pagination.md +++ b/docs/architecture/pagination.md @@ -146,6 +146,16 @@ cross-reference its number) and writes back what it found, which is a document c is another pass: a pass that lands what the last one did would set the round going again. 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 +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 +**one** transaction, a dispatch of its own costing a view update over the whole document: +that alone was 55 ms per index on a 460-page file. + **Layout constants** (must stay in sync between `pageBreaks.ts`, `Editor.svelte`, and `editor.css`): - `PAGE_HEIGHT = 1123px` (A4 portrait), `PAGE_GAP = 20px`, `CYCLE = PAGE_HEIGHT + PAGE_GAP = 1143px`. - Page height/width and margins are read **live** from CSS custom properties (`--user-page-height`, `--user-page-width`, `--user-margin-*`) so orientation/margin changes don't require new constants. `getCycle()` in `Editor.svelte` reads `--user-page-height` at runtime. diff --git a/src/lib/editor/extensions/crossReference.ts b/src/lib/editor/extensions/crossReference.ts index 44c202a..38b56c2 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, 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(); - 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); + } + }; } } diff --git a/src/lib/editor/extensions/pageBreaks.ts b/src/lib/editor/extensions/pageBreaks.ts index 98a7f4b..0301d03 100644 --- a/src/lib/editor/extensions/pageBreaks.ts +++ b/src/lib/editor/extensions/pageBreaks.ts @@ -329,6 +329,41 @@ export const pageBreakKey = new PluginKey('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; raf: number | null }; +const fieldRounds = new WeakMap(); + +// 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. diff --git a/src/lib/editor/extensions/tableOfContents.ts b/src/lib/editor/extensions/tableOfContents.ts index 9a245e9..6c112cf 100644 --- a/src/lib/editor/extensions/tableOfContents.ts +++ b/src/lib/editor/extensions/tableOfContents.ts @@ -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('.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 {