From 98d34aa1181400470ed9e5dbe093cfa54ef9f10f Mon Sep 17 00:00:00 2001 From: khizarshah01 Date: Mon, 18 Aug 2025 23:54:12 +0530 Subject: [PATCH 1/7] deps: add react-easy-crop for image cropping --- apps/meteor/package.json | 1 + package.json | 3 ++- yarn.lock | 22 ++++++++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/meteor/package.json b/apps/meteor/package.json index c2d47cdc40001..379ada51b5738 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -426,6 +426,7 @@ "react": "~18.3.1", "react-aria": "~3.37.0", "react-dom": "~18.3.1", + "react-easy-crop": "^5.5.0", "react-error-boundary": "^3.1.4", "react-hook-form": "~7.45.4", "react-i18next": "~13.2.2", diff --git a/package.json b/package.json index 88bcfb8eb3b53..ee83e279f4cc9 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ }, "dependencies": { "@types/stream-buffers": "^3.0.7", - "node-gyp": "^10.2.0" + "node-gyp": "^10.2.0", + "react-easy-crop": "^5.5.0" } } diff --git a/yarn.lock b/yarn.lock index c7203c6d304d6..b87914b0f7d42 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9427,6 +9427,7 @@ __metadata: react-aria: "npm:~3.37.0" react-docgen-typescript-plugin: "npm:^1.0.8" react-dom: "npm:~18.3.1" + react-easy-crop: "npm:^5.5.0" react-error-boundary: "npm:^3.1.4" react-hook-form: "npm:~7.45.4" react-i18next: "npm:~13.2.2" @@ -29567,6 +29568,13 @@ __metadata: languageName: node linkType: hard +"normalize-wheel@npm:^1.0.1": + version: 1.0.1 + resolution: "normalize-wheel@npm:1.0.1" + checksum: 10/7a10d4026e64549654c7ec4c08b99e73f857c6f39b9d864e1c5c1fb2c9708e48d6eb388350dbec08407d82828a602ab5366d6cf4d766d70c2aadfadec9d31f78 + languageName: node + linkType: hard + "normalize.css@npm:^8.0.1": version: 8.0.1 resolution: "normalize.css@npm:8.0.1" @@ -32689,6 +32697,19 @@ __metadata: languageName: node linkType: hard +"react-easy-crop@npm:^5.5.0": + version: 5.5.0 + resolution: "react-easy-crop@npm:5.5.0" + dependencies: + normalize-wheel: "npm:^1.0.1" + tslib: "npm:^2.0.1" + peerDependencies: + react: ">=16.4.0" + react-dom: ">=16.4.0" + checksum: 10/388723bc7a7eda9f40ab0a001ebd66461629855e9fc3819c21ebfc37ac4b368751b0de78ade7779811861b850e8a6c3bb68fd5cb24e4e07584a302fb3b64a5d4 + languageName: node + linkType: hard + "react-error-boundary@npm:^3.1.4": version: 3.1.4 resolution: "react-error-boundary@npm:3.1.4" @@ -33821,6 +33842,7 @@ __metadata: "@types/node": "npm:~22.16.5" "@types/stream-buffers": "npm:^3.0.7" node-gyp: "npm:^10.2.0" + react-easy-crop: "npm:^5.5.0" ts-node: "npm:^10.9.2" turbo: "npm:~2.5.8" typescript: "npm:~5.9.3" From ac563384a1735fbcd84c30e25ebad00e69950386 Mon Sep 17 00:00:00 2001 From: khizarshah01 Date: Mon, 18 Aug 2025 23:55:27 +0530 Subject: [PATCH 2/7] feat(image-upload): add client-side image cropping in FileUpload --- .../client/lib/chats/flows/uploadFiles.ts | 15 +-- .../modals/FileUploadModal/FilePreview.tsx | 115 ++++++++++++++++-- .../FileUploadModal/FileUploadModal.tsx | 15 ++- 3 files changed, 125 insertions(+), 20 deletions(-) diff --git a/apps/meteor/client/lib/chats/flows/uploadFiles.ts b/apps/meteor/client/lib/chats/flows/uploadFiles.ts index 3190efb7487a7..f7eca0def11a2 100644 --- a/apps/meteor/client/lib/chats/flows/uploadFiles.ts +++ b/apps/meteor/client/lib/chats/flows/uploadFiles.ts @@ -70,8 +70,9 @@ export const uploadFiles = async (chat: ChatAPI, files: readonly File[], resetFi imperativeModal.close(); uploadNextFile(); }, - onSubmit: async (fileName: string, description?: string): Promise => { - Object.defineProperty(file, 'name', { + onSubmit: async (fileName: string, description?: string, croppedFile?: File): Promise => { + const fileToUpload = croppedFile ?? file; + Object.defineProperty(fileToUpload, 'name', { writable: true, value: fileName, }); @@ -80,30 +81,30 @@ export const uploadFiles = async (chat: ChatAPI, files: readonly File[], resetFi const e2eRoom = await e2e.getInstanceByRoomId(room._id); if (!e2eRoom) { - uploadFile(file, { description }); + uploadFile(fileToUpload, { description }); return; } if (!settings.peek('E2E_Enable_Encrypt_Files')) { - uploadFile(file, { description }); + uploadFile(fileToUpload, { description }); return; } const shouldConvertSentMessages = await e2eRoom.shouldConvertSentMessages({ msg }); if (!shouldConvertSentMessages) { - uploadFile(file, { description }); + uploadFile(fileToUpload, { description }); return; } - const encryptedFile = await e2eRoom.encryptFile(file); + const encryptedFile = await e2eRoom.encryptFile(fileToUpload); if (encryptedFile) { const getContent = async (_id: string, fileUrl: string): Promise => { const attachments = []; const attachment: FileAttachmentProps = { - title: file.name, + title: fileToUpload.name, type: 'file', description, title_link: fileUrl, diff --git a/apps/meteor/client/views/room/modals/FileUploadModal/FilePreview.tsx b/apps/meteor/client/views/room/modals/FileUploadModal/FilePreview.tsx index a7f9e8ace84fe..9eadb9984f06e 100644 --- a/apps/meteor/client/views/room/modals/FileUploadModal/FilePreview.tsx +++ b/apps/meteor/client/views/room/modals/FileUploadModal/FilePreview.tsx @@ -1,8 +1,8 @@ -import type { ReactElement } from 'react'; - +import { useState, useCallback, useEffect, type ReactElement } from 'react'; import GenericPreview from './GenericPreview'; import MediaPreview from './MediaPreview'; import { isIE11 } from '../../../../lib/utils/isIE11'; +import Cropper, { Area } from 'react-easy-crop'; export enum FilePreviewType { IMAGE = 'image', @@ -38,18 +38,115 @@ const shouldShowMediaPreview = (file: File, fileType: FilePreviewType | undefine return true; }; +async function getCroppedFile(imageSrc: string, crop: Area, original: File): Promise { + const image = new Image(); + image.src = imageSrc; + await new Promise((resolve, reject) => { + image.onload = resolve; + image.onerror = reject; + }); + + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + + canvas.width = Math.round(crop.width); + canvas.height = Math.round(crop.height); + + ctx.drawImage( + image, + Math.round(crop.x), + Math.round(crop.y), + Math.round(crop.width), + Math.round(crop.height), + 0, + 0, + Math.round(crop.width), + Math.round(crop.height), + ); + + const mime = original.type && original.type.startsWith('image/') ? original.type : 'image/jpeg'; + const blob: Blob | null = await new Promise((resolve) => canvas.toBlob(resolve, mime, 0.95)); + if (!blob) return null; + + const dot = original.name.lastIndexOf('.'); + const base = dot > 0 ? original.name.slice(0, dot) : original.name; + const ext = dot > 0 ? original.name.slice(dot) : '.jpg'; + const name = `${base}-cropped${ext}`; + + return new File([blob], name, { type: blob.type, lastModified: Date.now() }); +} + type FilePreviewProps = { - file: File; + file: File; + onFileChange?: (file: File) => void; + startCropping?: boolean; + onCropDone?: () => void; }; -const FilePreview = ({ file }: FilePreviewProps): ReactElement => { - const fileType = getFileType(file.type); +const FilePreview = ({ file, onFileChange, startCropping, onCropDone }: FilePreviewProps): ReactElement => { + const fileType = getFileType(file.type); - if (shouldShowMediaPreview(file, fileType)) { - return ; - } + const [crop, setCrop] = useState({ x: 0, y: 0 }); + const [zoom, setZoom] = useState(1); + const [croppedArea, setCroppedArea] = useState(null); + const [croppedImageURL, setCroppedImageURL] = useState(null); + const [isCroppingInternal, setIsCroppingInternal] = useState(false); + + const objUrl = URL.createObjectURL(file); + + useEffect(() => { + if (startCropping) setIsCroppingInternal(true); + }, [startCropping]); + + const onCropComplete = useCallback((_area: Area, croppedAreaPixels: Area) => { + setCroppedArea(croppedAreaPixels); + }, []); + + const handleCropDone = useCallback(async () => { + if (!croppedArea) return; + const croppedFile = await getCroppedFile(objUrl, croppedArea, file); + if (!croppedFile) return; + + const previewUrl = URL.createObjectURL(croppedFile); + setCroppedImageURL(previewUrl); + onFileChange?.(croppedFile); + setIsCroppingInternal(false); + onCropDone?.(); + }, [file, croppedArea, objUrl, onFileChange, onCropDone]); + + if (fileType === FilePreviewType.IMAGE && !croppedImageURL && isCroppingInternal) { + return ( +
+ + +
+ ); + } + + if (croppedImageURL) { + return Cropped preview; + } + + if (shouldShowMediaPreview(file, fileType)) { + return ; + } - return ; + return ; }; export default FilePreview; diff --git a/apps/meteor/client/views/room/modals/FileUploadModal/FileUploadModal.tsx b/apps/meteor/client/views/room/modals/FileUploadModal/FileUploadModal.tsx index b152abad53d64..3debb8d69bc9c 100644 --- a/apps/meteor/client/views/room/modals/FileUploadModal/FileUploadModal.tsx +++ b/apps/meteor/client/views/room/modals/FileUploadModal/FileUploadModal.tsx @@ -19,14 +19,14 @@ import { useAutoFocus, useMergedRefs } from '@rocket.chat/fuselage-hooks'; import { useToastMessageDispatch, useTranslation, useSetting } from '@rocket.chat/ui-contexts'; import fileSize from 'filesize'; import type { ReactElement, ComponentProps } from 'react'; -import { memo, useEffect, useId } from 'react'; +import { memo, useEffect, useId, useState } from 'react'; import { useForm } from 'react-hook-form'; import FilePreview from './FilePreview'; type FileUploadModalProps = { onClose: () => void; - onSubmit: (name: string, description?: string) => void; + onSubmit: (name: string, description: string | undefined, file: File) => void; file: File; fileName: string; fileDescription?: string; @@ -43,6 +43,8 @@ const FileUploadModal = ({ invalidContentType, showDescription = true, }: FileUploadModalProps): ReactElement => { + const [currentFile, setCurrentFile] = useState(file); + const [startCropping, setStartCropping] = useState(false); const { register, handleSubmit, @@ -67,7 +69,7 @@ const FileUploadModal = ({ }); } - onSubmit(name, description); + onSubmit(name, description, currentFile); }; useEffect(() => { @@ -114,8 +116,13 @@ const FileUploadModal = ({ - + + {currentFile.type.startsWith('image/') && !startCropping && ( + + )} {t('Upload_file_name')} From e139ee5c7e9042738e428c73e52846f87671e219 Mon Sep 17 00:00:00 2001 From: khizarshah01 Date: Wed, 27 Aug 2025 17:57:48 +0530 Subject: [PATCH 3/7] fix linting errors --- .../modals/FileUploadModal/FilePreview.tsx | 204 +++++++++--------- .../FileUploadModal/FileUploadModal.tsx | 10 +- 2 files changed, 106 insertions(+), 108 deletions(-) diff --git a/apps/meteor/client/views/room/modals/FileUploadModal/FilePreview.tsx b/apps/meteor/client/views/room/modals/FileUploadModal/FilePreview.tsx index 9eadb9984f06e..cde85166e21bd 100644 --- a/apps/meteor/client/views/room/modals/FileUploadModal/FilePreview.tsx +++ b/apps/meteor/client/views/room/modals/FileUploadModal/FilePreview.tsx @@ -1,8 +1,10 @@ import { useState, useCallback, useEffect, type ReactElement } from 'react'; +import type { Area } from 'react-easy-crop'; +import Cropper from 'react-easy-crop'; + import GenericPreview from './GenericPreview'; import MediaPreview from './MediaPreview'; import { isIE11 } from '../../../../lib/utils/isIE11'; -import Cropper, { Area } from 'react-easy-crop'; export enum FilePreviewType { IMAGE = 'image', @@ -39,114 +41,110 @@ const shouldShowMediaPreview = (file: File, fileType: FilePreviewType | undefine }; async function getCroppedFile(imageSrc: string, crop: Area, original: File): Promise { - const image = new Image(); - image.src = imageSrc; - await new Promise((resolve, reject) => { - image.onload = resolve; - image.onerror = reject; - }); - - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); - if (!ctx) return null; - - canvas.width = Math.round(crop.width); - canvas.height = Math.round(crop.height); - - ctx.drawImage( - image, - Math.round(crop.x), - Math.round(crop.y), - Math.round(crop.width), - Math.round(crop.height), - 0, - 0, - Math.round(crop.width), - Math.round(crop.height), - ); - - const mime = original.type && original.type.startsWith('image/') ? original.type : 'image/jpeg'; - const blob: Blob | null = await new Promise((resolve) => canvas.toBlob(resolve, mime, 0.95)); - if (!blob) return null; - - const dot = original.name.lastIndexOf('.'); - const base = dot > 0 ? original.name.slice(0, dot) : original.name; - const ext = dot > 0 ? original.name.slice(dot) : '.jpg'; - const name = `${base}-cropped${ext}`; - - return new File([blob], name, { type: blob.type, lastModified: Date.now() }); + const image = new Image(); + image.src = imageSrc; + await new Promise((resolve, reject) => { + image.onload = resolve; + image.onerror = reject; + }); + + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + + canvas.width = Math.round(crop.width); + canvas.height = Math.round(crop.height); + + ctx.drawImage( + image, + Math.round(crop.x), + Math.round(crop.y), + Math.round(crop.width), + Math.round(crop.height), + 0, + 0, + Math.round(crop.width), + Math.round(crop.height), + ); + + const mime = original.type && original.type.startsWith('image/') ? original.type : 'image/jpeg'; + const blob: Blob | null = await new Promise((resolve) => canvas.toBlob(resolve, mime, 0.95)); + if (!blob) return null; + + const dot = original.name.lastIndexOf('.'); + const base = dot > 0 ? original.name.slice(0, dot) : original.name; + const ext = dot > 0 ? original.name.slice(dot) : '.jpg'; + const name = `${base}-cropped${ext}`; + + return new File([blob], name, { type: blob.type, lastModified: Date.now() }); } type FilePreviewProps = { - file: File; - onFileChange?: (file: File) => void; - startCropping?: boolean; - onCropDone?: () => void; + file: File; + onFileChange?: (file: File) => void; + startCropping?: boolean; + onCropDone?: () => void; }; const FilePreview = ({ file, onFileChange, startCropping, onCropDone }: FilePreviewProps): ReactElement => { - const fileType = getFileType(file.type); - - const [crop, setCrop] = useState({ x: 0, y: 0 }); - const [zoom, setZoom] = useState(1); - const [croppedArea, setCroppedArea] = useState(null); - const [croppedImageURL, setCroppedImageURL] = useState(null); - const [isCroppingInternal, setIsCroppingInternal] = useState(false); - - const objUrl = URL.createObjectURL(file); - - useEffect(() => { - if (startCropping) setIsCroppingInternal(true); - }, [startCropping]); - - const onCropComplete = useCallback((_area: Area, croppedAreaPixels: Area) => { - setCroppedArea(croppedAreaPixels); - }, []); - - const handleCropDone = useCallback(async () => { - if (!croppedArea) return; - const croppedFile = await getCroppedFile(objUrl, croppedArea, file); - if (!croppedFile) return; - - const previewUrl = URL.createObjectURL(croppedFile); - setCroppedImageURL(previewUrl); - onFileChange?.(croppedFile); - setIsCroppingInternal(false); - onCropDone?.(); - }, [file, croppedArea, objUrl, onFileChange, onCropDone]); - - if (fileType === FilePreviewType.IMAGE && !croppedImageURL && isCroppingInternal) { - return ( -
- - -
- ); - } - - if (croppedImageURL) { - return Cropped preview; - } - - if (shouldShowMediaPreview(file, fileType)) { - return ; - } - - return ; + const fileType = getFileType(file.type); + + const [crop, setCrop] = useState({ x: 0, y: 0 }); + const [zoom, setZoom] = useState(1); + const [croppedArea, setCroppedArea] = useState(null); + const [croppedImageURL, setCroppedImageURL] = useState(null); + const [isCroppingInternal, setIsCroppingInternal] = useState(false); + + const objUrl = URL.createObjectURL(file); + + useEffect(() => { + if (startCropping) setIsCroppingInternal(true); + }, [startCropping]); + + const onCropComplete = useCallback((_area: Area, croppedAreaPixels: Area) => { + setCroppedArea(croppedAreaPixels); + }, []); + + const handleCropDone = useCallback(async () => { + if (!croppedArea) return; + const croppedFile = await getCroppedFile(objUrl, croppedArea, file); + if (!croppedFile) return; + + const previewUrl = URL.createObjectURL(croppedFile); + setCroppedImageURL(previewUrl); + onFileChange?.(croppedFile); + setIsCroppingInternal(false); + onCropDone?.(); + }, [file, croppedArea, objUrl, onFileChange, onCropDone]); + + if (fileType === FilePreviewType.IMAGE && !croppedImageURL && isCroppingInternal) { + return ( +
+ + +
+ ); + } + + if (croppedImageURL) { + return Cropped preview; + } + + if (shouldShowMediaPreview(file, fileType)) { + return ; + } + + return ; }; export default FilePreview; diff --git a/apps/meteor/client/views/room/modals/FileUploadModal/FileUploadModal.tsx b/apps/meteor/client/views/room/modals/FileUploadModal/FileUploadModal.tsx index 3debb8d69bc9c..567a5c53be017 100644 --- a/apps/meteor/client/views/room/modals/FileUploadModal/FileUploadModal.tsx +++ b/apps/meteor/client/views/room/modals/FileUploadModal/FileUploadModal.tsx @@ -118,11 +118,11 @@ const FileUploadModal = ({ - {currentFile.type.startsWith('image/') && !startCropping && ( - - )} + {currentFile.type.startsWith('image/') && !startCropping && ( + + )} {t('Upload_file_name')} From 830e924be36f798fea59349d0cbffc88be8b25de Mon Sep 17 00:00:00 2001 From: khizarshah01 Date: Thu, 28 Aug 2025 22:16:00 +0530 Subject: [PATCH 4/7] feat: add CropFilePreview with feature preview flag (imageCropPreview) --- .../FileUploadModal/CropFilePreview.tsx | 150 ++++++++++++++++++ .../modals/FileUploadModal/FilePreview.tsx | 99 +----------- .../FileUploadModal/FileUploadModal.tsx | 18 ++- packages/i18n/src/locales/en.i18n.json | 2 + .../src/hooks/useFeaturePreviewList.ts | 11 +- 5 files changed, 179 insertions(+), 101 deletions(-) create mode 100644 apps/meteor/client/views/room/modals/FileUploadModal/CropFilePreview.tsx diff --git a/apps/meteor/client/views/room/modals/FileUploadModal/CropFilePreview.tsx b/apps/meteor/client/views/room/modals/FileUploadModal/CropFilePreview.tsx new file mode 100644 index 0000000000000..8adfcf7c971e8 --- /dev/null +++ b/apps/meteor/client/views/room/modals/FileUploadModal/CropFilePreview.tsx @@ -0,0 +1,150 @@ +import { useState, useCallback, useEffect, type ReactElement } from 'react'; +import type { Area } from 'react-easy-crop'; +import Cropper from 'react-easy-crop'; + +import GenericPreview from './GenericPreview'; +import MediaPreview from './MediaPreview'; +import { isIE11 } from '../../../../lib/utils/isIE11'; + +export enum FilePreviewType { + IMAGE = 'image', + AUDIO = 'audio', + VIDEO = 'video', +} + +const getFileType = (fileType: File['type']): FilePreviewType | undefined => { + if (!fileType) { + return; + } + for (const type of Object.values(FilePreviewType)) { + if (fileType.indexOf(type) > -1) { + return type; + } + } +}; + +const shouldShowMediaPreview = (file: File, fileType: FilePreviewType | undefined): boolean => { + if (!fileType) { + return false; + } + if (isIE11) { + return false; + } + // Avoid preview if file size bigger than 10mb + if (file.size > 10000000) { + return false; + } + if (!Object.values(FilePreviewType).includes(fileType)) { + return false; + } + return true; +}; + +async function getCroppedFile(imageSrc: string, crop: Area, original: File): Promise { + const image = new Image(); + image.src = imageSrc; + await new Promise((resolve, reject) => { + image.onload = resolve; + image.onerror = reject; + }); + + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + + canvas.width = Math.round(crop.width); + canvas.height = Math.round(crop.height); + + ctx.drawImage( + image, + Math.round(crop.x), + Math.round(crop.y), + Math.round(crop.width), + Math.round(crop.height), + 0, + 0, + Math.round(crop.width), + Math.round(crop.height), + ); + + const mime = original.type && original.type.startsWith('image/') ? original.type : 'image/jpeg'; + const blob: Blob | null = await new Promise((resolve) => canvas.toBlob(resolve, mime, 0.95)); + if (!blob) return null; + + const dot = original.name.lastIndexOf('.'); + const base = dot > 0 ? original.name.slice(0, dot) : original.name; + const ext = dot > 0 ? original.name.slice(dot) : '.jpg'; + const name = `${base}-cropped${ext}`; + + return new File([blob], name, { type: blob.type, lastModified: Date.now() }); +} + +type FilePreviewProps = { + file: File; + onFileChange?: (file: File) => void; + startCropping?: boolean; + onCropDone?: () => void; +}; + +const CropFilePreview = ({ file, onFileChange, startCropping, onCropDone }: FilePreviewProps): ReactElement => { + const fileType = getFileType(file.type); + + const [crop, setCrop] = useState({ x: 0, y: 0 }); + const [zoom, setZoom] = useState(1); + const [croppedArea, setCroppedArea] = useState(null); + const [croppedImageURL, setCroppedImageURL] = useState(null); + const [isCroppingInternal, setIsCroppingInternal] = useState(false); + + const objUrl = URL.createObjectURL(file); + + useEffect(() => { + if (startCropping) setIsCroppingInternal(true); + }, [startCropping]); + + const onCropComplete = useCallback((_area: Area, croppedAreaPixels: Area) => { + setCroppedArea(croppedAreaPixels); + }, []); + + const handleCropDone = useCallback(async () => { + if (!croppedArea) return; + const croppedFile = await getCroppedFile(objUrl, croppedArea, file); + if (!croppedFile) return; + + const previewUrl = URL.createObjectURL(croppedFile); + setCroppedImageURL(previewUrl); + onFileChange?.(croppedFile); + setIsCroppingInternal(false); + onCropDone?.(); + }, [file, croppedArea, objUrl, onFileChange, onCropDone]); + + if (fileType === FilePreviewType.IMAGE && !croppedImageURL && isCroppingInternal) { + return ( +
+ + +
+ ); + } + + if (croppedImageURL) { + return Cropped preview; + } + + if (shouldShowMediaPreview(file, fileType)) { + return ; + } + + return ; +}; + +export default CropFilePreview; diff --git a/apps/meteor/client/views/room/modals/FileUploadModal/FilePreview.tsx b/apps/meteor/client/views/room/modals/FileUploadModal/FilePreview.tsx index cde85166e21bd..a7f9e8ace84fe 100644 --- a/apps/meteor/client/views/room/modals/FileUploadModal/FilePreview.tsx +++ b/apps/meteor/client/views/room/modals/FileUploadModal/FilePreview.tsx @@ -1,6 +1,4 @@ -import { useState, useCallback, useEffect, type ReactElement } from 'react'; -import type { Area } from 'react-easy-crop'; -import Cropper from 'react-easy-crop'; +import type { ReactElement } from 'react'; import GenericPreview from './GenericPreview'; import MediaPreview from './MediaPreview'; @@ -40,106 +38,13 @@ const shouldShowMediaPreview = (file: File, fileType: FilePreviewType | undefine return true; }; -async function getCroppedFile(imageSrc: string, crop: Area, original: File): Promise { - const image = new Image(); - image.src = imageSrc; - await new Promise((resolve, reject) => { - image.onload = resolve; - image.onerror = reject; - }); - - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); - if (!ctx) return null; - - canvas.width = Math.round(crop.width); - canvas.height = Math.round(crop.height); - - ctx.drawImage( - image, - Math.round(crop.x), - Math.round(crop.y), - Math.round(crop.width), - Math.round(crop.height), - 0, - 0, - Math.round(crop.width), - Math.round(crop.height), - ); - - const mime = original.type && original.type.startsWith('image/') ? original.type : 'image/jpeg'; - const blob: Blob | null = await new Promise((resolve) => canvas.toBlob(resolve, mime, 0.95)); - if (!blob) return null; - - const dot = original.name.lastIndexOf('.'); - const base = dot > 0 ? original.name.slice(0, dot) : original.name; - const ext = dot > 0 ? original.name.slice(dot) : '.jpg'; - const name = `${base}-cropped${ext}`; - - return new File([blob], name, { type: blob.type, lastModified: Date.now() }); -} - type FilePreviewProps = { file: File; - onFileChange?: (file: File) => void; - startCropping?: boolean; - onCropDone?: () => void; }; -const FilePreview = ({ file, onFileChange, startCropping, onCropDone }: FilePreviewProps): ReactElement => { +const FilePreview = ({ file }: FilePreviewProps): ReactElement => { const fileType = getFileType(file.type); - const [crop, setCrop] = useState({ x: 0, y: 0 }); - const [zoom, setZoom] = useState(1); - const [croppedArea, setCroppedArea] = useState(null); - const [croppedImageURL, setCroppedImageURL] = useState(null); - const [isCroppingInternal, setIsCroppingInternal] = useState(false); - - const objUrl = URL.createObjectURL(file); - - useEffect(() => { - if (startCropping) setIsCroppingInternal(true); - }, [startCropping]); - - const onCropComplete = useCallback((_area: Area, croppedAreaPixels: Area) => { - setCroppedArea(croppedAreaPixels); - }, []); - - const handleCropDone = useCallback(async () => { - if (!croppedArea) return; - const croppedFile = await getCroppedFile(objUrl, croppedArea, file); - if (!croppedFile) return; - - const previewUrl = URL.createObjectURL(croppedFile); - setCroppedImageURL(previewUrl); - onFileChange?.(croppedFile); - setIsCroppingInternal(false); - onCropDone?.(); - }, [file, croppedArea, objUrl, onFileChange, onCropDone]); - - if (fileType === FilePreviewType.IMAGE && !croppedImageURL && isCroppingInternal) { - return ( -
- - -
- ); - } - - if (croppedImageURL) { - return Cropped preview; - } - if (shouldShowMediaPreview(file, fileType)) { return ; } diff --git a/apps/meteor/client/views/room/modals/FileUploadModal/FileUploadModal.tsx b/apps/meteor/client/views/room/modals/FileUploadModal/FileUploadModal.tsx index 567a5c53be017..689b48b3e9c28 100644 --- a/apps/meteor/client/views/room/modals/FileUploadModal/FileUploadModal.tsx +++ b/apps/meteor/client/views/room/modals/FileUploadModal/FileUploadModal.tsx @@ -16,12 +16,14 @@ import { ModalFooterControllers, } from '@rocket.chat/fuselage'; import { useAutoFocus, useMergedRefs } from '@rocket.chat/fuselage-hooks'; +import { useFeaturePreview } from '@rocket.chat/ui-client'; import { useToastMessageDispatch, useTranslation, useSetting } from '@rocket.chat/ui-contexts'; import fileSize from 'filesize'; import type { ReactElement, ComponentProps } from 'react'; import { memo, useEffect, useId, useState } from 'react'; import { useForm } from 'react-hook-form'; +import CropFilePreview from './CropFilePreview'; import FilePreview from './FilePreview'; type FileUploadModalProps = { @@ -55,6 +57,7 @@ const FileUploadModal = ({ const dispatchToastMessage = useToastMessageDispatch(); const maxMsgSize = useSetting('Message_MaxAllowedSize', 5000); const maxFileSize = useSetting('FileUpload_MaxFileSize', 104857600); + const enablePreview = useFeaturePreview('imageCropPreview'); const isDescriptionValid = (description: string) => description.length >= maxMsgSize ? t('Cannot_upload_file_character_limit', { count: maxMsgSize }) : true; @@ -116,10 +119,19 @@ const FileUploadModal = ({ - + {enablePreview ? ( + setStartCropping(false)} + /> + ) : ( + + )} - {currentFile.type.startsWith('image/') && !startCropping && ( - )} diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index dc60f672c4edc..b44d1dd5e65ad 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -2510,6 +2510,8 @@ "Ignore": "Ignore", "Ignore_Two_Factor_Authentication": "Ignore Two Factor Authentication", "Ignored": "Ignored", + "Image_crop_preview": "Image Crop Preview", + "Image_crop_preview_description": "Enable cropping of images before sending in chat", "Image_gallery": "Image gallery", "Images": "Images", "Impersonate_next_agent_from_queue": "Impersonate next agent from queue", diff --git a/packages/ui-client/src/hooks/useFeaturePreviewList.ts b/packages/ui-client/src/hooks/useFeaturePreviewList.ts index f8fef33c53900..f9e820bf7ec5f 100644 --- a/packages/ui-client/src/hooks/useFeaturePreviewList.ts +++ b/packages/ui-client/src/hooks/useFeaturePreviewList.ts @@ -6,7 +6,8 @@ export type FeaturesAvailable = | 'contextualbarResizable' | 'newNavigation' | 'secondarySidebar' - | 'expandableMessageComposer'; + | 'expandableMessageComposer' + | 'imageCropPreview'; export type FeaturePreviewProps = { name: FeaturesAvailable; @@ -43,6 +44,14 @@ export const defaultFeaturesPreview: FeaturePreviewProps[] = [ value: false, enabled: true, }, + { + name: 'imageCropPreview', + i18n: 'Image_crop_preview', + description: 'Image_crop_preview_description', + group: 'Message', + value: false, + enabled: true, + }, { name: 'contextualbarResizable', i18n: 'Contextualbar_resizable', From 27fc11bf65ad394585d86eb4b316bdcfe263649a Mon Sep 17 00:00:00 2001 From: khizarshah01 Date: Sun, 21 Sep 2025 00:50:58 +0530 Subject: [PATCH 5/7] feat: use fuselage for UI and separate CropFilePreview component --- .changeset/young-buses-grow.md | 5 ++ .../FileUploadModal/CropFilePreview.tsx | 89 ++++++++++++------- .../FileUploadModal/FileUploadModal.tsx | 40 ++++++--- packages/i18n/src/locales/en.i18n.json | 3 + 4 files changed, 90 insertions(+), 47 deletions(-) create mode 100644 .changeset/young-buses-grow.md diff --git a/.changeset/young-buses-grow.md b/.changeset/young-buses-grow.md new file mode 100644 index 0000000000000..15105db45b585 --- /dev/null +++ b/.changeset/young-buses-grow.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +feat: add image cropping functionality for file uplods, users can now crop images before sending them in chat diff --git a/apps/meteor/client/views/room/modals/FileUploadModal/CropFilePreview.tsx b/apps/meteor/client/views/room/modals/FileUploadModal/CropFilePreview.tsx index 8adfcf7c971e8..0adf46ffecc46 100644 --- a/apps/meteor/client/views/room/modals/FileUploadModal/CropFilePreview.tsx +++ b/apps/meteor/client/views/room/modals/FileUploadModal/CropFilePreview.tsx @@ -1,3 +1,5 @@ +import { Button, ButtonGroup, Box } from '@rocket.chat/fuselage'; +import { useTranslation } from '@rocket.chat/ui-contexts'; import { useState, useCallback, useEffect, type ReactElement } from 'react'; import type { Area } from 'react-easy-crop'; import Cropper from 'react-easy-crop'; @@ -82,62 +84,83 @@ async function getCroppedFile(imageSrc: string, crop: Area, original: File): Pro type FilePreviewProps = { file: File; onFileChange?: (file: File) => void; - startCropping?: boolean; - onCropDone?: () => void; + onCancel?: () => void; }; -const CropFilePreview = ({ file, onFileChange, startCropping, onCropDone }: FilePreviewProps): ReactElement => { +const CropFilePreview = ({ file, onFileChange, onCancel }: FilePreviewProps): ReactElement => { + const t = useTranslation(); const fileType = getFileType(file.type); - const [crop, setCrop] = useState({ x: 0, y: 0 }); const [zoom, setZoom] = useState(1); const [croppedArea, setCroppedArea] = useState(null); const [croppedImageURL, setCroppedImageURL] = useState(null); - const [isCroppingInternal, setIsCroppingInternal] = useState(false); - - const objUrl = URL.createObjectURL(file); + const [objectUrl, setObjectUrl] = useState(null); useEffect(() => { - if (startCropping) setIsCroppingInternal(true); - }, [startCropping]); + const url = URL.createObjectURL(file); + setObjectUrl(url); + + return () => { + URL.revokeObjectURL(url); + if (croppedImageURL) { + URL.revokeObjectURL(croppedImageURL); + } + }; + }, [file, croppedImageURL]); const onCropComplete = useCallback((_area: Area, croppedAreaPixels: Area) => { setCroppedArea(croppedAreaPixels); }, []); const handleCropDone = useCallback(async () => { - if (!croppedArea) return; - const croppedFile = await getCroppedFile(objUrl, croppedArea, file); + if (!croppedArea || !objectUrl) return; + const croppedFile = await getCroppedFile(objectUrl, croppedArea, file); if (!croppedFile) return; const previewUrl = URL.createObjectURL(croppedFile); setCroppedImageURL(previewUrl); onFileChange?.(croppedFile); - setIsCroppingInternal(false); - onCropDone?.(); - }, [file, croppedArea, objUrl, onFileChange, onCropDone]); + }, [file, croppedArea, objectUrl, onFileChange]); - if (fileType === FilePreviewType.IMAGE && !croppedImageURL && isCroppingInternal) { - return ( -
- - -
- ); + if (croppedImageURL) { + return {t('Cropped_preview')}; } - if (croppedImageURL) { - return Cropped preview; + if (!objectUrl) { + return ; + } + + if (fileType === FilePreviewType.IMAGE && onCancel) { + return ( + + + + + + + + + + ); } if (shouldShowMediaPreview(file, fileType)) { diff --git a/apps/meteor/client/views/room/modals/FileUploadModal/FileUploadModal.tsx b/apps/meteor/client/views/room/modals/FileUploadModal/FileUploadModal.tsx index 689b48b3e9c28..a946a19e2fdd6 100644 --- a/apps/meteor/client/views/room/modals/FileUploadModal/FileUploadModal.tsx +++ b/apps/meteor/client/views/room/modals/FileUploadModal/FileUploadModal.tsx @@ -46,7 +46,7 @@ const FileUploadModal = ({ showDescription = true, }: FileUploadModalProps): ReactElement => { const [currentFile, setCurrentFile] = useState(file); - const [startCropping, setStartCropping] = useState(false); + const [isCropping, setIsCropping] = useState(false); const { register, handleSubmit, @@ -105,6 +105,27 @@ const FileUploadModal = ({ const descriptionRef = useMergedRefs(ref, autoFocusRef); + if (isCropping) { + return ( + + + {t('Crop_Image')} + setIsCropping(false)} /> + + + { + setCurrentFile(newFile); + setIsCropping(false); + }} + onCancel={() => setIsCropping(false)} + /> + + + ); + } + return ( - {enablePreview ? ( - setStartCropping(false)} - /> - ) : ( - - )} + - {enablePreview && currentFile.type.startsWith('image/') && !startCropping && ( - )} diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index b44d1dd5e65ad..582ceeebb81ae 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -1496,6 +1496,9 @@ "Created_at_s_by_s": "Created at %s by %s", "Created_at_s_by_s_triggered_by_s": "Created at %s by %s triggered by %s", "Created_by": "Created by", + "Crop": "Crop", + "Crop_Image": "Crop Image", + "Cropped_preview": "Cropped preview", "Crowd_Connection_successful": "Crowd Connection Successful", "Crowd_Remove_Orphaned_Users": "Remove Orphaned Users", "Crowd_sync_interval_Description": "The interval between synchronizations. Example `every 24 hours` or `on the first day of the week`, more examples at [Cron Text Parser](http://bunkat.github.io/later/parsers.html#text)", From db1dbd435f6fcfc68a54f8b089172fe4870324aa Mon Sep 17 00:00:00 2001 From: khizarshah01 Date: Sun, 2 Nov 2025 14:56:36 +0530 Subject: [PATCH 6/7] chore: fix typos in changeset and i18n --- .changeset/young-buses-grow.md | 2 +- packages/i18n/src/locales/en.i18n.json | 4 ++-- packages/ui-client/src/hooks/useFeaturePreviewList.ts | 5 +++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.changeset/young-buses-grow.md b/.changeset/young-buses-grow.md index 15105db45b585..916269412e646 100644 --- a/.changeset/young-buses-grow.md +++ b/.changeset/young-buses-grow.md @@ -2,4 +2,4 @@ '@rocket.chat/meteor': minor --- -feat: add image cropping functionality for file uplods, users can now crop images before sending them in chat +feat: add image cropping functionality for file uploads; users can now crop images before sending them in chat diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 582ceeebb81ae..29f44fa73c119 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -2513,8 +2513,8 @@ "Ignore": "Ignore", "Ignore_Two_Factor_Authentication": "Ignore Two Factor Authentication", "Ignored": "Ignored", - "Image_crop_preview": "Image Crop Preview", - "Image_crop_preview_description": "Enable cropping of images before sending in chat", + "Image_crop": "Image Cropping", + "Image_crop_description": "Crop images before sending them in chat to remove unwanted parts and send only what matters.", "Image_gallery": "Image gallery", "Images": "Images", "Impersonate_next_agent_from_queue": "Impersonate next agent from queue", diff --git a/packages/ui-client/src/hooks/useFeaturePreviewList.ts b/packages/ui-client/src/hooks/useFeaturePreviewList.ts index f9e820bf7ec5f..50f762ac7e8cb 100644 --- a/packages/ui-client/src/hooks/useFeaturePreviewList.ts +++ b/packages/ui-client/src/hooks/useFeaturePreviewList.ts @@ -46,9 +46,10 @@ export const defaultFeaturesPreview: FeaturePreviewProps[] = [ }, { name: 'imageCropPreview', - i18n: 'Image_crop_preview', - description: 'Image_crop_preview_description', + i18n: 'Image_crop', + description: 'Image_crop_description', group: 'Message', + imageUrl: 'images/featurePreview/image-crop.png', value: false, enabled: true, }, From 944f734ff53a201e15876a176058057027510335 Mon Sep 17 00:00:00 2001 From: khizarshah01 Date: Sun, 2 Nov 2025 20:04:11 +0530 Subject: [PATCH 7/7] feat: add feature preview image for image cropping --- .../public/images/featurePreview/image-crop.png | Bin 0 -> 21866 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 apps/meteor/public/images/featurePreview/image-crop.png diff --git a/apps/meteor/public/images/featurePreview/image-crop.png b/apps/meteor/public/images/featurePreview/image-crop.png new file mode 100644 index 0000000000000000000000000000000000000000..e48f52cb75882a898492dbb52edca88f3fd8193a GIT binary patch literal 21866 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 Wh(diy;WrTg0000