Build Core and Handles into in-memory CDN modules, add the marketing site and tutorial docs, and provide a SQLite-backed WebGPU playground with TypeScript tooling and profiling. Amp-Thread-ID: https://ampcode.com/threads/T-01a02485-5574-707c-bff4-5668d83bee8a Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
626 lines
22 KiB
JavaScript
626 lines
22 KiB
JavaScript
const defaultSource = `import {
|
||
ArcRotateCamera,
|
||
ColorGrading,
|
||
FXAA,
|
||
Mesh,
|
||
PBRMaterial,
|
||
PointLight,
|
||
Scene,
|
||
} from "@yawn/handles";
|
||
|
||
const scene = new Scene(canvas, { hdr: true, fps: 60 });
|
||
await scene.ready;
|
||
|
||
const material = new PBRMaterial(scene, {
|
||
baseColor: [0.12, 0.58, 1, 1],
|
||
metallic: 0.15,
|
||
roughness: 0.35,
|
||
});
|
||
await material.ready;
|
||
|
||
const mesh = new Mesh(scene, {
|
||
material,
|
||
vertexData: {
|
||
positions: [-0.72, -0.62, 0, 0.72, -0.62, 0, 0, 0.76, 0],
|
||
normals: [0, 0, 1, 0, 0, 1, 0, 0, 1],
|
||
indices: [0, 1, 2],
|
||
},
|
||
});
|
||
await mesh.ready;
|
||
|
||
log("Scene ready — drag your pointer across the preview.");
|
||
const move = (event: PointerEvent) => {
|
||
const bounds = canvas.getBoundingClientRect();
|
||
mesh.position.x = ((event.clientX - bounds.left) / bounds.width - 0.5) * 1.2;
|
||
mesh.position.y = (0.5 - (event.clientY - bounds.top) / bounds.height) * 0.8;
|
||
};
|
||
canvas.addEventListener("pointermove", move);
|
||
|
||
export default {
|
||
scene,
|
||
mesh,
|
||
dispose() {
|
||
canvas.removeEventListener("pointermove", move);
|
||
scene.dispose();
|
||
},
|
||
};`;
|
||
|
||
const snippets = {
|
||
camera: `// Orbit camera — drag to orbit and use the wheel to zoom.
|
||
const camera = new ArcRotateCamera(scene, {
|
||
target: mesh,
|
||
alpha: 0,
|
||
beta: Math.PI / 2,
|
||
radius: 3,
|
||
controls: { element: canvas, pointer: true },
|
||
});
|
||
await camera.ready;`,
|
||
light: `// A warm point light backed by shared rows.
|
||
const light = new PointLight(scene, {
|
||
position: [0, 0.7, 1],
|
||
color: [1, 0.68, 0.4],
|
||
intensity: 12,
|
||
range: 8,
|
||
});
|
||
await light.ready;`,
|
||
instances: `// Clones share geometry until one clone changes its vertex data.
|
||
for (let x = -2; x <= 2; x++) {
|
||
const instance = mesh.clone({ position: [x * 0.35, 0, 0] });
|
||
await instance.ready;
|
||
}`,
|
||
rows: `// Application-owned hot data in the same shared arena.
|
||
const velocity = await scene.ensureRows("app.velocity", 1024, 16, "f32");
|
||
velocity.row(0).set([1, 0, 0, 0]);
|
||
log("velocity[0]", velocity.read(0));`,
|
||
post: `// Batch graph changes so the loadout is rebuilt once.
|
||
await scene.batchGraphUpdates(async () => {
|
||
const grade = new ColorGrading(scene, { toneMap: "aces", amount: 1 });
|
||
const fxaa = new FXAA(scene);
|
||
await Promise.all([grade.ready, fxaa.ready]);
|
||
});`,
|
||
};
|
||
|
||
const elements = Object.fromEntries(
|
||
[
|
||
"canvas",
|
||
"clear-console",
|
||
"console-count",
|
||
"console-empty",
|
||
"console-lines",
|
||
"cursor-status",
|
||
"dirty-indicator",
|
||
"editor",
|
||
"editor-diagnostics",
|
||
"format-button",
|
||
"fps-status",
|
||
"fullscreen-button",
|
||
"inspector",
|
||
"inspector-button",
|
||
"preview",
|
||
"preview-message",
|
||
"profile-adapter",
|
||
"profile-empty",
|
||
"profile-latency",
|
||
"profile-passes",
|
||
"profile-results",
|
||
"profile-total",
|
||
"project-title",
|
||
"reset-button",
|
||
"resize-handle",
|
||
"resolution-status",
|
||
"revision-label",
|
||
"run-button",
|
||
"run-status",
|
||
"save-button",
|
||
"share-button",
|
||
"snippets-button",
|
||
"snippets-dialog",
|
||
"tab-dirty",
|
||
"toast",
|
||
"type-status",
|
||
"workbench",
|
||
].map((id) => [id, document.getElementById(id)]),
|
||
);
|
||
|
||
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
||
const packages = Promise.all([import("/pkg/handles.js"), import("/pkg/core.js")]).then(
|
||
([handles, core]) => ({ ...handles, ...core }),
|
||
);
|
||
let monaco;
|
||
let editor;
|
||
let model;
|
||
let current;
|
||
let currentID = "";
|
||
let currentRevision = 0;
|
||
let initialSource = defaultSource;
|
||
let generation = 0;
|
||
let dirty = false;
|
||
let activePanel = "console";
|
||
let stopProfile;
|
||
let profileCore;
|
||
let toastTimer;
|
||
let frameRequest;
|
||
let sampledFrame = 0;
|
||
let sampledAt = 0;
|
||
|
||
function savedLocation() {
|
||
const match = location.pathname.match(/^\/playground\/([a-z0-9]{6,24})\/(\d+)\/?$/);
|
||
return match ? { id: match[1], revision: Number(match[2]) } : undefined;
|
||
}
|
||
|
||
async function loadInitialPlayground() {
|
||
const saved = savedLocation();
|
||
if (saved) {
|
||
try {
|
||
const response = await fetch(`/api/playgrounds/${saved.id}/${saved.revision}`);
|
||
if (!response.ok) throw new Error("This saved playground could not be found.");
|
||
const result = await response.json();
|
||
currentID = result.id;
|
||
currentRevision = result.revision;
|
||
elements["project-title"].value = result.title;
|
||
elements["revision-label"].textContent = `Revision ${result.revision}`;
|
||
elements["share-button"].hidden = false;
|
||
initialSource = result.code;
|
||
document.title = `${result.title} — Yawn Playground`;
|
||
return result.code;
|
||
} catch (error) {
|
||
history.replaceState(null, "", "/playground");
|
||
setRunStatus(error.message, "failed");
|
||
showToast(error.message);
|
||
}
|
||
}
|
||
return localStorage.getItem("yawn:playground:draft") || defaultSource;
|
||
}
|
||
|
||
function loadMonaco() {
|
||
return new Promise((resolve, reject) => {
|
||
if (!window.require?.config) {
|
||
reject(new Error("The TypeScript editor could not be loaded."));
|
||
return;
|
||
}
|
||
const monacoBase = `${window.location.origin}/assets/monaco/`;
|
||
const workerSource = `
|
||
self.MonacoEnvironment = { baseUrl: ${JSON.stringify(monacoBase)} };
|
||
importScripts(${JSON.stringify(`${monacoBase}vs/base/worker/workerMain.js`)});
|
||
`;
|
||
const workerURL = URL.createObjectURL(
|
||
new Blob([workerSource], { type: "text/javascript" }),
|
||
);
|
||
window.MonacoEnvironment = { getWorkerUrl: () => workerURL };
|
||
window.require.config({ paths: { vs: `${monacoBase}vs` } });
|
||
window.require(["vs/editor/editor.main"], () => resolve(window.monaco), reject);
|
||
});
|
||
}
|
||
|
||
async function createEditor(source) {
|
||
monaco = await loadMonaco();
|
||
const types = await fetch("/assets/yawn.d.ts").then((response) => response.text());
|
||
const defaults = monaco.languages.typescript.typescriptDefaults;
|
||
defaults.setEagerModelSync(true);
|
||
defaults.setCompilerOptions({
|
||
allowNonTsExtensions: true,
|
||
module: monaco.languages.typescript.ModuleKind.ESNext,
|
||
moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs,
|
||
noEmit: false,
|
||
strict: true,
|
||
target: monaco.languages.typescript.ScriptTarget.ESNext,
|
||
});
|
||
defaults.setDiagnosticsOptions({ noSemanticValidation: false, noSyntaxValidation: false });
|
||
defaults.addExtraLib(types, "file:///types/yawn.d.ts");
|
||
monaco.editor.defineTheme("yawn", {
|
||
base: "vs-dark",
|
||
inherit: true,
|
||
rules: [
|
||
{ token: "comment", foreground: "658198", fontStyle: "italic" },
|
||
{ token: "keyword", foreground: "C792EA" },
|
||
{ token: "string", foreground: "8FD5B6" },
|
||
{ token: "number", foreground: "F6AD7B" },
|
||
{ token: "type.identifier", foreground: "69C7F4" },
|
||
],
|
||
colors: {
|
||
"editor.background": "#0B1927",
|
||
"editor.foreground": "#C8D8E5",
|
||
"editor.lineHighlightBackground": "#102235",
|
||
"editor.selectionBackground": "#1D5B8066",
|
||
"editorCursor.foreground": "#55BDF4",
|
||
"editorGutter.background": "#0B1927",
|
||
"editorLineNumber.foreground": "#405B70",
|
||
"editorLineNumber.activeForeground": "#89A4B8",
|
||
"editorIndentGuide.background1": "#1B3143",
|
||
"editorWidget.background": "#102538",
|
||
"editorSuggestWidget.background": "#102538",
|
||
},
|
||
});
|
||
elements.editor.textContent = "";
|
||
model = monaco.editor.createModel(source, "typescript", monaco.Uri.parse("file:///playground/scene.ts"));
|
||
editor = monaco.editor.create(elements.editor, {
|
||
model,
|
||
theme: "yawn",
|
||
automaticLayout: true,
|
||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
||
fontLigatures: true,
|
||
fontSize: 12,
|
||
lineHeight: 20,
|
||
minimap: { enabled: false },
|
||
padding: { top: 12, bottom: 12 },
|
||
renderLineHighlight: "all",
|
||
scrollBeyondLastLine: false,
|
||
smoothScrolling: true,
|
||
tabSize: 2,
|
||
wordWrap: "off",
|
||
});
|
||
editor.addAction({
|
||
id: "yawn.run",
|
||
label: "Run playground",
|
||
keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter],
|
||
run,
|
||
});
|
||
editor.onDidChangeCursorPosition(({ position }) => {
|
||
elements["cursor-status"].textContent = `Ln ${position.lineNumber}, Col ${position.column}`;
|
||
});
|
||
model.onDidChangeContent(() => {
|
||
markDirty(true);
|
||
if (!currentID) localStorage.setItem("yawn:playground:draft", model.getValue());
|
||
});
|
||
monaco.editor.onDidChangeMarkers(([resource]) => {
|
||
if (resource.toString() === model.uri.toString()) updateDiagnostics();
|
||
});
|
||
elements["type-status"].textContent = "TypeScript language service ready";
|
||
updateDiagnostics();
|
||
}
|
||
|
||
function updateDiagnostics() {
|
||
if (!monaco || !model) return;
|
||
const markers = monaco.editor.getModelMarkers({ resource: model.uri });
|
||
const errors = markers.filter((marker) => marker.severity === monaco.MarkerSeverity.Error).length;
|
||
const warnings = markers.filter((marker) => marker.severity === monaco.MarkerSeverity.Warning).length;
|
||
const total = errors + warnings;
|
||
elements["editor-diagnostics"].classList.toggle("has-errors", errors > 0);
|
||
elements["editor-diagnostics"].lastChild.textContent = total
|
||
? ` ${errors} error${errors === 1 ? "" : "s"}, ${warnings} warning${warnings === 1 ? "" : "s"}`
|
||
: " No problems";
|
||
elements["type-status"].textContent = errors
|
||
? `${errors} TypeScript error${errors === 1 ? "" : "s"}`
|
||
: "TypeScript language service ready";
|
||
}
|
||
|
||
function markDirty(value) {
|
||
dirty = value;
|
||
elements["dirty-indicator"].classList.toggle("visible", value);
|
||
elements["tab-dirty"].classList.toggle("visible", value);
|
||
}
|
||
|
||
async function transpile() {
|
||
const worker = await monaco.languages.typescript.getTypeScriptWorker();
|
||
const client = await worker(model.uri);
|
||
const emitted = await client.getEmitOutput(model.uri.toString());
|
||
const output = emitted.outputFiles.find((file) => file.name.endsWith(".js"));
|
||
if (emitted.emitSkipped || !output) throw new Error("TypeScript could not emit this scene.");
|
||
return output.text
|
||
.replace(/^\s*import\s+(?:[\s\S]*?\s+from\s+)?["'][^"']+["'];?\s*$/gm, "")
|
||
.replace(/^\s*export\s*\{\s*\};?\s*$/gm, "")
|
||
.replace(/\bexport\s+default\s+/, "return ");
|
||
}
|
||
|
||
async function disposeCurrent() {
|
||
stopProfile?.();
|
||
stopProfile = undefined;
|
||
if (profileCore) {
|
||
try {
|
||
await profileCore.setProfiler(false);
|
||
} catch {
|
||
// A disposed core cannot disable a profiler that is already gone.
|
||
}
|
||
profileCore = undefined;
|
||
}
|
||
const value = current;
|
||
current = undefined;
|
||
delete window.__yawnPlayground;
|
||
if (!value) return;
|
||
if (typeof value === "function") await value();
|
||
else if (typeof value.dispose === "function") await value.dispose();
|
||
else if (value.scene?.dispose) value.scene.dispose();
|
||
}
|
||
|
||
function replaceCanvas() {
|
||
const previous = elements.canvas;
|
||
const canvas = document.createElement("canvas");
|
||
canvas.id = "canvas";
|
||
canvas.setAttribute("aria-label", "Yawn WebGPU preview");
|
||
const scale = Math.min(devicePixelRatio || 1, 2);
|
||
canvas.width = Math.max(1, Math.round(elements.preview.clientWidth * scale));
|
||
canvas.height = Math.max(1, Math.round(elements.preview.clientHeight * scale));
|
||
previous.replaceWith(canvas);
|
||
elements.canvas = canvas;
|
||
elements["resolution-status"].textContent = `${canvas.width} × ${canvas.height}`;
|
||
return canvas;
|
||
}
|
||
|
||
async function run() {
|
||
if (!editor || elements["run-button"].disabled) return;
|
||
const runID = ++generation;
|
||
elements["run-button"].disabled = true;
|
||
elements["run-button"].lastChild.textContent = " Running";
|
||
clearConsole();
|
||
setRunStatus("Compiling", "running");
|
||
showPreviewMessage("Compiling TypeScript", "Checking types and preparing the render graph.");
|
||
try {
|
||
if (!crossOriginIsolated) throw new Error("Cross-origin isolation is disabled on this page.");
|
||
if (!navigator.gpu) throw new Error("WebGPU is not available in this browser.");
|
||
const [code, api] = await Promise.all([transpile(), packages]);
|
||
await disposeCurrent();
|
||
const canvas = replaceCanvas();
|
||
const names = Object.keys(api);
|
||
const started = performance.now();
|
||
const result = await new AsyncFunction(...names, "canvas", "log", code)(
|
||
...names.map((name) => api[name]),
|
||
canvas,
|
||
(...values) => appendConsole(values.map(formatValue).join(" ")),
|
||
);
|
||
if (runID !== generation) {
|
||
if (result?.dispose) await result.dispose();
|
||
return;
|
||
}
|
||
current = result;
|
||
window.__yawnPlayground = result;
|
||
elements["preview-message"].hidden = true;
|
||
setRunStatus(`Running · ${Math.round(performance.now() - started)} ms`, "ready");
|
||
if (activePanel === "profile") await attachProfiler();
|
||
} catch (error) {
|
||
if (runID === generation) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
appendConsole(message, true);
|
||
setRunStatus("Run failed", "failed");
|
||
showPreviewMessage("Scene could not start", message);
|
||
openInspector("console");
|
||
}
|
||
} finally {
|
||
if (runID === generation) {
|
||
elements["run-button"].disabled = false;
|
||
elements["run-button"].innerHTML = '<span aria-hidden="true">▶</span> Run <kbd>Ctrl ↵</kbd>';
|
||
}
|
||
}
|
||
}
|
||
|
||
function currentCore() {
|
||
return current?.scene?.core ?? current?.core;
|
||
}
|
||
|
||
async function attachProfiler() {
|
||
stopProfile?.();
|
||
stopProfile = undefined;
|
||
profileCore = currentCore();
|
||
if (!profileCore?.onProfile || !profileCore?.setProfiler) {
|
||
setProfileMessage("Run a scene to begin profiling.");
|
||
return;
|
||
}
|
||
stopProfile = profileCore.onProfile(renderProfile);
|
||
const supported = await profileCore.setProfiler(true);
|
||
if (!supported) setProfileMessage("Timestamp queries are not supported by this GPU adapter.");
|
||
}
|
||
|
||
function setProfileMessage(message) {
|
||
elements["profile-empty"].querySelector("strong").textContent = message;
|
||
elements["profile-empty"].hidden = false;
|
||
elements["profile-results"].hidden = true;
|
||
}
|
||
|
||
function renderProfile(profile) {
|
||
elements["profile-empty"].hidden = true;
|
||
elements["profile-results"].hidden = false;
|
||
elements["profile-total"].textContent = `${profile.milliseconds.toFixed(2)} ms`;
|
||
elements["profile-latency"].textContent = `${profile.readbackMilliseconds.toFixed(2)} ms`;
|
||
elements["profile-adapter"].textContent = profile.adapter || "WebGPU adapter";
|
||
elements["profile-adapter"].title = profile.adapter || "WebGPU adapter";
|
||
elements["profile-passes"].replaceChildren(
|
||
...profile.passes.map((pass) => {
|
||
const row = document.createElement("tr");
|
||
const name = document.createElement("td");
|
||
const time = document.createElement("td");
|
||
name.textContent = pass.name;
|
||
time.textContent = `${pass.milliseconds.toFixed(2)} ms`;
|
||
row.append(name, time);
|
||
return row;
|
||
}),
|
||
);
|
||
}
|
||
|
||
function formatValue(value) {
|
||
if (typeof value === "string") return value;
|
||
if (value instanceof Error) return value.message;
|
||
try {
|
||
return JSON.stringify(value);
|
||
} catch {
|
||
return String(value);
|
||
}
|
||
}
|
||
|
||
function appendConsole(message, error = false) {
|
||
elements["console-empty"].hidden = true;
|
||
const line = document.createElement("li");
|
||
line.textContent = message;
|
||
line.classList.toggle("error", error);
|
||
elements["console-lines"].append(line);
|
||
elements["console-count"].textContent = elements["console-lines"].children.length;
|
||
line.scrollIntoView({ block: "nearest" });
|
||
}
|
||
|
||
function clearConsole() {
|
||
elements["console-lines"].replaceChildren();
|
||
elements["console-empty"].hidden = false;
|
||
elements["console-count"].textContent = "0";
|
||
}
|
||
|
||
function setRunStatus(message, state) {
|
||
elements["run-status"].className = state === "running" ? "running" : state === "failed" ? "failed" : "";
|
||
elements["run-status"].lastChild.textContent = ` ${message}`;
|
||
}
|
||
|
||
function showPreviewMessage(title, detail) {
|
||
elements["preview-message"].hidden = false;
|
||
elements["preview-message"].querySelector("strong").textContent = title;
|
||
elements["preview-message"].querySelector("small").textContent = detail;
|
||
}
|
||
|
||
function openInspector(panel) {
|
||
activePanel = panel;
|
||
elements.inspector.classList.remove("collapsed");
|
||
elements["inspector-button"].setAttribute("aria-pressed", "true");
|
||
for (const button of document.querySelectorAll(".inspector-tabs [data-panel]")) {
|
||
const active = button.dataset.panel === panel;
|
||
button.classList.toggle("active", active);
|
||
button.setAttribute("aria-selected", String(active));
|
||
}
|
||
for (const target of document.querySelectorAll(".inspector-panel")) {
|
||
target.classList.toggle("active", target.id === `${panel}-panel`);
|
||
}
|
||
if (panel === "profile") void attachProfiler();
|
||
else if (profileCore) {
|
||
stopProfile?.();
|
||
stopProfile = undefined;
|
||
void profileCore.setProfiler(false).catch(() => undefined);
|
||
profileCore = undefined;
|
||
}
|
||
}
|
||
|
||
async function save() {
|
||
if (!editor || elements["save-button"].disabled) return;
|
||
elements["save-button"].disabled = true;
|
||
try {
|
||
const response = await fetch("/api/playgrounds", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
id: currentID,
|
||
title: elements["project-title"].value.trim() || "Untitled playground",
|
||
code: model.getValue(),
|
||
}),
|
||
});
|
||
const result = await response.json();
|
||
if (!response.ok) throw new Error(result.error || "The playground could not be saved.");
|
||
currentID = result.id;
|
||
currentRevision = result.revision;
|
||
initialSource = result.code;
|
||
history.pushState(null, "", `/playground/${result.id}/${result.revision}`);
|
||
elements["revision-label"].textContent = `Revision ${result.revision}`;
|
||
elements["share-button"].hidden = false;
|
||
localStorage.removeItem("yawn:playground:draft");
|
||
markDirty(false);
|
||
showToast(`Saved revision ${result.revision}`);
|
||
} catch (error) {
|
||
showToast(error instanceof Error ? error.message : String(error));
|
||
} finally {
|
||
elements["save-button"].disabled = false;
|
||
}
|
||
}
|
||
|
||
function showToast(message) {
|
||
clearTimeout(toastTimer);
|
||
elements.toast.textContent = message;
|
||
elements.toast.classList.add("visible");
|
||
toastTimer = setTimeout(() => elements.toast.classList.remove("visible"), 2400);
|
||
}
|
||
|
||
function insertSnippet(name) {
|
||
const selection = editor.getSelection();
|
||
const prefix = model.getValueInRange(selection).endsWith("\n") ? "" : "\n\n";
|
||
editor.executeEdits("snippet", [{ range: selection, text: `${prefix}${snippets[name]}\n`, forceMoveMarkers: true }]);
|
||
elements["snippets-dialog"].close();
|
||
editor.focus();
|
||
}
|
||
|
||
function sampleFPS(now = performance.now()) {
|
||
try {
|
||
const core = currentCore();
|
||
if (!core) throw new Error();
|
||
const frame = Number(core.array("signals").row(0)[1]);
|
||
if (!sampledAt || frame < sampledFrame) {
|
||
sampledAt = now;
|
||
sampledFrame = frame;
|
||
} else if (now - sampledAt >= 500) {
|
||
const fps = ((frame - sampledFrame) * 1000) / (now - sampledAt);
|
||
elements["fps-status"].textContent = `${fps < 10 ? fps.toFixed(1) : Math.round(fps)} FPS`;
|
||
sampledAt = now;
|
||
sampledFrame = frame;
|
||
}
|
||
} catch {
|
||
sampledAt = now;
|
||
sampledFrame = 0;
|
||
elements["fps-status"].textContent = "0 FPS";
|
||
}
|
||
frameRequest = requestAnimationFrame(sampleFPS);
|
||
}
|
||
|
||
elements["run-button"].addEventListener("click", run);
|
||
elements["save-button"].addEventListener("click", save);
|
||
elements["share-button"].addEventListener("click", async () => {
|
||
await navigator.clipboard.writeText(location.href);
|
||
showToast("Playground URL copied");
|
||
});
|
||
elements["project-title"].addEventListener("input", () => markDirty(true));
|
||
elements["reset-button"].addEventListener("click", () => {
|
||
model?.setValue(initialSource);
|
||
void run();
|
||
});
|
||
elements["format-button"].addEventListener("click", () => editor?.getAction("editor.action.formatDocument").run());
|
||
elements["snippets-button"].addEventListener("click", () => elements["snippets-dialog"].showModal());
|
||
elements["snippets-dialog"].querySelector("[data-close-dialog]").addEventListener("click", () => elements["snippets-dialog"].close());
|
||
elements["snippets-dialog"].addEventListener("click", (event) => {
|
||
if (event.target === elements["snippets-dialog"]) elements["snippets-dialog"].close();
|
||
});
|
||
for (const button of document.querySelectorAll("[data-snippet]")) {
|
||
button.addEventListener("click", () => insertSnippet(button.dataset.snippet));
|
||
}
|
||
elements["snippets-dialog"].querySelector("input").addEventListener("input", (event) => {
|
||
const query = event.target.value.trim().toLowerCase();
|
||
for (const button of document.querySelectorAll("[data-snippet]")) {
|
||
button.hidden = Boolean(query) && !button.textContent.toLowerCase().includes(query);
|
||
}
|
||
});
|
||
for (const button of document.querySelectorAll(".inspector-tabs [data-panel]")) {
|
||
button.addEventListener("click", () => openInspector(button.dataset.panel));
|
||
}
|
||
elements["clear-console"].addEventListener("click", clearConsole);
|
||
elements["inspector-button"].addEventListener("click", () => {
|
||
const collapsed = elements.inspector.classList.toggle("collapsed");
|
||
elements["inspector-button"].setAttribute("aria-pressed", String(!collapsed));
|
||
});
|
||
elements["fullscreen-button"].addEventListener("click", () => {
|
||
if (document.fullscreenElement) void document.exitFullscreen();
|
||
else void document.documentElement.requestFullscreen();
|
||
});
|
||
|
||
elements["resize-handle"].addEventListener("pointerdown", (event) => {
|
||
if (innerWidth <= 800) return;
|
||
elements["resize-handle"].setPointerCapture(event.pointerId);
|
||
elements["resize-handle"].classList.add("dragging");
|
||
});
|
||
elements["resize-handle"].addEventListener("pointermove", (event) => {
|
||
if (!elements["resize-handle"].hasPointerCapture(event.pointerId)) return;
|
||
const bounds = elements.workbench.getBoundingClientRect();
|
||
const percentage = Math.min(70, Math.max(30, ((event.clientX - bounds.left) / bounds.width) * 100));
|
||
elements.workbench.style.setProperty("--editor-width", `${percentage}%`);
|
||
});
|
||
elements["resize-handle"].addEventListener("pointerup", (event) => {
|
||
elements["resize-handle"].releasePointerCapture(event.pointerId);
|
||
elements["resize-handle"].classList.remove("dragging");
|
||
});
|
||
addEventListener("popstate", () => location.reload());
|
||
addEventListener("beforeunload", () => {
|
||
cancelAnimationFrame(frameRequest);
|
||
void disposeCurrent();
|
||
});
|
||
|
||
try {
|
||
const source = await loadInitialPlayground();
|
||
await createEditor(source);
|
||
markDirty(false);
|
||
sampleFPS();
|
||
await run();
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
elements.editor.textContent = message;
|
||
setRunStatus("Editor failed", "failed");
|
||
showPreviewMessage("Editor could not start", message);
|
||
}
|