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}