diff --git a/package.json b/package.json
index 02264ad..21efebe 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "owlbear-rodeo",
- "version": "1.3.3",
+ "version": "1.4.0",
"private": true,
"dependencies": {
"@msgpack/msgpack": "^1.12.1",
@@ -16,12 +16,13 @@
"fake-indexeddb": "^3.0.0",
"interactjs": "^1.9.7",
"konva": "^6.0.0",
- "normalize-wheel": "^1.0.1",
+ "polygon-clipping": "^0.14.3",
"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-media": "^2.0.0-rc.1",
"react-modal": "^3.11.2",
"react-resize-detector": "^4.2.3",
"react-router-dom": "^5.1.2",
diff --git a/src/components/LoadingOverlay.js b/src/components/LoadingOverlay.js
index eca0d0b..8cc4b9e 100644
--- a/src/components/LoadingOverlay.js
+++ b/src/components/LoadingOverlay.js
@@ -15,6 +15,7 @@ function LoadingOverlay() {
alignItems: "center",
top: 0,
left: 0,
+ flexDirection: "column",
}}
bg="muted"
>
diff --git a/src/components/Modal.js b/src/components/Modal.js
index 94b9eda..8133ef0 100644
--- a/src/components/Modal.js
+++ b/src/components/Modal.js
@@ -30,14 +30,20 @@ function StyledModal({
}}
{...props}
>
- {children}
- {allowClose && (
-
- )}
+ {/* Stop keyboard events when modal is open to prevent shortcuts from triggering */}
+
e.stopPropagation()}
+ onKeyUp={(e) => e.stopPropagation()}
+ >
+ {children}
+ {allowClose && (
+
+ )}
+
);
}
diff --git a/src/components/map/Map.js b/src/components/map/Map.js
index 38b54dc..56ae6a1 100644
--- a/src/components/map/Map.js
+++ b/src/components/map/Map.js
@@ -7,15 +7,15 @@ import MapDrawing from "./MapDrawing";
import MapFog from "./MapFog";
import MapDice from "./MapDice";
import MapGrid from "./MapGrid";
+import MapMeasure from "./MapMeasure";
+import MapLoadingOverlay from "./MapLoadingOverlay";
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 { drawActionsToShapes } from "../../helpers/drawing";
function Map({
map,
@@ -35,7 +35,6 @@ function Map({
disabledTokens,
}) {
const { tokensById } = useContext(TokenDataContext);
- const { isLoading } = useContext(MapLoadingContext);
const gridX = map && map.gridX;
const gridY = map && map.gridY;
@@ -47,17 +46,15 @@ function Map({
const [selectedToolId, setSelectedToolId] = useState("pan");
const [toolSettings, setToolSettings] = useState({
- fog: { type: "add", useEdgeSnapping: true, useGridSnapping: false },
- brush: {
- color: "darkGray",
- type: "stroke",
- useBlending: false,
- },
- shape: {
+ fog: { type: "polygon", useEdgeSnapping: false, useFogCut: false },
+ drawing: {
color: "red",
- type: "rectangle",
+ type: "brush",
useBlending: true,
},
+ measure: {
+ type: "chebyshev",
+ },
});
function handleToolSettingChange(tool, change) {
@@ -108,6 +105,10 @@ function Map({
onFogDraw({ type: "add", shapes: [shape] });
}
+ function handleFogShapeSubtract(shape) {
+ onFogDraw({ type: "subtract", shapes: [shape] });
+ }
+
function handleFogShapesRemove(shapeIds) {
onFogDraw({ type: "remove", shapeIds });
}
@@ -121,59 +122,38 @@ function Map({
if (!mapState) {
return;
}
- function actionsToShapes(actions, actionIndex) {
- let shapesById = {};
- for (let i = 0; i <= actionIndex; i++) {
- const action = actions[i];
- if (action.type === "add" || action.type === "edit") {
- for (let shape of action.shapes) {
- shapesById[shape.id] = shape;
- }
- }
- if (action.type === "remove") {
- shapesById = omit(shapesById, action.shapeIds);
- }
- }
- return Object.values(shapesById);
- }
-
setMapShapes(
- actionsToShapes(mapState.mapDrawActions, mapState.mapDrawActionIndex)
+ drawActionsToShapes(mapState.mapDrawActions, mapState.mapDrawActionIndex)
);
setFogShapes(
- actionsToShapes(mapState.fogDrawActions, mapState.fogDrawActionIndex)
+ drawActionsToShapes(mapState.fogDrawActions, mapState.fogDrawActionIndex)
);
}, [mapState]);
const disabledControls = [];
if (!allowMapDrawing) {
- disabledControls.push("brush");
- disabledControls.push("shape");
- disabledControls.push("erase");
+ disabledControls.push("drawing");
}
if (!map) {
disabledControls.push("pan");
- }
- if (mapShapes.length === 0) {
- disabledControls.push("erase");
+ disabledControls.push("measure");
}
if (!allowFogDrawing) {
disabledControls.push("fog");
}
- const disabledSettings = { fog: [], brush: [], shape: [], erase: [] };
+ const disabledSettings = { fog: [], drawing: [] };
+ if (mapShapes.length === 0) {
+ disabledSettings.drawing.push("erase");
+ }
if (!mapState || mapState.mapDrawActionIndex < 0) {
- disabledSettings.brush.push("undo");
- disabledSettings.shape.push("undo");
- disabledSettings.erase.push("undo");
+ disabledSettings.drawing.push("undo");
}
if (
!mapState ||
mapState.mapDrawActionIndex === mapState.mapDrawActions.length - 1
) {
- disabledSettings.brush.push("redo");
- disabledSettings.shape.push("redo");
- disabledSettings.erase.push("redo");
+ disabledSettings.drawing.push("redo");
}
if (!mapState || mapState.fogDrawActionIndex < 0) {
disabledSettings.fog.push("undo");
@@ -287,6 +267,7 @@ function Map({
);
+ const mapMeasure = (
+
+ );
+
return (
- {isLoading && }
+
>
}
selectedToolId={selectedToolId}
+ onSelectedToolChange={setSelectedToolId}
+ disabledControls={disabledControls}
>
{mapGrid}
{mapDrawing}
{mapTokens}
{mapFog}
+ {mapMeasure}
);
}
diff --git a/src/components/map/MapControls.js b/src/components/map/MapControls.js
index 93c2a25..983bd0e 100644
--- a/src/components/map/MapControls.js
+++ b/src/components/map/MapControls.js
@@ -7,15 +7,13 @@ import Divider from "../Divider";
import SelectMapButton from "./SelectMapButton";
import FogToolSettings from "./controls/FogToolSettings";
-import BrushToolSettings from "./controls/BrushToolSettings";
-import ShapeToolSettings from "./controls/ShapeToolSettings";
-import EraseToolSettings from "./controls/EraseToolSettings";
+import DrawingToolSettings from "./controls/DrawingToolSettings";
+import MeasureToolSettings from "./controls/MeasureToolSettings";
import PanToolIcon from "../../icons/PanToolIcon";
import FogToolIcon from "../../icons/FogToolIcon";
import BrushToolIcon from "../../icons/BrushToolIcon";
-import ShapeToolIcon from "../../icons/ShapeToolIcon";
-import EraseToolIcon from "../../icons/EraseToolIcon";
+import MeasureToolIcon from "../../icons/MeasureToolIcon";
import ExpandMoreIcon from "../../icons/ExpandMoreIcon";
function MapContols({
@@ -45,26 +43,20 @@ function MapContols({
title: "Fog Tool",
SettingsComponent: FogToolSettings,
},
- brush: {
- id: "brush",
+ drawing: {
+ id: "drawing",
icon: ,
- title: "Brush Tool",
- SettingsComponent: BrushToolSettings,
+ title: "Drawing Tool",
+ SettingsComponent: DrawingToolSettings,
},
- shape: {
- id: "shape",
- icon: ,
- title: "Shape Tool",
- SettingsComponent: ShapeToolSettings,
- },
- erase: {
- id: "erase",
- icon: ,
- title: "Erase tool",
- SettingsComponent: EraseToolSettings,
+ measure: {
+ id: "measure",
+ icon: ,
+ title: "Measure Tool",
+ SettingsComponent: MeasureToolSettings,
},
};
- const tools = ["pan", "fog", "brush", "shape", "erase"];
+ const tools = ["pan", "fog", "drawing", "measure"];
const sections = [
{
@@ -79,7 +71,7 @@ function MapContols({
),
},
{
- id: "drawing",
+ id: "tools",
component: tools.map((tool) => (
{
- setIsBrushDown(false);
- if (erasingShapes.length > 0) {
- onShapesRemove(erasingShapes.map((shape) => shape.id));
- setErasingShapes([]);
+ useEffect(() => {
+ if (!isEditing) {
+ return;
}
- }, [erasingShapes, onShapesRemove]);
+ const mapStage = mapStageRef.current;
- 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,
- });
- }
- setIsBrushDown(true);
+ function getBrushPosition() {
+ const mapImage = mapStage.findOne("#mapImage");
+ return getBrushPositionForTool(
+ getRelativePointerPositionNormalized(mapImage),
+ selectedToolId,
+ selectedToolSettings,
+ gridSize,
+ shapes
+ );
+ }
+
+ function handleBrushDown() {
+ const brushPosition = getBrushPosition();
+ const commonShapeData = {
+ color: selectedToolSettings && selectedToolSettings.color,
+ blend: selectedToolSettings && selectedToolSettings.useBlending,
+ id: shortid.generate(),
+ };
+ if (isBrush) {
+ setDrawingShape({
+ type: "path",
+ pathType: selectedToolSettings.type === "brush" ? "stroke" : "fill",
+ data: { points: [brushPosition] },
+ strokeWidth: selectedToolSettings.type === "brush" ? 1 : 0,
+ ...commonShapeData,
+ });
+ } else if (isShape) {
+ setDrawingShape({
+ type: "shape",
+ shapeType: selectedToolSettings.type,
+ data: getDefaultShapeData(selectedToolSettings.type, brushPosition),
+ strokeWidth: selectedToolSettings.type === "line" ? 1 : 0,
+ ...commonShapeData,
+ });
}
+ setIsBrushDown(true);
+ }
- function continueShape() {
- const brushPosition = getBrushPositionForTool(
- mapBrushPosition,
- selectedToolId,
- selectedToolSettings,
- gridSize,
- shapes
- );
- if (selectedToolId === "brush") {
+ function handleBrushMove() {
+ const brushPosition = getBrushPosition();
+ if (isBrushDown && drawingShape) {
+ if (isBrush) {
setDrawingShape((prevShape) => {
const prevPoints = prevShape.data.points;
if (
@@ -108,7 +116,7 @@ function MapDrawing({
data: { points: simplified },
};
});
- } else if (selectedToolId === "shape") {
+ } else if (isShape) {
setDrawingShape((prevShape) => ({
...prevShape,
data: getUpdatedShapeData(
@@ -120,46 +128,52 @@ function MapDrawing({
}));
}
}
+ }
- function endShape() {
- if (selectedToolId === "brush" && drawingShape) {
- if (drawingShape.data.points.length > 1) {
- onShapeAdd(drawingShape);
- }
- } else if (selectedToolId === "shape" && drawingShape) {
+ function handleBrushUp() {
+ if (isBrush && drawingShape) {
+ if (drawingShape.data.points.length > 1) {
onShapeAdd(drawingShape);
}
- setDrawingShape(null);
- handleBrushUp();
+ } else if (isShape && drawingShape) {
+ onShapeAdd(drawingShape);
}
- switch (brushState) {
- case "first":
- startShape();
- return;
- case "drawing":
- continueShape();
- return;
- case "last":
- endShape();
- return;
- default:
- return;
+ if (erasingShapes.length > 0) {
+ onShapesRemove(erasingShapes.map((shape) => shape.id));
+ setErasingShapes([]);
}
- },
- [
- selectedToolId,
- selectedToolSettings,
- gridSize,
- stageScale,
- onShapeAdd,
- shapes,
- drawingShape,
- handleBrushUp,
- ]
- );
- useMapBrush(isEditing, handleShapeDraw);
+ setDrawingShape(null);
+ setIsBrushDown(false);
+ }
+
+ interactionEmitter.on("dragStart", handleBrushDown);
+ interactionEmitter.on("drag", handleBrushMove);
+ interactionEmitter.on("dragEnd", handleBrushUp);
+
+ return () => {
+ interactionEmitter.off("dragStart", handleBrushDown);
+ interactionEmitter.off("drag", handleBrushMove);
+ interactionEmitter.off("dragEnd", handleBrushUp);
+ };
+ }, [
+ drawingShape,
+ erasingShapes,
+ gridSize,
+ isBrush,
+ isBrushDown,
+ isEditing,
+ isShape,
+ mapStageRef,
+ onShapeAdd,
+ onShapesRemove,
+ selectedToolId,
+ selectedToolSettings,
+ shapes,
+ stageScale,
+ interactionEmitter,
+ ]);
function handleShapeOver(shape, isDown) {
if (shouldHover && isDown) {
@@ -178,6 +192,7 @@ function MapDrawing({
onTouchStart: () => handleShapeOver(shape, true),
fill: colors[shape.color] || shape.color,
opacity: shape.blend ? 0.5 : 1,
+ id: shape.id,
};
if (shape.type === "path") {
return (
@@ -232,6 +247,24 @@ function MapDrawing({
{...defaultProps}
/>
);
+ } else if (shape.shapeType === "line") {
+ return (
+ [...acc, point.x * mapWidth, point.y * mapHeight],
+ []
+ )}
+ strokeWidth={getStrokeWidth(
+ shape.strokeWidth,
+ gridSize,
+ mapWidth,
+ mapHeight
+ )}
+ stroke={colors[shape.color] || shape.color}
+ lineCap="round"
+ {...defaultProps}
+ />
+ );
}
}
}
diff --git a/src/components/map/MapFog.js b/src/components/map/MapFog.js
index 84dc929..7a03c81 100644
--- a/src/components/map/MapFog.js
+++ b/src/components/map/MapFog.js
@@ -1,11 +1,12 @@
-import React, { useContext, useState, useCallback } from "react";
+import React, { useContext, useState, useEffect, useCallback } from "react";
import shortid from "shortid";
-import { Group, Line } from "react-konva";
+import { Group } from "react-konva";
import useImage from "use-image";
import diagonalPattern from "../../images/DiagonalPattern.png";
import MapInteractionContext from "../../contexts/MapInteractionContext";
+import MapStageContext from "../../contexts/MapStageContext";
import { compare as comparePoints } from "../../helpers/vector2";
import {
@@ -13,20 +14,27 @@ import {
simplifyPoints,
getStrokeWidth,
} from "../../helpers/drawing";
-
import colors from "../../helpers/colors";
-import useMapBrush from "../../helpers/useMapBrush";
+import {
+ HoleyLine,
+ getRelativePointerPositionNormalized,
+ Tick,
+} from "../../helpers/konva";
function MapFog({
shapes,
onShapeAdd,
+ onShapeSubtract,
onShapesRemove,
onShapesEdit,
selectedToolId,
selectedToolSettings,
gridSize,
}) {
- const { stageScale, mapWidth, mapHeight } = useContext(MapInteractionContext);
+ const { stageScale, mapWidth, mapHeight, interactionEmitter } = useContext(
+ MapInteractionContext
+ );
+ const mapStageRef = useContext(MapStageContext);
const [drawingShape, setDrawingShape] = useState(null);
const [isBrushDown, setIsBrushDown] = useState(false);
const [editingShapes, setEditingShapes] = useState([]);
@@ -39,120 +47,272 @@ function MapFog({
const [patternImage] = useImage(diagonalPattern);
- const handleBrushUp = useCallback(() => {
- setIsBrushDown(false);
- if (editingShapes.length > 0) {
- if (selectedToolSettings.type === "remove") {
- onShapesRemove(editingShapes.map((shape) => shape.id));
- } else if (selectedToolSettings.type === "toggle") {
- onShapesEdit(
- editingShapes.map((shape) => ({ ...shape, visible: !shape.visible }))
- );
- }
- setEditingShapes([]);
+ useEffect(() => {
+ if (!isEditing) {
+ return;
}
- }, [editingShapes, onShapesRemove, onShapesEdit, selectedToolSettings]);
- const handleShapeDraw = useCallback(
- (brushState, mapBrushPosition) => {
- function startShape() {
- const brushPosition = getBrushPositionForTool(
- mapBrushPosition,
- selectedToolId,
- selectedToolSettings,
- gridSize,
- shapes
- );
- if (selectedToolSettings.type === "add") {
- setDrawingShape({
- type: "fog",
- data: { points: [brushPosition] },
- strokeWidth: 0.5,
- color: "black",
- blend: false,
- id: shortid.generate(),
- visible: true,
- });
- }
- setIsBrushDown(true);
+ const mapStage = mapStageRef.current;
+
+ function getBrushPosition() {
+ const mapImage = mapStage.findOne("#mapImage");
+ return getBrushPositionForTool(
+ getRelativePointerPositionNormalized(mapImage),
+ selectedToolId,
+ selectedToolSettings,
+ gridSize,
+ shapes
+ );
+ }
+
+ function handleBrushDown() {
+ const brushPosition = getBrushPosition();
+ if (selectedToolSettings.type === "brush") {
+ setDrawingShape({
+ type: "fog",
+ data: {
+ points: [brushPosition],
+ holes: [],
+ },
+ strokeWidth: 0.5,
+ color: selectedToolSettings.useFogSubtract ? "red" : "black",
+ blend: false,
+ id: shortid.generate(),
+ visible: true,
+ });
}
+ setIsBrushDown(true);
+ }
- 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 handleBrushMove() {
+ if (
+ selectedToolSettings.type === "brush" &&
+ isBrushDown &&
+ drawingShape
+ ) {
+ const brushPosition = getBrushPosition();
+ setDrawingShape((prevShape) => {
+ const prevPoints = prevShape.data.points;
+ if (
+ comparePoints(
+ prevPoints[prevPoints.length - 1],
+ brushPosition,
+ 0.001
+ )
+ ) {
+ return prevShape;
+ }
+ return {
+ ...prevShape,
+ data: {
+ ...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
- ),
- },
- };
+ function handleBrushUp() {
+ if (selectedToolSettings.type === "brush" && drawingShape) {
+ const subtract = selectedToolSettings.useFogSubtract;
+
+ if (drawingShape.data.points.length > 1) {
+ let shapeData = {};
+ if (subtract) {
+ shapeData = { id: drawingShape.id, type: drawingShape.type };
+ } else {
+ shapeData = { ...drawingShape, color: "black" };
+ }
+ const shape = {
+ ...shapeData,
+ data: {
+ ...drawingShape.data,
+ points: simplifyPoints(
+ drawingShape.data.points,
+ gridSize,
+ // Downscale fog as smoothing doesn't currently work with edge snapping
+ stageScale / 2
+ ),
+ },
+ };
+ if (subtract) {
+ onShapeSubtract(shape);
+ } else {
onShapeAdd(shape);
}
}
setDrawingShape(null);
- handleBrushUp();
}
- switch (brushState) {
- case "first":
- startShape();
- return;
- case "drawing":
- continueShape();
- return;
- case "last":
- endShape();
- return;
- default:
- return;
+ // Erase
+ if (editingShapes.length > 0) {
+ if (selectedToolSettings.type === "remove") {
+ onShapesRemove(editingShapes.map((shape) => shape.id));
+ } else if (selectedToolSettings.type === "toggle") {
+ onShapesEdit(
+ editingShapes.map((shape) => ({
+ ...shape,
+ visible: !shape.visible,
+ }))
+ );
+ }
+ setEditingShapes([]);
}
- },
- [
- selectedToolId,
- selectedToolSettings,
- gridSize,
- stageScale,
- onShapeAdd,
- shapes,
- drawingShape,
- handleBrushUp,
- ]
- );
- useMapBrush(isEditing, handleShapeDraw);
+ setIsBrushDown(false);
+ }
+
+ function handlePolygonClick() {
+ if (selectedToolSettings.type === "polygon") {
+ const brushPosition = getBrushPosition();
+ setDrawingShape((prevDrawingShape) => {
+ if (prevDrawingShape) {
+ return {
+ ...prevDrawingShape,
+ data: {
+ ...prevDrawingShape.data,
+ points: [...prevDrawingShape.data.points, brushPosition],
+ },
+ };
+ } else {
+ return {
+ type: "fog",
+ data: {
+ points: [brushPosition, brushPosition],
+ holes: [],
+ },
+ strokeWidth: 0.5,
+ color: selectedToolSettings.useFogSubtract ? "red" : "black",
+ blend: false,
+ id: shortid.generate(),
+ visible: true,
+ };
+ }
+ });
+ }
+ }
+
+ function handlePolygonMove() {
+ if (selectedToolSettings.type === "polygon" && drawingShape) {
+ const brushPosition = getBrushPosition();
+ setDrawingShape((prevShape) => {
+ if (!prevShape) {
+ return;
+ }
+ return {
+ ...prevShape,
+ data: {
+ ...prevShape.data,
+ points: [...prevShape.data.points.slice(0, -1), brushPosition],
+ },
+ };
+ });
+ }
+ }
+
+ interactionEmitter.on("dragStart", handleBrushDown);
+ interactionEmitter.on("drag", handleBrushMove);
+ interactionEmitter.on("dragEnd", handleBrushUp);
+ // Use mouse events for polygon and erase to allow for single clicks
+ mapStage.on("mousedown touchstart", handlePolygonMove);
+ mapStage.on("mousemove touchmove", handlePolygonMove);
+ mapStage.on("click tap", handlePolygonClick);
+
+ return () => {
+ interactionEmitter.off("dragStart", handleBrushDown);
+ interactionEmitter.off("drag", handleBrushMove);
+ interactionEmitter.off("dragEnd", handleBrushUp);
+ mapStage.off("mousedown touchstart", handlePolygonMove);
+ mapStage.off("mousemove touchmove", handlePolygonMove);
+ mapStage.off("click tap", handlePolygonClick);
+ };
+ }, [
+ mapStageRef,
+ isEditing,
+ drawingShape,
+ editingShapes,
+ gridSize,
+ isBrushDown,
+ onShapeAdd,
+ onShapeSubtract,
+ onShapesEdit,
+ onShapesRemove,
+ selectedToolId,
+ selectedToolSettings,
+ shapes,
+ stageScale,
+ interactionEmitter,
+ ]);
+
+ const finishDrawingPolygon = useCallback(() => {
+ const subtract = selectedToolSettings.useFogSubtract;
+ const data = {
+ ...drawingShape.data,
+ // Remove the last point as it hasn't been placed yet
+ points: drawingShape.data.points.slice(0, -1),
+ };
+ if (subtract) {
+ onShapeSubtract({
+ id: drawingShape.id,
+ type: drawingShape.type,
+ data: data,
+ });
+ } else {
+ onShapeAdd({ ...drawingShape, data: data, color: "black" });
+ }
+
+ setDrawingShape(null);
+ }, [selectedToolSettings, drawingShape, onShapeSubtract, onShapeAdd]);
+
+ // Add keyboard shortcuts
+ useEffect(() => {
+ function handleKeyDown({ key }) {
+ if (
+ key === "Enter" &&
+ selectedToolSettings.type === "polygon" &&
+ drawingShape
+ ) {
+ finishDrawingPolygon();
+ }
+ if (key === "Escape" && drawingShape) {
+ setDrawingShape(null);
+ }
+ if (key === "Alt" && drawingShape) {
+ updateShapeColor();
+ }
+ }
+
+ function handleKeyUp({ key }) {
+ if (key === "Alt" && drawingShape) {
+ updateShapeColor();
+ }
+ }
+
+ function updateShapeColor() {
+ setDrawingShape((prevShape) => {
+ if (!prevShape) {
+ return;
+ }
+ return {
+ ...prevShape,
+ color: selectedToolSettings.useFogSubtract ? "black" : "red",
+ };
+ });
+ }
+
+ interactionEmitter.on("keyDown", handleKeyDown);
+ interactionEmitter.on("keyUp", handleKeyUp);
+ return () => {
+ interactionEmitter.off("keyDown", handleKeyDown);
+ interactionEmitter.off("keyUp", handleKeyUp);
+ };
+ }, [
+ finishDrawingPolygon,
+ interactionEmitter,
+ drawingShape,
+ selectedToolSettings,
+ ]);
function handleShapeOver(shape, isDown) {
if (shouldHover && isDown) {
@@ -162,18 +322,23 @@ function MapFog({
}
}
+ function reducePoints(acc, point) {
+ return [...acc, point.x * mapWidth, point.y * mapHeight];
+ }
+
function renderShape(shape) {
+ const points = shape.data.points.reduce(reducePoints, []);
+ const holes =
+ shape.data.holes &&
+ shape.data.holes.map((hole) => hole.reduce(reducePoints, []));
return (
- handleShapeOver(shape, isBrushDown)}
onTouchOver={() => handleShapeOver(shape, isBrushDown)}
onMouseDown={() => handleShapeOver(shape, true)}
onTouchStart={() => handleShapeOver(shape, true)}
- points={shape.data.points.reduce(
- (acc, point) => [...acc, point.x * mapWidth, point.y * mapHeight],
- []
- )}
+ points={points}
stroke={colors[shape.color] || shape.color}
fill={colors[shape.color] || shape.color}
closed
@@ -188,6 +353,7 @@ function MapFog({
opacity={isEditing ? 0.5 : 1}
fillPatternImage={patternImage}
fillPriority={isEditing && !shape.visible ? "pattern" : "color"}
+ holes={holes}
/>
);
}
@@ -200,10 +366,37 @@ function MapFog({
return renderShape(editingShape);
}
+ function renderPolygonAcceptTick(shape) {
+ if (shape.data.points.length === 0) {
+ return null;
+ }
+ const isCross = shape.data.points.length < 4;
+ return (
+ {
+ e.cancelBubble = true;
+ if (isCross) {
+ setDrawingShape(null);
+ } else {
+ finishDrawingPolygon();
+ }
+ }}
+ />
+ );
+ }
+
return (
{shapes.map(renderShape)}
{drawingShape && renderShape(drawingShape)}
+ {drawingShape &&
+ selectedToolSettings &&
+ selectedToolSettings.type === "polygon" &&
+ renderPolygonAcceptTick(drawingShape)}
{editingShapes.length > 0 && editingShapes.map(renderEditingShape)}
);
diff --git a/src/components/map/MapGrid.js b/src/components/map/MapGrid.js
index 06df57a..c54bb51 100644
--- a/src/components/map/MapGrid.js
+++ b/src/components/map/MapGrid.js
@@ -71,7 +71,7 @@ function MapGrid({ map, gridSize }) {
points={[x * lineSpacingX, 0, x * lineSpacingX, mapHeight]}
stroke={isImageLight ? "black" : "white"}
strokeWidth={getStrokeWidth(0.1, gridSize, mapWidth, mapHeight)}
- opacity={0.8}
+ opacity={0.5}
/>
);
}
@@ -82,7 +82,7 @@ function MapGrid({ map, gridSize }) {
points={[0, y * lineSpacingY, mapWidth, y * lineSpacingY]}
stroke={isImageLight ? "black" : "white"}
strokeWidth={getStrokeWidth(0.1, gridSize, mapWidth, mapHeight)}
- opacity={0.8}
+ opacity={0.5}
/>
);
}
diff --git a/src/components/map/MapInteraction.js b/src/components/map/MapInteraction.js
index 60e929a..f71b93e 100644
--- a/src/components/map/MapInteraction.js
+++ b/src/components/map/MapInteraction.js
@@ -4,6 +4,7 @@ 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 { EventEmitter } from "events";
import usePreventOverscroll from "../../helpers/usePreventOverscroll";
import useDataSource from "../../helpers/useDataSource";
@@ -11,7 +12,9 @@ import useDataSource from "../../helpers/useDataSource";
import { mapSources as defaultMapSources } from "../../maps";
import { MapInteractionProvider } from "../../contexts/MapInteractionContext";
-import MapStageContext from "../../contexts/MapStageContext";
+import MapStageContext, {
+ MapStageProvider,
+} from "../../contexts/MapStageContext";
import AuthContext from "../../contexts/AuthContext";
const wheelZoomSpeed = -0.001;
@@ -19,22 +22,26 @@ const touchZoomSpeed = 0.005;
const minZoom = 0.1;
const maxZoom = 5;
-function MapInteraction({ map, children, controls, selectedToolId }) {
+function MapInteraction({
+ map,
+ children,
+ controls,
+ selectedToolId,
+ onSelectedToolChange,
+ disabledControls,
+}) {
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(() => {
@@ -54,36 +61,20 @@ function MapInteraction({ map, children, controls, selectedToolId }) {
}
}, [map]);
- // 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();
-
- const offsetX = x - containerRect.left - mapRect.x;
- const offsetY = y - containerRect.top - mapRect.y;
-
- const normalizedX = offsetX / mapRect.width;
- const normalizedY = offsetY / mapRect.height;
-
- return { x: normalizedX, y: normalizedY };
- }
- }
-
const pinchPreviousDistanceRef = useRef();
const pinchPreviousOriginRef = useRef();
- const isInteractingCanvas = useRef(false);
+ const isInteractingWithCanvas = useRef(false);
+ const previousSelectedToolRef = useRef(selectedToolId);
+
+ const [interactionEmitter] = useState(new EventEmitter());
const bind = useGesture({
onWheelStart: ({ event }) => {
- isInteractingCanvas.current =
+ isInteractingWithCanvas.current =
event.target === mapLayerRef.current.getCanvas()._canvas;
},
onWheel: ({ delta }) => {
- if (preventMapInteraction || !isInteractingCanvas.current) {
+ if (preventMapInteraction || !isInteractingWithCanvas.current) {
return;
}
const newScale = Math.min(
@@ -92,6 +83,11 @@ function MapInteraction({ map, children, controls, selectedToolId }) {
);
setStageScale(newScale);
},
+ onPinchStart: () => {
+ // Change to pan tool when pinching and zooming
+ previousSelectedToolRef.current = selectedToolId;
+ onSelectedToolChange("pan");
+ },
onPinch: ({ da, origin, first }) => {
const [distance] = da;
const [originX, originY] = origin;
@@ -125,12 +121,19 @@ function MapInteraction({ map, children, controls, selectedToolId }) {
pinchPreviousDistanceRef.current = distance;
pinchPreviousOriginRef.current = { x: originX, y: originY };
},
+ onPinchEnd: () => {
+ onSelectedToolChange(previousSelectedToolRef.current);
+ },
onDragStart: ({ event }) => {
- isInteractingCanvas.current =
+ isInteractingWithCanvas.current =
event.target === mapLayerRef.current.getCanvas()._canvas;
},
- onDrag: ({ delta, xy, first, last, pinching }) => {
- if (preventMapInteraction || pinching || !isInteractingCanvas.current) {
+ onDrag: ({ delta, first, last, pinching }) => {
+ if (
+ preventMapInteraction ||
+ pinching ||
+ !isInteractingWithCanvas.current
+ ) {
return;
}
@@ -147,15 +150,14 @@ function MapInteraction({ map, children, controls, selectedToolId }) {
layer.draw();
stageTranslateRef.current = newTranslate;
}
- mapDragPositionRef.current = getMapDragPosition(xy);
- const newDragState = first ? "first" : last ? "last" : "dragging";
- if (stageDragState !== newDragState) {
- setStageDragState(newDragState);
+ if (first) {
+ interactionEmitter.emit("dragStart");
+ } else if (last) {
+ interactionEmitter.emit("dragEnd");
+ } else {
+ interactionEmitter.emit("drag");
}
},
- onDragEnd: () => {
- setStageDragState("none");
- },
});
function handleResize(width, height) {
@@ -165,13 +167,53 @@ function MapInteraction({ map, children, controls, selectedToolId }) {
stageHeightRef.current = height;
}
+ function handleKeyDown(event) {
+ // Change to pan tool when pressing space
+ if (event.key === " " && selectedToolId === "pan") {
+ // Stop active state on pan icon from being selected
+ event.preventDefault();
+ }
+ if (
+ event.key === " " &&
+ selectedToolId !== "pan" &&
+ !disabledControls.includes("pan")
+ ) {
+ event.preventDefault();
+ previousSelectedToolRef.current = selectedToolId;
+ onSelectedToolChange("pan");
+ }
+
+ // Basic keyboard shortcuts
+ if (event.key === "w" && !disabledControls.includes("pan")) {
+ onSelectedToolChange("pan");
+ }
+ if (event.key === "d" && !disabledControls.includes("drawing")) {
+ onSelectedToolChange("drawing");
+ }
+ if (event.key === "f" && !disabledControls.includes("fog")) {
+ onSelectedToolChange("fog");
+ }
+ if (event.key === "m" && !disabledControls.includes("measure")) {
+ onSelectedToolChange("measure");
+ }
+
+ interactionEmitter.emit("keyDown", event);
+ }
+
+ function handleKeyUp(event) {
+ if (event.key === " " && selectedToolId === "pan") {
+ onSelectedToolChange(previousSelectedToolRef.current);
+ }
+ interactionEmitter.emit("keyUp", event);
+ }
+
function getCursorForTool(tool) {
switch (tool) {
case "pan":
return "move";
case "fog":
- case "brush":
- case "shape":
+ case "drawing":
+ case "measure":
return "crosshair";
default:
return "default";
@@ -194,11 +236,10 @@ function MapInteraction({ map, children, controls, selectedToolId }) {
stageScale,
stageWidth,
stageHeight,
- stageDragState,
setPreventMapInteraction,
mapWidth,
mapHeight,
- mapDragPositionRef,
+ interactionEmitter,
};
return (
@@ -208,10 +249,14 @@ function MapInteraction({ map, children, controls, selectedToolId }) {
position: "relative",
cursor: getCursorForTool(selectedToolId),
touchAction: "none",
+ outline: "none",
}}
ref={containerRef}
{...bind()}
className="map"
+ tabIndex={1}
+ onKeyDown={handleKeyDown}
+ onKeyUp={handleKeyUp}
>
- {children}
+
+ {children}
+
diff --git a/src/components/map/MapLoadingOverlay.js b/src/components/map/MapLoadingOverlay.js
new file mode 100644
index 0000000..8374fc5
--- /dev/null
+++ b/src/components/map/MapLoadingOverlay.js
@@ -0,0 +1,63 @@
+import React, { useContext, useEffect, useRef } from "react";
+import { Box, Progress } from "theme-ui";
+
+import Spinner from "../Spinner";
+import MapLoadingContext from "../../contexts/MapLoadingContext";
+
+function MapLoadingOverlay() {
+ const { isLoading, loadingProgressRef } = useContext(MapLoadingContext);
+
+ const requestRef = useRef();
+ const progressBarRef = useRef();
+
+ // Use an animation frame to update the progress bar
+ // This bypasses react allowing the animation to be smooth
+ useEffect(() => {
+ function animate() {
+ if (!isLoading) {
+ return;
+ }
+ requestRef.current = requestAnimationFrame(animate);
+ if (progressBarRef.current) {
+ progressBarRef.current.value = loadingProgressRef.current;
+ }
+ }
+
+ requestRef.current = requestAnimationFrame(animate);
+
+ return () => {
+ cancelAnimationFrame(requestRef.current);
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isLoading]);
+
+ return (
+ isLoading && (
+
+
+
+
+ )
+ );
+}
+
+export default MapLoadingOverlay;
diff --git a/src/components/map/MapMeasure.js b/src/components/map/MapMeasure.js
new file mode 100644
index 0000000..5ca4678
--- /dev/null
+++ b/src/components/map/MapMeasure.js
@@ -0,0 +1,142 @@
+import React, { useContext, useState, useEffect } from "react";
+import { Group, Line, Text, Label, Tag } from "react-konva";
+
+import MapInteractionContext from "../../contexts/MapInteractionContext";
+import MapStageContext from "../../contexts/MapStageContext";
+
+import {
+ getBrushPositionForTool,
+ getDefaultShapeData,
+ getUpdatedShapeData,
+ getStrokeWidth,
+} from "../../helpers/drawing";
+import { getRelativePointerPositionNormalized } from "../../helpers/konva";
+import * as Vector2 from "../../helpers/vector2";
+
+function MapMeasure({ selectedToolSettings, active, gridSize }) {
+ const { stageScale, mapWidth, mapHeight, interactionEmitter } = useContext(
+ MapInteractionContext
+ );
+ const mapStageRef = useContext(MapStageContext);
+ const [drawingShapeData, setDrawingShapeData] = useState(null);
+ const [isBrushDown, setIsBrushDown] = useState(false);
+
+ useEffect(() => {
+ if (!active) {
+ return;
+ }
+ const mapStage = mapStageRef.current;
+
+ function getBrushPosition() {
+ const mapImage = mapStage.findOne("#mapImage");
+ return getBrushPositionForTool(
+ getRelativePointerPositionNormalized(mapImage),
+ "drawing",
+ { type: "line" },
+ gridSize,
+ []
+ );
+ }
+
+ function handleBrushDown() {
+ const brushPosition = getBrushPosition();
+ const { points } = getDefaultShapeData("line", brushPosition);
+ const length = 0;
+ setDrawingShapeData({ length, points });
+ setIsBrushDown(true);
+ }
+
+ function handleBrushMove() {
+ const brushPosition = getBrushPosition();
+ if (isBrushDown && drawingShapeData) {
+ const { points } = getUpdatedShapeData(
+ "line",
+ drawingShapeData,
+ brushPosition,
+ gridSize
+ );
+ const length = Vector2.distance(
+ Vector2.divide(points[0], gridSize),
+ Vector2.divide(points[1], gridSize),
+ selectedToolSettings.type
+ );
+ setDrawingShapeData({
+ length,
+ points,
+ });
+ }
+ }
+
+ function handleBrushUp() {
+ setDrawingShapeData(null);
+ setIsBrushDown(false);
+ }
+
+ interactionEmitter.on("dragStart", handleBrushDown);
+ interactionEmitter.on("drag", handleBrushMove);
+ interactionEmitter.on("dragEnd", handleBrushUp);
+
+ return () => {
+ interactionEmitter.off("dragStart", handleBrushDown);
+ interactionEmitter.off("drag", handleBrushMove);
+ interactionEmitter.off("dragEnd", handleBrushUp);
+ };
+ }, [
+ drawingShapeData,
+ gridSize,
+ isBrushDown,
+ mapStageRef,
+ interactionEmitter,
+ active,
+ selectedToolSettings,
+ ]);
+
+ function renderShape(shapeData) {
+ const linePoints = shapeData.points.reduce(
+ (acc, point) => [...acc, point.x * mapWidth, point.y * mapHeight],
+ []
+ );
+
+ const lineCenter = Vector2.multiply(
+ Vector2.divide(Vector2.add(shapeData.points[0], shapeData.points[1]), 2),
+ { x: mapWidth, y: mapHeight }
+ );
+
+ return (
+
+
+
+
+
+ );
+ }
+
+ return {drawingShapeData && renderShape(drawingShapeData)};
+}
+
+export default MapMeasure;
diff --git a/src/components/map/MapMenu.js b/src/components/map/MapMenu.js
index 2c79ddb..5fb8782 100644
--- a/src/components/map/MapMenu.js
+++ b/src/components/map/MapMenu.js
@@ -79,7 +79,13 @@ function MapMenu({
}}
contentRef={handleModalContent}
>
- {children}
+ {/* Stop keyboard events when modal is open to prevent shortcuts from triggering */}
+ e.stopPropagation()}
+ onKeyUp={(e) => e.stopPropagation()}
+ >
+ {children}
+
);
}
diff --git a/src/components/map/MapSettings.js b/src/components/map/MapSettings.js
index 1861bd4..bfabeec 100644
--- a/src/components/map/MapSettings.js
+++ b/src/components/map/MapSettings.js
@@ -3,6 +3,8 @@ import { Flex, Box, Label, Input, Checkbox, IconButton } from "theme-ui";
import ExpandMoreIcon from "../../icons/ExpandMoreIcon";
+import { isEmpty } from "../../helpers/shared";
+
import Divider from "../Divider";
function MapSettings({
@@ -24,6 +26,9 @@ function MapSettings({
}
}
+ const mapEmpty = !map || isEmpty(map);
+ const mapStateEmpty = !mapState || isEmpty(mapState);
+
return (
@@ -36,7 +41,7 @@ function MapSettings({
onChange={(e) =>
onSettingsChange("gridX", parseInt(e.target.value))
}
- disabled={!map || map.type === "default"}
+ disabled={mapEmpty || map.type === "default"}
min={1}
my={1}
/>
@@ -50,7 +55,7 @@ function MapSettings({
onChange={(e) =>
onSettingsChange("gridY", parseInt(e.target.value))
}
- disabled={!map || map.type === "default"}
+ disabled={mapEmpty || map.type === "default"}
min={1}
my={1}
/>
@@ -64,7 +69,7 @@ function MapSettings({
name="name"
value={(map && map.name) || ""}
onChange={(e) => onSettingsChange("name", e.target.value)}
- disabled={!map || map.type === "default"}
+ disabled={mapEmpty || map.type === "default"}
my={1}
/>
@@ -72,7 +77,7 @@ function MapSettings({
{databaseStatus === "disabled" && (
diff --git a/src/components/map/MapToken.js b/src/components/map/MapToken.js
index f55dd4e..afaf796 100644
--- a/src/components/map/MapToken.js
+++ b/src/components/map/MapToken.js
@@ -140,7 +140,7 @@ function MapToken({
}
function handlePointerOut() {
- if (!draggable) {
+ if (tokenOpacity !== 1.0) {
setTokenOpacity(1.0);
}
}
diff --git a/src/components/map/SelectMapButton.js b/src/components/map/SelectMapButton.js
index fb8c81f..799a1dd 100644
--- a/src/components/map/SelectMapButton.js
+++ b/src/components/map/SelectMapButton.js
@@ -19,12 +19,9 @@ function SelectMapButton({
currentMapState && updateMapState(currentMapState.mapId, currentMapState);
setIsModalOpen(true);
}
- function closeModal() {
- setIsModalOpen(false);
- }
function handleDone() {
- closeModal();
+ setIsModalOpen(false);
}
return (
@@ -38,7 +35,6 @@ function SelectMapButton({
- onSettingChange({ color })}
- />
-
- onSettingChange({ type: "stroke" })}
- isSelected={settings.type === "stroke"}
- >
-
-
- onSettingChange({ type: "fill" })}
- isSelected={settings.type === "fill"}
- >
-
-
-
- onSettingChange({ useBlending })}
- />
-
- onToolAction("mapUndo")}
- disabled={disabledActions.includes("undo")}
- />
- onToolAction("mapRedo")}
- disabled={disabledActions.includes("redo")}
- />
-
- );
-}
-
-export default BrushToolSettings;
diff --git a/src/components/map/controls/DrawingToolSettings.js b/src/components/map/controls/DrawingToolSettings.js
new file mode 100644
index 0000000..9f0bb6f
--- /dev/null
+++ b/src/components/map/controls/DrawingToolSettings.js
@@ -0,0 +1,171 @@
+import React, { useEffect, useContext } from "react";
+import { Flex, IconButton } from "theme-ui";
+import { useMedia } from "react-media";
+
+import ColorControl from "./ColorControl";
+import AlphaBlendToggle from "./AlphaBlendToggle";
+import RadioIconButton from "./RadioIconButton";
+import ToolSection from "./ToolSection";
+
+import BrushIcon from "../../../icons/BrushToolIcon";
+import BrushPaintIcon from "../../../icons/BrushPaintIcon";
+import BrushLineIcon from "../../../icons/BrushLineIcon";
+import BrushRectangleIcon from "../../../icons/BrushRectangleIcon";
+import BrushCircleIcon from "../../../icons/BrushCircleIcon";
+import BrushTriangleIcon from "../../../icons/BrushTriangleIcon";
+import EraseAllIcon from "../../../icons/EraseAllIcon";
+import EraseIcon from "../../../icons/EraseToolIcon";
+
+import UndoButton from "./UndoButton";
+import RedoButton from "./RedoButton";
+
+import Divider from "../../Divider";
+
+import MapInteractionContext from "../../../contexts/MapInteractionContext";
+
+function DrawingToolSettings({
+ settings,
+ onSettingChange,
+ onToolAction,
+ disabledActions,
+}) {
+ const { interactionEmitter } = useContext(MapInteractionContext);
+
+ // Keyboard shotcuts
+ useEffect(() => {
+ function handleKeyDown({ key, ctrlKey, metaKey, shiftKey }) {
+ if (key === "b") {
+ onSettingChange({ type: "brush" });
+ } else if (key === "p") {
+ onSettingChange({ type: "paint" });
+ } else if (key === "l") {
+ onSettingChange({ type: "line" });
+ } else if (key === "r") {
+ onSettingChange({ type: "rectangle" });
+ } else if (key === "c") {
+ onSettingChange({ type: "circle" });
+ } else if (key === "t") {
+ onSettingChange({ type: "triangle" });
+ } else if (key === "e") {
+ onSettingChange({ type: "erase" });
+ } else if (key === "o") {
+ onSettingChange({ useBlending: !settings.useBlending });
+ } else if (
+ (key === "z" || key === "Z") &&
+ (ctrlKey || metaKey) &&
+ shiftKey &&
+ !disabledActions.includes("redo")
+ ) {
+ onToolAction("mapRedo");
+ } else if (
+ key === "z" &&
+ (ctrlKey || metaKey) &&
+ !shiftKey &&
+ !disabledActions.includes("undo")
+ ) {
+ onToolAction("mapUndo");
+ }
+ }
+
+ interactionEmitter.on("keyDown", handleKeyDown);
+ return () => {
+ interactionEmitter.off("keyDown", handleKeyDown);
+ };
+ });
+
+ // Change to brush if on erase and it gets disabled
+ useEffect(() => {
+ if (settings.type === "erase" && disabledActions.includes("erase")) {
+ onSettingChange({ type: "brush" });
+ }
+ }, [disabledActions, settings, onSettingChange]);
+
+ const isSmallScreen = useMedia({ query: "(max-width: 799px)" });
+
+ const tools = [
+ {
+ id: "brush",
+ title: "Brush",
+ isSelected: settings.type === "brush",
+ icon: ,
+ },
+ {
+ id: "paint",
+ title: "Paint",
+ isSelected: settings.type === "paint",
+ icon: ,
+ },
+ {
+ id: "line",
+ title: "Line",
+ isSelected: settings.type === "line",
+ icon: ,
+ },
+ {
+ id: "rectangle",
+ title: "Rectangle",
+ isSelected: settings.type === "rectangle",
+ icon: ,
+ },
+ {
+ id: "circle",
+ title: "Circle",
+ isSelected: settings.type === "circle",
+ icon: ,
+ },
+ {
+ id: "triangle",
+ title: "Triangle",
+ isSelected: settings.type === "triangle",
+ icon: ,
+ },
+ ];
+
+ return (
+
+ onSettingChange({ color })}
+ />
+
+ onSettingChange({ type: tool.id })}
+ collapse={isSmallScreen}
+ />
+
+ onSettingChange({ type: "erase" })}
+ isSelected={settings.type === "erase"}
+ disabled={disabledActions.includes("erase")}
+ >
+
+
+ onToolAction("eraseAll")}
+ disabled={disabledActions.includes("erase")}
+ >
+
+
+
+ onSettingChange({ useBlending })}
+ />
+
+ onToolAction("mapUndo")}
+ disabled={disabledActions.includes("undo")}
+ />
+ onToolAction("mapRedo")}
+ disabled={disabledActions.includes("redo")}
+ />
+
+ );
+}
+
+export default DrawingToolSettings;
diff --git a/src/components/map/controls/EraseToolSettings.js b/src/components/map/controls/EraseToolSettings.js
deleted file mode 100644
index c41b117..0000000
--- a/src/components/map/controls/EraseToolSettings.js
+++ /dev/null
@@ -1,34 +0,0 @@
-import React from "react";
-import { Flex, IconButton } from "theme-ui";
-
-import EraseAllIcon from "../../../icons/EraseAllIcon";
-
-import UndoButton from "./UndoButton";
-import RedoButton from "./RedoButton";
-
-import Divider from "../../Divider";
-
-function EraseToolSettings({ onToolAction, disabledActions }) {
- return (
-
- onToolAction("eraseAll")}
- >
-
-
-
- onToolAction("mapUndo")}
- disabled={disabledActions.includes("undo")}
- />
- onToolAction("mapRedo")}
- disabled={disabledActions.includes("redo")}
- />
-
- );
-}
-
-export default EraseToolSettings;
diff --git a/src/components/map/controls/FogToolSettings.js b/src/components/map/controls/FogToolSettings.js
index 96e22e2..f94b386 100644
--- a/src/components/map/controls/FogToolSettings.js
+++ b/src/components/map/controls/FogToolSettings.js
@@ -1,33 +1,124 @@
-import React from "react";
+import React, { useContext, useEffect } from "react";
import { Flex } from "theme-ui";
+import { useMedia } from "react-media";
import EdgeSnappingToggle from "./EdgeSnappingToggle";
import RadioIconButton from "./RadioIconButton";
-import GridSnappingToggle from "./GridSnappingToggle";
-import FogAddIcon from "../../../icons/FogAddIcon";
+import FogBrushIcon from "../../../icons/FogBrushIcon";
+import FogPolygonIcon from "../../../icons/FogPolygonIcon";
import FogRemoveIcon from "../../../icons/FogRemoveIcon";
import FogToggleIcon from "../../../icons/FogToggleIcon";
+import FogAddIcon from "../../../icons/FogAddIcon";
+import FogSubtractIcon from "../../../icons/FogSubtractIcon";
import UndoButton from "./UndoButton";
import RedoButton from "./RedoButton";
import Divider from "../../Divider";
+import MapInteractionContext from "../../../contexts/MapInteractionContext";
+import ToolSection from "./ToolSection";
+
function BrushToolSettings({
settings,
onSettingChange,
onToolAction,
disabledActions,
}) {
+ const { interactionEmitter } = useContext(MapInteractionContext);
+
+ // Keyboard shortcuts
+ useEffect(() => {
+ function handleKeyDown({ key, ctrlKey, metaKey, shiftKey }) {
+ if (key === "Alt") {
+ onSettingChange({ useFogSubtract: !settings.useFogSubtract });
+ } else if (key === "p") {
+ onSettingChange({ type: "polygon" });
+ } else if (key === "b") {
+ onSettingChange({ type: "brush" });
+ } else if (key === "t") {
+ onSettingChange({ type: "toggle" });
+ } else if (key === "r") {
+ onSettingChange({ type: "remove" });
+ } else if (key === "s") {
+ onSettingChange({ useEdgeSnapping: !settings.useEdgeSnapping });
+ } else if (
+ (key === "z" || key === "Z") &&
+ (ctrlKey || metaKey) &&
+ shiftKey &&
+ !disabledActions.includes("redo")
+ ) {
+ onToolAction("fogRedo");
+ } else if (
+ key === "z" &&
+ (ctrlKey || metaKey) &&
+ !shiftKey &&
+ !disabledActions.includes("undo")
+ ) {
+ onToolAction("fogUndo");
+ }
+ }
+
+ function handleKeyUp({ key }) {
+ if (key === "Alt") {
+ onSettingChange({ useFogSubtract: !settings.useFogSubtract });
+ }
+ }
+
+ interactionEmitter.on("keyDown", handleKeyDown);
+ interactionEmitter.on("keyUp", handleKeyUp);
+ return () => {
+ interactionEmitter.off("keyDown", handleKeyDown);
+ interactionEmitter.off("keyUp", handleKeyUp);
+ };
+ });
+
+ const isSmallScreen = useMedia({ query: "(max-width: 799px)" });
+ const drawTools = [
+ {
+ id: "polygon",
+ title: "Fog Polygon",
+ isSelected: settings.type === "polygon",
+ icon: ,
+ },
+ {
+ id: "brush",
+ title: "Fog Brush",
+ isSelected: settings.type === "brush",
+ icon: ,
+ },
+ ];
+
+ const modeTools = [
+ {
+ id: "add",
+ title: "Add Fog",
+ isSelected: !settings.useFogSubtract,
+ icon: ,
+ },
+ {
+ id: "subtract",
+ title: "Subtract Fog",
+ isSelected: settings.useFogSubtract,
+ icon: ,
+ },
+ ];
+
return (
+ onSettingChange({ type: tool.id })}
+ collapse={isSmallScreen}
+ />
+
onSettingChange({ type: "add" })}
- isSelected={settings.type === "add"}
+ title="Toggle Fog"
+ onClick={() => onSettingChange({ type: "toggle" })}
+ isSelected={settings.type === "toggle"}
>
-
+
- onSettingChange({ type: "toggle" })}
- isSelected={settings.type === "toggle"}
- >
-
-
+
+
+ onSettingChange({ useFogSubtract: tool.id === "subtract" })
+ }
+ collapse={isSmallScreen}
+ />
-
- onSettingChange({ useGridSnapping })
- }
- />
onToolAction("fogUndo")}
diff --git a/src/components/map/controls/GridSnappingToggle.js b/src/components/map/controls/GridSnappingToggle.js
deleted file mode 100644
index b09232a..0000000
--- a/src/components/map/controls/GridSnappingToggle.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import React from "react";
-import { IconButton } from "theme-ui";
-
-import GridOnIcon from "../../../icons/GridOnIcon";
-import GridOffIcon from "../../../icons/GridOffIcon";
-
-function GridSnappingToggle({ useGridSnapping, onGridSnappingChange }) {
- return (
- onGridSnappingChange(!useGridSnapping)}
- >
- {useGridSnapping ? : }
-
- );
-}
-
-export default GridSnappingToggle;
diff --git a/src/components/map/controls/MeasureToolSettings.js b/src/components/map/controls/MeasureToolSettings.js
new file mode 100644
index 0000000..d29f447
--- /dev/null
+++ b/src/components/map/controls/MeasureToolSettings.js
@@ -0,0 +1,65 @@
+import React, { useEffect, useContext } from "react";
+import { Flex } from "theme-ui";
+
+import ToolSection from "./ToolSection";
+import MeasureChebyshevIcon from "../../../icons/MeasureChebyshevIcon";
+import MeasureEuclideanIcon from "../../../icons/MeasureEuclideanIcon";
+import MeasureManhattanIcon from "../../../icons/MeasureManhattanIcon";
+
+import MapInteractionContext from "../../../contexts/MapInteractionContext";
+
+function MeasureToolSettings({ settings, onSettingChange }) {
+ const { interactionEmitter } = useContext(MapInteractionContext);
+
+ // Keyboard shortcuts
+ useEffect(() => {
+ function handleKeyDown({ key }) {
+ if (key === "g") {
+ onSettingChange({ type: "chebyshev" });
+ } else if (key === "l") {
+ onSettingChange({ type: "euclidean" });
+ } else if (key === "c") {
+ onSettingChange({ type: "manhattan" });
+ }
+ }
+ interactionEmitter.on("keyDown", handleKeyDown);
+
+ return () => {
+ interactionEmitter.off("keyDown", handleKeyDown);
+ };
+ });
+
+ const tools = [
+ {
+ id: "chebyshev",
+ title: "Grid Distance",
+ isSelected: settings.type === "chebyshev",
+ icon: ,
+ },
+ {
+ id: "euclidean",
+ title: "Line Distance",
+ isSelected: settings.type === "euclidean",
+ icon: ,
+ },
+ {
+ id: "manhattan",
+ title: "City Block Distance",
+ isSelected: settings.type === "manhattan",
+ icon: ,
+ },
+ ];
+
+ // TODO Add keyboard shortcuts
+
+ return (
+
+ onSettingChange({ type: tool.id })}
+ />
+
+ );
+}
+
+export default MeasureToolSettings;
diff --git a/src/components/map/controls/ShapeToolSettings.js b/src/components/map/controls/ShapeToolSettings.js
deleted file mode 100644
index cd388b7..0000000
--- a/src/components/map/controls/ShapeToolSettings.js
+++ /dev/null
@@ -1,69 +0,0 @@
-import React from "react";
-import { Flex } from "theme-ui";
-
-import ColorControl from "./ColorControl";
-import AlphaBlendToggle from "./AlphaBlendToggle";
-import RadioIconButton from "./RadioIconButton";
-
-import ShapeRectangleIcon from "../../../icons/ShapeRectangleIcon";
-import ShapeCircleIcon from "../../../icons/ShapeCircleIcon";
-import ShapeTriangleIcon from "../../../icons/ShapeTriangleIcon";
-
-import UndoButton from "./UndoButton";
-import RedoButton from "./RedoButton";
-
-import Divider from "../../Divider";
-
-function ShapeToolSettings({
- settings,
- onSettingChange,
- onToolAction,
- disabledActions,
-}) {
- return (
-
- onSettingChange({ color })}
- />
-
- onSettingChange({ type: "rectangle" })}
- isSelected={settings.type === "rectangle"}
- >
-
-
- onSettingChange({ type: "circle" })}
- isSelected={settings.type === "circle"}
- >
-
-
- onSettingChange({ type: "triangle" })}
- isSelected={settings.type === "triangle"}
- >
-
-
-
- onSettingChange({ useBlending })}
- />
-
- onToolAction("mapUndo")}
- disabled={disabledActions.includes("undo")}
- />
- onToolAction("mapRedo")}
- disabled={disabledActions.includes("redo")}
- />
-
- );
-}
-
-export default ShapeToolSettings;
diff --git a/src/components/map/controls/ToolSection.js b/src/components/map/controls/ToolSection.js
new file mode 100644
index 0000000..30c24cb
--- /dev/null
+++ b/src/components/map/controls/ToolSection.js
@@ -0,0 +1,103 @@
+import React, { useState, useEffect } from "react";
+import { Box, Flex } from "theme-ui";
+
+import RadioIconButton from "./RadioIconButton";
+
+// Section of map tools with the option to collapse into a vertical list
+function ToolSection({ collapse, tools, onToolClick }) {
+ const [showMore, setShowMore] = useState(false);
+ const [collapsedTool, setCollapsedTool] = useState();
+
+ useEffect(() => {
+ const selectedTool = tools.find((tool) => tool.isSelected);
+ if (selectedTool) {
+ setCollapsedTool(selectedTool);
+ } else {
+ setCollapsedTool(
+ (prevTool) => prevTool && { ...prevTool, isSelected: false }
+ );
+ }
+ }, [tools]);
+
+ function handleToolClick(tool) {
+ if (collapse && tool.isSelected) {
+ setShowMore(!showMore);
+ } else if (collapse && !tool.isSelected) {
+ setShowMore(false);
+ }
+ onToolClick(tool);
+ }
+
+ function renderTool(tool) {
+ return (
+ handleToolClick(tool)}
+ key={tool.id}
+ isSelected={tool.isSelected}
+ >
+ {tool.icon}
+
+ );
+ }
+
+ if (collapse) {
+ if (!collapsedTool) {
+ return null;
+ }
+ return (
+
+ {renderTool(collapsedTool)}
+ {/* Render chevron when more tools is available */}
+
+ {showMore && (
+
+ {tools.filter((tool) => !tool.isSelected).map(renderTool)}
+
+ )}
+
+ );
+ } else {
+ return tools.map((tool) => (
+ handleToolClick(tool)}
+ key={tool.id}
+ isSelected={tool.isSelected}
+ >
+ {tool.icon}
+
+ ));
+ }
+}
+
+ToolSection.defaultProps = {
+ collapse: false,
+};
+
+export default ToolSection;
diff --git a/src/components/token/TokenSettings.js b/src/components/token/TokenSettings.js
index b01103a..92d2f20 100644
--- a/src/components/token/TokenSettings.js
+++ b/src/components/token/TokenSettings.js
@@ -2,6 +2,7 @@ import React from "react";
import { Flex, Box, Input, IconButton, Label, Checkbox } from "theme-ui";
import ExpandMoreIcon from "../../icons/ExpandMoreIcon";
+import { isEmpty } from "../../helpers/shared";
function TokenSettings({
token,
@@ -9,6 +10,7 @@ function TokenSettings({
showMore,
onShowMoreChange,
}) {
+ const tokenEmpty = !token || isEmpty(token);
return (
@@ -21,7 +23,7 @@ function TokenSettings({
onChange={(e) =>
onSettingsChange("defaultSize", parseInt(e.target.value))
}
- disabled={!token || token.type === "default"}
+ disabled={tokenEmpty || token.type === "default"}
min={1}
my={1}
/>
@@ -35,7 +37,7 @@ function TokenSettings({
name="name"
value={(token && token.name) || ""}
onChange={(e) => onSettingsChange("name", e.target.value)}
- disabled={!token || token.type === "default"}
+ disabled={tokenEmpty || token.type === "default"}
my={1}
/>
@@ -44,7 +46,7 @@ function TokenSettings({
onSettingsChange("isVehicle", e.target.checked)
}
@@ -56,7 +58,7 @@ function TokenSettings({
onSettingsChange("hideInSidebar", e.target.checked)
}
@@ -79,7 +81,6 @@ function TokenSettings({
}}
aria-label={showMore ? "Show Less" : "Show More"}
title={showMore ? "Show Less" : "Show More"}
- disabled={!token}
>
diff --git a/src/contexts/MapInteractionContext.js b/src/contexts/MapInteractionContext.js
index 6b11577..cc8397b 100644
--- a/src/contexts/MapInteractionContext.js
+++ b/src/contexts/MapInteractionContext.js
@@ -4,11 +4,10 @@ const MapInteractionContext = React.createContext({
stageScale: 1,
stageWidth: 1,
stageHeight: 1,
- stageDragState: "none",
setPreventMapInteraction: () => {},
mapWidth: 1,
mapHeight: 1,
- mapDragPositionRef: { current: undefined },
+ interactionEmitter: null,
});
export const MapInteractionProvider = MapInteractionContext.Provider;
diff --git a/src/contexts/MapLoadingContext.js b/src/contexts/MapLoadingContext.js
index 13ec1d7..e413a09 100644
--- a/src/contexts/MapLoadingContext.js
+++ b/src/contexts/MapLoadingContext.js
@@ -1,4 +1,5 @@
-import React, { useState } from "react";
+import React, { useState, useRef } from "react";
+import { omit, isEmpty } from "../helpers/shared";
const MapLoadingContext = React.createContext();
@@ -13,12 +14,36 @@ export function MapLoadingProvider({ children }) {
setLoadingAssetCount((prevLoadingAssets) => prevLoadingAssets - 1);
}
+ const assetProgressRef = useRef({});
+ const loadingProgressRef = useRef(null);
+ function assetProgressUpdate({ id, count, total }) {
+ if (count === total) {
+ assetProgressRef.current = omit(assetProgressRef.current, [id]);
+ } else {
+ assetProgressRef.current = {
+ ...assetProgressRef.current,
+ [id]: { count, total },
+ };
+ }
+ if (!isEmpty(assetProgressRef.current)) {
+ let total = 0;
+ let count = 0;
+ for (let progress of Object.values(assetProgressRef.current)) {
+ total += progress.total;
+ count += progress.count;
+ }
+ loadingProgressRef.current = count / total;
+ }
+ }
+
const isLoading = loadingAssetCount > 0;
const value = {
assetLoadStart,
assetLoadFinish,
isLoading,
+ assetProgressUpdate,
+ loadingProgressRef,
};
return (
diff --git a/src/database.js b/src/database.js
index 98dfdb9..e12c614 100644
--- a/src/database.js
+++ b/src/database.js
@@ -100,6 +100,23 @@ function loadVersions(db) {
map.showGrid = false;
});
});
+ // v1.4.0 - Added fog subtraction
+ db.version(5)
+ .stores({})
+ .upgrade((tx) => {
+ return tx
+ .table("states")
+ .toCollection()
+ .modify((state) => {
+ for (let fogAction of state.fogDrawActions) {
+ if (fogAction.type === "add" || fogAction.type === "edit") {
+ for (let shape of fogAction.shapes) {
+ shape.data.holes = [];
+ }
+ }
+ }
+ });
+ });
}
// Get the dexie database used in DatabaseContext
diff --git a/src/docs/faq/connection.md b/src/docs/faq/connection.md
index d69a5a0..8c035f7 100644
--- a/src/docs/faq/connection.md
+++ b/src/docs/faq/connection.md
@@ -17,3 +17,7 @@ The good news is that Safari will still work if the two devices are connected to
### WebRTC not supported.
Owlbear Rodeo uses WebRTC to communicate between players. Ensure your browser supports WebRTC. A list of supported browsers can be found [here](https://caniuse.com/#feat=rtcpeerconnection).
+
+### Unable to connect to party.
+
+This can happen when your internet connection is stable but a peer to peer connection wasn't able to be established between party members. Refreshing the page can help in fixing this.
diff --git a/src/docs/releaseNotes/v1.4.0.md b/src/docs/releaseNotes/v1.4.0.md
new file mode 100644
index 0000000..7c664b4
--- /dev/null
+++ b/src/docs/releaseNotes/v1.4.0.md
@@ -0,0 +1,73 @@
+# v1.4.0
+
+## Major Changes
+
+### Reworked Fog Tools
+
+The fog tool now has more options for supporting different interaction paradigms and allowing for greater accuracy when creating fog shapes.
+
+- The new fog polygon tool now allows you to click on the map to easily place down the perimeter for a fog shape. Each point will snap to the grid allowing a quick method for creating straight shapes that align with the map. This replaces the old grid snap option.
+- When drawing fog shapes there is now the option to set the shape to subtract instead of add. This can be used to carve out existing fog shapes to create more complicated geometry or for creating holes in the fog to show players only portions of an area.
+
+### New Measure Tool
+
+A new measure tool has been added to allow you to easily find out how far areas of the map are from one another. This tool has three options for calculating distance.
+
+- Grid Distance (default) or Chebyshev distance, this is the distance on a grid and is the metric use in D&D.
+- Line Distance or Euclidean distance is the actual distance between the two points of the measure tool.
+- City Block Distance or Manhattan distance is the distance when only travelling in the horizontal or vertical directions.
+
+### Keyboard Shortcut Support
+
+When interacting with the map we now support keyboard shortcuts for quickly switching between tools. A list of supported shortcuts is listed below.
+
+**Tools**
+
+- Pan (W) (The space bar can also be held to quickly toggle to the pan tool when in another tool.)
+- Fog (F)
+- Draw (D)
+- Measure (M)
+
+**Fog**
+
+- Polygon (P)
+- Brush (B)
+- Toggle (T)
+- Remove (R)
+- Add / Subtract (The Alt key can be held to toggle to the other setting e.g. while drawing with the add brush tool, holding Alt will change to the subtract option.)
+- Edge Snapping (S)
+- Undo (Ctrl / Cmd + Z)
+- Redo (Ctrl / Cmd + Shift + Z)
+
+**Drawing**
+
+- Brush (B)
+- Paint (P)
+- Line (L)
+- Rectangle (R)
+- Circle (C)
+- Triangle (T)
+- Erase (E)
+- Toggle Blending (B)
+- Undo (Ctrl / Cmd + Z)
+- Redo (Ctrl / Cmd + Shift + Z)
+
+**Measure**
+
+- Grid (G)
+- Line (L)
+- City Block (C)
+
+## Minor Changes
+
+- The brush tool, shape tool and erase tool have been combined in to one drawing tool. Having these tools combined should hopefully make the drawing experience a little simpler.
+- Added a new line tool that will allow you to draw staight lines.
+- Fixed performance regression for drawing tools that was introduced in v1.3.0.
+- Fixed performance issues when editing map and token settings.
+- Added a notification for when a user can connect to the server but not to other party members.
+- Fixed a bug that lead to a token getting stuck to the cursor when moving.
+- Added a new loading indicator showing the progress of map and tokens when downloading them from other party members.
+- Fixed a bug that stopped the undo and redo buttons for fog editing being synced to other party members.
+
+[Reddit]()
+[Twitter]()
diff --git a/src/helpers/Peer.js b/src/helpers/Peer.js
index 680506b..32984eb 100644
--- a/src/helpers/Peer.js
+++ b/src/helpers/Peer.js
@@ -29,6 +29,12 @@ class Peer extends SimplePeer {
chunk.count++;
this.currentChunks[unpacked.id] = chunk;
+ this.emit("dataProgress", {
+ id: unpacked.id,
+ count: chunk.count,
+ total: chunk.total,
+ });
+
// All chunks have been loaded
if (chunk.count === chunk.total) {
// Merge chunks with a blob
@@ -46,16 +52,19 @@ class Peer extends SimplePeer {
}
send(data) {
- const packedData = encode(data);
-
- if (packedData.byteLength > MAX_BUFFER_SIZE) {
- const chunks = this.chunk(packedData);
- for (let chunk of chunks) {
- super.send(encode(chunk));
+ try {
+ const packedData = encode(data);
+ if (packedData.byteLength > MAX_BUFFER_SIZE) {
+ const chunks = this.chunk(packedData);
+ for (let chunk of chunks) {
+ super.send(encode(chunk));
+ }
+ return;
+ } else {
+ super.send(packedData);
}
- return;
- } else {
- super.send(packedData);
+ } catch (error) {
+ console.error(error);
}
}
diff --git a/src/helpers/drawing.js b/src/helpers/drawing.js
index 0ff64d0..363bac2 100644
--- a/src/helpers/drawing.js
+++ b/src/helpers/drawing.js
@@ -1,7 +1,8 @@
import simplify from "simplify-js";
+import polygonClipping from "polygon-clipping";
import * as Vector2 from "./vector2";
-import { toDegrees } from "./shared";
+import { toDegrees, omit } from "./shared";
const snappingThreshold = 1 / 5;
export function getBrushPositionForTool(
@@ -12,27 +13,58 @@ export function getBrushPositionForTool(
shapes
) {
let position = brushPosition;
- if (tool === "shape") {
- const snapped = Vector2.roundTo(position, gridSize);
+ const useGridSnappning =
+ (tool === "drawing" &&
+ (toolSettings.type === "line" ||
+ toolSettings.type === "rectangle" ||
+ toolSettings.type === "circle" ||
+ toolSettings.type === "triangle")) ||
+ (tool === "fog" && toolSettings.type === "polygon");
+
+ if (useGridSnappning) {
+ // Snap to corners of grid
+ const gridSnap = Vector2.roundTo(position, gridSize);
+ const gridDistance = Vector2.length(Vector2.subtract(gridSnap, position));
+
+ // Snap to center of grid
+ const centerSnap = Vector2.add(
+ Vector2.roundTo(position, gridSize),
+ Vector2.multiply(gridSize, 0.5)
+ );
+ const centerDistance = Vector2.length(
+ Vector2.subtract(centerSnap, position)
+ );
const minGrid = Vector2.min(gridSize);
- const distance = Vector2.length(Vector2.subtract(snapped, position));
- if (distance < minGrid * snappingThreshold) {
- position = snapped;
+ if (gridDistance < minGrid * snappingThreshold) {
+ position = gridSnap;
+ } else if (centerDistance < minGrid * snappingThreshold) {
+ position = centerSnap;
}
}
- if (tool === "fog" && toolSettings.type === "add") {
- if (toolSettings.useGridSnapping) {
- position = Vector2.roundTo(position, gridSize);
- }
- if (toolSettings.useEdgeSnapping) {
- const minGrid = Vector2.min(gridSize);
- let closestDistance = Number.MAX_VALUE;
- let closestPosition = position;
- // Find the closest point on all fog shapes
- for (let shape of shapes) {
- if (shape.type === "fog") {
- const points = shape.data.points;
- const isInShape = Vector2.pointInPolygon(position, points);
+
+ const useEdgeSnapping = tool === "fog" && toolSettings.useEdgeSnapping;
+
+ if (useEdgeSnapping) {
+ const minGrid = Vector2.min(gridSize);
+ let closestDistance = Number.MAX_VALUE;
+ let closestPosition = position;
+ // Find the closest point on all fog shapes
+ for (let shape of shapes) {
+ if (shape.type === "fog") {
+ // Include shape points and holes
+ let pointArray = [shape.data.points, ...shape.data.holes];
+
+ // Check whether the position is in the shape but not any holes
+ let isInShape = Vector2.pointInPolygon(position, shape.data.points);
+ if (shape.data.holes.length > 0) {
+ for (let hole of shape.data.holes) {
+ if (Vector2.pointInPolygon(position, hole)) {
+ isInShape = false;
+ }
+ }
+ }
+
+ for (let points of pointArray) {
// Find the closest point to each line of the shape
for (let i = 0; i < points.length; i++) {
const a = points[i];
@@ -54,15 +86,22 @@ export function getBrushPositionForTool(
}
}
}
- position = closestPosition;
}
+ position = closestPosition;
}
return position;
}
export function getDefaultShapeData(type, brushPosition) {
- if (type === "circle") {
+ if (type === "line") {
+ return {
+ points: [
+ { x: brushPosition.x, y: brushPosition.y },
+ { x: brushPosition.x, y: brushPosition.y },
+ ],
+ };
+ } else if (type === "circle") {
return { x: brushPosition.x, y: brushPosition.y, radius: 0 };
} else if (type === "rectangle") {
return {
@@ -94,7 +133,11 @@ export function getGridScale(gridSize) {
export function getUpdatedShapeData(type, data, brushPosition, gridSize) {
const gridScale = getGridScale(gridSize);
- if (type === "circle") {
+ if (type === "line") {
+ return {
+ points: [data.points[0], { x: brushPosition.x, y: brushPosition.y }],
+ };
+ } else if (type === "circle") {
const dif = Vector2.subtract(brushPosition, {
x: data.x,
y: data.y,
@@ -120,7 +163,7 @@ export function getUpdatedShapeData(type, data, brushPosition, gridSize) {
const length = Vector2.length(scaled);
const direction = Vector2.normalize(scaled);
// Get the angle for a triangle who's width is the same as it's length
- const angle = Math.atan(length / 2 / length);
+ const angle = Math.atan(length / 2 / (length === 0 ? 1 : length));
const sideLength = length / Math.cos(angle);
const leftDir = Vector2.rotateDirection(direction, toDegrees(angle));
@@ -155,3 +198,53 @@ export function simplifyPoints(points, gridSize, scale) {
(Vector2.min(gridSize) * defaultSimplifySize) / scale
);
}
+
+export function drawActionsToShapes(actions, actionIndex) {
+ let shapesById = {};
+ for (let i = 0; i <= actionIndex; i++) {
+ const action = actions[i];
+ if (action.type === "add" || action.type === "edit") {
+ for (let shape of action.shapes) {
+ shapesById[shape.id] = shape;
+ }
+ }
+ if (action.type === "remove") {
+ shapesById = omit(shapesById, action.shapeIds);
+ }
+ if (action.type === "subtract") {
+ const actionGeom = action.shapes.map((actionShape) => [
+ actionShape.data.points.map(({ x, y }) => [x, y]),
+ ]);
+ let subtractedShapes = {};
+ for (let shape of Object.values(shapesById)) {
+ const shapePoints = shape.data.points.map(({ x, y }) => [x, y]);
+ const shapeHoles = shape.data.holes.map((hole) =>
+ hole.map(({ x, y }) => [x, y])
+ );
+ let shapeGeom = [[shapePoints, ...shapeHoles]];
+ const difference = polygonClipping.difference(shapeGeom, actionGeom);
+ for (let i = 0; i < difference.length; i++) {
+ let newId = difference.length > 1 ? `${shape.id}-${i}` : shape.id;
+ // Holes detected
+ let holes = [];
+ if (difference[i].length > 1) {
+ for (let j = 1; j < difference[i].length; j++) {
+ holes.push(difference[i][j].map(([x, y]) => ({ x, y })));
+ }
+ }
+
+ subtractedShapes[newId] = {
+ ...shape,
+ id: newId,
+ data: {
+ points: difference[i][0].map(([x, y]) => ({ x, y })),
+ holes,
+ },
+ };
+ }
+ }
+ shapesById = subtractedShapes;
+ }
+ }
+ return Object.values(shapesById);
+}
diff --git a/src/helpers/konva.js b/src/helpers/konva.js
new file mode 100644
index 0000000..83da4dd
--- /dev/null
+++ b/src/helpers/konva.js
@@ -0,0 +1,156 @@
+import React, { useState } from "react";
+import { Line, Group, Path, Circle } from "react-konva";
+
+// Holes should be wound in the opposite direction as the containing points array
+export function HoleyLine({ holes, ...props }) {
+ // Converted from https://github.com/rfestag/konva/blob/master/src/shapes/Line.ts
+ function drawLine(points, context, shape) {
+ const length = points.length;
+ const tension = shape.tension();
+ const closed = shape.closed();
+ const bezier = shape.bezier();
+
+ if (!length) {
+ return;
+ }
+
+ context.moveTo(points[0], points[1]);
+
+ if (tension !== 0 && length > 4) {
+ const tensionPoints = shape.getTensionPoints();
+ const tensionLength = tensionPoints.length;
+ let n = closed ? 0 : 4;
+
+ if (!closed) {
+ context.quadraticCurveTo(
+ tensionPoints[0],
+ tensionPoints[1],
+ tensionPoints[2],
+ tensionPoints[3]
+ );
+ }
+
+ while (n < tensionLength - 2) {
+ context.bezierCurveTo(
+ tensionPoints[n++],
+ tensionPoints[n++],
+ tensionPoints[n++],
+ tensionPoints[n++],
+ tensionPoints[n++],
+ tensionPoints[n++]
+ );
+ }
+
+ if (!closed) {
+ context.quadraticCurveTo(
+ tensionPoints[tensionLength - 2],
+ tensionPoints[tensionLength - 1],
+ points[length - 2],
+ points[length - 1]
+ );
+ }
+ } else if (bezier) {
+ // no tension but bezier
+ let n = 2;
+
+ while (n < length) {
+ context.bezierCurveTo(
+ points[n++],
+ points[n++],
+ points[n++],
+ points[n++],
+ points[n++],
+ points[n++]
+ );
+ }
+ } else {
+ // no tension
+ for (let n = 2; n < length; n += 2) {
+ context.lineTo(points[n], points[n + 1]);
+ }
+ }
+ }
+
+ // Draw points and holes
+ function sceneFunc(context, shape) {
+ const points = shape.points();
+ const closed = shape.closed();
+
+ if (!points.length) {
+ return;
+ }
+
+ context.beginPath();
+ drawLine(points, context, shape);
+
+ context.beginPath();
+ drawLine(points, context, shape);
+
+ // closed e.g. polygons and blobs
+ if (closed) {
+ context.closePath();
+ if (holes && holes.length) {
+ for (let hole of holes) {
+ drawLine(hole, context, shape);
+ context.closePath();
+ }
+ }
+ context.fillStrokeShape(shape);
+ } else {
+ // open e.g. lines and splines
+ context.strokeShape(shape);
+ }
+ }
+
+ return ;
+}
+
+export function Tick({ x, y, scale, onClick, cross }) {
+ const [fill, setFill] = useState("white");
+ function handleEnter() {
+ setFill("hsl(260, 100%, 80%)");
+ }
+
+ function handleLeave() {
+ setFill("white");
+ }
+ return (
+
+
+
+
+ );
+}
+
+export function getRelativePointerPosition(node) {
+ let transform = node.getAbsoluteTransform().copy();
+ transform.invert();
+ let posision = node.getStage().getPointerPosition();
+ return transform.point(posision);
+}
+
+export function getRelativePointerPositionNormalized(node) {
+ const relativePosition = getRelativePointerPosition(node);
+ return {
+ x: relativePosition.x / node.width(),
+ y: relativePosition.y / node.height(),
+ };
+}
diff --git a/src/helpers/shared.js b/src/helpers/shared.js
index 8f11569..68277f9 100644
--- a/src/helpers/shared.js
+++ b/src/helpers/shared.js
@@ -50,3 +50,7 @@ export function logImage(url, width, height) {
].join(" ");
console.log("%c ", style);
}
+
+export function isEmpty(obj) {
+ return Object.keys(obj).length === 0 && obj.constructor === Object;
+}
diff --git a/src/helpers/useMapBrush.js b/src/helpers/useMapBrush.js
deleted file mode 100644
index 3c96f7d..0000000
--- a/src/helpers/useMapBrush.js
+++ /dev/null
@@ -1,71 +0,0 @@
-import { useContext, useRef, useEffect } from "react";
-
-import MapInteractionContext from "../contexts/MapInteractionContext";
-
-import { compare } from "./vector2";
-
-import usePrevious from "./usePrevious";
-
-/**
- * @callback onBrushUpdate
- * @param {string} drawState "first" | "drawing" | "last"
- * @param {Object} brushPosition the normalized x and y coordinates of the brush on the map
- */
-
-/**
- * Helper to get the maps drag position as it changes
- * @param {boolean} shouldUpdate
- * @param {onBrushUpdate} onBrushUpdate
- */
-function useMapBrush(shouldUpdate, onBrushUpdate) {
- const { stageDragState, mapDragPositionRef } = useContext(
- MapInteractionContext
- );
-
- const requestRef = useRef();
- const previousDragState = usePrevious(stageDragState);
- const previousBrushPositionRef = useRef(mapDragPositionRef.current);
-
- useEffect(() => {
- function updateBrush(forceUpdate) {
- const drawState =
- stageDragState === "dragging" ? "drawing" : stageDragState;
- const brushPosition = mapDragPositionRef.current;
- const previousBrushPostition = previousBrushPositionRef.current;
- // Only update brush when it has moved
- if (
- !compare(brushPosition, previousBrushPostition, 0.0001) ||
- forceUpdate
- ) {
- onBrushUpdate(drawState, brushPosition);
- previousBrushPositionRef.current = brushPosition;
- }
- }
-
- function animate() {
- if (!shouldUpdate) {
- return;
- }
- requestRef.current = requestAnimationFrame(animate);
- updateBrush(false);
- }
-
- requestRef.current = requestAnimationFrame(animate);
-
- if (stageDragState !== previousDragState && shouldUpdate) {
- updateBrush(true);
- }
-
- return () => {
- cancelAnimationFrame(requestRef.current);
- };
- }, [
- shouldUpdate,
- onBrushUpdate,
- stageDragState,
- mapDragPositionRef,
- previousDragState,
- ]);
-}
-
-export default useMapBrush;
diff --git a/src/helpers/useSession.js b/src/helpers/useSession.js
index 7d52331..282ceba 100644
--- a/src/helpers/useSession.js
+++ b/src/helpers/useSession.js
@@ -13,6 +13,7 @@ function useSession(
onPeerConnected,
onPeerDisconnected,
onPeerData,
+ onPeerDataProgress,
onPeerTrackAdded,
onPeerTrackRemoved,
onPeerError
@@ -76,6 +77,10 @@ function useSession(
onPeerData && onPeerData({ peer, data });
}
+ function handleDataProgress({ id, count, total }) {
+ onPeerDataProgress && onPeerDataProgress({ id, count, total });
+ }
+
function handleTrack(track, stream) {
onPeerTrackAdded && onPeerTrackAdded({ peer, track, stream });
track.addEventListener("mute", () => {
@@ -96,6 +101,7 @@ function useSession(
peer.connection.on("signal", handleSignal);
peer.connection.on("connect", handleConnect);
peer.connection.on("dataComplete", handleDataComplete);
+ peer.connection.on("dataProgress", handleDataProgress);
peer.connection.on("track", handleTrack);
peer.connection.on("close", handleClose);
peer.connection.on("error", handleError);
@@ -105,6 +111,7 @@ function useSession(
handleSignal,
handleConnect,
handleDataComplete,
+ handleDataProgress,
handleTrack,
handleClose,
handleError,
@@ -118,6 +125,7 @@ function useSession(
handleSignal,
handleConnect,
handleDataComplete,
+ handleDataProgress,
handleTrack,
handleClose,
handleError,
@@ -125,6 +133,7 @@ function useSession(
peer.connection.off("signal", handleSignal);
peer.connection.off("connect", handleConnect);
peer.connection.off("dataComplete", handleDataComplete);
+ peer.connection.off("dataProgress", handleDataProgress);
peer.connection.off("track", handleTrack);
peer.connection.off("close", handleClose);
peer.connection.off("error", handleError);
@@ -135,6 +144,7 @@ function useSession(
onPeerConnected,
onPeerDisconnected,
onPeerData,
+ onPeerDataProgress,
onPeerTrackAdded,
onPeerTrackRemoved,
onPeerError,
@@ -143,16 +153,19 @@ function useSession(
// Setup event listeners for the socket
useEffect(() => {
function addPeer(id, initiator, sync) {
- const connection = new Peer({
- initiator,
- trickle: true,
- config: { iceServers },
- });
-
- setPeers((prevPeers) => ({
- ...prevPeers,
- [id]: { id, connection, initiator, sync },
- }));
+ try {
+ const connection = new Peer({
+ initiator,
+ trickle: true,
+ config: { iceServers },
+ });
+ setPeers((prevPeers) => ({
+ ...prevPeers,
+ [id]: { id, connection, initiator, sync },
+ }));
+ } catch (error) {
+ onPeerError && onPeerError({ error });
+ }
}
function handlePartyMemberJoined(id) {
@@ -214,7 +227,7 @@ function useSession(
socket.off("signal", handleSignal);
socket.off("auth error", handleAuthError);
};
- }, [peers, setAuthenticationStatus, iceServers, joinParty]);
+ }, [peers, setAuthenticationStatus, iceServers, joinParty, onPeerError]);
return { peers, socket, connected };
}
diff --git a/src/helpers/vector2.js b/src/helpers/vector2.js
index 41be521..de79f9b 100644
--- a/src/helpers/vector2.js
+++ b/src/helpers/vector2.js
@@ -10,6 +10,9 @@ export function length(p) {
export function normalize(p) {
const l = length(p);
+ if (l === 0) {
+ return { x: 0, y: 0 };
+ }
return divide(p, l);
}
@@ -216,3 +219,22 @@ export function pointInPolygon(p, points) {
export function compare(a, b, threshold) {
return lengthSquared(subtract(a, b)) < threshold * threshold;
}
+
+/**
+ * Returns the distance between two vectors
+ * @param {Vector2} a
+ * @param {Vector2} b
+ * @param {string} type - "chebyshev" | "euclidean" | "manhattan"
+ */
+export function distance(a, b, type) {
+ switch (type) {
+ case "chebyshev":
+ return Math.max(Math.abs(a.x - b.x), Math.abs(a.y - b.y));
+ case "euclidean":
+ return length(subtract(a, b));
+ case "manhattan":
+ return Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
+ default:
+ return length(subtract(a, b));
+ }
+}
diff --git a/src/icons/ShapeCircleIcon.js b/src/icons/BrushCircleIcon.js
similarity index 84%
rename from src/icons/ShapeCircleIcon.js
rename to src/icons/BrushCircleIcon.js
index c393782..b4cf4ed 100644
--- a/src/icons/ShapeCircleIcon.js
+++ b/src/icons/BrushCircleIcon.js
@@ -1,6 +1,6 @@
import React from "react";
-function ShapeCircleIcon() {
+function BrushCircleIcon() {
return (
+ );
+}
+
+export default BrushLineIcon;
diff --git a/src/icons/BrushPaintIcon.js b/src/icons/BrushPaintIcon.js
new file mode 100644
index 0000000..3a59e77
--- /dev/null
+++ b/src/icons/BrushPaintIcon.js
@@ -0,0 +1,18 @@
+import React from "react";
+
+function BrushPaintIcon() {
+ return (
+
+ );
+}
+
+export default BrushPaintIcon;
diff --git a/src/icons/ShapeRectangleIcon.js b/src/icons/BrushRectangleIcon.js
similarity index 83%
rename from src/icons/ShapeRectangleIcon.js
rename to src/icons/BrushRectangleIcon.js
index 55d00ee..9856b88 100644
--- a/src/icons/ShapeRectangleIcon.js
+++ b/src/icons/BrushRectangleIcon.js
@@ -1,6 +1,6 @@
import React from "react";
-function ShapeRectangleIcon() {
+function BrushRectangleIcon() {
return (
);
}
diff --git a/src/icons/BrushFillIcon.js b/src/icons/FogBrushIcon.js
similarity index 88%
rename from src/icons/BrushFillIcon.js
rename to src/icons/FogBrushIcon.js
index b0776df..827c10d 100644
--- a/src/icons/BrushFillIcon.js
+++ b/src/icons/FogBrushIcon.js
@@ -1,6 +1,6 @@
import React from "react";
-function BrushFillIcon() {
+function FogBrushIcon() {
return (
+ );
+}
+
+export default FogPolygonIcon;
diff --git a/src/icons/FogRemoveIcon.js b/src/icons/FogRemoveIcon.js
index af6c2e6..9ad8330 100644
--- a/src/icons/FogRemoveIcon.js
+++ b/src/icons/FogRemoveIcon.js
@@ -10,7 +10,7 @@ function FogRemoveIcon() {
fill="currentcolor"
>
-
+
);
}
diff --git a/src/icons/FogSubtractIcon.js b/src/icons/FogSubtractIcon.js
new file mode 100644
index 0000000..af6c2e6
--- /dev/null
+++ b/src/icons/FogSubtractIcon.js
@@ -0,0 +1,18 @@
+import React from "react";
+
+function FogRemoveIcon() {
+ return (
+
+ );
+}
+
+export default FogRemoveIcon;
diff --git a/src/icons/GridOffIcon.js b/src/icons/GridOffIcon.js
deleted file mode 100644
index a1287c2..0000000
--- a/src/icons/GridOffIcon.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import React from "react";
-
-function GridOffIcon() {
- return (
-
- );
-}
-
-export default GridOffIcon;
diff --git a/src/icons/GridOnIcon.js b/src/icons/GridOnIcon.js
deleted file mode 100644
index 1796b30..0000000
--- a/src/icons/GridOnIcon.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import React from "react";
-
-function GridOnIcon() {
- return (
-
- );
-}
-
-export default GridOnIcon;
diff --git a/src/icons/MeasureChebyshevIcon.js b/src/icons/MeasureChebyshevIcon.js
new file mode 100644
index 0000000..5073dd2
--- /dev/null
+++ b/src/icons/MeasureChebyshevIcon.js
@@ -0,0 +1,18 @@
+import React from "react";
+
+function MeasureChebyshevIcon() {
+ return (
+
+ );
+}
+
+export default MeasureChebyshevIcon;
diff --git a/src/icons/MeasureEuclideanIcon.js b/src/icons/MeasureEuclideanIcon.js
new file mode 100644
index 0000000..6b29bf5
--- /dev/null
+++ b/src/icons/MeasureEuclideanIcon.js
@@ -0,0 +1,20 @@
+import React from "react";
+
+function MeasureEuclideanIcon() {
+ return (
+
+ );
+}
+
+export default MeasureEuclideanIcon;
diff --git a/src/icons/MeasureManhattanIcon.js b/src/icons/MeasureManhattanIcon.js
new file mode 100644
index 0000000..5371f99
--- /dev/null
+++ b/src/icons/MeasureManhattanIcon.js
@@ -0,0 +1,18 @@
+import React from "react";
+
+function MeasureManhattanIcon() {
+ return (
+
+ );
+}
+
+export default MeasureManhattanIcon;
diff --git a/src/icons/MeasureToolIcon.js b/src/icons/MeasureToolIcon.js
new file mode 100644
index 0000000..d62c53b
--- /dev/null
+++ b/src/icons/MeasureToolIcon.js
@@ -0,0 +1,19 @@
+import React from "react";
+
+function MeasureToolIcon() {
+ return (
+
+ );
+}
+
+export default MeasureToolIcon;
diff --git a/src/icons/ShapeToolIcon.js b/src/icons/ShapeToolIcon.js
deleted file mode 100644
index aa4398f..0000000
--- a/src/icons/ShapeToolIcon.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import React from "react";
-
-function ShapeToolIcon() {
- return (
-
- );
-}
-
-export default ShapeToolIcon;
diff --git a/src/modals/SelectMapModal.js b/src/modals/SelectMapModal.js
index bebe250..a053602 100644
--- a/src/modals/SelectMapModal.js
+++ b/src/modals/SelectMapModal.js
@@ -12,6 +12,8 @@ import blobToBuffer from "../helpers/blobToBuffer";
import MapDataContext from "../contexts/MapDataContext";
import AuthContext from "../contexts/AuthContext";
+import { isEmpty } from "../helpers/shared";
+
const defaultMapSize = 22;
const defaultMapProps = {
// Grid type
@@ -22,7 +24,6 @@ const defaultMapProps = {
function SelectMapModal({
isOpen,
- onRequestClose,
onDone,
onMapChange,
onMapStateChange,
@@ -147,7 +148,8 @@ function SelectMapModal({
}
}
- function handleMapSelect(map) {
+ async function handleMapSelect(map) {
+ await applyMapChanges();
setSelectedMapId(map.id);
}
@@ -161,8 +163,8 @@ function SelectMapModal({
async function handleDone() {
if (selectedMapId) {
- onMapChange(selectedMap, selectedMapState);
- onDone();
+ await applyMapChanges();
+ onMapChange(selectedMapWithChanges, selectedMapStateWithChanges);
}
onDone();
}
@@ -171,17 +173,43 @@ function SelectMapModal({
* Map settings
*/
const [showMoreSettings, setShowMoreSettings] = useState(false);
+ // Local cache of map setting changes
+ // Applied when done is clicked or map selection is changed
+ const [mapSettingChanges, setMapSettingChanges] = useState({});
+ const [mapStateSettingChanges, setMapStateSettingChanges] = useState({});
- async function handleMapSettingsChange(key, value) {
- await updateMap(selectedMapId, { [key]: value });
+ function handleMapSettingsChange(key, value) {
+ setMapSettingChanges((prevChanges) => ({ ...prevChanges, [key]: value }));
}
- async function handleMapStateSettingsChange(key, value) {
- await updateMapState(selectedMapId, { [key]: value });
+ function handleMapStateSettingsChange(key, value) {
+ setMapStateSettingChanges((prevChanges) => ({
+ ...prevChanges,
+ [key]: value,
+ }));
}
+ async function applyMapChanges() {
+ if (
+ selectedMapId &&
+ (!isEmpty(mapSettingChanges) || !isEmpty(mapStateSettingChanges))
+ ) {
+ await updateMap(selectedMapId, mapSettingChanges);
+ await updateMapState(selectedMapId, mapStateSettingChanges);
+
+ setMapSettingChanges({});
+ setMapStateSettingChanges({});
+ }
+ }
+
+ const selectedMapWithChanges = { ...selectedMap, ...mapSettingChanges };
+ const selectedMapStateWithChanges = {
+ ...selectedMapState,
+ ...mapStateSettingChanges,
+ };
+
return (
-
+
handleImagesUpload(event.target.files)}
@@ -203,15 +231,15 @@ function SelectMapModal({
maps={ownedMaps}
onMapAdd={openImageDialog}
onMapRemove={handleMapRemove}
- selectedMap={selectedMap}
- selectedMapState={selectedMapState}
+ selectedMap={selectedMapWithChanges}
+ selectedMapState={selectedMapStateWithChanges}
onMapSelect={handleMapSelect}
onMapReset={handleMapReset}
onDone={handleDone}
/>
({ ...prevChanges, [key]: value }));
}
+ async function applyTokenChanges() {
+ if (selectedTokenId && !isEmpty(tokenSettingChanges)) {
+ await updateToken(selectedTokenId, tokenSettingChanges);
+ setTokenSettingChanges({});
+ }
+ }
+
+ async function handleRequestClose() {
+ await applyTokenChanges();
+ onRequestClose();
+ }
+
+ const selectedTokenWithChanges = { ...selectedToken, ...tokenSettingChanges };
+
return (
-
+
handleImagesUpload(event.target.files)}
@@ -126,12 +145,12 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
Done
diff --git a/src/routes/Game.js b/src/routes/Game.js
index 6f2de46..9acfdd0 100644
--- a/src/routes/Game.js
+++ b/src/routes/Game.js
@@ -33,13 +33,16 @@ function Game() {
const { authenticationStatus, userId, nickname, setNickname } = useContext(
AuthContext
);
- const { assetLoadStart, assetLoadFinish } = useContext(MapLoadingContext);
+ const { assetLoadStart, assetLoadFinish, assetProgressUpdate } = useContext(
+ MapLoadingContext
+ );
const { peers, socket, connected } = useSession(
gameId,
handlePeerConnected,
handlePeerDisconnected,
handlePeerData,
+ handlePeerDataProgress,
handlePeerTrackAdded,
handlePeerTrackRemoved,
handlePeerError
@@ -145,7 +148,7 @@ function Game() {
});
}
- function updateDrawActionIndex(change, indexKey, actionsKey, peerId) {
+ function updateDrawActionIndex(change, indexKey, actionsKey) {
const newIndex = Math.min(
Math.max(currentMapState[indexKey] + change, -1),
currentMapState[actionsKey].length - 1
@@ -201,7 +204,7 @@ function Game() {
"fogDrawActions"
);
for (let peer of Object.values(peers)) {
- peer.connection.send({ id: "fogDrawIndex", data: index });
+ peer.connection.send({ id: "mapFogIndex", data: index });
}
}
@@ -212,7 +215,7 @@ function Game() {
"fogDrawActions"
);
for (let peer of Object.values(peers)) {
- peer.connection.send({ id: "fogDrawIndex", data: index });
+ peer.connection.send({ id: "mapFogIndex", data: index });
}
}
@@ -322,7 +325,6 @@ function Game() {
if (cachedMap && cachedMap.lastModified === newMap.lastModified) {
setCurrentMap(cachedMap);
} else {
- assetLoadStart();
peer.connection.send({ id: "mapRequest", data: newMap.id });
}
} else {
@@ -336,7 +338,6 @@ function Game() {
}
// A new map response with a file attached
if (data.id === "mapResponse") {
- assetLoadFinish();
if (data.data && data.data.type === "file") {
const newMap = { ...data.data, file: data.data.file };
putMap(newMap).then(() => {
@@ -357,7 +358,6 @@ function Game() {
!cachedToken ||
cachedToken.lastModified !== newToken.lastModified
) {
- assetLoadStart();
peer.connection.send({
id: "tokenRequest",
data: newToken.id,
@@ -370,7 +370,6 @@ function Game() {
peer.connection.send({ id: "tokenResponse", data: token });
}
if (data.id === "tokenResponse") {
- assetLoadFinish();
const newToken = data.data;
if (newToken && newToken.type === "file") {
putToken(newToken);
@@ -414,6 +413,16 @@ function Game() {
}
}
+ function handlePeerDataProgress({ id, total, count }) {
+ if (count === 1) {
+ assetLoadStart();
+ }
+ if (total === count) {
+ assetLoadFinish();
+ }
+ assetProgressUpdate({ id, total, count });
+ }
+
function handlePeerDisconnected(peer) {
setPartyNicknames((prevNicknames) => omit(prevNicknames, [peer.id]));
}
@@ -422,7 +431,9 @@ function Game() {
function handlePeerError({ error, peer }) {
console.error(error.code);
if (error.code === "ERR_WEBRTC_SUPPORT") {
- setPeerError("WebRTC not supported");
+ setPeerError("WebRTC not supported.");
+ } else if (error.code === "ERR_CREATE_OFFER") {
+ setPeerError("Unable to connect to party.");
}
}
diff --git a/src/routes/Home.js b/src/routes/Home.js
index 845478a..84a5169 100644
--- a/src/routes/Home.js
+++ b/src/routes/Home.js
@@ -51,7 +51,7 @@ function Home() {
Join Game
- Beta v1.3.3
+ Beta v1.4.0