Surface every outstanding suggestion as a card in the right-hand
whitespace, vertically aligned to the text it flags — so the writer sees
the whole queue at once instead of hovering each highlight. Cards stack
with collision avoidance, link both ways with their highlight (hover/click
↔ soft text wash, driven through the decoration plugin so it survives
edit repaints), and carry the same Accept / Dismiss / Ask Petal actions.
The rail is a progressive enhancement: it mounts only when there's room
beside the editor, otherwise the existing inline hover card is unchanged.
Stacked cards that reach the bottom-right corner tuck behind the
companion mascot (z-order).
When a card is expanded, the Ask Petal bubble now opens with the
Simplified-Chinese translation of the explanation (the English stays in
the card body) instead of repeating the same text twice — a new
POST /api/suggestions/{id}/translate one-shot LLM endpoint, loaded
lazily on open with an English fallback.
Verified live against the local LLM via the uitest harness: rail
stacking, hover↔text wash, expand/Ask Petal, accept-from-rail, narrow
fallback, and the Mandarin bubble.
Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
212 lines
8.0 KiB
TypeScript
212 lines
8.0 KiB
TypeScript
import { forwardRef, useLayoutEffect, useRef, useState } from 'react'
|
|
import type { Suggestion } from '../../api/client'
|
|
import { AskPetal } from './AskPetal'
|
|
import { TYPE_META } from './suggestionMeta'
|
|
|
|
// Vertical breathing room kept between stacked cards when their natural anchors
|
|
// would otherwise collide.
|
|
const CARD_GAP = 12
|
|
|
|
export interface RailItem {
|
|
suggestion: Suggestion
|
|
// Vertical offset (px) of the suggestion's highlight, relative to the editor
|
|
// wrapper — the position the card wants to sit beside.
|
|
anchorTop: number
|
|
}
|
|
|
|
interface Props {
|
|
items: RailItem[]
|
|
// The suggestion currently emphasized (its highlight hovered, or this card
|
|
// hovered) — gets a lifted, ring-accented treatment so the text↔card link reads.
|
|
activeId: string | null
|
|
// The card expanded to show the full explanation + Ask Petal, or null.
|
|
expandedId: string | null
|
|
onAccept: (s: Suggestion) => void
|
|
onDismiss: (s: Suggestion) => void
|
|
// Pointer entering/leaving a card, so the matching highlight can light up.
|
|
onHover: (id: string | null) => void
|
|
// A card's body was clicked — scroll its highlight into view and toggle expand.
|
|
onActivate: (id: string) => void
|
|
onToggleExpand: (id: string) => void
|
|
}
|
|
|
|
// SuggestionRail is the right-margin "comment column": every outstanding
|
|
// suggestion as a card, vertically aligned to the text it flags, so the writer
|
|
// sees the whole queue at once instead of hunting highlight by highlight. Cards
|
|
// are anchored to their highlight's vertical position and pushed down only as far
|
|
// as needed to avoid overlapping their neighbor above (a la doc-editor comments).
|
|
export function SuggestionRail({
|
|
items,
|
|
activeId,
|
|
expandedId,
|
|
onAccept,
|
|
onDismiss,
|
|
onHover,
|
|
onActivate,
|
|
onToggleExpand,
|
|
}: Props) {
|
|
// Measured resolved tops keyed by suggestion id (after collision avoidance).
|
|
const [tops, setTops] = useState<Record<string, number>>({})
|
|
const cardRefs = useRef<Map<string, HTMLDivElement>>(new Map())
|
|
// Bumped whenever a card's own height changes (Ask Petal streaming in, wrapping
|
|
// text) so the stack re-packs and cards below don't get overlapped.
|
|
const [measureTick, setMeasureTick] = useState(0)
|
|
const observerRef = useRef<ResizeObserver | null>(null)
|
|
if (!observerRef.current && typeof ResizeObserver !== 'undefined') {
|
|
observerRef.current = new ResizeObserver(() => setMeasureTick((t) => t + 1))
|
|
}
|
|
useLayoutEffect(() => () => observerRef.current?.disconnect(), [])
|
|
|
|
// Sort by anchor, then greedily stack: each card sits at its anchor unless that
|
|
// would overlap the previous card, in which case it slides down just enough.
|
|
// Re-runs whenever the anchors or the expanded card (which changes a height)
|
|
// shift. Reads live offsetHeight so variable-length explanations pack tightly.
|
|
const ordered = [...items].sort((a, b) => a.anchorTop - b.anchorTop)
|
|
const layoutKey = ordered.map((i) => `${i.suggestion.id}:${Math.round(i.anchorTop)}`).join('|')
|
|
useLayoutEffect(() => {
|
|
let cursor = -Infinity
|
|
const next: Record<string, number> = {}
|
|
for (const { suggestion, anchorTop } of ordered) {
|
|
const h = cardRefs.current.get(suggestion.id)?.offsetHeight ?? 96
|
|
const top = Math.max(anchorTop, cursor)
|
|
next[suggestion.id] = top
|
|
cursor = top + h + CARD_GAP
|
|
}
|
|
setTops((prev) => {
|
|
const ids = Object.keys(next)
|
|
if (ids.length === Object.keys(prev).length && ids.every((id) => prev[id] === next[id])) return prev
|
|
return next
|
|
})
|
|
// layoutKey captures anchor positions; expandedId/measureTick capture height
|
|
// changes (toggling detail, Ask Petal streaming in).
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [layoutKey, expandedId, measureTick])
|
|
|
|
return (
|
|
<div className="petal-rail petal-no-print" aria-label="Suggestions">
|
|
{ordered.map(({ suggestion }) => (
|
|
<RailCard
|
|
key={suggestion.id}
|
|
ref={(el) => {
|
|
const prev = cardRefs.current.get(suggestion.id)
|
|
if (prev && prev !== el) observerRef.current?.unobserve(prev)
|
|
if (el) {
|
|
cardRefs.current.set(suggestion.id, el)
|
|
observerRef.current?.observe(el)
|
|
} else {
|
|
cardRefs.current.delete(suggestion.id)
|
|
}
|
|
}}
|
|
suggestion={suggestion}
|
|
top={tops[suggestion.id] ?? 0}
|
|
active={activeId === suggestion.id}
|
|
expanded={expandedId === suggestion.id}
|
|
onAccept={onAccept}
|
|
onDismiss={onDismiss}
|
|
onHover={onHover}
|
|
onActivate={onActivate}
|
|
onToggleExpand={onToggleExpand}
|
|
/>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
interface CardProps {
|
|
suggestion: Suggestion
|
|
top: number
|
|
active: boolean
|
|
expanded: boolean
|
|
onAccept: (s: Suggestion) => void
|
|
onDismiss: (s: Suggestion) => void
|
|
onHover: (id: string | null) => void
|
|
onActivate: (id: string) => void
|
|
onToggleExpand: (id: string) => void
|
|
}
|
|
|
|
const RailCard = forwardRef<HTMLDivElement, CardProps>(function RailCard(
|
|
{ suggestion, top, active, expanded, onAccept, onDismiss, onHover, onActivate, onToggleExpand },
|
|
ref,
|
|
) {
|
|
const meta = TYPE_META[suggestion.type]
|
|
const hasReplacement = suggestion.replacement.trim() !== ''
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
role="group"
|
|
aria-label={`${meta.label} suggestion`}
|
|
onMouseEnter={() => onHover(suggestion.id)}
|
|
onMouseLeave={() => onHover(null)}
|
|
className={`petal-rail-card${active ? ' petal-rail-card-active' : ''}`}
|
|
style={{ top, borderLeftColor: meta.color }}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<span
|
|
className="inline-flex items-center rounded-full px-2 py-0.5 text-[0.68rem] font-bold"
|
|
style={{ background: meta.color, color: 'var(--color-plum)' }}
|
|
>
|
|
{meta.label}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
aria-label="Dismiss suggestion"
|
|
onClick={() => onDismiss(suggestion)}
|
|
className="petal-rail-x"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
|
|
{/* The body toggles the expanded explanation + Ask Petal and points the
|
|
editor at the flagged text. */}
|
|
<button type="button" onClick={() => onActivate(suggestion.id)} className="mt-2 block w-full text-left">
|
|
{hasReplacement && (
|
|
<span className="flex flex-col gap-0.5" style={{ fontFamily: 'var(--font-body)' }}>
|
|
<span className="truncate text-[0.9rem] line-through" style={{ color: 'var(--color-muted)' }}>
|
|
{suggestion.original}
|
|
</span>
|
|
<span className="truncate text-[0.9rem] font-medium" style={{ color: 'var(--color-plum)' }}>
|
|
{suggestion.replacement}
|
|
</span>
|
|
</span>
|
|
)}
|
|
<span
|
|
className={`mt-1.5 block text-[0.82rem] leading-snug${expanded ? '' : ' line-clamp-2'}`}
|
|
style={{ color: 'var(--color-plum)' }}
|
|
>
|
|
{suggestion.explanation}
|
|
</span>
|
|
</button>
|
|
|
|
{expanded && <AskPetal suggestionId={suggestion.id} explanation={suggestion.explanation} />}
|
|
|
|
<div className="mt-2.5 flex items-center gap-2">
|
|
{hasReplacement && (
|
|
<button
|
|
type="button"
|
|
onClick={() => onAccept(suggestion)}
|
|
className="rounded-full px-3 py-1 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={() => onToggleExpand(suggestion.id)}
|
|
className="rounded-full px-2.5 py-1 text-xs font-bold transition-colors"
|
|
style={{
|
|
background: expanded ? 'var(--color-surface-alt)' : 'transparent',
|
|
color: 'var(--color-accent-hover)',
|
|
}}
|
|
>
|
|
{expanded ? 'Hide Petal' : 'Ask Petal ✨'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
})
|