Skip to main content

dioxus_dnd/
files.rs

1//! OS file drops — the one drop type where the payload arrives *in the native
2//! event* (`evt.files()`) rather than through the shared context.
3//!
4//! Works on web and desktop. On desktop, [`dioxus::html::FileData::path`]
5//! gives you the real filesystem path; on web you read contents with
6//! `read_bytes()` / `read_string()` / `byte_stream()`.
7//!
8//! ```rust,ignore
9//! rsx! {
10//!     FileDropZone {
11//!         filter: FileFilter::new().extensions(["png", "jpg"]).max_size(5_000_000),
12//!         on_files: move |drop: FileDrop| async move {
13//!             for f in drop.files {
14//!                 let bytes = f.read_bytes().await.unwrap();
15//!                 // ...
16//!             }
17//!         },
18//!         "Drop images here"
19//!     }
20//! }
21//! ```
22
23use dioxus::html::{FileData, HasFileData};
24use dioxus::prelude::*;
25
26use crate::core::{client_point, element_point, Point};
27
28/// A batch of dropped files plus where they landed.
29#[derive(Clone, PartialEq)]
30pub struct FileDrop {
31    pub files: Vec<FileData>,
32    /// Pointer position in client (viewport) coordinates.
33    pub client: Point,
34    /// Pointer position relative to the drop zone element.
35    pub element: Point,
36}
37
38/// Why a file was rejected by a [`FileFilter`].
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum FileRejection {
41    /// Extension not in the allow-list.
42    Extension,
43    /// MIME type not in the allow-list.
44    ContentType,
45    /// Larger than `max_size` bytes.
46    TooLarge,
47    /// Batch exceeded `max_files`; this file was over the limit.
48    TooMany,
49}
50
51/// Declarative acceptance rules for dropped files.
52#[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    /// Allow only these extensions (case-insensitive, no leading dot).
66    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    /// Allow only these MIME types. A trailing `/*` wildcard is supported
76    /// (`"image/*"`).
77    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    /// Reject files larger than this many bytes.
87    pub fn max_size(mut self, bytes: u64) -> Self {
88        self.max_size = Some(bytes);
89        self
90    }
91
92    /// Accept at most this many files per drop.
93    pub fn max_files(mut self, n: usize) -> Self {
94        self.max_files = Some(n);
95        self
96    }
97
98    /// Check a single file against the rules (ignores `max_files`).
99    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    /// Split a batch into `(accepted, rejected)` applying every rule.
132    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/// A zone that accepts files dragged in from the operating system.
155///
156/// Independent of `DndContext` — file drops don't come from inside your app,
157/// so no provider is required.
158#[component]
159pub fn FileDropZone(
160    /// Acceptance rules; everything is accepted when omitted.
161    #[props(default)]
162    filter: Option<FileFilter>,
163    /// Fired with the accepted files of a drop (only if at least one passed).
164    on_files: EventHandler<FileDrop>,
165    /// Fired with the rejected files of a drop, if any.
166    #[props(default)]
167    on_rejected: Option<EventHandler<Vec<(FileData, FileRejection)>>>,
168    /// Fired with `true` when a drag hovers the zone, `false` when it leaves.
169    #[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                // Required: without preventDefault the browser never delivers
180                // the drop (it would open the file instead).
181                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    /// Minimal test double for the platform file object.
243    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        // extension must match at the end, not merely appear
311        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        // missing content type fails a type-restricted filter
327        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), // rejected on extension, doesn't consume a slot
349            file("c.png", 1, None),
350            file("d.png", 1, None), // over the count
351        ];
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}