271 lines
8.0 KiB
TypeScript
271 lines
8.0 KiB
TypeScript
import { useRef, useEffect, useState } from "react";
|
|
import { useGesture } from "react-use-gesture";
|
|
import { Handlers } from "react-use-gesture/dist/types";
|
|
import normalizeWheel from "normalize-wheel";
|
|
import Konva from "konva";
|
|
|
|
import { useKeyboard, useBlur } from "../contexts/KeyboardContext";
|
|
import { MapStage } from "../contexts/MapStageContext";
|
|
|
|
import shortcuts from "../shortcuts";
|
|
|
|
import Vector2 from "../helpers/Vector2";
|
|
|
|
const wheelZoomSpeed = -1;
|
|
const touchZoomSpeed = 0.005;
|
|
const minZoom = 0.1;
|
|
|
|
type StageScaleChangeEventHandler = (newScale: number) => void;
|
|
|
|
function useStageInteraction(
|
|
stageRef: MapStage,
|
|
stageScale: number,
|
|
onStageScaleChange: StageScaleChangeEventHandler,
|
|
stageTranslateRef: React.MutableRefObject<Vector2>,
|
|
layerRef: React.RefObject<Konva.Layer>,
|
|
maxZoom = 10,
|
|
tool = "move",
|
|
preventInteraction = false,
|
|
gesture: Handlers = {}
|
|
) {
|
|
const isInteractingWithCanvas = useRef(false);
|
|
const pinchPreviousDistanceRef = useRef<number>(0);
|
|
const pinchPreviousOriginRef = useRef<Vector2>({ x: 0, y: 0 });
|
|
|
|
const [zoomSpeed, setZoomSpeed] = useState(1);
|
|
|
|
// Prevent accessibility pinch to zoom on Mac
|
|
useEffect(() => {
|
|
function handleGesture(e: Event) {
|
|
e.preventDefault();
|
|
}
|
|
window.addEventListener("gesturestart", handleGesture);
|
|
window.addEventListener("gesturechange", handleGesture);
|
|
return () => {
|
|
window.removeEventListener("gesturestart", handleGesture);
|
|
window.removeEventListener("gesturechange", handleGesture);
|
|
};
|
|
});
|
|
|
|
useGesture(
|
|
{
|
|
...gesture,
|
|
onWheelStart: (props) => {
|
|
const { event } = props;
|
|
const layer = layerRef.current;
|
|
isInteractingWithCanvas.current =
|
|
layer !== null && event.target === layer.getCanvas()._canvas;
|
|
gesture.onWheelStart && gesture.onWheelStart(props);
|
|
},
|
|
onWheel: (props) => {
|
|
const stage = stageRef.current;
|
|
if (
|
|
preventInteraction ||
|
|
!isInteractingWithCanvas.current ||
|
|
stage === null
|
|
) {
|
|
return;
|
|
}
|
|
const { event, last } = props;
|
|
// Prevent double zoom on wheel end
|
|
if (!last) {
|
|
const { pixelY } = normalizeWheel(event);
|
|
|
|
const newScale = Math.min(
|
|
Math.max(
|
|
stageScale +
|
|
(pixelY * wheelZoomSpeed * stageScale * zoomSpeed) /
|
|
window.innerHeight,
|
|
minZoom
|
|
),
|
|
maxZoom
|
|
);
|
|
|
|
// Center on pointer
|
|
const pointer = stage.getPointerPosition();
|
|
if (pointer) {
|
|
const newTranslate = {
|
|
x: pointer.x - ((pointer.x - stage.x()) / stageScale) * newScale,
|
|
y: pointer.y - ((pointer.y - stage.y()) / stageScale) * newScale,
|
|
};
|
|
|
|
stage.position(newTranslate);
|
|
|
|
stageTranslateRef.current = newTranslate;
|
|
|
|
onStageScaleChange(newScale);
|
|
}
|
|
}
|
|
|
|
gesture.onWheel && gesture.onWheel(props);
|
|
},
|
|
onPinchStart: (props) => {
|
|
const { event } = props;
|
|
const layer = layerRef.current;
|
|
isInteractingWithCanvas.current =
|
|
layer !== null && event.target === layer.getCanvas()._canvas;
|
|
const { da, origin } = props;
|
|
const [distance] = da;
|
|
const [originX, originY] = origin;
|
|
pinchPreviousDistanceRef.current = distance;
|
|
pinchPreviousOriginRef.current = { x: originX, y: originY };
|
|
gesture.onPinchStart && gesture.onPinchStart(props);
|
|
},
|
|
onPinch: (props) => {
|
|
const layer = layerRef.current;
|
|
const stage = stageRef.current;
|
|
if (
|
|
preventInteraction ||
|
|
!isInteractingWithCanvas.current ||
|
|
layer === null ||
|
|
stage === null
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const { da, origin } = props;
|
|
const [distance] = da;
|
|
const [originX, originY] = origin;
|
|
|
|
// Apply scale
|
|
const distanceDelta = distance - pinchPreviousDistanceRef.current;
|
|
const originXDelta = originX - pinchPreviousOriginRef.current.x;
|
|
const originYDelta = originY - pinchPreviousOriginRef.current.y;
|
|
const newScale = Math.min(
|
|
Math.max(
|
|
stageScale +
|
|
distanceDelta * touchZoomSpeed * stageScale * zoomSpeed,
|
|
minZoom
|
|
),
|
|
maxZoom
|
|
);
|
|
|
|
const canvasRect = layer.getCanvas()._canvas.getBoundingClientRect();
|
|
const relativeOrigin = {
|
|
x: originX - canvasRect.left,
|
|
y: originY - canvasRect.top,
|
|
};
|
|
|
|
// Center on pinch origin
|
|
const centeredTranslate = {
|
|
x:
|
|
relativeOrigin.x -
|
|
((relativeOrigin.x - stage.x()) / stageScale) * newScale,
|
|
y:
|
|
relativeOrigin.y -
|
|
((relativeOrigin.y - stage.y()) / stageScale) * newScale,
|
|
};
|
|
|
|
// Add pinch movement
|
|
const newTranslate = {
|
|
x: centeredTranslate.x + originXDelta,
|
|
y: centeredTranslate.y + originYDelta,
|
|
};
|
|
|
|
stage.position(newTranslate);
|
|
stageTranslateRef.current = newTranslate;
|
|
|
|
onStageScaleChange(newScale);
|
|
|
|
pinchPreviousDistanceRef.current = distance;
|
|
pinchPreviousOriginRef.current = { x: originX, y: originY };
|
|
gesture.onPinch && gesture.onPinch(props);
|
|
},
|
|
onDragStart: (props) => {
|
|
const { event } = props;
|
|
const layer = layerRef.current;
|
|
isInteractingWithCanvas.current =
|
|
layer !== null && event.target === layer.getCanvas()._canvas;
|
|
gesture.onDragStart && gesture.onDragStart(props);
|
|
},
|
|
onDrag: (props) => {
|
|
const { delta, pinching, buttons } = props;
|
|
const stage = stageRef.current;
|
|
if (
|
|
preventInteraction ||
|
|
pinching ||
|
|
!isInteractingWithCanvas.current ||
|
|
stage === null
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const [dx, dy] = delta;
|
|
const stageTranslate = stageTranslateRef.current;
|
|
// Move with move tool and left click or any mouse button but right click
|
|
if ((tool === "move" && buttons < 2) || buttons > 2) {
|
|
const newTranslate = {
|
|
x: stageTranslate.x + dx,
|
|
y: stageTranslate.y + dy,
|
|
};
|
|
stage.position(newTranslate);
|
|
stage.draw();
|
|
stageTranslateRef.current = newTranslate;
|
|
}
|
|
gesture.onDrag && gesture.onDrag(props);
|
|
},
|
|
},
|
|
{
|
|
// Fix drawing using old pointer end position on touch devices when drawing new shapes
|
|
drag: { delay: 300 },
|
|
domTarget: window,
|
|
eventOptions: {
|
|
passive: false,
|
|
},
|
|
}
|
|
);
|
|
|
|
function handleKeyDown(event: KeyboardEvent) {
|
|
// TODO: Find better way to detect whether keyboard event should fire.
|
|
// This one fires on all open stages
|
|
const stage = stageRef.current;
|
|
if (preventInteraction || stage === null) {
|
|
return;
|
|
}
|
|
if (shortcuts.stageZoomIn(event) || shortcuts.stageZoomOut(event)) {
|
|
const pixelY = shortcuts.stageZoomIn(event) ? -100 : 100;
|
|
const newScale = Math.min(
|
|
Math.max(
|
|
stageScale +
|
|
(pixelY * wheelZoomSpeed * stageScale * zoomSpeed) /
|
|
window.innerHeight,
|
|
minZoom
|
|
),
|
|
maxZoom
|
|
);
|
|
|
|
// Center on pointer
|
|
const pointer = { x: window.innerWidth / 2, y: window.innerHeight / 2 };
|
|
const newTranslate = {
|
|
x: pointer.x - ((pointer.x - stage.x()) / stageScale) * newScale,
|
|
y: pointer.y - ((pointer.y - stage.y()) / stageScale) * newScale,
|
|
};
|
|
|
|
stage.position(newTranslate);
|
|
stageTranslateRef.current = newTranslate;
|
|
|
|
onStageScaleChange(newScale);
|
|
}
|
|
|
|
if (shortcuts.stagePrecisionZoom(event)) {
|
|
setZoomSpeed(0.25);
|
|
}
|
|
}
|
|
|
|
function handleKeyUp(event: KeyboardEvent) {
|
|
if (shortcuts.stagePrecisionZoom(event)) {
|
|
setZoomSpeed(1);
|
|
}
|
|
}
|
|
|
|
useKeyboard(handleKeyDown, handleKeyUp);
|
|
|
|
function handleBlur() {
|
|
setZoomSpeed(1);
|
|
}
|
|
|
|
useBlur(handleBlur);
|
|
}
|
|
|
|
export default useStageInteraction;
|