Phase 7: browser-side spell check (nspell, en-US)
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
This commit is contained in:
@@ -8,7 +8,10 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Toolbar } from '../Toolbar/Toolbar'
|
||||
import { SuggestionCard } from './SuggestionCard'
|
||||
import { SuggestionHighlight, setSuggestions, findRange } from './SuggestionHighlight'
|
||||
import { SpellCheck, setSpellChecker, wordAt } from './SpellCheck'
|
||||
import { MisspellCard } from './MisspellCard'
|
||||
import type { Suggestion } from '../../api/client'
|
||||
import type { SpellChecker } from '../../hooks/useSpellChecker'
|
||||
|
||||
export interface EditorChange {
|
||||
content: string // Tiptap JSON, stringified
|
||||
@@ -32,6 +35,19 @@ interface Props {
|
||||
voicing: boolean
|
||||
// Fired when the editor gains focus, so the app can enter distraction-free mode.
|
||||
onFocusMode?: () => void
|
||||
// Browser-side spell checker (null until the dictionary loads). Adding a word
|
||||
// to the personal dictionary is bubbled up so it persists app-wide.
|
||||
spellChecker: SpellChecker | null
|
||||
onAddWord: (word: string) => void
|
||||
}
|
||||
|
||||
interface MisspellState {
|
||||
word: string
|
||||
from: number
|
||||
to: number
|
||||
suggestions: string[]
|
||||
top: number
|
||||
left: number
|
||||
}
|
||||
|
||||
// A tiny CSS-only confetti burst played at an accept. Four palette-colored dots
|
||||
@@ -89,9 +105,13 @@ export function EditorCore({
|
||||
onVoiceCheck,
|
||||
voicing,
|
||||
onFocusMode,
|
||||
spellChecker,
|
||||
onAddWord,
|
||||
}: Props) {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
const [hover, setHover] = useState<HoverState | null>(null)
|
||||
// The open spelling popover (click a red-underlined word), or null.
|
||||
const [misspell, setMisspell] = useState<MisspellState | null>(null)
|
||||
// Transient confetti burst played at the last accept location.
|
||||
const [confetti, setConfetti] = useState<{ top: number; left: number } | null>(null)
|
||||
const confettiTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
@@ -109,6 +129,7 @@ export function EditorCore({
|
||||
Placeholder.configure({ placeholder: 'Start writing…' }),
|
||||
CharacterCount,
|
||||
SuggestionHighlight,
|
||||
SpellCheck,
|
||||
],
|
||||
content: parseDoc(initialContent),
|
||||
editorProps: {
|
||||
@@ -116,6 +137,8 @@ export function EditorCore({
|
||||
},
|
||||
onFocus: () => onFocusMode?.(),
|
||||
onUpdate: ({ editor }) => {
|
||||
// Any edit shifts positions, stranding the spelling popover's anchor.
|
||||
setMisspell(null)
|
||||
onChange({
|
||||
content: JSON.stringify(editor.getJSON()),
|
||||
content_text: editor.getText(),
|
||||
@@ -130,9 +153,17 @@ export function EditorCore({
|
||||
if (!editor) return
|
||||
editor.commands.setContent(parseDoc(initialContent) ?? '', false)
|
||||
setHover(null)
|
||||
setMisspell(null)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [docId, editor])
|
||||
|
||||
// Push the spell checker into its decoration plugin once the dictionary loads
|
||||
// (and again whenever the personal dictionary changes its identity).
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
setSpellChecker(editor.state, editor.view.dispatch, spellChecker)
|
||||
}, [editor, spellChecker])
|
||||
|
||||
// Push the current suggestion list into the decoration plugin.
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
@@ -232,6 +263,60 @@ export function EditorCore({
|
||||
[onDismiss, closeCard],
|
||||
)
|
||||
|
||||
// Click a red-underlined word to open its spelling popover, anchored under the
|
||||
// word. posAtCoords→wordAt resolves the exact PM span (robust to the same
|
||||
// misspelling appearing elsewhere); nspell supplies the corrections.
|
||||
const handleSpellClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!editor || !spellChecker) return
|
||||
const target = (e.target as HTMLElement).closest('.petal-misspelling') as HTMLElement | null
|
||||
if (!target) return
|
||||
const wrapper = wrapperRef.current
|
||||
if (!wrapper) return
|
||||
const coords = editor.view.posAtCoords({ left: e.clientX, top: e.clientY })
|
||||
if (!coords) return
|
||||
const range = wordAt(editor.state.doc, coords.pos)
|
||||
if (!range) return
|
||||
const elRect = target.getBoundingClientRect()
|
||||
const wrapRect = wrapper.getBoundingClientRect()
|
||||
const cardWidth = 240
|
||||
const left = Math.max(0, Math.min(elRect.left - wrapRect.left, wrapper.clientWidth - cardWidth))
|
||||
const top = elRect.bottom - wrapRect.top + 6
|
||||
// Opening a spelling popover supersedes any AI-suggestion hover card.
|
||||
closeCard()
|
||||
setMisspell({ ...range, suggestions: spellChecker.suggest(range.word), top, left })
|
||||
},
|
||||
[editor, spellChecker, closeCard],
|
||||
)
|
||||
|
||||
const replaceMisspelling = useCallback(
|
||||
(correction: string) => {
|
||||
if (editor && misspell) {
|
||||
editor.chain().focus().insertContentAt({ from: misspell.from, to: misspell.to }, correction).run()
|
||||
}
|
||||
setMisspell(null)
|
||||
},
|
||||
[editor, misspell],
|
||||
)
|
||||
|
||||
const addMisspellingToDict = useCallback(() => {
|
||||
if (misspell) onAddWord(misspell.word)
|
||||
setMisspell(null)
|
||||
}, [misspell, onAddWord])
|
||||
|
||||
// A pointer-down outside the popover (and not on another misspelling, which
|
||||
// would reopen it) closes the spelling card.
|
||||
useEffect(() => {
|
||||
if (!misspell) return
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const t = e.target as HTMLElement
|
||||
if (t.closest('.petal-misspell-card') || t.closest('.petal-misspelling')) return
|
||||
setMisspell(null)
|
||||
}
|
||||
document.addEventListener('mousedown', onDown)
|
||||
return () => document.removeEventListener('mousedown', onDown)
|
||||
}, [misspell])
|
||||
|
||||
useEffect(() => () => {
|
||||
clearTimeout(closeTimer.current)
|
||||
clearTimeout(confettiTimer.current)
|
||||
@@ -256,9 +341,19 @@ export function EditorCore({
|
||||
className="relative flex-1"
|
||||
onMouseOver={handleMouseOver}
|
||||
onMouseOut={handleMouseOut}
|
||||
onClick={handleSpellClick}
|
||||
>
|
||||
<EditorContent editor={editor} className="h-full" />
|
||||
{confetti && <Confetti top={confetti.top} left={confetti.left} />}
|
||||
{misspell && (
|
||||
<MisspellCard
|
||||
word={misspell.word}
|
||||
suggestions={misspell.suggestions}
|
||||
style={{ top: misspell.top, left: misspell.left }}
|
||||
onReplace={replaceMisspelling}
|
||||
onAdd={addMisspellingToDict}
|
||||
/>
|
||||
)}
|
||||
{hover && (
|
||||
<SuggestionCard
|
||||
suggestion={hover.suggestion}
|
||||
|
||||
75
web/src/components/Editor/MisspellCard.tsx
Normal file
75
web/src/components/Editor/MisspellCard.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
// MisspellCard is the little popover for a single misspelled word: the flagged
|
||||
// word, up to a few nspell corrections as tappable pills, and an "add to my
|
||||
// dictionary" action for names/terms Petal shouldn't nag about. Labels are
|
||||
// bilingual (zh-first, en subtitle) to match the rest of Petal's chrome — the
|
||||
// user writes in Mandarin and English (spec Note #17).
|
||||
|
||||
interface Props {
|
||||
word: string
|
||||
suggestions: string[]
|
||||
style: React.CSSProperties
|
||||
onReplace: (correction: string) => void
|
||||
onAdd: () => void
|
||||
}
|
||||
|
||||
export function MisspellCard({ word, suggestions, style, onReplace, onAdd }: Props) {
|
||||
const shown = suggestions.slice(0, 5)
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label={`Spelling suggestions for ${word}`}
|
||||
className="petal-misspell-card absolute z-20 p-3 text-sm"
|
||||
style={{
|
||||
width: 240,
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-card)',
|
||||
boxShadow: 'var(--shadow-soft)',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-bold"
|
||||
style={{ background: 'var(--color-accent)', color: 'white' }}
|
||||
>
|
||||
拼写 · Spelling
|
||||
</span>
|
||||
<span className="font-semibold" style={{ color: 'var(--color-muted)' }}>
|
||||
{word}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{shown.length > 0 ? (
|
||||
<div className="mt-2.5 flex flex-wrap gap-1.5">
|
||||
{shown.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => onReplace(s)}
|
||||
className="rounded-full px-3 py-1 text-xs font-semibold"
|
||||
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-plum)' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-surface-alt)')}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2.5 leading-snug" style={{ color: 'var(--color-muted)' }}>
|
||||
没有建议 · No suggestions
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAdd}
|
||||
className="mt-3 rounded-full px-2.5 py-1 text-xs font-bold transition-colors"
|
||||
style={{ background: 'transparent', color: 'var(--color-accent-hover)' }}
|
||||
>
|
||||
添加到词典 · Add to dictionary
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
144
web/src/components/Editor/SpellCheck.ts
Normal file
144
web/src/components/Editor/SpellCheck.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
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
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
@@ -22,7 +22,7 @@ interface PluginState {
|
||||
// mapOffset converts a character offset within a textblock's flattened text into
|
||||
// an absolute ProseMirror position, accounting for inline atoms (e.g. hard
|
||||
// breaks) that occupy a position but contribute no text.
|
||||
function mapOffset(block: PMNode, blockPos: number, targetOffset: number): number {
|
||||
export function mapOffset(block: PMNode, blockPos: number, targetOffset: number): number {
|
||||
let textOffset = 0
|
||||
let pmPos = blockPos + 1 // inline content starts just inside the block
|
||||
let result = pmPos
|
||||
|
||||
Reference in New Issue
Block a user