software/hoardy-web/./extension/lib/ui.js

Passively capture, archive, and hoard your web browsing history, including the contents of the pages you visit, for later offline viewing, replay, mirroring, data scraping, and/or indexing. Your own personal private Wayback Machine that can also archive HTTP POST requests and responses, as well as most other HTTP-level data.

Files

Raw Source

Contents

/*
 * Copyright (c) 2023-2026 Jan Malakhovski <oxij@oxij.org>
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */

/*
 * Utility functions for HTML UI making.
 *
 * Depends on `./base.js` and `./schedule-timeout.js`.
 */

"use strict";

// get document root node
function getRootNode(document) {
    return Array.from(document.children).filter((e) => e.tagName === "HTML")[0];
}

// add or remove a class based on condition
function setConditionalClass(node, className, condition) {
    if (condition) {
        node.classList.add(className);
    } else {
        node.classList.remove(className);
    }
}

function implySetConditionalClass(node, className, impliedClass, condition) {
    for (let e of node.getElementsByClassName(className)) {
        setConditionalClass(e, impliedClass, condition);
    }
}

function implySetConditionalOff(node, className, condition) {
    return implySetConditionalClass(node, className, "off", condition);
}

let scrollStartIntoView = { behavior: "smooth", block: "start" };
let scrollCenterIntoView = { behavior: "smooth", block: "center" };
let scrollEndIntoView = { behavior: "smooth", block: "end" };

function viewHTMLNode(el, scrollIntoViewOptions, showAllFunc, hideAllFunc) {
    if (el !== null) {
        if (showAllFunc !== undefined) {
            showAllFunc();
        }
        let defopts = el.tagName.startsWith("H") ? scrollStartIntoView : scrollCenterIntoView;
        // give the page a chance to redraw, in case the code just before this call changed styles
        setTimeout(() => {
            // and then scroll
            el.scrollIntoView(
                scrollIntoViewOptions
                    ? updateFromRec(assignRec({}, defopts), scrollIntoViewOptions)
                    : defopts,
            );
        }, 0);
    } else if (hideAllFunc !== undefined) {
        hideAllFunc();
    }
}

function viewNode(id, scrollIntoViewOptions, showAllFunc, hideAllFunc) {
    let el = id ? document.getElementById(id) : null;
    viewHTMLNode(el, scrollIntoViewOptions, showAllFunc, hideAllFunc);
}

// currently highlighted node
let targetNode;

// Highlight DOM node with the given id by adding "target" to its class list.
// It also un-highlights previously highlighted one, if any.
function highlightNode(id) {
    if (targetNode !== undefined) {
        targetNode.classList.remove("target");
    }

    let el = id ? document.getElementById(id) : null;
    if (el !== null) {
        targetNode = el;
        el.classList.add("target");
    }

    return el;
}

// highlightNode followed by viewNode, essentially
function focusNode(id, scrollIntoViewOptions, showAllFunc, hideAllFunc) {
    let el = highlightNode(id);
    viewHTMLNode(el, scrollIntoViewOptions, showAllFunc, hideAllFunc);
}

function viewHashNode(scrollIntoViewOptionsFunc, showAllFunc, hideAllFunc) {
    let id = document.location.hash.substr(1);
    let el = id ? document.getElementById(id) : null;
    // no-smooth scrolling by default here
    viewHTMLNode(
        el,
        scrollIntoViewOptionsFunc !== undefined ? scrollIntoViewOptionsFunc(id) : undefined,
        showAllFunc,
        hideAllFunc,
    );
}

function focusHashNode(scrollIntoViewOptionsFunc, showAllFunc, hideAllFunc) {
    let id = document.location.hash.substr(1);
    // no-smooth scrolling by default here
    focusNode(
        id,
        scrollIntoViewOptionsFunc !== undefined ? scrollIntoViewOptionsFunc(id) : undefined,
        showAllFunc,
        hideAllFunc,
    );
}

// setup history navigation
function setupHistoryPopState() {
    window.onpopstate = (event) => {
        let state = event.state;
        if (state === null) {
            return;
        }
        let id = state.id;
        if (id !== undefined) {
            focusNode(id);
        }
    };
}

// go from history state `fromState` to url or state `toURLOrState`
function historyFromTo(fromState, toURLOrState) {
    if (equalRec(history.state, fromState)) {
        return;
    }

    let fromURL = "#" + fromState.id;
    if (
        history.state !== null &&
        typeof history.state === "object" &&
        history.state.id?.startsWith("link-")
    ) {
        history.replaceState(fromState, "", fromURL);
    } else {
        history.pushState(fromState, "", fromURL);
    }

    if (typeof toURLOrState === "string") {
        history.pushState({ skip: true }, "", toURLOrState);
    } else if (toURLOrState !== undefined) {
        let toURL = "#" + toURLOrState.id;
        history.pushState(toURLOrState, "", toURL);
    }
}

// attach function to `onclick` of DOM node with a given id
function buttonToAction(id, action) {
    let el = document.getElementById(id);
    if (el === null) {
        throw new Error(`failed to attach an action to button id "${id}"`);
    }
    el.onclick = catchAll(action);
    return el;
}

function createElements(arg, ...args) {
    if (args.length === 0) {
        if (Array.isArray(arg)) {
            let res = [];
            for (let e of arg) {
                res.push(...createElements(...e));
            }
            return res;
        } else if (typeof arg === "string") {
            return [document.createTextNode(arg)];
        }
        return [arg];
    }

    if (typeof arg === "function") {
        return createElements(...args).map(arg);
    }

    let node = document.createElement(arg);
    node.append(...createElements(...args));
    return [node];
}

function appendElements(node, ...args) {
    node.append(...createElements(...args));
}

function replaceElements(node, ...args) {
    node.replaceChildren(...createElements(...args));
}

function createButton(value, title, func) {
    let el = document.createElement("input");
    el.type = "button";
    el.value = value;
    el.title = title;
    el.onclick = func;
    return el;
}

function appendButton(node, ...args) {
    node.append(createButton(...args));
}

// emulating booleanOrNull using a checkbox:
// - true -> checked,
// - null -> !checked,
// - false -> !checked + .false class
function setBooleanOrNull(el, value) {
    el.checked = value === true;
    if (value === false) {
        el.classList.add("false");
    } else {
        el.classList.remove("false");
    }
}

function setNumberOrNull(checkbox, el, value) {
    if (value === null) {
        el.disabled = true;
        checkbox.checked = false;
    } else {
        el.disabled = false;
        el.value = value;
        checkbox.checked = true;
    }
}

let scheduledUI = new Map();

function _mkHandleOnChange(prefix, update, value, getValue, setValue) {
    let cvalue = value;
    return (event, partial) => {
        let nvalue = getValue(event, partial);

        if (nvalue === cvalue) {
            return;
        }
        cvalue = nvalue;

        if (setValue !== undefined) {
            setValue(nvalue, event, partial);
        }
        update(nvalue, prefix, event.resetting);
    };
}

function _dispatchReset(el) {
    let event = new Event("change");
    event.resetting = true;
    el.dispatchEvent(event);
}

function _mkHandleKeyUp(prefix, timeout, onchange) {
    return (event) => {
        if (event.key === "Enter") {
            resetSingletonTimeout(scheduledUI, `update-${prefix}`, 0, () => onchange(event));
        } else if (timeout !== 0) {
            resetSingletonTimeout(scheduledUI, `update-${prefix}`, timeout, () =>
                onchange(event, true),
            );
        }
    };
}

// set values of DOM elements from a given object
function setUIInternal(node, prefix, value, update, resetAcc) {
    let typ = typeof value;

    if (value !== null && typ === "object") {
        for (let k of Object.keys(value)) {
            setUIInternal(
                node,
                prefix ? prefix + "." + k : k,
                value[k],
                update !== undefined
                    ? (newvalue, path) => {
                          value[k] = newvalue;
                          update(value, path);
                      }
                    : undefined,
                resetAcc,
            );
        }

        return;
    }

    let el = node.getElementById(prefix);
    if (el === null) {
        return;
    }

    let div = node.getElementById("genui-" + prefix);
    if (div !== null) {
        if ((value === null || typ === "boolean") && div.classList.contains("booleanOrNull")) {
            typ = "booleanOrNull";
        } else if ((value === null || typ === "number") && div.classList.contains("numberOrNull")) {
            typ = "numberOrNull";
        }
    }

    //console.log("setting UI", prefix, typ, el, value);

    if (el.tagName !== "INPUT") {
        // fallback so that this function could be used to set values of rangom HTML elements
        el.innerText = value;
        return;
    }

    let etyp = el.type;

    if (etyp === "button" && (typ === "number" || typ === "string")) {
        // fallback so that this function could be used to assign button labels
        el.value = value;
        return;
    }

    // actual implementation follows

    switch (typ) {
        case "boolean": {
            if (etyp !== "checkbox") {
                break;
            }

            el.checked = value;

            if (update === undefined) {
                return;
            }

            el.onchange = _mkHandleOnChange(prefix, update, value, () => el.checked);

            let defvalue = div.getAttribute("data-default");
            defvalue = defvalue === "true";
            resetAcc.push(() => {
                el.checked = defvalue;
                _dispatchReset(el);
            });

            return;
        }

        case "booleanOrNull": {
            if (etyp !== "checkbox") {
                break;
            }

            setBooleanOrNull(el, value);

            if (update === undefined) {
                return;
            }

            el.onchange = _mkHandleOnChange(
                prefix,
                update,
                value,
                (event) => {
                    if (event.resetting) {
                        return el.checked ? true : el.classList.contains("false") ? false : null;
                    }

                    // switch it like this: false -> null -> true -> false
                    return el.classList.contains("false") ? null : el.checked;
                },
                (nvalue) => setBooleanOrNull(el, nvalue),
            );

            let defvalue = div.getAttribute("data-default");
            defvalue = defvalue === null || defvalue === "null" ? null : defvalue === "true";
            resetAcc.push(() => {
                setBooleanOrNull(el, defvalue);
                _dispatchReset(el);
            });

            return;
        }

        case "number": {
            if (etyp !== "number") {
                break;
            }

            el.value = value;

            if (update === undefined) {
                return;
            }

            let onchange = _mkHandleOnChange(prefix, update, value, () =>
                Number(el.value).valueOf(),
            );
            el.onchange = onchange;

            let defvalue = div.getAttribute("data-default");
            defvalue = defvalue || "0";
            resetAcc.push(() => {
                el.value = defvalue;
                _dispatchReset(el);
            });

            let timeout = Number(div.getAttribute("timeout")).valueOf();
            el.onkeyup = _mkHandleKeyUp(prefix, timeout, onchange);

            return;
        }

        case "numberOrNull": {
            if (etyp !== "number") {
                break;
            }

            let checkbox = node.getElementById(prefix + "-notNull");
            setNumberOrNull(checkbox, el, value);

            if (update === undefined) {
                return;
            }

            let onchange = _mkHandleOnChange(
                prefix,
                update,
                value,
                () => (checkbox.checked ? Number(el.value).valueOf() : null),
                (nvalue) => setNumberOrNull(checkbox, el, nvalue),
            );
            checkbox.onchange = onchange;
            el.onchange = onchange;

            let defvalue = div.getAttribute("data-default");
            defvalue = defvalue === null || defvalue === "null" ? null : defvalue || "0";
            resetAcc.push(() => {
                setNumberOrNull(checkbox, el, defvalue);
                _dispatchReset(el);
            });

            let timeout = Number(div.getAttribute("timeout")).valueOf();
            el.onkeyup = _mkHandleKeyUp(prefix, timeout, onchange);

            return;
        }

        case "string": {
            if (etyp !== "text" && etyp !== "search") {
                break;
            }

            el.value = value;

            if (update === undefined) {
                return;
            }

            let trim = div.getAttribute("trim") !== null;

            let onchange = _mkHandleOnChange(prefix, update, value, (_event, partial) => {
                let nvalue = String(el.value).valueOf();

                if (trim) {
                    nvalue = nvalue.trim();
                    if (!partial) {
                        el.value = nvalue;
                    }
                }

                return nvalue;
            });
            el.onchange = onchange;

            let defvalue = div.getAttribute("data-default");
            defvalue = defvalue || "";
            resetAcc.push(() => {
                el.value = defvalue;
                _dispatchReset(el);
            });

            let timeout = Number(div.getAttribute("timeout")).valueOf();
            el.onkeyup = _mkHandleKeyUp(prefix, timeout, onchange);

            return;
        }
    }

    throw new Error(`setUI: can't set ${typ} to ${etyp}`);
}

function setUI(node, prefix, value, update) {
    if (update === undefined) {
        setUIInternal(node, prefix, value);
        return noop;
    }

    let resetAcc = [];
    setUIInternal(node, prefix, value, update, resetAcc);
    return () => {
        for (let func of resetAcc) {
            func();
        }
    };
}

// setUI, but with recursive updates for when `update` modifies the `value` too
function setUIRec(node, prefix, value, update) {
    if (update === undefined) {
        return setUI(node, prefix, value);
    }

    function recUpdate(nvalue, path) {
        update(nvalue, path);
        setUI(node, prefix, nvalue, recUpdate);
    }

    return setUI(node, prefix, value, recUpdate);
}

function createUINodes(typ, id, name, tabindex, defvalue) {
    let el = document.createElement("input");
    el.id = id;
    el.name = name;
    if (isValid(tabindex)) {
        el.setAttribute("tabindex", tabindex);
    }

    switch (typ) {
        case "boolean":
        case "booleanOrNull":
            el.type = "checkbox";
            el.classList.add("toggle");

            if (typ === "boolean") {
                el.checked = defvalue === "true";
            } else {
                defvalue = defvalue === null || defvalue === "null" ? null : defvalue === "true";
                setBooleanOrNull(el, defvalue);
            }

            return [el];

        case "number":
        case "numberOrNull": {
            el.type = "number";

            if (typ === "number") {
                el.value = defvalue || "0";
                return [el];
            }

            let els = createUINodes("boolean", id + "-notNull", id, tabindex, null);
            els.push(el);

            defvalue = defvalue === null || defvalue === "null" ? null : defvalue || "0";
            setNumberOrNull(els[0], el, defvalue);
            return els;
        }

        case "string":
        case "search":
            el.type = typ === "string" ? "text" : typ;
            el.value = defvalue || "";
            return [el];
    }

    throw new Error(`createUINodes: unknown node type: ${typ}`);
}

function placeUINodes(node, places, typ, ...args) {
    let els = createUINodes(typ, ...args);
    for (let i = 0; i < els.length; ++i) {
        let child = places[i];
        if (child !== undefined) {
            node.replaceChild(els[i], child);
        } else if (typ.startsWith("boolean")) {
            node.prepend(els[i]);
        } else {
            node.append(els[i]);
        }
    }
}

// given a DOM node, replace <ui> nodes with corresponding UI elements
function makeUI(node) {
    for (let child of node.childNodes) {
        if (child.nodeName === "#text" || child.nodeName === "#comment") {
            continue;
        }

        makeUI(child);
    }

    if (node.tagName !== "UI") {
        return;
    }

    let id = node.getAttribute("id");
    let typ = node.getAttribute("type");
    let tabindex = node.getAttribute("tabindex");
    let defvalue = node.getAttribute("data-default");

    let div = document.createElement("div");
    div.id = "genui-" + id;
    // copy other attributes
    for (let attr of node.attributes) {
        let name = attr.name;
        if (name === "id" || name === "tabindex") {
            continue;
        }
        div.setAttribute(name, node.getAttribute(name));
    }
    div.classList.add("genui");
    div.classList.add(typ);

    let lbl = document.createElement("label");
    lbl.innerHTML = node.innerHTML.replaceAll("{}", `<span class="placeholder"></span>`);
    let places = lbl.getElementsByClassName("placeholder");
    placeUINodes(lbl, places, typ, id, id, tabindex, defvalue);
    div.append(lbl);

    node.parentElement.replaceChild(div, node);
}

async function verifyLinks(node, onerror, sameOrigin, allowOrigin, remapId) {
    if (allowOrigin === undefined) {
        allowOrigin = () => false;
    }
    if (remapId === undefined) {
        remapId = (_h, x) => x;
    }

    let selfUrl = new URL(document.location.href);
    selfUrl.hash = "";
    let selfHref = selfUrl.href;
    let selfOrigin = selfUrl.origin;

    let documents = new Map();

    for (let link of node.getElementsByTagName("a")) {
        let text = link.innerText;
        let href = link.href;

        let hashlessURL = new URL(href);
        let target = hashlessURL.hash.substr(1);
        hashlessURL.hash = "";
        let hashlessHref = hashlessURL.href;

        let doc;
        if (hashlessHref === selfHref) {
            // this document
            doc = document;
        } else if (
            (sameOrigin && hashlessURL.origin === selfOrigin) ||
            allowOrigin(hashlessURL.origin)
        ) {
            doc = await asyncCacheSingleton(documents, hashlessHref, async () => {
                console.info("verifyLinks: fetching:", hashlessHref);

                // something we can fetch
                let response = await fetch(hashlessHref);
                if (!response.ok) {
                    onerror("fetch failed", hashlessHref);
                    return null;
                }

                let text;
                try {
                    text = await response.text();
                } catch (err) {
                    onerror("fetch failed", hashlessHref, err);
                    return null;
                }

                try {
                    const parser = new DOMParser();
                    let x = parser.parseFromString(text, "text/html");
                    return x;
                } catch (err) {
                    onerror("parse failed", hashlessHref, err);
                    return null;
                }
            });
            if (doc === null) {
                continue;
            }
        } else {
            continue;
        }

        if (target !== "" && doc.getElementById(remapId(hashlessHref, target)) === null) {
            onerror("broken link", href, `from "${text}" at`, selfHref);
        }
    }
}

// current helpMark and helpDiv
let helpNodes;

// hide current tooltip
function hideHelp() {
    if (helpNodes === undefined) {
        return;
    }

    let [helpMark, helpDiv] = helpNodes;
    helpMark.checked = false;
    helpDiv.style.display = "none";
    helpNodes = undefined;
}

// given a DOM node, add help tooltips to all its children with data-help attribute
function addHelp(node, shortcuts, mapShortcutFunc, noHide) {
    for (let child of node.childNodes) {
        if (child.nodeName === "#text" || child.nodeName === "#comment") {
            continue;
        }

        addHelp(child, shortcuts, mapShortcutFunc, true);
    }

    if (!noHide) {
        node.addEventListener("click", hideHelp);
    }

    let origHelp = node.getAttribute("data-help");
    let help = origHelp;

    let shortcut;
    if (shortcuts !== undefined) {
        let sname = node.getAttribute("data-shortcut");
        if (sname !== null) {
            shortcut = shortcuts[sname];
        }
    }

    if (shortcut !== undefined) {
        help = mapShortcutFunc(help !== null ? help : shortcut.description, shortcut.shortcut);
    }

    if (!isValid(help)) {
        return;
    }

    node.removeAttribute("data-help");

    let classes = node.getAttribute("data-help-class");
    if (classes !== null) {
        classes = classes.split(" ");
    } else {
        classes = [];
    }
    node.removeAttribute("data-help-class");

    let helpTip = document.createElement("div");
    helpTip.classList.add("help-tip");
    if (origHelp !== null) {
        helpTip.setAttribute("data-orig-help", origHelp);
    }
    helpTip.style.display = "none";
    helpTip.innerHTML = microMarkdownToHTML(help);
    helpTip.onclick = hideHelp;

    let helpMark = document.createElement("input");
    helpMark.type = "checkbox";
    helpMark.classList.add("help-btn");
    helpMark.setAttribute("aria-label", "Show help for this element.");
    helpMark.setAttribute("tabindex", -1);

    helpMark.onchange = () => {
        hideHelp();

        if (helpMark.checked) {
            helpTip.style.display = "block";
            helpNodes = [helpMark, helpTip];
        } else {
            helpTip.style.display = "none";
        }
    };

    let root = document.createElement("span");
    root.classList.add("help-root");
    for (let c of classes) {
        root.classList.add(c);
    }

    node.parentElement.replaceChild(root, node);

    let main = node;
    if (node.tagName === "INPUT") {
        main = document.createElement("span");
        main.classList.add("help-main");
        main.append(node);
    }
    main.setAttribute("title", help);
    main.append(helpMark);
    root.append(main, helpTip);
}

let _classifyLinksNum = 0;

// Classify links under `node` based on their URLs. To get link highlights, run
// `setupHistoryPopState` before running this.
function classifyLinks(node, urlKlasses, setup) {
    if (setup === undefined) {
        setup = (link, info) => {
            let klass = info.klass;
            if (klass !== undefined) {
                link.classList.add(klass);
            }
            link.onclick = (_event) => {
                historyFromTo({ id: info.id });
            };
        };
    }

    for (let link of node.getElementsByTagName("a")) {
        let href = link.href;
        let hashlessUrl = new URL(href);
        let hash = hashlessUrl.hash;
        hashlessUrl.hash = "";
        let hashlessHref = hashlessUrl.href;

        let id = link.id;
        if (!id) {
            link.id = id = `link-${_classifyLinksNum}`;
            _classifyLinksNum += 1;
        }

        let klass;
        for (let [eurl, eklass] of urlKlasses) {
            if (href === eurl || hashlessHref === eurl) {
                // exact match
                klass = eklass;
                break;
            } else if (klass === undefined && hashlessHref.startsWith(eurl)) {
                // otherwise, take the first matching prefix
                klass = eklass;
            }
        }

        setup(link, { klass, id, href, hashlessHref, hash, target: hash.substr(1) });
    }
}

// `classifyLinks` with automaic URL mapping relative to
// `document.location.href`.
function classifyDocumentLinks(node, urlKlasses, setup) {
    return classifyLinks(
        node,
        urlKlasses.map((v) => {
            let url = new URL(v[0], document.location.href);
            return [url.href, v[1]];
        }),
        setup,
    );
}