add
This commit is contained in:
@@ -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,48 +359,63 @@ 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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user