Javascript网页设计实例:通过JS实现上传Markdown转化为脑图并下载脑图
- 创业
- 2025-08-28 06:00:02

功能预览 深度与密度测试
对于测试部分,分别对深度和密度进行了测试:
注意!!!!!!!只实现了识别Markdown中的#代表的层级,所以不能使用其余标识符!!! 注意!!!!!!!只实现了识别Markdown中的#代表的层级,所以不能使用其余标识符!!! 注意!!!!!!!只实现了识别Markdown中的#代表的层级,所以不能使用其余标识符!!! 注意!!!!!!!只实现了识别Markdown中的#代表的层级,所以不能使用其余标识符!!! 注意!!!!!!!只实现了识别Markdown中的#代表的层级,所以不能使用其余标识符!!!
测试数据:
# 量子力学基础 ## 1. 基础概念 ### 1.1 波粒二象性 #### 1.1.1 光的波粒二象性 ##### 1.1.1.1 光电效应 ###### 1.1.1.1.1 光电效应的实验现象 ###### 1.1.1.1.2 爱因斯坦的光电效应理论 ##### 1.1.1.2 康普顿效应 #### 1.1.2 微观粒子的波粒二象性 ##### 1.1.2.1 德布罗意假设 ##### 1.1.2.2 电子衍射实验 ### 1.2 不确定性原理 #### 1.2.1 海森堡不确定性原理 ##### 1.2.1.1 位置与动量的不确定性关系 ##### 1.2.1.2 能量与时间的不确定性关系 #### 1.2.2 理解与应用 ##### 1.2.2.1 对微观世界的解释 ##### 1.2.2.2 在量子计算中的意义 ## 2. 量子态与量子叠加 ### 2.1 量子态的描述 #### 2.1.1 状态向量与希尔伯特空间 ##### 2.1.1.1 状态向量的概念 ##### 2.1.1.2 希尔伯特空间的基本性质 #### 2.1.2 波函数与薛定谔方程 ##### 2.1.2.1 波函数的物理意义 ##### 2.1.2.2 薛定谔方程的形式与解法 ### 2.2 量子叠加原理 #### 2.2.1 叠加态的概念 ##### 2.2.1.1 叠加态的数学表示 ##### 2.2.1.2 叠加态的实验验证(双缝实验) #### 2.2.2 叠加态的应用 ##### 2.2.2.1 在量子通信中的应用 ##### 2.2.2.2 在量子计算中的应用 ## 3. 量子纠缠与非局域性 ### 3.1 量子纠缠的概念 #### 3.1.1 纠缠态的定义与分类 ##### 3.1.1.1 Bell态 ##### 3.1.1.2 GHZ态 #### 3.1.2 纠缠态的实验验证 ##### 3.1.2.1 EPR悖论 ##### 3.1.2.2 Bell不等式与实验结果 ### 3.2 非局域性与量子通信 #### 3.2.1 非局域性的物理意义 ##### 3.2.1.1 非局域性的实验验证 ##### 3.2.1.2 非局域性在量子隐形传态中的应用 #### 3.2.2 量子通信的基本原理 ##### 3.2.2.1 量子密钥分发(QKD) ##### 3.2.2.2 量子隐形传态(Quantum Teleportation)测试结果:
一、工具概述功能就是上传Markdown格式文件,然后转换为脑图,然后下载,没有添加其余功能了。
我觉得还可以添加: 1、为脑图添加标题。 2、现在的脑图颜色、连接方式单一,可以增加更多的样式。 3。。。。。。。。。再想想。
算了,本来也就是做一个样例,再想下去就快想出来一个成品了。。。。
半残不残的挺好的。。。。
另外,现在没做优化,所以,如果你直接copy代码的话,可能会出现一些内存占用的情况。
二、代码结构划分 1. HTML 结构 <div class="container"> <div class="upload-area"> <!-- 文件上传区域 --> <label for="markdownFile" class="upload-label">上传 Markdown 文件</label> <input type="file" id="markdownFile" accept=".md,.markdown" hidden> </div> <div class="mindmap-container"> <canvas id="canvas"></canvas> </div> </div> 定义了页面的基本布局,包括文件上传区域和画布容器。使用 canvas 元素作为图形渲染的主要载体。 2. CSS 样式 :root { --primary-color: #2196F3; --secondary-color: #4CAF50; --background: #f8f9fa; } body { font-family: 'Segoe UI', system-ui, sans-serif; margin: 0; background: var(--background); } 定义了主题颜色、字体和背景样式。实现了响应式布局和交互效果(如悬停动画)。 3. JavaScript 核心逻辑 class MindmapRenderer { constructor(canvas) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.scale = 1; this.offsetX = 0; this.offsetY = 0; this.nodes = []; this.initEvents(); } MindmapRenderer 类负责整个渲染流程,包括节点绘制、布局计算、交互操作(缩放和平移)。parseMarkdown 函数将 Markdown 文件解析为树状节点结构。exportHighResImage 函数实现高分辨率图片导出功能。
三、功能实现 1. 核心功能 (1)Markdown 转思维导图 支持将 Markdown 文件中的标题(# 符号)层级结构转换为树状结构。示例:# 根节点 ## 子节点1 ### 孙节点1 ## 子节点2 转换后生成如下结构:根节点 ├── 子节点1 │ └── 孙节点1 └── 子节点2 (2)节点渲染 每个节点根据层级(1-10)使用不同的样式(颜色、字体大小、圆角等)。示例:const NODE_STYLES = { 1: { bg: "#2962FF", text: "#fff", fontSize: 20 }, // 根节点样式 2: { bg: "#00C853", text: "#fff", fontSize: 19 }, // 子节点样式 // ... }; (3)交互操作 缩放和平移:用户可以通过鼠标滚轮缩放画布,拖拽画布进行平移。导出图片:支持将当前视图导出为高分辨率的 JPEG 图片。 (4)自动布局 使用递归算法计算节点位置和大小。动态调整子树宽度和高度,避免节点重叠。
2. 功能亮点 (1)动态布局算法 子树测量:递归测量每个节点及其子树的宽度和高度。碰撞检测:当节点过多时,自动调整位置避免重叠。压缩因子:优化节点布局,减少垂直方向的空间占用。 (2)交互体验 平滑缩放和平移:使用 CSS 3D 变换来实现流畅的操作。高分辨率导出:导出的图片保留所有细节,适合打印或分享。 (3)自适应设计 画布自适应:根据内容自动调整画布大小。响应式布局:使用 ResizeObserver 监听容器大小变化并自动调整。 (4)性能优化 渲染性能:通过合理布局减少重绘次数。事件处理:使用事件委托优化交互操作。
四、技术栈 1. 前端技术 HTML5 Canvas: 用于绘制复杂的图形和节点。CSS3: 实现响应式布局和交互效果。JavaScript ES6+: 使用现代 JavaScript 特性(如类、箭头函数等)。 2. 数据处理 Markdown 解析: 自行实现的解析器,支持多级标题嵌套。树形数据结构: 将 Markdown 文件转换为树形节点结构。 3. 交互技术 事件监听: 处理鼠标拖拽、滚轮缩放等操作。Canvas 缩放和平移: 使用 setTransform 方法实现复杂变换。 4. 图形绘制 Bezier 曲线: 绘制节点之间的连接线。文字换行: 在节点内实现文字自动换行。
五、当前缺点 1. 性能问题 处理大量节点时,渲染性能可能下降。缩放和平移操作在复杂场景下可能出现延迟。 2. 功能限制 Markdown 支持有限: 仅支持标题(#)语法,不支持其他 Markdown 元素(如列表、图片等)。缺乏编辑功能: 无法直接在画布上编辑节点内容。导出格式单一: 仅支持 JPEG 格式导出。 3. 用户体验 缺少加载进度提示,大文件上传时可能会出现卡顿。缩放和平移操作的手感有待优化(如增加惯性滚动)。 4. 代码结构 部分逻辑耦合度较高,维护成本较高。缺少单元测试和文档注释,代码可读性有待提升。
六、未来改进方向 1. 功能扩展 支持更多 Markdown 语法(如列表、图片、链接等)。增加节点编辑功能(如拖拽调整大小、修改文字内容)。支持更多导出格式(如 PNG、SVG)。 2. 性能优化 使用 Web Workers 分担后台计算任务。优化碰撞检测算法,减少计算量。 3. 用户体验提升 增加加载进度提示。优化缩放和平移操作的手感(如增加惯性滚动)。 4. 代码重构 提取公共逻辑,降低代码耦合度。增加单元测试和文档注释。
七、完整代码 <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Markdown转脑图</title> <style> :root { --primary-color: #2196F3; --secondary-color: #4CAF50; --background: #f8f9fa; } body { font-family: 'Segoe UI', system-ui, sans-serif; margin: 0; background: var(--background); } .container { max-width: 1800px; margin: 20px auto; padding: 20px; } .upload-area { text-align: center; margin-bottom: 30px; position: relative; } .mindmap-container { background: white; border-radius: 16px; box-shadow: 0 12px 32px rgba(0,0,0,0.1); overflow: hidden; height: 85vh; position: relative; } #canvas { cursor: grab; transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1); } .upload-label { display: inline-flex; align-items: center; padding: 12px 28px; background: var(--primary-color); color: white; border-radius: 10px; cursor: pointer; transition: transform 0.2s, box-shadow 0.2s; font-weight: 500; } .upload-label:hover { transform: translateY(-2px); box-shadow: 0 6px 16px rgba(33,150,243,0.25); } .export-btn { background: var(--secondary-color); margin-left: 15px; } .export-btn:hover { box-shadow: 0 6px 16px rgba(76,175,80,0.25); } </style> </head> <body> <div class="container"> <div class="upload-area"> <label for="markdownFile" class="upload-label"> 📁 上传 Markdown 文件 </label> <button class="upload-label export-btn" id="exportBtn">📷 导出图片</button> <input type="file" id="markdownFile" accept=".md,.markdown" hidden> </div> <div class="mindmap-container"> <canvas id="canvas"></canvas> </div> </div> <script> const NODE_STYLES = { 1: { bg: "#2962FF", text: "#fff", minWidth: 160, paddingX: 24, paddingY: 16, fontSize: 20, rectRadius: 12 }, 2: { bg: "#00C853", text: "#fff", minWidth: 148, paddingX: 22, paddingY: 14, fontSize: 19, rectRadius: 10 }, 3: { bg: "#AA00FF", text: "#fff", minWidth: 136, paddingX: 20, paddingY: 12, fontSize: 18, rectRadius: 9 }, 4: { bg: "#FF6D00", text: "#fff", minWidth: 124, paddingX: 18, paddingY: 10, fontSize: 17, rectRadius: 8 }, 5: { bg: "#6A1B9A", text: "#fff", minWidth: 112, paddingX: 16, paddingY: 9, fontSize: 16, rectRadius: 7 }, 6: { bg: "#D50000", text: "#fff", minWidth: 100, paddingX: 14, paddingY: 8, fontSize: 15, rectRadius: 6 }, 7: { bg: "#00897B", text: "#fff", minWidth: 88, paddingX: 12, paddingY: 7, fontSize: 14, rectRadius: 5 }, 8: { bg: "#546E7A", text: "#fff", minWidth: 76, paddingX: 10, paddingY: 6, fontSize: 13, rectRadius: 4 }, 9: { bg: "#757575", text: "#fff", minWidth: 64, paddingX: 8, paddingY: 5, fontSize: 12, rectRadius: 3 }, 10: { bg: "#BDBDBD", text: "#212121", minWidth: 52, paddingX: 6, paddingY: 4, fontSize: 11, rectRadius: 2 }, default: { bg: "#607D8B", text: "#fff", minWidth: 60, paddingX: 4, paddingY: 4, fontSize: 10, rectRadius: 2 } }; class MindmapRenderer { constructor(canvas) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.scale = 1; this.offsetX = 0; this.offsetY = 0; this.nodes = []; this.LAYOUT_CONFIG = { BASE_GAP_X: 60, BASE_GAP_Y: 25, DEPTH_REDUCTION: 1.4, MIN_SIBLING_GAP: 15, LINE_HEIGHT_RATIO: 1.2, DEPTH_WIDTH: 80 }; this.initEvents(); } initEvents() { let isDragging = false; let lastX = 0, lastY = 0; const handleStart = e => { isDragging = true; lastX = e.clientX; lastY = e.clientY; this.canvas.style.cursor = 'grabbing'; }; const handleMove = e => { if (isDragging) { const dx = (e.clientX - lastX) / this.scale; const dy = (e.clientY - lastY) / this.scale; this.offsetX += dx; this.offsetY += dy; lastX = e.clientX; lastY = e.clientY; this.render(); } }; const handleEnd = () => { isDragging = false; this.canvas.style.cursor = 'grab'; }; this.canvas.addEventListener('mousedown', handleStart); document.addEventListener('mousemove', handleMove); document.addEventListener('mouseup', handleEnd); this.canvas.addEventListener('wheel', e => { e.preventDefault(); const rect = this.canvas.getBoundingClientRect(); const mouseX = (e.clientX - rect.left - this.offsetX) / this.scale; const mouseY = (e.clientY - rect.top - this.offsetY) / this.scale; const zoom = e.deltaY < 0 ? 1.1 : 0.9; this.scale = Math.min(Math.max(this.scale * zoom, 0.3), 5); this.offsetX = (e.clientX - rect.left - mouseX * this.scale); this.offsetY = (e.clientY - rect.top - mouseY * this.scale); this.render(); }); } getNodeDepth(node) { let depth = 0; let current = node; while (current.parent) { depth++; current = current.parent; } return depth; } measureSubtree(node) { const ctx = this.ctx; node.size = this.calculateNodeSize(node, ctx); if (node.children.length === 0) { node.subtreeWidth = node.size.width; node.subtreeHeight = node.size.height; return; } let totalHeight = 0; let maxChildRight = 0; // 最大右侧位置 const depth = this.getNodeDepth(node); const dynamicGap = this.LAYOUT_CONFIG.BASE_GAP_Y * Math.pow(this.LAYOUT_CONFIG.DEPTH_REDUCTION, depth); node.children.forEach((child, index) => { this.measureSubtree(child); totalHeight += child.subtreeHeight; // 父右侧 + 间距 + 子节点宽度 const childRight = this.LAYOUT_CONFIG.DEPTH_WIDTH + child.size.width; maxChildRight = Math.max(maxChildRight, childRight); if (index !== node.children.length - 1) { totalHeight += dynamicGap; } }); node.subtreeHeight = Math.max(node.size.height, totalHeight); // 子树宽度 = 父节点半宽 + 最大子节点右侧 node.subtreeWidth = node.size.width / 2 + maxChildRight; } calculateNodeSize(node, ctx) { const style = NODE_STYLES[node.level] || NODE_STYLES.default; ctx.font = `${style.fontSize}px 'Segoe UI'`; const textMetrics = ctx.measureText(node.text); const contentWidth = textMetrics.width + style.paddingX * 2; const width = Math.max(style.minWidth, contentWidth); const lineHeight = style.fontSize * this.LAYOUT_CONFIG.LINE_HEIGHT_RATIO; const lines = Math.ceil(textMetrics.width / (width - style.paddingX * 2)); const height = Math.max(style.minWidth * 0.6, lines * lineHeight + style.paddingY * 2); return { width, height }; } calculateLayout(nodes) { const layoutNode = (node, startX, startY) => { node.x = startX; node.y = startY; if (node.children.length === 0) return; let currentY = startY - node.subtreeHeight / 2; const depth = this.getNodeDepth(node); const compressFactor = node.children.length > 3 ? 0.9 : 1; node.children.forEach(child => { // 修正子节点定位 const parentRightEdge = node.x + node.size.width / 2; const childX = parentRightEdge + this.LAYOUT_CONFIG.DEPTH_WIDTH + child.size.width / 2; const childY = currentY + child.subtreeHeight / 2 * compressFactor; layoutNode(child, childX, childY); currentY += child.subtreeHeight * compressFactor + this.LAYOUT_CONFIG.MIN_SIBLING_GAP; }); }; nodes.forEach(root => { this.measureSubtree(root); layoutNode(root, 100, this.canvas.height / 2 / this.scale); }); this.resolveCollisions(nodes); } resolveCollisions(nodes) { const findConflicts = (nodeList) => { nodeList.forEach((node, i) => { for (let j = i + 1; j < nodeList.length; j++) { const other = nodeList[j]; if (this.checkCollision(node, other)) { const offset = node.size.height + this.LAYOUT_CONFIG.MIN_SIBLING_GAP; other.y += offset; this.updateAncestorsPosition(other); } } if (node.children.length > 0) { findConflicts(node.children); } }); }; findConflicts(nodes); } checkCollision(a, b) { return Math.abs(a.x - b.x) < (a.size.width + b.size.width)/2 && Math.abs(a.y - b.y) < (a.size.height + b.size.height)/2; } updateAncestorsPosition(node) { let current = node.parent; while (current) { current.y = node.y; current = current.parent; } } drawNode(node) { const style = NODE_STYLES[node.level] || NODE_STYLES.default; const ctx = this.ctx; ctx.shadowColor = 'rgba(0,0,0,0.1)'; ctx.shadowOffsetX = 2; ctx.shadowOffsetY = 3; ctx.shadowBlur = 6; ctx.beginPath(); ctx.roundRect( node.x - node.size.width / 2, node.y - node.size.height / 2, node.size.width, node.size.height, style.rectRadius ); ctx.fillStyle = style.bg; ctx.fill(); ctx.shadowColor = 'transparent'; ctx.fillStyle = style.text; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.font = `${style.fontSize}px 'Segoe UI'`; this.wrapText(node.text, node.x, node.y, node.size.width - style.paddingX * 2, style.fontSize * 1.4 ); } wrapText(text, x, y, maxWidth, lineHeight) { const words = text.split(' '); let currentLine = ''; let currentY = y - (words.length > 1 ? lineHeight / 2 : 0); for (const word of words) { const testLine = currentLine ? `${currentLine} ${word}` : word; const metrics = this.ctx.measureText(testLine); if (metrics.width > maxWidth && currentLine) { this.ctx.fillText(currentLine, x, currentY); currentLine = word; currentY += lineHeight; } else { currentLine = testLine; } } this.ctx.fillText(currentLine, x, currentY); } drawConnection(parent, child) { const ctx = this.ctx; const parentRight = parent.x + parent.size.width / 2; const childLeft = child.x - child.size.width / 2; const controlX = (parentRight + childLeft) / 2; ctx.beginPath(); ctx.moveTo(parentRight, parent.y); ctx.bezierCurveTo( controlX, parent.y, controlX, child.y, childLeft, child.y ); ctx.strokeStyle = parent.level === 1 ? '#78909C' : '#B0BEC5'; ctx.lineWidth = 2; ctx.stroke(); } render() { this.ctx.save(); this.ctx.setTransform(1, 0, 0, 1, 0, 0); this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); this.ctx.scale(this.scale, this.scale); this.ctx.translate(this.offsetX, this.offsetY); this.traverseNodes(node => this.drawNode(node)); this.traverseNodes(node => { node.children.forEach(child => this.drawConnection(node, child)); }); this.ctx.restore(); } traverseNodes(callback) { const traverse = node => { callback(node); node.children.forEach(traverse); }; this.nodes.forEach(traverse); } } // 页面集成 const canvas = document.getElementById('canvas'); const renderer = new MindmapRenderer(canvas); const container = document.querySelector('.mindmap-container'); function adaptiveResize() { const computeCanvasSize = () => { if (!renderer.nodes.length) { return [container.clientWidth, container.clientHeight]; } let minX = Infinity, maxX = -Infinity; let minY = Infinity, maxY = -Infinity; renderer.traverseNodes(node => { minX = Math.min(minX, node.x - node.size.width/2); maxX = Math.max(maxX, node.x + node.size.width/2); minY = Math.min(minY, node.y - node.size.height/2); maxY = Math.max(maxY, node.y + node.size.height/2); }); return [ Math.max(container.clientWidth, (maxX - minX) * 1.2 * renderer.scale), Math.max(container.clientHeight, (maxY - minY) * 1.2 * renderer.scale) ]; }; const [newWidth, newHeight] = computeCanvasSize(); canvas.width = newWidth; canvas.height = newHeight; renderer.render(); } function resizeCanvas() { if (!renderer.nodes.length) { canvas.width = container.clientWidth; canvas.height = container.clientHeight; return; } let minX = Infinity, maxX = -Infinity; let minY = Infinity, maxY = -Infinity; renderer.traverseNodes(node => { minX = Math.min(minX, node.x - node.size.width/2); maxX = Math.max(maxX, node.x + node.size.width/2); minY = Math.min(minY, node.y - node.size.height/2); maxY = Math.max(maxY, node.y + node.size.height/2); }); canvas.width = Math.max(container.clientWidth, (maxX - minX) * 1.2 * renderer.scale); canvas.height = Math.max(container.clientHeight, (maxY - minY) * 1.2 * renderer.scale); renderer.render(); } // 响应式处理 const resizeObserver = new ResizeObserver(() => adaptiveResize()); resizeObserver.observe(container); // 文件处理 document.getElementById('markdownFile').addEventListener('change', async e => { const file = e.target.files[0]; if (!file) return; const text = await file.text(); renderer.nodes = parseMarkdown(text); renderer.calculateLayout(renderer.nodes); adaptiveResize(); }); // Markdown解析 function parseMarkdown(content) { const lines = content.split('\n').filter(l => l.trim()); const rootNodes = []; const stack = []; let lastLevel = 0; lines.forEach(line => { const match = line.match(/^(#+)\s*(.*)/); if (!match) return; const level = match[1].length; const node = { text: match[2].trim(), level: level, children: [], parent: null }; // 层级关系处理 if (level > lastLevel) { if (stack.length > 0) { node.parent = stack[stack.length - 1]; node.parent.children.push(node); } } else { while (stack.length && stack[stack.length - 1].level >= level) { stack.pop(); } if (stack.length) { node.parent = stack[stack.length - 1]; node.parent.children.push(node); } } if (!node.parent) rootNodes.push(node); stack.push(node); lastLevel = level; }); return rootNodes; } function exportHighResImage() { const exportCanvas = document.createElement("canvas"); const exportCtx = exportCanvas.getContext("2d"); // 计算全图边界 let minX = Infinity, maxX = -Infinity; let minY = Infinity, maxY = -Infinity; renderer.traverseNodes(node => { const halfWidth = node.size.width / 2; const halfHeight = node.size.height / 2; minX = Math.min(minX, node.x - halfWidth); maxX = Math.max(maxX, node.x + halfWidth); minY = Math.min(minY, node.y - halfHeight); maxY = Math.max(maxY, node.y + halfHeight); }); // 设置画布尺寸 const padding_left = 100; const padding_top = 80; exportCanvas.width = (maxX - minX) + padding_left * 2; exportCanvas.height = (maxY - minY) + padding_top * 2; // 填充白色背景 exportCtx.fillStyle = "#FFFFFF"; exportCtx.fillRect(0, 0, exportCanvas.width, exportCanvas.height); // 保存原始状态 const originalScale = renderer.scale; const originalOffsetX = renderer.offsetX; const originalOffsetY = renderer.offsetY; const originalCtx = renderer.ctx; renderer.scale = 1; renderer.offsetX = -minX + padding_left; renderer.offsetY = -minY + padding_top; renderer.ctx = exportCtx; // 执行渲染 renderer.render(); // 二次填充边缘透明区域 exportCtx.globalCompositeOperation = "destination-over"; exportCtx.fillStyle = "#FFFFFF"; exportCtx.fillRect(0, 0, exportCanvas.width, exportCanvas.height); // 导出为JPG const link = document.createElement("a"); link.download = "mindmap.jpg"; link.href = exportCanvas.toDataURL("image/jpeg", 1.0); link.click(); } // 绑定导出事件 document.getElementById('exportBtn').addEventListener('click', exportHighResImage); window.addEventListener('resize', resizeCanvas); resizeCanvas(); </script> </body> </html>
Javascript网页设计实例:通过JS实现上传Markdown转化为脑图并下载脑图由讯客互联创业栏目发布,感谢您对讯客互联的认可,以及对我们原创作品以及文章的青睐,非常欢迎各位朋友分享到个人网站或者朋友圈,但转载请说明文章出处“Javascript网页设计实例:通过JS实现上传Markdown转化为脑图并下载脑图”
下一篇
数据结构之队列