/* isokit Studio: the scene editor. Talks to `isokit studio` over /api, or to * window.IsoKitBackend when a page provides one (the web Studio runs the * engine as WASM in the page and answers the same requests). */ (function () { "use strict"; var $ = function (id) { return document.getElementById(id); }; // { api(path, body) -> json | Promise, source(meta), agentBox(meta), preview(), saveTitle } var B = window.IsoKitBackend || null; var S = { meta: null, doc: null, version: null, sel: null, placing: null, view: null, boxes: {}, warnings: [], busy: false, canUndo: false, canRedo: false, port: location.port }; var ICON_DEFAULT = "▪"; function esc(s) { return String(s === undefined || s === null ? "" : s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } function api(path, body) { if (B) return Promise.resolve().then(function () { return B.api(path, body); }); var o = body === undefined ? { cache: "no-store" } : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }; return fetch(path, o).then(function (r) { return r.json(); }); } var toastT = 0; function toast(msg, bad) { var t = $("toast"); t.textContent = msg; t.hidden = false; t.classList.toggle("st-bad", !!bad); clearTimeout(toastT); toastT = setTimeout(function () { t.hidden = true; }, bad ? 6000 : 2600); } function snapStep() { return parseFloat($("snap").value) || 0; } function snap(v) { var s = snapStep(); return s ? Math.round(v / s) * s : Math.round(v * 100) / 100; } function prefab(kind) { return (S.meta.catalog || []).filter(function (m) { return m.type === kind; })[0]; } function obj(id) { return (S.doc.objects || []).filter(function (o) { return o.id === id; })[0]; } function label(o) { var m = prefab(o.type); return o.name || (m ? m.label : o.type); } function icon(kind) { var m = prefab(kind); return (m && m.icon) || ICON_DEFAULT; } // ------------------------------------------------------------ server state function takeState(r) { if (r.doc) S.doc = r.doc; if (r.version !== undefined) S.version = r.version; if (r.can_undo !== undefined) { S.canUndo = r.can_undo; S.canRedo = r.can_redo; } $("undo").disabled = !S.canUndo; $("redo").disabled = !S.canRedo; } function op(o, quiet) { $("save").textContent = "Saving…"; $("save").classList.remove("st-err"); return api("/api/op", o).then(function (r) { if (r.error) { $("save").textContent = "Not saved"; $("save").classList.add("st-err"); toast(r.error, true); return null; } takeState(r); $("save").textContent = "Saved"; if (r.id && o.op !== "update") S.sel = r.id; if (S.sel && !obj(S.sel)) S.sel = null; S.view.refresh(); renderLists(); if (!quiet) renderRight(); return r; }, function (e) { $("save").textContent = B ? "Not saved" : "Offline"; $("save").classList.add("st-err"); toast((B ? "Could not save: " : "Studio server not reachable: ") + e, true); }); } function update(id, patch, quiet) { return op({ op: "update", id: id, patch: patch }, quiet); } function undo() { api("/api/undo", {}).then(function (r) { takeState(r); afterExternal(); }); } function redo() { api("/api/redo", {}).then(function (r) { takeState(r); afterExternal(); }); } function afterExternal() { if (S.sel && !obj(S.sel)) S.sel = null; S.view.refresh(); renderLists(); renderRight(); } function reloadAll(why) { return Promise.all([api("/api/meta"), api("/api/doc")]).then(function (rs) { var levelsChanged = JSON.stringify(rs[0].levels) !== JSON.stringify(S.meta.levels); S.meta = rs[0]; takeState(rs[1]); if (levelsChanged) { remount(); } else { S.view.source.meta = S.meta; S.view.refresh(); } renderPalette(); afterExternal(); if (why) toast(why); }); } // another editor or an agent changed the files: follow along function poll() { api("/api/version").then(function (r) { if (r.version !== S.version && !S.busy) { S.version = r.version; reloadAll(r.why || "Scene updated outside the Studio (agent or file edit)"); } }).catch(function () { /* server restarting */ }).then(function () { setTimeout(poll, 800); }); } // ------------------------------------------------------------ view var drag = null; function moveEls(id) { return S.view.svg.querySelectorAll('[data-obj="' + id.replace(/"/g, "") + '"]'); } function mountView() { var src = B ? B.source(toViewerMeta(S.meta)) : IsoKit.httpSource("", toViewerMeta(S.meta)); S.view = IsoKit.mount($("view"), src, { note: false, noSelect: true, wheelZoom: true, onRender: function (d) { if (!d || d.error) { $("stats").textContent = "script error"; return; } S.boxes = d.boxes || {}; S.warnings = d.warnings || []; $("stats").textContent = d.sprites + " sprites · " + d.nodes + " nodes · " + d.ms + " ms"; var w = $("warnbtn"); w.hidden = !S.warnings.length; w.textContent = S.warnings.length + " warning" + (S.warnings.length === 1 ? "" : "s"); if (S.sel) S.view.selectObj(S.sel); }, onHover: function (w) { if (w) $("cursor").textContent = "x " + w[0].toFixed(1) + " y " + w[1].toFixed(1); }, onPick: function (i) { select(i && i.obj ? i.obj : null); }, onKey: keyHandler, interact: { down: function (e, hit) { if (S.placing) { return true; } if (!hit.obj) return false; var o = obj(hit.obj); if (!o || o.locked) return false; drag = { id: o.id, ox: o.x, oy: o.y, w0: hit.world, moved: false, els: null }; return true; }, move: function (e, m) { if (S.placing || !drag || !m.world || !drag.w0) return; if (!drag.moved) { drag.moved = true; drag.els = moveEls(drag.id); select(drag.id, true); } var dx = snap(drag.ox + m.world[0] - drag.w0[0]) - drag.ox, dy = snap(drag.oy + m.world[1] - drag.w0[1]) - drag.oy; var a = S.view.screenOf(dx, dy, 0), b = S.view.screenOf(0, 0, 0); drag.dx = dx; drag.dy = dy; Array.prototype.forEach.call(drag.els, function (g) { g.setAttribute("transform", "translate(" + (a[0] - b[0]).toFixed(1) + " " + (a[1] - b[1]).toFixed(1) + ")"); g.classList.add("iso-moving"); }); $("cursor").textContent = "x " + (drag.ox + dx).toFixed(2) + " y " + (drag.oy + dy).toFixed(2); }, up: function (e, m) { if (S.placing) { if (!m.cancel && m.world) place(S.placing, m.world, e.shiftKey); return; } var d = drag; drag = null; if (!d) return; if (!d.moved || m.cancel) { select(d.id); return; } if (d.dx || d.dy) update(d.id, { x: +(d.ox + d.dx).toFixed(3), y: +(d.oy + d.dy).toFixed(3) }); else S.view.refresh(); }, cancel: function () { if (drag && drag.els) S.view.refresh(); drag = null; } } }); window.__studio = { S: S, view: S.view, op: op, select: select, reloadAll: reloadAll, toast: toast, api: api, obj: obj, snapStep: snapStep, ready: true }; } function toViewerMeta(m) { return { title: m.title, note: m.note, levels: m.levels, defaultLevel: m.default_level, defaultRotation: m.default_rotation }; } function remount() { var lv = S.view ? S.view.getLevel() : null, rot = S.view ? S.view.getRotation() : null; if (S.view) S.view.destroy(); mountView(); if (lv) S.view.setLevel(lv); if (rot !== null) S.view.setRotation(rot); } // ------------------------------------------------------------ actions function place(kind, w, keep) { var m = prefab(kind); op({ op: "add", type: kind, x: snap(w[0]), y: snap(w[1]) }).then(function (r) { if (r) toast((m ? m.label : kind) + " added" + (keep ? "" : " · Shift-click to place several")); }); if (!keep) setPlacing(null); } function setPlacing(kind) { S.placing = kind; document.querySelector(".st-centre").classList.toggle("st-placing", !!kind); Array.prototype.forEach.call(document.querySelectorAll(".st-tile"), function (b) { b.setAttribute("aria-pressed", String(b.getAttribute("data-type") === kind)); }); var h = $("hint"); if (kind) { var m = prefab(kind); h.textContent = "Click on the map to place: " + (m ? m.label : kind) + " · Esc to stop"; h.hidden = false; } else h.hidden = true; } function select(id, quiet) { if (id && !obj(id)) id = null; S.sel = id; S.view.selectObj(id); Array.prototype.forEach.call(document.querySelectorAll("#objects li"), function (li) { li.setAttribute("aria-selected", String(li.getAttribute("data-id") === id)); }); if (!quiet) renderRight(); } function remove(id) { if (id) op({ op: "remove", id: id }).then(function (r) { if (r) toast("Deleted · Ctrl+Z brings it back"); }); } function duplicate(id) { if (id) op({ op: "duplicate", id: id, dx: Math.max(snapStep(), 1) * 2, dy: Math.max(snapStep(), 1) * 2 }); } function turn(id, d) { var o = obj(id); if (o) update(id, { rot: ((o.rot || 0) + d + 360) % 360 }); } function nudge(id, dx, dy) { var o = obj(id); if (!o || o.locked) return; var s = snapStep() || 0.5; // arrow keys follow the screen: map screen directions onto world axes for the current rotation var rot = S.view.getRotation(); var right = IsoKit.unproject(rot, 1, 0, 0), up = IsoKit.unproject(rot, 0, -1, 0), o0 = IsoKit.unproject(rot, 0, 0, 0); var r = [right[0] - o0[0], right[1] - o0[1]], u = [up[0] - o0[0], up[1] - o0[1]]; var wx = dx * r[0] + dy * u[0], wy = dx * r[1] + dy * u[1]; // pick the dominant world axis so a key press moves along x or y only if (Math.abs(wx) >= Math.abs(wy)) { wy = 0; wx = Math.sign(wx); } else { wx = 0; wy = Math.sign(wy); } update(id, { x: +(o.x + wx * s).toFixed(3), y: +(o.y + wy * s).toFixed(3) }, true).then(function () { renderRight(); }); } function keyHandler(e) { var t = e.target, typing = t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.tagName === "SELECT"); if (typing || document.querySelector("dialog[open]")) return false; var k = e.key, ctrl = e.ctrlKey || e.metaKey; if (ctrl && (k === "z" || k === "Z")) { if (e.shiftKey) redo(); else undo(); return true; } if (ctrl && (k === "y" || k === "Y")) { redo(); return true; } if (k === "Escape") { if (S.placing) setPlacing(null); else select(null); return true; } if (!S.sel) return false; if (k === "Delete" || k === "Backspace") { remove(S.sel); return true; } if (ctrl && (k === "d" || k === "D")) { duplicate(S.sel); return true; } if (!ctrl && (k === "r" || k === "R")) { turn(S.sel, e.shiftKey ? -90 : 90); return true; } if (k === "ArrowLeft") { nudge(S.sel, -1, 0); return true; } if (k === "ArrowRight") { nudge(S.sel, 1, 0); return true; } if (k === "ArrowUp") { nudge(S.sel, 0, 1); return true; } if (k === "ArrowDown") { nudge(S.sel, 0, -1); return true; } return false; } document.addEventListener("keydown", function (e) { if (S.view && e.target === S.view.svg) return; // the viewer calls keyHandler itself if (keyHandler(e)) e.preventDefault(); }); // ------------------------------------------------------------ left panel function renderPalette() { var q = $("psearch").value.trim().toLowerCase(), cats = {}, order = []; (S.meta.catalog || []).forEach(function (m) { if (q && (m.label + " " + m.type + " " + m.category + " " + (m.help || "")).toLowerCase().indexOf(q) < 0) return; if (!cats[m.category]) { cats[m.category] = []; order.push(m.category); } cats[m.category].push(m); }); $("palette").innerHTML = order.map(function (c) { return '
' + esc(m.help) + "
" : "") + 'None ticked: shown on every level.
' : "") + '"; R.innerHTML = h; } function renderProject(R) { var m = S.meta, cfg = S.doc.cfg || {}; var base = location.origin; R.innerHTML = "Agents edit this scene with the same operations as the Studio; their changes appear here live. ✦ Agent (top bar) chats with one right here.
' + "HTTP (while the Studio runs):" + esc(base) + "/mcp" +
"stdio (any time):isokit mcp " + esc(m.dir || ".") + "Select an object to edit it, or pick one on the left to add it.
'; } $("right").addEventListener("click", function (e) { var b = e.target.closest("button[data-act]"); if (!b || !S.sel) return; var a = b.getAttribute("data-act"), o = obj(S.sel); if (a === "del") remove(S.sel); else if (a === "dup") duplicate(S.sel); else if (a === "focus") S.view.focus(S.sel, 3); else if (a === "front" || a === "back") op({ op: "reorder", id: S.sel, to: a }); else if (a === "lock") update(S.sel, { locked: !o.locked }); else if (a === "hide") update(S.sel, { hidden: !o.hidden }); else if (a === "rotl") turn(S.sel, -90); else if (a === "rotr") turn(S.sel, 90); }); var rangeT = 0; function formChange(e, live) { var t = e.target, o = S.sel ? obj(S.sel) : null; if (t.hasAttribute("data-cfg")) { var k = t.getAttribute("data-cfg"), v = t.type === "checkbox" ? t.checked : t.type === "number" || t.type === "range" ? parseFloat(t.value) : t.value, vals = {}; vals[k] = v; op({ op: "set_cfg", values: vals }, true); return; } if (!o) return; if (t.hasAttribute("data-f")) { var f = t.getAttribute("data-f"), val = t.type === "number" ? parseFloat(t.value) : t.value, patch = {}; if (t.type === "number" && !isFinite(val)) return; patch[f] = val; update(o.id, patch, live); } else if (t.hasAttribute("data-prop")) { var key = t.getAttribute("data-prop"), d = (prefab(o.type) || { props: [] }).props.filter(function (x) { return x.key === key; })[0] || {}; var pv = t.type === "checkbox" ? t.checked : (d.type === "float" || d.type === "int") ? parseFloat(t.value) : t.value; if ((d.type === "float" || d.type === "int") && !isFinite(pv)) return; if (d.type === "int") pv = Math.round(pv); // keep the slider and the number box in step Array.prototype.forEach.call(document.querySelectorAll('#right [data-prop="' + key + '"]'), function (x) { if (x !== t && x.type !== "checkbox") x.value = pv; }); var pp = {}; pp[key] = pv; if (live) { clearTimeout(rangeT); rangeT = setTimeout(function () { update(o.id, { props: pp }, true); }, 160); } else update(o.id, { props: pp }, true); } else if (t.hasAttribute("data-level")) { var lv = Array.prototype.filter.call(document.querySelectorAll("#right [data-level]"), function (x) { return x.checked; }).map(function (x) { return x.getAttribute("data-level"); }); update(o.id, { levels: lv }); } } $("right").addEventListener("change", function (e) { formChange(e, false); }); $("right").addEventListener("input", function (e) { if (e.target.type === "range") formChange(e, true); }); $("right").addEventListener("keydown", function (e) { if (e.key === "Enter" && e.target.tagName === "INPUT") e.target.blur(); }); // ------------------------------------------------------------ header $("undo").addEventListener("click", undo); $("redo").addEventListener("click", redo); $("help").addEventListener("click", function () { $("helpdlg").showModal(); }); $("theme").addEventListener("click", function () { var t = document.documentElement.getAttribute("data-theme") === "light" ? "dark" : "light"; document.documentElement.setAttribute("data-theme", t); try { localStorage.setItem("isokit-theme", t); } catch (e) { /* private mode */ } }); $("build").addEventListener("click", function () { api("/api/build", {}).then(function (r) { if (r.error) toast(r.error, true); else toast(r.message || "Page written: " + r.path); }, function (e) { toast(String(e), true); }); }); $("warnbtn").addEventListener("click", function () { select(null); }); if (B && B.preview) $("preview").addEventListener("click", function (e) { e.preventDefault(); B.preview(); }); if (B && B.saveTitle) $("save").title = B.saveTitle; // engine version from /api/meta; a web build adds its commit and date // (, tools/build_web.py) function showVersion() { var el = $("ver"), m = document.querySelector('meta[name="isokit-build"]'); var b = m ? m.getAttribute("content").split(" ") : []; var text = "isokit " + (S.meta.engine || "?"), tip = "isokit engine " + (S.meta.engine || "?"); if (b[0]) { text += " · " + b[0].slice(0, 7) + (b[2] ? "+" : ""); tip += ", build " + b[0] + (b[1] ? " (" + b[1] + ")" : "") + (b[2] ? ", with uncommitted changes" : ""); el.href = "https://git.b4l.co.th/B4L/isokit/commit/" + b[0]; } el.textContent = text; el.title = tip; } // ------------------------------------------------------------ start Promise.all([api("/api/meta"), api("/api/doc")]).then(function (rs) { S.meta = rs[0]; takeState(rs[1]); document.title = S.meta.title + " · isokit Studio"; $("title").textContent = S.meta.title; if (S.meta.load_error) toast(S.meta.load_error, true); showVersion(); renderPalette(); renderLists(); mountView(); renderRight(); poll(); window.dispatchEvent(new Event("isokit:studio")); // agent.js waits for this }); })();