(function(root, factory) { if (typeof define === 'function' && define.amd) { define([], factory); } else if (typeof exports === 'object') { module.exports = factory(); } else { root.ScrollWatch = factory(); } }(this, function() { 'use strict'; // Give each instance on the page a unique ID var instanceId = 0; // Store instance data privately so it can't be accessed/modified var instanceData = {}; var config = { // The default container is window, but we need the actual documentElement to determine positioning. container: window.document.documentElement, watch: '[data-scroll-watch]', watchOnce: true, inViewClass: 'scroll-watch-in-view', ignoreClass: 'scroll-watch-ignore', debounce: false, debounceTriggerLeading: false, scrollDebounce: 250, resizeDebounce: 250, scrollThrottle: 250, resizeThrottle: 250, watchOffsetXLeft: 0, watchOffsetXRight: 0, watchOffsetYTop: 0, watchOffsetYBottom: 0, infiniteScroll: false, infiniteOffset: 0, onElementInView: function(){}, onElementOutOfView: function(){}, onInfiniteXInView: function(){}, onInfiniteYInView: function(){} }; var initEvent = 'scrollwatchinit'; var extend = function(retObj) { var len = arguments.length; var i; var key; var obj; retObj = retObj || {}; for (i = 1; i < len; i++) { obj = arguments[i]; if (!obj) { continue; } for (key in obj) { if (obj.hasOwnProperty(key)) { retObj[key] = obj[key]; } } } return retObj; }; var throttle = function (fn, threshhold, scope) { var last; var deferTimer; threshhold = threshhold || 250; return function () { var context = scope || this; var now = +new Date(); var args = arguments; if (last && now < last + threshhold) { window.clearTimeout(deferTimer); deferTimer = setTimeout(function () { last = now; fn.apply(context, args); }, threshhold); } else { last = now; fn.apply(context, args); } }; }; // http://underscorejs.org/#debounce var debounce = function(func, wait, immediate) { var timeout; var args; var context; var timestamp; var result; var later = function() { var last = new Date().getTime() - timestamp; if (last < wait && last >= 0) { timeout = setTimeout(later, wait - last); } else { timeout = null; if (!immediate) { result = func.apply(context, args); if (!timeout) { context = args = null; } } } }; return function() { var callNow = immediate && !timeout; context = this; args = arguments; timestamp = new Date().getTime(); if (!timeout) { timeout = setTimeout(later, wait); } if (callNow) { result = func.apply(context, args); context = args = null; } return result; }; }; // If a string was passed in as the container element, use it as a selector and query the DOM, otherwise we'll assume a DOM node was passed in var saveContainerElement = function() { var config = instanceData[this._id].config; if (typeof config.container === 'string') { // A selector was passed in for the container config.container = document.querySelector(config.container); } }; // Save all elements to watch into an array var saveElements = function() { instanceData[this._id].elements = Array.prototype.slice.call(document.querySelectorAll(instanceData[this._id].config.watch + ':not(.' + instanceData[this._id].config.ignoreClass + ')')); }; // Save the scroll position of the scrolling container so we can perform comparison checks var saveScrollPosition = function() { instanceData[this._id].lastScrollPosition = getScrollPosition.call(this); }; var checkViewport = function(eventType) { checkElements.call(this, eventType); checkInfinite.call(this, eventType); // Chrome does not return 0,0 for scroll position when reloading a page that was previously scrolled. To combat this, we will leave the scroll position at the default 0,0 when a page is first loaded. if (eventType !== initEvent) { saveScrollPosition.call(this); } }; // Determine if the watched elements are viewable within the scrolling container var checkElements = function(eventType) { var data = instanceData[this._id]; var len = data.elements.length; var config = data.config; var inViewClass = config.inViewClass; var responseData = { eventType: eventType }; var el; var i; for (i = 0; i < len; i++) { el = data.elements[i]; // Prepare the data to pass to the callback responseData.el = el; if (eventType === 'scroll') { responseData.direction = getScrolledDirection.call(this, getScrolledAxis.call(this)); } if (isElementInView.call(this, el)) { if (!el.classList.contains(inViewClass)) { // Add a class hook and fire a callback for every element that just came into view el.classList.add(inViewClass); config.onElementInView.call(this, responseData); if (config.watchOnce) { // Remove this element so we don't check it again next time data.elements.splice(i, 1); len--; i--; // Flag this element with the ignore class so we don't store it again if a refresh happens el.classList.add(config.ignoreClass); } } } else { if (el.classList.contains(inViewClass) || eventType === initEvent) { // Remove the class hook and fire a callback for every element that just went out of view el.classList.remove(inViewClass); config.onElementOutOfView.call(this, responseData); } } } }; // Determine if the infinite scroll zone is in view. This could come into view by scrolling or resizing. Initial load must also be accounted for. var checkInfinite = function(eventType) { var data = instanceData[this._id]; var config = data.config; var i; var axis; var container; var viewableRange; var scrollSize; var callback; var responseData; if (config.infiniteScroll && !data.isInfiniteScrollPaused) { axis = ['x', 'y']; callback = ['onInfiniteXInView', 'onInfiniteYInView']; container = config.container; viewableRange = getViewableRange.call(this); scrollSize = [container.scrollWidth, container.scrollHeight]; responseData = {}; for (i = 0; i < 2; i++) { // If a scroll event triggered this check, verify the scroll position actually changed for each axis. This stops horizontal scrolls from triggering infiniteY callbacks and vice versa. In other words, only trigger an infinite callback if that axis was actually scrolled. if ((eventType === 'scroll' && hasScrollPositionChanged.call(this, axis[i]) || eventType === 'resize'|| eventType === 'refresh' || eventType === initEvent) && viewableRange[axis[i]].end + config.infiniteOffset >= scrollSize[i]) { // We've scrolled/resized all the way to the right/bottom responseData.eventType = eventType; if (eventType === 'scroll') { responseData.direction = getScrolledDirection.call(this, axis[i]); } config[callback[i]].call(this, responseData); } } } }; // Add listeners to the scrolling container for each instance var addListeners = function() { var data = instanceData[this._id]; var scrollingElement = getScrollingElement.call(this); scrollingElement.addEventListener('scroll', data.scrollHandler, false); scrollingElement.addEventListener('resize', data.resizeHandler, false); }; var removeListeners = function() { var data = instanceData[this._id]; var scrollingElement = getScrollingElement.call(this); scrollingElement.removeEventListener('scroll', data.scrollHandler); scrollingElement.removeEventListener('resize', data.resizeHandler); }; var getScrollingElement = function() { return isContainerWindow.call(this) ? window : instanceData[this._id].config.container; }; // Get the width and height of viewport/scrolling container var getViewportSize = function() { var size = { w: instanceData[this._id].config.container.clientWidth, h: instanceData[this._id].config.container.clientHeight }; return size; }; // Get the scrollbar position of the scrolling container var getScrollPosition = function() { var pos = {}; var container; if (isContainerWindow.call(this)) { pos.left = window.pageXOffset; pos.top = window.pageYOffset; } else { container = instanceData[this._id].config.container; pos.left = container.scrollLeft; pos.top = container.scrollTop; } return pos; }; // Get the pixel range currently viewable within the scrolling container var getViewableRange = function() { var range = { x: {}, y: {} }; var scrollPos = getScrollPosition.call(this); var viewportSize = getViewportSize.call(this); range.x.start = scrollPos.left; range.x.end = range.x.start + viewportSize.w; range.x.size = range.x.end - range.x.start; range.y.start = scrollPos.top; range.y.end = range.y.start + viewportSize.h; range.y.size = range.y.end - range.y.start; return range; }; // Get the pixel range of where this element falls within the scrolling container var getElementRange = function(el) { var range = { x: {}, y: {} }; var viewableRange = getViewableRange.call(this); var coords = el.getBoundingClientRect(); var containerCoords; if (isContainerWindow.call(this)) { range.x.start = coords.left + viewableRange.x.start; range.x.end = coords.right + viewableRange.x.start; range.y.start = coords.top + viewableRange.y.start; range.y.end = coords.bottom + viewableRange.y.start; } else { containerCoords = instanceData[this._id].config.container.getBoundingClientRect(); range.x.start = (coords.left - containerCoords.left) + viewableRange.x.start; range.x.end = range.x.start + coords.width; range.y.start = (coords.top - containerCoords.top) + viewableRange.y.start; range.y.end = range.y.start + coords.height; } range.x.size = range.x.end - range.x.start; range.y.size = range.y.end - range.y.start; return range; }; // Determines which axis was just scrolled (x/horizontal or y/vertical) var getScrolledAxis = function() { if (hasScrollPositionChanged.call(this, 'x')) { return 'x'; } if (hasScrollPositionChanged.call(this, 'y')) { return 'y'; } }; var getScrolledDirection = function(axis) { var scrollDir = {x: ['right', 'left'], y: ['down', 'up']}; var position = {x: 'left', y: 'top'}; var lastScrollPosition = instanceData[this._id].lastScrollPosition; var curScrollPosition = getScrollPosition.call(this); return curScrollPosition[position[axis]] > lastScrollPosition[position[axis]] ? scrollDir[axis][0] : scrollDir[axis][1]; }; var hasScrollPositionChanged = function(axis) { var position = {x: 'left', y: 'top'}; var lastScrollPosition = instanceData[this._id].lastScrollPosition; var curScrollPosition = getScrollPosition.call(this); return curScrollPosition[position[axis]] !== lastScrollPosition[position[axis]]; }; var isElementInView = function(el) { var viewableRange = getViewableRange.call(this); var elRange = getElementRange.call(this, el); return isElementInVerticalView.call(this, elRange, viewableRange) && isElementInHorizontalView.call(this, elRange, viewableRange); }; var isElementInVerticalView = function(elRange, viewableRange) { var config = instanceData[this._id].config; return elRange.y.start < viewableRange.y.end + config.watchOffsetYBottom && elRange.y.end > viewableRange.y.start - config.watchOffsetYTop; }; var isElementInHorizontalView = function(elRange, viewableRange) { var config = instanceData[this._id].config; return elRange.x.start < viewableRange.x.end + config.watchOffsetXRight && elRange.x.end > viewableRange.x.start - config.watchOffsetXLeft; }; var isContainerWindow = function() { return instanceData[this._id].config.container === window.document.documentElement; }; var mergeOptions = function(opts) { extend(instanceData[this._id].config, config, opts); }; var handler = function(e) { var eventType = e.type; // Protect against the instance being destroyed while we still have queued or pending handler events (via @jsonk000) if (!instanceData[this._id]) { return; } // For scroll events, only check the viewport if something has changed. Fixes issues when using gestures on a page that doesn't need to scroll. An event would still fire, but the position didn't change because the window/container "bounced" back into place. if (eventType === 'resize' || hasScrollPositionChanged.call(this, 'x') || hasScrollPositionChanged.call(this, 'y')) { checkViewport.call(this, eventType); } }; var ScrollWatch = function(opts) { var data; // Protect against missing new keyword if (this instanceof ScrollWatch) { Object.defineProperty(this, '_id', {value: instanceId++}); // Keep all instance data private, except for the '_id', which will be the key to get the private data for a specific instance data = instanceData[this._id] = { config: {}, // The elements to watch for this instance elements: [], lastScrollPosition: {top: 0, left: 0}, isInfiniteScrollPaused: false }; mergeOptions.call(this, opts); // In order to remove listeners later and keep a correct reference to 'this', give each instance it's own event handler if (data.config.debounce) { data.scrollHandler = debounce(handler.bind(this), data.config.scrollDebounce, data.config.debounceTriggerLeading); data.resizeHandler = debounce(handler.bind(this), data.config.resizeDebounce, data.config.debounceTriggerLeading); } else { data.scrollHandler = throttle(handler.bind(this), data.config.scrollThrottle, this); data.resizeHandler = throttle(handler.bind(this), data.config.resizeThrottle, this); } saveContainerElement.call(this); addListeners.call(this); saveElements.call(this); checkViewport.call(this, initEvent); } else { return new ScrollWatch(opts); } }; ScrollWatch.prototype = { // Should be manually called by user after loading in new content refresh: function() { saveElements.call(this); checkViewport.call(this, 'refresh'); }, destroy: function() { removeListeners.call(this); delete instanceData[this._id]; }, updateWatchOffsetXLeft: function(offset) { instanceData[this._id].config.watchOffsetXLeft = offset; }, updateWatchOffsetXRight: function(offset) { instanceData[this._id].config.watchOffsetXRight = offset; }, updateWatchOffsetYTop: function(offset) { instanceData[this._id].config.watchOffsetYTop = offset; }, updateWatchOffsetYBottom: function(offset) { instanceData[this._id].config.watchOffsetYBottom = offset; }, pauseInfiniteScroll: function() { instanceData[this._id].isInfiniteScrollPaused = true; }, resumeInfiniteScroll: function() { instanceData[this._id].isInfiniteScrollPaused = false; } }; return ScrollWatch; }));