HTML历理 树状思维导图3
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="renderer" content="webkit">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>脑图预览器 - 暂无标题 - ICbA</title>
<link rel="stylesheet" href="/_/_js/layui/css/layui.css">
<style>
html,body{height:100%;margin:0;overflow:hidden;font-family:"Microsoft YaHei",system-ui,-apple-system,sans-serif}
.wrap-box{display:flex;height:calc(100vh - 20px);gap:10px}
.left-box{width:420px;min-width:280px;max-width:1000px;display:flex;flex-direction:column}
.layui-card-header{white-space:nowrap!important;overflow:hidden;text-overflow:ellipsis}
.resize-bar{width:6px;background:#e2e2e2;cursor:ew-resize;border-radius:3px;user-select:none;position:relative}
.resize-bar::before{content:"";position:absolute;left:-4px;right:-4px;top:0;bottom:0}
.resize-bar:hover,.resize-bar.active{background:#1E9FFF}
.right-box{flex:1;min-width:340px;display:flex;flex-direction:column}
.mermaid svg .node{cursor:grab;transition:opacity .15s;will-change:transform}
.mermaid svg .node:active{cursor:grabbing}
.mermaid svg .node rect{rx:6px;ry:6px}
#chart::-webkit-scrollbar,.layui-textarea::-webkit-scrollbar{width:6px;height:6px}
#chart::-webkit-scrollbar-thumb,.layui-textarea::-webkit-scrollbar-thumb{background:#ccc;border-radius:3px}
#chart::-webkit-scrollbar-track,.layui-textarea::-webkit-scrollbar-track{background:#f6f6f6}
#chart{overscroll-behavior:contain}
.loading-tip{color:#666;text-align:center;padding:40px;font-size:14px}
.error-tip{color:#d94444;padding:16px;line-height:1.7}
</style>
</head>
<body>
<div class="layui-fluid" style="padding:10px;height:100vh;box-sizing:border-box;">
<div class="wrap-box">
<div class="left-box">
<div class="layui-card" style="flex:1;display:flex;flex-direction:column;height:100%;">
<div class="layui-card-header">
<span class="layui-text" title="格式:名称 || 链接(点击在新标签打开链接)、名称 ||note: 备注(悬浮注释;点击弹窗查看完整备注);Tab/空格 增加缩进加深层级,Shift+Tab 减少缩进提升层级, \n换行分隔节点的文本。(建议只使用Tab和Shift+Tab用来缩进)">格式:名称||链接,||note:备注;Tab缩进;\n换行</span>
</div>
<div class="layui-card-body" style="flex:1;display:flex;flex-direction:column;padding:10px;">
<div class="layui-btn-group" style="margin-bottom:8px;flex-wrap:wrap;">
<button class="layui-btn layui-btn-sm" id="btnDemo">载入示例</button>
<button class="layui-btn layui-btn-sm layui-btn-primary" id="btnClear">清空</button>
<button class="layui-btn layui-btn-sm layui-btn-normal" id="btnToggleDir">切换横向/纵向</button>
<button class="layui-btn layui-btn-sm layui-btn-warm" id="btnExportSvg">导出</button>
</div>
<textarea id="txt" class="layui-textarea" style="flex:1;resize:none;font-size:14px;line-height:1.6;" placeholder="一级标题1
二级标题11
三级内容111
三级内容112
二级标题12
三级内容121
三级内容122">一级标题1
二级标题11
三级内容111
三级内容112
二级标题12
三级内容121
三级内容122</textarea>
</div>
</div>
</div>
<div class="resize-bar" id="dragBar"></div>
<div class="right-box">
<div class="layui-card" style="height:100%;">
<div class="layui-card-header">
树形预览图
<span style="color:#888;font-size:12px;margin-left:10px;">(节点可拖拽调整位置;拖动不会触发跳转/弹窗)</span>
</div>
<div class="layui-card-body" id="chart" style="height:calc(100% - 55px);overflow:auto;">
<div class="loading-tip">正在初始化渲染引擎...</div>
</div>
</div>
</div>
</div>
</div>
<script src="/_/_js/layui/layui.js"></script>
<script src="/_/_js/mermaid/mermaid.min.js"></script>
<script>
layui.use(['jquery', 'layer'], function () {
const $ = layui.jquery;
const layer = layui.layer;
const CONFIG = Object.freeze({
DEBOUNCE: 320,
RESIZE_DEBOUNCE: 400,
MAX_TEXT: 40000,
DRAG_THRESHOLD: 6,
LEFT_MIN: 280,
LEFT_MAX: 1000
});
const $txt = $('#txt');
const $chart = $('#chart');
const $leftBox = $('.left-box');
const $dragBar = $('#dragBar');
let direction = "LR";
let renderTimer = null;
let resizeTimer = null;
let lastMmdCode = "";
let isRendering = false;
const nodeNoteMap = new Map();
mermaid.initialize({
startOnLoad: false,
maxTextSize: 60000,
theme: "neutral",
securityLevel: 'loose',
flowchart: {
useMaxWidth: false,
rankSpacing: 60,
nodeSpacing: 35
}
});
const debounce = (fn, delay) => {
let timer = null;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
}
};
(() => {
let dragging = false;
const savedWidth = localStorage.getItem('treeEditorLeftWidth');
if (savedWidth) $leftBox.width(Math.min(CONFIG.LEFT_MAX, Math.max(CONFIG.LEFT_MIN, +savedWidth)));
$dragBar.on('mousedown', e => {
dragging = true;
$dragBar.addClass('active');
e.preventDefault();
});
$(document)
.on('mousemove.resizepanel', e => {
if (!dragging) return;
const w = Math.max(CONFIG.LEFT_MIN, Math.min(CONFIG.LEFT_MAX, e.clientX - $leftBox.offset().left));
$leftBox.width(w);
})
.on('mouseup.resizepanel', () => {
if (dragging) {
$dragBar.removeClass('active');
dragging = false;
localStorage.setItem('treeEditorLeftWidth', $leftBox.width());
renderChart();
}
});
})();
$txt.on('keydown', e => {
const ta = e.target;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const value = ta.value;
if (e.key === 'Tab' && !e.shiftKey) {
e.preventDefault();
if (start !== end) {
const selected = value.slice(start, end);
const newSel = selected.replace(/^/gm, '\t');
ta.value = value.slice(0, start) + newSel + value.slice(end);
ta.selectionStart = start;
ta.selectionEnd = start + newSel.length;
} else {
ta.value = value.slice(0, start) + '\t' + value.slice(end);
ta.selectionStart = ta.selectionEnd = start + 1;
}
ta.dispatchEvent(new Event('input', { bubbles: true }));
return;
}
if (e.key === 'Tab' && e.shiftKey) {
e.preventDefault();
if (start !== end) {
const selected = value.slice(start, end);
const newSel = selected.replace(/^\t/gm, '');
ta.value = value.slice(0, start) + newSel + value.slice(end);
ta.selectionStart = start;
ta.selectionEnd = start + newSel.length;
} else {
if (start > 0 && value[start - 1] === '\t') {
ta.value = value.slice(0, start - 1) + value.slice(start);
ta.selectionStart = ta.selectionEnd = start - 1;
}
}
ta.dispatchEvent(new Event('input', { bubbles: true }));
}
});
const getTranslate = el => {
const transform = getComputedStyle(el).transform;
if (transform === 'none') return { x: 0, y: 0 };
const m = new WebKitCSSMatrix(transform);
return { x: m.m41, y: m.m42 };
};
const escapeStr = str => str
.replace(/&/g,'&')
.replace(/"/g,'"')
.replace(/#/g,'#')
.replace(/;/g,'#59;')
.replace(/\[/g,'#91;')
.replace(/\]/g,'#93;')
.replace(/\(/g,'#40;')
.replace(/\)/g,'#41;')
.replace(/\|/g,'#124;')
.replace(/{/g,'#123;')
.replace(/}/g,'#125;');
const bindNodeEvent = () => {
const $svg = $chart.find('svg');
if (!$svg.length) return;
let dragTarget = null, isDragging = false;
let startX, startY, originX, originY;
let preventClick = false;
$svg.find('.node').each(function () {
const $node = $(this);
const nid = $node.attr('data-nid');
$node.find('title').remove();
if (nodeNoteMap.has(nid)) {
const tipText = nodeNoteMap.get(nid).replace(/\\n/g, "\n");
const titleEl = document.createElementNS('http://www.w3.org/2000/svg', 'title');
titleEl.textContent = tipText;
this.appendChild(titleEl);
}
});
$svg
.off('mousedown click')
.on('mousedown', '.node', function (e) {
dragTarget = this;
isDragging = false;
preventClick = false;
const pos = getTranslate(dragTarget);
originX = pos.x;
originY = pos.y;
startX = e.clientX;
startY = e.clientY;
e.preventDefault();
})
.on('click', '.node', function (e) {
if (preventClick) {
e.preventDefault();
e.stopPropagation();
return false;
}
const nid = $(this).attr('data-nid');
if(nodeNoteMap.has(nid)){
const content = nodeNoteMap.get(nid).replace(/\\n/g,"<br>");
layer.open({
title: '该节点备注详情',
content: `<div style="padding:10px;white-space:pre-line;">${content}</div>`,
area: ['520px', 'auto'],
shadeClose:true
});
e.preventDefault();
return false;
}
});
$(document)
.off('mousemove.dragnode mouseup.dragnode')
.on('mousemove.dragnode', e => {
if (!dragTarget) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (Math.abs(dx) > CONFIG.DRAG_THRESHOLD || Math.abs(dy) > CONFIG.DRAG_THRESHOLD) {
isDragging = true;
preventClick = true;
dragTarget.style.transform = `translate(${originX + dx}px,${originY + dy}px)`;
}
})
.on('mouseup.dragnode', () => {
dragTarget = null;
isDragging = false;
setTimeout(() => preventClick = false, 80);
});
};
const parseTextToMermaid = rawText => {
nodeNoteMap.clear();
const lines = [];
rawText.split('\n').forEach(line => {
const pureLine = line.replace(/\r$/, '');
const contentTrim = pureLine.trim();
if (!contentTrim) return;
const prefixMatch = pureLine.match(/^([\t ]*)/);
const level = prefixMatch ? prefixMatch[1].length : 0;
lines.push({ level, text: contentTrim });
});
if (!lines.length) return null;
let code = `graph ${direction}
classDef linkNode fill:#f0f7ff,stroke:#5c9ce6,color:#000
classDef noteNode fill:#f8f0fc,stroke:#ba68c8,color:#000
`;
const stack = [];
lines.forEach((item, idx) => {
const nid = `node_${idx}`;
const sepIndex = item.text.indexOf('||');
let label, content = null;
if (sepIndex > 0) {
label = escapeStr(item.text.slice(0, sepIndex));
content = item.text.slice(sepIndex + 2).trim();
if (content.startsWith('note:')) {
const note = content.substring(5).replace(/\r?\n/g, '\\n');
nodeNoteMap.set(nid, note);
code += ` ${nid}["${label}"]\n class ${nid} noteNode\n`;
} else {
const jumpUrl = content === '' ? '#' : content;
code += ` ${nid}["${label}"]\n click ${nid} "${jumpUrl}" _blank\n class ${nid} linkNode\n`;
}
} else {
label = escapeStr(item.text);
code += ` ${nid}["${label}"]\n`;
}
while (stack.length && stack[stack.length - 1].level >= item.level) stack.pop();
if (stack.length) {
const parentId = stack[stack.length - 1].nid;
code += ` ${parentId} --> ${nid}\n`;
}
stack.push({ level: item.level, nid });
});
return code;
};
const renderChart = async () => {
if (isRendering) return;
const raw = $txt.val();
if (raw.length > CONFIG.MAX_TEXT) {
$chart.html('<div class="error-tip">文本超出最大限制,请精简内容!</div>');
lastMmdCode = '';
return;
}
if (!raw.trim()) {
$chart.html('<div style="padding:40px;color:#999;text-align:center;">请在左侧输入树形文本</div>');
lastMmdCode = '';
return;
}
const mmdCode = parseTextToMermaid(raw);
if (!mmdCode || mmdCode === lastMmdCode) return;
lastMmdCode = mmdCode;
isRendering = true;
$chart.html('<div class="loading-tip">渲染中...</div>');
try {
$chart.find('div[id^="mermaid_"]').remove();
const uniqueId = 'mermaid_' + Date.now() + '_' + Math.random().toString(36).slice(2);
const { svg } = await mermaid.render(uniqueId, mmdCode);
$chart.html(svg);
$chart.find('svg .node').each(function () {
const rawId = $(this).attr('id') || '';
const match = rawId.match(/node_(\d+)/);
if (match) $(this).attr('data-nid', 'node_' + match[1]);
});
bindNodeEvent();
} catch (err) {
console.error('渲染失败:', err);
setTimeout(() => {
if (!isRendering) renderChart();
}, 200);
lastMmdCode = '';
$chart.html(`<div class="error-tip">渲染出错:${err.message}<br>请检查文本缩进与语法</div>`);
} finally {
isRendering = false;
}
};
$txt.on('input', debounce(renderChart, CONFIG.DEBOUNCE));
$('#btnDemo').click(() => {
$txt.val(`一级标题1
二级标题11
三级内容111
三级内容112
二级标题12
三级内容121
三级内容122
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
使用示例
图案用法
🔶标题文本
🔹段落文本
⚠️提醒文本
💡思路文本
❓疑问文本
✍️解答文本
⬜空白选项
✅正确选项
❌错误选项
👍推荐文本
👎不推荐文本
📖参考资料
🔍拓展查阅
📌要点标记
🚫严禁操作
⏰时限提醒
💬链接备注
链接用法
外部链接||https://www.doubao.com/chat/
内部链接||/_/picf/Z/Z91/26071523373274.mp4
链接加备注||/Z/Ze2/html/0002/index.php###💬获取服务器的IPV6地址
符号用法
🔶运算符号||note:🔶运算符号:\\n🔹基础四则符号:+ 加号,- 减号,± 正负号,∓ 减加号,× 乘号,· 点乘,/ 斜除,÷ 除号,: 比号,! 阶乘,| 绝对值;\\n🔹高等运算补充:∗ 卷积;\\n🔹百分与乘方:% 百分号,‰ 千分号,^ 乘方;\\n🔹上下标字符:² 平方,³ 立方,⁴ 四次方,⁵ 五次方,₁ 下标1,₂ 下标2,₃ 下标3,₀ 下标0;\\n🔹根式符号:√ 二次根号,∛ 三次根号,∜ 四次根号;\\n🔹累加累乘符号:∑ 求和,∏ 求积;\\n🔹对数专用符号:㏒ 对数,lg 常用对数,㏑ 自然对数;\\n🔹微积分符号:Δ 增量/差分,d 微分,∂ 偏微分,∫ 积分,∮ 环路积分,∬ 二重积分,∭ 三重积分,∇ 梯度,∇· 散度,∇× 旋度,∇² 拉普拉斯算子;\\n🔹取整边界符号:⌊ 向下取整左括号,⌋ 向下取整右括号,⌈ 向上取整左括号,⌉ 向上取整右括号;\\n🔹逻辑运算符:∧ 逻辑与,∨ 逻辑或,¬ 逻辑非,⊕ 异或,⇒ 推出,→ 趋向/映射;
🔶数学符号||note:🔶数学符号:\\n🔹关系符号:= 等于,≠ 不等于,≈ 约等于,≡ 恒等,≜ 定义,> 大于,< 小于,≥ 大于等于,≤ 小于等于,≫ 远大于,≪ 远小于,∝ 成正比;\\n🔹集合符号:∅ 空集,∈ 属于,∉ 不属于,⊆ 子集,⊊ 真子集,⊇ 超集,⊋ 真超集,∪ 并集,∩ 交集,∁ 补集;\\n🔹数集符号:ℕ 自然数集,ℤ 整数集,ℚ 有理数集,ℝ 实数集,ℂ 复数集;\\n🔹复数符号:i 虚数单位,Re 实部,Im 虚部;\\n🔹几何符号:∠ 角,∟ 直角,△ 三角形,⊙ 圆,⌒ 圆弧,∥ 平行,⊥ 垂直,≌ 全等,∽ 相似;\\n🔹角度单位:° 度,′ 角分,″ 角秒;\\n🔹量词与无穷:∀ 任意,∃ 存在,∞ 无穷大;\\n🔹括号符号:( 左圆括号,) 右圆括号,[ 左方括号,] 右方括号,{ 左大括号,} 右大括号,⟨ 左尖括号,⟩ 右尖括号;\\n🔹因果推导:∵ 因为,∴ 所以;\\n🔹数学常数:π 圆周率,e 自然对数底;
🔶物理符号||note:🔶物理符号:\\n🔹SI 基本单位:m 米,kg 千克,s 秒,A 安培,K 开尔文,mol 摩尔,cd 坎德拉;\\n🔹常用单位词头:k 千,h 百,d 分,c 厘,m 毫,μ 微,n 纳,p 皮;\\n🔹力学物理量:m 质量,v 速度,a 加速度,F 力,G 引力常量,g 重力加速度,P 功率,W 功,ρ 密度,t 时间,s 位移;\\n🔹电磁学物理量:E 电场强度,B 磁感应强度,U 电压,I 电流,R 电阻,C 电容,L 电感,Φ 磁通量,Ω 欧姆/立体角;\\n🔹热学温度:T 热力学温标,Q 热量,U 内能,S 熵,℃ 摄氏度,℉ 华氏度;\\n🔹光学物理量:λ 波长,f 焦距/频率,n 折射率;\\n🔹基础物理常量:c 真空光速,h 普朗克常量,ħ 约化普朗克常量,e 元电荷,G 引力常量;\\n🔹希腊字母:α 阿尔法,β 贝塔,γ 伽马,δ 德尔塔,θ 西塔,λ 拉姆达,μ 缪,σ 西格玛,ω 欧米伽;
🔶化学符号||note:🔶化学符号:\\n🔹物质状态:(s) 固态,(l) 液态,(g) 气态,(aq) 水溶液;\\n🔹化学反应:→ 反应生成,⇌ 可逆反应,Δ 加热条件,↑ 气体生成,↓ 沉淀生成;\\n🔹热力学符号:ΔH 焓变,ΔG 吉布斯自由能变,ΔS 熵变;\\n🔹浓度与结构:c 物质的量浓度,σ 西格玛键,π 派键;\\n🔹酸碱度:pH 氢离子浓度指数;
🔶计算机符号||note:🔶计算机符号(PHP 版本):\\n🔹逻辑运算:&& 逻辑与,|| 逻辑或,! 逻辑非,and 与,or 或,xor 逻辑异或;\\n🔹位运算:& 按位与,| 按位或,^ 按位异或,~ 按位取反,<<左移,>> 右移;\\n🔹算术运算:+ 加法,- 减法,* 乘法,/ 除法,% 取模,** 幂运算;\\n🔹比较运算:> 大于,< 小于,>= 大于等于,<= 小于等于,== 弱等于,=== 严格等于,!= 弱不等,!== 严格不等,<=> 太空船比较运算符;\\n🔹赋值运算:= 基础赋值,+= 加赋值,-= 减赋值,*= 乘赋值,/= 除赋值,%= 取模赋值,**= 幂赋值,&= 按位与赋值,|= 按位或赋值,^= 异或赋值,<<= 左移赋值,>>= 右移赋值;\\n🔹递增递减:++ 自增,-- 自减;\\n🔹进制标识:0b 二进制前缀,0o 八进制前缀,0x 十六进制前缀;\\n🔹三元类运算符:?: 标准三元运算,简写?: 省略三元运算,?? 空合并运算符;\\n🔹错误控制符:@ 屏蔽运行错误;\\n🔹字符串运算符:. 字符串拼接,.= 拼接赋值;\\n🔹数组符号:[] 数组定义、下标取值、下标赋值,=> 数组键值绑定;\\n🔹对象符号:-> 对象实例成员访问,:: 静态成员访问,?-> 安全对象访问;\\n🔹类型运算符:instanceof 判断对象所属类;\\n🔹分隔符号:, 逗号分隔,; 语句结束符;`);
renderChart();
});
$('#btnClear').click(() => {
$txt.val('');
renderChart();
});
$('#btnToggleDir').click(() => {
direction = direction === 'LR' ? 'TD' : 'LR';
renderChart();
});
$('#btnExportSvg').click(() => {
const svgDom = $chart.find('svg')[0];
if (!svgDom) return layer.msg('暂无预览图形');
const serializer = new XMLSerializer();
let source = serializer.serializeToString(svgDom);
source = '<' + '?xml version="1.0" standalone="no"?>\r\n' + source;
const url = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(source);
const a = document.createElement('a');
a.href = url;
a.download = `ICbA树形图_${Date.now()}.svg`;
document.body.appendChild(a);
a.click();
a.remove();
});
$(window).on('resize', debounce(renderChart, CONFIG.RESIZE_DEBOUNCE));
renderChart();
});
</script>
</body>
</html>
效果图:


浙公网安备 33010602011771号