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