add
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState, useCallback } from "react";
|
import { useEffect, useRef, useState, useCallback } from "react";
|
||||||
import { Pixel } from "@shared/schema";
|
import { Pixel } from "@shared/schema";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -22,9 +23,11 @@ export function OptimizedCanvas({
|
|||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [zoom, setZoom] = useState(1);
|
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 [mouseCoords, setMouseCoords] = useState<{x: number, y: number} | null>(null);
|
||||||
const [previewPixel, setPreviewPixel] = 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
|
// Create pixel map for O(1) lookup
|
||||||
const pixelMap = new Map<string, string>();
|
const pixelMap = new Map<string, string>();
|
||||||
@@ -117,10 +120,6 @@ export function OptimizedCanvas({
|
|||||||
drawCanvas();
|
drawCanvas();
|
||||||
}, [drawCanvas]);
|
}, [drawCanvas]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setPixelSize(Math.max(2, 8 * zoom));
|
|
||||||
}, [zoom]);
|
|
||||||
|
|
||||||
const getPixelCoordinates = (event: React.MouseEvent<HTMLCanvasElement>) => {
|
const getPixelCoordinates = (event: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return null;
|
if (!canvas) return null;
|
||||||
@@ -158,18 +157,114 @@ export function OptimizedCanvas({
|
|||||||
const handleCanvasMouseLeave = () => {
|
const handleCanvasMouseLeave = () => {
|
||||||
setMouseCoords(null);
|
setMouseCoords(null);
|
||||||
setPreviewPixel(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 = () => {
|
const handleZoomIn = () => {
|
||||||
setZoom(prev => Math.min(prev * 1.2, 3));
|
zoomToPoint(zoom * 1.2);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleZoomOut = () => {
|
const handleZoomOut = () => {
|
||||||
setZoom(prev => Math.max(prev / 1.2, 0.5));
|
zoomToPoint(zoom / 1.2);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResetZoom = () => {
|
const handleResetZoom = () => {
|
||||||
setZoom(1);
|
zoomToPoint(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleWheel = (e: React.WheelEvent) => {
|
const handleWheel = (e: React.WheelEvent) => {
|
||||||
@@ -178,23 +273,39 @@ export function OptimizedCanvas({
|
|||||||
const zoomFactor = 1.1;
|
const zoomFactor = 1.1;
|
||||||
const delta = e.deltaY;
|
const delta = e.deltaY;
|
||||||
|
|
||||||
|
let newZoom;
|
||||||
if (delta < 0) {
|
if (delta < 0) {
|
||||||
setZoom(prev => Math.min(prev * zoomFactor, 3));
|
newZoom = zoom * zoomFactor;
|
||||||
} else {
|
} else {
|
||||||
setZoom(prev => Math.max(prev / zoomFactor, 0.5));
|
newZoom = zoom / zoomFactor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
zoomToPoint(newZoom, e.clientX, e.clientY);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 relative bg-canvas-bg overflow-hidden">
|
<div className="flex-1 relative bg-canvas-bg overflow-hidden">
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
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}
|
onWheel={handleWheel}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
onMouseMove={handleMouseMoveContainer}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
setIsPanning(false);
|
||||||
|
setLastPanPosition(null);
|
||||||
|
}}
|
||||||
data-testid="canvas-container"
|
data-testid="canvas-container"
|
||||||
|
style={{
|
||||||
|
scrollBehavior: isPanning ? 'auto' : 'auto'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Coordinate System Container */}
|
{/* Coordinate System Container */}
|
||||||
<div className="relative inline-block canvas-zoom">
|
<div className="relative inline-block">
|
||||||
{/* Top X-axis coordinates */}
|
{/* Top X-axis coordinates */}
|
||||||
<div className="flex ml-8 mb-1">
|
<div className="flex ml-8 mb-1">
|
||||||
{Array.from({ length: Math.ceil(canvasWidth / 10) }, (_, i) => (
|
{Array.from({ length: Math.ceil(canvasWidth / 10) }, (_, i) => (
|
||||||
@@ -203,7 +314,7 @@ export function OptimizedCanvas({
|
|||||||
className="text-xs text-gray-400 text-center"
|
className="text-xs text-gray-400 text-center"
|
||||||
style={{
|
style={{
|
||||||
width: `${10 * pixelSize}px`,
|
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}
|
{i * 10}
|
||||||
@@ -222,7 +333,7 @@ export function OptimizedCanvas({
|
|||||||
style={{
|
style={{
|
||||||
height: `${10 * pixelSize}px`,
|
height: `${10 * pixelSize}px`,
|
||||||
width: '24px',
|
width: '24px',
|
||||||
fontSize: `${Math.max(8, pixelSize * 0.8)}px`
|
fontSize: `${Math.max(8, Math.min(12, pixelSize * 0.8))}px`
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{i * 10}
|
{i * 10}
|
||||||
@@ -234,12 +345,13 @@ export function OptimizedCanvas({
|
|||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border border-gray-400 cursor-pointer",
|
"border border-gray-400",
|
||||||
cooldownActive && "cursor-not-allowed"
|
isPanning ? "cursor-grabbing" : cooldownActive ? "cursor-not-allowed" : "cursor-pointer"
|
||||||
)}
|
)}
|
||||||
onClick={handleCanvasClick}
|
onClick={handleCanvasClick}
|
||||||
onMouseMove={handleCanvasMouseMove}
|
onMouseMove={handleCanvasMouseMove}
|
||||||
onMouseLeave={handleCanvasMouseLeave}
|
onMouseLeave={handleCanvasMouseLeave}
|
||||||
|
onContextMenu={(e) => e.preventDefault()} // Verhindert Rechtsklick-Menü
|
||||||
data-testid="pixel-canvas"
|
data-testid="pixel-canvas"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -247,48 +359,63 @@ export function OptimizedCanvas({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Zoom Controls */}
|
{/* 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
|
<button
|
||||||
onClick={handleZoomIn}
|
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"
|
data-testid="button-zoom-in"
|
||||||
|
disabled={zoom >= 5}
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleZoomOut}
|
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"
|
data-testid="button-zoom-out"
|
||||||
|
disabled={zoom <= 0.1}
|
||||||
>
|
>
|
||||||
-
|
-
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleResetZoom}
|
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"
|
data-testid="button-zoom-reset"
|
||||||
>
|
>
|
||||||
100%
|
100%
|
||||||
</button>
|
</button>
|
||||||
|
<div className="text-xs text-gray-600 text-center font-mono">
|
||||||
|
{Math.round(zoom * 100)}%
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info Display */}
|
{/* Info Display */}
|
||||||
<div className="absolute bottom-4 left-4 bg-white/80 p-3 rounded shadow text-sm">
|
<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">
|
<div className="text-xs text-gray-600 font-semibold">
|
||||||
Canvas: {canvasWidth}x{canvasHeight}
|
Canvas: {canvasWidth}×{canvasHeight}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-400">
|
<div className="text-xs text-gray-500 mt-1">
|
||||||
Zoom: {Math.round(zoom * 100)}%
|
Pixel: {pixelSize}px | Zoom: {Math.round(zoom * 100)}%
|
||||||
</div>
|
</div>
|
||||||
{mouseCoords && (
|
{mouseCoords && (
|
||||||
<div className="text-xs text-green-400 mt-1">
|
<div className="text-xs text-green-600 mt-2 font-mono">
|
||||||
Mouse: ({mouseCoords.x}, {mouseCoords.y})
|
Position: ({mouseCoords.x}, {mouseCoords.y})
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{previewPixel && !cooldownActive && (
|
{previewPixel && !cooldownActive && (
|
||||||
<div className="text-xs text-blue-400 mt-1">
|
<div className="text-xs text-blue-600 mt-1">
|
||||||
Vorschau: {selectedColor}
|
Vorschau: {selectedColor}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user