import { createLogger } from "../core/logging"; import { Signal } from "../core/signal"; import { fastArrayDelete, fastArrayDeleteValueIfContained } from "./utils"; import { Vector } from "./vector"; import { IS_MOBILE, SUPPORT_TOUCH } from "./config"; import { SOUNDS } from "../platform/sound"; import { GLOBAL_APP } from "./globals"; const logger = createLogger("click_detector"); export const MAX_MOVE_DISTANCE_PX = IS_MOBILE ? 20 : 80; // For debugging const registerClickDetectors = G_IS_DEV && true; if (registerClickDetectors) { /** @type {Array} */ window.activeClickDetectors = []; } // Store active click detectors so we can cancel them /** @type {Array} */ const ongoingClickDetectors = []; // Store when the last touch event was registered, to avoid accepting a touch *and* a click event export let clickDetectorGlobals = { lastTouchTime: -1000, }; /** * Click detector creation payload typehints * @typedef {{ * consumeEvents?: boolean, * preventDefault?: boolean, * applyCssClass?: string, * captureTouchmove?: boolean, * targetOnly?: boolean, * maxDistance?: number, * clickSound?: string, * preventClick?: boolean, * }} ClickDetectorConstructorArgs */ // Detects clicks export class ClickDetector { /** * * @param {Element} element * @param {object} param1 * @param {boolean=} param1.consumeEvents Whether to call stopPropagation * (Useful for nested elements where the parent has a click handler as wel) * @param {boolean=} param1.preventDefault Whether to call preventDefault (Usually makes the handler faster) * @param {string=} param1.applyCssClass The css class to add while the element is pressed * @param {boolean=} param1.captureTouchmove Whether to capture touchmove events as well * @param {boolean=} param1.targetOnly Whether to also accept clicks on child elements (e.target !== element) * @param {number=} param1.maxDistance The maximum distance in pixels to accept clicks * @param {string=} param1.clickSound Sound key to play on touchdown * @param {boolean=} param1.preventClick Whether to prevent click events */ constructor( element, { consumeEvents = false, preventDefault = true, applyCssClass = "pressed", captureTouchmove = false, targetOnly = false, maxDistance = MAX_MOVE_DISTANCE_PX, clickSound = SOUNDS.uiClick, preventClick = false, } ) { assert(element, "No element given!"); this.clickDownPosition = null; this.consumeEvents = consumeEvents; this.preventDefault = preventDefault; this.applyCssClass = applyCssClass; this.captureTouchmove = captureTouchmove; this.targetOnly = targetOnly; this.clickSound = clickSound; this.maxDistance = maxDistance; this.preventClick = preventClick; // Signals this.click = new Signal(); this.rightClick = new Signal(); this.touchstart = new Signal(); this.touchmove = new Signal(); this.touchend = new Signal(); this.touchcancel = new Signal(); // Simple signals which just receive the touch position this.touchstartSimple = new Signal(); this.touchmoveSimple = new Signal(); this.touchendSimple = new Signal(); // Store time of touch start this.clickStartTime = null; // A click can be cancelled if another detector registers a click this.cancelled = false; this.internalBindTo(/** @type {HTMLElement} */ (element)); } /** * Cleans up all event listeners of this detector */ cleanup() { if (this.element) { if (registerClickDetectors) { const index = window.activeClickDetectors.indexOf(this); if (index < 0) { logger.error("Click detector cleanup but is not active"); } else { window.activeClickDetectors.splice(index, 1); } } const options = this.internalGetEventListenerOptions(); if (SUPPORT_TOUCH) { this.element.removeEventListener("touchstart", this.handlerTouchStart, options); this.element.removeEventListener("touchend", this.handlerTouchEnd, options); this.element.removeEventListener("touchcancel", this.handlerTouchCancel, options); } this.element.removeEventListener("mouseup", this.handlerTouchStart, options); this.element.removeEventListener("mousedown", this.handlerTouchEnd, options); this.element.removeEventListener("mouseout", this.handlerTouchCancel, options); if (this.captureTouchmove) { if (SUPPORT_TOUCH) { this.element.removeEventListener("touchmove", this.handlerTouchMove, options); } this.element.removeEventListener("mousemove", this.handlerTouchMove, options); } if (this.preventClick) { this.element.removeEventListener("click", this.handlerPreventClick, options); } this.click.removeAll(); this.touchstart.removeAll(); this.touchmove.removeAll(); this.touchend.removeAll(); this.touchcancel.removeAll(); this.element = null; } } // INTERNAL METHODS /** * * @param {Event} event */ internalPreventClick(event) { window.focus(); event.preventDefault(); } /** * Internal method to get the options to pass to an event listener */ internalGetEventListenerOptions() { return { capture: this.consumeEvents, passive: !this.preventDefault, }; } /** * Binds the click detector to an element * @param {HTMLElement} element */ internalBindTo(element) { const options = this.internalGetEventListenerOptions(); this.handlerTouchStart = this.internalOnPointerDown.bind(this); this.handlerTouchEnd = this.internalOnPointerEnd.bind(this); this.handlerTouchMove = this.internalOnPointerMove.bind(this); this.handlerTouchCancel = this.internalOnTouchCancel.bind(this); if (this.preventClick) { this.handlerPreventClick = this.internalPreventClick.bind(this); element.addEventListener("click", this.handlerPreventClick, options); } if (SUPPORT_TOUCH) { element.addEventListener("touchstart", this.handlerTouchStart, options); element.addEventListener("touchend", this.handlerTouchEnd, options); element.addEventListener("touchcancel", this.handlerTouchCancel, options); } element.addEventListener("mousedown", this.handlerTouchStart, options); element.addEventListener("mouseup", this.handlerTouchEnd, options); element.addEventListener("mouseout", this.handlerTouchCancel, options); if (this.captureTouchmove) { if (SUPPORT_TOUCH) { element.addEventListener("touchmove", this.handlerTouchMove, options); } element.addEventListener("mousemove", this.handlerTouchMove, options); } if (registerClickDetectors) { window.activeClickDetectors.push(this); } this.element = element; } /** * Returns if the bound element is currently in the DOM. */ internalIsDomElementAttached() { return this.element && document.documentElement.contains(this.element); } /** * Checks if the given event is relevant for this detector * @param {TouchEvent|MouseEvent} event */ internalEventPreHandler(event, expectedRemainingTouches = 1) { if (!this.element) { // Already cleaned up return false; } if (this.targetOnly && event.target !== this.element) { // Clicked a child element return false; } // Stop any propagation and defaults if configured if (this.consumeEvents && event.cancelable) { event.stopPropagation(); } if (this.preventDefault && event.cancelable) { event.preventDefault(); } if (window.TouchEvent && event instanceof TouchEvent) { clickDetectorGlobals.lastTouchTime = performance.now(); // console.log("Got touches", event.targetTouches.length, "vs", expectedRemainingTouches); if (event.targetTouches.length !== expectedRemainingTouches) { return false; } } if (event instanceof MouseEvent) { if (performance.now() - clickDetectorGlobals.lastTouchTime < 1000.0) { return false; } } return true; } /** * Extracts the mous position from an event * @param {TouchEvent|MouseEvent} event * @returns {Vector} The client space position */ static extractPointerPosition(event) { if (window.TouchEvent && event instanceof TouchEvent) { if (event.changedTouches.length !== 1) { logger.warn( "Got unexpected target touches:", event.targetTouches.length, "->", event.targetTouches ); return new Vector(0, 0); } const touch = event.changedTouches[0]; return new Vector(touch.clientX, touch.clientY); } if (event instanceof MouseEvent) { return new Vector(event.clientX, event.clientY); } assertAlways(false, "Got unknown event: " + event); return new Vector(0, 0); } /** * Cacnels all ongoing events on this detector */ cancelOngoingEvents() { if (this.applyCssClass && this.element) { this.element.classList.remove(this.applyCssClass); } this.clickDownPosition = null; this.clickStartTime = null; this.cancelled = true; fastArrayDeleteValueIfContained(ongoingClickDetectors, this); } /** * Internal pointer down handler * @param {TouchEvent|MouseEvent} event */ internalOnPointerDown(event) { window.focus(); if (!this.internalEventPreHandler(event, 1)) { return false; } const position = /** @type {typeof ClickDetector} */ (this.constructor).extractPointerPosition(event); if (event instanceof MouseEvent) { const isRightClick = event.button === 2; if (isRightClick) { // Ignore right clicks this.rightClick.dispatch(position, event); this.cancelled = true; this.clickDownPosition = null; return; } } if (this.clickDownPosition) { logger.warn("Ignoring double click"); return false; } this.cancelled = false; this.touchstart.dispatch(event); // Store where the touch started this.clickDownPosition = position; this.clickStartTime = performance.now(); this.touchstartSimple.dispatch(this.clickDownPosition.x, this.clickDownPosition.y); // If we are not currently within a click, register it if (ongoingClickDetectors.indexOf(this) < 0) { ongoingClickDetectors.push(this); } else { logger.warn("Click detector got pointer down of active pointer twice"); } // If we should apply any classes, do this now if (this.applyCssClass) { this.element.classList.add(this.applyCssClass); } // If we should play any sound, do this if (this.clickSound) { GLOBAL_APP.sound.playUiSound(this.clickSound); } return false; } /** * Internal pointer move handler * @param {TouchEvent|MouseEvent} event */ internalOnPointerMove(event) { if (!this.internalEventPreHandler(event, 1)) { return false; } this.touchmove.dispatch(event); const pos = /** @type {typeof ClickDetector} */ (this.constructor).extractPointerPosition(event); this.touchmoveSimple.dispatch(pos.x, pos.y); return false; } /** * Internal pointer end handler * @param {TouchEvent|MouseEvent} event */ internalOnPointerEnd(event) { window.focus(); if (!this.internalEventPreHandler(event, 0)) { return false; } if (this.cancelled) { // warn(this, "Not dispatching touchend on cancelled listener"); return false; } if (event instanceof MouseEvent) { const isRightClick = event.button === 2; if (isRightClick) { return; } } const index = ongoingClickDetectors.indexOf(this); if (index < 0) { logger.warn("Got pointer end but click detector is not in pressed state"); } else { fastArrayDelete(ongoingClickDetectors, index); } let dispatchClick = false; let dispatchClickPos = null; // Check for correct down position, otherwise must have pinched or so if (this.clickDownPosition) { const pos = /** @type {typeof ClickDetector} */ (this.constructor).extractPointerPosition(event); const distance = pos.distance(this.clickDownPosition); if (!IS_MOBILE || distance <= this.maxDistance) { dispatchClick = true; dispatchClickPos = pos; } else { console.warn("[ClickDetector] Touch does not count as click:", "(was", distance, ")"); } } this.clickDownPosition = null; this.clickStartTime = null; if (this.applyCssClass) { this.element.classList.remove(this.applyCssClass); } // Dispatch in the end to avoid the element getting invalidated // Also make sure that the element is still in the dom if (this.internalIsDomElementAttached()) { this.touchend.dispatch(event); this.touchendSimple.dispatch(); if (dispatchClick) { const detectors = ongoingClickDetectors.slice(); for (let i = 0; i < detectors.length; ++i) { detectors[i].cancelOngoingEvents(); } this.click.dispatch(dispatchClickPos, event); } } return false; } /** * Internal touch cancel handler * @param {TouchEvent|MouseEvent} event */ internalOnTouchCancel(event) { if (!this.internalEventPreHandler(event, 0)) { return false; } if (this.cancelled) { // warn(this, "Not dispatching touchcancel on cancelled listener"); return false; } this.cancelOngoingEvents(); this.touchcancel.dispatch(event); this.touchendSimple.dispatch(event); return false; } }