@playcanvas/web-components 0.18.0 → 0.19.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/src/app.ts CHANGED
@@ -66,6 +66,7 @@ import {
66
66
 
67
67
  import type { AssetElement } from './asset';
68
68
  import { AsyncElement } from './async-element';
69
+ import { SYNTHESIZED_EVENTS } from './entity-base';
69
70
  import type { EntityBaseElement } from './entity-base';
70
71
  import type { EntityOwnerElement } from './entity-owner';
71
72
  import { LoadingBar } from './loading-bar';
@@ -73,9 +74,6 @@ import type { MaterialElement } from './material';
73
74
  import { parseBool, parseEnum, parseNumber } from './parse';
74
75
  import type { WasmElement } from './wasm';
75
76
 
76
- /** The pointer event types the application synthesizes on `<pc-entity>` elements via picking. */
77
- const pointerEventTypes = ['pointermove', 'pointerdown', 'pointerup', 'pointerenter', 'pointerleave'] as const;
78
-
79
77
  /**
80
78
  * The event types whose listeners make an element a hover target. Hover resolution walks past
81
79
  * elements listening for none of them, so a silent element never swallows an ancestor's
@@ -83,6 +81,48 @@ const pointerEventTypes = ['pointermove', 'pointerdown', 'pointerup', 'pointeren
83
81
  */
84
82
  const hoverEventTypes = ['pointerenter', 'pointerleave', 'pointermove'] as const;
85
83
 
84
+ /**
85
+ * The canvas listeners each synthesized event type is driven by. Enter and leave are derived
86
+ * from move picks. A click is concluded from the down/up pair, with pointercancel discarding a
87
+ * press the browser takes back (for example a touch that becomes a scroll).
88
+ */
89
+ const canvasEventsFor: Record<(typeof SYNTHESIZED_EVENTS)[number], readonly string[]> = {
90
+ pointermove: ['pointermove'],
91
+ pointerenter: ['pointermove'],
92
+ pointerleave: ['pointermove'],
93
+ pointerdown: ['pointerdown'],
94
+ pointerup: ['pointerup'],
95
+ click: ['pointerdown', 'pointerup', 'pointercancel']
96
+ };
97
+
98
+ /**
99
+ * How long after a click a further click on the same target still raises the click count that
100
+ * `detail` carries, approximating the platform's double-click time.
101
+ */
102
+ const CLICK_CHAIN_MS = 500;
103
+
104
+ /**
105
+ * Finds the nearest common inclusive ancestor of two picked nodes - the node a click belongs to
106
+ * when the press and the release picked different geometry, exactly as the DOM assigns a click
107
+ * whose down and up have different targets.
108
+ *
109
+ * @param a - The node the press picked, or `null`.
110
+ * @param b - The node the release picked, or `null`.
111
+ * @returns The nearest common inclusive ancestor, or `null` when there is none.
112
+ */
113
+ const commonAncestor = (a: GraphNode | null, b: GraphNode | null): GraphNode | null => {
114
+ const ancestors = new Set<GraphNode>();
115
+ for (let node = a; node !== null; node = node.parent) {
116
+ ancestors.add(node);
117
+ }
118
+ for (let node = b; node !== null; node = node.parent) {
119
+ if (ancestors.has(node)) {
120
+ return node;
121
+ }
122
+ }
123
+ return null;
124
+ };
125
+
86
126
  /**
87
127
  * Gives `pc-app` the sizing contract of a replaced element (`<video>`, `<img>`): a block-level
88
128
  * box that the page's CSS sizes, defaulting to the canvas's own 300x150 intrinsic size, with the
@@ -182,14 +222,6 @@ class AppElement extends AsyncElement {
182
222
 
183
223
  private _picker: Picker | null = null;
184
224
 
185
- private _hasPointerListeners: Record<string, boolean> = {
186
- pointerenter: false,
187
- pointerleave: false,
188
- pointerdown: false,
189
- pointerup: false,
190
- pointermove: false
191
- };
192
-
193
225
  private _hoveredEntity: EntityBaseElement | null = null;
194
226
 
195
227
  // Identifies the newest in-flight hover pick, so out-of-order results can be discarded
@@ -198,9 +230,28 @@ class AppElement extends AsyncElement {
198
230
  private _pointerHandlers: Record<string, EventListener | null> = {
199
231
  pointermove: null,
200
232
  pointerdown: null,
201
- pointerup: null
233
+ pointerup: null,
234
+ pointercancel: null
202
235
  };
203
236
 
237
+ /**
238
+ * The pick of each pointer's primary-button press, keyed by pointerId and kept while a click
239
+ * may still conclude it. The promise is stored rather than its result, so a release can
240
+ * await a press pick that has not resolved yet. Entries are removed by the matching
241
+ * pointerup or pointercancel, and only ever stored while some element listens for click -
242
+ * which is also what keeps those two canvas listeners attached.
243
+ */
244
+ private _downPicks = new Map<number, Promise<GraphNode | null>>();
245
+
246
+ /** Whether any element in the tree listens for click. Maintained by _syncCanvasListeners. */
247
+ private _clickListened = false;
248
+
249
+ /**
250
+ * The previous click's target, time and count, for chaining successive clicks into the
251
+ * click count that `detail` carries. `null` until a click has fired.
252
+ */
253
+ private _lastClick: { element: EntityBaseElement; time: number; count: number } | null = null;
254
+
204
255
  private _app: AppBase | null = null;
205
256
 
206
257
  private _loadProgress = 0;
@@ -241,12 +292,12 @@ class AppElement extends AsyncElement {
241
292
  constructor() {
242
293
  super();
243
294
 
244
- // Track pointer listeners being added to and removed from descendant entities.
245
- // Registered once here rather than on every boot - the handlers no-op while there is no
246
- // canvas, and a re-booted element must not stack a second set.
247
- pointerEventTypes.forEach((type) => {
248
- this.addEventListener(`${type}:connect`, () => this._onPointerListenerAdded(type));
249
- this.addEventListener(`${type}:disconnect`, () => this._onPointerListenerRemoved(type));
295
+ // Track listeners for the synthesized events being added to and removed from descendant
296
+ // entities. Registered once here rather than on every boot - the sync no-ops while there
297
+ // is no canvas, and a re-booted element must not stack a second set.
298
+ SYNTHESIZED_EVENTS.forEach((type) => {
299
+ this.addEventListener(`${type}:connect`, () => this._syncCanvasListeners());
300
+ this.addEventListener(`${type}:disconnect`, () => this._syncCanvasListeners());
250
301
  });
251
302
  }
252
303
 
@@ -609,18 +660,14 @@ class AppElement extends AsyncElement {
609
660
  this._pointerHandlers.pointermove = listener(this._onPointerMove);
610
661
  this._pointerHandlers.pointerdown = listener(this._onPointerDown);
611
662
  this._pointerHandlers.pointerup = listener(this._onPointerUp);
663
+ this._pointerHandlers.pointercancel = (event: Event) => {
664
+ this._downPicks.delete((event as PointerEvent).pointerId);
665
+ };
612
666
 
613
- // Attach canvas handlers for listeners registered before this boot (e.g. handlers
614
- // created from onpointer* attributes when their elements were first upgraded, or
667
+ // Attach canvas listeners for element listeners registered before this boot (e.g.
668
+ // handlers created from inline attributes when their elements were first upgraded, or
615
669
  // listeners carried over from before a re-boot)
616
- pointerEventTypes.forEach((type) => {
617
- const anyListeners = Array.from(
618
- this.querySelectorAll<EntityBaseElement>('pc-entity, pc-model, pc-node')
619
- ).some((entity) => entity._hasListeners(type));
620
- if (anyListeners) {
621
- this._onPointerListenerAdded(type);
622
- }
623
- });
670
+ this._syncCanvasListeners();
624
671
  }
625
672
 
626
673
  private _pickerDestroy() {
@@ -637,15 +684,12 @@ class AppElement extends AsyncElement {
637
684
  this._pointerHandlers = {
638
685
  pointermove: null,
639
686
  pointerdown: null,
640
- pointerup: null
641
- };
642
- this._hasPointerListeners = {
643
- pointerenter: false,
644
- pointerleave: false,
645
- pointerdown: false,
646
- pointerup: false,
647
- pointermove: false
687
+ pointerup: null,
688
+ pointercancel: null
648
689
  };
690
+ this._downPicks.clear();
691
+ this._clickListened = false;
692
+ this._lastClick = null;
649
693
  }
650
694
 
651
695
  /**
@@ -871,7 +915,17 @@ class AppElement extends AsyncElement {
871
915
  private async _onPointerDown(event: PointerEvent) {
872
916
  if (!this._picker || !this.app) return;
873
917
 
874
- const node = await this._pickNode(event);
918
+ const pick = this._pickNode(event);
919
+
920
+ // A click concludes on the matching pointerup, which needs to know what the press
921
+ // picked. Primary button only - the only button a click can conclude from - and only
922
+ // while click is listened for, since it is the click mapping that keeps the pointerup
923
+ // and pointercancel listeners attached to clean the entry up again.
924
+ if (this._clickListened && event.button === 0) {
925
+ this._downPicks.set(event.pointerId, pick);
926
+ }
927
+
928
+ const node = await pick;
875
929
  if (!this._picker) return; // the element disconnected while the pick was in flight
876
930
 
877
931
  const entityElement = this._elementWithListener(node, 'pointerdown');
@@ -883,6 +937,11 @@ class AppElement extends AsyncElement {
883
937
  private async _onPointerUp(event: PointerEvent) {
884
938
  if (!this._picker || !this.app) return;
885
939
 
940
+ // The press pick this release may conclude as a click. Claimed synchronously, so the
941
+ // entry is gone before any other event for this pointer can be handled.
942
+ const downPick = this._downPicks.get(event.pointerId);
943
+ this._downPicks.delete(event.pointerId);
944
+
886
945
  const node = await this._pickNode(event);
887
946
  if (!this._picker) return; // the element disconnected while the pick was in flight
888
947
 
@@ -890,47 +949,63 @@ class AppElement extends AsyncElement {
890
949
  if (entityElement) {
891
950
  entityElement.dispatchEvent(new PointerEvent('pointerup', event));
892
951
  }
893
- }
894
952
 
895
- private _onPointerListenerAdded(type: string) {
896
- if (!this._hasPointerListeners[type] && this._canvas) {
897
- this._hasPointerListeners[type] = true;
898
-
899
- // For enter/leave events, we need the move handler
900
- const handler =
901
- type === 'pointerenter' || type === 'pointerleave'
902
- ? this._pointerHandlers.pointermove
903
- : this._pointerHandlers[type];
904
-
905
- if (handler) {
906
- this._canvas.addEventListener(
907
- type === 'pointerenter' || type === 'pointerleave' ? 'pointermove' : type,
908
- handler
909
- );
910
- }
953
+ // A click fires where the DOM fires it: at the nearest common inclusive ancestor of
954
+ // what the press and the release picked, for the primary button only. The press pick
955
+ // may still be in flight - a quick tap resolves in pick order, not event order.
956
+ if (!downPick || event.button !== 0) return;
957
+ const downNode = await downPick;
958
+ if (!this._picker) return;
959
+
960
+ const clickElement = this._elementWithListener(commonAncestor(downNode, node), 'click');
961
+ if (clickElement) {
962
+ const click = new PointerEvent('click', event);
963
+
964
+ // The init above copied pointerup's `detail`, which the Pointer Events spec fixes
965
+ // at 0 - but click is exempt: its detail is the click count, chained here as the
966
+ // platform chains it (same target, within the double-click window). Overridden
967
+ // with defineProperty because an event instance used as an init dict cannot have
968
+ // single fields replaced.
969
+ const time = performance.now();
970
+ const last = this._lastClick;
971
+ const count =
972
+ last && last.element === clickElement && time - last.time <= CLICK_CHAIN_MS ? last.count + 1 : 1;
973
+ this._lastClick = { element: clickElement, time, count };
974
+ Object.defineProperty(click, 'detail', { value: count });
975
+
976
+ clickElement.dispatchEvent(click);
911
977
  }
912
978
  }
913
979
 
914
- private _onPointerListenerRemoved(type: string) {
915
- const hasListeners = Array.from(
916
- this.querySelectorAll<EntityBaseElement>('pc-entity, pc-model, pc-node')
917
- ).some((entity) => entity._hasListeners(type));
918
-
919
- if (!hasListeners && this._canvas) {
920
- this._hasPointerListeners[type] = false;
921
-
922
- const handler =
923
- type === 'pointerenter' || type === 'pointerleave'
924
- ? this._pointerHandlers.pointermove
925
- : this._pointerHandlers[type];
980
+ /**
981
+ * Attaches exactly the canvas listeners the tree's current element listeners need, and
982
+ * detaches the rest. Recomputed whenever a listener connects or disconnects anywhere under
983
+ * this element: several synthesized types can need the same canvas listener (enter, leave
984
+ * and move all ride the move pick; click rides the down/up pair), so one type's removal
985
+ * must not detach a listener another type still uses. Re-attaching an attached listener is
986
+ * a no-op by EventTarget semantics, so no attach state is kept.
987
+ */
988
+ private _syncCanvasListeners() {
989
+ const canvas = this._canvas;
990
+ if (!canvas) return; // not booted yet: _pickerCreate syncs once the handlers exist
926
991
 
927
- if (handler) {
928
- this._canvas.removeEventListener(
929
- type === 'pointerenter' || type === 'pointerleave' ? 'pointermove' : type,
930
- handler
931
- );
992
+ const elements = Array.from(this.querySelectorAll<EntityBaseElement>('pc-entity, pc-model, pc-node'));
993
+ const needed = new Set<string>();
994
+ for (const type of SYNTHESIZED_EVENTS) {
995
+ if (elements.some((element) => element._hasListeners(type))) {
996
+ canvasEventsFor[type].forEach((canvasType) => needed.add(canvasType));
932
997
  }
933
998
  }
999
+ this._clickListened = elements.some((element) => element._hasListeners('click'));
1000
+
1001
+ Object.entries(this._pointerHandlers).forEach(([canvasType, handler]) => {
1002
+ if (!handler) return;
1003
+ if (needed.has(canvasType)) {
1004
+ canvas.addEventListener(canvasType, handler);
1005
+ } else {
1006
+ canvas.removeEventListener(canvasType, handler);
1007
+ }
1008
+ });
934
1009
  }
935
1010
 
936
1011
  /**
@@ -25,8 +25,6 @@ import { AnimComponentElement } from './anim-component';
25
25
  * @elementSummary The `<pc-anim-clip>` element declares one named animation clip on its parent
26
26
  * `<pc-anim>`, taken from the `asset` it names or, without one, from the enclosing `<pc-model>`'s
27
27
  * own animations. Must be a direct child of `<pc-anim>`.
28
- *
29
- * @category Components
30
28
  */
31
29
  class AnimClipElement extends AsyncElement {
32
30
  /**
@@ -4,18 +4,29 @@ import type { AppElement } from './app';
4
4
  import { AsyncElement } from './async-element';
5
5
 
6
6
  /**
7
- * The attribute names of the inline `onpointer*` event handlers, shared by every element that
8
- * fronts an engine entity. Spread into `observedAttributes` by subclasses.
7
+ * The event types the containing `<pc-app>` synthesizes on entity-fronting elements via picking:
8
+ * the `pointer*` events, plus `click` which concludes a primary-button press and release, and
9
+ * is delivered as a `PointerEvent` exactly as modern browsers deliver native clicks.
9
10
  * @internal
10
11
  */
11
- export const POINTER_ATTRIBUTES = [
12
- 'onpointerenter',
13
- 'onpointerleave',
14
- 'onpointerdown',
15
- 'onpointerup',
16
- 'onpointermove'
12
+ export const SYNTHESIZED_EVENTS = [
13
+ 'pointerenter',
14
+ 'pointerleave',
15
+ 'pointerdown',
16
+ 'pointerup',
17
+ 'pointermove',
18
+ 'click'
17
19
  ] as const;
18
20
 
21
+ const SYNTHESIZED_EVENT_SET: ReadonlySet<string> = new Set(SYNTHESIZED_EVENTS);
22
+
23
+ /**
24
+ * The attribute names of the inline event handlers (`onpointerdown`, `onclick`, ...), shared by
25
+ * every element that fronts an engine entity. Spread into `observedAttributes` by subclasses.
26
+ * @internal
27
+ */
28
+ export const EVENT_ATTRIBUTES = SYNTHESIZED_EVENTS.map((type) => `on${type}`);
29
+
19
30
  /**
20
31
  * The base class for elements that front an engine {@link Entity}: `<pc-entity>` and
21
32
  * `<pc-model>`, which create one, and `<pc-node>`, which binds to one inside a model's
@@ -34,12 +45,13 @@ class EntityBaseElement extends AsyncElement {
34
45
  protected _appElement: AppElement | null = null;
35
46
 
36
47
  /**
37
- * The pointer event listeners for the entity.
48
+ * The event listeners registered on the element, by type.
38
49
  */
39
50
  private _listeners: Record<string, EventListener[]> = {};
40
51
 
41
52
  /**
42
- * The event types for which an inline `onpointer*` attribute is currently present.
53
+ * The event types for which an inline handler attribute (`onpointerdown`, `onclick`, ...)
54
+ * is currently present.
43
55
  */
44
56
  private _inlineHandlerTypes = new Set<string>();
45
57
 
@@ -75,7 +87,7 @@ class EntityBaseElement extends AsyncElement {
75
87
  }
76
88
 
77
89
  /**
78
- * Tracks whether an inline `onpointer*` attribute is present. The browser itself compiles and
90
+ * Tracks whether an inline handler attribute is present. The browser itself compiles and
79
91
  * runs these attributes — they are standard `GlobalEventHandlers`, so setting one replaces
80
92
  * the previous handler and removing it removes the handler, exactly like `onclick` on any
81
93
  * HTML element. But because they bypass {@link EventTarget.addEventListener}, the connect/disconnect
@@ -105,7 +117,7 @@ class EntityBaseElement extends AsyncElement {
105
117
  }
106
118
  this._listeners[type].push(listener);
107
119
  super.addEventListener(type, listener, options);
108
- if (type.startsWith('pointer')) {
120
+ if (SYNTHESIZED_EVENT_SET.has(type)) {
109
121
  this.dispatchEvent(new CustomEvent(`${type}:connect`, { bubbles: true }));
110
122
  }
111
123
  }
@@ -115,15 +127,15 @@ class EntityBaseElement extends AsyncElement {
115
127
  this._listeners[type] = this._listeners[type].filter((l) => l !== listener);
116
128
  }
117
129
  super.removeEventListener(type, listener, options);
118
- if (type.startsWith('pointer')) {
130
+ if (SYNTHESIZED_EVENT_SET.has(type)) {
119
131
  this.dispatchEvent(new CustomEvent(`${type}:disconnect`, { bubbles: true }));
120
132
  }
121
133
  }
122
134
 
123
135
  /**
124
136
  * Whether the element has a listener for an event type, registered either with
125
- * {@link EventTarget.addEventListener} or with the matching inline `onpointer*` attribute. Read by the
126
- * containing `<pc-app>` element to gate pointer event synthesis.
137
+ * {@link EventTarget.addEventListener} or with the matching inline handler attribute. Read by the
138
+ * containing `<pc-app>` element to gate event synthesis.
127
139
  *
128
140
  * @param type - The event type.
129
141
  * @returns Whether a listener is registered.
package/src/entity.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Vec3 } from 'playcanvas';
2
2
 
3
- import { POINTER_ATTRIBUTES } from './entity-base';
3
+ import { EVENT_ATTRIBUTES } from './entity-base';
4
4
  import { buildDescendantEntities, EntityOwnerElement } from './entity-owner';
5
5
  import { parseBool, parseTags, parseVec3 } from './parse';
6
6
 
@@ -12,8 +12,8 @@ import { parseBool, parseTags, parseVec3 } from './parse';
12
12
  *
13
13
  * The pointer events below are dispatched by the containing `<pc-app>` element when the pointer
14
14
  * intersects this entity's geometry. They are only generated while the entity has a listener for
15
- * them, registered either with {@link EventTarget.addEventListener} or with the matching inline `onpointer*`
16
- * attribute.
15
+ * them, registered either with {@link EventTarget.addEventListener} or with the matching inline
16
+ * attribute (`onpointerdown`, `onclick`, ...).
17
17
  *
18
18
  * @elementSummary The `<pc-entity>` element creates an entity: a named, transformable node of the
19
19
  * scene hierarchy, and the host for component elements such as `<pc-camera>`, `<pc-light>` and
@@ -33,11 +33,17 @@ import { parseBool, parseTags, parseVec3 } from './parse';
33
33
  * entity.
34
34
  * @attribute {string} onpointerup - Script to run when a pointer button is released over the
35
35
  * entity.
36
+ * @attribute {string} onclick - Script to run when the entity is clicked: a primary pointer
37
+ * button pressed and then released over it.
36
38
  * @fires {PointerEvent} pointerenter - Fired when the pointer moves onto the entity.
37
39
  * @fires {PointerEvent} pointerleave - Fired when the pointer moves off the entity.
38
40
  * @fires {PointerEvent} pointermove - Fired when the pointer moves over the entity.
39
41
  * @fires {PointerEvent} pointerdown - Fired when a pointer button is pressed over the entity.
40
42
  * @fires {PointerEvent} pointerup - Fired when a pointer button is released over the entity.
43
+ * @fires {PointerEvent} click - Fired when a primary pointer button is pressed and then released
44
+ * over the entity. A press and release that picked different entities fires on their nearest
45
+ * common ancestor instead, as in the DOM. `detail` carries the click count, so a double click
46
+ * arrives as a click whose `detail` is 2.
41
47
  */
42
48
  class EntityElement extends EntityOwnerElement {
43
49
  connectedCallback() {
@@ -77,7 +83,7 @@ class EntityElement extends EntityOwnerElement {
77
83
  }
78
84
 
79
85
  static get observedAttributes() {
80
- return ['enabled', 'name', 'position', 'rotation', 'scale', 'tags', ...POINTER_ATTRIBUTES];
86
+ return ['enabled', 'name', 'position', 'rotation', 'scale', 'tags', ...EVENT_ATTRIBUTES];
81
87
  }
82
88
 
83
89
  attributeChangedCallback(name: string, _oldValue: string | null, newValue: string | null) {
@@ -105,6 +111,7 @@ class EntityElement extends EntityOwnerElement {
105
111
  case 'onpointerdown':
106
112
  case 'onpointerup':
107
113
  case 'onpointermove':
114
+ case 'onclick':
108
115
  this._updateInlineHandler(name, newValue);
109
116
  break;
110
117
  }
package/src/model.ts CHANGED
@@ -2,7 +2,7 @@ import type { ContainerResource, Entity, EventHandle } from 'playcanvas';
2
2
  import { Vec3 } from 'playcanvas';
3
3
 
4
4
  import { useAsset } from './asset';
5
- import { POINTER_ATTRIBUTES } from './entity-base';
5
+ import { EVENT_ATTRIBUTES } from './entity-base';
6
6
  import { buildDescendantEntities, EntityOwnerElement } from './entity-owner';
7
7
  import { parseBool, parseTags, parseVec3 } from './parse';
8
8
 
@@ -143,11 +143,17 @@ const formatHierarchy = (root: HierarchyNode, counts: ReadonlyMap<string, number
143
143
  * model.
144
144
  * @attribute {string} onpointerup - Script to run when a pointer button is released over the
145
145
  * model.
146
+ * @attribute {string} onclick - Script to run when the model is clicked: a primary pointer
147
+ * button pressed and then released over it.
146
148
  * @fires {PointerEvent} pointerenter - Fired when the pointer moves onto the model.
147
149
  * @fires {PointerEvent} pointerleave - Fired when the pointer moves off the model.
148
150
  * @fires {PointerEvent} pointermove - Fired when the pointer moves over the model.
149
151
  * @fires {PointerEvent} pointerdown - Fired when a pointer button is pressed over the model.
150
152
  * @fires {PointerEvent} pointerup - Fired when a pointer button is released over the model.
153
+ * @fires {PointerEvent} click - Fired when a primary pointer button is pressed and then released
154
+ * over the model. A press and release that picked different entities fires on their nearest
155
+ * common ancestor instead, as in the DOM. `detail` carries the click count, so a double click
156
+ * arrives as a click whose `detail` is 2.
151
157
  * @fires {Event} load - Fired each time a container asset finishes instantiating, including
152
158
  * re-instantiation after `asset` changes. Does not bubble — listen on this element, or use a
153
159
  * capture-phase listener on an ancestor.
@@ -435,7 +441,7 @@ class ModelElement extends EntityOwnerElement {
435
441
  }
436
442
 
437
443
  static get observedAttributes() {
438
- return ['asset', 'enabled', 'name', 'position', 'rotation', 'scale', 'tags', ...POINTER_ATTRIBUTES];
444
+ return ['asset', 'enabled', 'name', 'position', 'rotation', 'scale', 'tags', ...EVENT_ATTRIBUTES];
439
445
  }
440
446
 
441
447
  attributeChangedCallback(name: string, _oldValue: string | null, newValue: string | null) {
@@ -466,6 +472,7 @@ class ModelElement extends EntityOwnerElement {
466
472
  case 'onpointerdown':
467
473
  case 'onpointerup':
468
474
  case 'onpointermove':
475
+ case 'onclick':
469
476
  this._updateInlineHandler(name, newValue);
470
477
  break;
471
478
  }
package/src/node.ts CHANGED
@@ -2,7 +2,7 @@ import type { Entity, EventHandle, GraphNode, Material, MeshInstance, Quat, Rend
2
2
  import { Vec3 } from 'playcanvas';
3
3
 
4
4
  import { ComponentElement } from './components/component';
5
- import { EntityBaseElement, POINTER_ATTRIBUTES } from './entity-base';
5
+ import { EntityBaseElement, EVENT_ATTRIBUTES } from './entity-base';
6
6
  import { buildDescendantEntities } from './entity-owner';
7
7
  import type { EntityOwnerElement } from './entity-owner';
8
8
  import { MaterialElement } from './material';
@@ -193,11 +193,17 @@ const levenshtein = (a: string, b: string): number => {
193
193
  * node.
194
194
  * @attribute {string} onpointerup - Script to run when a pointer button is released over the
195
195
  * node.
196
+ * @attribute {string} onclick - Script to run when the node is clicked: a primary pointer
197
+ * button pressed and then released over it.
196
198
  * @fires {PointerEvent} pointerenter - Fired when the pointer moves onto the node.
197
199
  * @fires {PointerEvent} pointerleave - Fired when the pointer moves off the node.
198
200
  * @fires {PointerEvent} pointermove - Fired when the pointer moves over the node.
199
201
  * @fires {PointerEvent} pointerdown - Fired when a pointer button is pressed over the node.
200
202
  * @fires {PointerEvent} pointerup - Fired when a pointer button is released over the node.
203
+ * @fires {PointerEvent} click - Fired when a primary pointer button is pressed and then released
204
+ * over the node. A press and release that picked different entities fires on their nearest
205
+ * common ancestor instead, as in the DOM. `detail` carries the click count, so a double click
206
+ * arrives as a click whose `detail` is 2.
201
207
  */
202
208
  class NodeElement extends EntityBaseElement {
203
209
  private _name = '';
@@ -933,7 +939,7 @@ class NodeElement extends EntityBaseElement {
933
939
  'rotation',
934
940
  'scale',
935
941
  'tags',
936
- ...POINTER_ATTRIBUTES
942
+ ...EVENT_ATTRIBUTES
937
943
  ];
938
944
  }
939
945
 
@@ -982,6 +988,7 @@ class NodeElement extends EntityBaseElement {
982
988
  case 'onpointerdown':
983
989
  case 'onpointerup':
984
990
  case 'onpointermove':
991
+ case 'onclick':
985
992
  this._updateInlineHandler(name, newValue);
986
993
  break;
987
994
  }
package/src/scene.ts CHANGED
@@ -11,9 +11,15 @@ import { parseColor, parseEnum, parseNumber, parseVec3 } from './parse';
11
11
  * {@link HTMLElement} interface.
12
12
  *
13
13
  * @elementSummary The `<pc-scene>` element holds the entity hierarchy the application renders,
14
- * along with the scene-wide fog and gravity settings. Must be a direct child of `<pc-app>`.
14
+ * along with the scene-wide fog, exposure and gravity settings. Must be a direct child of
15
+ * `<pc-app>`.
15
16
  */
16
17
  class SceneElement extends AsyncElement {
18
+ /**
19
+ * The exposure of the scene.
20
+ */
21
+ private _exposure = 1;
22
+
17
23
  /**
18
24
  * The fog type of the scene.
19
25
  */
@@ -96,6 +102,8 @@ class SceneElement extends AsyncElement {
96
102
 
97
103
  private _updateSceneSettings() {
98
104
  if (this._scene) {
105
+ this._scene.exposure = this._exposure;
106
+
99
107
  this._scene.fog.type = this._fog;
100
108
  this._scene.fog.color = this._fogColor;
101
109
  this._scene.fog.density = this._fogDensity;
@@ -117,6 +125,26 @@ class SceneElement extends AsyncElement {
117
125
  this.closestApp?.app?.systems.rigidbody?.gravity.copy(value);
118
126
  }
119
127
 
128
+ /**
129
+ * Sets the exposure of the scene, which tweaks the overall brightness of the rendered image.
130
+ * Ignored if the scene is using physical units. Defaults to 1.
131
+ * @param value - The exposure.
132
+ */
133
+ set exposure(value: number) {
134
+ this._exposure = value;
135
+ if (this.scene) {
136
+ this.scene.exposure = value;
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Gets the exposure of the scene.
142
+ * @returns The exposure.
143
+ */
144
+ get exposure() {
145
+ return this._exposure;
146
+ }
147
+
120
148
  /**
121
149
  * Sets the fog type of the scene. Can be `none`, `linear`, `exp` or `exp2`. Defaults to
122
150
  * `none`.
@@ -233,11 +261,14 @@ class SceneElement extends AsyncElement {
233
261
  }
234
262
 
235
263
  static get observedAttributes() {
236
- return ['fog', 'fog-color', 'fog-density', 'fog-start', 'fog-end', 'gravity'];
264
+ return ['exposure', 'fog', 'fog-color', 'fog-density', 'fog-start', 'fog-end', 'gravity'];
237
265
  }
238
266
 
239
267
  attributeChangedCallback(name: string, _oldValue: string | null, newValue: string | null) {
240
268
  switch (name) {
269
+ case 'exposure':
270
+ this.exposure = parseNumber(newValue, 1, name);
271
+ break;
241
272
  case 'fog':
242
273
  this.fog = parseEnum(newValue, ['none', 'linear', 'exp', 'exp2'], 'none', name);
243
274
  break;