Implements professional image filter system with real-time preview: **Adjustments Dialog** (`components/modals/adjustments-dialog.tsx`): - Live preview canvas (max 400x400px) with real-time filter updates - Organized filter categories: Color Adjustments, Filters, Effects - 10 filter types with adjustable parameters: - **Brightness**: -100 to +100 adjustment - **Contrast**: -100 to +100 adjustment - **Hue/Saturation**: Hue rotation (±180°), Saturation (±100%), Lightness (±100%) - **Blur**: Gaussian blur with radius 1-50px - **Sharpen**: Amount 0-100% - **Invert**: One-click color inversion - **Grayscale**: Convert to monochrome - **Sepia**: Vintage sepia tone effect - **Threshold**: Binary black/white with adjustable threshold - **Posterize**: Reduce colors to 2-256 levels - Slider controls for all adjustable parameters - Reset button to restore default values - Apply/Cancel actions with undo support - Uses FilterCommand for history integration **Image Menu** (`components/editor/image-menu.tsx`): - New "Image" menu in header bar next to "File" - "Filters & Adjustments..." menu item opens dialog - Organized location for image-related operations - Extensible for future image operations **Integration**: - Added Image menu to editor layout header - Positioned between title and controls - Keyboard-accessible with proper ARIA labels **Technical Features**: - Async filter application using Web Workers for large images - Non-destructive editing with undo/redo support - Real-time preview updates as sliders adjust - FilterCommand integration for history - Canvas cloning for before/after states - Optimized preview rendering (scaled to 400px max) **User Experience**: - Modal dialog with backdrop - Sidebar filter list with hover states - Large preview area showing filter results - Smooth slider interactions - Instant visual feedback - Professional Photoshop-like interface All filters tested and working with undo/redo support. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
350 lines
13 KiB
TypeScript
350 lines
13 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import { useLayerStore } from '@/store';
|
|
import { useHistoryStore } from '@/store/history-store';
|
|
import { applyFilter } from '@/lib/filter-utils';
|
|
import { FilterCommand } from '@/core/commands/filter-command';
|
|
import type { FilterType, FilterParams } from '@/types/filter';
|
|
import { X, RotateCcw } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
interface AdjustmentsDialogProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
const FILTER_CATEGORIES = {
|
|
'Color Adjustments': ['brightness', 'contrast', 'hue-saturation'] as FilterType[],
|
|
'Filters': ['blur', 'sharpen'] as FilterType[],
|
|
'Effects': ['invert', 'grayscale', 'sepia', 'threshold', 'posterize'] as FilterType[],
|
|
};
|
|
|
|
const FILTER_NAMES: Record<FilterType, string> = {
|
|
brightness: 'Brightness',
|
|
contrast: 'Contrast',
|
|
'hue-saturation': 'Hue/Saturation',
|
|
blur: 'Blur',
|
|
sharpen: 'Sharpen',
|
|
invert: 'Invert Colors',
|
|
grayscale: 'Grayscale',
|
|
sepia: 'Sepia',
|
|
threshold: 'Threshold',
|
|
posterize: 'Posterize',
|
|
};
|
|
|
|
export function AdjustmentsDialog({ isOpen, onClose }: AdjustmentsDialogProps) {
|
|
const { getActiveLayer } = useLayerStore();
|
|
const { executeCommand } = useHistoryStore();
|
|
|
|
const [selectedFilter, setSelectedFilter] = useState<FilterType>('brightness');
|
|
const [params, setParams] = useState<FilterParams>({
|
|
brightness: 0,
|
|
contrast: 0,
|
|
hue: 0,
|
|
saturation: 0,
|
|
lightness: 0,
|
|
radius: 5,
|
|
amount: 50,
|
|
threshold: 128,
|
|
levels: 8,
|
|
});
|
|
const [previewCanvas, setPreviewCanvas] = useState<HTMLCanvasElement | null>(null);
|
|
|
|
const activeLayer = getActiveLayer();
|
|
|
|
// Update preview when params change
|
|
useEffect(() => {
|
|
if (!isOpen || !activeLayer?.canvas) return;
|
|
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = Math.min(activeLayer.canvas.width, 400);
|
|
canvas.height = Math.min(activeLayer.canvas.height, 400);
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
|
|
// Draw scaled layer
|
|
ctx.drawImage(activeLayer.canvas, 0, 0, canvas.width, canvas.height);
|
|
|
|
// Apply filter to preview
|
|
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
const filtered = applyFilter(imageData, selectedFilter, params);
|
|
ctx.putImageData(filtered, 0, 0);
|
|
|
|
setPreviewCanvas(canvas);
|
|
}, [isOpen, activeLayer, selectedFilter, params]);
|
|
|
|
const handleParamChange = useCallback((key: keyof FilterParams, value: number) => {
|
|
setParams((prev) => ({ ...prev, [key]: value }));
|
|
}, []);
|
|
|
|
const handleReset = useCallback(() => {
|
|
setParams({
|
|
brightness: 0,
|
|
contrast: 0,
|
|
hue: 0,
|
|
saturation: 0,
|
|
lightness: 0,
|
|
radius: 5,
|
|
amount: 50,
|
|
threshold: 128,
|
|
levels: 8,
|
|
});
|
|
}, []);
|
|
|
|
const handleApply = useCallback(async () => {
|
|
if (!activeLayer) return;
|
|
|
|
// Use the static method to apply filter and create command
|
|
const command = await FilterCommand.applyToLayerAsync(activeLayer, selectedFilter, params);
|
|
executeCommand(command);
|
|
|
|
onClose();
|
|
}, [activeLayer, selectedFilter, params, executeCommand, onClose]);
|
|
|
|
if (!isOpen) return null;
|
|
|
|
return (
|
|
<>
|
|
{/* Backdrop */}
|
|
<div
|
|
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
|
|
onClick={onClose}
|
|
/>
|
|
|
|
{/* Dialog */}
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 pointer-events-none">
|
|
<div className="w-full max-w-4xl max-h-[90vh] bg-card border border-border rounded-lg shadow-xl flex flex-col pointer-events-auto">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between p-4 border-b border-border">
|
|
<h2 className="text-lg font-semibold text-foreground">Filters & Adjustments</h2>
|
|
<button
|
|
onClick={onClose}
|
|
className="p-2 hover:bg-accent rounded-md transition-colors"
|
|
aria-label="Close"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex flex-1 overflow-hidden">
|
|
{/* Sidebar - Filter List */}
|
|
<div className="w-56 border-r border-border overflow-y-auto bg-accent/20">
|
|
{Object.entries(FILTER_CATEGORIES).map(([category, filters]) => (
|
|
<div key={category} className="p-2">
|
|
<div className="text-xs font-semibold text-muted-foreground px-2 py-1 uppercase tracking-wide">
|
|
{category}
|
|
</div>
|
|
<div className="space-y-0.5">
|
|
{filters.map((filter) => (
|
|
<button
|
|
key={filter}
|
|
onClick={() => setSelectedFilter(filter)}
|
|
className={cn(
|
|
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors',
|
|
selectedFilter === filter
|
|
? 'bg-primary text-primary-foreground'
|
|
: 'hover:bg-accent'
|
|
)}
|
|
>
|
|
{FILTER_NAMES[filter]}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Main Area */}
|
|
<div className="flex-1 flex flex-col">
|
|
{/* Preview */}
|
|
<div className="flex-1 flex items-center justify-center p-6 bg-accent/10">
|
|
{previewCanvas && (
|
|
<div className="relative">
|
|
<canvas
|
|
ref={(el) => {
|
|
if (el && previewCanvas) {
|
|
el.width = previewCanvas.width;
|
|
el.height = previewCanvas.height;
|
|
const ctx = el.getContext('2d');
|
|
if (ctx) ctx.drawImage(previewCanvas, 0, 0);
|
|
}
|
|
}}
|
|
className="border border-border rounded shadow-lg"
|
|
style={{ maxWidth: '400px', maxHeight: '400px' }}
|
|
/>
|
|
<div className="absolute bottom-2 right-2 bg-black/70 text-white text-xs px-2 py-1 rounded">
|
|
Preview
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Controls */}
|
|
<div className="border-t border-border p-4 bg-card">
|
|
<div className="space-y-4">
|
|
{/* Brightness/Contrast */}
|
|
{selectedFilter === 'brightness' && (
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">Brightness: {params.brightness}</label>
|
|
<input
|
|
type="range"
|
|
min="-100"
|
|
max="100"
|
|
value={params.brightness}
|
|
onChange={(e) => handleParamChange('brightness', Number(e.target.value))}
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{selectedFilter === 'contrast' && (
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">Contrast: {params.contrast}</label>
|
|
<input
|
|
type="range"
|
|
min="-100"
|
|
max="100"
|
|
value={params.contrast}
|
|
onChange={(e) => handleParamChange('contrast', Number(e.target.value))}
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{selectedFilter === 'hue-saturation' && (
|
|
<>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">Hue: {params.hue}°</label>
|
|
<input
|
|
type="range"
|
|
min="-180"
|
|
max="180"
|
|
value={params.hue}
|
|
onChange={(e) => handleParamChange('hue', Number(e.target.value))}
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">Saturation: {params.saturation}%</label>
|
|
<input
|
|
type="range"
|
|
min="-100"
|
|
max="100"
|
|
value={params.saturation}
|
|
onChange={(e) => handleParamChange('saturation', Number(e.target.value))}
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">Lightness: {params.lightness}%</label>
|
|
<input
|
|
type="range"
|
|
min="-100"
|
|
max="100"
|
|
value={params.lightness}
|
|
onChange={(e) => handleParamChange('lightness', Number(e.target.value))}
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{selectedFilter === 'blur' && (
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">Radius: {params.radius}px</label>
|
|
<input
|
|
type="range"
|
|
min="1"
|
|
max="50"
|
|
value={params.radius}
|
|
onChange={(e) => handleParamChange('radius', Number(e.target.value))}
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{selectedFilter === 'sharpen' && (
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">Amount: {params.amount}%</label>
|
|
<input
|
|
type="range"
|
|
min="0"
|
|
max="100"
|
|
value={params.amount}
|
|
onChange={(e) => handleParamChange('amount', Number(e.target.value))}
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{selectedFilter === 'threshold' && (
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">Threshold: {params.threshold}</label>
|
|
<input
|
|
type="range"
|
|
min="0"
|
|
max="255"
|
|
value={params.threshold}
|
|
onChange={(e) => handleParamChange('threshold', Number(e.target.value))}
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{selectedFilter === 'posterize' && (
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">Levels: {params.levels}</label>
|
|
<input
|
|
type="range"
|
|
min="2"
|
|
max="256"
|
|
value={params.levels}
|
|
onChange={(e) => handleParamChange('levels', Number(e.target.value))}
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* One-click filters (no params) */}
|
|
{['invert', 'grayscale', 'sepia'].includes(selectedFilter) && (
|
|
<div className="text-sm text-muted-foreground">
|
|
This filter has no adjustable parameters. Click Apply to use it.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="flex items-center justify-between p-4 border-t border-border bg-accent/10">
|
|
<button
|
|
onClick={handleReset}
|
|
className="flex items-center gap-2 px-4 py-2 text-sm hover:bg-accent rounded-md transition-colors"
|
|
>
|
|
<RotateCcw className="h-4 w-4" />
|
|
Reset
|
|
</button>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={onClose}
|
|
className="px-4 py-2 text-sm hover:bg-accent rounded-md transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleApply}
|
|
className="px-4 py-2 text-sm bg-primary text-primary-foreground hover:bg-primary/90 rounded-md transition-colors"
|
|
>
|
|
Apply
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|