"use strict"; // UI window editor (Wnd tab): the client's window layouts, rs.iop xml/*.xml ( 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 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, `\n\t\n`)); } function addElementMenu(ev, w) { const snippets = { 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`, "Imagery (image)": `\n\t\n`, "Imagery (frame)": `\n\t\n\t\t\n\t\n`, Style: ``, ExStyle: `IWS_EX_PUSH_DOUBLE`, 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 }); })();