Skip to main content

dioxus_flow/
node.rs

1//! Node rendering: the positioned wrapper, connection handles, and the
2//! default node view.
3
4use dioxus::prelude::*;
5
6use crate::state::{ConnectionState, DragState, FlowCore, Interaction};
7use crate::types::{HandleGeom, HandleKey, HandleKind, Id, Node, Point, Side, Size};
8
9/// Context available to custom node views.
10#[derive(Clone, PartialEq)]
11pub struct NodeViewCtx<T: Clone + PartialEq + 'static> {
12    pub node: Node<T>,
13    /// Whether this node is currently being dragged.
14    pub dragging: bool,
15}
16
17/// Identifies the node that a [`Handle`] belongs to. Provided by the node
18/// wrapper, consumed by handles rendered anywhere inside the node's view.
19#[derive(Clone, PartialEq)]
20pub(crate) struct NodeScope {
21    pub id: Id,
22}
23
24fn client_point(coords: dioxus::html::geometry::ClientPoint) -> Point {
25    Point::new(coords.x, coords.y)
26}
27
28/// The positioned, draggable wrapper around each node's content.
29#[component]
30pub(crate) fn NodeItem<T: Clone + PartialEq + 'static>(
31    nodes: Signal<Vec<Node<T>>>,
32    node: Node<T>,
33    node_view: Option<Callback<NodeViewCtx<T>, Element>>,
34    on_node_click: Option<EventHandler<Id>>,
35) -> Element {
36    let core = use_context::<FlowCore>();
37    use_context_provider(|| NodeScope {
38        id: node.id.clone(),
39    });
40
41    // Subscribes only to gesture start/end, not per-frame drag state.
42    let dragging = *core.interaction.read() == Interaction::DragNode
43        && core.drag.peek().grabs.iter().any(|(id, _)| id == &node.id);
44
45    let class = format!(
46        "df-node{}{}{}",
47        if node.selected { " df-selected" } else { "" },
48        if dragging { " df-dragging" } else { "" },
49        node.class
50            .as_deref()
51            .map(|c| format!(" {c}"))
52            .unwrap_or_default(),
53    );
54    let size_style = node
55        .size
56        .map(|s| format!("width:{}px;height:{}px;", s.width, s.height))
57        .unwrap_or_default();
58    let style = format!(
59        "transform:translate({}px,{}px);z-index:{};{}{}",
60        node.position.x,
61        node.position.y,
62        if dragging || node.selected { 1000 } else { 0 },
63        size_style,
64        node.style.as_deref().unwrap_or_default(),
65    );
66
67    let id_for_drag = node.id.clone();
68    let id_for_resize = node.id.clone();
69    let id_for_keys = node.id.clone();
70    let draggable = node.draggable;
71    let selectable = node.selectable;
72    let label = node.label.clone();
73
74    let content = match node_view {
75        Some(view) => view.call(NodeViewCtx {
76            node: node.clone(),
77            dragging,
78        }),
79        None => rsx! {
80            DefaultNodeView::<T> {
81                ctx: NodeViewCtx { node: node.clone(), dragging },
82            }
83        },
84    };
85
86    rsx! {
87        div {
88            class,
89            style,
90            tabindex: "0",
91            role: "group",
92            aria_label: "{label}",
93            onpointerdown: move |evt| {
94                node_pointer_down(core, nodes, &id_for_drag, draggable, selectable, &on_node_click, evt)
95            },
96            onkeydown: move |evt| {
97                node_key_down(core, nodes, &id_for_keys, draggable, selectable, evt)
98            },
99            onresize: move |evt| {
100                if let Ok(size) = evt.data().get_border_box_size() {
101                    store_measured(core, nodes, &id_for_resize, Size::new(size.width, size.height));
102                }
103            },
104            {content}
105        }
106    }
107}
108
109/// Keyboard access to the pointer gestures: Enter/Space (toggle-)selects,
110/// arrows nudge the focused node — or the whole selection when it belongs to
111/// one — by 10px (1px with Shift). Delete bubbles up to the flow's handler.
112fn node_key_down<T: Clone + PartialEq + 'static>(
113    core: FlowCore,
114    mut nodes: Signal<Vec<Node<T>>>,
115    id: &Id,
116    draggable: bool,
117    selectable: bool,
118    evt: Event<KeyboardData>,
119) {
120    let step = if evt.modifiers().shift() { 1.0 } else { 10.0 };
121    let delta = match evt.key() {
122        Key::ArrowUp => Point::new(0.0, -step),
123        Key::ArrowDown => Point::new(0.0, step),
124        Key::ArrowLeft => Point::new(-step, 0.0),
125        Key::ArrowRight => Point::new(step, 0.0),
126        Key::Enter => {
127            evt.prevent_default();
128            select_node(core, nodes, id, selectable, evt.modifiers().shift());
129            return;
130        }
131        Key::Character(c) if c == " " => {
132            evt.prevent_default();
133            select_node(core, nodes, id, selectable, evt.modifiers().shift());
134            return;
135        }
136        _ => return,
137    };
138    if !(draggable && core.config.peek().nodes_draggable) {
139        return;
140    }
141    evt.prevent_default();
142    core.cancel_animations();
143    let focused_selected = nodes
144        .peek()
145        .iter()
146        .find(|n| &n.id == id)
147        .map(|n| n.selected)
148        .unwrap_or(false);
149    nodes.with_mut(|nodes| {
150        for node in nodes.iter_mut() {
151            let moves = if focused_selected {
152                node.selected && node.draggable
153            } else {
154                &node.id == id
155            };
156            if moves {
157                node.position = node.position + delta;
158            }
159        }
160    });
161}
162
163fn select_node<T: Clone + PartialEq + 'static>(
164    core: FlowCore,
165    mut nodes: Signal<Vec<Node<T>>>,
166    id: &Id,
167    selectable: bool,
168    toggle: bool,
169) {
170    if !selectable {
171        return;
172    }
173    if toggle {
174        nodes.with_mut(|nodes| {
175            if let Some(node) = nodes.iter_mut().find(|n| &n.id == id) {
176                node.selected = !node.selected;
177            }
178        });
179    } else {
180        nodes.with_mut(|nodes| {
181            for node in nodes.iter_mut() {
182                node.selected = &node.id == id;
183            }
184        });
185        crate::flow::deselect_edges(core.edges);
186    }
187}
188
189/// Record a node's measured size. Sizes are batched into one `nodes` write
190/// per frame: resize events arrive once per node, and letting each of them
191/// re-render the graph makes mounting N nodes O(N²).
192fn store_measured<T: Clone + PartialEq + 'static>(
193    core: FlowCore,
194    mut nodes: Signal<Vec<Node<T>>>,
195    id: &Id,
196    size: Size,
197) {
198    let changed = nodes
199        .peek()
200        .iter()
201        .find(|n| &n.id == id)
202        .map(|n| match n.measured {
203            Some(m) => (m.width - size.width).abs() > 0.5 || (m.height - size.height).abs() > 0.5,
204            None => true,
205        })
206        .unwrap_or(false);
207    if !changed {
208        return;
209    }
210    core.pending_sizes.clone().write().push((id.clone(), size));
211    let mut queued = core.size_flush_queued;
212    if *queued.peek() {
213        return;
214    }
215    queued.set(true);
216    // Outlives the node that scheduled it, so a mid-flight deletion can't
217    // strand `size_flush_queued`; the flow itself may also unmount before
218    // this runs, so every access is fallible.
219    dioxus::core::spawn_forever(async move {
220        crate::anim::sleep_ms(0).await;
221        let mut queued_signal = core.size_flush_queued;
222        let Ok(mut queued) = queued_signal.try_write() else {
223            return;
224        };
225        *queued = false;
226        drop(queued);
227        let mut pending_signal = core.pending_sizes;
228        let Ok(mut pending) = pending_signal.try_write() else {
229            return;
230        };
231        let sizes = std::mem::take(&mut *pending);
232        drop(pending);
233        if sizes.is_empty() {
234            return;
235        }
236        let map: std::collections::HashMap<Id, Size> = sizes.into_iter().collect();
237        let Ok(mut nodes) = nodes.try_write() else {
238            return;
239        };
240        for node in nodes.iter_mut() {
241            if let Some(size) = map.get(&node.id) {
242                node.measured = Some(*size);
243            }
244        }
245    });
246}
247
248#[allow(clippy::too_many_arguments)]
249fn node_pointer_down<T: Clone + PartialEq + 'static>(
250    core: FlowCore,
251    mut nodes: Signal<Vec<Node<T>>>,
252    id: &Id,
253    draggable: bool,
254    selectable: bool,
255    on_node_click: &Option<EventHandler<Id>>,
256    evt: Event<PointerData>,
257) {
258    // A handle inside this node may have claimed the pointer already.
259    if *core.interaction.peek() != Interaction::None {
260        return;
261    }
262    core.cancel_animations();
263    let shift = evt.modifiers().shift();
264
265    if selectable {
266        let already_selected = nodes
267            .peek()
268            .iter()
269            .find(|n| &n.id == id)
270            .map(|n| n.selected)
271            .unwrap_or(false);
272        if shift {
273            nodes.with_mut(|nodes| {
274                if let Some(node) = nodes.iter_mut().find(|n| &n.id == id) {
275                    node.selected = !node.selected;
276                }
277            });
278        } else if !already_selected {
279            nodes.with_mut(|nodes| {
280                for node in nodes.iter_mut() {
281                    node.selected = &node.id == id;
282                }
283            });
284            crate::flow::deselect_edges(core.edges);
285        }
286    }
287    if let Some(handler) = on_node_click {
288        handler.call(id.clone());
289    }
290
291    let config = *core.config.peek();
292    if !(draggable && config.nodes_draggable) {
293        core.interaction.clone().set(Interaction::Pressed);
294        return;
295    }
296
297    let cursor_flow = core.client_to_flow(client_point(evt.client_coordinates()));
298    // Drag every selected draggable node as a group; always include the
299    // pressed node itself.
300    let grabs: Vec<(Id, Point)> = nodes
301        .peek()
302        .iter()
303        .filter(|n| (n.selected && n.draggable) || &n.id == id)
304        .map(|n| (n.id.clone(), cursor_flow - n.position))
305        .collect();
306    let client = client_point(evt.client_coordinates());
307    let mut drag = core.drag;
308    {
309        let mut state = drag.write();
310        *state = DragState {
311            pointer_id: Some(evt.pointer_id()),
312            origin_client: client,
313            last_client: client,
314            moved: false,
315            suppress_click: false,
316            grabs,
317        };
318    }
319    core.interaction.clone().set(Interaction::DragNode);
320}
321
322/// A connection point on a node. Place handles anywhere inside a custom node
323/// view; edges anchor to them and new connections can be dragged out of them.
324#[component]
325pub fn Handle(
326    /// Whether edges start (`Source`) or end (`Target`) here.
327    kind: HandleKind,
328    /// Which side of the node the handle sits on.
329    position: ReadSignal<Side>,
330    /// Optional handle id, referenced by `Edge::source_handle` /
331    /// `Edge::target_handle`. Needed when a node has multiple handles of the
332    /// same kind.
333    id: Option<String>,
334    /// Fraction (0..=1) along the side, defaults to centered.
335    #[props(default = 0.5)]
336    offset: f64,
337    /// Extra classes (e.g. Tailwind utilities) for the handle dot.
338    class: Option<String>,
339) -> Element {
340    let core = use_context::<FlowCore>();
341    let scope = use_context::<NodeScope>();
342    let key = use_hook(|| HandleKey {
343        node: scope.id.clone(),
344        kind,
345        id: id.clone().unwrap_or_default(),
346    });
347
348    // Keep the registry in sync with (possibly reactive) geometry props.
349    // Writes are queued and flushed in one batch per frame via the core.
350    {
351        let key = key.clone();
352        use_effect(move || {
353            let geom = HandleGeom {
354                side: *position.read(),
355                offset,
356            };
357            if core.handles.peek().get(&key) != Some(&geom) {
358                core.queue_handle_write(key.clone(), Some(geom));
359            }
360        });
361    }
362    {
363        let key = key.clone();
364        use_drop(move || {
365            core.queue_handle_write(key, None);
366        });
367    }
368
369    // Narrow memos: these only change on connection start/end and snap
370    // enter/leave, not on every pointer move.
371    let connect_from = core.connect_from.read();
372    let is_snap_target = core.snap_key.read().as_ref() == Some(&key);
373    let is_connect_source = connect_from.as_ref() == Some(&key);
374    let is_valid_target = connect_from
375        .as_ref()
376        .map(|from| from.kind != kind && from.node != key.node)
377        .unwrap_or(false);
378
379    let side = *position.read();
380    let class = format!(
381        "df-handle df-handle-{} df-handle-{}{}{}{}{}",
382        match kind {
383            HandleKind::Source => "source",
384            HandleKind::Target => "target",
385        },
386        side.class_name(),
387        if is_connect_source {
388            " df-connecting-from"
389        } else {
390            ""
391        },
392        if is_valid_target {
393            " df-valid-target"
394        } else {
395            ""
396        },
397        if is_snap_target { " df-snap" } else { "" },
398        class
399            .as_deref()
400            .map(|c| format!(" {c}"))
401            .unwrap_or_default(),
402    );
403    let pct = offset * 100.0;
404    let style = match side {
405        Side::Top => format!("left:{pct}%;top:0;"),
406        Side::Bottom => format!("left:{pct}%;top:100%;"),
407        Side::Left => format!("left:0;top:{pct}%;"),
408        Side::Right => format!("left:100%;top:{pct}%;"),
409    };
410
411    let key_for_down = key.clone();
412    rsx! {
413        div {
414            class,
415            style,
416            onpointerdown: move |evt| {
417                if *core.interaction.peek() != Interaction::None {
418                    return;
419                }
420                core.cancel_animations();
421                let client = client_point(evt.client_coordinates());
422                let cursor = core.client_to_flow(client);
423                {
424                    let mut drag = core.drag;
425                    let mut state = drag.write();
426                    *state = DragState {
427                        pointer_id: Some(evt.pointer_id()),
428                        origin_client: client,
429                        last_client: client,
430                        moved: false,
431                        suppress_click: false,
432                        grabs: Vec::new(),
433                    };
434                }
435                core.connection.clone().set(Some(ConnectionState {
436                    from: key_for_down.clone(),
437                    cursor,
438                    snap: None,
439                }));
440                core.interaction.clone().set(Interaction::Connect);
441                if let Some(handler) = &core.on_connect_start {
442                    handler.call(key_for_down.clone());
443                }
444            },
445        }
446    }
447}
448
449/// The built-in node view: a simple labeled box. Nodes typed `"input"` omit
450/// the target handle, `"output"` omits the source handle.
451///
452/// Custom `node_view` callbacks can delegate to this for node types they
453/// don't handle.
454#[component]
455pub fn DefaultNodeView<T: Clone + PartialEq + 'static>(ctx: NodeViewCtx<T>) -> Element {
456    let node = &ctx.node;
457    let is_input = node.node_type.as_deref() == Some("input");
458    let is_output = node.node_type.as_deref() == Some("output");
459    rsx! {
460        div { class: "df-node-default",
461            if !is_input {
462                Handle { kind: HandleKind::Target, position: node.target_side }
463            }
464            span { class: "df-node-label", "{node.label}" }
465            if !is_output {
466                Handle { kind: HandleKind::Source, position: node.source_side }
467            }
468        }
469    }
470}