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
}, [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(() => {
inputRef.current?.focus()
inputRef.current?.focus({ preventScroll: true })
}, [])
// 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)
} finally {
setStreaming(false)
inputRef.current?.focus()
inputRef.current?.focus({ preventScroll: true })
}
}

View File

@@ -580,9 +580,37 @@ 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.
// openMisspellAt resolves the word at a document position and, if the checker
// flags it as misspelled, opens the spelling popover anchored under the word.
// 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
// no hover, so the tap is the only way in (mouse users still get hover).
@@ -603,26 +631,16 @@ export function EditorCore({
}
return
}
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
if (!editor) return
// Only act on clicks in the editor text itself — the floating cards/popovers
// are children of this same wrapper, and a click on one shouldn't resolve a
// (hidden) word behind it.
if (!(e.target as HTMLElement).closest('.petal-prose')) 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()
setWordInfo(null)
setMisspell({ ...range, suggestions: spellChecker.suggest(range.word), top, left })
openMisspellAt(coords.pos)
},
[editor, spellChecker, closeCard, openCardFor, railEnabled],
[editor, openMisspellAt, openCardFor, railEnabled],
)
const replaceMisspelling = useCallback(
@@ -757,8 +775,10 @@ export function EditorCore({
.catch((err) => console.error('vocab save failed', err))
}, [wordInfo, exampleAt, docId])
// Right-click a 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).
// Right-click a misspelled word for spelling corrections (the familiar "did you
// 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(
(e: React.MouseEvent) => {
if (!editor) return
@@ -766,9 +786,11 @@ export function EditorCore({
if (!coords) return
if (!wordAt(editor.state.doc, coords.pos)) return
e.preventDefault()
// A misspelled word offers corrections first; otherwise look it up.
if (openMisspellAt(coords.pos)) return
openWordLookup(coords.pos)
},
[editor, openWordLookup],
[editor, openMisspellAt, openWordLookup],
)
// Touch has no hover or right-click, so a long-press (~500ms without moving)