Files
paint-ui/store/layer-effects-store.ts
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

300 lines
9.6 KiB
TypeScript

/**
* Layer Effects Store
* Manages non-destructive visual effects for layers
*/
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type {
LayerEffect,
LayerEffectsConfig,
EffectType,
} from '@/types/layer-effects';
import {
DEFAULT_DROP_SHADOW,
DEFAULT_OUTER_GLOW,
DEFAULT_STROKE,
DEFAULT_COLOR_OVERLAY,
DEFAULT_INNER_SHADOW,
DEFAULT_INNER_GLOW,
DEFAULT_BEVEL,
} from '@/types/layer-effects';
interface LayerEffectsStore {
// Layer effects configurations (one per layer)
layerEffects: Map<string, LayerEffectsConfig>;
// Global light settings (shared across effects that use it)
globalLightAngle: number;
globalLightAltitude: number;
// Actions
getLayerEffects: (layerId: string) => LayerEffectsConfig | undefined;
setLayerEffectsEnabled: (layerId: string, enabled: boolean) => void;
addEffect: (layerId: string, effectType: EffectType) => void;
removeEffect: (layerId: string, effectId: string) => void;
updateEffect: (layerId: string, effectId: string, updates: Partial<LayerEffect>) => void;
toggleEffect: (layerId: string, effectId: string) => void;
reorderEffect: (layerId: string, effectId: string, newIndex: number) => void;
duplicateEffect: (layerId: string, effectId: string) => void;
clearLayerEffects: (layerId: string) => void;
setGlobalLight: (angle: number, altitude: number) => void;
// Preset management
copyEffects: (fromLayerId: string) => LayerEffect[] | null;
pasteEffects: (toLayerId: string, effects: LayerEffect[]) => void;
}
// Helper to create default effect by type
function createDefaultEffect(type: EffectType): Omit<LayerEffect, 'id'> {
const defaults = {
dropShadow: DEFAULT_DROP_SHADOW,
outerGlow: DEFAULT_OUTER_GLOW,
stroke: DEFAULT_STROKE,
colorOverlay: DEFAULT_COLOR_OVERLAY,
innerShadow: DEFAULT_INNER_SHADOW,
innerGlow: DEFAULT_INNER_GLOW,
bevel: DEFAULT_BEVEL,
// TODO: Add defaults for remaining effect types
gradientOverlay: DEFAULT_COLOR_OVERLAY, // Placeholder
patternOverlay: DEFAULT_COLOR_OVERLAY, // Placeholder
satin: DEFAULT_INNER_SHADOW, // Placeholder
};
return defaults[type] as Omit<LayerEffect, 'id'>;
}
export const useLayerEffectsStore = create<LayerEffectsStore>()(
persist(
(set, get) => ({
layerEffects: new Map(),
globalLightAngle: 120,
globalLightAltitude: 30,
getLayerEffects: (layerId: string) => {
return get().layerEffects.get(layerId);
},
setLayerEffectsEnabled: (layerId: string, enabled: boolean) =>
set((state) => {
const newMap = new Map(state.layerEffects);
const config = newMap.get(layerId);
if (config) {
newMap.set(layerId, { ...config, enabled });
}
return { layerEffects: newMap };
}),
addEffect: (layerId: string, effectType: EffectType) =>
set((state) => {
const newMap = new Map(state.layerEffects);
let config = newMap.get(layerId);
const newEffect: LayerEffect = {
...createDefaultEffect(effectType),
id: `effect-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
type: effectType,
} as LayerEffect;
if (!config) {
// Create new config for this layer
config = {
layerId,
effects: [newEffect],
enabled: true,
globalLightAngle: state.globalLightAngle,
globalLightAltitude: state.globalLightAltitude,
};
} else {
// Add effect to existing config
config = {
...config,
effects: [...config.effects, newEffect],
};
}
newMap.set(layerId, config);
return { layerEffects: newMap };
}),
removeEffect: (layerId: string, effectId: string) =>
set((state) => {
const newMap = new Map(state.layerEffects);
const config = newMap.get(layerId);
if (config) {
const newEffects = config.effects.filter((e) => e.id !== effectId);
if (newEffects.length === 0) {
// Remove config if no effects left
newMap.delete(layerId);
} else {
newMap.set(layerId, { ...config, effects: newEffects });
}
}
return { layerEffects: newMap };
}),
updateEffect: (layerId: string, effectId: string, updates: Partial<LayerEffect>) =>
set((state) => {
const newMap = new Map(state.layerEffects);
const config = newMap.get(layerId);
if (config) {
const newEffects = config.effects.map((effect) =>
effect.id === effectId ? ({ ...effect, ...updates } as LayerEffect) : effect
);
newMap.set(layerId, { ...config, effects: newEffects });
}
return { layerEffects: newMap };
}),
toggleEffect: (layerId: string, effectId: string) =>
set((state) => {
const newMap = new Map(state.layerEffects);
const config = newMap.get(layerId);
if (config) {
const newEffects = config.effects.map((effect) =>
effect.id === effectId
? { ...effect, enabled: !effect.enabled }
: effect
);
newMap.set(layerId, { ...config, effects: newEffects });
}
return { layerEffects: newMap };
}),
reorderEffect: (layerId: string, effectId: string, newIndex: number) =>
set((state) => {
const newMap = new Map(state.layerEffects);
const config = newMap.get(layerId);
if (config) {
const effects = [...config.effects];
const currentIndex = effects.findIndex((e) => e.id === effectId);
if (currentIndex !== -1) {
const [effect] = effects.splice(currentIndex, 1);
effects.splice(newIndex, 0, effect);
newMap.set(layerId, { ...config, effects });
}
}
return { layerEffects: newMap };
}),
duplicateEffect: (layerId: string, effectId: string) =>
set((state) => {
const newMap = new Map(state.layerEffects);
const config = newMap.get(layerId);
if (config) {
const effect = config.effects.find((e) => e.id === effectId);
if (effect) {
const newEffect: LayerEffect = {
...effect,
id: `effect-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
};
const effectIndex = config.effects.findIndex((e) => e.id === effectId);
const newEffects = [
...config.effects.slice(0, effectIndex + 1),
newEffect,
...config.effects.slice(effectIndex + 1),
];
newMap.set(layerId, { ...config, effects: newEffects });
}
}
return { layerEffects: newMap };
}),
clearLayerEffects: (layerId: string) =>
set((state) => {
const newMap = new Map(state.layerEffects);
newMap.delete(layerId);
return { layerEffects: newMap };
}),
setGlobalLight: (angle: number, altitude: number) =>
set((state) => {
const newMap = new Map(state.layerEffects);
// Update global light for all layer configs
newMap.forEach((config, layerId) => {
newMap.set(layerId, {
...config,
globalLightAngle: angle,
globalLightAltitude: altitude,
});
});
return {
globalLightAngle: angle,
globalLightAltitude: altitude,
layerEffects: newMap,
};
}),
copyEffects: (fromLayerId: string) => {
const config = get().layerEffects.get(fromLayerId);
return config ? [...config.effects] : null;
},
pasteEffects: (toLayerId: string, effects: LayerEffect[]) =>
set((state) => {
const newMap = new Map(state.layerEffects);
let config = newMap.get(toLayerId);
// Clone effects with new IDs
const newEffects = effects.map((effect) => ({
...effect,
id: `effect-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
}));
if (!config) {
config = {
layerId: toLayerId,
effects: newEffects,
enabled: true,
globalLightAngle: state.globalLightAngle,
globalLightAltitude: state.globalLightAltitude,
};
} else {
config = {
...config,
effects: [...config.effects, ...newEffects],
};
}
newMap.set(toLayerId, config);
return { layerEffects: newMap };
}),
}),
{
name: 'layer-effects-storage',
partialize: (state) => ({
layerEffects: Array.from(state.layerEffects.entries()),
globalLightAngle: state.globalLightAngle,
globalLightAltitude: state.globalLightAltitude,
}),
merge: (persistedState: any, currentState) => {
// Convert array back to Map
const layerEffects = new Map(
(persistedState?.layerEffects || []) as [string, LayerEffectsConfig][]
);
return {
...currentState,
layerEffects,
globalLightAngle: persistedState?.globalLightAngle ?? currentState.globalLightAngle,
globalLightAltitude:
persistedState?.globalLightAltitude ?? currentState.globalLightAltitude,
};
},
}
)
);