use crate::{document, CATALOG}; use js_sys::{encode_uri_component, Date, RegExp}; use serde_derive::{Deserialize, Serialize}; use std::{convert::TryInto, sync::Mutex}; use wasm_bindgen::{prelude::*, JsCast, JsValue}; use web_sys::{ console, window, ClipboardEvent, Element, Event, FocusEvent, HtmlAnchorElement, HtmlDocument, HtmlElement, HtmlFormElement, HtmlInputElement, HtmlSelectElement, HtmlTextAreaElement, KeyboardEvent, MouseEvent, Node, }; macro_rules! mv { ( $( $var:ident ),* => $exp:expr ) => { { $( let $var = $var.clone(); )* $exp } } } fn get_elt_value(id: &'static str) -> String { let elt = document().get_element_by_id(id).unwrap(); let inp: Option<&HtmlInputElement> = elt.dyn_ref(); let textarea: Option<&HtmlTextAreaElement> = elt.dyn_ref(); let select: Option<&HtmlSelectElement> = elt.dyn_ref(); inp.map(|i| i.value()).unwrap_or_else(|| { textarea .map(|t| t.value()) .unwrap_or_else(|| select.unwrap().value()) }) } fn set_value>(id: &'static str, val: S) { let elt = document().get_element_by_id(id).unwrap(); let inp: Option<&HtmlInputElement> = elt.dyn_ref(); let textarea: Option<&HtmlTextAreaElement> = elt.dyn_ref(); let select: Option<&HtmlSelectElement> = elt.dyn_ref(); inp.map(|i| i.set_value(val.as_ref())).unwrap_or_else(|| { textarea .map(|t| t.set_value(val.as_ref())) .unwrap_or_else(|| select.unwrap().set_value(val.as_ref())) }) } fn no_return(evt: KeyboardEvent) { if evt.key() == "Enter" { evt.prevent_default(); } } #[derive(Debug)] pub enum EditorError { NoneError, DOMError, } impl From for EditorError { fn from(_: std::option::NoneError) -> Self { EditorError::NoneError } } const AUTOSAVE_DEBOUNCE_TIME: i32 = 5000; #[derive(Serialize, Deserialize)] struct AutosaveInformation { contents: String, cover: String, last_saved: f64, license: String, subtitle: String, tags: String, title: String, } fn is_basic_editor() -> bool { if let Some(basic_editor) = window() .unwrap() .local_storage() .unwrap() .unwrap() .get("basic-editor") .unwrap() { &basic_editor == "true" } else { false } } fn get_title() -> String { if is_basic_editor() { get_elt_value("title") } else { document() .query_selector("#plume-editor > h1") .unwrap() .unwrap() .dyn_ref::() .unwrap() .inner_text() } } fn get_autosave_id() -> String { format!( "editor_contents={}", window().unwrap().location().pathname().unwrap() ) } fn get_editor_contents() -> String { if is_basic_editor() { get_elt_value("editor-content") } else { let editor = document().query_selector("article").unwrap().unwrap(); let child_nodes = editor.child_nodes(); let mut md = String::new(); for i in 0..child_nodes.length() { let ch = child_nodes.get(i).unwrap(); let to_append = match ch.node_type() { Node::ELEMENT_NODE => { let elt = ch.dyn_ref::().unwrap(); if elt.tag_name() == "DIV" { elt.inner_html() } else { elt.outer_html() } } Node::TEXT_NODE => ch.node_value().unwrap_or_default(), _ => unreachable!(), }; md = format!("{}\n\n{}", md, to_append); } md } } fn get_subtitle() -> String { if is_basic_editor() { get_elt_value("subtitle") } else { document() .query_selector("#plume-editor > h2") .unwrap() .unwrap() .dyn_ref::() .unwrap() .inner_text() } } fn autosave() { let info = AutosaveInformation { contents: get_editor_contents(), title: get_title(), subtitle: get_subtitle(), tags: get_elt_value("tags"), license: get_elt_value("license"), last_saved: Date::now(), cover: get_elt_value("cover"), }; let id = get_autosave_id(); match window() .unwrap() .local_storage() .unwrap() .unwrap() .set(&id, &serde_json::to_string(&info).unwrap()) { Ok(_) => {} _ => console::log_1(&"Autosave failed D:".into()), } } fn load_autosave() { if let Ok(Some(autosave_str)) = window() .unwrap() .local_storage() .unwrap() .unwrap() .get(&get_autosave_id()) { let autosave_info: AutosaveInformation = serde_json::from_str(&autosave_str).ok().unwrap(); let message = i18n!( CATALOG, "Do you want to load the local autosave last edited at {}?"; Date::new(&JsValue::from_f64(autosave_info.last_saved)).to_date_string().as_string().unwrap() ); if let Ok(true) = window().unwrap().confirm_with_message(&message) { set_value("editor-content", &autosave_info.contents); set_value("title", &autosave_info.title); set_value("subtitle", &autosave_info.subtitle); set_value("tags", &autosave_info.tags); set_value("license", &autosave_info.license); set_value("cover", &autosave_info.cover); } else { clear_autosave(); } } } fn clear_autosave() { window() .unwrap() .local_storage() .unwrap() .unwrap() .remove_item(&get_autosave_id()) .unwrap(); console::log_1(&&format!("Saved to {}", &get_autosave_id()).into()); } type TimeoutHandle = i32; lazy_static! { static ref AUTOSAVE_TIMEOUT: Mutex> = Mutex::new(None); } fn autosave_debounce() { let window = window().unwrap(); let timeout = &mut AUTOSAVE_TIMEOUT.lock().unwrap(); if let Some(timeout) = timeout.take() { window.clear_timeout_with_handle(timeout); } let callback = Closure::once(autosave); **timeout = window .set_timeout_with_callback_and_timeout_and_arguments_0( callback.as_ref().unchecked_ref(), AUTOSAVE_DEBOUNCE_TIME, ) .ok(); callback.forget(); } fn init_widget( parent: &Element, tag: &'static str, placeholder_text: String, content: String, disable_return: bool, ) -> Result { let widget = placeholder( make_editable(tag).dyn_into::().unwrap(), &placeholder_text, ); if !content.is_empty() { widget .dataset() .set("edited", "true") .map_err(|_| EditorError::DOMError)?; } widget .append_child(&document().create_text_node(&content)) .map_err(|_| EditorError::DOMError)?; if disable_return { let callback = Closure::wrap(Box::new(no_return) as Box); widget .add_event_listener_with_callback("keydown", callback.as_ref().unchecked_ref()) .map_err(|_| EditorError::DOMError)?; callback.forget(); } parent .append_child(&widget) .map_err(|_| EditorError::DOMError)?; // We need to do that to make sure the placeholder is correctly rendered widget.focus().map_err(|_| EditorError::DOMError)?; widget.blur().map_err(|_| EditorError::DOMError)?; filter_paste(&widget); Ok(widget) } fn filter_paste(elt: &HtmlElement) { // Only insert text when pasting something let insert_text = Closure::wrap(Box::new(|evt: ClipboardEvent| { evt.prevent_default(); if let Some(data) = evt.clipboard_data() { if let Ok(data) = data.get_data("text") { document() .dyn_ref::() .unwrap() .exec_command_with_show_ui_and_value("insertText", false, &data) .unwrap(); } } }) as Box); elt.add_event_listener_with_callback("paste", insert_text.as_ref().unchecked_ref()) .unwrap(); insert_text.forget(); } pub fn init() -> Result<(), EditorError> { if let Some(ed) = document().get_element_by_id("plume-fallback-editor") { load_autosave(); let callback = Closure::wrap(Box::new(|_| clear_autosave()) as Box); ed.add_event_listener_with_callback("submit", callback.as_ref().unchecked_ref()) .map_err(|_| EditorError::DOMError)?; callback.forget(); } // Check if the user wants to use the basic editor if window() .unwrap() .local_storage() .unwrap() .unwrap() .get("basic-editor") .map(|x| x.is_some() && x.unwrap() == "true") .unwrap_or(true) { if let Some(editor) = document().get_element_by_id("plume-fallback-editor") { if let Ok(Some(title_label)) = document().query_selector("label[for=title]") { let editor_button = document() .create_element("a") .map_err(|_| EditorError::DOMError)?; editor_button .dyn_ref::() .unwrap() .set_href("#"); let disable_basic_editor = Closure::wrap(Box::new(|_| { let window = window().unwrap(); if window .local_storage() .unwrap() .unwrap() .set("basic-editor", "false") .is_err() { console::log_1(&"Failed to write into local storage".into()); } window.history().unwrap().go_with_delta(0).ok(); // refresh }) as Box); editor_button .add_event_listener_with_callback( "click", disable_basic_editor.as_ref().unchecked_ref(), ) .map_err(|_| EditorError::DOMError)?; disable_basic_editor.forget(); editor_button .append_child( &document().create_text_node(&i18n!(CATALOG, "Open the rich text editor")), ) .map_err(|_| EditorError::DOMError)?; editor .insert_before(&editor_button, Some(&title_label)) .ok(); let callback = Closure::wrap( Box::new(|_| autosave_debounce()) as Box ); document() .get_element_by_id("editor-content") .unwrap() .add_event_listener_with_callback("keydown", callback.as_ref().unchecked_ref()) .map_err(|_| EditorError::DOMError)?; callback.forget(); } } Ok(()) } else { init_editor() } } fn init_editor() -> Result<(), EditorError> { if let Some(ed) = document().get_element_by_id("plume-editor") { // Show the editor ed.dyn_ref::() .unwrap() .style() .set_property("display", "block") .map_err(|_| EditorError::DOMError)?; // And hide the HTML-only fallback let old_ed = document().get_element_by_id("plume-fallback-editor"); if old_ed.is_none() { return Ok(()); } let old_ed = old_ed.unwrap(); let old_title = document().get_element_by_id("plume-editor-title")?; old_ed .dyn_ref::() .unwrap() .style() .set_property("display", "none") .map_err(|_| EditorError::DOMError)?; old_title .dyn_ref::() .unwrap() .style() .set_property("display", "none") .map_err(|_| EditorError::DOMError)?; // Get content from the old editor (when editing an article for instance) let title_val = get_elt_value("title"); let subtitle_val = get_elt_value("subtitle"); let content_val = get_elt_value("editor-content"); // And pre-fill the new editor with this values let title = init_widget(&ed, "h1", i18n!(CATALOG, "Title"), title_val, true)?; let subtitle = init_widget( &ed, "h2", i18n!(CATALOG, "Subtitle, or summary"), subtitle_val, true, )?; let content = init_widget( &ed, "article", i18n!(CATALOG, "Write your article here. Markdown is supported."), content_val.clone(), false, )?; content.set_inner_html(&content_val); // character counter let character_counter = Closure::wrap(Box::new(mv!(content => move |_| { let update_char_count = Closure::wrap(Box::new(mv!(content => move || { if let Some(e) = document().get_element_by_id("char-count") { let count = chars_left("#plume-fallback-editor", &content).unwrap_or_default(); let text = i18n!(CATALOG, "Around {} characters left"; count); e.dyn_ref::().map(|e| { e.set_inner_text(&text); }).unwrap(); }; })) as Box); window().unwrap().set_timeout_with_callback_and_timeout_and_arguments(update_char_count.as_ref().unchecked_ref(), 0, &js_sys::Array::new()).unwrap(); update_char_count.forget(); autosave_debounce(); })) as Box); content .add_event_listener_with_callback("keydown", character_counter.as_ref().unchecked_ref()) .map_err(|_| EditorError::DOMError)?; character_counter.forget(); let show_popup = Closure::wrap(Box::new(mv!(title, subtitle, content, old_ed => move |_| { let popup = document().get_element_by_id("publish-popup").or_else(|| init_popup(&title, &subtitle, &content, &old_ed).ok() ).unwrap(); let bg = document().get_element_by_id("popup-bg").or_else(|| init_popup_bg().ok() ).unwrap(); popup.class_list().add_1("show").unwrap(); bg.class_list().add_1("show").unwrap(); })) as Box); document() .get_element_by_id("publish")? .add_event_listener_with_callback("click", show_popup.as_ref().unchecked_ref()) .map_err(|_| EditorError::DOMError)?; show_popup.forget(); show_errors(); setup_close_button(); } Ok(()) } fn setup_close_button() { if let Some(button) = document().get_element_by_id("close-editor") { let close_editor = Closure::wrap(Box::new(|_| { window() .unwrap() .local_storage() .unwrap() .unwrap() .set("basic-editor", "true") .unwrap(); window() .unwrap() .history() .unwrap() .go_with_delta(0) .unwrap(); // Refresh the page }) as Box); button .add_event_listener_with_callback("click", close_editor.as_ref().unchecked_ref()) .unwrap(); close_editor.forget(); } } fn show_errors() { let document = document(); if let Ok(Some(header)) = document.query_selector("header") { let list = document.create_element("header").unwrap(); list.class_list().add_1("messages").unwrap(); let errors = document.query_selector_all("p.error").unwrap(); for i in 0..errors.length() { let error = errors.get(i).unwrap(); error .parent_element() .unwrap() .remove_child(&error) .unwrap(); let _ = list.append_child(&error); } header .parent_element() .unwrap() .insert_before(&list, header.next_sibling().as_ref()) .unwrap(); } } fn init_popup( title: &HtmlElement, subtitle: &HtmlElement, content: &HtmlElement, old_ed: &Element, ) -> Result { let document = document(); let popup = document .create_element("div") .map_err(|_| EditorError::DOMError)?; popup .class_list() .add_1("popup") .map_err(|_| EditorError::DOMError)?; popup .set_attribute("id", "publish-popup") .map_err(|_| EditorError::DOMError)?; let tags = get_elt_value("tags") .split(',') .map(str::trim) .map(str::to_string) .collect::>(); let license = get_elt_value("license"); make_input(&i18n!(CATALOG, "Tags"), "popup-tags", &popup).set_value(&tags.join(", ")); make_input(&i18n!(CATALOG, "License"), "popup-license", &popup).set_value(&license); let cover_label = document .create_element("label") .map_err(|_| EditorError::DOMError)?; cover_label .append_child(&document.create_text_node(&i18n!(CATALOG, "Cover"))) .map_err(|_| EditorError::DOMError)?; cover_label .set_attribute("for", "cover") .map_err(|_| EditorError::DOMError)?; let cover = document.get_element_by_id("cover")?; cover.parent_element()?.remove_child(&cover).ok(); popup .append_child(&cover_label) .map_err(|_| EditorError::DOMError)?; popup .append_child(&cover) .map_err(|_| EditorError::DOMError)?; if let Some(draft_checkbox) = document.get_element_by_id("draft") { let draft_checkbox = draft_checkbox.dyn_ref::().unwrap(); let draft_label = document .create_element("label") .map_err(|_| EditorError::DOMError)?; draft_label .set_attribute("for", "popup-draft") .map_err(|_| EditorError::DOMError)?; let draft = document.create_element("input").unwrap(); draft.set_id("popup-draft"); let draft = draft.dyn_ref::().unwrap(); draft.set_name("popup-draft"); draft.set_type("checkbox"); draft.set_checked(draft_checkbox.checked()); draft_label .append_child(&draft) .map_err(|_| EditorError::DOMError)?; draft_label .append_child(&document.create_text_node(&i18n!(CATALOG, "This is a draft"))) .map_err(|_| EditorError::DOMError)?; popup .append_child(&draft_label) .map_err(|_| EditorError::DOMError)?; } let button = document .create_element("input") .map_err(|_| EditorError::DOMError)?; button .append_child(&document.create_text_node(&i18n!(CATALOG, "Publish"))) .map_err(|_| EditorError::DOMError)?; let button = button.dyn_ref::().unwrap(); button.set_type("submit"); button.set_value(&i18n!(CATALOG, "Publish")); let callback = Closure::wrap(Box::new(mv!(title, subtitle, content, old_ed => move |_| { let document = self::document(); title.focus().unwrap(); // Remove the placeholder before publishing set_value("title", title.inner_text()); subtitle.focus().unwrap(); set_value("subtitle", subtitle.inner_text()); content.focus().unwrap(); let mut md = String::new(); let child_nodes = content.child_nodes(); for i in 0..child_nodes.length() { let ch = child_nodes.get(i).unwrap(); let to_append = match ch.node_type() { Node::ELEMENT_NODE => { let ch = ch.dyn_ref::().unwrap(); if ch.tag_name() == "DIV" { ch.inner_html() } else { ch.outer_html() } }, Node::TEXT_NODE => ch.node_value().unwrap_or_default(), _ => unreachable!(), }; md = format!("{}\n\n{}", md, to_append); } set_value("editor-content", md); set_value("tags", get_elt_value("popup-tags")); if let Some(draft) = document.get_element_by_id("popup-draft") { if let Some(draft_checkbox) = document.get_element_by_id("draft") { let draft_checkbox = draft_checkbox.dyn_ref::().unwrap(); let draft = draft.dyn_ref::().unwrap(); draft_checkbox.set_checked(draft.checked()); } } let cover = document.get_element_by_id("cover").unwrap(); cover.parent_element().unwrap().remove_child(&cover).ok(); old_ed.append_child(&cover).unwrap(); set_value("license", get_elt_value("popup-license")); clear_autosave(); let old_ed = old_ed.dyn_ref::().unwrap(); old_ed.submit().unwrap(); })) as Box); button .add_event_listener_with_callback("click", callback.as_ref().unchecked_ref()) .map_err(|_| EditorError::DOMError)?; callback.forget(); popup .append_child(&button) .map_err(|_| EditorError::DOMError)?; document .body()? .append_child(&popup) .map_err(|_| EditorError::DOMError)?; Ok(popup) } fn init_popup_bg() -> Result { let bg = document() .create_element("div") .map_err(|_| EditorError::DOMError)?; bg.class_list() .add_1("popup-bg") .map_err(|_| EditorError::DOMError)?; bg.set_attribute("id", "popup-bg") .map_err(|_| EditorError::DOMError)?; document() .body()? .append_child(&bg) .map_err(|_| EditorError::DOMError)?; let callback = Closure::wrap(Box::new(|_| close_popup()) as Box); bg.add_event_listener_with_callback("click", callback.as_ref().unchecked_ref()) .unwrap(); callback.forget(); Ok(bg) } fn chars_left(selector: &str, content: &HtmlElement) -> Option { match document().query_selector(selector) { Ok(Some(form)) => form.dyn_ref::().and_then(|form| { if let Some(len) = form .get_attribute("content-size") .and_then(|s| s.parse::().ok()) { (encode_uri_component(&content.inner_html()) .replace("%20", "+") .replace("%0A", "%0D0A") .replace_by_pattern(&RegExp::new("[!'*()]", "g"), "XXX") .length() + 2_u32) .try_into() .map(|c: i32| len - c) .ok() } else { None } }), _ => None, } } fn close_popup() { let hide = |x: Element| x.class_list().remove_1("show"); document().get_element_by_id("publish-popup").map(hide); document().get_element_by_id("popup-bg").map(hide); } fn make_input(label_text: &str, name: &'static str, form: &Element) -> HtmlInputElement { let document = document(); let label = document.create_element("label").unwrap(); label .append_child(&document.create_text_node(label_text)) .unwrap(); label.set_attribute("for", name).unwrap(); let inp = document.create_element("input").unwrap(); let inp = inp.dyn_into::().unwrap(); inp.set_attribute("name", name).unwrap(); inp.set_attribute("id", name).unwrap(); form.append_child(&label).unwrap(); form.append_child(&inp).unwrap(); inp } fn make_editable(tag: &'static str) -> Element { let elt = document() .create_element(tag) .expect("Couldn't create editable element"); elt.set_attribute("contenteditable", "true") .expect("Couldn't make the element editable"); elt } fn placeholder(elt: HtmlElement, text: &str) -> HtmlElement { elt.dataset().set("placeholder", text).unwrap(); elt.dataset().set("edited", "false").unwrap(); let callback = Closure::wrap(Box::new(mv!(elt => move |_: FocusEvent| { if elt.dataset().get("edited").unwrap().as_str() != "true" { clear_children(&elt); } })) as Box); elt.add_event_listener_with_callback("focus", callback.as_ref().unchecked_ref()) .unwrap(); callback.forget(); let callback = Closure::wrap(Box::new(mv!(elt => move |_: Event| { if elt.dataset().get("edited").unwrap().as_str() != "true" { clear_children(&elt); let ph = document().create_element("span").expect("Couldn't create placeholder"); ph.class_list().add_1("placeholder").expect("Couldn't add class"); ph.append_child(&document().create_text_node(&elt.dataset().get("placeholder").unwrap_or_default())).unwrap(); elt.append_child(&ph).unwrap(); } })) as Box); elt.add_event_listener_with_callback("blur", callback.as_ref().unchecked_ref()) .unwrap(); callback.forget(); let callback = Closure::wrap(Box::new(mv!(elt => move |_: KeyboardEvent| { elt.dataset().set("edited", if elt.inner_text().trim_matches('\n').is_empty() { "false" } else { "true" }).expect("Couldn't update edition state"); })) as Box); elt.add_event_listener_with_callback("keyup", callback.as_ref().unchecked_ref()) .unwrap(); callback.forget(); elt } fn clear_children(elt: &HtmlElement) { let child_nodes = elt.child_nodes(); for _ in 0..child_nodes.length() { elt.remove_child(&child_nodes.get(0).unwrap()).unwrap(); } }