diff --git a/examples/file_upload.rs b/examples/file_upload.rs new file mode 100644 index 000000000..b23648b50 --- /dev/null +++ b/examples/file_upload.rs @@ -0,0 +1,37 @@ +#![allow(non_snake_case)] +use dioxus::prelude::*; + +fn main() { + dioxus_desktop::launch(App); +} + +fn App(cx: Scope) -> Element { + let files_uploaded: &UseRef> = use_ref(cx, Vec::new); + + cx.render(rsx! { + input { + r#type: "file", + accept: ".txt, .rs", + multiple: true, + onchange: |evt| { + to_owned![files_uploaded]; + async move { + if let Some(file_engine) = &evt.files { + let files = file_engine.files(); + for file_name in &files { + if let Some(file) = file_engine.read_file_to_string(file_name).await{ + files_uploaded.write().push(file); + } + } + } + } + }, + } + + ul { + for file in files_uploaded.read().iter() { + li { "{file}" } + } + } + }) +} diff --git a/packages/core/src/virtual_dom.rs b/packages/core/src/virtual_dom.rs index 2269815aa..da5526b32 100644 --- a/packages/core/src/virtual_dom.rs +++ b/packages/core/src/virtual_dom.rs @@ -384,51 +384,73 @@ impl VirtualDom { data, }; - // Loop through each dynamic attribute in this template before moving up to the template's parent. - while let Some(el_ref) = parent_path { - // safety: we maintain references of all vnodes in the element slab - let template = unsafe { el_ref.template.unwrap().as_ref() }; - let node_template = template.template.get(); - let target_path = el_ref.path; + // If the event bubbles, we traverse through the tree until we find the target element. + if bubbles { + // Loop through each dynamic attribute (in a depth first order) in this template before moving up to the template's parent. + while let Some(el_ref) = parent_path { + // safety: we maintain references of all vnodes in the element slab + let template = unsafe { el_ref.template.unwrap().as_ref() }; + let node_template = template.template.get(); + let target_path = el_ref.path; - for (idx, attr) in template.dynamic_attrs.iter().enumerate() { - let this_path = node_template.attr_paths[idx]; + for (idx, attr) in template.dynamic_attrs.iter().enumerate() { + let this_path = node_template.attr_paths[idx]; - // Remove the "on" prefix if it exists, TODO, we should remove this and settle on one - if attr.name.trim_start_matches("on") == name - && target_path.is_decendant(&this_path) - { - listeners.push(&attr.value); + // Remove the "on" prefix if it exists, TODO, we should remove this and settle on one + if attr.name.trim_start_matches("on") == name + && target_path.is_decendant(&this_path) + { + listeners.push(&attr.value); - // Break if the event doesn't bubble anyways - if !bubbles { - break; + // Break if this is the exact target element. + // This means we won't call two listeners with the same name on the same element. This should be + // documented, or be rejected from the rsx! macro outright + if target_path == this_path { + break; + } } + } - // Break if this is the exact target element. - // This means we won't call two listeners with the same name on the same element. This should be - // documented, or be rejected from the rsx! macro outright - if target_path == this_path { - break; + // Now that we've accumulated all the parent attributes for the target element, call them in reverse order + // We check the bubble state between each call to see if the event has been stopped from bubbling + for listener in listeners.drain(..).rev() { + if let AttributeValue::Listener(listener) = listener { + if let Some(cb) = listener.borrow_mut().as_deref_mut() { + cb(uievent.clone()); + } + + if !uievent.propagates.get() { + return; + } + } + } + + parent_path = template.parent.and_then(|id| self.elements.get(id.0)); + } + } else { + // Otherwise, we just call the listener on the target element + if let Some(el_ref) = parent_path { + // safety: we maintain references of all vnodes in the element slab + let template = unsafe { el_ref.template.unwrap().as_ref() }; + let node_template = template.template.get(); + let target_path = el_ref.path; + + for (idx, attr) in template.dynamic_attrs.iter().enumerate() { + let this_path = node_template.attr_paths[idx]; + + // Remove the "on" prefix if it exists, TODO, we should remove this and settle on one + // Only call the listener if this is the exact target element. + if attr.name.trim_start_matches("on") == name && target_path == this_path { + if let AttributeValue::Listener(listener) = &attr.value { + if let Some(cb) = listener.borrow_mut().as_deref_mut() { + cb(uievent.clone()); + } + + break; + } } } } - - // Now that we've accumulated all the parent attributes for the target element, call them in reverse order - // We check the bubble state between each call to see if the event has been stopped from bubbling - for listener in listeners.drain(..).rev() { - if let AttributeValue::Listener(listener) = listener { - if let Some(cb) = listener.borrow_mut().as_deref_mut() { - cb(uievent.clone()); - } - - if !uievent.propagates.get() { - return; - } - } - } - - parent_path = template.parent.and_then(|id| self.elements.get(id.0)); } } diff --git a/packages/desktop/Cargo.toml b/packages/desktop/Cargo.toml index 5722a42a2..8b41019e1 100644 --- a/packages/desktop/Cargo.toml +++ b/packages/desktop/Cargo.toml @@ -13,7 +13,7 @@ keywords = ["dom", "ui", "gui", "react"] [dependencies] dioxus-core = { path = "../core", version = "^0.3.0", features = ["serialize"] } -dioxus-html = { path = "../html", features = ["serialize"], version = "^0.3.0" } +dioxus-html = { path = "../html", features = ["serialize", "native-bind"], version = "^0.3.0" } dioxus-interpreter-js = { path = "../interpreter", version = "^0.3.0" } dioxus-hot-reload = { path = "../hot-reload", optional = true } @@ -23,12 +23,13 @@ thiserror = "1.0.30" log = "0.4.14" wry = { version = "0.27.2" } futures-channel = "0.3.21" -tokio = { version = "1.16.1", features = [ +tokio = { version = "1.27", features = [ "sync", "rt-multi-thread", "rt", "time", "macros", + "fs", ], optional = true, default-features = false } webbrowser = "0.8.0" infer = "0.11.0" @@ -36,6 +37,7 @@ dunce = "1.0.2" slab = "0.4" futures-util = "0.3.25" +rfd = "0.11.3" [target.'cfg(target_os = "ios")'.dependencies] objc = "0.2.7" diff --git a/packages/desktop/src/file_upload.rs b/packages/desktop/src/file_upload.rs new file mode 100644 index 000000000..ae4a81cc2 --- /dev/null +++ b/packages/desktop/src/file_upload.rs @@ -0,0 +1,74 @@ +use std::{path::PathBuf, str::FromStr}; + +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub(crate) struct FileDiologRequest { + accept: String, + multiple: bool, + pub event: String, + pub target: usize, + pub bubbles: bool, +} + +pub(crate) fn get_file_event(request: &FileDiologRequest) -> Vec { + let mut dialog = rfd::FileDialog::new(); + + let filters: Vec<_> = request + .accept + .split(',') + .filter_map(|s| Filters::from_str(s).ok()) + .collect(); + + let file_extensions: Vec<_> = filters + .iter() + .flat_map(|f| f.as_extensions().into_iter()) + .collect(); + + dialog = dialog.add_filter("name", file_extensions.as_slice()); + + let files: Vec<_> = if request.multiple { + dialog.pick_files().into_iter().flatten().collect() + } else { + dialog.pick_file().into_iter().collect() + }; + + files +} + +enum Filters { + Extension(String), + Mime(String), + Audio, + Video, + Image, +} + +impl Filters { + fn as_extensions(&self) -> Vec<&str> { + match self { + Filters::Extension(extension) => vec![extension.as_str()], + Filters::Mime(_) => vec![], + Filters::Audio => vec!["mp3", "wav", "ogg"], + Filters::Video => vec!["mp4", "webm"], + Filters::Image => vec!["png", "jpg", "jpeg", "gif", "webp"], + } + } +} + +impl FromStr for Filters { + type Err = String; + + fn from_str(s: &str) -> Result { + if let Some(extension) = s.strip_prefix('.') { + Ok(Filters::Extension(extension.to_string())) + } else { + match s { + "audio/*" => Ok(Filters::Audio), + "video/*" => Ok(Filters::Video), + "image/*" => Ok(Filters::Image), + _ => Ok(Filters::Mime(s.to_string())), + } + } + } +} diff --git a/packages/desktop/src/lib.rs b/packages/desktop/src/lib.rs index cdb592f72..807ebf48e 100644 --- a/packages/desktop/src/lib.rs +++ b/packages/desktop/src/lib.rs @@ -8,6 +8,7 @@ mod desktop_context; mod escape; mod eval; mod events; +mod file_upload; mod protocol; mod shortcut; mod waker; @@ -19,14 +20,14 @@ pub use desktop_context::{ }; use desktop_context::{EventData, UserWindowEvent, WebviewQueue, WindowEventHandlers}; use dioxus_core::*; -use dioxus_html::HtmlEvent; +use dioxus_html::{native_bind::NativeFileEngine, FormData, HtmlEvent}; pub use eval::{use_eval, EvalResult}; use futures_util::{pin_mut, FutureExt}; use shortcut::ShortcutRegistry; pub use shortcut::{use_global_shortcut, ShortcutHandle, ShortcutId, ShortcutRegistryError}; -use std::collections::HashMap; use std::rc::Rc; use std::task::Waker; +use std::{collections::HashMap, sync::Arc}; pub use tao::dpi::{LogicalSize, PhysicalSize}; use tao::event_loop::{EventLoopProxy, EventLoopWindowTarget}; pub use tao::window::WindowBuilder; @@ -264,6 +265,34 @@ pub fn launch_with_props(root: Component

, props: P, cfg: Config) } } + EventData::Ipc(msg) if msg.method() == "file_diolog" => { + if let Ok(file_diolog) = + serde_json::from_value::(msg.params()) + { + let id = ElementId(file_diolog.target); + let event_name = &file_diolog.event; + let event_bubbles = file_diolog.bubbles; + let files = file_upload::get_file_event(&file_diolog); + let data = Rc::new(FormData { + value: Default::default(), + values: Default::default(), + files: Some(Arc::new(NativeFileEngine::new(files))), + }); + + let view = webviews.get_mut(&event.1).unwrap(); + + if event_name == "change&input" { + view.dom + .handle_event("input", data.clone(), id, event_bubbles); + view.dom.handle_event("change", data, id, event_bubbles); + } else { + view.dom.handle_event(event_name, data, id, event_bubbles); + } + + send_edits(view.dom.render_immediate(), &view.webview); + } + } + _ => {} }, Event::GlobalShortcutEvent(id) => shortcut_manager.call_handlers(id), diff --git a/packages/desktop/src/protocol.rs b/packages/desktop/src/protocol.rs index 40cdfc88a..a10ba8662 100644 --- a/packages/desktop/src/protocol.rs +++ b/packages/desktop/src/protocol.rs @@ -9,10 +9,36 @@ use wry::{ }; fn module_loader(root_name: &str) -> String { + let js = INTERPRETER_JS.replace( + "/*POST_HANDLE_EDITS*/", + r#"// Prevent file inputs from opening the file dialog on click + let inputs = document.querySelectorAll("input"); + for (let input of inputs) { + if (!input.getAttribute("data-dioxus-file-listener")) { + input.setAttribute("data-dioxus-file-listener", true); + input.addEventListener("click", (event) => { + let target = event.target; + // prevent file inputs from opening the file dialog on click + const type = target.getAttribute("type"); + if (type === "file") { + let target_id = find_real_id(target); + if (target_id !== null) { + const send = (event_name) => { + const message = serializeIpcMessage("file_diolog", { accept: target.getAttribute("accept"), multiple: target.hasAttribute("multiple"), target: parseInt(target_id), bubbles: event_bubbles(event_name), event: event_name }); + window.ipc.postMessage(message); + }; + send("change&input"); + } + event.preventDefault(); + } + }); + } + }"#, + ); format!( r#" diff --git a/packages/web/Cargo.toml b/packages/web/Cargo.toml index 7adfbf13a..fcbb996bb 100644 --- a/packages/web/Cargo.toml +++ b/packages/web/Cargo.toml @@ -32,6 +32,7 @@ futures-channel = "0.3.21" serde_json = { version = "1.0" } serde = { version = "1.0" } serde-wasm-bindgen = "0.4.5" +async-trait = "0.1.58" [dependencies.web-sys] version = "0.3.56" @@ -76,6 +77,9 @@ features = [ "Location", "MessageEvent", "console", + "FileList", + "File", + "FileReader" ] [features] diff --git a/packages/web/src/dom.rs b/packages/web/src/dom.rs index 4d17749d8..e1e3b6365 100644 --- a/packages/web/src/dom.rs +++ b/packages/web/src/dom.rs @@ -10,15 +10,16 @@ use dioxus_core::{ BorrowedAttributeValue, ElementId, Mutation, Template, TemplateAttribute, TemplateNode, }; -use dioxus_html::{event_bubbles, CompositionData, FormData}; +use dioxus_html::{event_bubbles, CompositionData, FileEngine, FormData}; use dioxus_interpreter_js::{save_template, Channel}; use futures_channel::mpsc; +use js_sys::Array; use rustc_hash::FxHashMap; -use std::{any::Any, rc::Rc}; -use wasm_bindgen::{closure::Closure, JsCast}; -use web_sys::{Document, Element, Event, HtmlElement}; +use std::{any::Any, rc::Rc, sync::Arc}; +use wasm_bindgen::{closure::Closure, prelude::wasm_bindgen, JsCast}; +use web_sys::{console, Document, Element, Event, HtmlElement}; -use crate::Config; +use crate::{file_engine::WebFileEngine, Config}; pub struct WebsysDom { document: Document, @@ -206,6 +207,7 @@ impl WebsysDom { }, SetText { value, id } => i.set_text(id.0 as u32, value), NewEventListener { name, id, .. } => { + console::log_1(&format!("new event listener: {}", name).into()); i.new_event_listener(name, id.0 as u32, event_bubbles(name) as u8); } RemoveEventListener { name, id } => { @@ -224,6 +226,7 @@ impl WebsysDom { // We need tests that simulate clicks/etc and make sure every event type works. pub fn virtual_event_from_websys_event(event: web_sys::Event, target: Element) -> Rc { use dioxus_html::events::*; + console::log_1(&event.clone().into()); match event.type_().as_str() { "copy" | "cut" | "paste" => Rc::new(ClipboardData {}), @@ -325,47 +328,53 @@ fn read_input_to_data(target: Element) -> Rc { // try to fill in form values if let Some(form) = target.dyn_ref::() { - let elements = form.elements(); - for x in 0..elements.length() { - let element = elements.item(x).unwrap(); - if let Some(name) = element.get_attribute("name") { - let value: Option = element - .dyn_ref() - .map(|input: &web_sys::HtmlInputElement| { - match input.type_().as_str() { - "checkbox" => { - match input.checked() { - true => Some("true".to_string()), - false => Some("false".to_string()), - } - }, - "radio" => { - match input.checked() { - true => Some(input.value()), - false => None, - } - } - _ => Some(input.value()) - } - }) - .or_else(|| element.dyn_ref().map(|input: &web_sys::HtmlTextAreaElement| Some(input.value()))) - .or_else(|| element.dyn_ref().map(|input: &web_sys::HtmlSelectElement| Some(input.value()))) - .or_else(|| Some(element.dyn_ref::().unwrap().text_content())) - .expect("only an InputElement or TextAreaElement or an element with contenteditable=true can have an oninput event listener"); - if let Some(value) = value { - values.insert(name, value); + let form_data = get_form_data(form); + for value in form_data.entries().into_iter().flatten() { + if let Ok(array) = value.dyn_into::() { + if let Some(name) = array.get(0).as_string() { + if let Ok(item_values) = array.get(1).dyn_into::() { + let item_values = + item_values.iter().filter_map(|v| v.as_string()).collect(); + + values.insert(name, item_values); + } } } } } + let files = target + .dyn_ref() + .and_then(|input: &web_sys::HtmlInputElement| { + input.files().and_then(|files| { + WebFileEngine::new(files).map(|f| Arc::new(f) as Arc) + }) + }); + Rc::new(FormData { value, values, - files: None, + files, }) } +// web-sys does not expose the keys api for form data, so we need to manually bind to it +#[wasm_bindgen(inline_js = r#" + export function get_form_data(form) { + let values = new Map(); + const formData = new FormData(form); + + for (let name of formData.keys()) { + values.set(name, formData.getAll(name)); + } + + return values; + } +"#)] +extern "C" { + fn get_form_data(form: &web_sys::HtmlFormElement) -> js_sys::Map; +} + fn walk_event_for_id(event: &web_sys::Event) -> Option<(ElementId, web_sys::Element)> { let mut target = event .target() diff --git a/packages/web/src/file_engine.rs b/packages/web/src/file_engine.rs new file mode 100644 index 000000000..6552be084 --- /dev/null +++ b/packages/web/src/file_engine.rs @@ -0,0 +1,103 @@ +use dioxus_html::FileEngine; +use futures_channel::oneshot; +use js_sys::Uint8Array; +use wasm_bindgen::{prelude::Closure, JsCast}; +use web_sys::{File, FileList, FileReader}; + +pub(crate) struct WebFileEngine { + file_reader: FileReader, + file_list: FileList, +} + +impl WebFileEngine { + pub fn new(file_list: FileList) -> Option { + Some(Self { + file_list, + file_reader: FileReader::new().ok()?, + }) + } + + fn len(&self) -> usize { + self.file_list.length() as usize + } + + fn get(&self, index: usize) -> Option { + self.file_list.item(index as u32) + } + + fn find(&self, name: &str) -> Option { + (0..self.len()) + .filter_map(|i| self.get(i)) + .find(|f| f.name() == name) + } +} + +#[async_trait::async_trait(?Send)] +impl FileEngine for WebFileEngine { + fn files(&self) -> Vec { + (0..self.len()) + .filter_map(|i| self.get(i).map(|f| f.name())) + .collect() + } + + // read a file to bytes + async fn read_file(&self, file: &str) -> Option> { + let file = self.find(file)?; + + let file_reader = self.file_reader.clone(); + let (rx, tx) = oneshot::channel(); + let on_load: Closure = Closure::new({ + let mut rx = Some(rx); + move || { + let result = file_reader.result(); + let _ = rx + .take() + .expect("multiple files read without refreshing the channel") + .send(result); + } + }); + + self.file_reader + .set_onload(Some(on_load.as_ref().unchecked_ref())); + on_load.forget(); + self.file_reader.read_as_array_buffer(&file).ok()?; + + if let Ok(Ok(js_val)) = tx.await { + let as_u8_arr = Uint8Array::new(&js_val); + let as_u8_vec = as_u8_arr.to_vec(); + + Some(as_u8_vec) + } else { + None + } + } + + // read a file to string + async fn read_file_to_string(&self, file: &str) -> Option { + let file = self.find(file)?; + + let file_reader = self.file_reader.clone(); + let (rx, tx) = oneshot::channel(); + let on_load: Closure = Closure::new({ + let mut rx = Some(rx); + move || { + let result = file_reader.result(); + let _ = rx + .take() + .expect("multiple files read without refreshing the channel") + .send(result); + } + }); + + self.file_reader + .set_onload(Some(on_load.as_ref().unchecked_ref())); + on_load.forget(); + self.file_reader.read_as_text(&file).ok()?; + + if let Ok(Ok(js_val)) = tx.await { + js_val.as_string() + } else { + None + } + } +} diff --git a/packages/web/src/lib.rs b/packages/web/src/lib.rs index b9a806c5a..34aa7033b 100644 --- a/packages/web/src/lib.rs +++ b/packages/web/src/lib.rs @@ -61,6 +61,7 @@ use futures_util::{pin_mut, FutureExt, StreamExt}; mod cache; mod cfg; mod dom; +mod file_engine; mod hot_reload; #[cfg(feature = "hydrate")] mod rehydrate;