From 7e09cf3b8b8a4c55db55cba71e9449b198c8ccf0 Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Wed, 9 Sep 2026 01:12:56 +0200 Subject: [PATCH] perf: the whole document is spell-checked in idle time Checking every word of a 460-page file costs half a second, and at mount it was paid before the document was on screen. The pass now waits for idle time (2 s timeout), as does the one a language or dictionary change asks for; the check after an edit is unchanged. The file is named 200 ms earlier and settles 700 ms earlier, with the same page count, spacers, page numbers and references. --- src/lib/editor/extensions/CLAUDE.md | 2 +- src/lib/editor/extensions/spellCheck.ts | 21 ++++++++++++++++++--- tests/unit/spell-recheck.test.ts | 3 ++- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/lib/editor/extensions/CLAUDE.md b/src/lib/editor/extensions/CLAUDE.md index 28a7206..9410fa6 100644 --- a/src/lib/editor/extensions/CLAUDE.md +++ b/src/lib/editor/extensions/CLAUDE.md @@ -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 ``, 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 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) — 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. - **`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. diff --git a/src/lib/editor/extensions/spellCheck.ts b/src/lib/editor/extensions/spellCheck.ts index 1549d19..8e375a8 100644 --- a/src/lib/editor/extensions/spellCheck.ts +++ b/src/lib/editor/extensions/spellCheck.ts @@ -151,10 +151,25 @@ export const SpellCheck = Extension.create({ }, DEBOUNCE_MS); }; - // Language / personal-dictionary / ignore changes re-check immediately. - const unsubscribe = spellController.subscribe(() => recheck('all')); + // The whole document is read in idle time: it costs half a second on a + // 460-page one, and an opened file has it queued behind its own layout. + const whenIdle = typeof requestIdleCallback === 'function' + ? (cb: () => void) => requestIdleCallback(cb, { timeout: 2000 }) + : (cb: () => void) => setTimeout(cb, 0); + let allQueued = false; + const recheckAll = () => { + if (allQueued) return; + allQueued = true; + whenIdle(() => { + allQueued = false; + if (!editorView.isDestroyed) recheck('all'); + }); + }; + + // Language / personal-dictionary / ignore changes re-check the document. + const unsubscribe = spellController.subscribe(recheckAll); // Initial pass in case the checker is already loaded at mount. - queueMicrotask(() => recheck('all')); + recheckAll(); return { update(view, prevState) { diff --git a/tests/unit/spell-recheck.test.ts b/tests/unit/spell-recheck.test.ts index 55761f8..c2ab305 100644 --- a/tests/unit/spell-recheck.test.ts +++ b/tests/unit/spell-recheck.test.ts @@ -30,7 +30,8 @@ async function makeEditor() { extensions, content: { type: 'doc', content: [para('alpha xxone beta'), para('gamma xxtwo delta')] }, }); - await Promise.resolve(); // the mount's whole-document check + await Promise.resolve(); + vi.advanceTimersByTime(1); // the mount's whole-document check, which waits for idle time checks.length = 0; return editor; }