/* eslint-disable no-unused-vars */ /* globals Hammer, propagating */ /*eslint no-console: ["error", { allow: ["log", "warn", "info", "error"] }]*/ import Interface from './interface.js' import { Points, MapProxy } from './utils.js' import Events from './events.js' import Logging from './logging.js' /** Interaction patterns See interaction.html for explanation */ export class IInteractionTarget extends Interface { capture(event) { return typeof true } onStart(event, interaction) {} onMove(event, interaction) {} onEnd(event, interaction) {} onMouseWheel(event) {} } export class IInteractionMapperTarget extends Interface { capture(event) { return typeof true } findTarget(event, local, global) { return IInteractionTarget } } export class PointMap extends MapProxy { // Collects touch points, mouse coordinates, etc. as key value pairs. // Keys are pointer and touch ids, the special "mouse" key. // Values are points, i.e. all objects with numeric x and y properties. constructor(points = {}) { super() for (let key in points) { this.set(key, points[key]) } } toString() { let points = [] for (let key of this.keys()) { let value = this.get(key) points.push(`${key}:{x:${value.x}, y:${value.y}}`) } let attrs = points.join(', ') return `[PointMap ${attrs}]` } clone() { let result = new PointMap() for (let key of this.keys()) { let value = this.get(key) result.set(key, { x: value.x, y: value.y }) } return result } keyOf(value) { for (let key of this.keys()) { let p = this.get(key) if (p.x == value.x && p.y == value.y) { return key } } return null } firstKey() { for (let key of this.keys()) { return key } return null } first() { for (let key of this.keys()) { return this.get(key) } return null } farthests() { if (this.size == 0) { return null } let pairs = [] for (let key of this.keys()) { let p = this.get(key) p.key = key for (let k of this.keys()) { let q = this.get(k) q.key = k pairs.push([p, q]) } } let sorted = pairs.sort((a, b) => { return Points.distance(b[0], b[1]) - Points.distance(a[0], a[1]) }) return sorted[0] } mean() { if (this.size == 0) { return null } let x = 0.0, y = 0.0 for (let p of this.values()) { x += p.x y += p.y } return { x: x / this.size, y: y / this.size } } } export class InteractionDelta { /** *Creates an instance of InteractionDelta. * @param {*} x * @param {*} y * @param {*} zoom * @param {*} rotate * @param {*} about * @param {*} number - number of involved pointer * @param {*} distance - distance of farthests touch points * @memberof InteractionDelta */ constructor(x, y, zoom, rotate, about, number, distance) { this.x = x this.y = y this.zoom = zoom this.rotate = rotate this.about = about this.number = number this.distance = distance } toString() { let values = [] for (let key of Object.keys(this)) { let value = this[key] if (key == 'about') { values.push(`${key}:{x:${value.x}, y:${value.y}}`) } else { values.push(`${key}:${value}`) } } let attrs = values.join(', ') return `[InteractionDelta ${attrs}]` } } export class InteractionPoints { constructor(parent = null) { this.parent = parent this.current = new PointMap() this.previous = new PointMap() this.start = new PointMap() this.ended = new PointMap() this.timestamps = new Map() } moved(key) { let current = this.current.get(key) let previous = this.previous.get(key) return Points.subtract(current, previous) } move() { let current = this.current.mean() let previous = this.previous.mean() return Points.subtract(current, previous) } /** * Computes the delta between previous and current angles. Corrects * value that are larger than 45° * @param {*} a * @param {*} b * @returns delta */ diffAngle(a, b) { let alpha = Math.atan2(Math.sin(a - b), Math.cos(a - b)) if (Math.abs(alpha) > Math.PI / 4) { alpha -= Math.PI } return alpha } /** * Computes the delta between interaction points at t and t+1. * * @returns InteractionDelta * @memberof InteractionPoints */ delta() { let csize = this.current.size let psize = this.previous.size if (csize >= 2 && csize == psize) { // Reduce to the two farthests points let current = this.current.farthests() let c1 = current[0] let c2 = current[1] let p1 = this.previous.get(c1.key) let p2 = this.previous.get(c2.key) let d1 = Points.subtract(c1, p1) let d2 = Points.subtract(c2, p2) let cm = Points.mean(c1, c2) // Using the mean leads to jumps between time slices with 3 and 2 fingers // We use the mean of deltas instead let delta = Points.mean(d1, d2) let zoom = 1.0 let distance1 = Points.distance(p1, p2) let distance2 = Points.distance(c1, c2) if (distance1 != 0 && distance2 != 0) { zoom = distance2 / distance1 } let currentAngle = Points.angle(c1, c2) let previousAngle = Points.angle(p1, p2) let alpha = this.diffAngle(currentAngle, previousAngle) return new InteractionDelta( delta.x, delta.y, zoom, alpha, cm, csize, distance2 ) } else if ( csize == 1 && psize == 1 && this.current.firstKey() == this.previous.firstKey() ) { // We need to ensure that the keys are the same, since single points with different keys // can jump let current = this.current.first() let previous = this.previous.first() let delta = Points.subtract(current, previous) return new InteractionDelta( delta.x, delta.y, 1.0, 0.0, current, csize ) } return null } started(key, point) { this.current.set(key, point) this.start.set(key, point) this.previous.set(key, point) this.timestamps.set(key, performance.now()) } update(key, point) { // Returns true iff the key is new this.current.set(key, point) if (!this.start.has(key)) { this.start.set(key, point) this.previous.set(key, point) this.timestamps.set(key, performance.now()) return true } return false } updatePrevious() { for (let key of this.current.keys()) { this.previous.set(key, this.current.get(key)) } } stop(key, point) { if (this.current.has(key)) { this.current.delete(key) this.previous.delete(key) this.ended.set(key, point) } } finish(key, point) { this.current.delete(key) this.previous.delete(key) this.start.delete(key) this.timestamps.delete(key) this.ended.delete(key) } isFinished() { return this.current.size == 0 } isNoLongerTwoFinger() { return this.previous.size > 1 && this.current.size < 2 } isTap(key) { return this.parent.isTap(key) } isDoubleTap(key) { return this.parent.isDoubleTap(key) } isLongPress(key) { return this.parent.isLongPress(key) } } export class Interaction extends InteractionPoints { constructor(tapDistance = 10, tapDuration = 250.0, longPressTime = 500.0) { super() this.tapDistance = tapDistance this.tapCounts = new Map() this.tapPositions = new Map() this.tapTimestamps = new Map() this.tapDuration = tapDuration this.longPressTime = longPressTime this.targets = new Map() this.subInteractions = new Map() // target:Object : InteractionPoints } stop(key, point) { super.stop(key, point) for (let points of this.subInteractions.values()) { points.stop(key, point) } } addTarget(key, target) { this.targets.set(key, target) this.subInteractions.set(target, new InteractionPoints(this)) } removeTarget(key) { let target = this.targets.get(key) this.targets.delete(key) // Only remove target if no keys are refering to the target let remove = true for (let t of this.targets.values()) { if (target === t) { remove = false } } if (remove) { this.subInteractions.delete(target) } } finish(key, point) { super.finish(key, point) this.removeTarget(key) } mapInteraction(points, aspects, mappingFunc) { // Map centrally registered points to target interactions // Returns an array of [target, updated subInteraction] pairs let result = new Map() for (let key in points) { if (this.targets.has(key)) { let target = this.targets.get(key) if (this.subInteractions.has(target)) { let interaction = this.subInteractions.get(target) for (let aspect of aspects) { let pointMap = this[aspect] let point = pointMap.get(key) let mapped = mappingFunc(point, target) interaction[aspect].set(key, mapped) } result.set(target, interaction) } } } return result } registerTap(key, point) { if (this.tapCounts.has(key)) { let count = this.tapCounts.get(key) this.tapCounts.set(key, count + 1) } else { this.tapCounts.set(key, 1) } this.tapPositions.set(key, point) this.tapTimestamps.set(key, performance.now()) } unregisterTap(key) { this.tapCounts.delete(key) this.tapPositions.delete(key) this.tapTimestamps.delete(key) } isTap(key) { let ended = this.ended.get(key) let start = this.start.get(key) if ( start && ended && Points.distance(ended, start) < this.tapDistance ) { let t1 = this.timestamps.get(key) let tookLong = performance.now() > t1 + this.longPressTime if (tookLong) { return false } return true } return false } isDoubleTap(key) { let ended = this.ended.get(key) if (this.tapCounts.has(key) && this.tapCounts.get(key) > 2) { this.unregisterTap(key) } if (this.tapPositions.has(key)) { let pos = this.tapPositions.get(key) if (Points.distance(ended, pos) > this.tapDistance) { this.unregisterTap(key) } } if ( this.tapTimestamps.has(key) && performance.now() > this.tapTimestamps.get(key) + this.tapDuration ) { //console.log("tap too long") this.unregisterTap(key) } let result = false if (this.isTap(key)) { this.registerTap(key, ended) result = this.tapCounts.get(key) == 2 } else { this.unregisterTap(key) } //console.log("isDoubleTap", this.tapCounts.get(key), result) return result } isAnyTap() { for (let key of this.ended.keys()) { if (this.isTap(key)) return true } return false } isLongPress(key) { let ended = this.ended.get(key) let start = this.start.get(key) if ( start && ended && Points.distance(ended, start) < this.tapDistance ) { let t1 = this.timestamps.get(key) let tookLong = performance.now() > t1 + this.longPressTime if (tookLong) { return true } return false } return false } isAnyLongPress() { for (let key of this.ended.keys()) { if (this.isLongPress(key)) return true } return false } isStylus(key) { return key === 'stylus' } } /** * This class implements the main delegate functionality: All necessary event handlers are registered for the * given element. Uses PointerEvents if available or TouchEvents on iOS. The fallback is on mouse events. * Collects the events if the interaction target captures the start event (i.e. declares that * the target wants the start event as well as all following move and end evcents.) * * @export * @class InteractionDelegate */ export class InteractionDelegate { // Long press: http://stackoverflow.com/questions/1930895/how-long-is-the-event-onlongpress-in-the-android // Stylus support: https://w3c.github.io/touch-events/ /** * Creates an instance of InteractionDelegate. * @param {any} element * @param {any} target * @param {any} [{ mouseWheelElement = null, useCapture = true, capturePointerEvents = true, debug = false }={}] * @memberof InteractionDelegate */ constructor( element, target, { mouseWheelElement = null, useCapture = true, capturePointerEvents = true, cancelOnWindowOut = true, debug = false } = {} ) { this.debug = debug this.interaction = new Interaction() this.element = element this.mouseWheelElement = mouseWheelElement || element this.target = target this.useCapture = useCapture this.capturePointerEvents = capturePointerEvents this.cancelOnWindowOut = cancelOnWindowOut this.setupInteraction() } setupInteraction() { if (this.debug) { let error = this.targetInterface.implementationError( this.target.constructor ) if (error != null) { throw new Error('Expected IInteractionTarget: ' + error) } } this.setupTouchInteraction() this.setupMouseWheelInteraction() } get targetInterface() { return IInteractionTarget } setupTouchInteraction() { let element = this.element let useCapture = this.useCapture if (window.PointerEvent) { if (this.debug) console.log('Pointer API' + window.PointerEvent) element.addEventListener( 'pointerdown', e => { if (this.debug) console.log('pointerdown', e.pointerId) if (this.capture(e)) { if (this.capturePointerEvents) { try { element.setPointerCapture(e.pointerId) } catch (e) { console.warn('Cannot setPointerCapture') } } this.onStart(e) } }, useCapture ) element.addEventListener( 'pointermove', e => { if (this.debug) console.log('pointermove', e.pointerId, e.pointerType) if ( e.pointerType == 'touch' || (e.pointerType == 'mouse' && Events.isPointerDown(e)) ) { // this.capture(e) && if (this.debug) console.log('pointermove captured', e.pointerId) this.onMove(e) } }, useCapture ) element.addEventListener( 'pointerup', e => { if (this.debug) console.log('pointerup', e.pointerId, e.pointerType) this.onEnd(e) if (this.capturePointerEvents) { try { element.releasePointerCapture(e.pointerId) } catch (e) { console.warn('Cannot release pointer') } } }, useCapture ) element.addEventListener( 'pointercancel', e => { if (this.debug) console.log('pointercancel', e.pointerId, e.pointerType) this.onEnd(e) if (this.capturePointerEvents) element.releasePointerCapture(e.pointerId) }, useCapture ) if (!this.capturePointerEvents) { element.addEventListener( 'pointerleave', e => { if (this.debug) console.log( 'pointerleave', e.pointerId, e.pointerType ) if (e.target == element) this.onEnd(e) }, useCapture ) } if (!this.capturePointerEvents) { element.addEventListener( 'pointerout', e => { if (this.debug) console.log( 'pointerout', e.pointerId, e.pointerType ) if (e.target == element) this.onEnd(e) }, useCapture ) } if (this.cancelOnWindowOut) { window.addEventListener( 'pointerout', e => { if (this.debug) console.log( 'pointerout', e.pointerId, e.pointerType, e.target ) if (e.target == element) { this.onEnd(e) } }, useCapture ) } } else if (window.TouchEvent) { if (this.debug) console.log('Touch API') element.addEventListener( 'touchstart', e => { if (this.debug) console.log('touchstart', this.touchPoints(e)) if (this.capture(e)) { for (let touch of e.changedTouches) { this.onStart(touch) } } }, useCapture ) element.addEventListener( 'touchmove', e => { if (this.debug) console.log('touchmove', this.touchPoints(e), e) for (let touch of e.changedTouches) { this.onMove(touch) } for (let touch of e.targetTouches) { this.onMove(touch) } }, useCapture ) element.addEventListener( 'touchend', e => { if (this.debug) console.log('touchend', this.touchPoints(e)) for (let touch of e.changedTouches) { this.onEnd(touch) } }, useCapture ) element.addEventListener( 'touchcancel', e => { if (this.debug) console.log( 'touchcancel', e.targetTouches.length, e.changedTouches.length ) for (let touch of e.changedTouches) { this.onEnd(touch) } }, useCapture ) } else { if (this.debug) console.log('Mouse API') element.addEventListener( 'mousedown', e => { if (this.debug) console.log('mousedown', e) if (this.capture(e)) { this.onStart(e) } }, useCapture ) element.addEventListener( 'mousemove', e => { // Dow we only use move events if the mouse is down? // HOver effects have to be implemented by other means // && Events.isMouseDown(e)) if (Events.isMouseDown(e)) { if (this.debug) console.log('mousemove', e) this.onMove(e) } }, useCapture ) element.addEventListener( 'mouseup', e => { if (this.debug) console.log('mouseup', e) this.onEnd(e) }, true ) if (!this.capturePointerEvents) { element.addEventListener( 'mouseout', e => { if (e.target == element) { this.onEnd(e) console.warn( "Shouldn't happen: mouseout ends interaction" ) } }, useCapture ) } if (this.cancelOnWindowOut) { window.addEventListener( 'mouseout', e => { if (e.target == element) { this.onEnd(e) } }, useCapture ) } } } isDescendant(parent, child) { if (parent == child) return true let node = child.parentNode while (node != null) { if (node == parent) { return true } node = node.parentNode } return false } touchPoints(event) { let result = [] for (let touch of event.changedTouches) { result.push(this.extractPoint(touch)) } return result } setupMouseWheelInteraction() { this.mouseWheelElement.addEventListener( 'mousewheel', this.onMouseWheel.bind(this), true ) this.mouseWheelElement.addEventListener( 'DOMMouseScroll', this.onMouseWheel.bind(this), true ) } onMouseWheel(event) { if (this.capture(event) && this.target.onMouseWheel) { this.target.onMouseWheel(event) } else { //console.warn('Target has no onMouseWheel callback') } } onStart(event) { let extracted = this.extractPoint(event) this.startInteraction(event, extracted) this.target.onStart(event, this.interaction) } onMove(event) { let extracted = this.extractPoint(event, 'all') this.updateInteraction(event, extracted) this.target.onMove(event, this.interaction) this.interaction.updatePrevious() } onEnd(event) { let extracted = this.extractPoint(event, 'changedTouches') this.endInteraction(event, extracted) this.target.onEnd(event, this.interaction) this.finishInteraction(event, extracted) } /** * Asks the target whether the event should be captured * * @param {any} event * @returns {bool} * @memberof InteractionDelegate */ capture(event) { if (Events.isCaptured(event)) { return false } let captured = this.target.capture(event) return captured } getPosition(event) { return { x: event.clientX, y: event.clientY } } extractPoint(event, touchEventKey = 'all') { // 'targetTouches' let result = {} switch (event.constructor.name) { case 'MouseEvent': { let buttons = event.buttons || event.which if (buttons) result['mouse'] = this.getPosition(event) break } case 'PointerEvent': { result[event.pointerId.toString()] = this.getPosition(event) break } case 'Touch': { let id = event.touchType === 'stylus' ? 'stylus' : event.identifier.toString() result[id] = this.getPosition(event) break } // case 'TouchEvent': // // Needs to be observed: Perhaps changedTouches are all we need. If so // // we can remove the touchEventKey default parameter // if (touchEventKey == 'all') { // for(let t of event.targetTouches) { // result[t.identifier.toString()] = this.getPosition(t) // } // for(let t of event.changedTouches) { // result[t.identifier.toString()] = this.getPosition(t) // } // } // else { // for(let t of event.changedTouches) { // result[t.identifier.toString()] = this.getPosition(t) // } // } // break default: break } return result } interactionStarted(event, key, point) { // Callback: can be overwritten } interactionEnded(event, key, point) { // Callback: can be overwritten } interactionFinished(event, key, point) {} startInteraction(event, extracted) { for (let key in extracted) { let point = extracted[key] this.interaction.started(key, point) this.interactionStarted(event, key, point) } } updateInteraction(event, extracted) { for (let key in extracted) { let point = extracted[key] let updated = this.interaction.update(key, point) if (updated) { console.warn( "new pointer in updateInteraction shouldn't happen", key ) this.interactionStarted(event, key, point) } } } endInteraction(event, ended) { for (let key in ended) { let point = ended[key] this.interaction.stop(key, point) this.interactionEnded(event, key, point) } } finishInteraction(event, ended) { for (let key in ended) { let point = ended[key] this.interaction.finish(key, point) this.interactionFinished(event, key, point) } } } /** * A special InteractionDelegate that maps events to specific parts of * the interaction target. The InteractionTarget must implement a findTarget * method that returns an object implementing the IInteractionTarget interface. * * If the InteractionTarget also implements a mapPositionToPoint method this * is used to map the points to the local coordinate space of the the target. * * This makes it easier to lookup elements and relate events to local * positions. * * @export * @class InteractionMapper * @extends {InteractionDelegate} */ export class InteractionMapper extends InteractionDelegate { constructor( element, target, { tapDistance = 10, longPressTime = 500.0, useCapture = true, mouseWheelElement = null, logInteractionsAbove = 12 } = {} ) { super(element, target, { tapDistance, useCapture, longPressTime, mouseWheelElement }) this.logInteractionsAbove = logInteractionsAbove } get targetInterface() { return IInteractionMapperTarget } mapPositionToPoint(point, element = null) { if (this.target.mapPositionToPoint) { return this.target.mapPositionToPoint(point, element) } return point } interactionStarted(event, key, point) { if (this.target.findTarget) { let local = this.mapPositionToPoint(point) let found = this.target.findTarget(event, local, point) if (found != null) { this.interaction.addTarget(key, found) } } let size = this.interaction.current.size let limit = this.logInteractionsAbove if (size > limit) { Logging.log(`Number of interactions ${size} exceeds ${limit}`) } } onMouseWheel(event) { if (this.capture(event)) { if (this.target.findTarget) { let point = this.getPosition(event) let local = this.mapPositionToPoint(point) let found = this.target.findTarget(event, local, point) if (found != null && found.onMouseWheel) { found.onMouseWheel(event) return } } if (this.target.onMouseWheel) { this.target.onMouseWheel(event) } else { //console.warn('Target has no onMouseWheel callback', this.target) } } } onStart(event) { let extracted = this.extractPoint(event) this.startInteraction(event, extracted) let mapped = this.interaction.mapInteraction( extracted, ['current', 'start'], this.mapPositionToPoint.bind(this) ) for (let [target, interaction] of mapped.entries()) { target.onStart(event, interaction) } } onMove(event) { let extracted = this.extractPoint(event, 'all') this.updateInteraction(event, extracted) let mapped = this.interaction.mapInteraction( extracted, ['current', 'previous'], this.mapPositionToPoint.bind(this) ) for (let [target, interaction] of mapped.entries()) { target.onMove(event, interaction) interaction.updatePrevious() } this.interaction.updatePrevious() } onEnd(event) { let extracted = this.extractPoint(event, 'changedTouches') this.endInteraction(event, extracted) let mapped = this.interaction.mapInteraction( extracted, ['ended'], this.mapPositionToPoint.bind(this) ) for (let [target, interaction] of mapped.entries()) { target.onEnd(event, interaction) } this.finishInteraction(event, extracted) } /** * * * @static * @param {string|array} types - An event type, an array of event types or event types seperated by a space sign. The following * events are possible: * pan, panstart, panmove, panend, pancancel, panleft, panright, panup, pandown * pinch, pinchstart, pinchmove, pinchend, pinchcancel, pinchin, pinchout * press, pressup * rotate, rotatestart, rotatemove, rotateend, rotatecancel * swipe, swipeleft, swiperight, swipeup, swipedown * tap * @param {HTMLElement|HTMLElement[]} elements - An HTML element or an array of HTML elements. * @param {function} [cb] - The callback. A function which is executed after the event occurs. Receives the event object as the * first paramter * @param {object} [opts] - An options object. See the hammer documentation for more details. */ static on(types, elements, cb, opts = {}) { opts = Object.assign({}, {}, opts) if (typeof Hammer === 'undefined') { console.error('Hammer.js not found!') return this } // convert to array types = Array.isArray(types) ? types : types.split(/\s/) if ( elements instanceof NodeList || elements instanceof HTMLCollection ) { elements = Array.from(elements) } elements = Array.isArray(elements) ? elements : [elements] for (let i = 0; i < types.length; i++) { const type = types[i].toLowerCase() // list of hammer events const useHammer = /^(tap|doubletap|press|pan|swipe|pinch|rotate).*$/.test( type ) // if it is a hammer event if (useHammer) { for (let j = 0; j < elements.length; j++) { // if(elements[j].tagName == "svg") return false; let hammer = new Hammer(elements[j], opts) if (window.propagating !== 'undefined') { hammer = propagating(hammer) } // recognizers if (type.startsWith('pan')) { hammer .get('pan') .set( Object.assign( { direction: Hammer.DIRECTION_ALL }, opts ) ) } else if (type.startsWith('pinch')) { hammer .get('pinch') .set(Object.assign({ enable: true }, opts)) } else if (type.startsWith('press')) { hammer.get('press').set(opts) } else if (type.startsWith('rotate')) { hammer .get('rotate') .set(Object.assign({ enable: true }, opts)) } else if (type.startsWith('swipe')) { hammer .get('swipe') .set( Object.assign( { direction: Hammer.DIRECTION_ALL }, opts ) ) } else if (type.startsWith('tap')) { hammer.get('tap').set(opts) } hammer.on(type, event => { cb(event) }) } } else { for (let j = 0; j < elements.length; j++) { Hammer.on(elements[j], type, event => { cb(event) }) } } } return this } } window.InteractionMapper = InteractionMapper