icon/iconUtils.js

/**
 * @copyright 2015 Tridium, Inc. All Rights Reserved.
 * @author Logan Byam
 */

/* eslint-env browser */
/*global niagara */

define([ 'jquery',
        'Promise',
        'nmodule/js/rc/asyncUtils/asyncUtils' ], function (
         $,
         Promise,
         asyncUtils) {
  
  'use strict';
  
  var doRequire = asyncUtils.doRequire,

      replaceMap = {
        '/': '-',
        '\\': '-',
        '<': '&lt;',
        '>': '&gt;',
        '"': '&quot;',
        "'": '&#x27;',
        " ": '&nbsp;'
      },
    
      moduleOrdPrefix = /^(local:\|)?module:\/\//,
      toReplaceCss = /[/\\<>' "]/g,
      toReplaceImg = /[\\<>'"]/g,
      moduleDir = /^\/*module\//,
      toRemoveQueryParameters = /\?.*$/i,
      fileExtension = /\.(png|gif|bmp|jpg|jpeg)$/i,

      // icons/x16/blank.png encoded as base64
      blank = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA1BMVEUAAACnej3aAAAAAXRSTlMAQObYZgAAAAtJREFUCB1jIBEAAAAwAAEK50gUAAAAAElFTkSuQmCC',
      
      defaultSpritesheet = 'css!nmodule/icons/sprite/sprite',

      dummyElement = $('<div class="bajaux-icon-iconUtils" style="display: none;"/>')
        .appendTo($('body')),
    
      // uri -> boolean: is the img at this uri accounted for in a spritesheet?
      spriteCache = {},
      requireCache = {},
      cssClassCache = {};


  /**
   * Utility functions for working with icons and associated HTML.
   *
   * @exports bajaux/icon/iconUtils
   */
  const exports = {};


////////////////////////////////////////////////////////////////
// Support functions
////////////////////////////////////////////////////////////////

  function safeReplace(str) { return replaceMap[str]; }
  
  function toUri(treatAsOrd, str) {
    var iconIsOrd = treatAsOrd || isOrd(str),
      uri = String(str),
      isModuleOrd = uri.match(moduleOrdPrefix);
    if (isModuleOrd) {
      return uri.replace(moduleOrdPrefix, '/module/');
    } else {
      return (iconIsOrd ? '/ord/' : '') + uri;
    }
  }
  
  function isOrd(obj) {
    return obj && typeof obj.getType === 'function' &&
      String(obj.getType()) === 'baja:Ord';
  }
  
  function getThemeName() {
    return typeof niagara !== 'undefined' &&
      niagara.env &&
      niagara.env.themeName;
  }

  /**
   * Creates and preloads an Image with the given src.
   *
   * @inner
   * @param {String} src
   * @returns {Promise} promise to be resolved after the image finishes loading
   */
  function preloadImage(src) {
    // eslint-disable-next-line promise/avoid-new
    return new Promise(function (resolve, reject) {
      var img = new Image();
      img.onload = resolve;
      img.onerror = reject;
      img.onabort = reject;
      img.src = src;
    });
  }

  /**
   * Create the `span` html to hold an icon for the given URI.
   * 
   * @inner
   * @param {String} uri e.g. `/module/icons/x16/action.png`
   * @param {String} imgSrc the src to set on the img in the span
   * @param {boolean} asElement true if this should return an Element
   * @returns {string|HTMLSpanElement}
   */
  function toSpan(uri, imgSrc, asElement) {
    const cssClass = exports.toCssClass(uri);
    let span;
    if (asElement) {
      span = document.createElement('span');
      span.classList.add(cssClass);

      const img = document.createElement('img');
      img.src = imgSrc.replace(toReplaceImg, safeReplace);

      span.appendChild(img);
    } else {
      span = '<span class="' + cssClass + '"><img src="' +
        imgSrc.replace(toReplaceImg, safeReplace) + '"></span>';
    }

    if (spriteCache[uri] === undefined) {
      /* get it in the DOM so CSS rules apply */
      var jq = $(span).appendTo(dummyElement);
      spriteCache[uri] = jq.children('img').css('display') === 'none';
      jq.detach();
    }

    return span;
  }

  function cachedRequire(id) {
    return requireCache[id] || (requireCache[id] = doRequire(id));
  }





////////////////////////////////////////////////////////////////
// Exports
////////////////////////////////////////////////////////////////

  /**
   * Preload the default spritesheet as well as the theme spritesheet.
   * 
   * @private
   * @returns {Promise}
   */
  exports.$preloadSpritesheets = function () {
    var themeName = getThemeName();
    
    return Promise.all([
      cachedRequire(defaultSpritesheet),
      themeName && cachedRequire('css!nmodule/theme' + themeName + '/sprite/sprite')
    ]);
  };

  /**
   * Convert the URI to a usable CSS class, expected to be represented in the
   * spritesheet CSS for that module.
   * 
   * @param {String} uri
   * @returns {String}
   */
  exports.toCssClass = function (uri) {
    let cssClass = cssClassCache[uri];
    if (!cssClass) {
      cssClass = cssClassCache[uri] = 'icon-' + toUri(false, uri)
        .replace(moduleDir, '')
        .replace(toRemoveQueryParameters, '')
        .replace(fileExtension, '')
        .replace(toReplaceCss, safeReplace);
    }
    return cssClass;
  };
  
  /**
   * Given an icon value (string, `baja.Icon`, etc), convert it into a usable
   * HTML snippet.
   * 
   * The HTML will consist of a one or more `span` tags. Each `span` will have
   * one `img` element. If the icon is accounted for in a spritesheet, the
   * `img` tag will be hidden and the icon will be represented solely by the
   * `span` using pure CSS. If the icon is not in a spritesheet, the `img`
   * tag will be shown and have its `src` tag set to the raw icon image.
   * 
   * @param {String|baja.Ord|Array.<String|baja.Ord>|baja.Icon} icon
   * @param {boolean} [sync=false] set to true to wait for the image to finish
   * loading (making it possible to query it for width/height) before the
   * `toHtml` promise resolves
   * @returns {Promise.<string>} promise to be resolved with a raw HTML string containing
   * one or more `span` tags
   */
  exports.toHtml = function (icon, sync) {
    return toHtml(icon, sync, false)
      .then((spans) => spans.join(''));
  };


  /**
   * Just like `toHtml`, but resolves an array of raw Elements instead.
   *
   * @param {String|baja.Ord|Array.<String|baja.Ord>|baja.Icon} icon
   * @param {boolean} [sync=false] set to true to wait for the image to finish
   * loading (making it possible to query it for width/height) before the
   * `toHtml` promise resolves
   * @returns {Promise.<HTMLSpanElement[]>} to be resolved with an array of
   * `span` elements
   */
  exports.toElements = function (icon, sync) {
    return toHtml(icon, sync, true);
  };

  function toHtml(icon, sync, asElements) {
    return exports.$preloadSpritesheets()
      .then(() => {
        return Promise.all(exports.toUris(icon).map((uri) => {
          const span = toSpan(uri, blank, asElements);
          const isInSprite = spriteCache[uri];

          if (!isInSprite) {
            /* not accounted for in spritesheet - use raw icon */
            if (sync) {
              return preloadImage(uri)
                .then(function () {
                  return toSpan(uri, uri, asElements);
                });
            }
            return toSpan(uri, uri, asElements);
          }

          return span;
        }));
      });
  }


  /**
   * Convert a value to an array of image URIs.
   * 
   * @param {String|baja.Ord|Array.<String|baja.Ord>|baja.Icon} icon a string
   * or array of strings. Each string can be a URI directly, or a `module://`
   * ORD. These will be converted to URIs to image files. If passing in 
   * arbitrary ORDs, it's recommended to relativizeToSession() first.
   * 
   * @returns {Array.<String>} array of image URIs
   * @throws {Error} if invalid input given
   */
  exports.toUris = function (icon) {
    var arr, iconIsOrd = isOrd(icon);

    if (icon && typeof icon.getImageUris === 'function') {
      return icon.getImageUris();
    } else if (Array.isArray(icon)) {
      arr = icon;
    } else if (typeof icon === 'string' || iconIsOrd) {
      arr = String(icon).split('\n');
    } else {
      throw new Error('string, array, Icon, or Ord required');
    }
    
    return arr.map(toUri.bind(null, iconIsOrd));
  };
  
  return exports;
});