Five article fixes were five clicks, five confetti bursts and five undo steps. "Accept all Tidy-up (5)" makes them one of each. The single undo decided the implementation: every replacement goes into one Tiptap chain, which applies as one transaction and so undoes as one history event. That only works if the spans can't move under each other, so the plan resolves every span against the document as it stands and applies them last-first. Three outcomes rather than one, because a batch that quietly dropped a card would be reporting edits it never made: a span she already fixed herself is settled without an edit (what a single Accept does too), and a card quoting the same words as one already taken is left on screen, since findRange would resolve both to the same place. The control sits on the first card of its kind — the rail can't carry a category header, its cards are anchored to their own sentences — and only when the category has company. It is outlined rather than filled: it acts on cards she can't see from where she's standing.
167 lines
5.8 KiB
TypeScript
167 lines
5.8 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
|
import type { Suggestion, SuggestionType } from '../../api/client'
|
|
import { usePack } from '../../i18n'
|
|
import { AskPetal } from './AskPetal'
|
|
import { TYPE_META, batchLabel, typeLabel } from './suggestionMeta'
|
|
|
|
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
|
|
}
|
|
|
|
// 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,
|
|
}: 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<HTMLDivElement>(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])
|
|
|
|
function toggleAsking() {
|
|
setAsking((prev) => {
|
|
const next = !prev
|
|
onExpandChange(next)
|
|
return next
|
|
})
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={cardRef}
|
|
role="dialog"
|
|
aria-label={`${label} suggestion`}
|
|
onMouseEnter={onPointerEnter}
|
|
onMouseLeave={onPointerLeave}
|
|
className="petal-suggestion-card absolute z-20 p-3.5 text-sm"
|
|
style={{
|
|
width: asking ? 340 : 300,
|
|
background: 'var(--color-surface)',
|
|
border: '1px solid var(--color-border)',
|
|
borderRadius: 'var(--radius-card)',
|
|
boxShadow: 'var(--shadow-soft)',
|
|
...style,
|
|
}}
|
|
>
|
|
<span
|
|
className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-bold"
|
|
style={{ background: meta.color, color: 'var(--color-plum)' }}
|
|
>
|
|
{label}
|
|
</span>
|
|
|
|
{hasReplacement && (
|
|
<div className="mt-2.5 flex flex-col gap-1" style={{ fontFamily: 'var(--font-body)' }}>
|
|
<span className="text-[0.95rem] line-through" style={{ color: 'var(--color-muted)' }}>
|
|
{suggestion.original}
|
|
</span>
|
|
<span className="text-[0.95rem] font-medium" style={{ color: 'var(--color-plum)' }}>
|
|
{suggestion.replacement}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
<p className="mt-2.5 leading-snug" style={{ color: 'var(--color-plum)' }}>
|
|
{suggestion.explanation}
|
|
</p>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={toggleAsking}
|
|
className="mt-2 rounded-full px-2.5 py-1 text-xs font-bold transition-colors"
|
|
style={{
|
|
background: asking ? 'var(--color-surface-alt)' : 'transparent',
|
|
color: 'var(--color-accent-hover)',
|
|
}}
|
|
>
|
|
{asking ? 'Hide Petal' : 'Ask Petal ✨'}
|
|
</button>
|
|
|
|
{asking && <AskPetal suggestionId={suggestion.id} explanation={suggestion.explanation} />}
|
|
|
|
<div className="mt-3 flex items-center gap-2">
|
|
{hasReplacement && (
|
|
<button
|
|
type="button"
|
|
onClick={() => onAccept(suggestion)}
|
|
className="rounded-full px-3.5 py-1.5 text-xs font-bold text-white"
|
|
style={{ background: 'var(--color-accent)' }}
|
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
|
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
|
|
>
|
|
Accept
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() => onDismiss(suggestion)}
|
|
className="rounded-full px-3.5 py-1.5 text-xs font-semibold"
|
|
style={{ background: 'var(--color-surface-alt)', color: 'var(--color-muted)' }}
|
|
>
|
|
Dismiss
|
|
</button>
|
|
</div>
|
|
|
|
{hasReplacement && batchCount > 1 && (
|
|
<button
|
|
type="button"
|
|
onClick={() => onAcceptAll(suggestion.type)}
|
|
className="petal-accept-all mt-2 w-full rounded-full py-1.5 text-xs font-bold"
|
|
style={{ color: 'var(--color-plum)', borderColor: meta.color }}
|
|
>
|
|
{batchLabel(suggestion.type, batchCount)}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|