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>
582 lines
No EOL
20 KiB
TypeScript
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>
|
|
);
|
|
} |