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>({}) const cardRefs = useRef>(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(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 = {} 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 (
{ordered.map(({ suggestion }) => ( { 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} /> ))}
) } 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(function RailCard( { suggestion, top, active, expanded, onAccept, onDismiss, onHover, onActivate, onToggleExpand }, ref, ) { const meta = TYPE_META[suggestion.type] const hasReplacement = suggestion.replacement.trim() !== '' return (
onHover(suggestion.id)} onMouseLeave={() => onHover(null)} className={`petal-rail-card${active ? ' petal-rail-card-active' : ''}`} style={{ top, borderLeftColor: meta.color }} >
{meta.label}
{/* The body toggles the expanded explanation + Ask Petal and points the editor at the flagged text. */} {expanded && }
{hasReplacement && ( )}
) })