// oxlint-disable next/no-img-element "use client"; import { ArrowLeftIcon, ImageUpIcon, XIcon, ZoomInIcon, ZoomOutIcon, } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { Cropper, CropperCropArea, CropperDescription, CropperImage, } from "@/components/ui/cropper"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Slider } from "@/components/ui/slider"; import { useFileUpload } from "@/hooks/use-file-upload"; // Define type for pixel crop area type Area = { x: number; y: number; width: number; height: number }; // Helper function to create a cropped image blob const createImage = (url: string): Promise => new Promise((resolve, reject) => { const image = new Image(); image.addEventListener("load", () => resolve(image)); image.addEventListener("error", (error) => reject(error)); image.setAttribute("crossOrigin", "anonymous"); // Needed for canvas Tainted check image.src = url; }); async function getCroppedImg( imageSrc: string, pixelCrop: Area, outputWidth: number = pixelCrop.width, // Optional: specify output size outputHeight: number = pixelCrop.height, ): Promise { try { const image = await createImage(imageSrc); const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d"); if (!ctx) { return null; } // Set canvas size to desired output size canvas.width = outputWidth; canvas.height = outputHeight; // Draw the cropped image onto the canvas ctx.drawImage( image, pixelCrop.x, pixelCrop.y, pixelCrop.width, pixelCrop.height, 0, 0, outputWidth, // Draw onto the output size outputHeight, ); // Convert canvas to blob return new Promise((resolve) => { canvas.toBlob((blob) => { resolve(blob); }, "image/jpeg"); // Specify format and quality if needed }); } catch (error) { console.error("Error in getCroppedImg:", error); return null; } } export default function Component({ value, onValueChange, }: { value?: File | undefined; onValueChange?: (value: File | undefined) => void; }) { const [ { files, isDragging }, { handleDragEnter, handleDragLeave, handleDragOver, handleDrop, openFileDialog, removeFile, getInputProps, }, ] = useFileUpload({ accept: "image/*", }); const previewUrl = files[0]?.preview || null; const fileId = files[0]?.id; const [finalImageUrl, setFinalImageUrl] = useState(null); const [isDialogOpen, setIsDialogOpen] = useState(false); // Ref to track the previous file ID to detect new uploads const previousFileIdRef = useRef(null); // State to store the desired crop area in pixels const [croppedAreaPixels, setCroppedAreaPixels] = useState(null); // State for zoom level const [zoom, setZoom] = useState(1); // Callback for Cropper to provide crop data - Wrap with useCallback const handleCropChange = useCallback((pixels: Area | null) => { setCroppedAreaPixels(pixels); }, []); const handleApply = async () => { // Check if we have the necessary data if (!previewUrl || !fileId || !croppedAreaPixels) { console.error("Missing data for apply:", { croppedAreaPixels, fileId, previewUrl, }); // Remove file if apply is clicked without crop data? if (fileId) { removeFile(fileId); setCroppedAreaPixels(null); } return; } try { // 1. Get the cropped image blob using the helper const croppedBlob = await getCroppedImg(previewUrl, croppedAreaPixels); if (!croppedBlob) { throw new Error("Failed to generate cropped image blob."); } // 2. Create a NEW object URL from the cropped blob const newFinalUrl = URL.createObjectURL(croppedBlob); // 3. Revoke the OLD finalImageUrl if it exists if (finalImageUrl) { URL.revokeObjectURL(finalImageUrl); } // 4. Set the final avatar state to the NEW URL setFinalImageUrl(newFinalUrl); // 5. Create a File from the blob and pass it to the form const croppedFile = new File( [croppedBlob], files[0]?.file.name || "cropped-image.jpg", { type: croppedBlob.type }, ); onValueChange?.(croppedFile); // 6. Close the dialog (don't remove the file yet) setIsDialogOpen(false); } catch (error) { console.error("Error during apply:", error); // Close the dialog even if cropping fails setIsDialogOpen(false); } }; const handleRemoveFinalImage = () => { if (finalImageUrl) { URL.revokeObjectURL(finalImageUrl); } setFinalImageUrl(null); onValueChange?.(undefined); }; useEffect(() => { const currentFinalUrl = finalImageUrl; // Cleanup function return () => { if (currentFinalUrl?.startsWith("blob:")) { URL.revokeObjectURL(currentFinalUrl); } }; }, [finalImageUrl]); // Effect to open dialog when a *new* file is ready useEffect(() => { // Check if fileId exists and is different from the previous one if (fileId && fileId !== previousFileIdRef.current) { setIsDialogOpen(true); // Open dialog for the new file setCroppedAreaPixels(null); // Reset crop area for the new file setZoom(1); // Reset zoom for the new file } // Update the ref to the current fileId for the next render previousFileIdRef.current = fileId; }, [fileId]); // Depend only on fileId // Reset cropper when value is cleared externally (e.g., form reset) const previousValueRef = useRef(value); // biome-ignore lint/correctness/useExhaustiveDependencies: only fire on value change useEffect(() => { const wasSet = !!previousValueRef.current; const isBlank = !value; if (wasSet && isBlank) { if (finalImageUrl) { URL.revokeObjectURL(finalImageUrl); } setFinalImageUrl(null); setCroppedAreaPixels(null); setZoom(1); } previousValueRef.current = value; // oxlint-disable-next-line react-hooks/exhaustive-deps }, [value]); return ( <>
{/* Drop area - uses finalImageUrl */} {/* Remove button - depends on finalImageUrl */} {finalImageUrl && ( )}
{/* Cropper Dialog - Use isDialogOpen for open prop */} Crop image dialog
Crop image
{previewUrl && ( )}
); }