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:
2026-03-24 13:58:51 +03:00
parent 227b82f522
commit c0d0d839dc
14 changed files with 465 additions and 161 deletions

View File

@@ -37,6 +37,7 @@ import {
audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, filtersCache,
colorStripSourcesCache,
csptCache, stripFiltersCache,
gradientsCache, GradientEntity,
} from '../core/state.ts';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts';
@@ -55,7 +56,7 @@ import {
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_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';
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 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 _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
document.addEventListener('languageChanged', () => { if (apiKey) loadPictureSources(); });
@@ -220,13 +223,14 @@ export async function loadPictureSources() {
audioTemplatesCache.fetch(),
colorStripSourcesCache.fetch(),
csptCache.fetch(),
gradientsCache.fetch(),
filtersCache.data.length === 0 ? filtersCache.fetch() : Promise.resolve(filtersCache.data),
]);
renderPictureSourcesList(streams);
} catch (error) {
if (error.isAuth) return;
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>
`;
} finally {
@@ -273,7 +277,7 @@ const _streamSectionMap = {
};
function renderPictureSourcesList(streams: any) {
const container = document.getElementById('streams-list');
const container = document.getElementById('streams-list')!;
const activeTab = localStorage.getItem('activeStreamTab') || 'raw';
const renderStreamCard = (stream: any) => {
@@ -329,7 +333,7 @@ function renderPictureSourcesList(streams: any) {
removeTitle: t('common.delete'),
content: `
<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>
${detailsHtml}
${renderTagChips(stream.tags)}
@@ -352,7 +356,7 @@ function renderPictureSourcesList(streams: any) {
removeTitle: t('common.delete'),
content: `
<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>
${template.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(template.description)}</div>` : ''}
<div class="stream-card-props">
@@ -398,7 +402,7 @@ function renderPictureSourcesList(streams: any) {
removeTitle: t('common.delete'),
content: `
<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>
${tmpl.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(tmpl.description)}</div>` : ''}
${filterChainHtml}
@@ -424,7 +428,7 @@ function renderPictureSourcesList(streams: any) {
removeTitle: t('common.delete'),
content: `
<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>
${tmpl.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(tmpl.description)}</div>` : ''}
${filterChainHtml}
@@ -454,6 +458,8 @@ function renderPictureSourcesList(streams: any) {
const audioSourceMap = {};
_cachedAudioSources.forEach(s => { audioSourceMap[s.id] = s; });
const gradients = gradientsCache.data;
const tabs = [
{ 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 },
@@ -463,6 +469,7 @@ function renderPictureSourcesList(streams: any) {
{ 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: '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_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 },
@@ -501,6 +508,7 @@ function renderPictureSourcesList(streams: any) {
key: 'strip_group', icon: getColorStripIcon('static'), titleKey: 'tree.group.strip',
children: [
{ 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 },
]
},
@@ -553,15 +561,15 @@ function renderPictureSourcesList(streams: any) {
removeTitle: t('common.delete'),
content: `
<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 class="stream-card-props">${propsHtml}</div>
${renderTagChips(src.tags)}
${src.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(src.description)}</div>` : ''}`,
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="clone-audio" data-id="${src.id}" 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="test-audio" title="${t('audio_source.test')}">${ICON_TEST}</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" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
};
@@ -575,7 +583,7 @@ function renderPictureSourcesList(streams: any) {
removeTitle: t('common.delete'),
content: `
<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>
${template.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(template.description)}</div>` : ''}
<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
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) })));
@@ -618,6 +651,7 @@ function renderPictureSourcesList(streams: any) {
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 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 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) })));
@@ -633,6 +667,7 @@ function renderPictureSourcesList(streams: any) {
proc_templates: _cachedPPTemplates.length,
css_processing: csptTemplates.length,
color_strip: colorStrips.length,
gradients: gradients.length,
audio: _cachedAudioSources.length,
audio_templates: _cachedAudioTemplates.length,
value: _cachedValueSources.length,
@@ -644,6 +679,7 @@ function renderPictureSourcesList(streams: any) {
csProcTemplates.reconcile(procTemplateItems);
csCSPTemplates.reconcile(csptItems);
csColorStrips.reconcile(colorStripItems);
csGradients.reconcile(gradientItems);
csAudioMulti.reconcile(multiItems);
csAudioMono.reconcile(monoItems);
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 === 'css_processing') panelContent = csCSPTemplates.render(csptItems);
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_templates') panelContent = csAudioTemplates.render(audioTemplateItems);
else if (tab.key === 'value') panelContent = csValueSources.render(valueItems);
@@ -671,7 +708,7 @@ function renderPictureSourcesList(streams: any) {
}).join('');
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)
initSyncClockDelegation(container);
@@ -687,6 +724,7 @@ function renderPictureSourcesList(streams: any) {
'proc-streams': 'processed', 'proc-templates': 'proc_templates',
'css-proc-templates': 'css_processing',
'color-strips': 'color_strip',
'gradients': 'gradients',
'audio-multi': 'audio', 'audio-mono': 'audio',
'audio-templates': 'audio_templates',
'value-sources': 'value',
@@ -1465,7 +1503,7 @@ function _onFilterDragMove(e: any) {
}
// 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
const cards = ds.container.querySelectorAll('.pp-filter-card');
@@ -1534,8 +1572,8 @@ function _onFilterDragEnd() {
// Cleanup DOM
ds.card.style.display = '';
ds.placeholder.remove();
ds.clone.remove();
ds.placeholder!.remove();
ds.clone!.remove();
document.body.classList.remove('pp-filter-dragging');
// Reorder filters array