perf: the caret's page is read after the frame, not before it

coordsAtPos called straight from the selection handler lays the whole document out
before anything is painted — half a second on a 460-page file, and the writes of the
load that follow it throw the result away. The read is queued as its own task and
coalesced, so a run of selection changes costs one.
This commit is contained in:
Steffen Becker committed 2026-09-09 01:12:49 +02:00
1 parent 7e687b6ccb
commit 8cc6c6e20d
2 files changed
+27 -13

No files matched your search

+2
View File
@@ -35,6 +35,8 @@ App.svelte – owns app-level state (theme, zoom, margins, or
└─ footer statusbar – "Page X of Y" + zoom controls (20–300%)
```
`currentPage` is read one task after the selection changes (`scheduleCaretPage`): `coordsAtPos` straight from the handler lays the whole document out before anything is painted — half a second on a 460-page file, thrown away by the writes of the load that follow it.
`Editor.svelte` exposes `editor`, `tick`, `currentPage`, and `numPages` as bindable props to `App.svelte`, and takes `zoom`, `showFormattingMarks`, `pageMargins`, and `orientation` as inputs. `tick` is incremented on every TipTap transaction; toolbar components use `$derived(tick >= 0 && ...)` to re-evaluate `isActive`/value checks reactively without subscribing to ProseMirror directly.
## The settle gate (`Editor.svelte`)
+25 -13
View File
@@ -1287,19 +1287,10 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
// adding/removing rows or columns).
scheduleTableUi();
},
onSelectionUpdate: ({ editor: e }) => {
// Use the editor instance passed by TipTap directly — avoids any Svelte
// prop-reactivity timing issues. TipTap always auto-scrolls the cursor
// into view before firing this, so no visibility check is needed here.
const view = paneView() ?? e.view;
const tiptap = view.dom as HTMLElement | null;
if (!tiptap) return;
try {
const coords = view.coordsAtPos(e.state.selection.head);
const cursorInDoc = ((coords.top + coords.bottom) / 2 - tiptap.getBoundingClientRect().top) / (appliedZoom / 100);
currentPage = Math.max(1, Math.min(numPages, Math.floor(Math.max(0, cursorInDoc) / getCycle()) + 1));
followCaret(currentPage);
} catch { /* ignore */ }
onSelectionUpdate: () => {
// TipTap auto-scrolls the cursor into view before firing this, so the page it
// lands on needs no visibility check of its own.
scheduleCaretPage();
},
onUpdate: ({ editor: e, transaction }) => {
saveDocument(() => e.getJSON());
@@ -1391,6 +1382,27 @@ import { EMPTY_PAGE_DECOR, type PageDecor } from '../storage/pageDecor';
el.scrollTop = gridOrigin(el) + row * firstCycle * (appliedZoom / 100);
}
// The page the caret sits on, read one task later: called straight from the selection
// it would lay the whole document out before anything is painted — half a second on a
// 460-page file, thrown away by the writes of the load that follow it.
let caretPageTask: ReturnType<typeof setTimeout> | null = null;
function scheduleCaretPage(): void {
if (caretPageTask !== null) return;
caretPageTask = setTimeout(() => {
caretPageTask = null;
if (!editor || editor.isDestroyed) return;
const view = paneView() ?? editor.view;
const tiptap = view.dom as HTMLElement | null;
if (!tiptap) return;
try {
const coords = view.coordsAtPos(view.state.selection.head);
const cursorInDoc = ((coords.top + coords.bottom) / 2 - tiptap.getBoundingClientRect().top) / (appliedZoom / 100);
currentPage = Math.max(1, Math.min(numPages, Math.floor(Math.max(0, cursorInDoc) / getCycle()) + 1));
followCaret(currentPage);
} catch { /* ignore */ }
}, 0);
}
// The caret is drawn by whichever view holds the focus, so it has to be the one
// whose cell shows the caret's page — otherwise it blinks on a clipped page.
function followCaret(page: number) {