Merge branch 'upstream' into query-system

This commit is contained in:
Evan Almloff 2023-04-26 18:00:39 -05:00
commit e1f02f8135
38 changed files with 1146 additions and 110 deletions

View file

@ -23,6 +23,7 @@ members = [
"packages/signals",
"packages/hot-reload",
"docs/guide",
"examples/PWA-example",
]
# This is a "virtual package"

View file

@ -0,0 +1,17 @@
[package]
name = "dioxus-pwa-example"
version = "0.1.0"
authors = ["Antonio Curavalea <one.kyonblack@gmail.com>"]
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"

View file

@ -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 = []

View file

@ -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.

View file

@ -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.

View file

@ -0,0 +1,30 @@
<!DOCTYPE html>
<html>
<head>
<title>{app_title}</title>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register(
'/sw.js'
);
}
</script>
<link rel="manifest" href="manifest.json">
<meta content="text/html;charset=utf-8" http-equiv="Content-Type" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta charset="UTF-8" />
{style_include}
</head>
<body>
<div id="main"></div>
<script type="module">
import init from "/{base_path}/assets/dioxus/{app_name}.js";
init("/{base_path}/assets/dioxus/{app_name}_bg.wasm").then(wasm => {
if (wasm.__wbindgen_start == undefined) {
wasm.main();
}
});
</script>
{script_include}
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View file

@ -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"
}

View file

@ -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('<h1>Service Unavailable</h1>', {
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.');
})
);
});

View file

@ -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." }
}
))
}

37
examples/file_upload.rs Normal file
View file

@ -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<Vec<String>> = 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}" }
}
}
})
}

View file

@ -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::<i32>{} })
cx.render(rsx! { generic_child {
data: 0i32
} })
}
fn generic_child<T>(cx: Scope) -> Element {
cx.render(rsx! { div {} })
#[derive(PartialEq, Props)]
struct GenericChildProps<T: Display + PartialEq> {
data: T,
}
fn generic_child<T: Display + PartialEq>(cx: Scope<GenericChildProps<T>>) -> Element {
let data = &cx.props.data;
cx.render(rsx! { div {
"{data}"
} })
}

View file

@ -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));
}
}
}

View file

@ -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()
}

View file

@ -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));
}
}

View file

@ -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"

View file

@ -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<PathBuf> {
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<Self, Self::Err> {
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())),
}
}
}
}

View file

@ -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<P: 'static>(root: Component<P>, props: P, cfg: Config)
}
}
EventData::Ipc(msg) if msg.method() == "file_diolog" => {
if let Ok(file_diolog) =
serde_json::from_value::<file_upload::FileDiologRequest>(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),

View file

@ -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#"
<script>
{INTERPRETER_JS}
{js}
let rootname = "{root_name}";
let root = window.document.getElementById(rootname);

View file

@ -21,6 +21,8 @@ enumset = "1.0.11"
keyboard-types = "0.6.2"
async-trait = "0.1.58"
serde-value = "0.7.0"
tokio = { version = "1.27", features = ["fs", "io-util"], optional = true }
rfd = { version = "0.11.3", optional = true }
[dependencies.web-sys]
optional = true
@ -53,4 +55,5 @@ serde_json = "1"
default = ["serialize"]
serialize = ["serde", "serde_repr", "euclid/serde", "keyboard-types/serde", "dioxus-core/serialize"]
wasm-bind = ["web-sys", "wasm-bindgen"]
native-bind = ["tokio", "rfd"]
hot-reload-context = ["dioxus-rsx"]

View file

@ -10,12 +10,57 @@ pub type FormEvent = Event<FormData>;
pub struct FormData {
pub value: String,
pub values: HashMap<String, String>,
pub values: HashMap<String, Vec<String>>,
#[cfg_attr(feature = "serialize", serde(skip))]
#[cfg_attr(
feature = "serialize",
serde(skip_serializing, deserialize_with = "deserialize_file_engine")
)]
pub files: Option<std::sync::Arc<dyn FileEngine>>,
}
#[cfg(feature = "serialize")]
#[derive(serde::Serialize, serde::Deserialize)]
struct SerializedFileEngine {
files: HashMap<String, Vec<u8>>,
}
#[cfg(feature = "serialize")]
#[async_trait::async_trait(?Send)]
impl FileEngine for SerializedFileEngine {
fn files(&self) -> Vec<String> {
self.files.keys().cloned().collect()
}
async fn read_file(&self, file: &str) -> Option<Vec<u8>> {
self.files.get(file).cloned()
}
async fn read_file_to_string(&self, file: &str) -> Option<String> {
self.read_file(file)
.await
.map(|bytes| String::from_utf8_lossy(&bytes).to_string())
}
}
#[cfg(feature = "serialize")]
fn deserialize_file_engine<'de, D>(
deserializer: D,
) -> Result<Option<std::sync::Arc<dyn FileEngine>>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::Deserialize;
let Ok(file_engine) =
SerializedFileEngine::deserialize(deserializer) else{
return Ok(None);
};
let file_engine = std::sync::Arc::new(file_engine);
Ok(Some(file_engine))
}
impl PartialEq for FormData {
fn eq(&self, other: &Self) -> bool {
self.value == other.value && self.values == other.values

View file

@ -20,6 +20,8 @@ pub mod events;
pub mod geometry;
mod global_attributes;
pub mod input_data;
#[cfg(feature = "native-bind")]
pub mod native_bind;
mod render_template;
#[cfg(feature = "wasm-bind")]
mod web_sys_bind;

View file

@ -0,0 +1,3 @@
mod native_file_engine;
pub use native_file_engine::*;

View file

@ -0,0 +1,43 @@
use std::path::PathBuf;
use crate::FileEngine;
use tokio::fs::File;
use tokio::io::AsyncReadExt;
pub struct NativeFileEngine {
files: Vec<PathBuf>,
}
impl NativeFileEngine {
pub fn new(files: Vec<PathBuf>) -> Self {
Self { files }
}
}
#[async_trait::async_trait(?Send)]
impl FileEngine for NativeFileEngine {
fn files(&self) -> Vec<String> {
self.files
.iter()
.filter_map(|f| Some(f.to_str()?.to_string()))
.collect()
}
async fn read_file(&self, file: &str) -> Option<Vec<u8>> {
let mut file = File::open(file).await.ok()?;
let mut contents = Vec::new();
file.read_to_end(&mut contents).await.ok()?;
Some(contents)
}
async fn read_file_to_string(&self, file: &str) -> Option<String> {
let mut file = File::open(file).await.ok()?;
let mut contents = String::new();
file.read_to_string(&mut contents).await.ok()?;
Some(contents)
}
}

View file

@ -17,8 +17,7 @@ class ListenerMap {
} else {
this.global[event_name].active++;
}
}
else {
} else {
const id = element.getAttribute("data-dioxus-id");
if (!this.local[id]) {
this.local[id] = {};
@ -32,11 +31,13 @@ class ListenerMap {
if (bubbles) {
this.global[event_name].active--;
if (this.global[event_name].active === 0) {
this.root.removeEventListener(event_name, this.global[event_name].callback);
this.root.removeEventListener(
event_name,
this.global[event_name].callback
);
delete this.global[event_name];
}
}
else {
} else {
const id = element.getAttribute("data-dioxus-id");
delete this.local[id][event_name];
if (this.local[id].length === 0) {
@ -143,8 +144,7 @@ class Interpreter {
SetAttribute(id, field, value, ns) {
if (value === null) {
this.RemoveAttribute(id, field, ns);
}
else {
} else {
const node = this.nodes[id];
this.SetAttributeInner(node, field, value, ns);
}
@ -167,10 +167,10 @@ class Interpreter {
}
break;
case "checked":
node.checked = value === "true";
node.checked = value === "true" || value === true;
break;
case "selected":
node.selected = value === "true";
node.selected = value === "true" || value === true;
break;
case "dangerous_inner_html":
node.innerHTML = value;
@ -257,6 +257,8 @@ class Interpreter {
for (let edit of edits.edits) {
this.handleEdit(edit);
}
/*POST_HANDLE_EDITS*/
}
SaveTemplate(template) {
@ -387,7 +389,6 @@ class Interpreter {
this.RemoveEventListener(edit.id, edit.name);
break;
case "NewEventListener":
let bubbles = event_bubbles(edit.name);
// if this is a mounted listener, we send the event immediately
@ -418,7 +419,10 @@ class Interpreter {
let a_element = target.closest("a");
if (a_element != null) {
event.preventDefault();
if (shouldPreventDefault !== `onclick` && a_element.getAttribute(`dioxus-prevent-default`) !== `onclick`) {
if (
shouldPreventDefault !== `onclick` &&
a_element.getAttribute(`dioxus-prevent-default`) !== `onclick`
) {
const href = a_element.getAttribute("href");
if (href !== "" && href !== null && href !== undefined) {
window.ipc.postMessage(
@ -501,6 +505,99 @@ class Interpreter {
}
}
// this handler is only provided on the desktop and liveview implementations since this
// method is not used by the web implementation
function handler(event, name, bubbles) {
let target = event.target;
if (target != null) {
let shouldPreventDefault = target.getAttribute(`dioxus-prevent-default`);
if (event.type === "click") {
// Prevent redirects from links
let a_element = target.closest("a");
if (a_element != null) {
event.preventDefault();
if (
shouldPreventDefault !== `onclick` &&
a_element.getAttribute(`dioxus-prevent-default`) !== `onclick`
) {
const href = a_element.getAttribute("href");
if (href !== "" && href !== null && href !== undefined) {
window.ipc.postMessage(
serializeIpcMessage("browser_open", { href })
);
}
}
}
// also prevent buttons from submitting
if (target.tagName === "BUTTON" && event.type == "submit") {
event.preventDefault();
}
}
const realId = find_real_id(target);
shouldPreventDefault = target.getAttribute(`dioxus-prevent-default`);
if (shouldPreventDefault === `on${event.type}`) {
event.preventDefault();
}
if (event.type === "submit") {
event.preventDefault();
}
let contents = serialize_event(event);
/*POST_EVENT_SERIALIZATION*/
if (
target.tagName === "FORM" &&
(event.type === "submit" || event.type === "input")
) {
if (
target.tagName === "FORM" &&
(event.type === "submit" || event.type === "input")
) {
const formData = new FormData(target);
for (let name of formData.keys()) {
let value = formData.getAll(name);
contents.values[name] = value;
}
}
}
if (realId === null) {
return;
}
window.ipc.postMessage(
serializeIpcMessage("user_event", {
name: name,
element: parseInt(realId),
data: contents,
bubbles,
})
);
}
}
function find_real_id(target) {
let realId = target.getAttribute(`data-dioxus-id`);
// walk the tree to find the real element
while (realId == null) {
// we've reached the root we don't want to send an event
if (target.parentElement === null) {
return;
}
target = target.parentElement;
realId = target.getAttribute(`data-dioxus-id`);
}
return realId;
}
function get_mouse_data(event) {
const {
altKey,

View file

@ -20,7 +20,7 @@ futures-util = { version = "0.3.25", default-features = false, features = [
"sink",
] }
futures-channel = { version = "0.3.25", features = ["sink"] }
tokio = { version = "1.22.0", features = ["time"] }
tokio = { version = "1.22.0", features = ["time", "macros"] }
tokio-stream = { version = "0.1.11", features = ["net"] }
tokio-util = { version = "0.7.4", features = ["rt"] }
serde = { version = "1.0.151", features = ["derive"] }
@ -38,6 +38,7 @@ axum = { version = "0.6.1", optional = true, features = ["ws"] }
# salvo
salvo = { version = "0.37.7", optional = true, features = ["ws"] }
once_cell = "1.17.1"
# actix is ... complicated?
# actix-files = { version = "0.6.2", optional = true }

View file

@ -36,7 +36,51 @@ pub enum LiveViewError {
SendingFailed,
}
use dioxus_interpreter_js::INTERPRETER_JS;
use once_cell::sync::Lazy;
static INTERPRETER_JS: Lazy<String> = Lazy::new(|| {
let interpreter = dioxus_interpreter_js::INTERPRETER_JS;
let serialize_file_uploads = r#"if (
target.tagName === "INPUT" &&
(event.type === "change" || event.type === "input")
) {
const type = target.getAttribute("type");
if (type === "file") {
async function read_files() {
const files = target.files;
const file_contents = {};
for (let i = 0; i < files.length; i++) {
const file = files[i];
file_contents[file.name] = Array.from(
new Uint8Array(await file.arrayBuffer())
);
}
let file_engine = {
files: file_contents,
};
contents.files = file_engine;
if (realId === null) {
return;
}
const message = serializeIpcMessage("user_event", {
name: name,
element: parseInt(realId),
data: contents,
bubbles,
});
window.ipc.postMessage(message);
}
read_files();
return;
}
}"#;
interpreter.replace("/*POST_EVENT_SERIALIZATION*/", serialize_file_uploads)
});
static MAIN_JS: &str = include_str!("./main.js");
/// This script that gets injected into your app connects this page to the websocket endpoint
@ -44,11 +88,12 @@ static MAIN_JS: &str = include_str!("./main.js");
/// Once the endpoint is connected, it will send the initial state of the app, and then start
/// processing user events and returning edits to the liveview instance
pub fn interpreter_glue(url: &str) -> String {
let js = &*INTERPRETER_JS;
format!(
r#"
<script>
var WS_ADDR = "{url}";
{INTERPRETER_JS}
{js}
{MAIN_JS}
main();
</script>

View file

@ -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"

View file

@ -98,7 +98,8 @@ pub fn render<R: Driver>(
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();

View file

@ -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!(()))
}
}

View file

@ -25,3 +25,6 @@ convert_case = "0.5.0"
# default = ["html"]
# eventually more output options
[dev-dependencies]
pretty_assertions = "1.2.1"

View file

@ -0,0 +1,68 @@
use html_parser::Dom;
#[test]
fn simple_elements() {
let html = r#"
<div>
<div class="asd">hello world!</div>
<div id="asd">hello world!</div>
<div id="asd">hello world!</div>
<div for="asd">hello world!</div>
<div async="asd">hello world!</div>
<div LargeThing="asd">hello world!</div>
<ai-is-awesome>hello world!</ai-is-awesome>
</div>
"#
.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#"
<div>
<div class="asd">
<div class="asd">
<div class="asd">
<div class="asd">
</div>
</div>
</div>
</div>
</div>
"#
.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);
}

View file

@ -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]

View file

@ -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<dyn Any> {
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<FormData> {
// try to fill in form values
if let Some(form) = target.dyn_ref::<web_sys::HtmlFormElement>() {
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<String> = 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::<web_sys::HtmlElement>().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::<Array>() {
if let Some(name) = array.get(0).as_string() {
if let Ok(item_values) = array.get(1).dyn_into::<Array>() {
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<dyn FileEngine>)
})
});
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()

View file

@ -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<Self> {
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<File> {
self.file_list.item(index as u32)
}
fn find(&self, name: &str) -> Option<File> {
(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<String> {
(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<Vec<u8>> {
let file = self.find(file)?;
let file_reader = self.file_reader.clone();
let (rx, tx) = oneshot::channel();
let on_load: Closure<dyn FnMut()> = 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<String> {
let file = self.find(file)?;
let file_reader = self.file_reader.clone();
let (rx, tx) = oneshot::channel();
let on_load: Closure<dyn FnMut()> = 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
}
}
}

View file

@ -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<T: 'static>(root: fn(Scope<T>) -> 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.