@playcanvas/web-components 0.19.0 → 0.21.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 (104) hide show
  1. package/dist/app.d.cts +5 -99
  2. package/dist/app.d.ts +5 -99
  3. package/dist/asset-binding.d.cts +1 -0
  4. package/dist/asset-binding.d.ts +1 -0
  5. package/dist/components/anim-clip.d.cts +8 -10
  6. package/dist/components/anim-clip.d.ts +8 -10
  7. package/dist/components/anim-component.d.cts +5 -4
  8. package/dist/components/anim-component.d.ts +5 -4
  9. package/dist/components/audio-listener-component.d.cts +5 -4
  10. package/dist/components/audio-listener-component.d.ts +5 -4
  11. package/dist/components/button-component.d.cts +14 -9
  12. package/dist/components/button-component.d.ts +14 -9
  13. package/dist/components/camera-component.d.cts +5 -4
  14. package/dist/components/camera-component.d.ts +5 -4
  15. package/dist/components/collision-component.d.cts +5 -4
  16. package/dist/components/collision-component.d.ts +5 -4
  17. package/dist/components/component.d.cts +2 -2
  18. package/dist/components/component.d.ts +2 -2
  19. package/dist/components/element-component.d.cts +5 -4
  20. package/dist/components/element-component.d.ts +5 -4
  21. package/dist/components/gsplat-component.d.cts +5 -4
  22. package/dist/components/gsplat-component.d.ts +5 -4
  23. package/dist/components/joint-component.d.cts +29 -14
  24. package/dist/components/joint-component.d.ts +29 -14
  25. package/dist/components/layout-child-component.d.cts +5 -4
  26. package/dist/components/layout-child-component.d.ts +5 -4
  27. package/dist/components/layout-group-component.d.cts +5 -4
  28. package/dist/components/layout-group-component.d.ts +5 -4
  29. package/dist/components/light-component.d.cts +5 -4
  30. package/dist/components/light-component.d.ts +5 -4
  31. package/dist/components/particle-system-component.d.cts +22 -5
  32. package/dist/components/particle-system-component.d.ts +22 -5
  33. package/dist/components/render-component.d.cts +5 -4
  34. package/dist/components/render-component.d.ts +5 -4
  35. package/dist/components/rigid-body-component.d.cts +5 -4
  36. package/dist/components/rigid-body-component.d.ts +5 -4
  37. package/dist/components/screen-component.d.cts +5 -4
  38. package/dist/components/screen-component.d.ts +5 -4
  39. package/dist/components/script-component.d.cts +9 -6
  40. package/dist/components/script-component.d.ts +9 -6
  41. package/dist/components/script-instance.d.cts +14 -6
  42. package/dist/components/script-instance.d.ts +14 -6
  43. package/dist/components/scroll-view-component.d.cts +29 -16
  44. package/dist/components/scroll-view-component.d.ts +29 -16
  45. package/dist/components/scrollbar-component.d.cts +11 -7
  46. package/dist/components/scrollbar-component.d.ts +11 -7
  47. package/dist/components/sound-component.d.cts +5 -4
  48. package/dist/components/sound-component.d.ts +5 -4
  49. package/dist/custom-elements.json +59 -59
  50. package/dist/entity-reference.d.cts +13 -0
  51. package/dist/entity-reference.d.ts +13 -0
  52. package/dist/material.d.cts +8 -6
  53. package/dist/material.d.ts +8 -6
  54. package/dist/model.d.cts +8 -9
  55. package/dist/model.d.ts +8 -9
  56. package/dist/parse.d.cts +0 -3
  57. package/dist/parse.d.ts +0 -3
  58. package/dist/pointer-controller.d.cts +1 -0
  59. package/dist/pointer-controller.d.ts +1 -0
  60. package/dist/pwc.cjs +1085 -633
  61. package/dist/pwc.cjs.map +1 -1
  62. package/dist/pwc.js +1085 -633
  63. package/dist/pwc.js.map +1 -1
  64. package/dist/pwc.min.js +1 -1
  65. package/dist/pwc.min.js.map +1 -1
  66. package/dist/pwc.min.mjs +1 -1
  67. package/dist/pwc.min.mjs.map +1 -1
  68. package/dist/pwc.mjs +1086 -634
  69. package/dist/pwc.mjs.map +1 -1
  70. package/dist/sky.d.cts +6 -7
  71. package/dist/sky.d.ts +6 -7
  72. package/dist/vscode.html-custom-data.json +10 -10
  73. package/dist/web-types.json +58 -58
  74. package/package.json +3 -3
  75. package/src/app.ts +16 -416
  76. package/src/asset-binding.ts +141 -0
  77. package/src/components/anim-clip.ts +22 -45
  78. package/src/components/anim-component.ts +10 -6
  79. package/src/components/audio-listener-component.ts +6 -5
  80. package/src/components/button-component.ts +25 -15
  81. package/src/components/camera-component.ts +6 -5
  82. package/src/components/collision-component.ts +6 -5
  83. package/src/components/component.ts +6 -4
  84. package/src/components/element-component.ts +6 -5
  85. package/src/components/gsplat-component.ts +6 -5
  86. package/src/components/joint-component.ts +36 -20
  87. package/src/components/layout-child-component.ts +6 -5
  88. package/src/components/layout-group-component.ts +6 -5
  89. package/src/components/light-component.ts +6 -5
  90. package/src/components/particle-system-component.ts +56 -30
  91. package/src/components/render-component.ts +6 -5
  92. package/src/components/rigid-body-component.ts +6 -5
  93. package/src/components/screen-component.ts +6 -5
  94. package/src/components/script-component.ts +28 -17
  95. package/src/components/script-instance.ts +14 -6
  96. package/src/components/scroll-view-component.ts +56 -34
  97. package/src/components/scrollbar-component.ts +20 -13
  98. package/src/components/sound-component.ts +6 -5
  99. package/src/entity-reference.ts +237 -0
  100. package/src/material.ts +25 -29
  101. package/src/model.ts +24 -48
  102. package/src/parse.ts +0 -35
  103. package/src/pointer-controller.ts +550 -0
  104. package/src/sky.ts +19 -30
@@ -0,0 +1,550 @@
1
+ import type { AppBase, CameraComponent, GraphNode, GSplatComponent } from 'playcanvas';
2
+ import { MeshInstance, Picker } from 'playcanvas';
3
+
4
+ import type { EntityBaseElement } from './entity-base';
5
+ import { SYNTHESIZED_EVENTS } from './entity-base';
6
+
7
+ // Keep `export` on these declarations. TypeScript removes the declaration and its inline export
8
+ // when `stripInternal` is enabled. A separate `export { ... }` statement would remain in the
9
+ // generated .d.ts file and refer to a declaration that had been removed.
10
+
11
+ /**
12
+ * The event types whose listeners make an element a hover target. Hover resolution walks past
13
+ * elements listening for none of them, so a silent element never swallows an ancestor's
14
+ * enter/leave pair.
15
+ */
16
+ const hoverEventTypes = ['pointerenter', 'pointerleave', 'pointermove'] as const;
17
+
18
+ /**
19
+ * The canvas listeners each synthesized event type is driven by. Enter and leave are derived
20
+ * from move picks. A click is concluded from the down/up pair, with pointercancel discarding a
21
+ * press the browser takes back (for example a touch that becomes a scroll).
22
+ */
23
+ const canvasEventsFor: Record<(typeof SYNTHESIZED_EVENTS)[number], readonly string[]> = {
24
+ pointermove: ['pointermove'],
25
+ pointerenter: ['pointermove'],
26
+ pointerleave: ['pointermove'],
27
+ pointerdown: ['pointerdown'],
28
+ pointerup: ['pointerup'],
29
+ click: ['pointerdown', 'pointerup', 'pointercancel']
30
+ };
31
+
32
+ /**
33
+ * How long after a click a further click on the same target still raises the click count that
34
+ * `detail` carries, approximating the platform's double-click time.
35
+ */
36
+ const CLICK_CHAIN_MS = 500;
37
+
38
+ /**
39
+ * Finds the nearest common inclusive ancestor of two picked nodes - the node a click belongs to
40
+ * when the press and the release picked different geometry, exactly as the DOM assigns a click
41
+ * whose down and up have different targets.
42
+ *
43
+ * @param a - The node the press picked, or `null`.
44
+ * @param b - The node the release picked, or `null`.
45
+ * @returns The nearest common inclusive ancestor, or `null` when there is none.
46
+ */
47
+ const commonAncestor = (a: GraphNode | null, b: GraphNode | null): GraphNode | null => {
48
+ const ancestors = new Set<GraphNode>();
49
+ for (let node = a; node !== null; node = node.parent) {
50
+ ancestors.add(node);
51
+ }
52
+ for (let node = b; node !== null; node = node.parent) {
53
+ if (ancestors.has(node)) {
54
+ return node;
55
+ }
56
+ }
57
+ return null;
58
+ };
59
+
60
+ /**
61
+ * The services the pointer controller needs from its host `<pc-app>` element. Both are read
62
+ * fresh on every use, so the controller follows the host's registrations and tree without
63
+ * holding any of them.
64
+ *
65
+ * @internal
66
+ */
67
+ export type PointerHost = {
68
+ /**
69
+ * Resolves a graph node to the element fronting it, or `null` for a node no element fronts
70
+ * (for example, a node inside a model's instantiated hierarchy).
71
+ */
72
+ elementFromNode(node: GraphNode): EntityBaseElement | null;
73
+
74
+ /**
75
+ * The entity-fronting elements currently under the host, whose listeners decide which canvas
76
+ * handlers are needed.
77
+ */
78
+ pointerTargets(): EntityBaseElement[];
79
+ };
80
+
81
+ /**
82
+ * The pointer-input subsystem of a `<pc-app>` element: it owns the engine {@link Picker}, the
83
+ * canvas pointer handlers, and everything between them - mapping browser coordinates into the
84
+ * drawing buffer, selecting the camera, resolving picked nodes to listening elements, tracking
85
+ * hover, and dispatching the synthesized pointer and click events in canvas-event order.
86
+ *
87
+ * The host drives a small lifecycle: {@link connect} once the application and canvas exist,
88
+ * {@link resize} when the drawing buffer changes size, {@link syncListeners} when a descendant's
89
+ * pointer listeners change, and {@link disconnect} on teardown. Everything else is internal.
90
+ *
91
+ * @internal
92
+ */
93
+ export class PointerController {
94
+ private _host: PointerHost;
95
+
96
+ /**
97
+ * Incremented by every connect and disconnect. Async work captures the value when it starts
98
+ * and stops if it has moved on - so a pick or dispatch belonging to an earlier connection
99
+ * can neither keep reading through its destroyed picker nor deliver into a later
100
+ * connection. The field null checks alone cannot tell the two apart once a reconnect has
101
+ * repopulated them.
102
+ */
103
+ private _generation = 0;
104
+
105
+ private _app: AppBase | null = null;
106
+
107
+ private _canvas: HTMLCanvasElement | null = null;
108
+
109
+ private _picker: Picker | null = null;
110
+
111
+ private _hoveredEntity: EntityBaseElement | null = null;
112
+
113
+ // Identifies the newest in-flight hover pick, so out-of-order results can be discarded
114
+ private _pickToken = 0;
115
+
116
+ private _pointerHandlers: Record<string, EventListener | null> = {
117
+ pointermove: null,
118
+ pointerdown: null,
119
+ pointerup: null,
120
+ pointercancel: null
121
+ };
122
+
123
+ /**
124
+ * The pick of each pointer's primary-button press, keyed by pointerId and kept while a click
125
+ * may still conclude it. The promise is stored rather than its result, so a release can
126
+ * await a press pick that has not resolved yet. Entries are removed by the matching
127
+ * pointerup or pointercancel, and only ever stored while some element listens for click -
128
+ * which is also what keeps those two canvas listeners attached.
129
+ */
130
+ private _downPicks = new Map<number, Promise<GraphNode | null>>();
131
+
132
+ /** Whether any element in the tree listens for click. Maintained by syncListeners. */
133
+ private _clickListened = false;
134
+
135
+ /**
136
+ * The previous click's target, time and count, for chaining successive clicks into the
137
+ * click count that `detail` carries. `null` until a click has fired.
138
+ */
139
+ private _lastClick: { element: EntityBaseElement; time: number; count: number } | null = null;
140
+
141
+ /**
142
+ * Serializes dispatch of the discrete synthesized events (pointerdown, pointerup, click),
143
+ * whose picks resolve in GPU order, not canvas-event order. Replaced on disconnect, so a
144
+ * pick that never resolves cannot stall the dispatches of a later boot.
145
+ */
146
+ private _dispatchChain: Promise<void> = Promise.resolve();
147
+
148
+ /**
149
+ * @param host - The services the controller reads from its host element.
150
+ */
151
+ constructor(host: PointerHost) {
152
+ this._host = host;
153
+ }
154
+
155
+ /**
156
+ * Creates the picker and the canvas handlers for a booted application, and attaches whatever
157
+ * canvas listeners the tree's current element listeners already need (handlers created from
158
+ * inline attributes when their elements were first upgraded, or listeners carried over from
159
+ * before a re-boot).
160
+ *
161
+ * @param app - The application to pick against.
162
+ * @param canvas - The canvas the application renders into.
163
+ */
164
+ connect(app: AppBase, canvas: HTMLCanvasElement) {
165
+ this._generation++;
166
+ this._app = app;
167
+ this._canvas = canvas;
168
+
169
+ const { width, height } = app.graphicsDevice;
170
+ this._picker = new Picker(app, width, height);
171
+
172
+ // Create bound handlers but don't attach them yet. The move handler is async, so it is
173
+ // wrapped to discard the promise - a listener must not return one.
174
+ const listener = (handler: (event: PointerEvent) => void | Promise<void>): EventListener => {
175
+ return (event: Event) => {
176
+ handler.call(this, event as PointerEvent);
177
+ };
178
+ };
179
+
180
+ this._pointerHandlers.pointermove = listener(this._onPointerMove);
181
+ this._pointerHandlers.pointerdown = listener(this._onPointerDown);
182
+ this._pointerHandlers.pointerup = listener(this._onPointerUp);
183
+ this._pointerHandlers.pointercancel = (event: Event) => {
184
+ this._downPicks.delete((event as PointerEvent).pointerId);
185
+ };
186
+
187
+ this.syncListeners();
188
+ }
189
+
190
+ /**
191
+ * Detaches the canvas listeners and drops every piece of pointer state, so nothing picked or
192
+ * queued before the teardown can affect a later boot. Safe to call on a controller that was
193
+ * never connected.
194
+ */
195
+ disconnect() {
196
+ this._generation++;
197
+
198
+ if (this._canvas) {
199
+ Object.entries(this._pointerHandlers).forEach(([type, handler]) => {
200
+ if (handler) {
201
+ this._canvas!.removeEventListener(type, handler);
202
+ }
203
+ });
204
+ }
205
+
206
+ this._app = null;
207
+ this._canvas = null;
208
+ this._picker = null;
209
+ this._hoveredEntity = null;
210
+ this._pointerHandlers = {
211
+ pointermove: null,
212
+ pointerdown: null,
213
+ pointerup: null,
214
+ pointercancel: null
215
+ };
216
+ this._downPicks.clear();
217
+ this._clickListened = false;
218
+ this._lastClick = null;
219
+
220
+ // Replace the chain: a pick that never resolves must not stall a later boot's dispatches
221
+ this._dispatchChain = Promise.resolve();
222
+ }
223
+
224
+ /**
225
+ * Resizes the picker to the drawing buffer. The picker must track the buffer, or picks would
226
+ * land at stale coordinates after a resize.
227
+ *
228
+ * @param width - The drawing buffer width.
229
+ * @param height - The drawing buffer height.
230
+ */
231
+ resize(width: number, height: number) {
232
+ this._picker?.resize(width, height);
233
+ }
234
+
235
+ /**
236
+ * Attaches exactly the canvas listeners the tree's current element listeners need, and
237
+ * detaches the rest. Called whenever a listener connects or disconnects anywhere under the
238
+ * host element: several synthesized types can need the same canvas listener (enter, leave
239
+ * and move all ride the move pick; click rides the down/up pair), so one type's removal
240
+ * must not detach a listener another type still uses. Re-attaching an attached listener is
241
+ * a no-op by EventTarget semantics, so no attach state is kept. Does nothing before
242
+ * {@link connect} - connecting syncs once the handlers exist.
243
+ */
244
+ syncListeners() {
245
+ const canvas = this._canvas;
246
+ if (!canvas) return;
247
+
248
+ const elements = this._host.pointerTargets();
249
+ const needed = new Set<string>();
250
+ for (const type of SYNTHESIZED_EVENTS) {
251
+ if (elements.some((element) => element._hasListeners(type))) {
252
+ canvasEventsFor[type].forEach((canvasType) => needed.add(canvasType));
253
+ }
254
+ }
255
+ this._clickListened = elements.some((element) => element._hasListeners('click'));
256
+
257
+ Object.entries(this._pointerHandlers).forEach(([canvasType, handler]) => {
258
+ if (!handler) return;
259
+ if (needed.has(canvasType)) {
260
+ canvas.addEventListener(canvasType, handler);
261
+ } else {
262
+ canvas.removeEventListener(canvasType, handler);
263
+ }
264
+ });
265
+ }
266
+
267
+ /**
268
+ * Resolves the element that owns hover for a picked node: the nearest node up the parent
269
+ * chain - starting with the node itself - whose element listens for any of the hover event
270
+ * types. Skipping silent elements matches {@link _elementWithListener}, so a registered
271
+ * element with no hover listeners (a `<pc-model>` host, a plain child entity) is transparent
272
+ * to hover rather than swallowing a listening ancestor's enter/leave pair.
273
+ *
274
+ * @param node - The picked node, or `null`.
275
+ * @returns The hover-owning element, or `null`.
276
+ */
277
+ private _hoverTarget(node: GraphNode | null): EntityBaseElement | null {
278
+ while (node !== null) {
279
+ const element = this._host.elementFromNode(node);
280
+ if (element && hoverEventTypes.some((type) => element._hasListeners(type))) {
281
+ return element;
282
+ }
283
+ node = node.parent;
284
+ }
285
+ return null;
286
+ }
287
+
288
+ /**
289
+ * Like {@link _hoverTarget}, but for one event type: skips elements without a listener for
290
+ * `type`, so a hit on an unlistened child still reaches a listening ancestor.
291
+ *
292
+ * @param node - The picked node, or `null`.
293
+ * @param type - The pointer event type a listener is required for.
294
+ * @returns The nearest listening element, or `null`.
295
+ */
296
+ private _elementWithListener(node: GraphNode | null, type: string): EntityBaseElement | null {
297
+ while (node !== null) {
298
+ const element = this._host.elementFromNode(node);
299
+ if (element?._hasListeners(type)) {
300
+ return element;
301
+ }
302
+ node = node.parent;
303
+ }
304
+ return null;
305
+ }
306
+
307
+ /**
308
+ * Converts a pointer event's client coordinates into drawing-buffer coordinates - the space
309
+ * the pick buffer and the camera viewports are laid out in. When the canvas has no CSS box
310
+ * to map through (jsdom; a hidden canvas receives no pointer events in a browser), the
311
+ * client coordinates are passed through unmapped and `mapped` is false, so callers know the
312
+ * coordinates correspond to no real geometry.
313
+ *
314
+ * @param event - The pointer event to convert.
315
+ * @param canvas - The canvas the event was dispatched on.
316
+ * @returns The buffer-space coordinates, and whether they were actually mapped.
317
+ */
318
+ private _getPickerCoordinates(
319
+ event: PointerEvent,
320
+ canvas: HTMLCanvasElement
321
+ ): { x: number; y: number; mapped: boolean } {
322
+ const canvasRect = canvas.getBoundingClientRect();
323
+ if (canvasRect.width === 0 || canvasRect.height === 0) {
324
+ return { x: event.clientX, y: event.clientY, mapped: false };
325
+ }
326
+ const scaleX = canvas.width / canvasRect.width;
327
+ const scaleY = canvas.height / canvasRect.height;
328
+ return {
329
+ x: (event.clientX - canvasRect.left) * scaleX,
330
+ y: (event.clientY - canvasRect.top) * scaleY,
331
+ mapped: true
332
+ };
333
+ }
334
+
335
+ /**
336
+ * Whether a camera's viewport contains the point. A camera renders into its normalized
337
+ * `rect`, whose origin is the bottom-left of the canvas while buffer coordinates run from
338
+ * the top-left - so the vertical test flips, as the engine's ElementInput flips it for UI
339
+ * input. The right and bottom edges are exclusive: a viewport rasterizes the half-open
340
+ * pixel range [left, right) x [top, bottom), so a coordinate on a shared edge belongs to
341
+ * the viewport whose first pixel it is - never to the one it just left, whose pick buffer
342
+ * holds nothing there.
343
+ *
344
+ * @param camera - The camera to test.
345
+ * @param x - The x coordinate, in buffer space.
346
+ * @param y - The y coordinate, in buffer space.
347
+ * @param canvas - The canvas the coordinates are relative to.
348
+ * @returns Whether the camera's viewport contains the point.
349
+ */
350
+ private _cameraContains(camera: CameraComponent, x: number, y: number, canvas: HTMLCanvasElement): boolean {
351
+ const rect = camera.rect;
352
+ const left = rect.x * canvas.width;
353
+ const bottom = (1 - rect.y) * canvas.height;
354
+ const top = bottom - rect.w * canvas.height;
355
+ return x >= left && x < left + rect.z * canvas.width && y >= top && y < bottom;
356
+ }
357
+
358
+ /**
359
+ * Picks the scene under the pointer and returns the graph node that was hit, or `null`.
360
+ *
361
+ * The camera is resolved the way the engine's ElementInput resolves it for UI input:
362
+ * enabled cameras are tried topmost-first (they render in ascending `priority` order),
363
+ * skipping cameras that render to a texture and cameras whose viewport `rect` does not
364
+ * contain the pointer. A camera that picks nothing ends the search if it clears the color
365
+ * buffer - its background visually owns the pixel - and otherwise cedes to the cameras
366
+ * beneath it, so an overlay camera only intercepts picks where it actually drew something.
367
+ * The pick buffer is prepared per camera, so each camera picks from its own layers.
368
+ *
369
+ * The read back is asynchronous because the synchronous {@link Picker.getSelection} is not
370
+ * supported on WebGPU, where it returns an empty selection rather than failing - which
371
+ * silently disabled every `onpointer*` handler once WebGPU became the resolved backend. The
372
+ * async variant works on both backends and does not block the main thread on a GPU read.
373
+ *
374
+ * @param event - The pointer event to pick under.
375
+ * @returns The graph node under the pointer, or `null` if nothing was hit.
376
+ */
377
+ private async _pickNode(event: PointerEvent): Promise<GraphNode | null> {
378
+ const generation = this._generation;
379
+ const app = this._app;
380
+ const picker = this._picker;
381
+ const canvas = this._canvas;
382
+ if (!app || !picker || !canvas) return null;
383
+
384
+ const { x, y, mapped } = this._getPickerCoordinates(event, canvas);
385
+
386
+ // Walked from the end: the array is sorted by ascending priority, so the last camera
387
+ // renders last and sits on top. Read through .at() because a pick handler may remove
388
+ // cameras while an earlier iteration's read back is in flight.
389
+ const cameras = app.systems.camera?.cameras ?? [];
390
+ for (let i = cameras.length - 1; i >= 0; i--) {
391
+ const camera = cameras.at(i);
392
+
393
+ // A camera rendering to a texture is not on the canvas.
394
+ if (!camera || camera.renderTarget) continue;
395
+
396
+ // Coordinates that could not be mapped cannot be tested for containment.
397
+ if (mapped && !this._cameraContains(camera, x, y, canvas)) continue;
398
+
399
+ picker.prepare(camera, app.scene);
400
+ const selection = await picker.getSelectionAsync(x, y);
401
+
402
+ // The host may have disconnected - or disconnected and reconnected - while the read
403
+ // back was in flight. Either way this pick's connection is gone.
404
+ if (generation !== this._generation) return null;
405
+
406
+ if (selection.length > 0) {
407
+ const item = selection[0];
408
+ return item instanceof MeshInstance ? item.node : (item as GSplatComponent).entity;
409
+ }
410
+
411
+ // Nothing hit. A camera that clears the color buffer paints its background over
412
+ // everything beneath it, so the miss is final; one that does not is an overlay
413
+ // that the cameras beneath show through, so they get their turn.
414
+ if (camera.clearColorBuffer) return null;
415
+ }
416
+
417
+ return null;
418
+ }
419
+
420
+ private async _onPointerMove(event: PointerEvent) {
421
+ if (!this._picker || !this._app) return;
422
+
423
+ // Moves arrive faster than a pick resolves, so results can land out of order. Only the
424
+ // newest pick may update the hover state - an older one describes a pointer position the
425
+ // user has already left, and one from an earlier connection describes a scene that no
426
+ // longer exists.
427
+ const generation = this._generation;
428
+ const token = ++this._pickToken;
429
+ const node = await this._pickNode(event);
430
+ if (token !== this._pickToken || generation !== this._generation) return;
431
+
432
+ // The hovered element is the nearest one up the node's parent chain with a hover
433
+ // listener - the nearest-listener rule down/up use. Dispatch is still gated per event
434
+ // type below: having any hover listener selects the target, each event needs its own.
435
+ const newHoverEntity = this._hoverTarget(node);
436
+
437
+ // Handle enter/leave events
438
+ if (this._hoveredEntity !== newHoverEntity) {
439
+ if (this._hoveredEntity && this._hoveredEntity._hasListeners('pointerleave')) {
440
+ this._hoveredEntity.dispatchEvent(new PointerEvent('pointerleave', event));
441
+ }
442
+ if (newHoverEntity && newHoverEntity._hasListeners('pointerenter')) {
443
+ newHoverEntity.dispatchEvent(new PointerEvent('pointerenter', event));
444
+ }
445
+ }
446
+
447
+ // Update hover state
448
+ this._hoveredEntity = newHoverEntity;
449
+
450
+ // Handle pointermove event
451
+ if (newHoverEntity && newHoverEntity._hasListeners('pointermove')) {
452
+ newHoverEntity.dispatchEvent(new PointerEvent('pointermove', event));
453
+ }
454
+ }
455
+
456
+ /**
457
+ * Appends a dispatch step to {@link _dispatchChain}. Must be called synchronously from the
458
+ * canvas event handler - the order of appends is what carries canvas-event order. A step
459
+ * that rejects is reported and released, so the steps queued behind it still dispatch.
460
+ *
461
+ * @param step - The dispatch work to run once every earlier step has finished.
462
+ */
463
+ private _chainDispatch(step: () => Promise<void>) {
464
+ this._dispatchChain = this._dispatchChain.then(step).catch((error) => {
465
+ console.error(error);
466
+ });
467
+ }
468
+
469
+ private _onPointerDown(event: PointerEvent) {
470
+ if (!this._picker || !this._app) return;
471
+
472
+ const generation = this._generation;
473
+
474
+ // Picks stay concurrent - only the dispatch of the results is serialized
475
+ const pick = this._pickNode(event);
476
+
477
+ // A click concludes on the matching pointerup, which needs to know what the press
478
+ // picked. Primary button only - the only button a click can conclude from - and only
479
+ // while click is listened for, since it is the click mapping that keeps the pointerup
480
+ // and pointercancel listeners attached to clean the entry up again.
481
+ if (this._clickListened && event.button === 0) {
482
+ this._downPicks.set(event.pointerId, pick);
483
+ }
484
+
485
+ this._chainDispatch(async () => {
486
+ const node = await pick;
487
+ if (generation !== this._generation) return; // this press's connection is gone
488
+
489
+ const entityElement = this._elementWithListener(node, 'pointerdown');
490
+ if (entityElement) {
491
+ entityElement.dispatchEvent(new PointerEvent('pointerdown', event));
492
+ }
493
+ });
494
+ }
495
+
496
+ private _onPointerUp(event: PointerEvent) {
497
+ if (!this._picker || !this._app) return;
498
+
499
+ const generation = this._generation;
500
+
501
+ // The press pick this release may conclude as a click. Claimed synchronously, so the
502
+ // entry is gone before any other event for this pointer can be handled.
503
+ const downPick = this._downPicks.get(event.pointerId);
504
+ this._downPicks.delete(event.pointerId);
505
+
506
+ const pick = this._pickNode(event);
507
+
508
+ this._chainDispatch(async () => {
509
+ const node = await pick;
510
+ if (generation !== this._generation) return; // this release's connection is gone
511
+
512
+ const entityElement = this._elementWithListener(node, 'pointerup');
513
+ if (entityElement) {
514
+ entityElement.dispatchEvent(new PointerEvent('pointerup', event));
515
+ }
516
+ });
517
+
518
+ // A click fires where the DOM fires it: at the nearest common inclusive ancestor of
519
+ // what the press and the release picked, for the primary button only. Appended after
520
+ // the release's own step, so it dispatches after the pointerup that concludes it.
521
+ if (!downPick || event.button !== 0) return;
522
+
523
+ this._chainDispatch(async () => {
524
+ // A rejected pick was already reported by the press or release step that awaited it;
525
+ // here it just means no click can conclude.
526
+ const picked = await Promise.all([downPick, pick]).catch(() => null);
527
+ if (!picked || generation !== this._generation) return;
528
+
529
+ const [downNode, upNode] = picked;
530
+ const clickElement = this._elementWithListener(commonAncestor(downNode, upNode), 'click');
531
+ if (clickElement) {
532
+ const click = new PointerEvent('click', event);
533
+
534
+ // The init above copied pointerup's `detail`, which the Pointer Events spec fixes
535
+ // at 0 - but click is exempt: its detail is the click count, chained here as the
536
+ // platform chains it (same target, within the double-click window). Overridden
537
+ // with defineProperty because an event instance used as an init dict cannot have
538
+ // single fields replaced.
539
+ const time = performance.now();
540
+ const last = this._lastClick;
541
+ const count =
542
+ last && last.element === clickElement && time - last.time <= CLICK_CHAIN_MS ? last.count + 1 : 1;
543
+ this._lastClick = { element: clickElement, time, count };
544
+ Object.defineProperty(click, 'detail', { value: count });
545
+
546
+ clickElement.dispatchEvent(click);
547
+ }
548
+ });
549
+ }
550
+ }
package/src/sky.ts CHANGED
@@ -1,8 +1,9 @@
1
- import type { Asset, EventHandle, Scene, Texture } from 'playcanvas';
1
+ import type { Asset, Scene, Texture } from 'playcanvas';
2
2
  import { EnvLighting, LAYERID_SKYBOX, Quat, Vec3 } from 'playcanvas';
3
3
 
4
4
  import type { AppElement } from './app';
5
5
  import { useAsset } from './asset';
6
+ import { AssetBinding } from './asset-binding';
6
7
  import { AsyncElement } from './async-element';
7
8
  import { parseBool, parseEnum, parseNumber, parseVec3 } from './parse';
8
9
 
@@ -38,17 +39,17 @@ class SkyElement extends AsyncElement {
38
39
 
39
40
  /**
40
41
  * Incremented on every new load and on disconnect, and captured by a load when it starts. A
41
- * load that resumes from an await or a load callback abandons itself if the value has moved
42
- * on, so a superseded load cannot generate a skybox for a scene it no longer configures.
42
+ * load that resumes from an await abandons itself if the value has moved on, so a superseded
43
+ * load cannot generate a skybox for a scene it no longer configures. The asset subscription
44
+ * itself is guarded by the binding below.
43
45
  */
44
46
  private _loadGeneration = 0;
45
47
 
46
48
  /**
47
- * The pending asset-load subscription of the current load, if it is waiting for its asset.
48
- * Held so that whatever supersedes the load can detach the handler from the asset, rather
49
- * than leave it registered until the asset loads (or forever, if it never does).
49
+ * Watches the current texture asset while it loads. Starting a new load or disconnecting
50
+ * cancels it, so a superseded texture can never generate the skybox.
50
51
  */
51
- private _loadHandle: EventHandle | null = null;
52
+ private _binding = new AssetBinding();
52
53
 
53
54
  connectedCallback() {
54
55
  this._loadSkybox();
@@ -57,17 +58,12 @@ class SkyElement extends AsyncElement {
57
58
 
58
59
  disconnectedCallback() {
59
60
  this._loadGeneration++;
60
- this._detachLoadHandler();
61
+ this._binding.cancel();
61
62
  this._unloadSkybox();
62
63
  this._appElement = null;
63
64
  this._resetReady();
64
65
  }
65
66
 
66
- private _detachLoadHandler() {
67
- this._loadHandle?.off();
68
- this._loadHandle = null;
69
- }
70
-
71
67
  private _generateSkybox(asset: Asset) {
72
68
  if (!this._scene) return;
73
69
 
@@ -104,7 +100,7 @@ class SkyElement extends AsyncElement {
104
100
  private async _loadSkybox() {
105
101
  // Supersede any load already in flight - only the newest load may generate the skybox
106
102
  const generation = ++this._loadGeneration;
107
- this._detachLoadHandler();
103
+ this._binding.cancel();
108
104
 
109
105
  const appElement = await this.closestApp?.ready();
110
106
 
@@ -120,27 +116,20 @@ class SkyElement extends AsyncElement {
120
116
 
121
117
  this._appElement = appElement;
122
118
 
123
- const asset = useAsset(this._asset);
124
- if (!asset) {
119
+ // The scene is only adopted once the reference resolves: an unresolved id must leave the
120
+ // scene untouched, or this element's teardown would destroy a skybox it never created.
121
+ // The bind below repeats the resolution, which useAsset documents as free - it cannot
122
+ // happen after the bind, because a loaded asset delivers before bind returns and
123
+ // _generateSkybox needs the scene by then.
124
+ if (!useAsset(this._asset)) {
125
125
  return;
126
126
  }
127
127
 
128
128
  this._scene = app.scene;
129
129
 
130
- if (asset.loaded) {
131
- this._generateSkybox(asset);
132
- } else {
133
- // The generation is re-checked even though a superseded handler is detached: the
134
- // detach relies on how the engine's event emitter treats removal, while the check
135
- // holds on its own.
136
- this._loadHandle = asset.once('load', () => {
137
- this._loadHandle = null;
138
- if (generation !== this._loadGeneration) {
139
- return;
140
- }
141
- this._generateSkybox(asset);
142
- });
143
- }
130
+ this._binding.bind(this._asset, {
131
+ load: (asset) => this._generateSkybox(asset)
132
+ });
144
133
  }
145
134
 
146
135
  private _unloadSkybox() {