普通视图

发现新文章,点击刷新页面。
今天 — 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

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

昨天 — 2025年12月29日首页

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

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

❌
❌