/** * @private */Ext.define('Ext.event.publisher.Gesture', { extend: 'Ext.event.publisher.Dom', requires: [ 'Ext.util.Point', 'Ext.AnimationQueue' ], uses: 'Ext.event.gesture.*', type: 'gesture', config: { /** * @private * * By default the gesture publisher runs all handlers on requestAnimationFrame * timing for smooth performance of gestures and scrolling. Set this config * to false to disable requestAnimationFrame and run handlers immediately. * * Test environments may want to set this to false to ensure that gesture events * such as tap behave the same as dom events such as click in that they fire * immediately with no delay */ async: true }, isCancelEvent: { touchcancel: 1, pointercancel: 1, MSPointerCancel: 1 }, handledEvents: [], handledDomEvents: [], constructor: function(config) { var me = this, handledDomEvents = me.handledDomEvents, supports = Ext.supports, supportsTouchEvents = supports.TouchEvents, Fn = Ext.Function, onTouchStart = me.onTouchStart, onTouchMove = me.onTouchMove, onTouchEnd = me.onTouchEnd, // onTouchMove runs on requestAnimationFrame for performance reasons. // onTouchEnd must follow the same pattern, to avoid a scenario where touchend // could potentially be processed before the last touchmove // // Although it may seem unintuitive, onTouchStart must also run using // requestAnimationFrame timing. This is necessary mainly on muli-input devices // such as Windows 8 with Chrome (see https://sencha.jira.com/browse/EXTJS-14945) // on such browsers, if you click the mouse and then touch the screen in a // different location, the browser will simulate a "mousemove" event before // the touchstart event, as if you moved the mouse to the new location before // touching the screen. In this scenario we need to ensure that the simulated // mousemove happens BEFORE the touchstart event, or gesture recognizers can // get out of sync // // onTouchMove invocations are queued in such a way that the last invocation // wins if multiple invocations occur within a single animation frame // (this is the default behavior of createAnimationFrame) // // onTouchStart and onTouchEnd invocations are queued in FIFO order. This is // different from how onTouchMove behaves because when multiple "start" or // "end" events occur in quick succession, we need to handle them all so we // can sync the state of activeTouches and activeTouchesMap. asyncTouchStart = Fn.createAnimationFrame(me.onTouchStart, me, null, 1), asyncTouchMove = Fn.createAnimationFrame(me.onTouchMove, me), asyncTouchEnd = Fn.createAnimationFrame(me.onTouchEnd, me, null, 1); // set up handlers that do not use requestAnimationFrame for when the useAnimationFrame // config is set to false me._handlers = { touchstart: onTouchStart, touchmove: onTouchMove, touchend: onTouchEnd, touchcancel: onTouchEnd, pointerdown: onTouchStart, pointermove: onTouchMove, pointerup: onTouchEnd, pointercancel: onTouchEnd, MSPointerDown: onTouchStart, MSPointerMove: onTouchMove, MSPointerUp: onTouchEnd, MSPointerCancel: onTouchEnd, mousedown: onTouchStart, mousemove: onTouchMove, mouseup: onTouchEnd }; me._asyncHandlers = { touchstart: asyncTouchStart, touchmove: asyncTouchMove, touchend: asyncTouchEnd, touchcancel: asyncTouchEnd, pointerdown: asyncTouchStart, pointermove: asyncTouchMove, pointerup: asyncTouchEnd, pointercancel: asyncTouchEnd, MSPointerDown: asyncTouchStart, MSPointerMove: asyncTouchMove, MSPointerUp: asyncTouchEnd, MSPointerCancel: asyncTouchEnd, mousedown: asyncTouchStart, mousemove: asyncTouchMove, mouseup: asyncTouchEnd }; // A map that tracks names of the handledEvents of all registered recognizers me.activeTouchesMap = {}; me.activeTouches = []; me.changedTouches = []; me.recognizers = []; if (supportsTouchEvents) { // bind handlers that are only invoked when the browser has touchevents me.onTargetTouchMove = me.onTargetTouchMove.bind(me); me.onTargetTouchEnd = me.onTargetTouchEnd.bind(me); } if (supports.PointerEvents) { handledDomEvents.push('pointerdown', 'pointermove', 'pointerup', 'pointercancel'); me.mousePointerType = 'mouse'; } else if (supports.MSPointerEvents) { // IE10 uses vendor prefixed pointer events, IE11+ use unprefixed names. handledDomEvents.push('MSPointerDown', 'MSPointerMove', 'MSPointerUp', 'MSPointerCancel'); me.mousePointerType = 4; } else if (supportsTouchEvents) { handledDomEvents.push('touchstart', 'touchmove', 'touchend', 'touchcancel'); } if (!handledDomEvents.length || (supportsTouchEvents && Ext.os.is.Desktop)) { // If the browser doesn't have pointer events or touch events we use mouse events // to trigger gestures. The exception to this rule is touch enabled desktop // browsers such as chrome and firefox on Windows touch screen devices. These // browsers accept both touch and mouse input, so we need to listen for both // touch events and mouse events. handledDomEvents.push('mousedown', 'mousemove', 'mouseup'); } me.initConfig(config); return me.callParent(); }, onReady: function() { this.callParent(); Ext.Array.sort(this.recognizers, function(recognizerA, recognizerB) { var a = recognizerA.priority, b = recognizerB.priority; return (a > b) ? 1 : (a < b) ? -1 : 0; }); }, registerRecognizer: function(recognizer) { var me = this, handledEvents = recognizer.handledEvents, ln = handledEvents.length, i; // The recognizer will call our onRecognized method when it determines that a // gesture has occurred. recognizer.setOnRecognized(me.onRecognized); recognizer.setCallbackScope(me); // the gesture publishers handledEvents array is derived from the handledEvents // of all of its recognizers for (i = 0; i < ln; i++) { me.handledEvents.push(handledEvents[i]); } me.registerEvents(handledEvents); me.recognizers.push(recognizer); }, onRecognized: function(eventName, e, info) { var me = this, changedTouches = e.changedTouches, ln = changedTouches.length, targetGroups, targets, i, touch; info = info || {}; // At this point "e" still refers to the originally dispatched Ext.event.Event that // wraps a native browser event such as "touchend", or "mousemove". We need to // dispatch with an an event object that has the correct "recognized" type such // as "tap", or "drag". We don't want to change the type of the original event // object because it may be used asynchronously by event handlers, so we create a // new object that is chained to the original event. info.type = eventName; // Touch events have a handy feature - the original target of a touchstart is // always the target of successive touchmove/touchend events event if the touch // is dragged off of the original target. Pointer events also have this behavior // via the setPointerCapture method, unless their target is removed from the dom // mid-gesture, however, we do not currently use setPointerCapture because it // can change the target of translated mouse events. Mouse events do not have this // "capturing" feature at all - the target is always the element that was under // the mouse at the time the event occurred. To be safe, and to ensure consistency, // we just always set the target of recognized events to be the original target // that was cached when the first "start" event was received. info.target = changedTouches[0].target; // reset isStopped just in case the event that we are wrapping had // stoppedPropagation called info.isStopped = false; e = e.chain(info); if (ln > 1) { targetGroups = []; for (i = 0; i < ln; i++) { touch = changedTouches[i]; targetGroups.push(touch.targets); } targets = me.getCommonTargets(targetGroups); } else { targets = changedTouches[0].targets; } me.publish(eventName, targets, e); }, getCommonTargets: function(targetGroups) { var firstTargetGroup = targetGroups[0], ln = targetGroups.length; if (ln === 1) { return firstTargetGroup; } var commonTargets = [], i = 1, target, targets, j; while (true) { target = firstTargetGroup[firstTargetGroup.length - i]; if (!target) { return commonTargets; } for (j = 1; j < ln; j++) { targets = targetGroups[j]; if (targets[targets.length - i] !== target) { return commonTargets; } } commonTargets.unshift(target); i++; } return commonTargets; }, invokeRecognizers: function(methodName, e) { var recognizers = this.recognizers, ln = recognizers.length, i, recognizer; if (methodName === 'onStart') { for (i = 0; i < ln; i++) { recognizers[i].isActive = true; } } for (i = 0; i < ln; i++) { recognizer = recognizers[i]; if (recognizer.isActive && recognizer[methodName].call(recognizer, e) === false) { recognizer.isActive = false; } } }, updateTouches: function(e, isEnd) { var me = this, browserEvent = e.browserEvent, // the touchSource is the object from which we get data about the changed touch // point or points related to an event object, such as identifier, target, and // coordinates. For touch event the source is changedTouches, for mouse and // pointer events it is the event object itself. touchSources = browserEvent.changedTouches || [browserEvent], activeTouches = me.activeTouches, activeTouchesMap = me.activeTouchesMap, changedTouches = [], touchSource, identifier, touch, target, i, ln, x, y; for (i = 0, ln = touchSources.length; i < ln; i++) { touchSource = touchSources[i]; if ('identifier' in touchSource) { // touch events have an identifier property on their touches objects. // It can be 0, hence the "in" check identifier = touchSource.identifier; } else if ('pointerId' in touchSource) { // Pointer events have a pointerId on the event object itself identifier = touchSource.pointerId; } else { // Mouse events don't have an identifier, so we always use 1 since there // can only be one mouse touch point active at a time identifier = 1; } touch = activeTouchesMap[identifier]; if (!touch) { target = Ext.event.Event.resolveTextNode(touchSource.target); touch = activeTouchesMap[identifier] = { identifier: identifier, target: target, // There are 2 main advantages to caching the targets here, vs. // waiting until onRecognized // 1. for "move" events we don't have to construct the targets array // for every event - a theoretical performance win // 2. if the target is removed from the dom mid-gesture we still // want any gestures listeners on elements that were above the // target to complete. This means the propagating targets must reflect // the target element's initial hierarchy when the gesture began targets: me.getPropagatingTargets(target) }; activeTouches.push(touch); } if (isEnd) { delete activeTouchesMap[identifier]; Ext.Array.remove(activeTouches, touch); } x = touchSource.pageX; y = touchSource.pageY; touch.pageX = x; touch.pageY = y; // recognizers frequently use Point methods, so go ahead and create a Point. touch.point = new Ext.util.Point(x, y); changedTouches.push(touch); } // decorate the event object with the touch point info so that it can be used from // within gesture recognizers (clone touches, just in case event object is used // asynchronously since this.activeTouches is continuously modified) e.touches = Ext.Array.clone(activeTouches); // no need to clone changedTouches since we just created it from scratch e.changedTouches = changedTouches; }, doDelegatedEvent: function(e) { var me = this; // call parent method to dispatch the browser event (e.g. touchstart, mousemove) // before proceeding to the gesture recognition step. e = me.callParent([e, false]); // superclass method will return false if the event being handled is a // "emulated" event. This may include emulated mouse events on browsers that // support touch events, or "compatibility" mouse events on browsers that // support pointer events. If this is the case, do not proceed with gesture // recognition. if (e) { if (!e.button || e.button < 1) { // mouse gestures (and pointer gestures triggered by a mouse) can only be // initiated using the left button (0). button value < 0 is also acceptable // (e.g. pointermove has a button value of -1) me.handlers[e.type].call(me, e); } // wait until after handlers have been dispatched before calling afterEvent. // this ensures that timestamps captured in afterEvent represent the time // that event handling completed for this event. me.afterEvent(e); } }, onTouchStart: function(e) { var me = this, target = e.target, touches = e.browserEvent.touches; if (e.browserEvent.type === 'touchstart') { // When using touch events, if the target is removed from the dom mid-gesture // the touchend event cannot be handled normally because it will not bubble // to the top of the dom since the target el is no longer attached to the dom. // Add some special handlers to clean everything up. (see onTargetTouchEnd) // use addEventListener directly so that we don't have to spin up an instance // of Ext.Element for every event target. target.addEventListener('touchmove', me.onTargetTouchMove); target.addEventListener('touchend', me.onTargetTouchEnd); target.addEventListener('touchcancel', me.onTargetTouchEnd); } // There is a bug in IOS8 where touchstart, but not touchend event is // fired when clicking on controls for audio/video, which can leave // us in a bad state here. if (touches && touches.length <= me.activeTouches.length) { me.removeGhostTouches(touches); } me.updateTouches(e); if (!me.isStarted) { // this is the first active touch - invoke "onStart" which indicates the // beginning of a gesture me.isStarted = true; me.invokeRecognizers('onStart', e); // Disable garbage collection during gestures so that if the target element // of a gesture is removed from the dom, it does not get garbage collected // until the gesture is complete if (Ext.enableGarbageCollector) { Ext.dom.GarbageCollector.pause(); } } me.invokeRecognizers('onTouchStart', e); }, onTouchMove: function(e) { var me = this, mousePointerType = me.mousePointerType; if (me.isStarted) { // In IE10/11, the corresponding pointerup event is not fired after the pointerdown after // the mouse is released from the scrollbar. However, it does fire a pointermove event with buttons: 0, so // we capture that here and ensure the touch end process is completed. if (mousePointerType && e.browserEvent.pointerType === mousePointerType && e.buttons === 0) { e.type = Ext.dom.Element.prototype.eventMap.touchend; e.button = 0; me.onTouchEnd(e); return; } me.updateTouches(e); if (e.changedTouches.length > 0) { me.invokeRecognizers('onTouchMove', e); } } }, // This method serves as the handler for both "end" and "cancel" events. This is // because they are handled identically with the exception of the recognizer method // that is called. onTouchEnd: function(e) { var me = this; if (!me.isStarted) { return; } me.updateTouches(e, true); // If an exception is thrown in any of the recognizers, we still need to run // the cleanup. Otherwise the gesture might get "stuck" and *every* pointer event // after that will fire the same handlers over and over, potentially spewing // the same exceptions endlessly. See https://sencha.jira.com/browse/EXTJS-15674. // We don't want to mask the original exception though, let it propagate. try { me.invokeRecognizers(me.isCancelEvent[e.type] ? 'onTouchCancel' : 'onTouchEnd', e); } finally { if (!me.activeTouches.length) { // no more active touches - invoke onEnd to indicate the end of the gesture me.isStarted = false; me.invokeRecognizers('onEnd', e); // Gesture is finished, safe to resume garbage collection so that any target // elements destroyed while gesture was in progress can be collected if (Ext.enableGarbageCollector) { Ext.dom.GarbageCollector.resume(); } } } }, onTargetTouchMove: function(e) { if (Ext.elevateFunction) { // using [e] is faster than using arguments in most browsers // http://jsperf.com/passing-arguments Ext.elevateFunction(this.doTargetTouchMove, this, [e]); } else { this.doTargetTouchMove(e); } }, doTargetTouchMove: function(e) { // handle touchmove if the target el was removed from dom mid-gesture. // see onTouchStart/onTargetTouchEnd for further explanation if (!Ext.getBody().contains(e.target)) { this.onTouchMove(new Ext.event.Event(e)); } }, onTargetTouchEnd: function(e) { if (Ext.elevateFunction) { // using [e] is faster than using arguments in most browsers // http://jsperf.com/passing-arguments Ext.elevateFunction(this.doTargetTouchEnd, this, [e]); } else { this.doTargetTouchEnd(e); } }, doTargetTouchEnd: function(e) { var me = this, target = e.target; target.removeEventListener('touchmove', me.onTargetTouchMove); target.removeEventListener('touchend', me.onTargetTouchEnd); target.removeEventListener('touchcancel', me.onTargetTouchEnd); // if the target el was removed from the dom mid-gesture, then the touchend event, // when it occurs, will not be handled because it will not bubble to the top of // the dom. This is because the "target" of the touchend is the removed element. // If this is the case, go ahead and trigger touchend handling now. // Detect whether the target was removed from the DOM mid gesture by using Element.contains. // Originally we attempted to detect this by listening for the DOMNodeRemovedFromDocument // event, and setting a flag on the element when it was removed, however that // approach only works when the element is removed using removedChild, and fails // if the element is removed because some ancestor had innerHTML assigned. // note: this handling is applicable for actual touchend events, pointer and mouse // events will fire on whatever element is under the cursor/pointer after the // original target has been removed. if (!Ext.getBody().contains(target)) { me.onTouchEnd(new Ext.event.Event(e)); } }, updateAsync: function(async) { this.handlers = async ? this._asyncHandlers : this._handlers; }, /** * Resets the internal state of the Gesture publisher and all of its recognizers. * Applications will not typically need to use this method, but it is useful for * Unit-testing situations where a clean slate is required for each test. * * Calling this method will also reset the state of Ext.event.publisher.Dom */ reset: function() { var me = this, recognizers = me.recognizers, ln = recognizers.length, i, recognizer; me.activeTouchesMap = {}; me.activeTouches = []; me.changedTouches = []; me.isStarted = false; for (i = 0; i < ln; i++) { recognizer = recognizers[i]; recognizer.reset(); recognizer.isActive = false; } this.callParent(); }, privates: { removeGhostTouches: function(touches) { var ids = {}, len = touches.length, activeTouches = this.activeTouches, map = this.activeTouchesMap, i, id, touch; // Collect the actual touches for (i = 0; i < len; ++i) { ids[touches[i].identifier] = true; } i = activeTouches.length; while (i--) { touch = activeTouches[i]; id = touch.identifier; if (!touches[id]) { Ext.Array.remove(activeTouches, touch); delete map[id]; } } } }}, function(Gesture) { Gesture.instance = new Gesture();});