@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/dist/pwc.mjs CHANGED
@@ -177,6 +177,136 @@ class WasmElement extends AsyncElement {
177
177
  }
178
178
  customElements.define('pc-wasm', WasmElement);
179
179
 
180
+ /**
181
+ * The event types the containing `<pc-app>` synthesizes on entity-fronting elements via picking:
182
+ * the `pointer*` events, plus `click` — which concludes a primary-button press and release, and
183
+ * is delivered as a `PointerEvent` exactly as modern browsers deliver native clicks.
184
+ * @internal
185
+ */
186
+ const SYNTHESIZED_EVENTS = [
187
+ 'pointerenter',
188
+ 'pointerleave',
189
+ 'pointerdown',
190
+ 'pointerup',
191
+ 'pointermove',
192
+ 'click'
193
+ ];
194
+ const SYNTHESIZED_EVENT_SET = new Set(SYNTHESIZED_EVENTS);
195
+ /**
196
+ * The attribute names of the inline event handlers (`onpointerdown`, `onclick`, ...), shared by
197
+ * every element that fronts an engine entity. Spread into `observedAttributes` by subclasses.
198
+ * @internal
199
+ */
200
+ const EVENT_ATTRIBUTES = SYNTHESIZED_EVENTS.map((type) => `on${type}`);
201
+ /**
202
+ * The base class for elements that front an engine {@link Entity}: `<pc-entity>` and
203
+ * `<pc-model>`, which create one, and `<pc-node>`, which binds to one inside a model's
204
+ * instantiated hierarchy. It carries what all of them need — the `entity` contract, registration
205
+ * with the owning application (which joins picked scene nodes back to elements by identity,
206
+ * never by name), and the pointer listener bookkeeping that lets the application lazily attach
207
+ * its canvas handlers.
208
+ */
209
+ class EntityBaseElement extends AsyncElement {
210
+ _entity = null;
211
+ /**
212
+ * The application element this entity is registered with, cached at registration time so the
213
+ * entity can be unregistered even once this element has left the DOM.
214
+ */
215
+ _appElement = null;
216
+ /**
217
+ * The event listeners registered on the element, by type.
218
+ */
219
+ _listeners = {};
220
+ /**
221
+ * The event types for which an inline handler attribute (`onpointerdown`, `onclick`, ...)
222
+ * is currently present.
223
+ */
224
+ _inlineHandlerTypes = new Set();
225
+ /**
226
+ * The PlayCanvas entity instance. `null` until the element is ready, and again once the
227
+ * entity is gone — await {@link whenReady} or the element's `ready()` promise before
228
+ * accessing it.
229
+ * @returns The entity instance, or `null`.
230
+ */
231
+ get entity() {
232
+ return this._entity;
233
+ }
234
+ /**
235
+ * Registers `entity` as this element's backing entity with the owning application, which
236
+ * joins engine nodes back to elements by identity (never by name).
237
+ *
238
+ * @param entity - The entity to register.
239
+ */
240
+ _registerEntity(entity) {
241
+ this._appElement = this.closestApp;
242
+ this._appElement?._registerEntityElement(entity, this);
243
+ }
244
+ /**
245
+ * Removes the registration for `entity`.
246
+ *
247
+ * @param entity - The entity to unregister.
248
+ */
249
+ _unregisterEntity(entity) {
250
+ this._appElement?._unregisterEntityElement(entity);
251
+ this._appElement = null;
252
+ }
253
+ /**
254
+ * Tracks whether an inline handler attribute is present. The browser itself compiles and
255
+ * runs these attributes — they are standard `GlobalEventHandlers`, so setting one replaces
256
+ * the previous handler and removing it removes the handler, exactly like `onclick` on any
257
+ * HTML element. But because they bypass {@link EventTarget.addEventListener}, the connect/disconnect
258
+ * bookkeeping that lets the application lazily attach its canvas pointer handlers must be
259
+ * kept in sync here.
260
+ *
261
+ * @param name - The attribute name (e.g. 'onpointerdown').
262
+ * @param value - The attribute value, or `null` when the attribute has been removed.
263
+ */
264
+ _updateInlineHandler(name, value) {
265
+ const type = name.substring(2);
266
+ const had = this._inlineHandlerTypes.has(type);
267
+ const has = value !== null;
268
+ if (has && !had) {
269
+ this._inlineHandlerTypes.add(type);
270
+ this.dispatchEvent(new CustomEvent(`${type}:connect`, { bubbles: true }));
271
+ }
272
+ else if (!has && had) {
273
+ this._inlineHandlerTypes.delete(type);
274
+ this.dispatchEvent(new CustomEvent(`${type}:disconnect`, { bubbles: true }));
275
+ }
276
+ }
277
+ addEventListener(type, listener, options) {
278
+ if (!this._listeners[type]) {
279
+ this._listeners[type] = [];
280
+ }
281
+ this._listeners[type].push(listener);
282
+ super.addEventListener(type, listener, options);
283
+ if (SYNTHESIZED_EVENT_SET.has(type)) {
284
+ this.dispatchEvent(new CustomEvent(`${type}:connect`, { bubbles: true }));
285
+ }
286
+ }
287
+ removeEventListener(type, listener, options) {
288
+ if (this._listeners[type]) {
289
+ this._listeners[type] = this._listeners[type].filter((l) => l !== listener);
290
+ }
291
+ super.removeEventListener(type, listener, options);
292
+ if (SYNTHESIZED_EVENT_SET.has(type)) {
293
+ this.dispatchEvent(new CustomEvent(`${type}:disconnect`, { bubbles: true }));
294
+ }
295
+ }
296
+ /**
297
+ * Whether the element has a listener for an event type, registered either with
298
+ * {@link EventTarget.addEventListener} or with the matching inline handler attribute. Read by the
299
+ * containing `<pc-app>` element to gate event synthesis.
300
+ *
301
+ * @param type - The event type.
302
+ * @returns Whether a listener is registered.
303
+ * @internal
304
+ */
305
+ _hasListeners(type) {
306
+ return Boolean(this._listeners[type]?.length) || this._inlineHandlerTypes.has(type);
307
+ }
308
+ }
309
+
180
310
  /** Covers the 0.2s opacity transition; jsdom never fires transitionend, so removal is timed. */
181
311
  const REMOVAL_DELAY_MS = 250;
182
312
  /**
@@ -730,14 +860,51 @@ const getEntity = (ref) => {
730
860
  return element?.entity ?? null;
731
861
  };
732
862
 
733
- /** The pointer event types the application synthesizes on `<pc-entity>` elements via picking. */
734
- const pointerEventTypes = ['pointermove', 'pointerdown', 'pointerup', 'pointerenter', 'pointerleave'];
735
863
  /**
736
864
  * The event types whose listeners make an element a hover target. Hover resolution walks past
737
865
  * elements listening for none of them, so a silent element never swallows an ancestor's
738
866
  * enter/leave pair.
739
867
  */
740
868
  const hoverEventTypes = ['pointerenter', 'pointerleave', 'pointermove'];
869
+ /**
870
+ * The canvas listeners each synthesized event type is driven by. Enter and leave are derived
871
+ * from move picks. A click is concluded from the down/up pair, with pointercancel discarding a
872
+ * press the browser takes back (for example a touch that becomes a scroll).
873
+ */
874
+ const canvasEventsFor = {
875
+ pointermove: ['pointermove'],
876
+ pointerenter: ['pointermove'],
877
+ pointerleave: ['pointermove'],
878
+ pointerdown: ['pointerdown'],
879
+ pointerup: ['pointerup'],
880
+ click: ['pointerdown', 'pointerup', 'pointercancel']
881
+ };
882
+ /**
883
+ * How long after a click a further click on the same target still raises the click count that
884
+ * `detail` carries, approximating the platform's double-click time.
885
+ */
886
+ const CLICK_CHAIN_MS = 500;
887
+ /**
888
+ * Finds the nearest common inclusive ancestor of two picked nodes - the node a click belongs to
889
+ * when the press and the release picked different geometry, exactly as the DOM assigns a click
890
+ * whose down and up have different targets.
891
+ *
892
+ * @param a - The node the press picked, or `null`.
893
+ * @param b - The node the release picked, or `null`.
894
+ * @returns The nearest common inclusive ancestor, or `null` when there is none.
895
+ */
896
+ const commonAncestor = (a, b) => {
897
+ const ancestors = new Set();
898
+ for (let node = a; node !== null; node = node.parent) {
899
+ ancestors.add(node);
900
+ }
901
+ for (let node = b; node !== null; node = node.parent) {
902
+ if (ancestors.has(node)) {
903
+ return node;
904
+ }
905
+ }
906
+ return null;
907
+ };
741
908
  /**
742
909
  * Gives `pc-app` the sizing contract of a replaced element (`<video>`, `<img>`): a block-level
743
910
  * box that the page's CSS sizes, defaulting to the canvas's own 300x150 intrinsic size, with the
@@ -822,21 +989,30 @@ class AppElement extends AsyncElement {
822
989
  */
823
990
  _entityElements = new Map();
824
991
  _picker = null;
825
- _hasPointerListeners = {
826
- pointerenter: false,
827
- pointerleave: false,
828
- pointerdown: false,
829
- pointerup: false,
830
- pointermove: false
831
- };
832
992
  _hoveredEntity = null;
833
993
  // Identifies the newest in-flight hover pick, so out-of-order results can be discarded
834
994
  _pickToken = 0;
835
995
  _pointerHandlers = {
836
996
  pointermove: null,
837
997
  pointerdown: null,
838
- pointerup: null
998
+ pointerup: null,
999
+ pointercancel: null
839
1000
  };
1001
+ /**
1002
+ * The pick of each pointer's primary-button press, keyed by pointerId and kept while a click
1003
+ * may still conclude it. The promise is stored rather than its result, so a release can
1004
+ * await a press pick that has not resolved yet. Entries are removed by the matching
1005
+ * pointerup or pointercancel, and only ever stored while some element listens for click -
1006
+ * which is also what keeps those two canvas listeners attached.
1007
+ */
1008
+ _downPicks = new Map();
1009
+ /** Whether any element in the tree listens for click. Maintained by _syncCanvasListeners. */
1010
+ _clickListened = false;
1011
+ /**
1012
+ * The previous click's target, time and count, for chaining successive clicks into the
1013
+ * click count that `detail` carries. `null` until a click has fired.
1014
+ */
1015
+ _lastClick = null;
840
1016
  _app = null;
841
1017
  _loadProgress = 0;
842
1018
  /**
@@ -871,12 +1047,12 @@ class AppElement extends AsyncElement {
871
1047
  */
872
1048
  constructor() {
873
1049
  super();
874
- // Track pointer listeners being added to and removed from descendant entities.
875
- // Registered once here rather than on every boot - the handlers no-op while there is no
876
- // canvas, and a re-booted element must not stack a second set.
877
- pointerEventTypes.forEach((type) => {
878
- this.addEventListener(`${type}:connect`, () => this._onPointerListenerAdded(type));
879
- this.addEventListener(`${type}:disconnect`, () => this._onPointerListenerRemoved(type));
1050
+ // Track listeners for the synthesized events being added to and removed from descendant
1051
+ // entities. Registered once here rather than on every boot - the sync no-ops while there
1052
+ // is no canvas, and a re-booted element must not stack a second set.
1053
+ SYNTHESIZED_EVENTS.forEach((type) => {
1054
+ this.addEventListener(`${type}:connect`, () => this._syncCanvasListeners());
1055
+ this.addEventListener(`${type}:disconnect`, () => this._syncCanvasListeners());
880
1056
  });
881
1057
  }
882
1058
  async connectedCallback() {
@@ -1194,15 +1370,13 @@ class AppElement extends AsyncElement {
1194
1370
  this._pointerHandlers.pointermove = listener(this._onPointerMove);
1195
1371
  this._pointerHandlers.pointerdown = listener(this._onPointerDown);
1196
1372
  this._pointerHandlers.pointerup = listener(this._onPointerUp);
1197
- // Attach canvas handlers for listeners registered before this boot (e.g. handlers
1198
- // created from onpointer* attributes when their elements were first upgraded, or
1373
+ this._pointerHandlers.pointercancel = (event) => {
1374
+ this._downPicks.delete(event.pointerId);
1375
+ };
1376
+ // Attach canvas listeners for element listeners registered before this boot (e.g.
1377
+ // handlers created from inline attributes when their elements were first upgraded, or
1199
1378
  // listeners carried over from before a re-boot)
1200
- pointerEventTypes.forEach((type) => {
1201
- const anyListeners = Array.from(this.querySelectorAll('pc-entity, pc-model, pc-node')).some((entity) => entity._hasListeners(type));
1202
- if (anyListeners) {
1203
- this._onPointerListenerAdded(type);
1204
- }
1205
- });
1379
+ this._syncCanvasListeners();
1206
1380
  }
1207
1381
  _pickerDestroy() {
1208
1382
  if (this._canvas) {
@@ -1217,15 +1391,12 @@ class AppElement extends AsyncElement {
1217
1391
  this._pointerHandlers = {
1218
1392
  pointermove: null,
1219
1393
  pointerdown: null,
1220
- pointerup: null
1221
- };
1222
- this._hasPointerListeners = {
1223
- pointerenter: false,
1224
- pointerleave: false,
1225
- pointerdown: false,
1226
- pointerup: false,
1227
- pointermove: false
1394
+ pointerup: null,
1395
+ pointercancel: null
1228
1396
  };
1397
+ this._downPicks.clear();
1398
+ this._clickListened = false;
1399
+ this._lastClick = null;
1229
1400
  }
1230
1401
  /**
1231
1402
  * Registers the element that fronts an entity. Called by EntityElement when it creates its
@@ -1431,7 +1602,15 @@ class AppElement extends AsyncElement {
1431
1602
  async _onPointerDown(event) {
1432
1603
  if (!this._picker || !this.app)
1433
1604
  return;
1434
- const node = await this._pickNode(event);
1605
+ const pick = this._pickNode(event);
1606
+ // A click concludes on the matching pointerup, which needs to know what the press
1607
+ // picked. Primary button only - the only button a click can conclude from - and only
1608
+ // while click is listened for, since it is the click mapping that keeps the pointerup
1609
+ // and pointercancel listeners attached to clean the entry up again.
1610
+ if (this._clickListened && event.button === 0) {
1611
+ this._downPicks.set(event.pointerId, pick);
1612
+ }
1613
+ const node = await pick;
1435
1614
  if (!this._picker)
1436
1615
  return; // the element disconnected while the pick was in flight
1437
1616
  const entityElement = this._elementWithListener(node, 'pointerdown');
@@ -1442,6 +1621,10 @@ class AppElement extends AsyncElement {
1442
1621
  async _onPointerUp(event) {
1443
1622
  if (!this._picker || !this.app)
1444
1623
  return;
1624
+ // The press pick this release may conclude as a click. Claimed synchronously, so the
1625
+ // entry is gone before any other event for this pointer can be handled.
1626
+ const downPick = this._downPicks.get(event.pointerId);
1627
+ this._downPicks.delete(event.pointerId);
1445
1628
  const node = await this._pickNode(event);
1446
1629
  if (!this._picker)
1447
1630
  return; // the element disconnected while the pick was in flight
@@ -1449,30 +1632,60 @@ class AppElement extends AsyncElement {
1449
1632
  if (entityElement) {
1450
1633
  entityElement.dispatchEvent(new PointerEvent('pointerup', event));
1451
1634
  }
1452
- }
1453
- _onPointerListenerAdded(type) {
1454
- if (!this._hasPointerListeners[type] && this._canvas) {
1455
- this._hasPointerListeners[type] = true;
1456
- // For enter/leave events, we need the move handler
1457
- const handler = type === 'pointerenter' || type === 'pointerleave'
1458
- ? this._pointerHandlers.pointermove
1459
- : this._pointerHandlers[type];
1460
- if (handler) {
1461
- this._canvas.addEventListener(type === 'pointerenter' || type === 'pointerleave' ? 'pointermove' : type, handler);
1635
+ // A click fires where the DOM fires it: at the nearest common inclusive ancestor of
1636
+ // what the press and the release picked, for the primary button only. The press pick
1637
+ // may still be in flight - a quick tap resolves in pick order, not event order.
1638
+ if (!downPick || event.button !== 0)
1639
+ return;
1640
+ const downNode = await downPick;
1641
+ if (!this._picker)
1642
+ return;
1643
+ const clickElement = this._elementWithListener(commonAncestor(downNode, node), 'click');
1644
+ if (clickElement) {
1645
+ const click = new PointerEvent('click', event);
1646
+ // The init above copied pointerup's `detail`, which the Pointer Events spec fixes
1647
+ // at 0 - but click is exempt: its detail is the click count, chained here as the
1648
+ // platform chains it (same target, within the double-click window). Overridden
1649
+ // with defineProperty because an event instance used as an init dict cannot have
1650
+ // single fields replaced.
1651
+ const time = performance.now();
1652
+ const last = this._lastClick;
1653
+ const count = last && last.element === clickElement && time - last.time <= CLICK_CHAIN_MS ? last.count + 1 : 1;
1654
+ this._lastClick = { element: clickElement, time, count };
1655
+ Object.defineProperty(click, 'detail', { value: count });
1656
+ clickElement.dispatchEvent(click);
1657
+ }
1658
+ }
1659
+ /**
1660
+ * Attaches exactly the canvas listeners the tree's current element listeners need, and
1661
+ * detaches the rest. Recomputed whenever a listener connects or disconnects anywhere under
1662
+ * this element: several synthesized types can need the same canvas listener (enter, leave
1663
+ * and move all ride the move pick; click rides the down/up pair), so one type's removal
1664
+ * must not detach a listener another type still uses. Re-attaching an attached listener is
1665
+ * a no-op by EventTarget semantics, so no attach state is kept.
1666
+ */
1667
+ _syncCanvasListeners() {
1668
+ const canvas = this._canvas;
1669
+ if (!canvas)
1670
+ return; // not booted yet: _pickerCreate syncs once the handlers exist
1671
+ const elements = Array.from(this.querySelectorAll('pc-entity, pc-model, pc-node'));
1672
+ const needed = new Set();
1673
+ for (const type of SYNTHESIZED_EVENTS) {
1674
+ if (elements.some((element) => element._hasListeners(type))) {
1675
+ canvasEventsFor[type].forEach((canvasType) => needed.add(canvasType));
1462
1676
  }
1463
1677
  }
1464
- }
1465
- _onPointerListenerRemoved(type) {
1466
- const hasListeners = Array.from(this.querySelectorAll('pc-entity, pc-model, pc-node')).some((entity) => entity._hasListeners(type));
1467
- if (!hasListeners && this._canvas) {
1468
- this._hasPointerListeners[type] = false;
1469
- const handler = type === 'pointerenter' || type === 'pointerleave'
1470
- ? this._pointerHandlers.pointermove
1471
- : this._pointerHandlers[type];
1472
- if (handler) {
1473
- this._canvas.removeEventListener(type === 'pointerenter' || type === 'pointerleave' ? 'pointermove' : type, handler);
1678
+ this._clickListened = elements.some((element) => element._hasListeners('click'));
1679
+ Object.entries(this._pointerHandlers).forEach(([canvasType, handler]) => {
1680
+ if (!handler)
1681
+ return;
1682
+ if (needed.has(canvasType)) {
1683
+ canvas.addEventListener(canvasType, handler);
1474
1684
  }
1475
- }
1685
+ else {
1686
+ canvas.removeEventListener(canvasType, handler);
1687
+ }
1688
+ });
1476
1689
  }
1477
1690
  /**
1478
1691
  * Warns that a graphics option was written too late to have any effect. These options are read
@@ -1641,126 +1854,6 @@ class AppElement extends AsyncElement {
1641
1854
  }
1642
1855
  customElements.define('pc-app', AppElement);
1643
1856
 
1644
- /**
1645
- * The attribute names of the inline `onpointer*` event handlers, shared by every element that
1646
- * fronts an engine entity. Spread into `observedAttributes` by subclasses.
1647
- * @internal
1648
- */
1649
- const POINTER_ATTRIBUTES = [
1650
- 'onpointerenter',
1651
- 'onpointerleave',
1652
- 'onpointerdown',
1653
- 'onpointerup',
1654
- 'onpointermove'
1655
- ];
1656
- /**
1657
- * The base class for elements that front an engine {@link Entity}: `<pc-entity>` and
1658
- * `<pc-model>`, which create one, and `<pc-node>`, which binds to one inside a model's
1659
- * instantiated hierarchy. It carries what all of them need — the `entity` contract, registration
1660
- * with the owning application (which joins picked scene nodes back to elements by identity,
1661
- * never by name), and the pointer listener bookkeeping that lets the application lazily attach
1662
- * its canvas handlers.
1663
- */
1664
- class EntityBaseElement extends AsyncElement {
1665
- _entity = null;
1666
- /**
1667
- * The application element this entity is registered with, cached at registration time so the
1668
- * entity can be unregistered even once this element has left the DOM.
1669
- */
1670
- _appElement = null;
1671
- /**
1672
- * The pointer event listeners for the entity.
1673
- */
1674
- _listeners = {};
1675
- /**
1676
- * The event types for which an inline `onpointer*` attribute is currently present.
1677
- */
1678
- _inlineHandlerTypes = new Set();
1679
- /**
1680
- * The PlayCanvas entity instance. `null` until the element is ready, and again once the
1681
- * entity is gone — await {@link whenReady} or the element's `ready()` promise before
1682
- * accessing it.
1683
- * @returns The entity instance, or `null`.
1684
- */
1685
- get entity() {
1686
- return this._entity;
1687
- }
1688
- /**
1689
- * Registers `entity` as this element's backing entity with the owning application, which
1690
- * joins engine nodes back to elements by identity (never by name).
1691
- *
1692
- * @param entity - The entity to register.
1693
- */
1694
- _registerEntity(entity) {
1695
- this._appElement = this.closestApp;
1696
- this._appElement?._registerEntityElement(entity, this);
1697
- }
1698
- /**
1699
- * Removes the registration for `entity`.
1700
- *
1701
- * @param entity - The entity to unregister.
1702
- */
1703
- _unregisterEntity(entity) {
1704
- this._appElement?._unregisterEntityElement(entity);
1705
- this._appElement = null;
1706
- }
1707
- /**
1708
- * Tracks whether an inline `onpointer*` attribute is present. The browser itself compiles and
1709
- * runs these attributes — they are standard `GlobalEventHandlers`, so setting one replaces
1710
- * the previous handler and removing it removes the handler, exactly like `onclick` on any
1711
- * HTML element. But because they bypass {@link EventTarget.addEventListener}, the connect/disconnect
1712
- * bookkeeping that lets the application lazily attach its canvas pointer handlers must be
1713
- * kept in sync here.
1714
- *
1715
- * @param name - The attribute name (e.g. 'onpointerdown').
1716
- * @param value - The attribute value, or `null` when the attribute has been removed.
1717
- */
1718
- _updateInlineHandler(name, value) {
1719
- const type = name.substring(2);
1720
- const had = this._inlineHandlerTypes.has(type);
1721
- const has = value !== null;
1722
- if (has && !had) {
1723
- this._inlineHandlerTypes.add(type);
1724
- this.dispatchEvent(new CustomEvent(`${type}:connect`, { bubbles: true }));
1725
- }
1726
- else if (!has && had) {
1727
- this._inlineHandlerTypes.delete(type);
1728
- this.dispatchEvent(new CustomEvent(`${type}:disconnect`, { bubbles: true }));
1729
- }
1730
- }
1731
- addEventListener(type, listener, options) {
1732
- if (!this._listeners[type]) {
1733
- this._listeners[type] = [];
1734
- }
1735
- this._listeners[type].push(listener);
1736
- super.addEventListener(type, listener, options);
1737
- if (type.startsWith('pointer')) {
1738
- this.dispatchEvent(new CustomEvent(`${type}:connect`, { bubbles: true }));
1739
- }
1740
- }
1741
- removeEventListener(type, listener, options) {
1742
- if (this._listeners[type]) {
1743
- this._listeners[type] = this._listeners[type].filter((l) => l !== listener);
1744
- }
1745
- super.removeEventListener(type, listener, options);
1746
- if (type.startsWith('pointer')) {
1747
- this.dispatchEvent(new CustomEvent(`${type}:disconnect`, { bubbles: true }));
1748
- }
1749
- }
1750
- /**
1751
- * Whether the element has a listener for an event type, registered either with
1752
- * {@link EventTarget.addEventListener} or with the matching inline `onpointer*` attribute. Read by the
1753
- * containing `<pc-app>` element to gate pointer event synthesis.
1754
- *
1755
- * @param type - The event type.
1756
- * @returns Whether a listener is registered.
1757
- * @internal
1758
- */
1759
- _hasListeners(type) {
1760
- return Boolean(this._listeners[type]?.length) || this._inlineHandlerTypes.has(type);
1761
- }
1762
- }
1763
-
1764
1857
  /**
1765
1858
  * Creates and parents the entities of every descendant entity-owning element of `root`, in two
1766
1859
  * passes so that no parent's existence depends on document order. Called wherever a subtree could
@@ -2028,8 +2121,8 @@ class EntityOwnerElement extends EntityBaseElement {
2028
2121
  *
2029
2122
  * The pointer events below are dispatched by the containing `<pc-app>` element when the pointer
2030
2123
  * intersects this entity's geometry. They are only generated while the entity has a listener for
2031
- * them, registered either with {@link EventTarget.addEventListener} or with the matching inline `onpointer*`
2032
- * attribute.
2124
+ * them, registered either with {@link EventTarget.addEventListener} or with the matching inline
2125
+ * attribute (`onpointerdown`, `onclick`, ...).
2033
2126
  *
2034
2127
  * @elementSummary The `<pc-entity>` element creates an entity: a named, transformable node of the
2035
2128
  * scene hierarchy, and the host for component elements such as `<pc-camera>`, `<pc-light>` and
@@ -2049,11 +2142,17 @@ class EntityOwnerElement extends EntityBaseElement {
2049
2142
  * entity.
2050
2143
  * @attribute {string} onpointerup - Script to run when a pointer button is released over the
2051
2144
  * entity.
2145
+ * @attribute {string} onclick - Script to run when the entity is clicked: a primary pointer
2146
+ * button pressed and then released over it.
2052
2147
  * @fires {PointerEvent} pointerenter - Fired when the pointer moves onto the entity.
2053
2148
  * @fires {PointerEvent} pointerleave - Fired when the pointer moves off the entity.
2054
2149
  * @fires {PointerEvent} pointermove - Fired when the pointer moves over the entity.
2055
2150
  * @fires {PointerEvent} pointerdown - Fired when a pointer button is pressed over the entity.
2056
2151
  * @fires {PointerEvent} pointerup - Fired when a pointer button is released over the entity.
2152
+ * @fires {PointerEvent} click - Fired when a primary pointer button is pressed and then released
2153
+ * over the entity. A press and release that picked different entities fires on their nearest
2154
+ * common ancestor instead, as in the DOM. `detail` carries the click count, so a double click
2155
+ * arrives as a click whose `detail` is 2.
2057
2156
  */
2058
2157
  class EntityElement extends EntityOwnerElement {
2059
2158
  connectedCallback() {
@@ -2088,7 +2187,7 @@ class EntityElement extends EntityOwnerElement {
2088
2187
  this._entity?.destroy();
2089
2188
  }
2090
2189
  static get observedAttributes() {
2091
- return ['enabled', 'name', 'position', 'rotation', 'scale', 'tags', ...POINTER_ATTRIBUTES];
2190
+ return ['enabled', 'name', 'position', 'rotation', 'scale', 'tags', ...EVENT_ATTRIBUTES];
2092
2191
  }
2093
2192
  attributeChangedCallback(name, _oldValue, newValue) {
2094
2193
  switch (name) {
@@ -2115,6 +2214,7 @@ class EntityElement extends EntityOwnerElement {
2115
2214
  case 'onpointerdown':
2116
2215
  case 'onpointerup':
2117
2216
  case 'onpointermove':
2217
+ case 'onclick':
2118
2218
  this._updateInlineHandler(name, newValue);
2119
2219
  break;
2120
2220
  }
@@ -3064,11 +3164,17 @@ const formatHierarchy = (root, counts) => {
3064
3164
  * model.
3065
3165
  * @attribute {string} onpointerup - Script to run when a pointer button is released over the
3066
3166
  * model.
3167
+ * @attribute {string} onclick - Script to run when the model is clicked: a primary pointer
3168
+ * button pressed and then released over it.
3067
3169
  * @fires {PointerEvent} pointerenter - Fired when the pointer moves onto the model.
3068
3170
  * @fires {PointerEvent} pointerleave - Fired when the pointer moves off the model.
3069
3171
  * @fires {PointerEvent} pointermove - Fired when the pointer moves over the model.
3070
3172
  * @fires {PointerEvent} pointerdown - Fired when a pointer button is pressed over the model.
3071
3173
  * @fires {PointerEvent} pointerup - Fired when a pointer button is released over the model.
3174
+ * @fires {PointerEvent} click - Fired when a primary pointer button is pressed and then released
3175
+ * over the model. A press and release that picked different entities fires on their nearest
3176
+ * common ancestor instead, as in the DOM. `detail` carries the click count, so a double click
3177
+ * arrives as a click whose `detail` is 2.
3072
3178
  * @fires {Event} load - Fired each time a container asset finishes instantiating, including
3073
3179
  * re-instantiation after `asset` changes. Does not bubble — listen on this element, or use a
3074
3180
  * capture-phase listener on an ancestor.
@@ -3317,7 +3423,7 @@ class ModelElement extends EntityOwnerElement {
3317
3423
  return this._asset;
3318
3424
  }
3319
3425
  static get observedAttributes() {
3320
- return ['asset', 'enabled', 'name', 'position', 'rotation', 'scale', 'tags', ...POINTER_ATTRIBUTES];
3426
+ return ['asset', 'enabled', 'name', 'position', 'rotation', 'scale', 'tags', ...EVENT_ATTRIBUTES];
3321
3427
  }
3322
3428
  attributeChangedCallback(name, _oldValue, newValue) {
3323
3429
  switch (name) {
@@ -3347,6 +3453,7 @@ class ModelElement extends EntityOwnerElement {
3347
3453
  case 'onpointerdown':
3348
3454
  case 'onpointerup':
3349
3455
  case 'onpointermove':
3456
+ case 'onclick':
3350
3457
  this._updateInlineHandler(name, newValue);
3351
3458
  break;
3352
3459
  }
@@ -4210,8 +4317,6 @@ customElements.define('pc-anim', AnimComponentElement);
4210
4317
  * @elementSummary The `<pc-anim-clip>` element declares one named animation clip on its parent
4211
4318
  * `<pc-anim>`, taken from the `asset` it names or, without one, from the enclosing `<pc-model>`'s
4212
4319
  * own animations. Must be a direct child of `<pc-anim>`.
4213
- *
4214
- * @category Components
4215
4320
  */
4216
4321
  class AnimClipElement extends AsyncElement {
4217
4322
  /**
@@ -13340,11 +13445,17 @@ const levenshtein = (a, b) => {
13340
13445
  * node.
13341
13446
  * @attribute {string} onpointerup - Script to run when a pointer button is released over the
13342
13447
  * node.
13448
+ * @attribute {string} onclick - Script to run when the node is clicked: a primary pointer
13449
+ * button pressed and then released over it.
13343
13450
  * @fires {PointerEvent} pointerenter - Fired when the pointer moves onto the node.
13344
13451
  * @fires {PointerEvent} pointerleave - Fired when the pointer moves off the node.
13345
13452
  * @fires {PointerEvent} pointermove - Fired when the pointer moves over the node.
13346
13453
  * @fires {PointerEvent} pointerdown - Fired when a pointer button is pressed over the node.
13347
13454
  * @fires {PointerEvent} pointerup - Fired when a pointer button is released over the node.
13455
+ * @fires {PointerEvent} click - Fired when a primary pointer button is pressed and then released
13456
+ * over the node. A press and release that picked different entities fires on their nearest
13457
+ * common ancestor instead, as in the DOM. `detail` carries the click count, so a double click
13458
+ * arrives as a click whose `detail` is 2.
13348
13459
  */
13349
13460
  class NodeElement extends EntityBaseElement {
13350
13461
  _name = '';
@@ -13994,7 +14105,7 @@ class NodeElement extends EntityBaseElement {
13994
14105
  'rotation',
13995
14106
  'scale',
13996
14107
  'tags',
13997
- ...POINTER_ATTRIBUTES
14108
+ ...EVENT_ATTRIBUTES
13998
14109
  ];
13999
14110
  }
14000
14111
  attributeChangedCallback(name, _oldValue, newValue) {
@@ -14044,6 +14155,7 @@ class NodeElement extends EntityBaseElement {
14044
14155
  case 'onpointerdown':
14045
14156
  case 'onpointerup':
14046
14157
  case 'onpointermove':
14158
+ case 'onclick':
14047
14159
  this._updateInlineHandler(name, newValue);
14048
14160
  break;
14049
14161
  }
@@ -14058,9 +14170,14 @@ customElements.define('pc-node', NodeElement);
14058
14170
  * {@link HTMLElement} interface.
14059
14171
  *
14060
14172
  * @elementSummary The `<pc-scene>` element holds the entity hierarchy the application renders,
14061
- * along with the scene-wide fog and gravity settings. Must be a direct child of `<pc-app>`.
14173
+ * along with the scene-wide fog, exposure and gravity settings. Must be a direct child of
14174
+ * `<pc-app>`.
14062
14175
  */
14063
14176
  class SceneElement extends AsyncElement {
14177
+ /**
14178
+ * The exposure of the scene.
14179
+ */
14180
+ _exposure = 1;
14064
14181
  /**
14065
14182
  * The fog type of the scene.
14066
14183
  */
@@ -14128,6 +14245,7 @@ class SceneElement extends AsyncElement {
14128
14245
  }
14129
14246
  _updateSceneSettings() {
14130
14247
  if (this._scene) {
14248
+ this._scene.exposure = this._exposure;
14131
14249
  this._scene.fog.type = this._fog;
14132
14250
  this._scene.fog.color = this._fogColor;
14133
14251
  this._scene.fog.density = this._fogDensity;
@@ -14146,6 +14264,24 @@ class SceneElement extends AsyncElement {
14146
14264
  _applyGravity(value) {
14147
14265
  this.closestApp?.app?.systems.rigidbody?.gravity.copy(value);
14148
14266
  }
14267
+ /**
14268
+ * Sets the exposure of the scene, which tweaks the overall brightness of the rendered image.
14269
+ * Ignored if the scene is using physical units. Defaults to 1.
14270
+ * @param value - The exposure.
14271
+ */
14272
+ set exposure(value) {
14273
+ this._exposure = value;
14274
+ if (this.scene) {
14275
+ this.scene.exposure = value;
14276
+ }
14277
+ }
14278
+ /**
14279
+ * Gets the exposure of the scene.
14280
+ * @returns The exposure.
14281
+ */
14282
+ get exposure() {
14283
+ return this._exposure;
14284
+ }
14149
14285
  /**
14150
14286
  * Sets the fog type of the scene. Can be `none`, `linear`, `exp` or `exp2`. Defaults to
14151
14287
  * `none`.
@@ -14250,10 +14386,13 @@ class SceneElement extends AsyncElement {
14250
14386
  return this._gravity;
14251
14387
  }
14252
14388
  static get observedAttributes() {
14253
- return ['fog', 'fog-color', 'fog-density', 'fog-start', 'fog-end', 'gravity'];
14389
+ return ['exposure', 'fog', 'fog-color', 'fog-density', 'fog-start', 'fog-end', 'gravity'];
14254
14390
  }
14255
14391
  attributeChangedCallback(name, _oldValue, newValue) {
14256
14392
  switch (name) {
14393
+ case 'exposure':
14394
+ this.exposure = parseNumber(newValue, 1, name);
14395
+ break;
14257
14396
  case 'fog':
14258
14397
  this.fog = parseEnum(newValue, ['none', 'linear', 'exp', 'exp2'], 'none', name);
14259
14398
  break;