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
145 lines
5.7 KiB
TypeScript
145 lines
5.7 KiB
TypeScript
import { Extension } from '@tiptap/core'
|
|
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
|
import type { EditorState, Transaction } from '@tiptap/pm/state'
|
|
import { Decoration, DecorationSet } from '@tiptap/pm/view'
|
|
import type { Node as PMNode } from '@tiptap/pm/model'
|
|
import { mapOffset } from './SuggestionHighlight'
|
|
import type { SpellChecker } from '../../hooks/useSpellChecker'
|
|
|
|
// SpellCheck renders browser-side nspell misspellings as ProseMirror
|
|
// decorations (a wavy red underline), recomputed from the live document on every
|
|
// change. Like the AI-suggestion layer it stores no marks — the underline is a
|
|
// pure overlay, so it never travels into saved content. English only: the word
|
|
// tokenizer matches Latin-letter runs, so CJK text (the user writes in both
|
|
// Mandarin and English) is simply never tokenized and never flagged.
|
|
|
|
export const spellPluginKey = new PluginKey<PluginState>('petalSpellCheck')
|
|
|
|
interface PluginState {
|
|
checker: SpellChecker | null
|
|
decorations: DecorationSet
|
|
}
|
|
|
|
// A word is a run of Latin letters with optional internal/edge apostrophes
|
|
// (don't, O'Brien). Anything else — digits, punctuation, CJK — terminates a run.
|
|
const WORD_RE = /[A-Za-z][A-Za-z']*/g
|
|
|
|
// isCheckable filters tokens we shouldn't flag: single letters and all-caps
|
|
// acronyms (NASA, USA), which dictionaries reliably miss and which read as noise
|
|
// when underlined.
|
|
function isCheckable(word: string): boolean {
|
|
if (word.length < 2) return false
|
|
if (word === word.toUpperCase()) return false
|
|
return true
|
|
}
|
|
|
|
// strip leading/trailing apostrophes (e.g. a quoted 'word') so the dictionary
|
|
// lookup sees the bare token; returns the core plus how many chars were trimmed
|
|
// off the front (to re-anchor the decoration).
|
|
function coreOf(word: string): { core: string; lead: number } {
|
|
const lead = word.match(/^'+/)?.[0].length ?? 0
|
|
const trail = word.match(/'+$/)?.[0].length ?? 0
|
|
return { core: word.slice(lead, word.length - trail), lead }
|
|
}
|
|
|
|
function eachMisspelling(
|
|
doc: PMNode,
|
|
checker: SpellChecker,
|
|
visit: (from: number, to: number, word: string) => void,
|
|
) {
|
|
doc.descendants((node, pos) => {
|
|
if (!node.isTextblock) return true
|
|
const text = node.textContent
|
|
WORD_RE.lastIndex = 0
|
|
let m: RegExpExecArray | null
|
|
while ((m = WORD_RE.exec(text)) !== null) {
|
|
const { core, lead } = coreOf(m[0])
|
|
if (!isCheckable(core) || checker.correct(core)) continue
|
|
const from = mapOffset(node, pos, m.index + lead)
|
|
const to = mapOffset(node, pos, m.index + lead + core.length)
|
|
visit(from, to, core)
|
|
}
|
|
return false // never descend into a textblock's inline children
|
|
})
|
|
}
|
|
|
|
function buildDecorations(doc: PMNode, checker: SpellChecker, cursor: number): DecorationSet {
|
|
const decos: Decoration[] = []
|
|
eachMisspelling(doc, checker, (from, to, _word) => {
|
|
// Don't flag the word the caret currently sits in — it's mid-typing, and a
|
|
// red underline appearing under the cursor on every keystroke is jittery.
|
|
if (cursor >= from && cursor <= to) return
|
|
decos.push(Decoration.inline(from, to, { class: 'petal-misspelling', 'data-misspelling': '' }))
|
|
})
|
|
return DecorationSet.create(doc, decos)
|
|
}
|
|
|
|
// wordAt resolves the misspelling token under a ProseMirror position (from a
|
|
// click), returning its range + text so the card can offer corrections and the
|
|
// replacement can target the exact span — robust to duplicate words anywhere
|
|
// else in the document. Returns null if the position isn't inside a Latin word.
|
|
export function wordAt(doc: PMNode, pos: number): { from: number; to: number; word: string } | null {
|
|
let found: { from: number; to: number; word: string } | null = null
|
|
doc.descendants((node, nodePos) => {
|
|
if (found) return false
|
|
if (!node.isTextblock) return true
|
|
if (pos <= nodePos || pos >= nodePos + node.nodeSize) return false
|
|
const text = node.textContent
|
|
WORD_RE.lastIndex = 0
|
|
let m: RegExpExecArray | null
|
|
while ((m = WORD_RE.exec(text)) !== null) {
|
|
const { core, lead } = coreOf(m[0])
|
|
if (!core) continue
|
|
const from = mapOffset(node, nodePos, m.index + lead)
|
|
const to = mapOffset(node, nodePos, m.index + lead + core.length)
|
|
if (pos >= from && pos <= to) {
|
|
found = { from, to, word: core }
|
|
break
|
|
}
|
|
}
|
|
return false
|
|
})
|
|
return found
|
|
}
|
|
|
|
// setSpellChecker pushes the (possibly null) checker into the plugin, rebuilding
|
|
// decorations immediately against the current document.
|
|
export function setSpellChecker(
|
|
state: EditorState,
|
|
dispatch: (tr: Transaction) => void,
|
|
checker: SpellChecker | null,
|
|
) {
|
|
dispatch(state.tr.setMeta(spellPluginKey, checker ?? null))
|
|
}
|
|
|
|
export const SpellCheck = Extension.create({
|
|
name: 'spellCheck',
|
|
|
|
addProseMirrorPlugins() {
|
|
return [
|
|
new Plugin<PluginState>({
|
|
key: spellPluginKey,
|
|
state: {
|
|
init: () => ({ checker: null, decorations: DecorationSet.empty }),
|
|
apply(tr, value, _oldState, newState) {
|
|
const meta = tr.getMeta(spellPluginKey) as SpellChecker | null | undefined
|
|
const checker = meta !== undefined ? meta : value.checker
|
|
if (!checker) return { checker: null, decorations: DecorationSet.empty }
|
|
// Rebuild on a checker swap, a doc edit, or a caret move (so the word
|
|
// you just left gets re-evaluated and the new caret word is exempt).
|
|
if (meta !== undefined || tr.docChanged || tr.selectionSet) {
|
|
return { checker, decorations: buildDecorations(newState.doc, checker, newState.selection.head) }
|
|
}
|
|
return { checker, decorations: value.decorations }
|
|
},
|
|
},
|
|
props: {
|
|
decorations(state) {
|
|
return spellPluginKey.getState(state)?.decorations
|
|
},
|
|
},
|
|
}),
|
|
]
|
|
},
|
|
})
|