Merge pull request #13 from mitchemmc/release/v1.4.0

Release/v1.4.0
This commit is contained in:
Mitchell McCaffrey 2020-06-28 19:09:52 +10:00 committed by GitHub
commit fac104367a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
61 changed files with 2044 additions and 788 deletions

View File

@ -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",

View File

@ -15,6 +15,7 @@ function LoadingOverlay() {
alignItems: "center",
top: 0,
left: 0,
flexDirection: "column",
}}
bg="muted"
>

View File

@ -30,14 +30,20 @@ function StyledModal({
}}
{...props}
>
{children}
{allowClose && (
<Close
m={0}
sx={{ position: "absolute", top: 0, right: 0 }}
onClick={onRequestClose}
/>
)}
{/* Stop keyboard events when modal is open to prevent shortcuts from triggering */}
<div
onKeyDown={(e) => e.stopPropagation()}
onKeyUp={(e) => e.stopPropagation()}
>
{children}
{allowClose && (
<Close
m={0}
sx={{ position: "absolute", top: 0, right: 0 }}
onClick={onRequestClose}
/>
)}
</div>
</Modal>
);
}

View File

@ -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({
<MapFog
shapes={fogShapes}
onShapeAdd={handleFogShapeAdd}
onShapeSubtract={handleFogShapeSubtract}
onShapesRemove={handleFogShapesRemove}
onShapesEdit={handleFogShapesEdit}
selectedToolId={selectedToolId}
@ -299,6 +280,14 @@ function Map({
<MapGrid map={map} gridSize={gridSizeNormalized} />
);
const mapMeasure = (
<MapMeasure
active={selectedToolId === "measure"}
gridSize={gridSizeNormalized}
selectedToolSettings={toolSettings[selectedToolId]}
/>
);
return (
<MapInteraction
map={map}
@ -308,15 +297,18 @@ function Map({
{tokenMenu}
{tokenDragOverlay}
<MapDice />
{isLoading && <LoadingOverlay />}
<MapLoadingOverlay />
</>
}
selectedToolId={selectedToolId}
onSelectedToolChange={setSelectedToolId}
disabledControls={disabledControls}
>
{mapGrid}
{mapDrawing}
{mapTokens}
{mapFog}
{mapMeasure}
</MapInteraction>
);
}

View File

@ -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: <BrushToolIcon />,
title: "Brush Tool",
SettingsComponent: BrushToolSettings,
title: "Drawing Tool",
SettingsComponent: DrawingToolSettings,
},
shape: {
id: "shape",
icon: <ShapeToolIcon />,
title: "Shape Tool",
SettingsComponent: ShapeToolSettings,
},
erase: {
id: "erase",
icon: <EraseToolIcon />,
title: "Erase tool",
SettingsComponent: EraseToolSettings,
measure: {
id: "measure",
icon: <MeasureToolIcon />,
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) => (
<RadioIconButton
key={tool}

View File

@ -1,8 +1,9 @@
import React, { useContext, useState, useCallback } from "react";
import React, { useContext, useState, useEffect } from "react";
import shortid from "shortid";
import { Group, Line, Rect, Circle } from "react-konva";
import MapInteractionContext from "../../contexts/MapInteractionContext";
import MapStageContext from "../../contexts/MapStageContext";
import { compare as comparePoints } from "../../helpers/vector2";
import {
@ -12,9 +13,9 @@ import {
simplifyPoints,
getStrokeWidth,
} from "../../helpers/drawing";
import { getRelativePointerPositionNormalized } from "../../helpers/konva";
import colors from "../../helpers/colors";
import useMapBrush from "../../helpers/useMapBrush";
function MapDrawing({
shapes,
@ -24,69 +25,76 @@ function MapDrawing({
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 [erasingShapes, setErasingShapes] = useState([]);
const shouldHover = selectedToolId === "erase";
const isEditing =
selectedToolId === "brush" ||
selectedToolId === "shape" ||
selectedToolId === "erase";
const shouldHover =
selectedToolSettings && selectedToolSettings.type === "erase";
const isEditing = selectedToolId === "drawing";
const isBrush =
selectedToolSettings &&
(selectedToolSettings.type === "brush" ||
selectedToolSettings.type === "paint");
const isShape =
selectedToolSettings &&
(selectedToolSettings.type === "line" ||
selectedToolSettings.type === "rectangle" ||
selectedToolSettings.type === "circle" ||
selectedToolSettings.type === "triangle");
const handleBrushUp = useCallback(() => {
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 (
<Line
points={shape.data.points.reduce(
(acc, point) => [...acc, point.x * mapWidth, point.y * mapHeight],
[]
)}
strokeWidth={getStrokeWidth(
shape.strokeWidth,
gridSize,
mapWidth,
mapHeight
)}
stroke={colors[shape.color] || shape.color}
lineCap="round"
{...defaultProps}
/>
);
}
}
}

View File

@ -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 (
<Line
<HoleyLine
key={shape.id}
onMouseMove={() => 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 (
<Tick
x={shape.data.points[0].x * mapWidth}
y={shape.data.points[0].y * mapHeight}
scale={1 / stageScale}
cross={isCross}
onClick={(e) => {
e.cancelBubble = true;
if (isCross) {
setDrawingShape(null);
} else {
finishDrawingPolygon();
}
}}
/>
);
}
return (
<Group>
{shapes.map(renderShape)}
{drawingShape && renderShape(drawingShape)}
{drawingShape &&
selectedToolSettings &&
selectedToolSettings.type === "polygon" &&
renderPolygonAcceptTick(drawingShape)}
{editingShapes.length > 0 && editingShapes.map(renderEditingShape)}
</Group>
);

View File

@ -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}
/>
);
}

View File

@ -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}
>
<ReactResizeDetector handleWidth handleHeight onResize={handleResize}>
<Stage
@ -234,7 +279,9 @@ function MapInteraction({ map, children, controls, selectedToolId }) {
{/* Forward auth context to konva elements */}
<AuthContext.Provider value={auth}>
<MapInteractionProvider value={mapInteraction}>
{children}
<MapStageProvider value={mapStageRef}>
{children}
</MapStageProvider>
</MapInteractionProvider>
</AuthContext.Provider>
</Layer>

View File

@ -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 && (
<Box
sx={{
position: "absolute",
width: "100%",
height: "100%",
display: "flex",
justifyContent: "center",
alignItems: "center",
top: 0,
left: 0,
flexDirection: "column",
}}
bg="muted"
>
<Spinner />
<Progress
ref={progressBarRef}
max={1}
value={0}
m={2}
sx={{ width: "32px" }}
/>
</Box>
)
);
}
export default MapLoadingOverlay;

View File

@ -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 (
<Group>
<Line
points={linePoints}
strokeWidth={getStrokeWidth(1.5, gridSize, mapWidth, mapHeight)}
stroke="hsla(230, 25%, 18%, 0.8)"
lineCap="round"
/>
<Line
points={linePoints}
strokeWidth={getStrokeWidth(0.25, gridSize, mapWidth, mapHeight)}
stroke="white"
lineCap="round"
/>
<Label
x={lineCenter.x}
y={lineCenter.y}
offsetX={26}
offsetY={26}
scaleX={1 / stageScale}
scaleY={1 / stageScale}
>
<Tag fill="hsla(230, 25%, 18%, 0.8)" cornerRadius={4} />
<Text
text={shapeData.length.toFixed(2)}
fill="white"
fontSize={24}
padding={4}
/>
</Label>
</Group>
);
}
return <Group>{drawingShapeData && renderShape(drawingShapeData)}</Group>;
}
export default MapMeasure;

View File

@ -79,7 +79,13 @@ function MapMenu({
}}
contentRef={handleModalContent}
>
{children}
{/* Stop keyboard events when modal is open to prevent shortcuts from triggering */}
<div
onKeyDown={(e) => e.stopPropagation()}
onKeyUp={(e) => e.stopPropagation()}
>
{children}
</div>
</Modal>
);
}

View File

@ -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 (
<Flex sx={{ flexDirection: "column" }}>
<Flex>
@ -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}
/>
</Box>
@ -72,7 +77,7 @@ function MapSettings({
<Label>
<Checkbox
checked={map && map.showGrid}
disabled={!map || map.type === "default"}
disabled={mapEmpty || map.type === "default"}
onChange={(e) => onSettingsChange("showGrid", e.target.checked)}
/>
Show Grid
@ -84,24 +89,28 @@ function MapSettings({
<Flex my={1}>
<Label>
<Checkbox
checked={mapState && mapState.editFlags.includes("fog")}
disabled={!mapState}
checked={!mapStateEmpty && mapState.editFlags.includes("fog")}
disabled={mapStateEmpty}
onChange={(e) => handleFlagChange(e, "fog")}
/>
Fog
</Label>
<Label>
<Checkbox
checked={mapState && mapState.editFlags.includes("drawing")}
disabled={mapState === null}
checked={
!mapStateEmpty && mapState.editFlags.includes("drawing")
}
disabled={mapStateEmpty}
onChange={(e) => handleFlagChange(e, "drawing")}
/>
Drawings
</Label>
<Label>
<Checkbox
checked={mapState && mapState.editFlags.includes("tokens")}
disabled={!mapState}
checked={
!mapStateEmpty && mapState.editFlags.includes("tokens")
}
disabled={mapStateEmpty}
onChange={(e) => handleFlagChange(e, "tokens")}
/>
Tokens
@ -122,7 +131,6 @@ function MapSettings({
}}
aria-label={showMore ? "Show Less" : "Show More"}
title={showMore ? "Show Less" : "Show More"}
disabled={!map}
>
<ExpandMoreIcon />
</IconButton>

View File

@ -58,20 +58,23 @@ function MapTiles({
>
<AddIcon large />
</Flex>
{maps.map((map) => (
<MapTile
key={map.id}
map={map}
mapState={
selectedMap && map.id === selectedMap.id && selectedMapState
}
isSelected={selectedMap && map.id === selectedMap.id}
onMapSelect={onMapSelect}
onMapRemove={onMapRemove}
onMapReset={onMapReset}
onDone={onDone}
/>
))}
{maps.map((map) => {
const isSelected = selectedMap && map.id === selectedMap.id;
return (
<MapTile
key={map.id}
// TODO: Move to selected map here and fix url error
// when done is clicked
map={map}
mapState={isSelected && selectedMapState}
isSelected={isSelected}
onMapSelect={onMapSelect}
onMapRemove={onMapRemove}
onMapReset={onMapReset}
onDone={onDone}
/>
);
})}
</Flex>
</SimpleBar>
{databaseStatus === "disabled" && (

View File

@ -140,7 +140,7 @@ function MapToken({
}
function handlePointerOut() {
if (!draggable) {
if (tokenOpacity !== 1.0) {
setTokenOpacity(1.0);
}
}

View File

@ -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({
</IconButton>
<SelectMapModal
isOpen={isModalOpen}
onRequestClose={closeModal}
onDone={handleDone}
onMapChange={onMapChange}
onMapStateChange={onMapStateChange}

View File

@ -1,61 +0,0 @@
import React from "react";
import { Flex } from "theme-ui";
import ColorControl from "./ColorControl";
import AlphaBlendToggle from "./AlphaBlendToggle";
import RadioIconButton from "./RadioIconButton";
import BrushStrokeIcon from "../../../icons/BrushStrokeIcon";
import BrushFillIcon from "../../../icons/BrushFillIcon";
import UndoButton from "./UndoButton";
import RedoButton from "./RedoButton";
import Divider from "../../Divider";
function BrushToolSettings({
settings,
onSettingChange,
onToolAction,
disabledActions,
}) {
return (
<Flex sx={{ alignItems: "center" }}>
<ColorControl
color={settings.color}
onColorChange={(color) => onSettingChange({ color })}
/>
<Divider vertical />
<RadioIconButton
title="Brush Type Stroke"
onClick={() => onSettingChange({ type: "stroke" })}
isSelected={settings.type === "stroke"}
>
<BrushStrokeIcon />
</RadioIconButton>
<RadioIconButton
title="Brush Type Fill"
onClick={() => onSettingChange({ type: "fill" })}
isSelected={settings.type === "fill"}
>
<BrushFillIcon />
</RadioIconButton>
<Divider vertical />
<AlphaBlendToggle
useBlending={settings.useBlending}
onBlendingChange={(useBlending) => onSettingChange({ useBlending })}
/>
<Divider vertical />
<UndoButton
onClick={() => onToolAction("mapUndo")}
disabled={disabledActions.includes("undo")}
/>
<RedoButton
onClick={() => onToolAction("mapRedo")}
disabled={disabledActions.includes("redo")}
/>
</Flex>
);
}
export default BrushToolSettings;

View File

@ -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: <BrushIcon />,
},
{
id: "paint",
title: "Paint",
isSelected: settings.type === "paint",
icon: <BrushPaintIcon />,
},
{
id: "line",
title: "Line",
isSelected: settings.type === "line",
icon: <BrushLineIcon />,
},
{
id: "rectangle",
title: "Rectangle",
isSelected: settings.type === "rectangle",
icon: <BrushRectangleIcon />,
},
{
id: "circle",
title: "Circle",
isSelected: settings.type === "circle",
icon: <BrushCircleIcon />,
},
{
id: "triangle",
title: "Triangle",
isSelected: settings.type === "triangle",
icon: <BrushTriangleIcon />,
},
];
return (
<Flex sx={{ alignItems: "center" }}>
<ColorControl
color={settings.color}
onColorChange={(color) => onSettingChange({ color })}
/>
<Divider vertical />
<ToolSection
tools={tools}
onToolClick={(tool) => onSettingChange({ type: tool.id })}
collapse={isSmallScreen}
/>
<Divider vertical />
<RadioIconButton
title="Erase"
onClick={() => onSettingChange({ type: "erase" })}
isSelected={settings.type === "erase"}
disabled={disabledActions.includes("erase")}
>
<EraseIcon />
</RadioIconButton>
<IconButton
aria-label="Erase All"
title="Erase All"
onClick={() => onToolAction("eraseAll")}
disabled={disabledActions.includes("erase")}
>
<EraseAllIcon />
</IconButton>
<Divider vertical />
<AlphaBlendToggle
useBlending={settings.useBlending}
onBlendingChange={(useBlending) => onSettingChange({ useBlending })}
/>
<Divider vertical />
<UndoButton
onClick={() => onToolAction("mapUndo")}
disabled={disabledActions.includes("undo")}
/>
<RedoButton
onClick={() => onToolAction("mapRedo")}
disabled={disabledActions.includes("redo")}
/>
</Flex>
);
}
export default DrawingToolSettings;

View File

@ -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 (
<Flex sx={{ alignItems: "center" }}>
<IconButton
aria-label="Erase All"
title="Erase All"
onClick={() => onToolAction("eraseAll")}
>
<EraseAllIcon />
</IconButton>
<Divider vertical />
<UndoButton
onClick={() => onToolAction("mapUndo")}
disabled={disabledActions.includes("undo")}
/>
<RedoButton
onClick={() => onToolAction("mapRedo")}
disabled={disabledActions.includes("redo")}
/>
</Flex>
);
}
export default EraseToolSettings;

View File

@ -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: <FogPolygonIcon />,
},
{
id: "brush",
title: "Fog Brush",
isSelected: settings.type === "brush",
icon: <FogBrushIcon />,
},
];
const modeTools = [
{
id: "add",
title: "Add Fog",
isSelected: !settings.useFogSubtract,
icon: <FogAddIcon />,
},
{
id: "subtract",
title: "Subtract Fog",
isSelected: settings.useFogSubtract,
icon: <FogSubtractIcon />,
},
];
return (
<Flex sx={{ alignItems: "center" }}>
<ToolSection
tools={drawTools}
onToolClick={(tool) => onSettingChange({ type: tool.id })}
collapse={isSmallScreen}
/>
<Divider vertical />
<RadioIconButton
title="Add Fog"
onClick={() => onSettingChange({ type: "add" })}
isSelected={settings.type === "add"}
title="Toggle Fog"
onClick={() => onSettingChange({ type: "toggle" })}
isSelected={settings.type === "toggle"}
>
<FogAddIcon />
<FogToggleIcon />
</RadioIconButton>
<RadioIconButton
title="Remove Fog"
@ -36,13 +127,14 @@ function BrushToolSettings({
>
<FogRemoveIcon />
</RadioIconButton>
<RadioIconButton
title="Toggle Fog"
onClick={() => onSettingChange({ type: "toggle" })}
isSelected={settings.type === "toggle"}
>
<FogToggleIcon />
</RadioIconButton>
<Divider vertical />
<ToolSection
tools={modeTools}
onToolClick={(tool) =>
onSettingChange({ useFogSubtract: tool.id === "subtract" })
}
collapse={isSmallScreen}
/>
<Divider vertical />
<EdgeSnappingToggle
useEdgeSnapping={settings.useEdgeSnapping}
@ -50,12 +142,6 @@ function BrushToolSettings({
onSettingChange({ useEdgeSnapping })
}
/>
<GridSnappingToggle
useGridSnapping={settings.useGridSnapping}
onGridSnappingChange={(useGridSnapping) =>
onSettingChange({ useGridSnapping })
}
/>
<Divider vertical />
<UndoButton
onClick={() => onToolAction("fogUndo")}

View File

@ -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 (
<IconButton
aria-label={
useGridSnapping ? "Disable Grid Snapping" : "Enable Grid Snapping"
}
title={useGridSnapping ? "Disable Grid Snapping" : "Enable Grid Snapping"}
onClick={() => onGridSnappingChange(!useGridSnapping)}
>
{useGridSnapping ? <GridOnIcon /> : <GridOffIcon />}
</IconButton>
);
}
export default GridSnappingToggle;

View File

@ -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: <MeasureChebyshevIcon />,
},
{
id: "euclidean",
title: "Line Distance",
isSelected: settings.type === "euclidean",
icon: <MeasureEuclideanIcon />,
},
{
id: "manhattan",
title: "City Block Distance",
isSelected: settings.type === "manhattan",
icon: <MeasureManhattanIcon />,
},
];
// TODO Add keyboard shortcuts
return (
<Flex sx={{ alignItems: "center" }}>
<ToolSection
tools={tools}
onToolClick={(tool) => onSettingChange({ type: tool.id })}
/>
</Flex>
);
}
export default MeasureToolSettings;

View File

@ -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 (
<Flex sx={{ alignItems: "center" }}>
<ColorControl
color={settings.color}
onColorChange={(color) => onSettingChange({ color })}
/>
<Divider vertical />
<RadioIconButton
title="Shape Type Rectangle"
onClick={() => onSettingChange({ type: "rectangle" })}
isSelected={settings.type === "rectangle"}
>
<ShapeRectangleIcon />
</RadioIconButton>
<RadioIconButton
title="Shape Type Circle"
onClick={() => onSettingChange({ type: "circle" })}
isSelected={settings.type === "circle"}
>
<ShapeCircleIcon />
</RadioIconButton>
<RadioIconButton
title="Shape Type Triangle"
onClick={() => onSettingChange({ type: "triangle" })}
isSelected={settings.type === "triangle"}
>
<ShapeTriangleIcon />
</RadioIconButton>
<Divider vertical />
<AlphaBlendToggle
useBlending={settings.useBlending}
onBlendingChange={(useBlending) => onSettingChange({ useBlending })}
/>
<Divider vertical />
<UndoButton
onClick={() => onToolAction("mapUndo")}
disabled={disabledActions.includes("undo")}
/>
<RedoButton
onClick={() => onToolAction("mapRedo")}
disabled={disabledActions.includes("redo")}
/>
</Flex>
);
}
export default ShapeToolSettings;

View File

@ -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 (
<RadioIconButton
title={tool.title}
onClick={() => handleToolClick(tool)}
key={tool.id}
isSelected={tool.isSelected}
>
{tool.icon}
</RadioIconButton>
);
}
if (collapse) {
if (!collapsedTool) {
return null;
}
return (
<Box sx={{ position: "relative" }}>
{renderTool(collapsedTool)}
{/* Render chevron when more tools is available */}
<Box
sx={{
position: "absolute",
width: 0,
height: 0,
borderTop: "4px solid",
borderTopColor: "text",
borderLeft: "4px solid transparent",
borderRight: "4px solid transparent",
transform: "translate(0, -4px) rotate(-45deg)",
bottom: 0,
right: 0,
pointerEvents: "none",
}}
/>
{showMore && (
<Flex
sx={{
position: "absolute",
top: "40px",
left: "50%",
transform: "translateX(-50%)",
flexDirection: "column",
borderRadius: "4px",
}}
bg="overlay"
p={2}
>
{tools.filter((tool) => !tool.isSelected).map(renderTool)}
</Flex>
)}
</Box>
);
} else {
return tools.map((tool) => (
<RadioIconButton
title={tool.title}
onClick={() => handleToolClick(tool)}
key={tool.id}
isSelected={tool.isSelected}
>
{tool.icon}
</RadioIconButton>
));
}
}
ToolSection.defaultProps = {
collapse: false,
};
export default ToolSection;

View File

@ -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 (
<Flex sx={{ flexDirection: "column" }}>
<Flex>
@ -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}
/>
</Box>
@ -44,7 +46,7 @@ function TokenSettings({
<Label>
<Checkbox
checked={token && token.isVehicle}
disabled={!token || token.type === "default"}
disabled={tokenEmpty || token.type === "default"}
onChange={(e) =>
onSettingsChange("isVehicle", e.target.checked)
}
@ -56,7 +58,7 @@ function TokenSettings({
<Label>
<Checkbox
checked={token && token.hideInSidebar}
disabled={!token || token.type === "default"}
disabled={tokenEmpty || token.type === "default"}
onChange={(e) =>
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}
>
<ExpandMoreIcon />
</IconButton>

View File

@ -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;

View File

@ -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 (

View File

@ -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

View File

@ -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.

View File

@ -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]()

View File

@ -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);
}
}

View File

@ -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);
}

156
src/helpers/konva.js Normal file
View File

@ -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 <Line sceneFunc={sceneFunc} {...props} />;
}
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 (
<Group
x={x}
y={y}
scaleX={scale}
scaleY={scale}
onMouseEnter={handleEnter}
onMouseLeave={handleLeave}
onClick={onClick}
onTap={onClick}
>
<Circle radius={12} fill="hsla(230, 25%, 18%, 0.8)" />
<Path
offsetX={12}
offsetY={12}
fill={fill}
data={
cross
? "M18.3 5.71c-.39-.39-1.02-.39-1.41 0L12 10.59 7.11 5.7c-.39-.39-1.02-.39-1.41 0-.39.39-.39 1.02 0 1.41L10.59 12 5.7 16.89c-.39.39-.39 1.02 0 1.41.39.39 1.02.39 1.41 0L12 13.41l4.89 4.89c.39.39 1.02.39 1.41 0 .39-.39.39-1.02 0-1.41L13.41 12l4.89-4.89c.38-.38.38-1.02 0-1.4z"
: "M9 16.2l-3.5-3.5c-.39-.39-1.01-.39-1.4 0-.39.39-.39 1.01 0 1.4l4.19 4.19c.39.39 1.02.39 1.41 0L20.3 7.7c.39-.39.39-1.01 0-1.4-.39-.39-1.01-.39-1.4 0L9 16.2z"
}
/>
</Group>
);
}
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(),
};
}

View File

@ -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;
}

View File

@ -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;

View File

@ -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 };
}

View File

@ -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));
}
}

View File

@ -1,6 +1,6 @@
import React from "react";
function ShapeCircleIcon() {
function BrushCircleIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
@ -15,4 +15,4 @@ function ShapeCircleIcon() {
);
}
export default ShapeCircleIcon;
export default BrushCircleIcon;

View File

@ -0,0 +1,18 @@
import React from "react";
function BrushLineIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 0 24 24"
width="24"
fill="currentcolor"
>
<path d="M0 0h24v24H0z" fill="none" />
<path d="M4.222 16.95L16.95 4.222a2 2 0 112.828 2.828L7.05 19.778a2 2 0 11-2.828-2.828z" />
</svg>
);
}
export default BrushLineIcon;

View File

@ -0,0 +1,18 @@
import React from "react";
function BrushPaintIcon() {
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="M18 4V3c0-.55-.45-1-1-1H5c-.55 0-1 .45-1 1v4c0 .55.45 1 1 1h12c.55 0 1-.45 1-1V6h1v4h-9c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h2c.55 0 1-.45 1-1v-9h7c.55 0 1-.45 1-1V5c0-.55-.45-1-1-1h-2z" />
</svg>
);
}
export default BrushPaintIcon;

View File

@ -1,6 +1,6 @@
import React from "react";
function ShapeRectangleIcon() {
function BrushRectangleIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
@ -15,4 +15,4 @@ function ShapeRectangleIcon() {
);
}
export default ShapeRectangleIcon;
export default BrushRectangleIcon;

View File

@ -1,6 +1,6 @@
import React from "react";
function ShapeTriangleIcon() {
function BrushTriangleIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
@ -15,4 +15,4 @@ function ShapeTriangleIcon() {
);
}
export default ShapeTriangleIcon;
export default BrushTriangleIcon;

View File

@ -7,15 +7,10 @@ function EraseToolIcon() {
height="24"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentcolor"
>
<g fill="none" fillRule="evenodd">
<path d="M0 0h24v24H0z" />
<path
d="M3.212 12.303c-1 1-1.182 2.455-.404 3.233l5.656 5.656c.778.778 2.233.596 3.233-.404l9.091-9.091c1-1 1.182-2.455.404-3.233l-5.656-5.656c-.778-.778-2.233-.596-3.233.404l-9.091 9.091zm6.667-2.424l4.242 4.242c.39.39.485.93.212 1.202l-3.96 3.96c-.272.272-.813.177-1.201-.212l-4.243-4.243c-.389-.388-.484-.93-.212-1.202l3.96-3.96c.272-.272.813-.176 1.202.213z"
fill="currentcolor"
fillRule="nonzero"
/>
</g>
<path d="M0 0h24v24H0z" fill="none" />
<path d="M3.212 12.303c-1 1-1.182 2.455-.404 3.233l5.656 5.656c.778.778 2.233.596 3.233-.404l9.091-9.091c1-1 1.182-2.455.404-3.233l-5.656-5.656c-.778-.778-2.233-.596-3.233.404l-9.091 9.091zm6.667-2.424l4.242 4.242c.39.39.485.93.212 1.202l-3.96 3.96c-.272.272-.813.177-1.201-.212l-4.243-4.243c-.389-.388-.484-.93-.212-1.202l3.96-3.96c.272-.272.813-.176 1.202.213z" />
</svg>
);
}

View File

@ -1,6 +1,6 @@
import React from "react";
function BrushFillIcon() {
function FogBrushIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
@ -15,4 +15,4 @@ function BrushFillIcon() {
);
}
export default BrushFillIcon;
export default FogBrushIcon;

View File

@ -0,0 +1,18 @@
import React from "react";
function FogPolygonIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 0 24 24"
width="24"
fill="currentcolor"
>
<path d="M0 0h24v24H0z" fill="none" />
<path d="M15.858 20a1 1 0 110 2h-8a1 1 0 010-2h8zm-5-17.998v9.516a2 2 0 102 0V2.12l5.725 10.312a1 1 0 01.049.87l-.058.117-3.093 5.333a1 1 0 01-.865.498H8.941a1 1 0 01-.875-.516L5.125 13.41a1 1 0 01-.002-.965l5.735-10.443z" />
</svg>
);
}
export default FogPolygonIcon;

View File

@ -10,7 +10,7 @@ function FogRemoveIcon() {
fill="currentcolor"
>
<path d="M0 0h24v24H0z" fill="none" />
<path d="M12 4a7.49 7.49 0 017.35 6.04c2.6.18 4.65 2.32 4.65 4.96 0 2.76-2.24 5-5 5H6c-3.31 0-6-2.69-6-6 0-3.09 2.34-5.64 5.35-5.96A7.496 7.496 0 0112 4zm4 8H8c-.55 0-1 .45-1 1s.45 1 1 1h8c.55 0 1-.45 1-1s-.45-1-1-1z" />
<path d="M3.212 12.303c-1 1-1.182 2.455-.404 3.233l5.656 5.656c.778.778 2.233.596 3.233-.404l9.091-9.091c1-1 1.182-2.455.404-3.233l-5.656-5.656c-.778-.778-2.233-.596-3.233.404l-9.091 9.091zm6.667-2.424l4.242 4.242c.39.39.485.93.212 1.202l-3.96 3.96c-.272.272-.813.177-1.201-.212l-4.243-4.243c-.389-.388-.484-.93-.212-1.202l3.96-3.96c.272-.272.813-.176 1.202.213z" />
</svg>
);
}

View File

@ -0,0 +1,18 @@
import React from "react";
function FogRemoveIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 0 24 24"
width="24"
fill="currentcolor"
>
<path d="M0 0h24v24H0z" fill="none" />
<path d="M12 4a7.49 7.49 0 017.35 6.04c2.6.18 4.65 2.32 4.65 4.96 0 2.76-2.24 5-5 5H6c-3.31 0-6-2.69-6-6 0-3.09 2.34-5.64 5.35-5.96A7.496 7.496 0 0112 4zm4 8H8c-.55 0-1 .45-1 1s.45 1 1 1h8c.55 0 1-.45 1-1s-.45-1-1-1z" />
</svg>
);
}
export default FogRemoveIcon;

View File

@ -1,18 +0,0 @@
import React from "react";
function GridOffIcon() {
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="M8 4v.89l2 2V4h4v4h-2.89l2 2H14v.89l2 2V10h4v4h-2.89l2 2H20v.89l2 2V4c0-1.1-.9-2-2-2H5.11l2 2H8zm8 0h3c.55 0 1 .45 1 1v3h-4V4zm6.16 17.88L2.12 1.84c-.39-.39-1.02-.39-1.41 0-.39.39-.39 1.02 0 1.41L2 4.55V20c0 1.1.9 2 2 2h15.45l1.3 1.3c.39.39 1.02.39 1.41 0 .39-.39.39-1.03 0-1.42zM10 12.55L11.45 14H10v-1.45zm-6-6L5.45 8H4V6.55zM8 20H5c-.55 0-1-.45-1-1v-3h4v4zm0-6H4v-4h3.45l.55.55V14zm6 6h-4v-4h3.45l.55.55V20zm2 0v-1.45L17.45 20H16z" />
</svg>
);
}
export default GridOffIcon;

View File

@ -1,18 +0,0 @@
import React from "react";
function GridOnIcon() {
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="M20 2H4c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM8 20H5c-.55 0-1-.45-1-1v-3h4v4zm0-6H4v-4h4v4zm0-6H4V5c0-.55.45-1 1-1h3v4zm6 12h-4v-4h4v4zm0-6h-4v-4h4v4zm0-6h-4V4h4v4zm5 12h-3v-4h4v3c0 .55-.45 1-1 1zm1-6h-4v-4h4v4zm0-6h-4V4h3c.55 0 1 .45 1 1v3z" />
</svg>
);
}
export default GridOnIcon;

View File

@ -0,0 +1,18 @@
import React from "react";
function MeasureChebyshevIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 0 24 24"
width="24"
fill="currentcolor"
>
<path d="M0 0h24v24H0z" fill="none" />
<path d="M4 8h4V4H4v4zm6 12h4v-4h-4v4zm-6 0h4v-4H4v4zm0-6h4v-4H4v4zm6 0h4v-4h-4v4zm6-10v4h4V4h-4zm-6 4h4V4h-4v4zm6 6h4v-4h-4v4zm0 6h4v-4h-4v4z" />
</svg>
);
}
export default MeasureChebyshevIcon;

View File

@ -0,0 +1,20 @@
import React from "react";
function MeasureEuclideanIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 0 24 24"
width="24"
fill="currentcolor"
>
<path d="M0 0h24v24H0z" fill="none" />
<path d="M6.36,18.78L6.61,21l1.62-1.54l2.77-7.6c-0.68-0.17-1.28-0.51-1.77-0.98L6.36,18.78z" />
<path d="M14.77,10.88c-0.49,0.47-1.1,0.81-1.77,0.98l2.77,7.6L17.39,21l0.26-2.22L14.77,10.88z" />
<path d="M14.94,8.6c0.3-1.56-0.6-2.94-1.94-3.42V4c0-0.55-0.45-1-1-1h0c-0.55,0-1,0.45-1,1v1.18C9.84,5.6,9,6.7,9,8 c0,1.84,1.66,3.3,3.56,2.95C13.74,10.73,14.71,9.78,14.94,8.6z M12,9c-0.55,0-1-0.45-1-1c0-0.55,0.45-1,1-1s1,0.45,1,1 C13,8.55,12.55,9,12,9z" />
</svg>
);
}
export default MeasureEuclideanIcon;

View File

@ -0,0 +1,18 @@
import React from "react";
function MeasureManhattanIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 0 24 24"
width="24"
fill="currentcolor"
>
<path d="M0 0h24v24H0z" fill="none" />
<path d="M17,11V5c0-1.1-0.9-2-2-2H9C7.9,3,7,3.9,7,5v2H5C3.9,7,3,7.9,3,9v10c0,1.1,0.9,2,2,2h5c0.55,0,1-0.45,1-1v-3h2v3 c0,0.55,0.45,1,1,1h5c1.1,0,2-0.9,2-2v-6c0-1.1-0.9-2-2-2H17z M7,19H5v-2h2V19z M7,15H5v-2h2V15z M7,11H5V9h2V11z M11,15H9v-2h2V15 z M11,11H9V9h2V11z M11,7H9V5h2V7z M15,15h-2v-2h2V15z M15,11h-2V9h2V11z M15,7h-2V5h2V7z M19,19h-2v-2h2V19z M19,15h-2v-2h2V15z" />
</svg>
);
}
export default MeasureManhattanIcon;

View File

@ -0,0 +1,19 @@
import React from "react";
function MeasureToolIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 0 24 24"
width="24"
fill="currentcolor"
transform="scale(-1 1)"
>
<path d="M0 0h24v24H0z" fill="none" />
<path d="M17.66,17.66l-0.71,0.71c-0.2,0.2-0.51,0.2-0.71,0l0,0c-0.2-0.2-0.2-0.51,0-0.71l0.71-0.71l-1.94-1.94l-0.71,0.71 c-0.2,0.2-0.51,0.2-0.71,0l0,0c-0.2-0.2-0.2-0.51,0-0.71l0.71-0.71l-1.94-1.94l-0.71,0.71c-0.2,0.2-0.51,0.2-0.71,0l0,0 c-0.2-0.2-0.2-0.51,0-0.71l0.71-0.71L9.7,9.7l-0.71,0.71c-0.2,0.2-0.51,0.2-0.71,0l0,0c-0.2-0.2-0.2-0.51,0-0.71l0.71-0.71 L7.05,7.05L6.34,7.76c-0.2,0.2-0.51,0.2-0.71,0l0,0c-0.2-0.2-0.2-0.51,0-0.71l0.71-0.71L4.85,4.85C4.54,4.54,4,4.76,4,5.21V18 c0,1.1,0.9,2,2,2h12.79c0.45,0,0.67-0.54,0.35-0.85L17.66,17.66z M7,16v-4.76L12.76,17H8C7.45,17,7,16.55,7,16z" />{" "}
</svg>
);
}
export default MeasureToolIcon;

View File

@ -1,20 +0,0 @@
import React from "react";
function ShapeToolIcon() {
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="M11.15 3.4L7.43 9.48c-.41.66.07 1.52.85 1.52h7.43c.78 0 1.26-.86.85-1.52L12.85 3.4c-.39-.64-1.31-.64-1.7 0z" />
<circle cx="17.5" cy="17.5" r="4.5" />
<path d="M4 21.5h6c.55 0 1-.45 1-1v-6c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v6c0 .55.45 1 1 1z" />
</svg>
);
}
export default ShapeToolIcon;

View File

@ -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 (
<Modal isOpen={isOpen} onRequestClose={onRequestClose}>
<Modal isOpen={isOpen} onRequestClose={handleDone}>
<ImageDrop onDrop={handleImagesUpload} dropText="Drop map to upload">
<input
onChange={(event) => 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}
/>
<MapSettings
map={selectedMap}
mapState={selectedMapState}
map={selectedMapWithChanges}
mapState={selectedMapStateWithChanges}
onSettingsChange={handleMapSettingsChange}
onStateSettingsChange={handleMapStateSettingsChange}
showMore={showMoreSettings}

View File

@ -11,6 +11,7 @@ import blobToBuffer from "../helpers/blobToBuffer";
import TokenDataContext from "../contexts/TokenDataContext";
import AuthContext from "../contexts/AuthContext";
import { isEmpty } from "../helpers/shared";
function SelectTokensModal({ isOpen, onRequestClose }) {
const { userId } = useContext(AuthContext);
@ -34,6 +35,7 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
function handleTokenAdd(token) {
addToken(token);
setSelectedTokenId(token.id);
}
async function handleImagesUpload(files) {
@ -69,8 +71,8 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
handleTokenAdd({
file: buffer,
name,
type: "file",
id: shortid.generate(),
type: "file",
created: Date.now(),
lastModified: Date.now(),
owner: userId,
@ -86,7 +88,8 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
});
}
function handleTokenSelect(token) {
async function handleTokenSelect(token) {
await applyTokenChanges();
setSelectedTokenId(token.id);
}
@ -100,12 +103,28 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
*/
const [showMoreSettings, setShowMoreSettings] = useState(false);
async function handleTokenSettingsChange(key, value) {
await updateToken(selectedTokenId, { [key]: value });
const [tokenSettingChanges, setTokenSettingChanges] = useState({});
function handleTokenSettingsChange(key, value) {
setTokenSettingChanges((prevChanges) => ({ ...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 (
<Modal isOpen={isOpen} onRequestClose={onRequestClose}>
<Modal isOpen={isOpen} onRequestClose={handleRequestClose}>
<ImageDrop onDrop={handleImagesUpload} dropText="Drop token to upload">
<input
onChange={(event) => handleImagesUpload(event.target.files)}
@ -126,12 +145,12 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
<TokenTiles
tokens={ownedTokens}
onTokenAdd={openImageDialog}
selectedToken={selectedToken}
selectedToken={selectedTokenWithChanges}
onTokenSelect={handleTokenSelect}
onTokenRemove={handleTokenRemove}
/>
<TokenSettings
token={selectedToken}
token={selectedTokenWithChanges}
showMore={showMoreSettings}
onSettingsChange={handleTokenSettingsChange}
onShowMoreChange={setShowMoreSettings}
@ -139,7 +158,7 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
<Button
variant="primary"
disabled={imageLoading}
onClick={onRequestClose}
onClick={handleRequestClose}
>
Done
</Button>

View File

@ -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.");
}
}

View File

@ -51,7 +51,7 @@ function Home() {
Join Game
</Button>
<Text variant="caption" as="p" sx={{ textAlign: "center" }}>
Beta v1.3.3
Beta v1.4.0
</Text>
<Button
m={2}

View File

@ -12,6 +12,7 @@ const v130 = raw("../docs/releaseNotes/v1.3.0.md");
const v131 = raw("../docs/releaseNotes/v1.3.1.md");
const v132 = raw("../docs/releaseNotes/v1.3.2.md");
const v133 = raw("../docs/releaseNotes/v1.3.3.md");
const v140 = raw("../docs/releaseNotes/v1.4.0.md");
function ReleaseNotes() {
return (
@ -34,6 +35,9 @@ function ReleaseNotes() {
<Text mb={2} variant="heading" as="h1" sx={{ fontSize: 5 }}>
Release Notes
</Text>
<div id="v140">
<Markdown source={v140} />
</div>
<div id="v133">
<Markdown source={v133} />
</div>

View File

@ -163,6 +163,10 @@ export default {
img: {
maxWidth: "100%",
},
progress: {
color: "text",
backgroundColor: "overlay",
},
},
prism: {
".comment,.prolog,.doctype,.cdata,.punctuation,.operator,.entity,.url": {

View File

@ -877,6 +877,13 @@
dependencies:
regenerator-runtime "^0.13.4"
"@babel/runtime@^7.9.6":
version "7.10.3"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.10.3.tgz#670d002655a7c366540c67f6fd3342cd09500364"
integrity sha512-RzGO0RLSdokm9Ipe/YD+7ww8X2Ro79qiXZF3HU9ljrM+qnJmH1Vqth+hbiQZy761LnMJTMitHDuKVYTk3k4dLw==
dependencies:
regenerator-runtime "^0.13.4"
"@babel/template@^7.4.0", "@babel/template@^7.8.3":
version "7.8.3"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.8.3.tgz#e02ad04fe262a657809327f578056ca15fd4d1b8"
@ -6812,6 +6819,13 @@ json-stringify-safe@~5.0.1:
resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"
integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=
json2mq@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/json2mq/-/json2mq-0.2.0.tgz#b637bd3ba9eabe122c83e9720483aeb10d2c904a"
integrity sha1-tje9O6nqvhIsg+lyBIOusQ0skEo=
dependencies:
string-convert "^0.2.0"
json3@^3.3.2:
version "3.3.3"
resolved "https://registry.yarnpkg.com/json3/-/json3-3.3.3.tgz#7fc10e375fc5ae42c4705a5cc0aa6f62be305b81"
@ -7627,11 +7641,6 @@ normalize-url@^3.0.0:
resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-3.3.0.tgz#b2e1c4dc4f7c6d57743df733a4f5978d18650559"
integrity sha512-U+JJi7duF1o+u2pynbp2zXDW2/PADgC30f0GsHZtRh+HOcXHnw137TrNlyxxRvWW5fjKd3bcLHPxofWuCjaeZg==
normalize-wheel@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/normalize-wheel/-/normalize-wheel-1.0.1.tgz#aec886affdb045070d856447df62ecf86146ec45"
integrity sha1-rsiGr/2wRQcNhWRH32Ls+GFG7EU=
npm-run-path@^2.0.0:
version "2.0.2"
resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f"
@ -8251,6 +8260,13 @@ pnp-webpack-plugin@1.6.0:
dependencies:
ts-pnp "^1.1.2"
polygon-clipping@^0.14.3:
version "0.14.3"
resolved "https://registry.yarnpkg.com/polygon-clipping/-/polygon-clipping-0.14.3.tgz#02affe4e2aaee69f686ea9dcd5f9566dd4c941af"
integrity sha512-bIaMFYIsHOShMN0JZsvkfk66S7gKMQMlYUpV7LIx+WOBYvJT09eCgoAv2JCDNj6SofA4+o5tlmV76zzUiG4aBQ==
dependencies:
splaytree "^3.0.1"
portfinder@^1.0.25:
version "1.0.25"
resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.25.tgz#254fd337ffba869f4b9d37edc298059cb4d35eca"
@ -9285,6 +9301,16 @@ react-markdown@^4.3.1:
unist-util-visit "^1.3.0"
xtend "^4.0.1"
react-media@^2.0.0-rc.1:
version "2.0.0-rc.1"
resolved "https://registry.yarnpkg.com/react-media/-/react-media-2.0.0-rc.1.tgz#1a8ed86ddb7f79fd1c70516e825179eb497adf9c"
integrity sha512-g9NN2NkmQBJn6Z6prKm3/SuG90p+rWiTIxHjXGhzhf00uRncVB5QmJTZgf2GVz1Bj93b1XwkdRZkYRAN8uMoBQ==
dependencies:
"@babel/runtime" "^7.9.6"
invariant "^2.2.2"
json2mq "^0.2.0"
prop-types "^15.5.10"
react-modal@^3.11.2:
version "3.11.2"
resolved "https://registry.yarnpkg.com/react-modal/-/react-modal-3.11.2.tgz#bad911976d4add31aa30dba8a41d11e21c4ac8a4"
@ -10433,6 +10459,11 @@ spdy@^4.0.1:
select-hose "^2.0.0"
spdy-transport "^3.0.0"
splaytree@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/splaytree/-/splaytree-3.0.1.tgz#038d8e6d597a3a1893e8e82693a0f4413b3559f7"
integrity sha512-WvQIHRDXLSVn72xjlIG/WGhv/4QO3m+iY2TpVdFRaXd1+/vkNlpkpw1QaNH5taghF9eWXDHWMWnzXyicR8d6ig==
split-string@^3.0.1, split-string@^3.0.2:
version "3.1.0"
resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2"
@ -10545,6 +10576,11 @@ strict-uri-encode@^1.0.0:
resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713"
integrity sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM=
string-convert@^0.2.0:
version "0.2.1"
resolved "https://registry.yarnpkg.com/string-convert/-/string-convert-0.2.1.tgz#6982cc3049fbb4cd85f8b24568b9d9bf39eeff97"
integrity sha1-aYLMMEn7tM2F+LJFaLnZvznu/5c=
string-length@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/string-length/-/string-length-2.0.0.tgz#d40dbb686a3ace960c1cffca562bf2c45f8363ed"