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:
@@ -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
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -7,3 +7,4 @@ export * from './color-store';
|
||||
export * from './selection-store';
|
||||
export * from './transform-store';
|
||||
export * from './shape-store';
|
||||
export * from './ui-store';
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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
92
store/ui-store.ts
Normal 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',
|
||||
}
|
||||
)
|
||||
);
|
||||
Reference in New Issue
Block a user