Add persistent render graph loadouts, grouped node authoring, code-appending apply behavior, explicit playground lighting, and resilient worker startup. Amp-Thread-ID: https://ampcode.com/threads/T-01a033d4-0e44-708e-af0e-6166283416ff Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
1304 lines
42 KiB
JavaScript
1304 lines
42 KiB
JavaScript
import {
|
||
compileFxNodeRenderGraph,
|
||
createRenderGraphEditor,
|
||
renderGraphNodeGroups,
|
||
renderGraphPresets,
|
||
} from "@yawn/fxnode-rg";
|
||
|
||
const renderGraphComment = "/* Render graph loadout:";
|
||
|
||
const defaultSource = `import {
|
||
AmbientLight,
|
||
ArcRotateCamera,
|
||
ColorGrading,
|
||
FXAA,
|
||
Icosphere,
|
||
PBRMaterial,
|
||
Plane,
|
||
PointLight,
|
||
Scene,
|
||
SpotLight,
|
||
} from "@yawn/handles";
|
||
|
||
const scene = new Scene(canvas, {
|
||
hdr: true,
|
||
fps: 60,
|
||
clearColor: [0.018, 0.035, 0.055, 1],
|
||
});
|
||
await scene.ready;
|
||
await scene.reserve({ nodes: 6, materials: 2 });
|
||
|
||
const objectMaterial = new PBRMaterial(scene, {
|
||
baseColor: [0.82, 0.86, 0.9, 1],
|
||
metallic: 0.12,
|
||
roughness: 0.32,
|
||
});
|
||
const groundMaterial = new PBRMaterial(scene, {
|
||
baseColor: [0.24, 0.28, 0.34, 1],
|
||
metallic: 0,
|
||
roughness: 0.82,
|
||
});
|
||
await Promise.all([objectMaterial.ready, groundMaterial.ready]);
|
||
|
||
const icosphere = new Icosphere(scene, {
|
||
material: objectMaterial,
|
||
subdivisions: 2,
|
||
position: [0, 0.2, 0],
|
||
});
|
||
const ground = new Plane(scene, {
|
||
material: groundMaterial,
|
||
size: 2.6,
|
||
position: [0, -0.55, 0],
|
||
});
|
||
await Promise.all([icosphere.ready, ground.ready]);
|
||
icosphere.rotateY(Math.PI / 5);
|
||
|
||
const camera = new ArcRotateCamera(scene, {
|
||
target: icosphere,
|
||
alpha: Math.PI / 4,
|
||
beta: Math.PI / 3,
|
||
radius: 4,
|
||
aspect: canvas.width / canvas.height,
|
||
controls: { element: canvas, pointer: true },
|
||
});
|
||
await camera.ready;
|
||
|
||
const lights = [
|
||
new AmbientLight(scene, { color: [0.02, 0.45, 0.48], intensity: 0.5 }),
|
||
new SpotLight(scene, {
|
||
position: [-2.8, 3, -2.8],
|
||
rotor: [-0.628, -0.628, 0, 0.4597],
|
||
color: [1, 0.22, 0.03],
|
||
intensity: 220,
|
||
range: 7,
|
||
innerAngle: 0.32,
|
||
outerAngle: 0.72,
|
||
}),
|
||
new PointLight(scene, {
|
||
position: [3.4, 1.8, 3.6],
|
||
color: [0.9, 0.04, 0.38],
|
||
intensity: 45,
|
||
range: 14,
|
||
radius: 1.5,
|
||
}),
|
||
];
|
||
await Promise.all(lights.map((light) => light.ready));
|
||
|
||
log("Icosphere ready — teal ambience, orange rim, and soft magenta fill are active.");
|
||
|
||
export default {
|
||
scene,
|
||
icosphere,
|
||
ground,
|
||
lights,
|
||
dispose() {
|
||
camera.detachControls();
|
||
scene.dispose();
|
||
},
|
||
};`;
|
||
|
||
const snippets = {
|
||
camera: `// Orbit camera — drag to orbit and use the wheel to zoom.
|
||
const orbitCamera = new ArcRotateCamera(scene, {
|
||
target: icosphere,
|
||
alpha: 0,
|
||
beta: Math.PI / 2,
|
||
radius: 3,
|
||
controls: { element: canvas, pointer: true },
|
||
});
|
||
await orbitCamera.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 = icosphere.clone({ position: [x * 0.7, 0.2, 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",
|
||
"apply-loadout-button",
|
||
"clear-console",
|
||
"console-count",
|
||
"console-empty",
|
||
"console-lines",
|
||
"copy-loadout-code-button",
|
||
"cursor-status",
|
||
"dirty-indicator",
|
||
"editor",
|
||
"editor-diagnostics",
|
||
"editor-mode-status",
|
||
"encoding-status",
|
||
"format-button",
|
||
"fps-status",
|
||
"fullscreen-button",
|
||
"graph-tab-dirty",
|
||
"inspector",
|
||
"inspector-button",
|
||
"loadout-name",
|
||
"loadout-select",
|
||
"loadout-status",
|
||
"preview",
|
||
"preview-message",
|
||
"profile-adapter",
|
||
"profile-empty",
|
||
"profile-latency",
|
||
"profile-passes",
|
||
"profile-results",
|
||
"profile-total",
|
||
"project-title",
|
||
"render-graph-canvas",
|
||
"render-graph-editor",
|
||
"render-graph-hint",
|
||
"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",
|
||
"workspace-language",
|
||
"workbench",
|
||
].map((id) => [id, document.getElementById(id)]),
|
||
);
|
||
|
||
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
||
const nativeSetTimeout = window.setTimeout.bind(window);
|
||
const nativeSetInterval = window.setInterval.bind(window);
|
||
const nativeClearTimeout = window.clearTimeout.bind(window);
|
||
const nativeClearInterval = window.clearInterval.bind(window);
|
||
const playgroundTimerIDs = new Set();
|
||
|
||
function trackedSetTimeout(callback, delay, ...arguments_) {
|
||
let id;
|
||
const wrapped =
|
||
typeof callback === "function"
|
||
? (...callbackArguments) => {
|
||
playgroundTimerIDs.delete(id);
|
||
callback.apply(window, callbackArguments);
|
||
}
|
||
: callback;
|
||
id = nativeSetTimeout(wrapped, delay, ...arguments_);
|
||
playgroundTimerIDs.add(id);
|
||
return id;
|
||
}
|
||
|
||
function trackedSetInterval(callback, delay, ...arguments_) {
|
||
const id = nativeSetInterval(callback, delay, ...arguments_);
|
||
playgroundTimerIDs.add(id);
|
||
return id;
|
||
}
|
||
|
||
function trackedClearTimer(id) {
|
||
playgroundTimerIDs.delete(id);
|
||
nativeClearTimeout(id);
|
||
nativeClearInterval(id);
|
||
}
|
||
|
||
function clearPlaygroundTimers() {
|
||
for (const id of playgroundTimerIDs) {
|
||
nativeClearTimeout(id);
|
||
nativeClearInterval(id);
|
||
}
|
||
playgroundTimerIDs.clear();
|
||
}
|
||
|
||
window.__yawnPlaygroundTimers = {
|
||
ids: playgroundTimerIDs,
|
||
setTimeout: trackedSetTimeout,
|
||
setInterval: trackedSetInterval,
|
||
clearTimeout: trackedClearTimer,
|
||
clearInterval: trackedClearTimer,
|
||
clear: clearPlaygroundTimers,
|
||
};
|
||
|
||
const packages = Promise.all([
|
||
import("@yawn/handles"),
|
||
import("@yawn/core"),
|
||
]).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 lastRunSource = "";
|
||
let dirty = false;
|
||
let activePanel = "console";
|
||
let activeCodingTab = "scene";
|
||
let stopProfile;
|
||
let profileCore;
|
||
let toastTimer;
|
||
let frameRequest;
|
||
let sampledFrame = 0;
|
||
let sampledAt = 0;
|
||
let renderGraphEditor;
|
||
let renderGraphMenu;
|
||
let renderGraphMenuOutsideListener;
|
||
let graphDirty = false;
|
||
let loadoutsDirty = false;
|
||
let suppressGraphChanges = false;
|
||
let customLoadouts = [];
|
||
let currentLoadoutID = "beauty";
|
||
let compiledRenderGraph = "";
|
||
|
||
localStorage.removeItem("yawn:playground:loadouts");
|
||
localStorage.removeItem("yawn:playground:active-loadout");
|
||
localStorage.removeItem("yawn:playground:draft");
|
||
|
||
addEventListener("yawn-theme-change", (event) => {
|
||
monaco?.editor.setTheme(event.detail.resolved === "dark" ? "yawn" : "vs");
|
||
});
|
||
|
||
function savedLocation() {
|
||
const match = location.pathname.match(
|
||
/^\/playground\/([a-z0-9]{6,24})\/(\d+)\/?$/,
|
||
);
|
||
return match ? { id: match[1], revision: Number(match[2]) } : undefined;
|
||
}
|
||
|
||
function validCustomLoadouts(value) {
|
||
if (!Array.isArray(value)) return [];
|
||
return value
|
||
.filter(
|
||
(loadout) =>
|
||
loadout &&
|
||
typeof loadout.id === "string" &&
|
||
/^custom-[a-z0-9-]+$/u.test(loadout.id) &&
|
||
typeof loadout.name === "string" &&
|
||
loadout.name.trim() &&
|
||
loadout.layout &&
|
||
Array.isArray(loadout.layout.nodes) &&
|
||
Array.isArray(loadout.layout.links),
|
||
)
|
||
.slice(0, 40)
|
||
.map((loadout) => ({
|
||
id: loadout.id,
|
||
name: loadout.name.trim().slice(0, 64),
|
||
builtIn: false,
|
||
layout: loadout.layout,
|
||
}));
|
||
}
|
||
|
||
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;
|
||
customLoadouts = validCustomLoadouts(result.loadouts);
|
||
document.title = `${result.title} — Yawn Playground`;
|
||
return result.code;
|
||
} catch (error) {
|
||
history.replaceState(null, "", "/playground");
|
||
setRunStatus(error.message, "failed");
|
||
showToast(error.message);
|
||
}
|
||
}
|
||
customLoadouts = [];
|
||
return 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: document.documentElement.dataset.theme === "dark" ? "yawn" : "vs",
|
||
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 }) => {
|
||
if (activeCodingTab !== "scene") return;
|
||
elements["cursor-status"].textContent =
|
||
`Ln ${position.lineNumber}, Col ${position.column}`;
|
||
});
|
||
model.onDidChangeContent(() => {
|
||
markDirty(true);
|
||
});
|
||
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;
|
||
if (activeCodingTab !== "scene") 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, target = "code") {
|
||
dirty = value;
|
||
elements["dirty-indicator"].classList.toggle("visible", value);
|
||
if (!value) {
|
||
elements["tab-dirty"].classList.remove("visible");
|
||
elements["graph-tab-dirty"].classList.remove("visible");
|
||
graphDirty = false;
|
||
loadoutsDirty = false;
|
||
} else if (target === "graph") {
|
||
elements["graph-tab-dirty"].classList.add("visible");
|
||
} else {
|
||
elements["tab-dirty"].classList.add("visible");
|
||
}
|
||
}
|
||
|
||
function allLoadouts() {
|
||
return [...renderGraphPresets, ...customLoadouts];
|
||
}
|
||
|
||
function activeLoadout() {
|
||
return allLoadouts().find((loadout) => loadout.id === currentLoadoutID);
|
||
}
|
||
|
||
function loadoutLayout(loadout = activeLoadout()) {
|
||
if (!loadout) throw new Error("The selected render graph loadout is missing");
|
||
return loadout.layout;
|
||
}
|
||
|
||
function renderLoadoutOptions() {
|
||
const builtIn = document.createElement("optgroup");
|
||
const saved = document.createElement("optgroup");
|
||
builtIn.label = "Prepared loadouts";
|
||
saved.label = "Playground loadouts";
|
||
for (const loadout of renderGraphPresets) {
|
||
const option = document.createElement("option");
|
||
option.value = loadout.id;
|
||
option.textContent = loadout.name;
|
||
builtIn.append(option);
|
||
}
|
||
for (const loadout of customLoadouts) {
|
||
const option = document.createElement("option");
|
||
option.value = loadout.id;
|
||
option.textContent = loadout.name;
|
||
saved.append(option);
|
||
}
|
||
elements["loadout-select"].replaceChildren(
|
||
builtIn,
|
||
...(customLoadouts.length ? [saved] : []),
|
||
);
|
||
elements["loadout-select"].value = currentLoadoutID;
|
||
const currentLoadout = activeLoadout();
|
||
elements["loadout-name"].value = currentLoadout?.name ?? "";
|
||
}
|
||
|
||
function setLoadoutStatus(message, state = "ready") {
|
||
elements["loadout-status"].className =
|
||
state === "busy" ? "busy" : state === "failed" ? "failed" : "";
|
||
elements["loadout-status"].title = message;
|
||
elements["loadout-status"].lastChild.textContent = ` ${message}`;
|
||
}
|
||
|
||
function markGraphChanged() {
|
||
if (suppressGraphChanges) return;
|
||
compiledRenderGraph = "";
|
||
graphDirty = true;
|
||
loadoutsDirty = true;
|
||
markDirty(true, "graph");
|
||
setLoadoutStatus("Unsaved graph changes");
|
||
}
|
||
|
||
function stageActiveLoadout(layout) {
|
||
const currentLoadout = activeLoadout();
|
||
if (!currentLoadout)
|
||
throw new Error("The selected render graph loadout is missing");
|
||
const name =
|
||
elements["loadout-name"].value.trim() ||
|
||
`${currentLoadout.name || "Render graph"} custom`;
|
||
let id = currentLoadout.builtIn ? "" : currentLoadout.id;
|
||
if (!id) id = `custom-${crypto.randomUUID().slice(0, 8)}`;
|
||
const changed =
|
||
currentLoadout.builtIn || graphDirty || currentLoadout.name !== name;
|
||
const staged = changed
|
||
? {
|
||
id,
|
||
name: name.slice(0, 64),
|
||
builtIn: false,
|
||
layout,
|
||
}
|
||
: currentLoadout;
|
||
if (changed) {
|
||
const index = customLoadouts.findIndex((loadout) => loadout.id === id);
|
||
if (index < 0) customLoadouts.push(staged);
|
||
else customLoadouts[index] = staged;
|
||
loadoutsDirty = true;
|
||
}
|
||
currentLoadoutID = id;
|
||
graphDirty = false;
|
||
renderLoadoutOptions();
|
||
if (changed) markDirty(true, "graph");
|
||
else elements["graph-tab-dirty"].classList.toggle("visible", loadoutsDirty);
|
||
return staged;
|
||
}
|
||
|
||
function generatedRenderGraphBlock(name, serialized) {
|
||
const safeName = name.replace(/[\r\n]+/gu, " ").replaceAll("*/", "* /");
|
||
return `${renderGraphComment} ${safeName} */ { const renderGraph = ${JSON.stringify(serialized)}; const loadoutId = await scene.compileRenderGraph(renderGraph); await scene.setLoadout(loadoutId); }`;
|
||
}
|
||
|
||
function withAppendedRenderGraph(source, name, serialized) {
|
||
const block = generatedRenderGraphBlock(name, serialized);
|
||
return `${source.trimEnd()}\n\n${block}\n`;
|
||
}
|
||
|
||
async function compileLoadout(layout) {
|
||
const scene = current?.scene;
|
||
if (!scene?.getRenderGraph)
|
||
throw new Error("Run a Scene before compiling a render graph loadout");
|
||
return compileFxNodeRenderGraph(layout, await scene.getRenderGraph());
|
||
}
|
||
|
||
function closeRenderGraphMenu() {
|
||
if (renderGraphMenuOutsideListener) {
|
||
document.removeEventListener(
|
||
"pointerdown",
|
||
renderGraphMenuOutsideListener,
|
||
true,
|
||
);
|
||
renderGraphMenuOutsideListener = undefined;
|
||
}
|
||
renderGraphMenu?.remove();
|
||
renderGraphMenu = undefined;
|
||
}
|
||
|
||
function renderGraphOption(item, request, groupTitle) {
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = "rg-menu-option";
|
||
button.dataset.typeId = item.typeId;
|
||
button.dataset.search =
|
||
`${item.title} ${item.description} ${item.keywords || ""} ${groupTitle}`.toLowerCase();
|
||
const title = document.createElement("span");
|
||
const add = document.createElement("b");
|
||
const description = document.createElement("small");
|
||
title.textContent = item.title;
|
||
add.textContent = "+";
|
||
description.textContent = item.description;
|
||
button.append(title, add, description);
|
||
button.addEventListener("click", async () => {
|
||
closeRenderGraphMenu();
|
||
try {
|
||
await request.add(item.typeId);
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
setLoadoutStatus(message, "failed");
|
||
showToast(message);
|
||
}
|
||
});
|
||
return button;
|
||
}
|
||
|
||
function openRenderGraphMenu(request) {
|
||
closeRenderGraphMenu();
|
||
const menu = document.createElement("div");
|
||
menu.className = "rg-node-menu";
|
||
menu.dataset.renderGraphMenu = "";
|
||
menu.setAttribute("role", "menu");
|
||
menu.setAttribute("aria-label", "Add render graph node");
|
||
const search = document.createElement("label");
|
||
search.className = "rg-menu-search";
|
||
search.innerHTML = '<span aria-hidden="true">⌕</span>';
|
||
const input = document.createElement("input");
|
||
input.type = "search";
|
||
input.placeholder = "Search nodes";
|
||
input.setAttribute("aria-label", "Search render graph nodes");
|
||
search.append(input);
|
||
const groups = document.createElement("div");
|
||
groups.className = "rg-menu-groups";
|
||
const results = document.createElement("div");
|
||
results.className = "rg-menu-results";
|
||
results.hidden = true;
|
||
const empty = document.createElement("div");
|
||
empty.className = "rg-menu-empty";
|
||
empty.textContent = "No render graph nodes match.";
|
||
empty.hidden = true;
|
||
|
||
const openGroup = (target) => {
|
||
for (const group of groups.children)
|
||
group.classList.toggle("open", group === target);
|
||
};
|
||
for (const group of renderGraphNodeGroups) {
|
||
const host = document.createElement("div");
|
||
host.className = "rg-menu-group";
|
||
host.dataset.group = group.id;
|
||
const trigger = document.createElement("button");
|
||
trigger.type = "button";
|
||
trigger.className = "rg-menu-group-trigger";
|
||
trigger.setAttribute("aria-haspopup", "menu");
|
||
trigger.innerHTML = `<span class="rg-menu-group-icon">${group.icon}</span><span>${group.title}<br><small>${group.items.length} nodes</small></span><span class="rg-menu-chevron">›</span>`;
|
||
const submenu = document.createElement("div");
|
||
submenu.className = "rg-menu-submenu";
|
||
submenu.setAttribute("role", "menu");
|
||
const heading = document.createElement("strong");
|
||
heading.textContent = group.title;
|
||
submenu.append(
|
||
heading,
|
||
...group.items.map((item) =>
|
||
renderGraphOption(item, request, group.title),
|
||
),
|
||
);
|
||
trigger.addEventListener("pointerenter", () => openGroup(host));
|
||
trigger.addEventListener("click", () => openGroup(host));
|
||
host.append(trigger, submenu);
|
||
groups.append(host);
|
||
}
|
||
|
||
input.addEventListener("input", () => {
|
||
const query = input.value.trim().toLowerCase();
|
||
groups.hidden = Boolean(query);
|
||
results.hidden = !query;
|
||
results.replaceChildren();
|
||
if (query) {
|
||
const matches = renderGraphNodeGroups.flatMap((group) =>
|
||
group.items
|
||
.map((item) => renderGraphOption(item, request, group.title))
|
||
.filter((button) => button.dataset.search.includes(query)),
|
||
);
|
||
results.append(...matches);
|
||
empty.hidden = matches.length > 0;
|
||
} else {
|
||
empty.hidden = true;
|
||
}
|
||
});
|
||
input.addEventListener("keydown", (event) => {
|
||
if (event.key === "Escape") {
|
||
event.preventDefault();
|
||
closeRenderGraphMenu();
|
||
elements["render-graph-canvas"].focus();
|
||
}
|
||
});
|
||
menu.append(search, groups, results, empty);
|
||
document.body.append(menu);
|
||
const canvasBounds = elements["render-graph-canvas"].getBoundingClientRect();
|
||
const menuBounds = menu.getBoundingClientRect();
|
||
const margin = 8;
|
||
const x = canvasBounds.left + request.viewPosition.x;
|
||
const y = canvasBounds.top + request.viewPosition.y;
|
||
const needsLeftSubmenu = x + menuBounds.width + 270 > innerWidth - margin;
|
||
menu.classList.toggle("open-left", needsLeftSubmenu);
|
||
menu.style.left = `${Math.max(margin, Math.min(x, innerWidth - menuBounds.width - margin))}px`;
|
||
menu.style.top = `${Math.max(margin, Math.min(y, innerHeight - menuBounds.height - margin))}px`;
|
||
menu.style.visibility = "visible";
|
||
renderGraphMenu = menu;
|
||
input.focus();
|
||
renderGraphMenuOutsideListener = (event) => {
|
||
if (!menu.contains(event.target)) closeRenderGraphMenu();
|
||
};
|
||
document.addEventListener(
|
||
"pointerdown",
|
||
renderGraphMenuOutsideListener,
|
||
true,
|
||
);
|
||
}
|
||
|
||
async function switchLoadout(id) {
|
||
const next = allLoadouts().find((loadout) => loadout.id === id);
|
||
if (!next || !renderGraphEditor) return;
|
||
if (graphDirty) stageActiveLoadout(await renderGraphEditor.save());
|
||
suppressGraphChanges = true;
|
||
try {
|
||
currentLoadoutID = next.id;
|
||
await renderGraphEditor.load(loadoutLayout(next));
|
||
graphDirty = false;
|
||
elements["graph-tab-dirty"].classList.toggle("visible", loadoutsDirty);
|
||
renderLoadoutOptions();
|
||
setLoadoutStatus(`${next.name} selected · Apply to update scene.ts`);
|
||
window.__yawnRenderGraph.selected = next.id;
|
||
window.__yawnRenderGraph.layout = loadoutLayout(next);
|
||
} finally {
|
||
suppressGraphChanges = false;
|
||
}
|
||
}
|
||
|
||
async function applyLoadout() {
|
||
if (!renderGraphEditor || elements["apply-loadout-button"].disabled) return;
|
||
elements["apply-loadout-button"].disabled = true;
|
||
try {
|
||
const layout = await renderGraphEditor.save();
|
||
const staged = stageActiveLoadout(layout);
|
||
if (model.getValue() !== lastRunSource && !(await run()))
|
||
throw new Error(
|
||
"The Scene must run successfully before applying a loadout",
|
||
);
|
||
setLoadoutStatus(`Compiling ${staged.name}…`, "busy");
|
||
compiledRenderGraph = await compileLoadout(layout);
|
||
const source = withAppendedRenderGraph(
|
||
model.getValue(),
|
||
staged.name,
|
||
compiledRenderGraph,
|
||
);
|
||
if (source !== model.getValue()) model.setValue(source);
|
||
window.__yawnRenderGraph.selected = staged.id;
|
||
window.__yawnRenderGraph.layout = structuredClone(layout);
|
||
window.__yawnRenderGraph.serialized = compiledRenderGraph;
|
||
if (!(await run()))
|
||
throw new Error("The generated render graph loadout could not run");
|
||
setLoadoutStatus(`${staged.name} applied from scene.ts`);
|
||
showToast(`Appended ${staged.name} to scene.ts and reran the playground`);
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
setLoadoutStatus(message, "failed");
|
||
showToast(message);
|
||
} finally {
|
||
elements["apply-loadout-button"].disabled = false;
|
||
}
|
||
}
|
||
|
||
async function copyLoadoutCode() {
|
||
const button = elements["copy-loadout-code-button"];
|
||
if (!renderGraphEditor || button.disabled) return;
|
||
button.disabled = true;
|
||
try {
|
||
const layout = await renderGraphEditor.save();
|
||
compiledRenderGraph = await compileLoadout(layout);
|
||
const name = elements["loadout-name"].value.trim() || activeLoadout().name;
|
||
const code = generatedRenderGraphBlock(name, compiledRenderGraph);
|
||
await navigator.clipboard.writeText(code);
|
||
window.__yawnRenderGraph.serialized = compiledRenderGraph;
|
||
showToast(`${activeLoadout().name} loadout code copied`);
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
setLoadoutStatus(message, "failed");
|
||
showToast(message);
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function initializeRenderGraphWorkspace() {
|
||
renderLoadoutOptions();
|
||
renderGraphEditor = await createRenderGraphEditor({
|
||
canvas: elements["render-graph-canvas"],
|
||
initialLayout: loadoutLayout(),
|
||
workerUrl: "/pkg/fxnode-worker.js",
|
||
onChange: markGraphChanged,
|
||
onAddNodeMenu: openRenderGraphMenu,
|
||
onError(error) {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
setLoadoutStatus(message, "failed");
|
||
},
|
||
});
|
||
window.__yawnRenderGraph = {
|
||
editor: renderGraphEditor,
|
||
layout: loadoutLayout(),
|
||
presets: renderGraphPresets.map(({ id, name }) => ({ id, name })),
|
||
selected: currentLoadoutID,
|
||
serialized: compiledRenderGraph,
|
||
switchLoadout,
|
||
};
|
||
setLoadoutStatus(`${activeLoadout().name} selected`);
|
||
}
|
||
|
||
function activateCodingTab(tab) {
|
||
activeCodingTab = tab;
|
||
closeRenderGraphMenu();
|
||
for (const button of document.querySelectorAll("[data-editor-tab]")) {
|
||
const active = button.dataset.editorTab === tab;
|
||
button.classList.toggle("active", active);
|
||
button.setAttribute("aria-selected", String(active));
|
||
}
|
||
elements.editor.hidden = tab !== "scene";
|
||
elements["render-graph-editor"].hidden = tab !== "render-graph";
|
||
for (const target of document.querySelectorAll("[data-code-tool]"))
|
||
target.hidden = tab !== "scene";
|
||
for (const target of document.querySelectorAll("[data-graph-tool]"))
|
||
target.hidden = tab !== "render-graph";
|
||
if (tab === "render-graph") {
|
||
elements["workspace-language"].textContent = "Render graph";
|
||
elements["type-status"].textContent = "FxNode worker ready";
|
||
elements["cursor-status"].textContent = "Node canvas";
|
||
elements["editor-mode-status"].textContent = "FxNode";
|
||
elements["encoding-status"].textContent = "S-expression v1";
|
||
elements["editor-diagnostics"].lastChild.textContent =
|
||
" Graph valid on save";
|
||
elements["editor-diagnostics"].classList.remove("has-errors");
|
||
requestAnimationFrame(() => void renderGraphEditor?.syncViewport());
|
||
} else {
|
||
elements["workspace-language"].textContent = "TypeScript";
|
||
const position = editor?.getPosition();
|
||
elements["cursor-status"].textContent = position
|
||
? `Ln ${position.lineNumber}, Col ${position.column}`
|
||
: "Ln 1, Col 1";
|
||
elements["editor-mode-status"].textContent = "Spaces: 2";
|
||
elements["encoding-status"].textContent = "UTF-8";
|
||
updateDiagnostics();
|
||
editor?.layout();
|
||
editor?.focus();
|
||
}
|
||
}
|
||
|
||
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.");
|
||
const code = 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+/, "__yawnPlaygroundDefaultExport = ");
|
||
return `let __yawnPlaygroundDefaultExport;\n${code}\nreturn __yawnPlaygroundDefaultExport;`;
|
||
}
|
||
|
||
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 false;
|
||
clearPlaygroundTimers();
|
||
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",
|
||
"setTimeout",
|
||
"setInterval",
|
||
"clearTimeout",
|
||
"clearInterval",
|
||
code,
|
||
)(
|
||
...names.map((name) => api[name]),
|
||
canvas,
|
||
(...values) => appendConsole(values.map(formatValue).join(" ")),
|
||
trackedSetTimeout,
|
||
trackedSetInterval,
|
||
trackedClearTimer,
|
||
trackedClearTimer,
|
||
);
|
||
if (runID !== generation) {
|
||
if (result?.dispose) await result.dispose();
|
||
return false;
|
||
}
|
||
current = result;
|
||
window.__yawnPlayground = result;
|
||
lastRunSource = model.getValue();
|
||
elements["preview-message"].hidden = true;
|
||
setRunStatus(
|
||
`Running · ${Math.round(performance.now() - started)} ms`,
|
||
"ready",
|
||
);
|
||
if (activePanel === "profile") await attachProfiler();
|
||
return true;
|
||
} 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");
|
||
}
|
||
return false;
|
||
} 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 hadLoadoutChanges = loadoutsDirty;
|
||
let stagedLoadout;
|
||
if (graphDirty && renderGraphEditor)
|
||
stagedLoadout = stageActiveLoadout(await renderGraphEditor.save());
|
||
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(),
|
||
loadouts: customLoadouts,
|
||
}),
|
||
});
|
||
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;
|
||
customLoadouts = validCustomLoadouts(result.loadouts);
|
||
renderLoadoutOptions();
|
||
history.pushState(null, "", `/playground/${result.id}/${result.revision}`);
|
||
elements["revision-label"].textContent = `Revision ${result.revision}`;
|
||
elements["share-button"].hidden = false;
|
||
markDirty(false);
|
||
if (stagedLoadout) setLoadoutStatus("Saved · Apply to update scene.ts");
|
||
else if (hadLoadoutChanges)
|
||
setLoadoutStatus("Applied code · saved with playground");
|
||
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["apply-loadout-button"].addEventListener("click", applyLoadout);
|
||
elements["copy-loadout-code-button"].addEventListener("click", copyLoadoutCode);
|
||
elements["loadout-name"].addEventListener("input", markGraphChanged);
|
||
elements["loadout-select"].addEventListener(
|
||
"change",
|
||
(event) =>
|
||
void switchLoadout(event.target.value).catch((error) => {
|
||
const message = error instanceof Error ? error.message : String(error);
|
||
setLoadoutStatus(message, "failed");
|
||
showToast(message);
|
||
}),
|
||
);
|
||
for (const button of document.querySelectorAll("[data-editor-tab]")) {
|
||
button.addEventListener("click", () =>
|
||
activateCodingTab(button.dataset.editorTab),
|
||
);
|
||
}
|
||
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", (event) => {
|
||
if (!dirty) return;
|
||
event.preventDefault();
|
||
event.returnValue = "";
|
||
});
|
||
addEventListener("pagehide", (event) => {
|
||
if (event.persisted) return;
|
||
cancelAnimationFrame(frameRequest);
|
||
closeRenderGraphMenu();
|
||
void renderGraphEditor?.destroy();
|
||
void disposeCurrent();
|
||
});
|
||
|
||
try {
|
||
const source = await loadInitialPlayground();
|
||
await createEditor(source);
|
||
await initializeRenderGraphWorkspace();
|
||
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);
|
||
}
|