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