{
  "$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<T>(\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<string | null>(null);\n\n  const inputRef = React.useRef<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => {\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<HTMLTextAreaElement>) => {\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    <div className={cn(\"w-full max-w-[720px]\", className)}>\n      <div\n        className={cn(\n          // Padding is symmetric on all four sides, and the input is exactly as\n          // tall as the send button — that alone centers the button in the row.\n          \"rounded-[28px] border border-border/80 bg-card p-2 shadow-md\",\n          \"transition-colors focus-within:border-border\",\n          disabled && \"opacity-60\",\n        )}\n      >\n        {items.length > 0 && (\n          <div className=\"flex flex-wrap gap-3 px-2 pb-2 pt-1.5\">\n            {items.map((item) => (\n              <AttachmentCard\n                key={item.id}\n                attachment={item}\n                disabled={disabled}\n                onOpen={() => setOpenId(item.id)}\n                onRemove={() => removeAttachment(item.id)}\n              />\n            ))}\n          </div>\n        )}\n\n        <div className=\"flex items-end gap-2 pl-4\">\n          <Textarea\n            ref={inputRef}\n            rows={1}\n            value={text}\n            disabled={disabled}\n            aria-label={label}\n            placeholder={placeholder}\n            onPaste={handlePaste}\n            onKeyDown={handleKeyDown}\n            onChange={(event) => 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          <Button\n            size=\"icon\"\n            onClick={submit}\n            disabled={!canSubmit}\n            aria-label=\"Send message\"\n            className={cn(\n              \"h-12 w-12 shrink-0 rounded-full transition-all duration-200 ease-out\",\n              canSubmit\n                ? cn(\n                    accentClassName,\n                    \"shadow-md hover:scale-[1.04] active:scale-95\",\n                  )\n                : // A washed-out accent reads as a broken brand color. Rest in\n                  // neutral instead, so \"nothing to send\" looks deliberate.\n                  \"bg-muted text-muted-foreground disabled:opacity-100\",\n            )}\n          >\n            <ArrowRight className=\"size-5\" strokeWidth={2.25} />\n          </Button>\n        </div>\n      </div>\n\n      <AttachmentEditor\n        attachment={openItem}\n        editable={editable}\n        accentClassName={accentClassName}\n        onClose={() => setOpenId(null)}\n        onSave={saveAttachment}\n        onRemove={removeAttachment}\n      />\n    </div>\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    <div className=\"group relative\">\n      <button\n        type=\"button\"\n        onClick={onOpen}\n        disabled={disabled}\n        aria-label={`Open attachment: ${title}`}\n        className={cn(\n          \"flex h-[104px] w-[256px] flex-col overflow-hidden rounded-2xl\",\n          \"border border-border/80 bg-card px-4 py-3 text-left shadow-sm\",\n          \"transition-all duration-200 ease-out\",\n          \"hover:-translate-y-px hover:border-border hover:shadow-md\",\n          \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50\",\n          \"disabled:pointer-events-none\",\n        )}\n      >\n        <span className=\"w-full truncate text-[15px] font-semibold tracking-[-0.01em] text-foreground\">\n          {title}\n        </span>\n        <span\n          className=\"mt-1 flex-1 overflow-hidden whitespace-pre-wrap break-words text-[13px] leading-[1.5] text-muted-foreground\"\n          style={PREVIEW_FADE}\n        >\n          {preview}\n        </span>\n      </button>\n\n      <Button\n        size=\"icon\"\n        variant=\"outline\"\n        onClick={onRemove}\n        disabled={disabled}\n        aria-label={`Remove attachment: ${title}`}\n        className={cn(\n          \"absolute -right-1.5 -top-1.5 size-6 rounded-full bg-background p-0\",\n          \"text-muted-foreground shadow-sm hover:text-foreground\",\n          // Always visible where there is no hover to reveal it (touch), then\n          // hover-revealed from sm up so the card stays clean on a desktop.\n          \"transition-opacity duration-150 sm:opacity-0\",\n          \"sm:group-hover:opacity-100 sm:focus-visible:opacity-100\",\n        )}\n      >\n        <X className=\"size-3.5\" strokeWidth={2.25} />\n      </Button>\n    </div>\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<PasteAttachment | null>(null);\n\n  const editorRef = React.useRef<HTMLTextAreaElement>(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    <Dialog open={open} onOpenChange={(next) => !next && onClose()}>\n      {active && (\n        <DialogContent\n          className={cn(\n            \"gap-0 rounded-[26px] border-border/70 bg-card px-7 pb-5 pt-6\",\n            \"sm:max-w-[600px]\",\n            // The dialog's own corner ✕ is redundant here: Remove and Save are\n            // the only two ways out, and Escape still closes.\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          <DialogHeader className=\"flex-row items-baseline justify-between gap-4 space-y-0\">\n            <DialogTitle className=\"text-[19px] tracking-[-0.01em]\">\n              {heading}\n            </DialogTitle>\n            <DialogDescription className=\"shrink-0 text-[14px] tabular-nums\">\n              {formatCount(draft.length)}\n            </DialogDescription>\n          </DialogHeader>\n\n          <div className=\"relative mt-4\">\n            <Textarea\n              ref={editorRef}\n              value={draft}\n              readOnly={!editable}\n              spellCheck={false}\n              onScroll={syncOverflow}\n              aria-label={`${heading} content`}\n              onChange={(event) => {\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            <div\n              aria-hidden\n              className={cn(\n                \"pointer-events-none absolute inset-x-0 top-0 h-8\",\n                \"bg-gradient-to-b from-card to-transparent transition-opacity duration-200\",\n                overflow.top ? \"opacity-100\" : \"opacity-0\",\n              )}\n            />\n            <div\n              aria-hidden\n              className={cn(\n                \"pointer-events-none absolute inset-x-0 bottom-0 h-20\",\n                \"bg-gradient-to-t from-card via-card/85 to-transparent transition-opacity duration-200\",\n                overflow.bottom ? \"opacity-100\" : \"opacity-0\",\n              )}\n            />\n          </div>\n\n          <DialogFooter className=\"mt-4 flex-row items-center justify-between sm:justify-between\">\n            <Button\n              variant=\"ghost\"\n              onClick={() => 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            </Button>\n\n            <Button\n              onClick={commit}\n              disabled={editable && !canSave}\n              className={cn(\n                \"h-auto rounded-full px-6 py-2.5 text-[15px] shadow-sm\",\n                \"transition-all duration-200 ease-out hover:scale-[1.02] active:scale-[0.98]\",\n                accentClassName,\n              )}\n            >\n              {editable ? \"Save\" : \"Close\"}\n            </Button>\n          </DialogFooter>\n        </DialogContent>\n      )}\n    </Dialog>\n  );\n}\n\nexport default SmartPasteInput;\n",
      "type": "registry:ui",
      "target": "components/ruixen/smart-paste-input.tsx"
    }
  ]
}