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:
88
web/src/hooks/useSpellChecker.ts
Normal file
88
web/src/hooks/useSpellChecker.ts
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user