import { useEffect, useRef, useState } from 'react' import type { Suggestion, SuggestionType } from '../../api/client' import { usePack } from '../../i18n' import { fromIME } from '../../lib/ime' import { AskPetal } from './AskPetal' import { TYPE_META, batchLabel, typeLabel } from './suggestionMeta' import type { Direction } from './triage' interface Props { suggestion: Suggestion style: React.CSSProperties // How many pending suggestions share this card's type (including this one). // Two or more offers to take the whole category in one step. batchCount: number onAccept: (s: Suggestion) => void onAcceptAll: (type: SuggestionType) => void onDismiss: (s: Suggestion) => void onPointerEnter: () => void onPointerLeave: () => void // Pins the card open while the Ask Petal panel is expanded, so the chat isn't // dismissed by the hover-close timer when the pointer drifts away. onExpandChange: (expanded: boolean) => void // How far the card reaches below the wrapper's top, in wrapper coordinates — // the same report the rail makes (item 4). The card is absolutely positioned // and so adds no layout height of its own; without this, an Ask Petal // conversation that runs past the last line of text has no scrollable page // under it and its Accept button simply can't be reached. 0 means "nothing to // cover", which is what an unmounted card reports on its way out. onExtent?: (bottom: number) => void // Keyboard triage (item 8). The card was opened by a keystroke rather than a // pointer, so it takes focus and answers the keys itself: the writer is // walking the underlines and never touches the mouse. keyboard?: boolean // Move to the next/previous underline, and leave triage entirely. onStep?: (dir: Direction) => void onExit?: () => void } // SuggestionCard is the hover panel for a single suggestion: a colored type tag, // the original → replacement diff, the friendly explanation, and accept/dismiss // actions. Voice flags carry no replacement, so the diff row is hidden and only // Dismiss is offered (awareness-only). export function SuggestionCard({ suggestion, style, batchCount, onAccept, onAcceptAll, onDismiss, onPointerEnter, onPointerLeave, onExpandChange, onExtent, keyboard = false, onStep, onExit, }: Props) { const pack = usePack() const meta = TYPE_META[suggestion.type] const label = typeLabel(suggestion.type, pack) const hasReplacement = suggestion.replacement.trim() !== '' const [asking, setAsking] = useState(false) const cardRef = useRef(null) // Report the card's reach while it is open, and withdraw it on the way out. // A ResizeObserver rather than a one-shot measure because the card grows // after it is mounted: the Ask Petal panel opens, and then the reply streams // into it token by token. useEffect(() => { const el = cardRef.current if (!el || !onExtent) return const report = () => onExtent(el.offsetTop + el.offsetHeight) report() const observer = new ResizeObserver(report) observer.observe(el) return () => { observer.disconnect() onExtent(0) } }, [onExtent]) // In triage the card is where the keys land, so it has to hold focus — and it // has to re-take it on every step, because stepping keeps this same component // mounted and only swaps the suggestion inside it. `preventScroll` for the // reason AskPetal's input gives: the card is an absolutely-positioned overlay, // and letting the browser "reveal" it would jump the document out from under // the sentence she is reading. The span is scrolled to deliberately elsewhere. useEffect(() => { if (keyboard) cardRef.current?.focus({ preventScroll: true }) }, [keyboard, suggestion.id]) // The triage keys. Only bound in keyboard mode: a card opened by the pointer // never holds focus, and stealing Tab from one that somehow did would break // ordinary focus movement for no gain. function handleKeyDown(e: React.KeyboardEvent) { if (!keyboard || fromIME(e)) return const target = e.target as HTMLElement // Ask Petal's question field is a text input inside this card. While it has // focus it owns every key it can use — Tab, Enter and the letters are hers // to type — and only Escape is taken, to step back out to the card. const typing = target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement if (e.key === 'Escape') { e.preventDefault() // Escape also leaves distraction-free mode (App's window listener), which // would restore the sidebar and pull the rail out from under her mid- // triage. In triage this key means "this card", or "triage" — never "the // writing mode". e.stopPropagation() if (typing) cardRef.current?.focus({ preventScroll: true }) else onExit?.() return } if (typing) return if (e.key === 'Tab') { e.preventDefault() onStep?.(e.shiftKey ? -1 : 1) } else if (e.key === 'Enter') { // An awareness-only card has nothing to accept; Enter on it does nothing // rather than quietly meaning something else. if (!hasReplacement) return e.preventDefault() onAccept(suggestion) } else if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault() onDismiss(suggestion) } else if (e.key === '?') { e.preventDefault() // Opening hands focus to the panel's own input; Escape there comes back // here, and ? then closes it again. toggleAsking() } } function toggleAsking() { setAsking((prev) => { const next = !prev onExpandChange(next) return next }) } return (
{label} {hasReplacement && (
{suggestion.original} {suggestion.replacement}
)}

{suggestion.explanation}

{asking && }
{hasReplacement && ( )}
{hasReplacement && batchCount > 1 && ( )} {keyboard && (
{pack.editor.triageHint.native}
{pack.editor.triageHint.en}
)}
) }