stylus/js/color/color-picker.js

902 lines
27 KiB
JavaScript

/* global colorConverter */
/* global colorMimicry */
'use strict';
(window.CodeMirror ? window.CodeMirror.prototype : window).colorpicker = function () {
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 $rgb = {};
const $hsl = {};
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;
$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'),
]),
]),
]),
$inputGroups.rgb = $(['input-group', 'rgb'], [
$(['input-field', 'rgb-r'], [
$rgb.r = $('input', {tag: 'input', type: 'number', min: 0, max: 255, step: 1}),
$('title', 'R'),
]),
$(['input-field', 'rgb-g'], [
$rgb.g = $('input', {tag: 'input', type: 'number', min: 0, max: 255, step: 1}),
$('title', 'G'),
]),
$(['input-field', 'rgb-b'], [
$rgb.b = $('input', {tag: 'input', type: 'number', min: 0, max: 255, step: 1}),
$('title', 'B'),
]),
$(['input-field', 'rgb-a'], [
$rgb.a = $('input', {tag: 'input', type: 'text', pattern: alphaPattern, spellcheck: false}),
$('title', 'A'),
]),
]),
$inputGroups.hsl = $(['input-group', 'hsl'], [
$(['input-field', 'hsl-h'], [
$hsl.h = $('input', {tag: 'input', type: 'number', step: 1}),
$('title', 'H'),
]),
$(['input-field', 'hsl-s'], [
$hsl.s = $('input', {tag: 'input', type: 'number', min: 0, max: 100, step: 1}),
$('title', 'S'),
]),
$(['input-field', 'hsl-l'], [
$hsl.l = $('input', {tag: 'input', type: 'number', min: 0, max: 100, step: 1}),
$('title', 'L'),
]),
$(['input-field', 'hsl-a'], [
$hsl.a = $('input', {tag: 'input', type: 'text', pattern: alphaPattern, spellcheck: false}),
$('title', 'A'),
]),
]),
$('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,
}),
]);
$inputs.hex = [$hexCode];
$inputs.rgb = [$rgb.r, $rgb.g, $rgb.b, $rgb.a];
$inputs.hsl = [$hsl.h, $hsl.s, $hsl.l, $hsl.a];
const inputsToArray = inputs => inputs.map(el => parseFloat(el.value));
const inputsToHexString = () => $hexCode.value.trim();
const inputsToRGB = ([r, g, b, a] = inputsToArray($inputs.rgb)) => ({r, g, b, a, type: 'rgb'});
const inputsToHSL = ([h, s, l, a] = inputsToArray($inputs.hsl)) => ({h, s, l, a, type: 'hsl'});
Object.defineProperty($inputs.hex, 'color', {get: inputsToHexString});
Object.defineProperty($inputs.rgb, 'color', {get: inputsToRGB});
Object.defineProperty($inputs.hsl, 'color', {get: inputsToHSL});
Object.defineProperty($inputs, 'color', {get: () => $inputs[currentFormat].color});
Object.defineProperty($inputs, 'colorString', {
get: () => currentFormat && colorConverter.format($inputs[currentFormat].color),
});
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;
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) {
switch (typeof color) {
case 'string':
color = colorConverter.parse(color);
break;
case 'object': {
const {r, g, b, a} = color;
if (!isNaN(r) && !isNaN(g) && !isNaN(b)) {
color = {r, g, b, a, type: 'rgb'};
break;
}
const {h, s, l} = color;
if (!isNaN(h) && !isNaN(s) && !isNaN(l)) {
color = {h, s, l, a, type: 'hsl'};
break;
}
}
// fallthrough
default:
return false;
}
if (color) {
if (!initialized) {
init();
}
setFromColor(color);
}
return Boolean(color);
}
function getColor(type) {
if (!initialized) {
return;
}
readCurrentColorFromRamps();
const color = type === 'hsl' ?
colorConverter.HSVtoHSL(HSV) :
colorConverter.HSVtoRGB(HSV);
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 = ['hex', 'rgb', 'hsl'];
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 ($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 = $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 === $inputs.hsl[0];
const isAlpha = el === $inputs[currentFormat][3];
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][3];
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 = color.type === 'hsl' ?
colorConverter.HSLtoHSV(color) :
colorConverter.RGBtoHSV(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($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.HSVtoRGB(HSV);
switch (currentFormat) {
case 'hex':
rgb.a = HSV.a;
$hexCode.value = colorToString(rgb, 'hex');
break;
case 'rgb': {
$rgb.r.value = rgb.r;
$rgb.g.value = rgb.g;
$rgb.b.value = rgb.b;
$rgb.a.value = alphaToString() || 1;
break;
}
case 'hsl': {
const {h, s, l} = colorConverter.HSVtoHSL(HSV);
$hsl.h.value = h;
$hsl.s.value = s;
$hsl.l.value = l;
$hsl.a.value = alphaToString() || 1;
}
}
$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 &&
$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.hexUppercase);
}
function alphaToString(a = HSV.a) {
return colorConverter.formatAlpha(a);
}
function currentColorToString(format = currentFormat, alpha = HSV.a) {
const converted = format === 'hsl' ?
colorConverter.HSVtoHSL(HSV) :
colorConverter.HSVtoRGB(HSV);
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 constrain(min, max, value) {
return value < min ? min : value > max ? max : value;
}
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
};