UNPKG

postpone

Version:

A polyfill for postponing the loading of media.

523 lines (467 loc) 19.5 kB
(function ( root, factory ) { if ( typeof define === "function" && define.amd ) { define( [], factory ); } else if ( typeof exports === "object" ) { module.exports = factory(); } else { root.Postpone = factory(); } }(this, function() { "use strict"; /** * Creates a new Postpone instance. * @constructor * @param {number|string} [threshold] - The distance from an edge at which an * element should be considered to be at the edge. */ var Postpone = function( threshold ) { if ( !( this instanceof Postpone ) ) return new Postpone( threshold ); /** * The init method for Postpone gets the object running. It runs * postpone.postpone() to attach scroll event handlers and check if any * elements are already visible. Then, init will start the watch process. * @returns this */ this.init = function( threshold ) { /** * @property {string} tags - A list of all the tags for which postpone * will work; */ this.tags = "audio, embed, iframe, img, image, object, picture, use, video, tref"; /** * @property {array} elements - An array of all the postponed elements in the document. */ this.elements = []; /** * @property {array} visible - An array of all the non-hidden postponed * elements in the document. */ this.elements.visible = []; /** * @property {object} scrollElements - A variable to keep track of the * elements with respoect to which the postponed elements scroll. */ this.scrollElements = []; this.setThreshold( threshold ); /** Call method to start looking for postponed media. */ return this.start(); }; return this.init( threshold ); }; /** * The main postpone method. This method iterates over all the elements with * a `postpone` attribute and links them to a scroll event so that they are not * loaded until they become visible. * @returns this */ Postpone.prototype.postpone = function() { /** * Remove any previous event handlers so they can be reattached for new * postponed elements without duplicating old ones. This must be done * before updating the scroll elements so the references to the event * callbacks still exist. */ this.unbindEvents(); /** * Update the elements and scroll elements to properly load or postpone * them. */ this.getElements(); this.getScrollElements(); /** * If any of the postponed elements should be visible to begin with, * load them. */ for ( var id in this.scrollElements ) { for ( var i = 0, element = {}; i < this.scrollElements[ id ].length; i++ ) { element = this.scrollElements[ id ][ i ]; if ( this.isInViewport( element, this.scrollElements[ id ].element ) ) { this.load( element ); } } } if ( this.elements.length ) { /** Attach scroll event listeners. */ this.bindEvents(); } return this; }; /** * A helper method to unbind the scroll event callbacks. * @returns this */ Postpone.prototype.unbindEvents = function() { for ( var id in this.scrollElements ) { this._removeEventListener( id === "window" ? window : this.scrollElements[ id ].element, this.scrollElements[ id ].callback ); } }; /** * A helper method to bind the scroll event callbacks. * @returns this */ Postpone.prototype.bindEvents = function() { for ( var id in this.scrollElements ) { this.scrollElements[ id ].callback = Function.prototype.bind ? this.scrollHandler.bind( this ) : function( _this ) { return function() { return _this.scrollHandler.apply( _this, arguments ); }; }( this ); this._addEventListener( id === "window" ? window : this.scrollElements[ id ].element, this.scrollElements[ id ].callback ); } return this; }; /** * A helper method to find all of the elements with a postponed attribute. * @returns {Boolean} A boolean stating whether new postpone elements have been * found. */ Postpone.prototype.getElements = function() { var elements = [], visible = [], matches = this._slice( document.querySelectorAll( this.tags ) ), postpone = null, change = false; for ( var i = 0; i < matches.length; i++ ) { postpone = matches[ i ].getAttribute( "postpone" ); if ( typeof postpone === "string" && postpone !== "false" ) { elements.push( matches[ i ] ); if ( this.isVisible( matches[ i ] ) ) { visible.push( matches[ i ] ); /** Check if this element is not already postponed. */ if ( matches[ i ] !== this.elements.visible[ visible.length - 1 ] ) { change = true; } } } } /** Check if old postponed elements are no longer on the page. */ if ( this.elements.visible.length !== visible.length ) { change = true; } this.elements = elements; this.elements.visible = visible; return change; }; /** * A helper method to find all of the elements with respect to which * postponed elements scroll. The elements are stored with a unique ID as * their key. * @returns {object} A hash with arrays of postponed elements associated with * IDs of their scroll elements. */ Postpone.prototype.getScrollElements = function() { this.scrollElements = {}; var id = "", element = {}, scrollElement = {}; for ( var i = 0; i < this.elements.visible.length; i++ ) { element = this.elements.visible[ i ]; /** * Find the element relative to which the postponed element's * position should be calculated. */ if ( element.getAttribute( "data-scroll-element" ) ) { scrollElement = document.querySelector( element.getAttribute( "data-scroll-element" ) ); /** * If the scroll element does not have an ID, generate one and * assign it as a data attribute. */ id = scrollElement.getAttribute( "data-id" ); if ( !id ) { scrollElement.setAttribute( "data-id", id = new Date().getTime() ); } /** * If the element does not have a scroll element specified then * assume its position should be calculated relative to the window. */ } else { scrollElement = "window"; id = "window"; } /** * If the array already has this id as a key, then add the current * element to the array in its value, otherwise create a new key. */ if ( this.scrollElements[ id ] ) { this.scrollElements[ id ].push( element ); } else { this.scrollElements[ id ] = [ element ]; this.scrollElements[ id ].element = scrollElement; } } return this.scrollElements; }; /** * Method to watch the document for new postponed elements. * @returns this */ Postpone.prototype.watch = function() { /** * Refresh the array of postponed elements, this.elements. If the postponed * elements have changed, then process them. */ if ( this.getElements() ) { this.postpone(); } /** * This timeout calls the watch method every 500ms. In other words, * postpone will look for new postponed elements twice a second. * @property {number} timeout - The ID for the current timeout. */ this.timeout = window.setTimeout( (function( _this ) { return function() { return _this.watch(); }; })( this ), 500); return this; }; /** * Method to start watching for elements that should postponed. * @returns this */ Postpone.prototype.start = function() { /** Ensure that watching has stopped before starting to watch. */ if ( this.timeout ) this.stop(); /** * Call `postpone` to ensure events are bound and items in view are * loaded. */ this.postpone(); /** Start watching. */ this.watch(); return this; }; /** * Method to stop watching for elements that should postponed and unbind events * associated with postponed elements. * @returns this */ Postpone.prototype.stop = function() { if ( this.timeout ) window.clearTimeout( this.timeout ); /* Unbind the scroll events associated with postponed elements. */ this.unbindEvents(); return this; }; /** * This method defines the scroll event handler used to test if postponed * elementes are visible. * @param {object} e - Event object. * @returns this */ Postpone.prototype.scrollHandler = function( e ) { var scrollElement = e.srcElement || e.target || window.document, elements = this.scrollElements[ scrollElement === window.document ? scrollElement = "window" : scrollElement.getAttribute( "data-id" ) ], element = {}, scrolledIntoView = false; for ( var i = 0; i < elements.length; i++ ) { element = elements[ i ]; /** * If an element is visible then we no longer need to postpone it * and can download it. */ if ( this.isInViewport( element, scrollElement ) ) { this.load( element ); } } return this; }; /** * A convenience method to easily set the threshold property of postpone. * @param {number|string} threshold - The distance from an edge at which an * element should be considered to be at the edge. * @returns this */ Postpone.prototype.setThreshold = function( threshold ) { threshold = threshold ? threshold : 0; /** * @property {object} threshold - A hash containing the value and unit of * measurement of the desired postpone threshold. */ this.threshold = {}; /** * @property {number} value - The number of units from an edge at * which an element should be considered to be at the edge. This is * useful to start loading images or other resources before they scroll * into view to prevent flash of content. */ this.threshold.value = parseInt( threshold, 10 ); /** * @property {string} unit - The unit of measurement for the threshold * value. Currently, only `vh` and `px` are supported. By default, the unit * is `vh`. */ this.threshold.unit = ( typeof threshold === "number" ) ? "vh" : ( threshold.match(/[a-zA-Z]+/)[ 0 ] || "vh" ); return this; }; /** * Small helper method to find the total vertical offset of an element. * @param {object} el - The element we wish to locate. * @returns {number} The total vertical offset of the element. */ Postpone.prototype.offsetTop = function( el ) { var temp = el, o = 0; /** Iterate over all parents of el up to body to find the vertical offset. */ while ( temp && temp.tagName.toLowerCase() !== "body" && temp.tagName.toLowerCase() !== "html" ) { o += temp.offsetTop; temp = temp.offsetParent; } return o; }; /** * Small helper method to determine if an element is visually hidden or not. * This method check if the element provided, or any of its parents have the * style `display: none;`. * @param {object} el - The element we wish to locate. * @returns {boolean} Returns true if the element is visible and false if it is * hidden. */ Postpone.prototype.isVisible = function( el ) { var temp = el, isVisible = true; /** * Iterate over all parents of el up to HTML to find if el or a parent is * hidden. */ while ( temp && temp.parentElement && isVisible ) { isVisible = temp.currentStyle ? temp.currentStyle.display !== "none" : document.defaultView.getComputedStyle( temp ).getPropertyValue( "display" ) !== "none"; temp = temp.parentElement; } return isVisible; }; /** * Helper method to determine if an element is in the browser's viewport. * @param {object} el - The element we wish to test. * @param {object} [scrollElement] - The element with respect to which `el` scrolls. * @returns {boolean} Return true if the `el` is in view and false if it is not. */ Postpone.prototype.isInViewport = function( el, scrollElement ) { /** If no scroll element is specified, then assume the scroll element is the window. */ scrollElement = scrollElement ? scrollElement : "window"; if ( scrollElement === "window" ) { scrollElement = document.documentElement.scrollTop ? document.documentElement : document.body; } /** Use clientHeight instead of window.innerHeight for compatability with ie8. */ var viewPortHeight = document.documentElement.clientHeight, top = this.offsetTop( el ), scrollHeight = scrollElement.scrollTop + this.offsetTop( scrollElement ), isHighEnough = false, isLowEnough = false, threshold = 0; if ( this.threshold.unit === "vh" ) { threshold = viewPortHeight * this.threshold.value / 100; } else if ( this.threshold.unit === "px" ) { threshold = this.threshold.value; } /** Check if element is above bottom of screen. */ isHighEnough = viewPortHeight + scrollHeight + threshold >= top; /** Check if element is below top of screen. */ isLowEnough = ( el.height || 0 ) + top + threshold >= scrollHeight; return isHighEnough && isLowEnough; }; /** * This method takes care of loading the media that should no longer be * postponed. * @param {object} el - The element that should be loaded. * @returns {object} The element that was loaded. */ Postpone.prototype.load = function( el ) { var child = {}, i = 0; el.removeAttribute( "postpone" ); /** If the element has a `data-src` attribute then copy it to `src`. */ if ( ~"audio, embed, iframe, img, picture, video".indexOf( el.tagName.toLowerCase() ) && el.getAttribute( "data-src" ) ) { el.setAttribute( "src", el.getAttribute( "data-src" ) ); } if ( ~"image, tref, use".indexOf( el.tagName.toLowerCase() ) && el.getAttribute( "data-xlink:href" ) ) { el.setAttribute( "xlink:href", el.getAttribute( "data-xlink:href" ) ); } else if ( ~"audio, video".indexOf( el.tagName.toLowerCase() ) && el.children.length ) { for ( i = 0; i < el.children.length; i++ ) { child = el.children[ i ]; if ( child.tagName.toLowerCase() === "source" && child.getAttribute( "data-src" ) ) { child.setAttribute( "src", child.getAttribute( "data-src" ) ); } } } else if ( el.tagName.toLowerCase() === "picture" && el.children.length ) { for ( i = 0; i < el.children.length; i++ ) { child = el.children[ i ]; if ( child.tagName.toLowerCase() === "source" ) { if ( child.getAttribute( "data-src" ) ) { child.setAttribute( "src", child.getAttribute( "data-src" ) ); } if ( child.getAttribute( "data-srcset" ) ) { child.setAttribute( "srcset", child.getAttribute( "data-srcset" ) ); } } } } else if ( el.tagName.toLowerCase() === "object" && el.getAttribute( "data-data" ) ) { el.setAttribute( "data", el.getAttribute( "data-data" ) ); /** * This is necessary to make Safari 7 refresh the object's new content. */ var activeElement = document.activeElement; el.focus(); if ( activeElement ) { activeElement.focus(); } } return el; }; /** * A helper method to convert array-like objects into arrays. * @param {object} arr - The object to be converted. * @returns {array} An array representation of the supplied object. * @api private */ Postpone.prototype._slice = function( object ) { /** Try to use `slice` to convert the object. */ try { return Array.prototype.slice.call( object ); /** * If that doesn't work, manually iterate over the object and convert * it to an array. */ } catch(e) { var array = []; for ( var i = 0; i < object.length; i++ ) { array.push( object[ i ] ); } return array; } }; /** * A helper method to abstract event listener creation. * @param {object} el - The element to which the event should be added. * @param {function} callback - The callback to be executed when the event * is fired. * @returns undefined * @api private */ Postpone.prototype._addEventListener = function( el, callback ) { /** Try to add the event using `addEventListener`. */ try { return el.addEventListener( "scroll", callback ); /** If that doesn't work, add the event using `attachEvent`. */ } catch(e) { return el.attachEvent( "onscroll", callback ); } }; /** * A helper method to abstract event listener removal. * @param {object} el - The element from which the event should be removed. * @param {function} callback - The callback to be executed when the event * is fired. * @returns undefined * @api private */ Postpone.prototype._removeEventListener = function( el, callback ) { /** Try to remove the event using `removeEventListener`. */ try { return el.removeEventListener( "scroll", callback ); /** If that doesn't work, remove the event using `detachEvent`. */ } catch(e) { return el.detachEvent( "onscroll", callback ); } }; /** Expose `Postpone`. */ return Postpone; }));