colorpicker.js

import { Component } from "./component.js";
import { Label } from "./label.js";
import { Style } from "./style.js";
import { VSlider } from "./vslider.js";

/**
 * Creates a input for entering color values, with a preview swatch. Now includes optional sliders for visually setting colors.
 * <div><img src="https://www.minicomps.org/images/colorpicker.png"/></div>
 * @example
 * const panel = new Panel(document.body, 20, 20, 200, 200);
 * new ColorPicker(panel, 20, 20, "Color", "#f00", event => console.log(event.target.color));
 * @extends Component
 */
export class ColorPicker extends Component {
  /**
   * Constructor
   * @param {HTMLElement} parent - The element to add this color picker to.
   * @param {number} x - The x position of the color picker. Default 0.
   * @param {number} y - The y position of the color picker. Default 0.
   * @param {string} label - The text shown in the text label of the color picker. Default empty string.
   * @param {string} color - The initial color value of the color picker. Default #f00.
   * @param {function} defaultHandler - A function that will handle the "change" event.
   */
  constructor(parent, x, y, label, color, defaultHandler) {
    super(parent, x, y);
    color = color || "#f00";
    this._label = label || "";
    this._labelPosition = ColorPicker.labelPosition;
    this._color = this._correctColor(color);
    this._color = this._cropColor(color);
    this._useSliders = ColorPicker.useSliders;
    this._width = 100;
    this._height = 20;

    this._createChildren();
    this._createStyle();
    this._createListeners();

    this.setSliderPosition(ColorPicker.sliderPosition);
    this.addEventListener("change", defaultHandler);
    this._addToParent();
  }

  //////////////////////////////////
  // Core
  //////////////////////////////////

  _createChildren() {
    this._setWrapperClass("MinimalColorPicker");

    this._input = this._createInput(this._wrapper, "MinimalColorPickerInput");
    this._input.maxLength = 7;
    this._input.value = this._color;

    this._textLabel = new Label(this._wrapper, 0, -15, this._label);

    this._sliderContainer = this._createDiv(this._wrapper, "MinimalColorPickerSliders");
    this._redSlider = new VSlider(this._sliderContainer, 12, 20, "R", this.getRed(), 0, 255)
      .setHeight(100);
    this._greenSlider = new VSlider(this._sliderContainer, 42, 20, "G", this.getGreen(), 0, 255)
      .setHeight(100);
    this._blueSlider = new VSlider(this._sliderContainer, 72, 20, "B", this.getBlue(), 0, 255)
      .setHeight(100);

    this._preview = this._createDiv(this._wrapper, "MinimalColorPickerPreview");
    this._preview.style.backgroundColor = this._color;
  }

  _createStyle() {
    const style = document.createElement("style");
    style.textContent = Style.colorpicker;
    this.shadowRoot.append(style);
  }

  _createListeners() {
    this._onFocus = this._onFocus.bind(this);
    this._onInput = this._onInput.bind(this);
    this._updateFromSliders = this._updateFromSliders.bind(this);
    this._onKeyPress = this._onKeyPress.bind(this);

    this._redSlider.addHandler(this._updateFromSliders);
    this._greenSlider.addHandler(this._updateFromSliders);
    this._blueSlider.addHandler(this._updateFromSliders);
    this._input.addEventListener("input", this._onInput);
    this._input.addEventListener("focus", this._onFocus);
    this.addEventListener("keydown", this._onKeyPress);
    this.addEventListener("blur", () => this.showSliders(false));
  }

  //////////////////////////////////
  // Handlers
  //////////////////////////////////

  _onInput() {
    const color = this._correctColor(this._input.value);
    this._input.value = color;
    if ((color.length === 4 || color.length === 7) && this._color !== color) {
      this._color = color;
      this._preview.style.backgroundColor = this._color;
      this._updateSliders();
      this.dispatchEvent(new CustomEvent("change", { detail: this._color }));
    }
  }

  _onFocus() {
    this.showSliders(true);
  }

  _updateFromSliders() {
    const red = this._redSlider.value;
    const green = this._greenSlider.value;
    const blue = this._blueSlider.value;
    this.setRGB(red, green, blue);
    this.dispatchEvent(new CustomEvent("change", { detail: this._color }));
  }

  _onKeyPress(event) {
    if (event.keyCode === 27) {
      // escape
      this.showSliders(false);
    }
  }

  //////////////////////////////////
  // Private
  //////////////////////////////////

  _correctColor(color) {
    color = "#" + color.replace(/[^0-9a-fA-F]/g, "");
    return color.toUpperCase();
  }

  _cropColor(color) {
    if (color.length > 7) {
      color = color.substring(0, 7);
    }
    return color;
  }

  _updateLabel() {
    if (this._labelPosition === "left") {
      this._textLabel.x = -this._textLabel.width - 5;
      this._textLabel.y = (this._height - this._textLabel.height) / 2;
    } else if (this._labelPosition === "right") {
      this._textLabel.x = this._width + 5;
      this._textLabel.y = (this._height - this._textLabel.height) / 2;
    } else if (this._labelPosition === "top") {
      this._textLabel.x = 0;
      this._textLabel.y = -this._textLabel.height - 5;
    } else {
      this._textLabel.x = 0;
      this._textLabel.y = this._height + 5;
    }
  }

  _updateSliders() {
    this._redSlider.value = this.getRed();
    this._greenSlider.value = this.getGreen();
    this._blueSlider.value = this.getBlue();
  }

  _updateSliderPosition() {
    if (this._sliderPosition === "bottom") {
      this._sliderContainer.style.top = "25px";
    } else if (this._sliderPosition === "top") {
      this._sliderContainer.style.top = "-155px";
    }
  }

  //////////////////////////////////
  // Public
  //////////////////////////////////

  /**
   * Adds a handler function for the "change" event on this color picker.
   * @param {function} handler - A function that will handle the "change" event.
   * @returns This instance, suitable for chaining.
   */
  addHandler(handler) {
    this.addEventListener("change", handler);
    return this;
  }

  /**
   * Automatically changes the value of a property on a target object with the main value of this component changes.
   * @param {object} target - The target object to change.
   * @param {string} prop - The string name of a property on the target object.
   * @return This instance, suitable for chaining.
   */
  bind(target, prop) {
    this.addEventListener("change", event => {
      target[prop] = event.detail;
    });
    return this;
  }

  /**
   * Gets the blue channel of the current color value as a numerical value from 0 to 255.
   */
  getBlue() {
    return this.getNumber() & 255;
  }

  /**
   * @returns the current color.
   */
  getColor() {
    return this._color;
  }

  /**
   * Gets the green channel of the current color value as a numerical value from 0 to 255.
   */
  getGreen() {
    return this.getNumber() >> 8 & 255;
  }

  /**
   * @returns the current label text.
   */
  getLabel() {
    return this._label;
  }

  /**
   * Gets the current label position.
   * @returns The position of the label.
   */
  getLabelPosition() {
    return this._labelPosition;
  }

  /**
   * Gets the current value of this component as a single 24-bit number from 0 to 16777215 (0x000000 to 0xffffff).
   * @returns {number} The numeric representation of this color picker's color.
   */
  getNumber() {
    const c = this._color.substring(1);
    if (c.length === 3) {
      let r = c.charAt(0);
      let g = c.charAt(1);
      let b = c.charAt(2);
      r += r;
      g += g;
      b += b;
      return parseInt(r + g + b, 16);
    }
    return parseInt(c, 16);
  }

  /**
   * Gets the red channel of the current color value as a numerical value from 0 to 255.
   */
  getRed() {
    return this.getNumber() >> 16;
  }

  /**
   * @returns the position of the sliders.
   */
  getSliderPosition() {
    return this._sliderPosition;
  }

  /**
   * @returns Whether or not the color picker is set to use sliders.
   */
  getUseSliders() {
    return this._useSliders;
  }

  /**
   * Sets the color value of this color picker. Valid inputs are three or six character strings containing hexadecimal digits (0-9 and upper or lower case A-F), optionally preceded by a "#" character.
   * @param {string} color - The color to set.
   * @returns This instance, suitable for chaining.
   * @example
   * colorpicker.setColor("#f9c");
   * colorpicker.setColor("#F9C");
   * colorpicker.setColor("f9c");
   * colorpicker.setColor("F9C");
   * colorpicker.setColor("#ff99cc");
   * colorpicker.setColor("#FF99CC");
   * colorpicker.setColor("ff99cc");
   * colorpicker.setColor("FF99CC");
   */
  setColor(color) {
    color = this._correctColor(color);
    color = this._cropColor(color);
    this._color = color;
    this._input.value = color;
    this._preview.style.backgroundColor = color;
    this._updateSliders();
    return this;
  }

  setEnabled(enabled) {
    if (this._enabled === enabled) {
      return this;
    }
    super.setEnabled(enabled);
    this._textLabel.enabled = enabled;
    this._input.disabled = !this._enabled;
    if (this._enabled) {
      this._preview.setAttribute("class", "MinimalColorPickerPreview");
      this._input.addEventListener("input", this._onInput);
    } else {
      this._preview.setAttribute("class", "MinimalColorPickerPreviewDisabled");
      this._input.removeEventListener("input", this._onInput);
    }
    return this;
  }

  /**
   * Sets the height of this component. In reality, this component is fixed size, so setting height or width has no effect.
   */
  setHeight() {
    return this;
  }

  /**
   * Sets the label of this color picker.
   * @param {string} label - The label to set on this color picker.
   * @returns this instance, suitable for chaining.
   */
  setLabel(label) {
    this._label = label;
    this._textLabel.text = label;
    this._updateLabel();
    return this;
  }

  /**
   * Sets the position of the text label.
   * @param {string} position - The position to place the text label: "top" (default), "left" or "bottom".
   * @returns this instance, suitable for chaining.
   */
  setLabelPosition(position) {
    this._labelPosition = position;
    this._updateLabel();
    return this;
  }

  /**
   * Sets the color value using a single 24-bit number.
   * @param {number} num - The number to parse into a color value. This would usually be in decimal (e.g. 16777215) or hexadecimal (e.g. 0xffffff).
   * @returns This instance, suitable for chaining.
   */
  setNumber(num) {
    const red = num >> 16;
    const green = num >> 8 & 255;
    const blue = num & 255;
    this.setRGB(red, green, blue);
    return this;
  }

  /**
   * Sets the color value to a random RGB value.
   * @returns This instance, suitable for chaining.
   */
  setRandom() {
    this.setNumber(Math.random() * 0xffffff);
    return this;
  }

  /**
   * Sets the color value using three values for red, green and blue.
   * @param {number} r - The value of the red channel (0 - 255).
   * @param {number} g - The value of the red channel (0 - 255).
   * @param {number} b - The value of the red channel (0 - 255).
   * @returns This instance, suitable for chaining.
   */
  setRGB(r, g, b) {
    let red = r.toString(16);
    let green = g.toString(16);
    let blue = b.toString(16);
    if (red.length === 1) {
      red = "0" + red;
    }
    if (green.length === 1) {
      green = "0" + green;
    }
    if (blue.length === 1) {
      blue = "0" + blue;
    }
    if ( red.charAt(0) === red.charAt(1) && green.charAt(0) === green.charAt(1) && blue.charAt(0) === blue.charAt(1)) {
      red = red.charAt(0);
      green = green.charAt(0);
      blue = blue.charAt(0);
    }
    this.setColor(red + green + blue);
    return this;
  }

  /**
   * Gets and sets the position of the slider popup.
   * @param {string} position - The position where the popup will open. Valid values are "bottom" (default) and "top".
   * @returns This instance, suitable for chaining.
   */
  setSliderPosition(position) {
    this._sliderPosition = position;
    this._updateSliderPosition();
    return this;
  }

  /**
   * Sets whether clicking into the input area will open up a pane with sliders for setting colors visually.
   * @param {boolean} useSliders - Whether or not to use the slider ui.
   * @returns This instance, suitable for chaining.
   */
  setUseSliders(useSliders) {
    this._useSliders = useSliders;
    return this;
  }

  /**
   * Sets the width of this component. In reality, this component is fixed size, so setting height or width has no effect.
   */
  setWidth() {
    return this;
  }

  /**
   * Programatically show or hide the slider container for setting rgb values visually.
   * @param {boolean} show - Whether to show or hide the sliders.
   * @returns This instance, suitable for chaining.
   */
  showSliders(show) {
    if (show && this._useSliders) {
      this._initialZ = this.style.zIndex;
      this.style.zIndex = Style.popupZIndex;
      this._sliderContainer.style.display = "block";
    } else {
      this.style.zIndex = this._initialZ;
      this._sliderContainer.style.display = "none";
    }
    return this;
  }

  //////////////////////////////////
  // Getters/Setters
  // alphabetical. getter first.
  //////////////////////////////////

  /**
   * Gets the red channel of the current color value as a numerical value from 0 to 255.
   */
  get red() {
    return this.getRed();
  }

  /**
   * Gets the green channel of the current color value as a numerical value from 0 to 255.
   */
  get green() {
    return this.getGreen();
  }

  /**
   * Gets the blue channel of the current color value as a numerical value from 0 to 255.
   */
  get blue() {
    return this.getBlue();
  }

  /**
   * Sets and gets the color value of this color picker. Valid inputs are three or six character strings containing hexadecimal digits (0-9 and upper or lower case A-F), optionally preceded by a "#" character.
   * @example
   * colorpicker.color = "#f9c";
   * colorpicker.color = "#F9C";
   * colorpicker.color = "f9c";
   * colorpicker.color = "F9C";
   * colorpicker.color = "#ff99cc";
   * colorpicker.color = "#FF99CC";
   * colorpicker.color = "ff99cc";
   * colorpicker.color = "FF99CC";
   */
  get color() {
    return this.getColor();
  }
  set color(color) {
    this.setColor(color);
  }

  /**
   * Gets and sets the text of the color picker's label.
   */
  get label() {
    return this.getLabel();
  }
  set label(label) {
    this.setLabel(label);
  }

  /**
   * Gets and sets the position of the text label displayed on the color picker. Valid values are "top" (default), "left", "right" and "bottom".
   */
  get labelPosition() {
    return this.getLabelPosition();
  }
  set labelPosition(pos) {
    this.setLabelPosition(pos);
  }

  /**
   * Gets and sets the position of the slider popup. Valid values are "bottom" (default) and "top".
   */
  get sliderPosition() {
    return this.getSliderPosition();
  }
  set sliderPosition(pos) {
    this.setSliderPosition(pos);
  }

  /**
   * Gets and sets whether clicking into the input area will open up a pane with sliders for setting colors visually.
   */
  get useSliders() {
    return this.getUseSliders();
  }
  set useSliders(useSliders) {
    this.setUseSliders(useSliders);
  }
}

//////////////////////////////////
// DEFAULTS
//////////////////////////////////

/**
 * Default labelPosition of all ColorPickers.
 */
ColorPicker.labelPosition = "top";
/**
 * Default useSliders value of all ColorPickers.
 */
ColorPicker.useSliders = true;
/**
 * Default sliderPosition of all ColorPickers.
 */
ColorPicker.sliderPosition = "bottom";

customElements.define("minimal-colorpicker", ColorPicker);