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

@@ -162,6 +162,7 @@ import {
loadGraphEditor, openGraphSearch, closeGraphSearch,
toggleGraphLegend, toggleGraphMinimap,
graphFitAll, graphZoomIn, graphZoomOut, graphRelayout,
graphToggleFullscreen, graphAddEntity,
} from './features/graph-editor.js';
// Layer 6: tabs, navigation, command palette, settings
@@ -472,6 +473,8 @@ Object.assign(window, {
graphZoomIn,
graphZoomOut,
graphRelayout,
graphToggleFullscreen,
graphAddEntity,
// tabs / navigation / command palette
switchTab,

View File

@@ -51,16 +51,11 @@ function _createArrowMarker(type) {
}
function _renderEdge(edge) {
const { from, to, type, points, fromNode, toNode, field, editable } = edge;
const { from, to, type, fromNode, toNode, field, editable } = edge;
const cssClass = `graph-edge graph-edge-${type}${editable === false ? ' graph-edge-nested' : ''}`;
let d;
if (points) {
// Adjust ELK start/end points to match port positions
const adjusted = _adjustEndpoints(points, fromNode, toNode, edge.fromPortY, edge.toPortY);
d = _pointsToPath(adjusted);
} else {
d = _defaultBezier(fromNode, toNode, edge.fromPortY, edge.toPortY);
}
// Always use port-aware bezier — ELK routes without port knowledge so
// its bend points don't align with actual port positions.
const d = _defaultBezier(fromNode, toNode, edge.fromPortY, edge.toPortY);
const path = svgEl('path', {
class: cssClass,

View File

@@ -74,9 +74,30 @@ export function renderNodes(group, nodeMap, callbacks = {}) {
/**
* Render a single node.
*/
// Per-node color overrides (persisted in localStorage)
const _NC_KEY = 'graph_node_colors';
let _nodeColorOverrides = null;
function _loadNodeColors() {
if (_nodeColorOverrides) return _nodeColorOverrides;
try { _nodeColorOverrides = JSON.parse(localStorage.getItem(_NC_KEY)) || {}; } catch { _nodeColorOverrides = {}; }
return _nodeColorOverrides;
}
function _saveNodeColor(nodeId, color) {
const map = _loadNodeColors();
map[nodeId] = color;
localStorage.setItem(_NC_KEY, JSON.stringify(map));
}
export function getNodeColor(nodeId, kind) {
const map = _loadNodeColors();
return map[nodeId] || ENTITY_COLORS[kind] || '#666';
}
function renderNode(node, callbacks) {
const { id, kind, name, subtype, x, y, width, height, running } = node;
const color = ENTITY_COLORS[kind] || '#666';
const color = getNodeColor(id, kind);
const g = svgEl('g', {
class: `graph-node${running ? ' running' : ''}`,
@@ -111,6 +132,48 @@ function renderNode(node, callbacks) {
});
g.appendChild(barCover);
// Clickable color bar overlay (wider hit area)
const barHit = svgEl('rect', {
class: 'graph-node-color-bar-hit',
x: 0, y: 0,
width: 12, height,
fill: 'transparent',
cursor: 'pointer',
});
barHit.style.cursor = 'pointer';
barHit.addEventListener('click', (e) => {
e.stopPropagation();
// Create temporary color input positioned near the click
const input = document.createElement('input');
input.type = 'color';
input.value = color;
input.style.position = 'fixed';
input.style.left = e.clientX + 'px';
input.style.top = e.clientY + 'px';
input.style.width = '0';
input.style.height = '0';
input.style.padding = '0';
input.style.border = 'none';
input.style.opacity = '0';
input.style.pointerEvents = 'none';
document.body.appendChild(input);
input.addEventListener('input', () => {
const c = input.value;
bar.setAttribute('fill', c);
barCover.setAttribute('fill', c);
_saveNodeColor(id, c);
});
input.addEventListener('change', () => {
input.remove();
});
// Fallback remove if user cancels
input.addEventListener('blur', () => {
setTimeout(() => input.remove(), 200);
});
input.click();
});
g.appendChild(barHit);
// Input ports (left side)
if (node.inputPorts?.types) {
for (const t of node.inputPorts.types) {
@@ -242,7 +305,7 @@ function _createOverlay(node, nodeWidth, callbacks) {
// Test button for applicable kinds
if (TEST_KINDS.has(node.kind) || (node.kind === 'output_target' && node.subtype === 'key_colors')) {
btns.push({ icon: '\u25B7', action: 'test', cls: '' }); // test
btns.push({ icon: '\uD83D\uDC41', action: 'test', cls: '' }); // 👁 test/preview
}
// Notification test for notification color strip sources

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() {

View File

@@ -27,6 +27,7 @@ const gettingStartedSteps = [
{ selector: '#tab-btn-automations', textKey: 'tour.automations', position: 'bottom' },
{ selector: '#tab-btn-targets', textKey: 'tour.targets', position: 'bottom' },
{ selector: '#tab-btn-streams', textKey: 'tour.sources', position: 'bottom' },
{ selector: '#tab-btn-graph', textKey: 'tour.graph', position: 'bottom' },
{ selector: 'a.header-link[href="/docs"]', textKey: 'tour.api', position: 'bottom' },
{ selector: '[onclick*="openCommandPalette"]', textKey: 'tour.search', position: 'bottom' },
{ selector: '[onclick*="toggleTheme"]', textKey: 'tour.theme', position: 'bottom' },