Collaborative-pixel-art/frontend/src/store/canvasStore.ts
martin 415919b3e1 Fix pixel persistence and improve mobile UX
- Fix pixel data storage to include user information (userId, username, timestamp)
- Enhance zoom controls to center properly without drift
- Improve mobile modal centering with flexbox layout
- Add dynamic backend URL detection for network access
- Fix CORS configuration for development mode
- Add mobile-optimized touch targets and safe area support

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-22 20:14:48 +02:00

284 lines
No EOL
8.3 KiB
TypeScript

import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { CANVAS_CONFIG, COLORS } from '@gaplace/shared';
interface PixelData {
x: number;
y: number;
color: string;
timestamp?: number;
userId?: string;
username?: string;
}
interface PixelInfo {
color: string;
userId?: string;
username?: string;
timestamp?: number;
}
interface Chunk {
chunkX: number;
chunkY: number;
pixels: Map<string, PixelInfo>; // key: "x,y", value: pixel info
isLoaded: boolean;
lastModified: number;
}
interface Viewport {
x: number;
y: number;
width: number;
height: number;
zoom: number;
}
interface CanvasState {
// Canvas data
canvasId: string;
chunks: Map<string, Chunk>; // key: "chunkX,chunkY"
// Viewport
viewport: Viewport;
// Tools
selectedColor: string;
selectedTool: 'pixel' | 'fill' | 'eyedropper';
brushSize: number;
// UI state
isLoading: boolean;
isPanning: boolean;
showGrid: boolean;
showCursors: boolean;
// User presence
activeUsers: string[];
userCursors: Map<string, { x: number; y: number; username: string; color: string }>;
// Stats
totalPixels: number;
userPixels: number;
// Actions
setCanvasId: (id: string) => void;
setPixel: (x: number, y: number, color: string, userId?: string, username?: string) => void;
loadChunk: (chunkX: number, chunkY: number, pixels: PixelData[]) => void;
setViewport: (viewport: Partial<Viewport>) => void;
setZoom: (zoom: number, centerX?: number, centerY?: number) => void;
pan: (deltaX: number, deltaY: number) => void;
setSelectedColor: (color: string) => void;
setSelectedTool: (tool: 'pixel' | 'fill' | 'eyedropper') => void;
setBrushSize: (size: number) => void;
setUserCursor: (userId: string, x: number, y: number, username: string, color: string) => void;
removeUserCursor: (userId: string) => void;
setActiveUsers: (users: string[]) => void;
setStats: (totalPixels: number, userPixels?: number) => void;
getPixelAt: (x: number, y: number) => string | null;
getPixelInfo: (x: number, y: number) => PixelInfo | null;
getChunkKey: (chunkX: number, chunkY: number) => string;
getPixelKey: (x: number, y: number) => string;
getChunkCoordinates: (x: number, y: number) => { chunkX: number; chunkY: number };
}
export const useCanvasStore = create<CanvasState>()(
devtools(
(set, get) => ({
// Initial state
canvasId: 'default',
chunks: new Map(),
viewport: {
x: 0,
y: 0,
width: 1920,
height: 1080,
zoom: 1,
},
selectedColor: COLORS.PALETTE[0],
selectedTool: 'pixel',
brushSize: 1,
isLoading: false,
isPanning: false,
showGrid: true,
showCursors: true,
activeUsers: [],
userCursors: new Map(),
totalPixels: 0,
userPixels: 0,
// Actions
setCanvasId: (id) => set({ canvasId: id }),
setPixel: (x, y, color, userId, username) => {
const state = get();
const { chunkX, chunkY } = state.getChunkCoordinates(x, y);
const chunkKey = state.getChunkKey(chunkX, chunkY);
const pixelKey = state.getPixelKey(
x % CANVAS_CONFIG.DEFAULT_CHUNK_SIZE,
y % CANVAS_CONFIG.DEFAULT_CHUNK_SIZE
);
const chunks = new Map(state.chunks);
let chunk = chunks.get(chunkKey);
if (!chunk) {
chunk = {
chunkX,
chunkY,
pixels: new Map(),
isLoaded: true,
lastModified: Date.now(),
};
}
const pixelInfo: PixelInfo = {
color,
userId,
username,
timestamp: Date.now()
};
chunk.pixels.set(pixelKey, pixelInfo);
chunk.lastModified = Date.now();
chunks.set(chunkKey, chunk);
set({ chunks });
},
loadChunk: (chunkX, chunkY, pixels) => {
const state = get();
const chunkKey = state.getChunkKey(chunkX, chunkY);
const chunks = new Map(state.chunks);
const pixelMap = new Map<string, PixelInfo>();
pixels.forEach((pixel) => {
const localX = pixel.x % CANVAS_CONFIG.DEFAULT_CHUNK_SIZE;
const localY = pixel.y % CANVAS_CONFIG.DEFAULT_CHUNK_SIZE;
const pixelKey = state.getPixelKey(localX, localY);
pixelMap.set(pixelKey, {
color: pixel.color,
userId: pixel.userId,
username: pixel.username,
timestamp: pixel.timestamp
});
});
const chunk: Chunk = {
chunkX,
chunkY,
pixels: pixelMap,
isLoaded: true,
lastModified: Date.now(),
};
chunks.set(chunkKey, chunk);
set({ chunks });
},
setViewport: (newViewport) => {
const state = get();
set({
viewport: { ...state.viewport, ...newViewport },
});
},
setZoom: (zoom, centerX, centerY) => {
const state = get();
const clampedZoom = Math.max(CANVAS_CONFIG.MIN_ZOOM, Math.min(CANVAS_CONFIG.MAX_ZOOM, zoom));
let newViewport = { ...state.viewport, zoom: clampedZoom };
// If center point is provided, zoom towards that point
if (centerX !== undefined && centerY !== undefined) {
const BASE_PIXEL_SIZE = 32;
const currentPixelSize = BASE_PIXEL_SIZE * state.viewport.zoom;
const newPixelSize = BASE_PIXEL_SIZE * clampedZoom;
// Calculate what canvas coordinate is at the center point
const canvasCenterX = (centerX + state.viewport.x) / currentPixelSize;
const canvasCenterY = (centerY + state.viewport.y) / currentPixelSize;
// Calculate new viewport to keep same canvas point at center
newViewport.x = canvasCenterX * newPixelSize - centerX;
newViewport.y = canvasCenterY * newPixelSize - centerY;
}
set({ viewport: newViewport });
},
pan: (deltaX, deltaY) => {
const state = get();
set({
viewport: {
...state.viewport,
x: state.viewport.x + deltaX,
y: state.viewport.y + deltaY,
},
});
},
setSelectedColor: (color) => set({ selectedColor: color }),
setSelectedTool: (tool) => set({ selectedTool: tool }),
setBrushSize: (size) => set({ brushSize: Math.max(1, Math.min(10, size)) }),
setUserCursor: (userId, x, y, username, color) => {
const state = get();
const userCursors = new Map(state.userCursors);
userCursors.set(userId, { x, y, username, color });
set({ userCursors });
},
removeUserCursor: (userId) => {
const state = get();
const userCursors = new Map(state.userCursors);
userCursors.delete(userId);
set({ userCursors });
},
setActiveUsers: (users) => set({ activeUsers: users }),
setStats: (totalPixels, userPixels) => set({
totalPixels,
...(userPixels !== undefined && { userPixels })
}),
getPixelAt: (x, y) => {
const state = get();
const { chunkX, chunkY } = state.getChunkCoordinates(x, y);
const chunkKey = state.getChunkKey(chunkX, chunkY);
const chunk = state.chunks.get(chunkKey);
if (!chunk) return null;
const localX = x % CANVAS_CONFIG.DEFAULT_CHUNK_SIZE;
const localY = y % CANVAS_CONFIG.DEFAULT_CHUNK_SIZE;
const pixelKey = state.getPixelKey(localX, localY);
return chunk.pixels.get(pixelKey)?.color || null;
},
getPixelInfo: (x, y) => {
const state = get();
const { chunkX, chunkY } = state.getChunkCoordinates(x, y);
const chunkKey = state.getChunkKey(chunkX, chunkY);
const chunk = state.chunks.get(chunkKey);
if (!chunk) return null;
const localX = x % CANVAS_CONFIG.DEFAULT_CHUNK_SIZE;
const localY = y % CANVAS_CONFIG.DEFAULT_CHUNK_SIZE;
const pixelKey = state.getPixelKey(localX, localY);
return chunk.pixels.get(pixelKey) || null;
},
getChunkKey: (chunkX, chunkY) => `${chunkX},${chunkY}`,
getPixelKey: (x, y) => `${x},${y}`,
getChunkCoordinates: (x, y) => ({
chunkX: Math.floor(x / CANVAS_CONFIG.DEFAULT_CHUNK_SIZE),
chunkY: Math.floor(y / CANVAS_CONFIG.DEFAULT_CHUNK_SIZE),
}),
}),
{ name: 'canvas-store' }
)
);