{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "document-viewer",
  "title": "Document Viewer",
  "description": "Dialog viewer for images and PDFs: zoom, drag-to-pan, multi-page navigation, download, pdf.js rendering with iframe fallback; InlineDocumentView for embedding without the dialog.",
  "dependencies": [
    "cn",
    "lucide-react"
  ],
  "registryDependencies": [
    "@sav/button",
    "@sav/dialog",
    "@sav/pdf-pages"
  ],
  "files": [
    {
      "path": "registry/sav/components/document-viewer.tsx",
      "content": "// @sav-registry: document-viewer\n\"use client\";\n\n// In-app viewer for uploaded scans and documents. The consumer mints the URL\n// (typically a short-TTL signed link, per click, never stored) and the browser\n// loads it inline: images render in an <img> with zoom + drag-to-pan; PDFs\n// render through pdf-pages.tsx (pdf.js draws every page to a canvas, stacked\n// and scrollable, zoom shared with the image toolbar; a native <iframe> only as\n// the fallback when pdf.js cannot load the file). Anything else falls back to\n// a download prompt. Theme-aware via design tokens only.\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport {\n  ChevronLeftIcon,\n  ChevronRightIcon,\n  DownloadIcon,\n  FileIcon,\n  RotateCcwIcon,\n  XIcon,\n  ZoomInIcon,\n  ZoomOutIcon,\n} from \"lucide-react\";\n\nimport { cn } from \"cn\";\n\nimport { Button, buttonVariants } from \"@/registry/sav/ui/button\";\nimport {\n  Dialog,\n  DialogClose,\n  DialogContent,\n  DialogTitle,\n} from \"@/registry/sav/ui/dialog\";\nimport { PdfPages } from \"@/registry/sav/components/pdf-pages\";\n\nconst MIN_ZOOM = 0.25;\nconst MAX_ZOOM = 4;\nconst ZOOM_STEP = 0.25;\n\nconst IMAGE_EXTENSIONS = new Set([\"jpg\", \"jpeg\", \"png\", \"webp\", \"gif\", \"avif\"]);\n\ntype DocumentKind = \"image\" | \"pdf\" | \"unknown\";\n\nfunction inferKind(fileName: string | null): DocumentKind {\n  const ext = fileName?.split(\".\").pop()?.toLowerCase() ?? \"\";\n  if (ext === \"pdf\") return \"pdf\";\n  if (IMAGE_EXTENSIONS.has(ext)) return \"image\";\n  return \"unknown\";\n}\n\nfunction snapZoom(value: number): number {\n  const stepped = Math.round(value / ZOOM_STEP) * ZOOM_STEP;\n  return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, stepped));\n}\n\nexport interface ViewerPage {\n  url: string;\n  downloadUrl: string;\n  fileName: string | null;\n}\n\nexport interface DocumentViewerProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  url: string | null;\n  /** Attachment-disposition link for the Download button; falls back to `url`. */\n  downloadUrl?: string | null;\n  fileName: string | null;\n  /** Multi-page scans (e.g. front and back of a card): when 2+ pages are\n   *  given, the toolbar grows prev/next navigation and `url`/`fileName` are\n   *  ignored. */\n  pages?: ViewerPage[] | null;\n}\n\nexport function DocumentViewer({\n  open,\n  onOpenChange,\n  url,\n  downloadUrl,\n  fileName,\n  pages,\n}: DocumentViewerProps) {\n  const [pageIndex, setPageIndex] = useState(0);\n  const paged = pages && pages.length > 0 ? pages : null;\n  const boundedIndex = paged ? Math.min(pageIndex, paged.length - 1) : 0;\n  const current = paged\n    ? paged[boundedIndex]\n    : { url, downloadUrl: downloadUrl ?? url, fileName };\n\n  return (\n    <Dialog\n      open={open}\n      onOpenChange={(next) => {\n        if (!next) setPageIndex(0);\n        onOpenChange(next);\n      }}\n    >\n      <DialogContent\n        showCloseButton={false}\n        className=\"flex h-[90vh] max-h-[90vh] w-[95vw] max-w-5xl flex-col gap-0 overflow-hidden p-0 sm:max-w-5xl\"\n      >\n        <DialogTitle className=\"sr-only\">{current.fileName ?? \"Document\"}</DialogTitle>\n        {/* Keyed by url so each freshly-minted link mounts a clean view (zoom=1). */}\n        <InlineDocumentView\n          key={current.url ?? \"empty\"}\n          url={current.url}\n          downloadUrl={current.downloadUrl ?? current.url}\n          fileName={current.fileName}\n          pageSlot={\n            paged && paged.length > 1 ? (\n              <>\n                <Button\n                  variant=\"ghost\"\n                  size=\"icon-sm\"\n                  onClick={() => setPageIndex(boundedIndex - 1)}\n                  disabled={boundedIndex === 0}\n                  aria-label=\"Previous page\"\n                >\n                  <ChevronLeftIcon className=\"size-4\" />\n                </Button>\n                <span className=\"text-xs tabular-nums text-muted-foreground\">\n                  {boundedIndex + 1}/{paged.length}\n                </span>\n                <Button\n                  variant=\"ghost\"\n                  size=\"icon-sm\"\n                  onClick={() => setPageIndex(boundedIndex + 1)}\n                  disabled={boundedIndex >= paged.length - 1}\n                  aria-label=\"Next page\"\n                >\n                  <ChevronRightIcon className=\"size-4\" />\n                </Button>\n                <span className=\"mx-1 h-5 w-px bg-border\" aria-hidden />\n              </>\n            ) : null\n          }\n          closeSlot={\n            <DialogClose\n              render={<Button variant=\"ghost\" size=\"icon-sm\" aria-label=\"Close\" />}\n            >\n              <XIcon className=\"size-4\" />\n            </DialogClose>\n          }\n        />\n      </DialogContent>\n    </Dialog>\n  );\n}\n\n/**\n * Dialog-free viewer body — also usable inline, e.g. in an upload wizard's\n * review pane. `closeSlot` lets the dialog wrapper inject its close button.\n */\nexport function InlineDocumentView({\n  url,\n  downloadUrl,\n  fileName,\n  pageSlot,\n  closeSlot,\n}: {\n  url: string | null;\n  downloadUrl: string | null;\n  fileName: string | null;\n  /** Page navigation controls injected by a multi-page wrapper. */\n  pageSlot?: React.ReactNode;\n  closeSlot?: React.ReactNode;\n}) {\n  const kind = inferKind(fileName);\n  const label = fileName ?? \"Document\";\n\n  const [zoom, setZoom] = useState(1);\n  const [offset, setOffset] = useState({ x: 0, y: 0 });\n  const [dragging, setDragging] = useState(false);\n  // pdf.js could not load the file (network, malformed): native viewer instead.\n  const [pdfFallback, setPdfFallback] = useState(false);\n\n  const containerRef = useRef<HTMLDivElement>(null);\n  const draggingRef = useRef(false);\n  const dragStart = useRef({ x: 0, y: 0, ox: 0, oy: 0 });\n\n  const setZoomClamped = useCallback((next: number) => {\n    const nz = snapZoom(next);\n    setZoom(nz);\n    // No panning while fit-to-frame — recentre on the way back to 100%.\n    setOffset((current) => (nz <= 1 ? { x: 0, y: 0 } : current));\n  }, []);\n\n  function resetView() {\n    setZoom(1);\n    setOffset({ x: 0, y: 0 });\n  }\n\n  // Ctrl/Cmd + wheel to zoom (non-passive so the page doesn't scroll instead).\n  // `zoom` is a dependency so the listener always sees the current level.\n  useEffect(() => {\n    const el = containerRef.current;\n    if (!el || kind !== \"image\") return;\n    function onWheel(event: WheelEvent) {\n      if (!event.ctrlKey && !event.metaKey) return;\n      event.preventDefault();\n      setZoomClamped(zoom + (event.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP));\n    }\n    el.addEventListener(\"wheel\", onWheel, { passive: false });\n    return () => el.removeEventListener(\"wheel\", onWheel);\n  }, [kind, zoom, setZoomClamped]);\n\n  function onPointerDown(event: React.PointerEvent<HTMLDivElement>) {\n    if (zoom <= 1) return;\n    event.preventDefault();\n    event.currentTarget.setPointerCapture(event.pointerId);\n    draggingRef.current = true;\n    setDragging(true);\n    dragStart.current = { x: event.clientX, y: event.clientY, ox: offset.x, oy: offset.y };\n  }\n\n  function onPointerMove(event: React.PointerEvent<HTMLDivElement>) {\n    if (!draggingRef.current) return;\n    setOffset({\n      x: dragStart.current.ox + (event.clientX - dragStart.current.x),\n      y: dragStart.current.oy + (event.clientY - dragStart.current.y),\n    });\n  }\n\n  function endDrag(event: React.PointerEvent<HTMLDivElement>) {\n    if (!draggingRef.current) return;\n    draggingRef.current = false;\n    setDragging(false);\n    event.currentTarget.releasePointerCapture?.(event.pointerId);\n  }\n\n  const isDefaultView = zoom === 1 && offset.x === 0 && offset.y === 0;\n  const downloadClasses = buttonVariants({ variant: \"outline\", size: \"sm\" });\n  const zoomable = kind === \"image\" || (kind === \"pdf\" && !pdfFallback);\n\n  return (\n    <>\n      {/* Toolbar */}\n      <div className=\"flex items-center gap-2 border-b border-border bg-popover px-3 py-2\">\n        <div className=\"flex min-w-0 flex-1 items-center gap-2\">\n          <FileIcon className=\"size-4 shrink-0 text-muted-foreground\" aria-hidden />\n          <span className=\"truncate text-sm font-medium\" title={label}>\n            {label}\n          </span>\n        </div>\n        <div className=\"flex items-center gap-1\">\n          {pageSlot}\n          {zoomable ? (\n            <>\n              <Button\n                variant=\"ghost\"\n                size=\"icon-sm\"\n                onClick={() => setZoomClamped(zoom - ZOOM_STEP)}\n                disabled={zoom <= MIN_ZOOM}\n                aria-label=\"Zoom out\"\n              >\n                <ZoomOutIcon className=\"size-4\" />\n              </Button>\n              <span className=\"w-12 text-center text-xs tabular-nums text-muted-foreground\">\n                {Math.round(zoom * 100)}%\n              </span>\n              <Button\n                variant=\"ghost\"\n                size=\"icon-sm\"\n                onClick={() => setZoomClamped(zoom + ZOOM_STEP)}\n                disabled={zoom >= MAX_ZOOM}\n                aria-label=\"Zoom in\"\n              >\n                <ZoomInIcon className=\"size-4\" />\n              </Button>\n              <Button\n                variant=\"ghost\"\n                size=\"icon-sm\"\n                onClick={resetView}\n                disabled={isDefaultView}\n                aria-label=\"Reset view\"\n              >\n                <RotateCcwIcon className=\"size-4\" />\n              </Button>\n              <span className=\"mx-1 h-5 w-px bg-border\" aria-hidden />\n            </>\n          ) : null}\n          {downloadUrl ? (\n            <a\n              href={downloadUrl}\n              download={fileName ?? undefined}\n              target=\"_blank\"\n              rel=\"noopener\"\n              className={downloadClasses}\n            >\n              <DownloadIcon data-icon=\"inline-start\" />\n              Download\n            </a>\n          ) : null}\n          {closeSlot}\n        </div>\n      </div>\n\n      {/* Content */}\n      <div className=\"relative flex-1 overflow-hidden bg-muted/30\">\n        {!url ? (\n          <div className=\"flex h-full items-center justify-center text-sm text-muted-foreground\">\n            Loading…\n          </div>\n        ) : kind === \"pdf\" && !pdfFallback ? (\n          <PdfPages url={url} zoom={zoom} label={label} onError={() => setPdfFallback(true)} />\n        ) : kind === \"pdf\" ? (\n          <iframe src={url} title={label} className=\"h-full w-full border-0\" />\n        ) : kind === \"image\" ? (\n          <div\n            ref={containerRef}\n            onPointerDown={onPointerDown}\n            onPointerMove={onPointerMove}\n            onPointerUp={endDrag}\n            onPointerCancel={endDrag}\n            className={cn(\n              \"flex h-full w-full items-start justify-center overflow-hidden p-4\",\n              zoom > 1 ? (dragging ? \"cursor-grabbing\" : \"cursor-grab\") : \"cursor-default\",\n            )}\n          >\n            {/* eslint-disable-next-line @next/next/no-img-element */}\n            <img\n              src={url}\n              alt={`Preview of ${label}`}\n              draggable={false}\n              className=\"max-h-full max-w-full object-contain select-none\"\n              style={{\n                transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoom})`,\n                transformOrigin: \"center top\",\n                transition: dragging ? \"none\" : \"transform 150ms ease\",\n              }}\n            />\n          </div>\n        ) : (\n          <div className=\"flex h-full flex-col items-center justify-center gap-3 p-6 text-center\">\n            <FileIcon className=\"size-8 text-muted-foreground\" aria-hidden />\n            <p className=\"text-sm text-muted-foreground\">\n              Preview isn’t available for this file type.\n            </p>\n            <a\n              href={downloadUrl ?? url}\n              download={fileName ?? undefined}\n              target=\"_blank\"\n              rel=\"noopener\"\n              className={downloadClasses}\n            >\n              <DownloadIcon data-icon=\"inline-start\" />\n              Download to view\n            </a>\n          </div>\n        )}\n      </div>\n    </>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/document-viewer.tsx"
    }
  ],
  "categories": [
    "patterns"
  ],
  "type": "registry:component"
}