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 */}
);
}
// 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 (
{}}
/>
);
}