记一种拖拉拽编排流程的思路
记一种拖拉拽编排流程的思路
有这么一个场景,我们希望能在界面上编排流程,可以添加任意类型的节点,也可以编排节点之间的约束条件。拿采购流程举例,项目经理节点发起采购流程指向采购部门,如果金额在5W以下,采购部门直接评审结束;否则还要经过CEO审批。
想到了三种实现技术:
1、AntG6可视化组件
2、Angular官方material组件
3、原生Component
其中angular
的@angular/cdk/drag-drop
组件用起来最方便。这里记录一下原生JS实现的思路。
原生Component实现思路
按示例,视图应该拆分成“网格画布”、“节点”、“连线”、“条件”四个组件。考虑可能需要添加各类组态效果——连线具有数据流向动画、点击会进行条件设置、各类节点约束条件多样等等,需要要利用继承、多态等特性。
基础类型只实现拖拉拽以及数据交换的API,其子类拓展各种交互效果。
所以基础类型的主要属性、函数大概就清晰了。
画布:具备节点集合和连线集合,以及节点和连线的渲染函数;当然,还有网格。
节点:具备坐标,以之为始的连线,以之为终的坐标;拖动开始、中、结束事件。
连线:起始节点坐标,终止节点坐标;条件。
条件:起始节点坐标,终止节点坐标。
为了达到添加实体自动渲染的效果,这里采用Proxy或者getter、setter做双向绑定。
连线采用SVG
的PATH
实现,轨迹转换成贝塞尔曲线;条件采用SVG
的TEXT
实现;连线上的数据流向采用SVG
的stroke-dasharray
动画。
Demo代码
下面为代码草稿,效果图如下:
DragCanvas
class DragCanvasElement extends HTMLDivElement{
constructor() {
super();
this.init();
}
init() {
// var shadow = this.attachShadow({mode: 'open'});
let canvas = document.createElement('div');
let lines = document.createElementNS('http://www.w3.org/2000/svg','svg');
canvas.setAttribute('class', 'canvas');
lines.classList.add('point-line');
lines.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
lines.setAttribute('version', '1.1');
lines.innerHTML = `
<defs>
<marker markerWidth="5" markerHeight="5" viewBox="-6 -6 12 12" refX="5" refY="0" markerUnits="strokeWidth" orient="auto" id="marker">
<polygon points="-2,0 -5,5 5,0 -5,-5" stroke="#4a90e2" fill="#4a90e2" stroke-width="1px"></polygon>
</marker>
<filter x="0" y="0" width="1" height="1" id="textBg">
<feFlood flood-color="yellow"/>
<feComposite in="SourceGraphic"/>
</filter>
</defs>`;// 连线的箭头,条件文本的背景
canvas.appendChild(lines);
this.append(canvas);
}
}
class RuleDragCanvasElement extends DragCanvasElement {
constructor() {
super();
this._ruleDragNodes = [];//节点集
this._ruleDragLines = [];//线集合
this.ruleDragNodes = this.watchRuleDragNodes();
this.ruleDragLines = this.watchRuleDragLines();
}
// Proxy监听,画布里添加后增加对应的DOM元素
watchRuleDragNodes() {
let ctx = this;
return new Proxy(this._ruleDragLines, {
get(target, prop, receiver) {
return Reflect.get(target, prop, receiver);
},
set(target, prop, value, receiver) {
// do sth
if(value.constructor == RuleDragNodeElement) {
ctx.append(value);
}
return Reflect.set(target,prop,value,receiver);
}
});
}
watchRuleDragLines() {
let ctx = this;
return new Proxy(this._ruleDragNodes, {
get(target, prop, receiver) {
return Reflect.get(target, prop, receiver);
},
set(target, prop, value, receiver) {
// do sth
if(value.constructor == RuleDragLineElement) {
ctx.querySelector('svg').append(value.querySelector('g'));
}
return Reflect.set(target,prop,value,receiver)
}
});
}
}
customElements.define('rule-drag-canvas', RuleDragCanvasElement, { extends: 'div' });
DragNode
const DRAG_NODE_STYLE_RULES = `
.canvas-node {
border-radius: 4px;
width: 152px;
height: 36px;
position: absolute;
top: 0;
left: 0;
background: #c2185b;
text-align: center;
line-height: 36px;
user-select: none;
color: #fff;
}
`;
class DragNodeElement extends HTMLElement {
constructor() {
super();
this.id = new Date().getTime();
this._point = {x:0, y:0}; //实际坐标,与movePoint区别;如拖到不合适的位置需要回到原来位置。
this._movPoint = this.point; //拖动过程,以这个坐标显示
this.events = {};
this.relationLine = { //关联的线段
prev: [],
next: []
}
this._draggable = true;
this._name = 'xxxx';
this.init();
this._ondragstart();
this._ondrag();
this._ondragend();
}
get draggable() {
return this._draggable;
}
set draggable(v) {
this._draggable = v;
}
get name() {
return this._name;
}
set name(v) {
this._name = v;
if(this.shadowRoot) {
this.shadowRoot.querySelector('.canvas-node').textContent = v;
}
}
get point() {
return this._point;
}
set point(v) {
this._point = v;
this.movPoint = v;
}
get movPoint() {
return this._movPoint;
}
set movPoint(v) {
this._movPoint = v;
if(v) {
this._getNodeEl().setAttribute('style', `transform: translate3d(${v.x}px, ${v.y}px, 0px)`);
}
}
init() {
let shadow = this.attachShadow({mode: 'open'});
let style = document.createElement('style');
style.textContent = DRAG_NODE_STYLE_RULES;
shadow.appendChild(style);
let node = document.createElement('div');
node.classList.add('canvas-node');
node.textContent = this.name
node.setAttribute('draggable', this.draggable);
shadow.appendChild(node);
}
onDragStarted(fn) {
if(!this.events['dragStarted']) {
this.events['dragStarted'] = [];
}
this.events['dragStarted'].push(fn);
}
onDragMoved(fn) {
if(!this.events['dragMoved']) {
this.events['dragMoved'] = [];
}
this.events['dragMoved'].push(fn);
}
onDragEnded(fn) {
if(!this.events['dragEnded']) {
this.events['dragEnded'] = [];
}
this.events['dragEnded'].push(fn);
}
_ondragstart() {
let ctx = this;
this.addEventListener('dragstart', function(e) {
console.log('dragstart', e);
e.dataTransfer.setDragImage(e.target.cloneNode(), 0, 0);
ctx.startPos = {x: e.pageX, y: e.pageY};
if(ctx.events['dragStarted']) {
ctx.events['dragStarted'].forEach(fn=> fn.apply(ctx, arguments));
}
});
}
_ondrag() {
let ctx = this;
this.addEventListener('drag', function(e) {
let movePos = {x: e.pageX, y: e.pageY};
ctx.movPoint = {x: movePos.x - ctx.startPos.x + ctx.point.x, y: movePos.y - ctx.startPos.y + ctx.point.y };
ctx.resetRelationLinePoint(ctx, ctx.movPoint);
//边界判定 drag在鼠标划出浏览器就无法监听了。
if(ctx.events['dragMoved']) {
ctx.events['dragMoved'].forEach(fn=> fn.apply(ctx, arguments));
}
});
}
_ondragend() {
let ctx = this;
this.addEventListener('dragend', function(e) {
console.log('dragEnd', e);
let endPos = {x: e.pageX, y: e.pageY};
ctx.movPoint = {x: endPos.x - ctx.startPos.x + ctx.point.x, y: endPos.y - ctx.startPos.y + ctx.point.y };
ctx.point = ctx.movPoint;
ctx.resetRelationLinePoint(ctx, ctx.point);
if(ctx.events['dragEnded']) {
ctx.events['dragEnded'].forEach(fn=> fn.apply(ctx, arguments));
}
});
}
_getNodeEl() {
if(!this.shadowRoot) {
return null;
}
return this.shadowRoot.querySelector('.canvas-node');
}
resetRelationLinePoint(ctx, point) {
ctx.relationLine.prev.forEach(item=> {
item.setStartPoint(point);
});
ctx.relationLine.next.forEach(item=> {
item.setEndPoint(point);
});
}
}
class RuleDragNodeElement extends DragNodeElement {
constructor() {
super();
}
}
customElements.define('rule-drag-node', RuleDragNodeElement);
DragLine
const DRAG_NODE_STYLE_RULES = `
.canvas-node {
border-radius: 4px;
width: 152px;
height: 36px;
position: absolute;
top: 0;
left: 0;
background: #c2185b;
text-align: center;
line-height: 36px;
user-select: none;
color: #fff;
}
`;
class DragNodeElement extends HTMLElement {
constructor() {
super();
this.id = new Date().getTime();
this._point = {x:0, y:0}; //实际坐标,与movePoint区别;如拖到不合适的位置需要回到原来位置。
this._movPoint = this.point; //拖动过程,以这个坐标显示
this.events = {};
this.relationLine = { //关联的线段
prev: [],
next: []
}
this._draggable = true;
this._name = 'xxxx';
this.init();
this._ondragstart();
this._ondrag();
this._ondragend();
}
get draggable() {
return this._draggable;
}
set draggable(v) {
this._draggable = v;
}
get name() {
return this._name;
}
set name(v) {
this._name = v;
if(this.shadowRoot) {
this.shadowRoot.querySelector('.canvas-node').textContent = v;
}
}
get point() {
return this._point;
}
set point(v) {
this._point = v;
this.movPoint = v;
}
get movPoint() {
return this._movPoint;
}
set movPoint(v) {
this._movPoint = v;
if(v) {
this._getNodeEl().setAttribute('style', `transform: translate3d(${v.x}px, ${v.y}px, 0px)`);
}
}
init() {
let shadow = this.attachShadow({mode: 'open'});
let style = document.createElement('style');
style.textContent = DRAG_NODE_STYLE_RULES;
shadow.appendChild(style);
let node = document.createElement('div');
node.classList.add('canvas-node');
node.textContent = this.name
node.setAttribute('draggable', this.draggable);
shadow.appendChild(node);
}
onDragStarted(fn) {
if(!this.events['dragStarted']) {
this.events['dragStarted'] = [];
}
this.events['dragStarted'].push(fn);
}
onDragMoved(fn) {
if(!this.events['dragMoved']) {
this.events['dragMoved'] = [];
}
this.events['dragMoved'].push(fn);
}
onDragEnded(fn) {
if(!this.events['dragEnded']) {
this.events['dragEnded'] = [];
}
this.events['dragEnded'].push(fn);
}
_ondragstart() {
let ctx = this;
this.addEventListener('dragstart', function(e) {
console.log('dragstart', e);
e.dataTransfer.setDragImage(e.target.cloneNode(), 0, 0);
ctx.startPos = {x: e.pageX, y: e.pageY};
if(ctx.events['dragStarted']) {
ctx.events['dragStarted'].forEach(fn=> fn.apply(ctx, arguments));
}
});
}
_ondrag() {
let ctx = this;
this.addEventListener('drag', function(e) {
let movePos = {x: e.pageX, y: e.pageY};
ctx.movPoint = {x: movePos.x - ctx.startPos.x + ctx.point.x, y: movePos.y - ctx.startPos.y + ctx.point.y };
ctx.resetRelationLinePoint(ctx, ctx.movPoint);
//边界判定 drag在鼠标划出浏览器就无法监听了。
if(ctx.events['dragMoved']) {
ctx.events['dragMoved'].forEach(fn=> fn.apply(ctx, arguments));
}
});
}
_ondragend() {
let ctx = this;
this.addEventListener('dragend', function(e) {
console.log('dragEnd', e);
let endPos = {x: e.pageX, y: e.pageY};
ctx.movPoint = {x: endPos.x - ctx.startPos.x + ctx.point.x, y: endPos.y - ctx.startPos.y + ctx.point.y };
ctx.point = ctx.movPoint;
ctx.resetRelationLinePoint(ctx, ctx.point);
if(ctx.events['dragEnded']) {
ctx.events['dragEnded'].forEach(fn=> fn.apply(ctx, arguments));
}
});
}
_getNodeEl() {
if(!this.shadowRoot) {
return null;
}
return this.shadowRoot.querySelector('.canvas-node');
}
resetRelationLinePoint(ctx, point) {
ctx.relationLine.prev.forEach(item=> {
item.setStartPoint(point);
});
ctx.relationLine.next.forEach(item=> {
item.setEndPoint(point);
});
}
}
class RuleDragNodeElement extends DragNodeElement {
constructor() {
super();
}
}
customElements.define('rule-drag-node', RuleDragNodeElement);
DragCond
class DragCondElement extends HTMLElement {
constructor() {
super();
this._startPoint = {x:0,y:0};
this._endPoint = {x:0,y:0};
this._name = '';
this.init();
}
get name() {
return this._name;
}
set name(v) {
this._name = v;
this.text.textContent = v;
}
get startPoint() {
return this._startPoint;
}
set startPoint(v) {
this.setStartPoint(v);
}
get endPoint() {
return this._endPoint;
}
set endPoint(v) {
this.setEndPoint(v);
}
setStartPoint(v) {
this._startPoint = v;
if(v) {
let pos = this.calcPos(this.startPoint, this.endPoint);
console.log(v,pos);
this.text.setAttribute('x', pos.x);
this.text.setAttribute('y', pos.y);
}
}
setEndPoint(v) {
this._endPoint = v;
if(v) {
let pos = this.calcPos(this.startPoint, this.endPoint);
this.text.setAttribute('x', pos.x);
this.text.setAttribute('y', pos.y);
}
}
init() {
this.text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
this.text.textContent = "hello Word!";
this.text.setAttribute('filter', 'url(#textBg)');
this.append(this.text);
}
calcPos(s, e) {
// 限定条件块的宽高,计算的坐标要拉回一半长度
return {
x: (s.x + 146 + e.x - 60) / 2,
y: (s.y + 18 + e.y + 38) / 2
}
}
}
class RuleDragCondElement extends DragCondElement {
constructor() {
super();
}
}
customElements.define('rule-drag-cond', RuleDragCondElement);
index.html
<!DOCTYPE html>
<html>
<head>
<title>WebComp Drag</title>
<link rel="stylesheet" href="./css/styles.css"></link>
<script src="./js/DragCanvas.js"></script>
<script src="./js/DragNode.js"></script>
<script src="./js/DragLine.js"></script>
<script src="./js/DragCond.js"></script>
</head>
<body>
</body>
<script>
let ruleDragCanvas = new RuleDragCanvasElement();
document.body.append(ruleDragCanvas);
let dragNode1 = createNode('Node 1', {x: 0, y: 0});
let dragNode2 = createNode('Node 2', {x: 300, y: 50});
let dragNode3 = createNode('Node 3', {x: 200, y: 400});
let dragNode4 = createNode('Node 4', {x: 600, y: 350});
let ruleDragCond = new RuleDragCondElement();
ruleDragCond.name = '条件'
let line1 = lineTo(dragNode1, dragNode2);
let line2 = lineTo(dragNode1, dragNode3, 'move', ruleDragCond);
let line3 = lineTo(dragNode2, dragNode4);
let line4 = lineTo(dragNode3, dragNode4, 'move');
function createNode(name, point) {
let dragNode = new RuleDragNodeElement();
dragNode.name = name;
dragNode.point = point;
ruleDragCanvas.ruleDragNodes.push(dragNode);
return dragNode;
}
function lineTo(node1, node2, animate, cond) {
let ruleDragLine = new RuleDragLineElement();
if(animate) {
ruleDragLine.animateMove = animate;
}
if(cond) {
ruleDragLine.cond = cond;
}
ruleDragLine.startPoint = node1.point;
ruleDragLine.endPoint = node2.point;
ruleDragCanvas.ruleDragLines.push(ruleDragLine);
node1.relationLine.prev.push(ruleDragLine);
node2.relationLine.next.push(ruleDragLine);
}
</script>
</html>
styles.css
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
width: 100vw;
height: 100vh;
position: relative;
}
.canvas {
min-height: 600px;
width: 100%;
background: #eee;
position: relative;
}
.canvas::before {
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-color: transparent;
background-image: linear-gradient(
90deg, rgba(0, 0, 0, 0.1) 1px, transparent 0), linear-gradient(
180deg, rgba(0, 0, 0, 0.1) 1px, transparent 0);
background-size: 25px 25px;
min-height: 100%;
min-width: 100%;
user-select: none;
}
.point-line {
width: 100%;
height: 100%;
position: absolute;
}
.point-line path {
stroke: #4a90e2;
stroke-width: 3;
fill: none;
cursor: pointer;
}
.point-line path:hover {
stroke-width: 6;
transition: .24s ease-out;
}
.canvas-node {
border-radius: 4px;
width: 152px;
height: 36px;
position: absolute;
top: 0;
left: 0;
background: #c2185b;
text-align: center;
line-height: 36px;
user-select: none;
color: #fff;
}
.move {
stroke-dasharray: 16 20%;
stroke-dashoffset: 320;
animation: move 2.5s linear normal infinite;
}
@keyframes move {
from {
stroke-dashoffset: 104%;
stroke: #fff;
}
to {
stroke-dashoffset: 0%;
stroke: #fff;
}
}
敌人总是会在你最不想它出现的地方出现!