Add interactive graph editor connections: port-based edges, drag-connect, and detach

- Add visible typed ports on graph nodes (colored dots for each edge type)
- Route edges to specific port positions instead of node center
- Drag from output port to compatible input port to create/change connections
- Right-click edge context menu with Disconnect option
- Delete key detaches selected edge
- Mark nested edges (composite layers, zones) as non-editable with dotted style
- Add resolve_ref helper for empty-string sentinel to clear reference fields
- Apply resolve_ref across all storage stores for consistent detach support
- Add connection mapping module (graph-connections.js) with API field resolution
- Add i18n keys for connection operations (en/ru/zh)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 17:15:33 +03:00
parent ff24ec95e6
commit b370bb7d75
17 changed files with 661 additions and 60 deletions

View File

@@ -3,7 +3,7 @@
*/
import { GraphCanvas } from '../core/graph-canvas.js';
import { computeLayout, ENTITY_COLORS, ENTITY_LABELS } from '../core/graph-layout.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 { renderEdges, highlightChain, clearEdgeHighlights, updateEdgesForNode, renderFlowDots, updateFlowDotsForNode } from '../core/graph-edges.js';
import {
@@ -16,6 +16,7 @@ import {
import { fetchWithAuth } from '../core/api.js';
import { showToast } from '../core/ui.js';
import { t } from '../core/i18n.js';
import { findConnection, getCompatibleInputs, getConnectionByField, updateConnection, detachConnection, isEditableEdge } from '../core/graph-connections.js';
let _canvas = null;
let _nodeMap = null;
@@ -42,6 +43,16 @@ let _manualPositions = new Map();
let _rubberBand = null;
let _rubberBandListenersAdded = false;
// Port-drag connection state
let _connectState = null; // { sourceNodeId, sourceKind, portType, startPos, dragPath }
let _connectListenersAdded = false;
// Edge context menu
let _edgeContextMenu = null;
// Selected edge for Delete key detach
let _selectedEdge = null; // { from, to, field, targetKind }
// Minimap position/size persisted in localStorage
const _MM_KEY = 'graph_minimap';
function _loadMinimapRect() {
@@ -86,6 +97,7 @@ export async function loadGraphEditor() {
// Apply manual position overrides from previous drag operations
_applyManualPositions(nodes, edges);
computePorts(nodes, edges);
_nodeMap = nodes;
_edges = edges;
_bounds = _calcBounds(nodes);
@@ -229,6 +241,7 @@ function _renderGraph(container) {
_initMinimap(container.querySelector('.graph-minimap'));
_initToolbarDrag(container.querySelector('.graph-toolbar'));
_initNodeDrag(nodeGroup, edgeGroup);
_initPortDrag(svgEl, nodeGroup, edgeGroup);
_initRubberBand(svgEl);
// Edge click: select edge and its endpoints
@@ -239,6 +252,15 @@ function _renderGraph(container) {
_onEdgeClick(edgePath, nodeGroup, edgeGroup);
});
// Edge right-click: detach connection
edgeGroup.addEventListener('contextmenu', (e) => {
const edgePath = e.target.closest('.graph-edge');
if (!edgePath) return;
e.preventDefault();
e.stopPropagation();
_onEdgeContextMenu(edgePath, e, container);
});
const searchInput = container.querySelector('.graph-search-input');
if (searchInput) {
searchInput.addEventListener('input', (e) => _renderSearchResults(e.target.value));
@@ -247,6 +269,7 @@ function _renderGraph(container) {
// Deselect on click on empty space (not after a pan gesture)
svgEl.addEventListener('click', (e) => {
_dismissEdgeContextMenu();
if (_canvas.wasPanning) return;
if (e.shiftKey) return; // Shift+click reserved for rubber-band
if (!e.target.closest('.graph-node')) {
@@ -274,6 +297,7 @@ function _renderGraph(container) {
function _deselect(nodeGroup, edgeGroup) {
_selectedIds.clear();
_selectedEdge = null;
if (nodeGroup) {
updateSelection(nodeGroup, _selectedIds);
nodeGroup.querySelectorAll('.graph-node').forEach(n => n.style.opacity = '1');
@@ -766,11 +790,15 @@ function _onKeydown(e) {
_deselect(ng, eg);
}
}
// Delete key → delete single selected node
if (e.key === 'Delete' && _selectedIds.size === 1) {
const nodeId = [..._selectedIds][0];
const node = _nodeMap.get(nodeId);
if (node) _onDeleteNode(node);
// Delete key → detach selected edge or delete single selected node
if (e.key === 'Delete') {
if (_selectedEdge) {
_detachSelectedEdge();
} else if (_selectedIds.size === 1) {
const nodeId = [..._selectedIds][0];
const node = _nodeMap.get(nodeId);
if (node) _onDeleteNode(node);
}
}
// Ctrl+A → select all
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
@@ -797,6 +825,15 @@ function _selectAll() {
function _onEdgeClick(edgePath, nodeGroup, edgeGroup) {
const fromId = edgePath.getAttribute('data-from');
const toId = edgePath.getAttribute('data-to');
const field = edgePath.getAttribute('data-field') || '';
// Track selected edge for Delete key detach
const toNode = _nodeMap?.get(toId);
if (toNode && isEditableEdge(field)) {
_selectedEdge = { from: fromId, to: toId, field, targetKind: toNode.kind };
} else {
_selectedEdge = null;
}
_selectedIds.clear();
_selectedIds.add(fromId);
@@ -827,6 +864,7 @@ function _initNodeDrag(nodeGroup, edgeGroup) {
const nodeEl = e.target.closest('.graph-node');
if (!nodeEl) return;
if (e.target.closest('.graph-node-overlay-btn')) return;
if (e.target.closest('.graph-port-out')) return; // handled by port drag
const nodeId = nodeEl.getAttribute('data-id');
const node = _nodeMap.get(nodeId);
@@ -1089,6 +1127,203 @@ function _escHtml(s) {
return d.innerHTML;
}
/* ── Port drag (connect/reconnect) ── */
const SVG_NS = 'http://www.w3.org/2000/svg';
function _initPortDrag(svgEl, nodeGroup, edgeGroup) {
// Capture-phase on output ports to prevent node drag
nodeGroup.addEventListener('pointerdown', (e) => {
const port = e.target.closest('.graph-port-out');
if (!port || e.button !== 0) return;
e.stopPropagation();
e.preventDefault();
const sourceNodeId = port.getAttribute('data-node-id');
const sourceKind = port.getAttribute('data-node-kind');
const portType = port.getAttribute('data-port-type');
const sourceNode = _nodeMap?.get(sourceNodeId);
if (!sourceNode) return;
// Compute start position in graph coords (output port = right side of node)
const portY = sourceNode.outputPorts?.ports?.[portType] ?? sourceNode.height / 2;
const startX = sourceNode.x + sourceNode.width;
const startY = sourceNode.y + portY;
// Create temporary drag edge in SVG
const dragPath = document.createElementNS(SVG_NS, 'path');
dragPath.setAttribute('class', 'graph-drag-edge');
dragPath.setAttribute('d', `M ${startX} ${startY} L ${startX} ${startY}`);
const root = svgEl.querySelector('.graph-root');
root.appendChild(dragPath);
_connectState = { sourceNodeId, sourceKind, portType, startX, startY, dragPath };
if (_canvas) _canvas.blockPan = true;
svgEl.classList.add('connecting');
// Highlight compatible input ports
const compatible = getCompatibleInputs(sourceKind);
const compatibleSet = new Set(compatible.map(c => `${c.targetKind}:${c.edgeType}`));
nodeGroup.querySelectorAll('.graph-port-in').forEach(p => {
const nKind = p.getAttribute('data-node-kind');
const pType = p.getAttribute('data-port-type');
const nId = p.getAttribute('data-node-id');
// Don't connect to self
if (nId === sourceNodeId) {
p.classList.add('graph-port-incompatible');
return;
}
if (compatibleSet.has(`${nKind}:${pType}`)) {
p.classList.add('graph-port-compatible');
} else {
p.classList.add('graph-port-incompatible');
}
});
}, true); // capture phase to beat node drag
if (!_connectListenersAdded) {
window.addEventListener('pointermove', _onConnectPointerMove);
window.addEventListener('pointerup', _onConnectPointerUp);
_connectListenersAdded = true;
}
}
function _onConnectPointerMove(e) {
if (!_connectState || !_canvas) return;
const gp = _canvas.screenToGraph(e.clientX, e.clientY);
const { startX, startY, dragPath } = _connectState;
const dx = Math.abs(gp.x - startX) * 0.4;
dragPath.setAttribute('d',
`M ${startX} ${startY} C ${startX + dx} ${startY} ${gp.x - dx} ${gp.y} ${gp.x} ${gp.y}`
);
// Highlight drop target port
const svgEl = document.querySelector('.graph-svg');
if (!svgEl) return;
const elem = document.elementFromPoint(e.clientX, e.clientY);
const port = elem?.closest?.('.graph-port-compatible');
svgEl.querySelectorAll('.graph-port-drop-target').forEach(p => p.classList.remove('graph-port-drop-target'));
if (port) port.classList.add('graph-port-drop-target');
}
function _onConnectPointerUp(e) {
if (!_connectState) return;
const { sourceNodeId, sourceKind, portType, dragPath } = _connectState;
// Clean up drag edge
dragPath.remove();
const svgEl = document.querySelector('.graph-svg');
if (svgEl) svgEl.classList.remove('connecting');
if (_canvas) _canvas.blockPan = false;
// Clean up port highlights
const nodeGroup = document.querySelector('.graph-nodes');
if (nodeGroup) {
nodeGroup.querySelectorAll('.graph-port-compatible, .graph-port-incompatible, .graph-port-drop-target').forEach(p => {
p.classList.remove('graph-port-compatible', 'graph-port-incompatible', 'graph-port-drop-target');
});
}
// Check if dropped on a compatible input port
const elem = document.elementFromPoint(e.clientX, e.clientY);
const targetPort = elem?.closest?.('.graph-port-in');
if (targetPort) {
const targetNodeId = targetPort.getAttribute('data-node-id');
const targetKind = targetPort.getAttribute('data-node-kind');
const targetPortType = targetPort.getAttribute('data-port-type');
if (targetNodeId !== sourceNodeId) {
// Find the matching connection
const matches = findConnection(targetKind, sourceKind, targetPortType);
if (matches.length === 1) {
_doConnect(targetNodeId, targetKind, matches[0].field, sourceNodeId);
} else if (matches.length > 1) {
// Multiple possible fields (e.g., template → picture_source could be capture or pp template)
// Resolve by source kind
const exact = matches.find(m => m.sourceKind === sourceKind);
if (exact) {
_doConnect(targetNodeId, targetKind, exact.field, sourceNodeId);
}
}
}
}
_connectState = null;
}
async function _doConnect(targetId, targetKind, field, sourceId) {
const ok = await updateConnection(targetId, targetKind, field, sourceId);
if (ok) {
showToast(t('graph.connection_updated') || 'Connection updated', 'success');
await loadGraphEditor();
} else {
showToast(t('graph.connection_failed') || 'Failed to update connection', 'error');
}
}
/* ── Edge context menu (right-click to detach) ── */
function _onEdgeContextMenu(edgePath, e, container) {
_dismissEdgeContextMenu();
const field = edgePath.getAttribute('data-field') || '';
if (!isEditableEdge(field)) return; // nested fields can't be detached from graph
const toId = edgePath.getAttribute('data-to');
const toNode = _nodeMap?.get(toId);
if (!toNode) return;
const menu = document.createElement('div');
menu.className = 'graph-edge-menu';
menu.style.left = `${e.clientX - container.getBoundingClientRect().left}px`;
menu.style.top = `${e.clientY - container.getBoundingClientRect().top}px`;
const btn = document.createElement('button');
btn.className = 'graph-edge-menu-item danger';
btn.textContent = t('graph.disconnect') || 'Disconnect';
btn.addEventListener('click', async () => {
_dismissEdgeContextMenu();
const ok = await detachConnection(toId, toNode.kind, field);
if (ok) {
showToast(t('graph.connection_removed') || 'Connection removed', 'success');
await loadGraphEditor();
} else {
showToast(t('graph.disconnect_failed') || 'Failed to disconnect', 'error');
}
});
menu.appendChild(btn);
container.querySelector('.graph-container').appendChild(menu);
_edgeContextMenu = menu;
}
function _dismissEdgeContextMenu() {
if (_edgeContextMenu) {
_edgeContextMenu.remove();
_edgeContextMenu = null;
}
}
async function _detachSelectedEdge() {
if (!_selectedEdge) return;
const { to, field, targetKind } = _selectedEdge;
_selectedEdge = null;
const ok = await detachConnection(to, targetKind, field);
if (ok) {
showToast(t('graph.connection_removed') || 'Connection removed', 'success');
await loadGraphEditor();
} else {
showToast(t('graph.disconnect_failed') || 'Failed to disconnect', 'error');
}
}
// Re-render graph when language changes (toolbar titles, legend, search placeholder use t())
document.addEventListener('languageChanged', () => {
if (_initialized && _nodeMap) {