Skip to main content

nightshade_api/web/
pointer_drag.rs

1//! Pointer-based drag and drop that works inside webviews where HTML5 DnD does not fire.
2//! `provide_drag()` at the root, then wrap items in `DragSource` and targets in `DropZone`,
3//! and render one `DragLayer`.
4
5use std::collections::HashMap;
6
7use leptos::prelude::*;
8
9/// What is being dragged: a `kind` used by drop zones to accept or reject it, an
10/// `id` identifying the source, and a `label` shown in the drag preview.
11#[derive(Clone)]
12pub struct DragPayload {
13    /// Category of the dragged item, matched against by drop zones.
14    pub kind: String,
15    /// Identifier of the dragged item.
16    pub id: String,
17    /// Text shown in the floating drag preview.
18    pub label: String,
19}
20
21impl DragPayload {
22    /// Creates a payload from a kind, id, and label.
23    pub fn new(kind: impl Into<String>, id: impl Into<String>, label: impl Into<String>) -> Self {
24        Self {
25            kind: kind.into(),
26            id: id.into(),
27            label: label.into(),
28        }
29    }
30}
31
32const DRAG_THRESHOLD: f64 = 4.0;
33
34/// The shared drag session, provided via context by `provide_drag`. Tracks the
35/// active payload, the pointer position, the hovered drop zone, and the registry
36/// of drop callbacks. `Copy`, so it can be captured in event handlers.
37#[derive(Clone, Copy)]
38pub struct DragState {
39    payload: RwSignal<Option<DragPayload>>,
40    position: RwSignal<(f64, f64)>,
41    over: RwSignal<Option<String>>,
42    zones: StoredValue<HashMap<String, Callback<DragPayload>>>,
43    pending: StoredValue<Option<(DragPayload, f64, f64)>>,
44}
45
46impl DragState {
47    /// Reactively reports whether a drag is currently in progress.
48    pub fn active(&self) -> bool {
49        self.payload.get().is_some()
50    }
51
52    /// Arms a potential drag at a start position; it begins once the pointer moves past the threshold.
53    pub fn arm(&self, payload: DragPayload, x: f64, y: f64) {
54        self.pending.set_value(Some((payload, x, y)));
55    }
56
57    /// Reactively reads the payload of the active drag, if any.
58    pub fn payload(&self) -> Option<DragPayload> {
59        self.payload.get()
60    }
61
62    /// Reactively reads the current pointer position.
63    pub fn position(&self) -> (f64, f64) {
64        self.position.get()
65    }
66
67    /// Reactively reads the id of the drop zone currently under the pointer, if any.
68    pub fn over(&self) -> Option<String> {
69        self.over.get()
70    }
71
72    /// Begins an active drag at the given position with the given payload.
73    pub fn start(&self, payload: DragPayload, x: f64, y: f64) {
74        self.position.set((x, y));
75        self.payload.set(Some(payload));
76    }
77
78    /// Marks the drop zone `id` as hovered while a drag is active.
79    pub fn set_over(&self, id: String) {
80        if self.payload.get_untracked().is_some() {
81            self.over.set(Some(id));
82        }
83    }
84
85    /// Clears the hovered drop zone if it is currently `id`.
86    pub fn clear_over(&self, id: &str) {
87        if self.over.get_untracked().as_deref() == Some(id) {
88            self.over.set(None);
89        }
90    }
91
92    fn register(&self, id: String, on_drop: Callback<DragPayload>) {
93        self.zones.update_value(|zones| {
94            zones.insert(id, on_drop);
95        });
96    }
97
98    fn unregister(&self, id: &str) {
99        self.zones.update_value(|zones| {
100            zones.remove(id);
101        });
102    }
103
104    fn finish(&self) {
105        let payload = self.payload.get_untracked();
106        let over = self.over.get_untracked();
107        if let (Some(payload), Some(over)) = (payload, over) {
108            let callback = self.zones.with_value(|zones| zones.get(&over).copied());
109            if let Some(callback) = callback {
110                callback.run(payload);
111            }
112        }
113        self.payload.set(None);
114        self.over.set(None);
115    }
116}
117
118/// Creates the drag session, provides it via context, and installs the window
119/// pointer listeners that promote an armed drag to active, track the pointer, and
120/// commit the drop on release. Call once near the root; returns the `DragState`.
121pub fn provide_drag() -> DragState {
122    let state = DragState {
123        payload: RwSignal::new(None),
124        position: RwSignal::new((0.0, 0.0)),
125        over: RwSignal::new(None),
126        zones: StoredValue::new(HashMap::new()),
127        pending: StoredValue::new(None),
128    };
129    provide_context(state);
130    let move_handle = window_event_listener(
131        leptos::ev::pointermove,
132        move |event: web_sys::PointerEvent| {
133            let x = event.client_x() as f64;
134            let y = event.client_y() as f64;
135            if state.payload.get_untracked().is_some() {
136                state.position.set((x, y));
137            } else if let Some((payload, start_x, start_y)) = state.pending.get_value()
138                && (x - start_x).hypot(y - start_y) > DRAG_THRESHOLD
139            {
140                state.pending.set_value(None);
141                state.start(payload, x, y);
142            }
143        },
144    );
145    let up_handle = window_event_listener(leptos::ev::pointerup, move |_| {
146        state.pending.set_value(None);
147        if state.payload.get_untracked().is_some() {
148            state.finish();
149        }
150    });
151    on_cleanup(move || {
152        move_handle.remove();
153        up_handle.remove();
154    });
155    state
156}
157
158/// Reads the drag session from context. Returns a detached, no-op `DragState` if
159/// `provide_drag` was never called.
160pub fn use_drag() -> DragState {
161    use_context::<DragState>().unwrap_or_else(|| DragState {
162        payload: RwSignal::new(None),
163        position: RwSignal::new((0.0, 0.0)),
164        over: RwSignal::new(None),
165        zones: StoredValue::new(HashMap::new()),
166        pending: StoredValue::new(None),
167    })
168}
169
170/// Wraps children as a draggable item. Arms a drag with a `DragPayload` built
171/// from `kind`, `id`, and `label` on pointer down; the drag starts once the
172/// pointer moves past the threshold.
173#[component]
174pub fn DragSource(
175    #[prop(into)] kind: String,
176    #[prop(into)] id: String,
177    #[prop(into, optional)] label: String,
178    #[prop(into, optional)] class: String,
179    children: Children,
180) -> impl IntoView {
181    let drag = use_drag();
182    let payload = StoredValue::new(DragPayload::new(kind, id, label));
183    view! {
184        <div
185            class=format!("nightshade-drag-source {class}")
186            on:pointerdown=move |event: web_sys::PointerEvent| {
187                drag.arm(payload.get_value(), event.client_x() as f64, event.client_y() as f64);
188            }
189        >
190            {children()}
191        </div>
192    }
193}
194
195/// Wraps children as a drop target registered under `id`. Highlights while a
196/// drag hovers it and runs `on_drop` with the dropped `DragPayload` when a drag
197/// is released over it.
198#[component]
199pub fn DropZone(
200    #[prop(into)] id: String,
201    on_drop: Callback<DragPayload>,
202    #[prop(into, optional)] class: String,
203    children: Children,
204) -> impl IntoView {
205    let drag = use_drag();
206    let id = StoredValue::new(id);
207    drag.register(id.get_value(), on_drop);
208    on_cleanup(move || drag.unregister(&id.get_value()));
209    let is_over = move || drag.over().as_deref() == Some(id.get_value().as_str());
210    view! {
211        <div
212            class=format!("nightshade-drop-zone {class}")
213            class:over=is_over
214            on:pointerenter=move |_| drag.set_over(id.get_value())
215            on:pointermove=move |_| drag.set_over(id.get_value())
216            on:pointerleave=move |_| drag.clear_over(&id.get_value())
217        >
218            {children()}
219        </div>
220    }
221}
222
223/// Renders the floating drag preview that follows the pointer while a drag is
224/// active, showing the payload's label. Render one of these near the root.
225#[component]
226pub fn DragLayer() -> impl IntoView {
227    let drag = use_drag();
228    view! {
229        <Show when=move || drag.active() fallback=|| ()>
230            <div
231                class="nightshade-drag-preview"
232                style=move || {
233                    let (x, y) = drag.position();
234                    format!("left:{}px;top:{}px", x + 10.0, y + 10.0)
235                }
236            >
237                {move || drag.payload().map(|payload| payload.label)}
238            </div>
239        </Show>
240    }
241}