mirror of
https://git.mirrors.martin98.com/https://github.com/langgenius/dify.git
synced 2025-05-31 02:24:53 +08:00
Mermaid analysis optimization (#18089)
Co-authored-by: luowei <glpat-EjySCyNjWiLqAED-YmwM> Co-authored-by: crazywoola <427733928@qq.com> Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
parent
413271eaa6
commit
ef188564f3
@ -1,116 +1,528 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import mermaid from 'mermaid'
|
||||
import { usePrevious } from 'ahooks'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'
|
||||
import { cleanUpSvgCode } from './utils'
|
||||
import { MoonIcon, SunIcon } from '@heroicons/react/24/solid'
|
||||
import {
|
||||
cleanUpSvgCode,
|
||||
isMermaidCodeComplete,
|
||||
prepareMermaidCode,
|
||||
processSvgForTheme,
|
||||
svgToBase64,
|
||||
waitForDOMElement,
|
||||
} from './utils'
|
||||
import LoadingAnim from '@/app/components/base/chat/chat/loading-anim'
|
||||
import cn from '@/utils/classnames'
|
||||
import ImagePreview from '@/app/components/base/image-uploader/image-preview'
|
||||
import { Theme } from '@/types/app'
|
||||
|
||||
let mermaidAPI: any
|
||||
mermaidAPI = null
|
||||
// Global flags and cache for mermaid
|
||||
let isMermaidInitialized = false
|
||||
const diagramCache = new Map<string, string>()
|
||||
let mermaidAPI: any = null
|
||||
|
||||
if (typeof window !== 'undefined')
|
||||
mermaidAPI = mermaid.mermaidAPI
|
||||
|
||||
const svgToBase64 = (svgGraph: string) => {
|
||||
const svgBytes = new TextEncoder().encode(svgGraph)
|
||||
const blob = new Blob([svgBytes], { type: 'image/svg+xml;charset=utf-8' })
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onloadend = () => resolve(reader.result)
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
// Theme configurations
|
||||
const THEMES = {
|
||||
light: {
|
||||
name: 'Light Theme',
|
||||
background: '#ffffff',
|
||||
primaryColor: '#ffffff',
|
||||
primaryBorderColor: '#000000',
|
||||
primaryTextColor: '#000000',
|
||||
secondaryColor: '#ffffff',
|
||||
tertiaryColor: '#ffffff',
|
||||
nodeColors: [
|
||||
{ bg: '#f0f9ff', color: '#0369a1' },
|
||||
{ bg: '#f0fdf4', color: '#166534' },
|
||||
{ bg: '#fef2f2', color: '#b91c1c' },
|
||||
{ bg: '#faf5ff', color: '#7e22ce' },
|
||||
{ bg: '#fffbeb', color: '#b45309' },
|
||||
],
|
||||
connectionColor: '#74a0e0',
|
||||
},
|
||||
dark: {
|
||||
name: 'Dark Theme',
|
||||
background: '#1e293b',
|
||||
primaryColor: '#334155',
|
||||
primaryBorderColor: '#94a3b8',
|
||||
primaryTextColor: '#e2e8f0',
|
||||
secondaryColor: '#475569',
|
||||
tertiaryColor: '#334155',
|
||||
nodeColors: [
|
||||
{ bg: '#164e63', color: '#e0f2fe' },
|
||||
{ bg: '#14532d', color: '#dcfce7' },
|
||||
{ bg: '#7f1d1d', color: '#fee2e2' },
|
||||
{ bg: '#581c87', color: '#f3e8ff' },
|
||||
{ bg: '#78350f', color: '#fef3c7' },
|
||||
],
|
||||
connectionColor: '#60a5fa',
|
||||
},
|
||||
}
|
||||
|
||||
const Flowchart = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: {
|
||||
PrimitiveCode: string
|
||||
} & {
|
||||
ref: React.RefObject<unknown>;
|
||||
},
|
||||
) => {
|
||||
const { t } = useTranslation()
|
||||
const [svgCode, setSvgCode] = useState(null)
|
||||
const [look, setLook] = useState<'classic' | 'handDrawn'>('classic')
|
||||
|
||||
const prevPrimitiveCode = usePrevious(props.PrimitiveCode)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const timeRef = useRef<number>(0)
|
||||
const [errMsg, setErrMsg] = useState('')
|
||||
const [imagePreviewUrl, setImagePreviewUrl] = useState('')
|
||||
|
||||
const renderFlowchart = useCallback(async (PrimitiveCode: string) => {
|
||||
setSvgCode(null)
|
||||
setIsLoading(true)
|
||||
|
||||
/**
|
||||
* Initializes mermaid library with default configuration
|
||||
*/
|
||||
const initMermaid = () => {
|
||||
if (typeof window !== 'undefined' && !isMermaidInitialized) {
|
||||
try {
|
||||
if (typeof window !== 'undefined' && mermaidAPI) {
|
||||
const svgGraph = await mermaidAPI.render('flowchart', PrimitiveCode)
|
||||
const base64Svg: any = await svgToBase64(cleanUpSvgCode(svgGraph.svg))
|
||||
setSvgCode(base64Svg)
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
if (prevPrimitiveCode === props.PrimitiveCode) {
|
||||
setIsLoading(false)
|
||||
setErrMsg((error as Error).message)
|
||||
}
|
||||
}
|
||||
}, [props.PrimitiveCode])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
mermaid.initialize({
|
||||
startOnLoad: true,
|
||||
theme: 'neutral',
|
||||
look,
|
||||
startOnLoad: false,
|
||||
fontFamily: 'sans-serif',
|
||||
securityLevel: 'loose',
|
||||
flowchart: {
|
||||
htmlLabels: true,
|
||||
useMaxWidth: true,
|
||||
diagramPadding: 10,
|
||||
curve: 'basis',
|
||||
nodeSpacing: 50,
|
||||
rankSpacing: 70,
|
||||
},
|
||||
gantt: {
|
||||
titleTopMargin: 25,
|
||||
barHeight: 20,
|
||||
barGap: 4,
|
||||
topPadding: 50,
|
||||
leftPadding: 75,
|
||||
gridLineStartPadding: 35,
|
||||
fontSize: 11,
|
||||
numberSectionStyles: 4,
|
||||
axisFormat: '%Y-%m-%d',
|
||||
},
|
||||
maxTextSize: 50000,
|
||||
})
|
||||
|
||||
renderFlowchart(props.PrimitiveCode)
|
||||
isMermaidInitialized = true
|
||||
}
|
||||
}, [look])
|
||||
catch (error) {
|
||||
console.error('Mermaid initialization error:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
return isMermaidInitialized
|
||||
}
|
||||
|
||||
const Flowchart = React.forwardRef((props: {
|
||||
PrimitiveCode: string
|
||||
theme?: 'light' | 'dark'
|
||||
}, ref) => {
|
||||
const { t } = useTranslation()
|
||||
const [svgCode, setSvgCode] = useState<string | null>(null)
|
||||
const [look, setLook] = useState<'classic' | 'handDrawn'>('classic')
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
const [currentTheme, setCurrentTheme] = useState<'light' | 'dark'>(props.theme || 'light')
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const chartId = useRef(`mermaid-chart-${Math.random().toString(36).substr(2, 9)}`).current
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const renderTimeoutRef = useRef<NodeJS.Timeout>()
|
||||
const [errMsg, setErrMsg] = useState('')
|
||||
const [imagePreviewUrl, setImagePreviewUrl] = useState('')
|
||||
const [isCodeComplete, setIsCodeComplete] = useState(false)
|
||||
const codeCompletionCheckRef = useRef<NodeJS.Timeout>()
|
||||
|
||||
// Create cache key from code, style and theme
|
||||
const cacheKey = useMemo(() => {
|
||||
return `${props.PrimitiveCode}-${look}-${currentTheme}`
|
||||
}, [props.PrimitiveCode, look, currentTheme])
|
||||
|
||||
/**
|
||||
* Renders Mermaid chart
|
||||
*/
|
||||
const renderMermaidChart = async (code: string, style: 'classic' | 'handDrawn') => {
|
||||
if (style === 'handDrawn') {
|
||||
// Special handling for hand-drawn style
|
||||
if (containerRef.current)
|
||||
containerRef.current.innerHTML = `<div id="${chartId}"></div>`
|
||||
await new Promise(resolve => setTimeout(resolve, 30))
|
||||
|
||||
if (typeof window !== 'undefined' && mermaidAPI) {
|
||||
// Prefer using mermaidAPI directly for hand-drawn style
|
||||
return await mermaidAPI.render(chartId, code)
|
||||
}
|
||||
else {
|
||||
// Fall back to standard rendering if mermaidAPI is not available
|
||||
const { svg } = await mermaid.render(chartId, code)
|
||||
return { svg }
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Standard rendering for classic style - using the extracted waitForDOMElement function
|
||||
const renderWithRetry = async () => {
|
||||
if (containerRef.current)
|
||||
containerRef.current.innerHTML = `<div id="${chartId}"></div>`
|
||||
await new Promise(resolve => setTimeout(resolve, 30))
|
||||
const { svg } = await mermaid.render(chartId, code)
|
||||
return { svg }
|
||||
}
|
||||
return await waitForDOMElement(renderWithRetry)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle rendering errors
|
||||
*/
|
||||
const handleRenderError = (error: any) => {
|
||||
console.error('Mermaid rendering error:', error)
|
||||
const errorMsg = (error as Error).message
|
||||
|
||||
if (errorMsg.includes('getAttribute')) {
|
||||
diagramCache.clear()
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
securityLevel: 'loose',
|
||||
})
|
||||
}
|
||||
else {
|
||||
setErrMsg(`Rendering chart failed, please refresh and try again ${look === 'handDrawn' ? 'Or try using classic mode' : ''}`)
|
||||
}
|
||||
|
||||
if (look === 'handDrawn') {
|
||||
try {
|
||||
// Clear possible cache issues
|
||||
diagramCache.delete(`${props.PrimitiveCode}-handDrawn-${currentTheme}`)
|
||||
|
||||
// Reset mermaid configuration
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
securityLevel: 'loose',
|
||||
theme: 'default',
|
||||
maxTextSize: 50000,
|
||||
})
|
||||
|
||||
// Try rendering with standard mode
|
||||
setLook('classic')
|
||||
setErrMsg('Hand-drawn mode is not supported for this diagram. Switched to classic mode.')
|
||||
|
||||
// Delay error clearing
|
||||
setTimeout(() => {
|
||||
if (containerRef.current) {
|
||||
// Try rendering again with standard mode, but can't call renderFlowchart directly due to circular dependency
|
||||
// Instead set state to trigger re-render
|
||||
setIsCodeComplete(true) // This will trigger useEffect re-render
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
catch (e) {
|
||||
console.error('Reset after handDrawn error failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
// Initialize mermaid
|
||||
useEffect(() => {
|
||||
if (timeRef.current)
|
||||
window.clearTimeout(timeRef.current)
|
||||
const api = initMermaid()
|
||||
if (api)
|
||||
setIsInitialized(true)
|
||||
}, [])
|
||||
|
||||
timeRef.current = window.setTimeout(() => {
|
||||
renderFlowchart(props.PrimitiveCode)
|
||||
// Update theme when prop changes
|
||||
useEffect(() => {
|
||||
if (props.theme)
|
||||
setCurrentTheme(props.theme)
|
||||
}, [props.theme])
|
||||
|
||||
// Validate mermaid code and check for completeness
|
||||
useEffect(() => {
|
||||
if (codeCompletionCheckRef.current)
|
||||
clearTimeout(codeCompletionCheckRef.current)
|
||||
|
||||
// Reset code complete status when code changes
|
||||
setIsCodeComplete(false)
|
||||
|
||||
// If no code or code is extremely short, don't proceed
|
||||
if (!props.PrimitiveCode || props.PrimitiveCode.length < 10)
|
||||
return
|
||||
|
||||
// Check if code already in cache - if so we know it's valid
|
||||
if (diagramCache.has(cacheKey)) {
|
||||
setIsCodeComplete(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Initial check using the extracted isMermaidCodeComplete function
|
||||
const isComplete = isMermaidCodeComplete(props.PrimitiveCode)
|
||||
if (isComplete) {
|
||||
setIsCodeComplete(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Set a delay to check again in case code is still being generated
|
||||
codeCompletionCheckRef.current = setTimeout(() => {
|
||||
setIsCodeComplete(isMermaidCodeComplete(props.PrimitiveCode))
|
||||
}, 300)
|
||||
}, [props.PrimitiveCode])
|
||||
|
||||
return () => {
|
||||
if (codeCompletionCheckRef.current)
|
||||
clearTimeout(codeCompletionCheckRef.current)
|
||||
}
|
||||
}, [props.PrimitiveCode, cacheKey])
|
||||
|
||||
/**
|
||||
* Renders flowchart based on provided code
|
||||
*/
|
||||
const renderFlowchart = useCallback(async (primitiveCode: string) => {
|
||||
if (!isInitialized || !containerRef.current) {
|
||||
setIsLoading(false)
|
||||
setErrMsg(!isInitialized ? 'Mermaid initialization failed' : 'Container element not found')
|
||||
return
|
||||
}
|
||||
|
||||
// Don't render if code is not complete yet
|
||||
if (!isCodeComplete) {
|
||||
setIsLoading(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Return cached result if available
|
||||
if (diagramCache.has(cacheKey)) {
|
||||
setSvgCode(diagramCache.get(cacheKey) || null)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setErrMsg('')
|
||||
|
||||
try {
|
||||
let finalCode: string
|
||||
|
||||
// Check if it's a gantt chart
|
||||
const isGanttChart = primitiveCode.trim().startsWith('gantt')
|
||||
|
||||
if (isGanttChart) {
|
||||
// For gantt charts, ensure each task is on its own line
|
||||
// and preserve exact whitespace/format
|
||||
finalCode = primitiveCode.trim()
|
||||
}
|
||||
else {
|
||||
// Step 1: Clean and prepare Mermaid code using the extracted prepareMermaidCode function
|
||||
finalCode = prepareMermaidCode(primitiveCode, look)
|
||||
}
|
||||
|
||||
// Step 2: Render chart
|
||||
const svgGraph = await renderMermaidChart(finalCode, look)
|
||||
|
||||
// Step 3: Apply theme to SVG using the extracted processSvgForTheme function
|
||||
const processedSvg = processSvgForTheme(
|
||||
svgGraph.svg,
|
||||
currentTheme === Theme.dark,
|
||||
look === 'handDrawn',
|
||||
THEMES,
|
||||
)
|
||||
|
||||
// Step 4: Clean SVG code and convert to base64 using the extracted functions
|
||||
const cleanedSvg = cleanUpSvgCode(processedSvg)
|
||||
const base64Svg = await svgToBase64(cleanedSvg)
|
||||
|
||||
if (base64Svg && typeof base64Svg === 'string') {
|
||||
diagramCache.set(cacheKey, base64Svg)
|
||||
setSvgCode(base64Svg)
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
}
|
||||
catch (error) {
|
||||
// Error handling
|
||||
handleRenderError(error)
|
||||
}
|
||||
}, [chartId, isInitialized, cacheKey, isCodeComplete, look, currentTheme, t])
|
||||
|
||||
/**
|
||||
* Configure mermaid based on selected style and theme
|
||||
*/
|
||||
const configureMermaid = useCallback(() => {
|
||||
if (typeof window !== 'undefined' && isInitialized) {
|
||||
const themeVars = THEMES[currentTheme]
|
||||
const config: any = {
|
||||
startOnLoad: false,
|
||||
securityLevel: 'loose',
|
||||
fontFamily: 'sans-serif',
|
||||
maxTextSize: 50000,
|
||||
gantt: {
|
||||
titleTopMargin: 25,
|
||||
barHeight: 20,
|
||||
barGap: 4,
|
||||
topPadding: 50,
|
||||
leftPadding: 75,
|
||||
gridLineStartPadding: 35,
|
||||
fontSize: 11,
|
||||
numberSectionStyles: 4,
|
||||
axisFormat: '%Y-%m-%d',
|
||||
},
|
||||
}
|
||||
|
||||
if (look === 'classic') {
|
||||
config.theme = currentTheme === 'dark' ? 'dark' : 'neutral'
|
||||
config.flowchart = {
|
||||
htmlLabels: true,
|
||||
useMaxWidth: true,
|
||||
diagramPadding: 12,
|
||||
nodeSpacing: 60,
|
||||
rankSpacing: 80,
|
||||
curve: 'linear',
|
||||
ranker: 'tight-tree',
|
||||
}
|
||||
}
|
||||
else {
|
||||
config.theme = 'default'
|
||||
config.themeCSS = `
|
||||
.node rect { fill-opacity: 0.85; }
|
||||
.edgePath .path { stroke-width: 1.5px; }
|
||||
.label { font-family: 'sans-serif'; }
|
||||
.edgeLabel { font-family: 'sans-serif'; }
|
||||
.cluster rect { rx: 5px; ry: 5px; }
|
||||
`
|
||||
config.themeVariables = {
|
||||
fontSize: '14px',
|
||||
fontFamily: 'sans-serif',
|
||||
}
|
||||
config.flowchart = {
|
||||
htmlLabels: true,
|
||||
useMaxWidth: true,
|
||||
diagramPadding: 10,
|
||||
nodeSpacing: 40,
|
||||
rankSpacing: 60,
|
||||
curve: 'basis',
|
||||
}
|
||||
config.themeVariables.primaryBorderColor = currentTheme === 'dark' ? THEMES.dark.connectionColor : THEMES.light.connectionColor
|
||||
}
|
||||
|
||||
if (currentTheme === 'dark' && !config.themeVariables) {
|
||||
config.themeVariables = {
|
||||
background: themeVars.background,
|
||||
primaryColor: themeVars.primaryColor,
|
||||
primaryBorderColor: themeVars.primaryBorderColor,
|
||||
primaryTextColor: themeVars.primaryTextColor,
|
||||
secondaryColor: themeVars.secondaryColor,
|
||||
tertiaryColor: themeVars.tertiaryColor,
|
||||
fontFamily: 'sans-serif',
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
mermaid.initialize(config)
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Config error:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, [currentTheme, isInitialized, look])
|
||||
|
||||
// Effect for theme and style configuration
|
||||
useEffect(() => {
|
||||
if (diagramCache.has(cacheKey)) {
|
||||
setSvgCode(diagramCache.get(cacheKey) || null)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (configureMermaid() && containerRef.current && isCodeComplete)
|
||||
renderFlowchart(props.PrimitiveCode)
|
||||
}, [look, props.PrimitiveCode, renderFlowchart, isInitialized, cacheKey, currentTheme, isCodeComplete, configureMermaid])
|
||||
|
||||
// Effect for rendering with debounce
|
||||
useEffect(() => {
|
||||
if (diagramCache.has(cacheKey)) {
|
||||
setSvgCode(diagramCache.get(cacheKey) || null)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (renderTimeoutRef.current)
|
||||
clearTimeout(renderTimeoutRef.current)
|
||||
|
||||
if (isCodeComplete) {
|
||||
renderTimeoutRef.current = setTimeout(() => {
|
||||
if (isInitialized)
|
||||
renderFlowchart(props.PrimitiveCode)
|
||||
}, 300)
|
||||
}
|
||||
else {
|
||||
setIsLoading(true)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (renderTimeoutRef.current)
|
||||
clearTimeout(renderTimeoutRef.current)
|
||||
}
|
||||
}, [props.PrimitiveCode, renderFlowchart, isInitialized, cacheKey, isCodeComplete])
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (containerRef.current)
|
||||
containerRef.current.innerHTML = ''
|
||||
if (renderTimeoutRef.current)
|
||||
clearTimeout(renderTimeoutRef.current)
|
||||
if (codeCompletionCheckRef.current)
|
||||
clearTimeout(codeCompletionCheckRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const toggleTheme = () => {
|
||||
setCurrentTheme(prevTheme => prevTheme === 'light' ? Theme.dark : Theme.light)
|
||||
diagramCache.clear()
|
||||
}
|
||||
|
||||
// Style classes for theme-dependent elements
|
||||
const themeClasses = {
|
||||
container: cn('relative', {
|
||||
'bg-white': currentTheme === Theme.light,
|
||||
'bg-slate-900': currentTheme === Theme.dark,
|
||||
}),
|
||||
mermaidDiv: cn('mermaid cursor-pointer h-auto w-full relative', {
|
||||
'bg-white': currentTheme === Theme.light,
|
||||
'bg-slate-900': currentTheme === Theme.dark,
|
||||
}),
|
||||
errorMessage: cn('py-4 px-[26px]', {
|
||||
'text-red-500': currentTheme === Theme.light,
|
||||
'text-red-400': currentTheme === Theme.dark,
|
||||
}),
|
||||
errorIcon: cn('w-6 h-6', {
|
||||
'text-red-500': currentTheme === Theme.light,
|
||||
'text-red-400': currentTheme === Theme.dark,
|
||||
}),
|
||||
segmented: cn('msh-segmented msh-segmented-sm css-23bs09 css-var-r1', {
|
||||
'text-gray-700': currentTheme === Theme.light,
|
||||
'text-gray-300': currentTheme === Theme.dark,
|
||||
}),
|
||||
themeToggle: cn('flex items-center justify-center w-10 h-10 rounded-full transition-all duration-300 shadow-md backdrop-blur-sm', {
|
||||
'bg-white/80 hover:bg-white hover:shadow-lg text-gray-700 border border-gray-200': currentTheme === Theme.light,
|
||||
'bg-slate-800/80 hover:bg-slate-700 hover:shadow-lg text-yellow-300 border border-slate-600': currentTheme === Theme.dark,
|
||||
}),
|
||||
}
|
||||
|
||||
// Style classes for look options
|
||||
const getLookButtonClass = (lookType: 'classic' | 'handDrawn') => {
|
||||
return cn(
|
||||
'flex items-center justify-center mb-4 w-[calc((100%-8px)/2)] h-8 rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg cursor-pointer system-sm-medium text-text-secondary',
|
||||
look === lookType && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary',
|
||||
currentTheme === Theme.dark && 'border-slate-600 bg-slate-800 text-slate-300',
|
||||
look === lookType && currentTheme === Theme.dark && 'border-blue-500 bg-slate-700 text-white',
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line ts/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
(<div ref={ref}>
|
||||
<div className="msh-segmented msh-segmented-sm css-23bs09 css-var-r1">
|
||||
<div ref={ref as React.RefObject<HTMLDivElement>} className={themeClasses.container}>
|
||||
<div className={themeClasses.segmented}>
|
||||
<div className="msh-segmented-group">
|
||||
<label className="msh-segmented-item m-2 flex w-[200px] items-center space-x-1">
|
||||
<div key='classic'
|
||||
className={cn('system-sm-medium mb-4 flex h-8 w-[calc((100%-8px)/2)] cursor-pointer items-center justify-center rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg text-text-secondary',
|
||||
look === 'classic' && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary',
|
||||
)}
|
||||
|
||||
<label className="msh-segmented-item flex items-center space-x-1 m-2 w-[200px]">
|
||||
<div
|
||||
key='classic'
|
||||
className={getLookButtonClass('classic')}
|
||||
onClick={() => setLook('classic')}
|
||||
>
|
||||
<div className="msh-segmented-item-label">{t('app.mermaid.classic')}</div>
|
||||
</div>
|
||||
<div key='handDrawn'
|
||||
className={cn(
|
||||
'system-sm-medium mb-4 flex h-8 w-[calc((100%-8px)/2)] cursor-pointer items-center justify-center rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg text-text-secondary',
|
||||
look === 'handDrawn' && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary',
|
||||
)}
|
||||
<div
|
||||
key='handDrawn'
|
||||
className={getLookButtonClass('handDrawn')}
|
||||
onClick={() => setLook('handDrawn')}
|
||||
>
|
||||
<div className="msh-segmented-item-label">{t('app.mermaid.handDrawn')}</div>
|
||||
@ -118,31 +530,60 @@ const Flowchart = (
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
svgCode
|
||||
&& <div className="mermaid object-fit: cover h-auto w-full cursor-pointer" onClick={() => setImagePreviewUrl(svgCode)}>
|
||||
{svgCode && <img src={svgCode} alt="mermaid_chart" />}
|
||||
|
||||
<div ref={containerRef} style={{ position: 'absolute', visibility: 'hidden', height: 0, overflow: 'hidden' }} />
|
||||
|
||||
{isLoading && !svgCode && (
|
||||
<div className='py-4 px-[26px]'>
|
||||
<LoadingAnim type='text'/>
|
||||
{!isCodeComplete && (
|
||||
<div className="mt-2 text-sm text-gray-500">
|
||||
{t('common.wait_for_completion', 'Waiting for diagram code to complete...')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
{isLoading
|
||||
&& <div className='px-[26px] py-4'>
|
||||
<LoadingAnim type='text' />
|
||||
)}
|
||||
|
||||
{svgCode && (
|
||||
<div className={themeClasses.mermaidDiv} style={{ objectFit: 'cover' }} onClick={() => setImagePreviewUrl(svgCode)}>
|
||||
<div className="absolute left-2 bottom-2 z-[100]">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleTheme()
|
||||
}}
|
||||
className={themeClasses.themeToggle}
|
||||
title={(currentTheme === Theme.light ? t('app.theme.switchDark') : t('app.theme.switchLight')) || ''}
|
||||
style={{ transform: 'translate3d(0, 0, 0)' }}
|
||||
>
|
||||
{currentTheme === Theme.light ? <MoonIcon className="h-5 w-5" /> : <SunIcon className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<img
|
||||
src={svgCode}
|
||||
alt="mermaid_chart"
|
||||
style={{ maxWidth: '100%' }}
|
||||
onError={() => { setErrMsg('Chart rendering failed, please refresh and retry') }}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
{
|
||||
errMsg
|
||||
&& <div className='px-[26px] py-4'>
|
||||
<ExclamationTriangleIcon className='h-6 w-6 text-red-500' />
|
||||
|
||||
{errMsg}
|
||||
)}
|
||||
|
||||
{errMsg && (
|
||||
<div className={themeClasses.errorMessage}>
|
||||
<div className="flex items-center">
|
||||
<ExclamationTriangleIcon className={themeClasses.errorIcon}/>
|
||||
<span className="ml-2">{errMsg}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
{
|
||||
imagePreviewUrl && (<ImagePreview title='mermaid_chart' url={imagePreviewUrl} onCancel={() => setImagePreviewUrl('')} />)
|
||||
}
|
||||
</div>)
|
||||
)}
|
||||
|
||||
{imagePreviewUrl && (
|
||||
<ImagePreview title='mermaid_chart' url={imagePreviewUrl} onCancel={() => setImagePreviewUrl('')} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
Flowchart.displayName = 'Flowchart'
|
||||
|
||||
|
@ -1,3 +1,236 @@
|
||||
export function cleanUpSvgCode(svgCode: string): string {
|
||||
return svgCode.replaceAll('<br>', '<br/>')
|
||||
}
|
||||
|
||||
/**
|
||||
* Preprocesses mermaid code to fix common syntax issues
|
||||
*/
|
||||
export function preprocessMermaidCode(code: string): string {
|
||||
if (!code || typeof code !== 'string')
|
||||
return ''
|
||||
|
||||
// First check if this is a gantt chart
|
||||
if (code.trim().startsWith('gantt')) {
|
||||
// For gantt charts, we need to ensure each task is on its own line
|
||||
// Split the code into lines and process each line separately
|
||||
const lines = code.split('\n').map(line => line.trim())
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
return code
|
||||
// Replace English colons with Chinese colons in section nodes to avoid parsing issues
|
||||
.replace(/section\s+([^:]+):/g, (match, sectionName) => `section ${sectionName}:`)
|
||||
// Fix common syntax issues
|
||||
.replace(/fifopacket/g, 'rect')
|
||||
// Ensure graph has direction
|
||||
.replace(/^graph\s+((?:TB|BT|RL|LR)*)/, (match, direction) => {
|
||||
return direction ? match : 'graph TD'
|
||||
})
|
||||
// Clean up empty lines and extra spaces
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares mermaid code based on selected style
|
||||
*/
|
||||
export function prepareMermaidCode(code: string, style: 'classic' | 'handDrawn'): string {
|
||||
let finalCode = preprocessMermaidCode(code)
|
||||
|
||||
// Special handling for gantt charts
|
||||
if (finalCode.trim().startsWith('gantt')) {
|
||||
// For gantt charts, preserve the structure exactly as is
|
||||
return finalCode
|
||||
}
|
||||
|
||||
if (style === 'handDrawn') {
|
||||
finalCode = finalCode
|
||||
// Remove style definitions that interfere with hand-drawn style
|
||||
.replace(/style\s+[^\n]+/g, '')
|
||||
.replace(/linkStyle\s+[^\n]+/g, '')
|
||||
.replace(/^flowchart/, 'graph')
|
||||
// Remove any styles that might interfere with hand-drawn style
|
||||
.replace(/class="[^"]*"/g, '')
|
||||
.replace(/fill="[^"]*"/g, '')
|
||||
.replace(/stroke="[^"]*"/g, '')
|
||||
|
||||
// Ensure hand-drawn style charts always start with graph
|
||||
if (!finalCode.startsWith('graph') && !finalCode.startsWith('flowchart'))
|
||||
finalCode = `graph TD\n${finalCode}`
|
||||
}
|
||||
|
||||
return finalCode
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts SVG to base64 string for image rendering
|
||||
*/
|
||||
export function svgToBase64(svgGraph: string): Promise<string> {
|
||||
if (!svgGraph)
|
||||
return Promise.resolve('')
|
||||
|
||||
try {
|
||||
// Ensure SVG has correct XML declaration
|
||||
if (!svgGraph.includes('<?xml'))
|
||||
svgGraph = `<?xml version="1.0" encoding="UTF-8"?>${svgGraph}`
|
||||
|
||||
const blob = new Blob([new TextEncoder().encode(svgGraph)], { type: 'image/svg+xml;charset=utf-8' })
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onloadend = () => resolve(reader.result as string)
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error converting SVG to base64:', error)
|
||||
return Promise.resolve('')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes SVG for theme styling
|
||||
*/
|
||||
export function processSvgForTheme(
|
||||
svg: string,
|
||||
isDark: boolean,
|
||||
isHandDrawn: boolean,
|
||||
themes: {
|
||||
light: any
|
||||
dark: any
|
||||
},
|
||||
): string {
|
||||
let processedSvg = svg
|
||||
|
||||
if (isDark) {
|
||||
processedSvg = processedSvg
|
||||
.replace(/style="fill: ?#000000"/g, 'style="fill: #e2e8f0"')
|
||||
.replace(/style="stroke: ?#000000"/g, 'style="stroke: #94a3b8"')
|
||||
.replace(/<rect [^>]*fill="#ffffff"/g, '<rect $& fill="#1e293b"')
|
||||
|
||||
if (isHandDrawn) {
|
||||
processedSvg = processedSvg
|
||||
.replace(/fill="#[a-fA-F0-9]{6}"/g, `fill="${themes.dark.nodeColors[0].bg}"`)
|
||||
.replace(/stroke="#[a-fA-F0-9]{6}"/g, `stroke="${themes.dark.connectionColor}"`)
|
||||
.replace(/stroke-width="1"/g, 'stroke-width="1.5"')
|
||||
}
|
||||
else {
|
||||
let i = 0
|
||||
themes.dark.nodeColors.forEach(() => {
|
||||
const regex = /fill="#[a-fA-F0-9]{6}"[^>]*class="node-[^"]*"/g
|
||||
processedSvg = processedSvg.replace(regex, (match: string) => {
|
||||
const colorIndex = i % themes.dark.nodeColors.length
|
||||
i++
|
||||
return match.replace(/fill="#[a-fA-F0-9]{6}"/, `fill="${themes.dark.nodeColors[colorIndex].bg}"`)
|
||||
})
|
||||
})
|
||||
|
||||
processedSvg = processedSvg
|
||||
.replace(/<path [^>]*stroke="#[a-fA-F0-9]{6}"/g,
|
||||
`<path stroke="${themes.dark.connectionColor}" stroke-width="1.5"`)
|
||||
.replace(/<(line|polyline) [^>]*stroke="#[a-fA-F0-9]{6}"/g,
|
||||
`<$1 stroke="${themes.dark.connectionColor}" stroke-width="1.5"`)
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (isHandDrawn) {
|
||||
processedSvg = processedSvg
|
||||
.replace(/fill="#[a-fA-F0-9]{6}"/g, `fill="${themes.light.nodeColors[0].bg}"`)
|
||||
.replace(/stroke="#[a-fA-F0-9]{6}"/g, `stroke="${themes.light.connectionColor}"`)
|
||||
.replace(/stroke-width="1"/g, 'stroke-width="1.5"')
|
||||
}
|
||||
else {
|
||||
themes.light.nodeColors.forEach(() => {
|
||||
const regex = /fill="#[a-fA-F0-9]{6}"[^>]*class="node-[^"]*"/g
|
||||
let i = 0
|
||||
processedSvg = processedSvg.replace(regex, (match: string) => {
|
||||
const colorIndex = i % themes.light.nodeColors.length
|
||||
i++
|
||||
return match.replace(/fill="#[a-fA-F0-9]{6}"/, `fill="${themes.light.nodeColors[colorIndex].bg}"`)
|
||||
})
|
||||
})
|
||||
|
||||
processedSvg = processedSvg
|
||||
.replace(/<path [^>]*stroke="#[a-fA-F0-9]{6}"/g,
|
||||
`<path stroke="${themes.light.connectionColor}"`)
|
||||
.replace(/<(line|polyline) [^>]*stroke="#[a-fA-F0-9]{6}"/g,
|
||||
`<$1 stroke="${themes.light.connectionColor}"`)
|
||||
}
|
||||
}
|
||||
|
||||
return processedSvg
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if mermaid code is complete and valid
|
||||
*/
|
||||
export function isMermaidCodeComplete(code: string): boolean {
|
||||
if (!code || code.trim().length === 0)
|
||||
return false
|
||||
|
||||
try {
|
||||
const trimmedCode = code.trim()
|
||||
|
||||
// Special handling for gantt charts
|
||||
if (trimmedCode.startsWith('gantt')) {
|
||||
// For gantt charts, check if it has at least a title and one task
|
||||
const lines = trimmedCode.split('\n').filter(line => line.trim().length > 0)
|
||||
return lines.length >= 3
|
||||
}
|
||||
|
||||
// Check for basic syntax structure
|
||||
const hasValidStart = /^(graph|flowchart|sequenceDiagram|classDiagram|classDef|class|stateDiagram|gantt|pie|er|journey|requirementDiagram)/.test(trimmedCode)
|
||||
|
||||
// Check for balanced brackets and parentheses
|
||||
const isBalanced = (() => {
|
||||
const stack = []
|
||||
const pairs = { '{': '}', '[': ']', '(': ')' }
|
||||
|
||||
for (const char of trimmedCode) {
|
||||
if (char in pairs) {
|
||||
stack.push(char)
|
||||
}
|
||||
else if (Object.values(pairs).includes(char)) {
|
||||
const last = stack.pop()
|
||||
if (pairs[last as keyof typeof pairs] !== char)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return stack.length === 0
|
||||
})()
|
||||
|
||||
// Check for common syntax errors
|
||||
const hasNoSyntaxErrors = !trimmedCode.includes('undefined')
|
||||
&& !trimmedCode.includes('[object Object]')
|
||||
&& trimmedCode.split('\n').every(line =>
|
||||
!(line.includes('-->') && !line.match(/\S+\s*-->\s*\S+/)))
|
||||
|
||||
return hasValidStart && isBalanced && hasNoSyntaxErrors
|
||||
}
|
||||
catch (error) {
|
||||
console.debug('Mermaid code validation error:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to wait for DOM element with retry mechanism
|
||||
*/
|
||||
export function waitForDOMElement(callback: () => Promise<any>, maxAttempts = 3, delay = 100): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let attempts = 0
|
||||
const tryRender = async () => {
|
||||
try {
|
||||
resolve(await callback())
|
||||
}
|
||||
catch (error) {
|
||||
attempts++
|
||||
if (attempts < maxAttempts)
|
||||
setTimeout(tryRender, delay)
|
||||
else
|
||||
reject(error)
|
||||
}
|
||||
}
|
||||
tryRender()
|
||||
})
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user