dioxus_dnd/external.rs
1//! Drops arriving from *outside* your app - selected text, links dragged from
2//! another tab, content from other applications - plus typed serde payloads
3//! over the `DataTransfer` bridge for interop scenarios the Rust-side
4//! context can't reach.
5//!
6//! For drags between elements of your own app, prefer the core context: it
7//! carries any `Clone` type with zero serialization. Reach for this module
8//! when the *browser* is the transport.
9
10use dioxus::html::HasFileData;
11use dioxus::prelude::*;
12
13use crate::core::{client_point, element_point, Point};
14
15/// Content the browser handed us from an external drag, best-effort decoded
16/// in order of specificity.
17///
18/// **Untrusted input.** These payloads come from outside your app and are
19/// fully attacker-controlled. Treat them like any other external data:
20/// - [`ExternalPayload::Html`] is arbitrary markup - sanitize it before
21/// rendering via `dangerous_inner_html` (raw insertion is stored/reflected
22/// XSS).
23/// - [`ExternalPayload::Url`] may carry a `javascript:` or `data:` scheme -
24/// scheme-check before navigating to it or building an anchor from it.
25#[derive(Debug, Clone, PartialEq)]
26pub enum ExternalPayload {
27 /// `text/uri-list` - links dragged from the URL bar, bookmarks, other tabs.
28 /// May use any scheme; validate before use.
29 Url(String),
30 /// `text/html` - rich content (e.g. a selection dragged from a page).
31 /// Arbitrary untrusted markup; sanitize before rendering.
32 Html(String),
33 /// `text/plain`.
34 Text(String),
35}
36
37/// A decoded external drop.
38#[derive(Clone, PartialEq)]
39pub struct ExternalDrop {
40 /// All representations the browser offered, most specific first.
41 pub payloads: Vec<ExternalPayload>,
42 /// Files, if the drag carried any (also see [`crate::files`]).
43 pub files: Vec<dioxus::html::FileData>,
44 pub client: Point,
45 pub element: Point,
46}
47
48impl ExternalDrop {
49 /// The most specific text-ish payload, if any.
50 pub fn best(&self) -> Option<&ExternalPayload> {
51 self.payloads.first()
52 }
53
54 /// First URL payload, parsed out of `text/uri-list` (one URL per line,
55 /// `#` lines are comments).
56 pub fn url(&self) -> Option<&str> {
57 self.payloads.iter().find_map(|p| match p {
58 ExternalPayload::Url(u) => Some(u.as_str()),
59 _ => None,
60 })
61 }
62
63 /// First plain-text payload.
64 pub fn text(&self) -> Option<&str> {
65 self.payloads.iter().find_map(|p| match p {
66 ExternalPayload::Text(t) => Some(t.as_str()),
67 _ => None,
68 })
69 }
70}
71
72/// Decode an incoming drag event's `DataTransfer` into [`ExternalPayload`]s.
73pub fn classify(evt: &DragEvent) -> Vec<ExternalPayload> {
74 let dt = evt.data_transfer();
75 let mut out = Vec::new();
76 if let Some(uris) = dt.get_data("text/uri-list") {
77 for line in uris.lines() {
78 let line = line.trim();
79 if !line.is_empty() && !line.starts_with('#') {
80 out.push(ExternalPayload::Url(line.to_string()));
81 }
82 }
83 }
84 if let Some(html) = dt.get_data("text/html") {
85 if !html.is_empty() {
86 out.push(ExternalPayload::Html(html));
87 }
88 }
89 if let Some(text) = dt.get_data("text/plain") {
90 if !text.is_empty() {
91 out.push(ExternalPayload::Text(text));
92 }
93 }
94 out
95}
96
97/// A zone accepting drops that originate outside the app.
98///
99/// While a drag hovers the zone the div carries `data-over="true"` (absent
100/// otherwise) for styling without `on_hover` wiring.
101#[component]
102pub fn ExternalDropZone(
103 on_drop: EventHandler<ExternalDrop>,
104 /// Fired with `true`/`false` on hover enter/leave.
105 #[props(default)]
106 on_hover: Option<EventHandler<bool>>,
107 #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
108 children: Element,
109) -> Element {
110 let mut depth = use_signal(|| 0u32);
111
112 rsx! {
113 div {
114 "data-over": if depth() > 0 { "true" },
115 ondragover: move |evt: DragEvent| {
116 evt.prevent_default();
117 },
118 ondragenter: move |evt: DragEvent| {
119 evt.prevent_default();
120 let d = depth() + 1;
121 depth.set(d);
122 if d == 1 {
123 if let Some(h) = &on_hover {
124 h.call(true);
125 }
126 }
127 },
128 ondragleave: move |_| {
129 let d = depth().saturating_sub(1);
130 depth.set(d);
131 if d == 0 {
132 if let Some(h) = &on_hover {
133 h.call(false);
134 }
135 }
136 },
137 ondrop: move |evt: DragEvent| {
138 evt.prevent_default();
139 depth.set(0);
140 if let Some(h) = &on_hover {
141 h.call(false);
142 }
143 let payloads = classify(&evt);
144 let files = evt.files();
145 if payloads.is_empty() && files.is_empty() {
146 return;
147 }
148 on_drop.call(ExternalDrop {
149 payloads,
150 files,
151 client: client_point(&evt),
152 element: element_point(&evt),
153 });
154 },
155 ..attributes,
156 {children}
157 }
158 }
159}
160
161/// Typed payloads over the native `DataTransfer` (JSON-encoded under
162/// `application/json`, wire-compatible with dioxus-html's own
163/// `store`/`retrieve`). Useful when the browser must carry the data - e.g.
164/// dragging between two separate Dioxus apps or windows - at the cost of
165/// requiring `Serialize`/`Deserialize`.
166#[cfg(feature = "serde")]
167pub mod typed {
168 use dioxus::prelude::*;
169
170 /// Store a typed payload on the drag's `DataTransfer`. Call in `ondragstart`.
171 pub fn store<T: serde::Serialize>(evt: &DragEvent, value: &T) -> Result<(), String> {
172 let json = serde_json::to_string(value).map_err(|e| e.to_string())?;
173 evt.data_transfer().set_data("application/json", &json)
174 }
175
176 /// Retrieve a typed payload from a drop's `DataTransfer`. Call in `ondrop`.
177 pub fn retrieve<T: for<'de> serde::Deserialize<'de>>(
178 evt: &DragEvent,
179 ) -> Result<Option<T>, String> {
180 match evt.data_transfer().get_data("application/json") {
181 Some(json) => serde_json::from_str(&json)
182 .map(Some)
183 .map_err(|e| e.to_string()),
184 None => Ok(None),
185 }
186 }
187}