{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "image-cropper",
  "title": "Image Cropper",
  "description": "Pan, zoom, and rotate images with fixed or free aspect ratio and blob output.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "button",
    "@drift-ui/use-controllable-state"
  ],
  "files": [
    {
      "path": "registry/default/ui/image-cropper.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  CheckIcon,\n  RotateCcwIcon,\n  RotateCwIcon,\n  ZoomInIcon,\n  ZoomOutIcon,\n} from \"lucide-react\"\n\nimport { useControllableState } from \"@/registry/default/hooks/use-controllable-state\"\nimport {\n  canvasToBlob,\n  clampCrop,\n  computeCropSize,\n  computeInitialRenderSize,\n  computePixelCrop,\n  drawCroppedImageToCanvas,\n  type CropShape,\n  type PixelCrop,\n  type Point,\n  type Size,\n} from \"@/registry/default/lib/image-cropper\"\nimport { cn } from \"@/registry/default/lib/utils\"\nimport { Button } from \"@/registry/default/ui/button\"\n\ninterface ImageCropperProps extends Omit<\n  React.ComponentProps<\"div\">,\n  \"onChange\"\n> {\n  src: string | File | Blob\n  aspect?: number | null\n  cropShape?: CropShape\n  zoom?: number\n  defaultZoom?: number\n  onZoomChange?: (zoom: number) => void\n  minZoom?: number\n  maxZoom?: number\n  rotation?: number\n  defaultRotation?: number\n  onRotationChange?: (rotation: number) => void\n  crop?: Point\n  defaultCrop?: Point\n  onCropChange?: (crop: Point) => void\n  onCropComplete?: (blob: Blob, pixelCrop: PixelCrop) => void\n  onCropError?: (error: Error) => void\n  outputType?: \"image/png\" | \"image/jpeg\" | \"image/webp\"\n  outputQuality?: number\n  showControls?: boolean\n  showGrid?: boolean\n  disabled?: boolean\n}\n\nfunction ImageCropper({\n  src,\n  aspect = 1,\n  cropShape = \"rect\",\n  zoom: controlledZoom,\n  defaultZoom = 1,\n  onZoomChange,\n  minZoom = 1,\n  maxZoom = 3,\n  rotation: controlledRotation,\n  defaultRotation = 0,\n  onRotationChange,\n  crop: controlledCrop,\n  defaultCrop = { x: 0, y: 0 },\n  onCropChange,\n  onCropComplete,\n  onCropError,\n  outputType = \"image/png\",\n  outputQuality = 0.92,\n  showControls = true,\n  showGrid = true,\n  disabled = false,\n  className,\n  \"aria-label\": ariaLabel = \"Image cropper\",\n  ...props\n}: ImageCropperProps) {\n  const { value: zoom, setValue: setZoom } = useControllableState({\n    value: controlledZoom,\n    defaultValue: defaultZoom,\n    onChange: onZoomChange,\n  })\n\n  const { value: rotation, setValue: setRotation } = useControllableState({\n    value: controlledRotation,\n    defaultValue: defaultRotation,\n    onChange: onRotationChange,\n  })\n\n  const { value: crop, setValue: setCrop } = useControllableState({\n    value: controlledCrop,\n    defaultValue: defaultCrop,\n    onChange: onCropChange,\n  })\n\n  const containerRef = React.useRef<HTMLDivElement>(null)\n  const imageRef = React.useRef<HTMLImageElement>(null)\n  const instructionsId = React.useId()\n\n  const [containerSize, setContainerSize] = React.useState<Size>({\n    width: 320,\n    height: 320,\n  })\n  const [naturalSize, setNaturalSize] = React.useState<Size>({\n    width: 0,\n    height: 0,\n  })\n  const [isDragging, setIsDragging] = React.useState(false)\n  const [statusAnnouncement, setStatusAnnouncement] = React.useState(\"\")\n  const [objectUrl, setObjectUrl] = React.useState<string | null>(null)\n\n  const activePointers = React.useRef<Map<number, Point>>(new Map())\n  const pinchStart = React.useRef<{ distance: number; zoom: number } | null>(\n    null\n  )\n  const dragStart = React.useRef<{ pointer: Point; crop: Point }>({\n    pointer: { x: 0, y: 0 },\n    crop: { x: 0, y: 0 },\n  })\n\n  React.useEffect(() => {\n    if (\n      typeof window !== \"undefined\" &&\n      typeof src !== \"string\" &&\n      src instanceof Blob\n    ) {\n      const url = URL.createObjectURL(src)\n      // eslint-disable-next-line react-hooks/set-state-in-effect\n      setObjectUrl(url)\n      return () => {\n        URL.revokeObjectURL(url)\n      }\n    }\n    setObjectUrl(null)\n  }, [src])\n\n  const imageSrc = typeof src === \"string\" ? src : (objectUrl ?? \"\")\n\n  React.useEffect(() => {\n    const el = containerRef.current\n    if (!el) return\n\n    const updateSize = () => {\n      const rect = el.getBoundingClientRect()\n      if (rect.width > 0 && rect.height > 0) {\n        setContainerSize({\n          width: Math.round(rect.width),\n          height: Math.round(rect.height),\n        })\n      }\n    }\n\n    updateSize()\n    const observer = new ResizeObserver(updateSize)\n    observer.observe(el)\n    return () => observer.disconnect()\n  }, [])\n\n  const cropSize = React.useMemo(() => {\n    return computeCropSize(containerSize.width, containerSize.height, aspect)\n  }, [containerSize.width, containerSize.height, aspect])\n\n  const imageRenderSize = React.useMemo(() => {\n    return computeInitialRenderSize(naturalSize, cropSize, rotation)\n  }, [naturalSize, cropSize, rotation])\n\n  const updateZoom = React.useCallback(\n    (targetZoom: number) => {\n      const clampedZoom = Math.min(maxZoom, Math.max(minZoom, targetZoom))\n      const roundedZoom = Number(clampedZoom.toFixed(2))\n      setZoom(roundedZoom)\n      const nextCrop = clampCrop(\n        crop,\n        imageRenderSize,\n        cropSize,\n        roundedZoom,\n        rotation\n      )\n      setCrop(nextCrop)\n      setStatusAnnouncement(`Zoom ${Math.round(roundedZoom * 100)}%`)\n    },\n    [\n      maxZoom,\n      minZoom,\n      crop,\n      imageRenderSize,\n      cropSize,\n      rotation,\n      setZoom,\n      setCrop,\n    ]\n  )\n\n  const commitCrop = React.useCallback(async () => {\n    if (\n      naturalSize.width <= 0 ||\n      naturalSize.height <= 0 ||\n      !imageRef.current\n    ) {\n      return\n    }\n\n    const pixelCrop = computePixelCrop(\n      crop,\n      imageRenderSize,\n      cropSize,\n      naturalSize,\n      zoom,\n      rotation\n    )\n\n    try {\n      const canvas = drawCroppedImageToCanvas(\n        imageRef.current,\n        naturalSize,\n        pixelCrop,\n        rotation\n      )\n      const blob = await canvasToBlob(canvas, outputType, outputQuality)\n      onCropComplete?.(blob, pixelCrop)\n      setStatusAnnouncement(\"Crop applied\")\n    } catch (err) {\n      onCropError?.(err instanceof Error ? err : new Error(String(err)))\n    }\n  }, [\n    crop,\n    imageRenderSize,\n    cropSize,\n    naturalSize,\n    zoom,\n    rotation,\n    outputType,\n    outputQuality,\n    onCropComplete,\n    onCropError,\n  ])\n\n  function handleImageLoad(event: React.SyntheticEvent<HTMLImageElement>) {\n    const img = event.currentTarget\n    setNaturalSize({\n      width: img.naturalWidth || img.width,\n      height: img.naturalHeight || img.height,\n    })\n  }\n\n  function handleImageError() {\n    onCropError?.(new Error(\"Failed to load image source\"))\n  }\n\n  function handlePointerDown(event: React.PointerEvent<HTMLDivElement>) {\n    if (disabled || event.button !== 0) return\n    event.currentTarget.setPointerCapture(event.pointerId)\n    activePointers.current.set(event.pointerId, {\n      x: event.clientX,\n      y: event.clientY,\n    })\n\n    if (activePointers.current.size === 1) {\n      setIsDragging(true)\n      dragStart.current = {\n        pointer: { x: event.clientX, y: event.clientY },\n        crop: { ...crop },\n      }\n    } else if (activePointers.current.size === 2) {\n      setIsDragging(false)\n      const pts = Array.from(activePointers.current.values())\n      const distance = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y)\n      pinchStart.current = { distance, zoom }\n    }\n  }\n\n  function handlePointerMove(event: React.PointerEvent<HTMLDivElement>) {\n    if (disabled || !activePointers.current.has(event.pointerId)) return\n    activePointers.current.set(event.pointerId, {\n      x: event.clientX,\n      y: event.clientY,\n    })\n\n    if (activePointers.current.size === 2 && pinchStart.current) {\n      const pts = Array.from(activePointers.current.values())\n      const currentDist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y)\n      if (pinchStart.current.distance > 0) {\n        const factor = currentDist / pinchStart.current.distance\n        updateZoom(pinchStart.current.zoom * factor)\n      }\n      return\n    }\n\n    if (isDragging && activePointers.current.size === 1) {\n      const dx = event.clientX - dragStart.current.pointer.x\n      const dy = event.clientY - dragStart.current.pointer.y\n\n      const nextCrop = clampCrop(\n        {\n          x: dragStart.current.crop.x + dx,\n          y: dragStart.current.crop.y + dy,\n        },\n        imageRenderSize,\n        cropSize,\n        zoom,\n        rotation\n      )\n\n      setCrop(nextCrop)\n    }\n  }\n\n  function handlePointerUp(event: React.PointerEvent<HTMLDivElement>) {\n    activePointers.current.delete(event.pointerId)\n    try {\n      event.currentTarget.releasePointerCapture(event.pointerId)\n    } catch {}\n\n    if (activePointers.current.size === 0) {\n      setIsDragging(false)\n      pinchStart.current = null\n    } else if (activePointers.current.size === 1) {\n      pinchStart.current = null\n      const remaining = Array.from(activePointers.current.values())[0]\n      dragStart.current = {\n        pointer: { x: remaining.x, y: remaining.y },\n        crop: { ...crop },\n      }\n      setIsDragging(true)\n    }\n  }\n\n  React.useEffect(() => {\n    const el = containerRef.current\n    if (!el || disabled) return\n\n    const onWheel = (e: WheelEvent) => {\n      e.preventDefault()\n      const step = e.deltaY < 0 ? 0.1 : -0.1\n      updateZoom(zoom + step)\n    }\n\n    el.addEventListener(\"wheel\", onWheel, { passive: false })\n    return () => el.removeEventListener(\"wheel\", onWheel)\n  }, [disabled, zoom, updateZoom])\n\n  function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {\n    if (disabled) return\n\n    const step = event.shiftKey ? 50 : 5\n    let dx = 0\n    let dy = 0\n\n    if (event.key === \"ArrowLeft\") dx = -step\n    else if (event.key === \"ArrowRight\") dx = step\n    else if (event.key === \"ArrowUp\") dy = -step\n    else if (event.key === \"ArrowDown\") dy = step\n    else if (event.key === \"+\" || event.key === \"=\" || event.key === \"]\") {\n      event.preventDefault()\n      updateZoom(zoom + 0.1)\n      return\n    } else if (event.key === \"-\" || event.key === \"_\" || event.key === \"[\") {\n      event.preventDefault()\n      updateZoom(zoom - 0.1)\n      return\n    } else if (event.key === \"r\" || event.key === \"R\") {\n      event.preventDefault()\n      handleRotate(90)\n      return\n    } else if (event.key === \"Enter\") {\n      event.preventDefault()\n      commitCrop()\n      return\n    } else {\n      return\n    }\n\n    event.preventDefault()\n    const nextCrop = clampCrop(\n      { x: crop.x + dx, y: crop.y + dy },\n      imageRenderSize,\n      cropSize,\n      zoom,\n      rotation\n    )\n    setCrop(nextCrop)\n    setStatusAnnouncement(\n      `Position ${Math.round(nextCrop.x)}, ${Math.round(nextCrop.y)}`\n    )\n  }\n\n  function handleRotate(delta: number) {\n    if (disabled) return\n    const nextRotation = (((rotation + delta) % 360) + 360) % 360\n    setRotation(nextRotation)\n    const nextRenderSize = computeInitialRenderSize(\n      naturalSize,\n      cropSize,\n      nextRotation\n    )\n    const nextCrop = clampCrop(\n      crop,\n      nextRenderSize,\n      cropSize,\n      zoom,\n      nextRotation\n    )\n    setCrop(nextCrop)\n    setStatusAnnouncement(`Rotated to ${nextRotation} degrees`)\n  }\n\n  return (\n    <div\n      data-slot=\"image-cropper\"\n      className={cn(\n        \"flex flex-col gap-3 rounded-lg border border-border bg-card p-3 text-card-foreground shadow-xs\",\n        disabled && \"pointer-events-none opacity-50\",\n        className\n      )}\n      {...props}\n    >\n      <div id={instructionsId} className=\"sr-only\">\n        Use arrow keys to pan (hold Shift for 10x larger steps), plus and minus\n        or bracket keys to zoom, R to rotate, and Enter to apply crop.\n      </div>\n      <div aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n        {statusAnnouncement}\n      </div>\n\n      <div\n        ref={containerRef}\n        role=\"region\"\n        aria-label={ariaLabel}\n        aria-describedby={instructionsId}\n        tabIndex={disabled ? -1 : 0}\n        onPointerDown={handlePointerDown}\n        onPointerMove={handlePointerMove}\n        onPointerUp={handlePointerUp}\n        onPointerCancel={handlePointerUp}\n        onKeyDown={handleKeyDown}\n        className={cn(\n          \"relative flex h-72 w-full touch-none items-center justify-center overflow-hidden rounded-md bg-black/90 outline-none select-none sm:h-80\",\n          isDragging ? \"cursor-grabbing\" : \"cursor-grab\",\n          \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\"\n        )}\n      >\n        {imageSrc && (\n          // eslint-disable-next-line @next/next/no-img-element\n          <img\n            ref={imageRef}\n            src={imageSrc}\n            alt=\"Source preview\"\n            crossOrigin=\"anonymous\"\n            onLoad={handleImageLoad}\n            onError={handleImageError}\n            style={{\n              width: imageRenderSize.width || \"auto\",\n              height: imageRenderSize.height || \"auto\",\n              transform: `translate(${crop.x}px, ${crop.y}px) rotate(${rotation}deg) scale(${zoom})`,\n              transformOrigin: \"center center\",\n            }}\n            className=\"pointer-events-none max-h-none max-w-none transition-transform duration-75 ease-out\"\n          />\n        )}\n\n        <div\n          data-slot=\"crop-area\"\n          style={{\n            width: cropSize.width,\n            height: cropSize.height,\n            boxShadow: \"0 0 0 9999px rgba(0, 0, 0, 0.65)\",\n          }}\n          className={cn(\n            \"pointer-events-none absolute border border-white/80 transition-all\",\n            cropShape === \"round\" ? \"rounded-full\" : \"rounded-xs\"\n          )}\n        >\n          {showGrid && cropShape === \"rect\" && (\n            <div className=\"absolute inset-0 grid grid-cols-3 grid-rows-3\">\n              <div className=\"border-r border-b border-white/30\" />\n              <div className=\"border-r border-b border-white/30\" />\n              <div className=\"border-b border-white/30\" />\n              <div className=\"border-r border-b border-white/30\" />\n              <div className=\"border-r border-b border-white/30\" />\n              <div className=\"border-b border-white/30\" />\n              <div className=\"border-r border-b border-white/30\" />\n              <div className=\"border-r border-b border-white/30\" />\n              <div />\n            </div>\n          )}\n        </div>\n      </div>\n\n      {showControls && (\n        <div\n          data-slot=\"crop-controls\"\n          className=\"flex flex-wrap items-center justify-between gap-3 pt-1\"\n        >\n          <div className=\"flex min-w-44 flex-1 items-center gap-2\">\n            <ZoomOutIcon className=\"h-4 w-4 shrink-0 text-muted-foreground\" />\n            <input\n              type=\"range\"\n              aria-label=\"Zoom level\"\n              aria-valuemin={minZoom}\n              aria-valuemax={maxZoom}\n              aria-valuenow={zoom}\n              aria-valuetext={`${Math.round(zoom * 100)}%`}\n              min={minZoom}\n              max={maxZoom}\n              step={0.05}\n              disabled={disabled}\n              value={zoom}\n              onChange={(e) => updateZoom(parseFloat(e.target.value))}\n              className=\"h-1.5 w-full cursor-pointer appearance-none rounded-lg bg-secondary accent-primary\"\n            />\n            <ZoomInIcon className=\"h-4 w-4 shrink-0 text-muted-foreground\" />\n            <span className=\"w-10 shrink-0 text-right font-mono text-xs text-muted-foreground\">\n              {Math.round(zoom * 100)}%\n            </span>\n          </div>\n\n          <div className=\"flex items-center gap-1.5\">\n            <Button\n              type=\"button\"\n              variant=\"outline\"\n              size=\"icon\"\n              disabled={disabled}\n              onClick={() => handleRotate(-90)}\n              aria-label=\"Rotate counter-clockwise\"\n              title=\"Rotate counter-clockwise\"\n            >\n              <RotateCcwIcon className=\"h-4 w-4\" />\n            </Button>\n            <Button\n              type=\"button\"\n              variant=\"outline\"\n              size=\"icon\"\n              disabled={disabled}\n              onClick={() => handleRotate(90)}\n              aria-label=\"Rotate clockwise\"\n              title=\"Rotate clockwise\"\n            >\n              <RotateCwIcon className=\"h-4 w-4\" />\n            </Button>\n            <Button\n              type=\"button\"\n              size=\"sm\"\n              disabled={disabled}\n              onClick={() => commitCrop()}\n              className=\"gap-1.5\"\n            >\n              <CheckIcon className=\"h-4 w-4\" />\n              Apply\n            </Button>\n          </div>\n        </div>\n      )}\n    </div>\n  )\n}\n\nexport {\n  ImageCropper,\n  type ImageCropperProps,\n  type CropShape,\n  type PixelCrop,\n  type Point,\n  type Size,\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/default/lib/image-cropper.ts",
      "content": "export type Point = {\n  x: number\n  y: number\n}\n\nexport type Size = {\n  width: number\n  height: number\n}\n\nexport type PixelCrop = {\n  x: number\n  y: number\n  width: number\n  height: number\n}\n\nexport type CropShape = \"rect\" | \"round\"\n\nexport function computeCropSize(\n  containerWidth: number,\n  containerHeight: number,\n  aspect?: number | null\n): Size {\n  const paddingFactor = 0.85\n  const maxWidth = Math.max(40, containerWidth * paddingFactor)\n  const maxHeight = Math.max(40, containerHeight * paddingFactor)\n\n  if (!aspect || aspect <= 0) {\n    return {\n      width: Math.round(maxWidth),\n      height: Math.round(maxHeight),\n    }\n  }\n\n  const containerAspect = maxWidth / maxHeight\n\n  let width: number\n  let height: number\n\n  if (aspect >= containerAspect) {\n    width = maxWidth\n    height = width / aspect\n    if (height > maxHeight) {\n      height = maxHeight\n      width = height * aspect\n    }\n  } else {\n    height = maxHeight\n    width = height * aspect\n    if (width > maxWidth) {\n      width = maxWidth\n      height = width / aspect\n    }\n  }\n\n  return {\n    width: Math.max(20, Math.round(width)),\n    height: Math.max(20, Math.round(height)),\n  }\n}\n\nexport function computeInitialRenderSize(\n  naturalSize: Size,\n  cropSize: Size,\n  rotation: number = 0\n): Size {\n  if (naturalSize.width <= 0 || naturalSize.height <= 0) {\n    return { width: cropSize.width, height: cropSize.height }\n  }\n\n  const rad = (rotation * Math.PI) / 180\n  const cos = Math.abs(Math.cos(rad))\n  const sin = Math.abs(Math.sin(rad))\n\n  const requiredWidth = cropSize.width * cos + cropSize.height * sin\n  const requiredHeight = cropSize.height * cos + cropSize.width * sin\n\n  const scale = Math.max(\n    requiredWidth / naturalSize.width,\n    requiredHeight / naturalSize.height\n  )\n\n  return {\n    width: Math.max(1, Math.round(naturalSize.width * scale)),\n    height: Math.max(1, Math.round(naturalSize.height * scale)),\n  }\n}\n\nexport function getRotatedSize(size: Size, rotation: number): Size {\n  const rad = (rotation * Math.PI) / 180\n  const cos = Math.abs(Math.cos(rad))\n  const sin = Math.abs(Math.sin(rad))\n\n  return {\n    width: size.width * cos + size.height * sin,\n    height: size.width * sin + size.height * cos,\n  }\n}\n\nexport function getBoundaries(\n  imageRenderSize: Size,\n  cropSize: Size,\n  zoom: number,\n  rotation: number = 0\n): { maxPanX: number; maxPanY: number } {\n  const rotated = getRotatedSize(imageRenderSize, rotation)\n  const scaledWidth = rotated.width * zoom\n  const scaledHeight = rotated.height * zoom\n\n  const maxPanX = Math.max(0, (scaledWidth - cropSize.width) / 2)\n  const maxPanY = Math.max(0, (scaledHeight - cropSize.height) / 2)\n\n  return { maxPanX, maxPanY }\n}\n\nexport function clampCrop(\n  crop: Point,\n  imageRenderSize: Size,\n  cropSize: Size,\n  zoom: number,\n  rotation: number = 0\n): Point {\n  const { maxPanX, maxPanY } = getBoundaries(\n    imageRenderSize,\n    cropSize,\n    zoom,\n    rotation\n  )\n\n  const x = Math.max(-maxPanX, Math.min(maxPanX, crop.x))\n  const y = Math.max(-maxPanY, Math.min(maxPanY, crop.y))\n\n  return {\n    x: Object.is(x, -0) ? 0 : x,\n    y: Object.is(y, -0) ? 0 : y,\n  }\n}\n\nexport function computePixelCrop(\n  crop: Point,\n  imageRenderSize: Size,\n  cropSize: Size,\n  naturalSize: Size,\n  zoom: number,\n  rotation: number = 0\n): PixelCrop {\n  const rotNatural = getRotatedSize(naturalSize, rotation)\n  const rotRender = getRotatedSize(imageRenderSize, rotation)\n\n  if (rotRender.width <= 0 || rotRender.height <= 0) {\n    return { x: 0, y: 0, width: naturalSize.width, height: naturalSize.height }\n  }\n\n  const scaleX = rotNatural.width / rotRender.width\n  const scaleY = rotNatural.height / rotRender.height\n\n  const renderCropWidth = cropSize.width / zoom\n  const renderCropHeight = cropSize.height / zoom\n\n  const centerX = rotRender.width / 2 - crop.x / zoom\n  const centerY = rotRender.height / 2 - crop.y / zoom\n\n  const pixelWidth = renderCropWidth * scaleX\n  const pixelHeight = renderCropHeight * scaleY\n\n  const pixelX = (centerX - renderCropWidth / 2) * scaleX\n  const pixelY = (centerY - renderCropHeight / 2) * scaleY\n\n  const clampedX = Math.max(0, Math.min(rotNatural.width - pixelWidth, pixelX))\n  const clampedY = Math.max(\n    0,\n    Math.min(rotNatural.height - pixelHeight, pixelY)\n  )\n  const clampedWidth = Math.min(rotNatural.width - clampedX, pixelWidth)\n  const clampedHeight = Math.min(rotNatural.height - clampedY, pixelHeight)\n\n  return {\n    x: Math.round(clampedX),\n    y: Math.round(clampedY),\n    width: Math.max(1, Math.round(clampedWidth)),\n    height: Math.max(1, Math.round(clampedHeight)),\n  }\n}\n\nexport function drawCroppedImageToCanvas(\n  image: CanvasImageSource,\n  naturalSize: Size,\n  pixelCrop: PixelCrop,\n  rotation: number = 0,\n  targetCanvas?: HTMLCanvasElement\n): HTMLCanvasElement {\n  const canvas = targetCanvas ?? document.createElement(\"canvas\")\n  canvas.width = pixelCrop.width\n  canvas.height = pixelCrop.height\n\n  const ctx = canvas.getContext(\"2d\")\n  if (!ctx) return canvas\n\n  const normalizedRotation = ((rotation % 360) + 360) % 360\n\n  if (normalizedRotation === 0) {\n    ctx.drawImage(\n      image,\n      pixelCrop.x,\n      pixelCrop.y,\n      pixelCrop.width,\n      pixelCrop.height,\n      0,\n      0,\n      pixelCrop.width,\n      pixelCrop.height\n    )\n    return canvas\n  }\n\n  const rotNatural = getRotatedSize(naturalSize, normalizedRotation)\n\n  const rotatedCanvas = document.createElement(\"canvas\")\n  rotatedCanvas.width = Math.round(rotNatural.width)\n  rotatedCanvas.height = Math.round(rotNatural.height)\n\n  const rotCtx = rotatedCanvas.getContext(\"2d\")\n  if (!rotCtx) return canvas\n\n  rotCtx.translate(rotatedCanvas.width / 2, rotatedCanvas.height / 2)\n  rotCtx.rotate((normalizedRotation * Math.PI) / 180)\n  rotCtx.drawImage(\n    image,\n    -naturalSize.width / 2,\n    -naturalSize.height / 2,\n    naturalSize.width,\n    naturalSize.height\n  )\n\n  ctx.drawImage(\n    rotatedCanvas,\n    pixelCrop.x,\n    pixelCrop.y,\n    pixelCrop.width,\n    pixelCrop.height,\n    0,\n    0,\n    pixelCrop.width,\n    pixelCrop.height\n  )\n\n  return canvas\n}\n\nexport function canvasToBlob(\n  canvas: HTMLCanvasElement,\n  type: string = \"image/png\",\n  quality: number = 0.92\n): Promise<Blob> {\n  return new Promise((resolve, reject) => {\n    if (typeof canvas.toBlob !== \"function\") {\n      reject(new Error(\"canvas.toBlob is not supported in this environment\"))\n      return\n    }\n\n    canvas.toBlob(\n      (blob) => {\n        if (blob) {\n          resolve(blob)\n        } else {\n          reject(new Error(\"Failed to create blob from canvas\"))\n        }\n      },\n      type,\n      quality\n    )\n  })\n}\n",
      "type": "registry:lib"
    }
  ],
  "type": "registry:ui"
}