#![doc = include_str!("../docs/api/drag-out.md")]
use dioxus::prelude::*;
use crate::core::DropEffect;
#[derive(Debug, Clone, PartialEq)]
pub enum OutboundContent {
Text(String),
Url {
url: String,
title: Option<String>,
},
Html {
html: String,
fallback_text: String,
},
Custom(Vec<(String, String)>),
}
impl OutboundContent {
pub fn url(url: impl Into<String>, title: Option<&str>) -> Self {
Self::Url {
url: url.into(),
title: title.map(str::to_string),
}
}
pub fn entries(&self) -> Vec<(String, String)> {
match self {
OutboundContent::Text(t) => vec![("text/plain".into(), t.clone())],
OutboundContent::Url { url, title } => {
let mut out = vec![
("text/uri-list".into(), url.clone()),
("text/plain".into(), url.clone()),
];
if let Some(title) = title {
let anchor = if is_safe_href(url) {
format!(
r#"<a href="{}">{}</a>"#,
escape_html_attr(url),
escape_html_text(title)
)
} else {
format!("<a>{}</a>", escape_html_text(title))
};
out.push(("text/html".into(), anchor));
}
out
}
OutboundContent::Html {
html,
fallback_text,
} => vec![
("text/html".into(), html.clone()),
("text/plain".into(), fallback_text.clone()),
],
OutboundContent::Custom(pairs) => pairs.clone(),
}
}
}
fn escape_html_attr(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
fn escape_html_text(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
fn is_safe_href(url: &str) -> bool {
let trimmed = url.trim_start_matches(|c: char| c.is_ascii_whitespace() || c.is_control());
let lower = trimmed.to_ascii_lowercase();
!["javascript:", "data:", "vbscript:"]
.iter()
.any(|scheme| lower.starts_with(scheme))
}
#[component]
pub fn ExternalDragSource(
content: OutboundContent,
#[props(default = DropEffect::Copy)]
effect: DropEffect,
#[props(default)]
disabled: bool,
#[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
children: Element,
) -> Element {
let mut attributes = attributes;
crate::core::components::protect_attributes(&mut attributes, &["draggable", "ondragstart"]);
rsx! {
div {
draggable: !disabled,
ondragstart: move |evt: DragEvent| {
if disabled {
return;
}
evt.stop_propagation();
let dt = evt.data_transfer();
for (format, data) in content.entries() {
let _ = dt.set_data(&format, &data);
}
dt.set_effect_allowed(effect.as_str());
},
..attributes,
{children}
}
}
}
#[cfg(feature = "serde")]
#[component]
pub fn TypedDragSource<T: serde::Serialize + Clone + PartialEq + 'static>(
payload: T,
#[props(default)]
fallback_text: Option<String>,
#[props(default = DropEffect::Copy)]
effect: DropEffect,
#[props(default)]
disabled: bool,
#[props(default)]
on_error: Option<EventHandler<String>>,
#[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
children: Element,
) -> Element {
let mut attributes = attributes;
crate::core::components::protect_attributes(&mut attributes, &["draggable", "ondragstart"]);
rsx! {
div {
draggable: !disabled,
ondragstart: move |evt: DragEvent| {
if disabled {
return;
}
evt.stop_propagation();
let dt = evt.data_transfer();
let json = match serde_json::to_string(&payload) {
Ok(json) => {
let _ = dt.set_data(crate::external::typed::MIME, &json);
Some(json)
}
Err(e) => {
if let Some(h) = &on_error {
h.call(e.to_string());
}
None
}
};
if let Some(text) = fallback_text.clone().or(json) {
let _ = dt.set_data("text/plain", &text);
}
dt.set_effect_allowed(effect.as_str());
},
..attributes,
{children}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn url_content_covers_all_formats() {
let c = OutboundContent::url("https://example.com", Some("Example"));
let e = c.entries();
assert_eq!(e[0].0, "text/uri-list");
assert_eq!(e[1], ("text/plain".into(), "https://example.com".into()));
assert!(e[2].1.contains(r#"href="https://example.com""#));
assert_eq!(OutboundContent::url("https://x.y", None).entries().len(), 2);
}
#[test]
fn url_html_entry_escapes_attribute_and_text() {
let c = OutboundContent::url("https://x.y/?a=1&b=\"2\"", Some("A & B <img src=x>"));
let html = &c.entries()[2].1;
assert_eq!(
html,
r#"<a href="https://x.y/?a=1&b="2"">A & B <img src=x></a>"#
);
assert_eq!(c.entries()[1].1, "https://x.y/?a=1&b=\"2\"");
}
#[test]
fn url_html_entry_drops_href_for_dangerous_schemes() {
for bad in [
"javascript:alert(1)",
" JavaScript:alert(1)",
"data:text/html,<script>",
"vbscript:msgbox",
] {
let c = OutboundContent::url(bad, Some("click"));
let html = &c.entries()[2].1;
assert!(!html.contains("href="), "{bad} kept an href: {html}");
assert_eq!(html, "<a>click</a>");
}
assert!(
OutboundContent::url("mailto:a@b.c", Some("mail")).entries()[2]
.1
.contains("href=")
);
}
}