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:
@@ -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