mirror of
https://github.com/R0m1k3/noteflow.git
synced 2026-10-22 14:48:18 +02:00
feat: add automatic image compression before upload
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
This commit is contained in:
3 files changed
+198
-22
No files matched your search
+22
-11
@@ -31,6 +31,7 @@ import { showError, showSuccess } from "@/utils/toast";
|
|||||||
import { ConfirmModal } from "@/components/modals/ConfirmModal";
|
import { ConfirmModal } from "@/components/modals/ConfirmModal";
|
||||||
import { InputModal } from "@/components/modals/InputModal";
|
import { InputModal } from "@/components/modals/InputModal";
|
||||||
import { AddUserModal } from "@/components/modals/AddUserModal";
|
import { AddUserModal } from "@/components/modals/AddUserModal";
|
||||||
|
import { compressImage, formatFileSize } from "@/utils/imageCompression";
|
||||||
|
|
||||||
// ===== FONCTIONS UTILITAIRES TIMEZONE EUROPE/PARIS =====
|
// ===== FONCTIONS UTILITAIRES TIMEZONE EUROPE/PARIS =====
|
||||||
|
|
||||||
@@ -1339,18 +1340,28 @@ const Index = () => {
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={async (e) => {
|
onChange={async (e) => {
|
||||||
if (e.target.files && e.target.files[0] && openNote?.id) {
|
if (e.target.files && e.target.files[0] && openNote?.id) {
|
||||||
const file = e.target.files[0];
|
const originalFile = e.target.files[0];
|
||||||
|
const originalSize = formatFileSize(originalFile.size);
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
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) {
|
if (image) {
|
||||||
const updatedImages = [...(openNote.images || []), image];
|
const updatedImages = [...(openNote.images || []), image];
|
||||||
setOpenNote({ ...openNote, images: updatedImages });
|
setOpenNote({ ...openNote, images: updatedImages });
|
||||||
@@ -1360,7 +1371,7 @@ const Index = () => {
|
|||||||
showError("Erreur lors de l'upload de l'image");
|
showError("Erreur lors de l'upload de l'image");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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 = '';
|
e.target.value = '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import NotesService, { Note } from "@/services/NotesService";
|
import NotesService, { Note } from "@/services/NotesService";
|
||||||
import { showError, showSuccess } from "@/utils/toast";
|
import { showError, showSuccess } from "@/utils/toast";
|
||||||
|
import { compressImage, formatFileSize } from "@/utils/imageCompression";
|
||||||
|
|
||||||
export default function NoteDetailPage() {
|
export default function NoteDetailPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
@@ -119,18 +120,28 @@ export default function NoteDetailPage() {
|
|||||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (!e.target.files || !e.target.files[0] || !note?.id) return;
|
if (!e.target.files || !e.target.files[0] || !note?.id) return;
|
||||||
|
|
||||||
const file = e.target.files[0];
|
const originalFile = e.target.files[0];
|
||||||
|
const originalSize = formatFileSize(originalFile.size);
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
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) {
|
if (image) {
|
||||||
const updatedImages = [...(note.images || []), image];
|
const updatedImages = [...(note.images || []), image];
|
||||||
setNote({ ...note, images: updatedImages });
|
setNote({ ...note, images: updatedImages });
|
||||||
@@ -139,7 +150,7 @@ export default function NoteDetailPage() {
|
|||||||
showError("Erreur lors de l'upload de l'image");
|
showError("Erreur lors de l'upload de l'image");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError("Erreur lors de l'upload");
|
showError(error instanceof Error ? error.message : "Erreur lors de l'upload");
|
||||||
}
|
}
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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<File> - Le fichier image compressé
|
||||||
|
*/
|
||||||
|
export async function compressImage(
|
||||||
|
file: File,
|
||||||
|
maxWidth: number = 1920,
|
||||||
|
maxHeight: number = 1080,
|
||||||
|
quality: number = 0.8
|
||||||
|
): Promise<File> {
|
||||||
|
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];
|
||||||
|
}
|
||||||
Reference in new issue
Block a user