{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"image-cropper","type":"registry:ui","title":"Image Cropper","description":"Move, zoom and turn a picture under a crop of any shape, then draw the crop into a file with cropImage.","dependencies":["lucide-react"],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/lib-locale.json","https://dashral.com/r/button.json","https://dashral.com/r/slider.json","https://dashral.com/r/spinner.json"],"files":[{"path":"registry/dashral/image-cropper.tsx","type":"registry:file","target":"components/dashral/image-cropper.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\nimport { RotateCwIcon, ZoomInIcon, ZoomOutIcon } from \"lucide-react\"\n\nimport { useLocale } from \"./lib/locale\"\nimport { cn } from \"./lib/utils\"\nimport { Button } from \"./button\"\nimport { Slider } from \"./slider\"\nimport { Spinner } from \"./spinner\"\n\n/** The part of the picture that's kept, in the picture's own pixels (before it's turned), and how far it's turned. */\ntype CropArea = { x: number; y: number; width: number; height: number; rotation: number }\n\ntype ImageCropperProps = {\n  /** The picture: a URL, or URL.createObjectURL(file) for a file someone just picked. */\n  src: string\n  /** Width ÷ height of the crop: 1 for a square or circle, 16 / 9 for a banner. */\n  aspect?: number\n  /** The crop's outline. A circle still crops a square; draw it round where you show it. */\n  shape?: \"rect\" | \"circle\"\n  /** Controlled zoom, from minZoom to maxZoom. 1 fills the crop exactly. */\n  zoom?: number\n  defaultZoom?: number\n  onZoomChange?: (zoom: number) => void\n  minZoom?: number\n  maxZoom?: number\n  /** Controlled turn in degrees, a multiple of 90, clockwise. */\n  rotation?: number\n  defaultRotation?: number\n  onRotationChange?: (rotation: number) => void\n  /** Called whenever the crop changes, and once the picture has loaded. Pass the area to cropImage. */\n  onCropChange?: (area: CropArea) => void\n  /** The zoom slider and the rotate button under the picture. */\n  showControls?: boolean\n  /** \"anonymous\" for a picture on another site (it must allow it), so cropImage can read it. */\n  crossOrigin?: \"anonymous\" | \"use-credentials\"\n  /** Height of the picture area in pixels; it's as wide as its container. */\n  height?: number\n  disabled?: boolean\n  /** The picture area's name for screen readers. */\n  \"aria-label\"?: string\n  className?: string\n  classNames?: { viewport?: string; image?: string; frame?: string; controls?: string }\n}\n\ntype Point = { x: number; y: number }\n\n// Turning by a multiple of 90° in screen space (y points down), exact, with no sin/cos rounding.\nfunction turn({ x, y }: Point, degrees: number): Point {\n  switch (((degrees % 360) + 360) % 360) {\n    case 90:\n      return { x: -y, y: x }\n    case 180:\n      return { x: -x, y: -y }\n    case 270:\n      return { x: y, y: -x }\n    default:\n      return { x, y }\n  }\n}\n\nconst clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))\nconst sideways = (rotation: number) => (((rotation % 180) + 180) % 180) === 90\n// Room around the crop, so the picture outside it shows, dimmed.\nconst inset = 24\n\n/**\n * A picture to crop before it's uploaded: drag it (or use the arrow keys) to move it under the crop, zoom with the\n * slider, the wheel, a pinch or + and −, and turn it 90° at a time. It never leaves an empty corner in the crop.\n * onCropChange gives the area; cropImage turns it into a file.\n */\nfunction ImageCropper({\n  src,\n  aspect: aspectProp = 1,\n  shape = \"rect\",\n  zoom: zoomProp,\n  defaultZoom = 1,\n  onZoomChange,\n  minZoom = 1,\n  maxZoom = 3,\n  rotation: rotationProp,\n  defaultRotation = 0,\n  onRotationChange,\n  onCropChange,\n  showControls = true,\n  crossOrigin,\n  height = 288,\n  disabled = false,\n  \"aria-label\": ariaLabel,\n  className,\n  classNames,\n}: ImageCropperProps) {\n  const { t } = useLocale()\n  const viewportRef = React.useRef<HTMLDivElement>(null)\n  const instructionsId = React.useId()\n\n  const [zoomState, setZoomState] = React.useState(defaultZoom)\n  const [rotationState, setRotationState] = React.useState(defaultRotation)\n  const zoom = clamp(zoomProp ?? zoomState, minZoom, maxZoom)\n  // Quarter turns only: anything else snaps to the nearest one.\n  const rotation = Math.round((rotationProp ?? rotationState) / 90) * 90\n  const aspect = Number.isFinite(aspectProp) && aspectProp > 0 ? aspectProp : 1\n\n  // The picture's own size, the picture area's size, and the point of the picture at the crop's centre (in the\n  // picture's pixels): kept that way, the crop stays on the same spot when the area resizes or the picture turns.\n  const [natural, setNatural] = React.useState<{ width: number; height: number } | null>(null)\n  const [failed, setFailed] = React.useState(false)\n  const imageRef = React.useRef<HTMLImageElement>(null)\n  const [box, setBox] = React.useState({ width: 0, height: 0 })\n  const [center, setCenter] = React.useState<Point | null>(null)\n  // While a finger or the mouse moves the picture it follows exactly; otherwise zoom and turns glide.\n  const [moving, setMoving] = React.useState(false)\n\n  // A new picture starts again, centred and unzoomed.\n  const [shownSrc, setShownSrc] = React.useState(src)\n  if (shownSrc !== src) {\n    setShownSrc(src)\n    setNatural(null)\n    setFailed(false)\n    setCenter(null)\n    if (zoomProp === undefined) setZoomState(defaultZoom)\n    if (rotationProp === undefined) setRotationState(defaultRotation)\n  }\n\n  function loaded(image: HTMLImageElement) {\n    if (image.naturalWidth && image.naturalHeight) setNatural({ width: image.naturalWidth, height: image.naturalHeight })\n    else setFailed(true)\n  }\n  // A picture already in the cache can finish loading before React listens for it (when the page is hydrated).\n  React.useEffect(() => {\n    const image = imageRef.current\n    if (image?.complete && !natural) loaded(image)\n  }, [src]) // eslint-disable-line react-hooks/exhaustive-deps\n\n  // Placed: loaded and laid out once, at rest. Only then does it fade in and start to glide, so it doesn't sweep in\n  // from its own size.\n  const [placed, setPlaced] = React.useState(false)\n  const ready = Boolean(natural && box.width)\n  React.useEffect(() => {\n    if (!ready) return setPlaced(false)\n    const frameId = requestAnimationFrame(() => setPlaced(true))\n    return () => cancelAnimationFrame(frameId)\n  }, [ready])\n\n  React.useEffect(() => {\n    const viewport = viewportRef.current\n    if (!viewport) return\n    const observer = new ResizeObserver(([entry]) => entry && setBox({ width: entry.contentRect.width, height: entry.contentRect.height }))\n    observer.observe(viewport)\n    return () => observer.disconnect()\n  }, [])\n\n  // The crop: the largest box of the right shape inside the area, less the inset.\n  const room = { width: Math.max(0, box.width - inset * 2), height: Math.max(0, box.height - inset * 2) }\n  const frame = room.width / room.height > aspect ? { width: room.height * aspect, height: room.height } : { width: room.width, height: room.width / aspect }\n\n  // The picture as turned, and the scale at which it just covers the crop (zoom 1).\n  const turned = natural && (sideways(rotation) ? { width: natural.height, height: natural.width } : natural)\n  const cover = turned ? Math.max(frame.width / turned.width, frame.height / turned.height) : 1\n  const scale = cover * zoom\n\n  /** Keeps the crop inside the picture, at a given zoom and turn. */\n  const fit = React.useCallback(\n    (point: Point, atScale: number, atRotation: number): Point => {\n      if (!natural || !(atScale > 0)) return point\n      const size = sideways(atRotation) ? { width: natural.height, height: natural.width } : natural\n      const half = { x: natural.width / 2, y: natural.height / 2 }\n      // From the picture's centre to the crop's, turned the way it's shown.\n      const offset = turn({ x: point.x - half.x, y: point.y - half.y }, atRotation)\n      const roomX = Math.max(0, (size.width - frame.width / atScale) / 2)\n      const roomY = Math.max(0, (size.height - frame.height / atScale) / 2)\n      const back = turn({ x: clamp(offset.x, -roomX, roomX), y: clamp(offset.y, -roomY, roomY) }, -atRotation)\n      return { x: half.x + back.x, y: half.y + back.y }\n    },\n    [natural, frame.width, frame.height]\n  )\n\n  const centre = natural ? fit(center ?? { x: natural.width / 2, y: natural.height / 2 }, scale, rotation) : null\n\n  // The area in the picture's pixels, reported whenever it changes.\n  const area = React.useMemo<CropArea | null>(() => {\n    if (!natural || !centre || !frame.width) return null\n    const across = frame.width / scale\n    const down = frame.height / scale\n    const [exactWidth, exactHeight] = sideways(rotation) ? [down, across] : [across, down]\n    // Whole pixels, kept inside the picture, so rounding never adds a sliver from outside it.\n    const width = Math.max(1, Math.min(natural.width, Math.round(exactWidth)))\n    const height = Math.max(1, Math.min(natural.height, Math.round(exactHeight)))\n    return {\n      x: clamp(Math.round(centre.x - exactWidth / 2), 0, natural.width - width),\n      y: clamp(Math.round(centre.y - exactHeight / 2), 0, natural.height - height),\n      width,\n      height,\n      rotation: ((rotation % 360) + 360) % 360,\n    }\n  }, [natural, centre?.x, centre?.y, frame.width, frame.height, scale, rotation]) // eslint-disable-line react-hooks/exhaustive-deps\n\n  const onCropChangeRef = React.useRef(onCropChange)\n  onCropChangeRef.current = onCropChange\n  // The picture is part of it: a new picture of the same size, cropped the same way, is still a new crop.\n  const areaKey = area && `${src} ${area.x},${area.y},${area.width},${area.height},${area.rotation}`\n  React.useEffect(() => {\n    if (area) onCropChangeRef.current?.(area)\n  }, [areaKey]) // eslint-disable-line react-hooks/exhaustive-deps\n\n  function setZoom(next: number, around: Point = { x: 0, y: 0 }) {\n    const value = clamp(next, minZoom, maxZoom)\n    if (value === zoom || !centre) return\n    // The point under the pointer (or the crop's centre) stays where it is.\n    const nextScale = cover * value\n    const shift = turn(around, -rotation)\n    const k = 1 / scale - 1 / nextScale\n    setCenter(fit({ x: centre.x + shift.x * k, y: centre.y + shift.y * k }, nextScale, rotation))\n    if (zoomProp === undefined) setZoomState(value)\n    onZoomChange?.(value)\n  }\n\n  function moveBy(dx: number, dy: number) {\n    if (!centre) return\n    const step = turn({ x: dx, y: dy }, -rotation)\n    setCenter(fit({ x: centre.x - step.x / scale, y: centre.y - step.y / scale }, scale, rotation))\n  }\n\n  function rotate() {\n    const next = rotation + 90\n    if (rotationProp === undefined) setRotationState(next)\n    onRotationChange?.(next)\n  }\n\n  // Pointers on the picture: one moves it, two pinch to zoom.\n  const pointers = React.useRef(new Map<number, Point>())\n  /** The first two pointers on the picture. */\n  const pair = () => [...pointers.current.values()] as [Point, Point]\n  const pinch = React.useRef<{ distance: number; zoom: number } | null>(null)\n\n  function relative(event: { clientX: number; clientY: number }): Point {\n    const rect = viewportRef.current!.getBoundingClientRect()\n    return { x: event.clientX - rect.left - rect.width / 2, y: event.clientY - rect.top - rect.height / 2 }\n  }\n\n  function onPointerDown(event: React.PointerEvent<HTMLDivElement>) {\n    if (disabled || !natural || (event.pointerType === \"mouse\" && event.button !== 0)) return\n    event.currentTarget.setPointerCapture(event.pointerId)\n    pointers.current.set(event.pointerId, { x: event.clientX, y: event.clientY })\n    if (pointers.current.size === 2) {\n      const [a, b] = pair()\n      pinch.current = { distance: Math.hypot(a.x - b.x, a.y - b.y), zoom }\n    }\n    setMoving(true)\n  }\n\n  function onPointerMove(event: React.PointerEvent<HTMLDivElement>) {\n    const last = pointers.current.get(event.pointerId)\n    if (!last) return\n    const now = { x: event.clientX, y: event.clientY }\n    pointers.current.set(event.pointerId, now)\n    if (pointers.current.size === 2 && pinch.current) {\n      const [a, b] = pair()\n      const mid = relative({ clientX: (a.x + b.x) / 2, clientY: (a.y + b.y) / 2 })\n      setZoom((pinch.current.zoom * Math.hypot(a.x - b.x, a.y - b.y)) / pinch.current.distance, mid)\n    } else if (pointers.current.size === 1) {\n      moveBy(now.x - last.x, now.y - last.y)\n    }\n  }\n\n  function onPointerUp(event: React.PointerEvent<HTMLDivElement>) {\n    pointers.current.delete(event.pointerId)\n    if (pointers.current.size < 2) pinch.current = null\n    if (pointers.current.size === 0) setMoving(false)\n  }\n\n  // The wheel zooms around the pointer. It needs a listener that isn't passive, to keep the page from scrolling.\n  const wheel = React.useRef<(event: WheelEvent) => void>(() => {})\n  wheel.current = (event) => {\n    if (disabled || !natural) return\n    event.preventDefault()\n    // Some mice scroll by lines or pages rather than pixels.\n    const pixels = event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 400 : 1)\n    setZoom(zoom * Math.exp(-clamp(pixels, -200, 200) * 0.002), relative(event))\n  }\n  React.useEffect(() => {\n    const viewport = viewportRef.current\n    if (!viewport) return\n    const listener = (event: WheelEvent) => wheel.current(event)\n    viewport.addEventListener(\"wheel\", listener, { passive: false })\n    return () => viewport.removeEventListener(\"wheel\", listener)\n  }, [])\n\n  function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {\n    if (disabled || !natural) return\n    const step = event.shiftKey ? 40 : 8\n    // The arrows move the picture the way they point, as a drag would.\n    const moves: Record<string, [number, number]> = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] }\n    const move = moves[event.key]\n    if (move) moveBy(move[0], move[1])\n    else if (event.key === \"+\" || event.key === \"=\") setZoom(zoom + 0.1)\n    else if (event.key === \"-\" || event.key === \"_\") setZoom(zoom - 0.1)\n    else return\n    event.preventDefault()\n  }\n\n  // Where the picture sits: its centre moved from the crop's by the turned offset, then scaled and turned.\n  const offset = natural && centre ? turn({ x: (natural.width / 2 - centre.x) * scale, y: (natural.height / 2 - centre.y) * scale }, rotation) : { x: 0, y: 0 }\n\n  return (\n    <div data-slot=\"image-cropper\" className={cn(\"grid gap-3\", disabled && \"opacity-50\", className)}>\n      <div\n        ref={viewportRef}\n        role=\"group\"\n        aria-label={ariaLabel ?? t(\"cropArea\")}\n        aria-describedby={instructionsId}\n        aria-disabled={disabled || undefined}\n        tabIndex={disabled ? -1 : 0}\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={onPointerUp}\n        onPointerCancel={onPointerUp}\n        onLostPointerCapture={onPointerUp}\n        onKeyDown={onKeyDown}\n        data-moving={moving || undefined}\n        style={{ height }}\n        className={cn(\n          \"group/cropper relative isolate touch-none overflow-hidden rounded-lg border bg-muted outline-none select-none\",\n          \"focus-visible:ring-2 focus-visible:ring-ring/50\",\n          natural && !disabled && \"cursor-grab data-moving:cursor-grabbing\",\n          classNames?.viewport\n        )}\n      >\n        <span id={instructionsId} className=\"sr-only\">\n          {t(\"cropInstructions\")}\n        </span>\n        <img\n          key={src}\n          ref={imageRef}\n          src={src}\n          alt=\"\"\n          draggable={false}\n          crossOrigin={crossOrigin}\n          onLoad={(event) => loaded(event.currentTarget)}\n          onError={() => setFailed(true)}\n          style={{\n            width: natural?.width,\n            height: natural?.height,\n            transform: `translate(-50%, -50%) translate(${offset.x}px, ${offset.y}px) rotate(${rotation}deg) scale(${scale})`,\n          }}\n          className={cn(\n            \"pointer-events-none absolute top-1/2 left-1/2 max-w-none origin-center\",\n            // Fades in once it's loaded and placed, instead of popping in at its own size; after that zoom and turns glide.\n            \"transition-opacity duration-(--duration-standard) ease-standard\",\n            placed ? \"opacity-100\" : \"opacity-0\",\n            placed && !moving && \"transition-[opacity,transform] motion-reduce:transition-opacity\",\n            classNames?.image\n          )}\n        />\n        {/* While the picture is on its way the area breathes, and a spinner says so after a beat (a quick picture never\n            flashes it); both go as the picture fades in. */}\n        {!placed && !failed && (\n          <>\n            <span aria-hidden className=\"pointer-events-none absolute inset-0 bg-foreground/8 motion-safe:animate-pulse\" />\n            <span className=\"pointer-events-none absolute inset-0 grid animate-in place-items-center delay-200 duration-(--duration-standard) fill-mode-both fade-in-0 motion-reduce:animate-none\">\n              <Spinner size=\"sm\" label={t(\"loading\")} className=\"text-muted-foreground\" />\n            </span>\n          </>\n        )}\n        {failed && (\n          <p role=\"alert\" className=\"absolute inset-0 flex items-center justify-center p-6 text-center text-sm text-muted-foreground\">\n            {t(\"imageFailed\")}\n          </p>\n        )}\n        {frame.width > 0 && !failed && (\n          <div\n            aria-hidden\n            style={{ width: frame.width, height: frame.height }}\n            className={cn(\n              // The outside of the crop dimmed by the frame's own shadow, so it follows a circle as well as a box.\n              \"pointer-events-none absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 shadow-[0_0_0_9999px_rgb(0_0_0/0.5)] ring-1 ring-white/80\",\n              // It arrives with the picture, so an empty crop never shows on its own.\n              \"transition-opacity duration-(--duration-standard) ease-standard\",\n              placed ? \"opacity-100\" : \"opacity-0\",\n              shape === \"circle\" ? \"rounded-full\" : \"rounded-[2px]\",\n              classNames?.frame\n            )}\n          >\n            {/* Thirds, while the picture moves, to line it up. */}\n            <span\n              className={cn(\n                \"absolute inset-0 overflow-hidden opacity-0 transition-opacity duration-(--duration-fast) ease-standard group-data-moving/cropper:opacity-100\",\n                shape === \"circle\" && \"rounded-full\"\n              )}\n            >\n              <span className=\"absolute inset-y-0 left-1/3 w-px bg-white/40\" />\n              <span className=\"absolute inset-y-0 left-2/3 w-px bg-white/40\" />\n              <span className=\"absolute inset-x-0 top-1/3 h-px bg-white/40\" />\n              <span className=\"absolute inset-x-0 top-2/3 h-px bg-white/40\" />\n            </span>\n          </div>\n        )}\n      </div>\n      {showControls && (\n        <div data-slot=\"image-cropper-controls\" className={cn(\"flex items-center gap-1\", classNames?.controls)}>\n          <Button\n            variant=\"ghost\"\n            size=\"icon-sm\"\n            aria-label={t(\"zoomOutImage\")}\n            disabled={disabled || !natural || zoom <= minZoom}\n            onClick={() => setZoom(zoom - 0.25)}\n          >\n            <ZoomOutIcon />\n          </Button>\n          <Slider\n            aria-label={t(\"zoom\")}\n            min={minZoom}\n            max={maxZoom}\n            step={0.01}\n            value={[zoom]}\n            disabled={disabled || !natural}\n            onValueChange={([value]) => value !== undefined && setZoom(value)}\n            className=\"mx-1 flex-1\"\n          />\n          <Button\n            variant=\"ghost\"\n            size=\"icon-sm\"\n            aria-label={t(\"zoomInImage\")}\n            disabled={disabled || !natural || zoom >= maxZoom}\n            onClick={() => setZoom(zoom + 0.25)}\n          >\n            <ZoomInIcon />\n          </Button>\n          <Button variant=\"ghost\" size=\"icon-sm\" aria-label={t(\"rotateImage\")} disabled={disabled || !natural} onClick={rotate} className=\"ms-1\">\n            <RotateCwIcon />\n          </Button>\n        </div>\n      )}\n    </div>\n  )\n}\n\ntype CropImageOptions = {\n  /** Width of the file in pixels; the height follows the crop's shape. The crop's own size by default. */\n  width?: number\n  /** \"image/jpeg\" (the default), \"image/png\" to keep transparency, or \"image/webp\". */\n  type?: string\n  /** 0 to 1, for JPEG and WebP. */\n  quality?: number\n  /** As on the cropper, for a picture on another site. */\n  crossOrigin?: \"anonymous\" | \"use-credentials\"\n}\n\n/**\n * Draws the cropped part of a picture into a Blob, ready to upload: `new File([blob], \"avatar.jpg\", { type: blob.type })`.\n * Runs in the browser.\n */\nasync function cropImage(src: string, area: CropArea, { width, type = \"image/jpeg\", quality = 0.92, crossOrigin }: CropImageOptions = {}) {\n  const image = new Image()\n  if (crossOrigin) image.crossOrigin = crossOrigin\n  image.src = src\n  await image.decode()\n\n  // The crop as shown (turned), and the file's size.\n  const [shownWidth, shownHeight] = sideways(area.rotation) ? [area.height, area.width] : [area.width, area.height]\n  const k = width ? width / shownWidth : 1\n  const canvas = document.createElement(\"canvas\")\n  canvas.width = Math.max(1, Math.round(shownWidth * k))\n  canvas.height = Math.max(1, Math.round(shownHeight * k))\n  const context = canvas.getContext(\"2d\")!\n  context.imageSmoothingQuality = \"high\"\n  // JPEG has no transparency: a transparent picture gets a white ground instead of a black one.\n  if (type === \"image/jpeg\") {\n    context.fillStyle = \"#fff\"\n    context.fillRect(0, 0, canvas.width, canvas.height)\n  }\n  context.translate(canvas.width / 2, canvas.height / 2)\n  context.rotate((area.rotation * Math.PI) / 180)\n  context.scale(k, k)\n  context.drawImage(image, -(area.x + area.width / 2), -(area.y + area.height / 2))\n\n  return new Promise<Blob>((resolve, reject) =>\n    canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error(\"Couldn't draw the cropped picture.\"))), type, quality)\n  )\n}\n\nexport { ImageCropper, cropImage, type ImageCropperProps, type CropArea, type CropImageOptions }\n"}],"cssVars":{"theme":{"ease-standard":"var(--ease-standard)","ease-enter":"var(--ease-enter)","ease-exit":"var(--ease-exit)","ease-spring":"var(--ease-spring)"},"light":{"duration-fast":"160ms","duration-standard":"240ms","duration-considered":"480ms","ease-standard":"cubic-bezier(0.2, 0, 0, 1)","ease-enter":"cubic-bezier(0, 0, 0, 1)","ease-exit":"cubic-bezier(0.3, 0, 1, 1)","ease-spring":"linear(0, 0.071 4%, 0.234 8%, 0.433 12%, 0.626 17%, 0.791 21%, 0.917 25%, 1.004 29%, 1.055 33%, 1.079 38%, 1.083 42%, 1.075 46%, 1.06 50%, 1.043 54%, 1.027 58%, 1.015 62%, 1.005 67%, 0.998 71%, 0.995 75%, 0.993 79%, 0.993 83%, 0.994 88%, 0.995 92%, 0.997 96%, 1)"},"dark":{}}}