ldc-groupe/resources/ts/back/Components/Inputs/ImageCropper.tsx
2025-11-07 19:00:09 +01:00

1036 lines
41 KiB
TypeScript

// * Packages
import { Dispatch, MouseEvent, SetStateAction, useCallback, useEffect, useRef, useState, type SyntheticEvent } from "react"
import { ChevronDownIcon, CropIcon, FlipHorizontalIcon, FlipVerticalIcon, RotateCcwIcon, RotateCwIcon, TrashIcon, Trash2Icon, UploadIcon, ZoomInIcon, ZoomOutIcon, RefreshCwIcon } from "lucide-react"
import ReactCrop, {
type Crop,
type PixelCrop,
} from "react-image-crop"
import { ErrorCode, FileWithPath, useDropzone } from "react-dropzone"
import "react-image-crop/dist/ReactCrop.css"
// * Components shadcn
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogTrigger,
} from "@/components/ui/dialog"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import { Label } from "@/components/ui/label"
import { Slider } from "@/components/ui/slider"
// * Other
import useIsMobile from "@/hooks/use-mobile"
import useTrans from "@/hooks/use-translations"
import str from "@/hooks/use-string"
import { useToast } from "@/hooks/use-toast"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import InputError from "../InputError"
import { usePage } from "@inertiajs/react"
import { AppEnv } from "@/back/types/global"
import { route } from "ziggy-js"
export type FileWithPreview = FileWithPath & {
preview: string
}
type ImageCropperProps = {
name: string;
value?: string | null,
setData: (name: string, value: File | string | null) => void;
label?: string;
required?: boolean;
validationRules?: {
width: string | null;
height: string | null;
} | null
maxFileSize: string | null;
allowedFormats: string | null;
reset: (name?: string) => void;
fileName?: string;
}
interface ImageCropperComponentProps {
dialogOpen: boolean;
name: string;
value?: string | null;
setData: (name: string, value: File | string | null) => void;
setDialogOpen: Dispatch<SetStateAction<boolean>>
selectedFile: FileWithPreview | null
setSelectedFile: Dispatch<SetStateAction<FileWithPreview | null>>
validationRules: CropValidationRules
reset: (name?: string) => void;
setIsReset: (value: boolean) => void;
isReset: boolean;
url: string;
finalFileName: string;
}
interface CropValidationRules {
width: number
height: number
}
export default function ImageCropper(props: ImageCropperProps) {
const {
value,
setData,
name = "defaultName",
label = "defaultLabel",
required = false,
maxFileSize,
validationRules,
allowedFormats,
reset,
fileName = 'image'
} = props;
const { toast } = useToast();
const { errors } = usePage().props;
const { url } = usePage().props.appEnv as AppEnv;
const [isReset, setIsReset] = useState<boolean>(false);
const finalFileName = `${fileName}.png`;
const [selectedFile, setSelectedFile] = useState<FileWithPreview | null>(
value
? ({
preview: `${url}/${value}`
} as unknown as FileWithPreview)
: null
);
const [isDialogOpen, setDialogOpen] = useState(false)
const [accept, setAccept] = useState<Record<string, string[]>>({
"image/*": [],
});
const [extensions, setExtensions] = useState<string>();
const localValidationRules = {
width: validationRules?.width ? parseInt(validationRules.width, 10) : 250,
height: validationRules?.height ? parseInt(validationRules?.height, 10) : 250,
};
const localMaxFileSize = maxFileSize ? (parseInt(maxFileSize, 10) * 1024) : 50000;
// Fonction utilitaire pour formater la taille de fichier
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
const buildDropzoneAccept = (): Record<string, string[]> => {
if (!allowedFormats) return {};
const extensions = allowedFormats.split(",");
setExtensions(allowedFormats);
const mimeMap: Record<string, string> = {
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
gif: "image/gif",
pdf: "application/pdf",
svg: "image/svg+xml",
webp: "image/webp",
};
const result: Record<string, Set<string>> = {};
extensions.forEach(ext => {
const cleanExt = ext.trim().toLowerCase();
const mime = mimeMap[cleanExt];
if (mime) {
if (!result[mime]) {
result[mime] = new Set();
}
result[mime].add(`.${cleanExt}`);
}
});
// Convert Set to Array for React Dropzone
const accept: Record<string, string[]> = {};
for (const [mime, extSet] of Object.entries(result)) {
accept[mime] = Array.from(extSet);
}
setAccept(accept);
return accept;
};
useEffect(() => {
buildDropzoneAccept()
}, [])
/** This error message for the useDropZone can not be used directly in the hook because or react hook error, they have to be used as they actually are*/
const dropErrorMessages = {
noFile: str(useTrans('back.image_cropper.toast.no_file')).ucFirst().value(),
tooLarge: str(useTrans('back.image_cropper.toast.too_big', {
params: {
maxSize: formatFileSize(localMaxFileSize)
}
})).ucFirst().value(),
forbidden: str(useTrans('back.image_cropper.toast.forbidden_file')).ucFirst().value(),
tooMany: str(useTrans('back.image_cropper.toast.too_many')).ucFirst().value(),
default: str(useTrans('back.image_cropper.toast.defaultError')).ucFirst().value(),
};
const onDrop = useCallback(
(acceptedFiles: FileWithPath[]) => {
const file = acceptedFiles[0]
if (!file) {
toast({
title: "Oups !",
description: dropErrorMessages.noFile
})
return
}
const fileWithPreview = Object.assign(file, {
preview: URL.createObjectURL(file),
})
setSelectedFile(fileWithPreview)
setDialogOpen(true)
},
[maxFileSize, formatFileSize],
)
const { getRootProps, getInputProps } = useDropzone({
onDrop,
accept,
multiple: false,
maxSize: localMaxFileSize,
onDropRejected: (fileRejections) => {
const rejection = fileRejections[0]
const rejectionCode = rejection?.errors?.[0]?.code;
if (rejectionCode === ErrorCode.FileTooLarge) {
toast({
title: 'Oups !',
description: `${dropErrorMessages.tooLarge} (${formatFileSize(rejection.file.size)}).`
})
} else if (rejectionCode === ErrorCode.FileInvalidType) {
toast({
title: 'Oups !',
description: dropErrorMessages.forbidden
})
} else if (rejectionCode === ErrorCode.TooManyFiles) {
toast({
title: 'Oups !',
description: dropErrorMessages.tooMany
})
} else {
toast({
title: 'Oups !',
description: dropErrorMessages.default
})
}
}
});
return (
<div>
<Label htmlFor={name}>{label + (required ? " *" : "")}</Label>
<div className="relative flex flex-col gap-1">
{selectedFile ? (
<ImageCropperComponent
dialogOpen={isDialogOpen}
value={value}
reset={reset}
setData={setData}
setDialogOpen={setDialogOpen}
selectedFile={selectedFile}
setSelectedFile={setSelectedFile}
validationRules={{ width: localValidationRules.width, height: localValidationRules.height }}
name={name}
url={url}
isReset={isReset}
setIsReset={setIsReset}
finalFileName={finalFileName}
/>
) : (
<div
{...getRootProps()}
className="size-36 cursor-pointer outline-offset-2 outline-gray-200 outline-dashed outline-2 rounded-none flex items-center justify-center"
>
<input name={name} {...getInputProps()} />
<div className="rounded-none"><UploadIcon /></div>
</div>
)}
</div>
<small>
{extensions ? (
str(useTrans('back.image_cropper.validation.allowed_formats', {
params: {
formats: extensions.toString()
}
})).ucFirst().value()
) : (
str(useTrans('back.image_cropper.validation.all_formats')).ucFirst().value()
)}. <br />
{str(useTrans('back.image_cropper.validation.rules_weight', {
params: {
size: formatFileSize(localMaxFileSize).toString(),
}
})).ucFirst().value()}
</small>
<InputError message={errors[name]} />
</div>
)
}
function ImageCropperComponent({
dialogOpen,
setDialogOpen,
selectedFile,
setSelectedFile,
validationRules,
setData,
name,
value,
reset,
setIsReset,
isReset,
url,
finalFileName,
}: ImageCropperComponentProps) {
const aspect = 1
const isMobile = useIsMobile()
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 = isMobile ? 350 : 550;
const maxContainerWidth = isMobile ? 450 : 900;
/**
* 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)
}
/**
* 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
tmpCtx.rotate((flipX && flipY) ? rad : ((flipX || flipY) ? - rad : rad))
// 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(
url: string,
callback: (base64: string | ArrayBuffer | null) => void,
errorCallback?: (error: any) => void
): void {
fetch(url)
.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 arr = dataUrl.split(',')
const mimeMatch = arr[0].match(/:(.*?);/)
const mime = mimeMatch ? mimeMatch[1] : 'image/png'
const bstr = atob(arr[1])
let n = bstr.length
const u8arr = new Uint8Array(n)
while (n--) {
u8arr[n] = bstr.charCodeAt(n)
}
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)
}
},
(err) => { }
);
}, [])
/**
* 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) {
alert("Something went wrong!")
}
}
const placeCropZone = (width: number, height: number, scaleFactor: number) => {
const displayedWidth = width * scaleFactor
const displayedHeight = height * scaleFactor
// 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
})
}
// 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 imageFilterOptions = [
{
label: () => `${str(useTrans('back.image_cropper.filters.brightness')).ucFirst().value()}: ${brightness}%`,
tooltip: 'brightness',
slider: {
min: 0,
max: 200,
step: 1,
value: [brightness],
onValueChange: (e: number[]) => {
setIsReset(false)
setBrightness(parseInt(e[0].toString()))
},
}
},
{
label: () => `${str(useTrans('back.image_cropper.filters.contrast')).ucFirst().value()}: ${contrast}%`,
tooltip: 'contrast',
slider: {
min: 0,
max: 200,
step: 1,
value: [contrast],
onValueChange: (e: number[]) => {
setContrast(parseInt(e[0].toString()))
setIsReset(false)
},
}
},
{
label: () => `${str(useTrans('back.image_cropper.filters.saturation')).ucFirst().value()}: ${saturation}%`,
tooltip: 'saturation',
slider: {
min: 0,
max: 200,
step: 1,
value: [saturation],
onValueChange: (e: number[]) => {
setSaturation(parseInt(e[0].toString()))
setIsReset(false)
},
}
},
{
label: () => `${str(useTrans('back.image_cropper.filters.sepia')).ucFirst().value()}: ${sepia}%`,
tooltip: 'sepia',
slider: {
min: 0,
max: 100,
step: 1,
value: [sepia],
onValueChange: (e: number[]) => {
setSepia(parseInt(e[0].toString()))
setIsReset(false)
},
}
},
{
label: () => `${str(useTrans('back.image_cropper.filters.grayscale')).ucFirst().value()}: ${grayscale}%`,
tooltip: 'grayscale',
slider: {
min: 0,
max: 100,
step: 1,
value: [grayscale],
onValueChange: (e: number[]) => {
setGrayscale(parseInt(e[0].toString()))
setIsReset(false)
},
}
}
];
const mainOptions = [
{
label: () => `${str(useTrans('back.image_cropper.filters.rotation')).ucFirst().value()}: ${rotation}°`,
tooltip: 'rotation',
slider: {
min: -180,
max: 180,
step: 1,
value: [rotation],
onValueChange: (e: number[]) => {
setRotation(parseInt(e[0].toString()))
setIsReset(false)
},
},
buttons: [
{
icon: RotateCcwIcon,
tooltip: 'rotation_back',
fn: () => setRotation((r) => r - 10),
},
{
icon: RotateCwIcon,
tooltip: 'rotation_forward',
fn: () => setRotation((r) => r + 10),
},
],
},
{
label: () => `${str(useTrans('back.image_cropper.filters.zoom')).ucFirst().value()}: ${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((value) => {
const newValue = value - 0.1;
return newValue < -0.99 ? -0.99 : parseFloat(newValue.toFixed(1));
}
)
setIsReset(false)
},
},
{
icon: ZoomInIcon,
tooltip: 'zoomIn',
fn: () => {
setZoom((value) => {
const newValue = value + 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 ? croppedImage : selectedFile?.preview}
alt="ImagePreview"
/>
{!selectedFile && (
<div className="w-full h-full flex items-center justify-center">
<UploadIcon />
</div>
)}
</div>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button variant={"destructive"} size={"sm"} onClick={() => {
setSelectedFile(null)
setData(name, null)
}} className="absolute top-2 right-2"><TrashIcon /></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-6xl flex flex-col 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
style={{
height: maxContainerHeight,
width: maxContainerWidth
}}
className="relative overflow-hidden bg-gray-100"
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<img
ref={imgRef}
src={selectedFile?.preview}
onLoad={onImageLoad}
onMouseDown={handleMouseDown}
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>
<div className="w-full">
{mainOptions.map((option, index) => (
<div key={index} 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 key={index}>
<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(useTrans(`back.image_cropper.filters.${option.tooltip}`)).ucFirst().value()}
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-1">
{option.buttons.map((option, btnIndex) => (
<Tooltip key={btnIndex}>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={option.fn}
>
<option.icon />
</Button>
</TooltipTrigger>
<TooltipContent>
{str(useTrans(`back.image_cropper.filters.${option.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, index) => (
<div key={index} className="flex flex-col items-start">
<label className="mb-2 text-sm">{option.label()}</label>
<Tooltip key={index}>
<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(useTrans(`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, index) => {
return <>
<Tooltip key={index}>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={option.fn}
>
<option.icon />
</Button>
</TooltipTrigger>
<TooltipContent>
{str(useTrans(`back.image_cropper.filters.${option.tooltip}`)).ucFirst().value()}
</TooltipContent>
</Tooltip>
</>
})}
</div>
</div>
<DialogFooter className="p-6 pt-0 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>
</DialogContent>
</Dialog>
)
}