diff --git a/package-lock.json b/package-lock.json index 2d868596f30..20330544422 100644 --- a/package-lock.json +++ b/package-lock.json @@ -73,6 +73,7 @@ "react": "18.3.1", "react-day-picker": "^8.10.1", "react-dom": "18.3.1", + "react-easy-crop": "^5.2.0", "react-google-recaptcha": "^3.1.0", "react-hook-form": "^7.53.2", "react-i18next": "^15.2.0", @@ -13831,6 +13832,12 @@ "node": ">=0.10.0" } }, + "node_modules/normalize-wheel": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz", + "integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==", + "license": "BSD-3-Clause" + }, "node_modules/npm-run-path": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", @@ -15071,6 +15078,20 @@ "react": "^18.3.1" } }, + "node_modules/react-easy-crop": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-5.2.0.tgz", + "integrity": "sha512-gjb7jN+WnwfgpbNUI2jSwyoIxF1sJ0PVSNVgEysAgF1rj8AqR75fqmdvqZ6PFVgEX3rT1G4HJELesiQXr2ZvAg==", + "license": "MIT", + "dependencies": { + "normalize-wheel": "^1.0.1", + "tslib": "^2.0.1" + }, + "peerDependencies": { + "react": ">=16.4.0", + "react-dom": ">=16.4.0" + } + }, "node_modules/react-google-recaptcha": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/react-google-recaptcha/-/react-google-recaptcha-3.1.0.tgz", diff --git a/package.json b/package.json index 05eec6bf7e0..03a65a76b55 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,7 @@ "react": "18.3.1", "react-day-picker": "^8.10.1", "react-dom": "18.3.1", + "react-easy-crop": "^5.2.0", "react-google-recaptcha": "^3.1.0", "react-hook-form": "^7.53.2", "react-i18next": "^15.2.0", diff --git a/src/Utils/getCroppedImg.tsx b/src/Utils/getCroppedImg.tsx new file mode 100644 index 00000000000..ec83f133448 --- /dev/null +++ b/src/Utils/getCroppedImg.tsx @@ -0,0 +1,34 @@ +const createImage = (url: string): Promise => + new Promise((resolve, reject) => { + const image = new Image(); + image.addEventListener("load", () => resolve(image)); + image.addEventListener("error", (error) => reject(error)); + image.setAttribute("crossOrigin", "anonymous"); // needed to avoid cross-origin issues on some browsers + image.src = url; + }); + +export async function getCroppedImg(imageSrc: string, croppedAreaPixels: any) { + const image = await createImage(imageSrc); + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + + if (!ctx) { + return null; + } + + const { x, y, width, height } = croppedAreaPixels; + + canvas.width = width; + canvas.height = height; + + ctx.drawImage(image, x, y, width, height, 0, 0, width, height); + + return new Promise((resolve) => { + canvas.toBlob((blob) => { + if (!blob) { + return resolve(""); + } + resolve(URL.createObjectURL(blob)); + }, "image/png"); + }); +} diff --git a/src/components/Common/AvatarEditModal.tsx b/src/components/Common/AvatarEditModal.tsx index 65132eccd59..12ab7691659 100644 --- a/src/components/Common/AvatarEditModal.tsx +++ b/src/components/Common/AvatarEditModal.tsx @@ -5,6 +5,7 @@ import React, { useRef, useState, } from "react"; +import Cropper from "react-easy-crop"; import { useTranslation } from "react-i18next"; import Webcam from "react-webcam"; import { toast } from "sonner"; @@ -17,6 +18,8 @@ import DialogModal from "@/components/Common/Dialog"; import useDragAndDrop from "@/hooks/useDragAndDrop"; +import { getCroppedImg } from "@/Utils/getCroppedImg"; + interface Props { title: string; open: boolean; @@ -29,12 +32,12 @@ interface Props { const VideoConstraints = { user: { - width: 1280, + width: 720, height: 720, facingMode: "user", }, environment: { - width: 1280, + width: 720, height: 720, facingMode: { exact: "environment" }, }, @@ -59,7 +62,7 @@ const AvatarEditModal = ({ const [preview, setPreview] = useState(); const [isCameraOpen, setIsCameraOpen] = useState(false); const webRef = useRef(null); - const [previewImage, setPreviewImage] = useState(null); + const [previewImage, setPreviewImage] = useState(null); const [isCaptureImgBeingUploaded, setIsCaptureImgBeingUploaded] = useState(false); const [constraint, setConstraint] = useState( @@ -67,6 +70,11 @@ const AvatarEditModal = ({ ); const { t } = useTranslation(); const [isDragging, setIsDragging] = useState(false); + const [crop, setCrop] = useState({ x: 0, y: 0 }); + const [zoom, setZoom] = useState(1); + const [croppedAreaPixels, setCroppedAreaPixels] = useState(null); + const [croppedImage, setCroppedImage] = useState(null); + const [isCropping, setIsCropping] = useState(false); const handleSwitchCamera = useCallback(() => { setConstraint( @@ -74,7 +82,14 @@ const AvatarEditModal = ({ ? VideoConstraints.environment : VideoConstraints.user, ); - }, []); + }, [constraint]); + + const onCropComplete = useCallback( + (croppedArea: any, croppedAreaPixels: any) => { + setCroppedAreaPixels(croppedAreaPixels); + }, + [], + ); const captureImage = () => { setPreviewImage(webRef.current.getScreenshot()); @@ -87,13 +102,57 @@ const AvatarEditModal = ({ }); }; + const handleCropImage = async () => { + if (!previewImage && !preview) return; + setIsCropping(true); + const imageSrc = previewImage || preview; + try { + const croppedImage = await getCroppedImg( + imageSrc as string, + croppedAreaPixels, + ); + setCroppedImage(croppedImage); + } catch (_e) { + toast.error("Unable to crop the image at the moment"); + setCroppedImage(null); + } finally { + setIsCropping(false); + setPreview(undefined); + setPreviewImage(null); + } + }; + + useEffect(() => { + if (croppedImage) { + fetch(croppedImage) + .then((res) => res.blob()) + .then((blob) => { + const myFile = new File([blob], "cropped_image.png", { + type: blob.type, + }); + setSelectedFile(myFile); + setCroppedImage(null); + }); + } + }, [croppedImage]); + const closeModal = () => { setPreview(undefined); setIsProcessing(false); setSelectedFile(undefined); + setPreviewImage(null); + setCroppedImage(null); onClose?.(); }; + useEffect(() => { + if (previewImage || preview) { + setIsCropping(true); + } else { + setIsCropping(false); + } + }, [preview, previewImage]); + useEffect(() => { if (!isImageFile(selectedFile)) { return; @@ -178,6 +237,110 @@ const AvatarEditModal = ({ const hintMessage = hint || defaultHint; + const renderImagePreview = () => { + if (!preview && !imageUrl && !previewImage) + return ( +
+ +

+ {dragProps.fileDropError !== "" + ? dragProps.fileDropError + : `${t("drag_drop_image_to_upload")}`} +

+

+ {t("no_image_found")}. {hintMessage} +

+
+ ); + + return ( +
+
+ {isCropping ? ( + + ) : ( + cover-photo + )} +
+ {isCropping && ( +
+ + +
+ )} +
+ ); + }; + return ( {!isCameraOpen ? ( <> - {preview || imageUrl ? ( - <> -
- cover-photo -
-

- {hintMessage} -

- - ) : ( -
- -

- {dragProps.fileDropError !== "" - ? dragProps.fileDropError - : `${t("drag_drop_image_to_upload")}`} -

-

- {t("no_image_found")}. {hintMessage} -

-
- )} + {renderImagePreview()} +

+ {hintMessage} +

@@ -331,11 +433,16 @@ const AvatarEditModal = ({ <> { setIsCameraOpen(false); toast.warning(t("camera_permission_denied")); @@ -344,7 +451,9 @@ const AvatarEditModal = ({ ) : ( <> - +
+ camera preview +
)}
@@ -376,6 +485,9 @@ const AvatarEditModal = ({ > {t("retake")} +