import { Color, RGBAColor, HSVAColor } from './color'
import { isNumber, isString } from './types'
import { clamp, Position, getMousePosition } from './utils'
type ColorPickerOptions = {
window?: Window
el?: HTMLElement | string
background?: string | number
widthUnits?: string
heightUnits?: string
width?: number
height?: number
color?: string | number
}
export class ColorPicker {
private _window: Window
private _document: Document
private _widthUnits: string = 'px'
private _heightUnits: string = 'px'
private _huePosition: number = 0
private _hueHeight: number = 0
private _maxHue: number = 0
public _inputIsNumber: boolean = false
private _saturationWidth: number = 0
private _isChoosing: boolean = false
private _callbacks: Function[] = []
public width: number = 0
public height: number = 0
public hue: number = 0
public position: Position = { x: 0, y: 0 }
public color: Color = new Color(0)
public backgroundColor: Color = new Color(0)
public hueColor: Color = new Color(0)
public $el: HTMLElement
public $saturation: HTMLElement
public $hue: HTMLElement
public $sbSelector: HTMLElement
public $hSelector: HTMLElement
constructor(options: ColorPickerOptions = {}) {
// Register window and document references in case this is instantiated inside of an iframe
this._window = options.window || window
this._document = this._window.document
// Create DOM
this.$el = this._document.createElement('div')
this.$el.className = 'Scp'
this.$el.innerHTML = `
`
// DOM accessors
this.$saturation = this.$el.querySelector('.Scp-saturation')
this.$hue = this.$el.querySelector('.Scp-hue')
this.$sbSelector = this.$el.querySelector('.Scp-sbSelector')
this.$hSelector = this.$el.querySelector('.Scp-hSelector')
// Event listeners
this.$saturation.addEventListener('mousedown', this._onSaturationMouseDown)
this.$saturation.addEventListener('touchstart', this._onSaturationMouseDown)
this.$hue.addEventListener('mousedown', this._onHueMouseDown)
this.$hue.addEventListener('touchstart', this._onHueMouseDown)
// Some styling and DOMing from options
if (options.el) {
this.appendTo(options.el)
}
if (options.background) {
this.setBackgroundColor(options.background)
}
if (options.widthUnits) {
this._widthUnits = options.widthUnits
}
if (options.heightUnits) {
this._heightUnits = options.heightUnits
}
this.setSize(options.width || 175, options.height || 150)
this.setColor(options.color)
}
/**
* Add the ColorPicker instance to a DOM element.
* @param {HTMLElement} el
* @return {ColorPicker} Returns itself for chaining purpose
*/
public appendTo(el: HTMLElement | string): ColorPicker {
if (isString(el)) {
document.querySelector(el as string).appendChild(this.$el)
} else {
;(el as HTMLElement).appendChild(this.$el)
}
return this
}
/**
* Removes picker from its parent and kill all listeners.
* Call this method for proper destroy.
*/
public remove() {
this._callbacks = []
this._onSaturationMouseUp()
this._onHueMouseUp()
this.$saturation.removeEventListener(
'mousedown',
this._onSaturationMouseDown
)
this.$saturation.removeEventListener(
'touchstart',
this._onSaturationMouseDown
)
this.$hue.removeEventListener('mousedown', this._onHueMouseDown)
this.$hue.removeEventListener('touchstart', this._onHueMouseDown)
// this.off()
if (this.$el.parentNode) {
this.$el.parentNode.removeChild(this.$el)
}
}
/**
* Manually set the current color of the picker. This is the method
* used on instantiation to convert `color` option to actual color for
* the picker. Param can be a hexadecimal number or an hex String.
* @param {String|Number} color hex color desired
* @return {ColorPicker} Returns itself for chaining purpose
*/
public setColor(color: string | number): ColorPicker {
this._inputIsNumber = isNumber(color)
this.color.fromHex(color)
const { h, s, v } = this.color.hsv
if (!isNaN(h)) {
this.hue = h
}
this._moveSelectorTo(this._saturationWidth * s, (1 - v) * this._hueHeight)
this._moveHueTo((1 - this.hue) * this._hueHeight)
this._updateHue()
return this
}
/**
* Set size of the color picker for a given width and height. Note that
* a padding of 5px will be added if you chose to use the background option
* of the constructor.
* @param {Number} width
* @param {Number} height
* @return {ColorPicker} Returns itself for chaining purpose
*/
public setSize(width: number, height: number): ColorPicker {
this.width = width
this.height = height
this.$el.style.width = this.width + this._widthUnits
this.$el.style.height = this.height + this._heightUnits
this._saturationWidth = this.width - 25
this.$saturation.style.width = this._saturationWidth + 'px'
this._hueHeight = this.height
this._maxHue = this._hueHeight - 2
return this
}
/**
* Set the background color of the picker. It also adds a 5px padding
* for design purpose.
* @param {String|Number} color hex color desired for background
* @return {ColorPicker} Returns itself for chaining purpose
*/
public setBackgroundColor(color: string | number): ColorPicker {
this.backgroundColor.fromHex(color)
this.$el.style.padding = '5px'
this.$el.style.background = this.backgroundColor.hexString
return this
}
/**
* Removes background of the picker if previously set. It's no use
* calling this method if you didn't set the background option on start
* or if you didn't call setBackgroundColor previously.
*/
public setNoBackground(): ColorPicker {
this.$el.style.padding = '0px'
this.$el.style.background = 'none'
return this
}
/**
* Registers callback to the update event of the picker.
* picker inherits from [component/emitter](https://github.com/component/emitter)
* so you could do the same thing by calling `colorPicker.on('update');`
* @param {Function} callback
* @return {ColorPicker} Returns itself for chaining purpose
*/
public onChange(callback: Function): ColorPicker {
if (this._callbacks.indexOf(callback) < 0) {
this._callbacks.push(callback)
callback(this.getHexString())
}
return this
}
/**
* Is true when mouse is down and user is currently choosing a color.
*/
public get isChoosing(): boolean {
return this._isChoosing
}
/* =============================================================================
Color getters
============================================================================= */
/**
* Main color getter, will return a formatted color string depending on input
* or a number depending on the last setColor call.
* @return {Number|String}
*/
public getColor(): number | string {
if (this._inputIsNumber) {
return this.getHexNumber()
}
return this.getHexString()
}
/**
* Returns color as css hex string (ex: '#FF0000').
* @return {String}
*/
public getHexString(): string {
return this.color.hexString.toUpperCase()
}
/**
* Returns color as number (ex: 0xFF0000).
* @return {Number}
*/
public getHexNumber(): number {
return this.color.hex
}
/**
* Returns color as {r: 1, g: 0, b: 0} object.
* @return {Object}
*/
public getRGB(): RGBAColor {
return this.color.rgb
}
/**
* Returns color as {h: 100, s: 1, v: 1} object.
* @return {Object}
*/
public getHSV(): HSVAColor {
return this.color.hsv
}
/**
* Returns true if color is perceived as dark
* @return {Boolean}
*/
public isDark(): boolean {
return this.color.isDark
}
/**
* Returns true if color is perceived as light
* @return {Boolean}
*/
public isLight(): boolean {
return this.color.isLight
}
/* =============================================================================
Private methods
============================================================================= */
private _moveSelectorTo(x: number, y: number): void {
this.position.x = clamp(x, 0, this._saturationWidth)
this.position.y = clamp(y, 0, this._hueHeight)
this.$sbSelector.style.transform = `translate(${this.position.x}px, ${this.position.y}px)`
}
private _updateColorFromPosition(): void {
this.color.fromHsv({
h: this.hue,
s: this.position.x / this._saturationWidth,
v: 1 - this.position.y / this._hueHeight
})
this._updateColor()
}
private _moveHueTo(y: number): void {
this._huePosition = clamp(y, 0, this._maxHue)
this.$hSelector.style.transform = `translateY(${this._huePosition}px)`
}
private _updateHueFromPosition(): void {
const hsvColor = this.getHSV()
this.hue = 1 - this._huePosition / this._maxHue
this.color.fromHsv({ h: this.hue, s: hsvColor.s, v: hsvColor.v })
this._updateHue()
}
private _updateHue(): void {
this.hueColor.fromHsv({ h: this.hue, s: 1, v: 1 })
this.$saturation.style.background = `linear-gradient(to right, #fff, ${this.hueColor.hexString})`
this._updateColor()
}
private _updateColor(): void {
this.$sbSelector.style.background = this.getHexString()
this.$sbSelector.style.borderColor = this.isDark() ? '#fff' : '#000'
this._triggerChange()
}
private _triggerChange(): void {
this._callbacks.forEach(callback => callback(this.getHexString()))
}
/* =============================================================================
Events handlers
============================================================================= */
private _onSaturationMouseDown = (e: MouseEvent | TouchEvent): void => {
const sbOffset = this.$saturation.getBoundingClientRect()
const { x, y } = getMousePosition(e)
this._isChoosing = true
this._moveSelectorTo(x - sbOffset.left, y - sbOffset.top)
this._updateColorFromPosition()
this._window.addEventListener('mouseup', this._onSaturationMouseUp)
this._window.addEventListener('touchend', this._onSaturationMouseUp)
this._window.addEventListener('mousemove', this._onSaturationMouseMove)
this._window.addEventListener('touchmove', this._onSaturationMouseMove)
e.preventDefault()
}
private _onSaturationMouseMove = (e: MouseEvent | TouchEvent): void => {
const sbOffset = this.$saturation.getBoundingClientRect()
const { x, y } = getMousePosition(e)
this._moveSelectorTo(x - sbOffset.left, y - sbOffset.top)
this._updateColorFromPosition()
}
private _onSaturationMouseUp = () => {
this._isChoosing = false
this._window.removeEventListener('mouseup', this._onSaturationMouseUp)
this._window.removeEventListener('touchend', this._onSaturationMouseUp)
this._window.removeEventListener('mousemove', this._onSaturationMouseMove)
this._window.removeEventListener('touchmove', this._onSaturationMouseMove)
}
private _onHueMouseDown = (e: MouseEvent | TouchEvent): void => {
const hOffset = this.$hue.getBoundingClientRect()
const { y } = getMousePosition(e)
this._isChoosing = true
this._moveHueTo(y - hOffset.top)
this._updateHueFromPosition()
this._window.addEventListener('mouseup', this._onHueMouseUp)
this._window.addEventListener('touchend', this._onHueMouseUp)
this._window.addEventListener('mousemove', this._onHueMouseMove)
this._window.addEventListener('touchmove', this._onHueMouseMove)
e.preventDefault()
}
private _onHueMouseMove = e => {
const hOffset = this.$hue.getBoundingClientRect()
const { y } = getMousePosition(e)
this._moveHueTo(y - hOffset.top)
this._updateHueFromPosition()
}
private _onHueMouseUp = () => {
this._isChoosing = false
this._window.removeEventListener('mouseup', this._onHueMouseUp)
this._window.removeEventListener('touchend', this._onHueMouseUp)
this._window.removeEventListener('mousemove', this._onHueMouseMove)
this._window.removeEventListener('touchmove', this._onHueMouseMove)
}
}