perf: a keystroke keeps its spacers, and typing waits for a pause
The page-break decorations are mapped through every transaction and each spacer widget carries a key, so a pass that lands the same spacers keeps their DOM instead of rebuilding all of them. An edit within 150 ms of the last pass waits for a pause in the typing. Measured at the top of a 124-page document: 1.5 s a keystroke before, 40 ms after. The dom run holds the budget.
This commit is contained in:
1 parent
b2dcfa3aaf
commit
50aeb4a570
3 files changed
+74
-3
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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<typeof setTimeout> | 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);
|
||||
},
|
||||
|
||||
+40
-1
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user