边「+」按钮与插入节点
边上的「+」按钮是 rust-agent-flow 的标志性交互:它把「在已有流程中间插入一个新节点」从「删边 → 建节点 → 连两条边」的繁琐操作,简化为一次点击。本节讲清楚按钮如何定位、如何命中,以及点击后 `insert_node_at_edge` 的完整流程。
按钮的视觉
在每条可见边的中点附近,渲染一个小圆形「+」按钮:
●──────┐
⊕ ← 「+」按钮
│
●──────┘
按钮状态:
| 状态 | 视觉 | |------|------| | 默认 | 半透明圆 + 「+」图标 | | `hovered_plus == Some(eid)` | 不透明 + cursor: pointer + tooltip「插入节点」 | | `AddingNodeFromEdge` 中 | 该边的「+」按钮高亮,浮层已弹出 |
plus_button_at_target:源侧还是目标侧
按钮位置由节点的 `IFlowNode::plus_button_at_target` 决定,它回答一个问题:「+」按钮应该出现在边的哪一端?
pub trait IFlowNode: ... {
/// 决定「+」按钮锚定在源节点还是目标节点侧。
/// 返回 None 表示该边不显示「+」。
fn plus_button_at_target(&self) -> Option<PlusTarget>;
}
pub enum PlusTarget {
Source, // 锚定在源节点出口附近
Target, // 锚定在目标节点入口附近
}
为什么需要这个选择?因为不同节点的语义不同:
| 节点类型 | 推荐 PlusTarget | 原因 | |----------|----------------|------| | `action` | Target | 动作的输出多变,在入口侧插入更直观 | | `condition` | Source | 条件的分支是关注点,在出口侧插入 | | `loop` | Source | 循环体的入口是插入点 | | `agent` | Target | 代理的输入是关注点 |
实际位置计算:
fn plus_button_position(edge: &Edge, target: PlusTarget,
from_pos: PointF, to_pos: PointF) -> PointF {
match target {
PlusTarget::Source => from_pos + (to_pos - from_pos) * 0.3,
PlusTarget::Target => from_pos + (to_pos - from_pos) * 0.7,
}
}
按钮不是放在严格中点,而是放在源侧 30% 或目标侧 70% 处,让按钮「靠近」它语义所属的节点。
render_edge_plus_buttons 的流程
render_edge_plus_buttons
│
├─ 遍历所有可见边
│ │
│ ├─ 取源节点 IFlowNode
│ ├─ 调 plus_button_at_target
│ │ └─ None ──► 跳过该边
│ │
│ ├─ 计算按钮逻辑坐标位置
│ ├─ 变换到屏幕坐标(*scale + offset)
│ ├─ 渲染圆形 + 「+」图标
│ │
│ └─ hovered_plus 匹配?
│ ├─ 是 ──► cursor: pointer + tooltip
│ └─ 否 ──► 默认半透明
│
└─ 返回所有按钮的层
按钮层在节点层之上、tooltip 之下,保证不被节点遮挡。
命中测试
「+」按钮的命中不是「点到圆心」,而是「点到折线距离 ≤ PLUS_HIT_RADIUS」。这把整条边都变成可点击区域,用户不必精确瞄准小圆点:
fn hit_test_edge_plus(&self, logical: PointF) -> Option<EdgeId> {
for edge in self.graph.visible_edges() {
let pts = self.edge_path_points(edge); // 逻辑坐标折线点
if point_to_polyline_dist(logical, &pts) <= PLUS_HIT_RADIUS {
return Some(edge.id);
}
}
None
}
`PLUS_HIT_RADIUS` 是逻辑坐标常量。命中后 `on_mouse_down` 进入 `AddingNodeFromEdge` 状态,anchor 记录屏幕坐标供浮层定位。
insert_node_at_edge 全流程
点击浮层项后,调用 `insert_node_at_edge(edge_id, kind, cx)`。这是 `graph_ops.rs` 的核心操作之一,分四步:
insert_node_at_edge(edge_id, kind, cx)
│
├─ 1. 读原边信息
│ src_node = edge.from
│ dst_node = edge.to
│ edge_type = edge.edge_type
│
├─ 2. 删原边
│ graph.remove_edge(edge_id)
│
├─ 3. 创建新节点
│ schema = registry.schema(kind)
│ data = schema.default_data // schema 提供默认数据
│ size = schema.default_size // schema 提供默认尺寸
│ new_node = Node::new(kind, data, size)
│ graph.add_node(new_node)
│
├─ 4. 连两条新边
│ graph.add_edge(src_node → new_node, edge_type)
│ graph.add_edge(new_node → dst_node, edge_type)
│
├─ 5. 选中新节点
│ self.selected = Some(new_node.id)
│
└─ 6. relayout + cx.notify
// Dagre 重新布局,新节点插入到 src 与 dst 之间
pub fn insert_node_at_edge(&mut self, edge_id: EdgeId, kind: NodeKind, cx: &mut Context<Self>) {
// 1. 读原边
let (src, dst, edge_type) = match self.graph.edge(edge_id) {
Some(e) => (e.from, e.to, e.edge_type),
None => return,
};
// 2. 删原边
self.graph.remove_edge(edge_id);
// 3. 建新节点(用 schema 默认数据 + 默认尺寸)
let schema = self.registry.schema(kind);
let new_node = Node::new(kind, schema.default_data.clone(), schema.default_size);
let new_id = self.graph.add_node(new_node);
// 4. 连两条新边,沿用原边类型
self.graph.add_edge(src, new_id, edge_type);
self.graph.add_edge(new_id, dst, edge_type);
// 5. 选中 + 重布局
self.selected = Some(new_id);
self.relayout();
cx.notify();
}
关键点:新边沿用原边的 `edge_type`,保证视觉一致;新节点用 schema 的 `default_data` 与 `default_size`,无需调用方提供;最后 `relayout` 让 Dagre 把新节点排到 src 与 dst 之间。
为什么不手动定位新节点
有人可能想「新节点放在原边中点位置」省一次 relayout。框架没这么做,原因:
- Dagre 布局会重新排所有节点,手动定位会被覆盖。 - 新节点插入后,邻居节点也需要让位,手动算太复杂。 - relayout 是 O(V+E) 的,对常见规模(几十节点)足够快。
只在 `DraggingNode` 这种「用户明确指定位置」的场景才跳过 relayout。
delete_node 的桥接逻辑
与 `insert_node_at_edge` 对称的是 `delete_node`,它也要处理边:
delete_node(node_id)
│
├─ 收集入边(src → node)与出边(node → dst)
│
├─ 删除节点及其所有边
│
├─ 线性桥接:
│ 对每个 (src, dst) 配对,若不存在重复,则
│ graph.add_edge(src → dst, edge_type)
│
└─ relayout
「线性桥接」假设节点是线性流程中的一个环节:删掉它后,把前后直接连起来。对于多入多出的节点(如 condition),桥接策略会更保守——可能只桥接「单入单出」的情况,避免产生语义错误的边。
handle_node_action 的分派
`handle_node_action(node_id, action, cx)` 是节点操作的统一入口:
pub enum NodeAction {
Delete,
ToggleCollapse,
SetData(Value),
}
pub fn handle_node_action(&mut self, node_id: NodeId, action: NodeAction, cx: &mut Context<Self>) {
match action {
NodeAction::Delete => self.delete_node(node_id, cx),
NodeAction::ToggleCollapse => {
self.graph.node_mut(node_id).collapsed = !self.graph.node(node_id).collapsed;
self.relayout();
cx.notify();
}
NodeAction::SetData(data) => {
self.graph.node_mut(node_id).data = data;
if self.update_node_size_if_changed(node_id) {
self.relayout();
}
cx.notify();
}
}
}
`SetData` 后用 `update_node_size_if_changed` 做单节点优化,仅在尺寸真变化时才触发完整 relayout。
完整的「+」交互时序
用户点边「+」
│
▼
on_mouse_down
├─ hit_test → EdgePlusButton(edge_id)
└─ state = AddingNodeFromEdge { edge_id, anchor: screen }
│
▼
render
└─ render_node_picker (定位 = anchor + (10,10))
│
▼
用户点浮层项 "action"
│
▼
on_picker_select(action)
├─ insert_node_at_edge(edge_id, action, cx)
│ ├─ 读原边 → 删原边 → 建节点 → 连两新边
│ └─ selected = new_id, relayout, notify
└─ state = Idle
│
▼
render
└─ 新节点已插入,被选中高亮,浮层消失
小结
边「+」按钮由 `IFlowNode::plus_button_at_target` 决定锚定在源侧或目标侧,位置取边路径的 30%/70% 处。命中用「点到折线距离」而非点到圆心,提升可用性。点击后 `insert_node_at_edge` 走「读原边 → 删原边 → 建新节点(schema 默认数据)→ 连两条新边 → 选中 → relayout」六步。与之对称的 `delete_node` 用「线性桥接」保持流程连通。这套设计让边的「+」成为节点编辑器最顺手的扩展入口。
下一章:[回到目录](../INDEX.md)