Files
paint-ui/store/canvas-store.ts
Sebastian Krüger b7b072f6d2 feat: improve UI and transparency support
UI Improvements:
- Style scrollbars with primary color accent
- Scrollbar thumb transitions on hover (40% → 60% → 80% opacity)
- Add fill tool options to toolbar (color picker + opacity)
- Support for Firefox with scrollbar-color property

Transparency Support:
- Set default canvas background to transparent
- First layer now transparent instead of white fill
- Enables creating images with transparency
- Checkerboard pattern shows through transparent areas
- Proper PNG export support with alpha channel

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-21 09:17:12 +01:00

197 lines
5.1 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: 'transparent',
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
}),
}
)
);