Odysseus v1.0
This commit is contained in:
453
static/js/colorPicker.js
Normal file
453
static/js/colorPicker.js
Normal file
@@ -0,0 +1,453 @@
|
||||
// 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);
|
||||
}
|
||||
Reference in New Issue
Block a user