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>
This commit is contained in:
parent
5eb7a1482e
commit
98f290a662
69 changed files with 17771 additions and 1589 deletions
582
frontend/src/components/canvas/VirtualCanvas.tsx
Normal file
582
frontend/src/components/canvas/VirtualCanvas.tsx
Normal file
|
|
@ -0,0 +1,582 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue