超能郭工
前端坤坤的小破站,IKUN永不过时 🐔

Vue + AntV X6 流程设计编辑器实战

1 个月前
技术分享

公司最近搞一个海外社交平台营销SAAS平台,涉及到用户自动化流程设计的编辑器。这个需求一直都蛮想挑战的。

大致设计稿如下

Image

接需求后第一件事自然就是调研可用的框架,最终选定了阿里系的 AntV X6,在官网将他的示例中与需求所需的功能都多尝试与学习,感觉大概满足需求后,就开始上去就干‼️

复杂的任务,第一步当然是分解任务,将任务拆成一件件小的任务,分解的过程也是思考和确定执行方案的一个过程。

创建画布并使用Vue节点

把样式写完就是开始 面向 ctrl+c ctrl+v 开发,从各种示例中摘抄代码,再参照API文档调整属性

TriggerNode.vue html
复制成功
<template>
  <div class="graph-node">
    <div class="p-m2 flex justify-between items-center bg-brand/10">
      <div class="text-tm flex items-center gap-m1">
        <img class="w-[24px]" src="/automation/trigger.png" />
        <span class="text-primary">When...</span>
      </div>
    </div>
    <div>
      <div class="p-m2">
        <div class="flex flex-col gap-m2">
          <div class="bg-brand/10 rounded-md p-m2">
            <div class="flex items-center gap-m1">
              <img class="w-[24px]" src="/automation/whatsapp.png" />
              <span class="text-tm text-primary">Trigger title</span>
            </div>
            <div class="b-t-1 b-t-solid b-t-border my-m1"></div>
            <div class="text-bm text-secondary">Trigger desc</div>
          </div>
          <div class="bg-brand/10 rounded-md p-m2">
            <div class="flex items-center gap-m1">
              <img class="w-[24px]" src="/automation/whatsapp.png" />
              <span class="text-tm text-primary">Trigger title</span>
            </div>
            <div class="b-t-1 b-t-solid b-t-border my-m1"></div>
            <div class="text-bm text-secondary">Trigger desc</div>
          </div>
          <button class="btn btn-outline w-full">Add new trigger</button>
          <div class="text-bm text-secondary text-right">Then</div>
        </div>
      </div>
    </div>
  </div>
</template>
GraphCanvas.vue html
复制成功
<template>
  <div class="relative overflow-hidden size-full" v-bind="$attrs">
    <div class="size-full">
      <div ref="GraphDOM" class="select-none"></div>
    </div>
    <TeleportContainer />
  </div>
</template>
<script setup lang="ts">
import "./graph";
import { Graph } from "@antv/x6";
import { register, getTeleport } from "@antv/x6-vue-shape";
import { onMounted, ref } from "vue";
import TriggerNode from "./components/TriggerNode.vue";

const TeleportContainer = getTeleport();

// 注册TriggerNode节点
register({
  shape: "TriggerNode",
  component: TriggerNode,
});

onMounted(() => {
  initGraph();
});

const GraphDOM = ref<HTMLDivElement>();
let graph = ref<Graph>();

function initGraph() {
  if (GraphDOM.value) {
    graph.value = new Graph({
      container: GraphDOM.value,
      autoResize: true,
      background: {
        color: "#f8fafb",
      },
      // 高亮器配置为className,但不设置args,取消默认的连线与拖动时节点高亮样式
      highlighting: {
        default: {
          name: "className",
        },
      },
    });
		// 创建 TriggerNode 节点
    const triggerNode = graph.value.addNode({
      id: "TriggerNode",
      shape: "TriggerNode",
      x: 300,
      y: 100,
    });
  }
}
</script>
<style lang="less">
@import "./graph.less";
</style>
graph.less less
复制成功
.graph-node {
  --at-apply: "w-[362px] rounded-lg bg-white shadow-node overflow-hidden box-border";
  &:hover {
    --at-apply: "shadow-node ring-3 ring-brand";
  }
}

Image

Image

⚠️ 突然在safari浏览器发现的问题

在拖动节点的过程中,节点的渲染出现卡屏的现象

Image

原因与方案

经过各种在找 github 中issue 列表和尝试中,发现主要原因是内容(包括阴影)超出svg的包裹。

svg的包裹元素不会自动根据容器内的DOM结构变化而撑开,所以我们就需要在节点尺寸发生改变时,设置容器的宽高(需加上阴影占用的宽高)

Image

‼️定义一个节点的内间距,这个参数在后面许多的计算中都需要参与

util.ts typescript
复制成功
export const GRAPH_NODE_PADDING = 20
graph.less less
复制成功
.x6-node foreignObject body {
  padding: 20px;
}

未来所有新增的节点都需要考虑处理这个问题,所以我们就抽取出一个组件并复用

NodeFrame.vue html
复制成功
<template>
  <div ref="NodeRef" class="graph-node">
    <div :class="['p-m2 flex justify-between items-center', props.headerClass]">
      <slot name="header">
        <div class="text-tm flex items-center gap-m1">
          <img class="w-[24px]" :src="props.icon" />
          {{ title }}
        </div>
      </slot>
    </div>
    <div>
      <div class="p-m2">
        <slot v-bind:data="data"></slot>
      </div>
    </div>
  </div>
</template>
<script setup lang="ts">
import { Graph, Node } from "@antv/x6";
import { inject, nextTick, onMounted, ref } from "vue";
import { GRAPH_NODE_PADDING } from "../util";
const getGraph = inject<() => Graph>("getGraph");
const getNode = inject<() => Node>("getNode");

const graph = getGraph!();
const node = getNode!();

const NodeRef = ref<HTMLDivElement>();
const data = ref<Record<string, any>>({});

interface IProp {
  icon?: string;
  title?: string;
  headerClass?: string;
}
const props = withDefaults(defineProps<IProp>(), {
  icon: "",
  title: "",
  headerClass: "",
});

onMounted(() => {
  if (!NodeRef.value) return;
  // 创建ResizeObserver订阅观察尺寸发生变化时设置 node 容器尺寸
  const observer = new ResizeObserver((entries) => {
    for (const entry of entries) {
      rerender(entry.contentRect);
    }
    console.log("size change");
  });
  observer.observe(NodeRef.value);
  node.on("change:data", () => {
    data.value = node.getData();
  });
});

const rerender = (rect: DOMRectReadOnly) => {
  if (!NodeRef.value) return;
  const padding = GRAPH_NODE_PADDING * 2;
  const height = rect.height / graph.zoom();
  const width = rect.width / graph.zoom();
  node.size(width + padding, height + padding);
};
</script>

再改造 TriggerNode 使用该组件

html
复制成功
<template>
  <NodeFrame header-class="bg-brand/10" icon="/automation/trigger.png" title="When...">
    <div class="flex flex-col gap-m2">
      <div class="bg-brand/10 rounded-md p-m2">
        <div class="flex items-center gap-m1">
          <img class="w-[24px]" src="/automation/whatsapp.png" />
          <span class="text-tm text-primary">Trigger title #1</span>
        </div>
        <div class="b-t-1 b-t-solid b-t-border my-m1"></div>
        <div class="text-bm text-secondary">Trigger description</div>
      </div>
      <div class="bg-brand/10 rounded-md p-m2">
        <div class="flex items-center gap-m1">
          <img class="w-[24px]" src="/automation/whatsapp.png" />
          <span class="text-tm text-primary">Trigger title #2</span>
        </div>
        <div class="b-t-1 b-t-solid b-t-border my-m1"></div>
        <div class="text-bm text-secondary">Trigger description</div>
      </div>
      <button class="btn btn-outline w-full">Add new trigger</button>
      <div class="text-bm text-secondary text-right">Then</div>
    </div>
  </NodeFrame>
</template>
<script setup lang="ts">
import NodeFrame from "./NodeFrame.vue";
</script>

节点的端口配置

节点与节点之间的连接,需求是通过连接桩交互进行连线,在这之前,我们先将节点与连线相关的配置统一整理在一起。

graph.ts typescript
复制成功
import { register } from "@antv/x6-vue-shape";
import TriggerNode from "./components/TriggerNode.vue";

// 需求中存在不同情况不同的端口颜色
export const portStyles = (color = "var(--edge-border)") => {
  return {
    circle: {
      class: "graph-port",
      color,
      style: `--port-color: ${color};`,
      magnet: true,
      r: 7,
    },
  };
};

register({
  shape: "TriggerNode",
  component: TriggerNode,
  ports: {
    groups: {
      then: {
        position: {
          name: "absolute",
        },
        attrs: portStyles(),
      },
    },
  },
});

端口位置X6提供蛮多种策略的,但我们的需求较为复杂,就统一使用绝对定位,并抽成组件通过根据 DOM 的位置计算并偏移定位。

NodePort.vue html
复制成功
<template>
  <div ref="portEl">
    <slot></slot>
  </div>
</template>

<script setup lang="ts">
import { onMounted, ref, nextTick, toRefs, inject, onUnmounted } from "vue";
import { Node, Graph } from "@antv/x6";
import { GRAPH_NODE_PADDING } from "../util";

type PostionType = "top" | "right" | "left" | "bottom" | "bottom-right";
type Offset = [number, number];
const getGraph = inject("getGraph") as () => Graph;

const graph = getGraph();

const props = withDefaults(
  defineProps<{
    node: Node;
    id: string;
    group: string; // 端口对应的组 groups
    position?: PostionType; // 对齐位置
    offset?: Offset; // 平面轴的偏移量
  }>(),
  {
    position: "right",
    offset: () => [0, 0],
  }
);

const { node } = props;
const { group, id } = toRefs(props);

const portEl = ref<HTMLDivElement>();

onMounted(() => {
  const nodeEl = document.querySelector(
    `[data-cell-id="${node.id}"] .graph-node`
  );
  if (nodeEl) {
    const observer = new ResizeObserver(() => {
      updatePostPosition();
    });
    observer.observe(nodeEl);
  }
});

onUnmounted(() => {
  removePort();
});

const removePort = () => {
  node.removePort(id.value);
};

/**
 * 更新连接桩位置
 */
const updatePostPosition = async () => {
  await nextTick();
  if (!portEl.value) return;
  const containerRect = portEl.value!.getBoundingClientRect();
  const zoom = graph.zoom();
  const wrap = document.querySelector(
    `[data-cell-id="${node.id}"] .graph-node`
  );
  if (!wrap) return;
  const wrapRect = wrap!.getBoundingClientRect();
  // 计算position位置
  const basicX = containerRect.left - wrapRect.left;
  const basicY = containerRect.top - wrapRect.top;
  const positionMap: Partial<Record<PostionType, Offset>> = {
    right: [basicX + containerRect.width, basicY + containerRect.height / 2],
    left: [basicX, basicY + containerRect.height / 2],
    top: [basicX + containerRect.width / 2, basicY],
    bottom: [basicX + containerRect.width / 2, basicY + containerRect.height],
  };
  const position = positionMap[props.position] || [0, 0];
  // 处理偏移量
  // 缩放时会影响到连接桩定位的计算,所以需要将缩放因素计算在内
  const offsetPosition = [
    position[0] + (props.offset[0] + GRAPH_NODE_PADDING) * zoom,
    position[1] + (props.offset[1] + GRAPH_NODE_PADDING) * zoom,
  ];
  const port = node.getPort(id.value);
  if (port) {
    node.setPortProp(id.value, "args", {
      x: offsetPosition[0] / zoom,
      y: offsetPosition[1] / zoom,
    });
  } else {
    node.addPort({
      id: id.value,
      group: group.value,
      args: {
        x: offsetPosition[0] / zoom,
        y: offsetPosition[1] / zoom,
      },
    });
  }
};
</script>

graph.less less
复制成功
/* 为画板增加常用的连线颜色变量 */
.x6-graph {
  --edge-border: theme("colors.border");
  --edge-hover: theme("colors.brand");
  --edge-success: theme("colors.success");
  --edge-danger: theme("colors.danger");
}

/* 增加一下 port 的样式*/
.graph-port {
  fill: white;
  stroke-width: 3;
  stroke: var(--port-color);

  &:hover {
    opacity: 1;
    transform: scale(1.2);
  }
  &[connected="true"] {
    fill: var(--port-color);
  }
  &[hover="true"] {
    fill: var(--edge-hover) !important;
    stroke: var(--edge-hover) !important;
  }
}
TriggerNode.vue html
复制成功
<template>
  ...
-	  		  <div class="text-bm text-secondary text-right">Then</div>
				  <!-- 将 上一行 换成 下面这段,引用 NodePort 为节点创建端口 -->
+         <NodePort
+           v-if="node"
+           group="then"
+           id="then"
+           :offset="[16, 0]"
+           :node="node"
+           class="text-bm text-secondary text-right"
+         >
+           Then
+         </NodePort>
  ...
</template>
<script setup lang="ts">
import { inject } from "vue";
import { Node } from "@antv/x6";
import NodePort from "./NodePort.vue";

const getNode = inject<() => Node>("getNode");
const node = getNode!();
</script>

Image

连线的样式与锚点

当你完成端口的设置后,再注册多一个Node后并添加后,节点之间就可以进行连线了,但连线的样式不符合需求,就需要为连线增加样式与锚点。

Image

graph.ts typescript
复制成功
// 注册线连接端点连接时的锚点
Graph.registerAnchor("port-right", function (view, magnet) {
  const bbox = view.getBBoxOfElement(magnet);
  const result = bbox.getRightMiddle();
  return result;
});

// 注册线连接节点连接时的锚点
Graph.registerAnchor("node-left", function (view, magnet) {
  const bbox = view.getBBoxOfElement(magnet);
  const result = bbox.getTopLeft();
  result.x = bbox.x + GRAPH_NODE_PADDING;
  result.y = bbox.y + 25 + GRAPH_NODE_PADDING;
  return result;
});

// 注册箭头样式
Graph.registerMarker("arrow-storke", () => {
  return {
    tagName: "path",
    strokeWidth: 3,
    width: 14,
    height: 14,
    d: "M7 -7L0 0L7 7",
    strokeLinecap: "round",
    fill: "none",
  };
});

// 注册主题样式的连线样式
Graph.registerEdge("graph-edge", {
  inherit: "edge",
  attrs: {
    line: {
      class: "graph-edge",
      color: "var(--edge-border)",
      stroke: "var(--edge-border)",
      strokeWidth: 3,
      strokeLinecap: "round",
      targetMarker: "arrow-storke",
    },
  },
});
GraphCanvas.vue typescript
复制成功
//...
		graph.value = new Graph({
      container: GraphDOM.value,
      autoResize: true,
      panning: true,
+     connecting: {
+       connector: "curveConnector",
+       allowBlank: false,
+       sourceAnchor: "port-right",
+       targetAnchor: "node-left",
+       connectionPoint: { name: "anchor" },
+       // 创建连线时配置连线样式
+       createEdge({ sourceMagnet }) {
+         let portColor = sourceMagnet.getAttribute("color");
+         const color = portColor || "var(--edge-border)";
+         const attrs = {
+           line: {
+             color, // 记录原色彩,方便hover高亮变色后回复原色
+             stroke: color,
+           },
+         };
+         return this.createEdge({
+           shape: "graph-edge",
+           attrs,
+         });
+       },
+     },
      highlighting: {
        default: {
          name: "className",
        },
      },
      background: {
        color: "#f8fafb",
      },
    });
//...

需求中连线并不像现在这种 smooth 的样式,这里就涉及到开发的高端操作:沟通更改一下设计需求,因为 AntV X6 提供的连线路由策略在蛮多情况下不太符合设计要求,且自定义路由策略需要更多的时间,我们当然是没必要在这种不太重要的地方浪费过多的时间啦。

Image

注册连线工具

Image

需求中鼠标进入连线区域,还需要显示删除按钮,用于删除连线

连线的按钮工具注册按钮后,对按钮的定位方式暂时只支持通过svg元素的transform属性偏移,使用div元素可能需要自己克隆后改写,所以就不搞那么复杂了,小小研究下svg的时间成本更低一些

graph.ts typescript
复制成功
Graph.registerEdgeTool("edge-remove", {
  inherit: "button",
  className: "edge-remove",
  markup: [
    {
      tagName: "g",
      attrs: {
        filter: "url(#drop_shadow)",
      },
      children: [
        {
          tagName: "rect",
          attrs: {
            x: -17,
            y: -17,
            width: 34,
            height: 34,
            rx: 8,
          },
        },
        {
          tagName: "path",
          attrs: {
            fill: "#F03D3D",
            stroke: "#F03D3D",
            strokeWidth: "0.1",
            strokeLinecap: "round",
            d: "m1.56,-6.79c0.18,0 0.33,0.16 0.33,0.34l0,0.73l-3.78,0l0,-0.73c0,-0.18 0.15,-0.34 0.33,-0.34l3.12,0zm-4.72,0.34l0,0.73l-3.26,0c-0.35,0 -0.63,0.28 -0.63,0.63c0,0.35 0.28,0.63 0.63,0.63l0.93,0l0,10.46c0,0.89 0.71,1.61 1.6,1.61l7.78,0c0.89,0 1.6,-0.72 1.6,-1.61l0,-10.46l0.93,0c0.35,0 0.63,-0.28 0.63,-0.63c0,-0.35 -0.28,-0.63 -0.63,-0.63l-3.26,0l0,-0.73c0,-0.88 -0.72,-1.6 -1.6,-1.6l-3.12,0c-0.88,0 -1.6,0.72 -1.6,1.6zm7.39,1.99l0,10.46c0,0.19 -0.15,0.34 -0.34,0.34l-7.78,0c-0.19,0 -0.34,-0.15 -0.34,-0.34l0,-10.46l8.46,0zm-3.11,1.7l0,7.37c0,0.35 0.28,0.63 0.63,0.63c0.35,0 0.63,-0.28 0.63,-0.63l0,-7.37c0,-0.35 -0.28,-0.63 -0.63,-0.63c-0.35,0 -0.63,0.28 -0.63,0.63zm-3.5,0l0,7.37c0,0.35 0.28,0.63 0.63,0.63c0.35,0 0.63,-0.28 0.63,-0.63l0,-7.37c0,-0.35 -0.28,-0.63 -0.63,-0.63c-0.35,0 -0.63,0.28 -0.63,0.63z",
          },
        },
      ],
    },
    {
      tagName: "filter",
      attrs: {
        id: "drop_shadow",
        width: 46,
        height: 46,
      },
      children: [
        {
          tagName: "feDropShadow",
          attrs: {
            stdDeviation: 6,
            dx: 0,
            dy: 3,
            floodColor: "rgba(0,0,0,.12)",
          },
        },
      ],
    },
  ],
  onClick({ view }: { view: EdgeView }) {
    view.cell.removeTools();
    view.cell.remove();
  },
});
graph.less less
复制成功
+ .edge-remove {
+   --at-apply: "fill-white cursor-pointer";
+   &:hover {
+     --at-apply: "fill-brand-900";
+   }
+ }

注册工具后使用就在下面的交互章节一起说明吧

节点与连线交互与限制

节点交互

  • 选中节点并平移画布将节点置入可视区域
  • 操作、移除、连线节点时校验所有节点的状态
GraphCanvas.vue typescript
复制成功
//#region 设置节点交互
const settingNodeEffect = () => {
  if (!graph.value) return;
  graph.value.on("node:click", ({ node }) => {
    if (currentNode.value?.id !== node.id) {
      checkGraphStatus();
    }
    setCurrentNode(node);
  });

  graph.value.on("cell:removed", ({ cell }) => {
    checkGraphStatus();
    if (cell.isNode() && currentNode.value?.id === cell.id) {
      setCurrentNode(undefined);
    }
  });

  graph.value.on("blank:click", () => {
    if (currentNode.value !== undefined) {
      setCurrentNode(undefined);
      checkGraphStatus();
    }
  });
};

// 设置聚焦节点
const setCurrentNode = (nodeOrId?: Node | string) => {
  if (!graph.value) return;
  // 先将所有节点的聚焦状态关闭
  graph.value.getNodes().forEach(async (node: Node) => {
    node.attr(".graph-node/focus", "false");
  });
  // 支持通过 ID 或直接 Node对象 设置
  let node: Cell;
  if (!nodeOrId) {
    currentNode.value = undefined;
    return;
  } else if (typeof nodeOrId === "string") {
    node = graph.value.getCellById(nodeOrId);
  } else {
    node = nodeOrId;
  }
  // 聚焦前确认节点是否有效与是否为节点
  if (!node?.isNode()) return;
  setTimeout(() => {
    currentNode.value = node;
    node.attr(".graph-node/focus", "true");
  }, 50);
  // 平移至当前节点的位置
  const translate = graph.value.translate();
  const zoom = graph.value.zoom();
  const bbox = node.getBBox();
  if (
    (bbox.x + translate.tx) * zoom < 360 ||
    (bbox.y + translate.ty) * zoom < 10
  ) {
    graph.value.positionPoint({ x: bbox.x, y: bbox.y }, 380, 100);
  }
};

const checkGraphStatus = () => {
  if (!graph.value) return;
  //缓存所有连线对应的端口数据
  const connectedPort = graph.value.getEdges().map((edge) => {
    const originColor = edge.getAttrByPath<string>("line/color");
    edge.attr("line/stroke", originColor);
    edge.removeTools();
    return `${edge.getSourceCellId()}-${edge.getSourcePortId()}${
      edge.getTargetNode() ? "" : "-shortcut"
    }`;
  });
  graph.value.getNodes().forEach(async (node: Node) => {
    // 此处可以增加检验节点是否正确并为节点设置 error 状态
    const ports = node.getPorts();
    ports.forEach((port) => {
      // 判断端口是否已连接
      const connected = connectedPort.indexOf(`${node.id}-${port.id}`) > -1;
      node.setPortProp(port.id!, {
        attrs: {
          circle: {
            connected,
            hover: false,
          },
        },
      });
    });
  });
};
//#endregion

连线与端口交互

  • 节点每个端口只可以连接一个节点
  • 连接后的端口需要更改一下样式
  • 需求中连线需要在 hover 时变色
  • 连接增加删除连线的按钮
GraphCanvas.vue typescript
复制成功
//#region 设置连线交互效果
const settingEdgeEffect = () => {
  if (!graph.value) return;
  // 设置连线添加或连接事件,保证端口仅可连接一个节点
  graph.value.on("edge:added", onlyOutgoingEdge);
  graph.value.on("edge:connected", onlyOutgoingEdge);
  // 注册鼠标移入连线事件
  graph.value.on("edge:mouseenter", ({ edge, e }) => {
    if (e.handleObj.originType === "mouseup" || !edge.getTargetNode()) return;
    // 设置连线颜色
    edge.attr("line/stroke", "var(--edge-hover)");
    // 对应连线的端口也要设置hover状态
    edge.getSourceNode()?.setPortProp(edge.getSourcePortId()!, {
      attrs: { circle: { hover: true } },
    });
    // 连线增加删除按钮,引导节点连线不添加
    if (!edge.hasTools() && edge.getTargetNode()?.shape !== "GuideNode") {
      edge.addTools([
        {
          name: "edge-remove",
          args: {
            distance: edge.getPolyline().length() * -0.3,
          },
        },
      ]);
    }
  });
  // 设置鼠标移出连线事件
  graph.value.on("edge:mouseleave", ({ edge }) => {
    // 获取连接原颜色,并设置
    const originColor = edge.getAttrByPath<string>("line/color");
    edge.attr("line/stroke", originColor);
    // 对应连线的端口也要设置hover状态
    edge.getSourceNode()?.setPortProp(edge.getSourcePortId()!, {
      attrs: { circle: { hover: false } },
    });
    // 移除连线上的所有按钮
    edge.removeTools();
  });
};

// 限制节点每个端口只可以连接一个节点
const onlyOutgoingEdge = ({ edge }: { edge: Edge }) => {
  if (graph.value && edge.getSourceCellId() && edge.getTargetCellId()) {
    // 设置连线对应的source端口为连接状态
    edge.getSourceNode()?.setPortProp(edge.getSourcePortId()!, {
      attrs: { circle: { connected: true } },
    });
    // 获取此节点端口的所有的连线,仅保留一条连线
    let outgoingEdges = graph.value.getOutgoingEdges(edge.getSourceCellId());
    if (outgoingEdges) {
      outgoingEdges.forEach((outEdge) => {
        if (
          outEdge.getSourcePortId() === edge.getSourcePortId() &&
          outEdge.id !== edge.id
        ) {
          graph.value?.removeEdge(outEdge);
        }
      });
    }
  }
};
//#endregion

连线至空白出显示快捷菜单

需求中可添加节点的方式其中包括引导节点外,还有个在连线至空白处,弹出快捷创建节点的菜单

GraphCanvas.vue html
复制成功
<template>
	<!-- ... -->
  <div
    :key="shortcut.pos?.x"
    v-show="shortcut.pos"
    :class="[
      'shortcut-menu shadow-node rounded-sm absolute b-[1.5px] b-dashed b-border bg-white',
    ]"
    @mousedown.stop
    :style="menuPosStyle"
  >
    <div class="p-m2 text-tl">Choose first step👇🏻</div>
    <div class="p-m2">
      <ChooseNextStep @select="handleChooseNode" />
    </div>
  </div>
</template>
<script setup lang="ts">
import ChooseNextStep from "./components/ChooseNextStep.vue";
//#region 显示快捷创建节点菜单
const nextMenuRef = ref<HTMLDivElement>();
const nextMenu = reactive<{
  pos?: { x: number; y: number };
  edge?: Edge;
}>({});

const menuPosStyle = computed(() => {
  return {
    left: `${nextMenu.pos?.x || 0}px`,
    top: `${(nextMenu.pos?.y || 0) - 25}px`,
  };
});

const settingNextMenu = () => {
  if (!graph.value) return;

  graph.value.on("edge:mouseup", ({ edge, e }) => {
    if (!edge.getTargetCell()) {
      hideNextMenu();
      if (nextMenuRef.value) {
        const bbox = nextMenuRef.value.getBoundingClientRect();
        let x = e.clientX;
        let y = e.clientY;
        x =
          bbox.width + x > window.innerWidth
            ? x - (bbox.width + x - window.innerWidth)
            : x;
        y =
          bbox.height + y > window.innerHeight
            ? y - (bbox.height + y - window.innerHeight)
            : y;
        nextMenu.pos = { x, y };
        const edgeProp = edge.getProp(),
          offsetX = x - e.clientX,
          offsetY = y - e.clientY;
        edgeProp.target.x += offsetX;
        edgeProp.target.y += offsetY;
        nextMenu.edge = graph.value?.addEdge(edgeProp);
      }
    }
  });

  window.addEventListener("mousedown", hideNextMenu);
};

const hideNextMenu = () => {
  nextMenu.pos = undefined;
  if (nextMenu.edge?.id) {
    graph.value?.removeCell(nextMenu.edge.id);
    nextMenu.edge = undefined;
  }
};

const handleChooseNode = (shape: string) => {
  nextMenu.pos = undefined;
  if (graph.value && nextMenu.edge && nextMenu.edge.isEdge()) {
    // 添加节点
    const newNode = graph.value.addNode({
      id: randomID(),
      shape,
      ...nextMenu.edge.getTargetPoint(),
    });
    // 将新建出来的连线设置target,完成连线
    nextMenu.edge.setTarget(newNode);
    nextMenu.edge
      .getSourceNode()
      ?.setPortProp(nextMenu.edge.getSourcePortId()!, {
        attrs: { circle: { connected: true } },
      });
    //手动触发仅支持一个
    onlyOutgoingEdge({ edge: nextMenu.edge });
    nextMenu.edge = undefined;
  }
};
//#endregion
</script>

应用交互

最后将这些交互设置在初始化Graph时配置一下,

GraphCanvas.vue typescript
复制成功
//...
function initGraph() {
	
  if (GraphDOM.value) {
    graph.value = new Graph({
     // ...
    })
    
+ 	settingNodeEffect();
+ 	settingEdgeEffect();
+ 	settingNextMenu();
+   graph.value.addNode({
+     id: "TriggerNode",
+     shape: "TriggerNode",
+     x: 300,
+     y: 100,
+   });
}
// ...

‼️注意:数据的设置

typescript
复制成功
export declare namespace Cell {
    interface SetDataOptions extends SetOptions {
        deep?: boolean;
        overwrite?: boolean;
    }
}
setData(data: any, options?: Cell.SetDataOptions): this
replaceData(data: any, options: Cell.SetOptions = {}): this
updateData(data: any, options: Cell.SetOptions = {}): this
removeData(options: Cell.SetOptions): this

其中 replaceDataupdateData 是基于 setData 方法的二次封装,不做特别说明

typescript
复制成功
replaceData(data) === setData(data, { ...options, overwrite: true })
updateData(data) === setData(data, { ...options, deep: false })

setData 方法是通过浅比较来判断数据是否有更新,从而决定是否触发节点重绘。

关于数据的设置值得重点注意,有非常严重的深浅拷贝问题。

导致当设置的data中,删除某项不生效之类的,甚至先 cloneDeep 数据后再 setData 都一样不生效

解决方法:

最后在官方 issue 中也找到同遇到这个问题的xdm,其使用 removeData + setData 的方法解决,确实不失为一种绝佳方案

github 关联的 issue 2.x 的X6 node.setData()无法做到强制替换 · Issue #3262 · antvis/X6

typescript
复制成功
node.removeData()
node.setData(data)

最后

X6 整体的应用还是很不错的,背靠大厂,使用也方便,文档只能说还行,不过还好在许多示例多,文档在介绍自己的功能上不够全面。项目运行测试1年多也基本没出现过什么问题。

写这篇文章真是太累了,刚开始想着就贴代码好了,但又感觉贴的太多影响阅读的体验,其次以前的代码总是常看常新,项目过程中没有问题尽可能不去乱重构与优化,也会耗费测试的资源,所以这次在总结并写成文章时,总是各种心痒的尝试新的方案去优化许多部分。

附件:X6-Demo

preview: kwokronny/x6-demo: Created with StackBlitz ⚡️

标签:
Vue
JS/TS
如果这篇文章有帮助到您,可以请我喝杯咖啡
感谢您赐我KUN力
Wechat 赞赏码Alipay 收款码
Vue + AntV X6 流程设计编辑器实战
本站除注明转载外均为原创文章,采用 CC BY-NC-ND 4.0 协议。转载请注明出处,不得用于商业用途
评论