Co-authored-by: crazywoola <427733928@qq.com>tags/0.8.3
| 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')) { | ||||
| 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 | |||||
| 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 | <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> | </div> | ||||
| </Tooltip> | </Tooltip> | ||||
| </div>, | </div>, |
| 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' | ||||
| 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> = { | ||||
| 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) | ||||
| // 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 || '') | ||||
| > | > | ||||
| <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$/, '')} | ||||
| </div> | </div> | ||||
| {(language === 'mermaid' && isSVG) | {(language === 'mermaid' && isSVG) | ||||
| ? (<Flowchart PrimitiveCode={String(children).replace(/\n$/, '')} />) | ? (<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 | : (<SyntaxHighlighter | ||||
| {...props} | {...props} | ||||
| style={atelierHeathLight} | style={atelierHeathLight} | ||||
| 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 }) => { | ||||
| 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) => { | ||||
| } | } | ||||
| }, | }, | ||||
| ]} | ]} | ||||
| disallowedElements={['script', 'iframe', 'head', 'html', 'meta', 'link', 'style', 'body']} | |||||
| components={{ | components={{ | ||||
| code: CodeBlock, | code: CodeBlock, | ||||
| img: Img, | img: Img, | ||||
| // 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 | ||||
| } | } | ||||
| } | } |
| 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 |
| "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", |
| 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" |