ldc-laravel-base-project/resources/ts/back/Components/Inputs/ImageCropper.tsx
2026-07-09 17:32:17 +02:00

1030 lines
33 KiB
TypeScript

import "react-image-crop/dist/ReactCrop.css";
import { usePage } from "@inertiajs/react";
import {
ChevronDownIcon,
CropIcon,
FlipHorizontalIcon,
FlipVerticalIcon,
RotateCcwIcon,
RotateCwIcon,
Trash2Icon,
Upload,
ZoomInIcon,
ZoomOutIcon,
RefreshCwIcon,
} from "lucide-react";
import {
type Dispatch,
type MouseEvent,
type SetStateAction,
useEffect,
useRef,
useState,
type SyntheticEvent,
} from "react";
import { type FileWithPath } from "react-dropzone";
import ReactCrop, { type Crop, type PixelCrop } from "react-image-crop";
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 useTrans from "@/hooks/use-translations";
import { cn, GetTranslation } 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<SetStateAction<boolean>>;
reset: (name?: string) => void;
setIsReset: (value: boolean) => void;
}
export default function ImageCropper({
dialogOpen,
setDialogOpen,
selectedFile,
validationRules,
setData,
name,
value,
reset,
setIsReset,
isReset,
url,
finalFileName,
}: ImageCropperProps) {
const { routes } = usePage().props.configBackend as ConfigBackend;
const { errors } = usePage().props;
const aspect = 1;
const { isDesktop } = useDeviceType();
const imgRef = useRef<HTMLImageElement | null>(null);
const isDuplicating = route().current(`${routes.name}*.duplicate`);
// Zone of the crop in %.
const [cropZone, setCropZone] = useState<Crop>();
const [croppedImage, setCroppedImage] = useState<string>("");
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,
});
// É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);
}
// Reset de la position et dimensions quand l'image change
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setImagePosition({ x: 0, y: 0 });
setOriginalDimensions({ width: 0, height: 0 });
}, [selectedFile]);
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<HTMLImageElement>
* @return void
*/
function onImageLoad(e: SyntheticEvent<HTMLImageElement>): 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(
useTrans("uwil-backend::back.image_cropper.filters.brightness"),
)
.ucFirst()
.value();
const transContrast = str(
useTrans("uwil-backend::back.image_cropper.filters.contrast"),
)
.ucFirst()
.value();
const transSaturation = str(
useTrans("uwil-backend::back.image_cropper.filters.saturation"),
)
.ucFirst()
.value();
const transSepia = str(
useTrans("uwil-backend::back.image_cropper.filters.sepia"),
)
.ucFirst()
.value();
const transGrayscale = str(
useTrans("uwil-backend::back.image_cropper.filters.grayscale"),
)
.ucFirst()
.value();
const transRotation = str(
useTrans("uwil-backend::back.image_cropper.filters.rotation"),
)
.ucFirst()
.value();
const transZoom = str(
useTrans("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 (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<div className="relative">
<DialogTrigger asChild>
<Button
variant="ghost"
type="button"
className={cn("flex border overflow-hidden w-full h-36", {
"border-destructive": errors[name],
})}
>
<img
className="w-full h-full object-contain"
src={croppedImage.length ? croppedImage : selectedFile?.preview}
alt={useTrans("uwil-backend::back.image_cropper.preview_image")}
/>
{!selectedFile && (
<div className="w-full h-full flex items-center justify-center">
<Upload />
</div>
)}
</Button>
</DialogTrigger>
</div>
<DialogContent className="overflow-y-scroll max-h-[90vh] w-full max-w-full md:max-w-xl lg:max-w-5xl xl:max-w-screen-xl">
<DialogTitle>
{useTrans("uwil-backend::back.image_cropper.modale_title")}
</DialogTitle>
<div className="flex flex-col xl:flex-row items-center gap-4">
{/* Cropper content */}
<div
className="w-full flex items-center"
style={{
height: maxContainerHeight,
width: maxContainerWidth,
}}
>
<ReactCrop
crop={cropZone}
onChange={(_, percentCrop) => {
setCropZone(percentCrop);
}}
aspect={aspect}
className="w-full"
locked
>
<div
role="presentation"
style={{
height: maxContainerHeight,
width: maxContainerWidth,
}}
className="relative overflow-hidden bg-gray-100"
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseDown={handleMouseDown}
onMouseLeave={handleMouseUp}
>
<img
ref={imgRef}
src={selectedFile?.preview}
onLoad={onImageLoad}
alt={useTrans(
"uwil-backend::back.image_cropper.preview_image",
)}
style={{
position: "absolute",
left: `${String(imagePosition.x)}px`,
top: `${String(imagePosition.y)}px`,
width:
originalDimensions.width > 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}
/>
</div>
</ReactCrop>
</div>
{/* Cropper filters */}
<div className="flex flex-col items-center justify-between gap-4 w-full h-full">
<div className="w-full">
<DialogDescription className="text-center mb-4">
{useTrans(
"uwil-backend::back.image_cropper.modale_description",
)}
</DialogDescription>
{mainOptions.map((option, optionIndex) => (
<div
key={optionIndex}
className="flex items-center gap-2 w-full mb-4"
>
<div className="flex flex-col w-full items-start">
<label htmlFor="rotation" className="mb-2 text-sm">
{option.label()}
</label>
<Tooltip>
<TooltipTrigger asChild>
<Slider
min={option.slider.min}
max={option.slider.max}
value={option.slider.value}
className="w-full"
onValueChange={option.slider.onValueChange}
/>
</TooltipTrigger>
<TooltipContent>
{str(
GetTranslation(
`uwil-backend::back.image_cropper.filters.${option.tooltip}`,
),
)
.ucFirst()
.value()}
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-1">
{option.buttons.map((currentOption, currentOptionIndex) => (
<Tooltip key={currentOptionIndex}>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={currentOption.fn}
>
<currentOption.icon />
</Button>
</TooltipTrigger>
<TooltipContent>
{str(
GetTranslation(
`uwil-backend::back.image_cropper.filters.${currentOption.tooltip}`,
),
)
.ucFirst()
.value()}
</TooltipContent>
</Tooltip>
))}
</div>
</div>
))}
{/* FILTRES - Collapsible */}
<Collapsible
open={filtersOpen}
onOpenChange={setFiltersOpen}
className="w-full mb-4"
>
<CollapsibleTrigger asChild>
<Button variant="outline" className="w-full justify-between">
<span className="text-sm">
{str(
useTrans("uwil-backend::back.filter", {
choice: 2,
}),
)
.ucFirst()
.value()}
</span>
<ChevronDownIcon
className={`h-4 w-4 transition-transform duration-200 ${filtersOpen ? "rotate-180" : ""}`}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-4">
<div className="grid grid-cols-2 gap-4">
{imageFilterOptions.map((option, optionIndex) => (
<div
key={optionIndex}
className="flex flex-col items-start"
>
<label
htmlFor={str(option.label()).slug().value()}
className="mb-2 text-sm"
>
{option.label()}
</label>
<Tooltip>
<TooltipTrigger asChild>
<Slider
id={str(option.label()).slug().value()}
min={option.slider.min}
max={option.slider.max}
value={option.slider.value}
className="w-full"
onValueChange={option.slider.onValueChange}
/>
</TooltipTrigger>
<TooltipContent>
{str(
GetTranslation(
`uwil-backend::back.image_cropper.filters.${option.tooltip}`,
),
)
.ucFirst()
.value()}
</TooltipContent>
</Tooltip>
</div>
))}
</div>
</CollapsibleContent>
</Collapsible>
{/* Others actions */}
<div className="flex flex-row justify-center gap-2 mb-4">
{otherOptions.map((option, optionKey) => {
return (
<Tooltip key={optionKey}>
<TooltipTrigger asChild>
<Button variant="outline" size="sm" onClick={option.fn}>
<option.icon />
</Button>
</TooltipTrigger>
<TooltipContent>
{str(
GetTranslation(
`uwil-backend::back.image_cropper.filters.${option.tooltip}`,
),
)
.ucFirst()
.value()}
</TooltipContent>
</Tooltip>
);
})}
</div>
</div>
<DialogFooter className="justify-center">
<DialogClose asChild>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
type="reset"
className="w-fit"
variant="outline"
onClick={() => {
setDialogOpen(false);
}}
>
<Trash2Icon className="mr-1.5 size-4" />
{str(useTrans("uwil-backend::back.cancel"))
.ucFirst()
.value()}
</Button>
</TooltipTrigger>
<TooltipContent>
{str(
useTrans(
"uwil-backend::back.image_cropper.action_the_crop",
{
params: {
action: str(useTrans("uwil-backend::back.cancel"))
.ucFirst()
.value(),
},
},
),
)
.ucFirst()
.value()}
</TooltipContent>
</Tooltip>
</DialogClose>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="submit"
size="sm"
className="w-fit"
onClick={() => {
onCrop();
}}
>
<CropIcon className="mr-1.5 size-4" />
{str(useTrans("uwil-backend::back.crop")).ucFirst().value()}
</Button>
</TooltipTrigger>
<TooltipContent>
{str(
useTrans(
"uwil-backend::back.image_cropper.action_the_crop",
{
params: {
action: str(useTrans("uwil-backend::back.validate"))
.ucFirst()
.value(),
},
},
),
)
.ucFirst()
.value()}
</TooltipContent>
</Tooltip>
</DialogFooter>
</div>
</div>
</DialogContent>
</Dialog>
);
}