Skip to main content

dioxus_dnd/
canvas.rs

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