From cc635f786f05ebb10c3af604a9fa4fec5dc16f1f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Nov 2025 13:14:37 +0000 Subject: [PATCH] feat: add automatic image compression before upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added client-side image compression to improve upload speed and reduce storage. New Features: - Created imageCompression utility (src/utils/imageCompression.ts) * Automatically compresses images larger than 1MB * Resizes to max 1920x1080px while preserving aspect ratio * Uses 80% JPEG quality by default * Preserves PNG transparency when present * Falls back to 60% quality if still over 2MB Mobile (NoteDetailPage.tsx): - Integrated compression before upload - Shows compression stats to user (e.g., "5.2 MB → 1.1 MB") - Better error messages with specific details Desktop (Index.tsx): - Same compression integration as mobile - Consistent user experience across platforms Benefits: ✅ Users can upload large photos from cameras/phones ✅ Faster uploads (smaller files) ✅ Reduced server storage requirements ✅ Better mobile experience (photos often 5-10MB) ✅ Transparent to user (happens automatically) Technical Details: - Uses HTML5 Canvas API for processing - Client-side compression (no server load) - Maintains image quality while reducing size - Handles JPEG, PNG, WebP, and GIF formats - Smart transparency detection for PNGs --- src/pages/Index.tsx | 33 ++++-- src/pages/mobile/NoteDetailPage.tsx | 33 ++++-- src/utils/imageCompression.ts | 154 ++++++++++++++++++++++++++++ 3 files changed, 198 insertions(+), 22 deletions(-) create mode 100644 src/utils/imageCompression.ts diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index ef8238a..b553b3b 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -31,6 +31,7 @@ import { showError, showSuccess } from "@/utils/toast"; import { ConfirmModal } from "@/components/modals/ConfirmModal"; import { InputModal } from "@/components/modals/InputModal"; import { AddUserModal } from "@/components/modals/AddUserModal"; +import { compressImage, formatFileSize } from "@/utils/imageCompression"; // ===== FONCTIONS UTILITAIRES TIMEZONE EUROPE/PARIS ===== @@ -1339,18 +1340,28 @@ const Index = () => { className="hidden" onChange={async (e) => { if (e.target.files && e.target.files[0] && openNote?.id) { - const file = e.target.files[0]; - - // Vérifier la taille du fichier (5MB max) - const maxSize = 5 * 1024 * 1024; // 5MB - if (file.size > maxSize) { - showError("L'image est trop grande (max 5MB)"); - e.target.value = ''; - return; - } + const originalFile = e.target.files[0]; + const originalSize = formatFileSize(originalFile.size); try { - const image = await NotesService.uploadImage(openNote.id, file); + // Compresser l'image avant l'upload + const compressedFile = await compressImage(originalFile); + const compressedSize = formatFileSize(compressedFile.size); + + // Afficher un message si l'image a été compressée + if (compressedFile.size < originalFile.size) { + showSuccess(`Image compressée: ${originalSize} → ${compressedSize}`); + } + + // Vérifier la taille finale (5MB max après compression) + const maxSize = 5 * 1024 * 1024; // 5MB + if (compressedFile.size > maxSize) { + showError("L'image est trop grande même après compression (max 5MB)"); + e.target.value = ''; + return; + } + + const image = await NotesService.uploadImage(openNote.id, compressedFile); if (image) { const updatedImages = [...(openNote.images || []), image]; setOpenNote({ ...openNote, images: updatedImages }); @@ -1360,7 +1371,7 @@ const Index = () => { showError("Erreur lors de l'upload de l'image"); } } catch (error) { - showError("Erreur lors de l'upload de l'image"); + showError(error instanceof Error ? error.message : "Erreur lors de l'upload de l'image"); } e.target.value = ''; } diff --git a/src/pages/mobile/NoteDetailPage.tsx b/src/pages/mobile/NoteDetailPage.tsx index 0e71128..2a51d4d 100644 --- a/src/pages/mobile/NoteDetailPage.tsx +++ b/src/pages/mobile/NoteDetailPage.tsx @@ -27,6 +27,7 @@ import { } from "@/components/ui/dropdown-menu"; import NotesService, { Note } from "@/services/NotesService"; import { showError, showSuccess } from "@/utils/toast"; +import { compressImage, formatFileSize } from "@/utils/imageCompression"; export default function NoteDetailPage() { const { id } = useParams(); @@ -119,18 +120,28 @@ export default function NoteDetailPage() { const handleImageUpload = async (e: React.ChangeEvent) => { if (!e.target.files || !e.target.files[0] || !note?.id) return; - const file = e.target.files[0]; - - // Vérifier la taille du fichier (5MB max) - const maxSize = 5 * 1024 * 1024; // 5MB - if (file.size > maxSize) { - showError("L'image est trop grande (max 5MB)"); - e.target.value = ''; - return; - } + const originalFile = e.target.files[0]; + const originalSize = formatFileSize(originalFile.size); try { - const image = await NotesService.uploadImage(note.id, file); + // Compresser l'image avant l'upload + const compressedFile = await compressImage(originalFile); + const compressedSize = formatFileSize(compressedFile.size); + + // Afficher un message si l'image a été compressée + if (compressedFile.size < originalFile.size) { + showSuccess(`Image compressée: ${originalSize} → ${compressedSize}`); + } + + // Vérifier la taille finale (5MB max après compression) + const maxSize = 5 * 1024 * 1024; // 5MB + if (compressedFile.size > maxSize) { + showError("L'image est trop grande même après compression (max 5MB)"); + e.target.value = ''; + return; + } + + const image = await NotesService.uploadImage(note.id, compressedFile); if (image) { const updatedImages = [...(note.images || []), image]; setNote({ ...note, images: updatedImages }); @@ -139,7 +150,7 @@ export default function NoteDetailPage() { showError("Erreur lors de l'upload de l'image"); } } catch (error) { - showError("Erreur lors de l'upload"); + showError(error instanceof Error ? error.message : "Erreur lors de l'upload"); } e.target.value = ''; }; diff --git a/src/utils/imageCompression.ts b/src/utils/imageCompression.ts new file mode 100644 index 0000000..76e81fe --- /dev/null +++ b/src/utils/imageCompression.ts @@ -0,0 +1,154 @@ +/** + * Compresse une image en la redimensionnant et en réduisant sa qualité + * @param file - Le fichier image à compresser + * @param maxWidth - Largeur maximale (défaut: 1920px) + * @param maxHeight - Hauteur maximale (défaut: 1080px) + * @param quality - Qualité de compression JPEG (0-1, défaut: 0.8) + * @returns Promise - Le fichier image compressé + */ +export async function compressImage( + file: File, + maxWidth: number = 1920, + maxHeight: number = 1080, + quality: number = 0.8 +): Promise { + return new Promise((resolve, reject) => { + // Vérifier que c'est bien une image + if (!file.type.startsWith('image/')) { + reject(new Error('Le fichier n\'est pas une image')); + return; + } + + // Si l'image est déjà petite (< 1MB), on la retourne telle quelle + const oneMB = 1024 * 1024; + if (file.size < oneMB) { + resolve(file); + return; + } + + const reader = new FileReader(); + + reader.onerror = () => reject(new Error('Erreur lors de la lecture du fichier')); + + reader.onload = (e) => { + const img = new Image(); + + img.onerror = () => reject(new Error('Erreur lors du chargement de l\'image')); + + img.onload = () => { + try { + // Calculer les nouvelles dimensions en gardant le ratio + let width = img.width; + let height = img.height; + + if (width > maxWidth) { + height = (height * maxWidth) / width; + width = maxWidth; + } + + if (height > maxHeight) { + width = (width * maxHeight) / height; + height = maxHeight; + } + + // Créer un canvas pour redimensionner l'image + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + + const ctx = canvas.getContext('2d'); + if (!ctx) { + reject(new Error('Impossible de créer le contexte canvas')); + return; + } + + // Dessiner l'image redimensionnée + ctx.drawImage(img, 0, 0, width, height); + + // Déterminer le type de sortie et la qualité + let outputType = 'image/jpeg'; + let outputQuality = quality; + + // Si c'est un PNG avec transparence, on garde le format PNG + if (file.type === 'image/png') { + // Vérifier si l'image a de la transparence + const imageData = ctx.getImageData(0, 0, width, height); + const hasTransparency = imageData.data.some((_, i) => i % 4 === 3 && imageData.data[i] < 255); + + if (hasTransparency) { + outputType = 'image/png'; + outputQuality = 0.9; // PNG utilise une compression sans perte + } + } + + // Convertir le canvas en Blob + canvas.toBlob( + (blob) => { + if (!blob) { + reject(new Error('Erreur lors de la conversion de l\'image')); + return; + } + + // Si l'image compressée fait toujours plus de 2MB, on réduit encore la qualité + const twoMB = 2 * 1024 * 1024; + if (blob.size > twoMB && outputType === 'image/jpeg' && outputQuality > 0.5) { + // Réessayer avec une qualité plus faible + canvas.toBlob( + (blob2) => { + if (!blob2) { + reject(new Error('Erreur lors de la deuxième compression')); + return; + } + + // Créer un nouveau fichier avec le blob compressé + const compressedFile = new File( + [blob2], + file.name.replace(/\.[^/.]+$/, '.jpg'), // Remplacer l'extension par .jpg + { type: 'image/jpeg' } + ); + + resolve(compressedFile); + }, + 'image/jpeg', + 0.6 // Qualité réduite + ); + return; + } + + // Créer un nouveau fichier avec le blob compressé + const extension = outputType === 'image/png' ? '.png' : '.jpg'; + const compressedFile = new File( + [blob], + file.name.replace(/\.[^/.]+$/, extension), + { type: outputType } + ); + + resolve(compressedFile); + }, + outputType, + outputQuality + ); + } catch (error) { + reject(error); + } + }; + + img.src = e.target?.result as string; + }; + + reader.readAsDataURL(file); + }); +} + +/** + * Formate la taille d'un fichier en MB ou KB + */ +export function formatFileSize(bytes: number): string { + if (bytes === 0) return '0 B'; + + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]; +}