import type { FC } from 'react' import React, { useCallback, useEffect, useRef, useState } from 'react' import { t } from 'i18next' import { createPortal } from 'react-dom' import { RiAddBoxLine, RiCloseLine, RiDownloadCloud2Line, RiFileCopyLine, RiZoomInLine, RiZoomOutLine } from '@remixicon/react' import Tooltip from '@/app/components/base/tooltip' import Toast from '@/app/components/base/toast' type ImagePreviewProps = { url: string title: string onCancel: () => void } const isBase64 = (str: string): boolean => { try { return btoa(atob(str)) === str } catch (err) { return false } } const ImagePreview: FC<ImagePreviewProps> = ({ url, title, onCancel, }) => { const [scale, setScale] = useState(1) const [position, setPosition] = useState({ x: 0, y: 0 }) const [isDragging, setIsDragging] = useState(false) const imgRef = useRef<HTMLImageElement>(null) const dragStartRef = useRef({ x: 0, y: 0 }) const [isCopied, setIsCopied] = useState(false) const containerRef = useRef<HTMLDivElement>(null) const openInNewTab = () => { // Open in a new window, considering the case when the page is inside an iframe if (url.startsWith('http') || url.startsWith('https')) { window.open(url, '_blank') } else if (url.startsWith('data:image')) { // Base64 image const win = window.open() win?.document.write(`<img src="${url}" alt="${title}" />`) } else { Toast.notify({ type: 'error', message: `Unable to open image: ${url}`, }) } } const downloadImage = () => { // Open in a new window, considering the case when the page is inside an iframe if (url.startsWith('http') || url.startsWith('https')) { const a = document.createElement('a') a.href = url a.download = title a.click() } else if (url.startsWith('data:image')) { // Base64 image const a = document.createElement('a') a.href = url a.download = title a.click() } else { Toast.notify({ type: 'error', message: `Unable to open image: ${url}`, }) } } const zoomIn = () => { setScale(prevScale => Math.min(prevScale * 1.2, 15)) } const zoomOut = () => { setScale((prevScale) => { const newScale = Math.max(prevScale / 1.2, 0.5) if (newScale === 1) setPosition({ x: 0, y: 0 }) // Reset position when fully zoomed out return newScale }) } const imageBase64ToBlob = (base64: string, type = 'image/png'): Blob => { const byteCharacters = atob(base64) const byteArrays = [] for (let offset = 0; offset < byteCharacters.length; offset += 512) { const slice = byteCharacters.slice(offset, offset + 512) const byteNumbers = new Array(slice.length) for (let i = 0; i < slice.length; i++) byteNumbers[i] = slice.charCodeAt(i) const byteArray = new Uint8Array(byteNumbers) byteArrays.push(byteArray) } return new Blob(byteArrays, { type }) } const imageCopy = useCallback(() => { const shareImage = async () => { try { const base64Data = url.split(',')[1] const blob = imageBase64ToBlob(base64Data, 'image/png') await navigator.clipboard.write([ new ClipboardItem({ [blob.type]: blob, }), ]) setIsCopied(true) Toast.notify({ type: 'success', message: t('common.operation.imageCopied'), }) } catch (err) { console.error('Failed to copy image:', err) const link = document.createElement('a') link.href = url link.download = `${title}.png` document.body.appendChild(link) link.click() document.body.removeChild(link) Toast.notify({ type: 'info', message: t('common.operation.imageDownloaded'), }) } } shareImage() }, [title, url]) const handleWheel = useCallback((e: React.WheelEvent<HTMLDivElement>) => { if (e.deltaY < 0) zoomIn() else zoomOut() }, []) const handleMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => { if (scale > 1) { setIsDragging(true) dragStartRef.current = { x: e.clientX - position.x, y: e.clientY - position.y } } }, [scale, position]) const handleMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => { if (isDragging && scale > 1) { const deltaX = e.clientX - dragStartRef.current.x const deltaY = e.clientY - dragStartRef.current.y // Calculate boundaries const imgRect = imgRef.current?.getBoundingClientRect() const containerRect = imgRef.current?.parentElement?.getBoundingClientRect() if (imgRect && containerRect) { const maxX = (imgRect.width * scale - containerRect.width) / 2 const maxY = (imgRect.height * scale - containerRect.height) / 2 setPosition({ x: Math.max(-maxX, Math.min(maxX, deltaX)), y: Math.max(-maxY, Math.min(maxY, deltaY)), }) } } }, [isDragging, scale]) const handleMouseUp = useCallback(() => { setIsDragging(false) }, []) useEffect(() => { document.addEventListener('mouseup', handleMouseUp) return () => { document.removeEventListener('mouseup', handleMouseUp) } }, [handleMouseUp]) useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') onCancel() } window.addEventListener('keydown', handleKeyDown) // Set focus to the container element if (containerRef.current) containerRef.current.focus() // Cleanup function return () => { window.removeEventListener('keydown', handleKeyDown) } }, [onCancel]) return createPortal( <div className='fixed inset-0 p-8 flex items-center justify-center bg-black/80 z-[1000] image-preview-container' onClick={e => e.stopPropagation()} onWheel={handleWheel} onMouseDown={handleMouseDown} onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} style={{ cursor: scale > 1 ? 'move' : 'default' }} tabIndex={-1}> {/* eslint-disable-next-line @next/next/no-img-element */} <img ref={imgRef} alt={title} src={isBase64(url) ? `data:image/png;base64,${url}` : url} className='max-w-full max-h-full' style={{ transform: `scale(${scale}) translate(${position.x}px, ${position.y}px)`, transition: isDragging ? 'none' : 'transform 0.2s ease-in-out', }} /> <Tooltip popupContent={t('common.operation.copyImage')}> <div className='absolute top-6 right-48 flex items-center justify-center w-8 h-8 rounded-lg cursor-pointer' onClick={imageCopy}> {isCopied ? <RiFileCopyLine className='w-4 h-4 text-green-500'/> : <RiFileCopyLine className='w-4 h-4 text-gray-500'/>} </div> </Tooltip> <Tooltip popupContent={t('common.operation.zoomOut')}> <div className='absolute top-6 right-40 flex items-center justify-center w-8 h-8 rounded-lg cursor-pointer' onClick={zoomOut}> <RiZoomOutLine className='w-4 h-4 text-gray-500'/> </div> </Tooltip> <Tooltip popupContent={t('common.operation.zoomIn')}> <div className='absolute top-6 right-32 flex items-center justify-center w-8 h-8 rounded-lg cursor-pointer' onClick={zoomIn}> <RiZoomInLine className='w-4 h-4 text-gray-500'/> </div> </Tooltip> <Tooltip popupContent={t('common.operation.download')}> <div className='absolute top-6 right-24 flex items-center justify-center w-8 h-8 rounded-lg cursor-pointer' onClick={downloadImage}> <RiDownloadCloud2Line className='w-4 h-4 text-gray-500'/> </div> </Tooltip> <Tooltip popupContent={t('common.operation.openInNewTab')}> <div className='absolute top-6 right-16 flex items-center justify-center w-8 h-8 rounded-lg cursor-pointer' onClick={openInNewTab}> <RiAddBoxLine className='w-4 h-4 text-gray-500'/> </div> </Tooltip> <Tooltip popupContent={t('common.operation.cancel')}> <div className='absolute top-6 right-6 flex items-center justify-center w-8 h-8 bg-white/8 rounded-lg backdrop-blur-[2px] cursor-pointer' onClick={onCancel}> <RiCloseLine className='w-4 h-4 text-gray-500'/> </div> </Tooltip> </div>, document.body, ) } export default ImagePreview