Separate tree nodes into independent panels, remove graph local search, UI improvements

- Split Sources tab: raw/raw_templates, processed/proc_templates each get own panel
- Split Targets tab: led-devices, led-targets, kc-targets, kc-patterns each get own panel
- Remove graph local search — search button and / key open global command palette
- Add graphNavigateToNode for command palette → graph node navigation
- Add tree group expand/collapse animation (max-height + opacity transition)
- Make tree group headers visually distinct (smaller, uppercase, left border on children)
- Make CardSection collapse opt-in via collapsible flag (disabled by default)
- Move filter textbox next to section title (remove margin-left: auto)
- Fix notification bell button vertical centering in test preview
- Fix clipboard copy on non-HTTPS with execCommand fallback
- Add overlay toggle button on picture-based CSS cards
- Add CSPT to graph add-entity picker and global search
- Update all cross-link navigation paths for new panel keys
- Add i18n keys for new tree groups and search groups (en/ru/zh)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 12:32:13 +03:00
parent 3292e0daaf
commit 6c7b7ea7d7
12 changed files with 127 additions and 185 deletions

View File

@@ -732,11 +732,15 @@ body.pp-filter-dragging .pp-filter-drag-handle {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
cursor: default;
user-select: none;
padding: 8px 0;
}
.cs-header:has(.cs-chevron) {
cursor: pointer;
}
/* Prevent sticky header from clipping cards that lift on hover */
[data-cs-content] {
padding-top: 4px;
@@ -783,7 +787,6 @@ body.pp-filter-dragging .pp-filter-drag-handle {
.cs-filter-wrap {
position: relative;
margin-left: auto;
width: 180px;
max-width: 40%;
flex-shrink: 0;

View File

@@ -30,6 +30,10 @@
margin-bottom: 2px;
}
.tree-group:first-child > .tree-group-header {
margin-top: 0;
}
.tree-group-header {
display: flex;
align-items: center;
@@ -37,13 +41,14 @@
padding: 6px 10px;
cursor: pointer;
user-select: none;
font-size: 0.78rem;
font-weight: 600;
color: var(--text-secondary);
font-size: 0.72rem;
font-weight: 700;
color: var(--text-muted);
border-radius: 6px;
transition: color 0.15s, background 0.15s;
text-transform: uppercase;
letter-spacing: 0.03em;
letter-spacing: 0.05em;
margin-top: 6px;
}
.tree-group-header:hover {
@@ -98,21 +103,28 @@
.tree-children {
overflow: hidden;
margin-left: 14px;
border-left: 1px solid var(--border-color);
padding-left: 0;
max-height: 500px;
opacity: 1;
transition: max-height 0.25s ease, opacity 0.2s ease;
}
.tree-children.collapsed {
display: none;
max-height: 0;
opacity: 0;
}
.tree-leaf {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 10px 5px 26px;
padding: 5px 10px 5px 12px;
cursor: pointer;
font-size: 0.8rem;
color: var(--text-secondary);
border-radius: 6px;
border-radius: 0 6px 6px 0;
margin: 1px 0;
transition: color 0.15s, background 0.15s;
}
@@ -243,6 +255,12 @@
display: flex;
flex-wrap: wrap;
gap: 2px;
margin-left: 0;
border-left: none;
padding-left: 0;
max-height: none;
opacity: 1;
transition: none;
}
.tree-children.collapsed {

View File

@@ -163,7 +163,7 @@ import {
// Layer 5.5: graph editor
import {
loadGraphEditor, openGraphSearch, closeGraphSearch,
loadGraphEditor,
toggleGraphLegend, toggleGraphMinimap, toggleGraphFilter,
graphFitAll, graphZoomIn, graphZoomOut, graphRelayout,
graphToggleFullscreen, graphAddEntity,
@@ -481,8 +481,6 @@ Object.assign(window, {
// graph editor
loadGraphEditor,
openGraphSearch,
closeGraphSearch,
toggleGraphLegend,
toggleGraphMinimap,
toggleGraphFilter,

View File

@@ -45,13 +45,14 @@ export class CardSection {
* @param {string} [opts.keyAttr] data attribute that uniquely identifies cards (e.g. 'data-device-id')
* @param {string} [opts.headerExtra] Extra HTML injected between count badge and filter (e.g. action buttons)
*/
constructor(sectionKey, { titleKey, gridClass, addCardOnclick, keyAttr, headerExtra }) {
constructor(sectionKey, { titleKey, gridClass, addCardOnclick, keyAttr, headerExtra, collapsible }) {
this.sectionKey = sectionKey;
this.titleKey = titleKey;
this.gridClass = gridClass;
this.addCardOnclick = addCardOnclick || '';
this.keyAttr = keyAttr || '';
this.headerExtra = headerExtra || '';
this.collapsible = !!collapsible;
this._filterValue = '';
this._lastItems = null;
this._dragState = null;
@@ -73,7 +74,7 @@ export class CardSection {
const count = items.length;
const cardsHtml = items.map(i => i.html).join('');
const isCollapsed = !!_getCollapsedMap()[this.sectionKey];
const isCollapsed = this.collapsible && !!_getCollapsedMap()[this.sectionKey];
const chevronStyle = isCollapsed ? '' : ' style="transform:rotate(90deg)"';
const contentDisplay = isCollapsed ? ' style="display:none"' : '';
const collapsedClass = isCollapsed ? ' cs-collapsed' : '';
@@ -85,7 +86,7 @@ export class CardSection {
return `
<div class="subtab-section${collapsedClass}" data-card-section="${this.sectionKey}">
<div class="subtab-section-header cs-header" data-cs-toggle="${this.sectionKey}">
<span class="cs-chevron"${chevronStyle}>&#9654;</span>
${this.collapsible ? `<span class="cs-chevron"${chevronStyle}>&#9654;</span>` : ''}
<span class="cs-title" data-i18n="${this.titleKey}">${t(this.titleKey)}</span>
<span class="cs-count">${count}</span>
${this.headerExtra ? `<span class="cs-header-extra">${this.headerExtra}</span>` : ''}
@@ -109,10 +110,12 @@ export class CardSection {
const filterInput = document.querySelector(`[data-cs-filter="${this.sectionKey}"]`);
if (!header || !content) return;
header.addEventListener('mousedown', (e) => {
if (e.target.closest('.cs-filter-wrap') || e.target.closest('.cs-header-extra')) return;
this._toggleCollapse(header, content);
});
if (this.collapsible) {
header.addEventListener('mousedown', (e) => {
if (e.target.closest('.cs-filter-wrap') || e.target.closest('.cs-header-extra')) return;
this._toggleCollapse(header, content);
});
}
if (filterInput) {
const resetBtn = document.querySelector(`[data-cs-filter-reset="${this.sectionKey}"]`);

View File

@@ -38,7 +38,7 @@ function _buildItems(results, states = {}) {
_mapEntities(devices, d => items.push({
name: d.name, detail: d.device_type, group: 'devices', icon: ICON_DEVICE,
nav: ['targets', 'led', 'led-devices', 'data-device-id', d.id],
nav: ['targets', 'led-devices', 'led-devices', 'data-device-id', d.id],
}));
_mapEntities(targets, tgt => {
@@ -46,12 +46,12 @@ function _buildItems(results, states = {}) {
if (tgt.target_type === 'key_colors') {
items.push({
name: tgt.name, detail: 'key_colors', group: 'kc_targets', icon: getTargetTypeIcon('key_colors'),
nav: ['targets', 'key_colors', 'kc-targets', 'data-kc-target-id', tgt.id], running,
nav: ['targets', 'kc-targets', 'kc-targets', 'data-kc-target-id', tgt.id], running,
});
} else {
items.push({
name: tgt.name, detail: tgt.target_type, group: 'targets', icon: ICON_TARGET,
nav: ['targets', 'led', 'led-targets', 'data-target-id', tgt.id], running,
nav: ['targets', 'led-targets', 'led-targets', 'data-target-id', tgt.id], running,
});
}
});
@@ -68,17 +68,17 @@ function _buildItems(results, states = {}) {
_mapEntities(capTempl, ct => items.push({
name: ct.name, detail: ct.engine_type, group: 'capture_templates', icon: ICON_CAPTURE_TEMPLATE,
nav: ['streams', 'raw', 'raw-templates', 'data-template-id', ct.id],
nav: ['streams', 'raw_templates', 'raw-templates', 'data-template-id', ct.id],
}));
_mapEntities(ppTempl, pp => items.push({
name: pp.name, detail: '', group: 'pp_templates', icon: ICON_PP_TEMPLATE,
nav: ['streams', 'processed', 'proc-templates', 'data-pp-template-id', pp.id],
nav: ['streams', 'proc_templates', 'proc-templates', 'data-pp-template-id', pp.id],
}));
_mapEntities(patTempl, pt => items.push({
name: pt.name, detail: '', group: 'pattern_templates', icon: ICON_PATTERN_TEMPLATE,
nav: ['targets', 'key_colors', 'kc-patterns', 'data-pattern-template-id', pt.id],
nav: ['targets', 'kc-patterns', 'kc-patterns', 'data-pattern-template-id', pt.id],
}));
_mapEntities(audioSrc, a => {

View File

@@ -28,9 +28,6 @@ let _selectedIds = new Set();
let _initialized = false;
let _legendVisible = (() => { try { return localStorage.getItem('graph_legend_visible') === '1'; } catch { return false; } })();
let _minimapVisible = true;
let _searchVisible = false;
let _searchIndex = -1;
let _searchItems = [];
let _loading = false;
let _filterVisible = false;
let _filterQuery = ''; // current active filter text
@@ -209,29 +206,6 @@ export async function loadGraphEditor() {
}
}
export function openGraphSearch() {
if (!_nodeMap) return;
const panel = document.querySelector('.graph-search');
if (!panel) return;
_searchItems = [];
for (const node of _nodeMap.values()) _searchItems.push(node);
_searchIndex = -1;
_searchVisible = true;
panel.classList.add('visible');
const input = panel.querySelector('.graph-search-input');
input.value = '';
input.focus();
_renderSearchResults('');
}
export function closeGraphSearch() {
_searchVisible = false;
const panel = document.querySelector('.graph-search');
if (panel) panel.classList.remove('visible');
}
export function toggleGraphLegend() {
_legendVisible = !_legendVisible;
try { localStorage.setItem('graph_legend_visible', _legendVisible ? '1' : '0'); } catch {}
@@ -587,12 +561,6 @@ function _renderGraph(container) {
_onEdgeContextMenu(edgePath, e, container);
});
const searchInput = container.querySelector('.graph-search-input');
if (searchInput) {
searchInput.addEventListener('input', (e) => _renderSearchResults(e.target.value));
searchInput.addEventListener('keydown', _onSearchKeydown);
}
const filterInput = container.querySelector('.graph-filter-input');
if (filterInput) {
filterInput.addEventListener('input', (e) => _applyFilter(e.target.value));
@@ -714,7 +682,7 @@ function _graphHTML() {
<svg class="icon" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/><path d="M8 11h6"/></svg>
</button>
<span class="graph-toolbar-sep"></span>
<button class="btn-icon" onclick="openGraphSearch()" title="${t('graph.search')} (/)">
<button class="btn-icon" onclick="openCommandPalette()" title="${t('graph.search')} (/)">
<svg class="icon" viewBox="0 0 24 24"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></svg>
</button>
<button class="btn-icon graph-filter-btn" onclick="toggleGraphFilter()" title="${t('graph.filter')} (F)">
@@ -753,11 +721,6 @@ function _graphHTML() {
<svg></svg>
</div>
<div class="graph-search">
<input class="graph-search-input" placeholder="${t('graph.search_placeholder')}" autocomplete="off">
<div class="graph-search-results"></div>
</div>
<div class="graph-filter">
<div class="graph-filter-row">
<svg class="graph-filter-icon" viewBox="0 0 24 24" width="16" height="16"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
@@ -997,60 +960,6 @@ function _initToolbarDrag(tbEl) {
_makeDraggable(tbEl, handle, { loadFn: _loadToolbarPos, saveFn: _saveToolbarPos });
}
/* ── Search ── */
function _renderSearchResults(query) {
const results = document.querySelector('.graph-search-results');
if (!results) return;
const q = query.toLowerCase().trim();
const filtered = q
? _searchItems.filter(n => n.name.toLowerCase().includes(q) || n.kind.includes(q) || (n.subtype || '').includes(q))
: _searchItems.slice(0, 20);
_searchIndex = filtered.length > 0 ? 0 : -1;
results.innerHTML = filtered.map((n, i) => {
const color = ENTITY_COLORS[n.kind] || '#666';
return `<div class="graph-search-item${i === _searchIndex ? ' active' : ''}" data-id="${n.id}">
<span class="graph-search-item-dot" style="background:${color}"></span>
<span class="graph-search-item-name">${_escHtml(n.name)}</span>
<span class="graph-search-item-type">${n.kind.replace(/_/g, ' ')}</span>
</div>`;
}).join('');
results.querySelectorAll('.graph-search-item').forEach(item => {
item.addEventListener('click', () => {
_navigateToNode(item.getAttribute('data-id'));
closeGraphSearch();
});
});
}
function _onSearchKeydown(e) {
const results = document.querySelectorAll('.graph-search-item');
if (e.key === 'ArrowDown') { e.preventDefault(); _searchIndex = Math.min(_searchIndex + 1, results.length - 1); _updateSearchActive(results); }
else if (e.key === 'ArrowUp') { e.preventDefault(); _searchIndex = Math.max(_searchIndex - 1, 0); _updateSearchActive(results); }
else if (e.key === 'Enter') { e.preventDefault(); if (results[_searchIndex]) { _navigateToNode(results[_searchIndex].getAttribute('data-id')); closeGraphSearch(); } }
else if (e.key === 'Escape') { closeGraphSearch(); }
}
function _updateSearchActive(items) {
items.forEach((el, i) => el.classList.toggle('active', i === _searchIndex));
if (items[_searchIndex]) items[_searchIndex].scrollIntoView({ block: 'nearest' });
}
function _navigateToNode(nodeId) {
const node = _nodeMap?.get(nodeId);
if (!node || !_canvas) return;
_canvas.panTo(node.x + node.width / 2, node.y + node.height / 2, true);
const nodeGroup = document.querySelector('.graph-nodes');
if (nodeGroup) { highlightNode(nodeGroup, nodeId); setTimeout(() => highlightNode(nodeGroup, null), 3000); }
const edgeGroup = document.querySelector('.graph-edges');
if (edgeGroup && _edges) { highlightChain(edgeGroup, nodeId, _edges); setTimeout(() => clearEdgeHighlights(edgeGroup), 5000); }
}
/* ── Node callbacks ── */
@@ -1213,11 +1122,10 @@ function _onKeydown(e) {
// Skip when typing in search input (except Escape/F11)
const inInput = e.target.matches('input, textarea, select');
if (e.key === '/' && !_searchVisible && !inInput) { e.preventDefault(); openGraphSearch(); }
if (e.key === '/' && !inInput) { e.preventDefault(); window.openCommandPalette?.(); }
if ((e.key === 'f' || e.key === 'F') && !inInput && !e.ctrlKey && !e.metaKey) { e.preventDefault(); toggleGraphFilter(); }
if (e.key === 'Escape') {
if (_filterVisible) { toggleGraphFilter(); }
else if (_searchVisible) { closeGraphSearch(); }
else {
const ng = document.querySelector('.graph-nodes');
const eg = document.querySelector('.graph-edges');
@@ -1249,7 +1157,7 @@ function _onKeydown(e) {
graphAddEntity();
}
// Arrow keys / WASD → spatial navigation between nodes
if (_selectedIds.size <= 1 && !_searchVisible && !inInput) {
if (_selectedIds.size <= 1 && !inInput) {
const dir = _arrowDir(e);
if (dir) {
e.preventDefault();
@@ -1649,14 +1557,6 @@ function _calcBounds(nodeMap) {
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
}
/* ── Helpers ── */
function _escHtml(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
/* ── Port drag (connect/reconnect) ── */
const SVG_NS = 'http://www.w3.org/2000/svg';

View File

@@ -228,7 +228,7 @@ export function createKCTargetCard(target, sourceMap, patternTemplateMap, valueS
</div>
<div class="stream-card-props">
<span class="stream-card-prop${source ? ' stream-card-link' : ''}" title="${t('kc.source')}"${source ? ` onclick="event.stopPropagation(); navigateToCard('streams','${source.stream_type === 'static_image' ? 'static_image' : source.stream_type === 'processed' ? 'processed' : 'raw'}','${source.stream_type === 'static_image' ? 'static-streams' : source.stream_type === 'processed' ? 'proc-streams' : 'raw-streams'}','data-stream-id','${target.picture_source_id}')"` : ''}>${ICON_LINK_SOURCE} ${escapeHtml(sourceName)}</span>
<span class="stream-card-prop${patTmpl ? ' stream-card-link' : ''}" title="${t('kc.pattern_template')}"${patTmpl ? ` onclick="event.stopPropagation(); navigateToCard('targets','key_colors','kc-patterns','data-pattern-template-id','${kcSettings.pattern_template_id}')"` : ''}>${ICON_PATTERN_TEMPLATE} ${escapeHtml(patternName)}</span>
<span class="stream-card-prop${patTmpl ? ' stream-card-link' : ''}" title="${t('kc.pattern_template')}"${patTmpl ? ` onclick="event.stopPropagation(); navigateToCard('targets','kc-patterns','kc-patterns','data-pattern-template-id','${kcSettings.pattern_template_id}')"` : ''}>${ICON_PATTERN_TEMPLATE} ${escapeHtml(patternName)}</span>
<span class="stream-card-prop">▭ ${rectCount} rect${rectCount !== 1 ? 's' : ''}</span>
<span class="stream-card-prop" title="${t('kc.fps')}">${ICON_FPS} ${kcSettings.fps ?? 10}</span>
${bvs ? `<span class="stream-card-prop stream-card-prop-full stream-card-link" title="${t('targets.brightness_vs')}" onclick="event.stopPropagation(); navigateToCard('streams','value','value-sources','data-id','${bvsId}')">${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}</span>` : ''}

View File

@@ -1246,9 +1246,11 @@ export function switchStreamTab(tabKey) {
}
const _streamSectionMap = {
raw: [csRawStreams, csRawTemplates],
raw: [csRawStreams],
raw_templates: [csRawTemplates],
static_image: [csStaticStreams],
processed: [csProcStreams, csProcTemplates],
processed: [csProcStreams],
proc_templates: [csProcTemplates],
css_processing: [csCSPTemplates],
color_strip: [csColorStrips],
audio: [csAudioMulti, csAudioMono, csAudioTemplates],
@@ -1283,7 +1285,7 @@ function renderPictureSourcesList(streams) {
detailsHtml = `<div class="stream-card-props">
<span class="stream-card-prop" title="${t('streams.display')}">${ICON_MONITOR} ${stream.display_index ?? 0}</span>
<span class="stream-card-prop" title="${t('streams.target_fps')}">${ICON_FPS} ${stream.target_fps ?? 30}</span>
${capTmplName ? `<span class="stream-card-prop stream-card-link" title="${t('streams.capture_template')}" onclick="event.stopPropagation(); navigateToCard('streams','raw','raw-templates','data-template-id','${stream.capture_template_id}')">${ICON_TEMPLATE} ${capTmplName}</span>` : ''}
${capTmplName ? `<span class="stream-card-prop stream-card-link" title="${t('streams.capture_template')}" onclick="event.stopPropagation(); navigateToCard('streams','raw_templates','raw-templates','data-template-id','${stream.capture_template_id}')">${ICON_TEMPLATE} ${capTmplName}</span>` : ''}
</div>`;
} else if (stream.stream_type === 'processed') {
const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id);
@@ -1297,7 +1299,7 @@ function renderPictureSourcesList(streams) {
}
detailsHtml = `<div class="stream-card-props">
<span class="stream-card-prop stream-card-link" title="${t('streams.source')}" onclick="event.stopPropagation(); navigateToCard('streams','${sourceSubTab}','${sourceSection}','data-stream-id','${stream.source_stream_id}')">${ICON_LINK_SOURCE} ${sourceName}</span>
${ppTmplName ? `<span class="stream-card-prop stream-card-link" title="${t('streams.pp_template')}" onclick="event.stopPropagation(); navigateToCard('streams','processed','proc-templates','data-pp-template-id','${stream.postprocessing_template_id}')">${ICON_TEMPLATE} ${ppTmplName}</span>` : ''}
${ppTmplName ? `<span class="stream-card-prop stream-card-link" title="${t('streams.pp_template')}" onclick="event.stopPropagation(); navigateToCard('streams','proc_templates','proc-templates','data-pp-template-id','${stream.postprocessing_template_id}')">${ICON_TEMPLATE} ${ppTmplName}</span>` : ''}
</div>`;
} else if (stream.stream_type === 'static_image') {
const src = stream.image_source || '';
@@ -1440,8 +1442,10 @@ function renderPictureSourcesList(streams) {
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 },
{ key: 'static_image', icon: getPictureSourceIcon('static_image'), titleKey: 'streams.group.static_image', count: staticImageStreams.length },
{ key: 'processed', icon: getPictureSourceIcon('processed'), titleKey: 'streams.group.processed', count: processedStreams.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: 'color_strip', icon: getColorStripIcon('static'), titleKey: 'streams.group.color_strip', count: colorStrips.length },
{ key: 'audio', icon: getAudioSourceIcon('multichannel'), titleKey: 'streams.group.audio', count: _cachedAudioSources.length },
@@ -1452,11 +1456,21 @@ function renderPictureSourcesList(streams) {
// Build tree navigation structure
const treeGroups = [
{
key: 'picture_group', icon: getPictureSourceIcon('raw'), titleKey: 'tree.group.picture',
key: 'capture_group', icon: getPictureSourceIcon('raw'), titleKey: 'tree.group.capture',
children: [
{ key: 'raw', titleKey: 'streams.group.raw', icon: getPictureSourceIcon('raw'), count: rawStreams.length },
{ key: 'static_image', titleKey: 'streams.group.static_image', icon: getPictureSourceIcon('static_image'), count: staticImageStreams.length },
{ key: 'raw_templates', titleKey: 'streams.group.raw_templates', icon: ICON_CAPTURE_TEMPLATE, count: _cachedCaptureTemplates.length },
]
},
{
key: 'static_image', icon: getPictureSourceIcon('static_image'), titleKey: 'streams.group.static_image',
count: staticImageStreams.length,
},
{
key: 'processing_group', icon: getPictureSourceIcon('processed'), titleKey: 'tree.group.processing',
children: [
{ key: 'processed', titleKey: 'streams.group.processed', icon: getPictureSourceIcon('processed'), count: processedStreams.length },
{ key: 'proc_templates', titleKey: 'streams.group.proc_templates', icon: ICON_PP_TEMPLATE, count: _cachedPPTemplates.length },
]
},
{
@@ -1584,8 +1598,10 @@ function renderPictureSourcesList(streams) {
// Incremental update: reconcile cards in-place
_streamsTree.updateCounts({
raw: rawStreams.length,
raw_templates: _cachedCaptureTemplates.length,
static_image: staticImageStreams.length,
processed: processedStreams.length,
proc_templates: _cachedPPTemplates.length,
css_processing: csptTemplates.length,
color_strip: colorStrips.length,
audio: _cachedAudioSources.length + _cachedAudioTemplates.length,
@@ -1608,8 +1624,10 @@ function renderPictureSourcesList(streams) {
// First render: build full HTML
const panels = tabs.map(tab => {
let panelContent = '';
if (tab.key === 'raw') panelContent = csRawStreams.render(rawStreamItems) + csRawTemplates.render(rawTemplateItems);
else if (tab.key === 'processed') panelContent = csProcStreams.render(procStreamItems) + csProcTemplates.render(procTemplateItems);
if (tab.key === 'raw') panelContent = csRawStreams.render(rawStreamItems);
else if (tab.key === 'raw_templates') panelContent = csRawTemplates.render(rawTemplateItems);
else if (tab.key === 'processed') panelContent = csProcStreams.render(procStreamItems);
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 === 'audio') panelContent = csAudioMulti.render(multiItems) + csAudioMono.render(monoItems) + csAudioTemplates.render(audioTemplateItems);
@@ -1626,9 +1644,9 @@ function renderPictureSourcesList(streams) {
_streamsTree.setExtraHtml(`<button class="btn-expand-collapse" onclick="expandAllStreamSections()" data-i18n-title="section.expand_all" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllStreamSections()" data-i18n-title="section.collapse_all" title="${t('section.collapse_all')}">⊟</button><button class="tutorial-trigger-btn" onclick="startSourcesTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);
_streamsTree.update(treeGroups, activeTab);
_streamsTree.observeSections('streams-list', {
'raw-streams': 'raw', 'raw-templates': 'raw',
'raw-streams': 'raw', 'raw-templates': 'raw_templates',
'static-streams': 'static_image',
'proc-streams': 'processed', 'proc-templates': 'processed',
'proc-streams': 'processed', 'proc-templates': 'proc_templates',
'css-proc-templates': 'css_processing',
'color-strips': 'color_strip',
'audio-multi': 'audio', 'audio-mono': 'audio', 'audio-templates': 'audio',

View File

@@ -535,18 +535,10 @@ export async function saveTargetEditor() {
let _treeTriggered = false;
const _targetsTree = new TreeNav('targets-tree-nav', {
onSelect: (key, leaf) => {
const subTab = leaf?.subTab || key;
onSelect: (key) => {
_treeTriggered = true;
switchTargetSubTab(subTab);
switchTargetSubTab(key);
_treeTriggered = false;
// Scroll to specific section
if (leaf?.sectionKey) {
requestAnimationFrame(() => {
const section = document.querySelector(`[data-card-section="${leaf.sectionKey}"]`);
if (section) section.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
}
}
});
@@ -558,25 +550,25 @@ export function switchTargetSubTab(tabKey) {
updateSubTabHash('targets', tabKey);
// Update tree active state (unless the tree triggered this switch)
if (!_treeTriggered) {
const leafKey = _targetsTree.getLeafForSubTab(tabKey);
if (leafKey) _targetsTree.setActive(leafKey);
_targetsTree.setActive(tabKey);
}
}
const _targetSectionMap = {
'led-devices': [csDevices],
'led-targets': [csLedTargets],
'kc-targets': [csKCTargets],
'kc-patterns': [csPatternTemplates],
};
export function expandAllTargetSections() {
const activeSubTab = localStorage.getItem('activeTargetSubTab') || 'led';
const sections = activeSubTab === 'key_colors'
? [csKCTargets, csPatternTemplates]
: [csDevices, csLedTargets];
CardSection.expandAll(sections);
const activeSubTab = localStorage.getItem('activeTargetSubTab') || 'led-devices';
CardSection.expandAll(_targetSectionMap[activeSubTab] || []);
}
export function collapseAllTargetSections() {
const activeSubTab = localStorage.getItem('activeTargetSubTab') || 'led';
const sections = activeSubTab === 'key_colors'
? [csKCTargets, csPatternTemplates]
: [csDevices, csLedTargets];
CardSection.collapseAll(sections);
const activeSubTab = localStorage.getItem('activeTargetSubTab') || 'led-devices';
CardSection.collapseAll(_targetSectionMap[activeSubTab] || []);
}
let _loadTargetsLock = false;
@@ -666,20 +658,22 @@ export async function loadTargetsTab() {
{
key: 'led_group', icon: getTargetTypeIcon('led'), titleKey: 'targets.subtab.led',
children: [
{ key: 'led-devices', titleKey: 'targets.section.devices', icon: getDeviceTypeIcon('wled'), count: ledDevices.length, subTab: 'led', sectionKey: 'led-devices' },
{ key: 'led-targets', titleKey: 'targets.section.targets', icon: getTargetTypeIcon('led'), count: ledTargets.length, subTab: 'led', sectionKey: 'led-targets' },
{ key: 'led-devices', titleKey: 'targets.section.devices', icon: getDeviceTypeIcon('wled'), count: ledDevices.length },
{ key: 'led-targets', titleKey: 'targets.section.targets', icon: getTargetTypeIcon('led'), count: ledTargets.length },
]
},
{
key: 'kc_group', icon: getTargetTypeIcon('key_colors'), titleKey: 'targets.subtab.key_colors',
children: [
{ key: 'kc-targets', titleKey: 'targets.section.key_colors', icon: getTargetTypeIcon('key_colors'), count: kcTargets.length, subTab: 'key_colors', sectionKey: 'kc-targets' },
{ key: 'kc-patterns', titleKey: 'targets.section.pattern_templates', icon: ICON_TEMPLATE, count: patternTemplates.length, subTab: 'key_colors', sectionKey: 'kc-patterns' },
{ key: 'kc-targets', titleKey: 'targets.section.key_colors', icon: getTargetTypeIcon('key_colors'), count: kcTargets.length },
{ key: 'kc-patterns', titleKey: 'targets.section.pattern_templates', icon: ICON_TEMPLATE, count: patternTemplates.length },
]
}
];
// Determine which tree leaf is active
const activeLeaf = activeSubTab === 'key_colors' ? 'kc-targets' : 'led-devices';
// Determine which tree leaf is active — migrate old values
const validLeaves = ['led-devices', 'led-targets', 'kc-targets', 'kc-patterns'];
const activeLeaf = validLeaves.includes(activeSubTab) ? activeSubTab
: activeSubTab === 'key_colors' ? 'kc-targets' : 'led-devices';
// Use window.createPatternTemplateCard to avoid circular import
const createPatternTemplateCard = window.createPatternTemplateCard || (() => '');
@@ -718,17 +712,13 @@ export async function loadTargetsTab() {
}
} else {
// ── First render: build full HTML ──
const ledPanel = `
<div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'led' ? ' active' : ''}" id="target-sub-tab-led">
${csDevices.render(deviceItems)}
${csLedTargets.render(ledTargetItems)}
</div>`;
const kcPanel = `
<div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'key_colors' ? ' active' : ''}" id="target-sub-tab-key_colors">
${csKCTargets.render(kcTargetItems)}
${csPatternTemplates.render(patternItems)}
</div>`;
container.innerHTML = ledPanel + kcPanel;
const panels = [
{ key: 'led-devices', html: csDevices.render(deviceItems) },
{ key: 'led-targets', html: csLedTargets.render(ledTargetItems) },
{ key: 'kc-targets', html: csKCTargets.render(kcTargetItems) },
{ key: 'kc-patterns', html: csPatternTemplates.render(patternItems) },
].map(p => `<div class="target-sub-tab-panel stream-tab-panel${p.key === activeLeaf ? ' active' : ''}" id="target-sub-tab-${p.key}">${p.html}</div>`).join('');
container.innerHTML = panels;
CardSection.bindAll([csDevices, csLedTargets, csKCTargets, csPatternTemplates]);
// Render tree sidebar with expand/collapse buttons
@@ -1001,7 +991,7 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap, valueSo
</div>
</div>
<div class="stream-card-props">
<span class="stream-card-prop stream-card-link" title="${t('targets.device')}" onclick="event.stopPropagation(); navigateToCard('targets','led','led-devices','data-device-id','${target.device_id}')">${ICON_LED} ${escapeHtml(deviceName)}</span>
<span class="stream-card-prop stream-card-link" title="${t('targets.device')}" onclick="event.stopPropagation(); navigateToCard('targets','led-devices','led-devices','data-device-id','${target.device_id}')">${ICON_LED} ${escapeHtml(deviceName)}</span>
<span class="stream-card-prop" title="${t('targets.fps')}">${ICON_FPS} ${target.fps || 30}</span>
<span class="stream-card-prop" title="${t('targets.protocol')}">${_protocolBadge(device, target)}</span>
<span class="stream-card-prop${cssId ? ' stream-card-link' : ''}" title="${t('targets.color_strip_source')}"${cssId ? ` onclick="event.stopPropagation(); navigateToCard('streams','color_strip','color-strips','data-css-id','${cssId}')"` : ''}>${ICON_FILM} ${cssSummary}</span>

View File

@@ -417,8 +417,10 @@
"section.collapse_all": "Collapse all sections",
"streams.title": "Sources",
"streams.description": "Sources define the capture pipeline. A raw source captures from a display using a capture template. A processed source applies postprocessing to another source. Assign sources to devices.",
"streams.group.raw": "Screen Capture",
"streams.group.processed": "Processed",
"streams.group.raw": "Sources",
"streams.group.raw_templates": "Engine Templates",
"streams.group.processed": "Sources",
"streams.group.proc_templates": "Filter Templates",
"streams.group.css_processing": "Processing Templates",
"streams.group.color_strip": "Color Strips",
"streams.group.audio": "Audio",
@@ -1213,6 +1215,8 @@
"audio_template.error.delete": "Failed to delete audio template",
"streams.group.value": "Value Sources",
"streams.group.sync": "Sync Clocks",
"tree.group.capture": "Screen Capture",
"tree.group.processing": "Processed",
"tree.group.picture": "Picture",
"tree.group.strip": "Color Strip",
"tree.group.utility": "Utility",

View File

@@ -366,8 +366,10 @@
"section.collapse_all": "Свернуть все секции",
"streams.title": "Источники",
"streams.description": "Источники определяют конвейер захвата. Сырой источник захватывает экран с помощью шаблона захвата. Обработанный источник применяет постобработку к другому источнику. Назначайте источники устройствам.",
"streams.group.raw": "Захват Экрана",
"streams.group.processed": "Обработанные",
"streams.group.raw": "Источники",
"streams.group.raw_templates": "Шаблоны движка",
"streams.group.processed": "Источники",
"streams.group.proc_templates": "Шаблоны фильтров",
"streams.group.css_processing": "Шаблоны Обработки",
"streams.group.color_strip": "Цветовые Полосы",
"streams.group.audio": "Аудио",
@@ -1162,6 +1164,8 @@
"audio_template.error.delete": "Не удалось удалить аудиошаблон",
"streams.group.value": "Источники значений",
"streams.group.sync": "Часы синхронизации",
"tree.group.capture": "Захват Экрана",
"tree.group.processing": "Обработанные",
"tree.group.picture": "Изображения",
"tree.group.strip": "Цветовые Полосы",
"tree.group.utility": "Утилиты",

View File

@@ -366,8 +366,10 @@
"section.collapse_all": "全部折叠",
"streams.title": "源",
"streams.description": "源定义采集管线。原始源使用采集模板从显示器采集。处理源对另一个源应用后处理。将源分配给设备。",
"streams.group.raw": "屏幕采集",
"streams.group.processed": "已处理",
"streams.group.raw": "",
"streams.group.raw_templates": "引擎模板",
"streams.group.processed": "源",
"streams.group.proc_templates": "滤镜模板",
"streams.group.css_processing": "处理模板",
"streams.group.color_strip": "色带源",
"streams.group.audio": "音频",
@@ -1162,6 +1164,8 @@
"audio_template.error.delete": "删除音频模板失败",
"streams.group.value": "值源",
"streams.group.sync": "同步时钟",
"tree.group.capture": "屏幕采集",
"tree.group.processing": "已处理",
"tree.group.picture": "图片",
"tree.group.strip": "色带",
"tree.group.utility": "工具",