html
复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mermaid 实时编辑器</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- FontAwesome 图标 -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Mermaid.js -->
<script src="https://cdn.jsdelivr.net/npm/mermaid@11.12.2/dist/mermaid.min.js"></script>
<!-- svg-pan-zoom 库 -->
<script src="https://cdn.jsdelivr.net/npm/svg-pan-zoom@3.6.1/dist/svg-pan-zoom.min.js"></script>
<style>
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: #f1f1f1; }
::-webkit-scrollbar-thumb { background: #c1c1c1; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #a8a8a8; }
.code-font { font-family: 'Menlo', 'Monaco', 'Courier New', monospace; }
body { background-color: #f5f7fa; }
#error-container {
display: none;
color: #ef4444;
background: #fee2e2;
padding: 10px;
border-radius: 6px;
margin-top: 10px;
font-size: 0.875rem;
white-space: pre-wrap;
}
#graph-container {
cursor: grab;
background-image: radial-gradient(#e5e7eb 1px, transparent 1px);
background-size: 20px 20px;
position: relative;
}
#graph-container:active { cursor: grabbing; }
#graphDiv { width: 100%; height: 100%; }
/* 加载遮罩层 */
#loading-overlay {
position: absolute;
inset: 0;
background: rgba(255,255,255,0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 50;
backdrop-filter: blur(2px);
display: none; /* 默认隐藏 */
}
.spinner {
width: 30px; height: 30px;
border: 3px solid #e5e7eb;
border-top-color: #4f46e5;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* 行号样式微调 */
#line-numbers {
min-width: 40px;
text-align: right;
user-select: none;
}
</style>
</head>
<body class="h-screen flex flex-col overflow-hidden">
<!-- 顶部导航 -->
<div class="bg-white border-b border-gray-200 px-6 py-3 flex justify-between items-center shrink-0 shadow-sm z-10">
<div class="flex items-center gap-2">
<span class="font-bold text-xl text-indigo-600">Mermaid</span>
<span class="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded">Live Editor</span>
</div>
<div class="flex space-x-3 text-gray-500">
<button onclick="downloadSVG()" title="下载 SVG" class="hover:text-indigo-600 hover:bg-indigo-50 px-3 py-1.5 rounded transition flex items-center gap-2 text-sm font-medium">
<i class="fas fa-download"></i> 导出 SVG
</button>
</div>
</div>
<!-- 主体内容 -->
<div class="flex flex-1 overflow-hidden p-4 gap-4">
<!-- 左侧:代码编辑 -->
<div class="w-1/3 flex flex-col min-w-[300px]">
<div class="bg-white rounded-t-lg border border-gray-200 border-b-0 px-4 py-2 text-xs font-bold text-gray-500 uppercase flex justify-between items-center">
<span>Source Code</span>
</div>
<!-- 修改:添加包裹容器以实现行号布局 -->
<div class="flex-1 flex overflow-hidden relative border border-gray-200 shadow-sm bg-white focus-within:border-indigo-400 transition-colors">
<!-- 行号栏 -->
<div id="line-numbers" class="bg-gray-50 text-gray-400 pt-4 pr-2 code-font text-sm leading-relaxed border-r border-gray-100 overflow-hidden">
1
</div>
<!-- 编辑框 (修改了 padding 和 whitespace) -->
<textarea id="inputCode" class="flex-1 w-full p-4 pl-3 resize-none outline-none code-font text-sm text-gray-700 bg-transparent border-none shadow-none focus:ring-0 leading-relaxed whitespace-pre" spellcheck="false" placeholder="在此输入 Mermaid 代码..."></textarea>
</div>
<div id="error-container" class="shadow-sm"></div>
</div>
<!-- 右侧:预览画布 -->
<div class="flex-1 bg-white rounded-lg shadow-sm border border-gray-200 relative overflow-hidden flex flex-col">
<div class="bg-gray-50 px-4 py-2 border-b border-gray-200 text-xs font-bold text-gray-500 uppercase flex justify-between items-center z-10">
<span>Preview</span>
<span class="text-gray-400 text-[10px] flex items-center gap-1">
<i class="fas fa-mouse"></i> 滚轮缩放 / 拖拽移动
</span>
</div>
<!-- 绘图容器 -->
<div id="graph-container" class="flex-1 overflow-hidden relative">
<!-- 加载动画 -->
<div id="loading-overlay"><div class="spinner"></div></div>
<!-- SVG 容器 -->
<div id="graphDiv" class="h-full w-full"></div>
</div>
<!-- 右下角控制栏 -->
<div class="absolute bottom-6 right-6 flex bg-white border border-gray-200 rounded-lg shadow-lg z-20">
<button onclick="zoomAct('in')" class="p-2 w-10 hover:bg-gray-50 text-gray-600 border-r border-gray-200 transition" title="放大"><i class="fas fa-plus"></i></button>
<button onclick="zoomAct('reset')" class="p-2 w-10 hover:bg-gray-50 text-gray-600 border-r border-gray-200 transition" title="自适应"><i class="fas fa-compress-arrows-alt"></i></button>
<button onclick="zoomAct('out')" class="p-2 w-10 hover:bg-gray-50 text-gray-600 transition" title="缩小"><i class="fas fa-minus"></i></button>
</div>
</div>
</div>
<script>
// 1. 初始化 Mermaid
mermaid.initialize({
startOnLoad: false,
theme: 'default',
securityLevel: 'loose',
logLevel: 'error',
er: {
useMaxWidth: false // 尝试禁用最大宽度(部分版本有效)
}
});
// 默认演示代码
const defaultCode = ``;
const inputEl = document.getElementById('inputCode');
const lineNumEl = document.getElementById('line-numbers');
const graphDiv = document.getElementById('graphDiv');
const errorContainer = document.getElementById('error-container');
const loadingOverlay = document.getElementById('loading-overlay');
let panZoomInstance = null;
inputEl.value = defaultCode;
// 新增:更新行号逻辑
const updateLineNumbers = () => {
const lines = inputEl.value.split('\n').length;
// 为了保证性能,如果行数没变可以不做操作,这里简单处理全量更新
// 使用 <br> 保证换行与 textarea 一致
lineNumEl.innerHTML = Array(lines).fill(0).map((_, i) => i + 1).join('<br>');
};
// 新增:行号同步滚动
inputEl.addEventListener('scroll', () => {
lineNumEl.scrollTop = inputEl.scrollTop;
});
// 2. 渲染核心函数
const renderDiagram = async () => {
const code = inputEl.value.trim();
if (!code) return;
// 显示加载状态
loadingOverlay.style.display = 'flex';
errorContainer.style.display = 'none';
try {
// 生成唯一ID
const id = 'mermaid-svg-' + Date.now();
// 验证语法
if (!await mermaid.parse(code)) {
throw new Error('Syntax Error');
}
// 渲染 SVG
const { svg } = await mermaid.render(id, code);
graphDiv.innerHTML = svg;
// 清理 Mermaid 的默认样式以兼容 svg-pan-zoom
const svgElement = graphDiv.querySelector('svg');
if (svgElement) {
// 移除 style 属性(特别是 max-width),否则 pan-zoom 会失效
svgElement.removeAttribute('style');
// 显式设置宽高为 100% 以填满容器
svgElement.setAttribute('width', '100%');
svgElement.setAttribute('height', '100%');
}
setupPanZoom();
} catch (error) {
console.error(error);
errorContainer.style.display = 'block';
// 简单的错误信息格式化
const msg = error.message || error.str || '语法错误,请检查代码';
errorContainer.innerText = msg;
} finally {
// 隐藏加载状态
loadingOverlay.style.display = 'none';
}
};
// 3. 配置 svg-pan-zoom
function setupPanZoom() {
if (panZoomInstance) {
panZoomInstance.destroy();
panZoomInstance = null;
}
const svgElement = graphDiv.querySelector('svg');
if (!svgElement) return;
panZoomInstance = svgPanZoom(svgElement, {
zoomEnabled: true,
controlIconsEnabled: false,
fit: true,
center: true,
minZoom: 0.1,
maxZoom: 10,
dblClickZoomEnabled: false
});
}
// 4. 防抖处理
let debounceTimer;
inputEl.addEventListener('input', () => {
updateLineNumbers(); // 输入时立即更新行号
clearTimeout(debounceTimer);
debounceTimer = setTimeout(renderDiagram, 500);
});
// 5. 缩放控制
function zoomAct(action) {
if (!panZoomInstance) return;
switch(action) {
case 'in': panZoomInstance.zoomIn(); break;
case 'out': panZoomInstance.zoomOut(); break;
case 'reset':
panZoomInstance.reset();
panZoomInstance.fit();
panZoomInstance.center();
break;
}
}
// 6. 下载 SVG (增强版:清理 PanZoom 注入的属性)
function downloadSVG() {
const svgEl = graphDiv.querySelector('svg');
if (!svgEl) return;
// 克隆节点以免影响当前视图
const clone = svgEl.cloneNode(true);
// 1. 移除 style 和 class
clone.removeAttribute('style');
clone.removeAttribute('class');
clone.setAttribute('width', '100%'); // 或者设为具体的 viewBox 尺寸
// 2. 尝试解包 svg-pan-zoom 创建的 <g class="svg-pan-zoom_viewport">
// 这样下载的文件在 Illustrator 中打开结构更干净
const viewportGroup = clone.querySelector('.svg-pan-zoom_viewport');
if (viewportGroup) {
// 将 viewport 内部的内容移到 SVG 根节点下,并移除 transform
// 注意:这会重置缩放,下载的是全图
const innerContents = Array.from(viewportGroup.childNodes);
innerContents.forEach(node => clone.appendChild(node));
clone.removeChild(viewportGroup);
}
// 添加 XML 声明
const svgData = '<?xml version="1.0" encoding="UTF-8"?>' + clone.outerHTML;
const blob = new Blob([svgData], {type: 'image/svg+xml;charset=utf-8'});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `mermaid-diagram-${Date.now()}.svg`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
// 首次加载
window.addEventListener('load', () => {
updateLineNumbers(); // 初始化行号
renderDiagram();
});
// 窗口大小改变时重置
window.addEventListener('resize', () => {
if(panZoomInstance) panZoomInstance.resize();
});
</script>
</body>
</html>