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-2025 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`.
 */

"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 defaultScrollIntoViewOptionsStart = { behavior: "smooth", block: "start" };
let defaultScrollIntoViewOptionsCenter = { behavior: "smooth", block: "center" };

function viewHTMLNode(el, scrollIntoViewOptions, showAllFunc, hideAllFunc) {
    if (el !== null) {
        if (showAllFunc !== undefined)
            showAllFunc();
        let defopts = el.tagName.startsWith("H") ? defaultScrollIntoViewOptionsStart : defaultScrollIntoViewOptionsCenter;
        // 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(scrollIntoViewOptions, showAllFunc, hideAllFunc) {
    let hash = document.location.hash.substr(1);
    let el = hash ? document.getElementById(hash) : null;
    // no-smooth scrolling by default here
    viewHTMLNode(el, scrollIntoViewOptions, showAllFunc, hideAllFunc);
}

function focusHashNode(scrollIntoViewOptions, showAllFunc, hideAllFunc) {
    let hash = document.location.hash.substr(1);
    // no-smooth scrolling by default here
    focusNode(hash, scrollIntoViewOptions, 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 !== undefined
        && 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 = action;
    return el;
}

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

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

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

    let div = node.getElementById("div-" + prefix);
    if (div !== null) {
        if (div.classList.contains("tristate"))
            typ = "tristate";
        else if (div.classList.contains("omega"))
            typ = "omega";
    }

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

    if (typ === "boolean" && el.tagName === "INPUT" && el.type === "checkbox") {
        el.checked = value;
        if (update !== undefined)
            el.onchange = () => {
                update(el.checked, prefix);
            };
    } else if (typ === "tristate" && el.tagName === "INPUT" && el.type === "checkbox") {
        // emulating tristate using a checkbox:
        // - true -> true,
        // - false -> null,
        // - false + .false class -> false
        el.checked = value === true;
        if (value === false)
            el.classList.add("false");
        if (update !== undefined)
            el.onchange = () => {
                // switch it like this:
                // null -> true -> false -> null
                let nvalue = el.checked;
                if (el.classList.contains("false")) {
                    nvalue = null;
                    el.checked = false;
                    el.classList.remove("false");
                } else if (!nvalue)
                    el.classList.add("false");
                update(nvalue, prefix);
            };
    } else if ((typ === "number" || typ === "string") && el.tagName === "INPUT"
               && (el.type === "number" || el.type === "text" || el.type === "button")) {
        el.value  = value;
        if (update !== undefined && el.type != "button")
            el.onchange = () => {
                let nvalue = el.value;
                if (typ === "number")
                    nvalue = Number(nvalue).valueOf();
                else if (typ === "string")
                    nvalue = String(nvalue).valueOf();
                update(nvalue, prefix);
            };
    } else if (typ === "omega" && el.tagName === "INPUT" && el.type === "number") {
        let checkbox = node.getElementById(prefix + "-omega");
        checkbox.checked = value !== null;
        el.disabled = value === null;
        if (update !== undefined) {
            let onchange = () => {
                let isNull = !checkbox.checked;
                if (isNull)
                    div.classList.add("null");
                else
                    div.classList.remove("null");
                el.disabled = isNull;
                update(isNull ? null : Number(el.value).valueOf(), prefix);
            };
            checkbox.onchange = onchange;
            el.onchange = onchange;
        }
    } else
        el.innerText = value;
}

// setUI, but with recursive updates
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);
}

// 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 res = document.createElement("div");
    res.id = "div-" + id;
    // copy other attributes
    for (let attr of node.attributes) {
        let name = attr.name;
        if (name === "id" || name === "tabindex" || name === "data-default") continue;
        res.setAttribute(name, node.getAttribute(name))
    }
    res.classList.add("genui");
    res.classList.add(typ);

    let lbl = document.createElement("label");
    lbl.innerHTML = node.innerHTML.replaceAll("{}", `<span class="placeholder"></span>`);
    let placeholders = lbl.getElementsByClassName("placeholder");

    function mk(tt, sub) {
        let ne = document.createElement("input");
        ne.id = id + sub;
        ne.name = id;
        if (tabindex !== null)
            ne.setAttribute("tabindex", tabindex);

        switch (tt) {
        case "boolean":
            ne.type = "checkbox";
            ne.classList.add("toggle");
            ne.checked = defvalue || false;
            break;
        case "number":
            ne.type = "number";
            ne.value = defvalue || 0;
            break;
        case "string":
            ne.type = "text";
            ne.value = defvalue || "";
            break;
        }

        return ne;
    }

    function place(i, tt, sub) {
        let ne = mk(tt, sub);
        let placeholder = placeholders[i];
        if (placeholder !== undefined)
            lbl.replaceChild(ne, placeholder);
        else if (tt !== "boolean")
            lbl.appendChild(ne);
        else
            lbl.prepend(ne);
        return ne;
    }

    if (typ === "tristate")
        place(0, "boolean", "");
    else if (typ === "omega") {
        place(1, "number", "");
        place(0, "boolean", "-omega");
    } else
        place(0, typ, "");

    res.appendChild(lbl);

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

// 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 help = node.getAttribute("data-help");
    if (help === null) return;
    let origHelp = help;
    node.removeAttribute("data-help");

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

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

    let helpTip = document.createElement("div");
    helpTip.classList.add("help-tip");
    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.appendChild(node);
    }
    main.setAttribute("title", help);
    main.appendChild(helpMark);
    root.appendChild(main);
    root.appendChild(helpTip);
}

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

    let num_links = 0;
    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-${num_links}`;
            num_links += 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);
}