import { usePage } from "@inertiajs/react"; import { ChevronDownIcon, CropIcon, FlipHorizontalIcon, FlipVerticalIcon, RefreshCwIcon, RotateCcwIcon, RotateCwIcon, Trash2Icon, Upload, ZoomInIcon, ZoomOutIcon, } from "lucide-react"; import { useEffect, useRef, useState, type Dispatch, type MouseEvent, type SetStateAction, type SyntheticEvent, } from "react"; import { type FileWithPath } from "react-dropzone"; import ReactCrop, { type Crop, type PixelCrop } from "react-image-crop"; import "react-image-crop/dist/ReactCrop.css"; import type { ConfigBackend } from "@/back/types/global"; import { Button } from "@/components/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import { Slider } from "@/components/ui/slider"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import useDeviceType from "@/hooks/use-device-type"; import str from "@/hooks/use-string"; import trans from "@/hooks/use-translations"; import { cn } from "@/lib/utils"; interface CropValidationRules { width: number; height: number; } type FileWithPreview = FileWithPath & { preview: string; }; interface ImageCropperProps { dialogOpen: boolean; name: string; value?: string | File | null; selectedFile: FileWithPreview | null; validationRules: CropValidationRules; isReset: boolean; url: string; finalFileName: string; setData: (name: string, value: File | string | null) => void; setDialogOpen: Dispatch>; reset: (...fields: string[]) => void; setIsReset: (value: boolean) => void; } export default function ImageCropper({ dialogOpen, setDialogOpen, selectedFile, validationRules, setData, name, value, reset, setIsReset, isReset, url, finalFileName, }: Readonly) { const { routes } = usePage().props.configBackend as ConfigBackend; const { errors } = usePage().props; const aspect = 1; const { isDesktop } = useDeviceType(); const imgRef = useRef(null); const isDuplicating = route().current(`${routes.name}*.duplicate`); // Zone of the crop in %. const [cropZone, setCropZone] = useState(); const [croppedImage, setCroppedImage] = useState(""); const [rotation, setRotation] = useState(0); const [flipX, setFlipX] = useState(false); const [flipY, setFlipY] = useState(false); const [zoom, setZoom] = useState(0); const [scaleFactor, setScaleFactor] = useState(1); const [imagePosition, setImagePosition] = useState({ x: 0, y: 0 }); const [isDragging, setIsDragging] = useState(false); const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); const [originalDimensions, setOriginalDimensions] = useState({ width: 0, height: 0, }); const [prevSelectedFile, setPrevSelectedFile] = useState(selectedFile); if (selectedFile !== prevSelectedFile) { setPrevSelectedFile(selectedFile); setImagePosition({ x: 0, y: 0 }); setOriginalDimensions({ width: 0, height: 0 }); } // États pour les filtres const [brightness, setBrightness] = useState(100); const [contrast, setContrast] = useState(100); const [saturation, setSaturation] = useState(100); const [sepia, setSepia] = useState(0); const [grayscale, setGrayscale] = useState(0); const [filtersOpen, setFiltersOpen] = useState(false); const maxContainerHeight = isDesktop ? 550 : 350; const maxContainerWidth = isDesktop ? 900 : 450; /** * Generate imge from canvas with all crop options. * @param image HTMLImageElement Ref. of the image tag. * @param cropLocalization Crop localization in the image. * @return string */ function getCroppedImg(image: HTMLImageElement, crop: PixelCrop): string { const canvas = document.createElement("canvas"); canvas.width = validationRules.width; canvas.height = validationRules.height; const ctx = canvas.getContext("2d"); if (!ctx) { throw new Error("canvas context missing."); } // Calculer le ratio entre l'affichage et les dimensions originales const displayScale = scaleFactor * (1 + zoom); // Position de l'image affichée avec zoom const displayedWidth = originalDimensions.width * displayScale; const displayedHeight = originalDimensions.height * displayScale; const offsetX = imagePosition.x - (displayedWidth - originalDimensions.width * scaleFactor) / 2; const offsetY = imagePosition.y - (displayedHeight - originalDimensions.height * scaleFactor) / 2; // Centre de l'image dans le container d'affichage const imageCenterX = offsetX + displayedWidth / 2; const imageCenterY = offsetY + displayedHeight / 2; // Centre du crop dans le container d'affichage const cropCenterX = crop.x + crop.width / 2; const cropCenterY = crop.y + crop.height / 2; // Distance du centre du crop au centre de l'image (en pixels d'affichage) const deltaX = cropCenterX - imageCenterX; const deltaY = cropCenterY - imageCenterY; // Taille du canvas temporaire basée sur la résolution originale de la zone croppée // On limite toutefois pour éviter des canvas trop grands (max 4x la taille finale) const tmpWidth = Math.ceil(crop.width / displayScale); const tmpHeight = Math.ceil(crop.height / displayScale); // Créer un canvas temporaire à haute résolution const tmpCanvas = document.createElement("canvas"); tmpCanvas.width = tmpWidth; tmpCanvas.height = tmpHeight; const tmpCtx = tmpCanvas.getContext("2d"); if (!tmpCtx) { throw new Error("canvas context missing."); } tmpCtx.save(); // Appliquer les filtres tmpCtx.filter = `brightness(${String(brightness)}%) contrast(${String(contrast)}%) saturate(${String(saturation)}%) sepia(${String(sepia)}%) grayscale(${String(grayscale)}%)`; // Translater au centre du canvas temporaire tmpCtx.translate(tmpWidth / 2, tmpHeight / 2); // Appliquer rotation et flip tmpCtx.rotate((rotation * Math.PI) / 180); tmpCtx.scale(flipX ? -1 : 1, flipY ? -1 : 1); // Calculer où dessiner l'image pour que la zone croppée soit centrée // deltaX/deltaY sont en pixels d'affichage, on les convertit en pixels originaux const originalDeltaX = deltaX / displayScale; const originalDeltaY = deltaY / displayScale; // Dessiner l'image originale à sa pleine résolution tmpCtx.drawImage( image, -originalDimensions.width / 2 - originalDeltaX, -originalDimensions.height / 2 - originalDeltaY, originalDimensions.width, originalDimensions.height, ); tmpCtx.restore(); // Redimensionner vers la taille finale en une seule opération ctx.drawImage( tmpCanvas, 0, 0, tmpWidth, tmpHeight, 0, 0, canvas.width, canvas.height, ); return canvas.toDataURL("image/png"); } /** * Generate base64 from an url. * @param targetUrl string * @param callback (base64: string | ArrayBuffer | null) => void * @param errorCallback (error: string) => void */ function urlToBase64( targetUrl: string, callback: (base64: string | ArrayBuffer | null) => void, errorCallback?: (error: string) => void, ): void { fetch(targetUrl) .then((response) => response.blob()) .then((blob) => { const reader = new FileReader(); reader.onloadend = () => { callback(reader.result); }; reader.readAsDataURL(blob); }) .catch((error: unknown) => { if (errorCallback) errorCallback(String(error)); }); } /** * Generate file from the base64. * @param dataUrl base64 string * @param filename name of your file * @return File */ function base64ToFile(dataUrl: string): File { const [meta, base64] = dataUrl.split(","); const mimeMatch = /:(.*?);/.exec(meta); const mime = mimeMatch ? mimeMatch[1] : "image/png"; const decodedBase64 = atob(base64); const { length } = decodedBase64; const u8arr = new Uint8Array(length); for (let i = 0; i < length; i += 1) { u8arr[i] = decodedBase64.charCodeAt(i); } return new File([u8arr], finalFileName, { type: mime }); } // Fonctions de gestion du drag function handleMouseDown(e: MouseEvent) { setIsDragging(true); setDragStart({ x: e.clientX - imagePosition.x, y: e.clientY - imagePosition.y, }); } function handleMouseMove(e: MouseEvent) { if (!isDragging) return; setImagePosition({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y, }); } function handleMouseUp() { setIsDragging(false); } useEffect(() => { if (!value || !isDuplicating) return; const pathFileBase = `${url}/${typeof value === "object" ? value.name : value}`; urlToBase64( pathFileBase, (base64) => { if (typeof base64 === "string") { const file = base64ToFile(base64); setData(name, file); } }, (err) => { console.error(err); }, ); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); /** * Crop the image with all options. * @return void */ function onCrop(): void { try { if (imgRef.current && cropZone) { // Recalculer les coordonnées en pixels à partir de cropZone actuel // cropZone est en % par rapport au container de ReactCrop const cropX = (cropZone.x / 100) * maxContainerWidth; const cropY = (cropZone.y / 100) * maxContainerHeight; const cropWidth = (cropZone.width / 100) * maxContainerWidth; const cropHeight = (cropZone.height / 100) * maxContainerHeight; // Créer l'objet cropLocalization mis à jour const currentCropLocalization: PixelCrop = { unit: "px", x: cropX, y: cropY, width: cropWidth, height: cropHeight, }; const finalImage = getCroppedImg( imgRef.current, currentCropLocalization, ); setCroppedImage(finalImage); const file = base64ToFile(finalImage); if (!isReset) { setData(name, file); } else { reset(name); } setDialogOpen(false); } } catch (error: unknown) { throw new Error(`Something went wrong : ${String(error)}`); } } /** * Set the crop zone. * @param width number * @param height number * @param currentScaleFactor number */ function placeCropZone( width: number, height: number, currentScaleFactor: number, ): void { const displayedWidth = width * currentScaleFactor; const displayedHeight = height * currentScaleFactor; // Calculer l'offset pour centrer l'image dans le container const centerOffsetX = (maxContainerWidth - displayedWidth) / 2; const centerOffsetY = (maxContainerHeight - displayedHeight) / 2; // Initialiser la position centrée (seulement si imagePosition est à 0,0) if (imagePosition.x === 0 && imagePosition.y === 0) { setImagePosition({ x: centerOffsetX, y: centerOffsetY }); } // Zone de crop simple : garder les dimensions d'origine, réduire seulement si ça dépasse le container let finalCropWidth = validationRules.width; let finalCropHeight = validationRules.height; // Si la zone dépasse le container, la réduire proportionnellement if ( finalCropWidth > maxContainerWidth * 0.9 || finalCropHeight > maxContainerHeight * 0.9 ) { const containerWidthRatio = (maxContainerWidth * 0.9) / finalCropWidth; const containerHeightRatio = (maxContainerHeight * 0.9) / finalCropHeight; const reductionRatio = Math.min( containerWidthRatio, containerHeightRatio, ); finalCropWidth = validationRules.width * reductionRatio; finalCropHeight = validationRules.height * reductionRatio; } // Convertir en pourcentage par rapport au CONTAINER (pas à l'image) const cropWidthPercent = (finalCropWidth / maxContainerWidth) * 100; const cropHeightPercent = (finalCropHeight / maxContainerHeight) * 100; setCropZone( cropZone ?? { unit: "%", width: cropWidthPercent, height: cropHeightPercent, x: (100 - cropWidthPercent) / 2, // Centrer horizontalement y: (100 - cropHeightPercent) / 2, // Centrer verticalement }, ); } /** * Load image when open the dialog. * @param e SyntheticEvent * @return void */ function onImageLoad(e: SyntheticEvent): void { const { naturalWidth: width, naturalHeight: height } = e.currentTarget; // STOCKER les dimensions naturelles originales setOriginalDimensions({ width, height }); // Calculer le facteur d'échelle pour l'image const imageWidthRatio = maxContainerWidth / width; const imageHeightRatio = maxContainerHeight / height; const imageScaleFactor = Math.min(imageWidthRatio, imageHeightRatio, 1); // Ne pas agrandir l'image setScaleFactor(imageScaleFactor); placeCropZone(width, height, imageScaleFactor); } /** * Reset function to return to initial state. * @return void */ function resetToInitialState(): void { // Options reset. setRotation(0); setFlipX(false); setFlipY(false); setZoom(0); setBrightness(100); setContrast(100); setSaturation(100); setSepia(0); setGrayscale(0); if (value) setIsReset(true); // Recalculer et forcer le centrage de l'image ET de la zone de crop if (originalDimensions.width > 0) { const displayedWidth = originalDimensions.width * scaleFactor; const displayedHeight = originalDimensions.height * scaleFactor; // Calculer l'offset pour centrer l'image dans le container const centerOffsetX = (maxContainerWidth - displayedWidth) / 2; const centerOffsetY = (maxContainerHeight - displayedHeight) / 2; // Forcer la position centrée setImagePosition({ x: centerOffsetX, y: centerOffsetY }); // IMPORTANT: Recalculer la zone de crop centrée let finalCropWidth = validationRules.width; let finalCropHeight = validationRules.height; // Si la zone dépasse le container, la réduire proportionnellement if ( finalCropWidth > maxContainerWidth * 0.9 || finalCropHeight > maxContainerHeight * 0.9 ) { const containerWidthRatio = (maxContainerWidth * 0.9) / finalCropWidth; const containerHeightRatio = (maxContainerHeight * 0.9) / finalCropHeight; const reductionRatio = Math.min( containerWidthRatio, containerHeightRatio, ); finalCropWidth = validationRules.width * reductionRatio; finalCropHeight = validationRules.height * reductionRatio; } // Convertir en pourcentage et centrer const cropWidthPercent = (finalCropWidth / maxContainerWidth) * 100; const cropHeightPercent = (finalCropHeight / maxContainerHeight) * 100; setCropZone({ unit: "%", width: cropWidthPercent, height: cropHeightPercent, x: (100 - cropWidthPercent) / 2, // Centrer horizontalement y: (100 - cropHeightPercent) / 2, // Centrer verticalement }); } else { // Si pas d'image, remettre à 0,0 setImagePosition({ x: 0, y: 0 }); } } // Translations. const transBrightness = str( trans("uwil-backend::back.image_cropper.filters.brightness"), ) .ucFirst() .value(); const transContrast = str( trans("uwil-backend::back.image_cropper.filters.contrast"), ) .ucFirst() .value(); const transSaturation = str( trans("uwil-backend::back.image_cropper.filters.saturation"), ) .ucFirst() .value(); const transSepia = str( trans("uwil-backend::back.image_cropper.filters.sepia"), ) .ucFirst() .value(); const transGrayscale = str( trans("uwil-backend::back.image_cropper.filters.grayscale"), ) .ucFirst() .value(); const transRotation = str( trans("uwil-backend::back.image_cropper.filters.rotation"), ) .ucFirst() .value(); const transZoom = str(trans("uwil-backend::back.image_cropper.filters.zoom")) .ucFirst() .value(); // Filters (brightness, contrast, saturation, sepia, grayscale). const imageFilterOptions = [ { label: () => `${transBrightness}: ${String(brightness)}%`, tooltip: "brightness", slider: { min: 0, max: 200, step: 1, value: [brightness], onValueChange: (e: number[]) => { setIsReset(false); setBrightness(parseInt(e[0].toString(), 10)); }, }, }, { label: () => `${transContrast}: ${String(contrast)}%`, tooltip: "contrast", slider: { min: 0, max: 200, step: 1, value: [contrast], onValueChange: (e: number[]) => { setContrast(parseInt(e[0].toString(), 10)); setIsReset(false); }, }, }, { label: () => `${transSaturation}: ${String(saturation)}%`, tooltip: "saturation", slider: { min: 0, max: 200, step: 1, value: [saturation], onValueChange: (e: number[]) => { setSaturation(parseInt(e[0].toString(), 10)); setIsReset(false); }, }, }, { label: () => `${transSepia}: ${String(sepia)}%`, tooltip: "sepia", slider: { min: 0, max: 100, step: 1, value: [sepia], onValueChange: (e: number[]) => { setSepia(parseInt(e[0].toString(), 10)); setIsReset(false); }, }, }, { label: () => `${transGrayscale}: ${String(grayscale)}%`, tooltip: "grayscale", slider: { min: 0, max: 100, step: 1, value: [grayscale], onValueChange: (e: number[]) => { setGrayscale(parseInt(e[0].toString(), 10)); setIsReset(false); }, }, }, ]; // Main options (rotation, zoom). const mainOptions = [ { label: () => `${transRotation}: ${String(rotation)}°`, tooltip: "rotation", slider: { min: -180, max: 180, step: 1, value: [rotation], onValueChange: (e: number[]) => { setRotation(parseInt(e[0].toString(), 10)); setIsReset(false); }, }, buttons: [ { icon: RotateCcwIcon, tooltip: "rotation_back", fn: () => { setRotation((r) => r - 10); }, }, { icon: RotateCwIcon, tooltip: "rotation_forward", fn: () => { setRotation((r) => r + 10); }, }, ], }, { label: () => `${transZoom}: ${String(Math.round((1 + zoom) * 100))}%`, tooltip: "zoom", slider: { min: -0.99, max: 500, step: 0.1, value: [Math.round((1 + zoom) * 100)], onValueChange: (e: number[]) => { setZoom(e[0] / 100 - 1); setIsReset(false); }, }, buttons: [ { icon: ZoomOutIcon, tooltip: "zoomOut", fn: () => { setZoom((currentValue) => { const newValue = currentValue - 0.1; return newValue < -0.99 ? -0.99 : parseFloat(newValue.toFixed(1)); }); setIsReset(false); }, }, { icon: ZoomInIcon, tooltip: "zoomIn", fn: () => { setZoom((currentValue) => { const newValue = currentValue + 0.1; return newValue > 4 ? 4 : parseFloat(newValue.toFixed(1)); }); setIsReset(false); }, }, ], }, ]; // Other options (reset, horizontal and vertical flip) const otherOptions = [ { icon: FlipHorizontalIcon, fn: () => { setFlipX((prev) => !prev); }, tooltip: "horizontal_flip", }, { icon: FlipVerticalIcon, fn: () => { setFlipY((prev) => !prev); }, tooltip: "vertical_flip", }, { icon: RefreshCwIcon, fn: resetToInitialState, tooltip: "reset", }, ]; return (
{trans("uwil-backend::back.image_cropper.modale_title")}
{/* Cropper content */}
{ setCropZone(percentCrop); }} aspect={aspect} className="w-full" locked >
{trans("uwil-backend::back.image_cropper.preview_image")} 0 ? `${String(originalDimensions.width * scaleFactor)}px` : "auto", height: originalDimensions.height > 0 ? `${String(originalDimensions.height * scaleFactor)}px` : "auto", transform: `scale(${String(1 + zoom)}) rotate(${String(rotation)}deg) scaleX(${String(flipX ? -1 : 1)}) scaleY(${String(flipY ? -1 : 1)})`, filter: `brightness(${String(brightness)}%) contrast(${String(contrast)}%) saturate(${String(saturation)}%) sepia(${String(sepia)}%) grayscale(${String(grayscale)}%)`, transformOrigin: "center center", transition: !isDragging ? "transform 0.2s ease, filter 0.2s ease" : "none", cursor: isDragging ? "grabbing" : "grab", userSelect: "none", }} draggable={false} />
{/* Cropper filters */}
{trans("uwil-backend::back.image_cropper.modale_description")} {mainOptions.map((option) => (
{str( trans( `uwil-backend::back.image_cropper.filters.${option.tooltip}`, ), ) .ucFirst() .value()}
{option.buttons.map((currentOption) => ( {str( trans( `uwil-backend::back.image_cropper.filters.${currentOption.tooltip}`, ), ) .ucFirst() .value()} ))}
))} {/* FILTRES - Collapsible */}
{imageFilterOptions.map((option) => (
{str( trans( `uwil-backend::back.image_cropper.filters.${option.tooltip}`, ), ) .ucFirst() .value()}
))}
{/* Others actions */}
{otherOptions.map((option) => { return ( {str( trans( `uwil-backend::back.image_cropper.filters.${option.tooltip}`, ), ) .ucFirst() .value()} ); })}
{str( trans( "uwil-backend::back.image_cropper.action_the_crop", { params: { action: str(trans("uwil-backend::back.cancel")) .ucFirst() .value(), }, }, ), ) .ucFirst() .value()} {str( trans("uwil-backend::back.image_cropper.action_the_crop", { params: { action: str(trans("uwil-backend::back.validate")) .ucFirst() .value(), }, }), ) .ucFirst() .value()}
); }