@field/cascade 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/AGENTS.md +17 -1
  2. package/CHANGELOG.md +214 -0
  3. package/DESIGN.md +14 -0
  4. package/README.md +41 -25
  5. package/dist/assets/CodeEditor-DSZBpzdy.js +623 -0
  6. package/dist/assets/CodePanel-sY6sZa7B.js +2 -0
  7. package/dist/assets/{CustomNodeDialog-BQbEhIwL.js → CustomNodeDialog-BRKnqZhp.js} +1 -1
  8. package/dist/assets/{ExportDialog-CjTbbTZJ.js → ExportDialog-C7dVGua4.js} +2 -2
  9. package/dist/assets/OpenGraphDialog-ZsgL-iwr.js +1 -0
  10. package/dist/assets/ProjectSettings-Cw17nNe3.js +2 -0
  11. package/dist/assets/SettingsDialog-p25h3tj0.js +1 -0
  12. package/dist/assets/{VersionHistoryDialog-D6VBzSQ0.js → VersionHistoryDialog-DzFLUotB.js} +2 -2
  13. package/dist/assets/{core-BInLG4gw.js → core-BICrEt0y.js} +8 -8
  14. package/dist/assets/{cssMode-DfR2qmYG.js → cssMode-D7TXw57I.js} +1 -1
  15. package/dist/assets/definitions-DUww2Rt-.js +1 -0
  16. package/dist/assets/{editor.api-Ck8_vgQV.js → editor.api-DvkCO3ko.js} +1 -1
  17. package/dist/assets/{freemarker2-fVn10Wn3.js → freemarker2-BA2lxsF1.js} +1 -1
  18. package/dist/assets/geo-C2RHOVHC.js +1 -0
  19. package/dist/assets/{handlebars-BCSchLT2.js → handlebars-BcDiyPd8.js} +1 -1
  20. package/dist/assets/{html-CVpSv_k9.js → html-C7O4TyhX.js} +1 -1
  21. package/dist/assets/{htmlMode-Da4ESTv0.js → htmlMode-WaDGF3Mj.js} +1 -1
  22. package/dist/assets/{image-BLn--oSF.js → image-DR4oeyJB.js} +3 -3
  23. package/dist/assets/index-7vae6YFC.css +1 -0
  24. package/dist/assets/index-DyvDC6pS.js +219 -0
  25. package/dist/assets/index-client-DqYmKstU.js +1 -0
  26. package/dist/assets/{javascript-BpX0Gvcf.js → javascript-CpIEK7tz.js} +1 -1
  27. package/dist/assets/{jsonMode-CsLqaou7.js → jsonMode-B2FltW0f.js} +1 -1
  28. package/dist/assets/legacy-CTEeXeqF.js +2 -0
  29. package/dist/assets/{liquid-efUz51Wf.js → liquid-CN6FFHZE.js} +1 -1
  30. package/dist/assets/{lspLanguageFeatures-DCi5-K0n.js → lspLanguageFeatures-QhV9XtLp.js} +1 -1
  31. package/dist/assets/math-DaDEgWKt.js +1 -0
  32. package/dist/assets/{mdx-DP_sjGVy.js → mdx-DE89pafV.js} +1 -1
  33. package/dist/assets/pop-pLH7L0PW.js +1 -0
  34. package/dist/assets/{python-aAuP3LUi.js → python-ByTllTB9.js} +1 -1
  35. package/dist/assets/{razor-7M6sMmC5.js → razor-USYlRRCE.js} +1 -1
  36. package/dist/assets/studioScenes-8sq9FfFg.js +1 -0
  37. package/dist/assets/{tsMode-Bf4CnoYK.js → tsMode-BXpN8iUz.js} +1 -1
  38. package/dist/assets/{typescript-ddhYBGyT.js → typescript-DInBjGQQ.js} +1 -1
  39. package/dist/assets/{workers-X5la8DDT.js → workers-C7Ye5B7D.js} +1 -1
  40. package/dist/assets/{xml-BzbXa3Ps.js → xml-U0Uocqyo.js} +1 -1
  41. package/dist/assets/{yaml-CXs-VzxL.js → yaml-CBw_PVYf.js} +1 -1
  42. package/dist/cli/index.js +19978 -3423
  43. package/dist/cli/runtime/stage.ts +70 -10
  44. package/dist/contracts/bundle.d.ts +75 -0
  45. package/dist/contracts/bundle.js +112 -0
  46. package/dist/contracts/definition.d.ts +67 -4
  47. package/dist/contracts/document.d.ts +29 -0
  48. package/dist/contracts/editor.d.ts +172 -0
  49. package/dist/contracts/editor.js +2 -0
  50. package/dist/contracts/index.d.ts +2 -0
  51. package/dist/contracts/index.js +2 -0
  52. package/dist/contracts/validate.js +59 -3
  53. package/dist/contracts/values.d.ts +3 -3
  54. package/dist/contracts/values.js +1 -0
  55. package/dist/index.html +4 -4
  56. package/dist/player/app.js +17 -5
  57. package/dist/player/editor.js +1894 -0
  58. package/dist/player/editorEngine.js +477 -0
  59. package/dist/runtime/builtins/core/bundle.d.ts +70 -0
  60. package/dist/runtime/builtins/core/bundle.js +81 -0
  61. package/dist/runtime/builtins/core/index.d.ts +3 -2
  62. package/dist/runtime/builtins/core/index.js +6 -2
  63. package/dist/runtime/builtins/core/routing.d.ts +27 -0
  64. package/dist/runtime/builtins/core/routing.js +28 -0
  65. package/dist/runtime/edit/document.d.ts +94 -0
  66. package/dist/runtime/edit/document.js +194 -0
  67. package/dist/runtime/edit/graphEditor.d.ts +223 -0
  68. package/dist/runtime/edit/graphEditor.js +893 -0
  69. package/dist/runtime/edit/index.d.ts +12 -0
  70. package/dist/runtime/edit/index.js +6 -0
  71. package/dist/runtime/edit/layout.d.ts +50 -0
  72. package/dist/runtime/edit/layout.js +126 -0
  73. package/dist/runtime/edit/operations.d.ts +65 -0
  74. package/dist/runtime/edit/operations.js +57 -0
  75. package/dist/runtime/edit/parameters.d.ts +75 -0
  76. package/dist/runtime/edit/parameters.js +284 -0
  77. package/dist/runtime/edit/read.d.ts +35 -0
  78. package/dist/runtime/edit/read.js +65 -0
  79. package/dist/runtime/editor/index.d.ts +104 -0
  80. package/dist/runtime/editor/index.js +439 -0
  81. package/dist/runtime/expressions/bundlePaths.d.ts +37 -0
  82. package/dist/runtime/expressions/bundlePaths.js +55 -0
  83. package/dist/runtime/expressions/index.d.ts +2 -0
  84. package/dist/runtime/expressions/index.js +1 -0
  85. package/dist/runtime/index.d.ts +2 -0
  86. package/dist/runtime/index.js +2 -0
  87. package/dist/runtime/params/animator.d.ts +5 -0
  88. package/dist/runtime/params/animator.js +16 -0
  89. package/dist/runtime/runtime.js +238 -12
  90. package/dist/runtime/seed.d.ts +29 -0
  91. package/dist/runtime/seed.js +52 -0
  92. package/dist/runtime/types.d.ts +12 -0
  93. package/dist/stage.js +40 -9
  94. package/dist/studio/panel.d.ts +2 -0
  95. package/doc/CLI_REFERENCE.md +100 -0
  96. package/doc/EDITOR_EXPORT.md +226 -0
  97. package/doc/HEADLESS_GPU.md +27 -0
  98. package/doc/MCP.md +53 -0
  99. package/doc/NODE_REFERENCE.md +66 -0
  100. package/doc/PROJECT_AUTHORING.md +137 -0
  101. package/package.json +15 -6
  102. package/dist/assets/CodeEditor-BEk-kIBd.js +0 -623
  103. package/dist/assets/CodePanel-CeSdjXkg.js +0 -2
  104. package/dist/assets/OpenGraphDialog-DCwH9itM.js +0 -1
  105. package/dist/assets/ProjectSettings-Dg7M5xt9.js +0 -2
  106. package/dist/assets/SettingsDialog-e7GgsjB9.js +0 -1
  107. package/dist/assets/definitions-DZpOjWKR.js +0 -1
  108. package/dist/assets/geo-D2VPjAdt.js +0 -1
  109. package/dist/assets/index-CMiBdEuT.js +0 -212
  110. package/dist/assets/index-DMIuml9a.css +0 -1
  111. package/dist/assets/index-client-BI2H82FJ.js +0 -1
  112. package/dist/assets/legacy-ZA7textG.js +0 -2
  113. package/dist/assets/math-B6hpxPJO.js +0 -1
  114. package/dist/assets/pop-CYUIjT6x.js +0 -1
  115. package/dist/assets/projectSettingsApi-C1WvIwfP.js +0 -1
@@ -0,0 +1,1894 @@
1
+ // src/player/editor.ts
2
+ import {
3
+ isScene as isScene2,
4
+ sceneFromGeometry as sceneFromGeometry2
5
+ } from "@field/cascade/contracts";
6
+ import {
7
+ controlOf,
8
+ createScene,
9
+ editorGraphVersion,
10
+ fromBase64,
11
+ isVisible,
12
+ publishedValues,
13
+ resolveScene,
14
+ sceneFile
15
+ } from "@field/cascade/runtime/editor";
16
+
17
+ // src/player/app.ts
18
+ import {
19
+ CAMERA_DEFAULTS,
20
+ isScene,
21
+ sceneFromGeometry
22
+ } from "@field/cascade/contracts";
23
+ import { createBrowserRuntimeHost } from "@field/cascade/runtime/browser";
24
+ import { renderScene } from "@field/cascade/runtime/render";
25
+ import { createRuntime, PREFLIGHT_WARNING_CODES } from "@field/cascade/runtime";
26
+ import { sceneBounds } from "@field/cascade/runtime/scene";
27
+ import { installIoBridge } from "@field/cascade/io";
28
+
29
+ // src/browser/gpu.ts
30
+ import {
31
+ createGpuHost
32
+ } from "@field/cascade/runtime";
33
+ function createBrowserGpuHost() {
34
+ const gpu = globalThis.navigator?.gpu;
35
+ if (!gpu) return void 0;
36
+ return createGpuHost({
37
+ async acquire() {
38
+ const adapter = await gpu.requestAdapter();
39
+ if (!adapter) {
40
+ throw new Error(
41
+ "No WebGPU adapter is available \u2014 the browser reports WebGPU but the system offers no GPU to render on."
42
+ );
43
+ }
44
+ const device = await adapter.requestDevice();
45
+ const info = adapter.info;
46
+ return {
47
+ device,
48
+ info: {
49
+ vendor: info?.vendor ?? "",
50
+ architecture: info?.architecture ?? "",
51
+ device: info?.device ?? "",
52
+ description: info?.description ?? ""
53
+ },
54
+ limits: device.limits ?? adapter.limits
55
+ };
56
+ }
57
+ });
58
+ }
59
+
60
+ // src/player/assets.ts
61
+ var DEFAULT_MAX_BYTES = 256 * 1024 * 1024;
62
+ var DEFAULT_MAX_ENTRIES = 4096;
63
+ var GENERATED_ROOT = ".cascade-player/generated";
64
+ var PlayerAssetStore = class {
65
+ constructor(assets, options = {}) {
66
+ this.authored = /* @__PURE__ */ new Map();
67
+ this.generated = /* @__PURE__ */ new Map();
68
+ this.generatedByUrl = /* @__PURE__ */ new Map();
69
+ this.generatedBytes = 0;
70
+ this.sequence = 0;
71
+ this.disposed = false;
72
+ this.maxGeneratedBytes = finiteLimit(
73
+ options.maxGeneratedBytes,
74
+ DEFAULT_MAX_BYTES,
75
+ "maxGeneratedBytes"
76
+ );
77
+ this.maxGeneratedEntries = finiteLimit(
78
+ options.maxGeneratedEntries,
79
+ DEFAULT_MAX_ENTRIES,
80
+ "maxGeneratedEntries"
81
+ );
82
+ for (const [path, url] of Object.entries(assets)) {
83
+ const clean = cleanPath(path);
84
+ if (typeof url !== "string" || url.length === 0)
85
+ throw new TypeError(`Player asset ${clean} has no deployment URL`);
86
+ this.authored.set(clean, url);
87
+ }
88
+ }
89
+ async read(ref, options = {}) {
90
+ this.assertActive();
91
+ const path = cleanPath(typeof ref === "string" ? ref : ref.path);
92
+ if (typeof ref === "string") assertIdentityMediaOptions(path, options);
93
+ const signal = "signal" in options ? options.signal : void 0;
94
+ if (signal?.aborted) throw abortError(signal.reason);
95
+ const generated = this.generated.get(path);
96
+ if (generated) return generated.bytes.slice();
97
+ const url = this.authored.get(path);
98
+ if (!url) throw new Error(`Player asset not found: ${path}`);
99
+ const relay = signal ? relayAbort(signal) : void 0;
100
+ try {
101
+ const response = await fetch(url, {
102
+ ...relay ? { signal: relay.signal } : {}
103
+ });
104
+ if (!response.ok)
105
+ throw new Error(`Could not read player asset ${path} (${response.status})`);
106
+ return new Uint8Array(await response.arrayBuffer());
107
+ } finally {
108
+ relay?.dispose();
109
+ }
110
+ }
111
+ async write(first, second, third) {
112
+ this.assertActive();
113
+ const fromIo = typeof first === "string";
114
+ const data = fromIo ? second : first;
115
+ if (!(data instanceof Uint8Array))
116
+ throw new TypeError("Player asset writes require Uint8Array bytes");
117
+ const metadata = fromIo ? { suggestedName: first, mediaType: mediaTypeForName(first) } : second;
118
+ if (third?.signal.aborted) throw abortError(third.signal.reason);
119
+ const name = safeName(metadata.suggestedName);
120
+ const path = `${GENERATED_ROOT}/${String(++this.sequence).padStart(8, "0")}-${name}`;
121
+ const nextBytes = this.generatedBytes + data.byteLength;
122
+ const nextEntries = this.generated.size + 1;
123
+ if (nextBytes > this.maxGeneratedBytes) {
124
+ throw new Error(
125
+ `Player asset ${path} would use ${nextBytes} bytes; limit is ${this.maxGeneratedBytes} bytes`
126
+ );
127
+ }
128
+ if (nextEntries > this.maxGeneratedEntries) {
129
+ throw new Error(
130
+ `Player asset ${path} would use ${nextEntries} entries; limit is ${this.maxGeneratedEntries} entries`
131
+ );
132
+ }
133
+ const stored = data.slice();
134
+ this.generated.set(path, {
135
+ bytes: stored,
136
+ ...metadata.mediaType ? { mediaType: metadata.mediaType } : {},
137
+ leases: 0
138
+ });
139
+ this.generatedBytes = nextBytes;
140
+ return fromIo ? path : { path, ...metadata.mediaType ? { mediaType: metadata.mediaType } : {} };
141
+ }
142
+ url(path, options = {}) {
143
+ this.assertActive();
144
+ assertIdentityMediaOptions(path, options);
145
+ const clean = cleanPath(path);
146
+ const authored = this.authored.get(clean);
147
+ if (authored) return authored;
148
+ const generated = this.generated.get(clean);
149
+ if (!generated) throw new Error(`Player asset not found: ${clean}`);
150
+ if (!generated.objectUrl) {
151
+ generated.objectUrl = URL.createObjectURL(blobFor(generated));
152
+ this.generatedByUrl.set(generated.objectUrl, clean);
153
+ }
154
+ return generated.objectUrl;
155
+ }
156
+ async resolveUrl(ref) {
157
+ this.assertActive();
158
+ const path = cleanPath(ref.path);
159
+ const generated = this.generated.get(path);
160
+ if (!generated) {
161
+ const authored = this.authored.get(path);
162
+ if (!authored) throw new Error(`Player asset not found: ${path}`);
163
+ return { value: authored, release: () => {
164
+ } };
165
+ }
166
+ generated.leases += 1;
167
+ let value;
168
+ try {
169
+ value = this.url(path, { raw: true });
170
+ } catch (error) {
171
+ generated.leases -= 1;
172
+ throw error;
173
+ }
174
+ let released = false;
175
+ return {
176
+ value,
177
+ release: () => {
178
+ if (released) return;
179
+ released = true;
180
+ generated.leases = Math.max(0, generated.leases - 1);
181
+ }
182
+ };
183
+ }
184
+ /** Remove generated entries that no settled graph value or display lease owns. */
185
+ collect(roots) {
186
+ if (this.disposed) return;
187
+ const reachable = /* @__PURE__ */ new Set();
188
+ const seen = /* @__PURE__ */ new Set();
189
+ for (const root of roots)
190
+ visitPaths(root, reachable, seen, this.generated, this.generatedByUrl);
191
+ const errors = [];
192
+ for (const [path, entry] of this.generated) {
193
+ if (reachable.has(path) || entry.leases > 0) continue;
194
+ try {
195
+ this.drop(path, entry);
196
+ } catch (error) {
197
+ errors.push(error);
198
+ }
199
+ }
200
+ throwCollectedErrors(errors, "Player asset collection failed");
201
+ }
202
+ stats() {
203
+ return {
204
+ generatedBytes: this.generatedBytes,
205
+ generatedEntries: this.generated.size,
206
+ objectUrls: this.generatedByUrl.size
207
+ };
208
+ }
209
+ dispose() {
210
+ if (this.disposed) return;
211
+ this.disposed = true;
212
+ const errors = [];
213
+ for (const [path, entry] of this.generated) {
214
+ try {
215
+ this.drop(path, entry);
216
+ } catch (error) {
217
+ errors.push(error);
218
+ }
219
+ }
220
+ throwCollectedErrors(errors, "Player asset disposal failed");
221
+ }
222
+ drop(path, entry) {
223
+ try {
224
+ if (entry.objectUrl) URL.revokeObjectURL(entry.objectUrl);
225
+ } finally {
226
+ if (entry.objectUrl) this.generatedByUrl.delete(entry.objectUrl);
227
+ this.generated.delete(path);
228
+ this.generatedBytes -= entry.bytes.byteLength;
229
+ }
230
+ }
231
+ assertActive() {
232
+ if (this.disposed) throw new Error("Player asset store is disposed");
233
+ }
234
+ };
235
+ function finiteLimit(value, fallback, name) {
236
+ const resolved = value ?? fallback;
237
+ if (!Number.isSafeInteger(resolved) || resolved < 0)
238
+ throw new TypeError(`${name} must be a non-negative safe integer`);
239
+ return resolved;
240
+ }
241
+ function cleanPath(path) {
242
+ const clean = path.replace(/^\.\//, "").replace(/\\/g, "/");
243
+ if (!clean || clean.startsWith("/") || clean.split("/").some((part) => part === ".."))
244
+ throw new Error(`Invalid player asset path: ${path}`);
245
+ return clean;
246
+ }
247
+ function safeName(value) {
248
+ const source = (value?.trim() || "asset.bin").replace(/\\/g, "/").split("/").pop();
249
+ const safe = source.replace(/[^A-Za-z0-9._-]/g, "-").replace(/^\.+/, "");
250
+ return safe || "asset.bin";
251
+ }
252
+ function assertIdentityMediaOptions(path, options) {
253
+ if (options.width !== void 0 || options.format !== void 0 || options.quality !== void 0)
254
+ throw new Error(`Player asset transforms are unsupported for ${path}; request the raw asset`);
255
+ }
256
+ function blobFor(entry) {
257
+ return new Blob([entry.bytes.slice().buffer], {
258
+ type: entry.mediaType ?? "application/octet-stream"
259
+ });
260
+ }
261
+ function visitPaths(value, reachable, seen, generated, generatedByUrl) {
262
+ if (typeof value === "string") {
263
+ const path = value.replace(/^\.\//, "");
264
+ if (generated.has(path)) reachable.add(path);
265
+ else {
266
+ const generatedPath = generatedByUrl.get(value);
267
+ if (generatedPath) reachable.add(generatedPath);
268
+ }
269
+ return;
270
+ }
271
+ if (typeof value !== "object" || value === null) return;
272
+ if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) return;
273
+ if (seen.has(value)) return;
274
+ seen.add(value);
275
+ if ("path" in value && typeof value.path === "string") {
276
+ const path = value.path.replace(/^\.\//, "");
277
+ if (generated.has(path)) reachable.add(path);
278
+ }
279
+ if (Array.isArray(value)) {
280
+ for (const item of value)
281
+ visitPaths(item, reachable, seen, generated, generatedByUrl);
282
+ return;
283
+ }
284
+ const prototype = Object.getPrototypeOf(value);
285
+ if (prototype !== Object.prototype && prototype !== null) return;
286
+ for (const item of Object.values(value))
287
+ visitPaths(item, reachable, seen, generated, generatedByUrl);
288
+ }
289
+ function relayAbort(signal) {
290
+ const controller = new AbortController();
291
+ const abort = () => controller.abort(signal.reason);
292
+ signal.addEventListener("abort", abort, { once: true });
293
+ if (signal.aborted) abort();
294
+ return {
295
+ signal: controller.signal,
296
+ dispose: () => signal.removeEventListener("abort", abort)
297
+ };
298
+ }
299
+ function abortError(reason) {
300
+ return reason instanceof Error ? reason : new Error(reason === void 0 ? "Aborted" : String(reason));
301
+ }
302
+ function mediaTypeForName(name) {
303
+ if (/\.png$/i.test(name)) return "image/png";
304
+ if (/\.svg$/i.test(name)) return "image/svg+xml";
305
+ if (/\.jpe?g$/i.test(name)) return "image/jpeg";
306
+ if (/\.webp$/i.test(name)) return "image/webp";
307
+ return void 0;
308
+ }
309
+ function throwCollectedErrors(errors, message2) {
310
+ if (errors.length === 1) throw errors[0];
311
+ if (errors.length > 1) {
312
+ const combined = new Error(message2);
313
+ Object.assign(combined, { errors });
314
+ throw combined;
315
+ }
316
+ }
317
+
318
+ // src/player/app.ts
319
+ function renderSceneCanvas(hostDocument, scene, width, height, stroke = [1, 1, 1, 1]) {
320
+ let canvas;
321
+ const camera = scene.camera ?? fitCamera(scene, width, height);
322
+ renderScene(scene.geometry, {
323
+ camera,
324
+ size: [width, height],
325
+ stroke: [...stroke],
326
+ strokeWidth: 1,
327
+ opacity: 1,
328
+ pointRadius: 1,
329
+ drawPoints: true,
330
+ surface: (surfaceWidth, surfaceHeight) => {
331
+ canvas = hostDocument.createElement("canvas");
332
+ canvas.width = surfaceWidth;
333
+ canvas.height = surfaceHeight;
334
+ return canvas;
335
+ }
336
+ });
337
+ return canvas;
338
+ }
339
+ function fitCamera(scene, width, height) {
340
+ const bounds = sceneBounds(scene);
341
+ if (!bounds) return { ...CAMERA_DEFAULTS, projection: "orthographic", resolution: [width, height] };
342
+ const centerX = ((bounds.min[0] ?? 0) + (bounds.max[0] ?? 0)) / 2;
343
+ const centerY = ((bounds.min[1] ?? 0) + (bounds.max[1] ?? 0)) / 2;
344
+ const maxZ = bounds.max[2] ?? 0;
345
+ const spanX = (bounds.max[0] ?? 0) - (bounds.min[0] ?? 0);
346
+ const spanY = (bounds.max[1] ?? 0) - (bounds.min[1] ?? 0);
347
+ const frameAspect = width / height;
348
+ return {
349
+ ...CAMERA_DEFAULTS,
350
+ translate: [centerX, centerY, maxZ + Math.max(spanX, spanY, 1)],
351
+ projection: "orthographic",
352
+ orthowidth: Math.max(spanX, spanY * frameAspect, 1) * 1.1,
353
+ resolution: [width, height]
354
+ };
355
+ }
356
+ function isGeometry(value) {
357
+ return typeof value === "object" && value !== null && value.kind === "geometry";
358
+ }
359
+ function isAssetRef(value) {
360
+ return typeof value === "object" && value !== null && typeof value.path === "string";
361
+ }
362
+ function isImageAsset(value) {
363
+ if ("size" in value && Array.isArray(value.size)) return true;
364
+ if ("mediaType" in value && value.mediaType?.startsWith("image/")) return true;
365
+ return /\.(png|svg|jpe?g|webp|gif|avif)$/i.test(value.path);
366
+ }
367
+ function jsonReplacer(_key, value) {
368
+ return ArrayBuffer.isView(value) ? Array.from(value) : value;
369
+ }
370
+ function canvasBlob(canvas) {
371
+ return new Promise((resolve, reject) => {
372
+ canvas.toBlob((blob) => blob ? resolve(blob) : reject(new Error("Canvas produced no image")), "image/png");
373
+ });
374
+ }
375
+ async function cleanupAll(actions) {
376
+ const errors = [];
377
+ for (const action of actions) {
378
+ try {
379
+ await action();
380
+ } catch (error) {
381
+ errors.push(error);
382
+ }
383
+ }
384
+ throwCollectedErrors(errors, "Cascade player cleanup failed");
385
+ }
386
+ function asError(value) {
387
+ return value instanceof Error ? value : new Error(String(value));
388
+ }
389
+ function isSvgText(value) {
390
+ return typeof value === "string" && /^\s*(?:<\?xml[^>]*>\s*)?(?:<!--[\s\S]*?-->\s*|<!DOCTYPE[^>]*>\s*)*<svg[\s>]/i.test(value.slice(0, 2e3));
391
+ }
392
+
393
+ // src/player/editorEngine.ts
394
+ import { createBrowserRuntimeHost as createBrowserRuntimeHost2 } from "@field/cascade/runtime/browser";
395
+ import { createRuntime as createRuntime2, PREFLIGHT_WARNING_CODES as PREFLIGHT_WARNING_CODES2 } from "@field/cascade/runtime";
396
+ import { installIoBridge as installIoBridge2 } from "@field/cascade/io";
397
+ var never = { aborted: false, reason: void 0, addEventListener() {
398
+ }, removeEventListener() {
399
+ } };
400
+ var LocalEngine = class _LocalEngine {
401
+ constructor(graph, store, release) {
402
+ this.graph = graph;
403
+ this.store = store;
404
+ this.release = release;
405
+ }
406
+ static async create(options) {
407
+ const store = new PlayerAssetStore(options.assets);
408
+ const disposeIo = installIoBridge2(store);
409
+ const gpu = createBrowserGpuHost();
410
+ const registrations = new Map(options.registrations.map((item) => [item.moduleId, item]));
411
+ const runtime = createRuntime2({
412
+ host: createBrowserRuntimeHost2({
413
+ modules: { resolve: async (moduleId) => registrations.get(moduleId) ?? null },
414
+ assets: store,
415
+ ...gpu ? { gpu } : {}
416
+ }),
417
+ nodes: options.registrations
418
+ });
419
+ const release = () => cleanupAll([() => runtime.dispose(), () => gpu?.dispose(), () => disposeIo(), () => store.dispose()]);
420
+ try {
421
+ const graph = await runtime.load(options.document);
422
+ const blocking = graph.preflight().find((item) => !PREFLIGHT_WARNING_CODES2.has(item.code));
423
+ if (blocking) throw new Error(blocking.message);
424
+ return new _LocalEngine(graph, store, async () => {
425
+ await graph.dispose();
426
+ await release();
427
+ });
428
+ } catch (error) {
429
+ await release().catch(() => {
430
+ });
431
+ throw error;
432
+ }
433
+ }
434
+ async nodes() {
435
+ return this.graph.inspect().nodes.map((node) => ({
436
+ id: node.id,
437
+ moduleId: node.moduleId,
438
+ ...node.parent ? { parent: node.parent } : {},
439
+ props: Object.fromEntries(Object.entries(node.props).map(([name, prop]) => [name, { type: prop.type, value: prop.value }]))
440
+ }));
441
+ }
442
+ async setProps(writes) {
443
+ for (const [node, prop, value] of writes) await this.graph.setProp(node, prop, value);
444
+ }
445
+ async run(options = {}) {
446
+ const result = await this.graph.run(options);
447
+ this.collect();
448
+ return { status: result.status, messages: result.diagnostics.map((item) => item.message) };
449
+ }
450
+ async output(node, port) {
451
+ return this.graph.getOutput(node, port);
452
+ }
453
+ async outputs() {
454
+ return new Map(this.graph.inspect().nodes.map((node) => [node.id, new Map(this.graph.getOutputs(node.id))]));
455
+ }
456
+ writeAsset(bytes, metadata) {
457
+ return this.store.write(bytes, metadata, { signal: never });
458
+ }
459
+ readAsset(ref) {
460
+ return this.store.read(ref, { signal: never });
461
+ }
462
+ dispose() {
463
+ return this.release();
464
+ }
465
+ /** Drop generated assets nothing reaches any more: not a prop, not an output. */
466
+ collect() {
467
+ const roots = [];
468
+ for (const node of this.graph.inspect().nodes) {
469
+ roots.push(...Object.values(node.props).map((prop) => prop.value));
470
+ roots.push(...this.graph.getOutputs(node.id).values());
471
+ }
472
+ try {
473
+ this.store.collect(roots);
474
+ } catch {
475
+ }
476
+ }
477
+ };
478
+ async function createWorkerEngine(url, options) {
479
+ const worker = new Worker(url, { type: "module", name: "cascade-editor-engine" });
480
+ let sequence = 0;
481
+ const pending = /* @__PURE__ */ new Map();
482
+ worker.onmessage = (event) => {
483
+ const entry = pending.get(event.data.id);
484
+ if (!entry) return;
485
+ pending.delete(event.data.id);
486
+ if (event.data.ok) entry.resolve(event.data.value);
487
+ else entry.reject(new Error(event.data.error));
488
+ };
489
+ worker.onerror = (event) => {
490
+ const error = new Error(event.message || "Editor engine worker failed");
491
+ for (const entry of pending.values()) entry.reject(error);
492
+ pending.clear();
493
+ };
494
+ const call = (method, ...args) => new Promise((resolve, reject) => {
495
+ const id = ++sequence;
496
+ pending.set(id, { resolve, reject });
497
+ worker.postMessage({ id, method, args });
498
+ });
499
+ const assets = Object.fromEntries(Object.entries(options.assets).map(([name, href]) => [name, new URL(href, location.href).href]));
500
+ await call("init", options.document, assets);
501
+ return {
502
+ nodes: () => call("nodes"),
503
+ setProps: (writes) => call("setProps", writes),
504
+ run: (runOptions = {}) => call("run", runOptions),
505
+ output: (node, port) => call("output", node, port),
506
+ outputs: () => call("outputs"),
507
+ writeAsset: (bytes, metadata) => call("writeAsset", bytes, metadata),
508
+ readAsset: (ref) => call("readAsset", ref),
509
+ async dispose() {
510
+ try {
511
+ await call("dispose");
512
+ } finally {
513
+ worker.terminate();
514
+ }
515
+ }
516
+ };
517
+ }
518
+
519
+ // src/player/editorTouch.ts
520
+ var FIT = { scale: 1, x: 0, y: 0 };
521
+ var MAX_SCALE = 8;
522
+ function pinch(start, from, to) {
523
+ const scale = clamp(start.scale * (to.distance / Math.max(1, from.distance)), 1, MAX_SCALE);
524
+ const ratio = scale / start.scale;
525
+ return {
526
+ scale,
527
+ x: to.mid.x - (from.mid.x - start.x) * ratio,
528
+ y: to.mid.y - (from.mid.y - start.y) * ratio
529
+ };
530
+ }
531
+ function bound(state, width, height) {
532
+ if (state.scale <= 1.001) return FIT;
533
+ const minX = width - width * state.scale;
534
+ const minY = height - height * state.scale;
535
+ return { scale: state.scale, x: clamp(state.x, minX, 0), y: clamp(state.y, minY, 0) };
536
+ }
537
+ function zoomAt(point, scale) {
538
+ return { scale, x: point.x - point.x * scale, y: point.y - point.y * scale };
539
+ }
540
+ function settle(height, velocity, snaps) {
541
+ const sorted = [...snaps].sort((a, b) => a - b);
542
+ if (Math.abs(velocity) > 0.5) {
543
+ if (velocity > 0) return sorted.find((snap) => snap > height + 1) ?? sorted[sorted.length - 1];
544
+ return [...sorted].reverse().find((snap) => snap < height - 1) ?? sorted[0];
545
+ }
546
+ return sorted.reduce((best, snap) => Math.abs(snap - height) < Math.abs(best - height) ? snap : best, sorted[0]);
547
+ }
548
+ function stepOf(step, min, max, integer) {
549
+ if (step !== void 0 && step > 0) return step;
550
+ if (integer) return 1;
551
+ if (min !== void 0 && max !== void 0 && max > min) return niceStep((max - min) / 100);
552
+ return 1;
553
+ }
554
+ function niceStep(value) {
555
+ const power = 10 ** Math.floor(Math.log10(value));
556
+ const fraction = value / power;
557
+ return (fraction < 1.5 ? 1 : fraction < 3.5 ? 2 : fraction < 7.5 ? 5 : 10) * power;
558
+ }
559
+ function stepValue(value, direction, step, min, max) {
560
+ const decimals = Math.max(0, -Math.floor(Math.log10(step)) + 2);
561
+ const next = Number((value + direction * step).toFixed(Math.min(10, decimals)));
562
+ return clamp(next, min ?? -Infinity, max ?? Infinity);
563
+ }
564
+ function notch(value, min, max) {
565
+ return max > min ? Math.round((value - min) / (max - min) * 10) : 0;
566
+ }
567
+ function clamp(value, min, max) {
568
+ return Math.min(max, Math.max(min, value));
569
+ }
570
+ function attachZoom(surface, layer, enabled) {
571
+ let state = FIT;
572
+ const pointers = /* @__PURE__ */ new Map();
573
+ let gesture;
574
+ let lastTap = { time: 0, x: 0, y: 0 };
575
+ let moved = false;
576
+ const apply = (next, animate = false) => {
577
+ const box = surface.getBoundingClientRect();
578
+ state = bound(next, box.width, box.height);
579
+ layer.style.transition = animate ? "transform 180ms ease-out" : "";
580
+ layer.style.transform = state.scale === 1 ? "" : `translate(${state.x}px,${state.y}px) scale(${state.scale})`;
581
+ };
582
+ const local = (event) => {
583
+ const box = surface.getBoundingClientRect();
584
+ return { x: event.clientX - box.left, y: event.clientY - box.top };
585
+ };
586
+ const begin = () => {
587
+ const points = [...pointers.values()];
588
+ if (points.length >= 2) {
589
+ const [a, b] = points;
590
+ gesture = { start: state, mid: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }, distance: Math.hypot(a.x - b.x, a.y - b.y) };
591
+ } else if (points.length === 1) {
592
+ gesture = { start: state, mid: points[0], distance: 0 };
593
+ } else gesture = void 0;
594
+ };
595
+ surface.addEventListener("pointerdown", (event) => {
596
+ if (!enabled()) return;
597
+ surface.setPointerCapture?.(event.pointerId);
598
+ pointers.set(event.pointerId, local(event));
599
+ moved = false;
600
+ begin();
601
+ });
602
+ surface.addEventListener("pointermove", (event) => {
603
+ if (!pointers.has(event.pointerId) || !gesture) return;
604
+ pointers.set(event.pointerId, local(event));
605
+ const points = [...pointers.values()];
606
+ if (points.length >= 2 && gesture.distance > 0) {
607
+ const [a, b] = points;
608
+ moved = true;
609
+ apply(pinch(gesture.start, gesture, { mid: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }, distance: Math.hypot(a.x - b.x, a.y - b.y) }));
610
+ } else if (points.length === 1 && gesture.start.scale > 1) {
611
+ const point = points[0];
612
+ if (Math.hypot(point.x - gesture.mid.x, point.y - gesture.mid.y) > 4) moved = true;
613
+ apply({ ...gesture.start, x: gesture.start.x + point.x - gesture.mid.x, y: gesture.start.y + point.y - gesture.mid.y });
614
+ }
615
+ });
616
+ const end = (event) => {
617
+ if (!pointers.has(event.pointerId)) return;
618
+ const point = local(event);
619
+ pointers.delete(event.pointerId);
620
+ begin();
621
+ if (pointers.size || moved || event.type === "pointercancel") return;
622
+ const now = performance.now();
623
+ if (now - lastTap.time < 320 && Math.hypot(point.x - lastTap.x, point.y - lastTap.y) < 30) {
624
+ apply(state.scale > 1 ? FIT : zoomAt(point, 2.5), true);
625
+ lastTap = { time: 0, x: 0, y: 0 };
626
+ } else lastTap = { time: now, x: point.x, y: point.y };
627
+ };
628
+ surface.addEventListener("pointerup", end);
629
+ surface.addEventListener("pointercancel", end);
630
+ surface.addEventListener("gesturestart", (event) => {
631
+ if (enabled()) event.preventDefault();
632
+ });
633
+ return { reset: () => apply(FIT) };
634
+ }
635
+ function attachSheet(grip, options) {
636
+ let drag;
637
+ grip.addEventListener("pointerdown", (event) => {
638
+ if (!options.enabled()) return;
639
+ if (event.target?.closest?.("input,select,textarea,button")) return;
640
+ grip.setPointerCapture?.(event.pointerId);
641
+ drag = { id: event.pointerId, y: event.clientY, height: options.height(), time: performance.now(), velocity: 0, last: event.clientY, moved: false };
642
+ });
643
+ grip.addEventListener("pointermove", (event) => {
644
+ if (!drag || event.pointerId !== drag.id) return;
645
+ const now = performance.now();
646
+ const dt = Math.max(1, now - drag.time);
647
+ drag.velocity = (drag.last - event.clientY) / dt;
648
+ drag.time = now;
649
+ drag.last = event.clientY;
650
+ if (Math.abs(event.clientY - drag.y) > 4) drag.moved = true;
651
+ const snaps = options.snaps();
652
+ const height = clamp(drag.height + drag.y - event.clientY, Math.min(...snaps) - 24, Math.max(...snaps));
653
+ options.onHeight(height, false);
654
+ });
655
+ const end = (event) => {
656
+ if (!drag || event.pointerId !== drag.id) return;
657
+ const snaps = [...options.snaps()].sort((a, b) => a - b);
658
+ if (!drag.moved) {
659
+ const current = options.height();
660
+ const next = snaps.find((snap) => snap > current + 1) ?? snaps[0];
661
+ options.onHeight(next, true);
662
+ } else options.onHeight(settle(options.height(), drag.velocity, snaps), true);
663
+ drag = void 0;
664
+ };
665
+ grip.addEventListener("pointerup", end);
666
+ grip.addEventListener("pointercancel", end);
667
+ }
668
+ async function shareFile(name, blob, fallback) {
669
+ const nav = globalThis.navigator;
670
+ if (nav && typeof nav.share === "function" && typeof File !== "undefined") {
671
+ const file = new File([blob], name, { type: blob.type || "application/octet-stream" });
672
+ if (nav.canShare?.({ files: [file] })) {
673
+ try {
674
+ await nav.share({ files: [file], title: name });
675
+ return "shared";
676
+ } catch (error) {
677
+ const kind = error?.name;
678
+ if (kind === "AbortError") return "cancelled";
679
+ if (kind === "NotAllowedError") return "needs-tap";
680
+ }
681
+ }
682
+ }
683
+ fallback();
684
+ return "downloaded";
685
+ }
686
+ function tick() {
687
+ try {
688
+ globalThis.navigator?.vibrate?.(4);
689
+ } catch {
690
+ }
691
+ }
692
+
693
+ // src/player/editor.ts
694
+ var STYLE = `
695
+ :root{--bg:#f4f4f2;--panel:#fff;--ink:#1c1c1c;--muted:#6b6b6b;--line:#dededa;--accent:#1c1c1c;--field:#f7f7f5;--warn:#8a5a00;--bad:#a1261b}
696
+ @media (prefers-color-scheme:dark){:root:not([data-theme="light"]){--bg:#151515;--panel:#1d1d1d;--ink:#ececec;--muted:#9a9a9a;--line:#2f2f2f;--accent:#ececec;--field:#232323;--warn:#e0b050;--bad:#ff8a7a}}
697
+ :root[data-theme="dark"]{--bg:#151515;--panel:#1d1d1d;--ink:#ececec;--muted:#9a9a9a;--line:#2f2f2f;--accent:#ececec;--field:#232323;--warn:#e0b050;--bad:#ff8a7a}
698
+ *{box-sizing:border-box}html,body{margin:0;height:100%}
699
+ body{background:var(--bg);color:var(--ink);font:13px/1.4 system-ui,-apple-system,"Segoe UI",sans-serif}
700
+ .ce{display:grid;grid-template-rows:auto 1fr;height:100vh;height:100dvh}
701
+ .ce-bar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:8px 16px;border-bottom:1px solid var(--line);background:var(--panel)}
702
+ .ce-bar h1{font-size:14px;font-weight:600;margin:0 8px 0 0}
703
+ .ce-bar .sp{flex:1}
704
+ .ce-status{color:var(--muted);font-variant-numeric:tabular-nums}
705
+ .ce-main{display:grid;grid-template-columns:1fr 340px;min-height:0}
706
+ .ce-view{position:relative;display:grid;place-items:center;overflow:auto;min-width:0;min-height:0;padding:16px}
707
+ .ce-view img,.ce-view canvas{max-width:100%;max-height:100%;display:block;box-shadow:0 0 0 1px var(--line)}
708
+ .ce-side{overflow:auto;border-left:1px solid var(--line);background:var(--panel)}
709
+ .ce-group{border-bottom:1px solid var(--line)}
710
+ .ce-group>summary{cursor:pointer;padding:10px 16px;font-weight:600;list-style:none}
711
+ .ce-group>summary::-webkit-details-marker{display:none}
712
+ .ce-group>div{padding:0 16px 12px;display:grid;gap:10px}
713
+ .ce-p{display:grid;gap:4px}
714
+ .ce-p label,.ce-p .lab{display:flex;justify-content:space-between;gap:8px;color:var(--muted)}
715
+ .ce-p .help{color:var(--muted);font-size:11px}
716
+ .ce-row{display:flex;gap:6px;align-items:center}
717
+ .ce-row input[type=range]{flex:1;min-width:0}
718
+ input,select,textarea,button{font:inherit;color:var(--ink)}
719
+ input[type=number],input[type=text],select,textarea{background:var(--field);border:1px solid var(--line);border-radius:4px;padding:4px 6px;min-width:0}
720
+ input[type=number]{width:84px}
721
+ textarea{width:100%;resize:vertical;min-height:52px}
722
+ button{background:var(--field);border:1px solid var(--line);border-radius:4px;padding:4px 10px;cursor:pointer}
723
+ button:hover{border-color:var(--muted)}
724
+ button.primary{background:var(--accent);color:var(--bg);border-color:var(--accent)}
725
+ .ce-drop{border:1px dashed var(--line);border-radius:4px;padding:8px;color:var(--muted);text-align:center;cursor:pointer}
726
+ .ce-drop.over{border-color:var(--ink);color:var(--ink)}
727
+ .ce-msg{padding:6px 16px;white-space:pre-wrap}
728
+ .ce-msg.warn{color:var(--warn)}.ce-msg.bad{color:var(--bad)}
729
+ .ce-inspect{position:absolute;inset:0;overflow:auto;background:var(--bg);padding:16px;display:none;gap:12px;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));align-content:start}
730
+ .ce-inspect.on{display:grid}
731
+ .ce-stage{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:8px;display:grid;gap:6px;min-width:0}
732
+ .ce-stage h3{margin:0;font-size:12px}.ce-stage .mod{color:var(--muted);font-size:11px}
733
+ .ce-stage .out{display:grid;gap:4px}.ce-stage .out>span{color:var(--muted);font-size:11px}
734
+ .ce-stage img,.ce-stage canvas{width:100%;height:140px;object-fit:contain;background:var(--field);display:block}
735
+ .ce-stage pre{margin:0;max-height:140px;overflow:auto;font-size:11px;background:var(--field);padding:6px}
736
+ .ce-indent{margin-left:12px}
737
+
738
+ /* Phones (.m, set from a media query): the preview on top, controls in a bottom sheet, one-row header. */
739
+ .ce-grip,.ce-more,.ce-menu,.ce-busy,.ce-step,.ce-toast{display:none}
740
+ .ce.m{overflow:hidden;padding:0 env(safe-area-inset-right) 0 env(safe-area-inset-left)}
741
+ .ce.m .ce-bar{flex-wrap:nowrap;gap:4px;min-height:52px;padding:calc(4px + env(safe-area-inset-top)) 4px 4px 16px}
742
+ .ce.m .ce-bar>*:not(h1):not(.ce-more):not(.ce-busy){display:none}
743
+ .ce.m .ce-bar h1{flex:1;min-width:0;font-size:17px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
744
+ .ce.m .ce-more{display:inline-grid;place-items:center;width:44px;height:44px;padding:0;border:0;background:transparent;font-size:24px;line-height:1;letter-spacing:1px}
745
+ .ce.m .ce-busy{display:block;flex:none;width:8px;height:8px;border-radius:50%;background:var(--muted);opacity:0;transition:opacity .2s}
746
+ .ce.m.busy .ce-busy{opacity:1;animation:ce-pulse 1s ease-in-out infinite}
747
+ @keyframes ce-pulse{50%{opacity:.2}}
748
+ .ce.m .ce-main{display:block;position:relative;min-height:0}
749
+ .ce.m .ce-view{position:absolute;inset:0 0 var(--ce-cut,45%) 0;display:block;padding:0;overflow:hidden}
750
+ .ce.m .ce-out{position:absolute;inset:8px 8px 12px;overflow:hidden;touch-action:none;-webkit-user-select:none;user-select:none}
751
+ .ce.m .ce-zoom{width:100%;height:100%;display:flex;align-items:center;justify-content:center;transform-origin:0 0}
752
+ .ce.m .ce-zoom>img,.ce.m .ce-zoom>canvas{max-width:100%;max-height:100%;-webkit-user-drag:none}
753
+ .ce.m .ce-inspect{grid-template-columns:1fr;padding:12px;touch-action:pan-y;z-index:1}
754
+ .ce.m .ce-stage img,.ce.m .ce-stage canvas{height:200px}
755
+ .ce.m .ce-side{position:fixed;left:0;right:0;bottom:0;z-index:5;height:var(--ce-sheet,45%);display:flex;flex-direction:column;overflow:hidden;border:0;border-top:1px solid var(--line);border-radius:14px 14px 0 0;box-shadow:0 -4px 24px rgba(0,0,0,.12);padding:0 env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)}
756
+ .ce.m .ce-side.settle{transition:height .24s cubic-bezier(.2,.8,.2,1)}
757
+ .ce.m .ce-head{flex:none;touch-action:none;-webkit-user-select:none;user-select:none}
758
+ .ce.m .ce-head textarea,.ce.m .ce-head input{-webkit-user-select:text;user-select:text}
759
+ .ce.m .ce-grip{display:block;height:24px;cursor:grab}
760
+ .ce.m .ce-grip::before{content:"";display:block;width:40px;height:5px;border-radius:3px;background:var(--line);margin:10px auto 0}
761
+ .ce.m .ce-peek{display:grid;gap:10px;padding:0 16px 12px}
762
+ .ce.m .ce-peek:empty{display:none}
763
+ .ce.m .ce-peek .help{display:none}
764
+ .ce.m .ce-msg{max-height:25vh;overflow:auto;padding:4px 16px 8px}
765
+ .ce.m .ce-body{flex:1;min-height:0;overflow:auto;overscroll-behavior:contain;border-top:1px solid var(--line)}
766
+ .ce.m.land .ce-main{display:grid;grid-template-columns:1fr min(340px,46vw)}
767
+ .ce.m.land .ce-view{position:relative;inset:auto;height:100%}
768
+ .ce.m.land .ce-side{position:static;height:auto;min-height:0;box-shadow:none;border-radius:0;border-left:1px solid var(--line);border-top:0;padding:0 0 env(safe-area-inset-bottom)}
769
+ .ce.m.land .ce-grip{display:none}.ce.m.land .ce-head{touch-action:auto;padding-top:10px}
770
+ .ce.m input,.ce.m select,.ce.m textarea,.ce.m button{font-size:16px}
771
+ .ce.m button,.ce.m select,.ce.m input[type=number],.ce.m input[type=text]{min-height:44px}
772
+ .ce.m button{padding:0 14px;border-radius:8px}
773
+ .ce.m select,.ce.m input[type=number],.ce.m input[type=text],.ce.m textarea{border-radius:8px;padding:6px 10px}
774
+ .ce.m input[type=number]{width:76px}
775
+ .ce.m select{-webkit-appearance:none;appearance:none;height:44px;padding-right:32px;background-image:linear-gradient(45deg,transparent 50%,var(--muted) 50%),linear-gradient(135deg,var(--muted) 50%,transparent 50%);background-position:calc(100% - 19px) 52%,calc(100% - 14px) 52%;background-size:5px 5px;background-repeat:no-repeat}
776
+ .ce.m .ce-step{display:inline-grid;place-items:center;flex:none;width:44px;padding:0;font-size:22px;line-height:1}
777
+ .ce.m .ce-group>summary{display:flex;align-items:center;min-height:48px;font-size:15px}
778
+ .ce.m .ce-group>summary::after{content:"";margin-left:auto;width:8px;height:8px;border-right:2px solid var(--muted);border-bottom:2px solid var(--muted);transform:translateY(-2px) rotate(45deg);transition:transform .2s}
779
+ .ce.m .ce-group[open]>summary::after{transform:translateY(2px) rotate(-135deg)}
780
+ .ce.m .ce-group>div{gap:14px;padding-bottom:16px}
781
+ .ce.m .ce-p label,.ce.m .ce-p .lab{font-size:14px;align-items:center}
782
+ .ce.m .ce-row{gap:8px}
783
+ .ce.m input[type=range]{-webkit-appearance:none;appearance:none;height:44px;margin:0;background:transparent;touch-action:pan-y}
784
+ .ce.m input[type=range]::-webkit-slider-runnable-track{height:4px;border-radius:2px;background:var(--line)}
785
+ .ce.m input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:28px;height:28px;margin-top:-12px;border-radius:50%;background:var(--panel);border:1px solid var(--muted);box-shadow:0 1px 4px rgba(0,0,0,.25)}
786
+ .ce.m input[type=range]::-moz-range-track{height:4px;border-radius:2px;background:var(--line)}
787
+ .ce.m input[type=range]::-moz-range-thumb{width:28px;height:28px;border-radius:50%;background:var(--panel);border:1px solid var(--muted)}
788
+ .ce.m input[type=checkbox]{width:24px;height:24px;margin:0 6px 0 0}
789
+ .ce.m .ce-p>label:has(input[type=checkbox]){min-height:44px}
790
+ .ce.m input[type=color]{width:56px;height:44px;padding:2px;border:1px solid var(--line);border-radius:8px;background:var(--field)}
791
+ .ce.m .ce-drop{display:grid;place-items:center;min-height:56px;border-radius:8px}
792
+ .ce.m .ce-menu.on{display:grid;gap:2px;position:fixed;z-index:20;top:calc(52px + env(safe-area-inset-top));right:calc(8px + env(safe-area-inset-right));width:min(280px,calc(100vw - 16px));max-height:calc(100dvh - 72px - env(safe-area-inset-top));overflow:auto;padding:6px;background:var(--panel);border:1px solid var(--line);border-radius:12px;box-shadow:0 12px 32px rgba(0,0,0,.22)}
793
+ .ce-menu button{width:100%;text-align:left;border:0;background:transparent}
794
+ .ce-menu select{width:100%}
795
+ .ce-menu hr{border:0;border-top:1px solid var(--line);margin:4px 0;width:100%}
796
+ .ce-menu .meta{color:var(--muted);padding:8px 14px;font-size:13px;font-variant-numeric:tabular-nums}
797
+ .ce.m .ce-toast{display:flex;align-items:center;gap:8px;position:fixed;z-index:15;left:50%;bottom:calc(var(--ce-sheet,45%) + 12px);transform:translateX(-50%);max-width:calc(100vw - 32px);padding:8px 8px 8px 16px;border-radius:22px;background:var(--ink);color:var(--bg);font-size:14px;opacity:0;pointer-events:none;transition:opacity .2s}
798
+ .ce.m.land .ce-toast{bottom:calc(16px + env(safe-area-inset-bottom))}
799
+ .ce.m .ce-toast.on{opacity:1;pointer-events:auto}
800
+ .ce.m .ce-toast button{min-height:36px;color:var(--bg);background:transparent;border-color:var(--bg)}
801
+ .ce.m .ce-toast button[hidden]{display:none}
802
+ `;
803
+ var PHONE = "(max-width: 760px), (pointer: coarse) and (max-height: 540px)";
804
+ async function startEditor(options) {
805
+ const document = options.document;
806
+ const editor = document.editor;
807
+ if (!editor) throw new Error("This export has no editor declaration");
808
+ const host = options.root?.ownerDocument ?? globalThis.document;
809
+ const root = options.root ?? host.body;
810
+ let engine;
811
+ try {
812
+ engine = options.worker && typeof Worker !== "undefined" ? await createWorkerEngine(options.worker, { document, assets: options.assets }) : await LocalEngine.create({ document, assets: options.assets, registrations: options.registrations ?? [] });
813
+ const controller = new EditorController(document, editor, engine, root);
814
+ await controller.start(options.panel);
815
+ return controller;
816
+ } catch (error) {
817
+ await engine?.dispose().catch(() => {
818
+ });
819
+ root.replaceChildren(message(host, asError(error).message, "bad"));
820
+ throw error;
821
+ }
822
+ }
823
+ var EditorController = class {
824
+ constructor(document, editor, engine, root) {
825
+ this.document = document;
826
+ this.editor = editor;
827
+ this.engine = engine;
828
+ this.root = root;
829
+ this.values = {};
830
+ this.files = /* @__PURE__ */ new Map();
831
+ this.listeners = /* @__PURE__ */ new Set();
832
+ this.rows = /* @__PURE__ */ new Map();
833
+ this.propTypes = /* @__PURE__ */ new Map();
834
+ this.tail = Promise.resolve();
835
+ this.queued = false;
836
+ this.pending = /* @__PURE__ */ new Map();
837
+ this.inspecting = false;
838
+ this.nodes = [];
839
+ /** Phone layout, and whether it is the landscape (side panel) form. */
840
+ this.mobile = false;
841
+ this.landscape = false;
842
+ this.sheetHeight = 0;
843
+ this.snapIndex = 0;
844
+ this.peekSlots = /* @__PURE__ */ new Map();
845
+ this.groupsFolded = false;
846
+ this.draftWritten = false;
847
+ this.inspectReleases = [];
848
+ this.parameters = [...editor.parameters ?? []].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
849
+ this.byId = new Map(this.parameters.map((parameter) => [parameter.id, parameter]));
850
+ this.defaults = publishedValues(document);
851
+ this.values = { ...this.defaults };
852
+ this.view = (editor.outputs ?? []).find((output) => output.view) ?? editor.outputs?.[0];
853
+ this.storageKey = `cascade-editor:${editor.id}:${editorGraphVersion(document)}`;
854
+ }
855
+ async start(panel) {
856
+ const inspected = /* @__PURE__ */ new Map();
857
+ this.nodes = await this.engine.nodes();
858
+ for (const node of this.nodes)
859
+ for (const [name, prop] of Object.entries(node.props)) {
860
+ this.propTypes.set(`${node.id}\0${name}`, prop.type);
861
+ inspected.set(`${node.id}\0${name}`, prop.value);
862
+ }
863
+ for (const parameter of this.parameters)
864
+ if (this.defaults[parameter.id] === void 0) {
865
+ const value = inspected.get(`${parameter.node}\0${parameter.prop}`);
866
+ if (value !== void 0) this.defaults[parameter.id] = JSON.parse(JSON.stringify(value));
867
+ }
868
+ this.values = { ...this.defaults };
869
+ this.ui = this.build();
870
+ const remembered = readLocal(this.storageKey);
871
+ if (remembered) {
872
+ const resolved = resolveScene(this.document, remembered);
873
+ await this.assign(resolved.values);
874
+ await this.restoreFiles(remembered.files ?? {}, false);
875
+ }
876
+ await this.restoreRememberedFiles();
877
+ this.renderParameters();
878
+ if (panel) await this.mountPanel(panel);
879
+ this.ui.inspectToggle.hidden = this.editor.inspect === false;
880
+ this.ui.menuInspect.hidden = this.editor.inspect === false;
881
+ this.setupTouch();
882
+ await this.cook();
883
+ }
884
+ control(parameter) {
885
+ return controlOf(parameter, this.defaults[parameter.id], this.propTypes.get(`${parameter.node}\0${parameter.prop}`));
886
+ }
887
+ /** The panel API, also handy from the console. */
888
+ api() {
889
+ return {
890
+ editor: this.editor,
891
+ get: (id) => this.values[id],
892
+ set: (id, value) => this.set(id, value),
893
+ values: () => ({ ...this.values }),
894
+ subscribe: (listener) => {
895
+ this.listeners.add(listener);
896
+ return () => this.listeners.delete(listener);
897
+ },
898
+ output: async (id) => {
899
+ const output = (this.editor.outputs ?? []).find((item) => item.id === id);
900
+ if (!output) throw new Error(`Unknown output ${id}`);
901
+ return this.exclusive(async () => {
902
+ await this.runTarget(output);
903
+ return this.engine.output(output.node, output.port);
904
+ });
905
+ }
906
+ };
907
+ }
908
+ /**
909
+ * Set a published value. The write waits for the graph to be idle: a graph
910
+ * that is cooking refuses edits, and a slider dragged during a slow cook
911
+ * would otherwise throw on every step. Resolves once the change is drawn.
912
+ */
913
+ set(id, value) {
914
+ if (!this.byId.has(id)) return Promise.reject(new Error(`Unknown parameter ${id}`));
915
+ this.values[id] = value;
916
+ this.pending.set(id, value);
917
+ return this.changed([id]);
918
+ }
919
+ async dispose() {
920
+ this.viewRelease?.();
921
+ this.panelDispose?.();
922
+ await this.engine.dispose();
923
+ }
924
+ // ---------------------------------------------------------------- layout
925
+ build() {
926
+ const d = this.root.ownerDocument;
927
+ const style = d.createElement("style");
928
+ style.textContent = STYLE;
929
+ d.head.append(style);
930
+ d.title = this.editor.title ?? this.editor.id;
931
+ const shell = el(d, "div", "ce");
932
+ const bar = el(d, "header", "ce-bar");
933
+ const title = el(d, "h1");
934
+ title.textContent = this.editor.title ?? this.editor.id;
935
+ const presets = d.createElement("select");
936
+ presets.setAttribute("aria-label", "Preset");
937
+ presets.append(option(d, "", "Presets\u2026"), option(d, "__defaults", "Graph defaults"));
938
+ for (const preset of this.editor.presets ?? []) presets.append(option(d, preset.id, preset.label));
939
+ presets.addEventListener("change", () => {
940
+ const id = presets.value;
941
+ presets.value = "";
942
+ if (id) void this.applyPreset(id).catch((error) => this.report(asError(error).message, "bad"));
943
+ });
944
+ const open = button(d, "Open scene\u2026");
945
+ const sceneInput = d.createElement("input");
946
+ sceneInput.type = "file";
947
+ sceneInput.accept = ".json,application/json";
948
+ sceneInput.hidden = true;
949
+ open.addEventListener("click", () => sceneInput.click());
950
+ sceneInput.addEventListener("change", () => {
951
+ const file = sceneInput.files?.[0];
952
+ sceneInput.value = "";
953
+ if (file) void this.openScene(file).catch((error) => this.report(asError(error).message, "bad"));
954
+ });
955
+ const save = button(d, "Save scene");
956
+ save.addEventListener("click", () => void this.saveScene().catch((error) => this.report(asError(error).message, "bad")));
957
+ const inspectToggle = el(d, "label");
958
+ const inspectBox = d.createElement("input");
959
+ inspectBox.type = "checkbox";
960
+ inspectToggle.append(inspectBox, d.createTextNode(" Inspect stages"));
961
+ inspectBox.addEventListener("change", () => {
962
+ this.inspecting = inspectBox.checked;
963
+ inspect.classList.toggle("on", this.inspecting);
964
+ menuInspect.setAttribute("aria-checked", String(this.inspecting));
965
+ menuInspect.textContent = this.inspecting ? "Inspect stages \u2713" : "Inspect stages";
966
+ if (this.inspecting) void this.cook().catch(() => {
967
+ });
968
+ });
969
+ const status = el(d, "span", "ce-status");
970
+ const busy = el(d, "span", "ce-busy");
971
+ busy.setAttribute("aria-hidden", "true");
972
+ const more = button(d, "\u22EF");
973
+ more.className = "ce-more";
974
+ more.setAttribute("aria-label", "Menu");
975
+ more.setAttribute("aria-haspopup", "menu");
976
+ bar.append(title, presets, open, save, sceneInput, el(d, "span", "sp"), inspectToggle, status, busy, more);
977
+ const menu = el(d, "div", "ce-menu");
978
+ menu.setAttribute("role", "menu");
979
+ const closeMenu = () => {
980
+ menu.classList.remove("on");
981
+ more.setAttribute("aria-expanded", "false");
982
+ };
983
+ more.addEventListener("click", () => {
984
+ const on = menu.classList.toggle("on");
985
+ more.setAttribute("aria-expanded", String(on));
986
+ });
987
+ d.addEventListener("pointerdown", (event) => {
988
+ const target = event.target;
989
+ if (menu.classList.contains("on") && target && !menu.contains(target) && !more.contains(target)) closeMenu();
990
+ });
991
+ const menuPresets = presets.cloneNode(true);
992
+ menuPresets.addEventListener("change", () => {
993
+ const id = menuPresets.value;
994
+ menuPresets.value = "";
995
+ closeMenu();
996
+ if (id) void this.applyPreset(id).catch((error) => this.report(asError(error).message, "bad"));
997
+ });
998
+ const menuItem = (text, run) => {
999
+ const item = button(d, text);
1000
+ item.setAttribute("role", "menuitem");
1001
+ item.addEventListener("click", () => {
1002
+ closeMenu();
1003
+ run();
1004
+ });
1005
+ return item;
1006
+ };
1007
+ const menuInspect = menuItem("Inspect stages", () => {
1008
+ inspectBox.checked = !inspectBox.checked;
1009
+ inspectBox.dispatchEvent(new Event("change"));
1010
+ });
1011
+ menuInspect.setAttribute("role", "menuitemcheckbox");
1012
+ menuInspect.setAttribute("aria-checked", "false");
1013
+ const menuStatus = el(d, "div", "meta");
1014
+ menu.append(
1015
+ menuPresets,
1016
+ menuItem("Open scene\u2026", () => sceneInput.click()),
1017
+ menuItem("Save scene", () => void this.saveScene().catch((error) => this.report(asError(error).message, "bad"))),
1018
+ menuInspect
1019
+ );
1020
+ if ((this.editor.outputs ?? []).length) menu.append(el(d, "hr"));
1021
+ for (const output2 of this.editor.outputs ?? [])
1022
+ menu.append(menuItem(output2.label ?? `Export ${output2.id}`, () => void this.exportOutput(output2).catch((error) => this.report(asError(error).message, "bad"))));
1023
+ menu.append(el(d, "hr"), menuStatus);
1024
+ const main = el(d, "div", "ce-main");
1025
+ const viewer = el(d, "section", "ce-view");
1026
+ const surface = el(d, "div", "ce-out");
1027
+ const output = el(d, "div", "ce-zoom");
1028
+ surface.append(output);
1029
+ const inspect = el(d, "div", "ce-inspect");
1030
+ viewer.append(surface, inspect);
1031
+ const side = el(d, "aside", "ce-side");
1032
+ const head = el(d, "div", "ce-head");
1033
+ const grip = el(d, "div", "ce-grip");
1034
+ grip.setAttribute("aria-hidden", "true");
1035
+ const messages = el(d, "div");
1036
+ const peek = el(d, "div", "ce-peek");
1037
+ head.append(grip, messages, peek);
1038
+ const body = el(d, "div", "ce-body");
1039
+ const panelSlot = el(d, "div");
1040
+ const groups = el(d, "div");
1041
+ body.append(panelSlot, groups);
1042
+ side.append(head, body);
1043
+ main.append(viewer, side);
1044
+ const toast = el(d, "div", "ce-toast");
1045
+ toast.setAttribute("role", "status");
1046
+ const toastText = el(d, "span");
1047
+ const toastAction = button(d, "");
1048
+ toastAction.hidden = true;
1049
+ toast.append(toastText, toastAction);
1050
+ shell.append(bar, main, menu, toast);
1051
+ this.root.replaceChildren(shell);
1052
+ const viewport = d.querySelector('meta[name="viewport"]');
1053
+ if (viewport && !/viewport-fit/.test(viewport.getAttribute("content") ?? ""))
1054
+ viewport.setAttribute("content", `${viewport.getAttribute("content")},viewport-fit=cover`);
1055
+ viewer.addEventListener("dragover", (event) => event.preventDefault());
1056
+ viewer.addEventListener("drop", (event) => {
1057
+ event.preventDefault();
1058
+ const file = event.dataTransfer?.files?.[0];
1059
+ if (file) void this.dropAnywhere(file).catch((error) => this.report(asError(error).message, "bad"));
1060
+ });
1061
+ return { shell, status, output, surface, viewer, inspect, inspectToggle, messages, panelSlot, groups, bar, side, head, peek, menuInspect, menuStatus, toast, toastText, toastAction };
1062
+ }
1063
+ renderParameters() {
1064
+ const d = this.root.ownerDocument;
1065
+ const outputParameters = new Set((this.editor.outputs ?? []).flatMap((output) => output.parameters ?? []));
1066
+ const order = [...this.editor.groups ?? []];
1067
+ for (const parameter of this.parameters) {
1068
+ const group = parameter.group ?? "Parameters";
1069
+ if (!outputParameters.has(parameter.id) && !order.includes(group)) order.push(group);
1070
+ }
1071
+ const exportGroup = "Export";
1072
+ if (!order.includes(exportGroup)) order.push(exportGroup);
1073
+ this.ui.groups.replaceChildren();
1074
+ for (const group of order) {
1075
+ const details = d.createElement("details");
1076
+ details.className = "ce-group";
1077
+ details.open = true;
1078
+ const summary = d.createElement("summary");
1079
+ summary.textContent = group;
1080
+ const body = el(d, "div");
1081
+ details.append(summary, body);
1082
+ for (const parameter of this.parameters)
1083
+ if ((parameter.group ?? "Parameters") === group && !outputParameters.has(parameter.id)) body.append(this.row(parameter));
1084
+ if (group === exportGroup) {
1085
+ for (const output of this.editor.outputs ?? []) {
1086
+ for (const id of output.parameters ?? []) {
1087
+ const parameter = this.byId.get(id);
1088
+ if (parameter && !this.rows.has(id)) body.append(this.row(parameter));
1089
+ }
1090
+ const action = button(d, `Export ${output.label ?? output.id}`);
1091
+ action.addEventListener("click", () => void this.exportOutput(output).catch((error) => this.report(asError(error).message, "bad")));
1092
+ body.append(action);
1093
+ }
1094
+ }
1095
+ if (body.childElementCount) this.ui.groups.append(details);
1096
+ }
1097
+ this.refreshVisibility();
1098
+ }
1099
+ row(parameter) {
1100
+ const d = this.root.ownerDocument;
1101
+ const wrap = el(d, "div", "ce-p");
1102
+ const label = el(d, "label");
1103
+ const name = el(d, "span");
1104
+ name.textContent = parameter.label ?? parameter.id;
1105
+ const readout = el(d, "span");
1106
+ label.append(name, readout);
1107
+ wrap.append(label);
1108
+ const control = this.control(parameter);
1109
+ const commit = (value) => void this.set(parameter.id, value).catch((error) => this.report(asError(error).message, "bad"));
1110
+ let refresh = () => {
1111
+ };
1112
+ const unit = parameter.unit ? ` ${parameter.unit}` : "";
1113
+ if (control === "number" || control === "slider" || control === "int" || control === "seed") {
1114
+ const integer = control === "int" || control === "seed";
1115
+ const line = el(d, "div", "ce-row");
1116
+ const range = d.createElement("input");
1117
+ range.type = "range";
1118
+ const number = d.createElement("input");
1119
+ number.type = "number";
1120
+ const step = parameter.step ?? (integer ? 1 : "any");
1121
+ for (const input of [range, number]) {
1122
+ if (parameter.min !== void 0) input.min = String(parameter.min);
1123
+ if (parameter.max !== void 0) input.max = String(parameter.max);
1124
+ input.step = String(step);
1125
+ }
1126
+ if (range.step === "any") range.step = String(((parameter.max ?? 1) - (parameter.min ?? 0)) / 1e3);
1127
+ const hasRange = parameter.min !== void 0 && parameter.max !== void 0 && control !== "seed";
1128
+ if (hasRange) line.append(range);
1129
+ line.append(number);
1130
+ if (unit) line.append(d.createTextNode(unit));
1131
+ if (integer && (parameter.min ?? 0) >= 0) number.inputMode = "numeric";
1132
+ else if ((parameter.min ?? 0) >= 0) number.inputMode = "decimal";
1133
+ const stepSize = stepOf(parameter.step, parameter.min, parameter.max, integer);
1134
+ if (control !== "seed") {
1135
+ for (const direction of [-1, 1]) {
1136
+ const stepper = button(d, direction < 0 ? "\u2212" : "+");
1137
+ stepper.className = "ce-step";
1138
+ stepper.setAttribute("aria-label", `${direction < 0 ? "Decrease" : "Increase"} ${parameter.label ?? parameter.id}`);
1139
+ stepper.addEventListener("click", () => {
1140
+ const current = Number(this.values[parameter.id] ?? parameter.min ?? 0);
1141
+ const value = stepValue(Number.isFinite(current) ? current : 0, direction, stepSize, parameter.min, parameter.max);
1142
+ number.value = String(value);
1143
+ range.value = String(value);
1144
+ tick();
1145
+ commit(value);
1146
+ });
1147
+ line.append(stepper);
1148
+ }
1149
+ }
1150
+ let lastNotch = Number.NaN;
1151
+ const begin = () => this.beginDraft(parameter.id);
1152
+ const end = () => this.endDraft();
1153
+ range.addEventListener("pointerdown", begin);
1154
+ range.addEventListener("touchstart", begin, { passive: true });
1155
+ for (const type of ["pointerup", "pointercancel", "touchend", "touchcancel", "change"]) range.addEventListener(type, end);
1156
+ const parse = (text) => {
1157
+ const value = Number(text);
1158
+ return Number.isFinite(value) ? integer ? Math.round(value) : value : void 0;
1159
+ };
1160
+ range.addEventListener("input", () => {
1161
+ const value = parse(range.value);
1162
+ if (value === void 0) return;
1163
+ number.value = String(value);
1164
+ const step2 = notch(value, parameter.min ?? 0, parameter.max ?? 1);
1165
+ if (this.mobile && step2 !== lastNotch && !Number.isNaN(lastNotch)) tick();
1166
+ lastNotch = step2;
1167
+ commit(value);
1168
+ });
1169
+ number.addEventListener("change", () => {
1170
+ const value = parse(number.value);
1171
+ if (value !== void 0) commit(value);
1172
+ });
1173
+ if (control === "seed") {
1174
+ const reroll = button(d, "Reroll");
1175
+ reroll.addEventListener("click", () => {
1176
+ const value = Math.floor(Math.random() * Math.max(2, parameter.max ?? 99999));
1177
+ number.value = String(value);
1178
+ commit(value);
1179
+ });
1180
+ line.append(reroll);
1181
+ }
1182
+ refresh = () => {
1183
+ const value = this.values[parameter.id];
1184
+ range.value = String(value ?? "");
1185
+ number.value = String(value ?? "");
1186
+ };
1187
+ wrap.append(line);
1188
+ } else if (control === "bool") {
1189
+ const box = d.createElement("input");
1190
+ box.type = "checkbox";
1191
+ box.addEventListener("change", () => commit(box.checked));
1192
+ label.prepend(box);
1193
+ label.style.justifyContent = "flex-start";
1194
+ refresh = () => {
1195
+ box.checked = this.values[parameter.id] === true;
1196
+ };
1197
+ } else if (control === "select") {
1198
+ const select = d.createElement("select");
1199
+ const options = (parameter.options ?? []).map(
1200
+ (item) => item !== null && typeof item === "object" && "value" in item ? item : { value: item, label: String(item) }
1201
+ );
1202
+ options.forEach((item, index) => {
1203
+ const entry = option(d, String(index), item.label);
1204
+ if ("disabled" in item && item.disabled) entry.disabled = true;
1205
+ select.append(entry);
1206
+ });
1207
+ select.addEventListener("change", () => commit(options[Number(select.value)]?.value));
1208
+ refresh = () => {
1209
+ select.value = String(options.findIndex((item) => Object.is(item.value, this.values[parameter.id])));
1210
+ };
1211
+ wrap.append(select);
1212
+ } else if (control === "text" || control === "textarea") {
1213
+ const input = control === "textarea" ? d.createElement("textarea") : d.createElement("input");
1214
+ if (input instanceof HTMLInputElement) input.type = "text";
1215
+ let timer;
1216
+ input.addEventListener("input", () => {
1217
+ clearTimeout(timer);
1218
+ timer = setTimeout(() => commit(input.value), 250);
1219
+ });
1220
+ refresh = () => {
1221
+ if (d.activeElement !== input) input.value = String(this.values[parameter.id] ?? "");
1222
+ };
1223
+ wrap.append(input);
1224
+ } else if (control === "color") {
1225
+ const line = el(d, "div", "ce-row");
1226
+ const picker = d.createElement("input");
1227
+ picker.type = "color";
1228
+ const hex = d.createElement("input");
1229
+ hex.type = "text";
1230
+ hex.size = 8;
1231
+ const asArray = Array.isArray(this.values[parameter.id]);
1232
+ const toValue = (text) => asArray ? hexToRgba(text) : text;
1233
+ picker.addEventListener("input", () => {
1234
+ hex.value = picker.value;
1235
+ commit(toValue(picker.value));
1236
+ });
1237
+ hex.addEventListener("change", () => {
1238
+ if (/^#[0-9a-f]{6}$/i.test(hex.value)) {
1239
+ picker.value = hex.value;
1240
+ commit(toValue(hex.value));
1241
+ }
1242
+ });
1243
+ refresh = () => {
1244
+ const value = this.values[parameter.id];
1245
+ const text = Array.isArray(value) ? rgbaToHex(value) : String(value ?? "#000000");
1246
+ picker.value = text.slice(0, 7);
1247
+ hex.value = text;
1248
+ };
1249
+ line.append(picker, hex);
1250
+ wrap.append(line);
1251
+ } else if (control === "vec2") {
1252
+ const line = el(d, "div", "ce-row");
1253
+ const inputs = [d.createElement("input"), d.createElement("input")];
1254
+ for (const input of inputs) {
1255
+ input.type = "number";
1256
+ input.step = String(parameter.step ?? "any");
1257
+ if (parameter.min !== void 0) input.min = String(parameter.min);
1258
+ if (parameter.max !== void 0) input.max = String(parameter.max);
1259
+ input.addEventListener("change", () => {
1260
+ const value = inputs.map((item) => Number(item.value));
1261
+ if (value.every(Number.isFinite)) commit(value);
1262
+ });
1263
+ line.append(input);
1264
+ }
1265
+ if (unit) line.append(d.createTextNode(unit));
1266
+ refresh = () => {
1267
+ const value = this.values[parameter.id] ?? [0, 0];
1268
+ inputs.forEach((input, index) => {
1269
+ input.value = String(value[index] ?? 0);
1270
+ });
1271
+ };
1272
+ wrap.append(line);
1273
+ } else if (control === "file") {
1274
+ const drop = el(d, "div", "ce-drop");
1275
+ const picker = d.createElement("input");
1276
+ picker.type = "file";
1277
+ picker.hidden = true;
1278
+ if (parameter.accept?.length) picker.accept = parameter.accept.join(",");
1279
+ const clear = button(d, "Use default");
1280
+ clear.addEventListener("click", () => void this.clearFile(parameter).catch((error) => this.report(asError(error).message, "bad")));
1281
+ drop.addEventListener("click", () => picker.click());
1282
+ drop.addEventListener("dragover", (event) => {
1283
+ event.preventDefault();
1284
+ drop.classList.add("over");
1285
+ });
1286
+ drop.addEventListener("dragleave", () => drop.classList.remove("over"));
1287
+ drop.addEventListener("drop", (event) => {
1288
+ event.preventDefault();
1289
+ event.stopPropagation();
1290
+ drop.classList.remove("over");
1291
+ const file = event.dataTransfer?.files?.[0];
1292
+ if (file) void this.dropFile(parameter, file).catch((error) => this.report(asError(error).message, "bad"));
1293
+ });
1294
+ picker.addEventListener("change", () => {
1295
+ const file = picker.files?.[0];
1296
+ picker.value = "";
1297
+ if (file) void this.dropFile(parameter, file).catch((error) => this.report(asError(error).message, "bad"));
1298
+ });
1299
+ refresh = () => {
1300
+ const dropped = this.files.get(parameter.id);
1301
+ const value = this.values[parameter.id];
1302
+ drop.textContent = dropped ? `${dropped.name} (stays in this browser)` : `${value?.path ? `Default: ${value.path.split("/").pop()}` : "None"}. ${this.mobile ? "Tap to choose a file" : "Drop a file or click"}`;
1303
+ clear.hidden = !dropped;
1304
+ };
1305
+ wrap.append(drop, picker, clear);
1306
+ } else {
1307
+ const pre = el(d, "pre");
1308
+ refresh = () => {
1309
+ pre.textContent = JSON.stringify(this.values[parameter.id]);
1310
+ };
1311
+ wrap.append(pre);
1312
+ }
1313
+ if (parameter.help) {
1314
+ const help = el(d, "div", "help");
1315
+ help.textContent = parameter.help;
1316
+ wrap.append(help);
1317
+ }
1318
+ if ((control === "number" || control === "slider" || control === "int") && unit === "") readout.textContent = "";
1319
+ refresh();
1320
+ this.rows.set(parameter.id, { element: wrap, refresh });
1321
+ return wrap;
1322
+ }
1323
+ refreshVisibility() {
1324
+ for (const parameter of this.parameters) {
1325
+ const row = this.rows.get(parameter.id);
1326
+ if (row) row.element.hidden = !isVisible(parameter, this.values);
1327
+ }
1328
+ }
1329
+ async mountPanel(panel) {
1330
+ const placement = this.editor.panel?.placement ?? "extend";
1331
+ if (placement === "replace") this.ui.groups.hidden = true;
1332
+ const dispose = await panel.mount(this.ui.panelSlot, this.api());
1333
+ if (typeof dispose === "function") this.panelDispose = dispose;
1334
+ }
1335
+ // ---------------------------------------------------------------- values
1336
+ changed(ids) {
1337
+ for (const id of ids) this.rows.get(id)?.refresh();
1338
+ this.refreshVisibility();
1339
+ this.remember();
1340
+ for (const listener of this.listeners) listener({ ...this.values });
1341
+ const cooked = this.cook();
1342
+ cooked.catch(() => {
1343
+ });
1344
+ return cooked;
1345
+ }
1346
+ /** Stage several values; the next cook writes them. */
1347
+ async assign(values) {
1348
+ for (const [id, value] of Object.entries(values)) {
1349
+ if (!this.byId.has(id) || value === void 0) continue;
1350
+ this.values[id] = value;
1351
+ this.pending.set(id, value);
1352
+ }
1353
+ }
1354
+ /** A phone starts dragging a slider: cook the declared draft values until it lets go. */
1355
+ beginDraft(id) {
1356
+ if (!this.mobile || this.drafting || !Object.keys(this.editor.draft ?? {}).length) return;
1357
+ this.drafting = id;
1358
+ this.draftWritten = false;
1359
+ }
1360
+ /** The drag ended: put the real values back and cook them. */
1361
+ endDraft() {
1362
+ if (!this.drafting) return;
1363
+ const written = this.draftWritten;
1364
+ this.drafting = void 0;
1365
+ this.draftWritten = false;
1366
+ if (!written) return;
1367
+ for (const id of Object.keys(this.editor.draft ?? {}))
1368
+ if (this.byId.has(id)) this.pending.set(id, this.values[id]);
1369
+ void this.cook().catch(() => {
1370
+ });
1371
+ }
1372
+ /** Write staged values into the idle graph. Only ever called inside `exclusive`. */
1373
+ async flush() {
1374
+ const writes = [...this.pending].map(([id, value]) => {
1375
+ const parameter = this.byId.get(id);
1376
+ return [parameter.node, parameter.prop, value];
1377
+ });
1378
+ this.pending.clear();
1379
+ if (this.drafting && !this.draftWritten) {
1380
+ for (const [id, value] of Object.entries(this.editor.draft ?? {})) {
1381
+ const parameter = this.byId.get(id);
1382
+ if (parameter && id !== this.drafting) writes.push([parameter.node, parameter.prop, value]);
1383
+ }
1384
+ this.draftWritten = true;
1385
+ }
1386
+ if (writes.length) await this.engine.setProps(writes);
1387
+ }
1388
+ /** Run one piece of graph work at a time, after any staged writes. */
1389
+ exclusive(work) {
1390
+ const run = this.tail.then(async () => {
1391
+ try {
1392
+ await this.flush();
1393
+ } catch (error) {
1394
+ this.report(asError(error).message, "bad");
1395
+ }
1396
+ return work();
1397
+ });
1398
+ this.tail = run.then(() => {
1399
+ }, () => {
1400
+ });
1401
+ return run;
1402
+ }
1403
+ async applyPreset(id) {
1404
+ const preset = (this.editor.presets ?? []).find((item) => item.id === id);
1405
+ const values = {};
1406
+ const keep = new Set(preset?.keep ?? []);
1407
+ for (const parameter of this.parameters)
1408
+ if (this.control(parameter) !== "file" && !keep.has(parameter.id)) values[parameter.id] = this.defaults[parameter.id];
1409
+ Object.assign(values, preset?.values ?? {});
1410
+ await this.assign(values);
1411
+ this.changed([...this.rows.keys()]);
1412
+ this.report(preset ? `Preset: ${preset.label}` : "Graph defaults", "ok");
1413
+ }
1414
+ async dropAnywhere(file) {
1415
+ if (file.name.endsWith(".json") || file.type === "application/json") return this.openScene(file);
1416
+ const target = this.parameters.find((parameter) => this.control(parameter) === "file" && accepts(parameter, file));
1417
+ if (!target) throw new Error(`Nothing here accepts ${file.name}`);
1418
+ await this.dropFile(target, file);
1419
+ }
1420
+ async dropFile(parameter, file, remember = true) {
1421
+ if (!accepts(parameter, file)) throw new Error(`${parameter.label ?? parameter.id} accepts ${parameter.accept?.join(", ")}`);
1422
+ const bytes = new Uint8Array(await file.arrayBuffer());
1423
+ await this.useFile(parameter, { name: file.name, mediaType: file.type || mediaTypeForName(file.name), bytes });
1424
+ if (remember) await rememberFile(this.editor.id, parameter.id, { name: file.name, mediaType: file.type, bytes });
1425
+ }
1426
+ async useFile(parameter, file) {
1427
+ const ref = await this.engine.writeAsset(file.bytes.slice(), { mediaType: file.mediaType, suggestedName: file.name });
1428
+ let value = ref;
1429
+ if (this.propTypes.get(`${parameter.node}\0${parameter.prop}`) === "image") {
1430
+ const bitmap = await createImageBitmap(new Blob([file.bytes], { type: file.mediaType }));
1431
+ value = { path: ref.path, size: [bitmap.width, bitmap.height], channels: "rgba", depth: "u8", space: "srgb" };
1432
+ bitmap.close();
1433
+ }
1434
+ file.scene = await sceneFile(parameter, file.bytes, file.name, file.mediaType);
1435
+ this.files.set(parameter.id, file);
1436
+ await this.set(parameter.id, value);
1437
+ }
1438
+ async clearFile(parameter) {
1439
+ this.files.delete(parameter.id);
1440
+ await rememberFile(this.editor.id, parameter.id, null);
1441
+ await this.set(parameter.id, this.defaults[parameter.id]);
1442
+ }
1443
+ async restoreRememberedFiles() {
1444
+ for (const parameter of this.parameters) {
1445
+ if (this.control(parameter) !== "file" || this.files.has(parameter.id)) continue;
1446
+ const stored = await recallFile(this.editor.id, parameter.id);
1447
+ if (!stored) continue;
1448
+ try {
1449
+ await this.useFile(parameter, stored);
1450
+ } catch (error) {
1451
+ this.report(`${parameter.label ?? parameter.id}: ${asError(error).message}`, "warn");
1452
+ }
1453
+ }
1454
+ }
1455
+ /** Bring files into play from a scene: embedded bytes, or a remembered file with the same hash. */
1456
+ async restoreFiles(files, report = true) {
1457
+ const warnings = [];
1458
+ for (const [id, file] of Object.entries(files)) {
1459
+ const parameter = this.byId.get(id);
1460
+ if (!parameter) continue;
1461
+ if (file.data) {
1462
+ await this.useFile(parameter, { name: file.name, mediaType: file.mediaType, bytes: fromBase64(file.data) });
1463
+ continue;
1464
+ }
1465
+ const stored = await recallFile(this.editor.id, id);
1466
+ const storedHash = stored ? (await sceneFile({ ...parameter, embed: "never" }, stored.bytes, stored.name)).sha256 : "";
1467
+ if (stored && storedHash === file.sha256) await this.useFile(parameter, stored);
1468
+ else warnings.push(`${parameter.label ?? id}: this scene used "${file.name}", which is not in this browser. Drop it to match.`);
1469
+ }
1470
+ if (report && warnings.length) this.report(warnings.join("\n"), "warn");
1471
+ return warnings;
1472
+ }
1473
+ async openScene(file) {
1474
+ const resolved = resolveScene(this.document, JSON.parse(await file.text()));
1475
+ const values = {};
1476
+ for (const parameter of this.parameters)
1477
+ if (this.control(parameter) !== "file") values[parameter.id] = this.defaults[parameter.id];
1478
+ Object.assign(values, resolved.values);
1479
+ await this.assign(values);
1480
+ const fileWarnings = await this.restoreFiles(resolved.files, false);
1481
+ this.changed([...this.rows.keys()]);
1482
+ const warnings = [...resolved.warnings, ...fileWarnings];
1483
+ this.report(warnings.length ? warnings.join("\n") : "Scene opened", warnings.length ? "warn" : "ok");
1484
+ }
1485
+ currentScene(name) {
1486
+ const values = {};
1487
+ const files = {};
1488
+ for (const parameter of this.parameters) {
1489
+ const dropped = this.files.get(parameter.id);
1490
+ if (dropped?.scene) files[parameter.id] = dropped.scene;
1491
+ else if (this.control(parameter) !== "file" && this.values[parameter.id] !== void 0)
1492
+ values[parameter.id] = this.values[parameter.id];
1493
+ }
1494
+ return createScene(this.document, { name, values, files, savedAt: (/* @__PURE__ */ new Date()).toISOString() });
1495
+ }
1496
+ async saveScene() {
1497
+ const name = prompt("Scene name", "scene")?.trim();
1498
+ if (!name) return;
1499
+ const scene = this.currentScene(name);
1500
+ await this.deliver(`${slug(name)}.scene.json`, new Blob([JSON.stringify(scene, null, 2)], { type: "application/json" }));
1501
+ }
1502
+ remember() {
1503
+ try {
1504
+ const scene = this.currentScene();
1505
+ const light = { ...scene, files: Object.fromEntries(Object.entries(scene.files ?? {}).map(([id, file]) => [id, { ...file, data: void 0 }])) };
1506
+ localStorage.setItem(this.storageKey, JSON.stringify(light));
1507
+ } catch {
1508
+ }
1509
+ }
1510
+ // ---------------------------------------------------------------- cooking
1511
+ cook() {
1512
+ if (this.queued && this.pendingCook) return this.pendingCook;
1513
+ this.queued = true;
1514
+ this.ui.shell.classList.add("busy");
1515
+ const run = this.exclusive(async () => {
1516
+ this.queued = false;
1517
+ const started = performance.now();
1518
+ try {
1519
+ if (this.inspecting) {
1520
+ const result = await this.engine.run({ reuse: true });
1521
+ if (result.status !== "completed") throw new Error(result.messages.join("\n") || result.status);
1522
+ } else if (this.view) await this.runTarget(this.view);
1523
+ if (this.view) await this.show(await this.engine.output(this.view.node, this.view.port));
1524
+ if (this.inspecting) await this.renderInspect();
1525
+ const took = Math.round(performance.now() - started);
1526
+ this.ui.status.textContent = `${took} ms`;
1527
+ this.ui.menuStatus.textContent = `Last cook ${took} ms`;
1528
+ this.clearReport("bad");
1529
+ } catch (error) {
1530
+ this.report(asError(error).message, "bad");
1531
+ } finally {
1532
+ if (!this.queued) this.ui.shell.classList.remove("busy");
1533
+ }
1534
+ });
1535
+ this.pendingCook = run;
1536
+ return run;
1537
+ }
1538
+ async runTarget(output) {
1539
+ const result = await this.engine.run({ target: { kind: "output", nodeId: output.node, outputName: output.port }, reuse: true });
1540
+ if (result.status !== "completed") throw new Error(result.messages.join("\n") || `Cook ${result.status}`);
1541
+ }
1542
+ async show(value) {
1543
+ const { element, release } = await this.render(value, 0, 0);
1544
+ this.viewRelease?.();
1545
+ this.viewRelease = release;
1546
+ this.ui.output.replaceChildren(element);
1547
+ }
1548
+ /** One output as an element: images and SVG as pictures, geometry drawn, anything else as JSON. */
1549
+ async render(value, width, height) {
1550
+ const d = this.root.ownerDocument;
1551
+ if (isSvgText(value)) {
1552
+ const url = URL.createObjectURL(new Blob([value], { type: "image/svg+xml" }));
1553
+ const image = d.createElement("img");
1554
+ image.src = url;
1555
+ image.alt = "SVG output";
1556
+ await image.decode().catch(() => {
1557
+ });
1558
+ return { element: image, release: () => URL.revokeObjectURL(url) };
1559
+ }
1560
+ if (isAssetRef(value) && isImageAsset(value)) {
1561
+ const bytes = await this.engine.readAsset(value);
1562
+ const type = "mediaType" in value && value.mediaType || mediaTypeForName(value.path) || "image/png";
1563
+ const url = URL.createObjectURL(new Blob([bytes], { type }));
1564
+ const image = d.createElement("img");
1565
+ image.src = url;
1566
+ image.alt = value.path;
1567
+ await image.decode().catch(() => {
1568
+ });
1569
+ return { element: image, release: () => URL.revokeObjectURL(url) };
1570
+ }
1571
+ if (isGeometry(value) || isScene2(value)) {
1572
+ const scene = isScene2(value) ? value : sceneFromGeometry2(value);
1573
+ const box = this.ui.viewer.getBoundingClientRect();
1574
+ const dark = d.defaultView?.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false;
1575
+ const ink = dark ? [0.92, 0.92, 0.92, 1] : [0.11, 0.11, 0.11, 1];
1576
+ return { element: renderSceneCanvas(d, scene, width || Math.max(320, Math.round(box?.width ?? 800)), height || Math.max(240, Math.round(box?.height ?? 600)), ink) };
1577
+ }
1578
+ const pre = d.createElement("pre");
1579
+ pre.textContent = summarise(value);
1580
+ return { element: pre };
1581
+ }
1582
+ async renderInspect() {
1583
+ const d = this.root.ownerDocument;
1584
+ for (const release of this.inspectReleases.splice(0)) release();
1585
+ const nodes = this.nodes;
1586
+ const outputs = await this.engine.outputs();
1587
+ const depth = (id) => {
1588
+ let level = 0;
1589
+ let current = nodes.find((node) => node.id === id)?.parent;
1590
+ while (current) {
1591
+ level++;
1592
+ current = nodes.find((node) => node.id === current)?.parent;
1593
+ }
1594
+ return level;
1595
+ };
1596
+ const cards = [];
1597
+ for (const node of nodes) {
1598
+ if (node.moduleId === "cascade.core.Input" || node.moduleId === "cascade.core.Output") continue;
1599
+ const card = el(d, "div", "ce-stage");
1600
+ if (depth(node.id)) card.classList.add("ce-indent");
1601
+ const title = el(d, "h3");
1602
+ title.textContent = node.id;
1603
+ const module = el(d, "div", "mod");
1604
+ module.textContent = `${node.moduleId}${node.parent ? ` \xB7 in ${node.parent}` : ""}`;
1605
+ card.append(title, module);
1606
+ for (const [port, value] of outputs.get(node.id) ?? []) {
1607
+ const out = el(d, "div", "out");
1608
+ const name = el(d, "span");
1609
+ name.textContent = port;
1610
+ const { element, release } = await this.render(value, 220, 140);
1611
+ if (release) this.inspectReleases.push(release);
1612
+ out.append(name, element);
1613
+ card.append(out);
1614
+ }
1615
+ cards.push(card);
1616
+ }
1617
+ this.ui.inspect.replaceChildren(...cards);
1618
+ }
1619
+ async exportOutput(output) {
1620
+ this.ui.status.textContent = `Exporting ${output.label ?? output.id}\u2026`;
1621
+ const value = await this.exclusive(async () => {
1622
+ await this.runTarget(output);
1623
+ return this.engine.output(output.node, output.port);
1624
+ });
1625
+ const blob = await this.toBlob(value, output);
1626
+ const scene = slug(this.editor.id);
1627
+ const extension = output.format === "text" ? "txt" : output.format;
1628
+ const name = (output.filename ?? `{scene}-{id}.${extension}`).split("{id}").join(output.id).split("{scene}").join(scene);
1629
+ await this.deliver(name, blob);
1630
+ }
1631
+ /**
1632
+ * Hand a file over: a download on a desktop, the share sheet on a phone (Save
1633
+ * to Files, Photos, AirDrop), falling back to a download. When the cook took
1634
+ * longer than the browser lets a tap count, the toast offers a fresh tap.
1635
+ */
1636
+ async deliver(name, blob) {
1637
+ const d = this.root.ownerDocument;
1638
+ const save = () => download(d, name, blob);
1639
+ if (!this.mobile) {
1640
+ save();
1641
+ this.report(`Saved ${name}`, "ok");
1642
+ return;
1643
+ }
1644
+ const result = await shareFile(name, blob, save);
1645
+ if (result === "needs-tap")
1646
+ this.toast(`${name} is ready`, "Share", () => void shareFile(name, blob, save).then((again) => {
1647
+ if (again === "downloaded") this.report(`Saved ${name}`, "ok");
1648
+ }));
1649
+ else if (result === "downloaded") this.report(`Saved ${name}`, "ok");
1650
+ else this.ui.status.textContent = result === "shared" ? `Shared ${name}` : "";
1651
+ }
1652
+ async toBlob(value, output) {
1653
+ if (value instanceof Blob) return value;
1654
+ if (isAssetRef(value)) {
1655
+ const bytes = await this.engine.readAsset(value);
1656
+ return new Blob([bytes], { type: "mediaType" in value && value.mediaType || mediaTypeForName(value.path) || "application/octet-stream" });
1657
+ }
1658
+ if (typeof value === "string")
1659
+ return new Blob([value], { type: output.format === "svg" ? "image/svg+xml" : "text/plain;charset=utf-8" });
1660
+ if (output.format === "png" && (isGeometry(value) || isScene2(value))) {
1661
+ const scene = isScene2(value) ? value : sceneFromGeometry2(value);
1662
+ return canvasBlob(renderSceneCanvas(this.root.ownerDocument, scene, 2048, 2048));
1663
+ }
1664
+ return new Blob([JSON.stringify(value, jsonReplacer, 2)], { type: "application/json" });
1665
+ }
1666
+ // ---------------------------------------------------------------- phones
1667
+ setupTouch() {
1668
+ const win = this.root.ownerDocument.defaultView;
1669
+ const query = win?.matchMedia?.(PHONE);
1670
+ const update = () => this.applyMode(query?.matches ?? false, (win?.innerWidth ?? 0) > (win?.innerHeight ?? 0));
1671
+ query?.addEventListener?.("change", update);
1672
+ win?.addEventListener("resize", update);
1673
+ attachZoom(this.ui.surface, this.ui.output, () => this.mobile);
1674
+ attachSheet(this.ui.head, {
1675
+ enabled: () => this.mobile && !this.landscape,
1676
+ height: () => this.sheetHeight,
1677
+ snaps: () => this.snaps(),
1678
+ onHeight: (height, settled) => this.setSheet(height, settled)
1679
+ });
1680
+ if (typeof ResizeObserver !== "undefined")
1681
+ new ResizeObserver(() => {
1682
+ if (this.mobile && !this.landscape && this.snapIndex === 0) this.setSheet(this.snaps()[0], false);
1683
+ }).observe(this.ui.head);
1684
+ update();
1685
+ }
1686
+ applyMode(mobile, landscape) {
1687
+ const changed = mobile !== this.mobile;
1688
+ this.mobile = mobile;
1689
+ this.landscape = mobile && landscape;
1690
+ this.ui.shell.classList.toggle("m", mobile);
1691
+ this.ui.shell.classList.toggle("land", this.landscape);
1692
+ if (changed) {
1693
+ this.placePeek(mobile);
1694
+ if (mobile && !this.groupsFolded) {
1695
+ this.groupsFolded = true;
1696
+ for (const details of this.ui.groups.querySelectorAll("details")) details.open = false;
1697
+ }
1698
+ for (const row of this.rows.values()) row.refresh();
1699
+ }
1700
+ if (mobile && !this.landscape) this.setSheet(this.snaps()[Math.min(this.snapIndex, 2)], false);
1701
+ else {
1702
+ this.ui.shell.style.removeProperty("--ce-sheet");
1703
+ this.ui.shell.style.removeProperty("--ce-cut");
1704
+ }
1705
+ }
1706
+ /** The parameters kept in reach at the peek: declared, else the first text and seed. */
1707
+ peekIds() {
1708
+ if (this.editor.peek) return this.editor.peek.filter((id) => this.byId.has(id));
1709
+ const ids = [];
1710
+ const text = this.parameters.find((parameter) => ["text", "textarea"].includes(this.control(parameter)));
1711
+ const seed = this.parameters.find((parameter) => this.control(parameter) === "seed");
1712
+ for (const parameter of this.parameters) if (parameter === text || parameter === seed) ids.push(parameter.id);
1713
+ return ids;
1714
+ }
1715
+ /** Move the peek rows into the sheet's head on a phone, and back to their groups otherwise. */
1716
+ placePeek(mobile) {
1717
+ const d = this.root.ownerDocument;
1718
+ for (const id of this.peekIds()) {
1719
+ const row = this.rows.get(id)?.element;
1720
+ if (!row) continue;
1721
+ if (mobile && row.parentElement !== this.ui.peek) {
1722
+ const slot = d.createComment(`peek:${id}`);
1723
+ row.before(slot);
1724
+ this.peekSlots.set(id, slot);
1725
+ this.ui.peek.append(row);
1726
+ } else if (!mobile) {
1727
+ const slot = this.peekSlots.get(id);
1728
+ if (slot) {
1729
+ slot.replaceWith(row);
1730
+ this.peekSlots.delete(id);
1731
+ }
1732
+ }
1733
+ }
1734
+ }
1735
+ /** Peek, half and full heights of the sheet, in px. */
1736
+ snaps() {
1737
+ const shell = this.ui.shell.clientHeight || this.root.ownerDocument.defaultView?.innerHeight || 800;
1738
+ const inset = parseFloat(getComputedStyle(this.ui.side).paddingBottom) || 0;
1739
+ const peek = Math.min(Math.round(this.ui.head.scrollHeight + inset + 1), Math.round(shell * 0.45));
1740
+ const full = Math.max(peek, shell - this.ui.bar.offsetHeight - 8);
1741
+ const half = Math.min(full, Math.max(peek + 96, Math.round(shell * 0.55)));
1742
+ return [peek, half, full];
1743
+ }
1744
+ setSheet(height, settled) {
1745
+ const snaps = this.snaps();
1746
+ this.sheetHeight = height;
1747
+ this.ui.side.classList.toggle("settle", settled);
1748
+ this.ui.shell.style.setProperty("--ce-sheet", `${Math.round(height)}px`);
1749
+ this.ui.shell.style.setProperty("--ce-cut", `${Math.round(Math.min(height, snaps[1]))}px`);
1750
+ if (settled) {
1751
+ this.snapIndex = snaps.reduce((best, snap, index) => Math.abs(snap - height) < Math.abs(snaps[best] - height) ? index : best, 0);
1752
+ this.ui.side.dataset.snap = ["peek", "half", "full"][this.snapIndex];
1753
+ }
1754
+ }
1755
+ toast(text, action, run) {
1756
+ this.ui.toastText.textContent = text;
1757
+ this.ui.toastAction.hidden = !action;
1758
+ this.ui.toastAction.textContent = action ?? "";
1759
+ this.ui.toastAction.onclick = run ? () => {
1760
+ this.ui.toast.classList.remove("on");
1761
+ run();
1762
+ } : null;
1763
+ this.ui.toast.classList.add("on");
1764
+ clearTimeout(this.toastTimer);
1765
+ this.toastTimer = setTimeout(() => this.ui.toast.classList.remove("on"), action ? 8e3 : 2200);
1766
+ }
1767
+ // ---------------------------------------------------------------- messages
1768
+ report(text, kind) {
1769
+ const d = this.root.ownerDocument;
1770
+ this.clearReport(kind);
1771
+ if (kind === "ok") {
1772
+ this.ui.status.textContent = text;
1773
+ if (this.mobile) this.toast(text);
1774
+ return;
1775
+ }
1776
+ this.ui.messages.append(message(d, text, kind));
1777
+ }
1778
+ clearReport(kind) {
1779
+ for (const child of [...this.ui.messages.children]) if (child.classList.contains(kind)) child.remove();
1780
+ }
1781
+ };
1782
+ function el(d, tag, className) {
1783
+ const element = d.createElement(tag);
1784
+ if (className) element.className = className;
1785
+ return element;
1786
+ }
1787
+ function button(d, text) {
1788
+ const element = d.createElement("button");
1789
+ element.type = "button";
1790
+ element.textContent = text;
1791
+ return element;
1792
+ }
1793
+ function option(d, value, label) {
1794
+ const element = d.createElement("option");
1795
+ element.value = value;
1796
+ element.textContent = label;
1797
+ return element;
1798
+ }
1799
+ function message(d, text, kind) {
1800
+ const element = el(d, "div", `ce-msg ${kind}`);
1801
+ element.setAttribute("role", kind === "bad" ? "alert" : "status");
1802
+ element.textContent = text;
1803
+ return element;
1804
+ }
1805
+ function accepts(parameter, file) {
1806
+ const accept = parameter.accept ?? [];
1807
+ if (!accept.length) return true;
1808
+ const name = file.name.toLowerCase();
1809
+ return accept.some((rule) => {
1810
+ const lower = rule.toLowerCase();
1811
+ if (lower.startsWith(".")) return name.endsWith(lower);
1812
+ if (lower.endsWith("/*")) return file.type.startsWith(lower.slice(0, -1));
1813
+ return file.type === lower;
1814
+ });
1815
+ }
1816
+ function summarise(value) {
1817
+ if (value === void 0) return "No output";
1818
+ if (ArrayBuffer.isView(value)) return `${value.constructor.name} (${value.length})`;
1819
+ const text = JSON.stringify(value, (key, item) => {
1820
+ if (ArrayBuffer.isView(item)) return `${item.constructor.name}(${item.length})`;
1821
+ if (Array.isArray(item) && item.length > 24) return [...item.slice(0, 24), `\u2026 ${item.length - 24} more`];
1822
+ return item;
1823
+ }, 2) ?? String(value);
1824
+ return text.length > 4e3 ? `${text.slice(0, 4e3)}
1825
+ \u2026` : text;
1826
+ }
1827
+ function slug(text) {
1828
+ return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "scene";
1829
+ }
1830
+ function download(d, name, blob) {
1831
+ const url = URL.createObjectURL(blob);
1832
+ const link = d.createElement("a");
1833
+ link.href = url;
1834
+ link.download = name;
1835
+ d.body.append(link);
1836
+ link.click();
1837
+ link.remove();
1838
+ setTimeout(() => URL.revokeObjectURL(url), 5e3);
1839
+ }
1840
+ function hexToRgba(hex) {
1841
+ const value = parseInt(hex.slice(1, 7), 16);
1842
+ return [(value >> 16 & 255) / 255, (value >> 8 & 255) / 255, (value & 255) / 255, 1];
1843
+ }
1844
+ function rgbaToHex(value) {
1845
+ return `#${value.slice(0, 3).map((part) => Math.round(Math.max(0, Math.min(1, part)) * 255).toString(16).padStart(2, "0")).join("")}`;
1846
+ }
1847
+ function readLocal(key) {
1848
+ try {
1849
+ const text = localStorage.getItem(key);
1850
+ return text ? JSON.parse(text) : void 0;
1851
+ } catch {
1852
+ return void 0;
1853
+ }
1854
+ }
1855
+ var DB = "cascade-editor";
1856
+ var STORE = "files";
1857
+ function database() {
1858
+ return new Promise((resolve, reject) => {
1859
+ const request = indexedDB.open(DB, 1);
1860
+ request.onupgradeneeded = () => request.result.createObjectStore(STORE);
1861
+ request.onsuccess = () => resolve(request.result);
1862
+ request.onerror = () => reject(request.error);
1863
+ });
1864
+ }
1865
+ async function rememberFile(editor, id, file) {
1866
+ try {
1867
+ const db = await database();
1868
+ await new Promise((resolve, reject) => {
1869
+ const transaction = db.transaction(STORE, "readwrite");
1870
+ const store = transaction.objectStore(STORE);
1871
+ if (file) store.put({ name: file.name, mediaType: file.mediaType, bytes: file.bytes }, `${editor}:${id}`);
1872
+ else store.delete(`${editor}:${id}`);
1873
+ transaction.oncomplete = () => resolve();
1874
+ transaction.onerror = () => reject(transaction.error);
1875
+ });
1876
+ } catch {
1877
+ }
1878
+ }
1879
+ async function recallFile(editor, id) {
1880
+ try {
1881
+ const db = await database();
1882
+ return await new Promise((resolve) => {
1883
+ const request = db.transaction(STORE, "readonly").objectStore(STORE).get(`${editor}:${id}`);
1884
+ request.onsuccess = () => resolve(request.result ?? null);
1885
+ request.onerror = () => resolve(null);
1886
+ });
1887
+ } catch {
1888
+ return null;
1889
+ }
1890
+ }
1891
+ export {
1892
+ EditorController,
1893
+ startEditor
1894
+ };