From 5b70f69fb7efb0951717068bd991be66417b0c6d Mon Sep 17 00:00:00 2001 From: Mitchell McCaffrey Date: Thu, 21 May 2020 16:46:50 +1000 Subject: [PATCH] Moved map and map tokens to Konva --- package.json | 5 + src/components/map/Map.js | 240 +++++------------------- src/components/map/MapInteraction.js | 254 +++++++++++--------------- src/components/map/MapMenu.js | 12 +- src/components/map/MapToken.js | 134 +++++++------- src/components/token/ProxyToken.js | 30 ++- src/components/token/TokenMenu.js | 127 +++++-------- src/contexts/MapInteractionContext.js | 9 +- src/contexts/MapStageContext.js | 8 + src/helpers/usePreventOverscroll.js | 25 +++ src/routes/Game.js | 17 +- yarn.lock | 62 +++++++ 12 files changed, 420 insertions(+), 503 deletions(-) create mode 100644 src/contexts/MapStageContext.js create mode 100644 src/helpers/usePreventOverscroll.js diff --git a/package.json b/package.json index 341f414..83a4a78 100644 --- a/package.json +++ b/package.json @@ -11,21 +11,26 @@ "dexie": "^2.0.4", "fake-indexeddb": "^3.0.0", "interactjs": "^1.9.7", + "konva": "^6.0.0", "normalize-wheel": "^1.0.1", "raw.macro": "^0.3.0", "react": "^16.13.0", "react-dom": "^16.13.0", + "react-konva": "^16.13.0-3", "react-markdown": "^4.3.1", "react-modal": "^3.11.2", + "react-resize-detector": "^4.2.3", "react-router-dom": "^5.1.2", "react-router-hash-link": "^1.2.2", "react-scripts": "3.4.0", + "react-use-gesture": "^7.0.15", "shortid": "^2.2.15", "simple-peer": "^9.6.2", "simplebar-react": "^2.1.0", "simplify-js": "^1.2.4", "socket.io-client": "^2.3.0", "theme-ui": "^0.3.1", + "use-image": "^1.0.5", "webrtc-adapter": "^7.5.1" }, "scripts": { diff --git a/src/components/map/Map.js b/src/components/map/Map.js index f259128..c9182dc 100644 --- a/src/components/map/Map.js +++ b/src/components/map/Map.js @@ -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 = ( - - - - ); - - const mapTokens = ( - - {mapState && - Object.values(mapState.tokens).map((tokenState) => ( - token.id === tokenState.tokenId)} - tokenState={tokenState} - tokenSizePercent={tokenSizePercent} - className={`${mapTokenProxyClassName} ${mapTokenMenuClassName}`} - /> - ))} - - ); - - const mapDrawing = ( - - ); - - const mapFog = ( - - ); - const mapControls = ( ); + + 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) => ( + token.id === tokenState.tokenId)} + tokenState={tokenState} + tokenSizePercent={tokenSizePercent} + onTokenStateChange={onMapTokenStateChange} + onTokenMenuOpen={handleTokenMenuOpen} + /> + )); + + const tokenMenu = isTokenMenuOpen && ( + setIsTokenMenuOpen(false)} + onTokenChange={onMapTokenStateChange} + tokenState={mapState.tokens[tokenMenuOptions.tokenStateId]} + tokenImage={tokenMenuOptions.tokenImage} + /> + ); + return ( - <> - - {map && mapImage} - {map && mapDrawing} - {map && mapFog} - {map && mapTokens} - - - - + + {mapControls} + {tokenMenu} + + } + > + {mapTokens} + ); } diff --git a/src/components/map/MapInteraction.js b/src/components/map/MapInteraction.js index 48bfff8..142c393 100644 --- a/src/components/map/MapInteraction.js +++ b/src/components/map/MapInteraction.js @@ -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 ( - - - - + + + - - {children} - - - - - {controls} - {loading && } + {/* Forward auth context to konva elements */} + + + {children} + + + + + + + {controls} + ); } diff --git a/src/components/map/MapMenu.js b/src/components/map/MapMenu.js index 6ddfd75..909f141 100644 --- a/src/components/map/MapMenu.js +++ b/src/components/map/MapMenu.js @@ -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); diff --git a/src/components/map/MapToken.js b/src/components/map/MapToken.js index 79d8087..82b2f53 100644 --- a/src/components/map/MapToken.js +++ b/src/components/map/MapToken.js @@ -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 ( - - - - - {tokenState.statuses && } - {tokenState.label && } - - - + setPreventMapInteraction(true)} + onMouseUp={() => setPreventMapInteraction(false)} + onTouchStart={() => setPreventMapInteraction(true)} + onTouchEnd={() => setPreventMapInteraction(false)} + /> ); } diff --git a/src/components/token/ProxyToken.js b/src/components/token/ProxyToken.js index 90c4c32..a0dd52c 100644 --- a/src/components/token/ProxyToken.js +++ b/src/components/token/ProxyToken.js @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from "react"; +import React, { useEffect, useRef, useState, useContext } from "react"; import ReactDOM from "react-dom"; import { Image, Box } from "theme-ui"; import interact from "interactjs"; @@ -8,6 +8,8 @@ import usePortal from "../../helpers/usePortal"; import TokenLabel from "./TokenLabel"; import TokenStatus from "./TokenStatus"; +import MapStageContext from "../../contexts/MapStageContext"; + /** * @callback onProxyDragEnd * @param {boolean} isOnMap whether the token was dropped on the map @@ -44,6 +46,7 @@ function ProxyToken({ }, [tokens, disabledTokens]); const proxyOnMap = useRef(false); + const mapStageRef = useContext(MapStageContext); useEffect(() => { interact(`.${tokenClassName}`).draggable({ @@ -110,18 +113,27 @@ function ProxyToken({ } let proxy = proxyRef.current; if (proxy) { - const mapImage = document.querySelector(".mapImage"); - if (onProxyDragEnd && mapImage) { - const mapImageRect = mapImage.getBoundingClientRect(); + const mapStage = mapStageRef.current; + if (onProxyDragEnd && mapStage) { + const mapImageRect = mapStage + .findOne("#mapImage") + .getClientRect(); + + const map = document.querySelector(".map"); + const mapRect = map.getBoundingClientRect(); let x = parseFloat(proxy.getAttribute("data-x")) || 0; let y = parseFloat(proxy.getAttribute("data-y")) || 0; + + // TODO: This seems to be wrong when map is zoomed + // Convert coordiantes to be relative to the map - x = x - mapImageRect.left; - y = y - mapImageRect.top; + x = x - mapRect.left - mapImageRect.x; + y = y - mapRect.top - mapImageRect.y; + // Normalize to map width - x = x / (mapImageRect.right - mapImageRect.left); - y = y / (mapImageRect.bottom - mapImageRect.top); + x = x / mapImageRect.width; + y = y / mapImageRect.height; // Get the token from the supplied tokens if it exists const token = tokensRef.current[id] || {}; @@ -145,7 +157,7 @@ function ProxyToken({ }, }, }); - }, [onProxyDragEnd, tokenClassName, proxyContainer]); + }, [onProxyDragEnd, tokenClassName, proxyContainer, mapStageRef]); if (!imageSource) { return null; diff --git a/src/components/token/TokenMenu.js b/src/components/token/TokenMenu.js index b87ab78..07161fe 100644 --- a/src/components/token/TokenMenu.js +++ b/src/components/token/TokenMenu.js @@ -1,11 +1,12 @@ -import React, { useEffect, useState, useRef } from "react"; -import interact from "interactjs"; +import React, { useEffect, useState } from "react"; import { Box, Input, Slider, Flex, Text } from "theme-ui"; import MapMenu from "../map/MapMenu"; import colors, { colorOptions } from "../../helpers/colors"; +import usePrevious from "../../helpers/usePrevious"; + const defaultTokenMaxSize = 6; /** @@ -20,105 +21,59 @@ const defaultTokenMaxSize = 6; * @param {Object} tokens An mapping of tokens to use as a base when calling onTokenChange * @param {Object} disabledTokens An optional mapping of tokens that shouldn't allow interaction */ -function TokenMenu({ tokenClassName, onTokenChange, tokens, disabledTokens }) { - const [isOpen, setIsOpen] = useState(false); +function TokenMenu({ + isOpen, + onRequestClose, + tokenState, + tokenImage, + onTokenChange, +}) { + const wasOpen = usePrevious(isOpen); - function handleRequestClose() { - setIsOpen(false); - } - - // Store the tokens in a ref and access in the interactjs loop - // This is needed to stop interactjs from creating multiple listeners - const tokensRef = useRef(tokens); - const disabledTokensRef = useRef(disabledTokens); - useEffect(() => { - tokensRef.current = tokens; - disabledTokensRef.current = disabledTokens; - }, [tokens, disabledTokens]); - - const [currentToken, setCurrentToken] = useState({}); - const [menuLeft, setMenuLeft] = useState(0); - const [menuTop, setMenuTop] = useState(0); const [tokenMaxSize, setTokenMaxSize] = useState(defaultTokenMaxSize); + useEffect(() => { + if (isOpen && !wasOpen) { + setTokenMaxSize(Math.max(tokenState.size, defaultTokenMaxSize)); + } + }, [isOpen, tokenState, wasOpen]); function handleLabelChange(event) { const label = event.target.value; - setCurrentToken((prevToken) => ({ - ...prevToken, - label: label, - })); - - onTokenChange({ ...currentToken, label: label }); + onTokenChange({ ...tokenState, label: label }); } + const [menuLeft, setMenuLeft] = useState(0); + const [menuTop, setMenuTop] = useState(0); + + useEffect(() => { + if (tokenImage) { + const imageRect = tokenImage.getClientRect(); + const map = document.querySelector(".map"); + const mapRect = map.getBoundingClientRect(); + + // Center X for the menu which is 156px wide + setMenuLeft(mapRect.left + imageRect.x + imageRect.width / 2 - 156 / 2); + // Y 12px from the bottom + setMenuTop(mapRect.top + imageRect.y + imageRect.height + 12); + } + }, [tokenImage]); + function handleStatusChange(status) { - const statuses = currentToken.statuses; + const statuses = tokenState.statuses; let newStatuses = []; if (statuses.includes(status)) { newStatuses = statuses.filter((s) => s !== status); } else { newStatuses = [...statuses, status]; } - setCurrentToken((prevToken) => ({ - ...prevToken, - statuses: newStatuses, - })); - onTokenChange({ ...currentToken, statuses: newStatuses }); + onTokenChange({ ...tokenState, statuses: newStatuses }); } function handleSizeChange(event) { const newSize = parseInt(event.target.value); - setCurrentToken((prevToken) => ({ - ...prevToken, - size: newSize, - })); - onTokenChange({ ...currentToken, size: newSize }); + onTokenChange({ ...tokenState, size: newSize }); } - useEffect(() => { - function handleTokenMenuOpen(event) { - const target = event.target; - const id = target.getAttribute("data-id"); - if (id in disabledTokensRef.current) { - return; - } - const token = tokensRef.current[id] || {}; - setCurrentToken(token); - // Set token max size to be higher if needed - setTokenMaxSize(Math.max(token.size, defaultTokenMaxSize)); - - const targetRect = target.getBoundingClientRect(); - setMenuLeft(targetRect.left); - setMenuTop(targetRect.bottom); - - setIsOpen(true); - } - - // Add listener for tap gesture - const tokenInteract = interact(`.${tokenClassName}`).on( - "tap", - handleTokenMenuOpen - ); - - function handleMapContextMenu(event) { - event.preventDefault(); - if (event.target.classList.contains(tokenClassName)) { - handleTokenMenuOpen(event); - } - } - - // Handle context menu on the map level as handling - // on the token level lead to the default menu still - // being displayed - const map = document.querySelector(".map"); - map.addEventListener("contextmenu", handleMapContextMenu); - - return () => { - map.removeEventListener("contextmenu", handleMapContextMenu); - tokenInteract.unset(); - }; - }, [tokenClassName]); - function handleModalContent(node) { if (node) { // Focus input @@ -145,7 +100,7 @@ function TokenMenu({ tokenClassName, onTokenChange, tokens, disabledTokens }) { return ( { e.preventDefault(); - handleRequestClose(); + onRequestClose(); }} sx={{ alignItems: "center" }} > @@ -170,7 +125,7 @@ function TokenMenu({ tokenClassName, onTokenChange, tokens, disabledTokens }) { handleStatusChange(color)} aria-label={`Token label Color ${color}`} > - {currentToken.statuses && currentToken.statuses.includes(color) && ( + {tokenState.statuses && tokenState.statuses.includes(color) && ( {}, + mapWidth: 1, + mapHeight: 1, }); export const MapInteractionProvider = MapInteractionContext.Provider; diff --git a/src/contexts/MapStageContext.js b/src/contexts/MapStageContext.js new file mode 100644 index 0000000..e8f61d8 --- /dev/null +++ b/src/contexts/MapStageContext.js @@ -0,0 +1,8 @@ +import React from "react"; + +const MapStageContext = React.createContext({ + mapStageRef: { current: null }, +}); +export const MapStageProvider = MapStageContext.Provider; + +export default MapStageContext; diff --git a/src/helpers/usePreventOverscroll.js b/src/helpers/usePreventOverscroll.js new file mode 100644 index 0000000..73cd602 --- /dev/null +++ b/src/helpers/usePreventOverscroll.js @@ -0,0 +1,25 @@ +import { useEffect } from "react"; + +function usePreventOverscroll(elementRef) { + useEffect(() => { + // Stop overscroll on chrome and safari + // also stop pinch to zoom on chrome + function preventOverscroll(event) { + event.preventDefault(); + } + const element = elementRef.current; + if (element) { + element.addEventListener("wheel", preventOverscroll, { + passive: false, + }); + } + + return () => { + if (element) { + element.removeEventListener("wheel", preventOverscroll); + } + }; + }, [elementRef]); +} + +export default usePreventOverscroll; diff --git a/src/routes/Game.js b/src/routes/Game.js index edc5cb6..0bcad2d 100644 --- a/src/routes/Game.js +++ b/src/routes/Game.js @@ -1,4 +1,10 @@ -import React, { useState, useEffect, useCallback, useContext } from "react"; +import React, { + useState, + useEffect, + useCallback, + useContext, + useRef, +} from "react"; import { Flex, Box, Text } from "theme-ui"; import { useParams } from "react-router-dom"; @@ -19,6 +25,7 @@ import AuthContext from "../contexts/AuthContext"; import DatabaseContext from "../contexts/DatabaseContext"; import TokenDataContext from "../contexts/TokenDataContext"; import MapDataContext from "../contexts/MapDataContext"; +import { MapStageProvider } from "../contexts/MapStageContext"; function Game() { const { id: gameId } = useParams(); @@ -500,8 +507,12 @@ function Game() { } }, [stream, peers, handleStreamEnd]); + // A ref to the Konva stage + // the ref will be assigned in the MapInteraction component + const mapStageRef = useRef(); + return ( - <> + {authenticationStatus === "unknown" && } - + ); } diff --git a/yarn.lock b/yarn.lock index 9cb4b13..b368756 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6868,6 +6868,11 @@ kleur@^3.0.3: resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== +konva@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/konva/-/konva-6.0.0.tgz#9b3d13a4622f353c4ce736fbf1fa4b6483240649" + integrity sha512-YTwmtz3KzbzdC0KDRHWLzuk0KXB4NUdaQqytrxacXE1C39V6wCk7Nnu0wgq+GdGbG6m8A1qiEU9TSJ19qdIzDw== + last-call-webpack-plugin@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/last-call-webpack-plugin/-/last-call-webpack-plugin-3.0.0.tgz#9742df0e10e3cf46e5c0381c2de90d3a7a2d7555" @@ -6988,6 +6993,11 @@ locate-path@^5.0.0: dependencies: p-locate "^4.1.0" +lodash-es@^4.17.15: + version "4.17.15" + resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.15.tgz#21bd96839354412f23d7a10340e5eac6ee455d78" + integrity sha512-rlrc3yU3+JNOpZ9zj5pQtxnx2THmvRykwL4Xlxoa8I9lHBlVbbyPhgyPMioxVZ4NqyxaVVtaJnzsyOidQIhyyQ== + lodash._reinterpolate@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d" @@ -9088,6 +9098,11 @@ queue-microtask@^1.1.0: resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.1.2.tgz#139bf8186db0c545017ec66c2664ac646d5c571e" integrity sha512-F9wwNePtXrzZenAB3ax0Y8TSKGvuB7Qw16J30hspEUTbfUM+H827XyN3rlpwhVmtm5wuZtbKIHjOnwDn7MUxWQ== +raf-schd@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/raf-schd/-/raf-schd-4.0.2.tgz#bd44c708188f2e84c810bf55fcea9231bcaed8a0" + integrity sha512-VhlMZmGy6A6hrkJWHLNTGl5gtgMUm+xfGza6wbwnE914yeQ5Ybm18vgM734RZhMgfw4tacUrWseGZlpUrrakEQ== + raf@^3.4.1: version "3.4.1" resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39" @@ -9209,6 +9224,14 @@ react-is@^16.8.1, react-is@^16.8.4: resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.12.0.tgz#2cc0fe0fba742d97fd527c42a13bec4eeb06241c" integrity sha512-rPCkf/mWBtKc97aLL9/txD8DZdemK0vkA3JMLShjlJB3Pj3s+lpf1KaBzMfQrAmhMQB0n1cU/SUGgKKBCe837Q== +react-konva@^16.13.0-3: + version "16.13.0-3" + resolved "https://registry.yarnpkg.com/react-konva/-/react-konva-16.13.0-3.tgz#9ef1e813c8b2dd61b54b26151ccbdeed52b89a80" + integrity sha512-U9az1RidQD4c64oZoHiiv6GU6h2ggHO30nZDqfQWuBTH+Bl2wij6Z0NgbUyVyN1IpKIgXRiEKMS9idlxhAzTXQ== + dependencies: + react-reconciler "^0.25.1" + scheduler "^0.19.1" + react-lifecycles-compat@^3.0.0: version "3.0.4" resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" @@ -9238,6 +9261,27 @@ react-modal@^3.11.2: react-lifecycles-compat "^3.0.0" warning "^4.0.3" +react-reconciler@^0.25.1: + version "0.25.1" + resolved "https://registry.yarnpkg.com/react-reconciler/-/react-reconciler-0.25.1.tgz#f9814d59d115e1210762287ce987801529363aaa" + integrity sha512-R5UwsIvRcSs3w8n9k3tBoTtUHdVhu9u84EG7E5M0Jk9F5i6DA1pQzPfUZd6opYWGy56MJOtV3VADzy6DRwYDjw== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + prop-types "^15.6.2" + scheduler "^0.19.1" + +react-resize-detector@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/react-resize-detector/-/react-resize-detector-4.2.3.tgz#7df258668a30bdfd88e655bbdb27db7fd7b23127" + integrity sha512-4AeS6lxdz2KOgDZaOVt1duoDHrbYwSrUX32KeM9j6t9ISyRphoJbTRCMS1aPFxZHFqcCGLT1gMl3lEcSWZNW0A== + dependencies: + lodash "^4.17.15" + lodash-es "^4.17.15" + prop-types "^15.7.2" + raf-schd "^4.0.2" + resize-observer-polyfill "^1.5.1" + react-router-dom@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-5.1.2.tgz#06701b834352f44d37fbb6311f870f84c76b9c18" @@ -9334,6 +9378,11 @@ react-scripts@3.4.0: optionalDependencies: fsevents "2.1.2" +react-use-gesture@^7.0.15: + version "7.0.15" + resolved "https://registry.yarnpkg.com/react-use-gesture/-/react-use-gesture-7.0.15.tgz#93c651e916a31cfb12d079e7fa1543d5b0511e07" + integrity sha512-vHQkaa7oUbSDTAcFk9huQXa7E8KPrZH91erPuOMoqZT513qvtbb/SzTQ33lHc71/kOoJkMbzOkc4uoA4sT7Ogg== + react@^16.13.0: version "16.13.0" resolved "https://registry.yarnpkg.com/react/-/react-16.13.0.tgz#d046eabcdf64e457bbeed1e792e235e1b9934cf7" @@ -9891,6 +9940,14 @@ scheduler@^0.19.0: loose-envify "^1.1.0" object-assign "^4.1.1" +scheduler@^0.19.1: + version "0.19.1" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.19.1.tgz#4f3e2ed2c1a7d65681f4c854fa8c5a1ccb40f196" + integrity sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + schema-utils@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-1.0.0.tgz#0b79a93204d7b600d4b2850d1f66c2a34951c770" @@ -11171,6 +11228,11 @@ url@^0.11.0: punycode "1.3.2" querystring "0.2.0" +use-image@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/use-image/-/use-image-1.0.5.tgz#51fa23fe705c3ad0d4ae3eca6cf636551c591693" + integrity sha512-tv1tHn1GRcbrifNzCPAN81Z1Fayfd3GXkUDFx0/dUkqqPmADNDRoCyT9MqrUX9GPcofsQl6SREPr9Zavm3dRTQ== + use@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"