feat(editor): spell & grammar checking (Chunk #7)
Offline spellcheck via hunspell compiled to WASM (runs fully in-browser), plus optional self-hostable LanguageTool grammar/style checking. - hunspell-wasm spell engine with bundled en_US dictionary (base64-decoded browser-safe via atob + TextDecoder) - LanguageTool engine (self-hostable, docker compose snippet under docker/languagetool/) - single decoration pipeline: spell + grammar hits merge into one squiggle layer (red=spelling, blue=grammar) with a side panel to jump/apply/ignore and add-to-dictionary - useCheckService hook wired into WritingView; check.css loaded in main.tsx - spell + grammar unit tests (30 editor tests green) PLAN.md and CHUNK#7.md updated to reflect completion.
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* useCheckService — wires the editor's CheckService into the WritingView.
|
||||
*
|
||||
* Creates the spell/grammar engines from the user's settings, attaches the
|
||||
* service to the editor, and exposes the resolved issues, counts, and action
|
||||
* handlers to the CheckPanel.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { Editor } from "@pubscript/editor";
|
||||
import type { CheckedIssue } from "@pubscript/editor";
|
||||
import { CheckService } from "@pubscript/editor";
|
||||
import { HunspellSpellEngine } from "@pubscript/editor";
|
||||
import { LanguageToolEngine } from "@pubscript/editor";
|
||||
import { useSettings } from "../store/settings.js";
|
||||
|
||||
interface UseCheckServiceOptions {
|
||||
editor: Editor;
|
||||
}
|
||||
|
||||
interface UseCheckServiceResult {
|
||||
service: CheckService | null;
|
||||
issues: CheckedIssue[];
|
||||
counts: { all: number; spelling: number; grammar: number } | null;
|
||||
activeIndex: number | null;
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
onNavigate: (issue: CheckedIssue) => void;
|
||||
onApply: (issue: CheckedIssue, replacement: string) => void;
|
||||
onDismiss: (issue: CheckedIssue) => void;
|
||||
onAddToDictionary: (word: string) => void;
|
||||
onToggleSpell: () => void;
|
||||
onToggleGrammar: () => void;
|
||||
}
|
||||
|
||||
/** Load the user's personal dictionary from localStorage. */
|
||||
function loadDictionary(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem("ps:dictionary");
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed.filter((w) => typeof w === "string") : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the user's personal dictionary to localStorage. */
|
||||
function saveDictionary(words: string[]): void {
|
||||
try {
|
||||
localStorage.setItem("ps:dictionary", JSON.stringify(words));
|
||||
} catch {
|
||||
// Ignore write failures (private mode, quota, etc.).
|
||||
}
|
||||
}
|
||||
|
||||
export function useCheckService({ editor }: UseCheckServiceOptions): UseCheckServiceResult {
|
||||
const {
|
||||
enableSpellcheck,
|
||||
enableGrammarCheck,
|
||||
spellcheckLanguage,
|
||||
grammarProvider,
|
||||
grammarApiKey,
|
||||
} = useSettings();
|
||||
|
||||
const serviceRef = useRef<CheckService | null>(null);
|
||||
const [issues, setIssues] = useState<CheckedIssue[]>([]);
|
||||
const [activeIndex, setActiveIndex] = useState<number | null>(null);
|
||||
const [counts, setCounts] = useState<UseCheckServiceResult["counts"]>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [dictionary, setDictionary] = useState<string[]>(loadDictionary);
|
||||
|
||||
// (Re)create the engines whenever settings change.
|
||||
useEffect(() => {
|
||||
// Dispose any previous service.
|
||||
serviceRef.current?.dispose();
|
||||
|
||||
// Build the spell engine from the bundled English dictionary.
|
||||
const spellEngine = new HunspellSpellEngine();
|
||||
|
||||
// Build the grammar engine based on the provider.
|
||||
let grammarEngine;
|
||||
if (enableGrammarCheck && grammarProvider === "languagetool") {
|
||||
grammarEngine = new LanguageToolEngine({
|
||||
baseUrl: "https://api.languagetool.org",
|
||||
...(grammarApiKey ? { apiKey: grammarApiKey } : {}),
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
// Create the service.
|
||||
const options = {
|
||||
spellEngine,
|
||||
...(grammarEngine ? { grammarEngine } : {}),
|
||||
language: spellcheckLanguage,
|
||||
spellEnabled: enableSpellcheck,
|
||||
grammarEnabled: enableGrammarCheck,
|
||||
debounceMs: 400,
|
||||
onUpdate: (result: { issues: CheckedIssue[]; counts: { spelling: number; grammar: number; total: number } }) => {
|
||||
setIssues(result.issues);
|
||||
setCounts({
|
||||
all: result.counts.total,
|
||||
spelling: result.counts.spelling,
|
||||
grammar: result.counts.grammar,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const service = new CheckService(options);
|
||||
service.attach(editor);
|
||||
serviceRef.current = service;
|
||||
|
||||
return () => {
|
||||
service.dispose();
|
||||
serviceRef.current = null;
|
||||
};
|
||||
}, [editor, enableSpellcheck, enableGrammarCheck, spellcheckLanguage, grammarProvider, grammarApiKey]);
|
||||
|
||||
// Persist the personal dictionary.
|
||||
useEffect(() => {
|
||||
saveDictionary(dictionary);
|
||||
}, [dictionary]);
|
||||
|
||||
const onApply = useCallback(
|
||||
(issue: CheckedIssue, replacement: string) => {
|
||||
serviceRef.current?.applySuggestion(issue, replacement);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onDismiss = useCallback(
|
||||
(issue: CheckedIssue) => {
|
||||
serviceRef.current?.ignoreIssue(issue);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onAddToDictionary = useCallback((word: string) => {
|
||||
serviceRef.current?.ignoreWord({ id: `ignore:${word}`, text: word } as CheckedIssue);
|
||||
setDictionary((prev) => (prev.includes(word) ? prev : [...prev, word]));
|
||||
}, []);
|
||||
|
||||
const onToggleSpell = useCallback(() => {
|
||||
serviceRef.current?.setSpellEnabled(!serviceRef.current.isSpellEnabled());
|
||||
}, []);
|
||||
|
||||
const onToggleGrammar = useCallback(() => {
|
||||
serviceRef.current?.setGrammarEnabled(!serviceRef.current.isGrammarEnabled());
|
||||
}, []);
|
||||
|
||||
const onNavigate = useCallback(
|
||||
(issue: CheckedIssue) => {
|
||||
if (!serviceRef.current) return;
|
||||
const idx = issues.indexOf(issue);
|
||||
setActiveIndex(idx >= 0 ? idx : null);
|
||||
// Jump the editor view to the issue's position.
|
||||
editor.commands.setTextSelection({ from: issue.from, to: issue.to });
|
||||
const view = editor.view;
|
||||
view.focus();
|
||||
// Scroll the editor's scrollable content into view.
|
||||
const dom = view.dom.querySelector(".ProseMirror") as HTMLElement | null;
|
||||
if (dom) {
|
||||
dom.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
},
|
||||
[editor, issues],
|
||||
);
|
||||
|
||||
return {
|
||||
service: serviceRef.current,
|
||||
issues,
|
||||
counts,
|
||||
activeIndex,
|
||||
open,
|
||||
setOpen,
|
||||
onNavigate,
|
||||
onApply,
|
||||
onDismiss,
|
||||
onAddToDictionary,
|
||||
onToggleSpell,
|
||||
onToggleGrammar,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user