diff --git a/cypress/e2e/desktop/imageCompression.cy.ts b/cypress/e2e/desktop/imageCompression.cy.ts new file mode 100644 index 00000000..3b738b28 --- /dev/null +++ b/cypress/e2e/desktop/imageCompression.cy.ts @@ -0,0 +1,138 @@ +import { + autocompleteInput, + checkTag, + expectFormWasSubmitted, + input, + interceptResourceSubmitRequest, + nextPageOrSubmit, + openContributeMenu, + selectAddress, + selectEntryType, + selectResource +} from 'utils/crowdsourcing.ts'; +import { GUIDELINES_TEXTAREA } from 'utils/selectors.ts'; + +const FIVE_MB = 5 * 1024 * 1024; +const UPLOADED_IMAGE_KEY = 'compressed-image.jpeg'; + +type TestWindow = Window & typeof globalThis; + +const createNoisyPng = (win: TestWindow): Promise => { + const canvas = win.document.createElement('canvas'); + canvas.width = 3000; + canvas.height = 2000; + + const context = canvas.getContext('2d'); + if (!context) { + throw new Error('Could not create canvas context'); + } + + const imageData = context.createImageData(canvas.width, canvas.height); + const data = imageData.data; + const chunk = new Uint8Array(65536); + for (let offset = 0; offset < data.length; offset += chunk.length) { + win.crypto.getRandomValues(chunk); + data.set( + chunk.subarray(0, Math.min(chunk.length, data.length - offset)), + offset + ); + } + for (let alpha = 3; alpha < data.length; alpha += 4) { + data[alpha] = 255; + } + context.putImageData(imageData, 0, 0); + + return new Promise((resolve, reject) => { + canvas.toBlob(blob => { + if (!blob) { + reject(new Error('Could not create test image')); + return; + } + resolve(new win.File([blob], 'large-image.png', { type: 'image/png' })); + }, 'image/png'); + }); +}; + +const dropFile = (win: TestWindow, file: File) => { + const dataTransfer = new win.DataTransfer(); + dataTransfer.items.add(file); + + const fileInput = + win.document.querySelector('input[type="file"]'); + if (!fileInput) { + throw new Error('Could not find the image upload input'); + } + + fileInput.files = dataTransfer.files; + fileInput.dispatchEvent(new win.Event('change', { bubbles: true })); +}; + +describe('image compression', () => { + beforeEach(() => { + cy.visit('/'); + openContributeMenu(); + interceptResourceSubmitRequest(); + + cy.intercept( + { method: 'GET', hostname: 'phlask.me', pathname: '/submit-image' }, + { + statusCode: 200, + body: { + putURL: 'https://s3.test/compressed-image', + getURL: UPLOADED_IMAGE_KEY, + errorMessage: null + } + } + ).as('submitImageRequest'); + + cy.intercept( + { method: 'PUT', url: 'https://s3.test/compressed-image' }, + { statusCode: 200, body: '' } + ).as('uploadImageRequest'); + }); + + it('should compress an image over 5 MB before uploading it', () => { + selectResource('WATER'); + + input('name', 'Cypress Test Name'); + selectAddress(); + + input('description', 'Cypress Test Description'); + selectEntryType(); + + autocompleteInput('water.dispenser_type', 'Sink'); + + nextPageOrSubmit(); + + cy.window().then(async win => { + const file = await createNoisyPng(win); + expect(file.size, 'test image size').to.be.greaterThan(FIVE_MB); + dropFile(win, file); + }); + + cy.wait('@submitImageRequest', { timeout: 30000 }) + .its('request.url') + .should('include', 'type=image/jpeg'); + + cy.wait('@uploadImageRequest', { timeout: 30000 }).then(({ request }) => { + expect(request.headers['content-type']).to.equal('image/jpeg'); + expect( + Number(request.headers['content-length']), + 'uploaded image size' + ).to.be.lessThan(FIVE_MB); + }); + + checkTag(); + cy.get(GUIDELINES_TEXTAREA).type('Cypress Test'); + + nextPageOrSubmit(); + + cy.wait('@resourceSubmitRequest') + .its('request.body') + .then(body => { + expect(body.images).to.deep.equal([UPLOADED_IMAGE_KEY]); + }); + + expectFormWasSubmitted(); + }); +}); diff --git a/package.json b/package.json index dd7f5be4..70d1c0a4 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "@supabase/supabase-js": "^2.116.0", "@tanstack/react-query": "^5.102.8", "@vis.gl/react-google-maps": "^1.10.0", + "compressorjs": "^1.4.0", "date-fns": "^4.4.0", "posthog-js": "^1.428.7", "react": "^19.2.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0bcaff4f..9b8ab5e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@vis.gl/react-google-maps': specifier: ^1.10.0 version: 1.10.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + compressorjs: + specifier: ^1.4.0 + version: 1.4.0 date-fns: specifier: ^4.4.0 version: 4.4.0 @@ -1941,6 +1944,9 @@ packages: bluebird@3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + blueimp-canvas-to-blob@3.29.0: + resolution: {integrity: sha512-0pcSSGxC0QxT+yVkivxIqW0Y4VlO2XSDPofBAqoJ1qJxgH9eiUDLv50Rixij2cDuEfx4M6DpD9UGZpRhT5Q8qg==} + brace-expansion@1.1.18: resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} @@ -2073,6 +2079,9 @@ packages: resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} engines: {node: '>=4.0.0'} + compressorjs@1.4.0: + resolution: {integrity: sha512-cfBWoYc4EkIgsbphT7R0SwSHHY+zGSMBaBK0e0T+iPJejKpsIyco3OoQUdH6ghTVrH2bysXjhlO5zTDPJWzSmQ==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -2721,6 +2730,10 @@ packages: resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} engines: {node: '>= 0.4'} + is-blob@2.1.0: + resolution: {integrity: sha512-SZ/fTft5eUhQM6oF/ZaASFDEdbFVe89Imltn9uZr03wdKMcWNVYSMjQPFtg05QuNkt5l5c135ElvXEQG0rk4tw==} + engines: {node: '>=6'} + is-boolean-object@1.2.2: resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} engines: {node: '>= 0.4'} @@ -5451,6 +5464,8 @@ snapshots: bluebird@3.7.2: {} + blueimp-canvas-to-blob@3.29.0: {} + brace-expansion@1.1.18: dependencies: balanced-match: 1.0.2 @@ -5581,6 +5596,11 @@ snapshots: common-tags@1.8.2: {} + compressorjs@1.4.0: + dependencies: + blueimp-canvas-to-blob: 3.29.0 + is-blob: 2.1.0 + concat-map@0.0.1: {} concat-stream@1.6.2: @@ -6415,6 +6435,8 @@ snapshots: dependencies: has-bigints: 1.1.0 + is-blob@2.1.0: {} + is-boolean-object@1.2.2: dependencies: call-bound: 1.0.4 diff --git a/src/components/ImageUploader/ImageUploader.tsx b/src/components/ImageUploader/ImageUploader.tsx index b3789393..6567b1cb 100644 --- a/src/components/ImageUploader/ImageUploader.tsx +++ b/src/components/ImageUploader/ImageUploader.tsx @@ -1,7 +1,6 @@ import { Box, Stack, Typography } from '@mui/material'; import type { ReactNode } from 'react'; import { useDropzone, type DropzoneOptions } from 'react-dropzone'; -import fileSizePretty from 'utils/formatFileSize'; import { Upload } from 'icons'; export type RenderContentConfig = { @@ -10,7 +9,7 @@ export type RenderContentConfig = { type ImageUploaderProps = Pick< DropzoneOptions, - 'accept' | 'maxSize' | 'maxFiles' | 'onDrop' + 'accept' | 'maxFiles' | 'onDrop' > & { renderContent?: (config: RenderContentConfig) => ReactNode; }; @@ -18,7 +17,6 @@ type ImageUploaderProps = Pick< const ImageUploader = ({ onDrop, accept, - maxSize, maxFiles, renderContent = () => null }: ImageUploaderProps) => { @@ -26,7 +24,6 @@ const ImageUploader = ({ useDropzone({ onDrop, accept, - maxSize, maxFiles }); return ( @@ -34,9 +31,6 @@ const ImageUploader = ({ - - {maxSize ? `Max file size: ${fileSizePretty(maxSize)}` : null} - {accept ? `accepted: ${Object.values(accept) diff --git a/src/components/forms/FormImageUploadField/FormImageUploadField.tsx b/src/components/forms/FormImageUploadField/FormImageUploadField.tsx index a03a2e9f..4194d8af 100644 --- a/src/components/forms/FormImageUploadField/FormImageUploadField.tsx +++ b/src/components/forms/FormImageUploadField/FormImageUploadField.tsx @@ -10,11 +10,11 @@ import { type FieldValues, type Path } from 'react-hook-form'; +import compressImage from 'utils/compressImage'; type FormImageUploadFieldProps = { name: Path; accept?: Accept; - maxSize?: number; maxFiles?: number; renderContent?: (config: RenderContentConfig) => ReactNode; }; @@ -28,7 +28,6 @@ const defaultAccept = { const FormImageUploadField = ({ name, accept = defaultAccept, - maxSize = 5242880, maxFiles = 1, renderContent = () => null }: FormImageUploadFieldProps) => { @@ -36,13 +35,19 @@ const FormImageUploadField = ({ const { mutate: uploadImage } = useUploadImageMutation(); const { field } = useController({ control, name }); - const onDrop = (acceptedFiles: File[]) => { + const onDrop = async (acceptedFiles: File[]) => { const file = acceptedFiles.at(0); if (!file) { return; } - uploadImage(file, { - onSuccess: data => field.onChange('images', [data]) + + const image = await compressImage(file).catch(error => { + console.error(error instanceof Error ? error.message : error); + return file; + }); + + uploadImage(image, { + onSuccess: data => field.onChange([data]) }); }; @@ -50,7 +55,6 @@ const FormImageUploadField = ({ diff --git a/src/utils/compressImage.ts b/src/utils/compressImage.ts new file mode 100644 index 00000000..810cb673 --- /dev/null +++ b/src/utils/compressImage.ts @@ -0,0 +1,40 @@ +import Compressor from 'compressorjs'; + +const MAX_IMAGE_BYTES = 5 * 1024 * 1024; +const MAX_IMAGE_DIMENSION = 2048; + +const renameToJpeg = (blob: Blob, original: File) => { + const baseName = original.name.replace(/\.[^.]+$/, '') || 'image'; + return new File([blob], `${baseName}.jpeg`, { + type: blob.type, + lastModified: original.lastModified + }); +}; + +const compressToBlob = (file: File) => + new Promise((resolve, reject) => { + new Compressor(file, { + maxWidth: MAX_IMAGE_DIMENSION, + maxHeight: MAX_IMAGE_DIMENSION, + mimeType: 'image/jpeg', + convertSize: MAX_IMAGE_BYTES, + quality: 0.8, + success: resolve, + error: reject + }); + }); + +const compressImage = async (file: File): Promise => { + const isCompressibleImage = + file.type.startsWith('image/') && file.type !== 'image/gif'; + + if (!isCompressibleImage || file.size <= MAX_IMAGE_BYTES) { + return file; + } + + const compressed = await compressToBlob(file); + + return renameToJpeg(compressed, file); +}; + +export default compressImage; diff --git a/src/utils/formatFileSize.ts b/src/utils/formatFileSize.ts deleted file mode 100644 index 70958524..00000000 --- a/src/utils/formatFileSize.ts +++ /dev/null @@ -1,23 +0,0 @@ -type FormatFileSizeOptions = { - standard?: 'binary' | 'si'; -}; - -const formatFileSize = ( - bytes: number, - decimals = 1, - options: FormatFileSizeOptions = {} -) => { - if (bytes === 0) return '0 B'; - - const { standard = 'binary' } = options; - const k = standard === 'si' ? 1000 : 1024; - const sizes = - standard === 'si' - ? ['B', 'kB', 'MB', 'GB', 'TB', 'PB'] - : ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - const size = parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)); - return `${size} ${sizes[i]}`; -}; - -export default formatFileSize;