/** * x is a value between 0 and 1, indicating where in the animation you are. */ var duScrollDefaultEasing = function (x) { 'use strict'; if(x < 0.5) { return Math.pow(x*2, 2)/2; } return 1-Math.pow((1-x)*2, 2)/2; }; angular.module('duScroll', [ 'duScroll.scrollspy', 'duScroll.smoothScroll', 'duScroll.scrollContainer', 'duScroll.spyContext', 'duScroll.scrollHelpers' ]) //Default animation duration for smoothScroll directive .value('duScrollDuration', 350) //Scrollspy debounce interval, set to 0 to disable .value('duScrollSpyWait', 100) //Wether or not multiple scrollspies can be active at once .value('duScrollGreedy', false) //Default offset for smoothScroll directive .value('duScrollOffset', 0) //Default easing function for scroll animation .value('duScrollEasing', duScrollDefaultEasing); angular.module('duScroll.scrollHelpers', ['duScroll.requestAnimation']) .run(["$window", "$q", "cancelAnimation", "requestAnimation", "duScrollEasing", "duScrollDuration", "duScrollOffset", function($window, $q, cancelAnimation, requestAnimation, duScrollEasing, duScrollDuration, duScrollOffset) { 'use strict'; var proto = angular.element.prototype; var isDocument = function(el) { return (typeof HTMLDocument !== 'undefined' && el instanceof HTMLDocument) || (el.nodeType && el.nodeType === el.DOCUMENT_NODE); }; var isElement = function(el) { return (typeof HTMLElement !== 'undefined' && el instanceof HTMLElement) || (el.nodeType && el.nodeType === el.ELEMENT_NODE); }; var unwrap = function(el) { return isElement(el) || isDocument(el) ? el : el[0]; }; proto.scrollTo = function(left, top, duration, easing) { var aliasFn; if(angular.isElement(left)) { aliasFn = this.scrollToElement; } else if(duration) { aliasFn = this.scrollToAnimated; } if(aliasFn) { return aliasFn.apply(this, arguments); } var el = unwrap(this); if(isDocument(el)) { return $window.scrollTo(left, top); } el.scrollLeft = left; el.scrollTop = top; }; var scrollAnimation, deferred; proto.scrollToAnimated = function(left, top, duration, easing) { if(duration && !easing) { easing = duScrollEasing; } var startLeft = this.scrollLeft(), startTop = this.scrollTop(), deltaLeft = Math.round(left - startLeft), deltaTop = Math.round(top - startTop); var startTime = null; var el = this; var cancelOnEvents = 'scroll mousedown mousewheel touchmove keydown'; var cancelScrollAnimation = function($event) { if (!$event || $event.which > 0) { el.unbind(cancelOnEvents, cancelScrollAnimation); cancelAnimation(scrollAnimation); deferred.reject(); scrollAnimation = null; } }; if(scrollAnimation) { cancelScrollAnimation(); } deferred = $q.defer(); if(!deltaLeft && !deltaTop) { deferred.resolve(); return deferred.promise; } var animationStep = function(timestamp) { if (startTime === null) { startTime = timestamp; } var progress = timestamp - startTime; var percent = (progress >= duration ? 1 : easing(progress/duration)); el.scrollTo( startLeft + Math.ceil(deltaLeft * percent), startTop + Math.ceil(deltaTop * percent) ); if(percent < 1) { scrollAnimation = requestAnimation(animationStep); } else { el.unbind(cancelOnEvents, cancelScrollAnimation); scrollAnimation = null; deferred.resolve(); } }; //Fix random mobile safari bug when scrolling to top by hitting status bar el.scrollTo(startLeft, startTop); el.bind(cancelOnEvents, cancelScrollAnimation); scrollAnimation = requestAnimation(animationStep); return deferred.promise; }; proto.scrollToElement = function(target, offset, duration, easing) { var el = unwrap(this); if(!angular.isNumber(offset) || isNaN(offset)) { offset = duScrollOffset; } var top = this.scrollTop() + unwrap(target).getBoundingClientRect().top - offset; if(isElement(el)) { top -= el.getBoundingClientRect().top; } return this.scrollTo(0, top, duration, easing); }; var overloaders = { scrollLeft: function(value, duration, easing) { if(angular.isNumber(value)) { return this.scrollTo(value, this.scrollTop(), duration, easing); } var el = unwrap(this); if(isDocument(el)) { return $window.scrollX || document.documentElement.scrollLeft || document.body.scrollLeft; } return el.scrollLeft; }, scrollTop: function(value, duration, easing) { if(angular.isNumber(value)) { return this.scrollTo(this.scrollTop(), value, duration, easing); } var el = unwrap(this); if(isDocument(el)) { return $window.scrollY || document.documentElement.scrollTop || document.body.scrollTop; } return el.scrollTop; } }; proto.scrollToElementAnimated = function(target, offset, duration, easing) { return this.scrollToElement(target, offset, duration || duScrollDuration, easing); }; proto.scrollTopAnimated = function(top, duration, easing) { return this.scrollTop(top, duration || duScrollDuration, easing); }; proto.scrollLeftAnimated = function(left, duration, easing) { return this.scrollLeft(left, duration || duScrollDuration, easing); }; //Add duration and easing functionality to existing jQuery getter/setters var overloadScrollPos = function(superFn, overloadFn) { return function(value, duration, easing) { if(duration) { return overloadFn.apply(this, arguments); } return superFn.apply(this, arguments); }; }; for(var methodName in overloaders) { proto[methodName] = (proto[methodName] ? overloadScrollPos(proto[methodName], overloaders[methodName]) : overloaders[methodName]); } }]); //Adapted from https://gist.github.com/paulirish/1579671 angular.module('duScroll.polyfill', []) .factory('polyfill', ["$window", function($window) { 'use strict'; var vendors = ['webkit', 'moz', 'o', 'ms']; return function(fnName, fallback) { if($window[fnName]) { return $window[fnName]; } var suffix = fnName.substr(0, 1).toUpperCase() + fnName.substr(1); for(var key, i = 0; i < vendors.length; i++) { key = vendors[i]+suffix; if($window[key]) { return $window[key]; } } return fallback; }; }]); angular.module('duScroll.requestAnimation', ['duScroll.polyfill']) .factory('requestAnimation', ["polyfill", "$timeout", function(polyfill, $timeout) { 'use strict'; var lastTime = 0; var fallback = function(callback, element) { var currTime = new Date().getTime(); var timeToCall = Math.max(0, 16 - (currTime - lastTime)); var id = $timeout(function() { callback(currTime + timeToCall); }, timeToCall); lastTime = currTime + timeToCall; return id; }; return polyfill('requestAnimationFrame', fallback); }]) .factory('cancelAnimation', ["polyfill", "$timeout", function(polyfill, $timeout) { 'use strict'; var fallback = function(promise) { $timeout.cancel(promise); }; return polyfill('cancelAnimationFrame', fallback); }]); angular.module('duScroll.spyAPI', ['duScroll.scrollContainerAPI']) .factory('spyAPI', ["$rootScope", "$timeout", "scrollContainerAPI", "duScrollGreedy", "duScrollSpyWait", function($rootScope, $timeout, scrollContainerAPI, duScrollGreedy, duScrollSpyWait) { 'use strict'; var createScrollHandler = function(context) { var timer = false, queued = false; var handler = function() { queued = false; var container = context.container, containerEl = container[0], containerOffset = 0; if (typeof HTMLElement !== 'undefined' && containerEl instanceof HTMLElement || containerEl.nodeType && containerEl.nodeType === containerEl.ELEMENT_NODE) { containerOffset = containerEl.getBoundingClientRect().top; } var i, currentlyActive, toBeActive, spies, spy, pos; spies = context.spies; currentlyActive = context.currentlyActive; toBeActive = undefined; for(i = 0; i < spies.length; i++) { spy = spies[i]; pos = spy.getTargetPosition(); if (!pos) continue; if(pos.top + spy.offset - containerOffset < 20 && (pos.top*-1 + containerOffset) < pos.height) { if(!toBeActive || toBeActive.top < pos.top) { toBeActive = { top: pos.top, spy: spy }; } } } if(toBeActive) { toBeActive = toBeActive.spy; } if(currentlyActive === toBeActive || (duScrollGreedy && !toBeActive)) return; if(currentlyActive) { currentlyActive.$element.removeClass('active'); $rootScope.$broadcast('duScrollspy:becameInactive', currentlyActive.$element); } if(toBeActive) { toBeActive.$element.addClass('active'); $rootScope.$broadcast('duScrollspy:becameActive', toBeActive.$element); } context.currentlyActive = toBeActive; }; if(!duScrollSpyWait) { return handler; } //Debounce for potential performance savings return function() { if(!timer) { handler(); timer = $timeout(function() { timer = false; if(queued) { handler(); } }, duScrollSpyWait, false); } else { queued = true; } }; }; var contexts = {}; var createContext = function($scope) { var id = $scope.$id; var context = { spies: [] }; context.handler = createScrollHandler(context); contexts[id] = context; $scope.$on('$destroy', function() { destroyContext($scope); }); return id; }; var destroyContext = function($scope) { var id = $scope.$id; var context = contexts[id], container = context.container; if(container) { container.off('scroll', context.handler); } delete contexts[id]; }; var defaultContextId = createContext($rootScope); var getContextForScope = function(scope) { if(contexts[scope.$id]) { return contexts[scope.$id]; } if(scope.$parent) { return getContextForScope(scope.$parent); } return contexts[defaultContextId]; }; var getContextForSpy = function(spy) { var context, contextId, scope = spy.$element.scope(); if(scope) { return getContextForScope(scope); } //No scope, most likely destroyed for(contextId in contexts) { context = contexts[contextId]; if(context.spies.indexOf(spy) !== -1) { return context; } } }; var isElementInDocument = function(element) { while (element.parentNode) { element = element.parentNode; if (element === document) { return true; } } return false; }; var addSpy = function(spy) { var context = getContextForSpy(spy); if (!context) return; context.spies.push(spy); if (!context.container || !isElementInDocument(context.container)) { if(context.container) { context.container.off('scroll', context.handler); } context.container = scrollContainerAPI.getContainer(spy.$element.scope()); context.container.on('scroll', context.handler).triggerHandler('scroll'); } }; var removeSpy = function(spy) { var context = getContextForSpy(spy); if(spy === context.currentlyActive) { context.currentlyActive = null; } var i = context.spies.indexOf(spy); if(i !== -1) { context.spies.splice(i, 1); } }; return { addSpy: addSpy, removeSpy: removeSpy, createContext: createContext, destroyContext: destroyContext, getContextForScope: getContextForScope }; }]); angular.module('duScroll.scrollContainerAPI', []) .factory('scrollContainerAPI', ["$document", function($document) { 'use strict'; var containers = {}; var setContainer = function(scope, element) { var id = scope.$id; containers[id] = element; return id; }; var getContainerId = function(scope) { if(containers[scope.$id]) { return scope.$id; } if(scope.$parent) { return getContainerId(scope.$parent); } return; }; var getContainer = function(scope) { var id = getContainerId(scope); return id ? containers[id] : $document; }; var removeContainer = function(scope) { var id = getContainerId(scope); if(id) { delete containers[id]; } }; return { getContainerId: getContainerId, getContainer: getContainer, setContainer: setContainer, removeContainer: removeContainer }; }]); angular.module('duScroll.smoothScroll', ['duScroll.scrollHelpers', 'duScroll.scrollContainerAPI']) .directive('duSmoothScroll', ["duScrollDuration", "duScrollOffset", "scrollContainerAPI", function(duScrollDuration, duScrollOffset, scrollContainerAPI) { 'use strict'; return { link : function($scope, $element, $attr) { $element.on('click', function(e) { if(!$attr.href || $attr.href.indexOf('#') === -1) return; var target = document.getElementById($attr.href.replace(/.*(?=#[^\s]+$)/, '').substring(1)); if(!target || !target.getBoundingClientRect) return; if (e.stopPropagation) e.stopPropagation(); if (e.preventDefault) e.preventDefault(); var offset = $attr.offset ? parseInt($attr.offset, 10) : duScrollOffset; var duration = $attr.duration ? parseInt($attr.duration, 10) : duScrollDuration; var container = scrollContainerAPI.getContainer($scope); container.scrollToElement( angular.element(target), isNaN(offset) ? 0 : offset, isNaN(duration) ? 0 : duration ); }); } }; }]); angular.module('duScroll.spyContext', ['duScroll.spyAPI']) .directive('duSpyContext', ["spyAPI", function(spyAPI) { 'use strict'; return { restrict: 'A', scope: true, compile: function compile(tElement, tAttrs, transclude) { return { pre: function preLink($scope, iElement, iAttrs, controller) { spyAPI.createContext($scope); } }; } }; }]); angular.module('duScroll.scrollContainer', ['duScroll.scrollContainerAPI']) .directive('duScrollContainer', ["scrollContainerAPI", function(scrollContainerAPI){ 'use strict'; return { restrict: 'A', scope: true, compile: function compile(tElement, tAttrs, transclude) { return { pre: function preLink($scope, iElement, iAttrs, controller) { iAttrs.$observe('duScrollContainer', function(element) { if(angular.isString(element)) { element = document.getElementById(element); } element = (angular.isElement(element) ? angular.element(element) : iElement); scrollContainerAPI.setContainer($scope, element); $scope.$on('$destroy', function() { scrollContainerAPI.removeContainer($scope); }); }); } }; } }; }]); angular.module('duScroll.scrollspy', ['duScroll.spyAPI']) .directive('duScrollspy', ["spyAPI", "duScrollOffset", "$timeout", "$rootScope", function(spyAPI, duScrollOffset, $timeout, $rootScope) { 'use strict'; var Spy = function(targetElementOrId, $element, offset) { if(angular.isElement(targetElementOrId)) { this.target = targetElementOrId; } else if(angular.isString(targetElementOrId)) { this.targetId = targetElementOrId; } this.$element = $element; this.offset = offset; }; Spy.prototype.getTargetElement = function() { if (!this.target && this.targetId) { this.target = document.getElementById(this.targetId); } return this.target; }; Spy.prototype.getTargetPosition = function() { var target = this.getTargetElement(); if(target) { return target.getBoundingClientRect(); } }; Spy.prototype.flushTargetCache = function() { if(this.targetId) { this.target = undefined; } }; return { link: function ($scope, $element, $attr) { var href = $attr.ngHref || $attr.href; var targetId; if (href && href.indexOf('#') !== -1) { targetId = href.replace(/.*(?=#[^\s]+$)/, '').substring(1); } else if($attr.duScrollspy) { targetId = $attr.duScrollspy; } if(!targetId) return; // Run this in the next execution loop so that the scroll context has a chance // to initialize $timeout(function() { var spy = new Spy(targetId, $element, -($attr.offset ? parseInt($attr.offset, 10) : duScrollOffset)); spyAPI.addSpy(spy); $scope.$on('$destroy', function() { spyAPI.removeSpy(spy); }); $scope.$on('$locationChangeSuccess', spy.flushTargetCache.bind(spy)); $rootScope.$on('$stateChangeSuccess', spy.flushTargetCache.bind(spy)); }, 0, false); } }; }]);