Refactored stage zooming to center on the mouse position

This commit is contained in:
Mitchell McCaffrey 2020-10-22 16:10:31 +11:00
parent dddfb3d498
commit f26cbf24d2
5 changed files with 157 additions and 159 deletions

View File

@ -1,4 +1,4 @@
import React, { useState, useRef, useEffect, useContext } from "react";
import React, { useState, useRef, useContext } from "react";
import { Box, IconButton } from "theme-ui";
import { Stage, Layer, Image } from "react-konva";
import ReactResizeDetector from "react-resize-detector";
@ -6,6 +6,7 @@ import ReactResizeDetector from "react-resize-detector";
import useMapImage from "../../helpers/useMapImage";
import usePreventOverscroll from "../../helpers/usePreventOverscroll";
import useStageInteraction from "../../helpers/useStageInteraction";
import useImageCenter from "../../helpers/useImageCenter";
import { getMapDefaultInset, getMapMaxZoom } from "../../helpers/map";
import { MapInteractionProvider } from "../../contexts/MapInteractionContext";
@ -25,19 +26,6 @@ function MapEditor({ map, onSettingsChange }) {
const [stageHeight, setStageHeight] = useState(1);
const [stageScale, setStageScale] = useState(1);
const stageRatio = stageWidth / stageHeight;
const mapRatio = map ? map.width / map.height : 1;
let mapWidth;
let mapHeight;
if (stageRatio > mapRatio) {
mapWidth = map ? stageHeight / (map.height / map.width) : stageWidth;
mapHeight = stageHeight;
} else {
mapWidth = stageWidth;
mapHeight = map ? stageWidth * (map.height / map.width) : stageHeight;
}
const defaultInset = getMapDefaultInset(
map.width,
map.height,
@ -46,6 +34,7 @@ function MapEditor({ map, onSettingsChange }) {
);
const stageTranslateRef = useRef({ x: 0, y: 0 });
const mapStageRef = useRef();
const mapLayerRef = useRef();
const [preventMapInteraction, setPreventMapInteraction] = useState(false);
@ -54,46 +43,32 @@ function MapEditor({ map, onSettingsChange }) {
setStageHeight(height);
}
// Reset map translate and scale
useEffect(() => {
const layer = mapLayerRef.current;
const containerRect = containerRef.current.getBoundingClientRect();
if (layer) {
let newTranslate;
if (stageRatio > mapRatio) {
newTranslate = {
x: -(mapWidth - containerRect.width) / 2,
y: 0,
};
} else {
newTranslate = {
x: 0,
y: -(mapHeight - containerRect.height) / 2,
};
}
const containerRef = useRef();
usePreventOverscroll(containerRef);
layer.x(newTranslate.x);
layer.y(newTranslate.y);
layer.draw();
stageTranslateRef.current = newTranslate;
setStageScale(1);
}
}, [map.id, mapWidth, mapHeight, stageRatio, mapRatio]);
const [mapWidth, mapHeight] = useImageCenter(
map,
mapStageRef,
stageWidth,
stageHeight,
stageTranslateRef,
setStageScale,
mapLayerRef,
containerRef,
true
);
const bind = useStageInteraction(
mapLayerRef.current,
mapStageRef.current,
stageScale,
setStageScale,
stageTranslateRef,
mapLayerRef.current,
getMapMaxZoom(map),
"pan",
preventMapInteraction
);
const containerRef = useRef();
usePreventOverscroll(containerRef);
function handleGridChange(inset) {
onSettingsChange("grid", {
...map.grid,
@ -129,7 +104,6 @@ function MapEditor({ map, onSettingsChange }) {
map.grid.inset.topLeft.y !== defaultInset.topLeft.y ||
map.grid.inset.bottomRight.x !== defaultInset.bottomRight.x ||
map.grid.inset.bottomRight.y !== defaultInset.bottomRight.y;
return (
<Box
sx={{
@ -149,19 +123,17 @@ function MapEditor({ map, onSettingsChange }) {
width={stageWidth}
height={stageHeight}
scale={{ x: stageScale, y: stageScale }}
x={stageWidth / 2}
y={stageHeight / 2}
offset={{ x: stageWidth / 2, y: stageHeight / 2 }}
ref={mapStageRef}
>
<Layer ref={mapLayerRef}>
<Image image={mapImageSource} width={mapWidth} height={mapHeight} />
<KeyboardContext.Provider value={keyboardValue}>
<MapInteractionProvider value={mapInteraction}>
{showGridControls && canEditGrid && (
<MapGrid map={map} strokeWidth={0.5} />
)}
{showGridControls && canEditGrid && (
<MapGridEditor map={map} onGridChange={handleGridChange} />
<>
<MapGrid map={map} strokeWidth={0.5} />
<MapGridEditor map={map} onGridChange={handleGridChange} />
</>
)}
</MapInteractionProvider>
</KeyboardContext.Provider>

View File

@ -8,6 +8,7 @@ import useMapImage from "../../helpers/useMapImage";
import usePreventOverscroll from "../../helpers/usePreventOverscroll";
import useKeyboard from "../../helpers/useKeyboard";
import useStageInteraction from "../../helpers/useStageInteraction";
import useImageCenter from "../../helpers/useImageCenter";
import { getMapMaxZoom } from "../../helpers/map";
import { MapInteractionProvider } from "../../contexts/MapInteractionContext";
@ -43,52 +44,41 @@ function MapInteraction({
const [stageScale, setStageScale] = useState(1);
const [preventMapInteraction, setPreventMapInteraction] = useState(false);
const stageWidthRef = useRef(stageWidth);
const stageHeightRef = useRef(stageHeight);
// Avoid state udpates when panning the map by using a ref and updating the konva element directly
const stageTranslateRef = useRef({ x: 0, y: 0 });
// Reset transform when map changes
const previousMapIdRef = useRef();
useEffect(() => {
const layer = mapLayerRef.current;
const previousMapId = previousMapIdRef.current;
if (map && layer && previousMapId !== map.id) {
const mapHeight = stageWidthRef.current * (map.height / map.width);
const newTranslate = {
x: 0,
y: -(mapHeight - stageHeightRef.current) / 2,
};
layer.x(newTranslate.x);
layer.y(newTranslate.y);
layer.draw();
stageTranslateRef.current = newTranslate;
setStageScale(1);
}
previousMapIdRef.current = map && map.id;
}, [map]);
const mapStageRef = useContext(MapStageContext);
const mapLayerRef = useRef();
const mapImageRef = useRef();
function handleResize(width, height) {
setStageWidth(width);
setStageHeight(height);
stageWidthRef.current = width;
stageHeightRef.current = height;
}
const mapStageRef = useContext(MapStageContext);
const mapLayerRef = useRef();
const mapImageRef = useRef();
const containerRef = useRef();
usePreventOverscroll(containerRef);
const [mapWidth, mapHeight] = useImageCenter(
map,
mapStageRef,
stageWidth,
stageHeight,
stageTranslateRef,
setStageScale,
mapLayerRef,
containerRef
);
const previousSelectedToolRef = useRef(selectedToolId);
const [interactionEmitter] = useState(new EventEmitter());
const bind = useStageInteraction(
mapLayerRef.current,
mapStageRef.current,
stageScale,
setStageScale,
stageTranslateRef,
mapLayerRef.current,
getMapMaxZoom(map),
selectedToolId,
preventMapInteraction,
@ -171,12 +161,6 @@ function MapInteraction({
}
}
const containerRef = useRef();
usePreventOverscroll(containerRef);
const mapWidth = stageWidth;
const mapHeight = map ? stageWidth * (map.height / map.width) : stageHeight;
const auth = useContext(AuthContext);
const settings = useContext(SettingsContext);
@ -208,9 +192,6 @@ function MapInteraction({
width={stageWidth}
height={stageHeight}
scale={{ x: stageScale, y: stageScale }}
x={stageWidth / 2}
y={stageHeight / 2}
offset={{ x: stageWidth / 2, y: stageHeight / 2 }}
ref={mapStageRef}
>
<Layer ref={mapLayerRef}>

View File

@ -7,6 +7,7 @@ import useImage from "use-image";
import usePreventOverscroll from "../../helpers/usePreventOverscroll";
import useStageInteraction from "../../helpers/useStageInteraction";
import useDataSource from "../../helpers/useDataSource";
import useImageCenter from "../../helpers/useImageCenter";
import GridOnIcon from "../../icons/GridOnIcon";
import GridOffIcon from "../../icons/GridOffIcon";
@ -29,81 +30,43 @@ function TokenPreview({ token }) {
unknownSource
);
const [tokenSourceImage] = useImage(tokenSource);
const [tokenRatio, setTokenRatio] = useState(1);
useEffect(() => {
if (tokenSourceImage) {
setTokenRatio(tokenSourceImage.width / tokenSourceImage.height);
}
}, [tokenSourceImage]);
const [stageWidth, setStageWidth] = useState(1);
const [stageHeight, setStageHeight] = useState(1);
const [stageScale, setStageScale] = useState(1);
const stageRatio = stageWidth / stageHeight;
let tokenWidth;
let tokenHeight;
if (stageRatio > tokenRatio) {
tokenWidth = tokenSourceImage
? stageHeight / (tokenSourceImage.height / tokenSourceImage.width)
: stageWidth;
tokenHeight = stageHeight;
} else {
tokenWidth = stageWidth;
tokenHeight = tokenSourceImage
? stageWidth * (tokenSourceImage.height / tokenSourceImage.width)
: stageHeight;
}
const stageTranslateRef = useRef({ x: 0, y: 0 });
const mapLayerRef = useRef();
const tokenStageRef = useRef();
const tokenLayerRef = useRef();
function handleResize(width, height) {
setStageWidth(width);
setStageHeight(height);
}
// Reset map translate and scale
useEffect(() => {
const layer = mapLayerRef.current;
const containerRect = containerRef.current.getBoundingClientRect();
if (layer) {
let newTranslate;
if (stageRatio > tokenRatio) {
newTranslate = {
x: -(tokenWidth - containerRect.width) / 2,
y: 0,
};
} else {
newTranslate = {
x: 0,
y: -(tokenHeight - containerRect.height) / 2,
};
}
const containerRef = useRef();
usePreventOverscroll(containerRef);
layer.x(newTranslate.x);
layer.y(newTranslate.y);
layer.draw();
stageTranslateRef.current = newTranslate;
setStageScale(1);
}
}, [token.id, tokenWidth, tokenHeight, stageRatio, tokenRatio]);
const [tokenWidth, tokenHeight] = useImageCenter(
token,
tokenStageRef,
stageWidth,
stageHeight,
stageTranslateRef,
setStageScale,
tokenLayerRef,
containerRef,
true
);
const bind = useStageInteraction(
mapLayerRef.current,
tokenStageRef.current,
stageScale,
setStageScale,
stageTranslateRef,
10,
"pan"
tokenLayerRef.current
);
const containerRef = useRef();
usePreventOverscroll(containerRef);
const [showGridPreview, setShowGridPreview] = useState(true);
const gridWidth = tokenWidth;
const gridX = token.defaultSize;
@ -134,11 +97,9 @@ function TokenPreview({ token }) {
width={stageWidth}
height={stageHeight}
scale={{ x: stageScale, y: stageScale }}
x={stageWidth / 2}
y={stageHeight / 2}
offset={{ x: stageWidth / 2, y: stageHeight / 2 }}
ref={tokenStageRef}
>
<Layer ref={mapLayerRef}>
<Layer ref={tokenLayerRef}>
<Image
image={tokenSourceImage}
width={tokenWidth}

View File

@ -0,0 +1,71 @@
import { useEffect, useRef } from "react";
function useImageCenter(
data,
stageRef,
stageWidth,
stageHeight,
stageTranslateRef,
setStageScale,
imageLayerRef,
containerRef,
responsive = false
) {
const stageRatio = stageWidth / stageHeight;
const imageRatio = data ? data.width / data.height : 1;
let imageWidth;
let imageHeight;
if (stageRatio > imageRatio) {
imageWidth = data ? stageHeight / (data.height / data.width) : stageWidth;
imageHeight = stageHeight;
} else {
imageWidth = stageWidth;
imageHeight = data ? stageWidth * (data.height / data.width) : stageHeight;
}
// Reset data translate and scale
const previousDataIdRef = useRef();
const previousStageRatioRef = useRef(stageRatio);
useEffect(() => {
if (!data) {
return;
}
const layer = imageLayerRef.current;
const containerRect = containerRef.current.getBoundingClientRect();
const previousDataId = previousDataIdRef.current;
const previousStageRatio = previousStageRatioRef.current;
// Update when the id has changed and if responsive update when the stage changes
const shouldUpdate = responsive
? previousDataId !== data.id || previousStageRatio !== stageRatio
: previousDataId !== data.id;
if (layer && shouldUpdate) {
let newTranslate;
if (stageRatio > imageRatio) {
newTranslate = {
x: -(imageWidth - containerRect.width) / 2,
y: 0,
};
} else {
newTranslate = {
x: 0,
y: -(imageHeight - containerRect.height) / 2,
};
}
layer.position(newTranslate);
stageRef.current.position({ x: 0, y: 0 });
stageTranslateRef.current = { x: 0, y: 0 };
setStageScale(1);
}
previousDataIdRef.current = data.id;
previousStageRatioRef.current = stageRatio;
});
return [imageWidth, imageHeight];
}
export default useImageCenter;

View File

@ -7,10 +7,11 @@ const touchZoomSpeed = 0.005;
const minZoom = 0.1;
function useStageInteraction(
layer,
stage,
stageScale,
onStageScaleChange,
stageTranslateRef,
layer,
maxZoom = 10,
tool = "pan",
preventInteraction = false,
@ -43,6 +44,20 @@ function useStageInteraction(
),
maxZoom
);
const pointer = stage.getPointerPosition();
const pointerChange = {
x: (pointer.x - stage.x()) / stageScale,
y: (pointer.y - stage.y()) / stageScale,
};
const newTranslate = {
x: pointer.x - pointerChange.x * newScale,
y: pointer.y - pointerChange.y * newScale,
};
stage.position(newTranslate);
stageTranslateRef.current = newTranslate;
onStageScaleChange(newScale);
gesture.onWheel && gesture.onWheel(props);
},
@ -68,12 +83,11 @@ function useStageInteraction(
// Apply translate
const stageTranslate = stageTranslateRef.current;
const newTranslate = {
x: stageTranslate.x + originXDelta / newScale,
y: stageTranslate.y + originYDelta / newScale,
x: stageTranslate.x + originXDelta,
y: stageTranslate.y + originYDelta,
};
layer.x(newTranslate.x);
layer.y(newTranslate.y);
layer.draw();
stage.position(newTranslate);
stage.draw();
stageTranslateRef.current = newTranslate;
pinchPreviousDistanceRef.current = distance;
@ -96,12 +110,11 @@ function useStageInteraction(
const stageTranslate = stageTranslateRef.current;
if (tool === "pan") {
const newTranslate = {
x: stageTranslate.x + dx / stageScale,
y: stageTranslate.y + dy / stageScale,
x: stageTranslate.x + dx,
y: stageTranslate.y + dy,
};
layer.x(newTranslate.x);
layer.y(newTranslate.y);
layer.draw();
stage.position(newTranslate);
stage.draw();
stageTranslateRef.current = newTranslate;
}
gesture.onDrag && gesture.onDrag(props);