Ver código fonte

add svg render & Image preview optimization (#8387)

Co-authored-by: crazywoola <427733928@qq.com>
Charlie.Wei 7 meses atrás
pai
commit
445497cf89

+ 229 - 23
web/app/components/base/image-uploader/image-preview.tsx

@@ -1,26 +1,42 @@
 import type { FC } from 'react'
-import { useRef } from 'react'
+import React, { useCallback, useEffect, useRef, useState } from 'react'
 import { t } from 'i18next'
 import { createPortal } from 'react-dom'
-import { RiCloseLine, RiExternalLinkLine } from '@remixicon/react'
+import { RiAddBoxLine, RiCloseLine, RiDownloadCloud2Line, RiFileCopyLine, RiZoomInLine, RiZoomOutLine } from '@remixicon/react'
 import Tooltip from '@/app/components/base/tooltip'
-import { randomString } from '@/utils'
+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 selector = useRef(`copy-tooltip-${randomString(4)}`)
+  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')) {
+    if (url.startsWith('http') || url.startsWith('https')) {
       window.open(url, '_blank')
     }
     else if (url.startsWith('data:image')) {
@@ -29,34 +45,224 @@ const ImagePreview: FC<ImagePreviewProps> = ({
       win?.document.write(`<img src="${url}" alt="${title}" />`)
     }
     else {
-      console.error('Unable to open image', url)
+      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 imageTobase64ToBlob = (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 = imageTobase64ToBlob(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]' onClick={e => e.stopPropagation()}>
+    <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={url}
+        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',
+        }}
       />
-      <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-white' />
-      </div>
-      <Tooltip
-        selector={selector.current}
-        content={(t('common.operation.openInNewTab') ?? 'Open in new tab')}
-        className='z-10'
-      >
+      <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.close')}>
         <div
-          className='absolute top-6 right-16 flex items-center justify-center w-8 h-8 rounded-lg cursor-pointer'
-          onClick={openInNewTab}
-        >
-          <RiExternalLinkLine className='w-4 h-4 text-white' />
+          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>,

+ 20 - 17
web/app/components/base/markdown.tsx

@@ -5,6 +5,7 @@ import RemarkMath from 'remark-math'
 import RemarkBreaks from 'remark-breaks'
 import RehypeKatex from 'rehype-katex'
 import RemarkGfm from 'remark-gfm'
+import RehypeRaw from 'rehype-raw'
 import SyntaxHighlighter from 'react-syntax-highlighter'
 import { atelierHeathLight } from 'react-syntax-highlighter/dist/esm/styles/hljs'
 import type { RefObject } from 'react'
@@ -18,6 +19,7 @@ import ImageGallery from '@/app/components/base/image-gallery'
 import { useChatContext } from '@/app/components/base/chat/chat/context'
 import VideoGallery from '@/app/components/base/video-gallery'
 import AudioGallery from '@/app/components/base/audio-gallery'
+import SVGRenderer from '@/app/components/base/svg-gallery'
 
 // Available language https://github.com/react-syntax-highlighter/react-syntax-highlighter/blob/master/AVAILABLE_LANGUAGES_HLJS.MD
 const capitalizationLanguageNameMap: Record<string, string> = {
@@ -40,6 +42,7 @@ const capitalizationLanguageNameMap: Record<string, string> = {
   powershell: 'PowerShell',
   json: 'JSON',
   latex: 'Latex',
+  svg: 'SVG',
 }
 const getCorrectCapitalizationLanguageName = (language: string) => {
   if (!language)
@@ -107,6 +110,7 @@ const useLazyLoad = (ref: RefObject<Element>): boolean => {
 // Error: Minified React error 185;
 // visit https://reactjs.org/docs/error-decoder.html?invariant=185 for the full message
 // or use the non-minified dev environment for full errors and additional helpful warnings.
+
 const CodeBlock: CodeComponent = memo(({ inline, className, children, ...props }) => {
   const [isSVG, setIsSVG] = useState(true)
   const match = /language-(\w+)/.exec(className || '')
@@ -134,7 +138,7 @@ const CodeBlock: CodeComponent = memo(({ inline, className, children, ...props }
           >
             <div className='text-[13px] text-gray-500 font-normal'>{languageShowName}</div>
             <div style={{ display: 'flex' }}>
-              {language === 'mermaid' && <SVGBtn isSVG={isSVG} setIsSVG={setIsSVG} />}
+              {language === 'mermaid' && <SVGBtn isSVG={isSVG} setIsSVG={setIsSVG}/>}
               <CopyBtn
                 className='mr-1'
                 value={String(children).replace(/\n$/, '')}
@@ -144,12 +148,10 @@ const CodeBlock: CodeComponent = memo(({ inline, className, children, ...props }
           </div>
           {(language === 'mermaid' && isSVG)
             ? (<Flowchart PrimitiveCode={String(children).replace(/\n$/, '')} />)
-            : (
-              (language === 'echarts')
-                ? (<div style={{ minHeight: '250px', minWidth: '250px' }}><ErrorBoundary><ReactEcharts
-                  option={chartData}
-                >
-                </ReactEcharts></ErrorBoundary></div>)
+            : (language === 'echarts'
+              ? (<div style={{ minHeight: '350px', minWidth: '700px' }}><ErrorBoundary><ReactEcharts option={chartData} /></ErrorBoundary></div>)
+              : (language === 'svg'
+                ? (<ErrorBoundary><SVGRenderer content={String(children).replace(/\n$/, '')} /></ErrorBoundary>)
                 : (<SyntaxHighlighter
                   {...props}
                   style={atelierHeathLight}
@@ -162,17 +164,12 @@ const CodeBlock: CodeComponent = memo(({ inline, className, children, ...props }
                   PreTag="div"
                 >
                   {String(children).replace(/\n$/, '')}
-                </SyntaxHighlighter>))}
+                </SyntaxHighlighter>)))}
         </div>
       )
-      : (
-        <code {...props} className={className}>
-          {children}
-        </code>
-      )
+      : (<code {...props} className={className}>{children}</code>)
   }, [chartData, children, className, inline, isSVG, language, languageShowName, match, props])
 })
-
 CodeBlock.displayName = 'CodeBlock'
 
 const VideoBlock: CodeComponent = memo(({ node }) => {
@@ -230,6 +227,7 @@ export function Markdown(props: { content: string; className?: string }) {
         remarkPlugins={[[RemarkGfm, RemarkMath, { singleDollarTextMath: false }], RemarkBreaks]}
         rehypePlugins={[
           RehypeKatex,
+          RehypeRaw as any,
           // The Rehype plug-in is used to remove the ref attribute of an element
           () => {
             return (tree) => {
@@ -244,6 +242,7 @@ export function Markdown(props: { content: string; className?: string }) {
             }
           },
         ]}
+        disallowedElements={['script', 'iframe', 'head', 'html', 'meta', 'link', 'style', 'body']}
         components={{
           code: CodeBlock,
           img: Img,
@@ -266,19 +265,23 @@ export function Markdown(props: { content: string; className?: string }) {
 // This can happen when a component attempts to access an undefined object that references an unregistered map, causing the program to crash.
 
 export default class ErrorBoundary extends Component {
-  constructor(props) {
+  constructor(props: any) {
     super(props)
     this.state = { hasError: false }
   }
 
-  componentDidCatch(error, errorInfo) {
+  componentDidCatch(error: any, errorInfo: any) {
     this.setState({ hasError: true })
     console.error(error, errorInfo)
   }
 
   render() {
+    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+    // @ts-expect-error
     if (this.state.hasError)
-      return <div>Oops! ECharts reported a runtime error. <br />(see the browser console for more information)</div>
+      return <div>Oops! An error occurred. This could be due to an ECharts runtime error or invalid SVG content. <br />(see the browser console for more information)</div>
+    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+    // @ts-expect-error
     return this.props.children
   }
 }

+ 79 - 0
web/app/components/base/svg-gallery/index.tsx

@@ -0,0 +1,79 @@
+import { useEffect, useRef, useState } from 'react'
+import { SVG } from '@svgdotjs/svg.js'
+import ImagePreview from '@/app/components/base/image-uploader/image-preview'
+
+export const SVGRenderer = ({ content }: { content: string }) => {
+  const svgRef = useRef<HTMLDivElement>(null)
+  const [imagePreview, setImagePreview] = useState('')
+  const [windowSize, setWindowSize] = useState({
+    width: typeof window !== 'undefined' ? window.innerWidth : 0,
+    height: typeof window !== 'undefined' ? window.innerHeight : 0,
+  })
+
+  const svgToDataURL = (svgElement: Element): string => {
+    const svgString = new XMLSerializer().serializeToString(svgElement)
+    const base64String = Buffer.from(svgString).toString('base64')
+    return `data:image/svg+xml;base64,${base64String}`
+  }
+
+  useEffect(() => {
+    const handleResize = () => {
+      setWindowSize({ width: window.innerWidth, height: window.innerHeight })
+    }
+
+    window.addEventListener('resize', handleResize)
+    return () => window.removeEventListener('resize', handleResize)
+  }, [])
+
+  useEffect(() => {
+    if (svgRef.current) {
+      try {
+        svgRef.current.innerHTML = ''
+        const draw = SVG().addTo(svgRef.current).size('100%', '100%')
+
+        const parser = new DOMParser()
+        const svgDoc = parser.parseFromString(content, 'image/svg+xml')
+        const svgElement = svgDoc.documentElement
+
+        if (!(svgElement instanceof SVGElement))
+          throw new Error('Invalid SVG content')
+
+        const originalWidth = parseInt(svgElement.getAttribute('width') || '400', 10)
+        const originalHeight = parseInt(svgElement.getAttribute('height') || '600', 10)
+        const scale = Math.min(windowSize.width / originalWidth, windowSize.height / originalHeight, 1)
+        const scaledWidth = originalWidth * scale
+        const scaledHeight = originalHeight * scale
+        draw.size(scaledWidth, scaledHeight)
+
+        const rootElement = draw.svg(content)
+        rootElement.scale(scale)
+
+        rootElement.click(() => {
+          setImagePreview(svgToDataURL(svgElement as Element))
+        })
+      }
+      catch (error) {
+        if (svgRef.current)
+          svgRef.current.innerHTML = 'Error rendering SVG. Wait for the image content to complete.'
+      }
+    }
+  }, [content, windowSize])
+
+  return (
+    <>
+      <div ref={svgRef} style={{
+        width: '100%',
+        height: '100%',
+        minHeight: '300px',
+        maxHeight: '80vh',
+        display: 'flex',
+        justifyContent: 'center',
+        alignItems: 'center',
+        cursor: 'pointer',
+      }} />
+      {imagePreview && (<ImagePreview url={imagePreview} title='Preview' onCancel={() => setImagePreview('')} />)}
+    </>
+  )
+}
+
+export default SVGRenderer

+ 1 - 0
web/package.json

@@ -44,6 +44,7 @@
     "classnames": "^2.3.2",
     "copy-to-clipboard": "^3.3.3",
     "crypto-js": "^4.2.0",
+    "@svgdotjs/svg.js": "^3.2.4",
     "dayjs": "^1.11.7",
     "echarts": "^5.4.1",
     "echarts-for-react": "^3.0.2",

+ 5 - 0
web/yarn.lock

@@ -1489,6 +1489,11 @@
   dependencies:
     "@sinonjs/commons" "^3.0.0"
 
+"@svgdotjs/svg.js@^3.2.4":
+  version "3.2.4"
+  resolved "https://registry.yarnpkg.com/@svgdotjs/svg.js/-/svg.js-3.2.4.tgz#4716be92a64c66b29921b63f7235fcfb953fb13a"
+  integrity sha512-BjJ/7vWNowlX3Z8O4ywT58DqbNRyYlkk6Yz/D13aB7hGmfQTvGX4Tkgtm/ApYlu9M7lCQi15xUEidqMUmdMYwg==
+
 "@swc/counter@^0.1.3":
   version "0.1.3"
   resolved "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz"