ColorItem.js

/**
 * Color manipulation class, specific for Bootstrap Colorpicker
 */
import QixColor from 'color';

/**
 * HSVA color data class, containing the hue, saturation, value and alpha
 * information.
 */
class HSVAColor {
  /**
   * @param {number|int} h
   * @param {number|int} s
   * @param {number|int} v
   * @param {number|int} a
   */
  constructor(h, s, v, a) {
    this.h = isNaN(h) ? 0 : h;
    this.s = isNaN(s) ? 0 : s;
    this.v = isNaN(v) ? 0 : v;
    this.a = isNaN(h) ? 1 : a;
  }

  toString() {
    return `${this.h}, ${this.s}%, ${this.v}%, ${this.a}`;
  }
}

/**
 * HSVA color manipulation
 */
class ColorItem {

  /**
   * Returns the HSVAColor class
   *
   * @static
   * @example let colorData = new ColorItem.HSVAColor(360, 100, 100, 1);
   * @returns {HSVAColor}
   */
  static get HSVAColor() {
    return HSVAColor;
  }

  /**
   * Applies a method of the QixColor API and returns a new Color object or
   * the return value of the method call.
   *
   * If no argument is provided, the internal QixColor object is returned.
   *
   * @param {String} fn QixColor function name
   * @param args QixColor function arguments
   * @example let darkerColor = color.api('darken', 0.25);
   * @example let luminosity = color.api('luminosity');
   * @example color = color.api('negate');
   * @example let qColor = color.api().negate();
   * @returns {ColorItem|QixColor|*}
   */
  api(fn, ...args) {
    if (arguments.length === 0) {
      return this._color;
    }

    let result = this._color[fn].apply(this._color, args);

    if (!(result instanceof QixColor)) {
      // return result of the method call
      return result;
    }

    return new ColorItem(result, this.format);
  }

  /**
   * Returns the original ColorItem constructor data,
   * plus a 'valid' flag to know if it's valid or not.
   *
   * @returns {{color: *, format: String, valid: boolean}}
   */
  get original() {
    return this._original;
  }

  /**
   * @param {ColorItem|HSVAColor|QixColor|String|*|null} color Color data
   * @param {String|null} format Color model to convert to by default. Supported: 'rgb', 'hsl', 'hex'.
   * @param {boolean} disableHexInputFallback Disable fixing hex3 format
   */
  constructor(color = null, format = null, disableHexInputFallback = false) {
    this.replace(color, format, disableHexInputFallback);
  }

  /**
   * Replaces the internal QixColor object with a new one.
   * This also replaces the internal original color data.
   *
   * @param {ColorItem|HSVAColor|QixColor|String|*|null} color Color data to be parsed (if needed)
   * @param {String|null} format Color model to convert to by default. Supported: 'rgb', 'hsl', 'hex'.
   * @param {boolean} disableHexInputFallback Disable fixing hex3 format
   * @example color.replace('rgb(255,0,0)', 'hsl');
   * @example color.replace(hsvaColorData);
   */
  replace(color, format = null, disableHexInputFallback = false) {
    format = ColorItem.sanitizeFormat(format);

    /**
     * @type {{color: *, format: String}}
     * @private
     */
    this._original = {
      color: color,
      format: format,
      valid: true
    };
    /**
     * @type {QixColor}
     * @private
     */
    this._color = ColorItem.parse(color, disableHexInputFallback);

    if (this._color === null) {
      this._color = QixColor();
      this._original.valid = false;
      return;
    }

    /**
     * @type {*|string}
     * @private
     */
    this._format = format ? format :
      (ColorItem.isHex(color) ? 'hex' : this._color.model);
  }

  /**
   * Parses the color returning a Qix Color object or null if cannot be
   * parsed.
   *
   * @param {ColorItem|HSVAColor|QixColor|String|*|null} color Color data
   * @param {boolean} disableHexInputFallback Disable fixing hex3 format
   * @example let qColor = ColorItem.parse('rgb(255,0,0)');
   * @static
   * @returns {QixColor|null}
   */
  static parse(color, disableHexInputFallback = false) {
    if (color instanceof QixColor) {
      return color;
    }

    if (color instanceof ColorItem) {
      return color._color;
    }

    let format = null;

    if (color instanceof HSVAColor) {
      color = [color.h, color.s, color.v, isNaN(color.a) ? 1 : color.a];
    } else {
      color = ColorItem.sanitizeString(color);
    }

    if (color === null) {
      return null;
    }

    if (Array.isArray(color)) {
      format = 'hsv';
    }

    if (ColorItem.isHex(color) && (color.length !== 6 && color.length !== 7) && disableHexInputFallback) {
      return null;
    }

    try {
      return QixColor(color, format);
    } catch (e) {
      return null;
    }
  }

  /**
   * Sanitizes a color string, adding missing hash to hexadecimal colors
   * and converting 'transparent' to a color code.
   *
   * @param {String|*} str Color string
   * @example let colorStr = ColorItem.sanitizeString('ffaa00');
   * @static
   * @returns {String|*}
   */
  static sanitizeString(str) {
    if (!(typeof str === 'string' || str instanceof String)) {
      return str;
    }

    if (str.match(/^[0-9a-f]{2,}$/i)) {
      return `#${str}`;
    }

    if (str.toLowerCase() === 'transparent') {
      return '#FFFFFF00';
    }

    return str;
  }

  /**
   * Detects if a value is a string and a color in hexadecimal format (in any variant).
   *
   * @param {String} str
   * @example ColorItem.isHex('rgba(0,0,0)'); // false
   * @example ColorItem.isHex('ffaa00'); // true
   * @example ColorItem.isHex('#ffaa00'); // true
   * @static
   * @returns {boolean}
   */
  static isHex(str) {
    if (!(typeof str === 'string' || str instanceof String)) {
      return false;
    }

    return !!str.match(/^#?[0-9a-f]{2,}$/i);
  }

  /**
   * Sanitizes a color format to one supported by web browsers.
   * Returns an empty string of the format can't be recognised.
   *
   * @param {String|*} format
   * @example ColorItem.sanitizeFormat('rgba'); // 'rgb'
   * @example ColorItem.isHex('hex8'); // 'hex'
   * @example ColorItem.isHex('invalid'); // ''
   * @static
   * @returns {String} 'rgb', 'hsl', 'hex' or ''.
   */
  static sanitizeFormat(format) {
    switch (format) {
      case 'hex':
      case 'hex3':
      case 'hex4':
      case 'hex6':
      case 'hex8':
        return 'hex';
      case 'rgb':
      case 'rgba':
      case 'keyword':
      case 'name':
        return 'rgb';
      case 'hsl':
      case 'hsla':
      case 'hsv':
      case 'hsva':
      case 'hwb': // HWB this is supported by Qix Color, but not by browsers
      case 'hwba':
        return 'hsl';
      default :
        return '';
    }
  }

  /**
   * Returns true if the color is valid, false if not.
   *
   * @returns {boolean}
   */
  isValid() {
    return this._original.valid === true;
  }

  /**
   * Hue value from 0 to 360
   *
   * @returns {int}
   */
  get hue() {
    return this._color.hue();
  }

  /**
   * Saturation value from 0 to 100
   *
   * @returns {int}
   */
  get saturation() {
    return this._color.saturationv();
  }

  /**
   * Value channel value from 0 to 100
   *
   * @returns {int}
   */
  get value() {
    return this._color.value();
  }

  /**
   * Alpha value from 0.0 to 1.0
   *
   * @returns {number}
   */
  get alpha() {
    let a = this._color.alpha();

    return isNaN(a) ? 1 : a;
  }

  /**
   * Default color format to convert to when calling toString() or string()
   *
   * @returns {String} 'rgb', 'hsl', 'hex' or ''
   */
  get format() {
    return this._format ? this._format : this._color.model;
  }

  /**
   * Sets the hue value
   *
   * @param {int} value Integer from 0 to 360
   */
  set hue(value) {
    this._color = this._color.hue(value);
  }

  /**
   * Sets the hue ratio, where 1.0 is 0, 0.5 is 180 and 0.0 is 360.
   *
   * @ignore
   * @param {number} h Ratio from 1.0 to 0.0
   */
  setHueRatio(h) {
    this.hue = ((1 - h) * 360);
  }

  /**
   * Sets the saturation value
   *
   * @param {int} value Integer from 0 to 100
   */
  set saturation(value) {
    this._color = this._color.saturationv(value);
  }

  /**
   * Sets the saturation ratio, where 1.0 is 100 and 0.0 is 0.
   *
   * @ignore
   * @param {number} s Ratio from 0.0 to 1.0
   */
  setSaturationRatio(s) {
    this.saturation = (s * 100);
  }

  /**
   * Sets the 'value' channel value
   *
   * @param {int} value Integer from 0 to 100
   */
  set value(value) {
    this._color = this._color.value(value);
  }

  /**
   * Sets the value ratio, where 1.0 is 0 and 0.0 is 100.
   *
   * @ignore
   * @param {number} v Ratio from 1.0 to 0.0
   */
  setValueRatio(v) {
    this.value = ((1 - v) * 100);
  }

  /**
   * Sets the alpha value. It will be rounded to 2 decimals.
   *
   * @param {int} value Float from 0.0 to 1.0
   */
  set alpha(value) {
    // 2 decimals max
    this._color = this._color.alpha(Math.round(value * 100) / 100);
  }

  /**
   * Sets the alpha ratio, where 1.0 is 0.0 and 0.0 is 1.0.
   *
   * @ignore
   * @param {number} a Ratio from 1.0 to 0.0
   */
  setAlphaRatio(a) {
    this.alpha = 1 - a;
  }

  /**
   * Sets the default color format
   *
   * @param {String} value Supported: 'rgb', 'hsl', 'hex'
   */
  set format(value) {
    this._format = ColorItem.sanitizeFormat(value);
  }

  /**
   * Returns true if the saturation value is zero, false otherwise
   *
   * @returns {boolean}
   */
  isDesaturated() {
    return this.saturation === 0;
  }

  /**
   * Returns true if the alpha value is zero, false otherwise
   *
   * @returns {boolean}
   */
  isTransparent() {
    return this.alpha === 0;
  }

  /**
   * Returns true if the alpha value is numeric and less than 1, false otherwise
   *
   * @returns {boolean}
   */
  hasTransparency() {
    return this.hasAlpha() && (this.alpha < 1);
  }

  /**
   * Returns true if the alpha value is numeric, false otherwise
   *
   * @returns {boolean}
   */
  hasAlpha() {
    return !isNaN(this.alpha);
  }

  /**
   * Returns a new HSVAColor object, based on the current color
   *
   * @returns {HSVAColor}
   */
  toObject() {
    return new HSVAColor(this.hue, this.saturation, this.value, this.alpha);
  }

  /**
   * Alias of toObject()
   *
   * @returns {HSVAColor}
   */
  toHsva() {
    return this.toObject();
  }

  /**
   * Returns a new HSVAColor object with the ratio values (from 0.0 to 1.0),
   * based on the current color.
   *
   * @ignore
   * @returns {HSVAColor}
   */
  toHsvaRatio() {
    return new HSVAColor(
      this.hue / 360,
      this.saturation / 100,
      this.value / 100,
      this.alpha
    );
  }

  /**
   * Converts the current color to its string representation,
   * using the internal format of this instance.
   *
   * @returns {String}
   */
  toString() {
    return this.string();
  }

  /**
   * Converts the current color to its string representation,
   * using the given format.
   *
   * @param {String|null} format Format to convert to. If empty or null, the internal format will be used.
   * @returns {String}
   */
  string(format = null) {
    format = ColorItem.sanitizeFormat(format ? format : this.format);

    if (!format) {
      return this._color.round().string();
    }

    if (this._color[format] === undefined) {
      throw new Error(`Unsupported color format: '${format}'`);
    }

    let str = this._color[format]();

    return str.round ? str.round().string() : str;
  }

  /**
   * Returns true if the given color values equals this one, false otherwise.
   * The format is not compared.
   * If any of the colors is invalid, the result will be false.
   *
   * @param {ColorItem|HSVAColor|QixColor|String|*|null} color Color data
   *
   * @returns {boolean}
   */
  equals(color) {
    color = (color instanceof ColorItem) ? color : new ColorItem(color);

    if (!color.isValid() || !this.isValid()) {
      return false;
    }

    return (
      this.hue === color.hue &&
      this.saturation === color.saturation &&
      this.value === color.value &&
      this.alpha === color.alpha
    );
  }

  /**
   * Creates a copy of this instance
   *
   * @returns {ColorItem}
   */
  getClone() {
    return new ColorItem(this._color, this.format);
  }

  /**
   * Creates a copy of this instance, only copying the hue value,
   * and setting the others to its max value.
   *
   * @returns {ColorItem}
   */
  getCloneHueOnly() {
    return new ColorItem([this.hue, 100, 100, 1], this.format);
  }

  /**
   * Creates a copy of this instance setting the alpha to the max.
   *
   * @returns {ColorItem}
   */
  getCloneOpaque() {
    return new ColorItem(this._color.alpha(1), this.format);
  }

  /**
   * Converts the color to a RGB string
   *
   * @returns {String}
   */
  toRgbString() {
    return this.string('rgb');
  }

  /**
   * Converts the color to a Hexadecimal string
   *
   * @returns {String}
   */
  toHexString() {
    return this.string('hex');
  }

  /**
   * Converts the color to a HSL string
   *
   * @returns {String}
   */
  toHslString() {
    return this.string('hsl');
  }

  /**
   * Returns true if the color is dark, false otherwhise.
   * This is useful to decide a text color.
   *
   * @returns {boolean}
   */
  isDark() {
    return this._color.isDark();
  }

  /**
   * Returns true if the color is light, false otherwhise.
   * This is useful to decide a text color.
   *
   * @returns {boolean}
   */
  isLight() {
    return this._color.isLight();
  }

  /**
   * Generates a list of colors using the given hue-based formula or the given array of hue values.
   * Hue formulas can be extended using ColorItem.colorFormulas static property.
   *
   * @param {String|Number[]} formula Examples: 'complementary', 'triad', 'tetrad', 'splitcomplement', [180, 270]
   * @example let colors = color.generate('triad');
   * @example let colors = color.generate([45, 80, 112, 200]);
   * @returns {ColorItem[]}
   */
  generate(formula) {
    let hues = [];

    if (Array.isArray(formula)) {
      hues = formula;
    } else if (!ColorItem.colorFormulas.hasOwnProperty(formula)) {
      throw new Error(`No color formula found with the name '${formula}'.`);
    } else {
      hues = ColorItem.colorFormulas[formula];
    }

    let colors = [], mainColor = this._color, format = this.format;

    hues.forEach(function (hue) {
      let levels = [
        hue ? ((mainColor.hue() + hue) % 360) : mainColor.hue(),
        mainColor.saturationv(),
        mainColor.value(),
        mainColor.alpha()
      ];

      colors.push(new ColorItem(levels, format));
    });

    return colors;
  }
}

/**
 * List of hue-based color formulas used by ColorItem.prototype.generate()
 *
 * @static
 * @type {{complementary: number[], triad: number[], tetrad: number[], splitcomplement: number[]}}
 */
ColorItem.colorFormulas = {
  complementary: [180],
  triad: [0, 120, 240],
  tetrad: [0, 90, 180, 270],
  splitcomplement: [0, 72, 216]
};

export default ColorItem;

export {
  HSVAColor,
  ColorItem
};