1036 lines
41 KiB
TypeScript
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>
|
|
)
|
|
}
|