diff --git a/.env.production b/.env.production
index 3277f06..fc85f2e 100644
--- a/.env.production
+++ b/.env.production
@@ -1,2 +1,2 @@
-REACT_APP_BROKER_URL=https://agent.owlbear.rodeo
-REACT_APP_ICE_SERVERS_URL=https://agent.owlbear.rodeo/iceservers
\ No newline at end of file
+REACT_APP_BROKER_URL=https://agent.owlbear.dev
+REACT_APP_ICE_SERVERS_URL=https://agent.owlbear.dev/iceservers
\ No newline at end of file
diff --git a/package.json b/package.json
index d5b1828..de15e2c 100644
--- a/package.json
+++ b/package.json
@@ -11,24 +11,31 @@
"ammo.js": "kripken/ammo.js#aab297a4164779c3a9d8dc8d9da26958de3cb778",
"babylonjs": "^4.1.0",
"babylonjs-loaders": "^4.1.0",
+ "case": "^1.6.3",
"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-spring": "^8.0.27",
+ "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/App.js b/src/App.js
index c666293..3cac500 100644
--- a/src/App.js
+++ b/src/App.js
@@ -11,6 +11,9 @@ import ReleaseNotes from "./routes/ReleaseNotes";
import { AuthProvider } from "./contexts/AuthContext";
import { DatabaseProvider } from "./contexts/DatabaseContext";
+import { MapDataProvider } from "./contexts/MapDataContext";
+import { TokenDataProvider } from "./contexts/TokenDataContext";
+import { MapLoadingProvider } from "./contexts/MapLoadingContext";
function App() {
return (
@@ -29,7 +32,13 @@ function App() {
-
+
+
+
+
+
+
+
diff --git a/src/components/ImageDrop.js b/src/components/ImageDrop.js
new file mode 100644
index 0000000..da41987
--- /dev/null
+++ b/src/components/ImageDrop.js
@@ -0,0 +1,61 @@
+import React, { useState } from "react";
+import { Box, Flex, Text } from "theme-ui";
+
+function ImageDrop({ onDrop, dropText, children }) {
+ const [dragging, setDragging] = useState(false);
+ function handleImageDragEnter(event) {
+ event.preventDefault();
+ event.stopPropagation();
+ setDragging(true);
+ }
+
+ function handleImageDragLeave(event) {
+ event.preventDefault();
+ event.stopPropagation();
+ setDragging(false);
+ }
+
+ function handleImageDrop(event) {
+ event.preventDefault();
+ event.stopPropagation();
+ const file = event.dataTransfer.files[0];
+ if (file && file.type.startsWith("image")) {
+ onDrop(file);
+ }
+ setDragging(false);
+ }
+
+ return (
+
+ {children}
+ {dragging && (
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ e.dataTransfer.dropEffect = "copy";
+ }}
+ onDrop={handleImageDrop}
+ >
+
+ {dropText || "Drop image to upload"}
+
+
+ )}
+
+ );
+}
+
+export default ImageDrop;
diff --git a/src/components/Markdown.js b/src/components/Markdown.js
index 0f4130a..7af125e 100644
--- a/src/components/Markdown.js
+++ b/src/components/Markdown.js
@@ -7,7 +7,6 @@ function Paragraph(props) {
}
function Heading({ level, ...props }) {
- console.log(props);
const fontSize = level === 1 ? 5 : level === 2 ? 3 : 1;
return (
-
-
- );
-
- 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({});
+ const [draggingTokenOptions, setDraggingTokenOptions] = useState();
+ function handleTokenMenuOpen(tokenStateId, tokenImage) {
+ setTokenMenuOptions({ tokenStateId, tokenImage });
+ setIsTokenMenuOpen(true);
+ }
+
+ // Sort so vehicles render below other tokens
+ function sortMapTokenStates(a, b) {
+ const tokenA = tokensById[a.tokenId];
+ const tokenB = tokensById[b.tokenId];
+ if (tokenA && tokenB) {
+ return tokenB.isVehicle - tokenA.isVehicle;
+ } else if (tokenA) {
+ return 1;
+ } else if (tokenB) {
+ return -1;
+ } else {
+ return 0;
+ }
+ }
+
+ const mapTokens =
+ mapState &&
+ Object.values(mapState.tokens)
+ .sort(sortMapTokenStates)
+ .map((tokenState) => (
+
+ setDraggingTokenOptions({ tokenState, tokenImage: e.target })
+ }
+ onTokenDragEnd={() => setDraggingTokenOptions(null)}
+ draggable={
+ (selectedToolId === "pan" || selectedToolId === "erase") &&
+ !(tokenState.id in disabledTokens)
+ }
+ mapState={mapState}
+ />
+ ));
+
+ const tokenMenu = (
+ setIsTokenMenuOpen(false)}
+ onTokenStateChange={onMapTokenStateChange}
+ tokenState={mapState && mapState.tokens[tokenMenuOptions.tokenStateId]}
+ tokenImage={tokenMenuOptions.tokenImage}
+ />
+ );
+
+ const tokenDragOverlay = draggingTokenOptions && (
+ {
+ onMapTokenStateRemove(state);
+ setDraggingTokenOptions(null);
+ }}
+ onTokenStateChange={onMapTokenStateChange}
+ tokenState={draggingTokenOptions && draggingTokenOptions.tokenState}
+ tokenImage={draggingTokenOptions && draggingTokenOptions.tokenImage}
+ token={tokensById[draggingTokenOptions.tokenState.tokenId]}
+ mapState={mapState}
+ />
+ );
+
+ const mapDrawing = (
+
+ );
+
+ const mapFog = (
+
+ );
+
return (
- <>
-
-
- {mapControls}
- {loading && }
- >
- }
- >
- {map && mapImage}
- {map && mapDrawing}
- {map && mapFog}
- {map && mapTokens}
-
-
-
- >
+
+ {mapControls}
+ {tokenMenu}
+ {tokenDragOverlay}
+
+ {isLoading && }
+ >
+ }
+ selectedToolId={selectedToolId}
+ >
+ {mapDrawing}
+ {mapTokens}
+ {mapFog}
+
);
}
diff --git a/src/components/map/MapControls.js b/src/components/map/MapControls.js
index 9090bf0..ac69c0f 100644
--- a/src/components/map/MapControls.js
+++ b/src/components/map/MapControls.js
@@ -22,6 +22,7 @@ function MapContols({
onMapChange,
onMapStateChange,
currentMap,
+ currentMapState,
selectedToolId,
onSelectedToolChange,
toolSettings,
@@ -73,6 +74,7 @@ function MapContols({
onMapChange={onMapChange}
onMapStateChange={onMapStateChange}
currentMap={currentMap}
+ currentMapState={currentMapState}
/>
),
},
diff --git a/src/components/map/MapDrawing.js b/src/components/map/MapDrawing.js
index fbc5416..922b1e4 100644
--- a/src/components/map/MapDrawing.js
+++ b/src/components/map/MapDrawing.js
@@ -1,260 +1,255 @@
-import React, { useRef, useEffect, useState, useContext } from "react";
+import React, { useContext, useState, useCallback } from "react";
import shortid from "shortid";
+import { Group, Line, Rect, Circle } from "react-konva";
+
+import MapInteractionContext from "../../contexts/MapInteractionContext";
import { compare as comparePoints } from "../../helpers/vector2";
import {
getBrushPositionForTool,
getDefaultShapeData,
getUpdatedShapeData,
- isShapeHovered,
- drawShape,
simplifyPoints,
- getRelativePointerPosition,
+ getStrokeWidth,
} from "../../helpers/drawing";
-import MapInteractionContext from "../../contexts/MapInteractionContext";
+import colors from "../../helpers/colors";
+import useMapBrush from "../../helpers/useMapBrush";
function MapDrawing({
- width,
- height,
- selectedTool,
- toolSettings,
shapes,
onShapeAdd,
onShapeRemove,
+ selectedToolId,
+ selectedToolSettings,
gridSize,
}) {
- const canvasRef = useRef();
- const containerRef = useRef();
-
- const [isPointerDown, setIsPointerDown] = useState(false);
+ const { stageScale, mapWidth, mapHeight } = useContext(MapInteractionContext);
const [drawingShape, setDrawingShape] = useState(null);
- const [pointerPosition, setPointerPosition] = useState({ x: -1, y: -1 });
- const shouldHover = selectedTool === "erase";
+ const shouldHover = selectedToolId === "erase";
const isEditing =
- selectedTool === "brush" ||
- selectedTool === "shape" ||
- selectedTool === "erase";
+ selectedToolId === "brush" ||
+ selectedToolId === "shape" ||
+ selectedToolId === "erase";
- const { scaleRef } = useContext(MapInteractionContext);
-
- // Reset pointer position when tool changes
- useEffect(() => {
- setPointerPosition({ x: -1, y: -1 });
- }, [selectedTool]);
-
- function handleStart(event) {
- if (!isEditing) {
- return;
- }
- if (event.touches && event.touches.length !== 1) {
- setIsPointerDown(false);
- setDrawingShape(null);
- return;
- }
- const pointer = event.touches ? event.touches[0] : event;
- const position = getRelativePointerPosition(pointer, containerRef.current);
- setPointerPosition(position);
- setIsPointerDown(true);
- const brushPosition = getBrushPositionForTool(
- position,
- selectedTool,
- toolSettings,
- gridSize,
- shapes
- );
- const commonShapeData = {
- color: toolSettings && toolSettings.color,
- blend: toolSettings && toolSettings.useBlending,
- id: shortid.generate(),
- };
- if (selectedTool === "brush") {
- setDrawingShape({
- type: "path",
- pathType: toolSettings.type,
- data: { points: [brushPosition] },
- strokeWidth: toolSettings.type === "stroke" ? 1 : 0,
- ...commonShapeData,
- });
- } else if (selectedTool === "shape") {
- setDrawingShape({
- type: "shape",
- shapeType: toolSettings.type,
- data: getDefaultShapeData(toolSettings.type, brushPosition),
- strokeWidth: 0,
- ...commonShapeData,
- });
- }
- }
-
- function handleMove(event) {
- if (!isEditing) {
- return;
- }
- if (event.touches && event.touches.length !== 1) {
- return;
- }
- const pointer = event.touches ? event.touches[0] : event;
- // Set pointer position every frame for erase tool and fog
- if (shouldHover) {
- const position = getRelativePointerPosition(
- pointer,
- containerRef.current
- );
- setPointerPosition(position);
- }
- if (isPointerDown) {
- const position = getRelativePointerPosition(
- pointer,
- containerRef.current
- );
- setPointerPosition(position);
- const brushPosition = getBrushPositionForTool(
- position,
- selectedTool,
- toolSettings,
- gridSize,
- shapes
- );
- if (selectedTool === "brush") {
- setDrawingShape((prevShape) => {
- const prevPoints = prevShape.data.points;
- if (
- comparePoints(
- prevPoints[prevPoints.length - 1],
- brushPosition,
- 0.001
- )
- ) {
- return prevShape;
- }
- const simplified = simplifyPoints(
- [...prevPoints, brushPosition],
- gridSize,
- scaleRef.current
- );
- return {
- ...prevShape,
- data: { points: simplified },
- };
- });
- } else if (selectedTool === "shape") {
- setDrawingShape((prevShape) => ({
- ...prevShape,
- data: getUpdatedShapeData(
- prevShape.shapeType,
- prevShape.data,
- brushPosition,
- gridSize
- ),
- }));
- }
- }
- }
-
- function handleStop(event) {
- if (!isEditing) {
- return;
- }
- if (event.touches && event.touches.length !== 0) {
- return;
- }
- if (selectedTool === "brush" && drawingShape) {
- if (drawingShape.data.points.length > 1) {
- onShapeAdd(drawingShape);
- }
- } else if (selectedTool === "shape" && drawingShape) {
- onShapeAdd(drawingShape);
- }
-
- if (selectedTool === "erase" && hoveredShapeRef.current && isPointerDown) {
- onShapeRemove(hoveredShapeRef.current.id);
- }
- setIsPointerDown(false);
- setDrawingShape(null);
- }
-
- // Add listeners for draw events on map to allow drawing past the bounds
- // of the container
- useEffect(() => {
- const map = document.querySelector(".map");
- map.addEventListener("mousedown", handleStart);
- map.addEventListener("mousemove", handleMove);
- map.addEventListener("mouseup", handleStop);
- map.addEventListener("touchstart", handleStart);
- map.addEventListener("touchmove", handleMove);
- map.addEventListener("touchend", handleStop);
-
- return () => {
- map.removeEventListener("mousedown", handleStart);
- map.removeEventListener("mousemove", handleMove);
- map.removeEventListener("mouseup", handleStop);
- map.removeEventListener("touchstart", handleStart);
- map.removeEventListener("touchmove", handleMove);
- map.removeEventListener("touchend", handleStop);
- };
- });
-
- /**
- * Rendering
- */
- const hoveredShapeRef = useRef(null);
- useEffect(() => {
- const canvas = canvasRef.current;
- if (canvas) {
- const context = canvas.getContext("2d");
-
- context.clearRect(0, 0, width, height);
- let hoveredShape = null;
- for (let shape of shapes) {
- if (shouldHover) {
- if (isShapeHovered(shape, context, pointerPosition, width, height)) {
- hoveredShape = shape;
- }
+ const handleShapeDraw = useCallback(
+ (brushState, mapBrushPosition) => {
+ function startShape() {
+ const brushPosition = getBrushPositionForTool(
+ mapBrushPosition,
+ selectedToolId,
+ selectedToolSettings,
+ gridSize,
+ shapes
+ );
+ const commonShapeData = {
+ color: selectedToolSettings && selectedToolSettings.color,
+ blend: selectedToolSettings && selectedToolSettings.useBlending,
+ id: shortid.generate(),
+ };
+ if (selectedToolId === "brush") {
+ setDrawingShape({
+ type: "path",
+ pathType: selectedToolSettings.type,
+ data: { points: [brushPosition] },
+ strokeWidth: selectedToolSettings.type === "stroke" ? 1 : 0,
+ ...commonShapeData,
+ });
+ } else if (selectedToolId === "shape") {
+ setDrawingShape({
+ type: "shape",
+ shapeType: selectedToolSettings.type,
+ data: getDefaultShapeData(selectedToolSettings.type, brushPosition),
+ strokeWidth: 0,
+ ...commonShapeData,
+ });
}
- drawShape(shape, context, gridSize, width, height);
}
- if (drawingShape) {
- drawShape(drawingShape, context, gridSize, width, height);
+
+ function continueShape() {
+ const brushPosition = getBrushPositionForTool(
+ mapBrushPosition,
+ selectedToolId,
+ selectedToolSettings,
+ gridSize,
+ shapes
+ );
+ if (selectedToolId === "brush") {
+ setDrawingShape((prevShape) => {
+ const prevPoints = prevShape.data.points;
+ if (
+ comparePoints(
+ prevPoints[prevPoints.length - 1],
+ brushPosition,
+ 0.001
+ )
+ ) {
+ return prevShape;
+ }
+ const simplified = simplifyPoints(
+ [...prevPoints, brushPosition],
+ gridSize,
+ stageScale
+ );
+ return {
+ ...prevShape,
+ data: { points: simplified },
+ };
+ });
+ } else if (selectedToolId === "shape") {
+ setDrawingShape((prevShape) => ({
+ ...prevShape,
+ data: getUpdatedShapeData(
+ prevShape.shapeType,
+ prevShape.data,
+ brushPosition,
+ gridSize
+ ),
+ }));
+ }
}
- if (hoveredShape) {
- const shape = { ...hoveredShape, color: "#BB99FF", blend: true };
- drawShape(shape, context, gridSize, width, height);
+
+ function endShape() {
+ if (selectedToolId === "brush" && drawingShape) {
+ if (drawingShape.data.points.length > 1) {
+ onShapeAdd(drawingShape);
+ }
+ } else if (selectedToolId === "shape" && drawingShape) {
+ onShapeAdd(drawingShape);
+ }
+ setDrawingShape(null);
}
- hoveredShapeRef.current = hoveredShape;
+
+ switch (brushState) {
+ case "first":
+ startShape();
+ return;
+ case "drawing":
+ continueShape();
+ return;
+ case "last":
+ endShape();
+ return;
+ default:
+ return;
+ }
+ },
+ [
+ selectedToolId,
+ selectedToolSettings,
+ gridSize,
+ stageScale,
+ onShapeAdd,
+ shapes,
+ drawingShape,
+ ]
+ );
+
+ useMapBrush(isEditing, handleShapeDraw);
+
+ function handleShapeClick(_, shape) {
+ if (selectedToolId === "erase") {
+ onShapeRemove(shape.id);
}
- }, [
- shapes,
- width,
- height,
- pointerPosition,
- isPointerDown,
- selectedTool,
- drawingShape,
- gridSize,
- shouldHover,
- ]);
+ }
+
+ function handleShapeMouseOver(event, shape) {
+ if (shouldHover) {
+ const path = event.target;
+ const hoverColor = "#BB99FF";
+ path.fill(hoverColor);
+ if (shape.type === "path") {
+ path.stroke(hoverColor);
+ }
+ path.getLayer().draw();
+ }
+ }
+
+ function handleShapeMouseOut(event, shape) {
+ if (shouldHover) {
+ const path = event.target;
+ const color = colors[shape.color] || shape.color;
+ path.fill(color);
+ if (shape.type === "path") {
+ path.stroke(color);
+ }
+ path.getLayer().draw();
+ }
+ }
+
+ function renderShape(shape) {
+ const defaultProps = {
+ key: shape.id,
+ onMouseOver: (e) => handleShapeMouseOver(e, shape),
+ onMouseOut: (e) => handleShapeMouseOut(e, shape),
+ onClick: (e) => handleShapeClick(e, shape),
+ onTap: (e) => handleShapeClick(e, shape),
+ fill: colors[shape.color] || shape.color,
+ opacity: shape.blend ? 0.5 : 1,
+ };
+ if (shape.type === "path") {
+ return (
+ [...acc, point.x * mapWidth, point.y * mapHeight],
+ []
+ )}
+ stroke={colors[shape.color] || shape.color}
+ tension={0.5}
+ closed={shape.pathType === "fill"}
+ fillEnabled={shape.pathType === "fill"}
+ lineCap="round"
+ strokeWidth={getStrokeWidth(
+ shape.strokeWidth,
+ gridSize,
+ mapWidth,
+ mapHeight
+ )}
+ {...defaultProps}
+ />
+ );
+ } else if (shape.type === "shape") {
+ if (shape.shapeType === "rectangle") {
+ return (
+
+ );
+ } else if (shape.shapeType === "circle") {
+ const minSide = mapWidth < mapHeight ? mapWidth : mapHeight;
+ return (
+
+ );
+ } else if (shape.shapeType === "triangle") {
+ return (
+ [...acc, point.x * mapWidth, point.y * mapHeight],
+ []
+ )}
+ closed={true}
+ {...defaultProps}
+ />
+ );
+ }
+ }
+ }
return (
-
-
-
+
+ {shapes.map(renderShape)}
+ {drawingShape && renderShape(drawingShape)}
+
);
}
diff --git a/src/components/map/MapFog.js b/src/components/map/MapFog.js
index c0cbfba..fbb8782 100644
--- a/src/components/map/MapFog.js
+++ b/src/components/map/MapFog.js
@@ -1,275 +1,213 @@
-import React, { useRef, useEffect, useState, useContext } from "react";
+import React, { useContext, useState, useCallback } from "react";
import shortid from "shortid";
+import { Group, Line } from "react-konva";
+import useImage from "use-image";
+
+import diagonalPattern from "../../images/DiagonalPattern.png";
+
+import MapInteractionContext from "../../contexts/MapInteractionContext";
import { compare as comparePoints } from "../../helpers/vector2";
import {
getBrushPositionForTool,
- isShapeHovered,
- drawShape,
simplifyPoints,
- getRelativePointerPosition,
+ getStrokeWidth,
} from "../../helpers/drawing";
-import MapInteractionContext from "../../contexts/MapInteractionContext";
-
-import diagonalPattern from "../../images/DiagonalPattern.png";
+import colors from "../../helpers/colors";
+import useMapBrush from "../../helpers/useMapBrush";
function MapFog({
- width,
- height,
- isEditing,
- toolSettings,
shapes,
onShapeAdd,
onShapeRemove,
onShapeEdit,
+ selectedToolId,
+ selectedToolSettings,
gridSize,
}) {
- const canvasRef = useRef();
- const containerRef = useRef();
-
- const [isPointerDown, setIsPointerDown] = useState(false);
+ const { stageScale, mapWidth, mapHeight } = useContext(MapInteractionContext);
const [drawingShape, setDrawingShape] = useState(null);
- const [pointerPosition, setPointerPosition] = useState({ x: -1, y: -1 });
+ const isEditing = selectedToolId === "fog";
const shouldHover =
isEditing &&
- (toolSettings.type === "toggle" || toolSettings.type === "remove");
+ (selectedToolSettings.type === "toggle" ||
+ selectedToolSettings.type === "remove");
- const { scaleRef } = useContext(MapInteractionContext);
+ const [patternImage] = useImage(diagonalPattern);
- // Reset pointer position when tool changes
- useEffect(() => {
- setPointerPosition({ x: -1, y: -1 });
- }, [isEditing, toolSettings]);
-
- function handleStart(event) {
- if (!isEditing) {
- return;
- }
- if (event.touches && event.touches.length !== 1) {
- setIsPointerDown(false);
- setDrawingShape(null);
- return;
- }
- const pointer = event.touches ? event.touches[0] : event;
- const position = getRelativePointerPosition(pointer, containerRef.current);
- setPointerPosition(position);
- setIsPointerDown(true);
- const brushPosition = getBrushPositionForTool(
- position,
- "fog",
- toolSettings,
- gridSize,
- shapes
- );
- if (isEditing && toolSettings.type === "add") {
- setDrawingShape({
- type: "fog",
- data: { points: [brushPosition] },
- strokeWidth: 0.5,
- color: "black",
- blend: true, // Blend while drawing
- id: shortid.generate(),
- visible: true,
- });
- }
- }
-
- function handleMove(event) {
- if (!isEditing) {
- return;
- }
- if (event.touches && event.touches.length !== 1) {
- return;
- }
- const pointer = event.touches ? event.touches[0] : event;
- const position = getRelativePointerPosition(pointer, containerRef.current);
- // Set pointer position every frame for erase tool and fog
- if (shouldHover) {
- setPointerPosition(position);
- }
- if (isPointerDown) {
- setPointerPosition(position);
- const brushPosition = getBrushPositionForTool(
- position,
- "fog",
- toolSettings,
- gridSize,
- shapes
- );
- if (isEditing && toolSettings.type === "add" && drawingShape) {
- setDrawingShape((prevShape) => {
- const prevPoints = prevShape.data.points;
- if (
- comparePoints(
- prevPoints[prevPoints.length - 1],
- brushPosition,
- 0.001
- )
- ) {
- return prevShape;
- }
- return {
- ...prevShape,
- data: { points: [...prevPoints, brushPosition] },
- };
- });
- }
- }
- }
-
- function handleStop(event) {
- if (!isEditing) {
- return;
- }
- if (event.touches && event.touches.length !== 0) {
- return;
- }
- if (isEditing && toolSettings.type === "add" && drawingShape) {
- if (drawingShape.data.points.length > 1) {
- const shape = {
- ...drawingShape,
- data: {
- points: simplifyPoints(
- drawingShape.data.points,
- gridSize,
- // Downscale fog as smoothing doesn't currently work with edge snapping
- scaleRef.current / 2
- ),
- },
- blend: false,
- };
- onShapeAdd(shape);
- }
- }
-
- if (hoveredShapeRef.current && isPointerDown) {
- if (toolSettings.type === "remove") {
- onShapeRemove(hoveredShapeRef.current.id);
- } else if (toolSettings.type === "toggle") {
- onShapeEdit({
- ...hoveredShapeRef.current,
- visible: !hoveredShapeRef.current.visible,
- });
- }
- }
- setDrawingShape(null);
- setIsPointerDown(false);
- }
-
- // Add listeners for draw events on map to allow drawing past the bounds
- // of the container
- useEffect(() => {
- const map = document.querySelector(".map");
- map.addEventListener("mousedown", handleStart);
- map.addEventListener("mousemove", handleMove);
- map.addEventListener("mouseup", handleStop);
- map.addEventListener("touchstart", handleStart);
- map.addEventListener("touchmove", handleMove);
- map.addEventListener("touchend", handleStop);
-
- return () => {
- map.removeEventListener("mousedown", handleStart);
- map.removeEventListener("mousemove", handleMove);
- map.removeEventListener("mouseup", handleStop);
- map.removeEventListener("touchstart", handleStart);
- map.removeEventListener("touchmove", handleMove);
- map.removeEventListener("touchend", handleStop);
- };
- });
-
- /**
- * Rendering
- */
- const hoveredShapeRef = useRef(null);
- const diagonalPatternRef = useRef();
-
- useEffect(() => {
- let image = new Image();
- image.src = diagonalPattern;
- diagonalPatternRef.current = image;
- }, []);
-
- useEffect(() => {
- const canvas = canvasRef.current;
- if (canvas) {
- const context = canvas.getContext("2d");
-
- context.clearRect(0, 0, width, height);
- let hoveredShape = null;
- if (isEditing) {
- const editPattern = context.createPattern(
- diagonalPatternRef.current,
- "repeat"
+ const handleShapeDraw = useCallback(
+ (brushState, mapBrushPosition) => {
+ function startShape() {
+ const brushPosition = getBrushPositionForTool(
+ mapBrushPosition,
+ selectedToolId,
+ selectedToolSettings,
+ gridSize,
+ shapes
);
- for (let shape of shapes) {
- if (shouldHover) {
- if (
- isShapeHovered(shape, context, pointerPosition, width, height)
- ) {
- hoveredShape = shape;
- }
- }
- drawShape(
- {
- ...shape,
- blend: true,
- color: shape.visible ? "black" : editPattern,
- },
- context,
- gridSize,
- width,
- height
- );
- }
- if (drawingShape) {
- drawShape(drawingShape, context, gridSize, width, height);
- }
- if (hoveredShape) {
- const shape = { ...hoveredShape, color: "#BB99FF", blend: true };
- drawShape(shape, context, gridSize, width, height);
- }
- } else {
- // Not editing
- for (let shape of shapes) {
- if (shape.visible) {
- drawShape(shape, context, gridSize, width, height);
- }
+ if (selectedToolSettings.type === "add") {
+ setDrawingShape({
+ type: "fog",
+ data: { points: [brushPosition] },
+ strokeWidth: 0.5,
+ color: "black",
+ blend: false,
+ id: shortid.generate(),
+ visible: true,
+ });
}
}
- hoveredShapeRef.current = hoveredShape;
+
+ function continueShape() {
+ const brushPosition = getBrushPositionForTool(
+ mapBrushPosition,
+ selectedToolId,
+ selectedToolSettings,
+ gridSize,
+ shapes
+ );
+ if (selectedToolSettings.type === "add") {
+ setDrawingShape((prevShape) => {
+ const prevPoints = prevShape.data.points;
+ if (
+ comparePoints(
+ prevPoints[prevPoints.length - 1],
+ brushPosition,
+ 0.001
+ )
+ ) {
+ return prevShape;
+ }
+ return {
+ ...prevShape,
+ data: { points: [...prevPoints, brushPosition] },
+ };
+ });
+ }
+ }
+
+ function endShape() {
+ if (selectedToolSettings.type === "add" && drawingShape) {
+ if (drawingShape.data.points.length > 1) {
+ const shape = {
+ ...drawingShape,
+ data: {
+ points: simplifyPoints(
+ drawingShape.data.points,
+ gridSize,
+ // Downscale fog as smoothing doesn't currently work with edge snapping
+ stageScale / 2
+ ),
+ },
+ };
+ onShapeAdd(shape);
+ }
+ }
+ setDrawingShape(null);
+ }
+
+ switch (brushState) {
+ case "first":
+ startShape();
+ return;
+ case "drawing":
+ continueShape();
+ return;
+ case "last":
+ endShape();
+ return;
+ default:
+ return;
+ }
+ },
+ [
+ selectedToolId,
+ selectedToolSettings,
+ gridSize,
+ stageScale,
+ onShapeAdd,
+ shapes,
+ drawingShape,
+ ]
+ );
+
+ useMapBrush(isEditing, handleShapeDraw);
+
+ function handleShapeClick(_, shape) {
+ if (!isEditing) {
+ return;
}
- }, [
- shapes,
- width,
- height,
- pointerPosition,
- isEditing,
- drawingShape,
- gridSize,
- shouldHover,
- ]);
+
+ if (selectedToolSettings.type === "remove") {
+ onShapeRemove(shape.id);
+ } else if (selectedToolSettings.type === "toggle") {
+ onShapeEdit({ ...shape, visible: !shape.visible });
+ }
+ }
+
+ function handleShapeMouseOver(event, shape) {
+ if (shouldHover) {
+ const path = event.target;
+ if (shape.visible) {
+ const hoverColor = "#BB99FF";
+ path.fill(hoverColor);
+ } else {
+ path.opacity(1);
+ }
+ path.getLayer().draw();
+ }
+ }
+
+ function handleShapeMouseOut(event, shape) {
+ if (shouldHover) {
+ const path = event.target;
+ if (shape.visible) {
+ const color = colors[shape.color] || shape.color;
+ path.fill(color);
+ } else {
+ path.opacity(0.5);
+ }
+ path.getLayer().draw();
+ }
+ }
+
+ function renderShape(shape) {
+ return (
+ handleShapeMouseOver(e, shape)}
+ onMouseOut={(e) => handleShapeMouseOut(e, shape)}
+ onClick={(e) => handleShapeClick(e, shape)}
+ onTap={(e) => handleShapeClick(e, shape)}
+ points={shape.data.points.reduce(
+ (acc, point) => [...acc, point.x * mapWidth, point.y * mapHeight],
+ []
+ )}
+ stroke={colors[shape.color] || shape.color}
+ fill={colors[shape.color] || shape.color}
+ closed
+ lineCap="round"
+ strokeWidth={getStrokeWidth(
+ shape.strokeWidth,
+ gridSize,
+ mapWidth,
+ mapHeight
+ )}
+ visible={isEditing || shape.visible}
+ opacity={isEditing ? 0.5 : 1}
+ fillPatternImage={patternImage}
+ fillPriority={isEditing && !shape.visible ? "pattern" : "color"}
+ />
+ );
+ }
return (
-
-
-
+
+ {shapes.map(renderShape)}
+ {drawingShape && renderShape(drawingShape)}
+
);
}
diff --git a/src/components/map/MapInteraction.js b/src/components/map/MapInteraction.js
index 5bac21a..0348c16 100644
--- a/src/components/map/MapInteraction.js
+++ b/src/components/map/MapInteraction.js
@@ -1,162 +1,236 @@
-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 MapStageContext from "../../contexts/MapStageContext";
+import AuthContext from "../../contexts/AuthContext";
-const zoomSpeed = -0.001;
+const wheelZoomSpeed = 0.001;
+const touchZoomSpeed = 0.005;
const minZoom = 0.1;
const maxZoom = 5;
-function MapInteraction({
- map,
- aspectRatio,
- isEnabled,
- children,
- sideContent,
-}) {
- const mapContainerRef = useRef();
- const mapMoveContainerRef = useRef();
- const mapTranslateRef = useRef({ x: 0, y: 0 });
- const mapScaleRef = useRef(1);
- function setTranslateAndScale(newTranslate, newScale) {
- const moveContainer = mapMoveContainerRef.current;
- moveContainer.style.transform = `translate(${newTranslate.x}px, ${newTranslate.y}px) scale(${newScale})`;
- mapScaleRef.current = newScale;
- mapTranslateRef.current = newTranslate;
- }
+function MapInteraction({ map, children, controls, selectedToolId }) {
+ const mapSource = useDataSource(map, defaultMapSources);
+ const [mapSourceImage] = useImage(mapSource);
+ const [stageWidth, setStageWidth] = useState(1);
+ const [stageHeight, setStageHeight] = useState(1);
+ const [stageScale, setStageScale] = useState(1);
+ // "none" | "first" | "dragging" | "last"
+ const [stageDragState, setStageDragState] = useState("none");
+ 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 });
+ const mapDragPositionRef = useRef({ x: 0, y: 0 });
+
+ // Reset transform when map changes
useEffect(() => {
- function handleMove(event, isGesture) {
- const scale = mapScaleRef.current;
- const translate = mapTranslateRef.current;
+ const layer = mapLayerRef.current;
+ if (map && layer) {
+ 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;
- 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,
- y: translate.y + event.dy,
- };
- }
- setTranslateAndScale(newTranslate, newScale);
+ setStageScale(1);
}
- 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;
+ // Convert a client space XY to be normalized to the map image
+ function getMapDragPosition(xy) {
+ const [x, y] = xy;
+ const container = containerRef.current;
+ const mapImage = mapImageRef.current;
+ if (container && mapImage) {
+ const containerRect = container.getBoundingClientRect();
+ const mapRect = mapImage.getClientRect();
- function handleZoom(event) {
- // Stop overscroll on chrome and safari
- // also stop pinch to zoom on chrome
- event.preventDefault();
+ const offsetX = x - containerRect.left - mapRect.x;
+ const offsetY = y - containerRect.top - mapRect.y;
- // Try and normalize the wheel event to prevent OS differences for zoom speed
- const normalized = normalizeWheel(event);
+ const normalizedX = offsetX / mapRect.width;
+ const normalizedY = offsetY / mapRect.height;
- 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);
+ return { x: normalizedX, y: normalizedY };
}
+ }
- if (mapContainer) {
- mapContainer.addEventListener("wheel", handleZoom, {
- passive: false,
- });
- }
+ const pinchPreviousDistanceRef = useRef();
+ const pinchPreviousOriginRef = useRef();
- return () => {
- if (mapContainer) {
- mapContainer.removeEventListener("wheel", handleZoom);
+ const bind = useGesture({
+ onWheel: ({ delta }) => {
+ const newScale = Math.min(
+ Math.max(stageScale + delta[1] * wheelZoomSpeed, minZoom),
+ maxZoom
+ );
+ setStageScale(newScale);
+ },
+ onPinch: ({ da, origin, first }) => {
+ const [distance] = da;
+ const [originX, originY] = origin;
+ if (first) {
+ pinchPreviousDistanceRef.current = distance;
+ pinchPreviousOriginRef.current = { x: originX, y: originY };
}
- };
- }, []);
+
+ // 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, minZoom),
+ maxZoom
+ );
+ setStageScale(newScale);
+
+ // Apply translate
+ const stageTranslate = stageTranslateRef.current;
+ const layer = mapLayerRef.current;
+ const newTranslate = {
+ x: stageTranslate.x + originXDelta / newScale,
+ y: stageTranslate.y + originYDelta / newScale,
+ };
+ layer.x(newTranslate.x);
+ layer.y(newTranslate.y);
+ layer.draw();
+ stageTranslateRef.current = newTranslate;
+
+ pinchPreviousDistanceRef.current = distance;
+ pinchPreviousOriginRef.current = { x: originX, y: originY };
+ },
+ onDrag: ({ delta, xy, first, last, pinching }) => {
+ if (preventMapInteraction || pinching) {
+ return;
+ }
+
+ const [dx, dy] = delta;
+ const stageTranslate = stageTranslateRef.current;
+ const layer = mapLayerRef.current;
+ if (selectedToolId === "pan") {
+ const newTranslate = {
+ x: stageTranslate.x + dx / stageScale,
+ y: stageTranslate.y + dy / stageScale,
+ };
+ layer.x(newTranslate.x);
+ layer.y(newTranslate.y);
+ layer.draw();
+ stageTranslateRef.current = newTranslate;
+ }
+ mapDragPositionRef.current = getMapDragPosition(xy);
+ const newDragState = first ? "first" : last ? "last" : "dragging";
+ if (stageDragState !== newDragState) {
+ setStageDragState(newDragState);
+ }
+ },
+ onDragEnd: () => {
+ setStageDragState("none");
+ },
+ });
+
+ function handleResize(width, height) {
+ setStageWidth(width);
+ setStageHeight(height);
+ stageWidthRef.current = width;
+ stageHeightRef.current = height;
+ }
+
+ function getCursorForTool(tool) {
+ switch (tool) {
+ case "pan":
+ return "move";
+ case "fog":
+ case "brush":
+ case "shape":
+ return "crosshair";
+ default:
+ return "default";
+ }
+ }
+
+ const containerRef = useRef();
+ usePreventOverscroll(containerRef);
+
+ const mapWidth = stageWidth;
+ const mapHeight = map ? stageWidth * (map.height / map.width) : stageHeight;
+
+ const mapStageRef = useContext(MapStageContext);
+ const mapLayerRef = useRef();
+ const mapImageRef = useRef();
+
+ const auth = useContext(AuthContext);
+
+ const mapInteraction = {
+ stageScale,
+ stageWidth,
+ stageHeight,
+ stageDragState,
+ setPreventMapInteraction,
+ mapWidth,
+ mapHeight,
+ mapDragPositionRef,
+ };
return (
-
-
-
-
- {children}
-
-
-
- {sideContent}
+
+
+
+
+ {/* Forward auth context to konva elements */}
+
+
+ {children}
+
+
+
+
+
+
+ {controls}
+
);
}
diff --git a/src/components/map/MapMenu.js b/src/components/map/MapMenu.js
index 6ddfd75..f710585 100644
--- a/src/components/map/MapMenu.js
+++ b/src/components/map/MapMenu.js
@@ -1,8 +1,11 @@
-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";
+
+import usePrevious from "../../helpers/usePrevious";
+
function MapMenu({
isOpen,
onRequestClose,
@@ -21,6 +24,17 @@ function MapMenu({
// callback
const [modalContentNode, setModalContentNode] = useState(null);
+ // Toggle map interaction when menu is opened
+ const wasOpen = usePrevious(isOpen);
+ const { setPreventMapInteraction } = useContext(MapInteractionContext);
+ useEffect(() => {
+ if (isOpen && !wasOpen) {
+ setPreventMapInteraction(true);
+ } else if (wasOpen && !isOpen) {
+ setPreventMapInteraction(false);
+ }
+ }, [isOpen, setPreventMapInteraction, wasOpen]);
+
useEffect(() => {
// Close modal if interacting with any other element
function handlePointerDown(event) {
@@ -45,6 +59,7 @@ function MapMenu({
{ once: true }
);
}
+
return () => {
if (modalContentNode) {
document.body.removeEventListener("pointerdown", handlePointerDown);
diff --git a/src/components/map/MapSettings.js b/src/components/map/MapSettings.js
index 230d327..dcefccb 100644
--- a/src/components/map/MapSettings.js
+++ b/src/components/map/MapSettings.js
@@ -34,7 +34,7 @@ function MapSettings({
onChange={(e) =>
onSettingsChange("gridX", parseInt(e.target.value))
}
- disabled={map === null || map.type === "default"}
+ disabled={!map || map.type === "default"}
min={1}
my={1}
/>
@@ -48,7 +48,7 @@ function MapSettings({
onChange={(e) =>
onSettingsChange("gridY", parseInt(e.target.value))
}
- disabled={map === null || map.type === "default"}
+ disabled={!map || map.type === "default"}
min={1}
my={1}
/>
@@ -61,19 +61,15 @@ function MapSettings({
diff --git a/src/components/map/MapToken.js b/src/components/map/MapToken.js
index 6d8309a..9f67bba 100644
--- a/src/components/map/MapToken.js
+++ b/src/components/map/MapToken.js
@@ -1,69 +1,230 @@
-import React, { useRef } from "react";
-import { Box, Image } from "theme-ui";
+import React, { useContext, useState, useEffect, useRef } from "react";
+import { Image as KonvaImage, Group } from "react-konva";
+import { useSpring, animated } from "react-spring/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 usePrevious from "../../helpers/usePrevious";
-import { tokenSources } from "../../tokens";
+import AuthContext from "../../contexts/AuthContext";
+import MapInteractionContext from "../../contexts/MapInteractionContext";
-function MapToken({ token, tokenState, tokenSizePercent, className }) {
- const imageSource = useDataSource(token, tokenSources);
+import TokenStatus from "../token/TokenStatus";
+import TokenLabel from "../token/TokenLabel";
+import { tokenSources, unknownSource } from "../../tokens";
+
+function MapToken({
+ token,
+ tokenState,
+ tokenSizePercent,
+ onTokenStateChange,
+ onTokenMenuOpen,
+ onTokenDragStart,
+ onTokenDragEnd,
+ draggable,
+ mapState,
+}) {
+ const { userId } = useContext(AuthContext);
+ 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 handleDragStart(event) {
+ const tokenImage = event.target;
+ const tokenImageRect = tokenImage.getClientRect();
+
+ if (token.isVehicle) {
+ // Find all other tokens on the map
+ const layer = tokenImage.getLayer();
+ const tokens = layer.find(".token");
+ for (let other of tokens) {
+ if (other === tokenImage) {
+ continue;
+ }
+ const otherRect = other.getClientRect();
+ const otherCenter = {
+ x: otherRect.x + otherRect.width / 2,
+ y: otherRect.y + otherRect.height / 2,
+ };
+ // Check the other tokens center overlaps this tokens bounding box
+ if (
+ otherCenter.x > tokenImageRect.x &&
+ otherCenter.x < tokenImageRect.x + tokenImageRect.width &&
+ otherCenter.y > tokenImageRect.y &&
+ otherCenter.y < tokenImageRect.y + tokenImageRect.height
+ ) {
+ // Save and restore token position after moving layer
+ const position = other.absolutePosition();
+ other.moveTo(tokenImage);
+ other.absolutePosition(position);
+ }
+ }
+ }
+
+ onTokenDragStart(event);
+ }
+
+ function handleDragEnd(event) {
+ const tokenImage = event.target;
+
+ const mountChanges = {};
+ if (token.isVehicle) {
+ const layer = tokenImage.getLayer();
+ const mountedTokens = tokenImage.find(".token");
+ for (let mountedToken of mountedTokens) {
+ // Save and restore token position after moving layer
+ const position = mountedToken.absolutePosition();
+ mountedToken.moveTo(layer);
+ mountedToken.absolutePosition(position);
+ mountChanges[mountedToken.id()] = {
+ ...mapState.tokens[mountedToken.id()],
+ x: mountedToken.x() / mapWidth,
+ y: mountedToken.y() / mapHeight,
+ lastEditedBy: userId,
+ };
+ }
+ }
+
+ setPreventMapInteraction(false);
+ onTokenStateChange({
+ ...mountChanges,
+ [tokenState.id]: {
+ ...tokenState,
+ x: tokenImage.x() / mapWidth,
+ y: tokenImage.y() / mapHeight,
+ lastEditedBy: userId,
+ },
+ });
+ onTokenDragEnd(event);
+ }
+
+ function handleClick(event) {
+ if (draggable) {
+ const tokenImage = event.target;
+ onTokenMenuOpen(tokenState.id, tokenImage);
+ }
+ }
+
+ const [tokenOpacity, setTokenOpacity] = useState(1);
+ function handlePointerDown() {
+ if (draggable) {
+ setPreventMapInteraction(true);
+ }
+ }
+
+ function handlePointerUp() {
+ if (draggable) {
+ setPreventMapInteraction(false);
+ }
+ }
+
+ function handlePointerOver() {
+ if (!draggable) {
+ setTokenOpacity(0.5);
+ }
+ }
+
+ function handlePointerOut() {
+ if (!draggable) {
+ setTokenOpacity(1.0);
+ }
+ }
+
+ 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 &&
+ tokenSourceStatus === "loaded" &&
+ tokenWidth > 0 &&
+ tokenHeight > 0
+ ) {
+ image.cache({
+ pixelRatio: debouncedStageScale * window.devicePixelRatio,
+ });
+ image.drawHitFromCache();
+ // Force redraw
+ image.getLayer().draw();
+ }
+ }, [debouncedStageScale, tokenWidth, tokenHeight, tokenSourceStatus]);
+
+ // Animate to new token positions if edited by others
+ const tokenX = tokenState.x * mapWidth;
+ const tokenY = tokenState.y * mapHeight;
+ const previousWidth = usePrevious(mapWidth);
+ const previousHeight = usePrevious(mapHeight);
+ const resized = mapWidth !== previousWidth || mapHeight !== previousHeight;
+ const skipAnimation = tokenState.lastEditedBy === userId || resized;
+ const props = useSpring({
+ x: tokenX,
+ y: tokenY,
+ immediate: skipAnimation,
+ });
return (
-
-
-
-
- {tokenState.statuses && (
-
- )}
- {tokenState.label && }
-
-
-
+
+
+
+
+
+
);
}
diff --git a/src/components/map/SelectMapButton.js b/src/components/map/SelectMapButton.js
index 2e9dc87..f397a89 100644
--- a/src/components/map/SelectMapButton.js
+++ b/src/components/map/SelectMapButton.js
@@ -1,16 +1,30 @@
-import React, { useState } from "react";
+import React, { useState, useContext } from "react";
import { IconButton } from "theme-ui";
import SelectMapModal from "../../modals/SelectMapModal";
import SelectMapIcon from "../../icons/SelectMapIcon";
-function SelectMapButton({ onMapChange, onMapStateChange, currentMap }) {
- const [isAddModalOpen, setIsAddModalOpen] = useState(false);
+import MapDataContext from "../../contexts/MapDataContext";
+import MapInteractionContext from "../../contexts/MapInteractionContext";
+
+function SelectMapButton({
+ onMapChange,
+ onMapStateChange,
+ currentMap,
+ currentMapState,
+}) {
+ const [isModalOpen, setIsModalOpen] = useState(false);
+
+ const { setPreventMapInteraction } = useContext(MapInteractionContext);
+ const { updateMapState } = useContext(MapDataContext);
function openModal() {
- setIsAddModalOpen(true);
+ currentMapState && updateMapState(currentMapState.mapId, currentMapState);
+ setIsModalOpen(true);
+ setPreventMapInteraction(true);
}
function closeModal() {
- setIsAddModalOpen(false);
+ setIsModalOpen(false);
+ setPreventMapInteraction(false);
}
function handleDone() {
@@ -27,7 +41,7 @@ function SelectMapButton({ onMapChange, onMapStateChange, currentMap }) {
diff --git a/src/components/token/ProxyToken.js b/src/components/token/ProxyToken.js
index 0ed984d..d9419bf 100644
--- a/src/components/token/ProxyToken.js
+++ b/src/components/token/ProxyToken.js
@@ -1,12 +1,11 @@
-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";
import usePortal from "../../helpers/usePortal";
-import TokenLabel from "./TokenLabel";
-import TokenStatus from "./TokenStatus";
+import MapStageContext from "../../contexts/MapStageContext";
/**
* @callback onProxyDragEnd
@@ -19,46 +18,33 @@ import TokenStatus from "./TokenStatus";
* @param {string} tokenClassName The class name to attach the interactjs handler to
* @param {onProxyDragEnd} onProxyDragEnd Called when the proxy token is dropped
* @param {Object} tokens An optional mapping of tokens to use as a base when calling OnProxyDragEnd
- * @param {Object} disabledTokens An optional mapping of tokens that shouldn't allow movement
*/
-function ProxyToken({
- tokenClassName,
- onProxyDragEnd,
- tokens,
- disabledTokens,
-}) {
+function ProxyToken({ tokenClassName, onProxyDragEnd, tokens }) {
const proxyContainer = usePortal("root");
const [imageSource, setImageSource] = useState("");
- const [tokenId, setTokenId] = useState(null);
const proxyRef = useRef();
// 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]);
+ }, [tokens]);
const proxyOnMap = useRef(false);
+ const mapStageRef = useContext(MapStageContext);
useEffect(() => {
interact(`.${tokenClassName}`).draggable({
listeners: {
start: (event) => {
let target = event.target;
- const id = target.dataset.id;
- if (id in disabledTokensRef.current) {
- return;
- }
// Hide the token and copy it's image to the proxy
target.parentElement.style.opacity = "0.25";
setImageSource(target.src);
- setTokenId(id);
let proxy = proxyRef.current;
if (proxy) {
@@ -105,23 +91,29 @@ function ProxyToken({
end: (event) => {
let target = event.target;
const id = target.dataset.id;
- if (id in disabledTokensRef.current) {
- return;
- }
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 +137,7 @@ function ProxyToken({
},
},
});
- }, [onProxyDragEnd, tokenClassName, proxyContainer]);
+ }, [onProxyDragEnd, tokenClassName, proxyContainer, mapStageRef]);
if (!imageSource) {
return null;
@@ -175,12 +167,6 @@ function ProxyToken({
width: "100%",
}}
/>
- {tokens[tokenId] && tokens[tokenId].statuses && (
-
- )}
- {tokens[tokenId] && tokens[tokenId].label && (
-
- )}
,
proxyContainer
@@ -189,7 +175,6 @@ function ProxyToken({
ProxyToken.defaultProps = {
tokens: {},
- disabledTokens: {},
};
export default ProxyToken;
diff --git a/src/components/token/SelectTokensButton.js b/src/components/token/SelectTokensButton.js
new file mode 100644
index 0000000..2792b02
--- /dev/null
+++ b/src/components/token/SelectTokensButton.js
@@ -0,0 +1,38 @@
+import React, { useState } from "react";
+import { IconButton } from "theme-ui";
+
+import SelectTokensIcon from "../../icons/SelectTokensIcon";
+
+import SelectTokensModal from "../../modals/SelectTokensModal";
+
+function SelectTokensButton() {
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ function openModal() {
+ setIsModalOpen(true);
+ }
+ function closeModal() {
+ setIsModalOpen(false);
+ }
+
+ function handleDone() {
+ closeModal();
+ }
+ return (
+ <>
+
+
+
+
+ >
+ );
+}
+
+export default SelectTokensButton;
diff --git a/src/components/token/TokenDragOverlay.js b/src/components/token/TokenDragOverlay.js
new file mode 100644
index 0000000..70097bc
--- /dev/null
+++ b/src/components/token/TokenDragOverlay.js
@@ -0,0 +1,133 @@
+import React, { useContext, useEffect, useRef, useState } from "react";
+import { Box, IconButton } from "theme-ui";
+
+import RemoveTokenIcon from "../../icons/RemoveTokenIcon";
+
+import AuthContext from "../../contexts/AuthContext";
+import MapInteractionContext from "../../contexts/MapInteractionContext";
+
+function TokenDragOverlay({
+ onTokenStateRemove,
+ onTokenStateChange,
+ token,
+ tokenState,
+ tokenImage,
+ mapState,
+}) {
+ const { userId } = useContext(AuthContext);
+ const { setPreventMapInteraction, mapWidth, mapHeight } = useContext(
+ MapInteractionContext
+ );
+
+ const [isRemoveHovered, setIsRemoveHovered] = useState(false);
+ const removeTokenRef = useRef();
+
+ // Detect token hover on remove icon manually to support touch devices
+ useEffect(() => {
+ const map = document.querySelector(".map");
+ const mapRect = map.getBoundingClientRect();
+
+ function detectRemoveHover() {
+ const pointerPosition = tokenImage.getStage().getPointerPosition();
+ const screenSpacePointerPosition = {
+ x: pointerPosition.x + mapRect.left,
+ y: pointerPosition.y + mapRect.top,
+ };
+ const removeIconPosition = removeTokenRef.current.getBoundingClientRect();
+
+ if (
+ screenSpacePointerPosition.x > removeIconPosition.left &&
+ screenSpacePointerPosition.y > removeIconPosition.top &&
+ screenSpacePointerPosition.x < removeIconPosition.right &&
+ screenSpacePointerPosition.y < removeIconPosition.bottom
+ ) {
+ if (!isRemoveHovered) {
+ setIsRemoveHovered(true);
+ }
+ } else if (isRemoveHovered) {
+ setIsRemoveHovered(false);
+ }
+ }
+
+ let handler;
+ if (tokenState && tokenImage) {
+ handler = setInterval(detectRemoveHover, 100);
+ }
+
+ return () => {
+ if (handler) {
+ clearInterval(handler);
+ }
+ };
+ }, [tokenState, tokenImage, isRemoveHovered]);
+
+ // Detect drag end of token image and remove it if it is over the remove icon
+ useEffect(() => {
+ function handleTokenDragEnd() {
+ if (isRemoveHovered) {
+ // Handle other tokens when a vehicle gets deleted
+ if (token.isVehicle) {
+ const layer = tokenImage.getLayer();
+ const mountedTokens = tokenImage.find(".token");
+ for (let mountedToken of mountedTokens) {
+ // Save and restore token position after moving layer
+ const position = mountedToken.absolutePosition();
+ mountedToken.moveTo(layer);
+ mountedToken.absolutePosition(position);
+ onTokenStateChange({
+ [mountedToken.id()]: {
+ ...mapState.tokens[mountedToken.id()],
+ x: mountedToken.x() / mapWidth,
+ y: mountedToken.y() / mapHeight,
+ lastEditedBy: userId,
+ },
+ });
+ }
+ }
+ onTokenStateRemove(tokenState);
+ setPreventMapInteraction(false);
+ }
+ }
+ tokenImage.on("dragend", handleTokenDragEnd);
+ return () => {
+ tokenImage.off("dragend", handleTokenDragEnd);
+ };
+ }, [
+ tokenImage,
+ token,
+ tokenState,
+ isRemoveHovered,
+ mapWidth,
+ mapHeight,
+ userId,
+ onTokenStateChange,
+ onTokenStateRemove,
+ setPreventMapInteraction,
+ mapState.tokens,
+ ]);
+
+ return (
+
+
+
+
+
+ );
+}
+
+export default TokenDragOverlay;
diff --git a/src/components/token/TokenLabel.js b/src/components/token/TokenLabel.js
index b5d90fb..8d0e5f0 100644
--- a/src/components/token/TokenLabel.js
+++ b/src/components/token/TokenLabel.js
@@ -1,51 +1,49 @@
-import React from "react";
-import { Image, Box, Text } from "theme-ui";
+import React, { useRef, useEffect, useState } from "react";
+import { Rect, Text, Group } from "react-konva";
-import tokenLabel from "../../images/TokenLabel.png";
+function TokenLabel({ tokenState, width, height }) {
+ const fontSize = height / 6 / tokenState.size;
+ const paddingY = height / 16 / tokenState.size;
+ const paddingX = height / 8 / tokenState.size;
+
+ const [rectWidth, setRectWidth] = useState(0);
+ useEffect(() => {
+ const text = textRef.current;
+ if (text && tokenState.label) {
+ setRectWidth(text.getTextWidth() + paddingX);
+ } else {
+ setRectWidth(0);
+ }
+ }, [tokenState.label, paddingX]);
+
+ const textRef = useRef();
-function TokenLabel({ label }) {
return (
-
-
-
-
+
+
+
+
);
}
diff --git a/src/components/token/TokenMenu.js b/src/components/token/TokenMenu.js
index d64063a..74ac4e2 100644
--- a/src/components/token/TokenMenu.js
+++ b/src/components/token/TokenMenu.js
@@ -1,119 +1,78 @@
-import React, { useEffect, useState, useRef } from "react";
-import interact from "interactjs";
-import { Box, Input } from "theme-ui";
+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";
-/**
- * @callback onTokenChange
- * @param {Object} token the token that was changed
- */
+import usePrevious from "../../helpers/usePrevious";
-/**
- *
- * @param {string} tokenClassName The class name to attach the interactjs handler to
- * @param {onProxyDragEnd} onTokenChange Called when the the token data is changed
- * @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);
+const defaultTokenMaxSize = 6;
+function TokenMenu({
+ isOpen,
+ onRequestClose,
+ tokenState,
+ tokenImage,
+ onTokenStateChange,
+}) {
+ 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 [tokenMaxSize, setTokenMaxSize] = useState(defaultTokenMaxSize);
const [menuLeft, setMenuLeft] = useState(0);
const [menuTop, setMenuTop] = useState(0);
+ useEffect(() => {
+ if (isOpen && !wasOpen && tokenState) {
+ setTokenMaxSize(Math.max(tokenState.size, defaultTokenMaxSize));
+ // Update menu position
+ 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);
+ }
+ }
+ }, [isOpen, tokenState, wasOpen, tokenImage]);
function handleLabelChange(event) {
- // Slice to remove Label: text
- const label = event.target.value.slice(7);
- if (label.length <= 1) {
- setCurrentToken((prevToken) => ({
- ...prevToken,
- label: label,
- }));
-
- onTokenChange({ ...currentToken, label: label });
- }
+ const label = event.target.value;
+ onTokenStateChange({ [tokenState.id]: { ...tokenState, label: label } });
}
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 });
+ onTokenStateChange({
+ [tokenState.id]: { ...tokenState, statuses: newStatuses },
+ });
}
- 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);
+ function handleSizeChange(event) {
+ const newSize = parseInt(event.target.value);
+ onTokenStateChange({ [tokenState.id]: { ...tokenState, size: newSize } });
+ }
- 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 handleRotationChange(event) {
+ const newRotation = parseInt(event.target.value);
+ onTokenStateChange({
+ [tokenState.id]: { ...tokenState, rotation: newRotation },
+ });
+ }
function handleModalContent(node) {
if (node) {
// Focus input
const tokenLabelInput = node.querySelector("#changeTokenLabel");
tokenLabelInput.focus();
- tokenLabelInput.setSelectionRange(7, 8);
+ tokenLabelInput.select();
// Ensure menu is in bounds
const nodeRect = node.getBoundingClientRect();
@@ -134,23 +93,32 @@ function TokenMenu({ tokenClassName, onTokenChange, tokens, disabledTokens }) {
return (
-
-
+ {
e.preventDefault();
- handleRequestClose();
+ onRequestClose();
}}
+ sx={{ alignItems: "center" }}
>
+
+ Label:
+
-
+
handleStatusChange(color)}
aria-label={`Token label Color ${color}`}
>
- {currentToken.statuses && currentToken.statuses.includes(color) && (
-
- )}
+ {tokenState &&
+ tokenState.statuses &&
+ tokenState.statuses.includes(color) && (
+
+ )}
))}
+
+
+ Size:
+
+
+
+
+
+ Rotation:
+
+
+
);
diff --git a/src/components/token/TokenSettings.js b/src/components/token/TokenSettings.js
new file mode 100644
index 0000000..fbbd2ca
--- /dev/null
+++ b/src/components/token/TokenSettings.js
@@ -0,0 +1,76 @@
+import React from "react";
+import { Flex, Box, Input, IconButton, Label, Checkbox } from "theme-ui";
+
+import ExpandMoreIcon from "../../icons/ExpandMoreIcon";
+
+function TokenSettings({
+ token,
+ onSettingsChange,
+ showMore,
+ onShowMoreChange,
+}) {
+ return (
+
+
+
+
+
+ onSettingsChange("defaultSize", parseInt(e.target.value))
+ }
+ disabled={!token || token.type === "default"}
+ min={1}
+ my={1}
+ />
+
+
+ {showMore && (
+ <>
+
+
+ onSettingsChange("name", e.target.value)}
+ disabled={!token || token.type === "default"}
+ my={1}
+ />
+
+
+
+
+ >
+ )}
+ {
+ e.stopPropagation();
+ e.preventDefault();
+ onShowMoreChange(!showMore);
+ }}
+ sx={{
+ transform: `rotate(${showMore ? "180deg" : "0"})`,
+ alignSelf: "center",
+ }}
+ aria-label={showMore ? "Show Less" : "Show More"}
+ title={showMore ? "Show Less" : "Show More"}
+ disabled={!token}
+ >
+
+
+
+ );
+}
+
+export default TokenSettings;
diff --git a/src/components/token/TokenStatus.js b/src/components/token/TokenStatus.js
index 5621589..80a0377 100644
--- a/src/components/token/TokenStatus.js
+++ b/src/components/token/TokenStatus.js
@@ -1,46 +1,24 @@
import React from "react";
-import { Box } from "theme-ui";
+import { Circle, Group } from "react-konva";
import colors from "../../helpers/colors";
-function TokenStatus({ statuses }) {
+function TokenStatus({ tokenState, width, height }) {
return (
-
- {statuses.map((status, index) => (
-
+ {tokenState.statuses.map((status, index) => (
+
-
-
+ width={width}
+ height={height}
+ stroke={colors[status]}
+ strokeWidth={width / 20 / tokenState.size}
+ scaleX={1 - index / 10 / tokenState.size}
+ scaleY={1 - index / 10 / tokenState.size}
+ opacity={0.8}
+ />
))}
-
+
);
}
diff --git a/src/components/token/TokenTile.js b/src/components/token/TokenTile.js
new file mode 100644
index 0000000..35c8b33
--- /dev/null
+++ b/src/components/token/TokenTile.js
@@ -0,0 +1,81 @@
+import React from "react";
+import { Flex, Image, Text, Box, IconButton } from "theme-ui";
+
+import RemoveTokenIcon from "../../icons/RemoveTokenIcon";
+
+import useDataSource from "../../helpers/useDataSource";
+import {
+ tokenSources as defaultTokenSources,
+ unknownSource,
+} from "../../tokens";
+
+function TokenTile({ token, isSelected, onTokenSelect, onTokenRemove }) {
+ const tokenSource = useDataSource(token, defaultTokenSources, unknownSource);
+ const isDefault = token.type === "default";
+
+ return (
+ onTokenSelect(token)}
+ sx={{
+ borderColor: "primary",
+ borderStyle: isSelected ? "solid" : "none",
+ borderWidth: "4px",
+ position: "relative",
+ width: "150px",
+ height: "150px",
+ borderRadius: "4px",
+ justifyContent: "center",
+ alignItems: "center",
+ cursor: "pointer",
+ }}
+ m={2}
+ bg="muted"
+ >
+
+
+
+ {token.name}
+
+
+ {isSelected && !isDefault && (
+
+ {
+ onTokenRemove(token.id);
+ }}
+ bg="overlay"
+ sx={{ borderRadius: "50%" }}
+ m={1}
+ >
+
+
+
+ )}
+
+ );
+}
+
+export default TokenTile;
diff --git a/src/components/token/TokenTiles.js b/src/components/token/TokenTiles.js
new file mode 100644
index 0000000..f9ae7e2
--- /dev/null
+++ b/src/components/token/TokenTiles.js
@@ -0,0 +1,67 @@
+import React from "react";
+import { Flex } from "theme-ui";
+import SimpleBar from "simplebar-react";
+
+import AddIcon from "../../icons/AddIcon";
+
+import TokenTile from "./TokenTile";
+
+function TokenTiles({
+ tokens,
+ onTokenAdd,
+ onTokenSelect,
+ selectedToken,
+ onTokenRemove,
+}) {
+ return (
+
+
+
+
+
+ {tokens.map((token) => (
+
+ ))}
+
+
+ );
+}
+
+export default TokenTiles;
diff --git a/src/components/token/Tokens.js b/src/components/token/Tokens.js
index 5fde471..cf99210 100644
--- a/src/components/token/Tokens.js
+++ b/src/components/token/Tokens.js
@@ -1,34 +1,38 @@
-import React, { useState, useContext } from "react";
-import { Box } from "theme-ui";
+import React, { useContext } from "react";
+import { Box, Flex } from "theme-ui";
import shortid from "shortid";
import SimpleBar from "simplebar-react";
import ListToken from "./ListToken";
import ProxyToken from "./ProxyToken";
-import NumberInput from "../NumberInput";
+
+import SelectTokensButton from "./SelectTokensButton";
import { fromEntries } from "../../helpers/shared";
import AuthContext from "../../contexts/AuthContext";
+import TokenDataContext from "../../contexts/TokenDataContext";
const listTokenClassName = "list-token";
-function Tokens({ onCreateMapTokenState, tokens }) {
- const [tokenSize, setTokenSize] = useState(1);
+function Tokens({ onMapTokenStateCreate }) {
const { userId } = useContext(AuthContext);
+ const { ownedTokens, tokens } = useContext(TokenDataContext);
function handleProxyDragEnd(isOnMap, token) {
- if (isOnMap && onCreateMapTokenState) {
+ if (isOnMap && onMapTokenStateCreate) {
// Create a token state from the dragged token
- onCreateMapTokenState({
+ onMapTokenStateCreate({
id: shortid.generate(),
tokenId: token.id,
owner: userId,
- size: tokenSize,
+ size: token.defaultSize,
label: "",
statuses: [],
x: token.x,
y: token.y,
+ lastEditedBy: userId,
+ rotation: 0,
});
}
}
@@ -43,24 +47,27 @@ function Tokens({ onCreateMapTokenState, tokens }) {
overflow: "hidden",
}}
>
-
- {tokens.map((token) => (
-
- ))}
+
+ {ownedTokens
+ .filter((token) => token.owner === userId)
+ .map((token) => (
+
+ ))}
-
-
-
+
+
+
{
+ if (!userId || !database) {
+ return;
+ }
+ async function getDefaultMaps() {
+ const defaultMapsWithIds = [];
+ for (let i = 0; i < defaultMaps.length; i++) {
+ const defaultMap = defaultMaps[i];
+ const id = `__default-${defaultMap.name}`;
+ defaultMapsWithIds.push({
+ ...defaultMap,
+ id,
+ owner: userId,
+ // Emulate the time increasing to avoid sort errors
+ created: Date.now() + i,
+ lastModified: Date.now() + i,
+ gridType: "grid",
+ });
+ // Add a state for the map if there isn't one already
+ const state = await database.table("states").get(id);
+ if (!state) {
+ await database.table("states").add({ ...defaultMapState, mapId: id });
+ }
+ }
+ return defaultMapsWithIds;
+ }
+
+ async function loadMaps() {
+ let storedMaps = await database.table("maps").toArray();
+ const sortedMaps = storedMaps.sort((a, b) => b.created - a.created);
+ const defaultMapsWithIds = await getDefaultMaps();
+ const allMaps = [...sortedMaps, ...defaultMapsWithIds];
+ setMaps(allMaps);
+ const storedStates = await database.table("states").toArray();
+ setMapStates(storedStates);
+ }
+
+ loadMaps();
+ }, [userId, database]);
+
+ async function addMap(map) {
+ await database.table("maps").add(map);
+ const state = { ...defaultMapState, mapId: map.id };
+ await database.table("states").add(state);
+ setMaps((prevMaps) => [map, ...prevMaps]);
+ setMapStates((prevStates) => [state, ...prevStates]);
+ }
+
+ async function removeMap(id) {
+ await database.table("maps").delete(id);
+ await database.table("states").delete(id);
+ setMaps((prevMaps) => {
+ const filtered = prevMaps.filter((map) => map.id !== id);
+ return filtered;
+ });
+ setMapStates((prevMapsStates) => {
+ const filtered = prevMapsStates.filter((state) => state.mapId !== id);
+ return filtered;
+ });
+ }
+
+ async function resetMap(id) {
+ const state = { ...defaultMapState, mapId: id };
+ await database.table("states").put(state);
+ setMapStates((prevMapStates) => {
+ const newStates = [...prevMapStates];
+ const i = newStates.findIndex((state) => state.mapId === id);
+ if (i > -1) {
+ newStates[i] = state;
+ }
+ return newStates;
+ });
+ return state;
+ }
+
+ async function updateMap(id, update) {
+ const change = { ...update, lastModified: Date.now() };
+ await database.table("maps").update(id, change);
+ setMaps((prevMaps) => {
+ const newMaps = [...prevMaps];
+ const i = newMaps.findIndex((map) => map.id === id);
+ if (i > -1) {
+ newMaps[i] = { ...newMaps[i], ...change };
+ }
+ return newMaps;
+ });
+ }
+
+ async function updateMapState(id, update) {
+ await database.table("states").update(id, update);
+ setMapStates((prevMapStates) => {
+ const newStates = [...prevMapStates];
+ const i = newStates.findIndex((state) => state.mapId === id);
+ if (i > -1) {
+ newStates[i] = { ...newStates[i], ...update };
+ }
+ return newStates;
+ });
+ }
+
+ async function putMap(map) {
+ await database.table("maps").put(map);
+ setMaps((prevMaps) => {
+ const newMaps = [...prevMaps];
+ const i = newMaps.findIndex((m) => m.id === map.id);
+ if (i > -1) {
+ newMaps[i] = { ...newMaps[i], ...map };
+ } else {
+ newMaps.unshift(map);
+ }
+ return newMaps;
+ });
+ }
+
+ function getMap(mapId) {
+ return maps.find((map) => map.id === mapId);
+ }
+
+ const ownedMaps = maps.filter((map) => map.owner === userId);
+
+ const value = {
+ maps,
+ ownedMaps,
+ mapStates,
+ addMap,
+ removeMap,
+ resetMap,
+ updateMap,
+ updateMapState,
+ putMap,
+ getMap,
+ };
+ return (
+ {children}
+ );
+}
+
+export default MapDataContext;
diff --git a/src/contexts/MapInteractionContext.js b/src/contexts/MapInteractionContext.js
index 504c31e..6b11577 100644
--- a/src/contexts/MapInteractionContext.js
+++ b/src/contexts/MapInteractionContext.js
@@ -1,8 +1,14 @@
import React from "react";
const MapInteractionContext = React.createContext({
- translateRef: null,
- scaleRef: null,
+ stageScale: 1,
+ stageWidth: 1,
+ stageHeight: 1,
+ stageDragState: "none",
+ setPreventMapInteraction: () => {},
+ mapWidth: 1,
+ mapHeight: 1,
+ mapDragPositionRef: { current: undefined },
});
export const MapInteractionProvider = MapInteractionContext.Provider;
diff --git a/src/contexts/MapLoadingContext.js b/src/contexts/MapLoadingContext.js
new file mode 100644
index 0000000..13ec1d7
--- /dev/null
+++ b/src/contexts/MapLoadingContext.js
@@ -0,0 +1,31 @@
+import React, { useState } from "react";
+
+const MapLoadingContext = React.createContext();
+
+export function MapLoadingProvider({ children }) {
+ const [loadingAssetCount, setLoadingAssetCount] = useState(0);
+
+ function assetLoadStart() {
+ setLoadingAssetCount((prevLoadingAssets) => prevLoadingAssets + 1);
+ }
+
+ function assetLoadFinish() {
+ setLoadingAssetCount((prevLoadingAssets) => prevLoadingAssets - 1);
+ }
+
+ const isLoading = loadingAssetCount > 0;
+
+ const value = {
+ assetLoadStart,
+ assetLoadFinish,
+ isLoading,
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export default MapLoadingContext;
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/contexts/TokenDataContext.js b/src/contexts/TokenDataContext.js
new file mode 100644
index 0000000..14cd6c0
--- /dev/null
+++ b/src/contexts/TokenDataContext.js
@@ -0,0 +1,112 @@
+import React, { useEffect, useState, useContext } from "react";
+
+import AuthContext from "./AuthContext";
+import DatabaseContext from "./DatabaseContext";
+
+import { tokens as defaultTokens } from "../tokens";
+
+const TokenDataContext = React.createContext();
+
+export function TokenDataProvider({ children }) {
+ const { database } = useContext(DatabaseContext);
+ const { userId } = useContext(AuthContext);
+
+ const [tokens, setTokens] = useState([]);
+
+ useEffect(() => {
+ if (!userId || !database) {
+ return;
+ }
+ function getDefaultTokes() {
+ const defaultTokensWithIds = [];
+ for (let defaultToken of defaultTokens) {
+ defaultTokensWithIds.push({
+ ...defaultToken,
+ id: `__default-${defaultToken.name}`,
+ owner: userId,
+ });
+ }
+ return defaultTokensWithIds;
+ }
+
+ async function loadTokens() {
+ let storedTokens = await database.table("tokens").toArray();
+ const sortedTokens = storedTokens.sort((a, b) => b.created - a.created);
+ const defaultTokensWithIds = getDefaultTokes();
+ const allTokens = [...sortedTokens, ...defaultTokensWithIds];
+ setTokens(allTokens);
+ }
+
+ loadTokens();
+ }, [userId, database]);
+
+ async function addToken(token) {
+ await database.table("tokens").add(token);
+ setTokens((prevTokens) => [token, ...prevTokens]);
+ }
+
+ async function removeToken(id) {
+ await database.table("tokens").delete(id);
+ setTokens((prevTokens) => {
+ const filtered = prevTokens.filter((token) => token.id !== id);
+ return filtered;
+ });
+ }
+
+ async function updateToken(id, update) {
+ const change = { ...update, lastModified: Date.now() };
+ await database.table("tokens").update(id, change);
+ setTokens((prevTokens) => {
+ const newTokens = [...prevTokens];
+ const i = newTokens.findIndex((token) => token.id === id);
+ if (i > -1) {
+ newTokens[i] = { ...newTokens[i], ...change };
+ }
+ return newTokens;
+ });
+ }
+
+ async function putToken(token) {
+ await database.table("tokens").put(token);
+ setTokens((prevTokens) => {
+ const newTokens = [...prevTokens];
+ const i = newTokens.findIndex((t) => t.id === token.id);
+ if (i > -1) {
+ newTokens[i] = { ...newTokens[i], ...token };
+ } else {
+ newTokens.unshift(token);
+ }
+ return newTokens;
+ });
+ }
+
+ function getToken(tokenId) {
+ return tokens.find((token) => token.id === tokenId);
+ }
+
+ const ownedTokens = tokens.filter((token) => token.owner === userId);
+
+ const tokensById = tokens.reduce((obj, token) => {
+ obj[token.id] = token;
+ return obj;
+ }, {});
+
+ const value = {
+ tokens,
+ ownedTokens,
+ addToken,
+ removeToken,
+ updateToken,
+ putToken,
+ getToken,
+ tokensById,
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export default TokenDataContext;
diff --git a/src/database.js b/src/database.js
index 71851cb..72b0480 100644
--- a/src/database.js
+++ b/src/database.js
@@ -26,6 +26,69 @@ function loadVersions(db) {
map.file = mapBuffers[map.id];
});
});
+ // v1.3.0 - Added new default tokens
+ db.version(3)
+ .stores({})
+ .upgrade((tx) => {
+ return tx
+ .table("states")
+ .toCollection()
+ .modify((state) => {
+ function mapTokenId(id) {
+ switch (id) {
+ case "__default-Axes":
+ return "__default-Barbarian";
+ case "__default-Bird":
+ return "__default-Druid";
+ case "__default-Book":
+ return "__default-Wizard";
+ case "__default-Crown":
+ return "__default-Humanoid";
+ case "__default-Dragon":
+ return "__default-Dragon";
+ case "__default-Eye":
+ return "__default-Warlock";
+ case "__default-Fist":
+ return "__default-Monk";
+ case "__default-Horse":
+ return "__default-Fey";
+ case "__default-Leaf":
+ return "__default-Druid";
+ case "__default-Lion":
+ return "__default-Monstrosity";
+ case "__default-Money":
+ return "__default-Humanoid";
+ case "__default-Moon":
+ return "__default-Cleric";
+ case "__default-Potion":
+ return "__default-Sorcerer";
+ case "__default-Shield":
+ return "__default-Paladin";
+ case "__default-Skull":
+ return "__default-Undead";
+ case "__default-Snake":
+ return "__default-Beast";
+ case "__default-Sun":
+ return "__default-Cleric";
+ case "__default-Swords":
+ return "__default-Fighter";
+ case "__default-Tree":
+ return "__default-Plant";
+ case "__default-Triangle":
+ return "__default-Sorcerer";
+ default:
+ return "__default-Fighter";
+ }
+ }
+ for (let stateId in state.tokens) {
+ state.tokens[stateId].tokenId = mapTokenId(
+ state.tokens[stateId].tokenId
+ );
+ state.tokens[stateId].lastEditedBy = "";
+ state.tokens[stateId].rotation = 0;
+ }
+ });
+ });
}
// Get the dexie database used in DatabaseContext
diff --git a/src/docs/releaseNotes/v1.2.1.md b/src/docs/releaseNotes/v1.2.1.md
new file mode 100644
index 0000000..fd55366
--- /dev/null
+++ b/src/docs/releaseNotes/v1.2.1.md
@@ -0,0 +1,10 @@
+# v1.2.1
+
+## Minor Changes
+
+- Changed the way maps are stored and sent to other players which should fix a few of the issues with maps not sending properly.
+- Added relay servers to use as a fallback for when players aren't able to create a direct connection, this should fix most issues with connection failures.
+- Fixed a bug that would stop users from uploading custom maps when web storage was disabled (this mainly happened with Firefox in Private Mode).
+- Added a new release notes page on the site which shows all the release notes in one place.
+
+[Reddit](https://www.reddit.com/r/OwlbearRodeo/comments/ggyiz8/beta_v121_release_connection_issues_and_map/)
diff --git a/src/helpers/blobToBuffer.js b/src/helpers/blobToBuffer.js
index c816ced..70e86af 100644
--- a/src/helpers/blobToBuffer.js
+++ b/src/helpers/blobToBuffer.js
@@ -3,8 +3,21 @@ async function blobToBuffer(blob) {
const arrayBuffer = await blob.arrayBuffer();
return new Uint8Array(arrayBuffer);
} else {
- const arrayBuffer = new Response(blob).arrayBuffer();
- return new Uint8Array(arrayBuffer);
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+
+ function onLoadEnd(event) {
+ reader.removeEventListener("loadend", onLoadEnd, false);
+ if (event.error) {
+ reject(event.error);
+ } else {
+ resolve(Buffer.from(reader.result));
+ }
+ }
+
+ reader.addEventListener("loadend", onLoadEnd, false);
+ reader.readAsArrayBuffer(blob);
+ });
}
}
diff --git a/src/helpers/drawing.js b/src/helpers/drawing.js
index 89a192b..0ff64d0 100644
--- a/src/helpers/drawing.js
+++ b/src/helpers/drawing.js
@@ -2,7 +2,6 @@ import simplify from "simplify-js";
import * as Vector2 from "./vector2";
import { toDegrees } from "./shared";
-import colors from "./colors";
const snappingThreshold = 1 / 5;
export function getBrushPositionForTool(
@@ -140,207 +139,13 @@ export function getUpdatedShapeData(type, data, brushPosition, gridSize) {
}
}
-const defaultStrokeSize = 1 / 10;
-export function getStrokeSize(multiplier, gridSize, canvasWidth, canvasHeight) {
+const defaultStrokeWidth = 1 / 10;
+export function getStrokeWidth(multiplier, gridSize, mapWidth, mapHeight) {
const gridPixelSize = Vector2.multiply(gridSize, {
- x: canvasWidth,
- y: canvasHeight,
+ x: mapWidth,
+ y: mapHeight,
});
- return Vector2.min(gridPixelSize) * defaultStrokeSize * multiplier;
-}
-
-export function shapeHasFill(shape) {
- return (
- shape.type === "fog" ||
- shape.type === "shape" ||
- (shape.type === "path" && shape.pathType === "fill")
- );
-}
-
-export function pointsToQuadraticBezier(points) {
- const quadraticPoints = [];
-
- // Draw a smooth curve between the points where each control point
- // is the current point in the array and the next point is the center of
- // the current point and the next point
- for (let i = 1; i < points.length - 2; i++) {
- const start = points[i - 1];
- const controlPoint = points[i];
- const next = points[i + 1];
- const end = Vector2.divide(Vector2.add(controlPoint, next), 2);
-
- quadraticPoints.push({ start, controlPoint, end });
- }
- // Curve through the last two points
- quadraticPoints.push({
- start: points[points.length - 2],
- controlPoint: points[points.length - 1],
- end: points[points.length - 1],
- });
-
- return quadraticPoints;
-}
-
-export function pointsToPathSmooth(points, close, canvasWidth, canvasHeight) {
- const path = new Path2D();
- if (points.length < 2) {
- return path;
- }
- path.moveTo(points[0].x * canvasWidth, points[0].y * canvasHeight);
-
- const quadraticPoints = pointsToQuadraticBezier(points);
- for (let quadPoint of quadraticPoints) {
- const pointScaled = Vector2.multiply(quadPoint.end, {
- x: canvasWidth,
- y: canvasHeight,
- });
- const controlScaled = Vector2.multiply(quadPoint.controlPoint, {
- x: canvasWidth,
- y: canvasHeight,
- });
- path.quadraticCurveTo(
- controlScaled.x,
- controlScaled.y,
- pointScaled.x,
- pointScaled.y
- );
- }
-
- if (close) {
- path.closePath();
- }
- return path;
-}
-
-export function pointsToPathSharp(points, close, canvasWidth, canvasHeight) {
- const path = new Path2D();
- path.moveTo(points[0].x * canvasWidth, points[0].y * canvasHeight);
- for (let point of points.slice(1)) {
- path.lineTo(point.x * canvasWidth, point.y * canvasHeight);
- }
- if (close) {
- path.closePath();
- }
-
- return path;
-}
-
-export function circleToPath(x, y, radius, canvasWidth, canvasHeight) {
- const path = new Path2D();
- const minSide = canvasWidth < canvasHeight ? canvasWidth : canvasHeight;
- path.arc(
- x * canvasWidth,
- y * canvasHeight,
- radius * minSide,
- 0,
- 2 * Math.PI,
- true
- );
- return path;
-}
-
-export function rectangleToPath(
- x,
- y,
- width,
- height,
- canvasWidth,
- canvasHeight
-) {
- const path = new Path2D();
- path.rect(
- x * canvasWidth,
- y * canvasHeight,
- width * canvasWidth,
- height * canvasHeight
- );
- return path;
-}
-
-export function shapeToPath(shape, canvasWidth, canvasHeight) {
- const data = shape.data;
- if (shape.type === "path") {
- return pointsToPathSmooth(
- data.points,
- shape.pathType === "fill",
- canvasWidth,
- canvasHeight
- );
- } else if (shape.type === "shape") {
- if (shape.shapeType === "circle") {
- return circleToPath(
- data.x,
- data.y,
- data.radius,
- canvasWidth,
- canvasHeight
- );
- } else if (shape.shapeType === "rectangle") {
- return rectangleToPath(
- data.x,
- data.y,
- data.width,
- data.height,
- canvasWidth,
- canvasHeight
- );
- } else if (shape.shapeType === "triangle") {
- return pointsToPathSharp(data.points, true, canvasWidth, canvasHeight);
- }
- } else if (shape.type === "fog") {
- return pointsToPathSharp(
- shape.data.points,
- true,
- canvasWidth,
- canvasHeight
- );
- }
-}
-
-export function isShapeHovered(
- shape,
- context,
- hoverPosition,
- canvasWidth,
- canvasHeight
-) {
- const path = shapeToPath(shape, canvasWidth, canvasHeight);
- if (shapeHasFill(shape)) {
- return context.isPointInPath(
- path,
- hoverPosition.x * canvasWidth,
- hoverPosition.y * canvasHeight
- );
- } else {
- return context.isPointInStroke(
- path,
- hoverPosition.x * canvasWidth,
- hoverPosition.y * canvasHeight
- );
- }
-}
-
-export function drawShape(shape, context, gridSize, canvasWidth, canvasHeight) {
- const path = shapeToPath(shape, canvasWidth, canvasHeight);
- const color = colors[shape.color] || shape.color;
- const fill = shapeHasFill(shape);
-
- context.globalAlpha = shape.blend ? 0.5 : 1.0;
- context.fillStyle = color;
- context.strokeStyle = color;
- if (shape.strokeWidth > 0) {
- context.lineCap = "round";
- context.lineWidth = getStrokeSize(
- shape.strokeWidth,
- gridSize,
- canvasWidth,
- canvasHeight
- );
- context.stroke(path);
- }
- if (fill) {
- context.fill(path);
- }
+ return Vector2.min(gridPixelSize) * defaultStrokeWidth * multiplier;
}
const defaultSimplifySize = 1 / 100;
@@ -350,12 +155,3 @@ export function simplifyPoints(points, gridSize, scale) {
(Vector2.min(gridSize) * defaultSimplifySize) / scale
);
}
-
-export function getRelativePointerPosition(event, container) {
- if (container) {
- const containerRect = container.getBoundingClientRect();
- const x = (event.clientX - containerRect.x) / containerRect.width;
- const y = (event.clientY - containerRect.y) / containerRect.height;
- return { x, y };
- }
-}
diff --git a/src/helpers/useDataSource.js b/src/helpers/useDataSource.js
index 4af57a1..6dc39b8 100644
--- a/src/helpers/useDataSource.js
+++ b/src/helpers/useDataSource.js
@@ -2,14 +2,14 @@ import { useEffect, useState } from "react";
// Helper function to load either file or default data
// into a URL and ensure that it is revoked if needed
-function useDataSource(data, defaultSources) {
+function useDataSource(data, defaultSources, unknownSource) {
const [dataSource, setDataSource] = useState(null);
useEffect(() => {
if (!data) {
- setDataSource(null);
+ setDataSource(unknownSource);
return;
}
- let url = null;
+ let url = unknownSource;
if (data.type === "file") {
url = URL.createObjectURL(new Blob([data.file]));
} else if (data.type === "default") {
@@ -22,7 +22,7 @@ function useDataSource(data, defaultSources) {
URL.revokeObjectURL(url);
}
};
- }, [data, defaultSources]);
+ }, [data, defaultSources, unknownSource]);
return dataSource;
}
diff --git a/src/helpers/useMapBrush.js b/src/helpers/useMapBrush.js
new file mode 100644
index 0000000..3c96f7d
--- /dev/null
+++ b/src/helpers/useMapBrush.js
@@ -0,0 +1,71 @@
+import { useContext, useRef, useEffect } from "react";
+
+import MapInteractionContext from "../contexts/MapInteractionContext";
+
+import { compare } from "./vector2";
+
+import usePrevious from "./usePrevious";
+
+/**
+ * @callback onBrushUpdate
+ * @param {string} drawState "first" | "drawing" | "last"
+ * @param {Object} brushPosition the normalized x and y coordinates of the brush on the map
+ */
+
+/**
+ * Helper to get the maps drag position as it changes
+ * @param {boolean} shouldUpdate
+ * @param {onBrushUpdate} onBrushUpdate
+ */
+function useMapBrush(shouldUpdate, onBrushUpdate) {
+ const { stageDragState, mapDragPositionRef } = useContext(
+ MapInteractionContext
+ );
+
+ const requestRef = useRef();
+ const previousDragState = usePrevious(stageDragState);
+ const previousBrushPositionRef = useRef(mapDragPositionRef.current);
+
+ useEffect(() => {
+ function updateBrush(forceUpdate) {
+ const drawState =
+ stageDragState === "dragging" ? "drawing" : stageDragState;
+ const brushPosition = mapDragPositionRef.current;
+ const previousBrushPostition = previousBrushPositionRef.current;
+ // Only update brush when it has moved
+ if (
+ !compare(brushPosition, previousBrushPostition, 0.0001) ||
+ forceUpdate
+ ) {
+ onBrushUpdate(drawState, brushPosition);
+ previousBrushPositionRef.current = brushPosition;
+ }
+ }
+
+ function animate() {
+ if (!shouldUpdate) {
+ return;
+ }
+ requestRef.current = requestAnimationFrame(animate);
+ updateBrush(false);
+ }
+
+ requestRef.current = requestAnimationFrame(animate);
+
+ if (stageDragState !== previousDragState && shouldUpdate) {
+ updateBrush(true);
+ }
+
+ return () => {
+ cancelAnimationFrame(requestRef.current);
+ };
+ }, [
+ shouldUpdate,
+ onBrushUpdate,
+ stageDragState,
+ mapDragPositionRef,
+ previousDragState,
+ ]);
+}
+
+export default useMapBrush;
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/helpers/useSession.js b/src/helpers/useSession.js
index 1de6308..e728a0b 100644
--- a/src/helpers/useSession.js
+++ b/src/helpers/useSession.js
@@ -136,7 +136,7 @@ function useSession(
function addPeer(id, initiator, sync) {
const connection = new Peer({
initiator,
- trickle: false,
+ trickle: true,
config: { iceServers },
});
diff --git a/src/icons/RemoveTokenIcon.js b/src/icons/RemoveTokenIcon.js
new file mode 100644
index 0000000..2f587d2
--- /dev/null
+++ b/src/icons/RemoveTokenIcon.js
@@ -0,0 +1,18 @@
+import React from "react";
+
+function RemoveTokenIcon() {
+ return (
+
+ );
+}
+
+export default RemoveTokenIcon;
diff --git a/src/icons/SelectTokensIcon.js b/src/icons/SelectTokensIcon.js
new file mode 100644
index 0000000..0637422
--- /dev/null
+++ b/src/icons/SelectTokensIcon.js
@@ -0,0 +1,18 @@
+import React from "react";
+
+function SelectMapIcon() {
+ return (
+
+ );
+}
+
+export default SelectMapIcon;
diff --git a/src/maps/Unknown Grid 22x22.jpg b/src/maps/Unknown Grid 22x22.jpg
new file mode 100644
index 0000000..d4e9933
Binary files /dev/null and b/src/maps/Unknown Grid 22x22.jpg differ
diff --git a/src/maps/index.js b/src/maps/index.js
index 9fe93d7..8f1bbbf 100644
--- a/src/maps/index.js
+++ b/src/maps/index.js
@@ -1,3 +1,5 @@
+import Case from "case";
+
import blankImage from "./Blank Grid 22x22.jpg";
import grassImage from "./Grass Grid 22x22.jpg";
import sandImage from "./Sand Grid 22x22.jpg";
@@ -5,6 +7,8 @@ import stoneImage from "./Stone Grid 22x22.jpg";
import waterImage from "./Water Grid 22x22.jpg";
import woodImage from "./Wood Grid 22x22.jpg";
+import unknownImage from "./Unknown Grid 22x22.jpg";
+
export const mapSources = {
blank: blankImage,
grass: grassImage,
@@ -16,10 +20,12 @@ export const mapSources = {
export const maps = Object.keys(mapSources).map((key) => ({
key,
- name: key.charAt(0).toUpperCase() + key.slice(1),
+ name: Case.capital(key),
gridX: 22,
gridY: 22,
width: 1024,
height: 1024,
type: "default",
}));
+
+export const unknownSource = unknownImage;
diff --git a/src/modals/SelectMapModal.js b/src/modals/SelectMapModal.js
index cb0cbcb..91de48a 100644
--- a/src/modals/SelectMapModal.js
+++ b/src/modals/SelectMapModal.js
@@ -1,32 +1,18 @@
-import React, { useRef, useState, useEffect, useContext } from "react";
-import { Box, Button, Flex, Label, Text } from "theme-ui";
+import React, { useRef, useState, useContext } from "react";
+import { Button, Flex, Label } from "theme-ui";
import shortid from "shortid";
import Modal from "../components/Modal";
import MapTiles from "../components/map/MapTiles";
import MapSettings from "../components/map/MapSettings";
+import ImageDrop from "../components/ImageDrop";
-import AuthContext from "../contexts/AuthContext";
-import DatabaseContext from "../contexts/DatabaseContext";
-
-import usePrevious from "../helpers/usePrevious";
import blobToBuffer from "../helpers/blobToBuffer";
-import { maps as defaultMaps } from "../maps";
+import MapDataContext from "../contexts/MapDataContext";
+import AuthContext from "../contexts/AuthContext";
const defaultMapSize = 22;
-const defaultMapState = {
- tokens: {},
- // An index into the draw actions array to which only actions before the
- // index will be performed (used in undo and redo)
- mapDrawActionIndex: -1,
- mapDrawActions: [],
- fogDrawActionIndex: -1,
- fogDrawActions: [],
- // Flags to determine what other people can edit
- editFlags: ["drawing", "tokens"],
-};
-
const defaultMapProps = {
// Grid type
// TODO: add support for hex horizontal and hex vertical
@@ -42,68 +28,26 @@ function SelectMapModal({
// The map currently being view in the map screen
currentMap,
}) {
- const { database } = useContext(DatabaseContext);
const { userId } = useContext(AuthContext);
-
- const wasOpen = usePrevious(isOpen);
+ const {
+ ownedMaps,
+ mapStates,
+ addMap,
+ removeMap,
+ resetMap,
+ updateMap,
+ updateMapState,
+ } = useContext(MapDataContext);
const [imageLoading, setImageLoading] = useState(false);
// The map selected in the modal
- const [selectedMap, setSelectedMap] = useState(null);
- const [selectedMapState, setSelectedMapState] = useState(null);
- const [maps, setMaps] = useState([]);
- // Load maps from the database and ensure state is properly setup
- useEffect(() => {
- if (!userId || !database) {
- return;
- }
- async function getDefaultMaps() {
- const defaultMapsWithIds = [];
- for (let i = 0; i < defaultMaps.length; i++) {
- const defaultMap = defaultMaps[i];
- const id = `__default-${defaultMap.name}`;
- defaultMapsWithIds.push({
- ...defaultMap,
- id,
- owner: userId,
- // Emulate the time increasing to avoid sort errors
- created: Date.now() + i,
- lastModified: Date.now() + i,
- ...defaultMapProps,
- });
- // Add a state for the map if there isn't one already
- const state = await database.table("states").get(id);
- if (!state) {
- await database.table("states").add({ ...defaultMapState, mapId: id });
- }
- }
- return defaultMapsWithIds;
- }
+ const [selectedMapId, setSelectedMapId] = useState(null);
- async function loadMaps() {
- let storedMaps = await database
- .table("maps")
- .where({ owner: userId })
- .toArray();
- const sortedMaps = storedMaps.sort((a, b) => b.created - a.created);
- const defaultMapsWithIds = await getDefaultMaps();
- const allMaps = [...sortedMaps, ...defaultMapsWithIds];
- setMaps(allMaps);
-
- // reload map state as is may have changed while the modal was closed
- if (selectedMap) {
- const state = await database.table("states").get(selectedMap.id);
- if (state) {
- setSelectedMapState(state);
- }
- }
- }
-
- if (!wasOpen && isOpen) {
- loadMaps();
- }
- }, [userId, database, isOpen, wasOpen, selectedMap]);
+ const selectedMap = ownedMaps.find((map) => map.id === selectedMapId);
+ const selectedMapState = mapStates.find(
+ (state) => state.mapId === selectedMapId
+ );
const fileInputRef = useRef();
@@ -180,108 +124,55 @@ function SelectMapModal({
}
async function handleMapAdd(map) {
- await database.table("maps").add(map);
- const state = { ...defaultMapState, mapId: map.id };
- await database.table("states").add(state);
- setMaps((prevMaps) => [map, ...prevMaps]);
- setSelectedMap(map);
- setSelectedMapState(state);
+ await addMap(map);
+ setSelectedMapId(map.id);
}
async function handleMapRemove(id) {
- await database.table("maps").delete(id);
- await database.table("states").delete(id);
- setMaps((prevMaps) => {
- const filtered = prevMaps.filter((map) => map.id !== id);
- setSelectedMap(filtered[0]);
- database.table("states").get(filtered[0].id).then(setSelectedMapState);
- return filtered;
- });
+ await removeMap(id);
+ setSelectedMapId(null);
// Removed the map from the map screen if needed
- if (currentMap && currentMap.id === selectedMap.id) {
+ if (currentMap && currentMap.id === selectedMapId) {
onMapChange(null, null);
}
}
- async function handleMapSelect(map) {
- const state = await database.table("states").get(map.id);
- setSelectedMapState(state);
- setSelectedMap(map);
+ function handleMapSelect(map) {
+ setSelectedMapId(map.id);
}
async function handleMapReset(id) {
- const state = { ...defaultMapState, mapId: id };
- await database.table("states").put(state);
- setSelectedMapState(state);
+ const newState = await resetMap(id);
// Reset the state of the current map if needed
- if (currentMap && currentMap.id === selectedMap.id) {
- onMapStateChange(state);
+ if (currentMap && currentMap.id === selectedMapId) {
+ onMapStateChange(newState);
}
}
- async function handleSubmit(e) {
- e.preventDefault();
- if (selectedMap) {
+ async function handleDone() {
+ if (selectedMapId) {
onMapChange(selectedMap, selectedMapState);
onDone();
}
onDone();
}
- /**
- * Drag and Drop
- */
- const [dragging, setDragging] = useState(false);
- function handleImageDragEnter(event) {
- event.preventDefault();
- event.stopPropagation();
- setDragging(true);
- }
-
- function handleImageDragLeave(event) {
- event.preventDefault();
- event.stopPropagation();
- setDragging(false);
- }
-
- function handleImageDrop(event) {
- event.preventDefault();
- event.stopPropagation();
- const file = event.dataTransfer.files[0];
- if (file && file.type.startsWith("image")) {
- handleImageUpload(file);
- }
- setDragging(false);
- }
-
/**
* Map settings
*/
const [showMoreSettings, setShowMoreSettings] = useState(false);
async function handleMapSettingsChange(key, value) {
- const change = { [key]: value, lastModified: Date.now() };
- database.table("maps").update(selectedMap.id, change);
- const newMap = { ...selectedMap, ...change };
- setMaps((prevMaps) => {
- const newMaps = [...prevMaps];
- const i = newMaps.findIndex((map) => map.id === selectedMap.id);
- if (i > -1) {
- newMaps[i] = newMap;
- }
- return newMaps;
- });
- setSelectedMap(newMap);
+ await updateMap(selectedMapId, { [key]: value });
}
async function handleMapStateSettingsChange(key, value) {
- database.table("states").update(selectedMap.id, { [key]: value });
- setSelectedMapState((prevState) => ({ ...prevState, [key]: value }));
+ await updateMapState(selectedMapId, { [key]: value });
}
return (
-
+
handleImageUpload(event.target.files[0])}
type="file"
@@ -298,14 +189,14 @@ function SelectMapModal({
Select or import a map
-
+
);
}
diff --git a/src/modals/SelectTokensModal.js b/src/modals/SelectTokensModal.js
new file mode 100644
index 0000000..751f721
--- /dev/null
+++ b/src/modals/SelectTokensModal.js
@@ -0,0 +1,140 @@
+import React, { useRef, useContext, useState } from "react";
+import { Flex, Label, Button } from "theme-ui";
+import shortid from "shortid";
+
+import Modal from "../components/Modal";
+import ImageDrop from "../components/ImageDrop";
+import TokenTiles from "../components/token/TokenTiles";
+import TokenSettings from "../components/token/TokenSettings";
+
+import blobToBuffer from "../helpers/blobToBuffer";
+
+import TokenDataContext from "../contexts/TokenDataContext";
+import AuthContext from "../contexts/AuthContext";
+
+function SelectTokensModal({ isOpen, onRequestClose }) {
+ const { userId } = useContext(AuthContext);
+ const { ownedTokens, addToken, removeToken, updateToken } = useContext(
+ TokenDataContext
+ );
+ const fileInputRef = useRef();
+
+ const [imageLoading, setImageLoading] = useState(false);
+
+ const [selectedTokenId, setSelectedTokenId] = useState(null);
+ const selectedToken = ownedTokens.find(
+ (token) => token.id === selectedTokenId
+ );
+
+ function openImageDialog() {
+ if (fileInputRef.current) {
+ fileInputRef.current.click();
+ }
+ }
+
+ function handleTokenAdd(token) {
+ addToken(token);
+ }
+
+ function handleImageUpload(file) {
+ let name = "Unknown Map";
+ if (file.name) {
+ // Remove file extension
+ name = file.name.replace(/\.[^/.]+$/, "");
+ // Removed grid size expression
+ name = name.replace(/(\[ ?|\( ?)?\d+ ?(x|X) ?\d+( ?\]| ?\))?/, "");
+ // Clean string
+ name = name.replace(/ +/g, " ");
+ name = name.trim();
+ }
+ let image = new Image();
+ setImageLoading(true);
+ blobToBuffer(file).then((buffer) => {
+ // Copy file to avoid permissions issues
+ const blob = new Blob([buffer]);
+ // Create and load the image temporarily to get its dimensions
+ const url = URL.createObjectURL(blob);
+ image.onload = function () {
+ handleTokenAdd({
+ file: buffer,
+ name,
+ type: "file",
+ id: shortid.generate(),
+ created: Date.now(),
+ lastModified: Date.now(),
+ owner: userId,
+ defaultSize: 1,
+ isVehicle: false,
+ });
+ setImageLoading(false);
+ };
+ image.src = url;
+
+ // Set file input to null to allow adding the same image 2 times in a row
+ fileInputRef.current.value = null;
+ });
+ }
+
+ function handleTokenSelect(token) {
+ setSelectedTokenId(token.id);
+ }
+
+ async function handleTokenRemove(id) {
+ await removeToken(id);
+ setSelectedTokenId(null);
+ }
+
+ /**
+ * Token settings
+ */
+ const [showMoreSettings, setShowMoreSettings] = useState(false);
+
+ async function handleTokenSettingsChange(key, value) {
+ await updateToken(selectedTokenId, { [key]: value });
+ }
+
+ return (
+
+
+ handleImageUpload(event.target.files[0])}
+ type="file"
+ accept="image/*"
+ style={{ display: "none" }}
+ ref={fileInputRef}
+ />
+
+
+
+
+
+ Done
+
+
+
+
+ );
+}
+
+export default SelectTokensModal;
diff --git a/src/modals/SettingsModal.js b/src/modals/SettingsModal.js
index 43fbe55..61a75ee 100644
--- a/src/modals/SettingsModal.js
+++ b/src/modals/SettingsModal.js
@@ -18,7 +18,26 @@ function SettingsModal({ isOpen, onRequestClose }) {
async function handleClearCache() {
await database.table("maps").where("owner").notEqual(userId).delete();
- // TODO: With custom tokens look up all tokens that aren't being used in a state
+ // Find all other peoples tokens who aren't benig used in a map state and delete them
+ const tokens = await database
+ .table("tokens")
+ .where("owner")
+ .notEqual(userId)
+ .toArray();
+ const states = await database.table("states").toArray();
+ for (let token of tokens) {
+ let inUse = false;
+ for (let state of states) {
+ for (let tokenState of Object.values(state.tokens)) {
+ if (token.id === tokenState.tokenId) {
+ inUse = true;
+ }
+ }
+ }
+ if (!inUse) {
+ database.table("tokens").delete(token.id);
+ }
+ }
window.location.reload();
}
diff --git a/src/routes/Game.js b/src/routes/Game.js
index f5d2c96..4e0e4f8 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";
@@ -17,15 +23,17 @@ import AuthModal from "../modals/AuthModal";
import AuthContext from "../contexts/AuthContext";
import DatabaseContext from "../contexts/DatabaseContext";
-
-import { tokens as defaultTokens } from "../tokens";
+import TokenDataContext from "../contexts/TokenDataContext";
+import MapDataContext from "../contexts/MapDataContext";
+import MapLoadingContext from "../contexts/MapLoadingContext";
+import { MapStageProvider } from "../contexts/MapStageContext";
function Game() {
- const { database } = useContext(DatabaseContext);
const { id: gameId } = useParams();
const { authenticationStatus, userId, nickname, setNickname } = useContext(
AuthContext
);
+ const { assetLoadStart, assetLoadFinish } = useContext(MapLoadingContext);
const { peers, socket } = useSession(
gameId,
@@ -37,64 +45,70 @@ function Game() {
handlePeerError
);
+ const { putToken, getToken } = useContext(TokenDataContext);
+ const { putMap, getMap } = useContext(MapDataContext);
+
/**
* Map state
*/
- const [map, setMap] = useState(null);
- const [mapState, setMapState] = useState(null);
- const [mapLoading, setMapLoading] = useState(false);
+ const [currentMap, setCurrentMap] = useState(null);
+ const [currentMapState, setCurrentMapState] = useState(null);
const canEditMapDrawing =
- map !== null &&
- mapState !== null &&
- (mapState.editFlags.includes("drawing") || map.owner === userId);
+ currentMap !== null &&
+ currentMapState !== null &&
+ (currentMapState.editFlags.includes("drawing") ||
+ currentMap.owner === userId);
const canEditFogDrawing =
- map !== null &&
- mapState !== null &&
- (mapState.editFlags.includes("fog") || map.owner === userId);
+ currentMap !== null &&
+ currentMapState !== null &&
+ (currentMapState.editFlags.includes("fog") || currentMap.owner === userId);
const disabledMapTokens = {};
// If we have a map and state and have the token permission disabled
// and are not the map owner
if (
- mapState !== null &&
- map !== null &&
- !mapState.editFlags.includes("tokens") &&
- map.owner !== userId
+ currentMapState !== null &&
+ currentMap !== null &&
+ !currentMapState.editFlags.includes("tokens") &&
+ currentMap.owner !== userId
) {
- for (let token of Object.values(mapState.tokens)) {
+ for (let token of Object.values(currentMapState.tokens)) {
if (token.owner !== userId) {
disabledMapTokens[token.id] = true;
}
}
}
+ const { database } = useContext(DatabaseContext);
// Sync the map state to the database after 500ms of inactivity
- const debouncedMapState = useDebounce(mapState, 500);
+ const debouncedMapState = useDebounce(currentMapState, 500);
useEffect(() => {
if (
debouncedMapState &&
debouncedMapState.mapId &&
- map &&
- map.owner === userId &&
+ currentMap &&
+ currentMap.owner === userId &&
database
) {
+ // Update the database directly to avoid re-renders
database
.table("states")
.update(debouncedMapState.mapId, debouncedMapState);
}
- }, [map, debouncedMapState, userId, database]);
+ }, [currentMap, debouncedMapState, userId, database]);
function handleMapChange(newMap, newMapState) {
- setMapState(newMapState);
- setMap(newMap);
+ setCurrentMapState(newMapState);
+ setCurrentMap(newMap);
for (let peer of Object.values(peers)) {
// Clear the map so the new map state isn't shown on an old map
peer.connection.send({ id: "map", data: null });
peer.connection.send({ id: "mapState", data: newMapState });
sendMapDataToPeer(peer, newMap);
+ sendTokensToPeer(peer, newMapState);
}
}
@@ -110,42 +124,14 @@ function Game() {
}
function handleMapStateChange(newMapState) {
- setMapState(newMapState);
+ setCurrentMapState(newMapState);
for (let peer of Object.values(peers)) {
peer.connection.send({ id: "mapState", data: newMapState });
}
}
- async function handleMapTokenStateChange(token) {
- if (mapState === null) {
- return;
- }
- setMapState((prevMapState) => ({
- ...prevMapState,
- tokens: {
- ...prevMapState.tokens,
- [token.id]: token,
- },
- }));
- for (let peer of Object.values(peers)) {
- const data = { [token.id]: token };
- peer.connection.send({ id: "tokenStateEdit", data });
- }
- }
-
- function handleMapTokenStateRemove(token) {
- setMapState((prevMapState) => {
- const { [token.id]: old, ...rest } = prevMapState.tokens;
- return { ...prevMapState, tokens: rest };
- });
- for (let peer of Object.values(peers)) {
- const data = { [token.id]: token };
- peer.connection.send({ id: "tokenStateRemove", data });
- }
- }
-
function addMapDrawActions(actions, indexKey, actionsKey) {
- setMapState((prevMapState) => {
+ setCurrentMapState((prevMapState) => {
const newActions = [
...prevMapState[actionsKey].slice(0, prevMapState[indexKey] + 1),
...actions,
@@ -161,11 +147,11 @@ function Game() {
function updateDrawActionIndex(change, indexKey, actionsKey, peerId) {
const newIndex = Math.min(
- Math.max(mapState[indexKey] + change, -1),
- mapState[actionsKey].length - 1
+ Math.max(currentMapState[indexKey] + change, -1),
+ currentMapState[actionsKey].length - 1
);
- setMapState((prevMapState) => ({
+ setCurrentMapState((prevMapState) => ({
...prevMapState,
[indexKey]: newIndex,
}));
@@ -230,6 +216,67 @@ function Game() {
}
}
+ /**
+ * Token state
+ */
+
+ // Get all tokens from a token state and send it to a peer
+ function sendTokensToPeer(peer, state) {
+ let sentTokens = {};
+ for (let tokenState of Object.values(state.tokens)) {
+ const token = getToken(tokenState.tokenId);
+ if (
+ token &&
+ token.type === "file" &&
+ !(tokenState.tokenId in sentTokens)
+ ) {
+ sentTokens[tokenState.tokenId] = true;
+ // Omit file from token peer will request file if needed
+ const { file, ...rest } = token;
+ peer.connection.send({ id: "token", data: rest });
+ }
+ }
+ }
+
+ async function handleMapTokenStateCreate(tokenState) {
+ // If file type token send the token to the other peers
+ const token = getToken(tokenState.tokenId);
+ if (token && token.type === "file") {
+ const { file, ...rest } = token;
+ for (let peer of Object.values(peers)) {
+ peer.connection.send({ id: "token", data: rest });
+ }
+ }
+ handleMapTokenStateChange({ [tokenState.id]: tokenState });
+ }
+
+ function handleMapTokenStateChange(change) {
+ if (currentMapState === null) {
+ return;
+ }
+ setCurrentMapState((prevMapState) => ({
+ ...prevMapState,
+ tokens: {
+ ...prevMapState.tokens,
+ ...change,
+ },
+ }));
+ for (let peer of Object.values(peers)) {
+ peer.connection.send({ id: "tokenStateEdit", data: change });
+ }
+ }
+
+ function handleMapTokenStateRemove(tokenState) {
+ setCurrentMapState((prevMapState) => {
+ const { [tokenState.id]: old, ...rest } = prevMapState.tokens;
+ return { ...prevMapState, tokens: rest };
+ });
+ for (let peer of Object.values(peers)) {
+ const data = { [tokenState.id]: tokenState };
+ peer.connection.send({ id: "tokenStateRemove", data });
+ }
+ }
+
/**
* Party state
*/
@@ -259,63 +306,84 @@ function Game() {
function handlePeerData({ data, peer }) {
if (data.id === "sync") {
- if (mapState) {
- peer.connection.send({ id: "mapState", data: mapState });
+ if (currentMapState) {
+ peer.connection.send({ id: "mapState", data: currentMapState });
+ sendTokensToPeer(peer, currentMapState);
}
- if (map) {
- sendMapDataToPeer(peer, map);
+ if (currentMap) {
+ sendMapDataToPeer(peer, currentMap);
}
}
if (data.id === "map") {
const newMap = data.data;
// If is a file map check cache and request the full file if outdated
if (newMap && newMap.type === "file") {
- database
- .table("maps")
- .get(newMap.id)
- .then((cachedMap) => {
- if (cachedMap && cachedMap.lastModified === newMap.lastModified) {
- setMap(cachedMap);
- } else {
- setMapLoading(true);
- peer.connection.send({ id: "mapRequest" });
- }
- });
+ const cachedMap = getMap(newMap.id);
+ if (cachedMap && cachedMap.lastModified === newMap.lastModified) {
+ setCurrentMap(cachedMap);
+ } else {
+ assetLoadStart();
+ peer.connection.send({ id: "mapRequest", data: newMap.id });
+ }
} else {
- setMap(newMap);
+ setCurrentMap(newMap);
}
}
// Send full map data including file
if (data.id === "mapRequest") {
+ const map = getMap(data.data);
peer.connection.send({ id: "mapResponse", data: map });
}
// A new map response with a file attached
if (data.id === "mapResponse") {
- setMapLoading(false);
+ assetLoadFinish();
if (data.data && data.data.type === "file") {
const newMap = { ...data.data, file: data.data.file };
- // Store in db
- database
- .table("maps")
- .put(newMap)
- .then(() => {
- setMap(newMap);
- });
+ putMap(newMap).then(() => {
+ setCurrentMap(newMap);
+ });
} else {
- setMap(data.data);
+ setCurrentMap(data.data);
}
}
if (data.id === "mapState") {
- setMapState(data.data);
+ setCurrentMapState(data.data);
+ }
+ if (data.id === "token") {
+ const newToken = data.data;
+ if (newToken && newToken.type === "file") {
+ const cachedToken = getToken(newToken.id);
+ if (
+ !cachedToken ||
+ cachedToken.lastModified !== newToken.lastModified
+ ) {
+ assetLoadStart();
+ peer.connection.send({
+ id: "tokenRequest",
+ data: newToken.id,
+ });
+ }
+ }
+ }
+ if (data.id === "tokenRequest") {
+ const token = getToken(data.data);
+ peer.connection.send({ id: "tokenResponse", data: token });
+ }
+ if (data.id === "tokenResponse") {
+ assetLoadFinish();
+ const newToken = data.data;
+ if (newToken && newToken.type === "file") {
+ putToken(newToken);
+ }
}
if (data.id === "tokenStateEdit") {
- setMapState((prevMapState) => ({
+ setCurrentMapState((prevMapState) => ({
...prevMapState,
tokens: { ...prevMapState.tokens, ...data.data },
}));
}
if (data.id === "tokenStateRemove") {
- setMapState((prevMapState) => ({
+ setCurrentMapState((prevMapState) => ({
...prevMapState,
tokens: omit(prevMapState.tokens, Object.keys(data.data)),
}));
@@ -330,7 +398,7 @@ function Game() {
addMapDrawActions(data.data, "mapDrawActionIndex", "mapDrawActions");
}
if (data.id === "mapDrawIndex") {
- setMapState((prevMapState) => ({
+ setCurrentMapState((prevMapState) => ({
...prevMapState,
mapDrawActionIndex: data.data,
}));
@@ -339,7 +407,7 @@ function Game() {
addMapDrawActions(data.data, "fogDrawActionIndex", "fogDrawActions");
}
if (data.id === "mapFogIndex") {
- setMapState((prevMapState) => ({
+ setCurrentMapState((prevMapState) => ({
...prevMapState,
fogDrawActionIndex: data.data,
}));
@@ -438,30 +506,19 @@ function Game() {
}
}, [stream, peers, handleStreamEnd]);
- /**
- * Token data
- */
- const [tokens, setTokens] = useState([]);
- useEffect(() => {
- if (!userId) {
- return;
- }
- const defaultTokensWithIds = [];
- for (let defaultToken of defaultTokens) {
- defaultTokensWithIds.push({
- ...defaultToken,
- id: `__default-${defaultToken.name}`,
- owner: userId,
- });
- }
- setTokens(defaultTokensWithIds);
- }, [userId]);
+ // A ref to the Konva stage
+ // the ref will be assigned in the MapInteraction component
+ const mapStageRef = useRef();
return (
- <>
+
-
+
setPeerError(null)}>
@@ -508,7 +560,7 @@ function Game() {
{authenticationStatus === "unknown" && }
- >
+
);
}
diff --git a/src/routes/ReleaseNotes.js b/src/routes/ReleaseNotes.js
index 095f472..1bd12ab 100644
--- a/src/routes/ReleaseNotes.js
+++ b/src/routes/ReleaseNotes.js
@@ -7,6 +7,7 @@ import Markdown from "../components/Markdown";
const v110 = raw("../docs/releaseNotes/v1.1.0.md");
const v120 = raw("../docs/releaseNotes/v1.2.0.md");
+const v121 = raw("../docs/releaseNotes/v1.2.1.md");
function ReleaseNotes() {
return (
@@ -29,6 +30,9 @@ function ReleaseNotes() {
Release Notes
+
+
+
diff --git a/src/tokens/Aberration.png b/src/tokens/Aberration.png
new file mode 100644
index 0000000..ecda159
Binary files /dev/null and b/src/tokens/Aberration.png differ
diff --git a/src/tokens/Artificer.png b/src/tokens/Artificer.png
new file mode 100644
index 0000000..f4361d0
Binary files /dev/null and b/src/tokens/Artificer.png differ
diff --git a/src/tokens/Axes.png b/src/tokens/Axes.png
deleted file mode 100644
index 7bd7f64..0000000
Binary files a/src/tokens/Axes.png and /dev/null differ
diff --git a/src/tokens/Barbarian.png b/src/tokens/Barbarian.png
new file mode 100644
index 0000000..c26cfef
Binary files /dev/null and b/src/tokens/Barbarian.png differ
diff --git a/src/tokens/Bard.png b/src/tokens/Bard.png
new file mode 100644
index 0000000..39da152
Binary files /dev/null and b/src/tokens/Bard.png differ
diff --git a/src/tokens/Beast.png b/src/tokens/Beast.png
new file mode 100644
index 0000000..b4b901d
Binary files /dev/null and b/src/tokens/Beast.png differ
diff --git a/src/tokens/Bird.png b/src/tokens/Bird.png
deleted file mode 100644
index 9473c7b..0000000
Binary files a/src/tokens/Bird.png and /dev/null differ
diff --git a/src/tokens/Blood Hunter.png b/src/tokens/Blood Hunter.png
new file mode 100644
index 0000000..fa708a5
Binary files /dev/null and b/src/tokens/Blood Hunter.png differ
diff --git a/src/tokens/Book.png b/src/tokens/Book.png
deleted file mode 100644
index 944a2c5..0000000
Binary files a/src/tokens/Book.png and /dev/null differ
diff --git a/src/tokens/Celestial.png b/src/tokens/Celestial.png
new file mode 100644
index 0000000..eae7a0a
Binary files /dev/null and b/src/tokens/Celestial.png differ
diff --git a/src/tokens/Cleric.png b/src/tokens/Cleric.png
new file mode 100644
index 0000000..ef668bd
Binary files /dev/null and b/src/tokens/Cleric.png differ
diff --git a/src/tokens/Construct.png b/src/tokens/Construct.png
new file mode 100644
index 0000000..006a5ac
Binary files /dev/null and b/src/tokens/Construct.png differ
diff --git a/src/tokens/Crown.png b/src/tokens/Crown.png
deleted file mode 100644
index 9158249..0000000
Binary files a/src/tokens/Crown.png and /dev/null differ
diff --git a/src/tokens/Dragon.png b/src/tokens/Dragon.png
index 2ec5e83..5863bfc 100644
Binary files a/src/tokens/Dragon.png and b/src/tokens/Dragon.png differ
diff --git a/src/tokens/Druid.png b/src/tokens/Druid.png
new file mode 100644
index 0000000..db41ad4
Binary files /dev/null and b/src/tokens/Druid.png differ
diff --git a/src/tokens/Elemental.png b/src/tokens/Elemental.png
new file mode 100644
index 0000000..05d79be
Binary files /dev/null and b/src/tokens/Elemental.png differ
diff --git a/src/tokens/Eye.png b/src/tokens/Eye.png
deleted file mode 100644
index 45219b0..0000000
Binary files a/src/tokens/Eye.png and /dev/null differ
diff --git a/src/tokens/Fey.png b/src/tokens/Fey.png
new file mode 100644
index 0000000..e0da46d
Binary files /dev/null and b/src/tokens/Fey.png differ
diff --git a/src/tokens/Fiend.png b/src/tokens/Fiend.png
new file mode 100644
index 0000000..78be9a3
Binary files /dev/null and b/src/tokens/Fiend.png differ
diff --git a/src/tokens/Fighter.png b/src/tokens/Fighter.png
new file mode 100644
index 0000000..b647532
Binary files /dev/null and b/src/tokens/Fighter.png differ
diff --git a/src/tokens/Fist.png b/src/tokens/Fist.png
deleted file mode 100644
index 0a2abc9..0000000
Binary files a/src/tokens/Fist.png and /dev/null differ
diff --git a/src/tokens/Giant.png b/src/tokens/Giant.png
new file mode 100644
index 0000000..f36eb36
Binary files /dev/null and b/src/tokens/Giant.png differ
diff --git a/src/tokens/Goblinoid.png b/src/tokens/Goblinoid.png
new file mode 100644
index 0000000..54a3fbb
Binary files /dev/null and b/src/tokens/Goblinoid.png differ
diff --git a/src/tokens/Horse.png b/src/tokens/Horse.png
deleted file mode 100644
index b43e98f..0000000
Binary files a/src/tokens/Horse.png and /dev/null differ
diff --git a/src/tokens/Humanoid.png b/src/tokens/Humanoid.png
new file mode 100644
index 0000000..112f556
Binary files /dev/null and b/src/tokens/Humanoid.png differ
diff --git a/src/tokens/Leaf.png b/src/tokens/Leaf.png
deleted file mode 100644
index 63b4ae0..0000000
Binary files a/src/tokens/Leaf.png and /dev/null differ
diff --git a/src/tokens/Lion.png b/src/tokens/Lion.png
deleted file mode 100644
index de3f2c1..0000000
Binary files a/src/tokens/Lion.png and /dev/null differ
diff --git a/src/tokens/Money.png b/src/tokens/Money.png
deleted file mode 100644
index c7fc359..0000000
Binary files a/src/tokens/Money.png and /dev/null differ
diff --git a/src/tokens/Monk.png b/src/tokens/Monk.png
new file mode 100644
index 0000000..c04998e
Binary files /dev/null and b/src/tokens/Monk.png differ
diff --git a/src/tokens/Monstrosity.png b/src/tokens/Monstrosity.png
new file mode 100644
index 0000000..c05d13b
Binary files /dev/null and b/src/tokens/Monstrosity.png differ
diff --git a/src/tokens/Moon.png b/src/tokens/Moon.png
deleted file mode 100644
index 91ee3a8..0000000
Binary files a/src/tokens/Moon.png and /dev/null differ
diff --git a/src/tokens/Ooze.png b/src/tokens/Ooze.png
new file mode 100644
index 0000000..10551a1
Binary files /dev/null and b/src/tokens/Ooze.png differ
diff --git a/src/tokens/Paladin.png b/src/tokens/Paladin.png
new file mode 100644
index 0000000..4d05148
Binary files /dev/null and b/src/tokens/Paladin.png differ
diff --git a/src/tokens/Plant.png b/src/tokens/Plant.png
new file mode 100644
index 0000000..d163956
Binary files /dev/null and b/src/tokens/Plant.png differ
diff --git a/src/tokens/Potion.png b/src/tokens/Potion.png
deleted file mode 100644
index 056dd28..0000000
Binary files a/src/tokens/Potion.png and /dev/null differ
diff --git a/src/tokens/Ranger.png b/src/tokens/Ranger.png
new file mode 100644
index 0000000..4ccd1c7
Binary files /dev/null and b/src/tokens/Ranger.png differ
diff --git a/src/tokens/Rouge.png b/src/tokens/Rouge.png
new file mode 100644
index 0000000..25ccf4f
Binary files /dev/null and b/src/tokens/Rouge.png differ
diff --git a/src/tokens/Shapechanger.png b/src/tokens/Shapechanger.png
new file mode 100644
index 0000000..3ac8666
Binary files /dev/null and b/src/tokens/Shapechanger.png differ
diff --git a/src/tokens/Shield.png b/src/tokens/Shield.png
deleted file mode 100644
index e4eba5a..0000000
Binary files a/src/tokens/Shield.png and /dev/null differ
diff --git a/src/tokens/Skull.png b/src/tokens/Skull.png
deleted file mode 100644
index 3939468..0000000
Binary files a/src/tokens/Skull.png and /dev/null differ
diff --git a/src/tokens/Snake.png b/src/tokens/Snake.png
deleted file mode 100644
index 4a12bc8..0000000
Binary files a/src/tokens/Snake.png and /dev/null differ
diff --git a/src/tokens/Sorcerer.png b/src/tokens/Sorcerer.png
new file mode 100644
index 0000000..f53b5e3
Binary files /dev/null and b/src/tokens/Sorcerer.png differ
diff --git a/src/tokens/Sun.png b/src/tokens/Sun.png
deleted file mode 100644
index 093c458..0000000
Binary files a/src/tokens/Sun.png and /dev/null differ
diff --git a/src/tokens/Swords.png b/src/tokens/Swords.png
deleted file mode 100644
index 0483427..0000000
Binary files a/src/tokens/Swords.png and /dev/null differ
diff --git a/src/tokens/Titan.png b/src/tokens/Titan.png
new file mode 100644
index 0000000..8b00c9e
Binary files /dev/null and b/src/tokens/Titan.png differ
diff --git a/src/tokens/Tree.png b/src/tokens/Tree.png
deleted file mode 100644
index 20373a1..0000000
Binary files a/src/tokens/Tree.png and /dev/null differ
diff --git a/src/tokens/Triangle.png b/src/tokens/Triangle.png
deleted file mode 100644
index 753f889..0000000
Binary files a/src/tokens/Triangle.png and /dev/null differ
diff --git a/src/tokens/Undead.png b/src/tokens/Undead.png
new file mode 100644
index 0000000..37c494a
Binary files /dev/null and b/src/tokens/Undead.png differ
diff --git a/src/tokens/Unknown.png b/src/tokens/Unknown.png
new file mode 100644
index 0000000..8987eed
Binary files /dev/null and b/src/tokens/Unknown.png differ
diff --git a/src/tokens/Warlock.png b/src/tokens/Warlock.png
new file mode 100644
index 0000000..f522b64
Binary files /dev/null and b/src/tokens/Warlock.png differ
diff --git a/src/tokens/Wizard.png b/src/tokens/Wizard.png
new file mode 100644
index 0000000..93f5970
Binary files /dev/null and b/src/tokens/Wizard.png differ
diff --git a/src/tokens/index.js b/src/tokens/index.js
index 1132688..88c8881 100644
--- a/src/tokens/index.js
+++ b/src/tokens/index.js
@@ -1,49 +1,91 @@
-import axes from "./Axes.png";
-import bird from "./Bird.png";
-import book from "./Book.png";
-import crown from "./Crown.png";
+import Case from "case";
+
+import aberration from "./Aberration.png";
+import artificer from "./Artificer.png";
+import barbarian from "./Barbarian.png";
+import bard from "./Bard.png";
+import beast from "./Beast.png";
+import bloodHunter from "./Blood Hunter.png";
+import celestial from "./Celestial.png";
+import cleric from "./Cleric.png";
+import construct from "./Construct.png";
import dragon from "./Dragon.png";
-import eye from "./Eye.png";
-import fist from "./Fist.png";
-import horse from "./Horse.png";
-import leaf from "./Leaf.png";
-import lion from "./Lion.png";
-import money from "./Money.png";
-import moon from "./Moon.png";
-import potion from "./Potion.png";
-import shield from "./Shield.png";
-import skull from "./Skull.png";
-import snake from "./Snake.png";
-import sun from "./Sun.png";
-import swords from "./Swords.png";
-import tree from "./Tree.png";
-import triangle from "./Triangle.png";
+import druid from "./Druid.png";
+import elemental from "./Elemental.png";
+import fey from "./Fey.png";
+import fiend from "./Fiend.png";
+import fighter from "./Fighter.png";
+import giant from "./Giant.png";
+import goblinoid from "./Goblinoid.png";
+import humanoid from "./Humanoid.png";
+import monk from "./Monk.png";
+import monstrosity from "./Monstrosity.png";
+import ooze from "./Ooze.png";
+import paladin from "./Paladin.png";
+import plant from "./Plant.png";
+import ranger from "./Ranger.png";
+import rouge from "./Rouge.png";
+import shapechanger from "./Shapechanger.png";
+import sorcerer from "./Sorcerer.png";
+import titan from "./Titan.png";
+import undead from "./Undead.png";
+import warlock from "./Warlock.png";
+import wizard from "./Wizard.png";
+import unknown from "./Unknown.png";
export const tokenSources = {
- axes,
- bird,
- book,
- crown,
+ barbarian,
+ bard,
+ cleric,
+ druid,
+ fighter,
+ monk,
+ paladin,
+ ranger,
+ rouge,
+ sorcerer,
+ warlock,
+ wizard,
+ artificer,
+ bloodHunter,
+ aberration,
+ beast,
+ celestial,
+ construct,
dragon,
- eye,
- fist,
- horse,
- leaf,
- lion,
- money,
- moon,
- potion,
- shield,
- skull,
- snake,
- sun,
- swords,
- tree,
- triangle,
+ elemental,
+ fey,
+ fiend,
+ giant,
+ goblinoid,
+ humanoid,
+ monstrosity,
+ ooze,
+ plant,
+ shapechanger,
+ titan,
+ undead,
};
+function getDefaultTokenSize(key) {
+ switch (key) {
+ case "dragon":
+ case "elemental":
+ case "giant":
+ case "ooze":
+ case "titan":
+ return 2;
+ default:
+ return 1;
+ }
+}
+
export const tokens = Object.keys(tokenSources).map((key) => ({
key,
- name: key.charAt(0).toUpperCase() + key.slice(1),
+ name: Case.capital(key),
type: "default",
+ defaultSize: getDefaultTokenSize(key),
+ isVehicle: false,
}));
+
+export const unknownSource = unknown;
diff --git a/yarn.lock b/yarn.lock
index 2db3f6f..df494c8 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -863,6 +863,13 @@
dependencies:
regenerator-runtime "^0.13.4"
+"@babel/runtime@^7.3.1":
+ version "7.9.6"
+ resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.9.6.tgz#a9102eb5cadedf3f31d08a9ecf294af7827ea29f"
+ integrity sha512-64AF1xY3OAkFHqOb9s4jpgk1Mm5vDZ4L3acHvAml+53nO1XbXLuDodsVpO4OIUsmemlUHMxNdYMNJmsvOwLrvQ==
+ dependencies:
+ regenerator-runtime "^0.13.4"
+
"@babel/runtime@^7.5.1", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.4", "@babel/runtime@^7.8.4":
version "7.8.7"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.8.7.tgz#8fefce9802db54881ba59f90bb28719b4996324d"
@@ -3111,6 +3118,11 @@ case-sensitive-paths-webpack-plugin@2.3.0:
resolved "https://registry.yarnpkg.com/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.3.0.tgz#23ac613cc9a856e4f88ff8bb73bbb5e989825cf7"
integrity sha512-/4YgnZS8y1UXXmC02xD5rRrBEu6T5ub+mQHLNRj0fzTRbgdBYhsNo2V5EqwgqrExjxsjtF/OpAKAMkKsxbD5XQ==
+case@^1.6.3:
+ version "1.6.3"
+ resolved "https://registry.yarnpkg.com/case/-/case-1.6.3.tgz#0a4386e3e9825351ca2e6216c60467ff5f1ea1c9"
+ integrity sha512-mzDSXIPaFwVDvZAHqZ9VlbyF4yyXRuX6IvB06WvPYkqJVO24kX1PPhv9bfpKNFZyxYFmmgo03HUiD8iklmJYRQ==
+
caseless@~0.12.0:
version "0.12.0"
resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
@@ -6890,6 +6902,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"
@@ -7010,6 +7027,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"
@@ -8988,7 +9010,7 @@ prompts@^2.0.1:
kleur "^3.0.3"
sisteransi "^1.0.3"
-prop-types@^15.5.10, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2:
+prop-types@^15.5.10, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2:
version "15.7.2"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==
@@ -9110,6 +9132,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"
@@ -9231,6 +9258,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"
@@ -9260,6 +9295,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"
@@ -9356,6 +9412,19 @@ react-scripts@3.4.0:
optionalDependencies:
fsevents "2.1.2"
+react-spring@^8.0.27:
+ version "8.0.27"
+ resolved "https://registry.yarnpkg.com/react-spring/-/react-spring-8.0.27.tgz#97d4dee677f41e0b2adcb696f3839680a3aa356a"
+ integrity sha512-nDpWBe3ZVezukNRandTeLSPcwwTMjNVu1IDq9qA/AMiUqHuRN4BeSWvKr3eIxxg1vtiYiOLy4FqdfCP5IoP77g==
+ dependencies:
+ "@babel/runtime" "^7.3.1"
+ prop-types "^15.5.8"
+
+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"
@@ -9913,6 +9982,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"
@@ -11193,6 +11270,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"