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:
299
store/layer-effects-store.ts
Normal file
299
store/layer-effects-store.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
Reference in New Issue
Block a user