| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274 | 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
 |