Skip to main content

dioxus_dnd/
dragout.rs

1#![doc = include_str!("../docs/api/drag-out.md")]
2
3use dioxus::prelude::*;
4
5use crate::core::DropEffect;
6
7/// What to place on the outbound `DataTransfer`.
8#[derive(Debug, Clone, PartialEq)]
9pub enum OutboundContent {
10    /// Plain text (`text/plain`).
11    Text(String),
12    /// A link: written as `text/uri-list` *and* `text/plain` (and, with a
13    /// title, `text/html` as an anchor) so maximal targets understand it.
14    Url {
15        url: String,
16        /// Optional human title, used for the HTML representation.
17        title: Option<String>,
18    },
19    /// Rich content: `text/html` plus a plain-text fallback.
20    Html {
21        html: String,
22        /// Written as `text/plain` for targets that don't take HTML.
23        fallback_text: String,
24    },
25    /// Raw `(format, data)` pairs, written verbatim in order.
26    Custom(Vec<(String, String)>),
27}
28
29impl OutboundContent {
30    /// Convenience constructor for [`OutboundContent::Url`].
31    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    /// The `(format, data)` pairs this content writes, in order. Pure, for
39    /// testability.
40    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                // `text/uri-list` / `text/plain` are plain-text formats, so the
45                // raw url is written verbatim. Only the `text/html` anchor is an
46                // injection surface: escape both fields for their context, and
47                // omit the `href` for dangerous schemes (javascript:/data:/…)
48                // so a hostile url can't carry an active link into the target.
49                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
79/// Escape a string for use inside a double-quoted HTML attribute value.
80fn escape_html_attr(s: &str) -> String {
81    s.replace('&', "&amp;")
82        .replace('<', "&lt;")
83        .replace('>', "&gt;")
84        .replace('"', "&quot;")
85        .replace('\'', "&#39;")
86}
87
88/// Escape a string for use as HTML text content.
89fn escape_html_text(s: &str) -> String {
90    s.replace('&', "&amp;")
91        .replace('<', "&lt;")
92        .replace('>', "&gt;")
93}
94
95/// Is this url safe to place in an anchor `href`? Rejects the schemes that can
96/// execute script when the dragged HTML lands in another app
97/// (`javascript:`, `data:`, `vbscript:`), matching leniently: leading ASCII
98/// whitespace and control characters are ignored and the scheme is
99/// case-insensitive, mirroring how browsers resolve a url.
100fn 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/// Makes its children draggable *out of the app*, populating the native
109/// `DataTransfer` on drag start.
110#[component]
111pub fn ExternalDragSource(
112    /// The content written to the drag's `DataTransfer`.
113    content: OutboundContent,
114    /// Effect advertised to the receiving application. Defaults to `Copy`,
115    /// which is what outbound drags almost always mean.
116    #[props(default = DropEffect::Copy)]
117    effect: DropEffect,
118    /// Disable without unmounting.
119    #[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/// Makes its children draggable with a **typed** payload on the native
147/// `DataTransfer`: `payload` is serialized to JSON under
148/// [`crate::external::typed::MIME`] at drag start, always alongside a
149/// `text/plain` fallback so non-typed targets (text editors, other apps)
150/// still receive something legible. The receiving end is
151/// [`crate::external::TypedDropZone`] (yours, in another app) - or any
152/// consumer of dioxus-html's wire-compatible `retrieve`.
153///
154/// For drags between windows of ONE app, prefer a
155/// [`crate::core::DndWorld`]: live Rust payloads, no serialization.
156///
157/// Serialization failures are contained: the drag degrades to carrying
158/// only the fallback text (and reports through `on_error` if wired).
159#[cfg(feature = "serde")]
160#[component]
161pub fn TypedDragSource<T: serde::Serialize + Clone + PartialEq + 'static>(
162    /// The value serialized onto the drag's `DataTransfer`.
163    payload: T,
164    /// The `text/plain` fallback written alongside the JSON. Defaults to
165    /// the JSON itself, which pastes legibly into text targets.
166    #[props(default)]
167    fallback_text: Option<String>,
168    /// Effect advertised to the receiving application. Defaults to `Copy`.
169    #[props(default = DropEffect::Copy)]
170    effect: DropEffect,
171    /// Disable without unmounting.
172    #[props(default)]
173    disabled: bool,
174    /// Fired when the payload fails to serialize at drag start.
175    #[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        // no title → no html entry
227        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        // A url with a query string (`&`) and a title with markup must not
233        // break or inject into the generated anchor.
234        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&amp;b=&quot;2&quot;">A &amp; B &lt;img src=x&gt;</a>"#
239        );
240        // Plain-text formats still carry the raw url.
241        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        // Ordinary schemes keep the href.
258        assert!(
259            OutboundContent::url("mailto:a@b.c", Some("mail")).entries()[2]
260                .1
261                .contains("href=")
262        );
263    }
264}