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:
1 parent
6693d04313
commit
50c7ec5236
6 files changed
+97
-23
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) ?? '';
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
Reference in new issue
Block a user