{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pdf-viewer",
  "title": "PDF Viewer",
  "description": "Page-by-page PDF rendering on a canvas, with paging and zoom, over any loader.",
  "dependencies": [
    "lucide-react@^1.31.0",
    "pdfjs-dist@^6.2.108"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/default/pdf-viewer/pdf-viewer.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  ChevronLeft,\n  ChevronRight,\n  TriangleAlert,\n  ZoomIn,\n  ZoomOut,\n} from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type PdfPageHandle = {\n  width: number\n  height: number\n  render: (canvas: HTMLCanvasElement, scale: number) => Promise<void>\n}\n\nexport type PdfDocumentHandle = {\n  pageCount: number\n  getPage: (pageNumber: number) => Promise<PdfPageHandle>\n  destroy?: () => void\n}\n\nexport type PdfLoader = (\n  source: string | ArrayBuffer\n) => Promise<PdfDocumentHandle>\n\nexport type PdfViewerProps = Omit<\n  React.HTMLAttributes<HTMLElement>,\n  \"children\"\n> & {\n  source?: string | ArrayBuffer\n  document?: PdfDocumentHandle\n  loader?: PdfLoader\n  page?: number\n  defaultPage?: number\n  onPageChange?: (page: number) => void\n  defaultScale?: number\n  minScale?: number\n  maxScale?: number\n  label?: string\n  loadingLabel?: React.ReactNode\n  workerSrc?: string\n}\n\n/** Hold the loading state back so a fast result never flashes it. */\nconst LOADING_DELAY_MS = 120\n\nconst MISSING_PDFJS =\n  'PdfViewer needs the \"pdfjs-dist\" package, or a `loader` prop that returns a document handle.'\n\nasync function loadWithPdfjs(\n  source: string | ArrayBuffer,\n  workerSrc?: string\n): Promise<PdfDocumentHandle> {\n  let pdfjs: typeof import(\"pdfjs-dist\")\n\n  try {\n    pdfjs = await import(\"pdfjs-dist\")\n  } catch {\n    throw new Error(MISSING_PDFJS)\n  }\n\n  if (workerSrc) pdfjs.GlobalWorkerOptions.workerSrc = workerSrc\n\n  const task = pdfjs.getDocument(\n    typeof source === \"string\"\n      ? { url: source }\n      : { data: new Uint8Array(source) }\n  )\n  const pdf = await task.promise\n\n  return {\n    pageCount: pdf.numPages,\n    destroy: () => void task.destroy(),\n    async getPage(pageNumber) {\n      const page = await pdf.getPage(pageNumber)\n      const viewport = page.getViewport({ scale: 1 })\n\n      return {\n        width: viewport.width,\n        height: viewport.height,\n        async render(canvas, scale) {\n          const scaled = page.getViewport({ scale })\n          const context = canvas.getContext(\"2d\")\n          if (!context) return\n\n          canvas.width = scaled.width\n          canvas.height = scaled.height\n\n          await page.render({\n            canvas,\n            canvasContext: context,\n            viewport: scaled,\n          }).promise\n        },\n      }\n    },\n  }\n}\n\nexport function PdfViewer({\n  source,\n  document: controlledDocument,\n  loader,\n  page,\n  defaultPage = 1,\n  onPageChange,\n  defaultScale = 1,\n  minScale = 0.5,\n  maxScale = 3,\n  label = \"PDF document\",\n  loadingLabel = \"Opening the document…\",\n  workerSrc,\n  className,\n  ...rootProps\n}: PdfViewerProps) {\n  const canvasRef = React.useRef<HTMLCanvasElement>(null)\n  const [loaded, setLoaded] = React.useState<PdfDocumentHandle | null>(null)\n  const [error, setError] = React.useState<string | null>(null)\n  const [loading, setLoading] = React.useState(false)\n  const [scale, setScale] = React.useState(defaultScale)\n  const [uncontrolledPage, setUncontrolledPage] = React.useState(defaultPage)\n\n  const current = page ?? uncontrolledPage\n  const handle = controlledDocument ?? loaded\n  const pageCount = handle?.pageCount ?? 0\n\n  const load = React.useRef(loader)\n  React.useEffect(() => {\n    load.current = loader\n  })\n\n  React.useEffect(() => {\n    if (controlledDocument || source == null) return\n\n    const controller = new AbortController()\n    const spinner = window.setTimeout(() => setLoading(true), LOADING_DELAY_MS)\n    let opened: PdfDocumentHandle | null = null\n\n    void (async () => {\n      try {\n        const open =\n          load.current ??\n          ((input: string | ArrayBuffer) => loadWithPdfjs(input, workerSrc))\n        opened = await open(source)\n        if (controller.signal.aborted) return\n        setLoaded(opened)\n        setError(null)\n      } catch (cause) {\n        if (controller.signal.aborted) return\n        setError(cause instanceof Error ? cause.message : String(cause))\n      } finally {\n        window.clearTimeout(spinner)\n        if (!controller.signal.aborted) setLoading(false)\n      }\n    })()\n\n    return () => {\n      controller.abort()\n      window.clearTimeout(spinner)\n      opened?.destroy?.()\n    }\n  }, [source, controlledDocument, workerSrc])\n\n  React.useEffect(() => {\n    if (!handle || !canvasRef.current) return\n\n    const controller = new AbortController()\n    const canvas = canvasRef.current\n\n    void (async () => {\n      try {\n        const target = await handle.getPage(current)\n        if (controller.signal.aborted) return\n        await target.render(canvas, scale)\n      } catch (cause) {\n        if (controller.signal.aborted) return\n        setError(cause instanceof Error ? cause.message : String(cause))\n      }\n    })()\n\n    return () => controller.abort()\n  }, [handle, current, scale])\n\n  const goTo = (next: number) => {\n    const clamped = Math.min(Math.max(next, 1), Math.max(pageCount, 1))\n    if (page === undefined) setUncontrolledPage(clamped)\n    onPageChange?.(clamped)\n  }\n\n  if (error) {\n    return (\n      <section\n        data-slot=\"pdf-viewer\"\n        data-state=\"error\"\n        className={cn(\n          \"border-border bg-card text-destructive flex items-center gap-2 rounded-[var(--radius)] border px-4 py-3 text-sm\",\n          className\n        )}\n        {...rootProps}\n      >\n        <TriangleAlert aria-hidden=\"true\" size={15} className=\"shrink-0\" />\n        <p role=\"alert\">{error}</p>\n      </section>\n    )\n  }\n\n  return (\n    <section\n      data-slot=\"pdf-viewer\"\n      data-state={loading ? \"loading\" : \"ready\"}\n      aria-label={label}\n      aria-busy={loading || undefined}\n      className={cn(\n        \"border-border bg-card text-card-foreground overflow-hidden rounded-[var(--radius)] border\",\n        className\n      )}\n      {...rootProps}\n    >\n      <div\n        data-slot=\"pdf-viewer-toolbar\"\n        className=\"border-border flex flex-wrap items-center gap-2 border-b px-3 py-2\"\n      >\n        <div className=\"flex items-center gap-1\">\n          <button\n            type=\"button\"\n            aria-label=\"Previous page\"\n            disabled={current <= 1}\n            className=\"hover:bg-muted focus-visible:ring-ring inline-flex size-9 items-center justify-center rounded-full transition-colors duration-150 focus-visible:ring-2 focus-visible:outline-none disabled:opacity-40 motion-reduce:transition-none\"\n            onClick={() => goTo(current - 1)}\n          >\n            <ChevronLeft aria-hidden=\"true\" size={15} />\n          </button>\n\n          <p\n            aria-live=\"polite\"\n            className=\"font-[family-name:var(--font-mono),monospace] text-xs tabular-nums\"\n          >\n            {pageCount > 0 ? `${current} / ${pageCount}` : \"—\"}\n          </p>\n\n          <button\n            type=\"button\"\n            aria-label=\"Next page\"\n            disabled={pageCount === 0 || current >= pageCount}\n            className=\"hover:bg-muted focus-visible:ring-ring inline-flex size-9 items-center justify-center rounded-full transition-colors duration-150 focus-visible:ring-2 focus-visible:outline-none disabled:opacity-40 motion-reduce:transition-none\"\n            onClick={() => goTo(current + 1)}\n          >\n            <ChevronRight aria-hidden=\"true\" size={15} />\n          </button>\n        </div>\n\n        <div className=\"ml-auto flex items-center gap-1\">\n          <button\n            type=\"button\"\n            aria-label=\"Zoom out\"\n            disabled={scale <= minScale}\n            className=\"hover:bg-muted focus-visible:ring-ring inline-flex size-9 items-center justify-center rounded-full transition-colors duration-150 focus-visible:ring-2 focus-visible:outline-none disabled:opacity-40 motion-reduce:transition-none\"\n            onClick={() =>\n              setScale((value) => Math.max(value - 0.25, minScale))\n            }\n          >\n            <ZoomOut aria-hidden=\"true\" size={15} />\n          </button>\n\n          <p className=\"text-muted-foreground w-12 text-center font-[family-name:var(--font-mono),monospace] text-xs tabular-nums\">\n            {Math.round(scale * 100)}%\n          </p>\n\n          <button\n            type=\"button\"\n            aria-label=\"Zoom in\"\n            disabled={scale >= maxScale}\n            className=\"hover:bg-muted focus-visible:ring-ring inline-flex size-9 items-center justify-center rounded-full transition-colors duration-150 focus-visible:ring-2 focus-visible:outline-none disabled:opacity-40 motion-reduce:transition-none\"\n            onClick={() =>\n              setScale((value) => Math.min(value + 0.25, maxScale))\n            }\n          >\n            <ZoomIn aria-hidden=\"true\" size={15} />\n          </button>\n        </div>\n      </div>\n\n      <div className=\"bg-muted max-h-[30rem] overflow-auto p-4\">\n        {loading && !handle ? (\n          <p className=\"text-muted-foreground py-6 text-center text-sm\">\n            {loadingLabel}\n          </p>\n        ) : null}\n\n        <canvas\n          ref={canvasRef}\n          data-slot=\"pdf-viewer-canvas\"\n          role=\"img\"\n          aria-label={`${label}, page ${current}`}\n          className=\"mx-auto block max-w-full shadow-sm\"\n        />\n      </div>\n    </section>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "document",
    "pdf",
    "viewer",
    "canvas"
  ],
  "type": "registry:ui"
}