import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import Cropper, { type Area } from 'react-easy-crop'; import { Modal } from './Modal'; interface Props { /** When true, the dialog is mounted. Always pair with a `key` change on * the source file so a fresh open re-runs the loader. */ open: boolean; /** Source image. Object URL is created/revoked internally. */ file: File | null; /** width:height ratio of the crop box. 1 for avatar, 3 for banner. */ aspect: number; /** Output image dimensions in px. The crop area is scaled to this. */ outputWidth: number; outputHeight: number; title: string; /** Called with the encoded blob when the user confirms. The dialog stays * open until the parent flips `open` back off (typically after upload * completes) — gives the parent a chance to show errors inline. */ onConfirm: (blob: Blob) => void; onClose: () => void; } const ENCODE_QUALITY = 0.85; // Crop dialog used by both avatar (1:1) and banner (3:1) flows. Wraps // react-easy-crop in our standard modal chrome and runs the canvas crop // inline on confirm so the upload helpers receive a ready-to-store Blob. export function ImageCropDialog({ open, file, aspect, outputWidth, outputHeight, title, onConfirm, onClose, }: Props) { const [crop, setCrop] = useState<{ x: number; y: number }>({ x: 0, y: 0 }); const [zoom, setZoom] = useState(1); const [areaPixels, setAreaPixels] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); // Resolve the file into an object URL exactly once per file. Revoked on // unmount or when the file changes so a long crop session doesn't leak. const objectUrl = useMemo(() => { if (!file) return null; return URL.createObjectURL(file); }, [file]); useEffect(() => { return () => { if (objectUrl) URL.revokeObjectURL(objectUrl); }; }, [objectUrl]); // Reset transient UI state every time a new file is loaded so reopens // don't carry over the previous session's zoom/offset. useEffect(() => { if (!file) return; setCrop({ x: 0, y: 0 }); setZoom(1); setAreaPixels(null); setError(null); }, [file]); const onCropComplete = useCallback((_: Area, pixels: Area) => { setAreaPixels(pixels); }, []); const submitting = useRef(false); const handleConfirm = useCallback(async () => { if (submitting.current || !objectUrl || !areaPixels) return; submitting.current = true; setBusy(true); setError(null); try { const blob = await renderCrop({ srcUrl: objectUrl, area: areaPixels, outputWidth, outputHeight, }); onConfirm(blob); } catch (err: unknown) { setError(err instanceof Error ? err.message : 'crop failed'); } finally { submitting.current = false; setBusy(false); } }, [objectUrl, areaPixels, outputWidth, outputHeight, onConfirm]); return ( undefined : onClose} size="lg">
{/* Cropper canvas. Fixed height (320px) so the layout stays stable regardless of the source image dimensions; react-easy-crop fits the image into the box and the user pans/zooms inside. */}
{objectUrl && ( )}
setZoom(Number(e.target.value))} className="flex-1 accent-accent" /> {Math.round(zoom * 100)}%
{error && (

{error}

)}
); } interface RenderArgs { srcUrl: string; area: Area; outputWidth: number; outputHeight: number; } // Off-thread canvas would be nicer but the input is bounded (single image, // max ~12MP after react-easy-crop's clamp) so the main-thread cost is in // the tens of ms. Keeping it inline avoids the OffscreenCanvas + Worker // plumbing for a one-shot operation. async function renderCrop({ srcUrl, area, outputWidth, outputHeight, }: RenderArgs): Promise { const img = await new Promise((resolve, reject) => { const i = new Image(); i.onload = () => resolve(i); i.onerror = () => reject(new Error('image load failed')); i.src = srcUrl; }); // Don't upscale beyond the source crop. A 200x200 selection stays at // 200x200 instead of inflating to outputWidth — saves bytes and avoids // the soft look of canvas-resampled enlargement. const targetWidth = Math.min(outputWidth, Math.round(area.width)); const targetHeight = Math.min(outputHeight, Math.round(area.height)); const canvas = document.createElement('canvas'); canvas.width = targetWidth; canvas.height = targetHeight; const ctx = canvas.getContext('2d'); if (!ctx) throw new Error('canvas context unavailable'); ctx.drawImage( img, area.x, area.y, area.width, area.height, 0, 0, targetWidth, targetHeight, ); const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/webp', ENCODE_QUALITY), ); if (blob) return blob; const jpeg = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', ENCODE_QUALITY), ); if (!jpeg) throw new Error('canvas toBlob returned null'); return jpeg; }