feat: implement UI state persistence and theme toggle

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>
This commit is contained in:
2025-11-21 09:03:14 +01:00
parent 50bfd2940f
commit cd59f0606b
17 changed files with 570 additions and 264 deletions

View File

@@ -102,8 +102,8 @@ export function CanvasWithTools() {
ctx.scale(zoom, zoom);
ctx.translate(-width / 2, -height / 2);
// Draw checkerboard background
drawCheckerboard(ctx, 10, '#ffffff', '#e0e0e0');
// Draw checkerboard background (only within canvas bounds)
drawCheckerboard(ctx, 10, '#ffffff', '#e0e0e0', width, height);
// Draw background color if not transparent
if (backgroundColor && backgroundColor !== 'transparent') {
@@ -127,9 +127,9 @@ export function CanvasWithTools() {
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
// Draw grid if enabled
// Draw grid if enabled (only within canvas bounds)
if (showGrid) {
drawGrid(ctx, gridSize, 'rgba(0, 0, 0, 0.15)');
drawGrid(ctx, gridSize, 'rgba(0, 0, 0, 0.15)', width, height);
}
// Draw selection if active (marching ants)
@@ -172,7 +172,7 @@ export function CanvasWithTools() {
const screenX = e.clientX - rect.left;
const screenY = e.clientY - rect.top;
const canvasPos = screenToCanvas(screenX, screenY);
const canvasPos = screenToCanvas(screenX, screenY, rect.width, rect.height);
// Check for panning
if (e.button === 1 || (e.button === 0 && e.shiftKey)) {
@@ -259,7 +259,7 @@ export function CanvasWithTools() {
const screenX = e.clientX - rect.left;
const screenY = e.clientY - rect.top;
const canvasPos = screenToCanvas(screenX, screenY);
const canvasPos = screenToCanvas(screenX, screenY, rect.width, rect.height);
// Panning
if (isPanning) {

View File

@@ -51,8 +51,8 @@ export function CanvasWrapper() {
ctx.scale(zoom, zoom);
ctx.translate(-width / 2, -height / 2);
// Draw checkerboard background
drawCheckerboard(ctx, 10, '#ffffff', '#e0e0e0');
// Draw checkerboard background (only within canvas bounds)
drawCheckerboard(ctx, 10, '#ffffff', '#e0e0e0', width, height);
// Draw background color if not transparent
if (backgroundColor && backgroundColor !== 'transparent') {
@@ -76,9 +76,9 @@ export function CanvasWrapper() {
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
// Draw grid if enabled
// Draw grid if enabled (only within canvas bounds)
if (showGrid) {
drawGrid(ctx, gridSize, 'rgba(0, 0, 0, 0.15)');
drawGrid(ctx, gridSize, 'rgba(0, 0, 0, 0.15)', width, height);
}
// Draw selection if active

View File

@@ -5,7 +5,7 @@ import { useColorStore } from '@/store/color-store';
import { useToolStore } from '@/store';
import { ColorPicker } from './color-picker';
import { ColorSwatches } from './color-swatches';
import { ArrowLeftRight, Palette, Clock } from 'lucide-react';
import { ArrowLeftRight, Palette, Clock, Grid3x3 } from 'lucide-react';
import { cn } from '@/lib/utils';
type Tab = 'picker' | 'swatches' | 'recent';
@@ -32,15 +32,10 @@ export function ColorPanel() {
};
return (
<div className="flex flex-col h-full bg-card border-r border-border w-64">
{/* Header */}
<div className="border-b border-border p-3">
<h2 className="text-sm font-semibold text-card-foreground">Colors</h2>
</div>
<div className="flex flex-col h-full bg-card w-full">
{/* Current colors display */}
<div className="p-3 border-b border-border">
<div className="flex items-center gap-2">
<div className="border-b border-border">
<div className="flex items-center gap-2 p-3">
{/* Primary/Secondary color squares */}
<div className="relative">
<button
@@ -75,42 +70,45 @@ export function ColorPanel() {
</div>
{/* Tabs */}
<div className="flex border-b border-border">
<button
onClick={() => setActiveTab('picker')}
className={cn(
'flex-1 flex items-center justify-center gap-2 py-2 text-sm transition-colors',
activeTab === 'picker'
? 'bg-background text-foreground border-b-2 border-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
>
<Palette className="h-4 w-4" />
Picker
</button>
<button
onClick={() => setActiveTab('swatches')}
className={cn(
'flex-1 flex items-center justify-center gap-2 py-2 text-sm transition-colors',
activeTab === 'swatches'
? 'bg-background text-foreground border-b-2 border-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
>
Swatches
</button>
<button
onClick={() => setActiveTab('recent')}
className={cn(
'flex-1 flex items-center justify-center gap-2 py-2 text-sm transition-colors',
activeTab === 'recent'
? 'bg-background text-foreground border-b-2 border-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
>
<Clock className="h-4 w-4" />
Recent
</button>
<div className="border-b border-border">
<div className="flex">
<button
onClick={() => setActiveTab('picker')}
className={cn(
'flex-1 flex items-center justify-center gap-2 py-2 text-sm transition-colors',
activeTab === 'picker'
? 'bg-background text-foreground border-b-2 border-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
>
<Palette className="h-4 w-4" />
Picker
</button>
<button
onClick={() => setActiveTab('swatches')}
className={cn(
'flex-1 flex items-center justify-center gap-2 py-2 text-sm transition-colors',
activeTab === 'swatches'
? 'bg-background text-foreground border-b-2 border-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
>
<Grid3x3 className="h-4 w-4" />
Swatches
</button>
<button
onClick={() => setActiveTab('recent')}
className={cn(
'flex-1 flex items-center justify-center gap-2 py-2 text-sm transition-colors',
activeTab === 'recent'
? 'bg-background text-foreground border-b-2 border-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
>
<Clock className="h-4 w-4" />
Recent
</button>
</div>
</div>
{/* Content */}

View File

@@ -7,6 +7,7 @@ import { CanvasWithTools } from '@/components/canvas/canvas-with-tools';
import { FileMenu } from './file-menu';
import { ToolOptions } from './tool-options';
import { PanelDock } from './panel-dock';
import { ThemeToggle } from './theme-toggle';
import { ToolPalette } from '@/components/tools';
import { useKeyboardShortcuts } from '@/hooks/use-keyboard-shortcuts';
import { useFileOperations } from '@/hooks/use-file-operations';
@@ -155,6 +156,11 @@ export function EditorLayout() {
<div className="w-px h-6 bg-border mx-1" />
{/* Theme Toggle */}
<ThemeToggle />
<div className="w-px h-6 bg-border mx-1" />
{/* New Layer */}
<button
onClick={handleNewLayer}
@@ -167,7 +173,7 @@ export function EditorLayout() {
</div>
{/* Tool Adjustments Bar */}
<div className="flex h-10 items-center border-b border-border bg-card/50 px-4">
<div className="flex h-10 items-center border-b border-border bg-card/50">
<ToolOptions />
</div>

View File

@@ -8,13 +8,6 @@ export function HistoryPanel() {
return (
<div className="flex h-full flex-col bg-card">
<div className="border-b border-border p-3">
<h2 className="flex items-center gap-2 text-sm font-semibold text-card-foreground">
<History className="h-4 w-4" />
History
</h2>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-2">
{undoStack.length === 0 && redoStack.length === 0 ? (
<div className="flex h-full items-center justify-center">

View File

@@ -1,7 +1,8 @@
'use client';
import { useState } from 'react';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { useState, useRef, useEffect } from 'react';
import { ChevronDown, ChevronRight, Wand2, Square, Move, Hexagon, History, Sliders, Wrench } from 'lucide-react';
import { useUIStore } from '@/store';
import { LayersPanel } from '@/components/layers/layers-panel';
import { ColorPanel } from '@/components/colors';
import { FilterPanel } from '@/components/filters';
@@ -12,73 +13,208 @@ import { HistoryPanel } from './history-panel';
interface CollapsibleSectionProps {
title: string;
icon?: React.ComponentType<{ className?: string }>;
children: React.ReactNode;
defaultOpen?: boolean;
id: 'filters' | 'selection' | 'transform' | 'shapeSettings';
}
function CollapsibleSection({ title, children, defaultOpen = true }: CollapsibleSectionProps) {
const [isOpen, setIsOpen] = useState(defaultOpen);
function CollapsibleSection({ title, icon: Icon, children, id }: CollapsibleSectionProps) {
const { collapsed, toggleCollapsed } = useUIStore();
const isOpen = collapsed[id];
return (
<div className="border-b border-border">
<button
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center justify-between p-3 hover:bg-accent/50 transition-colors"
onClick={() => toggleCollapsed(id)}
className="w-full flex items-center justify-between px-3 py-2 hover:bg-accent/50 transition-colors"
>
<h3 className="text-sm font-semibold text-card-foreground">{title}</h3>
<div className="flex items-center gap-2">
{Icon && <Icon className="h-4 w-4 text-primary" />}
<h3 className="text-sm font-semibold text-card-foreground">{title}</h3>
</div>
{isOpen ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
</button>
{isOpen && <div className="pb-2">{children}</div>}
{isOpen && <div>{children}</div>}
</div>
);
}
export function PanelDock() {
const [activeTab, setActiveTab] = useState<'adjustments' | 'tools' | 'history'>('adjustments');
const {
panelDock,
setActiveTab,
setPanelWidth,
setLayersHeight,
setColorsHeight,
} = useUIStore();
const { activeTab, width, layersHeight, colorsHeight } = panelDock;
const [isResizingWidth, setIsResizingWidth] = useState(false);
const [isResizingLayersHeight, setIsResizingLayersHeight] = useState(false);
const [isResizingColorsHeight, setIsResizingColorsHeight] = useState(false);
const dockRef = useRef<HTMLDivElement>(null);
// Handle width resize
useEffect(() => {
if (!isResizingWidth) return;
const handleMouseMove = (e: MouseEvent) => {
if (!dockRef.current) return;
const containerRect = dockRef.current.parentElement?.getBoundingClientRect();
if (!containerRect) return;
const newWidth = containerRect.right - e.clientX;
setPanelWidth(newWidth);
};
const handleMouseUp = () => {
setIsResizingWidth(false);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isResizingWidth]);
// Handle layers height resize
useEffect(() => {
if (!isResizingLayersHeight) return;
const handleMouseMove = (e: MouseEvent) => {
if (!dockRef.current) return;
const dockRect = dockRef.current.getBoundingClientRect();
const relativeY = e.clientY - dockRect.top;
const percentage = (relativeY / dockRect.height) * 100;
setLayersHeight(percentage);
};
const handleMouseUp = () => {
setIsResizingLayersHeight(false);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isResizingLayersHeight]);
// Handle colors height resize
useEffect(() => {
if (!isResizingColorsHeight) return;
const handleMouseMove = (e: MouseEvent) => {
if (!dockRef.current) return;
const dockRect = dockRef.current.getBoundingClientRect();
const layersBottom = (layersHeight / 100) * dockRect.height;
const relativeY = e.clientY - dockRect.top - layersBottom;
const percentage = (relativeY / dockRect.height) * 100;
setColorsHeight(percentage);
};
const handleMouseUp = () => {
setIsResizingColorsHeight(false);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isResizingColorsHeight, layersHeight]);
return (
<div className="w-[280px] border-l border-border bg-card flex flex-col overflow-hidden">
{/* Always visible: Layers Panel */}
<div className="border-b border-border" style={{ height: '50%', minHeight: '300px' }}>
<div
ref={dockRef}
className="border-l border-border bg-card flex flex-col overflow-hidden relative"
style={{ width: `${width}px` }}
>
{/* Width resize handle (left edge) */}
<div
className="absolute left-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors z-10"
onMouseDown={() => setIsResizingWidth(true)}
/>
{/* Layers Panel */}
<div
className="overflow-hidden"
style={{ height: `${layersHeight}%` }}
>
<LayersPanel />
</div>
{/* Height resize handle (between layers and colors) */}
<div
className="h-1 cursor-row-resize hover:bg-primary/50 transition-colors border-b border-border flex-shrink-0"
onMouseDown={() => setIsResizingLayersHeight(true)}
/>
{/* Colors Panel */}
<div
className="overflow-hidden flex flex-col bg-card"
style={{ height: `${colorsHeight}%` }}
>
<div className="border-b border-border p-3 flex-shrink-0">
<h2 className="text-sm font-semibold text-card-foreground">Colors</h2>
</div>
<div className="flex-1 overflow-y-auto">
<ColorPanel />
</div>
</div>
{/* Height resize handle (between colors and tabs) */}
<div
className="h-1 cursor-row-resize hover:bg-primary/50 transition-colors border-b border-border flex-shrink-0"
onMouseDown={() => setIsResizingColorsHeight(true)}
/>
{/* Tabbed section */}
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex-1 flex flex-col overflow-hidden" style={{ height: `${100 - layersHeight - colorsHeight}%` }}>
{/* Tab buttons */}
<div className="flex border-b border-border">
<button
onClick={() => setActiveTab('adjustments')}
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium transition-colors ${
activeTab === 'adjustments'
? 'bg-background text-foreground border-b-2 border-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
}`}
>
<Sliders className="h-4 w-4" />
Adjustments
</button>
<button
onClick={() => setActiveTab('tools')}
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium transition-colors ${
activeTab === 'tools'
? 'bg-background text-foreground border-b-2 border-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
}`}
>
<Wrench className="h-4 w-4" />
Tools
</button>
<button
onClick={() => setActiveTab('history')}
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium transition-colors ${
activeTab === 'history'
? 'bg-background text-foreground border-b-2 border-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
}`}
>
<History className="h-4 w-4" />
History
</button>
</div>
@@ -87,16 +223,13 @@ export function PanelDock() {
<div className="flex-1 overflow-y-auto">
{activeTab === 'adjustments' && (
<div>
<CollapsibleSection title="Colors">
<ColorPanel />
</CollapsibleSection>
<CollapsibleSection title="Filters">
<CollapsibleSection title="Filters" icon={Wand2} id="filters">
<FilterPanel />
</CollapsibleSection>
<CollapsibleSection title="Selection">
<CollapsibleSection title="Selection" icon={Square} id="selection">
<SelectionPanel />
</CollapsibleSection>
<CollapsibleSection title="Transform">
<CollapsibleSection title="Transform" icon={Move} id="transform">
<TransformPanel />
</CollapsibleSection>
</div>
@@ -104,7 +237,7 @@ export function PanelDock() {
{activeTab === 'tools' && (
<div>
<CollapsibleSection title="Shape Settings">
<CollapsibleSection title="Shape Settings" icon={Hexagon} id="shapeSettings">
<ShapePanel />
</CollapsibleSection>
</div>

View File

@@ -0,0 +1,55 @@
'use client';
import { useEffect, useState } from 'react';
import { Moon, Sun } from 'lucide-react';
export function ThemeToggle() {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
const savedTheme = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const currentTheme = savedTheme === 'dark' || (!savedTheme && prefersDark) ? 'dark' : 'light';
setTheme(currentTheme);
}, []);
const toggleTheme = () => {
const newTheme = theme === 'light' ? 'dark' : 'light';
setTheme(newTheme);
localStorage.setItem('theme', newTheme);
if (newTheme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
};
// Prevent hydration mismatch
if (!mounted) {
return (
<button
className="rounded-md p-2 text-muted-foreground"
disabled
>
<Sun className="h-4 w-4" />
</button>
);
}
return (
<button
onClick={toggleTheme}
className="rounded-md p-2 hover:bg-accent transition-colors"
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
>
{theme === 'light' ? (
<Moon className="h-4 w-4" />
) : (
<Sun className="h-4 w-4" />
)}
</button>
);
}

View File

@@ -109,13 +109,7 @@ export function FilterPanel() {
};
return (
<div className="w-64 border-l border-border bg-card flex flex-col">
{/* Header */}
<div className="flex items-center gap-2 border-b border-border p-3">
<Wand2 className="h-5 w-5 text-primary" />
<h2 className="text-sm font-semibold">Filters</h2>
</div>
<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">

View File

@@ -128,13 +128,7 @@ export function SelectionPanel() {
};
return (
<div className="w-64 border-l border-border bg-card flex flex-col">
{/* Header */}
<div className="flex items-center gap-2 border-b border-border p-3">
<Square className="h-5 w-5 text-primary" />
<h2 className="text-sm font-semibold">Selection</h2>
</div>
<div className="w-full border-l border-border bg-card flex flex-col">
{/* Selection Tools */}
<div className="border-b border-border p-3">
<h3 className="text-xs font-semibold text-muted-foreground uppercase mb-2">

View File

@@ -63,13 +63,7 @@ export function ShapePanel() {
};
return (
<div className="w-64 border-l border-border bg-card flex flex-col">
{/* Header */}
<div className="flex items-center gap-2 border-b border-border p-3">
<Square className="h-5 w-5 text-primary" />
<h2 className="text-sm font-semibold">Shapes</h2>
</div>
<div className="w-full border-l border-border bg-card flex flex-col">
{/* Shape Types */}
<div className="border-b border-border p-3">
<h3 className="text-xs font-semibold text-muted-foreground uppercase mb-2">

View File

@@ -51,13 +51,7 @@ export function TransformPanel() {
};
return (
<div className="w-64 border-l border-border bg-card flex flex-col">
{/* Header */}
<div className="flex items-center gap-2 border-b border-border p-3">
<Move className="h-5 w-5 text-primary" />
<h2 className="text-sm font-semibold">Transform</h2>
</div>
<div className="w-full border-l border-border bg-card flex flex-col">
{/* Transform Tools */}
<div className="border-b border-border p-3">
<h3 className="text-xs font-semibold text-muted-foreground uppercase mb-2">