HTML历理 树状思维导图2

<!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}
		.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;transition:background .2s;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!important;transition:opacity 0.15s}
		.mermaid svg .node:active{cursor:grabbing!important}
		.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:#cccccc;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">格式:名称||url 添加链接,Tab/空格缩进分级</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
		});
		let direction = "LR";
		let renderTimer = null;
		let resizeTimer = null;
		let lastMmdCode = "";

		mermaid.initialize({
			startOnLoad: false,
			maxTextSize: 60000,
			theme: "neutral",
			securityLevel: 'loose',
			flowchart: {
				useMaxWidth: false,
				rankSpacing: 60,
				nodeSpacing: 35
			}
		});

		// 左右面板拖拽缩放
		(function initResize(){
			const $left = $('.left-box');
			const $bar = $('#dragBar');
			let dragging = false;
			const savedWidth = localStorage.getItem('treeEditorLeftWidth');
			if(savedWidth) $left.width(+savedWidth);

			$bar.on('mousedown', e => {
				dragging = true;
				$bar.addClass('active');
				e.preventDefault();
			});
			$(document)
					.on('mousemove.resizepanel', e => {
						if(!dragging) return;
						const w = Math.max(280, Math.min(1000, e.clientX - $left.offset().left));
						$left.width(w);
					})
					.on('mouseup.resizepanel', () => {
						if(dragging){
							$bar.removeClass('active');
							dragging = false;
							localStorage.setItem('treeEditorLeftWidth', $left.width());
						}
					});
		})();

		// Textarea Tab缩进处理
		$('#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}));
			}
		});

		// 获取元素translate位置
		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};
		};

		// mermaid标签转义
		const escapeStr = str => str
				.replace(/&/g,'&amp')
				.replace(/"/g,'&quot')
				.replace(/#/g,'&#35')
				.replace(/;/g,'#59;')
				.replace(/\[/g,'#91;')
				.replace(/\]/g,'#93;')
				.replace(/\(/g,'#40;')
				.replace(/\)/g,'#41;')
				.replace(/\|/g,'#124;')
				.replace(/{/g,'#123;')
				.replace(/}/g,'#125;');

		let dragEventBind = false;
		const unbindNodeDrag = () => {
			$(document).off('mousemove.dragnode mouseup.dragnode');
			dragEventBind = false;
		};

		// 节点拖拽绑定
		const bindNodeDrag = () => {
			unbindNodeDrag();
			const $svg = $('#chart svg');
			if(!$svg.length) return;

			let dragTarget = null;
			let isDragging = false;
			let startX, startY, originX, originY;

			$svg.find('.node').off('mousedown').on('mousedown', e => {
				dragTarget = e.currentTarget;
				isDragging = false;
				const pos = getTranslate(dragTarget);
				originX = pos.x;
				originY = pos.y;
				startX = e.clientX;
				startY = e.clientY;
				e.preventDefault();
				e.stopPropagation();
			});
			$(document)
					.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;
							dragTarget.style.transform = `translate(${originX+dx}px,${originY+dy}px)`;
						}
					})
					.on('mouseup.dragnode', () => {
						if(isDragging && dragTarget){
							$(dragTarget).one('click', ev => ev.preventDefault());
						}
						dragTarget = null;
						isDragging = false;
					});
			dragEventBind = true;
		};

		// 渲染主逻辑
		async function renderChart(){
			try{
				const raw = $('#txt').val();
				if(raw.length > CONFIG.MAX_TEXT){
					$('#chart').html('<div class="error-tip">文本超出最大限制,请精简内容!</div>');
					unbindNodeDrag();
					return;
				}
				if(!raw.trim()){
					$('#chart').html('<div style="padding:40px;color:#999;text-align:center;">请在左侧输入树形文本</div>');
					unbindNodeDrag();
					lastMmdCode = "";
					return;
				}

				const lines = [];
				raw.split('\n').forEach(line => {
					const contentTrim = line.trim();
					if(!contentTrim) return;
					const prefix = line.match(/^([\t ]*)/)[1];
					const level = prefix.length;
					lines.push({level, text: contentTrim});
				});

				if(!lines.length){
					$('#chart').html('<div style="padding:40px;color:#999;text-align:center;">有效内容为空</div>');
					unbindNodeDrag();
					lastMmdCode = "";
					return;
				}

				let code = `graph ${direction}
    classDef linkNode fill:#fff8e8,stroke:#ff9800,stroke-width:1.5px,color:#222
`;
				const stack = [];
				lines.forEach((item, idx) => {
					const nid = `node_${idx}`;
					let label, linkUrl = null;
					const sepIndex = item.text.indexOf('||');
					if(sepIndex > 0){
						label = escapeStr(item.text.slice(0, sepIndex));
						linkUrl = item.text.slice(sepIndex+2).trim() || null;
						// 禁止javascript伪协议
						if(linkUrl && /^javascript:/i.test(linkUrl)) linkUrl = null;
					}else{
						label = escapeStr(item.text);
					}
					code += `    ${nid}["${label}"]\n`;
					if(linkUrl){
						code += `    click ${nid} "${linkUrl}" _blank\n    class ${nid} linkNode\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});
				});

				// 代码无变化跳过渲染
				if(lastMmdCode === code) return;
				lastMmdCode = code;

				$('#chart').html('<div class="loading-tip">渲染中...</div>');
				const uniqueId = 'mermaid_' + Date.now() + '_' + Math.random().toString(36).slice(2);
				const {svg} = await mermaid.render(uniqueId, code);
				$('#chart').html(svg);
				setTimeout(bindNodeDrag, 80);
			}catch(err){
				console.error('渲染失败', err);
				unbindNodeDrag();
				lastMmdCode = "";
				$('#chart').html(`<div class="error-tip">渲染出错:${err.message}<br>请检查文本缩进与语法</div>`);
			}
		}

		// 输入防抖
		$('#txt').on('input', () => {
			clearTimeout(renderTimer);
			renderTimer = setTimeout(renderChart, CONFIG.DEBOUNCE);
		});

		// 按钮事件
		$('#btnDemo').click(() => {
			$('#txt').val(`一级标题1
	二级标题11
		三级内容111
		三级内容112
	二级标题12
		三级内容121
		三级内容122

-
	-
		-
			-
				-
					-
						-
							-
								-
									-
										-
											-
												-
													-
														-
															-
使用示例
	普通文本
	外部链接||https://www.doubao.com/chat/
	内部链接||/R/R11/in.se.php?f=MTU%3D&u=Mg%3D%3D&w=0
	图片文件||/_/picf/Z/Z91/26071523370986.png
	音频文件||/_/picf/Z/Z91/26071517454150.mp3
	视频文件||/_/picf/Z/Z91/26071523373274.mp4
	文档文件||/Z/Ze3/file/0006/博弈论.pdf
	网页文件||/Z/Ze2/html/0002/index.php
	备注内容||###这是备注`);
			renderChart();
		});

		$('#btnClear').click(() => {
			$('#txt').val('');
			renderChart();
		});

		$('#btnToggleDir').click(() => {
			direction = direction === 'LR' ? 'TD' : 'LR';
			renderChart();
		});

		$('#btnExportSvg').click(()=>{
			const svgDom = $('#chart 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树形图_'+new Date().getTime()+'.svg';
			document.body.appendChild(a);
			a.click();
			a.remove();
		});

		$(window).on('resize', ()=>{
			clearTimeout(resizeTimer);
			resizeTimer = setTimeout(renderChart, CONFIG.RESIZE_DEBOUNCE);
		})

		renderChart();
	});
</script>
</body>
</html>

效果图:
111

posted @ 2026-07-31 15:34  onestopweb  阅读(9)  评论(0)    收藏  举报