veza/apps/web/src/components/ui/optimized-image.tsx

319 lines
8.4 KiB
TypeScript
Raw Normal View History

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>
);
}