Add Go CDN and playground server
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>
This commit is contained in:
@@ -0,0 +1,625 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user