feat(phase-11): implement comprehensive non-destructive layer effects system

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>
This commit is contained in:
2025-11-21 17:42:36 +01:00
parent 63a6801155
commit 9aa6e2d5d9
6 changed files with 1427 additions and 64 deletions

View File

@@ -9,6 +9,7 @@ import {
updateLayerWithHistory,
duplicateLayerWithHistory,
} from '@/lib/layer-operations';
import { LayerEffectsPanel } from './layer-effects-panel';
export function LayersPanel() {
const { layers, activeLayerId, setActiveLayer, reorderLayer } = useLayerStore();
@@ -99,79 +100,84 @@ export function LayersPanel() {
<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" />
<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'
)}
</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">
onClick={() => setActiveLayer(layer.id)}
onContextMenu={(e) => handleContextMenu(e, layer.id)}
>
<button
className="shrink-0 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
duplicateLayerWithHistory(layer.id);
updateLayerWithHistory(layer.id, { visible: !layer.visible }, 'Toggle Visibility');
}}
title="Duplicate layer"
title="Toggle visibility"
>
<Copy className="h-4 w-4" />
{layer.visible ? (
<Eye className="h-4 w-4" />
) : (
<EyeOff 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 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>
{/* Layer Effects Panel for Active Layer */}
{activeLayerId && <LayerEffectsPanel layerId={activeLayerId} />}
</div>
</div>
);