1use dioxus::html::{FileData, HasFileData};
24use dioxus::prelude::*;
25
26use crate::core::{client_point, element_point, Point};
27
28#[derive(Clone, PartialEq)]
30pub struct FileDrop {
31 pub files: Vec<FileData>,
32 pub client: Point,
34 pub element: Point,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum FileRejection {
41 Extension,
43 ContentType,
45 TooLarge,
47 TooMany,
49}
50
51#[derive(Debug, Clone, PartialEq, Default)]
53pub struct FileFilter {
54 extensions: Vec<String>,
55 content_types: Vec<String>,
56 max_size: Option<u64>,
57 max_files: Option<usize>,
58}
59
60impl FileFilter {
61 pub fn new() -> Self {
62 Self::default()
63 }
64
65 pub fn extensions<I, S>(mut self, exts: I) -> Self
67 where
68 I: IntoIterator<Item = S>,
69 S: Into<String>,
70 {
71 self.extensions = exts.into_iter().map(|s| s.into().to_lowercase()).collect();
72 self
73 }
74
75 pub fn content_types<I, S>(mut self, types: I) -> Self
78 where
79 I: IntoIterator<Item = S>,
80 S: Into<String>,
81 {
82 self.content_types = types.into_iter().map(|s| s.into().to_lowercase()).collect();
83 self
84 }
85
86 pub fn max_size(mut self, bytes: u64) -> Self {
88 self.max_size = Some(bytes);
89 self
90 }
91
92 pub fn max_files(mut self, n: usize) -> Self {
94 self.max_files = Some(n);
95 self
96 }
97
98 pub fn check(&self, file: &FileData) -> Result<(), FileRejection> {
100 if !self.extensions.is_empty() {
101 let name = file.name().to_lowercase();
102 let ok = self
103 .extensions
104 .iter()
105 .any(|ext| name.ends_with(&format!(".{ext}")));
106 if !ok {
107 return Err(FileRejection::Extension);
108 }
109 }
110 if !self.content_types.is_empty() {
111 let ct = file.content_type().unwrap_or_default().to_lowercase();
112 let ok = self.content_types.iter().any(|allowed| {
113 if let Some(prefix) = allowed.strip_suffix("/*") {
114 ct.starts_with(prefix)
115 } else {
116 ct == *allowed
117 }
118 });
119 if !ok {
120 return Err(FileRejection::ContentType);
121 }
122 }
123 if let Some(max) = self.max_size {
124 if file.size() > max {
125 return Err(FileRejection::TooLarge);
126 }
127 }
128 Ok(())
129 }
130
131 pub fn partition(
133 &self,
134 files: Vec<FileData>,
135 ) -> (Vec<FileData>, Vec<(FileData, FileRejection)>) {
136 let mut ok = Vec::new();
137 let mut bad = Vec::new();
138 for file in files {
139 if let Some(max) = self.max_files {
140 if ok.len() >= max {
141 bad.push((file, FileRejection::TooMany));
142 continue;
143 }
144 }
145 match self.check(&file) {
146 Ok(()) => ok.push(file),
147 Err(why) => bad.push((file, why)),
148 }
149 }
150 (ok, bad)
151 }
152}
153
154#[component]
159pub fn FileDropZone(
160 #[props(default)]
162 filter: Option<FileFilter>,
163 on_files: EventHandler<FileDrop>,
165 #[props(default)]
167 on_rejected: Option<EventHandler<Vec<(FileData, FileRejection)>>>,
168 #[props(default)]
170 on_hover: Option<EventHandler<bool>>,
171 #[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
172 children: Element,
173) -> Element {
174 let mut depth = use_signal(|| 0u32);
175
176 rsx! {
177 div {
178 ondragover: move |evt: DragEvent| {
179 evt.prevent_default();
182 },
183 ondragenter: move |evt: DragEvent| {
184 evt.prevent_default();
185 let d = depth() + 1;
186 depth.set(d);
187 if d == 1 {
188 if let Some(h) = &on_hover {
189 h.call(true);
190 }
191 }
192 },
193 ondragleave: move |_| {
194 let d = depth().saturating_sub(1);
195 depth.set(d);
196 if d == 0 {
197 if let Some(h) = &on_hover {
198 h.call(false);
199 }
200 }
201 },
202 ondrop: move |evt: DragEvent| {
203 evt.prevent_default();
204 depth.set(0);
205 if let Some(h) = &on_hover {
206 h.call(false);
207 }
208 let files = evt.files();
209 if files.is_empty() {
210 return;
211 }
212 let (accepted, rejected) = match &filter {
213 Some(f) => f.partition(files),
214 None => (files, Vec::new()),
215 };
216 if !rejected.is_empty() {
217 if let Some(h) = &on_rejected {
218 h.call(rejected);
219 }
220 }
221 if !accepted.is_empty() {
222 on_files.call(FileDrop {
223 files: accepted,
224 client: client_point(&evt),
225 element: element_point(&evt),
226 });
227 }
228 },
229 ..attributes,
230 {children}
231 }
232 }
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238 use dioxus::html::NativeFileData;
239 use std::path::PathBuf;
240 use std::pin::Pin;
241
242 struct MockFile {
244 name: &'static str,
245 size: u64,
246 content_type: Option<&'static str>,
247 }
248
249 impl NativeFileData for MockFile {
250 fn name(&self) -> String {
251 self.name.to_string()
252 }
253 fn size(&self) -> u64 {
254 self.size
255 }
256 fn last_modified(&self) -> u64 {
257 0
258 }
259 fn path(&self) -> PathBuf {
260 PathBuf::new()
261 }
262 fn content_type(&self) -> Option<String> {
263 self.content_type.map(str::to_string)
264 }
265 fn read_bytes(
266 &self,
267 ) -> Pin<Box<dyn std::future::Future<Output = Result<bytes::Bytes, dioxus::CapturedError>>>>
268 {
269 Box::pin(std::future::ready(Ok(bytes::Bytes::new())))
270 }
271 fn byte_stream(
272 &self,
273 ) -> Pin<
274 Box<
275 dyn futures_util::Stream<Item = Result<bytes::Bytes, dioxus::CapturedError>>
276 + Send
277 + 'static,
278 >,
279 > {
280 Box::pin(futures_util::stream::empty())
281 }
282 fn read_string(
283 &self,
284 ) -> Pin<Box<dyn std::future::Future<Output = Result<String, dioxus::CapturedError>>>>
285 {
286 Box::pin(std::future::ready(Ok(String::new())))
287 }
288 fn inner(&self) -> &dyn std::any::Any {
289 self
290 }
291 }
292
293 fn file(name: &'static str, size: u64, ct: Option<&'static str>) -> FileData {
294 FileData::new(MockFile {
295 name,
296 size,
297 content_type: ct,
298 })
299 }
300
301 #[test]
302 fn extension_filter_is_case_insensitive() {
303 let f = FileFilter::new().extensions(["png", "JPG"]);
304 assert!(f.check(&file("Photo.PNG", 10, None)).is_ok());
305 assert!(f.check(&file("photo.jpg", 10, None)).is_ok());
306 assert_eq!(
307 f.check(&file("notes.txt", 10, None)),
308 Err(FileRejection::Extension)
309 );
310 assert_eq!(
312 f.check(&file("png.txt", 10, None)),
313 Err(FileRejection::Extension)
314 );
315 }
316
317 #[test]
318 fn content_type_wildcards() {
319 let f = FileFilter::new().content_types(["image/*", "application/pdf"]);
320 assert!(f.check(&file("a", 1, Some("image/webp"))).is_ok());
321 assert!(f.check(&file("b", 1, Some("application/pdf"))).is_ok());
322 assert_eq!(
323 f.check(&file("c", 1, Some("text/plain"))),
324 Err(FileRejection::ContentType)
325 );
326 assert_eq!(
328 f.check(&file("d", 1, None)),
329 Err(FileRejection::ContentType)
330 );
331 }
332
333 #[test]
334 fn size_limit() {
335 let f = FileFilter::new().max_size(100);
336 assert!(f.check(&file("ok", 100, None)).is_ok());
337 assert_eq!(
338 f.check(&file("big", 101, None)),
339 Err(FileRejection::TooLarge)
340 );
341 }
342
343 #[test]
344 fn partition_applies_count_after_other_rules() {
345 let f = FileFilter::new().extensions(["png"]).max_files(2);
346 let batch = vec![
347 file("a.png", 1, None),
348 file("b.txt", 1, None), file("c.png", 1, None),
350 file("d.png", 1, None), ];
352 let (ok, bad) = f.partition(batch);
353 assert_eq!(
354 ok.iter().map(|f| f.name()).collect::<Vec<_>>(),
355 vec!["a.png", "c.png"]
356 );
357 assert_eq!(bad.len(), 2);
358 assert_eq!(bad[0].1, FileRejection::Extension);
359 assert_eq!(bad[1].1, FileRejection::TooMany);
360 }
361}