#![doc = include_str!("../docs/api/file-drops.md")]
use dioxus::html::{FileData, HasFileData};
use dioxus::prelude::*;
use crate::core::{client_point, element_point, Point};
#[derive(Clone, PartialEq)]
pub struct FileDrop {
pub files: Vec<FileData>,
pub client: Point,
pub element: Point,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum FileRejection {
Extension,
ContentType,
TooLarge,
TooMany,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct FileFilter {
extensions: Vec<String>,
content_types: Vec<String>,
max_size: Option<u64>,
max_files: Option<usize>,
}
impl FileFilter {
pub fn new() -> Self {
Self::default()
}
pub fn extensions<I, S>(mut self, exts: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.extensions = exts
.into_iter()
.map(|s| normalize_extension(&s.into()))
.filter(|s| !s.is_empty())
.collect();
self
}
pub fn content_types<I, S>(mut self, types: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.content_types = types
.into_iter()
.map(|s| normalize_content_type(&s.into()))
.filter(|s| !s.is_empty())
.collect();
self
}
pub fn max_size(mut self, bytes: u64) -> Self {
self.max_size = Some(bytes);
self
}
pub fn max_files(mut self, n: usize) -> Self {
self.max_files = Some(n);
self
}
pub fn check(&self, file: &FileData) -> Result<(), FileRejection> {
if !self.extensions.is_empty() {
let name = file.name().to_ascii_lowercase();
let ok = self
.extensions
.iter()
.any(|ext| name.ends_with(&format!(".{ext}")));
if !ok {
return Err(FileRejection::Extension);
}
}
if !self.content_types.is_empty() {
let ct = file
.content_type()
.map(|s| normalize_content_type(&s))
.unwrap_or_default();
let ok = self
.content_types
.iter()
.any(|allowed| content_type_matches(allowed, &ct));
if !ok {
return Err(FileRejection::ContentType);
}
}
if let Some(max) = self.max_size {
if file.size() > max {
return Err(FileRejection::TooLarge);
}
}
Ok(())
}
pub fn partition(
&self,
files: Vec<FileData>,
) -> (Vec<FileData>, Vec<(FileData, FileRejection)>) {
let mut ok = Vec::new();
let mut bad = Vec::new();
for file in files {
if let Some(max) = self.max_files {
if ok.len() >= max {
bad.push((file, FileRejection::TooMany));
continue;
}
}
match self.check(&file) {
Ok(()) => ok.push(file),
Err(why) => bad.push((file, why)),
}
}
(ok, bad)
}
fn picker_accept(&self) -> Option<String> {
let mut hints = self
.extensions
.iter()
.map(|extension| format!(".{extension}"))
.collect::<Vec<_>>();
for content_type in &self.content_types {
let Some((ty, subtype)) = split_content_type(content_type) else {
continue;
};
if ty == "*" || ty.contains('*') || (subtype != "*" && subtype.contains('*')) {
continue;
}
if !hints.contains(content_type) {
hints.push(content_type.clone());
}
}
(!hints.is_empty()).then(|| hints.join(","))
}
}
fn normalize_extension(ext: &str) -> String {
ext.trim().trim_start_matches('.').to_ascii_lowercase()
}
fn normalize_content_type(content_type: &str) -> String {
content_type
.split_once(';')
.map(|(base, _)| base)
.unwrap_or(content_type)
.trim()
.to_ascii_lowercase()
}
fn split_content_type(content_type: &str) -> Option<(&str, &str)> {
let (ty, subtype) = content_type.split_once('/')?;
if ty.is_empty() || subtype.is_empty() || subtype.contains('/') {
return None;
}
Some((ty, subtype))
}
fn content_type_matches(pattern: &str, content_type: &str) -> bool {
let Some((pattern_type, pattern_subtype)) = split_content_type(pattern) else {
return false;
};
let Some((actual_type, actual_subtype)) = split_content_type(content_type) else {
return false;
};
if pattern_type == "*" && pattern_subtype == "*" {
return true;
}
if pattern_type == "*" && !pattern_subtype.starts_with("*+") {
return false;
}
if pattern_type != "*" && pattern_type != actual_type {
return false;
}
if pattern_subtype == "*" {
return true;
}
if let Some(suffix) = pattern_subtype.strip_prefix("*+") {
return actual_subtype
.rsplit_once('+')
.map(|(_, actual_suffix)| actual_suffix == suffix)
.unwrap_or(false);
}
pattern_subtype == actual_subtype
}
fn deliver_files(
files: Vec<FileData>,
filter: Option<&FileFilter>,
on_files: &EventHandler<FileDrop>,
on_rejected: Option<&EventHandler<Vec<(FileData, FileRejection)>>>,
client: Point,
element: Point,
) {
if files.is_empty() {
return;
}
let (accepted, rejected) = match filter {
Some(filter) => filter.partition(files),
None => (files, Vec::new()),
};
if !rejected.is_empty() {
if let Some(handler) = on_rejected {
handler.call(rejected);
}
}
if !accepted.is_empty() {
on_files.call(FileDrop {
files: accepted,
client,
element,
});
}
}
#[component]
pub fn FileDropZone(
#[props(default)]
filter: Option<FileFilter>,
on_files: EventHandler<FileDrop>,
#[props(default)]
on_rejected: Option<EventHandler<Vec<(FileData, FileRejection)>>>,
#[props(default)]
on_hover: Option<EventHandler<bool>>,
#[props(default = true)]
multiple: bool,
#[props(default)]
disabled: bool,
#[props(default = "Choose or drop files".to_string())]
label: String,
#[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
children: Element,
) -> Element {
let mut depth = use_signal(|| 0u32);
let input_id = use_hook(|| {
format!(
"dioxus-dnd-file-input-{}",
dioxus::core::current_scope_id().0
)
});
let picker_input_id = input_id.clone();
let picker_accept = filter.as_ref().and_then(FileFilter::picker_accept);
let picker_filter = filter.clone();
let picker_on_files = on_files;
let picker_on_rejected = on_rejected;
let open_picker = use_callback(move |_: ()| {
if disabled {
return;
}
let script = format!(
"const input = document.getElementById({picker_input_id:?}); \
if (input) {{ input.value = ''; input.click(); }}"
);
let _ = dioxus::document::eval(&script);
});
let mut attributes = attributes;
crate::core::components::protect_attributes(
&mut attributes,
&[
"data-over",
"data-disabled",
"role",
"tabindex",
"aria-label",
"aria-disabled",
"onclick",
"onkeydown",
"ondragover",
"ondragenter",
"ondragleave",
"ondrop",
],
);
rsx! {
div {
"data-over": if !disabled && depth() > 0 { "true" },
"data-disabled": if disabled { "true" },
role: "button",
tabindex: if disabled { -1_i64 } else { 0 },
aria_label: label,
aria_disabled: disabled,
onclick: move |_| open_picker.call(()),
onkeydown: move |event: KeyboardEvent| {
if disabled {
return;
}
let key = event.key();
if matches!(key, Key::Enter)
|| matches!(&key, Key::Character(value) if value == " ")
{
event.prevent_default();
open_picker.call(());
}
},
ondragover: move |evt: DragEvent| {
evt.prevent_default();
},
ondragenter: move |evt: DragEvent| {
evt.prevent_default();
if disabled {
return;
}
let d = depth() + 1;
depth.set(d);
if d == 1 {
if let Some(h) = &on_hover {
h.call(true);
}
}
},
ondragleave: move |_| {
if disabled {
depth.set(0);
return;
}
let d = depth().saturating_sub(1);
depth.set(d);
if d == 0 {
if let Some(h) = &on_hover {
h.call(false);
}
}
},
ondrop: move |evt: DragEvent| {
evt.prevent_default();
depth.set(0);
if let Some(h) = &on_hover {
h.call(false);
}
if disabled {
return;
}
deliver_files(
evt.files(),
filter.as_ref(),
&on_files,
on_rejected.as_ref(),
client_point(&evt),
element_point(&evt),
);
},
..attributes,
{children}
input {
id: input_id,
type: "file",
accept: picker_accept,
multiple,
disabled,
hidden: true,
onclick: move |evt: MouseEvent| evt.stop_propagation(),
onchange: move |evt: FormEvent| {
if disabled {
return;
}
deliver_files(
evt.files(),
picker_filter.as_ref(),
&picker_on_files,
picker_on_rejected.as_ref(),
Point::default(),
Point::default(),
);
},
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use dioxus::html::NativeFileData;
use std::path::PathBuf;
use std::pin::Pin;
struct MockFile {
name: &'static str,
size: u64,
content_type: Option<&'static str>,
}
impl NativeFileData for MockFile {
fn name(&self) -> String {
self.name.to_string()
}
fn size(&self) -> u64 {
self.size
}
fn last_modified(&self) -> u64 {
0
}
fn path(&self) -> PathBuf {
PathBuf::new()
}
fn content_type(&self) -> Option<String> {
self.content_type.map(str::to_string)
}
fn read_bytes(
&self,
) -> Pin<Box<dyn std::future::Future<Output = Result<bytes::Bytes, dioxus::CapturedError>>>>
{
Box::pin(std::future::ready(Ok(bytes::Bytes::new())))
}
fn byte_stream(
&self,
) -> Pin<
Box<
dyn futures_util::Stream<Item = Result<bytes::Bytes, dioxus::CapturedError>>
+ Send
+ 'static,
>,
> {
Box::pin(futures_util::stream::empty())
}
fn read_string(
&self,
) -> Pin<Box<dyn std::future::Future<Output = Result<String, dioxus::CapturedError>>>>
{
Box::pin(std::future::ready(Ok(String::new())))
}
fn inner(&self) -> &dyn std::any::Any {
self
}
}
fn file(name: &'static str, size: u64, ct: Option<&'static str>) -> FileData {
FileData::new(MockFile {
name,
size,
content_type: ct,
})
}
#[test]
fn extension_filter_is_case_insensitive() {
let f = FileFilter::new().extensions(["png", ".JPG", " gif "]);
assert!(f.check(&file("Photo.PNG", 10, None)).is_ok());
assert!(f.check(&file("photo.jpg", 10, None)).is_ok());
assert!(f.check(&file("clip.GIF", 10, None)).is_ok());
assert_eq!(
f.check(&file("notes.txt", 10, None)),
Err(FileRejection::Extension)
);
assert_eq!(
f.check(&file("png.txt", 10, None)),
Err(FileRejection::Extension)
);
}
#[test]
fn content_type_wildcards() {
let f = FileFilter::new().content_types(["image/*", "application/pdf"]);
assert!(f.check(&file("a", 1, Some("image/webp"))).is_ok());
assert!(f.check(&file("b", 1, Some("application/pdf"))).is_ok());
assert_eq!(
f.check(&file("c", 1, Some("text/plain"))),
Err(FileRejection::ContentType)
);
assert_eq!(
f.check(&file("d", 1, None)),
Err(FileRejection::ContentType)
);
}
#[test]
fn content_type_wildcards_match_whole_type_only() {
let f = FileFilter::new().content_types(["image/*"]);
assert!(f.check(&file("a", 1, Some("image/svg+xml"))).is_ok());
assert_eq!(
f.check(&file("b", 1, Some("imageevil/png"))),
Err(FileRejection::ContentType)
);
assert_eq!(
f.check(&file("c", 1, Some("application/image"))),
Err(FileRejection::ContentType)
);
assert_eq!(
f.check(&file("d", 1, Some("image/png/extra"))),
Err(FileRejection::ContentType)
);
}
#[test]
fn content_type_matching_normalizes_case_whitespace_and_parameters() {
let f = FileFilter::new().content_types([" Application/PDF ", "text/plain"]);
assert!(f.check(&file("a", 1, Some("application/pdf"))).is_ok());
assert!(f
.check(&file("b", 1, Some("TEXT/PLAIN; charset=utf-8")))
.is_ok());
}
#[test]
fn content_type_all_wildcard_accepts_any_typed_file() {
let f = FileFilter::new().content_types(["*/*"]);
assert!(f.check(&file("a", 1, Some("image/png"))).is_ok());
assert!(f
.check(&file("b", 1, Some("application/octet-stream")))
.is_ok());
assert_eq!(
f.check(&file("c", 1, None)),
Err(FileRejection::ContentType)
);
}
#[test]
fn content_type_structured_suffix_wildcards() {
let app_json = FileFilter::new().content_types(["application/*+json"]);
assert!(app_json
.check(&file("a", 1, Some("application/ld+json")))
.is_ok());
assert!(app_json
.check(&file("b", 1, Some("application/vnd.api+json")))
.is_ok());
assert_eq!(
app_json.check(&file("c", 1, Some("text/ld+json"))),
Err(FileRejection::ContentType)
);
assert_eq!(
app_json.check(&file("d", 1, Some("application/json"))),
Err(FileRejection::ContentType)
);
let any_json = FileFilter::new().content_types(["*/*+json"]);
assert!(any_json
.check(&file("e", 1, Some("application/problem+json")))
.is_ok());
assert!(any_json
.check(&file("f", 1, Some("model/gltf+json")))
.is_ok());
assert_eq!(
any_json.check(&file("g", 1, Some("application/json"))),
Err(FileRejection::ContentType)
);
}
#[test]
fn malformed_content_type_patterns_do_not_match() {
let f = FileFilter::new().content_types(["image", "image/", "/png", "image/png/extra"]);
assert_eq!(
f.check(&file("a", 1, Some("image/png"))),
Err(FileRejection::ContentType)
);
}
#[test]
fn unsupported_subtype_only_wildcard_does_not_match() {
let f = FileFilter::new().content_types(["*/json"]);
assert_eq!(
f.check(&file("a", 1, Some("application/json"))),
Err(FileRejection::ContentType)
);
assert_eq!(
f.check(&file("b", 1, Some("text/json"))),
Err(FileRejection::ContentType)
);
}
#[test]
fn size_limit() {
let f = FileFilter::new().max_size(100);
assert!(f.check(&file("ok", 100, None)).is_ok());
assert_eq!(
f.check(&file("big", 101, None)),
Err(FileRejection::TooLarge)
);
}
#[test]
fn partition_applies_count_after_other_rules() {
let f = FileFilter::new().extensions(["png"]).max_files(2);
let batch = vec![
file("a.png", 1, None),
file("b.txt", 1, None), file("c.png", 1, None),
file("d.png", 1, None), ];
let (ok, bad) = f.partition(batch);
assert_eq!(
ok.iter().map(|f| f.name()).collect::<Vec<_>>(),
vec!["a.png", "c.png"]
);
assert_eq!(bad.len(), 2);
assert_eq!(bad[0].1, FileRejection::Extension);
assert_eq!(bad[1].1, FileRejection::TooMany);
}
#[test]
fn picker_accept_uses_only_rules_the_dialog_can_represent() {
let filter = FileFilter::new()
.extensions(["png", ".JPG"])
.content_types(["image/*", "application/pdf", "application/*+json", "*/*"]);
assert_eq!(
filter.picker_accept().as_deref(),
Some(".png,.jpg,image/*,application/pdf")
);
assert_eq!(
FileFilter::new().max_size(10).picker_accept(),
None,
"non-picker rules must not create an empty accept restriction"
);
}
}