Vue Flow自定义连线

4,097 阅读4分钟

公司的业务使用到了Vue Flow,像比较基础的设置颜色或者marker和label之类的官网都有比较清楚的教程,这边主要讲一下如何使用自定义托拽等操作。

vueflow.dev/examples/ed…

本文示例github地址:github.com/jianqianyan…

原理说明

Vue Flow连线的本质还是在两个点之间绘制svg,官方提供了多种连线方式,直接通过F12也可以看到,连线就是就是计算了这个路径然后给他设置在path上面,于是我们就可以通过修改这个path的方式来实现托拽线条等操作。

image.png

官方的案例提供了自定义连线的方式,自定义一个组件,然后引入,可以通过类似插槽的方式使用也可以作为参数传递,我这里更加推荐按使用参数传递。

插槽

    <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函数计算出来的就是

image.png

使用getStraightPath

image.png

把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.....->终点的线条,然后再给点增加可以托拽、点击线条在点击位置增加点位等,就实现了托拽线条。

效果如下:

1.gif

<!-- 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
​

知道了如何操作线条后,其实很多样式就很好做了,比如修改连线的规则,让线大开大合一点,看起来更大气,如:

QQ_1736750586042.png

end