summaryrefslogtreecommitdiff
path: root/makima/frontend/src/components/chains
diff options
context:
space:
mode:
Diffstat (limited to 'makima/frontend/src/components/chains')
-rw-r--r--makima/frontend/src/components/chains/ChainEditor.tsx419
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;