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

View File

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

View File

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

View File

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

View File

@@ -8,13 +8,6 @@ export function HistoryPanel() {
return ( return (
<div className="flex h-full flex-col bg-card"> <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"> <div className="flex-1 overflow-y-auto p-2 space-y-2">
{undoStack.length === 0 && redoStack.length === 0 ? ( {undoStack.length === 0 && redoStack.length === 0 ? (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">

View File

@@ -1,7 +1,8 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useRef, useEffect } from 'react';
import { ChevronDown, ChevronRight } from 'lucide-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 { LayersPanel } from '@/components/layers/layers-panel';
import { ColorPanel } from '@/components/colors'; import { ColorPanel } from '@/components/colors';
import { FilterPanel } from '@/components/filters'; import { FilterPanel } from '@/components/filters';
@@ -12,73 +13,208 @@ import { HistoryPanel } from './history-panel';
interface CollapsibleSectionProps { interface CollapsibleSectionProps {
title: string; title: string;
icon?: React.ComponentType<{ className?: string }>;
children: React.ReactNode; children: React.ReactNode;
defaultOpen?: boolean; id: 'filters' | 'selection' | 'transform' | 'shapeSettings';
} }
function CollapsibleSection({ title, children, defaultOpen = true }: CollapsibleSectionProps) { function CollapsibleSection({ title, icon: Icon, children, id }: CollapsibleSectionProps) {
const [isOpen, setIsOpen] = useState(defaultOpen); const { collapsed, toggleCollapsed } = useUIStore();
const isOpen = collapsed[id];
return ( return (
<div className="border-b border-border"> <div className="border-b border-border">
<button <button
onClick={() => setIsOpen(!isOpen)} onClick={() => toggleCollapsed(id)}
className="w-full flex items-center justify-between p-3 hover:bg-accent/50 transition-colors" 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 ? ( {isOpen ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" /> <ChevronDown className="h-4 w-4 text-muted-foreground" />
) : ( ) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" /> <ChevronRight className="h-4 w-4 text-muted-foreground" />
)} )}
</button> </button>
{isOpen && <div className="pb-2">{children}</div>} {isOpen && <div>{children}</div>}
</div> </div>
); );
} }
export function PanelDock() { 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 ( return (
<div className="w-[280px] border-l border-border bg-card flex flex-col overflow-hidden"> <div
{/* Always visible: Layers Panel */} ref={dockRef}
<div className="border-b border-border" style={{ height: '50%', minHeight: '300px' }}> 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 /> <LayersPanel />
</div> </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 */} {/* 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 */} {/* Tab buttons */}
<div className="flex border-b border-border"> <div className="flex border-b border-border">
<button <button
onClick={() => setActiveTab('adjustments')} 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' activeTab === 'adjustments'
? 'bg-background text-foreground border-b-2 border-primary' ? 'bg-background text-foreground border-b-2 border-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50' : 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
}`} }`}
> >
<Sliders className="h-4 w-4" />
Adjustments Adjustments
</button> </button>
<button <button
onClick={() => setActiveTab('tools')} 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' activeTab === 'tools'
? 'bg-background text-foreground border-b-2 border-primary' ? 'bg-background text-foreground border-b-2 border-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50' : 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
}`} }`}
> >
<Wrench className="h-4 w-4" />
Tools Tools
</button> </button>
<button <button
onClick={() => setActiveTab('history')} 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' activeTab === 'history'
? 'bg-background text-foreground border-b-2 border-primary' ? 'bg-background text-foreground border-b-2 border-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50' : 'text-muted-foreground hover:text-foreground hover:bg-accent/50'
}`} }`}
> >
<History className="h-4 w-4" />
History History
</button> </button>
</div> </div>
@@ -87,16 +223,13 @@ export function PanelDock() {
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{activeTab === 'adjustments' && ( {activeTab === 'adjustments' && (
<div> <div>
<CollapsibleSection title="Colors"> <CollapsibleSection title="Filters" icon={Wand2} id="filters">
<ColorPanel />
</CollapsibleSection>
<CollapsibleSection title="Filters">
<FilterPanel /> <FilterPanel />
</CollapsibleSection> </CollapsibleSection>
<CollapsibleSection title="Selection"> <CollapsibleSection title="Selection" icon={Square} id="selection">
<SelectionPanel /> <SelectionPanel />
</CollapsibleSection> </CollapsibleSection>
<CollapsibleSection title="Transform"> <CollapsibleSection title="Transform" icon={Move} id="transform">
<TransformPanel /> <TransformPanel />
</CollapsibleSection> </CollapsibleSection>
</div> </div>
@@ -104,7 +237,7 @@ export function PanelDock() {
{activeTab === 'tools' && ( {activeTab === 'tools' && (
<div> <div>
<CollapsibleSection title="Shape Settings"> <CollapsibleSection title="Shape Settings" icon={Hexagon} id="shapeSettings">
<ShapePanel /> <ShapePanel />
</CollapsibleSection> </CollapsibleSection>
</div> </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 ( return (
<div className="w-64 border-l border-border bg-card flex flex-col"> <div className="w-full 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>
{/* Filter list */} {/* Filter list */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<div className="p-2 space-y-1"> <div className="p-2 space-y-1">

View File

@@ -128,13 +128,7 @@ export function SelectionPanel() {
}; };
return ( return (
<div className="w-64 border-l border-border bg-card flex flex-col"> <div className="w-full 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>
{/* Selection Tools */} {/* Selection Tools */}
<div className="border-b border-border p-3"> <div className="border-b border-border p-3">
<h3 className="text-xs font-semibold text-muted-foreground uppercase mb-2"> <h3 className="text-xs font-semibold text-muted-foreground uppercase mb-2">

View File

@@ -63,13 +63,7 @@ export function ShapePanel() {
}; };
return ( return (
<div className="w-64 border-l border-border bg-card flex flex-col"> <div className="w-full 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>
{/* Shape Types */} {/* Shape Types */}
<div className="border-b border-border p-3"> <div className="border-b border-border p-3">
<h3 className="text-xs font-semibold text-muted-foreground uppercase mb-2"> <h3 className="text-xs font-semibold text-muted-foreground uppercase mb-2">

View File

@@ -51,13 +51,7 @@ export function TransformPanel() {
}; };
return ( return (
<div className="w-64 border-l border-border bg-card flex flex-col"> <div className="w-full 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>
{/* Transform Tools */} {/* Transform Tools */}
<div className="border-b border-border p-3"> <div className="border-b border-border p-3">
<h3 className="text-xs font-semibold text-muted-foreground uppercase mb-2"> <h3 className="text-xs font-semibold text-muted-foreground uppercase mb-2">

View File

@@ -41,12 +41,15 @@ export function drawCheckerboard(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
squareSize = 10, squareSize = 10,
color1 = '#ffffff', color1 = '#ffffff',
color2 = '#cccccc' color2 = '#cccccc',
width?: number,
height?: number
): void { ): void {
const { width, height } = ctx.canvas; const w = width ?? ctx.canvas.width;
const h = height ?? ctx.canvas.height;
for (let y = 0; y < height; y += squareSize) { for (let y = 0; y < h; y += squareSize) {
for (let x = 0; x < width; x += squareSize) { for (let x = 0; x < w; x += squareSize) {
const isEven = (Math.floor(x / squareSize) + Math.floor(y / squareSize)) % 2 === 0; const isEven = (Math.floor(x / squareSize) + Math.floor(y / squareSize)) % 2 === 0;
ctx.fillStyle = isEven ? color1 : color2; ctx.fillStyle = isEven ? color1 : color2;
ctx.fillRect(x, y, squareSize, squareSize); ctx.fillRect(x, y, squareSize, squareSize);
@@ -160,26 +163,29 @@ export async function loadImageFromFile(file: File): Promise<HTMLImageElement> {
export function drawGrid( export function drawGrid(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
gridSize: number, gridSize: number,
color = 'rgba(0, 0, 0, 0.1)' color = 'rgba(0, 0, 0, 0.1)',
width?: number,
height?: number
): void { ): void {
const { width, height } = ctx.canvas; const w = width ?? ctx.canvas.width;
const h = height ?? ctx.canvas.height;
ctx.strokeStyle = color; ctx.strokeStyle = color;
ctx.lineWidth = 1; ctx.lineWidth = 1;
// Vertical lines // Vertical lines
for (let x = 0; x <= width; x += gridSize) { for (let x = 0; x <= w; x += gridSize) {
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(x + 0.5, 0); ctx.moveTo(x + 0.5, 0);
ctx.lineTo(x + 0.5, height); ctx.lineTo(x + 0.5, h);
ctx.stroke(); ctx.stroke();
} }
// Horizontal lines // Horizontal lines
for (let y = 0; y <= height; y += gridSize) { for (let y = 0; y <= h; y += gridSize) {
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(0, y + 0.5); ctx.moveTo(0, y + 0.5);
ctx.lineTo(width, y + 0.5); ctx.lineTo(w, y + 0.5);
ctx.stroke(); ctx.stroke();
} }
} }

View File

@@ -1,4 +1,5 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { CanvasState, CanvasSelection, Point } from '@/types'; import type { CanvasState, CanvasSelection, Point } from '@/types';
interface CanvasStore extends CanvasState { interface CanvasStore extends CanvasState {
@@ -38,7 +39,7 @@ interface CanvasStore extends CanvasState {
/** Clear selection */ /** Clear selection */
clearSelection: () => void; clearSelection: () => void;
/** Convert screen coordinates to canvas coordinates */ /** Convert screen coordinates to canvas coordinates */
screenToCanvas: (screenX: number, screenY: number) => Point; screenToCanvas: (screenX: number, screenY: number, containerWidth?: number, containerHeight?: number) => Point;
/** Convert canvas coordinates to screen coordinates */ /** Convert canvas coordinates to screen coordinates */
canvasToScreen: (canvasX: number, canvasY: number) => Point; canvasToScreen: (canvasX: number, canvasY: number) => Point;
} }
@@ -49,24 +50,26 @@ const MIN_ZOOM = 0.1;
const MAX_ZOOM = 10; const MAX_ZOOM = 10;
const ZOOM_STEP = 1.2; const ZOOM_STEP = 1.2;
export const useCanvasStore = create<CanvasStore>((set, get) => ({ export const useCanvasStore = create<CanvasStore>()(
width: DEFAULT_CANVAS_WIDTH, persist(
height: DEFAULT_CANVAS_HEIGHT, (set, get) => ({
zoom: 1, width: DEFAULT_CANVAS_WIDTH,
offsetX: 0, height: DEFAULT_CANVAS_HEIGHT,
offsetY: 0, zoom: 1,
backgroundColor: '#ffffff', offsetX: 0,
showGrid: false, offsetY: 0,
gridSize: 20, backgroundColor: '#ffffff',
showRulers: true, showGrid: false,
snapToGrid: false, gridSize: 20,
selection: { showRulers: true,
active: false, snapToGrid: false,
x: 0, selection: {
y: 0, active: false,
width: 0, x: 0,
height: 0, y: 0,
}, width: 0,
height: 0,
},
setDimensions: (width, height) => { setDimensions: (width, height) => {
set({ width, height }); set({ width, height });
@@ -153,11 +156,20 @@ export const useCanvasStore = create<CanvasStore>((set, get) => ({
}); });
}, },
screenToCanvas: (screenX, screenY) => { screenToCanvas: (screenX, screenY, containerWidth = 0, containerHeight = 0) => {
const { zoom, offsetX, offsetY } = get(); const { zoom, offsetX, offsetY, width, height } = get();
// The canvas is rendered with this transformation:
// 1. translate(offsetX + containerWidth/2, offsetY + containerHeight/2) - center in viewport with offset
// 2. scale(zoom) - apply zoom
// 3. translate(-width/2, -height/2) - position canvas so (0,0) is at top-left
//
// To reverse:
// 1. Subtract container center and offset
// 2. Divide by zoom
// 3. Add canvas center
return { return {
x: (screenX - offsetX) / zoom, x: (screenX - containerWidth / 2 - offsetX) / zoom + width / 2,
y: (screenY - offsetY) / zoom, y: (screenY - containerHeight / 2 - offsetY) / zoom + height / 2,
}; };
}, },
@@ -168,4 +180,17 @@ export const useCanvasStore = create<CanvasStore>((set, get) => ({
y: canvasY * zoom + offsetY, y: canvasY * zoom + offsetY,
}; };
}, },
})); }),
{
name: 'canvas-view-storage',
partialize: (state) => ({
backgroundColor: state.backgroundColor,
showGrid: state.showGrid,
gridSize: state.gridSize,
showRulers: state.showRulers,
snapToGrid: state.snapToGrid,
// Exclude: width, height, zoom, offsetX, offsetY, selection
}),
}
)
);

View File

@@ -7,3 +7,4 @@ export * from './color-store';
export * from './selection-store'; export * from './selection-store';
export * from './transform-store'; export * from './transform-store';
export * from './shape-store'; export * from './shape-store';
export * from './ui-store';

View File

@@ -1,4 +1,5 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { ShapeSettings, ShapeType, ShapeStore as IShapeStore } from '@/types/shape'; import type { ShapeSettings, ShapeType, ShapeStore as IShapeStore } from '@/types/shape';
const DEFAULT_SETTINGS: ShapeSettings = { const DEFAULT_SETTINGS: ShapeSettings = {
@@ -15,66 +16,73 @@ const DEFAULT_SETTINGS: ShapeSettings = {
arrowHeadAngle: 30, arrowHeadAngle: 30,
}; };
export const useShapeStore = create<IShapeStore>((set) => ({ export const useShapeStore = create<IShapeStore>()(
settings: { ...DEFAULT_SETTINGS }, persist(
(set) => ({
settings: { ...DEFAULT_SETTINGS },
setShapeType: (type) => setShapeType: (type) =>
set((state) => ({ set((state) => ({
settings: { ...state.settings, type }, settings: { ...state.settings, type },
})), })),
setFill: (fill) => setFill: (fill) =>
set((state) => ({ set((state) => ({
settings: { ...state.settings, fill }, settings: { ...state.settings, fill },
})), })),
setFillColor: (fillColor) => setFillColor: (fillColor) =>
set((state) => ({ set((state) => ({
settings: { ...state.settings, fillColor }, settings: { ...state.settings, fillColor },
})), })),
setStroke: (stroke) => setStroke: (stroke) =>
set((state) => ({ set((state) => ({
settings: { ...state.settings, stroke }, settings: { ...state.settings, stroke },
})), })),
setStrokeColor: (strokeColor) => setStrokeColor: (strokeColor) =>
set((state) => ({ set((state) => ({
settings: { ...state.settings, strokeColor }, settings: { ...state.settings, strokeColor },
})), })),
setStrokeWidth: (strokeWidth) => setStrokeWidth: (strokeWidth) =>
set((state) => ({ set((state) => ({
settings: { ...state.settings, strokeWidth: Math.max(1, Math.min(100, strokeWidth)) }, settings: { ...state.settings, strokeWidth: Math.max(1, Math.min(100, strokeWidth)) },
})), })),
setCornerRadius: (cornerRadius) => setCornerRadius: (cornerRadius) =>
set((state) => ({ set((state) => ({
settings: { ...state.settings, cornerRadius: Math.max(0, Math.min(100, cornerRadius)) }, settings: { ...state.settings, cornerRadius: Math.max(0, Math.min(100, cornerRadius)) },
})), })),
setSides: (sides) => setSides: (sides) =>
set((state) => ({ set((state) => ({
settings: { ...state.settings, sides: Math.max(3, Math.min(20, sides)) }, settings: { ...state.settings, sides: Math.max(3, Math.min(20, sides)) },
})), })),
setInnerRadius: (innerRadius) => setInnerRadius: (innerRadius) =>
set((state) => ({ set((state) => ({
settings: { ...state.settings, innerRadius: Math.max(0.1, Math.min(0.9, innerRadius)) }, settings: { ...state.settings, innerRadius: Math.max(0.1, Math.min(0.9, innerRadius)) },
})), })),
setArrowHeadSize: (arrowHeadSize) => setArrowHeadSize: (arrowHeadSize) =>
set((state) => ({ set((state) => ({
settings: { ...state.settings, arrowHeadSize: Math.max(5, Math.min(100, arrowHeadSize)) }, settings: { ...state.settings, arrowHeadSize: Math.max(5, Math.min(100, arrowHeadSize)) },
})), })),
setArrowHeadAngle: (arrowHeadAngle) => setArrowHeadAngle: (arrowHeadAngle) =>
set((state) => ({ set((state) => ({
settings: { ...state.settings, arrowHeadAngle: Math.max(10, Math.min(60, arrowHeadAngle)) }, settings: { ...state.settings, arrowHeadAngle: Math.max(10, Math.min(60, arrowHeadAngle)) },
})), })),
updateSettings: (settings) => updateSettings: (settings) =>
set((state) => ({ set((state) => ({
settings: { ...state.settings, ...settings }, settings: { ...state.settings, ...settings },
})), })),
})); }),
{
name: 'shape-storage',
}
)
);

View File

@@ -1,4 +1,5 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { ToolType, ToolSettings, ToolState } from '@/types'; import type { ToolType, ToolSettings, ToolState } from '@/types';
interface ToolStore extends ToolState { interface ToolStore extends ToolState {
@@ -31,77 +32,89 @@ const DEFAULT_SETTINGS: ToolSettings = {
spacing: 0.25, spacing: 0.25,
}; };
export const useToolStore = create<ToolStore>((set) => ({ export const useToolStore = create<ToolStore>()(
activeTool: 'brush', persist(
settings: { ...DEFAULT_SETTINGS }, (set) => ({
cursor: 'crosshair', activeTool: 'brush',
settings: { ...DEFAULT_SETTINGS },
cursor: 'crosshair',
setActiveTool: (tool) => { setActiveTool: (tool) => {
const cursors: Record<ToolType, string> = { const cursors: Record<ToolType, string> = {
select: 'crosshair', select: 'crosshair',
move: 'move', move: 'move',
pencil: 'crosshair', pencil: 'crosshair',
brush: 'crosshair', brush: 'crosshair',
eraser: 'crosshair', eraser: 'crosshair',
fill: 'crosshair', fill: 'crosshair',
eyedropper: 'crosshair', eyedropper: 'crosshair',
text: 'text', text: 'text',
shape: 'crosshair', shape: 'crosshair',
crop: 'crosshair', crop: 'crosshair',
clone: 'crosshair', clone: 'crosshair',
blur: 'crosshair', blur: 'crosshair',
sharpen: 'crosshair', sharpen: 'crosshair',
}; };
set({ set({
activeTool: tool, activeTool: tool,
cursor: cursors[tool], cursor: cursors[tool],
}); });
}, },
updateSettings: (settings) => { updateSettings: (settings) => {
set((state) => ({ set((state) => ({
settings: { ...state.settings, ...settings }, settings: { ...state.settings, ...settings },
})); }));
}, },
setSize: (size) => { setSize: (size) => {
set((state) => ({ set((state) => ({
settings: { ...state.settings, size: Math.max(1, Math.min(1000, size)) }, settings: { ...state.settings, size: Math.max(1, Math.min(1000, size)) },
})); }));
}, },
setOpacity: (opacity) => { setOpacity: (opacity) => {
set((state) => ({ set((state) => ({
settings: { ...state.settings, opacity: Math.max(0, Math.min(1, opacity)) }, settings: { ...state.settings, opacity: Math.max(0, Math.min(1, opacity)) },
})); }));
}, },
setHardness: (hardness) => { setHardness: (hardness) => {
set((state) => ({ set((state) => ({
settings: { ...state.settings, hardness: Math.max(0, Math.min(1, hardness)) }, settings: { ...state.settings, hardness: Math.max(0, Math.min(1, hardness)) },
})); }));
}, },
setColor: (color) => { setColor: (color) => {
set((state) => ({ set((state) => ({
settings: { ...state.settings, color }, settings: { ...state.settings, color },
})); }));
}, },
setFlow: (flow) => { setFlow: (flow) => {
set((state) => ({ set((state) => ({
settings: { ...state.settings, flow: Math.max(0, Math.min(1, flow)) }, settings: { ...state.settings, flow: Math.max(0, Math.min(1, flow)) },
})); }));
}, },
setSpacing: (spacing) => { setSpacing: (spacing) => {
set((state) => ({ set((state) => ({
settings: { ...state.settings, spacing: Math.max(0.01, Math.min(10, spacing)) }, settings: { ...state.settings, spacing: Math.max(0.01, Math.min(10, spacing)) },
})); }));
}, },
resetSettings: () => { resetSettings: () => {
set({ settings: { ...DEFAULT_SETTINGS } }); set({ settings: { ...DEFAULT_SETTINGS } });
}, },
})); }),
{
name: 'tool-storage',
partialize: (state) => ({
activeTool: state.activeTool,
settings: state.settings,
// Exclude cursor - it's derived from activeTool
}),
}
)
);

92
store/ui-store.ts Normal file
View File

@@ -0,0 +1,92 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type PanelTab = 'adjustments' | 'tools' | 'history';
interface CollapsibleState {
filters: boolean;
selection: boolean;
transform: boolean;
shapeSettings: boolean;
}
interface PanelDockState {
activeTab: PanelTab;
width: number;
layersHeight: number;
colorsHeight: number;
}
interface UIStore {
panelDock: PanelDockState;
collapsed: CollapsibleState;
/** Set active tab in panel dock */
setActiveTab: (tab: PanelTab) => void;
/** Set panel dock width */
setPanelWidth: (width: number) => void;
/** Set layers panel height percentage */
setLayersHeight: (height: number) => void;
/** Set colors panel height percentage */
setColorsHeight: (height: number) => void;
/** Toggle collapsible section */
toggleCollapsed: (section: keyof CollapsibleState) => void;
/** Set collapsible section state */
setCollapsed: (section: keyof CollapsibleState, collapsed: boolean) => void;
}
const DEFAULT_PANEL_DOCK: PanelDockState = {
activeTab: 'adjustments',
width: 280,
layersHeight: 40,
colorsHeight: 20,
};
const DEFAULT_COLLAPSED: CollapsibleState = {
filters: true,
selection: true,
transform: true,
shapeSettings: true,
};
export const useUIStore = create<UIStore>()(
persist(
(set) => ({
panelDock: { ...DEFAULT_PANEL_DOCK },
collapsed: { ...DEFAULT_COLLAPSED },
setActiveTab: (tab) =>
set((state) => ({
panelDock: { ...state.panelDock, activeTab: tab },
})),
setPanelWidth: (width) =>
set((state) => ({
panelDock: { ...state.panelDock, width: Math.max(280, Math.min(600, width)) },
})),
setLayersHeight: (height) =>
set((state) => ({
panelDock: { ...state.panelDock, layersHeight: Math.max(15, Math.min(70, height)) },
})),
setColorsHeight: (height) =>
set((state) => ({
panelDock: { ...state.panelDock, colorsHeight: Math.max(10, Math.min(40, height)) },
})),
toggleCollapsed: (section) =>
set((state) => ({
collapsed: { ...state.collapsed, [section]: !state.collapsed[section] },
})),
setCollapsed: (section, collapsed) =>
set((state) => ({
collapsed: { ...state.collapsed, [section]: collapsed },
})),
}),
{
name: 'ui-storage',
}
)
);