/** * The `PointerManager` class serves as a central event manager that interprets raw pointer events * (like mouse clicks, touch gestures, or stylus inputs) into higher-level gestures. It abstracts * away the complexity of handling multiple input types and transforms them into common gestures * like taps, holds, panning (drag), and pinch-to-zoom, which are common in modern user interfaces. * * Key mechanisms: * * 1. **Event Handling and Gesture Recognition**: * - `PointerManager` listens for low-level pointer events (such as `pointerdown`, `pointermove`, * and `pointerup`) and converts them into higher-level gestures. * - For example, a quick touch and release is interpreted as a "tap," while a sustained touch * (greater than 600ms) is recognized as a "hold." * - It handles both mouse and touch events uniformly, making it ideal for web applications that * need to support diverse input devices (mouse, trackpad, touch screens). * * 2. **Multi-pointer Support**: * - `PointerManager` supports multiple pointers simultaneously, making it capable of recognizing * complex gestures involving more than one finger or pointer, such as pinch-to-zoom. * - For multi-pointer gestures, it tracks each pointer's position and movement separately, * allowing precise gesture handling. * * 3. **Idle Detection**: * - The class includes idle detection mechanisms, which can trigger actions or events when no * pointer activity is detected for a specified period. This can be useful for implementing * user inactivity warnings or pausing certain interactive elements when the user is idle. * * 4. **Callback-based Gesture Management**: * - The core of the `PointerManager` class revolves around registering and triggering callbacks * for different gestures. Callbacks are provided by the user of this class for events like * pan (`onPan`), pinch (`onPinch`), and others. * - The class ensures that once a gesture starts, it monitors and triggers the appropriate * callbacks, such as `panStart`, `panMove`, and `panEnd`, depending on the detected gesture. * * 5. **Buffer Management**: * - The `PointerManager` class also includes a buffer system for managing and storing recent * events, allowing the developer to enqueue, push, pop, or shift pointer data as needed. * This can be helpful for applications that need to track the history of pointer events * for gesture recognition or undo functionality. * * 6. **Error Handling**: * - The class includes error handling to ensure that all required gesture handlers are defined * by the user. For example, it will throw an error if any essential callback functions for * pan or pinch gestures are missing, ensuring robust gesture management. * * Typical usage involves: * - Registering gesture handlers (e.g., for taps, panning, pinching). * - The class then monitors all pointer events and triggers the corresponding gesture callbacks * when appropriate. * * Example: * ```js * const manager = new PointerManager(); * manager.onPan({ * panStart: (e) => console.log('Pan started', e), * panMove: (e) => console.log('Panning', e), * panEnd: (e) => console.log('Pan ended', e), * priority: 1 * }); * manager.onPinch({ * pinchStart: (e) => console.log('Pinch started', e), * pinchMove: (e) => console.log('Pinching', e), * pinchEnd: (e) => console.log('Pinch ended', e), * priority: 1 * }); * ``` * * In this example, `PointerManager` registers handlers for pan and pinch gestures, automatically * converting pointer events into the desired interactions. By abstracting the raw pointer events, * `PointerManager` allows developers to focus on handling higher-level gestures without worrying * about the underlying complexity. * @fires PointerManager#fingerHover - Triggered when a pointer moves over a target. * @fires PointerManager#fingerSingleTap - Triggered on a quick touch or click. * @fires PointerManager#fingerDoubleTap - Triggered on two quick touches or clicks. * @fires PointerManager#fingerHold - Triggered when a touch or click is held for more than 600ms. * @fires PointerManager#mouseWheel - Triggered when the mouse wheel is rotated. * @fires PointerManager#panStart - Triggered when a pan (drag) gesture begins. * @fires PointerManager#panMove - Triggered during a pan gesture. * @fires PointerManager#panEnd - Triggered when a pan gesture ends. * @fires PointerManager#pinchStart - Triggered when a pinch gesture begins. * @fires PointerManager#pinchMove - Triggered during a pinch gesture. * @fires PointerManager#pinchEnd - Triggered when a pinch gesture ends. * */ class PointerManager { /** * Creates a new PointerManager instance. * @param {HTMLElement} target - DOM element to attach event listeners to * @param {Object} [options] - Configuration options * @param {number} [options.pinchMaxInterval=250] - Maximum time (ms) between touches to trigger pinch * @param {number} [options.idleTime=60] - Seconds of inactivity before idle event */ constructor(target, options) { this.target = target; Object.assign(this, { pinchMaxInterval: 250, // in ms, fingerDown event max distance in time to trigger a pinch. idleTime: 60, //in seconds, }); if (options) Object.assign(this, options); this.idleTimeout = null; this.idling = false; this.currentPointers = []; this.eventObservers = new Map(); this.ppmm = PointerManager.getPixelsPerMM(); this.target.style.touchAction = "none"; this.target.addEventListener('pointerdown', (e) => this.handleEvent(e), false); this.target.addEventListener('pointermove', (e) => this.handleEvent(e), false); this.target.addEventListener('pointerup', (e) => this.handleEvent(e), false); this.target.addEventListener('pointercancel', (e) => this.handleEvent(e), false); this.target.addEventListener('wheel', (e) => this.handleEvent(e), false); this.startIdle(); } /** * Constant for targeting all pointers. * @type {number} * @readonly */ static get ANYPOINTER() { return -1; } /// Utilities /** * Splits a string into an array based on whitespace. * * @param {string} str - The input string to split. * @returns {Array<string>} An array of strings split by whitespace. * @private */ static splitStr(str) { return str.trim().split(/\s+/g); } /** * Calculates device pixels per millimeter. * @returns {number} Pixels per millimeter for current display * @private */ static getPixelsPerMM() { // Get the device pixel ratio const pixelRatio = window.devicePixelRatio || 1; // Create a div to measure const div = document.createElement("div"); div.style.width = "1in"; div.style.height = "1in"; div.style.position = "absolute"; div.style.left = "-100%"; div.style.top = "-100%"; document.body.appendChild(div); // Measure the div const pixelsPerInch = div.offsetWidth * pixelRatio; // Clean up document.body.removeChild(div); // Convert pixels per inch to pixels per mm const pixelsPerMM = pixelsPerInch / 25.4; return pixelsPerMM; } /////////////////////////////////////////////////////////// /// Class interface /** * Registers event handlers. * @param {string} eventTypes - Space-separated list of event types * @param {Object|Function} obj - Handler object or function * @param {number} [idx=ANYPOINTER] - Pointer index to target, or ANYPOINTER for all * @returns {Object} Handler object */ on(eventTypes, obj, idx = PointerManager.ANYPOINTER) { eventTypes = PointerManager.splitStr(eventTypes); if (typeof (obj) == 'function') { obj = Object.fromEntries(eventTypes.map(e => [e, obj])); obj.priority = -1000; } eventTypes.forEach(eventType => { if (idx == PointerManager.ANYPOINTER) { this.broadcastOn(eventType, obj); } else { const p = this.currentPointers[idx]; if (!p) { throw new Error("Bad Index"); } p.on(eventType, obj); } }); return obj; } /** * Unregisters event handlers. * @param {string} eventTypes - Space-separated list of event types * @param {Object|Function} callback - Handler to remove * @param {number} [idx=ANYPOINTER] - Pointer index to target */ off(eventTypes, callback, idx = PointerManager.ANYPOINTER) { if (idx == PointerManager.ANYPOINTER) { this.broadcastOff(eventTypes, callback); } else { PointerManager.splitStr(eventTypes).forEach(eventType => { const p = this.currentPointers[idx]; if (!p) { throw new Error("Bad Index"); } p.off(eventType, callback); }); } } /** * Registers a complete event handler with multiple callbacks. * @param {Object} handler - Handler object * @param {number} handler.priority - Handler priority (higher = earlier execution) * @param {Function} [handler.fingerHover] - Hover callback * @param {Function} [handler.fingerSingleTap] - Single tap callback * @param {Function} [handler.fingerDoubleTap] - Double tap callback * @param {Function} [handler.fingerHold] - Hold callback * @param {Function} [handler.mouseWheel] - Mouse wheel callback * @param {Function} [handler.panStart] - Pan start callback * @param {Function} [handler.panMove] - Pan move callback * @param {Function} [handler.panEnd] - Pan end callback * @param {Function} [handler.pinchStart] - Pinch start callback * @param {Function} [handler.pinchMove] - Pinch move callback * @param {Function} [handler.pinchEnd] - Pinch end callback * @throws {Error} If handler lacks priority or required callbacks */ onEvent(handler) { const cb_properties = ['fingerHover', 'fingerSingleTap', 'fingerDoubleTap', 'fingerHold', 'mouseWheel', 'wentIdle', 'activeAgain']; if (!handler.hasOwnProperty('priority')) throw new Error("Event handler has not priority property"); if (!cb_properties.some((e) => typeof (handler[e]) == 'function')) throw new Error("Event handler properties are wrong or missing"); for (let e of cb_properties) if (typeof (handler[e]) == 'function') { this.on(e, handler); } if (handler.panStart) this.onPan(handler); if (handler.pinchStart) this.onPinch(handler); } /** * Registers callbacks for pan gestures (start, move, and end). * * @param {Object} handler - The handler object containing pan gesture callbacks. * @param {function} handler.panStart - Callback function executed when the pan gesture starts. * @param {function} handler.panMove - Callback function executed during the pan gesture movement. * @param {function} handler.panEnd - Callback function executed when the pan gesture ends. * @throws {Error} Throws an error if any required callback functions (`panStart`, `panMove`, `panEnd`) are missing. */ onPan(handler) { const cb_properties = ['panStart', 'panMove', 'panEnd']; if (!handler.hasOwnProperty('priority')) throw new Error("Event handler has not priority property"); if (!cb_properties.every((e) => typeof (handler[e]) == 'function')) throw new Error("Pan handler is missing one of this functions: panStart, panMove or panEnd"); handler.fingerMovingStart = (e) => { handler.panStart(e); if (!e.defaultPrevented) return; this.on('fingerMoving', (e1) => { handler.panMove(e1); }, e.idx); this.on('fingerMovingEnd', (e2) => { handler.panEnd(e2); }, e.idx); } this.on('fingerMovingStart', handler); } /** * Registers callbacks for pinch gestures (start, move, and end). * * @param {Object} handler - The handler object containing pinch gesture callbacks. * @param {function} handler.pinchStart - Callback function executed when the pinch gesture starts. * @param {function} handler.pinchMove - Callback function executed during the pinch gesture movement. * @param {function} handler.pinchEnd - Callback function executed when the pinch gesture ends. * @throws {Error} Throws an error if any required callback functions (`pinchStart`, `pinchMove`, `pinchEnd`) are missing. */ onPinch(handler) { const cb_properties = ['pinchStart', 'pinchMove', 'pinchEnd']; if (!handler.hasOwnProperty('priority')) throw new Error("Event handler has not priority property"); if (!cb_properties.every((e) => typeof (handler[e]) == 'function')) throw new Error("Pinch handler is missing one of this functions: pinchStart, pinchMove or pinchEnd"); handler.fingerDown = (e1) => { //find other pointers not in moving status const filtered = this.currentPointers.filter(cp => cp && cp.idx != e1.idx && cp.status == cp.stateEnum.DETECT); if (filtered.length == 0) return; //for each pointer search for the last fingerDown event. const fingerDownEvents = []; for (let cp of filtered) { let down = null; for (let e of cp.eventHistory.toArray()) if (e.fingerType == 'fingerDown') down = e; if (down) fingerDownEvents.push(down); } //we start from the closest one //TODO maybe we should sort by distance instead. fingerDownEvents.sort((a, b) => b.timeStamp - a.timeStamp); for (let e2 of fingerDownEvents) { if (e1.timeStamp - e2.timeStamp > this.pinchMaxInterval) break; handler.pinchStart(e1, e2); if (!e1.defaultPrevented) break; clearTimeout(this.currentPointers[e1.idx].timeout); clearTimeout(this.currentPointers[e2.idx].timeout); this.on('fingerMovingStart', (e) => e.preventDefault(), e1.idx); //we need to capture this event (pan conflict) this.on('fingerMovingStart', (e) => e.preventDefault(), e2.idx); this.on('fingerMoving', (e) => e2 && handler.pinchMove(e1 = e, e2), e1.idx); //we need to assign e1 and e2, to keep last position. this.on('fingerMoving', (e) => e1 && handler.pinchMove(e1, e2 = e), e2.idx); this.on('fingerMovingEnd', (e) => { if (e2) handler.pinchEnd(e, e2); e1 = e2 = null; }, e1.idx); this.on('fingerMovingEnd', (e) => { if (e1) handler.pinchEnd(e1, e); e1 = e2 = null; }, e2.idx); break; } } this.on('fingerDown', handler); } /////////////////////////////////////////////////////////// /// Implementation stuff // register broadcast handlers broadcastOn(eventType, obj) { if (!this.eventObservers.has(eventType)) { this.eventObservers.set(eventType, new Set()); } this.eventObservers.get(eventType).add(obj); } broadcastOff(eventTypes, obj) { PointerManager.splitStr(eventTypes).forEach(eventType => { if (this.eventObservers.has(eventType)) { if (!obj) { this.eventObservers.delete(eventType); } else { const handlers = this.eventObservers.get(eventType); handlers.delete(obj); if (handlers.size === 0) { this.eventObservers.delete(eventType); } } } }); } broadcast(e) { if (!this.eventObservers.has(e.fingerType)) return; const handlers = Array.from(this.eventObservers.get(e.fingerType)); handlers.sort((a, b) => b.priority - a.priority); for (const obj of handlers) { obj[e.fingerType](e); if (e.defaultPrevented) break; } } addCurrPointer(cp) { let result = -1; for (let i = 0; i < this.currentPointers.length && result < 0; i++) { if (this.currentPointers[i] == null) { result = i; } } if (result < 0) { this.currentPointers.push(cp); result = this.currentPointers.length - 1; } else { this.currentPointers[result] = cp; } return result; } removeCurrPointer(index) { this.currentPointers[index] = null; while ((this.currentPointers.length > 0) && (this.currentPointers[this.currentPointers.length - 1] == null)) { this.currentPointers.pop(); } } startIdle() { if (this.idleTimeout) clearTimeout(this.idleTimeout) this.idleTimeout = setTimeout(() => { this.broadcast({ fingerType: 'wentIdle' }); this.idling = true; }, this.idleTime * 1000); } handleEvent(e) { //IDLING MANAGEMENT if (this.idling) { this.broadcast({ fingerType: 'activeAgain' }); this.idling = false; } else { this.startIdle(); } if (e.type == 'pointerdown') this.target.setPointerCapture(e.pointerId); if (e.type == 'pointercancel') console.log(e); let handled = false; for (let i = 0; i < this.currentPointers.length && !handled; i++) { const cp = this.currentPointers[i]; if (cp) { handled = cp.handleEvent(e); if (cp.isDone()) this.removeCurrPointer(i); } } if (!handled) { const cp = new SinglePointerHandler(this, e.pointerId, { ppmm: this.ppmm }); handled = cp.handleEvent(e); } //e.preventDefault(); } } /** * Handles events for a single pointer. * @private */ class SinglePointerHandler { /** * Creates a new SinglePointerHandler instance. * @param {PointerManager} parent - Parent PointerManager instance * @param {number} pointerId - Pointer identifier * @param {Object} [options] - Configuration options * @param {number} [options.ppmm=3] - Pixels per millimeter */ constructor(parent, pointerId, options) { this.parent = parent; this.pointerId = pointerId; Object.assign(this, { ppmm: 3, // 27in screen 1920x1080 = 3 ppmm }); if (options) Object.assign(this, options); this.eventHistory = new CircularBuffer(10); this.isActive = false; this.startTap = 0; this.threshold = 15; // 15mm this.eventObservers = new Map(); this.isDown = false; this.done = false; this.stateEnum = { IDLE: 0, DETECT: 1, HOVER: 2, MOVING_START: 3, MOVING: 4, MOVING_END: 5, HOLD: 6, TAPS_DETECT: 7, SINGLE_TAP: 8, DOUBLE_TAP_DETECT: 9, DOUBLE_TAP: 10, }; this.status = this.stateEnum.IDLE; this.timeout = null; this.holdTimeoutThreshold = 600; this.tapTimeoutThreshold = 100; this.oldDownPos = { clientX: 0, clientY: 0 }; this.movingThreshold = 1; // 1mm this.idx = this.parent.addCurrPointer(this); } /////////////////////////////////////////////////////////// /// Utilities static distance(x0, y0, x1, y1) { return Math.sqrt((x1 - x0) ** 2 + (y1 - y0) ** 2); } distanceMM(x0, y0, x1, y1) { return SinglePointerHandler.distance(x0, y0, x1, y1) / this.ppmm; } /////////////////////////////////////////////////////////// /// Class interface on(eventType, obj) { this.eventObservers.set(eventType, obj); } off(eventType) { if (this.eventObservers.has(eventType)) { this.eventObservers.delete(eventType); } } /////////////////////////////////////////////////////////// /// Implementation stuff addToHistory(e) { this.eventHistory.push(e); } prevPointerEvent() { return this.eventHistory.last(); } handlePointerDown(e) { this.startTap = e.timeStamp; } handlePointerUp(e) { const tapDuration = e.timeStamp - this.startTap; } isLikelySamePointer(e) { let result = this.pointerId == e.pointerId; if (!result && !this.isDown && e.type == "pointerdown") { const prevP = this.prevPointerEvent(); if (prevP) { result = (e.pointerType == prevP.pointerType) && this.distanceMM(e.clientX, e.clientY, prevP.clientX, prevP.clientY) < this.threshold; } } return result; } // emit+broadcast emit(e) { if (this.eventObservers.has(e.fingerType)) { this.eventObservers.get(e.fingerType)[e.fingerType](e); if (e.defaultPrevented) return; } this.parent.broadcast(e); } // output Event, speed is computed only on pointermove createOutputEvent(e, type) { const result = e; result.fingerType = type; result.originSrc = this.originSrc; result.speedX = 0; result.speedY = 0; result.idx = this.idx; const prevP = this.prevPointerEvent(); if (prevP && (e.type == 'pointermove')) { const dt = result.timeStamp - prevP.timeStamp; if (dt > 0) { result.speedX = (result.clientX - prevP.clientX) / dt * 1000.0; // px/s result.speedY = (result.clientY - prevP.clientY) / dt * 1000.0; // px/s } } return result; } // Finite State Machine processEvent(e) { let distance = 0; if (e.type == "pointerdown") { this.oldDownPos.clientX = e.clientX; this.oldDownPos.clientY = e.clientY; this.isDown = true; } if (e.type == "pointerup" || e.type == "pointercancel") this.isDown = false; if (e.type == "pointermove" && this.isDown) { distance = this.distanceMM(e.clientX, e.clientY, this.oldDownPos.clientX, this.oldDownPos.clientY) } if (e.type == "wheel") { this.emit(this.createOutputEvent(e, 'mouseWheel')); return; } switch (this.status) { case this.stateEnum.HOVER: case this.stateEnum.IDLE: if (e.type == 'pointermove') { this.emit(this.createOutputEvent(e, 'fingerHover')); this.status = this.stateEnum.HOVER; this.originSrc = e.composedPath()[0]; } else if (e.type == 'pointerdown') { this.status = this.stateEnum.DETECT; this.emit(this.createOutputEvent(e, 'fingerDown')); if (e.defaultPrevented) { // An observer captured the fingerDown event this.status = this.stateEnum.MOVING; break; } this.originSrc = e.composedPath()[0]; this.timeout = setTimeout(() => { this.emit(this.createOutputEvent(e, 'fingerHold')); if (e.defaultPrevented) this.status = this.stateEnum.IDLE; }, this.holdTimeoutThreshold); } break; case this.stateEnum.DETECT: if (e.type == 'pointercancel') { /// For Firefox clearTimeout(this.timeout); this.status = this.stateEnum.IDLE; this.emit(this.createOutputEvent(e, 'fingerHold')); } else if (e.type == 'pointermove' && distance > this.movingThreshold) { clearTimeout(this.timeout); this.status = this.stateEnum.MOVING; this.emit(this.createOutputEvent(e, 'fingerMovingStart')); } else if (e.type == 'pointerup') { clearTimeout(this.timeout); this.status = this.stateEnum.TAPS_DETECT; this.timeout = setTimeout(() => { this.status = this.stateEnum.IDLE; this.emit(this.createOutputEvent(e, 'fingerSingleTap')); }, this.tapTimeoutThreshold); } break; case this.stateEnum.TAPS_DETECT: if (e.type == 'pointerdown') { clearTimeout(this.timeout); this.status = this.stateEnum.DOUBLE_TAP_DETECT; this.timeout = setTimeout(() => { this.emit(this.createOutputEvent(e, 'fingerHold')); if (e.defaultPrevented) this.status = this.stateEnum.IDLE; }, this.tapTimeoutThreshold); } else if (e.type == 'pointermove' && distance > this.movingThreshold) { clearTimeout(this.timeout); this.status = this.stateEnum.IDLE; this.emit(this.createOutputEvent(e, 'fingerHover')); } break; case this.stateEnum.DOUBLE_TAP_DETECT: if (e.type == 'pointerup' || e.type == 'pointercancel') { clearTimeout(this.timeout); this.status = this.stateEnum.IDLE; this.emit(this.createOutputEvent(e, 'fingerDoubleTap')); } break; case this.stateEnum.DOUBLE_TAP_DETECT: if (e.type == 'pointermove' && distance > this.movingThreshold) { this.status = this.stateEnum.MOVING; this.emit(this.createOutputEvent(e, 'fingerMovingStart')); } break; case this.stateEnum.MOVING: if (e.type == 'pointermove') { // Remain MOVING this.emit(this.createOutputEvent(e, 'fingerMoving')); } else if (e.type == 'pointerup' || e.type == 'pointercancel') { this.status = this.stateEnum.IDLE; this.emit(this.createOutputEvent(e, 'fingerMovingEnd')); } break; default: console.log("ERROR " + this.status); console.log(e); break; } this.addToHistory(e); } handleEvent(e) { let result = false; if (this.isLikelySamePointer(e)) { this.pointerId = e.pointerId; //it's mine this.processEvent(e); result = true; } return result; } isDone() { return this.status == this.stateEnum.IDLE; } } /** * Circular buffer for event history. * @private */ class CircularBuffer { /** * Creates a new CircularBuffer instance. * @param {number} capacity - Maximum number of elements * @throws {TypeError} If capacity is not a positive integer */ constructor(capacity) { if (typeof capacity != "number" || !Number.isInteger(capacity) || capacity < 1) throw new TypeError("Invalid capacity"); this.buffer = new Array(capacity); this.capacity = capacity; this.first = 0; this.size = 0; } clear() { this.first = 0; this.size = 0; } empty() { return this.size == 0; } size() { return this.size; } capacity() { return this.capacity; } first() { let result = null; if (this.size > 0) result = this.buffer[this.first]; return result; } last() { let result = null; if (this.size > 0) result = this.buffer[(this.first + this.size - 1) % this.capacity]; return result; } enqueue(v) { this.first = (this.first > 0) ? this.first - 1 : this.first = this.capacity - 1; this.buffer[this.first] = v; if (this.size < this.capacity) this.size++; } push(v) { if (this.size === this.capacity) { this.buffer[this.first] = v; this.first = (this.first + 1) % this.capacity; } else { this.buffer[(this.first + this.size) % this.capacity] = v; this.size++; } } dequeue() { if (this.size == 0) throw new RangeError("Dequeue on empty buffer"); const v = this.buffer[(this.first + this.size - 1) % this.capacity]; this.size--; return v; } pop() { return this.dequeue(); } shift() { if (this.size == 0) throw new RangeError("Shift on empty buffer"); const v = this.buffer[this.first]; if (this.first == this.capacity - 1) this.first = 0; else this.first++; this.size--; return v; } get(start, end) { if (this.size === 0 && start === 0 && (end === undefined || end === 0)) return []; if (typeof start !== "number" || !Number.isInteger(start) || start < 0) throw new TypeError("Invalid start value"); if (start >= this.size) throw new RangeError("Start index past end of buffer: " + start); if (end === undefined) return this.buffer[(this.first + start) % this.capacity]; if (typeof end !== "number" || !Number.isInteger(end) || end < 0) throw new TypeError("Invalid end value"); if (end >= this.size) throw new RangeError("End index past end of buffer: " + end); const result = []; for (let i = start; i <= end; i++) { result.push(this.buffer[(this.first + i) % this.capacity]); } return result; } toArray() { if (this.size == 0) return []; return this.get(0, this.size - 1); } } export { PointerManager }