- 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>
284 lines
No EOL
8.3 KiB
TypeScript
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' }
|
|
)
|
|
); |