diff --git a/docs/architecture/frames.md b/docs/architecture/frames.md index b56c489..bd2f2a0 100644 --- a/docs/architecture/frames.md +++ b/docs/architecture/frames.md @@ -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 diff --git a/docs/architecture/pagination.md b/docs/architecture/pagination.md index 5e10cc3..34d3862 100644 --- a/docs/architecture/pagination.md +++ b/docs/architecture/pagination.md @@ -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. diff --git a/src/lib/editor/extensions/image.ts b/src/lib/editor/extensions/image.ts index 80f4cd9..85267b2 100644 --- a/src/lib/editor/extensions/image.ts +++ b/src/lib/editor/extensions/image.ts @@ -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) ?? ''; diff --git a/src/lib/editor/extensions/pageBreaks.ts b/src/lib/editor/extensions/pageBreaks.ts index f3bbbf8..98a7f4b 100644 --- a/src/lib/editor/extensions/pageBreaks.ts +++ b/src/lib/editor/extensions/pageBreaks.ts @@ -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(), diff --git a/src/lib/editor/extensions/textBox.ts b/src/lib/editor/extensions/textBox.ts index 83835ce..3138cf4 100644 --- a/src/lib/editor/extensions/textBox.ts +++ b/src/lib/editor/extensions/textBox.ts @@ -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(); +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); } } diff --git a/tests/dom/run.mjs b/tests/dom/run.mjs index 4e950b6..4decb12 100644 --- a/tests/dom/run.mjs +++ b/tests/dom/run.mjs @@ -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';