proteus-sdk 0.0.0-stage → 0.1.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.
package/dist/index.js ADDED
@@ -0,0 +1,750 @@
1
+ /**
2
+ * The Proteus TypeScript SDK: create components, connect them, and transition
3
+ * between them.
4
+ *
5
+ * Start with {@link mount}, which runs an app on a `<canvas>`. Each handle
6
+ * ({@link Handle}, {@link TransitionChannel}, {@link TextureHandle}) keeps a
7
+ * reference to the {@link ProteusApp} it came from, so its methods are called
8
+ * directly: `button.onClick(() => ...)`.
9
+ *
10
+ * @packageDocumentation
11
+ */
12
+ import { Handle as WasmHandle, ProteusApp as WasmApp, TextureHandle as WasmTextureHandle, } from "../pkg/proteus_sdk_web.js";
13
+ export * from "./types.js";
14
+ export * from "./convert.js";
15
+ export * from "./mount.js";
16
+ // ---------------------------------------------------------------------------
17
+ // Handle
18
+ // ---------------------------------------------------------------------------
19
+ /**
20
+ * A handle to one component.
21
+ *
22
+ * Callbacks registered with the `on*` methods run every time their event
23
+ * happens, until the component is destroyed. They run after the app's update
24
+ * for that tick, so anything they start takes effect on the next tick.
25
+ *
26
+ * Once the component is destroyed, methods that change it throw, and the
27
+ * failure is also logged. Methods that read it return `undefined`. A method
28
+ * that returns `boolean` uses `false` for "there was nothing to do", such as
29
+ * an image that hasn't been baked yet; that is not an error and doesn't throw.
30
+ */
31
+ export class Handle {
32
+ /**
33
+ * Prefer {@link ProteusApp.component} or {@link ProteusApp.handleFromId}
34
+ * over calling this directly.
35
+ */
36
+ constructor(app,
37
+ /** @internal */
38
+ wasmHandle) {
39
+ this.app = app;
40
+ this.wasmHandle = wasmHandle;
41
+ }
42
+ /** The component's ID, for {@link ProteusApp.handleFromId} and {@link ComponentSpec.children}. */
43
+ id() {
44
+ return this.wasmHandle.id();
45
+ }
46
+ /** A snapshot of the component's current state, or `undefined` if it has been destroyed. */
47
+ get() {
48
+ return this.app.get(this);
49
+ }
50
+ /** Calls `cb` each time the pointer is pressed on this component. A click also gives it focus. */
51
+ onClick(cb) {
52
+ this.app.wasmApp.onClick(this.wasmHandle, cb);
53
+ }
54
+ /**
55
+ * Calls `cb` each time a transition finishes on this component.
56
+ *
57
+ * Every transition reports its completion once, on one component:
58
+ *
59
+ * - {@link Handle.animateTo}: this component.
60
+ * - {@link TransitionChannel.set}: the `to` component.
61
+ * - {@link Handle.splitTo} or {@link Handle.splitToWithStates} with
62
+ * `"row"`, `"column"` or `"grid"`: the source, once every target has arrived.
63
+ * - {@link Handle.splitTo} or {@link Handle.splitToWithStates} with
64
+ * `"perTarget"`: each target, separately. The source has no transition of
65
+ * its own; it is hidden as soon as the split starts.
66
+ * - {@link Handle.mergeFrom}: the destination, once every source has
67
+ * arrived.
68
+ *
69
+ * Changes of interaction style, such as a hover effect, don't count.
70
+ */
71
+ onTransitionComplete(cb) {
72
+ this.app.wasmApp.onTransitionComplete(this.wasmHandle, cb);
73
+ }
74
+ /** Calls `cb` each time the pointer moves onto this component. */
75
+ onHoverEnter(cb) {
76
+ this.app.wasmApp.onHoverEnter(this.wasmHandle, cb);
77
+ }
78
+ /** Calls `cb` each time the pointer moves off this component. */
79
+ onHoverExit(cb) {
80
+ this.app.wasmApp.onHoverExit(this.wasmHandle, cb);
81
+ }
82
+ /**
83
+ * Calls `cb` each time the pointer is pressed on this component. This fires
84
+ * at the same moment as {@link Handle.onClick}; use it with
85
+ * {@link Handle.onRelease} to follow a press from start to end.
86
+ */
87
+ onPress(cb) {
88
+ this.app.wasmApp.onPress(this.wasmHandle, cb);
89
+ }
90
+ /**
91
+ * Calls `cb` each time the pointer is released after a press on this
92
+ * component, even if the pointer has moved off it.
93
+ */
94
+ onRelease(cb) {
95
+ this.app.wasmApp.onRelease(this.wasmHandle, cb);
96
+ }
97
+ /** Calls `cb` each time this component gains focus. */
98
+ onFocus(cb) {
99
+ this.app.wasmApp.onFocus(this.wasmHandle, cb);
100
+ }
101
+ /** Calls `cb` each time this component loses focus. */
102
+ onBlur(cb) {
103
+ this.app.wasmApp.onBlur(this.wasmHandle, cb);
104
+ }
105
+ /**
106
+ * Calls `cb` every tick while this component is pressed, with the distance
107
+ * the pointer moved since the previous tick, in world units (y points up).
108
+ */
109
+ onDrag(cb) {
110
+ this.app.wasmApp.onDrag(this.wasmHandle, (x, y) => cb({ x, y }));
111
+ }
112
+ /**
113
+ * Makes `child` a child of this component. Its geometry becomes relative to
114
+ * this component's.
115
+ */
116
+ addChild(child) {
117
+ this.app.wasmApp.addChild(this.wasmHandle, child.wasmHandle);
118
+ }
119
+ /**
120
+ * Detaches `child` from this component. The child is kept, as a top-level
121
+ * component; to destroy it instead, call {@link Handle.destroy} on it.
122
+ *
123
+ * A detached child may move on screen: its geometry was relative to this
124
+ * component, and is now relative to the world.
125
+ *
126
+ * Throws if either component no longer exists, or if `child` isn't a child
127
+ * of this component.
128
+ */
129
+ removeChild(child) {
130
+ this.app.wasmApp.removeChild(this.wasmHandle, child.wasmHandle);
131
+ }
132
+ /**
133
+ * Splits this component into `targets`: a 1→N transition.
134
+ *
135
+ * Each target ends at its own declared geometry. This component is hidden
136
+ * as soon as the split starts. With `"row"`, `"column"` and `"grid"`, slices of
137
+ * this component move into place and the targets appear when they arrive;
138
+ * with `"perTarget"`, the targets themselves move. The transition starts on
139
+ * the next tick.
140
+ *
141
+ * `childBehavior`, if given, sets each target's timing separately. It is
142
+ * called once per target with its index and the total; see
143
+ * {@link ChildBehavior}.
144
+ */
145
+ splitTo(targets, config, strategy, childBehavior) {
146
+ const ids = new Float64Array(targets.map((t) => t.id()));
147
+ if (childBehavior) {
148
+ this.app.wasmApp.splitToWithBehavior(this.wasmHandle, ids, config, strategy, childBehavior);
149
+ }
150
+ else {
151
+ this.app.wasmApp.splitTo(this.wasmHandle, ids, config, strategy);
152
+ }
153
+ }
154
+ /**
155
+ * Merges `sources` into this component: an N→1 transition.
156
+ *
157
+ * The sources are hidden as soon as the merge starts. `layout` decides
158
+ * which part of this component each source moves toward. The transition
159
+ * starts on the next tick. `childBehavior`, if given, sets each source's
160
+ * timing separately; see {@link ChildBehavior}.
161
+ */
162
+ mergeFrom(sources, config, layout, childBehavior) {
163
+ const ids = new Float64Array(sources.map((s) => s.id()));
164
+ if (childBehavior) {
165
+ this.app.wasmApp.mergeFromWithBehavior(this.wasmHandle, ids, config, layout, childBehavior);
166
+ }
167
+ else {
168
+ this.app.wasmApp.mergeFrom(this.wasmHandle, ids, config, layout);
169
+ }
170
+ }
171
+ /**
172
+ * Destroys this component and its children, along with their callbacks and
173
+ * the channels they own.
174
+ *
175
+ * @throws if it was already destroyed. Safe to ignore, but reported so that
176
+ * destroying twice is visible.
177
+ */
178
+ destroy() {
179
+ this.app.wasmApp.destroy(this.wasmHandle);
180
+ }
181
+ /**
182
+ * Removes this component's text, image and baked content, and releases
183
+ * their textures. The component itself remains, drawn as a plain quad in
184
+ * its color. Nothing is baked again; to show new content, use
185
+ * {@link Handle.setText} or {@link Handle.setImage}.
186
+ *
187
+ * A component made with {@link ComponentSpec.bake} lost its children and
188
+ * its own color when it was baked, so afterwards it is a plain white quad.
189
+ *
190
+ * Releasing a texture doesn't free atlas space immediately. A texture that
191
+ * no component references becomes available for reuse, and the atlas
192
+ * reclaims its space when it needs room for another texture. Textures
193
+ * marked `eternal` are never reclaimed.
194
+ *
195
+ * @throws if this component no longer exists.
196
+ */
197
+ freeResources() {
198
+ this.app.wasmApp.freeResources(this.wasmHandle);
199
+ }
200
+ /**
201
+ * Replaces this component's text, or adds text to a component that has
202
+ * none. The host bakes the new text before it next draws, and the old
203
+ * text's texture is released (see {@link Handle.freeResources}).
204
+ *
205
+ * @example
206
+ * ```ts
207
+ * score.setText({ content: `Score: ${points}`, sizePx: 24 });
208
+ * ```
209
+ *
210
+ * @throws if this component no longer exists, or `text` isn't a valid
211
+ * {@link TextSpec}.
212
+ */
213
+ setText(text) {
214
+ this.app.wasmApp.setText(this.wasmHandle, text);
215
+ }
216
+ /**
217
+ * Replaces this component's image, or adds an image to a component that
218
+ * has none. The host decodes and bakes the new image before it next draws,
219
+ * and the old image's texture is released (see
220
+ * {@link Handle.freeResources}). A crop set with {@link Handle.cropImage}
221
+ * applied to the old image, so it is cleared.
222
+ *
223
+ * @throws if this component no longer exists, or `image` isn't a valid
224
+ * {@link ImageSpec}.
225
+ */
226
+ setImage(image) {
227
+ this.app.wasmApp.setImage(this.wasmHandle, image);
228
+ }
229
+ /**
230
+ * Like {@link Handle.splitTo}, with each target's end geometry given
231
+ * explicitly instead of taken from its declared geometry.
232
+ *
233
+ * Use this when a target should end somewhere other than its declared
234
+ * geometry, or when this component is also one of the targets. In the
235
+ * second case, pass its end geometry here rather than calling
236
+ * {@link Handle.setDeclaredGeometry} first, which would move it before the
237
+ * split begins.
238
+ */
239
+ splitToWithStates(targets, config, strategy) {
240
+ this.app.wasmApp.splitToWithStates(this.wasmHandle, targets.map((t) => ({ id: t.handle.id(), state: t.state })), config, strategy);
241
+ }
242
+ /**
243
+ * Sets this component's declared geometry and moves it there immediately.
244
+ *
245
+ * Use this when a component's layout can only be worked out after it is
246
+ * created, such as a cell sized to fit its text
247
+ * ({@link Handle.bakedTextSize}). Transitions into the component, and its
248
+ * interaction styles, use the new geometry.
249
+ */
250
+ setDeclaredGeometry(state) {
251
+ this.app.wasmApp.setDeclaredGeometry(this.wasmHandle, state);
252
+ }
253
+ /**
254
+ * Transitions this component from its current geometry to `to`, where it
255
+ * then rests: `to` becomes its declared geometry, as with
256
+ * {@link Handle.setDeclaredGeometry}, so a later transition into it, or an
257
+ * interaction style, starts from there.
258
+ *
259
+ * Unlike {@link TransitionChannel.set}, only this component is involved, which
260
+ * makes this a good fit for moving a component around repeatedly. Calling
261
+ * it during a transition starts a new one from wherever the component is.
262
+ * The transition starts on the next tick.
263
+ *
264
+ * @example
265
+ * ```ts
266
+ * card.animateTo(
267
+ * { ...card.get()!.geometry, position: { x: 300, y: 0, z: 0 } },
268
+ * { duration: 0.3, easing: "easeOutCubic" },
269
+ * );
270
+ * ```
271
+ */
272
+ animateTo(to, config) {
273
+ this.app.wasmApp.animateTo(this.wasmHandle, to, config);
274
+ }
275
+ /**
276
+ * The size in pixels of this component's baked text, or `undefined` if the
277
+ * text hasn't been baked yet or the component has none. The host bakes text
278
+ * the next time it renders a frame.
279
+ */
280
+ bakedTextSize() {
281
+ return this.app.wasmApp.bakedTextSize(this.wasmHandle);
282
+ }
283
+ /**
284
+ * The size in pixels of this component's baked image, or `undefined` if the
285
+ * image hasn't been baked yet or the component has none. This is the full
286
+ * image size, even after {@link Handle.cropImage}.
287
+ */
288
+ bakedImageSize() {
289
+ return this.app.wasmApp.bakedImageSize(this.wasmHandle);
290
+ }
291
+ /**
292
+ * Shows `source`'s baked image on this component, replacing its current
293
+ * image. Returns `false` if `source` has no baked image yet.
294
+ *
295
+ * The two components share the texture, which stays in the atlas as long as
296
+ * either one references it. Use this to prepare a hidden component before a
297
+ * transition reveals it: for example, before merging grid tiles into a
298
+ * detail view, copy the selected tile's image onto the detail view.
299
+ */
300
+ copyBakedImageFrom(source) {
301
+ return this.app.wasmApp.copyBakedImageFrom(this.wasmHandle, source.wasmHandle);
302
+ }
303
+ /**
304
+ * Shows only the part of this component's image that `crop` selects: for
305
+ * example `{ kind: "centeredSquare" }` to fill a square grid tile with an
306
+ * image of any shape.
307
+ *
308
+ * The crop is always measured from the whole image, so calling it again
309
+ * replaces the crop rather than cropping the crop, and `{ kind: "none" }`
310
+ * shows the whole image again. Only the visible region changes: no pixels
311
+ * are copied. Returns `false`, and changes nothing, if the image hasn't been
312
+ * baked yet. Throws if `crop` isn't a valid {@link ImageCrop}.
313
+ *
314
+ * @example
315
+ * ```ts
316
+ * // A 16:9 view of the image, kept to its top edge.
317
+ * tile.cropImage({ kind: "aspect", ratio: 16 / 9, anchor: { x: 0.5, y: 0 } });
318
+ * ```
319
+ */
320
+ cropImage(crop) {
321
+ return this.app.wasmApp.cropImage(this.wasmHandle, crop);
322
+ }
323
+ /**
324
+ * Sets whether this component responds to input.
325
+ *
326
+ * A non-interactive component is not there for input: it is never hovered,
327
+ * pressed, dragged or focused, and input goes to whatever is behind it.
328
+ * `false` has the same effect as {@link ComponentSpec.nonInteractive},
329
+ * applied after creation. Use it for things that are never controls, such
330
+ * as backgrounds and labels. For a control that is temporarily unavailable,
331
+ * use {@link Handle.setDisabled}, which still blocks input.
332
+ *
333
+ * Proteus currently handles pointer input only (on the web, that includes
334
+ * touch and pen). Other kinds of input will follow the same rule.
335
+ */
336
+ setInteractive(interactive) {
337
+ this.app.wasmApp.setInteractive(this.wasmHandle, interactive);
338
+ }
339
+ /**
340
+ * Shows or hides this component and its children.
341
+ *
342
+ * A hidden component is neither drawn nor hit-tested.
343
+ * {@link TransitionChannel.set} already hides the component it transitions from
344
+ * and shows the one it transitions to; use this for everything else.
345
+ *
346
+ * A hidden component stops being drawn on the next frame and stops receiving
347
+ * input one tick later. Input is matched against what was last drawn, so a
348
+ * click in the same tick as the hide still reaches it.
349
+ */
350
+ setVisible(visible) {
351
+ this.app.wasmApp.setVisible(this.wasmHandle, visible);
352
+ }
353
+ /**
354
+ * Sets this component's opacity, clamped to `0`–`1`.
355
+ *
356
+ * Opacity multiplies down the hierarchy: a child at `0.6` under a parent at
357
+ * `0.6` is drawn at `0.36`. It only affects drawing, so a component at `0`
358
+ * still receives pointer input. To take a component out of input as well,
359
+ * use {@link Handle.setVisible}.
360
+ */
361
+ setOpacity(opacity) {
362
+ this.app.wasmApp.setOpacity(this.wasmHandle, opacity);
363
+ }
364
+ /**
365
+ * Disables or re-enables this component.
366
+ *
367
+ * A disabled component is still drawn and still blocks input from reaching
368
+ * what is behind it, but fires no events itself, and shows its
369
+ * {@link ComponentSpec.disabled} style, as a disabled control does on the
370
+ * web. Use it for a control that isn't available yet, such as a submit
371
+ * button. For something that is never a control, use
372
+ * {@link Handle.setInteractive}, which lets input through.
373
+ */
374
+ setDisabled(disabled) {
375
+ this.app.wasmApp.setDisabled(this.wasmHandle, disabled);
376
+ }
377
+ /**
378
+ * Sets whether this component accepts input while transitioning.
379
+ * `undefined` restores the default, where a transitioning component ignores
380
+ * input.
381
+ */
382
+ setTransitionInteraction(config) {
383
+ this.app.wasmApp.setTransitionInteraction(this.wasmHandle, config ?? null);
384
+ }
385
+ /**
386
+ * Sets this component's image to `texture`, replacing its current image.
387
+ * Returns `false` if the texture has been evicted.
388
+ *
389
+ * Only the image changes: text on the component is still drawn on top, and
390
+ * video, if the component is showing it, still plays. Swapping textures is
391
+ * cheap, which makes this suitable for frame-by-frame animation from
392
+ * textures loaded in advance.
393
+ */
394
+ setTexture(texture) {
395
+ return this.app.wasmApp.setTexture(this.wasmHandle, texture.wasmHandle);
396
+ }
397
+ /**
398
+ * Shows `video` on this component, in place of its image or color.
399
+ * **Experimental**: see {@link VideoHandle}.
400
+ *
401
+ * If the component also has an image, {@link Handle.setVideoCrossfade}
402
+ * blends between the two; this starts fully on the video. Returns `false`,
403
+ * changing nothing, if `video` was released or replaced.
404
+ *
405
+ * @throws if this component no longer exists.
406
+ */
407
+ showVideo(video) {
408
+ return this.app.wasmApp.showVideo(this.wasmHandle, video.wasmHandle);
409
+ }
410
+ /**
411
+ * Stops showing video on this component, which returns to its image or
412
+ * color.
413
+ *
414
+ * @throws if this component no longer exists.
415
+ */
416
+ hideVideo() {
417
+ this.app.wasmApp.hideVideo(this.wasmHandle);
418
+ }
419
+ /**
420
+ * Sets the blend between this component's image (`0`) and its video (`1`).
421
+ * To fade the video in, call it with `0` right after
422
+ * {@link Handle.showVideo}, then raise it over time. Returns `false` if the
423
+ * component isn't showing video.
424
+ *
425
+ * @throws if this component no longer exists.
426
+ */
427
+ setVideoCrossfade(videoT) {
428
+ return this.app.wasmApp.setVideoCrossfade(this.wasmHandle, videoT);
429
+ }
430
+ }
431
+ // ---------------------------------------------------------------------------
432
+ // TransitionChannel
433
+ // ---------------------------------------------------------------------------
434
+ /**
435
+ * A transition channel, which transitions one component into another: see
436
+ * {@link TransitionChannel.set}.
437
+ */
438
+ export class TransitionChannel {
439
+ /** Prefer {@link ProteusApp.transitionChannel} over calling this directly. */
440
+ constructor(app,
441
+ /** @internal */
442
+ wasmHandle) {
443
+ this.app = app;
444
+ this.wasmHandle = wasmHandle;
445
+ }
446
+ /**
447
+ * Transitions `from` into `to`: a 1→1 transition.
448
+ *
449
+ * `from` is hidden, and `to` is shown and moves from `from`'s current
450
+ * geometry to its own declared geometry. If `to` is already transitioning,
451
+ * the request is dropped unless `interruptible` is set; then a new
452
+ * transition starts from wherever `to` is. The transition starts on the
453
+ * next tick. A request that can't run is reported to
454
+ * {@link TransitionChannel.onDropped}. On a destroyed channel, the call is
455
+ * ignored with a warning.
456
+ *
457
+ * @example
458
+ * ```ts
459
+ * const open = app.transitionChannel();
460
+ * button.onClick(() => open.set(panel, button, { duration: 0.4 }));
461
+ * ```
462
+ */
463
+ set(to, from, config, interruptible = false) {
464
+ this.app.wasmApp.channelSet(this.wasmHandle, to.wasmHandle, from.wasmHandle, config, interruptible);
465
+ }
466
+ /**
467
+ * Calls `cb` with the reason each time a {@link TransitionChannel.set} request on
468
+ * this channel can't run. See {@link DropReason}.
469
+ */
470
+ onDropped(cb) {
471
+ this.app.wasmApp.onDropped(this.wasmHandle, cb);
472
+ }
473
+ /** Destroys this channel and its `onDropped` handlers. Later `set` calls are ignored, with a warning. */
474
+ destroy() {
475
+ this.app.wasmApp.channelDestroy(this.wasmHandle);
476
+ }
477
+ }
478
+ // ---------------------------------------------------------------------------
479
+ // TextureHandle
480
+ // ---------------------------------------------------------------------------
481
+ /**
482
+ * A texture in the atlas. Show it on a component with
483
+ * {@link Handle.setTexture}.
484
+ *
485
+ * A texture can't be freed through its handle: once no component references
486
+ * it, the atlas can reclaim its space when it needs room. See
487
+ * {@link Handle.freeResources}.
488
+ */
489
+ export class TextureHandle {
490
+ /** Prefer {@link ProteusApp.texture} over calling this directly. */
491
+ constructor(app,
492
+ /** @internal */
493
+ wasmHandle) {
494
+ this.app = app;
495
+ this.wasmHandle = wasmHandle;
496
+ }
497
+ /** The texture's ID, for {@link ProteusApp.textureFromId}. */
498
+ id() {
499
+ return this.wasmHandle.id();
500
+ }
501
+ /** The texture's kind and size, or `undefined` if it has been evicted or never existed. */
502
+ state() {
503
+ return this.app.wasmApp.textureState(this.wasmHandle);
504
+ }
505
+ }
506
+ // ---------------------------------------------------------------------------
507
+ // VideoHandle
508
+ // ---------------------------------------------------------------------------
509
+ /**
510
+ * A video whose frames your own player supplies. **Experimental.**
511
+ *
512
+ * Proteus shows video; it doesn't play it. Play the video with your own
513
+ * player, usually a `<video>` element, and upload each frame: with
514
+ * {@link VideoHandle.uploadFrom} for a `<video>` element, or
515
+ * {@link VideoHandle.uploadFrame} for pixels you decoded yourself. Show it on
516
+ * components with {@link Handle.showVideo}. Playback controls are the
517
+ * player's own.
518
+ *
519
+ * There is one video at a time: {@link ProteusApp.createVideo} replaces any
520
+ * video that already exists, and its handle stops working.
521
+ *
522
+ * @example
523
+ * ```ts
524
+ * const video = app.createVideo();
525
+ * tile.showVideo(video);
526
+ * const element = document.querySelector("video")!;
527
+ * const upload = () => {
528
+ * video.uploadFrom(element);
529
+ * element.requestVideoFrameCallback(upload);
530
+ * };
531
+ * element.requestVideoFrameCallback(upload);
532
+ * ```
533
+ */
534
+ export class VideoHandle {
535
+ /** Prefer {@link ProteusApp.createVideo} over calling this directly. */
536
+ constructor(app,
537
+ /** @internal */
538
+ wasmHandle) {
539
+ this.app = app;
540
+ this.wasmHandle = wasmHandle;
541
+ }
542
+ /**
543
+ * Uploads one frame of `width × height` RGBA pixels, `width * height * 4`
544
+ * bytes, which every component showing this video draws from the next
545
+ * frame. The video's texture follows the frames' size.
546
+ *
547
+ * Returns `false`, uploading nothing, if the video was released or
548
+ * replaced, or if `rgba` has the wrong length.
549
+ */
550
+ uploadFrame(width, height, rgba) {
551
+ const bytes = rgba instanceof Uint8Array
552
+ ? rgba
553
+ : new Uint8Array(rgba.buffer, rgba.byteOffset, rgba.byteLength);
554
+ return this.app.wasmApp.uploadVideoFrame(this.wasmHandle, width, height, bytes);
555
+ }
556
+ /**
557
+ * Uploads the frame `element` is showing now. Call it for each new frame,
558
+ * for example from `requestVideoFrameCallback`.
559
+ *
560
+ * The frame is drawn to an offscreen canvas and read back, which works in
561
+ * every browser. Returns `false` if the element has no frame yet, or as
562
+ * {@link VideoHandle.uploadFrame} does.
563
+ *
564
+ * @throws a `SecurityError` if the video comes from another origin without
565
+ * CORS, since the browser then won't let its pixels be read.
566
+ */
567
+ uploadFrom(element) {
568
+ const width = element.videoWidth;
569
+ const height = element.videoHeight;
570
+ if (width === 0 || height === 0) {
571
+ return false;
572
+ }
573
+ if (!this.canvas || !this.context) {
574
+ this.canvas = document.createElement("canvas");
575
+ const context = this.canvas.getContext("2d", { willReadFrequently: true });
576
+ if (!context) {
577
+ return false;
578
+ }
579
+ this.context = context;
580
+ }
581
+ if (this.canvas.width !== width || this.canvas.height !== height) {
582
+ this.canvas.width = width;
583
+ this.canvas.height = height;
584
+ }
585
+ this.context.drawImage(element, 0, 0, width, height);
586
+ const pixels = this.context.getImageData(0, 0, width, height).data;
587
+ return this.uploadFrame(width, height, pixels);
588
+ }
589
+ /**
590
+ * Releases this video and its GPU texture. Components showing video show
591
+ * nothing in its place until {@link Handle.hideVideo} or another video.
592
+ */
593
+ release() {
594
+ this.app.wasmApp.releaseVideo(this.wasmHandle);
595
+ }
596
+ }
597
+ // ---------------------------------------------------------------------------
598
+ // ProteusApp
599
+ // ---------------------------------------------------------------------------
600
+ /**
601
+ * An app's components, channels and callbacks.
602
+ *
603
+ * {@link mount} creates one and passes it to your `setup` function; that is the
604
+ * usual way to get one. `new ProteusApp()` creates a standalone app that draws
605
+ * nothing, which is useful for tests and other headless use.
606
+ */
607
+ export class ProteusApp {
608
+ /**
609
+ * Creates a standalone app that draws nothing.
610
+ *
611
+ * @param wasmApp For internal use by {@link mount}; omit it.
612
+ */
613
+ constructor(wasmApp) {
614
+ this.wasmApp = wasmApp ?? new WasmApp();
615
+ }
616
+ /** Creates a component from `spec` and returns its handle. */
617
+ component(spec) {
618
+ return new Handle(this, this.wasmApp.component(spec));
619
+ }
620
+ /**
621
+ * Returns a {@link Handle} for a component ID, from {@link Handle.id} or
622
+ * {@link ComponentData.children}.
623
+ *
624
+ * The ID isn't checked. If its component has been destroyed, the handle
625
+ * behaves like any handle to a destroyed component.
626
+ */
627
+ handleFromId(id) {
628
+ return new Handle(this, WasmHandle.fromId(id));
629
+ }
630
+ /**
631
+ * Creates a transition channel, which transitions one component into
632
+ * another with {@link TransitionChannel.set}. If `owner` is given, the
633
+ * channel is destroyed along with it.
634
+ */
635
+ transitionChannel(owner) {
636
+ // The bridge takes the owner's ID rather than its handle object, which
637
+ // it would otherwise consume.
638
+ return new TransitionChannel(this, this.wasmApp.transitionChannel(owner?.id()));
639
+ }
640
+ /** Returns a {@link TextureHandle} for a texture ID. The same as {@link ProteusApp.textureFromId}. */
641
+ texture(id) {
642
+ return new TextureHandle(this, this.wasmApp.texture(id));
643
+ }
644
+ /**
645
+ * Decodes a PNG or JPEG image and adds it to the atlas, returning a handle
646
+ * to show with {@link Handle.setTexture}.
647
+ *
648
+ * The pixels are on the GPU when this returns, so there is nothing to wait
649
+ * for. You supply the bytes:
650
+ *
651
+ * ```ts
652
+ * const bytes = new Uint8Array(await (await fetch(url)).arrayBuffer());
653
+ * const tex = app.loadTexture(bytes, { maxSide: 512 });
654
+ * if (tex) tile.setTexture(tex);
655
+ * ```
656
+ *
657
+ * Show the texture on a component in the same frame. From the next frame
658
+ * on, a texture that no component uses may be evicted to make room.
659
+ * Several textures can be loaded and then shown: none is evicted in the
660
+ * frame it was loaded.
661
+ *
662
+ * Returns `undefined` if the bytes can't be decoded. An image that decodes
663
+ * but doesn't fit the atlas returns a handle to no texture, which draws
664
+ * nothing.
665
+ */
666
+ loadTexture(bytes, request) {
667
+ const wasm = this.wasmApp.loadTexture(bytes, request ?? null);
668
+ return wasm ? new TextureHandle(this, wasm) : undefined;
669
+ }
670
+ /**
671
+ * Adds RGBA pixels to the atlas and returns a handle to them. `rgba` holds
672
+ * `width * height * 4` bytes. For PNG or JPEG data, use
673
+ * {@link ProteusApp.loadTexture}.
674
+ *
675
+ * Show the texture on a component in the same frame: from the next frame
676
+ * on, a texture that no component uses may be evicted to make room. If the
677
+ * atlas is full, the handle refers to no texture and draws nothing.
678
+ */
679
+ bakeTexture(width, height, rgba, request) {
680
+ return new TextureHandle(this, this.wasmApp.bakeTexture(width, height, rgba, request ?? null));
681
+ }
682
+ /**
683
+ * Creates a video, whose frames your own player supplies.
684
+ * **Experimental**: see {@link VideoHandle}. There is one video at a time:
685
+ * this replaces any video that already exists.
686
+ */
687
+ createVideo() {
688
+ return new VideoHandle(this, this.wasmApp.createVideo());
689
+ }
690
+ /** Returns a {@link TextureHandle} for an ID from {@link TextureHandle.id}. */
691
+ textureFromId(id) {
692
+ return new TextureHandle(this, WasmTextureHandle.fromId(id));
693
+ }
694
+ /** A snapshot of a component's current state, or `undefined` if it has been destroyed. */
695
+ get(handle) {
696
+ return this.wasmApp.get(handle.wasmHandle);
697
+ }
698
+ /**
699
+ * Advances the app by `deltaSeconds`, then runs callbacks for the events
700
+ * that occurred. {@link mount} calls this for you every frame.
701
+ */
702
+ tick(deltaSeconds) {
703
+ this.wasmApp.tick(deltaSeconds);
704
+ }
705
+ /**
706
+ * Updates the pointer position, in world units: the origin is the center of
707
+ * the viewport and y points up. Convert from page coordinates with
708
+ * {@link topLeftToWorld}. {@link mount} reports the pointer for you.
709
+ */
710
+ pointerMoved(x, y) {
711
+ this.wasmApp.pointerMoved(x, y);
712
+ }
713
+ /** Records that the pointer left the viewport. */
714
+ pointerLeft() {
715
+ this.wasmApp.pointerLeft();
716
+ }
717
+ /** Records that the pointer was pressed. */
718
+ pointerPressed() {
719
+ this.wasmApp.pointerPressed();
720
+ }
721
+ /** Records that the pointer was released. */
722
+ pointerReleased() {
723
+ this.wasmApp.pointerReleased();
724
+ }
725
+ /**
726
+ * Calls {@link ProteusApp.tick} on every animation frame, and returns a
727
+ * function that stops the loop.
728
+ *
729
+ * Only for a standalone app created with `new ProteusApp()`. An app from
730
+ * {@link mount} is already ticked every frame; starting this loop as well
731
+ * would tick it twice.
732
+ */
733
+ startAnimationLoop() {
734
+ let lastTime = null;
735
+ let stopped = false;
736
+ const step = (time) => {
737
+ if (stopped)
738
+ return;
739
+ if (lastTime !== null) {
740
+ this.tick((time - lastTime) / 1000);
741
+ }
742
+ lastTime = time;
743
+ requestAnimationFrame(step);
744
+ };
745
+ requestAnimationFrame(step);
746
+ return () => {
747
+ stopped = true;
748
+ };
749
+ }
750
+ }