';
}
function applyToInput(pushChange) {
if (!_input) return;
const hex = hsvToHex(_h, _s, _v);
_input.value = hex; // setter also updates style.background
if (pushChange) _input.dispatchEvent(new Event('input', { bubbles: true }));
syncUI();
}
function setFromHex(hex) {
const v = hexToHsv(hex);
_h = v.h; _s = v.s; _v = v.v;
}
// ── Handlers ──────────────────────────────────────────────────────────
// Window-level pointer listeners — installed ONCE, not per-popover, so they
// don't leak when the popover is rebuilt on every open.
let _windowPointerInstalled = false;
function _installWindowPointer() {
if (_windowPointerInstalled) return;
_windowPointerInstalled = true;
window.addEventListener('pointermove', (e) => { if (_drag) handleDrag(e); });
window.addEventListener('pointerup', () => {
if (_drag) {
_drag = null;
commitCurrent();
}
});
}
function wireHandlers(p) {
const sl = p.querySelector('.cp-sl');
const hue = p.querySelector('.cp-hue');
const hex = p.querySelector('.cp-hex');
const eye = p.querySelector('.cp-eyedropper');
const onDown = (type) => (e) => {
_drag = type;
handleDrag(e);
e.preventDefault();
};
sl.addEventListener('pointerdown', onDown('sl'));
hue.addEventListener('pointerdown', onDown('hue'));
_installWindowPointer();
hex.addEventListener('input', () => {
let v = hex.value.trim();
if (!v.startsWith('#')) v = '#' + v;
if (/^#[0-9a-f]{6}$/i.test(v)) {
setFromHex(v);
applyToInput(true);
}
});
hex.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { commitCurrent(); close(); }
if (e.key === 'Escape') { close(); }
});
p.addEventListener('click', (e) => {
const sw = e.target.closest('.cp-swatch');
if (sw && sw.dataset.hex) {
setFromHex(sw.dataset.hex);
applyToInput(true);
commitCurrent();
}
});
if (window.EyeDropper) {
eye.addEventListener('click', async (ev) => {
ev.stopPropagation();
// Suppress the outside-click close while the OS eyedropper is open.
// Without this, the user's pixel-pick fires a window click that
// hits our document-capture listener and closes the popover.
const wasOnOutside = _onOutside;
_detachOutsideHandlers();
try {
const r = await new window.EyeDropper().open();
if (r && r.sRGBHex) {
setFromHex(r.sRGBHex);
applyToInput(true);
commitCurrent();
}
} catch (_) { /* user cancelled */ }
// Re-arm outside-click handler after a frame so the eyedropper's
// own pick-click doesn't immediately re-close us.
if (wasOnOutside && _popover) {
requestAnimationFrame(() => {
if (!_popover) return;
_onOutside = wasOnOutside;
_onEsc = (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); } };
document.addEventListener('click', _onOutside, true);
document.addEventListener('keydown', _onEsc, true);
});
}
});
} else {
eye.disabled = true;
eye.style.opacity = '0.3';
eye.title = 'Eyedropper not supported in this browser';
}
}
function handleDrag(e) {
if (_drag === 'sl') {
const sl = _popover.querySelector('.cp-sl');
const r = sl.getBoundingClientRect();
const x = clamp((e.clientX - r.left) / r.width, 0, 1);
const y = clamp((e.clientY - r.top) / r.height, 0, 1);
_s = x * 100;
_v = (1 - y) * 100;
applyToInput(true);
} else if (_drag === 'hue') {
const hue = _popover.querySelector('.cp-hue');
const r = hue.getBoundingClientRect();
const x = clamp((e.clientX - r.left) / r.width, 0, 1);
_h = x * 360;
applyToInput(true);
}
}
function commitCurrent() {
if (!_input) return;
addRecent(_input.value);
syncUI();
}
// ── Open / close ──────────────────────────────────────────────────────
function position(p, anchor) {
const rect = anchor.getBoundingClientRect();
const pRect = p.getBoundingClientRect();
let left = rect.left;
let top = rect.bottom + 6;
if (left + pRect.width > window.innerWidth - 8) left = window.innerWidth - pRect.width - 8;
if (top + pRect.height > window.innerHeight - 8) top = rect.top - pRect.height - 6;
if (left < 8) left = 8;
if (top < 8) top = 8;
p.style.left = left + 'px';
p.style.top = top + 'px';
}
let _onEsc = null;
function _detachOutsideHandlers() {
if (_onOutside) {
document.removeEventListener('click', _onOutside, true);
document.removeEventListener('mousedown', _onOutside, true);
document.removeEventListener('pointerdown', _onOutside, true);
_onOutside = null;
}
if (_onEsc) {
document.removeEventListener('keydown', _onEsc, true);
_onEsc = null;
}
}
function _destroyPopover() {
_detachOutsideHandlers();
if (_popover && _popover.parentNode) {
_popover.parentNode.removeChild(_popover);
}
_popover = null;
_input = null;
_drag = null;
}
function open(inputEl) {
// Always tear down any previous popover so we never inherit stale state
// (orphaned listeners, hidden-but-mispositioned div, etc.).
_destroyPopover();
_popover = buildPopover();
_input = inputEl;
setFromHex(inputEl.value || '#000000');
_popover.style.display = 'block';
_popover.style.visibility = 'visible';
_popover.style.opacity = '1';
_popover.style.pointerEvents = 'auto';
// Let it render with its natural size, then position
requestAnimationFrame(() => {
if (_popover && _input) position(_popover, _input);
});
syncUI();
_onOutside = (e) => {
if (_drag) return; // ignore during drag
if (!_popover) return;
if (_popover.contains(e.target)) return;
if (e.target === _input) return;
// If the click landed on a modal close button (X), swallow it so the
// popover-close doesn't also dismiss the enclosing modal. The user
// wants their first click to just close the color picker.
const closeBtn = e.target.closest && e.target.closest('.close-btn, [aria-label*="lose" i]');
if (closeBtn) {
e.preventDefault();
e.stopPropagation();
if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation();
}
close();
};
_onEsc = (e) => {
if (e.key === 'Escape') {
// Same idea for the keyboard: Escape closes the picker first; the
// modal's own Esc handler only fires on the next press.
e.preventDefault();
e.stopPropagation();
if (typeof e.stopImmediatePropagation === 'function') e.stopImmediatePropagation();
close();
}
};
// Defer install so the click that opened us doesn't immediately close us.
// Use requestAnimationFrame instead of setTimeout(0) to be sure the current
// click event has fully bubbled before we register the listener.
requestAnimationFrame(() => {
document.addEventListener('click', _onOutside, true);
// pointerdown fires before click on touch devices, and reliably even
// when the tap target swallows the click. Catching it ensures
// outside-touches close the picker on mobile.
document.addEventListener('pointerdown', _onOutside, true);
document.addEventListener('keydown', _onEsc, true);
});
}
function close() {
_destroyPopover();
}
// ── Attach to inputs ──────────────────────────────────────────────────
// Standard setter we need to call after wrapping .value with a custom setter.
const _NATIVE_VALUE_DESC = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value');
function _syncSwatch(el) {
const v = _NATIVE_VALUE_DESC.get.call(el);
if (/^#[0-9a-f]{6}$/i.test(v || '')) el.style.background = v;
}
export function attachColorPicker(inputEl) {
if (!inputEl || inputEl.dataset.cpAttached === '1') return;
inputEl.dataset.cpAttached = '1';
// Neutralize the native color dialog by changing the element's type.
// Existing `.value` reads + `input` event listeners continue to work.
const initialAttr = inputEl.getAttribute('value');
const initial = inputEl.value || initialAttr || '#000000';
inputEl.setAttribute('data-cp-original-type', inputEl.type || 'color');
inputEl.type = 'text';
inputEl.readOnly = true;
inputEl.classList.add('cp-swatch-input');
// Wrap .value so ANY assignment (from theme.js applyColors etc.) auto-updates the swatch bg.
Object.defineProperty(inputEl, 'value', {
configurable: true,
get() { return _NATIVE_VALUE_DESC.get.call(this); },
set(v) {
_NATIVE_VALUE_DESC.set.call(this, v);
_syncSwatch(this);
},
});
// Apply initial value so swatch shows color even before any programmatic set.
inputEl.value = initial;
// Use mousedown so we fire BEFORE any document-level click handler
// (e.g. our own _onOutside listener) can decide to close.
inputEl.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
// If the same input already has the picker open, close it (toggle).
// Otherwise always (re)open — never get stuck in a "won't reopen" state.
if (_input === inputEl && _popover) {
close();
} else {
open(inputEl);
}
});
// Suppress the trailing click so it can't bubble to overlays/listeners.
inputEl.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
});
}
export function initColorPickers(root = document) {
root.querySelectorAll('input[type="color"]').forEach(attachColorPicker);
}
// Re-run on new inputs that may mount after init
export function refreshColorPickers(root = document) {
initColorPickers(root);
}