{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "selection-actions", "type": "registry:component", "title": "Selection Actions", "description": "Highlight a passage and hand it to the agent to rewrite.", "dependencies": [ "iconoir-react" ], "registryDependencies": [ "https://www.beautifului.dev/r/foundation.json", "https://www.beautifului.dev/r/button.json", "https://www.beautifului.dev/r/shimmer.json", "https://www.beautifului.dev/r/stream-text.json" ], "files": [ { "path": "components/primitives/SelectionActions.tsx", "content": "\"use client\";\n\nimport {\n useCallback,\n useEffect,\n useLayoutEffect,\n useRef,\n useState,\n type ReactNode,\n} from \"react\";\nimport {\n ArrowUp,\n ChatBubbleQuestion,\n Check,\n EmojiSatisfied,\n NavArrowRight,\n Refresh,\n Scissor,\n Spark,\n TextBox,\n Xmark,\n} from \"iconoir-react\";\nimport { Button } from \"@/components/atoms/Button\";\nimport { Shimmer } from \"@/components/atoms/Shimmer\";\nimport { StreamText } from \"@/components/atoms/StreamText\";\n\n/* ─────────────────────────────────────────────────────────\n * SELECTION ACTIONS\n * A contextual AI bar attached beneath selected text.\n * The global theme owns its surface; this component only\n * composes existing surface, ink, accent, radius and motion\n * tokens.\n * ───────────────────────────────────────────────────────── */\n\nconst LEAD = \"Pistachio holds the top slot all weekend. \";\nconst PICKED =\n \"Churn it first thing Saturday so the batch has time to firm up before the afternoon rush.\";\nconst REWRITE =\n \"Churn pistachio first thing Saturday so the batch has time to fully firm before the afternoon rush.\";\n\n/* The passage: lead-in text, the selected `original`, and the streamed `rewrite`. */\nexport type SelectionText = {\n lead: string;\n original: string;\n rewrite: string;\n};\n\n/* A single AI action offered in the bar. Omit `action` for a no-op button\n * (e.g. Explain); `busyLabel` is the gerund shown while it runs. */\nexport type SelectionAction = {\n id: string;\n icon: ReactNode;\n action?: string;\n busyLabel?: string;\n};\n\n/* The action set: `primary` are always visible; `more` reveal on expand. */\nexport type SelectionActionSet = {\n primary: SelectionAction[];\n more: SelectionAction[];\n};\n\n/* Prominent copy strings. */\nexport type SelectionActionsLabels = {\n keep: string;\n discard: string;\n placeholder: string;\n};\n\nconst DEFAULT_TEXT: SelectionText = {\n lead: LEAD,\n original: PICKED,\n rewrite: REWRITE,\n};\n\nconst DEFAULT_LABELS: SelectionActionsLabels = {\n keep: \"Keep\",\n discard: \"Discard\",\n placeholder: \"Describe edits\",\n};\n\ntype Mode = \"idle\" | \"thinking\" | \"streaming\" | \"result\";\n\nconst iconProps = {\n width: 14,\n height: 14,\n strokeWidth: 1.8,\n \"aria-hidden\": true,\n} as const;\n\nconst icons = {\n explain: ,\n improve: ,\n shorten: ,\n tone: ,\n grammar: ,\n send: (\n \n ),\n chevron: ,\n check: ,\n close: ,\n retry: ,\n};\n\n/* the single \"keep\" affirm — solid ink with a hairline (not the atom's filled\n * highlight) shadow, so it stays a local one-off rather than a Button variant */\nconst primary =\n \"inline-flex h-7 shrink-0 items-center gap-1 rounded-full bg-ink px-2.5 text-[12.5px] font-normal text-canvas shadow-hairline transition-[opacity,transform] duration-150 hover:opacity-90 active:scale-[0.96]\";\n\n\nconst DEFAULT_ACTIONS: SelectionActionSet = {\n primary: [\n { id: \"Explain\", icon: icons.explain },\n { id: \"Improve\", icon: icons.improve, action: \"Improve\", busyLabel: \"Improving\" },\n ],\n more: [\n { id: \"Shorten\", icon: icons.shorten, action: \"Shorten\", busyLabel: \"Shortening\" },\n { id: \"Tone\", icon: icons.tone, action: \"Change tone\", busyLabel: \"Changing tone\" },\n { id: \"Grammar\", icon: icons.grammar, action: \"Fix grammar\" },\n ],\n};\n\nexport type SelectionActionsProps = {\n /** Accepted for gallery/registry parity; not used by this bar. */\n variant?: string;\n /** The passage shown above the bar. */\n text?: Partial;\n /** The AI actions offered in the bar. */\n actions?: SelectionActionSet;\n /** Prominent copy strings. */\n labels?: Partial;\n /** Called with the action name whenever an edit is run. */\n onAction?: (action: string) => void;\n};\n\nexport default function SelectionActions({\n text: textProp,\n actions = DEFAULT_ACTIONS,\n labels,\n onAction,\n}: SelectionActionsProps = {}) {\n const passage = { ...DEFAULT_TEXT, ...textProp };\n const copy = { ...DEFAULT_LABELS, ...labels };\n const [shown, setShown] = useState(false);\n const [mode, setMode] = useState(\"idle\");\n const [action, setAction] = useState(\"Improve\");\n const [prompt, setPrompt] = useState(\"\");\n const [typingWidth, setTypingWidth] = useState(null);\n const [expanded, setExpanded] = useState(false);\n const [anchor, setAnchor] = useState({ x: 0, y: 0 });\n const [positioned, setPositioned] = useState(false);\n\n const hostRef = useRef(null);\n const selectionRef = useRef(null);\n const barRef = useRef(null);\n const contentRef = useRef(null);\n const frameRef = useRef(null);\n const previousModeRef = useRef(\"idle\");\n const lastWidthRef = useRef(0);\n const widthAnimationRef = useRef(null);\n\n useEffect(() => {\n const timer = window.setTimeout(() => setShown(true), 280);\n return () => window.clearTimeout(timer);\n }, []);\n\n useEffect(() => {\n if (mode !== \"thinking\") return;\n const timer = window.setTimeout(() => setMode(\"streaming\"), 700);\n return () => window.clearTimeout(timer);\n }, [mode]);\n\n /* Attach beneath the final selected line, while centering the bar\n * against the complete selection bounds. requestAnimationFrame batches\n * streaming reflow measurements and avoids visible intermediate positions. */\n const place = useCallback(() => {\n if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);\n frameRef.current = requestAnimationFrame(() => {\n const host = hostRef.current;\n const selection = selectionRef.current;\n if (!host || !selection) return;\n\n const bounds = selection.getBoundingClientRect();\n const lines = Array.from(selection.getClientRects());\n const lastLine = lines.at(-1);\n if (!lastLine) return;\n\n const hostBounds = host.getBoundingClientRect();\n const next = {\n x: Math.round(bounds.left - hostBounds.left + bounds.width / 2),\n y: Math.round(lastLine.bottom - hostBounds.top + 8),\n };\n\n setAnchor((current) =>\n current.x === next.x && current.y === next.y ? current : next,\n );\n setPositioned(true);\n });\n }, []);\n\n useLayoutEffect(() => {\n place();\n }, [mode, place]);\n\n useEffect(() => {\n const host = hostRef.current;\n if (!host) return;\n const observer = new ResizeObserver(place);\n observer.observe(host);\n window.addEventListener(\"resize\", place);\n return () => {\n observer.disconnect();\n window.removeEventListener(\"resize\", place);\n if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);\n };\n }, [place]);\n\n /* Intrinsic width handles the preset expansion. When the entire content\n * changes between idle, loading and confirmation, animate from the last\n * rendered width to the new intrinsic width before the browser paints. */\n useLayoutEffect(() => {\n const bar = barRef.current;\n const content = contentRef.current;\n if (!bar || !content) return;\n\n const nextWidth = Math.ceil(content.getBoundingClientRect().width) + 8;\n const previousWidth =\n lastWidthRef.current || Math.ceil(bar.getBoundingClientRect().width);\n\n if (\n previousModeRef.current !== mode &&\n Math.abs(nextWidth - previousWidth) > 1\n ) {\n widthAnimationRef.current?.cancel();\n const animation = bar.animate(\n [\n { width: `${previousWidth}px` },\n { width: `${nextWidth}px` },\n ],\n {\n duration: 320,\n easing: \"cubic-bezier(0.23,1,0.32,1)\",\n },\n );\n widthAnimationRef.current = animation;\n animation.onfinish = () => {\n lastWidthRef.current = nextWidth;\n widthAnimationRef.current = null;\n };\n } else {\n lastWidthRef.current = nextWidth;\n }\n\n previousModeRef.current = mode;\n }, [mode]);\n\n useEffect(() => {\n const content = contentRef.current;\n if (!content) return;\n\n const observer = new ResizeObserver(() => {\n if (widthAnimationRef.current?.playState === \"running\") return;\n lastWidthRef.current =\n Math.ceil(content.getBoundingClientRect().width) + 8;\n });\n observer.observe(content);\n return () => {\n observer.disconnect();\n widthAnimationRef.current?.cancel();\n };\n }, []);\n\n const run = (nextAction: string) => {\n setAction(nextAction);\n setExpanded(false);\n setMode(\"thinking\");\n onAction?.(nextAction);\n };\n\n const reset = () => {\n setExpanded(false);\n setPrompt(\"\");\n setTypingWidth(null);\n setAction(\"Improve\");\n setMode(\"idle\");\n };\n\n const busy = mode === \"thinking\" || mode === \"streaming\";\n const visible = shown && positioned;\n const hasPrompt = prompt.trim().length > 0;\n const busyLabelMap: Record = {};\n for (const item of [...actions.primary, ...actions.more]) {\n if (item.action && item.busyLabel) busyLabelMap[item.action] = item.busyLabel;\n }\n const busyLabel = busyLabelMap[action] ?? \"Editing\";\n\n return (\n
\n
\n

\n {passage.lead}\n \n {mode === \"idle\" || mode === \"thinking\" ? (\n passage.original\n ) : mode === \"streaming\" ? (\n setMode(\"result\")}\n />\n ) : (\n passage.rewrite\n )}\n \n

\n\n \n {/* A 36px pill wraps 28px controls at a 4px inset. The controls\n resolve to a 14px radius, preserving the concentric curve. */}\n \n \n {busy && (\n \n \n {mode === \"thinking\" ? (\n \n {busyLabel}…\n \n ) : (\n {busyLabel}…\n )}\n \n )}\n\n {mode === \"result\" && (\n <>\n \n {icons.check}\n {copy.keep}\n \n \n \n run(action)}\n className=\"flex size-7 shrink-0 items-center justify-center rounded-full text-ink-3 transition-[background-color,color,transform] duration-150 hover:bg-hover-2 hover:text-ink-2 active:scale-[0.96]\"\n >\n {icons.retry}\n \n \n )}\n\n {mode === \"idle\" && (\n <>\n \n {\n event.preventDefault();\n run(prompt.trim() || \"Improve\");\n }}\n >\n {\n const next = event.target.value;\n if (!prompt.trim() && next.trim()) {\n setTypingWidth(\n Math.ceil(\n barRef.current?.getBoundingClientRect().width ??\n 0,\n ),\n );\n } else if (!next.trim()) {\n setTypingWidth(null);\n }\n setPrompt(next);\n }}\n aria-label={copy.placeholder}\n placeholder={copy.placeholder}\n className=\"h-7 w-full bg-transparent pr-2.5 pl-3 text-[12.5px] text-ink placeholder:text-ink-3\"\n />\n \n
\n\n \n {!expanded && (\n \n )}\n {actions.primary.map((item) => (\n run(item.action!) : undefined}\n >\n {item.icon}\n {item.id}\n \n ))}\n\n \n {actions.more.map((item) => (\n run(item.action!) : undefined}\n >\n {item.icon}\n {item.id}\n \n ))}\n
\n\n \n setExpanded((value) => !value)}\n className=\"flex size-7 shrink-0 items-center justify-center rounded-full text-ink transition-[background-color,transform] duration-200 hover:bg-hover active:scale-[0.96]\"\n >\n \n {icons.chevron}\n \n \n \n\n \n run(prompt.trim())}\n className=\"flex size-7 shrink-0 items-center justify-center rounded-full bg-ink text-surface transition-[opacity,transform] duration-200 active:scale-[0.94]\"\n >\n {icons.send}\n \n \n \n )}\n \n \n \n \n\n \n );\n}\n", "type": "registry:component", "target": "components/primitives/SelectionActions.tsx" } ] }