1030 lines
33 KiB
TypeScript
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>
|
|
);
|
|
}
|