UNPKG

angular-lazy-image

Version:

Loading responsive image when container (which is preventing reflow) is in view.

635 lines (479 loc) 23.1 kB
/* global angular */ angular.module('afkl.lazyImage', []); /* global angular */ angular.module('afkl.lazyImage') .service('afklSrcSetService', ['$window', function($window) { 'use strict'; /** * For other applications wanting the srccset/best image approach it is possible to use this module only * Loosely based on https://raw.github.com/borismus/srcset-polyfill/master/js/srcset-info.js */ var INT_REGEXP = /^[0-9]+$/; // SRCSET IMG OBJECT function ImageInfo(options) { this.src = options.src; this.w = options.w || Infinity; this.h = options.h || Infinity; this.x = options.x || 1; } /** * Parse srcset rules * @param {string} descString Containing all srcset rules * @return {object} Srcset rules */ var _parseDescriptors = function (descString) { var descriptors = descString.split(/\s/); var out = {}; for (var i = 0, l = descriptors.length; i < l; i++) { var desc = descriptors[i]; if (desc.length > 0) { var lastChar = desc.slice(-1); var value = desc.substring(0, desc.length - 1); var intVal = parseInt(value, 10); var floatVal = parseFloat(value); if (value.match(INT_REGEXP) && lastChar === 'w') { out[lastChar] = intVal; } else if (value.match(INT_REGEXP) && lastChar === 'h') { out[lastChar] = intVal; } else if (!isNaN(floatVal) && lastChar === 'x') { out[lastChar] = floatVal; } } } return out; }; /** * Returns best candidate under given circumstances * @param {object} images Candidate image * @param {function} criteriaFn Rule * @return {object} Returns best candidate under given criteria */ var _getBestCandidateIf = function (images, criteriaFn) { var bestCandidate = images[0]; for (var i = 0, l = images.length; i < l; i++) { var candidate = images[i]; if (criteriaFn(candidate, bestCandidate)) { bestCandidate = candidate; } } return bestCandidate; }; /** * Remove candidate under given circumstances * @param {object} images Candidate image * @param {function} criteriaFn Rule * @return {object} Removes images from global image collection (candidates) */ var _removeCandidatesIf = function (images, criteriaFn) { for (var i = images.length - 1; i >= 0; i--) { var candidate = images[i]; if (criteriaFn(candidate)) { images.splice(i, 1); // remove it } } return images; }; /** * Direct implementation of "processing the image candidates": * http://www.whatwg.org/specs/web-apps/current-work/multipage/embedded-content-1.html#processing-the-image-candidates * * @param {array} imageCandidates (required) * @param {object} view (optional) * @returns {ImageInfo} The best image of the possible candidates. */ var getBestImage = function (imageCandidates, view) { if (!imageCandidates) { return; } if (!view) { view = { 'w' : $window.innerWidth || document.documentElement.clientWidth, 'h' : $window.innerHeight || document.documentElement.clientHeight, 'x' : $window.devicePixelRatio || 1 }; } var images = imageCandidates.slice(0); /* LARGEST */ // Width var largestWidth = _getBestCandidateIf(images, function (a, b) { return a.w > b.w; }); // Less than client width. _removeCandidatesIf(images, (function () { return function (a) { return a.w < view.w; }; })(this)); // If none are left, keep the one with largest width. if (images.length === 0) { images = [largestWidth]; } // Height var largestHeight = _getBestCandidateIf(images, function (a, b) { return a.h > b.h; }); // Less than client height. _removeCandidatesIf(images, (function () { return function (a) { return a.h < view.h; }; })(this)); // If none are left, keep one with largest height. if (images.length === 0) { images = [largestHeight]; } // Pixel density. var largestPxDensity = _getBestCandidateIf(images, function (a, b) { return a.x > b.x; }); // Remove all candidates with pxdensity less than client pxdensity. _removeCandidatesIf(images, (function () { return function (a) { return a.x < view.x; }; })(this)); // If none are left, keep one with largest pixel density. if (images.length === 0) { images = [largestPxDensity]; } /* SMALLEST */ // Width var smallestWidth = _getBestCandidateIf(images, function (a, b) { return a.w < b.w; }); // Remove all candidates with width greater than it. _removeCandidatesIf(images, function (a) { return a.w > smallestWidth.w; }); // Height var smallestHeight = _getBestCandidateIf(images, function (a, b) { return a.h < b.h; }); // Remove all candidates with height greater than it. _removeCandidatesIf(images, function (a) { return a.h > smallestHeight.h; }); // Pixel density var smallestPxDensity = _getBestCandidateIf(images, function (a, b) { return a.x < b.x; }); // Remove all candidates with pixel density less than smallest px density. _removeCandidatesIf(images, function (a) { return a.x > smallestPxDensity.x; }); return images[0]; }; // options {src: null/string, srcset: string} // options.src normal url or null // options.srcset 997-s.jpg 480w, 997-m.jpg 768w, 997-xl.jpg 1x var getSrcset = function (options) { var imageCandidates = []; var srcValue = options.src; var srcsetValue = options.srcset; if (!srcsetValue) { return; } /* PUSH CANDIDATE [{src: _, x: _, w: _, h:_}, ...] */ var _addCandidate = function (img) { for (var j = 0, ln = imageCandidates.length; j < ln; j++) { var existingCandidate = imageCandidates[j]; // DUPLICATE if (existingCandidate.x === img.x && existingCandidate.w === img.w && existingCandidate.h === img.h) { return; } } imageCandidates.push(img); }; var _parse = function () { var input = srcsetValue, position = 0, rawCandidates = [], url, descriptors; while (input !== '') { while (input.charAt(0) === ' ') { input = input.slice(1); } position = input.indexOf(' '); if (position !== -1) { url = input.slice(0, position); // if (url === '') { break; } input = input.slice(position + 1); position = input.indexOf(','); if (position === -1) { descriptors = input; input = ''; } else { descriptors = input.slice(0, position); input = input.slice(position + 1); } rawCandidates.push({ url: url, descriptors: descriptors }); } else { rawCandidates.push({ url: input, descriptors: '' }); input = ''; } } // FROM RAW CANDIDATES PUSH IMAGES TO COMPLETE SET for (var i = 0, l = rawCandidates.length; i < l; i++) { var candidate = rawCandidates[i], desc = _parseDescriptors(candidate.descriptors); _addCandidate(new ImageInfo({ src: candidate.url, x: desc.x, w: desc.w, h: desc.h })); } if (srcValue) { _addCandidate(new ImageInfo({src: srcValue})); } }; _parse(); // Return best available image for current view based on our list of candidates var bestImage = getBestImage(imageCandidates); /** * Object returning best match at moment, and total collection of candidates (so 'image' API can be used by consumer) * @type {Object} */ var object = { 'best': bestImage, // IMAGE INFORMATION WHICH FITS BEST WHEN API IS REQUESTED 'candidates': imageCandidates // ALL IMAGE CANDIDATES BY GIVEN SRCSET ATTRIBUTES }; // empty collection imageCandidates = null; // pass best match and candidates return object; }; // throttle function to be used in directive function throttle(callback, delay) { var last, deferTimer; return function() { var now = +new Date(); if (last && now < last + delay) { clearTimeout(deferTimer); deferTimer = setTimeout(function () { last = now; callback(); }, delay + last - now); } else { last = now; callback(); } }; } /** * PUBLIC API */ return { get: getSrcset, // RETURNS BEST IMAGE AND IMAGE CANDIDATES image: getBestImage, // RETURNS BEST IMAGE WITH GIVEN CANDIDATES throttle: throttle // RETURNS A THROTTLER FUNCTION }; }]); /* global angular */ angular.module('afkl.lazyImage') .directive('afklImageContainer', function () { 'use strict'; return { restrict: 'A', // We have to use controller instead of link here so that it will always run earlier than nested afklLazyImage directives controller: ['$scope', '$element', function ($scope, $element) { $element.data('afklImageContainer', $element); }] }; }) .directive('afklLazyImage', ['$rootScope', '$window', '$timeout', 'afklSrcSetService', '$parse', function ($rootScope, $window, $timeout, srcSetService, $parse) { 'use strict'; // Use srcSetService to find out our best available image var bestImage = function (images) { var image = srcSetService.get({srcset: images}); var sourceUrl; if (image) { sourceUrl = image.best.src; } return sourceUrl; }; return { restrict: 'A', link: function (scope, element, attrs) { var _concatImgAttrs = function (imgAttrs) { var result = []; var CLASSNAME = 'afkl-lazy-image'; var setClass = false; if (!!options.imgAttrs) { result = Array.prototype.map.call(imgAttrs, function(item) { for (var key in item) { if (item.hasOwnProperty(key)) { // TODO: TITLE CAN COME LATER (FROM DATA MODEL) var value = item[key]; if (key === 'class') { setClass = true; value = value + ' ' + CLASSNAME; } return String.prototype.concat.call(key, '="', value, '"'); } } }); } if (!setClass) { result.push('class="' + CLASSNAME + '"'); } return result.join(' '); }; // CONFIGURATION VARS var $container = element.inheritedData('afklImageContainer'); if (!$container) { $container = angular.element(attrs.afklLazyImageContainer || $window); } var loaded = false; var timeout; var images = attrs.afklLazyImage; // srcset attributes var options = attrs.afklLazyImageOptions ? $parse(attrs.afklLazyImageOptions)(scope) : {}; // options (background, offset) var img = null; // Angular element to image which will be placed var currentImage = null; // current image url var offset = options.offset ? options.offset : 50; // default offset var imgAttrs = _concatImgAttrs(options.imgAttrs); // all image attributes like class, title, onerror var LOADING = 'afkl-lazy-image-loading'; attrs.afklLazyImageLoaded = false; var _containerScrollTop = function () { // See if we can use jQuery, with extra check // TODO: check if number is returned if ($container.scrollTop) { var scrollTopPosition = $container.scrollTop(); if (scrollTopPosition) { return scrollTopPosition; } } var c = $container[0]; if (c.pageYOffset !== undefined) { return c.pageYOffset; } else if (c.scrollTop !== undefined) { return c.scrollTop; } return document.documentElement.scrollTop || 0; }; var _containerInnerHeight = function () { if ($container.innerHeight) { return $container.innerHeight(); } var c = $container[0]; if (c.innerHeight !== undefined) { return c.innerHeight; } else if (c.clientHeight !== undefined) { return c.clientHeight; } return document.documentElement.clientHeight || 0; }; // Begin with offset and update on resize var _elementOffset = function () { if (element.offset) { return element.offset().top; } var box = element[0].getBoundingClientRect(); return box.top + _containerScrollTop() - document.documentElement.clientTop; }; var _elementOffsetContainer = function () { if (element.offset) { return element.offset().top - $container.offset().top; } return element[0].getBoundingClientRect().top - $container[0].getBoundingClientRect().top; }; // Update url of our image var _setImage = function () { if (options.background) { element[0].style.backgroundImage = 'url("' + currentImage +'")'; } else if (!!img) { img[0].src = currentImage; } }; // Append image to DOM var _placeImage = function () { loaded = true; // What is my best image available var hasImage = bestImage(images); if (hasImage) { // we have to make an image if background is false (default) if (!options.background) { if (!img) { element.addClass(LOADING); img = angular.element('<img ' + imgAttrs + ' />'); img.one('load', _loaded); img.one('error', _error); // remove loading class when image is acually loaded element.append(img); } } // set correct src/url _checkIfNewImage(); } // Element is added to dom, no need to listen to scroll anymore $container.off('scroll', _onViewChange); }; // Check on resize if actually a new image is best fit, if so then apply it var _checkIfNewImage = function () { if (loaded) { var newImage = bestImage(images); if (newImage !== currentImage) { // update current url currentImage = newImage; // TODO: loading state... // update image url _setImage(); } } }; // First update our begin offset _checkIfNewImage(); var _loaded = function () { attrs.$set('afklLazyImageLoaded', 'done'); element.removeClass(LOADING); }; var _error = function () { attrs.$set('afklLazyImageLoaded', 'fail'); }; // Check if the container is in view for the first time. Utilized by the scroll and resize events. var _onViewChange = function () { // only do stuff when not set already if (!loaded) { // Config vars var remaining, shouldLoad, windowBottom; var height = _containerInnerHeight(); var scroll = _containerScrollTop(); var elOffset = $container[0] === $window ? _elementOffset() : _elementOffsetContainer(); windowBottom = $container[0] === $window ? height + scroll : height; remaining = elOffset - windowBottom; // Is our top of our image container in bottom of our viewport? //console.log($container[0].className, _elementOffset(), _elementPosition(), height, scroll, remaining, elOffset); shouldLoad = remaining <= offset; // Append image first time when it comes into our view, after that only resizing can have influence if (shouldLoad) { _placeImage(); } } }; var _onViewChangeThrottled = srcSetService.throttle(_onViewChange, 300); // EVENT: RESIZE THROTTLED var _onResize = function () { $timeout.cancel(timeout); timeout = $timeout(function() { _checkIfNewImage(); _onViewChange(); }, 300); }; // Remove events for total destroy var _eventsOff = function() { $timeout.cancel(timeout); angular.element($window).off('resize', _onResize); angular.element($window).off('scroll', _onViewChangeThrottled); if ($container[0] !== $window) { $container.off('resize', _onResize); $container.off('scroll', _onViewChangeThrottled); } // remove image being placed if (img) { img.remove(); } img = timeout = currentImage = undefined; }; // set events for scrolling and resizing on window // even if container is not window it is important // to cover two cases: // - when container size is bigger than window's size // - when container's side is out of initial window border angular.element($window).on('resize', _onResize); angular.element($window).on('scroll', _onViewChangeThrottled); // if container is not window, set events for container as well if ($container[0] !== $window) { $container.on('resize', _onResize); $container.on('scroll', _onViewChangeThrottled); } // events for image change attrs.$observe('afklLazyImage', function () { images = attrs.afklLazyImage; if (loaded) { _placeImage(); } }); // Image should be directly placed if (options.nolazy) { _placeImage(); } scope.$on('afkl.lazyImage.destroyed', _onResize); // Remove all events when destroy takes place scope.$on('$destroy', function () { // tell our other kids, i got removed $rootScope.$broadcast('afkl.lazyImage.destroyed'); // remove our events and image return _eventsOff(); }); return _onViewChange(); } }; }]);