- 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)
607 lines
33 KiB
JavaScript
607 lines
33 KiB
JavaScript
/* isokit web Studio: the Studio GUI with the engine running in the page (WASM).
|
|
*
|
|
* Projects live in this browser (IndexedDB), or in a folder on disk the user
|
|
* opened (File System Access API, Chrome / Edge): edits are written straight
|
|
* to its files and changes made there by agents or editors show up live.
|
|
* This module answers the Studio's /api requests (window.IsoKitBackend), adds
|
|
* the project and code dialogs, and then starts the unchanged studio.js.
|
|
*/
|
|
import init, { Studio, new_project_files, engine_version } from "./isokit_wasm.js";
|
|
|
|
var Q = import.meta.url.split("?")[1] || "";
|
|
function asset(rel) { return new URL(rel + (Q ? "?" + Q : ""), import.meta.url); }
|
|
var $ = function (id) { return document.getElementById(id); };
|
|
function esc(s) { return String(s === undefined || s === null ? "" : s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); }
|
|
function slug(s) { return String(s || "scene").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "scene"; }
|
|
function newId() { return "p-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); }
|
|
|
|
var toastT = 0;
|
|
function toast(msg, bad) {
|
|
if (window.__studio && window.__studio.toast) { window.__studio.toast(msg, bad); return; }
|
|
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);
|
|
}
|
|
|
|
// ------------------------------------------------------------ project files
|
|
var SKIP_DIRS = { dist: 1, target: 1, node_modules: 1 };
|
|
function skipDir(name) { return name.charAt(0) === "." || SKIP_DIRS[name]; }
|
|
function projectFile(name) { return /\.(rhai|toml|json)$/.test(name); }
|
|
function keepFile(path) { return projectFile(path) || /(^|\/)README\.md$/.test(path); }
|
|
/** A file the project keeps (not an export under dist/, not in a dot folder). */
|
|
function inProject(path) { return keepFile(path) && !path.split("/").slice(0, -1).some(skipDir); }
|
|
|
|
/** Files of an import (path -> text), re-rooted at the folder that holds isokit.toml. */
|
|
function reroot(all) {
|
|
var roots = Object.keys(all).filter(function (p) { return /(^|\/)isokit\.toml$/.test(p); })
|
|
.sort(function (a, b) { return a.split("/").length - b.split("/").length; });
|
|
if (!roots.length) throw new Error("no isokit.toml found: an isokit project has one at its top");
|
|
var pre = roots[0].slice(0, roots[0].length - "isokit.toml".length), out = {};
|
|
Object.keys(all).forEach(function (p) {
|
|
if (p.indexOf(pre) !== 0) return;
|
|
var rel = p.slice(pre.length), parts = rel.split("/");
|
|
if (parts.slice(0, -1).some(skipDir) || !keepFile(rel)) return;
|
|
out[rel] = all[p];
|
|
});
|
|
return out;
|
|
}
|
|
|
|
// ------------------------------------------------------------ zip (store to write, store + deflate to read)
|
|
var CRC = (function () { var t = new Uint32Array(256); for (var n = 0; n < 256; n++) { var c = n; for (var k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; } return t; })();
|
|
function crc32(b) { var c = 0xFFFFFFFF; for (var i = 0; i < b.length; i++) c = CRC[(c ^ b[i]) & 255] ^ (c >>> 8); return (c ^ 0xFFFFFFFF) >>> 0; }
|
|
function zip(files, root) {
|
|
var enc = new TextEncoder(), parts = [], cd = [], off = 0, n = 0, d = new Date();
|
|
var time = (d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1);
|
|
var date = ((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate();
|
|
Object.keys(files).sort().forEach(function (p) {
|
|
var name = enc.encode(root + p), data = enc.encode(files[p]), crc = crc32(data);
|
|
var h = new DataView(new ArrayBuffer(30));
|
|
h.setUint32(0, 0x04034b50, true); h.setUint16(4, 20, true); h.setUint16(6, 0x0800, true);
|
|
h.setUint16(10, time, true); h.setUint16(12, date, true); h.setUint32(14, crc, true);
|
|
h.setUint32(18, data.length, true); h.setUint32(22, data.length, true); h.setUint16(26, name.length, true);
|
|
parts.push(h.buffer, name, data);
|
|
var c = new DataView(new ArrayBuffer(46));
|
|
c.setUint32(0, 0x02014b50, true); c.setUint16(4, 20, true); c.setUint16(6, 20, true); c.setUint16(8, 0x0800, true);
|
|
c.setUint16(12, time, true); c.setUint16(14, date, true); c.setUint32(16, crc, true);
|
|
c.setUint32(20, data.length, true); c.setUint32(24, data.length, true); c.setUint16(28, name.length, true);
|
|
c.setUint32(42, off, true);
|
|
cd.push(c.buffer, name);
|
|
off += 30 + name.length + data.length; n++;
|
|
});
|
|
var size = cd.reduce(function (s, x) { return s + x.byteLength; }, 0);
|
|
var e = new DataView(new ArrayBuffer(22));
|
|
e.setUint32(0, 0x06054b50, true); e.setUint16(8, n, true); e.setUint16(10, n, true); e.setUint32(12, size, true); e.setUint32(16, off, true);
|
|
return new Blob(parts.concat(cd, [e.buffer]), { type: "application/zip" });
|
|
}
|
|
async function unzip(buf) {
|
|
var v = new DataView(buf), u8 = new Uint8Array(buf), dec = new TextDecoder(), out = {};
|
|
var i = buf.byteLength - 22;
|
|
while (i >= 0 && v.getUint32(i, true) !== 0x06054b50) i--;
|
|
if (i < 0) throw new Error("not a zip file");
|
|
var n = v.getUint16(i + 10, true), p = v.getUint32(i + 16, true);
|
|
for (var k = 0; k < n; k++) {
|
|
if (v.getUint32(p, true) !== 0x02014b50) throw new Error("damaged zip file");
|
|
var method = v.getUint16(p + 10, true), csize = v.getUint32(p + 20, true), nlen = v.getUint16(p + 28, true);
|
|
var skip = nlen + v.getUint16(p + 30, true) + v.getUint16(p + 32, true), loc = v.getUint32(p + 42, true);
|
|
var name = dec.decode(u8.subarray(p + 46, p + 46 + nlen)).replace(/\\/g, "/");
|
|
p += 46 + skip;
|
|
if (/\/$/.test(name) || !keepFile(name) || csize > 8 << 20) continue;
|
|
var start = loc + 30 + v.getUint16(loc + 26, true) + v.getUint16(loc + 28, true), data = u8.subarray(start, start + csize);
|
|
if (method === 8) data = new Uint8Array(await new Response(new Blob([data]).stream().pipeThrough(new DecompressionStream("deflate-raw"))).arrayBuffer());
|
|
else if (method !== 0) continue;
|
|
out[name] = dec.decode(data);
|
|
}
|
|
return out;
|
|
}
|
|
function download(name, blob) {
|
|
var a = document.createElement("a");
|
|
a.href = URL.createObjectURL(blob); a.download = name;
|
|
document.body.appendChild(a); a.click(); a.remove();
|
|
setTimeout(function () { URL.revokeObjectURL(a.href); }, 30000);
|
|
}
|
|
|
|
// ------------------------------------------------------------ folders (File System Access) and drops
|
|
var canOpenFolder = typeof window.showDirectoryPicker === "function";
|
|
async function readFolder(dir, pre, out, stats) {
|
|
out = out || {}; stats = stats || {}; pre = pre || "";
|
|
for await (var entry of dir.values()) {
|
|
if (entry.kind === "directory") { if (!skipDir(entry.name)) await readFolder(entry, pre + entry.name + "/", out, stats); }
|
|
else if (keepFile(pre + entry.name)) {
|
|
var f = await entry.getFile();
|
|
stats[pre + entry.name] = f.size + ":" + f.lastModified;
|
|
if (!(pre + entry.name in out)) out[pre + entry.name] = await f.text();
|
|
}
|
|
}
|
|
return { files: out, stats: stats };
|
|
}
|
|
async function folderStats(dir, pre, stats, handles) {
|
|
for await (var entry of dir.values()) {
|
|
if (entry.kind === "directory") { if (!skipDir(entry.name)) await folderStats(entry, pre + entry.name + "/", stats, handles); }
|
|
else if (keepFile(pre + entry.name)) { var f = await entry.getFile(); stats[pre + entry.name] = f.size + ":" + f.lastModified; handles[pre + entry.name] = f; }
|
|
}
|
|
}
|
|
async function fileHandle(dir, path, create) {
|
|
var parts = path.split("/"), d = dir;
|
|
for (var i = 0; i < parts.length - 1; i++) d = await d.getDirectoryHandle(parts[i], { create: create });
|
|
return d.getFileHandle(parts[parts.length - 1], { create: create });
|
|
}
|
|
async function permitted(handle, ask) {
|
|
var o = { mode: "readwrite" };
|
|
if ((await handle.queryPermission(o)) === "granted") return true;
|
|
return ask ? (await handle.requestPermission(o)) === "granted" : false;
|
|
}
|
|
function readEntry(entry, path, out) {
|
|
if (entry.isFile) {
|
|
if (!keepFile(path)) return Promise.resolve();
|
|
return new Promise(function (res, rej) { entry.file(function (f) { f.text().then(function (t) { out[path] = t; res(); }, rej); }, rej); });
|
|
}
|
|
if (!entry.isDirectory || skipDir(entry.name)) return Promise.resolve();
|
|
var reader = entry.createReader(), jobs = [];
|
|
return new Promise(function (res, rej) {
|
|
(function more() {
|
|
reader.readEntries(function (list) {
|
|
if (!list.length) { Promise.all(jobs).then(res, rej); return; }
|
|
list.forEach(function (e) { jobs.push(readEntry(e, path + "/" + e.name, out)); });
|
|
more();
|
|
}, rej);
|
|
})();
|
|
});
|
|
}
|
|
|
|
// ------------------------------------------------------------ storage (IndexedDB)
|
|
var DB = null;
|
|
function db() {
|
|
if (!DB) DB = new Promise(function (res, rej) {
|
|
var r = indexedDB.open("isokit-studio", 1);
|
|
r.onupgradeneeded = function () { r.result.createObjectStore("projects", { keyPath: "id" }); };
|
|
r.onsuccess = function () { res(r.result); };
|
|
r.onerror = function () { rej(r.error); };
|
|
});
|
|
return DB;
|
|
}
|
|
function tx(mode, fn) {
|
|
return db().then(function (d) {
|
|
return new Promise(function (res, rej) {
|
|
var t = d.transaction("projects", mode), r = fn(t.objectStore("projects")), out;
|
|
if (r) r.onsuccess = function () { out = r.result; };
|
|
t.oncomplete = function () { res(out); };
|
|
t.onerror = t.onabort = function () { rej(t.error); };
|
|
});
|
|
});
|
|
}
|
|
var store = {
|
|
all: function () { return tx("readonly", function (s) { return s.getAll(); }); },
|
|
get: function (id) { return tx("readonly", function (s) { return s.get(id); }); },
|
|
put: function (rec) { return tx("readwrite", function (s) { return s.put(rec); }); },
|
|
del: function (id) { return tx("readwrite", function (s) { return s.delete(id); }); }
|
|
};
|
|
function titleOf(files) { var m = /^\s*title\s*=\s*"([^"]*)"/m.exec(files["isokit.toml"] || ""); return (m && m[1]) || "Untitled"; }
|
|
async function createProject(files, name) {
|
|
var rec = { id: newId(), name: name || titleOf(files), kind: "local", files: files, updated: Date.now() };
|
|
await store.put(rec);
|
|
return rec;
|
|
}
|
|
function openProject(id) { history.replaceState(null, "", "#p=" + encodeURIComponent(id)); location.reload(); }
|
|
|
|
// ------------------------------------------------------------ the open project
|
|
var P = { rec: null, files: {}, stats: {}, why: null, queue: Promise.resolve() };
|
|
/** Run storage jobs one after the other; a failed job does not block the next. */
|
|
function enqueue(job) {
|
|
var run = P.queue.then(job);
|
|
P.queue = run.catch(function (e) { console.error("isokit: storage", e); });
|
|
return run;
|
|
}
|
|
var studio = null, chan = typeof BroadcastChannel === "function" ? new BroadcastChannel("isokit-studio") : null;
|
|
function isFolder() { return P.rec && P.rec.kind === "folder"; }
|
|
|
|
/** Persist files written by the engine or the code editor (in order; null deletes). */
|
|
function persist(changes) {
|
|
var paths = Object.keys(changes);
|
|
if (!paths.length) return Promise.resolve();
|
|
return enqueue(async function () {
|
|
if (isFolder()) {
|
|
for (var p of paths) {
|
|
if (changes[p] === null) {
|
|
var parts = p.split("/"), d = P.rec.handle;
|
|
for (var i = 0; i < parts.length - 1; i++) d = await d.getDirectoryHandle(parts[i]);
|
|
await d.removeEntry(parts[parts.length - 1]).catch(function () { /* already gone */ });
|
|
delete P.files[p]; delete P.stats[p];
|
|
} else {
|
|
var h = await fileHandle(P.rec.handle, p, true), w = await h.createWritable();
|
|
await w.write(changes[p]); await w.close();
|
|
var f = await h.getFile();
|
|
if (inProject(p)) { P.files[p] = changes[p]; P.stats[p] = f.size + ":" + f.lastModified; }
|
|
}
|
|
}
|
|
} else {
|
|
var kept = {};
|
|
paths.forEach(function (p) {
|
|
if (!inProject(p)) return; // exports (dist/index.html) stay out of the browser store
|
|
if (changes[p] === null) delete P.files[p]; else P.files[p] = changes[p];
|
|
kept[p] = changes[p];
|
|
});
|
|
if (!Object.keys(kept).length) return;
|
|
P.rec.files = P.files; P.rec.updated = Date.now();
|
|
await store.put(P.rec);
|
|
if (chan) chan.postMessage({ id: P.rec.id, files: kept });
|
|
}
|
|
});
|
|
}
|
|
function flush() { return persist(JSON.parse(studio.take_written())); }
|
|
|
|
/** An edit from outside the Studio (code editor, another tab, the folder). */
|
|
function outside(changes, why) {
|
|
Object.keys(changes).forEach(function (p) {
|
|
if (changes[p] === null) delete P.files[p]; else P.files[p] = changes[p];
|
|
if (studio) studio.set_file(p, changes[p]);
|
|
});
|
|
P.why = why;
|
|
}
|
|
if (chan) chan.onmessage = function (e) {
|
|
if (P.rec && !isFolder() && e.data && e.data.id === P.rec.id) outside(e.data.files, "Changed in another tab");
|
|
};
|
|
|
|
// a folder project follows changes made on disk (agents, editors, git)
|
|
function watchFolder() {
|
|
setInterval(function () {
|
|
if (document.hidden) return;
|
|
enqueue(async function () {
|
|
var stats = {}, got = {}, changes = {}, n = 0;
|
|
try { await folderStats(P.rec.handle, "", stats, got); } catch (e) { return; }
|
|
for (var p in stats) {
|
|
if (stats[p] === P.stats[p]) continue;
|
|
var t = await got[p].text();
|
|
P.stats[p] = stats[p];
|
|
if (t !== P.files[p]) { changes[p] = t; n++; }
|
|
}
|
|
Object.keys(P.files).forEach(function (p) { if (!(p in stats) && projectFile(p)) { changes[p] = null; delete P.stats[p]; n++; } });
|
|
if (n) outside(changes, "Files changed in the folder (agent or editor)");
|
|
});
|
|
}, 1500);
|
|
}
|
|
|
|
// ------------------------------------------------------------ the backend studio.js talks to
|
|
var themeCss = null, lastVersion = null;
|
|
function J(s) { return JSON.parse(s); }
|
|
function backend() {
|
|
return {
|
|
web: true,
|
|
saveTitle: isFolder() ? "Every change is written to the folder at once" : "Every change is saved in this browser at once",
|
|
api: function (path, body) {
|
|
try { return route(path, body); } catch (e) { crashed(e); throw e; }
|
|
},
|
|
source: function (meta) {
|
|
return {
|
|
meta: meta, dev: true,
|
|
render: function (level, rot, cfg) {
|
|
try { return J(studio.render(level, rot, cfg ? JSON.stringify(cfg) : "", true)); } catch (e) { crashed(e); throw e; }
|
|
}
|
|
};
|
|
},
|
|
preview: function () {
|
|
var r = J(studio.page());
|
|
if (r.error) { toast(r.error.message || String(r.error), true); return; }
|
|
window.open(URL.createObjectURL(new Blob([r.html], { type: "text/html" })), "_blank", "noopener");
|
|
},
|
|
agentBox: function () {
|
|
var dir = isFolder() ? "<folder of " + P.rec.name + ">" : "<project folder>";
|
|
return '<div class="st-box"><h2>AI agents (MCP)</h2>' +
|
|
'<p class="st-help">The 15 isokit MCP tools run inside this page: browsers with WebMCP offer them to their assistant, and scripts call <b>isokit.mcp(message)</b>.</p>' +
|
|
(isFolder() ? "Desktop agents work on the same folder; their edits show up here live:" : "For desktop agents, open a folder (Projects → Open folder) or export the project, then:") +
|
|
"<code>isokit mcp " + esc(dir) + "</code></div>";
|
|
}
|
|
};
|
|
}
|
|
function route(path, body) {
|
|
var get = body === undefined;
|
|
if (get && path === "/api/meta") {
|
|
var m = J(studio.meta()), css = studio.theme_css();
|
|
if (css !== themeCss) { themeCss = css; $("iso-theme").textContent = css; }
|
|
if (!isFolder() && m.title && m.title !== P.rec.name) { P.rec.name = m.title; store.put(P.rec); }
|
|
$("wb-where").textContent = isFolder() ? "📁 " + P.rec.name : "in this browser";
|
|
return m;
|
|
}
|
|
if (get && path === "/api/doc") return J(studio.state());
|
|
if (get && path === "/api/version") {
|
|
var v = studio.version(), r = { version: v };
|
|
if (v !== lastVersion) { lastVersion = v; if (P.why) { r.why = P.why; P.why = null; } }
|
|
return r;
|
|
}
|
|
if (get && path === "/api/check") return J(studio.check());
|
|
var res;
|
|
if (path === "/api/op") res = J(studio.op(JSON.stringify(body)));
|
|
else if (path === "/api/undo") res = J(studio.undo());
|
|
else if (path === "/api/redo") res = J(studio.redo());
|
|
else if (path === "/api/build") return buildPage();
|
|
else return { error: "unknown request " + path };
|
|
lastVersion = studio.version();
|
|
return flush().then(function () { return res; });
|
|
}
|
|
function buildPage() {
|
|
if (isFolder()) { // like `isokit build`: the page goes where isokit.toml says (dist/index.html)
|
|
var out = J(studio.mcp(JSON.stringify({ jsonrpc: "2.0", id: 0, method: "tools/call", params: { name: "build_html", arguments: {} } })));
|
|
var res = out.result || {};
|
|
if (res.isError) return { error: res.content[0].text };
|
|
return flush().then(function () { return { message: "Page written to the folder: " + J(res.content[0].text).path.replace(/\\/g, "/") }; });
|
|
}
|
|
var r = J(studio.page());
|
|
if (r.error) return { error: r.error.message || String(r.error) };
|
|
var name = slug(P.rec.name) + ".html";
|
|
download(name, new Blob([r.html], { type: "text/html" }));
|
|
return { message: "Page downloaded: " + name };
|
|
}
|
|
|
|
// ------------------------------------------------------------ MCP inside the page (and WebMCP)
|
|
var rpcId = 0;
|
|
function mcpCall(msg) {
|
|
var out = studio.mcp(typeof msg === "string" ? msg : JSON.stringify(msg));
|
|
P.why = "Edited by an agent (MCP)";
|
|
return flush().then(function () { return out ? J(out) : null; });
|
|
}
|
|
function callTool(name, args) {
|
|
return mcpCall({ jsonrpc: "2.0", id: ++rpcId, method: "tools/call", params: { name: name, arguments: args || {} } }).then(function (r) { return r.result; });
|
|
}
|
|
function registerWebMcp() {
|
|
var mc = navigator.modelContext;
|
|
if (!mc) return 0;
|
|
var tools = J(studio.mcp(JSON.stringify({ jsonrpc: "2.0", id: 0, method: "tools/list" }))).result.tools.map(function (t) {
|
|
return { name: t.name, description: "isokit: " + t.description, inputSchema: t.inputSchema, execute: function (args) { return callTool(t.name, args); } };
|
|
});
|
|
try {
|
|
if (typeof mc.provideContext === "function") mc.provideContext({ tools: tools });
|
|
else if (typeof mc.registerTool === "function") tools.forEach(function (t) { mc.registerTool(t); });
|
|
else return 0;
|
|
} catch (e) { console.warn("isokit: WebMCP registration failed", e); return 0; }
|
|
return tools.length;
|
|
}
|
|
|
|
// ------------------------------------------------------------ UI: header, projects, code
|
|
function injectUI() {
|
|
var title = $("title");
|
|
title.insertAdjacentHTML("afterend",
|
|
'<span class="wb-where" id="wb-where"></span>' +
|
|
'<button type="button" id="wb-projects" title="Open, create, import and export projects">Projects ▾</button>' +
|
|
'<button type="button" id="wb-code" title="Edit the scripts, prefabs, theme and settings">{ } Code</button>');
|
|
document.body.insertAdjacentHTML("beforeend",
|
|
'<dialog id="wb-projdlg" class="st-dialog wb-projdlg">' +
|
|
'<h3>Projects</h3>' +
|
|
'<div class="wb-cur" id="wb-cur"></div>' +
|
|
'<ul class="wb-plist" id="wb-plist"></ul>' +
|
|
'<div class="wb-new"><input id="wb-ntitle" placeholder="Title of a new project" autocomplete="off"><button type="button" id="wb-ncreate">New project</button><button type="button" id="wb-ndemo" title="A fresh copy of the demo scene">Demo copy</button></div>' +
|
|
'<div class="wb-drop" id="wb-drop">Import: drop a project folder or .zip here, or <button type="button" id="wb-izip" title="Import a project .zip into this browser">.zip…</button><button type="button" id="wb-idir" title="Copy a project folder into this browser">folder…</button>' +
|
|
(canOpenFolder ? '<span class="st-grow"></span><button type="button" id="wb-ofolder" title="Edit the files in the folder on disk directly; changes made there show up live">Open folder live…</button>' : "") + "</div>" +
|
|
'<p class="st-help">Projects are kept in this browser. ' + (canOpenFolder ? "A folder opened live is edited on disk, so agents (<b>isokit mcp</b>), editors and git see the same files." : "To edit a folder on disk directly, use Chrome or Edge.") + "</p>" +
|
|
'<input type="file" id="wb-fzip" accept=".zip,application/zip" hidden><input type="file" id="wb-fdir" webkitdirectory multiple hidden>' +
|
|
'<form method="dialog"><button>Close</button></form></dialog>' +
|
|
'<dialog id="wb-codedlg" class="st-dialog wb-codedlg">' +
|
|
'<div class="wb-chead"><b>Code</b><span class="wb-cpath" id="wb-cpath"></span><span class="st-grow"></span>' +
|
|
'<button type="button" id="wb-cnew">New file</button><button type="button" id="wb-cdel">Delete file</button>' +
|
|
'<button type="button" id="wb-capply" title="Ctrl+S">Apply</button><button type="button" id="wb-cclose">Close</button></div>' +
|
|
'<div class="wb-cbody"><ul class="wb-cfiles" id="wb-cfiles"></ul><textarea id="wb-ctext" spellcheck="false" autocomplete="off" wrap="off"></textarea></div>' +
|
|
'<div class="wb-cmsg" id="wb-cmsg"></div></dialog>' +
|
|
'<div class="wb-gate" id="wb-gate" hidden></div>');
|
|
$("wb-projects").addEventListener("click", showProjects);
|
|
$("wb-code").addEventListener("click", function () { showCode(); });
|
|
wireProjects(); wireCode();
|
|
}
|
|
|
|
async function showProjects() {
|
|
var list = (await store.all()).sort(function (a, b) { return b.updated - a.updated; });
|
|
var cur = P.rec;
|
|
$("wb-cur").innerHTML = cur ? "<b>" + esc(cur.name) + "</b> <small>" + (cur.kind === "folder" ? "folder on disk" : "in this browser") + "</small>" +
|
|
'<span class="st-grow"></span><button type="button" data-act="export" title="All project files as a .zip (for isokit on the desktop, git, a colleague)">Export .zip</button>' : "No project open";
|
|
$("wb-plist").innerHTML = list.map(function (r) {
|
|
var when = new Date(r.updated).toLocaleString([], { dateStyle: "medium", timeStyle: "short" });
|
|
return '<li data-id="' + esc(r.id) + '"' + (cur && r.id === cur.id ? ' aria-current="true"' : "") + "><i>" + (r.kind === "folder" ? "📁" : "▣") + "</i><span>" + esc(r.name) + "</span><small>" + esc(when) + "</small>" +
|
|
'<button type="button" data-act="open"' + (cur && r.id === cur.id ? " disabled" : "") + '>Open</button><button type="button" data-act="del" title="' + (r.kind === "folder" ? "Forget the folder (its files stay)" : "Delete from this browser") + '">' + (r.kind === "folder" ? "Forget" : "Delete") + "</button></li>";
|
|
}).join("") || '<li class="wb-empty">No projects yet.</li>';
|
|
if (!$("wb-projdlg").open) $("wb-projdlg").showModal();
|
|
}
|
|
function wireProjects() {
|
|
$("wb-projdlg").addEventListener("click", async function (e) {
|
|
var b = e.target.closest("button[data-act]"); if (!b) return;
|
|
var a = b.getAttribute("data-act"), li = b.closest("li"), id = li && li.getAttribute("data-id");
|
|
if (a === "export") exportZip();
|
|
else if (a === "open") openProject(id);
|
|
else if (a === "del") {
|
|
var r = await store.get(id);
|
|
if (!r || !confirm((r.kind === "folder" ? "Forget the folder \u201c" : "Delete \u201c") + r.name + "\u201d" + (r.kind === "folder" ? "? Its files stay on disk." : " from this browser? Export it first to keep a copy."))) return;
|
|
await store.del(id);
|
|
if (P.rec && id === P.rec.id) { localStorage.removeItem("isokit-web-last"); location.hash = ""; location.reload(); return; }
|
|
showProjects();
|
|
}
|
|
});
|
|
$("wb-ncreate").addEventListener("click", async function () {
|
|
var t = $("wb-ntitle").value.trim() || "New site";
|
|
openProject((await createProject(J(new_project_files(t)), t)).id);
|
|
});
|
|
$("wb-ntitle").addEventListener("keydown", function (e) { if (e.key === "Enter") $("wb-ncreate").click(); });
|
|
$("wb-ndemo").addEventListener("click", async function () { openProject((await createProject(await demoFiles())).id); });
|
|
$("wb-izip").addEventListener("click", function () { $("wb-fzip").click(); });
|
|
$("wb-idir").addEventListener("click", function () { $("wb-fdir").click(); });
|
|
$("wb-fzip").addEventListener("change", function () { var f = this.files[0]; if (f) importZip(f); this.value = ""; });
|
|
$("wb-fdir").addEventListener("change", async function () {
|
|
var all = {}, list = Array.prototype.slice.call(this.files);
|
|
for (var f of list) { var p = f.webkitRelativePath || f.name; if (inProject(p)) all[p] = await f.text(); }
|
|
this.value = "";
|
|
importFiles(all);
|
|
});
|
|
if (canOpenFolder) $("wb-ofolder").addEventListener("click", openFolder);
|
|
var drop = $("wb-drop");
|
|
["dragenter", "dragover"].forEach(function (k) { drop.addEventListener(k, function (e) { e.preventDefault(); drop.classList.add("wb-over"); }); });
|
|
drop.addEventListener("dragleave", function () { drop.classList.remove("wb-over"); });
|
|
drop.addEventListener("drop", async function (e) {
|
|
e.preventDefault(); drop.classList.remove("wb-over");
|
|
var items = Array.prototype.slice.call(e.dataTransfer.items || []), files = Array.prototype.slice.call(e.dataTransfer.files || []);
|
|
var zipFile = files.filter(function (f) { return /\.zip$/i.test(f.name); })[0];
|
|
if (zipFile) { importZip(zipFile); return; }
|
|
var all = {}, jobs = [];
|
|
items.forEach(function (it) { var en = it.webkitGetAsEntry && it.webkitGetAsEntry(); if (en) jobs.push(readEntry(en, en.name, all)); });
|
|
try { await Promise.all(jobs); importFiles(all); } catch (err) { toast("Could not read the drop: " + err, true); }
|
|
});
|
|
}
|
|
async function importFiles(all) {
|
|
try {
|
|
var files = reroot(all);
|
|
new Studio("check", JSON.stringify(files)).free();
|
|
openProject((await createProject(files)).id);
|
|
} catch (e) { toast("Not imported: " + (e.message || e), true); }
|
|
}
|
|
async function importZip(f) {
|
|
try { importFiles(await unzip(await f.arrayBuffer())); } catch (e) { toast("Not imported: " + (e.message || e), true); }
|
|
}
|
|
async function openFolder() {
|
|
var dir;
|
|
try { dir = await window.showDirectoryPicker({ id: "isokit", mode: "readwrite" }); } catch (e) { return; }
|
|
var got = await readFolder(dir);
|
|
if (!got.files["isokit.toml"]) {
|
|
if (!confirm("\u201c" + dir.name + "\u201d has no isokit.toml. Start a new project in it?")) return;
|
|
var fresh = J(new_project_files(dir.name));
|
|
for (var p in fresh) { var h = await fileHandle(dir, p, true), w = await h.createWritable(); await w.write(fresh[p]); await w.close(); }
|
|
}
|
|
var rec = { id: newId(), name: dir.name, kind: "folder", handle: dir, updated: Date.now() };
|
|
await store.put(rec);
|
|
openProject(rec.id);
|
|
}
|
|
function exportZip() {
|
|
var name = slug(P.rec.name);
|
|
download(name + ".zip", zip(P.files, name + "/"));
|
|
toast("Exported " + name + ".zip");
|
|
}
|
|
async function demoFiles() { return (await fetch(asset("demo.json"))).json(); }
|
|
|
|
// the code editor: the project's files, applied as one outside edit
|
|
var C = { path: null, dirty: false };
|
|
function codeFiles() { return Object.keys(P.files).sort(function (a, b) { return (a.indexOf("/") >= 0) - (b.indexOf("/") >= 0) || a.localeCompare(b); }); }
|
|
function showCode(path) {
|
|
var files = codeFiles();
|
|
path = path || C.path || (files.indexOf("scene.rhai") >= 0 ? "scene.rhai" : "isokit.toml");
|
|
if (!(path in P.files)) path = files[0];
|
|
C.path = path; C.dirty = false;
|
|
$("wb-cfiles").innerHTML = files.map(function (p) { return '<li data-path="' + esc(p) + '" aria-selected="' + (p === path) + '">' + esc(p) + "</li>"; }).join("");
|
|
$("wb-cpath").textContent = path || "";
|
|
$("wb-ctext").value = path ? P.files[path] : "";
|
|
$("wb-capply").disabled = true;
|
|
codeMsg();
|
|
if (!$("wb-codedlg").open) $("wb-codedlg").showModal();
|
|
$("wb-ctext").focus();
|
|
}
|
|
/** Errors of the loaded project (red), else `note`. */
|
|
function codeMsg(note) {
|
|
var m = $("wb-cmsg"), errs = [];
|
|
if (studio) {
|
|
var meta = J(studio.meta());
|
|
if (meta.load_error) errs.push(meta.load_error);
|
|
var e = meta.script_error;
|
|
if (e) errs.push((e.file || "script") + (e.line ? ":" + e.line : "") + (e.col ? ":" + e.col : "") + ": " + e.message);
|
|
}
|
|
m.textContent = errs.length ? errs.join("\n") : note || "Scripts run when you apply (Ctrl+S). Changes can be undone in the Studio.";
|
|
m.classList.toggle("wb-bad", errs.length > 0);
|
|
}
|
|
function leaveCode() { return !C.dirty || confirm("Discard the changes to " + C.path + "?"); }
|
|
async function applyCode() {
|
|
if (!C.path) return;
|
|
var ch = {}; ch[C.path] = $("wb-ctext").value;
|
|
outside(ch, "Code applied");
|
|
await persist(ch);
|
|
C.dirty = false; $("wb-capply").disabled = true;
|
|
if (!studio) { tryStart(); return; }
|
|
codeMsg("Applied: " + C.path);
|
|
}
|
|
function wireCode() {
|
|
var ta = $("wb-ctext");
|
|
ta.addEventListener("input", function () { C.dirty = true; $("wb-capply").disabled = false; });
|
|
ta.addEventListener("keydown", function (e) {
|
|
if ((e.ctrlKey || e.metaKey) && (e.key === "s" || e.key === "S" || e.key === "Enter")) { e.preventDefault(); applyCode(); return; }
|
|
if (e.key === "Tab" && !e.shiftKey) { e.preventDefault(); ta.setRangeText(" ", ta.selectionStart, ta.selectionEnd, "end"); C.dirty = true; $("wb-capply").disabled = false; }
|
|
e.stopPropagation(); // the Studio's keys (Delete, arrows, R) must not fire while typing
|
|
});
|
|
$("wb-cfiles").addEventListener("click", function (e) { var li = e.target.closest("li"); if (li && leaveCode()) showCode(li.getAttribute("data-path")); });
|
|
$("wb-capply").addEventListener("click", applyCode);
|
|
$("wb-cclose").addEventListener("click", function () { if (leaveCode()) $("wb-codedlg").close(); });
|
|
$("wb-codedlg").addEventListener("cancel", function (e) { if (!leaveCode()) e.preventDefault(); });
|
|
$("wb-cnew").addEventListener("click", async function () {
|
|
if (!leaveCode()) return;
|
|
var p = (prompt("New file (relative path), e.g. lib/kiosk.rhai", "lib/new.rhai") || "").trim().replace(/\\/g, "/");
|
|
if (!p) return;
|
|
if (!projectFile(p) || /(^|\/)\.\.(\/|$)/.test(p) || p.charAt(0) === "/") { toast("Use a relative path ending in .rhai, .toml or .json", true); return; }
|
|
if (p in P.files) { showCode(p); return; }
|
|
var body = /\.rhai$/.test(p) ? "// " + p + ": list it in isokit.toml under prelude to load its prefabs\n" : "";
|
|
var ch = {}; ch[p] = body; outside(ch, "File added: " + p); await persist(ch);
|
|
showCode(p);
|
|
});
|
|
$("wb-cdel").addEventListener("click", async function () {
|
|
if (!C.path || C.path === "isokit.toml") { toast("isokit.toml is the project itself and stays", true); return; }
|
|
if (!confirm("Delete " + C.path + "?")) return;
|
|
var ch = {}; ch[C.path] = null; outside(ch, "File deleted: " + C.path); await persist(ch);
|
|
C.path = null; showCode();
|
|
});
|
|
}
|
|
|
|
// ------------------------------------------------------------ start
|
|
function gate(html) { var g = $("wb-gate"); g.innerHTML = '<div class="wb-gatebox">' + html + "</div>"; g.hidden = false; return g; }
|
|
function tryStart() {
|
|
try { studio = new Studio(isFolder() ? P.rec.name : "browser: " + P.rec.name, JSON.stringify(P.files)); }
|
|
catch (e) {
|
|
var g = gate("<h3>" + esc(P.rec.name) + " does not load</h3><pre>" + esc(String(e)) + "</pre>" +
|
|
'<p class="st-help">Fix the file in the code editor, or open another project.</p>' +
|
|
'<button type="button" id="wb-gcode">Edit the files</button> <button type="button" id="wb-gproj">Projects</button>');
|
|
g.querySelector("#wb-gcode").onclick = function () { showCode("isokit.toml"); };
|
|
g.querySelector("#wb-gproj").onclick = showProjects;
|
|
return;
|
|
}
|
|
$("wb-gate").hidden = true;
|
|
window.IsoKitBackend = backend();
|
|
window.isokit = {
|
|
engine: engine_version(), project: P.rec.name, studio: studio,
|
|
files: function () { return Object.assign({}, P.files); },
|
|
mcp: mcpCall, call: callTool, webmcp: registerWebMcp()
|
|
};
|
|
var s = document.createElement("script");
|
|
s.src = asset("../studio/studio.js").href;
|
|
document.body.appendChild(s);
|
|
}
|
|
async function boot() {
|
|
injectUI();
|
|
await init({ module_or_path: asset("isokit_wasm_bg.wasm") });
|
|
var m = /[#&]p=([^&]+)/.exec(location.hash), id = m ? decodeURIComponent(m[1]) : localStorage.getItem("isokit-web-last");
|
|
var rec = id ? await store.get(id) : null;
|
|
if (!rec) {
|
|
var all = await store.all();
|
|
rec = all.sort(function (a, b) { return b.updated - a.updated; })[0] || await createProject(await demoFiles());
|
|
}
|
|
P.rec = rec;
|
|
history.replaceState(null, "", "#p=" + encodeURIComponent(rec.id));
|
|
localStorage.setItem("isokit-web-last", rec.id);
|
|
window.addEventListener("hashchange", function () { location.reload(); });
|
|
if (isFolder()) {
|
|
if (!(await permitted(rec.handle, false))) {
|
|
var g = gate("<h3>Open the folder \u201c" + esc(rec.name) + "\u201d</h3><p>The browser asks once per visit before the Studio may edit files on disk.</p>" +
|
|
'<button type="button" id="wb-gallow">Allow access</button> <button type="button" id="wb-gproj">Other project</button>');
|
|
g.querySelector("#wb-gproj").onclick = showProjects;
|
|
await new Promise(function (res) {
|
|
g.querySelector("#wb-gallow").onclick = async function () { if (await permitted(rec.handle, true)) res(); };
|
|
});
|
|
}
|
|
var got = await readFolder(rec.handle);
|
|
P.files = got.files; P.stats = got.stats;
|
|
watchFolder();
|
|
} else {
|
|
P.files = rec.files || {};
|
|
}
|
|
tryStart();
|
|
}
|
|
// an engine panic leaves the WASM instance unusable: say so instead of failing silently
|
|
function crashed(e) {
|
|
if (!(e instanceof WebAssembly.RuntimeError) && !/unsafe aliasing|unreachable/.test(String(e && e.message))) return;
|
|
var g = gate("<h3>The engine stopped</h3><pre>" + esc(e.message || e) + "</pre><p class=\"st-help\">Your last saved state is kept. Reload to continue; the browser console has the details.</p>" +
|
|
'<button type="button" id="wb-greload">Reload</button>');
|
|
g.querySelector("#wb-greload").onclick = function () { location.reload(); };
|
|
}
|
|
window.addEventListener("error", function (e) { crashed(e.error); });
|
|
window.addEventListener("unhandledrejection", function (e) { crashed(e.reason); });
|
|
boot().catch(function (e) {
|
|
console.error(e);
|
|
gate("<h3>The web Studio could not start</h3><pre>" + esc(e && e.stack || e) + "</pre><p class=\"st-help\">It needs a current browser (WebAssembly, ES modules, IndexedDB).</p>");
|
|
});
|