{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "image-cropper",
  "type": "registry:ui",
  "title": "Image cropper",
  "description": "Left edge in source pixels, measured on the image after `rotation`.",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://ui.minidev.pro/r/button.json",
    "https://ui.minidev.pro/r/segmented-control.json",
    "https://ui.minidev.pro/r/slider.json",
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/image-cropper.tsx",
      "type": "registry:ui",
      "target": "components/ui/image-cropper.tsx",
      "content": "\"use client\"\nimport * as React from \"react\"\nimport { motion, useReducedMotion } from \"motion/react\"\nimport { CircleIcon, DownloadIcon, RotateCwIcon, ZoomInIcon, ZoomOutIcon } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\nimport { SegmentedControl } from \"@/components/ui/segmented-control\"\nimport { Slider } from \"@/components/ui/slider\"\n\nconst SNAPPY = { type: \"spring\", bounce: 0.18, duration: 0.42 } as const\nconst MORPH = { type: \"spring\", bounce: 0.16, duration: 0.5 } as const\nconst RELEASE = { type: \"spring\", stiffness: 400, damping: 40 } as const\nconst ELASTIC = 0.12\n\ntype CropRect = {\n  /** Left edge in source pixels, measured on the image after `rotation`. */\n  x: number\n  y: number\n  width: number\n  height: number\n  /** Clockwise rotation in degrees: 0, 90, 180 or 270. */\n  rotation: number\n}\n\ntype CropOptions = {\n  rotation?: number\n  /** Clip to an ellipse (a circle for square crops). Use a PNG or WebP type to keep the corners transparent. */\n  circle?: boolean\n  /** Output width in pixels. Height follows the crop's aspect. Defaults to the crop's own width. */\n  width?: number\n  type?: string\n  quality?: number\n}\n\nfunction loadImage(src: string) {\n  return new Promise<HTMLImageElement>((resolve, reject) => {\n    const img = new Image()\n    if (!src.startsWith(\"data:\") && !src.startsWith(\"blob:\")) img.crossOrigin = \"anonymous\"\n    img.onload = () => resolve(img)\n    img.onerror = () => reject(new Error(\"Could not load the image\"))\n    img.src = src\n  })\n}\n\n/**\n * Renders a crop rectangle (as returned by ImageCropper's onCrop) to a Blob\n * with a canvas. Remote images need CORS headers or the canvas is tainted.\n */\nasync function getCroppedBlob(src: string | HTMLImageElement, rect: CropRect, opts: CropOptions = {}): Promise<Blob> {\n  const img = typeof src === \"string\" ? await loadImage(src) : src\n  const rotation = (((opts.rotation ?? rect.rotation ?? 0) % 360) + 360) % 360\n  const nw = img.naturalWidth\n  const nh = img.naturalHeight\n  const ew = rotation % 180 ? nh : nw\n  const eh = rotation % 180 ? nw : nh\n  const k = (opts.width ?? rect.width) / rect.width\n  const canvas = document.createElement(\"canvas\")\n  canvas.width = Math.max(1, Math.round(rect.width * k))\n  canvas.height = Math.max(1, Math.round(rect.height * k))\n  const ctx = canvas.getContext(\"2d\")\n  if (!ctx) throw new Error(\"Canvas 2D is not available\")\n  ctx.imageSmoothingQuality = \"high\"\n  if (opts.circle) {\n    ctx.beginPath()\n    ctx.ellipse(canvas.width / 2, canvas.height / 2, canvas.width / 2, canvas.height / 2, 0, 0, Math.PI * 2)\n    ctx.clip()\n  }\n  ctx.scale(k, k)\n  ctx.translate(-rect.x, -rect.y)\n  ctx.translate(ew / 2, eh / 2)\n  ctx.rotate((rotation * Math.PI) / 180)\n  ctx.drawImage(img, -nw / 2, -nh / 2, nw, nh)\n  const type = opts.type ?? \"image/png\"\n  return new Promise((resolve, reject) => canvas.toBlob((b) => (b ? resolve(b) : reject(new Error(\"Export failed\"))), type, opts.quality ?? 0.92))\n}\n\nconst SAMPLE_SVG = `<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1200\" height=\"800\" viewBox=\"0 0 1200 800\">\n<defs>\n<linearGradient id=\"sky\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\"><stop offset=\"0\" stop-color=\"hsl(258 62% 22%)\"/><stop offset=\"0.38\" stop-color=\"hsl(282 52% 42%)\"/><stop offset=\"0.68\" stop-color=\"hsl(336 72% 64%)\"/><stop offset=\"0.86\" stop-color=\"hsl(28 92% 70%)\"/></linearGradient>\n<radialGradient id=\"sun\" cx=\"0.5\" cy=\"0.5\" r=\"0.5\"><stop offset=\"0\" stop-color=\"hsl(46 100% 90%)\"/><stop offset=\"0.55\" stop-color=\"hsl(36 100% 74%)\"/><stop offset=\"1\" stop-color=\"hsl(18 96% 66%)\"/></radialGradient>\n<radialGradient id=\"glow\" cx=\"0.5\" cy=\"0.5\" r=\"0.5\"><stop offset=\"0\" stop-color=\"hsl(36 100% 80%)\" stop-opacity=\"0.65\"/><stop offset=\"1\" stop-color=\"hsl(336 80% 70%)\" stop-opacity=\"0\"/></radialGradient>\n<linearGradient id=\"lake\" x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\"><stop offset=\"0\" stop-color=\"hsl(330 52% 56%)\"/><stop offset=\"0.5\" stop-color=\"hsl(270 40% 34%)\"/><stop offset=\"1\" stop-color=\"hsl(244 44% 16%)\"/></linearGradient>\n</defs>\n<rect width=\"1200\" height=\"800\" fill=\"url(#sky)\"/>\n<g fill=\"hsl(48 100% 96%)\" opacity=\"0.8\"><circle cx=\"110\" cy=\"70\" r=\"2\"/><circle cx=\"260\" cy=\"140\" r=\"1.6\"/><circle cx=\"420\" cy=\"54\" r=\"2.2\"/><circle cx=\"590\" cy=\"120\" r=\"1.4\"/><circle cx=\"930\" cy=\"64\" r=\"2\"/><circle cx=\"1080\" cy=\"150\" r=\"1.8\"/><circle cx=\"1010\" cy=\"40\" r=\"1.4\"/><circle cx=\"330\" cy=\"30\" r=\"1.4\"/><circle cx=\"760\" cy=\"96\" r=\"1.6\"/></g>\n<circle cx=\"770\" cy=\"470\" r=\"260\" fill=\"url(#glow)\"/>\n<circle cx=\"770\" cy=\"470\" r=\"96\" fill=\"url(#sun)\"/>\n<path d=\"M0 470 L120 360 L210 420 L330 290 L450 410 L540 350 L660 452 L760 380 L880 300 L990 400 L1090 340 L1200 410 L1200 560 L0 560Z\" fill=\"hsl(286 38% 46%)\" opacity=\"0.85\"/>\n<path d=\"M0 520 L90 450 L200 500 L300 420 L420 492 L520 440 L640 516 L740 470 L860 430 L960 498 L1070 452 L1200 506 L1200 560 L0 560Z\" fill=\"hsl(266 40% 30%)\"/>\n<rect y=\"556\" width=\"1200\" height=\"244\" fill=\"url(#lake)\"/>\n<g fill=\"hsl(40 100% 86%)\" opacity=\"0.7\"><rect x=\"700\" y=\"574\" width=\"140\" height=\"4\" rx=\"2\"/><rect x=\"720\" y=\"596\" width=\"100\" height=\"3\" rx=\"1.5\"/><rect x=\"736\" y=\"616\" width=\"68\" height=\"3\" rx=\"1.5\"/><rect x=\"750\" y=\"636\" width=\"40\" height=\"2\" rx=\"1\"/></g>\n<path d=\"M0 556 L1200 556\" stroke=\"hsl(30 100% 84%)\" stroke-opacity=\"0.5\" stroke-width=\"2\"/>\n<path d=\"M0 700 C160 664 300 676 430 700 C560 724 650 690 760 700 L760 800 L0 800Z\" fill=\"hsl(232 46% 12%)\"/>\n<g fill=\"hsl(228 50% 9%)\"><path d=\"M92 700 L118 600 L144 700Z\"/><path d=\"M136 704 L170 560 L204 704Z\"/><path d=\"M196 706 L222 620 L248 706Z\"/><path d=\"M1010 720 L1046 590 L1082 720Z\"/><path d=\"M1064 724 L1094 630 L1124 724Z\"/></g>\n<path d=\"M760 760 C900 730 1040 716 1200 724 L1200 800 L760 800Z\" fill=\"hsl(232 46% 12%)\"/>\n</svg>`\n\nconst SAMPLE_SRC = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(SAMPLE_SVG)}`\n\ntype Aspect = \"1:1\" | \"4:3\" | \"16:9\" | \"3:2\" | \"4:5\" | \"9:16\"\n\nconst ratio = (a: string) => {\n  const [w, h] = a.split(\":\").map(Number)\n  return w > 0 && h > 0 ? w / h : 1\n}\nconst even = (n: number) => Math.max(2, Math.round(n / 2) * 2)\nconst clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n))\n\ntype View = { fx: number; fy: number; zoom: number; rot: number }\ntype Mode = \"instant\" | \"snappy\" | \"morph\" | \"release\"\n\n/**\n * Crop an image inside a frame: drag to pan, zoom with the slider, the wheel\n * or a pinch, switch between aspect presets, mask to a circle for avatars and\n * rotate in 90 degree steps. The image rubber-bands at the frame edges and\n * settles back on release. Keyboard: arrows pan (Shift for bigger steps),\n * + and - zoom, R rotates. onCrop returns the crop rectangle in source pixels,\n * and the exported getCroppedBlob(src, rect, opts) renders it with a canvas.\n */\ntype ImageCropperProps = {\n  /** Image URL or data URI. Remote images need CORS headers for getCroppedBlob. */\n  src?: string\n  alt?: string\n  /** Aspect presets offered, as \"w:h\". */\n  aspects?: Aspect[]\n  defaultAspect?: Aspect\n  /** Start with the circular avatar mask on. */\n  defaultCircle?: boolean\n  /** Offer the circular mask toggle. */\n  allowCircle?: boolean\n  /** Largest zoom, as a multiple of the size that just covers the frame. */\n  maxZoom?: number\n  /** Show the live preview and crop details beside the frame. */\n  showPreview?: boolean\n  /** Called with the crop in source pixels once an interaction settles. */\n  onCrop?: (rect: CropRect, info: { aspect: Aspect; circle: boolean }) => void\n  /** File name used by the export button. */\n  fileName?: string\n  className?: string\n}\n\nfunction ImageCropper({\n  src = SAMPLE_SRC,\n  alt = \"Lumen sample landscape\",\n  aspects = [\"1:1\", \"4:3\", \"16:9\"],\n  defaultAspect = \"4:3\",\n  defaultCircle = false,\n  allowCircle = true,\n  maxZoom = 4,\n  showPreview = true,\n  onCrop,\n  fileName = \"lumen-cover.png\",\n  className,\n}: ImageCropperProps) {\n  const reduce = useReducedMotion()\n  const uid = React.useId()\n  const stageRef = React.useRef<HTMLDivElement>(null)\n  const [stageW, setStageW] = React.useState(0)\n  const [natural, setNatural] = React.useState<{ w: number; h: number } | null>(null)\n  const [aspect, setAspect] = React.useState<Aspect>(defaultAspect)\n  const [circle, setCircle] = React.useState(defaultCircle)\n  const [view, setViewState] = React.useState<View>({ fx: 0.5, fy: 0.5, zoom: 1, rot: 0 })\n  const [mode, setMode] = React.useState<Mode>(\"snappy\")\n  const [active, setActive] = React.useState(false)\n  const [busy, setBusy] = React.useState(false)\n  const viewRef = React.useRef(view)\n  viewRef.current = view\n\n  React.useEffect(() => {\n    const el = stageRef.current\n    if (!el) return\n    const ro = new ResizeObserver(([e]) => setStageW(Math.round(e.contentRect.width)))\n    ro.observe(el)\n    return () => ro.disconnect()\n  }, [])\n\n  React.useEffect(() => {\n    let alive = true\n    setNatural(null)\n    setViewState({ fx: 0.5, fy: 0.5, zoom: 1, rot: 0 })\n    loadImage(src)\n      .then((img) => alive && setNatural({ w: img.naturalWidth || 1, h: img.naturalHeight || 1 }))\n      .catch(() => alive && setNatural(null))\n    return () => {\n      alive = false\n    }\n  }, [src])\n\n  const effAspect: Aspect = circle ? \"1:1\" : aspect\n  const a = ratio(effAspect)\n  const stageH = stageW ? even(clamp(stageW * 0.66, 240, 380)) : 0\n  const pad = stageW < 360 ? 16 : 24\n  const cw = stageW ? even(Math.min(stageW - pad * 2, (stageH - pad * 2) * a)) : 0\n  const ch = stageW ? even(cw / a) : 0\n\n  const turned = (((view.rot % 360) + 360) % 360) % 180 !== 0\n  const ew = natural ? (turned ? natural.h : natural.w) : 1\n  const eh = natural ? (turned ? natural.w : natural.h) : 1\n  const base = cw && natural ? Math.max(cw / ew, ch / eh) : 1\n  const geo = React.useRef({ ew, eh, base, cw, ch })\n  geo.current = { ew, eh, base, cw, ch }\n\n  // Keep the point under the frame centre inside the bounds that let the image cover the frame.\n  const bound = React.useCallback((v: View, elastic = false): View => {\n    const { ew, eh, base, cw, ch } = geo.current\n    const s = base * v.zoom\n    const hx = Math.min(0.5, cw / (2 * ew * s))\n    const hy = Math.min(0.5, ch / (2 * eh * s))\n    const fit = (f: number, h: number) => {\n      const c = clamp(f, h, 1 - h)\n      return elastic ? c + (f - c) * ELASTIC : c\n    }\n    return { ...v, fx: fit(v.fx, hx), fy: fit(v.fy, hy) }\n  }, [])\n\n  const setView = React.useCallback(\n    (next: View, m: Mode, elastic = false) => {\n      setMode(m)\n      setViewState(bound(next, elastic))\n    },\n    [bound]\n  )\n\n  // Re-clamp when the frame or image geometry changes (aspect, mask, resize, rotation).\n  React.useEffect(() => {\n    setViewState((v) => bound(v))\n  }, [cw, ch, ew, eh, bound])\n\n  const zoomAt = React.useCallback(\n    (zoom: number, px: number, py: number, m: Mode) => {\n      const v = viewRef.current\n      const { ew, eh, base } = geo.current\n      const z = clamp(zoom, 1, maxZoom)\n      const s0 = base * v.zoom\n      const s1 = base * z\n      const fx = v.fx + px / (ew * s0) - px / (ew * s1)\n      const fy = v.fy + py / (eh * s0) - py / (eh * s1)\n      setView({ ...v, zoom: z, fx, fy }, m)\n    },\n    [maxZoom, setView]\n  )\n\n  // Wheel zoom needs a non-passive listener to keep the page from scrolling.\n  React.useEffect(() => {\n    const el = stageRef.current\n    if (!el) return\n    const onWheel = (e: WheelEvent) => {\n      if (!natural) return\n      e.preventDefault()\n      const r = el.getBoundingClientRect()\n      const factor = Math.exp(-e.deltaY * (e.deltaMode === 1 ? 0.05 : 0.0018))\n      zoomAt(viewRef.current.zoom * factor, e.clientX - r.left - r.width / 2, e.clientY - r.top - r.height / 2, \"instant\")\n    }\n    el.addEventListener(\"wheel\", onWheel, { passive: false })\n    return () => el.removeEventListener(\"wheel\", onWheel)\n  }, [natural, zoomAt])\n\n  const pointers = React.useRef(new Map<number, { x: number; y: number }>())\n  const gesture = React.useRef<{ x: number; y: number; view: View; dist: number } | null>(null)\n\n  const startGesture = () => {\n    const pts = [...pointers.current.values()]\n    if (!pts.length) {\n      gesture.current = null\n      return\n    }\n    const cx = pts.reduce((s, p) => s + p.x, 0) / pts.length\n    const cy = pts.reduce((s, p) => s + p.y, 0) / pts.length\n    const dist = pts.length > 1 ? Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y) : 0\n    gesture.current = { x: cx, y: cy, view: viewRef.current, dist }\n  }\n\n  const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (!natural || (e.pointerType === \"mouse\" && e.button !== 0)) return\n    e.currentTarget.setPointerCapture(e.pointerId)\n    pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY })\n    startGesture()\n    setActive(true)\n  }\n\n  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (!pointers.current.has(e.pointerId) || !gesture.current) return\n    pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY })\n    const g = gesture.current\n    const pts = [...pointers.current.values()]\n    const cx = pts.reduce((s, p) => s + p.x, 0) / pts.length\n    const cy = pts.reduce((s, p) => s + p.y, 0) / pts.length\n    const { ew, eh, base } = geo.current\n    let zoom = g.view.zoom\n    if (pts.length > 1 && g.dist > 0) {\n      zoom = clamp(g.view.zoom * (Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y) / g.dist), 1, maxZoom)\n    }\n    const r = e.currentTarget.getBoundingClientRect()\n    const px = g.x - r.left - r.width / 2\n    const py = g.y - r.top - r.height / 2\n    const s0 = base * g.view.zoom\n    const s1 = base * zoom\n    // Keep the image point that was under the gesture centre under it, then follow the centre.\n    const fx = g.view.fx + px / (ew * s0) - px / (ew * s1) - (cx - g.x) / (ew * s1)\n    const fy = g.view.fy + py / (eh * s0) - py / (eh * s1) - (cy - g.y) / (eh * s1)\n    setView({ ...g.view, zoom, fx, fy }, \"instant\", true)\n  }\n\n  const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (!pointers.current.delete(e.pointerId)) return\n    if (pointers.current.size) {\n      startGesture()\n      return\n    }\n    gesture.current = null\n    setActive(false)\n    setView(viewRef.current, \"release\")\n  }\n\n  const rotate = () => {\n    const v = viewRef.current\n    // The point under the centre turns with the image.\n    setMode(\"morph\")\n    setViewState({ ...v, rot: v.rot + 90, fx: 1 - v.fy, fy: v.fx })\n  }\n\n  const reset = () => setView({ fx: 0.5, fy: 0.5, zoom: 1, rot: 0 }, \"morph\")\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    const v = viewRef.current\n    const { ew, eh, base } = geo.current\n    const s = base * v.zoom\n    const step = e.shiftKey ? 48 : 12\n    const pan = (dx: number, dy: number) => setView({ ...v, fx: v.fx - dx / (ew * s), fy: v.fy - dy / (eh * s) }, \"snappy\")\n    switch (e.key) {\n      case \"ArrowLeft\":\n        pan(-step, 0)\n        break\n      case \"ArrowRight\":\n        pan(step, 0)\n        break\n      case \"ArrowUp\":\n        pan(0, -step)\n        break\n      case \"ArrowDown\":\n        pan(0, step)\n        break\n      case \"+\":\n      case \"=\":\n        zoomAt(v.zoom + 0.2, 0, 0, \"snappy\")\n        break\n      case \"-\":\n      case \"_\":\n        zoomAt(v.zoom - 0.2, 0, 0, \"snappy\")\n        break\n      case \"r\":\n      case \"R\":\n        rotate()\n        break\n      case \"0\":\n        reset()\n        break\n      default:\n        return\n    }\n    e.preventDefault()\n  }\n\n  const s = base * view.zoom\n  const ox = (0.5 - view.fx) * ew * s\n  const oy = (0.5 - view.fy) * eh * s\n  const rect: CropRect | null = natural && cw\n    ? (() => {\n        const w = Math.min(ew, Math.round(cw / s))\n        const h = Math.min(eh, Math.round(ch / s))\n        return {\n          x: clamp(Math.round(view.fx * ew - w / 2), 0, ew - w),\n          y: clamp(Math.round(view.fy * eh - h / 2), 0, eh - h),\n          width: w,\n          height: h,\n          rotation: (((view.rot % 360) + 360) % 360),\n        }\n      })()\n    : null\n\n  const latest = React.useRef(onCrop)\n  latest.current = onCrop\n  const key = rect ? `${rect.x},${rect.y},${rect.width},${rect.height},${rect.rotation},${circle},${effAspect}` : \"\"\n  const [settled, setSettled] = React.useState<CropRect | null>(null)\n  React.useEffect(() => {\n    if (!rect || active) return\n    const t = window.setTimeout(() => {\n      setSettled(rect)\n      latest.current?.(rect, { aspect: effAspect, circle })\n    }, 160)\n    return () => window.clearTimeout(t)\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [key, active])\n\n  const tr =\n    reduce || mode === \"instant\" ? { duration: 0 } : mode === \"release\" ? RELEASE : mode === \"morph\" ? MORPH : SNAPPY\n  const frameTr = reduce ? { duration: 0 } : MORPH\n\n  const previewW = 112\n  const pw = a >= 1 ? previewW : even(previewW * a)\n  const ph = even(pw / a)\n  const k = cw ? pw / cw : 0\n\n  const imgStyle = natural\n    ? { width: natural.w, height: natural.h, marginLeft: -natural.w / 2, marginTop: -natural.h / 2 }\n    : undefined\n\n  const exportPng = async () => {\n    if (!rect) return\n    setBusy(true)\n    try {\n      const blob = await getCroppedBlob(src, rect, { circle, type: \"image/png\" })\n      const url = URL.createObjectURL(blob)\n      const link = document.createElement(\"a\")\n      link.href = url\n      link.download = fileName\n      link.click()\n      window.setTimeout(() => URL.revokeObjectURL(url), 1000)\n    } finally {\n      setBusy(false)\n    }\n  }\n\n  const aspectOptions = aspects.map((x) => ({ value: x, label: x, disabled: circle && x !== \"1:1\" }))\n\n  return (\n    <div data-slot=\"image-cropper\" className={cn(\"grid w-full max-w-3xl gap-4\", showPreview && \"md:grid-cols-[minmax(0,1fr)_10.5rem]\", className)}>\n      <div className=\"flex min-w-0 flex-col gap-3\">\n        <div\n          ref={stageRef}\n          tabIndex={0}\n          role=\"group\"\n          aria-roledescription=\"image cropper\"\n          aria-label={`Crop ${alt}`}\n          aria-describedby={`${uid}-help`}\n          onPointerDown={onPointerDown}\n          onPointerMove={onPointerMove}\n          onPointerUp={onPointerUp}\n          onPointerCancel={onPointerUp}\n          onKeyDown={onKeyDown}\n          className={cn(\n            \"relative isolate touch-none overflow-hidden rounded-xl border border-border bg-sunken outline-none select-none\",\n            \"focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg\",\n            natural ? (active ? \"cursor-grabbing\" : \"cursor-grab\") : \"cursor-progress\"\n          )}\n          style={{ height: stageH || 280 }}\n        >\n          {natural && cw ? (\n            <>\n              <motion.img\n                src={src}\n                alt=\"\"\n                draggable={false}\n                initial={false}\n                animate={{ x: ox, y: oy, scale: s, rotate: view.rot }}\n                transition={tr}\n                className=\"pointer-events-none absolute top-1/2 left-1/2 max-w-none origin-center\"\n                style={imgStyle}\n              />\n              <motion.div\n                aria-hidden\n                initial={false}\n                animate={{ width: cw, height: ch, borderRadius: circle ? cw / 2 : 12 }}\n                transition={frameTr}\n                className=\"pointer-events-none absolute top-1/2 left-1/2 overflow-hidden border border-on-ink/80 dark:border-ink/80\"\n                style={{\n                  x: \"-50%\",\n                  y: \"-50%\",\n                  boxShadow: \"0 0 0 100vmax var(--overlay-scrim), 0 0 0 100vmax var(--overlay-scrim)\",\n                }}\n              >\n                <span\n                  className={cn(\n                    \"absolute inset-0 transition-opacity duration-200 ease-hairline\",\n                    active ? \"opacity-100\" : \"opacity-0\"\n                  )}\n                >\n                  <span className=\"absolute inset-y-0 left-1/3 w-px bg-on-ink/50 dark:bg-ink/50\" />\n                  <span className=\"absolute inset-y-0 left-2/3 w-px bg-on-ink/50 dark:bg-ink/50\" />\n                  <span className=\"absolute inset-x-0 top-1/3 h-px bg-on-ink/50 dark:bg-ink/50\" />\n                  <span className=\"absolute inset-x-0 top-2/3 h-px bg-on-ink/50 dark:bg-ink/50\" />\n                </span>\n              </motion.div>\n            </>\n          ) : (\n            <span className=\"absolute inset-0 grid place-items-center text-xs text-fg-subtle\">Loading image</span>\n          )}\n        </div>\n        <p id={`${uid}-help`} className=\"sr-only\">\n          Drag, or use the arrow keys, to move the image. Scroll, pinch, or press plus and minus to zoom. Press R to rotate and 0 to reset.\n        </p>\n\n        <div className=\"flex flex-wrap items-center gap-2\">\n          <SegmentedControl\n            aria-label=\"Aspect ratio\"\n            size=\"sm\"\n            options={aspectOptions}\n            value={effAspect}\n            onChange={(v) => {\n              setMode(\"morph\")\n              setAspect(v as Aspect)\n            }}\n          />\n          {allowCircle ? (\n            <Button\n              type=\"button\"\n              size=\"sm\"\n              variant=\"outline\"\n              aria-pressed={circle}\n              onClick={() => {\n                setMode(\"morph\")\n                setCircle((c) => !c)\n              }}\n              className=\"aria-pressed:border-accent-line aria-pressed:bg-accent-soft aria-pressed:text-accent-fg aria-pressed:[&_svg]:text-accent-fg\"\n            >\n              <CircleIcon className=\"size-3.5\" />\n              <span className=\"max-sm:sr-only\">Circle</span>\n            </Button>\n          ) : null}\n          <Button type=\"button\" size=\"sm\" variant=\"outline\" onClick={rotate} aria-label=\"Rotate 90 degrees clockwise\" disabled={!natural}>\n            <RotateCwIcon className=\"size-3.5\" />\n            <span className=\"max-sm:sr-only\">Rotate</span>\n          </Button>\n        </div>\n\n        <div className=\"flex items-center gap-2\">\n          <Button type=\"button\" size=\"icon-sm\" variant=\"ghost\" aria-label=\"Zoom out\" disabled={!natural || view.zoom <= 1} onClick={() => zoomAt(view.zoom - 0.25, 0, 0, \"snappy\")}>\n            <ZoomOutIcon className=\"size-4\" />\n          </Button>\n          <Slider\n            aria-label=\"Zoom\"\n            min={1}\n            max={maxZoom}\n            step={0.01}\n            value={view.zoom}\n            disabled={!natural}\n            format={(n) => `${Math.round(n * 100)}%`}\n            onValueChange={(n) => zoomAt(Array.isArray(n) ? n[0] : (n as number), 0, 0, \"instant\")}\n            className=\"min-w-0 flex-1\"\n          />\n          <Button type=\"button\" size=\"icon-sm\" variant=\"ghost\" aria-label=\"Zoom in\" disabled={!natural || view.zoom >= maxZoom} onClick={() => zoomAt(view.zoom + 0.25, 0, 0, \"snappy\")}>\n            <ZoomInIcon className=\"size-4\" />\n          </Button>\n          <span className=\"w-10 text-right font-mono text-xs text-fg-muted tabular-nums\">{Math.round(view.zoom * 100)}%</span>\n        </div>\n      </div>\n\n      {showPreview ? (\n        <div className=\"flex min-w-0 flex-row items-start gap-4 md:flex-col md:gap-3\">\n          <div className=\"flex shrink-0 flex-col gap-2\">\n            <span className=\"text-xs font-medium text-fg-muted\">Preview</span>\n            <div className=\"grid size-[8.5rem] place-items-center rounded-xl border border-border bg-surface shadow-raised\">\n              <motion.div\n                initial={false}\n                animate={{ width: pw, height: ph, borderRadius: circle ? pw / 2 : 8 }}\n                transition={frameTr}\n                className=\"relative overflow-hidden bg-sunken\"\n              >\n                {natural && cw ? (\n                  <motion.img\n                    src={src}\n                    alt={`Cropped preview of ${alt}`}\n                    draggable={false}\n                    initial={false}\n                    animate={{ x: ox * k, y: oy * k, scale: s * k, rotate: view.rot }}\n                    transition={tr}\n                    className=\"pointer-events-none absolute top-1/2 left-1/2 max-w-none origin-center\"\n                    style={imgStyle}\n                  />\n                ) : null}\n              </motion.div>\n            </div>\n          </div>\n          <div className=\"flex min-w-0 flex-1 flex-col gap-3\">\n            <dl className=\"grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 font-mono text-xs tabular-nums\">\n              <dt className=\"text-fg-subtle\">Size</dt>\n              <dd className=\"text-fg\">{settled ? `${settled.width} × ${settled.height}` : \"–\"}</dd>\n              <dt className=\"text-fg-subtle\">Origin</dt>\n              <dd className=\"text-fg\">{settled ? `${settled.x}, ${settled.y}` : \"–\"}</dd>\n              <dt className=\"text-fg-subtle\">Turn</dt>\n              <dd className=\"text-fg\">{settled ? `${settled.rotation}°` : \"–\"}</dd>\n            </dl>\n            <Button type=\"button\" size=\"sm\" variant=\"outline\" onClick={exportPng} disabled={!rect || busy} className=\"w-fit\">\n              <DownloadIcon className=\"size-3.5\" /> {busy ? \"Exporting\" : \"Export PNG\"}\n            </Button>\n          </div>\n        </div>\n      ) : null}\n    </div>\n  )\n}\n\nexport { ImageCropper, getCroppedBlob }\nexport type { ImageCropperProps, CropRect, CropOptions }\n"
    }
  ]
}
