Skip to content

Latest commit

 

History

History
371 lines (345 loc) · 13.3 KB

color-picker.md

File metadata and controls

371 lines (345 loc) · 13.3 KB

Color Picker

This color picker is built from looking at the color picker on DuckDuckGo.

On the left side is a square with a color in the upper right, white in the upper left, and black on the bottom. On the right side of that is a bar with the different color hues. To the right of that are colors.

Moving in the square in the left to the upper right, S and V in HSV get set to zero. Selecting the red at the top of the hue bar, the RGB gets set to 255, 0, 0. Then moving down, this can be observed:

  • It starts with all red. RGB is 255, 0, 0 and hue is 0
  • Green gets added to red. RGB is 255, 255, 0 and hue is 60
  • The red gets removed, leaving just green. RGB is 0, 255, 0 and hue is 120
  • Blue gets added to green. RGB is 0, 255, 255 and hue is 180.
  • The green gets removed, leaving just blue. RGB is 0, 0, 255 and hue is 240.
  • Red gets added to blue. RGB is 255, 0, 255 and hue is 300.
  • The blue gets removed, leaving just red. RGB is back to 255, 0, 0 and the hue is 0.

On the left side, saturation and value can be identified. On the left side, it is white to black, indicating that from left to right the saturation goes from 0 to 100. The remaining is value. It goes 0 to 100 from bottom to top.

The area on the left is called the shade selector. The saturation and lightness come from it, and a conversion to RGB is done to update the hex code.

Below are a color input and a text input. The color input varies between browsers. It is sufficient in most desktop browsers but not all mobile browsers, where it is restricted to just selecting from a set of colors.

ColorPicker.js

export class ColorPicker extends HTMLElement {
  connectedCallback() {
    this.attachShadow({mode: 'open'})
    this.shadowRoot.adoptedStyleSheets = [this.constructor.styles]
    this.shadeSelect = document.createElement('div')
    this.shadeSelect.classList.add('shade-select')
    this.shadeSelectOverlay = document.createElement('div')
    this.shadeSelectOverlay.classList.add('shade-select-overlay')
    this.shadeSelect.append(this.shadeSelectOverlay)
    this.shadeSelectThumb = document.createElement('div')
    this.shadeSelectThumb.classList.add('shade-select-thumb')
    this.shadeSelect.append(this.shadeSelectThumb)
    this.shadeSelectThumb.addEventListener('pointerdown', e => {
      this.shadeSelectThumb.setPointerCapture(e.pointerId)
      this.shadeDragOffset = [
        e.clientX - this.shadeSelectThumb.clientLeft,
        e.clientY - this.shadeSelectThumb.clientTop,
      ]
      e.stopPropagation()
    })
    this.shadeSelect.addEventListener('pointerdown', e => {
      this.shadeDragOffset = [
        e.clientX - this.shadeSelectThumb.clientLeft,
        e.clientY - this.shadeSelectThumb.clientTop,
      ]
      this.moveShadeThumb(e)
      this.shadeSelectThumb.setPointerCapture(e.pointerId)
    })
    this.shadeSelectThumb.addEventListener('pointerup', () => {
      this.shadeDragOffset = undefined
    })
    this.shadeSelectThumb.addEventListener('pointermove', e => {
      if (this.shadeDragOffset !== undefined) {
        this.moveShadeThumb(e)
      }
    })
    this.hueSelect = document.createElement('div')
    this.hueSelect.classList.add('hue-select')
    this.hueSelectThumb = document.createElement('div')
    this.hueSelectThumb.classList.add('hue-select-thumb')
    this.hueSelect.append(this.hueSelectThumb)
    this.hueSelectThumb.addEventListener('pointerdown', e => {
      this.hueSelectThumb.setPointerCapture(e.pointerId)
      this.hueDragOffset = e.clientY - this.hueSelectThumb.clientTop
      e.stopPropagation()
    })
    this.hueSelect.addEventListener('pointerdown', e => {
      this.hueDragOffset = e.clientY - this.hueSelectThumb.clientTop
      this.moveHueThumb(e)
      this.hueSelectThumb.setPointerCapture(e.pointerId)
    })
    this.hueSelectThumb.addEventListener('pointerup', () => {
      this.hueDragOffset = undefined
    })
    this.hueSelect.addEventListener('pointerup', () => {
      this.hueDragOffset = undefined
    })
    this.hueSelectThumb.addEventListener('pointermove', e => {
      if (this.hueDragOffset !== undefined) {
        this.moveHueThumb(e)
      }
    })
    this.colorInput = document.createElement('input')
    this.colorInput.setAttribute('type', 'color')
    this.colorInput.addEventListener('input', () => {
      this.updateColorFromInput(this.colorInput.value)
      this.colorTextInput.value = this.colorInput.value
    })
    this.colorTextInput = document.createElement('input')
    this.colorTextInput.addEventListener('input', () => {
      if (this.colorTextInput.value.match(/^#[0-9a-f]{6}$/)) {
        this.colorInput.value = this.colorTextInput.value
        this.updateColorFromInput()
      }
    })
    this.colorTextInput.addEventListener('blur', () => {
      if (!this.colorTextInput.value.match(/^#[0-9a-f]{6}$/)) {
        this.colorTextInput.value = this.shadeColor
      }
    })
    const inputArea = document.createElement('div')
    inputArea.classList.add('input-area')
    inputArea.append(this.colorInput, this.colorTextInput)
    this.shadowRoot.append(this.shadeSelect, this.hueSelect, inputArea)
    this.hue = 240
    this.saturation = 1
    this.lightness = 1
    this.updateHueColor()
    this.updateShadeColor()
    this.colorInput.value = this.shadeColor
    this.colorTextInput.value = this.shadeColor
  }

  moveShadeThumb(e) {
    const x = e.pageX - this.shadeSelect.offsetLeft
    const y = e.pageY - this.shadeSelect.offsetTop
    const shadeLeft = Math.max(0, Math.min(x, this.shadeSelect.clientWidth))
    const shadeTop = Math.max(0, Math.min(y, this.shadeSelect.clientHeight))
    this.saturation = shadeLeft / this.shadeSelect.clientWidth
    this.lightness = 1 - (shadeTop / this.shadeSelect.clientHeight)
    this.updateShadeColor()
    this.colorInput.value = this.shadeColor
    this.colorTextInput.value = this.shadeColor
    this.dispatchEvent(new CustomEvent(
      'value-input', {bubbles: true}
    ))
  }

  moveHueThumb(e) {
    const y = Math.min(Math.max(e.pageY - this.hueSelect.offsetTop, 0), this.hueSelect.clientHeight)
    this.hue = Math.floor(y * 360 / this.hueSelect.clientHeight)
    this.updateHueColor()
    this.updateShadeColor()
    this.colorInput.value = this.shadeColor
    this.colorTextInput.value = this.shadeColor
    this.dispatchEvent(new CustomEvent(
      'value-input', {bubbles: true}
    ))
  }

  updateHueColor() {
    const hue = this.hue
    let r = 255, g = 0, b = 0
    if (hue > 60 && hue < 120) {
      r = Math.floor((120 - hue) * 256/60)
    } else if (hue >= 120 && hue <= 240) {
      r = 0
    } else if (hue > 240 && hue < 300) {
      r = Math.floor((hue - 240) * 256/60)
    }
    if (hue > 0 && hue < 60) {
      g = Math.floor(hue * 256/60)
    } else if (hue >= 60 && hue <= 180) {
      g = 255
    } else if (hue > 180 && hue < 240) {
      g = Math.floor((240 - hue) * 256/60)
    }
    if (hue > 120 && hue < 180) {
      b = Math.floor((hue - 120) * 256/60)
    } else if (hue >= 180 && hue <= 300) {
      b = 255
    } else if (hue > 300 && hue < 360) {
      b = Math.floor((360 - hue) * 256/60)
    }
    this.hueColorArray = [r, g, b]
    const hueColor = `#` + [r, g, b].map(n => n.toString(16).padStart(2, '0')).join('')
    const invertColor = `#` + [r, g, b].map(n => (255 - n).toString(16).padStart(2, '0')).join('') + `ff`
    const hueTop = hue * this.hueSelect.clientHeight / 360
    this.style.setProperty('--hue-color', hueColor)
    this.style.setProperty('--invert-color', invertColor)
    this.style.setProperty('--hue-top', `${hueTop}px`)
  }

  updateShadeColor() {
    this.shadeColorArray = this.hueColorArray.map(n => (
      (this.lightness * n) + ((255 - n) * this.lightness * (1 - this.saturation))
    ))
    // background: linear-gradient(to top, #000000, var(--hue-color, #0000ff));
    // background: linear-gradient(to top, #00000000, var(--invert-color, #ffff00ff));
    // mask-image: linear-gradient(to left, #00000000, #ffffffff);
    this.shadeColor = `#` + this.shadeColorArray.map(n => Math.floor(n).toString(16).padStart(2, '0')).join('')
    this.style.setProperty('--shade-color', this.shadeColor)
    const shadeLeft = this.saturation * this.shadeSelect.clientWidth
    const shadeTop = (1 - this.lightness) * this.shadeSelect.clientHeight
    this.style.setProperty('--shade-left', `${shadeLeft}px`)
    this.style.setProperty('--shade-top', `${shadeTop}px`)
  }

  updateColorFromInput() {
    const [r, g, b] = [1, 3, 5].map(n => parseInt(this.colorInput.value.slice(n, n + 2), 16))
    const max = Math.max(r, g, b)
    this.lightness = max / 255
    const min = Math.min(r, g, b)
    this.saturation = (max - min === 0) ? 0 : ((max - min) / max)
    if (this.saturation !== 0) {
      if (max === r) {
        if (g >= b) {
          this.hue = Math.floor(60 - 60 * ((r - g) / (r - b)))
        } else {
          this.hue = Math.floor(300 + 60 * ((r - b) / (r - g)))
        }
      } else if (max === g) {
        if (r >= b) {
          this.hue = Math.floor(60 + 60 * ((g - r) / (g - b)))
        } else {
          this.hue = Math.floor(180 - 60 * ((g - b) / (g - r)))
        }
      } else if (max === b) {
        if (g >= r) {
          this.hue = Math.floor(180 + 60 * ((b - g) / (b - r)))
        } else {
          this.hue = Math.floor(300 - 60 * ((b - r) / (b - g)))
        }
      }
    }
    this.updateHueColor()
    this.updateShadeColor()
  }

  get value() {
    return this.colorTextInput.value
  }

  set value(value) {
    this.colorInput.value = value
    this.colorInput.dispatchEvent(new Event('input', { bubbles: true }))
  }

  static get styles() {
    if (!this._styles) {
      this._styles = new CSSStyleSheet()
      this._styles.replaceSync(`
        :host {
          display: grid;
          grid-template-columns: max-content max-content;
          padding: 10px;
          gap: 10px;
        }
        * {
          box-sizing: border-box;
        }
        .shade-select {
          display: grid;
          grid-template-columns: 200px;
          grid-template-rows: 200px;
          background: linear-gradient(to top, #000000, var(--hue-color, #0000ff));
          position: relative;
        }
        .shade-select-overlay {
          background: linear-gradient(to top, #00000000, var(--invert-color, #ffff00ff));
          mask-image: linear-gradient(to left, #00000000, #ffffffff);
          grid-row: 1;
          grid-column: 1;
          mix-blend-mode: screen;
        }
        .shade-select-thumb {
          position: absolute;
          margin-top: -8px;
          margin-left: -8px;
          top: var(--shade-top, 0px);
          left: var(--shade-left, 200px);
          height: 16px;
          width: 16px;
          border: 2px solid var(--thumb-border-color, #cccccc);
          border-radius: 8px;
          background-color: var(--shade-color, #0000ff);
        }
        .hue-select {
          background: linear-gradient(to bottom, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000);
          display: flex;
          position: relative;
          width: 20px;
        }
        .hue-select-thumb {
          position: absolute;
          margin-top: -8px;
          margin-left: -5px;
          top: var(--hue-top, 133.33px);
          height: 16px;
          width: 30px;
          border: 2px solid var(--thumb-border-color, #cccccc);
          border-radius: 8px;
          background-color: var(--hue-color, #0000ff);
        }
        .input-area {
          grid-row: 2;
          grid-column: 1 / span 2;
          display: grid;
          grid-template-columns: max-content 1fr;
          gap: 5px;
        }
      `)
    }
    return this._styles
  }
}

ExampleView.js

export class ExampleView extends HTMLElement {
  connectedCallback() {
    this.attachShadow({mode: 'open'})
    this.shadowRoot.adoptedStyleSheets = [this.constructor.styles]
    if (![...document.adoptedStyleSheets].includes(this.constructor.globalStyles)) {
      document.adoptedStyleSheets = [...document.adoptedStyleSheets, this.constructor.globalStyles]
    }
    this.controlColorPicker = document.createElement('color-picker')
    this.controlColorPicker.addEventListener('value-input', () => {
      this.colorPicker.style.setProperty('--thumb-border-color', this.controlColorPicker.value)
      this.colorPicker.value = this.controlColorPicker.value
    })
    this.colorPicker = document.createElement('color-picker')
    this.shadowRoot.append(this.controlColorPicker, this.colorPicker)
  }

  static get styles() {
    if (!this._styles) {
      this._styles = new CSSStyleSheet()
      this._styles.replaceSync(`
        :host {
          display: flex;
          flex-direction: row;
          align-items: center;
          justify-content: center;
          min-height: 100vh;
          gap: 20px;
        }
      `)
    }
    return this._styles
  }

  static get globalStyles() {
    if (!this._globalStyles) {
      this._globalStyles = new CSSStyleSheet()
      this._globalStyles.replaceSync(`
        body {
          display: grid;
          grid-template-columns: 1fr;
          margin: 0;
        }
      `)
    }
    return this._globalStyles
  }
}

app.js

import {ColorPicker} from '/ColorPicker.js'
import {ExampleView} from '/ExampleView.js'

customElements.define('color-picker', ColorPicker)
customElements.define('example-view', ExampleView)

async function setup() {
  const exampleView = document.createElement('example-view')
  document.body.append(exampleView)
}

await setup()