diff --git a/server/src/wled_controller/static/css/graph-editor.css b/server/src/wled_controller/static/css/graph-editor.css index 3dd8f9c..a3f6ddf 100644 --- a/server/src/wled_controller/static/css/graph-editor.css +++ b/server/src/wled_controller/static/css/graph-editor.css @@ -256,8 +256,7 @@ .graph-node-body { fill: var(--card-bg); - stroke: var(--border-color); - stroke-width: 1; + stroke: none; rx: 8; ry: 8; transition: stroke 0.15s; @@ -265,6 +264,7 @@ .graph-node:hover .graph-node-body { stroke: var(--text-secondary); + stroke-width: 1; } .graph-node.selected .graph-node-body { @@ -561,6 +561,7 @@ .graph-node.orphan .graph-node-body { stroke: var(--warning-color); + stroke-width: 1; stroke-dasharray: 4 3; } @@ -679,6 +680,77 @@ /* ── Loading overlay for relayout ── */ +/* ── Add entity menu ── */ + +.graph-add-entity-menu { + position: absolute; + z-index: 30; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 10px; + box-shadow: 0 8px 24px var(--shadow-color); + padding: 6px; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2px; + min-width: 280px; +} + +.graph-add-entity-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border: none; + background: transparent; + color: var(--text-color); + font-size: 0.8rem; + font-family: inherit; + text-align: left; + border-radius: 6px; + cursor: pointer; + transition: background 0.1s; + white-space: nowrap; +} + +.graph-add-entity-item:hover { + background: var(--bg-secondary); +} + +.graph-add-entity-dot { + width: 8px; + height: 8px; + border-radius: 2px; + flex-shrink: 0; +} + +.graph-add-entity-icon { + font-size: 1rem; + flex-shrink: 0; +} + +/* ── Fullscreen mode ── */ + +.graph-container:fullscreen { + background: var(--bg-color); + height: 100vh; +} + +.graph-container:fullscreen #bg-anim-canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + z-index: 0; +} + +.graph-container:fullscreen .graph-svg { + position: relative; + z-index: 1; +} + +/* ── Loading overlay for relayout ── */ + .graph-loading-overlay { position: absolute; inset: 0; diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index a86a6b1..9634973 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -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, diff --git a/server/src/wled_controller/static/js/core/graph-edges.js b/server/src/wled_controller/static/js/core/graph-edges.js index 4664d85..9d7d4ac 100644 --- a/server/src/wled_controller/static/js/core/graph-edges.js +++ b/server/src/wled_controller/static/js/core/graph-edges.js @@ -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, diff --git a/server/src/wled_controller/static/js/core/graph-nodes.js b/server/src/wled_controller/static/js/core/graph-nodes.js index 0b14954..b8cb3b7 100644 --- a/server/src/wled_controller/static/js/core/graph-nodes.js +++ b/server/src/wled_controller/static/js/core/graph-nodes.js @@ -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 diff --git a/server/src/wled_controller/static/js/features/graph-editor.js b/server/src/wled_controller/static/js/features/graph-editor.js index afaebac..da2791c 100644 --- a/server/src/wled_controller/static/js/features/graph-editor.js +++ b/server/src/wled_controller/static/js/features/graph-editor.js @@ -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 = `${label}`; + 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() { + + +