一、前言
进来大厂有一段时间了,emmm...确实学到很多,现在自己还能慢慢研究别人的一些技术方案,然后融入自己的一些想法,故创造了一个流程图设计的demo。
笔者也是找了很多线上的方案,但是现在的方案(如jsplumb,g6)看文档比较麻烦,而且配置非常多,有时候还不满足需求(比如需要对齐辅助线和吸附的功能)。想用一些高级功能还要付费。
所以笔者希望能够开源一个demo,供大家参考参考。由于是一套方案,所以笔者开始只是以完成功能为主,ui样式优化将会非常弱化,以后再进行维护。
好了,废话不多说,我们直接看一下效果吧~~~
github地址:https://github.com/zhiyuan3458/lzy-flow-chart
好吧,其实写文档挺累的,如果大家觉得有用的话,可以看完这篇文章,送一些些小费给笔者~~~😊
二、认清一下架构
在讲解细节之前,笔者希望大家理解react的数据驱动视图的说法。笔者一向带着这种思想去写代码的,就相当于把每一个组件抽象成js的数组或者对象进行开发,这样会更符合react的思想。希望大家可以体会一下这个思想和带着这个思想看下去。
这里讲一下笔者开发的这个流程图demo的架构,一个left-panel组件,它相当于是一个工具栏了,可以通过拖动工具栏的一些流程图小组件到右边的编辑器当中。
右边的是一个流程图编辑器(right-panel),里面包含了flow-line组件(两节点的连接线)和node组件(节点组件)
三、left-panel组件
左边工具栏其实核心功能是从左边拖拽一个小组件出来到右边编辑器当中。我们来看一下下图:
笔者把这个拖拽的方法写在了onMouseDown函数当中,我们一起来看下里面的内容:
/* 开始拖拽时 */
const onMouseDown = (e, node) => {
e.preventDefault();
/* setIsExpand是拖拽的时候把左边工具栏收起,不是关键代码 */
setIsExpand(false);
// let flag = false;
// createDrag(e);
/*
获取到左侧栏距离top值,它是相对于父元素的top值(这个值用来判断用户是否拖拽到了编辑器外部去
了) */
const leftPanelTop = leftPanel.current.offsetTop;
// const leftPanelWidth = leftPanel.current.offsetWidth;
/* 获取到鼠标开始点击的时候,小组件的左上角的x,y坐标 */
/* 因为用户有可能是拖动小组件的任何一个部位,所以我们要减去它的offsetLeft */
const tempX = e.clientX - e.target.offsetLeft;
const tempY = e.clientY - e.target.offsetTop;
/* 获取拖拽的小组件的宽高,用来计算小组件到时位置的bottom和right值 */
const width = e.target.offsetWidth;
const height = e.target.offsetHeight;
/* 这个函数是用来判断小组件是否拖出了编辑器的边界外的 */
function isInRight (e) {
const left = e.clientX - tempX;
const top = e.clientY - tempY - leftPanelTop;
return left > 0 && top > 0;
}
/* 来到这里就是当组件在拖动过程中触发了 */
const move = e => {
const curX = e.clientX;
const curY = e.clientY;
/* 通过减去tempX和tempY得出x,y值 */
const x = curX -tempX;
const y = curY - tempY;
// dragDOM.style.left = `${ x }px`;
// dragDOM.style.top = `${ y }px`;
// dragDOM.style.borderColor = isInRight(e) ? 'green' : 'red';
const _node = { ...node, id: DRAG_DOM_ID, x, y, r: x + width, b: y + height, width, height };
/* 总结拖拽出来的_node信息然后扔给父组件进行处理 */
props.moveFromLeft(_node);
};
/* 拖拽完后,释放鼠标触发 */
const mouseup = e => {
e.preventDefault();
document.removeEventListener('mousemove', move);
document.removeEventListener('mouseup', mouseup);
// if (dragDOM && !flag) {
// document.body.removeChild(dragDOM);
// dragDOM = null;
// }
/* 继续判断一下是否拖拽出了编辑器外了 */
if (!isInRight(e)) {
return false;
}
/* 计算鼠标释放后,此时小组件在右侧编辑器的top(y),left(x),bottom,right值 */
const x = e.clientX - tempX;
const y = e.clientY - tempY - leftPanelTop;
const r = x + width;
const b = y + height;
const _node = { ...node, id: getUUID(), x, y, r, b, width, height };
/* 总结出释放后小组件的信息(_node),扔给父组件处理 */
props.dropNode(_node);
};
/* 当鼠标点击左侧工具栏的小组件就会从这里开始执行监听 */
setTimeout(() => {
document.removeEventListener('mousemove', move);
document.removeEventListener('mouseup', mouseup);
document.addEventListener('mousemove', move);
document.addEventListener('mouseup', mouseup);
}, 20);
};
附:
ok,接下来当鼠标拖动完释放后,如果成功,将会把拖动出来的一个节点呈现在右侧编辑器当中,这一部分的逻辑就会交给父组件去处理了(父组件中有个addNode函数是专门处理从左侧拖动节点到右侧编辑器之后的逻辑)
我们可以看一下这个代码:
/* 从左边栏拖拽出一个节点进行添加 */
const addNode = (node) => {
// const guideLines = setGuideLine(node, latestNodes.current, dragDOM);
/* 拖动出来的新节点,合并到原来的节点数组中 */
setNodes(nodes => {
const dragVNode = nodes.find(item => item.id === DRAG_DOM_ID);
if (dragVNode) {
const { x, y, b, r } = dragVNode;
node = { ...node, x, y, b, r };
}
/*
DRAG_DOM_ID是一个特定的id,是拖拽的时候显示出来的一个阴影,图1-3会有加以说明,
因为已经拖拽完了,这个阴影就要消失掉(所以就要filter过滤掉它)
*/
return [...nodes, node].filter(item => item.id !== DRAG_DOM_ID);
});
/* 清空对齐辅助线 */
setGuideLines([]);
};
附:
好了,讲完拖动之后的代码处理逻辑之后,我们继续说一下,拖动的时候的处理逻辑吧(如何显示对齐辅助线和吸附对齐等),我们接着看看下面的代码:
/* 从左边栏拉出一个box(鼠标还没释放),一直拖动中时就一直触发这个函数 */
const moveFromLeft = (node) => {
// const guideLines = setGuideLine(node, latestNodes.current, dragDOM);
// setGuideLines(guideLines);
/* 这个是用来记录最新的节点组的信息的,如果在这里不了解的话可以看看react的hook用法 */
const nodes = latestNodes.current;
/* 拼接出拖拽中的虚拟阴影 */
const dragVNode = { ...node, id: DRAG_DOM_ID, style: DRAG_DOM_STYLE };
/* 如果已经存在这个阴影,就去改变这个阴影的位置信息即可 */
if (nodes.find(item => item.id === dragVNode.id)) {
setNodes(nodes => nodes.map(item => item.id === dragVNode.id ? dragVNode : item));
} else {
/* 如果没有该阴影,则往节点组中加入该阴影信息 */
setNodes(nodes => [...nodes, dragVNode]);
}
/*
setGuideLine这个方法放在了src/pages/flow-chart/utils/index.js文件中,
这个函数非常关键,是用来计算得出显示的对齐辅助线和最新的拖拽中的节点位置,以及吸附功能
等下我们可以对这个函数进行展开讲解
*/
const { newNodes, guideLines } = setGuideLine(dragVNode, latestNodes.current);
/* 设置一下最新的节点组 */
setNodes(newNodes);
/* 设置一下拖动中出现的对齐辅助线 */
setGuideLines(guideLines);
};
设置辅助线,实现吸附对齐和返回新的辅助线和节点组的函数:
/* 用来过滤掉一些坐标不对齐的辅助线 */
function fliterRepeatLine (lines, moveNode, { x, y }) {
let _lines = lines.map(node => {
if (node.fromPos) {
switch (node.dire) {
case 'll': { return { ...node, toPos: { x, y: y + moveNode.height } }; break; }
case 'tt': { return { ...node, toPos: { x: x + moveNode.width, y } }; break; }
case 'bb': { return { ...node, toPos: { x: x + moveNode.width, y: y + moveNode.height } }; break; }
case 'rr': { return { ...node, toPos: { x: x + moveNode.width, y: y + moveNode.height } }; break; }
case 'lr': { return { ...node, toPos: { x, y: y + moveNode.height } }; break; }
case 'rl': { return { ...node, toPos: { x: x + moveNode.width, y: y + moveNode.height } }; break; }
case 'tb': { return { ...node, toPos: { x: x + moveNode.width, y } }; break; }
case 'bt': { return { ...node, toPos: { x: x + moveNode.width, y: y + moveNode.height } }; break; }
}
} else {
switch (node.dire) {
case 'll': { return { ...node, fromPos: { x, y } }; break; }
case 'tt': { return { ...node, fromPos: { x, y } }; break; }
case 'bb': { return { ...node, fromPos: { x, y: y + moveNode.height } }; break; }
case 'rr': { return { ...node, fromPos: { x: x + moveNode.width, y: y } }; break; }
case 'lr': { return { ...node, fromPos: { x, y } }; break; }
case 'rl': { return { ...node, fromPos: { x: x + moveNode.width, y } }; break; }
case 'tb': { return { ...node, fromPos: { x, y } }; break; }
case 'bt': { return { ...node, fromPos: { x, y: y + moveNode.height } }; break; }
}
}
});
_lines = _lines.filter(line => line.toPos.x === line.fromPos.x
|| line.toPos.y === line.fromPos.y);
return _lines;
}
/* 设置辅助线,吸附功能,返回新的节点组和对齐辅助线 */
/* moveNode是正在移动中的阴影,nodes是节点组 */
export function setGuideLine (moveNode = {}, nodes = []) {
if (Array.isArray(nodes) && nodes.length <= 0) return [];
let guideLines = [];
let curMoveNodeX = null;
let curMoveNodeY = null;
/* 获取当前移动阴影的left(x),top(y),bottom,right的位置信息 */
const {
x: moveNodeX, y: moveNodeY,
r: moveNodeR, b: moveNodeB
} = moveNode;
/* 从节点组中过滤到阴影这个节点,因为不需要跟它进行对齐 */
const _node = nodes.filter(node => node.id !== moveNode.id);
/*
遍历每个节点组中的节点,如果当前移动的阴影和节点组中的某个节点A的某个位置
(left,top,right,bottom)相差5px,
就直接把当前移动的阴影的某个位置设置为节点A的位置
*/
_node.forEach(item => {
if (Math.abs(moveNodeX - item.x) <= 5) {
curMoveNodeX = item.x;
const itemY = item.y;
if (moveNodeY > itemY) {
const fromPos = { x: item.x, y: item.y };
guideLines.push({ id: getUUID(), fromPos, dire: 'll' });
} else {
const toPos = { x: item.x, y: item.b };
guideLines.push({ id: getUUID(), toPos, dire: 'll' });
}
}
if (Math.abs(moveNodeY - item.y) <= 5) {
curMoveNodeY = item.y;
const itemX = item.x;
if (moveNodeX > itemX) {
const fromPos = { x: item.x, y: item.y };
guideLines.push({ id: getUUID(), fromPos, dire: 'tt' });
} else {
const toPos = { x: item.r, y: item.y };
guideLines.push({ id: getUUID(), toPos, dire: 'tt' });
}
}
if (Math.abs(moveNodeB - item.b) <= 5) {
curMoveNodeY = item.b - moveNode.height;
if (moveNodeX > item.x) {
const fromPos = { x: item.x, y: item.b };
guideLines.push({ id: getUUID(), fromPos, dire: 'bb' });
} else {
const toPos = { x: item.r, y: item.b };
guideLines.push({ id: getUUID(), toPos, dire: 'bb' });
}
}
if (Math.abs(moveNodeR - item.r) <= 5) {
curMoveNodeX = item.r - moveNode.width;
if (moveNodeY > item.y) {
const fromPos = { x: item.r, y: item.y };
guideLines.push({ id: getUUID(), fromPos, dire: 'rr' });
} else {
const toPos = { x: item.r, y: item.b };
guideLines.push({ id: getUUID(), toPos, dire: 'rr' });
}
}
if (Math.abs(moveNodeX - item.r) <= 5) {
curMoveNodeX = item.r;
if (moveNodeY > item.y) {
const fromPos = { x: item.r, y: item.y };
guideLines.push({ id: getUUID(), fromPos, dire: 'lr' });
} else {
const toPos = { x: item.r, y: item.b };
guideLines.push({ id: getUUID(), toPos, dire: 'lr' });
}
}
if (Math.abs(moveNodeR - item.x) <= 5) {
curMoveNodeX = item.x - moveNode.width;
if (moveNodeY > item.y) {
const fromPos = { x: item.x, y: item.y };
guideLines.push({ id: getUUID(), fromPos, dire: 'rl' });
} else {
const toPos = { x: item.x, y: item.b };
guideLines.push({ id: getUUID(), toPos, dire: 'rl' });
}
}
if (Math.abs(moveNodeY - item.b) <= 5) {
curMoveNodeY = item.b;
if (moveNodeX > item.x) {
const fromPos = { x: item.x, y: item.b };
guideLines.push({ id: getUUID(), fromPos, dire: 'tb' });
} else {
const toPos = { x: item.r, y: item.b };
guideLines.push({ id: getUUID(), toPos, dire: 'tb' });
}
}
if (Math.abs(moveNodeB - item.y) <= 5) {
curMoveNodeY = item.y - moveNode.height;
if (moveNodeX > item.x) {
const fromPos = { x: item.x, y: item.y };
guideLines.push({ id: getUUID(), fromPos, dire: 'bt' });
} else {
const toPos = { x: item.r, y: item.y };
guideLines.push({ id: getUUID(), toPos, dire: 'bt' });
}
}
});
const x = curMoveNodeX ? curMoveNodeX : moveNodeX;
const y = curMoveNodeY ? curMoveNodeY : moveNodeY;
const r = x + moveNode.width;
const b = y + moveNode.height;
guideLines = fliterRepeatLine(guideLines, moveNode, { x, y });
const newNodes = nodes.map(item => item.id === moveNode.id ? { ...item, x, y, b, r } : item);
const newNode = { ...moveNode, x, y, r, b };
return { newNodes, guideLines, newNode };
}
四、结语
写的有些累了,下次我们会讲一下right-panel组件的操作,还有会优化一些这篇文章的讲法。因为其实比较复杂的,有兴趣的可以加入qq群:534775880或者扫一下下面的二维码加群探讨探讨技术:
最后,写文档不易,如果觉得开源的组件还不错,有帮助的,希望可以给笔者一个小tips~或者有需要合作的项目可以联系笔者噢~~~