From a430284aa21e3ae1f0d5654e55b2ad2852519cc2 Mon Sep 17 00:00:00 2001 From: wwf <yearningwang@iqtogether.com> Date: 星期三, 04 六月 2025 15:17:49 +0800 Subject: [PATCH] 初始化 --- app/components/base/mermaid/index.tsx | 615 +++++++------------------------------------------------ 1 files changed, 82 insertions(+), 533 deletions(-) diff --git a/app/components/base/mermaid/index.tsx b/app/components/base/mermaid/index.tsx index a484261..bcc30ca 100644 --- a/app/components/base/mermaid/index.tsx +++ b/app/components/base/mermaid/index.tsx @@ -1,528 +1,108 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import React, { useCallback, useEffect, 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 { 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' -// Global flags and cache for mermaid -let isMermaidInitialized = false -const diagramCache = new Map<string, string>() -let mermaidAPI: any = null +let mermaidAPI: any +mermaidAPI = null if (typeof window !== 'undefined') mermaidAPI = mermaid.mermaidAPI -// 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', - }, -} - -/** - * Initializes mermaid library with default configuration - */ -const initMermaid = () => { - if (typeof window !== 'undefined' && !isMermaidInitialized) { - try { - mermaid.initialize({ - 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, - }) - isMermaidInitialized = true - } - catch (error) { - console.error('Mermaid initialization error:', error) - return null - } - } - return isMermaidInitialized +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) + }) } const Flowchart = React.forwardRef((props: { PrimitiveCode: string - theme?: 'light' | 'dark' }, ref) => { const { t } = useTranslation() - const [svgCode, setSvgCode] = useState<string | null>(null) + const [svgCode, setSvgCode] = useState(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 prevPrimitiveCode = usePrevious(props.PrimitiveCode) const [isLoading, setIsLoading] = useState(true) - const renderTimeoutRef = useRef<NodeJS.Timeout>() + const timeRef = 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(() => { - const api = initMermaid() - if (api) - setIsInitialized(true) - }, []) - - // 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) - - 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 - } - + const renderFlowchart = useCallback(async (PrimitiveCode: string) => { + setSvgCode(null) 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) + if (typeof window !== 'undefined' && mermaidAPI) { + const svgGraph = await mermaidAPI.render('flowchart', PrimitiveCode) + const base64Svg: any = await svgToBase64(svgGraph.svg) setSvgCode(base64Svg) + setIsLoading(false) } - - setIsLoading(false) } catch (error) { - // Error handling - handleRenderError(error) + if (prevPrimitiveCode === props.PrimitiveCode) { + setIsLoading(false) + setErrMsg((error as Error).message) + } } - }, [chartId, isInitialized, cacheKey, isCodeComplete, look, currentTheme, t]) + }, [props.PrimitiveCode]) - /** - * 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', + useEffect(() => { + if (typeof window !== 'undefined') { + mermaid.initialize({ + startOnLoad: true, + theme: 'neutral', + look, + flowchart: { + htmlLabels: true, + useMaxWidth: true, }, - } + }) - 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]) + } + }, [look]) - // Effect for rendering with debounce useEffect(() => { - if (diagramCache.has(cacheKey)) { - setSvgCode(diagramCache.get(cacheKey) || null) - setIsLoading(false) - return - } + if (timeRef.current) + clearTimeout(timeRef.current) - 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 relative h-auto w-full cursor-pointer', { - 'bg-white': currentTheme === Theme.light, - 'bg-slate-900': currentTheme === Theme.dark, - }), - errorMessage: cn('px-[26px] py-4', { - 'text-red-500': currentTheme === Theme.light, - 'text-red-400': currentTheme === Theme.dark, - }), - errorIcon: cn('h-6 w-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 h-10 w-10 items-center justify-center rounded-full shadow-md backdrop-blur-sm transition-all duration-300', { - '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( - '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 === 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', - ) - } + timeRef.current = setTimeout(() => { + renderFlowchart(props.PrimitiveCode) + }, 300) + }, [props.PrimitiveCode]) return ( - <div ref={ref as React.RefObject<HTMLDivElement>} className={themeClasses.container}> - <div className={themeClasses.segmented}> + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + <div ref={ref}> + <div className="msh-segmented msh-segmented-sm css-23bs09 css-var-r1"> <div className="msh-segmented-group"> - <label className="msh-segmented-item m-2 flex w-[200px] items-center space-x-1"> - <div - key='classic' - className={getLookButtonClass('classic')} + <label className="msh-segmented-item flex items-center space-x-1 m-2 w-[200px]"> + <div key='classic' + className={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 === 'classic' && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary', + )} + onClick={() => setLook('classic')} > <div className="msh-segmented-item-label">{t('app.mermaid.classic')}</div> </div> - <div - key='handDrawn' - className={getLookButtonClass('handDrawn')} + <div key='handDrawn' + className={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 === 'handDrawn' && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary', + )} onClick={() => setLook('handDrawn')} > <div className="msh-segmented-item-label">{t('app.mermaid.handDrawn')}</div> @@ -530,61 +110,30 @@ </label> </div> </div> - - <div ref={containerRef} style={{ position: 'absolute', visibility: 'hidden', height: 0, overflow: 'hidden' }} /> - - {isLoading && !svgCode && ( - <div className='px-[26px] py-4'> - <LoadingAnim type='text'/> - {!isCodeComplete && ( - <div className="mt-2 text-sm text-gray-500"> - {t('common.wait_for_completion', 'Waiting for diagram code to complete...')} + { + svgCode + && <div className="mermaid cursor-pointer h-auto w-full object-fit: cover" onClick={() => setImagePreviewUrl(svgCode)}> + {svgCode && <img src={svgCode} alt="mermaid_chart" />} </div> - )} - </div> - )} - - {svgCode && ( - <div className={themeClasses.mermaidDiv} style={{ objectFit: 'cover' }} onClick={() => setImagePreviewUrl(svgCode)}> - <div className="absolute bottom-2 left-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={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('')} /> - )} + } + {isLoading + && <div className='py-4 px-[26px]'> + <LoadingAnim type='text'/> + </div> + } + { + errMsg + && <div className='py-4 px-[26px]'> + <ExclamationTriangleIcon className='w-6 h-6 text-red-500'/> + + {errMsg} + </div> + } + { + imagePreviewUrl && (<ImagePreview title='mermaid_chart' url={imagePreviewUrl} onCancel={() => setImagePreviewUrl('')} />) + } </div> ) }) - -Flowchart.displayName = 'Flowchart' export default Flowchart -- Gitblit v1.8.0