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>
197 lines
5.0 KiB
TypeScript
197 lines
5.0 KiB
TypeScript
import { create } from 'zustand';
|
|
import { persist } from 'zustand/middleware';
|
|
import type { CanvasState, CanvasSelection, Point } from '@/types';
|
|
|
|
interface CanvasStore extends CanvasState {
|
|
/** Selection state */
|
|
selection: CanvasSelection;
|
|
|
|
/** Set canvas dimensions */
|
|
setDimensions: (width: number, height: number) => void;
|
|
/** Set zoom level */
|
|
setZoom: (zoom: number) => void;
|
|
/** Zoom in (1.2x) */
|
|
zoomIn: () => void;
|
|
/** Zoom out (0.8x) */
|
|
zoomOut: () => void;
|
|
/** Fit canvas to viewport */
|
|
zoomToFit: (viewportWidth: number, viewportHeight: number) => void;
|
|
/** Zoom to actual size (100%) */
|
|
zoomToActual: () => void;
|
|
/** Set pan offset */
|
|
setPanOffset: (x: number, y: number) => void;
|
|
/** Pan by delta */
|
|
pan: (dx: number, dy: number) => void;
|
|
/** Reset pan to center */
|
|
resetPan: () => void;
|
|
/** Toggle grid visibility */
|
|
toggleGrid: () => void;
|
|
/** Set grid size */
|
|
setGridSize: (size: number) => void;
|
|
/** Toggle rulers */
|
|
toggleRulers: () => void;
|
|
/** Toggle snap to grid */
|
|
toggleSnapToGrid: () => void;
|
|
/** Set background color */
|
|
setBackgroundColor: (color: string) => void;
|
|
/** Set selection */
|
|
setSelection: (selection: Partial<CanvasSelection>) => void;
|
|
/** Clear selection */
|
|
clearSelection: () => void;
|
|
/** Convert screen coordinates to canvas coordinates */
|
|
screenToCanvas: (screenX: number, screenY: number, containerWidth?: number, containerHeight?: number) => Point;
|
|
/** Convert canvas coordinates to screen coordinates */
|
|
canvasToScreen: (canvasX: number, canvasY: number) => Point;
|
|
}
|
|
|
|
const DEFAULT_CANVAS_WIDTH = 800;
|
|
const DEFAULT_CANVAS_HEIGHT = 600;
|
|
const MIN_ZOOM = 0.1;
|
|
const MAX_ZOOM = 10;
|
|
const ZOOM_STEP = 1.2;
|
|
|
|
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 });
|
|
},
|
|
|
|
setZoom: (zoom) => {
|
|
const clampedZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom));
|
|
set({ zoom: clampedZoom });
|
|
},
|
|
|
|
zoomIn: () => {
|
|
const { zoom } = get();
|
|
get().setZoom(zoom * ZOOM_STEP);
|
|
},
|
|
|
|
zoomOut: () => {
|
|
const { zoom } = get();
|
|
get().setZoom(zoom / ZOOM_STEP);
|
|
},
|
|
|
|
zoomToFit: (viewportWidth, viewportHeight) => {
|
|
const { width, height } = get();
|
|
const padding = 40;
|
|
const scaleX = (viewportWidth - padding * 2) / width;
|
|
const scaleY = (viewportHeight - padding * 2) / height;
|
|
const zoom = Math.min(scaleX, scaleY, 1);
|
|
set({ zoom, offsetX: 0, offsetY: 0 });
|
|
},
|
|
|
|
zoomToActual: () => {
|
|
set({ zoom: 1 });
|
|
},
|
|
|
|
setPanOffset: (x, y) => {
|
|
set({ offsetX: x, offsetY: y });
|
|
},
|
|
|
|
pan: (dx, dy) => {
|
|
set((state) => ({
|
|
offsetX: state.offsetX + dx,
|
|
offsetY: state.offsetY + dy,
|
|
}));
|
|
},
|
|
|
|
resetPan: () => {
|
|
set({ offsetX: 0, offsetY: 0 });
|
|
},
|
|
|
|
toggleGrid: () => {
|
|
set((state) => ({ showGrid: !state.showGrid }));
|
|
},
|
|
|
|
setGridSize: (size) => {
|
|
set({ gridSize: Math.max(1, size) });
|
|
},
|
|
|
|
toggleRulers: () => {
|
|
set((state) => ({ showRulers: !state.showRulers }));
|
|
},
|
|
|
|
toggleSnapToGrid: () => {
|
|
set((state) => ({ snapToGrid: !state.snapToGrid }));
|
|
},
|
|
|
|
setBackgroundColor: (color) => {
|
|
set({ backgroundColor: color });
|
|
},
|
|
|
|
setSelection: (selection) => {
|
|
set((state) => ({
|
|
selection: { ...state.selection, ...selection },
|
|
}));
|
|
},
|
|
|
|
clearSelection: () => {
|
|
set({
|
|
selection: {
|
|
active: false,
|
|
x: 0,
|
|
y: 0,
|
|
width: 0,
|
|
height: 0,
|
|
},
|
|
});
|
|
},
|
|
|
|
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 - containerWidth / 2 - offsetX) / zoom + width / 2,
|
|
y: (screenY - containerHeight / 2 - offsetY) / zoom + height / 2,
|
|
};
|
|
},
|
|
|
|
canvasToScreen: (canvasX, canvasY) => {
|
|
const { zoom, offsetX, offsetY } = get();
|
|
return {
|
|
x: canvasX * zoom + offsetX,
|
|
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
|
|
}),
|
|
}
|
|
)
|
|
);
|