Files
ledgrab/server/src/wled_controller/static/js/features/css-gradient-editor.ts
T
alexei.dolgolyov f2871319cb
Lint & Test / test (push) Failing after 48s
refactor: comprehensive code quality, security, and release readiness improvements
Security: tighten CORS defaults, add webhook rate limiting, fix XSS in
automations, guard WebSocket JSON.parse, validate ADB address input,
seal debug exception leak, URL-encode WS tokens, CSS.escape in selectors.

Code quality: add Pydantic models for brightness/power endpoints, fix
thread safety and name uniqueness in DeviceStore, immutable update
pattern, split 6 oversized files into 16 focused modules, enable
TypeScript strictNullChecks (741→102 errors), type state variables,
add dom-utils helper, migrate 3 modules from inline onclick to event
delegation, ProcessorDependencies dataclass.

Performance: async store saves, health endpoint log level, command
palette debounce, optimized entity-events comparison, fix service
worker precache list.

Testing: expand from 45 to 293 passing tests — add store tests (141),
route tests (25), core logic tests (42), E2E flow tests (33), organize
into tests/api/, tests/storage/, tests/core/, tests/e2e/.

DevOps: CI test pipeline, pre-commit config, Dockerfile multi-stage
build with non-root user and health check, docker-compose improvements,
version bump to 0.2.0.

Docs: rewrite CLAUDE.md (202→56 lines), server/CLAUDE.md (212→76),
create contexts/server-operations.md, fix .js→.ts references, fix env
var prefix in README, rewrite INSTALLATION.md, add CONTRIBUTING.md and
.env.example.
2026-03-22 00:38:28 +03:00

460 lines
18 KiB
TypeScript
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.ts';
/* ── Types ─────────────────────────────────────────────────────── */
interface GradientStop {
position: number;
color: number[];
colorRight: number[] | null;
}
interface GradientDragState {
idx: number;
trackRect: DOMRect;
}
interface GradientPresetStop {
position: number;
color: number[];
color_right?: number[];
}
interface CustomPreset {
name: string;
stops: GradientPresetStop[];
}
/* ── Color conversion utilities ───────────────────────────────── */
export function rgbArrayToHex(rgb: number[]): string {
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: string): number[] {
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: GradientStop[] = [];
let _gradientSelectedIdx: number = -1;
let _gradientDragging: GradientDragState | null = null;
let _gradientOnChange: (() => void) | null = null;
/** Set a callback that fires whenever stops change. */
export function gradientSetOnChange(fn: (() => void) | null): void { _gradientOnChange = fn; }
/** Read-only accessor for save/dirty-check from the parent module. */
export function getGradientStops(): GradientStop[] {
return _gradientStops;
}
/* ── Interpolation (mirrors Python backend exactly) ───────────── */
function _gradientInterpolate(stops: GradientStop[], pos: number): number[] {
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: GradientPresetStop[]): void {
_gradientStops = stops.map(s => ({
position: parseFloat(String(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: GradientPresetStop[], w: number = 80, h: number = 16): string {
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: string): void {
if (!key || !GRADIENT_PRESETS[key]) return;
gradientInit(GRADIENT_PRESETS[key]);
}
/* ── Render ───────────────────────────────────────────────────── */
export function gradientRenderAll(): void {
_gradientRenderCanvas();
_gradientRenderMarkers();
_gradientRenderStopList();
if (_gradientOnChange) _gradientOnChange();
}
function _gradientRenderCanvas(): void {
const canvas = document.getElementById('gradient-canvas') as HTMLCanvasElement | null;
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(): void {
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: number): void {
_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(): void {
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 target = e.target as HTMLInputElement;
const val = Math.min(1, Math.max(0, parseFloat(target.value) || 0));
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) => {
const val = (e.target as HTMLInputElement).value;
_gradientStops[idx].color = hexToRgbArray(val);
const markers = document.querySelectorAll('.gradient-marker');
if (markers[idx]) (markers[idx] as HTMLElement).style.background = val;
_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 as HTMLInputElement).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?: number): void {
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: MouseEvent, idx: number): void {
const track = document.getElementById('gradient-markers-track');
if (!track) return;
_gradientDragging = { idx, trackRect: track.getBoundingClientRect() };
const onMove = (me: MouseEvent): void => {
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 = (): void => {
_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(): CustomPreset[] {
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: string): void {
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: string): void {
const presets = loadCustomGradientPresets().filter(p => p.name !== name);
localStorage.setItem(_CUSTOM_PRESETS_KEY, JSON.stringify(presets));
}
/* ── Track click → add stop ───────────────────────────────────── */
function _gradientSetupTrackClick(): void {
const track = document.getElementById('gradient-markers-track');
if (!track || (track as any)._gradientClickBound) return;
(track as any)._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);
}
});
}