Files
isokit/studio/studio.js
T
grabowski 5eb60d2a0c isokit: web Studio (engine as WASM, projects in the browser or a live folder)
- isokit-wasm: wasm-bindgen build of the engine; a project in memory behind
  the same JSON API as `isokit studio`, plus the MCP tools
- isokit-project: Store trait (FsStore folder, MemStore memory) under a
  generic Workspace; Project::from_files; shared Studio API module (api.rs)
  used by serve.rs and the WASM build; web_time for clocks in the browser
- studio.js runs on a pluggable backend (window.IsoKitBackend); no Studio
  keys while a dialog is open
- web/: projects in IndexedDB, new/demo/delete, zip export/import (stored and
  deflated), folder import by drop or picker, folders opened live via File
  System Access with outside edits followed, code editor, page build and
  preview, two tabs in step, isokit.mcp() and WebMCP registration
- tools/build_web.py -> dist/web (static), tools/check_web.py (44 checks)
2026-10-22 17:00:42 +07:00

369 lines
23 KiB
JavaScript

/* 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); }
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 };
}
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 '<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("") : "") +
(B && B.agentBox ? B.agentBox(m) :
'<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(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;
// ------------------------------------------------------------ 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();
});
})();