feat: add comprehensive Filters & Adjustments dialog with live preview

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>
This commit is contained in:
2025-11-21 19:36:38 +01:00
parent 54aac626a2
commit c97ec454f7
3 changed files with 417 additions and 3 deletions

View File

@@ -5,6 +5,7 @@ import { useCanvasStore, useLayerStore } from '@/store';
import { useHistoryStore } from '@/store/history-store';
import { CanvasWithTools } from '@/components/canvas/canvas-with-tools';
import { FileMenu } from './file-menu';
import { ImageMenu } from './image-menu';
import { ToolOptions } from './tool-options';
import { PanelDock } from './panel-dock';
import { ThemeToggle } from './theme-toggle';
@@ -101,12 +102,13 @@ export function EditorLayout() {
{/* Header Bar */}
<div className="flex h-12 items-center justify-between border-b border-border bg-card px-4">
{/* Left: Title and File Menu */}
<div className="flex items-center gap-4">
<h1 className="text-lg font-semibold bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent">
{/* Left: Title and Menus */}
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold bg-gradient-to-r from-primary via-accent to-primary bg-clip-text text-transparent mr-2">
Paint UI
</h1>
<FileMenu />
<ImageMenu />
</div>
{/* Right: Controls */}

View File

@@ -0,0 +1,63 @@
'use client';
import { useState } from 'react';
import { AdjustmentsDialog } from '@/components/modals/adjustments-dialog';
import {
Sliders,
ChevronDown,
} from 'lucide-react';
export function ImageMenu() {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isAdjustmentsOpen, setIsAdjustmentsOpen] = useState(false);
return (
<>
<div className="relative">
<button
onClick={() => setIsMenuOpen(!isMenuOpen)}
className="flex items-center gap-2 px-3 py-2 hover:bg-accent rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
aria-label="Image menu"
aria-expanded={isMenuOpen}
aria-haspopup="menu"
>
<span className="text-sm font-medium">Image</span>
<ChevronDown className="h-4 w-4" aria-hidden="true" />
</button>
{isMenuOpen && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setIsMenuOpen(false)}
aria-hidden="true"
/>
<div
className="absolute left-0 top-full mt-1 w-56 bg-card border border-border rounded-md shadow-lg z-20"
role="menu"
aria-label="Image operations"
>
<button
onClick={() => {
setIsAdjustmentsOpen(true);
setIsMenuOpen(false);
}}
className="flex items-center gap-3 w-full px-4 py-2 text-sm hover:bg-accent transition-colors focus:outline-none focus:bg-accent text-left"
role="menuitem"
>
<Sliders className="h-4 w-4" aria-hidden="true" />
Filters & Adjustments...
</button>
</div>
</>
)}
</div>
{/* Adjustments Dialog */}
<AdjustmentsDialog
isOpen={isAdjustmentsOpen}
onClose={() => setIsAdjustmentsOpen(false)}
/>
</>
);
}

View File

@@ -0,0 +1,349 @@
'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>
</>
);
}