Files
Calendarr/frontend/js/color-picker.js
Scarriffle 978ad55af4 fix: Color-Picker-Cursor korrekt auf Palette ausgerichtet
Der Cursor war relativ zum .gcp-Container positioniert, aber ohne den
Offset des Canvas innerhalb des Containers (Padding). Jetzt wird die
Canvas-Position via getBoundingClientRect() eingerechnet, sodass der
Cursor exakt auf der Farbpalette bleibt.
2026-04-13 09:22:42 +02:00

256 lines
10 KiB
JavaScript

/* ── 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 = `
<canvas class="gcp-sv" width="220" height="160"></canvas>
<div class="gcp-sv-cursor"></div>
<div class="gcp-hue-track">
<canvas class="gcp-hue" width="220" height="14"></canvas>
<div class="gcp-hue-thumb"></div>
</div>
<div class="gcp-bottom">
<div class="gcp-preview"></div>
<input class="gcp-hex" type="text" maxlength="7" spellcheck="false" />
</div>
<button class="gcp-select">${t('color_select')}</button>
`;
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 rects to position cursor relative to the picker container
const svRect = svCanvas.getBoundingClientRect();
const pickerRect = picker.getBoundingClientRect();
const hueRect = hueCanvas.getBoundingClientRect();
const hueTrackRect = hueTrack.getBoundingClientRect();
// SV cursor: offset canvas position within picker + position within canvas
svCursor.style.left = (svRect.left - pickerRect.left + s * svRect.width) + 'px';
svCursor.style.top = (svRect.top - pickerRect.top + (1 - v) * svRect.height) + 'px';
// Hue thumb: offset canvas position within track + position within canvas
hueThumb.style.left = (hueRect.left - hueTrackRect.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);
});
}