diff options
| author | soryu <soryu@soryu.co> | 2026-02-04 12:04:58 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-02-04 12:04:58 +0000 |
| commit | 9521910b1fcbbc29c80b791e2c91d814030cb3cf (patch) | |
| tree | f159f5d4be4d8f79f9f089caf1359a8335bf7d2b /makima/frontend/src | |
| parent | 8692cfcc9567d5404f50aa4aec6ce1bae9ab26ed (diff) | |
| download | soryu-9521910b1fcbbc29c80b791e2c91d814030cb3cf.tar.gz soryu-9521910b1fcbbc29c80b791e2c91d814030cb3cf.zip | |
Improve chain DAG editor UX
- Fix drag lag by disabling CSS transitions during drag
- Add canvas right-click context menu to create definitions at position
- Auto-find free grid space for new definitions to avoid overlap
- Fix startChain API to send empty JSON body (backend expects JSON)
- Add PlusIcon for context menu
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'makima/frontend/src')
| -rw-r--r-- | makima/frontend/src/components/chains/ChainEditor.tsx | 121 | ||||
| -rw-r--r-- | makima/frontend/src/lib/api.ts | 2 |
2 files changed, 114 insertions, 9 deletions
diff --git a/makima/frontend/src/components/chains/ChainEditor.tsx b/makima/frontend/src/components/chains/ChainEditor.tsx index 09d590a..a4c8a39 100644 --- a/makima/frontend/src/components/chains/ChainEditor.tsx +++ b/makima/frontend/src/components/chains/ChainEditor.tsx @@ -67,11 +67,12 @@ export function ChainEditor({ toPoint: { x: number; y: number }; } | null>(null); - // Context menu state + // Context menu state (nodeId is null for canvas context menu) const [contextMenu, setContextMenu] = useState<{ x: number; y: number; - nodeId: string; + nodeId: string | null; + canvasPosition?: { gridX: number; gridY: number }; } | null>(null); // Load definitions when chain changes @@ -221,9 +222,41 @@ export function ChainEditor({ } }, [chain.id, onRefresh]); + // Position for new definition (set from canvas right-click) + const [newDefinitionPosition, setNewDefinitionPosition] = useState<{ x: number; y: number } | null>(null); + + // Find free space on the grid for new definitions + const findFreePosition = useCallback(() => { + if (!definitionGraph?.nodes || definitionGraph.nodes.length === 0) { + return { x: 0, y: 0 }; + } + + // Get all occupied positions + const occupied = new Set<string>(); + for (const node of definitionGraph.nodes) { + occupied.add(`${node.x},${node.y}`); + } + + // Find first free position by scanning row by row + for (let y = 0; y < 10; y++) { + for (let x = 0; x < 10; x++) { + if (!occupied.has(`${x},${y}`)) { + return { x, y }; + } + } + } + + // Fallback: place at the end of the last row + const maxY = Math.max(...definitionGraph.nodes.map((n) => n.y || 0)); + return { x: 0, y: maxY + 1 }; + }, [definitionGraph?.nodes]); + const handleAddDefinition = useCallback(async (req: AddContractDefinitionRequest) => { try { - await createChainDefinition(chain.id, req); + // Use specified position or find free space + const position = newDefinitionPosition || findFreePosition(); + const reqWithPosition = { ...req, editorX: position.x, editorY: position.y }; + await createChainDefinition(chain.id, reqWithPosition); // Reload definitions const [defs, defGraph] = await Promise.all([ listChainDefinitions(chain.id), @@ -232,11 +265,12 @@ export function ChainEditor({ setDefinitions(defs); setDefinitionGraph(defGraph); setShowAddDefinition(false); + setNewDefinitionPosition(null); } catch (err) { console.error("Failed to add definition:", err); setError(err instanceof Error ? err.message : "Failed to add definition"); } - }, [chain.id]); + }, [chain.id, newDefinitionPosition, findFreePosition]); const handleDeleteDefinition = useCallback(async (definitionId: string) => { if (!confirm("Are you sure you want to delete this definition?")) return; @@ -274,10 +308,39 @@ export function ChainEditor({ setContextMenu({ x: e.clientX, y: e.clientY, nodeId }); }, []); + // Canvas context menu (for creating new definitions) + const handleCanvasContextMenu = useCallback((e: React.MouseEvent) => { + if (!showDefinitions || chain.status !== "pending") return; + e.preventDefault(); + + const canvasRect = canvasRef.current?.getBoundingClientRect(); + if (!canvasRect) return; + + // Calculate grid position from click + const clickX = e.clientX - canvasRect.left; + const clickY = e.clientY - canvasRect.top; + const gridX = Math.round((clickX - CANVAS_PADDING) / (NODE_WIDTH + 60)); + const gridY = Math.round((clickY - CANVAS_PADDING) / (NODE_HEIGHT + 40)); + + setContextMenu({ + x: e.clientX, + y: e.clientY, + nodeId: null, + canvasPosition: { gridX: Math.max(0, gridX), gridY: Math.max(0, gridY) }, + }); + }, [showDefinitions, chain.status]); + const closeContextMenu = useCallback(() => { setContextMenu(null); }, []); + // Handle creating definition at canvas position + const handleCreateAtPosition = useCallback((gridX: number, gridY: number) => { + setNewDefinitionPosition({ x: gridX, y: gridY }); + setShowAddDefinition(true); + closeContextMenu(); + }, [closeContextMenu]); + // Handle canvas click to close context menu const handleCanvasClick = useCallback(() => { if (contextMenu) { @@ -525,6 +588,7 @@ export function ChainEditor({ cursor: draggedNode ? "grabbing" : edgeDrawing ? "crosshair" : "default", }} onClick={handleCanvasClick} + onContextMenu={handleCanvasContextMenu} onMouseMove={(e) => { if (draggedNode) handleDragMove(e); if (edgeDrawing) handleEdgeDrawMove(e); @@ -654,13 +718,15 @@ export function ChainEditor({ onMouseEnter={() => !draggedNode && setHoveredNode(node.id)} onMouseLeave={() => !draggedNode && setHoveredNode(null)} onMouseUp={() => edgeDrawing && handleEdgeDrawEnd(node.id)} - className={`absolute transition-all duration-150 ${ + className={`absolute ${ + isDragging ? "z-50 shadow-lg" : "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, @@ -877,11 +943,13 @@ export function ChainEditor({ x={contextMenu.x} y={contextMenu.y} nodeId={contextMenu.nodeId} - definition={definitions.find((d) => d.id === contextMenu.nodeId)} + canvasPosition={contextMenu.canvasPosition} + definition={contextMenu.nodeId ? definitions.find((d) => d.id === contextMenu.nodeId) : undefined} allDefinitions={definitions} onClose={closeContextMenu} onDelete={handleDeleteDefinition} onRemoveDependency={handleRemoveDependency} + onCreateAtPosition={handleCreateAtPosition} /> )} </div> @@ -892,23 +960,27 @@ export function ChainEditor({ interface ContextMenuProps { x: number; y: number; - nodeId: string; + nodeId: string | null; + canvasPosition?: { gridX: number; gridY: number }; definition?: ChainContractDefinition; allDefinitions: ChainContractDefinition[]; onClose: () => void; onDelete: (id: string) => void; onRemoveDependency: (nodeId: string, depName: string) => void; + onCreateAtPosition: (gridX: number, gridY: number) => void; } function ContextMenu({ x, y, nodeId, + canvasPosition, definition, allDefinitions: _allDefinitions, onClose, onDelete, onRemoveDependency, + onCreateAtPosition, }: ContextMenuProps) { const menuRef = useRef<HTMLDivElement>(null); @@ -932,7 +1004,29 @@ function ContextMenu({ return () => document.removeEventListener("keydown", handleKeyDown); }, [onClose]); - if (!definition) return null; + // Canvas context menu (no node selected) + if (!nodeId && canvasPosition) { + return ( + <div + ref={menuRef} + className="fixed z-50 min-w-40 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] shadow-xl rounded-lg overflow-hidden" + style={{ left: x, top: y }} + > + <div className="py-1"> + <button + onClick={() => onCreateAtPosition(canvasPosition.gridX, canvasPosition.gridY)} + className="w-full px-3 py-2 text-left font-mono text-xs text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)] transition-colors flex items-center gap-2" + > + <PlusIcon className="w-3 h-3" /> + Create Definition Here + </button> + </div> + </div> + ); + } + + // Node context menu requires both nodeId and definition + if (!nodeId || !definition) return null; const dependencies = definition.dependsOnNames || []; @@ -1006,6 +1100,15 @@ function TrashIcon({ className }: { className?: string }) { ); } +// Plus Icon +function PlusIcon({ className }: { className?: string }) { + return ( + <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> + <path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" /> + </svg> + ); +} + interface DefinitionDetailPanelProps { definition: ChainContractDefinition; onClose: () => void; diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts index 6a40aec..d68c1ad 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -3425,6 +3425,8 @@ export async function getChainDefinitionGraph( export async function startChain(chainId: string): Promise<StartChainResponse> { const res = await authFetch(`${API_BASE}/api/v1/chains/${chainId}/start`, { method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), }); if (!res.ok) { const error = await res.json().catch(() => ({ message: res.statusText })); |
