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:
parent
b4ec72fb53
commit
f522ffd54b
6 changed files with 1414 additions and 0 deletions
575
voussoirkitjs/common.js
Normal file
575
voussoirkitjs/common.js
Normal 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);
|
48
voussoirkitjs/contextmenus.js
Normal file
48
voussoirkitjs/contextmenus.js
Normal 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
252
voussoirkitjs/editor.js
Normal 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
110
voussoirkitjs/hotkeys.js
Normal 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
228
voussoirkitjs/http.js
Normal 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
201
voussoirkitjs/spinners.js
Normal 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);
|
Loading…
Reference in a new issue