Phase 7: browser-side spell check (nspell, en-US)

Vendored Hunspell en.aff/en.dic into web/public/dictionaries/en/ (served as a
static asset + embedded in the binary, kept out of the JS bundle). dictionary-en
moved to a devDep — only used to source the files.

- useSpellChecker (App-level, loads once/session): fetches the dict, builds an
  nspell instance, replays a localStorage personal word list; addWord persists
  and bumps a version so consumers re-decorate. Ambient types in
  src/types/nspell.d.ts (the package ships none).
- SpellCheck Tiptap extension: misspellings as ProseMirror decorations (no
  stored marks), recomputed on edit / caret move / checker swap. Latin-only
  tokenizer so CJK is never flagged; skips short tokens + all-caps acronyms;
  exempts the caret word to avoid mid-typing jitter. Reuses mapOffset (now
  exported from SuggestionHighlight); wordAt resolves the exact span on click.
- MisspellCard: soft rose wavy underline, bilingual popover with up to 5 nspell
  corrections (click to replace) + add-to-dictionary. Closes on outside-pointer,
  edit, or doc switch.

Chinese spell check intentionally omitted — nspell is dictionary-based and
English-only; Chinese typos (homophone 别字) need an LLM, out of v1 scope.

tsc/vite/go build+vet clean; live server serves both dict files; nspell
behavior smoke-tested.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
prosolis
2026-06-25 22:18:21 -07:00
parent 183219b5de
commit 660f00d452
14 changed files with 50622 additions and 3 deletions

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { api, type DocSummary, type Document, type Suggestion } from './api/client'
import { useAutoSave } from './hooks/useAutoSave'
import { useCheckpoint } from './hooks/useCheckpoint'
import { useSpellChecker } from './hooks/useSpellChecker'
import { DocList } from './components/DocList/DocList'
import { EditorCore, type EditorChange } from './components/Editor/EditorCore'
import { StatusBar } from './components/StatusBar/StatusBar'
@@ -30,6 +31,8 @@ export default function App() {
runVoice,
removeSuggestion,
} = useCheckpoint(currentDoc?.id ?? null)
// Browser-side spell checker — loads the en-US dictionary once per session.
const { checker: spellChecker, addWord } = useSpellChecker()
// Patch a summary in the sidebar list (optimistic title / word-count updates).
const patchSummary = useCallback((id: string, patch: Partial<DocSummary>) => {
@@ -222,6 +225,8 @@ export default function App() {
onVoiceCheck={runVoice}
voicing={voicing}
onFocusMode={() => setFocusMode(true)}
spellChecker={spellChecker}
onAddWord={addWord}
/>
</div>
</div>