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