dioxus_dnd/dragout.rs
1//! Drag *out* of your app - the mirror of [`crate::external`]. Wrap content
2//! in [`ExternalDragSource`] and users can drag it into other browser tabs,
3//! the URL bar, text editors, or any application that accepts the standard
4//! `DataTransfer` formats.
5//!
6//! ```text
7//! ExternalDragSource {
8//! content: OutboundContent::url("https://dioxuslabs.com", Some("Dioxus")),
9//! a { href: "https://dioxuslabs.com", "Dioxus" }
10//! }
11//! ```
12//!
13//! No provider needed - the browser is the transport here, not the shared
14//! context. (For dragging typed Rust payloads between two of *your own*
15//! windows, see `external::typed` with the `serde` feature.)
16
17use dioxus::prelude::*;
18
19use crate::core::DropEffect;
20
21/// What to place on the outbound `DataTransfer`.
22#[derive(Debug, Clone, PartialEq)]
23pub enum OutboundContent {
24 /// Plain text (`text/plain`).
25 Text(String),
26 /// A link: written as `text/uri-list` *and* `text/plain` (and, with a
27 /// title, `text/html` as an anchor) so maximal targets understand it.
28 Url {
29 url: String,
30 /// Optional human title, used for the HTML representation.
31 title: Option<String>,
32 },
33 /// Rich content: `text/html` plus a plain-text fallback.
34 Html {
35 html: String,
36 /// Written as `text/plain` for targets that don't take HTML.
37 fallback_text: String,
38 },
39 /// Raw `(format, data)` pairs, written verbatim in order.
40 Custom(Vec<(String, String)>),
41}
42
43impl OutboundContent {
44 /// Convenience constructor for [`OutboundContent::Url`].
45 pub fn url(url: impl Into<String>, title: Option<&str>) -> Self {
46 Self::Url {
47 url: url.into(),
48 title: title.map(str::to_string),
49 }
50 }
51
52 /// The `(format, data)` pairs this content writes, in order. Pure, for
53 /// testability.
54 pub fn entries(&self) -> Vec<(String, String)> {
55 match self {
56 OutboundContent::Text(t) => vec![("text/plain".into(), t.clone())],
57 OutboundContent::Url { url, title } => {
58 // `text/uri-list` / `text/plain` are plain-text formats, so the
59 // raw url is written verbatim. Only the `text/html` anchor is an
60 // injection surface: escape both fields for their context, and
61 // omit the `href` for dangerous schemes (javascript:/data:/…)
62 // so a hostile url can't carry an active link into the target.
63 let mut out = vec![
64 ("text/uri-list".into(), url.clone()),
65 ("text/plain".into(), url.clone()),
66 ];
67 if let Some(title) = title {
68 let anchor = if is_safe_href(url) {
69 format!(
70 r#"<a href="{}">{}</a>"#,
71 escape_html_attr(url),
72 escape_html_text(title)
73 )
74 } else {
75 format!("<a>{}</a>", escape_html_text(title))
76 };
77 out.push(("text/html".into(), anchor));
78 }
79 out
80 }
81 OutboundContent::Html {
82 html,
83 fallback_text,
84 } => vec![
85 ("text/html".into(), html.clone()),
86 ("text/plain".into(), fallback_text.clone()),
87 ],
88 OutboundContent::Custom(pairs) => pairs.clone(),
89 }
90 }
91}
92
93/// Escape a string for use inside a double-quoted HTML attribute value.
94fn escape_html_attr(s: &str) -> String {
95 s.replace('&', "&")
96 .replace('<', "<")
97 .replace('>', ">")
98 .replace('"', """)
99 .replace('\'', "'")
100}
101
102/// Escape a string for use as HTML text content.
103fn escape_html_text(s: &str) -> String {
104 s.replace('&', "&")
105 .replace('<', "<")
106 .replace('>', ">")
107}
108
109/// Is this url safe to place in an anchor `href`? Rejects the schemes that can
110/// execute script when the dragged HTML lands in another app
111/// (`javascript:`, `data:`, `vbscript:`), matching leniently: leading ASCII
112/// whitespace and control characters are ignored and the scheme is
113/// case-insensitive, mirroring how browsers resolve a url.
114fn is_safe_href(url: &str) -> bool {
115 let trimmed = url.trim_start_matches(|c: char| c.is_ascii_whitespace() || c.is_control());
116 let lower = trimmed.to_ascii_lowercase();
117 !["javascript:", "data:", "vbscript:"]
118 .iter()
119 .any(|scheme| lower.starts_with(scheme))
120}
121
122/// Makes its children draggable *out of the app*, populating the native
123/// `DataTransfer` on drag start.
124#[component]
125pub fn ExternalDragSource(
126 /// The content written to the drag's `DataTransfer`.
127 content: OutboundContent,
128 /// Effect advertised to the receiving application. Defaults to `Copy`,
129 /// which is what outbound drags almost always mean.
130 #[props(default = DropEffect::Copy)]
131 effect: DropEffect,
132 /// Disable without unmounting.
133 #[props(default)]
134 disabled: bool,
135 #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
136 children: Element,
137) -> Element {
138 rsx! {
139 div {
140 draggable: !disabled,
141 ondragstart: move |evt: DragEvent| {
142 if disabled {
143 return;
144 }
145 evt.stop_propagation();
146 let dt = evt.data_transfer();
147 for (format, data) in content.entries() {
148 let _ = dt.set_data(&format, &data);
149 }
150 dt.set_effect_allowed(effect.as_str());
151 },
152 ..attributes,
153 {children}
154 }
155 }
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161
162 #[test]
163 fn url_content_covers_all_formats() {
164 let c = OutboundContent::url("https://example.com", Some("Example"));
165 let e = c.entries();
166 assert_eq!(e[0].0, "text/uri-list");
167 assert_eq!(e[1], ("text/plain".into(), "https://example.com".into()));
168 assert!(e[2].1.contains(r#"href="https://example.com""#));
169
170 // no title → no html entry
171 assert_eq!(OutboundContent::url("https://x.y", None).entries().len(), 2);
172 }
173
174 #[test]
175 fn url_html_entry_escapes_attribute_and_text() {
176 // A url with a query string (`&`) and a title with markup must not
177 // break or inject into the generated anchor.
178 let c = OutboundContent::url("https://x.y/?a=1&b=\"2\"", Some("A & B <img src=x>"));
179 let html = &c.entries()[2].1;
180 assert_eq!(
181 html,
182 r#"<a href="https://x.y/?a=1&b="2"">A & B <img src=x></a>"#
183 );
184 // Plain-text formats still carry the raw url.
185 assert_eq!(c.entries()[1].1, "https://x.y/?a=1&b=\"2\"");
186 }
187
188 #[test]
189 fn url_html_entry_drops_href_for_dangerous_schemes() {
190 for bad in [
191 "javascript:alert(1)",
192 " JavaScript:alert(1)",
193 "data:text/html,<script>",
194 "vbscript:msgbox",
195 ] {
196 let c = OutboundContent::url(bad, Some("click"));
197 let html = &c.entries()[2].1;
198 assert!(!html.contains("href="), "{bad} kept an href: {html}");
199 assert_eq!(html, "<a>click</a>");
200 }
201 // Ordinary schemes keep the href.
202 assert!(
203 OutboundContent::url("mailto:a@b.c", Some("mail")).entries()[2]
204 .1
205 .contains("href=")
206 );
207 }
208}