diff --git a/docs/architecture/pagination.md b/docs/architecture/pagination.md index 5bea1c6..1497db1 100644 --- a/docs/architecture/pagination.md +++ b/docs/architecture/pagination.md @@ -110,6 +110,15 @@ width. Every **widget** decoration therefore builds its DOM in a `toDOM` functio passed directly can only live in one of the two documents, and each view keeps taking it back from the other. +**A pass keeps its spacers, and typing gets a pause.** The decoration set is mapped through +every transaction (`apply`) and each spacer widget carries a `key` naming what it draws, so +a pass that lands the same spacers keeps their DOM. Unmapped, an edit above them left every +widget one position off and the view rebuilt all of them — measured at the top of a +124-page document: 1.5 s a keystroke, 144 ms with the mapping. An edit within +`EDIT_IDLE_MS` of the last pass waits for a pause in the typing (the first after a pause +runs at once); a forced recalc or a layout-only write never waits. The dom run +(`tests/dom/run.mjs`) holds the budget. + **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/pageBreaks.ts b/src/lib/editor/extensions/pageBreaks.ts index bede610..81d9cf1 100644 --- a/src/lib/editor/extensions/pageBreaks.ts +++ b/src/lib/editor/extensions/pageBreaks.ts @@ -315,6 +315,13 @@ export function isSplitPane(view: EditorView): boolean { // foot of the gap, which is the top of the continuation. type RowSpacer = { columns: number; close: string; open: string; header: { html: string; height: number } | null }; +// What a spacer's DOM shows, as the widget key: without one a fresh toDOM is a fresh +// widget, and an edit near the top moves — and so rebuilds — every spacer below it. +function spacerKey(p: { height: number; row?: RowSpacer | null }): string { + if (!p.row) return `s${p.height}`; + return `r${p.height}|${p.row.columns}|${p.row.close}|${p.row.open}|${p.row.header?.html ?? ''}`; +} + type Leaf = { el: HTMLElement; kind: 'atomic' | 'splittable'; @@ -380,6 +387,11 @@ export const PageBreaks = Extension.create({ let decorations = DecorationSet.empty; let isUpdating = false; let rafId: number | null = null; + // Keys can come faster than a long document paginates: an edit within EDIT_IDLE_MS of + // the last pass waits for a pause in the typing; the first after a pause runs at once. + const EDIT_IDLE_MS = 150; + let idleTimer: ReturnType | null = null; + let lastPassAt = 0; let lastPlacementsKey = ''; // The layout before the current one: two layouts can each imply the other (a block // below a float's overhang moves further than the model's spacer), so seeing the older @@ -398,6 +410,9 @@ export const PageBreaks = Extension.create({ state: { init: () => ({ recalc: 0, edit: 0 }), apply(tr, value) { + // The spacers follow the text until the next pass: left unmapped, an edit above + // them puts every widget one position off and the view tears them all down. + if (tr.docChanged) decorations = decorations.map(tr.mapping, tr.doc); const recalc = value.recalc + (tr.getMeta(FORCE_PAGE_RECALC) ? 1 : 0); const edit = value.edit + (tr.docChanged && tr.getMeta('addToHistory') !== false ? 1 : 0); @@ -1780,7 +1795,7 @@ export const PageBreaks = Extension.create({ spacerEl.style.userSelect = 'none'; spacerEl.setAttribute('contenteditable', 'false'); return spacerEl; - }, { side: -1 })); + }, { side: -1, key: spacerKey(p) })); if (collapsedTrailing) { decoArray.push(Decoration.node(collapsedTrailing.from, collapsedTrailing.to, { style: 'height:0;min-height:0;margin:0;overflow:hidden', @@ -1857,6 +1872,7 @@ export const PageBreaks = Extension.create({ }; isUpdating = false; + lastPassAt = performance.now(); // A per-page restart counts within the page each anchor landed on, which only // this pass knows (notes.ts). Renumbering can rewrap, so it takes a pass of its @@ -1876,9 +1892,15 @@ export const PageBreaks = Extension.create({ } function schedule() { + if (idleTimer !== null) { clearTimeout(idleTimer); idleTimer = null; } if (rafId !== null) cancelAnimationFrame(rafId); rafId = requestAnimationFrame(calculate); } + function scheduleEdit() { + if (performance.now() - lastPassAt > EDIT_IDLE_MS) { schedule(); return; } + if (idleTimer !== null) clearTimeout(idleTimer); + idleTimer = setTimeout(schedule, EDIT_IDLE_MS); + } // Initial calculation schedule(); @@ -1895,10 +1917,11 @@ export const PageBreaks = Extension.create({ // pm-pagecount — answer the last pass, so forgetting the ping-pong // memory on them would leave the guard with nothing to catch. if (forced || isEditTr) prevPlacementsKey = null; - schedule(); + if (isEditTr && !forced) scheduleEdit(); else schedule(); } }, destroy() { + if (idleTimer !== null) clearTimeout(idleTimer); if (rafId !== null) cancelAnimationFrame(rafId); debugAccessors.delete(editorView); }, diff --git a/tests/dom/run.mjs b/tests/dom/run.mjs index d517d83..e3f840a 100644 --- a/tests/dom/run.mjs +++ b/tests/dom/run.mjs @@ -4,7 +4,7 @@ // uncaught page error. import { join } from 'node:path'; import { readFile } from 'node:fs/promises'; -import { ROOT, MOD, checker, previewServer, openApp } from '../browser.mjs'; +import { ROOT, MOD, checker, previewServer, openApp, settle } from '../browser.mjs'; const PORT = +(process.env.DOM_PORT ?? 4185); const { check, failures } = checker(); @@ -181,6 +181,45 @@ try { })); check(hf.first === 'true' && !hf.running && hf.firstPage.includes('Titelseite') && /"header":1.8/.test(hf.dist), `the ribbon's header/footer switches reach the document (first page: ${hf.first}, running zone: ${hf.running}, distances: ${hf.dist})`); + + // Typing latency in a long document. A keystroke at the top moves every page break + // below it, so this is where a whole-document pass costs the most: blocked main-thread + // time per keystroke in a burst, then the pass that follows the burst. + const LOREM = 'Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua'.split(' '); + const long = { type: 'doc', content: [] }; + for (let i = 0; i < 1600; i++) { + let s = ''; + for (let k = i; s.length < 300; k++) s += LOREM[k % LOREM.length] + ' '; + long.content.push({ type: 'paragraph', content: [{ type: 'text', text: s.trim() }] }); + } + await page.evaluate((d) => localStorage.setItem('edentext-doc', JSON.stringify(d)), long); + await page.reload({ waitUntil: 'load' }); + await page.waitForSelector('.tiptap', { timeout: 15_000 }); + await settle(page, true); + const longPages = await pageCount(); + // How long the page holds the thread: a zero timeout resolves once it is free again. + const blocked = async () => { + const t = performance.now(); + await page.evaluate(() => new Promise((r) => setTimeout(r, 0))); + return performance.now() - t; + }; + await caretTo(1); + const keys = []; + for (const ch of 'The quick brown fox jumps') { + const t = performance.now(); + await page.keyboard.type(ch); + await blocked(); + keys.push(performance.now() - t); + } + let pass = 0; + for (const until = Date.now() + 4000; Date.now() < until;) pass = Math.max(pass, await blocked()); + keys.sort((a, b) => a - b); + const keyMedian = Math.round(keys[keys.length >> 1]); + const keyMax = Math.round(keys[keys.length - 1]); + // The keystroke is the budget (a runner is slower than a laptop, so with room); the pass + // is what a whole-document layout costs, recorded for the day it goes incremental. + check(longPages > 100 && keyMedian < 300, + `typing at the top of a ${longPages}-page document: ${keyMedian} ms per keystroke (max ${keyMax}), ${Math.round(pass)} ms pass after the burst`); } catch (err) { check(false, `dom run threw: ${err.message ?? err}`); } finally {