Skip to main content

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('&', "&amp;")
96        .replace('<', "&lt;")
97        .replace('>', "&gt;")
98        .replace('"', "&quot;")
99        .replace('\'', "&#39;")
100}
101
102/// Escape a string for use as HTML text content.
103fn escape_html_text(s: &str) -> String {
104    s.replace('&', "&amp;")
105        .replace('<', "&lt;")
106        .replace('>', "&gt;")
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&amp;b=&quot;2&quot;">A &amp; B &lt;img src=x&gt;</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}