Skip to main content

dioxus_dnd/
external.rs

1#![doc = include_str!("../docs/api/external-content.md")]
2
3use dioxus::html::HasFileData;
4use dioxus::prelude::*;
5
6use crate::core::{client_point, element_point, Point};
7
8/// Content the browser handed us from an external drag, best-effort decoded
9/// in order of specificity.
10///
11/// **Untrusted input.** These payloads come from outside your app and are
12/// fully attacker-controlled. Treat them like any other external data:
13/// - [`ExternalPayload::Html`] is arbitrary markup - sanitize it before
14///   rendering via `dangerous_inner_html` (raw insertion is stored/reflected
15///   XSS).
16/// - [`ExternalPayload::Url`] may carry a `javascript:` or `data:` scheme -
17///   scheme-check before navigating to it or building an anchor from it.
18#[derive(Debug, Clone, PartialEq)]
19pub enum ExternalPayload {
20    /// `text/uri-list` - links dragged from the URL bar, bookmarks, other tabs.
21    /// May use any scheme; validate before use.
22    Url(String),
23    /// `text/html` - rich content (e.g. a selection dragged from a page).
24    /// Arbitrary untrusted markup; sanitize before rendering.
25    Html(String),
26    /// `text/plain`.
27    Text(String),
28}
29
30/// A decoded external drop.
31#[derive(Clone, PartialEq)]
32pub struct ExternalDrop {
33    /// All representations the browser offered, most specific first.
34    pub payloads: Vec<ExternalPayload>,
35    /// Files, if the drag carried any (also see [`crate::files`]).
36    pub files: Vec<dioxus::html::FileData>,
37    pub client: Point,
38    pub element: Point,
39}
40
41impl ExternalDrop {
42    /// The most specific text-ish payload, if any.
43    pub fn best(&self) -> Option<&ExternalPayload> {
44        self.payloads.first()
45    }
46
47    /// First URL payload, parsed out of `text/uri-list` (one URL per line,
48    /// `#` lines are comments).
49    pub fn url(&self) -> Option<&str> {
50        self.payloads.iter().find_map(|p| match p {
51            ExternalPayload::Url(u) => Some(u.as_str()),
52            _ => None,
53        })
54    }
55
56    /// First plain-text payload.
57    pub fn text(&self) -> Option<&str> {
58        self.payloads.iter().find_map(|p| match p {
59            ExternalPayload::Text(t) => Some(t.as_str()),
60            _ => None,
61        })
62    }
63}
64
65/// Decode an incoming drag event's `DataTransfer` into [`ExternalPayload`]s.
66pub fn classify(evt: &DragEvent) -> Vec<ExternalPayload> {
67    let dt = evt.data_transfer();
68    let mut out = Vec::new();
69    if let Some(uris) = dt.get_data("text/uri-list") {
70        for line in uris.lines() {
71            let line = line.trim();
72            if !line.is_empty() && !line.starts_with('#') {
73                out.push(ExternalPayload::Url(line.to_string()));
74            }
75        }
76    }
77    if let Some(html) = dt.get_data("text/html") {
78        if !html.is_empty() {
79            out.push(ExternalPayload::Html(html));
80        }
81    }
82    if let Some(text) = dt.get_data("text/plain") {
83        if !text.is_empty() {
84            out.push(ExternalPayload::Text(text));
85        }
86    }
87    out
88}
89
90/// A zone accepting drops that originate outside the app.
91///
92/// While a drag hovers the zone the div carries `data-over="true"` (absent
93/// otherwise) for styling without `on_hover` wiring.
94#[component]
95pub fn ExternalDropZone(
96    on_drop: EventHandler<ExternalDrop>,
97    /// Fired with `true`/`false` on hover enter/leave.
98    #[props(default)]
99    on_hover: Option<EventHandler<bool>>,
100    #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
101    children: Element,
102) -> Element {
103    let mut depth = use_signal(|| 0u32);
104    let mut attributes = attributes;
105    crate::core::components::protect_attributes(
106        &mut attributes,
107        &[
108            "data-over",
109            "ondragover",
110            "ondragenter",
111            "ondragleave",
112            "ondrop",
113        ],
114    );
115
116    rsx! {
117        div {
118            "data-over": if depth() > 0 { "true" },
119            ondragover: move |evt: DragEvent| {
120                evt.prevent_default();
121            },
122            ondragenter: move |evt: DragEvent| {
123                evt.prevent_default();
124                let d = depth() + 1;
125                depth.set(d);
126                if d == 1 {
127                    if let Some(h) = &on_hover {
128                        h.call(true);
129                    }
130                }
131            },
132            ondragleave: move |_| {
133                let d = depth().saturating_sub(1);
134                depth.set(d);
135                if d == 0 {
136                    if let Some(h) = &on_hover {
137                        h.call(false);
138                    }
139                }
140            },
141            ondrop: move |evt: DragEvent| {
142                evt.prevent_default();
143                depth.set(0);
144                if let Some(h) = &on_hover {
145                    h.call(false);
146                }
147                let payloads = classify(&evt);
148                let files = evt.files();
149                if payloads.is_empty() && files.is_empty() {
150                    return;
151                }
152                on_drop.call(ExternalDrop {
153                    payloads,
154                    files,
155                    client: client_point(&evt),
156                    element: element_point(&evt),
157                });
158            },
159            ..attributes,
160            {children}
161        }
162    }
163}
164
165/// Typed payloads over the native `DataTransfer` (JSON-encoded under
166/// [`typed::MIME`], wire-compatible with dioxus-html's own
167/// `store`/`retrieve`). Useful when the browser must carry the data - e.g.
168/// dragging between two separate Dioxus apps - at the cost of requiring
169/// `Serialize`/`Deserialize`. (Between windows of ONE app, prefer a
170/// [`crate::core::DndWorld`]: live Rust payloads, no serialization.)
171///
172/// Component wrappers: [`TypedDropZone`] here and
173/// [`crate::dragout::TypedDragSource`] on the outbound side.
174#[cfg(feature = "serde")]
175pub mod typed {
176    use dioxus::html::DataTransfer;
177    use dioxus::prelude::*;
178
179    /// The format typed payloads travel under. A single hardcoded MIME
180    /// keeps the wire format compatible with dioxus-html's own
181    /// `DataTransfer::store`/`retrieve` helpers.
182    pub const MIME: &str = "application/json";
183
184    /// Store a typed payload on a `DataTransfer` directly. The building
185    /// block behind [`store()`]; also the testable seam.
186    pub fn store_in<T: serde::Serialize>(dt: &DataTransfer, value: &T) -> Result<(), String> {
187        let json = serde_json::to_string(value).map_err(|e| e.to_string())?;
188        dt.set_data(MIME, &json)
189    }
190
191    /// Retrieve a typed payload from a `DataTransfer` directly.
192    /// `Ok(None)` when the drag carries no [`MIME`] entry (not a typed
193    /// drag); `Err` when it does but the JSON doesn't decode as `T`.
194    /// "No entry" includes the empty string: the DOM's `getData` returns
195    /// `""` for absent formats rather than null, so on web every untyped
196    /// drag reads as `Some("")` (the same reality [`super::classify`]
197    /// guards against).
198    pub fn retrieve_from<T: for<'de> serde::Deserialize<'de>>(
199        dt: &DataTransfer,
200    ) -> Result<Option<T>, String> {
201        match dt.get_data(MIME) {
202            Some(json) if !json.trim().is_empty() => serde_json::from_str(&json)
203                .map(Some)
204                .map_err(|e| e.to_string()),
205            _ => Ok(None),
206        }
207    }
208
209    /// Store a typed payload on the drag's `DataTransfer`. Call in `ondragstart`.
210    pub fn store<T: serde::Serialize>(evt: &DragEvent, value: &T) -> Result<(), String> {
211        store_in(&evt.data_transfer(), value)
212    }
213
214    /// Retrieve a typed payload from a drop's `DataTransfer`. Call in `ondrop`.
215    pub fn retrieve<T: for<'de> serde::Deserialize<'de>>(
216        evt: &DragEvent,
217    ) -> Result<Option<T>, String> {
218        retrieve_from(&evt.data_transfer())
219    }
220}
221
222/// A successfully decoded typed drop, as delivered by [`TypedDropZone`].
223#[cfg(feature = "serde")]
224#[derive(Debug, Clone, PartialEq)]
225pub struct TypedDrop<T> {
226    /// The decoded payload. Like every external payload it crossed an app
227    /// boundary and is untrusted input - validate it like any other.
228    pub payload: T,
229    /// Pointer position in client (viewport) coordinates at drop time.
230    pub client: Point,
231    /// Pointer position relative to the zone's element.
232    pub element: Point,
233}
234
235/// A zone accepting typed drags (see [`typed`]) - JSON under
236/// [`typed::MIME`] decoded to `T` and delivered as a [`TypedDrop`].
237///
238/// Handles the HTML5 boilerplate like [`ExternalDropZone`]: `preventDefault`
239/// on dragover, enter/leave depth counting, and `data-over="true"` while
240/// hovered. One honest limitation, spec-imposed: during hover the payload
241/// is unreadable (`DataTransfer` protected mode) and dioxus exposes no
242/// `types` list, so `data-over` lights for ANY drag hovering the zone -
243/// acceptance can only be judged at drop time. Drags with no typed entry
244/// at all are ignored silently at drop; drags whose JSON fails to decode
245/// as `T` fire `on_invalid` with the decode error.
246#[cfg(feature = "serde")]
247#[component]
248pub fn TypedDropZone<T: serde::de::DeserializeOwned + Clone + PartialEq + 'static>(
249    /// Fired with the decoded payload on a successful typed drop.
250    on_drop: EventHandler<TypedDrop<T>>,
251    /// Fired when a drop carried a [`typed::MIME`] entry that failed to
252    /// decode as `T` (the decode error, for diagnostics).
253    #[props(default)]
254    on_invalid: Option<EventHandler<String>>,
255    /// Fired with `true`/`false` on hover enter/leave.
256    #[props(default)]
257    on_hover: Option<EventHandler<bool>>,
258    #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
259    children: Element,
260) -> Element {
261    let mut depth = use_signal(|| 0u32);
262    let mut attributes = attributes;
263    crate::core::components::protect_attributes(
264        &mut attributes,
265        &[
266            "data-over",
267            "ondragover",
268            "ondragenter",
269            "ondragleave",
270            "ondrop",
271        ],
272    );
273
274    rsx! {
275        div {
276            "data-over": if depth() > 0 { "true" },
277            ondragover: move |evt: DragEvent| {
278                evt.prevent_default();
279            },
280            ondragenter: move |evt: DragEvent| {
281                evt.prevent_default();
282                let d = depth() + 1;
283                depth.set(d);
284                if d == 1 {
285                    if let Some(h) = &on_hover {
286                        h.call(true);
287                    }
288                }
289            },
290            ondragleave: move |_| {
291                let d = depth().saturating_sub(1);
292                depth.set(d);
293                if d == 0 {
294                    if let Some(h) = &on_hover {
295                        h.call(false);
296                    }
297                }
298            },
299            ondrop: move |evt: DragEvent| {
300                evt.prevent_default();
301                depth.set(0);
302                if let Some(h) = &on_hover {
303                    h.call(false);
304                }
305                match typed::retrieve::<T>(&evt) {
306                    Ok(Some(payload)) => on_drop.call(TypedDrop {
307                        payload,
308                        client: client_point(&evt),
309                        element: element_point(&evt),
310                    }),
311                    // No typed entry: not a typed drag - not ours.
312                    Ok(None) => {}
313                    Err(e) => {
314                        if let Some(h) = &on_invalid {
315                            h.call(e);
316                        }
317                    }
318                }
319            },
320            ..attributes,
321            {children}
322        }
323    }
324}