{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "image-viewer",
  "title": "Image Viewer",
  "description": "A full-screen photo viewer with pinch and double-tap zoom, swipe sideways to change photo, swipe down to close, arrow keys, buttons and a spoken position.",
  "dependencies": [
    "@radix-ui/react-dialog",
    "@use-gesture/react",
    "lucide-react"
  ],
  "registryDependencies": [
    "@tealmac/pinch-zoom"
  ],
  "files": [
    {
      "path": "registry/tealmac/image-viewer.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport * as DialogPrimitive from \"@radix-ui/react-dialog\"\nimport { useDrag } from \"@use-gesture/react\"\nimport { ChevronLeftIcon, ChevronRightIcon, XIcon } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { PinchZoom } from \"@/registry/tealmac/pinch-zoom\"\n\nexport interface ViewerImage {\n  src: string\n  alt: string\n  caption?: string\n}\n\nexport interface ImageViewerProps {\n  images: ViewerImage[]\n  open: boolean\n  onOpenChange: (open: boolean) => void\n  /** Index of the image shown. */\n  index: number\n  onIndexChange: (index: number) => void\n  className?: string\n}\n\n/**\n * A full-screen photo viewer. Pinch or double tap to zoom; at normal size,\n * swipe sideways to change photos and swipe down to close (the backdrop fades\n * as you go). Buttons and the keyboard do the same: Left and Right change the\n * photo, + and - zoom, Escape closes. The count is announced as you move.\n */\nexport function ImageViewer({\n  images,\n  open,\n  onOpenChange,\n  index,\n  onIndexChange,\n  className,\n}: ImageViewerProps) {\n  const [zoomed, setZoomed] = React.useState(false)\n  const [offset, setOffset] = React.useState({ x: 0, y: 0 })\n  const [dragging, setDragging] = React.useState(false)\n  const image = images[index]\n  const last = images.length - 1\n\n  const go = (to: number) => {\n    if (to < 0 || to > last) return\n    setZoomed(false)\n    onIndexChange(to)\n  }\n\n  const bind = useDrag(\n    ({\n      first,\n      last: done,\n      movement: [mx, my],\n      velocity: [vx, vy],\n      direction: [dx],\n      event,\n    }) => {\n      if (zoomed || event.type.startsWith(\"key\")) return\n      if (first) setDragging(true)\n      const vertical = Math.abs(my) > Math.abs(mx)\n      const next = vertical ? { x: 0, y: Math.max(0, my) } : { x: mx, y: 0 }\n      if (!done) {\n        setOffset(next)\n        return\n      }\n      setDragging(false)\n      setOffset({ x: 0, y: 0 })\n      if (vertical) {\n        if (my > 120 || vy > 0.8) onOpenChange(false)\n      } else if (Math.abs(mx) > 80 || vx > 0.6) {\n        const rtl = document.dir === \"rtl\"\n        const forward = dx < 0 !== rtl\n        go(index + (forward ? 1 : -1))\n      }\n    },\n    { threshold: 4, pointer: { touch: true, capture: false }, enabled: !zoomed }\n  )\n\n  if (!image) return null\n  const fade = 1 - Math.min(0.6, offset.y / 400)\n\n  return (\n    <DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>\n      <DialogPrimitive.Portal>\n        <DialogPrimitive.Overlay\n          className=\"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black motion-reduce:animate-none\"\n          style={{ opacity: fade }}\n        />\n        <DialogPrimitive.Content\n          data-slot=\"image-viewer\"\n          onKeyDown={(event) => {\n            if (event.key === \"ArrowLeft\" || event.key === \"ArrowRight\") {\n              if (zoomed) return\n              const rtl = document.dir === \"rtl\"\n              const forward = (event.key === \"ArrowRight\") !== rtl\n              go(index + (forward ? 1 : -1))\n              event.preventDefault()\n            }\n          }}\n          className={cn(\n            \"fixed inset-0 z-50 flex flex-col text-white outline-none\",\n            className\n          )}\n        >\n          <DialogPrimitive.Title className=\"sr-only\">\n            {image.alt}\n          </DialogPrimitive.Title>\n          <DialogPrimitive.Description className=\"sr-only\">\n            Photo {index + 1} of {images.length}.\n          </DialogPrimitive.Description>\n          <div className=\"flex items-center justify-between p-3 pt-[max(0.75rem,env(safe-area-inset-top))]\">\n            <span\n              aria-hidden\n              className=\"rounded-full bg-white/10 px-3 py-1 text-sm\"\n            >\n              {index + 1} / {images.length}\n            </span>\n            <DialogPrimitive.Close className=\"grid size-11 place-items-center rounded-full bg-white/10 outline-none hover:bg-white/20 focus-visible:ring-2 focus-visible:ring-white\">\n              <XIcon className=\"size-5\" />\n              <span className=\"sr-only\">Close</span>\n            </DialogPrimitive.Close>\n          </div>\n          <div\n            {...bind()}\n            className={cn(\n              \"relative min-h-0 flex-1\",\n              !zoomed && \"touch-none\",\n              !dragging &&\n                \"transition-transform duration-200 motion-reduce:transition-none\"\n            )}\n            style={{ transform: `translate(${offset.x}px, ${offset.y}px)` }}\n          >\n            <PinchZoom\n              key={image.src}\n              label={image.alt}\n              className=\"size-full\"\n              onScaleChange={(s) => setZoomed(s > 1)}\n            >\n              <img\n                src={image.src}\n                alt={image.alt}\n                draggable={false}\n                className=\"max-h-full max-w-full object-contain\"\n              />\n            </PinchZoom>\n          </div>\n          {image.caption && (\n            <p className=\"px-4 pt-2 pb-[max(1rem,env(safe-area-inset-bottom))] text-center text-sm text-white/80\">\n              {image.caption}\n            </p>\n          )}\n          {images.length > 1 && (\n            <>\n              <button\n                type=\"button\"\n                aria-label=\"Previous photo\"\n                disabled={index === 0}\n                onClick={() => go(index - 1)}\n                className=\"absolute start-2 top-1/2 grid size-11 -translate-y-1/2 place-items-center rounded-full bg-white/10 outline-none hover:bg-white/20 focus-visible:ring-2 focus-visible:ring-white disabled:opacity-30\"\n              >\n                <ChevronLeftIcon className=\"size-5 rtl:rotate-180\" />\n              </button>\n              <button\n                type=\"button\"\n                aria-label=\"Next photo\"\n                disabled={index === last}\n                onClick={() => go(index + 1)}\n                className=\"absolute end-2 top-1/2 grid size-11 -translate-y-1/2 place-items-center rounded-full bg-white/10 outline-none hover:bg-white/20 focus-visible:ring-2 focus-visible:ring-white disabled:opacity-30\"\n              >\n                <ChevronRightIcon className=\"size-5 rtl:rotate-180\" />\n              </button>\n            </>\n          )}\n          <div role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n            Photo {index + 1} of {images.length}: {image.alt}\n          </div>\n        </DialogPrimitive.Content>\n      </DialogPrimitive.Portal>\n    </DialogPrimitive.Root>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}