- beautifului.dev 组件直连:Sidebar Nav、Tool Chips、Approval Card(抽屉化)、Streaming/Thinking、Code Block、Selection Actions、Prompt Bar、Search
25 lines
21 KiB
JSON
25 lines
21 KiB
JSON
{
|
|
"$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: <ChatBubbleQuestion {...iconProps} />,\n improve: <Spark {...iconProps} />,\n shorten: <Scissor {...iconProps} />,\n tone: <EmojiSatisfied {...iconProps} />,\n grammar: <TextBox {...iconProps} />,\n send: (\n <ArrowUp\n width=\"16\"\n height=\"16\"\n strokeWidth=\"2.4\"\n aria-hidden=\"true\"\n />\n ),\n chevron: <NavArrowRight {...iconProps} />,\n check: <Check {...iconProps} />,\n close: <Xmark {...iconProps} />,\n retry: <Refresh {...iconProps} />,\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<SelectionText>;\n /** The AI actions offered in the bar. */\n actions?: SelectionActionSet;\n /** Prominent copy strings. */\n labels?: Partial<SelectionActionsLabels>;\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<Mode>(\"idle\");\n const [action, setAction] = useState(\"Improve\");\n const [prompt, setPrompt] = useState(\"\");\n const [typingWidth, setTypingWidth] = useState<number | null>(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<HTMLDivElement>(null);\n const selectionRef = useRef<HTMLSpanElement>(null);\n const barRef = useRef<HTMLDivElement>(null);\n const contentRef = useRef<HTMLDivElement>(null);\n const frameRef = useRef<number | null>(null);\n const previousModeRef = useRef<Mode>(\"idle\");\n const lastWidthRef = useRef(0);\n const widthAnimationRef = useRef<Animation | null>(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<string, string> = {};\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 <div className=\"w-full max-w-[460px]\">\n <div ref={hostRef} className=\"relative select-none pb-12\">\n <p className=\"text-[13px] leading-relaxed text-ink\">\n {passage.lead}\n <span\n ref={selectionRef}\n className=\"box-decoration-clone rounded-[3px] bg-[color-mix(in_srgb,var(--accent)_14%,var(--surface))] text-ink dark:bg-accent-tint\"\n >\n {mode === \"idle\" || mode === \"thinking\" ? (\n passage.original\n ) : mode === \"streaming\" ? (\n <StreamText\n text={passage.rewrite}\n onProgress={place}\n onDone={() => setMode(\"result\")}\n />\n ) : (\n passage.rewrite\n )}\n </span>\n </p>\n\n <div\n className=\"absolute top-0 left-0 z-10\"\n style={{\n transform: `translate3d(${anchor.x}px, ${anchor.y}px, 0) translateX(-50%)`,\n transition:\n \"transform 320ms cubic-bezier(0.77,0,0.175,1), opacity 180ms ease-out\",\n opacity: visible ? 1 : 0,\n pointerEvents: visible ? \"auto\" : \"none\",\n willChange: \"transform\",\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 <div\n ref={barRef}\n className=\"flex h-9 w-fit max-w-[calc(100vw-48px)] items-center justify-center gap-0.5 overflow-hidden rounded-full bg-surface p-1 font-sans font-normal text-ink shadow-overlay\"\n style={{\n width:\n mode === \"idle\" && hasPrompt && typingWidth\n ? typingWidth\n : undefined,\n ...(visible\n ? {\n animation:\n \"pop-in 220ms cubic-bezier(0.23,1,0.32,1) both\",\n }\n : {}),\n }}\n >\n <div\n ref={contentRef}\n className=\"flex w-fit shrink-0 items-center justify-center gap-0.5\"\n style={{\n width:\n mode === \"idle\" && hasPrompt && typingWidth\n ? typingWidth - 8\n : undefined,\n }}\n >\n {busy && (\n <span className=\"inline-flex h-7 items-center gap-1.5 whitespace-nowrap px-2.5 text-[12.5px] font-normal text-ink-2\">\n <span\n className=\"size-3 shrink-0 rounded-full border-[1.5px] border-line-strong border-t-ink-2\"\n style={{ animation: \"spin 700ms linear infinite\" }}\n />\n {mode === \"thinking\" ? (\n <Shimmer className=\"text-[12.5px] font-normal\">\n {busyLabel}…\n </Shimmer>\n ) : (\n <span>{busyLabel}…</span>\n )}\n </span>\n )}\n\n {mode === \"result\" && (\n <>\n <button\n type=\"button\"\n onClick={reset}\n className={primary}\n >\n {icons.check}\n {copy.keep}\n </button>\n <Button type=\"button\" variant=\"quiet\" size=\"xs\" className=\"shrink-0\" onClick={reset}>\n {icons.close}\n {copy.discard}\n </Button>\n <span className=\"mx-0.5 h-4 w-px shrink-0 bg-line\" />\n <button\n type=\"button\"\n aria-label=\"Try again\"\n onClick={() => 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 </button>\n </>\n )}\n\n {mode === \"idle\" && (\n <>\n <div\n className=\"flex min-w-0 items-center overflow-hidden transition-[max-width,opacity,transform] duration-400\"\n style={{\n maxWidth: expanded\n ? 0\n : hasPrompt && typingWidth\n ? typingWidth - 40\n : 145,\n opacity: expanded ? 0 : 1,\n transform: expanded ? \"translateX(-8px)\" : \"translateX(0)\",\n transitionTimingFunction: \"cubic-bezier(0.23,1,0.32,1)\",\n }}\n >\n <form\n className=\"flex h-7 shrink-0 items-center transition-[width] duration-400\"\n style={{\n width:\n hasPrompt && typingWidth ? typingWidth - 40 : 145,\n transitionTimingFunction: \"cubic-bezier(0.23,1,0.32,1)\",\n }}\n onSubmit={(event) => {\n event.preventDefault();\n run(prompt.trim() || \"Improve\");\n }}\n >\n <input\n value={prompt}\n onChange={(event) => {\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 </form>\n </div>\n\n <div\n className=\"flex min-w-0 items-center gap-0.5 overflow-hidden transition-[max-width,opacity,transform] duration-400\"\n style={{\n maxWidth: hasPrompt ? 0 : expanded ? 462 : 224,\n opacity: hasPrompt ? 0 : 1,\n transform: hasPrompt ? \"translateX(-8px)\" : \"translateX(0)\",\n transitionTimingFunction: \"cubic-bezier(0.23,1,0.32,1)\",\n }}\n >\n {!expanded && (\n <span className=\"mx-1 h-4 w-px shrink-0 bg-line-strong\" />\n )}\n {actions.primary.map((item) => (\n <Button\n key={item.id}\n type=\"button\"\n variant=\"quiet\"\n size=\"xs\"\n className=\"shrink-0\"\n onClick={item.action ? () => run(item.action!) : undefined}\n >\n {item.icon}\n {item.id}\n </Button>\n ))}\n\n <div\n className=\"flex min-w-0 items-center gap-0.5 overflow-hidden transition-[max-width,opacity,margin] duration-400\"\n style={{\n maxWidth: expanded ? 262 : 0,\n opacity: expanded ? 1 : 0,\n marginLeft: expanded ? 2 : 0,\n transitionTimingFunction: \"cubic-bezier(0.23,1,0.32,1)\",\n }}\n >\n {actions.more.map((item) => (\n <Button\n key={item.id}\n type=\"button\"\n variant=\"quiet\"\n size=\"xs\"\n className=\"shrink-0\"\n onClick={item.action ? () => run(item.action!) : undefined}\n >\n {item.icon}\n {item.id}\n </Button>\n ))}\n </div>\n\n <span className=\"mx-0.5 h-4 w-px shrink-0 bg-line\" />\n <button\n type=\"button\"\n aria-label={expanded ? \"Show fewer actions\" : \"Show more actions\"}\n aria-expanded={expanded}\n onClick={() => 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 <span\n className=\"flex transition-transform duration-400\"\n style={{\n transform: expanded ? \"rotate(180deg)\" : \"rotate(0deg)\",\n transitionTimingFunction: \"cubic-bezier(0.23,1,0.32,1)\",\n }}\n >\n {icons.chevron}\n </span>\n </button>\n </div>\n\n <div\n className=\"flex min-w-0 items-center overflow-hidden transition-[max-width,opacity,transform] duration-400\"\n style={{\n maxWidth: hasPrompt ? 30 : 0,\n opacity: hasPrompt ? 1 : 0,\n transform: hasPrompt ? \"scale(1)\" : \"scale(0.88)\",\n transitionTimingFunction: \"cubic-bezier(0.23,1,0.32,1)\",\n }}\n >\n <button\n type=\"button\"\n aria-label=\"Send edit instruction\"\n onClick={() => 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 </button>\n </div>\n </>\n )}\n </div>\n </div>\n </div>\n </div>\n\n </div>\n );\n}\n",
|
|
"type": "registry:component",
|
|
"target": "components/primitives/SelectionActions.tsx"
|
|
}
|
|
]
|
|
}
|