Files
dify/web/app/components/datasets/common/image-previewer/index.tsx
2025-12-09 11:44:50 +08:00

224 lines
6.3 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import Button from '@/app/components/base/button'
import Loading from '@/app/components/base/loading'
import { formatFileSize } from '@/utils/format'
import { RiArrowLeftLine, RiArrowRightLine, RiCloseLine, RiRefreshLine } from '@remixicon/react'
import { createPortal } from 'react-dom'
import { useHotkeys } from 'react-hotkeys-hook'
type CachedImage = {
blobUrl?: string
status: 'loading' | 'loaded' | 'error'
width: number
height: number
}
const imageCache = new Map<string, CachedImage>()
export type ImageInfo = {
url: string
name: string
size: number
}
type ImagePreviewerProps = {
images: ImageInfo[]
initialIndex?: number
onClose: () => void
}
const ImagePreviewer = ({
images,
initialIndex = 0,
onClose,
}: ImagePreviewerProps) => {
const [currentIndex, setCurrentIndex] = useState(initialIndex)
const [cachedImages, setCachedImages] = useState<Record<string, CachedImage>>(() => {
return images.reduce((acc, image) => {
acc[image.url] = {
status: 'loading',
width: 0,
height: 0,
}
return acc
}, {} as Record<string, CachedImage>)
})
const isMounted = useRef(false)
const fetchImage = useCallback(async (image: ImageInfo) => {
const { url } = image
// Skip if already cached
if (imageCache.has(url)) return
try {
const res = await fetch(url)
if (!res.ok) throw new Error(`Failed to load: ${url}`)
const blob = await res.blob()
const blobUrl = URL.createObjectURL(blob)
const img = new Image()
img.src = blobUrl
img.onload = () => {
if (!isMounted.current) return
imageCache.set(url, {
blobUrl,
status: 'loaded',
width: img.naturalWidth,
height: img.naturalHeight,
})
setCachedImages((prev) => {
return {
...prev,
[url]: {
blobUrl,
status: 'loaded',
width: img.naturalWidth,
height: img.naturalHeight,
},
}
})
}
}
catch {
if (isMounted.current) {
setCachedImages((prev) => {
return {
...prev,
[url]: {
status: 'error',
width: 0,
height: 0,
},
}
})
}
}
}, [])
useEffect(() => {
isMounted.current = true
images.forEach((image) => {
fetchImage(image)
})
return () => {
isMounted.current = false
// Cleanup released blob URLs not in current list
imageCache.forEach(({ blobUrl }, key) => {
if (blobUrl)
URL.revokeObjectURL(blobUrl)
imageCache.delete(key)
})
}
}, [])
const currentImage = useMemo(() => {
return images[currentIndex]
}, [images, currentIndex])
const prevImage = useCallback(() => {
if (currentIndex === 0)
return
setCurrentIndex(prevIndex => prevIndex - 1)
}, [currentIndex])
const nextImage = useCallback(() => {
if (currentIndex === images.length - 1)
return
setCurrentIndex(prevIndex => prevIndex + 1)
}, [currentIndex, images.length])
const retryImage = useCallback((image: ImageInfo) => {
setCachedImages((prev) => {
return {
...prev,
[image.url]: {
...prev[image.url],
status: 'loading',
},
}
})
fetchImage(image)
}, [fetchImage])
useHotkeys('esc', onClose)
useHotkeys('left', prevImage)
useHotkeys('right', nextImage)
return createPortal(
<div
className='image-previewer fixed inset-0 z-[10000] flex items-center justify-center bg-background-overlay-fullscreen p-5 pb-4 backdrop-blur-[6px]'
onClick={e => e.stopPropagation()}
tabIndex={-1}
>
<div className='absolute right-6 top-6 z-10 flex cursor-pointer flex-col items-center gap-y-1'>
<Button
variant='tertiary'
onClick={onClose}
className='size-9 rounded-[10px] p-0'
size='large'
>
<RiCloseLine className='size-5' />
</Button>
<span className='system-2xs-medium-uppercase text-text-tertiary'>
Esc
</span>
</div>
{cachedImages[currentImage.url].status === 'loading' && (
<Loading type='app' />
)}
{cachedImages[currentImage.url].status === 'error' && (
<div className='system-sm-regular flex max-w-sm flex-col items-center gap-y-2 text-text-tertiary'>
<span>{`Failed to load image: ${currentImage.url}. Please try again.`}</span>
<Button
variant='secondary'
onClick={() => retryImage(currentImage)}
className='size-9 rounded-full p-0'
size='large'
>
<RiRefreshLine className='size-5' />
</Button>
</div>
)}
{cachedImages[currentImage.url].status === 'loaded' && (
<div className='flex size-full flex-col items-center justify-center gap-y-2'>
<img
alt={currentImage.name}
src={cachedImages[currentImage.url].blobUrl}
className='max-h-[calc(100%-2.5rem)] max-w-full object-contain shadow-lg ring-8 ring-effects-image-frame backdrop-blur-[5px]'
/>
<div className='system-sm-regular flex shrink-0 gap-x-2 pb-1 pt-3 text-text-tertiary'>
<span>{currentImage.name}</span>
<span>·</span>
<span>{`${cachedImages[currentImage.url].width} × ${cachedImages[currentImage.url].height}`}</span>
<span>·</span>
<span>{formatFileSize(currentImage.size)}</span>
</div>
</div>
)}
<Button
variant='secondary'
onClick={prevImage}
className='absolute left-8 top-1/2 z-10 size-9 -translate-y-1/2 rounded-full p-0'
disabled={currentIndex === 0}
size='large'
>
<RiArrowLeftLine className='size-5' />
</Button>
<Button
variant='secondary'
onClick={nextImage}
className='absolute right-8 top-1/2 z-10 size-9 -translate-y-1/2 rounded-full p-0'
disabled={currentIndex === images.length - 1}
size='large'
>
<RiArrowRightLine className='size-5' />
</Button>
</div>,
document.body,
)
}
export default ImagePreviewer