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(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(() => { 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 } }