mirror of
https://git.mirrors.martin98.com/https://github.com/langgenius/dify.git
synced 2025-05-21 20:18:57 +08:00
add svg render & Image preview optimization (#8387)
Co-authored-by: crazywoola <427733928@qq.com>
This commit is contained in:
parent
fa1af8e47b
commit
445497cf89
@ -1,26 +1,42 @@
|
|||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
import { useRef } from 'react'
|
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { t } from 'i18next'
|
import { t } from 'i18next'
|
||||||
import { createPortal } from 'react-dom'
|
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 Tooltip from '@/app/components/base/tooltip'
|
||||||
import { randomString } from '@/utils'
|
import Toast from '@/app/components/base/toast'
|
||||||
|
|
||||||
type ImagePreviewProps = {
|
type ImagePreviewProps = {
|
||||||
url: string
|
url: string
|
||||||
title: string
|
title: string
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isBase64 = (str: string): boolean => {
|
||||||
|
try {
|
||||||
|
return btoa(atob(str)) === str
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const ImagePreview: FC<ImagePreviewProps> = ({
|
const ImagePreview: FC<ImagePreviewProps> = ({
|
||||||
url,
|
url,
|
||||||
title,
|
title,
|
||||||
onCancel,
|
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 = () => {
|
const openInNewTab = () => {
|
||||||
// Open in a new window, considering the case when the page is inside an iframe
|
// 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')
|
window.open(url, '_blank')
|
||||||
}
|
}
|
||||||
else if (url.startsWith('data:image')) {
|
else if (url.startsWith('data:image')) {
|
||||||
@ -29,34 +45,224 @@ const ImagePreview: FC<ImagePreviewProps> = ({
|
|||||||
win?.document.write(`<img src="${url}" alt="${title}" />`)
|
win?.document.write(`<img src="${url}" alt="${title}" />`)
|
||||||
}
|
}
|
||||||
else {
|
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(
|
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 */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
|
ref={imgRef}
|
||||||
alt={title}
|
alt={title}
|
||||||
src={url}
|
src={isBase64(url) ? `data:image/png;base64,${url}` : url}
|
||||||
className='max-w-full max-h-full'
|
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
|
<Tooltip popupContent={t('common.operation.copyImage')}>
|
||||||
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'
|
<div className='absolute top-6 right-48 flex items-center justify-center w-8 h-8 rounded-lg cursor-pointer'
|
||||||
onClick={onCancel}
|
onClick={imageCopy}>
|
||||||
>
|
{isCopied
|
||||||
<RiCloseLine className='w-4 h-4 text-white' />
|
? <RiFileCopyLine className='w-4 h-4 text-green-500'/>
|
||||||
</div>
|
: <RiFileCopyLine className='w-4 h-4 text-gray-500'/>}
|
||||||
<Tooltip
|
</div>
|
||||||
selector={selector.current}
|
</Tooltip>
|
||||||
content={(t('common.operation.openInNewTab') ?? 'Open in new tab')}
|
<Tooltip popupContent={t('common.operation.zoomOut')}>
|
||||||
className='z-10'
|
<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
|
<div
|
||||||
className='absolute top-6 right-16 flex items-center justify-center w-8 h-8 rounded-lg cursor-pointer'
|
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={openInNewTab}
|
onClick={onCancel}>
|
||||||
>
|
<RiCloseLine className='w-4 h-4 text-gray-500'/>
|
||||||
<RiExternalLinkLine className='w-4 h-4 text-white' />
|
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>,
|
</div>,
|
||||||
|
@ -5,6 +5,7 @@ import RemarkMath from 'remark-math'
|
|||||||
import RemarkBreaks from 'remark-breaks'
|
import RemarkBreaks from 'remark-breaks'
|
||||||
import RehypeKatex from 'rehype-katex'
|
import RehypeKatex from 'rehype-katex'
|
||||||
import RemarkGfm from 'remark-gfm'
|
import RemarkGfm from 'remark-gfm'
|
||||||
|
import RehypeRaw from 'rehype-raw'
|
||||||
import SyntaxHighlighter from 'react-syntax-highlighter'
|
import SyntaxHighlighter from 'react-syntax-highlighter'
|
||||||
import { atelierHeathLight } from 'react-syntax-highlighter/dist/esm/styles/hljs'
|
import { atelierHeathLight } from 'react-syntax-highlighter/dist/esm/styles/hljs'
|
||||||
import type { RefObject } from 'react'
|
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 { useChatContext } from '@/app/components/base/chat/chat/context'
|
||||||
import VideoGallery from '@/app/components/base/video-gallery'
|
import VideoGallery from '@/app/components/base/video-gallery'
|
||||||
import AudioGallery from '@/app/components/base/audio-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
|
// Available language https://github.com/react-syntax-highlighter/react-syntax-highlighter/blob/master/AVAILABLE_LANGUAGES_HLJS.MD
|
||||||
const capitalizationLanguageNameMap: Record<string, string> = {
|
const capitalizationLanguageNameMap: Record<string, string> = {
|
||||||
@ -40,6 +42,7 @@ const capitalizationLanguageNameMap: Record<string, string> = {
|
|||||||
powershell: 'PowerShell',
|
powershell: 'PowerShell',
|
||||||
json: 'JSON',
|
json: 'JSON',
|
||||||
latex: 'Latex',
|
latex: 'Latex',
|
||||||
|
svg: 'SVG',
|
||||||
}
|
}
|
||||||
const getCorrectCapitalizationLanguageName = (language: string) => {
|
const getCorrectCapitalizationLanguageName = (language: string) => {
|
||||||
if (!language)
|
if (!language)
|
||||||
@ -107,6 +110,7 @@ const useLazyLoad = (ref: RefObject<Element>): boolean => {
|
|||||||
// Error: Minified React error 185;
|
// Error: Minified React error 185;
|
||||||
// visit https://reactjs.org/docs/error-decoder.html?invariant=185 for the full message
|
// 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.
|
// or use the non-minified dev environment for full errors and additional helpful warnings.
|
||||||
|
|
||||||
const CodeBlock: CodeComponent = memo(({ inline, className, children, ...props }) => {
|
const CodeBlock: CodeComponent = memo(({ inline, className, children, ...props }) => {
|
||||||
const [isSVG, setIsSVG] = useState(true)
|
const [isSVG, setIsSVG] = useState(true)
|
||||||
const match = /language-(\w+)/.exec(className || '')
|
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 className='text-[13px] text-gray-500 font-normal'>{languageShowName}</div>
|
||||||
<div style={{ display: 'flex' }}>
|
<div style={{ display: 'flex' }}>
|
||||||
{language === 'mermaid' && <SVGBtn isSVG={isSVG} setIsSVG={setIsSVG} />}
|
{language === 'mermaid' && <SVGBtn isSVG={isSVG} setIsSVG={setIsSVG}/>}
|
||||||
<CopyBtn
|
<CopyBtn
|
||||||
className='mr-1'
|
className='mr-1'
|
||||||
value={String(children).replace(/\n$/, '')}
|
value={String(children).replace(/\n$/, '')}
|
||||||
@ -144,12 +148,10 @@ const CodeBlock: CodeComponent = memo(({ inline, className, children, ...props }
|
|||||||
</div>
|
</div>
|
||||||
{(language === 'mermaid' && isSVG)
|
{(language === 'mermaid' && isSVG)
|
||||||
? (<Flowchart PrimitiveCode={String(children).replace(/\n$/, '')} />)
|
? (<Flowchart PrimitiveCode={String(children).replace(/\n$/, '')} />)
|
||||||
: (
|
: (language === 'echarts'
|
||||||
(language === 'echarts')
|
? (<div style={{ minHeight: '350px', minWidth: '700px' }}><ErrorBoundary><ReactEcharts option={chartData} /></ErrorBoundary></div>)
|
||||||
? (<div style={{ minHeight: '250px', minWidth: '250px' }}><ErrorBoundary><ReactEcharts
|
: (language === 'svg'
|
||||||
option={chartData}
|
? (<ErrorBoundary><SVGRenderer content={String(children).replace(/\n$/, '')} /></ErrorBoundary>)
|
||||||
>
|
|
||||||
</ReactEcharts></ErrorBoundary></div>)
|
|
||||||
: (<SyntaxHighlighter
|
: (<SyntaxHighlighter
|
||||||
{...props}
|
{...props}
|
||||||
style={atelierHeathLight}
|
style={atelierHeathLight}
|
||||||
@ -162,17 +164,12 @@ const CodeBlock: CodeComponent = memo(({ inline, className, children, ...props }
|
|||||||
PreTag="div"
|
PreTag="div"
|
||||||
>
|
>
|
||||||
{String(children).replace(/\n$/, '')}
|
{String(children).replace(/\n$/, '')}
|
||||||
</SyntaxHighlighter>))}
|
</SyntaxHighlighter>)))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
: (
|
: (<code {...props} className={className}>{children}</code>)
|
||||||
<code {...props} className={className}>
|
|
||||||
{children}
|
|
||||||
</code>
|
|
||||||
)
|
|
||||||
}, [chartData, children, className, inline, isSVG, language, languageShowName, match, props])
|
}, [chartData, children, className, inline, isSVG, language, languageShowName, match, props])
|
||||||
})
|
})
|
||||||
|
|
||||||
CodeBlock.displayName = 'CodeBlock'
|
CodeBlock.displayName = 'CodeBlock'
|
||||||
|
|
||||||
const VideoBlock: CodeComponent = memo(({ node }) => {
|
const VideoBlock: CodeComponent = memo(({ node }) => {
|
||||||
@ -230,6 +227,7 @@ export function Markdown(props: { content: string; className?: string }) {
|
|||||||
remarkPlugins={[[RemarkGfm, RemarkMath, { singleDollarTextMath: false }], RemarkBreaks]}
|
remarkPlugins={[[RemarkGfm, RemarkMath, { singleDollarTextMath: false }], RemarkBreaks]}
|
||||||
rehypePlugins={[
|
rehypePlugins={[
|
||||||
RehypeKatex,
|
RehypeKatex,
|
||||||
|
RehypeRaw as any,
|
||||||
// The Rehype plug-in is used to remove the ref attribute of an element
|
// The Rehype plug-in is used to remove the ref attribute of an element
|
||||||
() => {
|
() => {
|
||||||
return (tree) => {
|
return (tree) => {
|
||||||
@ -244,6 +242,7 @@ export function Markdown(props: { content: string; className?: string }) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
disallowedElements={['script', 'iframe', 'head', 'html', 'meta', 'link', 'style', 'body']}
|
||||||
components={{
|
components={{
|
||||||
code: CodeBlock,
|
code: CodeBlock,
|
||||||
img: Img,
|
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.
|
// 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 {
|
export default class ErrorBoundary extends Component {
|
||||||
constructor(props) {
|
constructor(props: any) {
|
||||||
super(props)
|
super(props)
|
||||||
this.state = { hasError: false }
|
this.state = { hasError: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidCatch(error, errorInfo) {
|
componentDidCatch(error: any, errorInfo: any) {
|
||||||
this.setState({ hasError: true })
|
this.setState({ hasError: true })
|
||||||
console.error(error, errorInfo)
|
console.error(error, errorInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-expect-error
|
||||||
if (this.state.hasError)
|
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
|
return this.props.children
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
79
web/app/components/base/svg-gallery/index.tsx
Normal file
79
web/app/components/base/svg-gallery/index.tsx
Normal file
@ -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
|
@ -44,6 +44,7 @@
|
|||||||
"classnames": "^2.3.2",
|
"classnames": "^2.3.2",
|
||||||
"copy-to-clipboard": "^3.3.3",
|
"copy-to-clipboard": "^3.3.3",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
|
"@svgdotjs/svg.js": "^3.2.4",
|
||||||
"dayjs": "^1.11.7",
|
"dayjs": "^1.11.7",
|
||||||
"echarts": "^5.4.1",
|
"echarts": "^5.4.1",
|
||||||
"echarts-for-react": "^3.0.2",
|
"echarts-for-react": "^3.0.2",
|
||||||
|
@ -1489,6 +1489,11 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@sinonjs/commons" "^3.0.0"
|
"@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":
|
"@swc/counter@^0.1.3":
|
||||||
version "0.1.3"
|
version "0.1.3"
|
||||||
resolved "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz"
|
resolved "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user