/**
* Processes the touch-action css property for an Ext.dom.Element, and provides
* compatible behavior on devices that do not support pointer events.
* @private
*/
Ext.define('Ext.dom.TouchAction', {
singleton: true,
requires: [
'Ext.dom.Element',
'Ext.util.Point'
],
lastTouchStartTime: 0,
/**
* @property
* The minimum distance a touch must move before being cancelled (only applicable
* on browsers that use touch events). Allows the direction of movement to be detected
* so that panX and panY can be separately cancelled.
* @private
*/
minMoveDistance: 8,
spaceRe: /\s+/,
preventSingle: null,
preventMulti: null,
disabledOverflowDom: null,
panXCls: Ext.baseCSSPrefix + 'touch-action-pan-x',
panYCls: Ext.baseCSSPrefix + 'touch-action-pan-y',
cssValues: [
'none',
'pan-x',
'pan-y',
'pan-x pan-y',
'pinch-zoom',
'pan-x pinch-zoom',
'pan-y pinch-zoom',
'pan-x pan-y pinch-zoom',
'double-tap-zoom',
'pan-x double-tap-zoom',
'pan-y double-tap-zoom',
'pan-x pan-y double-tap-zoom',
'pinch-zoom double-tap-zoom',
'pan-x pinch-zoom double-tap-zoom',
'pan-y pinch-zoom double-tap-zoom',
''
],
objectValues: [
{ panX: false, panY: false, pinchZoom: false, doubleTapZoom: false },
{ panX: true, panY: false, pinchZoom: false, doubleTapZoom: false },
{ panX: false, panY: true, pinchZoom: false, doubleTapZoom: false },
{ panX: true, panY: true, pinchZoom: false, doubleTapZoom: false },
{ panX: false, panY: false, pinchZoom: true, doubleTapZoom: false },
{ panX: true, panY: false, pinchZoom: true, doubleTapZoom: false },
{ panX: false, panY: true, pinchZoom: true, doubleTapZoom: false },
{ panX: true, panY: true, pinchZoom: true, doubleTapZoom: false },
{ panX: false, panY: false, pinchZoom: false, doubleTapZoom: true },
{ panX: true, panY: false, pinchZoom: false, doubleTapZoom: true },
{ panX: false, panY: true, pinchZoom: false, doubleTapZoom: true },
{ panX: true, panY: true, pinchZoom: false, doubleTapZoom: true },
{ panX: false, panY: false, pinchZoom: true, doubleTapZoom: true },
{ panX: true, panY: false, pinchZoom: true, doubleTapZoom: true },
{ panX: false, panY: true, pinchZoom: true, doubleTapZoom: true },
{ panX: true, panY: true, pinchZoom: true, doubleTapZoom: true }
],
attributeName: 'data-extTouchAction',
constructor: function() {
var me = this,
supports = Ext.supports;
if (supports.TouchAction) {
me.cssProp = 'touch-action';
}
else if (supports.MSPointerEvents) {
me.cssProp = '-ms-touch-action';
}
if (supports.TouchEvents) {
Ext.getWin().on({
touchstart: 'onTouchStart',
touchmove: 'onTouchMove',
touchend: 'onTouchEnd',
scope: me,
delegated: false,
translate: false,
capture: true,
priority: 5000
});
Ext.on({
scroll: 'onScroll',
scope: me,
destroyable: true
});
}
//<debug>
if (Ext.isFunction(Object.freeze)) {
/* eslint-disable-next-line vars-on-top, one-var */
var objectValues = me.objectValues,
i, ln;
for (i = 0, ln = objectValues.length; i < ln; i++) {
Object.freeze(objectValues[i]);
}
}
//</debug>
},
/**
* Returns true if all of the event's targets are contained within the element
* @param {HTMLElement} dom
* @param {Ext.event.Event} e
* @private
* @return {Boolean}
*/
containsTargets: function(dom, e) {
var contains = true,
event = e.browserEvent,
touches = e.type === 'touchend' ? event.changedTouches : event.touches,
i, ln;
for (i = 0, ln = touches.length; i < ln; i++) {
if (!dom.contains(touches[i].target)) {
contains = false;
break;
}
}
return contains;
},
/**
* Forces overflow to 'hidden' on the x or y axis starting with the "el" and ascending
* upward to all ancestors that have overflow 'auto' or 'scroll' on the given axis.
* The added classes will remain in place until the end of the current gesture (when
* the final touchend event is received) at which point they will be removed by invoking
* {@link #resetOverflow}.
*
* This is invoked at the beginning of a gesture when we make the initial determination
* that we are disabling scrolling on one of the axes (because touch-action contains
* pan-x or pan-y in the value, but not both). Dynamically manipulating the overflow
* in this way vs just adding a static class ensures that the non-touch-scrolling axis
* can still be scrolled using the mouse.
*
* We only do this on browsers that do not have space-consuming scrollbars (e.g. on
* android, but not on chrome desktop) to avoid a situation where scrollbars disappear
* during the gesture and re-appear afterwards.
*
* We also skip this on iOS because of the following bugs in safari (already filed with apple):
* 1. Dynamically setting scroll position to hidden on either axis resets visual scroll
* position to 0:
* https://gist.github.com/pguerrant/105e8d91e3ffcb1b6e2eed7ecc0571d3
* 2. Scrolling an element that has overflow set to hidden on either axis causes scroll
* position to be reset to 0 on the hidden axis:
* https://gist.github.com/pguerrant/e959c47a6b1d4b841cc3267a61950f33
*
* The downside is that on iOS, and on desktop-touch hybrid browsers such as chrome once
* the user initiates scrolling in an allowed direction, it cannot be disabled in the
* disallowed direction, This trade-off seems better than the alternatives -
* vanishing/reappearing scrollbars on desktop, and scroll positions resetting to 0 on iOS.
*
* @param {HTMLElement} dom
* @param {Boolean} [vertical=false] `true` to disable scrolling on the y axis, `false`
* to disable scrolling on the x axis
*
* @private
*/
disableOverflow: function(dom, vertical) {
var me = this,
overflowName = vertical ? 'overflow-y' : 'overflow-x',
overflowStyle, cls;
if (!me.disabledOverflowDom && !Ext.isiOS && !Ext.scrollbar.width()) {
me.disabledOverflowDom = dom;
cls = vertical ? me.panXCls : me.panYCls;
while (dom) {
overflowStyle = Ext.fly(dom).getStyle(overflowName);
if (overflowStyle === 'auto' || overflowStyle === 'scroll') {
Ext.fly(dom).addCls(cls);
}
dom = dom.parentNode;
}
}
},
/**
* Returns the touch action for the passed HTMLElement
* @param {HTMLElement} dom
* @return {Object}
*/
get: function(dom) {
var flags = dom.getAttribute(this.attributeName),
ret = null;
if (flags != null) {
ret = this.objectValues[flags];
}
return ret;
},
/**
* Accepts a touch action in the object form accepted by
* {@link Ext.Component}, and converts it to a number representing the desired touch action(s).
*
* All touchActions absent from the passed object are defaulted to true.
*
* @param {Object} touchAction
* @returns {Number} A number representing the touch action using the following mapping:
*
* panX 1 "00000001"
* panY 2 "00000010"
* pinchZoom 4 "00000100"
* doubleTapZoom 8 "00001000"
*
* 0 represents a css value of "none" and all bits on is the same as "auto"
* @private
*/
getFlags: function(touchAction) {
var flags;
if (typeof touchAction === 'number') {
flags = touchAction;
}
else {
flags = 0;
if (touchAction.panX !== false) {
flags |= 1;
}
if (touchAction.panY !== false) {
flags |= 2;
}
if (touchAction.pinchZoom !== false) {
flags |= 4;
}
if (touchAction.doubleTapZoom !== false) {
flags |= 8;
}
}
return flags;
},
isScrollable: function(el, vertical, forward) {
var overflowStyle = Ext.fly(el).getStyle(vertical ? 'overflow-y' : 'overflow-x'),
isScrollable = (overflowStyle === 'auto' || overflowStyle === 'scroll');
if (isScrollable) {
if (vertical) {
isScrollable = forward
? (el.scrollTop + el.clientHeight) < el.scrollHeight
: el.scrollTop > 0;
}
else {
isScrollable = forward
? (el.scrollLeft + el.clientWidth) < el.scrollWidth
: el.scrollLeft > 0;
}
}
return isScrollable;
},
lookupFlags: function(dom) {
return parseInt((dom.getAttribute && dom.getAttribute(this.attributeName)) || 15, 10);
},
onScroll: function() {
// This flag tracks whether or not a scroll has occurred since the last touchstart event
this.scrollOccurred = true;
// once scrolling begins we cannot attempt to preventDefault on the touchend event
// or chrome will issue warnings in the console.
this.isDoubleTap = false;
},
onTouchEnd: function(e) {
var me = this,
dom = e.target,
touchCount, flags, doubleTapZoom;
touchCount = e.browserEvent.touches.length;
if (touchCount === 0) {
if (me.isDoubleTap) {
while (dom) {
flags = me.lookupFlags(dom);
if (flags != null) {
doubleTapZoom = flags & 8;
if (!doubleTapZoom) {
e.preventDefault();
}
}
dom = dom.parentNode;
}
}
me.isDoubleTap = false;
me.preventSingle = null;
me.preventMulti = null;
me.resetOverflow();
}
},
onTouchMove: function(e) {
var me = this,
prevent = null,
dom = e.target,
flags, touchCount, panX, panY, point, startPoint, isVertical,
scale, distance, deltaX, deltaY, preventSingle, preventMulti;
preventSingle = me.preventSingle;
preventMulti = me.preventMulti;
touchCount = e.browserEvent.touches.length;
// Don't check for touchCount here when checking for preventMulti.
// This ensures that if we determined not to cancel the multi-touch gesture
// previously we will not attempt to start canceling once touch count is
// reduced to one (If we do attempt to start canceling at that point chrome
// will issue warnings in the console because scrolling has already started).
if ((touchCount === 1 && (preventSingle === false)) || (preventMulti === false)) {
return;
}
if ((touchCount > 1 && (preventMulti === true)) ||
(touchCount === 1 && (preventSingle === true))) {
prevent = true;
}
else {
if (touchCount === 1) {
point = e.getPoint();
startPoint = me.startPoint;
scale = Ext.Element.getViewportScale();
// account for scale so that move distance is actual screen pixels, not page pixels
distance = point.getDistanceTo(me.startPoint) * scale;
deltaX = point.x - startPoint.x;
deltaY = point.y - startPoint.y;
isVertical = Math.abs(deltaY) >= Math.abs(deltaX);
}
while (dom && (dom.nodeType === 1)) {
flags = me.lookupFlags(dom);
if (flags & 0) { // touch-action: none
prevent = true;
}
else if (touchCount === 1) {
panX = !!(flags & 1);
panY = !!(flags & 2);
if (panX && panY) {
prevent = false;
}
else if (!panX && !panY) {
prevent = true;
}
else if (distance >= me.minMoveDistance) {
prevent = !!((panX && isVertical) || (panY && !isVertical));
}
// if the element itself is scrollable, and has no touch action
// preventing it from scrolling, allow it to scroll - do
// not allow an ancestor's touchAction to prevent scrolling
if (!prevent && me.isScrollable(dom, isVertical, (isVertical ? deltaY : deltaX) < 0)) { // eslint-disable-line max-len
break;
}
}
else if (me.containsTargets(dom, e)) { // multi-touch, all targets contained
prevent = !(flags & 4);
}
else { // multi-touch and not all targets contained within element
prevent = false;
}
if (prevent) {
break;
}
dom = dom.parentNode;
}
}
// In chrome preventing a touchmove event does not prevent the defualt
// action such as scrolling from taking place on subsequent touchmove
// events. Setting these flags tells us to prevent the touchmove event
// for the remainder of the gesture.
// explicitly setting these flags to false means do not prevent this gesture
// going forward. This prevents chrome from complaining because we
// called preventDefault() after scrolling has already started
if (touchCount === 1) {
me.preventSingle = prevent;
}
else if (touchCount > 1) {
me.preventMulti = prevent;
}
if (prevent) {
e.preventDefault();
}
},
onTouchStart: function(e) {
var me = this,
time, flags, dom, panX, panY;
if (e.browserEvent.touches.length === 1) {
time = e.time;
// Use a time of 500ms between touchstart events to detecting a double tap that
// might possibly cause the screen to zoom. Although in reality this is usually
// 300ms iOS can sometimes take a bit longer so 500 seems safe.
// Can't be a double tap if a scroll occurred in between this touch and the previous
// one.
if (!me.scrollOccurred && ((time - me.lastTouchStartTime) <= 500)) {
me.isDoubleTap = true;
}
me.lastTouchStartTime = time;
me.scrollOccurred = false;
me.startPoint = e.getPoint();
dom = e.target;
while (dom) {
flags = me.lookupFlags(dom);
if (flags != null) {
panX = !!(flags & 1);
panY = !!(flags & 2);
if (panX !== panY) {
me.disableOverflow(dom, panX);
break;
}
}
dom = dom.parentNode;
}
}
else {
// multi touch is never a double tap
me.isDoubleTap = false;
}
},
/**
* Removes any classes that were added using {@link #disableOverflow}
*/
resetOverflow: function() {
var me = this,
dom = me.disabledOverflowDom;
while (dom) {
Ext.fly(dom).removeCls([me.panXCls, me.panYCls]);
dom = dom.parentNode;
}
me.disabledOverflowDom = null;
},
/**
* Sets the touch action value for an element
* @param {HTMLElement} dom The dom element
* @param {Object/Number} value The touch action as an object with touch action names
* as keys and boolean values, or as a bit flag (see {@link #getFlags})
*
* For example the following two calls are equivalent:
*
* Ext.dom.TouchAction.set(domElement, {
* panX: false,
* pinchZoom: false
* });
*
* Ext.dom.TouchAction.set(domElement, 5);
*
* valid touch action names are:
*
* - `'panX'`
* - `'panY'`
* - `'pinchZoom'`
* - `'doubleTapZoom'`
*
* @private
*/
set: function(dom, value) {
var me = this,
cssProp = me.cssProp,
flags = me.getFlags(value),
// We can only set values for CSS touch-action in the dom if they are supported
// by the browser, otherwise the entire touch-action property is ignored.
supportedFlags = (flags & Ext.supports.TouchAction),
attributeName = me.attributeName;
if (cssProp) {
Ext.fly(dom).setStyle(cssProp, me.cssValues[supportedFlags]);
}
if (flags === 15) {
dom.removeAttribute(attributeName);
}
else {
dom.setAttribute(attributeName, flags);
}
}
});