Spelling popover: right-click corrections + robust word resolution
Resolve the misspelled word from the click/right-click position 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, so the span is already gone by the time the handler runs. Factored into openMisspellAt(pos), which only opens when the checker actually flags the word, and is gated to clicks inside .petal-prose so a click on a floating card doesn't resolve a word hidden behind it. Right-click now offers spelling corrections first on a misspelled word (the familiar "did you mean" gesture), falling back to word lookup otherwise. AskPetal: focus the input with preventScroll so opening the card doesn't jump the document to the top. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
@@ -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 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user