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:
@@ -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
|
||||
|
||||
278
components/layers/layer-effects-panel.tsx
Normal file
278
components/layers/layer-effects-panel.tsx
Normal file
@@ -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<EffectType, string> = {
|
||||
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<EffectType, JSX.Element> = {
|
||||
dropShadow: <Sparkles className="h-3 w-3" />,
|
||||
innerShadow: <Sparkles className="h-3 w-3" />,
|
||||
outerGlow: <Sparkles className="h-3 w-3" />,
|
||||
innerGlow: <Sparkles className="h-3 w-3" />,
|
||||
stroke: <Sparkles className="h-3 w-3" />,
|
||||
bevel: <Sparkles className="h-3 w-3" />,
|
||||
colorOverlay: <Sparkles className="h-3 w-3" />,
|
||||
gradientOverlay: <Sparkles className="h-3 w-3" />,
|
||||
patternOverlay: <Sparkles className="h-3 w-3" />,
|
||||
satin: <Sparkles className="h-3 w-3" />,
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="border-t border-border p-2">
|
||||
<button
|
||||
onClick={() => setIsExpanded(true)}
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors w-full"
|
||||
>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<Sparkles className="h-3 w-3" />
|
||||
<span>Layer Effects</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-t border-border">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-2 bg-accent/50">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="flex items-center gap-2 text-xs font-medium text-card-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
)}
|
||||
<Sparkles className="h-3 w-3" />
|
||||
<span>Layer Effects</span>
|
||||
{hasEffects && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({effectsConfig.effects.length})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{hasEffects && (
|
||||
<button
|
||||
onClick={handleToggleAll}
|
||||
className="p-1 hover:bg-accent rounded transition-colors"
|
||||
title={effectsConfig.enabled ? 'Disable all effects' : 'Enable all effects'}
|
||||
>
|
||||
{effectsConfig.enabled ? (
|
||||
<Eye className="h-3 w-3" />
|
||||
) : (
|
||||
<EyeOff className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="p-2 space-y-1">
|
||||
{/* Effects List */}
|
||||
{hasEffects && (
|
||||
<div className="space-y-1 mb-2">
|
||||
{effectsConfig.effects.map((effect) => (
|
||||
<div
|
||||
key={effect.id}
|
||||
className={cn(
|
||||
'group flex items-center gap-2 p-2 rounded border transition-colors',
|
||||
effect.enabled
|
||||
? 'border-border bg-background'
|
||||
: 'border-border/50 bg-muted/30'
|
||||
)}
|
||||
>
|
||||
{/* Effect Icon & Name */}
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<span className="text-muted-foreground">
|
||||
{EFFECT_ICONS[effect.type]}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs truncate',
|
||||
effect.enabled ? 'text-foreground' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{EFFECT_NAMES[effect.type]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => handleToggleEffect(effect.id)}
|
||||
className="p-1 hover:bg-accent rounded transition-colors"
|
||||
title={effect.enabled ? 'Disable effect' : 'Enable effect'}
|
||||
>
|
||||
{effect.enabled ? (
|
||||
<Eye className="h-3 w-3" />
|
||||
) : (
|
||||
<EyeOff className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleDuplicateEffect(effect.id, effect.type)}
|
||||
className="p-1 hover:bg-accent rounded transition-colors"
|
||||
title="Duplicate effect"
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleRemoveEffect(effect.id, effect.type)}
|
||||
className="p-1 hover:bg-destructive/10 hover:text-destructive rounded transition-colors"
|
||||
title="Remove effect"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Effect Button */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowAddMenu(!showAddMenu)}
|
||||
className="w-full flex items-center justify-center gap-2 p-2 text-xs bg-primary text-primary-foreground hover:bg-primary/90 rounded transition-colors"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
<span>Add Effect</span>
|
||||
</button>
|
||||
|
||||
{/* Add Effect Menu */}
|
||||
{showAddMenu && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-[100]"
|
||||
onClick={() => setShowAddMenu(false)}
|
||||
/>
|
||||
<div className="absolute bottom-full left-0 right-0 mb-1 z-[101] bg-card border border-border rounded-md shadow-lg py-1 max-h-48 overflow-y-auto">
|
||||
<button
|
||||
onClick={() => handleAddEffect('dropShadow')}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs hover:bg-accent transition-colors text-left"
|
||||
>
|
||||
<Sparkles className="h-3 w-3" />
|
||||
<span>Drop Shadow</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAddEffect('outerGlow')}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs hover:bg-accent transition-colors text-left"
|
||||
>
|
||||
<Sparkles className="h-3 w-3" />
|
||||
<span>Outer Glow</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAddEffect('stroke')}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs hover:bg-accent transition-colors text-left"
|
||||
>
|
||||
<Sparkles className="h-3 w-3" />
|
||||
<span>Stroke</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAddEffect('colorOverlay')}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs hover:bg-accent transition-colors text-left"
|
||||
>
|
||||
<Sparkles className="h-3 w-3" />
|
||||
<span>Color Overlay</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAddEffect('innerShadow')}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs hover:bg-accent transition-colors text-left"
|
||||
>
|
||||
<Sparkles className="h-3 w-3" />
|
||||
<span>Inner Shadow</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAddEffect('innerGlow')}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs hover:bg-accent transition-colors text-left"
|
||||
>
|
||||
<Sparkles className="h-3 w-3" />
|
||||
<span>Inner Glow</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
<h2 className="text-sm font-semibold text-card-foreground">Layers</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
{sortedLayers.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">No layers</p>
|
||||
</div>
|
||||
) : (
|
||||
sortedLayers.map((layer) => (
|
||||
<div
|
||||
key={layer.id}
|
||||
className={cn(
|
||||
'group flex items-center gap-2 rounded-md border p-2 transition-colors cursor-pointer',
|
||||
activeLayerId === layer.id
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border hover:border-primary/50 hover:bg-accent/50'
|
||||
)}
|
||||
onClick={() => setActiveLayer(layer.id)}
|
||||
onContextMenu={(e) => handleContextMenu(e, layer.id)}
|
||||
>
|
||||
<button
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
updateLayerWithHistory(layer.id, { visible: !layer.visible }, 'Toggle Visibility');
|
||||
}}
|
||||
title="Toggle visibility"
|
||||
>
|
||||
{layer.visible ? (
|
||||
<Eye className="h-4 w-4" />
|
||||
) : (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="p-2 space-y-1">
|
||||
{sortedLayers.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">No layers</p>
|
||||
</div>
|
||||
) : (
|
||||
sortedLayers.map((layer) => (
|
||||
<div
|
||||
key={layer.id}
|
||||
className={cn(
|
||||
'group flex items-center gap-2 rounded-md border p-2 transition-colors cursor-pointer',
|
||||
activeLayerId === layer.id
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border hover:border-primary/50 hover:bg-accent/50'
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-card-foreground truncate">
|
||||
{layer.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{layer.width} × {layer.height}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
onClick={() => setActiveLayer(layer.id)}
|
||||
onContextMenu={(e) => handleContextMenu(e, layer.id)}
|
||||
>
|
||||
<button
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
duplicateLayerWithHistory(layer.id);
|
||||
updateLayerWithHistory(layer.id, { visible: !layer.visible }, 'Toggle Visibility');
|
||||
}}
|
||||
title="Duplicate layer"
|
||||
title="Toggle visibility"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
{layer.visible ? (
|
||||
<Eye className="h-4 w-4" />
|
||||
) : (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (layers.length > 1 && confirm('Delete this layer?')) {
|
||||
deleteLayerWithHistory(layer.id);
|
||||
}
|
||||
}}
|
||||
title="Delete layer"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-xs text-muted-foreground">
|
||||
{Math.round(layer.opacity * 100)}%
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-card-foreground truncate">
|
||||
{layer.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{layer.width} × {layer.height}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
duplicateLayerWithHistory(layer.id);
|
||||
}}
|
||||
title="Duplicate layer"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (layers.length > 1 && confirm('Delete this layer?')) {
|
||||
deleteLayerWithHistory(layer.id);
|
||||
}
|
||||
}}
|
||||
title="Delete layer"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-xs text-muted-foreground">
|
||||
{Math.round(layer.opacity * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Layer Effects Panel for Active Layer */}
|
||||
{activeLayerId && <LayerEffectsPanel layerId={activeLayerId} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user