feat: codeblock dot language (#6783)

* feat(CodeBlock): support dot language in code block

- render DOT using @viz-js/viz
- highlight DOT using @viz-js/lang-dot (CodeEditor only)
- extract a special view map, update file structure
- extract and reuse the PreviewError component across special views
- update dependencies, fix peer dependencies

* chore: prepare for merge
This commit is contained in:
one 2025-07-10 19:32:51 +08:00 committed by GitHub
parent db642f0837
commit 2a72f391b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 314 additions and 90 deletions

View File

@ -92,6 +92,7 @@
"@cherrystudio/embedjs-loader-xml": "^0.1.31",
"@cherrystudio/embedjs-ollama": "^0.1.31",
"@cherrystudio/embedjs-openai": "^0.1.31",
"@codemirror/view": "^6.0.0",
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
"@electron-toolkit/eslint-config-ts": "^3.0.0",
"@electron-toolkit/preload": "^3.0.0",
@ -141,6 +142,8 @@
"@vitest/coverage-v8": "^3.1.4",
"@vitest/ui": "^3.1.4",
"@vitest/web-worker": "^3.1.4",
"@viz-js/lang-dot": "^1.0.5",
"@viz-js/viz": "^3.14.0",
"@xyflow/react": "^12.4.4",
"antd": "patch:antd@npm%3A5.24.7#~/.yarn/patches/antd-npm-5.24.7-356a553ae5.patch",
"archiver": "^7.0.1",
@ -225,6 +228,7 @@
"tiny-pinyin": "^1.3.2",
"tokenx": "^1.1.0",
"typescript": "^5.6.2",
"unified": "^11.0.5",
"uuid": "^10.0.0",
"vite": "6.2.6",
"vitest": "^3.1.4",

View File

@ -129,9 +129,7 @@
overflow-x: auto;
font-family: 'Fira Code', 'Courier New', Courier, monospace;
background-color: var(--color-background-mute);
&:has(.mermaid),
&:has(.plantuml-preview),
&:has(.svg-preview) {
&:has(.special-preview) {
background-color: transparent;
}
&:not(pre pre) {

View File

@ -1,4 +1,4 @@
import { CodeTool, TOOL_SPECS, useCodeTool } from '@renderer/components/CodeToolbar'
import { TOOL_SPECS, useCodeTool } from '@renderer/components/CodeToolbar'
import { useCodeStyle } from '@renderer/context/CodeStyleProvider'
import { useCodeHighlight } from '@renderer/hooks/useCodeHighlight'
import { useSettings } from '@renderer/hooks/useSettings'
@ -12,10 +12,10 @@ import { useTranslation } from 'react-i18next'
import { ThemedToken } from 'shiki/core'
import styled from 'styled-components'
interface CodePreviewProps {
children: string
import { BasicPreviewProps } from './types'
interface CodePreviewProps extends BasicPreviewProps {
language: string
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
}
const MAX_COLLAPSE_HEIGHT = 350

View File

@ -0,0 +1,102 @@
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
import SvgSpinners180Ring from '@renderer/components/Icons/SvgSpinners180Ring'
import { AsyncInitializer } from '@renderer/utils/asyncInitializer'
import { Flex, Spin } from 'antd'
import { debounce } from 'lodash'
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import styled from 'styled-components'
import PreviewError from './PreviewError'
import { BasicPreviewProps } from './types'
// 管理 viz 实例
const vizInitializer = new AsyncInitializer(async () => {
const module = await import('@viz-js/viz')
return await module.instance()
})
/** Graphviz
*
*/
const GraphvizPreview: React.FC<BasicPreviewProps> = ({ children, setTools }) => {
const graphvizRef = useRef<HTMLDivElement>(null)
const [error, setError] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
// 使用通用图像工具
const { handleZoom, handleCopyImage, handleDownload } = usePreviewToolHandlers(graphvizRef, {
imgSelector: 'svg',
prefix: 'graphviz',
enableWheelZoom: true
})
// 使用工具栏
usePreviewTools({
setTools,
handleZoom,
handleCopyImage,
handleDownload
})
// 实际的渲染函数
const renderGraphviz = useCallback(async (content: string) => {
if (!content || !graphvizRef.current) return
try {
setIsLoading(true)
const viz = await vizInitializer.get()
const svgElement = viz.renderSVGElement(content)
// 清空容器并添加新的 SVG
graphvizRef.current.innerHTML = ''
graphvizRef.current.appendChild(svgElement)
// 渲染成功,清除错误记录
setError(null)
} catch (error) {
setError((error as Error).message || 'DOT syntax error or rendering failed')
} finally {
setIsLoading(false)
}
}, [])
// debounce 渲染
const debouncedRender = useMemo(
() =>
debounce((content: string) => {
startTransition(() => renderGraphviz(content))
}, 300),
[renderGraphviz]
)
// 触发渲染
useEffect(() => {
if (children) {
setIsLoading(true)
debouncedRender(children)
} else {
debouncedRender.cancel()
setIsLoading(false)
}
return () => {
debouncedRender.cancel()
}
}, [children, debouncedRender])
return (
<Spin spinning={isLoading} indicator={<SvgSpinners180Ring color="var(--color-text-2)" />}>
<Flex vertical style={{ minHeight: isLoading ? '2rem' : 'auto' }}>
{error && <PreviewError>{error}</PreviewError>}
<StyledGraphviz ref={graphvizRef} className="graphviz special-preview" />
</Flex>
</Spin>
)
}
const StyledGraphviz = styled.div`
overflow: auto;
`
export default memo(GraphvizPreview)

View File

@ -1,5 +1,5 @@
import { nanoid } from '@reduxjs/toolkit'
import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
import SvgSpinners180Ring from '@renderer/components/Icons/SvgSpinners180Ring'
import { useMermaid } from '@renderer/hooks/useMermaid'
import { Flex, Spin } from 'antd'
@ -7,16 +7,14 @@ import { debounce } from 'lodash'
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import styled from 'styled-components'
interface Props {
children: string
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
}
import PreviewError from './PreviewError'
import { BasicPreviewProps } from './types'
/** Mermaid
*
* FIXME: 等将来容易判断代码块结束位置时再重构
*/
const MermaidPreview: React.FC<Props> = ({ children, setTools }) => {
const MermaidPreview: React.FC<BasicPreviewProps> = ({ children, setTools }) => {
const { mermaid, isLoading: isLoadingMermaid, error: mermaidError } = useMermaid()
const mermaidRef = useRef<HTMLDivElement>(null)
const diagramId = useRef<string>(`mermaid-${nanoid(6)}`).current
@ -143,7 +141,7 @@ const MermaidPreview: React.FC<Props> = ({ children, setTools }) => {
return (
<Spin spinning={isLoading} indicator={<SvgSpinners180Ring color="var(--color-text-2)" />}>
<Flex vertical style={{ minHeight: isLoading ? '2rem' : 'auto' }}>
{(mermaidError || error) && <StyledError>{mermaidError || error}</StyledError>}
{(mermaidError || error) && <PreviewError>{mermaidError || error}</PreviewError>}
<StyledMermaid ref={mermaidRef} className="mermaid" />
</Flex>
</Spin>
@ -154,14 +152,4 @@ const StyledMermaid = styled.div`
overflow: auto;
`
const StyledError = styled.div`
overflow: auto;
padding: 16px;
color: #ff4d4f;
border: 1px solid #ff4d4f;
border-radius: 4px;
word-wrap: break-word;
white-space: pre-wrap;
`
export default memo(MermaidPreview)

View File

@ -1,11 +1,13 @@
import { LoadingOutlined } from '@ant-design/icons'
import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
import { Spin } from 'antd'
import pako from 'pako'
import React, { memo, useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { BasicPreviewProps } from './types'
const PlantUMLServer = 'https://www.plantuml.com/plantuml'
function encode64(data: Uint8Array) {
let r = ''
@ -132,12 +134,7 @@ const PlantUMLServerImage: React.FC<PlantUMLServerImageProps> = ({ format, diagr
)
}
interface PlantUMLProps {
children: string
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
}
const PlantUmlPreview: React.FC<PlantUMLProps> = ({ children, setTools }) => {
const PlantUmlPreview: React.FC<BasicPreviewProps> = ({ children, setTools }) => {
const { t } = useTranslation()
const containerRef = useRef<HTMLDivElement>(null)
@ -174,7 +171,7 @@ const PlantUmlPreview: React.FC<PlantUMLProps> = ({ children, setTools }) => {
return (
<div ref={containerRef}>
<PlantUMLServerImage format="svg" diagram={children} className="plantuml-preview" />
<PlantUMLServerImage format="svg" diagram={children} className="plantuml-preview special-preview" />
</div>
)
}

View File

@ -0,0 +1,14 @@
import { memo } from 'react'
import { styled } from 'styled-components'
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 default memo(PreviewError)

View File

@ -1,15 +1,12 @@
import { CodeTool, usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
import { usePreviewToolHandlers, usePreviewTools } from '@renderer/components/CodeToolbar'
import { memo, useEffect, useRef } from 'react'
interface Props {
children: string
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
}
import { BasicPreviewProps } from './types'
/**
* 使 Shadow DOM SVG
*/
const SvgPreview: React.FC<Props> = ({ children, setTools }) => {
const SvgPreview: React.FC<BasicPreviewProps> = ({ children, setTools }) => {
const svgContainerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
@ -58,7 +55,7 @@ const SvgPreview: React.FC<Props> = ({ children, setTools }) => {
handleDownload
})
return <div ref={svgContainerRef} className="svg-preview" />
return <div ref={svgContainerRef} className="svg-preview special-preview" />
}
export default memo(SvgPreview)

View File

@ -0,0 +1,20 @@
import GraphvizPreview from './GraphvizPreview'
import MermaidPreview from './MermaidPreview'
import PlantUmlPreview from './PlantUmlPreview'
import SvgPreview from './SvgPreview'
/**
*
*/
export const SPECIAL_VIEWS = ['mermaid', 'plantuml', 'svg', 'dot', 'graphviz']
/**
*
*/
export const SPECIAL_VIEW_COMPONENTS = {
mermaid: MermaidPreview,
plantuml: PlantUmlPreview,
svg: SvgPreview,
dot: GraphvizPreview,
graphviz: GraphvizPreview
} as const

View File

@ -0,0 +1,2 @@
export * from './types'
export * from './view'

View File

@ -0,0 +1,14 @@
import { CodeTool } from '@renderer/components/CodeToolbar'
/**
* props
*/
export interface BasicPreviewProps {
children: string
setTools?: (value: React.SetStateAction<CodeTool[]>) => void
}
/**
*
*/
export type ViewMode = 'source' | 'special' | 'split'

View File

@ -12,13 +12,10 @@ import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import CodePreview from './CodePreview'
import { SPECIAL_VIEW_COMPONENTS, SPECIAL_VIEWS } from './constants'
import HtmlArtifactsCard from './HtmlArtifactsCard'
import MermaidPreview from './MermaidPreview'
import PlantUmlPreview from './PlantUmlPreview'
import StatusBar from './StatusBar'
import SvgPreview from './SvgPreview'
type ViewMode = 'source' | 'special' | 'split'
import { ViewMode } from './types'
interface Props {
children: string
@ -42,7 +39,7 @@ interface Props {
* - quick
* - core
*/
const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave }) => {
const { t } = useTranslation()
const { codeEditor, codeExecution } = useSettings()
@ -57,7 +54,7 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
return codeExecution.enabled && language === 'python'
}, [codeExecution.enabled, language])
const hasSpecialView = useMemo(() => ['mermaid', 'plantuml', 'svg'].includes(language), [language])
const hasSpecialView = useMemo(() => SPECIAL_VIEWS.includes(language), [language])
const isInSpecialView = useMemo(() => {
return hasSpecialView && viewMode === 'special'
@ -201,14 +198,16 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
// 特殊视图组件映射
const specialView = useMemo(() => {
if (language === 'mermaid') {
return <MermaidPreview setTools={setTools}>{children}</MermaidPreview>
} else if (language === 'plantuml' && isValidPlantUML(children)) {
return <PlantUmlPreview setTools={setTools}>{children}</PlantUmlPreview>
} else if (language === 'svg') {
return <SvgPreview setTools={setTools}>{children}</SvgPreview>
const SpecialView = SPECIAL_VIEW_COMPONENTS[language as keyof typeof SPECIAL_VIEW_COMPONENTS]
if (!SpecialView) return null
// PlantUML 语法验证
if (language === 'plantuml' && !isValidPlantUML(children)) {
return null
}
return null
return <SpecialView setTools={setTools}>{children}</SpecialView>
}, [children, language])
const renderHeader = useMemo(() => {
@ -242,7 +241,7 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
{isExecutable && output && <StatusBar>{output}</StatusBar>}
</CodeBlockWrapper>
)
}
})
const CodeBlockWrapper = styled.div<{ $isInSpecialView: boolean }>`
position: relative;
@ -293,5 +292,3 @@ const SplitViewWrapper = styled.div`
overflow: hidden;
}
`
export default memo(CodeBlockView)

View File

@ -12,45 +12,111 @@ const linterLoaders: Record<string, () => Promise<any>> = {
}
}
/**
*
*/
const specialLanguageLoaders: Record<string, () => Promise<Extension>> = {
dot: async () => {
const mod = await import('@viz-js/lang-dot')
return mod.dot()
}
}
/**
*
*/
async function loadLanguageExtension(language: string, languageMap: Record<string, string>): Promise<Extension | null> {
let normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase()
// 如果语言名包含 `-`,转换为驼峰命名法
if (normalizedLang.includes('-')) {
normalizedLang = normalizedLang.replace(/-([a-z])/g, (_, char) => char.toUpperCase())
}
// 尝试加载特殊语言
const specialLoader = specialLanguageLoaders[normalizedLang]
if (specialLoader) {
try {
return await specialLoader()
} catch (error) {
console.debug(`Failed to load language ${normalizedLang}`, error)
return null
}
}
// 回退到 uiw/codemirror 包含的语言
try {
const { loadLanguage } = await import('@uiw/codemirror-extensions-langs')
const extension = loadLanguage(normalizedLang as any)
return extension || null
} catch (error) {
console.debug(`Failed to load language ${normalizedLang}`, error)
return null
}
}
/**
* linter
*/
async function loadLinterExtension(language: string): Promise<Extension | null> {
const loader = linterLoaders[language]
if (!loader) return null
try {
return await loader()
} catch (error) {
console.debug(`Failed to load linter for ${language}`, error)
return null
}
}
/**
*
*/
export const useLanguageExtensions = (language: string, lint?: boolean) => {
const { languageMap } = useCodeStyle()
const [extensions, setExtensions] = useState<Extension[]>([])
// 加载语言
useEffect(() => {
let normalizedLang = languageMap[language as keyof typeof languageMap] || language.toLowerCase()
let cancelled = false
// 如果语言名包含 `-`,转换为驼峰命名法
if (normalizedLang.includes('-')) {
normalizedLang = normalizedLang.replace(/-([a-z])/g, (_, char) => char.toUpperCase())
}
const loadAllExtensions = async () => {
try {
// 加载所有扩展
const [languageResult, linterResult] = await Promise.allSettled([
loadLanguageExtension(language, languageMap),
lint ? loadLinterExtension(language) : Promise.resolve(null)
])
import('@uiw/codemirror-extensions-langs')
.then(({ loadLanguage }) => {
const extension = loadLanguage(normalizedLang as any)
if (extension) {
setExtensions((prev) => [...prev, extension])
if (cancelled) return
const results: Extension[] = []
// 语言扩展
if (languageResult.status === 'fulfilled' && languageResult.value) {
results.push(languageResult.value)
}
})
.catch((error) => {
console.debug(`Failed to load language: ${normalizedLang}`, error)
})
}, [language, languageMap])
useEffect(() => {
if (!lint) return
// linter 扩展
if (linterResult.status === 'fulfilled' && linterResult.value) {
results.push(linterResult.value)
}
const loader = linterLoaders[language]
if (loader) {
loader()
.then((extension) => {
setExtensions((prev) => [...prev, extension])
})
.catch((error) => {
console.error(`Failed to load linter for ${language}`, error)
})
setExtensions(results)
} catch (error) {
if (!cancelled) {
console.debug('Failed to load language extensions:', error)
setExtensions([])
}
}
}
}, [language, lint])
loadAllExtensions()
return () => {
cancelled = true
}
}, [language, lint, languageMap])
return extensions
}

View File

@ -99,7 +99,8 @@ export const CodeStyleProvider: React.FC<PropsWithChildren> = ({ children }) =>
bash: 'shell',
'objective-c++': 'objective-cpp',
svg: 'xml',
vab: 'vb'
vab: 'vb',
graphviz: 'dot'
} as Record<string, string>
}, [])

View File

@ -1,4 +1,4 @@
import CodeBlockView from '@renderer/components/CodeBlockView'
import { CodeBlockView } from '@renderer/components/CodeBlockView'
import React, { memo, useCallback } from 'react'
interface Props {

View File

@ -3412,7 +3412,7 @@ __metadata:
languageName: node
linkType: hard
"@lezer/common@npm:^1.0.0, @lezer/common@npm:^1.0.2, @lezer/common@npm:^1.1.0, @lezer/common@npm:^1.2.0, @lezer/common@npm:^1.2.1":
"@lezer/common@npm:^1.0.0, @lezer/common@npm:^1.0.2, @lezer/common@npm:^1.0.3, @lezer/common@npm:^1.1.0, @lezer/common@npm:^1.2.0, @lezer/common@npm:^1.2.1":
version: 1.2.3
resolution: "@lezer/common@npm:1.2.3"
checksum: 10c0/fe9f8e111080ef94037a34ca2af1221c8d01c1763ba5ecf708a286185c76119509a5d19d924c8842172716716ddce22d7834394670c4a9432f0ba9f3b7c0f50d
@ -3515,7 +3515,7 @@ __metadata:
languageName: node
linkType: hard
"@lezer/lr@npm:^1.0.0, @lezer/lr@npm:^1.1.0, @lezer/lr@npm:^1.3.0, @lezer/lr@npm:^1.3.1, @lezer/lr@npm:^1.3.10, @lezer/lr@npm:^1.3.3, @lezer/lr@npm:^1.4.0":
"@lezer/lr@npm:^1.0.0, @lezer/lr@npm:^1.1.0, @lezer/lr@npm:^1.3.0, @lezer/lr@npm:^1.3.1, @lezer/lr@npm:^1.3.10, @lezer/lr@npm:^1.3.3, @lezer/lr@npm:^1.4.0, @lezer/lr@npm:^1.4.2":
version: 1.4.2
resolution: "@lezer/lr@npm:1.4.2"
dependencies:
@ -3578,7 +3578,7 @@ __metadata:
languageName: node
linkType: hard
"@lezer/xml@npm:^1.0.0":
"@lezer/xml@npm:^1.0.0, @lezer/xml@npm:^1.0.2":
version: 1.0.6
resolution: "@lezer/xml@npm:1.0.6"
dependencies:
@ -6962,6 +6962,26 @@ __metadata:
languageName: node
linkType: hard
"@viz-js/lang-dot@npm:^1.0.5":
version: 1.0.5
resolution: "@viz-js/lang-dot@npm:1.0.5"
dependencies:
"@codemirror/language": "npm:^6.8.0"
"@lezer/common": "npm:^1.0.3"
"@lezer/highlight": "npm:^1.1.6"
"@lezer/lr": "npm:^1.4.2"
"@lezer/xml": "npm:^1.0.2"
checksum: 10c0/86e81bf077e0a6f418fe2d5cfd8d7f7a7c032bdec13e5dfe3d21620c548e674832f6c9b300eeaad7b0842a3c4044d4ce33d5af9e359ae1efeda0a84d772b77a4
languageName: node
linkType: hard
"@viz-js/viz@npm:^3.14.0":
version: 3.14.0
resolution: "@viz-js/viz@npm:3.14.0"
checksum: 10c0/901afa2d99e8f33cc4abf352f1559e0c16958e01f0750a65a33799aebfe175a18d74f6945f1ff93f64b53b69976dc3d07d39d65c58dda955abd0979dacc4294c
languageName: node
linkType: hard
"@vue/compiler-core@npm:3.5.17":
version: 3.5.17
resolution: "@vue/compiler-core@npm:3.5.17"
@ -7075,6 +7095,7 @@ __metadata:
"@cherrystudio/embedjs-openai": "npm:^0.1.31"
"@cherrystudio/mac-system-ocr": "npm:^0.2.2"
"@cherrystudio/pdf-to-img-napi": "npm:^0.0.1"
"@codemirror/view": "npm:^6.0.0"
"@electron-toolkit/eslint-config-prettier": "npm:^3.0.0"
"@electron-toolkit/eslint-config-ts": "npm:^3.0.0"
"@electron-toolkit/preload": "npm:^3.0.0"
@ -7127,6 +7148,8 @@ __metadata:
"@vitest/coverage-v8": "npm:^3.1.4"
"@vitest/ui": "npm:^3.1.4"
"@vitest/web-worker": "npm:^3.1.4"
"@viz-js/lang-dot": "npm:^1.0.5"
"@viz-js/viz": "npm:^3.14.0"
"@xyflow/react": "npm:^12.4.4"
antd: "patch:antd@npm%3A5.24.7#~/.yarn/patches/antd-npm-5.24.7-356a553ae5.patch"
archiver: "npm:^7.0.1"
@ -7221,6 +7244,7 @@ __metadata:
tokenx: "npm:^1.1.0"
turndown: "npm:7.2.0"
typescript: "npm:^5.6.2"
unified: "npm:^11.0.5"
uuid: "npm:^10.0.0"
vite: "npm:6.2.6"
vitest: "npm:^3.1.4"
@ -19565,7 +19589,7 @@ __metadata:
languageName: node
linkType: hard
"unified@npm:^11.0.0":
"unified@npm:^11.0.0, unified@npm:^11.0.5":
version: 11.0.5
resolution: "unified@npm:11.0.5"
dependencies: