"use strict"; // Generated by tools/extract-legacy.mjs: helpers copied from the desktop app.js + web additions. // ---- web additions (src/legacy/host-web.js) ---- // The desktop app.js kept these as its own state; in the web version they come from the site. // The active "profile": the game folder opened on the Game Folder page (mounted at /game in the // worker). profileSource() (copied below) turns it into the tools' default asset source. var WS = null; /** Called by the site when the game folder changes. kind: "packs" (has .iop) or "dir" (unpacked). */ function setWebGameFolder(name, kind) { WS = name ? { id: "web", name, gameDir: "/game", sourceKind: kind || "packs" } : null; } // IOP Manager is not part of the web tools yet: no open sessions. var IOP = { files: [], active: null, rows: [], cwd: "", profiles: [], index: null, expanded: new Set(), mode: "folders" }; /** Turn placeholders into SVG icons and wire data-cmd buttons (app.js did this once at load). */ function hydrateLegacy(root) { for (const n of root.querySelectorAll("[data-icon]")) n.replaceWith(Object.assign(icon(n.dataset.icon), { className: `ic ${n.className}`.trim() })); for (const b of root.querySelectorAll("[data-cmd]")) b.addEventListener("click", () => $(b.dataset.cmd).onclick?.()); } // ---- app.js 6-103 ---- const $ = (id) => document.getElementById(id); const bridge = window.api; // (window.api itself is a read-only global from the preload) const call = bridge.call; const el = (tag, props = {}, children = []) => { const n = document.createElement(tag); for (const [k, v] of Object.entries(props)) { if (k === "class") n.className = v; else if (k === "text") n.textContent = v; else if (k === "style") n.style.cssText = v || ""; // CSSOM: allowed by the page's CSP, style="" isn't else if (k.startsWith("on")) n.addEventListener(k.slice(2), v); else if (v !== undefined && v !== null && v !== false) n.setAttribute(k, v === true ? "" : v); } for (const c of [].concat(children)) if (c != null) n.append(c); return n; }; const size = (n) => (n >= 2 ** 30 ? `${(n / 2 ** 30).toFixed(2)} GB` : n >= 2 ** 20 ? `${(n / 2 ** 20).toFixed(1)} MB` : n >= 1024 ? `${(n / 1024).toFixed(1)} KB` : `${n} B`); const hex = (n) => (n >>> 0).toString(16).padStart(8, "0"); const fmtDos = (date, time) => { if (!date) return ""; const y = 1980 + (date >> 9), m = (date >> 5) & 15, d = date & 31; const hh = time >> 11, mm = (time >> 5) & 63; return `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")} ${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}`; }; const MAX_ENTRIES = 65535; // client limit: ZipArchive without ZIP64, 16-bit entry index // ---------- toast / modal / task ---------- function toast(msg, bad = false) { const t = el("div", { class: `toast${bad ? " bad" : ""}`, text: msg, onclick: () => hide() }); const hide = () => { t.classList.add("out"); setTimeout(() => t.remove(), 220); }; $("toast").append(t); setTimeout(hide, bad ? 8000 : 4500); } let modalSeq = 0; function modal(title, body, buttons) { return new Promise((resolve) => { // a modal opened right after another closes: the old one's close timer must not hide it const seq = ++modalSeq; $("modal").classList.remove("closing"); $("modal-title").textContent = title; $("modal-body").replaceChildren(...[].concat(body).map((b) => (typeof b === "string" ? el("p", { text: b }) : b))); $("modal-actions").replaceChildren( ...buttons.map((b) => el("button", { class: `btn ${b.kind || ""}`, text: b.label, onclick: () => { $("modal").classList.add("closing"); setTimeout(() => { if (seq !== modalSeq) return; $("modal").hidden = true; $("modal").classList.remove("closing"); }, 160); resolve(b.value); }, }), ), ); $("modal").hidden = false; $("modal-body").querySelector("input,select")?.focus(); }); } const confirmBox = (title, text, ok = "OK", kind = "primary") => modal(title, text, [{ label: "Cancel", value: false }, { label: ok, kind, value: true }]); async function prompt(title, label, value = "") { const input = el("input", { type: "text", value, spellcheck: "false" }); input.addEventListener("keydown", (e) => e.key === "Enter" && $("modal-actions").lastChild.click()); const ok = await modal(title, [el("label", { text: label }), input], [{ label: "Cancel", value: false }, { label: "OK", kind: "primary", value: true }]); return ok ? input.value.trim() : null; } let taskSeq = 0; const taskHandlers = new Map(); bridge.onTask((p) => taskHandlers.get(p.taskId)?.(p)); /** Runs a main-process task with the progress bar; `label(p)` turns progress into text. */ async function runTask(channel, args, { label = () => "Working…", onEvent } = {}) { const id = `t${++taskSeq}`; $("task").hidden = false; $("task-text").textContent = "Starting…"; $("task-bar").style.width = "0%"; $("task-cancel").onclick = () => call("task:cancel", id); taskHandlers.set(id, (p) => { onEvent?.(p); if (p.type === "log") return; $("task-text").textContent = label(p); if (p.total) $("task-bar").style.width = `${Math.min(100, (100 * (p.bytesTotal ? p.bytes / p.bytesTotal : p.done / p.total)).toFixed(1))}%`; }); try { return await call(channel, id, ...args); } finally { taskHandlers.delete(id); $("task").hidden = true; } } // ---- app.js 162-162 ---- const baseName = (p) => p.replace(/\/$/, "").split("/").pop(); // ---- app.js 494-558 ---- const ICONS = { box: '', pulse: '', power: '', upload: '', gem: '', rocket: '', key: '', plus: '', more: '', grid: '', list: '', up: '', search: '', download: '', swap: '', pencil: '', trash: '', fileplus: '', folderplus: '', folder: '', external: '', note: '', apps: '', shield: '', zap: '', lock: '', refresh: '', newfile: '', import: '', server: '', gear: '', image: '', undo: '', redo: '', save: '', minus: '', fit: '', draw: '', copy: '', cube: '', play: '', pause: '', brush: '', shirt: '', bone: '', target: '', floor: '', focus: '', camera: '', layout: '', map: '', scroll: '', check: '', edit: '', grid: '', undo: '', redo: '', }; function icon(name) { const i = document.createElement("i"); i.className = "ic"; i.innerHTML = `${ICONS[name] || ""}`; return i; } // ---- app.js 564-593 ---- // ---------- menus (dropdowns + right-click) ---------- function showMenu(items, { x, y, anchor } = {}) { const m = $("menu"); m.replaceChildren( ...items.map((it) => it === "-" ? el("div", { class: "sep" }) : el("button", { class: `item${it.danger ? " danger" : ""}`, disabled: !!it.disabled, onclick: () => (hideMenu(), it.run()) }, [icon(it.icon || "none"), el("span", { text: it.label }), it.hint ? el("kbd", { text: it.hint }) : null]), ), ); m.hidden = false; const r = anchor?.getBoundingClientRect(); const w = m.offsetWidth; const h = m.offsetHeight; let left = anchor ? r.right - w : x; let top = anchor ? r.bottom + 6 : y; left = Math.max(8, Math.min(left, innerWidth - w - 8)); if (top + h > innerHeight - 8) top = anchor ? r.top - h - 6 : innerHeight - h - 8; m.style.left = `${left}px`; m.style.top = `${top}px`; replay(m, "open"); } function hideMenu() { $("menu").hidden = true; } document.addEventListener("mousedown", (e) => !e.target.closest("#menu") && hideMenu()); document.addEventListener("keydown", (e) => e.key === "Escape" && hideMenu()); window.addEventListener("blur", hideMenu); const cmd = (id) => ({ disabled: $(id).disabled, run: () => $(id).onclick() }); // ---- app.js 818-823 ---- /** Restarts a CSS animation class. */ function replay(node, cls) { node.classList.remove(cls); void node.offsetWidth; node.classList.add(cls); } // ---- app.js 1051-1063 ---- /** Editors with unsaved changes (their state lives in the window, a reload would drop it). */ function unsavedWork() { const out = []; if (window.FXE && window.FXE.text !== window.FXE.saved) out.push("FX"); if (window.MAP?.dirty) out.push("Map"); if (window.WND?.dirty) out.push("Wnd"); if (typeof IMS !== "undefined" && IMS.dirty) out.push("UI image sets"); // (imageset.js: a script-level const) if (window.MDL?.dirtyItems?.().length) out.push("3D"); if (window.ANS?.clip?.dirty) out.push("Anim"); if (window.COS?.dirty) out.push("Costume"); if (window.TEX?.dirty) out.push("Texture"); return out; } // ---- web overrides (src/legacy/host-web-after.js), loaded after the app.js copies ---- // Same as the desktop, but an unpacked game folder (no .iop) is a "dir" source, not "packs". function profileSource() { return WS?.gameDir ? { kind: WS.sourceKind === "dir" ? "dir" : "packs", folder: WS.gameDir, name: WS.name } : null; } function profileSourceOption() { const s = profileSource(); return s ? [{ value: s, title: `${WS.name} — game folder`, sub: s.kind === "dir" ? "unpacked files" : "its packs" }] : []; }