/* ImageCropper.tsx ---------------- Reusable React component for interactive image cropping with: • Drag / resize cropping via react-easy-crop • Custom aspect ratio or free-form • Optional circular crop overlay (avatar mode) • Automatic scaling so the exported image never exceeds maxSizePx • Returns PNG data URL + Blob on confirm • Responsive / accessible with shadcn/ui Button & Slider components Styling relies on Tailwind + shadcn design tokens. */ "use client" import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" import Cropper from "react-easy-crop" import { Button } from "./ui/button" import { Slider } from "./ui/slider" import { cn } from "../utils/cn" import { Check, RotateCcw } from "lucide-react" /* ------------------------------------------------------------ * Types * ----------------------------------------------------------*/ export interface ImageCropperResult { /** Cropped PNG data URL */ dataUrl: string /** Corresponding PNG Blob */ blob: Blob } export interface ImageCropperProps { /** Source image (URL or data URI) */ src: string /** Called when user confirms crop */ onComplete(result: ImageCropperResult): void /** Called when user cancels crop */ onCancel?(): void /** Aspect ratio (width / height). If omitted, free-form */ aspectRatio?: number /** Enable circular crop overlay for avatars */ circular?: boolean /** Maximum width/height for the exported PNG (defaults 512) */ maxSizePx?: number /** Optional className for wrapper */ className?: string } /* ------------------------------------------------------------ * Helpers * ----------------------------------------------------------*/ function degToRad(deg: number) { return (deg * Math.PI) / 180 } /** Util to create an HTMLImageElement that resolves when loaded */ function loadImage(src: string): Promise { return new Promise((resolve, reject) => { const img = new Image() img.crossOrigin = "anonymous" // prevent canvas tainting img.onload = () => resolve(img) img.onerror = () => reject(new Error("Failed to load image")) img.src = src }) } /* ------------------------------------------------------------ * Component * ----------------------------------------------------------*/ export const ImageCropper: React.FC = ({ src, onComplete, onCancel, aspectRatio, circular = false, maxSizePx = 512, className, }) => { const [crop, setCrop] = useState({ x: 0, y: 0 }) const [zoom, setZoom] = useState(1) const [rotation, setRotation] = useState(0) const [croppedAreaPixels, setCroppedAreaPixels] = useState< | { x: number; y: number; width: number; height: number } | null >(null) /* ------------------ crop complete callback -----------------*/ const onCropComplete = useCallback((_: any, area: any) => { setCroppedAreaPixels(area) }, []) /* ------------------ Build checkered background --------------*/ const checkerBg = "bg-[length:16px_16px] bg-[linear-gradient(45deg,transparent_25%,#2a2a2a_25%,#2a2a2a_75%,transparent_75%,transparent),linear-gradient(45deg,#2a2a2a_25%,transparent_25%,transparent_75%,#2a2a2a_75%,#2a2a2a)]" /* ------------------ Export logic ---------------------------*/ const exportCrop = useCallback(async () => { if (!croppedAreaPixels) return undefined const img = await loadImage(src) // Create canvas the size of crop const canvas = document.createElement("canvas") // Scale crop to fit maxSizePx const scale = Math.min(1, maxSizePx / Math.max(croppedAreaPixels.width, croppedAreaPixels.height)) const outputW = Math.round(croppedAreaPixels.width * scale) const outputH = Math.round(croppedAreaPixels.height * scale) canvas.width = outputW canvas.height = outputH const ctx = canvas.getContext("2d")! // Draw cropped portion ctx.drawImage( img, croppedAreaPixels.x, croppedAreaPixels.y, croppedAreaPixels.width, croppedAreaPixels.height, 0, 0, outputW, outputH, ) // If circular mode, clip to circle if (circular) { const temp = document.createElement("canvas") temp.width = outputW temp.height = outputH const tctx = temp.getContext("2d")! tctx.beginPath() tctx.arc(outputW / 2, outputH / 2, outputW / 2, 0, Math.PI * 2) tctx.closePath() tctx.clip() tctx.drawImage(canvas, 0, 0) canvas.width = outputW canvas.height = outputH ctx.clearRect(0, 0, outputW, outputH) ctx.drawImage(temp, 0, 0) } return new Promise((resolve) => { canvas.toBlob( (blob) => { if (!blob) throw new Error("Canvas export failed") const reader = new FileReader() reader.onloadend = () => { resolve({ dataUrl: reader.result as string, blob }) } reader.readAsDataURL(blob) }, "image/png", ) }) }, [croppedAreaPixels, circular, maxSizePx, src]) as () => Promise /* ------------------ Keyboard accessibility -----------------*/ const handleKey = (e: React.KeyboardEvent) => { // Enter to confirm, Esc to cancel if (e.key === "Enter") { e.preventDefault() void exportCrop().then((res) => { if (res) onComplete(res) }) } else if (e.key === "Escape") { e.preventDefault() onCancel?.() } } /* ------------------ Render ---------------------------------*/ return (
{/* Cropper container */}
{/* Controls */}
{/* Zoom */}
Zoom setZoom(v[0])} aria-label="Zoom" className="flex-1" />
{/* Rotate */}
Rotate setRotation(v[0])} aria-label="Rotation" className="flex-1" />
{/* Action buttons */}
{onCancel && ( )}
) }