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">

View File

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

View File

@@ -1,4 +1,5 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { CanvasState, CanvasSelection, Point } from '@/types';
interface CanvasStore extends CanvasState {
@@ -38,7 +39,7 @@ interface CanvasStore extends CanvasState {
/** Clear selection */
clearSelection: () => void;
/** 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 */
canvasToScreen: (canvasX: number, canvasY: number) => Point;
}
@@ -49,24 +50,26 @@ const MIN_ZOOM = 0.1;
const MAX_ZOOM = 10;
const ZOOM_STEP = 1.2;
export const useCanvasStore = create<CanvasStore>((set, get) => ({
width: DEFAULT_CANVAS_WIDTH,
height: DEFAULT_CANVAS_HEIGHT,
zoom: 1,
offsetX: 0,
offsetY: 0,
backgroundColor: '#ffffff',
showGrid: false,
gridSize: 20,
showRulers: true,
snapToGrid: false,
selection: {
active: false,
x: 0,
y: 0,
width: 0,
height: 0,
},
export const useCanvasStore = create<CanvasStore>()(
persist(
(set, get) => ({
width: DEFAULT_CANVAS_WIDTH,
height: DEFAULT_CANVAS_HEIGHT,
zoom: 1,
offsetX: 0,
offsetY: 0,
backgroundColor: '#ffffff',
showGrid: false,
gridSize: 20,
showRulers: true,
snapToGrid: false,
selection: {
active: false,
x: 0,
y: 0,
width: 0,
height: 0,
},
setDimensions: (width, height) => {
set({ width, height });
@@ -153,11 +156,20 @@ export const useCanvasStore = create<CanvasStore>((set, get) => ({
});
},
screenToCanvas: (screenX, screenY) => {
const { zoom, offsetX, offsetY } = get();
screenToCanvas: (screenX, screenY, containerWidth = 0, containerHeight = 0) => {
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 {
x: (screenX - offsetX) / zoom,
y: (screenY - offsetY) / zoom,
x: (screenX - containerWidth / 2 - offsetX) / zoom + width / 2,
y: (screenY - containerHeight / 2 - offsetY) / zoom + height / 2,
};
},
@@ -168,4 +180,17 @@ export const useCanvasStore = create<CanvasStore>((set, get) => ({
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 './transform-store';
export * from './shape-store';
export * from './ui-store';

View File

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

View File

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