319 lines
8.4 KiB
TypeScript
319 lines
8.4 KiB
TypeScript
|
|
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||
|
|
import { useIntersectionObserver } from '@/hooks/useIntersectionObserver';
|
||
|
|
|
||
|
|
interface OptimizedImageProps {
|
||
|
|
src: string;
|
||
|
|
alt: string;
|
||
|
|
width?: number;
|
||
|
|
height?: number;
|
||
|
|
className?: string;
|
||
|
|
placeholder?: string;
|
||
|
|
blurDataURL?: string;
|
||
|
|
priority?: boolean;
|
||
|
|
quality?: number;
|
||
|
|
sizes?: string;
|
||
|
|
onLoad?: () => void;
|
||
|
|
onError?: () => void;
|
||
|
|
fallback?: React.ReactNode;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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 { isIntersecting, ref: intersectionRef } = useIntersectionObserver({
|
||
|
|
threshold: 0.1,
|
||
|
|
rootMargin: '50px',
|
||
|
|
});
|
||
|
|
|
||
|
|
// 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 }) {
|
||
|
|
const [isLoaded, setIsLoaded] = useState(false);
|
||
|
|
|
||
|
|
// Générer srcset pour différentes tailles
|
||
|
|
const generateSrcSet = useCallback((baseSrc: string) => {
|
||
|
|
const widths = [320, 640, 768, 1024, 1280, 1920];
|
||
|
|
return widths
|
||
|
|
.map(width => `${baseSrc}?w=${width} ${width}w`)
|
||
|
|
.join(', ');
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const srcSet = generateSrcSet(src);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<OptimizedImage
|
||
|
|
{...props}
|
||
|
|
src={src}
|
||
|
|
alt={alt}
|
||
|
|
className={className}
|
||
|
|
sizes={sizes}
|
||
|
|
onLoad={() => setIsLoaded(true)}
|
||
|
|
>
|
||
|
|
<img
|
||
|
|
src={src}
|
||
|
|
srcSet={srcSet}
|
||
|
|
alt={alt}
|
||
|
|
sizes={sizes}
|
||
|
|
className={`w-full h-auto ${isLoaded ? 'opacity-100' : 'opacity-0'} transition-opacity duration-300`}
|
||
|
|
loading="lazy"
|
||
|
|
decoding="async"
|
||
|
|
/>
|
||
|
|
</OptimizedImage>
|
||
|
|
);
|
||
|
|
}
|