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>
300 lines
9.6 KiB
TypeScript
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,
|
|
};
|
|
},
|
|
}
|
|
)
|
|
);
|