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:
2025-11-21 15:52:35 +01:00
parent b2a0b92209
commit 108dfb5cec
5 changed files with 247 additions and 2 deletions

View File

@@ -1,7 +1,8 @@
'use client';
import { useLayerStore } from '@/store';
import { Eye, EyeOff, Trash2, Copy } from 'lucide-react';
import { useContextMenuStore } from '@/store/context-menu-store';
import { Eye, EyeOff, Trash2, Copy, Layers, MoveUp, MoveDown } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
deleteLayerWithHistory,
@@ -10,11 +11,88 @@ import {
} from '@/lib/layer-operations';
export function LayersPanel() {
const { layers, activeLayerId, setActiveLayer } = useLayerStore();
const { layers, activeLayerId, setActiveLayer, reorderLayer } = useLayerStore();
const { showContextMenu } = useContextMenuStore();
// Sort layers by order (highest first)
const sortedLayers = [...layers].sort((a, b) => b.order - a.order);
const handleMoveLayer = (layerId: string, direction: 'up' | 'down') => {
const layer = layers.find((l) => l.id === layerId);
if (!layer) return;
const layerIndex = sortedLayers.findIndex((l) => l.id === layerId);
if (direction === 'up' && layerIndex > 0) {
const targetLayer = sortedLayers[layerIndex - 1];
reorderLayer(layerId, targetLayer.order);
} else if (direction === 'down' && layerIndex < sortedLayers.length - 1) {
const targetLayer = sortedLayers[layerIndex + 1];
reorderLayer(layerId, targetLayer.order);
}
};
const handleContextMenu = (e: React.MouseEvent, layerId: string) => {
e.preventDefault();
const layer = layers.find((l) => l.id === layerId);
if (!layer) return;
const layerIndex = sortedLayers.findIndex((l) => l.id === layerId);
const canMoveUp = layerIndex > 0;
const canMoveDown = layerIndex < sortedLayers.length - 1;
const canDelete = layers.length > 1;
showContextMenu(e.clientX, e.clientY, [
{
label: 'Duplicate Layer',
icon: <Copy className="h-4 w-4" />,
onClick: () => duplicateLayerWithHistory(layerId),
},
{
separator: true,
label: '',
onClick: () => {},
},
{
label: 'Move Up',
icon: <MoveUp className="h-4 w-4" />,
onClick: () => handleMoveLayer(layerId, 'up'),
disabled: !canMoveUp,
},
{
label: 'Move Down',
icon: <MoveDown className="h-4 w-4" />,
onClick: () => handleMoveLayer(layerId, 'down'),
disabled: !canMoveDown,
},
{
separator: true,
label: '',
onClick: () => {},
},
{
label: layer.visible ? 'Hide Layer' : 'Show Layer',
icon: layer.visible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />,
onClick: () => updateLayerWithHistory(layerId, { visible: !layer.visible }, 'Toggle Visibility'),
},
{
separator: true,
label: '',
onClick: () => {},
},
{
label: 'Delete Layer',
icon: <Trash2 className="h-4 w-4" />,
onClick: () => {
if (confirm('Delete this layer?')) {
deleteLayerWithHistory(layerId);
}
},
disabled: !canDelete,
danger: true,
},
]);
};
return (
<div className="flex h-full flex-col bg-card">
<div className="border-b border-border p-3">
@@ -37,6 +115,7 @@ export function LayersPanel() {
: 'border-border hover:border-primary/50 hover:bg-accent/50'
)}
onClick={() => setActiveLayer(layer.id)}
onContextMenu={(e) => handleContextMenu(e, layer.id)}
>
<button
className="shrink-0 text-muted-foreground hover:text-foreground"