{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "image-cropper",
  "title": "Image Cropper",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-svelte/components/image-cropper/ImageCropper.svelte",
      "content": "<script lang=\"ts\" module>\n  import type { HTMLAttributes } from 'svelte/elements'\n\n  /** Imperative handle — grab it with `bind:this` (React `ImageCropperHandle` parity). */\n  export interface ImageCropperHandle {\n    getCroppedCanvas: () => HTMLCanvasElement | null\n    getCroppedBlob: (type?: string, quality?: number) => Promise<Blob | null>\n  }\n\n  export interface ImageCropperProps extends HTMLAttributes<HTMLDivElement> {\n    src: string\n    alt?: string\n    aspectRatio?: number\n    /** Two-way bound zoom (`bind:zoom`). */\n    zoom?: number\n    /** Fired with the clamped zoom whenever it changes (React parity — `bind:zoom` still works). */\n    onZoomChange?: (zoom: number) => void\n    minZoom?: number\n    maxZoom?: number\n    disabled?: boolean\n    showZoom?: boolean\n    rounded?: 'lg' | 'full'\n    ref?: HTMLDivElement | null\n  }\n</script>\n\n<script lang=\"ts\">\n  import { tick } from 'svelte'\n  import { cn } from '$lib/utils'\n\n  let {\n    class: className,\n    src,\n    alt = '',\n    aspectRatio,\n    zoom = $bindable(1),\n    onZoomChange,\n    minZoom = 1,\n    maxZoom = 4,\n    disabled = false,\n    showZoom = false,\n    rounded = 'lg',\n    ref = $bindable(null),\n    ...restProps\n  }: ImageCropperProps = $props()\n\n  let viewport: HTMLElement | null = $state(null)\n  let img: HTMLImageElement | null = $state(null)\n  let pan = $state({ x: 0, y: 0 })\n  let natural = $state({ w: 0, h: 0 })\n  let dragging = $state(false)\n  let lastPointer = $state({ x: 0, y: 0 })\n\n  function 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\n  function clamp(n: number, min: number, max: number) {\n    return Math.min(max, Math.max(min, n))\n  }\n\n  function setZoom(value: number) {\n    zoom = clamp(value, minZoom, maxZoom)\n    onZoomChange?.(zoom)\n    tick().then(clampPan)\n  }\n\n  function onZoomInput(e: Event & { currentTarget: HTMLInputElement }) {\n    setZoom(Number(e.currentTarget.value))\n  }\n\n  function clampPan() {\n    const el = viewport\n    if (!el || !natural.w) return\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    pan = {\n      x: clamp(pan.x, -maxX, maxX),\n      y: clamp(pan.y, -maxY, maxY),\n    }\n  }\n\n  const imgStyle = $derived.by(() => {\n    const el = viewport\n    const nw = natural.w\n    const nh = natural.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\n    return (\n      `width: ${nw * scale}px; height: ${nh * scale}px; ` +\n      `transform: translate(calc(-50% + ${pan.x}px), calc(-50% + ${pan.y}px))`\n    )\n  })\n\n  function onLoad() {\n    if (!img) return\n    natural = { w: img.naturalWidth, h: img.naturalHeight }\n    pan = { x: 0, y: 0 }\n    tick().then(clampPan)\n  }\n\n  function onPointerDown(e: PointerEvent & { currentTarget: HTMLElement }) {\n    if (disabled) return\n    dragging = true\n    lastPointer = { x: e.clientX, y: e.clientY }\n    e.currentTarget.setPointerCapture(e.pointerId)\n  }\n\n  function onPointerMove(e: PointerEvent) {\n    if (!dragging) return\n    pan = {\n      x: pan.x + (e.clientX - lastPointer.x),\n      y: pan.y + (e.clientY - lastPointer.y),\n    }\n    lastPointer = { x: e.clientX, y: e.clientY }\n    clampPan()\n  }\n\n  function onPointerUp(e: PointerEvent & { currentTarget: HTMLElement }) {\n    dragging = false\n    try {\n      e.currentTarget.releasePointerCapture(e.pointerId)\n    } catch {\n      /* already released */\n    }\n  }\n\n  function onWheel(e: WheelEvent) {\n    if (disabled) return\n    e.preventDefault()\n    setZoom(zoom + (e.deltaY > 0 ? -0.12 : 0.12))\n  }\n\n  $effect(() => {\n    void zoom\n    tick().then(clampPan)\n  })\n\n  function onKeydown(e: KeyboardEvent) {\n    if (disabled) return\n    const step = 8\n    if (e.key === 'ArrowLeft') pan = { ...pan, x: pan.x - step }\n    if (e.key === 'ArrowRight') pan = { ...pan, x: pan.x + step }\n    if (e.key === 'ArrowUp') pan = { ...pan, y: pan.y - step }\n    if (e.key === 'ArrowDown') pan = { ...pan, y: pan.y + step }\n    if (e.key === '+' || e.key === '=') setZoom(zoom + 0.2)\n    if (e.key === '-' || e.key === '_') setZoom(zoom - 0.2)\n    clampPan()\n  }\n\n  /** Render the current viewport to a canvas. Reachable via `bind:this`. */\n  export function getCroppedCanvas() {\n    const el = viewport\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 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\n  /** Render the current viewport to a Blob. Reachable via `bind:this`. */\n  export function 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</script>\n\n<div\n  bind:this={ref}\n  data-uipkge=\"\"\n  data-slot=\"image-cropper\"\n  class={cn('flex w-full max-w-md flex-col gap-3', className)}\n  {...restProps}\n>\n  <!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions: keyboard-operated crop viewport (arrows pan, + / − zoom) -->\n  <div\n    bind:this={viewport}\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={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 ? `aspect-ratio: ${aspectRatio}` : 'aspect-ratio: 1'}\n    onpointerdown={onPointerDown}\n    onpointermove={onPointerMove}\n    onpointerup={onPointerUp}\n    onpointercancel={onPointerUp}\n    onwheel={onWheel}\n    onkeydown={onKeydown}\n  >\n    <img\n      bind:this={img}\n      data-slot=\"image-cropper-image\"\n      {src}\n      {alt}\n      draggable=\"false\"\n      class=\"pointer-events-none absolute top-1/2 left-1/2 max-w-none\"\n      style={imgStyle}\n      onload={onLoad}\n    />\n  </div>\n  {#if showZoom}\n    <label 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        oninput={onZoomInput}\n      />\n      <span class=\"w-10 tabular-nums\">{zoom.toFixed(1)}×</span>\n    </label>\n  {/if}\n</div>\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/image-cropper/ImageCropper.svelte"
    },
    {
      "path": "packages/registry-svelte/components/image-cropper/index.ts",
      "content": "export { default as ImageCropper, type ImageCropperHandle, type ImageCropperProps } from './ImageCropper.svelte'\n",
      "type": "registry:ui",
      "target": "~/src/lib/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"
  ]
}