{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "smart-paste-input", "type": "registry:ui", "title": "Smart Paste Input", "description": "A chat composer that catches long pastes and turns them into an attachment card instead of flooding the input — click the card to open a full-screen editor with a live character count, edit the text in place, then Save or Remove.", "dependencies": [ "lucide-react" ], "registryDependencies": [ "button", "dialog", "textarea" ], "files": [ { "path": "registry/ruixenui/smart-paste-input.tsx", "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { ArrowRight, X } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface PasteAttachment {\n /** Stable, unique identifier. Generated for you when a paste is captured. */\n id: string;\n /** Full text of the attachment. This is what the editor reads and writes. */\n content: string;\n /** Card heading. Derived from the first line of `content` when omitted. */\n title?: string;\n /** Heading shown in the expanded editor. Default `\"Pasted text\"`. */\n label?: string;\n}\n\nexport interface SmartPasteSubmitPayload {\n /** Whatever is left in the input, trimmed. */\n text: string;\n /** Attachments riding along with the message. */\n attachments: PasteAttachment[];\n}\n\nexport interface SmartPasteInputProps {\n /** Controlled input text. Pair with `onValueChange`. */\n value?: string;\n /** Initial text when uncontrolled. */\n defaultValue?: string;\n /** Fires on every keystroke, and with `\"\"` after a submit. */\n onValueChange?: (value: string) => void;\n /** Controlled attachments. Pair with `onAttachmentsChange`. */\n attachments?: PasteAttachment[];\n /** Initial attachments when uncontrolled. */\n defaultAttachments?: PasteAttachment[];\n /** Fires whenever an attachment is captured, edited or removed. */\n onAttachmentsChange?: (attachments: PasteAttachment[]) => void;\n /** Fires on send (button, or `Enter` without `Shift`). */\n onSubmit?: (payload: SmartPasteSubmitPayload) => void;\n /** Input placeholder. Default `\"Ask anything...\"`. */\n placeholder?: string;\n /** A paste this long (in characters) becomes an attachment instead of inline text. Default `320`. */\n pasteThreshold?: number;\n /** A paste with at least this many lines becomes an attachment, however short. Default `8`. */\n pasteLineThreshold?: number;\n /** Cap on attachments. Pastes past the cap fall back to plain inline text. Default `4`. */\n maxAttachments?: number;\n /** Let the expanded editor write back. When `false` it is a read-only viewer. Default `true`. */\n editable?: boolean;\n /** Disables the input, the send button and paste capture. */\n disabled?: boolean;\n /** Tallest the input grows before it scrolls, in pixels. Default `160`. */\n maxInputHeight?: number;\n /** Classes for the send + save buttons. Override to reskin the accent. */\n accentClassName?: string;\n /** Accessible name for the composer. Default `\"Message\"`. */\n label?: string;\n className?: string;\n}\n\n// Theme tokens, not a hardcoded brand color: the send and save buttons follow\n// whatever `--primary` is set to, in light and dark alike. Pass\n// `accentClassName` to override (e.g. \"bg-blue-600 text-white hover:bg-blue-700\").\nconst DEFAULT_ACCENT = \"bg-primary text-primary-foreground hover:bg-primary/90\";\n\n// Strips the shadcn Textarea back to bare text: no border, no ring, no chrome.\n// The composer shell owns all of that.\nconst BARE_TEXTAREA =\n \"w-full resize-none border-0 bg-transparent px-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\";\n\n// Fades the last visible line of the card preview into the card's edge, so the\n// text reads as \"there is more of this\" instead of as a hard crop.\nconst PREVIEW_FADE = {\n maskImage: \"linear-gradient(to bottom, #000 45%, transparent 100%)\",\n WebkitMaskImage: \"linear-gradient(to bottom, #000 45%, transparent 100%)\",\n} as const;\n\nlet attachmentCount = 0;\n\n/** Heading for a pasted blob: its first meaningful line, stripped of markdown. */\nexport function derivePasteTitle(content: string, fallback = \"Pasted text\") {\n const first = content\n .split(\"\\n\")\n .map((line) => line.trim())\n .find(Boolean);\n if (!first) return fallback;\n const clean = first\n .replace(/^#{1,6}\\s+/, \"\")\n .replace(/^[-*+]\\s+/, \"\")\n .trim();\n if (!clean) return fallback;\n return clean.length > 64 ? `${clean.slice(0, 63).trimEnd()}…` : clean;\n}\n\n/** Body for the card: everything after the line that became the title. */\nfunction derivePastePreview(content: string) {\n const lines = content.split(\"\\n\");\n let i = 0;\n while (i < lines.length && !lines[i].trim()) i += 1;\n i += 1; // the title line itself\n while (i < lines.length && !lines[i].trim()) i += 1;\n const rest = lines.slice(i).join(\"\\n\").trim();\n return rest || content.trim();\n}\n\nfunction formatCount(count: number) {\n return `${count} ${count === 1 ? \"character\" : \"characters\"}`;\n}\n\n/** Uncontrolled state that steps aside the moment a `value` prop shows up. */\nfunction useControllable(\n controlled: T | undefined,\n fallback: T,\n onChange?: (value: T) => void,\n) {\n const [uncontrolled, setUncontrolled] = React.useState(fallback);\n const isControlled = controlled !== undefined;\n const value = isControlled ? (controlled as T) : uncontrolled;\n\n const valueRef = React.useRef(value);\n valueRef.current = value;\n\n const setValue = React.useCallback(\n (next: T | ((prev: T) => T)) => {\n const resolved =\n typeof next === \"function\"\n ? (next as (prev: T) => T)(valueRef.current)\n : next;\n if (!isControlled) setUncontrolled(resolved);\n onChange?.(resolved);\n },\n [isControlled, onChange],\n );\n\n return [value, setValue] as const;\n}\n\nexport function SmartPasteInput({\n value,\n defaultValue = \"\",\n onValueChange,\n attachments,\n defaultAttachments = [],\n onAttachmentsChange,\n onSubmit,\n placeholder = \"Ask anything...\",\n pasteThreshold = 320,\n pasteLineThreshold = 8,\n maxAttachments = 4,\n editable = true,\n disabled = false,\n maxInputHeight = 160,\n accentClassName = DEFAULT_ACCENT,\n label = \"Message\",\n className,\n}: SmartPasteInputProps) {\n const [text, setText] = useControllable(value, defaultValue, onValueChange);\n const [items, setItems] = useControllable(\n attachments,\n defaultAttachments,\n onAttachmentsChange,\n );\n const [openId, setOpenId] = React.useState(null);\n\n const inputRef = React.useRef(null);\n const openItem = items.find((item) => item.id === openId) ?? null;\n const canSubmit = !disabled && (text.trim().length > 0 || items.length > 0);\n\n // Grow with the content, then scroll — the send button stays pinned to the\n // bottom edge so it never drifts as the message gets taller.\n React.useLayoutEffect(() => {\n const node = inputRef.current;\n if (!node) return;\n node.style.height = \"0px\";\n node.style.height = `${Math.min(node.scrollHeight, maxInputHeight)}px`;\n }, [text, maxInputHeight]);\n\n const handlePaste = (event: React.ClipboardEvent) => {\n if (disabled) return;\n const pasted = event.clipboardData.getData(\"text/plain\");\n if (!pasted) return;\n\n const isLong =\n pasted.length >= pasteThreshold ||\n pasted.split(\"\\n\").length >= pasteLineThreshold;\n // Past the cap, fall through to a plain inline paste rather than silently\n // dropping the text on the floor.\n if (!isLong || items.length >= maxAttachments) return;\n\n event.preventDefault();\n attachmentCount += 1;\n setItems((prev) => [\n ...prev,\n { id: `paste-${attachmentCount}`, content: pasted },\n ]);\n };\n\n const handleKeyDown = (event: React.KeyboardEvent) => {\n if (\n event.key !== \"Enter\" ||\n event.shiftKey ||\n event.nativeEvent.isComposing\n )\n return;\n event.preventDefault();\n submit();\n };\n\n const submit = () => {\n if (!canSubmit) return;\n onSubmit?.({ text: text.trim(), attachments: items });\n setText(\"\");\n setItems([]);\n };\n\n const saveAttachment = (id: string, content: string) => {\n setItems((prev) =>\n prev.map((item) => (item.id === id ? { ...item, content } : item)),\n );\n setOpenId(null);\n };\n\n const removeAttachment = (id: string) => {\n setItems((prev) => prev.filter((item) => item.id !== id));\n setOpenId(null);\n };\n\n return (\n
\n \n {items.length > 0 && (\n
\n {items.map((item) => (\n setOpenId(item.id)}\n onRemove={() => removeAttachment(item.id)}\n />\n ))}\n
\n )}\n\n
\n setText(event.target.value)}\n className={cn(\n BARE_TEXTAREA,\n // 11 + 26 + 11 = 48: the send button's exact height, so one line\n // of text sits on the button's centerline.\n \"min-h-[48px] flex-1 py-[11px] pr-2\",\n \"text-[17px] leading-[26px] tracking-[-0.01em] text-foreground\",\n \"placeholder:text-muted-foreground/70\",\n )}\n style={{ maxHeight: maxInputHeight }}\n />\n\n \n \n \n
\n
\n\n setOpenId(null)}\n onSave={saveAttachment}\n onRemove={removeAttachment}\n />\n \n );\n}\n\nfunction AttachmentCard({\n attachment,\n disabled,\n onOpen,\n onRemove,\n}: {\n attachment: PasteAttachment;\n disabled?: boolean;\n onOpen: () => void;\n onRemove: () => void;\n}) {\n const title = attachment.title ?? derivePasteTitle(attachment.content);\n const preview = React.useMemo(\n () => derivePastePreview(attachment.content),\n [attachment.content],\n );\n\n return (\n
\n \n \n {title}\n \n \n {preview}\n \n \n\n \n \n \n
\n );\n}\n\nfunction AttachmentEditor({\n attachment,\n editable,\n accentClassName,\n onClose,\n onSave,\n onRemove,\n}: {\n attachment: PasteAttachment | null;\n editable: boolean;\n accentClassName: string;\n onClose: () => void;\n onSave: (id: string, content: string) => void;\n onRemove: (id: string) => void;\n}) {\n const [draft, setDraft] = React.useState(\"\");\n const [overflow, setOverflow] = React.useState({ top: false, bottom: false });\n // Outlives `attachment` by one close, so the dialog still has content to\n // render while Radix plays its exit animation.\n const [snapshot, setSnapshot] = React.useState(null);\n\n const editorRef = React.useRef(null);\n const open = attachment !== null;\n\n // Seed the draft on open. Everything typed after that is local until Save, so\n // Escape and a click on the overlay throw the edit away.\n React.useLayoutEffect(() => {\n if (!attachment) return;\n setSnapshot(attachment);\n setDraft(attachment.content);\n }, [attachment]);\n\n const syncOverflow = React.useCallback(() => {\n const node = editorRef.current;\n if (!node) return;\n setOverflow({\n top: node.scrollTop > 2,\n bottom: node.scrollTop + node.clientHeight < node.scrollHeight - 2,\n });\n }, []);\n\n const active = attachment ?? snapshot;\n const heading = active?.label ?? \"Pasted text\";\n const canSave = editable && draft.trim().length > 0;\n\n const commit = () => {\n if (!active) return;\n if (!editable) return onClose();\n if (!canSave) return;\n onSave(active.id, draft);\n };\n\n return (\n !next && onClose()}>\n {active && (\n button]:hidden\",\n )}\n onOpenAutoFocus={(event) => {\n event.preventDefault();\n const node = editorRef.current;\n if (!node) return;\n // Focusing a textarea parks the caret at the end, which scrolls a\n // long paste straight to its last line. Open at the top instead.\n node.focus();\n node.setSelectionRange(0, 0);\n node.scrollTop = 0;\n syncOverflow();\n }}\n onKeyDown={(event) => {\n if (event.key === \"Enter\" && (event.metaKey || event.ctrlKey)) {\n event.preventDefault();\n commit();\n }\n }}\n >\n \n \n {heading}\n \n \n {formatCount(draft.length)}\n \n \n\n
\n {\n setDraft(event.target.value);\n syncOverflow();\n }}\n className={cn(\n BARE_TEXTAREA,\n // Caps at 45vh so the dialog still fits on a short viewport.\n \"h-[300px] max-h-[45vh] py-0\",\n \"font-mono text-[15px] leading-[1.85] text-foreground\",\n )}\n />\n\n \n \n
\n\n \n onRemove(active.id)}\n // -ml-3 cancels the ghost button's padding, so the label lines up\n // with the left edge of the text above it.\n className=\"-ml-3 text-[15px] font-normal text-muted-foreground hover:text-foreground\"\n >\n Remove\n \n\n \n {editable ? \"Save\" : \"Close\"}\n \n \n \n )}\n
\n );\n}\n\nexport default SmartPasteInput;\n", "type": "registry:ui", "target": "components/ruixen/smart-paste-input.tsx" } ] }