409 lines
9.7 KiB
TypeScript
409 lines
9.7 KiB
TypeScript
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
|
|
* <OptimizedImage
|
|
* src="/image.jpg"
|
|
* alt="Description"
|
|
* width={800}
|
|
* height={600}
|
|
* />
|
|
* ```
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* // Avec placeholder blur et priorité
|
|
* <OptimizedImage
|
|
* src="/hero.jpg"
|
|
* alt="Hero image"
|
|
* width={1920}
|
|
* height={1080}
|
|
* blurDataURL="/hero-blur.jpg"
|
|
* priority={true}
|
|
* />
|
|
* ```
|
|
*
|
|
* @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 (
|
|
<div
|
|
className={`bg-gray-200 animate-pulse ${className}`}
|
|
style={{ width, height }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<img
|
|
src={blurDataURL}
|
|
alt=""
|
|
className={`blur-sm ${className}`}
|
|
style={{ width, height }}
|
|
aria-hidden="true"
|
|
/>
|
|
);
|
|
}
|
|
|
|
// Hook pour détecter le support des formats d'image
|
|
function useImageFormatSupport() {
|
|
const [supportedFormats, setSupportedFormats] = useState<string[]>([]);
|
|
|
|
useEffect(() => {
|
|
const checkFormatSupport = async () => {
|
|
const formats: string[] = [];
|
|
|
|
// Test WebP
|
|
const webpSupported = await new Promise<boolean>((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<boolean>((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<string | null>(null);
|
|
const imgRef = useRef<HTMLImageElement>(null);
|
|
const supportedFormats = useImageFormatSupport();
|
|
|
|
// Intersection Observer pour le lazy loading
|
|
const intersectionRef = useRef<HTMLDivElement>(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 || (
|
|
<div
|
|
className={`bg-gray-200 flex items-center justify-center ${className}`}
|
|
style={{ width, height }}
|
|
>
|
|
<span className="text-gray-400 text-sm">Image non disponible</span>
|
|
</div>
|
|
)
|
|
);
|
|
}
|
|
|
|
// Rendu du placeholder pendant le chargement
|
|
if (!isLoaded && !priority) {
|
|
return (
|
|
<div
|
|
ref={intersectionRef}
|
|
className={`relative ${className}`}
|
|
style={{ width, height }}
|
|
>
|
|
<BlurPlaceholder
|
|
blurDataURL={blurDataURL}
|
|
width={width}
|
|
height={height}
|
|
className="absolute inset-0"
|
|
/>
|
|
{placeholder && (
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
{placeholder}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Rendu de l'image optimisée
|
|
return (
|
|
<picture className={className}>
|
|
{/* Sources pour différents formats */}
|
|
{imageSources.map((source, index) => (
|
|
<source
|
|
key={index}
|
|
srcSet={source.src}
|
|
type={source.type}
|
|
sizes={source.sizes}
|
|
/>
|
|
))}
|
|
|
|
{/* Image principale */}
|
|
<img
|
|
ref={imgRef}
|
|
src={currentSrc || src}
|
|
alt={alt}
|
|
width={width}
|
|
height={height}
|
|
className={`transition-opacity duration-300 ${isLoaded ? 'opacity-100' : 'opacity-0'
|
|
} ${className}`}
|
|
onLoad={handleImageLoad}
|
|
onError={handleImageError}
|
|
loading={priority ? 'eager' : 'lazy'}
|
|
decoding="async"
|
|
style={{
|
|
width,
|
|
height,
|
|
}}
|
|
/>
|
|
</picture>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<OptimizedImage
|
|
{...props}
|
|
src={src}
|
|
alt={alt}
|
|
className={className}
|
|
sizes={sizes}
|
|
onLoad={() => { }}
|
|
/>
|
|
);
|
|
}
|