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

@@ -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',
}
)
);