import { GestureRecognizerConfiguration } from "./configuration/gestureRecognizerConfiguration.js"; import { InputEventEngine } from "./inputEventEngine.js"; import { InputSample } from "./headless/inputSample.js"; import { Nonoptional } from "./nonoptional.js"; import { ZoneBoundaryChecker } from "./configuration/zoneBoundaryChecker.js"; import { GestureSource } from "./headless/gestureSource.js"; function touchListToArray(list: TouchList) { const arr: Touch[] = []; for(let i=0; i < list.length; i++) { arr.push(list.item(i)); } return arr; } export class TouchEventEngine extends InputEventEngine { private readonly _touchStart: typeof TouchEventEngine.prototype.onTouchStart; private readonly _touchMove: typeof TouchEventEngine.prototype.onTouchMove; private readonly _touchEnd: typeof TouchEventEngine.prototype.onTouchEnd; private safeBoundMaskMap: {[id: number]: number} = {}; public constructor(config: Nonoptional>) { super(config); // We use this approach, rather than .bind, because _this_ version allows hook // insertion for unit tests via prototype manipulation. The .bind version doesn't. this._touchStart = (event: TouchEvent) => this.onTouchStart(event); this._touchMove = (event: TouchEvent) => this.onTouchMove(event); this._touchEnd = (event: TouchEvent) => this.onTouchEnd(event); } private get eventRoot(): HTMLElement { return this.config.touchEventRoot; } // public static forPredictiveBanner(banner: SuggestionBanner, handlerRoot: SuggestionManager) { // const config: GestureRecognizerConfiguration = { // targetRoot: banner.getDiv(), // // document.body is the event root b/c we need to track the mouse if it leaves // // the VisualKeyboard's hierarchy. // eventRoot: banner.getDiv(), // }; // return new TouchEventEngine(config); // } registerEventHandlers() { // The 'passive' property ensures we can prevent MouseEvent followups from TouchEvents. // It is only specified during `addEventListener`, not during `removeEventListener`. this.eventRoot.addEventListener('touchstart', this._touchStart, {capture: true, passive: false}); this.eventRoot.addEventListener('touchmove', this._touchMove, {capture: false, passive: false}); // The listener below fails to capture when performing automated testing checks in Chrome emulation unless 'true'. this.eventRoot.addEventListener('touchend', this._touchEnd, {capture: true, passive: false}); } unregisterEventHandlers() { this.eventRoot.removeEventListener('touchstart', this._touchStart, true); this.eventRoot.removeEventListener('touchmove', this._touchMove, false); this.eventRoot.removeEventListener('touchend', this._touchEnd, true); } private preventPropagation(e: TouchEvent) { // Standard event maintenance if(e.cancelable) { // Chrome generates error-log messages if this is attempted while // the condition is false. e.preventDefault(); } if(typeof e.stopImmediatePropagation == 'function') { e.stopImmediatePropagation(); } else if(typeof e.stopPropagation == 'function') { e.stopPropagation(); } } public dropTouchpoint(source: GestureSource) { super.dropTouchpoint(source); for(const key of Object.keys(this.safeBoundMaskMap)) { if(this.getTouchpointWithId(Number.parseInt(key, 10)) == source) { delete this.safeBoundMaskMap[key]; } } } private buildSampleFromTouch(touch: Touch, timestamp: number) { // WILL be null for newly-starting `GestureSource`s / contact points. const source = this.getTouchpointWithId(touch.identifier); return this.buildSampleFor(touch.clientX, touch.clientY, touch.target, timestamp, source); } onTouchStart(event: TouchEvent) { // If it's not an event we'd consider handling, do not prevent event // propagation! Just don't process it. if(!this.config.targetRoot.contains(event.target as Node)) { return; } this.preventPropagation(event); // In case a touch ID is reused, we can pre-emptively filter it for special cases to cancel the old version, // noting that it's included by a changedTouch. (Only _new_ contact points are included in .changedTouches // during a touchstart.) const allTouches = touchListToArray(event.touches); const newTouches = touchListToArray(event.changedTouches); // Maintain all touches in the `.touches` array that are NOT marked as `.changedTouches` (and therefore, new) this.maintainTouchpointsWithIds(allTouches .filter((touch1) => newTouches.findIndex(touch2 => touch1.identifier == touch2.identifier) == -1) .map((touch) => touch.identifier) ); // Ensure the same timestamp is used for all touches being updated. const timestamp = performance.now(); // During a touch-start, only _new_ touch contact points are listed here; // we shouldn't signal "input start" for any previously-existing touch points, // so `.changedTouches` is the best way forward. for(let i=0; i < event.changedTouches.length; i++) { const touch = event.changedTouches.item(i); const sample = this.buildSampleFromTouch(touch, timestamp); if(!ZoneBoundaryChecker.inputStartOutOfBoundsCheck(sample, this.config)) { // If we started very close to a safe zone border, remember which one(s). // This is important for input-sequence cancellation check logic. this.safeBoundMaskMap[touch.identifier] = ZoneBoundaryChecker.inputStartSafeBoundProximityCheck(sample, this.config); } else { // This touchpoint shouldn't be considered; do not signal a touchstart for it. continue; } this.onInputStart(touch.identifier, sample, event.target, true); } } onTouchMove(event: TouchEvent) { let propagationActive = true; // Ensure the same timestamp is used for all touches being updated. const timestamp = performance.now(); this.maintainTouchpointsWithIds(touchListToArray(event.touches) .map((touch) => touch.identifier) ); // Do not change to `changedTouches` - we need a sample for all active touches in order // to facilitate path-update synchronization for multi-touch gestures. // // May be worth doing changedTouches _first_ though. for(let i=0; i < event.touches.length; i++) { const touch = event.touches.item(i); if(!this.hasActiveTouchpoint(touch.identifier)) { continue; } if(propagationActive) { this.preventPropagation(event); propagationActive = false; } const config = this.getConfigForId(touch.identifier); const sample = this.buildSampleFromTouch(touch, timestamp); if(!ZoneBoundaryChecker.inputMoveCancellationCheck(sample, config, this.safeBoundMaskMap[touch.identifier])) { this.onInputMove(touch.identifier, sample, touch.target); } else { this.onInputMoveCancel(touch.identifier, sample, touch.target); } } } onTouchEnd(event: TouchEvent) { let propagationActive = true; // Only lists touch contact points that have been lifted; touchmove is raised separately if any movement occurred. for(let i=0; i < event.changedTouches.length; i++) { const touch = event.changedTouches.item(i); if(!this.hasActiveTouchpoint(touch.identifier)) { continue; } if(propagationActive) { this.preventPropagation(event); propagationActive = false; } this.onInputEnd(touch.identifier, event.target); } } }