Files
ledgrab/server/src/wled_controller/static/js/features/css-gradient-editor.js
T
alexei.dolgolyov 014b4175b9 Add transient preview WS endpoint and test button in CSS editor modal
- Add /color-strip-sources/preview/ws endpoint for ad-hoc source preview
  without saving (accepts full config JSON, streams RGB frames)
- Add test preview button (flask icon) to CSS editor modal footer
- For self-contained types (static, gradient, color_cycle, effect, daylight,
  candlelight), always previews current form values via transient WS
- For non-previewable types, falls back to saved source test endpoint
- Fix route ordering: preview/ws registered before {source_id}/ws
- Fix css-test-led-control label alignment (display: inline globally)
- Add gradient onChange callback for future live-update support
- Add i18n keys for preview (en/ru/zh)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 14:49:22 +03:00

399 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 01, 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);
}
/* ── 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);
}
});
}