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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user