/* global colorConverter */ /* global colorMimicry */ 'use strict'; (window.CodeMirror ? window.CodeMirror.prototype : window).colorpicker = function () { const {constrain} = colorConverter; const cm = window.CodeMirror && this; const CSS_PREFIX = 'colorpicker-'; const HUE_COLORS = [ {hex: '#ff0000', start: .0}, {hex: '#ffff00', start: .17}, {hex: '#00ff00', start: .33}, {hex: '#00ffff', start: .50}, {hex: '#0000ff', start: .67}, {hex: '#ff00ff', start: .83}, {hex: '#ff0000', start: 1}, ]; const MIN_HEIGHT = 220; const MARGIN = 8; let maxHeight = '0px'; let HSV = {}; let currentFormat; const prevHSV = {}; let initialized = false; let shown = false; let options = {}; let /** @type {HTMLElement} */ $root; let /** @type {HTMLElement} */ $sat; let /** @type {HTMLElement} */ $satPointer; let /** @type {HTMLElement} */ $hue; let /** @type {HTMLElement} */ $hueKnob; let /** @type {HTMLElement} */ $opacity; let /** @type {HTMLElement} */ $opacityBar; let /** @type {HTMLElement} */ $opacityKnob; let /** @type {HTMLElement} */ $swatch; let /** @type {HTMLElement} */ $formatChangeButton; let /** @type {HTMLElement} */ $hexCode; let /** @type {HTMLElement} */ $palette; const $inputGroups = {}; const $inputs = {}; const $hexLettercase = {}; const allowInputFocus = !('ontouchstart' in document) || window.innerHeight > 800; const dragging = { saturationPointerPos: {x: 0, y: 0}, hueKnobPos: 0, saturation: false, hue: false, opacity: false, popup: false, }; let prevFocusedElement; let lastOutputColor; let userActivity; const PUBLIC_API = { $root, show, hide, setColor, getColor, options, }; return PUBLIC_API; //region DOM function init() { /** @returns {HTMLElement} */ function $(cls, props = {}, children = []) { if (Array.isArray(props) || typeof props === 'string' || props instanceof Node) { children = props; props = {}; } const el = document.createElement(props.tag || 'div'); el.className = toArray(cls).map(c => c ? CSS_PREFIX + c : '').join(' '); el.append(...toArray(children)); if (props) delete props.tag; return Object.assign(el, props); } const alphaPattern = /^\s*(0+\.?|0*\.\d+|0*1\.?|0*1\.0*)?\s*$/.source; const nestedObj = (obj, key) => (obj[key] || (obj[key] = {})); const makeNum = (type, channel, props, min, max) => $(['input-field', `${type}-${channel}`], [ (nestedObj($inputs, type)[channel] = $('input', props || {tag: 'input', type: 'number', min, max, step: 1})), $('title', channel.toUpperCase()), ]); const ColorGroup = (type, channels) => ( $inputGroups[type] = $(['input-group', type], [ ...Object.entries(channels).map(([k, v]) => makeNum(type, k, null, v[0], v[1])), makeNum(type, 'a', {tag: 'input', type: 'text', pattern: alphaPattern, spellcheck: false}), ]) ); $root = $('popup', { oninput: setFromInputs, onkeydown: setFromKeyboard, }, [ $sat = $('saturation-container', { onmousedown: onSaturationMouseDown, onmouseup: onSaturationMouseUp, }, [ $('saturation', [ $('value', [ $satPointer = $('drag-pointer'), ]), ]), ]), $('popup-mover', {onmousedown: onPopupMoveStart}), $('sliders', [ $('hue', {onmousedown: onHueMouseDown}, [ $hue = $('hue-container', [ $hueKnob = $('hue-knob', {onmousedown: onHueKnobMouseDown}), ]), ]), $('opacity', [ $opacity = $('opacity-container', {onmousedown: onOpacityMouseDown}, [ $opacityBar = $('opacity-bar'), $opacityKnob = $('opacity-knob', {onmousedown: onOpacityKnobMouseDown}), ]), ]), $('empty'), $swatch = $('swatch'), ]), $(['input-container', 'hex'], [ $inputGroups.hex = $(['input-group', 'hex'], [ $(['input-field', 'hex'], [ $hexCode = $('input', {tag: 'input', type: 'text', spellcheck: false, pattern: /^\s*#([a-fA-F\d]{3}([a-fA-F\d]([a-fA-F\d]{2}([a-fA-F\d]{2})?)?)?)\s*$/.source, }), $('title', [ $hexLettercase.true = $('title-action', {onclick: onHexLettercaseClicked}, 'HEX'), '\xA0/\xA0', $hexLettercase.false = $('title-action', {onclick: onHexLettercaseClicked}, 'hex'), ]), ]), ]), ColorGroup('rgb', {r: [0, 255], g: [0, 255], b: [0, 255]}), ColorGroup('hsl', {h: [], s: [0, 100], l: [0, 100]}), ColorGroup('hwb', {h: [], w: [0, 100], b: [0, 100]}), $('format-change', [ $formatChangeButton = $('format-change-button', {onclick: setFromFormatElement}, '↔'), ]), window.EyeDropper && $('dropper', { tag: 'img', onclick: () => new window.EyeDropper().open().then(r => setFromColor(r.sRGBHex), () => 0), srcset: '/images/eyedropper/16px.png, /images/eyedropper/32px.png 2x', }), ]), $palette = $('palette', { onclick: onPaletteClicked, oncontextmenu: onPaletteClicked, }), ]); const inputsToObj = type => { const res = {type}; for (const [k, el] of Object.entries($inputs[type])) { res[k] = parseFloat(el.value); } return res; }; for (const [key, val] of Object.entries($inputs)) { Object.defineProperty(val, 'color', { get: inputsToObj.bind(null, key), }); } Object.defineProperty($inputs.hex = [$hexCode], 'color', { get: () => $hexCode.value.trim(), }); Object.defineProperty($inputs, 'color', { get: () => $inputs[currentFormat].color, }); Object.defineProperty($inputs, 'colorString', { get: () => currentFormat && colorConverter.format($inputs[currentFormat].color, undefined, {round: true}), }); HUE_COLORS.forEach(color => Object.assign(color, colorConverter.parse(color.hex))); $root.style.setProperty('--margin', MARGIN + 'px'); initialized = true; } //endregion //region Public API function show(opt) { if (!initialized) { init(); } HSV = {}; currentFormat = ''; options = PUBLIC_API.options = opt; if (opt.round !== false) opt.round = true; prevFocusedElement = document.activeElement; userActivity = 0; lastOutputColor = opt.color || ''; $formatChangeButton.title = opt.tooltipForSwitcher || ''; maxHeight = `${opt.maxHeight || 300}px`; $root.className = [...$root.classList] .filter(c => !c.startsWith(`${CSS_PREFIX}theme-`)) .concat(`${CSS_PREFIX}theme-${['dark', 'light'].includes(opt.theme) ? opt.theme : guessTheme()}`) .join(' '); document.body.appendChild($root); shown = true; registerEvents(); setFromColor(opt.color); setFromHexLettercaseElement(); if (Array.isArray(options.palette)) { renderPalette(); } if (!isNaN(options.left) && !isNaN(options.top)) { reposition(); } } function hide() { if (shown) { colorpickerCallback(''); unregisterEvents(); focusNoScroll(prevFocusedElement); $root.remove(); shown = false; } } function setColor(color) { if (typeof color === 'string') { color = colorConverter.parse(color); } else if (typeof color === 'object' && color && !color.type) { color = Object.assign({}, color, {type: colorConverter.guessType(color)}); } if (!color || !color.type) { return false; } if (!initialized) { init(); } setFromColor(color); return true; } function getColor(type) { if (!initialized) { return; } readCurrentColorFromRamps(); const color = colorConverter.fromHSV(HSV, type); return type ? colorToString(color, type) : color; } //endregion //region DOM-to-state function readCurrentColorFromRamps() { if ($sat.offsetWidth === 0) { HSV.h = HSV.s = HSV.v = 0; } else { const {x, y} = dragging.saturationPointerPos; HSV.h = colorConverter.snapToInt((dragging.hueKnobPos / $hue.offsetWidth) * 360); HSV.s = x / $sat.offsetWidth; HSV.v = ($sat.offsetHeight - y) / $sat.offsetHeight; } } function setFromSaturationElement(event) { event.preventDefault(); const w = $sat.offsetWidth; const h = $sat.offsetHeight; const bb = $root.getBoundingClientRect(); const deltaX = event.clientX - bb.left; const deltaY = event.clientY - bb.top; const x = dragging.saturationPointerPos.x = constrain(0, w, deltaX); const y = dragging.saturationPointerPos.y = constrain(0, h, deltaY); $satPointer.style.left = `${x - 5}px`; $satPointer.style.top = `${y - 5}px`; readCurrentColorFromRamps(); renderInputs(); } function setFromHueElement(event) { const {left, width} = getScreenBounds($hue); const currentX = event ? getTouchPosition(event).clientX : left + width * colorConverter.constrainHue(HSV.h) / 360; const normalizedH = constrain(0, 1, (currentX - left) / width); const x = dragging.hueKnobPos = width * normalizedH; $hueKnob.style.left = (x - Math.round($hueKnob.offsetWidth / 2)) + 'px'; $sat.style.backgroundColor = hueDistanceToColorString(normalizedH); HSV.h = event ? Math.round(normalizedH * 360) : HSV.h; renderInputs(); } function setFromOpacityElement(event) { const {left, width} = getScreenBounds($opacity); const normalized = constrain(0, 1, (getTouchPosition(event).clientX - left) / width); const x = width * normalized; $opacityKnob.style.left = (x - Math.ceil($opacityKnob.offsetWidth / 2)) + 'px'; HSV.a = Math.round(normalized * 100) / 100; renderInputs(); } function setFromFormatElement({shiftKey}) { userActivity = performance.now(); HSV.a = isNaN(HSV.a) ? 1 : HSV.a; const formats = Object.keys($inputGroups); const dir = shiftKey ? -1 : 1; const total = formats.length; if ($inputs.colorString === $inputs.prevColorString) { Object.assign(HSV, prevHSV); } switchInputGroup(formats[(formats.indexOf(currentFormat) + dir + total) % total]); renderInputs(); } function setFromHexLettercaseElement() { const isUpper = Boolean(options.hexUppercase); $hexLettercase[isUpper].dataset.active = ''; delete $hexLettercase[!isUpper].dataset.active; const value = $hexCode.value; $hexCode.value = isUpper ? value.toUpperCase() : value.toLowerCase(); setFromInputs(); } function setFromInputs(event) { userActivity = event ? performance.now() : userActivity; if (Object.values($inputs[currentFormat]).every(validateInput)) { setFromColor($inputs.color); } } function setFromKeyboard(event) { const {key, ctrlKey: ctrl, altKey: alt, shiftKey: shift, metaKey: meta} = event; switch (key) { case 'Tab': case 'PageUp': case 'PageDown': if (!ctrl && !alt && !meta) { const el = document.activeElement; const inputs = Object.values($inputs[currentFormat]); const lastInput = inputs[inputs.length - 1]; if (key === 'Tab' && shift && el === inputs[0]) { maybeFocus(lastInput); } else if (key === 'Tab' && !shift && el === lastInput) { maybeFocus(inputs[0]); } else if (key !== 'Tab' && !shift) { setFromFormatElement({shift: key === 'PageUp' || shift}); } else { return; } event.preventDefault(); } return; case 'ArrowUp': case 'ArrowDown': if (!event.metaKey && document.activeElement.localName === 'input' && document.activeElement.checkValidity()) { setFromKeyboardIncrement(event); } return; } } function setFromKeyboardIncrement(event) { const el = document.activeElement; const {key, ctrlKey: ctrl, altKey: alt, shiftKey: shift} = event; const dir = key === 'ArrowUp' ? 1 : -1; let value, newValue; if (currentFormat === 'hex') { value = el.value.trim(); const isShort = value.length <= 5; const [r, g, b, a = ''] = el.value.match(isShort ? /[\da-f]/gi : /[\da-f]{2}/gi); let ceiling, data; if (!ctrl && !shift && !alt) { ceiling = isShort ? 0xFFF : 0xFFFFFF; data = [[true, r + g + b]]; } else { ceiling = isShort ? 15 : 255; data = [[ctrl, r], [shift, g], [alt, b]]; } newValue = '#' + data.map(([affected, part]) => { part = constrain(0, ceiling, parseInt(part, 16) + dir * (affected ? 1 : 0)); return (part + ceiling + 1).toString(16).slice(1); }).join('') + a; newValue = options.hexUppercase ? newValue.toUpperCase() : newValue.toLowerCase(); } else if (!alt) { value = parseFloat(el.value); const isHue = el.title === 'H'; const isAlpha = el === $inputs[currentFormat].a; const isRGB = currentFormat === 'rgb'; const min = isHue ? -360 : 0; const max = isHue ? 360 : isAlpha ? 1 : isRGB ? 255 : 100; const scale = isAlpha ? .01 : 1; const delta = shift && !ctrl ? 10 : ctrl && !shift ? (isHue || isRGB ? 100 : 50) : 1; newValue = constrain(min, max, value + delta * scale * dir); newValue = isAlpha ? alphaToString(newValue) : newValue; } event.preventDefault(); userActivity = performance.now(); if (newValue !== undefined && newValue !== value) { el.value = newValue; setFromColor($inputs.color); } } function validateInput(el) { const isAlpha = el === $inputs[currentFormat].a; let isValid = (isAlpha || el.value.trim()) && el.checkValidity(); if (!isAlpha && !isValid && currentFormat === 'rgb') { isValid = parseAs(el, parseInt); } else if (isAlpha && !isValid) { isValid = parseAs(el, parseFloat); } if (isAlpha && isValid) { isValid = lastOutputColor !== colorToString($inputs.color); } return isValid; } //endregion //region State-to-DOM function setFromColor(color) { color = typeof color === 'string' ? colorConverter.parse(color) : color; color = color || colorConverter.parse('#f00'); const newHSV = colorConverter.toHSV(color); if (Object.entries(newHSV).every(([k, v]) => v === HSV[k] || Math.abs(v - HSV[k]) < 1e-3)) { return; } HSV = newHSV; renderKnobs(color); switchInputGroup(color.type); setFromHueElement(); } function switchInputGroup(format) { if (currentFormat === format) { return; } if (currentFormat) { delete $inputGroups[currentFormat].dataset.active; } else { for (const format in $inputGroups) { delete $inputGroups[format].dataset.active; } } $inputGroups[format].dataset.active = ''; maybeFocus(Object.values($inputs[format])[0]); currentFormat = format; } function renderKnobs(color) { const x = $sat.offsetWidth * HSV.s; const y = $sat.offsetHeight * (1 - HSV.v); $satPointer.style.left = (x - 5) + 'px'; $satPointer.style.top = (y - 5) + 'px'; dragging.saturationPointerPos = {x, y}; const hueX = $hue.offsetWidth * constrain(0, 1, HSV.h / 360); $hueKnob.style.left = (hueX - 7.5) + 'px'; dragging.hueKnobPos = hueX; const opacityX = $opacity.offsetWidth * (isNaN(HSV.a) ? 1 : HSV.a); $opacityKnob.style.left = (opacityX - 7.5) + 'px'; $sat.style.backgroundColor = color; } function renderInputs() { const rgb = colorConverter.fromHSV(HSV, 'rgb'); if (currentFormat === 'hex') { $hexCode.value = colorToString(rgb, 'hex'); } else { for (const [k, v] of Object.entries(colorConverter.fromHSV(HSV, currentFormat))) { const el = $inputs[currentFormat][k]; if (el) el.value = k === 'a' ? alphaToString() || 1 : Math.round(v); } } $swatch.style.backgroundColor = colorToString(rgb, 'rgb'); $opacityBar.style.background = 'linear-gradient(to right,' + colorToString(Object.assign(rgb, {a: 0}), 'rgb') + ',' + colorToString(Object.assign(rgb, {a: 1}), 'rgb') + ')'; colorpickerCallback(); const colorString = $inputs.colorString; if ($inputs.prevColorString === colorString) { // keep the internal HSV calculated initially for this color format Object.assign(HSV, prevHSV); } else { // remember the internal HSV $inputs.prevColorString = colorString; Object.assign(prevHSV, HSV); } } //endregion //region Event listeners /** @param {MouseEvent} event */ function onPopupMoveStart(event) { if (!event.button && !hasModifiers(event)) { captureMouse(event, 'popup'); $root.dataset.moving = ''; const [x, y] = ($root.style.transform.match(/[-.\d]+/g) || []).map(parseFloat); dragging.popupX = event.clientX - (x || 0); dragging.popupY = event.clientY - (y || 0); document.addEventListener('mouseup', onPopupMoveEnd); } } /** @param {MouseEvent} event */ function onPopupMove({clientX: x, clientY: y}) { $root.style.transform = `translate(${x - dragging.popupX}px, ${y - dragging.popupY}px)`; } /** @param {MouseEvent} event */ function onPopupMoveEnd(event) { if (!event.button) { document.addEventListener('mouseup', onPopupMoveEnd); delete $root.dataset.moving; } } /** @param {MouseEvent} event */ function onPopupResizeStart(event) { if (event.target === $root && !event.button && !hasModifiers(event)) { document.addEventListener('mouseup', onPopupResizeEnd); $root.dataset.resizing = ''; } } /** @param {MouseEvent} event */ function onPopupResizeEnd(event) { if (!event.button) { delete $root.dataset.resizing; document.removeEventListener('mouseup', onPopupResizeEnd); if (maxHeight !== $root.style.height) { maxHeight = $root.style.height; PUBLIC_API.options.maxHeight = parseFloat(maxHeight); fitPaletteHeight(); } } } function onHexLettercaseClicked() { options.hexUppercase = !options.hexUppercase; setFromHexLettercaseElement(); } function onSaturationMouseDown(event) { if (captureMouse(event, 'saturation')) { setFromSaturationElement(event); } } function onSaturationMouseUp(event) { releaseMouse(event, 'saturation'); } function onHueKnobMouseDown(event) { captureMouse(event, 'hue'); } function onOpacityKnobMouseDown(event) { captureMouse(event, 'opacity'); } function onHueMouseDown(event) { if (captureMouse(event, 'hue')) { setFromHueElement(event); } } function onOpacityMouseDown(event) { if (captureMouse(event, 'opacity')) { setFromOpacityElement(event); } } /** @param {MouseEvent} e */ function onPaletteClicked(e) { if (e.target !== e.currentTarget && e.target.__color) { if (!e.button && setColor(e.target.__color)) { userActivity = performance.now(); colorpickerCallback(); } else if (e.button && options.paletteCallback) { e.preventDefault(); // suppress the default context menu options.paletteCallback(e.target); } } } function onMouseUp(event) { releaseMouse(event, ['saturation', 'hue', 'opacity', 'popup']); if (onMouseDown.outsideClick) { if (!prevFocusedElement) hide(); } } function onMouseDown(event) { onMouseDown.outsideClick = !event.button && !event.target.closest('.colorpicker-popup'); if (onMouseDown.outsideClick) { prevFocusedElement = null; captureMouse(event); } } function onMouseMove(event) { if (event.button) return; if (dragging.saturation) setFromSaturationElement(event); if (dragging.hue) setFromHueElement(event); if (dragging.opacity) setFromOpacityElement(event); if (dragging.popup) onPopupMove(event); } function onKeyDown(e) { if (!hasModifiers(e)) { switch (e.key) { case 'Enter': case 'Escape': e.preventDefault(); e.stopPropagation(); hide(); break; } } } function onCloseRequest(event) { if (event.detail !== PUBLIC_API) { hide(); } else if (!prevFocusedElement && cm) { // we're between mousedown and mouseup and colorview wants to re-open us in this cm // so we'll prevent onMouseUp from hiding us to avoid flicker prevFocusedElement = cm.display.input; } } //endregion //region Event utilities function colorpickerCallback(colorString = currentColorToString()) { const isCallable = typeof options.callback === 'function'; // hiding if (!colorString && isCallable) { options.callback(''); return; } if ( userActivity && Object.values($inputs[currentFormat]).every(el => el.checkValidity()) ) { lastOutputColor = colorString.replace(/\b0\./g, '.'); if (isCallable) { options.callback(lastOutputColor); } } } function captureMouse({button}, mode) { if (button !== 0) { return; } document.addEventListener('mouseup', onMouseUp); document.addEventListener('mousemove', onMouseMove); if (!mode) { return; } for (const m of toArray(mode)) { dragging[m] = true; } userActivity = performance.now(); return true; } function hasModifiers(e) { return e.shiftKey || e.ctrlKey || e.altKey || e.metaKey; } function releaseMouse(event, mode) { if (event && event.button !== 0) { return; } document.removeEventListener('mouseup', onMouseUp); document.removeEventListener('mousemove', onMouseMove); if (!mode) { return; } for (const m of toArray(mode)) { dragging[m] = false; } userActivity = performance.now(); return true; } function getTouchPosition(event) { return event.touches && event.touches[0] || event; } function registerEvents() { window.addEventListener('keydown', onKeyDown, true); window.addEventListener('mousedown', onMouseDown, true); window.addEventListener('close-colorpicker-popup', onCloseRequest, true); } function unregisterEvents() { window.removeEventListener('keydown', onKeyDown, true); window.removeEventListener('mousedown', onMouseDown, true); window.removeEventListener('close-colorpicker-popup', onCloseRequest, true); releaseMouse(); } //endregion //region Color conversion utilities function colorToString(color, type = currentFormat) { return colorConverter.format(color, type, options); } function alphaToString(a = HSV.a) { return colorConverter.formatAlpha(a); } function currentColorToString(format = currentFormat, alpha = HSV.a) { const converted = colorConverter.fromHSV(HSV, format); converted.a = isNaN(alpha) || alpha === 1 ? undefined : alpha; return colorToString(converted, format); } function mixColorToString(start, end, amount) { const obj = { r: start.r + (end.r - start.r) * amount, g: start.g + (end.g - start.g) * amount, b: start.b + (end.b - start.b) * amount, a: 1, }; return colorToString(obj, 'hex'); } function hueDistanceToColorString(hueRatio) { let prevColor; for (const color of HUE_COLORS) { if (prevColor && color.start >= hueRatio) { return mixColorToString(prevColor, color, (hueRatio - prevColor.start) / (color.start - prevColor.start)); } prevColor = color; } return HUE_COLORS[0].hex; } //endregion //region Miscellaneous utilities function reposition() { const width = $root.offsetWidth; const height = $root.offsetHeight; const maxTop = window.innerHeight - height; const maxTopUnobscured = options.top <= maxTop ? maxTop : options.top - height - 20; const maxRight = window.innerWidth - width; const maxRightUnobscured = options.left <= maxRight ? maxRight : options.left - width; const left = constrain(0, Math.max(0, maxRightUnobscured), options.left); const top = constrain(0, Math.max(0, maxTopUnobscured), options.top); $root.style.left = left + 'px'; $root.style.top = top + 'px'; $root.style.transform = ''; } function renderPalette() { // Might need to clear a lot of elements so this is known to be faster than textContent = '' while ($palette.firstChild) $palette.firstChild.remove(); $palette.append(...options.palette); if (options.palette.length) { $root.dataset.resizable = ''; $root.addEventListener('mousedown', onPopupResizeStart); fitPaletteHeight(); } else { delete $root.dataset.resizable; $root.removeEventListener('mousedown', onPopupResizeStart); } } function fitPaletteHeight() { const fit = MIN_HEIGHT + $palette.scrollHeight + MARGIN; $root.style.setProperty('--fit-height', Math.min(fit, parseFloat(maxHeight)) + 'px'); } function maybeFocus(el) { if (allowInputFocus) { el.focus(); } } function focusNoScroll(el) { if (el) { const {scrollY: y, scrollX: x} = window; el.focus({preventScroll: true}); el = null; if (window.scrollY !== y || window.scrollX !== x) { setTimeout(window.scrollTo, 0, x, y); } } } function getScreenBounds(el) { const bounds = el.getBoundingClientRect(); const {scrollTop, scrollLeft} = document.scrollingElement; return { top: bounds.top + scrollTop, left: bounds.left + scrollLeft, width: bounds.width, height: bounds.height, }; } function guessTheme() { const el = options.guessBrightness || cm && ((cm.display.renderedView || [])[0] || {}).text || cm && cm.display.lineDiv; const bgLuma = colorMimicry(el, {bg: 'backgroundColor'}).bgLuma; return bgLuma < .5 ? 'dark' : 'light'; } function parseAs(el, parser) { const num = parser(el.value); if (!isNaN(num) && (!el.min || num >= parseFloat(el.min)) && (!el.max || num <= parseFloat(el.max))) { el.value = num; return true; } } function toArray(val) { return !val ? [] : Array.isArray(val) ? val : [val]; } //endregion };