Merge feat/spelling-popover: right-click corrections + robust word resolution

This commit is contained in:
prosolis
2026-06-26 22:23:42 -07:00
2 changed files with 51 additions and 26 deletions

View File

@@ -34,9 +34,12 @@ export function AskPetal({ suggestionId, explanation }: Props) {
if (el) el.scrollTop = el.scrollHeight if (el) el.scrollTop = el.scrollHeight
}, [messages]) }, [messages])
// Focus the input when the panel opens. // Focus the input when the panel opens. preventScroll: the card is already on
// screen as an absolutely-positioned overlay, and a default focus() would make
// the browser scroll its ancestor to "reveal" the input — jumping the document
// to the top.
useEffect(() => { useEffect(() => {
inputRef.current?.focus() inputRef.current?.focus({ preventScroll: true })
}, []) }, [])
// Fetch the Chinese translation of the explanation to seed the first bubble. // Fetch the Chinese translation of the explanation to seed the first bubble.
@@ -95,7 +98,7 @@ export function AskPetal({ suggestionId, explanation }: Props) {
console.error('Ask Petal chat failed:', err) console.error('Ask Petal chat failed:', err)
} finally { } finally {
setStreaming(false) setStreaming(false)
inputRef.current?.focus() inputRef.current?.focus({ preventScroll: true })
} }
} }

View File

@@ -580,9 +580,37 @@ export function EditorCore({
[onDismiss, closeCard], [onDismiss, closeCard],
) )
// Click a red-underlined word to open its spelling popover, anchored under the // openMisspellAt resolves the word at a document position and, if the checker
// word. posAtCoords→wordAt resolves the exact PM span (robust to the same // flags it as misspelled, opens the spelling popover anchored under the word.
// misspelling appearing elsewhere); nspell supplies the corrections. // Returns true if it opened a card. We resolve the word from coordinates and
// the checker rather than from the `.petal-misspelling` DOM span: clicking a
// word moves the caret into it, which fires the decoration rebuild that
// deliberately un-underlines the caret word (see SpellCheck's caret exemption),
// so by the time a click/contextmenu handler runs the span is already gone.
const openMisspellAt = useCallback(
(pos: number): boolean => {
if (!editor || !spellChecker) return false
const range = wordAt(editor.state.doc, pos)
if (!range || spellChecker.correct(range.word)) return false
const wrapper = wrapperRef.current
if (!wrapper) return false
const start = editor.view.coordsAtPos(range.from)
const end = editor.view.coordsAtPos(range.to)
const wrapRect = wrapper.getBoundingClientRect()
const cardWidth = 240
const left = Math.max(0, Math.min(start.left - wrapRect.left, wrapper.clientWidth - cardWidth))
const top = end.bottom - wrapRect.top + 6
// Opening a spelling popover supersedes any AI-suggestion or lookup card.
closeCard()
setWordInfo(null)
setMisspell({ ...range, suggestions: spellChecker.suggest(range.word), top, left })
return true
},
[editor, spellChecker, closeCard],
)
// Click a misspelled word to open its spelling popover, anchored under the
// word; nspell supplies the corrections.
// //
// Tapping an AI-suggestion highlight also opens its card here — on touch there's // Tapping an AI-suggestion highlight also opens its card here — on touch there's
// no hover, so the tap is the only way in (mouse users still get hover). // no hover, so the tap is the only way in (mouse users still get hover).
@@ -603,26 +631,16 @@ export function EditorCore({
} }
return return
} }
if (!editor || !spellChecker) return if (!editor) return
const target = (e.target as HTMLElement).closest('.petal-misspelling') as HTMLElement | null // Only act on clicks in the editor text itself — the floating cards/popovers
if (!target) return // are children of this same wrapper, and a click on one shouldn't resolve a
const wrapper = wrapperRef.current // (hidden) word behind it.
if (!wrapper) return if (!(e.target as HTMLElement).closest('.petal-prose')) return
const coords = editor.view.posAtCoords({ left: e.clientX, top: e.clientY }) const coords = editor.view.posAtCoords({ left: e.clientX, top: e.clientY })
if (!coords) return if (!coords) return
const range = wordAt(editor.state.doc, coords.pos) openMisspellAt(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()
setWordInfo(null)
setMisspell({ ...range, suggestions: spellChecker.suggest(range.word), top, left })
}, },
[editor, spellChecker, closeCard, openCardFor, railEnabled], [editor, openMisspellAt, openCardFor, railEnabled],
) )
const replaceMisspelling = useCallback( const replaceMisspelling = useCallback(
@@ -757,8 +775,10 @@ export function EditorCore({
.catch((err) => console.error('vocab save failed', err)) .catch((err) => console.error('vocab save failed', err))
}, [wordInfo, exampleAt, docId]) }, [wordInfo, exampleAt, docId])
// Right-click a word to look it up. Right-clicking off any word falls through // Right-click a misspelled word for spelling corrections (the familiar "did you
// to the native menu (so copy/paste-by-menu still works — see the Selection fix). // mean" gesture); right-click any other word to look it up. Right-clicking off
// any word falls through to the native menu (so copy/paste-by-menu still works
// — see the Selection fix).
const handleContextMenu = useCallback( const handleContextMenu = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
if (!editor) return if (!editor) return
@@ -766,9 +786,11 @@ export function EditorCore({
if (!coords) return if (!coords) return
if (!wordAt(editor.state.doc, coords.pos)) return if (!wordAt(editor.state.doc, coords.pos)) return
e.preventDefault() e.preventDefault()
// A misspelled word offers corrections first; otherwise look it up.
if (openMisspellAt(coords.pos)) return
openWordLookup(coords.pos) openWordLookup(coords.pos)
}, },
[editor, openWordLookup], [editor, openMisspellAt, openWordLookup],
) )
// Touch has no hover or right-click, so a long-press (~500ms without moving) // Touch has no hover or right-click, so a long-press (~500ms without moving)