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