Add voussoirkitjs folder.

All of the javascript that I use on my three main web projects.
This can now be the canonical edition of the files.
This commit is contained in:
voussoir 2022-10-12 18:37:11 -07:00
parent b4ec72fb53
commit f522ffd54b
No known key found for this signature in database
GPG key ID: 5F7554F8C26DACCB
6 changed files with 1414 additions and 0 deletions

575
voussoirkitjs/common.js Normal file
View file

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

View file

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

252
voussoirkitjs/editor.js Normal file
View file

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

110
voussoirkitjs/hotkeys.js Normal file
View file

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

228
voussoirkitjs/http.js Normal file
View file

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

201
voussoirkitjs/spinners.js Normal file
View file

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