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:
Charlie.Wei 2025-04-22 13:06:47 +08:00 committed by GitHub
parent 413271eaa6
commit ef188564f3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 776 additions and 102 deletions

View File

@ -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' />
&nbsp;
{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'

View File

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