From 80c82f0b48f2f3f7a8e5750adee5651f7d8a54d4 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 4 Apr 2026 17:26:59 +0000 Subject: [PATCH] feat: add ContextMenu component for network diagram editor Charcoal-styled context menu with action factories for node and canvas variants. Viewport-clamped positioning, auto-dismiss on click outside, escape, or scroll. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/network/ContextMenu.tsx | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 frontend/src/components/network/ContextMenu.tsx diff --git a/frontend/src/components/network/ContextMenu.tsx b/frontend/src/components/network/ContextMenu.tsx new file mode 100644 index 00000000..ff798b65 --- /dev/null +++ b/frontend/src/components/network/ContextMenu.tsx @@ -0,0 +1,106 @@ +import { useEffect, useRef } from 'react' +import { Copy, CopyPlus, Trash2, ClipboardPaste, BoxSelect, Maximize2 } from 'lucide-react' +import { cn } from '@/lib/utils' + +interface MenuAction { + label: string + icon: React.ElementType + shortcut: string + onClick: () => void + disabled?: boolean +} + +interface ContextMenuProps { + position: { x: number; y: number } + actions: MenuAction[] + onClose: () => void +} + +export function ContextMenu({ position, actions, onClose }: ContextMenuProps) { + const menuRef = useRef(null) + + const clampedPosition = { ...position } + if (typeof window !== 'undefined') { + const menuWidth = 192 + const menuHeight = actions.length * 36 + 8 + if (clampedPosition.x + menuWidth > window.innerWidth) { + clampedPosition.x = window.innerWidth - menuWidth - 8 + } + if (clampedPosition.y + menuHeight > window.innerHeight) { + clampedPosition.y = window.innerHeight - menuHeight - 8 + } + } + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as HTMLElement)) { + onClose() + } + } + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + const handleScroll = () => onClose() + + document.addEventListener('mousedown', handleClickOutside) + document.addEventListener('keydown', handleEscape) + document.addEventListener('scroll', handleScroll, true) + return () => { + document.removeEventListener('mousedown', handleClickOutside) + document.removeEventListener('keydown', handleEscape) + document.removeEventListener('scroll', handleScroll, true) + } + }, [onClose]) + + return ( +
+ {actions.map((action) => ( + + ))} +
+ ) +} + +export function getNodeMenuActions(handlers: { + onCopy: () => void + onDuplicate: () => void + onDelete: () => void +}): MenuAction[] { + return [ + { label: 'Copy', icon: Copy, shortcut: 'Ctrl+C', onClick: handlers.onCopy }, + { label: 'Duplicate', icon: CopyPlus, shortcut: 'Ctrl+D', onClick: handlers.onDuplicate }, + { label: 'Delete', icon: Trash2, shortcut: 'Del', onClick: handlers.onDelete }, + ] +} + +export function getCanvasMenuActions(handlers: { + onPaste: () => void + onSelectAll: () => void + onFitView: () => void + hasClipboard: boolean +}): MenuAction[] { + return [ + { label: 'Paste', icon: ClipboardPaste, shortcut: 'Ctrl+V', onClick: handlers.onPaste, disabled: !handlers.hasClipboard }, + { label: 'Select All', icon: BoxSelect, shortcut: 'Ctrl+A', onClick: handlers.onSelectAll }, + { label: 'Fit View', icon: Maximize2, shortcut: '⌘⇧F', onClick: handlers.onFitView }, + ] +}