From 108dfb5cec426270ca87a7008a82c0a4bb7fc9de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Fri, 21 Nov 2025 15:52:35 +0100 Subject: [PATCH] feat(ui): implement right-click context menu system for layers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/layout.tsx | 2 + components/layers/layers-panel.tsx | 83 ++++++++++++++++++- components/ui/context-menu.tsx | 123 +++++++++++++++++++++++++++++ store/context-menu-store.ts | 40 ++++++++++ store/index.ts | 1 + 5 files changed, 247 insertions(+), 2 deletions(-) create mode 100644 components/ui/context-menu.tsx create mode 100644 store/context-menu-store.ts diff --git a/app/layout.tsx b/app/layout.tsx index 044d75f..8d73368 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from 'next'; import './globals.css'; import { ToastProvider } from '@/components/providers/toast-provider'; +import { ContextMenu } from '@/components/ui/context-menu'; export const metadata: Metadata = { title: 'Paint UI - Browser Image Editor', @@ -36,6 +37,7 @@ export default function RootLayout({ {children} + ); diff --git a/components/layers/layers-panel.tsx b/components/layers/layers-panel.tsx index 1fe246a..b1c684e 100644 --- a/components/layers/layers-panel.tsx +++ b/components/layers/layers-panel.tsx @@ -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: , + onClick: () => duplicateLayerWithHistory(layerId), + }, + { + separator: true, + label: '', + onClick: () => {}, + }, + { + label: 'Move Up', + icon: , + onClick: () => handleMoveLayer(layerId, 'up'), + disabled: !canMoveUp, + }, + { + label: 'Move Down', + icon: , + onClick: () => handleMoveLayer(layerId, 'down'), + disabled: !canMoveDown, + }, + { + separator: true, + label: '', + onClick: () => {}, + }, + { + label: layer.visible ? 'Hide Layer' : 'Show Layer', + icon: layer.visible ? : , + onClick: () => updateLayerWithHistory(layerId, { visible: !layer.visible }, 'Toggle Visibility'), + }, + { + separator: true, + label: '', + onClick: () => {}, + }, + { + label: 'Delete Layer', + icon: , + onClick: () => { + if (confirm('Delete this layer?')) { + deleteLayerWithHistory(layerId); + } + }, + disabled: !canDelete, + danger: true, + }, + ]); + }; + return (
@@ -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)} > + ); + })} +
+ + ); +} diff --git a/store/context-menu-store.ts b/store/context-menu-store.ts new file mode 100644 index 0000000..1c94091 --- /dev/null +++ b/store/context-menu-store.ts @@ -0,0 +1,40 @@ +import { create } from 'zustand'; + +export interface ContextMenuItem { + label: string; + icon?: React.ReactNode; + onClick: () => void; + disabled?: boolean; + separator?: boolean; + danger?: boolean; +} + +interface ContextMenuState { + isOpen: boolean; + position: { x: number; y: number } | null; + items: ContextMenuItem[]; + showContextMenu: (x: number, y: number, items: ContextMenuItem[]) => void; + hideContextMenu: () => void; +} + +export const useContextMenuStore = create((set) => ({ + isOpen: false, + position: null, + items: [], + + showContextMenu: (x, y, items) => { + set({ + isOpen: true, + position: { x, y }, + items, + }); + }, + + hideContextMenu: () => { + set({ + isOpen: false, + position: null, + items: [], + }); + }, +})); diff --git a/store/index.ts b/store/index.ts index 6904d0c..04e7911 100644 --- a/store/index.ts +++ b/store/index.ts @@ -10,3 +10,4 @@ export * from './shape-store'; export * from './text-store'; export * from './ui-store'; export * from './toast-store'; +export * from './context-menu-store';