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,6 +1,7 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
import { ToastProvider } from '@/components/providers/toast-provider';
|
import { ToastProvider } from '@/components/providers/toast-provider';
|
||||||
|
import { ContextMenu } from '@/components/ui/context-menu';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Paint UI - Browser Image Editor',
|
title: 'Paint UI - Browser Image Editor',
|
||||||
@@ -36,6 +37,7 @@ export default function RootLayout({
|
|||||||
<body className="min-h-screen antialiased overflow-hidden">
|
<body className="min-h-screen antialiased overflow-hidden">
|
||||||
{children}
|
{children}
|
||||||
<ToastProvider />
|
<ToastProvider />
|
||||||
|
<ContextMenu />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useLayerStore } from '@/store';
|
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 { cn } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
deleteLayerWithHistory,
|
deleteLayerWithHistory,
|
||||||
@@ -10,11 +11,88 @@ import {
|
|||||||
} from '@/lib/layer-operations';
|
} from '@/lib/layer-operations';
|
||||||
|
|
||||||
export function LayersPanel() {
|
export function LayersPanel() {
|
||||||
const { layers, activeLayerId, setActiveLayer } = useLayerStore();
|
const { layers, activeLayerId, setActiveLayer, reorderLayer } = useLayerStore();
|
||||||
|
const { showContextMenu } = useContextMenuStore();
|
||||||
|
|
||||||
// Sort layers by order (highest first)
|
// Sort layers by order (highest first)
|
||||||
const sortedLayers = [...layers].sort((a, b) => b.order - a.order);
|
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 (
|
return (
|
||||||
<div className="flex h-full flex-col bg-card">
|
<div className="flex h-full flex-col bg-card">
|
||||||
<div className="border-b border-border p-3">
|
<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'
|
: 'border-border hover:border-primary/50 hover:bg-accent/50'
|
||||||
)}
|
)}
|
||||||
onClick={() => setActiveLayer(layer.id)}
|
onClick={() => setActiveLayer(layer.id)}
|
||||||
|
onContextMenu={(e) => handleContextMenu(e, layer.id)}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||||
|
|||||||
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
store/context-menu-store.ts
Normal file
40
store/context-menu-store.ts
Normal file
@@ -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<ContextMenuState>((set) => ({
|
||||||
|
isOpen: false,
|
||||||
|
position: null,
|
||||||
|
items: [],
|
||||||
|
|
||||||
|
showContextMenu: (x, y, items) => {
|
||||||
|
set({
|
||||||
|
isOpen: true,
|
||||||
|
position: { x, y },
|
||||||
|
items,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
hideContextMenu: () => {
|
||||||
|
set({
|
||||||
|
isOpen: false,
|
||||||
|
position: null,
|
||||||
|
items: [],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -10,3 +10,4 @@ export * from './shape-store';
|
|||||||
export * from './text-store';
|
export * from './text-store';
|
||||||
export * from './ui-store';
|
export * from './ui-store';
|
||||||
export * from './toast-store';
|
export * from './toast-store';
|
||||||
|
export * from './context-menu-store';
|
||||||
|
|||||||
Reference in New Issue
Block a user