1#![doc = include_str!("../docs/api/drag-out.md")]
2
3use dioxus::prelude::*;
4
5use crate::core::DropEffect;
6
7#[derive(Debug, Clone, PartialEq)]
9pub enum OutboundContent {
10 Text(String),
12 Url {
15 url: String,
16 title: Option<String>,
18 },
19 Html {
21 html: String,
22 fallback_text: String,
24 },
25 Custom(Vec<(String, String)>),
27}
28
29impl OutboundContent {
30 pub fn url(url: impl Into<String>, title: Option<&str>) -> Self {
32 Self::Url {
33 url: url.into(),
34 title: title.map(str::to_string),
35 }
36 }
37
38 pub fn entries(&self) -> Vec<(String, String)> {
41 match self {
42 OutboundContent::Text(t) => vec![("text/plain".into(), t.clone())],
43 OutboundContent::Url { url, title } => {
44 let mut out = vec![
50 ("text/uri-list".into(), url.clone()),
51 ("text/plain".into(), url.clone()),
52 ];
53 if let Some(title) = title {
54 let anchor = if is_safe_href(url) {
55 format!(
56 r#"<a href="{}">{}</a>"#,
57 escape_html_attr(url),
58 escape_html_text(title)
59 )
60 } else {
61 format!("<a>{}</a>", escape_html_text(title))
62 };
63 out.push(("text/html".into(), anchor));
64 }
65 out
66 }
67 OutboundContent::Html {
68 html,
69 fallback_text,
70 } => vec![
71 ("text/html".into(), html.clone()),
72 ("text/plain".into(), fallback_text.clone()),
73 ],
74 OutboundContent::Custom(pairs) => pairs.clone(),
75 }
76 }
77}
78
79fn escape_html_attr(s: &str) -> String {
81 s.replace('&', "&")
82 .replace('<', "<")
83 .replace('>', ">")
84 .replace('"', """)
85 .replace('\'', "'")
86}
87
88fn escape_html_text(s: &str) -> String {
90 s.replace('&', "&")
91 .replace('<', "<")
92 .replace('>', ">")
93}
94
95fn is_safe_href(url: &str) -> bool {
101 let trimmed = url.trim_start_matches(|c: char| c.is_ascii_whitespace() || c.is_control());
102 let lower = trimmed.to_ascii_lowercase();
103 !["javascript:", "data:", "vbscript:"]
104 .iter()
105 .any(|scheme| lower.starts_with(scheme))
106}
107
108#[component]
111pub fn ExternalDragSource(
112 content: OutboundContent,
114 #[props(default = DropEffect::Copy)]
117 effect: DropEffect,
118 #[props(default)]
120 disabled: bool,
121 #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
122 children: Element,
123) -> Element {
124 rsx! {
125 div {
126 draggable: !disabled,
127 ondragstart: move |evt: DragEvent| {
128 if disabled {
129 return;
130 }
131 evt.stop_propagation();
132 let dt = evt.data_transfer();
133 for (format, data) in content.entries() {
134 let _ = dt.set_data(&format, &data);
135 }
136 dt.set_effect_allowed(effect.as_str());
137 },
138 ..attributes,
139 {children}
140 }
141 }
142}
143
144#[cfg(feature = "serde")]
158#[component]
159pub fn TypedDragSource<T: serde::Serialize + Clone + PartialEq + 'static>(
160 payload: T,
162 #[props(default)]
165 fallback_text: Option<String>,
166 #[props(default = DropEffect::Copy)]
168 effect: DropEffect,
169 #[props(default)]
171 disabled: bool,
172 #[props(default)]
174 on_error: Option<EventHandler<String>>,
175 #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
176 children: Element,
177) -> Element {
178 rsx! {
179 div {
180 draggable: !disabled,
181 ondragstart: move |evt: DragEvent| {
182 if disabled {
183 return;
184 }
185 evt.stop_propagation();
186 let dt = evt.data_transfer();
187 let json = match serde_json::to_string(&payload) {
188 Ok(json) => {
189 let _ = dt.set_data(crate::external::typed::MIME, &json);
190 Some(json)
191 }
192 Err(e) => {
193 if let Some(h) = &on_error {
194 h.call(e.to_string());
195 }
196 None
197 }
198 };
199 if let Some(text) = fallback_text.clone().or(json) {
200 let _ = dt.set_data("text/plain", &text);
201 }
202 dt.set_effect_allowed(effect.as_str());
203 },
204 ..attributes,
205 {children}
206 }
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 #[test]
215 fn url_content_covers_all_formats() {
216 let c = OutboundContent::url("https://example.com", Some("Example"));
217 let e = c.entries();
218 assert_eq!(e[0].0, "text/uri-list");
219 assert_eq!(e[1], ("text/plain".into(), "https://example.com".into()));
220 assert!(e[2].1.contains(r#"href="https://example.com""#));
221
222 assert_eq!(OutboundContent::url("https://x.y", None).entries().len(), 2);
224 }
225
226 #[test]
227 fn url_html_entry_escapes_attribute_and_text() {
228 let c = OutboundContent::url("https://x.y/?a=1&b=\"2\"", Some("A & B <img src=x>"));
231 let html = &c.entries()[2].1;
232 assert_eq!(
233 html,
234 r#"<a href="https://x.y/?a=1&b="2"">A & B <img src=x></a>"#
235 );
236 assert_eq!(c.entries()[1].1, "https://x.y/?a=1&b=\"2\"");
238 }
239
240 #[test]
241 fn url_html_entry_drops_href_for_dangerous_schemes() {
242 for bad in [
243 "javascript:alert(1)",
244 " JavaScript:alert(1)",
245 "data:text/html,<script>",
246 "vbscript:msgbox",
247 ] {
248 let c = OutboundContent::url(bad, Some("click"));
249 let html = &c.entries()[2].1;
250 assert!(!html.contains("href="), "{bad} kept an href: {html}");
251 assert_eq!(html, "<a>click</a>");
252 }
253 assert!(
255 OutboundContent::url("mailto:a@b.c", Some("mail")).entries()[2]
256 .1
257 .contains("href=")
258 );
259 }
260}