Moved map and map tokens to Konva

This commit is contained in:
Mitchell McCaffrey
2020-05-21 16:46:50 +10:00
parent 542388a67f
commit 5b70f69fb7
12 changed files with 420 additions and 503 deletions
+49 -191
View File
@@ -1,24 +1,12 @@
import React, { useRef, useEffect, useState, useContext } from "react";
import { Box, Image } from "theme-ui";
import React, { useState, useContext } from "react";
import ProxyToken from "../token/ProxyToken";
import TokenMenu from "../token/TokenMenu";
import MapToken from "./MapToken";
import MapDrawing from "./MapDrawing";
import MapFog from "./MapFog";
import MapControls from "./MapControls";
import { omit } from "../../helpers/shared";
import useDataSource from "../../helpers/useDataSource";
import MapInteraction from "./MapInteraction";
import MapToken from "./MapToken";
import AuthContext from "../../contexts/AuthContext";
import TokenDataContext from "../../contexts/TokenDataContext";
import { mapSources as defaultMapSources } from "../../maps";
const mapTokenProxyClassName = "map-token__proxy";
const mapTokenMenuClassName = "map-token__menu";
import TokenMenu from "../token/TokenMenu";
function Map({
map,
@@ -38,24 +26,12 @@ function Map({
disabledTokens,
loading,
}) {
const { userId } = useContext(AuthContext);
const { tokens } = useContext(TokenDataContext);
const mapSource = useDataSource(map, defaultMapSources);
function handleProxyDragEnd(isOnMap, tokenState) {
if (isOnMap && onMapTokenStateChange) {
onMapTokenStateChange({ ...tokenState, lastEditedBy: userId });
}
if (!isOnMap && onMapTokenStateRemove) {
onMapTokenStateRemove({ ...tokenState, lastEditedBy: userId });
}
}
/**
* Map drawing
*/
const gridX = map && map.gridX;
const gridY = map && map.gridY;
const gridSizeNormalized = { x: 1 / gridX || 0, y: 1 / gridY || 0 };
const tokenSizePercent = gridSizeNormalized.x;
const [selectedToolId, setSelectedToolId] = useState("pan");
const [toolSettings, setToolSettings] = useState({
@@ -105,55 +81,7 @@ function Map({
}
const [mapShapes, setMapShapes] = useState([]);
function handleMapShapeAdd(shape) {
onMapDraw({ type: "add", shapes: [shape] });
}
function handleMapShapeRemove(shapeId) {
onMapDraw({ type: "remove", shapeIds: [shapeId] });
}
const [fogShapes, setFogShapes] = useState([]);
function handleFogShapeAdd(shape) {
onFogDraw({ type: "add", shapes: [shape] });
}
function handleFogShapeRemove(shapeId) {
onFogDraw({ type: "remove", shapeIds: [shapeId] });
}
function handleFogShapeEdit(shape) {
onFogDraw({ type: "edit", shapes: [shape] });
}
// Replay the draw actions and convert them to shapes for the map drawing
useEffect(() => {
if (!mapState) {
return;
}
function actionsToShapes(actions, actionIndex) {
let shapesById = {};
for (let i = 0; i <= actionIndex; i++) {
const action = actions[i];
if (action.type === "add" || action.type === "edit") {
for (let shape of action.shapes) {
shapesById[shape.id] = shape;
}
}
if (action.type === "remove") {
shapesById = omit(shapesById, action.shapeIds);
}
}
return Object.values(shapesById);
}
setMapShapes(
actionsToShapes(mapState.mapDrawActions, mapState.mapDrawActionIndex)
);
setFogShapes(
actionsToShapes(mapState.fogDrawActions, mapState.fogDrawActionIndex)
);
}, [mapState]);
const disabledControls = [];
if (!allowMapDrawing) {
@@ -195,92 +123,6 @@ function Map({
disabledSettings.fog.push("redo");
}
/**
* Member setup
*/
const mapRef = useRef(null);
const gridX = map && map.gridX;
const gridY = map && map.gridY;
const gridSizeNormalized = { x: 1 / gridX || 0, y: 1 / gridY || 0 };
const tokenSizePercent = gridSizeNormalized.x * 100;
const aspectRatio = (map && map.width / map.height) || 1;
const mapImage = (
<Box
sx={{
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
}}
>
<Image
ref={mapRef}
className="mapImage"
sx={{
width: "100%",
userSelect: "none",
touchAction: "none",
}}
src={mapSource}
/>
</Box>
);
const mapTokens = (
<Box
sx={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
pointerEvents: "none",
}}
>
{mapState &&
Object.values(mapState.tokens).map((tokenState) => (
<MapToken
key={tokenState.id}
token={tokens.find((token) => token.id === tokenState.tokenId)}
tokenState={tokenState}
tokenSizePercent={tokenSizePercent}
className={`${mapTokenProxyClassName} ${mapTokenMenuClassName}`}
/>
))}
</Box>
);
const mapDrawing = (
<MapDrawing
width={map ? map.width : 0}
height={map ? map.height : 0}
selectedTool={selectedToolId !== "fog" ? selectedToolId : "none"}
toolSettings={toolSettings[selectedToolId]}
shapes={mapShapes}
onShapeAdd={handleMapShapeAdd}
onShapeRemove={handleMapShapeRemove}
gridSize={gridSizeNormalized}
/>
);
const mapFog = (
<MapFog
width={map ? map.width : 0}
height={map ? map.height : 0}
isEditing={selectedToolId === "fog"}
toolSettings={toolSettings["fog"]}
shapes={fogShapes}
onShapeAdd={handleFogShapeAdd}
onShapeRemove={handleFogShapeRemove}
onShapeEdit={handleFogShapeEdit}
gridSize={gridSizeNormalized}
/>
);
const mapControls = (
<MapControls
onMapChange={onMapChange}
@@ -296,33 +138,49 @@ function Map({
disabledSettings={disabledSettings}
/>
);
const [isTokenMenuOpen, setIsTokenMenuOpen] = useState(false);
const [tokenMenuOptions, setTokenMenuOptions] = useState({});
function handleTokenMenuOpen(tokenStateId, tokenImage) {
setTokenMenuOptions({ tokenStateId, tokenImage });
setIsTokenMenuOpen(true);
}
const mapTokens =
mapState &&
Object.values(mapState.tokens).map((tokenState) => (
<MapToken
key={tokenState.id}
token={tokens.find((token) => token.id === tokenState.tokenId)}
tokenState={tokenState}
tokenSizePercent={tokenSizePercent}
onTokenStateChange={onMapTokenStateChange}
onTokenMenuOpen={handleTokenMenuOpen}
/>
));
const tokenMenu = isTokenMenuOpen && (
<TokenMenu
isOpen={isTokenMenuOpen}
onRequestClose={() => setIsTokenMenuOpen(false)}
onTokenChange={onMapTokenStateChange}
tokenState={mapState.tokens[tokenMenuOptions.tokenStateId]}
tokenImage={tokenMenuOptions.tokenImage}
/>
);
return (
<>
<MapInteraction
map={map}
aspectRatio={aspectRatio}
isEnabled={selectedToolId === "pan"}
controls={mapControls}
loading={loading}
>
{map && mapImage}
{map && mapDrawing}
{map && mapFog}
{map && mapTokens}
</MapInteraction>
<ProxyToken
tokenClassName={mapTokenProxyClassName}
onProxyDragEnd={handleProxyDragEnd}
tokens={mapState && mapState.tokens}
disabledTokens={disabledTokens}
/>
<TokenMenu
tokenClassName={mapTokenMenuClassName}
onTokenChange={onMapTokenStateChange}
tokens={mapState && mapState.tokens}
disabledTokens={disabledTokens}
/>
</>
<MapInteraction
map={map}
controls={
<>
{mapControls}
{tokenMenu}
</>
}
>
{mapTokens}
</MapInteraction>
);
}
+106 -148
View File
@@ -1,171 +1,129 @@
import React, { useRef, useEffect } from "react";
import React, { useRef, useEffect, useState, useContext } from "react";
import { Box } from "theme-ui";
import interact from "interactjs";
import normalizeWheel from "normalize-wheel";
import { useGesture } from "react-use-gesture";
import ReactResizeDetector from "react-resize-detector";
import useImage from "use-image";
import { Stage, Layer, Image } from "react-konva";
import usePreventOverscroll from "../../helpers/usePreventOverscroll";
import useDataSource from "../../helpers/useDataSource";
import { mapSources as defaultMapSources } from "../../maps";
import { MapInteractionProvider } from "../../contexts/MapInteractionContext";
import LoadingOverlay from "../LoadingOverlay";
import MapStageContext from "../../contexts/MapStageContext";
import AuthContext from "../../contexts/AuthContext";
const zoomSpeed = -0.001;
const minZoom = 0.1;
const maxZoom = 5;
function MapInteraction({
map,
aspectRatio,
isEnabled,
children,
controls,
loading,
}) {
const mapContainerRef = useRef();
const mapMoveContainerRef = useRef();
const mapScaleContainerRef = useRef();
const mapTranslateRef = useRef({ x: 0, y: 0 });
const mapScaleRef = useRef(1);
function setTranslateAndScale(newTranslate, newScale) {
const moveContainer = mapMoveContainerRef.current;
const scaleContainer = mapScaleContainerRef.current;
moveContainer.style.transform = `translate(${newTranslate.x}px, ${newTranslate.y}px)`;
scaleContainer.style.transform = ` scale(${newScale})`;
mapScaleRef.current = newScale;
mapTranslateRef.current = newTranslate;
}
function MapInteraction({ map, children, controls }) {
const mapSource = useDataSource(map, defaultMapSources);
const [mapSourceImage] = useImage(mapSource);
const [stageWidth, setStageWidth] = useState(1);
const [stageHeight, setStageHeight] = useState(1);
const [stageScale, setStageScale] = useState(1);
const [stageTranslate, setStageTranslate] = useState({ x: 0, y: 0 });
const [preventMapInteraction, setPreventMapInteraction] = useState(false);
const stageWidthRef = useRef(stageWidth);
const stageHeightRef = useRef(stageHeight);
const stageScaleRef = useRef(stageScale);
const stageTranslateRef = useRef(stageTranslate);
useEffect(() => {
function handleMove(event, isGesture) {
const scale = mapScaleRef.current;
const translate = mapTranslateRef.current;
let newScale = scale;
let newTranslate = translate;
if (isGesture) {
newScale = Math.max(Math.min(scale + event.ds, maxZoom), minZoom);
}
if (isEnabled || isGesture) {
newTranslate = {
x: translate.x + event.dx / newScale,
y: translate.y + event.dy / newScale,
};
}
setTranslateAndScale(newTranslate, newScale);
if (map) {
const mapHeight = stageWidthRef.current * (map.height / map.width);
setStageTranslate({ x: 0, y: -(mapHeight - stageHeightRef.current) / 2 });
}
const mapInteract = interact(".map")
.gesturable({
listeners: {
move: (e) => handleMove(e, true),
},
})
.draggable({
inertia: true,
listeners: {
move: (e) => handleMove(e, false),
},
cursorChecker: () => {
return isEnabled && map ? "move" : "default";
},
})
.on("doubletap", (event) => {
event.preventDefault();
if (isEnabled) {
setTranslateAndScale({ x: 0, y: 0 }, 1);
}
});
return () => {
mapInteract.unset();
};
}, [isEnabled, map]);
// Reset map transform when map changes
useEffect(() => {
setTranslateAndScale({ x: 0, y: 0 }, 1);
}, [map]);
// Bind the wheel event of the map via a ref
// in order to support non-passive event listening
// to allow the track pad zoom to be interrupted
// see https://github.com/facebook/react/issues/14856
useEffect(() => {
const mapContainer = mapContainerRef.current;
function handleZoom(event) {
// Stop overscroll on chrome and safari
// also stop pinch to zoom on chrome
event.preventDefault();
// Try and normalize the wheel event to prevent OS differences for zoom speed
const normalized = normalizeWheel(event);
const scale = mapScaleRef.current;
const translate = mapTranslateRef.current;
const deltaY = normalized.pixelY * zoomSpeed;
const newScale = Math.max(Math.min(scale + deltaY, maxZoom), minZoom);
setTranslateAndScale(translate, newScale);
}
if (mapContainer) {
mapContainer.addEventListener("wheel", handleZoom, {
passive: false,
});
}
return () => {
if (mapContainer) {
mapContainer.removeEventListener("wheel", handleZoom);
const bind = useGesture({
onWheel: ({ delta }) => {
const newScale = Math.min(
Math.max(stageScale - delta[1] * zoomSpeed, minZoom),
maxZoom
);
setStageScale(newScale);
stageScaleRef.current = newScale;
},
onDrag: ({ delta }) => {
if (!preventMapInteraction) {
const newTranslate = {
x: stageTranslate.x + delta[0] / stageScale,
y: stageTranslate.y + delta[1] / stageScale,
};
setStageTranslate(newTranslate);
stageTranslateRef.current = newTranslate;
}
};
}, []);
},
});
function handleResize(width, height) {
setStageWidth(width);
setStageHeight(height);
stageWidthRef.current = width;
stageHeightRef.current = height;
}
const containerRef = useRef();
usePreventOverscroll(containerRef);
const mapWidth = stageWidth;
const mapHeight = map ? stageWidth * (map.height / map.width) : stageHeight;
const mapStageRef = useContext(MapStageContext);
const auth = useContext(AuthContext);
const mapInteraction = {
stageTranslate,
stageScale,
stageWidth,
stageHeight,
setPreventMapInteraction,
mapWidth,
mapHeight,
};
return (
<Box
sx={{ flexGrow: 1, position: "relative" }}
ref={containerRef}
{...bind()}
className="map"
sx={{
flexGrow: 1,
position: "relative",
overflow: "hidden",
backgroundColor: "rgba(0, 0, 0, 0.1)",
userSelect: "none",
touchAction: "none",
}}
bg="background"
ref={mapContainerRef}
>
<Box
sx={{
position: "relative",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
}}
>
<Box ref={mapScaleContainerRef}>
<Box ref={mapMoveContainerRef}>
<Box
sx={{
width: "100%",
height: 0,
paddingBottom: `${(1 / aspectRatio) * 100}%`,
}}
<ReactResizeDetector handleWidth handleHeight onResize={handleResize}>
<Stage
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 x={stageTranslate.x} y={stageTranslate.y}>
<Image
image={mapSourceImage}
width={mapWidth}
height={mapHeight}
id="mapImage"
/>
<MapInteractionProvider
value={{
translateRef: mapTranslateRef,
scaleRef: mapScaleRef,
}}
>
{children}
</MapInteractionProvider>
</Box>
</Box>
</Box>
{controls}
{loading && <LoadingOverlay />}
{/* Forward auth context to konva elements */}
<AuthContext.Provider value={auth}>
<MapInteractionProvider value={mapInteraction}>
{children}
</MapInteractionProvider>
</AuthContext.Provider>
</Layer>
</Stage>
</ReactResizeDetector>
<MapInteractionProvider value={mapInteraction}>
{controls}
</MapInteractionProvider>
</Box>
);
}
+9 -3
View File
@@ -1,8 +1,9 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useState, useContext } from "react";
import Modal from "react-modal";
import { useThemeUI } from "theme-ui";
import MapInteractionContext from "../../contexts/MapInteractionContext";
function MapMenu({
isOpen,
onRequestClose,
@@ -21,6 +22,8 @@ function MapMenu({
// callback
const [modalContentNode, setModalContentNode] = useState(null);
const { setPreventMapInteraction } = useContext(MapInteractionContext);
useEffect(() => {
// Close modal if interacting with any other element
function handlePointerDown(event) {
@@ -29,17 +32,20 @@ function MapMenu({
!path.includes(modalContentNode) &&
!(excludeNode && path.includes(excludeNode))
) {
setPreventMapInteraction(false);
onRequestClose();
document.body.removeEventListener("pointerdown", handlePointerDown);
}
}
if (modalContentNode) {
setPreventMapInteraction(true);
document.body.addEventListener("pointerdown", handlePointerDown);
// Check for wheel event to close modal as well
document.body.addEventListener(
"wheel",
() => {
setPreventMapInteraction(false);
onRequestClose();
},
{ once: true }
@@ -50,7 +56,7 @@ function MapMenu({
document.body.removeEventListener("pointerdown", handlePointerDown);
}
};
}, [modalContentNode, excludeNode, onRequestClose]);
}, [modalContentNode, excludeNode, onRequestClose, setPreventMapInteraction]);
function handleModalContent(node) {
setModalContentNode(node);
+73 -61
View File
@@ -1,76 +1,88 @@
import React, { useRef, useContext } from "react";
import { Box, Image } from "theme-ui";
import React, { useContext, useState, useEffect, useRef } from "react";
import { Image as KonvaImage } from "react-konva";
import useImage from "use-image";
import TokenLabel from "../token/TokenLabel";
import TokenStatus from "../token/TokenStatus";
import usePreventTouch from "../../helpers/usePreventTouch";
import useDataSource from "../../helpers/useDataSource";
import useDebounce from "../../helpers/useDebounce";
import AuthContext from "../../contexts/AuthContext";
import MapInteractionContext from "../../contexts/MapInteractionContext";
import { tokenSources, unknownSource } from "../../tokens";
function MapToken({ token, tokenState, tokenSizePercent, className }) {
function MapToken({
token,
tokenState,
tokenSizePercent,
onTokenStateChange,
onTokenMenuOpen,
}) {
const { userId } = useContext(AuthContext);
const imageSource = useDataSource(token, tokenSources, unknownSource);
const {
setPreventMapInteraction,
mapWidth,
mapHeight,
stageScale,
} = useContext(MapInteractionContext);
const tokenSource = useDataSource(token, tokenSources, unknownSource);
const [tokenSourceImage, tokenSourceStatus] = useImage(tokenSource);
const [tokenAspectRatio, setTokenAspectRatio] = useState(1);
useEffect(() => {
if (tokenSourceImage) {
setTokenAspectRatio(tokenSourceImage.width / tokenSourceImage.height);
}
}, [tokenSourceImage]);
function handleDragEnd(event) {
onTokenStateChange({
...tokenState,
x: event.target.x() / mapWidth,
y: event.target.y() / mapHeight,
lastEditedBy: userId,
});
}
function handleClick(event) {
const tokenImage = event.target;
onTokenMenuOpen(tokenState.id, tokenImage);
}
const tokenWidth = tokenSizePercent * mapWidth * tokenState.size;
const tokenHeight =
tokenSizePercent * (mapWidth / tokenAspectRatio) * tokenState.size;
const debouncedStageScale = useDebounce(stageScale, 50);
const imageRef = useRef();
// Stop touch to prevent 3d touch gesutre on iOS
usePreventTouch(imageRef);
useEffect(() => {
const image = imageRef.current;
if (image) {
image.cache({
pixelRatio: debouncedStageScale,
});
image.drawHitFromCache();
// Force redraw
image.parent.draw();
}
}, [debouncedStageScale, tokenWidth, tokenHeight, tokenSourceStatus]);
return (
<Box
style={{
transform: `translate(${tokenState.x * 100}%, ${tokenState.y * 100}%)`,
width: "100%",
height: "100%",
transition:
tokenState.lastEditedBy === userId
? "initial"
: "transform 0.5s ease",
}}
sx={{
position: "absolute",
pointerEvents: "none",
}}
>
<Box
style={{
width: `${tokenSizePercent * (tokenState.size || 1)}%`,
}}
sx={{
position: "absolute",
pointerEvents: "all",
}}
>
<Box
sx={{
position: "absolute",
display: "flex", // Set display to flex to fix height being calculated wrong
width: "100%",
flexDirection: "column",
}}
>
<Image
className={className}
sx={{
userSelect: "none",
touchAction: "none",
width: "100%",
// Fix image from being clipped when transitioning
willChange: "transform",
}}
src={imageSource}
// pass id into the dom element which is then used by the ProxyToken
data-id={tokenState.id}
ref={imageRef}
/>
{tokenState.statuses && <TokenStatus token={tokenState} />}
{tokenState.label && <TokenLabel token={tokenState} />}
</Box>
</Box>
</Box>
<KonvaImage
ref={imageRef}
width={tokenWidth}
height={tokenHeight}
x={tokenState.x * mapWidth}
y={tokenState.y * mapHeight}
image={tokenSourceImage}
draggable
onDragEnd={handleDragEnd}
onClick={handleClick}
onMouseDown={() => setPreventMapInteraction(true)}
onMouseUp={() => setPreventMapInteraction(false)}
onTouchStart={() => setPreventMapInteraction(true)}
onTouchEnd={() => setPreventMapInteraction(false)}
/>
);
}