Files
polymer/src/standard/gestures.html
Daniel Freedman acdfc1e097 Test with ESLint enabled
Fix linter errors
2016-02-08 14:04:17 -08:00

727 lines
21 KiB
HTML

<!--
@license
Copyright (c) 2014 The Polymer Project Authors. All rights reserved.
This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt
The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt
The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt
Code distributed by Google as part of the polymer project is also
subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt
-->
<script>
(function() {
'use strict';
var wrap = Polymer.DomApi.wrap;
// detect native touch action support
var HAS_NATIVE_TA = typeof document.head.style.touchAction === 'string';
var GESTURE_KEY = '__polymerGestures';
var HANDLED_OBJ = '__polymerGesturesHandled';
var TOUCH_ACTION = '__polymerGesturesTouchAction';
// radius for tap and track
var TAP_DISTANCE = 25;
var TRACK_DISTANCE = 5;
// number of last N track positions to keep
var TRACK_LENGTH = 2;
// Disabling "mouse" handlers for 2500ms is enough
var MOUSE_TIMEOUT = 2500;
var MOUSE_EVENTS = ['mousedown', 'mousemove', 'mouseup', 'click'];
// an array of bitmask values for mapping MouseEvent.which to MouseEvent.buttons
var MOUSE_WHICH_TO_BUTTONS = [0, 1, 4, 2];
var MOUSE_HAS_BUTTONS = (function() {
try {
return new MouseEvent('test', {buttons: 1}).buttons === 1;
} catch (e) {
return false;
}
})();
// Check for touch-only devices
var IS_TOUCH_ONLY = navigator.userAgent.match(/iP(?:[oa]d|hone)|Android/);
// touch will make synthetic mouse events
// `preventDefault` on touchend will cancel them,
// but this breaks `<input>` focus and link clicks
// disable mouse handlers for MOUSE_TIMEOUT ms after
// a touchend to ignore synthetic mouse events
var mouseCanceller = function(mouseEvent) {
// skip synthetic mouse events
mouseEvent[HANDLED_OBJ] = {skip: true};
// disable "ghost clicks"
if (mouseEvent.type === 'click') {
var path = Polymer.dom(mouseEvent).path;
for (var i = 0; i < path.length; i++) {
if (path[i] === POINTERSTATE.mouse.target) {
return;
}
}
mouseEvent.preventDefault();
mouseEvent.stopPropagation();
}
};
function setupTeardownMouseCanceller(setup) {
for (var i = 0, en; i < MOUSE_EVENTS.length; i++) {
en = MOUSE_EVENTS[i];
if (setup) {
document.addEventListener(en, mouseCanceller, true);
} else {
document.removeEventListener(en, mouseCanceller, true);
}
}
}
function ignoreMouse() {
if (IS_TOUCH_ONLY) {
return;
}
if (!POINTERSTATE.mouse.mouseIgnoreJob) {
setupTeardownMouseCanceller(true);
}
var unset = function() {
setupTeardownMouseCanceller();
POINTERSTATE.mouse.target = null;
POINTERSTATE.mouse.mouseIgnoreJob = null;
};
POINTERSTATE.mouse.mouseIgnoreJob =
Polymer.Debounce(POINTERSTATE.mouse.mouseIgnoreJob, unset, MOUSE_TIMEOUT);
}
function hasLeftMouseButton(ev) {
var type = ev.type;
// exit early if the event is not a mouse event
if (MOUSE_EVENTS.indexOf(type) === -1) {
return false;
}
// ev.button is not reliable for mousemove (0 is overloaded as both left button and no buttons)
// instead we use ev.buttons (bitmask of buttons) or fall back to ev.which (deprecated, 0 for no buttons, 1 for left button)
if (type === 'mousemove') {
// allow undefined for testing events
var buttons = ev.buttons === undefined ? 1 : ev.buttons;
if ((ev instanceof window.MouseEvent) && !MOUSE_HAS_BUTTONS) {
buttons = MOUSE_WHICH_TO_BUTTONS[ev.which] || 0;
}
// buttons is a bitmask, check that the left button bit is set (1)
return Boolean(buttons & 1);
} else {
// allow undefined for testing events
var button = ev.button === undefined ? 0 : ev.button;
// ev.button is 0 in mousedown/mouseup/click for left button activation
return button === 0;
}
}
function isSyntheticClick(ev) {
if (ev.type === 'click') {
// ev.detail is 0 for HTMLElement.click in most browsers
if (ev.detail === 0) {
return true;
}
// in the worst case, check that the x/y position of the click is within
// the bounding box of the target of the event
// Thanks IE 10 >:(
var t = Gestures.findOriginalTarget(ev);
var bcr = t.getBoundingClientRect();
// use page x/y to account for scrolling
var x = ev.pageX, y = ev.pageY;
// ev is a synthetic click if the position is outside the bounding box of the target
return !((x >= bcr.left && x <= bcr.right) && (y >= bcr.top && y <= bcr.bottom));
}
return false;
}
var POINTERSTATE = {
mouse: {
target: null,
mouseIgnoreJob: null
},
touch: {
x: 0,
y: 0,
id: -1,
scrollDecided: false
}
};
function firstTouchAction(ev) {
var path = Polymer.dom(ev).path;
var ta = 'auto';
for (var i = 0, n; i < path.length; i++) {
n = path[i];
if (n[TOUCH_ACTION]) {
ta = n[TOUCH_ACTION];
break;
}
}
return ta;
}
function trackDocument(stateObj, movefn, upfn) {
stateObj.movefn = movefn;
stateObj.upfn = upfn;
document.addEventListener('mousemove', movefn);
document.addEventListener('mouseup', upfn);
}
function untrackDocument(stateObj) {
document.removeEventListener('mousemove', stateObj.movefn);
document.removeEventListener('mouseup', stateObj.upfn);
stateObj.movefn = null;
stateObj.upfn = null;
}
var Gestures = {
gestures: {},
recognizers: [],
deepTargetFind: function(x, y) {
var node = document.elementFromPoint(x, y);
var next = node;
// this code path is only taken when native ShadowDOM is used
// if there is a shadowroot, it may have a node at x/y
// if there is not a shadowroot, exit the loop
while (next && next.shadowRoot) {
// if there is a node at x/y in the shadowroot, look deeper
next = next.shadowRoot.elementFromPoint(x, y);
if (next) {
node = next;
}
}
return node;
},
// a cheaper check than Polymer.dom(ev).path[0];
findOriginalTarget: function(ev) {
// shadowdom
if (ev.path) {
return ev.path[0];
}
// shadydom
return ev.target;
},
handleNative: function(ev) {
var handled;
var type = ev.type;
var node = wrap(ev.currentTarget);
var gobj = node[GESTURE_KEY];
if (!gobj) {
return;
}
var gs = gobj[type];
if (!gs) {
return;
}
if (!ev[HANDLED_OBJ]) {
ev[HANDLED_OBJ] = {};
if (type.slice(0, 5) === 'touch') {
var t = ev.changedTouches[0];
if (type === 'touchstart') {
// only handle the first finger
if (ev.touches.length === 1) {
POINTERSTATE.touch.id = t.identifier;
}
}
if (POINTERSTATE.touch.id !== t.identifier) {
return;
}
if (!HAS_NATIVE_TA) {
if (type === 'touchstart' || type === 'touchmove') {
Gestures.handleTouchAction(ev);
}
}
if (type === 'touchend') {
POINTERSTATE.mouse.target = Polymer.dom(ev).rootTarget;
// ignore syntethic mouse events after a touch
ignoreMouse(true);
}
}
}
handled = ev[HANDLED_OBJ];
// used to ignore synthetic mouse events
if (handled.skip) {
return;
}
var recognizers = Gestures.recognizers;
// reset recognizer state
for (var i = 0, r; i < recognizers.length; i++) {
r = recognizers[i];
if (gs[r.name] && !handled[r.name]) {
if (r.flow && r.flow.start.indexOf(ev.type) > -1) {
if (r.reset) {
r.reset();
}
}
}
}
// enforce gesture recognizer order
for (i = 0, r; i < recognizers.length; i++) {
r = recognizers[i];
if (gs[r.name] && !handled[r.name]) {
handled[r.name] = true;
r[type](ev);
}
}
},
handleTouchAction: function(ev) {
var t = ev.changedTouches[0];
var type = ev.type;
if (type === 'touchstart') {
POINTERSTATE.touch.x = t.clientX;
POINTERSTATE.touch.y = t.clientY;
POINTERSTATE.touch.scrollDecided = false;
} else if (type === 'touchmove') {
if (POINTERSTATE.touch.scrollDecided) {
return;
}
POINTERSTATE.touch.scrollDecided = true;
var ta = firstTouchAction(ev);
var prevent = false;
var dx = Math.abs(POINTERSTATE.touch.x - t.clientX);
var dy = Math.abs(POINTERSTATE.touch.y - t.clientY);
if (!ev.cancelable) {
// scrolling is happening
} else if (ta === 'none') {
prevent = true;
} else if (ta === 'pan-x') {
prevent = dy > dx;
} else if (ta === 'pan-y') {
prevent = dx > dy;
}
if (prevent) {
ev.preventDefault();
} else {
Gestures.prevent('track');
}
}
},
// automate the event listeners for the native events
add: function(node, evType, handler) {
// SD polyfill: handle case where `node` is unwrapped, like `document`
node = wrap(node);
var recognizer = this.gestures[evType];
var deps = recognizer.deps;
var name = recognizer.name;
var gobj = node[GESTURE_KEY];
if (!gobj) {
node[GESTURE_KEY] = gobj = {};
}
for (var i = 0, dep, gd; i < deps.length; i++) {
dep = deps[i];
// don't add mouse handlers on iOS because they cause gray selection overlays
if (IS_TOUCH_ONLY && MOUSE_EVENTS.indexOf(dep) > -1) {
continue;
}
gd = gobj[dep];
if (!gd) {
gobj[dep] = gd = {_count: 0};
}
if (gd._count === 0) {
node.addEventListener(dep, this.handleNative);
}
gd[name] = (gd[name] || 0) + 1;
gd._count = (gd._count || 0) + 1;
}
node.addEventListener(evType, handler);
if (recognizer.touchAction) {
this.setTouchAction(node, recognizer.touchAction);
}
},
// automate event listener removal for native events
remove: function(node, evType, handler) {
// SD polyfill: handle case where `node` is unwrapped, like `document`
node = wrap(node);
var recognizer = this.gestures[evType];
var deps = recognizer.deps;
var name = recognizer.name;
var gobj = node[GESTURE_KEY];
if (gobj) {
for (var i = 0, dep, gd; i < deps.length; i++) {
dep = deps[i];
gd = gobj[dep];
if (gd && gd[name]) {
gd[name] = (gd[name] || 1) - 1;
gd._count = (gd._count || 1) - 1;
if (gd._count === 0) {
node.removeEventListener(dep, this.handleNative);
}
}
}
}
node.removeEventListener(evType, handler);
},
register: function(recog) {
this.recognizers.push(recog);
for (var i = 0; i < recog.emits.length; i++) {
this.gestures[recog.emits[i]] = recog;
}
},
findRecognizerByEvent: function(evName) {
for (var i = 0, r; i < this.recognizers.length; i++) {
r = this.recognizers[i];
for (var j = 0, n; j < r.emits.length; j++) {
n = r.emits[j];
if (n === evName) {
return r;
}
}
}
return null;
},
// set scrolling direction on node to check later on first move
// must call this before adding event listeners!
setTouchAction: function(node, value) {
if (HAS_NATIVE_TA) {
node.style.touchAction = value;
}
node[TOUCH_ACTION] = value;
},
fire: function(target, type, detail) {
var ev = Polymer.Base.fire(type, detail, {
node: target,
bubbles: true,
cancelable: true
});
// forward `preventDefault` in a clean way
if (ev.defaultPrevented) {
var se = detail.sourceEvent;
// sourceEvent may be a touch, which is not preventable this way
if (se && se.preventDefault) {
se.preventDefault();
}
}
},
prevent: function(evName) {
var recognizer = this.findRecognizerByEvent(evName);
if (recognizer.info) {
recognizer.info.prevent = true;
}
}
};
Gestures.register({
name: 'downup',
deps: ['mousedown', 'touchstart', 'touchend'],
flow: {
start: ['mousedown', 'touchstart'],
end: ['mouseup', 'touchend']
},
emits: ['down', 'up'],
info: {
movefn: null,
upfn: null
},
reset: function() {
untrackDocument(this.info);
},
mousedown: function(e) {
if (!hasLeftMouseButton(e)) {
return;
}
var t = Gestures.findOriginalTarget(e);
var self = this;
var movefn = function movefn(e) {
if (!hasLeftMouseButton(e)) {
self.fire('up', t, e);
untrackDocument(self.info);
}
};
var upfn = function upfn(e) {
if (hasLeftMouseButton(e)) {
self.fire('up', t, e);
}
untrackDocument(self.info);
};
trackDocument(this.info, movefn, upfn);
this.fire('down', t, e);
},
touchstart: function(e) {
this.fire('down', Gestures.findOriginalTarget(e), e.changedTouches[0]);
},
touchend: function(e) {
this.fire('up', Gestures.findOriginalTarget(e), e.changedTouches[0]);
},
fire: function(type, target, event) {
Gestures.fire(target, type, {
x: event.clientX,
y: event.clientY,
sourceEvent: event,
prevent: function(e) {
return Gestures.prevent(e);
}
});
}
});
Gestures.register({
name: 'track',
touchAction: 'none',
deps: ['mousedown', 'touchstart', 'touchmove', 'touchend'],
flow: {
start: ['mousedown', 'touchstart'],
end: ['mouseup', 'touchend']
},
emits: ['track'],
info: {
x: 0,
y: 0,
state: 'start',
started: false,
moves: [],
addMove: function(move) {
if (this.moves.length > TRACK_LENGTH) {
this.moves.shift();
}
this.moves.push(move);
},
movefn: null,
upfn: null,
prevent: false
},
reset: function() {
this.info.state = 'start';
this.info.started = false;
this.info.moves = [];
this.info.x = 0;
this.info.y = 0;
this.info.prevent = false;
untrackDocument(this.info);
},
hasMovedEnough: function(x, y) {
if (this.info.prevent) {
return false;
}
if (this.info.started) {
return true;
}
var dx = Math.abs(this.info.x - x);
var dy = Math.abs(this.info.y - y);
return (dx >= TRACK_DISTANCE || dy >= TRACK_DISTANCE);
},
mousedown: function(e) {
if (!hasLeftMouseButton(e)) {
return;
}
var t = Gestures.findOriginalTarget(e);
var self = this;
var movefn = function movefn(e) {
var x = e.clientX, y = e.clientY;
if (self.hasMovedEnough(x, y)) {
// first move is 'start', subsequent moves are 'move', mouseup is 'end'
self.info.state = self.info.started ? (e.type === 'mouseup' ? 'end' : 'track') : 'start';
self.info.addMove({x: x, y: y});
if (!hasLeftMouseButton(e)) {
// always fire "end"
self.info.state = 'end';
untrackDocument(self.info);
}
self.fire(t, e);
self.info.started = true;
}
};
var upfn = function upfn(e) {
if (self.info.started) {
Gestures.prevent('tap');
movefn(e);
}
// remove the temporary listeners
untrackDocument(self.info);
};
// add temporary document listeners as mouse retargets
trackDocument(this.info, movefn, upfn);
this.info.x = e.clientX;
this.info.y = e.clientY;
},
touchstart: function(e) {
var ct = e.changedTouches[0];
this.info.x = ct.clientX;
this.info.y = ct.clientY;
},
touchmove: function(e) {
var t = Gestures.findOriginalTarget(e);
var ct = e.changedTouches[0];
var x = ct.clientX, y = ct.clientY;
if (this.hasMovedEnough(x, y)) {
this.info.addMove({x: x, y: y});
this.fire(t, ct);
this.info.state = 'track';
this.info.started = true;
}
},
touchend: function(e) {
var t = Gestures.findOriginalTarget(e);
var ct = e.changedTouches[0];
// only trackend if track was started and not aborted
if (this.info.started) {
// iff tracking, always prevent tap
Gestures.prevent('tap');
// reset started state on up
this.info.state = 'end';
this.info.addMove({x: ct.clientX, y: ct.clientY});
this.fire(t, ct);
}
},
fire: function(target, touch) {
var secondlast = this.info.moves[this.info.moves.length - 2];
var lastmove = this.info.moves[this.info.moves.length - 1];
var dx = lastmove.x - this.info.x;
var dy = lastmove.y - this.info.y;
var ddx, ddy = 0;
if (secondlast) {
ddx = lastmove.x - secondlast.x;
ddy = lastmove.y - secondlast.y;
}
return Gestures.fire(target, 'track', {
state: this.info.state,
x: touch.clientX,
y: touch.clientY,
dx: dx,
dy: dy,
ddx: ddx,
ddy: ddy,
sourceEvent: touch,
hover: function() {
return Gestures.deepTargetFind(touch.clientX, touch.clientY);
}
});
}
});
Gestures.register({
name: 'tap',
deps: ['mousedown', 'click', 'touchstart', 'touchend'],
flow: {
start: ['mousedown', 'touchstart'],
end: ['click', 'touchend']
},
emits: ['tap'],
info: {
x: NaN,
y: NaN,
prevent: false
},
reset: function() {
this.info.x = NaN;
this.info.y = NaN;
this.info.prevent = false;
},
save: function(e) {
this.info.x = e.clientX;
this.info.y = e.clientY;
},
mousedown: function(e) {
if (hasLeftMouseButton(e)) {
this.save(e);
}
},
click: function(e) {
if (hasLeftMouseButton(e)) {
this.forward(e);
}
},
touchstart: function(e) {
this.save(e.changedTouches[0]);
},
touchend: function(e) {
this.forward(e.changedTouches[0]);
},
forward: function(e) {
var dx = Math.abs(e.clientX - this.info.x);
var dy = Math.abs(e.clientY - this.info.y);
var t = Gestures.findOriginalTarget(e);
// dx,dy can be NaN if `click` has been simulated and there was no `down` for `start`
if (isNaN(dx) || isNaN(dy) || (dx <= TAP_DISTANCE && dy <= TAP_DISTANCE) || isSyntheticClick(e)) {
// prevent taps from being generated if an event has canceled them
if (!this.info.prevent) {
Gestures.fire(t, 'tap', {
x: e.clientX,
y: e.clientY,
sourceEvent: e
});
}
}
}
});
var DIRECTION_MAP = {
x: 'pan-x',
y: 'pan-y',
none: 'none',
all: 'auto'
};
Polymer.Base._addFeature({
_setupGestures: function() {
this.__polymerGestures = null;
},
// override _listen to handle gestures
_listen: function(node, eventName, handler) {
if (Gestures.gestures[eventName]) {
Gestures.add(node, eventName, handler);
} else {
node.addEventListener(eventName, handler);
}
},
// override _unlisten to handle gestures
_unlisten: function(node, eventName, handler) {
if (Gestures.gestures[eventName]) {
Gestures.remove(node, eventName, handler);
} else {
node.removeEventListener(eventName, handler);
}
},
/**
* Override scrolling behavior to all direction, one direction, or none.
*
* Valid scroll directions:
* - 'all': scroll in any direction
* - 'x': scroll only in the 'x' direction
* - 'y': scroll only in the 'y' direction
* - 'none': disable scrolling for this node
*
* @method setScrollDirection
* @param {String=} direction Direction to allow scrolling
* Defaults to `all`.
* @param {HTMLElement=} node Element to apply scroll direction setting.
* Defaults to `this`.
*/
setScrollDirection: function(direction, node) {
node = node || this;
Gestures.setTouchAction(node, DIRECTION_MAP[direction] || 'auto');
}
});
// export
Polymer.Gestures = Gestures;
})();
</script>