Collaborative-pixel-art/frontend/src/components/canvas/VirtualCanvas.tsx
martin 98f290a662 Rewrite with modern stack
Major refactor from simple HTML/JS app to modern full-stack TypeScript application:

## Architecture Changes
- Migrated to monorepo structure with workspaces (backend, frontend, shared)
- Backend: Node.js + Express + TypeScript + Socket.IO
- Frontend: Next.js 15.5 + React 19 + TypeScript + Tailwind CSS
- Shared: Common types and utilities across packages

## Key Features Implemented
- Real-time WebSocket collaboration via Socket.IO
- Virtual canvas with chunked loading for performance
- Modern UI with dark mode and responsive design
- Mock database system for easy development (Redis/PostgreSQL compatible)
- Comprehensive error handling and rate limiting
- User presence and cursor tracking
- Infinite canvas support with zoom/pan controls

## Performance Optimizations
- Canvas virtualization - only renders visible viewport
- Chunked pixel data loading (64x64 pixel chunks)
- Optimized WebSocket protocol
- Memory-efficient state management with Zustand

## Development Experience
- Full TypeScript support across all packages
- Hot reload for both frontend and backend
- Docker support for production deployment
- Comprehensive linting and formatting
- Automated development server startup

## Fixed Issues
- Corrected start script paths
- Updated environment configuration
- Fixed ESLint configuration issues
- Ensured all dependencies are properly installed
- Verified build process works correctly

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-22 19:28:05 +02:00

582 lines
No EOL
20 KiB
TypeScript

'use client';
import { useRef, useEffect, useState, useCallback } from 'react';
import { useCanvasStore } from '../../store/canvasStore';
import { CANVAS_CONFIG } from '@gaplace/shared';
interface VirtualCanvasProps {
onPixelClick: (x: number, y: number) => void;
onCursorMove: (x: number, y: number) => void;
onChunkNeeded: (chunkX: number, chunkY: number) => void;
onHoverChange: (x: number, y: number, pixelInfo: { color: string; userId?: string } | null) => void;
selectedColor: string;
}
export function VirtualCanvas({ onPixelClick, onCursorMove, onChunkNeeded, onHoverChange, selectedColor }: VirtualCanvasProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const animationFrameRef = useRef<number | undefined>(undefined);
const isMouseDownRef = useRef(false);
const isPanningRef = useRef(false);
const lastPanPointRef = useRef<{ x: number; y: number } | null>(null);
const mouseDownPositionRef = useRef<{ x: number; y: number } | null>(null);
const DRAG_THRESHOLD = 5; // pixels
const [cursorStyle, setCursorStyle] = useState<'crosshair' | 'grab' | 'grabbing'>('crosshair');
const [hoverPixel, setHoverPixel] = useState<{ x: number; y: number } | null>(null);
// Touch handling state
const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(null);
const lastTouchesRef = useRef<TouchList | null>(null);
const pinchStartDistanceRef = useRef<number | null>(null);
const pinchStartZoomRef = useRef<number | null>(null);
const {
viewport,
chunks,
selectedTool,
showGrid,
userCursors,
setViewport,
setZoom,
pan,
getChunkCoordinates,
getPixelAt,
getPixelInfo,
} = useCanvasStore();
// Large modern pixel size - fixed base size to avoid circular dependencies
const BASE_PIXEL_SIZE = 32;
const pixelSize = BASE_PIXEL_SIZE * viewport.zoom;
// Convert screen coordinates to canvas coordinates
const screenToCanvas = useCallback((screenX: number, screenY: number) => {
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return { x: 0, y: 0 };
const x = Math.floor((screenX - rect.left + viewport.x) / pixelSize);
const y = Math.floor((screenY - rect.top + viewport.y) / pixelSize);
return { x, y };
}, [viewport.x, viewport.y, pixelSize]);
// Convert canvas coordinates to screen coordinates
const canvasToScreen = useCallback((canvasX: number, canvasY: number) => {
return {
x: canvasX * pixelSize - viewport.x,
y: canvasY * pixelSize - viewport.y,
};
}, [viewport.x, viewport.y, pixelSize]);
// Track requested chunks to prevent spam
const requestedChunksRef = useRef(new Set<string>());
// Get visible chunks and request loading if needed
const getVisibleChunks = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return [];
const startX = Math.floor(viewport.x / pixelSize);
const startY = Math.floor(viewport.y / pixelSize);
const endX = Math.floor((viewport.x + canvas.width) / pixelSize);
const endY = Math.floor((viewport.y + canvas.height) / pixelSize);
const startChunkX = Math.floor(startX / CANVAS_CONFIG.DEFAULT_CHUNK_SIZE);
const startChunkY = Math.floor(startY / CANVAS_CONFIG.DEFAULT_CHUNK_SIZE);
const endChunkX = Math.floor(endX / CANVAS_CONFIG.DEFAULT_CHUNK_SIZE);
const endChunkY = Math.floor(endY / CANVAS_CONFIG.DEFAULT_CHUNK_SIZE);
const visibleChunks: Array<{ chunkX: number; chunkY: number }> = [];
for (let chunkX = startChunkX; chunkX <= endChunkX; chunkX++) {
for (let chunkY = startChunkY; chunkY <= endChunkY; chunkY++) {
visibleChunks.push({ chunkX, chunkY });
// Request chunk if not loaded and not already requested
const chunkKey = `${chunkX},${chunkY}`;
const chunk = chunks.get(chunkKey);
if (!chunk || !chunk.isLoaded) {
if (!requestedChunksRef.current.has(chunkKey)) {
requestedChunksRef.current.add(chunkKey);
onChunkNeeded(chunkX, chunkY);
// Remove from requested after a delay to allow retry
setTimeout(() => {
requestedChunksRef.current.delete(chunkKey);
}, 5000);
}
}
}
}
return visibleChunks;
}, [viewport, pixelSize, chunks, onChunkNeeded]);
// Track dirty state to avoid unnecessary renders
const isDirtyRef = useRef(true);
const lastRenderTimeRef = useRef(0);
const MIN_RENDER_INTERVAL = 16; // ~60fps max
// Render function with performance optimizations
const render = useCallback(() => {
const now = performance.now();
if (now - lastRenderTimeRef.current < MIN_RENDER_INTERVAL) {
return;
}
if (!isDirtyRef.current) {
return;
}
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
if (!canvas || !ctx) return;
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Set pixel rendering
ctx.imageSmoothingEnabled = false;
const visibleChunks = getVisibleChunks();
// Render pixels from visible chunks
for (const { chunkX, chunkY } of visibleChunks) {
const chunkKey = `${chunkX},${chunkY}`;
const chunk = chunks.get(chunkKey);
if (!chunk || !chunk.isLoaded) continue;
for (const [pixelKey, pixelInfo] of chunk.pixels) {
const [localX, localY] = pixelKey.split(',').map(Number);
const worldX = chunkX * CANVAS_CONFIG.DEFAULT_CHUNK_SIZE + localX;
const worldY = chunkY * CANVAS_CONFIG.DEFAULT_CHUNK_SIZE + localY;
const screenPos = canvasToScreen(worldX, worldY);
// Only render if pixel is visible
if (
screenPos.x >= -pixelSize &&
screenPos.y >= -pixelSize &&
screenPos.x < canvas.width &&
screenPos.y < canvas.height
) {
ctx.fillStyle = pixelInfo.color;
ctx.fillRect(screenPos.x, screenPos.y, pixelSize, pixelSize);
}
}
}
// Render grid only when enabled and zoomed in enough (pixel size > 16px)
if (showGrid && pixelSize > 16) {
ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)';
ctx.lineWidth = 1;
ctx.shadowColor = 'rgba(0, 0, 0, 0.1)';
ctx.shadowBlur = 2;
const startX = Math.floor(viewport.x / pixelSize) * pixelSize - viewport.x;
const startY = Math.floor(viewport.y / pixelSize) * pixelSize - viewport.y;
// Vertical grid lines
for (let x = startX; x < canvas.width; x += pixelSize) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, canvas.height);
ctx.stroke();
}
// Horizontal grid lines
for (let y = startY; y < canvas.height; y += pixelSize) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(canvas.width, y);
ctx.stroke();
}
// Reset shadow
ctx.shadowBlur = 0;
}
// Render user cursors
for (const [userId, cursor] of userCursors) {
const screenPos = canvasToScreen(cursor.x, cursor.y);
if (
screenPos.x >= 0 &&
screenPos.y >= 0 &&
screenPos.x < canvas.width &&
screenPos.y < canvas.height
) {
// Draw cursor
ctx.strokeStyle = cursor.color;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(screenPos.x + pixelSize / 2, screenPos.y + pixelSize / 2, pixelSize + 4, 0, Math.PI * 2);
ctx.stroke();
// Draw username
ctx.fillStyle = cursor.color;
ctx.font = '12px sans-serif';
ctx.fillText(cursor.username, screenPos.x, screenPos.y - 8);
}
}
// Render hover cursor indicator when zoomed in enough
if (hoverPixel && pixelSize > 32) {
const screenPos = canvasToScreen(hoverPixel.x, hoverPixel.y);
if (
screenPos.x >= 0 &&
screenPos.y >= 0 &&
screenPos.x < canvas.width &&
screenPos.y < canvas.height
) {
// Draw subtle pixel highlight border
ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
ctx.lineWidth = 2;
ctx.setLineDash([4, 4]);
ctx.strokeRect(screenPos.x + 1, screenPos.y + 1, pixelSize - 2, pixelSize - 2);
ctx.setLineDash([]); // Reset line dash
// Draw small corner indicators
ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
const cornerSize = 3;
// Top-left corner
ctx.fillRect(screenPos.x, screenPos.y, cornerSize, cornerSize);
// Top-right corner
ctx.fillRect(screenPos.x + pixelSize - cornerSize, screenPos.y, cornerSize, cornerSize);
// Bottom-left corner
ctx.fillRect(screenPos.x, screenPos.y + pixelSize - cornerSize, cornerSize, cornerSize);
// Bottom-right corner
ctx.fillRect(screenPos.x + pixelSize - cornerSize, screenPos.y + pixelSize - cornerSize, cornerSize, cornerSize);
}
}
isDirtyRef.current = false;
lastRenderTimeRef.current = now;
}, [viewport, chunks, pixelSize, showGrid, userCursors, hoverPixel]);
// Mouse event handlers - Left click for both pixel placement and panning
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
if (e.button === 0) { // Left click only
isMouseDownRef.current = true;
isPanningRef.current = false; // Reset panning state
mouseDownPositionRef.current = { x: e.clientX, y: e.clientY };
lastPanPointRef.current = { x: e.clientX, y: e.clientY };
setCursorStyle('grab');
}
};
const handleMouseMove = (e: React.MouseEvent) => {
const { x, y } = screenToCanvas(e.clientX, e.clientY);
onCursorMove(x, y);
// Update hover pixel for cursor indicator
setHoverPixel({ x, y });
// Get pixel info at this position and call onHoverChange
const pixelInfo = getPixelInfo(x, y);
onHoverChange(x, y, pixelInfo);
if (isMouseDownRef.current && mouseDownPositionRef.current && lastPanPointRef.current) {
// Calculate distance from initial mouse down position
const deltaFromStart = Math.sqrt(
Math.pow(e.clientX - mouseDownPositionRef.current.x, 2) +
Math.pow(e.clientY - mouseDownPositionRef.current.y, 2)
);
// If moved more than threshold, start panning
if (deltaFromStart > DRAG_THRESHOLD && !isPanningRef.current) {
isPanningRef.current = true;
setCursorStyle('grabbing');
}
// If we're panning, update viewport
if (isPanningRef.current) {
const deltaX = lastPanPointRef.current.x - e.clientX;
const deltaY = lastPanPointRef.current.y - e.clientY;
pan(deltaX, deltaY);
lastPanPointRef.current = { x: e.clientX, y: e.clientY };
}
}
};
const handleMouseUp = (e: React.MouseEvent) => {
if (e.button === 0) {
// If we weren't panning, treat as pixel click
if (!isPanningRef.current && mouseDownPositionRef.current) {
const { x, y } = screenToCanvas(e.clientX, e.clientY);
onPixelClick(x, y);
}
// Reset all mouse state
isMouseDownRef.current = false;
isPanningRef.current = false;
lastPanPointRef.current = null;
mouseDownPositionRef.current = null;
setCursorStyle('crosshair');
}
};
const handleMouseLeave = () => {
// Stop all interactions when mouse leaves canvas
isMouseDownRef.current = false;
isPanningRef.current = false;
lastPanPointRef.current = null;
mouseDownPositionRef.current = null;
setHoverPixel(null);
setCursorStyle('crosshair');
};
const handleWheel = (e: React.WheelEvent) => {
e.preventDefault();
// Get mouse position in screen coordinates (relative to canvas)
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const mouseScreenX = e.clientX - rect.left;
const mouseScreenY = e.clientY - rect.top;
// Calculate world position that mouse is pointing to
const worldX = (mouseScreenX + viewport.x) / pixelSize;
const worldY = (mouseScreenY + viewport.y) / pixelSize;
// Calculate new zoom with better zoom increments
const zoomFactor = e.deltaY > 0 ? 0.8 : 1.25;
const newZoom = Math.max(0.1, Math.min(10.0, viewport.zoom * zoomFactor));
// Calculate new pixel size
const newPixelSize = BASE_PIXEL_SIZE * newZoom;
// Calculate new viewport position to keep world position under cursor
const newViewportX = worldX * newPixelSize - mouseScreenX;
const newViewportY = worldY * newPixelSize - mouseScreenY;
// Update viewport with new zoom and position
setViewport({
zoom: newZoom,
x: newViewportX,
y: newViewportY,
});
};
// Resize handler
useEffect(() => {
const handleResize = () => {
const container = containerRef.current;
const canvas = canvasRef.current;
if (!container || !canvas) return;
const rect = container.getBoundingClientRect();
const devicePixelRatio = window.devicePixelRatio || 1;
// Set the internal size to actual resolution
canvas.width = rect.width * devicePixelRatio;
canvas.height = rect.height * devicePixelRatio;
// Scale the canvas back down using CSS
canvas.style.width = rect.width + 'px';
canvas.style.height = rect.height + 'px';
// Scale the drawing context so everything draws at the correct size
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.scale(devicePixelRatio, devicePixelRatio);
}
setViewport({ width: rect.width, height: rect.height });
};
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [setViewport]);
// Mark as dirty when viewport, chunks, or other dependencies change
useEffect(() => {
isDirtyRef.current = true;
}, [viewport, chunks, userCursors, hoverPixel]);
// Animation loop with render on demand
useEffect(() => {
const animate = () => {
render();
animationFrameRef.current = requestAnimationFrame(animate);
};
animationFrameRef.current = requestAnimationFrame(animate);
return () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
};
}, [render]);
// Touch event handlers for mobile
const handleTouchStart = (e: React.TouchEvent) => {
e.preventDefault();
if (e.touches.length === 1) {
// Single touch - potential tap or pan
const touch = e.touches[0];
touchStartRef.current = {
x: touch.clientX,
y: touch.clientY,
time: Date.now()
};
lastPanPointRef.current = { x: touch.clientX, y: touch.clientY };
} else if (e.touches.length === 2) {
// Two finger pinch to zoom
const touch1 = e.touches[0];
const touch2 = e.touches[1];
const distance = Math.sqrt(
Math.pow(touch1.clientX - touch2.clientX, 2) +
Math.pow(touch1.clientY - touch2.clientY, 2)
);
pinchStartDistanceRef.current = distance;
pinchStartZoomRef.current = viewport.zoom;
// Center point between touches for zoom
const centerX = (touch1.clientX + touch2.clientX) / 2;
const centerY = (touch1.clientY + touch2.clientY) / 2;
lastPanPointRef.current = { x: centerX, y: centerY };
}
lastTouchesRef.current = Array.from(e.touches) as any;
};
const handleTouchMove = (e: React.TouchEvent) => {
e.preventDefault();
if (e.touches.length === 1 && touchStartRef.current && lastPanPointRef.current) {
// Single touch pan
const touch = e.touches[0];
const deltaX = lastPanPointRef.current.x - touch.clientX;
const deltaY = lastPanPointRef.current.y - touch.clientY;
// Check if we've moved enough to start panning
const totalDistance = Math.sqrt(
Math.pow(touch.clientX - touchStartRef.current.x, 2) +
Math.pow(touch.clientY - touchStartRef.current.y, 2)
);
if (totalDistance > DRAG_THRESHOLD) {
isPanningRef.current = true;
// Pan the viewport
const newViewportX = viewport.x + deltaX;
const newViewportY = viewport.y + deltaY;
setViewport({
x: newViewportX,
y: newViewportY,
});
lastPanPointRef.current = { x: touch.clientX, y: touch.clientY };
}
// Update hover for single touch
const { x, y } = screenToCanvas(touch.clientX, touch.clientY);
setHoverPixel({ x, y });
const pixelInfo = getPixelInfo(x, y);
onHoverChange(x, y, pixelInfo);
} else if (e.touches.length === 2 && pinchStartDistanceRef.current && pinchStartZoomRef.current) {
// Two finger pinch zoom
const touch1 = e.touches[0];
const touch2 = e.touches[1];
const currentDistance = Math.sqrt(
Math.pow(touch1.clientX - touch2.clientX, 2) +
Math.pow(touch1.clientY - touch2.clientY, 2)
);
const scale = currentDistance / pinchStartDistanceRef.current;
const newZoom = Math.max(0.1, Math.min(5.0, pinchStartZoomRef.current * scale));
// Zoom towards center of pinch
const rect = canvasRef.current?.getBoundingClientRect();
if (rect) {
const centerX = (touch1.clientX + touch2.clientX) / 2 - rect.left;
const centerY = (touch1.clientY + touch2.clientY) / 2 - rect.top;
const worldX = (centerX + viewport.x) / pixelSize;
const worldY = (centerY + viewport.y) / pixelSize;
const newPixelSize = BASE_PIXEL_SIZE * newZoom;
const newViewportX = worldX * newPixelSize - centerX;
const newViewportY = worldY * newPixelSize - centerY;
setViewport({
zoom: newZoom,
x: newViewportX,
y: newViewportY,
});
}
}
};
const handleTouchEnd = (e: React.TouchEvent) => {
e.preventDefault();
if (e.touches.length === 0) {
// All touches ended
if (touchStartRef.current && !isPanningRef.current) {
// This was a tap, not a pan
const timeDiff = Date.now() - touchStartRef.current.time;
if (timeDiff < 300) { // Quick tap
const { x, y } = screenToCanvas(touchStartRef.current.x, touchStartRef.current.y);
onPixelClick(x, y);
}
}
// Reset touch state
touchStartRef.current = null;
isPanningRef.current = false;
lastPanPointRef.current = null;
pinchStartDistanceRef.current = null;
pinchStartZoomRef.current = null;
}
lastTouchesRef.current = Array.from(e.touches) as any;
};
return (
<div
ref={containerRef}
className="fixed inset-0 w-full h-full"
style={{
cursor: cursorStyle,
background: `
linear-gradient(135deg,
rgba(15, 23, 42, 0.95) 0%,
rgba(30, 41, 59, 0.9) 25%,
rgba(51, 65, 85, 0.85) 50%,
rgba(30, 58, 138, 0.8) 75%,
rgba(29, 78, 216, 0.75) 100%
),
radial-gradient(circle at 20% 20%, rgba(99, 102, 241, 0.15) 0%, transparent 40%),
radial-gradient(circle at 80% 80%, rgba(59, 130, 246, 0.12) 0%, transparent 40%),
radial-gradient(circle at 40% 70%, rgba(147, 51, 234, 0.1) 0%, transparent 40%),
radial-gradient(circle at 70% 30%, rgba(16, 185, 129, 0.08) 0%, transparent 40%)
`,
backgroundAttachment: 'fixed'
}}
>
<canvas
ref={canvasRef}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
onWheel={handleWheel}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onContextMenu={(e) => e.preventDefault()}
className="w-full h-full touch-none"
style={{ touchAction: 'none' }}
/>
</div>
);
}