feat: implement pan and zoom functionality with wheel gestures in Canvas component
This commit is contained in:
@@ -15,6 +15,7 @@ interface CanvasProps {
|
|||||||
const Canvas: React.FC<CanvasProps> = ({ stageRef }) => {
|
const Canvas: React.FC<CanvasProps> = ({ stageRef }) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [dimensions, setDimensions] = useState({ width: window.innerWidth, height: window.innerHeight - 120 });
|
const [dimensions, setDimensions] = useState({ width: window.innerWidth, height: window.innerHeight - 120 });
|
||||||
|
const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
|
||||||
const {
|
const {
|
||||||
snap,
|
snap,
|
||||||
zoom,
|
zoom,
|
||||||
@@ -24,6 +25,7 @@ const Canvas: React.FC<CanvasProps> = ({ stageRef }) => {
|
|||||||
selectElement,
|
selectElement,
|
||||||
addElement,
|
addElement,
|
||||||
updateElement,
|
updateElement,
|
||||||
|
setZoom,
|
||||||
} = useCanvasStore();
|
} = useCanvasStore();
|
||||||
|
|
||||||
const { width, height } = snap.meta;
|
const { width, height } = snap.meta;
|
||||||
@@ -70,10 +72,15 @@ const Canvas: React.FC<CanvasProps> = ({ stageRef }) => {
|
|||||||
const scaledWidth = width * zoom;
|
const scaledWidth = width * zoom;
|
||||||
const scaledHeight = height * zoom;
|
const scaledHeight = height * zoom;
|
||||||
return {
|
return {
|
||||||
x: Math.max(20, (dimensions.width - scaledWidth) / 2),
|
x: Math.max(20, (dimensions.width - scaledWidth) / 2) + panOffset.x,
|
||||||
y: Math.max(20, (dimensions.height - scaledHeight) / 2),
|
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<MouseEvent>) => {
|
const handleStageClick = (e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||||
const clickedOnEmpty = e.target === e.target.getStage() || e.target.name() === 'background';
|
const clickedOnEmpty = e.target === e.target.getStage() || e.target.name() === 'background';
|
||||||
@@ -416,11 +423,66 @@ const Canvas: React.FC<CanvasProps> = ({ stageRef }) => {
|
|||||||
|
|
||||||
const stagePosition = useMemo(() => getStagePosition(), [getStagePosition]);
|
const stagePosition = useMemo(() => getStagePosition(), [getStagePosition]);
|
||||||
|
|
||||||
|
// Handle wheel/pinch zoom and pan on canvas
|
||||||
|
const handleWheel = useCallback((e: React.WheelEvent<HTMLDivElement>) => {
|
||||||
|
// 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="flex-1 bg-neutral-900 overflow-hidden relative"
|
className="flex-1 bg-neutral-900 overflow-hidden relative"
|
||||||
style={{ cursor: tool !== 'select' ? 'crosshair' : 'default' }}
|
style={{ cursor: tool !== 'select' ? 'crosshair' : 'default' }}
|
||||||
|
onWheel={handleWheel}
|
||||||
|
onDoubleClick={(e) => {
|
||||||
|
// Double-click on empty area to reset pan
|
||||||
|
if (e.target === containerRef.current) {
|
||||||
|
resetPan();
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Stage
|
<Stage
|
||||||
ref={stageRef}
|
ref={stageRef}
|
||||||
|
|||||||
Reference in New Issue
Block a user