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