diff --git a/frontend/src/components/canvas/ContextMenu.tsx b/frontend/src/components/canvas/ContextMenu.tsx new file mode 100644 index 0000000..f5afa42 --- /dev/null +++ b/frontend/src/components/canvas/ContextMenu.tsx @@ -0,0 +1,131 @@ +import { ChevronRight } from 'lucide-react' +import { useEffect, useRef, type LucideIcon } from 'react' +import ReactDOM from 'react-dom' + +export type MenuItem = + | { kind: 'action'; label: string; icon?: LucideIcon; danger?: boolean; onClick: () => void } + | { kind: 'submenu'; label: string; icon?: LucideIcon; items: MenuItem[] } + | { kind: 'separator' } + +interface ContextMenuProps { + x: number + y: number + items: MenuItem[] + onClose: () => void +} + +function MenuItems({ items, onClose }: { items: MenuItem[]; onClose: () => void }) { + return ( + <> + {items.map((item, i) => { + if (item.kind === 'separator') { + return
+ } + if (item.kind === 'submenu') { + const Icon = item.icon + return ( +
+
+ {Icon && } + {item.label} + +
+
+ {item.items.map((sub, j) => { + if (sub.kind === 'action') { + const SubIcon = sub.icon + return ( + + ) + } + return null + })} +
+
+ ) + } + // kind === 'action' + const Icon = item.icon + return ( + + ) + })} + + ) +} + +export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) { + const menuRef = useRef(null) + + useEffect(() => { + const handleMouseDown = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + onClose() + } + } + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + const handleScroll = () => onClose() + + document.addEventListener('mousedown', handleMouseDown) + document.addEventListener('keydown', handleKeyDown) + window.addEventListener('scroll', handleScroll, true) + return () => { + document.removeEventListener('mousedown', handleMouseDown) + document.removeEventListener('keydown', handleKeyDown) + window.removeEventListener('scroll', handleScroll, true) + } + }, [onClose]) + + // Clamp to viewport so the menu never renders off-screen + const vpW = window.innerWidth + const vpH = window.innerHeight + const menuW = 192 // min-w-48 + const menuH = 300 // generous estimate; real height unknown until rendered + const left = Math.min(x, vpW - menuW - 8) + const top = Math.min(y, vpH - menuH - 8) + + const menu = ( +
e.preventDefault()} + > + +
+ ) + + return ReactDOM.createPortal(menu, document.body) +} diff --git a/frontend/src/components/detail/EdgePanel.tsx b/frontend/src/components/detail/EdgePanel.tsx index 5e4510c..f541402 100644 --- a/frontend/src/components/detail/EdgePanel.tsx +++ b/frontend/src/components/detail/EdgePanel.tsx @@ -1,4 +1,4 @@ -import { ArrowRight } from 'lucide-react' +import { ArrowRight, Trash2 } from 'lucide-react' import { useCanvasStore } from '../../store/canvasStore' import { NODE_TYPE_META } from '../nodes/constants' @@ -6,6 +6,8 @@ export function EdgePanel() { const selectedEdgeId = useCanvasStore((s) => s.selectedEdgeId) const edges = useCanvasStore((s) => s.edges) const nodes = useCanvasStore((s) => s.nodes) + const deleteEdge = useCanvasStore((s) => s.deleteEdge) + const clearSelection = useCanvasStore((s) => s.clearSelection) const edge = edges.find((e) => e.id === selectedEdgeId) if (!edge) return null @@ -15,41 +17,55 @@ export function EdgePanel() { const isResource = edge.type === 'resource' return ( -
- {/* Edge category badge */} -
- - {isResource ? 'Resource' : 'Data'} - - - {isResource ? 'Static capability binding' : 'Runtime message flow'} - -
+
+
+ {/* Edge category badge */} +
+ + {isResource ? 'Resource' : 'Data'} + + + {isResource ? 'Static capability binding' : 'Runtime message flow'} + +
- {/* Source → Target */} -
- -
- + {/* Source → Target */} +
+ +
+ +
+
- +
+ + {/* Delete action */} +
+
) diff --git a/frontend/src/components/shell/CanvasContainer.tsx b/frontend/src/components/shell/CanvasContainer.tsx index bdfd131..22d542e 100644 --- a/frontend/src/components/shell/CanvasContainer.tsx +++ b/frontend/src/components/shell/CanvasContainer.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { ReactFlow, ReactFlowProvider, @@ -26,6 +26,7 @@ import { useCanvasStore } from '../../store/canvasStore' import { useUiStore } from '../../store/uiStore' import { useProgram, useKnowledgeEntries, useRefreshKnowledge } from '../../api/hooks' import { GraphPreviewOverlay } from '../canvas/GraphPreviewOverlay' +import { ContextMenu, type MenuItem } from '../canvas/ContextMenu' // --------------------------------------------------------------------------- // KnowledgeStalenessBar — shown when any entry scanned_at is >24h old @@ -144,6 +145,9 @@ function CanvasInner() { const setSelectedNode = useCanvasStore((s) => s.setSelectedNode) const setSelectedEdge = useCanvasStore((s) => s.setSelectedEdge) const clearSelection = useCanvasStore((s) => s.clearSelection) + const deleteNodeFromStore = useCanvasStore((s) => s.deleteNode) + const duplicateNodeFromStore = useCanvasStore((s) => s.duplicateNode) + const deleteEdgeFromStore = useCanvasStore((s) => s.deleteEdge) const snapshot = useCanvasStore((s) => s.snapshot) const triggerFitView = useCanvasStore((s) => s.triggerFitView) const setTriggerFitView = useCanvasStore((s) => s.setTriggerFitView) @@ -232,17 +236,119 @@ function CanvasInner() { [isOperate, screenToFlowPosition, addNodeToStore], ) + // --------------------------------------------------------------------------- + // Context menu state + // --------------------------------------------------------------------------- + + const [contextMenu, setContextMenu] = useState<{ + type: 'pane' | 'node' | 'edge' + x: number + y: number + flowPosition?: { x: number; y: number } + targetId?: string + } | null>(null) + + const closeContextMenu = useCallback(() => setContextMenu(null), []) + + const onPaneContextMenu = useCallback( + (e: React.MouseEvent) => { + if (isOperate) return + e.preventDefault() + const flowPosition = screenToFlowPosition({ x: e.clientX, y: e.clientY }) + setContextMenu({ type: 'pane', x: e.clientX, y: e.clientY, flowPosition }) + }, + [isOperate, screenToFlowPosition], + ) + + const onNodeContextMenu = useCallback( + (e: React.MouseEvent, node: Node) => { + if (isOperate) return + e.preventDefault() + setContextMenu({ type: 'node', x: e.clientX, y: e.clientY, targetId: node.id }) + }, + [isOperate], + ) + + const onEdgeContextMenu = useCallback( + (e: React.MouseEvent, edge: Edge) => { + if (isOperate) return + e.preventDefault() + setContextMenu({ type: 'edge', x: e.clientX, y: e.clientY, targetId: edge.id }) + }, + [isOperate], + ) + const onNodeClick: NodeMouseHandler> = useCallback( - (_e, node) => setSelectedNode(node.id), - [setSelectedNode], + (_e, node) => { setSelectedNode(node.id); closeContextMenu() }, + [setSelectedNode, closeContextMenu], ) const onEdgeClick: EdgeMouseHandler = useCallback( - (_e, edge) => setSelectedEdge(edge.id), - [setSelectedEdge], + (_e, edge) => { setSelectedEdge(edge.id); closeContextMenu() }, + [setSelectedEdge, closeContextMenu], ) - const onPaneClick = useCallback(() => clearSelection(), [clearSelection]) + const onPaneClick = useCallback(() => { clearSelection(); closeContextMenu() }, [clearSelection, closeContextMenu]) + + // --------------------------------------------------------------------------- + // Context menu items + // --------------------------------------------------------------------------- + + const contextMenuItems = useMemo((): MenuItem[] => { + if (!contextMenu) return [] + + if (contextMenu.type === 'pane') { + const nodeTypeKeys = Object.keys(NODE_TYPE_META) as (keyof typeof NODE_TYPE_META)[] + return [ + { + kind: 'submenu', + label: 'Add Node', + items: nodeTypeKeys.map((type) => ({ + kind: 'action' as const, + label: NODE_TYPE_META[type].label, + icon: NODE_TYPE_META[type].icon, + onClick: () => { + if (!contextMenu.flowPosition) return + const id = crypto.randomUUID() + addNodeToStore(buildDefaultNode(id, type, contextMenu.flowPosition)) + }, + })), + }, + ] + } + + if (contextMenu.type === 'node' && contextMenu.targetId) { + const targetId = contextMenu.targetId + return [ + { + kind: 'action', + label: 'Duplicate', + onClick: () => duplicateNodeFromStore(targetId), + }, + { kind: 'separator' }, + { + kind: 'action', + label: 'Delete', + danger: true, + onClick: () => { deleteNodeFromStore(targetId); clearSelection() }, + }, + ] + } + + if (contextMenu.type === 'edge' && contextMenu.targetId) { + const targetId = contextMenu.targetId + return [ + { + kind: 'action', + label: 'Delete', + danger: true, + onClick: () => { deleteEdgeFromStore(targetId); clearSelection() }, + }, + ] + } + + return [] + }, [contextMenu, addNodeToStore, deleteNodeFromStore, duplicateNodeFromStore, deleteEdgeFromStore, clearSelection]) // Animate data edges whose source node is currently running const animatedEdges = useMemo( @@ -275,6 +381,9 @@ function CanvasInner() { onNodeDragStart={isOperate ? undefined : onNodeDragStart} onEdgeClick={onEdgeClick} onPaneClick={onPaneClick} + onPaneContextMenu={onPaneContextMenu} + onNodeContextMenu={onNodeContextMenu} + onEdgeContextMenu={onEdgeContextMenu} nodesDraggable={!isOperate} nodesConnectable={!isOperate} edgesReconnectable={!isOperate} @@ -307,6 +416,15 @@ function CanvasInner() { {/* Graph preview overlay — shown when the assistant proposes a graph */} + + {contextMenu && ( + + )}
) } diff --git a/frontend/src/store/canvasStore.ts b/frontend/src/store/canvasStore.ts index f05be28..e5f92d1 100644 --- a/frontend/src/store/canvasStore.ts +++ b/frontend/src/store/canvasStore.ts @@ -102,6 +102,13 @@ interface CanvasState { setHoveredNodeId: (id: string | null) => void /** Apply a GraphDiff as a single undoable batch (snapshot → remove → add → modify). */ applyGraphDiff: (diff: GraphDiff) => void + + /** Delete a node by id (undoable via applyNodeChanges). */ + deleteNode: (id: string) => void + /** Duplicate a node — clones it with a new id, offset position (+40, +40), and cleared selection. */ + duplicateNode: (id: string) => void + /** Delete an edge by id (undoable via applyEdgeChanges). */ + deleteEdge: (id: string) => void } export const useCanvasStore = create((set, get) => ({ @@ -338,6 +345,27 @@ export const useCanvasStore = create((set, get) => ({ markDirty: () => set({ isDirty: true }), setHoveredNodeId: (id) => set({ hoveredNodeId: id }), + deleteNode: (id) => { + get().applyNodeChanges([{ type: 'remove', id }]) + }, + + duplicateNode: (id) => { + const node = get().nodes.find((n) => n.id === id) + if (!node) return + const cloned: Node = { + ...node, + id: crypto.randomUUID(), + selected: false, + position: { x: node.position.x + 40, y: node.position.y + 40 }, + data: { ...node.data, nodeType: { ...node.data.nodeType, config: { ...node.data.nodeType.config } } as typeof node.data.nodeType }, + } + get().addNode(cloned) + }, + + deleteEdge: (id) => { + get().applyEdgeChanges([{ type: 'remove', id }]) + }, + applyGraphDiff: (diff) => { get().snapshot() set((s) => {