{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "image-cropper",
  "type": "registry:ui",
  "title": "Image Cropper",
  "description": "Crop box with handles, aspect presets, zoom and rotate, exported to a Blob.",
  "categories": [
    "media"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/image-cropper.tsx",
      "content": "\"use client\";\nimport { Slider } from \"@base-ui/react/slider\";\nimport { Toggle } from \"@base-ui/react/toggle\";\nimport { ToggleGroup } from \"@base-ui/react/toggle-group\";\nimport { animate, motion, useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\n/* ------------------------------------------------------------------ geometry */\n\n/** A rectangle in the rotated image's own pixels. */\nexport type CropArea = { x: number; y: number; width: number; height: number };\nexport type CropRotation = 0 | 90 | 180 | 270;\nexport type CropAspect = { label: string; value: number | \"free\" };\n\ntype Rect = { x: number; y: number; w: number; h: number };\ntype View = { s: number; ox: number; oy: number };\ntype Handle = \"n\" | \"s\" | \"e\" | \"w\" | \"ne\" | \"nw\" | \"se\" | \"sw\";\n\nconst PAD = 20; // air between the stage edge and the fitted image or crop\nconst MIN = 44; // smallest crop on screen, px\n\nconst clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));\n\n/** The largest rect of `ratio` centered in w × h (or 90% of it for a free crop). */\nfunction initialCrop(w: number, h: number, ratio: number | \"free\"): Rect {\n  if (ratio === \"free\") return { x: w * 0.05, y: h * 0.05, w: w * 0.9, h: h * 0.9 };\n  let cw = w;\n  let ch = w / ratio;\n  if (ch > h) {\n    ch = h;\n    cw = h * ratio;\n  }\n  return { x: (w - cw) / 2, y: (h - ch) / 2, w: cw, h: ch };\n}\n\n/** Re-fit an existing crop to a new ratio around its center, staying inside the image. */\nfunction refit(c: Rect, W: number, H: number, ratio: number | \"free\"): Rect {\n  if (ratio === \"free\") return c;\n  const cx = c.x + c.w / 2;\n  const cy = c.y + c.h / 2;\n  const area = c.w * c.h;\n  let w = Math.sqrt(area * ratio);\n  let h = w / ratio;\n  const scale = Math.min(1, W / w, H / h);\n  w *= scale;\n  h *= scale;\n  return { x: clamp(cx - w / 2, 0, W - w), y: clamp(cy - h / 2, 0, H - h), w, h };\n}\n\n/** CSS transform that draws the natural image into rotated space, before the view scale. */\nfunction orient(r: CropRotation, nw: number, nh: number) {\n  if (r === 90) return `translate(${nh}px, 0) rotate(90deg)`;\n  if (r === 180) return `translate(${nw}px, ${nh}px) rotate(180deg)`;\n  if (r === 270) return `translate(0, ${nw}px) rotate(270deg)`;\n  return \"\";\n}\n\n/**\n * Draws the crop to a canvas and returns it as a Blob. `area` is in the rotated\n * image's pixels, exactly what the cropper reports. Remote images need CORS\n * (the cropper sets crossOrigin=\"anonymous\").\n */\nexport async function cropImage(\n  source: string | HTMLImageElement,\n  area: CropArea,\n  { rotation = 0, type = \"image/jpeg\", quality = 0.92, maxWidth }: { rotation?: CropRotation; type?: string; quality?: number; maxWidth?: number } = {},\n): Promise<Blob> {\n  const img =\n    typeof source === \"string\"\n      ? await new Promise<HTMLImageElement>((resolve, reject) => {\n          const i = new Image();\n          i.crossOrigin = \"anonymous\";\n          i.onload = () => resolve(i);\n          i.onerror = () => reject(new Error(\"Couldn’t load the image\"));\n          i.src = source;\n        })\n      : source;\n  const scale = maxWidth && area.width > maxWidth ? maxWidth / area.width : 1;\n  const canvas = document.createElement(\"canvas\");\n  canvas.width = Math.round(area.width * scale);\n  canvas.height = Math.round(area.height * scale);\n  const ctx = canvas.getContext(\"2d\");\n  if (!ctx) throw new Error(\"Canvas isn’t available\");\n  ctx.imageSmoothingQuality = \"high\";\n  ctx.scale(scale, scale);\n  ctx.translate(-area.x, -area.y);\n  const nw = img.naturalWidth;\n  const nh = img.naturalHeight;\n  // Same mapping as the on-screen transform: natural pixels into rotated space.\n  if (rotation === 90) ctx.transform(0, 1, -1, 0, nh, 0);\n  else if (rotation === 180) ctx.transform(-1, 0, 0, -1, nw, nh);\n  else if (rotation === 270) ctx.transform(0, -1, 1, 0, 0, nw);\n  ctx.drawImage(img, 0, 0);\n  return new Promise((resolve, reject) => canvas.toBlob((b) => (b ? resolve(b) : reject(new Error(\"Couldn’t export the crop\"))), type, quality));\n}\n\n/* ------------------------------------------------------------------ component */\n\nexport const defaultAspects: CropAspect[] = [\n  { label: \"Free\", value: \"free\" },\n  { label: \"1:1\", value: 1 },\n  { label: \"4:5\", value: 4 / 5 },\n  { label: \"16:9\", value: 16 / 9 },\n];\n\nexport type ImageCropperProps = Omit<React.ComponentProps<\"div\">, \"children\" | \"onChange\"> & {\n  src: string;\n  /** Describes the image for assistive tech. */\n  alt?: string;\n  aspects?: CropAspect[];\n  /** The chosen preset's label. */\n  aspect?: string;\n  defaultAspect?: string;\n  onAspectChange?: (label: string) => void;\n  maxZoom?: number;\n  /** Round mask for avatars. The area reported is still the square around it. */\n  shape?: \"rect\" | \"round\";\n  /** Stage height in px. The width follows the container. */\n  height?: number;\n  /** Called when a gesture ends (release, key up, preset, rotate) with the crop in rotated-image pixels. */\n  onCropComplete?: (area: CropArea, rotation: CropRotation) => void;\n};\n\nexport function ImageCropper({\n  src,\n  alt = \"\",\n  aspects = defaultAspects,\n  aspect: aspectProp,\n  defaultAspect,\n  onAspectChange,\n  maxZoom = 4,\n  shape = \"rect\",\n  height = 320,\n  onCropComplete,\n  className,\n  ...rest\n}: ImageCropperProps) {\n  const reduce = !!useReducedMotion();\n  const hintId = useId();\n  const [aspectLabel, setAspectLabel] = useControllableState({\n    value: aspectProp,\n    defaultValue: defaultAspect ?? (shape === \"round\" ? (aspects.find((a) => a.value === 1)?.label ?? aspects[0].label) : aspects[0].label),\n    onChange: onAspectChange,\n  });\n  const ratio = shape === \"round\" ? 1 : (aspects.find((a) => a.label === aspectLabel)?.value ?? \"free\");\n\n  const stageRef = useRef<HTMLDivElement>(null);\n  const flipRef = useRef<HTMLDivElement>(null);\n  const [stage, setStage] = useState({ w: 0, h: height });\n  const [natural, setNatural] = useState<{ w: number; h: number } | null>(null);\n  const [status, setStatus] = useState<\"loading\" | \"ready\" | \"error\">(\"loading\");\n  const [rotation, setRotation] = useState<CropRotation>(0);\n  const [crop, setCrop] = useState<Rect | null>(null);\n  const [view, setView] = useState<View>({ s: 1, ox: 0, oy: 0 });\n  const [gesture, setGesture] = useState<null | \"move\" | \"resize\" | \"pan\">(null);\n  const [activeHandle, setActiveHandle] = useState<Handle | null>(null);\n  const [announce, setAnnounce] = useState(\"\");\n  const drag = useRef<{ kind: \"move\" | \"resize\" | \"pan\"; handle?: Handle; px: number; py: number; crop: Rect; view: View } | null>(null);\n  const settleTimer = useRef<number>(undefined);\n  const tween = useRef<{ stop: () => void } | null>(null);\n  const keyTimer = useRef<number>(undefined);\n\n  // Rotated image size.\n  const W = natural ? (rotation % 180 ? natural.h : natural.w) : 0;\n  const H = natural ? (rotation % 180 ? natural.w : natural.h) : 0;\n  const fitScale = W && stage.w ? Math.min((stage.w - PAD * 2) / W, (stage.h - PAD * 2) / H) : 1;\n  const zoom = view.s / fitScale;\n\n  useEffect(() => {\n    const el = stageRef.current;\n    if (!el) return;\n    const ro = new ResizeObserver(([e]) => setStage({ w: e.contentRect.width, h: e.contentRect.height }));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n\n  useEffect(\n    () => () => {\n      window.clearTimeout(settleTimer.current);\n      window.clearTimeout(keyTimer.current);\n      tween.current?.stop();\n    },\n    [],\n  );\n\n  /** A view that shows the whole rotated image, centered. */\n  const fitView = useCallback((w: number, h: number): View => {\n    const s = Math.min((stage.w - PAD * 2) / w, (stage.h - PAD * 2) / h);\n    return { s, ox: (stage.w - w * s) / 2, oy: (stage.h - h * s) / 2 };\n  }, [stage.w, stage.h]);\n\n  const report = useCallback(\n    (c: Rect | null, r: CropRotation) => {\n      if (!c) return;\n      onCropComplete?.({ x: Math.round(c.x), y: Math.round(c.y), width: Math.round(c.w), height: Math.round(c.h) }, r);\n    },\n    [onCropComplete],\n  );\n  // First layout, and again whenever the stage resizes: fit the image, keep the crop.\n  const laidOut = useRef(\"\");\n  useEffect(() => {\n    if (!natural || !stage.w) return;\n    const key = `${stage.w}x${stage.h}`;\n    if (laidOut.current === key) return;\n    laidOut.current = key;\n    const t = window.setTimeout(() => {\n      setView(fitView(W, H));\n      if (!crop) {\n        const c = initialCrop(W, H, ratio);\n        setCrop(c);\n        report(c, rotation);\n      }\n    }, 0);\n    return () => window.clearTimeout(t);\n  }, [natural, stage.w, stage.h, W, H, ratio, fitView, crop, report, rotation]);\n\n\n\n  /** Tween the view (zoom and pan) on a spring; instant under reduced motion. */\n  const glide = useCallback(\n    (to: View) => {\n      tween.current?.stop();\n      if (reduce) return setView(to);\n      const from = view;\n      tween.current = animate(0, 1, {\n        ...spring.soft,\n        onUpdate: (t) => setView({ s: from.s + (to.s - from.s) * t, ox: from.ox + (to.ox - from.ox) * t, oy: from.oy + (to.oy - from.oy) * t }),\n      });\n    },\n    [reduce, view],\n  );\n\n  /** Keep the crop on screen: pan the view the least amount needed. */\n  const keepVisible = (v: View, c: Rect): View => {\n    const left = v.ox + c.x * v.s;\n    const top = v.oy + c.y * v.s;\n    const right = left + c.w * v.s;\n    const bottom = top + c.h * v.s;\n    let dx = 0;\n    let dy = 0;\n    if (right - left > stage.w - PAD * 2) dx = stage.w / 2 - (left + right) / 2;\n    else if (left < PAD) dx = PAD - left;\n    else if (right > stage.w - PAD) dx = stage.w - PAD - right;\n    if (bottom - top > stage.h - PAD * 2) dy = stage.h / 2 - (top + bottom) / 2;\n    else if (top < PAD) dy = PAD - top;\n    else if (bottom > stage.h - PAD) dy = stage.h - PAD - bottom;\n    return { ...v, ox: v.ox + dx, oy: v.oy + dy };\n  };\n\n  /** Zoom around a screen point, never so far that the crop can't fit on the stage. */\n  const zoomTo = (z: number, around?: { x: number; y: number }) => {\n    if (!crop) return;\n    const maxByCrop = Math.min((stage.w - PAD * 2) / crop.w, (stage.h - PAD * 2) / crop.h);\n    const s = clamp(z * fitScale, fitScale, Math.min(maxZoom * fitScale, maxByCrop));\n    const p = around ?? { x: view.ox + (crop.x + crop.w / 2) * view.s, y: view.oy + (crop.y + crop.h / 2) * view.s };\n    const ix = (p.x - view.ox) / view.s;\n    const iy = (p.y - view.oy) / view.s;\n    tween.current?.stop();\n    setView(keepVisible({ s, ox: p.x - ix * s, oy: p.y - iy * s }, crop));\n  };\n  const zoomCap = crop && stage.w ? Math.max(1, Math.min(maxZoom, Math.min((stage.w - PAD * 2) / crop.w, (stage.h - PAD * 2) / crop.h) / fitScale)) : maxZoom;\n\n  /* ---------------------------------------------------------------- aspect, rotate, reset */\n\n  const chooseAspect = (label: string) => {\n    const next = aspects.find((a) => a.label === label);\n    if (!next || !crop) return;\n    setAspectLabel(label);\n    const c = refit(crop, W, H, next.value);\n    setCrop(c);\n    glide(fitView(W, H));\n    report(c, rotation);\n  };\n\n  const rotate = () => {\n    if (!natural || !crop) return;\n    // Counter-clockwise, like every photo tool: the crop rotates with the picture.\n    const next = (((rotation - 90) % 360) + 360) % 360 as CropRotation;\n    const nW = H;\n    const nH = W;\n    let c: Rect = { x: crop.y, y: W - (crop.x + crop.w), w: crop.h, h: crop.w };\n    if (ratio !== \"free\") c = refit(c, nW, nH, ratio);\n    const to = fitView(nW, nH);\n    const k = view.s / to.s;\n    tween.current?.stop();\n    setRotation(next);\n    setCrop(c);\n    setView(to);\n    report(c, next);\n    setAnnounce(`Rotated to ${next} degrees`);\n    // FLIP: start the new layout turned back 90° at the old size, then let it settle.\n    const el = flipRef.current;\n    if (el && !reduce) animate(el, { rotate: [90, 0], scale: [k, 1] }, spring.soft);\n  };\n\n  const reset = () => {\n    if (!natural) return;\n    const c = initialCrop(natural.w, natural.h, ratio);\n    setRotation(0);\n    setCrop(c);\n    glide(fitView(natural.w, natural.h));\n    report(c, 0);\n  };\n\n  const dirty = !!crop && !!natural && (rotation !== 0 || Math.abs(zoom - 1) > 0.01 || JSON.stringify(roundRect(crop)) !== JSON.stringify(roundRect(initialCrop(W, H, ratio))));\n\n  /* ---------------------------------------------------------------- pointer */\n\n  const onDown = (e: React.PointerEvent, kind: \"move\" | \"resize\" | \"pan\", handle?: Handle) => {\n    if (!crop || e.button !== 0) return;\n    e.stopPropagation();\n    e.preventDefault();\n    (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);\n    window.clearTimeout(settleTimer.current);\n    tween.current?.stop();\n    drag.current = { kind, handle, px: e.clientX, py: e.clientY, crop, view };\n    setGesture(kind);\n    setActiveHandle(handle ?? null);\n  };\n\n  const onMove = (e: React.PointerEvent) => {\n    const d = drag.current;\n    if (!d) return;\n    const dx = (e.clientX - d.px) / d.view.s;\n    const dy = (e.clientY - d.py) / d.view.s;\n    if (d.kind === \"pan\") {\n      setView(keepVisible({ ...d.view, ox: d.view.ox + (e.clientX - d.px), oy: d.view.oy + (e.clientY - d.py) }, d.crop));\n      return;\n    }\n    if (d.kind === \"move\") {\n      setCrop({ ...d.crop, x: clamp(d.crop.x + dx, 0, W - d.crop.w), y: clamp(d.crop.y + dy, 0, H - d.crop.h) });\n      return;\n    }\n    setCrop(resize(d.crop, d.handle!, dx, dy, W, H, ratio, MIN / d.view.s));\n  };\n\n  const onUp = () => {\n    const d = drag.current;\n    if (!d) return;\n    drag.current = null;\n    setGesture(null);\n    setActiveHandle(null);\n    report(crop, rotation);\n    if (d.kind === \"resize\" && crop) {\n      // After a resize settles, bring the crop back to the middle at a comfortable size.\n      const c = crop;\n      settleTimer.current = window.setTimeout(() => {\n        const s = clamp(Math.min((stage.w - PAD * 4) / c.w, (stage.h - PAD * 4) / c.h), fitScale, fitScale * maxZoom);\n        glide({ s, ox: stage.w / 2 - (c.x + c.w / 2) * s, oy: stage.h / 2 - (c.y + c.h / 2) * s });\n      }, 450);\n    }\n  };\n\n  /* ---------------------------------------------------------------- keyboard */\n\n  const onKey = (e: React.KeyboardEvent) => {\n    if (!crop) return;\n    const step = (e.shiftKey ? 10 : 1) / view.s; // 1 or 10 screen pixels\n    const dir = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[e.key];\n    let c: Rect | null = null;\n    if (dir && e.altKey) c = resize(crop, \"se\", dir[0] * step, dir[1] * step, W, H, ratio, MIN / view.s);\n    else if (dir) c = { ...crop, x: clamp(crop.x + dir[0] * step, 0, W - crop.w), y: clamp(crop.y + dir[1] * step, 0, H - crop.h) };\n    else if (e.key === \"+\" || e.key === \"=\") return (e.preventDefault(), zoomTo(zoom * 1.2));\n    else if (e.key === \"-\") return (e.preventDefault(), zoomTo(zoom / 1.2));\n    else if (e.key.toLowerCase() === \"r\" && !e.metaKey && !e.ctrlKey) return (e.preventDefault(), rotate());\n    if (!c) return;\n    e.preventDefault();\n    e.stopPropagation();\n    setCrop(c);\n    setView(keepVisible(view, c));\n    const done = c;\n    window.clearTimeout(keyTimer.current);\n    keyTimer.current = window.setTimeout(() => {\n      report(done, rotation);\n      setAnnounce(`Crop ${Math.round(done.w)} by ${Math.round(done.h)} pixels, at ${Math.round(done.x)}, ${Math.round(done.y)}`);\n    }, 400);\n  };\n\n  /* ---------------------------------------------------------------- render */\n\n  const box = crop ? { left: view.ox + crop.x * view.s, top: view.oy + crop.y * view.s, width: crop.w * view.s, height: crop.h * view.s } : null;\n  const handles: Handle[] = ratio === \"free\" ? [\"nw\", \"n\", \"ne\", \"e\", \"se\", \"s\", \"sw\", \"w\"] : [\"nw\", \"ne\", \"se\", \"sw\"];\n  const size = crop ? `${Math.round(crop.w)} × ${Math.round(crop.h)}` : \"\";\n  const aspectOptions = shape === \"round\" ? [] : aspects;\n\n  return (\n    <div data-state={status} className={cn(\"flex w-full min-w-0 flex-col gap-2.5\", className)} {...rest}>\n      {/* The stage is dark in both themes: judging a crop needs the picture, not the page, to be brightest. */}\n      <div\n        ref={stageRef}\n        data-theme=\"dark\"\n        data-gesture={gesture ?? undefined}\n        className=\"relative w-full touch-none select-none overflow-hidden rounded-xl bg-page ring-1 ring-line\"\n        style={{ height }}\n        onPointerDown={(e) => onDown(e, \"pan\")}\n        onPointerMove={onMove}\n        onPointerUp={onUp}\n        onPointerCancel={onUp}\n        onWheel={(e) => {\n          if (!crop || !(e.ctrlKey || e.metaKey)) return;\n          // Pinch on a trackpad arrives as ctrl+wheel: zoom around the fingers.\n          const r = e.currentTarget.getBoundingClientRect();\n          zoomTo(zoom * Math.exp(-e.deltaY * 0.01), { x: e.clientX - r.left, y: e.clientY - r.top });\n        }}\n      >\n        <div ref={flipRef} className={cn(\"absolute inset-0 transition-opacity duration-200\", status === \"ready\" && crop ? \"opacity-100\" : \"opacity-0\")}>\n          {/* eslint-disable-next-line @next/next/no-img-element -- the natural size and CORS pixels are needed for the crop */}\n          <img\n            src={src}\n            alt={alt}\n            crossOrigin=\"anonymous\"\n            draggable={false}\n            ref={(i) => {\n              // A cached image can finish before hydration, and then onLoad never fires.\n              if (i?.complete && !natural) {\n                if (i.naturalWidth) {\n                  setNatural({ w: i.naturalWidth, h: i.naturalHeight });\n                  setStatus(\"ready\");\n                } else setStatus(\"error\");\n              }\n            }}\n            onLoad={(e) => {\n              const i = e.currentTarget;\n              setNatural({ w: i.naturalWidth, h: i.naturalHeight });\n              setStatus(\"ready\");\n            }}\n            onError={() => setStatus(\"error\")}\n            className=\"pointer-events-none absolute left-0 top-0 max-w-none origin-top-left\"\n            style={natural ? { width: natural.w, height: natural.h, transform: `translate(${view.ox}px, ${view.oy}px) scale(${view.s}) ${orient(rotation, natural.w, natural.h)}` } : { opacity: 0 }}\n          />\n          {box && (\n            <div\n              role=\"group\"\n              tabIndex={0}\n              aria-roledescription=\"crop area\"\n              aria-label={`Crop area, ${size} pixels`}\n              aria-describedby={hintId}\n              onKeyDown={onKey}\n              onPointerDown={(e) => onDown(e, \"move\")}\n              data-dragging={gesture === \"move\" || gesture === \"resize\" ? \"\" : undefined}\n              className={cn(\n                \"group/crop absolute cursor-move outline-none\",\n                // Everything outside the crop is dimmed by one huge spread shadow.\n                \"shadow-[0_0_0_9999px_var(--overlay)] transition-[box-shadow] duration-200\",\n                \"focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-fg-2\",\n                shape === \"round\" ? \"rounded-full\" : \"ring-1 ring-fg/80\",\n              )}\n              style={box}\n            >\n              {shape === \"round\" && <span aria-hidden className=\"pointer-events-none absolute inset-0 rounded-full ring-1 ring-fg/80\" />}\n              {/* Thirds, only while you're adjusting: a guide for the gesture, not decoration at rest. */}\n              <span\n                aria-hidden\n                className={cn(\n                  \"pointer-events-none absolute inset-0 overflow-hidden transition-opacity duration-300 group-data-dragging/crop:opacity-100 group-data-dragging/crop:duration-100\",\n                  shape === \"round\" && \"rounded-full\",\n                  \"opacity-0\",\n                )}\n              >\n                <span className=\"absolute inset-y-0 left-1/3 w-px bg-fg/40\" />\n                <span className=\"absolute inset-y-0 left-2/3 w-px bg-fg/40\" />\n                <span className=\"absolute inset-x-0 top-1/3 h-px bg-fg/40\" />\n                <span className=\"absolute inset-x-0 top-2/3 h-px bg-fg/40\" />\n              </span>\n              {handles.map((h) => (\n                <HandleGrip key={h} handle={h} active={activeHandle === h} onPointerDown={(e) => onDown(e, \"resize\", h)} />\n              ))}\n              {/* Live size while adjusting. */}\n              <span\n                aria-hidden\n                className={cn(\n                  \"tabular pointer-events-none absolute left-1/2 top-full mt-2 -translate-x-1/2 whitespace-nowrap rounded-md bg-page/80 px-1.5 py-0.5 font-mono text-[11px] text-fg\",\n                  \"transition-opacity duration-150\",\n                  gesture === \"resize\" ? \"opacity-100\" : \"opacity-0\",\n                )}\n              >\n                {size}\n              </span>\n            </div>\n          )}\n        </div>\n\n        {status === \"loading\" && (\n          <div aria-hidden className=\"absolute inset-5 grid place-items-center rounded-lg bg-hover animate-pulse-soft motion-reduce:animate-none\">\n            <svg width=\"20\" height=\"20\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" strokeLinejoin=\"round\" className=\"text-fg-4\">\n              <rect x=\"2.5\" y=\"3\" width=\"11\" height=\"10\" rx=\"1.75\" />\n              <circle cx=\"6\" cy=\"6.5\" r=\"1.1\" />\n              <path d=\"m2.75 11.5 3.4-3 2.4 2 1.6-1.4 3.1 2.6\" />\n            </svg>\n          </div>\n        )}\n        {status === \"error\" && (\n          <div role=\"alert\" className=\"absolute inset-0 grid place-items-center p-6 text-center\">\n            <div className=\"flex flex-col items-center gap-1\">\n              <p className=\"text-[13px] font-medium text-fg\">Couldn’t load this image</p>\n              <p className=\"text-[12px] text-fg-3\">Check the file or your connection, then choose it again.</p>\n            </div>\n          </div>\n        )}\n      </div>\n\n      <p id={hintId} className=\"sr-only\">\n        Arrow keys move the crop, Shift moves further, Option or Alt with arrows resizes. Plus and minus zoom, R rotates.\n      </p>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announce}\n      </span>\n\n      {/* Toolbar */}\n      <div className=\"flex flex-wrap items-center gap-x-3 gap-y-2\">\n        {aspectOptions.length > 1 && (\n          <AspectPicker options={aspectOptions} value={aspectLabel} onChange={chooseAspect} disabled={status !== \"ready\"} reduce={reduce} />\n        )}\n        <div className=\"ml-auto flex min-w-0 items-center gap-1\">\n          <ToolButton label=\"Zoom out\" onClick={() => zoomTo(zoom / 1.25)} disabled={status !== \"ready\" || zoom <= 1.001}>\n            <path d=\"M4 8h8\" />\n          </ToolButton>\n          <Slider.Root\n            value={Math.min(zoom, zoomCap)}\n            min={1}\n            max={Math.max(zoomCap, 1.0001)}\n            step={0.01}\n            disabled={status !== \"ready\" || zoomCap <= 1.001}\n            onValueChange={(v) => zoomTo(v)}\n            className=\"w-24 sm:w-28\"\n          >\n            <Slider.Control className=\"flex h-8 w-full touch-none items-center data-disabled:opacity-50\">\n              <Slider.Track className=\"relative h-[3px] w-full rounded-full bg-fg/15\">\n                <Slider.Indicator className=\"rounded-full bg-fg\" />\n                <Slider.Thumb\n                  aria-label=\"Zoom\"\n                  getAriaValueText={(_, v) => `${Math.round(v * 100)}%`}\n                  className={cn(\n                    \"size-3.5 rounded-full border border-line-2 bg-raised shadow-[var(--shadow)] transition-[scale] duration-150 ease-out-quart data-dragging:scale-110 motion-reduce:transition-none\",\n                    \"has-[:focus-visible]:outline-solid has-[:focus-visible]:outline-1 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-fg-3\",\n                  )}\n                />\n              </Slider.Track>\n            </Slider.Control>\n          </Slider.Root>\n          <ToolButton label=\"Zoom in\" onClick={() => zoomTo(zoom * 1.25)} disabled={status !== \"ready\" || zoom >= zoomCap - 0.001}>\n            <path d=\"M4 8h8M8 4v8\" />\n          </ToolButton>\n          <span aria-hidden className=\"mx-1 h-4 w-px bg-line-2\" />\n          <ToolButton label=\"Rotate left\" shortcut=\"R\" onClick={rotate} disabled={status !== \"ready\"}>\n            <path d=\"M3.5 6.5h6.25a2.75 2.75 0 0 1 2.75 2.75v3.25\" />\n            <path d=\"M5.75 4.25 3.5 6.5l2.25 2.25\" />\n          </ToolButton>\n          <ToolButton label=\"Reset crop\" onClick={reset} disabled={!dirty}>\n            <path d=\"M3 8a5 5 0 1 0 1.5-3.55M3 2.75V5h2.25\" />\n          </ToolButton>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nconst roundRect = (r: Rect) => [Math.round(r.x), Math.round(r.y), Math.round(r.w), Math.round(r.h)];\n\n/** Drag a handle: the opposite edge or corner stays put; a locked ratio is kept from the longer pull. */\nfunction resize(c: Rect, h: Handle, dx: number, dy: number, W: number, H: number, ratio: number | \"free\", min: number): Rect {\n  let { x, y, w, h: ht } = c;\n  const east = h.includes(\"e\");\n  const west = h.includes(\"w\");\n  const north = h.includes(\"n\");\n  const south = h.includes(\"s\");\n  if (ratio === \"free\") {\n    if (east) w = clamp(c.w + dx, min, W - c.x);\n    if (west) {\n      const nx = clamp(c.x + dx, 0, c.x + c.w - min);\n      w = c.w + (c.x - nx);\n      x = nx;\n    }\n    if (south) ht = clamp(c.h + dy, min, H - c.y);\n    if (north) {\n      const ny = clamp(c.y + dy, 0, c.y + c.h - min);\n      ht = c.h + (c.y - ny);\n      y = ny;\n    }\n    return { x, y, w, h: ht };\n  }\n  const ax = east ? c.x : c.x + c.w; // anchor\n  const ay = south ? c.y : c.y + c.h;\n  const pullW = c.w + (east ? dx : -dx);\n  const pullH = c.h + (south ? dy : -dy);\n  let nw = Math.max(pullW, pullH * ratio);\n  const maxW = Math.min(east ? W - ax : ax, (south ? H - ay : ay) * ratio);\n  nw = clamp(nw, Math.max(min, min * ratio), maxW);\n  const nh = nw / ratio;\n  return { x: east ? ax : ax - nw, y: south ? ay : ay - nh, w: nw, h: nh };\n}\n\nconst CURSOR: Record<Handle, string> = { n: \"cursor-ns-resize\", s: \"cursor-ns-resize\", e: \"cursor-ew-resize\", w: \"cursor-ew-resize\", ne: \"cursor-nesw-resize\", sw: \"cursor-nesw-resize\", nw: \"cursor-nwse-resize\", se: \"cursor-nwse-resize\" };\n\nfunction HandleGrip({ handle, active, onPointerDown }: { handle: Handle; active: boolean; onPointerDown: (e: React.PointerEvent) => void }) {\n  const corner = handle.length === 2;\n  const pos: Record<Handle, string> = {\n    nw: \"left-0 top-0 -translate-x-1/2 -translate-y-1/2\",\n    ne: \"right-0 top-0 translate-x-1/2 -translate-y-1/2\",\n    se: \"right-0 bottom-0 translate-x-1/2 translate-y-1/2\",\n    sw: \"left-0 bottom-0 -translate-x-1/2 translate-y-1/2\",\n    n: \"left-1/2 top-0 -translate-x-1/2 -translate-y-1/2\",\n    s: \"left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2\",\n    e: \"right-0 top-1/2 translate-x-1/2 -translate-y-1/2\",\n    w: \"left-0 top-1/2 -translate-x-1/2 -translate-y-1/2\",\n  };\n  // Corners are L-shaped brackets that hug the frame; edges are short bars.\n  // Each bracket starts on the handle's center (the crop corner) and hugs the frame line.\n  const bracket: Record<string, string> = {\n    nw: \"left-[calc(50%-1.5px)] top-[calc(50%-1.5px)] border-l-[3px] border-t-[3px] rounded-tl-[3px]\",\n    ne: \"right-[calc(50%-1.5px)] top-[calc(50%-1.5px)] border-r-[3px] border-t-[3px] rounded-tr-[3px]\",\n    se: \"right-[calc(50%-1.5px)] bottom-[calc(50%-1.5px)] border-r-[3px] border-b-[3px] rounded-br-[3px]\",\n    sw: \"left-[calc(50%-1.5px)] bottom-[calc(50%-1.5px)] border-l-[3px] border-b-[3px] rounded-bl-[3px]\",\n  };\n  return (\n    <span\n      aria-hidden\n      data-active={active ? \"\" : undefined}\n      onPointerDown={onPointerDown}\n      className={cn(\"group/handle absolute z-10 size-6 pointer-coarse:size-11\", pos[handle], CURSOR[handle])}\n    >\n      {corner ? (\n        <span\n          className={cn(\n            \"absolute size-4 border-fg transition-[scale] duration-150 ease-out-quart group-hover/handle:scale-110 group-data-active/handle:scale-125 motion-reduce:transition-none\",\n            bracket[handle],\n          )}\n          // Grow from the corner itself, so the bracket stretches along the frame rather than drifting.\n          style={{ transformOrigin: `${handle.includes(\"w\") ? \"left\" : \"right\"} ${handle.includes(\"n\") ? \"top\" : \"bottom\"}` }}\n        />\n      ) : (\n        <span\n          className={cn(\n            \"absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-fg transition-[scale] duration-150 ease-out-quart group-hover/handle:scale-110 group-data-active/handle:scale-125 motion-reduce:transition-none\",\n            handle === \"n\" || handle === \"s\" ? \"h-[3px] w-4\" : \"h-4 w-[3px]\",\n          )}\n        />\n      )}\n    </span>\n  );\n}\n\nfunction AspectPicker({ options, value, onChange, disabled, reduce }: { options: CropAspect[]; value: string; onChange: (v: string) => void; disabled: boolean; reduce: boolean }) {\n  const layoutId = useId();\n  return (\n    <ToggleGroup\n      aria-label=\"Aspect ratio\"\n      value={[value]}\n      onValueChange={(v) => {\n        if (v[0]) onChange(v[0]);\n      }}\n      disabled={disabled}\n      className=\"relative flex h-8 items-center rounded-lg border border-line bg-raised p-0.5 shadow-[var(--shadow)] data-disabled:opacity-50\"\n    >\n      {options.map((o) => (\n        <Toggle\n          key={o.label}\n          value={o.label}\n          className={cn(\n            \"relative h-full rounded-md px-2.5 font-mono text-[11.5px] text-fg-3 outline-none\",\n            \"transition-[color,scale] duration-150 ease-out-quart hover:text-fg active:scale-[0.96] data-pressed:text-fg\",\n            \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n          )}\n        >\n          {o.label === value && (\n            // One pill slides between presets.\n            <motion.span layoutId={layoutId} aria-hidden className=\"absolute inset-0 rounded-md bg-hover ring-1 ring-line-2\" transition={reduce ? { duration: 0 } : spring.snappy} />\n          )}\n          <span className=\"relative\">{o.label}</span>\n        </Toggle>\n      ))}\n    </ToggleGroup>\n  );\n}\n\nfunction ToolButton({ label, shortcut, onClick, disabled, children }: { label: string; shortcut?: string; onClick: () => void; disabled?: boolean; children: React.ReactNode }) {\n  return (\n    <button\n      type=\"button\"\n      aria-label={label}\n      aria-keyshortcuts={shortcut}\n      title={shortcut ? `${label} (${shortcut})` : label}\n      onClick={onClick}\n      disabled={disabled}\n      className={cn(\n        \"relative grid size-8 shrink-0 place-items-center rounded-lg text-fg-2 outline-none\",\n        \"transition-[background-color,color,scale] duration-150 ease-out-quart hover:bg-hover hover:text-fg active:scale-[0.9] active:duration-75\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3 disabled:pointer-events-none disabled:opacity-40\",\n        \"before:absolute before:-inset-1.5 before:content-[''] pointer-fine:before:hidden\",\n      )}\n    >\n      <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n        {children}\n      </svg>\n    </button>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/image-cropper"
}
