普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月31日首页

LogicFlow 插件魔改实录:手把手教你重写动态分组(DynamicGroup)🛠️

作者 橙某人
2025年12月31日 13:40

写在开头

嘿嘿,大家好!👋

今是2025年12月31日上午 8 时 40 分,也是2025年最后一天了,小编做到今天,今年就不做了,感觉这行还是不适应,自己每天不知道在做些什么,天天都忙忙忙,很累很崩溃,钱也没赚到,一点热情也没了。😋

最近,在项目中重度使用了 LogicFlow,不得不说,它的扩展性是真的强。💪💪💪

但是(注意,我要说但是了😄),在使用官方提供的 动态分组DynamicGroup)插件时,由于小编的业务场景比较特殊,遇到了一些让人头秃的问题。

本着 "开源不满足就魔改" 的极客精神,小编决定对 DynamicGroup 插件进行一次深度改造。

所以呢,今天要分享的是 DynamicGroup 插件的改造的完整过程、源码分析以及避坑指南,请诸君按需食用哈。

需求背景与痛点

最近在项目中,小编需要实现一个 "打组与拆组" 功能,不仅要能把部分节点框在一起组成一个整体分组,也需要能随时进行拆组,把子节点放出来。

基于这个需求背景,小编在 LogicFlow 官方文档上找到了两个插件:框选(SelectionSelect)与 动态分组 (DynamicGroup)。

  • SelectionSelect:负责画框框选节点。
  • DynamicGroup:负责把选中的节点 "打包" 成一个组。

这两个插件组合使用理论上能满足小编的业务需求,天作之合属于!🤔

不过,在实际集成过程中,小编发现它们虽然功能强大,但细节上还是有点 "水土不服",无法完全满足细腻的业务需求。于是,小编决定把这两个插件都重写了!

其中,框选插件的改动比较简单,主要是调整了一下样式和微小的交互逻辑,就不在今天展开细说了。我们重点要聊的,动态分组插件的改动过程。

下面,简述小编遇到的几个主要问题:

  • "连坐"机制:官方插件默认的逻辑是,删除分组时,会强制删除分组内的所有子节点。但在小编的"拆组"业务里,用户只是想解散分组,保留里面的子节点。
  • "越狱"现象:虽然插件提供了 isRestrict: true 来限制节点拖出分组,但在快速拖拽时,节点经常能"穿墙"而出,这个问题其实是和下面的问题应该是同个问题。
  • "误触"跳出:如果分组内的子节点包含输入框,点击输入框聚焦时,插件偶尔会误判为"拖拽结束(node:drop)",导致子节点莫名其妙地被移出分组。

为了解决这些症状,小编决定给 DynamicGroup 插件开点药食食。😁

DynamicGroup插件源码分析

在动手之前,咱们先来看看 DynamicGroup 插件的构造,这个插件由以下几个核心文件组成,咱们这次改造也沿用了这个结构:

  • index.js (核心逻辑):插件的入口,负责注册插件和监听全局事件(如 node:add, node:drop)。它维护了一个映射表,决定了节点什么时候进分组,什么时候出分组。

🔉插播一下,插件源码维护得挺好,有非常清晰的代码注释,再结合AI的协助,让人非常容易理解。👏

  • model.js (数据模型):定义分组的数据属性。比如 isRestrict(是否限制拖拽)、isCollapsed(是否折叠)等状态都是在这里管理的。
  • node.js (视图渲染):负责画出分组的样子(SVG)。这里也是离用户最近的地方,处理具体的鼠标交互事件(如 mousemove)。
  • utils.js (工具函数):提供一些计算几何关系的辅助方法,比如判断"这个点是不是在那个框里"。

搞清楚了这些,咱们就可以对症下药了!

改造一:解决"连坐"机制

痛点描述

官方默认的逻辑是:分组被删除 = 分组内的所有子节点一起被删除

如下:

123102.gif

但在很多业务场景下,用户点击删除分组,可能只是想"解散"这个组,而不是把里面的业务子节点也删了。

源码定位

打开 index.js,找到 removeNodeFromGroup 方法:

// 官方源码逻辑
removeNodeFromGroup = ({ data: node, model }) => {
  if (model.isGroup && node.children) {
    node.children.forEach((childId) => {
      this.nodeGroupMap.delete(childId);
      this.lf.deleteNode(childId); // 👈 在这里!直接把子节点干掉了
    });
  }
  // ...
};

改造方案

咱们需要引入一个配置项 retainChildren(保留子节点)。在删除前,检查一下这个属性,如果为 true,就只解除关系,不删节点。

为什么要如此做❓

因为小编还有另外的业务需求是右键删除节点功能,这个操作就需要把子节点也一起删除了,所以增加配置控制的形式更加灵活一些。

修改 index.js 文件:

// 改造后代码逻辑
removeNodeFromGroup = ({ data: node, model }) => {
  // 获取配置属性
  const retainChildren = model.properties && model.properties.retainChildren;
  if (model.isGroup && node.children) {
    node.children.forEach((childId) => {
      this.nodeGroupMap.delete(childId); // 解除映射关系
      // 关键判断:只有不保留时,才删除子节点
      if (!retainChildren) {
        this.lf.deleteNode(childId);
      }
    });
  }
  // ... 后续逻辑不变
};

这样,我们在创建分组时,只要加上 properties: { retainChildren: true },就能实现"拆组"效果了。

123101.gif

改造二:拒绝"误触"跳出

痛点描述

如果你的节点里包含输入框(HTML 节点),当你点击输入框聚焦时,LogicFlow 可能会触发 node:drop 事件(因为它认为你完成了一次交互)。

官方插件在监听 node:drop 时,会重新计算节点应该属于哪个分组。结果就是:点了一下输入框,插件误判你把节点移走了,直接把它踢出了分组。

123103.gif

源码定位

打开 index.js 文件,找到 addNodeToGroup 方法:

// 官方源码逻辑
addNodeToGroup = (node) => {
  // ... 计算节点当前的位置 bounds
  
  const preGroupId = this.nodeGroupMap.get(node.id); // 原来的组
  const targetGroup = this.getGroupByBounds(bounds, node); // 现在位置对应的组
  
  if (preGroupId) {
    // 👈 问题在这里:只要有原分组,它就默认先移除,再看要不要加入新组
    // 如果计算有些许误差,或者逻辑不够严谨,节点就“丢”了
    const group = this.lf.getNodeModelById(preGroupId);
    group.removeChild(node.id); 
    this.nodeGroupMap.delete(node.id);
  }
  
  // ...
};

这个方法会在 node:drop 事件被触发时被调用。

改造方案

逻辑很简单:如果节点原来的组和现在的组是同一个,那就啥也别动! 稳住别浪!🌊

修改 index.js 文件:

// 改造后代码逻辑
addNodeToGroup = (node) => {
  // ... 前面获取 bounds 逻辑不变

  const preGroupId = this.nodeGroupMap.get(node.id);
  const targetGroup = this.getGroupByBounds(bounds, node);
  const targetGroupId = targetGroup ? targetGroup.id : undefined;

  // 新增核心判断,直接 return 掉!
  if (preGroupId === targetGroupId) {
    return;
  }

  // ... 下面才是真正的移动逻辑
  if (preGroupId) {
    // ... 移除旧组逻辑
  }
  if (targetGroup) {
    // ... 加入新组逻辑
  }
};

这样子就搞定了:

123104.gif

改造三:"越狱"现象之绝对防御

痛点描述

DynamicGroup 插件支持设置 isRestrict: true 来限制子节点不能拖出分组。

image.png

这个问题挺奇怪的,小编在群里反馈给过官方维护人员,但是他们好像没有定位到这个问题,在小编本地也确实比较难复现,只会偶尔出现。但是呢,小编的测试同学却能一次一次的复现给小编看,这种问题最难受了。😭

面对铁证,小编也很无奈,只能请AI来协助了,我让AI大致分析了整个源码情况。

限制子节点的拖动范围原理大概是在 graphModel.addNodeMoveRules 里加规则。

但是!这个规则校验是基于"下一次位置是否合法"来拦截的。如果你鼠标甩得特别快(比如高 DPI 鼠标),deltaX/deltaY 很大,直接跳过了边界检测,节点就会"穿墙"而出。

源码定位

这次咱们要去视图层 node.js 文件。因为规则拦截(Model 层)已经防不住了,我们必须在渲染层(View 层)做最后的兜底。

我们需要监听 node:mousemove 事件,这是节点移动最频繁触发的地方。

改造方案

node.js 文件中,我们重写 onNodeMouseMove 方法,并在组件挂载时(componentDidMount)监听它。

逻辑核心

  1. 实时计算节点的新位置。
  2. 判断是否超出了分组的边界。
  3. 如果超出,强制将坐标修正回边界内(暴力修正)。
  4. 同步更新连线(这一点很容易漏,如果不更连线,节点回去了,线还在外面)。

修改 node.js 文件:

// 改造后代码逻辑

// 1. 在 component 绑定事件
componentDidMount() {
  super.componentDidMount();
  // 监听更底层的 mousemove
  this.props.graphModel.eventCenter.on("node:mousemove", this.onNodeMouseMove);
}

// 2. 核心处理逻辑
onNodeMouseMove({ data }) {
  const { model: curGroup, graphModel } = this.props;
  const model = graphModel.getNodeModelById(data.id); // 当前拖动的节点

  // 只有开启了限制,且是自家孩子,才管
  if (curGroup.children.has(model.id) && curGroup.isRestrict) {
    const groupBounds = curGroup.getBounds();
    const nodeBounds = model.getBounds();
    const padding = 10; // 内边距,别贴得太死

    let newX = model.x;
    let newY = model.y;

    // X轴 暴力修正
    if (nodeBounds.minX < groupBounds.minX + padding) {
      newX = groupBounds.minX + padding + model.width / 2;
    } else if (nodeBounds.maxX > groupBounds.maxX - padding) {
      newX = groupBounds.maxX - padding - model.width / 2;
    }

    // Y轴 暴力修正
    if (nodeBounds.minY < groupBounds.minY + padding) {
      newY = groupBounds.minY + padding + model.height / 2;
    } else if (nodeBounds.maxY > groupBounds.maxY - padding) {
      newY = groupBounds.maxY - padding - model.height / 2;
    }

    // 如果位置被我们强行修正了
    if (newX !== model.x || newY !== model.y) {
      // 移动节点
      model.moveTo(newX, newY);
      
      // 关键:手动更新连线!
      // 否则会出现节点被墙挡住了,但连线跟着鼠标飞出去了的诡异画面,非常神奇💥
      this.updateRelatedEdges(model, graphModel);
    }
  }
}

// 辅助方法:更新连线
updateRelatedEdges(model, graphModel) {
  const edges = graphModel.getNodeEdges(model.id);
  edges.forEach((edge) => {
    // 重新计算并设置连线的起点/终点
    if (edge.sourceNodeId === model.id) {
       // ... updateStartPoint 逻辑
    }
    if (edge.targetNodeId === model.id) {
       // ... updateEndPoint 逻辑
    }
  });
}

这里的改造大部分是AI在帮我写的,我只是最终确定一下代码逻辑的合理性,与没有太离谱和边界把控,还需要在页面进行测试验证,最终,确定基本没有什么问题,才交由小编的测试同学去验证,最终也顺利通过验证。😀

这里还是得表扬AI一番啊,这个问题前后大概花了十几二十分钟就搞定了,要是没有AI,靠人工来解决这个问题,时间上应该不敢想象吧。🤔

总结

通过对 DynamicGroup 插件的这番改造,咱们不仅解决了一系列交互 Bug,更重要的是深入理解了 LogicFlow 插件的运行机制。

  • Model 层 负责数据准确性(如删除逻辑)。
  • Logic 层(index.js)负责业务流转(如分组进出判断)。
  • View 层 负责极致的交互体验(如拖拽边界修正)。

希望这篇踩坑实录能给正在使用 LogicFlow 的小伙伴们一些灵感,嘿嘿。😉





至此,本篇文章就写完啦,撒花撒花。

image.png

希望本文对你有所帮助,如有任何疑问,期待你的留言哦。
老样子,点赞+评论=你会了,收藏=你精通了。

昨天 — 2025年12月30日首页

LogicFlow 交互新体验:告别直连,丝滑贝塞尔轨迹实战!🍫

作者 橙某人
2025年12月30日 11:36

写在开头

Hello everyone! 🤠

LogicFlow 交互新体验:让锚点"活"起来,鼠标跟随动效实战!🧲中,咱们解决了锚点的吸附交互问题。今天小编要分享的是另一项细节优化——连线轨迹(Trajectory)

大家在使用 LogicFlow 时,默认的拖拽连线,无论你最终生成的边是折线还是曲线,在鼠标拖拽的过程中,跟随鼠标的那条线始终是一条笔直的虚线。

效果如下:

123002.gif

对于一个追求极致体验的项目来说,这多少有点 "出戏"。🤔

在小编的项目中,我们最终生成的贝塞尔曲线,根据设计人员的要求,连线过程中也期望是以曲线的形式,以此匹配项目的整体设计规范。

于是,小编在翻阅 LogicFlow 文档时,找了一个 customTrajectory 属性,它能让咱们进行自定义连线轨迹。✨

非常灵活方便,在小编早期技术选型时,也是看中了 LogicFlow 库超强的自定义能力。

优化后的最终效果如下,请诸君按需食用哈。

123001.gif

需求背景

在小编项目中,节点之间的边采用的是三次贝塞尔曲线bezierCurveEdge),并且我们对边进行自定义开发。

但是,目前的痛点是:

  • 视觉割裂:拖拽时是直线,松手后变曲线,视觉体验不连续。
  • 风格不符:生硬的直线无法体现项目整体“圆润、流畅”的设计语言。

设计人员想要的效果是:

  • 用户拖拽连线时,跟随鼠标的引导线直接展示为曲线
  • 引导线的曲率动态计算,与最终生成的边保持一致。

具体实现

LogicFlow 提供了一个非常强大的配置项 customTrajectory,允许我们自定义连线过程中引导线的渲染逻辑。

第1️⃣步:理解customTrajectory属性

从文档上看 customTrajectory 属性接收的是一个函数,扒其相关的源码。⏰

源码位置在: packages/core/src/view/Anchor.tsx

核心逻辑如下:

image.png

从源码中可以清晰地看到,customTrajectory 是一个优先级更高的渲染函数。它接收起点 (sourcePoint)、终点 (targetPoint) 以及样式配置 (edgeStyle),要求返回一个 VDOM 节点(它在 render 函数中,通常是 SVG 元素)。

LogicFlow 内部使用 Preact/React 的 VDOM 机制,所以我们需要引入其的 h 函数来创建元素。

第2️⃣步:编写贝塞尔轨迹算法

核心难点在于计算贝塞尔曲线的控制点。为了让曲线看起来自然,通常控制点会根据起点和终点的相对位置进行偏移。

咱们新建文件 customTrajectory.js

import { h } from "@logicflow/core";

// 定义一些常量,保持与最终边的样式一致
const STROKE_COLOR = "#2961F7"; // 引导线颜色
const STROKE_WIDTH = 1;

/**
 * @name 自定义连线轨迹
 * @param {object} params 轨迹参数对象
 * @param {{x:number,y:number}} params.sourcePoint 起点坐标
 * @param {{x:number,y:number}} params.targetPoint 终点坐标(即鼠标当前位置)
 * @returns {*} Preact 虚拟节点
 */
export default function customTrajectory({ sourcePoint, targetPoint, ...edgeStyle }) {
  const { x: startX, y: startY } = sourcePoint;
  const { x: endX, y: endY } = targetPoint;
  
  // 1. 计算水平和垂直方向的距离
  const dx = Math.abs(endX - startX);
  const dy = Math.abs(endY - startY);
  
  // 2. 计算控制点偏移量
  // 这里采用“以水平方向为主”的策略,系数 0.6 是经验值,可以让曲线弯得更好看
  const controlOffset = Math.max(dx, dy) * 0.6;
  
  // 3. 生成三次贝塞尔曲线的路径(C 指令:C 控制点1.x,控制点1.y 控制点2.x,控制点2.y 终点.x,终点.y)
  let d = "";
  
  if (endX > startX && endY > startY) {
    // 右下方向:控制点1 右上,控制点2 左下
    d = `M ${startX},${startY} C ${startX + controlOffset},${startY} ${endX - controlOffset},${endY} ${endX},${endY}`;
  } else if (endX > startX && endY < startY) {
    // 右上方向:控制点1 右下,控制点2 左上
    d = `M ${startX},${startY} C ${startX + controlOffset},${startY} ${endX - controlOffset},${endY} ${endX},${endY}`;
  } else if (endX < startX && endY > startY) {
    // 左下方向:控制点1 左上,控制点2 右下
    d = `M ${startX},${startY} C ${startX - controlOffset},${startY} ${endX + controlOffset},${endY} ${endX},${endY}`;
  } else {
    // 左上方向:控制点1 左下,控制点2 右上
    d = `M ${startX},${startY} C ${startX - controlOffset},${startY} ${endX + controlOffset},${endY} ${endX},${endY}`;
  }

  // 4. 返回 SVG Path 元素
  return h(
    "path", 
    {
      d,
      fill: "none",
      pointerEvents: "none", // 极其重要!防止引导线阻挡鼠标事件
      ...edgeStyle,
      strokeDasharray: undefined, // 小编的项目设计不需要虚线,所以清除掉,你可以根据自己需要
      stroke: STROKE_COLOR, 
      strokeWidth: STROKE_WIDTH, 
    },
  );
}

注意🔉:这里的控制点计算逻辑(controlOffset)最好与你的边里的逻辑保持一致,这样松手的一瞬间,线条才不会有奇怪的 "跳动"。

这里如此复杂的计算是小编自己写的?那么牛?🐮

当然不是,这里小编也是借助了AI的协助。这要是放在几年前,这种交互效果直接就砍掉,实在太费劲了,现在不一样了,你让AI帮助你写,只要你提示词写得足够清楚,AI分分钟帮你解决,你只要调调参数,验收结果就行了。

第3️⃣步:注册配置

最后,在初始化 LogicFlow 时,将这个函数传给 customTrajectory 选项。

import LogicFlow from "@logicflow/core";
import customTrajectory from "./customTrajectory";

const lf = new LogicFlow({
  container: document.querySelector("#container"),
  // ... 其他配置
  
  // 注册自定义轨迹
  customTrajectory: customTrajectory,
  
  // 确保最终生成的边也是贝塞尔曲线
  edgeGenerator: () => "bezierCurveEdge", 
});

总结

通过简单的数学计算和 VDOM 渲染,咱们成功将 LogicFlow 的连线轨迹从 "工业风" 的直线升级成了 "艺术风" 的曲线。😋

希望这个小技巧能让你的流程图编辑体验更加出色!🌟





至此,本篇文章就写完啦,撒花撒花。

image.png

希望本文对你有所帮助,如有任何疑问,期待你的留言哦。
老样子,点赞+评论=你会了,收藏=你精通了。

昨天以前首页

LogicFlow 交互新体验:让锚点"活"起来,鼠标跟随动效实战!🧲

作者 橙某人
2025年12月29日 18:25

写在开头

Hey Juejin.cn community! 😀

今是2025年12月28日,距离上一次写文章,已经过去了近两个月的时间。这段时间公司业务实在繁忙,两个月十个周末里有四个都贡献给了加班,就连平日里的工作日也被紧凑的任务填满,忙得几乎脚不沾地。😵

好在一番埋头苦干后,总算能稍稍喘口气了。昨天,小编去爬了广州的南香山🌄,本以为是一座平平无奇的"小山"(低于500米海拔的山,小编基本能无压力速通,嘿嘿),想不到还有惊喜,上山的路是规整的盘山公路,沿着公路一路向上,大半个小时就登顶了;下山时,我们选了一条更野趣的原始小径,有密林、有陡坡,走起来比公路有意思多了,当然,这条路线是有前人走过的,我们跟着网友分享的轨迹,再对照着树上绑着的小红带指路,一路有惊无险地顺利下了山。💯 难受的是,我们得打车回山的另一边拿车😅,但整体来说,这次爬山的体验整体很愉快~

ad2235428be6eb20735aae76471b9532.jpgca17f1c56d240ea424322b74dadcd4b0.jpg

言归正传,最近基于 LogicFlow 开发流程图功能时,做了个自定义锚点的 "吸附" 效果:鼠标靠近节点时,锚点会自动弹出并灵动跟随鼠标移动,这个小效果挺有趣的,分享给大家参考,效果如下,请诸君按需食用哈。

122901.gif

需求背景

LogicFlow 中,锚点是静态的,固定在节点的上下左右四个位置上,这就导致了两个问题:

  1. 视觉干扰:如果一直显示锚点,画面会显得很乱。
  2. 交互困难:用户必须精确点击到锚点才能开始连线,容错率低。

其实...就是产品经理要求要炫酷一点😣,要我说静态的挺好,直观简单。

我们想要的效果是:

  • 平时隐藏锚点,保持界面整洁。
  • 鼠标移入节点区域时,显示锚点。
  • 重点来了🎯:当鼠标在节点附近移动时,锚点应该像有磁力一样,自动吸附到离鼠标最近的位置(或者跟随鼠标在一定范围内移动),让连线变得随手可得。

具体实现

要实现这个功能,我们需要深入 LogicFlow 的自定义机制

这次主要围绕到两个文件:

  • customNode.js: 自定义节点,用于集成我们写好的超级锚点。
  • customAnchor.js: 核心逻辑,实现锚点的渲染和鼠标跟随逻辑。

第1️⃣步:自定义锚点组件

首先,我们需要创建一个自定义的锚点渲染函数。这个函数会返回一个 SVG 元素(这里用 LogicFlow内置的 h 函数来创建),并且包含复杂的交互逻辑。

为什么要返回一个 SVG 元素?

这得说到 LogicFlow 的底层技术选型问题了,可以看看这篇文章:传送门

它的核心思想是:创建一个较大的透明容器(container),用来捕获鼠标事件。在这个容器内,我们放一个"小球"(ballGroup),这个小球就是我们看到的锚点。

// customAnchor.js
import { h } from "@logicflow/core";

// 定义一些常量,方便调整手感
const CONTAINER_WIDTH = 72;  // 感应区域宽度
const CONTAINER_HEIGHT = 80; // 感应区域高度
const BALL_SIZE = 20;        // 锚点小球大小

/**
 * @name 创建复杂动效锚点
 * @param {Object} params 参数对象
 * @returns {any} LogicFlow 可用的锚点渲染形状
 */
export function createCustomAnchor(params) {
  const { x, y, side, id, nodeModel, graphModel } = params || {};
  
  // 依据左右两侧计算容器左上角 (小编的业务中最多仅只有左右两个锚点)
  const halfW = CONTAINER_WIDTH / 2;
  // 如果是左侧锚点,容器应该往左偏;右侧同理(可根据自己需求调整,小编的业务是同一时间仅需展示一边的锚点即可)
  const offsetX = side === "left" ? -halfW : halfW;
  
  // 计算透明容器在画布上的绝对坐标
  const containerX = x + offsetX - CONTAINER_WIDTH / 2;
  const containerY = y - CONTAINER_HEIGHT / 2;

  // DOM 引用,用于后续直接操作 DOM 提升性能
  let containerRef = null;
  let ballGroupRef = null;

  // 核心逻辑:鼠标移动时,更新小球的位置
  function handleMouseMove(ev) {
    if (!containerRef || !ballGroupRef) return;
    
    // 获取容器相对于视口的位置
    const rect = containerRef.getBoundingClientRect();
    
    // 获取鼠标在画布上的位置(这里需要处理一下浏览器兼容性,简单起见用 clientX/Y)
    const clientX = ev.clientX;
    const clientY = ev.clientY;
    
    // 计算鼠标相对于容器左上角的偏移量
    let relX = clientX - rect.left; 
    let relY = clientY - rect.top;
    
    // 关键点:限制小球在容器内移动,防止跑出感应区
    relX = Math.max(0, Math.min(CONTAINER_WIDTH, relX));
    relY = Math.max(0, Math.min(CONTAINER_HEIGHT, relY));
    
    // 使用 setAttribute 直接更新 transform,性能最好
    ballGroupRef.setAttribute("transform", `translate(${containerX + relX}, ${containerY + relY})`);
  }
  
  // 鼠标移入:变色 + 激活动画
  function handleMouseEnter() {
    if (!ballGroupRef) return;
    ballGroupRef.style.transition = "transform 140ms ease";
    // 这里可以改变颜色,例如 ballGroupRef.style.color = 'red';
  }

  // 鼠标移出:复位
  function handleMouseLeave() {
    if (!ballGroupRef) return;
    // 鼠标离开时,平滑回到容器中心
    ballGroupRef.style.transition = "transform 160ms ease, opacity 320ms ease";
    
    // 计算中心位置
    const centerX = containerX + CONTAINER_WIDTH / 2;
    const centerY = containerY + CONTAINER_HEIGHT / 2;
    ballGroupRef.setAttribute("transform", `translate(${centerX}, ${centerY})`);
  }

  return h("g", {}, [
    // 1. 透明容器:用于扩大感应区域,这就是“吸附”的秘密
    h("rect", {
      x: containerX,
      y: containerY,
      width: CONTAINER_WIDTH,
      height: CONTAINER_HEIGHT,
      fill: "transparent", // 必须是透明但存在的
      cursor: "crosshair",
      onMouseEnter: handleMouseEnter,
      onMouseMove: handleMouseMove, // 绑定移动事件
      onMouseLeave: handleMouseLeave,
      // ... 绑定其他事件 ...
    }),
    
    // 2. 实际显示的锚点(小球)
    h("g", {
        // 初始位置居中
        transform: `translate(${containerX + CONTAINER_WIDTH / 2}, ${containerY + CONTAINER_HEIGHT / 2})`,
        "pointer-events": "none", // 让鼠标事件穿透到下方的 rect 上
        ref: (el) => { ballGroupRef = el; }
      },
      [ 
        // 这里画一个圆形和一个加号
        h("circle", { r: BALL_SIZE / 2, stroke: "currentColor", fill: "none" }),
        h("path", { d: "M-5 0 L5 0 M0 -5 L0 5", stroke: "currentColor" })
      ]
    ),
  ]);
}

这里有个小技巧⏰:我们并没有直接改变 SVG 的 cx/cy,而是通过 transform: translate(...) 来移动整个锚点组,这样性能更好,动画也更流畅。同时,pointer-events: none 确保了鼠标事件始终由底层的透明 rect 触发,避免闪烁。

第2️⃣步:在自定义节点中使用

写好了锚点逻辑,接下来要在节点中用起来,咱们需要在自定义节点类中重写 getAnchorShape 方法。

// customNode.js
import { HtmlNode, HtmlNodeModel } from "@logicflow/core";
import { createCustomAnchor } from "./customAnchor";

// 定义节点 View
class CustomNodeView extends HtmlNode {
  /**
   * @name 自定义节点锚点形状
   * @param {object} anchorData 锚点数据
   * @returns {object} 锚点形状对象
   */
  getAnchorShape(anchorData) {
    const { x, y, name, id } = anchorData;
    
    // 简单的业务逻辑:只显示左右两侧的锚点
    const side = name === "left" ? "left" : "right";
    
    // 调用我们刚才写的神器!传入必要的参数
    return createCustomAnchor({
      x,
      y,
      side,
      id,
      nodeModel: this.props.model,
      graphModel: this.props.graphModel,
    });
  }
}

// 定义节点 Model
class CustomNodeModel extends HtmlNodeModel {
  // 定义锚点位置
  getDefaultAnchor() {
    const { id, width, x, y } = this;
    return [
      { x: x - width / 2, y, name: "left", id: `${id}-L` },
      { x: x + width / 2, y, name: "right", id: `${id}-R` },
    ];
  }
}

export default {
  type: "custom-node",
  view: CustomNodeView,
  model: CustomNodeModel,
};

第3️⃣步:记录拖拽状态

在实现“手动连线”之前,我们面临一个关键问题:当我们在目标节点的锚点上松开鼠标时,我们怎么知道连线是从哪里发起的

LogicFlow 的默认行为中,customAnchor 并不知道当前的拖拽上下文。因此,我们需要借助全局状态管理(小编用的是Vue3,所以使用Pinia做的全局数据共享)和 LogicFlow 的事件系统来 "搭桥"。

1. 定义 Store

我们需要一个地方存放“当前正在拖拽的锚点信息”。

// stores/logicFlow.js
import { defineStore } from "pinia";

export const useLogicFlowStore = defineStore("logicFlow", {
  state: () => ({
    draggingInfo: null, // 存储拖拽中的连线信息
    isManualConnected: false, // 标记是否触发了手动连接
  }),
});

2. 监听 LogicFlow 事件

在 LogicFlow 初始化的地方,我们需要监听 anchor:dragstartanchor:dragend 事件,实时更新 Store。

import { useLogicFlowStore } from "@/stores/logicFlow";

export function initEvents(lf) {
  const store = useLogicFlowStore();

  // 锚点开始拖拽:记录源节点和源锚点信息
  lf.on("anchor:dragstart", (data) => {
    store.draggingInfo = data; 
    store.isManualConnected = false;
  });

  // 锚点拖拽结束:清空信息
  lf.on("anchor:dragend", () => {
    store.draggingInfo = null;
  });
}

有了这个铺垫,咱们的自定义锚点就能知道 "谁在连我" 了!😎

第4️⃣步:手动连线逻辑

你可能注意到了,锚点位置是“动”的,但 LogicFlow 的连线计算通常基于固定的锚点坐标。如果我们不做处理,可能会出现连线连不上的情况。(其实肯定是连不上的😂)

所以,我们需要在 handleMouseUp(鼠标抬起)时,手动帮 LogicFlow 建立连线。

// customAnchor.js
import { useLogicFlowStore } from "@/stores/logicFlow";

  // ... 在 createCustomAnchor 内部 ...

  function handleMouseUp() {
    const store = useLogicFlowStore();
    // 获取全局存储的拖拽信息
    const { draggingInfo } = store; 
    
    // 尝试手动建立连接
    if (draggingInfo && graphModel) {
      const sourceNode = draggingInfo.nodeModel;
      const sourceAnchor = draggingInfo.data;
      
      // 1. 基础校验:避免自连
      if (sourceAnchor.id === id) return;

      try {
        // 2. 构造边数据
        // 注意:这里我们把终点 (endPoint) 强制设为当前鼠标/锚点的视觉位置 {x, y}
        // 而不是节点原本定义的静态锚点位置
        const edgeData = {
            type: "bezier", // 贝塞尔曲线
            sourceNodeId: sourceNode.id,
            sourceAnchorId: sourceAnchor.id,
            targetNodeId: nodeModel.id,
            targetAnchorId: id,
            startPoint: { x: sourceAnchor.x, y: sourceAnchor.y },
            endPoint: { x, y }, // <--- ⏰关键!使用当前的动态坐标
        };

        // 3. 核心:手动调用 graphModel.addEdge 添加边
        graphModel.addEdge(edgeData);
      } catch (error) {
        console.error("手动连接失败", error);
      }
    }
  }

这样一来,当用户从一个节点拖拽连线到我们的动态锚点上松开鼠标时,就能精准地建立连接了!不管你的锚点 "跑" 到了哪里,连线都能准确追踪。🎯

总结

通过这次改造,咱们的流程图编辑体验得到了"质"的飞跃体验:

  1. 灵动:锚点不再是死板的钉子,而是会互动的精灵。👻
  2. 高效:增大了鼠标感应区域,用户连线更轻松,无需像素级瞄准。
  3. 美观:平时隐藏,用时显现,保持了画布的整洁。

希望这个方案能给正在使用 LogicFlow 的小伙伴们一些灵感吧!💡





至此,本篇文章就写完啦,撒花撒花。

image.png

希望本文对你有所帮助,如有任何疑问,期待你的留言哦。
老样子,点赞+评论=你会了,收藏=你精通了。

❌
❌