use std::rc::Rc; use std::cell::Cell; use wasm_bindgen::prelude::*; use serde_derive::{Serialize, Deserialize}; use web_sys::{window, HtmlElement, Storage}; use futures_signals::map_ref; use futures_signals::signal::{Signal, SignalExt, Mutable}; use futures_signals::signal_vec::{SignalVecExt, MutableVec}; use dominator::{Dom, text, routing, html, clone, events}; fn local_storage() -> Storage { window().unwrap_throw().local_storage().unwrap_throw().unwrap_throw() } // TODO make this more efficient #[inline] fn trim(input: &str) -> Option { let trimmed = input.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_owned()) } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Filter { Active, Completed, All, } impl Filter { fn signal() -> impl Signal { routing::url().map(|url| { match url.hash().as_str() { "#/active" => Filter::Active, "#/completed" => Filter::Completed, _ => Filter::All, } }) } #[inline] fn button(kind: Self) -> Dom { let url = match kind { Filter::Active => "#/active", Filter::Completed => "#/completed", Filter::All => "#/", }; let text = match kind { Filter::Active => "Active", Filter::Completed => "Completed", Filter::All => "All", }; routing::link(url, |dom| { dom .class_signal("selected", Self::signal() .map(clone!(kind => move |filter| filter == kind))) .text(text) }) } } #[derive(Debug, Serialize, Deserialize)] struct Todo { id: u32, title: Mutable, completed: Mutable, #[serde(skip)] editing: Mutable>, } #[derive(Debug, Serialize, Deserialize)] struct State { todo_id: Cell, #[serde(skip)] new_todo_title: Mutable, todo_list: MutableVec>, } impl State { fn new() -> Self { State { todo_id: Cell::new(0), new_todo_title: Mutable::new("".to_owned()), todo_list: MutableVec::new(), } } fn remove_todo(&self, todo: &Todo) { // TODO make this more efficient ? self.todo_list.lock_mut().retain(|x| x.id != todo.id); } fn deserialize() -> Self { local_storage() .get_item("todos-rust-dominator") .unwrap_throw() .and_then(|state_json| { serde_json::from_str(state_json.as_str()).ok() }) .unwrap_or_else(State::new) } fn serialize(&self) { let state_json = serde_json::to_string(self).unwrap_throw(); local_storage().set_item("todos-rust-dominator", state_json.as_str()).unwrap_throw(); } } #[wasm_bindgen(start)] pub fn main_js() -> Result<(), JsValue> { #[cfg(debug_assertions)] console_error_panic_hook::set_once(); let state = Rc::new(State::deserialize()); dominator::append_dom(dominator::body(), html!("section", { .class("todoapp") .children(&mut [ html!("header", { .class("header") .children(&mut [ html!("h1", { .text("todos") }), html!("input", { .focused(true) .class("new-todo") .attribute("placeholder", "What needs to be done?") .property_signal("value", state.new_todo_title.signal_cloned()) .event(clone!(state => move |event: events::Input| { state.new_todo_title.set_neq(event.value().unwrap_throw()); })) .event(clone!(state => move |event: events::KeyDown| { if event.key() == "Enter" { event.prevent_default(); let trimmed = trim(&state.new_todo_title.lock_ref()); if let Some(title) = trimmed { state.new_todo_title.set_neq("".to_owned()); let id = state.todo_id.get(); state.todo_id.set(id + 1); state.todo_list.lock_mut().push_cloned(Rc::new(Todo { id: id, title: Mutable::new(title), completed: Mutable::new(false), editing: Mutable::new(None), })); state.serialize(); } } })) }), ]) }), html!("section", { .class("main") // Hide if it doesn't have any todos. .visible_signal(state.todo_list.signal_vec_cloned() .len() .map(|len| len > 0)) .children(&mut [ html!("input", { .class("toggle-all") .attribute("id", "toggle-all") .attribute("type", "checkbox") .property_signal("checked", state.todo_list.signal_vec_cloned() .map_signal(|todo| todo.completed.signal()) .filter(|completed| !completed) .len() .map(|len| len != 0)) .event(clone!(state => move |event: events::Change| { // Toggles the boolean let checked = !event.checked().unwrap_throw(); { let todo_list = state.todo_list.lock_ref(); for todo in todo_list.iter() { todo.completed.set_neq(checked); } } state.serialize(); })) }), html!("label", { .attribute("for", "toggle-all") .text("Mark all as complete") }), html!("ul", { .class("todo-list") .children_signal_vec(state.todo_list.signal_vec_cloned() .map(clone!(state => move |todo| { html!("li", { .class_signal("editing", todo.editing.signal_cloned() .map(|x| x.is_some())) .class_signal("completed", todo.completed.signal()) .visible_signal(map_ref!( let filter = Filter::signal(), let completed = todo.completed.signal() => match *filter { Filter::Active => !completed, Filter::Completed => *completed, Filter::All => true, } ) .dedupe()) .children(&mut [ html!("div", { .class("view") .children(&mut [ html!("input", { .attribute("type", "checkbox") .class("toggle") .property_signal("checked", todo.completed.signal()) .event(clone!(state, todo => move |event: events::Change| { todo.completed.set_neq(event.checked().unwrap_throw()); state.serialize(); })) }), html!("label", { .event(clone!(todo => move |_: events::DoubleClick| { todo.editing.set_neq(Some(todo.title.get_cloned())); })) .text_signal(todo.title.signal_cloned()) }), html!("button", { .class("destroy") .event(clone!(state, todo => move |_: events::Click| { state.remove_todo(&todo); state.serialize(); })) }), ]) }), html!("input", { .class("edit") .property_signal("value", todo.editing.signal_cloned() .map(|x| x.unwrap_or_else(|| "".to_owned()))) .visible_signal(todo.editing.signal_cloned() .map(|x| x.is_some())) // TODO dedupe this somehow ? .focused_signal(todo.editing.signal_cloned() .map(|x| x.is_some())) .event(clone!(todo => move |event: events::KeyDown| { match event.key().as_str() { "Enter" => { event.dyn_target::().unwrap_throw().blur(); }, "Escape" => { todo.editing.set_neq(None); }, _ => {} } })) .event(clone!(todo => move |event: events::Input| { todo.editing.set_neq(Some(event.value().unwrap_throw())); })) // TODO global_event ? .event(clone!(state, todo => move |_: events::Blur| { if let Some(title) = todo.editing.replace(None) { if let Some(title) = trim(&title) { todo.title.set_neq(title); } else { state.remove_todo(&todo); } state.serialize(); } })) }), ]) }) }))) }), ]) }), html!("footer", { .class("footer") // Hide if it doesn't have any todos. .visible_signal(state.todo_list.signal_vec_cloned() .len() .map(|len| len > 0)) .children(&mut [ html!("span", { .class("todo-count") .children_signal_vec(state.todo_list.signal_vec_cloned() .map_signal(|todo| todo.completed.signal()) .filter(|completed| !completed) .len() // TODO make this more efficient .map(|len| { vec![ html!("strong", { .text(&len.to_string()) }), text(if len == 1 { " item left" } else { " items left" }), ] }) .to_signal_vec()) }), html!("ul", { .class("filters") .children(&mut [ html!("li", { .children(&mut [ Filter::button(Filter::All), ]) }), html!("li", { .children(&mut [ Filter::button(Filter::Active), ]) }), html!("li", { .children(&mut [ Filter::button(Filter::Completed), ]) }), ]) }), html!("button", { .class("clear-completed") // Hide if it doesn't have any completed items. .visible_signal(state.todo_list.signal_vec_cloned() .map_signal(|todo| todo.completed.signal()) .filter(|completed| *completed) .len() .map(|len| len > 0)) .event(clone!(state => move |_: events::Click| { state.todo_list.lock_mut().retain(|todo| todo.completed.get() == false); state.serialize(); })) .text("Clear completed") }), ]) }), ]) }), ); dominator::append_dom(dominator::body(), html!("footer", { .class("info") .children(&mut [ html!("p", { .text("Double-click to edit a todo") }), html!("p", { .children(&mut [ text("Created by "), html!("a", { .attribute("href", "https://github.com/Pauan") .text("Pauan") }), ]) }), html!("p", { .children(&mut [ text("Part of "), html!("a", { .attribute("href", "http://todomvc.com") .text("TodoMVC") }), ]) }), ]) }), ); Ok(()) }