feat(ui): implement right-click context menu system for layers
Added comprehensive context menu system with: Context Menu Infrastructure: - Context menu store using Zustand for state management - Reusable ContextMenu component with positioning logic - Automatic viewport boundary detection and adjustment - Keyboard support (Escape to close) - Click-outside detection Layer Context Menu Features: - Duplicate Layer (with icon) - Move Up/Down (with disabled state when not possible) - Show/Hide Layer (dynamic label based on state) - Delete Layer (with confirmation, danger styling, disabled when only one layer) - Visual separators between action groups UX Enhancements: - Smooth fade-in animation - Proper z-indexing (9999) above all content - Focus management with keyboard navigation - Disabled state styling for unavailable actions - Danger state (red text) for destructive actions - Icon support for better visual identification Accessibility: - role="menu" and role="menuitem" attributes - aria-label for screen readers - aria-disabled for unavailable actions - Keyboard navigation support The context menu system is extensible and can be used for other components beyond layers (canvas, tools, etc.). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
123
components/ui/context-menu.tsx
Normal file
123
components/ui/context-menu.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useContextMenuStore } from '@/store/context-menu-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function ContextMenu() {
|
||||
const { isOpen, position, items, hideContextMenu } = useContextMenuStore();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close menu when clicking outside
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
hideContextMenu();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
hideContextMenu();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [isOpen, hideContextMenu]);
|
||||
|
||||
// Adjust position to keep menu on screen
|
||||
useEffect(() => {
|
||||
if (!isOpen || !position || !menuRef.current) return;
|
||||
|
||||
const menu = menuRef.current;
|
||||
const rect = menu.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
let { x, y } = position;
|
||||
|
||||
// Adjust horizontal position
|
||||
if (x + rect.width > viewportWidth) {
|
||||
x = viewportWidth - rect.width - 8;
|
||||
}
|
||||
|
||||
// Adjust vertical position
|
||||
if (y + rect.height > viewportHeight) {
|
||||
y = viewportHeight - rect.height - 8;
|
||||
}
|
||||
|
||||
menu.style.left = `${Math.max(8, x)}px`;
|
||||
menu.style.top = `${Math.max(8, y)}px`;
|
||||
}, [isOpen, position]);
|
||||
|
||||
if (!isOpen || !position) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop to capture clicks */}
|
||||
<div
|
||||
className="fixed inset-0 z-[9998]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Context Menu */}
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed z-[9999] min-w-[200px] bg-card border border-border rounded-md shadow-lg py-1 animate-fadeIn"
|
||||
style={{
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
}}
|
||||
role="menu"
|
||||
aria-label="Context menu"
|
||||
>
|
||||
{items.map((item, index) => {
|
||||
if (item.separator) {
|
||||
return (
|
||||
<div
|
||||
key={`separator-${index}`}
|
||||
className="h-px bg-border my-1"
|
||||
role="separator"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => {
|
||||
if (!item.disabled) {
|
||||
item.onClick();
|
||||
hideContextMenu();
|
||||
}
|
||||
}}
|
||||
disabled={item.disabled}
|
||||
className={cn(
|
||||
'flex items-center gap-3 w-full px-4 py-2 text-sm text-left transition-colors',
|
||||
'focus:outline-none focus:bg-accent',
|
||||
item.disabled
|
||||
? 'text-muted-foreground cursor-not-allowed opacity-50'
|
||||
: item.danger
|
||||
? 'text-destructive hover:bg-destructive/10'
|
||||
: 'hover:bg-accent'
|
||||
)}
|
||||
role="menuitem"
|
||||
aria-disabled={item.disabled}
|
||||
>
|
||||
{item.icon && <span className="flex-shrink-0">{item.icon}</span>}
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user