/*
* angular-sticky-plugin
* https://github.com/harm-less/angular-sticky
* Version: 0.4.2 - 2017-11-01
* License: MIT
*/
'use strict';
angular.module('hl.sticky', [])
.factory('mediaQuery', function () {
return {
matches: function (query) {
return (query && (matchMedia('(' + query + ')').matches || matchMedia(query).matches));
}
};
})
.factory('hlStickyStack', ["$document", "DefaultStickyStackName", function ($document, DefaultStickyStackName) {
var documentEl = $document[0].documentElement;
var stacks = {};
function stickyStack(options) {
options = options || {};
var stackName = options.name || DefaultStickyStackName;
// use existing sticky stack
if (stacks[stackName]) {
return stacks[stackName];
}
// should be above all Bootstrap's z-indexes (but just before the modals)
var stickyZIndex = 1039;
var stack = [];
var $stack = {};
$stack.options = options;
$stack.stackName = stackName;
$stack.add = function (id, sticky) {
if (!angular.isString(id) || id === '') {
id = $stack.length();
}
sticky.id = id;
sticky.zIndex = stickyZIndex;
stack.push(sticky);
stickyZIndex -= 1;
return sticky;
};
$stack.get = function (id) {
for (var i = 0; i < stack.length; i++) {
if (id == stack[i].id) { // jshint ignore:line
return stack[i];
}
}
return false;
};
$stack.index = function (id) {
for (var i = 0; i < stack.length; i++) {
if (id == stack[i].id) { // jshint ignore:line
return i;
}
}
return -1;
};
$stack.range = function (start, end) {
return stack.slice(start, end);
};
$stack.all = function () {
return stack;
};
$stack.keys = function () {
var ids = [];
for (var i = 0; i < stack.length; i++) {
ids.push(stack[i].id);
}
return ids;
};
$stack.top = function () {
return stack[stack.length - 1];
};
$stack.remove = function (id) {
for (var i = 0; i < stack.length; i++) {
if (id == stack[i].id) { // jshint ignore:line
stickyZIndex += 1;
return stack.splice(i, 1)[0];
}
}
return false;
};
$stack.removeTop = function () {
stickyZIndex += 1;
return stack.splice(stack.length - 1, 1)[0];
};
$stack.length = function () {
return stack.length;
};
$stack.height = function (anchor) {
var height = {
top: 0,
bottom: 0
};
angular.forEach(stack, function(item) {
height[item.anchor()] += item.computedHeight(anchor);
});
return height[anchor];
};
$stack.heightAt = function (anchor, at) {
var atAdjusted = at - 1;
var stick;
var computedHeight;
var height = {
top: 0,
bottom: 0
};
for (var i = 0; i < stack.length; i++) {
stick = stack[i];
// check if the sticky element sticks at the queried position minus 1 pixel if the position is at the same place
if (stick.sticksAtPosition(anchor, atAdjusted)) {
var stickyAnchor = stick.anchor();
computedHeight = stick.computedHeight(anchor, atAdjusted - height[stickyAnchor]);
// add the height of the sticky element to the total
height[stickyAnchor] += computedHeight;
}
}
return height[anchor];
};
$stack.heightCurrent = function (anchor) {
return $stack.heightAt(anchor, window.pageYOffset || documentEl.scrollTop);
};
stacks[stackName] = $stack;
return $stack;
}
return stickyStack;
}])
.factory('hlStickyElement', ["$document", "$log", "hlStickyStack", "throttle", "mediaQuery", function($document, $log, hlStickyStack, throttle, mediaQuery) {
return function(element, options) {
options = options || {};
var stickyLineTop;
var stickyLineBottom;
var placeholder;
var _isSticking = false;
// elements
var bodyEl = $document[0].body;
var nativeEl = element[0];
var documentEl = $document[0].documentElement;
// attributes
var id = options.id || null;
var stickyMediaQuery = angular.isDefined(options.mediaQuery) ? options.mediaQuery : false;
var stickyClass = angular.isString(options.stickyClass) && options.stickyClass !== '' ? options.stickyClass : 'is-sticky';
var usePlaceholder = angular.isDefined(options.usePlaceholder) ? options.usePlaceholder : true;
var offsetTop = options.offsetTop ? parseInt(options.offsetTop) : 0;
var offsetBottom = options.offsetBottom ? parseInt(options.offsetBottom) : 0;
var anchor = typeof options.anchor === 'string' ? options.anchor.toLowerCase().trim() : 'top';
var container = null;
var stack = options.stack === false ? null : options.stack || hlStickyStack();
var event = angular.isFunction(options.event) ? options.event : angular.noop;
var globalOffset = {
top: 0,
bottom: 0
};
// initial style
var initialCSS = {
style: element.attr('style') || ''
};
// Methods
//
function stickyLinePositionTop() {
if (_isSticking) {
return stickyLineTop;
}
stickyLineTop = _getTopOffset(nativeEl) - offsetTop - _stackOffsetTop();
return stickyLineTop;
}
function stickyLinePositionBottom() {
if (_isSticking) {
return stickyLineBottom;
}
stickyLineBottom = _getBottomOffset(nativeEl) + offsetBottom + _stackOffsetBottom();
return stickyLineBottom;
}
function isEnabled() {
return (!angular.isDefined(options.enable) || options.enable);
}
function isSticky() {
return (isEnabled() && _isSticking) || options.alwaysSticky;
}
function sticksAtPosition(anchor, scrolledDistance) {
if (!matchesMediaQuery()) {
return false;
}
switch (anchor) {
case 'top': {
return sticksAtPositionTop(scrolledDistance);
}
case 'bottom': {
return sticksAtPositionBottom(scrolledDistance);
}
default: {
$log.error('Unknown anchor "' + anchor + '"');
break;
}
}
return false;
}
function sticksAtPositionTop(scrolledDistance) {
scrolledDistance = scrolledDistance !== undefined ? scrolledDistance : window.pageYOffset || bodyEl.scrollTop;
var scrollTop = scrolledDistance - (documentEl.clientTop || 0);
return scrollTop >= stickyLinePositionTop();
}
function sticksAtPositionBottom(scrolledDistance) {
scrolledDistance = scrolledDistance !== undefined ? scrolledDistance : (window.pageYOffset || bodyEl.scrollTop);
var scrollBottom = scrolledDistance + window.innerHeight;
return scrollBottom <= stickyLinePositionBottom();
}
function matchesMediaQuery() {
return stickyMediaQuery === false || mediaQuery.matches(stickyMediaQuery);
}
function render() {
var shouldStick = sticksAtPosition(anchor);
if (angular.isDefined(options.enable) && !options.enable) {
shouldStick = false;
}
if (angular.isDefined(options.alwaysSticky) && options.alwaysSticky) {
shouldStick = true;
}
// Switch the sticky mode if the element crosses the sticky line
// don't make the element sticky when it's already sticky
if (shouldStick && !_isSticking) {
stickElement();
event({event: 'stick'});
}
// don't unstick the element sticky when it isn't sticky already
else if (!shouldStick && _isSticking) {
unstickElement();
event({event: 'unstick'});
}
// stick after care
if (_isSticking) {
// update the top offset at an already sticking element
if (anchor === 'top') {
element.css('top', (offsetTop + _stackOffset(anchor) - containerBoundsBottom()) + 'px');
}
else if (anchor === 'bottom') {
element.css('bottom', (offsetBottom + _stackOffset(anchor) - containerBoundsTop()) + 'px');
}
element.css('width', elementWidth() + 'px');
}
}
function stickElement() {
_isSticking = true;
element.addClass(stickyClass);
// create placeholder to avoid jump
if (usePlaceholder) {
placeholder = placeholder || angular.element('
');
placeholder.css('height', elementHeight() + 'px');
element.after(placeholder);
}
var rect = nativeEl.getBoundingClientRect();
var css = {
'width': elementWidth() + 'px',
'position': 'fixed',
'left': rect.left + 'px',
'z-index': stack ? stack.get(id).zIndex - (globalOffset.zIndex || 0) : null
};
css['margin-' + anchor] = 0;
element.css(css);
}
function unstickElement() {
_isSticking = false;
element.removeClass(stickyClass);
// reset the original css we might have changed when the object was sticky
element.attr('style', initialCSS.style);
// if a placeholder was used, remove it from the DOM
if (placeholder) {
placeholder.remove();
}
}
function elementWidth() {
return nativeEl.offsetWidth;
}
function elementHeight() {
return nativeEl.offsetHeight;
}
function _getTopOffset(element) {
var pixels = 0;
if (element && element.offsetParent) {
do {
pixels += element.offsetTop;
element = element.offsetParent;
} while (element);
}
return pixels;
}
function _getBottomOffset (element) {
return _getTopOffset(element) + element.clientHeight;
}
function _stackOffset(anchor) {
var extraOffset = 0;
if (anchor === 'top' && globalOffset.top > 0) {
extraOffset += globalOffset.top;
}
if (anchor === 'bottom' && globalOffset.bottom > 0) {
extraOffset += globalOffset.bottom;
}
if (stack) {
var stickIndex = stack.index(id);
if (anchor === 'top') {
if (stickIndex > 0) {
// @todo the stack range calculation should be diverted to the stack
stack.range(0, stickIndex).forEach(function (stick) {
if (stick.isSticky()) {
extraOffset += stick.computedHeight(anchor);
}
});
}
}
if (anchor === 'bottom') {
if (stickIndex !== stack.length() - 1) {
// @todo the stack range calculation should be diverted to the stack
stack.range(stickIndex + 1, stack.length()).forEach(function (stick) {
if (stick.isSticky()) {
extraOffset += stick.computedHeight(anchor);
}
});
}
}
}
return extraOffset;
}
function _stackOffsetTop() { return _stackOffset('top'); }
function _stackOffsetBottom() { return _stackOffset('bottom'); }
function computedHeight(anchor, scrolledDistance) {
if (anchor === 'top') {
return Math.max(0, elementHeight() - containerBoundsBottom(scrolledDistance) + offsetTop);
}
else if (anchor === 'bottom') {
return Math.max(0, elementHeight() - containerBoundsTop(scrolledDistance) + offsetBottom);
}
return 0;
}
// @todo dffgdg
function containerBoundsTop(scrolledDistance) {
if (container === null) {
container = options.container !== undefined ? angular.isString(options.container) ? angular.element(documentEl.querySelector('#' + options.container))[0] : options.container : false;
}
if (container) {
var hasScrollDistance = !(scrolledDistance === null || scrolledDistance === undefined);
var containerRect = container.getBoundingClientRect();
var containerBottom = !hasScrollDistance ? containerRect.top - window.innerHeight + elementHeight() : (_getTopOffset(container) + containerRect.height) - scrolledDistance;
return Math.max(0, containerBottom - (offsetTop + _stackOffset(anchor)));
}
return 0;
}
function containerBoundsBottom(scrolledDistance) {
if (container === null) {
container = options.container !== undefined ? angular.isString(options.container) ? angular.element(documentEl.querySelector('#' + options.container))[0] : options.container : false;
}
if (container) {
var hasScrollDistance = !(scrolledDistance === null || scrolledDistance === undefined);
var containerRect = container.getBoundingClientRect();
var containerBottom = !hasScrollDistance ? containerRect.bottom : (_getTopOffset(container) + containerRect.height) - scrolledDistance;
return Math.max(0, (offsetTop + _stackOffset(anchor) + elementHeight()) - containerBottom);
}
return 0;
}
var $api = {};
if (stack) {
// add element to the sticky stack and save the id
var stackItem = stack.add(id, $api);
id = stackItem.id;
}
$api.draw = function(drawOptions) {
drawOptions = drawOptions || {};
var offset = drawOptions.offset;
if (offset) {
// setting global offsets added to the local offsets of the sticky element
globalOffset.top = offset.top || 0;
globalOffset.bottom = offset.bottom || 0;
globalOffset.zIndex = offset.zIndex;
}
// for resizing or other purposes that require a forced re-draw, we simply un-stick the element and re-stick it using the render method
if (drawOptions.force === true) {
unstickElement();
}
render();
};
$api.anchor = function() {
return anchor;
};
$api.isSticky = isSticky;
$api.isEnabled = isEnabled;
$api.computedHeight = computedHeight;
$api.sticksAtPosition = sticksAtPosition;
$api.destroy = function() {
unstickElement();
if (stack) {
stack.remove(id);
}
};
return $api;
};
}])
.constant('DefaultStickyStackName', 'default-stack')
.provider('hlStickyElementCollection', function() {
var $$count = 0;
var $stickyElement = {
collections: {},
defaults: {
checkDelay: 250
},
elementsDefaults: {
},
$get: ["$rootScope", "$window", "$document", "$log", "DefaultStickyStackName", "hlStickyElement", "hlStickyStack", "throttle", function($rootScope, $window, $document, $log, DefaultStickyStackName, hlStickyElement, hlStickyStack, throttle) {
var windowEl = angular.element($window);
var unbindViewContentLoaded;
var unbindIncludeContentLoaded;
var throttledResize;
function init() {
$$count++;
// make sure we can initialize it only once
if ($$count > 1) {
return;
}
// bind events
throttledResize = throttle(resize, $stickyElement.defaults.checkDelay, {leading: false});
windowEl.on('resize', throttledResize);
windowEl.on('scroll', drawEvent);
unbindViewContentLoaded = $rootScope.$on('$viewContentLoaded', throttledResize);
unbindIncludeContentLoaded = $rootScope.$on('$includeContentLoaded', throttledResize);
throttledResize();
}
function destroy() {
// check internal references counter
$$count--;
if ($$count > 0) {
return;
}
// unbind events
windowEl.off('resize', throttledResize);
windowEl.off('scroll', drawEvent);
unbindViewContentLoaded();
unbindIncludeContentLoaded();
}
function drawEvent() {
draw();
}
function resize() {
draw({force: true});
}
function draw(drawOptions) {
angular.forEach($stickyElement.collections, function(collection) {
collection.draw(drawOptions);
});
}
function stickyElementFactory(options) {
if (!options || !angular.isObject(options)) {
$log.warn('Must supply an options object');
options = {};
}
options = angular.extend({}, $stickyElement.elementsDefaults, options);
var collectionName = options.name || DefaultStickyStackName;
// use existing element collection
if ($stickyElement.collections[collectionName]) {
return $stickyElement.collections[collectionName];
}
var stickyStackFactory = hlStickyStack({
name: collectionName
});
var trackedElements = [];
var $sticky = {};
$sticky.addElement = function (element, stickyOptions) {
stickyOptions = stickyOptions || {};
stickyOptions.stack = stickyStackFactory;
var sticky = hlStickyElement(element, stickyOptions);
trackedElements.push({
stickyElement: sticky,
element: element
});
return sticky;
};
$sticky.removeElement = function(element) {
var toDelete;
for (var i = trackedElements.length; i--;) {
if ((angular.isString(element) && '#' + trackedElements[i].element.id === element) || trackedElements[i].element === element) {
toDelete = i;
break;
}
}
var deletedElement = trackedElements.splice(toDelete, 1)[0];
if (deletedElement) {
deletedElement.stickyElement.destroy();
}
return deletedElement;
};
$sticky.draw = function(drawOptions) {
var _drawOptions = {};
if (options.parent) {
var parentStack = hlStickyStack({
name: options.parent
});
_drawOptions.offset = {
top: parentStack.heightCurrent('top'),
zIndex: parentStack.length()
};
}
angular.extend(_drawOptions, drawOptions || {});
angular.forEach(trackedElements, function(element) {
element.stickyElement.draw(_drawOptions);
});
};
$sticky.destroy = function() {
angular.forEach(angular.copy(trackedElements), function(element) {
$sticky.removeElement(element);
});
delete $stickyElement.collections[collectionName];
destroy();
};
$sticky.trackedElements = function() {
return trackedElements;
};
// use new element collection
$stickyElement.collections[collectionName] = $sticky;
init();
return $sticky;
}
return stickyElementFactory;
}]
};
return $stickyElement;
})
.directive('hlSticky', ["$log", "$window", "$document", "hlStickyElementCollection", function($log, $window, $document, hlStickyElementCollection) {
return {
restrict: 'A',
scope: {
container: '@',
anchor: '@',
stickyClass: '@',
mediaQuery: '@',
collection: '@',
collectionParent: '@',
event: '&',
enable: '=',
alwaysSticky: '='
},
link: function($scope, $element, $attrs) {
$element.addClass('hl-sticky');
var stickyElementCollection = hlStickyElementCollection({
name: $scope.collection,
parent: $scope.collectionParent
});
var options = {
id: $attrs.hlSticky,
event: function(event) {
$scope.event({
event: event
})
}
};
angular.forEach(['anchor', 'container', 'stickyClass', 'mediaQuery', 'enable', 'alwaysSticky'], function(option) {
if (angular.isDefined($scope[option])) {
options[option] = $scope[option];
}
});
angular.forEach(['usePlaceholder', 'offsetTop', 'offsetBottom'], function(option) {
if (angular.isDefined($attrs[option])) {
options[option] = $scope.$parent.$eval($attrs[option]);
}
});
stickyElementCollection.addElement($element, options);
// listeners
$scope.$watch('enable', function (newValue, oldValue) {
if (newValue !== oldValue) {
options.enable = $scope.enable;
stickyElementCollection.draw({force: true});
}
});
$scope.$watch('alwaysSticky', function (newValue, oldValue) {
if (newValue !== oldValue) {
options.alwaysSticky = $scope.alwaysSticky;
stickyElementCollection.draw({force: true});
}
});
$scope.$on('$destroy', function onDestroy() {
stickyElementCollection.removeElement($element);
if (!stickyElementCollection.trackedElements().length) {
stickyElementCollection.destroy();
}
});
}
};
}])
.factory('throttle', ["$timeout", function($timeout) {
return function(func, wait, options) {
var timeout = null;
options = options || {};
return function() {
var that = this;
var args = arguments;
if (!timeout) {
if (options.leading !== false) {
func.apply(that, args);
}
timeout = $timeout(function later() {
timeout = null;
if (options.trailing !== false) {
func.apply(that, args);
}
}, wait, false);
}
};
};
}]);