Safety & Correctness: - Add confirmation dialogs to Stop All, turnOffDevice - i18n confirm dialog (title, yes, no buttons) - Fix duplicate tutorial-overlay ID - Define missing CSS variables (--radius, --text-primary, --hover-bg, --input-bg) - Fix toast z-index conflict with confirm dialog (2500 → 3000) UX Consistency: - Add backdrop-close to test modals - Add device clone feature (only entity without it) - Add sync clocks to command palette - Replace 20+ hardcoded accent colors with CSS vars/color-mix() - Remove dead .badge duplicate from components.css - Make calibration elements keyboard-accessible (div → button) - Add aria-labels to color picker swatches - Fix pattern canvas mobile horizontal scroll - Fix graph editor mobile bottom clipping Polish: - Add empty-state messages to all CardSection instances - Convert 21 px font-sizes to rem - Add scroll-behavior: smooth with reduced-motion override - Add @media print styles - Add :focus-visible to 4 missing interactive elements - Fix settings modal close label (Cancel → Close) - Fix api-key submit button i18n New Features: - Command palette actions: start/stop targets, activate scenes, enable/disable - Bulk start/stop API endpoints (POST /output-targets/bulk/start|stop) - OS notification history viewer modal - Scene "used by" automation reference count on cards - Clock elapsed time display on Streams tab cards - Device "last seen" relative timestamp on cards - Audio device refresh button in edit modal - Composite layer drag-to-reorder - MQTT settings panel (broker config with JSON persistence) - WebSocket log viewer with level filtering and ring buffer - Runtime log-level adjustment (GET/PUT endpoints + settings UI) - Animated value source waveform canvas preview - Gradient custom preset save/delete (localStorage) - API key read-only display in settings - Backup metadata (file size, auto/manual badges) - Server restart button with confirm + overlay - Partial config export/import per entity type - Progressive disclosure in target editor (Advanced section) CSS Architecture: - Define radius scale tokens (--radius-sm/md/lg/pill) - Scope .cs-filter selectors to remove 7 !important overrides - Consolidate duplicate toggle switch (filter-list → settings-toggle) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
434 lines
17 KiB
JavaScript
434 lines
17 KiB
JavaScript
/**
|
||
* Gradient stop editor — canvas preview, draggable markers, stop list, presets.
|
||
*
|
||
* Extracted from color-strips.js. Self-contained module that manages
|
||
* gradient stops state and renders into the CSS editor modal DOM.
|
||
*/
|
||
|
||
import { t } from '../core/i18n.js';
|
||
|
||
/* ── Color conversion utilities ───────────────────────────────── */
|
||
|
||
export function rgbArrayToHex(rgb) {
|
||
if (!Array.isArray(rgb) || rgb.length !== 3) return '#ffffff';
|
||
return '#' + rgb.map(v => Math.max(0, Math.min(255, v)).toString(16).padStart(2, '0')).join('');
|
||
}
|
||
|
||
/** Convert a CSS hex string like "#rrggbb" to an [R, G, B] array. */
|
||
export function hexToRgbArray(hex) {
|
||
const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
|
||
return m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)] : [255, 255, 255];
|
||
}
|
||
|
||
/* ── State ────────────────────────────────────────────────────── */
|
||
|
||
/**
|
||
* Internal state: array of stop objects.
|
||
* Each stop: { position: float 0–1, color: [R,G,B], colorRight: [R,G,B]|null }
|
||
*/
|
||
let _gradientStops = [];
|
||
let _gradientSelectedIdx = -1;
|
||
let _gradientDragging = null; // { idx, trackRect } while dragging
|
||
let _gradientOnChange = null;
|
||
|
||
/** Set a callback that fires whenever stops change. */
|
||
export function gradientSetOnChange(fn) { _gradientOnChange = fn; }
|
||
|
||
/** Read-only accessor for save/dirty-check from the parent module. */
|
||
export function getGradientStops() {
|
||
return _gradientStops;
|
||
}
|
||
|
||
/* ── Interpolation (mirrors Python backend exactly) ───────────── */
|
||
|
||
function _gradientInterpolate(stops, pos) {
|
||
if (!stops.length) return [128, 128, 128];
|
||
const sorted = [...stops].sort((a, b) => a.position - b.position);
|
||
|
||
if (pos <= sorted[0].position) return sorted[0].color.slice();
|
||
|
||
const last = sorted[sorted.length - 1];
|
||
if (pos >= last.position) return (last.colorRight || last.color).slice();
|
||
|
||
for (let i = 0; i < sorted.length - 1; i++) {
|
||
const a = sorted[i];
|
||
const b = sorted[i + 1];
|
||
if (a.position <= pos && pos <= b.position) {
|
||
const span = b.position - a.position;
|
||
const t2 = span > 0 ? (pos - a.position) / span : 0;
|
||
const lc = a.colorRight || a.color;
|
||
const rc = b.color;
|
||
return lc.map((c, j) => Math.round(c + t2 * (rc[j] - c)));
|
||
}
|
||
}
|
||
return [128, 128, 128];
|
||
}
|
||
|
||
/* ── Init ─────────────────────────────────────────────────────── */
|
||
|
||
export function gradientInit(stops) {
|
||
_gradientStops = stops.map(s => ({
|
||
position: parseFloat(s.position ?? 0),
|
||
color: (Array.isArray(s.color) && s.color.length === 3) ? [...s.color] : [255, 255, 255],
|
||
colorRight: (Array.isArray(s.color_right) && s.color_right.length === 3) ? [...s.color_right] : null,
|
||
}));
|
||
_gradientSelectedIdx = _gradientStops.length > 0 ? 0 : -1;
|
||
_gradientDragging = null;
|
||
_gradientSetupTrackClick();
|
||
gradientRenderAll();
|
||
}
|
||
|
||
/* ── Presets ──────────────────────────────────────────────────── */
|
||
|
||
export const GRADIENT_PRESETS = {
|
||
rainbow: [
|
||
{ position: 0.0, color: [255, 0, 0] },
|
||
{ position: 0.17, color: [255, 165, 0] },
|
||
{ position: 0.33, color: [255, 255, 0] },
|
||
{ position: 0.5, color: [0, 255, 0] },
|
||
{ position: 0.67, color: [0, 100, 255] },
|
||
{ position: 0.83, color: [75, 0, 130] },
|
||
{ position: 1.0, color: [148, 0, 211] },
|
||
],
|
||
sunset: [
|
||
{ position: 0.0, color: [255, 60, 0] },
|
||
{ position: 0.3, color: [255, 120, 20] },
|
||
{ position: 0.6, color: [200, 40, 80] },
|
||
{ position: 0.8, color: [120, 20, 120] },
|
||
{ position: 1.0, color: [40, 10, 60] },
|
||
],
|
||
ocean: [
|
||
{ position: 0.0, color: [0, 10, 40] },
|
||
{ position: 0.3, color: [0, 60, 120] },
|
||
{ position: 0.6, color: [0, 140, 180] },
|
||
{ position: 0.8, color: [100, 220, 240] },
|
||
{ position: 1.0, color: [200, 240, 255] },
|
||
],
|
||
forest: [
|
||
{ position: 0.0, color: [0, 40, 0] },
|
||
{ position: 0.3, color: [0, 100, 20] },
|
||
{ position: 0.6, color: [60, 180, 30] },
|
||
{ position: 0.8, color: [140, 220, 50] },
|
||
{ position: 1.0, color: [220, 255, 80] },
|
||
],
|
||
fire: [
|
||
{ position: 0.0, color: [0, 0, 0] },
|
||
{ position: 0.25, color: [80, 0, 0] },
|
||
{ position: 0.5, color: [255, 40, 0] },
|
||
{ position: 0.75, color: [255, 160, 0] },
|
||
{ position: 1.0, color: [255, 255, 60] },
|
||
],
|
||
lava: [
|
||
{ position: 0.0, color: [0, 0, 0] },
|
||
{ position: 0.3, color: [120, 0, 0] },
|
||
{ position: 0.6, color: [255, 60, 0] },
|
||
{ position: 0.8, color: [255, 160, 40] },
|
||
{ position: 1.0, color: [255, 255, 120] },
|
||
],
|
||
aurora: [
|
||
{ position: 0.0, color: [0, 20, 40] },
|
||
{ position: 0.25, color: [0, 200, 100] },
|
||
{ position: 0.5, color: [0, 100, 200] },
|
||
{ position: 0.75, color: [120, 0, 200] },
|
||
{ position: 1.0, color: [0, 200, 140] },
|
||
],
|
||
ice: [
|
||
{ position: 0.0, color: [255, 255, 255] },
|
||
{ position: 0.3, color: [180, 220, 255] },
|
||
{ position: 0.6, color: [80, 160, 255] },
|
||
{ position: 0.85, color: [20, 60, 180] },
|
||
{ position: 1.0, color: [10, 20, 80] },
|
||
],
|
||
warm: [
|
||
{ position: 0.0, color: [255, 255, 80] },
|
||
{ position: 0.33, color: [255, 160, 0] },
|
||
{ position: 0.67, color: [255, 60, 0] },
|
||
{ position: 1.0, color: [160, 0, 0] },
|
||
],
|
||
cool: [
|
||
{ position: 0.0, color: [0, 255, 200] },
|
||
{ position: 0.33, color: [0, 120, 255] },
|
||
{ position: 0.67, color: [60, 0, 255] },
|
||
{ position: 1.0, color: [120, 0, 180] },
|
||
],
|
||
neon: [
|
||
{ position: 0.0, color: [255, 0, 200] },
|
||
{ position: 0.25, color: [0, 255, 255] },
|
||
{ position: 0.5, color: [0, 255, 50] },
|
||
{ position: 0.75, color: [255, 255, 0] },
|
||
{ position: 1.0, color: [255, 0, 100] },
|
||
],
|
||
pastel: [
|
||
{ position: 0.0, color: [255, 180, 180] },
|
||
{ position: 0.2, color: [255, 220, 160] },
|
||
{ position: 0.4, color: [255, 255, 180] },
|
||
{ position: 0.6, color: [180, 255, 200] },
|
||
{ position: 0.8, color: [180, 200, 255] },
|
||
{ position: 1.0, color: [220, 180, 255] },
|
||
],
|
||
};
|
||
|
||
/**
|
||
* Build a gradient preview from GRADIENT_PRESETS entry (array of {position, color:[r,g,b]}).
|
||
*/
|
||
export function gradientPresetStripHTML(stops, w = 80, h = 16) {
|
||
const css = stops.map(s => `rgb(${s.color.join(',')}) ${(s.position * 100).toFixed(0)}%`).join(', ');
|
||
return `<span style="display:inline-block;width:${w}px;height:${h}px;border-radius:3px;background:linear-gradient(to right,${css});flex-shrink:0"></span>`;
|
||
}
|
||
|
||
export function applyGradientPreset(key) {
|
||
if (!key || !GRADIENT_PRESETS[key]) return;
|
||
gradientInit(GRADIENT_PRESETS[key]);
|
||
}
|
||
|
||
/* ── Render ───────────────────────────────────────────────────── */
|
||
|
||
export function gradientRenderAll() {
|
||
_gradientRenderCanvas();
|
||
_gradientRenderMarkers();
|
||
_gradientRenderStopList();
|
||
if (_gradientOnChange) _gradientOnChange();
|
||
}
|
||
|
||
function _gradientRenderCanvas() {
|
||
const canvas = document.getElementById('gradient-canvas');
|
||
if (!canvas) return;
|
||
|
||
// Sync canvas pixel width to its CSS display width
|
||
const W = Math.max(1, Math.round(canvas.offsetWidth || 300));
|
||
if (canvas.width !== W) canvas.width = W;
|
||
|
||
const ctx = canvas.getContext('2d');
|
||
const H = canvas.height;
|
||
const imgData = ctx.createImageData(W, H);
|
||
|
||
for (let x = 0; x < W; x++) {
|
||
const pos = W > 1 ? x / (W - 1) : 0;
|
||
const [r, g, b] = _gradientInterpolate(_gradientStops, pos);
|
||
for (let y = 0; y < H; y++) {
|
||
const idx = (y * W + x) * 4;
|
||
imgData.data[idx] = r;
|
||
imgData.data[idx + 1] = g;
|
||
imgData.data[idx + 2] = b;
|
||
imgData.data[idx + 3] = 255;
|
||
}
|
||
}
|
||
ctx.putImageData(imgData, 0, 0);
|
||
}
|
||
|
||
function _gradientRenderMarkers() {
|
||
const track = document.getElementById('gradient-markers-track');
|
||
if (!track) return;
|
||
track.innerHTML = '';
|
||
|
||
_gradientStops.forEach((stop, idx) => {
|
||
const marker = document.createElement('div');
|
||
marker.className = 'gradient-marker' + (idx === _gradientSelectedIdx ? ' selected' : '');
|
||
marker.style.left = `${stop.position * 100}%`;
|
||
marker.style.background = rgbArrayToHex(stop.color);
|
||
marker.title = `${(stop.position * 100).toFixed(0)}%`;
|
||
|
||
marker.addEventListener('mousedown', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
_gradientSelectedIdx = idx;
|
||
_gradientStartDrag(e, idx);
|
||
_gradientRenderMarkers();
|
||
_gradientRenderStopList();
|
||
});
|
||
|
||
track.appendChild(marker);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Update the selected stop index and reflect it via CSS classes only —
|
||
* no DOM rebuild, so in-flight click events on child elements are preserved.
|
||
*/
|
||
function _gradientSelectStop(idx) {
|
||
_gradientSelectedIdx = idx;
|
||
document.querySelectorAll('.gradient-stop-row').forEach((r, i) => r.classList.toggle('selected', i === idx));
|
||
document.querySelectorAll('.gradient-marker').forEach((m, i) => m.classList.toggle('selected', i === idx));
|
||
}
|
||
|
||
function _gradientRenderStopList() {
|
||
const list = document.getElementById('gradient-stops-list');
|
||
if (!list) return;
|
||
list.innerHTML = '';
|
||
|
||
_gradientStops.forEach((stop, idx) => {
|
||
const row = document.createElement('div');
|
||
row.className = 'gradient-stop-row' + (idx === _gradientSelectedIdx ? ' selected' : '');
|
||
|
||
const hasBidir = !!stop.colorRight;
|
||
const rightColor = stop.colorRight || stop.color;
|
||
|
||
row.innerHTML = `
|
||
<input type="number" class="gradient-stop-pos" value="${stop.position.toFixed(2)}"
|
||
min="0" max="1" step="0.01" title="${t('color_strip.gradient.position')}">
|
||
<input type="color" class="gradient-stop-color" value="${rgbArrayToHex(stop.color)}"
|
||
title="Left color">
|
||
<button type="button" class="btn btn-sm gradient-stop-bidir-btn${hasBidir ? ' active' : ''}"
|
||
title="${t('color_strip.gradient.bidir.hint')}">↔</button>
|
||
<input type="color" class="gradient-stop-color-right" value="${rgbArrayToHex(rightColor)}"
|
||
style="display:${hasBidir ? 'inline-block' : 'none'}" title="Right color">
|
||
<span class="gradient-stop-spacer"></span>
|
||
<button type="button" class="btn btn-sm btn-danger gradient-stop-remove-btn"
|
||
title="Remove stop"${_gradientStops.length <= 2 ? ' disabled' : ''}>✕</button>
|
||
`;
|
||
|
||
// Select row on mousedown — CSS-only update so child click events are not interrupted
|
||
row.addEventListener('mousedown', () => _gradientSelectStop(idx));
|
||
|
||
// Position
|
||
const posInput = row.querySelector('.gradient-stop-pos');
|
||
posInput.addEventListener('change', (e) => {
|
||
const val = Math.min(1, Math.max(0, parseFloat(e.target.value) || 0));
|
||
e.target.value = val.toFixed(2);
|
||
_gradientStops[idx].position = val;
|
||
gradientRenderAll();
|
||
});
|
||
posInput.addEventListener('focus', () => _gradientSelectStop(idx));
|
||
|
||
// Left color
|
||
row.querySelector('.gradient-stop-color').addEventListener('input', (e) => {
|
||
_gradientStops[idx].color = hexToRgbArray(e.target.value);
|
||
const markers = document.querySelectorAll('.gradient-marker');
|
||
if (markers[idx]) markers[idx].style.background = e.target.value;
|
||
_gradientRenderCanvas();
|
||
});
|
||
|
||
// Bidirectional toggle
|
||
row.querySelector('.gradient-stop-bidir-btn').addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
_gradientStops[idx].colorRight = _gradientStops[idx].colorRight
|
||
? null
|
||
: [..._gradientStops[idx].color];
|
||
_gradientRenderStopList();
|
||
_gradientRenderCanvas();
|
||
});
|
||
|
||
// Right color
|
||
row.querySelector('.gradient-stop-color-right').addEventListener('input', (e) => {
|
||
_gradientStops[idx].colorRight = hexToRgbArray(e.target.value);
|
||
_gradientRenderCanvas();
|
||
});
|
||
|
||
// Remove
|
||
row.querySelector('.btn-danger').addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
if (_gradientStops.length > 2) {
|
||
_gradientStops.splice(idx, 1);
|
||
if (_gradientSelectedIdx >= _gradientStops.length) {
|
||
_gradientSelectedIdx = _gradientStops.length - 1;
|
||
}
|
||
gradientRenderAll();
|
||
}
|
||
});
|
||
|
||
list.appendChild(row);
|
||
});
|
||
}
|
||
|
||
/* ── Add Stop ─────────────────────────────────────────────────── */
|
||
|
||
export function gradientAddStop(position) {
|
||
if (position === undefined) {
|
||
// Find the largest gap between adjacent stops and place in the middle
|
||
const sorted = [..._gradientStops].sort((a, b) => a.position - b.position);
|
||
let maxGap = 0, gapMid = 0.5;
|
||
for (let i = 0; i < sorted.length - 1; i++) {
|
||
const gap = sorted[i + 1].position - sorted[i].position;
|
||
if (gap > maxGap) {
|
||
maxGap = gap;
|
||
gapMid = (sorted[i].position + sorted[i + 1].position) / 2;
|
||
}
|
||
}
|
||
position = sorted.length >= 2 ? Math.round(gapMid * 100) / 100 : 0.5;
|
||
}
|
||
position = Math.min(1, Math.max(0, position));
|
||
const color = _gradientInterpolate(_gradientStops, position);
|
||
_gradientStops.push({ position, color, colorRight: null });
|
||
_gradientSelectedIdx = _gradientStops.length - 1;
|
||
gradientRenderAll();
|
||
}
|
||
|
||
/* ── Drag ─────────────────────────────────────────────────────── */
|
||
|
||
function _gradientStartDrag(e, idx) {
|
||
const track = document.getElementById('gradient-markers-track');
|
||
if (!track) return;
|
||
_gradientDragging = { idx, trackRect: track.getBoundingClientRect() };
|
||
|
||
const onMove = (me) => {
|
||
if (!_gradientDragging) return;
|
||
const { trackRect } = _gradientDragging;
|
||
const pos = Math.min(1, Math.max(0, (me.clientX - trackRect.left) / trackRect.width));
|
||
_gradientStops[_gradientDragging.idx].position = Math.round(pos * 100) / 100;
|
||
gradientRenderAll();
|
||
};
|
||
|
||
const onUp = () => {
|
||
_gradientDragging = null;
|
||
document.removeEventListener('mousemove', onMove);
|
||
document.removeEventListener('mouseup', onUp);
|
||
};
|
||
|
||
document.addEventListener('mousemove', onMove);
|
||
document.addEventListener('mouseup', onUp);
|
||
}
|
||
|
||
/* ── Custom presets (localStorage) ───────────────────────────── */
|
||
|
||
const _CUSTOM_PRESETS_KEY = 'custom_gradient_presets';
|
||
|
||
/** Load custom presets from localStorage. Returns an array of { name, stops }. */
|
||
export function loadCustomGradientPresets() {
|
||
try {
|
||
return JSON.parse(localStorage.getItem(_CUSTOM_PRESETS_KEY) || '[]');
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/** Save the current gradient stops as a named custom preset. */
|
||
export function saveCurrentAsCustomPreset(name) {
|
||
if (!name) return;
|
||
const stops = _gradientStops.map(s => ({
|
||
position: s.position,
|
||
color: [...s.color],
|
||
...(s.colorRight ? { color_right: [...s.colorRight] } : {}),
|
||
}));
|
||
const presets = loadCustomGradientPresets();
|
||
// Replace if same name exists
|
||
const idx = presets.findIndex(p => p.name === name);
|
||
if (idx >= 0) presets[idx] = { name, stops };
|
||
else presets.push({ name, stops });
|
||
localStorage.setItem(_CUSTOM_PRESETS_KEY, JSON.stringify(presets));
|
||
}
|
||
|
||
/** Delete a custom preset by name. */
|
||
export function deleteCustomGradientPreset(name) {
|
||
const presets = loadCustomGradientPresets().filter(p => p.name !== name);
|
||
localStorage.setItem(_CUSTOM_PRESETS_KEY, JSON.stringify(presets));
|
||
}
|
||
|
||
/* ── Track click → add stop ───────────────────────────────────── */
|
||
|
||
function _gradientSetupTrackClick() {
|
||
const track = document.getElementById('gradient-markers-track');
|
||
if (!track || track._gradientClickBound) return;
|
||
track._gradientClickBound = true;
|
||
|
||
track.addEventListener('click', (e) => {
|
||
if (_gradientDragging) return;
|
||
const rect = track.getBoundingClientRect();
|
||
const pos = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
|
||
// Ignore clicks very close to an existing marker
|
||
const tooClose = _gradientStops.some(s => Math.abs(s.position - pos) < 0.03);
|
||
if (!tooClose) {
|
||
gradientAddStop(Math.round(pos * 100) / 100);
|
||
}
|
||
});
|
||
}
|