Merge branch 'release/v1.3.0' into feature/dice
@ -1,2 +1,2 @@
|
||||
REACT_APP_BROKER_URL=https://agent.owlbear.rodeo
|
||||
REACT_APP_ICE_SERVERS_URL=https://agent.owlbear.rodeo/iceservers
|
||||
REACT_APP_BROKER_URL=https://agent.owlbear.dev
|
||||
REACT_APP_ICE_SERVERS_URL=https://agent.owlbear.dev/iceservers
|
@ -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": {
|
||||
|
11
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() {
|
||||
<FAQ />
|
||||
</Route>
|
||||
<Route path="/game/:id">
|
||||
<Game />
|
||||
<MapLoadingProvider>
|
||||
<MapDataProvider>
|
||||
<TokenDataProvider>
|
||||
<Game />
|
||||
</TokenDataProvider>
|
||||
</MapDataProvider>
|
||||
</MapLoadingProvider>
|
||||
</Route>
|
||||
<Route path="/">
|
||||
<Home />
|
||||
|
61
src/components/ImageDrop.js
Normal file
@ -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 (
|
||||
<Box onDragEnter={handleImageDragEnter}>
|
||||
{children}
|
||||
{dragging && (
|
||||
<Flex
|
||||
bg="overlay"
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
cursor: "copy",
|
||||
}}
|
||||
onDragLeave={handleImageDragLeave}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}}
|
||||
onDrop={handleImageDrop}
|
||||
>
|
||||
<Text sx={{ pointerEvents: "none" }}>
|
||||
{dropText || "Drop image to upload"}
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImageDrop;
|
@ -7,7 +7,6 @@ function Paragraph(props) {
|
||||
}
|
||||
|
||||
function Heading({ level, ...props }) {
|
||||
console.log(props);
|
||||
const fontSize = level === 1 ? 5 : level === 2 ? 3 : 1;
|
||||
return (
|
||||
<Text
|
||||
|
@ -1,28 +1,24 @@
|
||||
import React, { useRef, useEffect, useState } from "react";
|
||||
import { Box, Image } from "theme-ui";
|
||||
import React, { useState, useContext, useEffect } from "react";
|
||||
|
||||
import ProxyToken from "../token/ProxyToken";
|
||||
import TokenMenu from "../token/TokenMenu";
|
||||
import MapControls from "./MapControls";
|
||||
import MapInteraction from "./MapInteraction";
|
||||
import MapToken from "./MapToken";
|
||||
import MapDrawing from "./MapDrawing";
|
||||
import MapFog from "./MapFog";
|
||||
import MapControls from "./MapControls";
|
||||
import MapDice from "./MapDice";
|
||||
|
||||
import TokenDataContext from "../../contexts/TokenDataContext";
|
||||
import MapLoadingContext from "../../contexts/MapLoadingContext";
|
||||
|
||||
import TokenMenu from "../token/TokenMenu";
|
||||
import TokenDragOverlay from "../token/TokenDragOverlay";
|
||||
import LoadingOverlay from "../LoadingOverlay";
|
||||
|
||||
import { omit } from "../../helpers/shared";
|
||||
import useDataSource from "../../helpers/useDataSource";
|
||||
import MapInteraction from "./MapInteraction";
|
||||
|
||||
import { mapSources as defaultMapSources } from "../../maps";
|
||||
|
||||
const mapTokenProxyClassName = "map-token__proxy";
|
||||
const mapTokenMenuClassName = "map-token__menu";
|
||||
|
||||
function Map({
|
||||
map,
|
||||
mapState,
|
||||
tokens,
|
||||
onMapTokenStateChange,
|
||||
onMapTokenStateRemove,
|
||||
onMapChange,
|
||||
@ -36,23 +32,17 @@ function Map({
|
||||
allowMapDrawing,
|
||||
allowFogDrawing,
|
||||
disabledTokens,
|
||||
loading,
|
||||
}) {
|
||||
const mapSource = useDataSource(map, defaultMapSources);
|
||||
const { tokensById } = useContext(TokenDataContext);
|
||||
const { isLoading } = useContext(MapLoadingContext);
|
||||
|
||||
function handleProxyDragEnd(isOnMap, tokenState) {
|
||||
if (isOnMap && onMapTokenStateChange) {
|
||||
onMapTokenStateChange(tokenState);
|
||||
}
|
||||
|
||||
if (!isOnMap && onMapTokenStateRemove) {
|
||||
onMapTokenStateRemove(tokenState);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map drawing
|
||||
*/
|
||||
const gridX = map && map.gridX;
|
||||
const gridY = map && map.gridY;
|
||||
const gridSizeNormalized = {
|
||||
x: gridX ? 1 / gridX : 0,
|
||||
y: gridY ? 1 / gridY : 0,
|
||||
};
|
||||
const tokenSizePercent = gridSizeNormalized.x;
|
||||
|
||||
const [selectedToolId, setSelectedToolId] = useState("pan");
|
||||
const [toolSettings, setToolSettings] = useState({
|
||||
@ -102,6 +92,7 @@ function Map({
|
||||
}
|
||||
|
||||
const [mapShapes, setMapShapes] = useState([]);
|
||||
|
||||
function handleMapShapeAdd(shape) {
|
||||
onMapDraw({ type: "add", shapes: [shape] });
|
||||
}
|
||||
@ -111,6 +102,7 @@ function Map({
|
||||
}
|
||||
|
||||
const [fogShapes, setFogShapes] = useState([]);
|
||||
|
||||
function handleFogShapeAdd(shape) {
|
||||
onFogDraw({ type: "add", shapes: [shape] });
|
||||
}
|
||||
@ -192,97 +184,12 @@ function Map({
|
||||
disabledSettings.fog.push("redo");
|
||||
}
|
||||
|
||||
/**
|
||||
* Member setup
|
||||
*/
|
||||
|
||||
const mapRef = useRef(null);
|
||||
|
||||
const gridX = map && map.gridX;
|
||||
const gridY = map && map.gridY;
|
||||
const gridSizeNormalized = { x: 1 / gridX || 0, y: 1 / gridY || 0 };
|
||||
const tokenSizePercent = gridSizeNormalized.x * 100;
|
||||
const aspectRatio = (map && map.width / map.height) || 1;
|
||||
|
||||
const mapImage = (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
ref={mapRef}
|
||||
className="mapImage"
|
||||
sx={{
|
||||
width: "100%",
|
||||
userSelect: "none",
|
||||
touchAction: "none",
|
||||
}}
|
||||
src={mapSource}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const mapTokens = (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{mapState &&
|
||||
Object.values(mapState.tokens).map((tokenState) => (
|
||||
<MapToken
|
||||
key={tokenState.id}
|
||||
token={tokens.find((token) => token.id === tokenState.tokenId)}
|
||||
tokenState={tokenState}
|
||||
tokenSizePercent={tokenSizePercent}
|
||||
className={`${mapTokenProxyClassName} ${mapTokenMenuClassName}`}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const mapDrawing = (
|
||||
<MapDrawing
|
||||
width={map ? map.width : 0}
|
||||
height={map ? map.height : 0}
|
||||
selectedTool={selectedToolId !== "fog" ? selectedToolId : "none"}
|
||||
toolSettings={toolSettings[selectedToolId]}
|
||||
shapes={mapShapes}
|
||||
onShapeAdd={handleMapShapeAdd}
|
||||
onShapeRemove={handleMapShapeRemove}
|
||||
gridSize={gridSizeNormalized}
|
||||
/>
|
||||
);
|
||||
|
||||
const mapFog = (
|
||||
<MapFog
|
||||
width={map ? map.width : 0}
|
||||
height={map ? map.height : 0}
|
||||
isEditing={selectedToolId === "fog"}
|
||||
toolSettings={toolSettings["fog"]}
|
||||
shapes={fogShapes}
|
||||
onShapeAdd={handleFogShapeAdd}
|
||||
onShapeRemove={handleFogShapeRemove}
|
||||
onShapeEdit={handleFogShapeEdit}
|
||||
gridSize={gridSizeNormalized}
|
||||
/>
|
||||
);
|
||||
|
||||
const mapControls = (
|
||||
<MapControls
|
||||
onMapChange={onMapChange}
|
||||
onMapStateChange={onMapStateChange}
|
||||
currentMap={map}
|
||||
currentMapState={mapState}
|
||||
onSelectedToolChange={setSelectedToolId}
|
||||
selectedToolId={selectedToolId}
|
||||
toolSettings={toolSettings}
|
||||
@ -292,38 +199,119 @@ function Map({
|
||||
disabledSettings={disabledSettings}
|
||||
/>
|
||||
);
|
||||
|
||||
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) => (
|
||||
<MapToken
|
||||
key={tokenState.id}
|
||||
token={tokensById[tokenState.tokenId]}
|
||||
tokenState={tokenState}
|
||||
tokenSizePercent={tokenSizePercent}
|
||||
onTokenStateChange={onMapTokenStateChange}
|
||||
onTokenMenuOpen={handleTokenMenuOpen}
|
||||
onTokenDragStart={(e) =>
|
||||
setDraggingTokenOptions({ tokenState, tokenImage: e.target })
|
||||
}
|
||||
onTokenDragEnd={() => setDraggingTokenOptions(null)}
|
||||
draggable={
|
||||
(selectedToolId === "pan" || selectedToolId === "erase") &&
|
||||
!(tokenState.id in disabledTokens)
|
||||
}
|
||||
mapState={mapState}
|
||||
/>
|
||||
));
|
||||
|
||||
const tokenMenu = (
|
||||
<TokenMenu
|
||||
isOpen={isTokenMenuOpen}
|
||||
onRequestClose={() => setIsTokenMenuOpen(false)}
|
||||
onTokenStateChange={onMapTokenStateChange}
|
||||
tokenState={mapState && mapState.tokens[tokenMenuOptions.tokenStateId]}
|
||||
tokenImage={tokenMenuOptions.tokenImage}
|
||||
/>
|
||||
);
|
||||
|
||||
const tokenDragOverlay = draggingTokenOptions && (
|
||||
<TokenDragOverlay
|
||||
onTokenStateRemove={(state) => {
|
||||
onMapTokenStateRemove(state);
|
||||
setDraggingTokenOptions(null);
|
||||
}}
|
||||
onTokenStateChange={onMapTokenStateChange}
|
||||
tokenState={draggingTokenOptions && draggingTokenOptions.tokenState}
|
||||
tokenImage={draggingTokenOptions && draggingTokenOptions.tokenImage}
|
||||
token={tokensById[draggingTokenOptions.tokenState.tokenId]}
|
||||
mapState={mapState}
|
||||
/>
|
||||
);
|
||||
|
||||
const mapDrawing = (
|
||||
<MapDrawing
|
||||
shapes={mapShapes}
|
||||
onShapeAdd={handleMapShapeAdd}
|
||||
onShapeRemove={handleMapShapeRemove}
|
||||
selectedToolId={selectedToolId}
|
||||
selectedToolSettings={toolSettings[selectedToolId]}
|
||||
gridSize={gridSizeNormalized}
|
||||
/>
|
||||
);
|
||||
|
||||
const mapFog = (
|
||||
<MapFog
|
||||
shapes={fogShapes}
|
||||
onShapeAdd={handleFogShapeAdd}
|
||||
onShapeRemove={handleFogShapeRemove}
|
||||
onShapeEdit={handleFogShapeEdit}
|
||||
selectedToolId={selectedToolId}
|
||||
selectedToolSettings={toolSettings[selectedToolId]}
|
||||
gridSize={gridSizeNormalized}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MapInteraction
|
||||
map={map}
|
||||
aspectRatio={aspectRatio}
|
||||
isEnabled={selectedToolId === "pan"}
|
||||
sideContent={
|
||||
<>
|
||||
<MapDice />
|
||||
{mapControls}
|
||||
{loading && <LoadingOverlay />}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{map && mapImage}
|
||||
{map && mapDrawing}
|
||||
{map && mapFog}
|
||||
{map && mapTokens}
|
||||
</MapInteraction>
|
||||
<ProxyToken
|
||||
tokenClassName={mapTokenProxyClassName}
|
||||
onProxyDragEnd={handleProxyDragEnd}
|
||||
tokens={mapState && mapState.tokens}
|
||||
disabledTokens={disabledTokens}
|
||||
/>
|
||||
<TokenMenu
|
||||
tokenClassName={mapTokenMenuClassName}
|
||||
onTokenChange={onMapTokenStateChange}
|
||||
tokens={mapState && mapState.tokens}
|
||||
disabledTokens={disabledTokens}
|
||||
/>
|
||||
</>
|
||||
<MapInteraction
|
||||
map={map}
|
||||
controls={
|
||||
<>
|
||||
{mapControls}
|
||||
{tokenMenu}
|
||||
{tokenDragOverlay}
|
||||
<MapDice />
|
||||
{isLoading && <LoadingOverlay />}
|
||||
</>
|
||||
}
|
||||
selectedToolId={selectedToolId}
|
||||
>
|
||||
{mapDrawing}
|
||||
{mapTokens}
|
||||
{mapFog}
|
||||
</MapInteraction>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
@ -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 (
|
||||
<Line
|
||||
points={shape.data.points.reduce(
|
||||
(acc, point) => [...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 (
|
||||
<Rect
|
||||
x={shape.data.x * mapWidth}
|
||||
y={shape.data.y * mapHeight}
|
||||
width={shape.data.width * mapWidth}
|
||||
height={shape.data.height * mapHeight}
|
||||
{...defaultProps}
|
||||
/>
|
||||
);
|
||||
} else if (shape.shapeType === "circle") {
|
||||
const minSide = mapWidth < mapHeight ? mapWidth : mapHeight;
|
||||
return (
|
||||
<Circle
|
||||
x={shape.data.x * mapWidth}
|
||||
y={shape.data.y * mapHeight}
|
||||
radius={shape.data.radius * minSide}
|
||||
{...defaultProps}
|
||||
/>
|
||||
);
|
||||
} else if (shape.shapeType === "triangle") {
|
||||
return (
|
||||
<Line
|
||||
points={shape.data.points.reduce(
|
||||
(acc, point) => [...acc, point.x * mapWidth, point.y * mapHeight],
|
||||
[]
|
||||
)}
|
||||
closed={true}
|
||||
{...defaultProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
ref={containerRef}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={width}
|
||||
height={height}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
<Group>
|
||||
{shapes.map(renderShape)}
|
||||
{drawingShape && renderShape(drawingShape)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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 (
|
||||
<Line
|
||||
key={shape.id}
|
||||
onMouseOver={(e) => 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 (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
ref={containerRef}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={width}
|
||||
height={height}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
<Group>
|
||||
{shapes.map(renderShape)}
|
||||
{drawingShape && renderShape(drawingShape)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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 (
|
||||
<Box
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.1)",
|
||||
userSelect: "none",
|
||||
cursor: getCursorForTool(selectedToolId),
|
||||
touchAction: "none",
|
||||
}}
|
||||
bg="background"
|
||||
ref={containerRef}
|
||||
{...bind()}
|
||||
className="map"
|
||||
>
|
||||
<Box
|
||||
className="map"
|
||||
sx={{
|
||||
position: "relative",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
}}
|
||||
ref={mapContainerRef}
|
||||
>
|
||||
<Box ref={mapMoveContainerRef}>
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: 0,
|
||||
paddingBottom: `${(1 / aspectRatio) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
<MapInteractionProvider
|
||||
value={{
|
||||
translateRef: mapTranslateRef,
|
||||
scaleRef: mapScaleRef,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MapInteractionProvider>
|
||||
</Box>
|
||||
</Box>
|
||||
{sideContent}
|
||||
<ReactResizeDetector handleWidth handleHeight onResize={handleResize}>
|
||||
<Stage
|
||||
width={stageWidth}
|
||||
height={stageHeight}
|
||||
scale={{ x: stageScale, y: stageScale }}
|
||||
x={stageWidth / 2}
|
||||
y={stageHeight / 2}
|
||||
offset={{ x: stageWidth / 2, y: stageHeight / 2 }}
|
||||
ref={mapStageRef}
|
||||
>
|
||||
<Layer ref={mapLayerRef}>
|
||||
<Image
|
||||
image={mapSourceImage}
|
||||
width={mapWidth}
|
||||
height={mapHeight}
|
||||
id="mapImage"
|
||||
ref={mapImageRef}
|
||||
/>
|
||||
{/* Forward auth context to konva elements */}
|
||||
<AuthContext.Provider value={auth}>
|
||||
<MapInteractionProvider value={mapInteraction}>
|
||||
{children}
|
||||
</MapInteractionProvider>
|
||||
</AuthContext.Provider>
|
||||
</Layer>
|
||||
</Stage>
|
||||
</ReactResizeDetector>
|
||||
<MapInteractionProvider value={mapInteraction}>
|
||||
{controls}
|
||||
</MapInteractionProvider>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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({
|
||||
<Flex my={1}>
|
||||
<Label>
|
||||
<Checkbox
|
||||
checked={
|
||||
mapState !== null && mapState.editFlags.includes("fog")
|
||||
}
|
||||
disabled={mapState === null}
|
||||
checked={mapState && mapState.editFlags.includes("fog")}
|
||||
disabled={!mapState}
|
||||
onChange={(e) => handleFlagChange(e, "fog")}
|
||||
/>
|
||||
Fog
|
||||
</Label>
|
||||
<Label>
|
||||
<Checkbox
|
||||
checked={
|
||||
mapState !== null && mapState.editFlags.includes("drawing")
|
||||
}
|
||||
checked={mapState && mapState.editFlags.includes("drawing")}
|
||||
disabled={mapState === null}
|
||||
onChange={(e) => handleFlagChange(e, "drawing")}
|
||||
/>
|
||||
@ -81,10 +77,8 @@ function MapSettings({
|
||||
</Label>
|
||||
<Label>
|
||||
<Checkbox
|
||||
checked={
|
||||
mapState !== null && mapState.editFlags.includes("tokens")
|
||||
}
|
||||
disabled={mapState === null}
|
||||
checked={mapState && mapState.editFlags.includes("tokens")}
|
||||
disabled={!mapState}
|
||||
onChange={(e) => handleFlagChange(e, "tokens")}
|
||||
/>
|
||||
Tokens
|
||||
@ -97,7 +91,7 @@ function MapSettings({
|
||||
name="name"
|
||||
value={(map && map.name) || ""}
|
||||
onChange={(e) => onSettingsChange("name", e.target.value)}
|
||||
disabled={map === null || map.type === "default"}
|
||||
disabled={!map || map.type === "default"}
|
||||
my={1}
|
||||
/>
|
||||
</Box>
|
||||
@ -115,7 +109,7 @@ function MapSettings({
|
||||
}}
|
||||
aria-label={showMore ? "Show Less" : "Show More"}
|
||||
title={showMore ? "Show Less" : "Show More"}
|
||||
disabled={map === null}
|
||||
disabled={!map}
|
||||
>
|
||||
<ExpandMoreIcon />
|
||||
</IconButton>
|
||||
|
@ -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);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
@ -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}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
|
@ -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 (
|
||||
<Box
|
||||
style={{
|
||||
transform: `translate(${tokenState.x * 100}%, ${tokenState.y * 100}%)`,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
<animated.Group
|
||||
{...props}
|
||||
width={tokenWidth}
|
||||
height={tokenHeight}
|
||||
draggable={draggable}
|
||||
onMouseDown={handlePointerDown}
|
||||
onMouseUp={handlePointerUp}
|
||||
onMouseOver={handlePointerOver}
|
||||
onMouseOut={handlePointerOut}
|
||||
onTouchStart={handlePointerDown}
|
||||
onTouchEnd={handlePointerUp}
|
||||
onClick={handleClick}
|
||||
onTap={handleClick}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragStart={handleDragStart}
|
||||
opacity={tokenOpacity}
|
||||
name={token && token.isVehicle ? "vehicle" : "token"}
|
||||
id={tokenState.id}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: `${tokenSizePercent * (tokenState.size || 1)}%`,
|
||||
}}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
pointerEvents: "all",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
display: "flex", // Set display to flex to fix height being calculated wrong
|
||||
width: "100%",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
className={className}
|
||||
sx={{
|
||||
userSelect: "none",
|
||||
touchAction: "none",
|
||||
width: "100%",
|
||||
}}
|
||||
src={imageSource}
|
||||
// pass id into the dom element which is then used by the ProxyToken
|
||||
data-id={tokenState.id}
|
||||
ref={imageRef}
|
||||
/>
|
||||
{tokenState.statuses && (
|
||||
<TokenStatus statuses={tokenState.statuses} />
|
||||
)}
|
||||
{tokenState.label && <TokenLabel label={tokenState.label} />}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<KonvaImage
|
||||
ref={imageRef}
|
||||
width={tokenWidth}
|
||||
height={tokenHeight}
|
||||
x={0}
|
||||
y={0}
|
||||
image={tokenSourceImage}
|
||||
rotation={tokenState.rotation}
|
||||
offsetX={tokenWidth / 2}
|
||||
offsetY={tokenHeight / 2}
|
||||
/>
|
||||
<Group offsetX={tokenWidth / 2} offsetY={tokenHeight / 2}>
|
||||
<TokenStatus
|
||||
tokenState={tokenState}
|
||||
width={tokenWidth}
|
||||
height={tokenHeight}
|
||||
/>
|
||||
<TokenLabel
|
||||
tokenState={tokenState}
|
||||
width={tokenWidth}
|
||||
height={tokenHeight}
|
||||
/>
|
||||
</Group>
|
||||
</animated.Group>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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 }) {
|
||||
<SelectMapIcon />
|
||||
</IconButton>
|
||||
<SelectMapModal
|
||||
isOpen={isAddModalOpen}
|
||||
isOpen={isModalOpen}
|
||||
onRequestClose={closeModal}
|
||||
onDone={handleDone}
|
||||
onMapChange={onMapChange}
|
||||
|
@ -4,10 +4,10 @@ import { Box, Image } from "theme-ui";
|
||||
import usePreventTouch from "../../helpers/usePreventTouch";
|
||||
import useDataSource from "../../helpers/useDataSource";
|
||||
|
||||
import { tokenSources } from "../../tokens";
|
||||
import { tokenSources, unknownSource } from "../../tokens";
|
||||
|
||||
function ListToken({ token, className }) {
|
||||
const imageSource = useDataSource(token, tokenSources);
|
||||
const imageSource = useDataSource(token, tokenSources, unknownSource);
|
||||
|
||||
const imageRef = useRef();
|
||||
// Stop touch to prevent 3d touch gesutre on iOS
|
||||
@ -19,7 +19,13 @@ function ListToken({ token, className }) {
|
||||
src={imageSource}
|
||||
ref={imageRef}
|
||||
className={className}
|
||||
sx={{ userSelect: "none", touchAction: "none" }}
|
||||
sx={{
|
||||
userSelect: "none",
|
||||
touchAction: "none",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
// pass id into the dom element which is then used by the ProxyToken
|
||||
data-id={token.id}
|
||||
/>
|
||||
|
@ -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 && (
|
||||
<TokenStatus statuses={tokens[tokenId].statuses} />
|
||||
)}
|
||||
{tokens[tokenId] && tokens[tokenId].label && (
|
||||
<TokenLabel label={tokens[tokenId].label} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>,
|
||||
proxyContainer
|
||||
@ -189,7 +175,6 @@ function ProxyToken({
|
||||
|
||||
ProxyToken.defaultProps = {
|
||||
tokens: {},
|
||||
disabledTokens: {},
|
||||
};
|
||||
|
||||
export default ProxyToken;
|
||||
|
38
src/components/token/SelectTokensButton.js
Normal file
@ -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 (
|
||||
<>
|
||||
<IconButton
|
||||
aria-label="Edit Tokens"
|
||||
title="Edit Tokens"
|
||||
onClick={openModal}
|
||||
>
|
||||
<SelectTokensIcon />
|
||||
</IconButton>
|
||||
<SelectTokensModal
|
||||
isOpen={isModalOpen}
|
||||
onRequestClose={closeModal}
|
||||
onDone={handleDone}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SelectTokensButton;
|
133
src/components/token/TokenDragOverlay.js
Normal file
@ -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 (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
bottom: "32px",
|
||||
left: "50%",
|
||||
borderRadius: "50%",
|
||||
transform: isRemoveHovered
|
||||
? "translateX(-50%) scale(2.0)"
|
||||
: "translateX(-50%) scale(1.5)",
|
||||
transition: "transform 250ms ease",
|
||||
color: isRemoveHovered ? "primary" : "text",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
bg="overlay"
|
||||
ref={removeTokenRef}
|
||||
>
|
||||
<IconButton>
|
||||
<RemoveTokenIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default TokenDragOverlay;
|
@ -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 (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
transform: "scale(0.3) translate(0, 20%)",
|
||||
transformOrigin: "bottom center",
|
||||
pointerEvents: "none",
|
||||
width: "100%",
|
||||
display: "flex", // Set display to flex to fix height being calculated wrong
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Image sx={{ width: "100%" }} src={tokenLabel} />
|
||||
<svg
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
}}
|
||||
viewBox="0 0 100 100"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<foreignObject width="100%" height="100%">
|
||||
<Text
|
||||
as="p"
|
||||
variant="heading"
|
||||
sx={{
|
||||
// This value is actually 66%
|
||||
fontSize: "66px",
|
||||
width: "100px",
|
||||
height: "100px",
|
||||
textAlign: "center",
|
||||
verticalAlign: "middle",
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
color="hsl(210, 50%, 96%)"
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</foreignObject>
|
||||
</svg>
|
||||
</Box>
|
||||
<Group y={height - (fontSize + paddingY) / 2}>
|
||||
<Rect
|
||||
y={-paddingY / 2}
|
||||
width={rectWidth}
|
||||
offsetX={width / 2}
|
||||
x={width - rectWidth / 2}
|
||||
height={fontSize + paddingY}
|
||||
fill="hsla(230, 25%, 18%, 0.8)"
|
||||
cornerRadius={(fontSize + paddingY) / 2}
|
||||
/>
|
||||
<Text
|
||||
ref={textRef}
|
||||
width={width}
|
||||
text={tokenState.label}
|
||||
fontSize={fontSize}
|
||||
lineHeight={1}
|
||||
align="center"
|
||||
verticalAlign="bottom"
|
||||
fill="white"
|
||||
paddingX={paddingX}
|
||||
paddingY={paddingY}
|
||||
wrap="none"
|
||||
ellipsis={true}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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 (
|
||||
<MapMenu
|
||||
isOpen={isOpen}
|
||||
onRequestClose={handleRequestClose}
|
||||
onRequestClose={onRequestClose}
|
||||
top={`${menuTop}px`}
|
||||
left={`${menuLeft}px`}
|
||||
onModalContent={handleModalContent}
|
||||
>
|
||||
<Box sx={{ width: "104px" }} p={1}>
|
||||
<Box
|
||||
<Box sx={{ width: "156px" }} p={1}>
|
||||
<Flex
|
||||
as="form"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleRequestClose();
|
||||
onRequestClose();
|
||||
}}
|
||||
sx={{ alignItems: "center" }}
|
||||
>
|
||||
<Text
|
||||
as="label"
|
||||
variant="body2"
|
||||
sx={{ width: "45%", fontSize: "16px" }}
|
||||
p={1}
|
||||
>
|
||||
Label:
|
||||
</Text>
|
||||
<Input
|
||||
id="changeTokenLabel"
|
||||
onChange={handleLabelChange}
|
||||
value={`Label: ${currentToken.label}`}
|
||||
value={(tokenState && tokenState.label) || ""}
|
||||
sx={{
|
||||
padding: "4px",
|
||||
border: "none",
|
||||
@ -160,7 +128,7 @@ function TokenMenu({ tokenClassName, onTokenChange, tokens, disabledTokens }) {
|
||||
}}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
@ -172,8 +140,8 @@ function TokenMenu({ tokenClassName, onTokenChange, tokens, disabledTokens }) {
|
||||
<Box
|
||||
key={color}
|
||||
sx={{
|
||||
width: "25%",
|
||||
paddingTop: "25%",
|
||||
width: "16.66%",
|
||||
paddingTop: "16.66%",
|
||||
borderRadius: "50%",
|
||||
transform: "scale(0.75)",
|
||||
backgroundColor: colors[color],
|
||||
@ -182,21 +150,59 @@ function TokenMenu({ tokenClassName, onTokenChange, tokens, disabledTokens }) {
|
||||
onClick={() => handleStatusChange(color)}
|
||||
aria-label={`Token label Color ${color}`}
|
||||
>
|
||||
{currentToken.statuses && currentToken.statuses.includes(color) && (
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
border: "2px solid white",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
borderRadius: "50%",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{tokenState &&
|
||||
tokenState.statuses &&
|
||||
tokenState.statuses.includes(color) && (
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
border: "2px solid white",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
borderRadius: "50%",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Flex sx={{ alignItems: "center" }}>
|
||||
<Text
|
||||
as="label"
|
||||
variant="body2"
|
||||
sx={{ width: "40%", fontSize: "16px" }}
|
||||
p={1}
|
||||
>
|
||||
Size:
|
||||
</Text>
|
||||
<Slider
|
||||
value={(tokenState && tokenState.size) || 1}
|
||||
onChange={handleSizeChange}
|
||||
step={1}
|
||||
min={1}
|
||||
max={tokenMaxSize}
|
||||
mr={1}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex sx={{ alignItems: "center" }}>
|
||||
<Text
|
||||
as="label"
|
||||
variant="body2"
|
||||
sx={{ width: "95%", fontSize: "16px" }}
|
||||
p={1}
|
||||
>
|
||||
Rotation:
|
||||
</Text>
|
||||
<Slider
|
||||
value={(tokenState && tokenState.rotation) || 0}
|
||||
onChange={handleRotationChange}
|
||||
step={45}
|
||||
min={0}
|
||||
max={360}
|
||||
mr={1}
|
||||
/>
|
||||
</Flex>
|
||||
</Box>
|
||||
</MapMenu>
|
||||
);
|
||||
|
76
src/components/token/TokenSettings.js
Normal file
@ -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 (
|
||||
<Flex sx={{ flexDirection: "column" }}>
|
||||
<Flex>
|
||||
<Box mt={2} sx={{ flexGrow: 1 }}>
|
||||
<Label htmlFor="tokenSize">Default Size</Label>
|
||||
<Input
|
||||
type="number"
|
||||
name="tokenSize"
|
||||
value={(token && token.defaultSize) || 1}
|
||||
onChange={(e) =>
|
||||
onSettingsChange("defaultSize", parseInt(e.target.value))
|
||||
}
|
||||
disabled={!token || token.type === "default"}
|
||||
min={1}
|
||||
my={1}
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
{showMore && (
|
||||
<>
|
||||
<Box mt={2} sx={{ flexGrow: 1 }}>
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
name="name"
|
||||
value={(token && token.name) || ""}
|
||||
onChange={(e) => onSettingsChange("name", e.target.value)}
|
||||
disabled={!token || token.type === "default"}
|
||||
my={1}
|
||||
/>
|
||||
</Box>
|
||||
<Box my={2}>
|
||||
<Label>
|
||||
<Checkbox
|
||||
checked={token && token.isVehicle}
|
||||
disabled={!token || token.type === "default"}
|
||||
onChange={(e) =>
|
||||
onSettingsChange("isVehicle", e.target.checked)
|
||||
}
|
||||
/>
|
||||
Vehicle / Mount
|
||||
</Label>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
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}
|
||||
>
|
||||
<ExpandMoreIcon />
|
||||
</IconButton>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default TokenSettings;
|
@ -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 (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{statuses.map((status, index) => (
|
||||
<Box
|
||||
<Group x={width} y={height} offsetX={width / 2} offsetY={height / 2}>
|
||||
{tokenState.statuses.map((status, index) => (
|
||||
<Circle
|
||||
key={status}
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "absolute",
|
||||
opacity: 0.8,
|
||||
transform: `scale(${1 - index / 10})`,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
style={{ position: "absolute" }}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 100 100"
|
||||
>
|
||||
<circle
|
||||
r={47}
|
||||
cx={50}
|
||||
cy={50}
|
||||
fill="none"
|
||||
stroke={colors[status]}
|
||||
strokeWidth={4}
|
||||
/>
|
||||
</svg>
|
||||
</Box>
|
||||
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}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
|
81
src/components/token/TokenTile.js
Normal file
@ -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 (
|
||||
<Flex
|
||||
onClick={() => 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"
|
||||
>
|
||||
<Image
|
||||
sx={{ width: "100%", height: "100%", objectFit: "contain" }}
|
||||
src={tokenSource}
|
||||
/>
|
||||
<Flex
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background:
|
||||
"linear-gradient(to bottom, rgba(0,0,0,0) 70%, rgba(0,0,0,0.65) 100%);",
|
||||
alignItems: "flex-end",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
p={2}
|
||||
>
|
||||
<Text
|
||||
as="p"
|
||||
variant="heading"
|
||||
color="hsl(210, 50%, 96%)"
|
||||
sx={{ textAlign: "center" }}
|
||||
>
|
||||
{token.name}
|
||||
</Text>
|
||||
</Flex>
|
||||
{isSelected && !isDefault && (
|
||||
<Box sx={{ position: "absolute", top: 0, right: 0 }}>
|
||||
<IconButton
|
||||
aria-label="Remove Map"
|
||||
title="Remove Map"
|
||||
onClick={() => {
|
||||
onTokenRemove(token.id);
|
||||
}}
|
||||
bg="overlay"
|
||||
sx={{ borderRadius: "50%" }}
|
||||
m={1}
|
||||
>
|
||||
<RemoveTokenIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default TokenTile;
|
67
src/components/token/TokenTiles.js
Normal file
@ -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 (
|
||||
<SimpleBar style={{ maxHeight: "300px", width: "500px" }}>
|
||||
<Flex
|
||||
py={2}
|
||||
bg="muted"
|
||||
sx={{
|
||||
flexWrap: "wrap",
|
||||
width: "500px",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
onClick={onTokenAdd}
|
||||
sx={{
|
||||
":hover": {
|
||||
color: "primary",
|
||||
},
|
||||
":focus": {
|
||||
outline: "none",
|
||||
},
|
||||
":active": {
|
||||
color: "secondary",
|
||||
},
|
||||
width: "150px",
|
||||
height: "150px",
|
||||
borderRadius: "4px",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
m={2}
|
||||
bg="muted"
|
||||
aria-label="Add Token"
|
||||
title="Add Token"
|
||||
>
|
||||
<AddIcon large />
|
||||
</Flex>
|
||||
{tokens.map((token) => (
|
||||
<TokenTile
|
||||
key={token.id}
|
||||
token={token}
|
||||
isSelected={selectedToken && token.id === selectedToken.id}
|
||||
onTokenSelect={onTokenSelect}
|
||||
onTokenRemove={onTokenRemove}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
</SimpleBar>
|
||||
);
|
||||
}
|
||||
|
||||
export default TokenTiles;
|
@ -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",
|
||||
}}
|
||||
>
|
||||
<SimpleBar style={{ height: "calc(100% - 58px)", overflowX: "hidden" }}>
|
||||
{tokens.map((token) => (
|
||||
<ListToken
|
||||
key={token.id}
|
||||
token={token}
|
||||
className={listTokenClassName}
|
||||
/>
|
||||
))}
|
||||
<SimpleBar style={{ height: "calc(100% - 48px)", overflowX: "hidden" }}>
|
||||
{ownedTokens
|
||||
.filter((token) => token.owner === userId)
|
||||
.map((token) => (
|
||||
<ListToken
|
||||
key={token.id}
|
||||
token={token}
|
||||
className={listTokenClassName}
|
||||
/>
|
||||
))}
|
||||
</SimpleBar>
|
||||
<Box pt={1} bg="muted" sx={{ height: "58px" }}>
|
||||
<NumberInput
|
||||
value={tokenSize}
|
||||
onChange={setTokenSize}
|
||||
title="Size"
|
||||
min={1}
|
||||
max={9}
|
||||
/>
|
||||
</Box>
|
||||
<Flex
|
||||
bg="muted"
|
||||
sx={{
|
||||
justifyContent: "center",
|
||||
height: "48px",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<SelectTokensButton />
|
||||
</Flex>
|
||||
</Box>
|
||||
<ProxyToken
|
||||
tokenClassName={listTokenClassName}
|
||||
|
166
src/contexts/MapDataContext.js
Normal file
@ -0,0 +1,166 @@
|
||||
import React, { useEffect, useState, useContext } from "react";
|
||||
|
||||
import AuthContext from "./AuthContext";
|
||||
import DatabaseContext from "./DatabaseContext";
|
||||
|
||||
import { maps as defaultMaps } from "../maps";
|
||||
|
||||
const MapDataContext = React.createContext();
|
||||
|
||||
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"],
|
||||
};
|
||||
|
||||
export function MapDataProvider({ children }) {
|
||||
const { database } = useContext(DatabaseContext);
|
||||
const { userId } = useContext(AuthContext);
|
||||
|
||||
const [maps, setMaps] = useState([]);
|
||||
const [mapStates, setMapStates] = 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,
|
||||
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 (
|
||||
<MapDataContext.Provider value={value}>{children}</MapDataContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default MapDataContext;
|
@ -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;
|
||||
|
||||
|
31
src/contexts/MapLoadingContext.js
Normal file
@ -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 (
|
||||
<MapLoadingContext.Provider value={value}>
|
||||
{children}
|
||||
</MapLoadingContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default MapLoadingContext;
|
8
src/contexts/MapStageContext.js
Normal file
@ -0,0 +1,8 @@
|
||||
import React from "react";
|
||||
|
||||
const MapStageContext = React.createContext({
|
||||
mapStageRef: { current: null },
|
||||
});
|
||||
export const MapStageProvider = MapStageContext.Provider;
|
||||
|
||||
export default MapStageContext;
|
112
src/contexts/TokenDataContext.js
Normal file
@ -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 (
|
||||
<TokenDataContext.Provider value={value}>
|
||||
{children}
|
||||
</TokenDataContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default TokenDataContext;
|
@ -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
|
||||
|
10
src/docs/releaseNotes/v1.2.1.md
Normal file
@ -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/)
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
71
src/helpers/useMapBrush.js
Normal file
@ -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;
|
25
src/helpers/usePreventOverscroll.js
Normal file
@ -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;
|
@ -136,7 +136,7 @@ function useSession(
|
||||
function addPeer(id, initiator, sync) {
|
||||
const connection = new Peer({
|
||||
initiator,
|
||||
trickle: false,
|
||||
trickle: true,
|
||||
config: { iceServers },
|
||||
});
|
||||
|
||||
|
18
src/icons/RemoveTokenIcon.js
Normal file
@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
|
||||
function RemoveTokenIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
fill="currentcolor"
|
||||
>
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V9c0-1.1-.9-2-2-2H8c-1.1 0-2 .9-2 2v10zM18 4h-2.5l-.71-.71c-.18-.18-.44-.29-.7-.29H9.91c-.26 0-.52.11-.7.29L8.5 4H6c-.55 0-1 .45-1 1s.45 1 1 1h12c.55 0 1-.45 1-1s-.45-1-1-1z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default RemoveTokenIcon;
|
18
src/icons/SelectTokensIcon.js
Normal file
@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
|
||||
function SelectMapIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
fill="currentcolor"
|
||||
>
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm4 11h-3v3c0 .55-.45 1-1 1s-1-.45-1-1v-3H8c-.55 0-1-.45-1-1s.45-1 1-1h3V8c0-.55.45-1 1-1s1 .45 1 1v3h3c.55 0 1 .45 1 1s-.45 1-1 1z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default SelectMapIcon;
|
BIN
src/maps/Unknown Grid 22x22.jpg
Normal file
After Width: | Height: | Size: 170 KiB |
@ -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;
|
||||
|
@ -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 (
|
||||
<Modal isOpen={isOpen} onRequestClose={onRequestClose}>
|
||||
<Box as="form" onSubmit={handleSubmit} onDragEnter={handleImageDragEnter}>
|
||||
<ImageDrop onDrop={handleImageUpload} dropText="Drop map to upload">
|
||||
<input
|
||||
onChange={(event) => handleImageUpload(event.target.files[0])}
|
||||
type="file"
|
||||
@ -298,14 +189,14 @@ function SelectMapModal({
|
||||
Select or import a map
|
||||
</Label>
|
||||
<MapTiles
|
||||
maps={maps}
|
||||
maps={ownedMaps}
|
||||
onMapAdd={openImageDialog}
|
||||
onMapRemove={handleMapRemove}
|
||||
selectedMap={selectedMap}
|
||||
selectedMapState={selectedMapState}
|
||||
onMapSelect={handleMapSelect}
|
||||
onMapReset={handleMapReset}
|
||||
onSubmit={handleSubmit}
|
||||
onDone={handleDone}
|
||||
/>
|
||||
<MapSettings
|
||||
map={selectedMap}
|
||||
@ -315,35 +206,15 @@ function SelectMapModal({
|
||||
showMore={showMoreSettings}
|
||||
onShowMoreChange={setShowMoreSettings}
|
||||
/>
|
||||
<Button variant="primary" disabled={imageLoading}>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={imageLoading}
|
||||
onClick={handleDone}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
{dragging && (
|
||||
<Flex
|
||||
bg="muted"
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
cursor: "copy",
|
||||
}}
|
||||
onDragLeave={handleImageDragLeave}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}}
|
||||
onDrop={handleImageDrop}
|
||||
>
|
||||
<Text sx={{ pointerEvents: "none" }}>Drop map to upload</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
</ImageDrop>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
140
src/modals/SelectTokensModal.js
Normal file
@ -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 (
|
||||
<Modal isOpen={isOpen} onRequestClose={onRequestClose}>
|
||||
<ImageDrop onDrop={handleImageUpload} dropText="Drop token to upload">
|
||||
<input
|
||||
onChange={(event) => handleImageUpload(event.target.files[0])}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
ref={fileInputRef}
|
||||
/>
|
||||
<Flex
|
||||
sx={{
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Label pt={2} pb={1}>
|
||||
Edit or import a token
|
||||
</Label>
|
||||
<TokenTiles
|
||||
tokens={ownedTokens}
|
||||
onTokenAdd={openImageDialog}
|
||||
selectedToken={selectedToken}
|
||||
onTokenSelect={handleTokenSelect}
|
||||
onTokenRemove={handleTokenRemove}
|
||||
/>
|
||||
<TokenSettings
|
||||
token={selectedToken}
|
||||
showMore={showMoreSettings}
|
||||
onSettingsChange={handleTokenSettingsChange}
|
||||
onShowMoreChange={setShowMoreSettings}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={imageLoading}
|
||||
onClick={onRequestClose}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</Flex>
|
||||
</ImageDrop>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default SelectTokensModal;
|
@ -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();
|
||||
}
|
||||
|
||||
|
@ -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 (
|
||||
<>
|
||||
<MapStageProvider value={mapStageRef}>
|
||||
<Flex sx={{ flexDirection: "column", height: "100%" }}>
|
||||
<Flex
|
||||
sx={{ justifyContent: "space-between", flexGrow: 1, height: "100%" }}
|
||||
sx={{
|
||||
justifyContent: "space-between",
|
||||
flexGrow: 1,
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<Party
|
||||
nickname={nickname}
|
||||
@ -474,10 +531,8 @@ function Game() {
|
||||
onStreamEnd={handleStreamEnd}
|
||||
/>
|
||||
<Map
|
||||
map={map}
|
||||
mapState={mapState}
|
||||
tokens={tokens}
|
||||
loading={mapLoading}
|
||||
map={currentMap}
|
||||
mapState={currentMapState}
|
||||
onMapTokenStateChange={handleMapTokenStateChange}
|
||||
onMapTokenStateRemove={handleMapTokenStateRemove}
|
||||
onMapChange={handleMapChange}
|
||||
@ -492,10 +547,7 @@ function Game() {
|
||||
allowFogDrawing={canEditFogDrawing}
|
||||
disabledTokens={disabledMapTokens}
|
||||
/>
|
||||
<Tokens
|
||||
tokens={tokens}
|
||||
onCreateMapTokenState={handleMapTokenStateChange}
|
||||
/>
|
||||
<Tokens onMapTokenStateCreate={handleMapTokenStateCreate} />
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Banner isOpen={!!peerError} onRequestClose={() => setPeerError(null)}>
|
||||
@ -508,7 +560,7 @@ function Game() {
|
||||
</Banner>
|
||||
<AuthModal isOpen={authenticationStatus === "unauthenticated"} />
|
||||
{authenticationStatus === "unknown" && <LoadingOverlay />}
|
||||
</>
|
||||
</MapStageProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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() {
|
||||
<Text mb={2} variant="heading" as="h1" sx={{ fontSize: 5 }}>
|
||||
Release Notes
|
||||
</Text>
|
||||
<div id="v121">
|
||||
<Markdown source={v121} />
|
||||
</div>
|
||||
<div id="v120">
|
||||
<Markdown source={v120} />
|
||||
</div>
|
||||
|
BIN
src/tokens/Aberration.png
Normal file
After Width: | Height: | Size: 88 KiB |
BIN
src/tokens/Artificer.png
Normal file
After Width: | Height: | Size: 84 KiB |
Before Width: | Height: | Size: 105 KiB |
BIN
src/tokens/Barbarian.png
Normal file
After Width: | Height: | Size: 90 KiB |
BIN
src/tokens/Bard.png
Normal file
After Width: | Height: | Size: 92 KiB |
BIN
src/tokens/Beast.png
Normal file
After Width: | Height: | Size: 86 KiB |
Before Width: | Height: | Size: 99 KiB |
BIN
src/tokens/Blood Hunter.png
Normal file
After Width: | Height: | Size: 93 KiB |
Before Width: | Height: | Size: 102 KiB |
BIN
src/tokens/Celestial.png
Normal file
After Width: | Height: | Size: 92 KiB |
BIN
src/tokens/Cleric.png
Normal file
After Width: | Height: | Size: 86 KiB |
BIN
src/tokens/Construct.png
Normal file
After Width: | Height: | Size: 92 KiB |
Before Width: | Height: | Size: 102 KiB |
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 86 KiB |
BIN
src/tokens/Druid.png
Normal file
After Width: | Height: | Size: 81 KiB |
BIN
src/tokens/Elemental.png
Normal file
After Width: | Height: | Size: 80 KiB |
Before Width: | Height: | Size: 105 KiB |
BIN
src/tokens/Fey.png
Normal file
After Width: | Height: | Size: 82 KiB |
BIN
src/tokens/Fiend.png
Normal file
After Width: | Height: | Size: 82 KiB |
BIN
src/tokens/Fighter.png
Normal file
After Width: | Height: | Size: 100 KiB |
Before Width: | Height: | Size: 105 KiB |
BIN
src/tokens/Giant.png
Normal file
After Width: | Height: | Size: 90 KiB |
BIN
src/tokens/Goblinoid.png
Normal file
After Width: | Height: | Size: 89 KiB |
Before Width: | Height: | Size: 102 KiB |
BIN
src/tokens/Humanoid.png
Normal file
After Width: | Height: | Size: 83 KiB |
Before Width: | Height: | Size: 107 KiB |
Before Width: | Height: | Size: 100 KiB |
Before Width: | Height: | Size: 103 KiB |
BIN
src/tokens/Monk.png
Normal file
After Width: | Height: | Size: 84 KiB |
BIN
src/tokens/Monstrosity.png
Normal file
After Width: | Height: | Size: 86 KiB |
Before Width: | Height: | Size: 96 KiB |
BIN
src/tokens/Ooze.png
Normal file
After Width: | Height: | Size: 81 KiB |
BIN
src/tokens/Paladin.png
Normal file
After Width: | Height: | Size: 94 KiB |
BIN
src/tokens/Plant.png
Normal file
After Width: | Height: | Size: 88 KiB |
Before Width: | Height: | Size: 96 KiB |
BIN
src/tokens/Ranger.png
Normal file
After Width: | Height: | Size: 102 KiB |
BIN
src/tokens/Rouge.png
Normal file
After Width: | Height: | Size: 87 KiB |
BIN
src/tokens/Shapechanger.png
Normal file
After Width: | Height: | Size: 87 KiB |
Before Width: | Height: | Size: 103 KiB |
Before Width: | Height: | Size: 106 KiB |
Before Width: | Height: | Size: 99 KiB |
BIN
src/tokens/Sorcerer.png
Normal file
After Width: | Height: | Size: 84 KiB |
Before Width: | Height: | Size: 114 KiB |
Before Width: | Height: | Size: 103 KiB |
BIN
src/tokens/Titan.png
Normal file
After Width: | Height: | Size: 96 KiB |
Before Width: | Height: | Size: 109 KiB |
Before Width: | Height: | Size: 98 KiB |
BIN
src/tokens/Undead.png
Normal file
After Width: | Height: | Size: 85 KiB |
BIN
src/tokens/Unknown.png
Normal file
After Width: | Height: | Size: 65 KiB |
BIN
src/tokens/Warlock.png
Normal file
After Width: | Height: | Size: 101 KiB |
BIN
src/tokens/Wizard.png
Normal file
After Width: | Height: | Size: 93 KiB |