feat: add gradient entity modal and fix color picker clipping
Add full gradient editor modal with name, description, visual stop editor, tags, and dirty checking. Gradient editor now supports ID prefix to avoid DOM conflicts between CSS editor and standalone modal. Fix color picker popover clipped by template-card overflow:hidden. Fix gradient canvas not sizing correctly in standalone modal.
This commit is contained in:
@@ -480,13 +480,11 @@ body.cs-drag-active .card-drag-handle {
|
|||||||
.card-title {
|
.card-title {
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-title-text {
|
.card-title-text {
|
||||||
@@ -500,6 +498,7 @@ body.cs-drag-active .card-drag-handle {
|
|||||||
color: var(--primary-text-color);
|
color: var(--primary-text-color);
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
margin-right: 6px;
|
margin-right: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-url-badge {
|
.device-url-badge {
|
||||||
|
|||||||
@@ -580,6 +580,10 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header-actions {
|
.modal-header-actions {
|
||||||
@@ -1214,7 +1218,8 @@
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#gradient-canvas {
|
#gradient-canvas,
|
||||||
|
#ge-gradient-canvas {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -67,6 +67,8 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.template-card .template-card-header {
|
.template-card .template-card-header {
|
||||||
@@ -81,6 +83,7 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.template-name > .icon {
|
.template-name > .icon {
|
||||||
|
|||||||
@@ -123,11 +123,15 @@ import {
|
|||||||
compositeAddLayer, compositeRemoveLayer,
|
compositeAddLayer, compositeRemoveLayer,
|
||||||
mappedAddZone, mappedRemoveZone,
|
mappedAddZone, mappedRemoveZone,
|
||||||
onAudioVizChange,
|
onAudioVizChange,
|
||||||
applyGradientPreset,
|
|
||||||
onGradientPresetChange,
|
onGradientPresetChange,
|
||||||
promptAndSaveGradientPreset,
|
promptAndSaveGradientPreset,
|
||||||
applyCustomGradientPreset,
|
|
||||||
deleteAndRefreshGradientPreset,
|
deleteAndRefreshGradientPreset,
|
||||||
|
showGradientModal,
|
||||||
|
closeGradientEditor,
|
||||||
|
saveGradientEntity,
|
||||||
|
cloneGradient,
|
||||||
|
editGradient,
|
||||||
|
deleteGradient,
|
||||||
cloneColorStrip,
|
cloneColorStrip,
|
||||||
toggleCSSOverlay,
|
toggleCSSOverlay,
|
||||||
previewCSSFromEditor,
|
previewCSSFromEditor,
|
||||||
@@ -441,11 +445,15 @@ Object.assign(window, {
|
|||||||
mappedAddZone,
|
mappedAddZone,
|
||||||
mappedRemoveZone,
|
mappedRemoveZone,
|
||||||
onAudioVizChange,
|
onAudioVizChange,
|
||||||
applyGradientPreset,
|
|
||||||
onGradientPresetChange,
|
onGradientPresetChange,
|
||||||
promptAndSaveGradientPreset,
|
promptAndSaveGradientPreset,
|
||||||
applyCustomGradientPreset,
|
|
||||||
deleteAndRefreshGradientPreset,
|
deleteAndRefreshGradientPreset,
|
||||||
|
showGradientModal,
|
||||||
|
closeGradientEditor,
|
||||||
|
saveGradientEntity,
|
||||||
|
cloneGradient,
|
||||||
|
editGradient,
|
||||||
|
deleteGradient,
|
||||||
cloneColorStrip,
|
cloneColorStrip,
|
||||||
toggleCSSOverlay,
|
toggleCSSOverlay,
|
||||||
previewCSSFromEditor,
|
previewCSSFromEditor,
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export function wrapCard({
|
|||||||
<div class="${type}${classes ? ' ' + classes : ''}" ${dataAttr}="${id}"${colorStyle ? ` style="${colorStyle}" data-has-color="1"` : ''}>
|
<div class="${type}${classes ? ' ' + classes : ''}" ${dataAttr}="${id}"${colorStyle ? ` style="${colorStyle}" data-has-color="1"` : ''}>
|
||||||
<div class="card-top-actions">
|
<div class="card-top-actions">
|
||||||
${topButtons}
|
${topButtons}
|
||||||
<button class="card-remove-btn" onclick="${removeOnclick}" title="${removeTitle}">✕</button>
|
${removeOnclick ? `<button class="card-remove-btn" onclick="${removeOnclick}" title="${removeTitle}">✕</button>` : ''}
|
||||||
</div>
|
</div>
|
||||||
${content}
|
${content}
|
||||||
<div class="${actionsClass}">
|
<div class="${actionsClass}">
|
||||||
|
|||||||
@@ -330,3 +330,17 @@ export const scenePresetsCache = new DataCache<ScenePreset[]>({
|
|||||||
endpoint: '/scene-presets',
|
endpoint: '/scene-presets',
|
||||||
extractData: json => json.presets || [],
|
extractData: json => json.presets || [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export interface GradientEntity {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
stops: Array<{ position: number; color: number[] }>;
|
||||||
|
is_builtin: boolean;
|
||||||
|
description?: string;
|
||||||
|
tags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const gradientsCache = new DataCache<GradientEntity[]>({
|
||||||
|
endpoint: '/gradients',
|
||||||
|
extractData: json => json.gradients || [],
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||||
import { _cachedSyncClocks, _cachedCSPTemplates, audioSourcesCache, streamsCache, colorStripSourcesCache, valueSourcesCache, csptCache } from '../core/state.ts';
|
import { _cachedSyncClocks, _cachedCSPTemplates, audioSourcesCache, streamsCache, colorStripSourcesCache, valueSourcesCache, csptCache, gradientsCache, GradientEntity } from '../core/state.ts';
|
||||||
import { t } from '../core/i18n.ts';
|
import { t } from '../core/i18n.ts';
|
||||||
import { showToast, showConfirm, desktopFocus } from '../core/ui.ts';
|
import { showToast, showConfirm, desktopFocus } from '../core/ui.ts';
|
||||||
import { Modal } from '../core/modal.ts';
|
import { Modal } from '../core/modal.ts';
|
||||||
@@ -24,9 +24,8 @@ import { EntitySelect } from '../core/entity-palette.ts';
|
|||||||
import { getBaseOrigin } from './settings.ts';
|
import { getBaseOrigin } from './settings.ts';
|
||||||
import {
|
import {
|
||||||
rgbArrayToHex, hexToRgbArray,
|
rgbArrayToHex, hexToRgbArray,
|
||||||
gradientInit, gradientRenderAll, gradientAddStop, applyGradientPreset,
|
gradientInit, gradientRenderAll, gradientAddStop,
|
||||||
getGradientStops, GRADIENT_PRESETS, gradientPresetStripHTML,
|
getGradientStops, gradientSetIdPrefix,
|
||||||
loadCustomGradientPresets, saveCurrentAsCustomPreset, deleteCustomGradientPreset,
|
|
||||||
} from './css-gradient-editor.ts';
|
} from './css-gradient-editor.ts';
|
||||||
import {
|
import {
|
||||||
compositeDestroyEntitySelects, compositeSetAvailableSources, compositeGetAvailableSources,
|
compositeDestroyEntitySelects, compositeSetAvailableSources, compositeGetAvailableSources,
|
||||||
@@ -42,8 +41,7 @@ import {
|
|||||||
} from './color-strips-notification.ts';
|
} from './color-strips-notification.ts';
|
||||||
|
|
||||||
// Re-export for app.js window global bindings
|
// Re-export for app.js window global bindings
|
||||||
export { gradientInit, gradientRenderAll, gradientAddStop, applyGradientPreset };
|
export { gradientInit, gradientRenderAll, gradientAddStop };
|
||||||
export { saveCurrentAsCustomPreset, deleteCustomGradientPreset };
|
|
||||||
export { compositeAddLayer, compositeRemoveLayer };
|
export { compositeAddLayer, compositeRemoveLayer };
|
||||||
export {
|
export {
|
||||||
onNotificationFilterModeChange, notificationAddAppColor, notificationRemoveAppColor,
|
onNotificationFilterModeChange, notificationAddAppColor, notificationRemoveAppColor,
|
||||||
@@ -351,20 +349,6 @@ function _syncDaylightSpeedVisibility() {
|
|||||||
|
|
||||||
/* ── Gradient strip preview helper ────────────────────────────── */
|
/* ── Gradient strip preview helper ────────────────────────────── */
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a small inline CSS gradient preview from palette color points.
|
|
||||||
* @param {Array<[number, string]>} pts – [[position, 'r,g,b'], ...]
|
|
||||||
* @param {number} [w=80] width in px
|
|
||||||
* @param {number} [h=16] height in px
|
|
||||||
* @returns {string} HTML string
|
|
||||||
*/
|
|
||||||
function _gradientStripHTML(pts: any[], w = 80, h = 16) {
|
|
||||||
const stops = pts.map(([pos, rgb]) => `rgb(${rgb}) ${(pos * 100).toFixed(0)}%`).join(', ');
|
|
||||||
return `<span style="display:inline-block;width:${w}px;height:${h}px;border-radius:3px;background:linear-gradient(to right,${stops});flex-shrink:0"></span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* gradientPresetStripHTML imported from css-gradient-editor.js */
|
|
||||||
|
|
||||||
/* ── Effect / audio palette IconSelect instances ─────────────── */
|
/* ── Effect / audio palette IconSelect instances ─────────────── */
|
||||||
|
|
||||||
let _animationTypeIconSelect: any = null;
|
let _animationTypeIconSelect: any = null;
|
||||||
@@ -414,10 +398,10 @@ function _ensureEffectTypeIconSelect() {
|
|||||||
function _ensureEffectPaletteIconSelect() {
|
function _ensureEffectPaletteIconSelect() {
|
||||||
const sel = document.getElementById('css-editor-effect-palette') as HTMLSelectElement | null;
|
const sel = document.getElementById('css-editor-effect-palette') as HTMLSelectElement | null;
|
||||||
if (!sel) return;
|
if (!sel) return;
|
||||||
const items = Object.entries(_PALETTE_COLORS).map(([key, pts]) => ({
|
const gradients = _getGradients();
|
||||||
value: key, icon: _gradientStripHTML(pts), label: t(`color_strip.palette.${key}`),
|
const items = gradients.map(g => ({
|
||||||
|
value: g.id, icon: _gradientEntityStripHTML(g.stops), label: g.name,
|
||||||
}));
|
}));
|
||||||
items.push({ value: 'custom', icon: _icon(P.pencil), label: t('color_strip.palette.custom') });
|
|
||||||
if (_effectPaletteIconSelect) { _effectPaletteIconSelect.updateItems(items); return; }
|
if (_effectPaletteIconSelect) { _effectPaletteIconSelect.updateItems(items); return; }
|
||||||
_effectPaletteIconSelect = new IconSelect({ target: sel, items, columns: 2 });
|
_effectPaletteIconSelect = new IconSelect({ target: sel, items, columns: 2 });
|
||||||
}
|
}
|
||||||
@@ -451,8 +435,9 @@ function _ensureCandleTypeIconSelect() {
|
|||||||
function _ensureAudioPaletteIconSelect() {
|
function _ensureAudioPaletteIconSelect() {
|
||||||
const sel = document.getElementById('css-editor-audio-palette') as HTMLSelectElement | null;
|
const sel = document.getElementById('css-editor-audio-palette') as HTMLSelectElement | null;
|
||||||
if (!sel) return;
|
if (!sel) return;
|
||||||
const items = Object.entries(_PALETTE_COLORS).map(([key, pts]) => ({
|
const gradients = _getGradients();
|
||||||
value: key, icon: _gradientStripHTML(pts), label: t(`color_strip.palette.${key}`),
|
const items = gradients.map(g => ({
|
||||||
|
value: g.id, icon: _gradientEntityStripHTML(g.stops), label: g.name,
|
||||||
}));
|
}));
|
||||||
if (_audioPaletteIconSelect) { _audioPaletteIconSelect.updateItems(items); return; }
|
if (_audioPaletteIconSelect) { _audioPaletteIconSelect.updateItems(items); return; }
|
||||||
_audioPaletteIconSelect = new IconSelect({ target: sel, items, columns: 2 });
|
_audioPaletteIconSelect = new IconSelect({ target: sel, items, columns: 2 });
|
||||||
@@ -471,19 +456,19 @@ function _ensureAudioVizIconSelect() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _buildGradientPresetItems() {
|
function _buildGradientPresetItems() {
|
||||||
const builtIn = [
|
const gradients = _getGradients();
|
||||||
{ value: '', icon: _icon(P.palette), label: t('color_strip.gradient.preset.custom') },
|
const builtInItems = gradients.filter(g => g.is_builtin).map(g => ({
|
||||||
...Object.entries(GRADIENT_PRESETS).map(([key, stops]) => ({
|
value: g.id, icon: _gradientEntityStripHTML(g.stops), label: g.name,
|
||||||
value: key, icon: gradientPresetStripHTML(stops), label: t(`color_strip.gradient.preset.${key}`),
|
}));
|
||||||
})),
|
const userItems = gradients.filter(g => !g.is_builtin).map(g => ({
|
||||||
];
|
value: g.id, icon: _gradientEntityStripHTML(g.stops), label: g.name,
|
||||||
const custom = loadCustomGradientPresets().map((p: any) => ({
|
|
||||||
value: `__custom__${p.name}`,
|
|
||||||
icon: gradientPresetStripHTML(p.stops),
|
|
||||||
label: p.name,
|
|
||||||
isCustom: true,
|
isCustom: true,
|
||||||
}));
|
}));
|
||||||
return [...builtIn, ...custom];
|
return [
|
||||||
|
{ value: '', icon: _icon(P.palette), label: t('color_strip.gradient.preset.custom') },
|
||||||
|
...builtInItems,
|
||||||
|
...userItems,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function _ensureGradientPresetIconSelect() {
|
function _ensureGradientPresetIconSelect() {
|
||||||
@@ -491,7 +476,7 @@ function _ensureGradientPresetIconSelect() {
|
|||||||
if (!sel) return;
|
if (!sel) return;
|
||||||
const items = _buildGradientPresetItems();
|
const items = _buildGradientPresetItems();
|
||||||
if (_gradientPresetIconSelect) { _gradientPresetIconSelect.updateItems(items); return; }
|
if (_gradientPresetIconSelect) { _gradientPresetIconSelect.updateItems(items); return; }
|
||||||
_gradientPresetIconSelect = new IconSelect({ target: sel, items, columns: 3 });
|
_gradientPresetIconSelect = new IconSelect({ target: sel, items, columns: 3, onChange: (v) => onGradientPresetChange(v) });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rebuild the preset picker after adding/removing custom presets. */
|
/** Rebuild the preset picker after adding/removing custom presets. */
|
||||||
@@ -503,26 +488,26 @@ export function refreshGradientPresetPicker() {
|
|||||||
_renderCustomPresetList();
|
_renderCustomPresetList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Render the custom preset list below the save button. */
|
/** Render the user-created gradient list below the save button. */
|
||||||
function _renderCustomPresetList() {
|
function _renderCustomPresetList() {
|
||||||
const container = document.getElementById('css-editor-custom-presets-list') as HTMLElement | null;
|
const container = document.getElementById('css-editor-custom-presets-list') as HTMLElement | null;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
const presets = loadCustomGradientPresets();
|
const userGradients = _getGradients().filter(g => !g.is_builtin);
|
||||||
if (presets.length === 0) {
|
if (userGradients.length === 0) {
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
container.innerHTML = presets.map((p: any) => {
|
container.innerHTML = userGradients.map(g => {
|
||||||
const strip = gradientPresetStripHTML(p.stops, 60, 14);
|
const strip = _gradientEntityStripHTML(g.stops, 60, 14);
|
||||||
const safeName = escapeHtml(p.name);
|
const safeName = escapeHtml(g.name);
|
||||||
return `<div class="custom-preset-row">
|
return `<div class="custom-preset-row">
|
||||||
${strip}
|
${strip}
|
||||||
<span class="custom-preset-name">${safeName}</span>
|
<span class="custom-preset-name">${safeName}</span>
|
||||||
<button type="button" class="btn btn-icon btn-sm btn-secondary"
|
<button type="button" class="btn btn-icon btn-sm btn-secondary"
|
||||||
onclick="applyCustomGradientPreset(${JSON.stringify(p.name)})"
|
onclick="onGradientPresetChange('${g.id}')"
|
||||||
title="${t('color_strip.gradient.preset.apply')}">✓</button>
|
title="${t('color_strip.gradient.preset.apply')}">✓</button>
|
||||||
<button type="button" class="btn btn-icon btn-sm btn-danger"
|
<button type="button" class="btn btn-icon btn-sm btn-danger"
|
||||||
onclick="deleteAndRefreshGradientPreset(${JSON.stringify(p.name)})"
|
onclick="deleteAndRefreshGradientPreset('${g.id}')"
|
||||||
title="${t('common.delete')}">✕</button>
|
title="${t('common.delete')}">✕</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
@@ -551,38 +536,47 @@ function _buildAnimationTypeItems(cssType: any) {
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Handles the gradient preset selector change — routes to built-in or custom preset. */
|
/** Handles the gradient preset selector change — loads stops from gradient entity. */
|
||||||
export function onGradientPresetChange(value: any) {
|
export function onGradientPresetChange(value: any) {
|
||||||
if (!value) return; // "— Custom —" selected
|
if (!value) return; // "— Custom —" selected
|
||||||
if (value.startsWith('__custom__')) {
|
const g = _findGradient(value);
|
||||||
applyCustomGradientPreset(value.slice('__custom__'.length));
|
if (g) {
|
||||||
} else {
|
gradientInit(g.stops);
|
||||||
applyGradientPreset(value);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Called from inline onclick in the HTML save button. Prompts for a name and saves. */
|
/** Called from inline onclick in the HTML save button. Saves current gradient to server as new entity. */
|
||||||
export function promptAndSaveGradientPreset() {
|
export async function promptAndSaveGradientPreset() {
|
||||||
const name = window.prompt(t('color_strip.gradient.preset.save_prompt'), '');
|
const name = window.prompt(t('color_strip.gradient.preset.save_prompt'), '');
|
||||||
if (!name || !name.trim()) return;
|
if (!name || !name.trim()) return;
|
||||||
saveCurrentAsCustomPreset(name.trim());
|
const stops = getGradientStops().map(s => ({
|
||||||
showToast(t('color_strip.gradient.preset.saved'), 'success');
|
position: s.position,
|
||||||
refreshGradientPresetPicker();
|
color: s.color,
|
||||||
|
}));
|
||||||
|
try {
|
||||||
|
await fetchWithAuth('/gradients', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: name.trim(), stops }),
|
||||||
|
});
|
||||||
|
await gradientsCache.fetch({ force: true });
|
||||||
|
showToast(t('color_strip.gradient.preset.saved'), 'success');
|
||||||
|
refreshGradientPresetPicker();
|
||||||
|
} catch (e: any) {
|
||||||
|
showToast(e.message || 'Failed to save gradient', 'error');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Apply a custom preset by name. */
|
/** Delete a gradient entity and refresh the picker. */
|
||||||
export function applyCustomGradientPreset(name: any) {
|
export async function deleteAndRefreshGradientPreset(gradientId: any) {
|
||||||
const presets = loadCustomGradientPresets();
|
try {
|
||||||
const preset = presets.find((p: any) => p.name === name);
|
await fetchWithAuth(`/gradients/${gradientId}`, { method: 'DELETE' });
|
||||||
if (!preset) return;
|
await gradientsCache.fetch({ force: true });
|
||||||
gradientInit(preset.stops);
|
showToast(t('color_strip.gradient.preset.deleted'), 'success');
|
||||||
}
|
refreshGradientPresetPicker();
|
||||||
|
} catch (e: any) {
|
||||||
/** Delete a custom preset and refresh the picker. */
|
showToast(e.message || 'Failed to delete gradient', 'error');
|
||||||
export function deleteAndRefreshGradientPreset(name: any) {
|
}
|
||||||
deleteCustomGradientPreset(name);
|
|
||||||
showToast(t('color_strip.gradient.preset.deleted'), 'success');
|
|
||||||
refreshGradientPresetPicker();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function _ensureAnimationTypeIconSelect(cssType: any) {
|
function _ensureAnimationTypeIconSelect(cssType: any) {
|
||||||
@@ -596,17 +590,24 @@ function _ensureAnimationTypeIconSelect(cssType: any) {
|
|||||||
|
|
||||||
/* ── Effect type helpers ──────────────────────────────────────── */
|
/* ── Effect type helpers ──────────────────────────────────────── */
|
||||||
|
|
||||||
// Palette color control points — mirrors _PALETTE_DEFS in effect_stream.py
|
/**
|
||||||
const _PALETTE_COLORS = {
|
* Build a gradient strip preview HTML from a GradientEntity's stops.
|
||||||
fire: [[0,'0,0,0'],[0.33,'200,24,0'],[0.66,'255,160,0'],[1,'255,255,200']],
|
* Accepts [{position, color: [R,G,B]}, ...] format from the API.
|
||||||
ocean: [[0,'0,0,32'],[0.33,'0,16,128'],[0.66,'0,128,255'],[1,'128,224,255']],
|
*/
|
||||||
lava: [[0,'0,0,0'],[0.25,'128,0,0'],[0.5,'255,32,0'],[0.75,'255,160,0'],[1,'255,255,128']],
|
function _gradientEntityStripHTML(stops: Array<{ position: number; color: number[] }>, w = 80, h = 16) {
|
||||||
forest: [[0,'0,16,0'],[0.33,'0,80,0'],[0.66,'32,160,0'],[1,'128,255,64']],
|
const css = stops.map(s => `rgb(${s.color.join(',')}) ${(s.position * 100).toFixed(0)}%`).join(', ');
|
||||||
rainbow: [[0,'255,0,0'],[0.17,'255,255,0'],[0.33,'0,255,0'],[0.5,'0,255,255'],[0.67,'0,0,255'],[0.83,'255,0,255'],[1,'255,0,0']],
|
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>`;
|
||||||
aurora: [[0,'0,16,32'],[0.2,'0,80,64'],[0.4,'0,200,100'],[0.6,'64,128,255'],[0.8,'128,0,200'],[1,'0,16,32']],
|
}
|
||||||
sunset: [[0,'32,0,64'],[0.25,'128,0,128'],[0.5,'255,64,0'],[0.75,'255,192,64'],[1,'255,255,192']],
|
|
||||||
ice: [[0,'0,0,64'],[0.33,'0,64,192'],[0.66,'128,192,255'],[1,'240,248,255']],
|
/** Get cached gradient entities (or empty array if not yet loaded). */
|
||||||
};
|
function _getGradients(): GradientEntity[] {
|
||||||
|
return gradientsCache.data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find a gradient entity by ID. */
|
||||||
|
function _findGradient(id: string): GradientEntity | undefined {
|
||||||
|
return _getGradients().find(g => g.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
// Default palette per effect type
|
// Default palette per effect type
|
||||||
export function onEffectTypeChange() {
|
export function onEffectTypeChange() {
|
||||||
@@ -639,9 +640,7 @@ export function onEffectTypeChange() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function onEffectPaletteChange() {
|
export function onEffectPaletteChange() {
|
||||||
const palette = (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value;
|
// No-op — kept for HTML onclick compatibility
|
||||||
const customGroup = document.getElementById('css-editor-effect-custom-palette-group') as HTMLElement;
|
|
||||||
if (customGroup) customGroup.style.display = palette === 'custom' ? '' : 'none';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Color Cycle helpers ──────────────────────────────────────── */
|
/* ── Color Cycle helpers ──────────────────────────────────────── */
|
||||||
@@ -885,8 +884,9 @@ function _loadAudioState(css: any) {
|
|||||||
(document.getElementById('css-editor-audio-smoothing') as HTMLInputElement).value = smoothing;
|
(document.getElementById('css-editor-audio-smoothing') as HTMLInputElement).value = smoothing;
|
||||||
(document.getElementById('css-editor-audio-smoothing-val') as HTMLElement).textContent = parseFloat(smoothing).toFixed(2);
|
(document.getElementById('css-editor-audio-smoothing-val') as HTMLElement).textContent = parseFloat(smoothing).toFixed(2);
|
||||||
|
|
||||||
(document.getElementById('css-editor-audio-palette') as HTMLInputElement).value = css.palette || 'rainbow';
|
const audioGradientId = css.gradient_id || 'gr_builtin_rainbow';
|
||||||
if (_audioPaletteIconSelect) _audioPaletteIconSelect.setValue(css.palette || 'rainbow');
|
(document.getElementById('css-editor-audio-palette') as HTMLInputElement).value = audioGradientId;
|
||||||
|
if (_audioPaletteIconSelect) _audioPaletteIconSelect.setValue(audioGradientId);
|
||||||
(document.getElementById('css-editor-audio-color') as HTMLInputElement).value = rgbArrayToHex(css.color || [0, 255, 0]);
|
(document.getElementById('css-editor-audio-color') as HTMLInputElement).value = rgbArrayToHex(css.color || [0, 255, 0]);
|
||||||
(document.getElementById('css-editor-audio-color-peak') as HTMLInputElement).value = rgbArrayToHex(css.color_peak || [255, 0, 0]);
|
(document.getElementById('css-editor-audio-color-peak') as HTMLInputElement).value = rgbArrayToHex(css.color_peak || [255, 0, 0]);
|
||||||
(document.getElementById('css-editor-audio-mirror') as HTMLInputElement).checked = css.mirror || false;
|
(document.getElementById('css-editor-audio-mirror') as HTMLInputElement).checked = css.mirror || false;
|
||||||
@@ -905,8 +905,8 @@ function _resetAudioState() {
|
|||||||
(document.getElementById('css-editor-audio-sensitivity-val') as HTMLElement).textContent = '1.0';
|
(document.getElementById('css-editor-audio-sensitivity-val') as HTMLElement).textContent = '1.0';
|
||||||
(document.getElementById('css-editor-audio-smoothing') as HTMLInputElement).value = 0.3 as any;
|
(document.getElementById('css-editor-audio-smoothing') as HTMLInputElement).value = 0.3 as any;
|
||||||
(document.getElementById('css-editor-audio-smoothing-val') as HTMLElement).textContent = '0.30';
|
(document.getElementById('css-editor-audio-smoothing-val') as HTMLElement).textContent = '0.30';
|
||||||
(document.getElementById('css-editor-audio-palette') as HTMLInputElement).value = 'rainbow';
|
(document.getElementById('css-editor-audio-palette') as HTMLInputElement).value = 'gr_builtin_rainbow';
|
||||||
if (_audioPaletteIconSelect) _audioPaletteIconSelect.setValue('rainbow');
|
if (_audioPaletteIconSelect) _audioPaletteIconSelect.setValue('gr_builtin_rainbow');
|
||||||
(document.getElementById('css-editor-audio-color') as HTMLInputElement).value = '#00ff00';
|
(document.getElementById('css-editor-audio-color') as HTMLInputElement).value = '#00ff00';
|
||||||
(document.getElementById('css-editor-audio-color-peak') as HTMLInputElement).value = '#ff0000';
|
(document.getElementById('css-editor-audio-color-peak') as HTMLInputElement).value = '#ff0000';
|
||||||
(document.getElementById('css-editor-audio-mirror') as HTMLInputElement).checked = false;
|
(document.getElementById('css-editor-audio-mirror') as HTMLInputElement).checked = false;
|
||||||
@@ -939,7 +939,7 @@ export function createColorStripCard(source: ColorStripSource, pictureSourceMap:
|
|||||||
|
|
||||||
let propsHtml;
|
let propsHtml;
|
||||||
if (isStatic) {
|
if (isStatic) {
|
||||||
const hexColor = rgbArrayToHex(source.color);
|
const hexColor = rgbArrayToHex(source.color!);
|
||||||
propsHtml = `
|
propsHtml = `
|
||||||
<span class="stream-card-prop" title="${t('color_strip.static_color')}">
|
<span class="stream-card-prop" title="${t('color_strip.static_color')}">
|
||||||
<span style="display:inline-block;width:14px;height:14px;background:${hexColor};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${hexColor.toUpperCase()}
|
<span style="display:inline-block;width:14px;height:14px;background:${hexColor};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${hexColor.toUpperCase()}
|
||||||
@@ -1081,7 +1081,7 @@ export function createColorStripCard(source: ColorStripSource, pictureSourceMap:
|
|||||||
${psNames.length ? `<span class="stream-card-prop stream-card-prop-full" title="${t('color_strip.picture_source')}">${ICON_LINK_SOURCE} ${escapeHtml(psNames.join(', '))}</span>` : ''}
|
${psNames.length ? `<span class="stream-card-prop stream-card-prop-full" title="${t('color_strip.picture_source')}">${ICON_LINK_SOURCE} ${escapeHtml(psNames.join(', '))}</span>` : ''}
|
||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
const ps = pictureSourceMap && pictureSourceMap[source.picture_source_id];
|
const ps = pictureSourceMap && pictureSourceMap[source.picture_source_id!];
|
||||||
const srcName = ps ? ps.name : source.picture_source_id || '—';
|
const srcName = ps ? ps.name : source.picture_source_id || '—';
|
||||||
const cal = source.calibration ?? {} as Partial<import('../types.ts').Calibration>;
|
const cal = source.calibration ?? {} as Partial<import('../types.ts').Calibration>;
|
||||||
const calLeds = (cal.leds_top || 0) + (cal.leds_right || 0) + (cal.leds_bottom || 0) + (cal.leds_left || 0);
|
const calLeds = (cal.leds_top || 0) + (cal.leds_right || 0) + (cal.leds_bottom || 0) + (cal.leds_left || 0);
|
||||||
@@ -1209,18 +1209,26 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
|||||||
},
|
},
|
||||||
gradient: {
|
gradient: {
|
||||||
load(css) {
|
load(css) {
|
||||||
(document.getElementById('css-editor-gradient-preset') as HTMLInputElement).value = '';
|
const presetId = css.gradient_id || '';
|
||||||
if (_gradientPresetIconSelect) _gradientPresetIconSelect.setValue('');
|
(document.getElementById('css-editor-gradient-preset') as HTMLInputElement).value = presetId;
|
||||||
gradientInit(css.stops || [
|
if (_gradientPresetIconSelect) _gradientPresetIconSelect.setValue(presetId);
|
||||||
|
// If gradient_id is set, load stops from the gradient entity
|
||||||
|
let stops = css.stops || [
|
||||||
{ position: 0.0, color: [255, 0, 0] },
|
{ position: 0.0, color: [255, 0, 0] },
|
||||||
{ position: 1.0, color: [0, 0, 255] },
|
{ position: 1.0, color: [0, 0, 255] },
|
||||||
]);
|
];
|
||||||
|
if (presetId) {
|
||||||
|
const g = _findGradient(presetId);
|
||||||
|
if (g) stops = g.stops;
|
||||||
|
}
|
||||||
|
gradientInit(stops);
|
||||||
_loadAnimationState(css.animation);
|
_loadAnimationState(css.animation);
|
||||||
(document.getElementById('css-editor-gradient-easing') as HTMLSelectElement).value = css.easing || 'linear';
|
(document.getElementById('css-editor-gradient-easing') as HTMLSelectElement).value = css.easing || 'linear';
|
||||||
if (_gradientEasingIconSelect) _gradientEasingIconSelect.setValue(css.easing || 'linear');
|
if (_gradientEasingIconSelect) _gradientEasingIconSelect.setValue(css.easing || 'linear');
|
||||||
},
|
},
|
||||||
reset() {
|
reset() {
|
||||||
(document.getElementById('css-editor-gradient-preset') as HTMLInputElement).value = '';
|
(document.getElementById('css-editor-gradient-preset') as HTMLInputElement).value = '';
|
||||||
|
if (_gradientPresetIconSelect) _gradientPresetIconSelect.setValue('');
|
||||||
gradientInit([
|
gradientInit([
|
||||||
{ position: 0.0, color: [255, 0, 0] },
|
{ position: 0.0, color: [255, 0, 0] },
|
||||||
{ position: 1.0, color: [0, 0, 255] },
|
{ position: 1.0, color: [0, 0, 255] },
|
||||||
@@ -1235,8 +1243,10 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
|||||||
cssEditorModal.showError(t('color_strip.gradient.min_stops'));
|
cssEditorModal.showError(t('color_strip.gradient.min_stops'));
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const gradientId = (document.getElementById('css-editor-gradient-preset') as HTMLInputElement).value || null;
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
|
gradient_id: gradientId,
|
||||||
stops: gStops.map(s => ({
|
stops: gStops.map(s => ({
|
||||||
position: s.position,
|
position: s.position,
|
||||||
color: s.color,
|
color: s.color,
|
||||||
@@ -1252,36 +1262,33 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
|||||||
(document.getElementById('css-editor-effect-type') as HTMLInputElement).value = css.effect_type || 'fire';
|
(document.getElementById('css-editor-effect-type') as HTMLInputElement).value = css.effect_type || 'fire';
|
||||||
if (_effectTypeIconSelect) _effectTypeIconSelect.setValue(css.effect_type || 'fire');
|
if (_effectTypeIconSelect) _effectTypeIconSelect.setValue(css.effect_type || 'fire');
|
||||||
onEffectTypeChange();
|
onEffectTypeChange();
|
||||||
(document.getElementById('css-editor-effect-palette') as HTMLInputElement).value = css.palette || 'fire';
|
const gradientId = css.gradient_id || 'gr_builtin_fire';
|
||||||
if (_effectPaletteIconSelect) _effectPaletteIconSelect.setValue(css.palette || 'fire');
|
(document.getElementById('css-editor-effect-palette') as HTMLInputElement).value = gradientId;
|
||||||
|
if (_effectPaletteIconSelect) _effectPaletteIconSelect.setValue(gradientId);
|
||||||
(document.getElementById('css-editor-effect-color') as HTMLInputElement).value = rgbArrayToHex(css.color || [255, 80, 0]);
|
(document.getElementById('css-editor-effect-color') as HTMLInputElement).value = rgbArrayToHex(css.color || [255, 80, 0]);
|
||||||
(document.getElementById('css-editor-effect-intensity') as HTMLInputElement).value = css.intensity ?? 1.0;
|
(document.getElementById('css-editor-effect-intensity') as HTMLInputElement).value = css.intensity ?? 1.0;
|
||||||
(document.getElementById('css-editor-effect-intensity-val') as HTMLElement).textContent = parseFloat(css.intensity ?? 1.0).toFixed(1);
|
(document.getElementById('css-editor-effect-intensity-val') as HTMLElement).textContent = parseFloat(css.intensity ?? 1.0).toFixed(1);
|
||||||
(document.getElementById('css-editor-effect-scale') as HTMLInputElement).value = css.scale ?? 1.0;
|
(document.getElementById('css-editor-effect-scale') as HTMLInputElement).value = css.scale ?? 1.0;
|
||||||
(document.getElementById('css-editor-effect-scale-val') as HTMLElement).textContent = parseFloat(css.scale ?? 1.0).toFixed(1);
|
(document.getElementById('css-editor-effect-scale-val') as HTMLElement).textContent = parseFloat(css.scale ?? 1.0).toFixed(1);
|
||||||
(document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked = css.mirror || false;
|
(document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked = css.mirror || false;
|
||||||
// Custom palette
|
|
||||||
const cpTextarea = document.getElementById('css-editor-effect-custom-palette') as HTMLTextAreaElement;
|
|
||||||
if (cpTextarea) cpTextarea.value = css.custom_palette ? JSON.stringify(css.custom_palette) : '';
|
|
||||||
onEffectPaletteChange();
|
onEffectPaletteChange();
|
||||||
},
|
},
|
||||||
reset() {
|
reset() {
|
||||||
(document.getElementById('css-editor-effect-type') as HTMLInputElement).value = 'fire';
|
(document.getElementById('css-editor-effect-type') as HTMLInputElement).value = 'fire';
|
||||||
(document.getElementById('css-editor-effect-palette') as HTMLInputElement).value = 'fire';
|
(document.getElementById('css-editor-effect-palette') as HTMLInputElement).value = 'gr_builtin_fire';
|
||||||
|
if (_effectPaletteIconSelect) _effectPaletteIconSelect.setValue('gr_builtin_fire');
|
||||||
(document.getElementById('css-editor-effect-color') as HTMLInputElement).value = '#ff5000';
|
(document.getElementById('css-editor-effect-color') as HTMLInputElement).value = '#ff5000';
|
||||||
(document.getElementById('css-editor-effect-intensity') as HTMLInputElement).value = 1.0 as any;
|
(document.getElementById('css-editor-effect-intensity') as HTMLInputElement).value = 1.0 as any;
|
||||||
(document.getElementById('css-editor-effect-intensity-val') as HTMLElement).textContent = '1.0';
|
(document.getElementById('css-editor-effect-intensity-val') as HTMLElement).textContent = '1.0';
|
||||||
(document.getElementById('css-editor-effect-scale') as HTMLInputElement).value = 1.0 as any;
|
(document.getElementById('css-editor-effect-scale') as HTMLInputElement).value = 1.0 as any;
|
||||||
(document.getElementById('css-editor-effect-scale-val') as HTMLElement).textContent = '1.0';
|
(document.getElementById('css-editor-effect-scale-val') as HTMLElement).textContent = '1.0';
|
||||||
(document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked = false;
|
(document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked = false;
|
||||||
const cpTextarea = document.getElementById('css-editor-effect-custom-palette') as HTMLTextAreaElement;
|
|
||||||
if (cpTextarea) cpTextarea.value = '';
|
|
||||||
},
|
},
|
||||||
getPayload(name) {
|
getPayload(name) {
|
||||||
const payload: any = {
|
const payload: any = {
|
||||||
name,
|
name,
|
||||||
effect_type: (document.getElementById('css-editor-effect-type') as HTMLInputElement).value,
|
effect_type: (document.getElementById('css-editor-effect-type') as HTMLInputElement).value,
|
||||||
palette: (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value,
|
gradient_id: (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value,
|
||||||
intensity: parseFloat((document.getElementById('css-editor-effect-intensity') as HTMLInputElement).value),
|
intensity: parseFloat((document.getElementById('css-editor-effect-intensity') as HTMLInputElement).value),
|
||||||
scale: parseFloat((document.getElementById('css-editor-effect-scale') as HTMLInputElement).value),
|
scale: parseFloat((document.getElementById('css-editor-effect-scale') as HTMLInputElement).value),
|
||||||
mirror: (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked,
|
mirror: (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked,
|
||||||
@@ -1291,18 +1298,6 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
|||||||
const hex = (document.getElementById('css-editor-effect-color') as HTMLInputElement).value;
|
const hex = (document.getElementById('css-editor-effect-color') as HTMLInputElement).value;
|
||||||
payload.color = [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)];
|
payload.color = [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)];
|
||||||
}
|
}
|
||||||
// Custom palette
|
|
||||||
if (payload.palette === 'custom') {
|
|
||||||
const cpText = (document.getElementById('css-editor-effect-custom-palette') as HTMLTextAreaElement).value.trim();
|
|
||||||
if (cpText) {
|
|
||||||
try {
|
|
||||||
payload.custom_palette = JSON.parse(cpText);
|
|
||||||
} catch {
|
|
||||||
cssEditorModal.showError('Invalid custom palette JSON');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return payload;
|
return payload;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1321,7 +1316,7 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
|
|||||||
audio_source_id: (document.getElementById('css-editor-audio-source') as HTMLInputElement).value || null,
|
audio_source_id: (document.getElementById('css-editor-audio-source') as HTMLInputElement).value || null,
|
||||||
sensitivity: parseFloat((document.getElementById('css-editor-audio-sensitivity') as HTMLInputElement).value),
|
sensitivity: parseFloat((document.getElementById('css-editor-audio-sensitivity') as HTMLInputElement).value),
|
||||||
smoothing: parseFloat((document.getElementById('css-editor-audio-smoothing') as HTMLInputElement).value),
|
smoothing: parseFloat((document.getElementById('css-editor-audio-smoothing') as HTMLInputElement).value),
|
||||||
palette: (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value,
|
gradient_id: (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value,
|
||||||
color: hexToRgbArray((document.getElementById('css-editor-audio-color') as HTMLInputElement).value),
|
color: hexToRgbArray((document.getElementById('css-editor-audio-color') as HTMLInputElement).value),
|
||||||
color_peak: hexToRgbArray((document.getElementById('css-editor-audio-color-peak') as HTMLInputElement).value),
|
color_peak: hexToRgbArray((document.getElementById('css-editor-audio-color-peak') as HTMLInputElement).value),
|
||||||
mirror: (document.getElementById('css-editor-audio-mirror') as HTMLInputElement).checked,
|
mirror: (document.getElementById('css-editor-audio-mirror') as HTMLInputElement).checked,
|
||||||
@@ -1622,6 +1617,9 @@ export async function showCSSEditor(cssId: any = null, cloneData: any = null, pr
|
|||||||
(document.getElementById('css-editor-led-count') as HTMLInputElement).value = css.led_count ?? 0;
|
(document.getElementById('css-editor-led-count') as HTMLInputElement).value = css.led_count ?? 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Ensure gradient entities are loaded before opening the editor
|
||||||
|
await gradientsCache.fetch();
|
||||||
|
|
||||||
// Initialize icon-grid type selector (idempotent)
|
// Initialize icon-grid type selector (idempotent)
|
||||||
_ensureCSSTypeIconSelect();
|
_ensureCSSTypeIconSelect();
|
||||||
|
|
||||||
@@ -1900,6 +1898,162 @@ export async function stopCSSOverlay(cssId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Gradient entity management ────────────────────────────────────────
|
||||||
|
|
||||||
|
class GradientEditorModal extends Modal {
|
||||||
|
constructor() { super('gradient-editor-modal'); }
|
||||||
|
|
||||||
|
snapshotValues() {
|
||||||
|
return {
|
||||||
|
name: (document.getElementById('gradient-editor-name') as HTMLInputElement).value,
|
||||||
|
description: (document.getElementById('gradient-editor-description') as HTMLInputElement).value,
|
||||||
|
stops: JSON.stringify(getGradientStops()),
|
||||||
|
tags: JSON.stringify(_gradientTagsInput ? _gradientTagsInput.getValue() : []),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
onForceClose() {
|
||||||
|
if (_gradientTagsInput) { _gradientTagsInput.destroy(); _gradientTagsInput = null; }
|
||||||
|
gradientSetIdPrefix(''); // Reset to default for CSS editor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const gradientEditorModal = new GradientEditorModal();
|
||||||
|
let _gradientTagsInput: any = null;
|
||||||
|
|
||||||
|
/** Open the gradient editor modal for create, edit, or clone. */
|
||||||
|
export async function showGradientModal(editId: string | null = null, cloneData: any = null) {
|
||||||
|
const titleEl = document.getElementById('gradient-editor-title')!;
|
||||||
|
const idInput = document.getElementById('gradient-editor-id') as HTMLInputElement;
|
||||||
|
const nameInput = document.getElementById('gradient-editor-name') as HTMLInputElement;
|
||||||
|
const descInput = document.getElementById('gradient-editor-description') as HTMLInputElement;
|
||||||
|
const errorEl = document.getElementById('gradient-editor-error') as HTMLElement;
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
idInput.value = '';
|
||||||
|
nameInput.value = '';
|
||||||
|
descInput.value = '';
|
||||||
|
errorEl.style.display = 'none';
|
||||||
|
|
||||||
|
// Tags
|
||||||
|
if (_gradientTagsInput) { _gradientTagsInput.destroy(); _gradientTagsInput = null; }
|
||||||
|
const tagsContainer = document.getElementById('gradient-editor-tags-container')!;
|
||||||
|
_gradientTagsInput = new TagInput(tagsContainer, { placeholder: t('tags.placeholder') });
|
||||||
|
|
||||||
|
let stops = [{ position: 0.0, color: [255, 0, 0] }, { position: 1.0, color: [0, 0, 255] }];
|
||||||
|
|
||||||
|
if (editId) {
|
||||||
|
// Edit mode
|
||||||
|
const g = _findGradient(editId);
|
||||||
|
if (!g) return;
|
||||||
|
idInput.value = g.id;
|
||||||
|
nameInput.value = g.name;
|
||||||
|
descInput.value = g.description || '';
|
||||||
|
_gradientTagsInput.setValue(g.tags || []);
|
||||||
|
stops = g.stops;
|
||||||
|
titleEl.innerHTML = `${ICON_PALETTE} ${t('gradient.edit')}`;
|
||||||
|
} else if (cloneData) {
|
||||||
|
// Clone mode
|
||||||
|
nameInput.value = (cloneData.name || '') + ' (Copy)';
|
||||||
|
descInput.value = cloneData.description || '';
|
||||||
|
_gradientTagsInput.setValue(cloneData.tags || []);
|
||||||
|
stops = cloneData.stops || stops;
|
||||||
|
titleEl.innerHTML = `${ICON_PALETTE} ${t('gradient.add')}`;
|
||||||
|
} else {
|
||||||
|
// Create mode
|
||||||
|
titleEl.innerHTML = `${ICON_PALETTE} ${t('gradient.add')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
gradientEditorModal.open();
|
||||||
|
// Init gradient editor with ge- prefix (standalone modal, not CSS editor)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
gradientSetIdPrefix('ge-');
|
||||||
|
gradientInit(stops);
|
||||||
|
gradientEditorModal.snapshot();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close the gradient editor modal (with dirty check). */
|
||||||
|
export async function closeGradientEditor() {
|
||||||
|
await gradientEditorModal.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save gradient entity from the editor modal. */
|
||||||
|
export async function saveGradientEntity() {
|
||||||
|
const id = (document.getElementById('gradient-editor-id') as HTMLInputElement).value;
|
||||||
|
const name = (document.getElementById('gradient-editor-name') as HTMLInputElement).value.trim();
|
||||||
|
const description = (document.getElementById('gradient-editor-description') as HTMLInputElement).value.trim() || null;
|
||||||
|
const tags = _gradientTagsInput ? _gradientTagsInput.getValue() : [];
|
||||||
|
const errorEl = document.getElementById('gradient-editor-error') as HTMLElement;
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
errorEl.textContent = t('gradient.error.name_required');
|
||||||
|
errorEl.style.display = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stops = getGradientStops().map(s => ({
|
||||||
|
position: s.position,
|
||||||
|
color: [...s.color],
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (stops.length < 2) {
|
||||||
|
errorEl.textContent = t('gradient.error.min_stops');
|
||||||
|
errorEl.style.display = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: any = { name, stops, description, tags };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = id ? `/gradients/${id}` : '/gradients';
|
||||||
|
const method = id ? 'PUT' : 'POST';
|
||||||
|
const res = await fetchWithAuth(url, { method, body: JSON.stringify(payload) });
|
||||||
|
if (!res!.ok) {
|
||||||
|
const err = await res!.json();
|
||||||
|
throw new Error(err.detail || 'Failed to save gradient');
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast(id ? t('gradient.updated') : t('gradient.created'), 'success');
|
||||||
|
gradientsCache.invalidate();
|
||||||
|
gradientEditorModal.forceClose();
|
||||||
|
if (window.loadPictureSources) await window.loadPictureSources();
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.isAuth) return;
|
||||||
|
errorEl.textContent = e.message;
|
||||||
|
errorEl.style.display = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clone a gradient entity — opens editor with cloned data. */
|
||||||
|
export async function cloneGradient(gradientId: string) {
|
||||||
|
const g = _findGradient(gradientId);
|
||||||
|
if (!g) return;
|
||||||
|
await showGradientModal(null, g);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Edit a gradient entity. */
|
||||||
|
export async function editGradient(gradientId: string) {
|
||||||
|
await showGradientModal(gradientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete a gradient entity (with confirmation). */
|
||||||
|
export async function deleteGradient(gradientId: string) {
|
||||||
|
const g = _findGradient(gradientId);
|
||||||
|
if (!g) return;
|
||||||
|
const ok = await showConfirm(t('gradient.confirm_delete', { name: g.name }));
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await fetchWithAuth(`/gradients/${gradientId}`, { method: 'DELETE' });
|
||||||
|
gradientsCache.invalidate();
|
||||||
|
showToast(t('gradient.deleted'), 'success');
|
||||||
|
if (window.loadPictureSources) await window.loadPictureSources();
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.isAuth) return;
|
||||||
|
showToast(e.message || t('gradient.error.delete_failed'), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Test / Preview modal (extracted to color-strips-test.ts) ──
|
// ── Test / Preview modal (extracted to color-strips-test.ts) ──
|
||||||
export {
|
export {
|
||||||
previewCSSFromEditor,
|
previewCSSFromEditor,
|
||||||
|
|||||||
@@ -54,6 +54,12 @@ let _gradientStops: GradientStop[] = [];
|
|||||||
let _gradientSelectedIdx: number = -1;
|
let _gradientSelectedIdx: number = -1;
|
||||||
let _gradientDragging: GradientDragState | null = null;
|
let _gradientDragging: GradientDragState | null = null;
|
||||||
let _gradientOnChange: (() => void) | null = null;
|
let _gradientOnChange: (() => void) | null = null;
|
||||||
|
let _idPrefix: string = '';
|
||||||
|
|
||||||
|
/** Set an ID prefix for DOM elements (e.g. 'ge-' to find 'ge-gradient-canvas'). */
|
||||||
|
export function gradientSetIdPrefix(prefix: string): void { _idPrefix = prefix; }
|
||||||
|
|
||||||
|
function _el(id: string): HTMLElement | null { return document.getElementById(_idPrefix + id); }
|
||||||
|
|
||||||
/** Set a callback that fires whenever stops change. */
|
/** Set a callback that fires whenever stops change. */
|
||||||
export function gradientSetOnChange(fn: (() => void) | null): void { _gradientOnChange = fn; }
|
export function gradientSetOnChange(fn: (() => void) | null): void { _gradientOnChange = fn; }
|
||||||
@@ -215,7 +221,7 @@ export function gradientRenderAll(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _gradientRenderCanvas(): void {
|
function _gradientRenderCanvas(): void {
|
||||||
const canvas = document.getElementById('gradient-canvas') as HTMLCanvasElement | null;
|
const canvas = _el('gradient-canvas') as HTMLCanvasElement | null;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
|
|
||||||
// Sync canvas pixel width to its CSS display width
|
// Sync canvas pixel width to its CSS display width
|
||||||
@@ -241,7 +247,7 @@ function _gradientRenderCanvas(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _gradientRenderMarkers(): void {
|
function _gradientRenderMarkers(): void {
|
||||||
const track = document.getElementById('gradient-markers-track');
|
const track = _el('gradient-markers-track');
|
||||||
if (!track) return;
|
if (!track) return;
|
||||||
track.innerHTML = '';
|
track.innerHTML = '';
|
||||||
|
|
||||||
@@ -276,7 +282,7 @@ function _gradientSelectStop(idx: number): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _gradientRenderStopList(): void {
|
function _gradientRenderStopList(): void {
|
||||||
const list = document.getElementById('gradient-stops-list');
|
const list = _el('gradient-stops-list');
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
list.innerHTML = '';
|
list.innerHTML = '';
|
||||||
|
|
||||||
@@ -305,7 +311,7 @@ function _gradientRenderStopList(): void {
|
|||||||
row.addEventListener('mousedown', () => _gradientSelectStop(idx));
|
row.addEventListener('mousedown', () => _gradientSelectStop(idx));
|
||||||
|
|
||||||
// Position
|
// Position
|
||||||
const posInput = row.querySelector('.gradient-stop-pos');
|
const posInput = row.querySelector('.gradient-stop-pos')!;
|
||||||
posInput.addEventListener('change', (e) => {
|
posInput.addEventListener('change', (e) => {
|
||||||
const target = e.target as HTMLInputElement;
|
const target = e.target as HTMLInputElement;
|
||||||
const val = Math.min(1, Math.max(0, parseFloat(target.value) || 0));
|
const val = Math.min(1, Math.max(0, parseFloat(target.value) || 0));
|
||||||
@@ -316,7 +322,7 @@ function _gradientRenderStopList(): void {
|
|||||||
posInput.addEventListener('focus', () => _gradientSelectStop(idx));
|
posInput.addEventListener('focus', () => _gradientSelectStop(idx));
|
||||||
|
|
||||||
// Left color
|
// Left color
|
||||||
row.querySelector('.gradient-stop-color').addEventListener('input', (e) => {
|
row.querySelector('.gradient-stop-color')!.addEventListener('input', (e) => {
|
||||||
const val = (e.target as HTMLInputElement).value;
|
const val = (e.target as HTMLInputElement).value;
|
||||||
_gradientStops[idx].color = hexToRgbArray(val);
|
_gradientStops[idx].color = hexToRgbArray(val);
|
||||||
const markers = document.querySelectorAll('.gradient-marker');
|
const markers = document.querySelectorAll('.gradient-marker');
|
||||||
@@ -325,7 +331,7 @@ function _gradientRenderStopList(): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Bidirectional toggle
|
// Bidirectional toggle
|
||||||
row.querySelector('.gradient-stop-bidir-btn').addEventListener('click', (e) => {
|
row.querySelector('.gradient-stop-bidir-btn')!.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
_gradientStops[idx].colorRight = _gradientStops[idx].colorRight
|
_gradientStops[idx].colorRight = _gradientStops[idx].colorRight
|
||||||
? null
|
? null
|
||||||
@@ -335,13 +341,13 @@ function _gradientRenderStopList(): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Right color
|
// Right color
|
||||||
row.querySelector('.gradient-stop-color-right').addEventListener('input', (e) => {
|
row.querySelector('.gradient-stop-color-right')!.addEventListener('input', (e) => {
|
||||||
_gradientStops[idx].colorRight = hexToRgbArray((e.target as HTMLInputElement).value);
|
_gradientStops[idx].colorRight = hexToRgbArray((e.target as HTMLInputElement).value);
|
||||||
_gradientRenderCanvas();
|
_gradientRenderCanvas();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove
|
// Remove
|
||||||
row.querySelector('.btn-danger').addEventListener('click', (e) => {
|
row.querySelector('.btn-danger')!.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (_gradientStops.length > 2) {
|
if (_gradientStops.length > 2) {
|
||||||
_gradientStops.splice(idx, 1);
|
_gradientStops.splice(idx, 1);
|
||||||
@@ -382,7 +388,7 @@ export function gradientAddStop(position?: number): void {
|
|||||||
/* ── Drag ─────────────────────────────────────────────────────── */
|
/* ── Drag ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
function _gradientStartDrag(e: MouseEvent, idx: number): void {
|
function _gradientStartDrag(e: MouseEvent, idx: number): void {
|
||||||
const track = document.getElementById('gradient-markers-track');
|
const track = _el('gradient-markers-track');
|
||||||
if (!track) return;
|
if (!track) return;
|
||||||
_gradientDragging = { idx, trackRect: track.getBoundingClientRect() };
|
_gradientDragging = { idx, trackRect: track.getBoundingClientRect() };
|
||||||
|
|
||||||
@@ -442,7 +448,7 @@ export function deleteCustomGradientPreset(name: string): void {
|
|||||||
/* ── Track click → add stop ───────────────────────────────────── */
|
/* ── Track click → add stop ───────────────────────────────────── */
|
||||||
|
|
||||||
function _gradientSetupTrackClick(): void {
|
function _gradientSetupTrackClick(): void {
|
||||||
const track = document.getElementById('gradient-markers-track');
|
const track = _el('gradient-markers-track');
|
||||||
if (!track || (track as any)._gradientClickBound) return;
|
if (!track || (track as any)._gradientClickBound) return;
|
||||||
(track as any)._gradientClickBound = true;
|
(track as any)._gradientClickBound = true;
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, filtersCache,
|
audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, filtersCache,
|
||||||
colorStripSourcesCache,
|
colorStripSourcesCache,
|
||||||
csptCache, stripFiltersCache,
|
csptCache, stripFiltersCache,
|
||||||
|
gradientsCache, GradientEntity,
|
||||||
} from '../core/state.ts';
|
} from '../core/state.ts';
|
||||||
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.ts';
|
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.ts';
|
||||||
import { t } from '../core/i18n.ts';
|
import { t } from '../core/i18n.ts';
|
||||||
@@ -55,7 +56,7 @@ import {
|
|||||||
ICON_TEMPLATE, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_LINK_SOURCE,
|
ICON_TEMPLATE, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_LINK_SOURCE,
|
||||||
ICON_FPS, ICON_WEB, ICON_VALUE_SOURCE, ICON_CLOCK, ICON_AUDIO_LOOPBACK, ICON_AUDIO_INPUT,
|
ICON_FPS, ICON_WEB, ICON_VALUE_SOURCE, ICON_CLOCK, ICON_AUDIO_LOOPBACK, ICON_AUDIO_INPUT,
|
||||||
ICON_AUDIO_TEMPLATE, ICON_MONITOR, ICON_WRENCH, ICON_RADIO,
|
ICON_AUDIO_TEMPLATE, ICON_MONITOR, ICON_WRENCH, ICON_RADIO,
|
||||||
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_CSPT, ICON_HELP, ICON_TRASH,
|
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_CSPT, ICON_HELP, ICON_TRASH, ICON_PALETTE,
|
||||||
} from '../core/icons.ts';
|
} from '../core/icons.ts';
|
||||||
import * as P from '../core/icon-paths.ts';
|
import * as P from '../core/icon-paths.ts';
|
||||||
|
|
||||||
@@ -109,6 +110,8 @@ const csColorStrips = new CardSection('color-strips', { titleKey: 'targets.secti
|
|||||||
const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.value_sources', bulkActions: _valueSourceDeleteAction });
|
const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.value_sources', bulkActions: _valueSourceDeleteAction });
|
||||||
const csSyncClocks = new CardSection('sync-clocks', { titleKey: 'sync_clock.group.title', gridClass: 'templates-grid', addCardOnclick: "showSyncClockModal()", keyAttr: 'data-id', emptyKey: 'section.empty.sync_clocks', bulkActions: _syncClockDeleteAction });
|
const csSyncClocks = new CardSection('sync-clocks', { titleKey: 'sync_clock.group.title', gridClass: 'templates-grid', addCardOnclick: "showSyncClockModal()", keyAttr: 'data-id', emptyKey: 'section.empty.sync_clocks', bulkActions: _syncClockDeleteAction });
|
||||||
const csCSPTemplates = new CardSection('css-proc-templates', { titleKey: 'css_processing.title', gridClass: 'templates-grid', addCardOnclick: "showAddCSPTModal()", keyAttr: 'data-cspt-id', emptyKey: 'section.empty.cspt', bulkActions: _csptDeleteAction });
|
const csCSPTemplates = new CardSection('css-proc-templates', { titleKey: 'css_processing.title', gridClass: 'templates-grid', addCardOnclick: "showAddCSPTModal()", keyAttr: 'data-cspt-id', emptyKey: 'section.empty.cspt', bulkActions: _csptDeleteAction });
|
||||||
|
const _gradientDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('gradients', gradientsCache, 'gradient.deleted') }];
|
||||||
|
const csGradients = new CardSection('gradients', { titleKey: 'gradient.group.title', gridClass: 'templates-grid', addCardOnclick: "showGradientModal()", keyAttr: 'data-id', emptyKey: 'section.empty.gradients', bulkActions: _gradientDeleteAction });
|
||||||
|
|
||||||
// Re-render picture sources when language changes
|
// Re-render picture sources when language changes
|
||||||
document.addEventListener('languageChanged', () => { if (apiKey) loadPictureSources(); });
|
document.addEventListener('languageChanged', () => { if (apiKey) loadPictureSources(); });
|
||||||
@@ -220,13 +223,14 @@ export async function loadPictureSources() {
|
|||||||
audioTemplatesCache.fetch(),
|
audioTemplatesCache.fetch(),
|
||||||
colorStripSourcesCache.fetch(),
|
colorStripSourcesCache.fetch(),
|
||||||
csptCache.fetch(),
|
csptCache.fetch(),
|
||||||
|
gradientsCache.fetch(),
|
||||||
filtersCache.data.length === 0 ? filtersCache.fetch() : Promise.resolve(filtersCache.data),
|
filtersCache.data.length === 0 ? filtersCache.fetch() : Promise.resolve(filtersCache.data),
|
||||||
]);
|
]);
|
||||||
renderPictureSourcesList(streams);
|
renderPictureSourcesList(streams);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.isAuth) return;
|
if (error.isAuth) return;
|
||||||
console.error('Error loading picture sources:', error);
|
console.error('Error loading picture sources:', error);
|
||||||
document.getElementById('streams-list').innerHTML = `
|
document.getElementById('streams-list')!.innerHTML = `
|
||||||
<div class="error-message">${t('streams.error.load')}: ${error.message}</div>
|
<div class="error-message">${t('streams.error.load')}: ${error.message}</div>
|
||||||
`;
|
`;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -273,7 +277,7 @@ const _streamSectionMap = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function renderPictureSourcesList(streams: any) {
|
function renderPictureSourcesList(streams: any) {
|
||||||
const container = document.getElementById('streams-list');
|
const container = document.getElementById('streams-list')!;
|
||||||
const activeTab = localStorage.getItem('activeStreamTab') || 'raw';
|
const activeTab = localStorage.getItem('activeStreamTab') || 'raw';
|
||||||
|
|
||||||
const renderStreamCard = (stream: any) => {
|
const renderStreamCard = (stream: any) => {
|
||||||
@@ -329,7 +333,7 @@ function renderPictureSourcesList(streams: any) {
|
|||||||
removeTitle: t('common.delete'),
|
removeTitle: t('common.delete'),
|
||||||
content: `
|
content: `
|
||||||
<div class="template-card-header">
|
<div class="template-card-header">
|
||||||
<div class="template-name">${typeIcon} ${escapeHtml(stream.name)}</div>
|
<div class="template-name" title="${escapeHtml(stream.name)}">${typeIcon} ${escapeHtml(stream.name)}</div>
|
||||||
</div>
|
</div>
|
||||||
${detailsHtml}
|
${detailsHtml}
|
||||||
${renderTagChips(stream.tags)}
|
${renderTagChips(stream.tags)}
|
||||||
@@ -352,7 +356,7 @@ function renderPictureSourcesList(streams: any) {
|
|||||||
removeTitle: t('common.delete'),
|
removeTitle: t('common.delete'),
|
||||||
content: `
|
content: `
|
||||||
<div class="template-card-header">
|
<div class="template-card-header">
|
||||||
<div class="template-name">${ICON_TEMPLATE} ${escapeHtml(template.name)}</div>
|
<div class="template-name" title="${escapeHtml(template.name)}">${ICON_TEMPLATE} ${escapeHtml(template.name)}</div>
|
||||||
</div>
|
</div>
|
||||||
${template.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(template.description)}</div>` : ''}
|
${template.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(template.description)}</div>` : ''}
|
||||||
<div class="stream-card-props">
|
<div class="stream-card-props">
|
||||||
@@ -398,7 +402,7 @@ function renderPictureSourcesList(streams: any) {
|
|||||||
removeTitle: t('common.delete'),
|
removeTitle: t('common.delete'),
|
||||||
content: `
|
content: `
|
||||||
<div class="template-card-header">
|
<div class="template-card-header">
|
||||||
<div class="template-name">${ICON_TEMPLATE} ${escapeHtml(tmpl.name)}</div>
|
<div class="template-name" title="${escapeHtml(tmpl.name)}">${ICON_TEMPLATE} ${escapeHtml(tmpl.name)}</div>
|
||||||
</div>
|
</div>
|
||||||
${tmpl.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(tmpl.description)}</div>` : ''}
|
${tmpl.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(tmpl.description)}</div>` : ''}
|
||||||
${filterChainHtml}
|
${filterChainHtml}
|
||||||
@@ -424,7 +428,7 @@ function renderPictureSourcesList(streams: any) {
|
|||||||
removeTitle: t('common.delete'),
|
removeTitle: t('common.delete'),
|
||||||
content: `
|
content: `
|
||||||
<div class="template-card-header">
|
<div class="template-card-header">
|
||||||
<div class="template-name">${ICON_CSPT} ${escapeHtml(tmpl.name)}</div>
|
<div class="template-name" title="${escapeHtml(tmpl.name)}">${ICON_CSPT} ${escapeHtml(tmpl.name)}</div>
|
||||||
</div>
|
</div>
|
||||||
${tmpl.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(tmpl.description)}</div>` : ''}
|
${tmpl.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(tmpl.description)}</div>` : ''}
|
||||||
${filterChainHtml}
|
${filterChainHtml}
|
||||||
@@ -454,6 +458,8 @@ function renderPictureSourcesList(streams: any) {
|
|||||||
const audioSourceMap = {};
|
const audioSourceMap = {};
|
||||||
_cachedAudioSources.forEach(s => { audioSourceMap[s.id] = s; });
|
_cachedAudioSources.forEach(s => { audioSourceMap[s.id] = s; });
|
||||||
|
|
||||||
|
const gradients = gradientsCache.data;
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ key: 'raw', icon: getPictureSourceIcon('raw'), titleKey: 'streams.group.raw', count: rawStreams.length },
|
{ key: 'raw', icon: getPictureSourceIcon('raw'), titleKey: 'streams.group.raw', count: rawStreams.length },
|
||||||
{ key: 'raw_templates', icon: ICON_CAPTURE_TEMPLATE, titleKey: 'streams.group.raw_templates', count: _cachedCaptureTemplates.length },
|
{ key: 'raw_templates', icon: ICON_CAPTURE_TEMPLATE, titleKey: 'streams.group.raw_templates', count: _cachedCaptureTemplates.length },
|
||||||
@@ -463,6 +469,7 @@ function renderPictureSourcesList(streams: any) {
|
|||||||
{ key: 'proc_templates', icon: ICON_PP_TEMPLATE, titleKey: 'streams.group.proc_templates', count: _cachedPPTemplates.length },
|
{ key: 'proc_templates', icon: ICON_PP_TEMPLATE, titleKey: 'streams.group.proc_templates', count: _cachedPPTemplates.length },
|
||||||
{ key: 'css_processing', icon: ICON_CSPT, titleKey: 'streams.group.css_processing', count: csptTemplates.length },
|
{ key: 'css_processing', icon: ICON_CSPT, titleKey: 'streams.group.css_processing', count: csptTemplates.length },
|
||||||
{ key: 'color_strip', icon: getColorStripIcon('static'), titleKey: 'streams.group.color_strip', count: colorStrips.length },
|
{ key: 'color_strip', icon: getColorStripIcon('static'), titleKey: 'streams.group.color_strip', count: colorStrips.length },
|
||||||
|
{ key: 'gradients', icon: ICON_PALETTE, titleKey: 'streams.group.gradients', count: gradients.length },
|
||||||
{ key: 'audio', icon: getAudioSourceIcon('multichannel'), titleKey: 'streams.group.audio', count: _cachedAudioSources.length },
|
{ key: 'audio', icon: getAudioSourceIcon('multichannel'), titleKey: 'streams.group.audio', count: _cachedAudioSources.length },
|
||||||
{ key: 'audio_templates', icon: ICON_AUDIO_TEMPLATE, titleKey: 'streams.group.audio_templates', count: _cachedAudioTemplates.length },
|
{ key: 'audio_templates', icon: ICON_AUDIO_TEMPLATE, titleKey: 'streams.group.audio_templates', count: _cachedAudioTemplates.length },
|
||||||
{ key: 'value', icon: ICON_VALUE_SOURCE, titleKey: 'streams.group.value', count: _cachedValueSources.length },
|
{ key: 'value', icon: ICON_VALUE_SOURCE, titleKey: 'streams.group.value', count: _cachedValueSources.length },
|
||||||
@@ -501,6 +508,7 @@ function renderPictureSourcesList(streams: any) {
|
|||||||
key: 'strip_group', icon: getColorStripIcon('static'), titleKey: 'tree.group.strip',
|
key: 'strip_group', icon: getColorStripIcon('static'), titleKey: 'tree.group.strip',
|
||||||
children: [
|
children: [
|
||||||
{ key: 'color_strip', titleKey: 'tree.leaf.sources', icon: getColorStripIcon('static'), count: colorStrips.length },
|
{ key: 'color_strip', titleKey: 'tree.leaf.sources', icon: getColorStripIcon('static'), count: colorStrips.length },
|
||||||
|
{ key: 'gradients', titleKey: 'streams.group.gradients', icon: ICON_PALETTE, count: gradients.length },
|
||||||
{ key: 'css_processing', titleKey: 'tree.leaf.processing_templates', icon: ICON_CSPT, count: csptTemplates.length },
|
{ key: 'css_processing', titleKey: 'tree.leaf.processing_templates', icon: ICON_CSPT, count: csptTemplates.length },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -553,15 +561,15 @@ function renderPictureSourcesList(streams: any) {
|
|||||||
removeTitle: t('common.delete'),
|
removeTitle: t('common.delete'),
|
||||||
content: `
|
content: `
|
||||||
<div class="template-card-header">
|
<div class="template-card-header">
|
||||||
<div class="template-name">${icon} ${escapeHtml(src.name)}</div>
|
<div class="template-name" title="${escapeHtml(src.name)}">${icon} ${escapeHtml(src.name)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stream-card-props">${propsHtml}</div>
|
<div class="stream-card-props">${propsHtml}</div>
|
||||||
${renderTagChips(src.tags)}
|
${renderTagChips(src.tags)}
|
||||||
${src.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(src.description)}</div>` : ''}`,
|
${src.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(src.description)}</div>` : ''}`,
|
||||||
actions: `
|
actions: `
|
||||||
<button class="btn btn-icon btn-secondary" data-action="test-audio" data-id="${src.id}" title="${t('audio_source.test')}">${ICON_TEST}</button>
|
<button class="btn btn-icon btn-secondary" data-action="test-audio" title="${t('audio_source.test')}">${ICON_TEST}</button>
|
||||||
<button class="btn btn-icon btn-secondary" data-action="clone-audio" data-id="${src.id}" title="${t('common.clone')}">${ICON_CLONE}</button>
|
<button class="btn btn-icon btn-secondary" data-action="clone-audio" title="${t('common.clone')}">${ICON_CLONE}</button>
|
||||||
<button class="btn btn-icon btn-secondary" data-action="edit-audio" data-id="${src.id}" title="${t('common.edit')}">${ICON_EDIT}</button>`,
|
<button class="btn btn-icon btn-secondary" data-action="edit-audio" title="${t('common.edit')}">${ICON_EDIT}</button>`,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -575,7 +583,7 @@ function renderPictureSourcesList(streams: any) {
|
|||||||
removeTitle: t('common.delete'),
|
removeTitle: t('common.delete'),
|
||||||
content: `
|
content: `
|
||||||
<div class="template-card-header">
|
<div class="template-card-header">
|
||||||
<div class="template-name">${ICON_AUDIO_TEMPLATE} ${escapeHtml(template.name)}</div>
|
<div class="template-name" title="${escapeHtml(template.name)}">${ICON_AUDIO_TEMPLATE} ${escapeHtml(template.name)}</div>
|
||||||
</div>
|
</div>
|
||||||
${template.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(template.description)}</div>` : ''}
|
${template.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(template.description)}</div>` : ''}
|
||||||
<div class="stream-card-props">
|
<div class="stream-card-props">
|
||||||
@@ -607,6 +615,31 @@ function renderPictureSourcesList(streams: any) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Gradient card renderer
|
||||||
|
const renderGradientCard = (g: GradientEntity) => {
|
||||||
|
const cssStops = g.stops.map(s => `rgb(${s.color.join(',')}) ${(s.position * 100).toFixed(0)}%`).join(', ');
|
||||||
|
const stripPreview = `<div style="height:24px;border-radius:4px;background:linear-gradient(to right,${cssStops});margin-bottom:6px"></div>`;
|
||||||
|
const lockBadge = g.is_builtin ? `<span class="badge badge-info" style="font-size:0.7em;margin-left:4px">${t('gradient.builtin')}</span>` : '';
|
||||||
|
const cloneBtn = `<button class="btn btn-icon btn-secondary" onclick="cloneGradient('${g.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>`;
|
||||||
|
const editBtn = g.is_builtin ? '' : `<button class="btn btn-icon btn-secondary" onclick="editGradient('${g.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`;
|
||||||
|
return wrapCard({
|
||||||
|
type: 'template-card',
|
||||||
|
dataAttr: 'data-id',
|
||||||
|
id: g.id,
|
||||||
|
removeOnclick: g.is_builtin ? '' : `deleteGradient('${g.id}')`,
|
||||||
|
removeTitle: t('common.delete'),
|
||||||
|
content: `
|
||||||
|
<div class="template-card-header">
|
||||||
|
<div class="template-name">${ICON_PALETTE} ${escapeHtml(g.name)}${lockBadge}</div>
|
||||||
|
</div>
|
||||||
|
${stripPreview}
|
||||||
|
<div class="stream-card-props">
|
||||||
|
<span class="stream-card-prop">${g.stops.length} ${t('gradient.stops_label')}</span>
|
||||||
|
</div>`,
|
||||||
|
actions: `${cloneBtn}${editBtn}`,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Build item arrays for all sections
|
// Build item arrays for all sections
|
||||||
const rawStreamItems = csRawStreams.applySortOrder(rawStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })));
|
const rawStreamItems = csRawStreams.applySortOrder(rawStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })));
|
||||||
const rawTemplateItems = csRawTemplates.applySortOrder(_cachedCaptureTemplates.map(t => ({ key: t.id, html: renderCaptureTemplateCard(t) })));
|
const rawTemplateItems = csRawTemplates.applySortOrder(_cachedCaptureTemplates.map(t => ({ key: t.id, html: renderCaptureTemplateCard(t) })));
|
||||||
@@ -618,6 +651,7 @@ function renderPictureSourcesList(streams: any) {
|
|||||||
const staticItems = csStaticStreams.applySortOrder(staticImageStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })));
|
const staticItems = csStaticStreams.applySortOrder(staticImageStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })));
|
||||||
const videoItems = csVideoStreams.applySortOrder(videoStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })));
|
const videoItems = csVideoStreams.applySortOrder(videoStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })));
|
||||||
const colorStripItems = csColorStrips.applySortOrder(colorStrips.map(s => ({ key: s.id, html: createColorStripCard(s, pictureSourceMap, audioSourceMap) })));
|
const colorStripItems = csColorStrips.applySortOrder(colorStrips.map(s => ({ key: s.id, html: createColorStripCard(s, pictureSourceMap, audioSourceMap) })));
|
||||||
|
const gradientItems = csGradients.applySortOrder(gradients.map(g => ({ key: g.id, html: renderGradientCard(g) })));
|
||||||
const valueItems = csValueSources.applySortOrder(_cachedValueSources.map(s => ({ key: s.id, html: createValueSourceCard(s) })));
|
const valueItems = csValueSources.applySortOrder(_cachedValueSources.map(s => ({ key: s.id, html: createValueSourceCard(s) })));
|
||||||
const syncClockItems = csSyncClocks.applySortOrder(_cachedSyncClocks.map(s => ({ key: s.id, html: createSyncClockCard(s) })));
|
const syncClockItems = csSyncClocks.applySortOrder(_cachedSyncClocks.map(s => ({ key: s.id, html: createSyncClockCard(s) })));
|
||||||
const csptItems = csCSPTemplates.applySortOrder(csptTemplates.map(t => ({ key: t.id, html: renderCSPTCard(t) })));
|
const csptItems = csCSPTemplates.applySortOrder(csptTemplates.map(t => ({ key: t.id, html: renderCSPTCard(t) })));
|
||||||
@@ -633,6 +667,7 @@ function renderPictureSourcesList(streams: any) {
|
|||||||
proc_templates: _cachedPPTemplates.length,
|
proc_templates: _cachedPPTemplates.length,
|
||||||
css_processing: csptTemplates.length,
|
css_processing: csptTemplates.length,
|
||||||
color_strip: colorStrips.length,
|
color_strip: colorStrips.length,
|
||||||
|
gradients: gradients.length,
|
||||||
audio: _cachedAudioSources.length,
|
audio: _cachedAudioSources.length,
|
||||||
audio_templates: _cachedAudioTemplates.length,
|
audio_templates: _cachedAudioTemplates.length,
|
||||||
value: _cachedValueSources.length,
|
value: _cachedValueSources.length,
|
||||||
@@ -644,6 +679,7 @@ function renderPictureSourcesList(streams: any) {
|
|||||||
csProcTemplates.reconcile(procTemplateItems);
|
csProcTemplates.reconcile(procTemplateItems);
|
||||||
csCSPTemplates.reconcile(csptItems);
|
csCSPTemplates.reconcile(csptItems);
|
||||||
csColorStrips.reconcile(colorStripItems);
|
csColorStrips.reconcile(colorStripItems);
|
||||||
|
csGradients.reconcile(gradientItems);
|
||||||
csAudioMulti.reconcile(multiItems);
|
csAudioMulti.reconcile(multiItems);
|
||||||
csAudioMono.reconcile(monoItems);
|
csAudioMono.reconcile(monoItems);
|
||||||
csAudioTemplates.reconcile(audioTemplateItems);
|
csAudioTemplates.reconcile(audioTemplateItems);
|
||||||
@@ -661,6 +697,7 @@ function renderPictureSourcesList(streams: any) {
|
|||||||
else if (tab.key === 'proc_templates') panelContent = csProcTemplates.render(procTemplateItems);
|
else if (tab.key === 'proc_templates') panelContent = csProcTemplates.render(procTemplateItems);
|
||||||
else if (tab.key === 'css_processing') panelContent = csCSPTemplates.render(csptItems);
|
else if (tab.key === 'css_processing') panelContent = csCSPTemplates.render(csptItems);
|
||||||
else if (tab.key === 'color_strip') panelContent = csColorStrips.render(colorStripItems);
|
else if (tab.key === 'color_strip') panelContent = csColorStrips.render(colorStripItems);
|
||||||
|
else if (tab.key === 'gradients') panelContent = csGradients.render(gradientItems);
|
||||||
else if (tab.key === 'audio') panelContent = csAudioMulti.render(multiItems) + csAudioMono.render(monoItems);
|
else if (tab.key === 'audio') panelContent = csAudioMulti.render(multiItems) + csAudioMono.render(monoItems);
|
||||||
else if (tab.key === 'audio_templates') panelContent = csAudioTemplates.render(audioTemplateItems);
|
else if (tab.key === 'audio_templates') panelContent = csAudioTemplates.render(audioTemplateItems);
|
||||||
else if (tab.key === 'value') panelContent = csValueSources.render(valueItems);
|
else if (tab.key === 'value') panelContent = csValueSources.render(valueItems);
|
||||||
@@ -671,7 +708,7 @@ function renderPictureSourcesList(streams: any) {
|
|||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
container.innerHTML = panels;
|
container.innerHTML = panels;
|
||||||
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csAudioMulti, csAudioMono, csAudioTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks]);
|
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csGradients, csAudioMulti, csAudioMono, csAudioTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks]);
|
||||||
|
|
||||||
// Event delegation for card actions (replaces inline onclick handlers)
|
// Event delegation for card actions (replaces inline onclick handlers)
|
||||||
initSyncClockDelegation(container);
|
initSyncClockDelegation(container);
|
||||||
@@ -687,6 +724,7 @@ function renderPictureSourcesList(streams: any) {
|
|||||||
'proc-streams': 'processed', 'proc-templates': 'proc_templates',
|
'proc-streams': 'processed', 'proc-templates': 'proc_templates',
|
||||||
'css-proc-templates': 'css_processing',
|
'css-proc-templates': 'css_processing',
|
||||||
'color-strips': 'color_strip',
|
'color-strips': 'color_strip',
|
||||||
|
'gradients': 'gradients',
|
||||||
'audio-multi': 'audio', 'audio-mono': 'audio',
|
'audio-multi': 'audio', 'audio-mono': 'audio',
|
||||||
'audio-templates': 'audio_templates',
|
'audio-templates': 'audio_templates',
|
||||||
'value-sources': 'value',
|
'value-sources': 'value',
|
||||||
@@ -1465,7 +1503,7 @@ function _onFilterDragMove(e: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Position clone at pointer
|
// Position clone at pointer
|
||||||
ds.clone.style.top = (e.clientY - ds.offsetY) + 'px';
|
ds.clone!.style.top = (e.clientY - ds.offsetY) + 'px';
|
||||||
|
|
||||||
// Find drop target by vertical midpoint
|
// Find drop target by vertical midpoint
|
||||||
const cards = ds.container.querySelectorAll('.pp-filter-card');
|
const cards = ds.container.querySelectorAll('.pp-filter-card');
|
||||||
@@ -1534,8 +1572,8 @@ function _onFilterDragEnd() {
|
|||||||
|
|
||||||
// Cleanup DOM
|
// Cleanup DOM
|
||||||
ds.card.style.display = '';
|
ds.card.style.display = '';
|
||||||
ds.placeholder.remove();
|
ds.placeholder!.remove();
|
||||||
ds.clone.remove();
|
ds.clone!.remove();
|
||||||
document.body.classList.remove('pp-filter-dragging');
|
document.body.classList.remove('pp-filter-dragging');
|
||||||
|
|
||||||
// Reorder filters array
|
// Reorder filters array
|
||||||
|
|||||||
@@ -252,11 +252,15 @@ interface Window {
|
|||||||
mappedAddZone: (...args: any[]) => any;
|
mappedAddZone: (...args: any[]) => any;
|
||||||
mappedRemoveZone: (...args: any[]) => any;
|
mappedRemoveZone: (...args: any[]) => any;
|
||||||
onAudioVizChange: (...args: any[]) => any;
|
onAudioVizChange: (...args: any[]) => any;
|
||||||
applyGradientPreset: (...args: any[]) => any;
|
|
||||||
onGradientPresetChange: (...args: any[]) => any;
|
onGradientPresetChange: (...args: any[]) => any;
|
||||||
promptAndSaveGradientPreset: (...args: any[]) => any;
|
promptAndSaveGradientPreset: (...args: any[]) => any;
|
||||||
applyCustomGradientPreset: (...args: any[]) => any;
|
|
||||||
deleteAndRefreshGradientPreset: (...args: any[]) => any;
|
deleteAndRefreshGradientPreset: (...args: any[]) => any;
|
||||||
|
showGradientModal: (...args: any[]) => any;
|
||||||
|
closeGradientEditor: (...args: any[]) => any;
|
||||||
|
saveGradientEntity: (...args: any[]) => any;
|
||||||
|
cloneGradient: (...args: any[]) => any;
|
||||||
|
editGradient: (...args: any[]) => any;
|
||||||
|
deleteGradient: (...args: any[]) => any;
|
||||||
cloneColorStrip: (...args: any[]) => any;
|
cloneColorStrip: (...args: any[]) => any;
|
||||||
toggleCSSOverlay: (...args: any[]) => any;
|
toggleCSSOverlay: (...args: any[]) => any;
|
||||||
previewCSSFromEditor: (...args: any[]) => any;
|
previewCSSFromEditor: (...args: any[]) => any;
|
||||||
|
|||||||
@@ -1339,6 +1339,27 @@
|
|||||||
"audio_template.error.delete": "Failed to delete audio template",
|
"audio_template.error.delete": "Failed to delete audio template",
|
||||||
"streams.group.value": "Value Sources",
|
"streams.group.value": "Value Sources",
|
||||||
"streams.group.sync": "Sync Clocks",
|
"streams.group.sync": "Sync Clocks",
|
||||||
|
"streams.group.gradients": "Gradients",
|
||||||
|
"gradient.group.title": "Gradients",
|
||||||
|
"gradient.add": "Add Gradient",
|
||||||
|
"gradient.edit": "Edit Gradient",
|
||||||
|
"gradient.builtin": "Built-in",
|
||||||
|
"gradient.stops_label": "stops",
|
||||||
|
"gradient.name": "Name:",
|
||||||
|
"gradient.name.hint": "A descriptive name for this gradient.",
|
||||||
|
"gradient.description": "Description:",
|
||||||
|
"gradient.description.hint": "Optional description for this gradient.",
|
||||||
|
"gradient.created": "Gradient created",
|
||||||
|
"gradient.updated": "Gradient updated",
|
||||||
|
"gradient.cloned": "Gradient cloned",
|
||||||
|
"gradient.deleted": "Gradient deleted",
|
||||||
|
"gradient.error.name_required": "Name is required",
|
||||||
|
"gradient.error.min_stops": "At least 2 color stops are required",
|
||||||
|
"gradient.error.delete_failed": "Failed to delete gradient",
|
||||||
|
"gradient.create_name": "New gradient name:",
|
||||||
|
"gradient.edit_name": "Rename gradient:",
|
||||||
|
"gradient.confirm_delete": "Delete gradient \"{name}\"?",
|
||||||
|
"section.empty.gradients": "No gradients yet",
|
||||||
"tree.group.picture": "Picture Source",
|
"tree.group.picture": "Picture Source",
|
||||||
"tree.group.capture": "Screen Capture",
|
"tree.group.capture": "Screen Capture",
|
||||||
"tree.group.static": "Static",
|
"tree.group.static": "Static",
|
||||||
|
|||||||
@@ -180,6 +180,7 @@
|
|||||||
{% include 'modals/device-settings.html' %}
|
{% include 'modals/device-settings.html' %}
|
||||||
{% include 'modals/target-editor.html' %}
|
{% include 'modals/target-editor.html' %}
|
||||||
{% include 'modals/css-editor.html' %}
|
{% include 'modals/css-editor.html' %}
|
||||||
|
{% include 'modals/gradient-editor.html' %}
|
||||||
{% include 'modals/test-css-source.html' %}
|
{% include 'modals/test-css-source.html' %}
|
||||||
{% include 'modals/notification-history.html' %}
|
{% include 'modals/notification-history.html' %}
|
||||||
{% include 'modals/kc-editor.html' %}
|
{% include 'modals/kc-editor.html' %}
|
||||||
|
|||||||
@@ -212,16 +212,6 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="css-editor-effect-custom-palette-group" class="form-group" style="display:none">
|
|
||||||
<div class="label-row">
|
|
||||||
<label for="css-editor-effect-custom-palette" data-i18n="color_strip.effect.custom_palette">Custom Palette:</label>
|
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
|
||||||
</div>
|
|
||||||
<small class="input-hint" style="display:none" data-i18n="color_strip.effect.custom_palette.hint">JSON array of [position, R, G, B] stops.</small>
|
|
||||||
<textarea id="css-editor-effect-custom-palette" rows="3"
|
|
||||||
placeholder='[[0,0,0,0],[0.5,255,0,0],[1,255,255,0]]'></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="css-editor-effect-color-group" class="form-group" style="display:none">
|
<div id="css-editor-effect-color-group" class="form-group" style="display:none">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label for="css-editor-effect-color" data-i18n="color_strip.effect.color">Color:</label>
|
<label for="css-editor-effect-color" data-i18n="color_strip.effect.color">Color:</label>
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<!-- Gradient entity editor modal -->
|
||||||
|
<div id="gradient-editor-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="gradient-editor-title">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="gradient-editor-title" data-i18n="gradient.add">Add Gradient</h2>
|
||||||
|
<button class="modal-close-btn" onclick="closeGradientEditor()" data-i18n-aria-label="aria.close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<input type="hidden" id="gradient-editor-id">
|
||||||
|
<div id="gradient-editor-error" class="modal-error" style="display:none"></div>
|
||||||
|
|
||||||
|
<!-- Name + Tags (same form-group, matching other entities) -->
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="label-row">
|
||||||
|
<label for="gradient-editor-name" data-i18n="gradient.name">Name:</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="gradient.name.hint">A descriptive name for this gradient.</small>
|
||||||
|
<input type="text" id="gradient-editor-name" required>
|
||||||
|
<div id="gradient-editor-tags-container"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="label-row">
|
||||||
|
<label for="gradient-editor-description" data-i18n="gradient.description">Description:</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="gradient.description.hint">Optional description for this gradient.</small>
|
||||||
|
<input type="text" id="gradient-editor-description" maxlength="500">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Gradient preview + markers (unique IDs prefixed ge-) -->
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="label-row">
|
||||||
|
<label data-i18n="color_strip.gradient.preview">Gradient:</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="color_strip.gradient.preview.hint">Visual preview. Click the marker track below to add a stop. Drag markers to reposition.</small>
|
||||||
|
<div class="gradient-editor">
|
||||||
|
<canvas id="ge-gradient-canvas" height="44"></canvas>
|
||||||
|
<div id="ge-gradient-markers-track" class="gradient-markers-track"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Color stops list -->
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="label-row">
|
||||||
|
<label data-i18n="color_strip.gradient.stops">Color Stops:</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="color_strip.gradient.stops.hint">Each stop defines a color at a relative position (0.0 = start, 1.0 = end). The ↔ button adds a right-side color to create a hard edge at that stop.</small>
|
||||||
|
<div id="ge-gradient-stops-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-icon btn-secondary" onclick="closeGradientEditor()" title="Cancel" data-i18n-title="settings.button.cancel" data-i18n-aria-label="aria.cancel">✕</button>
|
||||||
|
<button class="btn btn-icon btn-primary" onclick="saveGradientEntity()" title="Save" data-i18n-title="settings.button.save" data-i18n-aria-label="aria.save">✓</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user