Skip to content

Commit

Permalink
Add support for pointer events to pc-entity (#67)
Browse files Browse the repository at this point in the history
* Add support for pointer events to pc-entity

* Lint fixes

* Make new Function code safer

* Lint fixes

* Tweak ESLint config

* Revert shapes example
  • Loading branch information
willeastcott authored Dec 7, 2024
1 parent 05971d9 commit aaa7839
Show file tree
Hide file tree
Showing 3 changed files with 258 additions and 3 deletions.
5 changes: 4 additions & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ export default [
languageOptions: {
parser: tsParser,
globals: {
...globals.browser
...globals.browser,
AddEventListenerOptions: "readonly",
EventListener: "readonly",
EventListenerOptions: "readonly"
}
},
plugins: {
Expand Down
168 changes: 167 additions & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Application, FILLMODE_FILL_WINDOW, Keyboard, Mouse, RESOLUTION_AUTO } from 'playcanvas';
import { Application, CameraComponent, FILLMODE_FILL_WINDOW, Keyboard, Mouse, Picker, RESOLUTION_AUTO } from 'playcanvas';

import { AssetElement } from './asset';
import { AsyncElement } from './async-element';
Expand Down Expand Up @@ -27,6 +27,24 @@ class AppElement extends AsyncElement {

private _hierarchyReady = false;

private _picker: Picker | null = null;

private _hasPointerListeners: { [key: string]: boolean } = {
pointerenter: false,
pointerleave: false,
pointerdown: false,
pointerup: false,
pointermove: false
};

private _hoveredEntity: EntityElement | null = null;

private _pointerHandlers: { [key: string]: EventListener | null } = {
pointermove: null,
pointerdown: null,
pointerup: null
};

/**
* The PlayCanvas application instance.
*/
Expand Down Expand Up @@ -71,6 +89,8 @@ class AppElement extends AsyncElement {
this.app.setCanvasFillMode(FILLMODE_FILL_WINDOW);
this.app.setCanvasResolution(RESOLUTION_AUTO);

this._pickerCreate();

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

disconnectedCallback() {
this._pickerDestroy();

// Clean up the application
if (this.app) {
this.app.destroy();
Expand All @@ -135,6 +157,150 @@ class AppElement extends AsyncElement {
}
}

_pickerCreate() {
const { width, height } = this.app!.graphicsDevice;
this._picker = new Picker(this.app!, width, height);

// Create bound handlers but don't attach them yet
this._pointerHandlers.pointermove = this._onPointerMove.bind(this) as EventListener;
this._pointerHandlers.pointerdown = this._onPointerDown.bind(this) as EventListener;
this._pointerHandlers.pointerup = this._onPointerUp.bind(this) as EventListener;

// Listen for pointer listeners being added/removed
['pointermove', 'pointerdown', 'pointerup', 'pointerenter', 'pointerleave'].forEach((type) => {
this.addEventListener(`${type}:connect`, () => this._onPointerListenerAdded(type));
this.addEventListener(`${type}:disconnect`, () => this._onPointerListenerRemoved(type));
});
}

_pickerDestroy() {
if (this._canvas) {
Object.entries(this._pointerHandlers).forEach(([type, handler]) => {
if (handler) {
this._canvas!.removeEventListener(type, handler);
}
});
}

this._picker = null;
this._pointerHandlers = {
pointermove: null,
pointerdown: null,
pointerup: null
};
}

_onPointerMove(event: PointerEvent) {
if (!this._picker || !this.app) return;

const camera = this.app!.root.findComponent('camera') as CameraComponent;
if (!camera) return;

const canvasRect = this._canvas!.getBoundingClientRect();
const x = event.clientX - canvasRect.left;
const y = event.clientY - canvasRect.top;

this._picker.prepare(camera, this.app!.scene);
const selection = this._picker.getSelection(x, y);

// Get the currently hovered entity (if any)
const newHoverEntity = selection.length > 0 ?
this.querySelector(`pc-entity[name="${selection[0].node.name}"]`) as EntityElement :
null;

// Handle enter/leave events
if (this._hoveredEntity !== newHoverEntity) {
if (this._hoveredEntity && this._hoveredEntity.hasListeners('pointerleave')) {
this._hoveredEntity.dispatchEvent(new PointerEvent('pointerleave', event));
}
if (newHoverEntity && newHoverEntity.hasListeners('pointerenter')) {
newHoverEntity.dispatchEvent(new PointerEvent('pointerenter', event));
}
}

// Update hover state
this._hoveredEntity = newHoverEntity;

// Handle pointermove event
if (newHoverEntity && newHoverEntity.hasListeners('pointermove')) {
newHoverEntity.dispatchEvent(new PointerEvent('pointermove', event));
}
}

_onPointerDown(event: PointerEvent) {
if (!this._picker || !this.app) return;

const camera = this.app!.root.findComponent('camera') as CameraComponent;
if (!camera) return;

const canvasRect = this._canvas!.getBoundingClientRect();
const x = event.clientX - canvasRect.left;
const y = event.clientY - canvasRect.top;

this._picker.prepare(camera, this.app!.scene);
const selection = this._picker.getSelection(x, y);

if (selection.length > 0) {
const entityElement = this.querySelector(`pc-entity[name="${selection[0].node.name}"]`) as EntityElement;
if (entityElement && entityElement.hasListeners('pointerdown')) {
entityElement.dispatchEvent(new PointerEvent('pointerdown', event));
}
}
}

_onPointerUp(event: PointerEvent) {
if (!this._picker || !this.app) return;

const camera = this.app!.root.findComponent('camera') as CameraComponent;
if (!camera) return;

const canvasRect = this._canvas!.getBoundingClientRect();
const x = event.clientX - canvasRect.left;
const y = event.clientY - canvasRect.top;

this._picker.prepare(camera, this.app!.scene);
const selection = this._picker.getSelection(x, y);

if (selection.length > 0) {
const entityElement = this.querySelector(`pc-entity[name="${selection[0].node.name}"]`) as EntityElement;
if (entityElement && entityElement.hasListeners('pointerup')) {
entityElement.dispatchEvent(new PointerEvent('pointerup', event));
}
}
}

_onPointerListenerAdded(type: string) {
if (!this._hasPointerListeners[type] && this._canvas) {
this._hasPointerListeners[type] = true;

// For enter/leave events, we need the move handler
const handler = (type === 'pointerenter' || type === 'pointerleave') ?
this._pointerHandlers.pointermove :
this._pointerHandlers[type];

if (handler) {
this._canvas.addEventListener(type === 'pointerenter' || type === 'pointerleave' ? 'pointermove' : type, handler);
}
}
}

_onPointerListenerRemoved(type: string) {
const hasListeners = Array.from(this.querySelectorAll<EntityElement>('pc-entity'))
.some(entity => entity.hasListeners(type));

if (!hasListeners && this._canvas) {
this._hasPointerListeners[type] = false;

const handler = (type === 'pointerenter' || type === 'pointerleave') ?
this._pointerHandlers.pointermove :
this._pointerHandlers[type];

if (handler) {
this._canvas.removeEventListener(type === 'pointerenter' || type === 'pointerleave' ? 'pointermove' : type, handler);
}
}
}

/**
* Sets the alpha flag.
* @param value - The alpha flag.
Expand Down
88 changes: 87 additions & 1 deletion src/entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ class EntityElement extends AsyncElement {
*/
private _tags: string[] = [];

/**
* The pointer event listeners for the entity.
*/
private _listeners: { [key: string]: EventListener[] } = {};

/**
* The PlayCanvas entity instance.
*/
Expand Down Expand Up @@ -72,6 +77,31 @@ class EntityElement extends AsyncElement {
if (tags) {
this.entity.tags.add(tags.split(',').map(tag => tag.trim()));
}

// Handle pointer events
const pointerEvents = [
'onpointerenter',
'onpointerleave',
'onpointerdown',
'onpointerup',
'onpointermove'
];

pointerEvents.forEach((eventName) => {
const handler = this.getAttribute(eventName);
if (handler) {
const eventType = eventName.substring(2); // remove 'on' prefix
const eventHandler = (event: Event) => {
try {
/* eslint-disable-next-line no-new-func */
new Function('event', 'this', handler).call(this, event);
} catch (e) {
console.error('Error in event handler:', e);
}
};
this.addEventListener(eventType, eventHandler);
}
});
}

buildHierarchy(app: Application) {
Expand Down Expand Up @@ -240,7 +270,19 @@ class EntityElement extends AsyncElement {
}

static get observedAttributes() {
return ['enabled', 'name', 'position', 'rotation', 'scale', 'tags'];
return [
'enabled',
'name',
'position',
'rotation',
'scale',
'tags',
'onpointerenter',
'onpointerleave',
'onpointerdown',
'onpointerup',
'onpointermove'
];
}

attributeChangedCallback(name: string, _oldValue: string, newValue: string) {
Expand All @@ -263,7 +305,51 @@ class EntityElement extends AsyncElement {
case 'tags':
this.tags = newValue.split(',').map(tag => tag.trim());
break;
case 'onpointerenter':
case 'onpointerleave':
case 'onpointerdown':
case 'onpointerup':
case 'onpointermove':
if (newValue) {
const eventName = name.substring(2);
// Use Function.prototype.bind to avoid new Function
const handler = (event: Event) => {
try {
/* eslint-disable-next-line no-new-func */
new Function('event', 'this', newValue).call(this, event);
} catch (e) {
console.error('Error in event handler:', e);
}
};
this.addEventListener(eventName, handler);
}
break;
}
}

addEventListener(type: string, listener: EventListener, options?: boolean | AddEventListenerOptions) {
if (!this._listeners[type]) {
this._listeners[type] = [];
}
this._listeners[type].push(listener);
super.addEventListener(type, listener, options);
if (type.startsWith('pointer')) {
this.dispatchEvent(new CustomEvent(`${type}:connect`, { bubbles: true }));
}
}

removeEventListener(type: string, listener: EventListener, options?: boolean | EventListenerOptions) {
if (this._listeners[type]) {
this._listeners[type] = this._listeners[type].filter(l => l !== listener);
}
super.removeEventListener(type, listener, options);
if (type.startsWith('pointer')) {
this.dispatchEvent(new CustomEvent(`${type}:disconnect`, { bubbles: true }));
}
}

hasListeners(type: string): boolean {
return Boolean(this._listeners[type]?.length);
}
}

Expand Down

0 comments on commit aaa7839

Please sign in to comment.