diff --git a/src/components/map/Map.js b/src/components/map/Map.js
index 4cd04f1..9df0541 100644
--- a/src/components/map/Map.js
+++ b/src/components/map/Map.js
@@ -1,14 +1,17 @@
-import React, { useState, useContext } from "react";
+import React, { useState, useContext, useEffect } from "react";
import MapControls from "./MapControls";
import MapInteraction from "./MapInteraction";
import MapToken from "./MapToken";
+import MapDrawing from "./MapDrawing";
import TokenDataContext from "../../contexts/TokenDataContext";
import TokenMenu from "../token/TokenMenu";
import TokenDragOverlay from "../token/TokenDragOverlay";
+import { omit } from "../../helpers/shared";
+
function Map({
map,
mapState,
@@ -84,6 +87,43 @@ function Map({
const [mapShapes, setMapShapes] = useState([]);
const [fogShapes, setFogShapes] = useState([]);
+ // Replay the draw actions and convert them to shapes for the map drawing
+ useEffect(() => {
+ 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)
+ );
+ setFogShapes(
+ actionsToShapes(mapState.fogDrawActions, mapState.fogDrawActionIndex)
+ );
+ }, [mapState]);
+
+ function handleMapShapeAdd(shape) {
+ onMapDraw({ type: "add", shapes: [shape] });
+ }
+
+ function handleMapShapeRemove(shapeId) {
+ onMapDraw({ type: "remove", shapeIds: [shapeId] });
+ }
+
const disabledControls = [];
if (!allowMapDrawing) {
disabledControls.push("brush");
@@ -182,6 +222,17 @@ function Map({
/>
);
+ const mapDrawing = (
+
+ );
+
return (
}
+ selectedToolId={selectedToolId}
>
+ {mapDrawing}
{mapTokens}
);
diff --git a/src/components/map/MapDrawing.js b/src/components/map/MapDrawing.js
index fbc5416..8f446f8 100644
--- a/src/components/map/MapDrawing.js
+++ b/src/components/map/MapDrawing.js
@@ -1,123 +1,89 @@
-import React, { useRef, useEffect, useState, useContext } from "react";
+import React, { useContext, useEffect, useState } from "react";
import shortid from "shortid";
+import { Group, Line, Rect, Circle } from "react-konva";
+
+import MapInteractionContext from "../../contexts/MapInteractionContext";
import { compare as comparePoints } from "../../helpers/vector2";
import {
getBrushPositionForTool,
getDefaultShapeData,
getUpdatedShapeData,
- isShapeHovered,
- drawShape,
simplifyPoints,
- getRelativePointerPosition,
+ getStrokeWidth,
} from "../../helpers/drawing";
-import MapInteractionContext from "../../contexts/MapInteractionContext";
+import colors from "../../helpers/colors";
function MapDrawing({
- width,
- height,
- selectedTool,
- toolSettings,
shapes,
onShapeAdd,
onShapeRemove,
+ selectedToolId,
+ selectedToolSettings,
gridSize,
}) {
- const canvasRef = useRef();
- const containerRef = useRef();
-
- const [isPointerDown, setIsPointerDown] = useState(false);
+ const {
+ stageDragState,
+ mapDragPosition,
+ stageScale,
+ mapWidth,
+ mapHeight,
+ } = useContext(MapInteractionContext);
const [drawingShape, setDrawingShape] = useState(null);
- const [pointerPosition, setPointerPosition] = useState({ x: -1, y: -1 });
- const shouldHover = selectedTool === "erase";
+ const shouldHover = selectedToolId === "erase";
const isEditing =
- selectedTool === "brush" ||
- selectedTool === "shape" ||
- selectedTool === "erase";
+ selectedToolId === "brush" ||
+ selectedToolId === "shape" ||
+ selectedToolId === "erase";
- const { scaleRef } = useContext(MapInteractionContext);
-
- // Reset pointer position when tool changes
useEffect(() => {
- setPointerPosition({ x: -1, y: -1 });
- }, [selectedTool]);
-
- function handleStart(event) {
if (!isEditing) {
return;
}
- if (event.touches && event.touches.length !== 1) {
- setIsPointerDown(false);
- setDrawingShape(null);
- return;
- }
- const pointer = event.touches ? event.touches[0] : event;
- const position = getRelativePointerPosition(pointer, containerRef.current);
- setPointerPosition(position);
- setIsPointerDown(true);
- const brushPosition = getBrushPositionForTool(
- position,
- selectedTool,
- toolSettings,
- gridSize,
- shapes
- );
- const commonShapeData = {
- color: toolSettings && toolSettings.color,
- blend: toolSettings && toolSettings.useBlending,
- id: shortid.generate(),
- };
- if (selectedTool === "brush") {
- setDrawingShape({
- type: "path",
- pathType: toolSettings.type,
- data: { points: [brushPosition] },
- strokeWidth: toolSettings.type === "stroke" ? 1 : 0,
- ...commonShapeData,
- });
- } else if (selectedTool === "shape") {
- setDrawingShape({
- type: "shape",
- shapeType: toolSettings.type,
- data: getDefaultShapeData(toolSettings.type, brushPosition),
- strokeWidth: 0,
- ...commonShapeData,
- });
- }
- }
- function handleMove(event) {
- if (!isEditing) {
- return;
- }
- if (event.touches && event.touches.length !== 1) {
- return;
- }
- const pointer = event.touches ? event.touches[0] : event;
- // Set pointer position every frame for erase tool and fog
- if (shouldHover) {
- const position = getRelativePointerPosition(
- pointer,
- containerRef.current
- );
- setPointerPosition(position);
- }
- if (isPointerDown) {
- const position = getRelativePointerPosition(
- pointer,
- containerRef.current
- );
- setPointerPosition(position);
+ function startShape() {
const brushPosition = getBrushPositionForTool(
- position,
- selectedTool,
- toolSettings,
+ mapDragPosition,
+ selectedToolId,
+ selectedToolSettings,
gridSize,
shapes
);
- if (selectedTool === "brush") {
+ 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,
+ });
+ }
+ }
+
+ function continueShape() {
+ const brushPosition = getBrushPositionForTool(
+ mapDragPosition,
+ selectedToolId,
+ selectedToolSettings,
+ gridSize,
+ shapes
+ );
+ if (selectedToolId === "brush") {
setDrawingShape((prevShape) => {
const prevPoints = prevShape.data.points;
if (
@@ -132,14 +98,14 @@ function MapDrawing({
const simplified = simplifyPoints(
[...prevPoints, brushPosition],
gridSize,
- scaleRef.current
+ stageScale
);
return {
...prevShape,
data: { points: simplified },
};
});
- } else if (selectedTool === "shape") {
+ } else if (selectedToolId === "shape") {
setDrawingShape((prevShape) => ({
...prevShape,
data: getUpdatedShapeData(
@@ -151,110 +117,145 @@ function MapDrawing({
}));
}
}
- }
- function handleStop(event) {
- if (!isEditing) {
- return;
- }
- if (event.touches && event.touches.length !== 0) {
- return;
- }
- if (selectedTool === "brush" && drawingShape) {
- if (drawingShape.data.points.length > 1) {
+ function endShape() {
+ if (selectedToolId === "brush" && drawingShape) {
+ if (drawingShape.data.points.length > 1) {
+ onShapeAdd(drawingShape);
+ }
+ } else if (selectedToolId === "shape" && drawingShape) {
onShapeAdd(drawingShape);
}
- } else if (selectedTool === "shape" && drawingShape) {
- onShapeAdd(drawingShape);
+ setDrawingShape(null);
}
- if (selectedTool === "erase" && hoveredShapeRef.current && isPointerDown) {
- onShapeRemove(hoveredShapeRef.current.id);
- }
- setIsPointerDown(false);
- setDrawingShape(null);
- }
-
- // Add listeners for draw events on map to allow drawing past the bounds
- // of the container
- useEffect(() => {
- const map = document.querySelector(".map");
- map.addEventListener("mousedown", handleStart);
- map.addEventListener("mousemove", handleMove);
- map.addEventListener("mouseup", handleStop);
- map.addEventListener("touchstart", handleStart);
- map.addEventListener("touchmove", handleMove);
- map.addEventListener("touchend", handleStop);
-
- return () => {
- map.removeEventListener("mousedown", handleStart);
- map.removeEventListener("mousemove", handleMove);
- map.removeEventListener("mouseup", handleStop);
- map.removeEventListener("touchstart", handleStart);
- map.removeEventListener("touchmove", handleMove);
- map.removeEventListener("touchend", handleStop);
- };
- });
-
- /**
- * Rendering
- */
- const hoveredShapeRef = useRef(null);
- useEffect(() => {
- const canvas = canvasRef.current;
- if (canvas) {
- const context = canvas.getContext("2d");
-
- context.clearRect(0, 0, width, height);
- let hoveredShape = null;
- for (let shape of shapes) {
- if (shouldHover) {
- if (isShapeHovered(shape, context, pointerPosition, width, height)) {
- hoveredShape = shape;
- }
- }
- drawShape(shape, context, gridSize, width, height);
- }
- if (drawingShape) {
- drawShape(drawingShape, context, gridSize, width, height);
- }
- if (hoveredShape) {
- const shape = { ...hoveredShape, color: "#BB99FF", blend: true };
- drawShape(shape, context, gridSize, width, height);
- }
- hoveredShapeRef.current = hoveredShape;
+ switch (stageDragState) {
+ case "first":
+ startShape();
+ return;
+ case "dragging":
+ continueShape();
+ return;
+ case "last":
+ endShape();
+ return;
+ default:
+ return;
}
}, [
- shapes,
- width,
- height,
- pointerPosition,
- isPointerDown,
- selectedTool,
- drawingShape,
+ stageDragState,
+ mapDragPosition,
+ selectedToolId,
+ selectedToolSettings,
+ isEditing,
gridSize,
- shouldHover,
+ stageScale,
+ onShapeAdd,
+ shapes,
+ drawingShape,
]);
+ function handleShapeClick(_, shape) {
+ if (selectedToolId === "erase") {
+ onShapeRemove(shape.id);
+ }
+ }
+
+ function handleShapeMouseOver(event, shape) {
+ if (shouldHover) {
+ const path = event.target;
+ const hoverColor = "#BB99FF";
+ path.fill(hoverColor);
+ if (shape.type === "path") {
+ path.stroke(hoverColor);
+ }
+ path.getLayer().draw();
+ }
+ }
+
+ function handleShapeMouseOut(event, shape) {
+ if (shouldHover) {
+ const path = event.target;
+ const color = colors[shape.color] || shape.color;
+ path.fill(color);
+ if (shape.type === "path") {
+ path.stroke(color);
+ }
+ path.getLayer().draw();
+ }
+ }
+
+ function renderShape(shape) {
+ const defaultProps = {
+ key: shape.id,
+ onMouseOver: (e) => handleShapeMouseOver(e, shape),
+ onMouseOut: (e) => handleShapeMouseOut(e, shape),
+ onClick: (e) => handleShapeClick(e, shape),
+ fill: colors[shape.color] || shape.color,
+ opacity: shape.blend ? 0.5 : 1,
+ };
+ if (shape.type === "path") {
+ return (
+ [...acc, point.x * mapWidth, point.y * mapHeight],
+ []
+ )}
+ stroke={colors[shape.color] || shape.color}
+ tension={0.5}
+ closed={shape.pathType === "fill"}
+ fillEnabled={shape.pathType === "fill"}
+ lineCap="round"
+ strokeWidth={getStrokeWidth(
+ shape.strokeWidth,
+ gridSize,
+ mapWidth,
+ mapHeight
+ )}
+ {...defaultProps}
+ />
+ );
+ } else if (shape.type === "shape") {
+ if (shape.shapeType === "rectangle") {
+ return (
+
+ );
+ } else if (shape.shapeType === "circle") {
+ const minSide = mapWidth < mapHeight ? mapWidth : mapHeight;
+ return (
+
+ );
+ } else if (shape.shapeType === "triangle") {
+ return (
+ [...acc, point.x * mapWidth, point.y * mapHeight],
+ []
+ )}
+ closed={true}
+ {...defaultProps}
+ />
+ );
+ }
+ }
+ }
+
return (
-
-
-
+
+ {shapes.map(renderShape)}
+ {drawingShape && renderShape(drawingShape)}
+
);
}
diff --git a/src/components/map/MapInteraction.js b/src/components/map/MapInteraction.js
index 142c393..7227d27 100644
--- a/src/components/map/MapInteraction.js
+++ b/src/components/map/MapInteraction.js
@@ -18,7 +18,7 @@ const zoomSpeed = -0.001;
const minZoom = 0.1;
const maxZoom = 5;
-function MapInteraction({ map, children, controls }) {
+function MapInteraction({ map, children, controls, selectedToolId }) {
const mapSource = useDataSource(map, defaultMapSources);
const [mapSourceImage] = useImage(mapSource);
@@ -26,7 +26,10 @@ function MapInteraction({ map, children, controls }) {
const [stageHeight, setStageHeight] = useState(1);
const [stageScale, setStageScale] = useState(1);
const [stageTranslate, setStageTranslate] = useState({ x: 0, y: 0 });
+ // "none" | "first" | "dragging" | "last"
+ const [stageDragState, setStageDragState] = useState("none");
const [preventMapInteraction, setPreventMapInteraction] = useState(false);
+ const [mapDragPosition, setMapDragPosition] = useState({ x: 0, y: 0 });
const stageWidthRef = useRef(stageWidth);
const stageHeightRef = useRef(stageHeight);
@@ -40,6 +43,25 @@ function MapInteraction({ map, children, controls }) {
}
}, [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 bind = useGesture({
onWheel: ({ delta }) => {
const newScale = Math.min(
@@ -49,16 +71,25 @@ function MapInteraction({ map, children, controls }) {
setStageScale(newScale);
stageScaleRef.current = newScale;
},
- onDrag: ({ delta }) => {
- if (!preventMapInteraction) {
+ onDrag: ({ delta, xy, first, last }) => {
+ if (preventMapInteraction) {
+ return;
+ }
+ setMapDragPosition(getMapDragPosition(xy));
+ setStageDragState(first ? "first" : last ? "last" : "dragging");
+ const [dx, dy] = delta;
+ if (selectedToolId === "pan") {
const newTranslate = {
- x: stageTranslate.x + delta[0] / stageScale,
- y: stageTranslate.y + delta[1] / stageScale,
+ x: stageTranslate.x + dx / stageScale,
+ y: stageTranslate.y + dy / stageScale,
};
setStageTranslate(newTranslate);
stageTranslateRef.current = newTranslate;
}
},
+ onDragEnd: () => {
+ setStageDragState("none");
+ },
});
function handleResize(width, height) {
@@ -75,6 +106,7 @@ function MapInteraction({ map, children, controls }) {
const mapHeight = map ? stageWidth * (map.height / map.width) : stageHeight;
const mapStageRef = useContext(MapStageContext);
+ const mapImageRef = useRef();
const auth = useContext(AuthContext);
@@ -83,9 +115,11 @@ function MapInteraction({ map, children, controls }) {
stageScale,
stageWidth,
stageHeight,
+ stageDragState,
setPreventMapInteraction,
mapWidth,
mapHeight,
+ mapDragPosition,
};
return (
@@ -111,6 +145,7 @@ function MapInteraction({ map, children, controls }) {
width={mapWidth}
height={mapHeight}
id="mapImage"
+ ref={mapImageRef}
/>
{/* Forward auth context to konva elements */}
diff --git a/src/helpers/drawing.js b/src/helpers/drawing.js
index 89a192b..4be7c41 100644
--- a/src/helpers/drawing.js
+++ b/src/helpers/drawing.js
@@ -140,13 +140,13 @@ export function getUpdatedShapeData(type, data, brushPosition, gridSize) {
}
}
-const defaultStrokeSize = 1 / 10;
-export function getStrokeSize(multiplier, gridSize, canvasWidth, canvasHeight) {
+const defaultStrokeWidth = 1 / 10;
+export function getStrokeWidth(multiplier, gridSize, mapWidth, mapHeight) {
const gridPixelSize = Vector2.multiply(gridSize, {
- x: canvasWidth,
- y: canvasHeight,
+ x: mapWidth,
+ y: mapHeight,
});
- return Vector2.min(gridPixelSize) * defaultStrokeSize * multiplier;
+ return Vector2.min(gridPixelSize) * defaultStrokeWidth * multiplier;
}
export function shapeHasFill(shape) {
@@ -330,7 +330,7 @@ export function drawShape(shape, context, gridSize, canvasWidth, canvasHeight) {
context.strokeStyle = color;
if (shape.strokeWidth > 0) {
context.lineCap = "round";
- context.lineWidth = getStrokeSize(
+ context.lineWidth = getStrokeWidth(
shape.strokeWidth,
gridSize,
canvasWidth,