LosaDev Tools: Lost Saga modding tools in the browser

This commit is contained in:
LosaDev committed 2026-11-07 23:08:43 +07:00
commit c5e04472fb
105 files changed
+38541

No files matched your search

+739
View File
@@ -0,0 +1,739 @@
"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 });
})();