Major improvements to UI state management and user preferences: - Add theme toggle with dark/light mode support - Implement Zustand persist middleware for UI state - Add ui-store for panel layout preferences (dock width, heights, tabs) - Persist tool settings (active tool, size, opacity, hardness, etc.) - Persist canvas view preferences (grid, rulers, snap-to-grid) - Persist shape tool settings - Persist collapsible section states - Fix canvas coordinate transformation for centered rendering - Constrain checkerboard and grid to canvas bounds - Add icons to all tab buttons and collapsible sections - Restructure panel-dock to use persisted state Storage impact: ~3.5KB total across all preferences Storage keys: tool-storage, canvas-view-storage, shape-storage, ui-storage 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
356 lines
12 KiB
TypeScript
356 lines
12 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { useFilterStore } from '@/store/filter-store';
|
|
import { useLayerStore } from '@/store/layer-store';
|
|
import { useHistoryStore } from '@/store/history-store';
|
|
import { useFilterPreview } from '@/hooks/use-filter-preview';
|
|
import { FilterCommand } from '@/core/commands/filter-command';
|
|
import type { FilterType } from '@/types/filter';
|
|
import {
|
|
Wand2,
|
|
Sun,
|
|
SunMoon,
|
|
Palette,
|
|
Droplet,
|
|
Sparkles,
|
|
Slash,
|
|
Paintbrush,
|
|
Circle,
|
|
Grid3x3,
|
|
Eye,
|
|
Check,
|
|
X,
|
|
} from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
const FILTERS: Array<{
|
|
type: FilterType;
|
|
label: string;
|
|
icon: React.ComponentType<{ className?: string }>;
|
|
hasParams: boolean;
|
|
}> = [
|
|
{ type: 'brightness', label: 'Brightness', icon: Sun, hasParams: true },
|
|
{ type: 'contrast', label: 'Contrast', icon: SunMoon, hasParams: true },
|
|
{
|
|
type: 'hue-saturation',
|
|
label: 'Hue/Saturation',
|
|
icon: Palette,
|
|
hasParams: true,
|
|
},
|
|
{ type: 'blur', label: 'Blur', icon: Droplet, hasParams: true },
|
|
{ type: 'sharpen', label: 'Sharpen', icon: Sparkles, hasParams: true },
|
|
{ type: 'invert', label: 'Invert', icon: Slash, hasParams: false },
|
|
{ type: 'grayscale', label: 'Grayscale', icon: Paintbrush, hasParams: false },
|
|
{ type: 'sepia', label: 'Sepia', icon: Circle, hasParams: false },
|
|
{ type: 'threshold', label: 'Threshold', icon: Grid3x3, hasParams: true },
|
|
{ type: 'posterize', label: 'Posterize', icon: Grid3x3, hasParams: true },
|
|
];
|
|
|
|
export function FilterPanel() {
|
|
const {
|
|
activeFilter,
|
|
params,
|
|
setActiveFilter,
|
|
updateParams,
|
|
resetParams,
|
|
isPreviewMode,
|
|
setPreviewMode,
|
|
} = useFilterStore();
|
|
const { activeLayerId, layers } = useLayerStore();
|
|
const { executeCommand } = useHistoryStore();
|
|
const [selectedFilter, setSelectedFilter] = useState<FilterType | null>(null);
|
|
|
|
useFilterPreview();
|
|
|
|
const activeLayer = layers.find((l) => l.id === activeLayerId);
|
|
const hasActiveLayer = !!activeLayer && !activeLayer.locked;
|
|
|
|
const handleFilterSelect = (filterType: FilterType) => {
|
|
const filter = FILTERS.find((f) => f.type === filterType);
|
|
if (!filter) return;
|
|
|
|
if (filter.hasParams) {
|
|
setSelectedFilter(filterType);
|
|
setActiveFilter(filterType);
|
|
resetParams();
|
|
} else {
|
|
// Apply filter immediately for filters without parameters
|
|
if (activeLayer) {
|
|
const command = FilterCommand.applyToLayer(activeLayer, filterType, {});
|
|
executeCommand(command);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleApply = () => {
|
|
if (activeFilter && activeLayer) {
|
|
setPreviewMode(false);
|
|
const command = FilterCommand.applyToLayer(
|
|
activeLayer,
|
|
activeFilter,
|
|
params
|
|
);
|
|
executeCommand(command);
|
|
setActiveFilter(null);
|
|
setSelectedFilter(null);
|
|
}
|
|
};
|
|
|
|
const handleCancel = () => {
|
|
setPreviewMode(false);
|
|
setActiveFilter(null);
|
|
setSelectedFilter(null);
|
|
resetParams();
|
|
};
|
|
|
|
const handlePreviewToggle = () => {
|
|
setPreviewMode(!isPreviewMode);
|
|
};
|
|
|
|
return (
|
|
<div className="w-full border-l border-border bg-card flex flex-col">
|
|
{/* Filter list */}
|
|
<div className="flex-1 overflow-y-auto">
|
|
<div className="p-2 space-y-1">
|
|
{FILTERS.map((filter) => (
|
|
<button
|
|
key={filter.type}
|
|
onClick={() => handleFilterSelect(filter.type)}
|
|
disabled={!hasActiveLayer}
|
|
className={cn(
|
|
'w-full flex items-center gap-2 rounded-md p-2 text-sm transition-colors',
|
|
selectedFilter === filter.type
|
|
? 'bg-primary text-primary-foreground'
|
|
: 'hover:bg-accent text-foreground',
|
|
!hasActiveLayer && 'opacity-50 cursor-not-allowed'
|
|
)}
|
|
>
|
|
<filter.icon className="h-4 w-4" />
|
|
<span>{filter.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Filter parameters */}
|
|
{selectedFilter && activeFilter && (
|
|
<div className="border-t border-border p-3 space-y-3">
|
|
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
|
|
Parameters
|
|
</h3>
|
|
|
|
{activeFilter === 'brightness' && (
|
|
<div className="space-y-2">
|
|
<label className="text-xs text-foreground">Brightness</label>
|
|
<input
|
|
type="range"
|
|
min="-100"
|
|
max="100"
|
|
value={params.brightness ?? 0}
|
|
onChange={(e) =>
|
|
updateParams({ brightness: Number(e.target.value) })
|
|
}
|
|
className="w-full"
|
|
/>
|
|
<div className="text-xs text-muted-foreground text-center">
|
|
{params.brightness ?? 0}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activeFilter === 'contrast' && (
|
|
<div className="space-y-2">
|
|
<label className="text-xs text-foreground">Contrast</label>
|
|
<input
|
|
type="range"
|
|
min="-100"
|
|
max="100"
|
|
value={params.contrast ?? 0}
|
|
onChange={(e) =>
|
|
updateParams({ contrast: Number(e.target.value) })
|
|
}
|
|
className="w-full"
|
|
/>
|
|
<div className="text-xs text-muted-foreground text-center">
|
|
{params.contrast ?? 0}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activeFilter === 'hue-saturation' && (
|
|
<>
|
|
<div className="space-y-2">
|
|
<label className="text-xs text-foreground">Hue</label>
|
|
<input
|
|
type="range"
|
|
min="-180"
|
|
max="180"
|
|
value={params.hue ?? 0}
|
|
onChange={(e) =>
|
|
updateParams({ hue: Number(e.target.value) })
|
|
}
|
|
className="w-full"
|
|
/>
|
|
<div className="text-xs text-muted-foreground text-center">
|
|
{params.hue ?? 0}°
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-xs text-foreground">Saturation</label>
|
|
<input
|
|
type="range"
|
|
min="-100"
|
|
max="100"
|
|
value={params.saturation ?? 0}
|
|
onChange={(e) =>
|
|
updateParams({ saturation: Number(e.target.value) })
|
|
}
|
|
className="w-full"
|
|
/>
|
|
<div className="text-xs text-muted-foreground text-center">
|
|
{params.saturation ?? 0}%
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-xs text-foreground">Lightness</label>
|
|
<input
|
|
type="range"
|
|
min="-100"
|
|
max="100"
|
|
value={params.lightness ?? 0}
|
|
onChange={(e) =>
|
|
updateParams({ lightness: Number(e.target.value) })
|
|
}
|
|
className="w-full"
|
|
/>
|
|
<div className="text-xs text-muted-foreground text-center">
|
|
{params.lightness ?? 0}%
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{activeFilter === 'blur' && (
|
|
<div className="space-y-2">
|
|
<label className="text-xs text-foreground">Radius</label>
|
|
<input
|
|
type="range"
|
|
min="1"
|
|
max="50"
|
|
value={params.radius ?? 5}
|
|
onChange={(e) =>
|
|
updateParams({ radius: Number(e.target.value) })
|
|
}
|
|
className="w-full"
|
|
/>
|
|
<div className="text-xs text-muted-foreground text-center">
|
|
{params.radius ?? 5}px
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activeFilter === 'sharpen' && (
|
|
<div className="space-y-2">
|
|
<label className="text-xs text-foreground">Amount</label>
|
|
<input
|
|
type="range"
|
|
min="0"
|
|
max="100"
|
|
value={params.amount ?? 50}
|
|
onChange={(e) =>
|
|
updateParams({ amount: Number(e.target.value) })
|
|
}
|
|
className="w-full"
|
|
/>
|
|
<div className="text-xs text-muted-foreground text-center">
|
|
{params.amount ?? 50}%
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activeFilter === 'threshold' && (
|
|
<div className="space-y-2">
|
|
<label className="text-xs text-foreground">Threshold</label>
|
|
<input
|
|
type="range"
|
|
min="0"
|
|
max="255"
|
|
value={params.threshold ?? 128}
|
|
onChange={(e) =>
|
|
updateParams({ threshold: Number(e.target.value) })
|
|
}
|
|
className="w-full"
|
|
/>
|
|
<div className="text-xs text-muted-foreground text-center">
|
|
{params.threshold ?? 128}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activeFilter === 'posterize' && (
|
|
<div className="space-y-2">
|
|
<label className="text-xs text-foreground">Levels</label>
|
|
<input
|
|
type="range"
|
|
min="2"
|
|
max="256"
|
|
value={params.levels ?? 8}
|
|
onChange={(e) =>
|
|
updateParams({ levels: Number(e.target.value) })
|
|
}
|
|
className="w-full"
|
|
/>
|
|
<div className="text-xs text-muted-foreground text-center">
|
|
{params.levels ?? 8}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Preview toggle */}
|
|
<button
|
|
onClick={handlePreviewToggle}
|
|
className={cn(
|
|
'w-full flex items-center justify-center gap-2 rounded-md p-2 text-sm font-medium transition-colors',
|
|
isPreviewMode
|
|
? 'bg-primary text-primary-foreground'
|
|
: 'bg-accent text-foreground hover:bg-accent/80'
|
|
)}
|
|
>
|
|
<Eye className="h-4 w-4" />
|
|
{isPreviewMode ? 'Preview On' : 'Preview Off'}
|
|
</button>
|
|
|
|
{/* Action buttons */}
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={handleApply}
|
|
className="flex-1 flex items-center justify-center gap-2 rounded-md bg-primary p-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
|
>
|
|
<Check className="h-4 w-4" />
|
|
Apply
|
|
</button>
|
|
<button
|
|
onClick={handleCancel}
|
|
className="flex-1 flex items-center justify-center gap-2 rounded-md bg-muted p-2 text-sm font-medium text-muted-foreground hover:bg-muted/80 transition-colors"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{!hasActiveLayer && (
|
|
<div className="p-3 border-t border-border">
|
|
<p className="text-xs text-muted-foreground text-center">
|
|
Select an unlocked layer to apply filters
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|