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

@@ -0,0 +1,88 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import nspell, { type NSpell } from 'nspell'
// useSpellChecker loads the vendored en-US Hunspell dictionary (served from
// /dictionaries/en, embedded in the Go binary via web/dist) and builds an
// in-browser nspell instance — zero backend round-trips, per spec. The
// dictionary is ~550KB, so it's fetched as a static asset (kept out of the JS
// bundle) once per app session, not per document. A personal word list lives in
// localStorage and is replayed into nspell on load; adding a word bumps a
// `version` so consumers re-run their decorations and the word stops flagging.
// SpellChecker is the minimal surface the editor decoration layer consumes.
export interface SpellChecker {
correct(word: string): boolean
suggest(word: string): string[]
}
const PERSONAL_KEY = 'petal.spell.personal'
function loadPersonal(): string[] {
try {
const raw = localStorage.getItem(PERSONAL_KEY)
const parsed = raw ? JSON.parse(raw) : []
return Array.isArray(parsed) ? parsed.filter((w): w is string => typeof w === 'string') : []
} catch {
return []
}
}
function savePersonal(words: string[]) {
try {
localStorage.setItem(PERSONAL_KEY, JSON.stringify(words))
} catch {
/* storage unavailable — personal words just won't persist this session */
}
}
export function useSpellChecker() {
const spellRef = useRef<NSpell | null>(null)
const [ready, setReady] = useState(false)
// Bumped whenever the personal dictionary changes, to force re-decoration.
const [version, setVersion] = useState(0)
useEffect(() => {
let cancelled = false
;(async () => {
try {
// Served from web/dist root (and embedded in the Go binary), same as /api.
const [aff, dic] = await Promise.all([
fetch('/dictionaries/en/en.aff').then((r) => r.text()),
fetch('/dictionaries/en/en.dic').then((r) => r.text()),
])
if (cancelled) return
const sp = nspell(aff, dic)
for (const w of loadPersonal()) sp.add(w)
spellRef.current = sp
setReady(true)
} catch (err) {
console.error('spell checker failed to load', err)
}
})()
return () => {
cancelled = true
}
}, [])
// Recreate the checker's identity on load and on every personal-dict change so
// the editor's effect re-pushes it and rebuilds decorations.
const checker = useMemo<SpellChecker | null>(() => {
if (!ready) return null
return {
correct: (w) => spellRef.current?.correct(w) ?? true,
suggest: (w) => spellRef.current?.suggest(w) ?? [],
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, version])
const addWord = useCallback((word: string) => {
const sp = spellRef.current
if (!sp) return
sp.add(word)
const next = Array.from(new Set([...loadPersonal(), word]))
savePersonal(next)
setVersion((v) => v + 1)
}, [])
return { checker, ready, addWord }
}