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}