bring remote assets to local
This commit is contained in:
1178
web/v2/base.css
Normal file
1178
web/v2/base.css
Normal file
File diff suppressed because it is too large
Load Diff
810
web/v2/base.js
Normal file
810
web/v2/base.js
Normal file
@ -0,0 +1,810 @@
|
||||
|
||||
/**
|
||||
* A shortcut for `*.querySelector()`.
|
||||
* @param {string} id The target query selector
|
||||
* @param {HTMLElement} ancestor The ancestor element to start from
|
||||
* @returns {HTMLElement|undefined} The selected element
|
||||
*/
|
||||
function $(selector, ancestor = document) {
|
||||
return ancestor.querySelector(selector);
|
||||
}
|
||||
/**
|
||||
* A shortcut for `*.querySelectorAll()`.
|
||||
* @param {string} id The target query selector
|
||||
* @param {HTMLElement} ancestor The ancestor element to start from
|
||||
* @returns {NodeListOf<any>} The selected elements
|
||||
*/
|
||||
function $$(selector, ancestor = document) {
|
||||
return ancestor.querySelectorAll(selector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Positions an element at the given coordinates, ensuring it stays within the window.
|
||||
* @param {HTMLElement} element The element
|
||||
* @param {number} x Left
|
||||
* @param {number} y Top
|
||||
*/
|
||||
function positionElement(element, x, y) {
|
||||
const elementWidth = element.offsetWidth;
|
||||
const elementHeight = element.offsetHeight;
|
||||
const windowWidth = window.innerWidth;
|
||||
const windowHeight = window.innerHeight;
|
||||
if (x + elementWidth > windowWidth) {
|
||||
x = x - elementWidth;
|
||||
if (x < 0) x = 0;
|
||||
}
|
||||
if (y + elementHeight > windowHeight) {
|
||||
y = y - elementHeight;
|
||||
if (y < 0) y = 0;
|
||||
}
|
||||
element.style.left = x + 'px';
|
||||
element.style.top = y + 'px';
|
||||
}
|
||||
|
||||
function isElementVisible(el) {
|
||||
if (!el || typeof el.getBoundingClientRect !== 'function') {
|
||||
return false;
|
||||
}
|
||||
const rect = el.getBoundingClientRect();
|
||||
return !!(rect.width || rect.height);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a popup
|
||||
*/
|
||||
class PopupBuilder {
|
||||
#shouldEscapeClose = true;
|
||||
#elCont; #elTitle; #elBody; #elActions;
|
||||
#onShow = () => {};
|
||||
#onHide = () => {};
|
||||
constructor() {
|
||||
// Create container
|
||||
this.#elCont = document.createElement('div');
|
||||
this.#elCont.classList.add('popupCont');
|
||||
// Create popup element
|
||||
this.el = document.createElement('div');
|
||||
this.el.classList.add('popup');
|
||||
this.#elCont.appendChild(this.el);
|
||||
// Add title
|
||||
this.#elTitle = document.createElement('div');
|
||||
this.#elTitle.classList.add('title', 'flex-no-shrink');
|
||||
this.#elTitle.innerText = 'Popup';
|
||||
this.el.appendChild(this.#elTitle);
|
||||
// Add body
|
||||
this.#elBody = document.createElement('div');
|
||||
this.#elBody.classList.add('body');
|
||||
this.el.appendChild(this.#elBody);
|
||||
// Add actions container
|
||||
this.#elActions = document.createElement('div');
|
||||
this.#elActions.classList.add('actions', 'flex-no-shrink');
|
||||
this.el.appendChild(this.#elActions);
|
||||
// Set up listeners
|
||||
this.#elCont.addEventListener('click', () => {
|
||||
if (this.#shouldEscapeClose) this.hide();
|
||||
});
|
||||
this.el.addEventListener('click', e => e.stopPropagation());
|
||||
// Set up focus trap
|
||||
this.trap = null;
|
||||
try {
|
||||
this.trap = focusTrap.createFocusTrap(this.#elCont, {
|
||||
initialFocus: false,
|
||||
escapeDeactivates: false
|
||||
});
|
||||
} catch(error) {}
|
||||
}
|
||||
/**
|
||||
* Sets the title of the popup.
|
||||
* @param {string} title The title of the popup
|
||||
* @returns {PopupBuilder}
|
||||
*/
|
||||
setTitle(title) {
|
||||
this.#elTitle.innerText = title;
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Adds an HTML element to the body of the popup.
|
||||
* @param {HTMLElement} element The element
|
||||
* @returns {PopupBuilder}
|
||||
*/
|
||||
addBody(element) {
|
||||
this.#elBody.appendChild(element);
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Appends raw HTML to the body of the popup.
|
||||
* @param {string} html An HTML string
|
||||
* @returns {PopupBuilder}
|
||||
*/
|
||||
addBodyHTML(html) {
|
||||
this.#elBody.insertAdjacentHTML('beforeend', html);
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Sets if clicking outside the popup or pressing escape will close the popup. Defaults to `true`.
|
||||
* @param {boolean} shouldClose
|
||||
* @returns {PopupBuilder}
|
||||
*/
|
||||
setClickOutside(shouldClose) {
|
||||
this.#shouldEscapeClose = shouldClose;
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Sets a callback function to run when the popup is shown.
|
||||
* @param {callback} onShow The callback to run
|
||||
* @returns {PopupBuilder}
|
||||
*/
|
||||
setOnShow(onShow) {
|
||||
this.#onShow = onShow;
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Sets a callback function to run when the popup is hidden.
|
||||
* @param {callback} onHide The callback to run
|
||||
* @returns {PopupBuilder}
|
||||
*/
|
||||
setOnHide(onHide) {
|
||||
this.#onHide = onHide;
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Shows the popup.
|
||||
*/
|
||||
show() {
|
||||
document.body.appendChild(this.#elCont);
|
||||
setTimeout(() => {
|
||||
this.#elCont.classList.add('visible');
|
||||
try {
|
||||
this.trap.activate();
|
||||
} catch (error) {
|
||||
console.warn(`Unable to trap focus inside popup. Make sure focus-trap and tabbable are available.`);
|
||||
}
|
||||
this.#onShow();
|
||||
}, 1);
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Hides the popup and removes it from the DOM.
|
||||
*/
|
||||
hide() {
|
||||
this.#elCont.classList.remove('visible');
|
||||
try {
|
||||
this.trap.deactivate();
|
||||
} catch (error) {}
|
||||
setTimeout(() => {
|
||||
this.#elCont.parentNode.removeChild(this.#elCont);
|
||||
}, 200);
|
||||
this.#onHide();
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* @callback PopupBuilderAddActionCallback
|
||||
* @param {PopupActionBuilder} actionBuilder An action builder to be modified and returned.
|
||||
*/
|
||||
/**
|
||||
* Adds an action button to the bottom of the popup. These buttons are displayed from right to left.
|
||||
* @param {PopupBuilderAddActionCallback} callback A callback function that returns a `PopupActionBuilder` object. This callback is passed a new `PopupActionBuilder` object that can be modified and returned.
|
||||
*/
|
||||
addAction(callback) {
|
||||
const builder = new PopupActionBuilder(this);
|
||||
const action = callback(builder);
|
||||
this.#elActions.appendChild(action.el);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Builds a popup action button
|
||||
*/
|
||||
class PopupActionBuilder {
|
||||
#shouldClose = true;
|
||||
#onClick = () => {};
|
||||
/**
|
||||
* @param {PopupBuilder} parent The parent popup builder.
|
||||
*/
|
||||
constructor(parent) {
|
||||
this.parent = parent;
|
||||
this.el = document.createElement('button');
|
||||
this.el.classList.add('btn', 'secondary');
|
||||
this.el.innerText = `Button`;
|
||||
this.el.addEventListener('click', () => {
|
||||
if (this.el.disabled) return;
|
||||
this.#onClick();
|
||||
if (this.#shouldClose) parent.hide();
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Sets whether this action button should use the primary button style. Defaults to `false`.
|
||||
* @param {boolean} isPrimary
|
||||
* @returns {PopupActionBuilder}
|
||||
*/
|
||||
setIsPrimary(isPrimary) {
|
||||
if (isPrimary) this.el.classList.remove('secondary');
|
||||
else this.el.classList.add('secondary');
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Sets whether this action button should use the danger button style. Defaults to `false`.
|
||||
* @param {boolean} isDanger
|
||||
* @returns {PopupActionBuilder}
|
||||
*/
|
||||
setIsDanger(isDanger) {
|
||||
if (isDanger) this.el.classList.add('danger');
|
||||
else this.el.classList.remove('danger');
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Sets whether clicking this action should close the popup. Defaults to `true`.
|
||||
* @param {boolean} shouldClose
|
||||
* @returns {PopupActionBuilder}
|
||||
*/
|
||||
setShouldClose(shouldClose) {
|
||||
this.#shouldClose = shouldClose;
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Sets the label of this action button.
|
||||
* @param {string} label The button's label
|
||||
* @returns {PopupActionBuilder}
|
||||
*/
|
||||
setLabel(label) {
|
||||
this.el.innerText = label;
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Disables the action button.
|
||||
* @returns {PopupActionBuilder}
|
||||
*/
|
||||
disable() {
|
||||
this.el.disabled = true;
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Enables the action button.
|
||||
* @returns {PopupActionBuilder}
|
||||
*/
|
||||
enable() {
|
||||
this.el.disabled = false;
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Sets a callback to be run when this action is clicked.
|
||||
* @param {callback} onClick The callback
|
||||
* @returns {PopupActionBuilder}
|
||||
*/
|
||||
setClickHandler(onClick) {
|
||||
this.#onClick = onClick;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a context menu
|
||||
*/
|
||||
class ContextMenuBuilder {
|
||||
#elCont;
|
||||
constructor() {
|
||||
// Create container
|
||||
this.#elCont = document.createElement('div');
|
||||
this.#elCont.classList.add('contextCont');
|
||||
// Create popup element
|
||||
this.el = document.createElement('div');
|
||||
this.el.classList.add('context');
|
||||
this.#elCont.appendChild(this.el);
|
||||
// Hide when clicking outside
|
||||
this.#elCont.addEventListener('click', () => {
|
||||
this.hide();
|
||||
});
|
||||
this.el.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
// Create focus trap
|
||||
try {
|
||||
this.trap = focusTrap.createFocusTrap(this.#elCont, {
|
||||
initialFocus: false,
|
||||
escapeDeactivates: false
|
||||
});
|
||||
} catch(error) {}
|
||||
}
|
||||
/**
|
||||
* @callback ContextMenuBuilderAddItemCallback
|
||||
* @param {ContextMenuItemBuilder} itemBuilder An item builder to be modified and returned.
|
||||
*/
|
||||
/**
|
||||
* Adds a clickable item to the context menu.
|
||||
* @param {ContextMenuBuilderAddItemCallback} callback A callback function that returns a `ContextMenuItemBuilder` object. This callback is passed a new `ContextMenuItemBuilder` object that can be modified and returned.
|
||||
* @returns {ContextMenuBuilder}
|
||||
*/
|
||||
addItem(callback) {
|
||||
const builder = new ContextMenuItemBuilder(this);
|
||||
const item = callback(builder);
|
||||
this.el.appendChild(item.el);
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Adds a separator to the context menu.
|
||||
* @returns {ContextMenuBuilder}
|
||||
*/
|
||||
addSeparator() {
|
||||
const el = document.createElement('div');
|
||||
el.classList.add('separator');
|
||||
this.el.appendChild(el);
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Sets if menu item icons should be shown. If `true`, the space an icon takes up is still visible even on items without set icons. If `false`, all item icons are hidden, regardless of whether they're set or not. Defaults to `true`.
|
||||
* @param {boolean} areVisible The new state
|
||||
* @returns {ContextMenuBuilder}
|
||||
*/
|
||||
setIconVisibility(areVisible) {
|
||||
this.el.classList.toggle('hideIcons', !areVisible);
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Shows the context menu at the specified fixed coordinates. This method is used by the other show methods.
|
||||
* @param {number} x Left
|
||||
* @param {number} y Top
|
||||
*/
|
||||
showAtCoords(x, y) {
|
||||
document.body.appendChild(this.#elCont);
|
||||
this.#elCont.classList.remove('ani');
|
||||
this.#elCont.classList.remove('visible');
|
||||
setTimeout(() => {
|
||||
this.el.style.scale = '1';
|
||||
positionElement(this.el, x, y);
|
||||
setTimeout(() => {
|
||||
this.#elCont.classList.add('ani');
|
||||
setTimeout(() => {
|
||||
this.#elCont.classList.add('visible');
|
||||
try {
|
||||
this.trap.activate();
|
||||
} catch (error) {
|
||||
console.warn(`Unable to trap focus inside context menu. Make sure focus-trap and tabbable are available.`);
|
||||
}
|
||||
}, 1);
|
||||
}, 10);
|
||||
}, 1);
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Shows the context menu originating from the center of an HTML element.
|
||||
* @param {HTMLElement} el The target element
|
||||
* @returns {ContextMenuBuilder}
|
||||
*/
|
||||
showAtElement(el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
return this.showAtCoords((rect.left+(rect.width/2)), (rect.top+(rect.height/2)));
|
||||
}
|
||||
/**
|
||||
* Shows the context menu at the current mouse position, or fixed in the top left if there hasn't been any mouse movement.
|
||||
* @returns {ContextMenuBuilder}
|
||||
*/
|
||||
showAtCursor() {
|
||||
return this.showAtCoords(mouse.x+5, mouse.y);
|
||||
}
|
||||
/**
|
||||
* Hides the context menu and removes it from the DOM.
|
||||
*/
|
||||
hide() {
|
||||
this.#elCont.classList.remove('visible');
|
||||
try {
|
||||
this.trap.deactivate();
|
||||
} catch (error) {}
|
||||
setTimeout(() => {
|
||||
this.#elCont.parentNode.removeChild(this.#elCont);
|
||||
}, 200);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Builds a clickable context menu item
|
||||
*/
|
||||
class ContextMenuItemBuilder {
|
||||
#onClick = () => {};
|
||||
constructor(parent) {
|
||||
this.parent = parent;
|
||||
// Create item container
|
||||
this.el = document.createElement('button');
|
||||
this.el.classList.add('item');
|
||||
// Create icon
|
||||
this.elIcon = document.createElement('div');
|
||||
this.elIcon.classList.add('icon', 'hidden');
|
||||
this.el.appendChild(this.elIcon);
|
||||
// Create label
|
||||
this.elLabel = document.createElement('div');
|
||||
this.elLabel.classList.add('label');
|
||||
this.elLabel.innerText = `Item`;
|
||||
this.el.appendChild(this.elLabel);
|
||||
// Set up click handler
|
||||
this.el.addEventListener('click', () => {
|
||||
if (this.el.disabled) return;
|
||||
this.#onClick();
|
||||
parent.hide();
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Sets the label for this menu item.
|
||||
* @param {string} label The item's label
|
||||
* @returns {ContextMenuItemBuilder}
|
||||
*/
|
||||
setLabel(label) {
|
||||
this.elLabel.innerText = label;
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Sets the icon for this item. If unset, no icon will be shown.
|
||||
* @param {string} icon A valid [Material Symbol](https://fonts.google.com/icons) string
|
||||
*
|
||||
* To make sure you're getting the right symbol, click on the icon, go to the **Android** tab, and copy the string in the code block.
|
||||
* @returns {ContextMenuItemBuilder}
|
||||
*/
|
||||
setIcon(icon) {
|
||||
this.elIcon.classList.remove('hidden');
|
||||
this.elIcon.innerText = icon;
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Sets hover tooltip text for this item.
|
||||
* @param {string} text The tooltip text
|
||||
* @returns {ContextMenuItemBuilder}
|
||||
*/
|
||||
setTooltip(text) {
|
||||
this.el.title = text;
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Sets whether or not the icon and text of this item should be red.
|
||||
* @param {boolean} isDanger
|
||||
* @returns {ContextMenuItemBuilder}
|
||||
*/
|
||||
setIsDanger(isDanger) {
|
||||
this.elIcon.classList.toggle('text-danger', isDanger);
|
||||
this.elLabel.classList.toggle('text-danger', isDanger);
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Disables this menu item.
|
||||
* @returns {ContextMenuItemBuilder}
|
||||
*/
|
||||
disable() {
|
||||
this.el.disabled = true;
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Enables this menu item.
|
||||
* @returns {ContextMenuItemBuilder}
|
||||
*/
|
||||
enable() {
|
||||
this.el.disabled = false;
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Sets a callback to be run when this item is clicked.
|
||||
* @param {callback} onClick The callback
|
||||
* @returns {ContextMenuItemBuilder}
|
||||
*/
|
||||
setClickHandler(onClick) {
|
||||
this.#onClick = onClick;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes a toast notification overlay container.
|
||||
*/
|
||||
class ToastOverlay {
|
||||
/**
|
||||
* @param {('left'|'center'|'right')} hAlign Horizontal alignment
|
||||
* @param {('top'|'bottom')} vAlign Vertical alignment
|
||||
*/
|
||||
constructor(hAlign = 'left', vAlign = 'bottom') {
|
||||
this.el = document.createElement('div');
|
||||
this.el.classList.add('toastOverlay', 'col', 'gap-10');
|
||||
if (hAlign == 'left') this.el.classList.add('align-start');
|
||||
if (hAlign == 'center') this.el.classList.add('align-center');
|
||||
if (hAlign == 'right') this.el.classList.add('align-end');
|
||||
if (vAlign == 'top') this.el.classList.add('justify-start');
|
||||
if (vAlign == 'bottom') {
|
||||
this.el.classList.add('justify-start');
|
||||
this.el.style.flexDirection = 'column-reverse';
|
||||
}
|
||||
document.body.appendChild(this.el);
|
||||
}
|
||||
/**
|
||||
* @callback ToastOverlayShowCallback
|
||||
* @param {ToastBuilder} itemBuilder An item builder to be modified and returned.
|
||||
*/
|
||||
/**
|
||||
* Shows a new toast notification.
|
||||
* @param {ToastOverlayShowCallback} callback A callback function that returns a `ToastBuilder` object. This callback is passed a new `ToastBuilder` object that can be modified and returned.
|
||||
* @returns {ToastOverlay}
|
||||
*/
|
||||
showToast(callback) {
|
||||
const builder = new ToastBuilder(this);
|
||||
const toast = callback(builder);
|
||||
this.el.insertAdjacentElement('afterbegin', toast.el);
|
||||
setTimeout(() => {
|
||||
const delay = toast.el.dataset.delay;
|
||||
if (delay) setTimeout(() => {
|
||||
toast.close();
|
||||
}, delay);
|
||||
toast.el.classList.add('visible');
|
||||
if (delay) setTimeout(close, delay);
|
||||
}, 100);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Builds a toast notification. This class is to be used alongside the `ToastOverlay` class.
|
||||
*/
|
||||
class ToastBuilder {
|
||||
constructor() {
|
||||
this.el = document.createElement('div');
|
||||
this.el.classList.add('toast', 'row', 'gap-10', 'align-center');
|
||||
this.elIcon = document.createElement('div');
|
||||
this.elIcon.classList.add('icon', 'hidden');
|
||||
this.el.appendChild(this.elIcon);
|
||||
this.elBody = document.createElement('div');
|
||||
this.elBody.classList.add('body');
|
||||
this.elBody.innerText = 'Toast notification';
|
||||
this.el.appendChild(this.elBody);
|
||||
this.elClose = document.createElement('button');
|
||||
this.elClose.classList.add('btn', 'secondary', 'iconOnly', 'small', 'close');
|
||||
this.elClose.innerHTML = '<div class="icon">close</div>';
|
||||
this.elClose.title = 'Dismiss';
|
||||
this.elClose.addEventListener('click', () => this.close());
|
||||
this.el.appendChild(this.elClose);
|
||||
this.el.dataset.delay = 5000;
|
||||
}
|
||||
/**
|
||||
* Sets the icon to show on the left of the toast.
|
||||
* @param {string} icon A valid [Material Symbol](https://fonts.google.com/icons) string
|
||||
* @returns {ToastBuilder}
|
||||
*/
|
||||
setIcon(icon) {
|
||||
this.elIcon.classList.remove('hidden');
|
||||
this.elIcon.innerText = icon;
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Sets the body of the toast.
|
||||
* @param {string} html The body HTML
|
||||
* @returns {ToastBuilder}
|
||||
*/
|
||||
setBodyHTML(html) {
|
||||
this.elBody.innerHTML = html;
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Sets the delay before the toast will automatically close after being shown. Set this to `0` to show the toast indefinitely (until the user closes it).
|
||||
*
|
||||
* Default is `5000` (5 seconds).
|
||||
* @param {number} delayMs The delay in milliseconds.
|
||||
* @returns {ToastBuilder}
|
||||
*/
|
||||
setCloseDelay(delayMs) {
|
||||
this.el.dataset.delay = delayMs;
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Sets whether or not the toast can be closed by the user. Rather, this determines if the close button is visible or not.
|
||||
* @param {boolean} isCloseable
|
||||
* @returns {ToastBuilder}
|
||||
*/
|
||||
setIsCloseable(isCloseable) {
|
||||
if (isCloseable) {
|
||||
this.elClose.style.display = '';
|
||||
} else {
|
||||
this.elClose.style.display = 'none';
|
||||
}
|
||||
return this;
|
||||
}
|
||||
/**
|
||||
* Closes the toast, assuming that it's visible.
|
||||
* @returns {ToastBuilder}
|
||||
*/
|
||||
close() {
|
||||
this.el.classList.remove('visible');
|
||||
setTimeout(() => {
|
||||
if (!this.el.parentNode) return;
|
||||
this.el.parentNode.removeChild(this.el);
|
||||
}, 200);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
function promptUrlDownload(url, name) {
|
||||
const a = document.createElement('a');
|
||||
a.href = url
|
||||
a.download = name || url.split('/').pop();
|
||||
a.click();
|
||||
}
|
||||
|
||||
// Create and append the tooltip element
|
||||
const tooltip = document.createElement('div');
|
||||
tooltip.classList.add('tooltip');
|
||||
tooltip.role = 'tooltip';
|
||||
tooltip.id = Date.now();
|
||||
document.body.appendChild(tooltip);
|
||||
let activeTooltipElement = null;
|
||||
|
||||
// Functions for the tooltip
|
||||
let tooltipTimeout;
|
||||
const showTooltip = el => {
|
||||
hideTooltip();
|
||||
tooltipTimeout = setTimeout(() => {
|
||||
// After 200ms, remove transitions and reset scale
|
||||
tooltip.style.transition = 'none';
|
||||
// Set the tooltip's content
|
||||
tooltip.innerHTML = el.dataset.tooltip;
|
||||
setTimeout(() => {
|
||||
// Position the tooltip and add transitions
|
||||
positionElement(tooltip, mouse.x+5, mouse.y);
|
||||
tooltip.style.transition = '';
|
||||
tooltipTimeout = setTimeout(() => {
|
||||
// Show the tooltip if the mouse is still over the element
|
||||
const isMouseOver = el.dataset.isMouseOver === 'true';
|
||||
if (!isMouseOver || !isElementVisible(el)) return;
|
||||
tooltip.classList.add('visible');
|
||||
}, 200);
|
||||
}, 50);
|
||||
}, 200);
|
||||
}
|
||||
|
||||
const hideTooltip = () => {
|
||||
clearTimeout(tooltipTimeout);
|
||||
tooltip.classList.remove('visible');
|
||||
};
|
||||
|
||||
// Continuously save mouse position
|
||||
const mouse = { x: 0, y: 0 };
|
||||
window.addEventListener('mousemove', (e) => {
|
||||
mouse.x = e.clientX;
|
||||
mouse.y = e.clientY;
|
||||
// Show or hide the tooltip accordingly
|
||||
if (tooltip.classList.contains('visible')) {
|
||||
hideTooltip();
|
||||
}
|
||||
if (activeTooltipElement) {
|
||||
showTooltip(activeTooltipElement);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle dynamic changes
|
||||
document.addEventListener('domChange', () => {
|
||||
|
||||
// Get elements with a title attribute
|
||||
const titleEls = $$('[title]:not([data-no-tooltip])');
|
||||
for (const el of titleEls) {
|
||||
// Remove the title attribute and add it to the tooltip data attribute
|
||||
const title = el.title;
|
||||
el.removeAttribute('title');
|
||||
el.dataset.tooltip = title;
|
||||
// Add aria label
|
||||
el.setAttribute('aria-describedby', tooltip.id);
|
||||
}
|
||||
// Get elements with a tooltip data attribute
|
||||
const tooltipEls = $$('[data-tooltip]:not([data-has-tooltip])');
|
||||
// Loop through 'em
|
||||
for (const el of tooltipEls) {
|
||||
el.dataset.isMouseOver = false;
|
||||
const show = () => {
|
||||
el.dataset.isMouseOver = true;
|
||||
activeTooltipElement = el;
|
||||
};
|
||||
const hide = () => {
|
||||
el.dataset.isMouseOver = false;
|
||||
activeTooltipElement = null;
|
||||
};
|
||||
el.addEventListener('mousemove', show);
|
||||
el.addEventListener('mouseleave', hide);
|
||||
el.addEventListener('mousedown', hide);
|
||||
el.addEventListener('mouseup', hide);
|
||||
// Mark the tooltip as added
|
||||
el.dataset.hasTooltip = true;
|
||||
}
|
||||
|
||||
// Get slider elements
|
||||
const sliders = $$('div.slider:not([data-modified])');
|
||||
// Loop through 'em
|
||||
for (const slider of sliders) {
|
||||
// Create elements
|
||||
const prog = document.createElement('progress');
|
||||
const input = document.createElement('input');
|
||||
// Set attributes
|
||||
let textbox;
|
||||
const onSliderChange = () => {
|
||||
// Collect data values
|
||||
const min = slider.dataset.min || 0;
|
||||
const max = slider.dataset.max || 100;
|
||||
const step = slider.dataset.step || 1;
|
||||
const value = slider.dataset.value || 0;
|
||||
const rangeId = slider.dataset.rangeId;
|
||||
const progId = slider.dataset.progId;
|
||||
textbox = $(slider.dataset.textbox);
|
||||
// Set attributes
|
||||
input.type = 'range';
|
||||
input.min = min;
|
||||
input.max = max;
|
||||
input.value = value;
|
||||
input.step = step;
|
||||
prog.min = min;
|
||||
prog.max = max;
|
||||
prog.value = value;
|
||||
prog.step = step;
|
||||
if (progId) prog.id = progId || '';
|
||||
if (rangeId) input.id = rangeId || '';
|
||||
// Handle the textbox
|
||||
if (textbox) {
|
||||
textbox.type = 'number';
|
||||
textbox.min = min;
|
||||
textbox.max = max;
|
||||
textbox.step = step;
|
||||
textbox.value = value;
|
||||
textbox.oninput = () => {
|
||||
input.value = textbox.value;
|
||||
input.dispatchEvent(new Event('input'));
|
||||
};
|
||||
textbox.onchange = textbox.oninput;
|
||||
}
|
||||
// Dispatch events
|
||||
input.dispatchEvent(new Event('change'));
|
||||
prog.dispatchEvent(new Event('change'));
|
||||
}
|
||||
onSliderChange();
|
||||
// Append elements
|
||||
slider.appendChild(prog);
|
||||
slider.appendChild(input);
|
||||
// Add event listeners
|
||||
input.addEventListener('input', () => {
|
||||
slider.dataset.value = input.value;
|
||||
prog.value = slider.dataset.value;
|
||||
if (textbox) textbox.value = slider.dataset.value;
|
||||
slider.dispatchEvent(new Event('input'));
|
||||
});
|
||||
input.addEventListener('change', () => {
|
||||
slider.dataset.value = input.value;
|
||||
prog.value = slider.dataset.value;
|
||||
if (textbox) textbox.value = slider.dataset.value;
|
||||
});
|
||||
prog.addEventListener('change', () => {
|
||||
input.value = slider.dataset.value;
|
||||
if (textbox) textbox.value = slider.dataset.value;
|
||||
});
|
||||
slider.addEventListener('change', onSliderChange);
|
||||
// Mark the slider as added
|
||||
slider.dataset.modified = true;
|
||||
}
|
||||
|
||||
// Get expandable textareas
|
||||
const textareas = $$('textarea[data-make-expandable]:not([data-modified])');
|
||||
// Loop through 'em
|
||||
for (const textarea of textareas) {
|
||||
textarea.addEventListener('resize', () => {
|
||||
if (!isElementVisible(textarea) || !textarea.scrollHeight) return;
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = textarea.scrollHeight + 'px';
|
||||
});
|
||||
textarea.dispatchEvent(new Event('resize'));
|
||||
textarea.addEventListener('input', () => {
|
||||
textarea.dispatchEvent(new Event('resize'));
|
||||
});
|
||||
window.addEventListener('resize', () => {
|
||||
textarea.dispatchEvent(new Event('resize'));
|
||||
});
|
||||
textarea.dataset.modified = true;
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
// Handle DOM mutations and dispatching the domChange event
|
||||
const mutationObs = new MutationObserver(() => {
|
||||
document.dispatchEvent(new Event('domChange'));
|
||||
});
|
||||
mutationObs.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
characterData: true,
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
window.addEventListener('domcontentloaded', () => {
|
||||
document.dispatchEvent(new Event('domChange'));
|
||||
});
|
338
web/v2/themes.css
Normal file
338
web/v2/themes.css
Normal file
@ -0,0 +1,338 @@
|
||||
|
||||
/*
|
||||
|
||||
See `themes.json` for some metadata about each theme.
|
||||
- The `themeColor` property is the suggested value for the `theme-color` meta tag.
|
||||
- Equal to the theme's `--b3` color.
|
||||
- The `name` property is the theme's canonical name.
|
||||
- The `hue` property is the suggested hue to use for colouring custom elements
|
||||
|
||||
*/
|
||||
|
||||
.lightblue,
|
||||
.lightyellow,
|
||||
.lightgreen,
|
||||
.lightmuted,
|
||||
.lightpurple {
|
||||
/* Background and foreground */
|
||||
--b0: hsl(180, 60%, 95%);
|
||||
--b1: hsl(180, 60%, 90%);
|
||||
--b2: hsl(180, 50%, 85%);
|
||||
--b3: hsl(180, 40%, 80%);
|
||||
--b4: hsl(180, 40%, 70%);
|
||||
--b5: hsl(180, 40%, 60%);
|
||||
--f4: hsl(180, 30%, 50%);
|
||||
--f3: hsl(180, 30%, 35%);
|
||||
--f2: hsl(180, 30%, 15%);
|
||||
--f1: black;
|
||||
/* Green accent */
|
||||
--green0: hsl(120, 60%, 40%);
|
||||
--green1: hsl(120, 60%, 65%);
|
||||
--green2: hsl(120, 60%, 80%);
|
||||
--green3: hsl(120, 60%, 85%);
|
||||
--green4: hsl(120, 60%, 90%);
|
||||
--green5: hsl(120, 60%, 95%);
|
||||
/* Yellow accent */
|
||||
--yellow0: hsl(40, 100%, 40%);
|
||||
--yellow1: hsl(40, 100%, 70%);
|
||||
--yellow2: hsl(40, 100%, 80%);
|
||||
--yellow3: hsl(40, 100%, 85%);
|
||||
--yellow4: hsl(40, 100%, 90%);
|
||||
--yellow5: hsl(40, 100%, 95%);
|
||||
/* Blue accent */
|
||||
--blue0: hsl(210, 80%, 45%);
|
||||
--blue1: hsl(210, 80%, 70%);
|
||||
--blue2: hsl(210, 80%, 80%);
|
||||
--blue3: hsl(210, 80%, 85%);
|
||||
--blue4: hsl(210, 80%, 90%);
|
||||
--blue5: hsl(210, 80%, 95%);
|
||||
/* Danger */
|
||||
--red0: hsl(355, 80%, 55%);
|
||||
--red1: hsl(355, 80%, 75%);
|
||||
--red2: hsl(355, 80%, 80%);
|
||||
--red3: hsl(355, 80%, 85%);
|
||||
--red4: hsl(355, 80%, 90%);
|
||||
--red5: hsl(355, 80%, 95%);
|
||||
/* Code */
|
||||
--syntax-comment: #6e7781;
|
||||
--syntax-constant: #0550ae;
|
||||
--syntax-entity: #8250df;
|
||||
--syntax-storage-modifier-import: #24292f;
|
||||
--syntax-entity-tag: #116329;
|
||||
--syntax-keyword: #cf222e;
|
||||
--syntax-string: #0a3069;
|
||||
--syntax-variable: #953800;
|
||||
}
|
||||
|
||||
.lightpurple {
|
||||
/* Background and foreground */
|
||||
--b0: hsl(280, 60%, 95%);
|
||||
--b1: hsl(280, 60%, 90%);
|
||||
--b2: hsl(280, 60%, 85%);
|
||||
--b3: hsl(280, 50%, 80%);
|
||||
--b4: hsl(290, 50%, 70%);
|
||||
--b5: hsl(300, 40%, 60%);
|
||||
--f4: hsl(310, 30%, 50%);
|
||||
--f3: hsl(320, 30%, 35%);
|
||||
--f2: hsl(320, 30%, 15%);
|
||||
--f1: black;
|
||||
}
|
||||
|
||||
.lightyellow {
|
||||
/* Background and foreground */
|
||||
--b0: hsl(50, 60%, 95%);
|
||||
--b1: hsl(50, 60%, 90%);
|
||||
--b2: hsl(40, 60%, 85%);
|
||||
--b3: hsl(30, 50%, 80%);
|
||||
--b4: hsl(30, 50%, 70%);
|
||||
--b5: hsl(20, 40%, 60%);
|
||||
--f4: hsl(10, 30%, 50%);
|
||||
--f3: hsl(10, 30%, 35%);
|
||||
--f2: hsl(0, 30%, 15%);
|
||||
--f1: black;
|
||||
}
|
||||
|
||||
.lightgreen {
|
||||
/* Background and foreground */
|
||||
--b0: hsl(100, 60%, 95%);
|
||||
--b1: hsl(100, 60%, 90%);
|
||||
--b2: hsl(110, 60%, 85%);
|
||||
--b3: hsl(120, 50%, 80%);
|
||||
--b4: hsl(120, 50%, 70%);
|
||||
--b5: hsl(130, 40%, 60%);
|
||||
--f4: hsl(140, 30%, 50%);
|
||||
--f3: hsl(140, 30%, 35%);
|
||||
--f2: hsl(150, 30%, 15%);
|
||||
--f1: black;
|
||||
--green0: hsl(180, 60%, 50%);
|
||||
--green1: hsl(180, 60%, 70%);
|
||||
--green2: hsl(180, 60%, 80%);
|
||||
--green3: hsl(180, 60%, 85%);
|
||||
--green4: hsl(180, 60%, 90%);
|
||||
}
|
||||
|
||||
.lightmuted {
|
||||
/* Background and foreground */
|
||||
--b0: hsl(160, 10%, 95%);
|
||||
--b1: hsl(170, 10%, 90%);
|
||||
--b2: hsl(180, 10%, 85%);
|
||||
--b3: hsl(190, 10%, 80%);
|
||||
--b4: hsl(200, 10%, 70%);
|
||||
--b5: hsl(210, 10%, 60%);
|
||||
--f4: hsl(220, 10%, 50%);
|
||||
--f3: hsl(230, 10%, 35%);
|
||||
--f2: hsl(240, 10%, 15%);
|
||||
--f1: black;
|
||||
}
|
||||
|
||||
.darkpurple,
|
||||
.darkyellow,
|
||||
.darkgreen,
|
||||
.darkmuted,
|
||||
.darkblue {
|
||||
/* Background and foreground */
|
||||
--b0: hsl(270, 30%, 15%);
|
||||
--b1: hsl(270, 30%, 20%);
|
||||
--b2: hsl(270, 30%, 25%);
|
||||
--b3: hsl(270, 30%, 30%);
|
||||
--b4: hsl(280, 30%, 40%);
|
||||
--b5: hsl(290, 30%, 50%);
|
||||
--f4: hsl(300, 30%, 65%);
|
||||
--f3: hsl(310, 40%, 80%);
|
||||
--f2: hsl(310, 50%, 90%);
|
||||
--f1: white;
|
||||
/* Green accent */
|
||||
--green0: hsl(150, 50%, 30%);
|
||||
--green1: hsl(150, 50%, 40%);
|
||||
--green2: hsl(150, 50%, 50%);
|
||||
--green3: hsl(150, 50%, 60%);
|
||||
--green4: hsl(150, 50%, 70%);
|
||||
--green5: hsl(150, 50%, 80%);
|
||||
/* Yellow accent */
|
||||
--yellow0: hsl(40, 70%, 40%);
|
||||
--yellow1: hsl(40, 70%, 50%);
|
||||
--yellow2: hsl(40, 70%, 60%);
|
||||
--yellow3: hsl(40, 70%, 70%);
|
||||
--yellow4: hsl(40, 70%, 80%);
|
||||
--yellow5: hsl(40, 70%, 90%);
|
||||
/* Blue accent */
|
||||
--blue0: hsl(240, 60%, 45%);
|
||||
--blue1: hsl(240, 60%, 55%);
|
||||
--blue2: hsl(240, 60%, 65%);
|
||||
--blue3: hsl(240, 60%, 75%);
|
||||
--blue4: hsl(240, 60%, 85%);
|
||||
--blue5: hsl(240, 60%, 95%);
|
||||
/* Danger */
|
||||
--red0: hsl(340, 55%, 40%);
|
||||
--red1: hsl(340, 55%, 50%);
|
||||
--red2: hsl(340, 55%, 60%);
|
||||
--red3: hsl(340, 55%, 70%);
|
||||
--red4: hsl(340, 55%, 80%);
|
||||
--red5: hsl(340, 55%, 90%);
|
||||
/* Code */
|
||||
--syntax-comment: #8b949e;
|
||||
--syntax-constant: #79c0ff;
|
||||
--syntax-entity: #d2a8ff;
|
||||
--syntax-storage-modifier-import: #c9d1d9;
|
||||
--syntax-entity-tag: #7ee787;
|
||||
--syntax-keyword: #ff7b72;
|
||||
--syntax-string: #a5d6ff;
|
||||
--syntax-variable: #ffa657;
|
||||
}
|
||||
|
||||
.darkpurple .textbox,
|
||||
.darkyellow .textbox,
|
||||
.darkgreen .textbox,
|
||||
.darkmuted .textbox,
|
||||
.darkblue .textbox {
|
||||
background-color: var(--b0);
|
||||
border-color: var(--b0);
|
||||
}
|
||||
.darkpurple .textbox:focus,
|
||||
.darkyellow .textbox:focus,
|
||||
.darkgreen .textbox:focus,
|
||||
.darkmuted .textbox:focus,
|
||||
.darkblue .textbox:focus,
|
||||
.darkpurple .textbox:focus-within,
|
||||
.darkyellow .textbox:focus-within,
|
||||
.darkgreen .textbox:focus-within,
|
||||
.darkmuted .textbox:focus-within,
|
||||
.darkblue .textbox:focus-within {
|
||||
background-color: var(--b2);
|
||||
border-color: var(--f4);
|
||||
}
|
||||
|
||||
.darkpurple .slider input[type="range"],
|
||||
.darkyellow .slider input[type="range"],
|
||||
.darkgreen .slider input[type="range"],
|
||||
.darkmuted .slider input[type="range"],
|
||||
.darkblue .slider input[type="range"] {
|
||||
--thumbFill: var(--f2);
|
||||
--thumbFillHover: var(--f4);
|
||||
}
|
||||
|
||||
.darkpurple .text-info,
|
||||
.darkyellow .text-info,
|
||||
.darkgreen .text-info,
|
||||
.darkmuted .text-info,
|
||||
.darkblue .text-info {
|
||||
color: var(--blue2) !important;
|
||||
}
|
||||
.darkpurple .text-success,
|
||||
.darkyellow .text-success,
|
||||
.darkgreen .text-success,
|
||||
.darkmuted .text-success,
|
||||
.darkblue .text-success {
|
||||
color: var(--green2) !important;
|
||||
}
|
||||
.darkpurple .text-warning,
|
||||
.darkyellow .text-warning,
|
||||
.darkgreen .text-warning,
|
||||
.darkmuted .text-warning,
|
||||
.darkblue .text-warning {
|
||||
color: var(--yellow2) !important;
|
||||
}
|
||||
.darkpurple .text-danger,
|
||||
.darkyellow .text-danger,
|
||||
.darkgreen .text-danger,
|
||||
.darkmuted .text-danger,
|
||||
.darkblue .text-danger {
|
||||
color: var(--red2) !important;
|
||||
}
|
||||
|
||||
.darkblue {
|
||||
/* Background and foreground */
|
||||
--b0: hsl(230, 30%, 8%);
|
||||
--b1: hsl(230, 30%, 12%);
|
||||
--b2: hsl(230, 30%, 18%);
|
||||
--b3: hsl(230, 30%, 20%);
|
||||
--b4: hsl(220, 30%, 30%);
|
||||
--b5: hsl(210, 30%, 45%);
|
||||
--f4: hsl(200, 30%, 60%);
|
||||
--f3: hsl(190, 40%, 75%);
|
||||
--f2: hsl(190, 50%, 85%);
|
||||
--f1: white;
|
||||
}
|
||||
|
||||
.darkyellow {
|
||||
/* Background and foreground */
|
||||
--b0: hsl(40, 20%, 8%);
|
||||
--b1: hsl(40, 20%, 12%);
|
||||
--b2: hsl(40, 20%, 18%);
|
||||
--b3: hsl(40, 30%, 20%);
|
||||
--b4: hsl(30, 30%, 30%);
|
||||
--b5: hsl(20, 30%, 45%);
|
||||
--f4: hsl(10, 30%, 60%);
|
||||
--f3: hsl(0, 40%, 75%);
|
||||
--f2: hsl(0, 50%, 85%);
|
||||
--f1: white;
|
||||
}
|
||||
|
||||
.darkgreen {
|
||||
/* Background and foreground */
|
||||
--b0: hsl(170, 20%, 8%);
|
||||
--b1: hsl(170, 20%, 12%);
|
||||
--b2: hsl(170, 20%, 18%);
|
||||
--b3: hsl(170, 30%, 20%);
|
||||
--b4: hsl(160, 30%, 30%);
|
||||
--b5: hsl(150, 30%, 45%);
|
||||
--f4: hsl(140, 30%, 60%);
|
||||
--f3: hsl(130, 40%, 75%);
|
||||
--f2: hsl(130, 50%, 85%);
|
||||
--f1: white;
|
||||
--green0: hsl(180, 50%, 30%);
|
||||
--green1: hsl(180, 50%, 40%);
|
||||
--green2: hsl(180, 50%, 50%);
|
||||
--green3: hsl(180, 50%, 60%);
|
||||
--green4: hsl(180, 50%, 70%);
|
||||
}
|
||||
|
||||
.darkmuted {
|
||||
/* Background and foreground */
|
||||
--b0: hsl(180, 10%, 5%);
|
||||
--b1: hsl(190, 10%, 8%);
|
||||
--b2: hsl(200, 10%, 12%);
|
||||
--b3: hsl(210, 10%, 15%);
|
||||
--b4: hsl(220, 10%, 25%);
|
||||
--b5: hsl(230, 10%, 40%);
|
||||
--f4: hsl(240, 10%, 55%);
|
||||
--f3: hsl(250, 10%, 70%);
|
||||
--f2: hsl(260, 10%, 85%);
|
||||
--f1: white;
|
||||
}
|
||||
|
||||
.lightblue,
|
||||
.lightyellow,
|
||||
.lightgreen,
|
||||
.lightmuted,
|
||||
.lightpurple {
|
||||
/* Luminance palette */
|
||||
--l0: var(--f1);
|
||||
--l1: var(--f2);
|
||||
--l2: var(--f3);
|
||||
--l3: var(--f4);
|
||||
--l4: var(--b5);
|
||||
--l5: var(--b4);
|
||||
--l6: var(--b3);
|
||||
--l7: var(--b2);
|
||||
--l8: var(--b1);
|
||||
--l9: var(--b0);
|
||||
}
|
||||
|
||||
.darkpurple,
|
||||
.darkyellow,
|
||||
.darkgreen,
|
||||
.darkmuted,
|
||||
.darkblue {
|
||||
/* Luminance palette */
|
||||
--l0: var(--b0);
|
||||
--l1: var(--b1);
|
||||
--l2: var(--b2);
|
||||
--l3: var(--b3);
|
||||
--l4: var(--b4);
|
||||
--l5: var(--b5);
|
||||
--l6: var(--f4);
|
||||
--l7: var(--f3);
|
||||
--l8: var(--f2);
|
||||
--l9: var(--f1);
|
||||
}
|
Reference in New Issue
Block a user