大致介绍下文章内容
这周接到了新需求,大致就是需要实现一个可拖拽,可连线的类似流程图的一个东西(技术不精,大佬们轻喷)。下面先上最终的实现效果:
左边是antd的Tree组件,需要做的就是拖动树节点到右边的流程图里面,记录最后的位置,并新增一个节点,该节点可以连线。
上图用到的插件是jsplumb(有朋友推荐我用g6,但因为代码已经肝好了,所以就先用这个写篇文章),我在网上找了一些参考文档,下面是链接:
shawchen08.github.io/2019/03/21/…
const flowchart: jsPlumbInstance = jsPlumb.getInstance({
grid: [10, 10], //节点移动时的最小距离
Anchors: ["TopCenter", "RightMiddle"], // 动态锚点、位置自适应
Container: "flowChart", //画布容器id
// 连线的样式 StateMachine、Flowchart,有四种默认类型:Bezier(贝塞尔曲线),Straight(直线),Flowchart(流程图),State machine(状态机)
Connector: ["Flowchart", { cornerRadius: 5, alwaysRespectStubs: true, stub: 5 }],
// ConnectionOverlays: [["Label", { label: '111' }]],
// ConnectionsDetachable: false, // 鼠标不能拖动删除线
// DeleteEndpointsOnDetach: false, // 删除线的时候节点不删除
EndpointStyle: { fill: 'transparent', stroke: '#1565C0', strokeWidth: 1, radius: 4 }, //端点的默认样式
LogEnabled: false, //是否打开jsPlumb的内部日志记录
PaintStyle: { stroke: "#2AAF8F", strokeWidth: 2 }, // 绘制线
HoverPaintStyle: { stroke: "#2AAF8F", strokeWidth: 2 }, // 绘制箭头
Overlays: [["Arrow", { width: 8, length: 8, location: 1 }]],
RenderMode: "svg"
})
上面是获取jsplumb实例时一些简单的配置,这里就不多介绍了,上面的参考文档里有详细介绍(其实是我也不太清楚)。
具体的实现过程
首先要实现的就是antd TreeNode的可拖拽,这个很简单,只需要在渲染TreeNode时给树节点加上draggable={true}这个属性,就可以让元素可拖拽了。但如果仅仅是这样,岂不是太简单了,我想的是封装一个DragElement组件,只要是用这个组件包裹的元素,都可以拖拽到流程图里生成节点。
DragElement组件的render很简单,就是用可拖拽的div包裹一下传入的children。下图是jsplumb插入节点时的dom结构,可以看到节点其实就是div(我本来以为会是canvas绘制的),所以我们可以通过css来修改节点的样式;每个节点都有一个唯一id,这个id后面连线的时候会用到。
return <div draggable={true} className={styles.dragElement} onDragEnd={handleDragEnd} onDragStart={handleDragStart} data-key={onlyId} data-title={title}>
{children}
</div>
attr里的data-key是为了生成流程节点时的唯一id,data-title是生成流程节点时的节点名称,这两个属性是必须的;关键的点在于onDragEnd、onDragStart这两个事件。因为jsplumb是基于jquery的,所以我在react中使用的时候用了很多的原生js(我也不知道这种写法好不好,希望大佬们多多指教)。
onDragStart-拖拽开始事件
拖拽开始事件只是为了给右侧的流程框添加样式,更直观的表达可以把元素拖拽进来。#flowChart这个元素就是右侧的流程框。
const handleDragStart = (e: React.DragEvent) => {
const flowChart = document.querySelector('#flowChart');
if (!flowChart) return;
flowChart.classList.add('flowChart_focus');
}
onDragEnd-拖拽结束事件
拖拽结束时需要做的操作就很多了,首先就是需要往#flowChart里添加一个div,并且需要获取到DragElement里的data-key、data-title,其次就是要记录节点拖拽的最终位置,好在对应位置生成节点。因为DragElement组件和右侧的FlowChart组件是兄弟关系,如果通过state,redux等来控制FlowChart渲染的话会很麻烦(个人感觉),所以我没有用state来驱动FlowChart渲染,而是直接进行了dom操作。
/**
* 获取元素在页面中的坐标(x, y)
* @param {Object} e
*/
function getElementPosition(e: any) {
var x = 0, y = 0;
const width = e.offsetWidth,
height = e.offsetHeight;
while (e != null) {
x += e.offsetLeft;
y += e.offsetTop;
e= e.offsetParent;
}
return { x: x, y: y, width, height };
}
/**
* 判断元素是否在另一元素内
* @param {Object} child
* @param {Object} father
*/
interface dom {
x: number,
y: number,
width: number,
height: number
}
function comparePos(child: dom, father: dom) {
let result = null;
if (child.x >= father.x && child.x <= (father.x + father.width) && child.y >= father.y && child.y <= (father.y + father.height)) {
const _x = child.x - father.x,
_y = child.y - father.y;
result = {
x: _x + 100 > father.width ? father.width - 100 : _x,
y: _y + 100 > father.height ? father.height - 100 : _y,
};
}
return result;
}
const handleDragEnd = (e: any) => {
const flowChart: flowChartProps | null = document.querySelector('#flowChart');
if (!flowChart) return;
const items = flowChart.querySelectorAll('.item');
flowChart.classList.remove('flowChart_focus');
if (Array.from(items).findIndex((item: any) => item.getAttribute('id') === onlyId) !== -1) {
console.error('模块已存在');
return;
}
const pos = getElementPosition(flowChart);// 获取元素的坐标
const result = comparePos({
x: e.clientX,
y: e.clientY,
width: e.target.offsetWidth,
height: e.target.offsetHeight
}, pos); // 判断元素是否在另一元素内
if (!!result) {
/* 创建控件 */
const _div: any = document.createElement('div');
_div.setAttribute('id', onlyId);
_div.setAttribute('title', title);
_div.flowParams = {
onlyId,
title,
...params
}
_div.classList.add('item');
_div.style.left = result.x + 'px';
_div.style.top = result.y + 'px';
/* 创建控件 */
/* 创建空间中间的文字块 */
const _text = document.createElement('span');
_text.classList.add('text');
_text.innerText = title.length > 6 ? title.slice(0, 5) + '...' : title;
/* 创建空间中间的文字块 */
/* 创建删除按钮 */
const _del = document.createElement('span');
_del.classList.add('delIcon');
/* 创建删除按钮 */
_div.appendChild(_text);
_div.appendChild(_del);
flowChart.appendChild(_div);
var common = {
maxConnections: -1 // 不限制链接数
}
flowChart.instance.addEndpoint(onlyId, {
anchor: 'Left',
maxConnections: -1
}, { ...common, isTarget: true }) // 添加了节点之后,需要给节点加上可连线的,isTarget标记为终点
flowChart.instance.addEndpoint(onlyId, {
anchor: 'Right',
maxConnections: -1
}, { ...common, isSource: true, }) // isSource标记为起点
flowChart.instance.draggable(_div, { containment: "#flowChart" });// 给新加入的节点赋予可拖拽属性
}
}
FlowChart组件-右侧流程图
FlowChart组件主要就是实例化jsplumb,生成默认的开始、结束节点,并绑定传入的各种事件。
onClick,onDelete
onClick节点的click事件,onDelete节点的删除事件,这两个事件触发时都会抛出该节点上的{id,title}属性,你可以根据id进行其他操作。
onConnect
flowchart.bind("connection", function (info) { const { sourceId, targetId } = info; onConnect && typeof onConnect === 'function' && onConnect({ sourceId, targetId }) });
其他事件
onConnect是jsplumb自带的连线事件,当连线完成时,会抛出起点与终点的节点属性。
flowchart.bind("click", function (connection) { flowchart.deleteConnection(connection); });这个事件是连线的点击事件,这里是删除连线。
beforeDrop-连接建立前事件,这里后续待完善,主要是为了在连接建立时,生成一个label,可供用户填写该连接的名称。
flowchart.bind("beforeDrop", function (info) {
// const connector = info.connection.canvas;
// const _left = parseInt(parseFloat(connector.style.left) + parseFloat(connector.getAttribute('width') / 2));
// const _top = parseInt(connector.style.top);
// const _div = document.createElement();
return true;
});
FlowChart代码
interface FlowChartInterface {
flow?: {
getData: () => void
setFlow: (params: object) => void
},
onClick?: (params: object) => void,
onDelete?: (params: object) => void,
onConnect?: (params: object) => void,
}
const FlowChart: FC<FlowChartInterface> = ({ flow, onClick, onDelete, onConnect }) => {
const [items] = useState([
{
key: 'startPoint',
title: '开始',
class: 'startPoint'
},
{
key: 'endPoint',
title: '结束',
class: 'endPoint'
}
]);
useEffect(() => {
const flowchart: jsPlumbInstance = jsPlumb.getInstance({
grid: [10, 10], //节点移动时的最小距离
Anchors: ["TopCenter", "RightMiddle"], // 动态锚点、位置自适应
Container: "flowChart", //画布容器id
// 连线的样式 StateMachine、Flowchart,有四种默认类型:Bezier(贝塞尔曲线),Straight(直线),Flowchart(流程图),State machine(状态机)
Connector: ["Flowchart", { cornerRadius: 5, alwaysRespectStubs: true, stub: 5 }],
// ConnectionOverlays: [["Label", { label: '111' }]],
// ConnectionsDetachable: false, // 鼠标不能拖动删除线
// DeleteEndpointsOnDetach: false, // 删除线的时候节点不删除
EndpointStyle: { fill: 'transparent', stroke: '#1565C0', strokeWidth: 1, radius: 4 }, //端点的默认样式
LogEnabled: false, //是否打开jsPlumb的内部日志记录
PaintStyle: { stroke: "#2AAF8F", strokeWidth: 2 }, // 绘制线
HoverPaintStyle: { stroke: "#2AAF8F", strokeWidth: 2 }, // 绘制箭头
Overlays: [["Arrow", { width: 8, length: 8, location: 1 }]],
RenderMode: "svg"
})
if(flow) {
flow.setFlow({
getData: () => {
const items = document.querySelectorAll('#flowChart .item');
const controls = Array.from(items).map(item => {
const _style = getComputedStyle(item, null);
return {
id: item.getAttribute('id'),
title: item.getAttribute('title'),
top: _style.getPropertyValue('top'),
left: _style.getPropertyValue('left'),
}
})
const connects = Array.from(flowchart.getAllConnections()).map((item) => ({
sourceId: item.sourceId,
targetId: item.targetId
}))
return {
controls,
connects
}
}
});
}
flowchart.addEndpoint('startPoint', {
anchor: 'Right',
maxConnections: -1
}, { maxConnections: -1, isSource: true })
flowchart.addEndpoint('endPoint', {
anchor: 'Left',
maxConnections: -1
}, { maxConnections: -1, isTarget: true })
flowchart.bind("click", function (connection) {
flowchart.deleteConnection(connection);
});
flowchart.bind("connection", function (info) {
const { sourceId, targetId } = info;
onConnect && typeof onConnect === 'function' && onConnect({ sourceId, targetId })
});
flowchart.bind("beforeDrop", function (info) {
// const connector = info.connection.canvas;
// const _left = parseInt(parseFloat(connector.style.left) + parseFloat(connector.getAttribute('width') / 2));
// const _top = parseInt(connector.style.top);
// const _div = document.createElement();
return true;
});
flowchart.draggable(document.querySelectorAll('#flowChart .item'), { containment: "#flowChart" });
const _dom: flowChartProps | null = document.querySelector('#flowChart');
const flowChartClick = (e: any) => {
if(!e.target) return;
e.stopPropagation();
if (e.target.classList.contains('delIcon')) {
flowchart.remove(e.target.parentNode);
onDelete && typeof onDelete === 'function' && onDelete(e.target.parentNode.flowParams);
}
if (e.target.classList.contains('item')) {
onClick && typeof onClick === 'function' && onClick(e.target.flowParams);
}
if (e.target.classList.contains('text')) {
onClick && typeof onClick === 'function' && onClick(e.target.parentNode.flowParams);
}
}
if (_dom) {
_dom['instance'] = flowchart;
_dom.addEventListener('click', flowChartClick);
}
return () => {
flowchart.unbind("click");
if (_dom) {
_dom.removeEventListener('click', flowChartClick);
}
}
}, []);