下面是效果图:
整体结构布局:
需要实现的功能列表:
节点与连接线的可配置节点的拖拽与渲染及连接线的绘制节点与连接线的选择节点的样式调整节点移动时的吸附撤销和恢复
节点与连接线的可配置
节点配置信息
[ { 'id': '', // 每次渲染会生成一个新的id 'name': 'start', // 节点名称,也就是类型 'label': '开始', // 左侧列表节点的名称 'displayName': '开始', // 渲染节点的显示名称(可修改) 'className': 'icon-circle start', // 节点在渲染时候的class,可用于自定义节点的样式 'attr': { // 节点的属性 'x': 0, // 节点相对于画布的 x 位置 'y': 0, // 节点相对于画布的 y 位置 'w': 70, // 节点的初始宽度 'h': 70 // 节点的初始高度 }, 'next': [], // 节点出度的线 'props': [] // 节点可配置的业务属性 }, // ...]
连接线配置信息
// next[ { // 连接线的id 'id': 'ee1c5fa3-f822-40f1-98a1-f76db6a2362b', // 连接线的结束节点id 'targetComponentId': 'fa7fbbfa-fc43-4ac8-8911-451d0098d0cb', // 连接线在起始节点的方向 'directionStart': 'right', // 连接线在结束节点的方向 'directionEnd': 'left', // 线的类型(直线、折线、曲线) 'lineType': 'straight', // 显示在连接线中点的标识信息 'extra': '', // 连接线在起始节点的id 'componentId': 'fde2a040-3795-4443-a57b-af412d06c023' }, // ...]
节点的属性配置结构
// props[ { // 表单的字段 name: 'displayName', // 表单的标签 label: '显示名称', // 字段的值 value: '旅客运输', // 编辑的类型 type: 'input', // 属性的必填字段 required: true, // 表单组件的其它属性 props: { placeholder: 'xxx' } }, // ...]
对于下拉选择的数据,如果下拉的数据非常多,那么配置保存的数据量也会很大,所以可以把所有的下拉数据统一管理,在获取左侧的配置节点的信息时,将所有的下拉数据提取出来,以 props 的 name 值为 key 保存起来,在用的时候用 props.name 来取对应的下拉数据。
另外还需要配置连接线的属性,相对于节点的属性,每一个节点的属性都有可能不一样,但是连接线在没有节点的时候是没有的,所以我们要先准备好连接线的属性,在连接线生成的时候,在加到连接线的属性里去。当然我们可以把连接线的属性设置为一样的,也可以根据节点的不同来设置不同连接线的属性。
最后使用的方式:
import { ref } from 'vue'import Workflow from '@/components/workflow'import { commonRequest } from '@/utils/common'import { ElMessage, ElMessageBox } from 'element-plus'import { useRoute } from 'vue-router'const route = useRoute()const processId = route.query.processId // || 'testca08c433c34046e4bb2a8d3ce3ebc'const processType = route.query.processType// 切换的当前节点const getActiveComponent = (component: Record) => { console.log('active component', component)}// 切换的当前连接线const getActiveLine = (line: Record) => { console.log('active line', line)}const workflowRef = ref<InstanceType>()// 获取配置的节点列表const getConfig = () => { commonRequest(`/workflow/getWorkflowConfig?processType=${processType}`).then((res: Record) => { // 需要把所有的属性根据name转换成 key - value 形式 const props: Record = {} transferOptions(res.result.nodes, props) // 设置左侧配置的节点数据 workflowRef.value?.setConfig(res.result) getData(props) })}// 获取之前已经配置好的数据const getData = (props: Record) => { commonRequest(`/workflow/getWfProcess/${processId}`).then((res: Record) => { // 调整属性,这里是为了当配置列表的节点或者属性有更新,从而更新已配置的节点的属性 adjustProps(props, res.result.processJson) // 设置已配置好的数据,并渲染 workflowRef.value?.setData(res.result.processJson, res.result.type || 'add') })}const init = () => { if (!processId) { ElMessageBox.alert('当前没有流程id') return } getConfig()}init()const transferOptions = (nodes: Record[], props: Record) => { nodes?.forEach((node: Record) => { props[node.name] = node.props })}const adjustProps = (props: Record, nodes: Record[]) => { nodes.forEach((node: Record) => { const oldProp: Record[] = node.props const res = transferKV(oldProp) node.props = JSON.parse(JSON.stringify(props[node.name])) node.props.forEach((prop: Record) => { prop.value = res[prop.name] }) })}const transferKV = (props: Record[]) => { const res: Record = {} props.forEach((prop: Record) => { res[prop.name] = prop.value }) return res}
节点的拖拽与渲染及连接线的绘制
关于节点的拖拽就不多说了,就是 drag 相关的用法,主要是渲染区域的节点和连接线的设计。
这里的渲染区域的思路是:以 canvas 元素作为画布背景,节点是以 div 的方式渲染拖拽进去的节点,拖拽的位置将是以 canvas 的相对位置来移动,大概的结构如下:
而连接线的绘制是根据 next 字段的信息,查找到 targetComponentId 组件的位置,然后在canvas上做两点间的 线条绘制。
链接的类型分为3种: 直线,折线,曲线
直线
直线的绘制最为简单,取两个点连接就行。
// 绘制直线const drawStraightLine = ( ctx: CanvasRenderingContext2D, points: [number, number][], highlight?: boolean) => { ctx.beginPath() ctx.moveTo(points[0][0], points[0][1]) ctx.lineTo(points[1][0], points[1][1]) // 是否是当前选中的连接线,当前连接线高亮 shadowLine(ctx, highlight) ctx.stroke() ctx.restore() ctx.closePath()}
折线
折线的方式比较复杂,因为折线需要尽可能的不要把连接线和节点重合,所以它要判断每一种连接线的场景,还有两个节点的宽度和高度也需要考虑计算。如下:
起始节点有四个方向,目标节点也有四个方向,还有目标节点相对于起始节点有四个象限,所以严格来说,总共有 4 * 4 * 4 = 64 种场景。这些场景中的折线点也不一样,最多的有 4 次, 最少的折 0 次,单求出这 64 种坐标点就用了 700 行代码。
最后的绘制方法与直线一样:
// 绘制折线const drawBrokenLine = ({ ctx, points }: WF.DrawLineType, highlight?: boolean) => { ctx.beginPath() ctx.moveTo(points[0][0], points[0][1]) for (let i = 1; i < points.length; i++) { ctx.lineTo(points[i][0], points[i][1]) } shadowLine(ctx, highlight) ctx.stroke() ctx.restore() ctx.closePath()}
曲线
曲线相对于折线来说,思路会简单很多,不需要考虑折线这么多场景。
这里的折线是用三阶的贝塞尔曲线来绘制的,固定的取四个点,两个起止点,两个控制点,其中两个起止点是固定的,我们只需要求出两个控制点的坐标即可。这里代码不多,可以直接贴出来:
/** * Description: 计算三阶贝塞尔曲线的坐标 */import WF from '../type'const coeff = 0.5export default function calcBezierPoints({ startDire, startx, starty, destDire, destx, desty }: WF.CalcBezierType, points: [number, number][]) { const p = Math.max(Math.abs(destx - startx), Math.abs(desty - starty)) * coeff switch (startDire) { case 'down': points.push([startx, starty + p]) break case 'up': points.push([startx, starty - p]) break case 'left': points.push([startx - p, starty]) break case 'right': points.push([startx + p, starty]) break // no default } switch (destDire) { case 'down': points.push([destx, desty + p]) break case 'up': points.push([destx, desty - p]) break case 'left': points.push([destx - p, desty]) break case 'right': points.push([destx + p, desty]) break // no default }}
简单一点来说,第一个控制点是根据起始点来算的,第二个控制点是跟根据结束点来算的。算的方式是根据当前点相对于节点的方向,继续往前算一段距离,而这段距离是根据起止两个点的最大相对距离的一半(可能有点绕…)。
绘制方法:
// 绘制贝塞尔曲线const drawBezier = ({ ctx, points }: WF.DrawLineType, highlight?: boolean) => { ctx.beginPath() ctx.moveTo(points[0][0], points[0][1]) ctx.bezierCurveTo( points[1][0], points[1][1], points[2][0], points[2][1], points[3][0], points[3][1] ) shadowLine(ctx, highlight) ctx.stroke() ctx.restore() ctx.globalCompositeOperation = 'source-over' //目标图像上显示源图像}
节点与连接线的选择
节点是用 div 来渲染的,所以节点的选择可以忽略,然后就是连接点的选择,首先第一点是鼠标在移动的时候都要判断鼠标的当前位置下面是否有连接线,所以这里就有 3 种判断方法,呃… 严格来说是两种,因为折线是多条直线,所以是按直线的判断方法来。
// 判断当前鼠标位置是否有线export const isAboveLine = (offsetX: number, offsetY: number, points: WF.LineInfo[]) => { for (let i = points.length - 1; i >= 0; --i) { const innerPonints = points[i].points let pre: [number, number], cur: [number, number] // 非曲线判断方法 if (points[i].type !== 'bezier') { for (let j = 1; j < innerPonints.length; j++) { pre = innerPonints[j - 1] cur = innerPonints[j] if (getDistance([offsetX, offsetY], pre, cur) < 20) { return points[i] } } } else { // 先用 x 求出对应的 t,用 t 求相应位置的 y,再比较得出的 y 与 offsetY 之间的差值 const tsx = getBezierT(innerPonints[0][0], innerPonints[1][0], innerPonints[2][0], innerPonints[3][0], offsetX) for (let x = 0; x < 3; x++) { if (tsx[x] = 0) { const ny = getThreeBezierPoint(tsx[x], innerPonints[0], innerPonints[1], innerPonints[2], innerPonints[3]) if (Math.abs(ny[1] - offsetY) < 8) { return points[i] } } } // 如果上述没有结果,则用 y 求出对应的 t,再用 t 求出对应的 x,与 offsetX 进行匹配 const tsy = getBezierT(innerPonints[0][1], innerPonints[1][1], innerPonints[2][1], innerPonints[3][1], offsetY) for (let y = 0; y < 3; y++) { if (tsy[y] = 0) { const nx = getThreeBezierPoint(tsy[y], innerPonints[0], innerPonints[1], innerPonints[2], innerPonints[3]) if (Math.abs(nx[0] - offsetX) < 8) { return points[i] } } } } } return false}
直线的判断方法是点到线段的距离:
/** * 求点到线段的距离 * @param {number} pt 直线外的点 * @param {number} p 直线内的点1 * @param {number} q 直线内的点2 * @returns {number} 距离 */function getDistance(pt: [number, number], p: [number, number], q: [number, number]) { const pqx = q[0] - p[0] const pqy = q[1] - p[1] let dx = pt[0] - p[0] let dy = pt[1] - p[1] const d = pqx * pqx + pqy * pqy // qp线段长度的平方 let t = pqx * dx + pqy * dy // p pt向量 点积 pq 向量(p相当于A点,q相当于B点,pt相当于P点) if (d > 0) { // 除数不能为0; 如果为零 t应该也为零。下面计算结果仍然成立。 t /= d // 此时t 相当于 上述推导中的 r。 } if (t < 0) { // 当t(r) 1) { // 当t(r)> 1时,最短距离即为 pt点 和 q点(B点和P点)之间的距离。 t = 1 } // t = 0,计算 pt点 和 p点的距离; t = 1, 计算 pt点 和 q点 的距离; 否则计算 pt点 和 投影点 的距离。 dx = p[0] + t * pqx - pt[0] dy = p[1] + t * pqy - pt[1] return dx * dx + dy * dy}
关于曲线的判断方法比较复杂,这里就不多介绍, 想了解的可以去看这篇:如何判断一个坐标点是否在三阶贝塞尔曲线附近
连接线还有一个功能就是双击连接线后可以编辑这条连接线的备注信息。这个备注信息的位置是在当前连接线的中心点位置。所以我们需要求出中心点,这个相对简单。
// 获取一条直线的中点坐标const getStraightLineCenterPoint = ([[x1, y1], [x2, y2]]: [number, number][]): [number, number] => { return [(x1 + x2) / 2, (y1 + y2) / 2]}// 获取一条折线的中点坐标const getBrokenCenterPoint = (points: [number, number][]): [number, number] => { const lineDistancehalf = getLineDistance(points) >> 1 let distanceSum = 0, pre = 0, tp: [number, number][] = [], distance = 0 for (let i = 1; i lineDistancehalf) { tp = [points[i - 1], points[i]] distance = lineDistancehalf - distanceSum break } distanceSum += pre } if (!tp.length) { return [0, 0] } let x = tp[0][0], y = tp[0][1] if (tp[0][0] === tp[1][0]) { if (tp[0][1] > tp[1][1]) { y -= distance } else { y += distance } } else { if (tp[0][0] > tp[1][0]) { x -= distance } else { x += distance } } return [x, y]}
曲线的中心点位置,可以直接拿三阶贝塞尔曲线公式求出
// 获取三阶贝塞尔曲线的中点坐标const getBezierCenterPoint = (points: [number, number][]) => { return getThreeBezierPoint( 0.5, points[0], points[1], points[2], points[3] )}/** * @desc 获取三阶贝塞尔曲线的线上坐标 * @param {number} t 当前百分比 * @param {Array} p1 起点坐标 * @param {Array} p2 终点坐标 * @param {Array} cp1 控制点1 * @param {Array} cp2 控制点2 */export const getThreeBezierPoint = ( t: number, p1: [number, number], cp1: [number, number], cp2: [number, number], p2: [number, number]): [number, number] => { const [x1, y1] = p1 const [x2, y2] = p2 const [cx1, cy1] = cp1 const [cx2, cy2] = cp2 const x = x1 * (1 - t) * (1 - t) * (1 - t) + 3 * cx1 * t * (1 - t) * (1 - t) + 3 * cx2 * t * t * (1 - t) + x2 * t * t * t const y = y1 * (1 - t) * (1 - t) * (1 - t) + 3 * cy1 * t * (1 - t) * (1 - t) + 3 * cy2 * t * t * (1 - t) + y2 * t * t * t return [x | 0, y | 0]}
在算出每一条的中心点位置后,在目标位置添加备注信息即可:
节点的样式调整
节点的样式调整主要是位置及大小,而这些属性就是节点里面的 attr,在相应的事件下根据鼠标移动的方向及位置,来调整节点的样式。
还有批量操作也是同样,不过批量操作是要先计算出哪些节点的范围。
// 获取范围选中内的组件export const getSelectedComponent = (componentList: WF.ComponentType[], areaPosi: WF.Attr) => { let selectedArea: WF.Attr | null = null let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity const selectedComponents = componentList.filter((component: WF.ComponentType) => { const res = areaPosi.x <= component.attr.x && areaPosi.y = component.attr.x + component.attr.w && areaPosi.y + areaPosi.h >= component.attr.y + component.attr.h if (res) { minx = Math.min(minx, component.attr.x) miny = Math.min(miny, component.attr.y) maxx = Math.max(maxx, component.attr.x + component.attr.w) maxy = Math.max(maxy, component.attr.y + component.attr.h) } return res }) if (selectedComponents.length) { selectedArea = { x: minx, y: miny, w: maxx - minx, h: maxy - miny } return { selectedArea, selectedComponents } } return null}
这个有个小功能没有做,就是在批量调整大小的时候,节点间的相对距离应该是不动的,这里忽略了。
节点移动时的吸附
这里的吸附功能其实是做了一个简单版的,就是 x 和 y 轴都只有一条校准线,且校准的优先级是从左至右,从上至下。
这里吸附的标准是节点的 6 个点:X 轴的左中右,Y 轴的上中下,当前节点在移动的时候,会用当前节点的 6 个点,一一去与其它节点的 6 个点做比较,在误差正负 2px 的情况,自动更新为0,即自定对齐。
因为移动当前节点时候,其它的节点是不动的,所以这里是做了一步预处理,即在鼠标按下去的时候,把其它的节点的 6 个点都线算出来,用 Set 结构保存,在移动的过程的比较中,计算量会相对较少。
// 计算其它节点的所有点位置export const clearupPostions = (componentList: WF.ComponentType[], currId: string) => { // x 坐标集合 const coordx = new Set() // y 坐标集合 const coordy = new Set() componentList.forEach((component: WF.ComponentType) => { if (component.id === currId) { return } const { x, y, w, h } = component.attr coordx.add(x) coordx.add(x + (w >> 1)) coordx.add(x + w) coordy.add(y) coordy.add(y + (h >> 1)) coordy.add(y + h) }) return [coordx, coordy]}
判读是否有可吸附的点
// 可吸附范围const ADSORBRANGE = 2// 查询是否有可吸附坐标const hasAdsorbable = ( coords: Set[], x: number, y: number, w: number, h: number) => { // x, y, w, h, w/2, h/2 const coord: (number | null)[] = [null, null, null, null, null, null] // 查询 x 坐标 for (let i = 0; i <= ADSORBRANGE; i++) { if (coords[0].has(x + i)) { coord[0] = i break } if (coords[0].has(x - i)) { coord[0] = -i break } } // 查询 y 坐标 for (let i = 0; i <= ADSORBRANGE; i++) { if (coords[1].has(y + i)) { coord[1] = i break } if (coords[1].has(y - i)) { coord[1] = -i break } } // 查询 x + w 坐标 for (let i = 0; i <= ADSORBRANGE; i++) { if (coords[0].has(x + w + i)) { coord[2] = i break } if (coords[0].has(x + w - i)) { coord[2] = -i break } } // 查询 y + h 坐标 for (let i = 0; i <= ADSORBRANGE; i++) { if (coords[1].has(y + h + i)) { coord[3] = i break } if (coords[1].has(y + h - i)) { coord[3] = -i break } } // 查询 x + w/2 坐标 for (let i = 0; i > 1) + i)) { coord[4] = i break } if (coords[0].has(x + (w >> 1) - i)) { coord[4] = -i break } } // 查询 y + h/2 坐标 for (let i = 0; i > 1) + i)) { coord[5] = i break } if (coords[1].has(y + (h >> 1) - i)) { coord[5] = -i break } } return coord}
最后更新状态。
// 获取修正后的 x, y,还有吸附线的状态export const getAdsordXY = ( coords: Set[], x: number, y: number, w: number, h: number) => { const vals = hasAdsorbable( coords, x, y, w, h ) let linex = null let liney = null if (vals[0] !== null) { // x x += vals[0] linex = x } else if (vals[2] !== null) { // x + w x += vals[2] linex = x + w } else if (vals[4] !== null) { // x + w/2 x += vals[4] linex = x + (w >> 1) } if (vals[1] !== null) { // y y += vals[1] liney = y } else if (vals[3] !== null) { // y + h y += vals[3] liney = y + h } else if (vals[5] !== null) { // y + h/2 y += vals[5] liney = y + (h >> 1) } return { x, y, linex, liney }}
撤销和恢复
撤销和恢复的功能是比较简单的,其实就是用栈来保存每一次需要保存的配置结构,就是要考虑哪些操作是可以撤销和恢复的,就是像节点移动,节点的新增和删除,连接线的连接,连接线的备注新增和编辑等等,在相关的操作下面入栈即可。
// 撤销和恢复操作const cacheComponentList = ref([])const currentComponentIndex = ref(-1)// 撤销const undo = () => { componentRenderList.value = JSON.parse(JSON.stringify(cacheComponentList.value[--currentComponentIndex.value])) // 更新视图 updateCanvas(true) cancelSelected()}// 恢复const redo = () => { componentRenderList.value = JSON.parse(JSON.stringify(cacheComponentList.value[++currentComponentIndex.value])) // 更新视图 updateCanvas(true) cancelSelected()}// 缓存入栈const chacheStack = () => { if (cacheComponentList.value.length - 1 > currentComponentIndex.value) { cacheComponentList.value.length = currentComponentIndex.value + 1 } cacheComponentList.value.push(JSON.parse(JSON.stringify(componentRenderList.value))) currentComponentIndex.value++}
最后
这里主要的已经差不多都写了,其实最红还有一个挺有用的功能还没有做。就是改变已经绘制的连接线的起止点。
这里的思路是:先选中需要改变起止点的连接线,然后把鼠标移动到起止点的位置,将它从已经绘制的状态改为正在绘制的状态,然后再选择它的开始位置或者结束位置。这个后面看情况吧,有空就加上。
(学习视频分享:web前端开发、编程基础视频)