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

406 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-kodo-slate 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-kodo-slate flex items-center justify-center ${className}`}
style={{ width, height }}
>
<span className="text-kodo-content-dim 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={() => {}}
/>
);
}