import React, { useState, useRef, useCallback, useEffect } from 'react'; import { useIntersectionObserver } from '@/hooks/useIntersectionObserver'; /** * OptimizedImageProps - Propriétés du composant OptimizedImage * * @interface OptimizedImageProps */ interface OptimizedImageProps { /** * URL de l'image à afficher */ src: string; /** * Texte alternatif de l'image (requis pour l'accessibilité) */ alt: string; /** * Largeur de l'image en pixels */ width?: number; /** * Hauteur de l'image en pixels */ height?: number; /** * Classes CSS personnalisées */ className?: string; /** * Placeholder personnalisé à afficher pendant le chargement */ placeholder?: string; /** * URL d'une image floutée pour l'effet blur placeholder */ blurDataURL?: string; /** * Si `true`, charge l'image immédiatement (pas de lazy loading) * * @default false */ priority?: boolean; /** * Qualité de l'image (0-100) * * @default 75 */ quality?: number; /** * Attribut sizes pour les images responsives * * @default '100vw' */ sizes?: string; /** * Fonction appelée lorsque l'image est chargée */ onLoad?: () => void; /** * Fonction appelée en cas d'erreur de chargement */ onError?: () => void; /** * Composant de fallback à afficher en cas d'erreur */ fallback?: React.ReactNode; } /** * OptimizedImage - Composant d'image optimisée avec lazy loading et formats multiples * * Composant d'image optimisé avec support pour : * - Lazy loading avec Intersection Observer * - Formats multiples (WebP, AVIF, JPEG, PNG, GIF) * - Placeholder avec blur * - Gestion des erreurs avec fallback * - Chargement prioritaire optionnel * * @example * ```tsx * // Image optimisée simple * * ``` * * @example * ```tsx * // Avec placeholder blur et priorité * * ``` * * @component * @param {OptimizedImageProps} props - Propriétés du composant * @returns {JSX.Element} Élément picture avec sources multiples et image optimisée */ // Configuration des formats supportés const SUPPORTED_FORMATS = ['webp', 'avif', 'jpeg', 'png', 'gif']; const FALLBACK_FORMAT = 'jpeg'; // Générer les sources pour différents formats function generateImageSources(src: string, sizes?: string) { const baseUrl = src.replace(/\.[^/.]+$/, ''); return SUPPORTED_FORMATS.map((format) => { const formatSrc = `${baseUrl}.${format}`; return { src: formatSrc, type: `image/${format}`, sizes: sizes || '100vw', }; }); } // Composant de placeholder avec blur function BlurPlaceholder({ blurDataURL, width, height, className, }: { blurDataURL?: string; width?: number; height?: number; className?: string; }) { if (!blurDataURL) { return (
); } return ( ); } // Hook pour détecter le support des formats d'image function useImageFormatSupport() { const [supportedFormats, setSupportedFormats] = useState([]); useEffect(() => { const checkFormatSupport = async () => { const formats: string[] = []; // Test WebP const webpSupported = await new Promise((resolve) => { const webp = new Image(); webp.onload = webp.onerror = () => resolve(webp.height === 2); webp.src = 'data:image/webp;base64,UklGRjoAAABXRUJQVlA4IC4AAACyAgCdASoCAAIALmk0mk0iIiIiIgBoSygABc6WWgAA/veff/0PP8bA//LwYAAA'; }); // Test AVIF const avifSupported = await new Promise((resolve) => { const avif = new Image(); avif.onload = avif.onerror = () => resolve(avif.height === 2); avif.src = 'data:image/avif;base64,AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUIAAADybWV0YQAAAAAAAAAoaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAGxpYmF2aWYAAAAADnBpdG0AAAAAAAEAAAAeaWxvYwAAAABEAAABAAEAAAABAAABGgAAABcAAAAoaWluZgAAAAAAAQAAABppbmZlAgAAAAABAABhdjAxQ29sb3IAAAAAamlwcnAAAABLaXBjbwAAABRpc3BlAAAAAAAAAAEAAAABAAAAEHBpeGkAAAAAAwgICAAAAAxhdjFDgQAMAAAAABNjb2xybmNseAACAAIABoAAAAAXaXBtYQAAAAAAAAABAAEEAQKDBAAAAB9tZGF0EgAKCBgABogQEAwgMgkAAAAAAAAG8AAAAA=='; }); if (webpSupported) formats.push('webp'); if (avifSupported) formats.push('avif'); formats.push('jpeg', 'png', 'gif'); // Formats de base toujours supportés setSupportedFormats(formats); }; checkFormatSupport(); }, []); return supportedFormats; } export function OptimizedImage({ src, alt, width, height, className = '', placeholder, blurDataURL, priority = false, quality: _quality = 75, sizes = '100vw', onLoad, onError, fallback, }: OptimizedImageProps) { const [isLoaded, setIsLoaded] = useState(false); const [hasError, setHasError] = useState(false); const [currentSrc, setCurrentSrc] = useState(null); const imgRef = useRef(null); const supportedFormats = useImageFormatSupport(); // Intersection Observer pour le lazy loading const intersectionRef = useRef(null); const entry = useIntersectionObserver(intersectionRef, { threshold: 0.1, rootMargin: '50px', }); const isIntersecting = !!entry?.isIntersecting; // Générer les sources optimisées const imageSources = React.useMemo(() => { return generateImageSources(src, sizes); }, [src, sizes]); // Sélectionner la meilleure source supportée const selectBestSource = useCallback(() => { const bestFormat = supportedFormats.find((format) => SUPPORTED_FORMATS.includes(format)) || FALLBACK_FORMAT; const bestSource = imageSources.find( (source) => source.type === `image/${bestFormat}`, ); return bestSource?.src || src; }, [supportedFormats, imageSources, src]); // Charger l'image const loadImage = useCallback(() => { if (isLoaded || hasError) return; const imageSrc = selectBestSource(); setCurrentSrc(imageSrc); const img = new Image(); img.onload = () => { setIsLoaded(true); onLoad?.(); }; img.onerror = () => { setHasError(true); onError?.(); }; img.src = imageSrc; }, [isLoaded, hasError, selectBestSource, onLoad, onError]); // Charger l'image quand elle devient visible ou si priorité useEffect(() => { if (priority || isIntersecting) { loadImage(); } }, [priority, isIntersecting, loadImage]); // Gestion des erreurs de chargement const handleImageError = useCallback(() => { setHasError(true); onError?.(); }, [onError]); // Gestion du chargement réussi const handleImageLoad = useCallback(() => { setIsLoaded(true); onLoad?.(); }, [onLoad]); // Rendu du fallback en cas d'erreur if (hasError) { return ( fallback || (
Image non disponible
) ); } // Rendu du placeholder pendant le chargement if (!isLoaded && !priority) { return (
{placeholder && (
{placeholder}
)}
); } // Rendu de l'image optimisée return ( {/* Sources pour différents formats */} {imageSources.map((source, index) => ( ))} {/* Image principale */} {alt} ); } // Hook pour preloader des images export function useImagePreloader() { const preloadImage = useCallback((src: string) => { const img = new Image(); img.src = src; return new Promise((resolve, reject) => { img.onload = () => resolve(img); img.onerror = reject; }); }, []); const preloadImages = useCallback( async (srcs: string[]) => { const promises = srcs.map((src) => preloadImage(src)); return Promise.allSettled(promises); }, [preloadImage], ); return { preloadImage, preloadImages }; } // Composant pour les images responsives avec srcset export function ResponsiveImage({ src, alt, className = '', sizes = '(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw', ...props }: OptimizedImageProps & { sizes?: string }) { return ( {}} /> ); }