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:
@@ -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() {
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user