feat(editor): add spell & grammar check engines with decorations

Wire real spell-checking into the editor via hunspell-wasm (bundled en_US
dictionary) and an optional LanguageTool grammar client.

- spell/: HunspellSpellEngine wrapper, pure spell logic, base64 en_US dict
- grammar/: LanguageToolEngine grammar client and pure grammar logic
- decorations/: collectBlock text assembly, offset-mapped CheckService,
  buildCheckDecorations (flat DecorationSet, inline spell/grammar marks)
- export engines and types from editor index; add hunspell-wasm dep

Verified: typecheck (6 pkgs), tests (all pass), lint (6 pkgs), and web
build all green.
This commit is contained in:
Schwitzz
2026-10-27 12:52:29 -05:00
parent b92129b62c
commit d310635b65
13 changed files with 775 additions and 1 deletions
@@ -0,0 +1,231 @@
/**
* CheckService — the glue between spell/grammar engines and the editor.
*
* Responsibilities:
* - Walk the document block-by-block and run the spell and grammar engines.
* - Resolve results to document positions and expose a decoration set for the
* editor view plus a flat issue list for the UI.
* - Apply suggestions and ignore-lists as document edits.
*
* The service is framework-agnostic: it talks to a TipTap editor through the
* small `TipTapEditor` surface (state, schema, view.dispatch) and reports
* changes through an `onUpdate` callback so any UI layer can react.
*/
import type { Editor as TipTapEditor } from "@tiptap/core";
import { DecorationSet } from "@tiptap/pm/view";
import type { EditorState } from "@tiptap/pm/state";
import {
buildCheckDecorations,
toCheckedIssues,
collectBlock,
type BlockIssues,
type CheckedIssue,
} from "./decorations.js";
import type { SpellEngine, SpellError } from "../spell/spell.js";
import type { GrammarEngine, GrammarError } from "../grammar/grammar.js";
import { HunspellSpellEngine } from "../spell/hunspell-engine.js";
export interface CheckServiceOptions {
spellEngine: SpellEngine;
grammarEngine?: GrammarEngine;
/** Language tag forwarded to the grammar engine (default "en-US"). */
language?: string;
spellEnabled?: boolean;
grammarEnabled?: boolean;
/** Debounce window for auto-checks triggered by typing (ms). */
debounceMs?: number;
/** Fired after every check with the resolved issues and counts. */
onUpdate?: (result: CheckResult) => void;
}
export interface CheckCounts {
spelling: number;
grammar: number;
total: number;
}
export interface CheckResult {
issues: CheckedIssue[];
counts: CheckCounts;
checking: boolean;
}
/** Run an async mapper over items with a bounded concurrency. */
async function mapWithConcurrency<T, R>(
items: T[],
limit: number,
map: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
const results: R[] = new Array(items.length);
let next = 0;
const worker = async (): Promise<void> => {
while (next < items.length) {
const i = next++;
results[i] = await map(items[i] as T, i);
}
};
const workers = Array.from({ length: Math.min(limit, items.length) }, () => worker());
await Promise.all(workers);
return results;
}
export class CheckService {
private spellEngine: SpellEngine;
private grammarEngine: GrammarEngine | null = null;
private language: string;
private spellEnabled: boolean;
private grammarEnabled: boolean;
private debounceMs: number;
private onUpdate: ((result: CheckResult) => void) | null = null;
private editor: TipTapEditor | null = null;
private decorations: DecorationSet = DecorationSet.empty;
private issues: CheckedIssue[] = [];
private ignored = new Set<string>();
private debounceTimer?: ReturnType<typeof setTimeout>;
constructor(options: CheckServiceOptions) {
this.spellEngine = options.spellEngine;
this.grammarEngine = options.grammarEngine ?? null;
this.language = options.language ?? "en-US";
this.spellEnabled = options.spellEnabled ?? true;
this.grammarEnabled = options.grammarEnabled ?? false;
this.debounceMs = options.debounceMs ?? 400;
this.onUpdate = options.onUpdate ?? null;
}
/** Bind the service to an editor instance and run the first check. */
attach(editor: TipTapEditor): void {
this.editor = editor;
this.check();
}
getDecorations(): DecorationSet {
return this.decorations;
}
getIssues(): CheckedIssue[] {
return this.issues.filter((issue) => !this.ignored.has(issue.id));
}
getCounts(): CheckCounts {
const spelling = this.issues.filter((i) => i.kind === "spelling").length;
const grammar = this.issues.filter((i) => i.kind === "grammar").length;
return { spelling, grammar, total: spelling + grammar };
}
isSpellEnabled(): boolean {
return this.spellEnabled;
}
isGrammarEnabled(): boolean {
return this.grammarEnabled;
}
setSpellEnabled(enabled: boolean): void {
this.spellEnabled = enabled;
this.check();
}
setGrammarEnabled(enabled: boolean): void {
this.grammarEnabled = enabled;
this.check();
}
/** Schedule a debounced re-check against the current document state. */
scheduleCheck(): void {
if (this.debounceTimer) clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => this.check(), this.debounceMs);
}
/** Run a full check now (ignores the debounce). */
async check(): Promise<CheckResult> {
if (!this.editor) return { issues: [], counts: this.countsSilent(), checking: false };
const result = await this.runChecks(this.editor.state);
this.issues = result.issues;
this.decorations = buildCheckDecorations(this.editor.state, result.blocks);
const checkResult: CheckResult = {
issues: result.issues,
counts: this.computeCounts(result.issues),
checking: false,
};
this.emit(checkResult);
return checkResult;
}
private countsSilent(): CheckCounts {
return { spelling: 0, grammar: 0, total: 0 };
}
private computeCounts(issues: CheckedIssue[]): CheckCounts {
const spelling = issues.filter((i) => i.kind === "spelling").length;
const grammar = issues.filter((i) => i.kind === "grammar").length;
return { spelling, grammar, total: spelling + grammar };
}
private emit(result: CheckResult): void {
this.onUpdate?.(result);
}
/** Walk the document, run the engines per block, and resolve positions. */
private async runChecks(state: EditorState): Promise<{ blocks: BlockIssues[]; issues: CheckedIssue[] }> {
const blocks: BlockIssues[] = [];
state.doc.forEach((block, blockStart) => {
if (!block.childCount) return;
const { text } = collectBlock(block, blockStart);
blocks.push({ node: block, blockStart, text, spell: [], grammar: [] });
});
const spellErrors = this.spellEnabled
? await mapWithConcurrency(blocks, 4, (block) => this.spellEngine.check(block.text))
: blocks.map(() => [] as SpellError[]);
blocks.forEach((block, i) => {
block.spell = spellErrors[i] ?? [];
});
const grammarErrors = this.grammarEnabled && this.grammarEngine
? await mapWithConcurrency(blocks, 3, (block) =>
block.text.trim() ? this.grammarEngine!.check(block.text, this.language) : Promise.resolve([]),
)
: blocks.map(() => [] as GrammarError[]);
blocks.forEach((block, i) => {
block.grammar = grammarErrors[i] ?? [];
});
const issues = toCheckedIssues(blocks);
return { blocks, issues };
}
/** Replace a flagged span with a suggestion and re-check. */
applySuggestion(issue: CheckedIssue, replacement: string): void {
if (!this.editor || issue.from === issue.to) return;
const textNode = this.editor.schema.text(replacement);
const tr = this.editor.state.tr.replaceWith(issue.from, issue.to, textNode);
this.editor.view.dispatch(tr);
this.scheduleCheck();
}
/** Add a misspelled word to the dictionary so it is no longer flagged. */
ignoreWord(issue: CheckedIssue): void {
if (this.spellEngine instanceof HunspellSpellEngine) {
(this.spellEngine as HunspellSpellEngine).addWord(issue.text);
}
this.ignored.add(issue.id);
this.scheduleCheck();
}
/** Locally suppress a grammar issue without editing the document. */
ignoreIssue(issue: CheckedIssue): void {
this.ignored.add(issue.id);
this.onUpdate?.({ issues: this.getIssues(), counts: this.countsSilent(), checking: false });
}
dispose(): void {
if (this.debounceTimer) clearTimeout(this.debounceTimer);
if (this.spellEngine instanceof HunspellSpellEngine) {
this.spellEngine.dispose();
}
this.editor = null;
}
}
@@ -0,0 +1,188 @@
/**
* Decoration building for spell/grammar results.
*
* Maps text offsets (relative to a block node) onto ProseMirror document
* positions and wraps the flagged spans in inline decorations. The outer set is
* left empty so consumers can layer cursor/selection decorations on top; the
* inner set carries the red/blue underlines rendered beneath the text.
*
* Block text is assembled from a walk over the node's inline children using a
* rare separator (U+0004) between inline nodes. This preserves word boundaries
* across marked text while keeping the assembled string in lockstep with the
* recorded `docPositions` map, so a spell/grammar offset resolves to the exact
* document character.
*/
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import type { EditorState } from "@tiptap/pm/state";
import type { Node as ProsemirrorNode } from "@tiptap/pm/model";
import type { SpellError } from "../spell/spell.js";
import type { GrammarError } from "../grammar/grammar.js";
/** Separator inserted between inline nodes when assembling block text. */
const SEP = "\uE001";
/** Sentinel doc position marking a separator character in `docPositions`. */
const SEP_POS = -1;
/** A flagged span with its position resolved to the document. */
export interface CheckedIssue {
/** Stable id: `${kind}:${from}:${to}`. */
id: string;
kind: "spelling" | "grammar";
/** Start position in the document. */
from: number;
/** End position in the document. */
to: number;
/** The flagged text. */
text: string;
/** Suggested replacements (spell candidates or grammar fixes). */
replacements: string[];
/** Grammar explanation, when this is a grammar issue. */
message?: string;
}
/** Issues grouped by the block node they belong to. */
export interface BlockIssues {
/** The block node itself. */
node: ProsemirrorNode;
/** Position of the block node in the document. */
blockStart: number;
/** Assembled text of the block (offsets used by engines are relative to this). */
text: string;
spell: SpellError[];
grammar: GrammarError[];
}
/**
* Assemble a block's text and record, per character, its absolute document
* position. Separator characters get the {@link SEP_POS} sentinel so callers
* can skip them when resolving an offset.
*/
export function collectBlock(block: ProsemirrorNode, blockStart: number): {
text: string;
docPositions: number[];
} {
let text = "";
const docPositions: number[] = [];
block.forEach((child, childPos) => {
if (child.isText && child.text) {
const absStart = blockStart + 1 + childPos;
for (let i = 0; i < child.text.length; i++) {
text += child.text[i];
docPositions.push(absStart + i);
}
text += SEP;
docPositions.push(SEP_POS);
}
});
return { text, docPositions };
}
/**
* Resolve a text offset within `block` to an absolute document position.
* `blockStart` is the position of the block node; its inline content begins at
* `blockStart + 1`.
*/
export function textOffsetToDocPos(
block: ProsemirrorNode,
blockStart: number,
offset: number,
): number {
const { docPositions } = collectBlock(block, blockStart);
return resolveOffset(docPositions, offset);
}
/** Map an index into the assembled block text to a document position. */
function resolveOffset(docPositions: number[], offset: number): number {
if (offset < 0 || offset >= docPositions.length) {
// Clamp to the last real position (end of block).
for (let i = docPositions.length - 1; i >= 0; i--) {
if (docPositions[i] !== SEP_POS) return docPositions[i] ?? 0;
}
return 0;
}
const at = docPositions[offset];
if (at !== SEP_POS) return at ?? 0;
// Offset lands on a separator: snap to the following real character, or the
// preceding one if the separator is trailing.
for (let i = offset; i < docPositions.length; i++) {
if (docPositions[i] !== SEP_POS) return docPositions[i] ?? 0;
}
for (let i = offset - 1; i >= 0; i--) {
if (docPositions[i] !== SEP_POS) return (docPositions[i] ?? 0) + 1;
}
return 0;
}
/** Resolve a `[offset, offset+length)` span to `[from, to)` document positions. */
function resolveSpan(docPositions: number[], offset: number, length: number): { from: number; to: number } {
const from = resolveOffset(docPositions, offset);
const end = resolveOffset(docPositions, offset + length - 1);
return { from, to: end + 1 };
}
const SPELL_CLASS = "ps-spell-deco";
const GRAMMAR_CLASS = "ps-grammar-deco";
/**
* Build a decoration set from block-grouped issues. Each flagged span becomes
* an inline range decoration; the class encodes the kind so CSS can style it.
*/
export function buildCheckDecorations(
state: EditorState,
blocks: BlockIssues[],
): DecorationSet {
const decos: Decoration[] = [];
for (const block of blocks) {
const { docPositions } = collectBlock(block.node, block.blockStart);
for (const error of block.spell) {
const { from, to } = resolveSpan(docPositions, error.offset, error.length);
decos.push(Decoration.inline(clampPos(state, from), clampPos(state, to), { class: SPELL_CLASS }));
}
for (const error of block.grammar) {
const { from, to } = resolveSpan(docPositions, error.offset, error.length);
decos.push(Decoration.inline(clampPos(state, from), clampPos(state, to), { class: GRAMMAR_CLASS }));
}
}
return DecorationSet.create(state.doc, decos);
}
/**
* Resolve block-grouped issues into flat, position-resolved {@link CheckedIssue}s
* for the UI (counts, suggestion list, apply/ignore actions).
*/
export function toCheckedIssues(blocks: BlockIssues[]): CheckedIssue[] {
const issues: CheckedIssue[] = [];
for (const block of blocks) {
const { text } = block;
for (const error of block.spell) {
const { from, to } = resolveSpan(collectBlock(block.node, block.blockStart).docPositions, error.offset, error.length);
issues.push({
id: `spelling:${from}:${to}`,
kind: "spelling",
from,
to,
text: error.word,
replacements: error.candidates.map((c) => c.word),
});
}
for (const error of block.grammar) {
const { from, to } = resolveSpan(collectBlock(block.node, block.blockStart).docPositions, error.offset, error.length);
const raw = text.slice(error.offset, error.offset + error.length).replace(new RegExp(SEP, "g"), " ");
issues.push({
id: `grammar:${from}:${to}`,
kind: "grammar",
from,
to,
text: raw,
replacements: error.replacements,
message: error.message,
});
}
}
return issues;
}
/** Clamp a computed position into the valid range of the document. */
function clampPos(state: EditorState, pos: number): number {
return Math.max(1, Math.min(pos, state.doc.nodeSize - 1));
}
+4
View File
@@ -0,0 +1,4 @@
export { CheckService } from "./check-service.js";
export type { CheckServiceOptions, CheckResult, CheckCounts } from "./check-service.js";
export { buildCheckDecorations } from "./decorations.js";
export type { CheckedIssue, BlockIssues } from "./decorations.js";