// ---- Angular Hammer ----
// Copyright (c) 2015 Ryan S Mullins <ryan@ryanmullins.org>
// Licensed under the MIT Software License
//
// (fairly heavy) modifications by James Wilson <me@unbui.lt>
//
(function (window, angular, Hammer) {
'use strict';
// Checking to make sure Hammer and Angular are defined
if (typeof angular === 'undefined') {
throw Error("angular-hammer: AngularJS (window.angular) is undefined but is necessary.");
}
if (typeof Hammer === 'undefined') {
throw Error("angular-hammer: HammerJS (window.Hammer) is undefined but is necessary.");
}
/**
* Mapping of the gesture event names with the Angular attribute directive
* names. Follows the form: <directiveName>:<eventName>.
*
* @type {Array}
*/
var gestureTypes = [
'hmCustom:custom',
'hmSwipe:swipe',
'hmSwipeleft:swipeleft',
'hmSwiperight:swiperight',
'hmSwipeup:swipeup',
'hmSwipedown:swipedown',
'hmPan:pan',
'hmPanstart:panstart',
'hmPanmove:panmove',
'hmPanend:panend',
'hmPancancel:pancancel',
'hmPanleft:panleft',
'hmPanright:panright',
'hmPanup:panup',
'hmPandown:pandown',
'hmPress:press',
'hmPressup:pressup',
'hmRotate:rotate',
'hmRotatestart:rotatestart',
'hmRotatemove:rotatemove',
'hmRotateend:rotateend',
'hmRotatecancel:rotatecancel',
'hmPinch:pinch',
'hmPinchstart:pinchstart',
'hmPinchmove:pinchmove',
'hmPinchend:pinchend',
'hmPinchcancel:pinchcancel',
'hmPinchin:pinchin',
'hmPinchout:pinchout',
'hmTap:tap',
'hmDoubletap:doubletap'
];
// ---- Module Definition ----
/**
* @module hmTouchEvents
* @description Angular.js module for adding Hammer.js event listeners to HTML
* elements using attribute directives
* @requires angular
* @requires hammer
*/
var NAME = 'hmTouchEvents';
var hmTouchEvents = angular.module('hmTouchEvents', []);
/**
* Provides a common interface for configuring global manager and recognizer
* options. Allows things like tap duration etc to be defaulted globally and
* overridden on a per-directive basis as needed.
*
* @return {Object} functions to add manager and recognizer options.
*/
hmTouchEvents.provider(NAME, function(){
var self = this;
var defaultRecognizerOpts = false;
var recognizerOptsHash = {};
var managerOpts = {};
//
// In order to use the Hamme rpresets provided, we need
// to map the recognizer fn to some name:
//
var recognizerFnToName = {};
recognizerFnToName[ Hammer.Tap.toString() ] = "tap";
recognizerFnToName[ Hammer.Pan.toString() ] = "pan";
recognizerFnToName[ Hammer.Pinch.toString() ] = "pinch";
recognizerFnToName[ Hammer.Press.toString() ] = "press";
recognizerFnToName[ Hammer.Rotate.toString() ] = "rotate";
recognizerFnToName[ Hammer.Swipe.toString() ] = "swipe";
//
// normalize opts, setting its name as it should be keyed by
// and any must-have options. currently only doubletap is treated
// specially. each _name leads to a new recognizer.
//
function normalizeRecognizerOptions(opts){
opts = angular.copy(opts);
if(opts.event){
if(opts.event == "doubletap"){
opts.type = "tap";
if(!opts.taps) opts.taps = 2;
opts._name = "doubletap";
} else {
opts._name = false;
}
} else {
opts._name = opts.type || false;
}
return opts;
}
//
// create default opts for some eventName.
// again, treat doubletap specially.
//
function defaultOptionsForEvent(eventName){
if(eventName == "custom"){
throw Error(NAME+"Provider: no defaults exist for custom events");
}
var ty = getRecognizerTypeFromeventName(eventName);
return normalizeRecognizerOptions(
eventName == "doubletap"
? {type:ty, event:"doubletap"}
: {type:ty}
);
}
//
// Make use of presets from Hammer.defaults.preset array
// in angular-hammer events.
//
self.applyHammerPresets = function(){
var hammerPresets = Hammer.defaults.preset;
//add every preset that, when normalized, has a _name.
//this precludes most custom events.
angular.forEach(hammerPresets, function(presetArr){
var data = presetArr[1];
if(!data.type) data.type = recognizerFnToName[presetArr[0]];
data = normalizeRecognizerOptions(data);
if(!data._name) return;
recognizerOptsHash[data._name] = data;
});
}
//
// Add a manager option (key/val to extend or object to set all):
//
self.addManagerOption = function(name, val){
if(typeof name == "object"){
angular.extend(managerOpts, name);
}
else {
managerOpts[name] = val;
}
}
//
// Add a recognizer option:
//
self.addRecognizerOption = function(val){
if(Array.isArray(val)){
for(var i = 0; i < val.length; i++) self.addRecognizerOption(val[i]);
return;
}
if(typeof val !== "object"){
throw Error(NAME+"Provider: addRecognizerOption: should be object or array of objects");
}
val = normalizeRecognizerOptions(val);
//hash by name if present, else if no event name,
//set as defaults.
if(val._name){
recognizerOptsHash[val.type] = val;
} else if(!val.event){
defaultRecognizerOpts = val;
}
}
//provide an interface to this that the hm-* directives use
//to extend their recognizer/manager opts.
self.$get = function(){
return {
extendWithDefaultManagerOpts: function(opts){
if(typeof opts != "object"){
opts = {};
} else {
opts = angular.copy(opts);
}
for(var name in managerOpts) {
if(!opts[name]) opts[name] = angular.copy(managerOpts[name]);
}
return opts;
},
extendWithDefaultRecognizerOpts: function(eventName, opts){
if(typeof opts !== "object"){
opts = [];
}
if(!Array.isArray(opts)){
opts = [opts];
}
//dont apply anything if this is custom event
//(beyond normalizing opts to an array):
if(eventName == "custom") return opts;
var recognizerType = getRecognizerTypeFromeventName(eventName);
var specificOpts = recognizerOptsHash[eventName] || recognizerOptsHash[recognizerType];
//get the last opt provided that matches the type or eventName
//that we have. normalizing removes any eventnames we dont care about
//(everything but doubletap at the moment).
var foundOpt;
var isExactMatch = false;
var defaults = angular.extend({}, defaultRecognizerOpts || {}, specificOpts || {});
opts.forEach(function(opt){
if(!opt.event && !opt.type){
return angular.extend(defaults, opt);
}
if(isExactMatch){
return;
}
//more specific wins over less specific.
if(opt.event == eventName){
foundOpt = opt;
isExactMatch = true;
} else if(!opt.event && opt.type == recognizerType){
foundOpt = opt;
}
});
if(!foundOpt) foundOpt = defaultOptionsForEvent(eventName);
else foundOpt = normalizeRecognizerOptions(foundOpt);
return [angular.extend(defaults, foundOpt)];
}
};
};
});
/**
* Iterates through each gesture type mapping and creates a directive for
* each of the
*
* @param {String} type Mapping in the form of <directiveName>:<eventName>
* @return None
*/
angular.forEach(gestureTypes, function (type) {
var directive = type.split(':'),
directiveName = directive[0],
eventName = directive[1];
hmTouchEvents.directive(directiveName, ['$parse', '$window', NAME, function ($parse, $window, defaultEvents) {
return {
restrict: 'A',
scope: false,
link: function (scope, element, attrs) {
// Check for Hammer and required functionality.
// error if they arent found as unexpected behaviour otherwise
if (!Hammer || !$window.addEventListener) {
throw Error(NAME+": window.Hammer or window.addEventListener not found, can't add event "+directiveName);
}
var hammer = element.data('hammer'),
managerOpts = defaultEvents.extendWithDefaultManagerOpts( scope.$eval(attrs.hmManagerOptions) ),
recognizerOpts = defaultEvents.extendWithDefaultRecognizerOpts( eventName, scope.$eval(attrs.hmRecognizerOptions) );
// Check for a manager, make one if needed and destroy it when
// the scope is destroyed
if (!hammer) {
hammer = new Hammer.Manager(element[0], managerOpts);
element.data('hammer', hammer);
scope.$on('$destroy', function () {
hammer.destroy();
});
}
// Obtain and wrap our handler function to do a couple of bits for
// us if options provided.
var handlerExpr = $parse(attrs[directiveName]).bind(null,scope);
var handler = function (event) {
event.element = element;
var recognizer = hammer.get(event.type);
if (recognizer) {
if (recognizer.options.preventDefault) {
event.preventDefault();
}
if (recognizer.options.stopPropagation) {
event.srcEvent.stopPropagation();
}
}
scope.$apply(function(){
handlerExpr({ '$event': event });
});
};
// The recognizer options are normalized to an array. This array
// contains whatever events we wish to add (our prior extending
// takes care of that), but we do a couple of specific things
// depending on this directive so that events play nice together.
angular.forEach(recognizerOpts, function (options) {
if(eventName !== 'custom'){
if (eventName === 'doubletap' && hammer.get('tap')) {
options.recognizeWith = 'tap';
}
else if (options.type == "pan" && hammer.get('swipe')) {
options.recognizeWith = 'swipe';
}
else if (options.type == "pinch" && hammer.get('rotate')) {
options.recognizeWith = 'rotate';
}
}
//add the recognizer with these options:
setupRecognizerWithOptions(
hammer,
applyManagerOptions(managerOpts, options),
element
);
//if custom there may be multiple events to apply, which
//we do here. else, we'll only ever add one.
hammer.on(eventName, handler);
});
}
};
}]);
});
// ---- Private Functions -----
/**
* Adds a gesture recognizer to a given manager. The type of recognizer to
* add is determined by the value of the options.type property.
*
* @param {Object} manager Hammer.js manager object assigned to an element
* @param {String} type Options that define the recognizer to add
* @return {Object} Reference to the new gesture recognizer, if
* successful, null otherwise.
*/
function addRecognizer (manager, name) {
if (manager === undefined || name === undefined) { return null; }
var recognizer;
if (name.indexOf('pan') > -1) {
recognizer = new Hammer.Pan();
} else if (name.indexOf('pinch') > -1) {
recognizer = new Hammer.Pinch();
} else if (name.indexOf('press') > -1) {
recognizer = new Hammer.Press();
} else if (name.indexOf('rotate') > -1) {
recognizer = new Hammer.Rotate();
} else if (name.indexOf('swipe') > -1) {
recognizer = new Hammer.Swipe();
} else {
recognizer = new Hammer.Tap();
}
manager.add(recognizer);
return recognizer;
}
/**
* Applies certain manager options to individual recognizer options.
*
* @param {Object} managerOpts Manager options
* @param {Object} recognizerOpts Recognizer options
* @return None
*/
function applyManagerOptions (managerOpts, recognizerOpts) {
if (managerOpts) {
recognizerOpts.preventGhosts = managerOpts.preventGhosts;
}
return recognizerOpts;
}
/**
* Extracts the type of recognizer that should be instantiated from a given
* event name. Used only when no recognizer options are provided.
*
* @param {String} eventName Name to derive the recognizer type from
* @return {string} Type of recognizer that fires events with that name
*/
function getRecognizerTypeFromeventName (eventName) {
if (eventName.indexOf('pan') > -1) {
return 'pan';
} else if (eventName.indexOf('pinch') > -1) {
return 'pinch';
} else if (eventName.indexOf('press') > -1) {
return 'press';
} else if (eventName.indexOf('rotate') > -1) {
return 'rotate';
} else if (eventName.indexOf('swipe') > -1) {
return 'swipe';
} else if (eventName.indexOf('tap') > -1) {
return 'tap';
} else {
return "custom";
}
}
/**
* Applies the passed options object to the appropriate gesture recognizer.
* Recognizers are created if they do not already exist. See the README for a
* description of the options object that can be passed to this function.
*
* @param {Object} manager Hammer.js manager object assigned to an element
* @param {Object} options Options applied to a recognizer managed by manager
* @return None
*/
function setupRecognizerWithOptions (manager, options, element) {
if (manager == null || options == null || options.type == null) {
return console.error('ERROR: Angular Hammer could not setup the' +
' recognizer. Values of the passed manager and options: ', manager, options);
}
var recognizer = manager.get(options._name);
if (!recognizer) {
recognizer = addRecognizer(manager, options._name);
}
if (!options.directions) {
if (options._name === 'pan' || options._name === 'swipe') {
options.directions = 'DIRECTION_ALL';
} else if (options._name.indexOf('left') > -1) {
options.directions = 'DIRECTION_LEFT';
} else if (options._name.indexOf('right') > -1) {
options.directions = 'DIRECTION_RIGHT';
} else if (options._name.indexOf('up') > -1) {
options.directions = 'DIRECTION_UP';
} else if (options._name.indexOf('down') > -1) {
options.directions = 'DIRECTION_DOWN';
} else {
options.directions = '';
}
}
options.direction = parseDirections(options.directions);
recognizer.set(options);
if (typeof options.recognizeWith === 'string') {
var recognizeWithRecognizer;
if (manager.get(options.recognizeWith) == null){
recognizeWithRecognizer = addRecognizer(manager, options.recognizeWith);
}
if (recognizeWithRecognizer != null) {
recognizer.recognizeWith(recognizeWithRecognizer);
}
}
if (typeof options.dropRecognizeWith === 'string' &&
manager.get(options.dropRecognizeWith) != null) {
recognizer.dropRecognizeWith(manager.get(options.dropRecognizeWith));
}
if (typeof options.requireFailure === 'string') {
var requireFailureRecognizer;
if (manager.get(options.requireFailure) == null){
requireFailureRecognizer = addRecognizer(manager, {type:options.requireFailure});
}
if (requireFailureRecognizer != null) {
recognizer.requireFailure(requireFailureRecognizer);
}
}
if (typeof options.dropRequireFailure === 'string' &&
manager.get(options.dropRequireFailure) != null) {
recognizer.dropRequireFailure(manager.get(options.dropRequireFailure));
}
if (options.preventGhosts === true && element != null) {
preventGhosts(element);
}
}
/**
* Parses the value of the directions property of any Angular Hammer options
* object and converts them into the standard Hammer.js directions values.
*
* @param {String} dirs Direction names separated by '|' characters
* @return {Number} Hammer.js direction value
*/
function parseDirections (dirs) {
var directions = 0;
angular.forEach(dirs.split('|'), function (direction) {
if (Hammer.hasOwnProperty(direction)) {
directions = directions | Hammer[direction];
}
});
return directions;
}
// ---- Preventing Ghost Clicks ----
/**
* Modified from: https://gist.github.com/jtangelder/361052976f044200ea17
*
* Prevent click events after a touchend.
*
* Inspired/copy-paste from this article of Google by Ryan Fioravanti
* https://developers.google.com/mobile/articles/fast_buttons#ghost
*/
function preventGhosts (element) {
if (!element) { return; }
var coordinates = [],
threshold = 25,
timeout = 2500;
if ('ontouchstart' in window) {
element[0].addEventListener('touchstart', resetCoordinates, true);
element[0].addEventListener('touchend', registerCoordinates, true);
element[0].addEventListener('click', preventGhostClick, true);
element[0].addEventListener('mouseup', preventGhostClick, true);
}
/**
* prevent clicks if they're in a registered XY region
* @param {MouseEvent} ev
*/
function preventGhostClick (ev) {
for (var i = 0; i < coordinates.length; i++) {
var x = coordinates[i][0];
var y = coordinates[i][1];
// within the range, so prevent the click
if (Math.abs(ev.clientX - x) < threshold &&
Math.abs(ev.clientY - y) < threshold) {
ev.stopPropagation();
ev.preventDefault();
break;
}
}
}
/**
* reset the coordinates array
*/
function resetCoordinates () {
coordinates = [];
}
/**
* remove the first coordinates set from the array
*/
function popCoordinates () {
coordinates.splice(0, 1);
}
/**
* if it is an final touchend, we want to register it's place
* @param {TouchEvent} ev
*/
function registerCoordinates (ev) {
// touchend is triggered on every releasing finger
// changed touches always contain the removed touches on a touchend
// the touches object might contain these also at some browsers (firefox os)
// so touches - changedTouches will be 0 or lower, like -1, on the final touchend
if(ev.touches.length - ev.changedTouches.length <= 0) {
var touch = ev.changedTouches[0];
coordinates.push([touch.clientX, touch.clientY]);
setTimeout(popCoordinates, timeout);
}
}
}
})(window, window.angular, window.Hammer);