diff --git a/web/src/components/Editor/AskPetal.tsx b/web/src/components/Editor/AskPetal.tsx index fc4f8a6..0eee1b4 100644 --- a/web/src/components/Editor/AskPetal.tsx +++ b/web/src/components/Editor/AskPetal.tsx @@ -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 }) } } diff --git a/web/src/components/Editor/EditorCore.tsx b/web/src/components/Editor/EditorCore.tsx index cdb4f4a..7f3b510 100644 --- a/web/src/components/Editor/EditorCore.tsx +++ b/web/src/components/Editor/EditorCore.tsx @@ -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)