Files
isokit/studio/studio.js
T

361 lines
23 KiB
JavaScript

/* isokit Studio: the scene editor (talks to `isokit studio` over /api). */
(function () {
"use strict";
var $ = function (id) { return document.getElementById(id); };
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); }
function 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 = "Offline"; $("save").classList.add("st-err"); toast("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("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 = 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 };
}
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) 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 '<div><div class="st-cat">' + esc(c) + '</div><div class="st-tiles">' + cats[c].map(function (m) {
return '<button type="button" class="st-tile" data-type="' + esc(m.type) + '" title="' + esc(m.help || m.label) + '" aria-pressed="' + (S.placing === m.type) + '"><i>' + esc(m.icon || ICON_DEFAULT) + "</i><span>" + esc(m.label) + "</span></button>";
}).join("") + "</div></div>";
}).join("") || '<div class="st-help">No object matches.</div>';
}
function renderLists() {
var q = $("osearch").value.trim().toLowerCase(), list = S.doc.objects || [];
$("ocount").textContent = list.length;
$("objects").innerHTML = list.filter(function (o) {
return !q || (o.id + " " + label(o) + " " + o.type).toLowerCase().indexOf(q) >= 0;
}).slice().reverse().map(function (o) {
return '<li data-id="' + esc(o.id) + '" aria-selected="' + (o.id === S.sel) + '"' + (o.hidden ? ' class="st-hidden"' : "") + ' title="' + esc(o.id) + '"><i>' + esc(icon(o.type)) + "</i><span>" + esc(label(o)) + "</span><small>" + (o.locked ? "🔒" : "") + "</small></li>";
}).join("");
}
$("palette").addEventListener("click", function (e) {
var b = e.target.closest(".st-tile"); if (!b) return;
var k = b.getAttribute("data-type");
setPlacing(S.placing === k ? null : k);
S.view.svg.focus();
});
$("psearch").addEventListener("input", renderPalette);
$("osearch").addEventListener("input", renderLists);
$("objects").addEventListener("click", function (e) { var li = e.target.closest("li"); if (li) select(li.getAttribute("data-id")); });
$("objects").addEventListener("dblclick", function (e) { var li = e.target.closest("li"); if (li) S.view.focus(li.getAttribute("data-id"), 3); });
// ------------------------------------------------------------ right panel
function field(key, lab, html, full) { return '<label for="f-' + key + '">' + esc(lab) + "</label>" + (full ? '<div class="st-full">' + html + "</div>" : html); }
function propInput(d, v) {
var id = "p-" + d.key, val = v === undefined ? d.default : v;
if (d.type === "bool") return '<input type="checkbox" id="' + id + '" data-prop="' + esc(d.key) + '"' + (val ? " checked" : "") + ">";
if (d.type === "choice" || (d.type === "material" && d.choices && d.choices.length)) {
var ch = (d.choices || []).slice(); if (val !== undefined && ch.indexOf(val) < 0) ch.unshift(val);
return '<select id="' + id + '" data-prop="' + esc(d.key) + '">' + ch.map(function (c) { return "<option" + (c === val ? " selected" : "") + ">" + esc(c) + "</option>"; }).join("") + "</select>";
}
if (d.type === "material") return '<input id="' + id + '" data-prop="' + esc(d.key) + '" list="materials" value="' + esc(val) + '">';
if (d.type === "float" || d.type === "int") {
var step = d.step || (d.type === "int" ? 1 : 0.1), mm = (d.min !== undefined ? ' min="' + d.min + '"' : "") + (d.max !== undefined ? ' max="' + d.max + '"' : "");
var range = d.min !== undefined && d.max !== undefined ? '<input type="range" data-prop="' + esc(d.key) + '" data-range="1"' + mm + ' step="' + step + '" value="' + esc(val) + '">' : "<span></span>";
return '<div class="st-num">' + range + '<input type="number" id="' + id + '" data-prop="' + esc(d.key) + '"' + mm + ' step="' + step + '" value="' + esc(val) + '"></div>';
}
return '<input id="' + id + '" data-prop="' + esc(d.key) + '" value="' + esc(val) + '">';
}
function renderRight() {
var R = $("right"), o = S.sel ? obj(S.sel) : null;
if (!o) { renderProject(R); return; }
var m = prefab(o.type) || { label: o.type, props: [] }, p = o.props || {};
var levels = S.meta.levels || [];
var h = "<h3><i>" + esc(m.icon || ICON_DEFAULT) + "</i>" + esc(label(o)) + "</h3>" +
'<div class="st-sub">' + esc(m.label) + " · id " + esc(o.id) + "</div>" +
(m.help ? '<p class="st-help">' + esc(m.help) + "</p>" : "") +
'<div class="st-actions">' +
'<button type="button" data-act="dup" title="Ctrl+D">Duplicate</button>' +
'<button type="button" data-act="focus" title="Zoom to it">Zoom to</button>' +
'<button type="button" data-act="front" title="Draw after the others (ground items)">To front</button>' +
'<button type="button" data-act="back" title="Draw before the others (ground items)">To back</button>' +
'<button type="button" data-act="lock">' + (o.locked ? "Unlock" : "Lock") + "</button>" +
'<button type="button" data-act="hide">' + (o.hidden ? "Show" : "Hide") + "</button>" +
'<button type="button" data-act="del" class="st-danger" title="Delete">Delete</button></div>' +
S.warnings.filter(function (w) { return w.indexOf("object " + o.id + ":") === 0; }).map(function (w) { return '<div class="st-warn">' + esc(w) + "</div>"; }).join("") +
'<h2>Placement</h2><div class="st-form">' +
field("xy", "Position x y z", '<div class="st-xy"><input type="number" step="any" data-f="x" value="' + o.x + '" title="x (east), m"><input type="number" step="any" data-f="y" value="' + o.y + '" title="y (north), m"><input type="number" step="any" data-f="z" value="' + (o.z || 0) + '" title="z (height), m"></div>') +
field("rot", "Rotation", '<div class="st-rot"><button type="button" data-act="rotl" title="Shift+R">⟲</button><b>' + (o.rot || 0) + '°</b><button type="button" data-act="rotr" title="R">⟳</button></div>') +
"</div>" +
(m.props.length ? "<h2>Properties</h2><div class=\"st-form\">" + m.props.map(function (d) { return field(d.key, d.label || d.key, propInput(d, p[d.key])); }).join("") + "</div>" : "") +
'<h2>Label and tooltip</h2><div class="st-form">' +
field("name", "Name", '<input data-f="name" value="' + esc(o.name || "") + '" placeholder="' + esc(m.label) + '">') +
field("tag", "Tag", '<input data-f="tag" value="' + esc(o.tag || "") + '" placeholder="label on the map">') +
field("info", "Tooltip", '<textarea data-f="info" rows="2" placeholder="shown on hover / tap">' + esc(o.info || "") + "</textarea>") +
"</div>" +
(levels.length > 1 ? '<h2>Shown on levels</h2><div class="st-levels">' + levels.map(function (l) {
return '<label><input type="checkbox" data-level="' + esc(l.id) + '"' + ((o.levels || []).indexOf(l.id) >= 0 ? " checked" : "") + "> " + esc(l.label || l.id) + "</label>";
}).join("") + '</div><p class="st-help">None ticked: shown on every level.</p>' : "") +
'<datalist id="materials">' + (S.meta.materials || []).map(function (x) { return "<option>" + esc(x) + "</option>"; }).join("") + "</datalist>";
R.innerHTML = h;
}
function renderProject(R) {
var m = S.meta, cfg = S.doc.cfg || {};
var base = location.origin;
R.innerHTML = "<h3>" + esc(m.title) + "</h3>" + '<div class="st-sub">' + (S.doc.objects || []).length + " objects · " + (m.catalog || []).length + " object types</div>" +
(m.params && m.params.length ? "<h2>Scene options</h2><div class=\"st-form\">" + m.params.map(function (p) {
var v = cfg[p.name] !== undefined ? cfg[p.name] : p.default;
return field(p.name, p.label || p.name, propInput({ key: p.name, type: p.type, default: p.default, choices: p.choices, min: p.min, max: p.max, step: p.step }, v).replace(/data-prop=/g, "data-cfg="));
}).join("") + "</div>" : "") +
(S.warnings.length ? "<h2>Warnings</h2>" + S.warnings.map(function (w) { return '<div class="st-warn">' + esc(w) + "</div>"; }).join("") : "") +
'<div class="st-box"><h2>Connect an AI agent (MCP)</h2>' +
'<p class="st-help">Agents edit this scene with the same operations as the Studio; their changes appear here live.</p>' +
"HTTP (while the Studio runs):<code>" + esc(base) + "/mcp</code>" +
"stdio (any time):<code>isokit mcp " + esc(m.dir || ".") + "</code></div>" +
'<p class="st-help">Select an object to edit it, or pick one on the left to add it.</p>';
}
$("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("Page written: " + r.path); });
});
$("warnbtn").addEventListener("click", function () { select(null); });
// ------------------------------------------------------------ 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);
renderPalette(); renderLists(); mountView(); renderRight();
poll();
});
})();