Adds Photoshop-style layer effects with full non-destructive editing support: **Core Architecture:** - Type system with 10 effect types and discriminated unions - Zustand store with Map-based storage and localStorage persistence - Canvas-based rendering engine with intelligent padding calculation - Effects applied at render time without modifying original layer data **Implemented Effects (6 core effects):** - Drop Shadow - Customizable shadow with angle, distance, size, and spread - Outer Glow - Soft glow around layer edges with spread control - Inner Shadow - Shadow effect inside layer boundaries - Inner Glow - Inward glow from edges with choke parameter - Stroke/Outline - Configurable stroke with position options - Color Overlay - Solid color overlay with blend modes **Rendering Engine Features:** - Smart padding calculation for effects extending beyond layer bounds - Effect stacking: Background → Layer → Modifying → Overlay - Canvas composition for complex effects (inner shadow/glow) - Global light system for consistent shadow angles - Blend mode support for all effects - Opacity control per effect **User Interface:** - Integrated effects panel in layers sidebar - Collapsible panel with effect count badge - Add effect dropdown with 6 effect types - Individual effect controls (visibility toggle, duplicate, delete) - Master enable/disable for all layer effects - Visual feedback with toast notifications **Store Features:** - Per-layer effects configuration - Effect reordering support - Copy/paste effects between layers - Duplicate effects within layer - Persistent storage across sessions - Global light angle/altitude management **Technical Implementation:** - Non-destructive: Original layer canvas never modified - Performance optimized with canvas padding only where needed - Type-safe with full TypeScript discriminated unions - Effects rendered in optimal order for visual quality - Map serialization for Zustand persistence **New Files:** - types/layer-effects.ts - Complete type definitions for all effects - store/layer-effects-store.ts - Zustand store with persistence - lib/layer-effects-renderer.ts - Canvas rendering engine - components/layers/layer-effects-panel.tsx - UI controls **Modified Files:** - components/canvas/canvas-with-tools.tsx - Integrated effects rendering - components/layers/layers-panel.tsx - Added effects panel to sidebar **Effects Planned (not yet implemented):** - Bevel & Emboss - 3D depth with highlights and shadows - Gradient Overlay - Gradient fills with angle control - Pattern Overlay - Repeating pattern fills - Satin - Soft interior shading effect All effects are fully functional, persistent, and can be toggled on/off without data loss. The system provides a solid foundation for advanced layer styling similar to professional image editors. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
185 lines
6.4 KiB
TypeScript
185 lines
6.4 KiB
TypeScript
'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';
|
||
import { LayerEffectsPanel } from './layer-effects-panel';
|
||
|
||
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">
|
||
<div className="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>
|
||
|
||
{/* Layer Effects Panel for Active Layer */}
|
||
{activeLayerId && <LayerEffectsPanel layerId={activeLayerId} />}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|