Files
paint-ui/components/layers/layers-panel.tsx
Sebastian Krüger 108dfb5cec 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>
2025-11-21 15:52:35 +01:00

179 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useLayerStore } from '@/store';
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,
updateLayerWithHistory,
duplicateLayerWithHistory,
} from '@/lib/layer-operations';
export function LayersPanel() {
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">
<h2 className="text-sm font-semibold text-card-foreground">Layers</h2>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{sortedLayers.length === 0 ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">No layers</p>
</div>
) : (
sortedLayers.map((layer) => (
<div
key={layer.id}
className={cn(
'group flex items-center gap-2 rounded-md border p-2 transition-colors cursor-pointer',
activeLayerId === layer.id
? 'border-primary bg-primary/10'
: '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"
onClick={(e) => {
e.stopPropagation();
updateLayerWithHistory(layer.id, { visible: !layer.visible }, 'Toggle Visibility');
}}
title="Toggle visibility"
>
{layer.visible ? (
<Eye className="h-4 w-4" />
) : (
<EyeOff className="h-4 w-4" />
)}
</button>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-card-foreground truncate">
{layer.name}
</p>
<p className="text-xs text-muted-foreground">
{layer.width} × {layer.height}
</p>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
className="shrink-0 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
duplicateLayerWithHistory(layer.id);
}}
title="Duplicate layer"
>
<Copy className="h-4 w-4" />
</button>
<button
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
if (layers.length > 1 && confirm('Delete this layer?')) {
deleteLayerWithHistory(layer.id);
}
}}
title="Delete layer"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
<div className="shrink-0 text-xs text-muted-foreground">
{Math.round(layer.opacity * 100)}%
</div>
</div>
))
)}
</div>
</div>
);
}