This repository has been archived on 2021-02-20. You can view files and clone it, but cannot push or open issues or pull requests.
shapez.io/src/js/core/click_detector.js

466 lines
16 KiB
JavaScript

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<ClickDetector>} */
window.activeClickDetectors = [];
}
// Store active click detectors so we can cancel them
/** @type {Array<ClickDetector>} */
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;
}
}