forked from achmizs/deus-gaming
477 lines
16 KiB
JavaScript
477 lines
16 KiB
JavaScript
/***************************/
|
||
/* INITIALIZATION REGISTRY */
|
||
/***************************/
|
||
|
||
/* Polyfill for requestIdleCallback in Apple and Microsoft browsers. */
|
||
if (!window.requestIdleCallback) {
|
||
window.requestIdleCallback = (fn) => { setTimeout(fn, 0); };
|
||
}
|
||
|
||
/* TBC. */
|
||
GW.initializersDone = { };
|
||
GW.initializers = { };
|
||
function registerInitializer(name, tryEarly, precondition, fn) {
|
||
GW.initializersDone[name] = false;
|
||
GW.initializers[name] = fn;
|
||
let wrapper = function () {
|
||
if (GW.initializersDone[name]) return;
|
||
if (!precondition()) {
|
||
if (tryEarly) {
|
||
setTimeout(() => requestIdleCallback(wrapper, { timeout: 1000 }), 50);
|
||
} else {
|
||
document.addEventListener("readystatechange", wrapper, { once: true });
|
||
}
|
||
return;
|
||
}
|
||
GW.initializersDone[name] = true;
|
||
fn();
|
||
};
|
||
if (tryEarly) {
|
||
requestIdleCallback(wrapper, { timeout: 1000 });
|
||
} else {
|
||
document.addEventListener("readystatechange", wrapper, { once: true });
|
||
requestIdleCallback(wrapper);
|
||
}
|
||
}
|
||
function forceInitializer(name) {
|
||
if (GW.initializersDone[name]) return;
|
||
GW.initializersDone[name] = true;
|
||
GW.initializers[name]();
|
||
}
|
||
|
||
/***********/
|
||
/* COOKIES */
|
||
/***********/
|
||
|
||
/* Sets a cookie. */
|
||
function setCookie(name, value, days) {
|
||
var expires = "";
|
||
if (!days) days = 36500;
|
||
if (days) {
|
||
var date = new Date();
|
||
date.setTime(date.getTime() + (days*24*60*60*1000));
|
||
expires = "; expires=" + date.toUTCString();
|
||
}
|
||
document.cookie = name + "=" + (value || "") + expires + "; path=/";
|
||
}
|
||
|
||
/* Reads the value of named cookie.
|
||
Returns the cookie as a string, or null if no such cookie exists. */
|
||
function readCookie(name) {
|
||
var nameEQ = name + "=";
|
||
var ca = document.cookie.split(';');
|
||
for(var i = 0; i < ca.length; i++) {
|
||
var c = ca[i];
|
||
while (c.charAt(0)==' ') c = c.substring(1, c.length);
|
||
if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length, c.length);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/****************************************************/
|
||
/* CSS CLASS MANIPULATION (polyfill for .classList) */
|
||
/****************************************************/
|
||
|
||
Element.prototype.addClass = function(className) {
|
||
if (!this.hasClass(className))
|
||
this.className = (this.className + " " + className).trim();
|
||
}
|
||
Element.prototype.addClasses = function(classNames) {
|
||
let elementClassNames = this.className.trim().split(/\s/);
|
||
|
||
classNames.forEach(className => {
|
||
if (!this.hasClass(className))
|
||
elementClassNames.push(className);
|
||
});
|
||
|
||
this.className = elementClassNames.join(" ");
|
||
}
|
||
Element.prototype.removeClass = function(className) {
|
||
this.className = this.className.replace(new RegExp("(^|\\s+)" + className + "(\\s+|$)"), "$1").trim();
|
||
if (this.className == "") this.removeAttribute("class");
|
||
}
|
||
Element.prototype.removeClasses = function(classNames) {
|
||
classNames.forEach(className => {
|
||
this.className = this.className.replace(new RegExp("(^|\\s+)" + className + "(\\s+|$)"), "$1").trim();
|
||
});
|
||
if (this.className == "") this.removeAttribute("class");
|
||
}
|
||
Element.prototype.hasClass = function(className) {
|
||
return (new RegExp("(^|\\s+)" + className + "(\\s+|$)")).test(this.className);
|
||
}
|
||
Element.prototype.toggleClass = function(className, on) {
|
||
if ((typeof on == "undefined" && !this.hasClass(className)) ||
|
||
on == true) {
|
||
this.addClass(className);
|
||
} else {
|
||
this.removeClass(className);
|
||
}
|
||
}
|
||
|
||
/********************/
|
||
/* QUERYING THE DOM */
|
||
/********************/
|
||
|
||
function queryAll(selector, context) {
|
||
context = context || document;
|
||
// Redirect simple selectors to the more performant function
|
||
if (/^(#?[\w-]+|\.[\w-.]+)$/.test(selector)) {
|
||
switch (selector.charAt(0)) {
|
||
case '#':
|
||
// Handle ID-based selectors
|
||
let element = document.getElementById(selector.substr(1));
|
||
return element ? [ element ] : [ ];
|
||
case '.':
|
||
// Handle class-based selectors
|
||
// Query by multiple classes by converting the selector
|
||
// string into single spaced class names
|
||
var classes = selector.substr(1).replace(/\./g, ' ');
|
||
return [].slice.call(context.getElementsByClassName(classes));
|
||
default:
|
||
// Handle tag-based selectors
|
||
return [].slice.call(context.getElementsByTagName(selector));
|
||
}
|
||
}
|
||
// Default to `querySelectorAll`
|
||
return [].slice.call(context.querySelectorAll(selector));
|
||
}
|
||
function query(selector, context) {
|
||
let all = queryAll(selector, context);
|
||
return (all.length > 0) ? all[0] : null;
|
||
}
|
||
Object.prototype.queryAll = function (selector) {
|
||
return queryAll(selector, this);
|
||
}
|
||
Object.prototype.query = function (selector) {
|
||
return query(selector, this);
|
||
}
|
||
|
||
/*******************************/
|
||
/* EVENT LISTENER MANIPULATION */
|
||
/*******************************/
|
||
|
||
/* Adds an event listener to a button (or other clickable element), attaching
|
||
it to both ‘click’ and ‘keyup’ events (for use with keyboard navigation).
|
||
Optionally also attaches the listener to the ‘mousedown’ event, making the
|
||
element activate on mouse down instead of mouse up. */
|
||
Element.prototype.addActivateEvent = function(func, includeMouseDown) {
|
||
let ael = this.activateEventListener = (event) => { if (event.button === 0 || event.key === ' ') func(event) };
|
||
if (includeMouseDown) this.addEventListener("mousedown", ael);
|
||
this.addEventListener("click", ael);
|
||
this.addEventListener("keyup", ael);
|
||
}
|
||
|
||
/* Removes event listener from a clickable element, automatically detaching it
|
||
from all relevant event types. */
|
||
Element.prototype.removeActivateEvent = function() {
|
||
let ael = this.activateEventListener;
|
||
this.removeEventListener("mousedown", ael);
|
||
this.removeEventListener("click", ael);
|
||
this.removeEventListener("keyup", ael);
|
||
}
|
||
|
||
/* Adds a scroll event listener to the page. */
|
||
function addScrollListener(fn, name) {
|
||
let wrapper = (event) => {
|
||
requestAnimationFrame(() => {
|
||
fn(event);
|
||
document.addEventListener("scroll", wrapper, { once: true, passive: true });
|
||
});
|
||
}
|
||
document.addEventListener("scroll", wrapper, { once: true, passive: true });
|
||
|
||
// Retain a reference to the scroll listener, if a name is provided.
|
||
if (typeof name != "undefined")
|
||
GW[name] = wrapper;
|
||
}
|
||
|
||
/************************/
|
||
/* ACTIVE MEDIA QUERIES */
|
||
/************************/
|
||
|
||
/* This function provides two slightly different versions of its functionality,
|
||
depending on how many arguments it gets.
|
||
|
||
If one function is given (in addition to the media query and its name), it
|
||
is called whenever the media query changes (in either direction).
|
||
|
||
If two functions are given (in addition to the media query and its name),
|
||
then the first function is called whenever the media query starts matching,
|
||
and the second function is called whenever the media query stops matching.
|
||
|
||
If you want to call a function for a change in one direction only, pass an
|
||
empty closure (NOT null!) as one of the function arguments.
|
||
|
||
There is also an optional fifth argument. This should be a function to be
|
||
called when the active media query is canceled.
|
||
*/
|
||
function doWhenMatchMedia(mediaQuery, name, ifMatchesOrAlwaysDo, otherwiseDo = null, whenCanceledDo = null) {
|
||
if (typeof GW.mediaQueryResponders == "undefined")
|
||
GW.mediaQueryResponders = { };
|
||
|
||
let mediaQueryResponder = (event, canceling = false) => {
|
||
if (canceling) {
|
||
GWLog(`Canceling media query “${name}”`);
|
||
|
||
if (whenCanceledDo != null)
|
||
whenCanceledDo(mediaQuery);
|
||
} else {
|
||
let matches = (typeof event == "undefined") ? mediaQuery.matches : event.matches;
|
||
|
||
GWLog(`Media query “${name}” triggered (matches: ${matches ? "YES" : "NO"})`);
|
||
|
||
if (otherwiseDo == null || matches) ifMatchesOrAlwaysDo(mediaQuery);
|
||
else otherwiseDo(mediaQuery);
|
||
}
|
||
};
|
||
mediaQueryResponder();
|
||
mediaQuery.addListener(mediaQueryResponder);
|
||
|
||
GW.mediaQueryResponders[name] = mediaQueryResponder;
|
||
}
|
||
|
||
/* Deactivates and discards an active media query, after calling the function
|
||
that was passed as the whenCanceledDo parameter when the media query was
|
||
added.
|
||
*/
|
||
function cancelDoWhenMatchMedia(name) {
|
||
GW.mediaQueryResponders[name](null, true);
|
||
|
||
for ([ key, mediaQuery ] of Object.entries(GW.mediaQueries))
|
||
mediaQuery.removeListener(GW.mediaQueryResponders[name]);
|
||
|
||
GW.mediaQueryResponders[name] = null;
|
||
}
|
||
|
||
/****************/
|
||
/* MISC HELPERS */
|
||
/****************/
|
||
|
||
/* Returns the passed object if it’s truthy, or a newly created HTMLElement.
|
||
Æ(x) is the element analogue of (x||{}).
|
||
*/
|
||
function Æ(x) {
|
||
return x || document.createElement(null);
|
||
}
|
||
|
||
/* If top of element is not at or above the top of the screen, scroll it into
|
||
view. */
|
||
Element.prototype.scrollIntoViewIfNeeded = function() {
|
||
GWLog("scrollIntoViewIfNeeded");
|
||
let rect = this.getBoundingClientRect();
|
||
if ((rect.bottom > window.innerHeight && rect.top > 0) ||
|
||
rect.top < 0) {
|
||
this.scrollIntoView(true);
|
||
}
|
||
}
|
||
|
||
/* Return the currently selected text, as HTML (rather than unstyled text).
|
||
*/
|
||
function getSelectionHTML() {
|
||
var container = document.createElement("div");
|
||
container.appendChild(window.getSelection().getRangeAt(0).cloneContents());
|
||
return container.innerHTML;
|
||
}
|
||
|
||
/* Return the value of a GET (i.e., URL) parameter.
|
||
*/
|
||
function getQueryVariable(variable) {
|
||
var query = window.location.search.substring(1);
|
||
var vars = query.split("&");
|
||
for (var i = 0; i < vars.length; i++) {
|
||
var pair = vars[i].split("=");
|
||
if (pair[0] == variable)
|
||
return pair[1];
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/* Given an element or a selector, removes that element (or the element
|
||
identified by the selector).
|
||
|
||
If multiple elements match the selector, only the first is removed.
|
||
*/
|
||
function removeElement(elementOrSelector, ancestor = document) {
|
||
if (typeof elementOrSelector == "string") elementOrSelector = ancestor.query(elementOrSelector);
|
||
if (elementOrSelector) elementOrSelector.parentElement.removeChild(elementOrSelector);
|
||
}
|
||
|
||
/* Returns true if the string begins with the given prefix.
|
||
*/
|
||
String.prototype.hasPrefix = function (prefix) {
|
||
return (this.lastIndexOf(prefix, 0) === 0);
|
||
}
|
||
|
||
/* Toggles whether the page is scrollable.
|
||
*/
|
||
function togglePageScrolling(enable) {
|
||
if (!enable) {
|
||
window.addEventListener('keydown', GW.scrollingDisabledKeyDown = (event) => {
|
||
let forbiddenKeys = [ " ", "Spacebar", "ArrowUp", "ArrowDown", "Up", "Down" ];
|
||
if (forbiddenKeys.contains(event.key) &&
|
||
event.target == document.body) {
|
||
event.preventDefault();
|
||
}
|
||
});
|
||
} else {
|
||
window.removeEventListener('keydown', GW.scrollingDisabledKeyDown);
|
||
}
|
||
}
|
||
|
||
/* Copies a string to the clipboard.
|
||
*/
|
||
function copyTextToClipboard(string) {
|
||
let scratchpad = query("#scratchpad");
|
||
scratchpad.value = string;
|
||
scratchpad.select();
|
||
document.execCommand("copy");
|
||
}
|
||
|
||
/* Returns the next element sibling of the element, wrapping around to the
|
||
first child of the parent if the element is the last child.
|
||
Returns the element itself, if it has no siblings or no parent.
|
||
*/
|
||
Element.prototype.nextElementSiblingCyclical = function() {
|
||
if (this.parentElement == null) return this;
|
||
|
||
return this.parentElement.children[(Array.prototype.indexOf.call(this.parentElement.children, this) + 1) % this.parentElement.children.length];
|
||
}
|
||
|
||
/* Returns the previous element sibling of the element, wrapping around to the
|
||
last child of the parent if the element is the first child.
|
||
Returns the element itself, if it has no siblings or no parent.
|
||
*/
|
||
Element.prototype.previousElementSiblingCyclical = function() {
|
||
if (this.parentElement == null) return this;
|
||
|
||
return this.parentElement.children[(Array.prototype.indexOf.call(this.parentElement.children, this) - 1) % this.parentElement.children.length];
|
||
}
|
||
|
||
/********************/
|
||
/* DEBUGGING OUTPUT */
|
||
/********************/
|
||
|
||
function GWLog (string) {
|
||
if (GW.loggingEnabled == true || (GW.loggingEnabled == null && localStorage.getItem("logging-enabled") == "true"))
|
||
console.log(string);
|
||
}
|
||
GW.enableLogging = (permanently = false) => {
|
||
if (permanently)
|
||
localStorage.setItem("logging-enabled", "true");
|
||
else
|
||
GW.loggingEnabled = true;
|
||
};
|
||
GW.disableLogging = (permanently = false) => {
|
||
if (permanently)
|
||
localStorage.removeItem("logging-enabled");
|
||
else
|
||
GW.loggingEnabled = false;
|
||
};
|
||
|
||
/**********/
|
||
/* NAV UI */
|
||
/**********/
|
||
|
||
/* Hide the site nav UI on scroll down; show it on scroll up.
|
||
|
||
Called by the ‘updateSiteNavUIStateScrollListener’ scroll listener.
|
||
*/
|
||
function updateSiteNavUIState(event) {
|
||
GWLog("updateSiteNavUIState");
|
||
|
||
let newScrollTop = window.pageYOffset || document.documentElement.scrollTop;
|
||
GW.scrollState.unbrokenDownScrollDistance = (newScrollTop > GW.scrollState.lastScrollTop) ?
|
||
(GW.scrollState.unbrokenDownScrollDistance + newScrollTop - GW.scrollState.lastScrollTop) :
|
||
0;
|
||
GW.scrollState.unbrokenUpScrollDistance = (newScrollTop < GW.scrollState.lastScrollTop) ?
|
||
(GW.scrollState.unbrokenUpScrollDistance + GW.scrollState.lastScrollTop - newScrollTop) :
|
||
0;
|
||
GW.scrollState.lastScrollTop = newScrollTop;
|
||
|
||
// Hide site nav UI when scrolling a full page down.
|
||
if (GW.scrollState.unbrokenDownScrollDistance > window.innerHeight) {
|
||
if (!GW.scrollState.siteNavUI[0].hasClass("hidden")) toggleSiteNavUI();
|
||
}
|
||
|
||
// Make site nav UI translucent when scrolling down.
|
||
GW.scrollState.siteNavUI.forEach(element => {
|
||
if (GW.scrollState.unbrokenDownScrollDistance > 0) element.addClass("translucent-on-scroll");
|
||
else element.removeClass("hidden");
|
||
});
|
||
|
||
// On desktop, show site nav UI when scrolling a full page up, or to the
|
||
// the top of the page.
|
||
// On mobile, show site nav UI on ANY scroll up.
|
||
if (GW.mediaQueries.mobileNarrow.matches) {
|
||
if (GW.scrollState.unbrokenUpScrollDistance > 0 || GW.scrollState.lastScrollTop <= 0)
|
||
showSiteNavUI();
|
||
} else if ( GW.scrollState.unbrokenUpScrollDistance > window.innerHeight
|
||
|| GW.scrollState.lastScrollTop == 0) {
|
||
showSiteNavUI();
|
||
}
|
||
}
|
||
|
||
function toggleSiteNavUI() {
|
||
GWLog("toggleSiteNavUI");
|
||
|
||
GW.scrollState.siteNavUI.forEach(element => {
|
||
element.toggleClass("hidden");
|
||
element.removeClass("translucent-on-scroll");
|
||
});
|
||
}
|
||
|
||
function showSiteNavUI() {
|
||
GWLog("showSiteNavUI");
|
||
|
||
GW.scrollState.siteNavUI.forEach(element => {
|
||
element.removeClass("hidden");
|
||
element.removeClass("translucent-on-scroll");
|
||
});
|
||
}
|
||
|
||
/******************/
|
||
/* INITIALIZATION */
|
||
/******************/
|
||
|
||
registerInitializer('earlyInitialize', true, () => (query("#content") != null), () => {
|
||
GWLog("INITIALIZER earlyInitialize");
|
||
// Check to see whether we’re on a mobile device (which we define as a touchscreen^W narrow viewport).
|
||
// GW.isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||
// GW.isMobile = ('ontouchstart' in document.documentElement);
|
||
GW.mediaQueries = {
|
||
mobileNarrow: matchMedia("(max-width: 520px)"),
|
||
mobileWide: matchMedia("(max-width: 900px)"),
|
||
mobileMax: matchMedia("(max-width: 960px)"),
|
||
hover: matchMedia("only screen and (hover: hover) and (pointer: fine)")
|
||
};
|
||
GW.isMobile = GW.mediaQueries.mobileMax.matches;
|
||
GW.isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
|
||
|
||
GW.siteNavUISelector = "main nav";
|
||
});
|
||
|
||
/*************************/
|
||
/* POST-LOAD ADJUSTMENTS */
|
||
/*************************/
|
||
|
||
registerInitializer('pageLayoutFinished', false, () => (document.readyState == "complete"), () => {
|
||
GWLog("INITIALIZER pageLayoutFinished");
|
||
|
||
forceInitializer('earlyInitialize');
|
||
|
||
// We pre-query the relevant elements, so we don’t have to run queryAll on
|
||
// every firing of the scroll listener.
|
||
GW.scrollState = {
|
||
"lastScrollTop": window.pageYOffset || document.documentElement.scrollTop,
|
||
"unbrokenDownScrollDistance": 0,
|
||
"unbrokenUpScrollDistance": 0,
|
||
"siteNavUI": queryAll(GW.siteNavUISelector),
|
||
};
|
||
addScrollListener(updateSiteNavUIState, "updateSiteNavUIStateScrollListener");
|
||
GW.scrollState.siteNavUI.forEach(element => {
|
||
element.addEventListener("mouseover", () => { showSiteNavUI(); });
|
||
});
|
||
});
|
||
|