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/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 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..de8864e --- /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)" + } + } + } +} diff --git a/packages/util/Cargo.toml b/packages/util/Cargo.toml index ef7d895..a2de720 100644 --- a/packages/util/Cargo.toml +++ b/packages/util/Cargo.toml @@ -15,4 +15,6 @@ repository.workspace = true [dependencies] dioxus = { workspace = true } +serde_json = { workspace = true } + serde.workspace = true diff --git a/packages/util/src/lib.rs b/packages/util/src/lib.rs index 4a879de..bc08ae8 100644 --- a/packages/util/src/lib.rs +++ b/packages/util/src/lib.rs @@ -1,3 +1,4 @@ //! Common utilities for Dioxus. pub mod scroll; +pub mod select_file; 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 { diff --git a/packages/util/src/select_file.rs b/packages/util/src/select_file.rs new file mode 100644 index 0000000..c51f04c --- /dev/null +++ b/packages/util/src/select_file.rs @@ -0,0 +1,249 @@ +use dioxus::document::{EvalError, eval}; +use serde::{Deserialize, Serialize, de::Error}; +use serde_json::Value; + +/// Represents a file selection with its metadata and data +#[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, + /// MIME type: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/MIME_types/Common_types + pub r#type: String, + /// The size of the file in bytes + pub size: u64, + /// The data contained in the file in the corresponding encoding if requested + pub data: T, +} + +#[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, + /// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/file#capture + pub capture: Option, +} + +/// 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, + 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(map_to_unit).transpose() +} + +/// 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(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( + "Expected no file data but received non-null data. This indicates a mismatch between encoding settings and returned data.", + ))); + } + + Ok(FileSelection { + name, + r#type, + size, + data: (), + }) +} + +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) +}