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({ @@ -115,7 +109,7 @@ function MapSettings({ }} aria-label={showMore ? "Show Less" : "Show More"} title={showMore ? "Show Less" : "Show More"} - disabled={map === null} + disabled={!map} > diff --git a/src/components/map/MapTile.js b/src/components/map/MapTile.js index 38c4989..640b8aa 100644 --- a/src/components/map/MapTile.js +++ b/src/components/map/MapTile.js @@ -6,7 +6,7 @@ import ResetMapIcon from "../../icons/ResetMapIcon"; import ExpandMoreDotIcon from "../../icons/ExpandMoreDotIcon"; import useDataSource from "../../helpers/useDataSource"; -import { mapSources as defaultMapSources } from "../../maps"; +import { mapSources as defaultMapSources, unknownSource } from "../../maps"; function MapTile({ map, @@ -15,9 +15,9 @@ function MapTile({ onMapSelect, onMapRemove, onMapReset, - onSubmit, + onDone, }) { - const mapSource = useDataSource(map, defaultMapSources); + const mapSource = useDataSource(map, defaultMapSources, unknownSource); const [isMapTileMenuOpen, setIsTileMenuOpen] = useState(false); const isDefault = map.type === "default"; const hasMapState = @@ -108,7 +108,7 @@ function MapTile({ }} onDoubleClick={(e) => { if (!isMapTileMenuOpen) { - onSubmit(e); + onDone(e); } }} > diff --git a/src/components/map/MapTiles.js b/src/components/map/MapTiles.js index 4ebc275..bae2a30 100644 --- a/src/components/map/MapTiles.js +++ b/src/components/map/MapTiles.js @@ -17,7 +17,7 @@ function MapTiles({ onMapAdd, onMapRemove, onMapReset, - onSubmit, + onDone, }) { const { databaseStatus } = useContext(DatabaseContext); return ( @@ -69,7 +69,7 @@ function MapTiles({ onMapSelect={onMapSelect} onMapRemove={onMapRemove} onMapReset={onMapReset} - onSubmit={onSubmit} + onDone={onDone} /> ))} 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 ( - - - - - - {label} - - - - + + + + ); } 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 - - {dragging && ( - { - e.preventDefault(); - e.stopPropagation(); - e.dataTransfer.dropEffect = "copy"; - }} - onDrop={handleImageDrop} - > - Drop map to upload - - )} - + ); } 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} + /> + + + + + + + + + ); +} + +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"