news 2026/9/15 11:44:04

LogicFlow dynamic-group 多选拖拽感应区高亮修复:activeGroups 多组高亮的设计与实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
LogicFlow dynamic-group 多选拖拽感应区高亮修复:activeGroups 多组高亮的设计与实现

LogicFlow dynamic-group 多选拖拽感应区高亮修复:activeGroups 多组高亮的设计与实现

【免费下载链接】LogicFlowA flow chart editing framework focus on business customization. 专注于业务自定义的流程图编辑框架,支持实现脑图、ER图、UML、工作流等各种图编辑场景。项目地址: https://gitcode.com/GitHub_Trending/lo/LogicFlow

本文围绕 LogicFlow 仓库中的设计文档 2026-07-03-dynamic-group-multi-select-sensor-design.md,完整还原dynamic-group插件"多选节点拖拽进组时感应区高亮随机失效"问题的根因分析、设计方案与落地实现。读完本文,你将掌握:为何forEach顺序会导致高亮随机,如何用Set集合 +「纯计算 + diff 更新」两阶段模式支持多分组同时高亮,以及该修复在 插件源码 与 单元测试 中的真实落地形态。

一、背景:多选拖拽时感应区高亮"随机消失"

LogicFlow 的dynamic-group插件提供了一种拖拽感应区(sensor outline)交互:当用户拖拽节点进入某个分组节点的边界范围时,该分组外围会亮起虚线描边(默认橙色#feb663),提示"松手后此节点将入组"。

在单节点拖拽场景下该交互是稳定的;但多节点框选拖拽selection整体拖动)时,感应区高亮出现不确定性 bug:

  • 拖拽多个选中节点时,究竟高亮哪个分组,取决于forEach对选中节点的迭代顺序,结果是随机的;
  • 更糟的是,某些组合下任何分组都不会高亮,但实际 drop 后节点又确实入了组——视觉提示与最终行为不一致,用户无法预判拖拽结果。

二、问题根因:onSelectionDrag的顺序覆盖

设计文档指出,根因在onSelectionDrag对每个选中节点依次调用setActiveGroup

onSelectionDrag = () => { const { nodes: selectedNodes } = this.lf.graphModel.getSelectElements() selectedNodes.forEach((node) => { this.setActiveGroup(node) // 每次调用都会先关掉当前 activeGroup,再尝试设新的 }) }

setActiveGroup内部的处理模式是:先调用this.activeGroup.setAllowAppendChild(false)关掉当前高亮,再决定是否点亮新的目标组。由于它保存的是单一引用(activeGroup?),多次调用时后一个节点的处理结果会覆盖前一个节点。

典型失败场景

选中节点 A(位于 Group X 内)和节点 B(不在任何组内)同时拖拽,迭代顺序为[A, B]时:

  1. 迭代到 A → Group X 亮起;
  2. 迭代到 B → 调setAllowAppendChild(false)关掉 Group X,随后发现targetGroup = null,提前 return;

最终 Group X 熄灭,用户看不到任何高亮,但 drop 后 A 会正确入组。

反之若迭代顺序为[B, A],Group X 则会亮起。这正是"结果随机"的来源。

三、行业方案调研:多组同时高亮是更合理语义

设计文档对主流图编辑工具的高亮策略做了对比:

工具高亮方式多组同时高亮
React Flow每个节点独立调getIntersectingNodes,CSS class 叠加
draw.io鼠标光标位置,单容器高亮
LF 当前整个 selection 触发一次,单activeGroup引用❌(且有 bug)

结论:React Flow 的做法最符合"每个节点各自找目标组、结果独立"的语义。即:多选拖拽时,如果节点 A 落在 Group X 内、节点 B 落在 Group Y 内,那么 X 和 Y 应当同时亮起,而不是互相覆盖。

四、设计方案:activeGroup单引用 →activeGroups集合

核心思路

activeGroup(单引用)改为activeGroupsSet<DynamicGroupNodeModel>),支持多组同时高亮。每帧拖拽时:

  1. 先纯计算出"所有应该活跃的组"(无任何副作用);
  2. 再做一次diff 更新视觉状态(只操作发生变化的组),消除迭代顺序的影响。

数据结构变更

// Before activeGroup?: DynamicGroupNodeModel // After activeGroups: Set<DynamicGroupNodeModel> = new Set()

该结构变更已落地在 index.ts 的插件类成员 中,注释明确写着"激活态的 group 节点(支持多组同时高亮)"。

五、核心实现解析(源码级)

设计文档中的四个改动点,在 packages/extension/src/dynamic-group/index.ts 中均已实现。下面逐一对照。

5.1 新增纯计算辅助方法getTargetGroupForNode

将"计算某节点目标组"的逻辑从setActiveGroup中提取为纯计算、无副作用的辅助方法:

private getTargetGroupForNode( node: LogicFlow.NodeData, ): DynamicGroupNodeModel | undefined { const nodeModel = this.lf.getNodeModelById(node.id) const bounds = nodeModel?.getBounds() if (!nodeModel || !bounds) return undefined const targetGroup = this.getGroupByBounds(bounds, node) if (!targetGroup) return undefined // 分组节点不能把自己设为目标组 if (nodeModel.isGroup && targetGroup.id === node.id) return undefined // 检查分组是否允许插入 if (!targetGroup.isAllowAppendIn(node)) return undefined return targetGroup }

对应实现见 getTargetGroupForNode,与设计稿逐行一致。三个关键判定值得展开:

  • getGroupByBounds:通过边界检测找到节点所在的候选分组。当多个分组重叠时,getGroupByBounds 会遍历候选组、取 zIndex 最高者作为目标组,避免重叠区域归属歧义;
  • nodeModel.isGroup && targetGroup.id === node.id:分组节点不能把自己设为目标组,防止自包含;
  • targetGroup.isAllowAppendIn(node):调用分组模型上的准入规则。默认实现见 model.ts 的 isAllowAppendIn,恒返回true,业务可通过节点properties.isAllowAppendIn或重写模型方法自定义准入逻辑。

5.2clearDragTargetHighlight重写:遍历集合统一熄灭

clearDragTargetHighlight() { for (const group of this.activeGroups) { group.setAllowAppendChild(false) } this.activeGroups.clear() }

对应实现见 clearDragTargetHighlight。它把当前活跃集合里的每个组都关掉高亮,再清空集合——不再像旧实现那样只关一个引用。该方法的调用点覆盖了所有拖拽结束路径:

  • onNodeDrop(单节点 drop);
  • onSelectionDrop(多选 drop);
  • onNodeMouseUp(未 drop 即松开鼠标);
  • onNodeDndAdd(拖放新增节点)。

5.3setActiveGroup重写:单节点路径,语义不变

单节点拖拽(onNodeDrag)仍走此方法,至多一个目标组,语义与修复前一致,但内部改用 Set + diff 更新:

setActiveGroup = (node: LogicFlow.NodeData) => { const targetGroup = this.getTargetGroupForNode(node) const next = new Set<DynamicGroupNodeModel>() if (targetGroup) next.add(targetGroup) // diff 更新:只变动有变化的组 for (const group of this.activeGroups) { if (!next.has(group)) group.setAllowAppendChild(false) } for (const group of next) { if (!this.activeGroups.has(group)) group.setAllowAppendChild(true) } this.activeGroups = next }

对应实现见 setActiveGroup,由 onNodeDrag 在node:drag/node:dnd:drag事件中触发。

5.4onSelectionDrag重写:多节点路径,修复核心 bug

这是本次修复的核心。改成两阶段处理

onSelectionDrag = () => { const { nodes: selectedNodes } = this.lf.graphModel.getSelectElements() // 1. 纯计算:每个节点独立找目标组,结果合并为 Set const next = new Set<DynamicGroupNodeModel>() selectedNodes.forEach((node) => { const targetGroup = this.getTargetGroupForNode(node) if (targetGroup) next.add(targetGroup) }) // 2. diff 更新:只操作有变化的组,避免无谓视觉抖动 for (const group of this.activeGroups) { if (!next.has(group)) group.setAllowAppendChild(false) } for (const group of next) { if (!this.activeGroups.has(group)) group.setAllowAppendChild(true) } this.activeGroups = next }

对应实现见 onSelectionDrag。对比旧版,有两点本质区别:

  1. 纯计算阶段不做任何副作用:先对每个选中节点独立调用getTargetGroupForNode,把结果合并进同一个Set。由于Set天然去重且与顺序无关,[A, B][B, A]会得到完全相同的结果;
  2. diff 更新阶段只操作发生变化的组:上一帧已亮且本帧仍亮的组不重复操作(避免无谓视觉抖动);本帧新增的组才点亮,本帧消失的组才熄灭。

5.5 事件注册与生命周期

上述方法通过 init() 中的事件注册 接入 LogicFlow 事件体系:

lf.on(NODE_DRAG_EVENTS, this.onNodeDrag) // node:drag + node:dnd:drag lf.on(EventType.SELECTION_DRAG, this.onSelectionDrag) lf.on(EventType.SELECTION_DROP, this.onSelectionDrop) lf.on(EventType.NODE_DROP, this.onNodeDrop) lf.on(EventType.NODE_MOUSEUP, this.onNodeMouseUp)

其中NODE_DRAG_EVENTS定义在 constant/events.ts,为node:dragnode:dnd:drag的组合事件名。destroy()中成对off注销,保证插件销毁后不残留监听。

此外 onGraphRendered 在整图重建(lf.render/graphDataToModel)时会重置插件侧状态,其中包含this.activeGroups.clear(),避免图数据切换后残留高亮引用。

六、感应区高亮如何渲染(底层联动)

要理解setAllowAppendChild(true)到底做了什么,需要看模型与视图层的联动:

  1. 模型层:setAllowAppendChild 设置@observable groupAddable响应式标记。同时 getAddableOutlineStyle 提供感应区描边样式:支持插件选项sensorOutline.stroke/strokeWidth定制,未配置时回退到 DEFAULT_SENSOR_OUTLINE(#feb663、线宽 2),并固定使用strokeDasharray: '4 4'虚线、fill: transparent
  2. 视图层:getAppendAreaShape 在groupAddable === true时渲染一个比分组节点外扩 8px + strokeWidth 的虚线矩形,并拼入 getShape 的渲染结果中;
  3. 边界检测语义getGroupByBounds依赖 utils.ts 的 isBoundsInGroup,判定条件是节点 bounds完全落在分组矩形范围内(minX >= x - width/2等四项比较),即"完全在内"才算命中感应区——这正是设计文档中"bounds 检测是行业惯例"的落地实现。

七、行为对比:修复前 vs 修复后

场景修复前修复后
A、B 都在 Group X 内同时拖拽随机亮 / 不亮Group X 稳定亮起
A 进 GA,B 进 GB随机只亮一个,且可能被消除GA、GB 同时亮起
A 进 GA,B 不在任何组GA 被 B 的迭代消除,不亮GA 亮起 ✓
单节点拖拽正常不变,行为一致
drop 逻辑(addNodeToGroup不涉及不变(独立计算,不依赖 activeGroups)

需要特别强调的是最后一行:drop 逻辑完全不依赖activeGroups。addNodeToGroup 在 drop 时独立地再次调用getGroupByBounds完成入组判定与成员关系变更(含同组内移动保持关系、跨组迁移、触发GROUP_NOT_ALLOWED事件等),高亮只是"预告",入组才是"事实",二者解耦使得本次修复不会波及落盘行为。

八、向后兼容与范围控制

兼容性说明

  • activeGroup属性被移除,改为activeGroups。该属性无公开文档、不在类型导出中,官方示例亦无直接访问plugin.activeGroup的用法,影响极小;
  • clearDragTargetHighlightsetActiveGroup是内部方法,签名不变、行为兼容
  • drop 逻辑(onNodeDroponSelectionDropaddNodeToGroup不做任何修改

不在本次范围内(明确排除)

设计文档列出了一系列经调研讨论后不纳入本次修复的改动,避免范围蔓延:

项目原因
改为鼠标坐标驱动的感应区检测draw.io / React Flow 均不采用,现有 bounds 检测是行业惯例
多节点 drop 原子性(全部入组或全部不入组)draw.io / React Flow 均采用各节点独立判断,当前行为符合行业惯例
autoResizeisRestrict门控解耦当前为有意设计,有明确注释,不在本次范围
单节点感应区从"完全在内"改为"中心在内"低优先级,独立评估

九、验证方式:单元测试 + 回归示例

单元测试

设计文档要求的手动验证点,在 packages/extension/test/dynamic-group/sensor-outline.test.ts 中已有自动化覆盖(测试通过lf.graphModel.eventCenter.emit('node:drag' / 'node:drop' / 'node:mouseup')模拟事件,并断言group.groupAddableactiveGroups集合状态):

  • 拖拽经过可入组分组 → 高亮亮起(groupAddable === trueactiveGroups.has(group) === true);
  • 同组内拖拽后 drop → 高亮清除(groupAddable === falseactiveGroups.size === 0);
  • 未 drop 即node:mouseup→ 高亮清除;
  • 拖出分组边界 → 高亮清除;
  • sensorOutline插件选项自定义描边 →getAddableOutlineStyle()返回定制值;
  • 未配置插件选项 → 回退DEFAULT_SENSOR_OUTLINE

这些测试即设计文档「验证方式」中单节点路径的自动化版本;多选路径(多组同时高亮)可通过下方回归示例人工验证。

回归示例

仓库提供了专门的回归工作台 examples/dynamic-group-regression,用于修复前后人工对比。启动方式:

# 仓库根目录 pnpm install # prepare 会自动 build:all # 或仅开发所需的最小构建 pnpm run build cd examples/dynamic-group-regression pnpm dev

按设计文档的验证清单逐项确认:

  1. 多选节点拖入同一个分组→ 分组感应区稳定亮起;
  2. 多选节点分别拖向两个不同分组→ 两个分组同时亮起;
  3. 多选节点中部分在组内、部分在组外→ 有效的分组亮起,组外节点不影响高亮;
  4. 单节点拖入分组→ 行为与之前一致。

十、小结

本次修复的本质是把"高亮状态"从单值引用升级为集合 + 两阶段更新

  • 语义上对齐 React Flow——每个节点独立判定目标组,结果用Set合并,天然去重、与顺序无关;
  • 性能上通过 diff 更新,每帧只对状态变化的组执行setAllowAppendChild,避免重复操作引发的视觉抖动;
  • 边界上严格解耦"高亮预告"与"drop 落盘"两条链路,保证修复高亮不影响既有的入组语义与成员关系管理。

该设计文档为 docs/superpowers/specs/2026-07-03-dynamic-group-multi-select-sensor-design.md,核心实现集中在 packages/extension/src/dynamic-group/index.ts,配合模型层 model.ts、视图层 node.ts、几何判定 utils.ts 与测试 sensor-outline.test.ts 即可完整理解该交互链路的全貌。

【免费下载链接】LogicFlowA flow chart editing framework focus on business customization. 专注于业务自定义的流程图编辑框架,支持实现脑图、ER图、UML、工作流等各种图编辑场景。项目地址: https://gitcode.com/GitHub_Trending/lo/LogicFlow

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/15 11:43:46

斯威士兰口岸强制型式审批新政正式落地

政策背景2026 年 4 月&#xff0c;斯威士兰通信委员会&#xff08;ESCCOM&#xff09;发布第 4/2026 号一般通知&#xff0c;宣布自 2026 年 4 月 13 日&#xff08;周一&#xff09;起&#xff0c;对所有进口或在斯威士兰境内使用的电子通信设备&#xff0c;强制要求提供型式审…

作者头像 李华
网站建设 2026/9/15 11:43:34

私域运营效率翻倍:微信多号聚合与自动运营全攻略

做私域运营做到第三年&#xff0c;我终于承认一个事实&#xff1a;让我每天加班到深夜的&#xff0c;不是客户太难搞&#xff0c;而是我自己太“忙”了。工位上三台手机排成一排&#xff0c;微信小红点从早闪到晚&#xff0c;我要么在切换账号&#xff0c;要么在复制粘贴同一段…

作者头像 李华
网站建设 2026/9/15 11:43:07

OmniGet aria2调优指南:大文件下载速度提升的5个关键参数

OmniGet aria2调优指南&#xff1a;大文件下载速度提升的5个关键参数 【免费下载链接】omniget Download Udemy and Hotmart courses, YouTube videos, music and books — 1,800 sites, no terminal. Free open-source desktop app for Windows, macOS and Linux, with a buil…

作者头像 李华
网站建设 2026/9/15 11:42:28

Upcoming Release

Upcoming Release 【免费下载链接】kedro Kedro is a toolbox for production-ready data science. It uses software engineering best practices to help you create data engineering and data science pipelines that are reproducible, maintainable, and modular. 项目…

作者头像 李华
网站建设 2026/9/15 11:42:17

主线程性能优化:从卡顿定位到高效减负方案

打开Chrome DevTools的Performance面板录一段操作回放&#xff0c;看着火焰图里一长串密密麻麻的Task&#xff0c;再切回页面体验一下点击按钮后的延迟、滚动时的掉帧&#xff0c;你会很直观地理解“PPT感”是怎么来的。这几年大家都在卷工程化、卷微前端、卷AI辅助编程&#x…

作者头像 李华