UNPKG

alwan

Version:

A simple, lightweight, customizable, touch friendly color picker, written in vanilla javascript with zero dependencies.

1,131 lines (1,097 loc) 36.8 kB
(function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : typeof define === 'function' && define.amd ? define(factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.Alwan = factory()); })(this, (function () { 'use strict'; const version = "2.2.0"; const alwanDefaults = { id: "", classname: "", theme: "light", parent: "", toggle: true, popover: true, position: "bottom-start", margin: 4, preset: true, color: "#000", default: "#000", target: "", disabled: false, format: "rgb", singleInput: false, inputs: true, opacity: true, preview: true, copy: true, swatches: [], toggleSwatches: false, closeOnScroll: false, i18n: { picker: "Color picker", buttons: { copy: "Copy color to clipboard", changeFormat: "Change color format", swatch: "Color swatch", toggleSwatches: "Toggle Swatches" }, sliders: { hue: "Change hue", alpha: "Change opacity" } } }; const ROOT = document; const DOC_ELEMENT = ROOT.documentElement; const DEFAULT_COLOR = "#000"; const BUTTON = "button"; const OPEN = "open"; const CLOSE = "close"; const COLOR = "color"; const CLICK = "click"; const POINTER_DOWN = "pointerdown"; const POINTER_MOVE = "pointermove"; const POINTER_UP = "pointerup"; const SCROLL = "scroll"; const RESIZE = "resize"; const KEY_DOWN = "keydown"; const INPUT = "input"; const CHANGE = "change"; const BLUR = "blur"; const FOCUS_IN = "focusin"; const MOUSE_LEAVE = "mouseleave"; const HEX_FORMAT = "hex"; const RGB_FORMAT = "rgb"; const HSL_FORMAT = "hsl"; const TAB = "Tab"; const ESCAPE = "Escape"; const ENTER = "Enter"; const CAPTURE_PHASE = { capture: true }; const COLOR_FORMATS = [HEX_FORMAT, RGB_FORMAT, HSL_FORMAT]; const isString = value => typeof value === "string"; const isElement = value => value instanceof Element; const isNumber = value => Number.isFinite(isString(value) && value.trim() !== "" ? +value : value); const getColorObjectFormat = colorObj => [HSL_FORMAT, RGB_FORMAT].find(format => [...format].every(channel => isNumber(colorObj[channel]))); const { keys, assign: merge, setPrototypeOf, prototype } = Object; const { isArray } = Array; const isPlainObject = obj => obj != null && typeof obj === "object" && !isArray(obj) && !isElement(obj); const ObjectForEach = (object, callbackFn) => keys(object).forEach(key => callbackFn(key, object[key])); const deepMerge = (target, source) => { ObjectForEach(source, (key, value) => { merge(target, { [key]: isPlainObject(value) ? deepMerge(target[key] || {}, value) : value }); }); return target; }; const getBody = () => ROOT.body; const getElements = (reference, context = DOC_ELEMENT) => { if (isString(reference) && reference.trim()) { return [...context.querySelectorAll(reference)]; } // Reference must be an element in the page. if (isElement(reference)) { return [reference]; } return []; }; const getInteractiveElements = context => getElements(`${INPUT},${BUTTON},[tabindex]`, context); const createElement = (tagName, className, content, attributes = {}, ariaLabel) => { const element = ROOT.createElement(tagName); if (className) { element.className = className.trim(); } if (content) { if (isString(content)) { element.innerHTML = content; } else { element.append(...(isArray(content) ? content : [content]).filter(child => !!child)); } } ObjectForEach(ariaLabel ? { ...attributes, "aria-label": ariaLabel } : attributes, (name, value) => { if (isNumber(value) || value) { element.setAttribute(name, value + ""); } }); return element; }; const createDivElement = (children, className, ariaLabel) => createElement("div", className, children, {}, ariaLabel); const replaceElement = (element, replacement) => { if (element && element !== replacement) { element.replaceWith(replacement); } return replacement; }; const createButton = (label = "", className = "", content, title = label) => { return createElement(BUTTON, "alwan__button " + className, content, { type: BUTTON, title }, label); }; const createSlider = (ariaLabel, sliderName, max, step = 1) => createElement(INPUT, "alwan__slider alwan__" + sliderName, "", { type: "range", max, step }, ariaLabel); const setColorProperty = (element, color) => element.style.setProperty("--color", color); const toggleModifierClass = (element, token, forced) => element.classList.toggle("alwan--" + token, forced); const translate = (element, x, y) => { element.style.transform = `translate(${x}px,${y}px)`; }; const getBoundingRectArray = (element, isContentBox) => { let { x, y, width, height } = element.getBoundingClientRect(); if (isContentBox) { x += element.clientLeft - element.scrollLeft; y += element.clientTop - element.scrollTop; } return [x, y, width, height, width + x, height + y]; }; const createContainer = children => createDivElement(children, "alwan__container"); const hideElement = (element, hidden) => element.style.display = hidden ? "none" : ""; const isContainingBlock = ({ transform, perspective, filter, containerType, backdropFilter, willChange, contain }) => transform !== "none" || perspective !== "none" || containerType !== "normal" || backdropFilter !== "none" || filter !== "none" || willChange && /\b(transform|perspective|filter)\b/.test(willChange) || contain && /\b(paint|layout|strict|content)\b/.test(contain); const topLayerSelectors = [":popover-open", ":modal"]; const isTopLayer = node => isElement(node) && topLayerSelectors.some(selector => { try { return node.matches(selector); } catch (_) { return false; } }); const getOffsetParentBoundingRect = (node, root) => { node = node && node.parentNode; if (!node || node === ROOT || isTopLayer(node)) { return [0, 0]; } if (node === root) { node = root.host; } if (isElement(node) && isContainingBlock(getComputedStyle(node))) { return getBoundingRectArray(node, true); } return getOffsetParentBoundingRect(node, root); }; const addEvent = (target, type, listener, options) => target.addEventListener(type, listener, options); const removeEvent = (target, type, listener) => target.removeEventListener(type, listener); const clipboardSVG = `<svg width="18" height="18" viewBox="0 0 24 24" aria-role="none"><path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z"></path></svg>`; const checkSVG = `<svg width="18" height="18" viewBox="0 0 24 24" aria-role="none"><path d="M9,20.42L2.79,14.21L5.62,11.38L9,14.77L18.88,4.88L21.71,7.71L9,20.42Z"></path></svg>`; const switchInputsSVG = `<svg width="15" height="15" viewBox="0 0 20 20" aria-role="none"><path d="M10 1L5 8h10l-5-7zm0 18l5-7H5l5 7z"></path></svg>`; const caretSVG = `<svg width="20" height="20" viewBox="0 0 24 24" aria-role="none"><path d="M6.984 14.016l5.016-5.016 5.016 5.016h-10.031z"></path></svg>`; const stringify = (color, format = RGB_FORMAT) => format + (format === RGB_FORMAT ? `(${color.r}, ${color.g}, ${color.b}` : `(${color.h}, ${color.s}%, ${color.l}%`) + (color.a < 1 ? `, ${color.a})` : ")"); const { min, max, abs, round, PI } = Math; const clamp = (value, end = 100, start = 0) => value > end ? end : value < start ? start : value; const normalizeAngle = angle => { angle %= 360; return round(angle < 0 ? angle + 360 : angle); }; const toDecimal = hex => parseInt(hex, 16); const Inputs = alwan => { let { config, s: colorState } = alwan; let inputsMap; let inputsFormat; let isChanged = false; const handleChange = () => { const color = {}; if (!isChanged) { colorState._cache(); isChanged = true; } ObjectForEach(inputsMap, (key, input) => color[key] = input.value); colorState._parse(color[inputsFormat] || stringify(color, inputsFormat), true); }; const build = () => { inputsMap = {}; const fields = inputsFormat === HEX_FORMAT || config.singleInput ? [inputsFormat] : [...(inputsFormat + (config.opacity ? "a" : ""))]; /** * returns: * * <div class="alwan__inputs"> * <label> * <input type="text" class="alwan__input"> * <span>color component or format</span> * </label> * ... * </div> */ return createDivElement(fields.map(field => createElement("label", "", [inputsMap[field] = createElement(INPUT, "alwan__input", [], { type: "text", value: colorState._value[field] }), createElement("span", "", field)])), "alwan__inputs"); }; return { _render({ inputs, format, i18n }) { let inputsGroup; let inputsWrapper; let formats = COLOR_FORMATS; let switchBtn; let inputCount; if (inputs !== true) { inputs = inputs || {}; formats = formats.filter(format => inputs[format]); } inputCount = formats.length; // validate the format option. formats = inputCount ? formats : COLOR_FORMATS; inputsFormat = formats[max(formats.indexOf(format), 0)]; colorState._setFormat(inputsFormat); if (!inputCount) { return null; } if (inputCount > 1) { switchBtn = createButton(i18n.buttons.changeFormat, "", switchInputsSVG); addEvent(switchBtn, CLICK, () => { inputsFormat = formats[(formats.indexOf(inputsFormat) + 1) % inputCount]; colorState._setFormat(inputsFormat); inputsGroup = replaceElement(inputsGroup, build()); }); } inputsGroup = build(); inputsWrapper = createDivElement(inputsGroup); addEvent(inputsWrapper, INPUT, handleChange); addEvent(inputsWrapper, CHANGE, () => { colorState._change(); isChanged = false; }); addEvent(inputsWrapper, FOCUS_IN, e => e.target.select()); // Pressing Enter causes the picker to close. addEvent(inputsWrapper, KEY_DOWN, e => e.key === ENTER && alwan.c._toggle(false)); return createContainer([inputsWrapper, switchBtn]); }, _setValues(color) { if (!isChanged) { ObjectForEach(inputsMap || {}, (key, input) => input.value = color[key] + ""); } } }; }; const ARROW_KEYS_X = { ArrowLeft: -1, ArrowRight: 1 }; const ARROW_KEYS_Y = { ArrowUp: -1, ArrowDown: 1 }; const Selector = ({ s: colorState }) => { let selectorEl; let cursor; let cursorX; let cursorY; let selectorBounds; const sl = { s: 0, l: 0 }; /** * Moves curosr using a pointer (mouse, touch or pen) or keyboard arrow keys. */ const moveCursor = (x, y) => { let [,, width, height] = selectorBounds; let v, l; cursorX = x = clamp(x, width); cursorY = y = clamp(y, height); translate(cursor, x, y); v = 1 - y / height; l = v * (1 - x / (2 * width)); sl.s = l === 1 || l === 0 ? 0 : (v - l) / min(l, 1 - l) * 100; sl.l = l * 100; colorState._update(sl); }; const dragMove = ({ x, y, buttons }) => { if (buttons) { moveCursor(x - selectorBounds[0], y - selectorBounds[1]); } else { dragEnd(); } }; const dragEnd = () => { colorState._change(); removeEvent(ROOT, POINTER_MOVE, dragMove); removeEvent(ROOT, POINTER_UP, dragEnd); }; const dragStart = e => { selectorEl.setPointerCapture(e.pointerId); colorState._cache(); selectorBounds = getBoundingRectArray(selectorEl); moveCursor(e.x - selectorBounds[0], e.y - selectorBounds[1]); addEvent(ROOT, POINTER_MOVE, dragMove); addEvent(ROOT, POINTER_UP, dragEnd); }; const handleKeyboard = e => { const key = e.key; const stepX = ARROW_KEYS_X[key] || 0; const stepY = ARROW_KEYS_Y[key] || 0; if (stepX || stepY) { e.preventDefault(); selectorBounds = getBoundingRectArray(selectorEl); colorState._cache(); moveCursor(cursorX + stepX * selectorBounds[2] / 100, cursorY + stepY * selectorBounds[3] / 100); colorState._change(); } }; return { _render({ i18n, disabled }) { cursor = createDivElement("", "alwan__cursor"); selectorEl = createDivElement(cursor, "alwan__selector", i18n.picker || i18n.palette); if (!disabled) { selectorEl.tabIndex = 0; addEvent(selectorEl, POINTER_DOWN, dragStart); addEvent(selectorEl, KEY_DOWN, handleKeyboard); } return selectorEl; }, _updateCursor(s, l) { l /= 100; s = l + s / 100 * min(l, 1 - l); selectorBounds = getBoundingRectArray(selectorEl); cursorX = (s ? 2 * (1 - l / s) : 0) * selectorBounds[2]; cursorY = (1 - s) * selectorBounds[3]; translate(cursor, cursorX, cursorY); } }; }; const Sliders = ({ s: colorState, e: _events }) => { let hue; let alpha; let container; return { _render({ opacity, i18n: { sliders } }) { hue = createSlider(sliders.hue, "hue", 360); alpha = opacity ? createSlider(sliders.alpha, "alpha", 1, 0.01) : null; container = createDivElement([hue, alpha]); addEvent(container, CHANGE, () => _events._emit(CHANGE)); addEvent(container, INPUT, ({ target }) => colorState._update({ [target === hue ? "h" : "a"]: target.value })); return container; }, _setValues(h, a) { hue.value = h + ""; if (alpha) { alpha.value = a + ""; } } }; }; const Swatches = alwan => { let isCollapsed = false; return { _render({ swatches, toggleSwatches, i18n: { buttons } }) { let container; let collapseButton; let collapseFn; if (!isArray(swatches) || !swatches.length) { return container; } container = createDivElement(swatches.map(color => { const str = isString(color) ? color : stringify(color, getColorObjectFormat(color)); const button = createButton(buttons.swatch, "alwan__swatch", "", str); setColorProperty(button, str); addEvent(button, CLICK, () => alwan.s._parse(str, true, true)); return button; }), "alwan__swatches"); if (!toggleSwatches) { return container; } collapseFn = (collapse = !isCollapsed) => { isCollapsed = collapse; toggleModifierClass(container, "collapse", isCollapsed); alwan.c._reposition(); }; collapseButton = createButton(buttons.toggleSwatches, "alwan__toggle-button", caretSVG); addEvent(collapseButton, CLICK, () => collapseFn()); collapseFn(isCollapsed); return createDivElement([container, collapseButton]); } }; }; /** * Color preview and copy Button. */ const Utility = alwan => ({ _render({ preview, copy, i18n }) { let copyButton; let setIcon; let clipboardIcon; let checkIcon; let clipboard; if (copy) { copyButton = createButton(i18n.buttons.copy, "alwan__cp", clipboardSVG + checkSVG); [clipboardIcon, checkIcon] = copyButton.children; setIcon = isCopied => { hideElement(clipboardIcon, isCopied); hideElement(checkIcon, !isCopied); }; setIcon(false); clipboard = navigator.clipboard; if (clipboard) { addEvent(copyButton, CLICK, () => clipboard.writeText(alwan.s._toString()).then(() => setIcon(true))); addEvent(copyButton, BLUR, () => setIcon()); addEvent(copyButton, MOUSE_LEAVE, () => copyButton.blur()); } } return preview ? createDivElement(copyButton, "alwan__preview") : copyButton; } }); const createComponents = alwan => [Selector, Utility, Sliders, Inputs, Swatches].map(component => component(alwan)); const renderComponents = (components, config) => components.map(component => isArray(component) ? createContainer(renderComponents(component, config)) : component._render(config)); // Indexes in the DOMRectArray. const LEFT = 0; // Also the x coordinate. const TOP = 1; // Also the y coordinate. const HEIGHT = 3; const RIGHT = 4; const BOTTOM = 5; const START = 0; const CENTER = 1; const END = 2; // Margin between the popover and the window edges. const GAP = 3; // Sides to fallback to for each side. const fallbackSides = { top: [TOP, BOTTOM, RIGHT, LEFT], bottom: [BOTTOM, TOP, RIGHT, LEFT], right: [RIGHT, LEFT, TOP, BOTTOM], left: [LEFT, RIGHT, TOP, BOTTOM] }; // Alignments to fallback to for each alignment. const fallbackAlignments = { start: [START, CENTER, END], center: [CENTER, START, END], end: [END, CENTER, START] }; const createPopover = (target, floating, ref, { margin, position, closeOnScroll, toggle, disabled }, { _toggle, _isOpen }) => { margin = isNumber(margin) ? +margin : 0; let isTargetVisible; let isFloatingVisible = _isOpen(); let rootNode = target.getRootNode(); const [side, alignment] = isString(position) ? position.split("-") : []; const floatingStyle = floating.style; const shadowRoot = rootNode instanceof ShadowRoot ? rootNode : null; const focusableElements = getInteractiveElements(floating); const firstFocusableElement = focusableElements[0]; const lastFocusableElement = focusableElements.pop(); const updatePosition = () => { const viewport = [DOC_ELEMENT.clientWidth, DOC_ELEMENT.clientHeight]; const targetRect = getBoundingRectArray(target); const floatingRect = getBoundingRectArray(floating); const offsetParentRect = getOffsetParentBoundingRect(floating, shadowRoot); const coordinates = [-1, -1]; floatingStyle.height = ""; // No need to update if the target element is not in the viewport. if (!isFloatingVisible || !isTargetVisible || targetRect[RIGHT] < 0 || targetRect[BOTTOM] < 0 || targetRect[LEFT] > viewport[0] /* viewport width */ || targetRect[TOP] > viewport[1] /* viewport height */) { return; } (fallbackSides[side] || fallbackSides.bottom).some(side => { // side is number: // 0 -> Left. // 1 -> Top. // 4 -> Right. // 5 -> Bottom. // axis can be 0 (x) or 1 (y). // axis + 2 -> width or height. // axis + 4 -> Right or Left bounds. let axis = side % 2; let point = targetRect[side] + (side <= 1 ? -floatingRect[axis + 2] - margin : margin); if (point < 0 || point + floatingRect[axis + 2] + margin > viewport[axis]) { return false; } coordinates[axis] = point; // Reverse the axis for the alignments. axis = +!axis; return (fallbackAlignments[alignment] || fallbackAlignments.center).some(alignment => { point = alignment === START ? targetRect[axis] : targetRect[axis + 4] - (alignment === END ? floatingRect[axis + 2] : (floatingRect[axis + 2] + targetRect[axis + 2]) / 2); if (point < 0 || point + floatingRect[axis + 2] > viewport[axis]) { return false; } coordinates[axis] = point; return true; }); }); translate(floating, ...coordinates.map((value, axis) => { // Set a dynamic height if the popover height is greater than // the viewport height. if (axis && value === -1 && floatingRect[HEIGHT] > viewport[axis]) { floatingStyle.height = viewport[axis] - GAP * 2 + "px"; floatingRect[HEIGHT] = viewport[axis] - GAP; } return round((value >= 0 ? value : // center the popover relative to the viewport. (viewport[axis] - floatingRect[axis + 2]) / 2) - offsetParentRect[axis]); })); }; if (toggle) { addEvent(floating, KEY_DOWN, e => { let { key, target, shiftKey } = e; let focusElement; if (key === ESCAPE) { _toggle(false); } else if (key === TAB) { if (target === firstFocusableElement && shiftKey) { focusElement = lastFocusableElement; } else if (target === lastFocusableElement && !shiftKey) { focusElement = firstFocusableElement; } if (focusElement) { focusElement.focus(); e.preventDefault(); } } }); } // We don't close if one of these elements is in the event's composed path. const excludedTargets = [floating, ref, ...(ref.labels || [])]; const closeByPointer = e => { const path = e.composedPath(); if (isFloatingVisible && !excludedTargets.some(el => path.includes(el))) { _toggle(false); } }; const observer = new IntersectionObserver(([entry]) => { isTargetVisible = entry.isIntersecting; // When the popover target becomes visible: // - Open it if toggle is false, otherwise restore its previous state. // otherwise: // - Close it. _toggle(!disabled && isTargetVisible && (!toggle || isFloatingVisible), true); }); const updatePositionOnScroll = ({ target: t }) => { if (shadowRoot && t.contains(shadowRoot.host) || t.contains(target)) { updatePosition(); if (closeOnScroll) { _toggle(false); } } }; /** * Adds/Removes events. */ const bindEventListeners = fn => { fn(window, RESIZE, updatePosition); fn(ROOT, SCROLL, updatePositionOnScroll, CAPTURE_PHASE); fn(ROOT, POINTER_DOWN, closeByPointer); if (shadowRoot) { fn(shadowRoot, SCROLL, updatePositionOnScroll, CAPTURE_PHASE); } }; observer.observe(target); bindEventListeners(addEvent); return { _isVisible: () => isTargetVisible, _reposition(isOpenNow, wasOpenBefore) { isFloatingVisible = isOpenNow; if (isTargetVisible) { updatePosition(); if (toggle && wasOpenBefore !== isOpenNow) { (isOpenNow ? firstFocusableElement : ref).focus(); } } }, _destroy() { floatingStyle.cssText = ""; observer.unobserve(target); bindEventListeners(removeEvent); } }; }; const refController = (userRef, toggleFn) => { const button = createButton(); const className = button.className + " alwan__ref "; let ref; if (userRef && userRef.id) { button.id = userRef.id; } return { _getEl(config) { ref = replaceElement(ref || userRef, config.preset || !userRef ? button : userRef); if (ref === button) { ref.className = (className + config.classname).trim(); if (!ref.parentNode) { getBody().append(ref); } } addEvent(ref, CLICK, toggleFn); return ref; }, _remove() { if (userRef) { removeEvent(userRef, CLICK, toggleFn); replaceElement(ref, userRef); } else { ref.remove(); } } }; }; const controller = (alwan, userRef) => { let innerEl = createDivElement(); let isOpen = false; let popoverInstance = null; let ref; const { config, s: colorState } = alwan; const refCtrl = refController(userRef, () => alwan.toggle()); const root = createDivElement(innerEl, "alwan"); const [selector, utility, sliders, inputs, swatches] = createComponents(alwan); colorState._setHooks( // On update. color => { setColorProperty(ref, color.rgb); innerEl.style.cssText = `--rgb:${color.r},${color.g},${color.b};--a:${color.a};--h:${color.h}`; inputs._setValues(color); }, // On setcolor. ({ a, h, s, l }) => { sliders._setValues(h, a); selector._updateCursor(s, l); }); return { _setup(options) { const self = this; const { id, color = colorState._value.hsl } = options; const { theme, parent, toggle, popover, target, disabled } = deepMerge(config, options); ref = refCtrl._getEl(config); let parentEl = getElements(parent)[0]; let targetEl = getElements(target)[0] || ref; id && (root.id = id); toggleModifierClass(root, "dark", theme === "dark"); toggleModifierClass(root, "block", !popover); innerEl = replaceElement(innerEl, createDivElement(renderComponents([selector, [utility, sliders], inputs, swatches], config))); // Hide reference element if both toggle and popover options are set to false, hideElement(ref, !popover && !toggle); if (popoverInstance) { popoverInstance._destroy(); popoverInstance = null; } if (popover) { parentEl = parentEl || toggle && getBody(); popoverInstance = createPopover(targetEl, root, ref, config, self); } else { // Open if toggle is false, or leave it as the previous state if // the color picker is not disabled. self._toggle(!toggle || !disabled && isOpen, true); } parentEl ? parentEl.append(root) : targetEl.after(root); if (disabled) { [ref, ...getInteractiveElements(root)].forEach(element => { element.disabled = true; }); } colorState._parse(color); }, _toggle(state = !isOpen, forced = false) { if (state !== isOpen && (!popoverInstance || popoverInstance._isVisible()) && !config.disabled && config.toggle || forced) { toggleModifierClass(root, OPEN, state); if (popoverInstance) { popoverInstance._reposition(state, isOpen); } isOpen = state; alwan.e._emit(isOpen ? OPEN : CLOSE); } }, _isOpen: () => isOpen, _reposition() { if (popoverInstance) { popoverInstance._reposition(isOpen, isOpen); } }, _destroy() { root.remove(); if (popoverInstance) { popoverInstance._destroy(); } refCtrl._remove(); } }; }; const decimalToHex = decimal => (decimal < 16 ? "0" : "") + decimal.toString(16); const RGBToHEX = ({ r, g, b, a }) => "#" + decimalToHex(r) + decimalToHex(g) + decimalToHex(b) + (a < 1 ? decimalToHex(round(a * 255)) : ""); /** * Helper function used for converting HSL to RGB. */ const fn = (k, s, l) => { k %= 12; return round((l - s * min(l, 1 - l) * max(-1, min(k - 3, 9 - k, 1))) * 255); }; const HSLToRGB = ({ h, s, l, a }) => { h /= 30; s /= 100; l /= 100; return { r: fn(h, s, l), g: fn(h + 8, s, l), b: fn(h + 4, s, l), a }; }; const RGBToHSL = ({ r, g, b, a }) => { r /= 255; g /= 255; b /= 255; const cMax = max(r, g, b); const cMin = min(r, g, b); const d = cMax - cMin; const l = (cMax + cMin) / 2; const h = d === 0 ? 0 : cMax === r ? (g - b) / d % 6 : cMax === g ? (b - r) / d + 2 : cMax === b ? (r - g) / d + 4 : 0; return { h: normalizeAngle(h * 60), s: d ? d / (1 - abs(2 * l - 1)) * 100 : 0, l: l * 100, a }; }; const ctx = createElement("canvas").getContext("2d"); const ANGLE_COEFFICIENT_MAP = { turn: 360, rad: 180 / PI, grad: 0.9 }; const HSL_REGEX = /a?\(\s*([+-]?\d*\.?\d+)(\w*)?\s*[\s,]\s*([+-]?\d*\.?\d+)%?\s*,?\s*([+-]?\d*\.?\d+)%?(?:\s*[\/,]\s*([+-]?\d*\.?\d+)(%)?)?\s*\)?$/; const parseColor = (color, opacity) => { let rgb; let hsl; let format; let str = ""; if (isString(color)) { str = color.trim(); } else if (isPlainObject(color)) { format = getColorObjectFormat(color); if (format) { str = stringify(color, format); } } if (/^hsl/.test(str)) { const [isHSL, h, angle, s, l, a = "1", percentage] = HSL_REGEX.exec(str) || []; if (isHSL) { hsl = { h: normalizeAngle(+h * (ANGLE_COEFFICIENT_MAP[angle] || 1)), s: clamp(+s), l: clamp(+l), a: clamp(+a / (percentage ? 100 : 1), 1) }; } } if (!hsl) { // # is optional. if (/^[\da-f]+$/i.test(str)) { str = "#" + str; } ctx.fillStyle = DEFAULT_COLOR; ctx.fillStyle = str; str = ctx.fillStyle; if (str[0] === "#") { rgb = { r: toDecimal(str[1] + str[2]), g: toDecimal(str[3] + str[4]), b: toDecimal(str[5] + str[6]), a: 1 }; } else { const [r, g, b, a] = str.match(/[\d\.]+/g).map(Number); rgb = { r, g, b, a }; } hsl = RGBToHSL(rgb); } hsl.a = opacity ? round(hsl.a * 100) / 100 : 1; if (rgb) { rgb.a = hsl.a; } return [hsl, rgb]; }; const colorState = alwan => { const state = { h: 0, s: 0, l: 0, r: 0, g: 0, b: 0, a: 1, rgb: "", hsl: "", hex: "" }; const config = alwan.config; const emitEvent = alwan.e._emit; let onUpdate; let onSetColor; let currentFormat; let cashedColor; return { _value: state, _toString: () => state[currentFormat], _setHooks(onUpdateFn, onSetColorFn) { onUpdate = onUpdateFn; onSetColor = onSetColorFn; }, _setFormat(format) { currentFormat = config.format = format; }, _update(hsl, rgb, emitColor = true, emitChange) { const previousHex = state.hex; merge(state, hsl); merge(state, rgb || HSLToRGB(state)); state.s = round(state.s); state.l = round(state.l); state.rgb = stringify(state); state.hsl = stringify(state, HSL_FORMAT); state.hex = RGBToHEX(state); onUpdate(state); if (previousHex !== state.hex) { emitColor && emitEvent(COLOR, state); emitChange && emitEvent(CHANGE, state); } }, _parse(color, emitColor = false, emitChange) { this._update(...parseColor(color, config.opacity), emitColor, emitChange); onSetColor(state); }, _cache() { cashedColor = state[currentFormat]; }, /** * Emit change event if the color have changed compared to the cashed color. */ _change() { if (cashedColor !== state[currentFormat]) { emitEvent(CHANGE, state); } } }; }; const eventEmitter = alwan => { const listeners = { [OPEN]: [], [CLOSE]: [], [CHANGE]: [], [COLOR]: [] }; return { _emit(type, value = alwan.s._value) { (listeners[type] || []).forEach(listener => listener(merge({ type, source: alwan }, value))); }, _on(event, listener) { if (listener && !(listeners[event] || []).includes(listener)) { listeners[event].push(listener); } }, _off(event, listener) { if (!event) { // Remove all listeners. ObjectForEach(listeners, event => { listeners[event] = []; }); } else if (listeners[event]) { if (listener) { listeners[event] = listeners[event].filter(fn => fn !== listener); } else { // Remove all listeners of the given event. listeners[event] = []; } } } }; }; class Alwan { static version() { return version; } static setDefaults(defaults) { deepMerge(alwanDefaults, defaults); } constructor(reference, options) { this.config = deepMerge({}, alwanDefaults); this.e = eventEmitter(this); this.s = colorState(this); this.c = controller(this, getElements(reference)[0]); this.c._setup(options || {}); } setOptions(options) { options && this.c._setup(options); } setColor(color) { this.s._parse(color); return this; } getColor() { return { ...this.s._value }; } isOpen() { return this.c._isOpen(); } open() { this.c._toggle(true); } close() { this.c._toggle(false); } toggle() { this.c._toggle(); } on(type, listener) { this.e._on(type, listener); } off(type, listener) { this.e._off(type, listener); } addSwatches(...swatches) { this.c._setup({ swatches: this.config.swatches.concat(swatches) }); } removeSwatches(...swatches) { this.c._setup({ swatches: this.config.swatches.filter((swatch, index) => !swatches.some(item => isNumber(item) ? +item === index : item === swatch)) }); } enable() { this.c._setup({ disabled: false }); } disable() { this.c._setup({ disabled: true }); } reset() { this.s._parse(this.config.default); } reposition() { this.c._reposition(); } trigger(type) { this.e._emit(type); } destroy() { this.c._destroy(); ObjectForEach(this, key => { this[key] = null; }); setPrototypeOf(this, prototype); } } return Alwan; }));