Files
rack-planner/client/src/components/mapper/ContextMenu.tsx
jason 0b4e9ea1e5 Add Service Mapper context menus, node edit modal, and edge type toggle
- Right-click on canvas → add any node type at cursor position
- Right-click on node → edit, duplicate, or delete
- Right-click on edge → toggle animation, set edge type (bezier/smooth/step/straight), delete
- Double-click a node → NodeEditModal (label, accent color, rack module link)
- ContextMenu component: viewport-clamped, closes on outside click or Escape
- All actions persist to API; React Flow state updated optimistically

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 22:00:27 -05:00

88 lines
2.6 KiB
TypeScript

import { useEffect, useRef, type ReactNode } from 'react';
interface MenuItem {
label: string;
icon?: ReactNode;
onClick: () => void;
variant?: 'default' | 'danger';
checked?: boolean;
separator?: false;
}
interface SeparatorItem {
separator: true;
}
export type ContextMenuEntry = MenuItem | SeparatorItem;
interface ContextMenuProps {
x: number;
y: number;
items: ContextMenuEntry[];
onClose: () => void;
}
export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleMouseDown(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
document.addEventListener('mousedown', handleMouseDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handleMouseDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [onClose]);
// Clamp so menu doesn't overflow right/bottom edge
const menuWidth = 192;
const menuHeight = items.length * 34;
const clampedX = Math.min(x, window.innerWidth - menuWidth - 8);
const clampedY = Math.min(y, window.innerHeight - menuHeight - 8);
return (
<div
ref={menuRef}
style={{ top: clampedY, left: clampedX, zIndex: 9999 }}
className="fixed min-w-[192px] bg-slate-800 border border-slate-600 rounded-lg shadow-2xl py-1 text-sm"
>
{items.map((item, i) => {
if ('separator' in item && item.separator) {
return <div key={i} className="my-1 border-t border-slate-700" />;
}
const mi = item as MenuItem;
return (
<button
key={i}
className={`w-full text-left flex items-center gap-2 px-3 py-1.5 transition-colors hover:bg-slate-700 ${
mi.variant === 'danger'
? 'text-red-400 hover:text-red-300'
: 'text-slate-200'
}`}
onClick={() => {
mi.onClick();
onClose();
}}
>
{mi.icon && <span className="shrink-0 w-4 text-slate-400">{mi.icon}</span>}
<span className="flex-1">{mi.label}</span>
{mi.checked !== undefined && (
<span className={`text-xs ${mi.checked ? 'text-blue-400' : 'text-slate-600'}`}>
{mi.checked ? '✓' : ''}
</span>
)}
</button>
);
})}
</div>
);
}