perf: the whole-document spell check waits for the pagination to stop

The check was queued in idle time, which on a long document falls between two
pagination passes: its decoration swap redraws the view, and the pass behind it
pays for the redraw. It now waits until no pass is running or queued
(`isPaginating`), so an opened file paginates first and is checked after.

Measured on a 460-page file: settled 5.99 s → 5.10 s (interleaved A/B, median
of seven; a second set of nine gave 5.76 → 5.34). Spacer, index and text-box
geometry byte-identical on five files.
This commit is contained in:
Steffen Becker committed 2026-09-09 20:53:20 +02:00
1 parent e399a3a62c
commit 0dd66c977f
4 files changed
+22 -5

No files matched your search

+1 -1
View File
@@ -38,7 +38,7 @@ Five topics are large enough to have their own deep-dive — read the file befor
- **`ruby.ts`** — a reading printed over its base (LibreOffice's Format ▸ Asian Phonetic Guide, Word's Phonetic Guide). An inline **atom** carrying both halves, because that is what both formats write: one element holding a base and a reading, edited as a unit (`RubyDialog.svelte`). Rendered as a real `<ruby>`, so the browser sets it. Alignment and position are both products' defaults and are not offered.
- **`bookmark.ts`** / **`crossReference.ts`** — a named range of text (a mark) and the live references to it (`REF`/`PAGEREF`). `ToolbarExpanded.svelte` owns both dialogs, as it does the link one.
- **`searchReplace.ts`** — Find & Replace: decorations highlight all matches, commands navigate/replace. Touches the document only for actual replacements. `useRegex` takes the term as a pattern instead of escaping it (a whole-word search then wraps it in a non-capturing group first, so the `\b` guards bound the alternation); each match keeps its captures so `expandGroups` can fill `$1`…`$9`/`$&` in the replacement. A `FormatSpec` (weight/slant/underline, font, size and colour — LibreOffice's Format… — plus a paragraph style, its Paragraph Styles box) narrows a text search to the runs carrying it, and **with no term is the search**: every such run, or every paragraph in the style, empty ones included. The same spec on the replacement applies it — and a replacement with **no text** keeps the text and only reformats, which is how both dialogs restyle a document without retyping it. A style also switches the node type, as `setParagraphStyle` does, so the extension takes the same `sheet` option. Font, size and colour live together on one **TextStyle** mark, and a mark of one type replaces that type whole, so each run's own attrs are the base of the new one — replacing the colour leaves the font alone. Sizes compare by value (12pt and 12.0pt are one size), colours and family names case-insensitively.
- **`spellCheck.ts`** — decorations from `spell/controller.ts`; `spellErrorAt` feeds the context menu, `wordRangeAt` the thesaurus (the word at a position, misspelled or not). Both define a word the same way, so what is looked up is what is highlighted. The **whole document** is checked in idle time (`requestIdleCallback`, 2 s timeout) — at mount, and on every language or dictionary change: it costs half a second on a 460-page file, which an opened document would pay before it is on screen. The check after a pause looks only at the textblocks edited since the last one (the dirty ranges ride the plugin state, mapped through every transaction) and swaps their squiggles in the set; the whole document is checked on a language or dictionary change. Building the whole set walks every block for every squiggle, seconds on a long document.
- **`spellCheck.ts`** — decorations from `spell/controller.ts`; `spellErrorAt` feeds the context menu, `wordRangeAt` the thesaurus (the word at a position, misspelled or not). Both define a word the same way, so what is looked up is what is highlighted. The **whole document** is checked in idle time (`requestIdleCallback`, 2 s timeout) and only once `isPaginating` (`pageBreaks.ts`) is false — at mount, and on every language or dictionary change: it costs half a second on a 460-page file, and landing next to a pagination pass pays for that pass's redraw of the view too (~0.6 s of an open, since the passes queue behind it). The check after a pause looks only at the textblocks edited since the last one (the dirty ranges ride the plugin state, mapped through every transaction) and swaps their squiggles in the set; the whole document is checked on a language or dictionary change. Building the whole set walks every block for every squiggle, seconds on a long document.
- **`outline.ts`** — the document's chapters for `NavigatorPane.svelte`: every **top-level** heading owning the blocks up to the next heading of its level or above (one inside a cell or a text box is not a chapter — moving it would move it out of its container). The two operations are pure functions the commands only apply: `chapterMove` returns the range to lift and where to put it back — the insert position for a move down is already corrected for the delete — and `chapterLevels` the headings a promote/demote rewrites, null when any of the subtree would leave 1…`maxLevel`. A move swaps with the adjacent chapter at the **same** level and stops at a heading above it, so a subheading never reparents itself; the level shift rewrites `styleName` too, or the paragraph would keep the old heading's look.
- **`indexEntry.ts`** — one place marked for the alphabetical index: an inline **atom**, not a mark, because that is what both formats write (a point `text:alphabetical-index-mark`, a Word `XE` field). It carries the term, so the marked text and the word the index lists can differ, plus `key1` for the heading it files under. `indexRows` merges the marks into the index's rows — one per term, its pages de-duplicated and sorted, the terms sorted case-insensitively by locale.
- **`bibliographyEntry.ts`** — one citation, an inline **atom** carrying the whole source record (`identifier`, `type`, and every remaining ODF field in one `fields` record, so a file's fields we have no UI for still round-trip). ODF puts that record on the mark itself, which is what lets the document travel without a database; Word keeps it in a custom-XML part and cites it by tag, so the same attrs feed both. The **citation style** (`utils/citationStyle.ts` — LibreOffice's cite-by-key, numbered, APA, MLA, Chicago) rides the bibliography index's node, which is where both formats keep it: a row is a token list, exactly what an ODF entry template is, so LibreOffice regenerates the same rows (it fills the types the document does not cite with its own default, so the import takes the first template reading as anything but `key`). Word gets the style as `b:Sources StyleName`. Each citation's label is derived, kept true by an `appendTransaction` off the undo stack like the caption numbers. `bibliographyRows` is the list: one row per source, **in document order** — LibreOffice's own default is document position, not alphabetical — reading "key: author, title, year", the shape its entry templates produce. `DOCX_SOURCE_TYPE`/`DOCX_BIB_FIELD` map onto Word's smaller set, and the fields it has no slot for survive only the ODF leg.
+9
View File
@@ -111,6 +111,13 @@ export type TableBreakBand = {
};
const debugAccessors = new WeakMap<EditorView, () => PageBreakDebugSnapshot | null>();
const paginating = new WeakMap<EditorView, () => boolean>();
// True while a pass is running or one is queued: work that redraws the view waits for
// a false, or it pays for the redraw of the pass behind it.
export function isPaginating(view: EditorView): boolean {
return paginating.get(view)?.() ?? false;
}
export function getPageBreakDebug(view: EditorView): PageBreakDebugSnapshot | null {
return debugAccessors.get(view)?.() ?? null;
@@ -504,6 +511,7 @@ export const PageBreaks = Extension.create({
if (isSplitPane(editorView)) return {};
let lastSnapshot: PageBreakDebugSnapshot | null = null;
paginating.set(editorView, () => isUpdating || rafId !== null || idleTimer !== null);
debugAccessors.set(editorView, (): PageBreakDebugSnapshot | null => {
const snap: PageBreakDebugSnapshot | null = lastSnapshot;
if (snap === null) return null;
@@ -2047,6 +2055,7 @@ export const PageBreaks = Extension.create({
if (idleTimer !== null) clearTimeout(idleTimer);
if (rafId !== null) cancelAnimationFrame(rafId);
debugAccessors.delete(editorView);
paginating.delete(editorView);
},
};
},
+6 -1
View File
@@ -4,6 +4,7 @@ import { Decoration, DecorationSet } from '@tiptap/pm/view';
import type { EditorState, Transaction } from '@tiptap/pm/state';
import type { Node as PmNode } from '@tiptap/pm/model';
import { spellController } from '../../spell/controller';
import { isPaginating } from './pageBreaks';
type Range = { from: number; to: number };
// dirty: what the edits since the last check touched, in the current document's positions.
@@ -156,13 +157,17 @@ export const SpellCheck = Extension.create({
const whenIdle = typeof requestIdleCallback === 'function'
? (cb: () => void) => requestIdleCallback(cb, { timeout: 2000 })
: (cb: () => void) => setTimeout(cb, 0);
// A pagination pass redraws the view, so a check landing next to one pays for
// that redraw too: it waits for the passes to stop instead.
let allQueued = false;
const recheckAll = () => {
if (allQueued) return;
allQueued = true;
whenIdle(() => {
allQueued = false;
if (!editorView.isDestroyed) recheck('all');
if (editorView.isDestroyed) return;
if (isPaginating(editorView)) recheckAll();
else recheck('all');
});
};
+6 -3
View File
@@ -22,7 +22,6 @@ const { spellErrorAt } = await import('../../src/lib/editor/extensions/spellChec
const para = (text: string) => ({ type: 'paragraph', content: [{ type: 'text', text }] });
async function makeEditor() {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
const el = document.createElement('div');
document.body.appendChild(el);
const editor = new Editor({
@@ -30,8 +29,12 @@ async function makeEditor() {
extensions,
content: { type: 'doc', content: [para('alpha xxone beta'), para('gamma xxtwo delta')] },
});
await Promise.resolve();
vi.advanceTimersByTime(1); // the mount's whole-document check, which waits for idle time
// The mount's whole-document check waits for idle time and for the pagination to be
// over, so it runs on real timers before the test takes them over.
for (let i = 0; i < 50 && !spellErrorAt(editor.state, XXONE); i++) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
checks.length = 0;
return editor;
}