perf: one refit for every frame, and a pass that moves nothing stays quiet

A single ResizeObserver refits the frames, reading each rotor before any wrapper is
written: a per-view one arrives in a callback of its own, so the browser laid the
document out again between two frames. pm-pagecount is announced only where the layout
differs from the last announcement — every reader re-reads the whole document from it and
writes back, which is another pass. Both node views also leave the DOM alone when update()
brings the same attrs object. A 460-page file holding 450 frames and two indexes settles
2.4 s earlier and is named 200 ms earlier, with every spacer and page number where it was.
This commit is contained in:
Steffen Becker committed 2026-09-09 00:08:36 +02:00
1 parent 6693d04313
commit 50c7ec5236
6 files changed
+97 -23

No files matched your search

+13
View File
@@ -23,6 +23,19 @@ paste into another window keeps the box where it stood in the line, while a prog
cannot read the attribute still gets the span's plain text. An **AutoText** entry stores
the slice's nodes for the same reason (`storage/autoText.ts`).
## What a frame costs a big document
Every frame is refitted by **one** `ResizeObserver` for the whole document, and a round's
reads run before its writes: a per-view observer is delivered in a callback of its own, so
the wrapper written for one frame makes the browser lay the document out again before the
next one's `offsetWidth` is read — 1.4 s of forced layout on a document holding 450 frames,
and the ProseMirror DOM observer flushes (each reading the selection, each another layout)
once per callback on top. The size is read from the rotor, not taken from the observation:
`offsetWidth` snaps to the pixel grid the frame sits on, and the fractional border box moves
blocks below it by a pixel. Both node views also leave the DOM alone when `update()` brings
the same `attrs` object — everything they write is drawn from the attrs, and text typed
inside a box keeps them, so there is nothing to redraw.
## Clicking a float
A block after a float keeps its own box **over** the float — only its line boxes move out
+8
View File
@@ -138,6 +138,14 @@ behind until the next collection, so one Range is reused; and TipTap's Placehold
after a split went from 410 to 150 ms, the keystroke that splits from 219 to 50 ms; in a
document of real words, where few squiggles split the text nodes, 47 and 18 ms.
**A pass tells the document only what changed.** `pm-pagecount` is announced where the
numbers on it — page count, document height, the section start pages, the placements, the
table bands — differ from the last announcement. Every reader re-reads the whole document
from the event (the index resolves each row's page, the header band its geometry, every
cross-reference its number) and writes back what it found, which is a document change, which
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.
**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.
+4
View File
@@ -730,7 +730,11 @@ class ImageView {
update(node: PMNode): boolean {
if (node.type !== this.node.type) return false;
// The picture is drawn from the attrs alone: an unchanged attrs object is the
// picture already on the page, and rewriting it costs a layout per picture.
const drawn = node.attrs === this.node.attrs;
this.node = node;
if (drawn) return true;
const src = (node.attrs.src as string) ?? '';
if (this.img.getAttribute('src') !== src) this.img.src = src;
this.img.alt = (node.attrs.alt as string) ?? '';
+13 -4
View File
@@ -430,6 +430,7 @@ export const PageBreaks = Extension.create({
// below a float's overhang moves further than the model's spacer), so seeing the older
// key return means a ping-pong. null, not '' — that is a spacer-free layout's own key.
let prevPlacementsKey: string | null = null;
let lastAnnounced = '';
// Each pass measures against the spacers left by the previous pass, so a changed
// result needs one more pass to re-measure and settle (see calculate). Bounded so
// a hypothetical two-layout ping-pong can't loop forever; reset per external change.
@@ -1899,10 +1900,18 @@ export const PageBreaks = Extension.create({
}));
// docHeight (document px) lets Editor.svelte size the scaled scroll footprint.
dom.dispatchEvent(new CustomEvent('pm-pagecount', {
bubbles: true,
detail: { numPages, docHeight: targetHeight, tableBreakBands, sectionStartPages },
}));
// Announced only where the layout differs from the last one: every reader
// re-reads the whole document from it and writes back what it found, so a pass
// that lands what the last one did would set the round going again.
const announced = `${numPages}:${targetHeight}:${sectionStartPages.join()}:${placementsKey}:`
+ tableBreakBands.map((b) => `${b.key}@${Math.round(b.closeY)}`).join();
if (announced !== lastAnnounced) {
lastAnnounced = announced;
dom.dispatchEvent(new CustomEvent('pm-pagecount', {
bubbles: true,
detail: { numPages, docHeight: targetHeight, tableBreakBands, sectionStartPages },
}));
}
lastSnapshot = {
timestamp: new Date().toISOString(),
+45 -17
View File
@@ -440,6 +440,32 @@ export const TextBox = Node.create({
},
});
type Size = { w: number; h: number };
const rotorSize = (el: HTMLElement): Size => ({ w: el.offsetWidth, h: el.offsetHeight });
// One observer for every frame in the document: a per-view one is delivered in a
// callback of its own, so the browser lays the document out again between any two
// frames — 1.4 s on a document holding 450 of them.
const fitted = new WeakMap<Element, TextBoxView>();
let fitObserver: ResizeObserver | null = null;
function observeFit(rotor: HTMLElement, view: TextBoxView): void {
fitted.set(rotor, view);
// A round's reads before its writes: the size is offsetWidth's, which snaps to the
// pixel grid the frame sits on and so is the rotor's own, not the observation's.
fitObserver ??= new ResizeObserver((entries) => {
const jobs = entries.map((e) => [fitted.get(e.target), rotorSize(e.target as HTMLElement)] as const);
for (const [view, size] of jobs) view?.refit(size);
});
fitObserver.observe(rotor);
}
function unobserveFit(rotor: HTMLElement): void {
fitObserver?.unobserve(rotor);
fitted.delete(rotor);
}
// Node view: like ImageView, an axis-aligned wrapper reserves the rotated bounding box and
// a centered rotor carries fill/stroke/rotation plus the handles — but the rotor holds an
// editable contentDOM and auto-grows, so a ResizeObserver re-fits the wrapper.
@@ -451,7 +477,6 @@ class TextBoxView {
private node: PMNode;
private editor: Editor;
private getPos: () => number;
private observer: ResizeObserver | null = null;
private resizing = false;
// The polygon outline, for a shape CSS cannot draw; null for the three it can.
private outline: SVGPathElement | null = null;
@@ -502,13 +527,7 @@ class TextBoxView {
// The rotor is out of flow and grows with its content; refit the wrapper to the
// rotated bounding box whenever the rendered size changes (typing, resizing), and
// re-fit an ellipse's text area to its now-current height.
this.observer = new ResizeObserver(() => {
this.applyShapeInset();
this.fitWrapper();
// A right float's margin is computed from the wrapper width just set.
this.applyWrap();
});
this.observer.observe(this.rotor);
observeFit(this.rotor, this);
this.applyAll();
}
@@ -606,7 +625,7 @@ class TextBoxView {
// a polygon's `textArea` — so text stays inside the outline. The vertical inset feeds
// back into the auto-grown height, so rewrite it only past a threshold; the
// ResizeObserver then settles (every ratio below 0.5 converges).
private applyShapeInset(): void {
private applyShapeInset(size?: Size): void {
const kind = this.attrs().shapeKind;
const area = SHAPES[kind]?.textArea;
if (kind !== 'ellipse' && !area) {
@@ -614,8 +633,7 @@ class TextBoxView {
this.lastInset = '';
return;
}
const w = this.rotor.offsetWidth;
const h = this.rotor.offsetHeight;
const { w, h } = size ?? rotorSize(this.rotor);
if (!w || !h) return;
const [l, t, r, b] = area
? [area[0] / 100, area[1] / 100, 1 - area[2] / 100, 1 - area[3] / 100]
@@ -644,9 +662,8 @@ class TextBoxView {
// Size the wrapper to the rotor's rotated bounding box so surrounding text
// reserves the right space (same math as ImageView.applyLayout).
private fitWrapper(): void {
const w = this.rotor.offsetWidth;
const h = this.rotor.offsetHeight;
private fitWrapper(size?: Size): void {
const { w, h } = size ?? rotorSize(this.rotor);
if (!w || !h) return;
const rad = (this.attrs().rotation * Math.PI) / 180;
const bw = Math.abs(w * Math.cos(rad)) + Math.abs(h * Math.sin(rad));
@@ -883,9 +900,12 @@ class TextBoxView {
update(node: PMNode): boolean {
if (node.type !== this.node.type) return false;
// Everything applyAll writes comes from the attrs; text typed inside the box carries
// the same attrs object, and the observer refits what that grows.
const drawn = node.attrs === this.node.attrs;
this.node = node;
// Never touch contentDOM children — ProseMirror owns them.
if (!this.resizing) this.applyAll();
if (!this.resizing && !drawn) this.applyAll();
return true;
}
@@ -918,9 +938,17 @@ class TextBoxView {
return this.isFrameHit(event as MouseEvent) || !this.editing(this.editor.state);
}
// What the shared observer reports, so nothing here reads the rotor back: one
// frame's write between two reads is a forced layout per frame in the document.
refit(size: Size): void {
this.applyShapeInset(size);
this.fitWrapper(size);
// A right float's margin is computed from the wrapper width just set.
this.applyWrap();
}
destroy(): void {
this.observer?.disconnect();
this.observer = null;
unobserveFit(this.rotor);
}
}
+14 -2
View File
@@ -223,8 +223,8 @@ try {
check(longPages > 100 && keyMedian < 100 && keyMax < 250,
`typing at the top of a ${longPages}-page document: ${keyMedian} ms per keystroke (max ${keyMax}), ${Math.round(pass)} ms pass after a split`);
// A letter typed and taken back leaves every block as tall as it was, so no pass runs
// (every pass ends in a pm-pagecount event), and the pause costs no more than a key: the
// spell checker re-reads the edited paragraph, not the document.
// (a pass that moves something ends in a pm-pagecount event), and the pause costs no more
// than a key: the spell checker re-reads the edited paragraph, not the document.
await page.evaluate(() => {
window.__passes = 0;
document.querySelector('.tiptap').addEventListener('pm-pagecount', () => { window.__passes++; });
@@ -238,6 +238,18 @@ try {
check(passes === 0 && idle < 100,
`a letter typed and taken back runs no pass and its pause is free (${passes} passes, ${Math.round(idle)} ms blocked at most)`);
// A pass that lands the layout the last one did announces nothing: every reader of the
// event re-reads the whole document from it — the index its page numbers, the header
// band its geometry — and a redundant round would set them all going again.
await page.evaluate(() => {
window.__passes = 0;
const editor = document.querySelector('.tiptap').editor;
editor.view.dispatch(editor.state.tr.setMeta('addToHistory', false).setMeta('forcePageBreakRecalc', true));
});
for (const until = Date.now() + 1500; Date.now() < until;) await blocked();
const quiet = await page.evaluate(() => window.__passes);
check(quiet === 0, `a recalc that finds the same layout announces no page count (${quiet} events)`);
// The block after a band-wrapped frame clears it; one after a page-anchored frame does
// not. Keyed on an attribute image.ts writes (a `:has()` restyles the whole document).
const PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNwaDgAAAKEAYEml6crAAAAAElFTkSuQmCC';