网上找了许多相关文章,但是都没有直接贴代码,还有内容较完善的代码,为了造福有需要的码农,在高度定制化的基础下,我决定写下这篇文章,附上代码解释,该功能只有基础的。
遇到bug请反馈,我目前没有遇到卡壳的问题。
看在博主辛苦的份上留下小心心❤,关注博主不迷路!!!!!
前言
首先讲一下这个“xmind”有什么功能,以及数据格式,这个或许是大家想知道的:
分为编辑模式和查看模式(这个不需要的话的,可以改),所有操作都在编辑模式下进行编辑模式 ==》节点:添加节点,删除节点,折叠节点,修改内容,修改节点样式(节点的背景色,边框,文字的大小,粗细,颜色),节点之间的挂载,框选节点进行概要。编辑模式 ==》连接线:删除连接线,修改连线的方向(水平方向和垂直方向,这个功能主要是因为连接线的算法,有更好的算法欢迎交流)。查看模式 ==》画布:超出拖拽。数据格式 ==》:可以根据业务需求新增字段,以下字段属于基础字段,这里不是使用的树形结构,扁平化结构方便渲染,若需要树形结构,可自己写个方法转换
const data =[
{
id:null, // 后端对节点的存储id
nodeId: uuidv4(), // 前端对节点的存储id
level: 0,
preIds: [], // 该节点之间相关的上级节点
nextIds: [], // 该节点之间相关的下级节点
name: `子节点`,
x: 0,
y: 0,
style: {
bgColor: '#FFE4E1',
borderColor: '#a0cfff',
borderWeight: 2,
fontColor: '#a0cfff',
fontWeight: 400,
fontSize: 14,
}
}]
大概思路列成以下:
模板布局以及部分意图样式特殊部分解释逻辑部分函数解释
以上的解释在文章中不会每个地方都解释,着重解释重要部分,其余查看代码。
模板部分
元素布局如下,需要注意的是画布部分

画布模板
以下代码就是画布所包含的模块:
svg画布:箭头标记,连接线,节点上的连接点,框选矩形。
节点:样式,内容,事件。
右键菜单项:节点,画布,连接线右键菜单项
双击节点内容:节点内容编辑器。
注意:
svgRef,canvasContainerRef在逻辑部分需要一起判断:svgRef范围只有可见区域,canvasContainerRef包含了滚动区域。若嫌麻烦可以将canvasContainerRef的内容宽高赋予svgRef。对于节点或画布的操作注意事件的冒泡
画布模板部分就这样了,其中的样式或者元素的显示与否根据自身判断。
<div class="canvas-container" ref="canvasContainerRef" @contextmenu="showContextMenu('add', '', $event)" @mousedown="handleCanvasMouseDown">
<svg ref="svgRef" @click="handleSvgClick" class="svg-box">
<!-- 定义箭头标记 -->
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#666" />
</marker>
<marker id="arrowhead-selected" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#666" />
</marker>
</defs>
<!-- 连接线 -->
<path v-for="(connection, index) in visibleConnections" :key="index" :d="connection.path" class="connection"
:class="{ selected: selectedConnection === connection }" :stroke="connection.selected ? '#a0cfff' : '#666'"
:stroke-width="2" fill="none"
:marker-end="connection.selected ? 'url(#arrowhead-selected)' : 'url(#arrowhead)'"
@click="selectConnection(connection)" @contextmenu.stop="showContextMenu('path', connection, $event)" />
<!-- 节点连接点 -->
<circle v-for="controlPoint in nodeControlPoints" :key="controlPoint.pointId" :cx="controlPoint.x"
:cy="controlPoint.y" r="7" fill="#a0cfff" class="control-point"
@mousedown="startConnectionFromPoint(controlPoint)" @click.stop />
<!-- 框选区域矩形 -->
<rect v-if="selectionBox.visible" :x="selectionBox.startX" :y="selectionBox.startY"
:width="selectionBox.width" :height="selectionBox.height" class="selection-box"
fill="rgba(64, 158, 255, 0.1)" stroke="#409eff" stroke-width="1" stroke-dasharray="5,5" />
</svg>
<!-- 节点 -->
<div v-for="node in visibleNodes" :key="node.nodeId"
:class="['node', dragActiveNode?.nodeId === node.nodeId ? 'dragActiveNode' : '']"
:ref="(el) => setNodeRef(el, node.nodeId)" :style="{
left: node.x + 'px',
top: node.y + 'px',
minWidth: nodeMinWidth + 'px',
minHeight: nodeMinHeight + 'px',
background: node.style.bgColor,
borderStyle: 'solid',
borderWidth: node.style.borderWeight + 'px',
borderColor: node.style.borderColor,
boxShadow: `0 0 3px 1px ${selectedNode?.nodeId === node.nodeId || selectionBox.selectedNodes.map(item => item.nodeId).includes(node.nodeId) ? node.style.borderColor : '#ffffff00'}`,
}" :data-node-id="node.nodeId" @contextmenu.stop="showContextMenu('node', node, $event)"
@mousedown="handleNodeMouseDown(node, $event)" @dblclick="handleNodeDoubleClick(node, $event)">
<div class="node-main" @mousedown="handleNodeMouseDown(node, $event)" :style="{
fontSize: node.style.fontSize + 'px',
fontWeight: node.style.fontWeight,
color: node.style.fontColor
}">
<div class="node-text">{{ node.name }}</div>
</div>
<div v-if="isNodeCollapsed(node) && getCollapsedNodeCount(node, treeData) > 0" class="collapsed-count" :style="{
color: node.style.fontColor,
borderColor: node.style.borderColor,
}">
{{ getCollapsedNodeCount(node, treeData) }}
</div>
</div>
<!-- 右键菜单 -->
<div v-if="contextMenu.visible" class="context-menu"
:style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }">
<div v-if="contextMenu.menuType === 'node' && isEditMode">
<div class="context-menu-item" @click="addNodeFromContextMenu">添加节点</div>
<div class="context-menu-item" @click="editNodeFromContextMenu">修改内容</div>
<div class="context-menu-item" @click="removeNodeFromContextMenu"
:class="{ disabled: selectedNode && selectedNode.level === 0 }">删除节点</div>
</div>
<div v-else-if="contextMenu.menuType === 'path' && isEditMode">
<div class="context-menu-item" @click="removeSelectedConnection">删除连线</div>
<div class="context-menu-item" @click=" contextMenu.otherMenu = !contextMenu.otherMenu">
修改方向
<div class="context-menu-item-direction" v-if="contextMenu.otherMenu">
<div class="context-menu-item" @click="toggleConnectionDirection(DirectionEnum.HOR)"
:class="{ disabled: selectedConnection.pathDirection === DirectionEnum.HOR }">水平方向</div>
<div class="context-menu-item" @click="toggleConnectionDirection(DirectionEnum.VER)"
:class="{ disabled: selectedConnection.pathDirection === DirectionEnum.VER }">垂直方向</div>
</div>
</div>
</div>
<div v-else-if="contextMenu.menuType === 'add' && isEditMode">
<div class="context-menu-item" @click="addNodeFromContextMenu"
:class="{ disabled: treeData.length === 0 }">添加节点</div>
</div>
<div v-if="contextMenu.menuType === 'node'" class="context-menu-item"
@click="toggleNodeCollapseFromContextMenu">
{{ isNodeCollapsed(selectedNode) ? '展开节点' : "折叠节点" }}
</div>
</div>
<!-- 节点编辑器 -->
<div v-if="contextEditor.visible" class="context-editor"
:style="{ left: contextEditor.x + 'px', top: contextEditor.y + 'px' }">
<el-input ref="textInput" v-model="contextEditor.editingNodeContent" type="textarea" resize="none"
:autosize="{ minRows: 2, maxRows: 4 }" placeholder="输入节点内容(支持HTML)" />
<div>
<el-button @click="saveNodeContent" type="primary">保存</el-button>
<el-button @click="hideEditor">取消</el-button>
</div>
</div>
</div>
样式部分
最主要就是 overflow: visible; 由于svg-box元素的宽高的不是内容的宽高,当svg的元素超出svg-box元素后就会隐藏,此时我们直接让他显示出来。
.svg-box {
box-sizing: border-box;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: visible;
background-color: #ffffff2a;
}
逻辑部分
节点的展开与折叠 / 节点数据的计算:
该部分主要涉及模板的渲染:treeData是包含所有的完整数据,折叠状态需要处理的是各个节点之间的父子关系来计算出可见的节点与连接线。
// 节点折叠状态管理
const collapsedNodes = ref(new Set()); // 存储被折叠的节点ID
// 可见节点计算(考虑折叠状态)
const visibleNodes = computed(() => {
return treeData.value.filter(node => {
// 检查节点的所有祖先节点是否都被展开
return isNodeVisible(node);
});
});
// 可见连接线计算(仅显示连接可见节点的线)
const visibleConnections = computed(() => {
return connections.value.filter(conn => {
const parent = treeData.value.find(n => n.nodeId === conn.parentId);
const child = treeData.value.find(n => n.nodeId === conn.childId);
return parent && child && isNodeVisible(parent) && isNodeVisible(child);
});
});
// 检查节点是否可见(所有祖先节点都未被折叠)
const isNodeVisible = (node) => {
// 根节点总是可见
if (!node.preIds || node.preIds.length === 0) {
return true;
}
// 检查所有祖先节点是否被折叠
for (const parentId of node.preIds) {
if (collapsedNodes.value.has(parentId)) {
return false;
}
// 递归检查祖先节点的可见性
const parent = treeData.value.find(n => n.nodeId === parentId);
if (parent && !isNodeVisible(parent)) {
return false;
}
}
return true;
};
// 检查节点是否被折叠
const isNodeCollapsed = (node) => {
return node && collapsedNodes.value.has(node.nodeId);
};
const toggleNodeCollapseFromContextMenu = () => {
if (!selectedNode.value) return
const nodeId = selectedNode.value.nodeId;
if (collapsedNodes.value.has(nodeId)) {
collapsedNodes.value.delete(nodeId);
} else {
collapsedNodes.value.add(nodeId);
}
hideAllMenu();
};
连接线数据
该部分主要涉及连接线的算法,以及连接线右键菜单中连接线方向。

连接线的数据算法主要依据两个节点之间水平差距和垂直差距来计算,如果单靠这两个点的话,又会出现一个问题:
当希望所有子节点都位于父节点的右侧,这时靠下的节点会由于因为垂直差距大于水平差距而导致连接线是向下,而不是先向右。
这时通过一个变量开控制连接线的优先走势,就不会出现这个问题。

// 连接线数据
const connections = computed(() => {
const conns = [];
treeData.value.forEach(childNode => {
if (!childNode.preIds || childNode.preIds.length === 0) return
childNode.preIds.forEach(parentId => {
const parentNode = treeData.value.find(node => node.nodeId === parentId);
if (!parentNode) return
const { nodeActualWidth: parentWidth, nodeActualHeight: parentHeight } = getActualData(parentNode);
const parentCenterX = parentNode.x + parentWidth / 2;
const parentCenterY = parentNode.y + parentHeight / 2;
const { nodeActualWidth: childWidth, nodeActualHeight: childHeight } = getActualData(childNode);
const childCenterX = childNode.x + childWidth / 2;
const childCenterY = childNode.y + childHeight / 2;
// 计算父节点和子节点的相对位置
const deltaX = childCenterX - parentCenterX;
const deltaY = childCenterY - parentCenterY;
// 特殊处理:当节点几乎在同一水平线上时,使用水平方向连接
const isAlmostHorizontal = Math.abs(deltaY) < nodeMinHeight; // 阈值
// 检查是否有用户自定义的方向偏好
const directionKey = `${parentNode.nodeId}-${childNode.nodeId}-direction`;
const userDirection = connectionPositions.value.get(directionKey);
// 获取连接点信息
const startPoint = getConnectionPoint(parentNode, deltaX, deltaY, true, userDirection);
const endPoint = getConnectionPoint(childNode, deltaX, deltaY, false, userDirection);
// 获取自定义中间点位置或计算默认位置
const midX = connectionPositions.value.get(`${parentNode.nodeId}-${childNode.nodeId}-midX`) ||
(startPoint.isHorizontalFirst || isAlmostHorizontal ? (startPoint.x + endPoint.x) / 2 : startPoint.x);
const midY = connectionPositions.value.get(`${parentNode.nodeId}-${childNode.nodeId}-midY`) ||
(startPoint.isHorizontalFirst || isAlmostHorizontal ? startPoint.y : (startPoint.y + endPoint.y) / 2);
let path;
let pathDirection;
// 水平方向优先,先水平再垂直
if (startPoint.isHorizontalFirst || isAlmostHorizontal) {
path = `M ${startPoint.x} ${startPoint.y} L ${midX} ${startPoint.y} L ${midX} ${endPoint.y} L ${endPoint.x} ${endPoint.y}`;
pathDirection = DirectionEnum.HOR
}
// 垂直方向优先,先垂直再水平
else {
path = `M ${startPoint.x} ${startPoint.y} L ${startPoint.x} ${midY} L ${endPoint.x} ${midY} L ${endPoint.x} ${endPoint.y}`;
pathDirection = DirectionEnum.VER
}
// 添加连接线
conns.push({
path: path,
pathDirection: pathDirection,
manual: false,
parentId: parentNode.nodeId,
childId: childNode.nodeId,
type: 'branch',
pathId: `${parentNode.nodeId}-${childNode.nodeId}-branch`,
startPoint: startPoint,
endPoint: endPoint
});
});
});
return conns;
});
/**
* 根据方向确定连接点位置
* @param node 节点
* @param deltaX 相对于目标节点的X差值
* @param deltaY 相对于目标节点的Y差值
* @param isStartPoint 是否为起点(父节点)
* @param userDirection 用户自定义方向
* @returns 连接点坐标和方向信息
*/
const getConnectionPoint = (node, deltaX, deltaY, isStartPoint, userDirection) => {
const { nodeActualWidth, nodeActualHeight } = getActualData(node);
// 判断主要方向
const isHorizontal = Math.abs(deltaX) >= Math.abs(deltaY);
const isRight = deltaX > 0;
const isDown = deltaY > 0;
// 特殊处理:当节点几乎在同一水平线上时,使用水平方向连接
const isAlmostHorizontal = Math.abs(deltaY) < nodeMinHeight; // 阈值
// 根据节点类型(起点/终点)和方向确定连接点
let x, y;
if (isStartPoint) {
// 起点(父节点)的连接点
if (userDirection === DirectionEnum.HOR || isAlmostHorizontal) {
// 水平方向优先,起点连接右边或左边
x = isRight ? node.x + nodeActualWidth : node.x;
y = node.y + nodeActualHeight / 2;
} else {
// 垂直方向优先,起点连接下边或上边
x = node.x + nodeActualWidth / 2;
y = isDown ? node.y + nodeActualHeight : node.y;
}
} else {
// 终点(子节点)的连接点
if (userDirection === DirectionEnum.HOR || isAlmostHorizontal) {
// 水平方向优先,终点连接左边或右边
x = isRight ? node.x : node.x + nodeActualWidth;
y = node.y + nodeActualHeight / 2;
} else {
// 垂直方向优先,终点连接上边或下边
x = node.x + nodeActualWidth / 2;
y = isDown ? node.y : node.y + nodeActualHeight;
}
}
return {
x,
y,
isHorizontalFirst: userDirection === DirectionEnum.HOR || isAlmostHorizontal,
isRight,
isDown,
isHorizontal
};
}
框选功能
该部分主要注意的就是画布,上面有解释到svgRef,canvasContainerRef在逻辑部分需要一起判断,就是为了超出svgRef的部分节点也能框选。
const handleCanvasSelectFrame = (event) => {
// 只有在框选模式下并且点击的是画布(不是节点)时才开始框选
if (event.target !== svgRef.value && event.target !== canvasContainerRef.value) return
event.preventDefault();
const { x, y } = getMousePositionInCanvas(event);
selectionBox.value = {
visible: true,
selectedNodes: [],
startX: x,
startY: y,
endX: x,
endY: y,
width: 0,
height: 0
};
// 添加鼠标移动和松开事件监听器
document.addEventListener('mousemove', handleCanvasMouseMove);
document.addEventListener('mouseup', handleCanvasMouseUp);
};
其余概括
其余的增删改,拖拽,挂载等功能,我认为没必要详细解释,代码中有注释,自己看的话应该是能看懂的主要注意的就是各个节点的上下级关系,一定要修改。以上代码不完全,完成代码我已上传仓库。
欢迎各位提出想法。第一次写”xmind”,定有不完美之处,后续想把它优化发布为自己的npm组件,有同伴一起吗?
仓库
mindMap: 手搓mindMap,基础用法
https://gitee.com/saro-d/mind-map
请注意,本篇内容为原创,有其他意图请告知
https://gitee.com/saro-d/mind-map


