{
)
}
-CodePreview.displayName = 'CodePreview'
+CodeViewer.displayName = 'CodeViewer'
const plainTokenStyle = {
color: 'inherit',
@@ -296,4 +258,4 @@ const ScrollContainer = styled.div<{
}
`
-export default memo(CodePreview)
+export default memo(CodeViewer)
diff --git a/src/renderer/src/components/Icons/DownloadIcons.tsx b/src/renderer/src/components/Icons/DownloadIcons.tsx
deleted file mode 100644
index 55c6f00f1a..0000000000
--- a/src/renderer/src/components/Icons/DownloadIcons.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-import { SVGProps } from 'react'
-
-// 基础下载图标
-export const DownloadIcon = (props: SVGProps
) => (
-
-)
-
-// 带有文件类型的下载图标基础组件
-const DownloadTypeIconBase = ({ type, ...props }: SVGProps & { type: string }) => (
-
-)
-
-// JPG 文件下载图标
-export const DownloadJpgIcon = (props: SVGProps) =>
-
-// PNG 文件下载图标
-export const DownloadPngIcon = (props: SVGProps) =>
-
-// SVG 文件下载图标
-export const DownloadSvgIcon = (props: SVGProps) =>
diff --git a/src/renderer/src/components/Icons/FileIcons.tsx b/src/renderer/src/components/Icons/FileIcons.tsx
new file mode 100644
index 0000000000..386c823ef4
--- /dev/null
+++ b/src/renderer/src/components/Icons/FileIcons.tsx
@@ -0,0 +1,70 @@
+import { CSSProperties, SVGProps } from 'react'
+
+interface BaseFileIconProps extends SVGProps {
+ size?: string
+ text?: string
+}
+
+const textStyle: CSSProperties = {
+ fontStyle: 'italic',
+ fontSize: '7.70985px',
+ lineHeight: 0.8,
+ fontFamily: "'Times New Roman'",
+ textAlign: 'center',
+ writingMode: 'horizontal-tb',
+ direction: 'ltr',
+ textAnchor: 'middle',
+ fill: 'none',
+ stroke: '#000000',
+ strokeWidth: '0.289119',
+ strokeLinejoin: 'round',
+ strokeDasharray: 'none'
+}
+
+const tspanStyle: CSSProperties = {
+ fontStyle: 'normal',
+ fontVariant: 'normal',
+ fontWeight: 'normal',
+ fontStretch: 'condensed',
+ fontSize: '7.70985px',
+ lineHeight: 0.8,
+ fontFamily: 'Arial',
+ fill: '#000000',
+ fillOpacity: 1,
+ strokeWidth: '0.289119',
+ strokeDasharray: 'none'
+}
+
+const BaseFileIcon = ({ size = '1.1em', text = 'SVG', ...props }: BaseFileIconProps) => (
+
+)
+
+export const FileSvgIcon = (props: Omit) =>
+export const FilePngIcon = (props: Omit) =>
diff --git a/src/renderer/src/components/Icons/index.ts b/src/renderer/src/components/Icons/index.ts
index cc6f4c2b60..94714e73cc 100644
--- a/src/renderer/src/components/Icons/index.ts
+++ b/src/renderer/src/components/Icons/index.ts
@@ -1,8 +1,8 @@
export { default as CopyIcon } from './CopyIcon'
export { default as DeleteIcon } from './DeleteIcon'
-export * from './DownloadIcons'
export { default as EditIcon } from './EditIcon'
export { default as FallbackFavicon } from './FallbackFavicon'
+export * from './FileIcons'
export { default as MinAppIcon } from './MinAppIcon'
export * from './NutstoreIcons'
export { default as OcrIcon } from './OcrIcon'
diff --git a/src/renderer/src/components/ImageViewer.tsx b/src/renderer/src/components/ImageViewer.tsx
index ddb28a4d52..bdb891a074 100644
--- a/src/renderer/src/components/ImageViewer.tsx
+++ b/src/renderer/src/components/ImageViewer.tsx
@@ -86,7 +86,7 @@ const ImageViewer: React.FC = ({ src, style, ...props }) => {
},
{
key: 'copy-image',
- label: t('code_block.preview.copy.image'),
+ label: t('preview.copy.image'),
icon: ,
onClick: () => handleCopyImage(src)
}
@@ -101,6 +101,7 @@ const ImageViewer: React.FC = ({ src, style, ...props }) => {
{...props}
preview={{
mask: typeof props.preview === 'object' ? props.preview.mask : false,
+ ...(typeof props.preview === 'object' ? props.preview : {}),
toolbarRender: (
_,
{
diff --git a/src/renderer/src/components/Preview/GraphvizPreview.tsx b/src/renderer/src/components/Preview/GraphvizPreview.tsx
new file mode 100644
index 0000000000..c3c5c641a2
--- /dev/null
+++ b/src/renderer/src/components/Preview/GraphvizPreview.tsx
@@ -0,0 +1,56 @@
+import { AsyncInitializer } from '@renderer/utils/asyncInitializer'
+import React, { memo, useCallback } from 'react'
+import styled from 'styled-components'
+
+import { useDebouncedRender } from './hooks/useDebouncedRender'
+import ImagePreviewLayout from './ImagePreviewLayout'
+import { BasicPreviewHandles, BasicPreviewProps } from './types'
+import { renderSvgInShadowHost } from './utils'
+
+// 管理 viz 实例
+const vizInitializer = new AsyncInitializer(async () => {
+ const module = await import('@viz-js/viz')
+ return await module.instance()
+})
+
+/** 预览 Graphviz 图表
+ * 使用 usePreviewRenderer hook 大幅简化组件逻辑
+ */
+const GraphvizPreview = ({
+ children,
+ enableToolbar = false,
+ ref
+}: BasicPreviewProps & { ref?: React.RefObject }) => {
+ // 定义渲染函数
+ const renderGraphviz = useCallback(async (content: string, container: HTMLDivElement) => {
+ const viz = await vizInitializer.get()
+ const svg = viz.renderString(content, { format: 'svg' })
+ renderSvgInShadowHost(svg, container)
+ }, [])
+
+ // 使用预览渲染器 hook
+ const { containerRef, error, isLoading } = useDebouncedRender(children, renderGraphviz, {
+ debounceDelay: 300
+ })
+
+ return (
+
+
+
+ )
+}
+
+const StyledGraphviz = styled.div`
+ overflow: auto;
+ position: relative;
+ width: 100%;
+ height: 100%;
+`
+
+export default memo(GraphvizPreview)
diff --git a/src/renderer/src/components/Preview/ImagePreviewLayout.tsx b/src/renderer/src/components/Preview/ImagePreviewLayout.tsx
new file mode 100644
index 0000000000..cff446e250
--- /dev/null
+++ b/src/renderer/src/components/Preview/ImagePreviewLayout.tsx
@@ -0,0 +1,60 @@
+import { useImageTools } from '@renderer/components/ActionTools/hooks/useImageTools'
+import { LoadingIcon } from '@renderer/components/Icons'
+import { Spin } from 'antd'
+import { memo, useImperativeHandle } from 'react'
+
+import ImageToolbar from './ImageToolbar'
+import { PreviewContainer, PreviewError } from './styles'
+import { BasicPreviewHandles } from './types'
+
+interface ImagePreviewLayoutProps {
+ children: React.ReactNode
+ ref?: React.RefObject
+ imageRef: React.RefObject
+ source: string
+ loading?: boolean
+ error?: string | null
+ enableToolbar?: boolean
+ className?: string
+}
+
+const ImagePreviewLayout = ({
+ children,
+ ref,
+ imageRef,
+ source,
+ loading,
+ error,
+ enableToolbar,
+ className
+}: ImagePreviewLayoutProps) => {
+ // 使用通用图像工具
+ const { pan, zoom, copy, download, dialog } = useImageTools(imageRef, {
+ imgSelector: 'svg',
+ prefix: source ?? 'svg',
+ enableDrag: true,
+ enableWheelZoom: true
+ })
+
+ useImperativeHandle(ref, () => {
+ return {
+ pan,
+ zoom,
+ copy,
+ download,
+ dialog
+ }
+ })
+
+ return (
+ }>
+
+ {error && {error}}
+ {children}
+ {!error && enableToolbar && }
+
+
+ )
+}
+
+export default memo(ImagePreviewLayout)
diff --git a/src/renderer/src/components/Preview/ImageToolButton.tsx b/src/renderer/src/components/Preview/ImageToolButton.tsx
new file mode 100644
index 0000000000..e14ae8fee0
--- /dev/null
+++ b/src/renderer/src/components/Preview/ImageToolButton.tsx
@@ -0,0 +1,18 @@
+import { Button, Tooltip } from 'antd'
+import { memo } from 'react'
+
+interface ImageToolButtonProps {
+ tooltip: string
+ icon: React.ReactNode
+ onClick: () => void
+}
+
+const ImageToolButton = ({ tooltip, icon, onClick }: ImageToolButtonProps) => {
+ return (
+
+
+
+ )
+}
+
+export default memo(ImageToolButton)
diff --git a/src/renderer/src/components/Preview/ImageToolbar.tsx b/src/renderer/src/components/Preview/ImageToolbar.tsx
new file mode 100644
index 0000000000..11d9695c25
--- /dev/null
+++ b/src/renderer/src/components/Preview/ImageToolbar.tsx
@@ -0,0 +1,107 @@
+import { ResetIcon } from '@renderer/components/Icons'
+import { classNames } from '@renderer/utils'
+import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Scan, ZoomIn, ZoomOut } from 'lucide-react'
+import { memo, useCallback } from 'react'
+import { useTranslation } from 'react-i18next'
+import styled from 'styled-components'
+
+import ImageToolButton from './ImageToolButton'
+
+interface ImageToolbarProps {
+ pan: (dx: number, dy: number, absolute?: boolean) => void
+ zoom: (delta: number, absolute?: boolean) => void
+ dialog: () => void
+ className?: string
+}
+
+const ImageToolbar = ({ pan, zoom, dialog, className }: ImageToolbarProps) => {
+ const { t } = useTranslation()
+
+ // 定义平移距离
+ const panDistance = 20
+
+ // 定义缩放增量
+ const zoomDelta = 0.1
+
+ const handleReset = useCallback(() => {
+ pan(0, 0, true)
+ zoom(1, true)
+ }, [pan, zoom])
+
+ return (
+
+ {/* Up */}
+
+
+ }
+ onClick={() => pan(0, -panDistance)}
+ />
+ } onClick={dialog} />
+
+
+ {/* Left, Reset, Right */}
+
+ }
+ onClick={() => pan(-panDistance, 0)}
+ />
+ } onClick={handleReset} />
+ }
+ onClick={() => pan(panDistance, 0)}
+ />
+
+
+ {/* Down, Zoom */}
+
+ }
+ onClick={() => zoom(-zoomDelta)}
+ />
+ }
+ onClick={() => pan(0, panDistance)}
+ />
+ }
+ onClick={() => zoom(zoomDelta)}
+ />
+
+
+ )
+}
+
+const ToolbarWrapper = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ position: absolute;
+ gap: 4px;
+ right: 1em;
+ bottom: 1em;
+ z-index: 5;
+
+ .ant-btn {
+ line-height: 0;
+ }
+`
+
+const ActionButtonRow = styled.div`
+ display: flex;
+ justify-content: center;
+ gap: 4px;
+ width: 100%;
+`
+
+const Spacer = styled.div`
+ flex: 1;
+`
+
+export default memo(ImageToolbar)
diff --git a/src/renderer/src/components/Preview/MermaidPreview.tsx b/src/renderer/src/components/Preview/MermaidPreview.tsx
new file mode 100644
index 0000000000..b4bd1b148e
--- /dev/null
+++ b/src/renderer/src/components/Preview/MermaidPreview.tsx
@@ -0,0 +1,120 @@
+import { nanoid } from '@reduxjs/toolkit'
+import { useMermaid } from '@renderer/hooks/useMermaid'
+import React, { memo, useCallback, useEffect, useRef, useState } from 'react'
+import styled from 'styled-components'
+
+import { useDebouncedRender } from './hooks/useDebouncedRender'
+import ImagePreviewLayout from './ImagePreviewLayout'
+import { BasicPreviewHandles, BasicPreviewProps } from './types'
+
+/** 预览 Mermaid 图表
+ * 使用 usePreviewRenderer hook 重构,同时保留必要的可见性检测逻辑
+ * FIXME: 等将来 mermaid-js 修复可见性问题后可以进一步简化
+ */
+const MermaidPreview = ({
+ children,
+ enableToolbar = false,
+ ref
+}: BasicPreviewProps & { ref?: React.RefObject }) => {
+ const { mermaid, isLoading: isLoadingMermaid, error: mermaidError } = useMermaid()
+ const diagramId = useRef(`mermaid-${nanoid(6)}`).current
+ const [isVisible, setIsVisible] = useState(true)
+
+ // 定义渲染函数
+ const renderMermaid = useCallback(
+ async (content: string, container: HTMLDivElement) => {
+ // 验证语法,提前抛出异常
+ await mermaid.parse(content)
+
+ const { svg } = await mermaid.render(diagramId, content, container)
+
+ // 避免不可见时产生 undefined 和 NaN
+ const fixedSvg = svg.replace(/translate\(undefined,\s*NaN\)/g, 'translate(0, 0)')
+ container.innerHTML = fixedSvg
+ },
+ [diagramId, mermaid]
+ )
+
+ // 可见性检测函数
+ const shouldRender = useCallback(() => {
+ return !isLoadingMermaid && isVisible
+ }, [isLoadingMermaid, isVisible])
+
+ // 使用预览渲染器 hook
+ const {
+ containerRef,
+ error: renderError,
+ isLoading: isRendering
+ } = useDebouncedRender(children, renderMermaid, {
+ debounceDelay: 300,
+ shouldRender
+ })
+
+ /**
+ * 监听可见性变化,用于触发重新渲染。
+ * 这是为了解决 `MessageGroup` 组件的 `fold` 布局中被 `display: none` 隐藏的图标无法正确渲染的问题。
+ * 监听时向上遍历到第一个有 `fold` className 的父节点为止(也就是目前的 `MessageWrapper`)。
+ * FIXME: 将来 mermaid-js 修复此问题后可以移除这里的相关逻辑。
+ */
+ useEffect(() => {
+ if (!containerRef.current) return
+
+ const checkVisibility = () => {
+ const element = containerRef.current
+ if (!element) return
+
+ const currentlyVisible = element.offsetParent !== null
+ setIsVisible(currentlyVisible)
+ }
+
+ // 初始检查
+ checkVisibility()
+
+ const observer = new MutationObserver(() => {
+ checkVisibility()
+ })
+
+ let targetElement = containerRef.current.parentElement
+ while (targetElement) {
+ observer.observe(targetElement, {
+ attributes: true,
+ attributeFilter: ['class', 'style']
+ })
+
+ if (targetElement.className?.includes('fold')) {
+ break
+ }
+
+ targetElement = targetElement.parentElement
+ }
+
+ return () => {
+ observer.disconnect()
+ }
+ }, [containerRef])
+
+ // 合并加载状态和错误状态
+ const isLoading = isLoadingMermaid || isRendering
+ const error = mermaidError || renderError
+
+ return (
+
+
+
+ )
+}
+
+const StyledMermaid = styled.div`
+ overflow: auto;
+ position: relative;
+ width: 100%;
+ height: 100%;
+`
+
+export default memo(MermaidPreview)
diff --git a/src/renderer/src/components/Preview/PlantUmlPreview.tsx b/src/renderer/src/components/Preview/PlantUmlPreview.tsx
new file mode 100644
index 0000000000..b94b87e187
--- /dev/null
+++ b/src/renderer/src/components/Preview/PlantUmlPreview.tsx
@@ -0,0 +1,136 @@
+import { loggerService } from '@logger'
+import pako from 'pako'
+import React, { memo, useCallback, useEffect } from 'react'
+
+import { useDebouncedRender } from './hooks/useDebouncedRender'
+import ImagePreviewLayout from './ImagePreviewLayout'
+import { BasicPreviewHandles, BasicPreviewProps } from './types'
+import { renderSvgInShadowHost } from './utils'
+
+const logger = loggerService.withContext('PlantUmlPreview')
+
+const PlantUMLServer = 'https://www.plantuml.com/plantuml'
+function encode64(data: Uint8Array) {
+ let r = ''
+ for (let i = 0; i < data.length; i += 3) {
+ if (i + 2 === data.length) {
+ r += append3bytes(data[i], data[i + 1], 0)
+ } else if (i + 1 === data.length) {
+ r += append3bytes(data[i], 0, 0)
+ } else {
+ r += append3bytes(data[i], data[i + 1], data[i + 2])
+ }
+ }
+ return r
+}
+
+function encode6bit(b: number) {
+ if (b < 10) {
+ return String.fromCharCode(48 + b)
+ }
+ b -= 10
+ if (b < 26) {
+ return String.fromCharCode(65 + b)
+ }
+ b -= 26
+ if (b < 26) {
+ return String.fromCharCode(97 + b)
+ }
+ b -= 26
+ if (b === 0) {
+ return '-'
+ }
+ if (b === 1) {
+ return '_'
+ }
+ return '?'
+}
+
+function append3bytes(b1: number, b2: number, b3: number) {
+ const c1 = b1 >> 2
+ const c2 = ((b1 & 0x3) << 4) | (b2 >> 4)
+ const c3 = ((b2 & 0xf) << 2) | (b3 >> 6)
+ const c4 = b3 & 0x3f
+ let r = ''
+ r += encode6bit(c1 & 0x3f)
+ r += encode6bit(c2 & 0x3f)
+ r += encode6bit(c3 & 0x3f)
+ r += encode6bit(c4 & 0x3f)
+ return r
+}
+/**
+ * https://plantuml.com/zh/code-javascript-synchronous
+ * To use PlantUML image generation, a text diagram description have to be :
+ 1. Encoded in UTF-8
+ 2. Compressed using Deflate algorithm
+ 3. Reencoded in ASCII using a transformation _close_ to base64
+ */
+function encodeDiagram(diagram: string): string {
+ const utf8text = new TextEncoder().encode(diagram)
+ const compressed = pako.deflateRaw(utf8text)
+ return encode64(compressed)
+}
+
+function getPlantUMLImageUrl(format: 'png' | 'svg', diagram: string, isDark?: boolean) {
+ const encodedDiagram = encodeDiagram(diagram)
+ if (isDark) {
+ return `${PlantUMLServer}/d${format}/${encodedDiagram}`
+ }
+ return `${PlantUMLServer}/${format}/${encodedDiagram}`
+}
+
+const PlantUmlPreview = ({
+ children,
+ enableToolbar = false,
+ ref
+}: BasicPreviewProps & { ref?: React.RefObject }) => {
+ // 定义渲染函数
+ const renderPlantUml = useCallback(async (content: string, container: HTMLDivElement) => {
+ const url = getPlantUMLImageUrl('svg', content, false)
+ const response = await fetch(url)
+ if (!response.ok) {
+ if (response.status === 400) {
+ throw new Error(
+ 'Diagram rendering failed (400): This is likely due to a syntax error in the diagram. Please check your code.'
+ )
+ }
+ if (response.status >= 500) {
+ throw new Error(
+ `Diagram rendering failed (${response.status}): The PlantUML server is temporarily unavailable. Please try again later.`
+ )
+ }
+ throw new Error(`Diagram rendering failed, server returned: ${response.status} ${response.statusText}`)
+ }
+
+ const text = await response.text()
+ renderSvgInShadowHost(text, container)
+ }, [])
+
+ // 使用预览渲染器 hook
+ const { containerRef, error, isLoading } = useDebouncedRender(children, renderPlantUml, {
+ debounceDelay: 300
+ })
+
+ // 记录网络错误
+ useEffect(() => {
+ if (error && error.includes('Failed to fetch')) {
+ logger.warn('Network Error: Unable to connect to PlantUML server. Please check your network connection.')
+ } else if (error) {
+ logger.warn(error)
+ }
+ }, [error])
+
+ return (
+
+
+
+ )
+}
+
+export default memo(PlantUmlPreview)
diff --git a/src/renderer/src/components/Preview/SvgPreview.tsx b/src/renderer/src/components/Preview/SvgPreview.tsx
new file mode 100644
index 0000000000..d9a4689fcd
--- /dev/null
+++ b/src/renderer/src/components/Preview/SvgPreview.tsx
@@ -0,0 +1,42 @@
+import { memo, useCallback } from 'react'
+
+import { useDebouncedRender } from './hooks/useDebouncedRender'
+import ImagePreviewLayout from './ImagePreviewLayout'
+import { BasicPreviewHandles } from './types'
+import { renderSvgInShadowHost } from './utils'
+
+interface SvgPreviewProps {
+ children: string
+ enableToolbar?: boolean
+ className?: string
+ ref?: React.RefObject
+}
+
+/**
+ * 使用 Shadow DOM 渲染 SVG
+ */
+const SvgPreview = ({ children, enableToolbar = false, className, ref }: SvgPreviewProps) => {
+ // 定义渲染函数
+ const renderSvg = useCallback(async (content: string, container: HTMLDivElement) => {
+ renderSvgInShadowHost(content, container)
+ }, [])
+
+ // 使用预览渲染器 hook
+ const { containerRef, error, isLoading } = useDebouncedRender(children, renderSvg, {
+ debounceDelay: 300
+ })
+
+ return (
+
+
+
+ )
+}
+
+export default memo(SvgPreview)
diff --git a/src/renderer/src/components/Preview/__tests__/GraphvizPreview.test.tsx b/src/renderer/src/components/Preview/__tests__/GraphvizPreview.test.tsx
new file mode 100644
index 0000000000..9853221b10
--- /dev/null
+++ b/src/renderer/src/components/Preview/__tests__/GraphvizPreview.test.tsx
@@ -0,0 +1,140 @@
+import GraphvizPreview from '@renderer/components/Preview/GraphvizPreview'
+import { render, screen } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+// Use vi.hoisted to manage mocks
+const mocks = vi.hoisted(() => ({
+ vizInstance: {
+ renderSVGElement: vi.fn()
+ },
+ vizInitializer: {
+ get: vi.fn()
+ },
+ ImagePreviewLayout: vi.fn(({ children, loading, error, enableToolbar, source }) => (
+
+ {enableToolbar &&
Toolbar
}
+ {loading &&
Loading...
}
+ {error &&
{error}
}
+
{children}
+
+ )),
+ useDebouncedRender: vi.fn()
+}))
+
+vi.mock('@renderer/components/Preview/ImagePreviewLayout', () => ({
+ default: mocks.ImagePreviewLayout
+}))
+
+vi.mock('@renderer/utils/asyncInitializer', () => ({
+ AsyncInitializer: class {
+ constructor() {
+ return mocks.vizInitializer
+ }
+ }
+}))
+
+vi.mock('@renderer/components/Preview/hooks/useDebouncedRender', () => ({
+ useDebouncedRender: mocks.useDebouncedRender
+}))
+
+describe('GraphvizPreview', () => {
+ const dotCode = 'digraph { a -> b }'
+ const mockContainerRef = { current: document.createElement('div') }
+
+ // Helper function to create mock useDebouncedRender return value
+ const createMockHookReturn = (overrides = {}) => ({
+ containerRef: mockContainerRef,
+ error: null,
+ isLoading: false,
+ triggerRender: vi.fn(),
+ cancelRender: vi.fn(),
+ clearError: vi.fn(),
+ setLoading: vi.fn(),
+ ...overrides
+ })
+
+ beforeEach(() => {
+ // Setup default successful state
+ mocks.useDebouncedRender.mockReturnValue(createMockHookReturn())
+ })
+
+ afterEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('basic rendering', () => {
+ it('should match snapshot', () => {
+ const { container } = render({dotCode})
+ expect(container).toMatchSnapshot()
+ })
+
+ it('should handle valid dot code', () => {
+ render({dotCode})
+
+ // Component should render without throwing
+ expect(screen.getByTestId('image-preview-layout')).toBeInTheDocument()
+ expect(mocks.useDebouncedRender).toHaveBeenCalledWith(
+ dotCode,
+ expect.any(Function),
+ expect.objectContaining({ debounceDelay: 300 })
+ )
+ })
+
+ it('should handle empty content', () => {
+ render({''})
+
+ // Component should render without throwing
+ expect(screen.getByTestId('image-preview-layout')).toBeInTheDocument()
+ expect(mocks.useDebouncedRender).toHaveBeenCalledWith('', expect.any(Function), expect.any(Object))
+ })
+ })
+
+ describe('loading state', () => {
+ it('should show loading indicator when rendering', () => {
+ mocks.useDebouncedRender.mockReturnValue(createMockHookReturn({ isLoading: true }))
+
+ render({dotCode})
+
+ expect(screen.getByTestId('loading')).toBeInTheDocument()
+ })
+
+ it('should not show loading indicator when not rendering', () => {
+ mocks.useDebouncedRender.mockReturnValue(createMockHookReturn({ isLoading: false }))
+
+ render({dotCode})
+
+ expect(screen.queryByTestId('loading')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('error handling', () => {
+ it('should show error message when rendering fails', () => {
+ const errorMessage = 'Invalid dot syntax'
+ mocks.useDebouncedRender.mockReturnValue(createMockHookReturn({ error: errorMessage }))
+
+ render({dotCode})
+
+ const errorElement = screen.getByTestId('error')
+ expect(errorElement).toBeInTheDocument()
+ expect(errorElement).toHaveTextContent(errorMessage)
+ })
+
+ it('should not show error when rendering is successful', () => {
+ mocks.useDebouncedRender.mockReturnValue(createMockHookReturn({ error: null }))
+
+ render({dotCode})
+
+ expect(screen.queryByTestId('error')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('ref forwarding', () => {
+ it('should forward ref to ImagePreviewLayout', () => {
+ const ref = { current: null }
+ render({dotCode})
+
+ // The ref should be passed to ImagePreviewLayout
+ expect(mocks.ImagePreviewLayout).toHaveBeenCalledWith(expect.objectContaining({ ref }), undefined)
+ })
+ })
+})
diff --git a/src/renderer/src/components/Preview/__tests__/ImagePreviewLayout.test.tsx b/src/renderer/src/components/Preview/__tests__/ImagePreviewLayout.test.tsx
new file mode 100644
index 0000000000..82d05e970a
--- /dev/null
+++ b/src/renderer/src/components/Preview/__tests__/ImagePreviewLayout.test.tsx
@@ -0,0 +1,122 @@
+import { render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import ImagePreviewLayout from '../ImagePreviewLayout'
+
+const mocks = vi.hoisted(() => ({
+ useImageTools: vi.fn(() => ({
+ pan: vi.fn(),
+ zoom: vi.fn(),
+ copy: vi.fn(),
+ download: vi.fn(),
+ dialog: vi.fn()
+ }))
+}))
+
+// Mock antd components
+vi.mock('antd', () => ({
+ Spin: ({ children, spinning }: any) => (
+
+ {children}
+
+ )
+}))
+
+vi.mock('@renderer/components/Icons', () => ({
+ LoadingIcon: () => Spinner
+}))
+
+// Mock ImageToolbar
+vi.mock('../ImageToolbar', () => ({
+ default: () => ImageToolbar
+}))
+
+// Mock styles
+vi.mock('../styles', () => ({
+ PreviewContainer: ({ children, vertical }: any) => (
+
+ {children}
+
+ ),
+ PreviewError: ({ children }: any) => {children}
+}))
+
+// Mock useImageTools
+vi.mock('@renderer/components/ActionTools/hooks/useImageTools', () => ({
+ useImageTools: mocks.useImageTools
+}))
+
+describe('ImagePreviewLayout', () => {
+ const mockImageRef = { current: null }
+
+ const defaultProps = {
+ imageRef: mockImageRef,
+ source: 'test-source',
+ children: Test Content
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should match snapshot', () => {
+ const { container } = render()
+ expect(container).toMatchSnapshot()
+ })
+
+ it('should render children correctly', () => {
+ render()
+ expect(screen.getByText('Test Content')).toBeInTheDocument()
+ })
+
+ it('should show loading state when loading is true', () => {
+ render()
+ expect(screen.getByTestId('spin')).toHaveAttribute('data-spinning', 'true')
+ })
+
+ it('should not show loading state when loading is false', () => {
+ render()
+ expect(screen.getByTestId('spin')).toHaveAttribute('data-spinning', 'false')
+ })
+
+ it('should display error message when error is provided', () => {
+ const errorMessage = 'Test error message'
+ render()
+ expect(screen.getByText(errorMessage)).toBeInTheDocument()
+ })
+
+ it('should not display error message when error is null', () => {
+ render()
+ expect(screen.queryByText('preview-error')).not.toBeInTheDocument()
+ })
+
+ it('should render ImageToolbar when enableToolbar is true and no error', () => {
+ render()
+ expect(screen.getByTestId('image-toolbar')).toBeInTheDocument()
+ })
+
+ it('should not render ImageToolbar when enableToolbar is false', () => {
+ render()
+ expect(screen.queryByTestId('image-toolbar')).not.toBeInTheDocument()
+ })
+
+ it('should not render ImageToolbar when there is an error', () => {
+ render()
+ expect(screen.queryByTestId('image-toolbar')).not.toBeInTheDocument()
+ })
+
+ it('should call useImageTools with correct parameters', () => {
+ render()
+
+ // Verify useImageTools was called with correct parameters
+ expect(mocks.useImageTools).toHaveBeenCalledWith(
+ mockImageRef,
+ expect.objectContaining({
+ imgSelector: 'svg',
+ prefix: 'test-source',
+ enableDrag: true,
+ enableWheelZoom: true
+ })
+ )
+ })
+})
diff --git a/src/renderer/src/components/Preview/__tests__/ImageToolButton.test.tsx b/src/renderer/src/components/Preview/__tests__/ImageToolButton.test.tsx
new file mode 100644
index 0000000000..d5a9a266bc
--- /dev/null
+++ b/src/renderer/src/components/Preview/__tests__/ImageToolButton.test.tsx
@@ -0,0 +1,31 @@
+import { render } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import ImageToolButton from '../ImageToolButton'
+
+// Mock antd components
+vi.mock('antd', () => ({
+ Button: vi.fn(({ children, onClick, ...props }) => (
+
+ )),
+ Tooltip: vi.fn(({ children, title }) => {children}
)
+}))
+
+describe('ImageToolButton', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ const defaultProps = {
+ tooltip: 'Test tooltip',
+ icon: Icon,
+ onClick: vi.fn()
+ }
+
+ it('should match snapshot', () => {
+ const { asFragment } = render()
+ expect(asFragment()).toMatchSnapshot()
+ })
+})
diff --git a/src/renderer/src/components/Preview/__tests__/ImageToolbar.test.tsx b/src/renderer/src/components/Preview/__tests__/ImageToolbar.test.tsx
new file mode 100644
index 0000000000..a64076e3a4
--- /dev/null
+++ b/src/renderer/src/components/Preview/__tests__/ImageToolbar.test.tsx
@@ -0,0 +1,96 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import ImageToolbar from '../ImageToolbar'
+
+// Mock react-i18next
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string) => key
+ })
+}))
+
+// Mock ImageToolButton
+vi.mock('../ImageToolButton', () => ({
+ default: vi.fn(({ tooltip, onClick, icon }) => (
+
+ ))
+}))
+
+// Mock lucide-react icons
+vi.mock('lucide-react', () => ({
+ ChevronUp: () => ↑,
+ ChevronDown: () => ↓,
+ ChevronLeft: () => ←,
+ ChevronRight: () => →,
+ ZoomIn: () => +,
+ ZoomOut: () => -,
+ Scan: () => ⊞
+}))
+
+vi.mock('@renderer/components/Icons', () => ({
+ ResetIcon: () => ↻
+}))
+
+// Mock utils
+vi.mock('@renderer/utils', () => ({
+ classNames: (...args: any[]) => args.filter(Boolean).join(' ')
+}))
+
+describe('ImageToolbar', () => {
+ const mockPan = vi.fn()
+ const mockZoom = vi.fn()
+ const mockOpenDialog = vi.fn()
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('should match snapshot', () => {
+ const { asFragment } = render()
+ expect(asFragment()).toMatchSnapshot()
+ })
+
+ it('calls onPan with correct values when pan buttons are clicked', () => {
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: 'preview.pan_up' }))
+ expect(mockPan).toHaveBeenCalledWith(0, -20)
+
+ fireEvent.click(screen.getByRole('button', { name: 'preview.pan_down' }))
+ expect(mockPan).toHaveBeenCalledWith(0, 20)
+
+ fireEvent.click(screen.getByRole('button', { name: 'preview.pan_left' }))
+ expect(mockPan).toHaveBeenCalledWith(-20, 0)
+
+ fireEvent.click(screen.getByRole('button', { name: 'preview.pan_right' }))
+ expect(mockPan).toHaveBeenCalledWith(20, 0)
+ })
+
+ it('calls onZoom with correct values when zoom buttons are clicked', () => {
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: 'preview.zoom_in' }))
+ expect(mockZoom).toHaveBeenCalledWith(0.1)
+
+ fireEvent.click(screen.getByRole('button', { name: 'preview.zoom_out' }))
+ expect(mockZoom).toHaveBeenCalledWith(-0.1)
+ })
+
+ it('calls onReset with correct values when reset button is clicked', () => {
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: 'preview.reset' }))
+ expect(mockPan).toHaveBeenCalledWith(0, 0, true)
+ expect(mockZoom).toHaveBeenCalledWith(1, true)
+ })
+
+ it('calls onOpenDialog when dialog button is clicked', () => {
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: 'preview.dialog' }))
+ expect(mockOpenDialog).toHaveBeenCalled()
+ })
+})
diff --git a/src/renderer/src/components/Preview/__tests__/MermaidPreview.test.tsx b/src/renderer/src/components/Preview/__tests__/MermaidPreview.test.tsx
new file mode 100644
index 0000000000..17ada0668c
--- /dev/null
+++ b/src/renderer/src/components/Preview/__tests__/MermaidPreview.test.tsx
@@ -0,0 +1,259 @@
+import { render, screen } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
+
+import { MermaidPreview } from '..'
+
+const mocks = vi.hoisted(() => ({
+ useMermaid: vi.fn(),
+ useDebouncedRender: vi.fn(),
+ ImagePreviewLayout: vi.fn(({ children, loading, error, enableToolbar, source }) => (
+
+ {enableToolbar &&
Toolbar
}
+ {loading &&
Loading...
}
+ {error &&
{error}
}
+
{children}
+
+ ))
+}))
+
+// Mock hooks
+vi.mock('@renderer/hooks/useMermaid', () => ({
+ useMermaid: () => mocks.useMermaid()
+}))
+
+vi.mock('@renderer/components/Preview/ImagePreviewLayout', () => ({
+ default: mocks.ImagePreviewLayout
+}))
+
+vi.mock('@renderer/components/Preview/hooks/useDebouncedRender', () => ({
+ useDebouncedRender: mocks.useDebouncedRender
+}))
+
+// Mock nanoid
+vi.mock('@reduxjs/toolkit', () => ({
+ nanoid: () => 'test-id-123456'
+}))
+
+describe('MermaidPreview', () => {
+ const mermaidCode = 'graph TD\nA-->B'
+ const mockContainerRef = { current: document.createElement('div') }
+
+ const mockMermaid = {
+ parse: vi.fn(),
+ render: vi.fn()
+ }
+
+ // Helper function to create mock useDebouncedRender return value
+ const createMockHookReturn = (overrides = {}) => ({
+ containerRef: mockContainerRef,
+ error: null,
+ isLoading: false,
+ triggerRender: vi.fn(),
+ cancelRender: vi.fn(),
+ clearError: vi.fn(),
+ setLoading: vi.fn(),
+ ...overrides
+ })
+
+ beforeEach(() => {
+ // Setup default mocks
+ mocks.useMermaid.mockReturnValue({
+ mermaid: mockMermaid,
+ isLoading: false,
+ error: null
+ })
+
+ mocks.useDebouncedRender.mockReturnValue(createMockHookReturn())
+
+ mockMermaid.parse.mockResolvedValue(true)
+ mockMermaid.render.mockResolvedValue({
+ svg: ''
+ })
+
+ // Mock MutationObserver
+ global.MutationObserver = vi.fn().mockImplementation(() => ({
+ observe: vi.fn(),
+ disconnect: vi.fn(),
+ takeRecords: vi.fn()
+ }))
+ })
+
+ afterEach(() => {
+ vi.clearAllMocks()
+ vi.restoreAllMocks()
+ })
+
+ describe('basic rendering', () => {
+ it('should match snapshot', () => {
+ const { container } = render({mermaidCode})
+ expect(container).toMatchSnapshot()
+ })
+
+ it('should handle valid mermaid content', () => {
+ render({mermaidCode})
+
+ expect(screen.getByTestId('image-preview-layout')).toBeInTheDocument()
+ expect(mocks.useDebouncedRender).toHaveBeenCalledWith(
+ mermaidCode,
+ expect.any(Function),
+ expect.objectContaining({
+ debounceDelay: 300,
+ shouldRender: expect.any(Function)
+ })
+ )
+ })
+
+ it('should handle empty content', () => {
+ render({''})
+
+ expect(screen.getByTestId('image-preview-layout')).toBeInTheDocument()
+ expect(mocks.useDebouncedRender).toHaveBeenCalledWith('', expect.any(Function), expect.any(Object))
+ })
+ })
+
+ describe('loading state', () => {
+ it('should show loading when useMermaid is loading', () => {
+ mocks.useMermaid.mockReturnValue({
+ mermaid: mockMermaid,
+ isLoading: true,
+ error: null
+ })
+
+ render({mermaidCode})
+
+ expect(screen.getByTestId('loading')).toBeInTheDocument()
+ })
+
+ it('should show loading when useDebouncedRender is loading', () => {
+ mocks.useDebouncedRender.mockReturnValue(createMockHookReturn({ isLoading: true }))
+
+ render({mermaidCode})
+
+ expect(screen.getByTestId('loading')).toBeInTheDocument()
+ })
+
+ it('should not show loading when both are not loading', () => {
+ render({mermaidCode})
+
+ expect(screen.queryByTestId('loading')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('error handling', () => {
+ it('should show error from useMermaid', () => {
+ const mermaidError = 'Mermaid initialization failed'
+ mocks.useMermaid.mockReturnValue({
+ mermaid: mockMermaid,
+ isLoading: false,
+ error: mermaidError
+ })
+
+ render({mermaidCode})
+
+ const errorElement = screen.getByTestId('error')
+ expect(errorElement).toBeInTheDocument()
+ expect(errorElement).toHaveTextContent(mermaidError)
+ })
+
+ it('should show error from useDebouncedRender', () => {
+ const renderError = 'Diagram rendering failed'
+ mocks.useDebouncedRender.mockReturnValue(createMockHookReturn({ error: renderError }))
+
+ render({mermaidCode})
+
+ const errorElement = screen.getByTestId('error')
+ expect(errorElement).toBeInTheDocument()
+ expect(errorElement).toHaveTextContent(renderError)
+ })
+
+ it('should prioritize useMermaid error over render error', () => {
+ const mermaidError = 'Mermaid initialization failed'
+ const renderError = 'Diagram rendering failed'
+
+ mocks.useMermaid.mockReturnValue({
+ mermaid: mockMermaid,
+ isLoading: false,
+ error: mermaidError
+ })
+ mocks.useDebouncedRender.mockReturnValue(createMockHookReturn({ error: renderError }))
+
+ render({mermaidCode})
+
+ const errorElement = screen.getByTestId('error')
+ expect(errorElement).toHaveTextContent(mermaidError)
+ })
+ })
+
+ describe('ref forwarding', () => {
+ it('should forward ref to ImagePreviewLayout', () => {
+ const ref = { current: null }
+ render({mermaidCode})
+
+ expect(mocks.ImagePreviewLayout).toHaveBeenCalledWith(expect.objectContaining({ ref }), undefined)
+ })
+ })
+
+ describe('visibility detection', () => {
+ it('should observe parent elements up to fold className', () => {
+ // Create a DOM structure that simulates MessageGroup fold layout
+ const foldContainer = document.createElement('div')
+ foldContainer.className = 'fold selected'
+
+ const messageWrapper = document.createElement('div')
+ messageWrapper.className = 'message-wrapper'
+
+ const codeBlock = document.createElement('div')
+ codeBlock.className = 'code-block'
+
+ foldContainer.appendChild(messageWrapper)
+ messageWrapper.appendChild(codeBlock)
+ document.body.appendChild(foldContainer)
+
+ try {
+ render({mermaidCode}, {
+ container: codeBlock
+ })
+
+ const observerInstance = (global.MutationObserver as Mock).mock.results[0]?.value
+ expect(observerInstance.observe).toHaveBeenCalled()
+ } finally {
+ // Cleanup
+ document.body.removeChild(foldContainer)
+ }
+ })
+
+ it('should handle visibility changes and trigger re-render', () => {
+ const mockTriggerRender = vi.fn()
+ mocks.useDebouncedRender.mockReturnValue(createMockHookReturn({ triggerRender: mockTriggerRender }))
+
+ const { container } = render({mermaidCode})
+
+ // Get the MutationObserver callback
+ const observerCallback = (global.MutationObserver as Mock).mock.calls[0][0]
+
+ // Mock the container element to be initially hidden
+ const mermaidElement = container.querySelector('.mermaid')
+ Object.defineProperty(mermaidElement, 'offsetParent', {
+ get: () => null, // Hidden
+ configurable: true
+ })
+
+ // Simulate MutationObserver detecting visibility change
+ observerCallback([])
+
+ // Now make it visible
+ Object.defineProperty(mermaidElement, 'offsetParent', {
+ get: () => document.body, // Visible
+ configurable: true
+ })
+
+ // Simulate another MutationObserver callback for visibility change
+ observerCallback([])
+
+ // The visibility change should have been detected and component should be ready to re-render
+ // We verify the component structure is correct for potential re-rendering
+ expect(screen.getByTestId('image-preview-layout')).toBeInTheDocument()
+ expect(mermaidElement).toBeInTheDocument()
+ })
+ })
+})
diff --git a/src/renderer/src/components/Preview/__tests__/PlantUmlPreview.test.tsx b/src/renderer/src/components/Preview/__tests__/PlantUmlPreview.test.tsx
new file mode 100644
index 0000000000..08447046ec
--- /dev/null
+++ b/src/renderer/src/components/Preview/__tests__/PlantUmlPreview.test.tsx
@@ -0,0 +1,169 @@
+import PlantUmlPreview from '@renderer/components/Preview/PlantUmlPreview'
+import { render, screen } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+// Use vi.hoisted to manage mocks
+const mocks = vi.hoisted(() => ({
+ ImagePreviewLayout: vi.fn(({ children, loading, error, enableToolbar, source }) => (
+
+ {enableToolbar &&
Toolbar
}
+ {loading &&
Loading...
}
+ {error &&
{error}
}
+
{children}
+
+ )),
+ renderSvgInShadowHost: vi.fn(),
+ useDebouncedRender: vi.fn(),
+ logger: {
+ warn: vi.fn()
+ }
+}))
+
+vi.mock('@renderer/components/Preview/ImagePreviewLayout', () => ({
+ default: mocks.ImagePreviewLayout
+}))
+
+vi.mock('@renderer/components/Preview/utils', () => ({
+ renderSvgInShadowHost: mocks.renderSvgInShadowHost
+}))
+
+vi.mock('@renderer/components/Preview/hooks/useDebouncedRender', () => ({
+ useDebouncedRender: mocks.useDebouncedRender
+}))
+
+describe('PlantUmlPreview', () => {
+ const diagram = '@startuml\nA -> B\n@enduml'
+ const mockContainerRef = { current: document.createElement('div') }
+
+ // Helper function to create mock useDebouncedRender return value
+ const createMockHookReturn = (overrides = {}) => ({
+ containerRef: mockContainerRef,
+ error: null,
+ isLoading: false,
+ triggerRender: vi.fn(),
+ cancelRender: vi.fn(),
+ clearError: vi.fn(),
+ setLoading: vi.fn(),
+ ...overrides
+ })
+
+ beforeEach(() => {
+ // Setup default successful state
+ mocks.useDebouncedRender.mockReturnValue(createMockHookReturn())
+ })
+
+ afterEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('basic rendering', () => {
+ it('should match snapshot', () => {
+ const { container } = render({diagram})
+ expect(container).toMatchSnapshot()
+ })
+
+ it('should handle valid plantuml diagram', () => {
+ render({diagram})
+
+ // Component should render without throwing
+ expect(screen.getByTestId('image-preview-layout')).toBeInTheDocument()
+ expect(mocks.useDebouncedRender).toHaveBeenCalledWith(
+ diagram,
+ expect.any(Function),
+ expect.objectContaining({ debounceDelay: 300 })
+ )
+ })
+
+ it('should handle empty content', () => {
+ render({''})
+
+ // Component should render without throwing
+ expect(screen.getByTestId('image-preview-layout')).toBeInTheDocument()
+ expect(mocks.useDebouncedRender).toHaveBeenCalledWith('', expect.any(Function), expect.any(Object))
+ })
+ })
+
+ describe('loading state', () => {
+ it('should show loading indicator when rendering', () => {
+ mocks.useDebouncedRender.mockReturnValue(createMockHookReturn({ isLoading: true }))
+
+ render({diagram})
+
+ expect(screen.getByTestId('loading')).toBeInTheDocument()
+ })
+
+ it('should not show loading indicator when not rendering', () => {
+ mocks.useDebouncedRender.mockReturnValue(createMockHookReturn({ isLoading: false }))
+
+ render({diagram})
+
+ expect(screen.queryByTestId('loading')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('error handling', () => {
+ it('should show network error message', () => {
+ const networkError = 'Network Error: Unable to connect to PlantUML server. Please check your network connection.'
+ mocks.useDebouncedRender.mockReturnValue(createMockHookReturn({ error: networkError }))
+
+ render({diagram})
+
+ const errorElement = screen.getByTestId('error')
+ expect(errorElement).toBeInTheDocument()
+ expect(errorElement).toHaveTextContent(networkError)
+ })
+
+ it('should show syntax error message for invalid diagram', () => {
+ const syntaxError =
+ 'Diagram rendering failed (400): This is likely due to a syntax error in the diagram. Please check your code.'
+ mocks.useDebouncedRender.mockReturnValue(createMockHookReturn({ error: syntaxError }))
+
+ render({diagram})
+
+ const errorElement = screen.getByTestId('error')
+ expect(errorElement).toBeInTheDocument()
+ expect(errorElement).toHaveTextContent(syntaxError)
+ })
+
+ it('should show server error message', () => {
+ const serverError =
+ 'Diagram rendering failed (503): The PlantUML server is temporarily unavailable. Please try again later.'
+ mocks.useDebouncedRender.mockReturnValue(createMockHookReturn({ error: serverError }))
+
+ render({diagram})
+
+ const errorElement = screen.getByTestId('error')
+ expect(errorElement).toBeInTheDocument()
+ expect(errorElement).toHaveTextContent(serverError)
+ })
+
+ it('should show generic error message for other errors', () => {
+ const genericError = "Diagram rendering failed, server returned: 418 I'm a teapot"
+ mocks.useDebouncedRender.mockReturnValue(createMockHookReturn({ error: genericError }))
+
+ render({diagram})
+
+ const errorElement = screen.getByTestId('error')
+ expect(errorElement).toBeInTheDocument()
+ expect(errorElement).toHaveTextContent(genericError)
+ })
+
+ it('should not show error when rendering is successful', () => {
+ mocks.useDebouncedRender.mockReturnValue(createMockHookReturn({ error: null }))
+
+ render({diagram})
+
+ expect(screen.queryByTestId('error')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('ref forwarding', () => {
+ it('should forward ref to ImagePreviewLayout', () => {
+ const ref = { current: null }
+ render({diagram})
+
+ // The ref should be passed to ImagePreviewLayout
+ expect(mocks.ImagePreviewLayout).toHaveBeenCalledWith(expect.objectContaining({ ref }), undefined)
+ })
+ })
+})
diff --git a/src/renderer/src/components/Preview/__tests__/SvgPreview.test.tsx b/src/renderer/src/components/Preview/__tests__/SvgPreview.test.tsx
new file mode 100644
index 0000000000..e4a32d7c1c
--- /dev/null
+++ b/src/renderer/src/components/Preview/__tests__/SvgPreview.test.tsx
@@ -0,0 +1,149 @@
+import SvgPreview from '@renderer/components/Preview/SvgPreview'
+import { render, screen } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+// Use vi.hoisted to manage mocks
+const mocks = vi.hoisted(() => ({
+ ImagePreviewLayout: vi.fn(({ children, loading, error, enableToolbar, source }) => (
+
+ {enableToolbar &&
Toolbar
}
+ {loading &&
Loading...
}
+ {error &&
{error}
}
+
{children}
+
+ )),
+ renderSvgInShadowHost: vi.fn(),
+ useDebouncedRender: vi.fn()
+}))
+
+vi.mock('@renderer/components/Preview/ImagePreviewLayout', () => ({
+ default: mocks.ImagePreviewLayout
+}))
+
+vi.mock('@renderer/components/Preview/utils', () => ({
+ renderSvgInShadowHost: mocks.renderSvgInShadowHost
+}))
+
+vi.mock('@renderer/components/Preview/hooks/useDebouncedRender', () => ({
+ useDebouncedRender: mocks.useDebouncedRender
+}))
+
+describe('SvgPreview', () => {
+ const svgContent = ''
+ const mockContainerRef = { current: document.createElement('div') }
+
+ // Helper function to create mock useDebouncedRender return value
+ const createMockHookReturn = (overrides = {}) => ({
+ containerRef: mockContainerRef,
+ error: null,
+ isLoading: false,
+ triggerRender: vi.fn(),
+ cancelRender: vi.fn(),
+ clearError: vi.fn(),
+ setLoading: vi.fn(),
+ ...overrides
+ })
+
+ beforeEach(() => {
+ // Setup default successful state
+ mocks.useDebouncedRender.mockReturnValue(createMockHookReturn())
+ })
+
+ afterEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('basic rendering', () => {
+ it('should match snapshot', () => {
+ const { container } = render({svgContent})
+ expect(container).toMatchSnapshot()
+ })
+
+ it('should handle valid svg content', () => {
+ render({svgContent})
+
+ // Component should render without throwing
+ expect(screen.getByTestId('image-preview-layout')).toBeInTheDocument()
+ expect(mocks.useDebouncedRender).toHaveBeenCalledWith(
+ svgContent,
+ expect.any(Function),
+ expect.objectContaining({ debounceDelay: 300 })
+ )
+ })
+
+ it('should handle empty content', () => {
+ render({''})
+
+ // Component should render without throwing
+ expect(screen.getByTestId('image-preview-layout')).toBeInTheDocument()
+ expect(mocks.useDebouncedRender).toHaveBeenCalledWith('', expect.any(Function), expect.any(Object))
+ })
+ })
+
+ describe('loading state', () => {
+ it('should show loading indicator when rendering', () => {
+ mocks.useDebouncedRender.mockReturnValue(createMockHookReturn({ isLoading: true }))
+
+ render({svgContent})
+
+ expect(screen.getByTestId('loading')).toBeInTheDocument()
+ })
+
+ it('should not show loading indicator when not rendering', () => {
+ mocks.useDebouncedRender.mockReturnValue(createMockHookReturn({ isLoading: false }))
+
+ render({svgContent})
+
+ expect(screen.queryByTestId('loading')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('error handling', () => {
+ it('should show error message when rendering fails', () => {
+ const errorMessage = 'Invalid SVG content'
+ mocks.useDebouncedRender.mockReturnValue(createMockHookReturn({ error: errorMessage }))
+
+ render({svgContent})
+
+ const errorElement = screen.getByTestId('error')
+ expect(errorElement).toBeInTheDocument()
+ expect(errorElement).toHaveTextContent(errorMessage)
+ })
+
+ it('should not show error when rendering is successful', () => {
+ mocks.useDebouncedRender.mockReturnValue(createMockHookReturn({ error: null }))
+
+ render({svgContent})
+
+ expect(screen.queryByTestId('error')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('custom styling', () => {
+ it('should use custom className when provided', () => {
+ render({svgContent})
+
+ const content = screen.getByTestId('preview-content')
+ const svgContainer = content.querySelector('.custom-svg-class')
+ expect(svgContainer).toBeInTheDocument()
+ })
+
+ it('should use default className when not provided', () => {
+ render({svgContent})
+
+ const content = screen.getByTestId('preview-content')
+ const svgContainer = content.querySelector('.svg-preview.special-preview')
+ expect(svgContainer).toBeInTheDocument()
+ })
+ })
+
+ describe('ref forwarding', () => {
+ it('should forward ref to ImagePreviewLayout', () => {
+ const ref = { current: null }
+ render({svgContent})
+
+ // The ref should be passed to ImagePreviewLayout
+ expect(mocks.ImagePreviewLayout).toHaveBeenCalledWith(expect.objectContaining({ ref }), undefined)
+ })
+ })
+})
diff --git a/src/renderer/src/components/Preview/__tests__/__snapshots__/GraphvizPreview.test.tsx.snap b/src/renderer/src/components/Preview/__tests__/__snapshots__/GraphvizPreview.test.tsx.snap
new file mode 100644
index 0000000000..923f35b9e7
--- /dev/null
+++ b/src/renderer/src/components/Preview/__tests__/__snapshots__/GraphvizPreview.test.tsx.snap
@@ -0,0 +1,30 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`GraphvizPreview > basic rendering > should match snapshot 1`] = `
+.c0 {
+ overflow: auto;
+ position: relative;
+ width: 100%;
+ height: 100%;
+}
+
+
+`;
diff --git a/src/renderer/src/components/Preview/__tests__/__snapshots__/ImagePreviewLayout.test.tsx.snap b/src/renderer/src/components/Preview/__tests__/__snapshots__/ImagePreviewLayout.test.tsx.snap
new file mode 100644
index 0000000000..13e847fc31
--- /dev/null
+++ b/src/renderer/src/components/Preview/__tests__/__snapshots__/ImagePreviewLayout.test.tsx.snap
@@ -0,0 +1,18 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`ImagePreviewLayout > should match snapshot 1`] = `
+
+`;
diff --git a/src/renderer/src/components/Preview/__tests__/__snapshots__/ImageToolButton.test.tsx.snap b/src/renderer/src/components/Preview/__tests__/__snapshots__/ImageToolButton.test.tsx.snap
new file mode 100644
index 0000000000..0f155f1ff1
--- /dev/null
+++ b/src/renderer/src/components/Preview/__tests__/__snapshots__/ImageToolButton.test.tsx.snap
@@ -0,0 +1,18 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`ImageToolButton > should match snapshot 1`] = `
+
+
+
+
+
+`;
diff --git a/src/renderer/src/components/Preview/__tests__/__snapshots__/ImageToolbar.test.tsx.snap b/src/renderer/src/components/Preview/__tests__/__snapshots__/ImageToolbar.test.tsx.snap
new file mode 100644
index 0000000000..b697f06aab
--- /dev/null
+++ b/src/renderer/src/components/Preview/__tests__/__snapshots__/ImageToolbar.test.tsx.snap
@@ -0,0 +1,141 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`ImageToolbar > should match snapshot 1`] = `
+
+ .c0 {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ position: absolute;
+ gap: 4px;
+ right: 1em;
+ bottom: 1em;
+ z-index: 5;
+}
+
+.c0 .ant-btn {
+ line-height: 0;
+}
+
+.c1 {
+ display: flex;
+ justify-content: center;
+ gap: 4px;
+ width: 100%;
+}
+
+.c2 {
+ flex: 1;
+}
+
+
+
+`;
diff --git a/src/renderer/src/components/Preview/__tests__/__snapshots__/MermaidPreview.test.tsx.snap b/src/renderer/src/components/Preview/__tests__/__snapshots__/MermaidPreview.test.tsx.snap
new file mode 100644
index 0000000000..01e98a63cd
--- /dev/null
+++ b/src/renderer/src/components/Preview/__tests__/__snapshots__/MermaidPreview.test.tsx.snap
@@ -0,0 +1,30 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`MermaidPreview > basic rendering > should match snapshot 1`] = `
+.c0 {
+ overflow: auto;
+ position: relative;
+ width: 100%;
+ height: 100%;
+}
+
+
+`;
diff --git a/src/renderer/src/components/Preview/__tests__/__snapshots__/PlantUmlPreview.test.tsx.snap b/src/renderer/src/components/Preview/__tests__/__snapshots__/PlantUmlPreview.test.tsx.snap
new file mode 100644
index 0000000000..34d7840cb4
--- /dev/null
+++ b/src/renderer/src/components/Preview/__tests__/__snapshots__/PlantUmlPreview.test.tsx.snap
@@ -0,0 +1,23 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`PlantUmlPreview > basic rendering > should match snapshot 1`] = `
+
+`;
diff --git a/src/renderer/src/components/Preview/__tests__/__snapshots__/SvgPreview.test.tsx.snap b/src/renderer/src/components/Preview/__tests__/__snapshots__/SvgPreview.test.tsx.snap
new file mode 100644
index 0000000000..00ee51b0e6
--- /dev/null
+++ b/src/renderer/src/components/Preview/__tests__/__snapshots__/SvgPreview.test.tsx.snap
@@ -0,0 +1,23 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`SvgPreview > basic rendering > should match snapshot 1`] = `
+
+`;
diff --git a/src/renderer/src/components/Preview/__tests__/useDebouncedRender.test.ts b/src/renderer/src/components/Preview/__tests__/useDebouncedRender.test.ts
new file mode 100644
index 0000000000..f54b87aa38
--- /dev/null
+++ b/src/renderer/src/components/Preview/__tests__/useDebouncedRender.test.ts
@@ -0,0 +1,49 @@
+import { renderHook } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+
+import { useDebouncedRender } from '../hooks/useDebouncedRender'
+
+describe('useDebouncedRender', () => {
+ const mockRenderFunction = vi.fn()
+
+ it('should return expected interface', () => {
+ const { result } = renderHook(() => useDebouncedRender('test content', mockRenderFunction))
+
+ // Verify hook returns all expected properties
+ expect(result.current).toHaveProperty('containerRef')
+ expect(result.current).toHaveProperty('error')
+ expect(result.current).toHaveProperty('isLoading')
+ expect(result.current).toHaveProperty('triggerRender')
+ expect(result.current).toHaveProperty('cancelRender')
+ expect(result.current).toHaveProperty('clearError')
+ expect(result.current).toHaveProperty('setLoading')
+
+ // Verify types of returned values
+ expect(result.current.containerRef).toEqual(expect.objectContaining({ current: null }))
+ expect(result.current.error).toBe(null)
+ expect(typeof result.current.isLoading).toBe('boolean')
+ expect(typeof result.current.triggerRender).toBe('function')
+ expect(typeof result.current.cancelRender).toBe('function')
+ expect(typeof result.current.clearError).toBe('function')
+ expect(typeof result.current.setLoading).toBe('function')
+ })
+
+ it('should handle different hook configurations', () => {
+ const shouldRender = vi.fn(() => true)
+ const options = {
+ debounceDelay: 500,
+ shouldRender
+ }
+
+ const { result } = renderHook(() => useDebouncedRender('content', mockRenderFunction, options))
+
+ // Hook should still return the expected interface regardless of options
+ expect(result.current).toHaveProperty('containerRef')
+ expect(result.current).toHaveProperty('error')
+ expect(result.current).toHaveProperty('isLoading')
+ expect(result.current).toHaveProperty('triggerRender')
+ expect(result.current).toHaveProperty('cancelRender')
+ expect(result.current).toHaveProperty('clearError')
+ expect(result.current).toHaveProperty('setLoading')
+ })
+})
diff --git a/src/renderer/src/components/Preview/__tests__/utils.test.ts b/src/renderer/src/components/Preview/__tests__/utils.test.ts
new file mode 100644
index 0000000000..c9722c33d4
--- /dev/null
+++ b/src/renderer/src/components/Preview/__tests__/utils.test.ts
@@ -0,0 +1,105 @@
+import { renderSvgInShadowHost } from '@renderer/components/Preview/utils'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+describe('renderSvgInShadowHost', () => {
+ let hostElement: HTMLElement
+
+ beforeEach(() => {
+ hostElement = document.createElement('div')
+ document.body.appendChild(hostElement)
+
+ // Mock attachShadow
+ Element.prototype.attachShadow = vi.fn().mockImplementation(function (this: HTMLElement) {
+ const shadowRoot = document.createElement('div')
+ Object.defineProperty(this, 'shadowRoot', {
+ value: shadowRoot,
+ writable: true,
+ configurable: true
+ })
+ // Simple innerHTML copy for test verification
+ Object.defineProperty(shadowRoot, 'innerHTML', {
+ set(value) {
+ shadowRoot.textContent = value // A simplified mock
+ },
+ get() {
+ return shadowRoot.textContent || ''
+ },
+ configurable: true
+ })
+
+ shadowRoot.appendChild = vi.fn((node: T): T => {
+ shadowRoot.append(node)
+ return node
+ })
+
+ return shadowRoot as unknown as ShadowRoot
+ })
+ })
+
+ afterEach(() => {
+ if (hostElement && hostElement.parentNode) {
+ hostElement.parentNode.removeChild(hostElement)
+ }
+ vi.clearAllMocks()
+ })
+
+ it('should attach a shadow root if one does not exist', () => {
+ renderSvgInShadowHost('', hostElement)
+ expect(Element.prototype.attachShadow).toHaveBeenCalledWith({ mode: 'open' })
+ })
+
+ it('should not attach a new shadow root if one already exists', () => {
+ // Attach a shadow root first
+ const existingShadowRoot = hostElement.attachShadow({ mode: 'open' })
+ vi.clearAllMocks() // Clear the mock call from the setup
+
+ renderSvgInShadowHost('', hostElement)
+
+ expect(Element.prototype.attachShadow).not.toHaveBeenCalled()
+ // Verify it works with the existing shadow root
+ expect(existingShadowRoot.appendChild).toHaveBeenCalled()
+ })
+
+ it('should inject styles and valid SVG content into the shadow DOM', () => {
+ const svgContent = ''
+ renderSvgInShadowHost(svgContent, hostElement)
+
+ const shadowRoot = hostElement.shadowRoot
+ expect(shadowRoot).not.toBeNull()
+ expect(shadowRoot?.querySelector('style')).not.toBeNull()
+ expect(shadowRoot?.querySelector('svg')).not.toBeNull()
+ expect(shadowRoot?.querySelector('rect')).not.toBeNull()
+ })
+
+ it('should throw an error if the host element is not available', () => {
+ expect(() => renderSvgInShadowHost('', null as any)).toThrow(
+ 'Host element for SVG rendering is not available.'
+ )
+ })
+
+ it('should throw an error for invalid SVG content', () => {
+ const invalidSvg = '' // Malformed
+ expect(() => renderSvgInShadowHost(invalidSvg, hostElement)).toThrow(/SVG parsing error/)
+ })
+
+ it('should throw an error for non-SVG content', () => {
+ const nonSvg = 'this is not svg
'
+ expect(() => renderSvgInShadowHost(nonSvg, hostElement)).toThrow('Invalid SVG content')
+ })
+
+ it('should not throw an error for empty or whitespace content', () => {
+ expect(() => renderSvgInShadowHost('', hostElement)).not.toThrow()
+ expect(() => renderSvgInShadowHost(' ', hostElement)).not.toThrow()
+ })
+
+ it('should clear previous content before rendering new content', () => {
+ const firstSvg = ''
+ renderSvgInShadowHost(firstSvg, hostElement)
+ expect(hostElement.shadowRoot?.querySelector('#first')).not.toBeNull()
+
+ const secondSvg = ''
+ renderSvgInShadowHost(secondSvg, hostElement)
+ expect(hostElement.shadowRoot?.querySelector('#first')).toBeNull()
+ expect(hostElement.shadowRoot?.querySelector('#second')).not.toBeNull()
+ })
+})
diff --git a/src/renderer/src/components/Preview/hooks/useDebouncedRender.ts b/src/renderer/src/components/Preview/hooks/useDebouncedRender.ts
new file mode 100644
index 0000000000..78c4ba06e0
--- /dev/null
+++ b/src/renderer/src/components/Preview/hooks/useDebouncedRender.ts
@@ -0,0 +1,167 @@
+import { loggerService } from '@logger'
+import { debounce } from 'lodash'
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+
+const logger = loggerService.withContext('useDebouncedRender')
+
+/**
+ * 预览渲染器选项
+ */
+export interface DebouncedRenderOptions {
+ /** 防抖延迟时间,默认 300ms */
+ debounceDelay?: number
+ /** 渲染前的额外条件检查 */
+ shouldRender?: () => boolean
+}
+
+/**
+ * 预览渲染器返回值
+ */
+export interface DebouncedRenderResult {
+ /** 容器元素引用 */
+ containerRef: React.RefObject
+ /** 错误状态 */
+ error: string | null
+ /** 加载状态 */
+ isLoading: boolean
+ /** 手动触发渲染 */
+ triggerRender: (content: string) => void
+ /** 取消渲染 */
+ cancelRender: () => void
+ /** 清除错误状态 */
+ clearError: () => void
+ /** 手动设置加载状态 */
+ setLoading: (loading: boolean) => void
+}
+
+/**
+ * 图像预览防抖渲染器 Hook
+ *
+ * - 容器 ref 管理
+ * - value 变化监听
+ * - 防抖渲染
+ * - 错误处理
+ * - 加载状态管理
+ *
+ * @param value 要渲染的内容
+ * @param renderFunction 实际的渲染函数,接收内容和容器元素
+ * @param options 配置选项
+ * @returns 渲染器状态、容器引用和控制函数
+ */
+export const useDebouncedRender = (
+ value: string,
+ renderFunction: (content: string, container: HTMLDivElement) => Promise,
+ options: DebouncedRenderOptions = {}
+): DebouncedRenderResult => {
+ const { debounceDelay = 300, shouldRender } = options
+
+ const containerRef = useRef(null)
+ const debouncedFunctionRef = useRef | null>(null)
+ const [error, setError] = useState(null)
+ const [isLoading, setIsLoading] = useState(false)
+
+ // 包装渲染函数,添加容器检查和错误处理
+ const wrappedRenderFunction = useCallback(
+ async (content: string): Promise => {
+ // 检查渲染前条件
+ if ((shouldRender && !shouldRender()) || !content) {
+ return
+ }
+
+ if (!containerRef.current) {
+ logger.warn('Container element not available')
+ throw new Error('Container element not available')
+ }
+
+ try {
+ setIsLoading(true)
+
+ await renderFunction(content, containerRef.current)
+
+ // 渲染成功,确保清除错误状态
+ setError(null)
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown rendering error'
+ logger.error(errorMessage)
+ setError(errorMessage)
+ } finally {
+ setIsLoading(false)
+ }
+ },
+ [renderFunction, shouldRender]
+ )
+
+ // 创建防抖版本的渲染函数
+ const debouncedRender = useMemo(() => {
+ const debouncedFn = debounce((content: string) => {
+ React.startTransition(() => {
+ wrappedRenderFunction(content)
+ })
+ }, debounceDelay)
+
+ // 存储引用用于后续取消
+ debouncedFunctionRef.current = debouncedFn
+
+ return debouncedFn
+ }, [wrappedRenderFunction, debounceDelay])
+
+ // 手动触发渲染的函数
+ const triggerRender = useCallback(
+ (content: string) => {
+ if (content) {
+ setIsLoading(true)
+ debouncedRender(content)
+ } else {
+ debouncedRender.cancel()
+ setIsLoading(false)
+ setError(null)
+ }
+ },
+ [debouncedRender]
+ )
+
+ const cancelRender = useCallback(() => {
+ debouncedRender.cancel()
+ setIsLoading(false)
+ }, [debouncedRender])
+
+ const clearError = useCallback(() => {
+ setError(null)
+ }, [])
+
+ // 手动设置加载状态
+ const setLoadingState = useCallback((loading: boolean) => {
+ setIsLoading(loading)
+ }, [])
+
+ // 监听 children 变化,自动触发渲染
+ useEffect(() => {
+ if (value) {
+ triggerRender(value)
+ } else {
+ cancelRender()
+ }
+
+ return () => {
+ cancelRender()
+ }
+ }, [value, triggerRender, cancelRender])
+
+ useEffect(() => {
+ return () => {
+ if (debouncedFunctionRef.current) {
+ debouncedFunctionRef.current.cancel()
+ }
+ }
+ }, [])
+
+ return {
+ containerRef,
+ error,
+ isLoading,
+ triggerRender,
+ cancelRender,
+ clearError,
+ setLoading: setLoadingState
+ }
+}
diff --git a/src/renderer/src/components/Preview/index.ts b/src/renderer/src/components/Preview/index.ts
new file mode 100644
index 0000000000..e048d87527
--- /dev/null
+++ b/src/renderer/src/components/Preview/index.ts
@@ -0,0 +1,5 @@
+export { default as GraphvizPreview } from './GraphvizPreview'
+export { default as MermaidPreview } from './MermaidPreview'
+export { default as PlantUmlPreview } from './PlantUmlPreview'
+export { default as SvgPreview } from './SvgPreview'
+export * from './types'
diff --git a/src/renderer/src/components/Preview/styles.ts b/src/renderer/src/components/Preview/styles.ts
new file mode 100644
index 0000000000..aa8718b34e
--- /dev/null
+++ b/src/renderer/src/components/Preview/styles.ts
@@ -0,0 +1,35 @@
+import { Flex } from 'antd'
+import { styled } from 'styled-components'
+
+export const PreviewError = styled.div`
+ overflow: auto;
+ padding: 16px;
+ color: #ff4d4f;
+ border: 1px solid #ff4d4f;
+ border-radius: 4px;
+ word-wrap: break-word;
+ white-space: pre-wrap;
+`
+
+export const PreviewContainer = styled(Flex).attrs({ role: 'alert' })`
+ position: relative;
+ /* Make sure the toolbar is visible */
+ min-height: 8rem;
+
+ .special-preview {
+ min-height: 8rem;
+ }
+
+ .preview-toolbar {
+ transition: opacity 0.3s ease-in-out;
+ transform: translateZ(0);
+ will-change: opacity;
+ opacity: 0;
+ }
+
+ &:hover {
+ .preview-toolbar {
+ opacity: 1;
+ }
+ }
+`
diff --git a/src/renderer/src/components/Preview/types.ts b/src/renderer/src/components/Preview/types.ts
new file mode 100644
index 0000000000..f67c08d224
--- /dev/null
+++ b/src/renderer/src/components/Preview/types.ts
@@ -0,0 +1,17 @@
+/**
+ * 预览组件的基本 props
+ */
+export interface BasicPreviewProps {
+ children: string
+ enableToolbar?: boolean
+}
+
+/**
+ * 通过 useImperativeHandle 暴露的方法类型
+ */
+export interface BasicPreviewHandles {
+ pan: (dx: number, dy: number, absolute?: boolean) => void
+ zoom: (delta: number, absolute?: boolean) => void
+ copy: () => Promise
+ download: (format: 'svg' | 'png') => Promise
+}
diff --git a/src/renderer/src/components/Preview/utils.ts b/src/renderer/src/components/Preview/utils.ts
new file mode 100644
index 0000000000..db5ba3457b
--- /dev/null
+++ b/src/renderer/src/components/Preview/utils.ts
@@ -0,0 +1,61 @@
+/**
+ * Renders an SVG string inside a host element's Shadow DOM to ensure style encapsulation.
+ * This function handles creating the shadow root, injecting base styles for the host,
+ * and safely parsing and appending the SVG content.
+ *
+ * @param svgContent The SVG string to render.
+ * @param hostElement The container element that will host the Shadow DOM.
+ * @throws An error if the SVG content is invalid or cannot be parsed.
+ */
+export function renderSvgInShadowHost(svgContent: string, hostElement: HTMLElement): void {
+ if (!hostElement) {
+ throw new Error('Host element for SVG rendering is not available.')
+ }
+
+ const shadowRoot = hostElement.shadowRoot || hostElement.attachShadow({ mode: 'open' })
+
+ // Base styles for the host element
+ const style = document.createElement('style')
+ style.textContent = `
+ :host {
+ padding: 1em;
+ background-color: white;
+ overflow: auto;
+ border: 0.5px solid var(--color-code-background);
+ border-radius: 8px;
+ display: block;
+ position: relative;
+ width: 100%;
+ height: 100%;
+ }
+ svg {
+ max-width: 100%;
+ height: auto;
+ }
+ `
+
+ // Clear previous content and append new style and SVG
+ shadowRoot.innerHTML = ''
+ shadowRoot.appendChild(style)
+
+ // Parse and append the SVG using DOMParser to prevent script execution and check for errors
+ if (svgContent.trim() === '') {
+ return
+ }
+ const parser = new DOMParser()
+ const doc = parser.parseFromString(svgContent, 'image/svg+xml')
+
+ const parserError = doc.querySelector('parsererror')
+ if (parserError) {
+ // Throw a specific error that can be caught by the calling component
+ throw new Error(`SVG parsing error: ${parserError.textContent || 'Unknown parsing error'}`)
+ }
+
+ const svgElement = doc.documentElement
+ if (svgElement && svgElement.nodeName.toLowerCase() === 'svg') {
+ shadowRoot.appendChild(svgElement.cloneNode(true))
+ } else if (svgContent.trim() !== '') {
+ // Do not throw error for empty content
+ throw new Error('Invalid SVG content: The provided string is not a valid SVG document.')
+ }
+}
diff --git a/src/renderer/src/components/__tests__/MermaidPreview.test.tsx b/src/renderer/src/components/__tests__/MermaidPreview.test.tsx
deleted file mode 100644
index 3f76fc5eb8..0000000000
--- a/src/renderer/src/components/__tests__/MermaidPreview.test.tsx
+++ /dev/null
@@ -1,221 +0,0 @@
-import { render, screen, waitFor } from '@testing-library/react'
-import { act } from 'react'
-import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
-
-import MermaidPreview from '../CodeBlockView/MermaidPreview'
-
-const mocks = vi.hoisted(() => ({
- useMermaid: vi.fn(),
- usePreviewToolHandlers: vi.fn(),
- usePreviewTools: vi.fn()
-}))
-
-// Mock hooks
-vi.mock('@renderer/hooks/useMermaid', () => ({
- useMermaid: () => mocks.useMermaid()
-}))
-
-vi.mock('@renderer/components/CodeToolbar', () => ({
- usePreviewToolHandlers: () => mocks.usePreviewToolHandlers(),
- usePreviewTools: () => mocks.usePreviewTools()
-}))
-
-// Mock nanoid
-vi.mock('@reduxjs/toolkit', () => ({
- nanoid: () => 'test-id-123456'
-}))
-
-// Mock lodash debounce
-vi.mock('lodash', async () => {
- const actual = await import('lodash')
- return {
- ...actual,
- debounce: vi.fn((fn) => {
- const debounced = (...args: any[]) => fn(...args)
- debounced.cancel = vi.fn()
- return debounced
- })
- }
-})
-
-// Mock antd components
-vi.mock('antd', () => ({
- Flex: ({ children, vertical, ...props }: any) => (
-
- {children}
-
- ),
- Spin: ({ children, spinning, indicator }: any) => (
-
- {spinning && indicator}
- {children}
-
- )
-}))
-
-describe('MermaidPreview', () => {
- const mockMermaid = {
- parse: vi.fn(),
- render: vi.fn()
- }
-
- beforeEach(() => {
- vi.clearAllMocks()
-
- mocks.useMermaid.mockReturnValue({
- mermaid: mockMermaid,
- isLoading: false,
- error: null
- })
-
- mocks.usePreviewToolHandlers.mockReturnValue({
- handleZoom: vi.fn(),
- handleCopyImage: vi.fn(),
- handleDownload: vi.fn()
- })
-
- mocks.usePreviewTools.mockReturnValue({})
-
- mockMermaid.parse.mockResolvedValue(true)
- mockMermaid.render.mockResolvedValue({
- svg: ''
- })
-
- // Mock MutationObserver
- global.MutationObserver = vi.fn().mockImplementation(() => ({
- observe: vi.fn(),
- disconnect: vi.fn(),
- takeRecords: vi.fn()
- }))
- })
-
- afterEach(() => {
- vi.restoreAllMocks()
- })
-
- describe('visibility detection', () => {
- it('should not render mermaid when element has display: none', async () => {
- const mermaidCode = 'graph TD\nA-->B'
-
- const { container } = render({mermaidCode})
-
- // Mock offsetParent to be null (simulating display: none)
- const mermaidElement = container.querySelector('.mermaid')
- if (mermaidElement) {
- Object.defineProperty(mermaidElement, 'offsetParent', {
- get: () => null,
- configurable: true
- })
- }
-
- // Re-render to trigger the effect
- render({mermaidCode})
-
- // Should not call mermaid render when offsetParent is null
- expect(mockMermaid.render).not.toHaveBeenCalled()
-
- const svgElement = mermaidElement?.querySelector('svg.flowchart')
- expect(svgElement).not.toBeInTheDocument()
- })
-
- it('should setup MutationObserver to monitor parent elements', () => {
- const mermaidCode = 'graph TD\nA-->B'
-
- render({mermaidCode})
-
- expect(global.MutationObserver).toHaveBeenCalledWith(expect.any(Function))
- })
-
- it('should observe parent elements up to fold className', () => {
- const mermaidCode = 'graph TD\nA-->B'
-
- // Create a DOM structure that simulates MessageGroup fold layout
- const foldContainer = document.createElement('div')
- foldContainer.className = 'fold selected'
-
- const messageWrapper = document.createElement('div')
- messageWrapper.className = 'message-wrapper'
-
- const codeBlock = document.createElement('div')
- codeBlock.className = 'code-block'
-
- foldContainer.appendChild(messageWrapper)
- messageWrapper.appendChild(codeBlock)
- document.body.appendChild(foldContainer)
-
- render({mermaidCode}, {
- container: codeBlock
- })
-
- const observerInstance = (global.MutationObserver as Mock).mock.results[0]?.value
- expect(observerInstance.observe).toHaveBeenCalled()
-
- // Cleanup
- document.body.removeChild(foldContainer)
- })
-
- it('should trigger re-render when visibility changes from hidden to visible', async () => {
- const mermaidCode = 'graph TD\nA-->B'
-
- const { container, rerender } = render({mermaidCode})
-
- const mermaidElement = container.querySelector('.mermaid')
-
- // Initially hidden (offsetParent is null)
- Object.defineProperty(mermaidElement, 'offsetParent', {
- get: () => null,
- configurable: true
- })
-
- // Clear previous calls
- mockMermaid.render.mockClear()
-
- // Re-render with hidden state
- rerender({mermaidCode})
-
- // Should not render when hidden
- expect(mockMermaid.render).not.toHaveBeenCalled()
-
- // Now make it visible
- Object.defineProperty(mermaidElement, 'offsetParent', {
- get: () => document.body,
- configurable: true
- })
-
- // Simulate MutationObserver callback
- const observerCallback = (global.MutationObserver as Mock).mock.calls[0][0]
- act(() => {
- observerCallback([])
- })
-
- // Re-render to trigger visibility change effect
- rerender({mermaidCode})
-
- await waitFor(() => {
- expect(mockMermaid.render).toHaveBeenCalledWith('mermaid-test-id-123456', mermaidCode, expect.any(Object))
-
- const svgElement = mermaidElement?.querySelector('svg.flowchart')
- expect(svgElement).toBeInTheDocument()
- expect(svgElement).toHaveClass('flowchart')
- })
- })
-
- it('should handle mermaid loading state', () => {
- mocks.useMermaid.mockReturnValue({
- mermaid: mockMermaid,
- isLoading: true,
- error: null
- })
-
- const mermaidCode = 'graph TD\nA-->B'
-
- render({mermaidCode})
-
- // Should not render when mermaid is loading
- expect(mockMermaid.render).not.toHaveBeenCalled()
-
- // Should show loading state
- expect(screen.getByTestId('spin')).toHaveAttribute('data-spinning', 'true')
- })
- })
-})
diff --git a/src/renderer/src/config/constant.ts b/src/renderer/src/config/constant.ts
index 70dd66b4d8..919115765e 100644
--- a/src/renderer/src/config/constant.ts
+++ b/src/renderer/src/config/constant.ts
@@ -39,3 +39,5 @@ export const THEME_COLOR_PRESETS = [
export const MAX_CONTEXT_COUNT = 100
export const UNLIMITED_CONTEXT_COUNT = 100000
+
+export const MAX_COLLAPSED_CODE_HEIGHT = 350
diff --git a/src/renderer/src/context/CodeStyleProvider.tsx b/src/renderer/src/context/CodeStyleProvider.tsx
index c14364b7b0..2b4d9d4004 100644
--- a/src/renderer/src/context/CodeStyleProvider.tsx
+++ b/src/renderer/src/context/CodeStyleProvider.tsx
@@ -40,7 +40,7 @@ const defaultCodeStyleContext: CodeStyleContextType = {
const CodeStyleContext = createContext(defaultCodeStyleContext)
export const CodeStyleProvider: React.FC = ({ children }) => {
- const { codeEditor, codePreview } = useSettings()
+ const { codeEditor, codeViewer } = useSettings()
const { theme } = useTheme()
const [shikiThemesInfo, setShikiThemesInfo] = useState([])
useMermaid()
@@ -71,12 +71,12 @@ export const CodeStyleProvider: React.FC = ({ children }) =>
// 获取当前使用的 Shiki 主题名称(只用于代码预览)
const activeShikiTheme = useMemo(() => {
const field = theme === ThemeMode.light ? 'themeLight' : 'themeDark'
- const codeStyle = codePreview[field]
+ const codeStyle = codeViewer[field]
if (!codeStyle || codeStyle === 'auto' || !themeNames.includes(codeStyle)) {
return theme === ThemeMode.light ? 'one-light' : 'material-theme-darker'
}
return codeStyle
- }, [theme, codePreview, themeNames])
+ }, [theme, codeViewer, themeNames])
const isShikiThemeDark = useMemo(() => {
const themeInfo = shikiThemesInfo.find((info) => info.id === activeShikiTheme)
diff --git a/src/renderer/src/hooks/__tests__/useTemporaryValue.test.ts b/src/renderer/src/hooks/__tests__/useTemporaryValue.test.ts
new file mode 100644
index 0000000000..b85dff0233
--- /dev/null
+++ b/src/renderer/src/hooks/__tests__/useTemporaryValue.test.ts
@@ -0,0 +1,206 @@
+import { act, renderHook } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { useTemporaryValue } from '../useTemporaryValue'
+
+describe('useTemporaryValue', () => {
+ beforeEach(() => {
+ // 使用假定时器
+ vi.useFakeTimers()
+ })
+
+ afterEach(() => {
+ // 恢复真实定时器
+ vi.useRealTimers()
+ })
+
+ describe('basic functionality', () => {
+ it('should return the default value initially', () => {
+ const { result } = renderHook(() => useTemporaryValue('default'))
+ const [value] = result.current
+
+ expect(value).toBe('default')
+ })
+
+ it('should temporarily change the value and then revert', () => {
+ const { result } = renderHook(() => useTemporaryValue('default', 1000))
+ const [, setTemporaryValue] = result.current
+
+ // 设置临时值
+ act(() => {
+ setTemporaryValue('temporary')
+ })
+
+ expect(result.current[0]).toBe('temporary')
+
+ // 快进定时器
+ act(() => {
+ vi.advanceTimersByTime(1000)
+ })
+
+ expect(result.current[0]).toBe('default')
+ })
+
+ it('should handle same value as default', () => {
+ const { result } = renderHook(() => useTemporaryValue('default', 1000))
+ const [, setTemporaryValue] = result.current
+
+ // 设置与默认值相同的值
+ act(() => {
+ setTemporaryValue('default')
+ })
+
+ expect(result.current[0]).toBe('default')
+
+ // 快进定时器(即使不需要恢复,也不会出错)
+ act(() => {
+ vi.advanceTimersByTime(1000)
+ })
+
+ // 应该保持默认值
+ expect(result.current[0]).toBe('default')
+ })
+ })
+
+ describe('timer management', () => {
+ it('should clear timeout on unmount', () => {
+ const { result, unmount } = renderHook(() => useTemporaryValue('default', 1000))
+ const [, setTemporaryValue] = result.current
+
+ // 设置临时值
+ act(() => {
+ setTemporaryValue('temporary')
+ })
+
+ // 验证值已更改
+ expect(result.current[0]).toBe('temporary')
+
+ // 卸载 hook
+ unmount()
+
+ // 快进定时器
+ act(() => {
+ vi.advanceTimersByTime(1000)
+ })
+
+ // 验证没有错误发生(值保持不变,因为我们已卸载)
+ expect(result.current[0]).toBe('temporary') // 注意:这里应该还是'temporary',因为组件已卸载
+ })
+
+ it('should handle multiple calls correctly', () => {
+ const { result } = renderHook(() => useTemporaryValue('default', 1000))
+ const [, setTemporaryValue] = result.current
+
+ // 设置临时值
+ act(() => {
+ setTemporaryValue('temporary1')
+ })
+
+ expect(result.current[0]).toBe('temporary1')
+
+ // 在第一个值过期前设置另一个临时值
+ act(() => {
+ setTemporaryValue('temporary2')
+ })
+
+ expect(result.current[0]).toBe('temporary2')
+
+ // 快进定时器
+ act(() => {
+ vi.advanceTimersByTime(1000)
+ })
+
+ expect(result.current[0]).toBe('default')
+ })
+
+ it('should handle custom duration', () => {
+ const { result } = renderHook(() => useTemporaryValue('default', 500))
+ const [, setTemporaryValue] = result.current
+
+ act(() => {
+ setTemporaryValue('temporary')
+ })
+
+ expect(result.current[0]).toBe('temporary')
+
+ act(() => {
+ vi.advanceTimersByTime(500)
+ })
+
+ expect(result.current[0]).toBe('default')
+ })
+
+ it('should handle very short duration', () => {
+ const { result } = renderHook(() => useTemporaryValue('default', 0))
+ const [, setTemporaryValue] = result.current
+
+ act(() => {
+ setTemporaryValue('temporary')
+ })
+
+ expect(result.current[0]).toBe('temporary')
+
+ // 对于0ms的定时器,需要运行所有微任务
+ act(() => {
+ vi.runAllTimers()
+ })
+
+ expect(result.current[0]).toBe('default')
+ })
+ })
+
+ describe('data types', () => {
+ it.each([
+ [false, true],
+ [0, 5],
+ ['', 'temporary'],
+ [null, 'value'],
+ [undefined, 'value'],
+ [{}, { key: 'value' }],
+ [[], [1, 2, 3]]
+ ])('should work with type: %p', (defaultValue, temporaryValue) => {
+ const { result } = renderHook(() => useTemporaryValue(defaultValue, 1000))
+ const [, setTemporaryValue] = result.current
+
+ act(() => {
+ setTemporaryValue(temporaryValue)
+ })
+
+ expect(result.current[0]).toEqual(temporaryValue)
+
+ act(() => {
+ vi.advanceTimersByTime(1000)
+ })
+
+ expect(result.current[0]).toEqual(defaultValue)
+ })
+ })
+
+ describe('edge cases', () => {
+ it('should handle same temporary value multiple times', () => {
+ const { result } = renderHook(() => useTemporaryValue('default', 1000))
+ const [, setTemporaryValue] = result.current
+
+ // 设置临时值
+ act(() => {
+ setTemporaryValue('temporary')
+ })
+
+ expect(result.current[0]).toBe('temporary')
+
+ // 再次设置相同的临时值
+ act(() => {
+ setTemporaryValue('temporary')
+ })
+
+ expect(result.current[0]).toBe('temporary')
+
+ // 快进定时器
+ act(() => {
+ vi.advanceTimersByTime(1000)
+ })
+
+ expect(result.current[0]).toBe('default')
+ })
+ })
+})
diff --git a/src/renderer/src/hooks/useTemporaryValue.ts b/src/renderer/src/hooks/useTemporaryValue.ts
new file mode 100644
index 0000000000..c494fc397e
--- /dev/null
+++ b/src/renderer/src/hooks/useTemporaryValue.ts
@@ -0,0 +1,62 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+
+/**
+ * A hook for managing a temporary value that automatically reverts to its default after a specified duration.
+ *
+ * @param defaultValue - The default value to revert to
+ * @param duration - The duration in milliseconds before the value reverts to default (default: 2000ms)
+ * @returns A tuple containing the current value and a function to set a temporary value
+ *
+ * @example
+ * const [copied, setCopiedTemporarily] = useTemporaryValue(false)
+ *
+ * const handleCopy = () => {
+ * // Copy logic here
+ * setCopiedTemporarily(true) // Will automatically revert to false after 2 seconds
+ * }
+ *
+ * @example
+ * const [status, setStatusTemporarily] = useTemporaryValue('idle', 3000)
+ *
+ * const handleSubmit = async () => {
+ * setStatusTemporarily('saving')
+ * await saveData()
+ * setStatusTemporarily('saved') // Will automatically revert to 'idle' after 3 seconds
+ * }
+ */
+export const useTemporaryValue = (defaultValue: T, duration: number = 2000) => {
+ const [value, setValue] = useState(defaultValue)
+ const timeoutRef = useRef(null)
+
+ const setTemporaryValue = useCallback(
+ (tempValue: T) => {
+ // Clear any existing timeout
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current)
+ }
+
+ // Set the new value
+ setValue(tempValue)
+
+ // Set timeout to revert to default value
+ if (tempValue !== defaultValue) {
+ timeoutRef.current = setTimeout(() => {
+ setValue(defaultValue)
+ timeoutRef.current = null
+ }, duration)
+ }
+ },
+ [defaultValue, duration]
+ )
+
+ // Clear timeout on unmount
+ useEffect(() => {
+ return () => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current)
+ }
+ }
+ }, [])
+
+ return [value, setTemporaryValue] as const
+}
diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json
index ae2cc8a762..1b87899702 100644
--- a/src/renderer/src/i18n/locales/en-us.json
+++ b/src/renderer/src/i18n/locales/en-us.json
@@ -505,6 +505,7 @@
"tip": "The run button will be displayed in the toolbar of executable code blocks, please do not execute dangerous code!",
"title": "Code Execution"
},
+ "code_image_tools": "Enable preview tools",
"code_wrappable": "Code block wrappable",
"context_count": {
"label": "Context",
@@ -648,15 +649,6 @@
},
"expand": "Expand",
"more": "More",
- "preview": {
- "copy": {
- "image": "Copy as image"
- },
- "label": "Preview",
- "source": "View Source Code",
- "zoom_in": "Zoom In",
- "zoom_out": "Zoom Out"
- },
"run": "Run",
"split": {
"label": "Split View",
@@ -1159,6 +1151,9 @@
"failed": "Delete Failed",
"success": "Delete Successful"
},
+ "dialog": {
+ "failed": "Preview failed"
+ },
"download": {
"failed": "Download failed",
"success": "Download successfully"
@@ -1654,6 +1649,22 @@
"seed_tip": "Controls upscaling randomness"
}
},
+ "preview": {
+ "copy": {
+ "image": "Copy as image"
+ },
+ "dialog": "Open Dialog",
+ "label": "Preview",
+ "pan": "Pan",
+ "pan_down": "Pan Down",
+ "pan_left": "Pan Left",
+ "pan_right": "Pan Right",
+ "pan_up": "Pan Up",
+ "reset": "Reset",
+ "source": "View Source Code",
+ "zoom_in": "Zoom In",
+ "zoom_out": "Zoom Out"
+ },
"prompts": {
"explanation": "Explain this concept to me",
"summarize": "Summarize this text",
diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json
index 8e3d2a9c11..f6b2808669 100644
--- a/src/renderer/src/i18n/locales/ja-jp.json
+++ b/src/renderer/src/i18n/locales/ja-jp.json
@@ -505,6 +505,7 @@
"tip": "実行可能なコードブロックのツールバーには実行ボタンが表示されます。危険なコードを実行しないでください!",
"title": "コード実行"
},
+ "code_image_tools": "プレビューツールを有効にする",
"code_wrappable": "コードブロック折り返し",
"context_count": {
"label": "コンテキスト",
@@ -648,15 +649,6 @@
},
"expand": "展開する",
"more": "もっと",
- "preview": {
- "copy": {
- "image": "画像としてコピー"
- },
- "label": "プレビュー",
- "source": "ソースコードを表示",
- "zoom_in": "拡大",
- "zoom_out": "縮小"
- },
"run": "コードを実行",
"split": {
"label": "分割視圖",
@@ -1159,6 +1151,9 @@
"failed": "削除に失敗しました",
"success": "削除が成功しました"
},
+ "dialog": {
+ "failed": "プレビューに失敗しました"
+ },
"download": {
"failed": "ダウンロードに失敗しました",
"success": "ダウンロードに成功しました"
@@ -1654,6 +1649,22 @@
"seed_tip": "拡大結果のランダム性を制御します"
}
},
+ "preview": {
+ "copy": {
+ "image": "画像としてコピー"
+ },
+ "dialog": "ダイアログを開く",
+ "label": "プレビュー",
+ "pan": "パン",
+ "pan_down": "下にパン",
+ "pan_left": "左にパン",
+ "pan_right": "右にパン",
+ "pan_up": "上にパン",
+ "reset": "リセット",
+ "source": "ソースコードを表示",
+ "zoom_in": "拡大",
+ "zoom_out": "縮小"
+ },
"prompts": {
"explanation": "この概念を説明してください",
"summarize": "このテキストを要約してください",
diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json
index c959dda58d..c62d17d702 100644
--- a/src/renderer/src/i18n/locales/ru-ru.json
+++ b/src/renderer/src/i18n/locales/ru-ru.json
@@ -505,6 +505,7 @@
"tip": "Выполнение кода в блоке кода возможно, но не рекомендуется выполнять опасный код!",
"title": "Выполнение кода"
},
+ "code_image_tools": "Включить инструменты предпросмотра",
"code_wrappable": "Блок кода можно переносить",
"context_count": {
"label": "Контекст",
@@ -648,15 +649,6 @@
},
"expand": "Развернуть",
"more": "Ещё",
- "preview": {
- "copy": {
- "image": "Скопировать как изображение"
- },
- "label": "Предварительный просмотр",
- "source": "Смотреть исходный код",
- "zoom_in": "Увеличить",
- "zoom_out": "Уменьшить"
- },
"run": "Выполнить код",
"split": {
"label": "Разделить на два окна",
@@ -1159,6 +1151,9 @@
"failed": "Ошибка удаления",
"success": "Удаление успешно"
},
+ "dialog": {
+ "failed": "Не удалось открыть диалог"
+ },
"download": {
"failed": "Скачивание не удалось",
"success": "Скачано успешно"
@@ -1654,6 +1649,22 @@
"seed_tip": "Контролирует случайный характер увеличения изображений для воспроизводимых результатов"
}
},
+ "preview": {
+ "copy": {
+ "image": "Скопировать как изображение"
+ },
+ "dialog": "Открыть диалог",
+ "label": "Предварительный просмотр",
+ "pan": "Перемещать",
+ "pan_down": "Переместить вниз",
+ "pan_left": "Переместить влево",
+ "pan_right": "Переместить вправо",
+ "pan_up": "Переместить вверх",
+ "reset": "Сбросить",
+ "source": "Смотреть исходный код",
+ "zoom_in": "Увеличить",
+ "zoom_out": "Уменьшить"
+ },
"prompts": {
"explanation": "Объясните мне этот концепт",
"summarize": "Суммируйте этот текст",
diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json
index 78bd4143ca..f6f267a877 100644
--- a/src/renderer/src/i18n/locales/zh-cn.json
+++ b/src/renderer/src/i18n/locales/zh-cn.json
@@ -505,6 +505,7 @@
"tip": "可执行的代码块工具栏中会显示运行按钮,注意不要执行危险代码!",
"title": "代码执行"
},
+ "code_image_tools": "启用预览工具",
"code_wrappable": "代码块可换行",
"context_count": {
"label": "上下文数",
@@ -648,15 +649,6 @@
},
"expand": "展开",
"more": "更多",
- "preview": {
- "copy": {
- "image": "复制为图片"
- },
- "label": "预览",
- "source": "查看源代码",
- "zoom_in": "放大",
- "zoom_out": "缩小"
- },
"run": "运行代码",
"split": {
"label": "分割视图",
@@ -1159,6 +1151,9 @@
"failed": "删除失败",
"success": "删除成功"
},
+ "dialog": {
+ "failed": "预览失败"
+ },
"download": {
"failed": "下载失败",
"success": "下载成功"
@@ -1654,6 +1649,22 @@
"seed_tip": "控制放大结果的随机性"
}
},
+ "preview": {
+ "copy": {
+ "image": "复制为图片"
+ },
+ "dialog": "打开预览窗口",
+ "label": "预览",
+ "pan": "移动",
+ "pan_down": "下移",
+ "pan_left": "左移",
+ "pan_right": "右移",
+ "pan_up": "上移",
+ "reset": "重置",
+ "source": "查看源代码",
+ "zoom_in": "放大",
+ "zoom_out": "缩小"
+ },
"prompts": {
"explanation": "帮我解释一下这个概念",
"summarize": "帮我总结一下这段话",
diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json
index 84869ab5c5..1d7e4bb5d8 100644
--- a/src/renderer/src/i18n/locales/zh-tw.json
+++ b/src/renderer/src/i18n/locales/zh-tw.json
@@ -505,6 +505,7 @@
"tip": "可執行的程式碼塊工具欄中會顯示運行按鈕,注意不要執行危險程式碼!",
"title": "程式碼執行"
},
+ "code_image_tools": "啟用預覽工具",
"code_wrappable": "程式碼區塊可自動換行",
"context_count": {
"label": "上下文",
@@ -648,15 +649,6 @@
},
"expand": "展開",
"more": "更多",
- "preview": {
- "copy": {
- "image": "複製為圖片"
- },
- "label": "預覽",
- "source": "查看源碼",
- "zoom_in": "放大",
- "zoom_out": "縮小"
- },
"run": "運行代碼",
"split": {
"label": "分割視圖",
@@ -1159,6 +1151,9 @@
"failed": "刪除失敗",
"success": "刪除成功"
},
+ "dialog": {
+ "failed": "預覽失敗"
+ },
"download": {
"failed": "下載失敗",
"success": "下載成功"
@@ -1654,6 +1649,22 @@
"seed_tip": "控制放大結果的隨機性"
}
},
+ "preview": {
+ "copy": {
+ "image": "複製為圖片"
+ },
+ "dialog": "開啟預覽窗口",
+ "label": "預覽",
+ "pan": "移動",
+ "pan_down": "下移",
+ "pan_left": "左移",
+ "pan_right": "右移",
+ "pan_up": "上移",
+ "reset": "重置",
+ "source": "查看源碼",
+ "zoom_in": "放大",
+ "zoom_out": "縮小"
+ },
"prompts": {
"explanation": "幫我解釋一下這個概念",
"summarize": "幫我總結一下這段話",
diff --git a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx
index 7f0d91602c..dc6b8a246d 100644
--- a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx
+++ b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx
@@ -25,8 +25,9 @@ import {
setCodeCollapsible,
setCodeEditor,
setCodeExecution,
- setCodePreview,
+ setCodeImageTools,
setCodeShowLineNumbers,
+ setCodeViewer,
setCodeWrappable,
setEnableBackspaceDeleteModel,
setEnableQuickPanelTriggers,
@@ -92,7 +93,8 @@ const SettingsTab: FC = (props) => {
codeCollapsible,
codeWrappable,
codeEditor,
- codePreview,
+ codeViewer,
+ codeImageTools,
codeExecution,
mathEngine,
autoTranslateWithSpace,
@@ -133,21 +135,21 @@ const SettingsTab: FC = (props) => {
? codeEditor.themeLight
: codeEditor.themeDark
: theme === ThemeMode.light
- ? codePreview.themeLight
- : codePreview.themeDark
+ ? codeViewer.themeLight
+ : codeViewer.themeDark
}, [
codeEditor.enabled,
codeEditor.themeLight,
codeEditor.themeDark,
theme,
- codePreview.themeLight,
- codePreview.themeDark
+ codeViewer.themeLight,
+ codeViewer.themeDark
])
const onCodeStyleChange = useCallback(
(value: CodeStyleVarious) => {
const field = theme === ThemeMode.light ? 'themeLight' : 'themeDark'
- const action = codeEditor.enabled ? setCodeEditor : setCodePreview
+ const action = codeEditor.enabled ? setCodeEditor : setCodeViewer
dispatch(action({ [field]: value }))
},
[dispatch, theme, codeEditor.enabled]
@@ -532,6 +534,15 @@ const SettingsTab: FC = (props) => {
{t('chat.settings.code_wrappable')}
dispatch(setCodeWrappable(checked))} />
+
+
+ {t('chat.settings.code_image_tools')}
+ dispatch(setCodeImageTools(checked))}
+ />
+
diff --git a/src/renderer/src/pages/settings/AssistantSettings/AssistantPromptSettings.tsx b/src/renderer/src/pages/settings/AssistantSettings/AssistantPromptSettings.tsx
index e413d4144c..fdc60341cb 100644
--- a/src/renderer/src/pages/settings/AssistantSettings/AssistantPromptSettings.tsx
+++ b/src/renderer/src/pages/settings/AssistantSettings/AssistantPromptSettings.tsx
@@ -132,13 +132,13 @@ const AssistantPromptSettings: React.FC = ({ assistant, updateAssistant }
onBlur={onUpdate}
height="calc(80vh - 202px)"
fontSize="var(--ant-font-size)"
+ expanded
+ unwrapped={false}
options={{
autocompletion: false,
- collapsible: false,
keymap: true,
lineNumbers: false,
- lint: false,
- wrappable: true
+ lint: false
}}
style={{
border: '0.5px solid var(--color-border)',
diff --git a/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx b/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx
index 69b9f4b7e5..acc1f668e2 100644
--- a/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx
+++ b/src/renderer/src/pages/settings/DisplaySettings/DisplaySettings.tsx
@@ -338,9 +338,9 @@ const DisplaySettings: FC = () => {
placeholder={t('settings.display.custom.css.placeholder')}
onChange={(value) => dispatch(setCustomCss(value))}
height="60vh"
+ expanded
+ unwrapped={false}
options={{
- collapsible: false,
- wrappable: true,
autocompletion: true,
lineNumbers: true,
foldGutter: true,
diff --git a/src/renderer/src/pages/settings/MCPSettings/AddMcpServerModal.tsx b/src/renderer/src/pages/settings/MCPSettings/AddMcpServerModal.tsx
index f060df5df5..d91418f2c8 100644
--- a/src/renderer/src/pages/settings/MCPSettings/AddMcpServerModal.tsx
+++ b/src/renderer/src/pages/settings/MCPSettings/AddMcpServerModal.tsx
@@ -291,10 +291,10 @@ const AddMcpServerModal: FC = ({
language="json"
onChange={handleEditorChange}
maxHeight="300px"
+ expanded
+ unwrapped={false}
options={{
lint: true,
- collapsible: true,
- wrappable: true,
lineNumbers: true,
foldGutter: true,
highlightActiveLine: true,
diff --git a/src/renderer/src/pages/settings/MCPSettings/EditMcpJsonPopup.tsx b/src/renderer/src/pages/settings/MCPSettings/EditMcpJsonPopup.tsx
index 829190eb95..f648c492ee 100644
--- a/src/renderer/src/pages/settings/MCPSettings/EditMcpJsonPopup.tsx
+++ b/src/renderer/src/pages/settings/MCPSettings/EditMcpJsonPopup.tsx
@@ -134,10 +134,10 @@ const PopupContainer: React.FC = ({ resolve }) => {
language="json"
onChange={(value) => setJsonConfig(value)}
height="60vh"
+ expanded
+ unwrapped={false}
options={{
lint: true,
- collapsible: false,
- wrappable: true,
lineNumbers: true,
foldGutter: true,
highlightActiveLine: true,
diff --git a/src/renderer/src/pages/settings/ProviderSettings/CustomHeaderPopup.tsx b/src/renderer/src/pages/settings/ProviderSettings/CustomHeaderPopup.tsx
index 7f74c457f1..3541aa64d3 100644
--- a/src/renderer/src/pages/settings/ProviderSettings/CustomHeaderPopup.tsx
+++ b/src/renderer/src/pages/settings/ProviderSettings/CustomHeaderPopup.tsx
@@ -78,10 +78,10 @@ const PopupContainer: React.FC = ({ provider, resolve }) => {
language="json"
onChange={(value) => setHeaderText(value)}
placeholder={`{\n "Header-Name": "Header-Value"\n}`}
+ expanded
+ unwrapped={false}
options={{
lint: true,
- collapsible: false,
- wrappable: true,
lineNumbers: true,
foldGutter: true,
highlightActiveLine: true,
diff --git a/src/renderer/src/services/ImagePreviewService.ts b/src/renderer/src/services/ImagePreviewService.ts
new file mode 100644
index 0000000000..f4ce450ae4
--- /dev/null
+++ b/src/renderer/src/services/ImagePreviewService.ts
@@ -0,0 +1,87 @@
+import { loggerService } from '@logger'
+import { TopView } from '@renderer/components/TopView'
+import { svgToPngBlob, svgToSvgBlob } from '@renderer/utils/image'
+import React from 'react'
+
+const logger = loggerService.withContext('ImagePreviewService')
+
+export type ImageInput = SVGElement | HTMLImageElement | string | Blob
+
+export interface ImagePreviewOptions {
+ format?: 'svg' | 'png' | 'jpeg'
+ scale?: number
+ quality?: number
+}
+
+/**
+ * 图像预览服务
+ * 提供统一的图像预览功能,支持多种输入类型
+ */
+export class ImagePreviewService {
+ /**
+ * 显示图像预览
+ * @param input 图像输入源
+ * @param options 预览选项
+ */
+ static async show(input: ImageInput, options: ImagePreviewOptions = {}): Promise {
+ try {
+ const imageUrl = await this.processInput(input, options)
+
+ // 动态导入 ImageViewer 避免循环依赖
+ const { default: ImageViewer } = await import('@renderer/components/ImageViewer')
+
+ const handleVisibilityChange = (visible: boolean) => {
+ if (!visible) {
+ // 清理创建的 URL
+ if (imageUrl.startsWith('blob:')) {
+ URL.revokeObjectURL(imageUrl)
+ }
+ TopView.hide('image-preview')
+ }
+ }
+
+ TopView.show(
+ () =>
+ React.createElement(ImageViewer, {
+ src: imageUrl,
+ style: { display: 'none' }, // 隐藏图片本身,只显示预览对话框
+ preview: {
+ visible: true,
+ onVisibleChange: handleVisibilityChange
+ }
+ }),
+ 'image-preview'
+ )
+ } catch (error) {
+ logger.error('Failed to show image preview:', error as Error)
+ throw error
+ }
+ }
+
+ /**
+ * 处理输入并转换为可预览的 URL
+ * @param input 图像输入源
+ * @param options 处理选项
+ * @returns 图像 URL
+ */
+ private static async processInput(input: ImageInput, options: ImagePreviewOptions): Promise {
+ if (input instanceof SVGElement) {
+ const blob = options.format === 'svg' ? svgToSvgBlob(input) : await svgToPngBlob(input, options.scale || 3)
+ return URL.createObjectURL(blob)
+ }
+
+ if (input instanceof HTMLImageElement) {
+ return input.src
+ }
+
+ if (typeof input === 'string') {
+ return input
+ }
+
+ if (input instanceof Blob) {
+ return URL.createObjectURL(input)
+ }
+
+ throw new Error('Unsupported input type')
+ }
+}
diff --git a/src/renderer/src/services/__tests__/ImagePreviewService.test.ts b/src/renderer/src/services/__tests__/ImagePreviewService.test.ts
new file mode 100644
index 0000000000..09da62db93
--- /dev/null
+++ b/src/renderer/src/services/__tests__/ImagePreviewService.test.ts
@@ -0,0 +1,119 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { ImagePreviewService } from '../ImagePreviewService'
+
+// Mock dependencies
+const mocks = vi.hoisted(() => ({
+ svgToPngBlob: vi.fn(),
+ svgToSvgBlob: vi.fn(),
+ TopView: {
+ show: vi.fn(),
+ hide: vi.fn()
+ },
+ ImageViewer: vi.fn(() => null),
+ createObjectURL: vi.fn(),
+ revokeObjectURL: vi.fn()
+}))
+
+vi.mock('@renderer/utils/image', () => ({
+ svgToPngBlob: mocks.svgToPngBlob,
+ svgToSvgBlob: mocks.svgToSvgBlob
+}))
+
+vi.mock('@renderer/components/TopView', () => ({
+ TopView: mocks.TopView
+}))
+
+vi.mock('@renderer/components/ImageViewer', () => ({
+ default: mocks.ImageViewer
+}))
+
+// Mock URL.createObjectURL and URL.revokeObjectURL
+Object.assign(global.URL, {
+ createObjectURL: mocks.createObjectURL,
+ revokeObjectURL: mocks.revokeObjectURL
+})
+
+describe('ImagePreviewService', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.createObjectURL.mockReturnValue('blob:mock-url')
+ })
+
+ afterEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('show', () => {
+ it('should handle SVG element input with PNG format', async () => {
+ const mockSvgElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
+ const mockBlob = new Blob(['mock'], { type: 'image/png' })
+
+ mocks.svgToPngBlob.mockResolvedValue(mockBlob)
+
+ await ImagePreviewService.show(mockSvgElement, { format: 'png', scale: 2 })
+
+ expect(mocks.svgToPngBlob).toHaveBeenCalledWith(mockSvgElement, 2)
+ expect(mocks.createObjectURL).toHaveBeenCalledWith(mockBlob)
+ expect(mocks.TopView.show).toHaveBeenCalledWith(expect.any(Function), 'image-preview')
+ })
+
+ it('should handle SVG element input with SVG format', async () => {
+ const mockSvgElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
+ const mockBlob = new Blob(['mock'], { type: 'image/svg+xml' })
+
+ mocks.svgToSvgBlob.mockReturnValue(mockBlob)
+
+ await ImagePreviewService.show(mockSvgElement, { format: 'svg' })
+
+ expect(mocks.svgToSvgBlob).toHaveBeenCalledWith(mockSvgElement)
+ expect(mocks.createObjectURL).toHaveBeenCalledWith(mockBlob)
+ expect(mocks.TopView.show).toHaveBeenCalled()
+ })
+
+ it('should handle string URL input', async () => {
+ const imageUrl = 'https://example.com/image.png'
+
+ await ImagePreviewService.show(imageUrl)
+
+ expect(mocks.TopView.show).toHaveBeenCalled()
+ expect(mocks.createObjectURL).not.toHaveBeenCalled()
+ })
+
+ it('should handle Blob input', async () => {
+ const mockBlob = new Blob(['mock'], { type: 'image/png' })
+
+ await ImagePreviewService.show(mockBlob)
+
+ expect(mocks.createObjectURL).toHaveBeenCalledWith(mockBlob)
+ expect(mocks.TopView.show).toHaveBeenCalled()
+ })
+
+ it('should handle HTMLImageElement input', async () => {
+ const mockImg = document.createElement('img')
+ mockImg.src = 'https://example.com/image.png'
+
+ await ImagePreviewService.show(mockImg)
+
+ expect(mocks.TopView.show).toHaveBeenCalled()
+ expect(mocks.createObjectURL).not.toHaveBeenCalled()
+ })
+
+ it('should throw error for unsupported input type', async () => {
+ const unsupportedInput = { invalid: 'input' } as any
+
+ await expect(ImagePreviewService.show(unsupportedInput)).rejects.toThrow('Unsupported input type')
+ })
+
+ it('should use default scale when not provided', async () => {
+ const mockSvgElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
+ const mockBlob = new Blob(['mock'], { type: 'image/png' })
+
+ mocks.svgToPngBlob.mockResolvedValue(mockBlob)
+
+ await ImagePreviewService.show(mockSvgElement)
+
+ expect(mocks.svgToPngBlob).toHaveBeenCalledWith(mockSvgElement, 3) // default scale
+ })
+ })
+})
diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts
index 88df884f74..e41befe6b4 100644
--- a/src/renderer/src/store/index.ts
+++ b/src/renderer/src/store/index.ts
@@ -60,7 +60,7 @@ const persistedReducer = persistReducer(
{
key: 'cherry-studio',
storage,
- version: 127,
+ version: 128,
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'],
migrate
},
diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts
index 9189faa6b3..6ba778ab86 100644
--- a/src/renderer/src/store/migrate.ts
+++ b/src/renderer/src/store/migrate.ts
@@ -1432,9 +1432,24 @@ const migrateConfig = {
serviceTier: 'auto'
}
- state.settings.codeExecution = settingsInitialState.codeExecution
- state.settings.codeEditor = settingsInitialState.codeEditor
- state.settings.codePreview = settingsInitialState.codePreview
+ state.settings.codeExecution = {
+ enabled: false,
+ timeoutMinutes: 1
+ }
+ state.settings.codeEditor = {
+ enabled: false,
+ themeLight: 'auto',
+ themeDark: 'auto',
+ highlightActiveLine: false,
+ foldGutter: false,
+ autocompletion: true,
+ keymap: false
+ }
+ // @ts-ignore eslint-disable-next-line
+ state.settings.codePreview = {
+ themeLight: 'auto',
+ themeDark: 'auto'
+ }
// @ts-ignore eslint-disable-next-line
if (state.settings.codeStyle) {
@@ -1969,10 +1984,6 @@ const migrateConfig = {
try {
addProvider(state, 'poe')
- if (!state.settings.proxyBypassRules) {
- state.settings.proxyBypassRules = defaultByPassRules
- }
-
// 迁移api选项设置
state.llm.providers.forEach((provider) => {
// 新字段默认支持
@@ -2001,11 +2012,33 @@ const migrateConfig = {
}
}
+ if (!state.settings.proxyBypassRules) {
+ state.settings.proxyBypassRules = defaultByPassRules
+ }
return state
} catch (error) {
logger.error('migrate 127 error', error as Error)
return state
}
+ },
+ '128': (state: RootState) => {
+ try {
+ // @ts-ignore eslint-disable-next-line
+ if (state.settings.codePreview) {
+ // @ts-ignore eslint-disable-next-line
+ state.settings.codeViewer = state.settings.codePreview
+ } else {
+ state.settings.codeViewer = {
+ themeLight: 'auto',
+ themeDark: 'auto'
+ }
+ }
+
+ return state
+ } catch (error) {
+ logger.error('migrate 128 error', error as Error)
+ return state
+ }
}
}
diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts
index be1d950b2a..3da47b8df6 100644
--- a/src/renderer/src/store/settings.ts
+++ b/src/renderer/src/store/settings.ts
@@ -90,13 +90,19 @@ export interface SettingsState {
autocompletion: boolean
keymap: boolean
}
+ /** @deprecated use codeViewer instead */
codePreview: {
themeLight: CodeStyleVarious
themeDark: CodeStyleVarious
}
+ codeViewer: {
+ themeLight: CodeStyleVarious
+ themeDark: CodeStyleVarious
+ }
codeShowLineNumbers: boolean
codeCollapsible: boolean
codeWrappable: boolean
+ codeImageTools: boolean
mathEngine: MathEngine
messageStyle: 'plain' | 'bubble'
foldDisplayMode: 'expanded' | 'compact'
@@ -263,13 +269,19 @@ export const initialState: SettingsState = {
autocompletion: true,
keymap: false
},
+ /** @deprecated use codeViewer instead */
codePreview: {
themeLight: 'auto',
themeDark: 'auto'
},
+ codeViewer: {
+ themeLight: 'auto',
+ themeDark: 'auto'
+ },
codeShowLineNumbers: false,
codeCollapsible: false,
codeWrappable: false,
+ codeImageTools: false,
mathEngine: 'KaTeX',
messageStyle: 'plain',
foldDisplayMode: 'expanded',
@@ -575,12 +587,12 @@ const settingsSlice = createSlice({
state.codeEditor.keymap = action.payload.keymap
}
},
- setCodePreview: (state, action: PayloadAction<{ themeLight?: string; themeDark?: string }>) => {
+ setCodeViewer: (state, action: PayloadAction<{ themeLight?: string; themeDark?: string }>) => {
if (action.payload.themeLight !== undefined) {
- state.codePreview.themeLight = action.payload.themeLight
+ state.codeViewer.themeLight = action.payload.themeLight
}
if (action.payload.themeDark !== undefined) {
- state.codePreview.themeDark = action.payload.themeDark
+ state.codeViewer.themeDark = action.payload.themeDark
}
},
setCodeShowLineNumbers: (state, action: PayloadAction) => {
@@ -592,6 +604,9 @@ const settingsSlice = createSlice({
setCodeWrappable: (state, action: PayloadAction) => {
state.codeWrappable = action.payload
},
+ setCodeImageTools: (state, action: PayloadAction) => {
+ state.codeImageTools = action.payload
+ },
setMathEngine: (state, action: PayloadAction) => {
state.mathEngine = action.payload
},
@@ -868,10 +883,11 @@ export const {
setWebdavDisableStream,
setCodeExecution,
setCodeEditor,
- setCodePreview,
+ setCodeViewer,
setCodeShowLineNumbers,
setCodeCollapsible,
setCodeWrappable,
+ setCodeImageTools,
setMathEngine,
setFoldDisplayMode,
setGridColumns,
diff --git a/src/renderer/src/utils/image.ts b/src/renderer/src/utils/image.ts
index 0a5889a1c1..a6ff7db536 100644
--- a/src/renderer/src/utils/image.ts
+++ b/src/renderer/src/utils/image.ts
@@ -177,3 +177,96 @@ export const captureScrollableDivAsBlob = async (
canvas?.toBlob(func, 'image/png')
})
}
+
+/**
+ * 将 SVG 元素转换为 Canvas 元素。
+ * @param svgElement 要转换的 SVG 元素
+ * @param scale 缩放比例
+ * @returns {Promise} 转换后的 Canvas 元素
+ */
+export const svgToCanvas = (svgElement: SVGElement, scale = 3): Promise => {
+ // 获取 SVG 尺寸信息
+ const viewBox = svgElement.getAttribute('viewBox')?.split(' ').map(Number) || []
+ const rect = svgElement.getBoundingClientRect()
+ const width = viewBox[2] || svgElement.clientWidth || rect.width
+ const height = viewBox[3] || svgElement.clientHeight || rect.height
+
+ // 序列化 SVG 内容
+ const svgData = new XMLSerializer().serializeToString(svgElement)
+
+ let svgBase64: string
+ try {
+ // 使用 TextEncoder 处理 Unicode 字符
+ const encoder = new TextEncoder()
+ const encodedData = encoder.encode(svgData)
+ const binaryString = Array.from(encodedData, (byte) => String.fromCodePoint(byte)).join('')
+ svgBase64 = `data:image/svg+xml;base64,${btoa(binaryString)}`
+ } catch (error) {
+ logger.warn('TextEncoder method failed, falling back to legacy method', error as Error)
+ svgBase64 = `data:image/svg+xml;base64,${btoa(decodeURIComponent(encodeURIComponent(svgData)))}`
+ }
+
+ // 创建 Canvas
+ const canvas = document.createElement('canvas')
+ const ctx = canvas.getContext('2d')
+
+ if (!ctx) {
+ return Promise.reject(new Error('Failed to get canvas context'))
+ }
+
+ canvas.width = width * scale
+ canvas.height = height * scale
+
+ return new Promise((resolve, reject) => {
+ const img = new Image()
+ img.crossOrigin = 'anonymous'
+
+ img.onload = () => {
+ try {
+ ctx.scale(scale, scale)
+ ctx.drawImage(img, 0, 0, width, height)
+ resolve(canvas)
+ } catch (error) {
+ reject(new Error(`Failed to draw image on canvas: ${error}`))
+ }
+ }
+
+ img.onerror = () => {
+ reject(new Error('Failed to load SVG image'))
+ }
+
+ img.src = svgBase64
+ })
+}
+
+/**
+ * 将 SVG 元素转换为 PNG 格式的 Blob。
+ * @param svgElement 要转换的 SVG 元素
+ * @param scale 缩放比例
+ * @returns {Promise} 转换后的 PNG Blob
+ */
+export const svgToPngBlob = (svgElement: SVGElement, scale = 3): Promise => {
+ return new Promise((resolve, reject) => {
+ svgToCanvas(svgElement, scale)
+ .then((canvas) => {
+ canvas.toBlob((blob) => {
+ if (blob) {
+ resolve(blob)
+ } else {
+ reject(new Error('Failed to create blob from canvas'))
+ }
+ }, 'image/png')
+ })
+ .catch(reject)
+ })
+}
+
+/**
+ * 将 SVG 元素转换为 SVG 格式的 Blob。
+ * @param svgElement 要转换的 SVG 元素
+ * @returns {Blob} 转换后的 SVG Blob
+ */
+export const svgToSvgBlob = (svgElement: SVGElement): Blob => {
+ const svgData = new XMLSerializer().serializeToString(svgElement)
+ return new Blob([svgData], { type: 'image/svg+xml' })
+}
diff --git a/src/renderer/src/utils/markdown.ts b/src/renderer/src/utils/markdown.ts
index 99e2591194..cade86f1b3 100644
--- a/src/renderer/src/utils/markdown.ts
+++ b/src/renderer/src/utils/markdown.ts
@@ -253,20 +253,6 @@ export function updateCodeBlock(raw: string, id: string, newContent: string): st
return unified().use(remarkStringify).stringify(tree)
}
-/**
- * 检查是否为有效的 PlantUML 图表
- * @param code 输入的 PlantUML 图表字符串
- * @returns 有效 true,无效 false
- */
-export function isValidPlantUML(code: string | null): boolean {
- if (!code || !code.trim().startsWith('@start')) {
- return false
- }
- const diagramType = code.match(/@start(\w+)/)?.[1]
-
- return diagramType !== undefined && code.search(`@end${diagramType}`) !== -1
-}
-
/**
* 检查代码是否具有HTML特征
* @param code 输入的代码字符串