/** * @fileOverview Telerik Backend Services Responsive Images JavaScript API * @author Telerik Backend Services * @version: 0.1.0 */ ;(function (ns, window, document, undefined) { /** * @namespace everliveImages * * @name everliveImages * @module everliveImages Library */ 'use strict'; /** * An object that will receive the new properties in * result of extending API default settings. * @type {Object} */ var options = {}; /** * An array to store all selected images. * @type {Array} */ var allImages = []; /** * An object that will receive current image parameters. * @type {Object} */ var currentImage; /** * Private API settings. * * @constant * @member settings * @property {String} dataSrc Image data-src attribute name * @property {String} dataDpi Image data-resimg-dpi attribute name * @property {String} urlTemplate URL template */ var settings = { dataSrc: 'data-src', dataDpi: 'data-resimg-dpi', urlTemplate: '[protocol][hostname][apikey]/[operations][url]' }; /** * Configurations defaults, can be overridden at initialization time. * * @member defaults * @property {String} server Image server URL * @property {String} apiKey Backend Services API Key * @property {Boolean} resOnLoad Resize images automatically once the page has fully loaded * @property {Boolean} resOnResize Resize images automatically on browser resize / device rotation * @property {Boolean} ssl Generates https requests * @property {String} resClass The class name that identifies which elements to be processed * @property {Function} onReady A function to execute after images processing are ready * @property {Function} onError A function to execute when image loading error occurred * @property {Boolean} debug Run in debugging mode */ var defaults = { server: 'bs1.cdn.telerik.com/image/v1/', apiKey: '', resOnLoad: true, resOnResize: true, ssl: false, resClass: 'resimgs', onReady: null, onError: null, debug: false }; /** * Check if an element exists in array using a comparer function. * * @private * @method inArray * @param {Function} comparer The function to process each item against. * @return {Boolean} True if item is in Array */ Array.prototype.inArray = function (comparer) { var i = 0, ii = this.length; for ( ; i < ii; i++) { if (comparer(this[i])) { return true; } } return false; }; /** * Adds an element to the array if it does not already exist using a comparer function. * * @private * @method pushIfNotExist * @param {Object} element Element which will be pushed into Array * @param {Function} comparer The function to process each item against */ Array.prototype.pushIfNotExist = function (element, comparer) { if ( ! this.inArray(comparer)) { this.push(element); } }; /** * Executes a provided function once per array element. * * @private * @method forEach * @param {Function} fn Function to execute for each element */ Array.prototype.forEach = Array.prototype.forEach || function (fn) { if (this === void 0 || this === null) { throw new TypeError(); } var t = Object(this); var len = t.length >>> 0; if (typeof fn !== 'function') { throw new TypeError(); } var thisArg = arguments.length >= 2 ? arguments[1] : void 0; for (var i = 0; i < len; i++) { if (i in t) fn.call(thisArg, t[i], i, t); } }; /** * Determines whether its argument represents a numeric value. * * @private * @method isNumber * @return {Boolean} If argument is a number, it returns true. Otherwise it returns false. */ String.prototype.isNumber = function () { return !isNaN(parseFloat(this)) && isFinite(this); }; /** * Returns an array of a given object's own enumerable properties, * in the same order as that provided by a for...in loop. * * @private * @method keys * @return {Array} Array whose elements are strings corresponding to the enumerable properties found directly upon object. */ Object.keys = Object.keys || (function () { var hasOwnProperty = Object.prototype.hasOwnProperty, hasDontEnumBug = !({ toString: null }).propertyIsEnumerable('toString'), dontEnums = [ 'toString', 'toLocaleString', 'valueOf', 'hasOwnProperty', 'isPrototypeOf', 'propertyIsEnumerable', 'constructor' ], dontEnumsLength = dontEnums.length; return function (obj) { if (typeof obj !== 'object' && typeof obj !== 'function' || obj === null) throw new TypeError('Object.keys called on non-object'); var result = []; for (var prop in obj) { if (hasOwnProperty.call(obj, prop)) result.push(prop); } if (hasDontEnumBug) { for (var i = 0; i < dontEnumsLength; i++) { if (hasOwnProperty.call(obj, dontEnums[i])) result.push(dontEnums[i]); } } return result; }; })(); /** * Gives the values of all the CSS properties of an element after applying the active stylesheets * and resolving any basic computation those values may contain. * * @private * @method getComputedStyle * @param {Object} el An HTML element for which to get the computed style. * @param {String} pseudo A string specifying the pseudo-element to match. Must be omitted (or null) for regular elements. * @return {Object} CSSStyleDeclaration object. */ window.getComputedStyle = window.getComputedStyle || function (el, pseudo) { this.el = el; this.getPropertyValue = function (prop) { var re = /(\-([a-z]){1})/g; if (prop == 'float') { prop = 'styleFloat'; } if (re.test(prop)) { prop = prop.replace(re, function () { return arguments[2].toUpperCase(); }); } return el.currentStyle[prop] ? el.currentStyle[prop] : null; }; return this; }; /** * Returns an array of all child elements which have all of the given class names. * When called on the document object, the complete document is searched, including the root node. * * @private * @method getElementByClassName * @param {String} search Class name to match. * @return {Array} All found elements. */ document.getElementsByClassName = document.getElementsByClassName || function (search) { var elements, pattern, i, results = []; if (document.querySelectorAll) { // IE8 return document.querySelectorAll('.' + search); } if (document.evaluate) { // IE6, IE7 pattern = ".//*[contains(concat(' ', @class, ' '), ' " + search + " ')]"; elements = document.evaluate(pattern, document, null, 0, null); while ((i = elements.iterateNext())) { results.push(i); } } else { elements = document.getElementsByTagName('*'); pattern = new RegExp('(^|\\s)' + search + '(\\s|$)'); for (i = 0; i < elements.length; i++) { if (pattern.test(elements[i].className)) { results.push(elements[i]); } } } return results; }; /** * Merge the contents of two or more objects together. * * @private * @method extend * @param {Object} out An object that will receive the new properties if additional objects are passed in. * @return {Object} Merged object */ var extend = function (out) { out = out || {}; var i = 1, ii = arguments.length; for ( ; i < ii; i++) { if ( ! arguments[i]) { continue; } for (var key in arguments[i]) { if (arguments[i].hasOwnProperty(key)) { out[key] = arguments[i][key]; } } } return out; }; /** * Check if given string is undefined, null, or empty string. * * @private * @method isNullOrEmpty * @param {String} value Tested string * @return {Boolean} Return true if string is null, empty or undefined */ var isNullOrEmpty = function (value) { return typeof value === 'undefined' || value === null || value === ''; }; /** * Check if given string is equal to 'img'. * * @private * @method isImageTag * @param {String} tag Tested string * @return {Boolean} Return true if string is 'img' */ var isImageTag = function (tag) { return tag === 'img'; }; /** * Check if API Key is set by user. * * @private * @method isApiKey * @return {Boolean} Return true if API Key is set */ var isApiKey = function () { var apiKey = options.apiKey, isApiKeyEmpty = isNullOrEmpty(apiKey); if (isApiKeyEmpty) { console.log('Backend Services API Key is not set.'); return false; } if ( ! isApiKeyEmpty && typeof apiKey !== 'string') { console.log('Backend Services API Key should be a String'); return false; } return true; }; var Logger = { log: function () { if (options.debug && console && typeof console.log === 'function') { var i = 0, ii = arguments.length; for ( ; i < ii; i++) { console.log(arguments[i]); } } } }; /** * Returns a function, that, as long as it continues to be invoked, will not * be triggered. The function will be called after it stops being called for * N milliseconds. If `execAsap` is passed, trigger the function on the * leading edge, instead of the trailing. * * @private * @method debounce * @param {Function} fn Function to be invoked * @param {Number} threshold Detection period * @param {Boolean} execAsap indicating whether the signal should happen at the beginning of the detection period (true) or the end. */ var debounce = function (fn, threshold, execAsap) { var timeout; return function debounced() { var obj = this, args = arguments; function delayed() { if ( ! execAsap) fn.apply(obj, args); timeout = null; } if (timeout) { clearTimeout(timeout); } else if (execAsap) { fn.apply(obj, args); } timeout = setTimeout(delayed, threshold || 100); }; }; /** * Get protocol type based on user configuration. * * @private * @method getProtocol * @param {Boolean} ssl * @return {String} Protocol type */ var getProtocol = function (ssl) { return ssl ? 'https://' : 'http://'; }; /** * Get device pixel ratio. * * @private * @method getDevicePixelRatio * @return {Number} Device pixel ratio */ var getDevicePixelRatio = function () { return window.devicePixelRatio ? window.devicePixelRatio : 1; }; /** * Get image pixel ratio based on element data-resimg-dpi attribute. * * @private * @method getPixelRatio * @param {Object} el An HTML element * @return {Number|Boolean} */ var getPixelRatio = function (el) { var pixelDensity = getAttr(el, settings.dataDpi) || ''; return pixelDensity !== '' ? pixelDensity.isNumber() ? parseFloat(pixelDensity) : false : getDevicePixelRatio(); }; /** * Get all images from DOM by Responsive images class name. * * @private * @method getImagesByClassName * @return {Object} A list of elements */ var getImagesByClassName = function () { return document.getElementsByClassName(options.resClass); }; /** * Get element data attribute. * * @private * @method getAttr * @param {Object} el An HTML element * @param {String} attr Name of data attribute * @return {String|Boolean} Value of data attribute or `false` if there is no data attribute */ var getAttr = function (el, attr) { var elAttr = el.getAttribute(attr); return elAttr || false; }; /** * Set data attribute to given element. * * @private * @method setAttr * @param {Object} el An HTML element * @param {String} attr Name of data attribute * @param {String|Number|Boolean} value Value for data attribute */ var setAttr = function (el, attr, value) { el.setAttribute(attr, value); }; /** * Check if element contains specific class name. * * @private * @method hasClass * @param {Object} el An HTML element * @param {String} cl Class name * @return {Boolean} True if element already contains class name */ var hasClass = function (el, cl) { var regex = new RegExp('(?:\\s|^)' + cl + '(?:\\s|$)'); return !!el.className.match(regex); }; /** * Add class to element * * @private * @method addClass * @param {Object} el An HTML element * @param {String} cl Class name */ var addClass = function (el, cl) { var classNames = el.className, hasClassName = hasClass(el, cl); if ( ! hasClassName) { classNames = classNames ? classNames + ' ' : ''; el.className = classNames + cl; } }; var convertToDomObject = function (items) { var newItems = []; items.forEach(function (item, i) { newItems.push(item[0]); }); return newItems; }; /** * Calculate width of the image container. * * @private * @method getImageWidth * @param {Object} el An HTML element * @return {Number} Calculated width */ var getImageWidth = function (el) { var parentEl = el.parentNode, parentWidth = parentEl.offsetWidth, itemStyle = window.getComputedStyle(parentEl, null), pl = parseFloat(itemStyle.getPropertyValue('padding-left')), pr = parseFloat(itemStyle.getPropertyValue('padding-right')), bl = parseFloat(itemStyle.getPropertyValue('border-left-width')), br = parseFloat(itemStyle.getPropertyValue('border-right-width')); return Math.abs(parentWidth - Math.ceil(pl + pr + bl + br)); }; /** * Get element width in case of responsive background image. * * @private * @method getBackgroundWidth * @param {Object} el An HTML element * @return {Number} Element width */ var getBackgroundWidth = function (el) { return Math.ceil(el.offsetWidth); }; /** * Get background image src from computed element style. * * @private * @method getBackgroundSrc * @param {Object} el An HTML element * @return {String} URL string */ var getBackgroundSrc = function (el) { var elStyle = window.getComputedStyle(el, null), backgrImage = elStyle.getPropertyValue('background-image'); return backgrImage !== 'none' ? backgrImage : false; }; /** * Extract user operations parameters from given string. * * @private * @method parseParamsString * @param {String} str URL string * @return {Object} Operations parameters */ var parseParamsString = function (str) { if ( ! str || typeof str === 'undefined' || str.length <= 1) { return false; } var isUserResize = false, params = [], tmp = str.split('/'), ii = tmp.length, i = 0; for (; i < ii; i++) { var item = tmp[i].split('='), tmpObj = {}; if (typeof item[1] === 'undefined') { item[1] = false; } else { item[1] = unescape(item[1].replace(/\+/g, ' ')); } tmpObj[item[0]] = item[1]; params.push(tmpObj); if (item[0] === 'resize') { isUserResize = true; } } return { params: params, isUserResize: isUserResize }; }; /** * Parse image SRC * * @private * @method getImgParams * @param {String} src Image SRC * @return {Object} Original image URL and Image operations */ var getImgParams = function (src) { var operations = '', imgUrl = src.replace(/.*?resize=[^//]*\//gi, ''), //src.replace(/.*?:\/\//g, ''), protocolRe = new RegExp('https?://', 'gi'), serverRe = new RegExp(options.server, 'gi'), apiKeyRe = new RegExp(options.apiKey + '/', 'gi'); operations = src.replace(imgUrl, '').replace(protocolRe, '').replace(serverRe, '').replace(apiKeyRe, '').toLowerCase(); if (operations !== '') { operations = operations.indexOf('/') ? operations.substring(0, operations.length - 1) : operations; } else { operations = false; } operations = parseParamsString(operations); // If it's a user resize operation, use the passed url in the data-src property if(operations.isUserResize) { imgUrl = src; } return { imgUrl: imgUrl, operations: operations.params, isUserResize: operations.isUserResize }; }; /** * Convert Image parameters object to string. * * @private * @method getImgParamsString * @param {Object} params Image parameters object * @return {String} Image parameters as string */ var getImgParamsString = function (params) { var paramsStr = '', i = 0, ii = params.length; for ( ; i < ii; i++) { var item = params[i], key = Object.keys(item)[0], value; if ( ! isImageTag(currentImage.tag) && key === 'resize') { continue; } var pixelDensity = getPixelRatio(currentImage.item); pixelDensity = (pixelDensity) ? ',pd:' + pixelDensity : ''; for (var k in item) { value = (key === 'resize') ? item[k] + pixelDensity : item[k]; } paramsStr += key + '=' + value + '/'; } return paramsStr; }; /** * Get Image URL with included resize parameters and pixel density. * * @private * @method getImgSrc * @param {Number} imgWidth Image container width * @return {String} Image SRC (Image server URL, API Key, operations parameters and Image URL) */ var getImgSrc = function (imgWidth) { var protocol = getProtocol(options.ssl), server = options.server, apiKey = options.apiKey, url = settings.urlTemplate, pixelDensity = getPixelRatio(currentImage.item); pixelDensity = pixelDensity ? ',pd:' + pixelDensity : ''; url = url.replace('[protocol]', protocol); url = url.replace('[apikey]', apiKey ? apiKey : ''); url = url.replace('[hostname]', server); var params = currentImage.operations || false; if (params) { var operations = ''; params = getImgParamsString(params); if (isImageTag(currentImage.tag)) { operations = imgWidth ? 'resize=w:' + imgWidth + pixelDensity + '/' + params : params; } else { operations = 'resize=w:' + imgWidth + pixelDensity + '/' + params; } url = url.replace('[operations]', operations); } else { url = url.replace('[operations]', 'resize=w:' + imgWidth + pixelDensity + '/'); } url = url.replace('[url]', currentImage.imgUrl); return url; }; /** * Set Image SRC attribute. * * @private * @method setImageSrc * @param {Object} el An HTML element * @param {String} src Image SRC * @param {String} tag Element tag name * @param {Function} callback Invoke callback after SRC is set to element */ var setImageSrc = function (el, src, tag, callback) { var img = new Image(); img.onerror = function () { Logger.log('ERROR: Can\'t be loaded: ' + this.src); var isOnError = options.onError && typeof options.onError === 'function'; if (isOnError) { options.onError(el); } var isCallback = callback && typeof callback === 'function'; if (isCallback) { callback(el); // pass element with error. } }; img.onload = function () { if (isImageTag(tag)) { el.src = src; el.style.visibility = 'visible'; } else { el.style.backgroundImage = 'url(' + src + ')'; } var isCallback = callback && typeof callback === 'function'; if (isCallback) { callback(); } }; img.src = src; }; /** * Get all HTML elements with responsive image class. * * @private * @method getAllImages * @param {Object} items HTML elements */ var getAllImages = function (items) { if ( ! isApiKey()) return; items = items || getImagesByClassName(); [].forEach.call(items, function (item, i) { var tag = item.tagName.toLowerCase(); item = { item: item, tag: tag, processed: false }; allImages.pushIfNotExist(item, function (e) { return e.item === item.item; }); }); processAllImages(); }; /** * Loop trough and process all selected. * * @private * @method processAllImages */ var processAllImages = function () { var hasUnprocessed; var processedImagesCount = 0; var allImagesCount = allImages.length; allImages.forEach(function (item, i) { currentImage = item; hasUnprocessed = processSingleImage(i, function imageProcessedCallback(err, element, src, tag) { processedImagesCount++; if(processedImagesCount === allImagesCount) { _triggerOnReady(options); } }); }); }; var _triggerOnReady = function(options) { var isOnReady = options.onReady && typeof options.onReady === 'function'; if (isOnReady) { var images = []; allImages.forEach(function (item, i) { if (item.processed) { images.push(item.item); } }); options.onReady({ count: images.length, items: images }); } }; /** * Check if image is already processed. * If not, the image will be processed. * * @private * @method processSingleImage * @param {Number} i Index of current processing image * @return {Boolean} Return true if image is processed */ var processSingleImage = function (i, imageProcessedCallback) { var element = currentImage.item, tag = currentImage.tag, processed = currentImage.processed; var isImage = isImageTag(tag), isProcessed, dataSrc, imgParams, imgWidth; if (isImage) { dataSrc = getAttr(element, settings.dataSrc); } else { dataSrc = getBackgroundSrc(element); dataSrc = dataSrc.replace(/url\(('?"?)(.*?)\1\)/gi, '$2'); } if (dataSrc) { imgParams = getImgParams(dataSrc); currentImage.operations = imgParams.operations; currentImage.imgUrl = imgParams.imgUrl; currentImage.isUserResize = imgParams.isUserResize; } else { if (isImage) { Logger.log('ERROR: No data-src attribute: ' + element.outerHTML); } else { Logger.log('ERROR: No background-image: ' + element.outerHTML); } return; } if(!currentImage.isUserResize) { imgWidth = (!isImage) ? getBackgroundWidth(element) : getImageWidth(element); } if (!processed) { imgWidth = imgWidth ? imgWidth : false; var src = ''; if(currentImage.isUserResize) { src = currentImage.imgUrl; } else { src = getImgSrc(imgWidth); } setImageSrc(element, src, tag, function (err) { if(!err) { allImages[i].processed = true; } if(imageProcessedCallback && typeof imageProcessedCallback === 'function') { imageProcessedCallback(err, element, src, tag); } }); return true; } return false; }; /** * Listen for window resize event. * * @private * @method onWindowResize * @param {Function} fn Function to be invoked on resize event */ var onWindowResize = function (fn) { if (window.addEventListener) { window.addEventListener('resize', debounce(fn), false); } else if (window.attachEvent) { window.attachEvent('onresize', debounce(fn)); } }; /** * Check if API Key is set, trigger Responsive images and extends configuration object. * * @public * @method init * @param {Object|String} config User configuration object / string (API Key) */ ns.init = function (config) { config = config || {}; if (typeof config === 'string') { config = {apiKey: config}; } options = extend({}, defaults, config); if ( ! isApiKey()) return; allImages = []; // clean up the images if (options.resOnLoad) { getAllImages(); } if (options.resOnResize) { onWindowResize(processAllImages); } }; /** * Give control over an image or set of images. * Any image element will be effected. * * @public * @method responsive * @param {Object} items A HTMLElement or a HTMLCollection of elements */ ns.responsive = function (items) { allImages = []; // clean up the images if ( ! items) { getAllImages(); return; } if ( ! items.length && items.nodeType) { items = [items]; } else if (items.length > 1 && !items[0].nodeType) { items = convertToDomObject(items); } getAllImages(items); }; /** * Trigger Responsive images manually. * * @public * @method responsiveAll */ ns.responsiveAll = function responsiveAll() { allImages = []; getAllImages(); } }(window.everliveImages = window.everliveImages || {}, window, document));