Enhance graph editor: fullscreen bg, add-entity focus, color picker fix, UI polish

- Move bg-anim canvas into graph container during fullscreen so dynamic background is visible
- Watch for new entity creation from graph add menu and auto-navigate to it after reload
- Position color picker at click coordinates instead of 0,0
- Replace test/preview play triangle with eye icon to distinguish from start/stop
- Always use port-aware bezier curves for edges instead of ELK routing
- Add fullscreen and add-entity buttons to toolbar with keyboard shortcuts (F11, +)
- Add confirmation dialog for relayout when manual positions exist
- Remove node body stroke, keep only color bar; add per-node color picker
- Clamp toolbar position on load to prevent off-screen drift
- Add graph tab to getting-started tutorial
- Add WASD/arrow spatial navigation, ESC reset, keyboard shortcuts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 17:48:55 +03:00
parent b370bb7d75
commit 5c7c2ad1b2
9 changed files with 446 additions and 22 deletions

View File

@@ -4,7 +4,7 @@
import { GraphCanvas } from '../core/graph-canvas.js';
import { computeLayout, computePorts, ENTITY_COLORS, ENTITY_LABELS } from '../core/graph-layout.js';
import { renderNodes, patchNodeRunning, highlightNode, markOrphans, updateSelection } from '../core/graph-nodes.js';
import { renderNodes, patchNodeRunning, highlightNode, markOrphans, updateSelection, getNodeColor } from '../core/graph-nodes.js';
import { renderEdges, highlightChain, clearEdgeHighlights, updateEdgesForNode, renderFlowDots, updateFlowDotsForNode } from '../core/graph-edges.js';
import {
devicesCache, captureTemplatesCache, ppTemplatesCache,
@@ -14,7 +14,7 @@ import {
automationsCacheObj,
} from '../core/state.js';
import { fetchWithAuth } from '../core/api.js';
import { showToast } from '../core/ui.js';
import { showToast, showConfirm } from '../core/ui.js';
import { t } from '../core/i18n.js';
import { findConnection, getCompatibleInputs, getConnectionByField, updateConnection, detachConnection, isEditableEdge } from '../core/graph-connections.js';
@@ -149,11 +149,160 @@ export function graphFitAll() {
export function graphZoomIn() { if (_canvas) _canvas.zoomIn(); }
export function graphZoomOut() { if (_canvas) _canvas.zoomOut(); }
export function graphToggleFullscreen() {
const container = document.querySelector('#graph-editor-content .graph-container');
if (!container) return;
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
// Move bg-anim canvas into container so it's visible in fullscreen
const bgCanvas = document.getElementById('bg-anim-canvas');
if (bgCanvas && !container.contains(bgCanvas)) {
container.insertBefore(bgCanvas, container.firstChild);
}
container.requestFullscreen().catch(() => {});
}
}
// Restore bg-anim canvas to body when exiting fullscreen
document.addEventListener('fullscreenchange', () => {
if (!document.fullscreenElement) {
const bgCanvas = document.getElementById('bg-anim-canvas');
if (bgCanvas && bgCanvas.parentElement !== document.body) {
document.body.insertBefore(bgCanvas, document.body.firstChild);
}
}
});
export async function graphRelayout() {
if (_manualPositions.size > 0) {
const ok = await showConfirm(t('graph.relayout_confirm'));
if (!ok) return;
}
_manualPositions.clear();
await loadGraphEditor();
}
// Entity kind → window function to open add/create modal
const ADD_ENTITY_MAP = [
{ kind: 'device', fn: () => window.showAddDevice?.() },
{ kind: 'capture_template', fn: () => window.showAddTemplateModal?.() },
{ kind: 'pp_template', fn: () => window.showAddPPTemplateModal?.() },
{ kind: 'audio_template', fn: () => window.showAddAudioTemplateModal?.() },
{ kind: 'picture_source', fn: () => window.showAddStreamModal?.() },
{ kind: 'audio_source', fn: () => window.showAudioSourceModal?.() },
{ kind: 'value_source', fn: () => window.showValueSourceModal?.() },
{ kind: 'color_strip_source', fn: () => window.showCSSEditor?.() },
{ kind: 'output_target', fn: () => window.showTargetEditor?.() },
{ kind: 'automation', fn: () => window.openAutomationEditor?.() },
];
// All caches to watch for new entity creation
const ALL_CACHES = [
devicesCache, captureTemplatesCache, ppTemplatesCache,
streamsCache, audioSourcesCache, audioTemplatesCache,
valueSourcesCache, colorStripSourcesCache, syncClocksCache,
outputTargetsCache, patternTemplatesCache, scenePresetsCache,
automationsCacheObj,
];
let _addEntityMenu = null;
export function graphAddEntity() {
if (_addEntityMenu) { _dismissAddEntityMenu(); return; }
const container = document.querySelector('#graph-editor-content .graph-container');
if (!container) return;
const toolbar = container.querySelector('.graph-toolbar');
const menu = document.createElement('div');
menu.className = 'graph-add-entity-menu';
// Position below toolbar
if (toolbar) {
menu.style.left = toolbar.offsetLeft + 'px';
menu.style.top = (toolbar.offsetTop + toolbar.offsetHeight + 6) + 'px';
}
for (const item of ADD_ENTITY_MAP) {
const btn = document.createElement('button');
btn.className = 'graph-add-entity-item';
const color = ENTITY_COLORS[item.kind] || '#666';
const label = ENTITY_LABELS[item.kind] || item.kind.replace(/_/g, ' ');
btn.innerHTML = `<span class="graph-add-entity-dot" style="background:${color}"></span><span>${label}</span>`;
btn.addEventListener('click', () => {
_dismissAddEntityMenu();
_watchForNewEntity();
item.fn();
});
menu.appendChild(btn);
}
container.appendChild(menu);
_addEntityMenu = menu;
// Close on click outside
setTimeout(() => {
document.addEventListener('click', _onAddEntityClickAway, true);
}, 0);
}
function _onAddEntityClickAway(e) {
if (_addEntityMenu && !_addEntityMenu.contains(e.target)) {
_dismissAddEntityMenu();
}
}
function _dismissAddEntityMenu() {
if (_addEntityMenu) {
_addEntityMenu.remove();
_addEntityMenu = null;
}
document.removeEventListener('click', _onAddEntityClickAway, true);
}
// Watch for new entity creation after add-entity menu action
let _entityWatchCleanup = null;
function _watchForNewEntity() {
// Cleanup any previous watcher
if (_entityWatchCleanup) _entityWatchCleanup();
// Snapshot all current IDs
const knownIds = new Set();
for (const cache of ALL_CACHES) {
for (const item of (cache.data || [])) {
if (item.id) knownIds.add(item.id);
}
}
const handler = (data) => {
if (!Array.isArray(data)) return;
for (const item of data) {
if (item.id && !knownIds.has(item.id)) {
// Found a new entity — reload graph and navigate to it
const newId = item.id;
cleanup();
loadGraphEditor().then(() => _navigateToNode(newId));
return;
}
}
};
for (const cache of ALL_CACHES) cache.subscribe(handler);
// Auto-cleanup after 2 minutes (user might cancel the modal)
const timeout = setTimeout(cleanup, 120_000);
function cleanup() {
clearTimeout(timeout);
for (const cache of ALL_CACHES) cache.unsubscribe(handler);
_entityWatchCleanup = null;
}
_entityWatchCleanup = cleanup;
}
/* ── Data fetching ── */
async function _fetchAllEntities() {
@@ -340,6 +489,13 @@ function _graphHTML() {
<button class="btn-icon" onclick="graphRelayout()" title="${t('graph.relayout')}">
<svg class="icon" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>
</button>
<button class="btn-icon" onclick="graphToggleFullscreen()" title="${t('graph.fullscreen')} (F11)">
<svg class="icon" viewBox="0 0 24 24"><path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/></svg>
</button>
<span class="graph-toolbar-sep"></span>
<button class="btn-icon" onclick="graphAddEntity()" title="${t('graph.add_entity')} (+)">
<svg class="icon" viewBox="0 0 24 24"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
</button>
</div>
<div class="graph-legend">
@@ -406,7 +562,7 @@ function _initMinimap(mmEl) {
let html = '';
for (const node of _nodeMap.values()) {
const color = ENTITY_COLORS[node.kind] || '#666';
const color = getNodeColor(node.id, node.kind);
html += `<rect class="graph-minimap-node" data-id="${node.id}" x="${node.x}" y="${node.y}" width="${node.width}" height="${node.height}" fill="${color}" opacity="0.7"/>`;
}
// Add viewport rect (updated live via _updateMinimapViewport)
@@ -547,8 +703,28 @@ function _mmRect(mmEl) {
/* ── Toolbar drag ── */
function _clampToolbar(tbEl) {
if (!tbEl) return;
const container = tbEl.closest('.graph-container');
if (!container) return;
const cr = container.getBoundingClientRect();
const tw = tbEl.offsetWidth, th = tbEl.offsetHeight;
if (!tw || !th) return; // not rendered yet
let l = tbEl.offsetLeft, top = tbEl.offsetTop;
const clamped = {
left: Math.max(0, Math.min(cr.width - tw, l)),
top: Math.max(0, Math.min(cr.height - th, top)),
};
if (clamped.left !== l || clamped.top !== top) {
tbEl.style.left = clamped.left + 'px';
tbEl.style.top = clamped.top + 'px';
_saveToolbarPos(clamped);
}
}
function _initToolbarDrag(tbEl) {
if (!tbEl) return;
_clampToolbar(tbEl); // ensure saved position is still valid
const container = tbEl.closest('.graph-container');
const handle = tbEl.querySelector('.graph-toolbar-drag');
if (!handle) return;
@@ -781,7 +957,10 @@ function _onNotificationTest(node) {
/* ── Keyboard ── */
function _onKeydown(e) {
if (e.key === '/' && !_searchVisible) { e.preventDefault(); openGraphSearch(); }
// 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 === 'Escape') {
if (_searchVisible) { closeGraphSearch(); }
else {
@@ -791,7 +970,7 @@ function _onKeydown(e) {
}
}
// Delete key → detach selected edge or delete single selected node
if (e.key === 'Delete') {
if (e.key === 'Delete' && !inInput) {
if (_selectedEdge) {
_detachSelectedEdge();
} else if (_selectedIds.size === 1) {
@@ -801,10 +980,106 @@ function _onKeydown(e) {
}
}
// Ctrl+A → select all
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
if ((e.ctrlKey || e.metaKey) && e.key === 'a' && !inInput) {
e.preventDefault();
_selectAll();
}
// F11 → fullscreen
if (e.key === 'F11') {
e.preventDefault();
graphToggleFullscreen();
}
// + → add entity
if ((e.key === '+' || (e.key === '=' && !e.ctrlKey && !e.metaKey)) && !inInput) {
graphAddEntity();
}
// Arrow keys / WASD → spatial navigation between nodes
if (_selectedIds.size <= 1 && !_searchVisible && !inInput) {
const dir = _arrowDir(e);
if (dir) {
e.preventDefault();
_navigateDirection(dir);
}
}
}
function _arrowDir(e) {
if (e.ctrlKey || e.metaKey) return null;
switch (e.key) {
case 'ArrowLeft': case 'a': case 'A': return 'left';
case 'ArrowRight': case 'd': case 'D': return 'right';
case 'ArrowUp': case 'w': case 'W': return 'up';
case 'ArrowDown': case 's': case 'S': return 'down';
default: return null;
}
}
function _navigateDirection(dir) {
if (!_nodeMap || _nodeMap.size === 0) return;
// Get current anchor node
let anchor = null;
if (_selectedIds.size === 1) {
anchor = _nodeMap.get([..._selectedIds][0]);
}
if (!anchor) {
// Select first visible node (topmost-leftmost)
let best = null;
for (const n of _nodeMap.values()) {
if (!best || n.y < best.y || (n.y === best.y && n.x < best.x)) best = n;
}
if (best) {
_selectedIds.clear();
_selectedIds.add(best.id);
const ng = document.querySelector('.graph-nodes');
const eg = document.querySelector('.graph-edges');
if (ng) updateSelection(ng, _selectedIds);
if (eg) clearEdgeHighlights(eg);
if (_canvas) _canvas.panTo(best.x + best.width / 2, best.y + best.height / 2, true);
}
return;
}
const cx = anchor.x + anchor.width / 2;
const cy = anchor.y + anchor.height / 2;
let bestNode = null;
let bestDist = Infinity;
for (const n of _nodeMap.values()) {
if (n.id === anchor.id) continue;
const nx = n.x + n.width / 2;
const ny = n.y + n.height / 2;
const dx = nx - cx;
const dy = ny - cy;
// Check direction constraint
let valid = false;
if (dir === 'right' && dx > 10) valid = true;
if (dir === 'left' && dx < -10) valid = true;
if (dir === 'down' && dy > 10) valid = true;
if (dir === 'up' && dy < -10) valid = true;
if (!valid) continue;
// Distance with directional bias (favor the primary axis)
const primaryDist = dir === 'left' || dir === 'right' ? Math.abs(dx) : Math.abs(dy);
const crossDist = dir === 'left' || dir === 'right' ? Math.abs(dy) : Math.abs(dx);
const dist = primaryDist + crossDist * 2;
if (dist < bestDist) {
bestDist = dist;
bestNode = n;
}
}
if (bestNode) {
_selectedIds.clear();
_selectedIds.add(bestNode.id);
const ng = document.querySelector('.graph-nodes');
const eg = document.querySelector('.graph-edges');
if (ng) updateSelection(ng, _selectedIds);
if (eg && _edges) highlightChain(eg, bestNode.id, _edges);
if (_canvas) _canvas.panTo(bestNode.x + bestNode.width / 2, bestNode.y + bestNode.height / 2, true);
}
}
function _selectAll() {