{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "image-cropper",
  "title": "Image Cropper",
  "description": "Drag, pinch or wheel to frame a picture, with keyboard nudges and an aspect lock, returning a crop rectangle that can never leave the image.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://miseui.junsu4621.workers.dev/r/motion.json",
    "https://miseui.junsu4621.workers.dev/r/slider.json"
  ],
  "files": [
    {
      "path": "registry/miseui/ui/image-cropper.tsx",
      "content": "import type * as React from \"react\"\r\nimport { useId, useRef, useState } from \"react\"\r\nimport { motion, useReducedMotion } from \"motion/react\"\r\nimport { useCornerResize } from \"@/registry/miseui/ui/image-cropper-corners\"\r\nimport { frameCentre, useCropGestures, useWheelZoom } from \"@/registry/miseui/ui/image-cropper-gestures\"\r\nimport { coverScale, cropFromView, zoomAt, type CropCorner, type CropRect, type CropSize } from \"@/registry/miseui/ui/image-cropper-math\"\r\nimport { CornerHandle, CropFrame, type CropFrameVariant } from \"@/registry/miseui/ui/image-cropper-parts\"\r\nimport { cropToBlob, useCropGeometry, type CropState } from \"@/registry/miseui/ui/image-cropper-state\"\r\nimport { Slider } from \"@/registry/miseui/ui/slider\"\r\nimport { cn } from \"@/registry/miseui/lib/utils\"\r\n\r\ntype ImageCropperVariant = CropFrameVariant\r\ntype ImageCropperEffect = \"spring\"\r\n\r\nconst DEFAULT_MAX_ZOOM = 4\r\nconst FRAME_INSET = 16\r\nconst CORNERS: readonly CropCorner[] = [\"nw\", \"ne\", \"sw\", \"se\"]\r\nconst ASPECT: Record<ImageCropperVariant, number | null> = { free: null, square: 1, circle: 1, wide: 16 / 9 }\r\n/** Free takes the picture's own shape once it is known, which overrides the fallback here. */\r\nconst STAGE_BASE_CLASS = \"relative w-full touch-none overflow-hidden rounded-lg bg-muted outline-none select-none focus-visible:ring-[3px] focus-visible:ring-ring/50\"\r\nconst STAGE_CLASS: Record<ImageCropperVariant, string> = { free: \"aspect-[4/3]\", square: \"aspect-square\", circle: \"aspect-square\", wide: \"aspect-[16/10]\" }\r\n\r\ninterface ImageCropperProps extends Omit<React.ComponentProps<\"div\">, \"children\" | \"defaultValue\"> {\r\n  /** Names the cropper for assistive tech, and the image when `src` is used. */\r\n  label: string\r\n  src?: string\r\n  /** Draw your own picture instead of `src`, at `imageSize`. */\r\n  children?: React.ReactNode\r\n  /** Needed with `children`. With `src` it is read from the file. */\r\n  imageSize?: CropSize\r\n  /** `free` a frame with corners to pull, `square` with thirds while dragging, `circle` for avatars, `wide` a 16:9 band. */\r\n  variant?: ImageCropperVariant\r\n  /** `spring` lets a drag run past an edge with resistance and springs back on release. */\r\n  effect?: ImageCropperEffect\r\n  /** Locks `free` to a shape, as width over height. The other variants have their own. Holding Shift on a corner locks it for one drag. */\r\n  aspect?: number\r\n  defaultCrop?: CropRect\r\n  /** Called when a gesture settles, in the image's own pixels. Always inside the image. */\r\n  onCropChange?: (crop: CropRect) => void\r\n  /** How far past the smallest fit the image can zoom. */\r\n  maxZoom?: number\r\n  disabled?: boolean\r\n  zoomLabel?: string\r\n  hint?: string\r\n}\r\n\r\nfunction ZoomRow({ crop, image, disabled, label }: { crop: CropState; image: CropSize | null; disabled: boolean; label: string }) {\r\n  const { geometry, live, commit, maxZoom } = crop\r\n  const least = geometry && image ? coverScale(image, geometry.frame) : 1\r\n  const current = geometry && image ? cropFromView(geometry.view, geometry.frame, image) : null\r\n  const setZoom = (zoom: number) => {\r\n    const now = live.current\r\n    if (!now || !image) return\r\n    commit({ ...now, view: zoomAt(now.view, least * zoom, frameCentre(now.frame), image, now.frame, maxZoom) })\r\n  }\r\n  return (\r\n    <div className=\"flex min-w-0 items-center gap-3\">\r\n      <Slider\r\n        aria-label={label}\r\n        disabled={disabled || !geometry}\r\n        min={1}\r\n        max={maxZoom}\r\n        step={0.01}\r\n        value={[geometry ? geometry.view.scale / least : 1]}\r\n        formatValue={(zoom) => `${Math.round(zoom * 100)}%`}\r\n        onValueChange={([zoom]) => setZoom(zoom)}\r\n        className=\"min-w-0 flex-1\"\r\n      />\r\n      <p data-slot=\"image-cropper-size\" className=\"shrink-0 text-[13px] text-muted-foreground tabular-nums\">\r\n        {current ? `${current.width} x ${current.height}` : null}\r\n      </p>\r\n    </div>\r\n  )\r\n}\r\n\r\nfunction StageImage({ crop, image, src, label, onLoaded, children }: { crop: CropState; image: CropSize | null; src?: string; label: string; onLoaded: (size: CropSize) => void; children?: React.ReactNode }) {\r\n  if (!image && !src) return null\r\n  const { x, y, scale } = crop.motion\r\n  return (\r\n    <motion.div data-slot=\"image-cropper-image\" className=\"absolute top-0 left-0\" style={{ x, y, scale, originX: 0, originY: 0, width: image?.width, height: image?.height }}>\r\n      {src ? (\r\n        <img\r\n          src={src}\r\n          alt={label}\r\n          draggable={false}\r\n          className=\"block size-full max-w-none\"\r\n          onLoad={(event) => onLoaded({ width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight })}\r\n        />\r\n      ) : (\r\n        children\r\n      )}\r\n    </motion.div>\r\n  )\r\n}\r\n\r\nfunction ImageCropper({\r\n  label, src, children, imageSize, variant = \"square\", effect, aspect: aspectProp, defaultCrop, onCropChange,\r\n  maxZoom = DEFAULT_MAX_ZOOM, disabled = false, zoomLabel = \"Zoom\",\r\n  hint = \"Drag to move the image. Arrow keys move the crop, plus and minus zoom.\",\r\n  className,\r\n  ...props\r\n}: ImageCropperProps) {\r\n  const stageRef = useRef<HTMLDivElement>(null)\r\n  const hintId = useId()\r\n  const reduceMotion = useReducedMotion() ?? false\r\n  const [loaded, setLoaded] = useState<CropSize | null>(null)\r\n  const [speaking, setSpeaking] = useState(false)\r\n  const image = imageSize ?? loaded\r\n  const free = variant === \"free\"\r\n  const aspect = free ? (aspectProp ?? null) : ASPECT[variant]\r\n  const crop = useCropGeometry({ stageRef, image, aspect, inset: variant === \"wide\" ? 0 : FRAME_INSET, free, defaultCrop, maxZoom, onCropChange })\r\n  const speak = () => setSpeaking(true)\r\n  const { surface, dragging, overscrolled } = useCropGestures({ crop, image, elastic: effect === \"spring\" && !reduceMotion, disabled, onKeyboardChange: speak })\r\n  useWheelZoom(crop, stageRef, image, disabled)\r\n  const cornerProps = useCornerResize(crop, image, aspect, speak)\r\n  const { geometry } = crop\r\n  const current = geometry && image ? cropFromView(geometry.view, geometry.frame, image) : null\r\n  return (\r\n    <div data-slot=\"image-cropper\" data-variant={variant} data-effect={effect} data-overscrolled={overscrolled ? \"true\" : undefined} className={cn(\"grid w-full min-w-0 grid-cols-[minmax(0,1fr)] gap-3\", disabled && \"opacity-50\", className)} {...props}>\r\n      <div\r\n        ref={stageRef}\r\n        data-slot=\"image-cropper-stage\"\r\n        role=\"group\"\r\n        aria-roledescription=\"image cropper\"\r\n        aria-label={label}\r\n        aria-describedby={hintId}\r\n        aria-disabled={disabled || undefined}\r\n        tabIndex={disabled ? undefined : 0}\r\n        style={free && image ? { aspectRatio: `${image.width} / ${image.height}` } : undefined}\r\n        className={cn(STAGE_BASE_CLASS, STAGE_CLASS[variant], !disabled && (dragging ? \"cursor-grabbing\" : \"cursor-grab\"))}\r\n        {...surface}\r\n        onBlur={() => setSpeaking(false)}\r\n      >\r\n        <StageImage crop={crop} image={image} src={src} label={label} onLoaded={setLoaded}>\r\n          {children}\r\n        </StageImage>\r\n        {geometry ? <CropFrame frame={geometry.frame} variant={variant} guides={dragging} /> : null}\r\n        {free && geometry && !disabled ? CORNERS.map((corner) => <CornerHandle key={corner} corner={corner} frame={geometry.frame} {...cornerProps(corner)} />) : null}\r\n      </div>\r\n      <ZoomRow crop={crop} image={image} disabled={disabled} label={zoomLabel} />\r\n      <span id={hintId} className=\"sr-only\">{hint}</span>\r\n      <span data-slot=\"image-cropper-live\" aria-live=\"polite\" className=\"sr-only\">{speaking && current ? `Crop ${current.width} by ${current.height}, at ${current.x}, ${current.y}` : \"\"}</span>\r\n    </div>\r\n  )\r\n}\r\n\r\nexport { ImageCropper, cropToBlob, type CropRect, type ImageCropperEffect, type ImageCropperProps, type ImageCropperVariant }\r\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/image-cropper-corners.ts",
      "content": "import type * as React from \"react\"\r\nimport { useRef } from \"react\"\r\nimport { NUDGES, NUDGE_FAST_PX, NUDGE_PX } from \"@/registry/miseui/ui/image-cropper-gestures\"\r\nimport { imageRect, intersect, resizeFrame, type CropCorner, type CropPoint, type CropRect, type CropSize } from \"@/registry/miseui/ui/image-cropper-math\"\r\nimport type { CropState } from \"@/registry/miseui/ui/image-cropper-state\"\r\n\r\nconst MIN_FRAME_PX = 48\r\n\r\nfunction useCornerResize({ live, commit }: CropState, image: CropSize | null, aspect: number | null, onKeyboardChange: () => void) {\r\n  const start = useRef<{ point: CropPoint; frame: CropRect } | null>(null)\r\n\r\n  const apply = (corner: CropCorner, base: CropRect, dx: number, dy: number, lockedShape: boolean) => {\r\n    const geometry = live.current\r\n    if (!geometry || !image) return\r\n    const ratio = aspect ?? (lockedShape ? base.width / base.height : null)\r\n    const bounds = intersect({ x: 0, y: 0, ...geometry.stage }, imageRect(geometry.view, image))\r\n    commit({ ...geometry, frame: resizeFrame(base, corner, dx, dy, bounds, MIN_FRAME_PX, ratio) })\r\n  }\r\n\r\n  return (corner: CropCorner) => ({\r\n    onPointerDown: (event: React.PointerEvent<HTMLElement>) => {\r\n      if (event.button !== 0 || !live.current) return\r\n      // The stage underneath would otherwise start panning the picture too.\r\n      event.stopPropagation()\r\n      event.currentTarget.setPointerCapture(event.pointerId)\r\n      start.current = { point: { x: event.clientX, y: event.clientY }, frame: live.current.frame }\r\n    },\r\n    onPointerMove: (event: React.PointerEvent<HTMLElement>) => {\r\n      if (!start.current) return\r\n      event.stopPropagation()\r\n      apply(corner, start.current.frame, event.clientX - start.current.point.x, event.clientY - start.current.point.y, event.shiftKey)\r\n    },\r\n    onPointerUp: (event: React.PointerEvent<HTMLElement>) => {\r\n      event.stopPropagation()\r\n      start.current = null\r\n    },\r\n    onKeyDown: (event: React.KeyboardEvent<HTMLElement>) => {\r\n      const nudge = NUDGES[event.key]\r\n      if (!nudge || !live.current) return\r\n      event.preventDefault()\r\n      event.stopPropagation()\r\n      const step = event.shiftKey ? NUDGE_FAST_PX : NUDGE_PX\r\n      apply(corner, live.current.frame, -nudge.x * step, -nudge.y * step, false)\r\n      onKeyboardChange()\r\n    },\r\n  })\r\n}\r\n\r\nexport { useCornerResize }\r\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/image-cropper-gestures.ts",
      "content": "import type * as React from \"react\"\r\nimport { useEffect, useRef, useState } from \"react\"\r\nimport { clampView, dragView, zoomAt, type CropPoint, type CropRect, type CropSize, type CropView } from \"@/registry/miseui/ui/image-cropper-math\"\r\nimport type { CropGeometry, CropState } from \"@/registry/miseui/ui/image-cropper-state\"\r\n\r\nconst WHEEL_SENSITIVITY = 0.0015\r\nconst KEY_ZOOM = 1.1\r\nconst NUDGE_PX = 4\r\nconst NUDGE_FAST_PX = 16\r\n\r\nconst NUDGES: Record<string, CropPoint> = {\r\n  ArrowLeft: { x: 1, y: 0 },\r\n  ArrowRight: { x: -1, y: 0 },\r\n  ArrowUp: { x: 0, y: 1 },\r\n  ArrowDown: { x: 0, y: -1 },\r\n}\r\n\r\ntype Gesture = { kind: \"drag\"; start: CropPoint; view: CropView } | { kind: \"pinch\"; distance: number; middle: CropPoint; view: CropView }\r\n\r\nconst midpoint = (a: CropPoint, b: CropPoint) => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 })\r\nconst distanceBetween = (a: CropPoint, b: CropPoint) => Math.hypot(a.x - b.x, a.y - b.y)\r\nconst sameView = (a: CropView, b: CropView) => a.x === b.x && a.y === b.y && a.scale === b.scale\r\nconst frameCentre = (frame: CropRect) => ({ x: frame.x + frame.width / 2, y: frame.y + frame.height / 2 })\r\n\r\nfunction gestureFrom(points: readonly CropPoint[], view: CropView): Gesture | null {\r\n  if (points.length === 0) return null\r\n  if (points.length === 1) return { kind: \"drag\", start: points[0], view }\r\n  return { kind: \"pinch\", distance: distanceBetween(points[0], points[1]), middle: midpoint(points[0], points[1]), view }\r\n}\r\n\r\ninterface FollowContext {\r\n  image: CropSize\r\n  frame: CropRect\r\n  elastic: boolean\r\n  maxZoom: number\r\n}\r\n\r\nfunction viewDuring(gesture: Gesture, points: readonly CropPoint[], { image, frame, elastic, maxZoom }: FollowContext): CropView | null {\r\n  if (gesture.kind === \"drag\") {\r\n    if (!points[0]) return null\r\n    return dragView(gesture.view, points[0].x - gesture.start.x, points[0].y - gesture.start.y, image, frame, elastic)\r\n  }\r\n  if (!points[1]) return null\r\n  const middle = midpoint(points[0], points[1])\r\n  const scale = gesture.view.scale * (distanceBetween(points[0], points[1]) / Math.max(1, gesture.distance))\r\n  const moved = { ...gesture.view, x: gesture.view.x + middle.x - gesture.middle.x, y: gesture.view.y + middle.y - gesture.middle.y }\r\n  return zoomAt(moved, scale, middle, image, frame, maxZoom)\r\n}\r\n\r\nfunction viewForKey(event: Pick<KeyboardEvent, \"key\" | \"shiftKey\">, geometry: CropGeometry, image: CropSize, maxZoom: number): CropView | null {\r\n  const nudge = NUDGES[event.key]\r\n  if (nudge) {\r\n    const step = event.shiftKey ? NUDGE_FAST_PX : NUDGE_PX\r\n    return dragView(geometry.view, nudge.x * step, nudge.y * step, image, geometry.frame)\r\n  }\r\n  const factor = event.key === \"+\" || event.key === \"=\" ? KEY_ZOOM : event.key === \"-\" ? 1 / KEY_ZOOM : null\r\n  if (factor === null) return null\r\n  return zoomAt(geometry.view, geometry.view.scale * factor, frameCentre(geometry.frame), image, geometry.frame, maxZoom)\r\n}\r\n\r\n/** React attaches wheel listeners as passive, and a passive one cannot stop the page scrolling behind the crop. */\r\nfunction useWheelZoom({ live, commit, maxZoom }: CropState, stageRef: React.RefObject<HTMLElement | null>, image: CropSize | null, disabled: boolean) {\r\n  useEffect(() => {\r\n    const element = stageRef.current\r\n    if (!element || disabled) return\r\n    const onWheel = (event: WheelEvent) => {\r\n      const geometry = live.current\r\n      if (!geometry || !image) return\r\n      event.preventDefault()\r\n      const rect = element.getBoundingClientRect()\r\n      const next = geometry.view.scale * Math.exp(-event.deltaY * WHEEL_SENSITIVITY)\r\n      commit({ ...geometry, view: zoomAt(geometry.view, next, { x: event.clientX - rect.left, y: event.clientY - rect.top }, image, geometry.frame, maxZoom) })\r\n    }\r\n    element.addEventListener(\"wheel\", onWheel, { passive: false })\r\n    return () => element.removeEventListener(\"wheel\", onWheel)\r\n  }, [stageRef, disabled, live, image, commit, maxZoom])\r\n}\r\n\r\ninterface GestureOptions {\r\n  crop: CropState\r\n  image: CropSize | null\r\n  /** Lets a drag run past the edges and springs it back on release. */\r\n  elastic: boolean\r\n  disabled: boolean\r\n  onKeyboardChange: () => void\r\n}\r\n\r\nconst local = (event: React.PointerEvent<HTMLElement>) => {\r\n  const rect = event.currentTarget.getBoundingClientRect()\r\n  return { x: event.clientX - rect.left, y: event.clientY - rect.top }\r\n}\r\n\r\n/** Only a release past the edge travels; a plain drag has already landed where it should. */\r\nfunction settle({ live, commit, maxZoom }: CropState, image: CropSize | null, elastic: boolean) {\r\n  const geometry = live.current\r\n  if (!geometry || !image) return\r\n  const view = clampView(geometry.view, image, geometry.frame, maxZoom)\r\n  commit({ ...geometry, view }, { spring: elastic && !sameView(view, geometry.view) })\r\n}\r\n\r\nfunction keyMove(event: React.KeyboardEvent<HTMLElement>, { live, commit, maxZoom }: CropState, image: CropSize | null, onChange: () => void) {\r\n  const geometry = live.current\r\n  if (!geometry || !image) return\r\n  const next = viewForKey(event, geometry, image, maxZoom)\r\n  if (!next) return\r\n  event.preventDefault()\r\n  commit({ ...geometry, view: next })\r\n  onChange()\r\n}\r\n\r\nfunction useCropGestures({ crop, image, elastic, disabled, onKeyboardChange }: GestureOptions) {\r\n  const pointers = useRef(new Map<number, CropPoint>())\r\n  const gesture = useRef<Gesture | null>(null)\r\n  const [dragging, setDragging] = useState(false)\r\n  const [overscrolled, setOverscrolled] = useState(false)\r\n  const { live, preview, stop, maxZoom } = crop\r\n\r\n  const restart = () => {\r\n    gesture.current = live.current ? gestureFrom([...pointers.current.values()], live.current.view) : null\r\n  }\r\n  const follow = () => {\r\n    const geometry = live.current\r\n    const next = gesture.current && geometry && image ? viewDuring(gesture.current, [...pointers.current.values()], { image, frame: geometry.frame, elastic, maxZoom }) : null\r\n    if (!next || !geometry || !image) return\r\n    preview(next)\r\n    setOverscrolled(!sameView(next, clampView(next, image, geometry.frame)))\r\n  }\r\n  const onPointerEnd = (event: React.PointerEvent<HTMLElement>) => {\r\n    if (!pointers.current.delete(event.pointerId)) return\r\n    restart()\r\n    if (pointers.current.size > 0) return\r\n    setDragging(false)\r\n    setOverscrolled(false)\r\n    settle(crop, image, elastic)\r\n  }\r\n  const surface = {\r\n    onPointerDown: (event: React.PointerEvent<HTMLElement>) => {\r\n      if (disabled || event.button !== 0) return\r\n      event.currentTarget.setPointerCapture(event.pointerId)\r\n      stop()\r\n      pointers.current.set(event.pointerId, local(event))\r\n      restart()\r\n      setDragging(true)\r\n    },\r\n    onPointerMove: (event: React.PointerEvent<HTMLElement>) => {\r\n      if (!pointers.current.has(event.pointerId)) return\r\n      pointers.current.set(event.pointerId, local(event))\r\n      follow()\r\n    },\r\n    onPointerUp: onPointerEnd,\r\n    onPointerCancel: onPointerEnd,\r\n    onKeyDown: (event: React.KeyboardEvent<HTMLElement>) => {\r\n      if (disabled || event.target !== event.currentTarget) return\r\n      keyMove(event, crop, image, onKeyboardChange)\r\n    },\r\n  }\r\n  return { surface, dragging, overscrolled }\r\n}\r\n\r\nexport { NUDGES, NUDGE_FAST_PX, NUDGE_PX, frameCentre, useCropGestures, useWheelZoom }\r\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/image-cropper-math.ts",
      "content": "import { rubberband } from \"@/registry/miseui/lib/motion\"\n\ninterface CropSize {\n  width: number\n  height: number\n}\n\ninterface CropPoint {\n  x: number\n  y: number\n}\n\n/** A rectangle. In the stage's px for the frame, in the image's own px for the crop. */\ninterface CropRect extends CropPoint, CropSize {}\n\n/** Where the image sits under the frame: its top-left corner in stage px, and its scale. */\ninterface CropView extends CropPoint {\n  scale: number\n}\n\ntype CropCorner = \"nw\" | \"ne\" | \"sw\" | \"se\"\n\nconst clamp = (value: number, low: number, high: number) => Math.min(Math.max(low, high), Math.max(low, value))\n\n/** The largest box of this shape inside the stage, centred. No aspect means the stage itself. */\nfunction fitFrame(stage: CropSize, aspect: number | null, inset = 16): CropRect {\n  const room = { width: Math.max(1, stage.width - inset * 2), height: Math.max(1, stage.height - inset * 2) }\n  const wide = aspect !== null && room.width / room.height > aspect\n  const width = aspect === null ? room.width : wide ? room.height * aspect : room.width\n  const height = aspect === null ? room.height : wide ? room.height : room.width / aspect\n  return { x: (stage.width - width) / 2, y: (stage.height - height) / 2, width, height }\n}\n\n/** The smallest scale at which the image still covers the frame: zooming out further would crop thin air. */\nconst coverScale = (image: CropSize, frame: CropSize) => Math.max(frame.width / image.width, frame.height / image.height)\n\nfunction panBounds(scale: number, image: CropSize, frame: CropRect) {\n  return {\n    minX: frame.x + frame.width - image.width * scale,\n    maxX: frame.x,\n    minY: frame.y + frame.height - image.height * scale,\n    maxY: frame.y,\n  }\n}\n\nfunction clampView(view: CropView, image: CropSize, frame: CropRect, maxZoom = Number.POSITIVE_INFINITY): CropView {\n  const least = coverScale(image, frame)\n  const scale = clamp(view.scale, least, least * maxZoom)\n  const bounds = panBounds(scale, image, frame)\n  return { scale, x: clamp(view.x, bounds.minX, bounds.maxX), y: clamp(view.y, bounds.minY, bounds.maxY) }\n}\n\nfunction centerView(image: CropSize, target: CropRect, scale = coverScale(image, target)): CropView {\n  return { scale, x: target.x + (target.width - image.width * scale) / 2, y: target.y + (target.height - image.height * scale) / 2 }\n}\n\n/** Zooms around a point, so whatever is under the fingers or the cursor stays under them. */\nfunction zoomAt(view: CropView, nextScale: number, focal: CropPoint, image: CropSize, frame: CropRect, maxZoom?: number): CropView {\n  const least = coverScale(image, frame)\n  const scale = clamp(nextScale, least, maxZoom === undefined ? Number.POSITIVE_INFINITY : least * maxZoom)\n  const ratio = scale / view.scale\n  return clampView({ scale, x: focal.x - (focal.x - view.x) * ratio, y: focal.y - (focal.y - view.y) * ratio }, image, frame, maxZoom)\n}\n\nfunction elasticAxis(value: number, low: number, high: number, dimension: number) {\n  if (value > high) return high + rubberband(value - high, dimension)\n  if (value < low) return low + rubberband(value - low, dimension)\n  return value\n}\n\n/** Past an edge the image either stops dead or, when `elastic`, follows with growing resistance until released. */\nfunction dragView(start: CropView, dx: number, dy: number, image: CropSize, frame: CropRect, elastic = false): CropView {\n  const raw = { scale: start.scale, x: start.x + dx, y: start.y + dy }\n  if (!elastic) return clampView(raw, image, frame)\n  const bounds = panBounds(start.scale, image, frame)\n  return {\n    scale: start.scale,\n    x: elasticAxis(raw.x, bounds.minX, bounds.maxX, frame.width),\n    y: elasticAxis(raw.y, bounds.minY, bounds.maxY, frame.height),\n  }\n}\n\n/** The answer, in the image's own px. Clamped again here because rounding must not step outside the image either. */\nfunction cropFromView(view: CropView, frame: CropRect, image: CropSize): CropRect {\n  const width = Math.min(image.width, Math.round(frame.width / view.scale))\n  const height = Math.min(image.height, Math.round(frame.height / view.scale))\n  return {\n    x: clamp(Math.round((frame.x - view.x) / view.scale), 0, image.width - width),\n    y: clamp(Math.round((frame.y - view.y) / view.scale), 0, image.height - height),\n    width,\n    height,\n  }\n}\n\nfunction viewFromCrop(crop: CropRect, frame: CropRect): CropView {\n  const scale = frame.width / crop.width\n  return { scale, x: frame.x - crop.x * scale, y: frame.y - crop.y * scale }\n}\n\nconst imageRect = (view: CropView, image: CropSize): CropRect => ({ x: view.x, y: view.y, width: image.width * view.scale, height: image.height * view.scale })\n\nfunction intersect(a: CropRect, b: CropRect): CropRect {\n  const x = Math.max(a.x, b.x)\n  const y = Math.max(a.y, b.y)\n  return { x, y, width: Math.max(0, Math.min(a.x + a.width, b.x + b.width) - x), height: Math.max(0, Math.min(a.y + a.height, b.y + b.height) - y) }\n}\n\nfunction lockedSize(rawWidth: number, rawHeight: number, roomX: number, roomY: number, aspect: number, least: number) {\n  const fitted = Math.min(Math.max(rawWidth, rawHeight * aspect), roomX, roomY * aspect)\n  const width = Math.min(Math.max(fitted, least), roomX, roomY * aspect)\n  return { width, height: width / aspect }\n}\n\n/** Moves one corner; the opposite corner stays put. `bounds` is where the image and stage overlap, so the frame can never leave the image. */\nfunction resizeFrame(frame: CropRect, corner: CropCorner, dx: number, dy: number, bounds: CropRect, least: number, aspect: number | null = null): CropRect {\n  const west = corner.endsWith(\"w\")\n  const north = corner.startsWith(\"n\")\n  const anchorX = west ? frame.x + frame.width : frame.x\n  const anchorY = north ? frame.y + frame.height : frame.y\n  const roomX = west ? anchorX - bounds.x : bounds.x + bounds.width - anchorX\n  const roomY = north ? anchorY - bounds.y : bounds.y + bounds.height - anchorY\n  const rawWidth = frame.width + (west ? -dx : dx)\n  const rawHeight = frame.height + (north ? -dy : dy)\n  const size = aspect\n    ? lockedSize(rawWidth, rawHeight, roomX, roomY, aspect, least)\n    : { width: Math.min(Math.max(rawWidth, least), roomX), height: Math.min(Math.max(rawHeight, least), roomY) }\n  return { x: west ? anchorX - size.width : anchorX, y: north ? anchorY - size.height : anchorY, ...size }\n}\n\nconst scaleFrame = (frame: CropRect, from: CropSize, to: CropSize): CropRect => {\n  const ratioX = to.width / from.width\n  const ratioY = to.height / from.height\n  return { x: frame.x * ratioX, y: frame.y * ratioY, width: frame.width * ratioX, height: frame.height * ratioY }\n}\n\nconst sameRect = (a: CropRect | null, b: CropRect | null) => a?.x === b?.x && a?.y === b?.y && a?.width === b?.width && a?.height === b?.height\n\nexport {\n  centerView,\n  clampView,\n  coverScale,\n  cropFromView,\n  dragView,\n  fitFrame,\n  imageRect,\n  intersect,\n  resizeFrame,\n  sameRect,\n  scaleFrame,\n  viewFromCrop,\n  zoomAt,\n  type CropCorner,\n  type CropPoint,\n  type CropRect,\n  type CropSize,\n  type CropView,\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/image-cropper-parts.tsx",
      "content": "import type * as React from \"react\"\nimport type { CropCorner, CropRect } from \"@/registry/miseui/ui/image-cropper-math\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\ntype CropFrameVariant = \"free\" | \"square\" | \"circle\" | \"wide\"\n\n/** Black and white in both themes: this sits on a photo, and the page's colours mean nothing there. */\nconst DIM_CLASS = \"shadow-[0_0_0_9999px_rgb(0_0_0/0.55)]\"\nconst FRAME_CLASS: Record<CropFrameVariant, string> = {\n  free: \"ring-1 ring-white/90\",\n  square: \"ring-1 ring-white/90\",\n  circle: \"rounded-full ring-1 ring-white/90\",\n  wide: \"border-y border-white/70\",\n}\n\nconst CORNER_NAME: Record<CropCorner, string> = { nw: \"top left\", ne: \"top right\", sw: \"bottom left\", se: \"bottom right\" }\nconst CORNER_CURSOR: Record<CropCorner, string> = { nw: \"cursor-nwse-resize\", ne: \"cursor-nesw-resize\", sw: \"cursor-nesw-resize\", se: \"cursor-nwse-resize\" }\n/** Two thin bars make the L, turned to face the frame's own corner. */\nconst CORNER_MARK: Record<CropCorner, string> = {\n  nw: \"top-1/2 left-1/2 border-t-2 border-l-2\",\n  ne: \"top-1/2 right-1/2 border-t-2 border-r-2\",\n  sw: \"bottom-1/2 left-1/2 border-b-2 border-l-2\",\n  se: \"bottom-1/2 right-1/2 border-b-2 border-r-2\",\n}\n\nconst THIRDS = [\"33.333%\", \"66.666%\"]\n\nfunction ThirdsGrid() {\n  return (\n    <div data-slot=\"image-cropper-thirds\" aria-hidden=\"true\" className=\"absolute inset-0\">\n      {THIRDS.map((at) => (\n        <span key={`v${at}`} className=\"absolute inset-y-0 w-px bg-white/40\" style={{ left: at }} />\n      ))}\n      {THIRDS.map((at) => (\n        <span key={`h${at}`} className=\"absolute inset-x-0 h-px bg-white/40\" style={{ top: at }} />\n      ))}\n    </div>\n  )\n}\n\nfunction CropFrame({ frame, variant, guides }: { frame: CropRect; variant: CropFrameVariant; guides: boolean }) {\n  return (\n    <div\n      data-slot=\"image-cropper-frame\"\n      aria-hidden=\"true\"\n      className={cn(\"pointer-events-none absolute\", DIM_CLASS, FRAME_CLASS[variant])}\n      style={{ left: frame.x, top: frame.y, width: frame.width, height: frame.height }}\n    >\n      {guides && (variant === \"square\" || variant === \"free\") ? <ThirdsGrid /> : null}\n    </div>\n  )\n}\n\ntype CornerHandleProps = React.ComponentProps<\"button\"> & {\n  corner: CropCorner\n  frame: CropRect\n}\n\n/** 24 px is enough for a mouse; a finger gets the full 40 px without the mark growing. */\nfunction CornerHandle({ corner, frame, className, ...props }: CornerHandleProps) {\n  const left = corner.endsWith(\"w\") ? frame.x : frame.x + frame.width\n  const top = corner.startsWith(\"n\") ? frame.y : frame.y + frame.height\n  return (\n    <button\n      type=\"button\"\n      data-slot=\"image-cropper-corner\"\n      data-corner={corner}\n      aria-label={`Resize crop from the ${CORNER_NAME[corner]} corner`}\n      className={cn(\"absolute size-6 -translate-x-1/2 -translate-y-1/2 touch-none rounded-sm outline-none focus-visible:ring-[3px] focus-visible:ring-white pointer-coarse:size-10\", CORNER_CURSOR[corner], className)}\n      style={{ left, top }}\n      {...props}\n    >\n      <span aria-hidden=\"true\" className={cn(\"absolute size-3 border-white\", CORNER_MARK[corner])} />\n    </button>\n  )\n}\n\nexport { CornerHandle, CropFrame, type CropFrameVariant }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/image-cropper-state.ts",
      "content": "import type * as React from \"react\"\r\nimport { useCallback, useEffect, useLayoutEffect, useRef, useState } from \"react\"\r\nimport { animate, useMotionValue, type AnimationPlaybackControls } from \"motion/react\"\r\nimport { SPRING_MOMENTUM } from \"@/registry/miseui/lib/motion\"\r\nimport {\r\n  centerView,\r\n  clampView,\r\n  coverScale,\r\n  cropFromView,\r\n  fitFrame,\r\n  sameRect,\r\n  scaleFrame,\r\n  viewFromCrop,\r\n  type CropRect,\r\n  type CropSize,\r\n  type CropView,\r\n} from \"@/registry/miseui/ui/image-cropper-math\"\r\n\r\ninterface CropGeometry {\r\n  stage: CropSize\r\n  frame: CropRect\r\n  view: CropView\r\n  /** Different image or shape: the old crop no longer means anything, so start over. */\r\n  key: string\r\n}\r\n\r\ninterface GeometryInput {\r\n  stage: CropSize\r\n  image: CropSize\r\n  aspect: number | null\r\n  inset: number\r\n  free: boolean\r\n  key: string\r\n  previous: CropGeometry | null\r\n  initial: CropRect | undefined\r\n}\r\n\r\nfunction firstGeometry({ stage, image, aspect, inset, free, key, initial }: GeometryInput): CropGeometry {\r\n  const frame = fitFrame(stage, aspect, inset)\r\n  if (initial) return { stage, frame, key, view: clampView(viewFromCrop(initial, frame), image, frame) }\r\n  // Free starts with the whole picture in view, not just enough of it to fill the frame.\r\n  const whole = { x: 0, y: 0, ...stage }\r\n  return { stage, frame, key, view: centerView(image, free ? whole : frame, free ? coverScale(image, whole) : undefined) }\r\n}\r\n\r\n/** A new stage size keeps what was chosen: the same pixels of the image stay inside the frame. */\r\nfunction nextGeometry(input: GeometryInput): CropGeometry {\r\n  const { stage, image, aspect, inset, free, key, previous } = input\r\n  if (!previous || previous.key !== key) return firstGeometry(input)\r\n  const crop = cropFromView(previous.view, previous.frame, image)\r\n  const frame = free ? scaleFrame(previous.frame, previous.stage, stage) : fitFrame(stage, aspect, inset)\r\n  return { stage, frame, key, view: clampView(viewFromCrop(crop, frame), image, frame) }\r\n}\r\n\r\ninterface CropperOptions {\r\n  stageRef: React.RefObject<HTMLElement | null>\r\n  image: CropSize | null\r\n  aspect: number | null\r\n  inset: number\r\n  free: boolean\r\n  defaultCrop?: CropRect\r\n  maxZoom: number\r\n  onCropChange?: (crop: CropRect) => void\r\n}\r\n\r\n/** Kept in a ref so an inline callback does not re-subscribe the observer on every render. */\r\nfunction useLatest<Value>(value: Value) {\r\n  const ref = useRef(value)\r\n  useEffect(() => {\r\n    ref.current = value\r\n  }, [value])\r\n  return ref\r\n}\r\n\r\n/** Observed in a layout effect, because the first entry then arrives before the first paint. */\r\nfunction useStageSize(stageRef: React.RefObject<HTMLElement | null>, enabled: boolean, onSize: (size: CropSize) => void) {\r\n  useLayoutEffect(() => {\r\n    const element = stageRef.current\r\n    if (!element || !enabled || typeof ResizeObserver === \"undefined\") return\r\n    const observer = new ResizeObserver(([entry]) => {\r\n      const size = { width: Math.round(entry.contentRect.width), height: Math.round(entry.contentRect.height) }\r\n      if (size.width > 0 && size.height > 0) onSize(size)\r\n    })\r\n    observer.observe(element)\r\n    return () => observer.disconnect()\r\n  }, [stageRef, enabled, onSize])\r\n}\r\n\r\n/** The picture's position outside React, so a drag moves it every frame without a render. */\r\nfunction useCropMotion() {\r\n  const running = useRef<readonly AnimationPlaybackControls[]>([])\r\n  const x = useMotionValue(0)\r\n  const y = useMotionValue(0)\r\n  const scale = useMotionValue(1)\r\n  const stop = useCallback(() => {\r\n    running.current.forEach((animation) => animation.stop())\r\n    running.current = []\r\n  }, [])\r\n  const show = useCallback(\r\n    (view: CropView) => {\r\n      x.set(view.x)\r\n      y.set(view.y)\r\n      scale.set(view.scale)\r\n    },\r\n    [x, y, scale],\r\n  )\r\n  const springTo = useCallback(\r\n    (view: CropView) => {\r\n      running.current = [animate(x, view.x, SPRING_MOMENTUM), animate(y, view.y, SPRING_MOMENTUM)]\r\n    },\r\n    [x, y],\r\n  )\r\n  useEffect(() => stop, [stop])\r\n  return { x, y, scale, stop, show, springTo }\r\n}\r\n\r\nfunction useCropGeometry({ stageRef, image, aspect, inset, free, defaultCrop, maxZoom, onCropChange }: CropperOptions) {\r\n  const imageWidth = image?.width ?? 0\r\n  const imageHeight = image?.height ?? 0\r\n  const key = `${imageWidth}x${imageHeight}:${aspect}:${inset}:${free}`\r\n  const [geometry, setGeometry] = useState<CropGeometry | null>(null)\r\n  const live = useRef<CropGeometry | null>(null)\r\n  const report = useLatest(onCropChange)\r\n  const reported = useRef<CropRect | null>(null)\r\n  const initial = useRef(defaultCrop)\r\n  const { x, y, scale, stop, show, springTo } = useCropMotion()\r\n\r\n  /** Moves the picture now without settling on it. */\r\n  const preview = useCallback(\r\n    (view: CropView) => {\r\n      if (!live.current) return\r\n      live.current = { ...live.current, view }\r\n      show(view)\r\n    },\r\n    [show],\r\n  )\r\n\r\n  const commit = useCallback(\r\n    (next: CropGeometry, options: { spring?: boolean } = {}) => {\r\n      stop()\r\n      live.current = next\r\n      setGeometry(next)\r\n      if (options.spring) springTo(next.view)\r\n      else show(next.view)\r\n      const crop = cropFromView(next.view, next.frame, { width: imageWidth, height: imageHeight })\r\n      if (sameRect(crop, reported.current)) return\r\n      reported.current = crop\r\n      report.current?.(crop)\r\n    },\r\n    [stop, show, springTo, imageWidth, imageHeight, report],\r\n  )\r\n\r\n  const measured = useCallback(\r\n    (stage: CropSize) => {\r\n      const previous = live.current\r\n      if (previous?.key === key && previous.stage.width === stage.width && previous.stage.height === stage.height) return\r\n      commit(nextGeometry({ stage, image: { width: imageWidth, height: imageHeight }, aspect, inset, free, key, previous, initial: initial.current }))\r\n    },\r\n    [imageWidth, imageHeight, aspect, inset, free, key, commit],\r\n  )\r\n  useStageSize(stageRef, imageWidth > 0 && imageHeight > 0, measured)\r\n\r\n  return { geometry, live, preview, commit, stop, motion: { x, y, scale }, maxZoom }\r\n}\r\n\r\ntype CropState = ReturnType<typeof useCropGeometry>\r\n\r\n/** Draws the crop on a canvas at its real size, for uploading. Throws if the source is cross-origin without CORS: browsers refuse to read those back. */\r\nfunction cropToBlob(source: CanvasImageSource, crop: CropRect, options: { type?: string; quality?: number } = {}) {\r\n  const canvas = document.createElement(\"canvas\")\r\n  canvas.width = crop.width\r\n  canvas.height = crop.height\r\n  const context = canvas.getContext(\"2d\")\r\n  if (!context) return Promise.reject(new Error(\"This browser cannot draw to a canvas.\"))\r\n  context.drawImage(source, crop.x, crop.y, crop.width, crop.height, 0, 0, crop.width, crop.height)\r\n  return new Promise<Blob>((resolve, reject) => {\r\n    canvas.toBlob(\r\n      (blob) => (blob ? resolve(blob) : reject(new Error(\"The crop could not be encoded.\"))),\r\n      options.type ?? \"image/png\",\r\n      options.quality,\r\n    )\r\n  })\r\n}\r\n\r\nexport { cropToBlob, nextGeometry, useCropGeometry, type CropGeometry, type CropState }\r\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "forms"
  ],
  "type": "registry:ui"
}