From 8692cfcc9567d5404f50aa4aec6ce1bae9ab26ed Mon Sep 17 00:00:00 2001 From: soryu Date: Wed, 4 Feb 2026 02:08:27 +0000 Subject: Add interactive DAG editor for chain definitions - Add drag-and-drop to reposition definition nodes - Add edge drawing from connector dot to create dependencies - Add right-click context menu with delete and dependency management - Show visual hints in footer for available interactions - Update node positions and dependencies via API on change Co-Authored-By: Claude Opus 4.5 --- .../frontend/src/components/chains/ChainEditor.tsx | 419 ++++++++++++++++++++- 1 file changed, 408 insertions(+), 11 deletions(-) diff --git a/makima/frontend/src/components/chains/ChainEditor.tsx b/makima/frontend/src/components/chains/ChainEditor.tsx index d278607..09d590a 100644 --- a/makima/frontend/src/components/chains/ChainEditor.tsx +++ b/makima/frontend/src/components/chains/ChainEditor.tsx @@ -10,6 +10,7 @@ import type { import { listChainDefinitions, createChainDefinition, + updateChainDefinition, deleteChainDefinition, getChainDefinitionGraph, startChain, @@ -55,6 +56,24 @@ export function ChainEditor({ const [isStopping, setIsStopping] = useState(false); const [error, setError] = useState(null); + // Drag state + const [draggedNode, setDraggedNode] = useState(null); + const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); + const [localPositions, setLocalPositions] = useState>(new Map()); + + // Edge drawing state + const [edgeDrawing, setEdgeDrawing] = useState<{ + fromNode: string; + toPoint: { x: number; y: number }; + } | null>(null); + + // Context menu state + const [contextMenu, setContextMenu] = useState<{ + x: number; + y: number; + nodeId: string; + } | null>(null); + // Load definitions when chain changes useEffect(() => { async function loadDefinitions() { @@ -76,16 +95,22 @@ export function ChainEditor({ const showDefinitions = chain.status === "pending" || chain.status === "archived"; const currentGraph = showDefinitions ? definitionGraph : graph; - // Use positions from graph nodes directly (x, y from server) + // Use positions from graph nodes, with local overrides for dragging const nodePositions = useMemo(() => { const positions = new Map(); if (showDefinitions && definitionGraph?.nodes) { for (const node of definitionGraph.nodes) { - positions.set(node.id, { - x: CANVAS_PADDING + (node.x || 0) * (NODE_WIDTH + 60), - y: CANVAS_PADDING + (node.y || 0) * (NODE_HEIGHT + 40), - }); + // Use local position if available (during drag), otherwise use server position + const localPos = localPositions.get(node.id); + if (localPos) { + positions.set(node.id, localPos); + } else { + positions.set(node.id, { + x: CANVAS_PADDING + (node.x || 0) * (NODE_WIDTH + 60), + y: CANVAS_PADDING + (node.y || 0) * (NODE_HEIGHT + 40), + }); + } } } else if (!showDefinitions && graph?.nodes) { for (const node of graph.nodes) { @@ -97,7 +122,7 @@ export function ChainEditor({ } return positions; - }, [showDefinitions, definitionGraph?.nodes, graph?.nodes]); + }, [showDefinitions, definitionGraph?.nodes, graph?.nodes, localPositions]); // Canvas dimensions const canvasDimensions = useMemo(() => { @@ -231,6 +256,171 @@ export function ChainEditor({ } }, [chain.id]); + // Refresh definitions helper + const refreshDefinitions = useCallback(async () => { + const [defs, defGraph] = await Promise.all([ + listChainDefinitions(chain.id), + getChainDefinitionGraph(chain.id), + ]); + setDefinitions(defs); + setDefinitionGraph(defGraph); + setLocalPositions(new Map()); // Clear local positions + }, [chain.id]); + + // Context menu handlers + const handleContextMenu = useCallback((e: React.MouseEvent, nodeId: string) => { + e.preventDefault(); + e.stopPropagation(); + setContextMenu({ x: e.clientX, y: e.clientY, nodeId }); + }, []); + + const closeContextMenu = useCallback(() => { + setContextMenu(null); + }, []); + + // Handle canvas click to close context menu + const handleCanvasClick = useCallback(() => { + if (contextMenu) { + closeContextMenu(); + } + }, [contextMenu, closeContextMenu]); + + // Drag handlers + const handleDragStart = useCallback((e: React.MouseEvent, nodeId: string) => { + if (!showDefinitions || chain.status !== "pending") return; + e.preventDefault(); + const pos = nodePositions.get(nodeId); + if (!pos) return; + + setDraggedNode(nodeId); + setDragOffset({ + x: e.clientX - pos.x, + y: e.clientY - pos.y, + }); + }, [showDefinitions, chain.status, nodePositions]); + + const handleDragMove = useCallback((e: React.MouseEvent) => { + if (!draggedNode) return; + + const canvasRect = canvasRef.current?.getBoundingClientRect(); + if (!canvasRect) return; + + const newX = Math.max(CANVAS_PADDING, e.clientX - dragOffset.x); + const newY = Math.max(CANVAS_PADDING, e.clientY - dragOffset.y); + + setLocalPositions(prev => { + const newMap = new Map(prev); + newMap.set(draggedNode, { x: newX, y: newY }); + return newMap; + }); + }, [draggedNode, dragOffset]); + + const handleDragEnd = useCallback(async () => { + if (!draggedNode) return; + + const localPos = localPositions.get(draggedNode); + if (localPos) { + // Convert pixel position back to grid coordinates + const gridX = Math.round((localPos.x - CANVAS_PADDING) / (NODE_WIDTH + 60)); + const gridY = Math.round((localPos.y - CANVAS_PADDING) / (NODE_HEIGHT + 40)); + + try { + await updateChainDefinition(chain.id, draggedNode, { + editorX: gridX, + editorY: gridY, + }); + await refreshDefinitions(); + } catch (err) { + console.error("Failed to update position:", err); + setError(err instanceof Error ? err.message : "Failed to update position"); + } + } + + setDraggedNode(null); + }, [draggedNode, localPositions, chain.id, refreshDefinitions]); + + // Edge drawing handlers + const handleEdgeDrawStart = useCallback((e: React.MouseEvent, nodeId: string) => { + if (!showDefinitions || chain.status !== "pending") return; + e.preventDefault(); + e.stopPropagation(); + + const canvasRect = canvasRef.current?.getBoundingClientRect(); + if (!canvasRect) return; + + const pos = nodePositions.get(nodeId); + if (!pos) return; + + setEdgeDrawing({ + fromNode: nodeId, + toPoint: { + x: pos.x + NODE_WIDTH / 2, + y: pos.y + NODE_HEIGHT, + }, + }); + }, [showDefinitions, chain.status, nodePositions]); + + const handleEdgeDrawMove = useCallback((e: React.MouseEvent) => { + if (!edgeDrawing) return; + + const canvasRect = canvasRef.current?.getBoundingClientRect(); + if (!canvasRect) return; + + setEdgeDrawing(prev => prev ? { + ...prev, + toPoint: { + x: e.clientX - canvasRect.left, + y: e.clientY - canvasRect.top, + }, + } : null); + }, [edgeDrawing]); + + const handleEdgeDrawEnd = useCallback(async (targetNodeId: string | null) => { + if (!edgeDrawing) return; + + if (targetNodeId && targetNodeId !== edgeDrawing.fromNode) { + // Find the target definition and its current dependencies + const fromDef = definitions.find(d => d.id === edgeDrawing.fromNode); + const toDef = definitions.find(d => d.id === targetNodeId); + + if (fromDef && toDef) { + // Add dependency: targetNodeId depends on fromNode + const currentDeps = toDef.dependsOnNames || []; + if (!currentDeps.includes(fromDef.name)) { + try { + await updateChainDefinition(chain.id, targetNodeId, { + dependsOn: [...currentDeps, fromDef.name], + }); + await refreshDefinitions(); + } catch (err) { + console.error("Failed to create dependency:", err); + setError(err instanceof Error ? err.message : "Failed to create dependency"); + } + } + } + } + + setEdgeDrawing(null); + }, [edgeDrawing, definitions, chain.id, refreshDefinitions]); + + // Handle removing a dependency via context menu + const handleRemoveDependency = useCallback(async (nodeId: string, depName: string) => { + const def = definitions.find(d => d.id === nodeId); + if (!def) return; + + const newDeps = (def.dependsOnNames || []).filter(d => d !== depName); + try { + await updateChainDefinition(chain.id, nodeId, { + dependsOn: newDeps, + }); + await refreshDefinitions(); + closeContextMenu(); + } catch (err) { + console.error("Failed to remove dependency:", err); + setError(err instanceof Error ? err.message : "Failed to remove dependency"); + } + }, [definitions, chain.id, refreshDefinitions, closeContextMenu]); + return (
{/* Header */} @@ -332,6 +522,20 @@ export function ChainEditor({ height: canvasDimensions.height, minWidth: "100%", minHeight: "100%", + cursor: draggedNode ? "grabbing" : edgeDrawing ? "crosshair" : "default", + }} + onClick={handleCanvasClick} + onMouseMove={(e) => { + if (draggedNode) handleDragMove(e); + if (edgeDrawing) handleEdgeDrawMove(e); + }} + onMouseUp={() => { + if (draggedNode) handleDragEnd(); + if (edgeDrawing) handleEdgeDrawEnd(null); + }} + onMouseLeave={() => { + if (draggedNode) handleDragEnd(); + if (edgeDrawing) setEdgeDrawing(null); }} > {/* SVG layer for edges */} @@ -357,6 +561,20 @@ export function ChainEditor({ opacity="0.6" /> + + + {currentGraph.edges.map((edge, index) => { const fromPos = nodePositions.get(edge.from); @@ -388,6 +606,27 @@ export function ChainEditor({ /> ); })} + {/* Edge drawing preview line */} + {edgeDrawing && (() => { + const fromPos = nodePositions.get(edgeDrawing.fromNode); + if (!fromPos) return null; + const startX = fromPos.x + NODE_WIDTH / 2; + const startY = fromPos.y + NODE_HEIGHT; + const endX = edgeDrawing.toPoint.x; + const endY = edgeDrawing.toPoint.y; + const midY = (startY + endY) / 2; + return ( + + ); + })()} {/* Node layer */} @@ -404,27 +643,34 @@ export function ChainEditor({ const isSelected = selectedNode === node.id; const isHovered = hoveredNode === node.id; + const isDragging = draggedNode === node.id; + const canEdit = chain.status === "pending"; + return (
handleNodeClick(node.id)} - onMouseEnter={() => setHoveredNode(node.id)} - onMouseLeave={() => setHoveredNode(null)} - className={`absolute cursor-pointer transition-all duration-150 ${ + onContextMenu={(e) => canEdit && handleContextMenu(e, node.id)} + onMouseEnter={() => !draggedNode && setHoveredNode(node.id)} + onMouseLeave={() => !draggedNode && setHoveredNode(null)} + onMouseUp={() => edgeDrawing && handleEdgeDrawEnd(node.id)} + className={`absolute transition-all duration-150 ${ isSelected ? isCheckpoint ? "ring-2 ring-[#a78bfa] ring-offset-2 ring-offset-[#050d18]" : "ring-2 ring-[#75aafc] ring-offset-2 ring-offset-[#050d18]" : "" - }`} + } ${isDragging ? "z-50 shadow-lg" : ""}`} style={{ left: pos.x, top: pos.y, width: NODE_WIDTH, height: NODE_HEIGHT, - transform: isHovered ? "scale(1.02)" : "scale(1)", + transform: isHovered && !isDragging ? "scale(1.02)" : "scale(1)", + cursor: isDragging ? "grabbing" : canEdit ? "grab" : "pointer", }} > + {/* Drag handle (entire node) */}
canEdit && handleDragStart(e, node.id)} > {/* Status indicator bar */}
+ {/* Edge drawing handle (connector dot at bottom) */} + {canEdit && (isHovered || isSelected) && !isDragging && ( +
handleEdgeDrawStart(e, node.id)} + title="Drag to create dependency" + /> + )}
); }) @@ -571,6 +826,16 @@ export function ChainEditor({ {showDefinitions ? ( <> {definitions.length} definitions + {chain.status === "pending" && ( + <> + | + Drag nodes to reposition + | + Drag from to link + | + Right-click for options + + )} {chain.status === "pending" && (
); } +// Context Menu Component +interface ContextMenuProps { + x: number; + y: number; + nodeId: string; + definition?: ChainContractDefinition; + allDefinitions: ChainContractDefinition[]; + onClose: () => void; + onDelete: (id: string) => void; + onRemoveDependency: (nodeId: string, depName: string) => void; +} + +function ContextMenu({ + x, + y, + nodeId, + definition, + allDefinitions: _allDefinitions, + onClose, + onDelete, + onRemoveDependency, +}: ContextMenuProps) { + const menuRef = useRef(null); + + // Close on click outside + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + onClose(); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [onClose]); + + // Close on escape + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [onClose]); + + if (!definition) return null; + + const dependencies = definition.dependsOnNames || []; + + return ( +
+ {/* Header */} +
+ {definition.name} +
+ + {/* Actions */} +
+ +
+ + {/* Dependencies section */} + {dependencies.length > 0 && ( + <> +
+
+ Dependencies +
+
+ {dependencies.map((depName) => ( +
+ {depName} + +
+ ))} +
+ + )} + + {/* Hint */} +
+ + Drag from connector to create dependency + +
+
+ ); +} + +// Trash Icon +function TrashIcon({ className }: { className?: string }) { + return ( + + + + ); +} + interface DefinitionDetailPanelProps { definition: ChainContractDefinition; onClose: () => void; -- cgit v1.2.3