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:
@@ -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 */}
|
||||
|
||||
63
components/editor/image-menu.tsx
Normal file
63
components/editor/image-menu.tsx
Normal 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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
349
components/modals/adjustments-dialog.tsx
Normal file
349
components/modals/adjustments-dialog.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user