Vercel/src/components/image/ImageWithFallback.tsx
2025-01-22 04:05:28 +01:00

153 lines
4.1 KiB
TypeScript

'use client';
/* eslint-disable jsx-a11y/alt-text */
import { BLUR_ENABLED } from '@/site/config';
import { useAppState } from '@/state/AppState';
import { clsx } from 'clsx/lite';
import Image, { ImageProps } from 'next/image';
import { useCallback, useEffect, useRef, useState } from 'react';
import FullscreenButton from '../FullscreenButton';
import Viewer from 'viewerjs';
import 'viewerjs/dist/viewer.css';
export default function ImageWithFallback(props: ImageProps & {
blurCompatibilityLevel?: 'none' | 'low' | 'high'
imgClassName?: string
allowFullscreen?: boolean
enableImageActions?: boolean
}) {
const {
className,
priority,
blurDataURL,
blurCompatibilityLevel = 'low',
imgClassName = 'object-cover h-full',
enableImageActions = false,
...rest
} = props;
const { shouldDebugImageFallbacks } = 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 containerRef = useRef<HTMLDivElement>(null);
const viewerRef = useRef<Viewer | null>(null);
const imgRef = useRef<HTMLImageElement | null>(null);
const { isFullscreen } = useAppState();
useEffect(() => {
const timeout = setTimeout(
() => setWasCached(imgRef.current?.complete ?? false),
100,
);
return () => clearTimeout(timeout);
}, []);
useEffect(() => {
if (!isLoading && !didError) {
const timeout = setTimeout(() => {
setHideFallback(true);
}, 1000);
return () => clearTimeout(timeout);
}
}, [isLoading, didError]);
useEffect(() => {
if (containerRef.current && enableImageActions) {
viewerRef.current = new Viewer(containerRef.current, {
inline: false,
button: true,
navbar: false,
title: false,
toolbar: {
zoomIn: 1,
zoomOut: 1,
reset: 1,
play: {
show: 0,
size: 'large',
},
tooltip: 1,
},
});
return () => {
viewerRef.current?.destroy();
};
}
}, [enableImageActions]);
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 (
<>
<style jsx global>
{'.viewer-canvas { background-color: black !important; }'}
</style>
<div
className={clsx(
className,
'flex relative',
)}
ref={containerRef}
>
{(showFallback || shouldDebugImageFallbacks) &&
<div className={clsx(
'@container',
'absolute inset-0',
'overflow-hidden',
'transition-opacity duration-300 ease-in',
!(BLUR_ENABLED && blurDataURL) && 'bg-main',
(isLoading || shouldDebugImageFallbacks)
? 'opacity-100'
: 'opacity-0',
)}>
{(BLUR_ENABLED && blurDataURL)
? <img {...{
...rest,
src: blurDataURL,
className: clsx(
imgClassName,
getBlurClass(),
),
}} />
: <div className={clsx(
'w-full h-full',
'bg-gray-100/50 dark:bg-gray-900/50',
)} />}
</div>}
<Image
{...rest}
ref={imgRef}
priority={priority}
className={clsx(
imgClassName,
!isFullscreen && enableImageActions && 'cursor-zoom-in',
)}
onLoad={onLoad}
onError={onError}
/>
{enableImageActions && <FullscreenButton imageRef={imgRef} />}
</div>
</>
);
}