/* 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
};