feat(组件): 新增 Mermaid 图表渲染组件

- 创建 MermaidBlock 组件支持流程图渲染
- 实现亮色/暗色双主题配色方案
- 支持主题切换时自动重新渲染
- 添加防抖处理优化流式输出体验
- 添加 Mermaid 背景色 CSS 变量
This commit is contained in:
gaoziman 2025-12-27 22:31:52 +08:00
parent 011f1d8742
commit ead84a1921
2 changed files with 485 additions and 0 deletions

View File

@ -77,6 +77,9 @@
--color-code-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); --color-code-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
/* Mac 红黄绿按钮 - 亮色主题隐藏 */ /* Mac 红黄绿按钮 - 亮色主题隐藏 */
--color-code-traffic-light-display: none; --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); --color-code-shadow: 0 8px 24px rgba(0, 0, 0, 0.25), 0 2px 8px rgba(0, 0, 0, 0.15);
/* Mac 红黄绿按钮 - 暗色主题显示 */ /* Mac 红黄绿按钮 - 暗色主题显示 */
--color-code-traffic-light-display: flex; --color-code-traffic-light-display: flex;
/* Mermaid 图表背景 - 暗色主题 */
--color-mermaid-bg: #3A353E;
} }
@theme inline { @theme inline {

View File

@ -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<ViewMode>('diagram');
const [copied, setCopied] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [svgContent, setSvgContent] = useState<string>('');
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<HTMLDivElement>(null);
const uniqueId = useId().replace(/:/g, '-');
// 防抖定时器引用
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
// 记录上次成功渲染的代码,避免重复渲染
const lastRenderedCodeRef = useRef<string>('');
// 使用 ref 跟踪 svgContent 是否有值,避免在 useCallback 中依赖 state
const hasSvgContentRef = useRef<boolean>(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 (
<>
<div
ref={containerRef}
className={cn(
'relative group rounded overflow-hidden my-4 border border-[var(--color-border)]',
className
)}
style={{ boxShadow: 'var(--color-code-shadow)' }}
>
{/* 工具栏 */}
<div
className="flex items-center justify-between px-4 py-2.5 text-[0.9em]"
style={{
backgroundColor: 'var(--color-code-toolbar-bg)',
color: 'var(--color-code-toolbar-text)',
borderBottom: '1px solid var(--color-code-border)'
}}
>
{/* 左侧:语言标识 + 视图切换 */}
<div className="flex items-center gap-3">
{/* Mermaid 图标 */}
<div className="flex items-center gap-2">
<div
className="w-4 h-4 rounded"
style={{ background: 'linear-gradient(135deg, #FF6B6B, #4ECDC4)' }}
/>
<span className="font-mono text-[13px]">mermaid</span>
</div>
{/* 视图切换 */}
<div
className="flex rounded-md overflow-hidden"
style={{
backgroundColor: 'var(--color-bg-tertiary)',
border: '1px solid var(--color-border)'
}}
>
<button
onClick={() => setViewMode('diagram')}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors',
viewMode === 'diagram'
? 'bg-[var(--color-primary)] text-white'
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'
)}
>
<Eye size={12} />
<span></span>
</button>
<button
onClick={() => setViewMode('code')}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors',
viewMode === 'code'
? 'bg-[var(--color-primary)] text-white'
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'
)}
>
<Code size={12} />
<span></span>
</button>
</div>
</div>
{/* 右侧:操作按钮 */}
<div className="flex items-center gap-2">
{/* 全屏按钮 */}
{viewMode === 'diagram' && !error && !isLoading && (
<button
onClick={toggleFullscreen}
className="inline-flex items-center gap-1.5 px-2 py-1 rounded transition-colors hover:bg-white/10"
style={{ color: 'var(--color-code-toolbar-text)' }}
title="全屏预览"
>
<Maximize2 size={14} />
</button>
)}
{/* 复制按钮 */}
<button
onClick={handleCopy}
className="inline-flex items-center gap-1.5 px-2 py-1 rounded transition-colors hover:bg-white/10"
style={{ color: 'var(--color-code-toolbar-text)' }}
title="复制代码"
>
{copied ? (
<>
<Check size={14} className="text-green-400" />
<span className="text-green-400">Copied!</span>
</>
) : (
<>
<Copy size={14} />
<span>Copy</span>
</>
)}
</button>
</div>
</div>
{/* 内容区域 */}
<div style={{ backgroundColor: viewMode === 'diagram' ? 'var(--color-mermaid-bg)' : 'var(--color-code-bg)' }}>
{viewMode === 'diagram' ? (
<div className="p-4 min-h-[200px] flex items-center justify-center">
{/* 流式输出中 - 显示代码输入中状态 */}
{isStreaming ? (
<div className="flex flex-col items-center gap-3 text-[var(--color-text-tertiary)]">
<FileCode size={32} style={{ color: 'var(--color-primary)', opacity: 0.6 }} />
<div className="flex items-center gap-2">
<span></span>
<div className="flex gap-1">
<span className="w-1.5 h-1.5 bg-[var(--color-primary)] rounded-full animate-bounce" style={{ animationDelay: '0s' }} />
<span className="w-1.5 h-1.5 bg-[var(--color-primary)] rounded-full animate-bounce" style={{ animationDelay: '0.2s' }} />
<span className="w-1.5 h-1.5 bg-[var(--color-primary)] rounded-full animate-bounce" style={{ animationDelay: '0.4s' }} />
</div>
</div>
<span className="text-xs opacity-70"></span>
</div>
) : isLoading || isWaitingToRender ? (
<div className="flex items-center gap-2 text-[var(--color-text-tertiary)]">
<Loader2 size={20} className="animate-spin" style={{ color: 'var(--color-primary)' }} />
<span>...</span>
</div>
) : error ? (
<div
className="flex items-start gap-3 p-4 rounded-lg w-full"
style={{
backgroundColor: 'rgba(248, 113, 113, 0.1)',
border: '1px solid rgba(248, 113, 113, 0.3)'
}}
>
<AlertCircle size={20} className="text-red-400 flex-shrink-0 mt-0.5" />
<div>
<div className="font-medium text-red-400 mb-1">Mermaid </div>
<div className="text-sm text-red-300/80">{error}</div>
</div>
</div>
) : (
<div
className="w-full overflow-auto"
dangerouslySetInnerHTML={{ __html: svgContent }}
/>
)}
</div>
) : (
<pre className="p-4 overflow-x-auto">
<code
className="font-mono text-[13px] leading-6"
style={{ color: 'var(--color-code-text)' }}
>
{code}
</code>
</pre>
)}
</div>
</div>
{/* 全屏模态框 */}
{isFullscreen && (
<div
className="fixed inset-0 z-[1000] flex items-center justify-center p-8"
style={{ backgroundColor: 'rgba(0, 0, 0, 0.9)' }}
onClick={() => setIsFullscreen(false)}
>
<button
onClick={() => setIsFullscreen(false)}
className="absolute top-4 right-4 w-12 h-12 flex items-center justify-center rounded-xl transition-colors"
style={{
backgroundColor: 'var(--color-bg-tertiary)',
color: 'var(--color-text-primary)'
}}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
<div
className="rounded p-8 max-w-[90vw] max-h-[90vh] overflow-auto"
style={{ backgroundColor: 'var(--color-mermaid-bg)' }}
onClick={(e) => e.stopPropagation()}
dangerouslySetInnerHTML={{ __html: svgContent }}
/>
</div>
)}
</>
);
}