Add tags to all entity types with chip-based input and autocomplete
- Add `tags: List[str]` field to all 13 entity types (devices, output targets, CSS sources, picture sources, audio sources, value sources, sync clocks, automations, scene presets, capture/audio/PP/pattern templates) - Update all stores, schemas, and route handlers for tag CRUD - Add GET /api/v1/tags endpoint aggregating unique tags across all stores - Create TagInput component with chip display, autocomplete dropdown, keyboard navigation, and API-backed suggestions - Display tag chips on all entity cards (searchable via existing text filter) - Add tag input to all 14 editor modals with dirty check support - Add CSS styles and i18n keys (en/ru/zh) for tag UI - Also includes code review fixes: thread safety, perf, store dedup Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* 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
|
||||
|
||||
/** 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();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user