Skip to main content

dioxus_dnd/
canvas.rs

1//! Free-position drops - node editors, whiteboards, floor planners. The drop
2//! answers not just "what landed" but "*where* exactly", corrected for grab
3//! offset, optionally snapped to a grid and clamped to bounds.
4
5use std::rc::Rc;
6
7use dioxus::prelude::*;
8
9use crate::core::{
10    use_dnd, use_zone_id, use_zone_registry, DragMode, DropOutcome, ParentZone, Point, Rect,
11    ZoneId, ZoneRecord,
12};
13
14/// A payload dropped at a position on the canvas.
15#[derive(Debug, Clone, PartialEq)]
16pub struct CanvasDrop<T> {
17    pub payload: T,
18    /// Top-left position for the dropped element, relative to the canvas -
19    /// already corrected for grab offset, snapping, and bounds.
20    pub position: Point,
21    /// The raw pointer position relative to the canvas, untouched.
22    pub pointer: Point,
23}
24
25/// Snap positions to a square grid.
26#[derive(Debug, Clone, Copy, PartialEq)]
27pub struct SnapGrid(pub f64);
28
29impl SnapGrid {
30    pub fn snap(&self, p: Point) -> Point {
31        if self.0 <= 0.0 {
32            return p;
33        }
34        Point::new(
35            (p.x / self.0).round() * self.0,
36            (p.y / self.0).round() * self.0,
37        )
38    }
39}
40
41/// Where a keyboard-driven canvas drop should place its pointer.
42///
43/// Pointer drops use their event geometry. This policy is only applied when
44/// the completed drop came from keyboard interaction.
45#[derive(Debug, Clone, Copy, PartialEq, Default)]
46pub enum CanvasKeyboardPlacement {
47    /// Use the selected zone geometry supplied by core keyboard navigation.
48    #[default]
49    Center,
50    /// Place at the canvas origin.
51    Origin,
52    /// Place at a fixed canvas-local point.
53    Fixed(Point),
54}
55
56/// Clamp reported top-left positions into `0..=width` × `0..=height`.
57///
58/// Bounds constrain the drop position returned in [`CanvasDrop::position`].
59/// They do not account for the dropped element's own width or height; subtract
60/// that yourself when you need the whole element to stay inside the canvas.
61#[derive(Debug, Clone, Copy, PartialEq)]
62pub struct Bounds {
63    pub width: f64,
64    pub height: f64,
65}
66
67impl Bounds {
68    pub fn clamp(&self, p: Point) -> Point {
69        Point::new(
70            clamp_axis(p.x, 0.0, self.width),
71            clamp_axis(p.y, 0.0, self.height),
72        )
73    }
74
75    /// Clamp a top-left position so an item of `width` × `height` stays fully
76    /// inside these bounds. If the item is larger than the bounds on an axis,
77    /// that axis pins to zero.
78    pub fn clamp_item(&self, p: Point, width: f64, height: f64) -> Point {
79        Point::new(
80            clamp_axis(p.x, 0.0, self.width - width),
81            clamp_axis(p.y, 0.0, self.height - height),
82        )
83    }
84
85    /// Clamp a rectangle by moving its top-left corner so the whole rectangle
86    /// stays inside these bounds. The returned point is the corrected
87    /// top-left.
88    pub fn clamp_rect(&self, rect: Rect) -> Point {
89        self.clamp_item(Point::new(rect.x, rect.y), rect.width, rect.height)
90    }
91}
92
93/// Convert a viewport/client point to canvas-local coordinates.
94pub fn client_to_canvas(client: Point, canvas_rect: Rect) -> Point {
95    client - canvas_rect.origin()
96}
97
98/// Convert a canvas-local point to viewport/client coordinates.
99pub fn canvas_to_client(point: Point, canvas_rect: Rect) -> Point {
100    point + canvas_rect.origin()
101}
102
103/// Compute the corrected top-left canvas placement from a raw canvas-relative
104/// pointer position and grab offset, then apply optional snap and bounds.
105pub fn canvas_position(
106    pointer: Point,
107    grab: Point,
108    snap: Option<SnapGrid>,
109    bounds: Option<Bounds>,
110) -> Point {
111    let mut position = pointer - grab;
112    if let Some(g) = snap {
113        position = g.snap(position);
114    }
115    if let Some(b) = bounds {
116        position = b.clamp(position);
117    }
118    position
119}
120
121/// Resolve the canvas-local pointer for a keyboard drop.
122pub fn canvas_keyboard_pointer(policy: CanvasKeyboardPlacement, element: Point) -> Point {
123    match policy {
124        CanvasKeyboardPlacement::Center => element,
125        CanvasKeyboardPlacement::Origin => Point::default(),
126        CanvasKeyboardPlacement::Fixed(point) => point,
127    }
128}
129
130fn clamp_axis(v: f64, min: f64, max: f64) -> f64 {
131    // std `f64::clamp` panics when a bound is NaN or when min > max. Treat a
132    // NaN bound as "unconstrained" on that side (rather than snapping the item
133    // to the origin), and an inverted *finite* range - e.g. an item larger than
134    // the container - pins to `min`, matching KeepInside's oversized behavior.
135    let lo = if min.is_nan() { f64::NEG_INFINITY } else { min };
136    let hi = if max.is_nan() { f64::INFINITY } else { max };
137    if lo > hi {
138        return if min.is_nan() { v } else { min };
139    }
140    v.clamp(lo, hi)
141}
142
143/// A canvas that reports drop positions.
144///
145/// Uses the shared `DndContext<T>`; start drags with the core `Draggable`
146/// (its recorded grab offset is what makes the drop position feel exact -
147/// the element lands where its ghost was, not where the pointer tip was).
148///
149/// While a drag is in flight the div carries `data-active="true"` (absent
150/// otherwise) - style the canvas as a target then, e.g. Tailwind
151/// `data-active:outline-dashed`.
152#[component]
153pub fn CanvasDropZone<T: Clone + PartialEq + 'static>(
154    /// Stable identity; auto-generated if omitted.
155    #[props(default)]
156    id: Option<ZoneId>,
157    /// Snap the corrected position to a grid.
158    #[props(default)]
159    snap: Option<SnapGrid>,
160    /// Clamp the corrected top-left position into these bounds.
161    #[props(default)]
162    bounds: Option<Bounds>,
163    /// Placement policy for keyboard-driven canvas drops.
164    #[props(default)]
165    keyboard: CanvasKeyboardPlacement,
166    /// Announced to screen readers when a keyboard drag targets the canvas.
167    #[props(default)]
168    label: Option<String>,
169    on_drop: EventHandler<CanvasDrop<T>>,
170    #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
171    children: Element,
172) -> Element {
173    let dnd = use_dnd::<T>();
174    let mut registry = use_zone_registry::<T>();
175    let auto_id = use_zone_id();
176    let zone_id = id.unwrap_or(auto_id);
177
178    // Mirror `snap`/`bounds` into signals so the registry callback - which is
179    // registered once (first render) - reads the *current* values, not the
180    // ones captured at mount. Keep them current during render so child probes
181    // and same-frame drops observe the latest geometry.
182    let mut snap_now = use_signal(|| snap);
183    let mut bounds_now = use_signal(|| bounds);
184    let mut keyboard_now = use_signal(|| keyboard);
185    if *snap_now.peek() != snap {
186        snap_now.set(snap);
187    }
188    if *bounds_now.peek() != bounds {
189        bounds_now.set(bounds);
190    }
191    if *keyboard_now.peek() != keyboard {
192        keyboard_now.set(keyboard);
193    }
194
195    // Turn a corrected drop at `pointer` (canvas-relative) into a CanvasDrop.
196    let place = move |payload: T, pointer: Point, grab: Point| {
197        let position = canvas_position(pointer, grab, *snap_now.peek(), *bounds_now.peek());
198        on_drop.call(CanvasDrop {
199            payload,
200            position,
201            pointer,
202        });
203    };
204
205    // Register as a zone so pointer and keyboard drags can drop here. The
206    // registry delivers a `DropOutcome`; `element` is the pointer relative to
207    // the canvas and `grab` is the pickup offset.
208    let parent = try_use_context::<ParentZone>().map(|p| p.0);
209    let mounted = use_signal(|| None::<Rc<MountedData>>);
210    let rect = use_signal(|| None::<Rect>);
211    let registered_drop = Callback::new(move |o: DropOutcome<T>| {
212        let pointer = if o.mode == DragMode::Keyboard {
213            canvas_keyboard_pointer(*keyboard_now.peek(), o.element)
214        } else {
215            o.element
216        };
217        place(o.payload, pointer, o.grab);
218    });
219    use_hook(|| {
220        registry.register(ZoneRecord {
221            id: zone_id,
222            parent,
223            label: label.clone(),
224            on_drop: registered_drop,
225            accepts: None,
226            mounted,
227            rect,
228        });
229    });
230    use_drop(move || {
231        registry.unregister(zone_id);
232    });
233    // Keep the registered label in sync if the prop changes across renders.
234    // Registry readers only `peek`, so this render-time write can't loop.
235    registry.sync_label(zone_id, label.clone());
236
237    rsx! {
238        div {
239            "data-active": if dnd.dragging() { "true" },
240            onmounted: move |evt: Event<MountedData>| {
241                let m: Rc<MountedData> = evt.data();
242                let mut mounted = mounted;
243                let mut rect = rect;
244                mounted.set(Some(m.clone()));
245                spawn(async move {
246                    if let Ok(r) = m.get_client_rect().await {
247                        rect.set(Some(Rect::new(
248                            r.origin.x,
249                            r.origin.y,
250                            r.size.width,
251                            r.size.height,
252                        )));
253                    }
254                });
255            },
256            ..attributes,
257            {children}
258        }
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    #[test]
267    fn snap_and_clamp() {
268        let g = SnapGrid(10.0);
269        let p = g.snap(Point::new(14.9, 15.1));
270        assert_eq!((p.x, p.y), (10.0, 20.0));
271        assert_eq!(
272            SnapGrid(0.0).snap(Point::new(3.3, 4.4)),
273            Point::new(3.3, 4.4)
274        );
275
276        let b = Bounds {
277            width: 100.0,
278            height: 50.0,
279        };
280        let p = b.clamp(Point::new(-5.0, 999.0));
281        assert_eq!((p.x, p.y), (0.0, 50.0));
282
283        let corrected = Point::new(107.0, 46.0) - Point::new(9.0, 8.0);
284        let positioned = b.clamp(g.snap(corrected));
285        assert_eq!(positioned, Point::new(100.0, 40.0));
286    }
287
288    #[test]
289    fn clamp_does_not_panic_on_non_finite_bounds() {
290        // Caller-supplied NaN/inf bounds must not panic (std `f64::clamp`
291        // would). A NaN bound is treated as unconstrained on that axis, and a
292        // negative position still can't go below the origin.
293        let b = Bounds {
294            width: f64::NAN,
295            height: f64::INFINITY,
296        };
297        let p = b.clamp(Point::new(25.0, 25.0));
298        assert_eq!((p.x, p.y), (25.0, 25.0), "unconstrained, unchanged");
299        let p = b.clamp(Point::new(-10.0, -10.0));
300        assert_eq!((p.x, p.y), (0.0, 0.0), "still floored at the origin");
301        // clamp_item is NaN-guarded via clamp_axis too.
302        let q = b.clamp_item(Point::new(-3.0, 10.0), 5.0, 5.0);
303        assert_eq!(q.x, 0.0);
304    }
305
306    #[test]
307    fn bounds_can_clamp_whole_items() {
308        let b = Bounds {
309            width: 100.0,
310            height: 50.0,
311        };
312
313        assert_eq!(
314            b.clamp_item(Point::new(90.0, 45.0), 20.0, 12.0),
315            Point::new(80.0, 38.0)
316        );
317        assert_eq!(
318            b.clamp_rect(Rect::new(-5.0, 60.0, 20.0, 10.0)),
319            Point::new(0.0, 40.0)
320        );
321        assert_eq!(
322            b.clamp_item(Point::new(20.0, 20.0), 150.0, 80.0),
323            Point::new(0.0, 0.0)
324        );
325    }
326
327    #[test]
328    fn coordinate_helpers_convert_between_client_and_canvas() {
329        let rect = Rect::new(40.0, 80.0, 320.0, 200.0);
330        let client = Point::new(64.0, 128.0);
331        let canvas = client_to_canvas(client, rect);
332
333        assert_eq!(canvas, Point::new(24.0, 48.0));
334        assert_eq!(canvas_to_client(canvas, rect), client);
335    }
336
337    #[test]
338    fn canvas_position_applies_grab_snap_then_bounds() {
339        let p = canvas_position(
340            Point::new(107.0, 46.0),
341            Point::new(9.0, 8.0),
342            Some(SnapGrid(10.0)),
343            Some(Bounds {
344                width: 100.0,
345                height: 50.0,
346            }),
347        );
348
349        assert_eq!(p, Point::new(100.0, 40.0));
350    }
351
352    #[test]
353    fn canvas_keyboard_pointer_uses_center_element_by_default() {
354        assert_eq!(
355            canvas_keyboard_pointer(CanvasKeyboardPlacement::default(), Point::new(40.0, 20.0)),
356            Point::new(40.0, 20.0)
357        );
358    }
359
360    #[test]
361    fn canvas_keyboard_pointer_can_use_origin() {
362        assert_eq!(
363            canvas_keyboard_pointer(CanvasKeyboardPlacement::Origin, Point::new(40.0, 20.0)),
364            Point::default()
365        );
366    }
367
368    #[test]
369    fn canvas_keyboard_pointer_can_use_fixed_point() {
370        assert_eq!(
371            canvas_keyboard_pointer(
372                CanvasKeyboardPlacement::Fixed(Point::new(12.0, 18.0)),
373                Point::new(40.0, 20.0),
374            ),
375            Point::new(12.0, 18.0)
376        );
377    }
378
379    #[test]
380    fn item_clamp_composes_after_canvas_position() {
381        let top_left = canvas_position(Point::new(156.0, 86.0), Point::new(4.0, 5.0), None, None);
382        let constrained = Bounds {
383            width: 160.0,
384            height: 90.0,
385        }
386        .clamp_item(top_left, 48.0, 32.0);
387
388        assert_eq!(top_left, Point::new(152.0, 81.0));
389        assert_eq!(constrained, Point::new(112.0, 58.0));
390    }
391}