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
105    rsx! {
106        div {
107            "data-over": if depth() > 0 { "true" },
108            ondragover: move |evt: DragEvent| {
109                evt.prevent_default();
110            },
111            ondragenter: move |evt: DragEvent| {
112                evt.prevent_default();
113                let d = depth() + 1;
114                depth.set(d);
115                if d == 1 {
116                    if let Some(h) = &on_hover {
117                        h.call(true);
118                    }
119                }
120            },
121            ondragleave: move |_| {
122                let d = depth().saturating_sub(1);
123                depth.set(d);
124                if d == 0 {
125                    if let Some(h) = &on_hover {
126                        h.call(false);
127                    }
128                }
129            },
130            ondrop: move |evt: DragEvent| {
131                evt.prevent_default();
132                depth.set(0);
133                if let Some(h) = &on_hover {
134                    h.call(false);
135                }
136                let payloads = classify(&evt);
137                let files = evt.files();
138                if payloads.is_empty() && files.is_empty() {
139                    return;
140                }
141                on_drop.call(ExternalDrop {
142                    payloads,
143                    files,
144                    client: client_point(&evt),
145                    element: element_point(&evt),
146                });
147            },
148            ..attributes,
149            {children}
150        }
151    }
152}
153
154/// Typed payloads over the native `DataTransfer` (JSON-encoded under
155/// [`typed::MIME`], wire-compatible with dioxus-html's own
156/// `store`/`retrieve`). Useful when the browser must carry the data - e.g.
157/// dragging between two separate Dioxus apps - at the cost of requiring
158/// `Serialize`/`Deserialize`. (Between windows of ONE app, prefer a
159/// [`crate::core::DndWorld`]: live Rust payloads, no serialization.)
160///
161/// Component wrappers: [`TypedDropZone`] here and
162/// [`crate::dragout::TypedDragSource`] on the outbound side.
163#[cfg(feature = "serde")]
164pub mod typed {
165    use dioxus::html::DataTransfer;
166    use dioxus::prelude::*;
167
168    /// The format typed payloads travel under. A single hardcoded MIME
169    /// keeps the wire format compatible with dioxus-html's own
170    /// `DataTransfer::store`/`retrieve` helpers.
171    pub const MIME: &str = "application/json";
172
173    /// Store a typed payload on a `DataTransfer` directly. The building
174    /// block behind [`store()`]; also the testable seam.
175    pub fn store_in<T: serde::Serialize>(dt: &DataTransfer, value: &T) -> Result<(), String> {
176        let json = serde_json::to_string(value).map_err(|e| e.to_string())?;
177        dt.set_data(MIME, &json)
178    }
179
180    /// Retrieve a typed payload from a `DataTransfer` directly.
181    /// `Ok(None)` when the drag carries no [`MIME`] entry (not a typed
182    /// drag); `Err` when it does but the JSON doesn't decode as `T`.
183    /// "No entry" includes the empty string: the DOM's `getData` returns
184    /// `""` for absent formats rather than null, so on web every untyped
185    /// drag reads as `Some("")` (the same reality [`super::classify`]
186    /// guards against).
187    pub fn retrieve_from<T: for<'de> serde::Deserialize<'de>>(
188        dt: &DataTransfer,
189    ) -> Result<Option<T>, String> {
190        match dt.get_data(MIME) {
191            Some(json) if !json.trim().is_empty() => serde_json::from_str(&json)
192                .map(Some)
193                .map_err(|e| e.to_string()),
194            _ => Ok(None),
195        }
196    }
197
198    /// Store a typed payload on the drag's `DataTransfer`. Call in `ondragstart`.
199    pub fn store<T: serde::Serialize>(evt: &DragEvent, value: &T) -> Result<(), String> {
200        store_in(&evt.data_transfer(), value)
201    }
202
203    /// Retrieve a typed payload from a drop's `DataTransfer`. Call in `ondrop`.
204    pub fn retrieve<T: for<'de> serde::Deserialize<'de>>(
205        evt: &DragEvent,
206    ) -> Result<Option<T>, String> {
207        retrieve_from(&evt.data_transfer())
208    }
209}
210
211/// A successfully decoded typed drop, as delivered by [`TypedDropZone`].
212#[cfg(feature = "serde")]
213#[derive(Debug, Clone, PartialEq)]
214pub struct TypedDrop<T> {
215    /// The decoded payload. Like every external payload it crossed an app
216    /// boundary and is untrusted input - validate it like any other.
217    pub payload: T,
218    /// Pointer position in client (viewport) coordinates at drop time.
219    pub client: Point,
220    /// Pointer position relative to the zone's element.
221    pub element: Point,
222}
223
224/// A zone accepting typed drags (see [`typed`]) - JSON under
225/// [`typed::MIME`] decoded to `T` and delivered as a [`TypedDrop`].
226///
227/// Handles the HTML5 boilerplate like [`ExternalDropZone`]: `preventDefault`
228/// on dragover, enter/leave depth counting, and `data-over="true"` while
229/// hovered. One honest limitation, spec-imposed: during hover the payload
230/// is unreadable (`DataTransfer` protected mode) and dioxus exposes no
231/// `types` list, so `data-over` lights for ANY drag hovering the zone -
232/// acceptance can only be judged at drop time. Drags with no typed entry
233/// at all are ignored silently at drop; drags whose JSON fails to decode
234/// as `T` fire `on_invalid` with the decode error.
235#[cfg(feature = "serde")]
236#[component]
237pub fn TypedDropZone<T: serde::de::DeserializeOwned + Clone + PartialEq + 'static>(
238    /// Fired with the decoded payload on a successful typed drop.
239    on_drop: EventHandler<TypedDrop<T>>,
240    /// Fired when a drop carried a [`typed::MIME`] entry that failed to
241    /// decode as `T` (the decode error, for diagnostics).
242    #[props(default)]
243    on_invalid: Option<EventHandler<String>>,
244    /// Fired with `true`/`false` on hover enter/leave.
245    #[props(default)]
246    on_hover: Option<EventHandler<bool>>,
247    #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
248    children: Element,
249) -> Element {
250    let mut depth = use_signal(|| 0u32);
251
252    rsx! {
253        div {
254            "data-over": if depth() > 0 { "true" },
255            ondragover: move |evt: DragEvent| {
256                evt.prevent_default();
257            },
258            ondragenter: move |evt: DragEvent| {
259                evt.prevent_default();
260                let d = depth() + 1;
261                depth.set(d);
262                if d == 1 {
263                    if let Some(h) = &on_hover {
264                        h.call(true);
265                    }
266                }
267            },
268            ondragleave: move |_| {
269                let d = depth().saturating_sub(1);
270                depth.set(d);
271                if d == 0 {
272                    if let Some(h) = &on_hover {
273                        h.call(false);
274                    }
275                }
276            },
277            ondrop: move |evt: DragEvent| {
278                evt.prevent_default();
279                depth.set(0);
280                if let Some(h) = &on_hover {
281                    h.call(false);
282                }
283                match typed::retrieve::<T>(&evt) {
284                    Ok(Some(payload)) => on_drop.call(TypedDrop {
285                        payload,
286                        client: client_point(&evt),
287                        element: element_point(&evt),
288                    }),
289                    // No typed entry: not a typed drag - not ours.
290                    Ok(None) => {}
291                    Err(e) => {
292                        if let Some(h) = &on_invalid {
293                            h.call(e);
294                        }
295                    }
296                }
297            },
298            ..attributes,
299            {children}
300        }
301    }
302}