From 21a555dd2ef0f3ad185d1372cee88515ecda96fb Mon Sep 17 00:00:00 2001 From: mcmah309 Date: Sat, 26 Jul 2025 11:31:17 +0000 Subject: [PATCH 01/11] Implement file_select api --- packages/util/Cargo.toml | 3 + packages/util/src/lib.rs | 3 + packages/util/src/select_file.rs | 274 +++++++++++++++++++++++++++++++ 3 files changed, 280 insertions(+) create mode 100644 packages/util/src/select_file.rs diff --git a/packages/util/Cargo.toml b/packages/util/Cargo.toml index ef7d895..284d5c7 100644 --- a/packages/util/Cargo.toml +++ b/packages/util/Cargo.toml @@ -15,4 +15,7 @@ repository.workspace = true [dependencies] dioxus = { workspace = true } +exact_format = "0.2" +serde_json = "1" + serde.workspace = true diff --git a/packages/util/src/lib.rs b/packages/util/src/lib.rs index 4a879de..d0571f1 100644 --- a/packages/util/src/lib.rs +++ b/packages/util/src/lib.rs @@ -1,3 +1,6 @@ //! Common utilities for Dioxus. pub mod scroll; +pub mod select_file; + + diff --git a/packages/util/src/select_file.rs b/packages/util/src/select_file.rs new file mode 100644 index 0000000..71e0d97 --- /dev/null +++ b/packages/util/src/select_file.rs @@ -0,0 +1,274 @@ +use dioxus::document::{EvalError, eval}; +use serde::{Deserialize, Serialize, de::Error}; +use serde_json::Value; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +enum DataEncoding { + /// base64-encoded with MIME type + DataUrl, + /// UTF-8 string + Text, + // Dev Note: There is no point in supporting this at the moment since `serde_json` (which dioxus uses internally) + // does not support bytes (js's byte buffer from `readAsArrayBuffer`). So we cant send the data back without conversions. + // At that point, it is just better use `DataUrl` or not request any data and perform the read on the Rust side. + // /// Raw bytes + // Bytes, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct FileSelection { + /// The file name including the extension but without the full path + name: String, + /// MIME type: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/MIME_types/Common_types + r#type: String, + /// The size of the file in bytes + size: u64, + /// The data contained in the file in the corresponding encoding if requested + data: T, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct FilePickerOptions { + /// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/file#accept + pub accept: Option, + /// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/file#capture + pub capture: Option, +} + +impl Default for FilePickerOptions { + fn default() -> Self { + Self { + accept: None, + capture: None, + } + } +} + +#[derive(Debug, Serialize)] +struct FilePickerOptionsInternal<'a> { + pub accept: &'a Option, + pub multiple: bool, + pub capture: &'a Option, + /// The encoding to use for data extraction. If none, no data for the actual file is returned + pub encoding: Option, +} + +/// Select a single file, returning the contents the data of the file as base64 encoded +pub async fn select_file_base64( + options: &FilePickerOptions, +) -> Result>, EvalError> { + let FilePickerOptions { accept, capture } = options; + select_file_internal(&FilePickerOptionsInternal { + accept, + multiple: false, + capture, + encoding: Some(DataEncoding::DataUrl), + }) + .await +} + +/// Select multiple files, returning the contents the data of the files as base64 encoded +pub async fn select_files_base64( + options: &FilePickerOptions, +) -> Result>, EvalError> { + let FilePickerOptions { accept, capture } = options; + select_files_internal(&FilePickerOptionsInternal { + accept, + multiple: true, + capture, + encoding: Some(DataEncoding::DataUrl), + }) + .await +} + +/// Select a single file, returning the contents the data of the file as utf-8 +pub async fn select_file_text( + options: &FilePickerOptions, +) -> Result>, EvalError> { + let FilePickerOptions { accept, capture } = options; + select_file_internal(&FilePickerOptionsInternal { + accept, + multiple: false, + capture, + encoding: Some(DataEncoding::Text), + }) + .await +} + +/// Select multiple files, returning the contents the data of the files as utf-8 +pub async fn select_files_text( + options: &FilePickerOptions, +) -> Result>, EvalError> { + let FilePickerOptions { accept, capture } = options; + select_files_internal(&FilePickerOptionsInternal { + accept, + multiple: true, + capture, + encoding: Some(DataEncoding::Text), + }) + .await +} + +/// Select a single file, returning no contents of the file +pub async fn select_file( + options: &FilePickerOptions, +) -> Result>, EvalError> { + let FilePickerOptions { accept, capture } = options; + let result: Option> = select_file_internal(&FilePickerOptionsInternal { + accept, + multiple: false, + capture, + encoding: None, + }) + .await?; + result.map_or(Ok(None), |file| { + let FileSelection { + name, + r#type, + size, + data, + } = file; + if matches!(data, Value::Null) { + Ok(Some(FileSelection { + name, + r#type, + size, + data: (), + })) + } else { + Err(EvalError::Serialization(serde_json::Error::custom( + "File data is not empty, but no data was requested".to_string(), + ))) + } + }) +} + +/// Select multiple files, returning no contents of the files +pub async fn select_files( + options: &FilePickerOptions, +) -> Result>, EvalError> { + let FilePickerOptions { accept, capture } = options; + let result: Vec> = select_files_internal(&FilePickerOptionsInternal { + accept, + multiple: true, + capture, + encoding: None, + }) + .await?; + result + .into_iter() + .map(|e| { + let FileSelection { + name, + r#type, + size, + data, + } = e; + if matches!(data, Value::Null) { + return Ok(FileSelection { + name, + r#type, + size, + data: (), + }); + } + Err(EvalError::Serialization(serde_json::Error::custom( + "File data is not empty, but no data was requested".to_string(), + ))) + }) + .collect() +} + +const SELECT_FILE_SCRIPT: &str = r#" +const attrs = await dioxus.recv(); + +const input = document.createElement("input"); +input.type = "file"; +if (attrs.accept) input.accept = attrs.accept; +if (attrs.multiple) input.multiple = true; +if (attrs.capture) input.capture = attrs.capture; + +input.onchange = async () => { + const files = input.files; + input.remove(); + + if (!files || files.length === 0) { + if (attrs.multiple) { + dioxus.send([]); + } else { + dioxus.send(null); + } + return; + } + + const readFile = (file) => new Promise((resolve) => { + const base = { + name: file.name, + type: file.type, + size: file.size, + }; + + if (attrs.encoding === undefined || attrs.encoding === null) { + resolve({ + ...base, + data: null, + }); + return; + } + + const reader = new FileReader(); + reader.onload = () => { + resolve({ + ...base, + data: reader.result, + }); + }; + + switch (attrs.encoding) { + case "text": + reader.readAsText(file); + break; + case "data_url": + reader.readAsDataURL(file); + break; + default: + console.error("Unsupported encoding:", attrs.encoding); + throw new Error("Unsupported encoding"); + } + }); + + const readFiles = await Promise.all([...files].map(readFile)); + if (attrs.multiple) { + dioxus.send(readFiles); + } else { + dioxus.send(readFiles[0]); + } +}; + +input.click();"#; + +async fn select_file_internal<'a, T>( + options: &'a FilePickerOptionsInternal<'a>, +) -> Result>, EvalError> +where + T: for<'de> Deserialize<'de>, +{ + let mut eval = eval(SELECT_FILE_SCRIPT); + eval.send(options)?; + let data = eval.recv().await?; + Ok(data) +} + +async fn select_files_internal<'a, T>( + options: &'a FilePickerOptionsInternal<'a>, +) -> Result>, EvalError> +where + T: for<'de> Deserialize<'de>, +{ + let mut eval = eval(SELECT_FILE_SCRIPT); + eval.send(options)?; + let data = eval.recv().await?; + Ok(data) +} From b49c3184e0cbc7c3627b668b8e67c4c8106fec1b Mon Sep 17 00:00:00 2001 From: mcmah309 Date: Sat, 26 Jul 2025 11:31:39 +0000 Subject: [PATCH 02/11] Add file_select example --- examples/select_file/Cargo.toml | 13 ++++ examples/select_file/README.md | 12 ++++ examples/select_file/src/main.rs | 106 +++++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 examples/select_file/Cargo.toml create mode 100644 examples/select_file/README.md create mode 100644 examples/select_file/src/main.rs diff --git a/examples/select_file/Cargo.toml b/examples/select_file/Cargo.toml new file mode 100644 index 0000000..bcdda98 --- /dev/null +++ b/examples/select_file/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "select-file-example" +version = "0.1.0" +edition = "2021" + +[dependencies] +dioxus-util.workspace = true +dioxus.workspace = true + +[features] +default = ["desktop"] +web = ["dioxus/web"] +desktop = ["dioxus/desktop"] \ No newline at end of file diff --git a/examples/select_file/README.md b/examples/select_file/README.md new file mode 100644 index 0000000..810ec0f --- /dev/null +++ b/examples/select_file/README.md @@ -0,0 +1,12 @@ +# select_file apis + + +### Run + +**Web** + +```dx serve --platform web``` + +**Desktop** + +```dx serve --platform desktop``` \ No newline at end of file diff --git a/examples/select_file/src/main.rs b/examples/select_file/src/main.rs new file mode 100644 index 0000000..09bdb10 --- /dev/null +++ b/examples/select_file/src/main.rs @@ -0,0 +1,106 @@ +use dioxus::logger::tracing::{info, Level}; +use dioxus::prelude::*; +use dioxus_util::select_file::{ + select_file, select_file_base64, select_file_text, select_files, + select_files_base64, select_files_text, FilePickerOptions, +}; + +fn main() { + dioxus::logger::init(Level::TRACE).unwrap(); + launch(App); +} + +#[component] +fn App() -> Element { + rsx! { + div { style: "display: flex; flex-direction: column; gap: 0.5rem;", + h1 { "File Picker Examples" } + + button { + onclick: move |_| async move { + let file = select_file_base64(&FilePickerOptions::default()) + .await + .unwrap(); + if let Some(file) = file { + info!("Selected a file with base64 data: {:?}", file); + } else { + info!("No file selected"); + } + }, + "Select one file with base64 data" + } + + button { + onclick: move |_| async move { + let files = select_files_base64(&FilePickerOptions::default()) + .await + .unwrap(); + if files.is_empty() { + info!("No files selected"); + } else { + for file in files { + info!("Selected file with base64 data: {:?}", file); + } + } + }, + "Select multiple files with base64 data" + } + + button { + onclick: move |_| async move { + let file = select_file_text(&FilePickerOptions::default()) + .await + .unwrap(); + if let Some(file) = file { + info!("Selected a file with text data: {:?}", file); + } else { + info!("No file selected"); + } + }, + "Select one file with text data" + } + + button { + onclick: move |_| async move { + let files = select_files_text(&FilePickerOptions::default()) + .await + .unwrap(); + if files.is_empty() { + info!("No files selected"); + } else { + for file in files { + info!("Selected file with text data: {:?}", file); + } + } + }, + "Select multiple files with text data" + } + + button { + onclick: move |_| async move { + let file = select_file(&FilePickerOptions::default()).await.unwrap(); + if let Some(file) = file { + info!("Selected a file with metadata only: {:?}", file); + } else { + info!("No file selected"); + } + }, + "Select one file (metadata only)" + } + + button { + onclick: move |_| async move { + let files = select_files(&FilePickerOptions::default()).await.unwrap(); + if files.is_empty() { + info!("No files selected"); + } else { + for file in files { + info!("Selected file with metadata only: {:?}", file); + } + } + }, + "Select multiple files (metadata only)" + } + } + } +} From a6e69b074029c1dfff8d619b4ac537602dc271e2 Mon Sep 17 00:00:00 2001 From: mcmah309 Date: Sat, 26 Jul 2025 11:36:52 +0000 Subject: [PATCH 03/11] Make file_select more concise --- packages/util/src/select_file.rs | 63 ++++++++++++-------------------- 1 file changed, 24 insertions(+), 39 deletions(-) diff --git a/packages/util/src/select_file.rs b/packages/util/src/select_file.rs index 71e0d97..90d5b35 100644 --- a/packages/util/src/select_file.rs +++ b/packages/util/src/select_file.rs @@ -123,26 +123,7 @@ pub async fn select_file( encoding: None, }) .await?; - result.map_or(Ok(None), |file| { - let FileSelection { - name, - r#type, - size, - data, - } = file; - if matches!(data, Value::Null) { - Ok(Some(FileSelection { - name, - r#type, - size, - data: (), - })) - } else { - Err(EvalError::Serialization(serde_json::Error::custom( - "File data is not empty, but no data was requested".to_string(), - ))) - } - }) + result.map(map_to_unit).transpose() } /// Select multiple files, returning no contents of the files @@ -159,28 +140,32 @@ pub async fn select_files( .await?; result .into_iter() - .map(|e| { - let FileSelection { - name, - r#type, - size, - data, - } = e; - if matches!(data, Value::Null) { - return Ok(FileSelection { - name, - r#type, - size, - data: (), - }); - } - Err(EvalError::Serialization(serde_json::Error::custom( - "File data is not empty, but no data was requested".to_string(), - ))) - }) + .map(map_to_unit) .collect() } +fn map_to_unit(file: FileSelection) -> Result, EvalError> { + let FileSelection { + name, + r#type, + size, + data, + } = file; + + if data != Value::Null { + return Err(EvalError::Serialization(serde_json::Error::custom( + "File data is not empty, but no data was requested", + ))); + } + + Ok(FileSelection { + name, + r#type, + size, + data: (), + }) +} + const SELECT_FILE_SCRIPT: &str = r#" const attrs = await dioxus.recv(); From c2401ca5f7f599a24d3d87c0b67504a1bb04d31c Mon Sep 17 00:00:00 2001 From: mcmah309 Date: Sat, 26 Jul 2025 11:38:25 +0000 Subject: [PATCH 04/11] refactor --- packages/util/src/select_file.rs | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/packages/util/src/select_file.rs b/packages/util/src/select_file.rs index 90d5b35..a92458c 100644 --- a/packages/util/src/select_file.rs +++ b/packages/util/src/select_file.rs @@ -2,21 +2,7 @@ use dioxus::document::{EvalError, eval}; use serde::{Deserialize, Serialize, de::Error}; use serde_json::Value; -#[derive(Debug, Serialize)] -#[serde(rename_all = "snake_case")] -#[non_exhaustive] -enum DataEncoding { - /// base64-encoded with MIME type - DataUrl, - /// UTF-8 string - Text, - // Dev Note: There is no point in supporting this at the moment since `serde_json` (which dioxus uses internally) - // does not support bytes (js's byte buffer from `readAsArrayBuffer`). So we cant send the data back without conversions. - // At that point, it is just better use `DataUrl` or not request any data and perform the read on the Rust side. - // /// Raw bytes - // Bytes, -} - +/// Represents a file selection with its metadata and data #[derive(Serialize, Deserialize, Debug)] pub struct FileSelection { /// The file name including the extension but without the full path @@ -46,6 +32,22 @@ impl Default for FilePickerOptions { } } +/// Encoding options for file data +#[derive(Debug, Serialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +enum DataEncoding { + /// base64-encoded with MIME type + DataUrl, + /// UTF-8 string + Text, + // Dev Note: There is no point in supporting this at the moment since `serde_json` (which dioxus uses internally) + // does not support bytes (js's byte buffer from `readAsArrayBuffer`). So we cant send the data back without conversions. + // At that point, it is just better use `DataUrl` or not request any data and perform the read on the Rust side. + // /// Raw bytes + // Bytes, +} + #[derive(Debug, Serialize)] struct FilePickerOptionsInternal<'a> { pub accept: &'a Option, From d049ea9877505535d6392b996b5920505f1f75ea Mon Sep 17 00:00:00 2001 From: mcmah309 Date: Sat, 26 Jul 2025 11:39:34 +0000 Subject: [PATCH 05/11] Update README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 0d2e9f5..cc8e69a 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ - [x] Channels - `dioxus-util` - [x] `use_root_scroll` + - [x] `select_file*` - [ ] Camera - [ ] WiFi - [ ] Bluetooth From 20a6bf24662ad738f69a783347026a8769a7526a Mon Sep 17 00:00:00 2001 From: mcmah309 Date: Sat, 26 Jul 2025 11:50:11 +0000 Subject: [PATCH 06/11] Move serde_json into workspace --- Cargo.toml | 1 + packages/util/Cargo.toml | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 9e3aa52..4bf1ed4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ futures = "0.3.31" futures-util = "0.3.31" serde = "1.0.163" +serde_json = "1.0.141" wasm-bindgen = "0.2.100" web-sys = "0.3.77" js-sys = "0.3.77" diff --git a/packages/util/Cargo.toml b/packages/util/Cargo.toml index 284d5c7..a2de720 100644 --- a/packages/util/Cargo.toml +++ b/packages/util/Cargo.toml @@ -15,7 +15,6 @@ repository.workspace = true [dependencies] dioxus = { workspace = true } -exact_format = "0.2" -serde_json = "1" +serde_json = { workspace = true } serde.workspace = true From fd6d52d1859e1f3416c3431d15376e2063e6aaa8 Mon Sep 17 00:00:00 2001 From: mcmah309 Date: Sat, 26 Jul 2025 11:51:18 +0000 Subject: [PATCH 07/11] Make fields pub --- packages/util/src/select_file.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/util/src/select_file.rs b/packages/util/src/select_file.rs index a92458c..ee68607 100644 --- a/packages/util/src/select_file.rs +++ b/packages/util/src/select_file.rs @@ -6,13 +6,13 @@ use serde_json::Value; #[derive(Serialize, Deserialize, Debug)] pub struct FileSelection { /// The file name including the extension but without the full path - name: String, + pub name: String, /// MIME type: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/MIME_types/Common_types - r#type: String, + pub r#type: String, /// The size of the file in bytes - size: u64, + pub size: u64, /// The data contained in the file in the corresponding encoding if requested - data: T, + pub data: T, } #[derive(Serialize, Deserialize, Debug)] From 6de30cd2788dcdf5e562c27fb7ef877ae1d5eeb9 Mon Sep 17 00:00:00 2001 From: Henry <56412856+mcmah309@users.noreply.github.com> Date: Sat, 26 Jul 2025 11:52:44 +0000 Subject: [PATCH 08/11] Update unit conversion error message Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/util/src/select_file.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/util/src/select_file.rs b/packages/util/src/select_file.rs index ee68607..335eb42 100644 --- a/packages/util/src/select_file.rs +++ b/packages/util/src/select_file.rs @@ -156,7 +156,7 @@ fn map_to_unit(file: FileSelection) -> Result, EvalErro if data != Value::Null { return Err(EvalError::Serialization(serde_json::Error::custom( - "File data is not empty, but no data was requested", + "Expected no file data but received non-null data. This indicates a mismatch between encoding settings and returned data.", ))); } From 2bdb6edcb0aa04b8e31225f4a0a510933f0ffdc5 Mon Sep 17 00:00:00 2001 From: mcmah309 Date: Sat, 26 Jul 2025 11:54:42 +0000 Subject: [PATCH 09/11] Cargo fmt --- examples/select_file/src/main.rs | 4 ++-- packages/util/src/lib.rs | 2 -- packages/util/src/select_file.rs | 5 +---- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/examples/select_file/src/main.rs b/examples/select_file/src/main.rs index 09bdb10..de8864e 100644 --- a/examples/select_file/src/main.rs +++ b/examples/select_file/src/main.rs @@ -1,8 +1,8 @@ use dioxus::logger::tracing::{info, Level}; use dioxus::prelude::*; use dioxus_util::select_file::{ - select_file, select_file_base64, select_file_text, select_files, - select_files_base64, select_files_text, FilePickerOptions, + select_file, select_file_base64, select_file_text, select_files, select_files_base64, + select_files_text, FilePickerOptions, }; fn main() { diff --git a/packages/util/src/lib.rs b/packages/util/src/lib.rs index d0571f1..bc08ae8 100644 --- a/packages/util/src/lib.rs +++ b/packages/util/src/lib.rs @@ -2,5 +2,3 @@ pub mod scroll; pub mod select_file; - - diff --git a/packages/util/src/select_file.rs b/packages/util/src/select_file.rs index 335eb42..437b3bf 100644 --- a/packages/util/src/select_file.rs +++ b/packages/util/src/select_file.rs @@ -140,10 +140,7 @@ pub async fn select_files( encoding: None, }) .await?; - result - .into_iter() - .map(map_to_unit) - .collect() + result.into_iter().map(map_to_unit).collect() } fn map_to_unit(file: FileSelection) -> Result, EvalError> { From a2b59fb82d4f41e4dbf3c07f32f876722f26e19f Mon Sep 17 00:00:00 2001 From: mcmah309 Date: Sat, 26 Jul 2025 11:56:31 +0000 Subject: [PATCH 10/11] Clippy --- packages/util/src/select_file.rs | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/packages/util/src/select_file.rs b/packages/util/src/select_file.rs index 437b3bf..c51f04c 100644 --- a/packages/util/src/select_file.rs +++ b/packages/util/src/select_file.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize, de::Error}; use serde_json::Value; /// Represents a file selection with its metadata and data -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct FileSelection { /// The file name including the extension but without the full path pub name: String, @@ -15,7 +15,7 @@ pub struct FileSelection { pub data: T, } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct FilePickerOptions { /// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/file#accept pub accept: Option, @@ -23,15 +23,6 @@ pub struct FilePickerOptions { pub capture: Option, } -impl Default for FilePickerOptions { - fn default() -> Self { - Self { - accept: None, - capture: None, - } - } -} - /// Encoding options for file data #[derive(Debug, Serialize)] #[serde(rename_all = "snake_case")] From 33ccf01a95cca484ff31a4bd802fc03220d8f41c Mon Sep 17 00:00:00 2001 From: mcmah309 Date: Sat, 26 Jul 2025 11:59:44 +0000 Subject: [PATCH 11/11] Clippy --- packages/util/src/scroll.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/util/src/scroll.rs b/packages/util/src/scroll.rs index b348b16..e8d76a9 100644 --- a/packages/util/src/scroll.rs +++ b/packages/util/src/scroll.rs @@ -49,7 +49,7 @@ static SCROLL_TRACKER_COUNTER: AtomicUsize = AtomicUsize::new(0); pub fn use_root_scroll() -> Signal { let callback_name = use_hook(|| { let instance_id = SCROLL_TRACKER_COUNTER.fetch_add(1, Ordering::SeqCst); - format!("scrollCallback_{}", instance_id) + format!("scrollCallback_{instance_id}") }); let mut scroll_metrics = use_signal(|| ScrollMetrics {