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 -> metrics object: is the img at this uri accounted for in a spritesheet?
      spriteCache = {},
      // uri -> metrics object: for images known not to be in the spritesheet
      imageCache = {},
      requireCache = {},
      cssClassCache = {};

  const URL_REGEX = /^url\(['"](.*)['"]\)$/;


  /**
   * 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);
      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);
      if (jq.children('img').css('display') === 'none') {
        const {
          backgroundImage,
          backgroundPositionX,
          backgroundPositionY,
          width,
          height
        } = window.getComputedStyle(jq[0], ':before');

        spriteCache[uri] = {
          uri: parseUriFromCss(backgroundImage),
          x: -parseFloat(backgroundPositionX, 10),
          y: -parseFloat(backgroundPositionY, 10),
          width: parseFloat(width, 10),
          height: parseFloat(height, 10)
        };
      } else {
        spriteCache[uri] = false;
      }
      jq.detach();
    }

    return span;
  }

  function parseUriFromCss(css) {
    const match = URL_REGEX.exec(css);
    if (match) { return match[1]; }
    return css;
  }

  /**
   * @param {string} uri
   * @returns {Promise.<object>}
   */
  function toImageMetrics(uri) {
    let metrics = imageCache[uri];
    if (metrics) { return Promise.resolve(metrics); }
    return preloadImage(uri)
      .then((img) => {
        metrics = { uri, x: 0, y: 0, width: img.width, height: img.height };
        imageCache[uri] = metrics;
        return metrics;
      });
  }

  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.Simple} icon icon as
   * ORD, URI, or array of the same; a baja.Icon; (as of 4.12) a `gx:Image`
   * @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.Simple} icon icon as
   * ORD, URI, or array of the same; a baja.Icon; (as of 4.12) a `gx:Image`
   * @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;
        }));
      });
  }


  /**
   * Given an icon, calculate the metrics needed to paint the icon in a painting
   * context such as a canvas.
   *
   * @param {String|baja.Ord|Array.<String|baja.Ord>|baja.Simple} icon icon as
   * ORD, URI, or array of the same; a baja.Icon; (as of 4.12) a `gx:Image`
   * @returns {Promise.<module:bajaux/icon/iconUtils~ImageMetrics>}
   * @since Niagara 4.11
   */
  exports.toImageMetrics = function (icon) {
    return exports.$preloadSpritesheets()
      .then(() => Promise.all(exports.toUris(icon).map((uri) => {
        toSpan(uri, blank);
        return spriteCache[uri] || toImageMetrics(uri);
      })));
  };

  /**
   * Convert a value to an array of image URIs.
   * 
   * @param {String|baja.Ord|Array.<String|baja.Ord>|baja.Simple} 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. Can also
   * be a `baja.Icon` or (as of 4.12) a `gx:Image`
   * 
   * @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));
  };

  /**
   * Metrics needed to paint an icon in a painting context such as a canvas.
   *
   * @typedef module:bajaux/icon/iconUtils~ImageMetrics
   * @property {string} uri URI of the image to paint
   * @property {number} x pixels from left edge of the image
   * @property {number} y pixels from top edge of the image
   * @property {number} width width in pixels
   * @property {number} height height in pixels
   */

  return exports;
});