454 lines
16 KiB
JavaScript
454 lines
16 KiB
JavaScript
// In-house color picker with live-feedback HSV square, hue bar,
|
|
// eyedropper, recent colors, and harmony suggestions.
|
|
// Non-invasive: wraps existing <input type="color"> elements —
|
|
// their .value stays the source of truth, and we dispatch 'input'
|
|
// events so existing listeners keep working.
|
|
|
|
const LS_RECENT = 'odysseus-recent-colors';
|
|
const MAX_RECENT = 12;
|
|
|
|
let _popover = null;
|
|
let _input = null;
|
|
let _h = 0, _s = 100, _v = 100; // HSV
|
|
let _drag = null; // 'sl' | 'hue' | null
|
|
let _onOutside = null;
|
|
|
|
// ── Color math ────────────────────────────────────────────────────────
|
|
function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
|
|
|
|
function hexToRgb(hex) {
|
|
hex = String(hex || '').replace('#', '');
|
|
if (hex.length === 3) hex = hex.split('').map(c => c + c).join('');
|
|
if (!/^[0-9a-f]{6}$/i.test(hex)) return { r: 0, g: 0, b: 0 };
|
|
const n = parseInt(hex, 16);
|
|
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
|
|
}
|
|
|
|
function rgbToHex(r, g, b) {
|
|
return '#' + [r, g, b].map(v =>
|
|
Math.round(clamp(v, 0, 255)).toString(16).padStart(2, '0')
|
|
).join('');
|
|
}
|
|
|
|
function rgbToHsv(r, g, b) {
|
|
r /= 255; g /= 255; b /= 255;
|
|
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
|
const d = max - min;
|
|
let h;
|
|
const s = max === 0 ? 0 : d / max;
|
|
const v = max;
|
|
if (d === 0) h = 0;
|
|
else if (max === r) h = ((g - b) / d + (g < b ? 6 : 0));
|
|
else if (max === g) h = (b - r) / d + 2;
|
|
else h = (r - g) / d + 4;
|
|
return { h: h * 60, s: s * 100, v: v * 100 };
|
|
}
|
|
|
|
function hsvToRgb(h, s, v) {
|
|
h = ((h % 360) + 360) % 360;
|
|
h /= 60; s /= 100; v /= 100;
|
|
const i = Math.floor(h);
|
|
const f = h - i;
|
|
const p = v * (1 - s);
|
|
const q = v * (1 - f * s);
|
|
const t = v * (1 - (1 - f) * s);
|
|
let r, g, b;
|
|
switch (i % 6) {
|
|
case 0: r = v; g = t; b = p; break;
|
|
case 1: r = q; g = v; b = p; break;
|
|
case 2: r = p; g = v; b = t; break;
|
|
case 3: r = p; g = q; b = v; break;
|
|
case 4: r = t; g = p; b = v; break;
|
|
case 5: r = v; g = p; b = q; break;
|
|
}
|
|
return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) };
|
|
}
|
|
|
|
function hsvToHex(h, s, v) { const { r, g, b } = hsvToRgb(h, s, v); return rgbToHex(r, g, b); }
|
|
|
|
function hexToHsv(hex) { const { r, g, b } = hexToRgb(hex); return rgbToHsv(r, g, b); }
|
|
|
|
// ── Storage ───────────────────────────────────────────────────────────
|
|
function getRecents() {
|
|
try { return JSON.parse(localStorage.getItem(LS_RECENT) || '[]'); }
|
|
catch { return []; }
|
|
}
|
|
|
|
function addRecent(hex) {
|
|
if (!/^#[0-9a-f]{6}$/i.test(hex)) return;
|
|
let recents = getRecents().filter(c => c.toLowerCase() !== hex.toLowerCase());
|
|
recents.unshift(hex.toLowerCase());
|
|
recents = recents.slice(0, MAX_RECENT);
|
|
try { localStorage.setItem(LS_RECENT, JSON.stringify(recents)); } catch {}
|
|
}
|
|
|
|
// ── Suggestions based on current color (5 harmony swatches) ──────────
|
|
function computeSuggestions() {
|
|
// Complement, analogous ±30°, split-complement (+150), tone shift
|
|
return [
|
|
{ hex: hsvToHex(_h + 180, _s, _v), label: 'Complement' },
|
|
{ hex: hsvToHex(_h + 30, _s, _v), label: 'Analogous +30°' },
|
|
{ hex: hsvToHex(_h - 30, _s, _v), label: 'Analogous -30°' },
|
|
{ hex: hsvToHex(_h + 150, _s, _v), label: 'Split-complement' },
|
|
{ hex: hsvToHex(_h, _s, clamp(_v > 50 ? _v - 30 : _v + 30, 10, 95)), label: 'Tone shift' },
|
|
];
|
|
}
|
|
|
|
// ── Popover build ─────────────────────────────────────────────────────
|
|
function buildPopover() {
|
|
const p = document.createElement('div');
|
|
p.className = 'cp-popover';
|
|
p.innerHTML = `
|
|
<div class="cp-sl" data-drag="sl">
|
|
<div class="cp-sl-white"></div>
|
|
<div class="cp-sl-black"></div>
|
|
<div class="cp-sl-handle"></div>
|
|
</div>
|
|
<div class="cp-hue" data-drag="hue">
|
|
<div class="cp-hue-handle"></div>
|
|
</div>
|
|
<div class="cp-row">
|
|
<div class="cp-preview"></div>
|
|
<input type="text" class="cp-hex" maxlength="7" spellcheck="false" autocomplete="off">
|
|
<button class="cp-eyedropper" title="Eyedropper" type="button">
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M2 22l4-4m0 0l3-3 5 5-3 3a2 2 0 01-2.8 0l-2.2-2.2a2 2 0 010-2.8z"/>
|
|
<path d="M14 8l3-3a3 3 0 014.2 4.2l-3 3-4.2-4.2z"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div class="cp-section-label">Suggestions</div>
|
|
<div class="cp-swatches cp-suggestions"></div>
|
|
<div class="cp-section-label">Recent</div>
|
|
<div class="cp-swatches cp-recent"></div>
|
|
`;
|
|
document.body.appendChild(p);
|
|
wireHandlers(p);
|
|
return p;
|
|
}
|
|
|
|
// ── UI sync ───────────────────────────────────────────────────────────
|
|
function syncUI() {
|
|
if (!_popover) return;
|
|
const sl = _popover.querySelector('.cp-sl');
|
|
const slH = _popover.querySelector('.cp-sl-handle');
|
|
const hue = _popover.querySelector('.cp-hue');
|
|
const hueH = _popover.querySelector('.cp-hue-handle');
|
|
const hex = _popover.querySelector('.cp-hex');
|
|
const preview = _popover.querySelector('.cp-preview');
|
|
|
|
const pureHue = hsvToHex(_h, 100, 100);
|
|
sl.style.background = pureHue; // base hue — white/black layers stacked on top via CSS
|
|
|
|
slH.style.left = (_s) + '%';
|
|
slH.style.top = (100 - _v) + '%';
|
|
|
|
hueH.style.left = (_h / 360 * 100) + '%';
|
|
|
|
const current = hsvToHex(_h, _s, _v);
|
|
preview.style.background = current;
|
|
if (document.activeElement !== hex) hex.value = current;
|
|
|
|
// Suggestions
|
|
const sContainer = _popover.querySelector('.cp-suggestions');
|
|
const sugs = computeSuggestions();
|
|
sContainer.innerHTML = sugs.map(s =>
|
|
`<button class="cp-swatch" title="${s.label}: ${s.hex}" data-hex="${s.hex}" style="background:${s.hex}"></button>`
|
|
).join('');
|
|
|
|
// Recents
|
|
const rContainer = _popover.querySelector('.cp-recent');
|
|
const recs = getRecents();
|
|
rContainer.innerHTML = recs.length
|
|
? recs.map(h => `<button class="cp-swatch" title="${h}" data-hex="${h}" style="background:${h}"></button>`).join('')
|
|
: '<div class="cp-recent-empty">(none yet)</div>';
|
|
}
|
|
|
|
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);
|
|
}
|