Files
paint-ui/components/layers/layers-panel.tsx
Sebastian Krüger 9aa6e2d5d9 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

185 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useLayerStore } from '@/store';
import { useContextMenuStore } from '@/store/context-menu-store';
import { Eye, EyeOff, Trash2, Copy, Layers, MoveUp, MoveDown } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
deleteLayerWithHistory,
updateLayerWithHistory,
duplicateLayerWithHistory,
} from '@/lib/layer-operations';
import { LayerEffectsPanel } from './layer-effects-panel';
export function LayersPanel() {
const { layers, activeLayerId, setActiveLayer, reorderLayer } = useLayerStore();
const { showContextMenu } = useContextMenuStore();
// Sort layers by order (highest first)
const sortedLayers = [...layers].sort((a, b) => b.order - a.order);
const handleMoveLayer = (layerId: string, direction: 'up' | 'down') => {
const layer = layers.find((l) => l.id === layerId);
if (!layer) return;
const layerIndex = sortedLayers.findIndex((l) => l.id === layerId);
if (direction === 'up' && layerIndex > 0) {
const targetLayer = sortedLayers[layerIndex - 1];
reorderLayer(layerId, targetLayer.order);
} else if (direction === 'down' && layerIndex < sortedLayers.length - 1) {
const targetLayer = sortedLayers[layerIndex + 1];
reorderLayer(layerId, targetLayer.order);
}
};
const handleContextMenu = (e: React.MouseEvent, layerId: string) => {
e.preventDefault();
const layer = layers.find((l) => l.id === layerId);
if (!layer) return;
const layerIndex = sortedLayers.findIndex((l) => l.id === layerId);
const canMoveUp = layerIndex > 0;
const canMoveDown = layerIndex < sortedLayers.length - 1;
const canDelete = layers.length > 1;
showContextMenu(e.clientX, e.clientY, [
{
label: 'Duplicate Layer',
icon: <Copy className="h-4 w-4" />,
onClick: () => duplicateLayerWithHistory(layerId),
},
{
separator: true,
label: '',
onClick: () => {},
},
{
label: 'Move Up',
icon: <MoveUp className="h-4 w-4" />,
onClick: () => handleMoveLayer(layerId, 'up'),
disabled: !canMoveUp,
},
{
label: 'Move Down',
icon: <MoveDown className="h-4 w-4" />,
onClick: () => handleMoveLayer(layerId, 'down'),
disabled: !canMoveDown,
},
{
separator: true,
label: '',
onClick: () => {},
},
{
label: layer.visible ? 'Hide Layer' : 'Show Layer',
icon: layer.visible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />,
onClick: () => updateLayerWithHistory(layerId, { visible: !layer.visible }, 'Toggle Visibility'),
},
{
separator: true,
label: '',
onClick: () => {},
},
{
label: 'Delete Layer',
icon: <Trash2 className="h-4 w-4" />,
onClick: () => {
if (confirm('Delete this layer?')) {
deleteLayerWithHistory(layerId);
}
},
disabled: !canDelete,
danger: true,
},
]);
};
return (
<div className="flex h-full flex-col bg-card">
<div className="border-b border-border p-3">
<h2 className="text-sm font-semibold text-card-foreground">Layers</h2>
</div>
<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'
)}
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" />
)}
</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">
<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>
{/* Layer Effects Panel for Active Layer */}
{activeLayerId && <LayerEffectsPanel layerId={activeLayerId} />}
</div>
</div>
);
}