import { EventBoundary } from './EventBoundary';
import type { FederatedMouseEvent } from './FederatedMouseEvent';
import { FederatedPointerEvent } from './FederatedPointerEvent';
import { FederatedWheelEvent } from './FederatedWheelEvent';
import { extensions, ExtensionType } from '@pixi/core';
import type { DisplayObject } from '@pixi/display';
import type { IRenderableObject, ExtensionMetadata, IPointData } from '@pixi/core';
const MOUSE_POINTER_ID = 1;
const TOUCH_TO_POINTER: Record<string, string> = {
touchstart: 'pointerdown',
touchend: 'pointerup',
touchendoutside: 'pointerupoutside',
touchmove: 'pointermove',
touchcancel: 'pointercancel',
};
interface Renderer
{
lastObjectRendered: IRenderableObject;
view: HTMLCanvasElement;
resolution: number;
plugins: Record<string, any>;
}
/**
* The system for handling UI events.
* @memberof PIXI
*/
export class EventSystem
{
/** @ignore */
static extension: ExtensionMetadata = {
name: 'events',
type: [
ExtensionType.RendererSystem,
ExtensionType.CanvasRendererSystem
],
};
/**
* The PIXI.EventBoundary for the stage.
*
* The rootTarget of this root boundary is automatically set to
* the last rendered object before any event processing is initiated. This means the main scene
* needs to be rendered atleast once before UI events will start propagating.
*
* The root boundary should only be changed during initialization. Otherwise, any state held by the
* event boundary may be lost (like hovered & pressed DisplayObjects).
*/
public readonly rootBoundary: EventBoundary;
/** Does the device support touch events https://www.w3.org/TR/touch-events/ */
public readonly supportsTouchEvents = 'ontouchstart' in globalThis;
/** Does the device support pointer events https://www.w3.org/Submission/pointer-events/ */
public readonly supportsPointerEvents = !!globalThis.PointerEvent;
/**
* Should default browser actions automatically be prevented.
* Does not apply to pointer events for backwards compatibility
* preventDefault on pointer events stops mouse events from firing
* Thus, for every pointer event, there will always be either a mouse of touch event alongside it.
* @default true
*/
public autoPreventDefault: boolean;
/**
* Dictionary of how different cursor modes are handled. Strings are handled as CSS cursor
* values, objects are handled as dictionaries of CSS values for domElement
,
* and functions are called instead of changing the CSS.
* Default CSS cursor values are provided for 'default' and 'pointer' modes.
* @member {Object<string, string | ((mode: string) => void) | CSSStyleDeclaration>}
*/
public cursorStyles: Record<string, string | ((mode: string) => void) | CSSStyleDeclaration>;
/**
* The DOM element to which the root event listeners are bound. This is automatically set to
* the renderer's view.
*/
public domElement: HTMLElement = null;
/** The resolution used to convert between the DOM client space into world space. */
public resolution = 1;
/** The renderer managing this EventSystem. */
public renderer: Renderer;
private currentCursor: string;
private rootPointerEvent: FederatedPointerEvent;
private rootWheelEvent: FederatedWheelEvent;
private eventsAdded: boolean;
/**
* @param {PIXI.Renderer} renderer
*/
constructor(renderer: Renderer)
{
this.renderer = renderer;
this.rootBoundary = new EventBoundary(null);
this.autoPreventDefault = true;
this.eventsAdded = false;
this.rootPointerEvent = new FederatedPointerEvent(null);
this.rootWheelEvent = new FederatedWheelEvent(null);
this.cursorStyles = {
default: 'inherit',
pointer: 'pointer',
};
this.onPointerDown = this.onPointerDown.bind(this);
this.onPointerMove = this.onPointerMove.bind(this);
this.onPointerUp = this.onPointerUp.bind(this);
this.onPointerOverOut = this.onPointerOverOut.bind(this);
this.onWheel = this.onWheel.bind(this);
}
/**
* Runner init called, view is available at this point.
* @ignore
*/
init(): void
{
const { view, resolution } = this.renderer;
this.setTargetElement(view);
this.resolution = resolution;
}
/** Destroys all event listeners and detaches the renderer. */
destroy(): void
{
this.setTargetElement(null);
this.renderer = null;
}
/**
* Sets the current cursor mode, handling any callbacks or CSS style changes.
* @param mode - cursor mode, a key from the cursorStyles dictionary
*/
public setCursor(mode: string): void
{
mode = mode || 'default';
let applyStyles = true;
// offscreen canvas does not support setting styles, but cursor modes can be functions,
// in order to handle pixi rendered cursors, so we can't bail
if (globalThis.OffscreenCanvas && this.domElement instanceof OffscreenCanvas)
{
applyStyles = false;
}
// if the mode didn't actually change, bail early
if (this.currentCursor === mode)
{
return;
}
this.currentCursor = mode;
const style = this.cursorStyles[mode];
// only do things if there is a cursor style for it
if (style)
{
switch (typeof style)
{
case 'string':
// string styles are handled as cursor CSS
if (applyStyles)
{
this.domElement.style.cursor = style;
}
break;
case 'function':
// functions are just called, and passed the cursor mode
style(mode);
break;
case 'object':
// if it is an object, assume that it is a dictionary of CSS styles,
// apply it to the interactionDOMElement
if (applyStyles)
{
Object.assign(this.domElement.style, style);
}
break;
}
}
else if (applyStyles && typeof mode === 'string' && !Object.prototype.hasOwnProperty.call(this.cursorStyles, mode))
{
// if it mode is a string (not a Symbol) and cursorStyles doesn't have any entry
// for the mode, then assume that the dev wants it to be CSS for the cursor.
this.domElement.style.cursor = mode;
}
}
/**
* Event handler for pointer down events on this.domElement.
* @param nativeEvent - The native mouse/pointer/touch event.
*/
private onPointerDown(nativeEvent: MouseEvent | PointerEvent | TouchEvent): void
{
this.rootBoundary.rootTarget = this.renderer.lastObjectRendered as DisplayObject;
// if we support touch events, then only use those for touch events, not pointer events
if (this.supportsTouchEvents && (nativeEvent as PointerEvent).pointerType === 'touch') return;
const events = this.normalizeToPointerData(nativeEvent);
/*
* No need to prevent default on natural pointer events, as there are no side effects
* Normalized events, however, may have the double mousedown/touchstart issue on the native android browser,
* so still need to be prevented.
*/
// Guaranteed that there will be at least one event in events, and all events must have the same pointer type
if (this.autoPreventDefault && (events[0] as any).isNormalized)
{
const cancelable = nativeEvent.cancelable || !('cancelable' in nativeEvent);
if (cancelable)
{
nativeEvent.preventDefault();
}
}
for (let i = 0, j = events.length; i < j; i++)
{
const nativeEvent = events[i];
const federatedEvent = this.bootstrapEvent(this.rootPointerEvent, nativeEvent);
this.rootBoundary.mapEvent(federatedEvent);
}
this.setCursor(this.rootBoundary.cursor);
}
/**
* Event handler for pointer move events on on this.domElement.
* @param nativeEvent - The native mouse/pointer/touch events.
*/
private onPointerMove(nativeEvent: MouseEvent | PointerEvent | TouchEvent): void
{
this.rootBoundary.rootTarget = this.renderer.lastObjectRendered as DisplayObject;
// if we support touch events, then only use those for touch events, not pointer events
if (this.supportsTouchEvents && (nativeEvent as PointerEvent).pointerType === 'touch') return;
const normalizedEvents = this.normalizeToPointerData(nativeEvent);
for (let i = 0, j = normalizedEvents.length; i < j; i++)
{
const event = this.bootstrapEvent(this.rootPointerEvent, normalizedEvents[i]);
this.rootBoundary.mapEvent(event);
}
this.setCursor(this.rootBoundary.cursor);
}
/**
* Event handler for pointer up events on this.domElement.
* @param nativeEvent - The native mouse/pointer/touch event.
*/
private onPointerUp(nativeEvent: MouseEvent | PointerEvent | TouchEvent): void
{
this.rootBoundary.rootTarget = this.renderer.lastObjectRendered as DisplayObject;
// if we support touch events, then only use those for touch events, not pointer events
if (this.supportsTouchEvents && (nativeEvent as PointerEvent).pointerType === 'touch') return;
let target = nativeEvent.target;
// if in shadow DOM use composedPath to access target
if (nativeEvent.composedPath && nativeEvent.composedPath().length > 0)
{
target = nativeEvent.composedPath()[0];
}
const outside = target !== this.domElement ? 'outside' : '';
const normalizedEvents = this.normalizeToPointerData(nativeEvent);
for (let i = 0, j = normalizedEvents.length; i < j; i++)
{
const event = this.bootstrapEvent(this.rootPointerEvent, normalizedEvents[i]);
event.type += outside;
this.rootBoundary.mapEvent(event);
}
this.setCursor(this.rootBoundary.cursor);
}
/**
* Event handler for pointer over & out events on this.domElement.
* @param nativeEvent - The native mouse/pointer/touch event.
*/
private onPointerOverOut(nativeEvent: MouseEvent | PointerEvent | TouchEvent): void
{
this.rootBoundary.rootTarget = this.renderer.lastObjectRendered as DisplayObject;
// if we support touch events, then only use those for touch events, not pointer events
if (this.supportsTouchEvents && (nativeEvent as PointerEvent).pointerType === 'touch') return;
const normalizedEvents = this.normalizeToPointerData(nativeEvent);
for (let i = 0, j = normalizedEvents.length; i < j; i++)
{
const event = this.bootstrapEvent(this.rootPointerEvent, normalizedEvents[i]);
this.rootBoundary.mapEvent(event);
}
this.setCursor(this.rootBoundary.cursor);
}
/**
* Passive handler for `wheel` events on this.domElement.
* @param nativeEvent - The native wheel event.
*/
protected onWheel(nativeEvent: WheelEvent): void
{
const wheelEvent = this.normalizeWheelEvent(nativeEvent);
this.rootBoundary.rootTarget = this.renderer.lastObjectRendered as DisplayObject;
this.rootBoundary.mapEvent(wheelEvent);
}
/**
* Sets the domElement and binds event listeners.
*
* To deregister the current DOM element without setting a new one, pass null
.
* @param element - The new DOM element.
*/
public setTargetElement(element: HTMLElement): void
{
this.removeEvents();
this.domElement = element;
this.addEvents();
}
/** Register event listeners on this.domElement. */
private addEvents(): void
{
if (this.eventsAdded || !this.domElement)
{
return;
}
const style = this.domElement.style as CrossCSSStyleDeclaration;
if ((globalThis.navigator as any).msPointerEnabled)
{
style.msContentZooming = 'none';
style.msTouchAction = 'none';
}
else if (this.supportsPointerEvents)
{
style.touchAction = 'none';
}
/*
* These events are added first, so that if pointer events are normalized, they are fired
* in the same order as non-normalized events. ie. pointer event 1st, mouse / touch 2nd
*/
if (this.supportsPointerEvents)
{
globalThis.document.addEventListener('pointermove', this.onPointerMove, true);
this.domElement.addEventListener('pointerdown', this.onPointerDown, true);
// pointerout is fired in addition to pointerup (for touch events) and pointercancel
// we already handle those, so for the purposes of what we do in onPointerOut, we only
// care about the pointerleave event
this.domElement.addEventListener('pointerleave', this.onPointerOverOut, true);
this.domElement.addEventListener('pointerover', this.onPointerOverOut, true);
// globalThis.addEventListener('pointercancel', this.onPointerCancel, true);
globalThis.addEventListener('pointerup', this.onPointerUp, true);
}
else
{
globalThis.document.addEventListener('mousemove', this.onPointerMove, true);
this.domElement.addEventListener('mousedown', this.onPointerDown, true);
this.domElement.addEventListener('mouseout', this.onPointerOverOut, true);
this.domElement.addEventListener('mouseover', this.onPointerOverOut, true);
globalThis.addEventListener('mouseup', this.onPointerUp, true);
}
// Always look directly for touch events so that we can provide original data
// In a future version we should change this to being just a fallback and rely solely on
// PointerEvents whenever available
if (this.supportsTouchEvents)
{
this.domElement.addEventListener('touchstart', this.onPointerDown, true);
// this.domElement.addEventListener('touchcancel', this.onPointerCancel, true);
this.domElement.addEventListener('touchend', this.onPointerUp, true);
this.domElement.addEventListener('touchmove', this.onPointerMove, true);
}
this.domElement.addEventListener('wheel', this.onWheel, {
passive: true,
capture: true,
});
this.eventsAdded = true;
}
/** Unregister event listeners on this.domElement. */
private removeEvents(): void
{
if (!this.eventsAdded || !this.domElement)
{
return;
}
const style = this.domElement.style as CrossCSSStyleDeclaration;
if ((globalThis.navigator as any).msPointerEnabled)
{
style.msContentZooming = '';
style.msTouchAction = '';
}
else if (this.supportsPointerEvents)
{
style.touchAction = '';
}
if (this.supportsPointerEvents)
{
globalThis.document.removeEventListener('pointermove', this.onPointerMove, true);
this.domElement.removeEventListener('pointerdown', this.onPointerDown, true);
this.domElement.removeEventListener('pointerleave', this.onPointerOverOut, true);
this.domElement.removeEventListener('pointerover', this.onPointerOverOut, true);
// globalThis.removeEventListener('pointercancel', this.onPointerCancel, true);
globalThis.removeEventListener('pointerup', this.onPointerUp, true);
}
else
{
globalThis.document.removeEventListener('mousemove', this.onPointerMove, true);
this.domElement.removeEventListener('mousedown', this.onPointerDown, true);
this.domElement.removeEventListener('mouseout', this.onPointerOverOut, true);
this.domElement.removeEventListener('mouseover', this.onPointerOverOut, true);
globalThis.removeEventListener('mouseup', this.onPointerUp, true);
}
if (this.supportsTouchEvents)
{
this.domElement.removeEventListener('touchstart', this.onPointerDown, true);
// this.domElement.removeEventListener('touchcancel', this.onPointerCancel, true);
this.domElement.removeEventListener('touchend', this.onPointerUp, true);
this.domElement.removeEventListener('touchmove', this.onPointerMove, true);
}
this.domElement.removeEventListener('wheel', this.onWheel, true);
this.domElement = null;
this.eventsAdded = false;
}
/**
* Maps x and y coords from a DOM object and maps them correctly to the PixiJS view. The
* resulting value is stored in the point. This takes into account the fact that the DOM
* element could be scaled and positioned anywhere on the screen.
* @param {PIXI.IPointData} point - the point that the result will be stored in
* @param {number} x - the x coord of the position to map
* @param {number} y - the y coord of the position to map
*/
public mapPositionToPoint(point: IPointData, x: number, y: number): void
{
let rect;
// IE 11 fix
if (!this.domElement.parentElement)
{
rect = {
x: 0,
y: 0,
width: (this.domElement as any).width,
height: (this.domElement as any).height,
left: 0,
top: 0
};
}
else
{
rect = this.domElement.getBoundingClientRect();
}
const resolutionMultiplier = 1.0 / this.resolution;
point.x = ((x - rect.left) * ((this.domElement as any).width / rect.width)) * resolutionMultiplier;
point.y = ((y - rect.top) * ((this.domElement as any).height / rect.height)) * resolutionMultiplier;
}
/**
* Ensures that the original event object contains all data that a regular pointer event would have
* @param event - The original event data from a touch or mouse event
* @returns An array containing a single normalized pointer event, in the case of a pointer
* or mouse event, or a multiple normalized pointer events if there are multiple changed touches
*/
private normalizeToPointerData(event: TouchEvent | MouseEvent | PointerEvent): PointerEvent[]
{
const normalizedEvents = [];
if (this.supportsTouchEvents && event instanceof TouchEvent)
{
for (let i = 0, li = event.changedTouches.length; i < li; i++)
{
const touch = event.changedTouches[i] as PixiTouch;
if (typeof touch.button === 'undefined') touch.button = 0;
if (typeof touch.buttons === 'undefined') touch.buttons = 1;
if (typeof touch.isPrimary === 'undefined')
{
touch.isPrimary = event.touches.length === 1 && event.type === 'touchstart';
}
if (typeof touch.width === 'undefined') touch.width = touch.radiusX || 1;
if (typeof touch.height === 'undefined') touch.height = touch.radiusY || 1;
if (typeof touch.tiltX === 'undefined') touch.tiltX = 0;
if (typeof touch.tiltY === 'undefined') touch.tiltY = 0;
if (typeof touch.pointerType === 'undefined') touch.pointerType = 'touch';
if (typeof touch.pointerId === 'undefined') touch.pointerId = touch.identifier || 0;
if (typeof touch.pressure === 'undefined') touch.pressure = touch.force || 0.5;
if (typeof touch.twist === 'undefined') touch.twist = 0;
if (typeof touch.tangentialPressure === 'undefined') touch.tangentialPressure = 0;
// TODO: Remove these, as layerX/Y is not a standard, is deprecated, has uneven
// support, and the fill ins are not quite the same
// offsetX/Y might be okay, but is not the same as clientX/Y when the canvas's top
// left is not 0,0 on the page
if (typeof touch.layerX === 'undefined') touch.layerX = touch.offsetX = touch.clientX;
if (typeof touch.layerY === 'undefined') touch.layerY = touch.offsetY = touch.clientY;
// mark the touch as normalized, just so that we know we did it
touch.isNormalized = true;
touch.type = event.type;
normalizedEvents.push(touch);
}
}
// apparently PointerEvent subclasses MouseEvent, so yay
else if (!globalThis.MouseEvent
|| (event instanceof MouseEvent && (!this.supportsPointerEvents || !(event instanceof globalThis.PointerEvent))))
{
const tempEvent = event as PixiPointerEvent;
if (typeof tempEvent.isPrimary === 'undefined') tempEvent.isPrimary = true;
if (typeof tempEvent.width === 'undefined') tempEvent.width = 1;
if (typeof tempEvent.height === 'undefined') tempEvent.height = 1;
if (typeof tempEvent.tiltX === 'undefined') tempEvent.tiltX = 0;
if (typeof tempEvent.tiltY === 'undefined') tempEvent.tiltY = 0;
if (typeof tempEvent.pointerType === 'undefined') tempEvent.pointerType = 'mouse';
if (typeof tempEvent.pointerId === 'undefined') tempEvent.pointerId = MOUSE_POINTER_ID;
if (typeof tempEvent.pressure === 'undefined') tempEvent.pressure = 0.5;
if (typeof tempEvent.twist === 'undefined') tempEvent.twist = 0;
if (typeof tempEvent.tangentialPressure === 'undefined') tempEvent.tangentialPressure = 0;
// mark the mouse event as normalized, just so that we know we did it
tempEvent.isNormalized = true;
normalizedEvents.push(tempEvent);
}
else
{
normalizedEvents.push(event);
}
return normalizedEvents as PointerEvent[];
}
/**
* Normalizes the native WheelEvent.
*
* The returned PIXI.FederatedWheelEvent is a shared instance. It will not persist across
* multiple native wheel events.
* @param nativeEvent - The native wheel event that occurred on the canvas.
* @returns A federated wheel event.
*/
protected normalizeWheelEvent(nativeEvent: WheelEvent): FederatedWheelEvent
{
const event = this.rootWheelEvent;
this.transferMouseData(event, nativeEvent);
event.deltaMode = nativeEvent.deltaMode;
event.deltaX = nativeEvent.deltaX;
event.deltaY = nativeEvent.deltaY;
event.deltaZ = nativeEvent.deltaZ;
this.mapPositionToPoint(event.screen, nativeEvent.clientX, nativeEvent.clientY);
event.global.copyFrom(event.screen);
event.offset.copyFrom(event.screen);
event.nativeEvent = nativeEvent;
event.type = nativeEvent.type;
return event;
}
/**
* Normalizes the nativeEvent
into a federateed FederatedPointerEvent
.
* @param event
* @param nativeEvent
*/
private bootstrapEvent(event: FederatedPointerEvent, nativeEvent: PointerEvent): FederatedPointerEvent
{
event.originalEvent = null;
event.nativeEvent = nativeEvent;
event.pointerId = nativeEvent.pointerId;
event.width = nativeEvent.width;
event.height = nativeEvent.height;
event.isPrimary = nativeEvent.isPrimary;
event.pointerType = nativeEvent.pointerType;
event.pressure = nativeEvent.pressure;
event.tangentialPressure = nativeEvent.tangentialPressure;
event.tiltX = nativeEvent.tiltX;
event.tiltY = nativeEvent.tiltY;
event.twist = nativeEvent.twist;
this.transferMouseData(event, nativeEvent);
this.mapPositionToPoint(event.screen, nativeEvent.clientX, nativeEvent.clientY);
event.global.copyFrom(event.screen);// global = screen for top-level
event.offset.copyFrom(event.screen);// EventBoundary recalculates using its rootTarget
event.isTrusted = nativeEvent.isTrusted;
if (event.type === 'pointerleave')
{
event.type = 'pointerout';
}
if (event.type.startsWith('mouse'))
{
event.type = event.type.replace('mouse', 'pointer');
}
if (event.type.startsWith('touch'))
{
event.type = TOUCH_TO_POINTER[event.type] || event.type;
}
return event;
}
/**
* Transfers base & mouse event data from the nativeEvent
to the federated event.
* @param event
* @param nativeEvent
*/
private transferMouseData(event: FederatedMouseEvent, nativeEvent: MouseEvent): void
{
event.isTrusted = nativeEvent.isTrusted;
event.srcElement = nativeEvent.srcElement;
event.timeStamp = performance.now();
event.type = nativeEvent.type;
event.altKey = nativeEvent.altKey;
event.button = nativeEvent.button;
event.buttons = nativeEvent.buttons;
event.client.x = nativeEvent.clientX;
event.client.y = nativeEvent.clientY;
event.ctrlKey = nativeEvent.ctrlKey;
event.metaKey = nativeEvent.metaKey;
event.movement.x = nativeEvent.movementX;
event.movement.y = nativeEvent.movementY;
event.page.x = nativeEvent.pageX;
event.page.y = nativeEvent.pageY;
event.relatedTarget = null;
}
}
interface CrossCSSStyleDeclaration extends CSSStyleDeclaration
{
msContentZooming: string;
msTouchAction: string;
}
interface PixiPointerEvent extends PointerEvent
{
isPrimary: boolean;
width: number;
height: number;
tiltX: number;
tiltY: number;
pointerType: string;
pointerId: number;
pressure: number;
twist: number;
tangentialPressure: number;
isNormalized: boolean;
type: string;
}
interface PixiTouch extends Touch
{
button: number;
buttons: number;
isPrimary: boolean;
width: number;
height: number;
tiltX: number;
tiltY: number;
pointerType: string;
pointerId: number;
pressure: number;
twist: number;
tangentialPressure: number;
layerX: number;
layerY: number;
offsetX: number;
offsetY: number;
isNormalized: boolean;
type: string;
}
extensions.add(EventSystem);