// // Copyright Kamil Pękala http://github.com/kamilkp // Angular Virtual Scroll Repeat v1.0.0-rc13 2015/08/30 // (function(window, angular) { 'use strict'; /* jshint eqnull:true */ /* jshint -W038 */ // DESCRIPTION: // vsRepeat directive stands for Virtual Scroll Repeat. It turns a standard ngRepeated set of elements in a scrollable container // into a component, where the user thinks he has all the elements rendered and all he needs to do is scroll (without any kind of // pagination - which most users loath) and at the same time the browser isn't overloaded by that many elements/angular bindings etc. // The directive renders only so many elements that can fit into current container's clientHeight/clientWidth. // LIMITATIONS: // - current version only supports an Array as a right-hand-side object for ngRepeat // - all rendered elements must have the same height/width or the sizes of the elements must be known up front // USAGE: // In order to use the vsRepeat directive you need to place a vs-repeat attribute on a direct parent of an element with ng-repeat // example: //
//
// //
//
// // You can also measure the single element's height/width (including all paddings and margins), and then speficy it as a value // of the attribute 'vs-repeat'. This can be used if one wants to override the automatically computed element size. // example: //
//
// //
//
// // IMPORTANT! // // - the vsRepeat directive must be applied to a direct parent of an element with ngRepeat // - the value of vsRepeat attribute is the single element's height/width measured in pixels. If none provided, the directive // will compute it automatically // OPTIONAL PARAMETERS (attributes): // vs-scroll-parent="selector" - selector to the scrollable container. The directive will look for a closest parent matching // the given selector (defaults to the current element) // vs-horizontal - stack repeated elements horizontally instead of vertically // vs-offset-before="value" - top/left offset in pixels (defaults to 0) // vs-offset-after="value" - bottom/right offset in pixels (defaults to 0) // vs-excess="value" - an integer number representing the number of elements to be rendered outside of the current container's viewport // (defaults to 2) // vs-size-property - a property name of the items in collection that is a number denoting the element size (in pixels) // vs-autoresize - use this attribute without vs-size-property and without specifying element's size. The automatically computed element style will // readjust upon window resize if the size is dependable on the viewport size // EVENTS: // - 'vsRepeatTrigger' - an event the directive listens for to manually trigger reinitialization // - 'vsRepeatReinitialized' - an event the directive emits upon reinitialization done var isMacOS = navigator.appVersion.indexOf('Mac') != -1, wheelEventName = typeof window.onwheel !== 'undefined' ? 'wheel' : typeof window.onmousewheel !== 'undefined' ? 'mousewheel' : 'DOMMouseScroll', dde = document.documentElement, matchingFunction = dde.matches ? 'matches' : dde.matchesSelector ? 'matchesSelector' : dde.webkitMatches ? 'webkitMatches' : dde.webkitMatchesSelector ? 'webkitMatchesSelector' : dde.msMatches ? 'msMatches' : dde.msMatchesSelector ? 'msMatchesSelector' : dde.mozMatches ? 'mozMatches' : dde.mozMatchesSelector ? 'mozMatchesSelector' : null; var closestElement = angular.element.prototype.closest || function (selector) { var el = this[0].parentNode; while (el !== document.documentElement && el != null && !el[matchingFunction](selector)) { el = el.parentNode; } if (el && el[matchingFunction](selector)) { return angular.element(el); } else { return angular.element(); } }; function getWindowScroll() { if ('pageYOffset' in window) { return { scrollTop: pageYOffset, scrollLeft: pageXOffset }; } else { var sx, sy, d = document, r = d.documentElement, b = d.body; sx = r.scrollLeft || b.scrollLeft || 0; sy = r.scrollTop || b.scrollTop || 0; return { scrollTop: sy, scrollLeft: sx }; } } function getClientSize(element, sizeProp) { if (element === window) { return sizeProp === 'clientWidth' ? window.innerWidth : window.innerHeight; } else { return element[sizeProp]; } } function getScrollPos(element, scrollProp) { return element === window ? getWindowScroll()[scrollProp] : element[scrollProp]; } function getScrollOffset(vsElement, scrollElement, isHorizontal) { var vsPos = vsElement.getBoundingClientRect()[isHorizontal ? 'left' : 'top']; var scrollPos = scrollElement === window ? 0 : scrollElement.getBoundingClientRect()[isHorizontal ? 'left' : 'top']; var correction = vsPos - scrollPos + (scrollElement === window ? getWindowScroll() : scrollElement)[isHorizontal ? 'scrollLeft' : 'scrollTop']; return correction; } var vsRepeatModule = angular.module('vs-repeat', []).directive('vsRepeat', ['$compile', function($compile) { return { restrict: 'A', scope: true, require: '?^vsRepeat', controller: ['$scope', function($scope) { this.$scrollParent = $scope.$scrollParent; this.$fillElement = $scope.$fillElement; }], compile: function($element) { var ngRepeatChild = $element.children().eq(0), ngRepeatExpression = ngRepeatChild.attr('ng-repeat') || ngRepeatChild.attr('data-ng-repeat'), childCloneHtml = ngRepeatChild[0].outerHTML, expressionMatches = /^\s*(\S+)\s+in\s+([\S\s]+?)(track\s+by\s+\S+)?$/.exec(ngRepeatExpression), lhs = expressionMatches[1], rhs = expressionMatches[2], rhsSuffix = expressionMatches[3], collectionName = '$vs_collection', attributesDictionary = { 'vsRepeat': 'elementSize', 'vsOffsetBefore': 'offsetBefore', 'vsOffsetAfter': 'offsetAfter', 'vsExcess': 'excess' }; $element.empty(); if (!window.getComputedStyle || window.getComputedStyle($element[0]).position !== 'absolute') { $element.css('position', 'relative'); } return { pre: function($scope, $element, $attrs, $ctrl) { var childClone = angular.element(childCloneHtml), originalCollection = [], originalLength, $$horizontal = typeof $attrs.vsHorizontal !== 'undefined', $wheelHelper, $fillElement, autoSize = !$attrs.vsRepeat, sizesPropertyExists = !!$attrs.vsSize || !!$attrs.vsSizeProperty, $scrollParent = $attrs.vsScrollParent ? $attrs.vsScrollParent === 'window' ? angular.element(window) : closestElement.call($element, $attrs.vsScrollParent) : $element, positioningProperty = $$horizontal ? 'left' : 'top', localScrollTrigger = false, $$options = 'vsOptions' in $attrs ? $scope.$eval($attrs.vsOptions) : {}, clientSize = $$horizontal ? 'clientWidth' : 'clientHeight', offsetSize = $$horizontal ? 'offsetWidth' : 'offsetHeight', scrollPos = $$horizontal ? 'scrollLeft' : 'scrollTop'; if (!('vsSize' in $attrs) && 'vsSizeProperty' in $attrs) { console.warn('vs-size-property attribute is deprecated. Please use vs-size attrubute which also accepts angular expressions.'); } if ($scrollParent.length === 0) { throw 'Specified scroll parent selector did not match any element'; } $scope.$scrollParent = $scrollParent; if (sizesPropertyExists) { $scope.sizesCumulative = []; } //initial defaults $scope.elementSize = (+$attrs.vsRepeat) || getClientSize($scrollParent[0], clientSize) || 50; $scope.offsetBefore = 0; $scope.offsetAfter = 0; $scope.excess = 2; $scope.scrollSettings = { scrollIndex: 0, scrollIndexPosition: 'top' }; $scope.$watch($attrs.vsScrollSettings, function(newValue) { if (typeof newValue === 'undefined') { return; } $scope.scrollSettings = newValue; reinitialize($scope.scrollSettings); }, true); Object.keys(attributesDictionary).forEach(function(key) { if ($attrs[key]) { $attrs.$observe(key, function(value) { // '+' serves for getting a number from the string as the attributes are always strings $scope[attributesDictionary[key]] = +value; reinitialize(); }); } }); $scope.$watchCollection(rhs, function(coll) { originalCollection = coll || []; refresh(); }); function refresh(event, data) { if (!originalCollection || originalCollection.length < 1) { $scope[collectionName] = []; originalLength = 0; resizeFillElement(0); $scope.sizesCumulative = [0]; return; } else { originalLength = originalCollection.length; if (sizesPropertyExists) { $scope.sizes = originalCollection.map(function(item) { var s = $scope.$new(false); angular.extend(s, item); s[lhs] = item; var size = ($attrs.vsSize || $attrs.vsSizeProperty) ? s.$eval($attrs.vsSize || $attrs.vsSizeProperty) : $scope.elementSize; s.$destroy(); return size; }); var sum = 0; $scope.sizesCumulative = $scope.sizes.map(function(size) { var res = sum; sum += size; return res; }); $scope.sizesCumulative.push(sum); } else { setAutoSize(); } } reinitialize(data); } function setAutoSize() { if (autoSize) { $scope.$$postDigest(function() { if ($element[0].offsetHeight || $element[0].offsetWidth) { // element is visible var children = $element.children(), i = 0; while (i < children.length) { if (children[i].attributes['ng-repeat'] != null || children[i].attributes['data-ng-repeat'] != null) { if (children[i][offsetSize]) { $scope.elementSize = children[i][offsetSize]; reinitialize(); autoSize = false; if ($scope.$root && !$scope.$root.$$phase) { $scope.$apply(); } } break; } i++; } } else { var dereg = $scope.$watch(function() { if ($element[0].offsetHeight || $element[0].offsetWidth) { dereg(); setAutoSize(); } }); } }); } } childClone.attr('ng-repeat', lhs + ' in ' + collectionName + (rhsSuffix ? ' ' + rhsSuffix : '')) .addClass('vs-repeat-repeated-element'); var offsetCalculationString = sizesPropertyExists ? '(sizesCumulative[$index + startIndex] + offsetBefore)' : '(($index + startIndex) * elementSize + offsetBefore)'; childClone.attr('vs-set-offset', offsetCalculationString); childClone.attr('vs-set-offset-positioning-property', positioningProperty); $compile(childClone)($scope); $element.append(childClone); $fillElement = angular.element('
') .css({ 'position': 'relative', 'min-height': '100%', 'min-width': '100%' }); $element.append($fillElement); $compile($fillElement)($scope); $scope.$fillElement = $fillElement; var _prevMouse = {}; if (isMacOS && $attrs.vsScrollParent !== 'window') { $wheelHelper = angular.element('
') .on(wheelEventName, function(e) { e.preventDefault(); e.stopPropagation(); if (e.originalEvent) { e = e.originalEvent; } $scrollParent[0].scrollLeft += (e.deltaX || -e.wheelDeltaX); $scrollParent[0].scrollTop += (e.deltaY || -e.wheelDeltaY); }).on('mousemove', function(e) { if (_prevMouse.x !== e.clientX || _prevMouse.y !== e.clientY) { angular.element(this).css('display', 'none'); } _prevMouse = { x: e.clientX, y: e.clientY }; }).css('display', 'none'); $fillElement.append($wheelHelper); } $scope.startIndex = 0; $scope.endIndex = 0; $scrollParent.on('scroll', function scrollHandler() { // Check if the scrolling was triggerred by a local action to avoid // unnecessary inner collection updating if (localScrollTrigger) { localScrollTrigger = false; } else { if (updateInnerCollection()) { $scope.$apply(); $scope.$broadcast('vsSetOffset-refresh'); } } }); if (isMacOS) { $scrollParent.on(wheelEventName, wheelHandler); } function wheelHandler(e) { var elem = e.currentTarget; if (elem.scrollWidth > elem.clientWidth || elem.scrollHeight > elem.clientHeight) { $wheelHelper.css('display', 'block'); } } function onWindowResize() { if (typeof $attrs.vsAutoresize !== 'undefined') { autoSize = true; setAutoSize(); if ($scope.$root && !$scope.$root.$$phase) { $scope.$apply(); } } if (updateInnerCollection()) { $scope.$apply(); $scope.$broadcast('vsSetOffset-refresh'); } } angular.element(window).on('resize', onWindowResize); $scope.$on('$destroy', function() { angular.element(window).off('resize', onWindowResize); }); $scope.$on('vsRepeatTrigger', refresh); $scope.$on('vsRepeatResize', function() { autoSize = true; setAutoSize(); }); var _prevStartIndex, _prevEndIndex, _minStartIndex, _maxEndIndex; $scope.$on('vsRenderAll', function() {//e , quantum) { if($$options.latch) { setTimeout(function() { // var __endIndex = Math.min($scope.endIndex + (quantum || 1), originalLength); var __endIndex = originalLength; _maxEndIndex = Math.max(__endIndex, _maxEndIndex); $scope.endIndex = $$options.latch ? _maxEndIndex : __endIndex; $scope[collectionName] = originalCollection.slice($scope.startIndex, $scope.endIndex); _prevEndIndex = $scope.endIndex; $scope.$apply(function() { $scope.$emit('vsRenderAllDone'); }); }); } }); function reinitialize(data) { _prevStartIndex = void 0; _prevEndIndex = void 0; _minStartIndex = originalLength; _maxEndIndex = 0; updateInnerCollection(data); resizeFillElement(sizesPropertyExists ? $scope.sizesCumulative[originalLength] : $scope.elementSize * originalLength ); // Allow Angular to update ng-repeat $index values before syncing offsets: $scope.$evalAsync(function(){ $scope.$broadcast('vsSetOffset-refresh'); }); $scope.$emit('vsRepeatReinitialized', $scope.startIndex, $scope.endIndex); } function resizeFillElement(size) { if ($$horizontal) { $fillElement.css({ 'width': $scope.offsetBefore + size + $scope.offsetAfter + 'px', 'height': '100%' }); if ($ctrl && $ctrl.$fillElement) { var referenceElement = $ctrl.$fillElement[0].parentNode.querySelector('[ng-repeat]'); if (referenceElement) { $ctrl.$fillElement.css({ 'width': referenceElement.scrollWidth + 'px' }); } } } else { $fillElement.css({ 'height': $scope.offsetBefore + size + $scope.offsetAfter + 'px', 'width': '100%' }); if ($ctrl && $ctrl.$fillElement) { referenceElement = $ctrl.$fillElement[0].parentNode.querySelector('[ng-repeat]'); if (referenceElement) { $ctrl.$fillElement.css({ 'height': referenceElement.scrollHeight + 'px' }); } } } } var _prevClientSize; function reinitOnClientHeightChange() { var ch = getClientSize($scrollParent[0], clientSize); if (ch !== _prevClientSize) { reinitialize(); if ($scope.$root && !$scope.$root.$$phase) { $scope.$apply(); $scope.$broadcast('vsSetOffset-refresh'); } } _prevClientSize = ch; } $scope.$watch(function() { if (typeof window.requestAnimationFrame === 'function') { window.requestAnimationFrame(reinitOnClientHeightChange); } else { reinitOnClientHeightChange(); } }); // Scroll to required position // scrollTo - number of pixels to be scrolled to function scrollToPosition(scrollTo) { var scrolled = false; if (scrollTo !== undefined && (typeof scrollTo) === 'number') { // Set the position to be scrolled to scrolled = Math.max(scrollTo, 0); // Is there a scroll change? if ($scrollParent[0][scrollPos] !== scrolled) { $scrollParent[0][scrollPos] = scrolled; localScrollTrigger = true; } else { scrolled = false; } // Emit the event $scope.$emit('vsRepeatScrolled', scrolled); } return scrolled; } function updateInnerCollection(data) { var $scrollPosition = getScrollPos($scrollParent[0], scrollPos); var $clientSize = getClientSize($scrollParent[0], clientSize); var scrollOffset = $element[0] === $scrollParent[0] ? 0 : getScrollOffset( $element[0], $scrollParent[0], $$horizontal ); var scrollChange = true, position, visibleStartIndex, scrollIndexCumulativeSize, scrollIndexSize; var __startIndex = $scope.startIndex; var __endIndex = $scope.endIndex; if (data && data.elementSize !== undefined) { $scope.elementSize = data.elementSize; } if (data && data.scrollIndex !== undefined) { if (typeof $scope.scrollSettings !== 'undefined') { $scope.scrollSettings.scrollIndex = data.scrollIndex; } if (sizesPropertyExists) { scrollIndexSize = $scope.sizes[data.scrollIndex]; scrollIndexCumulativeSize = $scope.sizesCumulative[data.scrollIndex]; } else { scrollIndexSize = $scope.elementSize; scrollIndexCumulativeSize = data.scrollIndex * $scope.elementSize; } // Item scroll position relative to the view, i.e. position === 0 means the top of the view, // position === $clientSize means the bottom if (data.scrollIndexPosition !== undefined) { if (typeof $scope.scrollSettings !== 'undefined') { $scope.scrollSettings.scrollIndexPosition = data.scrollIndexPosition; } position = 0; switch (typeof data.scrollIndexPosition) { case 'number': position = data.scrollIndexPosition + $scope.offsetBefore; break; case 'string': switch (data.scrollIndexPosition) { case 'top': position = $scope.offsetBefore; break; case 'middle': position = ($clientSize - scrollIndexSize) / 2; break; case 'bottom': position = $clientSize - scrollIndexSize - $scope.offsetAfter; break; case 'inview': case 'inview#top': case 'inview#middle': case 'inview#bottom': case 'inview#auto': // The item is in the viewport, do nothing if ( ($scrollParent[0][scrollPos] <= (scrollIndexCumulativeSize)) && ($scrollParent[0][scrollPos] + $clientSize - scrollIndexSize >= scrollIndexCumulativeSize)) { scrollChange = false; // The current item scroll position position = scrollIndexCumulativeSize - $scrollParent[0][scrollPos]; } // The item is out of the viewport else { if (data.scrollIndexPosition === 'inview#top' || data.scrollIndexPosition === 'inview') { // Get it at the top position = $scope.offsetBefore; } if (data.scrollIndexPosition === 'inview#bottom') { // Get it at the bottom position = $clientSize - scrollIndexSize + $scope.offsetAfter; } if (data.scrollIndexPosition === 'inview#middle') { // Get it at the middle position = ($clientSize - scrollIndexSize) / 2; } if (data.scrollIndexPosition === 'inview#auto') { // Get it at the bottom or at the top, depending on what is closer if ($scrollParent[0][scrollPos] <= scrollIndexCumulativeSize) { position = $clientSize - scrollIndexSize + $scope.offsetAfter; } else { position = $scope.offsetBefore; } } } break; default: console.warn('Incorrect scrollIndexPosition string value'); break; } break; default: console.warn('Incorrect scrollIndexPosition type'); break; } } else { // The item is not required to be in the viewport, do nothing scrollChange = false; // The current item scroll position if (sizesPropertyExists) { position = $scope.sizesCumulative[data.scrollIndex] - $scrollParent[0][scrollPos]; } else { position = (data.scrollIndex * $scope.elementSize) - $scrollParent[0][scrollPos]; } } __startIndex = data.scrollIndex; if (sizesPropertyExists) { while ($scope.sizesCumulative[__startIndex] > $scope.sizesCumulative[data.scrollIndex] - position) { __startIndex--; } // The real first item in the view visibleStartIndex = Math.max(__startIndex, 0); // Adjust the start index according to the excess __startIndex = Math.max( Math.floor(__startIndex - ($scope.excess / 2)), 0 ); __endIndex = __startIndex; while ($scope.sizesCumulative[__endIndex] < $scope.sizesCumulative[visibleStartIndex] - $scope.offsetBefore + $clientSize) { __endIndex++; } // Adjust the end index according to the excess __endIndex = Math.min( Math.ceil(__endIndex + ($scope.excess / 2)), originalLength ); } else { while ((__startIndex * $scope.elementSize) > (data.scrollIndex * $scope.elementSize) - position) { __startIndex--; } // The real first item in the view visibleStartIndex = Math.max(__startIndex, 0); __startIndex = Math.max( Math.floor(__startIndex - ($scope.excess / 2)), 0 ); __endIndex = Math.min( __startIndex + Math.ceil($clientSize / $scope.elementSize) + $scope.excess / 2, originalLength ); } } else { if (sizesPropertyExists) { __startIndex = 0; while ($scope.sizesCumulative[__startIndex] < $scrollPosition - $scope.offsetBefore - scrollOffset) { __startIndex++; } if (__startIndex > 0) { __startIndex--; } // Adjust the start index according to the excess __startIndex = Math.max( Math.floor(__startIndex - $scope.excess / 2), 0 ); __endIndex = __startIndex; while ($scope.sizesCumulative[__endIndex] < $scrollPosition - $scope.offsetBefore - scrollOffset + $clientSize) { __endIndex++; } // Adjust the end index according to the excess __endIndex = Math.min( Math.ceil(__endIndex + $scope.excess / 2), originalLength ); } else { __startIndex = Math.max( Math.floor( ($scrollPosition - $scope.offsetBefore - scrollOffset) / $scope.elementSize ) - $scope.excess / 2, 0 ); __endIndex = Math.min( __startIndex + Math.ceil( $clientSize / $scope.elementSize ) + $scope.excess, originalLength ); } } _minStartIndex = Math.min(__startIndex, _minStartIndex); _maxEndIndex = Math.max(__endIndex, _maxEndIndex); $scope.startIndex = $$options.latch ? _minStartIndex : __startIndex; $scope.endIndex = $$options.latch ? _maxEndIndex : __endIndex; if (data !== undefined && data.scrollIndex !== undefined && position !== undefined && scrollChange) { // Scroll to the requested position scrollToPosition(scrollIndexCumulativeSize - position); } var digestRequired = false; if (_prevStartIndex == null) { digestRequired = true; } else if (_prevEndIndex == null) { digestRequired = true; } if (!digestRequired) { if ($$options.hunked) { if (Math.abs($scope.startIndex - _prevStartIndex) >= $scope.excess / 2 || ($scope.startIndex === 0 && _prevStartIndex !== 0)) { digestRequired = true; } else if (Math.abs($scope.endIndex - _prevEndIndex) >= $scope.excess / 2 || ($scope.endIndex === originalLength && _prevEndIndex !== originalLength)) { digestRequired = true; } } else { digestRequired = $scope.startIndex !== _prevStartIndex || $scope.endIndex !== _prevEndIndex; } } if (digestRequired) { $scope[collectionName] = originalCollection.slice($scope.startIndex, $scope.endIndex); // Emit the event $scope.$emit('vsRepeatInnerCollectionUpdated', $scope.startIndex, $scope.endIndex, _prevStartIndex, _prevEndIndex); _prevStartIndex = $scope.startIndex; _prevEndIndex = $scope.endIndex; } return digestRequired; } } }; } }; }]).directive('vsSetOffset', [function() { return function($scope, $element, $attrs) { var positioningProperty = $attrs.vsSetOffsetPositioningProperty; setOffset(); $scope.$on('vsSetOffset-refresh', setOffset); function setOffset() { $element.css(positioningProperty, $scope.$eval($attrs.vsSetOffset) + 'px'); } }; }]); angular.element(document.head).append([ '' ].join('')); if (typeof module !== 'undefined' && module.exports) { module.exports = vsRepeatModule.name; } })(window, window.angular);