/** * Unveil2.js * A very lightweight jQuery plugin to lazy load images * Based on https://github.com/luis-almeida/unveil * * Licensed under the MIT license. * Copyright 2015 Joram van den Boezem * https://github.com/nabble/unveil2 */ (function(factory) { 'use strict'; if (typeof define === 'function' && define.amd) { define(['jquery'], factory); } else if (typeof exports !== 'undefined') { module.exports = factory(require('jquery')); } else { factory(jQuery); } }(function ($) { "use strict"; /** * # GLOBAL VARIABLES * --- */ /** * Store the string 'unveil' in a variable to save some bytes */ var unveilString = 'unveil', /** * jQuery event namespace */ unveilNamespace = '.' + unveilString, /** * Store the string 'src' in a variable to save some bytes */ srcString = 'src', /** * Store the string 'placeholder' in a variable to save some bytes */ placeholderString = 'placeholder'; /** * # PLUGIN * --- */ /** * @param {object} options An object of options, see API section in README * @returns {$} */ $.fn.unveil = function (options) { options = options || {}; // Initialize variables var $window = $(window), height = $window.height(), defaults = { // Public API placeholder: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', offset: 0, breakpoints: [], throttle: 250, debug: false, attribute: srcString, container: $window, // Undocumented retina: window.devicePixelRatio > 1, // Deprecated loading: null, loaded: null }, settings = $.extend(true, {}, defaults, options); if (settings.debug) console.log('Called unveil on', this.length, 'elements with the following options:', settings); /** * Sort sizes array, arrange highest minWidth to front of array */ settings.breakpoints.sort(function (a, b) { return b.minWidth - a.minWidth; }); var containerContext = settings.container.data(unveilString); if (!containerContext) { containerContext = { images: $(), initialized: false }; settings.container.data(unveilString, containerContext); } /** * # UNVEIL IMAGES * --- */ /** * This is the actual plugin logic, which determines the source attribute to use based on window width and presence of a retina screen, changes the source of the image, handles class name changes and triggers a callback if set. Once the image has been loaded, start the unveil lookup because the page layout could have changed. */ this.one(unveilString + unveilNamespace, function () { var i, $this = $(this), windowWidth = $window.width(), attrib = settings.attribute, targetSrc, defaultSrc, retinaSrc; // Determine attribute to extract source from for (i = 0; i < settings.breakpoints.length; i++) { var dataAttrib = settings.breakpoints[i].attribute.replace(/^data-/, ''); if (windowWidth >= settings.breakpoints[i].minWidth && $this.data(dataAttrib)) { attrib = dataAttrib; break; } } // Extract source defaultSrc = retinaSrc = $this.data(attrib); // Do we have a retina source? if (defaultSrc && defaultSrc.indexOf("|") !== -1) { retinaSrc = defaultSrc.split("|")[1]; defaultSrc = defaultSrc.split("|")[0]; } // Change attribute on image if (defaultSrc) { targetSrc = (settings.retina && retinaSrc) ? retinaSrc : defaultSrc; if (settings.debug) console.log('Unveiling image', { attribute: attrib, retina: settings.retina, defaultSrc: defaultSrc, retinaSrc: retinaSrc, targetSrc: targetSrc }); // Change classes $this.addClass(unveilString + '-loading'); // Fire up the callback if it's a function... if (typeof settings.loading === 'function') { settings.loading.call(this); } // ...and trigger custom event $this.trigger('loading' + unveilNamespace); // When new source has loaded, do stuff $this.one('load' + unveilNamespace, function () { // Change classes classLoaded($this); // Fire up the callback if it's a function... if (typeof settings.loaded === 'function') { settings.loaded.call(this); } // ...and trigger custom event $this.trigger('loaded' + unveilNamespace); // Loading the image may have modified page layout, // so unveil again lookup(); }); // Set new source if (this.nodeName === 'IMG') { $this.prop(srcString, targetSrc); } else { $('').attr(srcString, targetSrc).one('load' + unveilNamespace, function() { $(this).remove(); $this.css('backgroundImage', 'url(' + targetSrc + ')').trigger('load' + unveilNamespace); }); } // If the image has instantly loaded, change classes now if (this.complete) { classLoaded($this); } } }); this.one('destroy' + unveilNamespace, function () { $(this).off(unveilNamespace).removeData(unveilString); if (containerContext.images) { containerContext.images = containerContext.images.not(this); if (!containerContext.images.length) { destroyContainer(); } } }); /** * # HELPER FUNCTIONS * --- */ /** * Sets the classes when an image is done loading * * @param {object} $elm */ function classLoaded($elm) { $elm.removeClass(unveilString + '-' + placeholderString + ' ' + unveilString + '-loading'); $elm.addClass(unveilString + '-loaded'); } /** * Filter function which returns true when a given image is in the viewport. * * @returns {boolean} */ function inview() { // jshint validthis: true var $this = $(this); if ($this.is(':hidden')) { return; } var viewport = {top: 0 - settings.offset, bottom: $window.height() + settings.offset}, isCustomContainer = settings.container[0] !== $window[0], elementRect = this.getBoundingClientRect(); if (isCustomContainer) { var containerRect = settings.container[0].getBoundingClientRect(); if (contains(viewport, containerRect)) { var top = containerRect.top - settings.offset; var bottom = containerRect.bottom + settings.offset; var containerRectWithOffset = { top: top > viewport.top ? top : viewport.top, bottom: bottom < viewport.bottom ? bottom : viewport.bottom }; return contains(containerRectWithOffset, elementRect); } return false; } else { return contains(viewport, elementRect); } } /** * Whether `viewport` contains `rect` vertically */ function contains(viewport, rect) { return rect.bottom >= viewport.top && rect.top <= viewport.bottom; } /** * Sets the window height and calls the lookup function */ function resize() { height = $window.height(); lookup(); } /** * Throttle function with function call in tail. Based on http://sampsonblog.com/749/simple-throttle-function * * @param {function} callback * @returns {function} */ function throttle(callback) { var wait = false; // Initially, we're not waiting return function () { // We return a throttled function if (!wait) { // If we're not waiting wait = true; // Prevent future invocations setTimeout(function () { // After a period of time callback(); // Execute users function wait = false; // And allow future invocations }, settings.throttle); } }; } /** * # LOOKUP FUNCTION * --- */ /** * Function which filters images which are in view and triggers the unveil event on those images. */ function lookup() { if (settings.debug) console.log('Unveiling'); if (containerContext.images) { var batch = containerContext.images.filter(inview); batch.trigger(unveilString + unveilNamespace); containerContext.images = containerContext.images.not(batch); if (batch.length) { if (settings.debug) console.log('New images in view', batch.length, ', leaves', containerContext.length, 'in collection'); } } } function destroyContainer() { settings.container.off(unveilNamespace).removeData(unveilString); containerContext.images.off(unveilNamespace).removeData(unveilString); containerContext.initialized = false; containerContext.images = null; } /** * # BOOTSTRAPPING * --- */ /** * Read and reset the src attribute, to prevent loading of original images */ this.each(function () { var $this = $(this), elmPlaceholder = $this.data(srcString + '-' + placeholderString) || settings.placeholder; // If this element has been called before, // don't set placeholder now to prevent FOUI (Flash Of Ustyled Image) if (!$this.data(unveilString)) { // Add element to global array containerContext.images = $(containerContext.images).add(this); // Set the unveil flag $this.data(unveilString, true); // Set data-src if not set if (!$this.data(settings.attribute)) { $this.data(settings.attribute, $this.prop(settings.attribute)); } // Set placeholder $this .one('load' + unveilNamespace, function () { var $this = $(this); if ($this.hasClass(unveilString + '-loaded')) { return; } $this.addClass(unveilString + '-' + placeholderString); lookup(); }) .prop(srcString, '') .prop(srcString, elmPlaceholder); } }); if (settings.debug) console.log('Images now in collection', containerContext.images.length); /** * Bind global listeners */ {if (!containerContext.initialized) { settings.container .on('resize' + unveilNamespace, throttle(resize)) .on('scroll' + unveilNamespace, throttle(lookup)) .on('lookup' + unveilNamespace, lookup) .on('destroy' + unveilNamespace, destroyContainer); containerContext.initialized = true; }} /** * Wait a little bit for the placeholder to be set, so the src attribute is not empty (which will mark the image as hidden) */ {setTimeout(lookup, 0);} /** * That's all folks! */ return this; }; }));