{
  "$schema": "https://shadcn-vue.com/schema/registry-item.json",
  "name": "image-cropper",
  "title": "Image Cropper",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-vue/components/image-cropper/ImageCropper.vue",
      "content": "<script setup lang=\"ts\">\nimport { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'\nimport type { HTMLAttributes } from 'vue'\nimport { cn } from '@/lib/utils'\n\nconst props = withDefaults(\n  defineProps<{\n    src: string\n    alt?: string\n    aspectRatio?: number\n    minZoom?: number\n    maxZoom?: number\n    disabled?: boolean\n    showZoom?: boolean\n    rounded?: 'lg' | 'full'\n    class?: HTMLAttributes['class']\n  }>(),\n  {\n    alt: '',\n    minZoom: 1,\n    maxZoom: 4,\n    disabled: false,\n    showZoom: false,\n    rounded: 'lg',\n  },\n)\n\nconst zoom = defineModel<number>('zoom', { default: 1 })\nconst viewportRef = ref<HTMLElement | null>(null)\nconst imgRef = ref<HTMLImageElement | null>(null)\nconst pan = ref({ x: 0, y: 0 })\nconst natural = ref({ w: 0, h: 0 })\nconst dragging = ref(false)\nconst lastPointer = ref({ x: 0, y: 0 })\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\nfunction setZoom(value: number) {\n  zoom.value = clamp(value, props.minZoom, props.maxZoom)\n  nextTick(clampPan)\n}\n\nfunction onZoomInput(e: Event) {\n  setZoom(Number((e.target as HTMLInputElement).value))\n}\n\nfunction clampPan() {\n  const el = viewportRef.value\n  if (!el || !natural.value.w) return\n  const vw = el.clientWidth\n  const vh = el.clientHeight\n  const scale = coverScale(vw, vh, natural.value.w, natural.value.h) * zoom.value\n  const dw = natural.value.w * scale\n  const dh = natural.value.h * scale\n  const maxX = Math.abs(vw - dw) / 2\n  const maxY = Math.abs(vh - dh) / 2\n  pan.value = {\n    x: clamp(pan.value.x, -maxX, maxX),\n    y: clamp(pan.value.y, -maxY, maxY),\n  }\n}\n\nconst imgStyle = computed(() => {\n  const el = viewportRef.value\n  const nw = natural.value.w\n  const nh = natural.value.h\n  if (!el || !nw) return { transform: 'translate(-50%, -50%)' }\n  const vw = el.clientWidth\n  const vh = el.clientHeight\n  const scale = coverScale(vw, vh, nw, nh) * zoom.value\n  return {\n    width: `${nw * scale}px`,\n    height: `${nh * scale}px`,\n    transform: `translate(calc(-50% + ${pan.value.x}px), calc(-50% + ${pan.value.y}px))`,\n  }\n})\n\nfunction onLoad() {\n  const img = imgRef.value\n  if (!img) return\n  natural.value = { w: img.naturalWidth, h: img.naturalHeight }\n  pan.value = { x: 0, y: 0 }\n  nextTick(clampPan)\n}\n\nfunction onPointerDown(e: PointerEvent) {\n  if (props.disabled) return\n  dragging.value = true\n  lastPointer.value = { x: e.clientX, y: e.clientY }\n  ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)\n}\n\nfunction onPointerMove(e: PointerEvent) {\n  if (!dragging.value) return\n  pan.value = {\n    x: pan.value.x + (e.clientX - lastPointer.value.x),\n    y: pan.value.y + (e.clientY - lastPointer.value.y),\n  }\n  lastPointer.value = { x: e.clientX, y: e.clientY }\n  clampPan()\n}\n\nfunction onPointerUp(e: PointerEvent) {\n  dragging.value = false\n  try {\n    ;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)\n  } catch {\n    /* already released */\n  }\n}\n\nfunction onWheel(e: WheelEvent) {\n  if (props.disabled) return\n  e.preventDefault()\n  setZoom(zoom.value + (e.deltaY > 0 ? -0.12 : 0.12))\n}\n\nwatch(zoom, () => nextTick(clampPan))\n\nfunction onKeydown(e: KeyboardEvent) {\n  if (props.disabled) return\n  const step = 8\n  if (e.key === 'ArrowLeft') pan.value = { ...pan.value, x: pan.value.x - step }\n  if (e.key === 'ArrowRight') pan.value = { ...pan.value, x: pan.value.x + step }\n  if (e.key === 'ArrowUp') pan.value = { ...pan.value, y: pan.value.y - step }\n  if (e.key === 'ArrowDown') pan.value = { ...pan.value, y: pan.value.y + step }\n  if (e.key === '+' || e.key === '=') setZoom(zoom.value + 0.2)\n  if (e.key === '-' || e.key === '_') setZoom(zoom.value - 0.2)\n  clampPan()\n}\n\nfunction getCroppedCanvas() {\n  const el = viewportRef.value\n  const img = imgRef.value\n  if (!el || !img || !natural.value.w) return null\n  const vw = el.clientWidth\n  const vh = el.clientHeight\n  const scale = coverScale(vw, vh, natural.value.w, natural.value.h) * zoom.value\n  const dw = natural.value.w * scale\n  const dh = natural.value.h * scale\n  const left = (vw - dw) / 2 + pan.value.x\n  const top = (vh - dh) / 2 + pan.value.y\n  const sx = -left / scale\n  const sy = -top / scale\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, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height)\n  return canvas\n}\n\nfunction getCroppedBlob(type = 'image/png', quality?: number) {\n  return new Promise<Blob | null>((resolve) => {\n    const canvas = getCroppedCanvas()\n    if (!canvas) {\n      resolve(null)\n      return\n    }\n    canvas.toBlob((blob) => resolve(blob), type, quality)\n  })\n}\n\ndefineExpose({ getCroppedCanvas, getCroppedBlob })\n\nonBeforeUnmount(() => {\n  dragging.value = false\n})\n</script>\n\n<template>\n  <div data-uipkge data-slot=\"image-cropper\" :class=\"cn('flex w-full max-w-md flex-col gap-3', props.class)\">\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      :class=\"\n        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      \"\n      :style=\"aspectRatio ? { aspectRatio: String(aspectRatio) } : { aspectRatio: '1' }\"\n      @pointerdown=\"onPointerDown\"\n      @pointermove=\"onPointerMove\"\n      @pointerup=\"onPointerUp\"\n      @pointercancel=\"onPointerUp\"\n      @wheel=\"onWheel\"\n      @keydown=\"onKeydown\"\n    >\n      <img\n        ref=\"imgRef\"\n        data-slot=\"image-cropper-image\"\n        :src=\"src\"\n        :alt=\"alt\"\n        draggable=\"false\"\n        class=\"pointer-events-none absolute top-1/2 left-1/2 max-w-none\"\n        :style=\"imgStyle\"\n        @load=\"onLoad\"\n      />\n    </div>\n    <label v-if=\"showZoom\" data-slot=\"image-cropper-zoom\" class=\"text-muted-foreground flex items-center gap-3 text-xs\">\n      <span class=\"w-10\">Zoom</span>\n      <input\n        type=\"range\"\n        :min=\"minZoom\"\n        :max=\"maxZoom\"\n        step=\"0.05\"\n        :value=\"zoom\"\n        class=\"accent-primary h-1.5 w-full cursor-pointer\"\n        aria-label=\"Zoom\"\n        @input=\"onZoomInput\"\n      />\n      <span class=\"w-10 tabular-nums\">{{ zoom.toFixed(1) }}×</span>\n    </label>\n  </div>\n</template>\n",
      "type": "registry:ui",
      "target": "~/app/components/ui/image-cropper/ImageCropper.vue"
    },
    {
      "path": "packages/registry-vue/components/image-cropper/index.ts",
      "content": "export { default as ImageCropper } from './ImageCropper.vue'\n",
      "type": "registry:ui",
      "target": "~/app/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"
  ]
}