diff options
| author | soryu <soryu@soryu.co> | 2026-02-04 02:08:27 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-02-04 02:08:27 +0000 |
| commit | 8692cfcc9567d5404f50aa4aec6ce1bae9ab26ed (patch) | |
| tree | 4ebe36da50f43be2b9fce418c2e39bf1f6dcb654 /makima/frontend/src/components/chains | |
| parent | 612cecc5bd5dbfc73d4a3a9d38626378eaf39041 (diff) | |
| download | soryu-8692cfcc9567d5404f50aa4aec6ce1bae9ab26ed.tar.gz soryu-8692cfcc9567d5404f50aa4aec6ce1bae9ab26ed.zip | |
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 <noreply@anthropic.com>
Diffstat (limited to 'makima/frontend/src/components/chains')
| -rw-r--r-- | makima/frontend/src/components/chains/ChainEditor.tsx | 419 |
1 files 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<string | null>(null); + // Drag state + const [draggedNode, setDraggedNode] = useState<string | null>(null); + const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); + const [localPositions, setLocalPositions] = useState<Map<string, { x: number; y: number }>>(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<string, { x: number; y: number }>(); 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 ( <div className="panel h-full flex flex-col"> {/* 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" /> </marker> + <marker + id="arrowhead-preview" + markerWidth="10" + markerHeight="7" + refX="9" + refY="3.5" + orient="auto" + > + <polygon + points="0 0, 10 3.5, 0 7" + fill="#f59e0b" + opacity="0.8" + /> + </marker> </defs> {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 ( + <path + d={`M ${startX} ${startY} C ${startX} ${midY}, ${endX} ${midY}, ${endX} ${endY}`} + fill="none" + stroke="#f59e0b" + strokeWidth={2} + strokeDasharray="6 3" + markerEnd="url(#arrowhead-preview)" + opacity={0.8} + /> + ); + })()} </svg> {/* 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 ( <div key={node.id} onClick={() => 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) */} <div className={`w-full h-full rounded-lg border-2 overflow-hidden ${ isCheckpoint ? "bg-[#0f0a1e]" : "bg-[#0a1628]" @@ -437,6 +683,7 @@ export function ChainEditor({ : colors.border, borderStyle: node.isInstantiated ? "solid" : "dashed", }} + onMouseDown={(e) => canEdit && handleDragStart(e, node.id)} > {/* Status indicator bar */} <div @@ -471,6 +718,14 @@ export function ChainEditor({ </div> </div> </div> + {/* Edge drawing handle (connector dot at bottom) */} + {canEdit && (isHovered || isSelected) && !isDragging && ( + <div + className="absolute left-1/2 -translate-x-1/2 -bottom-2 w-4 h-4 rounded-full bg-[#f59e0b] border-2 border-[#0a1628] cursor-crosshair hover:scale-125 transition-transform" + onMouseDown={(e) => handleEdgeDrawStart(e, node.id)} + title="Drag to create dependency" + /> + )} </div> ); }) @@ -571,6 +826,16 @@ export function ChainEditor({ {showDefinitions ? ( <> <span>{definitions.length} definitions</span> + {chain.status === "pending" && ( + <> + <span className="text-[#556677]">|</span> + <span className="text-[#556677]">Drag nodes to reposition</span> + <span className="text-[#556677]">|</span> + <span className="text-[#556677]">Drag from <span className="text-[#f59e0b]">●</span> to link</span> + <span className="text-[#556677]">|</span> + <span className="text-[#556677]">Right-click for options</span> + </> + )} <span className="flex-1" /> {chain.status === "pending" && ( <button @@ -605,10 +870,142 @@ export function ChainEditor({ onCancel={() => setShowAddDefinition(false)} /> )} + + {/* Context Menu */} + {contextMenu && ( + <ContextMenu + x={contextMenu.x} + y={contextMenu.y} + nodeId={contextMenu.nodeId} + definition={definitions.find((d) => d.id === contextMenu.nodeId)} + allDefinitions={definitions} + onClose={closeContextMenu} + onDelete={handleDeleteDefinition} + onRemoveDependency={handleRemoveDependency} + /> + )} </div> ); } +// 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<HTMLDivElement>(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 ( + <div + ref={menuRef} + className="fixed z-50 min-w-48 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] shadow-xl rounded-lg overflow-hidden" + style={{ left: x, top: y }} + > + {/* Header */} + <div className="px-3 py-2 border-b border-[rgba(117,170,252,0.2)] bg-[#0f1c32]"> + <span className="font-mono text-xs text-[#dbe7ff]">{definition.name}</span> + </div> + + {/* Actions */} + <div className="py-1"> + <button + onClick={() => { + onDelete(nodeId); + onClose(); + }} + className="w-full px-3 py-2 text-left font-mono text-xs text-red-400 hover:bg-red-400/10 transition-colors flex items-center gap-2" + > + <TrashIcon className="w-3 h-3" /> + Delete Definition + </button> + </div> + + {/* Dependencies section */} + {dependencies.length > 0 && ( + <> + <div className="border-t border-[rgba(117,170,252,0.2)]" /> + <div className="px-3 py-2"> + <span className="font-mono text-[10px] text-[#8b949e] uppercase">Dependencies</span> + </div> + <div className="pb-1"> + {dependencies.map((depName) => ( + <div + key={depName} + className="px-3 py-1.5 flex items-center justify-between hover:bg-[rgba(117,170,252,0.1)] group" + > + <span className="font-mono text-xs text-[#9bc3ff]">{depName}</span> + <button + onClick={() => onRemoveDependency(nodeId, depName)} + className="font-mono text-[10px] text-red-400 opacity-0 group-hover:opacity-100 transition-opacity hover:underline" + > + Remove + </button> + </div> + ))} + </div> + </> + )} + + {/* Hint */} + <div className="border-t border-[rgba(117,170,252,0.2)] px-3 py-2"> + <span className="font-mono text-[10px] text-[#556677]"> + Drag from connector to create dependency + </span> + </div> + </div> + ); +} + +// Trash Icon +function TrashIcon({ className }: { className?: string }) { + return ( + <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> + <path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> + </svg> + ); +} + interface DefinitionDetailPanelProps { definition: ChainContractDefinition; onClose: () => void; |
