Skip to content

Commit aaa7839

Browse files
authored
Add support for pointer events to pc-entity (#67)
* Add support for pointer events to pc-entity * Lint fixes * Make new Function code safer * Lint fixes * Tweak ESLint config * Revert shapes example
1 parent 05971d9 commit aaa7839

File tree

3 files changed

+258
-3
lines changed

3 files changed

+258
-3
lines changed

eslint.config.mjs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ export default [
1818
languageOptions: {
1919
parser: tsParser,
2020
globals: {
21-
...globals.browser
21+
...globals.browser,
22+
AddEventListenerOptions: "readonly",
23+
EventListener: "readonly",
24+
EventListenerOptions: "readonly"
2225
}
2326
},
2427
plugins: {

src/app.ts

Lines changed: 167 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Application, FILLMODE_FILL_WINDOW, Keyboard, Mouse, RESOLUTION_AUTO } from 'playcanvas';
1+
import { Application, CameraComponent, FILLMODE_FILL_WINDOW, Keyboard, Mouse, Picker, RESOLUTION_AUTO } from 'playcanvas';
22

33
import { AssetElement } from './asset';
44
import { AsyncElement } from './async-element';
@@ -27,6 +27,24 @@ class AppElement extends AsyncElement {
2727

2828
private _hierarchyReady = false;
2929

30+
private _picker: Picker | null = null;
31+
32+
private _hasPointerListeners: { [key: string]: boolean } = {
33+
pointerenter: false,
34+
pointerleave: false,
35+
pointerdown: false,
36+
pointerup: false,
37+
pointermove: false
38+
};
39+
40+
private _hoveredEntity: EntityElement | null = null;
41+
42+
private _pointerHandlers: { [key: string]: EventListener | null } = {
43+
pointermove: null,
44+
pointerdown: null,
45+
pointerup: null
46+
};
47+
3048
/**
3149
* The PlayCanvas application instance.
3250
*/
@@ -71,6 +89,8 @@ class AppElement extends AsyncElement {
7189
this.app.setCanvasFillMode(FILLMODE_FILL_WINDOW);
7290
this.app.setCanvasResolution(RESOLUTION_AUTO);
7391

92+
this._pickerCreate();
93+
7494
// Get all pc-asset elements that are direct children of the pc-app element
7595
const assetElements = this.querySelectorAll<AssetElement>(':scope > pc-asset');
7696
Array.from(assetElements).forEach((assetElement) => {
@@ -113,6 +133,8 @@ class AppElement extends AsyncElement {
113133
}
114134

115135
disconnectedCallback() {
136+
this._pickerDestroy();
137+
116138
// Clean up the application
117139
if (this.app) {
118140
this.app.destroy();
@@ -135,6 +157,150 @@ class AppElement extends AsyncElement {
135157
}
136158
}
137159

160+
_pickerCreate() {
161+
const { width, height } = this.app!.graphicsDevice;
162+
this._picker = new Picker(this.app!, width, height);
163+
164+
// Create bound handlers but don't attach them yet
165+
this._pointerHandlers.pointermove = this._onPointerMove.bind(this) as EventListener;
166+
this._pointerHandlers.pointerdown = this._onPointerDown.bind(this) as EventListener;
167+
this._pointerHandlers.pointerup = this._onPointerUp.bind(this) as EventListener;
168+
169+
// Listen for pointer listeners being added/removed
170+
['pointermove', 'pointerdown', 'pointerup', 'pointerenter', 'pointerleave'].forEach((type) => {
171+
this.addEventListener(`${type}:connect`, () => this._onPointerListenerAdded(type));
172+
this.addEventListener(`${type}:disconnect`, () => this._onPointerListenerRemoved(type));
173+
});
174+
}
175+
176+
_pickerDestroy() {
177+
if (this._canvas) {
178+
Object.entries(this._pointerHandlers).forEach(([type, handler]) => {
179+
if (handler) {
180+
this._canvas!.removeEventListener(type, handler);
181+
}
182+
});
183+
}
184+
185+
this._picker = null;
186+
this._pointerHandlers = {
187+
pointermove: null,
188+
pointerdown: null,
189+
pointerup: null
190+
};
191+
}
192+
193+
_onPointerMove(event: PointerEvent) {
194+
if (!this._picker || !this.app) return;
195+
196+
const camera = this.app!.root.findComponent('camera') as CameraComponent;
197+
if (!camera) return;
198+
199+
const canvasRect = this._canvas!.getBoundingClientRect();
200+
const x = event.clientX - canvasRect.left;
201+
const y = event.clientY - canvasRect.top;
202+
203+
this._picker.prepare(camera, this.app!.scene);
204+
const selection = this._picker.getSelection(x, y);
205+
206+
// Get the currently hovered entity (if any)
207+
const newHoverEntity = selection.length > 0 ?
208+
this.querySelector(`pc-entity[name="${selection[0].node.name}"]`) as EntityElement :
209+
null;
210+
211+
// Handle enter/leave events
212+
if (this._hoveredEntity !== newHoverEntity) {
213+
if (this._hoveredEntity && this._hoveredEntity.hasListeners('pointerleave')) {
214+
this._hoveredEntity.dispatchEvent(new PointerEvent('pointerleave', event));
215+
}
216+
if (newHoverEntity && newHoverEntity.hasListeners('pointerenter')) {
217+
newHoverEntity.dispatchEvent(new PointerEvent('pointerenter', event));
218+
}
219+
}
220+
221+
// Update hover state
222+
this._hoveredEntity = newHoverEntity;
223+
224+
// Handle pointermove event
225+
if (newHoverEntity && newHoverEntity.hasListeners('pointermove')) {
226+
newHoverEntity.dispatchEvent(new PointerEvent('pointermove', event));
227+
}
228+
}
229+
230+
_onPointerDown(event: PointerEvent) {
231+
if (!this._picker || !this.app) return;
232+
233+
const camera = this.app!.root.findComponent('camera') as CameraComponent;
234+
if (!camera) return;
235+
236+
const canvasRect = this._canvas!.getBoundingClientRect();
237+
const x = event.clientX - canvasRect.left;
238+
const y = event.clientY - canvasRect.top;
239+
240+
this._picker.prepare(camera, this.app!.scene);
241+
const selection = this._picker.getSelection(x, y);
242+
243+
if (selection.length > 0) {
244+
const entityElement = this.querySelector(`pc-entity[name="${selection[0].node.name}"]`) as EntityElement;
245+
if (entityElement && entityElement.hasListeners('pointerdown')) {
246+
entityElement.dispatchEvent(new PointerEvent('pointerdown', event));
247+
}
248+
}
249+
}
250+
251+
_onPointerUp(event: PointerEvent) {
252+
if (!this._picker || !this.app) return;
253+
254+
const camera = this.app!.root.findComponent('camera') as CameraComponent;
255+
if (!camera) return;
256+
257+
const canvasRect = this._canvas!.getBoundingClientRect();
258+
const x = event.clientX - canvasRect.left;
259+
const y = event.clientY - canvasRect.top;
260+
261+
this._picker.prepare(camera, this.app!.scene);
262+
const selection = this._picker.getSelection(x, y);
263+
264+
if (selection.length > 0) {
265+
const entityElement = this.querySelector(`pc-entity[name="${selection[0].node.name}"]`) as EntityElement;
266+
if (entityElement && entityElement.hasListeners('pointerup')) {
267+
entityElement.dispatchEvent(new PointerEvent('pointerup', event));
268+
}
269+
}
270+
}
271+
272+
_onPointerListenerAdded(type: string) {
273+
if (!this._hasPointerListeners[type] && this._canvas) {
274+
this._hasPointerListeners[type] = true;
275+
276+
// For enter/leave events, we need the move handler
277+
const handler = (type === 'pointerenter' || type === 'pointerleave') ?
278+
this._pointerHandlers.pointermove :
279+
this._pointerHandlers[type];
280+
281+
if (handler) {
282+
this._canvas.addEventListener(type === 'pointerenter' || type === 'pointerleave' ? 'pointermove' : type, handler);
283+
}
284+
}
285+
}
286+
287+
_onPointerListenerRemoved(type: string) {
288+
const hasListeners = Array.from(this.querySelectorAll<EntityElement>('pc-entity'))
289+
.some(entity => entity.hasListeners(type));
290+
291+
if (!hasListeners && this._canvas) {
292+
this._hasPointerListeners[type] = false;
293+
294+
const handler = (type === 'pointerenter' || type === 'pointerleave') ?
295+
this._pointerHandlers.pointermove :
296+
this._pointerHandlers[type];
297+
298+
if (handler) {
299+
this._canvas.removeEventListener(type === 'pointerenter' || type === 'pointerleave' ? 'pointermove' : type, handler);
300+
}
301+
}
302+
}
303+
138304
/**
139305
* Sets the alpha flag.
140306
* @param value - The alpha flag.

src/entity.ts

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ class EntityElement extends AsyncElement {
3939
*/
4040
private _tags: string[] = [];
4141

42+
/**
43+
* The pointer event listeners for the entity.
44+
*/
45+
private _listeners: { [key: string]: EventListener[] } = {};
46+
4247
/**
4348
* The PlayCanvas entity instance.
4449
*/
@@ -72,6 +77,31 @@ class EntityElement extends AsyncElement {
7277
if (tags) {
7378
this.entity.tags.add(tags.split(',').map(tag => tag.trim()));
7479
}
80+
81+
// Handle pointer events
82+
const pointerEvents = [
83+
'onpointerenter',
84+
'onpointerleave',
85+
'onpointerdown',
86+
'onpointerup',
87+
'onpointermove'
88+
];
89+
90+
pointerEvents.forEach((eventName) => {
91+
const handler = this.getAttribute(eventName);
92+
if (handler) {
93+
const eventType = eventName.substring(2); // remove 'on' prefix
94+
const eventHandler = (event: Event) => {
95+
try {
96+
/* eslint-disable-next-line no-new-func */
97+
new Function('event', 'this', handler).call(this, event);
98+
} catch (e) {
99+
console.error('Error in event handler:', e);
100+
}
101+
};
102+
this.addEventListener(eventType, eventHandler);
103+
}
104+
});
75105
}
76106

77107
buildHierarchy(app: Application) {
@@ -240,7 +270,19 @@ class EntityElement extends AsyncElement {
240270
}
241271

242272
static get observedAttributes() {
243-
return ['enabled', 'name', 'position', 'rotation', 'scale', 'tags'];
273+
return [
274+
'enabled',
275+
'name',
276+
'position',
277+
'rotation',
278+
'scale',
279+
'tags',
280+
'onpointerenter',
281+
'onpointerleave',
282+
'onpointerdown',
283+
'onpointerup',
284+
'onpointermove'
285+
];
244286
}
245287

246288
attributeChangedCallback(name: string, _oldValue: string, newValue: string) {
@@ -263,7 +305,51 @@ class EntityElement extends AsyncElement {
263305
case 'tags':
264306
this.tags = newValue.split(',').map(tag => tag.trim());
265307
break;
308+
case 'onpointerenter':
309+
case 'onpointerleave':
310+
case 'onpointerdown':
311+
case 'onpointerup':
312+
case 'onpointermove':
313+
if (newValue) {
314+
const eventName = name.substring(2);
315+
// Use Function.prototype.bind to avoid new Function
316+
const handler = (event: Event) => {
317+
try {
318+
/* eslint-disable-next-line no-new-func */
319+
new Function('event', 'this', newValue).call(this, event);
320+
} catch (e) {
321+
console.error('Error in event handler:', e);
322+
}
323+
};
324+
this.addEventListener(eventName, handler);
325+
}
326+
break;
327+
}
328+
}
329+
330+
addEventListener(type: string, listener: EventListener, options?: boolean | AddEventListenerOptions) {
331+
if (!this._listeners[type]) {
332+
this._listeners[type] = [];
266333
}
334+
this._listeners[type].push(listener);
335+
super.addEventListener(type, listener, options);
336+
if (type.startsWith('pointer')) {
337+
this.dispatchEvent(new CustomEvent(`${type}:connect`, { bubbles: true }));
338+
}
339+
}
340+
341+
removeEventListener(type: string, listener: EventListener, options?: boolean | EventListenerOptions) {
342+
if (this._listeners[type]) {
343+
this._listeners[type] = this._listeners[type].filter(l => l !== listener);
344+
}
345+
super.removeEventListener(type, listener, options);
346+
if (type.startsWith('pointer')) {
347+
this.dispatchEvent(new CustomEvent(`${type}:disconnect`, { bubbles: true }));
348+
}
349+
}
350+
351+
hasListeners(type: string): boolean {
352+
return Boolean(this._listeners[type]?.length);
267353
}
268354
}
269355

0 commit comments

Comments
 (0)