add svg render & Image preview optimization (#8387)

Co-authored-by: crazywoola <427733928@qq.com>
This commit is contained in:
Charlie.Wei 2024-09-14 19:24:53 +08:00 committed by GitHub
parent fa1af8e47b
commit 445497cf89
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 334 additions and 40 deletions

View File

@ -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>,

View File

@ -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
} }
} }

View 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

View File

@ -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",

View File

@ -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"