| 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 = () => { |
| |
| if (url.startsWith('http') || url.startsWith('https')) { |
| window.open(url, '_blank') |
| } |
| else if (url.startsWith('data: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 = () => { |
| |
| 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')) { |
| |
| 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 }) |
|
|
| 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 |
|
|
| |
| 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) |
|
|
| |
| if (containerRef.current) |
| containerRef.current.focus() |
|
|
| |
| 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 |
|
|