{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "image-cropper",
  "type": "registry:ui",
  "title": "Image Cropper",
  "description": "Crop an image with a draggable, resizable box or a fixed frame — aspect presets, zoom, rotate and canvas output.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/image-cropper.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport type CropAspect = \"free\" | \"1:1\" | \"4:3\" | \"16:9\";\nexport type CropRotation = 0 | 90 | 180 | 270;\n\n/** The crop, in source pixels of the image after rotation. */\nexport interface CropRect {\n  x: number;\n  y: number;\n  width: number;\n  height: number;\n  /** Clockwise rotation applied to the source before cropping. */\n  rotation: CropRotation;\n}\n\nexport interface CroppedCanvasOptions {\n  /** Downscale the output so it is at most this wide (source pixels otherwise). */\n  maxWidth?: number;\n  /** Downscale the output so it is at most this tall. */\n  maxHeight?: number;\n}\n\nexport interface ImageCropperHandle {\n  /** A canvas with the cropped (and rotated) pixels, or null before the image has loaded. */\n  getCroppedCanvas: (options?: CroppedCanvasOptions) => HTMLCanvasElement | null;\n  /** Data URL of the crop; null before load or when the image is cross-origin without CORS. */\n  toDataURL: (type?: string, quality?: number, options?: CroppedCanvasOptions) => string | null;\n  getCropRect: () => CropRect | null;\n  /** Rotate by 90° (1 = clockwise, -1 = counter-clockwise); resets the crop. */\n  rotate: (direction?: 1 | -1) => void;\n  /** Back to the initial crop at 1× zoom. */\n  reset: () => void;\n  setZoom: (zoom: number) => void;\n}\n\nexport interface ImageCropperProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onChange\"> {\n  /** Image URL (remote images need CORS headers for the canvas output). */\n  src: string;\n  alt?: string;\n  /** Aspect ratio of the crop box; the toolbar can change it. */\n  aspect?: CropAspect;\n  /** \"box\": drag and resize the crop box over the image. \"pan\": fixed frame, drag the image under it. */\n  mode?: \"box\" | \"pan\";\n  /** Highest zoom level (1 = image fitted). */\n  maxZoom?: number;\n  /** Rule-of-thirds guides: while dragging, always, or never. */\n  grid?: \"drag\" | \"always\" | \"never\";\n  /** Aspect presets, zoom slider, rotate and reset below the image. */\n  showToolbar?: boolean;\n  /** Darkness of the area outside the crop (0–1). */\n  overlayOpacity?: number;\n  /** Height of the cropping stage in px. */\n  height?: number;\n  /** Accent for handles and focus rings. */\n  accent?: string;\n  /** Accessible name of the cropper. */\n  label?: string;\n  /** Passed to the image; use \"anonymous\" for CORS-enabled remote images. */\n  crossOrigin?: \"anonymous\" | \"use-credentials\";\n  /** Called when a crop gesture, key press or toolbar action settles. */\n  onChange?: (crop: CropRect) => void;\n}\n\n/* -------------------------------- geometry -------------------------------- */\n\ntype Handle = \"n\" | \"s\" | \"e\" | \"w\" | \"ne\" | \"nw\" | \"se\" | \"sw\";\n\ninterface Rect {\n  x: number;\n  y: number;\n  w: number;\n  h: number;\n}\n\ninterface Bounds {\n  l: number;\n  t: number;\n  r: number;\n  b: number;\n}\n\n/** Everything needed to draw the cropper, in stage pixels. Lives in a ref, never in state. */\ninterface Geo {\n  sw: number;\n  sh: number;\n  /** Natural size of the source. */\n  nw: number;\n  nh: number;\n  /** Size of the source after rotation. */\n  rw: number;\n  rh: number;\n  rot: CropRotation;\n  ratio: number | null;\n  mode: \"box\" | \"pan\";\n  maxZoom: number;\n  /** Scale at zoom 1 (contain-fit in box mode, cover the frame in pan mode). */\n  base: number;\n  zoom: number;\n  /** Top-left of the rotated image in the stage. */\n  ox: number;\n  oy: number;\n  box: Rect;\n}\n\ntype GeoEnv = Pick<Geo, \"sw\" | \"sh\" | \"nw\" | \"nh\" | \"rot\" | \"ratio\" | \"mode\" | \"maxZoom\">;\n\nconst PAD = 20;\nconst MIN_BOX = 32;\nconst ASPECTS: Record<CropAspect, number | null> = { free: null, \"1:1\": 1, \"4:3\": 4 / 3, \"16:9\": 16 / 9 };\nconst ASPECT_OPTIONS: CropAspect[] = [\"free\", \"1:1\", \"4:3\", \"16:9\"];\nconst HANDLES: Handle[] = [\"nw\", \"n\", \"ne\", \"e\", \"se\", \"s\", \"sw\", \"w\"];\n\nconst clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));\n\n/** Largest w×h with the given ratio inside w×h. */\nfunction fitRatio(w: number, h: number, ratio: number) {\n  return w / h > ratio ? { w: h * ratio, h } : { w, h: w / ratio };\n}\n\nconst scaleOf = (g: Geo) => g.base * g.zoom;\n\n/** Where the crop box may go: the image, intersected with the padded stage. */\nfunction boundsOf(g: Geo): Bounds {\n  const s = scaleOf(g);\n  return {\n    l: Math.max(PAD, g.ox),\n    t: Math.max(PAD, g.oy),\n    r: Math.min(g.sw - PAD, g.ox + g.rw * s),\n    b: Math.min(g.sh - PAD, g.oy + g.rh * s),\n  };\n}\n\n/** Keeps the image and the box valid after any change. Mutates `g`. */\nfunction constrain(g: Geo) {\n  const s = scaleOf(g);\n  const iw = g.rw * s;\n  const ih = g.rh * s;\n  if (g.mode === \"pan\") {\n    // The image always covers the fixed frame.\n    g.ox = clamp(g.ox, g.box.x + g.box.w - iw, g.box.x);\n    g.oy = clamp(g.oy, g.box.y + g.box.h - ih, g.box.y);\n    return;\n  }\n  // Small images stay inside the stage; large ones always cover it.\n  const innerW = g.sw - 2 * PAD;\n  const innerH = g.sh - 2 * PAD;\n  g.ox = iw <= innerW ? clamp(g.ox, PAD, g.sw - PAD - iw) : clamp(g.ox, g.sw - PAD - iw, PAD);\n  g.oy = ih <= innerH ? clamp(g.oy, PAD, g.sh - PAD - ih) : clamp(g.oy, g.sh - PAD - ih, PAD);\n  const b = boundsOf(g);\n  let { x, y, w, h } = g.box;\n  const k = Math.min(1, (b.r - b.l) / w, (b.b - b.t) / h);\n  if (k < 1) {\n    const cx = x + w / 2;\n    const cy = y + h / 2;\n    w *= k;\n    h *= k;\n    x = cx - w / 2;\n    y = cy - h / 2;\n  }\n  g.box = { x: clamp(x, b.l, b.r - w), y: clamp(y, b.t, b.b - h), w, h };\n}\n\n/** The crop in rotated-source pixels (unrounded). */\nfunction cropOf(g: Geo): Rect {\n  const s = scaleOf(g);\n  const x = clamp((g.box.x - g.ox) / s, 0, g.rw);\n  const y = clamp((g.box.y - g.oy) / s, 0, g.rh);\n  return { x, y, w: Math.min(g.box.w / s, g.rw - x), h: Math.min(g.box.h / s, g.rh - y) };\n}\n\nfunction toCropRect(g: Geo): CropRect {\n  const c = cropOf(g);\n  return { x: Math.round(c.x), y: Math.round(c.y), width: Math.round(c.w), height: Math.round(c.h), rotation: g.rot };\n}\n\n/** Builds the whole view from a crop (source px) and a zoom, centred in the stage. */\nfunction layout(env: GeoEnv, crop: Rect | null, zoom: number): Geo {\n  const turned = env.rot % 180 !== 0;\n  const rw = turned ? env.nh : env.nw;\n  const rh = turned ? env.nw : env.nh;\n  const innerW = Math.max(1, env.sw - 2 * PAD);\n  const innerH = Math.max(1, env.sh - 2 * PAD);\n  const g: Geo = { ...env, rw, rh, base: 1, zoom: clamp(zoom, 1, env.maxZoom), ox: 0, oy: 0, box: { x: 0, y: 0, w: 0, h: 0 } };\n\n  if (env.mode === \"pan\") {\n    const frame = fitRatio(innerW, innerH, env.ratio ?? rw / rh);\n    g.box = { x: (env.sw - frame.w) / 2, y: (env.sh - frame.h) / 2, w: frame.w, h: frame.h };\n    g.base = Math.max(frame.w / rw, frame.h / rh);\n    const s = scaleOf(g);\n    const cx = crop ? crop.x + crop.w / 2 : rw / 2;\n    const cy = crop ? crop.y + crop.h / 2 : rh / 2;\n    g.ox = env.sw / 2 - cx * s;\n    g.oy = env.sh / 2 - cy * s;\n  } else {\n    g.base = Math.min(innerW / rw, innerH / rh);\n    let c = crop;\n    if (!c) {\n      // Default crop: 80% of the image, shaped to the aspect, centred.\n      let w = rw * 0.8;\n      let h = rh * 0.8;\n      if (env.ratio) ({ w, h } = fitRatio(w, h, env.ratio));\n      c = { x: (rw - w) / 2, y: (rh - h) / 2, w, h };\n    }\n    // Zoom out if the crop would not fit the stage at this zoom.\n    const k = Math.min(1, innerW / (c.w * scaleOf(g)), innerH / (c.h * scaleOf(g)));\n    if (k < 1) g.zoom = Math.max(1, g.zoom * k);\n    const s = scaleOf(g);\n    const bw = c.w * s;\n    const bh = c.h * s;\n    // Centre the box, then let the image clamp and carry the box with it.\n    g.ox = (env.sw - bw) / 2 - c.x * s;\n    g.oy = (env.sh - bh) / 2 - c.y * s;\n    g.box = { x: 0, y: 0, w: bw, h: bh };\n    const iw = rw * s;\n    const ih = rh * s;\n    g.ox = iw <= innerW ? clamp(g.ox, PAD, env.sw - PAD - iw) : clamp(g.ox, env.sw - PAD - iw, PAD);\n    g.oy = ih <= innerH ? clamp(g.oy, PAD, env.sh - PAD - ih) : clamp(g.oy, env.sh - PAD - ih, PAD);\n    g.box.x = g.ox + c.x * s;\n    g.box.y = g.oy + c.y * s;\n  }\n  constrain(g);\n  return g;\n}\n\n/** Re-shapes a crop to a new ratio, keeping its centre and roughly its area. */\nfunction reshape(c: Rect, ratio: number, rw: number, rh: number): Rect {\n  let w = Math.sqrt(c.w * c.h * ratio);\n  let h = w / ratio;\n  const k = Math.min(1, rw / w, rh / h);\n  w *= k;\n  h *= k;\n  return { x: clamp(c.x + c.w / 2 - w / 2, 0, rw - w), y: clamp(c.y + c.h / 2 - h / 2, 0, rh - h), w, h };\n}\n\n/** Resizes from a handle, honouring the ratio, the bounds and a minimum size. */\nfunction resizeBox(start: Rect, handle: Handle, dx: number, dy: number, ratio: number | null, b: Bounds): Rect {\n  const hasW = handle.includes(\"w\");\n  const hasE = handle.includes(\"e\");\n  const hasN = handle.includes(\"n\");\n  const hasS = handle.includes(\"s\");\n  const right = start.x + start.w;\n  const bottom = start.y + start.h;\n\n  if (!ratio) {\n    let l = start.x;\n    let t = start.y;\n    let r = right;\n    let btm = bottom;\n    if (hasW) l = clamp(l + dx, b.l, r - MIN_BOX);\n    if (hasE) r = clamp(r + dx, l + MIN_BOX, b.r);\n    if (hasN) t = clamp(t + dy, b.t, btm - MIN_BOX);\n    if (hasS) btm = clamp(btm + dy, t + MIN_BOX, b.b);\n    return { x: l, y: t, w: r - l, h: btm - t };\n  }\n\n  const minW = Math.max(MIN_BOX, MIN_BOX * ratio);\n  if ((hasW || hasE) && (hasN || hasS)) {\n    // Corner: anchor the opposite corner, follow whichever axis moved further.\n    const ax = hasW ? right : start.x;\n    const ay = hasN ? bottom : start.y;\n    const w0 = start.w + (hasW ? -dx : dx);\n    const h0 = start.h + (hasN ? -dy : dy);\n    const maxW = Math.min(hasW ? ax - b.l : b.r - ax, (hasN ? ay - b.t : b.b - ay) * ratio);\n    const w = clamp(Math.abs(dx) >= Math.abs(dy) * ratio ? w0 : h0 * ratio, Math.min(minW, maxW), maxW);\n    const h = w / ratio;\n    return { x: hasW ? ax - w : ax, y: hasN ? ay - h : ay, w, h };\n  }\n  if (hasW || hasE) {\n    // Side edge: the height follows, centred on the box.\n    const ax = hasW ? right : start.x;\n    const cy = start.y + start.h / 2;\n    const maxW = Math.min(hasW ? ax - b.l : b.r - ax, 2 * Math.min(cy - b.t, b.b - cy) * ratio);\n    const w = clamp(start.w + (hasW ? -dx : dx), Math.min(minW, maxW), maxW);\n    const h = w / ratio;\n    return { x: hasW ? ax - w : ax, y: cy - h / 2, w, h };\n  }\n  const ay = hasN ? bottom : start.y;\n  const cx = start.x + start.w / 2;\n  const maxH = Math.min(hasN ? ay - b.t : b.b - ay, (2 * Math.min(cx - b.l, b.r - cx)) / ratio);\n  const h = clamp(start.h + (hasN ? -dy : dy), Math.min(minW / ratio, maxH), maxH);\n  const w = h * ratio;\n  return { x: cx - w / 2, y: hasN ? ay - h : ay, w, h };\n}\n\n/** Zoom around a stage point. Mutates `g`. */\nfunction zoomAt(g: Geo, px: number, py: number, zoom: number) {\n  const next = clamp(zoom, 1, g.maxZoom);\n  const k = next / g.zoom;\n  g.ox = px - (px - g.ox) * k;\n  g.oy = py - (py - g.oy) * k;\n  g.zoom = next;\n  constrain(g);\n}\n\nconst CURSORS: Record<Handle, string> = {\n  n: \"cursor-ns-resize\",\n  s: \"cursor-ns-resize\",\n  e: \"cursor-ew-resize\",\n  w: \"cursor-ew-resize\",\n  ne: \"cursor-nesw-resize\",\n  sw: \"cursor-nesw-resize\",\n  nw: \"cursor-nwse-resize\",\n  se: \"cursor-nwse-resize\",\n};\n\n/** Handle position: corners sit on the corners, edges on the midpoints. */\nfunction handlePosition(h: Handle) {\n  const x = h.includes(\"w\") ? \"left-0\" : h.includes(\"e\") ? \"left-full\" : \"left-1/2\";\n  const y = h.includes(\"n\") ? \"top-0\" : h.includes(\"s\") ? \"top-full\" : \"top-1/2\";\n  return `${x} ${y}`;\n}\n\ntype Drag =\n  | { kind: \"move\" | \"pan\"; x: number; y: number; box: Rect; ox: number; oy: number }\n  | { kind: \"resize\"; handle: Handle; x: number; y: number; box: Rect }\n  | { kind: \"pinch\"; dist: number; zoom: number };\n\n/* -------------------------------- component -------------------------------- */\n\n/**\n * An image cropper. Drag or resize the crop box (or pan the image under a fixed frame), zoom\n * with the slider, wheel or pinch, rotate in 90° steps and read the result as a canvas or data\n * URL through the ref. Geometry lives in a ref and is painted imperatively, so gestures never\n * re-render; state only changes when a gesture settles.\n */\nexport const ImageCropper = React.forwardRef<ImageCropperHandle, ImageCropperProps>(function ImageCropper(\n  {\n    src,\n    alt = \"\",\n    aspect = \"free\",\n    mode = \"box\",\n    maxZoom = 4,\n    grid = \"drag\",\n    showToolbar = true,\n    overlayOpacity = 0.6,\n    height = 360,\n    accent = \"#FF7448\",\n    label = \"Image cropper\",\n    crossOrigin,\n    onChange,\n    className = \"\",\n    style,\n    ...props\n  },\n  ref,\n) {\n  const uid = React.useId();\n  const hintId = `${uid}-hint`;\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const stageRef = React.useRef<HTMLDivElement>(null);\n  const frameRef = React.useRef<HTMLDivElement>(null);\n  const imgRef = React.useRef<HTMLImageElement>(null);\n  const boxRef = React.useRef<HTMLDivElement>(null);\n  const readoutRef = React.useRef<HTMLSpanElement>(null);\n  const zoomInputRef = React.useRef<HTMLInputElement>(null);\n  const zoomTextRef = React.useRef<HTMLSpanElement>(null);\n  const geoRef = React.useRef<Geo | null>(null);\n  const layoutKeyRef = React.useRef(\"\");\n  const dragRef = React.useRef<Drag | null>(null);\n  const pointersRef = React.useRef(new Map<number, { x: number; y: number }>());\n  const lastEmitRef = React.useRef(\"\");\n  const commitTimer = React.useRef<number | undefined>(undefined);\n\n  const [natural, setNatural] = React.useState<{ src: string; w: number; h: number } | null>(null);\n  const [rotation, setRotation] = React.useState<CropRotation>(0);\n  const [aspectChoice, setAspectChoice] = React.useState<CropAspect>(aspect);\n  const [prevAspect, setPrevAspect] = React.useState<CropAspect>(aspect);\n  const [live, setLive] = React.useState(\"\");\n  const [resetCount, setResetCount] = React.useState(0);\n\n  // The aspect prop seeds the toolbar choice and wins again whenever it changes.\n  if (aspect !== prevAspect) {\n    setPrevAspect(aspect);\n    setAspectChoice(aspect);\n  }\n\n  const size = natural && natural.src === src ? natural : null;\n  const zoomLimit = Math.max(1, maxZoom);\n\n  /* ------------------------------ painting ------------------------------ */\n\n  const paint = (g: Geo) => {\n    const s = scaleOf(g);\n    const frame = frameRef.current;\n    const img = imgRef.current;\n    const box = boxRef.current;\n    if (frame) {\n      frame.style.transform = `translate(${g.ox.toFixed(2)}px, ${g.oy.toFixed(2)}px)`;\n      frame.style.width = `${(g.rw * s).toFixed(2)}px`;\n      frame.style.height = `${(g.rh * s).toFixed(2)}px`;\n    }\n    if (img) {\n      img.style.width = `${(g.nw * s).toFixed(2)}px`;\n      img.style.height = `${(g.nh * s).toFixed(2)}px`;\n      img.style.transform = `translate(-50%, -50%) rotate(${g.rot}deg)`;\n    }\n    if (box) {\n      box.style.left = `${g.box.x.toFixed(2)}px`;\n      box.style.top = `${g.box.y.toFixed(2)}px`;\n      box.style.width = `${g.box.w.toFixed(2)}px`;\n      box.style.height = `${g.box.h.toFixed(2)}px`;\n    }\n    const c = toCropRect(g);\n    if (readoutRef.current) readoutRef.current.textContent = `${c.width} × ${c.height}`;\n    if (zoomInputRef.current) zoomInputRef.current.value = String(g.zoom);\n    if (zoomTextRef.current) zoomTextRef.current.textContent = `${g.zoom.toFixed(2)}×`;\n  };\n\n  /** Settles a change: announce it and report it (only when the crop actually changed). */\n  const commit = () => {\n    window.clearTimeout(commitTimer.current);\n    const g = geoRef.current;\n    if (!g) return;\n    const rect = toCropRect(g);\n    const key = `${rect.x},${rect.y},${rect.width},${rect.height},${rect.rotation}`;\n    if (key === lastEmitRef.current) return;\n    lastEmitRef.current = key;\n    const ratio = aspectChoice === \"free\" ? \"free aspect\" : `aspect ${aspectChoice}`;\n    setLive(`Crop ${rect.width} by ${rect.height} pixels at ${rect.x}, ${rect.y}, ${ratio}, zoom ${g.zoom.toFixed(1)}×.`);\n    onChange?.(rect);\n  };\n\n  /** For continuous inputs (wheel, slider, pinch): report once they pause. */\n  const scheduleCommit = () => {\n    window.clearTimeout(commitTimer.current);\n    commitTimer.current = window.setTimeout(commit, 160);\n  };\n\n  const applyLayout = () => {\n    const stage = stageRef.current;\n    if (!stage || !size) return;\n    const sw = stage.clientWidth;\n    const sh = stage.clientHeight;\n    if (!sw || !sh) return;\n    const env: GeoEnv = { sw, sh, nw: size.w, nh: size.h, rot: rotation, ratio: ASPECTS[aspectChoice], mode, maxZoom: zoomLimit };\n    const prev = geoRef.current;\n    const key = `${src}|${size.w}x${size.h}|${rotation}|${mode}|${resetCount}`;\n    let next: Geo;\n    if (!prev || layoutKeyRef.current !== key) {\n      next = layout(env, null, 1);\n    } else {\n      let crop = cropOf(prev);\n      if (env.ratio && prev.ratio !== env.ratio && mode === \"box\") crop = reshape(crop, env.ratio, prev.rw, prev.rh);\n      next = layout(env, crop, prev.zoom);\n    }\n    layoutKeyRef.current = key;\n    geoRef.current = next;\n    paint(next);\n    commit();\n  };\n\n  const relayout = React.useEffectEvent(applyLayout);\n\n  // Lay out before paint whenever the source, rotation, aspect, mode or limits change.\n  React.useLayoutEffect(() => {\n    relayout();\n  }, [size, rotation, aspectChoice, mode, zoomLimit, resetCount]);\n\n  React.useEffect(() => {\n    const stage = stageRef.current;\n    if (!stage) return;\n    const ro = new ResizeObserver(() => relayout());\n    ro.observe(stage);\n    return () => ro.disconnect();\n  }, []);\n\n  // A cached image can finish loading before hydration attaches onLoad.\n  const markLoaded = React.useEffectEvent((img: HTMLImageElement) => {\n    if (img.complete && img.naturalWidth) setNatural({ src, w: img.naturalWidth, h: img.naturalHeight });\n  });\n  React.useEffect(() => {\n    if (imgRef.current) markLoaded(imgRef.current);\n  }, [src]);\n\n  React.useEffect(() => () => window.clearTimeout(commitTimer.current), []);\n\n  /* ------------------------------ wheel zoom ----------------------------- */\n\n  const onWheel = React.useEffectEvent((event: WheelEvent) => {\n    const g = geoRef.current;\n    const stage = stageRef.current;\n    if (!g || !stage) return;\n    event.preventDefault();\n    const rect = stage.getBoundingClientRect();\n    // Trackpad pinch arrives as ctrl + wheel with small deltas.\n    const factor = Math.exp(-event.deltaY * (event.ctrlKey ? 0.01 : 0.0015));\n    zoomAt(g, event.clientX - rect.left, event.clientY - rect.top, g.zoom * factor);\n    paint(g);\n    scheduleCommit();\n  });\n\n  React.useEffect(() => {\n    const stage = stageRef.current;\n    if (!stage) return;\n    const handler = (event: WheelEvent) => onWheel(event);\n    stage.addEventListener(\"wheel\", handler, { passive: false });\n    return () => stage.removeEventListener(\"wheel\", handler);\n  }, []);\n\n  /* ---------------------------- pointer gestures ---------------------------- */\n\n  const localPoint = (event: React.PointerEvent<HTMLDivElement>) => {\n    const rect = event.currentTarget.getBoundingClientRect();\n    return { x: event.clientX - rect.left, y: event.clientY - rect.top };\n  };\n\n  const startPinch = (g: Geo) => {\n    const [a, b] = [...pointersRef.current.values()];\n    dragRef.current = { kind: \"pinch\", dist: Math.max(1, Math.hypot(a.x - b.x, a.y - b.y)), zoom: g.zoom };\n  };\n\n  const startPan = (g: Geo, p: { x: number; y: number }) => {\n    dragRef.current = { kind: \"pan\", x: p.x, y: p.y, box: { ...g.box }, ox: g.ox, oy: g.oy };\n  };\n\n  const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {\n    const g = geoRef.current;\n    if (!g || (event.pointerType === \"mouse\" && event.button !== 0)) return;\n    const p = localPoint(event);\n    event.currentTarget.setPointerCapture(event.pointerId);\n    pointersRef.current.set(event.pointerId, p);\n    rootRef.current?.setAttribute(\"data-dragging\", \"true\");\n\n    if (pointersRef.current.size === 2) {\n      startPinch(g);\n      return;\n    }\n    const target = event.target as Element;\n    const handle = target.closest<HTMLElement>(\"[data-ic-handle]\")?.dataset.icHandle as Handle | undefined;\n    const onBox = Boolean(target.closest(\"[data-ic-box]\"));\n    if (onBox) boxRef.current?.focus({ preventScroll: true });\n    if (g.mode === \"box\" && handle) dragRef.current = { kind: \"resize\", handle, x: p.x, y: p.y, box: { ...g.box } };\n    else if (g.mode === \"box\" && onBox) dragRef.current = { kind: \"move\", x: p.x, y: p.y, box: { ...g.box }, ox: g.ox, oy: g.oy };\n    else startPan(g, p);\n    event.preventDefault();\n  };\n\n  const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {\n    const g = geoRef.current;\n    const drag = dragRef.current;\n    if (!g || !drag || !pointersRef.current.has(event.pointerId)) return;\n    const p = localPoint(event);\n    pointersRef.current.set(event.pointerId, p);\n\n    if (drag.kind === \"pinch\") {\n      const [a, b] = [...pointersRef.current.values()];\n      if (!a || !b) return;\n      const dist = Math.hypot(a.x - b.x, a.y - b.y);\n      zoomAt(g, (a.x + b.x) / 2, (a.y + b.y) / 2, drag.zoom * (dist / drag.dist));\n      paint(g);\n      return;\n    }\n\n    const dx = p.x - drag.x;\n    const dy = p.y - drag.y;\n    if (drag.kind === \"resize\") {\n      g.box = resizeBox(drag.box, drag.handle, dx, dy, g.ratio, boundsOf(g));\n    } else if (drag.kind === \"move\") {\n      const b = boundsOf(g);\n      g.box = { ...drag.box, x: clamp(drag.box.x + dx, b.l, b.r - drag.box.w), y: clamp(drag.box.y + dy, b.t, b.b - drag.box.h) };\n    } else {\n      g.ox = drag.ox + dx;\n      g.oy = drag.oy + dy;\n      constrain(g);\n    }\n    paint(g);\n  };\n\n  const onPointerEnd = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (!pointersRef.current.delete(event.pointerId)) return;\n    const g = geoRef.current;\n    const rest = [...pointersRef.current.values()];\n    if (g && rest.length === 1) {\n      // Pinch ended with one finger down: continue as a pan from there.\n      startPan(g, rest[0]);\n      return;\n    }\n    if (rest.length) return;\n    dragRef.current = null;\n    rootRef.current?.removeAttribute(\"data-dragging\");\n    commit();\n  };\n\n  /* -------------------------------- keyboard -------------------------------- */\n\n  const onBoxKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    const g = geoRef.current;\n    if (!g) return;\n    const dirs: Record<string, [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };\n    const dir = dirs[event.key];\n    const cx = g.box.x + g.box.w / 2;\n    const cy = g.box.y + g.box.h / 2;\n    if (dir) {\n      if (g.mode === \"pan\") {\n        // The frame moves over the image, so the image moves the other way.\n        const step = event.shiftKey ? 24 : 4;\n        g.ox -= dir[0] * step;\n        g.oy -= dir[1] * step;\n        constrain(g);\n      } else if (event.shiftKey) {\n        g.box = resizeBox(g.box, dir[0] ? \"e\" : \"s\", dir[0] * 8, dir[1] * 8, g.ratio, boundsOf(g));\n      } else {\n        const b = boundsOf(g);\n        g.box = { ...g.box, x: clamp(g.box.x + dir[0] * 4, b.l, b.r - g.box.w), y: clamp(g.box.y + dir[1] * 4, b.t, b.b - g.box.h) };\n      }\n    } else if (event.key === \"+\" || event.key === \"=\") {\n      zoomAt(g, cx, cy, g.zoom * 1.15);\n    } else if (event.key === \"-\" || event.key === \"_\") {\n      zoomAt(g, cx, cy, g.zoom / 1.15);\n    } else {\n      return;\n    }\n    event.preventDefault();\n    paint(g);\n    commit();\n  };\n\n  /* ------------------------------ toolbar actions ----------------------------- */\n\n  const setZoomCentered = (zoom: number) => {\n    const g = geoRef.current;\n    if (!g) return;\n    zoomAt(g, g.box.x + g.box.w / 2, g.box.y + g.box.h / 2, zoom);\n    paint(g);\n  };\n\n  const rotate = (direction: 1 | -1 = 1) => setRotation((r) => (((r + direction * 90 + 360) % 360) as CropRotation));\n  const reset = () => {\n    setRotation(0);\n    setResetCount((n) => n + 1);\n  };\n\n  /* ------------------------------ imperative API ----------------------------- */\n\n  React.useImperativeHandle(ref, () => {\n    const getCroppedCanvas = (options: CroppedCanvasOptions = {}) => {\n      const g = geoRef.current;\n      const img = imgRef.current;\n      if (!g || !img || !img.naturalWidth) return null;\n      const c = cropOf(g);\n      const k = Math.min(1, (options.maxWidth ?? Infinity) / c.w, (options.maxHeight ?? Infinity) / c.h);\n      const canvas = document.createElement(\"canvas\");\n      canvas.width = Math.max(1, Math.round(c.w * k));\n      canvas.height = Math.max(1, Math.round(c.h * k));\n      const ctx = canvas.getContext(\"2d\");\n      if (!ctx) return null;\n      ctx.imageSmoothingQuality = \"high\";\n      ctx.scale(k, k);\n      ctx.translate(-c.x, -c.y);\n      // Rotate the source about the centre of the rotated frame.\n      ctx.translate(g.rw / 2, g.rh / 2);\n      ctx.rotate((g.rot * Math.PI) / 180);\n      ctx.drawImage(img, -g.nw / 2, -g.nh / 2, g.nw, g.nh);\n      return canvas;\n    };\n    return {\n      getCroppedCanvas,\n      toDataURL: (type = \"image/png\", quality?: number, options?: CroppedCanvasOptions) => {\n        const canvas = getCroppedCanvas(options);\n        if (!canvas) return null;\n        try {\n          return canvas.toDataURL(type, quality);\n        } catch {\n          // Cross-origin image without CORS: the canvas is tainted.\n          return null;\n        }\n      },\n      getCropRect: () => (geoRef.current ? toCropRect(geoRef.current) : null),\n      rotate,\n      reset,\n      setZoom: (zoom: number) => {\n        setZoomCentered(zoom);\n        commit();\n      },\n    };\n  });\n\n  /* --------------------------------- render --------------------------------- */\n\n  const ready = Boolean(size);\n  const showGrid = grid !== \"never\";\n  const keysHint =\n    mode === \"pan\"\n      ? \"Arrow keys move the image under the frame, Shift for larger steps. Plus and minus zoom.\"\n      : \"Arrow keys move the crop box. Shift plus arrow keys resize it. Plus and minus zoom.\";\n\n  return (\n    <div\n      ref={rootRef}\n      role=\"group\"\n      aria-label={label}\n      className={`group/ic flex w-full min-w-0 flex-col gap-3 text-zinc-950 dark:text-zinc-50 ${className}`}\n      style={{ [\"--ic-accent\" as string]: accent, ...style }}\n      {...props}\n    >\n      <div\n        ref={stageRef}\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={onPointerEnd}\n        onPointerCancel={onPointerEnd}\n        className={`relative w-full touch-none select-none overflow-hidden rounded-xl border border-zinc-900/10 bg-zinc-100 bg-[radial-gradient(rgb(0_0_0/0.07)_1px,transparent_1px)] [background-size:14px_14px] dark:border-white/10 dark:bg-zinc-900 dark:bg-[radial-gradient(rgb(255_255_255/0.06)_1px,transparent_1px)] ${\n          mode === \"pan\" ? \"cursor-grab active:cursor-grabbing\" : \"\"\n        }`}\n        style={{ height }}\n      >\n        {/* Rotated-image frame: sized and moved imperatively. */}\n        <div\n          ref={frameRef}\n          aria-hidden=\"true\"\n          className={`absolute left-0 top-0 transition-opacity duration-300 motion-reduce:transition-none ${ready ? \"opacity-100\" : \"opacity-0\"}`}\n        >\n          {/* eslint-disable-next-line @next/next/no-img-element -- framework-agnostic component */}\n          <img\n            ref={imgRef}\n            src={src}\n            alt=\"\"\n            crossOrigin={crossOrigin}\n            draggable={false}\n            onLoad={(e) => setNatural({ src, w: e.currentTarget.naturalWidth, h: e.currentTarget.naturalHeight })}\n            className=\"pointer-events-none absolute left-1/2 top-1/2 max-w-none origin-center\"\n          />\n        </div>\n\n        {!ready && (\n          <div className=\"absolute inset-0 grid place-items-center font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">\n            Loading image\n          </div>\n        )}\n\n        <div\n          ref={boxRef}\n          data-ic-box=\"\"\n          tabIndex={ready ? 0 : -1}\n          role=\"group\"\n          aria-roledescription=\"crop area\"\n          aria-label={alt ? `Crop area for ${alt}` : \"Crop area\"}\n          aria-describedby={hintId}\n          onKeyDown={onBoxKeyDown}\n          className={`absolute outline-hidden focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-solid focus-visible:outline-[color:var(--ic-accent)] ${\n            ready ? \"\" : \"invisible\"\n          } ${mode === \"box\" ? \"cursor-move\" : \"\"}`}\n          style={{ boxShadow: `0 0 0 9999px rgb(9 9 11 / ${clamp(overlayOpacity, 0, 1)})` }}\n        >\n          <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 border border-white/90 shadow-[inset_0_0_0_1px_rgb(0_0_0/0.15)]\" />\n          {showGrid && (\n            <div\n              aria-hidden=\"true\"\n              className={`pointer-events-none absolute inset-0 transition-opacity duration-200 motion-reduce:transition-none ${\n                grid === \"always\" ? \"opacity-100\" : \"opacity-0 group-data-[dragging=true]/ic:opacity-100\"\n              }`}\n            >\n              <span className=\"absolute inset-y-0 left-1/3 w-px bg-white/55\" />\n              <span className=\"absolute inset-y-0 left-2/3 w-px bg-white/55\" />\n              <span className=\"absolute inset-x-0 top-1/3 h-px bg-white/55\" />\n              <span className=\"absolute inset-x-0 top-2/3 h-px bg-white/55\" />\n            </div>\n          )}\n          <span\n            ref={readoutRef}\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute left-1.5 top-1.5 rounded-[4px] bg-zinc-950/70 px-1.5 py-0.5 font-mono text-[10px] tabular-nums tracking-[0.04em] text-white\"\n          />\n          {mode === \"box\" &&\n            HANDLES.map((h) => {\n              const corner = h.length === 2;\n              return (\n                <span\n                  key={h}\n                  data-ic-handle={h}\n                  aria-hidden=\"true\"\n                  className={`absolute grid size-6 -translate-x-1/2 -translate-y-1/2 place-items-center ${handlePosition(h)} ${CURSORS[h]}`}\n                >\n                  <span\n                    className={`block rounded-[2px] border border-[color:var(--ic-accent)] bg-white shadow-[0_1px_3px_rgb(0_0_0/0.35)] ${\n                      corner ? \"size-2.5\" : h === \"n\" || h === \"s\" ? \"h-1.5 w-4\" : \"h-4 w-1.5\"\n                    }`}\n                  />\n                </span>\n              );\n            })}\n        </div>\n      </div>\n\n      {showToolbar && (\n        <div className=\"flex flex-wrap items-center gap-x-4 gap-y-3\">\n          <div role=\"group\" aria-label=\"Aspect ratio\" className=\"flex rounded-full border border-zinc-900/15 p-0.5 dark:border-white/15\">\n            {ASPECT_OPTIONS.map((a) => (\n              <button\n                key={a}\n                type=\"button\"\n                aria-pressed={aspectChoice === a}\n                disabled={!ready}\n                onClick={() => setAspectChoice(a)}\n                className={`h-7 rounded-full px-2.5 font-mono text-[11px] uppercase tracking-[0.08em] transition-colors outline-hidden focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-solid focus-visible:outline-[color:var(--ic-accent)] disabled:opacity-50 ${\n                  aspectChoice === a\n                    ? \"bg-zinc-950 text-white dark:bg-white dark:text-zinc-950\"\n                    : \"text-zinc-600 hover:text-zinc-950 dark:text-zinc-300 dark:hover:text-white\"\n                }`}\n              >\n                {a === \"free\" ? \"Free\" : a}\n              </button>\n            ))}\n          </div>\n\n          <label className=\"flex min-w-[160px] flex-1 items-center gap-2.5\">\n            <span className=\"font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">Zoom</span>\n            <input\n              ref={zoomInputRef}\n              type=\"range\"\n              min={1}\n              max={zoomLimit}\n              step={0.01}\n              defaultValue={1}\n              disabled={!ready}\n              onChange={(e) => {\n                setZoomCentered(Number(e.currentTarget.value));\n                scheduleCommit();\n              }}\n              className=\"h-1 min-w-0 flex-1 cursor-pointer accent-[var(--ic-accent)] outline-hidden focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-solid focus-visible:outline-[color:var(--ic-accent)]\"\n            />\n            <span ref={zoomTextRef} aria-hidden=\"true\" className=\"w-11 text-right font-mono text-[11px] tabular-nums text-zinc-500 dark:text-zinc-400\">\n              1.00×\n            </span>\n          </label>\n\n          <div className=\"flex items-center gap-1\">\n            <button\n              type=\"button\"\n              aria-label=\"Rotate 90° clockwise\"\n              title=\"Rotate 90°\"\n              disabled={!ready}\n              onClick={() => rotate(1)}\n              className=\"grid size-8 place-items-center rounded-full border border-zinc-900/15 text-zinc-700 transition-colors outline-hidden hover:bg-zinc-900/5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[color:var(--ic-accent)] disabled:opacity-50 dark:border-white/15 dark:text-zinc-200 dark:hover:bg-white/10\"\n            >\n              <svg viewBox=\"0 0 16 16\" className=\"size-3.5\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" aria-hidden=\"true\">\n                <path d=\"M13 8a5 5 0 1 1-1.6-3.7\" strokeLinecap=\"round\" />\n                <path d=\"M13 2.5v2.8h-2.8\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n              </svg>\n            </button>\n            <button\n              type=\"button\"\n              disabled={!ready}\n              onClick={reset}\n              className=\"h-8 rounded-full border border-zinc-900/15 px-3 font-mono text-[11px] uppercase tracking-[0.08em] text-zinc-700 transition-colors outline-hidden hover:bg-zinc-900/5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[color:var(--ic-accent)] disabled:opacity-50 dark:border-white/15 dark:text-zinc-200 dark:hover:bg-white/10\"\n            >\n              Reset\n            </button>\n          </div>\n        </div>\n      )}\n\n      <p id={hintId} className=\"sr-only\">\n        {keysHint}\n      </p>\n      <p className=\"sr-only\" aria-live=\"polite\">\n        {live}\n      </p>\n    </div>\n  );\n});\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "forms"
  ],
  "docs": "Documentation: https://wancraft.my/components/image-cropper"
}
