From b322f4630392de70ccb513e4e0a2d74ff4b3f106 Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Sat, 13 Oct 2018 01:21:14 +0100 Subject: [PATCH] Adding in TODO MVC example using web-sys --- Cargo.toml | 1 + examples/todomvc/.gitignore | 3 + examples/todomvc/Cargo.toml | 41 ++ examples/todomvc/README.md | 17 + examples/todomvc/build.sh | 15 + examples/todomvc/index.css | 379 +++++++++++++++++ examples/todomvc/index.html | 43 ++ examples/todomvc/index.js | 3 + examples/todomvc/package.json | 13 + examples/todomvc/src/build.rs | 5 + examples/todomvc/src/controller.rs | 196 +++++++++ examples/todomvc/src/element.rs | 208 ++++++++++ examples/todomvc/src/lib.rs | 70 ++++ examples/todomvc/src/scheduler.rs | 138 +++++++ examples/todomvc/src/store.rs | 285 +++++++++++++ examples/todomvc/src/template.rs | 58 +++ examples/todomvc/src/view.rs | 478 ++++++++++++++++++++++ examples/todomvc/templates/itemsLeft.html | 1 + examples/todomvc/templates/row.html | 7 + examples/todomvc/webpack.config.js | 10 + guide/src/SUMMARY.md | 1 + guide/src/examples/todomvc.md | 23 ++ 22 files changed, 1995 insertions(+) create mode 100644 examples/todomvc/.gitignore create mode 100644 examples/todomvc/Cargo.toml create mode 100644 examples/todomvc/README.md create mode 100755 examples/todomvc/build.sh create mode 100644 examples/todomvc/index.css create mode 100644 examples/todomvc/index.html create mode 100644 examples/todomvc/index.js create mode 100644 examples/todomvc/package.json create mode 100644 examples/todomvc/src/build.rs create mode 100644 examples/todomvc/src/controller.rs create mode 100644 examples/todomvc/src/element.rs create mode 100644 examples/todomvc/src/lib.rs create mode 100644 examples/todomvc/src/scheduler.rs create mode 100644 examples/todomvc/src/store.rs create mode 100644 examples/todomvc/src/template.rs create mode 100644 examples/todomvc/src/view.rs create mode 100644 examples/todomvc/templates/itemsLeft.html create mode 100644 examples/todomvc/templates/row.html create mode 100644 examples/todomvc/webpack.config.js create mode 100644 guide/src/examples/todomvc.md diff --git a/Cargo.toml b/Cargo.toml index 7947a6a0f..d2609b4a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,6 +73,7 @@ members = [ "examples/paint", "examples/performance", "examples/raytrace-parallel", + "examples/todomvc", "examples/wasm-in-wasm", "examples/wasm2js", "examples/webaudio", diff --git a/examples/todomvc/.gitignore b/examples/todomvc/.gitignore new file mode 100644 index 000000000..6dfa488e2 --- /dev/null +++ b/examples/todomvc/.gitignore @@ -0,0 +1,3 @@ +target/ +todomvc.js +*.swp diff --git a/examples/todomvc/Cargo.toml b/examples/todomvc/Cargo.toml new file mode 100644 index 000000000..8e8bd3bfe --- /dev/null +++ b/examples/todomvc/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "todomvc" +version = "0.1.0" +authors = ["The wasm-bindgen Developers"] + +[lib] +crate-type = ["cdylib"] + +[profile.release] +lto = true +opt-level = 's' + +[build-dependencies] +askama = "0.7.2" + +[dependencies] +js-sys = { path = "../../crates/js-sys" } +wasm-bindgen = { path = "../../" } +askama = "0.7.2" + +[dependencies.web-sys] +path = "../../crates/web-sys" +features = [ + 'console', + 'CssStyleDeclaration', + 'Document', + 'DomStringMap', + 'DomTokenList', + 'Element', + 'Event', + 'EventTarget', + 'HtmlBodyElement', + 'HtmlElement', + 'HtmlInputElement', + 'KeyboardEvent', + 'Location', + 'Node', + 'NodeList', + 'Storage', + 'Window', +] diff --git a/examples/todomvc/README.md b/examples/todomvc/README.md new file mode 100644 index 000000000..1f2015076 --- /dev/null +++ b/examples/todomvc/README.md @@ -0,0 +1,17 @@ +# TODO MVC + +[View documentation for this example online][dox] or [View compiled example +online][compiled] + +[compiled]: https://rustwasm.github.io/wasm-bindgen/exbuild/todomvc/ +[dox]: https://rustwasm.github.io/wasm-bindgen/examples/todomvc.html + +You can build the example locally with: + +``` +$ ./build.sh +``` + +(or running the commands on Windows manually) + +and then visiting http://localhost:8080 in a browser should run the example! diff --git a/examples/todomvc/build.sh b/examples/todomvc/build.sh new file mode 100755 index 000000000..23ba0682e --- /dev/null +++ b/examples/todomvc/build.sh @@ -0,0 +1,15 @@ +#!/bin/sh + +# For more comments about what's going on here, see the `hello_world` example + +set -ex +cd "$(dirname $0)" + +cargo +nightly build --target wasm32-unknown-unknown + +cargo +nightly run --manifest-path ../../crates/cli/Cargo.toml \ + --bin wasm-bindgen -- \ + ../../target/wasm32-unknown-unknown/debug/todomvc.wasm --out-dir . + +npm install +npm run serve diff --git a/examples/todomvc/index.css b/examples/todomvc/index.css new file mode 100644 index 000000000..3ac79f05b --- /dev/null +++ b/examples/todomvc/index.css @@ -0,0 +1,379 @@ +html, +body { + margin: 0; + padding: 0; +} + +button { + margin: 0; + padding: 0; + border: 0; + background: none; + font-size: 100%; + vertical-align: baseline; + font-family: inherit; + font-weight: inherit; + color: inherit; + -webkit-appearance: none; + appearance: none; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif; + line-height: 1.4em; + background: #f5f5f5; + color: #4d4d4d; + min-width: 230px; + max-width: 550px; + margin: 0 auto; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-weight: 300; +} + +:focus { + outline: 0; +} + +.hidden { + display: none; +} + +.todoapp { + background: #fff; + margin: 130px 0 40px 0; + position: relative; + box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), + 0 25px 50px 0 rgba(0, 0, 0, 0.1); +} + +.todoapp input::-webkit-input-placeholder { + font-style: italic; + font-weight: 300; + color: #e6e6e6; +} + +.todoapp input::-moz-placeholder { + font-style: italic; + font-weight: 300; + color: #e6e6e6; +} + +.todoapp input::input-placeholder { + font-style: italic; + font-weight: 300; + color: #e6e6e6; +} + +.todoapp h1 { + position: absolute; + top: -155px; + width: 100%; + font-size: 100px; + font-weight: 100; + text-align: center; + color: rgba(175, 47, 47, 0.15); + -webkit-text-rendering: optimizeLegibility; + -moz-text-rendering: optimizeLegibility; + text-rendering: optimizeLegibility; +} + +.new-todo, +.edit { + position: relative; + margin: 0; + width: 100%; + font-size: 24px; + font-family: inherit; + font-weight: inherit; + line-height: 1.4em; + border: 0; + color: inherit; + padding: 6px; + border: 1px solid #999; + box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2); + box-sizing: border-box; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.new-todo { + padding: 16px 16px 16px 60px; + border: none; + background: rgba(0, 0, 0, 0.003); + box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03); +} + +.main { + position: relative; + z-index: 2; + border-top: 1px solid #e6e6e6; +} + +.toggle-all { + width: 1px; + height: 1px; + border: none; /* Mobile Safari */ + opacity: 0; + position: absolute; + right: 100%; + bottom: 100%; +} + +.toggle-all + label { + width: 60px; + height: 34px; + font-size: 0; + position: absolute; + top: -52px; + left: -13px; + -webkit-transform: rotate(90deg); + transform: rotate(90deg); +} + +.toggle-all + label:before { + content: '❯'; + font-size: 22px; + color: #e6e6e6; + padding: 10px 27px 10px 27px; +} + +.toggle-all:checked + label:before { + color: #737373; +} + +.todo-list { + margin: 0; + padding: 0; + list-style: none; +} + +.todo-list li { + position: relative; + font-size: 24px; + border-bottom: 1px solid #ededed; +} + +.todo-list li:last-child { + border-bottom: none; +} + +.todo-list li.editing { + border-bottom: none; + padding: 0; +} + +.todo-list li.editing .edit { + display: block; + width: 506px; + padding: 12px 16px; + margin: 0 0 0 43px; +} + +.todo-list li.editing .view { + display: none; +} + +.todo-list li .toggle { + text-align: center; + width: 40px; + /* auto, since non-WebKit browsers doesn't support input styling */ + height: auto; + position: absolute; + top: 0; + bottom: 0; + margin: auto 0; + border: none; /* Mobile Safari */ + -webkit-appearance: none; + appearance: none; +} + +.todo-list li .toggle { + opacity: 0; +} + +.todo-list li .toggle + label { + /* + Firefox requires `#` to be escaped - https://bugzilla.mozilla.org/show_bug.cgi?id=922433 + IE and Edge requires *everything* to be escaped to render, so we do that instead of just the `#` - https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/7157459/ + */ + background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23ededed%22%20stroke-width%3D%223%22/%3E%3C/svg%3E'); + background-repeat: no-repeat; + background-position: center left; +} + +.todo-list li .toggle:checked + label { + background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23bddad5%22%20stroke-width%3D%223%22/%3E%3Cpath%20fill%3D%22%235dc2af%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22/%3E%3C/svg%3E'); +} + +.todo-list li label { + word-break: break-all; + padding: 15px 15px 15px 60px; + display: block; + line-height: 1.2; + transition: color 0.4s; +} + +.todo-list li.completed label { + color: #d9d9d9; + text-decoration: line-through; +} + +.todo-list li .destroy { + display: none; + position: absolute; + top: 0; + right: 10px; + bottom: 0; + width: 40px; + height: 40px; + margin: auto 0; + font-size: 30px; + color: #cc9a9a; + margin-bottom: 11px; + transition: color 0.2s ease-out; +} + +.todo-list li .destroy:hover { + color: #af5b5e; +} + +.todo-list li .destroy:after { + content: '×'; +} + +.todo-list li:hover .destroy { + display: block; +} + +.todo-list li .edit { + display: none; +} + +.todo-list li.editing:last-child { + margin-bottom: -1px; +} + +.footer { + color: #777; + padding: 10px 15px; + height: 20px; + text-align: center; + border-top: 1px solid #e6e6e6; +} + +.footer:before { + content: ''; + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 50px; + overflow: hidden; + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2), + 0 8px 0 -3px #f6f6f6, + 0 9px 1px -3px rgba(0, 0, 0, 0.2), + 0 16px 0 -6px #f6f6f6, + 0 17px 2px -6px rgba(0, 0, 0, 0.2); +} + +.todo-count { + float: left; + text-align: left; +} + +.todo-count strong { + font-weight: 300; +} + +.filters { + margin: 0; + padding: 0; + list-style: none; + position: absolute; + right: 0; + left: 0; +} + +.filters li { + display: inline; +} + +.filters li a { + color: inherit; + margin: 3px; + padding: 3px 7px; + text-decoration: none; + border: 1px solid transparent; + border-radius: 3px; +} + +.filters li a:hover { + border-color: rgba(175, 47, 47, 0.1); +} + +.filters li a.selected { + border-color: rgba(175, 47, 47, 0.2); +} + +.clear-completed, +html .clear-completed:active { + float: right; + position: relative; + line-height: 20px; + text-decoration: none; + cursor: pointer; +} + +.clear-completed:hover { + text-decoration: underline; +} + +.info { + margin: 65px auto 0; + color: #bfbfbf; + font-size: 10px; + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); + text-align: center; +} + +.info p { + line-height: 1; +} + +.info a { + color: inherit; + text-decoration: none; + font-weight: 400; +} + +.info a:hover { + text-decoration: underline; +} + +/* + Hack to remove background from Mobile Safari. + Can't use it globally since it destroys checkboxes in Firefox +*/ +@media screen and (-webkit-min-device-pixel-ratio:0) { + .toggle-all, + .todo-list li .toggle { + background: none; + } + + .todo-list li .toggle { + height: 40px; + } +} + +@media (max-width: 430px) { + .footer { + height: 50px; + } + + .filters { + bottom: 10px; + } +} diff --git a/examples/todomvc/index.html b/examples/todomvc/index.html new file mode 100644 index 000000000..36082a2a7 --- /dev/null +++ b/examples/todomvc/index.html @@ -0,0 +1,43 @@ + + + + + web-sys WASM • TodoMVC + + + +
+
+

todos

+ +
+ +
+ + + + + diff --git a/examples/todomvc/index.js b/examples/todomvc/index.js new file mode 100644 index 000000000..cf999191d --- /dev/null +++ b/examples/todomvc/index.js @@ -0,0 +1,3 @@ +import('./todomvc').then(todomvc => { + todomvc.run(); +}); diff --git a/examples/todomvc/package.json b/examples/todomvc/package.json new file mode 100644 index 000000000..806119cdb --- /dev/null +++ b/examples/todomvc/package.json @@ -0,0 +1,13 @@ +{ + "scripts": { + "build": "webpack", + "serve": "webpack-dev-server" + }, + "devDependencies": { + "text-encoding": "^0.7.0", + "html-webpack-plugin": "^3.2.0", + "webpack": "^4.11.1", + "webpack-cli": "^3.1.1", + "webpack-dev-server": "^3.1.0" + } +} diff --git a/examples/todomvc/src/build.rs b/examples/todomvc/src/build.rs new file mode 100644 index 000000000..89e3e6b1a --- /dev/null +++ b/examples/todomvc/src/build.rs @@ -0,0 +1,5 @@ +extern crate askama; + +fn main() { + askama::rerun_if_templates_changed(); +} diff --git a/examples/todomvc/src/controller.rs b/examples/todomvc/src/controller.rs new file mode 100644 index 000000000..0bc307137 --- /dev/null +++ b/examples/todomvc/src/controller.rs @@ -0,0 +1,196 @@ +use crate::exit; +use crate::store::*; +use crate::view::ViewMessage; +use crate::{Message, Scheduler}; +use js_sys::Date; + +use std::cell::RefCell; +use std::rc::Weak; + +/// The controller of the application turns page state into functionality +pub struct Controller { + store: Store, + sched: RefCell>>, + active_route: String, + last_active_route: String, +} + +/// Messages that represent the methods to be called on the Controller +pub enum ControllerMessage { + AddItem(String), + SetPage(String), + EditItemSave(String, String), + ToggleItem(String, bool), + EditItemCancel(String), + RemoveCompleted(), + RemoveItem(String), + ToggleAll(bool), +} + +impl Controller { + pub fn new(store: Store, sched: Weak) -> Controller { + Controller { + store, + sched: RefCell::new(Some(sched)), + active_route: "".into(), + last_active_route: "none".into(), + } + } + + /// Used by `Scheduler` to convert a `ControllerMessage` into a function call on a `Controller` + pub fn call(&mut self, method_name: ControllerMessage) { + use self::ControllerMessage::*; + match method_name { + AddItem(title) => self.add_item(title), + SetPage(hash) => self.set_page(hash), + EditItemSave(id, value) => self.edit_item_save(id, value), + EditItemCancel(id) => self.edit_item_cancel(id), + RemoveCompleted() => self.remove_completed_items(), + RemoveItem(id) => self.remove_item(&id), + ToggleAll(completed) => self.toggle_all(completed), + ToggleItem(id, completed) => self.toggle_item(id, completed), + } + } + + fn toggle_item(&mut self, id: String, completed: bool) { + self.toggle_completed(id, completed); + self._filter(completed); + } + + fn add_message(&self, view_message: ViewMessage) { + if let Ok(sched) = self.sched.try_borrow_mut() { + if let Some(ref sched) = *sched { + if let Some(sched) = sched.upgrade() { + sched.add_message(Message::View(view_message)); + } + } + } + } + + pub fn set_page(&mut self, raw: String) { + let route = raw.trim_left_matches("#/"); + self.active_route = route.to_string(); + self._filter(false); + self.add_message(ViewMessage::UpdateFilterButtons(route.to_string())); + } + + /// Add an Item to the Store and display it in the list. + fn add_item(&mut self, title: String) { + self.store.insert(Item { + id: Date::now().to_string(), + title, + completed: false, + }); + self.add_message(ViewMessage::ClearNewTodo()); + self._filter(true); + } + + /// Save an Item in edit. + fn edit_item_save(&mut self, id: String, title: String) { + if !title.is_empty() { + self.store.update(ItemUpdate::Title { + id: id.clone(), + title: title.clone(), + }); + self.add_message(ViewMessage::EditItemDone(id.to_string(), title.to_string())); + } else { + self.remove_item(&id); + } + } + + /// Cancel the item editing mode. + fn edit_item_cancel(&mut self, id: String) { + let mut message = None; + if let Some(data) = self.store.find(ItemQuery::Id { id: id.clone() }) { + if let Some(todo) = data.get(0) { + let title = todo.title.to_string(); + let citem = id.to_string(); + message = Some(ViewMessage::EditItemDone(citem, title)); + } + } + if let Some(message) = message { + self.add_message(message); + } + } + + /// Remove the data and elements related to an Item. + fn remove_item(&mut self, id: &String) { + self.store.remove(ItemQuery::Id { id: id.clone() }); + self._filter(false); + let ritem = id.to_string(); + self.add_message(ViewMessage::RemoveItem(ritem)); + } + + /// Remove all completed items. + fn remove_completed_items(&mut self) { + self.store.remove(ItemQuery::Completed { completed: true }); + self._filter(true); + } + + /// Update an Item in storage based on the state of completed. + fn toggle_completed(&mut self, id: String, completed: bool) { + self.store.update(ItemUpdate::Completed { + id: id.clone(), + completed, + }); + let tid = id.to_string(); + self.add_message(ViewMessage::SetItemComplete(tid, completed)); + } + + /// Set all items to complete or active. + fn toggle_all(&mut self, completed: bool) { + let mut vals = Vec::new(); + self.store + .find( + ItemQuery::EmptyItemQuery, + ).map(|data| { + for item in data.iter() { + vals.push(item.id.clone()); + } + }); + for id in vals.iter() { + self.toggle_completed(id.to_string(), completed); + } + self._filter(false); + } + + /// Refresh the list based on the current route. + fn _filter(&mut self, force: bool) { + let route = &self.active_route; + + if force || self.last_active_route != "" || &self.last_active_route != route { + let query = match route.as_str() { + "completed" => ItemQuery::Completed { completed: true }, + "active" => ItemQuery::Completed { completed: false }, + _ => ItemQuery::EmptyItemQuery, + }; + let mut v = None; + { + let store = &mut self.store; + if let Some(res) = store.find(query) { + v = Some(res.into()); + } + } + if let Some(res) = v { + self.add_message(ViewMessage::ShowItems(res)); + } + } + + if let Some((total, active, completed)) = self.store.count() { + self.add_message(ViewMessage::SetItemsLeft(active)); + self.add_message(ViewMessage::SetClearCompletedButtonVisibility( + completed > 0, + )); + self.add_message(ViewMessage::SetCompleteAllCheckbox(completed == total)); + self.add_message(ViewMessage::SetMainVisibility(total > 0)); + } + + self.last_active_route = route.to_string(); + } +} + +impl Drop for Controller { + fn drop(&mut self) { + exit("calling drop on Controller"); + } +} diff --git a/examples/todomvc/src/element.rs b/examples/todomvc/src/element.rs new file mode 100644 index 000000000..8d073c8a3 --- /dev/null +++ b/examples/todomvc/src/element.rs @@ -0,0 +1,208 @@ +extern crate wasm_bindgen; +use wasm_bindgen::prelude::*; +use wasm_bindgen::JsCast; + +/// Wrapper for `web_sys::Element` to simplify calling different interfaces +pub struct Element { + el: Option, +} + +impl From for Option { + fn from(obj: Element) -> Option { + if let Some(el) = obj.el { + Some(el.into()) + } else { + None + } + } +} + +impl From for Option { + fn from(obj: Element) -> Option { + if let Some(el) = obj.el { + Some(el.into()) + } else { + None + } + } +} + +impl Element { + pub fn qs(selector: &str) -> Option { + let body: web_sys::Element = web_sys::window()?.document()?.body()?.into(); + let el = body.query_selector(selector).ok()?; + Some(Element { el }) + } + + /// Add event listener to this node + pub fn add_event_listener(&mut self, event_name: &str, handler: T) + where + T: 'static + FnMut(web_sys::Event), + { + let cb = Closure::wrap(Box::new(handler) as Box); + if let Some(el) = self.el.take() { + let el_et: web_sys::EventTarget = el.into(); + el_et.add_event_listener_with_callback(event_name, cb.as_ref().unchecked_ref()); + cb.forget(); + if let Ok(el) = el_et.dyn_into::() { + self.el = Some(el); + } + } + } + + /// Delegate an event to a selector + pub fn delegate( + &mut self, + selector: &'static str, + event: &str, + mut handler: T, + use_capture: bool, + ) where + T: 'static + FnMut(web_sys::Event) -> (), + { + let el = match self.el.take() { + Some(e) => e, + None => return, + }; + if let Some(dyn_el) = &el.dyn_ref::() + { + if let Some(window) = web_sys::window() { + if let Some(document) = window.document() { + // TODO document selector to the target element + let tg_el = document; + + let cb = Closure::wrap(Box::new(move |event: web_sys::Event| { + if let Some(target_element) = event.target() { + let dyn_target_el: Option< + &web_sys::Node, + > = wasm_bindgen::JsCast::dyn_ref(&target_element); + if let Some(target_element) = dyn_target_el { + if let Ok(potential_elements) = + tg_el.query_selector_all(selector) + { + let mut has_match = false; + for i in 0..potential_elements.length() { + if let Some(el) = potential_elements.get(i) { + if target_element.is_equal_node(Some(&el)) { + has_match = true; + } + break; + } + } + + if has_match { + handler(event); + } + } + } + } + }) as Box); + + dyn_el.add_event_listener_with_callback_and_bool( + event, + cb.as_ref().unchecked_ref(), + use_capture, + ); + cb.forget(); // TODO cycle collect + } + } + } + self.el = Some(el); + } + + /// Find child `Element`s from this node + pub fn qs_from(&mut self, selector: &str) -> Option { + let mut found_el = None; + if let Some(el) = self.el.as_ref() { + found_el = Some(Element { + el: el.query_selector(selector).ok()?, + }); + } + found_el + } + + /// Sets the inner HTML of the `self.el` element + pub fn set_inner_html(&mut self, value: String) { + if let Some(el) = self.el.take() { + el.set_inner_html(&value); + self.el = Some(el); + } + } + + /// Sets the text content of the `self.el` element + pub fn set_text_content(&mut self, value: &str) { + if let Some(el) = self.el.as_ref() { + if let Some(node) = &el.dyn_ref::() { + node.set_text_content(Some(&value)); + } + } + } + + /// Removes a class list item from the element + /// + /// ``` + /// e.class_list_remove(String::from("clickable")); + /// // removes the class 'clickable' from e.el + /// ``` + pub fn class_list_remove(&mut self, value: &str) { + if let Some(el) = self.el.take() { + el.class_list().remove_1(&value); + self.el = Some(el); + } + } + + /// Given another `Element` it will remove that child from the DOM from this element + /// Consumes `child` so it can't be used after it's removal. + pub fn remove_child(&mut self, mut child: Element) { + if let Some(child_el) = child.el.take() { + if let Some(el) = self.el.take() { + if let Some(el_node) = el.dyn_ref::() { + let child_node: web_sys::Node = child_el.into(); + el_node.remove_child(&child_node); + } + self.el = Some(el); + } + } + } + + /// Sets the whole class value for `self.el` + pub fn set_class_name(&mut self, class_name: &str) { + if let Some(el) = self.el.take() { + el.set_class_name(&class_name); + self.el = Some(el); + } + } + + /// Sets the visibility for the element in `self.el` + pub fn set_visibility(&mut self, visible: bool) { + if let Some(el) = self.el.take() { + { + let dyn_el: Option<&web_sys::HtmlElement> = wasm_bindgen::JsCast::dyn_ref(&el); + if let Some(el) = dyn_el { + el.set_hidden(!visible); + } + } + self.el = Some(el); + } + } + + /// Sets the visibility for the element in `self.el` (The element must be an input) + pub fn set_value(&mut self, value: &str) { + if let Some(el) = self.el.take() { + if let Some(el) = wasm_bindgen::JsCast::dyn_ref::(&el) { + el.set_value(&value); + } + self.el = Some(el); + } + } + + /// Sets the checked state for the element in `self.el` (The element must be an input) + pub fn set_checked(&mut self, checked: bool) { + if let Some(el) = self.el.take() { + if let Some(el) = wasm_bindgen::JsCast::dyn_ref::(&el) { + el.set_checked(checked); + } + self.el = Some(el); + } + } +} diff --git a/examples/todomvc/src/lib.rs b/examples/todomvc/src/lib.rs new file mode 100644 index 000000000..49e8b4b6c --- /dev/null +++ b/examples/todomvc/src/lib.rs @@ -0,0 +1,70 @@ +//! # TODO MVC +//! +//! A [TODO MVC](https://todomvc.com/) implementation written using [web-sys](https://rustwasm.github.io/wasm-bindgen/web-sys/overview.html) +#![warn(missing_docs)] + +extern crate wasm_bindgen; +use wasm_bindgen::prelude::*; + +extern crate js_sys; +extern crate web_sys; +use std::rc::Rc; + +#[macro_use] +extern crate askama; + +/// Controller of the program +pub mod controller; +/// Element wrapper to the DOM +pub mod element; +/// Schedule messages to the Controller and View +pub mod scheduler; +/// Stores items into localstorage +pub mod store; +/// Handles constructing template strings from data +pub mod template; +/// Presentation layer +pub mod view; +use crate::controller::{Controller, ControllerMessage}; +use crate::scheduler::Scheduler; +use crate::store::Store; +use crate::view::{View, ViewMessage}; + +/// Message wrapper enum used to pass through the scheduler to the Controller or View +pub enum Message { + /// Message wrapper to send to the controller + Controller(ControllerMessage), + /// Message wrapper to send to the view + View(ViewMessage), +} + +/// Used for debugging to the console +pub fn exit(message: &str) { + let v = wasm_bindgen::JsValue::from_str(&message.to_string()); + web_sys::console::exception_1(&v); + std::process::abort(); +} + +fn app(name: &str) { + let sched = Rc::new(Scheduler::new()); + let store = match Store::new(name) { + Some(s) => s, + None => return, + }; + let controller = Controller::new(store, Rc::downgrade(&sched)); + if let Some(mut view) = View::new(sched.clone()) { + let sch: &Rc = &sched; + view.init(); + sch.set_view(view); + sch.set_controller(controller); + sched.add_message(Message::Controller(ControllerMessage::SetPage( + "".to_string(), + ))); + } +} + +/// Entry point into the program from JavaScript +#[wasm_bindgen] +pub fn run() { + app("todos-wasmbindgen"); +} diff --git a/examples/todomvc/src/scheduler.rs b/examples/todomvc/src/scheduler.rs new file mode 100644 index 000000000..23f1eb919 --- /dev/null +++ b/examples/todomvc/src/scheduler.rs @@ -0,0 +1,138 @@ +use crate::controller::Controller; +use crate::exit; +use crate::view::View; +use crate::Message; +use std::cell::RefCell; +use std::rc::Rc; + +/// Creates an event loop that starts each time a message is added +pub struct Scheduler { + controller: Rc>>, + view: Rc>>, + events: RefCell>, + running: RefCell, +} + +impl Scheduler { + /// Construct a new `Scheduler` + pub fn new() -> Scheduler { + Scheduler { + controller: Rc::new(RefCell::new(None)), + view: Rc::new(RefCell::new(None)), + events: RefCell::new(Vec::new()), + running: RefCell::new(false), + } + } + + pub fn set_controller(&self, controller: Controller) { + if let Ok(mut controller_data) = self.controller.try_borrow_mut() { + controller_data.replace(controller); + } else { + exit("This might be a deadlock"); + } + } + + pub fn set_view(&self, view: View) { + if let Ok(mut view_data) = self.view.try_borrow_mut() { + view_data.replace(view); + } else { + exit("This might be a deadlock"); + } + } + + /// Add a new message onto the event stack + /// + /// Triggers running the event loop if it's not already running + pub fn add_message(&self, message: Message) { + let running = { + if let Ok(running) = self.running.try_borrow() { + running.clone() + } else { + exit("This might be a deadlock"); + false + } + }; + { + if let Ok(mut events) = self.events.try_borrow_mut() { + events.push(message); + } else { + exit("This might be a deadlock"); + } + } + if !running { + self.run(); + } + } + + /// Start the event loop, taking messages from the stack to run + fn run(&self) { + let mut events_len = 0; + { + if let Ok(events) = self.events.try_borrow() { + events_len = events.len().clone(); + } else { + exit("This might be a deadlock"); + } + } + if events_len == 0 { + if let Ok(mut running) = self.running.try_borrow_mut() { + *running = false; + } else { + exit("This might be a deadlock"); + } + } else { + { + if let Ok(mut running) = self.running.try_borrow_mut() { + *running = true; + } else { + exit("This might be a deadlock"); + } + } + self.next_message(); + } + } + + fn next_message(&self) { + let event = { + if let Ok(mut events) = self.events.try_borrow_mut() { + Some(events.pop()) + } else { + exit("This might be a deadlock"); + None + } + }; + if let Some(Some(event)) = event { + match event { + Message::Controller(e) => { + if let Ok(mut controller) = self.controller.try_borrow_mut() { + if let Some(ref mut ag) = *controller { + ag.call(e); + } + } else { + exit("This might be a deadlock"); + } + } + Message::View(e) => { + if let Ok(mut view) = self.view.try_borrow_mut() { + if let Some(ref mut ag) = *view { + ag.call(e); + } + } else { + exit("This might be a deadlock"); + } + } + } + self.run(); + } else if let Ok(mut running) = self.running.try_borrow_mut() { + *running = false; + } else { + exit("This might be a deadlock"); + } + } +} + +impl Drop for Scheduler { + fn drop(&mut self) { + exit("calling drop on Scheduler"); + } +} diff --git a/examples/todomvc/src/store.rs b/examples/todomvc/src/store.rs new file mode 100644 index 000000000..6e29e68e6 --- /dev/null +++ b/examples/todomvc/src/store.rs @@ -0,0 +1,285 @@ +use js_sys::JSON; +use wasm_bindgen::prelude::*; +use crate::exit; + +/// Stores items into localstorage +pub struct Store { + local_storage: web_sys::Storage, + data: ItemList, + name: String, +} + +impl Store { + /// Creates a new store with `name` as the localstorage value name + pub fn new(name: &str) -> Option { + let window = web_sys::window()?; + if let Ok(Some(local_storage)) = window.local_storage() { + let mut store = Store { + local_storage, + data: ItemList::new(), + name: String::from(name), + }; + store.fetch_local_storage(); + Some(store) + } else { + None + } + } + + /// Read the local ItemList from localStorage. + /// Returns an &Option of the stored database + /// Caches the store into `self.data` to reduce calls to JS + /// + /// Uses mut here as the return is something we might want to manipulate + /// + fn fetch_local_storage(&mut self) -> Option<()> { + let mut item_list = ItemList::new(); + // If we have an existing cached value, return early. + if let Ok(Some(value)) = self.local_storage.get_item(&self.name) { + let data = JSON::parse(&value).ok()?; + let iter = js_sys::try_iter(&data).ok()??; + for item in iter { + let item = item.ok()?; + let item_array: &js_sys::Array = wasm_bindgen::JsCast::dyn_ref(&item)?; + let title = item_array.shift().as_string()?; + let completed = item_array.shift().as_bool()?; + let id = item_array.shift().as_string()?; + let mut temp_item = Item { + title, + completed, + id, + }; + item_list.push(temp_item); + } + } + self.data = item_list; + Some(()) + } + + /// Write the local ItemList to localStorage. + fn sync_local_storage(&mut self) { + let array = js_sys::Array::new(); + for item in self.data.iter() { + let mut child = js_sys::Array::new(); + let s = item.title.clone(); + child.push(&JsValue::from(&s)); + child.push(&JsValue::from(item.completed)); + child.push(&JsValue::from(item.id.to_string())); + + array.push(&JsValue::from(child)); + } + if let Ok(storage_string) = JSON::stringify(&JsValue::from(array)) { + let storage_string: String = storage_string.to_string().into(); + self.local_storage + .set_item(&self.name, storage_string.as_str()); + } + } + + /// Find items with properties matching those on query. + /// `ItemQuery` query Query to match + /// + /// ``` + /// let data = db.find(ItemQuery::Completed {completed: true}); + /// // data will contain items whose completed properties are true + /// ``` + pub fn find(&mut self, query: ItemQuery) -> Option { + Some(self.data.iter().filter(|todo| query.matches(*todo)).collect()) + } + + /// Update an item in the Store. + /// + /// `ItemUpdate` update Record with an id and a property to update + pub fn update(&mut self, update: ItemUpdate) { + let id = update.id(); + self.data.iter_mut().for_each(|todo| { + if id == todo.id { + todo.update(&update); + } + }); + self.sync_local_storage(); + } + + /// Insert an item into the Store. + /// + /// `Item` item Item to insert + pub fn insert(&mut self, item: Item) { + self.data.push(item); + self.sync_local_storage(); + } + + /// Remove items from the Store based on a query. + /// query is an `ItemQuery` query Query matching the items to remove + pub fn remove(&mut self, query: ItemQuery) { + self.data.retain(|todo| !query.matches(todo)); + self.sync_local_storage(); + } + + /// Count total, active, and completed todos. + pub fn count(&mut self) -> Option<(usize, usize, usize)> { + self.find(ItemQuery::EmptyItemQuery).map(|data| { + let total = data.length(); + + let mut completed = 0; + for item in data.iter() { + if item.completed { + completed += 1; + } + } + (total, total - completed, completed) + }) + } +} + +/// Represents a todo item +pub struct Item { + pub id: String, + pub title: String, + pub completed: bool, +} + +impl Item { + pub fn update(&mut self, update: &ItemUpdate) { + match update { + ItemUpdate::Title { title, .. } => { + self.title = title.to_string(); + } + ItemUpdate::Completed { completed, .. } => { + self.completed = *completed; + } + } + } +} + +pub trait ItemListTrait { + fn new() -> Self; + fn get(&self, i: usize) -> Option<&T>; + fn length(&self) -> usize; + fn push(&mut self, item: T); + fn iter(&self) -> std::slice::Iter; +} + +pub struct ItemList { + list: Vec, +} +impl ItemList { + fn into_iter(self) -> std::vec::IntoIter { + self.list.into_iter() + } + fn retain(&mut self, f: F) + where + F: FnMut(&Item) -> bool { + self.list.retain(f); + } + fn iter_mut(&mut self) -> std::slice::IterMut { + self.list.iter_mut() + } +} +impl ItemListTrait for ItemList { + fn new() -> ItemList { + ItemList { list: Vec::new() } + } + fn get(&self, i: usize) -> Option<&Item> { + self.list.get(i) + } + fn length(&self) -> usize { + self.list.len() + } + fn push(&mut self, item: Item) { + self.list.push(item) + } + fn iter(&self) -> std::slice::Iter { + self.list.iter() + } +} +use std::iter::FromIterator; +impl<'a> FromIterator for ItemList { + fn from_iter>(iter: I) -> Self { + let mut c = ItemList::new(); + for i in iter { + c.push(i); + } + + c + } +} + +/// A borrowed set of Items filtered from the store +pub struct ItemListSlice<'a> { + list: Vec<&'a Item>, +} + +impl<'a> ItemListTrait<&'a Item> for ItemListSlice<'a> { + fn new() -> ItemListSlice<'a> { + ItemListSlice { list: Vec::new() } + } + fn get(&self, i: usize) -> Option<&&'a Item> { + self.list.get(i) + } + fn length(&self) -> usize { + self.list.len() + } + fn push(&mut self, item: &'a Item) { + self.list.push(item) + } + fn iter(&self) -> std::slice::Iter<&'a Item> { + self.list.iter() + } +} + +impl<'a> FromIterator<&'a Item> for ItemListSlice<'a> { + fn from_iter>(iter: I) -> Self { + let mut c = ItemListSlice::new(); + for i in iter { + c.push(i); + } + c + } +} + +impl<'a> Into for ItemListSlice<'a> { + fn into(self) -> ItemList { + let mut i = ItemList::new(); + let items = self.list.into_iter(); + for j in items { + // TODO neaten this cloning? + let item = Item { + id: j.id.clone(), + completed: j.completed, + title: j.title.clone(), + }; + i.push(item); + } + i + } +} + +/// Represents a search into the store +pub enum ItemQuery { + Id { id: String }, + Completed { completed: bool }, + EmptyItemQuery, +} + +impl ItemQuery { + fn matches(&self, item: &Item) -> bool { + match *self { + ItemQuery::EmptyItemQuery => true, + ItemQuery::Id { ref id } => &item.id == id, + ItemQuery::Completed { completed } => item.completed == completed, + } + } +} + +pub enum ItemUpdate { + Title { id: String, title: String }, + Completed { id: String, completed: bool }, +} + +impl ItemUpdate { + fn id(&self) -> String { + match self { + ItemUpdate::Title { id, .. } => id.clone(), + ItemUpdate::Completed { id, .. } => id.clone(), + } + } +} diff --git a/examples/todomvc/src/template.rs b/examples/todomvc/src/template.rs new file mode 100644 index 000000000..3ed9f1795 --- /dev/null +++ b/examples/todomvc/src/template.rs @@ -0,0 +1,58 @@ +use store::{ItemList, ItemListTrait, Item}; +use askama::Template as AskamaTemplate; + +#[derive(AskamaTemplate)] +#[template(path = "row.html")] +struct RowTemplate<'a> { + id: &'a str, + title: &'a str, + completed: bool, +} + + +#[derive(AskamaTemplate)] +#[template(path = "itemsLeft.html")] +struct ItemsLeftTemplate { + active_todos: usize, +} + +pub struct Template {} + +impl Template { + /// Format the contents of a todo list. + /// + /// items `ItemList` contains keys you want to find in the template to replace. + /// Returns the contents for a todo list + /// + pub fn item_list(items: ItemList) -> String { + let mut output = String::from(""); + for item in items.iter() { + let row = RowTemplate { + id: &item.id, + completed: item.completed, + title: &item.title, + }; + if let Ok(res) = row.render() { + output.push_str(&res); + } + } + output + } + + /// + /// Format the contents of an "items left" indicator. + /// + /// `active_todos` Number of active todos + /// + /// Returns the contents for an "items left" indicator + pub fn item_counter(active_todos: usize) -> String { + let items_left = ItemsLeftTemplate { + active_todos + }; + if let Ok(res) = items_left.render() { + res + } else { + String::new() + } + } +} diff --git a/examples/todomvc/src/view.rs b/examples/todomvc/src/view.rs new file mode 100644 index 000000000..5482307b4 --- /dev/null +++ b/examples/todomvc/src/view.rs @@ -0,0 +1,478 @@ +use crate::controller::ControllerMessage; +use crate::exit; +use crate::element::Element; +use crate::store::ItemList; +use crate::{Message, Scheduler}; +use std::cell::RefCell; +use std::rc::Rc; +use wasm_bindgen::JsCast; + +use crate::template::Template; + +const ENTER_KEY: u32 = 13; +const ESCAPE_KEY: u32 = 27; + +extern crate wasm_bindgen; +use wasm_bindgen::prelude::*; + +/// Messages that represent the methods to be called on the View +pub enum ViewMessage { + UpdateFilterButtons(String), + ClearNewTodo(), + ShowItems(ItemList), + SetItemsLeft(usize), + SetClearCompletedButtonVisibility(bool), + SetCompleteAllCheckbox(bool), + SetMainVisibility(bool), + RemoveItem(String), + EditItemDone(String, String), + SetItemComplete(String, bool), +} + +fn item_id(element: &web_sys::EventTarget) -> Option { + //TODO ugly reformat + let dyn_el: Option<&web_sys::Node> = wasm_bindgen::JsCast::dyn_ref(element); + if let Some(element_node) = dyn_el { + element_node.parent_node().map(|parent| { + let mut res = None; + if let Some(e) = wasm_bindgen::JsCast::dyn_ref::(&parent) { + if e.dataset().get("id") != "" { + res = Some(e.dataset().get("id")) + } + }; + if None == res { + if let Some(ep) = parent.parent_node() { + if let Some(dyn_el) = wasm_bindgen::JsCast::dyn_ref::(&ep) + { + res = Some(dyn_el.dataset().get("id")); + } + } + } + res.unwrap() + }) + } else { + None + } +} + +/// Presentation layer +#[wasm_bindgen] +pub struct View { + sched: RefCell>, + todo_list: Element, + todo_item_counter: Element, + clear_completed: Element, + main: Element, + toggle_all: Element, + new_todo: Element, + callbacks: Vec<(web_sys::EventTarget, String, Closure)>, +} + +impl View { + /// Construct a new view + pub fn new(sched: Rc) -> Option { + let todo_list = Element::qs(".todo-list")?; + let todo_item_counter = Element::qs(".todo-count")?; + let clear_completed = Element::qs(".clear-completed")?; + let main = Element::qs(".main")?; + let toggle_all = Element::qs(".toggle-all")?; + let new_todo = Element::qs(".new-todo")?; + Some(View { + sched: RefCell::new(sched), + todo_list, + todo_item_counter, + clear_completed, + main, + toggle_all, + new_todo, + callbacks: Vec::new(), + }) + } + + pub fn init(&mut self) { + let window = match web_sys::window() { + Some(w) => w, + None => return, + }; + let document = match window.document() { + Some(d) => d, + None => return, + }; + let sched = self.sched.clone(); + let set_page = Closure::wrap(Box::new(move || { + if let Some(location) = document.location() { + if let Ok(hash) = location.hash() { + if let Ok(sched) = &(sched.try_borrow_mut()) { + sched.add_message(Message::Controller(ControllerMessage::SetPage( + hash, + ))); + } + } + } + }) as Box); + + let window_et: web_sys::EventTarget = window.into(); + window_et.add_event_listener_with_callback( + "hashchange", + set_page.as_ref().unchecked_ref(), + ); + set_page.forget(); // Cycle collect this + //self.callbacks.push((window_et, "hashchange".to_string(), set_page)); + self.bind_add_item(); + self.bind_edit_item_save(); + self.bind_edit_item_cancel(); + self.bind_remove_item(); + self.bind_toggle_item(); + self.bind_edit_item(); + self.bind_remove_completed(); + self.bind_toggle_all(); + } + + fn bind_edit_item(&mut self) { + self.todo_list.delegate( + "li label", + "dblclick", + |e: web_sys::Event| { + if let Some(target) = e.target() { + if let Ok(el) = wasm_bindgen::JsCast::dyn_into::(target) { + View::edit_item(el); + } + } + }, + false, + ); + } + + /// Put an item into edit mode. + fn edit_item(target: web_sys::Element) { + let target_node: web_sys::Node = target.into(); + if let Some(parent_element) = target_node.parent_element() { + let parent_node: web_sys::Node = parent_element.into(); + if let Some(list_item) = parent_node.parent_element() { + list_item.class_list().add_1("editing"); + if let Some(input) = create_element("input") { + input.set_class_name("edit"); + let list_item_node: web_sys::Node = list_item.into(); + list_item_node.append_child(&input.into()); + } + } + } + if let Some(el) = wasm_bindgen::JsCast::dyn_ref::(&target_node) { + if let Some(input_el) = + wasm_bindgen::JsCast::dyn_ref::(&target_node) + { + input_el.set_value(&el.inner_text()); + } + el.focus(); + } + } + + /// Used by scheduler to convert a `ViewMessage` into a function call on the `View` + pub fn call(&mut self, method_name: ViewMessage) { + use self::ViewMessage::*; + match method_name { + UpdateFilterButtons(route) => self.update_filter_buttons(&route), + ClearNewTodo() => self.clear_new_todo(), + ShowItems(item_list) => self.show_items(item_list), + SetItemsLeft(count) => self.set_items_left(count), + SetClearCompletedButtonVisibility(visible) => { + self.set_clear_completed_button_visibility(visible) + } + SetCompleteAllCheckbox(complete) => self.set_complete_all_checkbox(complete), + SetMainVisibility(complete) => self.set_main_visibility(complete), + RemoveItem(id) => self.remove_item(&id), + EditItemDone(id, title) => self.edit_item_done(&id, &title), + SetItemComplete(id, completed) => self.set_item_complete(&id, completed), + } + } + + /// Populate the todo list with a list of items. + fn show_items(&mut self, items: ItemList) { + self.todo_list.set_inner_html(Template::item_list(items)); + } + + /// Gets the selector to find a todo item in the DOM + fn get_selector_string(id: &str) -> String { + let mut selector = String::from("[data-id=\""); + selector.push_str(id); + selector.push_str("\"]"); + selector + } + + /// Remove an item from the view. + fn remove_item(&mut self, id: &str) { + let elem = Element::qs(&View::get_selector_string(id)); + + if let Some(elem) = elem { + self.todo_list.remove_child(elem); + } + } + + /// Set the number in the 'items left' display. + fn set_items_left(&mut self, items_left: usize) { + // TODO what is items left? + self.todo_item_counter + .set_inner_html(Template::item_counter(items_left)); + } + + /// Set the visibility of the "Clear completed" button. + fn set_clear_completed_button_visibility(&mut self, visible: bool) { + self.clear_completed.set_visibility(visible); + } + + /// Set the visibility of the main content and footer. + fn set_main_visibility(&mut self, visible: bool) { + self.main.set_visibility(visible); + } + + /// Set the checked state of the Complete All checkbox. + fn set_complete_all_checkbox(&mut self, checked: bool) { + self.toggle_all.set_checked(checked); + } + + /// Change the appearance of the filter buttons based on the route. + fn update_filter_buttons(&self, route: &str) { + if let Some(mut el) = Element::qs(".filters .selected") { + el.set_class_name(""); + } + + let mut selector = String::from(".filters [href=\""); + selector.push_str(route); + selector.push_str("\"]"); + + if let Some(mut el) = Element::qs(&selector) { + el.set_class_name("selected"); + } + } + + /// Clear the new todo input + fn clear_new_todo(&mut self) { + self.new_todo.set_value(""); + } + + /// Render an item as either completed or not. + fn set_item_complete(&self, id: &str, completed: bool) { + if let Some(mut list_item) = Element::qs(&View::get_selector_string(id)) { + let class_name = if completed { "completed" } else { "" }; + list_item.set_class_name(class_name); + + // In case it was toggled from an event and not by clicking the checkbox + if let Some(mut el) = list_item.qs_from("input") { + el.set_checked(completed); + } + } + } + + /// Bring an item out of edit mode. + fn edit_item_done(&self, id: &str, title: &str) { + if let Some(mut list_item) = Element::qs(&View::get_selector_string(id)) { + if let Some(input) = list_item.qs_from("input.edit") { + list_item.class_list_remove("editing"); + + if let Some(mut list_item_label) = list_item.qs_from("label") { + list_item_label.set_text_content(title); + } + + list_item.remove_child(input); + } + } + } + + fn bind_add_item(&mut self) { + let sched = self.sched.clone(); + let cb = move |event: web_sys::Event| { + if let Some(target) = event.target() { + if let Some(input_el) = + wasm_bindgen::JsCast::dyn_ref::(&target) + { + let v = input_el.value(); // TODO remove with nll + let title = v.trim(); + if title != "" { + if let Ok(sched) = &(sched.try_borrow_mut()) { + sched.add_message(Message::Controller(ControllerMessage::AddItem( + String::from(title), + ))); + } + } + } + } + }; + self.new_todo.add_event_listener("change", cb); + } + + fn bind_remove_completed(&mut self) { + let sched = self.sched.clone(); + let handler = move |_| { + if let Ok(sched) = &(sched.try_borrow_mut()) { + sched.add_message(Message::Controller(ControllerMessage::RemoveCompleted())); + } + }; + self.clear_completed.add_event_listener("click", handler); + } + + fn bind_toggle_all(&mut self) { + let sched = self.sched.clone(); + self.toggle_all + .add_event_listener("click", move |event: web_sys::Event| { + if let Some(target) = event.target() { + if let Some(input_el) = + wasm_bindgen::JsCast::dyn_ref::(&target) + { + if let Ok(sched) = &(sched.try_borrow_mut()) { + sched.add_message(Message::Controller(ControllerMessage::ToggleAll( + input_el.checked(), + ))); + } + } + } + }); + } + + fn bind_remove_item(&mut self) { + let sched = self.sched.clone(); + self.todo_list.delegate( + ".destroy", + "click", + move |e: web_sys::Event| { + if let Some(target) = e.target() { + if let Some(item_id) = item_id(&target) { + if let Ok(sched) = &(sched.try_borrow_mut()) { + sched.add_message(Message::Controller(ControllerMessage::RemoveItem( + item_id, + ))); + } + } + } + }, + false, + ); + } + + fn bind_toggle_item(&mut self) { + let sched = self.sched.clone(); + self.todo_list.delegate( + ".toggle", + "click", + move |e: web_sys::Event| { + if let Some(target) = e.target() { + if let Some(input_el) = + wasm_bindgen::JsCast::dyn_ref::(&target) + { + if let Some(item_id) = item_id(&target) { + if let Ok(sched) = &(sched.try_borrow_mut()) { + sched.add_message(Message::Controller( + ControllerMessage::ToggleItem(item_id, input_el.checked()), + )); + } + } + } + } + }, + false, + ); + } + + fn bind_edit_item_save(&mut self) { + let sched = self.sched.clone(); + + self.todo_list.delegate( + "li .edit", + "blur", + move |e: web_sys::Event| { + if let Some(target) = e.target() { + if let Some(target_el) = + wasm_bindgen::JsCast::dyn_ref::(&target) + { + if target_el.dataset().get("iscanceled") != "true" { + if let Some(input_el) = + wasm_bindgen::JsCast::dyn_ref::(&target) + { + if let Some(item) = item_id(&target) { + // TODO refactor back into fn + // Was: &self.add_message(ControllerMessage::SetPage(hash)); + if let Ok(sched) = &(sched.try_borrow_mut()) { + sched.add_message(Message::Controller( + ControllerMessage::EditItemSave(item, input_el.value()), + )); + } + + // TODO refactor back into fn + } + } + } + } + } + }, + true, + ); + + // Remove the cursor from the input when you hit enter just like if it were a real form + self.todo_list.delegate( + "li .edit", + "keypress", + |e: web_sys::Event| { + if let Some(key_e) = wasm_bindgen::JsCast::dyn_ref::(&e) { + if key_e.key_code() == ENTER_KEY { + if let Some(target) = e.target() { + if let Some(el) = + wasm_bindgen::JsCast::dyn_ref::(&target) + { + el.blur(); + } + } + } + } + }, + false, + ); + } + + fn bind_edit_item_cancel(&mut self) { + let sched = self.sched.clone(); + self.todo_list.delegate( + "li .edit", + "keyup", + move |e: web_sys::Event| { + if let Some(key_e) = wasm_bindgen::JsCast::dyn_ref::(&e) { + if key_e.key_code() == ESCAPE_KEY { + if let Some(target) = e.target() { + if let Some(el) = + wasm_bindgen::JsCast::dyn_ref::(&target) + { + el.dataset().set("iscanceled", "true"); + el.blur(); + } + + if let Some(item_id) = item_id(&target) { + if let Ok(sched) = &(sched.try_borrow_mut()) { + sched.add_message(Message::Controller( + ControllerMessage::EditItemCancel(item_id), + )); + } + } + } + } + } + }, + false, + ); + } +} + +fn create_element(tag: &str) -> Option { + web_sys::window()?.document()?.create_element(tag).ok() +} + +impl Drop for View { + fn drop(&mut self) { + exit("calling drop on view"); + let callbacks: Vec<(web_sys::EventTarget, String, Closure)> = + self.callbacks.drain(..).collect(); + for callback in callbacks { + callback.0.remove_event_listener_with_callback( + callback.1.as_str(), + &callback.2.as_ref().unchecked_ref(), + ); + } + } +} diff --git a/examples/todomvc/templates/itemsLeft.html b/examples/todomvc/templates/itemsLeft.html new file mode 100644 index 000000000..1ed165f82 --- /dev/null +++ b/examples/todomvc/templates/itemsLeft.html @@ -0,0 +1 @@ +{{ active_todos }} item{% if active_todos > 1 %}s{% endif %} left diff --git a/examples/todomvc/templates/row.html b/examples/todomvc/templates/row.html new file mode 100644 index 000000000..924dc37a3 --- /dev/null +++ b/examples/todomvc/templates/row.html @@ -0,0 +1,7 @@ +
  • +
    + + + +
    +
  • diff --git a/examples/todomvc/webpack.config.js b/examples/todomvc/webpack.config.js new file mode 100644 index 000000000..dce271493 --- /dev/null +++ b/examples/todomvc/webpack.config.js @@ -0,0 +1,10 @@ +const path = require('path'); + +module.exports = { + entry: './index.js', + output: { + path: path.resolve(__dirname, 'dist'), + filename: 'index.js', + }, + mode: 'development' +}; diff --git a/guide/src/SUMMARY.md b/guide/src/SUMMARY.md index a119dc336..1faa7077f 100644 --- a/guide/src/SUMMARY.md +++ b/guide/src/SUMMARY.md @@ -27,6 +27,7 @@ - [web-sys: WebGL](./examples/webgl.md) - [web-sys: A Simple Paint Program](./examples/paint.md) - [Parallel Raytracing](./examples/raytrace.md) + - [web-sys: A TODO MVC App](./examples/todomvc.md) - [Reference](./reference/index.md) - [Passing Rust Closures to JS](./reference/passing-rust-closures-to-js.md) - [Receiving JS Closures in Rust](./reference/receiving-js-closures-in-rust.md) diff --git a/guide/src/examples/todomvc.md b/guide/src/examples/todomvc.md new file mode 100644 index 000000000..67de4acb8 --- /dev/null +++ b/guide/src/examples/todomvc.md @@ -0,0 +1,23 @@ +# TODO MVC using wasm-bingen and web-sys + +[View full source code][code] or [view the compiled example online][online] + +[online]: https://rustwasm.github.io/wasm-bindgen/exbuild/todomvc/ +[code]: https://github.com/rustwasm/wasm-bindgen/tree/master/examples/todomvc + +[wasm-bindgen](https://github.com/rustwasm/wasm-bindgen) and [web-sys](https://rustwasm.github.io/wasm-bindgen/api/web_sys/) coded [TODO MVC](https://todomvc.com/) + +The code was rewritten from the [ES6 version](http://todomvc.com/examples/vanilla-es6/). + +The core differences are: +- Having an [Element wrapper](/src/element.rs) that takes care of dyn and into refs in web-sys, +- A [Scheduler](/src/scheduler.rs) that allows Controller and View to communicate to each other by emulating something similar to the JS event loop. + + +## Size + +The size of the project hasn't undergone much work to make it optimised yet. + +- ~96kb release build +- ~76kb optimised with binaryen +- ~28kb brotli compressed