diff --git a/Cargo.toml b/Cargo.toml index 7b6a08a39..377ff37b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ members = [ "packages/signals", "packages/hot-reload", "docs/guide", + "examples/PWA-example", ] # This is a "virtual package" diff --git a/examples/PWA-example/Cargo.toml b/examples/PWA-example/Cargo.toml new file mode 100644 index 000000000..f11d80713 --- /dev/null +++ b/examples/PWA-example/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "dioxus-pwa-example" +version = "0.1.0" +authors = ["Antonio Curavalea "] +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +dioxus = { path = "../../packages/dioxus", version = "^0.3.0"} +dioxus-web = { path = "../../packages/web", version = "^0.3.0"} + +log = "0.4.6" + +# WebAssembly Debug +wasm-logger = "0.2.0" +console_error_panic_hook = "0.1.7" diff --git a/examples/PWA-example/Dioxus.toml b/examples/PWA-example/Dioxus.toml new file mode 100644 index 000000000..e3a472520 --- /dev/null +++ b/examples/PWA-example/Dioxus.toml @@ -0,0 +1,42 @@ +[application] + +# App (Project) Name +name = "dioxus-pwa-example" + +# Dioxus App Default Platform +# desktop, web, mobile, ssr +default_platform = "web" + +# `build` & `serve` dist path +out_dir = "dist" + +# resource (public) file folder +asset_dir = "public" + +[web.app] + +# HTML title tag content +title = "dioxus | ⛺" + +[web.watcher] + +# when watcher trigger, regenerate the `index.html` +reload_html = true + +# which files or dirs will be watcher monitoring +watch_path = ["src", "public"] + +# include `assets` in web platform +[web.resource] + +# CSS style file +style = [] + +# Javascript code file +script = [] + +[web.resource.dev] + +# Javascript code file +# serve: [dev-server] only +script = [] diff --git a/examples/PWA-example/LICENSE b/examples/PWA-example/LICENSE new file mode 100644 index 000000000..bcdd828e9 --- /dev/null +++ b/examples/PWA-example/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Dioxus + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/examples/PWA-example/README.md b/examples/PWA-example/README.md new file mode 100644 index 000000000..dfd986302 --- /dev/null +++ b/examples/PWA-example/README.md @@ -0,0 +1,44 @@ +# Dioxus PWA example + +This is a basic example of a progressive web app (PWA) using Dioxus and Dioxus CLI. +Currently PWA functionality requires the use of a service worker and manifest file, so this isn't 100% Rust yet. + +It is also very much usable as a template for your projects, if you're aiming to create a PWA. + +## Try the example + +Make sure you have Dioxus CLI installed (if you're unsure, run `cargo install dioxus-cli`). + +You can run `dioxus serve` in this directory to start the web server locally, or run +`dioxus build --release` to build the project so you can deploy it on a separate web-server. + +## Project Structure +``` +├── Cargo.toml +├── Dioxus.toml +├── index.html // Custom HTML is needed for this, to load the SW and manifest. +├── LICENSE +├── public +│ ├── favicon.ico +│ ├── logo_192.png +│ ├── logo_512.png +│ ├── manifest.json // The manifest file - edit this as you need to. +│ └── sw.js // The service worker - you must edit this for actual projects. +├── README.md +└── src + └── main.rs +``` + +## Resources + +If you're just getting started with PWAs, here are some useful resources: + +* [PWABuilder docs](https://docs.pwabuilder.com/#/) +* [MDN article on PWAs](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps) + +For service worker scripting (in JavaScript): + +* [Service worker guide from PWABuilder](https://docs.pwabuilder.com/#/home/sw-intro) +* [Service worker examples, also from PWABuilder](https://github.com/pwa-builder/pwabuilder-serviceworkers) + +If you want to stay as close to 100% Rust as possible, you can try using [wasi-worker](https://github.com/dunnock/wasi-worker) to replace the JS service worker file. The JSON manifest will still be required though. \ No newline at end of file diff --git a/examples/PWA-example/index.html b/examples/PWA-example/index.html new file mode 100644 index 000000000..44bfa59cc --- /dev/null +++ b/examples/PWA-example/index.html @@ -0,0 +1,30 @@ + + + + {app_title} + + + + + + {style_include} + + +
+ + {script_include} + + \ No newline at end of file diff --git a/examples/PWA-example/public/favicon.ico b/examples/PWA-example/public/favicon.ico new file mode 100644 index 000000000..b11015bdb Binary files /dev/null and b/examples/PWA-example/public/favicon.ico differ diff --git a/examples/PWA-example/public/logo_192.png b/examples/PWA-example/public/logo_192.png new file mode 100644 index 000000000..112eb5ee4 Binary files /dev/null and b/examples/PWA-example/public/logo_192.png differ diff --git a/examples/PWA-example/public/logo_512.png b/examples/PWA-example/public/logo_512.png new file mode 100644 index 000000000..43129772a Binary files /dev/null and b/examples/PWA-example/public/logo_512.png differ diff --git a/examples/PWA-example/public/manifest.json b/examples/PWA-example/public/manifest.json new file mode 100644 index 000000000..ef542899d --- /dev/null +++ b/examples/PWA-example/public/manifest.json @@ -0,0 +1,34 @@ +{ + "name": "Dioxus", + "icons": [ + { + "src": "logo_192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "logo_512.png", + "type": "image/png", + "sizes": "512x512", + "purpose": "any" + }, + { + "src": "logo_512.png", + "type": "image/png", + "sizes": "any", + "purpose": "any" + } + ], + "start_url": "/", + "id": "/", + "display": "standalone", + "display_override": ["window-control-overlay", "standalone"], + "scope": "/", + "theme_color": "#000000", + "background_color": "#ffffff", + "short_name": "Dioxus", + "description": "Dioxus is a portable, performant, and ergonomic framework for building cross-platform user interfaces in Rust.", + "dir": "ltr", + "lang": "en", + "orientation": "portrait" +} \ No newline at end of file diff --git a/examples/PWA-example/public/sw.js b/examples/PWA-example/public/sw.js new file mode 100644 index 000000000..162d37f4a --- /dev/null +++ b/examples/PWA-example/public/sw.js @@ -0,0 +1,198 @@ +"use strict"; + +//console.log('WORKER: executing.'); + +/* A version number is useful when updating the worker logic, + allowing you to remove outdated cache entries during the update. +*/ +var version = 'v1.0.0::'; + +/* These resources will be downloaded and cached by the service worker + during the installation process. If any resource fails to be downloaded, + then the service worker won't be installed either. +*/ +var offlineFundamentals = [ + // add here the files you want to cache + 'favicon.ico' +]; + +/* The install event fires when the service worker is first installed. + You can use this event to prepare the service worker to be able to serve + files while visitors are offline. +*/ +self.addEventListener("install", function (event) { + //console.log('WORKER: install event in progress.'); + /* Using event.waitUntil(p) blocks the installation process on the provided + promise. If the promise is rejected, the service worker won't be installed. + */ + event.waitUntil( + /* The caches built-in is a promise-based API that helps you cache responses, + as well as finding and deleting them. + */ + caches + /* You can open a cache by name, and this method returns a promise. We use + a versioned cache name here so that we can remove old cache entries in + one fell swoop later, when phasing out an older service worker. + */ + .open(version + 'fundamentals') + .then(function (cache) { + /* After the cache is opened, we can fill it with the offline fundamentals. + The method below will add all resources in `offlineFundamentals` to the + cache, after making requests for them. + */ + return cache.addAll(offlineFundamentals); + }) + .then(function () { + //console.log('WORKER: install completed'); + }) + ); +}); + +/* The fetch event fires whenever a page controlled by this service worker requests + a resource. This isn't limited to `fetch` or even XMLHttpRequest. Instead, it + comprehends even the request for the HTML page on first load, as well as JS and + CSS resources, fonts, any images, etc. +*/ +self.addEventListener("fetch", function (event) { + //console.log('WORKER: fetch event in progress.'); + + /* We should only cache GET requests, and deal with the rest of method in the + client-side, by handling failed POST,PUT,PATCH,etc. requests. + */ + if (event.request.method !== 'GET') { + /* If we don't block the event as shown below, then the request will go to + the network as usual. + */ + //console.log('WORKER: fetch event ignored.', event.request.method, event.request.url); + return; + } + /* Similar to event.waitUntil in that it blocks the fetch event on a promise. + Fulfillment result will be used as the response, and rejection will end in a + HTTP response indicating failure. + */ + event.respondWith( + caches + /* This method returns a promise that resolves to a cache entry matching + the request. Once the promise is settled, we can then provide a response + to the fetch request. + */ + .match(event.request) + .then(function (cached) { + /* Even if the response is in our cache, we go to the network as well. + This pattern is known for producing "eventually fresh" responses, + where we return cached responses immediately, and meanwhile pull + a network response and store that in the cache. + + Read more: + https://ponyfoo.com/articles/progressive-networking-serviceworker + */ + var networked = fetch(event.request) + // We handle the network request with success and failure scenarios. + .then(fetchedFromNetwork, unableToResolve) + // We should catch errors on the fetchedFromNetwork handler as well. + .catch(unableToResolve); + + /* We return the cached response immediately if there is one, and fall + back to waiting on the network as usual. + */ + //console.log('WORKER: fetch event', cached ? '(cached)' : '(network)', event.request.url); + return cached || networked; + + function fetchedFromNetwork(response) { + /* We copy the response before replying to the network request. + This is the response that will be stored on the ServiceWorker cache. + */ + var cacheCopy = response.clone(); + + //console.log('WORKER: fetch response from network.', event.request.url); + + caches + // We open a cache to store the response for this request. + .open(version + 'pages') + .then(function add(cache) { + /* We store the response for this request. It'll later become + available to caches.match(event.request) calls, when looking + for cached responses. + */ + cache.put(event.request, cacheCopy); + }) + .then(function () { + //console.log('WORKER: fetch response stored in cache.', event.request.url); + }); + + // Return the response so that the promise is settled in fulfillment. + return response; + } + + /* When this method is called, it means we were unable to produce a response + from either the cache or the network. This is our opportunity to produce + a meaningful response even when all else fails. It's the last chance, so + you probably want to display a "Service Unavailable" view or a generic + error response. + */ + function unableToResolve() { + /* There's a couple of things we can do here. + - Test the Accept header and then return one of the `offlineFundamentals` + e.g: `return caches.match('/some/cached/image.png')` + - You should also consider the origin. It's easier to decide what + "unavailable" means for requests against your origins than for requests + against a third party, such as an ad provider. + - Generate a Response programmaticaly, as shown below, and return that. + */ + + //console.log('WORKER: fetch request failed in both cache and network.'); + + /* Here we're creating a response programmatically. The first parameter is the + response body, and the second one defines the options for the response. + */ + return new Response('

Service Unavailable

', { + status: 503, + statusText: 'Service Unavailable', + headers: new Headers({ + 'Content-Type': 'text/html' + }) + }); + } + }) + ); +}); + +/* The activate event fires after a service worker has been successfully installed. + It is most useful when phasing out an older version of a service worker, as at + this point you know that the new worker was installed correctly. In this example, + we delete old caches that don't match the version in the worker we just finished + installing. +*/ +self.addEventListener("activate", function (event) { + /* Just like with the install event, event.waitUntil blocks activate on a promise. + Activation will fail unless the promise is fulfilled. + */ + //console.log('WORKER: activate event in progress.'); + + event.waitUntil( + caches + /* This method returns a promise which will resolve to an array of available + cache keys. + */ + .keys() + .then(function (keys) { + // We return a promise that settles when all outdated caches are deleted. + return Promise.all( + keys + .filter(function (key) { + // Filter by keys that don't start with the latest version prefix. + return !key.startsWith(version); + }) + .map(function (key) { + /* Return a promise that's fulfilled + when each outdated cache is deleted. + */ + return caches.delete(key); + }) + ); + }) + .then(function () { + //console.log('WORKER: activate completed.'); + }) + ); +}); diff --git a/examples/PWA-example/src/main.rs b/examples/PWA-example/src/main.rs new file mode 100644 index 000000000..4c09a9bc5 --- /dev/null +++ b/examples/PWA-example/src/main.rs @@ -0,0 +1,20 @@ +use dioxus::prelude::*; + +fn main() { + // init debug tool for WebAssembly + wasm_logger::init(wasm_logger::Config::default()); + console_error_panic_hook::set_once(); + + dioxus_web::launch(app); +} + +fn app(cx: Scope) -> Element { + cx.render(rsx! ( + div { + style: "text-align: center;", + h1 { "🌗 Dioxus 🚀" } + h3 { "Frontend that scales." } + p { "Dioxus is a portable, performant, and ergonomic framework for building cross-platform user interfaces in Rust." } + } + )) +} 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/examples/generic_component.rs b/examples/generic_component.rs index 9757b1032..3d6171a9b 100644 --- a/examples/generic_component.rs +++ b/examples/generic_component.rs @@ -1,3 +1,5 @@ +use std::fmt::Display; + use dioxus::prelude::*; fn main() { @@ -5,9 +7,20 @@ fn main() { } fn app(cx: Scope) -> Element { - cx.render(rsx! { generic_child::{} }) + cx.render(rsx! { generic_child { + data: 0i32 + } }) } -fn generic_child(cx: Scope) -> Element { - cx.render(rsx! { div {} }) +#[derive(PartialEq, Props)] +struct GenericChildProps { + data: T, +} + +fn generic_child(cx: Scope>) -> Element { + let data = &cx.props.data; + + cx.render(rsx! { div { + "{data}" + } }) } diff --git a/examples/svg.rs b/examples/svg.rs index 829a559ad..329cbe6ae 100644 --- a/examples/svg.rs +++ b/examples/svg.rs @@ -26,7 +26,7 @@ fn app(cx: Scope) -> Element { onclick: move |_| { use rand::Rng; let mut rng = rand::thread_rng(); - val.set(rng.gen_range(1..6)); + val.set(rng.gen_range(1..=6)); } } } diff --git a/packages/autofmt/src/element.rs b/packages/autofmt/src/element.rs index 6d2cdc830..5bb7d42fc 100644 --- a/packages/autofmt/src/element.rs +++ b/packages/autofmt/src/element.rs @@ -276,13 +276,12 @@ impl Writer<'_> { let start = location.start(); let line_start = start.line - 1; - let this_line = self.src[line_start]; - - let beginning = if this_line.len() > start.column { - this_line[..start.column].trim() - } else { - "" - }; + let beginning = self + .src + .get(line_start) + .filter(|this_line| this_line.len() > start.column) + .map(|this_line| this_line[..start.column].trim()) + .unwrap_or_default(); beginning.is_empty() } 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 32a2dffbd..69161e2a5 100644 --- a/packages/desktop/src/lib.rs +++ b/packages/desktop/src/lib.rs @@ -9,6 +9,7 @@ mod element; mod escape; mod eval; mod events; +mod file_upload; mod protocol; mod query; mod shortcut; @@ -22,15 +23,16 @@ pub use desktop_context::{ }; use desktop_context::{EventData, UserWindowEvent, WebviewQueue, WindowEventHandlers}; use dioxus_core::*; -use dioxus_html::{HtmlEvent, MountedData}; +use dioxus_html::MountedData; +use dioxus_html::{native_bind::NativeFileEngine, FormData, HtmlEvent}; use element::DesktopElement; 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; @@ -294,6 +296,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 efdc849a5..e35d67bb1 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/native-core/Cargo.toml b/packages/native-core/Cargo.toml index 9ea6d4c9f..c9be2f27d 100644 --- a/packages/native-core/Cargo.toml +++ b/packages/native-core/Cargo.toml @@ -29,7 +29,6 @@ lightningcss = "1.0.0-alpha.39" rayon = "1.6.1" shipyard = { version = "0.6.2", features = ["proc", "std"], default-features = false } -shipyard_hierarchy = "0.6.0" [dev-dependencies] rand = "0.8.5" diff --git a/packages/rink/src/lib.rs b/packages/rink/src/lib.rs index 9fceb6b48..7f15aa751 100644 --- a/packages/rink/src/lib.rs +++ b/packages/rink/src/lib.rs @@ -98,7 +98,8 @@ pub fn render( let event_tx_clone = event_tx.clone(); if !cfg.headless { std::thread::spawn(move || { - let tick_rate = Duration::from_millis(1000); + // Timeout after 10ms when waiting for events + let tick_rate = Duration::from_millis(10); loop { if crossterm::event::poll(tick_rate).unwrap() { let evt = crossterm::event::read().unwrap(); diff --git a/packages/router/src/components/route.rs b/packages/router/src/components/route.rs index cf665d2e1..7bfc48993 100644 --- a/packages/router/src/components/route.rs +++ b/packages/router/src/components/route.rs @@ -45,13 +45,13 @@ pub fn Route<'a>(cx: Scope<'a, RouteProps<'a>>) -> Element { router_root.register_total_route(route_context.total_route, cx.scope_id()); }); - log::debug!("Checking Route: {:?}", cx.props.to); + log::trace!("Checking Route: {:?}", cx.props.to); if router_root.should_render(cx.scope_id()) { - log::debug!("Route should render: {:?}", cx.scope_id()); + log::trace!("Route should render: {:?}", cx.scope_id()); cx.render(rsx!(&cx.props.children)) } else { - log::debug!("Route should *not* render: {:?}", cx.scope_id()); + log::trace!("Route should *not* render: {:?}", cx.scope_id()); cx.render(rsx!(())) } } diff --git a/packages/rsx-rosetta/Cargo.toml b/packages/rsx-rosetta/Cargo.toml index d160d2bf9..e6bc45225 100644 --- a/packages/rsx-rosetta/Cargo.toml +++ b/packages/rsx-rosetta/Cargo.toml @@ -25,3 +25,6 @@ convert_case = "0.5.0" # default = ["html"] # eventually more output options + +[dev-dependencies] +pretty_assertions = "1.2.1" \ No newline at end of file diff --git a/packages/rsx-rosetta/tests/simple.rs b/packages/rsx-rosetta/tests/simple.rs new file mode 100644 index 000000000..b8fd095e1 --- /dev/null +++ b/packages/rsx-rosetta/tests/simple.rs @@ -0,0 +1,68 @@ +use html_parser::Dom; + +#[test] +fn simple_elements() { + let html = r#" +

+
hello world!
+
hello world!
+
hello world!
+
hello world!
+
hello world!
+
hello world!
+ hello world! +
+ "# + .trim(); + + let dom = Dom::parse(html).unwrap(); + + let body = rsx_rosetta::rsx_from_html(&dom); + + let out = dioxus_autofmt::write_block_out(body).unwrap(); + + let expected = r#" + div { + div { class: "asd", "hello world!" } + div { id: "asd", "hello world!" } + div { id: "asd", "hello world!" } + div { r#for: "asd", "hello world!" } + div { r#async: "asd", "hello world!" } + div { large_thing: "asd", "hello world!" } + ai_is_awesome { "hello world!" } + }"#; + pretty_assertions::assert_eq!(&out, &expected); +} + +#[test] +fn deeply_nested() { + let html = r#" +
+
+
+
+
+
+
+
+
+
+ "# + .trim(); + + let dom = Dom::parse(html).unwrap(); + + let body = rsx_rosetta::rsx_from_html(&dom); + + let out = dioxus_autofmt::write_block_out(body).unwrap(); + + let expected = r#" + div { + div { class: "asd", + div { class: "asd", + div { class: "asd", div { class: "asd" } } + } + } + }"#; + pretty_assertions::assert_eq!(&out, &expected); +} 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 8aae595eb..2e3f982dc 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, MountedData}; +use dioxus_html::{event_bubbles, CompositionData, FileEngine, FormData, MountedData}; use dioxus_interpreter_js::{get_node, 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, @@ -254,6 +255,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 {}), @@ -355,47 +357,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 e208b0591..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; @@ -225,7 +226,7 @@ pub async fn run_with_props(root: fn(Scope) -> Element, root_prop websys_dom.mount(); loop { - log::debug!("waiting for work"); + log::trace!("waiting for work"); // if virtualdom has nothing, wait for it to have something before requesting idle time // if there is work then this future resolves immediately.