{"$schema":"https:\/\/ui.particle.academy\/schema\/registry-item.json","name":"image-viewer","type":"registry:ui","title":"ImageViewer","description":"Fit-to-container image with zoom \/ pan and a transparency checkerboard.","package":"react-fancy","dependencies":[],"registryDependencies":[],"files":[{"path":"components\/fancy\/image-viewer\/ImageViewer.tsx","content":"import {\n  forwardRef,\n  useCallback,\n  useRef,\n  useState,\n  type CSSProperties,\n} from \"react\";\nimport { cn } from \"..\/..\/utils\/cn\";\nimport { usePanZoom } from \"..\/..\/hooks\/use-pan-zoom\";\nimport type { ImageViewerProps, ImageViewerViewport } from \".\/ImageViewer.types\";\n\nconst DEFAULT_VIEWPORT: ImageViewerViewport = { panX: 0, panY: 0, zoom: 1 };\n\nconst fitClasses: Record<NonNullable<ImageViewerProps[\"fit\"]>, string> = {\n  contain: \"h-full w-full object-contain\",\n  cover: \"h-full w-full object-cover\",\n  none: \"max-w-none object-none\",\n};\n\nfunction clamp(n: number, min: number, max: number): number {\n  return Math.min(max, Math.max(min, n));\n}\n\n\/**\n * A standalone image viewer: fits the image to its container, with optional\n * Ctrl\/\u2318+wheel zoom, click-drag panning, and a transparency checkerboard. Works\n * anywhere on its own and is also what `<MediaViewer>` renders for images.\n *\n * The pan\/zoom viewport is controllable (`viewport` + `onViewportChange`) so an\n * agent bridge can drive it; it's uncontrolled by default.\n *\n * ```tsx\n * <ImageViewer src=\"\/logo.png\" alt=\"logo.png\" style={{ height: 320 }} \/>\n * ```\n *\/\nexport const ImageViewer = forwardRef<HTMLDivElement, ImageViewerProps>(\n  (\n    {\n      src,\n      alt = \"\",\n      fit = \"contain\",\n      zoomable = true,\n      pannable = true,\n      controls,\n      minZoom = 0.25,\n      maxZoom = 8,\n      checkerboard = true,\n      viewport,\n      defaultViewport,\n      onViewportChange,\n      onLoad,\n      onError,\n      className,\n      style,\n    },\n    ref,\n  ) => {\n    const containerRef = useRef<HTMLDivElement>(null);\n    const [status, setStatus] = useState<\"loading\" | \"loaded\" | \"error\">(\"loading\");\n\n    \/\/ Controllable viewport supporting functional updates (usePanZoom needs them).\n    const isControlled = viewport !== undefined;\n    const [internal, setInternal] = useState<ImageViewerViewport>(\n      defaultViewport ?? DEFAULT_VIEWPORT,\n    );\n    const current = isControlled ? viewport! : internal;\n    const currentRef = useRef(current);\n    currentRef.current = current;\n\n    const setViewport = useCallback(\n      (\n        next:\n          | ImageViewerViewport\n          | ((prev: ImageViewerViewport) => ImageViewerViewport),\n      ) => {\n        const resolved =\n          typeof next === \"function\" ? next(currentRef.current) : next;\n        if (!isControlled) setInternal(resolved);\n        onViewportChange?.(resolved);\n      },\n      [isControlled, onViewportChange],\n    );\n\n    const interactive = status === \"loaded\";\n    const { containerProps, isPanning } = usePanZoom({\n      viewport: current,\n      setViewport,\n      minZoom,\n      maxZoom,\n      pannable: pannable && interactive,\n      zoomable: zoomable && interactive,\n      containerRef,\n    });\n\n    \/\/ Zoom about the container centre \u2014 used by the +\/\u2212 buttons.\n    const zoomBy = useCallback(\n      (factor: number) => {\n        const el = containerRef.current;\n        if (!el) return;\n        const rect = el.getBoundingClientRect();\n        const cx = rect.width \/ 2;\n        const cy = rect.height \/ 2;\n        setViewport((prev) => {\n          const newZoom = clamp(prev.zoom * factor, minZoom, maxZoom);\n          const ratio = newZoom \/ prev.zoom;\n          return {\n            zoom: newZoom,\n            panX: cx - (cx - prev.panX) * ratio,\n            panY: cy - (cy - prev.panY) * ratio,\n          };\n        });\n      },\n      [setViewport, minZoom, maxZoom],\n    );\n\n    const reset = useCallback(() => setViewport(DEFAULT_VIEWPORT), [setViewport]);\n\n    const showControls = (controls ?? zoomable) && interactive;\n    const transform = `translate(${current.panX}px, ${current.panY}px) scale(${current.zoom})`;\n\n    return (\n      <div\n        ref={ref}\n        data-react-fancy-image-viewer=\"\"\n        className={cn(\n          \"relative isolate overflow-hidden rounded-md select-none\",\n          checkerboard\n            ? \"fancy-checkerboard\"\n            : \"bg-zinc-100 dark:bg-zinc-900\",\n          className,\n        )}\n        style={style}\n      >\n        <div\n          ref={containerRef}\n          data-canvas-bg=\"\"\n          className={cn(\n            \"absolute inset-0 flex items-center justify-center\",\n            isPanning\n              ? \"cursor-grabbing\"\n              : pannable && interactive\n                ? \"cursor-grab\"\n                : \"\",\n          )}\n          style={\n            {\n              transform,\n              transformOrigin: \"0 0\",\n              willChange: \"transform\",\n              touchAction: zoomable || pannable ? \"none\" : undefined,\n            } as CSSProperties\n          }\n          onDoubleClick={zoomable ? reset : undefined}\n          {...containerProps}\n        >\n          <img\n            src={src}\n            alt={alt}\n            draggable={false}\n            onLoad={() => {\n              setStatus(\"loaded\");\n              onLoad?.();\n            }}\n            onError={() => {\n              setStatus(\"error\");\n              onError?.();\n            }}\n            className={cn(\n              \"pointer-events-none block\",\n              status === \"error\" && \"hidden\",\n              fitClasses[fit],\n            )}\n          \/>\n        <\/div>\n\n        {status === \"error\" && (\n          <div\n            data-react-fancy-image-viewer-error=\"\"\n            className=\"absolute inset-0 flex flex-col items-center justify-center gap-1 p-4 text-center text-sm text-zinc-500 dark:text-zinc-400\"\n          >\n            <span className=\"font-medium\">Couldn&apos;t load image<\/span>\n            <span className=\"max-w-full truncate text-xs opacity-70\">{alt || src}<\/span>\n          <\/div>\n        )}\n\n        {showControls && (\n          <div\n            data-react-fancy-image-viewer-controls=\"\"\n            className=\"absolute bottom-2 right-2 flex items-center gap-0.5 rounded-md border border-zinc-200 bg-white\/90 p-0.5 text-zinc-700 shadow-sm backdrop-blur dark:border-zinc-700 dark:bg-zinc-900\/90 dark:text-zinc-200\"\n          >\n            <button\n              type=\"button\"\n              aria-label=\"Zoom out\"\n              onClick={() => zoomBy(1 \/ 1.25)}\n              className=\"flex h-6 w-6 items-center justify-center rounded text-base leading-none hover:bg-zinc-100 dark:hover:bg-zinc-800\"\n            >\n              \u2212\n            <\/button>\n            <button\n              type=\"button\"\n              aria-label=\"Reset zoom\"\n              onClick={reset}\n              className=\"min-w-[3rem] rounded px-1 text-center text-xs tabular-nums hover:bg-zinc-100 dark:hover:bg-zinc-800\"\n            >\n              {Math.round(current.zoom * 100)}%\n            <\/button>\n            <button\n              type=\"button\"\n              aria-label=\"Zoom in\"\n              onClick={() => zoomBy(1.25)}\n              className=\"flex h-6 w-6 items-center justify-center rounded text-base leading-none hover:bg-zinc-100 dark:hover:bg-zinc-800\"\n            >\n              +\n            <\/button>\n          <\/div>\n        )}\n      <\/div>\n    );\n  },\n);\n\nImageViewer.displayName = \"ImageViewer\";\n","type":"registry:ui","target":"components\/fancy\/image-viewer\/ImageViewer.tsx"},{"path":"components\/fancy\/image-viewer\/ImageViewer.types.ts","content":"import type { CSSProperties } from \"react\";\n\n\/** Pan offset (in container pixels) + zoom factor for an `<ImageViewer>`. *\/\nexport interface ImageViewerViewport {\n  panX: number;\n  panY: number;\n  zoom: number;\n}\n\nexport interface ImageViewerProps {\n  \/** Image source \u2014 an `http(s):`, `data:`, or `blob:` URL. *\/\n  src: string;\n  \/** Alt text for the image (also used as the download filename hint). *\/\n  alt?: string;\n  \/**\n   * How the image sits in its container at zoom 1. `\"contain\"` (default) fits\n   * the whole image, `\"cover\"` fills and crops, `\"none\"` shows it at natural\n   * size (pan to see the rest).\n   *\/\n  fit?: \"contain\" | \"cover\" | \"none\";\n  \/** Allow Ctrl\/\u2318+wheel zoom and the zoom controls (default `true`). *\/\n  zoomable?: boolean;\n  \/** Allow click-drag panning (default `true`). *\/\n  pannable?: boolean;\n  \/** Show the floating zoom controls. Defaults to the value of `zoomable`. *\/\n  controls?: boolean;\n  \/** Minimum zoom factor (default `0.25`). *\/\n  minZoom?: number;\n  \/** Maximum zoom factor (default `8`). *\/\n  maxZoom?: number;\n  \/**\n   * Paint a checkerboard behind the image so transparency reads clearly\n   * (default `true`). Set `false` for a flat themed surface.\n   *\/\n  checkerboard?: boolean;\n  \/**\n   * Controlled viewport (pan + zoom). Pair with `onViewportChange`. Omit for\n   * uncontrolled internal state. Exposed so an agent can pan\/zoom to point at\n   * something via a bridge.\n   *\/\n  viewport?: ImageViewerViewport;\n  \/** Initial viewport when uncontrolled. *\/\n  defaultViewport?: ImageViewerViewport;\n  \/** Called whenever the viewport (pan\/zoom) changes. *\/\n  onViewportChange?: (viewport: ImageViewerViewport) => void;\n  \/** Fired when the image finishes loading. *\/\n  onLoad?: () => void;\n  \/** Fired when the image fails to load. *\/\n  onError?: () => void;\n  \/** Additional CSS classes for the viewport container. *\/\n  className?: string;\n  \/** Inline styles for the viewport container (e.g. a fixed height). *\/\n  style?: CSSProperties;\n}\n","type":"registry:ui","target":"components\/fancy\/image-viewer\/ImageViewer.types.ts"},{"path":"components\/fancy\/image-viewer\/index.ts","content":"export { ImageViewer } from \".\/ImageViewer\";\nexport type { ImageViewerProps, ImageViewerViewport } from \".\/ImageViewer.types\";\n","type":"registry:ui","target":"components\/fancy\/image-viewer\/index.ts"}]}