最近有个需求,需要用x6实现一个思维导图,交互类似于processOn,并封装成组件(阅读此文章前,请了解一些x6的知识)。 技术站点:AntV x6,vue,javascript, css
注意:此文章不会提供全部源码,只是提供解决思路跟部分关键代码,很多坑,里面有提到一部分,其他的大家实践中可以自己探索一下。然后代码跟样式都是最初版本,没有经过优化,大家看着去优化优化
效果图:
非编辑模式(两种连接器)
编辑模式
组件具体需求
- 1:传入数据渲染一个树形结构的数据
- 2:传入数据支持修改节点文字颜色,文字大小等属性(简单暂不说)
- 3:支持节点文字编辑 (双击编辑节点)
- 4:支持几种子节点的布局算法(TB,BT,LR,RL)
- 5:支持几种连接器类型(path曲线,连接两个节点)
- 7:支持增加子节点,删除节点(每个节点有个+,点击可以新增节点,删除可以按backspace或者右击出现操作面板,选择删除操作)
- 8:支持调整子节点的顺序(节点拖拽到目标节点可跟目标节点相互交换数据)
- 9:支持节点的样式定义
- 10:支持画布拖拽、缩放
- 11:编辑模式跟非编辑模式(非编辑模式,仅支持查看,新增删除,编辑节点等操作都不支持)
- 12:支持节点收缩
- 以上具体交互请参考processOn
需求一:如何渲染一个树形结构?
一棵树由传入组件的数据,节点(父节点:type = 'parent',子节点:type="child",以及另一种特殊节点,双击编辑文案时,出现的输入框),边,以及连接器组成(数据模式已修改成只需要配置父节点类型,子节点不需要设置type字段)
1.数据
<template>
<div style="height: 300px; width: 100%">
<Mindmap :mindData="mindData" lineStroke="#A2B1C3" ></Mindmap>
</div>
</template>
<script>
export default { name: "MindmapDemo",
data() {
return
{
mindData: {
id: "1",
type: "parent",
label: "中心主题",
children: [
{
id: "1-1", label: "分支主题1",
children: [
{ id: "1-1-1", label: "子主题1" },
{ id: "1-1-2", label: "子主题2" },
],
},
{ id: "1-2", label: "分支主题2"},
], },
}
}
};
</script>
2. 节点创建
X6是基于 SVG
的渲染引擎,内置节点有Rect
,Circle
,HTML
等各种类型,但是因为我的需求对样式要求比较高,所以我选择的是html节点进行注册
// 节点注册
// nodeName: 节点名称,对应节点类型
export const creatHtmlDom = (nodeName) => {
if (nodeName in Shape.HTML.shapeMaps) {
return;
}
Shape.HTML.register({
shape: nodeName,
// propsData: 组件传入的所有属性,相当于this.$props
// data: mindData,传入组件的节点数据
effect: ["data", "propsData"],
html: (cell) => {
const { nodeData, propsData } = cell.getData();
if (nodeName == "edit") {
// 创建编辑节点输入框,设置样式
return editDom(nodeData, propsData);
} else if (nodeName == "child") {
// 子节点样式
return domChildContent(nodeData, propsData);
} else if (nodeName == "parent") {
// 父节点样式
return domParentContent(nodeData, propsData);
}
},
});
};
mounted中使用(节点名称可以加组件前缀优化,暂时先这样)
creatHtmlDom("parent");
creatHtmlDom("child");
creatHtmlDom("edit");
3. 边
注意:边要加key,key唯一,不然当你更改子组件布局方向时(比如横向布局改成纵向布局),由于你首次注册的是横向的布局,当你更改成纵向时,没有对应的连接器,会导致边的走向出现问题!!
export const registerEdge = (params, key) => {
const { lineStroke, strokeWidth ,connector} = params;
if (connector == "orgEdge") {
Graph.registerEdge(
"mindmap-org-edge",
{
zIndex: -1,
attrs: {
line: {
sourceMarker: null,
targetMarker: null,
stroke: lineStroke,
strokeWidth: strokeWidth,
},
},
},
true
);
} else {
Graph.registerEdge(
"mindmap-edge-" + key,
{
inherit: "edge",
connector: {
name: "mindmap-" + key,
},
attrs: {
line: {
targetMarker: "",
stroke: lineStroke,
strokeWidth: strokeWidth,
},
},
zIndex: 0,
},
true
);
}
};
mounted中使用:
registerEdge(this.$props, this.key);
4. 连接器
x6中其实有现成的连接器,但是因为需求需要的线条跟提供的不太一致,所以只能自己写,哭 可以自己去学习一下path
我定义的连接器类型:compact
:紧凑连接器(默认),loose
:疏松连接器,orgEdge
:组织结构连接器(这个用的x6现成的,所以不需要自定义)
export const registerConnector = (params, key) => {
const { direction, connector } = params;
// 连接器
Graph.registerConnector(
"mindmap-" + key,
(sourcePoint, targetPoint, routerPoints, options) => {
const params = {
sourcePoint: sourcePoint,
targetPoint: targetPoint,
routerPoints: routerPoints,
options: options,
direction,
};
if (connector == "loose") {
return looseConnector(params);
} else {
return compactConnector(params);
}
},
true
);
};
export const looseConnector = (params) => {
const { sourcePoint, targetPoint, options, direction } = params;
const midX = sourcePoint.x;
const midY = sourcePoint.y;
let ctrX = (targetPoint.x - midX) / 5 + midX;
let ctrY = targetPoint.y;
if (direction == "TB" || direction == "BT") {
ctrX = targetPoint.x;
ctrY = (targetPoint.y - midY) / 5 + midY;
}
const pathData = `
M ${sourcePoint.x} ${sourcePoint.y}
L ${midX} ${midY}
Q ${ctrX} ${ctrY} ${targetPoint.x} ${targetPoint.y}
`;
return options.raw ? Path.parse(pathData) : pathData;
};
export const compactConnector = (params) => {
const { sourcePoint, targetPoint, direction } = params;
let hgap = Math.abs(targetPoint.x - sourcePoint.x);
const path = new Path();
path.appendSegment(Path.createSegment("M", sourcePoint.x, sourcePoint.y));
path.appendSegment(Path.createSegment("L", sourcePoint.x, sourcePoint.y));
let x1 =
sourcePoint.x < targetPoint.x
? sourcePoint.x + hgap / 2
: sourcePoint.x - hgap / 2;
let y1 = sourcePoint.y;
let x2 =
sourcePoint.x < targetPoint.x
? targetPoint.x - hgap / 2
: targetPoint.x + hgap / 2;
let y2 = targetPoint.y;
if (direction == "TB" || direction == "BT") {
hgap = Math.abs(targetPoint.y - sourcePoint.y);
x1 = sourcePoint.x;
y1 =
sourcePoint.y < targetPoint.y
? sourcePoint.y + hgap / 2
: sourcePoint.y - hgap / 2;
x2 = targetPoint.x;
y2 =
sourcePoint.y < targetPoint.y
? targetPoint.y - hgap / 2
: targetPoint.y + hgap / 2;
}
// 水平三阶贝塞尔曲线
path.appendSegment(
Path.createSegment("C", x1, y1, x2, y2, targetPoint.x, targetPoint.y)
);
// path.appendSegment(Path.createSegment("L", targetPoint.x + 2, targetPoint.y));
return path.serialize();
};
5. 如何渲染组件接收的数据?
renderChart(params = {}) {
// isFirst:首次渲染
const {isFirst = false } = params;
if (!this.graph) return;
let result;
const _this = this;
result = Hierarchy.compactBox(this.mindData, {
// 布局方向(LR左到右,RL右到左,TB上到下,BT下到上)
direction: this.direction,
getHeight: (d) => {
const { newHeight } = displayTextSize(false, d, this.$props);
return newHeight;
},
getWidth: (d) => {
// 计算节点的宽度
const { newWidth } = displayTextSize(false, d, _this.$props);
return newWidth;
},
getChildren: (d) => {
// 此部分是收缩节点时使用
const hasCollapsed = !!d.collapsed;
return hasCollapsed ? null : d.children;
},
getHGap: () => {
const flag = this.direction == "LR" || this.direction == "RL";
return flag ? 40 : 20;
},
getVGap: () => {
const flag = this.direction == "LR" || this.direction == "RL";
return flag ? 10 : 30;
},
getSide: () => {
return "right";
},
});
const cells = [];
const traverse = (hierarchyItem) => {
if (hierarchyItem) {
const { data, children } = hierarchyItem;
if (data && Object.keys(data).length) {
let obj = createNodeData(data, this.$props, this.strokeWidth);
obj = {
...obj,
x: hierarchyItem.x,
y: hierarchyItem.y,
};
cells.push(this.graph.createNode(obj));
if (children) {
children.forEach((item) => {
const { id } = item;
setEdges(
this.graph,
cells,
hierarchyItem.id,
id,
this.direction,
this.key,
this.connector
);
traverse(item);
});
}
}
}
};
traverse(result);
this.graph.resetCells(cells);
if (this.connector == "orgEdge") {
creatOrgEdge(this.graph, this.direction);
}
// 重新定位选中元素
if (cells.length && this.refresh) {
// 标记位置,当选中一个节点时,节点样式数据会进行变动,触发renderChart刷新,
// 所以此时需要将上一次选择的节点进行重新定位
this.resetChoosedNode(isFirst);
}
this.refresh = true;
},
6.计算节点宽高
// 渲染时计算节点宽高(封装的公共方法)
export const displayTextSize = (newType, data, props) => {
const span = document.createElement("span");
let height = 0;
let width = 0;
if (newType !== "parent") {
span.className = "mindmap__text-child";
}
span.style.visibility = "hidden";
span.style.font = `${data.labelSize || props.labelSize}px Roboto`;
span.style.display = "inline-block";
span.innerText = data.label;
document.body.appendChild(span);
height = span.getBoundingClientRect().height;
width = span.getBoundingClientRect().width;
if (newType == "parent") {
const borderWidth = data.borderWidth || props.strokeWidth;
height = height + borderWidth * 2 + 20;
}
let iconWidth = 0;
if (props.isEdit) {
const iconSize = data.iconSize || props.iconSize;
iconWidth = Number(iconSize.split("px")[0]);
}
const _width = width + iconWidth;
// _width + 2 : 节点交换时,会添加边框,超出原本的宽度,导致文字换行,所以这边需要加宽2px
let newWidth = newType == "parent" ? _width + 2 + 20 : _width;
span.remove();
return {
newHeight: height,
newWidth: newWidth,
};
};
需求二:如何处理双击编辑节点功能
思路:监听双击事件,双击到目标节点时,将节点的类型替换成edit(前面edit类型节点已经注册,所以替换newType就行) 请看另一篇文章
需求三:节点拖拽交换位置(重点!!!)
其实X6是支持拖拽节点的,所以我原先的想法是想利用x6的节点拖拽,直接将节点拖到目标节点后,给目标节点添加对应的类名,设置对应的样式,但是这方案被老大否了,要求是要跟processOn一样的交互
仔细观察processOn的交互,鼠标按下选中节点,会产生一个透明度不高的节点,即幽灵节点(图一),并随着鼠标的移动而移动,如果拖动到目标节点的上部,节点上边会产生指示器,拖到下面会产生指示器,拖到目标节点中间,只是边框变色(图二)。
1. 思路
-
- 监听节点mousedown事件,生成一个新节点(幽灵节点),绝对定位,节点插入到当前选中节点之前,拥有共同父节点。
- 2.监听鼠标的mousemove事件,更改虚拟节点的left,top的定位 ,
- 3.寻找目标节点(离当前虚拟节点最近的节点)
- 4.分析拖动到目标节点的位置dropType,前面(before),后面(after),自己(inner),这部分需要注意横向跟纵向布局
- 5.针对不同的dropType,对目标节点添加不同的兄弟节点,兄弟节点的样式覆盖目标节点,产生一个位置指示器
- 6.拖动完成后,监听mouseup事件,分析如何换数据(交换数据规则:1. 子节点拖至父节点只能作为父节点的子级,不可成为兄弟节点 2. dropType为inner,拖动节点成为目标节点的子级;dropType为before,拖动节点插入到目标节点之前;dropType为after,拖动节点插入到目标节点之后 2.同级且相邻节点交换时,比如左右布局时,后节点拖动到前节点下面,不予处理,相对的,前节点拖动到后节点上面不予处理,因为本来拖动节点就在dropType相对应的位置 )3.当拖动节点为父节点,目标节点为子节点时,不予处理
图一:
图二:
效果图(样式有点丑,哈哈哈):
2. 监听mousedown
draggable-mixin.js
data() {
return {
dragState: {
dragging: false, // 拖动状态
initial: {}, // 初始化数据记录
},
};
},
mounted() {
// 幽灵节点
this.dragState.cloneNode = null;
// 指示器节点
this.dragState.lastActiveName = "";
// 除幽灵节点ghost之外mindmap的节点
this.allNotGhostNodes = null;
//除拖动节点外的节点位置信息
this.allNotGhostNodesConfig = {};
},
beforeDestroy() {
document.removeEventListener("mousemove", this.mousemoveHandler);
document.removeEventListener("mouseup", this.mouseupHandler);
},
// isEdit是否为编辑模式,非编辑模式不予监听
this.isEdit &&
this.graph.on("node:mousedown", (evt) => {
// 注意:鼠标进行拖动之前需要关闭画布平移操作,不然画布平移事件会导致节点移动功能冲突
this.graph.disablePanning();
this.mousedownHandler(evt);
});
mousedownHandler(evt) {
const { node, e } = evt;
const isInput = e.target.classList.contains("edit-input");
const id = node.id;
// 编辑节点文字时,输入框不可拖动
if (id && !isInput) {
// 节点属性赋值
const el_ = e.target;
const type = el_.dataset.type;
if (type == "child") {
// 处理节点拖拽
this.dropType = "";
this.dragState.dragging = true;
const childNodes = this.$el.querySelectorAll(".mindmap__text-child");
// 子节点查找
const target = Array.from(childNodes).find((item) => {
return item.dataset.id == id;
});
if (target) {
const t = target.getBoundingClientRect();
const cloneEl = target.cloneNode(true);
// 生成幽灵节点
cloneEl.classList.add("mindmap__ghost-node"); // 浮动
e.target.parentElement.appendChild(cloneEl); // 加入节点
this.dragState.cloneNode = cloneEl;
const deviationX = e.clientX - (t.x + t.width / 2);
const h =
this.direction == "TB" || this.direction == "BT"
? t.height / 2
: this.heightCenter
? t.height / 2
: t.height;
const deviationY = e.clientY - t.y - h;
// 让鼠标选中一个节点进行拖拽时,光标始终保持在节点中间的偏移量
this.dragState.initial.deviationX = deviationX;
this.dragState.initial.deviationY = deviationY;
// 点击时光标位置
this.dragState.initial.clientX = e.clientX;
this.dragState.initial.clientY = e.clientY;
// 注意,放大缩小时,路径变化
this.zoom = 1 / this.graph.zoom();
const node1 = Array.from(
this.$el.querySelectorAll(".mindmap__wrap-parent")
);
const node2 = Array.from(
this.$el.querySelectorAll(".mindmap__text-child")
).filter((node) => !node.classList.contains("mindmap__ghost-node"));
this.allNotGhostNodes = node1.concat(node2);
// 获取除幽灵节点以外的所有节点的定位信息,用于计算最近节点跟拖动位置判断
this.allNotGhostNodes.forEach((item) => {
this.allNotGhostNodesConfig[item.dataset.id] =
item.getBoundingClientRect();
});
}
document.addEventListener("mousemove", this.mousemoveHandler);
document.addEventListener("mouseup", this.mouseupHandler);
}
}
}
3. 监听mousemove
mousemoveHandler(e) {
let _clientX = e.clientX;
let _clientY = e.clientY;
if (this.dragState.dragging && this.dragState.cloneNode) {
this.dragState.cloneNode.style.visibility = "visible";
// 处理元素的移动:改变 left top 定位
this.dragState.cloneNode.style.left =
(_clientX -
this.dragState.initial.clientX +
this.dragState.initial.deviationX) *
this.zoom +
"px";
this.dragState.cloneNode.style.top =
(_clientY -
this.dragState.initial.clientY +
this.dragState.initial.deviationY) *
this.zoom +
"px";
// 找到离拖动节点最近节点
this.targetNode = findNearestNode(
this.allNotGhostNodesConfig,
this.dragState.cloneNode,
this.allNotGhostNodes
);
if (this.targetNode) {
// 计算拖动类型
const dropType = calculateDropType(
this.dragState.cloneNode,
this.direction,
this.targetNode
);
this.dropType = dropType;
// 判断是否允许生成指示器
const indicatorParams = {
dropType: dropType,
mindData: this.mindData,
dropNodeId: this.dragState.cloneNode.dataset.id,
targetNodeId: this.targetNode.node.dataset.id,
};
// 是否允许生成指示器,原因是由于,比如当前拖动的是父级节点,拖动到它自己的子节点上,这是不允许生成指示器的,不符合拖动条件
const result = allowCreateIndicator(indicatorParams);
if (result) {
if (this.dragState.lastActiveName) {
removeIndicator(this.$el, this.dragState.lastActiveName);
}
// 创建指示器节点
const params = {
dropType: this.dropType,
targetNode: this.targetNode,
direction: this.direction,
heightCenter: this.heightCenter,
};
const activeName = createIndicator(params);
this.dragState.lastActiveName = activeName;
} else {
removeIndicator(this.$el, this.dragState.lastActiveName);
}
}
}
},
4. 监听mouseup
mouseupHandler(e) {
// 开启平移
this.graph.enablePanning();
this.dragState.dragging = false;
document.removeEventListener("mousemove", this.mousemoveHandler);
document.removeEventListener("mouseup", this.mouseupHandler);
if (
this.dragState.cloneNode &&
this.targetNode &&
this.dropType &&
this.dragState.lastActiveName
) {
const cloneNodeId = this.dragState.cloneNode.dataset.id;
// 移动节点跟目标节点相同时,不予处理,并清除生成的虚拟节点
if (
cloneNodeId == this.targetNode.node.dataset.id &&
this.dropType == "inner"
) {
this.clearCloneNodeConfig();
return;
}
const params = {
dropType: this.dropType,
dropNodeId: cloneNodeId,
targetNodeId: this.targetNode.node.dataset.id,
mindData: this.mindData,
};
// 交换节点
exchangeNode(params);
// 设置选中节点
const { node } = findItem(this.mindData, cloneNodeId);
const { node: node2 } = findItem(this.mindData, params.targetNodeId);
// 看目标节点是否是收缩状态
const hasCollapsed = !!node2.collapsed;
if (node2.children) {
// 获取目标节点的所有子节点数量
const count = collapsedNodes(node2.children);
node2.count = count;
}
Vue.set(node2, "collapsed", hasCollapsed);
if (hasCollapsed && this.dropType == "inner") {
// 这个是选中节点进行编辑样式的数据
this.currentData = {};
} else {
const choosedParams = {
node: this.dragState.cloneNode,
el: this.$el,
};
this.refresh = false;
this.setChoosedNode(choosedParams, node);
this.resetEditNodeConfig();
// 更新视图
this.renderChart();
}
}
// 移除cloneNode并清除虚拟节点数据
this.clearCloneNodeConfig();
this.dropType = "";
},
5. 寻找最近节点
export const findNearestNode = (
allNotGhostNodesConfig,
draggingNode,
nodes
) => {
const distances = [];
const draggingNodeConfig = draggingNode.getBoundingClientRect();
let targetNode;
targetNode = nodes.find((node) => {
const nodeConfig = allNotGhostNodesConfig[node.dataset.id];
const x1 = draggingNodeConfig.x + draggingNodeConfig.width / 2;
const x2 = nodeConfig.x + nodeConfig.width / 2;
let y1 = draggingNodeConfig.y + nodeConfig.height / 2;
let y2 = nodeConfig.y + nodeConfig.height / 2;
if (
x1 >= nodeConfig.x &&
x1 <= nodeConfig.x + nodeConfig.width &&
y1 >= nodeConfig.y &&
y1 <= nodeConfig.y + nodeConfig.height
) {
return true;
}
const a = Math.abs(x1 - x2);
const b = Math.abs(y1 - y2);
let c = Math.sqrt(a * a + b * b);
c = Number(c.toFixed(3));
if (node.dataset.id !== draggingNode.dataset.id) {
distances.push({
node: node,
distance: c,
});
}
});
if (targetNode) {
targetNode = {
node: targetNode,
};
} else {
// 找出离拖动节点最近的节点(即目标节点)
targetNode = distances[0];
distances.forEach((item) => {
if (item.distance < targetNode.distance) {
targetNode = item;
}
});
}
return targetNode;
6. 获取droptype
这边需要注意的是,横向跟纵向布局的区别,纵向布局时,拖动节点在目标节点左侧,即为before,在目标节点右侧即为after,其他规则一致
思路就是:用拖动节点跟目标节点的x还有y的位置做判断,得到想要的类型 before,after,inner,有一个值得注意的地方,x越往右数值越大,y越往上数值越小,越往下数据越大 此部分不贴代码了
7. 节点交换
export const exchangeNode = (params) => {
const { dropType, dropNodeId, targetNodeId } = params;
const { _targetNode, targetParent, dropChildren, targetChildren } =
getTargetData(params);
const _dropChildren = cloneDeep(dropChildren);
// const _targetChildren = targetChildren && cloneDeep(targetChildren);
// 找出各自节点对应在父节点子级的位置,并进行交换数据
const { dropIndex, targetIndex } = findTargetIndex(
dropChildren,
targetChildren,
dropNodeId,
targetNodeId
);
if (dropType == "inner") {
if (_targetNode.node.type == "parent") {
_targetNode.node.children.push(_dropChildren[dropIndex]);
} else {
Vue.set(
targetChildren[targetIndex],
"children",
targetChildren[targetIndex].children || []
);
targetChildren[targetIndex].children.push(_dropChildren[dropIndex]);
}
dropChildren.splice(dropIndex, 1);
} else if (dropType == "before" || dropType == "after") {
// targetChildren中是否包含当前拖动节点
const index = targetChildren.findIndex((item) => item.id === dropNodeId);
if (index > -1) {
if (dropType == "before") {
targetParent.children.splice(index, 1);
}
targetParent.children.splice(
targetIndex + (dropType == "after" ? 1 : 0),
0,
_dropChildren[dropIndex]
);
if (dropType == "after") {
targetParent.children.splice(index, 1);
}
} else {
// 不包含时,在同级添加节点
targetParent.children.splice(
targetIndex + (dropType == "after" ? 1 : 0),
0,
_dropChildren[dropIndex]
);
dropChildren.splice(dropIndex, 1);
}
}
};
这地方不知道怎么插入视频,就不放了,只有上面的截图
需求四:节点样式动态处理
效果图:
需求五:支持节点收缩
需求六:组件封装
组件支持画布拖动,缩放,节点样式定义,画布背景,布局方向等参数支持,这部分需要自己去考虑哪些是否需要,我列了一点
最后,感谢大家阅读我的小文章,请帮我点亮一下我的小心心吧!!!