Files
paint-ui/store/layer-effects-store.ts

300 lines
9.6 KiB
TypeScript
Raw Permalink Normal View History

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
/**
* 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,
};
},
}
)
);