import { hslToHex } from "../utilities/tools.js"
/**
* @param {object} options
* @param {function(string): void} options.onSelect - called with hex color on each hue change
* @param {function(): void} options.onCancel - called when picker is dismissed without selection
*/
export class HuePicker {
/**
* @param {object} options - configuration callbacks for the hue picker
* @param {function(string): void} options.onSelect - called with hex color on each hue change
* @param {function(): void} options.onCancel - called when picker is dismissed without selection
*/
constructor({ onSelect, onCancel }) {
this.onSelect = onSelect;
this.onCancel = onCancel;
this.el = null;
this._picking = false;
this._onMouseMove = (e) => { if (this._picking) this.#applyHue(e); };
this._onMouseUp = () => { this._picking = false; };
}
/**
* Renders and positions the picker popover anchored below `anchorEl`.
* @param {HTMLElement} anchorEl - element to anchor below (e.g. a color swatch)
* @param {number} initialHue - hue (0–360) to pre-position the cursor at
*/
open(anchorEl, initialHue = 0) {
this.remove();
const picker = document.createElement('div');
picker.className = 'hue-picker';
this.el = picker;
const label = document.createElement('div');
label.className = 'hue-picker-label';
label.textContent = 'Speaker color';
picker.appendChild(label);
const stripWrap = document.createElement('div');
stripWrap.className = 'hue-strip-wrap';
const canvas = document.createElement('canvas');
canvas.width = 256; canvas.height = 14;
const ctx = canvas.getContext('2d');
const grad = ctx.createLinearGradient(0, 0, 256, 0);
for (let i = 0; i <= 12; i++) grad.addColorStop(i / 12, `hsl(${i * 30},100%,60%)`);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 256, 14);
stripWrap.appendChild(canvas);
const cursor = document.createElement('div');
cursor.className = 'hue-cursor';
cursor.style.left = (initialHue / 360 * 100) + '%';
stripWrap.appendChild(cursor);
this._cursor = cursor;
this._stripWrap = stripWrap;
stripWrap.addEventListener('mousedown', (e) => {
this._picking = true;
this.#applyHue(e);
e.stopPropagation();
});
window.addEventListener('mousemove', this._onMouseMove);
window.addEventListener('mouseup', this._onMouseUp);
picker.appendChild(stripWrap);
document.body.appendChild(picker);
// Position popover below the anchor, clamped to viewport edges
const sr = anchorEl.getBoundingClientRect();
let top = sr.bottom + 6;
let left = sr.left;
if (left + 180 > window.innerWidth) left = window.innerWidth - 188;
if (top + 80 > window.innerHeight) top = sr.top - 80;
picker.style.top = top + 'px';
picker.style.left = left + 'px';
// Close on outside click (deferred so the opening mousedown doesn't immediately trigger it)
setTimeout(() => {
document.addEventListener('mousedown', this._outsideHandler = (ev) => {
if (!picker.contains(ev.target) && ev.target !== anchorEl) {
this.onCancel?.();
this.remove();
}
});
}, 0);
}
/**
* Reads mouse position over the strip, computes a hue, and calls onSelect.
* @param {MouseEvent} e - the mouse event used to determine cursor position on the strip
*/
#applyHue(e) {
const rect = this._stripWrap.getBoundingClientRect();
const frac = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const hue = Math.round(frac * 360);
this._cursor.style.left = (frac * 100) + '%';
this.onSelect?.(hslToHex(hue, 100, 60));
}
/** Removes the picker from the DOM and cleans up all listeners. */
remove() {
if (!this.el) return;
this.el.remove();
this.el = null;
window.removeEventListener('mousemove', this._onMouseMove);
window.removeEventListener('mouseup', this._onMouseUp);
if (this._outsideHandler) {
document.removeEventListener('mousedown', this._outsideHandler);
this._outsideHandler = null;
}
}
}