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

@@ -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';