import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import nspell, { type NSpell } from 'nspell' import { api } from '../api/client' // 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. // // The personal word list — the words she's told Petal to stop flagging — lives // on the server, keyed by her account and by the dictionary's language. It used // to be one localStorage key, which meant two people sharing a device shared a // word list built from one person's private writing, and one person on a laptop // and a tablet had two lists that never met. It 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[] } // The dictionary this hook loads. Only en-US ships today; pt-PT arrives with the // first Latin pair, and its personal words are a separate list by design — an // English exception must not silence a Portuguese flag. const DICT_LANG = 'en' // Where the list lived before it had an owner (Phase 7). Read once, handed to // the account, and then removed — see takeLegacyWords. const LEGACY_KEY = 'petal.spell.personal' // takeLegacyWords reads the pre-account list without deleting it: the words are // only dropped from the browser once the server has actually accepted them, so // a failed request costs nothing. function readLegacyWords(): string[] { try { const raw = localStorage.getItem(LEGACY_KEY) const parsed = raw ? JSON.parse(raw) : [] return Array.isArray(parsed) ? parsed.filter((w): w is string => typeof w === 'string') : [] } catch { return [] } } function clearLegacyWords() { try { localStorage.removeItem(LEGACY_KEY) } catch { /* storage unavailable — nothing was read from it either */ } } 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) spellRef.current = sp setReady(true) // Her own words come from her account. A browser holding a list from // before accounts existed hands it over on the way — but only lets go of // it once the server has taken it. const legacy = readLegacyWords() const stored = legacy.length ? await api.addPersonalWords(DICT_LANG, legacy).then((res) => { clearLegacyWords() return res }) : await api.listPersonalWords(DICT_LANG) if (cancelled) return for (const w of stored.words) sp.add(w) setVersion((v) => v + 1) } catch (err) { // A failure here costs correct words being flagged, not writing. The // checker itself stays usable if only the word list failed to arrive. 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]) // Adding a word takes effect in the editor immediately and is persisted in the // background: the word stops being underlined the instant she asks, whatever // the network is doing. const addWord = useCallback((word: string) => { const sp = spellRef.current if (!sp) return sp.add(word) setVersion((v) => v + 1) api.addPersonalWords(DICT_LANG, [word]).catch((err) => { console.error('could not save personal word', err) }) }, []) return { checker, ready, addWord } }