diff --git a/src/lib/editor/extensions/CLAUDE.md b/src/lib/editor/extensions/CLAUDE.md index ba1d051..c158fe9 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. +- **`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. - **`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 95512db..1549d19 100644 --- a/src/lib/editor/extensions/spellCheck.ts +++ b/src/lib/editor/extensions/spellCheck.ts @@ -5,9 +5,15 @@ import type { EditorState, Transaction } from '@tiptap/pm/state'; import type { Node as PmNode } from '@tiptap/pm/model'; import { spellController } from '../../spell/controller'; -const spellCheckKey = new PluginKey('spellCheck'); +type Range = { from: number; to: number }; +// dirty: what the edits since the last check touched, in the current document's positions. +type SpellState = { set: DecorationSet; dirty: Range[] }; -const FORCE_RECHECK_META = 'spellCheck/forceRecheck'; +const spellCheckKey = new PluginKey('spellCheck'); + +// 'all' checks the whole document (a new language or dictionary), 'dirty' the edited blocks. +const RECHECK_META = 'spellCheck/recheck'; +type RecheckMode = 'all' | 'dirty'; // Re-check after the user pauses, so large docs stay responsive while typing. const DEBOUNCE_MS = 400; @@ -17,27 +23,70 @@ const DEBOUNCE_MS = 400; // exactly the highlighted range. const WORD_RE = /[\p{L}\p{M}]+(?:['’\-][\p{L}\p{M}]+)*/gu; -function buildDecorations(doc: PmNode): DecorationSet { - if (!spellController.isEnabled()) return DecorationSet.empty; - const decos: Decoration[] = []; - doc.descendants((node, pos) => { - if (!node.isText) return; - const text = node.text ?? ''; +// The misspelled words under `node`, whose content starts at `base` in the document. +function wordDecos(node: PmNode, base: number, decos: Decoration[]): void { + node.descendants((child, pos) => { + if (!child.isText) return; + const text = child.text ?? ''; WORD_RE.lastIndex = 0; let m: RegExpExecArray | null; while ((m = WORD_RE.exec(text)) !== null) { const word = m[0]; if (word.length < 2 || spellController.check(word)) continue; - const from = pos + m.index; + const from = base + pos + m.index; decos.push(Decoration.inline(from, from + word.length, { class: 'pm-spell-error' })); } }); +} + +function buildDecorations(doc: PmNode): DecorationSet { + if (!spellController.isEnabled()) return DecorationSet.empty; + const decos: Decoration[] = []; + wordDecos(doc, 0, decos); return decos.length ? DecorationSet.create(doc, decos) : DecorationSet.empty; } +// Only the textblocks the dirty ranges touch are checked again: a word never crosses a +// block, and building the whole set walks every block for every squiggle (seconds on a +// long document), while add/remove of one block's few walks the blocks once. +function recheckBlocks(doc: PmNode, set: DecorationSet, dirty: Range[]): DecorationSet { + if (!spellController.isEnabled()) return DecorationSet.empty; + const blocks = new Map(); + for (const { from, to } of dirty) { + if (from === to) { + // A deletion leaves a point; nodesBetween finds nothing at a point. + const $pos = doc.resolve(from); + if ($pos.parent.isTextblock) blocks.set($pos.before(), $pos.parent); + continue; + } + doc.nodesBetween(from, to, (node, pos) => { + if (!node.isTextblock) return true; + blocks.set(pos, node); + return false; + }); + } + const stale: Decoration[] = []; + const decos: Decoration[] = []; + for (const [pos, node] of blocks) { + stale.push(...set.find(pos + 1, pos + node.nodeSize - 1)); + wordDecos(node, pos + 1, decos); + } + return set.remove(stale).add(doc, decos); +} + +// What a transaction touched, in its final document's positions. +function changedRanges(tr: Transaction): Range[] { + const out: Range[] = []; + tr.mapping.maps.forEach((map, i) => { + const rest = tr.mapping.slice(i + 1); + map.forEach((_oldFrom, _oldTo, from, to) => out.push({ from: rest.map(from, -1), to: rest.map(to, 1) })); + }); + return out; +} + // The misspelled-word range covering `pos`, if any — used by the context menu. export function spellErrorAt(state: EditorState, pos: number): { from: number; to: number } | null { - const set = spellCheckKey.getState(state); + const set = spellCheckKey.getState(state)?.set; if (!set) return null; const found = set.find(pos, pos); return found.length ? { from: found[0].from, to: found[0].to } : null; @@ -65,39 +114,47 @@ export const SpellCheck = Extension.create({ addProseMirrorPlugins() { return [ - new Plugin({ + new Plugin({ key: spellCheckKey, state: { - init: () => DecorationSet.empty, - apply(tr: Transaction, old: DecorationSet, _oldState: EditorState, newState: EditorState) { - // Full recompute on demand; otherwise keep squiggles glued to text by - // mapping them through the change (a debounced recheck follows). - if (tr.getMeta(FORCE_RECHECK_META) === true) return buildDecorations(newState.doc); - return tr.docChanged ? old.map(tr.mapping, tr.doc) : old; + init: () => ({ set: DecorationSet.empty, dirty: [] }), + apply(tr: Transaction, old: SpellState, _oldState: EditorState, newState: EditorState) { + const mode = tr.getMeta(RECHECK_META) as RecheckMode | undefined; + if (mode === 'all') return { set: buildDecorations(newState.doc), dirty: [] }; + let { set, dirty } = old; + if (tr.docChanged) { + // Squiggles and dirty ranges stay glued to the text until the next check. + set = set.map(tr.mapping, tr.doc); + dirty = dirty + .map((r) => ({ from: tr.mapping.map(r.from, -1), to: tr.mapping.map(r.to, 1) })) + .concat(changedRanges(tr)); + } + if (mode === 'dirty' && dirty.length) return { set: recheckBlocks(newState.doc, set, dirty), dirty: [] }; + return set === old.set && dirty === old.dirty ? old : { set, dirty }; }, }, props: { decorations(state) { - return spellCheckKey.getState(state); + return spellCheckKey.getState(state)?.set; }, }, view(editorView) { let timer: ReturnType | undefined; - const recheck = () => { - editorView.dispatch(editorView.state.tr.setMeta(FORCE_RECHECK_META, true)); + const recheck = (mode: RecheckMode) => { + editorView.dispatch(editorView.state.tr.setMeta(RECHECK_META, mode)); }; const scheduleRecheck = () => { if (timer !== undefined) clearTimeout(timer); timer = setTimeout(() => { timer = undefined; - recheck(); + recheck('dirty'); }, DEBOUNCE_MS); }; // Language / personal-dictionary / ignore changes re-check immediately. - const unsubscribe = spellController.subscribe(recheck); + const unsubscribe = spellController.subscribe(() => recheck('all')); // Initial pass in case the checker is already loaded at mount. - queueMicrotask(recheck); + queueMicrotask(() => recheck('all')); return { update(view, prevState) { diff --git a/tests/dom/run.mjs b/tests/dom/run.mjs index 1fd66fc..007cadf 100644 --- a/tests/dom/run.mjs +++ b/tests/dom/run.mjs @@ -211,18 +211,20 @@ try { await blocked(); keys.push(performance.now() - t); } + // A split always moves the blocks below, so the pause after it holds a whole-document + // pass: what a layout costs, recorded for the day it goes incremental. + await page.keyboard.press('Enter'); 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]); // Typing feels fluid under ~100 ms a key; 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 < 100 && keyMax < 250, - `typing at the top of a ${longPages}-page document: ${keyMedian} ms per keystroke (max ${keyMax}), ${Math.round(pass)} ms pass after the burst`); - // A letter typed and taken back leaves every block as tall as it was, so no pass runs. - // Every pass ends in a pm-pagecount event, so the count is the proof; the blocked time - // in the label is the spell checker's whole-document recheck, not a pass. + `typing at the top of a ${longPages}-page document: ${keyMedian} ms per keystroke (max ${keyMax}), ${Math.round(pass)} ms pass after a split`); + // A letter typed and taken back leaves every block as tall as it was, so no pass runs + // (every pass ends in a pm-pagecount event), and the pause costs no more than a key: the + // spell checker re-reads the edited paragraph, not the document. await page.evaluate(() => { window.__passes = 0; document.querySelector('.tiptap').addEventListener('pm-pagecount', () => { window.__passes++; }); @@ -233,7 +235,8 @@ try { let idle = 0; for (const until = Date.now() + 1500; Date.now() < until;) idle = Math.max(idle, await blocked()); const passes = await page.evaluate(() => window.__passes); - check(passes === 0, `a letter typed and taken back runs no pass (${passes} passes, ${Math.round(idle)} ms blocked at most)`); + check(passes === 0 && idle < 100, + `a letter typed and taken back runs no pass and its pause is free (${passes} passes, ${Math.round(idle)} ms blocked at most)`); } catch (err) { check(false, `dom run threw: ${err.message ?? err}`); } finally { diff --git a/tests/unit/spell-recheck.test.ts b/tests/unit/spell-recheck.test.ts new file mode 100644 index 0000000..55761f8 --- /dev/null +++ b/tests/unit/spell-recheck.test.ts @@ -0,0 +1,70 @@ +// The check after an edit looks at the edited paragraph only; the others keep their +// squiggles. Drives a real body editor with a stub checker that flags xx-words. +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { Editor } from '@tiptap/core'; + +const { checks } = vi.hoisted(() => ({ checks: [] as string[] })); +vi.mock('../../src/lib/spell/controller', () => ({ + spellController: { + isEnabled: () => true, + check: (w: string) => { checks.push(w); return !w.startsWith('xx'); }, + suggest: () => [], + getLanguage: () => 'en-US', + addWord: () => {}, + ignoreWord: () => {}, + subscribe: () => () => {}, + }, +})); + +const { extensions } = await import('../../src/lib/editor/extensions'); +const { spellErrorAt } = await import('../../src/lib/editor/extensions/spellCheck'); + +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({ + element: el, + extensions, + content: { type: 'doc', content: [para('alpha xxone beta'), para('gamma xxtwo delta')] }, + }); + await Promise.resolve(); // the mount's whole-document check + checks.length = 0; + return editor; +} + +// 'alpha xxone beta' fills 1..17, so the second paragraph's text starts at 19. +const XXONE = 8; +const XXTWO = 26; + +afterEach(() => vi.useRealTimers()); + +describe('spell recheck after an edit', () => { + it('checks the edited paragraph only and keeps the other squiggles', async () => { + const editor = await makeEditor(); + expect(spellErrorAt(editor.state, XXONE)).toEqual({ from: 7, to: 12 }); + + editor.view.dispatch(editor.state.tr.insertText('q', 24)); // gamma → gammaq + vi.advanceTimersByTime(400); + expect(checks).toEqual(['gammaq', 'xxtwo', 'delta']); + expect(spellErrorAt(editor.state, XXONE)).toEqual({ from: 7, to: 12 }); + expect(spellErrorAt(editor.state, XXTWO + 1)).toEqual({ from: 26, to: 31 }); + editor.destroy(); + }); + + it('drops a squiggle the edit fixed, and re-reads a paragraph a deletion emptied into', async () => { + const editor = await makeEditor(); + editor.view.dispatch(editor.state.tr.insertText('two', 25, 30)); // xxtwo → two + vi.advanceTimersByTime(400); + expect(spellErrorAt(editor.state, XXTWO)).toBeNull(); + expect(spellErrorAt(editor.state, XXONE)).toEqual({ from: 7, to: 12 }); + + checks.length = 0; + editor.view.dispatch(editor.state.tr.delete(19, 25)); // 'gamma ' gone: a point, not a range + vi.advanceTimersByTime(400); + expect(checks).toEqual(['two', 'delta']); + editor.destroy(); + }); +});