4 | End2end Test Runner
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/scripts/test.bat:
--------------------------------------------------------------------------------
1 | @echo off
2 |
3 | REM Windows script for running unit tests
4 | REM You have to run server and capture some browser first
5 | REM
6 | REM Requirements:
7 | REM - NodeJS (http://nodejs.org/)
8 | REM - Karma (npm install -g karma)
9 |
10 | set BASE_DIR=%~dp0
11 | karma start "%BASE_DIR%\..\config\karma.conf.js" %*
12 |
--------------------------------------------------------------------------------
/scripts/e2e-test.bat:
--------------------------------------------------------------------------------
1 | @echo off
2 |
3 | REM Windows script for running e2e tests
4 | REM You have to run server and capture some browser first
5 | REM
6 | REM Requirements:
7 | REM - NodeJS (http://nodejs.org/)
8 | REM - Karma (npm install -g karma)
9 |
10 | set BASE_DIR=%~dp0
11 | karma start "%BASE_DIR%\..\config\karma-e2e.conf.js" %*
12 |
--------------------------------------------------------------------------------
/app/partials/recursive.html:
--------------------------------------------------------------------------------
1 |
2 |
10 |
488 |
489 |
490 | it('should linkify the snippet with urls', function() {
491 | expect(using('#linky-filter').binding('snippet | linky')).
492 | toBe('Pretty text with some links:
' +
493 | 'http://angularjs.org/,
' +
494 | 'us@somewhere.org,
' +
495 | 'another@somewhere.org,
' +
496 | 'and one more: ftp://127.0.0.1/.');
497 | });
498 |
499 | it ('should not linkify snippet without the linky filter', function() {
500 | expect(using('#escaped-html').binding('snippet')).
501 | toBe("Pretty text with some links:\n" +
502 | "http://angularjs.org/,\n" +
503 | "mailto:us@somewhere.org,\n" +
504 | "another@somewhere.org,\n" +
505 | "and one more: ftp://127.0.0.1/.");
506 | });
507 |
508 | it('should update', function() {
509 | input('snippet').enter('new http://link.');
510 | expect(using('#linky-filter').binding('snippet | linky')).
511 | toBe('new http://link.');
512 | expect(using('#escaped-html').binding('snippet')).toBe('new http://link.');
513 | });
514 |
515 | it('should work with the target property', function() {
516 | expect(using('#linky-target').binding("snippetWithTarget | linky:'_blank'")).
517 | toBe('http://angularjs.org/');
518 | });
519 |
520 |
521 | */
522 | angular.module('ngSanitize').filter('linky', function() {
523 | var LINKY_URL_REGEXP = /((ftp|https?):\/\/|(mailto:)?[A-Za-z0-9._%+-]+@)\S*[^\s\.\;\,\(\)\{\}\<\>]/,
524 | MAILTO_REGEXP = /^mailto:/;
525 |
526 | return function(text, target) {
527 | if (!text) return text;
528 | var match;
529 | var raw = text;
530 | var html = [];
531 | // TODO(vojta): use $sanitize instead
532 | var writer = htmlSanitizeWriter(html);
533 | var url;
534 | var i;
535 | var properties = {};
536 | if (angular.isDefined(target)) {
537 | properties.target = target;
538 | }
539 | while ((match = raw.match(LINKY_URL_REGEXP))) {
540 | // We can not end in these as they are sometimes found at the end of the sentence
541 | url = match[0];
542 | // if we did not match ftp/http/mailto then assume mailto
543 | if (match[2] == match[3]) url = 'mailto:' + url;
544 | i = match.index;
545 | writer.chars(raw.substr(0, i));
546 | properties.href = url;
547 | writer.start('a', properties);
548 | writer.chars(match[0].replace(MAILTO_REGEXP, ''));
549 | writer.end('a');
550 | raw = raw.substring(i + match[0].length);
551 | }
552 | writer.chars(raw);
553 | return html.join('');
554 | };
555 | });
556 |
557 |
558 | })(window, window.angular);
559 |
--------------------------------------------------------------------------------
/app/js/directives.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | angular.module('app.directives', [])
4 |
5 | // Main directive, that just publish a controller
6 | .directive('frangTree', function ($parse, $animate) {
7 | return {
8 | restrict: 'EA',
9 | controller: function($scope, $element) {
10 | this.insertChildren = null;
11 | this.init = function(insertChildren) {
12 | this.insertChildren = insertChildren;
13 | };
14 | }
15 | };
16 | })
17 |
18 | .directive('frangTreeRepeat', function ($parse, $animate) {
19 |
20 | // ---------- Some necessary internal functions from angular.js ----------
21 |
22 | function hashKey(obj) {
23 | var objType = typeof obj,
24 | key;
25 |
26 | if (objType == 'object' && obj !== null) {
27 | if (typeof (key = obj.$$hashKey) == 'function') {
28 | // must invoke on object to keep the right this
29 | key = obj.$$hashKey();
30 | } else if (key === undefined) {
31 | key = obj.$$hashKey = nextUid();
32 | }
33 | } else {
34 | key = obj;
35 | }
36 |
37 | return objType + ':' + key;
38 | }
39 | function isArrayLike(obj) {
40 | if (obj == null || isWindow(obj)) {
41 | return false;
42 | }
43 |
44 | var length = obj.length;
45 |
46 | if (obj.nodeType === 1 && length) {
47 | return true;
48 | }
49 |
50 | return isString(obj) || isArray(obj) || length === 0 ||
51 | typeof length === 'number' && length > 0 && (length - 1) in obj;
52 | }
53 | function isWindow(obj) {
54 | return obj && obj.document && obj.location && obj.alert && obj.setInterval;
55 | }
56 | function isString(value){return typeof value == 'string';}
57 | function isArray(value) {
58 | return toString.apply(value) == '[object Array]';
59 | }
60 | var uid = ['0', '0', '0'];
61 | function nextUid() {
62 | var index = uid.length;
63 | var digit;
64 |
65 | while(index) {
66 | index--;
67 | digit = uid[index].charCodeAt(0);
68 | if (digit == 57 /*'9'*/) {
69 | uid[index] = 'A';
70 | return uid.join('');
71 | }
72 | if (digit == 90 /*'Z'*/) {
73 | uid[index] = '0';
74 | } else {
75 | uid[index] = String.fromCharCode(digit + 1);
76 | return uid.join('');
77 | }
78 | }
79 | uid.unshift('0');
80 | return uid.join('');
81 | }
82 | function assertNotHasOwnProperty(name, context) {
83 | if (name === 'hasOwnProperty') {
84 | throw ngMinErr('badname', "hasOwnProperty is not a valid {0} name", context);
85 | }
86 | }
87 | var jqLite = angular.element;
88 | var forEach = angular.forEach;
89 |
90 | function minErr(module) {
91 | return function () {
92 | var code = arguments[0],
93 | prefix = '[' + (module ? module + ':' : '') + code + '] ',
94 | template = arguments[1],
95 | templateArgs = arguments,
96 | stringify = function (obj) {
97 | if (isFunction(obj)) {
98 | return obj.toString().replace(/ \{[\s\S]*$/, '');
99 | } else if (isUndefined(obj)) {
100 | return 'undefined';
101 | } else if (!isString(obj)) {
102 | return JSON.stringify(obj);
103 | }
104 | return obj;
105 | },
106 | message, i;
107 |
108 | message = prefix + template.replace(/\{\d+\}/g, function (match) {
109 | var index = +match.slice(1, -1), arg;
110 |
111 | if (index + 2 < templateArgs.length) {
112 | arg = templateArgs[index + 2];
113 | if (isFunction(arg)) {
114 | return arg.toString().replace(/ ?\{[\s\S]*$/, '');
115 | } else if (isUndefined(arg)) {
116 | return 'undefined';
117 | } else if (!isString(arg)) {
118 | return toJson(arg);
119 | }
120 | return arg;
121 | }
122 | return match;
123 | });
124 |
125 | message = message + '\nhttp://errors.angularjs.org/' + version.full + '/' +
126 | (module ? module + '/' : '') + code;
127 | for (i = 2; i < arguments.length; i++) {
128 | message = message + (i == 2 ? '?' : '&') + 'p' + (i-2) + '=' +
129 | encodeURIComponent(stringify(arguments[i]));
130 | }
131 |
132 | return new Error(message);
133 | };
134 | }
135 |
136 |
137 | // ---------- Some initializations at the beginning of ngRepeat factory ----------
138 |
139 | var NG_REMOVED = '$$NG_REMOVED';
140 | var ngRepeatMinErr = minErr('ngRepeat');
141 | var ngMinErr = minErr('ng');
142 | var toString = Object.prototype.toString;
143 | var isFunction = angular.isFunction;
144 | var isUndefined = angular.isUndefined;
145 | var toJson = angular.toJson;
146 |
147 | // ---------- Internal function at the end of ngRepeat factory ----------
148 |
149 | function getBlockElements(block) {
150 | if (block.startNode === block.endNode) {
151 | return jqLite(block.startNode);
152 | }
153 |
154 | var element = block.startNode;
155 | var elements = [element];
156 |
157 | do {
158 | element = element.nextSibling;
159 | if (!element) break;
160 | elements.push(element);
161 | } while (element !== block.endNode);
162 |
163 | return jqLite(elements);
164 | }
165 |
166 |
167 | // ---------- Add watch, extracted into a function to call it not only on the element but also on its children ----------
168 |
169 | function addRepeatWatch($scope, $element, _lastBlockMap, valueIdentifier, keyIdentifier,
170 | rhs, trackByIdExpFn, trackByIdArrayFn, trackByIdObjFn, linker, expression) {
171 | var lastBlockMap = _lastBlockMap;
172 |
173 | //watch props
174 | $scope.$watchCollection(rhs, function ngRepeatAction(collection){
175 | var index, length,
176 | previousNode = $element[0], // current position of the node
177 | nextNode,
178 | // Same as lastBlockMap but it has the current state. It will become the
179 | // lastBlockMap on the next iteration.
180 | nextBlockMap = {},
181 | arrayLength,
182 | childScope,
183 | key, value, // key/value of iteration
184 | trackById,
185 | trackByIdFn,
186 | collectionKeys,
187 | block, // last object information {scope, element, id}
188 | nextBlockOrder = [],
189 | elementsToRemove;
190 |
191 |
192 | if (isArrayLike(collection)) {
193 | collectionKeys = collection;
194 | trackByIdFn = trackByIdExpFn || trackByIdArrayFn;
195 | } else {
196 | trackByIdFn = trackByIdExpFn || trackByIdObjFn;
197 | // if object, extract keys, sort them and use to determine order of iteration over obj props
198 | collectionKeys = [];
199 | for (key in collection) {
200 | if (collection.hasOwnProperty(key) && key.charAt(0) != '$') {
201 | collectionKeys.push(key);
202 | }
203 | }
204 | collectionKeys.sort();
205 | }
206 |
207 | arrayLength = collectionKeys.length;
208 |
209 | // locate existing items
210 | length = nextBlockOrder.length = collectionKeys.length;
211 | for(index = 0; index < length; index++) {
212 | key = (collection === collectionKeys) ? index : collectionKeys[index];
213 | value = collection[key];
214 | trackById = trackByIdFn(key, value, index);
215 | assertNotHasOwnProperty(trackById, '`track by` id');
216 | if(lastBlockMap.hasOwnProperty(trackById)) {
217 | block = lastBlockMap[trackById]
218 | delete lastBlockMap[trackById];
219 | nextBlockMap[trackById] = block;
220 | nextBlockOrder[index] = block;
221 | } else if (nextBlockMap.hasOwnProperty(trackById)) {
222 | // restore lastBlockMap
223 | forEach(nextBlockOrder, function(block) {
224 | if (block && block.startNode) lastBlockMap[block.id] = block;
225 | });
226 | // This is a duplicate and we need to throw an error
227 | throw ngRepeatMinErr('dupes', "Duplicates in a repeater are not allowed. Use 'track by' expression to specify unique keys. Repeater: {0}, Duplicate key: {1}",
228 | expression, trackById);
229 | } else {
230 | // new never before seen block
231 | nextBlockOrder[index] = { id: trackById };
232 | nextBlockMap[trackById] = false;
233 | }
234 | }
235 |
236 | // remove existing items
237 | for (key in lastBlockMap) {
238 | // lastBlockMap is our own object so we don't need to use special hasOwnPropertyFn
239 | if (lastBlockMap.hasOwnProperty(key)) {
240 | block = lastBlockMap[key];
241 | elementsToRemove = getBlockElements(block);
242 | $animate.leave(elementsToRemove);
243 | forEach(elementsToRemove, function(element) { element[NG_REMOVED] = true; });
244 | block.scope.$destroy();
245 | }
246 | }
247 |
248 | // we are not using forEach for perf reasons (trying to avoid #call)
249 | for (index = 0, length = collectionKeys.length; index < length; index++) {
250 | key = (collection === collectionKeys) ? index : collectionKeys[index];
251 | value = collection[key];
252 | block = nextBlockOrder[index];
253 | if (nextBlockOrder[index - 1]) previousNode = nextBlockOrder[index - 1].endNode;
254 |
255 | if (block.startNode) {
256 | // if we have already seen this object, then we need to reuse the
257 | // associated scope/element
258 | childScope = block.scope;
259 |
260 | nextNode = previousNode;
261 | do {
262 | nextNode = nextNode.nextSibling;
263 | } while(nextNode && nextNode[NG_REMOVED]);
264 |
265 | if (block.startNode == nextNode) {
266 | // do nothing
267 | } else {
268 | // existing item which got moved
269 | $animate.move(getBlockElements(block), null, jqLite(previousNode));
270 | }
271 | previousNode = block.endNode;
272 | } else {
273 | // new item which we don't know about
274 | childScope = $scope.$new();
275 | }
276 |
277 | childScope[valueIdentifier] = value;
278 | if (keyIdentifier) childScope[keyIdentifier] = key;
279 | childScope.$index = index;
280 | childScope.$first = (index === 0);
281 | childScope.$last = (index === (arrayLength - 1));
282 | childScope.$middle = !(childScope.$first || childScope.$last);
283 | childScope.$odd = !(childScope.$even = index%2==0);
284 |
285 | if (!block.startNode) {
286 | linker(childScope, function(clone) {
287 | clone[clone.length++] = document.createComment(' end ngRepeat: ' + expression + ' ');
288 | $animate.enter(clone, null, jqLite(previousNode));
289 | previousNode = clone;
290 | block.scope = childScope;
291 | block.startNode = previousNode && previousNode.endNode ? previousNode.endNode : clone[0];
292 | block.endNode = clone[clone.length - 1];
293 | nextBlockMap[block.id] = block;
294 | });
295 | }
296 | }
297 | lastBlockMap = nextBlockMap;
298 | });
299 | }
300 |
301 |
302 | return {
303 | restrict: 'A',
304 | transclude: 'element',
305 | priority: 1000,
306 | terminal: true,
307 | require: '^frangTree',
308 | compile: function(element, attr, linker) {
309 | return function($scope, $element, $attr, ctrl){
310 | var expression = $attr.frangTreeRepeat;
311 | var match = expression.match(/^\s*(.+)\s+in\s+(.*?)\s*(\s+track\s+by\s+(.+)\s*)?$/),
312 | trackByExp, trackByExpGetter, trackByIdExpFn, trackByIdArrayFn, trackByIdObjFn,
313 | lhs, rhs, valueIdentifier, keyIdentifier,
314 | hashFnLocals = {$id: hashKey};
315 |
316 | if (!match) {
317 | throw ngRepeatMinErr('iexp', "Expected expression in form of '_item_ in _collection_[ track by _id_]' but got '{0}'.",
318 | expression);
319 | }
320 |
321 | lhs = match[1];
322 | rhs = match[2];
323 | trackByExp = match[4];
324 |
325 | if (trackByExp) {
326 | trackByExpGetter = $parse(trackByExp);
327 | trackByIdExpFn = function(key, value, index) {
328 | // assign key, value, and $index to the locals so that they can be used in hash functions
329 | if (keyIdentifier) hashFnLocals[keyIdentifier] = key;
330 | hashFnLocals[valueIdentifier] = value;
331 | hashFnLocals.$index = index;
332 | return trackByExpGetter($scope, hashFnLocals);
333 | };
334 | } else {
335 | trackByIdArrayFn = function(key, value) {
336 | return hashKey(value);
337 | }
338 | trackByIdObjFn = function(key) {
339 | return key;
340 | }
341 | }
342 |
343 | match = lhs.match(/^(?:([\$\w]+)|\(([\$\w]+)\s*,\s*([\$\w]+)\))$/);
344 | if (!match) {
345 | throw ngRepeatMinErr('iidexp', "'_item_' in '_item_ in _collection_' should be an identifier or '(_key_, _value_)' expression, but got '{0}'.",
346 | lhs);
347 | }
348 | valueIdentifier = match[3] || match[1];
349 | keyIdentifier = match[2];
350 |
351 | // Store a list of elements from previous run. This is a hash where key is the item from the
352 | // iterator, and the value is objects with following properties.
353 | // - scope: bound scope
354 | // - element: previous element.
355 | // - index: position
356 | var lastBlockMap = {};
357 |
358 |
359 | addRepeatWatch($scope, $element, /*lastBlockMap*/ {}, valueIdentifier, keyIdentifier,
360 | rhs, trackByIdExpFn, trackByIdArrayFn, trackByIdObjFn, linker, expression);
361 |
362 | ctrl.init(function ($scope, $element, collection) {
363 | addRepeatWatch($scope, $element, /*lastBlockMap*/ {}, valueIdentifier, keyIdentifier,
364 | collection, trackByIdExpFn, trackByIdArrayFn, trackByIdObjFn, linker, expression)
365 | });
366 | };
367 |
368 | }
369 | };
370 | })
371 |
372 | .directive('frangTreeInsertChildren', function () {
373 | return {
374 | restrict: 'EA',
375 | require: '^frangTree',
376 | link: function (scope, element, attrs, ctrl) {
377 | var comment = document.createComment('treeRepeat');
378 | element.append(comment);
379 |
380 | ctrl.insertChildren(scope, angular.element(comment), attrs.frangTreeInsertChildren);
381 | }
382 | };
383 | })
384 |
385 | .directive('frangTreeDrag', function($parse) {
386 | return {
387 | restrict: 'A',
388 | require: '^frangTree',
389 | link: function(scope, element, attrs, ctrl) {
390 | var el = element[0];
391 | var parsedDrag = $parse(attrs.frangTreeDrag);
392 | el.draggable = true;
393 | el.addEventListener(
394 | 'dragstart',
395 | function(e) {
396 | if (e.stopPropagation) e.stopPropagation();
397 | e.dataTransfer.effectAllowed = 'move';
398 | e.dataTransfer.setData('Text', 'nothing'); // Firefox requires some data
399 | element.addClass('tree-drag');
400 | ctrl.dragData = parsedDrag(scope);
401 | return false;
402 | },
403 | false
404 | );
405 | el.addEventListener(
406 | 'dragend',
407 | function(e) {
408 | if (e.stopPropagation) e.stopPropagation();
409 | element.removeClass('tree-drag');
410 | ctrl.dragData = null;
411 | return false;
412 | },
413 | false
414 | );
415 | }
416 | };
417 | })
418 |
419 | .directive('frangTreeDrop', function($parse) {
420 | return {
421 | restrict: 'A',
422 | require: '^frangTree',
423 | link: function(scope, element, attrs, ctrl) {
424 | var el = element[0];
425 | var parsedDrop = $parse(attrs.frangTreeDrop);
426 | var parsedAllowDrop = $parse(attrs.frangTreeAllowDrop || 'true');
427 | el.addEventListener(
428 | 'dragover',
429 | function(e) {
430 | if (parsedAllowDrop(scope, {dragData: ctrl.dragData})) {
431 | if (e.stopPropagation) { e.stopPropagation(); }
432 | e.dataTransfer.dropEffect = 'move';
433 | element.addClass('tree-drag-over');
434 | // allow drop
435 | if (e.preventDefault) { e.preventDefault(); }
436 | }
437 | return false;
438 | },
439 | false
440 | );
441 | el.addEventListener(
442 | 'dragenter',
443 | function(e) {
444 | if (parsedAllowDrop(scope, {dragData: ctrl.dragData})) {
445 | if (e.stopPropagation) { e.stopPropagation(); }
446 | element.addClass('tree-drag-over');
447 | // allow drop
448 | if (e.preventDefault) { e.preventDefault(); }
449 | }
450 | return false;
451 | },
452 | false
453 | );
454 | el.addEventListener(
455 | 'dragleave',
456 | function(e) {
457 | if (parsedAllowDrop(scope, {dragData: ctrl.dragData})) {
458 | if (e.stopPropagation) { e.stopPropagation(); }
459 | element.removeClass('tree-drag-over');
460 | }
461 | return false;
462 | },
463 | false
464 | );
465 | el.addEventListener(
466 | 'drop',
467 | function(e) {
468 | if (parsedAllowDrop(scope, {dragData: ctrl.dragData})) {
469 | if (e.stopPropagation) { e.stopPropagation(); }
470 | element.removeClass('tree-drag-over');
471 | scope.$apply(function () {
472 | parsedDrop(scope, {dragData: ctrl.dragData});
473 | });
474 | ctrl.dragData = null;
475 | if (e.preventDefault) { e.preventDefault(); }
476 | }
477 | return false;
478 | },
479 | false
480 | );
481 | }
482 | }
483 | });
484 |
485 |
--------------------------------------------------------------------------------
/app/lib/angular/angular-touch.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @license AngularJS v1.2.0-rc.3
3 | * (c) 2010-2012 Google, Inc. http://angularjs.org
4 | * License: MIT
5 | */
6 | (function(window, angular, undefined) {'use strict';
7 |
8 | /**
9 | * @ngdoc overview
10 | * @name ngTouch
11 | * @description
12 | *
13 | * # ngTouch
14 | *
15 | * `ngTouch` is the name of the optional Angular module that provides touch events and other
16 | * helpers for touch-enabled devices.
17 | * The implementation is based on jQuery Mobile touch event handling
18 | * ([jquerymobile.com](http://jquerymobile.com/))
19 | *
20 | * {@installModule touch}
21 | *
22 | * See {@link ngTouch.$swipe `$swipe`} for usage.
23 | */
24 |
25 | // define ngTouch module
26 | var ngTouch = angular.module('ngTouch', []);
27 |
28 | /**
29 | * @ngdoc object
30 | * @name ngTouch.$swipe
31 | *
32 | * @description
33 | * The `$swipe` service is a service that abstracts the messier details of hold-and-drag swipe
34 | * behavior, to make implementing swipe-related directives more convenient.
35 | *
36 | * Requires the {@link ngTouch `ngTouch`} module to be installed.
37 | *
38 | * `$swipe` is used by the `ngSwipeLeft` and `ngSwipeRight` directives in `ngTouch`, and by
39 | * `ngCarousel` in a separate component.
40 | *
41 | * # Usage
42 | * The `$swipe` service is an object with a single method: `bind`. `bind` takes an element
43 | * which is to be watched for swipes, and an object with four handler functions. See the
44 | * documentation for `bind` below.
45 | */
46 |
47 | ngTouch.factory('$swipe', [function() {
48 | // The total distance in any direction before we make the call on swipe vs. scroll.
49 | var MOVE_BUFFER_RADIUS = 10;
50 |
51 | function getCoordinates(event) {
52 | var touches = event.touches && event.touches.length ? event.touches : [event];
53 | var e = (event.changedTouches && event.changedTouches[0]) ||
54 | (event.originalEvent && event.originalEvent.changedTouches &&
55 | event.originalEvent.changedTouches[0]) ||
56 | touches[0].originalEvent || touches[0];
57 |
58 | return {
59 | x: e.clientX,
60 | y: e.clientY
61 | };
62 | }
63 |
64 | return {
65 | /**
66 | * @ngdoc method
67 | * @name ngTouch.$swipe#bind
68 | * @methodOf ngTouch.$swipe
69 | *
70 | * @description
71 | * The main method of `$swipe`. It takes an element to be watched for swipe motions, and an
72 | * object containing event handlers.
73 | *
74 | * The four events are `start`, `move`, `end`, and `cancel`. `start`, `move`, and `end`
75 | * receive as a parameter a coordinates object of the form `{ x: 150, y: 310 }`.
76 | *
77 | * `start` is called on either `mousedown` or `touchstart`. After this event, `$swipe` is
78 | * watching for `touchmove` or `mousemove` events. These events are ignored until the total
79 | * distance moved in either dimension exceeds a small threshold.
80 | *
81 | * Once this threshold is exceeded, either the horizontal or vertical delta is greater.
82 | * - If the horizontal distance is greater, this is a swipe and `move` and `end` events follow.
83 | * - If the vertical distance is greater, this is a scroll, and we let the browser take over.
84 | * A `cancel` event is sent.
85 | *
86 | * `move` is called on `mousemove` and `touchmove` after the above logic has determined that
87 | * a swipe is in progress.
88 | *
89 | * `end` is called when a swipe is successfully completed with a `touchend` or `mouseup`.
90 | *
91 | * `cancel` is called either on a `touchcancel` from the browser, or when we begin scrolling
92 | * as described above.
93 | *
94 | */
95 | bind: function(element, eventHandlers) {
96 | // Absolute total movement, used to control swipe vs. scroll.
97 | var totalX, totalY;
98 | // Coordinates of the start position.
99 | var startCoords;
100 | // Last event's position.
101 | var lastPos;
102 | // Whether a swipe is active.
103 | var active = false;
104 |
105 | element.on('touchstart mousedown', function(event) {
106 | startCoords = getCoordinates(event);
107 | active = true;
108 | totalX = 0;
109 | totalY = 0;
110 | lastPos = startCoords;
111 | eventHandlers['start'] && eventHandlers['start'](startCoords, event);
112 | });
113 |
114 | element.on('touchcancel', function(event) {
115 | active = false;
116 | eventHandlers['cancel'] && eventHandlers['cancel'](event);
117 | });
118 |
119 | element.on('touchmove mousemove', function(event) {
120 | if (!active) return;
121 |
122 | // Android will send a touchcancel if it thinks we're starting to scroll.
123 | // So when the total distance (+ or - or both) exceeds 10px in either direction,
124 | // we either:
125 | // - On totalX > totalY, we send preventDefault() and treat this as a swipe.
126 | // - On totalY > totalX, we let the browser handle it as a scroll.
127 |
128 | if (!startCoords) return;
129 | var coords = getCoordinates(event);
130 |
131 | totalX += Math.abs(coords.x - lastPos.x);
132 | totalY += Math.abs(coords.y - lastPos.y);
133 |
134 | lastPos = coords;
135 |
136 | if (totalX < MOVE_BUFFER_RADIUS && totalY < MOVE_BUFFER_RADIUS) {
137 | return;
138 | }
139 |
140 | // One of totalX or totalY has exceeded the buffer, so decide on swipe vs. scroll.
141 | if (totalY > totalX) {
142 | // Allow native scrolling to take over.
143 | active = false;
144 | eventHandlers['cancel'] && eventHandlers['cancel'](event);
145 | return;
146 | } else {
147 | // Prevent the browser from scrolling.
148 | event.preventDefault();
149 | eventHandlers['move'] && eventHandlers['move'](coords, event);
150 | }
151 | });
152 |
153 | element.on('touchend mouseup', function(event) {
154 | if (!active) return;
155 | active = false;
156 | eventHandlers['end'] && eventHandlers['end'](getCoordinates(event), event);
157 | });
158 | }
159 | };
160 | }]);
161 |
162 | /**
163 | * @ngdoc directive
164 | * @name ngTouch.directive:ngClick
165 | *
166 | * @description
167 | * A more powerful replacement for the default ngClick designed to be used on touchscreen
168 | * devices. Most mobile browsers wait about 300ms after a tap-and-release before sending
169 | * the click event. This version handles them immediately, and then prevents the
170 | * following click event from propagating.
171 | *
172 | * Requires the {@link ngTouch `ngTouch`} module to be installed.
173 | *
174 | * This directive can fall back to using an ordinary click event, and so works on desktop
175 | * browsers as well as mobile.
176 | *
177 | * This directive also sets the CSS class `ng-click-active` while the element is being held
178 | * down (by a mouse click or touch) so you can restyle the depressed element if you wish.
179 | *
180 | * @element ANY
181 | * @param {expression} ngClick {@link guide/expression Expression} to evaluate
182 | * upon tap. (Event object is available as `$event`)
183 | *
184 | * @example
185 |
186 |
187 |
190 | count: {{ count }}
191 |
192 |
193 | */
194 |
195 | ngTouch.config(['$provide', function($provide) {
196 | $provide.decorator('ngClickDirective', ['$delegate', function($delegate) {
197 | // drop the default ngClick directive
198 | $delegate.shift();
199 | return $delegate;
200 | }]);
201 | }]);
202 |
203 | ngTouch.directive('ngClick', ['$parse', '$timeout', '$rootElement',
204 | function($parse, $timeout, $rootElement) {
205 | var TAP_DURATION = 750; // Shorter than 750ms is a tap, longer is a taphold or drag.
206 | var MOVE_TOLERANCE = 12; // 12px seems to work in most mobile browsers.
207 | var PREVENT_DURATION = 2500; // 2.5 seconds maximum from preventGhostClick call to click
208 | var CLICKBUSTER_THRESHOLD = 25; // 25 pixels in any dimension is the limit for busting clicks.
209 |
210 | var ACTIVE_CLASS_NAME = 'ng-click-active';
211 | var lastPreventedTime;
212 | var touchCoordinates;
213 |
214 |
215 | // TAP EVENTS AND GHOST CLICKS
216 | //
217 | // Why tap events?
218 | // Mobile browsers detect a tap, then wait a moment (usually ~300ms) to see if you're
219 | // double-tapping, and then fire a click event.
220 | //
221 | // This delay sucks and makes mobile apps feel unresponsive.
222 | // So we detect touchstart, touchmove, touchcancel and touchend ourselves and determine when
223 | // the user has tapped on something.
224 | //
225 | // What happens when the browser then generates a click event?
226 | // The browser, of course, also detects the tap and fires a click after a delay. This results in
227 | // tapping/clicking twice. So we do "clickbusting" to prevent it.
228 | //
229 | // How does it work?
230 | // We attach global touchstart and click handlers, that run during the capture (early) phase.
231 | // So the sequence for a tap is:
232 | // - global touchstart: Sets an "allowable region" at the point touched.
233 | // - element's touchstart: Starts a touch
234 | // (- touchmove or touchcancel ends the touch, no click follows)
235 | // - element's touchend: Determines if the tap is valid (didn't move too far away, didn't hold
236 | // too long) and fires the user's tap handler. The touchend also calls preventGhostClick().
237 | // - preventGhostClick() removes the allowable region the global touchstart created.
238 | // - The browser generates a click event.
239 | // - The global click handler catches the click, and checks whether it was in an allowable region.
240 | // - If preventGhostClick was called, the region will have been removed, the click is busted.
241 | // - If the region is still there, the click proceeds normally. Therefore clicks on links and
242 | // other elements without ngTap on them work normally.
243 | //
244 | // This is an ugly, terrible hack!
245 | // Yeah, tell me about it. The alternatives are using the slow click events, or making our users
246 | // deal with the ghost clicks, so I consider this the least of evils. Fortunately Angular
247 | // encapsulates this ugly logic away from the user.
248 | //
249 | // Why not just put click handlers on the element?
250 | // We do that too, just to be sure. The problem is that the tap event might have caused the DOM
251 | // to change, so that the click fires in the same position but something else is there now. So
252 | // the handlers are global and care only about coordinates and not elements.
253 |
254 | // Checks if the coordinates are close enough to be within the region.
255 | function hit(x1, y1, x2, y2) {
256 | return Math.abs(x1 - x2) < CLICKBUSTER_THRESHOLD && Math.abs(y1 - y2) < CLICKBUSTER_THRESHOLD;
257 | }
258 |
259 | // Checks a list of allowable regions against a click location.
260 | // Returns true if the click should be allowed.
261 | // Splices out the allowable region from the list after it has been used.
262 | function checkAllowableRegions(touchCoordinates, x, y) {
263 | for (var i = 0; i < touchCoordinates.length; i += 2) {
264 | if (hit(touchCoordinates[i], touchCoordinates[i+1], x, y)) {
265 | touchCoordinates.splice(i, i + 2);
266 | return true; // allowable region
267 | }
268 | }
269 | return false; // No allowable region; bust it.
270 | }
271 |
272 | // Global click handler that prevents the click if it's in a bustable zone and preventGhostClick
273 | // was called recently.
274 | function onClick(event) {
275 | if (Date.now() - lastPreventedTime > PREVENT_DURATION) {
276 | return; // Too old.
277 | }
278 |
279 | var touches = event.touches && event.touches.length ? event.touches : [event];
280 | var x = touches[0].clientX;
281 | var y = touches[0].clientY;
282 | // Work around desktop Webkit quirk where clicking a label will fire two clicks (on the label
283 | // and on the input element). Depending on the exact browser, this second click we don't want
284 | // to bust has either (0,0) or negative coordinates.
285 | if (x < 1 && y < 1) {
286 | return; // offscreen
287 | }
288 |
289 | // Look for an allowable region containing this click.
290 | // If we find one, that means it was created by touchstart and not removed by
291 | // preventGhostClick, so we don't bust it.
292 | if (checkAllowableRegions(touchCoordinates, x, y)) {
293 | return;
294 | }
295 |
296 | // If we didn't find an allowable region, bust the click.
297 | event.stopPropagation();
298 | event.preventDefault();
299 |
300 | // Blur focused form elements
301 | event.target && event.target.blur();
302 | }
303 |
304 |
305 | // Global touchstart handler that creates an allowable region for a click event.
306 | // This allowable region can be removed by preventGhostClick if we want to bust it.
307 | function onTouchStart(event) {
308 | var touches = event.touches && event.touches.length ? event.touches : [event];
309 | var x = touches[0].clientX;
310 | var y = touches[0].clientY;
311 | touchCoordinates.push(x, y);
312 |
313 | $timeout(function() {
314 | // Remove the allowable region.
315 | for (var i = 0; i < touchCoordinates.length; i += 2) {
316 | if (touchCoordinates[i] == x && touchCoordinates[i+1] == y) {
317 | touchCoordinates.splice(i, i + 2);
318 | return;
319 | }
320 | }
321 | }, PREVENT_DURATION, false);
322 | }
323 |
324 | // On the first call, attaches some event handlers. Then whenever it gets called, it creates a
325 | // zone around the touchstart where clicks will get busted.
326 | function preventGhostClick(x, y) {
327 | if (!touchCoordinates) {
328 | $rootElement[0].addEventListener('click', onClick, true);
329 | $rootElement[0].addEventListener('touchstart', onTouchStart, true);
330 | touchCoordinates = [];
331 | }
332 |
333 | lastPreventedTime = Date.now();
334 |
335 | checkAllowableRegions(touchCoordinates, x, y);
336 | }
337 |
338 | // Actual linking function.
339 | return function(scope, element, attr) {
340 | var clickHandler = $parse(attr.ngClick),
341 | tapping = false,
342 | tapElement, // Used to blur the element after a tap.
343 | startTime, // Used to check if the tap was held too long.
344 | touchStartX,
345 | touchStartY;
346 |
347 | function resetState() {
348 | tapping = false;
349 | element.removeClass(ACTIVE_CLASS_NAME);
350 | }
351 |
352 | element.on('touchstart', function(event) {
353 | tapping = true;
354 | tapElement = event.target ? event.target : event.srcElement; // IE uses srcElement.
355 | // Hack for Safari, which can target text nodes instead of containers.
356 | if(tapElement.nodeType == 3) {
357 | tapElement = tapElement.parentNode;
358 | }
359 |
360 | element.addClass(ACTIVE_CLASS_NAME);
361 |
362 | startTime = Date.now();
363 |
364 | var touches = event.touches && event.touches.length ? event.touches : [event];
365 | var e = touches[0].originalEvent || touches[0];
366 | touchStartX = e.clientX;
367 | touchStartY = e.clientY;
368 | });
369 |
370 | element.on('touchmove', function(event) {
371 | resetState();
372 | });
373 |
374 | element.on('touchcancel', function(event) {
375 | resetState();
376 | });
377 |
378 | element.on('touchend', function(event) {
379 | var diff = Date.now() - startTime;
380 |
381 | var touches = (event.changedTouches && event.changedTouches.length) ? event.changedTouches :
382 | ((event.touches && event.touches.length) ? event.touches : [event]);
383 | var e = touches[0].originalEvent || touches[0];
384 | var x = e.clientX;
385 | var y = e.clientY;
386 | var dist = Math.sqrt( Math.pow(x - touchStartX, 2) + Math.pow(y - touchStartY, 2) );
387 |
388 | if (tapping && diff < TAP_DURATION && dist < MOVE_TOLERANCE) {
389 | // Call preventGhostClick so the clickbuster will catch the corresponding click.
390 | preventGhostClick(x, y);
391 |
392 | // Blur the focused element (the button, probably) before firing the callback.
393 | // This doesn't work perfectly on Android Chrome, but seems to work elsewhere.
394 | // I couldn't get anything to work reliably on Android Chrome.
395 | if (tapElement) {
396 | tapElement.blur();
397 | }
398 |
399 | if (!angular.isDefined(attr.disabled) || attr.disabled === false) {
400 | element.triggerHandler('click', [event]);
401 | }
402 | }
403 |
404 | resetState();
405 | });
406 |
407 | // Hack for iOS Safari's benefit. It goes searching for onclick handlers and is liable to click
408 | // something else nearby.
409 | element.onclick = function(event) { };
410 |
411 | // Actual click handler.
412 | // There are three different kinds of clicks, only two of which reach this point.
413 | // - On desktop browsers without touch events, their clicks will always come here.
414 | // - On mobile browsers, the simulated "fast" click will call this.
415 | // - But the browser's follow-up slow click will be "busted" before it reaches this handler.
416 | // Therefore it's safe to use this directive on both mobile and desktop.
417 | element.on('click', function(event, touchend) {
418 | scope.$apply(function() {
419 | clickHandler(scope, {$event: (touchend || event)});
420 | });
421 | });
422 |
423 | element.on('mousedown', function(event) {
424 | element.addClass(ACTIVE_CLASS_NAME);
425 | });
426 |
427 | element.on('mousemove mouseup', function(event) {
428 | element.removeClass(ACTIVE_CLASS_NAME);
429 | });
430 |
431 | };
432 | }]);
433 |
434 | /**
435 | * @ngdoc directive
436 | * @name ngTouch.directive:ngSwipeLeft
437 | *
438 | * @description
439 | * Specify custom behavior when an element is swiped to the left on a touchscreen device.
440 | * A leftward swipe is a quick, right-to-left slide of the finger.
441 | * Though ngSwipeLeft is designed for touch-based devices, it will work with a mouse click and drag too.
442 | *
443 | * Requires the {@link ngTouch `ngTouch`} module to be installed.
444 | *
445 | * @element ANY
446 | * @param {expression} ngSwipeLeft {@link guide/expression Expression} to evaluate
447 | * upon left swipe. (Event object is available as `$event`)
448 | *
449 | * @example
450 |
451 |
452 |
453 | Some list content, like an email in the inbox
454 |
455 |
456 |
457 |
458 |
459 |
460 |
461 | */
462 |
463 | /**
464 | * @ngdoc directive
465 | * @name ngTouch.directive:ngSwipeRight
466 | *
467 | * @description
468 | * Specify custom behavior when an element is swiped to the right on a touchscreen device.
469 | * A rightward swipe is a quick, left-to-right slide of the finger.
470 | * Though ngSwipeRight is designed for touch-based devices, it will work with a mouse click and drag too.
471 | *
472 | * Requires the {@link ngTouch `ngTouch`} module to be installed.
473 | *
474 | * @element ANY
475 | * @param {expression} ngSwipeRight {@link guide/expression Expression} to evaluate
476 | * upon right swipe. (Event object is available as `$event`)
477 | *
478 | * @example
479 |
480 |
481 |
482 | Some list content, like an email in the inbox
483 |