/* ── Gradient Color Picker (Dark Mode) ───────────────────── Usage: const hex = await openColorPicker(anchorEl, '#4285f4'); Returns hex string or null if cancelled. ────────────────────────────────────────────────────────── */ import { t } from './i18n.js'; // ── HSV ↔ RGB helpers ───────────────────────────────────── function hsvToRgb(h, s, v) { h = h / 360 * 6; const i = Math.floor(h), f = h - i, p = v * (1 - s), q = v * (1 - f * s), 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 [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]; } function rgbToHsv(r, g, b) { r /= 255; g /= 255; b /= 255; const max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min; let h = 0, s = max === 0 ? 0 : d / max, v = max; if (d !== 0) { switch (max) { case r: h = ((g - b) / d + (g < b ? 6 : 0)); break; case g: h = ((b - r) / d + 2); break; case b: h = ((r - g) / d + 4); break; } h *= 60; } return [h, s, v]; } function hexToRgb(hex) { hex = hex.replace('#', ''); if (hex.length === 3) hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2]; return [parseInt(hex.slice(0,2),16), parseInt(hex.slice(2,4),16), parseInt(hex.slice(4,6),16)]; } function rgbToHex(r, g, b) { return '#' + [r, g, b].map(c => c.toString(16).padStart(2, '0')).join(''); } // ── Active picker tracking ──────────────────────────────── let activePicker = null; let activeOutsideHandler = null; function closeActivePicker() { if (activePicker) { activePicker.remove(); activePicker = null; } if (activeOutsideHandler) { document.removeEventListener('mousedown', activeOutsideHandler); activeOutsideHandler = null; } } // ── Main export ─────────────────────────────────────────── export function openColorPicker(anchorEl, currentColor = '#4285f4') { closeActivePicker(); return new Promise((resolve) => { let [h, s, v] = rgbToHsv(...hexToRgb(currentColor)); // ── Build DOM ───────────────────────────────────────── const picker = document.createElement('div'); picker.className = 'gcp'; picker.innerHTML = `
`; const svCanvas = picker.querySelector('.gcp-sv'); const svCtx = svCanvas.getContext('2d', { willReadFrequently: true }); const svCursor = picker.querySelector('.gcp-sv-cursor'); const hueCanvas = picker.querySelector('.gcp-hue'); const hueCtx = hueCanvas.getContext('2d'); const hueThumb = picker.querySelector('.gcp-hue-thumb'); const preview = picker.querySelector('.gcp-preview'); const hexInput = picker.querySelector('.gcp-hex'); const selectBtn = picker.querySelector('.gcp-select'); // ── Draw functions ──────────────────────────────────── function drawSV() { const w = svCanvas.width, hh = svCanvas.height; // Base hue color const [r, g, b] = hsvToRgb(h, 1, 1); // Horizontal: white → hue color (saturation) const gradH = svCtx.createLinearGradient(0, 0, w, 0); gradH.addColorStop(0, '#fff'); gradH.addColorStop(1, `rgb(${r},${g},${b})`); svCtx.fillStyle = gradH; svCtx.fillRect(0, 0, w, hh); // Vertical: transparent → black (value) const gradV = svCtx.createLinearGradient(0, 0, 0, hh); gradV.addColorStop(0, 'rgba(0,0,0,0)'); gradV.addColorStop(1, '#000'); svCtx.fillStyle = gradV; svCtx.fillRect(0, 0, w, hh); } function drawHue() { const w = hueCanvas.width, hh = hueCanvas.height; const grad = hueCtx.createLinearGradient(0, 0, w, 0); for (let i = 0; i <= 6; i++) { const [r, g, b] = hsvToRgb(i * 60, 1, 1); grad.addColorStop(i / 6, `rgb(${r},${g},${b})`); } hueCtx.fillStyle = grad; hueCtx.fillRect(0, 0, w, hh); } function updateUI() { const [r, g, b] = hsvToRgb(h, s, v); const hex = rgbToHex(r, g, b); // Use rendered size so cursor matches the visible palette area const svRect = svCanvas.getBoundingClientRect(); const hueRect = hueCanvas.getBoundingClientRect(); // SV cursor position svCursor.style.left = (s * svRect.width) + 'px'; svCursor.style.top = ((1 - v) * svRect.height) + 'px'; // Hue thumb position hueThumb.style.left = (h / 360 * hueRect.width) + 'px'; // Preview + hex preview.style.background = hex; hexInput.value = hex.toUpperCase(); } // ── SV interaction ──────────────────────────────────── function handleSV(e) { const rect = svCanvas.getBoundingClientRect(); const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width)); const y = Math.max(0, Math.min(e.clientY - rect.top, rect.height)); s = x / rect.width; v = 1 - y / rect.height; updateUI(); } svCanvas.addEventListener('mousedown', (e) => { e.preventDefault(); handleSV(e); const move = (ev) => handleSV(ev); const up = () => { document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up); }; document.addEventListener('mousemove', move); document.addEventListener('mouseup', up); }); // Touch support for SV svCanvas.addEventListener('touchstart', (e) => { e.preventDefault(); const touch = (ev) => { const t = ev.touches[0]; handleSV(t); }; touch(e); const end = () => { document.removeEventListener('touchmove', touch); document.removeEventListener('touchend', end); }; document.addEventListener('touchmove', touch); document.addEventListener('touchend', end); }); // ── Hue interaction ─────────────────────────────────── function handleHue(e) { const rect = hueCanvas.getBoundingClientRect(); const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width)); h = (x / rect.width) * 360; drawSV(); updateUI(); } const hueTrack = picker.querySelector('.gcp-hue-track'); hueTrack.addEventListener('mousedown', (e) => { e.preventDefault(); handleHue(e); const move = (ev) => handleHue(ev); const up = () => { document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up); }; document.addEventListener('mousemove', move); document.addEventListener('mouseup', up); }); hueTrack.addEventListener('touchstart', (e) => { e.preventDefault(); const touch = (ev) => { const t = ev.touches[0]; handleHue(t); }; touch(e); const end = () => { document.removeEventListener('touchmove', touch); document.removeEventListener('touchend', end); }; document.addEventListener('touchmove', touch); document.addEventListener('touchend', end); }); // ── Hex input ───────────────────────────────────────── hexInput.addEventListener('change', () => { let val = hexInput.value.trim(); if (!val.startsWith('#')) val = '#' + val; if (/^#[0-9a-fA-F]{6}$/.test(val)) { [h, s, v] = rgbToHsv(...hexToRgb(val)); drawSV(); updateUI(); } }); // ── Select button ───────────────────────────────────── selectBtn.addEventListener('click', (e) => { e.stopPropagation(); const [r, g, b] = hsvToRgb(h, s, v); closeActivePicker(); resolve(rgbToHex(r, g, b)); }); // ── Position picker ─────────────────────────────────── document.body.appendChild(picker); activePicker = picker; const anchorRect = anchorEl.getBoundingClientRect(); let top = anchorRect.bottom + 6; let left = anchorRect.left; // Keep in viewport const pRect = picker.getBoundingClientRect(); if (left + pRect.width > window.innerWidth - 8) left = window.innerWidth - pRect.width - 8; if (left < 8) left = 8; if (top + pRect.height > window.innerHeight - 8) top = anchorRect.top - pRect.height - 6; picker.style.top = top + 'px'; picker.style.left = left + 'px'; // ── Initial draw ────────────────────────────────────── drawSV(); drawHue(); updateUI(); // ── Close on outside click ──────────────────────────── setTimeout(() => { activeOutsideHandler = (e) => { if (!picker.contains(e.target) && e.target !== anchorEl && !anchorEl.contains(e.target)) { closeActivePicker(); resolve(null); } }; document.addEventListener('mousedown', activeOutsideHandler); }, 0); }); }