From cd59f0606b6e7c7ac3bc87a071cf273901b2241e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Fri, 21 Nov 2025 09:03:14 +0100 Subject: [PATCH] feat: implement UI state persistence and theme toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- components/canvas/canvas-with-tools.tsx | 12 +- components/canvas/canvas-wrapper.tsx | 8 +- components/colors/color-panel.tsx | 88 ++++++----- components/editor/editor-layout.tsx | 8 +- components/editor/history-panel.tsx | 7 - components/editor/panel-dock.tsx | 181 ++++++++++++++++++++--- components/editor/theme-toggle.tsx | 55 +++++++ components/filters/filter-panel.tsx | 8 +- components/selection/selection-panel.tsx | 8 +- components/shapes/shape-panel.tsx | 8 +- components/transform/transform-panel.tsx | 8 +- lib/canvas-utils.ts | 26 ++-- store/canvas-store.ts | 73 ++++++--- store/index.ts | 1 + store/shape-store.ts | 110 +++++++------- store/tool-store.ts | 141 ++++++++++-------- store/ui-store.ts | 92 ++++++++++++ 17 files changed, 570 insertions(+), 264 deletions(-) create mode 100644 components/editor/theme-toggle.tsx create mode 100644 store/ui-store.ts diff --git a/components/canvas/canvas-with-tools.tsx b/components/canvas/canvas-with-tools.tsx index 6cca719..c7fc75e 100644 --- a/components/canvas/canvas-with-tools.tsx +++ b/components/canvas/canvas-with-tools.tsx @@ -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) { diff --git a/components/canvas/canvas-wrapper.tsx b/components/canvas/canvas-wrapper.tsx index 56ff1f4..16be9ae 100644 --- a/components/canvas/canvas-wrapper.tsx +++ b/components/canvas/canvas-wrapper.tsx @@ -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 diff --git a/components/colors/color-panel.tsx b/components/colors/color-panel.tsx index b292781..3a76860 100644 --- a/components/colors/color-panel.tsx +++ b/components/colors/color-panel.tsx @@ -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 ( -
- {/* Header */} -
-

Colors

-
- +
{/* Current colors display */} -
-
+
+
{/* Primary/Secondary color squares */}
- - +
+
+ + + +
{/* Content */} diff --git a/components/editor/editor-layout.tsx b/components/editor/editor-layout.tsx index 76fd5d4..4e899d2 100644 --- a/components/editor/editor-layout.tsx +++ b/components/editor/editor-layout.tsx @@ -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() {
+ {/* Theme Toggle */} + + +
+ {/* New Layer */} - {isOpen &&
{children}
} + {isOpen &&
{children}
}
); } 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(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 ( -
- {/* Always visible: Layers Panel */} -
+
+ {/* Width resize handle (left edge) */} +
setIsResizingWidth(true)} + /> + + {/* Layers Panel */} +
+ {/* Height resize handle (between layers and colors) */} +
setIsResizingLayersHeight(true)} + /> + + {/* Colors Panel */} +
+
+

Colors

+
+
+ +
+
+ + {/* Height resize handle (between colors and tabs) */} +
setIsResizingColorsHeight(true)} + /> + {/* Tabbed section */} -
+
{/* Tab buttons */}
@@ -87,16 +223,13 @@ export function PanelDock() {
{activeTab === 'adjustments' && (
- - - - + - + - +
@@ -104,7 +237,7 @@ export function PanelDock() { {activeTab === 'tools' && (
- +
diff --git a/components/editor/theme-toggle.tsx b/components/editor/theme-toggle.tsx new file mode 100644 index 0000000..e119677 --- /dev/null +++ b/components/editor/theme-toggle.tsx @@ -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 ( + + ); + } + + return ( + + ); +} diff --git a/components/filters/filter-panel.tsx b/components/filters/filter-panel.tsx index 9b4f6d1..2c570a3 100644 --- a/components/filters/filter-panel.tsx +++ b/components/filters/filter-panel.tsx @@ -109,13 +109,7 @@ export function FilterPanel() { }; return ( -
- {/* Header */} -
- -

Filters

-
- +
{/* Filter list */}
diff --git a/components/selection/selection-panel.tsx b/components/selection/selection-panel.tsx index e4580f2..b82f57a 100644 --- a/components/selection/selection-panel.tsx +++ b/components/selection/selection-panel.tsx @@ -128,13 +128,7 @@ export function SelectionPanel() { }; return ( -
- {/* Header */} -
- -

Selection

-
- +
{/* Selection Tools */}

diff --git a/components/shapes/shape-panel.tsx b/components/shapes/shape-panel.tsx index ebf9fd2..0162785 100644 --- a/components/shapes/shape-panel.tsx +++ b/components/shapes/shape-panel.tsx @@ -63,13 +63,7 @@ export function ShapePanel() { }; return ( -
- {/* Header */} -
- -

Shapes

-
- +
{/* Shape Types */}

diff --git a/components/transform/transform-panel.tsx b/components/transform/transform-panel.tsx index c53ba69..4554c42 100644 --- a/components/transform/transform-panel.tsx +++ b/components/transform/transform-panel.tsx @@ -51,13 +51,7 @@ export function TransformPanel() { }; return ( -
- {/* Header */} -
- -

Transform

-
- +
{/* Transform Tools */}

diff --git a/lib/canvas-utils.ts b/lib/canvas-utils.ts index c9383f8..3e541fe 100644 --- a/lib/canvas-utils.ts +++ b/lib/canvas-utils.ts @@ -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 { 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(); } } diff --git a/store/canvas-store.ts b/store/canvas-store.ts index da046d6..c22273c 100644 --- a/store/canvas-store.ts +++ b/store/canvas-store.ts @@ -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((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()( + 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((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((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 + }), + } + ) +); diff --git a/store/index.ts b/store/index.ts index 0cd8940..b6ec410 100644 --- a/store/index.ts +++ b/store/index.ts @@ -7,3 +7,4 @@ export * from './color-store'; export * from './selection-store'; export * from './transform-store'; export * from './shape-store'; +export * from './ui-store'; diff --git a/store/shape-store.ts b/store/shape-store.ts index e4fce24..8c4fe8c 100644 --- a/store/shape-store.ts +++ b/store/shape-store.ts @@ -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((set) => ({ - settings: { ...DEFAULT_SETTINGS }, +export const useShapeStore = create()( + 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', + } + ) +); diff --git a/store/tool-store.ts b/store/tool-store.ts index 309098d..5fa7a12 100644 --- a/store/tool-store.ts +++ b/store/tool-store.ts @@ -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((set) => ({ - activeTool: 'brush', - settings: { ...DEFAULT_SETTINGS }, - cursor: 'crosshair', +export const useToolStore = create()( + persist( + (set) => ({ + activeTool: 'brush', + settings: { ...DEFAULT_SETTINGS }, + cursor: 'crosshair', - setActiveTool: (tool) => { - const cursors: Record = { - 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 = { + 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 + }), + } + ) +); diff --git a/store/ui-store.ts b/store/ui-store.ts new file mode 100644 index 0000000..e279a36 --- /dev/null +++ b/store/ui-store.ts @@ -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()( + 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', + } + ) +);