From d95afbbc72d39031c387b4ea5c2dfb693f1bb4a9 Mon Sep 17 00:00:00 2001 From: yveskalume Date: Thu, 8 Jan 2026 10:14:15 +0200 Subject: [PATCH] feat: implement pan and zoom functionality with wheel gestures in Canvas component --- src/components/Canvas.tsx | 68 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 3 deletions(-) diff --git a/src/components/Canvas.tsx b/src/components/Canvas.tsx index 0ee7f1c..7516345 100644 --- a/src/components/Canvas.tsx +++ b/src/components/Canvas.tsx @@ -15,6 +15,7 @@ interface CanvasProps { const Canvas: React.FC = ({ stageRef }) => { const containerRef = useRef(null); const [dimensions, setDimensions] = useState({ width: window.innerWidth, height: window.innerHeight - 120 }); + const [panOffset, setPanOffset] = useState({ x: 0, y: 0 }); const { snap, zoom, @@ -24,6 +25,7 @@ const Canvas: React.FC = ({ stageRef }) => { selectElement, addElement, updateElement, + setZoom, } = useCanvasStore(); const { width, height } = snap.meta; @@ -70,10 +72,15 @@ const Canvas: React.FC = ({ stageRef }) => { const scaledWidth = width * zoom; const scaledHeight = height * zoom; return { - x: Math.max(20, (dimensions.width - scaledWidth) / 2), - y: Math.max(20, (dimensions.height - scaledHeight) / 2), + x: Math.max(20, (dimensions.width - scaledWidth) / 2) + panOffset.x, + y: Math.max(20, (dimensions.height - scaledHeight) / 2) + panOffset.y, }; - }, [width, height, zoom, dimensions]); + }, [width, height, zoom, dimensions, panOffset]); + + // Reset pan offset when zoom changes significantly or canvas is re-centered + const resetPan = useCallback(() => { + setPanOffset({ x: 0, y: 0 }); + }, []); const handleStageClick = (e: Konva.KonvaEventObject) => { const clickedOnEmpty = e.target === e.target.getStage() || e.target.name() === 'background'; @@ -416,11 +423,66 @@ const Canvas: React.FC = ({ stageRef }) => { const stagePosition = useMemo(() => getStagePosition(), [getStagePosition]); + // Handle wheel/pinch zoom and pan on canvas + const handleWheel = useCallback((e: React.WheelEvent) => { + // Check if it's a pinch zoom gesture (ctrlKey is true for pinch-to-zoom) + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + e.stopPropagation(); + + const scaleBy = 1.05; + const minZoom = 0.1; + const maxZoom = 3; + + // Determine zoom direction + const direction = e.deltaY > 0 ? -1 : 1; + const newZoom = direction > 0 ? zoom * scaleBy : zoom / scaleBy; + + // Clamp zoom between min and max + const clampedZoom = Math.min(Math.max(newZoom, minZoom), maxZoom); + setZoom(clampedZoom); + } else { + // Two-finger scroll for panning + e.preventDefault(); + e.stopPropagation(); + + setPanOffset(prev => ({ + x: prev.x - e.deltaX, + y: prev.y - e.deltaY, + })); + } + }, [zoom, setZoom]); + + // Prevent default browser zoom behavior on the container + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const preventDefaultZoom = (e: WheelEvent) => { + // Prevent default for both zoom (ctrl/meta) and pan (regular scroll) + e.preventDefault(); + }; + + // Use passive: false to allow preventDefault + container.addEventListener('wheel', preventDefaultZoom, { passive: false }); + + return () => { + container.removeEventListener('wheel', preventDefaultZoom); + }; + }, []); + return (
{ + // Double-click on empty area to reset pan + if (e.target === containerRef.current) { + resetPan(); + } + }} >