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('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({ 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 }, }, }), ] }, })