{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "image-cropper",
  "title": "Image Cropper",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-react/components/image-cropper/image-cropper.tsx",
      "content": "'use client'\n\nimport * as React from 'react'\nimport { cn } from '@/lib/utils'\n\nfunction coverScale(vw: number, vh: number, nw: number, nh: number) {\n  if (!nw || !nh) return 1\n  return Math.max(vw / nw, vh / nh)\n}\n\nfunction clamp(n: number, min: number, max: number) {\n  return Math.min(max, Math.max(min, n))\n}\n\nexport interface ImageCropperProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {\n  src: string\n  alt?: string\n  aspectRatio?: number\n  zoom?: number\n  defaultZoom?: number\n  onZoomChange?: (zoom: number) => void\n  minZoom?: number\n  maxZoom?: number\n  disabled?: boolean\n  showZoom?: boolean\n  rounded?: 'lg' | 'full'\n}\n\nexport interface ImageCropperHandle {\n  getCroppedCanvas: () => HTMLCanvasElement | null\n  getCroppedBlob: (type?: string, quality?: number) => Promise<Blob | null>\n}\n\nconst ImageCropper = React.forwardRef<ImageCropperHandle, ImageCropperProps>(\n  (\n    {\n      src,\n      alt = '',\n      aspectRatio = 1,\n      zoom: zoomProp,\n      defaultZoom = 1,\n      onZoomChange,\n      minZoom = 1,\n      maxZoom = 4,\n      disabled = false,\n      showZoom = false,\n      rounded = 'lg',\n      className,\n      ...props\n    },\n    ref,\n  ) => {\n    const viewportRef = React.useRef<HTMLDivElement>(null)\n    const imgRef = React.useRef<HTMLImageElement>(null)\n    const [uncontrolledZoom, setUncontrolledZoom] = React.useState(defaultZoom)\n    const zoom = zoomProp ?? uncontrolledZoom\n    const [pan, setPan] = React.useState({ x: 0, y: 0 })\n    const [natural, setNatural] = React.useState({ w: 0, h: 0 })\n    const dragging = React.useRef(false)\n    const lastPointer = React.useRef({ x: 0, y: 0 })\n    const panRef = React.useRef(pan)\n    panRef.current = pan\n\n    const setZoom = React.useCallback(\n      (value: number) => {\n        const next = clamp(value, minZoom, maxZoom)\n        onZoomChange?.(next)\n        if (zoomProp === undefined) setUncontrolledZoom(next)\n      },\n      [maxZoom, minZoom, onZoomChange, zoomProp],\n    )\n\n    const clampPan = React.useCallback(\n      (next = panRef.current) => {\n        const el = viewportRef.current\n        if (!el || !natural.w) return next\n        const vw = el.clientWidth\n        const vh = el.clientHeight\n        const scale = coverScale(vw, vh, natural.w, natural.h) * zoom\n        const dw = natural.w * scale\n        const dh = natural.h * scale\n        const maxX = Math.abs(vw - dw) / 2\n        const maxY = Math.abs(vh - dh) / 2\n        return { x: clamp(next.x, -maxX, maxX), y: clamp(next.y, -maxY, maxY) }\n      },\n      [natural.h, natural.w, zoom],\n    )\n\n    React.useEffect(() => {\n      setPan((p) => clampPan(p))\n    }, [clampPan, zoom])\n\n    const imgStyle = React.useMemo(() => {\n      const el = viewportRef.current\n      if (!el || !natural.w) return { transform: 'translate(-50%, -50%)' }\n      const scale = coverScale(el.clientWidth, el.clientHeight, natural.w, natural.h) * zoom\n      return {\n        width: `${natural.w * scale}px`,\n        height: `${natural.h * scale}px`,\n        transform: `translate(calc(-50% + ${pan.x}px), calc(-50% + ${pan.y}px))`,\n      }\n    }, [natural.h, natural.w, pan.x, pan.y, zoom])\n\n    const cropCanvas = React.useCallback(() => {\n      const el = viewportRef.current\n      const img = imgRef.current\n      if (!el || !img || !natural.w) return null\n      const vw = el.clientWidth\n      const vh = el.clientHeight\n      const scale = coverScale(vw, vh, natural.w, natural.h) * zoom\n      const dw = natural.w * scale\n      const dh = natural.h * scale\n      const left = (vw - dw) / 2 + pan.x\n      const top = (vh - dh) / 2 + pan.y\n      const sw = vw / scale\n      const sh = vh / scale\n      const canvas = document.createElement('canvas')\n      canvas.width = Math.max(1, Math.round(sw))\n      canvas.height = Math.max(1, Math.round(sh))\n      const ctx = canvas.getContext('2d')\n      if (!ctx) return null\n      ctx.drawImage(img, -left / scale, -top / scale, sw, sh, 0, 0, canvas.width, canvas.height)\n      return canvas\n    }, [natural.h, natural.w, pan.x, pan.y, zoom])\n\n    React.useImperativeHandle(\n      ref,\n      () => ({\n        getCroppedCanvas: cropCanvas,\n        getCroppedBlob(type = 'image/png', quality?: number) {\n          return new Promise((resolve) => {\n            const canvas = cropCanvas()\n            if (!canvas) {\n              resolve(null)\n              return\n            }\n            canvas.toBlob((blob) => resolve(blob), type, quality)\n          })\n        },\n      }),\n      [cropCanvas],\n    )\n\n    return (\n      <div\n        data-uipkge=\"\"\n        data-slot=\"image-cropper\"\n        className={cn('flex w-full max-w-md flex-col gap-3', className)}\n        {...props}\n      >\n        <div\n          ref={viewportRef}\n          data-slot=\"image-cropper-viewport\"\n          role=\"application\"\n          aria-label=\"Image crop viewport\"\n          tabIndex={0}\n          data-disabled={disabled ? '' : undefined}\n          className={cn(\n            'bg-muted relative w-full overflow-hidden select-none',\n            rounded === 'full' ? 'rounded-full' : 'rounded-lg',\n            disabled ? 'pointer-events-none opacity-60' : 'cursor-grab active:cursor-grabbing',\n          )}\n          style={{ aspectRatio: String(aspectRatio) }}\n          onPointerDown={(e) => {\n            if (disabled) return\n            dragging.current = true\n            lastPointer.current = { x: e.clientX, y: e.clientY }\n            e.currentTarget.setPointerCapture(e.pointerId)\n          }}\n          onPointerMove={(e) => {\n            if (!dragging.current) return\n            const next = {\n              x: panRef.current.x + (e.clientX - lastPointer.current.x),\n              y: panRef.current.y + (e.clientY - lastPointer.current.y),\n            }\n            lastPointer.current = { x: e.clientX, y: e.clientY }\n            setPan(clampPan(next))\n          }}\n          onPointerUp={(e) => {\n            dragging.current = false\n            try {\n              e.currentTarget.releasePointerCapture(e.pointerId)\n            } catch {\n              /* already released */\n            }\n          }}\n          onWheel={(e) => {\n            if (disabled) return\n            e.preventDefault()\n            setZoom(zoom + (e.deltaY > 0 ? -0.12 : 0.12))\n          }}\n          onKeyDown={(e) => {\n            if (disabled) return\n            const step = 8\n            if (e.key === 'ArrowLeft') setPan((p) => clampPan({ ...p, x: p.x - step }))\n            if (e.key === 'ArrowRight') setPan((p) => clampPan({ ...p, x: p.x + step }))\n            if (e.key === 'ArrowUp') setPan((p) => clampPan({ ...p, y: p.y - step }))\n            if (e.key === 'ArrowDown') setPan((p) => clampPan({ ...p, y: p.y + step }))\n            if (e.key === '+' || e.key === '=') setZoom(zoom + 0.2)\n            if (e.key === '-' || e.key === '_') setZoom(zoom - 0.2)\n          }}\n        >\n          <img\n            ref={imgRef}\n            data-slot=\"image-cropper-image\"\n            src={src}\n            alt={alt}\n            draggable={false}\n            className=\"pointer-events-none absolute top-1/2 left-1/2 max-w-none\"\n            style={imgStyle}\n            onLoad={(e) => {\n              const img = e.currentTarget\n              setNatural({ w: img.naturalWidth, h: img.naturalHeight })\n              setPan({ x: 0, y: 0 })\n            }}\n          />\n        </div>\n        {showZoom ? (\n          <label data-slot=\"image-cropper-zoom\" className=\"text-muted-foreground flex items-center gap-3 text-xs\">\n            <span className=\"w-10\">Zoom</span>\n            <input\n              type=\"range\"\n              min={minZoom}\n              max={maxZoom}\n              step={0.05}\n              value={zoom}\n              className=\"accent-primary h-1.5 w-full cursor-pointer\"\n              aria-label=\"Zoom\"\n              onChange={(e) => setZoom(Number(e.target.value))}\n            />\n            <span className=\"w-10 tabular-nums\">{zoom.toFixed(1)}×</span>\n          </label>\n        ) : null}\n      </div>\n    )\n  },\n)\nImageCropper.displayName = 'ImageCropper'\n\nexport { ImageCropper }\n",
      "type": "registry:ui",
      "target": "~/components/ui/image-cropper/image-cropper.tsx"
    },
    {
      "path": "packages/registry-react/components/image-cropper/index.ts",
      "content": "export { ImageCropper, type ImageCropperProps, type ImageCropperHandle } from './image-cropper'\n",
      "type": "registry:ui",
      "target": "~/components/ui/image-cropper/index.ts"
    }
  ],
  "dependencies": [],
  "devDependencies": [],
  "registryDependencies": [],
  "description": "Single image cropper. Drive look with props: aspectRatio, showZoom, rounded, minZoom, maxZoom, disabled.",
  "categories": [
    "form"
  ]
}