diff --git a/components/canvas/canvas-with-tools.tsx b/components/canvas/canvas-with-tools.tsx index d106d87..b632dfe 100644 --- a/components/canvas/canvas-with-tools.tsx +++ b/components/canvas/canvas-with-tools.tsx @@ -6,9 +6,11 @@ import { useHistoryStore } from '@/store/history-store'; import { useSelectionStore } from '@/store/selection-store'; import { useTextStore } from '@/store/text-store'; import { useContextMenuStore } from '@/store/context-menu-store'; +import { useLayerEffectsStore } from '@/store/layer-effects-store'; import { drawMarchingAnts } from '@/lib/selection-utils'; import { getContext, drawGrid, drawCheckerboard } from '@/lib/canvas-utils'; import { renderText } from '@/lib/text-utils'; +import { applyLayerEffects } from '@/lib/layer-effects-renderer'; import { DrawCommand } from '@/core/commands'; import { getTool, preloadCommonTools } from '@/lib/tool-loader'; import { useTouchGestures } from '@/hooks/use-touch-gestures'; @@ -54,6 +56,7 @@ export function CanvasWithTools() { const { activeSelection, selectionType, isMarching, clearSelection, selectAll } = useSelectionStore(); const { textObjects, editingTextId, isOnCanvasEditorActive } = useTextStore(); const { showContextMenu } = useContextMenuStore(); + const { getLayerEffects } = useLayerEffectsStore(); const [marchingOffset, setMarchingOffset] = useState(0); const [isPanning, setIsPanning] = useState(false); @@ -160,9 +163,15 @@ export function CanvasWithTools() { .forEach((layer) => { if (!layer.canvas) return; + // Get layer effects configuration + const effectsConfig = getLayerEffects(layer.id); + + // Apply effects to layer if any exist + const layerWithEffects = applyLayerEffects(layer.canvas, effectsConfig); + ctx.globalAlpha = layer.opacity; ctx.globalCompositeOperation = layer.blendMode as GlobalCompositeOperation; - ctx.drawImage(layer.canvas, layer.x, layer.y); + ctx.drawImage(layerWithEffects, layer.x, layer.y); }); // Reset composite operation diff --git a/components/layers/layer-effects-panel.tsx b/components/layers/layer-effects-panel.tsx new file mode 100644 index 0000000..aaaa069 --- /dev/null +++ b/components/layers/layer-effects-panel.tsx @@ -0,0 +1,278 @@ +'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 = { + 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 = { + dropShadow: , + innerShadow: , + outerGlow: , + innerGlow: , + stroke: , + bevel: , + colorOverlay: , + gradientOverlay: , + patternOverlay: , + satin: , +}; + +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 ( +
+ +
+ ); + } + + return ( +
+ {/* Header */} +
+ + + {hasEffects && ( + + )} +
+ + {isExpanded && ( +
+ {/* Effects List */} + {hasEffects && ( +
+ {effectsConfig.effects.map((effect) => ( +
+ {/* Effect Icon & Name */} +
+ + {EFFECT_ICONS[effect.type]} + + + {EFFECT_NAMES[effect.type]} + +
+ + {/* Actions */} +
+ + + + + +
+
+ ))} +
+ )} + + {/* Add Effect Button */} +
+ + + {/* Add Effect Menu */} + {showAddMenu && ( + <> +
setShowAddMenu(false)} + /> +
+ + + + + + +
+ + )} +
+
+ )} +
+ ); +} diff --git a/components/layers/layers-panel.tsx b/components/layers/layers-panel.tsx index b1c684e..3342ec5 100644 --- a/components/layers/layers-panel.tsx +++ b/components/layers/layers-panel.tsx @@ -9,6 +9,7 @@ import { updateLayerWithHistory, duplicateLayerWithHistory, } from '@/lib/layer-operations'; +import { LayerEffectsPanel } from './layer-effects-panel'; export function LayersPanel() { const { layers, activeLayerId, setActiveLayer, reorderLayer } = useLayerStore(); @@ -99,79 +100,84 @@ export function LayersPanel() {

Layers

-
- {sortedLayers.length === 0 ? ( -
-

No layers

-
- ) : ( - sortedLayers.map((layer) => ( -
setActiveLayer(layer.id)} - onContextMenu={(e) => handleContextMenu(e, layer.id)} - > - - -
-
- {Math.round(layer.opacity * 100)}% +
+

+ {layer.name} +

+

+ {layer.width} × {layer.height} +

+
+ +
+ + +
+ +
+ {Math.round(layer.opacity * 100)}% +
-
- )) - )} + )) + )} + + + {/* Layer Effects Panel for Active Layer */} + {activeLayerId && } ); diff --git a/lib/layer-effects-renderer.ts b/lib/layer-effects-renderer.ts new file mode 100644 index 0000000..2579272 --- /dev/null +++ b/lib/layer-effects-renderer.ts @@ -0,0 +1,444 @@ +/** + * Layer Effects Rendering Engine + * Applies non-destructive visual effects to layer canvases + */ + +import type { + LayerEffect, + LayerEffectsConfig, + DropShadowEffect, + OuterGlowEffect, + InnerShadowEffect, + InnerGlowEffect, + StrokeEffect, + ColorOverlayEffect, + BevelEffect, +} from '@/types/layer-effects'; + +/** + * Apply all enabled effects to a layer canvas + * Returns a new canvas with effects applied (non-destructive) + */ +export function applyLayerEffects( + sourceCanvas: HTMLCanvasElement, + effectsConfig: LayerEffectsConfig | undefined +): HTMLCanvasElement { + if (!effectsConfig || !effectsConfig.enabled || effectsConfig.effects.length === 0) { + return sourceCanvas; + } + + // Filter to only enabled effects + const activeEffects = effectsConfig.effects.filter((e) => e.enabled); + if (activeEffects.length === 0) { + return sourceCanvas; + } + + // Create output canvas with extra padding for effects like shadows and glows + const padding = calculateRequiredPadding(activeEffects); + const outputCanvas = document.createElement('canvas'); + outputCanvas.width = sourceCanvas.width + padding.left + padding.right; + outputCanvas.height = sourceCanvas.height + padding.top + padding.bottom; + + const ctx = outputCanvas.getContext('2d', { willReadFrequently: true }); + if (!ctx) return sourceCanvas; + + // Effects are applied in a specific order for best visual results + // Order: Background effects → Layer content → Overlay effects + + ctx.save(); + ctx.translate(padding.left, padding.top); + + // Step 1: Apply effects that go behind the layer (Drop Shadow, Outer Glow) + applyBackgroundEffects(ctx, sourceCanvas, activeEffects, effectsConfig); + + // Step 2: Draw the original layer content + ctx.globalCompositeOperation = 'source-over'; + ctx.drawImage(sourceCanvas, 0, 0); + + // Step 3: Apply effects that modify the layer (Inner Shadow, Inner Glow, Stroke) + applyModifyingEffects(ctx, sourceCanvas, activeEffects, effectsConfig); + + // Step 4: Apply overlay effects (Color Overlay, Gradient Overlay, Pattern Overlay) + applyOverlayEffects(ctx, sourceCanvas, activeEffects); + + ctx.restore(); + + return outputCanvas; +} + +/** + * Calculate required padding for effects + */ +function calculateRequiredPadding(effects: LayerEffect[]): { + top: number; + right: number; + bottom: number; + left: number; +} { + let maxPadding = 0; + + effects.forEach((effect) => { + if (!effect.enabled) return; + + switch (effect.type) { + case 'dropShadow': { + const shadow = effect as DropShadowEffect; + const maxBlur = shadow.size + shadow.distance; + maxPadding = Math.max(maxPadding, maxBlur); + break; + } + case 'outerGlow': { + const glow = effect as OuterGlowEffect; + maxPadding = Math.max(maxPadding, glow.size); + break; + } + case 'stroke': { + const stroke = effect as StrokeEffect; + if (stroke.position === 'outside') { + maxPadding = Math.max(maxPadding, stroke.size); + } else if (stroke.position === 'center') { + maxPadding = Math.max(maxPadding, stroke.size / 2); + } + break; + } + } + }); + + return { + top: Math.ceil(maxPadding), + right: Math.ceil(maxPadding), + bottom: Math.ceil(maxPadding), + left: Math.ceil(maxPadding), + }; +} + +/** + * Apply background effects (Drop Shadow, Outer Glow) + */ +function applyBackgroundEffects( + ctx: CanvasRenderingContext2D, + sourceCanvas: HTMLCanvasElement, + effects: LayerEffect[], + config: LayerEffectsConfig +): void { + effects.forEach((effect) => { + switch (effect.type) { + case 'dropShadow': + applyDropShadow(ctx, sourceCanvas, effect as DropShadowEffect, config); + break; + case 'outerGlow': + applyOuterGlow(ctx, sourceCanvas, effect as OuterGlowEffect); + break; + } + }); +} + +/** + * Apply modifying effects (Inner Shadow, Inner Glow, Stroke) + */ +function applyModifyingEffects( + ctx: CanvasRenderingContext2D, + sourceCanvas: HTMLCanvasElement, + effects: LayerEffect[], + config: LayerEffectsConfig +): void { + effects.forEach((effect) => { + switch (effect.type) { + case 'innerShadow': + applyInnerShadow(ctx, sourceCanvas, effect as InnerShadowEffect, config); + break; + case 'innerGlow': + applyInnerGlow(ctx, sourceCanvas, effect as InnerGlowEffect); + break; + case 'stroke': + applyStroke(ctx, sourceCanvas, effect as StrokeEffect); + break; + } + }); +} + +/** + * Apply overlay effects (Color Overlay, etc.) + */ +function applyOverlayEffects( + ctx: CanvasRenderingContext2D, + sourceCanvas: HTMLCanvasElement, + effects: LayerEffect[] +): void { + effects.forEach((effect) => { + switch (effect.type) { + case 'colorOverlay': + applyColorOverlay(ctx, sourceCanvas, effect as ColorOverlayEffect); + break; + // TODO: Gradient Overlay, Pattern Overlay + } + }); +} + +/** + * Apply Drop Shadow Effect + */ +function applyDropShadow( + ctx: CanvasRenderingContext2D, + sourceCanvas: HTMLCanvasElement, + effect: DropShadowEffect, + config: LayerEffectsConfig +): void { + const angle = effect.useGlobalLight ? config.globalLightAngle : effect.angle; + const angleRad = (angle * Math.PI) / 180; + + const offsetX = Math.cos(angleRad) * effect.distance; + const offsetY = Math.sin(angleRad) * effect.distance; + + ctx.save(); + ctx.globalAlpha = effect.opacity; + ctx.globalCompositeOperation = effect.blendMode as GlobalCompositeOperation; + + // Apply shadow + ctx.shadowColor = effect.color; + ctx.shadowBlur = effect.size; + ctx.shadowOffsetX = offsetX; + ctx.shadowOffsetY = offsetY; + + // Draw source as shadow + ctx.drawImage(sourceCanvas, 0, 0); + + ctx.restore(); +} + +/** + * Apply Outer Glow Effect + */ +function applyOuterGlow( + ctx: CanvasRenderingContext2D, + sourceCanvas: HTMLCanvasElement, + effect: OuterGlowEffect +): void { + ctx.save(); + ctx.globalAlpha = effect.opacity; + ctx.globalCompositeOperation = effect.blendMode as GlobalCompositeOperation; + + // Create glow using shadow + ctx.shadowColor = effect.color; + ctx.shadowBlur = effect.size; + ctx.shadowOffsetX = 0; + ctx.shadowOffsetY = 0; + + // Draw multiple times for stronger glow + const iterations = Math.ceil(effect.spread / 20) + 1; + for (let i = 0; i < iterations; i++) { + ctx.drawImage(sourceCanvas, 0, 0); + } + + ctx.restore(); +} + +/** + * Apply Inner Shadow Effect + */ +function applyInnerShadow( + ctx: CanvasRenderingContext2D, + sourceCanvas: HTMLCanvasElement, + effect: InnerShadowEffect, + config: LayerEffectsConfig +): void { + const angle = effect.useGlobalLight ? config.globalLightAngle : effect.angle; + const angleRad = (angle * Math.PI) / 180; + + const offsetX = Math.cos(angleRad) * effect.distance; + const offsetY = Math.sin(angleRad) * effect.distance; + + // Inner shadow is complex - we need to invert the layer, apply shadow, then comp it back + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = sourceCanvas.width; + tempCanvas.height = sourceCanvas.height; + const tempCtx = tempCanvas.getContext('2d'); + if (!tempCtx) return; + + // Step 1: Create inverted mask + tempCtx.fillStyle = 'white'; + tempCtx.fillRect(0, 0, tempCanvas.width, tempCanvas.height); + tempCtx.globalCompositeOperation = 'destination-out'; + tempCtx.drawImage(sourceCanvas, 0, 0); + + // Step 2: Apply shadow to inverted mask + tempCtx.globalCompositeOperation = 'source-over'; + tempCtx.shadowColor = effect.color; + tempCtx.shadowBlur = effect.size; + tempCtx.shadowOffsetX = -offsetX; + tempCtx.shadowOffsetY = -offsetY; + tempCtx.drawImage(tempCanvas, 0, 0); + + // Step 3: Clip to original layer shape + ctx.save(); + ctx.globalAlpha = effect.opacity; + ctx.globalCompositeOperation = 'source-atop'; + ctx.drawImage(tempCanvas, 0, 0); + ctx.restore(); +} + +/** + * Apply Inner Glow Effect + */ +function applyInnerGlow( + ctx: CanvasRenderingContext2D, + sourceCanvas: HTMLCanvasElement, + effect: InnerGlowEffect +): void { + // Similar to inner shadow but without offset + const tempCanvas = document.createElement('canvas'); + tempCanvas.width = sourceCanvas.width; + tempCanvas.height = sourceCanvas.height; + const tempCtx = tempCanvas.getContext('2d'); + if (!tempCtx) return; + + // Create inverted mask + tempCtx.fillStyle = 'white'; + tempCtx.fillRect(0, 0, tempCanvas.width, tempCanvas.height); + tempCtx.globalCompositeOperation = 'destination-out'; + tempCtx.drawImage(sourceCanvas, 0, 0); + + // Apply glow + tempCtx.globalCompositeOperation = 'source-over'; + tempCtx.shadowColor = effect.color; + tempCtx.shadowBlur = effect.size; + tempCtx.shadowOffsetX = 0; + tempCtx.shadowOffsetY = 0; + + const iterations = Math.ceil(effect.choke / 20) + 1; + for (let i = 0; i < iterations; i++) { + tempCtx.drawImage(tempCanvas, 0, 0); + } + + // Clip to original layer + ctx.save(); + ctx.globalAlpha = effect.opacity; + ctx.globalCompositeOperation = 'source-atop'; + ctx.drawImage(tempCanvas, 0, 0); + ctx.restore(); +} + +/** + * Apply Stroke Effect + */ +function applyStroke( + ctx: CanvasRenderingContext2D, + sourceCanvas: HTMLCanvasElement, + effect: StrokeEffect +): void { + ctx.save(); + ctx.globalAlpha = effect.opacity; + ctx.globalCompositeOperation = effect.blendMode as GlobalCompositeOperation; + + // For now, only implement solid color stroke + if (effect.fillType === 'color') { + ctx.strokeStyle = effect.color; + ctx.lineWidth = effect.size; + + // Get the outline of the layer + const imageData = ctx.getImageData(0, 0, sourceCanvas.width, sourceCanvas.height); + const outline = getLayerOutline(imageData); + + // Draw the stroke + ctx.beginPath(); + outline.forEach((point, index) => { + if (index === 0) { + ctx.moveTo(point.x, point.y); + } else { + ctx.lineTo(point.x, point.y); + } + }); + ctx.closePath(); + ctx.stroke(); + } + + ctx.restore(); +} + +/** + * Apply Color Overlay Effect + */ +function applyColorOverlay( + ctx: CanvasRenderingContext2D, + sourceCanvas: HTMLCanvasElement, + effect: ColorOverlayEffect +): void { + ctx.save(); + ctx.globalAlpha = effect.opacity; + ctx.globalCompositeOperation = effect.blendMode as GlobalCompositeOperation; + + // Fill with color, clipped to layer shape + ctx.fillStyle = effect.color; + ctx.fillRect(0, 0, sourceCanvas.width, sourceCanvas.height); + + ctx.restore(); +} + +/** + * Get outline points of a layer (for stroke effect) + * This is a simplified version - proper implementation would use edge detection + */ +function getLayerOutline(imageData: ImageData): Array<{ x: number; y: number }> { + const points: Array<{ x: number; y: number }> = []; + const width = imageData.width; + const height = imageData.height; + const data = imageData.data; + + // Simple edge detection - find pixels with alpha > 0 next to alpha === 0 + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const idx = (y * width + x) * 4; + const alpha = data[idx + 3]; + + if (alpha > 0) { + // Check if this is an edge pixel + const isEdge = + x === 0 || + x === width - 1 || + y === 0 || + y === height - 1 || + data[((y - 1) * width + x) * 4 + 3] === 0 || + data[((y + 1) * width + x) * 4 + 3] === 0 || + data[(y * width + (x - 1)) * 4 + 3] === 0 || + data[(y * width + (x + 1)) * 4 + 3] === 0; + + if (isEdge) { + points.push({ x, y }); + } + } + } + } + + return points; +} + +/** + * Check if a layer has any active effects + */ +export function hasActiveEffects(effectsConfig: LayerEffectsConfig | undefined): boolean { + if (!effectsConfig || !effectsConfig.enabled) return false; + return effectsConfig.effects.some((e) => e.enabled); +} + +/** + * Get the effective bounds of a layer with effects applied + * This is used to know how much larger the layer will be with effects + */ +export function getEffectBounds( + layerWidth: number, + layerHeight: number, + effectsConfig: LayerEffectsConfig | undefined +): { width: number; height: number; offsetX: number; offsetY: number } { + if (!effectsConfig || !effectsConfig.enabled) { + return { width: layerWidth, height: layerHeight, offsetX: 0, offsetY: 0 }; + } + + const padding = calculateRequiredPadding(effectsConfig.effects); + + return { + width: layerWidth + padding.left + padding.right, + height: layerHeight + padding.top + padding.bottom, + offsetX: padding.left, + offsetY: padding.top, + }; +} diff --git a/store/layer-effects-store.ts b/store/layer-effects-store.ts new file mode 100644 index 0000000..4735b97 --- /dev/null +++ b/store/layer-effects-store.ts @@ -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; + + // 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) => 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 { + 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; +} + +export const useLayerEffectsStore = create()( + 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) => + 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, + }; + }, + } + ) +); diff --git a/types/layer-effects.ts b/types/layer-effects.ts new file mode 100644 index 0000000..72fbe55 --- /dev/null +++ b/types/layer-effects.ts @@ -0,0 +1,327 @@ +/** + * Layer Effects Type Definitions + * Non-destructive visual effects for layers (inspired by Photoshop Layer Styles) + */ + +export type EffectType = + | 'dropShadow' + | 'innerShadow' + | 'outerGlow' + | 'innerGlow' + | 'stroke' + | 'bevel' + | 'colorOverlay' + | 'gradientOverlay' + | 'patternOverlay' + | 'satin'; + +export type BlendMode = + | 'normal' + | 'multiply' + | 'screen' + | 'overlay' + | 'soft-light' + | 'hard-light' + | 'color-dodge' + | 'color-burn' + | 'darken' + | 'lighten'; + +export type GradientType = 'linear' | 'radial' | 'angle'; +export type StrokePosition = 'outside' | 'inside' | 'center'; +export type BevelStyle = 'outer-bevel' | 'inner-bevel' | 'emboss' | 'pillow-emboss'; +export type BevelDirection = 'up' | 'down'; + +/** + * Base effect interface - all effects extend this + */ +export interface BaseEffect { + id: string; + type: EffectType; + enabled: boolean; + blendMode: BlendMode; + opacity: number; // 0-1 +} + +/** + * Drop Shadow Effect + * Casts a shadow behind the layer + */ +export interface DropShadowEffect extends BaseEffect { + type: 'dropShadow'; + color: string; + angle: number; // degrees 0-360 + distance: number; // pixels + spread: number; // 0-100% + size: number; // blur radius in pixels + useGlobalLight: boolean; +} + +/** + * Inner Shadow Effect + * Shadow appears inside the layer + */ +export interface InnerShadowEffect extends BaseEffect { + type: 'innerShadow'; + color: string; + angle: number; + distance: number; + choke: number; // 0-100%, similar to spread but inward + size: number; + useGlobalLight: boolean; +} + +/** + * Outer Glow Effect + * Soft glow around the outside edges + */ +export interface OuterGlowEffect extends BaseEffect { + type: 'outerGlow'; + color: string; + spread: number; // 0-100% + size: number; // blur radius + technique: 'softer' | 'precise'; + range: number; // 0-100% +} + +/** + * Inner Glow Effect + * Glow from the edges inward + */ +export interface InnerGlowEffect extends BaseEffect { + type: 'innerGlow'; + color: string; + source: 'edge' | 'center'; + choke: number; // 0-100% + size: number; + technique: 'softer' | 'precise'; + range: number; +} + +/** + * Stroke Effect + * Outline around the layer + */ +export interface StrokeEffect extends BaseEffect { + type: 'stroke'; + size: number; // stroke width in pixels + position: StrokePosition; + fillType: 'color' | 'gradient' | 'pattern'; + color: string; // for solid color + gradient?: { + type: GradientType; + colors: Array<{ color: string; position: number }>; // position 0-1 + angle: number; + }; + pattern?: { + id: string; + scale: number; + }; +} + +/** + * Bevel & Emboss Effect + * Creates 3D depth effect + */ +export interface BevelEffect extends BaseEffect { + type: 'bevel'; + style: BevelStyle; + technique: 'smooth' | 'chisel-hard' | 'chisel-soft'; + depth: number; // 0-1000% + direction: BevelDirection; + size: number; // pixels + soften: number; // pixels + angle: number; // light angle + altitude: number; // light altitude 0-90 degrees + useGlobalLight: boolean; + highlightMode: BlendMode; + highlightOpacity: number; + highlightColor: string; + shadowMode: BlendMode; + shadowOpacity: number; + shadowColor: string; +} + +/** + * Color Overlay Effect + * Fills the layer with a solid color + */ +export interface ColorOverlayEffect extends BaseEffect { + type: 'colorOverlay'; + color: string; +} + +/** + * Gradient Overlay Effect + * Fills the layer with a gradient + */ +export interface GradientOverlayEffect extends BaseEffect { + type: 'gradientOverlay'; + gradient: { + type: GradientType; + colors: Array<{ color: string; position: number }>; + angle: number; + scale: number; // 0-150% + reverse: boolean; + alignWithLayer: boolean; + }; +} + +/** + * Pattern Overlay Effect + * Fills the layer with a repeating pattern + */ +export interface PatternOverlayEffect extends BaseEffect { + type: 'patternOverlay'; + pattern: { + id: string; + scale: number; // percentage + snapToOrigin: boolean; + }; +} + +/** + * Satin Effect + * Creates soft interior shading + */ +export interface SatinEffect extends BaseEffect { + type: 'satin'; + color: string; + angle: number; + distance: number; + size: number; // blur + contour: 'linear' | 'gaussian' | 'cone' | 'cove'; + invert: boolean; +} + +/** + * Union type of all effect types + */ +export type LayerEffect = + | DropShadowEffect + | InnerShadowEffect + | OuterGlowEffect + | InnerGlowEffect + | StrokeEffect + | BevelEffect + | ColorOverlayEffect + | GradientOverlayEffect + | PatternOverlayEffect + | SatinEffect; + +/** + * Layer effects configuration for a single layer + */ +export interface LayerEffectsConfig { + layerId: string; + effects: LayerEffect[]; + enabled: boolean; // Master switch for all effects + globalLightAngle: number; // Shared light angle (default 120°) + globalLightAltitude: number; // Shared light altitude (default 30°) +} + +/** + * Effect presets for quick application + */ +export interface EffectPreset { + id: string; + name: string; + description?: string; + thumbnail?: string; + effects: Omit[]; +} + +/** + * Default effect configurations + */ +export const DEFAULT_DROP_SHADOW: Omit = { + type: 'dropShadow', + enabled: true, + blendMode: 'multiply', + opacity: 0.75, + color: '#000000', + angle: 120, + distance: 5, + spread: 0, + size: 5, + useGlobalLight: true, +}; + +export const DEFAULT_OUTER_GLOW: Omit = { + type: 'outerGlow', + enabled: true, + blendMode: 'screen', + opacity: 0.75, + color: '#ffffff', + spread: 0, + size: 5, + technique: 'softer', + range: 50, +}; + +export const DEFAULT_STROKE: Omit = { + type: 'stroke', + enabled: true, + blendMode: 'normal', + opacity: 1, + size: 3, + position: 'outside', + fillType: 'color', + color: '#000000', +}; + +export const DEFAULT_COLOR_OVERLAY: Omit = { + type: 'colorOverlay', + enabled: true, + blendMode: 'normal', + opacity: 1, + color: '#000000', +}; + +export const DEFAULT_INNER_SHADOW: Omit = { + type: 'innerShadow', + enabled: true, + blendMode: 'multiply', + opacity: 0.75, + color: '#000000', + angle: 120, + distance: 5, + choke: 0, + size: 5, + useGlobalLight: true, +}; + +export const DEFAULT_INNER_GLOW: Omit = { + type: 'innerGlow', + enabled: true, + blendMode: 'screen', + opacity: 0.75, + color: '#ffffff', + source: 'edge', + choke: 0, + size: 5, + technique: 'softer', + range: 50, +}; + +export const DEFAULT_BEVEL: Omit = { + type: 'bevel', + enabled: true, + blendMode: 'normal', + opacity: 1, + style: 'inner-bevel', + technique: 'smooth', + depth: 100, + direction: 'up', + size: 5, + soften: 0, + angle: 120, + altitude: 30, + useGlobalLight: true, + highlightMode: 'screen', + highlightOpacity: 0.75, + highlightColor: '#ffffff', + shadowMode: 'multiply', + shadowOpacity: 0.75, + shadowColor: '#000000', +};