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
89 lines
3.0 KiB
TypeScript
89 lines
3.0 KiB
TypeScript
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 }
|
|
}
|