feat(组件): 新增 Mermaid 图表渲染组件
- 创建 MermaidBlock 组件支持流程图渲染 - 实现亮色/暗色双主题配色方案 - 支持主题切换时自动重新渲染 - 添加防抖处理优化流式输出体验 - 添加 Mermaid 背景色 CSS 变量
This commit is contained in:
parent
011f1d8742
commit
ead84a1921
@ -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 {
|
||||
|
||||
479
src/components/markdown/MermaidBlock.tsx
Normal file
479
src/components/markdown/MermaidBlock.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user