From 8cc6c6e20dd89aa5db725f5b0d55f18db7b84d5a Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Wed, 9 Sep 2026 01:12:49 +0200 Subject: [PATCH] perf: the caret's page is read after the frame, not before it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/lib/components/CLAUDE.md | 2 ++ src/lib/components/Editor.svelte | 38 +++++++++++++++++++++----------- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/src/lib/components/CLAUDE.md b/src/lib/components/CLAUDE.md index a4220fb..b79b55e 100644 --- a/src/lib/components/CLAUDE.md +++ b/src/lib/components/CLAUDE.md @@ -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`) diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index 7590a8f..def3fbf 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -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 | 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) {