公司的业务使用到了Vue Flow,像比较基础的设置颜色或者marker和label之类的官网都有比较清楚的教程,这边主要讲一下如何使用自定义托拽等操作。
本文示例github地址:github.com/jianqianyan…
原理说明
Vue Flow连线的本质还是在两个点之间绘制svg,官方提供了多种连线方式,直接通过F12也可以看到,连线就是就是计算了这个路径然后给他设置在path上面,于是我们就可以通过修改这个path的方式来实现托拽线条等操作。
官方的案例提供了自定义连线的方式,自定义一个组件,然后引入,可以通过类似插槽的方式使用也可以作为参数传递,我这里更加推荐按使用参数传递。
插槽
<VueFlow :nodes="nodes" :edges="edges">
<template #edge-test1="customEdgeProps">
<TestEdge1
:id="customEdgeProps.id"
:source-x="customEdgeProps.sourceX"
:source-y="customEdgeProps.sourceY"
:target-x="customEdgeProps.targetX"
:target-y="customEdgeProps.targetY"
:source-position="customEdgeProps.sourcePosition"
:target-position="customEdgeProps.targetPosition"
:data="customEdgeProps.data"
:marker-end="customEdgeProps.markerEnd"
:style="customEdgeProps.style"
/>
</template>
</VueFlow>
作为参数传递
const edgesType: Record<string, EdgeComponent> = {
test1: markRaw(TestEdge1) as EdgeComponent
}
<VueFlow
:nodes="nodes"
:edges="edges"
:edge-types="edgeTypes"
>
</VueFlow>
<!-- TestEdge1.vue -->
<script setup lang="ts">
import { BaseEdge, getBezierPath, Position } from '@vue-flow/core'
import { computed, type PropType } from 'vue'
const props = defineProps({
id: {
type: String,
required: true
},
sourceX: {
type: Number,
required: true
},
sourceY: {
type: Number,
required: true
},
targetX: {
type: Number,
required: true
},
targetY: {
type: Number,
required: true
},
sourcePosition: {
type: String as PropType<Position>,
required: true
},
targetPosition: {
type: String as PropType<Position>,
required: true
},
data: {
type: Object,
required: false,
default: () => ({ text: '' })
},
markerEnd: {
type: String,
required: false
},
style: {
type: Object,
required: false
}
})
const path = computed(() => getBezierPath(props))
</script>
<template>
<BaseEdge :id="id" :style="style" :path="path[0]" :marker-end="markerEnd" />
</template>
只要在连线时增加上type我们设置的类型就可以使用我们自定义的线条。
const edges = ref([
{
id: 'e1->2',
source: '1',
target: '2',
type: 'test1'
}
])
比如使用getBezierPath函数计算出来的就是
使用getStraightPath
把path打印出来,具体每个值官网的说明如下
A path string you can use in an SVG, the labelX and labelY position (center of path) and offsetX, offsetY between source handle and label
然后我发现在一个edge组件里,即使有多个BaseEdge也没关系(实际上也可以直接给BaseEdge赋值数组),也会被认做同一个edge,因此我们可以记录一些点的位置,然后借助官方的计算函数,绘制从起点->点1->点2.....->终点的线条,然后再给点增加可以托拽、点击线条在点击位置增加点位等,就实现了托拽线条。
效果如下:
<!-- TestEdge1.vue -->
<script setup lang="ts">
import { BaseEdge, EdgeLabelRenderer } from '@vue-flow/core'
import { computed } from 'vue'
import usePoint from './usePoint'
const props = defineProps({
id: {
type: String,
required: true
},
sourceX: {
type: Number,
required: true
},
sourceY: {
type: Number,
required: true
},
targetX: {
type: Number,
required: true
},
targetY: {
type: Number,
required: true
},
data: {
type: Object,
required: false,
default: () => ({ text: '' })
},
style: {
type: Object,
required: false
}
})
const { nodes, path, onPointDown } = usePoint(
{ x: computed(() => props.sourceX), y: computed(() => props.sourceY) },
{ x: computed(() => props.targetX), y: computed(() => props.targetY) },
props.data.nodes,
props.id
)
</script>
<template>
<BaseEdge
v-for="(item, index) in path[0]"
:key="id + '-' + index"
:id="id + '-' + index"
:style="style"
:path="item"
/>
<EdgeLabelRenderer>
<template v-if="nodes && nodes.length">
<div
v-for="(item, index) in nodes"
:key="props.id + '-circle-' + index"
class="point-box"
:style="{
pointerEvents: 'all',
position: 'absolute',
transform: `translate(-50%, -50%) translate(${item.value.x}px,${item.value.y}px)`
}"
@mousedown="event => onPointDown(item, event)"
></div>
</template>
</EdgeLabelRenderer>
</template>
<style>
.point-box {
width: 10px;
height: 10px;
border-radius: 50%;
background: red;
}
</style>
// usePoint.ts
import { getStraightPath, useEdge, useVueFlow } from "@vue-flow/core";
import { onMounted, onUnmounted, ref, watch, type Ref } from 'vue';
// 计算点是否在直线上
function isPointOnLine(x1: number, y1: number, x2: number, y2: number, px: number, py: number) {
// 计算线段的长度
const lineLength = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
// 如果线段长度为0,即两个端点重合,则点必须与端点重合
if (lineLength === 0) {
return px === x1 && py === y1;
}
// 计算点到线段的投影点
const dot = ((px - x1) * (x2 - x1) + (py - y1) * (y2 - y1)) / lineLength ** 2;
const closestX = x1 + dot * (x2 - x1);
const closestY = y1 + dot * (y2 - y1);
// 计算点到投影点的距离
const distanceToLine = Math.sqrt((px - closestX) ** 2 + (py - closestY) ** 2);
// 如果距离为0,则点在线段上
return Math.abs(distanceToLine) < 5;
}
// 自定义节流函数
function throttle(fn: (args: MouseEvent) => void, delay: number) {
let lastTime = 0;
return function (this: unknown, args: MouseEvent) {
const now = Date.now();
if (now - lastTime >= delay) {
fn.call(this, args);
lastTime = now;
}
};
}
type Point = {
x: number,
y: number
}
type PointRef = { x: Ref<number>, y: Ref<number> }
const usePoint = (begin: PointRef, end: PointRef, defaultNodes: Point[], id: string) => {
const isDown = ref(false)
const { screenToFlowCoordinate } = useVueFlow();
const nodes = ref<Ref<Point>[]>([]);
const path = ref<[string[], number, number]>([
[], 0, 0
]);
let activeNode = ref<null | Point>(null);
const timer = ref<null | number>(null);
if (defaultNodes && defaultNodes.length) {
nodes.value = defaultNodes.map(item => {
return ref({
x: item.x,
y: item.y,
});
});
}
const freshPath = () => {
path.value[0] = [];
const newNodes = [
{ x: begin.x.value, y: begin.y.value },
...nodes.value.map(item => item.value),
{ x: end.x.value, y: end.y.value },
];
for (let i = 0; i < newNodes.length - 1; i++) {
const newPath = getStraightPath({
sourceX: newNodes[i].x,
sourceY: newNodes[i].y,
targetX: newNodes[i + 1].x,
targetY: newNodes[i + 1].y,
});
path.value[0].push(newPath[0]);
path.value[1] = newPath[1];
path.value[2] = newPath[2];
}
}
watch(
() => begin,
() => {
freshPath();
},
{
deep: true,
immediate: true,
}
);
watch(
() => end,
() => {
freshPath();
},
{
deep: true,
immediate: true,
}
);
const onMouseDown = (event: MouseEvent) => {
if (isDown.value) return
if (timer.value) {
clearTimeout(timer.value)
}
timer.value = setTimeout(() => {
event.preventDefault();
event.stopPropagation();
isDown.value = true;
const position = screenToFlowCoordinate({
x: event.clientX,
y: event.clientY,
});
const newNode = ref({
x: position.x,
y: position.y,
});
// 判断点位在哪条线上
const nodeArr = [
{ x: begin.x.value, y: begin.y.value },
...nodes.value.map(item => item.value),
{ x: end.x.value, y: end.y.value },
];
let index = -1;
for (let i = 0; i < nodeArr.length - 1; ++i) {
if (
isPointOnLine(
nodeArr[i].x,
nodeArr[i].y,
nodeArr[i + 1].x,
nodeArr[i + 1].y,
position.x,
position.y
)
) {
index = i;
break;
}
}
if (index !== -1) {
nodes.value.splice(index, 0, newNode);
activeNode = newNode;
}
}, 200)
}
const onMouseMove = throttle((event: MouseEvent) => {
if (isDown.value && nodes.value.length) {
const position = screenToFlowCoordinate({
x: event.clientX,
y: event.clientY,
});
if (activeNode.value) {
activeNode.value.x = position.x;
activeNode.value.y = position.y;
}
freshPath();
}
}, 16);
const onMouseUp = () => {
isDown.value = false;
if (timer.value) {
clearTimeout(timer.value);
}
};
const onPointDown = (node: Ref<Point>, event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
activeNode = node;
isDown.value = true;
};
type EdgeData = {
nodes: Point[];
};
let edge: ReturnType<typeof useEdge> & { edge: { data: EdgeData } };
watch(
nodes,
newNodes => {
if (!edge) {
return;
}
edge.edge.data.nodes = newNodes.map(item => {
return {
...item.value,
};
});
},
{
deep: true,
immediate: true,
}
);
onMounted(() => {
edge = useEdge(id) as ReturnType<typeof useEdge> & { edge: { data: EdgeData } };
const edgeEl = edge.edgeEl.value;
if (edgeEl) {
edgeEl.addEventListener('mousedown', onMouseDown);
edgeEl.addEventListener('mouseup', onMouseUp);
}
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
});
onUnmounted(() => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
});
return {
onMouseDown,
onMouseMove,
onMouseUp,
onPointDown,
path,
nodes,
}
}
export default usePoint
知道了如何操作线条后,其实很多样式就很好做了,比如修改连线的规则,让线大开大合一点,看起来更大气,如:
end