- Add API specification documentation - Add system specification document - Add UI mockups and documentation - Add utility modules (wifi)
453 lines
14 KiB
JavaScript
453 lines
14 KiB
JavaScript
/**
|
|
* Chromium-style Color Picker Component
|
|
* Matches native Chromium browser color picker design
|
|
*/
|
|
|
|
class ColorPickerChromium {
|
|
constructor(container, options = {}) {
|
|
this.container = typeof container === 'string' ? document.querySelector(container) : container;
|
|
this.options = {
|
|
initialColor: options.initialColor || '#FF0000',
|
|
onColorChange: options.onColorChange || null,
|
|
showHexInput: options.showHexInput !== false,
|
|
...options
|
|
};
|
|
this.currentColor = this.options.initialColor;
|
|
this.isOpen = false;
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
this.createPicker();
|
|
this.setupEventListeners();
|
|
this.updateColor(this.options.initialColor);
|
|
}
|
|
|
|
createPicker() {
|
|
this.container.innerHTML = '';
|
|
this.container.className = 'color-picker-container';
|
|
|
|
// Color preview button
|
|
this.previewBtn = document.createElement('button');
|
|
this.previewBtn.className = 'color-picker-preview';
|
|
this.previewBtn.type = 'button';
|
|
this.previewBtn.style.backgroundColor = this.currentColor;
|
|
this.previewBtn.setAttribute('aria-label', 'Open color picker');
|
|
|
|
// Dropdown panel
|
|
this.panel = document.createElement('div');
|
|
this.panel.className = 'color-picker-panel';
|
|
this.panel.style.display = 'none';
|
|
|
|
// Main color area (hue/saturation)
|
|
this.mainArea = document.createElement('div');
|
|
this.mainArea.className = 'color-picker-main';
|
|
this.mainCanvas = document.createElement('canvas');
|
|
this.mainCanvas.width = 200;
|
|
this.mainCanvas.height = 200;
|
|
this.mainCanvas.className = 'color-picker-canvas';
|
|
this.mainArea.appendChild(this.mainCanvas);
|
|
|
|
// Main area cursor
|
|
this.mainCursor = document.createElement('div');
|
|
this.mainCursor.className = 'color-picker-cursor';
|
|
this.mainArea.appendChild(this.mainCursor);
|
|
|
|
// Hue slider
|
|
this.hueArea = document.createElement('div');
|
|
this.hueArea.className = 'color-picker-hue';
|
|
this.hueCanvas = document.createElement('canvas');
|
|
this.hueCanvas.width = 24;
|
|
this.hueCanvas.height = 200;
|
|
this.hueCanvas.className = 'color-picker-canvas';
|
|
this.hueArea.appendChild(this.hueCanvas);
|
|
|
|
// Hue slider cursor
|
|
this.hueCursor = document.createElement('div');
|
|
this.hueCursor.className = 'color-picker-hue-cursor';
|
|
this.hueArea.appendChild(this.hueCursor);
|
|
|
|
// Controls section
|
|
this.controls = document.createElement('div');
|
|
this.controls.className = 'color-picker-controls';
|
|
|
|
// Hex input
|
|
if (this.options.showHexInput) {
|
|
this.hexInput = document.createElement('input');
|
|
this.hexInput.type = 'text';
|
|
this.hexInput.className = 'color-picker-hex';
|
|
this.hexInput.placeholder = '#000000';
|
|
this.hexInput.maxLength = 7;
|
|
this.controls.appendChild(this.hexInput);
|
|
}
|
|
|
|
// RGB inputs (Chromium style - no sliders, just number inputs)
|
|
this.rgbContainer = document.createElement('div');
|
|
this.rgbContainer.className = 'color-picker-rgb';
|
|
|
|
['R', 'G', 'B'].forEach((label) => {
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = 'color-picker-rgb-item';
|
|
wrapper.dataset.channel = label.toLowerCase();
|
|
|
|
const labelEl = document.createElement('label');
|
|
labelEl.textContent = label;
|
|
|
|
const input = document.createElement('input');
|
|
input.type = 'number';
|
|
input.className = 'color-picker-rgb-input';
|
|
input.min = 0;
|
|
input.max = 255;
|
|
input.value = 0;
|
|
input.dataset.channel = label.toLowerCase();
|
|
|
|
wrapper.appendChild(labelEl);
|
|
wrapper.appendChild(input);
|
|
this.rgbContainer.appendChild(wrapper);
|
|
|
|
this[`rgb${label}`] = input;
|
|
});
|
|
|
|
this.controls.appendChild(this.rgbContainer);
|
|
|
|
// Assemble panel
|
|
const pickerArea = document.createElement('div');
|
|
pickerArea.className = 'color-picker-area';
|
|
pickerArea.appendChild(this.mainArea);
|
|
pickerArea.appendChild(this.hueArea);
|
|
|
|
this.panel.appendChild(pickerArea);
|
|
this.panel.appendChild(this.controls);
|
|
|
|
// Assemble container
|
|
this.container.appendChild(this.previewBtn);
|
|
this.container.appendChild(this.panel);
|
|
|
|
// Draw canvases
|
|
this.drawHueCanvas();
|
|
this.drawMainCanvas(1.0); // Start with full saturation
|
|
}
|
|
|
|
setupEventListeners() {
|
|
// Toggle panel
|
|
this.previewBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
this.toggle();
|
|
});
|
|
|
|
// Close on outside click
|
|
document.addEventListener('click', (e) => {
|
|
if (!this.container.contains(e.target) && this.isOpen) {
|
|
this.close();
|
|
}
|
|
});
|
|
|
|
// Main area interaction
|
|
let isMainDragging = false;
|
|
this.mainCanvas.addEventListener('mousedown', (e) => {
|
|
isMainDragging = true;
|
|
this.handleMainAreaClick(e);
|
|
});
|
|
this.mainCanvas.addEventListener('mousemove', (e) => {
|
|
if (isMainDragging) {
|
|
this.handleMainAreaClick(e);
|
|
}
|
|
});
|
|
document.addEventListener('mouseup', () => {
|
|
isMainDragging = false;
|
|
});
|
|
|
|
// Touch support for main area
|
|
this.mainCanvas.addEventListener('touchstart', (e) => {
|
|
e.preventDefault();
|
|
isMainDragging = true;
|
|
this.handleMainAreaClick(e.touches[0]);
|
|
});
|
|
this.mainCanvas.addEventListener('touchmove', (e) => {
|
|
e.preventDefault();
|
|
if (isMainDragging) {
|
|
this.handleMainAreaClick(e.touches[0]);
|
|
}
|
|
});
|
|
this.mainCanvas.addEventListener('touchend', () => {
|
|
isMainDragging = false;
|
|
});
|
|
|
|
// Hue slider interaction
|
|
let isHueDragging = false;
|
|
this.hueCanvas.addEventListener('mousedown', (e) => {
|
|
isHueDragging = true;
|
|
this.handleHueClick(e);
|
|
});
|
|
this.hueCanvas.addEventListener('mousemove', (e) => {
|
|
if (isHueDragging) {
|
|
this.handleHueClick(e);
|
|
}
|
|
});
|
|
document.addEventListener('mouseup', () => {
|
|
isHueDragging = false;
|
|
});
|
|
|
|
// Touch support for hue slider
|
|
this.hueCanvas.addEventListener('touchstart', (e) => {
|
|
e.preventDefault();
|
|
isHueDragging = true;
|
|
this.handleHueClick(e.touches[0]);
|
|
});
|
|
this.hueCanvas.addEventListener('touchmove', (e) => {
|
|
e.preventDefault();
|
|
if (isHueDragging) {
|
|
this.handleHueClick(e.touches[0]);
|
|
}
|
|
});
|
|
this.hueCanvas.addEventListener('touchend', () => {
|
|
isHueDragging = false;
|
|
});
|
|
|
|
// Hex input
|
|
if (this.hexInput) {
|
|
this.hexInput.addEventListener('input', (e) => {
|
|
const value = e.target.value;
|
|
if (/^#[0-9A-Fa-f]{6}$/.test(value)) {
|
|
this.updateColor(value);
|
|
}
|
|
});
|
|
this.hexInput.addEventListener('blur', (e) => {
|
|
const value = e.target.value;
|
|
if (!/^#[0-9A-Fa-f]{6}$/.test(value) && value.length > 0) {
|
|
e.target.value = this.currentColor;
|
|
}
|
|
});
|
|
}
|
|
|
|
// RGB inputs (Chromium style - only number inputs)
|
|
['R', 'G', 'B'].forEach(label => {
|
|
this[`rgb${label}`].addEventListener('input', (e) => {
|
|
let value = parseInt(e.target.value) || 0;
|
|
value = Math.max(0, Math.min(255, value)); // Clamp to 0-255
|
|
e.target.value = value;
|
|
const r = parseInt(this.rgbR.value) || 0;
|
|
const g = parseInt(this.rgbG.value) || 0;
|
|
const b = parseInt(this.rgbB.value) || 0;
|
|
const hex = this.rgbToHex(r, g, b);
|
|
this.updateColor(hex, false); // Don't update RGB inputs to avoid loop
|
|
});
|
|
|
|
this[`rgb${label}`].addEventListener('blur', (e) => {
|
|
let value = parseInt(e.target.value) || 0;
|
|
value = Math.max(0, Math.min(255, value));
|
|
e.target.value = value;
|
|
});
|
|
});
|
|
}
|
|
|
|
drawHueCanvas() {
|
|
const ctx = this.hueCanvas.getContext('2d');
|
|
const gradient = ctx.createLinearGradient(0, 0, 0, 200);
|
|
|
|
for (let i = 0; i <= 6; i++) {
|
|
const hue = i * 60;
|
|
gradient.addColorStop(i / 6, `hsl(${hue}, 100%, 50%)`);
|
|
}
|
|
|
|
ctx.fillStyle = gradient;
|
|
ctx.fillRect(0, 0, 24, 200);
|
|
}
|
|
|
|
drawMainCanvas(hue) {
|
|
const ctx = this.mainCanvas.getContext('2d');
|
|
|
|
// Saturation gradient (left to right)
|
|
const satGradient = ctx.createLinearGradient(0, 0, 200, 0);
|
|
satGradient.addColorStop(0, `hsl(${hue}, 0%, 50%)`);
|
|
satGradient.addColorStop(1, `hsl(${hue}, 100%, 50%)`);
|
|
ctx.fillStyle = satGradient;
|
|
ctx.fillRect(0, 0, 200, 200);
|
|
|
|
// Brightness gradient (top to bottom)
|
|
const brightGradient = ctx.createLinearGradient(0, 0, 0, 200);
|
|
brightGradient.addColorStop(0, 'rgba(255, 255, 255, 0)');
|
|
brightGradient.addColorStop(1, 'rgba(0, 0, 0, 1)');
|
|
ctx.fillStyle = brightGradient;
|
|
ctx.fillRect(0, 0, 200, 200);
|
|
}
|
|
|
|
handleMainAreaClick(e) {
|
|
const rect = this.mainCanvas.getBoundingClientRect();
|
|
const x = Math.max(0, Math.min(200, e.clientX - rect.left));
|
|
const y = Math.max(0, Math.min(200, e.clientY - rect.top));
|
|
|
|
const saturation = x / 200;
|
|
const brightness = 1 - (y / 200);
|
|
|
|
this.updateColorFromHSB(this.hue, saturation, brightness);
|
|
this.updateCursor(x, y);
|
|
}
|
|
|
|
handleHueClick(e) {
|
|
const rect = this.hueCanvas.getBoundingClientRect();
|
|
const y = Math.max(0, Math.min(200, e.clientY - rect.top));
|
|
const hue = (y / 200) * 360;
|
|
|
|
this.hue = hue;
|
|
this.drawMainCanvas(hue);
|
|
this.updateHueCursor(y);
|
|
|
|
// Recalculate color with new hue
|
|
const rect2 = this.mainCanvas.getBoundingClientRect();
|
|
const x = parseFloat(this.mainCursor.style.left) || 0;
|
|
const y2 = parseFloat(this.mainCursor.style.top) || 0;
|
|
const saturation = x / 200;
|
|
const brightness = 1 - (y2 / 200);
|
|
this.updateColorFromHSB(hue, saturation, brightness);
|
|
}
|
|
|
|
updateColorFromHSB(h, s, v) {
|
|
const rgb = this.hsbToRgb(h, s, v);
|
|
const hex = this.rgbToHex(rgb.r, rgb.g, rgb.b);
|
|
this.updateColor(hex);
|
|
}
|
|
|
|
hsbToRgb(h, s, v) {
|
|
h = h / 360;
|
|
const i = Math.floor(h * 6);
|
|
const f = h * 6 - 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)
|
|
};
|
|
}
|
|
|
|
rgbToHex(r, g, b) {
|
|
return '#' + [r, g, b].map(x => {
|
|
const hex = x.toString(16);
|
|
return hex.length === 1 ? '0' + hex : hex;
|
|
}).join('').toUpperCase();
|
|
}
|
|
|
|
hexToRgb(hex) {
|
|
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
|
return result ? {
|
|
r: parseInt(result[1], 16),
|
|
g: parseInt(result[2], 16),
|
|
b: parseInt(result[3], 16)
|
|
} : null;
|
|
}
|
|
|
|
rgbToHsb(r, g, b) {
|
|
r /= 255;
|
|
g /= 255;
|
|
b /= 255;
|
|
|
|
const max = Math.max(r, g, b);
|
|
const min = Math.min(r, g, b);
|
|
const diff = max - min;
|
|
|
|
let h = 0;
|
|
if (diff !== 0) {
|
|
if (max === r) {
|
|
h = ((g - b) / diff) % 6) * 60;
|
|
} else if (max === g) {
|
|
h = ((b - r) / diff + 2) * 60;
|
|
} else {
|
|
h = ((r - g) / diff + 4) * 60;
|
|
}
|
|
}
|
|
if (h < 0) h += 360;
|
|
|
|
const s = max === 0 ? 0 : diff / max;
|
|
const v = max;
|
|
|
|
return { h, s, v };
|
|
}
|
|
|
|
updateColor(hex, updateInputs = true) {
|
|
this.currentColor = hex.toUpperCase();
|
|
this.previewBtn.style.backgroundColor = this.currentColor;
|
|
|
|
const rgb = this.hexToRgb(this.currentColor);
|
|
if (!rgb) return;
|
|
|
|
const hsb = this.rgbToHsb(rgb.r, rgb.g, rgb.b);
|
|
this.hue = hsb.h;
|
|
|
|
// Update main canvas
|
|
this.drawMainCanvas(this.hue);
|
|
|
|
// Update cursors
|
|
const x = hsb.s * 200;
|
|
const y = (1 - hsb.v) * 200;
|
|
this.updateCursor(x, y);
|
|
this.updateHueCursor((this.hue / 360) * 200);
|
|
|
|
// Update inputs
|
|
if (updateInputs) {
|
|
if (this.hexInput) {
|
|
this.hexInput.value = this.currentColor;
|
|
}
|
|
if (this.rgbR) {
|
|
this.rgbR.value = rgb.r;
|
|
this.rgbG.value = rgb.g;
|
|
this.rgbB.value = rgb.b;
|
|
}
|
|
}
|
|
|
|
// Callback
|
|
if (this.options.onColorChange) {
|
|
this.options.onColorChange(this.currentColor);
|
|
}
|
|
}
|
|
|
|
updateCursor(x, y) {
|
|
this.mainCursor.style.left = `${x}px`;
|
|
this.mainCursor.style.top = `${y}px`;
|
|
}
|
|
|
|
updateHueCursor(y) {
|
|
this.hueCursor.style.top = `${y}px`;
|
|
}
|
|
|
|
toggle() {
|
|
this.isOpen ? this.close() : this.open();
|
|
}
|
|
|
|
open() {
|
|
this.panel.style.display = 'block';
|
|
this.isOpen = true;
|
|
}
|
|
|
|
close() {
|
|
this.panel.style.display = 'none';
|
|
this.isOpen = false;
|
|
}
|
|
|
|
getColor() {
|
|
return this.currentColor;
|
|
}
|
|
|
|
setColor(color) {
|
|
this.updateColor(color);
|
|
}
|
|
}
|
|
|
|
// Export for use in other scripts
|
|
if (typeof module !== 'undefined' && module.exports) {
|
|
module.exports = ColorPickerChromium;
|
|
}
|
|
|