This commit is contained in:
2025-08-21 15:29:34 +02:00
parent d5f8de1e4c
commit 49923adcd2

View File

@@ -1,3 +1,4 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { Pixel } from "@shared/schema";
import { cn } from "@/lib/utils";
@@ -22,9 +23,11 @@ export function OptimizedCanvas({
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [zoom, setZoom] = useState(1);
const [pixelSize, setPixelSize] = useState(8);
const pixelSize = Math.max(2, 8 * zoom);
const [mouseCoords, setMouseCoords] = useState<{x: number, y: number} | null>(null);
const [previewPixel, setPreviewPixel] = useState<{x: number, y: number} | null>(null);
const [isPanning, setIsPanning] = useState(false);
const [lastPanPosition, setLastPanPosition] = useState<{x: number, y: number} | null>(null);
// Create pixel map for O(1) lookup
const pixelMap = new Map<string, string>();
@@ -117,10 +120,6 @@ export function OptimizedCanvas({
drawCanvas();
}, [drawCanvas]);
useEffect(() => {
setPixelSize(Math.max(2, 8 * zoom));
}, [zoom]);
const getPixelCoordinates = (event: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return null;
@@ -158,18 +157,114 @@ export function OptimizedCanvas({
const handleCanvasMouseLeave = () => {
setMouseCoords(null);
setPreviewPixel(null);
setIsPanning(false);
setLastPanPosition(null);
};
const handleMouseDown = (e: React.MouseEvent) => {
// Mittlere Maustaste (Button 1)
if (e.button === 1) {
e.preventDefault();
setIsPanning(true);
setLastPanPosition({ x: e.clientX, y: e.clientY });
}
};
const handleMouseUp = (e: React.MouseEvent) => {
if (e.button === 1) {
e.preventDefault();
setIsPanning(false);
setLastPanPosition(null);
}
};
const handleMouseMoveContainer = (e: React.MouseEvent) => {
if (isPanning && lastPanPosition && containerRef.current) {
e.preventDefault();
const deltaX = e.clientX - lastPanPosition.x;
const deltaY = e.clientY - lastPanPosition.y;
const container = containerRef.current;
container.scrollLeft -= deltaX;
container.scrollTop -= deltaY;
setLastPanPosition({ x: e.clientX, y: e.clientY });
}
};
const zoomToPoint = (newZoom: number, mouseX?: number, mouseY?: number) => {
const container = containerRef.current;
if (!container) return;
const clampedZoom = Math.max(0.1, Math.min(newZoom, 5));
if (Math.abs(clampedZoom - zoom) < 0.01) return;
const oldPixelSize = pixelSize;
const newPixelSize = Math.max(2, 8 * clampedZoom);
// Aktuelle Scroll-Position
const currentScrollLeft = container.scrollLeft;
const currentScrollTop = container.scrollTop;
let zoomPointX, zoomPointY;
if (mouseX !== undefined && mouseY !== undefined) {
// Zoomen an Mausposition
const containerRect = container.getBoundingClientRect();
const relativeMouseX = mouseX - containerRect.left;
const relativeMouseY = mouseY - containerRect.top;
// Canvas-Koordinaten der Mausposition
zoomPointX = (currentScrollLeft + relativeMouseX - 32) / oldPixelSize;
zoomPointY = (currentScrollTop + relativeMouseY - 32) / oldPixelSize;
setZoom(clampedZoom);
// Neue Scroll-Position berechnen, damit Mausposition gleich bleibt
requestAnimationFrame(() => {
const newScrollLeft = zoomPointX * newPixelSize + 32 - relativeMouseX;
const newScrollTop = zoomPointY * newPixelSize + 32 - relativeMouseY;
container.scrollTo({
left: Math.max(0, Math.min(newScrollLeft, container.scrollWidth - container.clientWidth)),
top: Math.max(0, Math.min(newScrollTop, container.scrollHeight - container.clientHeight)),
behavior: 'auto'
});
});
} else {
// Zoomen im Center des Viewports
const containerWidth = container.clientWidth;
const containerHeight = container.clientHeight;
zoomPointX = (currentScrollLeft + containerWidth / 2 - 32) / oldPixelSize;
zoomPointY = (currentScrollTop + containerHeight / 2 - 32) / oldPixelSize;
setZoom(clampedZoom);
requestAnimationFrame(() => {
const newScrollLeft = zoomPointX * newPixelSize + 32 - containerWidth / 2;
const newScrollTop = zoomPointY * newPixelSize + 32 - containerHeight / 2;
container.scrollTo({
left: Math.max(0, Math.min(newScrollLeft, container.scrollWidth - container.clientWidth)),
top: Math.max(0, Math.min(newScrollTop, container.scrollHeight - container.clientHeight)),
behavior: 'smooth'
});
});
}
};
const handleZoomIn = () => {
setZoom(prev => Math.min(prev * 1.2, 3));
zoomToPoint(zoom * 1.2);
};
const handleZoomOut = () => {
setZoom(prev => Math.max(prev / 1.2, 0.5));
zoomToPoint(zoom / 1.2);
};
const handleResetZoom = () => {
setZoom(1);
zoomToPoint(1);
};
const handleWheel = (e: React.WheelEvent) => {
@@ -178,23 +273,39 @@ export function OptimizedCanvas({
const zoomFactor = 1.1;
const delta = e.deltaY;
let newZoom;
if (delta < 0) {
setZoom(prev => Math.min(prev * zoomFactor, 3));
newZoom = zoom * zoomFactor;
} else {
setZoom(prev => Math.max(prev / zoomFactor, 0.5));
newZoom = zoom / zoomFactor;
}
zoomToPoint(newZoom, e.clientX, e.clientY);
};
return (
<div className="flex-1 relative bg-canvas-bg overflow-hidden">
<div
ref={containerRef}
className="w-full h-full overflow-auto p-8 scroll-smooth canvas-container"
className={cn(
"w-full h-full overflow-auto p-8 canvas-container",
isPanning && "cursor-grabbing select-none"
)}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onMouseMove={handleMouseMoveContainer}
onMouseLeave={() => {
setIsPanning(false);
setLastPanPosition(null);
}}
data-testid="canvas-container"
style={{
scrollBehavior: isPanning ? 'auto' : 'auto'
}}
>
{/* Coordinate System Container */}
<div className="relative inline-block canvas-zoom">
<div className="relative inline-block">
{/* Top X-axis coordinates */}
<div className="flex ml-8 mb-1">
{Array.from({ length: Math.ceil(canvasWidth / 10) }, (_, i) => (
@@ -203,7 +314,7 @@ export function OptimizedCanvas({
className="text-xs text-gray-400 text-center"
style={{
width: `${10 * pixelSize}px`,
fontSize: `${Math.max(8, pixelSize * 0.8)}px`
fontSize: `${Math.max(8, Math.min(12, pixelSize * 0.8))}px`
}}
>
{i * 10}
@@ -222,7 +333,7 @@ export function OptimizedCanvas({
style={{
height: `${10 * pixelSize}px`,
width: '24px',
fontSize: `${Math.max(8, pixelSize * 0.8)}px`
fontSize: `${Math.max(8, Math.min(12, pixelSize * 0.8))}px`
}}
>
{i * 10}
@@ -234,12 +345,13 @@ export function OptimizedCanvas({
<canvas
ref={canvasRef}
className={cn(
"border border-gray-400 cursor-pointer",
cooldownActive && "cursor-not-allowed"
"border border-gray-400",
isPanning ? "cursor-grabbing" : cooldownActive ? "cursor-not-allowed" : "cursor-pointer"
)}
onClick={handleCanvasClick}
onMouseMove={handleCanvasMouseMove}
onMouseLeave={handleCanvasMouseLeave}
onContextMenu={(e) => e.preventDefault()} // Verhindert Rechtsklick-Menü
data-testid="pixel-canvas"
/>
</div>
@@ -247,49 +359,64 @@ export function OptimizedCanvas({
</div>
{/* Zoom Controls */}
<div className="absolute top-4 right-4 flex flex-col gap-2 bg-white/80 p-2 rounded shadow">
<div className="absolute top-4 right-4 flex flex-col gap-2 bg-white/90 p-3 rounded-lg shadow-lg">
<button
onClick={handleZoomIn}
className="px-2 py-1 bg-blue-500 text-white rounded text-sm hover:bg-blue-600"
className="px-3 py-2 bg-blue-500 text-white rounded text-sm font-semibold hover:bg-blue-600 transition-colors"
data-testid="button-zoom-in"
disabled={zoom >= 5}
>
+
</button>
<button
onClick={handleZoomOut}
className="px-2 py-1 bg-blue-500 text-white rounded text-sm hover:bg-blue-600"
className="px-3 py-2 bg-blue-500 text-white rounded text-sm font-semibold hover:bg-blue-600 transition-colors"
data-testid="button-zoom-out"
disabled={zoom <= 0.1}
>
-
</button>
<button
onClick={handleResetZoom}
className="px-2 py-1 bg-gray-500 text-white rounded text-xs hover:bg-gray-600"
className="px-2 py-1 bg-gray-500 text-white rounded text-xs font-semibold hover:bg-gray-600 transition-colors"
data-testid="button-zoom-reset"
>
100%
</button>
<div className="text-xs text-gray-600 text-center font-mono">
{Math.round(zoom * 100)}%
</div>
</div>
{/* Info Display */}
<div className="absolute bottom-4 left-4 bg-white/80 p-3 rounded shadow text-sm">
<div className="text-xs text-gray-600">
Canvas: {canvasWidth}x{canvasHeight}
<div className="absolute bottom-4 left-4 bg-white/90 p-3 rounded-lg shadow-lg text-sm">
<div className="text-xs text-gray-600 font-semibold">
Canvas: {canvasWidth}×{canvasHeight}
</div>
<div className="text-xs text-gray-400">
Zoom: {Math.round(zoom * 100)}%
<div className="text-xs text-gray-500 mt-1">
Pixel: {pixelSize}px | Zoom: {Math.round(zoom * 100)}%
</div>
{mouseCoords && (
<div className="text-xs text-green-400 mt-1">
Mouse: ({mouseCoords.x}, {mouseCoords.y})
<div className="text-xs text-green-600 mt-2 font-mono">
Position: ({mouseCoords.x}, {mouseCoords.y})
</div>
)}
{previewPixel && !cooldownActive && (
<div className="text-xs text-blue-400 mt-1">
<div className="text-xs text-blue-600 mt-1">
Vorschau: {selectedColor}
</div>
)}
{cooldownActive && (
<div className="text-xs text-red-500 mt-1 font-semibold">
Cooldown aktiv
</div>
)}
{isPanning && (
<div className="text-xs text-blue-500 mt-1 font-semibold">
Bewege Canvas (Mittlere Maustaste)
</div>
)}
</div>
</div>
);
}
}