feat: add per-layer LED range and collapsible layers to composite source
Some checks failed
Lint & Test / test (push) Failing after 33s
Some checks failed
Lint & Test / test (push) Failing after 33s
Composite layers now support optional start/end LED range (toggleable) and reverse flag, making composite a superset of mapped source. Layers are collapsible with animated expand/collapse and consistent 0.85rem font sizing. Delete button restyled as ghost icon button. Also includes minor dashboard CSS overflow fixes.
This commit is contained in:
@@ -102,51 +102,130 @@ export function compositeRenderList() {
|
||||
`<option value="${tmpl.id}"${layer.processing_template_id === tmpl.id ? ' selected' : ''}>${escapeHtml(tmpl.name)}</option>`
|
||||
).join('');
|
||||
const canRemove = _compositeLayers.length > 1;
|
||||
const srcName = _compositeAvailableSources.find(s => s.id === layer.source_id)?.name || '—';
|
||||
const blendLabel = t(`color_strip.composite.blend_mode.${layer.blend_mode}`) || layer.blend_mode;
|
||||
return `
|
||||
<div class="composite-layer-item" data-layer-index="${i}">
|
||||
<div class="composite-layer-row">
|
||||
<div class="composite-layer-header">
|
||||
<span class="composite-layer-drag-handle" title="${t('filters.drag_to_reorder')}">⠇</span>
|
||||
<select class="composite-layer-source" data-idx="${i}">${srcOptions}</select>
|
||||
<select class="composite-layer-blend" data-idx="${i}">
|
||||
<option value="normal"${layer.blend_mode === 'normal' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.normal')}</option>
|
||||
<option value="add"${layer.blend_mode === 'add' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.add')}</option>
|
||||
<option value="multiply"${layer.blend_mode === 'multiply' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.multiply')}</option>
|
||||
<option value="screen"${layer.blend_mode === 'screen' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.screen')}</option>
|
||||
<option value="override"${layer.blend_mode === 'override' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.override')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="composite-layer-row">
|
||||
<label class="composite-layer-opacity-label">
|
||||
<span>${t('color_strip.composite.opacity')}:</span>
|
||||
<span class="composite-opacity-val">${parseFloat(layer.opacity).toFixed(2)}</span>
|
||||
</label>
|
||||
<input type="range" class="composite-layer-opacity" data-idx="${i}"
|
||||
min="0" max="1" step="0.05" value="${layer.opacity}">
|
||||
<span class="composite-layer-expand-btn">▶</span>
|
||||
<span class="composite-layer-summary">
|
||||
<span class="composite-layer-summary-name">${escapeHtml(srcName)}</span>
|
||||
<span class="composite-layer-summary-blend">${escapeHtml(blendLabel)}</span>
|
||||
</span>
|
||||
<label class="settings-toggle composite-layer-toggle">
|
||||
<input type="checkbox" class="composite-layer-enabled" data-idx="${i}"${layer.enabled ? ' checked' : ''}>
|
||||
<span class="settings-toggle-slider"></span>
|
||||
</label>
|
||||
${canRemove
|
||||
? `<button type="button" class="btn btn-secondary composite-layer-remove-btn"
|
||||
onclick="compositeRemoveLayer(${i})">✕</button>`
|
||||
? `<button type="button" class="composite-layer-remove-btn"
|
||||
onclick="compositeRemoveLayer(${i})" title="${t('common.delete')}">✕</button>`
|
||||
: ''}
|
||||
</div>
|
||||
<div class="composite-layer-row">
|
||||
<label class="composite-layer-brightness-label">
|
||||
<span>${t('color_strip.composite.brightness')}:</span>
|
||||
</label>
|
||||
<select class="composite-layer-brightness" data-idx="${i}">${vsOptions}</select>
|
||||
<div class="composite-layer-body-wrapper">
|
||||
<div class="composite-layer-body">
|
||||
<div class="composite-layer-row">
|
||||
<select class="composite-layer-source" data-idx="${i}">${srcOptions}</select>
|
||||
<select class="composite-layer-blend" data-idx="${i}">
|
||||
<option value="normal"${layer.blend_mode === 'normal' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.normal')}</option>
|
||||
<option value="add"${layer.blend_mode === 'add' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.add')}</option>
|
||||
<option value="multiply"${layer.blend_mode === 'multiply' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.multiply')}</option>
|
||||
<option value="screen"${layer.blend_mode === 'screen' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.screen')}</option>
|
||||
<option value="override"${layer.blend_mode === 'override' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.override')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="composite-layer-row">
|
||||
<label class="composite-layer-opacity-label">
|
||||
<span>${t('color_strip.composite.opacity')}:</span>
|
||||
<span class="composite-opacity-val">${parseFloat(layer.opacity).toFixed(2)}</span>
|
||||
</label>
|
||||
<input type="range" class="composite-layer-opacity" data-idx="${i}"
|
||||
min="0" max="1" step="0.05" value="${layer.opacity}">
|
||||
</div>
|
||||
<div class="composite-layer-row">
|
||||
<label class="composite-layer-brightness-label">
|
||||
<span>${t('color_strip.composite.brightness')}:</span>
|
||||
</label>
|
||||
<select class="composite-layer-brightness" data-idx="${i}">${vsOptions}</select>
|
||||
</div>
|
||||
<div class="composite-layer-row">
|
||||
<label class="composite-layer-brightness-label">
|
||||
<span>${t('color_strip.composite.processing')}:</span>
|
||||
</label>
|
||||
<select class="composite-layer-cspt" data-idx="${i}">${csptOptions}</select>
|
||||
</div>
|
||||
<div class="composite-layer-row composite-layer-range-row">
|
||||
<label class="composite-layer-range-toggle-label">
|
||||
<input type="checkbox" class="composite-layer-range-toggle" data-idx="${i}"${(layer.start > 0 || layer.end > 0) ? ' checked' : ''}>
|
||||
<span>${t('color_strip.composite.range')}</span>
|
||||
</label>
|
||||
<div class="composite-layer-range-fields${(layer.start > 0 || layer.end > 0) ? '' : ' composite-layer-range-disabled'}">
|
||||
<label>${t('color_strip.composite.range_start')}</label>
|
||||
<input type="number" class="composite-layer-start" data-idx="${i}"
|
||||
min="0" value="${layer.start || 0}"${(layer.start > 0 || layer.end > 0) ? '' : ' disabled'}>
|
||||
<label>${t('color_strip.composite.range_end')}</label>
|
||||
<input type="number" class="composite-layer-end" data-idx="${i}"
|
||||
min="0" value="${layer.end || 0}"${(layer.start > 0 || layer.end > 0) ? '' : ' disabled'}>
|
||||
</div>
|
||||
<label class="composite-layer-reverse-label">
|
||||
<input type="checkbox" class="composite-layer-reverse" data-idx="${i}"${layer.reverse ? ' checked' : ''}>
|
||||
<span>${t('color_strip.composite.reverse')}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="composite-layer-row">
|
||||
<label class="composite-layer-brightness-label">
|
||||
<span>${t('color_strip.composite.processing')}:</span>
|
||||
</label>
|
||||
<select class="composite-layer-cspt" data-idx="${i}">${csptOptions}</select>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Wire up expand/collapse
|
||||
list.querySelectorAll('.composite-layer-header').forEach(header => {
|
||||
const item = header.closest('.composite-layer-item') as HTMLElement;
|
||||
|
||||
header.addEventListener('click', (e: Event) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('.settings-toggle, .composite-layer-remove-btn, .composite-layer-drag-handle')) return;
|
||||
item.classList.toggle('composite-layer-expanded');
|
||||
});
|
||||
});
|
||||
|
||||
// Wire up source/blend change to update summary text
|
||||
list.querySelectorAll<HTMLSelectElement>('.composite-layer-source').forEach(sel => {
|
||||
sel.addEventListener('change', () => {
|
||||
const item = sel.closest('.composite-layer-item') as HTMLElement;
|
||||
const nameEl = item.querySelector('.composite-layer-summary-name') as HTMLElement;
|
||||
const selectedOpt = sel.options[sel.selectedIndex];
|
||||
if (nameEl && selectedOpt) nameEl.textContent = selectedOpt.text;
|
||||
});
|
||||
});
|
||||
list.querySelectorAll<HTMLSelectElement>('.composite-layer-blend').forEach(sel => {
|
||||
sel.addEventListener('change', () => {
|
||||
const item = sel.closest('.composite-layer-item') as HTMLElement;
|
||||
const blendEl = item.querySelector('.composite-layer-summary-blend') as HTMLElement;
|
||||
const selectedOpt = sel.options[sel.selectedIndex];
|
||||
if (blendEl && selectedOpt) blendEl.textContent = selectedOpt.text;
|
||||
});
|
||||
});
|
||||
|
||||
// Wire up range toggle: enable/disable fields, clear values when unchecked
|
||||
list.querySelectorAll<HTMLInputElement>('.composite-layer-range-toggle').forEach(el => {
|
||||
el.addEventListener('change', () => {
|
||||
const row = el.closest('.composite-layer-range-row')!;
|
||||
const fields = row.querySelector('.composite-layer-range-fields') as HTMLElement;
|
||||
const startInput = fields.querySelector('.composite-layer-start') as HTMLInputElement;
|
||||
const endInput = fields.querySelector('.composite-layer-end') as HTMLInputElement;
|
||||
if (el.checked) {
|
||||
fields.classList.remove('composite-layer-range-disabled');
|
||||
if (startInput) startInput.disabled = false;
|
||||
if (endInput) endInput.disabled = false;
|
||||
} else {
|
||||
fields.classList.add('composite-layer-range-disabled');
|
||||
if (startInput) { startInput.value = '0'; startInput.disabled = true; }
|
||||
if (endInput) { endInput.value = '0'; endInput.disabled = true; }
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Wire up live opacity display
|
||||
list.querySelectorAll<HTMLInputElement>('.composite-layer-opacity').forEach(el => {
|
||||
el.addEventListener('input', () => {
|
||||
@@ -205,6 +284,9 @@ export function compositeAddLayer() {
|
||||
enabled: true,
|
||||
brightness_source_id: null,
|
||||
processing_template_id: null,
|
||||
start: 0,
|
||||
end: 0,
|
||||
reverse: false,
|
||||
});
|
||||
compositeRenderList();
|
||||
}
|
||||
@@ -225,6 +307,9 @@ function _compositeLayersSyncFromDom() {
|
||||
const enableds = list.querySelectorAll<HTMLInputElement>('.composite-layer-enabled');
|
||||
const briSrcs = list.querySelectorAll<HTMLSelectElement>('.composite-layer-brightness');
|
||||
const csptSels = list.querySelectorAll<HTMLSelectElement>('.composite-layer-cspt');
|
||||
const starts = list.querySelectorAll<HTMLInputElement>('.composite-layer-start');
|
||||
const ends = list.querySelectorAll<HTMLInputElement>('.composite-layer-end');
|
||||
const reverses = list.querySelectorAll<HTMLInputElement>('.composite-layer-reverse');
|
||||
if (srcs.length === _compositeLayers.length) {
|
||||
for (let i = 0; i < srcs.length; i++) {
|
||||
_compositeLayers[i].source_id = srcs[i].value;
|
||||
@@ -233,6 +318,9 @@ function _compositeLayersSyncFromDom() {
|
||||
_compositeLayers[i].enabled = enableds[i].checked;
|
||||
_compositeLayers[i].brightness_source_id = briSrcs[i] ? (briSrcs[i].value || null) : null;
|
||||
_compositeLayers[i].processing_template_id = csptSels[i] ? (csptSels[i].value || null) : null;
|
||||
_compositeLayers[i].start = starts[i] ? (parseInt(starts[i].value) || 0) : 0;
|
||||
_compositeLayers[i].end = ends[i] ? (parseInt(ends[i].value) || 0) : 0;
|
||||
_compositeLayers[i].reverse = reverses[i] ? reverses[i].checked : false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -393,6 +481,9 @@ export function compositeGetLayers() {
|
||||
};
|
||||
if (l.brightness_source_id) layer.brightness_source_id = l.brightness_source_id;
|
||||
if (l.processing_template_id) layer.processing_template_id = l.processing_template_id;
|
||||
if (l.start) layer.start = l.start;
|
||||
if (l.end) layer.end = l.end;
|
||||
if (l.reverse) layer.reverse = l.reverse;
|
||||
return layer;
|
||||
});
|
||||
}
|
||||
@@ -407,7 +498,10 @@ export function loadCompositeState(css: any) {
|
||||
enabled: l.enabled != null ? l.enabled : true,
|
||||
brightness_source_id: l.brightness_source_id || null,
|
||||
processing_template_id: l.processing_template_id || null,
|
||||
start: l.start || 0,
|
||||
end: l.end || 0,
|
||||
reverse: l.reverse || false,
|
||||
}))
|
||||
: [{ source_id: '', blend_mode: 'normal', opacity: 1.0, enabled: true, brightness_source_id: null, processing_template_id: null }];
|
||||
: [{ source_id: '', blend_mode: 'normal', opacity: 1.0, enabled: true, brightness_source_id: null, processing_template_id: null, start: 0, end: 0, reverse: false }];
|
||||
compositeRenderList();
|
||||
}
|
||||
|
||||
@@ -598,7 +598,7 @@ function renderDashboardTarget(target: any, isRunning: boolean, devicesMap: Reco
|
||||
<div class="dashboard-target-info">
|
||||
<span class="dashboard-target-icon">${icon}</span>
|
||||
<div>
|
||||
<div class="dashboard-target-name">${escapeHtml(target.name)}${healthDot}</div>
|
||||
<div class="dashboard-target-name"><span class="dashboard-target-name-text">${escapeHtml(target.name)}</span>${healthDot}</div>
|
||||
${subtitleParts.length ? `<div class="dashboard-target-subtitle">${escapeHtml(subtitleParts.join(' · '))}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user