You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
286 lines
9.8 KiB
286 lines
9.8 KiB
import { Skeleton } from '@/components/ui/skeleton' |
|
import { cn } from '@/lib/utils' |
|
import { isRenderableMediaUrl, isSafeMediaUrl } from '@/lib/url' |
|
import { TImetaInfo } from '@/types' |
|
import { decode } from 'blurhash' |
|
import { ImageOff } from 'lucide-react' |
|
import { CSSProperties, HTMLAttributes, useEffect, useMemo, useRef, useState } from 'react' |
|
import { useTranslation } from 'react-i18next' |
|
|
|
/** Browsers often never fire `onError` for invalid URIs, ORB, or stalled fetches — this forces a visible error. */ |
|
const IMAGE_LOAD_TIMEOUT_MS = 10_000 |
|
|
|
/** Without reserved height, `absolute` skeleton + `opacity-0` img collapse to 0×0 — looks like “nothing”. */ |
|
function wrapperReserveStyle( |
|
dim: { width: number; height: number } | undefined, |
|
showError: boolean |
|
): CSSProperties | undefined { |
|
if (showError) return undefined |
|
if (dim && dim.width > 0 && dim.height > 0) { |
|
return { aspectRatio: `${dim.width} / ${dim.height}` } |
|
} |
|
return { minHeight: 'min(30vh, 280px)' } |
|
} |
|
|
|
function formatFileSize(bytes: number): string { |
|
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB` |
|
if (bytes >= 1_024) return `${Math.round(bytes / 1_024)} KB` |
|
return `${bytes} B` |
|
} |
|
|
|
export default function Image({ |
|
image: { url, blurHash, dim, alt: imetaAlt, fallback, size: fileSizeBytes }, |
|
alt, |
|
className = '', |
|
classNames = {}, |
|
hideIfError = false, |
|
errorPlaceholder = <ImageOff />, |
|
style: wrapperStyleProp, |
|
holdUntilClick = false, |
|
...props |
|
}: HTMLAttributes<HTMLSpanElement> & { |
|
classNames?: { |
|
wrapper?: string |
|
errorPlaceholder?: string |
|
} |
|
image: TImetaInfo |
|
alt?: string |
|
hideIfError?: boolean |
|
errorPlaceholder?: React.ReactNode |
|
/** |
|
* When true, the full image is NOT loaded until the user interacts. |
|
* Shows a blurhash canvas if available, otherwise a skeleton placeholder. |
|
* Intended for inline note images: clicking opens the lightbox (via the |
|
* onClick handler passed from MarkdownArticle) without ever loading the |
|
* full image inline. |
|
*/ |
|
holdUntilClick?: boolean |
|
}) { |
|
const { t } = useTranslation() |
|
const urlOk = !!url?.trim() |
|
// When holdUntilClick is active we start in the "held" state (regardless of blurHash). |
|
const shouldHold = holdUntilClick |
|
const [revealed, setRevealed] = useState(!shouldHold) |
|
const [isLoading, setIsLoading] = useState(urlOk && revealed) |
|
const [displaySkeleton, setDisplaySkeleton] = useState(urlOk) |
|
const [hasError, setHasError] = useState(!urlOk) |
|
const [imageUrl, setImageUrl] = useState(url) |
|
const [fallbackIndex, setFallbackIndex] = useState(0) |
|
const loadWatchRef = useRef<number | null>(null) |
|
// Track whether this image started in the held state (required an explicit click to reveal). |
|
// The timeout is only meaningful when the user already triggered a load — for auto-revealed |
|
// images, <img loading="lazy"> delays the browser request until the element nears the viewport, |
|
// so a 10 s timeout would fire before off-screen images are even fetched. |
|
const wasInitiallyHeldRef = useRef(holdUntilClick) |
|
|
|
const finalAlt = imetaAlt || alt |
|
const openLinkHref = |
|
(isSafeMediaUrl(url) && url.trim()) || (isSafeMediaUrl(imageUrl) && imageUrl.trim()) || '' |
|
|
|
const badSrc = !imageUrl?.trim() || !isRenderableMediaUrl(imageUrl.trim()) |
|
const showErrorState = hasError || badSrc |
|
|
|
const clearLoadWatch = () => { |
|
if (loadWatchRef.current != null) { |
|
clearTimeout(loadWatchRef.current) |
|
loadWatchRef.current = null |
|
} |
|
} |
|
|
|
useEffect(() => { |
|
setImageUrl(url) |
|
setRevealed(!shouldHold) |
|
setIsLoading(!!url?.trim() && !shouldHold) |
|
setHasError(false) |
|
setDisplaySkeleton(true) |
|
setFallbackIndex(0) |
|
clearLoadWatch() |
|
if (!url?.trim()) { |
|
setIsLoading(false) |
|
setHasError(true) |
|
setDisplaySkeleton(false) |
|
} |
|
// shouldHold is derived from props — intentionally not in deps to avoid reset loops |
|
// eslint-disable-next-line react-hooks/exhaustive-deps |
|
}, [url]) |
|
|
|
useEffect(() => { |
|
clearLoadWatch() |
|
if (badSrc || !url?.trim() || !revealed) return |
|
// Skip the timeout for auto-load images (holdUntilClick was false from mount). |
|
// Their <img loading="lazy"> request hasn't necessarily started yet when revealed |
|
// becomes true, so the timeout would fire before the browser even fetches the image. |
|
// For those images, onError is sufficient — it fires whenever the browser does try. |
|
if (!wasInitiallyHeldRef.current) return |
|
loadWatchRef.current = window.setTimeout(() => { |
|
loadWatchRef.current = null |
|
setIsLoading(false) |
|
setDisplaySkeleton(false) |
|
setHasError(true) |
|
}, IMAGE_LOAD_TIMEOUT_MS) |
|
return clearLoadWatch |
|
}, [imageUrl, badSrc, url, revealed]) |
|
|
|
if (hideIfError && showErrorState) return null |
|
|
|
const handleError = () => { |
|
clearLoadWatch() |
|
if (fallback && fallbackIndex < fallback.length) { |
|
const next = fallback[fallbackIndex] |
|
setFallbackIndex((prev) => prev + 1) |
|
setImageUrl(next) |
|
return |
|
} |
|
setIsLoading(false) |
|
setDisplaySkeleton(false) |
|
setHasError(true) |
|
} |
|
|
|
const handleLoad = () => { |
|
clearLoadWatch() |
|
setIsLoading(false) |
|
setHasError(false) |
|
setTimeout(() => setDisplaySkeleton(false), 600) |
|
} |
|
|
|
const reserveStyle = wrapperReserveStyle(dim, showErrorState) |
|
const mergedWrapperStyle: CSSProperties | undefined = |
|
reserveStyle || wrapperStyleProp |
|
? { ...reserveStyle, ...wrapperStyleProp } |
|
: undefined |
|
|
|
const handleReveal = () => { |
|
if (revealed) return |
|
setRevealed(true) |
|
setIsLoading(true) |
|
} |
|
|
|
return ( |
|
<span |
|
className={cn('relative overflow-hidden block w-full', classNames.wrapper)} |
|
style={mergedWrapperStyle} |
|
onClick={!revealed ? handleReveal : undefined} |
|
{...props} |
|
> |
|
{displaySkeleton && !showErrorState && ( |
|
<span className="absolute inset-0 z-10 block rounded-lg bg-muted/30"> |
|
{blurHash ? ( |
|
<BlurHashCanvas |
|
blurHash={blurHash} |
|
className={cn( |
|
'absolute inset-0 transition-opacity duration-500 rounded-lg', |
|
!revealed ? 'opacity-100' : 'opacity-0' |
|
)} |
|
/> |
|
) : !revealed && !isLoading ? ( |
|
// Static bg when held — no shimmer animation flashing indefinitely |
|
<span className="absolute inset-0 h-full w-full rounded-lg bg-muted" /> |
|
) : ( |
|
<Skeleton |
|
className={cn( |
|
'absolute inset-0 h-full min-h-[8rem] w-full transition-opacity duration-500 rounded-lg', |
|
isLoading ? 'opacity-100' : 'opacity-0' |
|
)} |
|
/> |
|
)} |
|
{!revealed && holdUntilClick && fileSizeBytes != null && ( |
|
<span className="absolute bottom-2 right-2 z-20 rounded-full bg-black/60 px-2 py-0.5 text-[11px] font-medium text-white/90 backdrop-blur-sm select-none pointer-events-none"> |
|
{formatFileSize(fileSizeBytes)} |
|
</span> |
|
)} |
|
</span> |
|
)} |
|
{!showErrorState && revealed && ( |
|
<img |
|
src={imageUrl} |
|
alt={finalAlt} |
|
title={finalAlt || undefined} |
|
referrerPolicy="no-referrer" |
|
decoding="async" |
|
loading="lazy" |
|
draggable={false} |
|
onLoad={handleLoad} |
|
onError={handleError} |
|
className={cn( |
|
'object-cover rounded-lg w-full h-full transition-opacity duration-500 pointer-events-none', |
|
isLoading ? 'opacity-0' : 'opacity-100', |
|
className |
|
)} |
|
width={dim?.width} |
|
height={dim?.height} |
|
/> |
|
)} |
|
{showErrorState && ( |
|
// All children are <span> so this block is inline-safe when Image is placed |
|
// inside a <p> by MarkdownArticle (avoids validateDOMNesting violations). |
|
<span |
|
role="alert" |
|
className={cn( |
|
'flex flex-col items-center justify-center gap-2 w-full min-h-[120px] p-4 rounded-lg bg-muted text-muted-foreground text-center', |
|
className, |
|
classNames.errorPlaceholder |
|
)} |
|
> |
|
<span className="flex shrink-0 text-muted-foreground [&_svg]:size-10">{errorPlaceholder}</span> |
|
<span className="text-sm leading-snug">{t('This image could not be loaded.')}</span> |
|
{badSrc && !hasError ? ( |
|
<span className="text-xs opacity-80 break-all max-w-full block">{t('Invalid or unsupported image address.')}</span> |
|
) : null} |
|
{openLinkHref ? ( |
|
<a |
|
href={openLinkHref} |
|
target="_blank" |
|
rel="noopener noreferrer" |
|
className="text-sm text-primary underline-offset-4 hover:underline break-all max-w-full" |
|
onClick={(e) => e.stopPropagation()} |
|
> |
|
{t('Open image link')} |
|
</a> |
|
) : null} |
|
</span> |
|
)} |
|
</span> |
|
) |
|
} |
|
|
|
const blurHashWidth = 32 |
|
const blurHashHeight = 32 |
|
function BlurHashCanvas({ blurHash, className = '' }: { blurHash: string; className?: string }) { |
|
const canvasRef = useRef<HTMLCanvasElement>(null) |
|
|
|
const pixels = useMemo(() => { |
|
if (!blurHash) return null |
|
try { |
|
return decode(blurHash, blurHashWidth, blurHashHeight) |
|
} catch { |
|
return null |
|
} |
|
}, [blurHash]) |
|
|
|
useEffect(() => { |
|
if (!pixels || !canvasRef.current) return |
|
|
|
const canvas = canvasRef.current |
|
const ctx = canvas.getContext('2d') |
|
if (!ctx) return |
|
|
|
const imageData = ctx.createImageData(blurHashWidth, blurHashHeight) |
|
imageData.data.set(pixels) |
|
ctx.putImageData(imageData, 0, 0) |
|
}, [pixels]) |
|
|
|
if (!blurHash) return null |
|
|
|
return ( |
|
<canvas |
|
ref={canvasRef} |
|
width={blurHashWidth} |
|
height={blurHashHeight} |
|
className={cn('w-full h-full object-cover rounded-lg', className)} |
|
style={{ |
|
imageRendering: 'auto', |
|
filter: 'blur(0.5px)' |
|
}} |
|
/> |
|
) |
|
}
|
|
|