From ead84a1921e15ca15efb73540d4d8e266b0cb643 Mon Sep 17 00:00:00 2001 From: gaoziman <2942894660@qq.com> Date: Sat, 27 Dec 2025 22:31:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=BB=84=E4=BB=B6):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=20Mermaid=20=E5=9B=BE=E8=A1=A8=E6=B8=B2=E6=9F=93=E7=BB=84?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 创建 MermaidBlock 组件支持流程图渲染 - 实现亮色/暗色双主题配色方案 - 支持主题切换时自动重新渲染 - 添加防抖处理优化流式输出体验 - 添加 Mermaid 背景色 CSS 变量 --- src/app/globals.css | 6 + src/components/markdown/MermaidBlock.tsx | 479 +++++++++++++++++++++++ 2 files changed, 485 insertions(+) create mode 100644 src/components/markdown/MermaidBlock.tsx diff --git a/src/app/globals.css b/src/app/globals.css index 2f34642..8be3bcc 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -77,6 +77,9 @@ --color-code-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); /* Mac 红黄绿按钮 - 亮色主题隐藏 */ --color-code-traffic-light-display: none; + + /* Mermaid 图表背景 - 亮色主题,与代码块背景保持一致 */ + --color-mermaid-bg: #F5F3F0; } /* ======================================== @@ -135,6 +138,9 @@ --color-code-shadow: 0 8px 24px rgba(0, 0, 0, 0.25), 0 2px 8px rgba(0, 0, 0, 0.15); /* Mac 红黄绿按钮 - 暗色主题显示 */ --color-code-traffic-light-display: flex; + + /* Mermaid 图表背景 - 暗色主题 */ + --color-mermaid-bg: #3A353E; } @theme inline { diff --git a/src/components/markdown/MermaidBlock.tsx b/src/components/markdown/MermaidBlock.tsx new file mode 100644 index 0000000..ad546de --- /dev/null +++ b/src/components/markdown/MermaidBlock.tsx @@ -0,0 +1,479 @@ +'use client'; + +import { useState, useEffect, useRef, useId, useCallback } from 'react'; +import { Copy, Check, Eye, Code, Maximize2, AlertCircle, Loader2, FileCode } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface MermaidBlockProps { + code: string; + className?: string; + /** 是否正在流式输出 */ + isStreaming?: boolean; +} + +type ViewMode = 'diagram' | 'code'; + +/** 防抖延迟时间(毫秒) */ +const DEBOUNCE_DELAY = 600; + +/** + * 检测当前是否为暗色主题 + */ +function isDarkTheme(): boolean { + if (typeof document === 'undefined') return false; + return document.documentElement.getAttribute('data-theme') === 'dark'; +} + +/** + * 获取 Mermaid 暗色主题配置(深紫灰主题) + * 节点背景使用深色,与暗色 UI 协调 + */ +function getDarkThemeConfig() { + return { + theme: 'base' as const, + themeVariables: { + primaryColor: '#4A4553', // 节点背景(深紫灰) + primaryTextColor: '#E4E4E7', // 节点文字(浅灰白) + primaryBorderColor: '#6B6575', // 节点边框(中紫灰) + secondaryColor: '#3A353E', // 次要节点背景 + secondaryTextColor: '#D4D4D8', + secondaryBorderColor: '#5A5560', + tertiaryColor: '#332E38', // 第三背景色 + tertiaryTextColor: '#A8A8A8', + tertiaryBorderColor: '#4A4553', + lineColor: '#8B8592', // 连接线颜色(浅紫灰) + textColor: '#E4E4E7', // 默认文字颜色 + mainBkg: '#4A4553', // 主背景 + nodeBorder: '#6B6575', // 节点边框 + clusterBkg: '#332E38', // 分组背景 + titleColor: '#F5F5F5', // 标题颜色 + edgeLabelBackground: '#3A353E', // 边缘标签背景 + } + }; +} + +/** + * 获取 Mermaid 亮色主题配置(暖色调方案) + * 米白 + 暖灰,与品牌橙色协调 + */ +function getLightThemeConfig() { + return { + theme: 'base' as const, + themeVariables: { + primaryColor: '#FAF7F4', // 节点背景(米白) + primaryTextColor: '#4A4553', // 节点文字(深灰) + primaryBorderColor: '#D4C4B8', // 节点边框(暖灰) + secondaryColor: '#F5EDE6', // 次要节点背景 + secondaryTextColor: '#4A4553', + secondaryBorderColor: '#D4C4B8', + tertiaryColor: '#EDE5DD', // 第三背景色 + tertiaryTextColor: '#6B7280', + tertiaryBorderColor: '#C8B8A8', + lineColor: '#B8A898', // 连接线颜色(暖灰) + textColor: '#4A4553', // 默认文字颜色 + mainBkg: '#FAF7F4', // 主背景 + nodeBorder: '#D4C4B8', // 节点边框 + clusterBkg: '#F5EDE6', // 分组背景 + titleColor: '#374151', // 标题颜色 + edgeLabelBackground: '#F5F3F0', // 边缘标签背景 + } + }; +} + +/** + * 清理全局 DOM 中的 Mermaid 错误元素 + * Mermaid 渲染失败时会在 document.body 中创建错误元素 + */ +function cleanupMermaidErrorElements() { + // 清理 Mermaid 创建的临时容器和错误元素 + const errorElements = document.querySelectorAll('[id^="dmermaid-"], [id^="d"]'); + errorElements.forEach((el) => { + // 只清理 Mermaid 相关的临时元素(通常是空的或包含错误信息) + if (el.id.startsWith('dmermaid-') || (el.id.startsWith('d') && el.id.includes('-'))) { + // 检查是否是 Mermaid 生成的元素 + if (el.closest('.mermaid') === null && el.parentElement === document.body) { + el.remove(); + } + } + }); +} + +export function MermaidBlock({ code, className, isStreaming = false }: MermaidBlockProps) { + const [viewMode, setViewMode] = useState('diagram'); + const [copied, setCopied] = useState(false); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [svgContent, setSvgContent] = useState(''); + const [isFullscreen, setIsFullscreen] = useState(false); + // 用于追踪是否正在等待渲染(防抖状态) + const [isWaitingToRender, setIsWaitingToRender] = useState(false); + // 当前主题状态,用于监听主题变化并触发重新渲染 + const [currentTheme, setCurrentTheme] = useState<'light' | 'dark'>(() => + typeof document !== 'undefined' && isDarkTheme() ? 'dark' : 'light' + ); + + const containerRef = useRef(null); + const uniqueId = useId().replace(/:/g, '-'); + // 防抖定时器引用 + const debounceTimerRef = useRef(null); + // 记录上次成功渲染的代码,避免重复渲染 + const lastRenderedCodeRef = useRef(''); + // 使用 ref 跟踪 svgContent 是否有值,避免在 useCallback 中依赖 state + const hasSvgContentRef = useRef(false); + + // 渲染 Mermaid 图表 + const renderMermaid = useCallback(async (codeToRender: string) => { + // 如果代码与上次相同且已有渲染结果,不重复渲染 + if (codeToRender === lastRenderedCodeRef.current && hasSvgContentRef.current) { + setIsLoading(false); + setIsWaitingToRender(false); + return; + } + + setIsLoading(true); + setError(null); + setIsWaitingToRender(false); + + // 清理全局 DOM 中的 Mermaid 错误元素 + cleanupMermaidErrorElements(); + + try { + // 动态导入 mermaid 以避免 SSR 问题 + const mermaid = (await import('mermaid')).default; + + // 检测当前主题并获取对应配置 + const isDark = isDarkTheme(); + const themeConfig = isDark ? getDarkThemeConfig() : getLightThemeConfig(); + + // 初始化 mermaid 配置 + mermaid.initialize({ + startOnLoad: false, + theme: themeConfig.theme, + themeVariables: themeConfig.themeVariables, + securityLevel: 'loose', + // 禁止 Mermaid 在全局 DOM 显示错误 + suppressErrorRendering: true, + flowchart: { + useMaxWidth: true, + htmlLabels: true, + curve: 'basis', + }, + sequence: { + useMaxWidth: true, + diagramMarginX: 50, + diagramMarginY: 10, + actorMargin: 50, + width: 150, + height: 65, + boxMargin: 10, + boxTextMargin: 5, + noteMargin: 10, + messageMargin: 35, + }, + gantt: { + useMaxWidth: true, + }, + }); + + // 渲染图表 + const { svg } = await mermaid.render(`mermaid-${uniqueId}-${Date.now()}`, codeToRender.trim()); + setSvgContent(svg); + hasSvgContentRef.current = true; + lastRenderedCodeRef.current = codeToRender; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : '渲染失败'; + setError(errorMessage); + hasSvgContentRef.current = false; + // 静默处理错误,不在控制台输出(流式输出时会有很多临时错误) + } finally { + setIsLoading(false); + setIsWaitingToRender(false); + // 再次清理,确保错误元素被移除 + cleanupMermaidErrorElements(); + } + }, [uniqueId]); + + // 带防抖的渲染触发 + useEffect(() => { + // 清理之前的定时器 + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + + // 如果正在流式输出,显示等待状态,不触发渲染 + if (isStreaming) { + setIsWaitingToRender(true); + setIsLoading(true); + return; + } + + // 如果代码为空或太短,不渲染 + if (!code || code.trim().length < 10) { + setIsWaitingToRender(true); + return; + } + + // 设置防抖:代码稳定后才渲染 + setIsWaitingToRender(true); + debounceTimerRef.current = setTimeout(() => { + renderMermaid(code); + }, DEBOUNCE_DELAY); + + // 清理函数 + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + }; + }, [code, isStreaming, renderMermaid, currentTheme]); + + // 组件卸载时清理 + useEffect(() => { + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + cleanupMermaidErrorElements(); + }; + }, []); + + // 监听主题变化,触发图表重新渲染 + useEffect(() => { + if (typeof document === 'undefined') return; + + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.attributeName === 'data-theme') { + const newTheme = isDarkTheme() ? 'dark' : 'light'; + if (newTheme !== currentTheme) { + setCurrentTheme(newTheme); + // 清除缓存,强制重新渲染 + lastRenderedCodeRef.current = ''; + hasSvgContentRef.current = false; + } + } + }); + }); + + observer.observe(document.documentElement, { attributes: true }); + + return () => observer.disconnect(); + }, [currentTheme]); + + // 复制代码 + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(code); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }; + + // 切换全屏 + const toggleFullscreen = () => { + setIsFullscreen(!isFullscreen); + }; + + // ESC 关闭全屏 + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && isFullscreen) { + setIsFullscreen(false); + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isFullscreen]); + + return ( + <> +
+ {/* 工具栏 */} +
+ {/* 左侧:语言标识 + 视图切换 */} +
+ {/* Mermaid 图标 */} +
+
+ mermaid +
+ + {/* 视图切换 */} +
+ + +
+
+ + {/* 右侧:操作按钮 */} +
+ {/* 全屏按钮 */} + {viewMode === 'diagram' && !error && !isLoading && ( + + )} + {/* 复制按钮 */} + +
+
+ + {/* 内容区域 */} +
+ {viewMode === 'diagram' ? ( +
+ {/* 流式输出中 - 显示代码输入中状态 */} + {isStreaming ? ( +
+ +
+ 代码输入中 +
+ + + +
+
+ 流式输出完成后将自动渲染图表 +
+ ) : isLoading || isWaitingToRender ? ( +
+ + 渲染中... +
+ ) : error ? ( +
+ +
+
Mermaid 渲染失败
+
{error}
+
+
+ ) : ( +
+ )} +
+ ) : ( +
+              
+                {code}
+              
+            
+ )} +
+
+ + {/* 全屏模态框 */} + {isFullscreen && ( +
setIsFullscreen(false)} + > + +
e.stopPropagation()} + dangerouslySetInnerHTML={{ __html: svgContent }} + /> +
+ )} + + ); +}