Add node dragging, animated flow dots, and canvas cleanup to graph editor
- Drag nodes to reposition with dead-zone, edge re-routing, and minimap sync - Animated flow dots trace upstream chains to running nodes - Manual positions persist across re-renders, cleared on relayout - Fix canvas event listener leak on re-render by calling destroy() Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -245,6 +245,15 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.graph-node.dragging {
|
||||
cursor: grabbing;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.graph-node.dragging .graph-node-overlay {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.graph-node-body {
|
||||
fill: var(--card-bg);
|
||||
stroke: var(--border-color);
|
||||
@@ -393,11 +402,13 @@
|
||||
.graph-edge-flow {
|
||||
fill: none;
|
||||
stroke-width: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.graph-edge-flow circle {
|
||||
r: 3;
|
||||
opacity: 0.8;
|
||||
opacity: 0.85;
|
||||
filter: drop-shadow(0 0 2px currentColor);
|
||||
}
|
||||
|
||||
/* ── Drag connection preview ── */
|
||||
|
||||
@@ -167,3 +167,102 @@ export function clearEdgeHighlights(edgeGroup) {
|
||||
path.classList.remove('highlighted', 'dimmed');
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Edge colors (matching CSS) ── */
|
||||
|
||||
const EDGE_COLORS = {
|
||||
picture: '#42A5F5',
|
||||
colorstrip: '#66BB6A',
|
||||
value: '#FFA726',
|
||||
device: '#78909C',
|
||||
clock: '#26C6DA',
|
||||
audio: '#EF5350',
|
||||
template: '#AB47BC',
|
||||
scene: '#CE93D8',
|
||||
default: '#999',
|
||||
};
|
||||
|
||||
export { EDGE_COLORS };
|
||||
|
||||
/**
|
||||
* Update edge paths connected to a specific node (e.g. after dragging).
|
||||
* Falls back to default bezier since ELK routing points are no longer valid.
|
||||
*/
|
||||
export function updateEdgesForNode(group, nodeId, nodeMap, edges) {
|
||||
for (const edge of edges) {
|
||||
if (edge.from !== nodeId && edge.to !== nodeId) continue;
|
||||
const fromNode = nodeMap.get(edge.from);
|
||||
const toNode = nodeMap.get(edge.to);
|
||||
if (!fromNode || !toNode) continue;
|
||||
|
||||
const d = _defaultBezier(fromNode, toNode);
|
||||
group.querySelectorAll(`.graph-edge[data-from="${edge.from}"][data-to="${edge.to}"]`).forEach(pathEl => {
|
||||
if ((pathEl.getAttribute('data-field') || '') === (edge.field || '')) {
|
||||
pathEl.setAttribute('d', d);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render animated flow dots on edges leading to running nodes.
|
||||
* @param {SVGGElement} group - the edges group
|
||||
* @param {Array} edges
|
||||
* @param {Set<string>} runningIds - IDs of currently running nodes
|
||||
*/
|
||||
export function renderFlowDots(group, edges, runningIds) {
|
||||
group.querySelectorAll('.graph-edge-flow').forEach(el => el.remove());
|
||||
if (!runningIds || runningIds.size === 0) return;
|
||||
|
||||
// Collect all upstream edges that feed into running nodes (full chain)
|
||||
const activeEdges = new Set();
|
||||
const visited = new Set();
|
||||
const stack = [...runningIds];
|
||||
while (stack.length) {
|
||||
const cur = stack.pop();
|
||||
if (visited.has(cur)) continue;
|
||||
visited.add(cur);
|
||||
for (let i = 0; i < edges.length; i++) {
|
||||
if (edges[i].to === cur) {
|
||||
activeEdges.add(i);
|
||||
stack.push(edges[i].from);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const idx of activeEdges) {
|
||||
const edge = edges[idx];
|
||||
const pathEl = group.querySelector(
|
||||
`.graph-edge[data-from="${edge.from}"][data-to="${edge.to}"][data-field="${edge.field || ''}"]`
|
||||
);
|
||||
if (!pathEl) continue;
|
||||
const d = pathEl.getAttribute('d');
|
||||
if (!d) continue;
|
||||
|
||||
const color = EDGE_COLORS[edge.type] || EDGE_COLORS.default;
|
||||
const flowG = svgEl('g', { class: 'graph-edge-flow' });
|
||||
|
||||
// Two dots staggered for smoother visual flow
|
||||
for (const beginFrac of ['0s', '1s']) {
|
||||
const circle = svgEl('circle', { fill: color, opacity: '0.85' });
|
||||
circle.setAttribute('r', '3');
|
||||
const anim = document.createElementNS(SVG_NS, 'animateMotion');
|
||||
anim.setAttribute('dur', '2s');
|
||||
anim.setAttribute('repeatCount', 'indefinite');
|
||||
anim.setAttribute('begin', beginFrac);
|
||||
anim.setAttribute('path', d);
|
||||
circle.appendChild(anim);
|
||||
flowG.appendChild(circle);
|
||||
}
|
||||
group.appendChild(flowG);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update flow dot paths for edges connected to a node (after drag).
|
||||
*/
|
||||
export function updateFlowDotsForNode(group, nodeId, nodeMap, edges) {
|
||||
// Just remove and let caller re-render if needed; or update paths
|
||||
// For simplicity, remove all flow dots — they'll be re-added on next render cycle
|
||||
group.querySelectorAll('.graph-edge-flow').forEach(el => el.remove());
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { GraphCanvas } from '../core/graph-canvas.js';
|
||||
import { computeLayout, ENTITY_COLORS, ENTITY_LABELS } from '../core/graph-layout.js';
|
||||
import { renderNodes, highlightNode, markOrphans, updateSelection } from '../core/graph-nodes.js';
|
||||
import { renderEdges, highlightChain, clearEdgeHighlights } from '../core/graph-edges.js';
|
||||
import { renderEdges, highlightChain, clearEdgeHighlights, updateEdgesForNode, renderFlowDots, updateFlowDotsForNode } from '../core/graph-edges.js';
|
||||
import {
|
||||
devicesCache, captureTemplatesCache, ppTemplatesCache,
|
||||
streamsCache, audioSourcesCache, audioTemplatesCache,
|
||||
@@ -28,6 +28,14 @@ let _searchIndex = -1;
|
||||
let _searchItems = [];
|
||||
let _loading = false;
|
||||
|
||||
// Node drag state
|
||||
let _dragState = null; // { nodeId, el, startClient, startNode, dragging }
|
||||
let _justDragged = false;
|
||||
let _dragListenersAdded = false;
|
||||
|
||||
// Manual position overrides (persisted in memory; cleared on relayout)
|
||||
let _manualPositions = new Map();
|
||||
|
||||
// Minimap position/size persisted in localStorage
|
||||
const _MM_KEY = 'graph_minimap';
|
||||
function _loadMinimapRect() {
|
||||
@@ -68,9 +76,13 @@ export async function loadGraphEditor() {
|
||||
try {
|
||||
const entities = await _fetchAllEntities();
|
||||
const { nodes, edges, bounds } = await computeLayout(entities);
|
||||
|
||||
// Apply manual position overrides from previous drag operations
|
||||
_applyManualPositions(nodes, edges);
|
||||
|
||||
_nodeMap = nodes;
|
||||
_edges = edges;
|
||||
_bounds = bounds;
|
||||
_bounds = _calcBounds(nodes);
|
||||
_renderGraph(container);
|
||||
} finally {
|
||||
_loading = false;
|
||||
@@ -120,6 +132,7 @@ export function graphZoomIn() { if (_canvas) _canvas.zoomIn(); }
|
||||
export function graphZoomOut() { if (_canvas) _canvas.zoomOut(); }
|
||||
|
||||
export async function graphRelayout() {
|
||||
_manualPositions.clear();
|
||||
await loadGraphEditor();
|
||||
}
|
||||
|
||||
@@ -147,6 +160,9 @@ async function _fetchAllEntities() {
|
||||
/* ── Rendering ── */
|
||||
|
||||
function _renderGraph(container) {
|
||||
// Destroy previous canvas to clean up window event listeners
|
||||
if (_canvas) { _canvas.destroy(); _canvas = null; }
|
||||
|
||||
container.innerHTML = _graphHTML();
|
||||
|
||||
const svgEl = container.querySelector('.graph-svg');
|
||||
@@ -164,6 +180,13 @@ function _renderGraph(container) {
|
||||
});
|
||||
markOrphans(nodeGroup, _nodeMap, _edges);
|
||||
|
||||
// Animated flow dots for running nodes
|
||||
const runningIds = new Set();
|
||||
for (const node of _nodeMap.values()) {
|
||||
if (node.running) runningIds.add(node.id);
|
||||
}
|
||||
renderFlowDots(edgeGroup, _edges, runningIds);
|
||||
|
||||
// Set bounds for view clamping, then fit
|
||||
if (_bounds) _canvas.setBounds(_bounds);
|
||||
requestAnimationFrame(() => {
|
||||
@@ -182,6 +205,7 @@ function _renderGraph(container) {
|
||||
_renderLegend(container.querySelector('.graph-legend'));
|
||||
_initMinimap(container.querySelector('.graph-minimap'));
|
||||
_initToolbarDrag(container.querySelector('.graph-toolbar'));
|
||||
_initNodeDrag(nodeGroup, edgeGroup);
|
||||
|
||||
const searchInput = container.querySelector('.graph-search-input');
|
||||
if (searchInput) {
|
||||
@@ -325,7 +349,7 @@ function _initMinimap(mmEl) {
|
||||
let html = '';
|
||||
for (const node of _nodeMap.values()) {
|
||||
const color = ENTITY_COLORS[node.kind] || '#666';
|
||||
html += `<rect class="graph-minimap-node" x="${node.x}" y="${node.y}" width="${node.width}" height="${node.height}" fill="${color}" opacity="0.7"/>`;
|
||||
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)
|
||||
html += `<rect class="graph-minimap-viewport" x="0" y="0" width="0" height="0"/>`;
|
||||
@@ -556,6 +580,8 @@ function _navigateToNode(nodeId) {
|
||||
/* ── Node callbacks ── */
|
||||
|
||||
function _onNodeClick(node, e) {
|
||||
if (_justDragged) return; // suppress click after node drag
|
||||
|
||||
const nodeGroup = document.querySelector('.graph-nodes');
|
||||
const edgeGroup = document.querySelector('.graph-edges');
|
||||
|
||||
@@ -639,6 +665,150 @@ function _onKeydown(e) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Node dragging ── */
|
||||
|
||||
const DRAG_DEAD_ZONE = 4;
|
||||
|
||||
function _initNodeDrag(nodeGroup, edgeGroup) {
|
||||
// Event delegation on node group for pointerdown
|
||||
nodeGroup.addEventListener('pointerdown', (e) => {
|
||||
if (e.button !== 0) return;
|
||||
const nodeEl = e.target.closest('.graph-node');
|
||||
if (!nodeEl) return;
|
||||
// Don't start drag from overlay buttons
|
||||
if (e.target.closest('.graph-node-overlay-btn')) return;
|
||||
|
||||
const nodeId = nodeEl.getAttribute('data-id');
|
||||
const node = _nodeMap.get(nodeId);
|
||||
if (!node) return;
|
||||
|
||||
_dragState = {
|
||||
nodeId,
|
||||
el: nodeEl,
|
||||
startClient: { x: e.clientX, y: e.clientY },
|
||||
startNode: { x: node.x, y: node.y },
|
||||
dragging: false,
|
||||
pointerId: e.pointerId,
|
||||
};
|
||||
|
||||
// Prevent canvas from starting a pan (canvas checks blockPan on left-click non-node)
|
||||
// But we also need to stop propagation so the canvas's onPointerDown doesn't fire
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
// Window-level move/up listeners (added once, reused across re-renders)
|
||||
if (!_dragListenersAdded) {
|
||||
window.addEventListener('pointermove', _onDragPointerMove);
|
||||
window.addEventListener('pointerup', _onDragPointerUp);
|
||||
_dragListenersAdded = true;
|
||||
}
|
||||
}
|
||||
|
||||
function _onDragPointerMove(e) {
|
||||
if (!_dragState) return;
|
||||
|
||||
const dx = e.clientX - _dragState.startClient.x;
|
||||
const dy = e.clientY - _dragState.startClient.y;
|
||||
|
||||
if (!_dragState.dragging) {
|
||||
if (Math.abs(dx) < DRAG_DEAD_ZONE && Math.abs(dy) < DRAG_DEAD_ZONE) return;
|
||||
_dragState.dragging = true;
|
||||
if (_canvas) _canvas.blockPan = true;
|
||||
_dragState.el.classList.add('dragging');
|
||||
}
|
||||
|
||||
if (!_canvas) return;
|
||||
const gdx = dx / _canvas.zoom;
|
||||
const gdy = dy / _canvas.zoom;
|
||||
|
||||
const node = _nodeMap.get(_dragState.nodeId);
|
||||
if (!node) return;
|
||||
|
||||
node.x = _dragState.startNode.x + gdx;
|
||||
node.y = _dragState.startNode.y + gdy;
|
||||
_dragState.el.setAttribute('transform', `translate(${node.x}, ${node.y})`);
|
||||
|
||||
// Update connected edges
|
||||
const edgeGroup = document.querySelector('.graph-edges');
|
||||
if (edgeGroup) {
|
||||
updateEdgesForNode(edgeGroup, _dragState.nodeId, _nodeMap, _edges);
|
||||
updateFlowDotsForNode(edgeGroup, _dragState.nodeId, _nodeMap, _edges);
|
||||
}
|
||||
|
||||
// Update minimap node position
|
||||
_updateMinimapNode(_dragState.nodeId, node);
|
||||
}
|
||||
|
||||
function _onDragPointerUp() {
|
||||
if (!_dragState) return;
|
||||
|
||||
if (_dragState.dragging) {
|
||||
_dragState.el.classList.remove('dragging');
|
||||
if (_canvas) _canvas.blockPan = false;
|
||||
_justDragged = true;
|
||||
requestAnimationFrame(() => { _justDragged = false; });
|
||||
|
||||
// Save manual position
|
||||
const node = _nodeMap.get(_dragState.nodeId);
|
||||
if (node) _manualPositions.set(_dragState.nodeId, { x: node.x, y: node.y });
|
||||
|
||||
// Recalc bounds for view clamping
|
||||
_bounds = _calcBounds(_nodeMap);
|
||||
if (_canvas && _bounds) _canvas.setBounds(_bounds);
|
||||
|
||||
// Re-render flow dots (paths changed)
|
||||
const edgeGroup = document.querySelector('.graph-edges');
|
||||
if (edgeGroup && _edges && _nodeMap) {
|
||||
const runningIds = new Set();
|
||||
for (const n of _nodeMap.values()) { if (n.running) runningIds.add(n.id); }
|
||||
renderFlowDots(edgeGroup, _edges, runningIds);
|
||||
}
|
||||
}
|
||||
|
||||
_dragState = null;
|
||||
}
|
||||
|
||||
function _updateMinimapNode(nodeId, node) {
|
||||
const mm = document.querySelector('.graph-minimap');
|
||||
if (!mm) return;
|
||||
const mmNode = mm.querySelector(`rect.graph-minimap-node[data-id="${nodeId}"]`);
|
||||
if (mmNode) {
|
||||
mmNode.setAttribute('x', node.x);
|
||||
mmNode.setAttribute('y', node.y);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Manual position helpers ── */
|
||||
|
||||
function _applyManualPositions(nodeMap, edges) {
|
||||
if (_manualPositions.size === 0) return;
|
||||
for (const [id, pos] of _manualPositions) {
|
||||
const node = nodeMap.get(id);
|
||||
if (node) {
|
||||
node.x = pos.x;
|
||||
node.y = pos.y;
|
||||
}
|
||||
}
|
||||
// Invalidate ELK edge routing for edges connected to moved nodes
|
||||
for (const edge of edges) {
|
||||
if (_manualPositions.has(edge.from) || _manualPositions.has(edge.to)) {
|
||||
edge.points = null; // forces default bezier
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _calcBounds(nodeMap) {
|
||||
if (!nodeMap || nodeMap.size === 0) return { x: 0, y: 0, width: 400, height: 300 };
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const n of nodeMap.values()) {
|
||||
minX = Math.min(minX, n.x);
|
||||
minY = Math.min(minY, n.y);
|
||||
maxX = Math.max(maxX, n.x + n.width);
|
||||
maxY = Math.max(maxY, n.y + n.height);
|
||||
}
|
||||
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
||||
}
|
||||
|
||||
/* ── Helpers ── */
|
||||
|
||||
function _escHtml(s) {
|
||||
|
||||
Reference in New Issue
Block a user