Files

740 lines
32 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
// UI window editor (Wnd tab): the client's window layouts, rs.iop xml/*.xml (<Window> trees).
// Shows the window as the game draws it (frames from xml/defaultuiframe.xml, images from
// xml/uiimageset.xml, titles with their text), lets windows be moved / resized on the canvas and
// every element / attribute be edited in the side panel or as XML source. Edits touch only the
// characters they change (xmldoc.js), so a patch carries just what was edited.
(() => {
const WND = {
src: null,
rows: [],
doc: null,
where: null,
title: "",
dirty: false,
undo: [],
redo: [],
sel: null, // index of the selected <Window> in doc.elements
zoom: 1,
panX: 40,
panY: 40,
screen: [1024, 768],
state: "Normal",
showHidden: true,
showBounds: true,
tex: new Map(),
};
window.WND = WND;
const toggled = (id) => $(id).classList.contains("on");
const X = XmlDoc;
const baseName = (p) => p.replace(/\\/g, "/").split("/").pop();
const toLatin1 = (u8) => {
let s = "";
for (let i = 0; i < u8.length; i += 8192) s += String.fromCharCode.apply(null, u8.subarray(i, i + 8192));
return s;
};
const fromLatin1 = (s) => Uint8Array.from(s, (c) => c.charCodeAt(0) & 255);
const cp949 = (s) => LSF.decode(fromLatin1(s));
// =====================================================================
// source + list
// =====================================================================
async function setSource(src, { quiet = false } = {}) {
try {
const d = await call("mdl:describe", src);
WND.src = { ...src, label: d.label };
} catch (err) {
return toast(err.message, true);
}
$("wnd-src-label").textContent = WND.src.kind === "dir" ? "Extracted folder" : WND.src.kind === "packs" ? "Pack folder" : `Packs next to ${WND.src.name}`;
$("wnd-src").title = `Reading from ${WND.src.label}`;
WND.tex.clear();
UIRes.reset();
await loadList();
if (!quiet) toast(`Reading windows from ${WND.src.label}.`);
}
async function chooseSource() {
const sessions = (await call("iop:sessions")).filter((s) => !s.encrypted || s.detected);
let choice = null;
const options = [
...profileSourceOption(),
...sessions.map((s) => ({ value: { kind: "session", sessionId: s.id, name: s.name }, title: `Packs next to ${s.name}`, sub: s.file.replace(/[\\/][^\\/]+$/, "") })),
{ value: "packs", title: "A folder with .iop packs…", sub: "the client folder: rs.iop (xml/, texts), map.iop / map2.iop (textures)" },
{ value: "dir", title: "A folder of extracted files…", sub: "xml/…, resource/texture… extracted from the packs" },
];
const list = el("div", { class: "choice-list" }, options.map((o, i) => el("label", { class: "choice" }, [el("input", { type: "radio", name: "wndsrc", checked: i === 0, onchange: () => (choice = o.value) }), el("span", {}, [el("b", { text: o.title }), el("em", { text: o.sub })])])));
choice = options[0].value;
if (!(await modal("Where are the UI files?", ["Window layouts are in rs.iop (xml/…); their pictures in the textures of map.iop / map2.iop. Pick the folder with all of them.", list], [{ label: "Cancel", value: false }, { label: "Use this", kind: "primary", value: true }]))) return false;
let src = choice;
if (choice === "packs" || choice === "dir") {
const folder = await call("dialog:folder", choice === "packs" ? "Folder with the .iop packs" : "Folder with the extracted files");
if (!folder) return false;
src = { kind: choice, folder };
}
await setSource(src);
return true;
}
$("wnd-src").onclick = () => chooseSource();
const list = new VTable($("wnd-list"), [{ key: 1, label: "Window file", width: 220, grow: true, title: true }], { multi: false, id: "wnd" });
list.keyOf = (r) => r[0];
list.onOpen = (r) => openRow(r);
async function loadList() {
if (!WND.src) return;
try {
const rows = await call("xml:list", WND.src);
WND.rows = rows.map(([key, path, pack]) => [key, path.replace(/^.*?xml\//i, ""), pack, path]);
} catch (err) {
toast(err.message, true);
WND.rows = [];
}
filterList();
}
function filterList() {
const q = $("wnd-search").value.trim().toLowerCase();
const rows = WND.rows.filter((r) => !q || r[1].toLowerCase().includes(q));
list.setRows(rows);
$("wnd-count").textContent = rows.length.toLocaleString();
}
$("wnd-search").addEventListener("input", filterList);
async function confirmDiscard() {
if (!WND.dirty) return true;
return confirmBox("Unsaved changes", "This window file has changes that aren't saved. Discard them?", "Discard", "danger");
}
async function openRow(r) {
if (!r || !(await confirmDiscard())) return;
try {
const hit = await call("mdl:readKey", WND.src, r[0]);
if (!hit) throw new Error("Not found in the packs any more.");
await openText(toLatin1(hit.bytes), hit.where, r[1]);
} catch (err) {
toast(err.message, true);
}
}
$("wnd-open-file").onclick = async () => {
if (!(await confirmDiscard())) return;
const file = await call("dialog:openXml");
if (!file) return;
try {
const hit = await call("mdl:readFile", file);
await openText(toLatin1(hit.bytes), hit.where, file);
} catch (err) {
toast(err.message, true);
}
};
async function openText(text, where, title) {
const doc = X.parse(text);
if (!doc.root || doc.root.tag !== "Window") throw new Error(`This isn't a window layout (root <${doc.root?.tag || "?"}>).`);
WND.doc = doc;
WND.where = where;
WND.title = title;
WND.dirty = false;
WND.undo = [];
WND.redo = [];
WND.sel = doc.elements.indexOf(doc.root);
UIText.setFile(title);
$("wnd-title").textContent = title;
$("wnd-dirty").hidden = true;
$("wnd-empty").hidden = true;
$("wnd-side").hidden = false;
if (doc.warnings?.length) toast(`Loaded with ${doc.warnings.length} XML quirk(s): ${doc.warnings[0]}`, true);
await UIRes.load(WND.src);
const tx = UIRes.texts();
$("wnd-textinfo").textContent = tx ? `${tx.count.toLocaleString()} UI texts · ${tx.encoding}` : "no text.txt";
$("wnd-textinfo").title = tx ? `STR(n) → ${UIText.key("n")} from ${tx.source}` : "";
await prefetch();
fit();
renderAll();
}
// =====================================================================
// edits (all through the source text, with undo)
// =====================================================================
function selEl() {
return WND.doc?.elements[WND.sel] || null;
}
/** Path of an element (tag + index among same-tag siblings) so it can be found again after a re-parse. */
function pathOf(el) {
const p = [];
for (let e = el; e && e.parent; e = e.parent) p.unshift([e.tag, e.parent.children.filter((c) => c.tag === e.tag).indexOf(e)]);
return p;
}
function byPath(doc, p) {
let e = doc.root;
for (const [tag, i] of p) {
e = e?.children.filter((c) => c.tag === tag)[i];
if (!e) return null;
}
return e;
}
function apply(newSrc, { keep = true } = {}) {
if (newSrc === WND.doc.src) return;
const selPath = keep && selEl() ? pathOf(selEl()) : null;
WND.undo.push(WND.doc.src);
if (WND.undo.length > 300) WND.undo.shift();
WND.redo = [];
reparse(newSrc, selPath);
}
function reparse(src, selPath) {
WND.doc = X.parse(src);
const s = selPath ? byPath(WND.doc, selPath) : null;
WND.sel = WND.doc.elements.indexOf(s || WND.doc.root);
WND.dirty = true;
$("wnd-dirty").hidden = false;
renderAll();
}
function undoRedo(from, to) {
const s = from.pop();
if (s === undefined) return;
const selPath = selEl() ? pathOf(selEl()) : null;
to.push(WND.doc.src);
reparse(s, selPath);
}
$("wnd-undo").onclick = () => undoRedo(WND.undo, WND.redo);
$("wnd-redo").onclick = () => undoRedo(WND.redo, WND.undo);
/** Edits one element: fn(doc, el) → new source. */
const editEl = (el, fn) => apply(fn(WND.doc, el));
async function save(as = false) {
if (!WND.doc) return;
let where = WND.where;
if (as || !where) {
const file = await call("dialog:saveXml", baseName(WND.title || "window.xml"));
if (!file) return;
where = { kind: "file", file };
} else if (where.kind === "pack" && !(await confirmBox("Save into the pack", `Write ${where.entry} into ${baseName(where.file)}? The new copy replaces the old one.`, "Save"))) return;
try {
await call("mdl:writeBack", where, fromLatin1(WND.doc.src));
WND.where = where;
WND.dirty = false;
$("wnd-dirty").hidden = true;
toast(`Saved ${where.kind === "pack" ? `${where.entry} into ${baseName(where.file)}` : where.file}.`);
} catch (err) {
toast(err.message, true);
}
}
$("wnd-save").onclick = (e) =>
showMenu(
[
{ label: WND.where?.kind === "pack" ? `Save into ${baseName(WND.where.file)}` : "Save", icon: WND.where?.kind === "pack" ? "box" : "save", hint: "Ctrl+S", disabled: !WND.doc, run: () => save() },
{ label: "Save as file…", icon: "download", disabled: !WND.doc, run: () => save(true) },
"-",
{ label: "Screenshot (PNG)…", icon: "camera", disabled: !WND.doc, run: screenshot },
],
{ anchor: e.currentTarget },
);
// =====================================================================
// layout (window rectangles in screen space)
// =====================================================================
const windows = () => (WND.doc ? WND.doc.elements.filter((e) => e.tag === "Window") : []);
const sizeOf = (w) => X.child(w, "Size");
const styleText = (w) => (X.child(w, "Style") ? X.text(WND.doc, X.child(w, "Style")) : "");
const isHidden = (w) => /IWS_START_HIDE/.test(styleText(w));
/** Screen rectangle of a window: its Size relative to the parent window (with X/Y align). */
function rectOf(w, cache = new Map()) {
if (cache.has(w)) return cache.get(w);
const s = sizeOf(w);
const pw = w.parent && w.parent.tag === "Window" ? w.parent : null;
const pr = pw ? rectOf(pw, cache) : { x: 0, y: 0, w: WND.screen[0], h: WND.screen[1] };
// ioWnd::SetSize: Width/Height "Parent" fill the parent; X/YAlign Center / Right / Bottom
// position against the parent, Rate scales by screen / 800×600
let x = X.num(s, "X"), y = X.num(s, "Y");
const wd = /^parent$/i.test(X.attr(s, "Width") || "") ? pr.w : X.num(s, "Width");
const ht = /^parent$/i.test(X.attr(s, "Height") || "") ? pr.h : X.num(s, "Height");
const xa = (X.attr(s, "XAlign") || "").toLowerCase();
const ya = (X.attr(s, "YAlign") || "").toLowerCase();
if (xa === "center") x = x + (pr.w - wd) / 2;
else if (xa === "right") x = x + pr.w - wd;
else if (xa === "rate") x = x * (WND.screen[0] / 800);
if (ya === "center") y = y + (pr.h - ht) / 2;
else if (ya === "bottom") y = y + pr.h - ht;
else if (ya === "rate") y = y * (WND.screen[1] / 600);
const r = { x: pr.x + x, y: pr.y + y, w: wd, h: ht, lx: x, ly: y };
cache.set(w, r);
return r;
}
// =====================================================================
// drawing (UIRes / UIDraw do the game-specific parts)
// =====================================================================
const canvas = $("wnd-canvas");
const ctx = canvas.getContext("2d");
function resize() {
const r = canvas.parentElement.getBoundingClientRect();
const dpr = devicePixelRatio || 1;
canvas.width = Math.max(1, Math.round(r.width * dpr));
canvas.height = Math.max(1, Math.round(r.height * dpr));
canvas.style.width = `${r.width}px`;
canvas.style.height = `${r.height}px`;
draw();
}
new ResizeObserver(resize).observe(canvas.parentElement);
function fit() {
const r = canvas.parentElement.getBoundingClientRect();
const root = WND.doc?.root;
const rr = root ? rectOf(root) : { x: 0, y: 0, w: WND.screen[0], h: WND.screen[1] };
const w = Math.max(rr.w, 50), h = Math.max(rr.h, 50);
WND.zoom = Math.max(0.1, Math.min(4, Math.min((r.width - 60) / w, (r.height - 60) / h)));
WND.panX = (r.width - w * WND.zoom) / 2 - rr.x * WND.zoom;
WND.panY = (r.height - h * WND.zoom) / 2 - rr.y * WND.zoom;
$("wnd-zoom").textContent = `${Math.round(WND.zoom * 100)}%`;
draw();
}
async function prefetch() {
// textures used by the file, so the first draw has them
await Promise.all(UIDraw.wanted(WND.doc).map(([s, i, f]) => UIRes.image(s, i, f)));
}
let drawQueued = false;
function draw() {
if (drawQueued) return;
drawQueued = true;
requestAnimationFrame(() => {
drawQueued = false;
drawNow();
});
}
function drawNow() {
const dpr = devicePixelRatio || 1;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (!WND.doc) return;
ctx.setTransform(dpr * WND.zoom, 0, 0, dpr * WND.zoom, dpr * WND.panX, dpr * WND.panY);
// the game screen: an opaque, scene-like backdrop (Multiply / Add images need something under them)
backdrop(ctx);
ctx.strokeStyle = "rgba(255,255,255,0.12)";
ctx.lineWidth = 1 / WND.zoom;
ctx.strokeRect(0, 0, WND.screen[0], WND.screen[1]);
const cache = new Map();
const preview = toggled("wnd-t-preview");
const showHidden = WND.showHidden && !preview;
if (preview) UIDraw.modalBack(ctx, WND.doc, WND.doc.root, WND.screen);
const drawWin = (w) => {
if (!showHidden && isHidden(w) && w !== WND.doc.root) return;
const r = rectOf(w, cache);
UIDraw.window(ctx, WND.doc, w, r, { state: WND.state, text: toggled("wnd-t-text"), images: toggled("wnd-t-img"), preview });
for (const c of w.children) if (c.tag === "Window") drawWin(c);
};
drawWin(WND.doc.root);
// bounds + selection
const px = 1 / WND.zoom;
if (preview) return;
if (WND.showBounds) {
ctx.lineWidth = px;
for (const w of windows()) {
if (!WND.showHidden && isHidden(w) && w !== WND.doc.root) continue;
const r = rectOf(w, cache);
ctx.strokeStyle = isHidden(w) ? "rgba(150,150,255,.35)" : "rgba(233,180,60,.35)";
ctx.setLineDash(isHidden(w) ? [3 * px, 3 * px] : []);
ctx.strokeRect(r.x + px / 2, r.y + px / 2, Math.max(0, r.w - px), Math.max(0, r.h - px));
}
ctx.setLineDash([]);
}
const s = selWindow();
if (s) {
const r = rectOf(s, cache);
ctx.lineWidth = 2 * px;
ctx.strokeStyle = "#f4c95a";
ctx.strokeRect(r.x, r.y, r.w, r.h);
ctx.fillStyle = "#f4c95a";
for (const [hx, hy] of handles(r)) ctx.fillRect(hx - 3.5 * px, hy - 3.5 * px, 7 * px, 7 * px);
ctx.font = `${11 * px}px ${getComputedStyle(document.body).fontFamily}`;
const label = `${X.attr(s, "ClassName") || "Window"} #${X.attr(s, "Id") ?? ""} ${Math.round(r.lx)},${Math.round(r.ly)} ${r.w}×${r.h}`;
const tw = ctx.measureText(label).width + 8 * px;
ctx.fillStyle = "rgba(20,19,24,.9)";
ctx.fillRect(r.x, r.y - 16 * px, tw, 15 * px);
ctx.fillStyle = "#f4c95a";
ctx.fillText(label, r.x + 4 * px, r.y - 5 * px);
}
}
function backdrop(g) {
const grad = g.createLinearGradient(0, 0, 0, WND.screen[1]);
grad.addColorStop(0, "#3b4a5e");
grad.addColorStop(1, "#1b222c");
g.fillStyle = grad;
g.fillRect(0, 0, WND.screen[0], WND.screen[1]);
}
const handles = (r) => [
[r.x, r.y, "nw"], [r.x + r.w / 2, r.y, "n"], [r.x + r.w, r.y, "ne"], [r.x + r.w, r.y + r.h / 2, "e"],
[r.x + r.w, r.y + r.h, "se"], [r.x + r.w / 2, r.y + r.h, "s"], [r.x, r.y + r.h, "sw"], [r.x, r.y + r.h / 2, "w"],
];
const CURSOR = { nw: "nwse-resize", se: "nwse-resize", ne: "nesw-resize", sw: "nesw-resize", n: "ns-resize", s: "ns-resize", e: "ew-resize", w: "ew-resize" };
function selWindow() {
let e = selEl();
while (e && e.tag !== "Window") e = e.parent;
return e;
}
// ---------- mouse: select, move, resize, pan, zoom ----------
const toScreen = (e) => {
const b = canvas.getBoundingClientRect();
return { x: (e.clientX - b.left - WND.panX) / WND.zoom, y: (e.clientY - b.top - WND.panY) / WND.zoom };
};
function hitWindow(p) {
const cache = new Map();
let best = null;
let area = Infinity;
for (const w of windows()) {
if (!WND.showHidden && isHidden(w) && w !== WND.doc.root) continue;
const r = rectOf(w, cache);
if (p.x >= r.x && p.x < r.x + r.w && p.y >= r.y && p.y < r.y + r.h && r.w * r.h <= area) {
best = w;
area = r.w * r.h;
}
}
return best;
}
function hitHandle(p) {
const s = selWindow();
if (!s) return null;
const r = rectOf(s);
const tol = 6 / WND.zoom;
return handles(r).find(([hx, hy]) => Math.abs(p.x - hx) <= tol && Math.abs(p.y - hy) <= tol)?.[2] || null;
}
let drag = null;
canvas.addEventListener("mousedown", (e) => {
if (!WND.doc) return;
canvas.focus();
const p = toScreen(e);
if (e.button !== 0 || e.altKey) {
drag = { mode: "pan", sx: e.clientX, sy: e.clientY, px: WND.panX, py: WND.panY };
return e.preventDefault();
}
const h = hitHandle(p);
const s = selWindow();
if (h && s) {
drag = { mode: "resize", h, start: p, w: s, s: sizeRect(s), before: WND.doc.src, path: pathOf(s) };
} else {
const w = hitWindow(p);
if (w) {
select(w);
drag = { mode: "move", start: p, w, s: sizeRect(w), before: WND.doc.src, path: pathOf(w), moved: false };
} else drag = { mode: "pan", sx: e.clientX, sy: e.clientY, px: WND.panX, py: WND.panY };
}
e.preventDefault();
});
canvas.addEventListener("contextmenu", (e) => e.preventDefault());
const sizeRect = (w) => {
const s = sizeOf(w);
return { x: X.num(s, "X"), y: X.num(s, "Y"), w: X.num(s, "Width"), h: X.num(s, "Height"), xa: (X.attr(s, "XAlign") || "").toLowerCase(), ya: (X.attr(s, "YAlign") || "").toLowerCase() };
};
window.addEventListener("mousemove", (e) => {
if (!$("view-wnd").classList.contains("on") || !WND.doc) return;
if (!drag) {
if (e.target !== canvas) return;
const p = toScreen(e);
const h = hitHandle(p);
canvas.style.cursor = h ? CURSOR[h] : hitWindow(p) ? "move" : "grab";
$("wnd-hover").textContent = `${Math.floor(p.x)}, ${Math.floor(p.y)}`;
return;
}
if (drag.mode === "pan") {
WND.panX = drag.px + e.clientX - drag.sx;
WND.panY = drag.py + e.clientY - drag.sy;
return draw();
}
const p = toScreen(e);
const dx = Math.round(p.x - drag.start.x);
const dy = Math.round(p.y - drag.start.y);
const s0 = drag.s;
const sx = s0.xa === "rate" ? 800 / WND.screen[0] : 1;
const sy = s0.ya === "rate" ? 600 / WND.screen[1] : 1;
let { x, y, w, h } = s0;
if (drag.mode === "move") {
x += dx * sx;
y += dy * sy;
} else {
if (drag.h.includes("e")) w = Math.max(0, s0.w + dx);
if (drag.h.includes("s")) h = Math.max(0, s0.h + dy);
if (drag.h.includes("w")) {
w = Math.max(0, s0.w - dx);
x = s0.x + dx * sx * (s0.xa === "center" ? 0.5 : 1);
}
if (drag.h.includes("n")) {
h = Math.max(0, s0.h - dy);
y = s0.y + dy * sy * (s0.ya === "center" ? 0.5 : 1);
}
if (s0.xa === "center" && drag.h.includes("e")) x = s0.x + dx / 2;
if (s0.ya === "center" && drag.h.includes("s")) y = s0.y + dy / 2;
}
// live: write straight into the source (undo is added on mouse-up)
const doc = X.parse(drag.before);
const win = byPath(doc, drag.path);
let src = drag.before;
const set = (k, v) => {
const d = X.parse(src);
src = X.setAttr(d, X.child(byPath(d, drag.path), "Size"), k, String(Math.round(v)));
};
if (win && X.child(win, "Size")) {
if (Math.round(x) !== s0.x || drag.mode === "resize") set("X", x);
if (Math.round(y) !== s0.y || drag.mode === "resize") set("Y", y);
if (drag.mode === "resize") {
set("Width", w);
set("Height", h);
}
drag.moved = true;
WND.doc = X.parse(src);
WND.sel = WND.doc.elements.indexOf(byPath(WND.doc, drag.path));
draw();
$("wnd-hover").textContent = `X ${Math.round(x)} Y ${Math.round(y)} ${Math.round(w)} × ${Math.round(h)}`;
}
});
window.addEventListener("mouseup", () => {
if (!drag) return;
const d = drag;
drag = null;
if ((d.mode === "move" || d.mode === "resize") && d.moved && WND.doc.src !== d.before) {
WND.undo.push(d.before);
WND.redo = [];
WND.dirty = true;
$("wnd-dirty").hidden = false;
renderSide();
}
canvas.style.cursor = "";
});
canvas.addEventListener(
"wheel",
(e) => {
e.preventDefault();
const b = canvas.getBoundingClientRect();
const cx = e.clientX - b.left, cy = e.clientY - b.top;
const nz = Math.max(0.1, Math.min(8, WND.zoom * (e.deltaY < 0 ? 1.15 : 1 / 1.15)));
WND.panX = cx - ((cx - WND.panX) * nz) / WND.zoom;
WND.panY = cy - ((cy - WND.panY) * nz) / WND.zoom;
WND.zoom = nz;
$("wnd-zoom").textContent = `${Math.round(nz * 100)}%`;
draw();
},
{ passive: false },
);
$("wnd-fit").onclick = fit;
$("wnd-zoom").onclick = () => {
WND.zoom = 1;
$("wnd-zoom").textContent = "100%";
draw();
};
$("wnd-screen").onchange = () => {
WND.screen = $("wnd-screen").value.split("x").map(Number);
fit();
};
$("wnd-state").onchange = () => {
WND.state = $("wnd-state").value;
draw();
};
for (const id of ["wnd-t-text", "wnd-t-img"]) $(id).onclick = () => ($(id).classList.toggle("on"), draw());
$("wnd-t-preview").onclick = () => {
$("wnd-t-preview").classList.toggle("on");
draw();
};
$("wnd-t-bounds").onclick = () => {
WND.showBounds = !WND.showBounds;
$("wnd-t-bounds").classList.toggle("on", WND.showBounds);
draw();
};
$("wnd-t-hidden").onclick = () => {
WND.showHidden = !WND.showHidden;
$("wnd-t-hidden").classList.toggle("on", WND.showHidden);
draw();
renderTree();
};
async function screenshot() {
const file = await call("dialog:savePng", `${baseName(WND.title).replace(/\.xml$/i, "")}.png`);
if (!file) return;
const root = rectOf(WND.doc.root);
const c = document.createElement("canvas");
c.width = Math.max(1, root.w);
c.height = Math.max(1, root.h);
const g = c.getContext("2d");
g.translate(-root.x, -root.y);
backdrop(g);
const cache = new Map();
const drawWin = (w) => {
if (isHidden(w) && w !== WND.doc.root) return; // the screenshot shows what the game shows
UIDraw.window(g, WND.doc, w, rectOf(w, cache), { state: WND.state, text: true, images: true, preview: true });
for (const ch of w.children) if (ch.tag === "Window") drawWin(ch);
};
drawWin(WND.doc.root);
await call("file:writeBase64", file, c.toDataURL("image/png").split(",")[1]);
toast(`Saved ${file}.`);
}
// =====================================================================
// side panel: window tree, properties, source
// =====================================================================
function select(el) {
WND.sel = WND.doc.elements.indexOf(el);
renderSide();
draw();
}
function renderAll() {
renderSide();
draw();
$("wnd-undo").disabled = !WND.undo.length;
$("wnd-redo").disabled = !WND.redo.length;
}
function renderSide() {
renderTree();
renderProps();
$("wnd-undo").disabled = !WND.undo.length;
$("wnd-redo").disabled = !WND.redo.length;
}
function renderTree() {
const out = [];
const sw = selWindow();
const walk = (w, depth) => {
const hidden = isHidden(w);
if (!WND.showHidden && hidden && w !== WND.doc.root) return;
const title = UIText.titleOf(WND.doc, w);
out.push(
el("div", { class: `wnd-node${w === sw ? " on" : ""}${hidden ? " hid" : ""}`, style: `padding-left:${8 + depth * 14}px`, onclick: () => select(w) }, [
el("b", { text: X.attr(w, "ClassName") || "Window" }),
el("em", { text: `#${X.attr(w, "Id") ?? ""}${title ? ` ${title}` : ""}` }),
]),
);
for (const c of w.children) if (c.tag === "Window") walk(c, depth + 1);
};
if (WND.doc) walk(WND.doc.root, 0);
$("wnd-tree").replaceChildren(...out);
$("wnd-tree").querySelector(".on")?.scrollIntoView({ block: "nearest" });
$("wnd-count-win").textContent = String(windows().length);
}
/** One element as an editable card: its attributes, text, child elements (not child windows). */
function elementCard(e, depth = 0) {
const doc = WND.doc;
const rows = [];
for (const a of e.attrs) {
const input = UIProps.input(e, a.name, a.value, (v) => editEl(e, (d, x) => X.setAttr(d, byPath(d, pathOf(x)) || x, a.name, v)));
rows.push(
el("div", { class: "map-kv" }, [
el("span", { class: "k", text: a.name }),
input,
el("button", { class: "iconbtn only", title: "Remove this attribute", onclick: () => editEl(e, (d, x) => X.removeAttr(d, x, a.name)) }, [icon("minus")]),
]),
);
}
if (!e.children.length && (e.textStart !== undefined || ["Style", "ExStyle", "Text"].includes(e.tag))) {
const t = X.text(doc, e);
const input = el("input", { type: "text", value: t.trim(), spellcheck: "false" });
input.addEventListener("change", () => editEl(e, (d, x) => X.setText(d, x, input.value)));
const resolved = e.tag === "Text" ? UIText.resolve(doc, e, t) : "";
rows.push(el("div", { class: "map-kv" }, [el("span", { class: "k", text: "(text)" }), input, el("span")]));
if (resolved && resolved !== t.trim()) rows.push(el("div", { class: "wnd-resolved", text: `→ ${resolved}` }));
}
// add attribute
const nk = el("input", { type: "text", placeholder: "attribute", spellcheck: "false" });
const nv = el("input", { type: "text", placeholder: "value", spellcheck: "false" });
const add = () => nk.value.trim() && editEl(e, (d, x) => X.setAttr(d, x, nk.value.trim(), nv.value));
nv.addEventListener("keydown", (ev) => ev.key === "Enter" && add());
const kids = e.children.filter((c) => c.tag !== "Window");
const head = el("div", { class: "wnd-el-head" }, [
el("b", { text: `<${e.tag}>` }),
el("span", { class: "spacer" }),
el("button", { class: "iconbtn only", title: "Copy this element", onclick: () => editEl(e, (d, x) => X.insertChild(d, x.parent, X.markupOf(d, x), x)) }, [icon("copy")]),
el("button", { class: "iconbtn only danger", title: "Remove this element", onclick: () => editEl(e, (d, x) => X.removeElement(d, x)) }, [icon("trash")]),
]);
return el("div", { class: `wnd-el depth${Math.min(depth, 3)}` }, [head, ...rows, el("div", { class: "map-kv add" }, [nk, nv, el("button", { class: "iconbtn only", title: "Add attribute", onclick: add }, [icon("plus")])]), ...kids.map((k) => elementCard(k, depth + 1))]);
}
function renderProps() {
const w = selWindow();
$("wnd-props-card").hidden = !w;
if (!w) return;
$("wnd-props-name").textContent = `${X.attr(w, "ClassName") || "Window"} #${X.attr(w, "Id") ?? ""}`;
const doc = WND.doc;
const quick = UIProps.quick(doc, w, { edit: editEl, pathOf, byPath });
const tools = el("div", { class: "map-sec-tools" }, [
el("button", { class: "iconbtn", title: "Add a child window", onclick: () => addChild(w) }, [icon("plus"), el("span", { text: "Child" })]),
el("button", { class: "iconbtn", title: "Duplicate this window", disabled: w === doc.root, onclick: () => editEl(w, (d, x) => X.insertChild(d, x.parent, X.markupOf(d, x), x)) }, [icon("copy"), el("span", { text: "Copy" })]),
el("button", { class: "iconbtn", title: "Add an element (Title, Imagery, ExtraInfo…)", onclick: (ev) => addElementMenu(ev, w) }, [icon("fileplus"), el("span", { text: "Element" })]),
el("span", { class: "spacer" }),
el("button", { class: "iconbtn only", title: "Edit this window as XML", onclick: () => editSource(w) }, [icon("note")]),
el("button", { class: "iconbtn only danger", title: "Delete this window", disabled: w === doc.root, onclick: () => editEl(w, (d, x) => X.removeElement(d, x)) }, [icon("trash")]),
]);
$("wnd-props").replaceChildren(tools, quick, el("h5", { text: "Elements" }), ...w.children.filter((c) => c.tag !== "Window").map((c) => elementCard(c)));
}
function addChild(w) {
const ids = w.children.filter((c) => c.tag === "Window").map((c) => Number(X.attr(c, "Id")) || 0);
const id = (ids.length ? Math.max(...ids) : 0) + 1;
editEl(w, (d, x) => X.insertChild(d, x, `<Window ClassName="ioWnd" Id="${id}">\n\t<Size X="10" Y="10" Width="100" Height="30" />\n</Window>`));
}
function addElementMenu(ev, w) {
const snippets = {
Title: `<Title>\n\t<Format X="0" Y="0" Size="12" Style="Normal" HorzAlign="Left"/>\n\t<Color Type="Normal" Text="FFFFFFFF" Back="FF000000"/>\n\t<Text>Text</Text>\n</Title>`,
"Imagery (image)": `<Imagery>\n\t<Image Type="Normal" Imageset="BaseImage001" Image="none"/>\n</Imagery>`,
"Imagery (frame)": `<Imagery>\n\t<DefaultFrameImagery Type="Normal" Templete="slot_empty_white">\n\t\t<Area X="0" Y="0" Width="100" Height="30"/>\n\t</DefaultFrameImagery>\n</Imagery>`,
Style: `<Style>IWS_PROCESS</Style>`,
ExStyle: `<ExStyle>IWS_EX_PUSH_DOUBLE</ExStyle>`,
ExtraInfo: `<ExtraInfo />`,
};
showMenu(Object.entries(snippets).map(([label, s]) => ({ label, icon: "plus", run: () => editEl(w, (d, x) => X.insertChild(d, x, s)) })), { x: ev.clientX, y: ev.clientY });
}
async function editSource(w) {
const ta = el("textarea", { class: "mdl-mat", spellcheck: "false" });
ta.value = cp949(X.markupOf(WND.doc, w));
const ok = await modal(`XML — ${X.attr(w, "ClassName") || "Window"} #${X.attr(w, "Id") ?? ""}`, [el("p", { class: "muted", text: "The window and its children as XML. Korean text is shown decoded and written back as CP949." }), ta], [{ label: "Cancel", value: false }, { label: "Apply", kind: "primary", value: true }]);
if (!ok) return;
let bytes;
try {
bytes = UIText.encodeCp949(ta.value);
} catch (err) {
return toast(err.message, true);
}
const markup = toLatin1(bytes);
try {
X.parse(markup);
} catch (err) {
return toast(`Not valid XML: ${err.message}`, true);
}
editEl(w, (d, x) => d.src.slice(0, x.start) + markup + d.src.slice(x.end));
}
// keys
document.addEventListener("keydown", (e) => {
if (!$("view-wnd").classList.contains("on") || !WND.doc) return;
if (e.target.matches?.("input, textarea, select")) return;
const k = e.key.toLowerCase();
if (e.ctrlKey && k === "z") return e.preventDefault(), undoRedo(WND.undo, WND.redo);
if (e.ctrlKey && k === "y") return e.preventDefault(), undoRedo(WND.redo, WND.undo);
if (e.ctrlKey && k === "s") return e.preventDefault(), save();
const w = selWindow();
const mv = { arrowleft: [-1, 0], arrowright: [1, 0], arrowup: [0, -1], arrowdown: [0, 1] }[k];
if (w && mv && sizeOf(w)) {
e.preventDefault();
const step = e.shiftKey ? 10 : 1;
const s = sizeRect(w);
const sx = 1, sy = 1;
let src = WND.doc.src;
let d = WND.doc;
if (mv[0]) src = X.setAttr(d, sizeOf(w), "X", String(s.x + mv[0] * step * sx));
d = X.parse(src);
if (mv[1]) src = X.setAttr(d, X.child(byPath(d, pathOf(w)), "Size"), "Y", String(s.y + mv[1] * step * sy));
apply(src);
}
if (e.key === "Delete" && w && w !== WND.doc.root) editEl(w, (d, x) => X.removeElement(d, x));
});
document.querySelector('[data-view="wnd"]').addEventListener("click", async () => {
resize();
if (WND.src) return;
if (profileSource()) return setSource(profileSource(), { quiet: true });
const sessions = (await call("iop:sessions")).filter((s) => !s.encrypted || s.detected);
const s = sessions.find((x) => /^rs\.iop$/i.test(x.name)) || sessions[0];
if (s) await setSource({ kind: "session", sessionId: s.id, name: s.name }, { quiet: true });
});
Object.assign(WND, { setSource, openRow, openText, draw: drawNow, select, rectOf, windows, save, apply });
})();