feat(mermaid): Rearchitect component for robustness, security, and theming (#21281)

This commit is contained in:
sayThQ199
2025-06-20 19:55:58 +08:00
committed by GitHub
parent 186ac74c1f
commit ba5eebf3a2
3 changed files with 196 additions and 250 deletions

View File

@@ -271,9 +271,7 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any
const content = String(children).replace(/\n$/, '') const content = String(children).replace(/\n$/, '')
switch (language) { switch (language) {
case 'mermaid': case 'mermaid':
if (isSVG) return <Flowchart PrimitiveCode={content} theme={theme as 'light' | 'dark'} />
return <Flowchart PrimitiveCode={content} />
break
case 'echarts': { case 'echarts': {
// Loading state: show loading indicator // Loading state: show loading indicator
if (chartState === 'loading') { if (chartState === 'loading') {
@@ -428,7 +426,7 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any
<div className='flex h-8 items-center justify-between rounded-t-[10px] border-b border-divider-subtle bg-components-input-bg-normal p-1 pl-3'> <div className='flex h-8 items-center justify-between rounded-t-[10px] border-b border-divider-subtle bg-components-input-bg-normal p-1 pl-3'>
<div className='system-xs-semibold-uppercase text-text-secondary'>{languageShowName}</div> <div className='system-xs-semibold-uppercase text-text-secondary'>{languageShowName}</div>
<div className='flex items-center gap-1'> <div className='flex items-center gap-1'>
{(['mermaid', 'svg']).includes(language!) && <SVGBtn isSVG={isSVG} setIsSVG={setIsSVG} />} {language === 'svg' && <SVGBtn isSVG={isSVG} setIsSVG={setIsSVG} />}
<ActionButton> <ActionButton>
<CopyIcon content={String(children).replace(/\n$/, '')} /> <CopyIcon content={String(children).replace(/\n$/, '')} />
</ActionButton> </ActionButton>

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import mermaid from 'mermaid' import mermaid, { type MermaidConfig } from 'mermaid'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline' import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'
import { MoonIcon, SunIcon } from '@heroicons/react/24/solid' import { MoonIcon, SunIcon } from '@heroicons/react/24/solid'
@@ -68,14 +68,13 @@ const THEMES = {
const initMermaid = () => { const initMermaid = () => {
if (typeof window !== 'undefined' && !isMermaidInitialized) { if (typeof window !== 'undefined' && !isMermaidInitialized) {
try { try {
mermaid.initialize({ const config: MermaidConfig = {
startOnLoad: false, startOnLoad: false,
fontFamily: 'sans-serif', fontFamily: 'sans-serif',
securityLevel: 'loose', securityLevel: 'loose',
flowchart: { flowchart: {
htmlLabels: true, htmlLabels: true,
useMaxWidth: true, useMaxWidth: true,
diagramPadding: 10,
curve: 'basis', curve: 'basis',
nodeSpacing: 50, nodeSpacing: 50,
rankSpacing: 70, rankSpacing: 70,
@@ -94,10 +93,10 @@ const initMermaid = () => {
mindmap: { mindmap: {
useMaxWidth: true, useMaxWidth: true,
padding: 10, padding: 10,
diagramPadding: 20,
}, },
maxTextSize: 50000, maxTextSize: 50000,
}) }
mermaid.initialize(config)
isMermaidInitialized = true isMermaidInitialized = true
} }
catch (error) { catch (error) {
@@ -113,7 +112,7 @@ const Flowchart = React.forwardRef((props: {
theme?: 'light' | 'dark' theme?: 'light' | 'dark'
}, ref) => { }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const [svgCode, setSvgCode] = useState<string | null>(null) const [svgString, setSvgString] = useState<string | null>(null)
const [look, setLook] = useState<'classic' | 'handDrawn'>('classic') const [look, setLook] = useState<'classic' | 'handDrawn'>('classic')
const [isInitialized, setIsInitialized] = useState(false) const [isInitialized, setIsInitialized] = useState(false)
const [currentTheme, setCurrentTheme] = useState<'light' | 'dark'>(props.theme || 'light') const [currentTheme, setCurrentTheme] = useState<'light' | 'dark'>(props.theme || 'light')
@@ -125,6 +124,7 @@ const Flowchart = React.forwardRef((props: {
const [imagePreviewUrl, setImagePreviewUrl] = useState('') const [imagePreviewUrl, setImagePreviewUrl] = useState('')
const [isCodeComplete, setIsCodeComplete] = useState(false) const [isCodeComplete, setIsCodeComplete] = useState(false)
const codeCompletionCheckRef = useRef<NodeJS.Timeout>() const codeCompletionCheckRef = useRef<NodeJS.Timeout>()
const prevCodeRef = useRef<string>()
// Create cache key from code, style and theme // Create cache key from code, style and theme
const cacheKey = useMemo(() => { const cacheKey = useMemo(() => {
@@ -169,50 +169,18 @@ const Flowchart = React.forwardRef((props: {
*/ */
const handleRenderError = (error: any) => { const handleRenderError = (error: any) => {
console.error('Mermaid rendering error:', error) console.error('Mermaid rendering error:', error)
const errorMsg = (error as Error).message
if (errorMsg.includes('getAttribute')) { // On any render error, assume the mermaid state is corrupted and force a re-initialization.
diagramCache.clear() try {
mermaid.initialize({ diagramCache.clear() // Clear cache to prevent using potentially corrupted SVGs
startOnLoad: false, isMermaidInitialized = false // <-- THE FIX: Force re-initialization
securityLevel: 'loose', initMermaid() // Re-initialize with the default safe configuration
})
} }
else { catch (reinitError) {
setErrMsg(`Rendering chart failed, please refresh and try again ${look === 'handDrawn' ? 'Or try using classic mode' : ''}`) console.error('Failed to re-initialize Mermaid after error:', reinitError)
}
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)
}
} }
setErrMsg(`Rendering failed: ${(error as Error).message || 'Unknown error. Please check the console.'}`)
setIsLoading(false) setIsLoading(false)
} }
@@ -223,51 +191,23 @@ const Flowchart = React.forwardRef((props: {
setIsInitialized(true) setIsInitialized(true)
}, []) }, [])
// Update theme when prop changes // Update theme when prop changes, but allow internal override.
const prevThemeRef = useRef<string>()
useEffect(() => { useEffect(() => {
if (props.theme) // Only react if the theme prop from the outside has actually changed.
if (props.theme && props.theme !== prevThemeRef.current) {
// When the global theme prop changes, it should act as the source of truth,
// overriding any local theme selection.
diagramCache.clear()
setSvgString(null)
setCurrentTheme(props.theme) setCurrentTheme(props.theme)
// Reset look to classic for a consistent state after a global change.
setLook('classic')
}
// Update the ref to the current prop value for the next render.
prevThemeRef.current = props.theme
}, [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)
return () => {
if (codeCompletionCheckRef.current)
clearTimeout(codeCompletionCheckRef.current)
}
}, [props.PrimitiveCode, cacheKey])
/**
* Renders flowchart based on provided code
*/
const renderFlowchart = useCallback(async (primitiveCode: string) => { const renderFlowchart = useCallback(async (primitiveCode: string) => {
if (!isInitialized || !containerRef.current) { if (!isInitialized || !containerRef.current) {
setIsLoading(false) setIsLoading(false)
@@ -275,15 +215,11 @@ const Flowchart = React.forwardRef((props: {
return return
} }
// Don't render if code is not complete yet
if (!isCodeComplete) {
setIsLoading(true)
return
}
// Return cached result if available // Return cached result if available
const cacheKey = `${primitiveCode}-${look}-${currentTheme}`
if (diagramCache.has(cacheKey)) { if (diagramCache.has(cacheKey)) {
setSvgCode(diagramCache.get(cacheKey) || null) setErrMsg('')
setSvgString(diagramCache.get(cacheKey) || null)
setIsLoading(false) setIsLoading(false)
return return
} }
@@ -294,17 +230,45 @@ const Flowchart = React.forwardRef((props: {
try { try {
let finalCode: string let finalCode: string
// Check if it's a gantt chart or mindmap const trimmedCode = primitiveCode.trim()
const isGanttChart = primitiveCode.trim().startsWith('gantt') const isGantt = trimmedCode.startsWith('gantt')
const isMindMap = primitiveCode.trim().startsWith('mindmap') const isMindMap = trimmedCode.startsWith('mindmap')
const isSequence = trimmedCode.startsWith('sequenceDiagram')
if (isGanttChart || isMindMap) { if (isGantt || isMindMap || isSequence) {
// For gantt charts and mindmaps, ensure each task is on its own line if (isGantt) {
// and preserve exact whitespace/format finalCode = trimmedCode
finalCode = primitiveCode.trim() .split('\n')
.map((line) => {
// Gantt charts have specific syntax needs.
const taskMatch = line.match(/^\s*([^:]+?)\s*:\s*(.*)/)
if (!taskMatch)
return line // Not a task line, return as is.
const taskName = taskMatch[1].trim()
let paramsStr = taskMatch[2].trim()
// Rule 1: Correct multiple "after" dependencies ONLY if they exist.
// This is a common mistake, e.g., "..., after task1, after task2, ..."
const afterCount = (paramsStr.match(/after /g) || []).length
if (afterCount > 1)
paramsStr = paramsStr.replace(/,\s*after\s+/g, ' ')
// Rule 2: Normalize spacing between parameters for consistency.
const finalParams = paramsStr.replace(/\s*,\s*/g, ', ').trim()
return `${taskName} :${finalParams}`
})
.join('\n')
}
else {
// For mindmap and sequence charts, which are sensitive to syntax,
// pass the code through directly.
finalCode = trimmedCode
}
} }
else { else {
// Step 1: Clean and prepare Mermaid code using the extracted prepareMermaidCode function // Step 1: Clean and prepare Mermaid code using the extracted prepareMermaidCode function
// This function handles flowcharts appropriately.
finalCode = prepareMermaidCode(primitiveCode, look) finalCode = prepareMermaidCode(primitiveCode, look)
} }
@@ -319,13 +283,12 @@ const Flowchart = React.forwardRef((props: {
THEMES, THEMES,
) )
// Step 4: Clean SVG code and convert to base64 using the extracted functions // Step 4: Clean up SVG code
const cleanedSvg = cleanUpSvgCode(processedSvg) const cleanedSvg = cleanUpSvgCode(processedSvg)
const base64Svg = await svgToBase64(cleanedSvg)
if (base64Svg && typeof base64Svg === 'string') { if (cleanedSvg && typeof cleanedSvg === 'string') {
diagramCache.set(cacheKey, base64Svg) diagramCache.set(cacheKey, cleanedSvg)
setSvgCode(base64Svg) setSvgString(cleanedSvg)
} }
setIsLoading(false) setIsLoading(false)
@@ -334,12 +297,9 @@ const Flowchart = React.forwardRef((props: {
// Error handling // Error handling
handleRenderError(error) handleRenderError(error)
} }
}, [chartId, isInitialized, cacheKey, isCodeComplete, look, currentTheme, t]) }, [chartId, isInitialized, look, currentTheme, t])
/** const configureMermaid = useCallback((primitiveCode: string) => {
* Configure mermaid based on selected style and theme
*/
const configureMermaid = useCallback(() => {
if (typeof window !== 'undefined' && isInitialized) { if (typeof window !== 'undefined' && isInitialized) {
const themeVars = THEMES[currentTheme] const themeVars = THEMES[currentTheme]
const config: any = { const config: any = {
@@ -361,23 +321,37 @@ const Flowchart = React.forwardRef((props: {
mindmap: { mindmap: {
useMaxWidth: true, useMaxWidth: true,
padding: 10, padding: 10,
diagramPadding: 20,
}, },
} }
const isFlowchart = primitiveCode.trim().startsWith('graph') || primitiveCode.trim().startsWith('flowchart')
if (look === 'classic') { if (look === 'classic') {
config.theme = currentTheme === 'dark' ? 'dark' : 'neutral' config.theme = currentTheme === 'dark' ? 'dark' : 'neutral'
config.flowchart = {
htmlLabels: true, if (isFlowchart) {
useMaxWidth: true, config.flowchart = {
diagramPadding: 12, htmlLabels: true,
nodeSpacing: 60, useMaxWidth: true,
rankSpacing: 80, nodeSpacing: 60,
curve: 'linear', rankSpacing: 80,
ranker: 'tight-tree', curve: 'linear',
ranker: 'tight-tree',
}
}
if (currentTheme === 'dark') {
config.themeVariables = {
background: themeVars.background,
primaryColor: themeVars.primaryColor,
primaryBorderColor: themeVars.primaryBorderColor,
primaryTextColor: themeVars.primaryTextColor,
secondaryColor: themeVars.secondaryColor,
tertiaryColor: themeVars.tertiaryColor,
}
} }
} }
else { else { // look === 'handDrawn'
config.theme = 'default' config.theme = 'default'
config.themeCSS = ` config.themeCSS = `
.node rect { fill-opacity: 0.85; } .node rect { fill-opacity: 0.85; }
@@ -389,27 +363,17 @@ const Flowchart = React.forwardRef((props: {
config.themeVariables = { config.themeVariables = {
fontSize: '14px', fontSize: '14px',
fontFamily: 'sans-serif', fontFamily: 'sans-serif',
primaryBorderColor: currentTheme === 'dark' ? THEMES.dark.connectionColor : THEMES.light.connectionColor,
} }
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) { if (isFlowchart) {
config.themeVariables = { config.flowchart = {
background: themeVars.background, htmlLabels: true,
primaryColor: themeVars.primaryColor, useMaxWidth: true,
primaryBorderColor: themeVars.primaryBorderColor, nodeSpacing: 40,
primaryTextColor: themeVars.primaryTextColor, rankSpacing: 60,
secondaryColor: themeVars.secondaryColor, curve: 'basis',
tertiaryColor: themeVars.tertiaryColor, }
fontFamily: 'sans-serif',
} }
} }
@@ -425,44 +389,50 @@ const Flowchart = React.forwardRef((props: {
return false return false
}, [currentTheme, isInitialized, look]) }, [currentTheme, isInitialized, look])
// Effect for theme and style configuration // This is the main rendering effect.
// It triggers whenever the code, theme, or style changes.
useEffect(() => { useEffect(() => {
if (diagramCache.has(cacheKey)) { if (!isInitialized)
setSvgCode(diagramCache.get(cacheKey) || null) return
setIsLoading(false)
return // Don't render if code is too short
} if (!props.PrimitiveCode || props.PrimitiveCode.length < 10) {
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) setIsLoading(false)
setSvgString(null)
return return
} }
// Use a timeout to handle streaming code and debounce rendering
if (renderTimeoutRef.current) if (renderTimeoutRef.current)
clearTimeout(renderTimeoutRef.current) clearTimeout(renderTimeoutRef.current)
if (isCodeComplete) { setIsLoading(true)
renderTimeoutRef.current = setTimeout(() => {
if (isInitialized) renderTimeoutRef.current = setTimeout(() => {
renderFlowchart(props.PrimitiveCode) // Final validation before rendering
}, 300) if (!isMermaidCodeComplete(props.PrimitiveCode)) {
} setIsLoading(false)
else { setErrMsg('Diagram code is not complete or invalid.')
setIsLoading(true) return
} }
const cacheKey = `${props.PrimitiveCode}-${look}-${currentTheme}`
if (diagramCache.has(cacheKey)) {
setErrMsg('')
setSvgString(diagramCache.get(cacheKey) || null)
setIsLoading(false)
return
}
if (configureMermaid(props.PrimitiveCode))
renderFlowchart(props.PrimitiveCode)
}, 300) // 300ms debounce
return () => { return () => {
if (renderTimeoutRef.current) if (renderTimeoutRef.current)
clearTimeout(renderTimeoutRef.current) clearTimeout(renderTimeoutRef.current)
} }
}, [props.PrimitiveCode, renderFlowchart, isInitialized, cacheKey, isCodeComplete]) }, [props.PrimitiveCode, look, currentTheme, isInitialized, configureMermaid, renderFlowchart])
// Cleanup on unmount // Cleanup on unmount
useEffect(() => { useEffect(() => {
@@ -471,14 +441,22 @@ const Flowchart = React.forwardRef((props: {
containerRef.current.innerHTML = '' containerRef.current.innerHTML = ''
if (renderTimeoutRef.current) if (renderTimeoutRef.current)
clearTimeout(renderTimeoutRef.current) clearTimeout(renderTimeoutRef.current)
if (codeCompletionCheckRef.current)
clearTimeout(codeCompletionCheckRef.current)
} }
}, []) }, [])
const handlePreviewClick = async () => {
if (svgString) {
const base64 = await svgToBase64(svgString)
setImagePreviewUrl(base64)
}
}
const toggleTheme = () => { const toggleTheme = () => {
setCurrentTheme(prevTheme => prevTheme === 'light' ? Theme.dark : Theme.light) const newTheme = currentTheme === 'light' ? 'dark' : 'light'
// Ensure a full, clean re-render cycle, consistent with global theme change.
diagramCache.clear() diagramCache.clear()
setSvgString(null)
setCurrentTheme(newTheme)
} }
// Style classes for theme-dependent elements // Style classes for theme-dependent elements
@@ -527,14 +505,26 @@ const Flowchart = React.forwardRef((props: {
<div <div
key='classic' key='classic'
className={getLookButtonClass('classic')} className={getLookButtonClass('classic')}
onClick={() => setLook('classic')} onClick={() => {
if (look !== 'classic') {
diagramCache.clear()
setSvgString(null)
setLook('classic')
}
}}
> >
<div className="msh-segmented-item-label">{t('app.mermaid.classic')}</div> <div className="msh-segmented-item-label">{t('app.mermaid.classic')}</div>
</div> </div>
<div <div
key='handDrawn' key='handDrawn'
className={getLookButtonClass('handDrawn')} className={getLookButtonClass('handDrawn')}
onClick={() => setLook('handDrawn')} onClick={() => {
if (look !== 'handDrawn') {
diagramCache.clear()
setSvgString(null)
setLook('handDrawn')
}
}}
> >
<div className="msh-segmented-item-label">{t('app.mermaid.handDrawn')}</div> <div className="msh-segmented-item-label">{t('app.mermaid.handDrawn')}</div>
</div> </div>
@@ -544,7 +534,7 @@ const Flowchart = React.forwardRef((props: {
<div ref={containerRef} style={{ position: 'absolute', visibility: 'hidden', height: 0, overflow: 'hidden' }} /> <div ref={containerRef} style={{ position: 'absolute', visibility: 'hidden', height: 0, overflow: 'hidden' }} />
{isLoading && !svgCode && ( {isLoading && !svgString && (
<div className='px-[26px] py-4'> <div className='px-[26px] py-4'>
<LoadingAnim type='text'/> <LoadingAnim type='text'/>
{!isCodeComplete && ( {!isCodeComplete && (
@@ -555,8 +545,8 @@ const Flowchart = React.forwardRef((props: {
</div> </div>
)} )}
{svgCode && ( {svgString && (
<div className={themeClasses.mermaidDiv} style={{ objectFit: 'cover' }} onClick={() => setImagePreviewUrl(svgCode)}> <div className={themeClasses.mermaidDiv} style={{ objectFit: 'cover' }} onClick={handlePreviewClick}>
<div className="absolute bottom-2 left-2 z-[100]"> <div className="absolute bottom-2 left-2 z-[100]">
<button <button
onClick={(e) => { onClick={(e) => {
@@ -571,11 +561,9 @@ const Flowchart = React.forwardRef((props: {
</button> </button>
</div> </div>
<img <div
src={svgCode}
alt="mermaid_chart"
style={{ maxWidth: '100%' }} style={{ maxWidth: '100%' }}
onError={() => { setErrMsg('Chart rendering failed, please refresh and retry') }} dangerouslySetInnerHTML={{ __html: svgString }}
/> />
</div> </div>
)} )}

View File

@@ -3,52 +3,31 @@ export function cleanUpSvgCode(svgCode: string): string {
} }
/** /**
* Preprocesses mermaid code to fix common syntax issues * Prepares mermaid code for rendering by sanitizing common syntax issues.
* @param {string} mermaidCode - The mermaid code to prepare
* @param {'classic' | 'handDrawn'} style - The rendering style
* @returns {string} - The prepared mermaid code
*/ */
export function preprocessMermaidCode(code: string): string { export const prepareMermaidCode = (mermaidCode: string, style: 'classic' | 'handDrawn'): string => {
if (!code || typeof code !== 'string') if (!mermaidCode || typeof mermaidCode !== 'string')
return '' return ''
// First check if this is a gantt chart let code = mermaidCode.trim()
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 // Security: Sanitize against javascript: protocol in click events (XSS vector)
// Replace English colons with Chinese colons in section nodes to avoid parsing issues code = code.replace(/(\bclick\s+\w+\s+")javascript:[^"]*(")/g, '$1#$2')
.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()
}
/** // Convenience: Basic BR replacement. This is a common and safe operation.
* Prepares mermaid code based on selected style code = code.replace(/<br\s*\/?>/g, '\n')
*/
export function prepareMermaidCode(code: string, style: 'classic' | 'handDrawn'): string {
let finalCode = preprocessMermaidCode(code)
// Special handling for gantt charts and mindmaps let finalCode = code
if (finalCode.trim().startsWith('gantt') || finalCode.trim().startsWith('mindmap')) {
// For gantt charts and mindmaps, preserve the structure exactly as is
return finalCode
}
// Hand-drawn style requires some specific clean-up.
if (style === 'handDrawn') { if (style === 'handDrawn') {
finalCode = finalCode finalCode = finalCode
// Remove style definitions that interfere with hand-drawn style
.replace(/style\s+[^\n]+/g, '') .replace(/style\s+[^\n]+/g, '')
.replace(/linkStyle\s+[^\n]+/g, '') .replace(/linkStyle\s+[^\n]+/g, '')
.replace(/^flowchart/, 'graph') .replace(/^flowchart/, 'graph')
// Remove any styles that might interfere with hand-drawn style
.replace(/class="[^"]*"/g, '') .replace(/class="[^"]*"/g, '')
.replace(/fill="[^"]*"/g, '') .replace(/fill="[^"]*"/g, '')
.replace(/stroke="[^"]*"/g, '') .replace(/stroke="[^"]*"/g, '')
@@ -82,7 +61,6 @@ export function svgToBase64(svgGraph: string): Promise<string> {
}) })
} }
catch (error) { catch (error) {
console.error('Error converting SVG to base64:', error)
return Promise.resolve('') return Promise.resolve('')
} }
} }
@@ -115,13 +93,11 @@ export function processSvgForTheme(
} }
else { else {
let i = 0 let i = 0
themes.dark.nodeColors.forEach(() => { const nodeColorRegex = /fill="#[a-fA-F0-9]{6}"[^>]*class="node-[^"]*"/g
const regex = /fill="#[a-fA-F0-9]{6}"[^>]*class="node-[^"]*"/g processedSvg = processedSvg.replace(nodeColorRegex, (match: string) => {
processedSvg = processedSvg.replace(regex, (match: string) => { const colorIndex = i % themes.dark.nodeColors.length
const colorIndex = i % themes.dark.nodeColors.length i++
i++ return match.replace(/fill="#[a-fA-F0-9]{6}"/, `fill="${themes.dark.nodeColors[colorIndex].bg}"`)
return match.replace(/fill="#[a-fA-F0-9]{6}"/, `fill="${themes.dark.nodeColors[colorIndex].bg}"`)
})
}) })
processedSvg = processedSvg processedSvg = processedSvg
@@ -139,14 +115,12 @@ export function processSvgForTheme(
.replace(/stroke-width="1"/g, 'stroke-width="1.5"') .replace(/stroke-width="1"/g, 'stroke-width="1.5"')
} }
else { else {
themes.light.nodeColors.forEach(() => { let i = 0
const regex = /fill="#[a-fA-F0-9]{6}"[^>]*class="node-[^"]*"/g const nodeColorRegex = /fill="#[a-fA-F0-9]{6}"[^>]*class="node-[^"]*"/g
let i = 0 processedSvg = processedSvg.replace(nodeColorRegex, (match: string) => {
processedSvg = processedSvg.replace(regex, (match: string) => { const colorIndex = i % themes.light.nodeColors.length
const colorIndex = i % themes.light.nodeColors.length i++
i++ return match.replace(/fill="#[a-fA-F0-9]{6}"/, `fill="${themes.light.nodeColors[colorIndex].bg}"`)
return match.replace(/fill="#[a-fA-F0-9]{6}"/, `fill="${themes.light.nodeColors[colorIndex].bg}"`)
})
}) })
processedSvg = processedSvg processedSvg = processedSvg
@@ -187,24 +161,10 @@ export function isMermaidCodeComplete(code: string): boolean {
// Check for basic syntax structure // Check for basic syntax structure
const hasValidStart = /^(graph|flowchart|sequenceDiagram|classDiagram|classDef|class|stateDiagram|gantt|pie|er|journey|requirementDiagram|mindmap)/.test(trimmedCode) const hasValidStart = /^(graph|flowchart|sequenceDiagram|classDiagram|classDef|class|stateDiagram|gantt|pie|er|journey|requirementDiagram|mindmap)/.test(trimmedCode)
// Check for balanced brackets and parentheses // The balanced bracket check was too strict and produced false negatives for valid
const isBalanced = (() => { // mermaid syntax like the asymmetric shape `A>B]`. Relying on Mermaid's own
const stack = [] // parser is more robust.
const pairs = { '{': '}', '[': ']', '(': ')' } const isBalanced = true
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 // Check for common syntax errors
const hasNoSyntaxErrors = !trimmedCode.includes('undefined') const hasNoSyntaxErrors = !trimmedCode.includes('undefined')
@@ -215,7 +175,7 @@ export function isMermaidCodeComplete(code: string): boolean {
return hasValidStart && isBalanced && hasNoSyntaxErrors return hasValidStart && isBalanced && hasNoSyntaxErrors
} }
catch (error) { catch (error) {
console.debug('Mermaid code validation error:', error) console.error('Mermaid code validation error:', error)
return false return false
} }
} }