ldc-groupe/resources/ts/back/Components/Inputs/ImageCropper/ImageCropperLogic.tsx
2025-11-12 16:48:20 +01:00

983 lines
33 KiB
TypeScript

// * Packages
import {
Dispatch,
MouseEvent,
SetStateAction,
useEffect,
useRef,
useState,
type SyntheticEvent,
} from "react";
import {
ChevronDownIcon,
CropIcon,
FlipHorizontalIcon,
FlipVerticalIcon,
RotateCcwIcon,
RotateCwIcon,
Trash,
Trash2Icon,
Upload,
ZoomInIcon,
ZoomOutIcon,
RefreshCwIcon,
} from "lucide-react";
import ReactCrop, { type Crop, type PixelCrop } from "react-image-crop";
import { FileWithPath } from "react-dropzone";
import "react-image-crop/dist/ReactCrop.css";
import { route } from "ziggy-js";
// * Components shadcn
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Slider } from "@/components/ui/slider";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
// * Other
import useTrans, { UseTransOptions } from "@/hooks/use-translations";
import str from "@/hooks/use-string";
import useDeviceType from "@/hooks/use-device-type";
interface CropValidationRules {
width: number;
height: number;
}
type FileWithPreview = FileWithPath & {
preview: string;
};
/* eslint-disable no-unused-vars, @typescript-eslint/no-unused-vars */
interface ImageCropperLogicProps {
dialogOpen: boolean;
name: string;
value?: string | null;
selectedFile: FileWithPreview | null;
validationRules: CropValidationRules;
isReset: boolean;
url: string;
finalFileName: string;
setData: (name: string, value: File | string | null) => void;
setDialogOpen: Dispatch<SetStateAction<boolean>>;
setSelectedFile: Dispatch<SetStateAction<FileWithPreview | null>>;
reset: (name?: string) => void;
setIsReset: (value: boolean) => void;
}
/* eslint-enable no-unused-vars, @typescript-eslint/no-unused-vars */
export default function ImageCropperLogic({
dialogOpen,
setDialogOpen,
selectedFile,
setSelectedFile,
validationRules,
setData,
name,
value,
reset,
setIsReset,
isReset,
url,
finalFileName,
}: ImageCropperLogicProps) {
const aspect = 1;
const { isDesktop } = useDeviceType();
const imgRef = useRef<HTMLImageElement | null>(null);
const hasRouteParams = route().routeParams?.dynamic_page;
const isDuplicating = hasRouteParams
? (route().current("back.dynamic-pages.duplicate", {
dynamic_page: route().routeParams.dynamic_page,
}) ?? false)
: false;
// 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,
cropLocalization: PixelCrop,
): string {
const canvas = document.createElement("canvas");
// NOUVEAU CALCUL: l'image a une taille de base (scaleFactor) + zoom appliqué via transform
const baseDisplayedWidth = originalDimensions.width * scaleFactor;
const baseDisplayedHeight = originalDimensions.height * scaleFactor;
const effectiveZoom = 1 + zoom;
// Calculer l'échelle vers l'image naturelle
const scaleToNatural = image.naturalWidth / baseDisplayedWidth;
// Les coordonnées de crop doivent être ajustées pour le zoom transform
// Le zoom étant centré, on doit calculer le décalage
const zoomOffsetX =
(baseDisplayedWidth * effectiveZoom - baseDisplayedWidth) / 2;
const zoomOffsetY =
(baseDisplayedHeight * effectiveZoom - baseDisplayedHeight) / 2;
// Ajuster les coordonnées par rapport à la position ET au zoom
const adjustedCropX =
((cropLocalization.x - imagePosition.x + zoomOffsetX) / effectiveZoom) *
scaleToNatural;
const adjustedCropY =
((cropLocalization.y - imagePosition.y + zoomOffsetY) / effectiveZoom) *
scaleToNatural;
const cropWidth = (cropLocalization.width / effectiveZoom) * scaleToNatural;
const cropHeight =
(cropLocalization.height / effectiveZoom) * scaleToNatural;
// Calculer l'angle en radians
const rad = (rotation * Math.PI) / 180;
// Créer un canvas temporaire assez grand pour contenir toute la rotation
const tmpCanvas = document.createElement("canvas");
const tmpCtx = tmpCanvas.getContext("2d")!;
tmpCanvas.width = image.naturalWidth;
tmpCanvas.height = image.naturalHeight;
tmpCtx.save();
// Appliquer les filtres CSS au contexte du canvas (sauf blur qui ne marche pas bien)
tmpCtx.filter = `brightness(${brightness}%) contrast(${contrast}%) saturate(${saturation}%) sepia(${sepia}%) grayscale(${grayscale}%)`;
// Placer le centre de rotation
tmpCtx.translate(tmpCanvas.width / 2, tmpCanvas.height / 2);
// Appliquer seulement le flip (le scale est maintenant dans les dimensions)
tmpCtx.scale(flipX ? -1 : 1, flipY ? -1 : 1);
// Appliquer rotation
const getRotation = () => {
if (flipX && flipY) return rad;
if (flipX || flipY) return -rad;
return rad;
};
tmpCtx.rotate(getRotation());
// Dessiner l'image au centre avec les filtres appliqués
tmpCtx.drawImage(image, -image.naturalWidth / 2, -image.naturalHeight / 2);
tmpCtx.restore();
// Maintenant, extraire la zone cropée depuis le canvas temporaire
const imageData = tmpCtx.getImageData(
adjustedCropX,
adjustedCropY,
cropWidth,
cropHeight,
);
// IMPORTANT: Redimensionner à la taille finale souhaitée (validationRules)
canvas.width = validationRules.width;
canvas.height = validationRules.height;
const ctx = canvas.getContext("2d")!;
// Créer un canvas temporaire avec l'image cropée
const tempCanvas = document.createElement("canvas");
const tempCtx = tempCanvas.getContext("2d")!;
tempCanvas.width = cropWidth;
tempCanvas.height = cropHeight;
tempCtx.putImageData(imageData, 0, 0);
// Redimensionner l'image cropée aux dimensions finales souhaitées
ctx.drawImage(
tempCanvas,
0,
0,
cropWidth,
cropHeight, // Source
0,
0,
validationRules.width,
validationRules.height, // Destination
);
return canvas.toDataURL("image/png");
}
function urlToBase64(
targetUrl: string,
// eslint-disable-next-line no-unused-vars
callback: (base64: string | ArrayBuffer | null) => void,
// eslint-disable-next-line no-unused-vars
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) => {
if (errorCallback) errorCallback(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 = meta.match(/:(.*?);/);
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(() => {
setImagePosition({ x: 0, y: 0 });
setOriginalDimensions({ width: 0, height: 0 });
}, [selectedFile]);
useEffect(() => {
if (!value || !isDuplicating) return;
const fileBase = `${url}/${value}`;
urlToBase64(
fileBase,
(base64) => {
if (typeof base64 === "string") {
const file = base64ToFile(base64);
setData(name, file);
}
},
() => {},
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/**
* Crop the image with all options.
*/
async function onCrop() {
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: Error | unknown) {
throw new Error(`Something went wrong : ${error}`);
}
}
const placeCropZone = (
width: number,
height: number,
currentScaleFactor: number,
) => {
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);
}
// Fonction de reset pour revenir à l'état initial
function resetToInitialState() {
setRotation(0);
setFlipX(false);
setFlipY(false);
setZoom(0);
// Reset des filtres
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 (imgRef.current && 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 });
}
}
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",
},
];
const transBrightness = str(useTrans("back.image_cropper.filters.brightness"))
.ucFirst()
.value();
const transContrast = str(useTrans("back.image_cropper.filters.contrast"))
.ucFirst()
.value();
const transSaturation = str(useTrans("back.image_cropper.filters.saturation"))
.ucFirst()
.value();
const transSepia = str(useTrans("back.image_cropper.filters.sepia"))
.ucFirst()
.value();
const transGrayscale = str(useTrans("back.image_cropper.filters.grayscale"))
.ucFirst()
.value();
const transRotation = str(useTrans("back.image_cropper.filters.rotation"))
.ucFirst()
.value();
const transZoom = str(useTrans("back.image_cropper.filters.zoom"))
.ucFirst()
.value();
const GetTranslation = (keyName: string, options?: UseTransOptions) => {
return useTrans(keyName, options);
};
const imageFilterOptions = [
{
label: () => `${transBrightness}: ${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}: ${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}: ${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}: ${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}: ${grayscale}%`,
tooltip: "grayscale",
slider: {
min: 0,
max: 100,
step: 1,
value: [grayscale],
onValueChange: (e: number[]) => {
setGrayscale(parseInt(e[0].toString(), 10));
setIsReset(false);
},
},
},
];
const mainOptions = [
{
label: () => `${transRotation}: ${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}: ${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);
},
},
],
},
];
return (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger className="relative w-fit">
<div className="flex flex-col items-center gap-2 w-fit">
<div className="aspect-square size-36 cursor-pointer outline-offset-2 outline-gray-200 outline-dashed outline-2 rounded-none">
<img
className="w-full h-full object-contain"
src={croppedImage.length ? croppedImage : selectedFile?.preview}
alt="ImagePreview"
/>
{!selectedFile && (
<div className="w-full h-full flex items-center justify-center">
<Upload />
</div>
)}
</div>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="destructive"
size="sm"
onClick={() => {
setSelectedFile(null);
setData(name, null);
}}
className="absolute top-2 right-2"
>
<Trash />
</Button>
</TooltipTrigger>
<TooltipContent>
{str(useTrans("back.cancel")).ucFirst().value()}
</TooltipContent>
</Tooltip>
</DialogTrigger>
<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("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)}
onComplete={() => {}}
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="Preview element"
style={{
position: "absolute",
left: `${imagePosition.x}px`,
top: `${imagePosition.y}px`,
width:
originalDimensions.width > 0
? `${originalDimensions.width * scaleFactor}px`
: "auto",
height:
originalDimensions.height > 0
? `${originalDimensions.height * scaleFactor}px`
: "auto",
transform: `scale(${1 + zoom}) rotate(${rotation}deg) scaleX(${flipX ? -1 : 1}) scaleY(${flipY ? -1 : 1})`,
filter: `brightness(${brightness}%) contrast(${contrast}%) saturate(${saturation}%) sepia(${sepia}%) grayscale(${grayscale}%)`,
transformOrigin: "center center",
transition: isDragging
? "none"
: "transform 0.2s ease, filter 0.2s ease",
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">
{mainOptions.map((option) => (
<div 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(
`back.image_cropper.filters.${option.tooltip}`,
),
)
.ucFirst()
.value()}
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-1">
{option.buttons.map((currentOption) => (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={currentOption.fn}
>
<currentOption.icon />
</Button>
</TooltipTrigger>
<TooltipContent>
{str(
GetTranslation(
`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("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) => (
<div 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(
`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) => {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="sm" onClick={option.fn}>
<option.icon />
</Button>
</TooltipTrigger>
<TooltipContent>
{str(
GetTranslation(
`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("back.cancel")).ucFirst().value()}
</Button>
</TooltipTrigger>
<TooltipContent>
{str(
useTrans("back.image_cropper.action_the_crop", {
params: {
action: str(useTrans("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("back.crop")).ucFirst().value()}
</Button>
</TooltipTrigger>
<TooltipContent>
{str(
useTrans("back.image_cropper.action_the_crop", {
params: {
action: str(useTrans("back.validate"))
.ucFirst()
.value(),
},
}),
)
.ucFirst()
.value()}
</TooltipContent>
</Tooltip>
</DialogFooter>
</div>
</div>
</DialogContent>
</Dialog>
);
}