diff --git a/voussoirkitjs/common.js b/voussoirkitjs/common.js new file mode 100644 index 0000000..20b1c90 --- /dev/null +++ b/voussoirkitjs/common.js @@ -0,0 +1,575 @@ +const common = {}; + +common.INPUT_TYPES = new Set(["INPUT", "TEXTAREA"]); + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// UTILS /////////////////////////////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////////////////////////// + +common.create_message_bubble = +function create_message_bubble(message_area, message_positivity, message_text, lifespan) +{ + if (lifespan === undefined) + { + lifespan = 8000; + } + const message = document.createElement("div"); + message.className = "message_bubble " + message_positivity; + const span = document.createElement("span"); + span.innerHTML = message_text; + message.appendChild(span); + message_area.appendChild(message); + setTimeout(function(){message_area.removeChild(message);}, lifespan); +} + +common.is_narrow_mode = +function is_narrow_mode() +{ + return getComputedStyle(document.documentElement).getPropertyValue("--narrow").trim() === "1"; +} + +common.is_wide_mode = +function is_wide_mode() +{ + return getComputedStyle(document.documentElement).getPropertyValue("--wide").trim() === "1"; +} + +common.go_to_root = +function go_to_root() +{ + window.location.href = "/"; +} + +common.refresh = +function refresh() +{ + window.location.reload(); +} + +common.refresh_or_alert = +function refresh_or_alert(response) +{ + if (response.meta.status !== 200) + { + alert(JSON.stringify(response)); + return; + } + window.location.reload(); +} + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// STRING TOOLS //////////////////////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////////////////////////// + +common.join_and_trail = +function join_and_trail(list, separator) +{ + if (list.length === 0) + { + return ""; + } + return list.join(separator) + separator +} + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// HTML & DOM ////////////////////////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////////////////////////// + +common.delete_all_children = +function delete_all_children(element) +{ + while (element.firstChild) + { + element.removeChild(element.firstChild); + } +} + +common.html_to_element = +function html_to_element(html) +{ + const template = document.createElement("template"); + template.innerHTML = html.trim(); + return template.content.firstElementChild; +} + +common.size_iframe_to_content = +function size_iframe_to_content(iframe) +{ + iframe.style.height = iframe.contentWindow.document.documentElement.scrollHeight + 'px'; +} + +common.update_dynamic_elements = +function update_dynamic_elements(class_name, text) +{ + /* + Find all elements with this class and set their innertext to this text. + */ + const elements = document.getElementsByClassName(class_name); + for (const element of elements) + { + element.innerText = text; + } +} + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// HOOKS & ADD-ONS ///////////////////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////////////////////////// + +common.bind_box_to_button = +function bind_box_to_button(box, button, ctrl_enter) +{ + /* + Bind a textbox to a button so that pressing Enter within the textbox is the + same as clicking the button. + + If `ctrl_enter` is true, then you must press ctrl+Enter to trigger the + button, which is important for textareas. + + Thanks Yaroslav Yakovlev + http://stackoverflow.com/a/9343095 + */ + const bound_box_hook = function(event) + { + if (event.key !== "Enter") + {return;} + + ctrl_success = !ctrl_enter || (event.ctrlKey); + + if (! ctrl_success) + {return;} + + button.click(); + } + box.addEventListener("keyup", bound_box_hook); +} + +//////////////////////////////////////////////////////////////////////////////////////////////////// +// CSS-JS CLASSES ////////////////////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////////////////////////// + +common.init_atag_merge_params = +function init_atag_merge_params(a) +{ + /* + To create an tag where the ?parameters written on the href are merged + with the parameters of the current page URL, give it the class + "merge_params". If the URL and href contain the same parameter, the href + takes priority. + + Optional: + data-merge-params: A whitelist of parameter names, separated by commas + or spaces. Only these parameters will be merged from the page URL. + + data-merge-params-except: A blacklist of parameter names, separated by + commas or spaces. All parameters except these will be merged from + the page URL. + + Example: + URL: ?filter=hello&orderby=score + href: "?orderby=date" + Result: "?filter=hello&orderby=date" + */ + const page_params = Array.from(new URLSearchParams(window.location.search)); + let to_merge; + + if (a.dataset.mergeParams) + { + const keep = new Set(a.dataset.mergeParams.split(/[\s,]+/)); + to_merge = page_params.filter(key_value => keep.has(key_value[0])); + delete a.dataset.mergeParams; + } + else if (a.dataset.mergeParamsExcept) + { + const remove = new Set(a.dataset.mergeParamsExcept.split(/[\s,]+/)); + to_merge = page_params.filter(key_value => (! remove.has(key_value[0]))); + delete a.dataset.mergeParamsExcept; + } + else + { + to_merge = page_params; + } + + to_merge = to_merge.concat(Array.from(new URLSearchParams(a.search))); + const new_params = new URLSearchParams(); + for (const [key, value] of to_merge) + { new_params.set(key, value); } + a.search = new_params.toString(); + a.classList.remove("merge_params"); +} + +common.init_all_atag_merge_params = +function init_all_atag_merge_params() +{ + const page_params = Array.from(new URLSearchParams(window.location.search)); + const as = Array.from(document.getElementsByClassName("merge_params")); + for (const a of as) + { + setTimeout(() => common.init_atag_merge_params(a), 0); + } +} + +//////////////////////////////////////////////////////////////////////////////////////////////////// + +common.init_button_with_confirm = +function init_button_with_confirm(button) +{ + /* + To create a button that requires a second confirmation step, assign it the + class "button_with_confirm". + + Required: + data-onclick: String that would normally be the button's onclick. + This is done so that if the button_with_confirm fails to initialize, + the button will be non-operational as opposed to being operational + but with no confirmation. For dangerous actions I think this is a + worthwhile move though it could lead to feature downtime. + + Optional: + data-prompt: Text that appears next to the confirm button. Default is + "Are you sure?". + + data-prompt-class: CSS class for the prompt span. + + data-confirm: Text inside the confirm button. Default is to inherit the + original button's text. + + data-confirm-class: CSS class for the confirm button. Default is to + inheret all classes of the original button, except for + "button_with_confirm" of course. + + data-cancel: Text inside the cancel button. Default is "Cancel". + + data-cancel-class: CSS class for the cancel button. + + data-holder-class: CSS class for the new span that holds the menu. + */ + button.classList.remove("button_with_confirm"); + + const holder = document.createElement("span"); + holder.className = ("confirm_holder " + (button.dataset.holderClass || "")).trim(); + delete button.dataset.holderClass; + if (button.dataset.holderId) + { + holder.id = button.dataset.holderId; + delete button.dataset.holderId; + } + button.parentElement.insertBefore(holder, button); + + const holder_stage1 = document.createElement("span"); + holder_stage1.className = "confirm_holder_stage1"; + holder_stage1.appendChild(button); + holder.appendChild(holder_stage1); + + const holder_stage2 = document.createElement("span"); + holder_stage2.className = "confirm_holder_stage2 hidden"; + holder.appendChild(holder_stage2); + + let prompt; + let input_source; + if (button.dataset.isInput) + { + prompt = document.createElement("input"); + prompt.placeholder = button.dataset.prompt || ""; + input_source = prompt; + } + else + { + prompt = document.createElement("span"); + prompt.innerText = (button.dataset.prompt || "Are you sure?") + " "; + input_source = undefined; + } + if (button.dataset.promptClass) + { + prompt.className = button.dataset.promptClass; + } + holder_stage2.appendChild(prompt) + delete button.dataset.prompt; + delete button.dataset.promptClass; + + const button_confirm = document.createElement("button"); + button_confirm.innerText = (button.dataset.confirm || button.innerText).trim(); + if (button.dataset.confirmClass === undefined) + { + button_confirm.className = button.className; + button_confirm.classList.remove("button_with_confirm"); + } + else + { + button_confirm.className = button.dataset.confirmClass; + } + button_confirm.input_source = input_source; + holder_stage2.appendChild(button_confirm); + holder_stage2.appendChild(document.createTextNode(" ")); + if (button.dataset.isInput) + { + common.bind_box_to_button(prompt, button_confirm); + } + delete button.dataset.confirm; + delete button.dataset.confirmClass; + delete button.dataset.isInput; + + const button_cancel = document.createElement("button"); + button_cancel.innerText = button.dataset.cancel || "Cancel"; + button_cancel.className = button.dataset.cancelClass || ""; + holder_stage2.appendChild(button_cancel); + delete button.dataset.cancel; + delete button.dataset.cancelClass; + + // If this is stupid, let me know. + const confirm_onclick = ` + let holder = event.target.parentElement.parentElement; + holder.getElementsByClassName("confirm_holder_stage1")[0].classList.remove("hidden"); + holder.getElementsByClassName("confirm_holder_stage2")[0].classList.add("hidden"); + ` + button.dataset.onclick; + button_confirm.onclick = Function(confirm_onclick); + + button.removeAttribute("onclick"); + button.onclick = function(event) + { + const holder = event.target.parentElement.parentElement; + holder.getElementsByClassName("confirm_holder_stage1")[0].classList.add("hidden"); + holder.getElementsByClassName("confirm_holder_stage2")[0].classList.remove("hidden"); + const input = holder.getElementsByTagName("input")[0]; + if (input) + { + input.focus(); + } + } + + button_cancel.onclick = function(event) + { + const holder = event.target.parentElement.parentElement; + holder.getElementsByClassName("confirm_holder_stage1")[0].classList.remove("hidden"); + holder.getElementsByClassName("confirm_holder_stage2")[0].classList.add("hidden"); + } + delete button.dataset.onclick; +} + +common.init_all_button_with_confirm = +function init_all_button_with_confirm() +{ + const buttons = Array.from(document.getElementsByClassName("button_with_confirm")); + for (const button of buttons) + { + setTimeout(() => common.init_button_with_confirm(button), 0); + } +} + +//////////////////////////////////////////////////////////////////////////////////////////////////// + +common.init_all_input_bind_to_button = +function init_all_input_bind_to_button() +{ + for (const input of document.querySelectorAll("*[data-bind-enter-to-button]")) + { + const button = document.getElementById(input.dataset.bindEnterToButton); + if (button) + { + common.bind_box_to_button(input, button, false); + delete input.dataset.bindEnterToButton; + } + } + for (const input of document.querySelectorAll("*[data-bind-ctrl-enter-to-button]")) + { + const button = document.getElementById(input.dataset.bindCtrlEnterToButton); + if (button) + { + common.bind_box_to_button(input, button, true); + delete input.dataset.bindCtrlEnterToButton; + } + } +} + +//////////////////////////////////////////////////////////////////////////////////////////////////// + +common.init_enable_on_pageload = +function init_enable_on_pageload(element) +{ + /* + To create an input element which is disabled at first, and is enabled when + the DOM has completed loading, give it the disabled attribute and the + class "enable_on_pageload". + */ + element.disabled = false; + element.classList.remove("enable_on_pageload"); +} + +common.init_all_enable_on_pageload = +function init_all_enable_on_pageload() +{ + const elements = Array.from(document.getElementsByClassName("enable_on_pageload")); + for (const element of elements) + { + setTimeout(() => common.init_enable_on_pageload(element), 0); + } +} + +//////////////////////////////////////////////////////////////////////////////////////////////////// + +common.init_entry_with_history = +function init_entry_with_history(input) +{ + input.addEventListener("keydown", common.entry_with_history_hook); + input.classList.remove("entry_with_history"); +} + +common.init_all_entry_with_history = +function init_all_entry_with_history() +{ + const inputs = Array.from(document.getElementsByClassName("entry_with_history")); + for (const input of inputs) + { + setTimeout(() => common.init_entry_with_history(input), 0); + } +} + +common.entry_with_history_hook = +function entry_with_history_hook(event) +{ + const box = event.target; + + if (box.entry_history === undefined) + {box.entry_history = [];} + + if (box.entry_history_pos === undefined) + {box.entry_history_pos = null;} + + if (event.key === "Enter") + { + if (box.value === "") + {return;} + box.entry_history.push(box.value); + box.entry_history_pos = null; + } + else if (event.key === "Escape") + { + box.entry_history_pos = null; + box.value = ""; + } + + if (box.entry_history.length == 0) + {return} + + if (box.entry_history_pos !== null && box.value !== box.entry_history[box.entry_history_pos]) + {return;} + + if (event.key === "ArrowUp") + { + if (box.entry_history_pos === null) + {box.entry_history_pos = box.entry_history.length - 1;} + else if (box.entry_history_pos == 0) + {;} + else + {box.entry_history_pos -= 1;} + + if (box.entry_history_pos === null) + {box.value = "";} + else + {box.value = box.entry_history[box.entry_history_pos];} + + setTimeout(function(){box.selectionStart = box.value.length;}, 0); + } + else if (event.key === "ArrowDown") + { + if (box.entry_history_pos === null) + {;} + else if (box.entry_history_pos == box.entry_history.length-1) + {box.entry_history_pos = null;} + else + {box.entry_history_pos += 1;} + + if (box.entry_history_pos === null) + {box.value = "";} + else + {box.value = box.entry_history[box.entry_history_pos];} + + setTimeout(function(){box.selectionStart = box.value.length;}, 0); + } +} + +//////////////////////////////////////////////////////////////////////////////////////////////////// + +common.init_tabbed_container = +function init_tabbed_container(tabbed_container) +{ + const button_container = document.createElement("div"); + button_container.className = "tab_buttons"; + tabbed_container.prepend(button_container); + const tabs = Array.from(tabbed_container.getElementsByClassName("tab")); + for (const tab of tabs) + { + tab.classList.add("hidden"); + const tab_id = tab.dataset.tabId || tab.dataset.tabTitle; + tab.dataset.tabId = tab_id; + tab.style.borderTopColor = "transparent"; + + const button = document.createElement("button"); + button.className = "tab_button tab_button_inactive"; + button.onclick = common.tabbed_container_switcher; + button.innerText = tab.dataset.tabTitle; + button.dataset.tabId = tab_id; + button_container.append(button); + } + tabs[0].classList.remove("hidden"); + tabbed_container.dataset.activeTabId = tabs[0].dataset.tabId; + button_container.firstElementChild.classList.remove("tab_button_inactive"); + button_container.firstElementChild.classList.add("tab_button_active"); +} + +common.init_all_tabbed_container = +function init_all_tabbed_container() +{ + const tabbed_containers = Array.from(document.getElementsByClassName("tabbed_container")); + for (const tabbed_container of tabbed_containers) + { + setTimeout(() => common.init_tabbed_container(tabbed_container), 0); + } +} + +common.tabbed_container_switcher = +function tabbed_container_switcher(event) +{ + const tab_button = event.target; + if (tab_button.classList.contains("tab_button_active")) + { return; } + + const tab_id = tab_button.dataset.tabId; + const tab_buttons = tab_button.parentElement.getElementsByClassName("tab_button"); + for (const tab_button of tab_buttons) + { + if (tab_button.dataset.tabId === tab_id) + { + tab_button.classList.remove("tab_button_inactive"); + tab_button.classList.add("tab_button_active"); + } + else + { + tab_button.classList.remove("tab_button_active"); + tab_button.classList.add("tab_button_inactive"); + } + } + const tabbed_container = tab_button.closest(".tabbed_container"); + tabbed_container.dataset.activeTabId = tab_id; + const tabs = tabbed_container.getElementsByClassName("tab"); + for (const tab of tabs) + { + if (tab.dataset.tabId === tab_id) + { tab.classList.remove("hidden"); } + else + { tab.classList.add("hidden"); } + } +} + +//////////////////////////////////////////////////////////////////////////////////////////////////// + +common.on_pageload = +function on_pageload() +{ + common.init_all_atag_merge_params(); + common.init_all_button_with_confirm(); + common.init_all_enable_on_pageload(); + common.init_all_entry_with_history(); + common.init_all_input_bind_to_button(); + common.init_all_tabbed_container(); +} +document.addEventListener("DOMContentLoaded", common.on_pageload); diff --git a/voussoirkitjs/contextmenus.js b/voussoirkitjs/contextmenus.js new file mode 100644 index 0000000..6040a75 --- /dev/null +++ b/voussoirkitjs/contextmenus.js @@ -0,0 +1,48 @@ +const contextmenus = {}; + +contextmenus.background_click = +function background_click(event) +{ + const contextmenu = event.target.closest(".contextmenu"); + if (! contextmenu) + { + contextmenus.hide_open_menus(); + return true; + } +} + +contextmenus.hide_open_menus = +function hide_open_menus() +{ + const elements = document.getElementsByClassName("open_contextmenu"); + while (elements.length > 0) + { + elements[0].classList.remove("open_contextmenu"); + } +} + +contextmenus.menu_is_open = +function menu_is_open() +{ + return document.getElementsByClassName("open_contextmenu").length > 0; +} + +contextmenus.show_menu = +function show_menu(event, menu) +{ + contextmenus.hide_open_menus(); + menu.classList.add("open_contextmenu"); + const html = document.documentElement; + const over_right = Math.max(0, event.clientX + menu.offsetWidth - html.clientWidth); + const over_bottom = Math.max(0, event.clientY + menu.offsetHeight - html.clientHeight); + const left = event.clientX - over_right; + const top = event.pageY - over_bottom; + menu.style.left = left + "px"; + menu.style.top = top + "px"; +} + +function on_pageload() +{ + document.body.addEventListener("click", contextmenus.background_click); +} +document.addEventListener("DOMContentLoaded", on_pageload); diff --git a/voussoirkitjs/editor.js b/voussoirkitjs/editor.js new file mode 100644 index 0000000..07cea3b --- /dev/null +++ b/voussoirkitjs/editor.js @@ -0,0 +1,252 @@ +const editor = {}; + +editor.PARAGRAPH_TYPES = new Set(["P", "PRE"]); + +editor.Editor = +function Editor(element_argss, on_open, on_save, on_cancel) +{ + /* + This class wraps around display elements like spans, headers, and + paragraphs, and creates edit elements like inputs and textareas to edit + them with. + + element_argss should be a list of dicts. Each dict is required to have "id" + which is unique amongst its peers, and "element" which is the display + element. Additionally, you may add the following properties to change the + element's behavior: + + "autofocus": true + When the user opens the editor, this element will get .focus(). + Only one element should have this. + + "empty_text": string + If the display element contains this text, then the edit element will + be set to "" when opened. + If the edit element contains "", then the display element will + contain this text when saved. + + "hide_when_empty": true + If the element does not have any text, it will get the "hidden" css + class after saving / closing. + + "placeholder": string + The placeholder attribute of the edit element. + + The editor object will contain a dict called elements that maps IDs to the + display element, edit elements, and your other options. + + Your on_open, on_save and on_cancel hooks will be called with the editor + object as the only argument. + + When your callbacks are used, the default `open`, `save`, `cancel` + methods are not called automatically. You should call them from within + your function. That's because you may wish to do some of your own + normalization before the default handler, and some of your own cleanup + after it. So it is up to you when to call the default. + */ + this.cancel = function() + { + this.close(); + }; + + this.close = function() + { + for (const element of Object.values(this.elements)) + { + element.edit.classList.add("hidden"); + if (! (element.display.innerText === "" && element.hide_when_empty)) + { + element.display.classList.remove("hidden"); + } + } + this.hide_spinner(); + this.hide_error(); + this.open_button.classList.remove("hidden"); + this.save_button.classList.add("hidden"); + this.cancel_button.classList.add("hidden"); + }; + + this.hide_error = function() + { + this.error_message.classList.add("hidden"); + }; + + this.hide_spinner = function() + { + this.spinner.hide(); + }; + + this.open = function() + { + for (const element of Object.values(this.elements)) + { + element.display.classList.add("hidden"); + element.edit.classList.remove("hidden"); + + if (element.autofocus) + { + element.edit.focus(); + } + + if (element.empty_text !== undefined && element.display.innerText == element.empty_text) + { + element.edit.value = ""; + } + else + { + element.edit.value = element.display.innerText; + } + } + this.open_button.classList.add("hidden"); + this.save_button.classList.remove("hidden"); + this.cancel_button.classList.remove("hidden"); + }; + + this.save = function() + { + for (const element of Object.values(this.elements)) + { + if (element.empty_text !== undefined && element.edit.value == "") + { + element.display.innerText = element.empty_text; + } + else + { + element.display.innerText = element.edit.value; + } + } + + this.close(); + }; + + this.show_error = function(message) + { + this.hide_spinner(); + this.error_message.innerText = message; + this.error_message.classList.remove("hidden"); + }; + + this.show_spinner = function(delay) + { + this.hide_error(); + this.spinner.show(delay); + }; + + this.elements = {}; + + // End-user can put anything they want in here. + this.misc_data = {}; + + // Keep track of last edit element so we can put the toolbox after it. + let last_element; + + for (const element_args of element_argss) + { + const element = {}; + element.id = element_args.id; + this.elements[element.id] = element; + + element.display = element_args.element; + element.empty_text = element_args.empty_text; + element.hide_when_empty = element_args.hide_when_empty; + element.autofocus = element_args.autofocus; + + if (editor.PARAGRAPH_TYPES.has(element.display.tagName)) + { + element.edit = document.createElement("textarea"); + element.edit.rows = 6; + } + else + { + element.edit = document.createElement("input"); + element.edit.type = "text"; + } + + element.edit.classList.add("editor_input"); + element.edit.classList.add("hidden"); + + if (element_args.placeholder !== undefined) + { + element.edit.placeholder = element_args.placeholder; + } + + element.display.parentElement.insertBefore(element.edit, element.display.nextSibling); + last_element = element.edit; + } + + this.binder = function(func, fallback) + { + /* + Given a function that takes an Editor as its first argument, + return a new function which requires no arguments and calls the + function with this editor. + + This is done so that the new function can be used in an event handler. + */ + if (func == undefined) + { + return fallback.bind(this); + } + + const bindable = () => func(this); + return bindable.bind(this); + } + + // In order to prevent page jumping on load, you can add an element with + // class editor_toolbox_placeholder to the page and size it so it matches + // the buttons that are going to get placed there. + const placeholders = document.getElementsByClassName("editor_toolbox_placeholder"); + for (const placeholder of placeholders) + { + placeholder.parentElement.removeChild(placeholder); + } + + const toolbox = document.createElement("div"); + toolbox.classList.add("editor_toolbox"); + last_element.parentElement.insertBefore(toolbox, last_element.nextSibling); + + this.open_button = document.createElement("button"); + this.open_button.innerText = "Edit"; + this.open_button.classList.add("editor_button"); + this.open_button.classList.add("editor_open_button"); + this.open_button.classList.add("green_button"); + this.open_button.onclick = this.binder(on_open, this.open); + toolbox.appendChild(this.open_button); + + this.save_button = document.createElement("button"); + this.save_button.innerText = "Save"; + this.save_button.classList.add("editor_button"); + this.save_button.classList.add("editor_save_button"); + this.save_button.classList.add("green_button"); + this.save_button.classList.add("hidden"); + this.save_button.onclick = this.binder(on_save, this.save); + toolbox.appendChild(this.save_button); + toolbox.appendChild(document.createTextNode(" ")); + + this.cancel_button = document.createElement("button"); + this.cancel_button.innerText = "Cancel"; + this.cancel_button.classList.add("editor_button"); + this.cancel_button.classList.add("editor_cancel_button"); + this.cancel_button.classList.add("gray_button"); + this.cancel_button.classList.add("hidden"); + this.cancel_button.onclick = this.binder(on_cancel, this.cancel); + toolbox.appendChild(this.cancel_button); + + this.error_message = document.createElement("span"); + this.error_message.classList.add("editor_error"); + this.error_message.classList.add("hidden"); + toolbox.appendChild(this.error_message); + + spinner_element = document.createElement("span"); + spinner_element.innerText = "Submitting..."; + spinner_element.classList.add("editor_spinner"); + spinner_element.classList.add("hidden"); + this.spinner = new spinners.Spinner(spinner_element); + toolbox.appendChild(spinner_element); + + for (const element of Object.values(this.elements)) + { + const ctrl_enter = element.edit.tagName == "TEXTAREA"; + common.bind_box_to_button(element.edit, this.save_button, ctrl_enter); + } +} diff --git a/voussoirkitjs/hotkeys.js b/voussoirkitjs/hotkeys.js new file mode 100644 index 0000000..3882cdf --- /dev/null +++ b/voussoirkitjs/hotkeys.js @@ -0,0 +1,110 @@ +const hotkeys = {}; + +hotkeys.HOTKEYS = {}; +hotkeys.HELPS = []; + +hotkeys.hotkey_identifier = +function hotkey_identifier(key, ctrlKey, shiftKey, altKey) +{ + // Return the string that will represent this hotkey in the dictionary. + return key.toLowerCase() + "." + (ctrlKey & 1) + "." + (shiftKey & 1) + "." + (altKey & 1); +} + +hotkeys.hotkey_human = +function hotkey_human(key, ctrlKey, shiftKey, altKey) +{ + // Return the string that will be displayed to the user to represent this hotkey. + let mods = []; + if (ctrlKey) { mods.push("CTRL"); } + if (shiftKey) { mods.push("SHIFT"); } + if (altKey) { mods.push("ALT"); } + mods = mods.join("+"); + if (mods) { mods = mods + "+"; } + return mods + key.toUpperCase(); +} + +hotkeys.register_help = +function register_help(help) +{ + hotkeys.HELPS.push(help); +} + +hotkeys.register_hotkey = +function register_hotkey(hotkey, action, description) +{ + if (! Array.isArray(hotkey)) + { + hotkey = hotkey.split(/\s+/g); + } + + const key = hotkey.pop(); + modifiers = hotkey.map(word => word.toLocaleLowerCase()); + const ctrlKey = modifiers.includes("control") || modifiers.includes("ctrl"); + const shiftKey = modifiers.includes("shift"); + const altKey = modifiers.includes("alt"); + + const identifier = hotkeys.hotkey_identifier(key, ctrlKey, shiftKey, altKey); + const human = hotkeys.hotkey_human(key, ctrlKey, shiftKey, altKey); + hotkeys.HOTKEYS[identifier] = {"action": action, "human": human, "description": description} +} + +hotkeys.should_prevent_hotkey = +function should_prevent_hotkey(event) +{ + /* + If the user is currently in an input element, then the registered hotkey + will be ignored and the browser will use its default behavior. + */ + if (event.target.tagName == "INPUT" && event.target.type == "checkbox") + { + return false; + } + else + { + return common.INPUT_TYPES.has(event.target.tagName); + } +} + +hotkeys.show_all_hotkeys = +function show_all_hotkeys() +{ + // Display an Alert with a list of all the hotkeys. + let lines = []; + for (const identifier in hotkeys.HOTKEYS) + { + const line = hotkeys.HOTKEYS[identifier]["human"] + " : " + hotkeys.HOTKEYS[identifier]["description"]; + lines.push(line); + } + if (hotkeys.HELPS) + { + lines.push(""); + } + for (const help of hotkeys.HELPS) + { + lines.push(help); + } + lines = lines.join("\n"); + alert(lines); +} + +hotkeys.hotkeys_listener = +function hotkeys_listener(event) +{ + // console.log(event.key); + if (hotkeys.should_prevent_hotkey(event)) + { + return; + } + + identifier = hotkeys.hotkey_identifier(event.key, event.ctrlKey, event.shiftKey, event.altKey); + //console.log(identifier); + if (identifier in hotkeys.HOTKEYS) + { + hotkeys.HOTKEYS[identifier]["action"](event); + event.preventDefault(); + } +} + +window.addEventListener("keydown", hotkeys.hotkeys_listener); + +hotkeys.register_hotkey("/", hotkeys.show_all_hotkeys, "Show hotkeys."); diff --git a/voussoirkitjs/http.js b/voussoirkitjs/http.js new file mode 100644 index 0000000..0b057a3 --- /dev/null +++ b/voussoirkitjs/http.js @@ -0,0 +1,228 @@ +const http = {}; + +http.HEADERS = {}; + +http.requests_in_flight = 0; + +http.request_queue = {}; +http.request_queue.array = []; + +http.request_queue.push = +function request_queue_push(func, kwargs) +{ + const delay = ((! kwargs) ? 0 : kwargs["delay"]) || 0; + http.request_queue.array.push(func) + setTimeout(http.request_queue.next, 0); +} + +http.request_queue.pushleft = +function request_queue_pushleft(func, kwargs) +{ + http.request_queue.array.unshift(func) + setTimeout(http.request_queue.next, 0); +} + +http.request_queue.clear = +function request_queue_clear() +{ + while (http.request_queue.array.length > 0) + { + http.request_queue.array.shift(); + } +} + +http.request_queue.next = +function request_queue_next() +{ + if (http.requests_in_flight > 0) + { + return; + } + if (http.request_queue.array.length === 0) + { + return; + } + const func = http.request_queue.array.shift(); + func(); +} + +http.formdata = +function formdata(data) +{ + const fd = new FormData(); + for (let [key, value] of Object.entries(data)) + { + if (value === undefined) + { + continue; + } + if (value === null) + { + value = ''; + } + fd.append(key, value); + } + return fd; +} + +http._request = +function _request(kwargs) +{ + /* + Perform an HTTP request and call the `callback` with the response. + + Required kwargs: + url + + Optional kwargs: + with_credentials: goes to xhr.withCredentials + callback + asynchronous: goes to the async parameter of xhr.open + headers: an object fully of {key: value} that will get added as headers in + addition to those in the global http.HEADERS. + data: the body of your post request. Can be a FormData object, a string, + or an object of {key: value} that will get automatically turned into + a FormData. + + The response will have the following structure: + { + "meta": { + "id": a large random number to uniquely identify this request. + "request": the XMLHttpRequest object, + "completed": true / false, + "status": If the connection failed or request otherwise could not + complete, `status` will be 0. If the request completed, + `status` will be the HTTP response code. + "json_ok": If the server responded with parseable json, `json_ok` + will be true, and that data will be in `response.data`. If the + server response was not parseable json, `json_ok` will be false + and `response.data` will be undefined. + "kwargs": The kwargs exactly as given to this call. + } + "data": {JSON parsed from server response if json_ok}, + "retry": function you can call to retry the request. + } + + So, from most lenient to most strict, error catching might look like: + if response.meta.completed + if response.meta.json_ok + if response.meta.status === 200 + if response.meta.status === 200 and response.meta.json_ok + */ + const request = new XMLHttpRequest(); + const response = { + "meta": { + "id": Math.random() * Number.MAX_SAFE_INTEGER, + "request": request, + "completed": false, + "status": 0, + "json_ok": false, + "kwargs": kwargs, + }, + "retry": function(){http._request(kwargs)}, + }; + + request.onreadystatechange = function() + { + /* + readystate values: + 0 UNSENT / ABORTED + 1 OPENED + 2 HEADERS_RECEIVED + 3 LOADING + 4 DONE + */ + if (request.readyState != 4) + { + return; + } + + http.requests_in_flight -= 1; + setTimeout(http.request_queue_next, 0); + + if (! (kwargs["callback"])) + { + return; + } + + response.meta.status = request.status; + + if (request.status != 0) + { + response.meta.completed = true; + try + { + response.data = JSON.parse(request.responseText); + response.meta.json_ok = true; + } + catch (exc) + { + response.meta.json_ok = false; + } + } + kwargs["callback"](response); + }; + + // Headers + + const asynchronous = "asynchronous" in kwargs ? kwargs["asynchronous"] : true; + request.open(kwargs["method"], kwargs["url"], asynchronous); + + for (const [header, value] of Object.entries(http.HEADERS)) + { + request.setRequestHeader(header, value); + } + + const more_headers = kwargs["headers"] || {}; + for (const [header, value] of Object.entries(more_headers)) + { + request.setRequestHeader(header, value); + } + + if (kwargs["with_credentials"]) + { + request.withCredentials = true; + } + + // Send + + let data = kwargs["data"]; + if (data === undefined || data === null) + { + request.send(); + } + else if (data instanceof FormData) + { + request.send(data); + } + else if (typeof(data) === "string" || data instanceof String) + { + request.send(data); + } + else + { + request.send(http.formdata(data)); + } + http.requests_in_flight += 1; + + return request; +} + +http.get = +function get(kwargs) +{ + kwargs["method"] = "GET"; + return http._request(kwargs); +} + +http.post = +function post(kwargs) +{ + /* + `data`: + a FormData object which you have already filled with values, or a + dictionary from which a FormData will be made, using http.formdata. + */ + kwargs["method"] = "POST"; + return http._request(kwargs); +} diff --git a/voussoirkitjs/spinners.js b/voussoirkitjs/spinners.js new file mode 100644 index 0000000..b79780b --- /dev/null +++ b/voussoirkitjs/spinners.js @@ -0,0 +1,201 @@ +const spinners = {}; + +/* +In general, spinners are used for functions that launch a callback, and the +callback will close the spinner after it runs. But, if your initial function +decides not to launch the callback (insufficient parameters, failed clientside +checks, etc.), you can have it return spinners.BAIL and the spinners will close +immediately. Of course, you're always welcome to use +spinners.close_button_spinner(button), but this return value means you don't +need to pull the button into a variable, as long as you weren't using the +return value anyway. +*/ +spinners.BAIL = "spinners.BAIL"; + +spinners.Spinner = +function Spinner(element) +{ + this.show = function(delay) + { + clearTimeout(this.delayed_showing_timeout); + + if (delay) + { + this.delayed_showing_timeout = setTimeout(function(thisthis){thisthis.show()}, delay, this); + } + else + { + this.delayed_showing_timeout = null; + this.element.classList.remove("hidden"); + } + } + + this.hide = function() + { + clearTimeout(this.delayed_showing_timeout); + this.delayed_showing_timeout = null; + + this.element.classList.add("hidden"); + } + + this.delayed_showing_timeout = null; + this.element = element; +} + +spinners.spinner_button_index = 0; +spinners.button_spinner_groups = {}; +/* +When a group member is closing, it will call the closer on all other members +in the group. Of course, this would recurse forever without some kind of +flagging, so this dict will hold group_id:true if a close is in progress, +and be empty otherwise. +*/ +spinners.spinner_group_closing = {}; + +spinners.add_to_spinner_group = +function add_to_spinner_group(group_id, button) +{ + if (!(group_id in spinners.button_spinner_groups)) + { + spinners.button_spinner_groups[group_id] = []; + } + spinners.button_spinner_groups[group_id].push(button); +} + +spinners.close_button_spinner = +function close_button_spinner(button) +{ + window[button.dataset.spinnerCloser](); +} + +spinners.close_grouped_spinners = +function close_grouped_spinners(group_id) +{ + if (group_id && !(spinners.spinner_group_closing[group_id])) + { + spinners.spinner_group_closing[group_id] = true; + for (const button of spinners.button_spinner_groups[group_id]) + { + window[button.dataset.spinnerCloser](); + } + delete spinners.spinner_group_closing[group_id]; + } +} + +spinners.open_grouped_spinners = +function open_grouped_spinners(group_id) +{ + for (const button of spinners.button_spinner_groups[group_id]) + { + window[button.dataset.spinnerOpener](); + } +} + +spinners.init_button_with_spinner = +function init_button_with_spinner() +{ + /* + To create a button that has a spinner, and cannot be clicked again while + the action is running, assign it the class "button_with_spinner". + When you're ready for the spinner to disappear, call + spinners.close_button_spinner(button). + + Optional: + data-spinner-id: If you want to use your own element as the spinner, + give its ID here. Otherwise a new one will be created. + + data-spinner-delay: The number of milliseconds to wait before the + spinner appears. For tasks that you expect to run very quickly, + this helps prevent a pointlessly short spinner. Note that the button + always becomes disabled immediately, and this delay only affects + the separate spinner element. + + data-holder-class: CSS class for the new span that holds the menu. + + data-spinner-group: An opaque string. All button_with_spinner that have + the same group will go into spinner mode when any of them is + clicked. Useful if you want to have two copies of a button on the + page, or two buttons which do opposite things and you only want one + to run at a time. + */ + const buttons = Array.from(document.getElementsByClassName("button_with_spinner")); + for (const button of buttons) + { + button.classList.remove("button_with_spinner"); + button.innerHTML = button.innerHTML.trim(); + + const holder = document.createElement("span"); + holder.classList.add("spinner_holder"); + holder.classList.add(button.dataset.holderClass || "spinner_holder"); + button.parentElement.insertBefore(holder, button); + holder.appendChild(button); + + if (button.dataset.spinnerGroup) + { + spinners.add_to_spinner_group(button.dataset.spinnerGroup, button); + } + + let spinner_element; + if (button.dataset.spinnerId) + { + spinner_element = document.getElementById(button.dataset.spinnerId); + spinner_element.classList.add("hidden"); + } + else + { + spinner_element = document.createElement("span"); + spinner_element.innerText = button.dataset.spinnerText || "Working..."; + spinner_element.classList.add("hidden"); + holder.appendChild(spinner_element); + } + + const spin = new spinners.Spinner(spinner_element); + const spin_delay = parseFloat(button.dataset.spinnerDelay) || 0; + + button.dataset.spinnerOpener = "spinner_opener_" + spinners.spinner_button_index; + window[button.dataset.spinnerOpener] = function spinner_opener() + { + spin.show(spin_delay); + button.disabled = true; + } + // It is expected that the function referenced by onclick will call + // spinners.close_button_spinner(button) when appropriate, since from + // our perspective we cannot be sure when to close the spinner. + button.dataset.spinnerCloser = "spinner_closer_" + spinners.spinner_button_index; + window[button.dataset.spinnerCloser] = function spinner_closer() + { + spinners.close_grouped_spinners(button.dataset.spinnerGroup); + spin.hide(); + button.disabled = false; + } + + const wrapped_onclick = button.onclick; + button.removeAttribute('onclick'); + button.onclick = function(event) + { + if (button.dataset.spinnerGroup) + { + spinners.open_grouped_spinners(button.dataset.spinnerGroup); + } + else + { + window[button.dataset.spinnerOpener](); + } + const ret = wrapped_onclick(event); + if (ret === spinners.BAIL) + { + window[button.dataset.spinnerCloser](); + } + return ret; + } + + spinners.spinner_button_index += 1; + } +} + +spinners.on_pageload = +function on_pageload() +{ + spinners.init_button_with_spinner(); +} +document.addEventListener("DOMContentLoaded", spinners.on_pageload);