740 lines
32 KiB
JavaScript
740 lines
32 KiB
JavaScript
"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 });
|
||
})();
|