// @ts-ignore: esbuild custom loader import styles from './styles.pcss'; import ColorPickerPopup from '../ui/popup/popup'; import { CUSTOM_EVENT_COLOR_HSV_CHANGED, CUSTOM_EVENT_COLOR_HUE_CHANGED, CUSTOM_EVENT_COLOR_ALPHA_CHANGED, CUSTOM_EVENT_BUTTON_CLICKED, sendButtonClickedCustomEvent } from '../domain/events-provider'; import { getUniqueId } from '../domain/common-provider'; import { hslaToString, hsvaToString, parseColor, rgbaToString } from '../domain/color-provider'; import { TinyColor } from '@ctrl/tinycolor'; // https://github.com/scttcper/tinycolor import { ColorInput } from '@ctrl/tinycolor/dist'; /** * predefined button widths */ const buttonPredefinedSizes: { [key: string]: string } = { sm: '0.875rem', md: '1.2rem', lg: '1.5rem', xl: '2.25rem', '2xl': '3rem', '3xl': '3.75rem', '4xl': '4.5rem', }; /* Usage: ------ */ interface IColorPickerState { // popup isPopupVisible: boolean; popupPosition: string; // color initialColor: TinyColor; color: TinyColor; // button buttonWidth?: string | null; buttonHeight?: string | null; buttonPadding?: string | null; } class ColorPicker extends HTMLElement { static get observedAttributes() { return ['color', 'popup-position', 'button-width', 'button-height', 'button-padding']; } // ----------- APIs ------------------------ /** * set any color that TinyColor accepts */ public set color(userColor: ColorInput) { this.state.color = new TinyColor(userColor); } /** * returns TinyColor object */ public get color() { return this.state.color; } /** * hex format getter */ public get hex() { return this.state.color.toHexString().toUpperCase(); } /** * hex with alpha format getter */ public get hex8() { return this.state.color.toHex8String().toUpperCase(); } /** * rgb format getter */ public get rgb() { return this.state.color.toRgbString(); } /** * rgba format getter */ public get rgba() { return rgbaToString(this.state.color); } /** * hsl format getter */ public get hsl() { return this.state.color.toHslString(); } /** * hsla format getter */ public get hsla() { return hslaToString(this.state.color); } /** * hsv format getter */ public get hsv() { return this.state.color.toHsvString(); } /** * hsva format getter */ public get hsva() { return hsvaToString(this.state.color); } public get opened() { return this.state.isPopupVisible; } public set opened(isOpened: boolean) { this.state.isPopupVisible = isOpened; } // ------------------------- INIT ---------------- // this id attribute is used for custom events public readonly cid: string; private $button: HTMLElement | null; private $buttonColor: HTMLElement | null; private $popupBox: HTMLElement | null; private stateDefaults: IColorPickerState = { isPopupVisible: false, popupPosition: 'left', initialColor: new TinyColor('#000'), color: new TinyColor('#000'), buttonWidth: null, buttonHeight: null, buttonPadding: null, }; private state: IColorPickerState; constructor() { super(); this.cid = getUniqueId(); // register web components if (!customElements.get('toolcool-color-picker-popup')) { customElements.define('toolcool-color-picker-popup', ColorPickerPopup); } this.attachShadow({ mode: 'open', // 'closed', 'open', }); this.toggle = this.toggle.bind(this); this.onKeyDown = this.onKeyDown.bind(this); this.clickedOutside = this.clickedOutside.bind(this); this.stopPropagation = this.stopPropagation.bind(this); this.hsvChanged = this.hsvChanged.bind(this); this.hueChanged = this.hueChanged.bind(this); this.alphaChanged = this.alphaChanged.bind(this); this.buttonClicked = this.buttonClicked.bind(this); this.formatButtonSize = this.formatButtonSize.bind(this); this.initState(); } // -------------------------------------------------- initState() { // eslint-disable-next-line const scope = this; this.state = new Proxy(scope.stateDefaults, { // eslint-disable-next-line set(target: IColorPickerState, key: string | symbol, value: any, _receiver: any): boolean { target[key] = value; if (key === 'isPopupVisible') { scope.onPopupVisibilityChange(); } if (key === 'popupPosition') { scope.onPopupPosChange(); } if (key === 'initialColor') { scope.onInitialColorChange(); } if (key === 'color') { scope.onColorChange(); } if (key === 'buttonWidth' || key === 'buttonHeight' || key === 'buttonPadding') { scope.setButtonSize(); } return true; }, }); } onPopupVisibilityChange() { if (!this.$popupBox) return; this.$popupBox.innerHTML = this.state.isPopupVisible ? `` : ''; } onPopupPosChange() { if (!this.$popupBox) return; const $popup = this.$popupBox.querySelector('toolcool-color-picker-popup'); if (!$popup) return; $popup.setAttribute('popup-position', this.state.popupPosition); } onInitialColorChange() { const bgColor = rgbaToString(this.state.color); if (this.$buttonColor) { this.$buttonColor.style.backgroundColor = bgColor; } const $popup = this.shadowRoot?.querySelector('toolcool-color-picker-popup'); if ($popup) { $popup.setAttribute('color', bgColor); } } setButtonSize() { if (!this.$button) return; if (this.state.buttonWidth) { this.$button.style.width = this.formatButtonSize(this.state.buttonWidth); } if (this.state.buttonHeight) { this.$button.style.height = this.formatButtonSize(this.state.buttonHeight); } if (this.state.buttonPadding) { this.$button.style.padding = this.state.buttonPadding; } } onColorChange() { if (this.$buttonColor) { this.$buttonColor.style.backgroundColor = rgbaToString(this.state.color); } this.dispatchEvent( new CustomEvent('change', { detail: { hex: this.hex, hex8: this.hex8, rgb: this.rgb, rgba: this.rgba, hsl: this.hsl, hsla: this.hsla, hsv: this.hsv, hsva: this.hsva, color: this.color, }, }) ); } hsvChanged(evt: CustomEvent) { if (!evt || !evt.detail || !evt.detail.cid) return; // handle only current instance if (evt.detail.cid !== this.cid) return; this.state.color = new TinyColor({ h: evt.detail.h, s: evt.detail.s, v: evt.detail.v, a: this.state.color.toHsv().a, }); } hueChanged(evt: CustomEvent) { if (!evt || !evt.detail || !evt.detail.cid) return; // handle only current instance if (evt.detail.cid !== this.cid) return; const hsv = this.state.color.toHsv(); this.state.color = new TinyColor({ h: evt.detail.h, s: hsv.s, v: hsv.v, a: hsv.a, }); } alphaChanged(evt: CustomEvent) { if (!evt || !evt.detail || !evt.detail.cid) return; // handle only current instance if (evt.detail.cid !== this.cid) return; const rgba = this.state.color.toRgb(); rgba.a = evt.detail.a; this.state.color = new TinyColor(rgba); } /** * when button clicked ---> close all other color pickers */ buttonClicked(evt: CustomEvent) { if (!evt || !evt.detail || !evt.detail.cid) return; if (evt.detail.cid === this.cid) return; this.state.isPopupVisible = false; } clickedOutside() { this.state.isPopupVisible = false; } toggle() { const isVisible = this.state.isPopupVisible; // setTimeout is used instead stopPropagation // to close other popup instances window.setTimeout(() => { this.state.isPopupVisible = !isVisible; sendButtonClickedCustomEvent(this.cid); }, 0); } onKeyDown(evt: KeyboardEvent) { if (evt.key === 'Escape') { // close the popup this.state.isPopupVisible = false; } } stopPropagation(evt: MouseEvent) { evt.stopPropagation(); } /** * button can accept predefined width and height lik sm, lg, etc. * and also it can accept any css sizes like 1rem, 50px, etc. */ formatButtonSize(size: string) { return buttonPredefinedSizes[size] ?? size; } // ------------------------- WEB COMPONENT LIFECYCLE ---------------------------- /** * when the custom element connected to DOM */ connectedCallback() { if (!this.shadowRoot) return; this.state.initialColor = parseColor(this.getAttribute('color')); this.state.color = parseColor(this.getAttribute('color')); this.state.popupPosition = this.getAttribute('popup-position') || 'left'; this.state.buttonWidth = this.getAttribute('button-width'); this.state.buttonHeight = this.getAttribute('button-height'); this.state.buttonPadding = this.getAttribute('button-padding'); this.shadowRoot.innerHTML = `
`; // init button and its events this.$button = this.shadowRoot.querySelector('.button'); this.$buttonColor = this.shadowRoot.querySelector('.button-color'); this.$button?.addEventListener('click', this.toggle); this.$button?.addEventListener('keydown', this.onKeyDown); this.$button?.addEventListener('mousedown', this.stopPropagation); // init popup container this.$popupBox = this.shadowRoot.querySelector('[data-popup-box]'); // init button dimensions this.setButtonSize(); // close popup when clicked outside - we use mousedown instead of click to fix strange behaviour when // user drags some inner element like saturation point from the bounds of the window, // and the popup is suddenly closed document.addEventListener('mousedown', this.clickedOutside); // custom event from other parts of the app document.addEventListener(CUSTOM_EVENT_COLOR_HSV_CHANGED, this.hsvChanged); document.addEventListener(CUSTOM_EVENT_COLOR_HUE_CHANGED, this.hueChanged); document.addEventListener(CUSTOM_EVENT_COLOR_ALPHA_CHANGED, this.alphaChanged); document.addEventListener(CUSTOM_EVENT_BUTTON_CLICKED, this.buttonClicked); } /** * when the custom element disconnected from DOM */ disconnectedCallback() { this.$button?.removeEventListener('click', this.toggle); this.$button?.removeEventListener('keydown', this.onKeyDown); this.$button?.removeEventListener('mousedown', this.stopPropagation); document.removeEventListener('mousedown', this.clickedOutside); document.removeEventListener(CUSTOM_EVENT_COLOR_HSV_CHANGED, this.hsvChanged); document.removeEventListener(CUSTOM_EVENT_COLOR_HUE_CHANGED, this.hueChanged); document.removeEventListener(CUSTOM_EVENT_COLOR_ALPHA_CHANGED, this.alphaChanged); document.removeEventListener(CUSTOM_EVENT_BUTTON_CLICKED, this.buttonClicked); } /** * when attributes change */ attributeChangedCallback(attrName: string) { switch (attrName) { case 'color': { this.state.initialColor = parseColor(this.getAttribute('color')); this.state.color = parseColor(this.getAttribute('color')); this.onInitialColorChange(); break; } case 'popup-position': { this.state.popupPosition = this.getAttribute('popup-position') || 'left'; this.onPopupPosChange(); break; } case 'button-width': { this.state.buttonWidth = this.getAttribute('button-width'); this.setButtonSize(); break; } case 'button-height': { this.state.buttonHeight = this.getAttribute('button-height'); this.setButtonSize(); break; } case 'button-padding': { this.state.buttonPadding = this.getAttribute('button-padding'); this.setButtonSize(); break; } } } } export default ColorPicker;