Files
paint-ui/components/layers/layer-effects-panel.tsx
Sebastian Krüger 9aa6e2d5d9 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>
2025-11-21 17:42:36 +01:00

279 lines
9.9 KiB
TypeScript

'use client';
import { useState } from 'react';
import { useLayerEffectsStore } from '@/store/layer-effects-store';
import { useToastStore } from '@/store/toast-store';
import type { EffectType, LayerEffect } from '@/types/layer-effects';
import {
Sparkles,
Eye,
EyeOff,
Trash2,
Copy,
Plus,
Settings,
ChevronDown,
ChevronRight,
} from 'lucide-react';
import { cn } from '@/lib/utils';
interface LayerEffectsPanelProps {
layerId: string;
}
const EFFECT_NAMES: Record<EffectType, string> = {
dropShadow: 'Drop Shadow',
innerShadow: 'Inner Shadow',
outerGlow: 'Outer Glow',
innerGlow: 'Inner Glow',
stroke: 'Stroke',
bevel: 'Bevel & Emboss',
colorOverlay: 'Color Overlay',
gradientOverlay: 'Gradient Overlay',
patternOverlay: 'Pattern Overlay',
satin: 'Satin',
};
const EFFECT_ICONS: Record<EffectType, JSX.Element> = {
dropShadow: <Sparkles className="h-3 w-3" />,
innerShadow: <Sparkles className="h-3 w-3" />,
outerGlow: <Sparkles className="h-3 w-3" />,
innerGlow: <Sparkles className="h-3 w-3" />,
stroke: <Sparkles className="h-3 w-3" />,
bevel: <Sparkles className="h-3 w-3" />,
colorOverlay: <Sparkles className="h-3 w-3" />,
gradientOverlay: <Sparkles className="h-3 w-3" />,
patternOverlay: <Sparkles className="h-3 w-3" />,
satin: <Sparkles className="h-3 w-3" />,
};
export function LayerEffectsPanel({ layerId }: LayerEffectsPanelProps) {
const {
getLayerEffects,
addEffect,
removeEffect,
toggleEffect,
duplicateEffect,
setLayerEffectsEnabled,
} = useLayerEffectsStore();
const { addToast } = useToastStore();
const [isExpanded, setIsExpanded] = useState(true);
const [showAddMenu, setShowAddMenu] = useState(false);
const effectsConfig = getLayerEffects(layerId);
const hasEffects = effectsConfig && effectsConfig.effects.length > 0;
const handleAddEffect = (effectType: EffectType) => {
addEffect(layerId, effectType);
setShowAddMenu(false);
addToast(`Added ${EFFECT_NAMES[effectType]}`, 'success');
};
const handleRemoveEffect = (effectId: string, effectType: EffectType) => {
removeEffect(layerId, effectId);
addToast(`Removed ${EFFECT_NAMES[effectType]}`, 'success');
};
const handleToggleEffect = (effectId: string) => {
toggleEffect(layerId, effectId);
};
const handleDuplicateEffect = (effectId: string, effectType: EffectType) => {
duplicateEffect(layerId, effectId);
addToast(`Duplicated ${EFFECT_NAMES[effectType]}`, 'success');
};
const handleToggleAll = () => {
if (effectsConfig) {
setLayerEffectsEnabled(layerId, !effectsConfig.enabled);
}
};
if (!hasEffects && !isExpanded) {
return (
<div className="border-t border-border p-2">
<button
onClick={() => setIsExpanded(true)}
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors w-full"
>
<ChevronRight className="h-3 w-3" />
<Sparkles className="h-3 w-3" />
<span>Layer Effects</span>
</button>
</div>
);
}
return (
<div className="border-t border-border">
{/* Header */}
<div className="flex items-center justify-between p-2 bg-accent/50">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-2 text-xs font-medium text-card-foreground hover:text-foreground transition-colors"
>
{isExpanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
<Sparkles className="h-3 w-3" />
<span>Layer Effects</span>
{hasEffects && (
<span className="text-xs text-muted-foreground">
({effectsConfig.effects.length})
</span>
)}
</button>
{hasEffects && (
<button
onClick={handleToggleAll}
className="p-1 hover:bg-accent rounded transition-colors"
title={effectsConfig.enabled ? 'Disable all effects' : 'Enable all effects'}
>
{effectsConfig.enabled ? (
<Eye className="h-3 w-3" />
) : (
<EyeOff className="h-3 w-3" />
)}
</button>
)}
</div>
{isExpanded && (
<div className="p-2 space-y-1">
{/* Effects List */}
{hasEffects && (
<div className="space-y-1 mb-2">
{effectsConfig.effects.map((effect) => (
<div
key={effect.id}
className={cn(
'group flex items-center gap-2 p-2 rounded border transition-colors',
effect.enabled
? 'border-border bg-background'
: 'border-border/50 bg-muted/30'
)}
>
{/* Effect Icon & Name */}
<div className="flex items-center gap-2 flex-1 min-w-0">
<span className="text-muted-foreground">
{EFFECT_ICONS[effect.type]}
</span>
<span
className={cn(
'text-xs truncate',
effect.enabled ? 'text-foreground' : 'text-muted-foreground'
)}
>
{EFFECT_NAMES[effect.type]}
</span>
</div>
{/* Actions */}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => handleToggleEffect(effect.id)}
className="p-1 hover:bg-accent rounded transition-colors"
title={effect.enabled ? 'Disable effect' : 'Enable effect'}
>
{effect.enabled ? (
<Eye className="h-3 w-3" />
) : (
<EyeOff className="h-3 w-3" />
)}
</button>
<button
onClick={() => handleDuplicateEffect(effect.id, effect.type)}
className="p-1 hover:bg-accent rounded transition-colors"
title="Duplicate effect"
>
<Copy className="h-3 w-3" />
</button>
<button
onClick={() => handleRemoveEffect(effect.id, effect.type)}
className="p-1 hover:bg-destructive/10 hover:text-destructive rounded transition-colors"
title="Remove effect"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
</div>
))}
</div>
)}
{/* Add Effect Button */}
<div className="relative">
<button
onClick={() => setShowAddMenu(!showAddMenu)}
className="w-full flex items-center justify-center gap-2 p-2 text-xs bg-primary text-primary-foreground hover:bg-primary/90 rounded transition-colors"
>
<Plus className="h-3 w-3" />
<span>Add Effect</span>
</button>
{/* Add Effect Menu */}
{showAddMenu && (
<>
<div
className="fixed inset-0 z-[100]"
onClick={() => setShowAddMenu(false)}
/>
<div className="absolute bottom-full left-0 right-0 mb-1 z-[101] bg-card border border-border rounded-md shadow-lg py-1 max-h-48 overflow-y-auto">
<button
onClick={() => handleAddEffect('dropShadow')}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs hover:bg-accent transition-colors text-left"
>
<Sparkles className="h-3 w-3" />
<span>Drop Shadow</span>
</button>
<button
onClick={() => handleAddEffect('outerGlow')}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs hover:bg-accent transition-colors text-left"
>
<Sparkles className="h-3 w-3" />
<span>Outer Glow</span>
</button>
<button
onClick={() => handleAddEffect('stroke')}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs hover:bg-accent transition-colors text-left"
>
<Sparkles className="h-3 w-3" />
<span>Stroke</span>
</button>
<button
onClick={() => handleAddEffect('colorOverlay')}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs hover:bg-accent transition-colors text-left"
>
<Sparkles className="h-3 w-3" />
<span>Color Overlay</span>
</button>
<button
onClick={() => handleAddEffect('innerShadow')}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs hover:bg-accent transition-colors text-left"
>
<Sparkles className="h-3 w-3" />
<span>Inner Shadow</span>
</button>
<button
onClick={() => handleAddEffect('innerGlow')}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs hover:bg-accent transition-colors text-left"
>
<Sparkles className="h-3 w-3" />
<span>Inner Glow</span>
</button>
</div>
</>
)}
</div>
</div>
)}
</div>
);
}