{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "use-clipboard-paste",
  "title": "useClipboardPaste",
  "description": "Catches pasted images, files and rich text — page-wide or scoped to a ref — draining clipboardData synchronously, gating files through accept/maxFiles with an itemised rejection list, plus an optional permission-checked active read.",
  "files": [
    {
      "path": "src/registry/hooks/use-clipboard-paste.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\n/**\n * Listening scope.\n * - `\"document\"` (default): a ⌘V anywhere on the page counts — the \"screenshot, paste, uploaded\" flow.\n * - a ref: only pastes whose **focus sits inside that element** count (decided with `contains`,\n *   not by attaching the listener to that element — see the hook's JSDoc).\n */\nexport type ClipboardPasteTarget = \"document\" | React.RefObject<Element | null>\n\n/**\n * Why a file was dropped. Branch UI copy on `reason`; `message` is a displayable English\n * fallback, but it is never localised.\n */\nexport type ClipboardRejectionReason = \"type-not-accepted\" | \"too-many-files\"\n\nexport interface ClipboardPasteRejection {\n  /** the original File comes along, so consumers can offer \"add anyway\" or \"upload another way\". */\n  file: File\n  reason: ClipboardRejectionReason\n  message: string\n}\n\nexport interface ClipboardPastePayload {\n  /** files that passed both gates (`accept` + `maxFiles`), in clipboard order. */\n  files: File[]\n  /** `text/plain`; `null` when `includeText: false` or the clipboard holds no plain text. */\n  text: string | null\n  /** `text/html` (usually present when copying from a web page — an Excel range, styled rich text). */\n  html: string | null\n  /** filtered-out files plus their reason. **Never dropped silently** — tell the user with these. */\n  rejected: ClipboardPasteRejection[]\n  /** `\"event\"` = the user pressed ⌘V; `\"read\"` = `readFromClipboard()` was called. */\n  source: \"event\" | \"read\"\n  /**\n   * The native event, `null` when `source === \"read\"`. `onPaste` runs **synchronously**\n   * inside the handler, so `payload.event?.preventDefault()` in the callback still works —\n   * call it to stop the image also being inserted into a contenteditable.\n   */\n  event: ClipboardEvent | null\n}\n\n/**\n * How `readFromClipboard()` can fail. **There is no \"pretend it worked\" outcome**:\n * - `disabled` — the hook is currently switched off with `disabled`, so it never touched the clipboard.\n * - `unsupported` — this browser has no `navigator.clipboard.read` (Firefox, for years).\n * - `insecure-context` — the page is not a secure context (plain http), so the Clipboard API is gone entirely.\n * - `document-not-focused` — the document has no focus (DevTools open, another tab), and the\n *   browser always refuses. Recoverable: click back into the page and press again.\n * - `permission-denied` — the user blocked `clipboard-read`, or the browser wanted a user\n *   gesture and this call was not inside one.\n * - `read-failed` — everything else (a format the browser would not hand over, an interrupted read).\n */\nexport type ClipboardReadErrorKind =\n  | \"disabled\"\n  | \"unsupported\"\n  | \"insecure-context\"\n  | \"document-not-focused\"\n  | \"permission-denied\"\n  | \"read-failed\"\n\nexport interface ClipboardReadError {\n  kind: ClipboardReadErrorKind\n  /** the browser's own text, or a built-in English one. **Branch on `kind`; never parse this.** */\n  message: string\n}\n\n/** Discriminated union: a payload only exists when `ok`, a failure always carries `error`, no middle state. */\nexport type ClipboardReadResult =\n  | { ok: true; payload: ClipboardPastePayload }\n  | { ok: false; error: ClipboardReadError }\n\nexport interface UseClipboardPasteOptions {\n  /**\n   * Called for each paste that actually carried something (empty pastes stay quiet — see the hook JSDoc).\n   * Held in a latest-ref, so an inline arrow function does not re-attach the listener every render.\n   */\n  onPaste?: (payload: ClipboardPastePayload) => void\n  /** Listening scope, `\"document\"` by default. */\n  target?: ClipboardPasteTarget\n  /**\n   * MIME allow-list in three flavours: wildcard `\"image/*\"`, exact `\"application/pdf\"`, and\n   * extension `\".png\"` (a screenshot's `file.type` is occasionally empty — the extension rule is its fallback).\n   * Omitted or empty means no filtering. Filtered files land in `payload.rejected`.\n   */\n  accept?: string[]\n  /**\n   * How many files one paste may keep. The overflow goes to `rejected` with reason `\"too-many-files\"`.\n   * `0` is **legal and meaningful**: `maxFiles={capacity - used}` is exactly 0 once capacity runs out,\n   * and then the whole batch is rejected with \"no capacity left\". Negatives are treated as 0;\n   * NaN / Infinity mean no limit.\n   */\n  maxFiles?: number\n  /** Whether to hand over `text/plain` and `text/html` too. `true` by default. */\n  includeText?: boolean\n  /**\n   * With `target: \"document\"`, whether to **also** capture pastes coming from an input /\n   * textarea / contenteditable. `false` by default — otherwise pasting a word into a search\n   * box counts as an upload. Ignored when `target` is a ref: you already drew the boundary\n   * yourself (pasting a screenshot into a chat composer is exactly that case).\n   */\n  captureInInputs?: boolean\n  /** When true no listener is attached and `readFromClipboard()` returns a `disabled` error. `lastPaste` is left alone. */\n  disabled?: boolean\n}\n\nexport interface UseClipboardPasteResult {\n  /** Whether this environment can receive paste events at all. `false` during SSR and the first hydration frame. */\n  isSupported: boolean\n  /**\n   * Whether `navigator.clipboard.read` exists (the active read). **True does not mean it will\n   * succeed** — it also needs a user gesture, permission and a focused document, and you only\n   * learn the real answer by calling.\n   */\n  canRead: boolean\n  /** The most recently delivered payload, `null` if nothing was ever captured. Same object handed to `onPaste`. */\n  lastPaste: ClipboardPastePayload | null\n  /** Read the clipboard on demand. Stable identity, safe in a dependency array. Never throws — failures come back as `ok: false`. */\n  readFromClipboard: () => Promise<ClipboardReadResult>\n}\n\ninterface ResolvedSettings {\n  target: ClipboardPasteTarget\n  rules: string[]\n  limit: number\n  includeText: boolean\n  captureInInputs: boolean\n  disabled: boolean\n}\n\nconst EDITABLE_SELECTOR = \"input, textarea, select, [contenteditable]\"\n\nconst subscribeNoop = () => () => {}\n\nfunction detectPasteSupport(): boolean {\n  return typeof document !== \"undefined\" && typeof window !== \"undefined\" && \"ClipboardEvent\" in window\n}\n\nfunction detectClipboardRead(): boolean {\n  return typeof navigator !== \"undefined\" && typeof navigator.clipboard?.read === \"function\"\n}\n\nfunction normalizeAccept(accept: string[] | undefined): string[] {\n  if (!accept) return []\n  return accept.map(rule => rule.trim().toLowerCase()).filter(Boolean)\n}\n\nfunction normalizeMaxFiles(maxFiles: number | undefined): number {\n  // NaN / Infinity mean \"no limit\"; a negative means 0 (capacity exhausted, reject the batch).\n  if (maxFiles === undefined || !Number.isFinite(maxFiles)) return Number.POSITIVE_INFINITY\n  return Math.max(0, Math.floor(maxFiles))\n}\n\n/** Three rule shapes: `\".png\"` extension, `\"image/*\"` wildcard, `\"application/pdf\"` exact MIME. */\nfunction matchesAccept(file: File, rules: string[]): boolean {\n  if (rules.length === 0) return true\n  const type = file.type.toLowerCase()\n  const name = file.name.toLowerCase()\n  return rules.some(rule => {\n    if (rule === \"*\" || rule === \"*/*\") return true\n    if (rule.startsWith(\".\")) return name.endsWith(rule)\n    if (rule.endsWith(\"/*\")) return type.startsWith(rule.slice(0, -1))\n    return type === rule\n  })\n}\n\nfunction describeFile(file: File): string {\n  return `${file.name || \"unnamed file\"} (${file.type || \"unknown type\"})`\n}\n\nfunction filterFiles(\n  incoming: File[],\n  rules: string[],\n  limit: number,\n): { files: File[]; rejected: ClipboardPasteRejection[] } {\n  const files: File[] = []\n  const rejected: ClipboardPasteRejection[] = []\n\n  for (const file of incoming) {\n    // order is deliberate: a file that fails the type check **does not consume** a maxFiles slot,\n    // otherwise one rejected attachment would push out images that still had room.\n    if (!matchesAccept(file, rules)) {\n      rejected.push({\n        file,\n        reason: \"type-not-accepted\",\n        message: `${describeFile(file)} does not match accept: ${rules.join(\", \")}`,\n      })\n      continue\n    }\n    if (files.length >= limit) {\n      rejected.push({\n        file,\n        reason: \"too-many-files\",\n        message:\n          limit === 0\n            ? `${describeFile(file)} was dropped: no capacity left (maxFiles is 0)`\n            : `${describeFile(file)} was dropped: at most ${limit} file${limit === 1 ? \"\" : \"s\"} per paste`,\n      })\n      continue\n    }\n    files.push(file)\n  }\n\n  return { files, rejected }\n}\n\n/**\n * **Must run synchronously.** `DataTransferItem.getAsFile()` is only valid during the tick the\n * event is dispatched; after any `await` / `setTimeout` the `items` list is cleared and\n * `getAsFile()` silently returns null — the classic Clipboard API trap that looks like\n * \"the code is fine, the files just never arrive\".\n */\nfunction collectFiles(data: DataTransfer): File[] {\n  const files: File[] = []\n  const items = data.items\n  if (items) {\n    for (let i = 0; i < items.length; i += 1) {\n      const item = items[i]\n      if (item.kind !== \"file\") continue\n      const file = item.getAsFile()\n      if (file) files.push(file)\n    }\n  }\n  // fallback: some browsers / sources leave `items` empty but fill `files` (equally event-scoped).\n  if (files.length === 0 && data.files) {\n    for (let i = 0; i < data.files.length; i += 1) files.push(data.files[i])\n  }\n  return files\n}\n\nfunction isEditableTarget(origin: EventTarget | null): boolean {\n  if (!(origin instanceof Element)) return false\n  const editable = origin.closest(EDITABLE_SELECTOR)\n  if (!editable) return false\n  const attr = editable.getAttribute(\"contenteditable\")\n  // `contenteditable=\"false\"` explicitly opts out of editing, so it is not an input.\n  if (attr !== null) return attr !== \"false\"\n  return true\n}\n\nfunction extensionForType(type: string): string {\n  const subtype = type.split(\"/\")[1] ?? \"bin\"\n  return subtype.split(\"+\")[0] || \"bin\"\n}\n\nfunction readError(kind: ClipboardReadErrorKind, message: string): ClipboardReadResult {\n  return { ok: false, error: { kind, message } }\n}\n\nfunction toReadError(reason: unknown): ClipboardReadError {\n  const name = reason instanceof Error ? reason.name : \"\"\n  const message = reason instanceof Error ? reason.message : String(reason)\n  if (name === \"NotAllowedError\") {\n    // Chromium reports \"document not focused\" as NotAllowedError too. The `hasFocus()` check up\n    // front catches nearly all of it; sniffing the message is the heuristic backstop — fall back\n    // to permission-denied when it does not match.\n    return /focus/i.test(message)\n      ? { kind: \"document-not-focused\", message }\n      : { kind: \"permission-denied\", message }\n  }\n  return { kind: \"read-failed\", message: message || \"navigator.clipboard.read() failed\" }\n}\n\n/**\n * Catches images / files / rich text that get **pasted in**: screenshot then ⌘V straight into an\n * upload box, a range from Excel pasted into a grid. `use-copy-to-clipboard` writes out; this\n * hook reads back in.\n *\n * **Two intake paths, one payload shape**:\n * 1. the passive `paste` event (no permission at all — the user presses ⌘V and it is there);\n * 2. the active `readFromClipboard()` (`navigator.clipboard.read()`, which needs a user gesture\n *    plus permission plus a focused document). Both run through the same `accept` / `maxFiles`\n *    gates and produce identically shaped payloads; only `source` differs.\n *\n * **`getAsFile()` has to be called synchronously**: `event.clipboardData.items` only lives for\n * the tick the event is dispatched, and after any `await` it hands back null. So the handler\n * **drains everything synchronously first** (files, text/plain, text/html in one pass) and only\n * then filters and calls setState — this is the number-one way to get this API wrong.\n *\n * **The listener always sits on `document`; scope is decided with `contains`**, rather than\n * attaching it to the element the ref points at. A ref object is read once, at the moment the\n * effect runs, so if the element is conditionally rendered (null first, or replaced by a key\n * change) the attach-to-element version would watch a node that does not exist or is detached.\n * Reading `ref.current` during the event is correct by construction, and a changing `target`\n * needs no re-attach. The trade-off, stated plainly: if anything calls `stopPropagation()` on\n * the paste event on the way up, this hook never sees it.\n *\n * **The document-wide listener skips inputs by default**: with `target: \"document\"`, pastes from\n * an input / textarea / contenteditable do not count (otherwise pasting a word into a search box\n * reads as an upload); switch them on with `captureInInputs`. No such filter when `target` is a\n * ref — you already drew the boundary.\n *\n * **Empty pastes stay quiet**: when files, text, html and rejected are all empty, neither\n * `onPaste` fires nor `lastPaste` updates. The typical case is plain text pasted under\n * `includeText: false` — as far as this hook is concerned, nothing happened.\n *\n * **Capability detection never runs during render**: `isSupported` / `canRead` go through\n * `useSyncExternalStore` with a server snapshot fixed at `false`, and React swaps in the real\n * value after hydration without a warning.\n */\nexport function useClipboardPaste(options: UseClipboardPasteOptions = {}): UseClipboardPasteResult {\n  const {\n    onPaste,\n    target = \"document\",\n    accept,\n    maxFiles,\n    includeText = true,\n    captureInInputs = false,\n    disabled = false,\n  } = options\n\n  const isSupported = React.useSyncExternalStore(subscribeNoop, detectPasteSupport, () => false)\n  const canRead = React.useSyncExternalStore(subscribeNoop, detectClipboardRead, () => false)\n\n  const [lastPaste, setLastPaste] = React.useState<ClipboardPastePayload | null>(null)\n\n  const settings: ResolvedSettings = {\n    target,\n    rules: normalizeAccept(accept),\n    limit: normalizeMaxFiles(maxFiles),\n    includeText,\n    captureInInputs,\n    disabled,\n  }\n\n  // latest-ref: neither options nor callback enter a dependency array. Consumers almost always\n  // pass an inline options literal plus an inline arrow, which would re-attach the listener every\n  // render. Seeded here so a child calling readFromClipboard() from its mount effect does not\n  // read a ref that has not been written yet.\n  const settingsRef = React.useRef(settings)\n  const onPasteRef = React.useRef(onPaste)\n  const mountedRef = React.useRef(false)\n\n  React.useEffect(() => {\n    settingsRef.current = settings\n    onPasteRef.current = onPaste\n  })\n\n  React.useEffect(() => {\n    // set true on every mount: only clearing it in cleanup would leave a live instance marked\n    // unmounted forever under StrictMode's mount → cleanup → mount.\n    mountedRef.current = true\n    return () => {\n      mountedRef.current = false\n    }\n  }, [])\n\n  const deliver = React.useCallback((payload: ClipboardPastePayload) => {\n    if (\n      payload.files.length === 0 &&\n      payload.text === null &&\n      payload.html === null &&\n      payload.rejected.length === 0\n    ) {\n      return\n    }\n    if (!mountedRef.current) return\n    setLastPaste(payload)\n    onPasteRef.current?.(payload)\n  }, [])\n\n  const handlePaste = React.useCallback(\n    (event: ClipboardEvent) => {\n      const {\n        target: scope,\n        rules,\n        limit,\n        includeText: wantsText,\n        captureInInputs: inInputs,\n      } = settingsRef.current\n\n      if (scope === \"document\") {\n        if (!inInputs && isEditableTarget(event.target)) return\n      } else {\n        const node = scope.current\n        // do nothing while the scope element is unmounted; read during the event, so conditional rendering stays accurate.\n        if (!node) return\n        if (!(event.target instanceof Node) || !node.contains(event.target)) return\n      }\n\n      const data = event.clipboardData\n      if (!data) return\n\n      // ↓↓↓ this block must run synchronously, no await: clipboardData expires with this tick.\n      const incoming = collectFiles(data)\n      const text = wantsText ? data.getData(\"text/plain\") : \"\"\n      const html = wantsText ? data.getData(\"text/html\") : \"\"\n      // ↑↑↑ everything is drained; async is fine from here on.\n\n      const { files, rejected } = filterFiles(incoming, rules, limit)\n      deliver({\n        files,\n        text: text || null,\n        html: html || null,\n        rejected,\n        source: \"event\",\n        event,\n      })\n    },\n    [deliver],\n  )\n\n  React.useEffect(() => {\n    if (disabled || !isSupported) return\n    document.addEventListener(\"paste\", handlePaste)\n    return () => document.removeEventListener(\"paste\", handlePaste)\n  }, [disabled, isSupported, handlePaste])\n\n  const readFromClipboard = React.useCallback(async (): Promise<ClipboardReadResult> => {\n    const { rules, limit, includeText: wantsText, disabled: off } = settingsRef.current\n\n    if (off) return readError(\"disabled\", \"This useClipboardPaste instance is disabled.\")\n    if (typeof navigator === \"undefined\" || typeof navigator.clipboard?.read !== \"function\") {\n      return readError(\n        \"unsupported\",\n        \"navigator.clipboard.read() does not exist in this browser — only the paste event is available here.\",\n      )\n    }\n    if (typeof window !== \"undefined\" && window.isSecureContext === false) {\n      return readError(\"insecure-context\", \"The Clipboard API needs a secure context (HTTPS or localhost).\")\n    }\n    // check up front instead of waiting for the throw: Chromium reports \"not focused\" as\n    // NotAllowedError too, and folding that into permission-denied sends users to site settings\n    // when clicking back into the page was the fix.\n    if (typeof document !== \"undefined\" && !document.hasFocus()) {\n      return readError(\n        \"document-not-focused\",\n        \"The document is not focused (DevTools or another tab has focus), so the browser refuses to read the clipboard.\",\n      )\n    }\n\n    let items: ClipboardItems\n    try {\n      items = await navigator.clipboard.read()\n    } catch (reason) {\n      return { ok: false, error: toReadError(reason) }\n    }\n\n    const incoming: File[] = []\n    let text: string | null = null\n    let html: string | null = null\n\n    for (const item of items) {\n      for (const type of item.types) {\n        const isPlain = type === \"text/plain\"\n        const isHtml = type === \"text/html\"\n        if (!wantsText && (isPlain || isHtml)) continue\n        // only non-text/* types become Files. Turning text/uri-list into a file is pointless.\n        if (!isPlain && !isHtml && type.startsWith(\"text/\")) continue\n        try {\n          const blob = await item.getType(type)\n          if (isPlain) text = ((text ?? \"\") + (await blob.text())) || null\n          else if (isHtml) html = ((html ?? \"\") + (await blob.text())) || null\n          else {\n            incoming.push(\n              new File([blob], `pasted-${type.split(\"/\")[0]}.${extensionForType(type)}`, {\n                type: blob.type || type,\n              }),\n            )\n          }\n        } catch {\n          // one unreadable type (the browser will not hand over that format) should not fail the whole read — skip it.\n        }\n      }\n    }\n\n    const { files, rejected } = filterFiles(incoming, rules, limit)\n    const payload: ClipboardPastePayload = {\n      files,\n      text,\n      html,\n      rejected,\n      source: \"read\",\n      event: null,\n    }\n    // a successful read of a clipboard holding nothing usable is still ok — permission was\n    // granted, there was just no content. Consumers check for an empty payload themselves;\n    // never report \"empty\" as \"failed\".\n    deliver(payload)\n    return { ok: true, payload }\n  }, [deliver])\n\n  return { isSupported, canRead, lastPaste, readFromClipboard }\n}\n\nexport default useClipboardPaste\n",
      "type": "registry:hook"
    }
  ],
  "type": "registry:hook"
}