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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user