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:
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}>▶</span>
|
||||
${this.collapsible ? `<span class="cs-chevron"${chevronStyle}>▶</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}"]`);
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>` : ''}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Утилиты",
|
||||
|
||||
@@ -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": "工具",
|
||||
|
||||
Reference in New Issue
Block a user