LogicFlow 交互新体验:让锚点"活"起来,鼠标跟随动效实战!🧲
写在开头
Hey Juejin.cn community! 😀
今是2025年12月28日,距离上一次写文章,已经过去了近两个月的时间。这段时间公司业务实在繁忙,两个月十个周末里有四个都贡献给了加班,就连平日里的工作日也被紧凑的任务填满,忙得几乎脚不沾地。😵
好在一番埋头苦干后,总算能稍稍喘口气了。昨天,小编去爬了广州的南香山🌄,本以为是一座平平无奇的"小山"(低于500米海拔的山,小编基本能无压力速通,嘿嘿),想不到还有惊喜,上山的路是规整的盘山公路,沿着公路一路向上,大半个小时就登顶了;下山时,我们选了一条更野趣的原始小径,有密林、有陡坡,走起来比公路有意思多了,当然,这条路线是有前人走过的,我们跟着网友分享的轨迹,再对照着树上绑着的小红带指路,一路有惊无险地顺利下了山。💯 难受的是,我们得打车回山的另一边拿车😅,但整体来说,这次爬山的体验整体很愉快~
![]()
![]()
言归正传,最近基于 LogicFlow 开发流程图功能时,做了个自定义锚点的 "吸附" 效果:鼠标靠近节点时,锚点会自动弹出并灵动跟随鼠标移动,这个小效果挺有趣的,分享给大家参考,效果如下,请诸君按需食用哈。
![]()
需求背景
在 LogicFlow 中,锚点是静态的,固定在节点的上下左右四个位置上,这就导致了两个问题:
- 视觉干扰:如果一直显示锚点,画面会显得很乱。
- 交互困难:用户必须精确点击到锚点才能开始连线,容错率低。
其实...就是产品经理要求要炫酷一点😣,要我说静态的挺好,直观简单。
我们想要的效果是:
- 平时隐藏锚点,保持界面整洁。
- 鼠标移入节点区域时,显示锚点。
- 重点来了🎯:当鼠标在节点附近移动时,锚点应该像有磁力一样,自动吸附到离鼠标最近的位置(或者跟随鼠标在一定范围内移动),让连线变得随手可得。
具体实现
要实现这个功能,我们需要深入 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:dragstart 和 anchor: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);
}
}
}
这样一来,当用户从一个节点拖拽连线到我们的动态锚点上松开鼠标时,就能精准地建立连接了!不管你的锚点 "跑" 到了哪里,连线都能准确追踪。🎯
总结
通过这次改造,咱们的流程图编辑体验得到了"质"的飞跃体验:
- 灵动:锚点不再是死板的钉子,而是会互动的精灵。👻
- 高效:增大了鼠标感应区域,用户连线更轻松,无需像素级瞄准。
- 美观:平时隐藏,用时显现,保持了画布的整洁。
希望这个方案能给正在使用 LogicFlow 的小伙伴们一些灵感吧!💡
至此,本篇文章就写完啦,撒花撒花。
![]()
希望本文对你有所帮助,如有任何疑问,期待你的留言哦。
老样子,点赞+评论=你会了,收藏=你精通了。