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 let mut attributes = attributes;
125 crate::core::components::protect_attributes(&mut attributes, &["draggable", "ondragstart"]);
126 rsx! {
127 div {
128 draggable: !disabled,
129 ondragstart: move |evt: DragEvent| {
130 if disabled {
131 return;
132 }
133 evt.stop_propagation();
134 let dt = evt.data_transfer();
135 for (format, data) in content.entries() {
136 let _ = dt.set_data(&format, &data);
137 }
138 dt.set_effect_allowed(effect.as_str());
139 },
140 ..attributes,
141 {children}
142 }
143 }
144}
145
146#[cfg(feature = "serde")]
160#[component]
161pub fn TypedDragSource<T: serde::Serialize + Clone + PartialEq + 'static>(
162 payload: T,
164 #[props(default)]
167 fallback_text: Option<String>,
168 #[props(default = DropEffect::Copy)]
170 effect: DropEffect,
171 #[props(default)]
173 disabled: bool,
174 #[props(default)]
176 on_error: Option<EventHandler<String>>,
177 #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
178 children: Element,
179) -> Element {
180 let mut attributes = attributes;
181 crate::core::components::protect_attributes(&mut attributes, &["draggable", "ondragstart"]);
182 rsx! {
183 div {
184 draggable: !disabled,
185 ondragstart: move |evt: DragEvent| {
186 if disabled {
187 return;
188 }
189 evt.stop_propagation();
190 let dt = evt.data_transfer();
191 let json = match serde_json::to_string(&payload) {
192 Ok(json) => {
193 let _ = dt.set_data(crate::external::typed::MIME, &json);
194 Some(json)
195 }
196 Err(e) => {
197 if let Some(h) = &on_error {
198 h.call(e.to_string());
199 }
200 None
201 }
202 };
203 if let Some(text) = fallback_text.clone().or(json) {
204 let _ = dt.set_data("text/plain", &text);
205 }
206 dt.set_effect_allowed(effect.as_str());
207 },
208 ..attributes,
209 {children}
210 }
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217
218 #[test]
219 fn url_content_covers_all_formats() {
220 let c = OutboundContent::url("https://example.com", Some("Example"));
221 let e = c.entries();
222 assert_eq!(e[0].0, "text/uri-list");
223 assert_eq!(e[1], ("text/plain".into(), "https://example.com".into()));
224 assert!(e[2].1.contains(r#"href="https://example.com""#));
225
226 assert_eq!(OutboundContent::url("https://x.y", None).entries().len(), 2);
228 }
229
230 #[test]
231 fn url_html_entry_escapes_attribute_and_text() {
232 let c = OutboundContent::url("https://x.y/?a=1&b=\"2\"", Some("A & B <img src=x>"));
235 let html = &c.entries()[2].1;
236 assert_eq!(
237 html,
238 r#"<a href="https://x.y/?a=1&b="2"">A & B <img src=x></a>"#
239 );
240 assert_eq!(c.entries()[1].1, "https://x.y/?a=1&b=\"2\"");
242 }
243
244 #[test]
245 fn url_html_entry_drops_href_for_dangerous_schemes() {
246 for bad in [
247 "javascript:alert(1)",
248 " JavaScript:alert(1)",
249 "data:text/html,<script>",
250 "vbscript:msgbox",
251 ] {
252 let c = OutboundContent::url(bad, Some("click"));
253 let html = &c.entries()[2].1;
254 assert!(!html.contains("href="), "{bad} kept an href: {html}");
255 assert_eq!(html, "<a>click</a>");
256 }
257 assert!(
259 OutboundContent::url("mailto:a@b.c", Some("mail")).entries()[2]
260 .1
261 .contains("href=")
262 );
263 }
264}