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