import type { Engine } from '../engine'; import { ScrollPreventionMode } from '../engine'; import { GlobalCoordinates } from '../math/global-coordinates'; import type { Vector } from '../math/vector'; import { vec } from '../math/vector'; import { PointerEvent } from './pointer-event'; import { WheelEvent } from './wheel-event'; import { PointerAbstraction } from './pointer-abstraction'; import { WheelDeltaMode } from './wheel-delta-mode'; import { PointerSystem } from './pointer-system'; import { NativePointerButton } from './native-pointer-button'; import { PointerButton } from './pointer-button'; import { fail } from '../util/util'; import { PointerType } from './pointer-type'; import { isCrossOriginIframe, isIframe } from '../util/iframe'; import type { EventKey, Handler, Subscription } from '../event-emitter'; import { EventEmitter } from '../event-emitter'; export type NativePointerEvent = globalThis.PointerEvent; export type NativeMouseEvent = globalThis.MouseEvent; export type NativeTouchEvent = globalThis.TouchEvent; export type NativeWheelEvent = globalThis.WheelEvent; export interface PointerEvents { move: PointerEvent; down: PointerEvent; up: PointerEvent; wheel: WheelEvent; } export const PointerEvents = { Move: 'move', Down: 'down', Up: 'up', Wheel: 'wheel' }; /** * Is this event a native touch event? */ function isTouchEvent(value: any): value is NativeTouchEvent { // Guard for Safari <= 13.1 return globalThis.TouchEvent && value instanceof globalThis.TouchEvent; } /** * Is this event a native pointer event */ function isPointerEvent(value: any): value is NativePointerEvent { // Guard for Safari <= 13.1 return globalThis.PointerEvent && value instanceof globalThis.PointerEvent; } export interface PointerInitOptions { grabWindowFocus?: boolean; } /** * The PointerEventProcessor is responsible for collecting all the events from the canvas and transforming them into GlobalCoordinates */ export class PointerEventReceiver { public events = new EventEmitter(); public primary: PointerAbstraction = new PointerAbstraction(); private _activeNativePointerIdsToNormalized = new Map(); private _freedNormalizedIds: number[] = []; private _nextNormalizedId = 0; public lastFramePointerCoords = new Map(); public currentFramePointerCoords = new Map(); public currentFramePointerDown = new Map(); public lastFramePointerDown = new Map(); public currentFrameDown: PointerEvent[] = []; public currentFrameUp: PointerEvent[] = []; public currentFrameMove: PointerEvent[] = []; public currentFrameCancel: PointerEvent[] = []; public currentFrameWheel: WheelEvent[] = []; private _enabled = true; constructor( public readonly target: GlobalEventHandlers & EventTarget, public engine: Engine ) {} public toggleEnabled(enabled: boolean) { this._enabled = enabled; } /** * Creates a new PointerEventReceiver with a new target and engine while preserving existing pointer event * handlers. * @param target * @param engine */ public recreate(target: GlobalEventHandlers & EventTarget, engine: Engine) { const eventReceiver = new PointerEventReceiver(target, engine); eventReceiver.primary = this.primary; eventReceiver._pointers = this._pointers; return eventReceiver; } private _pointers: PointerAbstraction[] = [this.primary]; /** * Locates a specific pointer by id, creates it if it doesn't exist * @param index */ public at(index: number): PointerAbstraction { if (index >= this._pointers.length) { // Ensure there is a pointer to retrieve for (let i = this._pointers.length - 1, max = index; i < max; i++) { this._pointers.push(new PointerAbstraction()); } } return this._pointers[index]; } /** * The number of pointers currently being tracked by excalibur */ public count(): number { return this._pointers.length; } /** * Is the specified pointer id down this frame * @param pointerId */ public isDown(pointerId: number) { if (!this._enabled) { return false; } return this.currentFramePointerDown.get(pointerId) ?? false; } /** * Was the specified pointer id down last frame * @param pointerId */ public wasDown(pointerId: number) { if (!this._enabled) { return false; } return this.lastFramePointerDown.get(pointerId) ?? false; } /** * Whether the Pointer is currently dragging. */ public isDragging(pointerId: number): boolean { if (!this._enabled) { return false; } return this.isDown(pointerId); } /** * Whether the Pointer just started dragging. */ public isDragStart(pointerId: number): boolean { if (!this._enabled) { return false; } return this.isDown(pointerId) && !this.wasDown(pointerId); } /** * Whether the Pointer just ended dragging. */ public isDragEnd(pointerId: number): boolean { if (!this._enabled) { return false; } return !this.isDown(pointerId) && this.wasDown(pointerId); } public emit>(eventName: TEventName, event: PointerEvents[TEventName]): void; public emit(eventName: string, event?: any): void; public emit | string>(eventName: TEventName, event?: any): void { this.events.emit(eventName, event); } public on>(eventName: TEventName, handler: Handler): Subscription; public on(eventName: string, handler: Handler): Subscription; public on | string>(eventName: TEventName, handler: Handler): Subscription { return this.events.on(eventName, handler); } public once>(eventName: TEventName, handler: Handler): Subscription; public once(eventName: string, handler: Handler): Subscription; public once | string>(eventName: TEventName, handler: Handler): Subscription { return this.events.once(eventName, handler); } public off>(eventName: TEventName, handler: Handler): void; public off(eventName: string, handler: Handler): void; public off(eventName: string): void; public off | string>(eventName: TEventName, handler?: Handler): void { this.events.off(eventName, handler as any); } /** * Called internally by excalibur * * Updates the current frame pointer info and emits raw pointer events * * This does not emit events to entities, see PointerSystem * @internal */ public update() { this.lastFramePointerDown = new Map(this.currentFramePointerDown); this.lastFramePointerCoords = new Map(this.currentFramePointerCoords); for (const event of this.currentFrameDown) { if (!event.active) { continue; } this.emit('down', event); const pointer = this.at(event.pointerId); pointer.emit('down', event); this.primary.emit('pointerdown', event); } for (const event of this.currentFrameUp) { if (!event.active) { continue; } this.emit('up', event); const pointer = this.at(event.pointerId); pointer.emit('up', event); } for (const event of this.currentFrameMove) { if (!event.active) { continue; } this.emit('move', event); const pointer = this.at(event.pointerId); pointer.emit('move', event); } for (const event of this.currentFrameCancel) { if (!event.active) { continue; } this.emit('cancel', event); const pointer = this.at(event.pointerId); pointer.emit('cancel', event); } for (const event of this.currentFrameWheel) { if (!event.active) { continue; } this.emit('pointerwheel', event); this.emit('wheel', event); this.primary.emit('pointerwheel', event); this.primary.emit('wheel', event); } if (this.engine.currentScene.camera.hasChanged()) { for (const pointer of this._pointers) { pointer._updateWorldPosition(this.engine); } } } private _releaseEndedPointer(event: PointerEvent, freedThisFrame: Set): void { this.currentFramePointerCoords.delete(event.pointerId); // A mouse remains in range after 'up' and keeps producing pointermove events under the same // native pointerId: the spec fires pointerout/pointerleave after pointerup only "for input // devices that do not support hover", and permits the UA to "always reuse the same pointerId // for a particular pointing device" (https://www.w3.org/TR/pointerevents3/#the-pointerup-event, // https://www.w3.org/TR/pointerevents3/#pointerid-attribute). Keep its id reserved so a later // touch can't steal it and inherit the mouse's per-id hover state. if (event.pointerType === PointerType.Mouse) { return; } const ids = this._activeNativePointerIdsToNormalized.entries(); for (const [native, normalized] of ids) { if (normalized === event.pointerId && !freedThisFrame.has(normalized)) { this._activeNativePointerIdsToNormalized.delete(native); this._freedNormalizedIds.push(normalized); freedThisFrame.add(normalized); } } } /** * Clears the current frame event and pointer data */ public clear() { const freedThisFrame = new Set(); for (const event of this.currentFrameUp) { this._releaseEndedPointer(event, freedThisFrame); } for (const event of this.currentFrameCancel) { this._releaseEndedPointer(event, freedThisFrame); } this.currentFrameDown.length = 0; this.currentFrameUp.length = 0; this.currentFrameMove.length = 0; this.currentFrameCancel.length = 0; this.currentFrameWheel.length = 0; } private _boundHandle = this._handle.bind(this); private _boundWheel = this._handleWheel.bind(this); /** * Initializes the pointer event receiver so that it can start listening to native * browser events. */ public init(options?: PointerInitOptions) { if (this.engine.isDisposed()) { return; } // Disabling the touch action avoids browser/platform gestures from firing on the canvas // It is important on mobile to have touch action 'none' // https://stackoverflow.com/questions/48124372/pointermove-event-not-working-with-touch-why-not if (this.target === this.engine.canvas) { this.engine.canvas.style.touchAction = 'none'; } else { document.body.style.touchAction = 'none'; } // Preferred pointer events if (window.PointerEvent) { this.target.addEventListener('pointerdown', this._boundHandle); this.target.addEventListener('pointerup', this._boundHandle); this.target.addEventListener('pointermove', this._boundHandle); this.target.addEventListener('pointercancel', this._boundHandle); } else { // Touch Events this.target.addEventListener('touchstart', this._boundHandle); this.target.addEventListener('touchend', this._boundHandle); this.target.addEventListener('touchmove', this._boundHandle); this.target.addEventListener('touchcancel', this._boundHandle); // Mouse Events this.target.addEventListener('mousedown', this._boundHandle); this.target.addEventListener('mouseup', this._boundHandle); this.target.addEventListener('mousemove', this._boundHandle); } // MDN MouseWheelEvent const wheelOptions = { passive: !( this.engine.pageScrollPreventionMode === ScrollPreventionMode.All || this.engine.pageScrollPreventionMode === ScrollPreventionMode.Canvas ) }; if ('onwheel' in document.createElement('div')) { // Modern Browsers this.target.addEventListener('wheel', this._boundWheel, wheelOptions); } else if (document.onmousewheel !== undefined) { // Webkit and IE this.target.addEventListener('mousewheel' as any, this._boundWheel, wheelOptions); } else { // Remaining browser and older Firefox this.target.addEventListener('MozMousePixelScroll' as any, this._boundWheel, wheelOptions); } const grabWindowFocus = options?.grabWindowFocus ?? true; // Handle cross origin iframe if (grabWindowFocus && (isCrossOriginIframe() || isIframe())) { const grabFocus = () => { window.focus(); }; // Preferred pointer events if (window.PointerEvent) { this.target.addEventListener('pointerdown', grabFocus); } else { // Touch Events this.target.addEventListener('touchstart', grabFocus); // Mouse Events this.target.addEventListener('mousedown', grabFocus); } } } public detach() { // Preferred pointer events if (window.PointerEvent) { this.target.removeEventListener('pointerdown', this._boundHandle); this.target.removeEventListener('pointerup', this._boundHandle); this.target.removeEventListener('pointermove', this._boundHandle); this.target.removeEventListener('pointercancel', this._boundHandle); } else { // Touch Events this.target.removeEventListener('touchstart', this._boundHandle); this.target.removeEventListener('touchend', this._boundHandle); this.target.removeEventListener('touchmove', this._boundHandle); this.target.removeEventListener('touchcancel', this._boundHandle); // Mouse Events this.target.removeEventListener('mousedown', this._boundHandle); this.target.removeEventListener('mouseup', this._boundHandle); this.target.removeEventListener('mousemove', this._boundHandle); } if ('onwheel' in document.createElement('div')) { // Modern Browsers this.target.removeEventListener('wheel', this._boundWheel); } else if (document.onmousewheel !== undefined) { // Webkit and IE this.target.removeEventListener('mousewheel' as any, this._boundWheel); } else { // Remaining browser and older Firefox this.target.removeEventListener('MozMousePixelScroll' as any, this._boundWheel); } } /** * Take native pointer id and map it to index in active pointers * @param nativePointerId */ private _normalizePointerId(nativePointerId: number): number { const existing = this._activeNativePointerIdsToNormalized.get(nativePointerId); if (existing !== undefined) { return existing; } // Reuse the smallest freed id (so a lone new contact lands back on the primary pointer at id 0), otherwise mint a new one let id: number; if (this._freedNormalizedIds.length > 0) { let minIndex = 0; for (let i = 1; i < this._freedNormalizedIds.length; i++) { if (this._freedNormalizedIds[i] < this._freedNormalizedIds[minIndex]) { minIndex = i; } } id = this._freedNormalizedIds.splice(minIndex, 1)[0]; } else { id = this._nextNormalizedId++; } // Save the mapping so we can reverse it later this._activeNativePointerIdsToNormalized.set(nativePointerId, id); return id; } /** * Responsible for handling and parsing pointer events */ private _handle(ev: NativeTouchEvent | NativePointerEvent | NativeMouseEvent) { if (this.engine.isDisposed() || !this._enabled) { return; } ev.preventDefault(); const eventCoords = new Map(); let button: PointerButton; let pointerType: PointerType; if (isTouchEvent(ev)) { button = PointerButton.Unknown; pointerType = PointerType.Touch; // https://developer.mozilla.org/en-US/docs/Web/API/TouchEvent for (let i = 0; i < ev.changedTouches.length; i++) { const touch = ev.changedTouches[i]; const coordinates = GlobalCoordinates.fromPagePosition(touch.pageX, touch.pageY, this.engine); const nativePointerId = i + 1; const pointerId = this._normalizePointerId(nativePointerId); this.currentFramePointerCoords.set(pointerId, coordinates); eventCoords.set(pointerId, coordinates); } } else { button = this._nativeButtonToPointerButton(ev.button); pointerType = PointerType.Mouse; const coordinates = GlobalCoordinates.fromPagePosition(ev.pageX, ev.pageY, this.engine); let nativePointerId = 1; if (isPointerEvent(ev)) { nativePointerId = ev.pointerId; pointerType = this._stringToPointerType(ev.pointerType); } const pointerId = this._normalizePointerId(nativePointerId); this.currentFramePointerCoords.set(pointerId, coordinates); eventCoords.set(pointerId, coordinates); } for (const [pointerId, coord] of eventCoords.entries()) { switch (ev.type) { case 'mousedown': case 'pointerdown': case 'touchstart': this.currentFrameDown.push(new PointerEvent('down', pointerId, button, pointerType, coord, ev)); this.currentFramePointerDown.set(pointerId, true); break; case 'mouseup': case 'pointerup': case 'touchend': this.currentFrameUp.push(new PointerEvent('up', pointerId, button, pointerType, coord, ev)); this.currentFramePointerDown.set(pointerId, false); break; case 'mousemove': case 'pointermove': case 'touchmove': this.currentFrameMove.push(new PointerEvent('move', pointerId, button, pointerType, coord, ev)); break; case 'touchcancel': case 'pointercancel': this.currentFrameCancel.push(new PointerEvent('cancel', pointerId, button, pointerType, coord, ev)); this.currentFramePointerDown.set(pointerId, false); break; } } } private _handleWheel(ev: NativeWheelEvent) { if (this.engine.isDisposed() || !this._enabled) { return; } // Should we prevent page scroll because of this event if ( this.engine.pageScrollPreventionMode === ScrollPreventionMode.All || (this.engine.pageScrollPreventionMode === ScrollPreventionMode.Canvas && ev.target === this.engine.canvas) ) { ev.preventDefault(); } const screen = this.engine.screen.pageToScreenCoordinates(vec(ev.pageX, ev.pageY)); const world = this.engine.screen.screenToWorldCoordinates(screen); /** * A constant used to normalize wheel events across different browsers * * This normalization factor is pulled from * https://developer.mozilla.org/en-US/docs/Web/Events/wheel#Listening_to_this_event_across_browser */ const ScrollWheelNormalizationFactor = -1 / 40; const deltaX = ev.deltaX || ev.wheelDeltaX * ScrollWheelNormalizationFactor || 0; const deltaY = ev.deltaY || ev.wheelDeltaY * ScrollWheelNormalizationFactor || ev.wheelDelta * ScrollWheelNormalizationFactor || ev.detail || 0; const deltaZ = ev.deltaZ || 0; let deltaMode = WheelDeltaMode.Pixel; if (ev.deltaMode) { if (ev.deltaMode === 1) { deltaMode = WheelDeltaMode.Line; } else if (ev.deltaMode === 2) { deltaMode = WheelDeltaMode.Page; } } const we = new WheelEvent(world.x, world.y, ev.pageX, ev.pageY, screen.x, screen.y, 0, deltaX, deltaY, deltaZ, deltaMode, ev); this.currentFrameWheel.push(we); } /** * Triggers an excalibur pointer event in a world space pos * * Useful for testing pointers in excalibur * @param type * @param pos */ public triggerEvent(type: 'down' | 'up' | 'move' | 'cancel', pos: Vector) { const page = this.engine.screen.worldToPageCoordinates(pos); // Send an event to the event receiver if (window.PointerEvent) { this._handle( new window.PointerEvent('pointer' + type, { pointerId: 0, clientX: page.x, clientY: page.y }) ); } else { // Safari hack this._handle( new window.MouseEvent('mouse' + type, { clientX: page.x, clientY: page.y }) ); } // Force update pointer system const pointerSystem = this.engine.currentScene.world.get(PointerSystem); if (pointerSystem) { pointerSystem!.preupdate!(this.engine.currentScene, 1); pointerSystem!.update(1); } } private _nativeButtonToPointerButton(s: NativePointerButton): PointerButton { switch (s) { case NativePointerButton.NoButton: return PointerButton.NoButton; case NativePointerButton.Left: return PointerButton.Left; case NativePointerButton.Middle: return PointerButton.Middle; case NativePointerButton.Right: return PointerButton.Right; case NativePointerButton.Unknown: return PointerButton.Unknown; default: return fail(s); } } private _stringToPointerType(s: string) { switch (s) { case 'touch': return PointerType.Touch; case 'mouse': return PointerType.Mouse; case 'pen': return PointerType.Pen; default: return PointerType.Unknown; } } }