diff --git a/docs/architecture/pagination.md b/docs/architecture/pagination.md index 1497db1..d51db7a 100644 --- a/docs/architecture/pagination.md +++ b/docs/architecture/pagination.md @@ -114,10 +114,15 @@ back from the other. 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. +124-page document: 1.5 s a keystroke, 144 ms with the mapping. An edit's pass waits for +`EDIT_IDLE_MS` of quiet, so while the typing goes on only the keystroke itself costs; a +forced recalc or a layout-only write never waits. When the quiet comes, the pass is skipped +if every edit since the last one only put text into or took text out of one textblock +(`textOnlyBlock`: a paragraph or heading, inside lists at most — a cell, a note or a frame +reaches further than its own height) and the content's bottom is where the last pass left +it: the block is as tall as before, so nothing below it moved. A break inside that block +still forces the pass, since its line has rewrapped. The dom run (`tests/dom/run.mjs`) +holds the keystroke budget and checks that a letter typed and taken back runs no pass. **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`. diff --git a/src/lib/editor/extensions/pageBreaks.ts b/src/lib/editor/extensions/pageBreaks.ts index 81d9cf1..5f45838 100644 --- a/src/lib/editor/extensions/pageBreaks.ts +++ b/src/lib/editor/extensions/pageBreaks.ts @@ -1,5 +1,6 @@ import { Extension } from '@tiptap/core'; -import { Plugin, PluginKey } from '@tiptap/pm/state'; +import { Plugin, PluginKey, type Transaction } from '@tiptap/pm/state'; +import { ReplaceStep } from '@tiptap/pm/transform'; import { Decoration, DecorationSet, type EditorView } from '@tiptap/pm/view'; import { COLUMNS_FIT_MARGIN_PX } from './columns'; import { isLeftPage, printedPageNumber } from '../../storage/pageNumbering'; @@ -290,7 +291,35 @@ function pageContentStart(page: number, marginTop: number, grid: PageGrid): numb // `recalc` counts forced recomputes, `edit` the document edits. Layout-only writes // (addToHistory:false) bump neither, so the convergence brake in `update` can tell an // answer to the last pass from a fresh change. -type PageBreakState = { recalc: number; edit: number }; +// textBlock: the textblock a text-only transaction stayed inside (its position), else null. +type PageBreakState = { recalc: number; edit: number; textBlock: number | null }; + +// Ancestors a leaf may have without the pass seeing more than the leaf: the walk +// descends these (CONTAINER_TAGS), and their height is the sum of their blocks'. +const FLOW_CONTAINERS = new Set(['bulletList', 'orderedList', 'listItem', 'blockquote']); + +// The textblock a transaction only put text into or took text out of — the one block +// whose height an edit like that can change. Null for anything else: a node, a split, a +// join, an edit in a cell, a note or a frame, whose height reaches further than itself. +function textOnlyBlock(tr: Transaction): number | null { + let block: number | null = null; + for (let i = 0; i < tr.steps.length; i++) { + const step = tr.steps[i]; + if (!(step instanceof ReplaceStep) || step.slice.openStart || step.slice.openEnd) return null; + let textOnly = true; + step.slice.content.forEach((n) => { if (!n.isText) textOnly = false; }); + if (!textOnly) return null; + const $from = tr.docs[i].resolve(step.from); + if (!$from.parent.isTextblock || step.to > $from.end()) return null; + for (let d = 1; d < $from.depth; d++) { + if (!FLOW_CONTAINERS.has($from.node(d).type.name)) return null; + } + const b = $from.before(); + if (block !== null && block !== b) return null; + block = b; + } + return block; +} // Exported for tabStops.ts: a placement change moves lines, so the tab advances // measured before it are stale. @@ -387,11 +416,15 @@ 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; + // A long document paginates slower than keys come, so an edit's pass waits for this + // much quiet: while the typing goes on, only the keystroke itself costs. + const EDIT_IDLE_MS = 300; let idleTimer: ReturnType | null = null; - let lastPassAt = 0; + // Where the content ended after the last pass: the same bottom after edits confined to + // one textblock means no block moved, and the pass has nothing to find. + let lastBottom = -1; + // The textblock every edit since the last pass stayed inside; false once one didn't. + let editBlock: number | null | false = null; 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 @@ -408,7 +441,7 @@ export const PageBreaks = Extension.create({ // Counters `update` watches. The plugin's own decoration dispatches carry neither // FORCE_PAGE_RECALC nor a doc change, so this never self-triggers. state: { - init: () => ({ recalc: 0, edit: 0 }), + init: () => ({ recalc: 0, edit: 0, textBlock: null }), 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. @@ -416,7 +449,11 @@ export const PageBreaks = Extension.create({ const recalc = value.recalc + (tr.getMeta(FORCE_PAGE_RECALC) ? 1 : 0); const edit = value.edit + (tr.docChanged && tr.getMeta('addToHistory') !== false ? 1 : 0); - return recalc === value.recalc && edit === value.edit ? value : { recalc, edit }; + if (recalc === value.recalc && edit === value.edit) return value; + const own = tr.docChanged ? textOnlyBlock(tr) : null; + // An appended transaction rides its root's dispatch: both must name the block. + const appended = tr.getMeta('appendedTransaction') !== undefined; + return { recalc, edit, textBlock: appended && value.textBlock !== own ? null : own }; }, }, props: { @@ -1135,6 +1172,7 @@ export const PageBreaks = Extension.create({ rafId = null; if (isUpdating || !editorView.dom.isConnected) return; isUpdating = true; + editBlock = null; const dom = editorView.dom; void dom.offsetHeight; // force reflow @@ -1872,7 +1910,7 @@ export const PageBreaks = Extension.create({ }; isUpdating = false; - lastPassAt = performance.now(); + lastBottom = contentBottom(); // 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 @@ -1896,10 +1934,29 @@ export const PageBreaks = Extension.create({ if (rafId !== null) cancelAnimationFrame(rafId); rafId = requestAnimationFrame(calculate); } + // The bottom of the last block, not .tiptap's height: its min-height would hide + // a block growing on a short document's only page. + function contentBottom(): number { + const last = editorView.dom.lastElementChild as HTMLElement | null; + return last ? last.offsetTop + last.offsetHeight : 0; + } function scheduleEdit() { - if (performance.now() - lastPassAt > EDIT_IDLE_MS) { schedule(); return; } if (idleTimer !== null) clearTimeout(idleTimer); - idleTimer = setTimeout(schedule, EDIT_IDLE_MS); + idleTimer = setTimeout(() => { + idleTimer = null; + const b = editBlock; + editBlock = null; + if (b === null) return; // a pass has run since the edit and measured it + // Text typed into one block that is as tall as before moved nothing below it — + // unless a page break falls inside the block, on a line the text has rewrapped. + if (b !== false && contentBottom() === lastBottom) { + const node = editorView.state.doc.nodeAt(b); + // Widgets only: a node decoration on the block is the page-top rule, not a break. + const inside = (spec: { key?: string }) => spec.key !== undefined; + if (node && !decorations.find(b + 1, b + node.nodeSize - 1, inside).length) return; + } + schedule(); + }, EDIT_IDLE_MS); } // Initial calculation @@ -1917,7 +1974,13 @@ 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; - if (isEditTr && !forced) scheduleEdit(); else schedule(); + if (isEditTr && !forced) { + const b = after?.textBlock ?? null; + editBlock = b === null || (editBlock !== null && editBlock !== b) ? false : b; + scheduleEdit(); + } else { + schedule(); + } } }, destroy() { diff --git a/tests/dom/run.mjs b/tests/dom/run.mjs index e3f840a..1fd66fc 100644 --- a/tests/dom/run.mjs +++ b/tests/dom/run.mjs @@ -216,10 +216,24 @@ try { 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 feels fluid under ~100 ms a key; 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 < 100 && keyMax < 250, `typing at the top of a ${longPages}-page document: ${keyMedian} ms per keystroke (max ${keyMax}), ${Math.round(pass)} ms pass after the burst`); + // 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, so the count is the proof; the blocked time + // in the label is the spell checker's whole-document recheck, not a pass. + await page.evaluate(() => { + window.__passes = 0; + document.querySelector('.tiptap').addEventListener('pm-pagecount', () => { window.__passes++; }); + }); + await page.keyboard.type('x'); + await blocked(); + await page.keyboard.press('Backspace'); + let idle = 0; + for (const until = Date.now() + 1500; Date.now() < until;) idle = Math.max(idle, await blocked()); + const passes = await page.evaluate(() => window.__passes); + check(passes === 0, `a letter typed and taken back runs no pass (${passes} passes, ${Math.round(idle)} ms blocked at most)`); } catch (err) { check(false, `dom run threw: ${err.message ?? err}`); } finally {