Skip to main content

dioxus_flow/
flow.rs

1//! The [`Canvas`] and [`Flow`] components: canvas, pan/zoom, pointer state
2//! machine, and the node/edge render layers.
3//!
4//! [`Canvas`] is the lower layer: a pannable/zoomable surface with the shared
5//! [`FlowCore`] context, the pane gesture state machine, and nothing drawn on
6//! it. [`Flow`] builds the node/edge layers, connection gesture defaults and
7//! keyboard handling on top. Applications with their own node/edge rendering
8//! (custom editors, seat-based ports…) can use [`Canvas`] directly and draw
9//! into its `world` slot.
10
11use std::collections::HashMap;
12use std::rc::Rc;
13use std::sync::atomic::{AtomicUsize, Ordering};
14
15use dioxus::html::geometry::WheelDelta;
16use dioxus::html::input_data::MouseButton;
17use dioxus::prelude::*;
18
19use crate::edge::{EdgeItem, EdgeMarkers, EdgeViewCtx, HANDLE_RIM};
20use crate::node::{NodeItem, NodeViewCtx};
21use crate::path::connection_path;
22use crate::state::{
23    orient_connection, ConnectionState, DragState, FlowApi, FlowConfig, FlowCore, FlowHandle,
24    Interaction,
25};
26use crate::types::{
27    AnchorMode, ConnectEnd, Connection, DeleteRequest, Edge, HandleKey, HandleKind, Id, Node,
28    NodeGeom, Point, Rect, Viewport,
29};
30
31/// The component styles [`Canvas`] injects at runtime, public so that tests
32/// and server-side renderers can install the same sheet themselves.
33pub static STYLE: &str = include_str!("style.css");
34
35static NEXT_IID: AtomicUsize = AtomicUsize::new(0);
36
37fn client_point(coords: dioxus::html::geometry::ClientPoint) -> Point {
38    Point::new(coords.x, coords.y)
39}
40
41/// How a wheel notch converts to pixels, per delta unit. A notch reports
42/// lines on Firefox and pixels on Chromium; without the conversion one notch
43/// moves the canvas by three pixels.
44fn wheel_pixels(delta: WheelDelta, page: f64) -> Point {
45    match delta {
46        WheelDelta::Pixels(v) => Point::new(v.x, v.y),
47        WheelDelta::Lines(v) => Point::new(v.x * 16.0, v.y * 16.0),
48        WheelDelta::Pages(v) => {
49            let scale = page.max(240.0);
50            Point::new(v.x * scale, v.y * scale)
51        }
52    }
53}
54
55/// Exponent per scrolled pixel while pinch-zooming (ctrl/meta + wheel, which
56/// is also what browsers report for a trackpad pinch).
57const PINCH_ZOOM_SENSITIVITY: f64 = 0.0025;
58
59/// The pannable/zoomable surface every flow is drawn on.
60///
61/// Owns the [`FlowCore`] context, the container geometry, and the pane
62/// gestures: pan (drag or scroll), zoom (scroll or pinch), pane clicks, and
63/// the in-flight connection state machine that [`crate::Handle`]s feed.
64/// Draws nothing itself: [`Flow`] passes its node/edge layers through the
65/// `world` slot, and an application using [`Canvas`] directly renders its own
66/// content there (in flow coordinates) and overlays as `children` (in screen
67/// coordinates).
68///
69/// An application-level gesture that starts on content inside the canvas can
70/// take the pointer away from the pane with [`FlowCore::claim_pointer`]; the
71/// pane then neither pans nor reports a pane click for that press.
72#[allow(clippy::too_many_arguments)]
73#[component]
74pub fn Canvas(
75    #[props(default = 0.25)] min_zoom: f64,
76    #[props(default = 4.0)] max_zoom: f64,
77    /// Pan the canvas by dragging empty space.
78    #[props(default = true)]
79    pan_on_drag: bool,
80    /// Zoom with the mouse wheel / trackpad. Only consulted when
81    /// `pan_on_scroll` is off.
82    #[props(default = true)]
83    zoom_on_scroll: bool,
84    /// Scrolling pans instead of zooming (shift swaps the axis, ctrl/meta —
85    /// a trackpad pinch included — zooms about the pointer). On by default, so
86    /// a two-finger trackpad drag pans. Takes precedence over
87    /// `zoom_on_scroll`; set it to `false` for wheel-zoom.
88    #[props(default = true)]
89    pan_on_scroll: bool,
90    /// Master switch for node dragging (read by [`Flow`]'s node layer).
91    #[props(default = true)]
92    nodes_draggable: bool,
93    /// How far (screen px) a press on a node must travel before it moves the
94    /// node, so a sloppy click never nudges one.
95    #[props(default = 0.0)]
96    drag_threshold: f64,
97    /// Snap radius (screen px) for completing a connection near a handle.
98    #[props(default = 28.0)]
99    connection_radius: f64,
100    #[props(default = 0.12)] fit_view_padding: f64,
101    /// `id` attribute for the root element, so applications can find, focus,
102    /// measure, or capture pointers to the canvas by id.
103    id: Option<String>,
104    /// Accessible name for the canvas.
105    #[props(default = "Node graph".to_string())]
106    aria_label: String,
107    /// Extra classes for the root element.
108    class: Option<String>,
109    /// A primary press reaching the pane, before the pane decides to pan:
110    /// the hook for application-level pane gestures (marquee selection,
111    /// pulling a connection from a node border…). A handler that starts one
112    /// claims the pointer with [`FlowCore::claim_pointer`]; the pane then
113    /// leaves this press alone.
114    on_pane_press: Option<Callback<Event<PointerData>>>,
115    /// The caller's edges, when it has any (the [`Flow`] layers and the
116    /// default connect behavior read and write these through the core).
117    edges: Option<Signal<Vec<Edge>>>,
118    /// Node geometry snapshot, when the caller renders nodes ([`Flow`] passes
119    /// its memo; standalone canvases leave it empty).
120    geoms: Option<Memo<Vec<NodeGeom>>>,
121    /// Type-erased "deselect all nodes" for pane clicks and edge selection,
122    /// provided by [`Flow`] which knows the node type.
123    deselect_nodes: Option<Callback<()>>,
124    /// Called when the user completes a connection between two handles. When
125    /// absent, the edge is added to `edges` automatically.
126    on_connect: Option<EventHandler<Connection>>,
127    /// A connection drag has left a handle (started, not completed).
128    on_connect_start: Option<EventHandler<HandleKey>>,
129    /// A connection drag ended — wherever it ended. `connection` is `None`
130    /// when the release was over nothing, and the point says where: the hook
131    /// for "drop on empty canvas to create the node there".
132    on_connect_end: Option<EventHandler<ConnectEnd>>,
133    /// The application's say over which connections may complete. A target
134    /// that fails is never offered as a snap and never completes.
135    is_valid_connection: Option<Callback<Connection, bool>>,
136    /// A node drag actually began (the press travelled past
137    /// `drag_threshold`), with the ids being dragged: the moment to snapshot
138    /// for undo.
139    on_node_drag_start: Option<EventHandler<Vec<Id>>>,
140    /// A node drag ended, with the ids that were dragged. Positions are
141    /// already final in the node list: the moment to snap, settle, persist.
142    on_node_drag_stop: Option<EventHandler<Vec<Id>>>,
143    /// Click on empty canvas; the point is in flow coordinates. Fires only
144    /// when the press neither travelled nor was claimed by content.
145    on_pane_click: Option<EventHandler<Point>>,
146    /// Double-click on the canvas; the point is in flow coordinates. The
147    /// pane cannot tell content from paper here — an application that must
148    /// can hit-test the client point itself before acting.
149    on_pane_double_click: Option<EventHandler<Point>>,
150    /// Keyboard events reaching the canvas root, after the canvas's own
151    /// Escape handling. [`Flow`] wires Delete/Backspace through this.
152    on_canvas_key_down: Option<Callback<Event<KeyboardData>>>,
153    /// Pointer moves while a node drag is in flight, in flow coordinates.
154    /// [`Flow`] applies the drag to its typed node list through this.
155    on_drag_move: Option<Callback<Point>>,
156    /// Content drawn inside the viewport transform, in flow coordinates.
157    world: Option<Element>,
158    /// Overlays drawn over the canvas in screen coordinates ([`Background`],
159    /// [`Controls`], [`MiniMap`], or your own — they can call [`use_flow`]).
160    ///
161    /// [`Background`]: crate::Background
162    /// [`Controls`]: crate::Controls
163    /// [`MiniMap`]: crate::MiniMap
164    /// [`use_flow`]: crate::use_flow
165    children: Element,
166) -> Element {
167    let viewport = use_signal(Viewport::default);
168    let container = use_signal(|| Rect::ZERO);
169    let interaction = use_signal(Interaction::default);
170    let connection = use_signal(|| None::<ConnectionState>);
171    let handles = use_signal(HashMap::new);
172    let mut config = use_signal(FlowConfig::default);
173    let drag = use_signal(DragState::default);
174    let epoch = use_signal(|| 0u64);
175    let pending_sizes = use_signal(Vec::new);
176    let size_flush_queued = use_signal(|| false);
177    let pending_handles = use_signal(Vec::new);
178    let handle_flush_queued = use_signal(|| false);
179    let snap_key = use_memo(move || {
180        connection
181            .read()
182            .as_ref()
183            .and_then(|c| c.snap.as_ref())
184            .map(|s| s.key.clone())
185    });
186    let connect_from = use_memo(move || connection.read().as_ref().map(|c| c.from.clone()));
187    let overlay_insets = use_signal(HashMap::new);
188    let own_edges = use_signal(Vec::new);
189    let edges = edges.unwrap_or(own_edges);
190    let empty_geoms = use_memo(Vec::new);
191    let geoms = geoms.unwrap_or(empty_geoms);
192    let deselect_nodes = deselect_nodes.unwrap_or_else(|| use_callback(move |_: ()| {}));
193
194    let core = use_hook(|| FlowCore {
195        iid: NEXT_IID.fetch_add(1, Ordering::Relaxed),
196        viewport,
197        container,
198        interaction,
199        connection,
200        handles,
201        edges,
202        geoms,
203        config,
204        drag,
205        epoch,
206        snap_key,
207        connect_from,
208        deselect_nodes,
209        overlay_insets,
210        pending_sizes,
211        size_flush_queued,
212        pending_handles,
213        handle_flush_queued,
214        on_connect_start,
215        valid_connection: is_valid_connection,
216    });
217    use_context_provider(|| core);
218
219    // Mirror config props into the shared config signal.
220    let cfg = FlowConfig {
221        min_zoom,
222        max_zoom,
223        pan_on_drag,
224        zoom_on_scroll,
225        pan_on_scroll,
226        nodes_draggable,
227        drag_threshold,
228        connection_radius,
229        fit_view_padding,
230    };
231    if *config.peek() != cfg {
232        config.set(cfg);
233    }
234
235    // Container geometry tracking.
236    let mounted: Signal<Option<Rc<MountedData>>> = use_signal(|| None);
237    let refresh_rect = use_callback(move |_: ()| {
238        let element = mounted.peek().clone();
239        let mut container = container;
240        if let Some(element) = element {
241            spawn(async move {
242                if let Ok(rect) = element.get_client_rect().await {
243                    let rect = Rect::new(rect.origin.x, rect.origin.y, rect.width(), rect.height());
244                    if *container.peek() != rect {
245                        container.set(rect);
246                    }
247                }
248            });
249        }
250    });
251
252    // ---- Pointer state machine ----------------------------------------
253
254    let end_gesture = use_callback(move |_: ()| {
255        let mut interaction = interaction;
256        let mut connection = connection;
257        if *interaction.peek() != Interaction::None {
258            interaction.set(Interaction::None);
259        }
260        if connection.peek().is_some() {
261            connection.set(None);
262        }
263        let mut drag = drag;
264        let mut state = drag.write();
265        state.pointer_id = None;
266        state.suppress_click = false;
267    });
268
269    let on_pointer_down = move |evt: Event<PointerData>| {
270        refresh_rect.call(());
271        core.cancel_animations();
272        // A node, handle, edge or overlay may have claimed this pointer
273        // already (children's handlers run first while bubbling).
274        if *interaction.peek() != Interaction::None {
275            return;
276        }
277        if evt.trigger_button() != Some(MouseButton::Primary) {
278            return;
279        }
280        // Offer the press to the application first; it may claim the pointer
281        // for a gesture of its own, in which case the pane stays out of it.
282        if let Some(handler) = &on_pane_press {
283            handler.call(evt.clone());
284            if *interaction.peek() != Interaction::None {
285                return;
286            }
287        }
288        let client = client_point(evt.client_coordinates());
289        {
290            let mut drag = drag;
291            let mut state = drag.write();
292            state.pointer_id = Some(evt.pointer_id());
293            state.origin_client = client;
294            state.last_client = client;
295            state.moved = false;
296            state.suppress_click = false;
297            state.grabs.clear();
298        }
299        let mut interaction = interaction;
300        if pan_on_drag {
301            interaction.set(Interaction::Pan);
302        } else {
303            interaction.set(Interaction::PanePressed);
304        }
305    };
306
307    let on_pointer_move = move |evt: Event<PointerData>| {
308        let current = *interaction.peek();
309        if current == Interaction::None {
310            return;
311        }
312        // A gesture belongs to the pointer that started it: a second finger
313        // must not steer the first one's pan.
314        if drag
315            .peek()
316            .pointer_id
317            .is_some_and(|id| id != evt.pointer_id())
318        {
319            return;
320        }
321        // Self-heal: if the pointer was released outside the container we
322        // never saw the pointerup.
323        if evt.held_buttons().is_empty() {
324            end_gesture.call(());
325            return;
326        }
327        let client = client_point(evt.client_coordinates());
328        match current {
329            Interaction::Pan => {
330                let delta = {
331                    let mut drag = drag;
332                    let mut state = drag.write();
333                    let delta = client - state.last_client;
334                    state.last_client = client;
335                    state.moved = true;
336                    delta
337                };
338                let mut viewport = viewport;
339                let vp = *viewport.peek();
340                viewport.set(vp.panned(delta));
341            }
342            Interaction::DragNode => {
343                // The press has to travel before it moves anything, so a
344                // sloppy click never nudges a node. Crossing the threshold is
345                // the moment the drag really starts — the snapshot-for-undo
346                // moment — so that is when `on_node_drag_start` fires.
347                let began = {
348                    let mut drag = drag;
349                    let mut state = drag.write();
350                    state.last_client = client;
351                    let travelled = state.origin_client.distance(client);
352                    let passed = state.moved || travelled >= config.peek().drag_threshold;
353                    let began = passed && !state.moved;
354                    if passed {
355                        state.moved = true;
356                    }
357                    if !passed {
358                        return;
359                    }
360                    began
361                };
362                if began {
363                    if let Some(handler) = &on_node_drag_start {
364                        let ids: Vec<Id> =
365                            drag.peek().grabs.iter().map(|(id, _)| id.clone()).collect();
366                        handler.call(ids);
367                    }
368                }
369                let flow = core.client_to_flow(client);
370                if let Some(handler) = &on_drag_move {
371                    handler.call(flow);
372                }
373            }
374            Interaction::Connect => {
375                let flow = core.client_to_flow(client);
376                let mut connection = connection;
377                let from = connection.peek().as_ref().map(|c| c.from.clone());
378                if let Some(from) = from {
379                    let snap = core.find_snap(&from, flow);
380                    connection.set(Some(ConnectionState {
381                        from,
382                        cursor: flow,
383                        snap,
384                    }));
385                }
386            }
387            _ => {}
388        }
389    };
390
391    let on_pointer_up = move |evt: Event<PointerData>| {
392        if drag
393            .peek()
394            .pointer_id
395            .is_some_and(|id| id != evt.pointer_id())
396        {
397            return;
398        }
399        let current = *interaction.peek();
400        match current {
401            // A click on empty canvas (no pan movement happened).
402            Interaction::Pan | Interaction::PanePressed => {
403                let state = drag.peek().clone();
404                let is_click =
405                    (current == Interaction::PanePressed || !state.moved) && !state.suppress_click;
406                if is_click {
407                    let client = client_point(evt.client_coordinates());
408                    let flow = core.client_to_flow(client);
409                    if !evt.modifiers().shift() {
410                        deselect_nodes.call(());
411                        deselect_edges(edges);
412                    }
413                    if let Some(handler) = &on_pane_click {
414                        handler.call(flow);
415                    }
416                }
417            }
418            Interaction::Connect => {
419                let done = connection.peek().clone();
420                if let Some(done) = done {
421                    let completed = done
422                        .snap
423                        .as_ref()
424                        .map(|snap| orient_connection(&done.from, &snap.key));
425                    if let Some(conn) = completed.clone() {
426                        match &on_connect {
427                            Some(handler) => handler.call(conn),
428                            None => add_edge_for_connection(edges, conn),
429                        }
430                    }
431                    // However it ended: the release point plus what (if
432                    // anything) completed. A `None` connection with a point is
433                    // the drop-on-empty-canvas hook.
434                    if let Some(handler) = &on_connect_end {
435                        let client = client_point(evt.client_coordinates());
436                        handler.call(ConnectEnd {
437                            point: core.client_to_flow(client),
438                            connection: completed,
439                        });
440                    }
441                }
442            }
443            Interaction::DragNode if drag.peek().moved => {
444                if let Some(handler) = &on_node_drag_stop {
445                    let ids: Vec<Id> = drag.peek().grabs.iter().map(|(id, _)| id.clone()).collect();
446                    handler.call(ids);
447                }
448            }
449            _ => {}
450        }
451        end_gesture.call(());
452    };
453
454    let on_wheel = move |evt: Event<WheelData>| {
455        let config = *config.peek();
456        if !config.pan_on_scroll && !config.zoom_on_scroll {
457            return;
458        }
459        evt.prevent_default();
460        core.cancel_animations();
461        let client = client_point(evt.client_coordinates());
462        let page = container.peek().height;
463        let delta = wheel_pixels(evt.delta(), page);
464        let modifiers = evt.modifiers();
465        // A pinch (or ctrl/meta scroll) zooms about the pointer in either
466        // scroll mode.
467        if config.pan_on_scroll {
468            if modifiers.ctrl() || modifiers.meta() {
469                if delta.y != 0.0 {
470                    let factor = (-delta.y * PINCH_ZOOM_SENSITIVITY).exp();
471                    core.zoom_by(factor, Some(client), 0);
472                }
473                return;
474            }
475            let mut viewport = viewport;
476            let vp = *viewport.peek();
477            // Shift turns a vertical wheel into horizontal travel, as
478            // everywhere else.
479            let by = if modifiers.shift() && delta.x == 0.0 {
480                Point::new(-delta.y, 0.0)
481            } else {
482                Point::new(-delta.x, -delta.y)
483            };
484            viewport.set(vp.panned(by));
485            return;
486        }
487        if delta.y == 0.0 {
488            return;
489        }
490        let factor = (-delta.y * 0.0022).exp().clamp(0.5, 2.0);
491        core.zoom_by(factor, Some(client), 0);
492    };
493
494    let on_key_down = move |evt: Event<KeyboardData>| {
495        if evt.key() == Key::Escape {
496            end_gesture.call(());
497        }
498        if let Some(handler) = &on_canvas_key_down {
499            handler.call(evt);
500        }
501    };
502
503    // Reading `interaction` here keeps cursor feedback classes fresh; it only
504    // changes on gesture start/end, never per pointer-move frame.
505    let gesture = *interaction.read();
506    let root_class = format!(
507        "dioxus-flow{}{}",
508        match gesture {
509            Interaction::Pan => " df-panning",
510            Interaction::Connect => " df-connecting",
511            _ => "",
512        },
513        class
514            .as_deref()
515            .map(|c| format!(" {c}"))
516            .unwrap_or_default()
517    );
518
519    rsx! {
520        document::Style { {STYLE} }
521        div {
522            id,
523            class: root_class,
524            tabindex: "0",
525            role: "application",
526            aria_label,
527            onmounted: move |evt| {
528                let mut mounted = mounted;
529                mounted.set(Some(evt.data()));
530                refresh_rect.call(());
531            },
532            onresize: move |_| refresh_rect.call(()),
533            onpointerdown: on_pointer_down,
534            onpointermove: on_pointer_move,
535            onpointerup: on_pointer_up,
536            onpointercancel: move |evt: Event<PointerData>| {
537                let owner = drag.peek().pointer_id;
538                if owner.is_none() || owner == Some(evt.pointer_id()) {
539                    end_gesture.call(());
540                }
541            },
542            onwheel: on_wheel,
543            ondoubleclick: move |evt: Event<MouseData>| {
544                if let Some(handler) = &on_pane_double_click {
545                    let client = client_point(evt.client_coordinates());
546                    handler.call(core.client_to_flow(client));
547                }
548            },
549            onkeydown: on_key_down,
550            ViewportPane { {world} }
551            {children}
552        }
553    }
554}
555
556/// An interactive node-graph canvas, in the spirit of react-flow.
557///
558/// Nodes and edges are owned by the caller as signals; the flow mutates them
559/// in response to user interaction (dragging, selection, connecting) and the
560/// caller can mutate them at any time (adding nodes, changing data…).
561///
562/// ```ignore
563/// let nodes = use_signal(|| vec![
564///     Node::new("1", "Input", (0.0, 0.0)).node_type("input"),
565///     Node::new("2", "Process", (0.0, 120.0)),
566/// ]);
567/// let edges = use_signal(|| vec![Edge::new("1", "2").animated(true)]);
568/// rsx! {
569///     Flow { nodes, edges, fit_view: true,
570///         Background {}
571///         Controls {}
572///         MiniMap {}
573///     }
574/// }
575/// ```
576#[component]
577pub fn Flow<T: Clone + PartialEq + 'static>(
578    /// The nodes, owned by the caller.
579    nodes: Signal<Vec<Node<T>>>,
580    /// The edges, owned by the caller.
581    edges: Signal<Vec<Edge>>,
582    /// How edges find their endpoints: [`AnchorMode::Handles`] (default) or
583    /// [`AnchorMode::Seats`] — solver-packed positions around each node's rim,
584    /// drawn with rim-aware curves and beads.
585    #[props(default)]
586    anchor: AnchorMode,
587    #[props(default = 0.25)] min_zoom: f64,
588    #[props(default = 4.0)] max_zoom: f64,
589    /// Pan the canvas by dragging empty space.
590    #[props(default = true)]
591    pan_on_drag: bool,
592    /// Zoom with the mouse wheel / trackpad. Only consulted when
593    /// `pan_on_scroll` is off.
594    #[props(default = true)]
595    zoom_on_scroll: bool,
596    /// Scrolling pans instead of zooming (ctrl/meta or a pinch zooms). On by
597    /// default, so a two-finger trackpad drag pans; set it to `false` for
598    /// wheel-zoom.
599    #[props(default = true)]
600    pan_on_scroll: bool,
601    /// Master switch for node dragging (individual nodes can also opt out).
602    #[props(default = true)]
603    nodes_draggable: bool,
604    /// How far (screen px) a press on a node must travel before it moves the
605    /// node, so a sloppy click never nudges one.
606    #[props(default = 0.0)]
607    drag_threshold: f64,
608    /// Snap radius (screen px) for completing a connection near a handle.
609    #[props(default = 28.0)]
610    connection_radius: f64,
611    /// Fit all nodes into view once nodes are measured after mount.
612    #[props(default = false)]
613    fit_view: bool,
614    #[props(default = 0.12)] fit_view_padding: f64,
615    /// Delete selected nodes/edges with Delete/Backspace.
616    #[props(default = true)]
617    delete_key: bool,
618    /// `id` attribute for the root element.
619    id: Option<String>,
620    /// Extra classes for the root element.
621    class: Option<String>,
622    /// Custom renderer for node contents. Receives a [`NodeViewCtx`]; fall
623    /// back to [`crate::DefaultNodeView`] for types you don't customize.
624    node_view: Option<Callback<NodeViewCtx<T>, Element>>,
625    /// Custom renderer for edges (SVG content).
626    edge_view: Option<Callback<EdgeViewCtx, Element>>,
627    /// Called when the user completes a connection between two handles. When
628    /// absent, the edge is added automatically.
629    on_connect: Option<EventHandler<Connection>>,
630    /// A connection drag has left a handle (started, not completed).
631    on_connect_start: Option<EventHandler<HandleKey>>,
632    /// A connection drag ended — wherever it ended. `connection` is `None`
633    /// when the release was over nothing, and the point says where: the hook
634    /// for "drop on empty canvas to create the node there".
635    on_connect_end: Option<EventHandler<ConnectEnd>>,
636    /// The application's say over which connections may complete. A target
637    /// that fails is never offered as a snap and never completes.
638    is_valid_connection: Option<Callback<Connection, bool>>,
639    /// A node drag actually began (the press travelled past
640    /// `drag_threshold`), with the ids being dragged: the moment to snapshot
641    /// for undo.
642    on_node_drag_start: Option<EventHandler<Vec<Id>>>,
643    /// A node drag ended, with the ids that were dragged. Positions are
644    /// already final in the node list: the moment to snap, settle, persist.
645    on_node_drag_stop: Option<EventHandler<Vec<Id>>>,
646    /// Called when Delete/Backspace is pressed with a selection. When absent,
647    /// the selection (plus connected edges) is deleted automatically; when
648    /// present, nothing is deleted — call
649    /// [`FlowHandle::delete_selected`](crate::FlowHandle::delete_selected)
650    /// from the handler to perform the default cascade (after confirming,
651    /// snapshotting for undo…).
652    on_delete: Option<EventHandler<DeleteRequest>>,
653    on_node_click: Option<EventHandler<Id>>,
654    on_edge_click: Option<EventHandler<Id>>,
655    /// Click on empty canvas; the point is in flow coordinates.
656    on_pane_click: Option<EventHandler<Point>>,
657    /// Double-click on the canvas; the point is in flow coordinates.
658    on_pane_double_click: Option<EventHandler<Point>>,
659    /// Attach a [`FlowHandle`] (from [`crate::use_flow_handle`]) for
660    /// programmatic control: fit view, zoom, auto-layout…
661    handle: Option<FlowHandle<T>>,
662    /// Overlays such as [`crate::Background`], [`crate::Controls`],
663    /// [`crate::MiniMap`], or your own (they can call [`crate::use_flow`]).
664    children: Element,
665) -> Element {
666    let geoms = use_memo(move || {
667        nodes
668            .read()
669            .iter()
670            .map(|node| NodeGeom {
671                id: node.id.clone(),
672                rect: node.rect(),
673                selected: node.selected,
674                source_side: node.source_side,
675                target_side: node.target_side,
676                measured: node.size.is_some() || node.measured.is_some(),
677            })
678            .collect::<Vec<_>>()
679    });
680    let deselect_nodes = use_callback(move |_: ()| {
681        if nodes.peek().iter().any(|n| n.selected) {
682            nodes.clone().with_mut(|nodes| {
683                for node in nodes.iter_mut() {
684                    node.selected = false;
685                }
686            });
687        }
688    });
689
690    // Wired to the canvas once it mounts (the core is created inside it).
691    let attach_core: Signal<Option<FlowCore>> = use_signal(|| None);
692
693    // Attach the programmatic handle, if provided.
694    use_effect(move || {
695        let Some(core) = *attach_core.read() else {
696            return;
697        };
698        if let Some(handle) = handle {
699            let mut inner = handle.inner;
700            if inner.peek().is_none() {
701                inner.set(Some(FlowApi { core, nodes }));
702            }
703        }
704    });
705
706    // Initial fit-view.
707    //
708    // This used to wait for every node to report a measured size, which cannot
709    // work now that the node layer is tiled: a tile the viewport cannot see is
710    // never laid out, so the nodes in it never measure, and the fit would wait
711    // forever. (The same wait could already hang on a node that was simply
712    // unmeasurable.) Instead, fit to the best rects available — `Node::rect`
713    // falls back to a declared or default size — and fit again as real
714    // measurements arrive, until the bounds stop moving.
715    //
716    // Re-fitting is bounded twice over: it stops as soon as the bounds settle,
717    // and it never survives the first gesture, so it cannot fight the user for
718    // control of the viewport.
719    let mut fits = use_signal(|| 0u8);
720    // Both inputs to the fit, so that either one still settling re-fits. The
721    // container matters as much as the bounds: a canvas whose height arrives a
722    // frame after its width would otherwise be fitted against, and kept at,
723    // the wrong viewport.
724    let mut fitted: Signal<Option<(Rect, Rect)>> = use_signal(|| None);
725    let mut touched = use_signal(|| false);
726    use_effect(move || {
727        if let Some(core) = *attach_core.read() {
728            if *core.interaction.read() != Interaction::None {
729                touched.set(true);
730            }
731        }
732    });
733    use_effect(move || {
734        let Some(core) = *attach_core.read() else {
735            return;
736        };
737        if !fit_view || *touched.peek() {
738            return;
739        }
740        let container = *core.container.read();
741        let bounds = geoms
742            .read()
743            .iter()
744            .map(|geom| geom.rect)
745            .reduce(|acc, rect| acc.union(&rect));
746        let Some(bounds) = bounds else { return };
747        if container.width <= 0.0 || container.height <= 0.0 {
748            return;
749        }
750        // A first fit, then again only while either input is still moving
751        // under it. Twelve is far more than settling takes, and caps any
752        // chance of a loop.
753        let same = |a: Rect, b: Rect| {
754            (a.x - b.x).abs() < 1.0
755                && (a.y - b.y).abs() < 1.0
756                && (a.width - b.width).abs() < 1.0
757                && (a.height - b.height).abs() < 1.0
758        };
759        let settled = fitted
760            .peek()
761            .is_some_and(|(b, c)| same(b, bounds) && same(c, container));
762        if settled || *fits.peek() >= 12 {
763            return;
764        }
765        let done = *fits.peek();
766        fitted.set(Some((bounds, container)));
767        fits.set(done + 1);
768        core.fit_view(0);
769    });
770
771    let on_drag_move = use_callback(move |flow: Point| {
772        let Some(core) = *attach_core.peek() else {
773            return;
774        };
775        let grabs = core.drag.peek().grabs.clone();
776        let mut nodes = nodes;
777        nodes.with_mut(|nodes| {
778            for (id, grab) in &grabs {
779                if let Some(node) = nodes.iter_mut().find(|n| &n.id == id) {
780                    node.position = flow - *grab;
781                }
782            }
783        });
784    });
785
786    let on_canvas_key_down = use_callback(move |evt: Event<KeyboardData>| match evt.key() {
787        Key::Delete | Key::Backspace if delete_key => {
788            let Some(core) = *attach_core.peek() else {
789                return;
790            };
791            let request = delete_request(nodes, core.edges);
792            if request.nodes.is_empty() && request.edges.is_empty() {
793                return;
794            }
795            match &on_delete {
796                Some(handler) => handler.call(request),
797                None => delete_selected(nodes, core.edges),
798            }
799        }
800        _ => {}
801    });
802
803    rsx! {
804        Canvas {
805            min_zoom,
806            max_zoom,
807            pan_on_drag,
808            zoom_on_scroll,
809            pan_on_scroll,
810            nodes_draggable,
811            drag_threshold,
812            connection_radius,
813            fit_view_padding,
814            id,
815            class,
816            edges,
817            geoms,
818            deselect_nodes,
819            on_connect,
820            on_connect_start,
821            on_connect_end,
822            is_valid_connection,
823            on_node_drag_start,
824            on_node_drag_stop,
825            on_pane_click,
826            on_pane_double_click,
827            on_canvas_key_down,
828            on_drag_move,
829            world: rsx! {
830                CoreProbe { attach_core }
831                match anchor {
832                    AnchorMode::Handles => rsx! {
833                        EdgesLayer { edge_view, on_edge_click }
834                        NodesLayer { nodes, node_view, on_node_click }
835                    },
836                    AnchorMode::Seats => rsx! {
837                        SeatGraphLayers {
838                            nodes,
839                            node_view,
840                            on_node_click,
841                            edge_view,
842                            on_edge_click,
843                        }
844                    },
845                }
846                ConnectionLine {}
847            },
848            {children}
849        }
850    }
851}
852
853/// The node layer sandwiched between seat-anchored edges and their beads.
854///
855/// One component so the three share one solve: the edge curves render under
856/// the nodes, but the beads — the dots where a connection meets a rim — sit
857/// over them, because a bead is threaded on the rim, not tucked behind it.
858#[component]
859fn SeatGraphLayers<T: Clone + PartialEq + 'static>(
860    nodes: Signal<Vec<Node<T>>>,
861    node_view: Option<Callback<NodeViewCtx<T>, Element>>,
862    on_node_click: Option<EventHandler<Id>>,
863    edge_view: Option<Callback<EdgeViewCtx, Element>>,
864    on_edge_click: Option<EventHandler<Id>>,
865) -> Element {
866    let core = use_context::<FlowCore>();
867    // The one expensive step, behind a memo: re-solves when node geometry or
868    // the edge list changes, never on pan or zoom. Applications with their
869    // own gesture policy run this solve themselves and hand the result to
870    // [`SeatEdges`]; here the edges signal is the whole story.
871    let anchors = use_memo(move || {
872        let geoms = core.geoms.read();
873        let frames: std::collections::BTreeMap<Id, Rect> = geoms
874            .iter()
875            .map(|geom| (geom.id.clone(), geom.rect))
876            .collect();
877        let links: Vec<crate::ports::Link> = core
878            .edges
879            .read()
880            .iter()
881            .map(|edge| crate::ports::Link {
882                id: edge.id.clone(),
883                start: crate::ports::Terminal::Node(edge.source.clone()),
884                end: crate::ports::Terminal::Node(edge.target.clone()),
885                start_seat: edge.source_seat,
886                end_seat: edge.target_seat,
887            })
888            .collect();
889        crate::ports::solve_ports(&frames, &links)
890    });
891
892    // `Flow`'s `edge_view` speaks the handle-mode context; hand it the seat
893    // geometry through the same shape. Views that want the full rim-aware
894    // geometry use [`SeatEdges`] directly.
895    let adapted_edge_view = edge_view.map(|view| {
896        Callback::new(move |ctx: crate::edge::SeatEdgeViewCtx| {
897            view.call(EdgeViewCtx {
898                edge: ctx.edge.clone(),
899                source: ctx.anchors.start.point(),
900                source_side: ctx.anchors.start.side(),
901                target: ctx.anchors.end.point(),
902                target_side: ctx.anchors.end.side(),
903                path: crate::path::EdgePath {
904                    d: ctx.geometry.path.clone(),
905                    label: ctx.geometry.label,
906                },
907                // Seat-mode arrowheads are drawn geometry, not markers.
908                marker_end: None,
909            })
910        })
911    });
912
913    let edges = core.edges;
914    let solved = anchors.read();
915    rsx! {
916        crate::edge::SeatEdges {
917            edges,
918            anchors,
919            edge_view: adapted_edge_view,
920            on_edge_click,
921        }
922        crate::edge::SeatEdgeLabels { edges, anchors }
923        NodesLayer { nodes, node_view, on_node_click }
924        // The beads, over the nodes they are threaded on.
925        svg { class: "df-edges df-ports", "aria-hidden": "true",
926            for edge in edges.read().iter() {
927                if let Some(pair) = solved.get(&edge.id) {
928                    g {
929                        key: "{edge.id}",
930                        class: if edge.selected { "df-selected" },
931                        circle {
932                            class: "df-port",
933                            cx: pair.start.x,
934                            cy: pair.start.y,
935                            r: crate::ports::PORT_RADIUS,
936                        }
937                        circle {
938                            class: "df-port",
939                            cx: pair.end.x,
940                            cy: pair.end.y,
941                            r: crate::ports::PORT_RADIUS,
942                        }
943                    }
944                }
945            }
946        }
947    }
948}
949
950/// Hands the canvas's core out to the owning [`Flow`], which renders above
951/// the canvas and so cannot `use_context` it.
952#[component]
953fn CoreProbe(attach_core: Signal<Option<FlowCore>>) -> Element {
954    let core = use_context::<FlowCore>();
955    let mut attach_core = attach_core;
956    if attach_core.peek().is_none() {
957        attach_core.set(Some(core));
958    }
959    rsx! {}
960}
961
962/// The pannable/zoomable transform layer. Isolated so per-frame viewport
963/// updates re-render only this tiny component, not the node/edge layers.
964#[component]
965fn ViewportPane(children: Element) -> Element {
966    let core = use_context::<FlowCore>();
967    let vp = *core.viewport.read();
968    rsx! {
969        div {
970            class: "df-viewport",
971            style: "transform: translate({vp.x}px, {vp.y}px) scale({vp.zoom});",
972            {children}
973        }
974    }
975}
976
977/// A layer inside the canvas that shares the viewport transform: children are
978/// laid out in flow coordinates. Render as a child of [`Canvas`] or [`Flow`]
979/// for world-space overlays (annotations, guides, custom edge layers…).
980#[component]
981pub fn WorldLayer(class: Option<String>, children: Element) -> Element {
982    let core = use_context::<FlowCore>();
983    let vp = *core.viewport.read();
984    let class = format!(
985        "df-world-layer{}",
986        class
987            .as_deref()
988            .map(|c| format!(" {c}"))
989            .unwrap_or_default()
990    );
991    rsx! {
992        div {
993            class,
994            style: "transform: translate({vp.x}px, {vp.y}px) scale({vp.zoom});",
995            {children}
996        }
997    }
998}
999
1000/// The nodes, grouped into world tiles.
1001///
1002/// The grouping is what lets a browser skip the graph it cannot see: panning
1003/// costs the same with 60 nodes on screen as with 950, because the engine
1004/// walks every node subtree under the viewport transform either way. A tile
1005/// carries `content-visibility: auto`, so one box off screen stands in for
1006/// everything inside it. See [`crate::tile`].
1007#[component]
1008fn NodesLayer<T: Clone + PartialEq + 'static>(
1009    nodes: Signal<Vec<Node<T>>>,
1010    node_view: Option<Callback<NodeViewCtx<T>, Element>>,
1011    on_node_click: Option<EventHandler<Id>>,
1012) -> Element {
1013    let core = use_context::<FlowCore>();
1014    let tiles = use_memo(move || {
1015        let nodes = nodes.read();
1016        // A node that has to paint above the graph raises its tile, because
1017        // containment makes each tile a stacking context and a raised node can
1018        // otherwise only rise above its own tile's siblings. Read inside the
1019        // memo, not captured from the render: a memo's closure is built once,
1020        // so a value captured here would be the one it saw on first render
1021        // forever, and a dragged node would never raise its tile.
1022        let grabbed = (*core.interaction.read() == Interaction::DragNode)
1023            .then(|| core.drag.peek().grabs.clone());
1024        crate::tile::tiles(nodes.iter().map(|node| {
1025            let raised = node.selected
1026                || grabbed
1027                    .as_ref()
1028                    .is_some_and(|grabs| grabs.iter().any(|(id, _)| id == &node.id));
1029            (node.rect(), raised)
1030        }))
1031    });
1032    // One borrow for the whole layer rather than one per node.
1033    let all = nodes.read();
1034    rsx! {
1035        div { class: "df-nodes",
1036            for tile in tiles.read().iter() {
1037                div {
1038                    key: "{tile.cell.0},{tile.cell.1}",
1039                    class: if tile.raised { "df-tile df-raised" } else { "df-tile" },
1040                    style: tile_style(tile),
1041                    for node in tile.members.iter().filter_map(|&i| all.get(i)) {
1042                        NodeItem::<T> {
1043                            key: "{node.id}",
1044                            nodes,
1045                            node: node.clone(),
1046                            origin: tile.origin(),
1047                            node_view,
1048                            on_node_click,
1049                        }
1050                    }
1051                }
1052            }
1053        }
1054    }
1055}
1056
1057/// A tile's box, and the promise that lets the engine skip it.
1058///
1059/// `contain-intrinsic-size` is the tile's own measured box rather than a
1060/// guess: a skipped tile then reserves exactly the space it will occupy once
1061/// it comes back, so scrolling past one never shifts the graph.
1062fn tile_style(tile: &crate::tile::Tile) -> String {
1063    format!(
1064        "transform:translate({}px,{}px);width:{}px;height:{}px;\
1065         contain-intrinsic-size:{}px {}px;",
1066        tile.rect.x,
1067        tile.rect.y,
1068        tile.rect.width,
1069        tile.rect.height,
1070        tile.rect.width,
1071        tile.rect.height,
1072    )
1073}
1074
1075#[component]
1076fn EdgesLayer(
1077    edge_view: Option<Callback<EdgeViewCtx, Element>>,
1078    on_edge_click: Option<EventHandler<Id>>,
1079) -> Element {
1080    let core = use_context::<FlowCore>();
1081    let edges = core.edges.read();
1082    let geoms = core.geoms.read();
1083    let handles = core.handles.read();
1084    let geom_by_id: HashMap<&str, &NodeGeom> =
1085        geoms.iter().map(|geom| (geom.id.as_str(), geom)).collect();
1086    // Borrowed lookup index: this layer re-renders every frame while a node
1087    // is dragged, and going through `resolve_anchor` would clone two key
1088    // Strings per edge per frame.
1089    let handle_idx: HashMap<(&str, HandleKind, &str), &crate::types::HandleGeom> = handles
1090        .iter()
1091        .map(|(key, geom)| ((key.node.as_str(), key.kind, key.id.as_str()), geom))
1092        .collect();
1093    let anchor = |geom: &NodeGeom, kind: HandleKind, handle_id: &Option<Id>| {
1094        let key = (geom.id.as_str(), kind, handle_id.as_deref().unwrap_or(""));
1095        crate::state::anchor_from_geom(handle_idx.get(&key).copied(), geom, kind)
1096    };
1097
1098    let items: Vec<_> = edges
1099        .iter()
1100        .filter_map(|edge| {
1101            let source_geom = geom_by_id.get(edge.source.as_str())?;
1102            let target_geom = geom_by_id.get(edge.target.as_str())?;
1103            let (source, source_side, source_on_handle) =
1104                anchor(source_geom, HandleKind::Source, &edge.source_handle);
1105            let (target, target_side, target_on_handle) =
1106                anchor(target_geom, HandleKind::Target, &edge.target_handle);
1107            // End the visible path at the handle's rim instead of its center
1108            // so arrowheads stay in front of the handle dot.
1109            let source = if source_on_handle {
1110                source + source_side.normal() * HANDLE_RIM
1111            } else {
1112                source
1113            };
1114            let target = if target_on_handle {
1115                target + target_side.normal() * HANDLE_RIM
1116            } else {
1117                target
1118            };
1119            Some((
1120                edge.clone(),
1121                source,
1122                source_side,
1123                target,
1124                target_side,
1125                source_geom.rect,
1126                target_geom.rect,
1127            ))
1128        })
1129        .collect();
1130
1131    // Grouped into the same world tiles the nodes use, for the same reason:
1132    // one `<svg>` off screen stands in for everything drawn inside it. A tile
1133    // clips, so it is sized from `edge_bounds` — the box the curve is
1134    // guaranteed to stay inside, bulge included — rather than from the
1135    // anchors.
1136    let tiles = crate::tile::tiles(items.iter().map(|item| {
1137        let (edge, source, source_side, target, target_side, source_rect, target_rect) = item;
1138        let geo = crate::path::EdgeGeometry::new(*source, *source_side, *target, *target_side)
1139            .with_rects(*source_rect, *target_rect);
1140        (crate::path::edge_bounds(edge.kind, &geo), edge.selected)
1141    }));
1142
1143    rsx! {
1144        // Decorative for assistive tech: nodes expose the graph's content,
1145        // and edge hit-paths are pointer-only.
1146        div { class: "df-edges", "aria-hidden": "true",
1147            // The markers live in their own zero-size svg: `url(#…)` resolves
1148            // across the whole document, so one copy serves every tile.
1149            svg { class: "df-edge-defs",
1150                defs { EdgeMarkers { iid: core.iid } }
1151            }
1152            for tile in tiles.iter() {
1153                svg {
1154                    key: "{tile.cell.0},{tile.cell.1}",
1155                    class: if tile.raised { "df-edge-tile df-raised" } else { "df-edge-tile" },
1156                    // A viewBox in world units means the paths inside need no
1157                    // rewriting: they stay in the coordinates everything else
1158                    // in the flow is expressed in.
1159                    view_box: "{tile.rect.x} {tile.rect.y} {tile.rect.width} {tile.rect.height}",
1160                    style: tile_style(tile),
1161                    for &i in tile.members.iter() {
1162                        if let Some((edge, source, source_side, target, target_side, source_rect, target_rect)) = items.get(i) {
1163                            EdgeItem {
1164                                key: "{edge.id}",
1165                                edge: edge.clone(),
1166                                source: *source,
1167                                source_side: *source_side,
1168                                target: *target,
1169                                target_side: *target_side,
1170                                source_rect: *source_rect,
1171                                target_rect: *target_rect,
1172                                edge_view,
1173                                on_edge_click,
1174                            }
1175                        }
1176                    }
1177                }
1178            }
1179        }
1180    }
1181}
1182
1183/// The dashed preview while dragging a new connection from a handle.
1184#[component]
1185fn ConnectionLine() -> Element {
1186    let core = use_context::<FlowCore>();
1187    let connection = core.connection.read();
1188    let Some(conn) = connection.as_ref() else {
1189        return rsx! {};
1190    };
1191    let Some((from, from_side)) = core.anchor_of(&conn.from) else {
1192        return rsx! {};
1193    };
1194    let (to, to_side) = match &conn.snap {
1195        Some(snap) => (snap.point, Some(snap.side)),
1196        None => (conn.cursor, None),
1197    };
1198    let d = connection_path(from, from_side, to, to_side);
1199    rsx! {
1200        svg { class: "df-connection",
1201            path { class: "df-connection-path", d }
1202        }
1203    }
1204}
1205
1206pub(crate) fn deselect_edges(mut edges: Signal<Vec<Edge>>) {
1207    if edges.peek().iter().any(|e| e.selected) {
1208        edges.with_mut(|edges| {
1209            for edge in edges.iter_mut() {
1210                edge.selected = false;
1211            }
1212        });
1213    }
1214}
1215
1216/// Default behavior when no `on_connect` handler is given: add the edge,
1217/// skipping exact duplicates and de-duplicating the generated id.
1218fn add_edge_for_connection(mut edges: Signal<Vec<Edge>>, conn: Connection) {
1219    let duplicate = edges.peek().iter().any(|e| {
1220        e.source == conn.source
1221            && e.target == conn.target
1222            && e.source_handle == conn.source_handle
1223            && e.target_handle == conn.target_handle
1224    });
1225    if duplicate {
1226        return;
1227    }
1228    let mut edge = conn.into_edge();
1229    let base = edge.id.clone();
1230    let mut n = 2;
1231    while edges.peek().iter().any(|e| e.id == edge.id) {
1232        edge.id = format!("{base}-{n}");
1233        n += 1;
1234    }
1235    edges.with_mut(|edges| edges.push(edge));
1236}
1237
1238/// What a delete keypress would remove, given the current selection.
1239fn delete_request<T: Clone + PartialEq + 'static>(
1240    nodes: Signal<Vec<Node<T>>>,
1241    edges: Signal<Vec<Edge>>,
1242) -> DeleteRequest {
1243    let removed: std::collections::HashSet<Id> = nodes
1244        .peek()
1245        .iter()
1246        .filter(|n| n.selected)
1247        .map(|n| n.id.clone())
1248        .collect();
1249    let edge_ids = edges
1250        .peek()
1251        .iter()
1252        .filter(|e| e.selected || removed.contains(&e.source) || removed.contains(&e.target))
1253        .map(|e| e.id.clone())
1254        .collect();
1255    DeleteRequest {
1256        nodes: removed.into_iter().collect(),
1257        edges: edge_ids,
1258    }
1259}
1260
1261pub(crate) fn delete_selected<T: Clone + PartialEq + 'static>(
1262    mut nodes: Signal<Vec<Node<T>>>,
1263    mut edges: Signal<Vec<Edge>>,
1264) {
1265    let removed: std::collections::HashSet<Id> = nodes
1266        .peek()
1267        .iter()
1268        .filter(|n| n.selected)
1269        .map(|n| n.id.clone())
1270        .collect();
1271    let any_edges = edges
1272        .peek()
1273        .iter()
1274        .any(|e| e.selected || removed.contains(&e.source) || removed.contains(&e.target));
1275    if !removed.is_empty() {
1276        nodes.with_mut(|nodes| nodes.retain(|n| !n.selected));
1277    }
1278    if any_edges {
1279        edges.with_mut(|edges| {
1280            edges.retain(|e| {
1281                !e.selected && !removed.contains(&e.source) && !removed.contains(&e.target)
1282            })
1283        });
1284    }
1285}