Files

261 lines
13 KiB
JavaScript

"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 <i data-icon="x"> 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: '<path d="M3.5 7.5 12 3l8.5 4.5v9L12 21l-8.5-4.5z"/><path d="M3.5 7.5 12 12l8.5-4.5M12 12v9"/>',
pulse: '<path d="M3 12h4l2.5-6 4 12 2.5-6H21"/>',
power: '<path d="M12 3v8"/><path d="M6.4 7.2a7.5 7.5 0 1 0 11.2 0"/>',
upload: '<path d="M12 16V4M7 9l5-5 5 5"/><path d="M4 15v4a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-4"/>',
gem: '<path d="M6 4h12l3 5-9 11L3 9z"/><path d="M3 9h18M9 4l3 16M15 4l-3 16"/>',
rocket: '<path d="M12 3c3 2 5 5.5 5 9.5L15 16H9l-2-3.5C7 8.5 9 5 12 3z"/><circle cx="12" cy="10" r="1.6"/><path d="M9 16l-2.5 3M15 16l2.5 3M12 17v4"/>',
key: '<circle cx="8" cy="15" r="4"/><path d="m11 12 9-9M16 7l3 3M14 9l2 2"/>',
plus: '<path d="M12 5v14M5 12h14"/>',
more: '<circle cx="5" cy="12" r="1.4"/><circle cx="12" cy="12" r="1.4"/><circle cx="19" cy="12" r="1.4"/>',
grid: '<rect x="4" y="4" width="7" height="7" rx="1"/><rect x="13" y="4" width="7" height="7" rx="1"/><rect x="4" y="13" width="7" height="7" rx="1"/><rect x="13" y="13" width="7" height="7" rx="1"/>',
list: '<path d="M9 6h11M9 12h11M9 18h11"/><circle cx="4.5" cy="6" r=".9"/><circle cx="4.5" cy="12" r=".9"/><circle cx="4.5" cy="18" r=".9"/>',
up: '<path d="M12 19V5M6 11l6-6 6 6"/>',
search: '<circle cx="11" cy="11" r="6.5"/><path d="m16 16 4 4"/>',
download: '<path d="M12 4v12M7 11l5 5 5-5"/><path d="M4 18v1a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-1"/>',
swap: '<path d="M7 4 3 8l4 4M3 8h14M17 20l4-4-4-4M21 16H7"/>',
pencil: '<path d="M4 20h4L19 9l-4-4L4 16z"/><path d="m13.5 6.5 4 4"/>',
trash: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/>',
fileplus: '<path d="M6 3h8l4 4v14H6z"/><path d="M14 3v4h4M12 11v6M9 14h6"/>',
folderplus: '<path d="M3 6h6l2 2h10v11H3z"/><path d="M12 11v5M9.5 13.5h5"/>',
folder: '<path d="M3 6h6l2 2h10v11H3z"/>',
external: '<path d="M14 4h6v6M20 4l-9 9"/><path d="M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5"/>',
note: '<path d="M6 3h9l3 3v15H6z"/><path d="M9 9h6M9 13h6M9 17h4"/>',
apps: '<rect x="4" y="4" width="6" height="6" rx="1"/><rect x="14" y="4" width="6" height="6" rx="1"/><rect x="4" y="14" width="6" height="6" rx="1"/><path d="M17 14v6M14 17h6"/>',
shield: '<path d="M12 3 5 6v6c0 4 3 7.5 7 9 4-1.5 7-5 7-9V6z"/><path d="m9 12 2 2 4-4"/>',
zap: '<path d="M13 3 5 13h6l-1 8 8-10h-6z"/>',
lock: '<rect x="5" y="11" width="14" height="9" rx="1.5"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/>',
refresh: '<path d="M20 12a8 8 0 1 1-2.3-5.7M20 4v5h-5"/>',
newfile: '<path d="M6 3h8l4 4v14H6z"/><path d="M14 3v4h4"/>',
import: '<path d="M12 4v11M7 10l5 5 5-5"/><path d="M5 20h14"/>',
server: '<rect x="4" y="4" width="16" height="7" rx="1.5"/><rect x="4" y="13" width="16" height="7" rx="1.5"/><path d="M8 7.5h.01M8 16.5h.01"/>',
gear: '<circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"/>',
image: '<rect x="3.5" y="4.5" width="17" height="15" rx="2"/><circle cx="9" cy="10" r="1.8"/><path d="m4 18 5-5 3 3 3-3 5 5"/>',
undo: '<path d="M9 7 4 12l5 5"/><path d="M4 12h10a6 6 0 0 1 0 12"/>',
redo: '<path d="m15 7 5 5-5 5"/><path d="M20 12H10a6 6 0 0 0 0 12"/>',
save: '<path d="M5 4h11l3 3v13H5z"/><path d="M8 4v5h7V4M8 20v-6h8v6"/>',
minus: '<path d="M5 12h14"/>',
fit: '<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/>',
draw: '<rect x="4" y="4" width="16" height="16" rx="1" stroke-dasharray="3 2.5"/><path d="M12 8v8M8 12h8"/>',
copy: '<rect x="8" y="8" width="12" height="12" rx="1.5"/><path d="M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3"/>',
cube: '<path d="M12 3 4 7.5v9L12 21l8-4.5v-9z"/><path d="m4 7.5 8 4.5 8-4.5M12 12v9"/><path d="m8 5.3 8 4.5" opacity=".45"/>',
play: '<path d="M8 5v14l11-7z"/>',
pause: '<path d="M8 5v14M16 5v14"/>',
brush: '<path d="M14.5 4.5 19.5 9.5 11 18l-5-5z"/><path d="M6 13c-2 0-3 1.5-3 3.5S2 20 2 20s4 .5 5.5-1 1.5-3 1.5-3"/><path d="m16.5 2.5 5 5"/>',
shirt: '<path d="M8.5 3.5 4 6l-1.5 4.5 3 1.2V20.5h13V11.7l3-1.2L20 6l-4.5-2.5c-.6 1.6-2 2.5-3.5 2.5S9.1 5.1 8.5 3.5z"/>',
bone: '<circle cx="6" cy="6" r="2.2"/><circle cx="18" cy="18" r="2.2"/><path d="m7.6 7.6 8.8 8.8"/>',
target: '<circle cx="12" cy="12" r="7"/><circle cx="12" cy="12" r="2"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3"/>',
floor: '<path d="M3 16 9 8h6l6 8z"/><path d="M12 8v8M6 12h12"/>',
focus: '<path d="M4 9V5a1 1 0 0 1 1-1h4M15 4h4a1 1 0 0 1 1 1v4M20 15v4a1 1 0 0 1-1 1h-4M9 20H5a1 1 0 0 1-1-1v-4"/><circle cx="12" cy="12" r="2.5"/>',
camera: '<path d="M4 8h3l2-3h6l2 3h3v11H4z"/><circle cx="12" cy="13" r="3.5"/>',
layout: '<rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M3.5 9h17M9 9v10.5"/>',
map: '<path d="M3 6.5 9 4l6 2.5L21 4v13.5L15 20l-6-2.5L3 20z"/><path d="M9 4v13.5M15 6.5V20"/>',
scroll: '<path d="M7 4h11a2 2 0 0 1 2 2v1h-4"/><path d="M16 7v11a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-1h10"/><path d="M7 4a2 2 0 0 0-2 2v11"/><path d="M8.5 9h4.5M8.5 12.5h4.5"/>',
check: '<path d="M5 12.5l4.5 4.5L19 7.5"/>',
edit: '<path d="M4 20h4L19 9l-4-4L4 16z"/><path d="M13.5 6.5l4 4"/>',
grid: '<rect x="3.5" y="4.5" width="17" height="15" rx="1.5"/><path d="M3.5 9.5h17M3.5 14.5h17M9 4.5v15M15 4.5v15"/>',
undo: '<path d="M9 14 4 9l5-5"/><path d="M4 9h10a6 6 0 0 1 0 12h-3"/>',
redo: '<path d="m15 14 5-5-5-5"/><path d="M20 9H10a6 6 0 0 0 0 12h3"/>',
};
function icon(name) {
const i = document.createElement("i");
i.className = "ic";
i.innerHTML = `<svg viewBox="0 0 24 24">${ICONS[name] || ""}</svg>`;
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" }] : [];
}