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]时:
- 迭代到 A → Group X 亮起;
- 迭代到 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(单引用)改为activeGroups(Set<DynamicGroupNodeModel>),支持多组同时高亮。每帧拖拽时:
- 先纯计算出"所有应该活跃的组"(无任何副作用);
- 再做一次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。对比旧版,有两点本质区别:
- 纯计算阶段不做任何副作用:先对每个选中节点独立调用
getTargetGroupForNode,把结果合并进同一个Set。由于Set天然去重且与顺序无关,[A, B]和[B, A]会得到完全相同的结果; - 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:drag与node:dnd:drag的组合事件名。destroy()中成对off注销,保证插件销毁后不残留监听。
此外 onGraphRendered 在整图重建(lf.render/graphDataToModel)时会重置插件侧状态,其中包含this.activeGroups.clear(),避免图数据切换后残留高亮引用。
六、感应区高亮如何渲染(底层联动)
要理解setAllowAppendChild(true)到底做了什么,需要看模型与视图层的联动:
- 模型层:setAllowAppendChild 设置
@observable groupAddable响应式标记。同时 getAddableOutlineStyle 提供感应区描边样式:支持插件选项sensorOutline.stroke/strokeWidth定制,未配置时回退到 DEFAULT_SENSOR_OUTLINE(#feb663、线宽 2),并固定使用strokeDasharray: '4 4'虚线、fill: transparent; - 视图层:getAppendAreaShape 在
groupAddable === true时渲染一个比分组节点外扩 8px + strokeWidth 的虚线矩形,并拼入 getShape 的渲染结果中; - 边界检测语义:
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的用法,影响极小;clearDragTargetHighlight、setActiveGroup是内部方法,签名不变、行为兼容;- drop 逻辑(
onNodeDrop、onSelectionDrop、addNodeToGroup)不做任何修改。
不在本次范围内(明确排除)
设计文档列出了一系列经调研讨论后不纳入本次修复的改动,避免范围蔓延:
| 项目 | 原因 |
|---|---|
| 改为鼠标坐标驱动的感应区检测 | draw.io / React Flow 均不采用,现有 bounds 检测是行业惯例 |
| 多节点 drop 原子性(全部入组或全部不入组) | draw.io / React Flow 均采用各节点独立判断,当前行为符合行业惯例 |
autoResize从isRestrict门控解耦 | 当前为有意设计,有明确注释,不在本次范围 |
| 单节点感应区从"完全在内"改为"中心在内" | 低优先级,独立评估 |
九、验证方式:单元测试 + 回归示例
单元测试
设计文档要求的手动验证点,在 packages/extension/test/dynamic-group/sensor-outline.test.ts 中已有自动化覆盖(测试通过lf.graphModel.eventCenter.emit('node:drag' / 'node:drop' / 'node:mouseup')模拟事件,并断言group.groupAddable与activeGroups集合状态):
- 拖拽经过可入组分组 → 高亮亮起(
groupAddable === true、activeGroups.has(group) === true); - 同组内拖拽后 drop → 高亮清除(
groupAddable === false、activeGroups.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按设计文档的验证清单逐项确认:
- 多选节点拖入同一个分组→ 分组感应区稳定亮起;
- 多选节点分别拖向两个不同分组→ 两个分组同时亮起;
- 多选节点中部分在组内、部分在组外→ 有效的分组亮起,组外节点不影响高亮;
- 单节点拖入分组→ 行为与之前一致。
十、小结
本次修复的本质是把"高亮状态"从单值引用升级为集合 + 两阶段更新:
- 语义上对齐 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),仅供参考