167 lines
4.6 KiB
TypeScript
167 lines
4.6 KiB
TypeScript
'use client';
|
|
|
|
/* eslint-disable jsx-a11y/alt-text */
|
|
import { BLUR_ENABLED } from '@/app/config';
|
|
import { useAppState } from '@/state/AppState';
|
|
import { clsx} from 'clsx/lite';
|
|
import Image, { ImageProps } from 'next/image';
|
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
|
|
export default function ImageWithFallback({
|
|
className,
|
|
classNameImage = 'object-cover h-full',
|
|
debug,
|
|
priority,
|
|
blurDataURL,
|
|
blurCompatibilityLevel = 'low',
|
|
...props
|
|
}: ImageProps & {
|
|
blurCompatibilityLevel?: 'none' | 'low' | 'high'
|
|
classNameImage?: string
|
|
debug?: boolean
|
|
}) {
|
|
const {
|
|
shouldDebugImageFallbacks,
|
|
areAdminDebugToolsEnabled,
|
|
} = useAppState();
|
|
|
|
const [wasCached, setWasCached] = useState(true);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [didError, setDidError] = useState(false);
|
|
|
|
const onLoad = useCallback(() => setIsLoading(false), []);
|
|
const onError = useCallback(() => setDidError(true), []);
|
|
|
|
const [hideFallback, setHideFallback] = useState(false);
|
|
|
|
const refImage = useRef<HTMLImageElement>(null);
|
|
const refFallback = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
setWasCached(
|
|
Boolean(refImage.current?.complete) &&
|
|
(refImage.current?.naturalWidth ?? 0) > 0,
|
|
);
|
|
}, []);
|
|
|
|
const shouldDebugFallback = areAdminDebugToolsEnabled && debug;
|
|
|
|
const debugFallbackStyles = useCallback(() => {
|
|
const stylesMap = refFallback.current?.computedStyleMap();
|
|
const styles = stylesMap
|
|
? Array.from(stylesMap.entries()).reduce((acc, [key, value]) => {
|
|
acc[key] = value.toString();
|
|
return acc;
|
|
}, {} as Record<string, string>) : {};
|
|
const opacity = (stylesMap?.get('opacity') as CSSUnitValue)?.value;
|
|
return {
|
|
styles,
|
|
opacity,
|
|
classList: refFallback.current?.classList,
|
|
};
|
|
}, []);
|
|
|
|
const outerTimeout = useRef<NodeJS.Timeout>(undefined);
|
|
const innerTimeout = useRef<NodeJS.Timeout>(undefined);
|
|
useEffect(() => {
|
|
if (!isLoading && !didError) {
|
|
outerTimeout.current = setTimeout(() => {
|
|
if (refFallback.current) {
|
|
const fallbackOpacity = (refFallback
|
|
.current
|
|
.computedStyleMap()
|
|
.get('opacity') as CSSUnitValue
|
|
)?.value;
|
|
// Address race condition where cached image is initially loaded
|
|
// and fallback is still being shown at full opacity
|
|
if (fallbackOpacity === 0) {
|
|
// Image has loaded and fallback is already hidden
|
|
setHideFallback(true);
|
|
if (shouldDebugFallback) {
|
|
console.log('Hide fallback: 01', debugFallbackStyles());
|
|
}
|
|
} else {
|
|
// Image has loaded but fallback is still visible
|
|
// Delay hiding fallback to avoid abrupt transition
|
|
innerTimeout.current = setTimeout(() =>{
|
|
setHideFallback(true);
|
|
if (shouldDebugFallback) {
|
|
console.log('Hide fallback: 02', debugFallbackStyles());
|
|
}
|
|
}, 1000);
|
|
}
|
|
}
|
|
}, 1000);
|
|
return () => {
|
|
clearTimeout(outerTimeout.current);
|
|
clearTimeout(innerTimeout.current);
|
|
};
|
|
}
|
|
}, [
|
|
isLoading,
|
|
didError,
|
|
shouldDebugFallback,
|
|
debugFallbackStyles,
|
|
]);
|
|
|
|
const showFallback =
|
|
!wasCached &&
|
|
!hideFallback;
|
|
|
|
const getBlurClass = () => {
|
|
switch (blurCompatibilityLevel) {
|
|
case 'high':
|
|
// Fix poorly blurred placeholder data generated on client
|
|
return 'blur-[4px] @xs:blue-md scale-[1.05]';
|
|
case 'low':
|
|
return 'blur-[2px] @xs:blue-md scale-[1.01]';
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={clsx(
|
|
'flex relative',
|
|
className,
|
|
)}
|
|
>
|
|
<Image {...{
|
|
...props,
|
|
ref: refImage,
|
|
priority,
|
|
className: classNameImage,
|
|
onLoad,
|
|
onError,
|
|
}} />
|
|
<div
|
|
ref={refFallback}
|
|
className={clsx(
|
|
'@container',
|
|
'absolute inset-0 pointer-events-none',
|
|
'overflow-hidden',
|
|
(showFallback || shouldDebugImageFallbacks) &&
|
|
'transition-opacity duration-300 ease-in',
|
|
!(BLUR_ENABLED && blurDataURL) && 'bg-main',
|
|
(isLoading || shouldDebugImageFallbacks)
|
|
? 'opacity-100'
|
|
: 'opacity-0',
|
|
)}
|
|
>
|
|
{(BLUR_ENABLED && blurDataURL)
|
|
? <img {...{
|
|
...props,
|
|
src: blurDataURL,
|
|
className: clsx(
|
|
getBlurClass(),
|
|
classNameImage,
|
|
),
|
|
}} />
|
|
: <div className={clsx(
|
|
'w-full h-full',
|
|
'bg-gray-100/50 dark:bg-gray-900/50',
|
|
)} />}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|