Establish application infrastructure
This commit is contained in:
		
						commit
						cd9a0ecc18
					
				| 
						 | 
					@ -0,0 +1,315 @@
 | 
				
			||||||
 | 
					import Toolkit from /**/"./toolkit/Toolkit.js";
 | 
				
			||||||
 | 
					import VB      from /**/"../shrooms-vb-core/web/VB.js";
 | 
				
			||||||
 | 
					import ZipFile from /**/"./util/ZipFile.js";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class App extends Toolkit.App {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Instance fields
 | 
				
			||||||
 | 
					    #bundle;   // Packaged assets
 | 
				
			||||||
 | 
					    #core;     // Emulation core
 | 
				
			||||||
 | 
					    #dateCode; // Numeric date code of bundle
 | 
				
			||||||
 | 
					    #theme;    // ID of global color palette
 | 
				
			||||||
 | 
					    #themes;   // Available color palettes
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Components
 | 
				
			||||||
 | 
					    #mnuExport;  // File -> Export bundle...
 | 
				
			||||||
 | 
					    #mnuLoadROM; // File -> Load ROM...
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ///////////////////////// Initialization Methods //////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    constructor() {
 | 
				
			||||||
 | 
					        super({
 | 
				
			||||||
 | 
					            className: "tk app",
 | 
				
			||||||
 | 
					            style: {
 | 
				
			||||||
 | 
					                display         : "grid",
 | 
				
			||||||
 | 
					                height          : "100vh",
 | 
				
			||||||
 | 
					                gridTemplateRows: "max-content auto"
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					        this.#construct.apply(this, arguments);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Asynchronous constructor
 | 
				
			||||||
 | 
					    async #construct(bundle, dateCode) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Instance fields
 | 
				
			||||||
 | 
					        this.#bundle   = bundle;
 | 
				
			||||||
 | 
					        this.#dateCode = dateCode;
 | 
				
			||||||
 | 
					        this.#theme    = null;
 | 
				
			||||||
 | 
					        this.#themes   = new Map();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Initialize components
 | 
				
			||||||
 | 
					        this.#initThemes();
 | 
				
			||||||
 | 
					        await this.#initLocales();
 | 
				
			||||||
 | 
					        this.title = /{{app.title}}/;
 | 
				
			||||||
 | 
					        this.#initMenus();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        let desktop = document.createElement("div");
 | 
				
			||||||
 | 
					        desktop.style.background = "var(--tk-desktop)";
 | 
				
			||||||
 | 
					        this.element.append(desktop);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        document.body.append(this.element);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        VB.create(!this.#bundle.isDebug ? {
 | 
				
			||||||
 | 
					            audioUrl: /***/"../shrooms-vb-core/web/Audio.js",
 | 
				
			||||||
 | 
					            coreUrl : /***/"../shrooms-vb-core/web/Core.js",
 | 
				
			||||||
 | 
					            wasmUrl : /***/"../shrooms-vb-core/web/core.wasm",
 | 
				
			||||||
 | 
					        } : {
 | 
				
			||||||
 | 
					            audioUrl: import.meta.resolve("../shrooms-vb-core/web/Audio.js" ),
 | 
				
			||||||
 | 
					            coreUrl : import.meta.resolve("../shrooms-vb-core/web/Core.js"  ),
 | 
				
			||||||
 | 
					            wasmUrl : import.meta.resolve("../shrooms-vb-core/web/core.wasm")
 | 
				
			||||||
 | 
					        }).then(c=>this.#onCoreCreate(c));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Display text
 | 
				
			||||||
 | 
					    async #initLocales() {
 | 
				
			||||||
 | 
					        for (let file of this.#bundle.list("shrooms-vb-web/locale/"))
 | 
				
			||||||
 | 
					            this.addLocale(await (await fetch(file.url)).json());
 | 
				
			||||||
 | 
					        this.locale = "en-US";
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Menu bar
 | 
				
			||||||
 | 
					    #initMenus() {
 | 
				
			||||||
 | 
					        let bar, item, sub, group;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Menu bar
 | 
				
			||||||
 | 
					        bar = new Toolkit.MenuBar(this);
 | 
				
			||||||
 | 
					        bar.ariaLabel = /{{app.menuBar}}/;
 | 
				
			||||||
 | 
					        this.add(bar);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // File
 | 
				
			||||||
 | 
					        item = new Toolkit.MenuItem(this);
 | 
				
			||||||
 | 
					        item.text = /{{menu.file._}}/;
 | 
				
			||||||
 | 
					        bar.add(item);
 | 
				
			||||||
 | 
					        sub = this.#mnuLoadROM = new Toolkit.MenuItem(this, {disabled:true});
 | 
				
			||||||
 | 
					        sub.text = /{{menu.file.loadROM}}/;
 | 
				
			||||||
 | 
					        sub.addEventListener("action", ()=>this.#onLoadROM());
 | 
				
			||||||
 | 
					        item.add(sub);
 | 
				
			||||||
 | 
					        sub = new Toolkit.MenuItem(this, { type: "checkbox" });
 | 
				
			||||||
 | 
					        sub.text = /{{menu.file.dualMode}}/;
 | 
				
			||||||
 | 
					        item.add(sub);
 | 
				
			||||||
 | 
					        sub = new Toolkit.MenuItem(this, { type: "checkbox" });
 | 
				
			||||||
 | 
					        sub.text = /{{menu.file.debugMode}}/;
 | 
				
			||||||
 | 
					        item.add(sub);
 | 
				
			||||||
 | 
					        item.addSeparator();
 | 
				
			||||||
 | 
					        sub = this.#mnuExport = new Toolkit.MenuItem(this);
 | 
				
			||||||
 | 
					        sub.text = /{{menu.file.exportBundle}}/;
 | 
				
			||||||
 | 
					        sub.addEventListener("action", ()=>this.#onExportBundle());
 | 
				
			||||||
 | 
					        item.add(sub);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Emulation
 | 
				
			||||||
 | 
					        item = new Toolkit.MenuItem(this);
 | 
				
			||||||
 | 
					        item.text = /{{menu.emulation._}}/;
 | 
				
			||||||
 | 
					        bar.add(item);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Theme
 | 
				
			||||||
 | 
					        item = new Toolkit.MenuItem(this);
 | 
				
			||||||
 | 
					        item.text = /{{menu.theme._}}/;
 | 
				
			||||||
 | 
					        bar.add(item);
 | 
				
			||||||
 | 
					        group = new Toolkit.Group();
 | 
				
			||||||
 | 
					        sub = new Toolkit.MenuItem(this, { type: "radio" });
 | 
				
			||||||
 | 
					        sub.text = /{{menu.theme.auto}}/;
 | 
				
			||||||
 | 
					        group.add(sub, "auto");
 | 
				
			||||||
 | 
					        item.add(sub);
 | 
				
			||||||
 | 
					        sub = new Toolkit.MenuItem(this, { type: "radio" });
 | 
				
			||||||
 | 
					        sub.text = /{{menu.theme.light}}/;
 | 
				
			||||||
 | 
					        group.add(sub, "light");
 | 
				
			||||||
 | 
					        item.add(sub);
 | 
				
			||||||
 | 
					        sub = new Toolkit.MenuItem(this, { type: "radio" });
 | 
				
			||||||
 | 
					        sub.text = /{{menu.theme.dark}}/;
 | 
				
			||||||
 | 
					        group.add(sub, "dark");
 | 
				
			||||||
 | 
					        item.add(sub);
 | 
				
			||||||
 | 
					        sub = new Toolkit.MenuItem(this, { type: "radio" });
 | 
				
			||||||
 | 
					        sub.text = /{{menu.theme.virtual}}/;
 | 
				
			||||||
 | 
					        group.add(sub, "virtual");
 | 
				
			||||||
 | 
					        item.add(sub);
 | 
				
			||||||
 | 
					        group.value = "auto";
 | 
				
			||||||
 | 
					        group.addEventListener("action", e=>{
 | 
				
			||||||
 | 
					            let theme = e[Toolkit.group].value;
 | 
				
			||||||
 | 
					            this.#setTheme(theme == "auto" ? null : theme);
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Color themes
 | 
				
			||||||
 | 
					    #initThemes() {
 | 
				
			||||||
 | 
					        let bundle = this.#bundle;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Base theme stylesheet
 | 
				
			||||||
 | 
					        document.head.append(Toolkit.stylesheet(this.#bundle.get(
 | 
				
			||||||
 | 
					            "shrooms-vb-web/theme/kiosk.css").url));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Color set stylesheets
 | 
				
			||||||
 | 
					        for (let id of [ "light", "dark", "virtual" ]) {
 | 
				
			||||||
 | 
					            let file  = bundle.get("shrooms-vb-web/theme/" + id + ".css");
 | 
				
			||||||
 | 
					            let theme = Toolkit.stylesheet(file.url);
 | 
				
			||||||
 | 
					            theme.disabled = id != "light";
 | 
				
			||||||
 | 
					            this.#themes.set(id, theme);
 | 
				
			||||||
 | 
					            document.head.append(theme);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Event handling
 | 
				
			||||||
 | 
					        this.addEventListener("dark", e=>this.#onDark());
 | 
				
			||||||
 | 
					        this.#onDark();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ///////////////////////////// Event Handlers //////////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Core created
 | 
				
			||||||
 | 
					    #onCoreCreate(core) {
 | 
				
			||||||
 | 
					        this.#core = core;
 | 
				
			||||||
 | 
					        this.#mnuLoadROM.disabled = false;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // User agent dark mode preference changed
 | 
				
			||||||
 | 
					    #onDark() {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Current color theme is not auto
 | 
				
			||||||
 | 
					        if (this.#theme != null)
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Working variables
 | 
				
			||||||
 | 
					        let active = this.#activeTheme();
 | 
				
			||||||
 | 
					        let auto   = this.#autoTheme();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // The active color theme matches the automatic color theme
 | 
				
			||||||
 | 
					        if (active == auto)
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Activate the automatic color theme
 | 
				
			||||||
 | 
					        this.#themes.get(auto  ).disabled = false;
 | 
				
			||||||
 | 
					        this.#themes.get(active).disabled = true;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // File -> Export bundle...
 | 
				
			||||||
 | 
					    async #onExportBundle() {
 | 
				
			||||||
 | 
					        this.#mnuExport.disabled = true;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Add the bundle contents to a .zip file
 | 
				
			||||||
 | 
					        let zip = new ZipFile();
 | 
				
			||||||
 | 
					        for (let asset of this.#bundle.values())
 | 
				
			||||||
 | 
					            zip.add(asset.name, asset.data);
 | 
				
			||||||
 | 
					        let blob = await zip.toBlob();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Prompt the user to save the file
 | 
				
			||||||
 | 
					        let link = document.createElement("a");
 | 
				
			||||||
 | 
					        link.download = "acid-shroom_" + this.#dateCode + ".zip";
 | 
				
			||||||
 | 
					        link.href     = URL.createObjectURL(blob);
 | 
				
			||||||
 | 
					        Object.assign(link.style, {
 | 
				
			||||||
 | 
					            position  : "absolute",
 | 
				
			||||||
 | 
					            visibility: "hidden"
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					        document.body.append(link);
 | 
				
			||||||
 | 
					        link.click();
 | 
				
			||||||
 | 
					        link.remove();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        this.#mnuExport.disabled = false;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // File -> Load ROM...
 | 
				
			||||||
 | 
					    async #onLoadROM() {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Produce an invisible file picker element
 | 
				
			||||||
 | 
					        let picker = document.createElement("input");
 | 
				
			||||||
 | 
					        picker.type = "file";
 | 
				
			||||||
 | 
					        Object.assign(picker.style, {
 | 
				
			||||||
 | 
					            position  : "absolute",
 | 
				
			||||||
 | 
					            visibility: "hidden"
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Prompt the user to select a file
 | 
				
			||||||
 | 
					        document.body.append(picker);
 | 
				
			||||||
 | 
					        await new Promise(resolve=>{
 | 
				
			||||||
 | 
					            picker.addEventListener("input", resolve);
 | 
				
			||||||
 | 
					            picker.click();
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					        picker.remove();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Select the file
 | 
				
			||||||
 | 
					        let file = picker.files[0] ?? null;
 | 
				
			||||||
 | 
					        if (file == null)
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Read the file
 | 
				
			||||||
 | 
					        let rom;
 | 
				
			||||||
 | 
					        try {
 | 
				
			||||||
 | 
					            if (file.size > 0x1000000) {
 | 
				
			||||||
 | 
					                console.log("ROM file length safeguard");
 | 
				
			||||||
 | 
					                throw 0;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            rom = new Uint8Array(await file.arrayBuffer());
 | 
				
			||||||
 | 
					        } catch {
 | 
				
			||||||
 | 
					            alert(this.translate(/{{menu.file.loadROMError}}/));
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Attempt to decode as ISX
 | 
				
			||||||
 | 
					        rom = await this.#core.fromISX(rom) ?? rom;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Error checking
 | 
				
			||||||
 | 
					        if (
 | 
				
			||||||
 | 
					            rom.length < 4         ||
 | 
				
			||||||
 | 
					            rom.length > 0x1000000 ||
 | 
				
			||||||
 | 
					            (rom.length & rom.length - 1) != 0 // Not a power of two
 | 
				
			||||||
 | 
					        ) {
 | 
				
			||||||
 | 
					            alert(this.translate(/{{menu.file.loadROMNotVB}}/));
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // TODO: Something with the ROM data
 | 
				
			||||||
 | 
					        console.log(rom.length.toString(16).toUpperCase().padStart(8, "0"));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ///////////////////////////// Private Methods /////////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Determine which color theme is active
 | 
				
			||||||
 | 
					    #activeTheme() {
 | 
				
			||||||
 | 
					        return [... this.#themes.entries()].find(e=>!e[1].disabled)[0];
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Determine which color theme should be selected automatically
 | 
				
			||||||
 | 
					    #autoTheme() {
 | 
				
			||||||
 | 
					        return Toolkit.isDark() ? "dark" : "light";
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Determine whether a ROM size is 
 | 
				
			||||||
 | 
					    #checkROMSize(size) {
 | 
				
			||||||
 | 
					        return !(
 | 
				
			||||||
 | 
					            file.size == 0          ||       // Too small
 | 
				
			||||||
 | 
					            file.size >  0x01000000 ||       // Too big
 | 
				
			||||||
 | 
					            (file.size - 1 & file.size) != 0 // Not a power of two
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Specify the active color theme
 | 
				
			||||||
 | 
					    #setTheme(id) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Theme is not changing
 | 
				
			||||||
 | 
					        if (id == this.#theme)
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Configure instance fields
 | 
				
			||||||
 | 
					        this.#theme = id;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Working variables
 | 
				
			||||||
 | 
					        let active = this.#activeTheme();
 | 
				
			||||||
 | 
					        let next   = id ?? this.#autoTheme();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Active stylesheet is not changing
 | 
				
			||||||
 | 
					        if (active == next)
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Change the active stylesheet
 | 
				
			||||||
 | 
					        this.#themes.get(next  ).disabled = false;
 | 
				
			||||||
 | 
					        this.#themes.get(active).disabled = true;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export { App };
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,185 @@
 | 
				
			||||||
 | 
					import java.io.*;
 | 
				
			||||||
 | 
					import java.nio.charset.*;
 | 
				
			||||||
 | 
					import java.time.*;
 | 
				
			||||||
 | 
					import java.util.*;
 | 
				
			||||||
 | 
					import java.util.zip.*;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Web application asset packager
 | 
				
			||||||
 | 
					public class Bundle {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Read a file from disk into a byte buffer
 | 
				
			||||||
 | 
					    static byte[] fileRead(File file) {
 | 
				
			||||||
 | 
					        try (var stream = new FileInputStream(file)) {
 | 
				
			||||||
 | 
					            return stream.readAllBytes();
 | 
				
			||||||
 | 
					        } catch (Exception e) { return null; }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Retrieve the canonical form of a file
 | 
				
			||||||
 | 
					    static File getFile(File file) {
 | 
				
			||||||
 | 
					        try { return file.getCanonicalFile(); }
 | 
				
			||||||
 | 
					        catch (Exception e) { return null; }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Retrieve the canonical file for a relative filename
 | 
				
			||||||
 | 
					    static File getFile(String filename) {
 | 
				
			||||||
 | 
					        return getFile(new File(filename));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // List all canonical files within a directory
 | 
				
			||||||
 | 
					    static File[] listFiles(File dir) {
 | 
				
			||||||
 | 
					        var ret = dir.listFiles();
 | 
				
			||||||
 | 
					        for (int x = 0; x < ret.length; x++)
 | 
				
			||||||
 | 
					            ret[x] = getFile(ret[x]);
 | 
				
			||||||
 | 
					        return ret;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // List all asset files to be bundled
 | 
				
			||||||
 | 
					    static Asset[] listAssets(File root, File main) {
 | 
				
			||||||
 | 
					        var assets = new ArrayList<Asset>();
 | 
				
			||||||
 | 
					        var dirs   = new ArrayList<File >();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Initial directories
 | 
				
			||||||
 | 
					        dirs.add(new File(root, "shrooms-vb-core"));
 | 
				
			||||||
 | 
					        dirs.add(new File(root, "shrooms-vb-web" ));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Process all directories
 | 
				
			||||||
 | 
					        while (dirs.size() != 0) {
 | 
				
			||||||
 | 
					            var dir = dirs.remove(0);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Process all child files and directories
 | 
				
			||||||
 | 
					            for (var file : listFiles(dir)) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Exclude this file or directory
 | 
				
			||||||
 | 
					                if (file.equals(main) || file.getName().startsWith(".git"))
 | 
				
			||||||
 | 
					                    continue;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Include this directory
 | 
				
			||||||
 | 
					                if (file.isDirectory())
 | 
				
			||||||
 | 
					                    dirs.add(file);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Include this file
 | 
				
			||||||
 | 
					                else assets.add(new Asset(root, file));
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Collections.sort(assets);
 | 
				
			||||||
 | 
					        return assets.toArray(new Asset[assets.size()]);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Determine the relative path of a file from the root directory
 | 
				
			||||||
 | 
					    static String relativePath(File root, File file) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Work backwards to identify the full path
 | 
				
			||||||
 | 
					        var path = new ArrayList<String>();
 | 
				
			||||||
 | 
					        while (!root.equals(file)) {
 | 
				
			||||||
 | 
					            path.add(0, file.getName());
 | 
				
			||||||
 | 
					            file = file.getParentFile();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Join the path parts with forward slashes
 | 
				
			||||||
 | 
					        var ret = new StringBuilder();
 | 
				
			||||||
 | 
					        for (String part : path)
 | 
				
			||||||
 | 
					            ret.append("/" + part);
 | 
				
			||||||
 | 
					        return ret.toString().substring(1);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Express a byte array as a Base64 string
 | 
				
			||||||
 | 
					    static String toBase64(byte[] data) {
 | 
				
			||||||
 | 
					        return Base64.getMimeEncoder(0, new byte[0]).encodeToString(data);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Encode a byte array as a zlib buffer
 | 
				
			||||||
 | 
					    static byte[] toZlib(byte[] data) {
 | 
				
			||||||
 | 
					        try {
 | 
				
			||||||
 | 
					            var comp = new Deflater(Deflater.BEST_COMPRESSION, false);
 | 
				
			||||||
 | 
					            comp.setInput(data);
 | 
				
			||||||
 | 
					            comp.finish();
 | 
				
			||||||
 | 
					            var ret = new byte[data.length];
 | 
				
			||||||
 | 
					            ret = Arrays.copyOf(ret, comp.deflate(ret));
 | 
				
			||||||
 | 
					            comp.end();
 | 
				
			||||||
 | 
					            return ret;
 | 
				
			||||||
 | 
					        } catch (Exception e) { throw new RuntimeException(e.getMessage()); }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Program entry point
 | 
				
			||||||
 | 
					    public static void main(String[] args) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Select all assets
 | 
				
			||||||
 | 
					        var root   = getFile("../");
 | 
				
			||||||
 | 
					        var main   = getFile("main.js");
 | 
				
			||||||
 | 
					        var assets = listAssets(root, main);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Resolve the current date code
 | 
				
			||||||
 | 
					        var    today    = ZonedDateTime.now(Clock.systemUTC());
 | 
				
			||||||
 | 
					        String dateCode = String.format("%04d%02d%02d",
 | 
				
			||||||
 | 
					            today.getYear(), today.getMonthValue(), today.getDayOfMonth());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Process the manifest
 | 
				
			||||||
 | 
					        var manifest = new StringBuilder();
 | 
				
			||||||
 | 
					        manifest.append("[");
 | 
				
			||||||
 | 
					        for (var asset : assets) {
 | 
				
			||||||
 | 
					            manifest.append(String.format("%s\"%s\",%d",
 | 
				
			||||||
 | 
					                asset == assets[0] ? "" : ",", asset.name, asset.length));
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        manifest.append(",\"" + dateCode + "\"]");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Encode the bundle
 | 
				
			||||||
 | 
					        var bundle = new ByteArrayOutputStream();
 | 
				
			||||||
 | 
					        try {
 | 
				
			||||||
 | 
					            bundle.write(fileRead(main));
 | 
				
			||||||
 | 
					            bundle.write(0);
 | 
				
			||||||
 | 
					            bundle.write(manifest.toString().getBytes(StandardCharsets.UTF_8));
 | 
				
			||||||
 | 
					            bundle.write(0);
 | 
				
			||||||
 | 
					            for (var asset : assets)
 | 
				
			||||||
 | 
					                bundle.write(fileRead(asset.file));
 | 
				
			||||||
 | 
					        } catch (Exception e) {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Read the HTML template
 | 
				
			||||||
 | 
					        var template = new String(
 | 
				
			||||||
 | 
					            fileRead(new File("template.html")), StandardCharsets.UTF_8)
 | 
				
			||||||
 | 
					            .split("\\\"\\\"");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Generate the output HTML file
 | 
				
			||||||
 | 
					        String filename = "../acid-shroom_" + dateCode + ".html";
 | 
				
			||||||
 | 
					        try (var stream = new FileOutputStream(filename)) {
 | 
				
			||||||
 | 
					            stream.write(template[0].getBytes(StandardCharsets.UTF_8));
 | 
				
			||||||
 | 
					            stream.write('"');
 | 
				
			||||||
 | 
					            stream.write(toBase64(toZlib(bundle.toByteArray()))
 | 
				
			||||||
 | 
					                .getBytes(StandardCharsets.UTF_8));
 | 
				
			||||||
 | 
					            stream.write('"');
 | 
				
			||||||
 | 
					            stream.write(template[1].getBytes(StandardCharsets.UTF_8));
 | 
				
			||||||
 | 
					        } catch (Exception e) {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ///////////////////////////////// Classes /////////////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Packaged asset file
 | 
				
			||||||
 | 
					    static class Asset implements Comparable<Asset> {
 | 
				
			||||||
 | 
					        File   file;   // File on disk
 | 
				
			||||||
 | 
					        int    length; // Size in bytes
 | 
				
			||||||
 | 
					        String name;   // Filename without path
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Asset(File root, File file) {
 | 
				
			||||||
 | 
					            this.file = file;
 | 
				
			||||||
 | 
					            length    = (int) file.length();
 | 
				
			||||||
 | 
					            name      = relativePath(root, file);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        public int compareTo(Asset o) {
 | 
				
			||||||
 | 
					            return name.compareTo(o.name);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        public boolean equals(Object o) {
 | 
				
			||||||
 | 
					            return o instanceof Asset && compareTo((Asset) o) == 0;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        public int hashCode() {
 | 
				
			||||||
 | 
					            return name.hashCode();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,33 @@
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					  "id"  : "en-US",
 | 
				
			||||||
 | 
					  "name": "English (US)",
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  "app": {
 | 
				
			||||||
 | 
					    "menuBar": "Main menu",
 | 
				
			||||||
 | 
					    "title"  : "Acid Shroom"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  "menu.emulation": {
 | 
				
			||||||
 | 
					    "_": "Emulation"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  "menu.file": {
 | 
				
			||||||
 | 
					    "_"           : "File",
 | 
				
			||||||
 | 
					    "debugMode"   : "Debug mode",
 | 
				
			||||||
 | 
					    "dualMode"    : "Dual mode",
 | 
				
			||||||
 | 
					    "exportBundle": "Export bundle...",
 | 
				
			||||||
 | 
					    "loadROM"     : "Load ROM...",
 | 
				
			||||||
 | 
					    "loadROMEx"   : "Load ROM {{index}}...",
 | 
				
			||||||
 | 
					    "loadROMError": "An error occurred while loading the selected file.",
 | 
				
			||||||
 | 
					    "loadROMNotVB": "The selected file does not appear to be a Virtual Boy ROM."
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  "menu.theme": {
 | 
				
			||||||
 | 
					    "_"      : "Theme",
 | 
				
			||||||
 | 
					    "auto"   : "Auto",
 | 
				
			||||||
 | 
					    "dark"   : "Dark",
 | 
				
			||||||
 | 
					    "light"  : "Light",
 | 
				
			||||||
 | 
					    "virtual": "Virtual"
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,281 @@
 | 
				
			||||||
 | 
					// Packaged asset manager
 | 
				
			||||||
 | 
					class Bundle extends Map {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Instance fields
 | 
				
			||||||
 | 
					    #isDebug; // True if running in debug mode
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ///////////////////////// Initialization Methods //////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    constructor() {
 | 
				
			||||||
 | 
					        super();
 | 
				
			||||||
 | 
					        this.#isDebug = location.host=="localhost" && location.hash=="#debug";
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /////////////////////////////// Properties ////////////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Determine whether debug mode is active
 | 
				
			||||||
 | 
					    get isDebug() { return this.#isDebug; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ///////////////////////////// Public Methods //////////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Insert an asset file
 | 
				
			||||||
 | 
					    add(name, data) {
 | 
				
			||||||
 | 
					        let asset = new Bundle.#Asset(this, name, data);
 | 
				
			||||||
 | 
					        this.set(name, asset);
 | 
				
			||||||
 | 
					        return asset;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // List files with names matching a given prefix
 | 
				
			||||||
 | 
					    list(prefix = "") {
 | 
				
			||||||
 | 
					        prefix  = String(prefix);
 | 
				
			||||||
 | 
					        let ret = [];
 | 
				
			||||||
 | 
					        for (let file of this.values()) {
 | 
				
			||||||
 | 
					            if (file.name.startsWith(prefix))
 | 
				
			||||||
 | 
					                ret.push(file);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        return ret.sort((a,b)=>a.name.localeCompare(b.name));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ///////////////////////////////// Classes /////////////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Packaged asset file
 | 
				
			||||||
 | 
					    static #Asset = class Asset {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Private fields
 | 
				
			||||||
 | 
					        #blobURL;   // Cached blob: URL
 | 
				
			||||||
 | 
					        #bundle;    // Parent Bundle object
 | 
				
			||||||
 | 
					        #data;      // Byte contents
 | 
				
			||||||
 | 
					        #dataURL;   // Cached data: URL
 | 
				
			||||||
 | 
					        #mime;      // MIME type
 | 
				
			||||||
 | 
					        #name;      // Filename
 | 
				
			||||||
 | 
					        #transform; // Transform URLs when not in debug mode
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        ////////////////////////////// Constants //////////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Mime types by file extension
 | 
				
			||||||
 | 
					        static #MIMES = {
 | 
				
			||||||
 | 
					            "html" : "text/html;charset=UTF-8"       ,
 | 
				
			||||||
 | 
					            "css"  : "text/css;charset=UTF-8"        ,
 | 
				
			||||||
 | 
					            "frag" : "text/plain;charset=UTF-8"      ,
 | 
				
			||||||
 | 
					            "gif"  : "image/gif"                     ,
 | 
				
			||||||
 | 
					            "js"   : "text/javascript;charset=UTF-8" ,
 | 
				
			||||||
 | 
					            "json" : "application/json;charset=UTF-8",
 | 
				
			||||||
 | 
					            "png"  : "image/png"                     ,
 | 
				
			||||||
 | 
					            "svg"  : "image/svg+xml;charset=UTF-8"   ,
 | 
				
			||||||
 | 
					            "txt"  : "text/plain;charset=UTF-8"      ,
 | 
				
			||||||
 | 
					            "vert" : "text/plain;charset=UTF-8"      ,
 | 
				
			||||||
 | 
					            "wasm" : "application/wasm"              ,
 | 
				
			||||||
 | 
					            "webp" : "image/webp"                    ,
 | 
				
			||||||
 | 
					            "woff2": "font/woff2"
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        /////////////////////// Initialization Methods ////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        constructor(bundle, name, data) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Select the MIME type from the file extension
 | 
				
			||||||
 | 
					            let mime = "." + name;
 | 
				
			||||||
 | 
					            let ext  = mime.substring(mime.lastIndexOf(".") + 1).toLowerCase();
 | 
				
			||||||
 | 
					            mime = Bundle.#Asset.#MIMES[ext] ?? "application/octet-stream";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Configure instanc fields
 | 
				
			||||||
 | 
					            this.#blobURL   = null;
 | 
				
			||||||
 | 
					            this.#bundle    = bundle;
 | 
				
			||||||
 | 
					            this.#data      = data;
 | 
				
			||||||
 | 
					            this.#dataURL   = null;
 | 
				
			||||||
 | 
					            this.#mime      = mime;
 | 
				
			||||||
 | 
					            this.#name      = name;
 | 
				
			||||||
 | 
					            this.#transform = ext == "css" || ext == "js";
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        ///////////////////////////// Properties //////////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Retrieve and potentially cache the blob: URL
 | 
				
			||||||
 | 
					        get blobURL() {
 | 
				
			||||||
 | 
					            if (this.#blobURL == null) {
 | 
				
			||||||
 | 
					                this.#blobURL = URL.createObjectURL(
 | 
				
			||||||
 | 
					                    new Blob([this.#urlData()], { type: this.#mime }));
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            return this.#blobURL;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Byte contents
 | 
				
			||||||
 | 
					        get data() { return this.#data; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Retrieve and potentially cache the data: URL
 | 
				
			||||||
 | 
					        get dataURL() {
 | 
				
			||||||
 | 
					            if (this.#dataURL == null) {
 | 
				
			||||||
 | 
					                this.#dataURL = "data:" + this.#mime + ";base64," + btoa(
 | 
				
			||||||
 | 
					                    Array.from(this.#urlData()).map(b=>String.fromCodePoint(b))
 | 
				
			||||||
 | 
					                    .join(""));
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            return this.#dataURL;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Filename
 | 
				
			||||||
 | 
					        get name() { return this.#name; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Text contents as UTF-8
 | 
				
			||||||
 | 
					        get text() { return new TextDecoder().decode(this.#data); }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Produce any suitable URL to fetch this file
 | 
				
			||||||
 | 
					        get url() {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Use the blob: URL in debug mode
 | 
				
			||||||
 | 
					            if (!this.#bundle.isDebug)
 | 
				
			||||||
 | 
					                return this.blobURL;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Resolve the virtual path otherwise
 | 
				
			||||||
 | 
					            let href = location.href.split("/");
 | 
				
			||||||
 | 
					            href.pop();
 | 
				
			||||||
 | 
					            return href.join("/") + "/" + this.name;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        /////////////////////////// Private Methods ///////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Prepare a data buffer for use in a data or blob URL
 | 
				
			||||||
 | 
					        #urlData() {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // No need to transform inner URLs
 | 
				
			||||||
 | 
					            if (!this.#transform)
 | 
				
			||||||
 | 
					                return this.#data;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Working variables
 | 
				
			||||||
 | 
					            let regex = /\/\*\*?\*\//g;
 | 
				
			||||||
 | 
					            let ret   = [];
 | 
				
			||||||
 | 
					            let src   = 0;
 | 
				
			||||||
 | 
					            let text  = this.text;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Transform all inner URLs
 | 
				
			||||||
 | 
					            for (;;) {
 | 
				
			||||||
 | 
					                let match = regex.exec(text);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // No more inner URLs
 | 
				
			||||||
 | 
					                if (match == null)
 | 
				
			||||||
 | 
					                    break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Locate the URL to transform
 | 
				
			||||||
 | 
					                let end, start;
 | 
				
			||||||
 | 
					                try {
 | 
				
			||||||
 | 
					                    start = text.indexOf("\"", match.index);
 | 
				
			||||||
 | 
					                    if (start == -1)
 | 
				
			||||||
 | 
					                        throw 0;
 | 
				
			||||||
 | 
					                    end   = text.indexOf("\"", ++start);
 | 
				
			||||||
 | 
					                    if (end == -1)
 | 
				
			||||||
 | 
					                        throw 0;
 | 
				
			||||||
 | 
					                } catch {
 | 
				
			||||||
 | 
					                    throw new Error(
 | 
				
			||||||
 | 
					                        "Malformed URL designator.\n" +
 | 
				
			||||||
 | 
					                        "File: " + this.name
 | 
				
			||||||
 | 
					                    );
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Working variables
 | 
				
			||||||
 | 
					                let url   = text.substring(start, end);
 | 
				
			||||||
 | 
					                let parts = url.split("/");
 | 
				
			||||||
 | 
					                let stack = [];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Initialize the stack to current path if URL is relative
 | 
				
			||||||
 | 
					                if (parts[0] == "." || parts[0] == "..") {
 | 
				
			||||||
 | 
					                    stack = this.name.split("/");
 | 
				
			||||||
 | 
					                    stack.pop();
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Process the URL path
 | 
				
			||||||
 | 
					                while (parts.length > 1) {
 | 
				
			||||||
 | 
					                    let part = parts.shift();
 | 
				
			||||||
 | 
					                    switch (part) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                        // Current directory--do not modify stack
 | 
				
			||||||
 | 
					                        case ".": break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                        // Parent directory--pop from stack
 | 
				
			||||||
 | 
					                        case "..":
 | 
				
			||||||
 | 
					                            if (stack.length == 0) {
 | 
				
			||||||
 | 
					                                throw new Error(
 | 
				
			||||||
 | 
					                                    "Stack underflow when parsing URL.\n" +
 | 
				
			||||||
 | 
					                                    "File: " + this.name + "\n" +
 | 
				
			||||||
 | 
					                                    "URL: " + url
 | 
				
			||||||
 | 
					                                );
 | 
				
			||||||
 | 
					                            }
 | 
				
			||||||
 | 
					                            stack.pop();
 | 
				
			||||||
 | 
					                            break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                        // Child directory--push to stack
 | 
				
			||||||
 | 
					                        default: stack.push(part);
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Compose the resolved filename
 | 
				
			||||||
 | 
					                let filename = stack.concat(parts).join("/");
 | 
				
			||||||
 | 
					                if (!this.#bundle.has(filename)) {
 | 
				
			||||||
 | 
					                    throw new Error(
 | 
				
			||||||
 | 
					                        "Referenced file does not exist.\n" +
 | 
				
			||||||
 | 
					                        "File: " + this.name + "\n" +
 | 
				
			||||||
 | 
					                        "URL: " + url + "\n" +
 | 
				
			||||||
 | 
					                        "Path: " + filename
 | 
				
			||||||
 | 
					                    );
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Working variables
 | 
				
			||||||
 | 
					                let file   = this.#bundle.get(filename);
 | 
				
			||||||
 | 
					                let newUrl = match[0] == "/**/" ? file.blobURL : file.dataURL;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Append the output text
 | 
				
			||||||
 | 
					                ret.push(text.substring(src, start), newUrl);
 | 
				
			||||||
 | 
					                src = end;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Incorporate remaining text
 | 
				
			||||||
 | 
					            ret.push(text.substring(src));
 | 
				
			||||||
 | 
					            return new TextEncoder().encode(ret.join(""));
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Program entry point
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Remove startup <script> elements
 | 
				
			||||||
 | 
					    let bytes = document.querySelectorAll("script");
 | 
				
			||||||
 | 
					    for (let script of bytes)
 | 
				
			||||||
 | 
					        script.remove();
 | 
				
			||||||
 | 
					    bytes = bytes[1].bytes;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Wait for the bundle element to finish loading
 | 
				
			||||||
 | 
					    if (document.readyState != "complete")
 | 
				
			||||||
 | 
					        await new Promise(resolve=>window.addEventListener("load", resolve));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Parse the manifest from the byte buffer
 | 
				
			||||||
 | 
					    let x = bytes.indexOf(0) + 1;
 | 
				
			||||||
 | 
					    let y = bytes.indexOf(0, x);
 | 
				
			||||||
 | 
					    let manifest = JSON.parse(new TextDecoder().decode(bytes.subarray(x, y)));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Compose the bundle from the packaged asset files
 | 
				
			||||||
 | 
					    let bundle = new Bundle();
 | 
				
			||||||
 | 
					    bundle.add("shrooms-vb-web/main.js", bytes.subarray(0, x - 1));
 | 
				
			||||||
 | 
					    for (x = 0, y++; x < manifest.length; x += 2)
 | 
				
			||||||
 | 
					        bundle.add(manifest[x], bytes.subarray(y, y += manifest[x + 1]));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Launch the application
 | 
				
			||||||
 | 
					    new (await import(bundle.get("shrooms-vb-web/App.js").url))
 | 
				
			||||||
 | 
					        .App(bundle, manifest.pop());
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1 @@
 | 
				
			||||||
 | 
					<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title></title><link rel="icon" href="data:;base64,iVBORw0KGgo="><style>body{background:#fff}@media screen and (prefers-color-scheme:dark){body{background:#111}}</style><script type="module">{let a,b=document.createElement("script");b.bytes=a=new Uint8Array(await new Response(new Blob([Uint8Array.from(Array.from(atob("")).map(c=>c.codePointAt(0)))]).stream().pipeThrough(new DecompressionStream("deflate"))).arrayBuffer());b.type="module";b.src=URL.createObjectURL(new Blob([a.subarray(0,a.indexOf(0))],{type:"text/javascript"}));document.head.append(b);}</script></head><body></body></html>
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,6 @@
 | 
				
			||||||
 | 
					<?xml version="1.0" encoding="UTF-8" standalone="no"?>
 | 
				
			||||||
 | 
					<svg xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 2.6458 2.6458" version="1.1">
 | 
				
			||||||
 | 
					  <g>
 | 
				
			||||||
 | 
					    <path style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.26458px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" d="m 1.05832,1.653625 1.3229,-1.3229 v 0.66145 l -1.3229,1.3229 -0.79374,-0.79374 v -0.66145 z" />
 | 
				
			||||||
 | 
					  </g>
 | 
				
			||||||
 | 
					</svg>
 | 
				
			||||||
| 
		 After Width: | Height: | Size: 459 B  | 
| 
						 | 
					@ -0,0 +1,20 @@
 | 
				
			||||||
 | 
					:root {
 | 
				
			||||||
 | 
					  --tk-control              : #333333;
 | 
				
			||||||
 | 
					  --tk-control-active       : #555555;
 | 
				
			||||||
 | 
					  --tk-control-border       : #cccccc;
 | 
				
			||||||
 | 
					  --tk-control-disabled-text: #9b9b9b;
 | 
				
			||||||
 | 
					  --tk-control-shadow       : #9b9b9b;
 | 
				
			||||||
 | 
					  --tk-control-text         : #e4e4e4;
 | 
				
			||||||
 | 
					  --tk-desktop              : #111111;
 | 
				
			||||||
 | 
					  --tk-window               : #111111;
 | 
				
			||||||
 | 
					  --tk-window-text          : #e4e4e4;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.tk .menu-bar .menu-item[aria-disabled] {
 | 
				
			||||||
 | 
					  text-shadow:
 | 
				
			||||||
 | 
					    -1px -1px 1px var(--tk-control),
 | 
				
			||||||
 | 
					     1px -1px 1px var(--tk-control),
 | 
				
			||||||
 | 
					     1px  1px 1px var(--tk-control),
 | 
				
			||||||
 | 
					    -1px  1px 1px var(--tk-control)
 | 
				
			||||||
 | 
					  ;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
										
											Binary file not shown.
										
									
								
							| 
						 | 
					@ -0,0 +1,128 @@
 | 
				
			||||||
 | 
					:root {
 | 
				
			||||||
 | 
					  --tk-font-dialog: "Roboto", sans-serif;
 | 
				
			||||||
 | 
					  --tk-font-mono  : "Inconsolata SemiExpanded Medium", monospace;
 | 
				
			||||||
 | 
					  --tk-text-scale : 0.75rem;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@font-face {
 | 
				
			||||||
 | 
					  font-family: "Roboto";
 | 
				
			||||||
 | 
					  src        : /**/url("./roboto.woff2") format("woff2");
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@font-face {
 | 
				
			||||||
 | 
					  font-family: "Inconsolata SemiExpanded Medium";
 | 
				
			||||||
 | 
					  src        : /**/url("./inconsolata.woff2") format("woff2");
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					body {
 | 
				
			||||||
 | 
					  margin  : 0;
 | 
				
			||||||
 | 
					  overflow: hidden;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.tk, .tk * {
 | 
				
			||||||
 | 
					  box-sizing : border-box;
 | 
				
			||||||
 | 
					  font-family: var(--tk-font-dialog);
 | 
				
			||||||
 | 
					  font-size  : var(--tk-text-scale);
 | 
				
			||||||
 | 
					  line-height: 1em;
 | 
				
			||||||
 | 
					  margin     : 0;
 | 
				
			||||||
 | 
					  outline    : none;
 | 
				
			||||||
 | 
					  padding    : 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.tk.mono {
 | 
				
			||||||
 | 
					  font-family: var(--tk-font-mono);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/*********************************** Menus ***********************************/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.tk .menu-bar, .tk .menu-bar .menu {
 | 
				
			||||||
 | 
					  background   : var(--tk-control);
 | 
				
			||||||
 | 
					  border-bottom: 1px solid var(--tk-control-border);
 | 
				
			||||||
 | 
					  gap          : 2px;
 | 
				
			||||||
 | 
					  padding      : 2px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.tk .menu-bar .menu-item > div {
 | 
				
			||||||
 | 
					  align-items: center;
 | 
				
			||||||
 | 
					  border     : 1px solid transparent;
 | 
				
			||||||
 | 
					  color      : var(--tk-control-text);
 | 
				
			||||||
 | 
					  cursor     : default;
 | 
				
			||||||
 | 
					  gap        : 2px;
 | 
				
			||||||
 | 
					  margin     : 0 1px 1px 0;
 | 
				
			||||||
 | 
					  padding    : 1px;
 | 
				
			||||||
 | 
					  user-select: none;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.tk .menu-bar .menu-item[aria-disabled] > div {
 | 
				
			||||||
 | 
					  color: var(--tk-control-disabled-text);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.tk .menu-bar .menu {
 | 
				
			||||||
 | 
					  border     : 1px solid var(--tk-control-border);
 | 
				
			||||||
 | 
					  box-shadow : 1px 1px 0 var(--tk-control-border);
 | 
				
			||||||
 | 
					  margin     : -1px 0 0 1px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.tk .menu-bar .menu-item:not(.active):is(:focus,:hover) > div {
 | 
				
			||||||
 | 
					  border-color: var(--tk-control-shadow);
 | 
				
			||||||
 | 
					  box-shadow  : 1px 1px 0 var(--tk-control-shadow);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.tk .menu-bar .menu-item:not(.active):is(:focus) > div {
 | 
				
			||||||
 | 
					  background: var(--tk-control-active);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.tk .menu-bar .menu-item.active > div {
 | 
				
			||||||
 | 
					  background  : var(--tk-control-active);
 | 
				
			||||||
 | 
					  border-color: var(--tk-control-shadow);
 | 
				
			||||||
 | 
					  box-shadow  : none;
 | 
				
			||||||
 | 
					  margin      : 1px 0 0 1px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.tk .menu-bar .menu-item[role="menuitemradio"] > div > :nth-child(1) {
 | 
				
			||||||
 | 
					  background   : var(--tk-window);
 | 
				
			||||||
 | 
					  border       : 1px solid var(--tk-control-border);
 | 
				
			||||||
 | 
					  border-radius: 50%;
 | 
				
			||||||
 | 
					  height       : 1em;
 | 
				
			||||||
 | 
					  width        : 1em;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.tk .menu-bar .menu-item[role="menuitemradio"][aria-checked="true"] >
 | 
				
			||||||
 | 
					  div > :nth-child(1):before {
 | 
				
			||||||
 | 
					  background   : var(--tk-window-text);
 | 
				
			||||||
 | 
					  content      : "";
 | 
				
			||||||
 | 
					  display      : block;
 | 
				
			||||||
 | 
					  height       : 100%;
 | 
				
			||||||
 | 
					  mask-image   : /**/url("./radio.svg");
 | 
				
			||||||
 | 
					  mask-position: center;
 | 
				
			||||||
 | 
					  mask-repeat  : no-repeat;
 | 
				
			||||||
 | 
					  mask-size    : contain;
 | 
				
			||||||
 | 
					  width        : 100%;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.tk .menu-bar .menu-item[role="menuitemcheckbox"] > div > :nth-child(1) {
 | 
				
			||||||
 | 
					  background: var(--tk-window);
 | 
				
			||||||
 | 
					  border    : 1px solid var(--tk-control-border);
 | 
				
			||||||
 | 
					  height    : 1em;
 | 
				
			||||||
 | 
					  width     : 1em;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.tk .menu-bar .menu-item[role="menuitemcheckbox"][aria-checked="true"] >
 | 
				
			||||||
 | 
					  div > :nth-child(1):before {
 | 
				
			||||||
 | 
					  background   : var(--tk-window-text);
 | 
				
			||||||
 | 
					  content      : "";
 | 
				
			||||||
 | 
					  display      : block;
 | 
				
			||||||
 | 
					  height       : 100%;
 | 
				
			||||||
 | 
					  mask-image   : /**/url("./check.svg");
 | 
				
			||||||
 | 
					  mask-position: center;
 | 
				
			||||||
 | 
					  mask-repeat  : no-repeat;
 | 
				
			||||||
 | 
					  mask-size    : contain;
 | 
				
			||||||
 | 
					  width        : 100%;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.tk .menu-bar .menu-separator {
 | 
				
			||||||
 | 
					  margin    : 2px;
 | 
				
			||||||
 | 
					  border-top: 1px solid var(--tk-control-shadow);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,11 @@
 | 
				
			||||||
 | 
					:root {
 | 
				
			||||||
 | 
					  --tk-control              : #eeeeee;
 | 
				
			||||||
 | 
					  --tk-control-active       : #cccccc;
 | 
				
			||||||
 | 
					  --tk-control-border       : #000000;
 | 
				
			||||||
 | 
					  --tk-control-disabled-text: #565656;
 | 
				
			||||||
 | 
					  --tk-control-shadow       : #6c6c6c;
 | 
				
			||||||
 | 
					  --tk-control-text         : #000000;
 | 
				
			||||||
 | 
					  --tk-desktop              : #cccccc;
 | 
				
			||||||
 | 
					  --tk-window               : #ffffff;
 | 
				
			||||||
 | 
					  --tk-window-text          : #000000;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,6 @@
 | 
				
			||||||
 | 
					<?xml version="1.0" encoding="UTF-8" standalone="no"?>
 | 
				
			||||||
 | 
					<svg xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 2.6458332 2.6458332" version="1.1">
 | 
				
			||||||
 | 
					  <g>
 | 
				
			||||||
 | 
					    <circle style="opacity:1;fill:#000000;stroke-width:0.264583;stroke-linecap:square" cx="1.3229166" cy="1.3229166" r="0.66145831" />
 | 
				
			||||||
 | 
					  </g>
 | 
				
			||||||
 | 
					</svg>
 | 
				
			||||||
| 
		 After Width: | Height: | Size: 361 B  | 
										
											Binary file not shown.
										
									
								
							| 
						 | 
					@ -0,0 +1,20 @@
 | 
				
			||||||
 | 
					:root {
 | 
				
			||||||
 | 
					  --tk-control              : #000000;
 | 
				
			||||||
 | 
					  --tk-control-active       : #550000;
 | 
				
			||||||
 | 
					  --tk-control-border       : #ff0000;
 | 
				
			||||||
 | 
					  --tk-control-disabled-text: #770000;
 | 
				
			||||||
 | 
					  --tk-control-shadow       : #aa0000;
 | 
				
			||||||
 | 
					  --tk-control-text         : #ff0000;
 | 
				
			||||||
 | 
					  --tk-desktop              : #000000;
 | 
				
			||||||
 | 
					  --tk-window               : #000000;
 | 
				
			||||||
 | 
					  --tk-window-text          : #ff0000;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.tk .menu-bar .menu-item[aria-disabled] {
 | 
				
			||||||
 | 
					  text-shadow:
 | 
				
			||||||
 | 
					    -1px -1px 1px var(--tk-control),
 | 
				
			||||||
 | 
					     1px -1px 1px var(--tk-control),
 | 
				
			||||||
 | 
					     1px  1px 1px var(--tk-control),
 | 
				
			||||||
 | 
					    -1px  1px 1px var(--tk-control)
 | 
				
			||||||
 | 
					  ;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,215 @@
 | 
				
			||||||
 | 
					// Top-level application container
 | 
				
			||||||
 | 
					export default (Toolkit,_package)=>class App extends Toolkit.Component {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Instance fields
 | 
				
			||||||
 | 
					    #components; // Registered Toolkit.Components
 | 
				
			||||||
 | 
					    #locale;     // Current display text dictionary
 | 
				
			||||||
 | 
					    #locales;    // Registered display text dictionaries
 | 
				
			||||||
 | 
					    #title;      // Application title
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ///////////////////////// Initialization Methods //////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    static {
 | 
				
			||||||
 | 
					        _package.App = {
 | 
				
			||||||
 | 
					            localize: (a,c)=>a.#localize(c),
 | 
				
			||||||
 | 
					            onCreate: (a,c)=>a.#onCreate(c),
 | 
				
			||||||
 | 
					            onDelete: (a,c)=>a.#onDelete(c)
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    constructor(overrides) {
 | 
				
			||||||
 | 
					        super(null, _package.override(overrides, { className: "tk-app" }));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        this.#components = new Set();
 | 
				
			||||||
 | 
					        this.#locale     = null;
 | 
				
			||||||
 | 
					        this.#locales    = new Map();
 | 
				
			||||||
 | 
					        this.#title      = null;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /////////////////////////////// Properties ////////////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Display text dictionary
 | 
				
			||||||
 | 
					    get locale() { return this.#locale?.get("id"); }
 | 
				
			||||||
 | 
					    set locale(value) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Unset the locale
 | 
				
			||||||
 | 
					        if (value == null) {
 | 
				
			||||||
 | 
					            if (this.#locale == null)
 | 
				
			||||||
 | 
					                return;
 | 
				
			||||||
 | 
					            this.#locale = null;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Change to a different locale
 | 
				
			||||||
 | 
					        else {
 | 
				
			||||||
 | 
					            value = value == null ? null : String(value);
 | 
				
			||||||
 | 
					            if (this.#locale?.get("id") == value)
 | 
				
			||||||
 | 
					                return; // Same locale specified
 | 
				
			||||||
 | 
					            if (!this.#locales.has(value))
 | 
				
			||||||
 | 
					                throw new RangeError("No locale with ID " + value);
 | 
				
			||||||
 | 
					            this.#locale = this.#locales.get(value);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Update all display text
 | 
				
			||||||
 | 
					        this.#localize();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Display title
 | 
				
			||||||
 | 
					    get title() { return this.#title; }
 | 
				
			||||||
 | 
					    set title(value) {
 | 
				
			||||||
 | 
					        if (value != null && !(value instanceof RegExp))
 | 
				
			||||||
 | 
					            value = String(value);
 | 
				
			||||||
 | 
					        this.#title = value;
 | 
				
			||||||
 | 
					        this.#onLocalize();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ///////////////////////////// Public Methods //////////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Register translation text
 | 
				
			||||||
 | 
					    addLocale(data) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Error checking
 | 
				
			||||||
 | 
					        if (!(data instanceof Object))
 | 
				
			||||||
 | 
					            throw new TypeError("Data must be an object.");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Working variables
 | 
				
			||||||
 | 
					        let locale  = new Map();
 | 
				
			||||||
 | 
					        let objects = [ [null,data] ];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Process all objects
 | 
				
			||||||
 | 
					        while (objects.length != 0) {
 | 
				
			||||||
 | 
					            let object = objects.shift();
 | 
				
			||||||
 | 
					            let prefix = object[0] ? object[0] + "." : "";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Process all members of the object
 | 
				
			||||||
 | 
					            for (let entry of Object.entries(object[1])) {
 | 
				
			||||||
 | 
					                let key   = prefix + entry[0];
 | 
				
			||||||
 | 
					                let value = entry[1];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Add the new object to he queue
 | 
				
			||||||
 | 
					                if (value instanceof Object) {
 | 
				
			||||||
 | 
					                    objects.push([ key, entry[1] ]);
 | 
				
			||||||
 | 
					                    continue;
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Error checking
 | 
				
			||||||
 | 
					                if (typeof(value) != "string")
 | 
				
			||||||
 | 
					                    throw new TypeError("Non-string value encountered: "+key);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Register the localization value
 | 
				
			||||||
 | 
					                locale.set(key, new RegExp(value));
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Validate "id"
 | 
				
			||||||
 | 
					        let id = locale.get("id");
 | 
				
			||||||
 | 
					        if (id == null)
 | 
				
			||||||
 | 
					            throw new Error("Locale does not contain \"id\" member.");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Register the locale
 | 
				
			||||||
 | 
					        this.#locales.set(id.source, locale);
 | 
				
			||||||
 | 
					        return id;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Resolve the value of some display text
 | 
				
			||||||
 | 
					    translate(text, comp = null) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Error checking
 | 
				
			||||||
 | 
					        if (comp != null) {
 | 
				
			||||||
 | 
					            if (!(comp instanceof Toolkit.Component))
 | 
				
			||||||
 | 
					                throw new TypeError("Component must be a Toolkit.Component.");
 | 
				
			||||||
 | 
					            if (comp != this && comp.app != this)
 | 
				
			||||||
 | 
					                throw new RangeError("Compoment must belong to this App.");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Nothing to resolve
 | 
				
			||||||
 | 
					        if (text == null)
 | 
				
			||||||
 | 
					            return null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Input validation
 | 
				
			||||||
 | 
					        if (!(text instanceof RegExp))
 | 
				
			||||||
 | 
					            text = String(text);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Working variables
 | 
				
			||||||
 | 
					        let locale = this.#locale;
 | 
				
			||||||
 | 
					        let ret    = [ text ];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Process all substitutions
 | 
				
			||||||
 | 
					        for (let x = 0; x < ret.length; x++) {
 | 
				
			||||||
 | 
					            let part = ret[x];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Do not perform substitutions
 | 
				
			||||||
 | 
					            if (!(part instanceof RegExp))
 | 
				
			||||||
 | 
					                continue;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Working variables
 | 
				
			||||||
 | 
					            part = part.source;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Locate the close of the innermost substitution
 | 
				
			||||||
 | 
					            let close = part.indexOf("}}");
 | 
				
			||||||
 | 
					            if (close == -1) {
 | 
				
			||||||
 | 
					                if (part.indexOf("{{") != -1)
 | 
				
			||||||
 | 
					                    throw new Error("Found {{ without matching }}.");
 | 
				
			||||||
 | 
					                ret[x] = part;
 | 
				
			||||||
 | 
					                continue;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Locate the opening of the innermost substitution
 | 
				
			||||||
 | 
					            let open = part.substring(0, close).lastIndexOf("{{");
 | 
				
			||||||
 | 
					            if (open == -1)
 | 
				
			||||||
 | 
					                throw new Error("Found }} without matching {{.");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Working variables
 | 
				
			||||||
 | 
					            let after  = part.substring(close + 2);
 | 
				
			||||||
 | 
					            let before = part.substring(0, open);
 | 
				
			||||||
 | 
					            let key    = part.substring(open + 2, close).trim();
 | 
				
			||||||
 | 
					            let value  = comp?.getSubstitution(key) ?? locale?.get(key) ??
 | 
				
			||||||
 | 
					                "{{\u00d7" + key.toUpperCase() + "\u00d7}}";
 | 
				
			||||||
 | 
					            let within = value instanceof RegExp ? value.source : value;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Compose the replacement text
 | 
				
			||||||
 | 
					            part = before + within + after;
 | 
				
			||||||
 | 
					            if (value instanceof RegExp)
 | 
				
			||||||
 | 
					                ret[x--] = new RegExp(part);
 | 
				
			||||||
 | 
					            else ret[x] = part;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return ret.join("");
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ///////////////////////////// Event Handlers //////////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Component created
 | 
				
			||||||
 | 
					    #onCreate(comp) {
 | 
				
			||||||
 | 
					        this.#components.add(comp);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Component deleted
 | 
				
			||||||
 | 
					    #onDelete(comp) {
 | 
				
			||||||
 | 
					        this.#components.delete(comp);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Configure display text
 | 
				
			||||||
 | 
					    #onLocalize() {
 | 
				
			||||||
 | 
					        document.title = this.translate(this.#title, this) ?? "";
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ///////////////////////////// Package Methods /////////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Update the display text for one or all components
 | 
				
			||||||
 | 
					    #localize(comp = null) {
 | 
				
			||||||
 | 
					        for (comp of (comp == null ? this.#components : [comp]))
 | 
				
			||||||
 | 
					            _package.Component.onLocalize(comp);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,315 @@
 | 
				
			||||||
 | 
					// Discrete UI widget
 | 
				
			||||||
 | 
					export default (Toolkit,_package)=>class Component {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Instance fields
 | 
				
			||||||
 | 
					    #_app;            // Containing app
 | 
				
			||||||
 | 
					    #_children;       // Child components
 | 
				
			||||||
 | 
					    #_element;        // Managed HTML element
 | 
				
			||||||
 | 
					    #_eventListeners; // Active event listeners
 | 
				
			||||||
 | 
					    #_parent;         // Containing component
 | 
				
			||||||
 | 
					    #_substitutions;  // Subtituted text entries
 | 
				
			||||||
 | 
					    #_visibility;     // Control visible property with CSS visibility
 | 
				
			||||||
 | 
					    #_visible;        // CSS display value to restore visibility
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ///////////////////////// Initialization Methods //////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    static {
 | 
				
			||||||
 | 
					        _package.Component = {
 | 
				
			||||||
 | 
					            onAdd     : c=>c.#onAdd(),
 | 
				
			||||||
 | 
					            onLocalize: (c,l)=>c.#onLocalize(l),
 | 
				
			||||||
 | 
					            setParent : (c,p)=>c.#_parent=p
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    constructor(app, overrides) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Error checking
 | 
				
			||||||
 | 
					        if (
 | 
				
			||||||
 | 
					            !(app  instanceof Toolkit.App) &&
 | 
				
			||||||
 | 
					            !(this instanceof Toolkit.App)
 | 
				
			||||||
 | 
					        ) throw new TypeError("Must supply a Toolkit.App.");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Working variables
 | 
				
			||||||
 | 
					        overrides   = Object.assign({}, overrides ?? {});
 | 
				
			||||||
 | 
					        let tagName = overrides.tagName ?? "div";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Instance fields
 | 
				
			||||||
 | 
					        this.#_app           = app;
 | 
				
			||||||
 | 
					        this.#_children      = null;
 | 
				
			||||||
 | 
					        this.#_element       = document.createElement(tagName);
 | 
				
			||||||
 | 
					        this.#_parent        = null;
 | 
				
			||||||
 | 
					        this.#_substitutions = null;
 | 
				
			||||||
 | 
					        this.visibility      = overrides.visibility;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Register the element with the Toolkit environment
 | 
				
			||||||
 | 
					        this.element[_package.componentKey] = this;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Apply overrides
 | 
				
			||||||
 | 
					        Object.assign(this.#_element.style, overrides.style ?? {});
 | 
				
			||||||
 | 
					        for (let entry of Object.entries(overrides)) {
 | 
				
			||||||
 | 
					            let key   = entry[0];
 | 
				
			||||||
 | 
					            let value = entry[1];
 | 
				
			||||||
 | 
					            switch (key) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Properties that are handled in other ways
 | 
				
			||||||
 | 
					                case "style":
 | 
				
			||||||
 | 
					                case "tagName":
 | 
				
			||||||
 | 
					                case "visibility":
 | 
				
			||||||
 | 
					                    break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Properties of the component
 | 
				
			||||||
 | 
					                case "enabled":
 | 
				
			||||||
 | 
					                case "focusable":
 | 
				
			||||||
 | 
					                case "id":
 | 
				
			||||||
 | 
					                case "visible":
 | 
				
			||||||
 | 
					                    this[key] = value;
 | 
				
			||||||
 | 
					                    break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Properties with special handling
 | 
				
			||||||
 | 
					                case "ariaLabelledBy":
 | 
				
			||||||
 | 
					                    if (value != null)
 | 
				
			||||||
 | 
					                        this.#_element.setAttribute("aria-labelledby", value);
 | 
				
			||||||
 | 
					                    else this.#_element.removeAttribute("aria-labelledby");
 | 
				
			||||||
 | 
					                    break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Properties of the element
 | 
				
			||||||
 | 
					                default:
 | 
				
			||||||
 | 
					                    this.#_element[key] = value;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Register the component with the app
 | 
				
			||||||
 | 
					        if (app != null)
 | 
				
			||||||
 | 
					            _package.App.onCreate(app, this);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /////////////////////////////// Properties ////////////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Containing Toolkit.App
 | 
				
			||||||
 | 
					    get app() { return this.#_app; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Child components
 | 
				
			||||||
 | 
					    get children() { return (this.#_children ?? []).slice(); }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // HTML class list
 | 
				
			||||||
 | 
					    get classList() { return this.#_element.classList; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // HTML element
 | 
				
			||||||
 | 
					    get element() { return this.#_element; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // HTML element ID
 | 
				
			||||||
 | 
					    get id()      { return this.#_element.id || null; }
 | 
				
			||||||
 | 
					    set id(value) { this.#_element.id = String(value ?? ""); }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Containing Toolkit.Component
 | 
				
			||||||
 | 
					    get parent() { return this.#_parent; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // HTML element style declaration state
 | 
				
			||||||
 | 
					    get style() { return this.#_element.style; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Visibility control
 | 
				
			||||||
 | 
					    get visibility() { return this.#_visibility; }
 | 
				
			||||||
 | 
					    set visibility(value) {
 | 
				
			||||||
 | 
					        value = !!value;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Property is not changing
 | 
				
			||||||
 | 
					        if (value == this.#_visibility)
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Update the visibility mode
 | 
				
			||||||
 | 
					        let visible       = this.visible;
 | 
				
			||||||
 | 
					        this.#_visibility = value;
 | 
				
			||||||
 | 
					        this.visible      = visible;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // HTML element visibility
 | 
				
			||||||
 | 
					    get visible() { return this.#_visible == null; }
 | 
				
			||||||
 | 
					    set visible(value) {
 | 
				
			||||||
 | 
					        value = !!value;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Property is not changing
 | 
				
			||||||
 | 
					        if (value == (this.#_visible == null))
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Show the element
 | 
				
			||||||
 | 
					        if (value) {
 | 
				
			||||||
 | 
					            if (this.#_visibility)
 | 
				
			||||||
 | 
					                this.#_element.style.removeProperty("visibility");
 | 
				
			||||||
 | 
					            else if (this.#_visible == "")
 | 
				
			||||||
 | 
					                this.#_element.style.removeProperty("display");
 | 
				
			||||||
 | 
					            else this.#_element.style.display = this.#_visible;
 | 
				
			||||||
 | 
					            this.#_visible = null;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Hide the element
 | 
				
			||||||
 | 
					        else {
 | 
				
			||||||
 | 
					            this.#_visible = this.#_element.style.display;
 | 
				
			||||||
 | 
					            if (this.#_visibility)
 | 
				
			||||||
 | 
					                this.#_element.style.visibility = "hidden";
 | 
				
			||||||
 | 
					            else this.#_element.style.display = "none";
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ///////////////////////////// Event Handlers //////////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Component added to parent, shold be overridden as needed
 | 
				
			||||||
 | 
					    #onAdd() {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Configure display text, should be overridden as needed
 | 
				
			||||||
 | 
					    #onLocalize() {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ///////////////////////////// Public Methods //////////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Add a child component
 | 
				
			||||||
 | 
					    add(comp) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Error checking
 | 
				
			||||||
 | 
					        if (!(comp instanceof Toolkit.Component))
 | 
				
			||||||
 | 
					            throw new TypeError("Component must be a Toolkit.Component.");
 | 
				
			||||||
 | 
					        if (comp.app != this && comp.app != this.#_app)
 | 
				
			||||||
 | 
					            throw new RangeError("Component must belong to the same App.");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // TODO: Disassociate the component from its current parent
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Associate the component
 | 
				
			||||||
 | 
					        (this.#_children ??= []).push(comp);
 | 
				
			||||||
 | 
					        comp.#_parent = this;
 | 
				
			||||||
 | 
					        if (arguments[1] === false)
 | 
				
			||||||
 | 
					            return; // Undocumented: prevent element management
 | 
				
			||||||
 | 
					        this.#_element.append(comp.element);
 | 
				
			||||||
 | 
					        comp.#onAdd();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Register an event listener
 | 
				
			||||||
 | 
					    addEventListener(type, listener) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Input validation
 | 
				
			||||||
 | 
					        type = String(type);
 | 
				
			||||||
 | 
					        if (!(listener instanceof Function))
 | 
				
			||||||
 | 
					            throw new TypeError("listener must be a function.");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // The event listener is already registered
 | 
				
			||||||
 | 
					        if (this.#_eventListeners?.get(type)?.includes(listener))
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Establish a set for the listener type
 | 
				
			||||||
 | 
					        this.#_eventListeners ??= new Map();
 | 
				
			||||||
 | 
					        if (!this.#_eventListeners.has(type)) {
 | 
				
			||||||
 | 
					            let listeners   = [];
 | 
				
			||||||
 | 
					            listeners.inner = new Map();
 | 
				
			||||||
 | 
					            this.#_eventListeners.set(type, listeners);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Dark events implemented via MediaQueryList
 | 
				
			||||||
 | 
					            if (type == "dark") {
 | 
				
			||||||
 | 
					                listeners.handler =
 | 
				
			||||||
 | 
					                    e=>{ this.#_emit("dark", { isDark: e.matches }); };
 | 
				
			||||||
 | 
					                _package.darkQuery
 | 
				
			||||||
 | 
					                    .addEventListener("change", listeners.handler);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Resize events implemented via ResizeObserver
 | 
				
			||||||
 | 
					            else if (type == "resize") {
 | 
				
			||||||
 | 
					                listeners.handler = new ResizeObserver(()=>{
 | 
				
			||||||
 | 
					                    this.#_emit("resize",
 | 
				
			||||||
 | 
					                        { bounds: this.#_element.getBoundingClientRect() });
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					                listeners.handler.observe(this.#_element);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            // Visibility events implemented via IntersectionObserver
 | 
				
			||||||
 | 
					            else if (type == "visibility") {
 | 
				
			||||||
 | 
					                listeners.handler = new ResizeObserver(()=>{
 | 
				
			||||||
 | 
					                    this.#_emit("visibility",
 | 
				
			||||||
 | 
					                        { visible: Toolkit.isVisible(this.#_element) });
 | 
				
			||||||
 | 
					                });
 | 
				
			||||||
 | 
					                listeners.handler.observe(this.#_element);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Register the listener
 | 
				
			||||||
 | 
					        let listeners = this.#_eventListeners.get(type);
 | 
				
			||||||
 | 
					        let inner     = e=>{ e[Toolkit.target] = this; listener(e); }
 | 
				
			||||||
 | 
					        listeners.push(listener);
 | 
				
			||||||
 | 
					        listeners.inner.set(listener, inner);
 | 
				
			||||||
 | 
					        this.#_element.addEventListener(type, inner);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Destroy a component and all of its application references
 | 
				
			||||||
 | 
					    delete() {
 | 
				
			||||||
 | 
					        // TODO: Remove from parent
 | 
				
			||||||
 | 
					        this.#_element.remove();
 | 
				
			||||||
 | 
					        let app = this.#_app;
 | 
				
			||||||
 | 
					        if (app != null) {
 | 
				
			||||||
 | 
					            this.#_app = null;
 | 
				
			||||||
 | 
					            _package.App.onDelete(app, this);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Retrieve the value for a substitution
 | 
				
			||||||
 | 
					    getSubstitution(key) {
 | 
				
			||||||
 | 
					        return key == null ? null :
 | 
				
			||||||
 | 
					            this.#_substitutions?.get(String(key)) ?? null;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Determine whether the element is fully visible
 | 
				
			||||||
 | 
					    isVisible() {
 | 
				
			||||||
 | 
					        return Toolkit.isVisible(this.#_element);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Generate a list of focusable descendant elements
 | 
				
			||||||
 | 
					    listFocusable() {
 | 
				
			||||||
 | 
					        return Toolkit.listFocusable(this.#_element);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Register or remove a substitution
 | 
				
			||||||
 | 
					    setSubstitution(key, value) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Error checking
 | 
				
			||||||
 | 
					        if (key == null)
 | 
				
			||||||
 | 
					            throw new TypeError("Key cannot be null.");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Input validation
 | 
				
			||||||
 | 
					        key = String(key);
 | 
				
			||||||
 | 
					        if (!(value instanceof RegExp))
 | 
				
			||||||
 | 
					            value = String(value);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Remove an association
 | 
				
			||||||
 | 
					        if (value == null) {
 | 
				
			||||||
 | 
					            if (this.#_substitutions?.has(key)) {
 | 
				
			||||||
 | 
					                this.#_substitutions.delete(key);
 | 
				
			||||||
 | 
					                if (this.#_substitutions.length == 0)
 | 
				
			||||||
 | 
					                    this.#_substitutions = null;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Register an association
 | 
				
			||||||
 | 
					        (this.#_substitutions ??= new Map()).set(key, value);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Update any display text
 | 
				
			||||||
 | 
					        this.#onLocalize();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ///////////////////////////// Private Methods /////////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Generate a custom event object
 | 
				
			||||||
 | 
					    #_emit(type, properties) {
 | 
				
			||||||
 | 
					        let e = new Event(type, { bubbles: true, cancelable: true });
 | 
				
			||||||
 | 
					        Object.defineProperties(e, { target: { value: this.#_element } });
 | 
				
			||||||
 | 
					        Object.assign(e, properties);
 | 
				
			||||||
 | 
					        this.#_element.dispatchEvent(e);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,280 @@
 | 
				
			||||||
 | 
					// Model state manager for checkboxes or radio buttons
 | 
				
			||||||
 | 
					export default (Toolkit,_package)=>class Group {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Instance fields
 | 
				
			||||||
 | 
					    #_app;            // Managed Toolkit.App
 | 
				
			||||||
 | 
					    #_byComponent;    // Mapping of values keyed by component
 | 
				
			||||||
 | 
					    #_byValue;        // Mapping of component sets keyed by value
 | 
				
			||||||
 | 
					    #_checked;        // Set of checked values
 | 
				
			||||||
 | 
					    #_eventListeners; // Active event listeners
 | 
				
			||||||
 | 
					    #_type;           // Group type, either "checkbox" or "radio"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ///////////////////////// Initialization Methods //////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    static {
 | 
				
			||||||
 | 
					        _package.Group = {
 | 
				
			||||||
 | 
					            onAction: (g,c)=>g.#_onAction(c)
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    constructor() {
 | 
				
			||||||
 | 
					        this.#_app            = null;
 | 
				
			||||||
 | 
					        this.#_byComponent    = new Map();
 | 
				
			||||||
 | 
					        this.#_byValue        = new Map();
 | 
				
			||||||
 | 
					        this.#_checked        = new Set();
 | 
				
			||||||
 | 
					        this.#_eventListeners = null;
 | 
				
			||||||
 | 
					        this.#_type           = null;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /////////////////////////////// Properties ////////////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Number of components in the group
 | 
				
			||||||
 | 
					    get size() { return this.#_byComponent.size; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Array of checked values or singular radio value (null if none)
 | 
				
			||||||
 | 
					    get value() {
 | 
				
			||||||
 | 
					        let ret = [... this.#_checked];
 | 
				
			||||||
 | 
					        return this.#_type == "checkbox" ? ret : ret[0] ?? null;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Specify the current checkbox values or radio value
 | 
				
			||||||
 | 
					    set value(value) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Error checking
 | 
				
			||||||
 | 
					        if (this.#_type == null)
 | 
				
			||||||
 | 
					            throw new Error("There are no components in the group.");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Update the radio value
 | 
				
			||||||
 | 
					        if (this.#_type == "radio") {
 | 
				
			||||||
 | 
					            if (value === null)
 | 
				
			||||||
 | 
					                this.set(this.value, false);
 | 
				
			||||||
 | 
					            this.set(value, true);
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Update the checkbox values
 | 
				
			||||||
 | 
					        let checked = new Set(Array.isArray(value) ? value : [ value ]);
 | 
				
			||||||
 | 
					        for (value of this.#_byValue.keys())
 | 
				
			||||||
 | 
					            this.set(value, checked.has(value));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ///////////////////////////// Public Methods //////////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Component iterator
 | 
				
			||||||
 | 
					    [Symbol.iterator]() { return this.components(); }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Add a component to the group
 | 
				
			||||||
 | 
					    add(ctrl, value) {
 | 
				
			||||||
 | 
					        let size = this.#_byComponent.size;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Error checking
 | 
				
			||||||
 | 
					        if (this.#_byComponent.has(ctrl))
 | 
				
			||||||
 | 
					            throw new Error("Control is already part of this group.");
 | 
				
			||||||
 | 
					        if (!(ctrl instanceof Toolkit.Component))
 | 
				
			||||||
 | 
					            throw new Error("Control must be a Toolkit.Component.");
 | 
				
			||||||
 | 
					        if (this.#_isOtherGroup(ctrl))
 | 
				
			||||||
 | 
					            throw new Error("Control is already part of another group.");
 | 
				
			||||||
 | 
					        if (size != 0 && ctrl.app != this.#_app) {
 | 
				
			||||||
 | 
					            throw new Error("All controls in the group must belong " +
 | 
				
			||||||
 | 
					                "to the same Toolkit.App.");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Determine the group type of the item being added
 | 
				
			||||||
 | 
					        let type = null;
 | 
				
			||||||
 | 
					        if (ctrl instanceof Toolkit.MenuItem) {
 | 
				
			||||||
 | 
					            if (ctrl.type == "checkbox")
 | 
				
			||||||
 | 
					                type = "checkbox";
 | 
				
			||||||
 | 
					            else if (ctrl.type == "radio")
 | 
				
			||||||
 | 
					                type = "radio";
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Error checking
 | 
				
			||||||
 | 
					        if (type == null) {
 | 
				
			||||||
 | 
					            throw new Error("Control must be of a checkbox or " +
 | 
				
			||||||
 | 
					                "radio button or variety.");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        if (size != 0 && type != this.#_type) {
 | 
				
			||||||
 | 
					            throw new Error("All controls in the group must be of the same " +
 | 
				
			||||||
 | 
					                "variety, either checkbox or radio button.");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // First component in the group
 | 
				
			||||||
 | 
					        if (size == 0) {
 | 
				
			||||||
 | 
					            this.#_app  = ctrl.app;
 | 
				
			||||||
 | 
					            this.#_type = type;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Register the component
 | 
				
			||||||
 | 
					        this.#_byComponent.set(ctrl, value);
 | 
				
			||||||
 | 
					        if (ctrl instanceof Toolkit.MenuItem)
 | 
				
			||||||
 | 
					            _package.MenuItem.setGroup(ctrl, this);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Register the value, add the component to the value's list
 | 
				
			||||||
 | 
					        if (!this.#_byValue.has(value))
 | 
				
			||||||
 | 
					            this.#_byValue.set(value, new Set());
 | 
				
			||||||
 | 
					        this.#_byValue.get(value).add(ctrl);
 | 
				
			||||||
 | 
					        if (ctrl instanceof Toolkit.MenuItem)
 | 
				
			||||||
 | 
					            _package.MenuItem.setChecked(ctrl, this.#_checked.has(value));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Register an event listener
 | 
				
			||||||
 | 
					    addEventListener(type, listener) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Input validation
 | 
				
			||||||
 | 
					        type = String(type);
 | 
				
			||||||
 | 
					        if (!(listener instanceof Function))
 | 
				
			||||||
 | 
					            throw new TypeError("listener must be a function.");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // The event listener is already registered
 | 
				
			||||||
 | 
					        if (this.#_eventListeners?.get(type)?.includes(listener))
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Establish a set for the listener type
 | 
				
			||||||
 | 
					        this.#_eventListeners ??= new Map();
 | 
				
			||||||
 | 
					        if (!this.#_eventListeners.has(type)) {
 | 
				
			||||||
 | 
					            let listeners   = [];
 | 
				
			||||||
 | 
					            listeners.inner = new Map();
 | 
				
			||||||
 | 
					            this.#_eventListeners.set(type, listeners);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Register the listener
 | 
				
			||||||
 | 
					        let listeners = this.#_eventListeners.get(type);
 | 
				
			||||||
 | 
					        let inner     = e=>{
 | 
				
			||||||
 | 
					            e[Toolkit.group ] = this;
 | 
				
			||||||
 | 
					            e[Toolkit.target] = Toolkit.component(e.target);
 | 
				
			||||||
 | 
					            listener(e);
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					        listeners.push(listener);
 | 
				
			||||||
 | 
					        listeners.inner.set(listener, inner);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Component iterator
 | 
				
			||||||
 | 
					    *components() {
 | 
				
			||||||
 | 
					        let ret = [... this.#_byComponent.keys()];
 | 
				
			||||||
 | 
					        for (let ctrl of ret)
 | 
				
			||||||
 | 
					            yield ctrl;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Determine whether a model value is currently checked
 | 
				
			||||||
 | 
					    is(value) {
 | 
				
			||||||
 | 
					        return this.#_checked.has(value);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Remove a component from the group
 | 
				
			||||||
 | 
					    remove(ctrl) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Error checking
 | 
				
			||||||
 | 
					        if (!this.#_byComponent.has(ctrl))
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Working variables
 | 
				
			||||||
 | 
					        let value      = this.#_byComponent.get(ctrl);
 | 
				
			||||||
 | 
					        let components = this.#_byValue    .get(value);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Unregister the component
 | 
				
			||||||
 | 
					        this.#_byComponent.delete(ctrl);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // No components remain
 | 
				
			||||||
 | 
					        if (this.#_byComponent.size == 0) {
 | 
				
			||||||
 | 
					            this.#_app  = null;
 | 
				
			||||||
 | 
					            this.#_type = null;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Un-register the value
 | 
				
			||||||
 | 
					        components.delete(ctrl);
 | 
				
			||||||
 | 
					        if (components.size == 0) {
 | 
				
			||||||
 | 
					            this.#_checked.delete(value);
 | 
				
			||||||
 | 
					            this.#_byValue.delete(value);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Detach the component from the group
 | 
				
			||||||
 | 
					        if (ctrl instanceof MenuItem)
 | 
				
			||||||
 | 
					            _package.MenuItem.setGroup(ctrl, null);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Specify whether a model value is currently checked
 | 
				
			||||||
 | 
					    set(value, checked) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Error checking
 | 
				
			||||||
 | 
					        if (!this.#_byValue.has(value))
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Checked state is not changing
 | 
				
			||||||
 | 
					        checked = !!checked;
 | 
				
			||||||
 | 
					        if (this.#_checked.has(value) == checked)
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Un-check the previous radio value
 | 
				
			||||||
 | 
					        if (this.#_type == "radio" && this.#_checked.size == 1) {
 | 
				
			||||||
 | 
					            let checked = [... this.#_checked][0];
 | 
				
			||||||
 | 
					            if (checked != value)
 | 
				
			||||||
 | 
					                this.set(checked, false);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Update components
 | 
				
			||||||
 | 
					        for (let ctrl of this.#_byValue.get(value)) {
 | 
				
			||||||
 | 
					            if (ctrl instanceof Toolkit.MenuItem)
 | 
				
			||||||
 | 
					                _package.MenuItem.setChecked(ctrl, checked);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Update model
 | 
				
			||||||
 | 
					        this.#_checked[checked ? "add" : "delete"](value);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Value iterator
 | 
				
			||||||
 | 
					    *values() {
 | 
				
			||||||
 | 
					        if (this.#_byComponent.size == 0)
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					        let ret = this.values;
 | 
				
			||||||
 | 
					        if (this.#_type != "checkbox")
 | 
				
			||||||
 | 
					            ret = [ ret ];
 | 
				
			||||||
 | 
					        for (let value of ret)
 | 
				
			||||||
 | 
					            yield value;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ///////////////////////////// Package Methods /////////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Control was activated by the user
 | 
				
			||||||
 | 
					    #_onAction(ctrl) {
 | 
				
			||||||
 | 
					        this.set(this.#_byComponent.get(ctrl),
 | 
				
			||||||
 | 
					            this.type == "radio" ? true : !this.#_checked.has(ctrl));
 | 
				
			||||||
 | 
					        if (!this.#_eventListeners.has("action"))
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					        let listeners = this.#_eventListeners.get("action");
 | 
				
			||||||
 | 
					        for (let listener of listeners) {
 | 
				
			||||||
 | 
					            listener = listeners.inner.get(listener);
 | 
				
			||||||
 | 
					            let e = new Event("group", { bubbles: true, cancelable: true });
 | 
				
			||||||
 | 
					            Object.defineProperties(e, { target: { value: ctrl.element } });
 | 
				
			||||||
 | 
					            listener(e);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ///////////////////////////// Private Methods /////////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Generate a custom event object
 | 
				
			||||||
 | 
					    #_emit(type, ctrl, properties) {
 | 
				
			||||||
 | 
					        let e = new Event(type, { bubbles: true, cancelable: true });
 | 
				
			||||||
 | 
					        Object.defineProperties(e, { target: { value: ctrl.element } });
 | 
				
			||||||
 | 
					        Object.assign(e, properties);
 | 
				
			||||||
 | 
					        return e;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Determine whether a component belongs to another Group
 | 
				
			||||||
 | 
					    #_isOtherGroup(ctrl) {
 | 
				
			||||||
 | 
					        let group = null;
 | 
				
			||||||
 | 
					        if (ctrl instanceof Toolkit.MenuItem)
 | 
				
			||||||
 | 
					            group = ctrl.group;
 | 
				
			||||||
 | 
					        return group != null && group != this;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,299 @@
 | 
				
			||||||
 | 
					// Window menu bar
 | 
				
			||||||
 | 
					export default (Toolkit,_package)=>class MenuBar extends Toolkit.Component {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Instance fields
 | 
				
			||||||
 | 
					    #_ariaLabel; // Accessible label
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ///////////////////////// Initialization Methods //////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    static {
 | 
				
			||||||
 | 
					        _package.MenuBar = {
 | 
				
			||||||
 | 
					            activate  : (c,i,f,x)=>c.#_activate(i,f,x),
 | 
				
			||||||
 | 
					            children  : c=>this.#_children(c),
 | 
				
			||||||
 | 
					            onLocalize: c=>c.#onLocalize()
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    constructor(app, overrides) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        super(app, _package.override(overrides, {
 | 
				
			||||||
 | 
					            ariaOrientation: "horizontal",
 | 
				
			||||||
 | 
					            className      : "menu-bar",
 | 
				
			||||||
 | 
					            role           : "menubar",
 | 
				
			||||||
 | 
					            style          : {
 | 
				
			||||||
 | 
					                display : "flex",
 | 
				
			||||||
 | 
					                flexWrap: "wrap"
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Configure instance fields
 | 
				
			||||||
 | 
					        this.#_ariaLabel = null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Configure event listeners
 | 
				
			||||||
 | 
					        this.addEventListener("focusout", e=>this.#_onBlur   (e));
 | 
				
			||||||
 | 
					        this.addEventListener("keydown" , e=>this.#_onKeyDown(e));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /////////////////////////////// Properties ////////////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Accessible label
 | 
				
			||||||
 | 
					    get ariaLabel() { return this.#_ariaLabel; }
 | 
				
			||||||
 | 
					    set ariaLabel(value) {
 | 
				
			||||||
 | 
					        if (value != null && !(value instanceof RegExp))
 | 
				
			||||||
 | 
					            value = String(value);
 | 
				
			||||||
 | 
					        this.#_ariaLabel = value;
 | 
				
			||||||
 | 
					        this.#onLocalize();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ///////////////////////////// Event Handlers //////////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Focus out
 | 
				
			||||||
 | 
					    #_onBlur(e) {
 | 
				
			||||||
 | 
					        if (this.element.contains(e.relatedTarget))
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Key pressed
 | 
				
			||||||
 | 
					    #_onKeyDown(e) {
 | 
				
			||||||
 | 
					        let item = Toolkit.component(e.originalTarget);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Processing by key
 | 
				
			||||||
 | 
					        switch (e.key) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            case " ":
 | 
				
			||||||
 | 
					            case "Enter":
 | 
				
			||||||
 | 
					            case "Pointer":
 | 
				
			||||||
 | 
					                this.#_activate(item, e.key == "Enter", e.key != " ");
 | 
				
			||||||
 | 
					                break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            case "ArrowDown":
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Expand the sub-menu and focus its first item
 | 
				
			||||||
 | 
					                if (item.parent instanceof Toolkit.MenuBar) {
 | 
				
			||||||
 | 
					                    item.expanded = true;
 | 
				
			||||||
 | 
					                    this.#_focusBookend(item, false);
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Focus the next available sibling
 | 
				
			||||||
 | 
					                else this.#_focusCycle(item, false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            case "ArrowUp":
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Focus the previous available sibling
 | 
				
			||||||
 | 
					                if (!(item.parent instanceof Toolkit.MenuBar))
 | 
				
			||||||
 | 
					                    this.#_focusCycle(item, true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            case "ArrowRight":
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Focus the next available sibling
 | 
				
			||||||
 | 
					                if (item.parent instanceof Toolkit.MenuBar)
 | 
				
			||||||
 | 
					                    this.#_focusCycle(item, false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Expand the sub-menu and focus its first item
 | 
				
			||||||
 | 
					                else if (item.children.length != 0) {
 | 
				
			||||||
 | 
					                    item.expanded = true;
 | 
				
			||||||
 | 
					                    this.#_focusBookend(item, false);
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Focus the next top-level sibling's first sub-item
 | 
				
			||||||
 | 
					                else {
 | 
				
			||||||
 | 
					                    while (!(item.parent instanceof Toolkit.MenuBar))
 | 
				
			||||||
 | 
					                        item = item.parent;
 | 
				
			||||||
 | 
					                    let expanded = item.expanded;
 | 
				
			||||||
 | 
					                    let next = this.#_focusCycle(item, false);
 | 
				
			||||||
 | 
					                    if (!(
 | 
				
			||||||
 | 
					                        expanded     &&
 | 
				
			||||||
 | 
					                        next != null &&
 | 
				
			||||||
 | 
					                        next != item &&
 | 
				
			||||||
 | 
					                        next.children.length != 0
 | 
				
			||||||
 | 
					                    )) break;
 | 
				
			||||||
 | 
					                    next.expanded = true;
 | 
				
			||||||
 | 
					                    this.#_focusBookend(next, false);
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            case "ArrowLeft":
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Focus the previous available sibling
 | 
				
			||||||
 | 
					                if (item.parent instanceof Toolkit.MenuBar)
 | 
				
			||||||
 | 
					                    this.#_focusCycle(item, true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Focus the previous top-level sibling's first sub-item
 | 
				
			||||||
 | 
					                else if (item.parent.parent instanceof Toolkit.MenuBar) {
 | 
				
			||||||
 | 
					                    while (!(item.parent instanceof Toolkit.MenuBar))
 | 
				
			||||||
 | 
					                        item = item.parent;
 | 
				
			||||||
 | 
					                    let expanded = item;
 | 
				
			||||||
 | 
					                    let next = this.#_focusCycle(item, true);
 | 
				
			||||||
 | 
					                    if (!(expanded && next != null && next != item))
 | 
				
			||||||
 | 
					                        break;
 | 
				
			||||||
 | 
					                    next.expanded = true;
 | 
				
			||||||
 | 
					                    this.#_focusBookend(next, false);
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Collapse the sub-menu
 | 
				
			||||||
 | 
					                else item.parent.element.focus();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            case "End": // Focus the last sibling menu item
 | 
				
			||||||
 | 
					                this.#_focusBookend(item.parent, true);
 | 
				
			||||||
 | 
					                break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            case "Escape":
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Collapse the sub-menu
 | 
				
			||||||
 | 
					                if (item.expanded) {
 | 
				
			||||||
 | 
					                    item.expanded = false;
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Collapse the current menu and focus on the menu item
 | 
				
			||||||
 | 
					                else if (!(item.parent instanceof Toolkit.MenuBar)) {
 | 
				
			||||||
 | 
					                    item.parent.expanded = false;
 | 
				
			||||||
 | 
					                    item.parent.element.focus();
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                // Restore focus to the previous element
 | 
				
			||||||
 | 
					                else this.#_restoreFocus();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            case "Home": // Focus the first sibling menu item
 | 
				
			||||||
 | 
					                this.#_focusBookend(item.parent, false);
 | 
				
			||||||
 | 
					                break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            default: { // Focus the next item that starts with the typed key
 | 
				
			||||||
 | 
					                let key = e.key.toLowerCase();
 | 
				
			||||||
 | 
					                if (key.length != 1)
 | 
				
			||||||
 | 
					                    return; // Allow the event to bubble
 | 
				
			||||||
 | 
					                this.#_focusCycle(item, false, key);
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Event has been handled
 | 
				
			||||||
 | 
					        Toolkit.consume(e);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Configure display text
 | 
				
			||||||
 | 
					    #onLocalize() {
 | 
				
			||||||
 | 
					        this.element.ariaLabel =
 | 
				
			||||||
 | 
					            this.app.translate(this.#_ariaLabel, this) ?? "";
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ///////////////////////////// Public Methods //////////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Add a menu item
 | 
				
			||||||
 | 
					    add(comp) {
 | 
				
			||||||
 | 
					        if (!(comp instanceof Toolkit.MenuItem))
 | 
				
			||||||
 | 
					            throw new TypeError("Component must be a Toolkit.MenuItem.");
 | 
				
			||||||
 | 
					        super.add(comp);
 | 
				
			||||||
 | 
					        comp.element.tabIndex =
 | 
				
			||||||
 | 
					            _package.MenuBar.children(this).length == 1 ? 0 : -1;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ///////////////////////////// Private Methods /////////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Activate a menu item
 | 
				
			||||||
 | 
					    #_activate(item, focus, close) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Error checking
 | 
				
			||||||
 | 
					        if (item.disabled)
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        //switch (item.constructor) {
 | 
				
			||||||
 | 
					        //    case Toolkit.CheckBoxMenuItem   : return;
 | 
				
			||||||
 | 
					        //    case Toolkit.RadioButtonMenuItem: return;
 | 
				
			||||||
 | 
					        //}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Item does not have a sub-menu
 | 
				
			||||||
 | 
					        if (_package.MenuBar.children(item).length == 0) {
 | 
				
			||||||
 | 
					            _package.MenuItem.activate(item, true);
 | 
				
			||||||
 | 
					            if (close || item.type == "button")
 | 
				
			||||||
 | 
					                this.#_restoreFocus();
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Collapse any other open sub-menu
 | 
				
			||||||
 | 
					        let prev = item.parent.children.find(c=>c.expanded);
 | 
				
			||||||
 | 
					        if (prev != null && prev != item)
 | 
				
			||||||
 | 
					            prev.expanded = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Expand the sub-menu
 | 
				
			||||||
 | 
					        item.expanded = true;
 | 
				
			||||||
 | 
					        if (focus)
 | 
				
			||||||
 | 
					            this.#_focusBookend(item, false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Select eligible menu items
 | 
				
			||||||
 | 
					    static #_children(menu) {
 | 
				
			||||||
 | 
					        return menu == null ? [] : menu.children.filter(c=>c.visible);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Collapse other sub-menus and expand a given sub-menu
 | 
				
			||||||
 | 
					    #_expand(item) {
 | 
				
			||||||
 | 
					        let other = item.parent.children.find(c=>c.expanded && c != item);
 | 
				
			||||||
 | 
					        if (other != null)
 | 
				
			||||||
 | 
					            other.expanded = false;
 | 
				
			||||||
 | 
					        if (item.children.length != 0)
 | 
				
			||||||
 | 
					            other.expanded = true;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Move focus to the first or last menu item
 | 
				
			||||||
 | 
					    #_focusBookend(menu, end) {
 | 
				
			||||||
 | 
					        let children = _package.MenuBar.children(menu);
 | 
				
			||||||
 | 
					        if (children.length == 0)
 | 
				
			||||||
 | 
					            return null;
 | 
				
			||||||
 | 
					        let item = children[end ? children.length - 1 : 0];
 | 
				
			||||||
 | 
					        item.element.focus();
 | 
				
			||||||
 | 
					        return item;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Move focus to the next sibling of a menu item
 | 
				
			||||||
 | 
					    #_focusCycle(item, reverse, key = null) {
 | 
				
			||||||
 | 
					        let children = _package.MenuBar.children(item.parent).filter(c=>
 | 
				
			||||||
 | 
					            (key == null || _package.MenuItem.startsWith(c, key)));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // No sibling menu items are eligible
 | 
				
			||||||
 | 
					        if (children.length == 0)
 | 
				
			||||||
 | 
					            return null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Working variables
 | 
				
			||||||
 | 
					        let index = children.indexOf(item);
 | 
				
			||||||
 | 
					        let step  = children.length + (reverse ? -1 : 1);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Find the next eligible sibling in the list
 | 
				
			||||||
 | 
					        let sibling = children[(index + step) % children.length];
 | 
				
			||||||
 | 
					        sibling.element.focus();
 | 
				
			||||||
 | 
					        return sibling;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Retrieve the root-level menu bar containing a menu item
 | 
				
			||||||
 | 
					    #_menuBar(item) {
 | 
				
			||||||
 | 
					        while (!(item instanceof Toolkit.MenuBar))
 | 
				
			||||||
 | 
					            item = item.parent;
 | 
				
			||||||
 | 
					        return item;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Restore focus to the previous component
 | 
				
			||||||
 | 
					    #_restoreFocus() {
 | 
				
			||||||
 | 
					        let item = _package.MenuBar.children(this).find(c=>c.expanded)
 | 
				
			||||||
 | 
					        if (item != null)
 | 
				
			||||||
 | 
					            item.expanded = false;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,552 @@
 | 
				
			||||||
 | 
					// Sub-menu container
 | 
				
			||||||
 | 
					let Menu = (Toolkit,_package)=>class Menu extends Toolkit.Component {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ///////////////////////// Initialization Methods //////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    constructor(app, overrides) {
 | 
				
			||||||
 | 
					        super(app, _package.override(overrides, {
 | 
				
			||||||
 | 
					            className : "menu",
 | 
				
			||||||
 | 
					            role      : "menu",
 | 
				
			||||||
 | 
					            visibility: true,
 | 
				
			||||||
 | 
					            visible   : false,
 | 
				
			||||||
 | 
					            style     : {
 | 
				
			||||||
 | 
					                display      : "flex",
 | 
				
			||||||
 | 
					                flexDirection: "column",
 | 
				
			||||||
 | 
					                position     : "absolute"
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Configure event handlers
 | 
				
			||||||
 | 
					        this.addEventListener("focusout"   , e=>this.#_onFocusOut(e));
 | 
				
			||||||
 | 
					        this.addEventListener("pointerdown", e=>Toolkit.consume  (e));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ///////////////////////////// Event Handlers //////////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Focus lost
 | 
				
			||||||
 | 
					    #_onFocusOut(e) {
 | 
				
			||||||
 | 
					        this.parent?.element?.dispatchEvent(
 | 
				
			||||||
 | 
					            new FocusEvent("focusout", { relatedTarget: e.relatedTarget }));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Menu separator
 | 
				
			||||||
 | 
					let Separator = (Toolkit,_package)=>class Separator extends Toolkit.Component {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ///////////////////////// Initialization Methods //////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    constructor(app, overrides) {
 | 
				
			||||||
 | 
					        super(app, _package.override(overrides, {
 | 
				
			||||||
 | 
					            ariaOrientation: "horizontal",
 | 
				
			||||||
 | 
					            className      : "menu-separator",
 | 
				
			||||||
 | 
					            role           : "separator"
 | 
				
			||||||
 | 
					        }));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /////////////////////////////// Properties ////////////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    get type() { return "separator"; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Menu item
 | 
				
			||||||
 | 
					export default (Toolkit,_package)=>class MenuItem extends Toolkit.Component {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Inner classes
 | 
				
			||||||
 | 
					    static #_Menu      = Menu     (Toolkit, _package);
 | 
				
			||||||
 | 
					    static #_Separator = Separator(Toolkit, _package);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Instance fields
 | 
				
			||||||
 | 
					    #_client;  // Interior content element
 | 
				
			||||||
 | 
					    #_columns; // Content elements
 | 
				
			||||||
 | 
					    #_drag;    // Click and drag context
 | 
				
			||||||
 | 
					    #_group;   // Containing Toolkit.Group
 | 
				
			||||||
 | 
					    #_icon;    // Icon image URL
 | 
				
			||||||
 | 
					    #_menu;    // Pop-up menu element
 | 
				
			||||||
 | 
					    #_resizer; // Column sizing listener
 | 
				
			||||||
 | 
					    #_start;   // Character that the display text starts with
 | 
				
			||||||
 | 
					    #_text;    // Display text
 | 
				
			||||||
 | 
					    #_value;   // Radio button value
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ///////////////////////// Initialization Methods //////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    static {
 | 
				
			||||||
 | 
					        _package.MenuItem = {
 | 
				
			||||||
 | 
					            activate  : (c,f)=>c.#_activate(f),
 | 
				
			||||||
 | 
					            menu      : c=>c.#_menu,
 | 
				
			||||||
 | 
					            onLocalize: c=>c.#_onLocalize(),
 | 
				
			||||||
 | 
					            setChecked: (c,v)=>c.#_setChecked(v),
 | 
				
			||||||
 | 
					            setGroup  : (c,g)=>c.#_group=g,
 | 
				
			||||||
 | 
					            startsWith: (c,k)=>c.#_startsWith(k)
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    constructor(app, overrides) {
 | 
				
			||||||
 | 
					        overrides = _package.override(overrides, {
 | 
				
			||||||
 | 
					            className: "menu-item"
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					        let underrides = _package.underride(overrides, {
 | 
				
			||||||
 | 
					            group: null,
 | 
				
			||||||
 | 
					            text : null,
 | 
				
			||||||
 | 
					            type : "button"
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					        super(app, overrides);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Configure instance fields
 | 
				
			||||||
 | 
					        this.disabled = overrides.disabled;
 | 
				
			||||||
 | 
					        this.#_drag   = null;
 | 
				
			||||||
 | 
					        this.#_icon   = null;
 | 
				
			||||||
 | 
					        this.#_menu   = null;
 | 
				
			||||||
 | 
					        this.#_start  = null;
 | 
				
			||||||
 | 
					        this.#_text   = null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Configure event handlers
 | 
				
			||||||
 | 
					        this.addEventListener("focusout"   , e=>this.#_onFocusOut   (e));
 | 
				
			||||||
 | 
					        this.addEventListener("pointerdown", e=>this.#_onPointerDown(e));
 | 
				
			||||||
 | 
					        this.addEventListener("pointermove", e=>this.#_onPointerMove(e));
 | 
				
			||||||
 | 
					        this.addEventListener("pointerup"  , e=>this.#_onPointerUp  (e));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Configure contents
 | 
				
			||||||
 | 
					        this.#_client = document.createElement("div");
 | 
				
			||||||
 | 
					        Object.assign(this.#_client.style, {
 | 
				
			||||||
 | 
					            display: "grid"
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					        this.#_columns = [
 | 
				
			||||||
 | 
					            document.createElement("div"), // Icon
 | 
				
			||||||
 | 
					            document.createElement("div"), // Text
 | 
				
			||||||
 | 
					            document.createElement("div")  // Shortcut
 | 
				
			||||||
 | 
					        ];
 | 
				
			||||||
 | 
					        this.element.append(this.#_client);
 | 
				
			||||||
 | 
					        for (let column of this.#_columns)
 | 
				
			||||||
 | 
					            this.#_client.append(column);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Configure properties
 | 
				
			||||||
 | 
					        this.group = underrides.group;
 | 
				
			||||||
 | 
					        this.text  = underrides.text;
 | 
				
			||||||
 | 
					        this.type  = underrides.type;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /////////////////////////////// Properties ////////////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Check box or radio button checked state
 | 
				
			||||||
 | 
					    get checked() { return this.element.ariaChecked == "true"; }
 | 
				
			||||||
 | 
					    set checked(value) {
 | 
				
			||||||
 | 
					        if (this.#_group == null)
 | 
				
			||||||
 | 
					            this.#_setChecked(!!value);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Element is inoperable
 | 
				
			||||||
 | 
					    get disabled() { return this.element.ariaDisabled == "true"; }
 | 
				
			||||||
 | 
					    set disabled(value) {
 | 
				
			||||||
 | 
					        value = Boolean(value);
 | 
				
			||||||
 | 
					        if (value == this.disabled)
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					        if (value)
 | 
				
			||||||
 | 
					            this.element.ariaDisabled = "true";
 | 
				
			||||||
 | 
					        else this.element.removeAttribute("aria-disabled");
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Sub-menu is visible
 | 
				
			||||||
 | 
					    get expanded() { return this.element.ariaExpanded == "true"; }
 | 
				
			||||||
 | 
					    set expanded(value) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Cannot be expanded
 | 
				
			||||||
 | 
					        if (this.children.length == 0)
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Input validation
 | 
				
			||||||
 | 
					        value = Boolean(value);
 | 
				
			||||||
 | 
					        if (value == this.expanded)
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Expand or collapse self
 | 
				
			||||||
 | 
					        this.element.ariaExpanded = String(value);
 | 
				
			||||||
 | 
					        this.element.classList[value ? "add" : "remove"]("active");
 | 
				
			||||||
 | 
					        this.#_menu.visible = value;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Position the sub-menu element
 | 
				
			||||||
 | 
					        if (value) {
 | 
				
			||||||
 | 
					            let bounds = this.element.getBoundingClientRect();
 | 
				
			||||||
 | 
					            Object.assign(this.#_menu.element.style,
 | 
				
			||||||
 | 
					                this.parent instanceof Toolkit.MenuBar ?
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
 | 
					                    left: bounds.left   + "px",
 | 
				
			||||||
 | 
					                    top : bounds.bottom + "px"
 | 
				
			||||||
 | 
					                } : {
 | 
				
			||||||
 | 
					                    left: bounds.right + "px",
 | 
				
			||||||
 | 
					                    top : bounds.top   + "px"
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Collapse any expanded sub-menu
 | 
				
			||||||
 | 
					        else {
 | 
				
			||||||
 | 
					            let item = this.children.find(c=>c.expanded);
 | 
				
			||||||
 | 
					            if (item != null)
 | 
				
			||||||
 | 
					                item.expanded = false;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Containing Toolkit.Group
 | 
				
			||||||
 | 
					    get group() { return this.#_group; }
 | 
				
			||||||
 | 
					    set group(value) {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Icon image URL
 | 
				
			||||||
 | 
					    get icon() { return this.#_icon; }
 | 
				
			||||||
 | 
					    set icon(value) {
 | 
				
			||||||
 | 
					        this.#_icon = value ? String(value) : null;
 | 
				
			||||||
 | 
					        this.#_refresh();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Display text
 | 
				
			||||||
 | 
					    get text() { return this.#_text; }
 | 
				
			||||||
 | 
					    set text(value) {
 | 
				
			||||||
 | 
					        if (value != null && !(value instanceof RegExp))
 | 
				
			||||||
 | 
					            value = String(value);
 | 
				
			||||||
 | 
					        this.#_text = value;
 | 
				
			||||||
 | 
					        this.#_onLocalize();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Menu item type
 | 
				
			||||||
 | 
					    get type() {
 | 
				
			||||||
 | 
					        switch (this.element.role) {
 | 
				
			||||||
 | 
					            case "menuitem"        : return "button";
 | 
				
			||||||
 | 
					            case "menuitemcheckbox": return "checkbox";
 | 
				
			||||||
 | 
					            case "menuitemradio"   : return "radio";
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        return null;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    set type(value) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Cannot change type if there is a sub-menu
 | 
				
			||||||
 | 
					        if (this.children.length != 0)
 | 
				
			||||||
 | 
					            throw new Error("Cannot change type while a sub-menu exists.");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Error checking
 | 
				
			||||||
 | 
					        value = value == null ? null : String(value);
 | 
				
			||||||
 | 
					        let type = this.type;
 | 
				
			||||||
 | 
					        if (type != null && value == type)
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Input validation
 | 
				
			||||||
 | 
					        switch (String(value)) {
 | 
				
			||||||
 | 
					            case "button"  : value = "menuitem"        ; break;
 | 
				
			||||||
 | 
					            case "checkbox": value = "menuitemcheckbox"; break;
 | 
				
			||||||
 | 
					            case "radio"   : value = "menuitemradio"   ; break;
 | 
				
			||||||
 | 
					            default:
 | 
				
			||||||
 | 
					                if (type != null)
 | 
				
			||||||
 | 
					                    return;
 | 
				
			||||||
 | 
					                value = "menuitem";
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Update the component
 | 
				
			||||||
 | 
					        this.element.role = value;
 | 
				
			||||||
 | 
					        this.#_refresh();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Radio button value
 | 
				
			||||||
 | 
					    get value()      { return this.#_value;  }
 | 
				
			||||||
 | 
					    set value(value) { this.#_value = value; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // HTML element visibility
 | 
				
			||||||
 | 
					    get visible() { return super.visible; }
 | 
				
			||||||
 | 
					    set visible(value) {
 | 
				
			||||||
 | 
					        value = !!value;
 | 
				
			||||||
 | 
					        if (value == super.visible)
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					        super.visible = value;
 | 
				
			||||||
 | 
					        // TODO: Refresh siblings and parent
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ///////////////////////////// Event Handlers //////////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Component added to parent
 | 
				
			||||||
 | 
					    #_onAdd() {
 | 
				
			||||||
 | 
					        if (this.#_menu != null)
 | 
				
			||||||
 | 
					            this.element.parent.append(this.#_menu.element);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Focus lost
 | 
				
			||||||
 | 
					    #_onFocusOut(e) {
 | 
				
			||||||
 | 
					        if (
 | 
				
			||||||
 | 
					            this.expanded                   &&
 | 
				
			||||||
 | 
					            this.element != e.relatedTarget &&
 | 
				
			||||||
 | 
					            !this.#_menu.element.contains(e.relatedTarget)
 | 
				
			||||||
 | 
					        ) this.expanded = false;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Configure display text
 | 
				
			||||||
 | 
					    #_onLocalize() {
 | 
				
			||||||
 | 
					        let text = this.app.translate(this.#_text, this) ?? "";
 | 
				
			||||||
 | 
					        this.#_columns[1].innerText = text;
 | 
				
			||||||
 | 
					        this.#_start = text.length == 0 ? null : text[0].toLowerCase();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Pointer pressed
 | 
				
			||||||
 | 
					    #_onPointerDown(e) {
 | 
				
			||||||
 | 
					        Toolkit.consume(e);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Acquire focus
 | 
				
			||||||
 | 
					        this.element.focus();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Error checking
 | 
				
			||||||
 | 
					        if (this.disabled || e.button != 0 || this.#_drag != null)
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Activate a sub-menu
 | 
				
			||||||
 | 
					        if (this.children.length != 0) {
 | 
				
			||||||
 | 
					            if (!this.expanded)
 | 
				
			||||||
 | 
					                this.#_activate();
 | 
				
			||||||
 | 
					            else this.expanded = false;
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Initiate a button response on a sub-menu item
 | 
				
			||||||
 | 
					        this.element.setPointerCapture(e.pointerId);
 | 
				
			||||||
 | 
					        this.element.classList.add("active");
 | 
				
			||||||
 | 
					        this.#_drag = e.pointerId;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Pointer moved
 | 
				
			||||||
 | 
					    #_onPointerMove(e) {
 | 
				
			||||||
 | 
					        Toolkit.consume(e);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Style the menu item like a button on drag
 | 
				
			||||||
 | 
					        if (this.#_drag != null) {
 | 
				
			||||||
 | 
					            this.element.classList
 | 
				
			||||||
 | 
					                [this.#_contains(e) ? "add" : "remove"]("active");
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Expand the sub-menu if another top-level sub-menu is expanded
 | 
				
			||||||
 | 
					        if (
 | 
				
			||||||
 | 
					            this.parent instanceof Toolkit.MenuBar &&
 | 
				
			||||||
 | 
					            this.children.length != 0
 | 
				
			||||||
 | 
					        ) {
 | 
				
			||||||
 | 
					            let item = this.parent.children.find(c=>c.expanded);
 | 
				
			||||||
 | 
					            if (item != null && !this.expanded) {
 | 
				
			||||||
 | 
					                this.expanded = true;
 | 
				
			||||||
 | 
					                this.element.focus();
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Pointer released
 | 
				
			||||||
 | 
					    #_onPointerUp(e) {
 | 
				
			||||||
 | 
					        Toolkit.consume(e);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Error checking
 | 
				
			||||||
 | 
					        if (e.button != 0 || this.#_drag != e.pointerId)
 | 
				
			||||||
 | 
					            return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Terminate the button response
 | 
				
			||||||
 | 
					        this.element.releasePointerCapture(e.pointerId);
 | 
				
			||||||
 | 
					        this.element.classList.remove("active");
 | 
				
			||||||
 | 
					        this.#_drag = null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Activate the menu item
 | 
				
			||||||
 | 
					        if (this.#_contains(e))
 | 
				
			||||||
 | 
					            this.#_activate();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Column resized
 | 
				
			||||||
 | 
					    #_onResizeColumn() {
 | 
				
			||||||
 | 
					        let widths = this.#_columns.map(c=>0);
 | 
				
			||||||
 | 
					        for (let item of _package.MenuBar.children(this))
 | 
				
			||||||
 | 
					        for (let x = 0; x < widths.length; x++) {
 | 
				
			||||||
 | 
					            let column = item.#_columns[x];
 | 
				
			||||||
 | 
					            column.style.removeProperty("min-width");
 | 
				
			||||||
 | 
					            widths[x] = Math.max(widths[x],
 | 
				
			||||||
 | 
					                column.getBoundingClientRect().width);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        for (let item of _package.MenuBar.children(this))
 | 
				
			||||||
 | 
					        for (let x = 0; x < widths.length; x++) {
 | 
				
			||||||
 | 
					            if (x == 1)
 | 
				
			||||||
 | 
					                continue; // Text
 | 
				
			||||||
 | 
					            item.#_columns[x].style.minWidth = widths[x] + "px";
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ///////////////////////////// Public Methods //////////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Add a menu item
 | 
				
			||||||
 | 
					    add(comp) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Error checking
 | 
				
			||||||
 | 
					        if (!(comp instanceof Toolkit.MenuItem))
 | 
				
			||||||
 | 
					            throw new TypeError("Component must be a Toolkit.MenuItem.");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Associate the menu item with self
 | 
				
			||||||
 | 
					        super.add(comp, false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // The menu sub-component does not exist
 | 
				
			||||||
 | 
					        if (this.#_menu == null) {
 | 
				
			||||||
 | 
					            this.id = this.id ?? Toolkit.id();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            this.#_menu = new this.constructor.#_Menu(this.app,
 | 
				
			||||||
 | 
					                { ariaLabelledBy: this.id });
 | 
				
			||||||
 | 
					            _package.Component.setParent(this.#_menu, this);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if (this.parent != null)
 | 
				
			||||||
 | 
					                this.element.after(this.#_menu.element);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            Object.assign(this.element, {
 | 
				
			||||||
 | 
					                ariaExpanded: "false",
 | 
				
			||||||
 | 
					                ariaHasPopup: "menu"
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            this.#_resizer ??=
 | 
				
			||||||
 | 
					                new ResizeObserver(()=>this.#_onResizeColumn());
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Add the component to the menu sub-component
 | 
				
			||||||
 | 
					        comp.element.tabIndex = -1;
 | 
				
			||||||
 | 
					        this.#_menu.element.append(comp.element);
 | 
				
			||||||
 | 
					        this.#_resizer.observe(comp.element);
 | 
				
			||||||
 | 
					        _package.Component.onAdd(comp);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Refresh all sub-menu items
 | 
				
			||||||
 | 
					        let children = _package.MenuBar.children(this);
 | 
				
			||||||
 | 
					        let icon     = this.#_needsIcon    (children);
 | 
				
			||||||
 | 
					        let shortcut = this.#_needsShortcut(children);
 | 
				
			||||||
 | 
					        for (let item of children)
 | 
				
			||||||
 | 
					            item.#_refresh(icon, shortcut);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Add a separator between menu items
 | 
				
			||||||
 | 
					    addSeparator(overrides = {}) {
 | 
				
			||||||
 | 
					        let item =
 | 
				
			||||||
 | 
					            new this.constructor.#_Separator(this.app, overrides);
 | 
				
			||||||
 | 
					        this.#_menu.add(item);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ///////////////////////////// Package Methods /////////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Reconfigure contents
 | 
				
			||||||
 | 
					    #_refresh(needsIcon = null, needsShortcut = null) {
 | 
				
			||||||
 | 
					        let client   = this.#_client.style;
 | 
				
			||||||
 | 
					        let icon     = this.#_columns[0].style;
 | 
				
			||||||
 | 
					        let shortcut = this.#_columns[2].style;
 | 
				
			||||||
 | 
					        let hasIcon  = true;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Input validation
 | 
				
			||||||
 | 
					        if (needsIcon == null || needsShortcut == null) {
 | 
				
			||||||
 | 
					            let children    = _package.MenuBar.children(this.parent);
 | 
				
			||||||
 | 
					            needsIcon     ??= this.#_needsIcon    (children);
 | 
				
			||||||
 | 
					            needsShortcut ??= this.#_needsShortcut(children);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Regular menu item
 | 
				
			||||||
 | 
					        if (this.type == "button") {
 | 
				
			||||||
 | 
					            if (this.#_icon != null) {
 | 
				
			||||||
 | 
					                icon.backgroundImage = "url(" + this.#_icon + ")";
 | 
				
			||||||
 | 
					            } else {
 | 
				
			||||||
 | 
					                icon.removeProperty("background-image");
 | 
				
			||||||
 | 
					                hasIcon = false;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Check box or radio button menu item
 | 
				
			||||||
 | 
					        else icon.removeProperty("background-image");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Configure layout
 | 
				
			||||||
 | 
					        let template = ["auto"];
 | 
				
			||||||
 | 
					        if (needsIcon || hasIcon) {
 | 
				
			||||||
 | 
					            template.unshift("max-content");
 | 
				
			||||||
 | 
					            icon.removeProperty("display");
 | 
				
			||||||
 | 
					        } else icon.display = "none";
 | 
				
			||||||
 | 
					        if (needsShortcut && false) { // TODO: Implement shortcut column
 | 
				
			||||||
 | 
					            template.push("max-content");
 | 
				
			||||||
 | 
					            shortcut.removeProperty("display");
 | 
				
			||||||
 | 
					        } else shortcut.display = "none";
 | 
				
			||||||
 | 
					        client.gridTemplateColumns = template.join(" ");
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Modify the checked state
 | 
				
			||||||
 | 
					    #_setChecked(value) {
 | 
				
			||||||
 | 
					        if (this.type != "button")
 | 
				
			||||||
 | 
					            this.element.ariaChecked = value ? "true" : "false";
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Determine whether the translated display text starts with a given string
 | 
				
			||||||
 | 
					    #_startsWith(pattern) {
 | 
				
			||||||
 | 
					        return this.#_start == pattern;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ///////////////////////////// Private Methods /////////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Simulate activation
 | 
				
			||||||
 | 
					    #_activate(fromMenuBar = false) {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Reroute activation handling to the containing Toolkit.MenuBar
 | 
				
			||||||
 | 
					        if (!fromMenuBar) {
 | 
				
			||||||
 | 
					            let bar = this.#_menuBar();
 | 
				
			||||||
 | 
					            if (bar != null) {
 | 
				
			||||||
 | 
					                _package.MenuBar.activate(bar, this, false, true);
 | 
				
			||||||
 | 
					                return;
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Handling by menu item type
 | 
				
			||||||
 | 
					        if (this.#_group != null)
 | 
				
			||||||
 | 
					            _package.Group.onAction(this.#_group, this);
 | 
				
			||||||
 | 
					        else if (this.type == "checkbox")
 | 
				
			||||||
 | 
					            this.checked = !this.checked;
 | 
				
			||||||
 | 
					        else if (this.type == "radio")
 | 
				
			||||||
 | 
					            this.checked = true;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Emit an action event
 | 
				
			||||||
 | 
					        this.element.dispatchEvent(new Event("action"));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Determine whether the pointer is within the element's boundary
 | 
				
			||||||
 | 
					    #_contains(e) {
 | 
				
			||||||
 | 
					        let bounds = this.element.getBoundingClientRect();
 | 
				
			||||||
 | 
					        return (
 | 
				
			||||||
 | 
					            e.clientX >= bounds.left  &&
 | 
				
			||||||
 | 
					            e.clientX <  bounds.right &&
 | 
				
			||||||
 | 
					            e.clientY >= bounds.top   &&
 | 
				
			||||||
 | 
					            e.clientY <  bounds.bottom
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Resolve the containing Toolkit.MenuBar
 | 
				
			||||||
 | 
					    #_menuBar() {
 | 
				
			||||||
 | 
					        let item = this.parent;
 | 
				
			||||||
 | 
					        while (item != null && !(item instanceof Toolkit.MenuBar))
 | 
				
			||||||
 | 
					            item = item.parent;
 | 
				
			||||||
 | 
					        return item;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Determine whether sub-menu items need to show the icon column
 | 
				
			||||||
 | 
					    #_needsIcon(children = null) {
 | 
				
			||||||
 | 
					        return ((children ?? _package.MenuBar.children(this))
 | 
				
			||||||
 | 
					            .some(c=>c.type != "button" || c.icon != null));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Determine whether sub-menu items need to show the shortcut column
 | 
				
			||||||
 | 
					    #_needsShortcut(children = null) {
 | 
				
			||||||
 | 
					        return false;
 | 
				
			||||||
 | 
					        //return ((children ?? _package.MenuBar.children(this))
 | 
				
			||||||
 | 
					        //    .any(c=>c.children.length != 0));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,133 @@
 | 
				
			||||||
 | 
					import App       from /**/"./App.js";
 | 
				
			||||||
 | 
					import Component from /**/"./Component.js";
 | 
				
			||||||
 | 
					import Group     from /**/"./Group.js";
 | 
				
			||||||
 | 
					import MenuBar   from /**/"./MenuBar.js";
 | 
				
			||||||
 | 
					import MenuItem  from /**/"./MenuItem.js";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Pseudo environment context
 | 
				
			||||||
 | 
					let _package = {};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// GUI widget toolkit root
 | 
				
			||||||
 | 
					class Toolkit {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    //////////////////////////////// Constants ////////////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Event keys
 | 
				
			||||||
 | 
					    static group  = Symbol(); // Events emitted by Toolkit.Group
 | 
				
			||||||
 | 
					    static target = Symbol(); // Event target as a Toolkit.Component
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ///////////////////////// Initialization Methods //////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Toolkit() { throw new Error("Cannot be instantiated."); }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    static {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Environment members
 | 
				
			||||||
 | 
					        Object.assign(_package, {
 | 
				
			||||||
 | 
					            componentKey: Symbol("Toolkit component"),
 | 
				
			||||||
 | 
					            darkQuery   : window.matchMedia("(prefers-color-scheme:dark)"),
 | 
				
			||||||
 | 
					            nextId      : 0n,
 | 
				
			||||||
 | 
					            override    : this.#override,
 | 
				
			||||||
 | 
					            underride   : this.#underride
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Register package classes with the Toolkit namespace
 | 
				
			||||||
 | 
					        _package.register = ()=>{
 | 
				
			||||||
 | 
					            this.Component = Component(this, _package);
 | 
				
			||||||
 | 
					            this.App       = App      (this, _package);
 | 
				
			||||||
 | 
					            this.Group     = Group    (this, _package);
 | 
				
			||||||
 | 
					            this.MenuBar   = MenuBar  (this, _package);
 | 
				
			||||||
 | 
					            this.MenuItem  = MenuItem (this, _package);
 | 
				
			||||||
 | 
					            Object.freeze(this);
 | 
				
			||||||
 | 
					            Object.seal  (this);
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ///////////////////////////// Static Methods //////////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Resolve the Toolkit.Component for an HTML element
 | 
				
			||||||
 | 
					    static component(element) {
 | 
				
			||||||
 | 
					        return element[_package.componentKey] ?? null;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Terminate an event
 | 
				
			||||||
 | 
					    static consume(event) {
 | 
				
			||||||
 | 
					        event.preventDefault();
 | 
				
			||||||
 | 
					        event.stopPropagation();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Generate a unique element ID
 | 
				
			||||||
 | 
					    static id() {
 | 
				
			||||||
 | 
					        return "tk-" + _package.nextId++;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Determine whether the user dark mode preference is active
 | 
				
			||||||
 | 
					    static isDark() {
 | 
				
			||||||
 | 
					        return _package.darkQuery.matches;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Determine whether an element is fully visible
 | 
				
			||||||
 | 
					    static isVisible(element, cache = null) {
 | 
				
			||||||
 | 
					        cache ??= new Map();
 | 
				
			||||||
 | 
					        for (let e = element; e instanceof Element; e = e.parentNode) {
 | 
				
			||||||
 | 
					            let style;
 | 
				
			||||||
 | 
					            if (!cache.has(e))
 | 
				
			||||||
 | 
					                cache.set(e, style = getComputedStyle(e));
 | 
				
			||||||
 | 
					            else style = cache.get(e);
 | 
				
			||||||
 | 
					            if (style.display == "none" || style.visibility == "hidden")
 | 
				
			||||||
 | 
					                return false;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        return true;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Generate a list of focusable descendant elements
 | 
				
			||||||
 | 
					    static listFocusable(element) {
 | 
				
			||||||
 | 
					        let cache = new Map();
 | 
				
			||||||
 | 
					        return Array.from(element.querySelectorAll(
 | 
				
			||||||
 | 
					            "*:is(a[href],area,button,details,input,textarea,select," +
 | 
				
			||||||
 | 
					            "[tabindex='0']):not([disabled])"
 | 
				
			||||||
 | 
					        )).filter(e=>this.isVisible(e, cache));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    static stylesheet(url) {
 | 
				
			||||||
 | 
					        let style = document.createElement("link");
 | 
				
			||||||
 | 
					        style.rel  = "stylesheet";
 | 
				
			||||||
 | 
					        style.href = url;
 | 
				
			||||||
 | 
					        return style;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ///////////////////////////// Package Methods /////////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Process overrides for Toolkit.Component initialization
 | 
				
			||||||
 | 
					    static #override(fromCaller, fromSelf) {
 | 
				
			||||||
 | 
					        fromCaller     = Object.assign({}, fromCaller ?? {});
 | 
				
			||||||
 | 
					        fromSelf       = Object.assign({}, fromSelf   ?? {});
 | 
				
			||||||
 | 
					        fromSelf.style = Object.assign(
 | 
				
			||||||
 | 
					            fromSelf.style ?? {}, fromCaller.style ?? {});
 | 
				
			||||||
 | 
					        delete fromCaller.style;
 | 
				
			||||||
 | 
					        Object.assign(fromSelf, fromCaller);
 | 
				
			||||||
 | 
					        return fromSelf;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Extract override properties for later processing
 | 
				
			||||||
 | 
					    static #underride(overrides, underrides) {
 | 
				
			||||||
 | 
					        let ret = {};
 | 
				
			||||||
 | 
					        for (let entry of Object.entries(underrides)) {
 | 
				
			||||||
 | 
					            ret[entry[0]] = overrides[entry[0]] ?? underrides[entry[1]];
 | 
				
			||||||
 | 
					            delete overrides[entry[0]];
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        return ret;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					_package.register();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default Toolkit;
 | 
				
			||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| 
						 | 
					@ -0,0 +1,202 @@
 | 
				
			||||||
 | 
					// File archiver
 | 
				
			||||||
 | 
					class ZipFile {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Instance fields
 | 
				
			||||||
 | 
					    #files; // Active collection of files in the archive
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ////////////////////////////// Constants //////////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // CRC32 lookup table
 | 
				
			||||||
 | 
					    static #CRC_LOOKUP = new Uint32Array(256);
 | 
				
			||||||
 | 
					    static {
 | 
				
			||||||
 | 
					        for (let x = 0; x <= 255; x++) {
 | 
				
			||||||
 | 
					            let l = x;
 | 
				
			||||||
 | 
					            for (let j = 7; j >= 0; j--)
 | 
				
			||||||
 | 
					                l = ((l >>> 1) ^ (0xEDB88320 & -(l & 1)));
 | 
				
			||||||
 | 
					            this.#CRC_LOOKUP[x] = l;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ///////////////////////// Initialization Methods //////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    constructor() {
 | 
				
			||||||
 | 
					        this.#files = new Map();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ///////////////////////////// Public Methods //////////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Iterator
 | 
				
			||||||
 | 
					    *[Symbol.iterator]() {
 | 
				
			||||||
 | 
					        let names = this.list();
 | 
				
			||||||
 | 
					        for (let name of names)
 | 
				
			||||||
 | 
					            yield name;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Add a file to the archive
 | 
				
			||||||
 | 
					    add(filename, data) {
 | 
				
			||||||
 | 
					        if (Array.from(filename).findIndex(c=>c.codePointAt(0) > 126) != -1)
 | 
				
			||||||
 | 
					            throw new Error("Filename must be ASCII.");
 | 
				
			||||||
 | 
					        if (this.#files.has(filename))
 | 
				
			||||||
 | 
					            throw new Error("File with given name already exists.");
 | 
				
			||||||
 | 
					        this.#files.set(filename, Uint8Array.from(data));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Retrieve the file data for a given filename
 | 
				
			||||||
 | 
					    get(filename) {
 | 
				
			||||||
 | 
					        if (!this.#files.has(filename))
 | 
				
			||||||
 | 
					            throw new Error("No file exists with the given name.");
 | 
				
			||||||
 | 
					        return this.#files.get(filename);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Retrieve a sorted list of contained filenames
 | 
				
			||||||
 | 
					    list() {
 | 
				
			||||||
 | 
					        return [... this.#files.keys()].sort();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Remove a file from the archive
 | 
				
			||||||
 | 
					    remove(filename) {
 | 
				
			||||||
 | 
					        if (!this.#files.has(filename))
 | 
				
			||||||
 | 
					            throw new Error("No file exists with the given name.");
 | 
				
			||||||
 | 
					        this.#files.delete(filename);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Produce a Blob representation of the compiled .zip file
 | 
				
			||||||
 | 
					    async toBlob() {
 | 
				
			||||||
 | 
					        let comps     = new Map();
 | 
				
			||||||
 | 
					        let count     = this.#files.size;
 | 
				
			||||||
 | 
					        let crc32s    = new Map();
 | 
				
			||||||
 | 
					        let filenames = this.list();
 | 
				
			||||||
 | 
					        let offsets   = new Map();
 | 
				
			||||||
 | 
					        let output    = [];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Preprocessing
 | 
				
			||||||
 | 
					        for (let name of filenames) {
 | 
				
			||||||
 | 
					            let data = this.#files.get(name);
 | 
				
			||||||
 | 
					            comps .set(name, this.#deflate(data));
 | 
				
			||||||
 | 
					            crc32s.set(name, this.#crc32  (data));
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Local files
 | 
				
			||||||
 | 
					        for (let name of filenames) {
 | 
				
			||||||
 | 
					            let data    = this.#files.get(name);
 | 
				
			||||||
 | 
					            let comp    = await comps.get(name);
 | 
				
			||||||
 | 
					            let deflate = comp.length < data.length;
 | 
				
			||||||
 | 
					            comps  .set(name, deflate ? comp.length : null);
 | 
				
			||||||
 | 
					            offsets.set(name, output.length);
 | 
				
			||||||
 | 
					            this.#zipHeader(output, name, data.length,
 | 
				
			||||||
 | 
					                comps.get(name), crc32s.get(name));
 | 
				
			||||||
 | 
					            this.#bytes(output, deflate ? comp : data);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Central directory
 | 
				
			||||||
 | 
					        let centralOffset = output.length;
 | 
				
			||||||
 | 
					        for (let name of filenames) {
 | 
				
			||||||
 | 
					            this.#zipHeader(
 | 
				
			||||||
 | 
					                output,
 | 
				
			||||||
 | 
					                name,
 | 
				
			||||||
 | 
					                this.#files.get(name).length,
 | 
				
			||||||
 | 
					                comps      .get(name),
 | 
				
			||||||
 | 
					                crc32s     .get(name),
 | 
				
			||||||
 | 
					                offsets    .get(name)
 | 
				
			||||||
 | 
					            );
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        let centralSize = output.length - centralOffset;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // End of central directory
 | 
				
			||||||
 | 
					        this.#u32(output, 0x06054B50);    // Signature
 | 
				
			||||||
 | 
					        this.#u16(output, 0);             // This disk number
 | 
				
			||||||
 | 
					        this.#u16(output, 0);             // Central start disk number
 | 
				
			||||||
 | 
					        this.#u16(output, count);         // Number of items this disk
 | 
				
			||||||
 | 
					        this.#u16(output, count);         // Number of items total
 | 
				
			||||||
 | 
					        this.#u32(output, centralSize);   // Size of central directory
 | 
				
			||||||
 | 
					        this.#u32(output, centralOffset); // Offset of central directory
 | 
				
			||||||
 | 
					        this.#u16(output, 0);             // Comment length
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return new Blob([Uint8Array.from(output)], {type:"application/zip"});
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    ///////////////////////////// Private Methods /////////////////////////////
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Output an array of bytes
 | 
				
			||||||
 | 
					    #bytes(output, x) {
 | 
				
			||||||
 | 
					        for (let b of x)
 | 
				
			||||||
 | 
					            output.push(b);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Calculate the CRC32 checksum for a byte array
 | 
				
			||||||
 | 
					    #crc32(data) {
 | 
				
			||||||
 | 
					        let c = 0xFFFFFFFF;
 | 
				
			||||||
 | 
					        for (let x = 0; x < data.length; x++)
 | 
				
			||||||
 | 
					            c = ((c >>> 8) ^ ZipFile.#CRC_LOOKUP[(c ^ data[x]) & 0xFF]);
 | 
				
			||||||
 | 
					        return ~c & 0xFFFFFFFF;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Compress a data buffer via DEFLATE
 | 
				
			||||||
 | 
					    #deflate(data) {
 | 
				
			||||||
 | 
					        return new Response(new Blob([data]).stream()
 | 
				
			||||||
 | 
					            .pipeThrough(new CompressionStream("deflate-raw"))).bytes();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Output an ASCII string
 | 
				
			||||||
 | 
					    #string(output, x) {
 | 
				
			||||||
 | 
					        this.#bytes(output, Array.from(x).map(c=>c.codePointAt(0)));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Output an 8-bit integer
 | 
				
			||||||
 | 
					    #u8(output, x) {
 | 
				
			||||||
 | 
					        output.push(x & 0xFF);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Output a 16-bit integer
 | 
				
			||||||
 | 
					    #u16(output, x) {
 | 
				
			||||||
 | 
					        this.#u8(output, x);
 | 
				
			||||||
 | 
					        this.#u8(output, x >> 8);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Output a 32-bit integer
 | 
				
			||||||
 | 
					    #u32(output, x) {
 | 
				
			||||||
 | 
					        this.#u16(output, x);
 | 
				
			||||||
 | 
					        this.#u16(output, x >> 16);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Output a ZIP header
 | 
				
			||||||
 | 
					    #zipHeader(output, name, dataLength, compLength, crc32, offset = null) {
 | 
				
			||||||
 | 
					        let central   = offset != null;
 | 
				
			||||||
 | 
					        let method    = compLength == null ? 0 : 8;
 | 
				
			||||||
 | 
					        let signature = central ? 0x02014B50 : 0x04034B50;
 | 
				
			||||||
 | 
					        compLength  ??= dataLength;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        this.#u32(output, signature);   // Signature
 | 
				
			||||||
 | 
					        if (central)
 | 
				
			||||||
 | 
					            this.#u16(output, 20);      // Version made by
 | 
				
			||||||
 | 
					        this.#u16(output, 20);          // Version extracted by
 | 
				
			||||||
 | 
					        this.#u16(output, 0);           // General-purpose flags
 | 
				
			||||||
 | 
					        this.#u16(output, method);      // Compression method
 | 
				
			||||||
 | 
					        this.#u16(output, 0);           // Modified time
 | 
				
			||||||
 | 
					        this.#u16(output, 0);           // Modified date
 | 
				
			||||||
 | 
					        this.#u32(output, crc32);       // CRC32 checksum
 | 
				
			||||||
 | 
					        this.#u32(output, compLength);  // Compressed size
 | 
				
			||||||
 | 
					        this.#u32(output, dataLength);  // Uncompressed size
 | 
				
			||||||
 | 
					        this.#u16(output, name.length); // Filename length
 | 
				
			||||||
 | 
					        this.#u16(output, 0);           // Extra field length
 | 
				
			||||||
 | 
					        if (central) {
 | 
				
			||||||
 | 
					            this.#u16(output, 0);       // File comment length
 | 
				
			||||||
 | 
					            this.#u16(output, 0);       // Disk number start
 | 
				
			||||||
 | 
					            this.#u16(output, 0);       // Internal file attributes
 | 
				
			||||||
 | 
					            this.#u32(output, 0);       // External file attributes
 | 
				
			||||||
 | 
					            this.#u32(output, offset);  // Offset of local header
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        this.#string(output, name);     // File name
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default ZipFile;
 | 
				
			||||||
		Loading…
	
		Reference in New Issue