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