Added back map drawing

This commit is contained in:
Mitchell McCaffrey 2020-05-22 13:47:11 +10:00
parent 9e01ad1d0e
commit d26932d17c
4 changed files with 286 additions and 197 deletions

View File

@ -1,14 +1,17 @@
import React, { useState, useContext } from "react"; import React, { useState, useContext, useEffect } from "react";
import MapControls from "./MapControls"; import MapControls from "./MapControls";
import MapInteraction from "./MapInteraction"; import MapInteraction from "./MapInteraction";
import MapToken from "./MapToken"; import MapToken from "./MapToken";
import MapDrawing from "./MapDrawing";
import TokenDataContext from "../../contexts/TokenDataContext"; import TokenDataContext from "../../contexts/TokenDataContext";
import TokenMenu from "../token/TokenMenu"; import TokenMenu from "../token/TokenMenu";
import TokenDragOverlay from "../token/TokenDragOverlay"; import TokenDragOverlay from "../token/TokenDragOverlay";
import { omit } from "../../helpers/shared";
function Map({ function Map({
map, map,
mapState, mapState,
@ -84,6 +87,43 @@ function Map({
const [mapShapes, setMapShapes] = useState([]); const [mapShapes, setMapShapes] = useState([]);
const [fogShapes, setFogShapes] = 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 = []; const disabledControls = [];
if (!allowMapDrawing) { if (!allowMapDrawing) {
disabledControls.push("brush"); disabledControls.push("brush");
@ -182,6 +222,17 @@ function Map({
/> />
); );
const mapDrawing = (
<MapDrawing
shapes={mapShapes}
onShapeAdd={handleMapShapeAdd}
onShapeRemove={handleMapShapeRemove}
selectedToolId={selectedToolId}
selectedToolSettings={toolSettings[selectedToolId]}
gridSize={gridSizeNormalized}
/>
);
return ( return (
<MapInteraction <MapInteraction
map={map} map={map}
@ -192,7 +243,9 @@ function Map({
{tokenDragOverlay} {tokenDragOverlay}
</> </>
} }
selectedToolId={selectedToolId}
> >
{mapDrawing}
{mapTokens} {mapTokens}
</MapInteraction> </MapInteraction>
); );

View File

@ -1,123 +1,89 @@
import React, { useRef, useEffect, useState, useContext } from "react"; import React, { useContext, useEffect, useState } from "react";
import shortid from "shortid"; 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 { compare as comparePoints } from "../../helpers/vector2";
import { import {
getBrushPositionForTool, getBrushPositionForTool,
getDefaultShapeData, getDefaultShapeData,
getUpdatedShapeData, getUpdatedShapeData,
isShapeHovered,
drawShape,
simplifyPoints, simplifyPoints,
getRelativePointerPosition, getStrokeWidth,
} from "../../helpers/drawing"; } from "../../helpers/drawing";
import MapInteractionContext from "../../contexts/MapInteractionContext"; import colors from "../../helpers/colors";
function MapDrawing({ function MapDrawing({
width,
height,
selectedTool,
toolSettings,
shapes, shapes,
onShapeAdd, onShapeAdd,
onShapeRemove, onShapeRemove,
selectedToolId,
selectedToolSettings,
gridSize, gridSize,
}) { }) {
const canvasRef = useRef(); const {
const containerRef = useRef(); stageDragState,
mapDragPosition,
const [isPointerDown, setIsPointerDown] = useState(false); stageScale,
mapWidth,
mapHeight,
} = useContext(MapInteractionContext);
const [drawingShape, setDrawingShape] = useState(null); const [drawingShape, setDrawingShape] = useState(null);
const [pointerPosition, setPointerPosition] = useState({ x: -1, y: -1 });
const shouldHover = selectedTool === "erase"; const shouldHover = selectedToolId === "erase";
const isEditing = const isEditing =
selectedTool === "brush" || selectedToolId === "brush" ||
selectedTool === "shape" || selectedToolId === "shape" ||
selectedTool === "erase"; selectedToolId === "erase";
const { scaleRef } = useContext(MapInteractionContext);
// Reset pointer position when tool changes
useEffect(() => { useEffect(() => {
setPointerPosition({ x: -1, y: -1 });
}, [selectedTool]);
function handleStart(event) {
if (!isEditing) { if (!isEditing) {
return; 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) { function startShape() {
if (!isEditing) {
return;
}
if (event.touches && event.touches.length !== 1) {
return;
}
const pointer = event.touches ? event.touches[0] : event;
// Set pointer position every frame for erase tool and fog
if (shouldHover) {
const position = getRelativePointerPosition(
pointer,
containerRef.current
);
setPointerPosition(position);
}
if (isPointerDown) {
const position = getRelativePointerPosition(
pointer,
containerRef.current
);
setPointerPosition(position);
const brushPosition = getBrushPositionForTool( const brushPosition = getBrushPositionForTool(
position, mapDragPosition,
selectedTool, selectedToolId,
toolSettings, selectedToolSettings,
gridSize, gridSize,
shapes 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) => { setDrawingShape((prevShape) => {
const prevPoints = prevShape.data.points; const prevPoints = prevShape.data.points;
if ( if (
@ -132,14 +98,14 @@ function MapDrawing({
const simplified = simplifyPoints( const simplified = simplifyPoints(
[...prevPoints, brushPosition], [...prevPoints, brushPosition],
gridSize, gridSize,
scaleRef.current stageScale
); );
return { return {
...prevShape, ...prevShape,
data: { points: simplified }, data: { points: simplified },
}; };
}); });
} else if (selectedTool === "shape") { } else if (selectedToolId === "shape") {
setDrawingShape((prevShape) => ({ setDrawingShape((prevShape) => ({
...prevShape, ...prevShape,
data: getUpdatedShapeData( data: getUpdatedShapeData(
@ -151,110 +117,145 @@ function MapDrawing({
})); }));
} }
} }
}
function handleStop(event) { function endShape() {
if (!isEditing) { if (selectedToolId === "brush" && drawingShape) {
return; if (drawingShape.data.points.length > 1) {
} onShapeAdd(drawingShape);
if (event.touches && event.touches.length !== 0) { }
return; } else if (selectedToolId === "shape" && drawingShape) {
}
if (selectedTool === "brush" && drawingShape) {
if (drawingShape.data.points.length > 1) {
onShapeAdd(drawingShape); onShapeAdd(drawingShape);
} }
} else if (selectedTool === "shape" && drawingShape) { setDrawingShape(null);
onShapeAdd(drawingShape);
} }
if (selectedTool === "erase" && hoveredShapeRef.current && isPointerDown) { switch (stageDragState) {
onShapeRemove(hoveredShapeRef.current.id); case "first":
} startShape();
setIsPointerDown(false); return;
setDrawingShape(null); case "dragging":
} continueShape();
return;
// Add listeners for draw events on map to allow drawing past the bounds case "last":
// of the container endShape();
useEffect(() => { return;
const map = document.querySelector(".map"); default:
map.addEventListener("mousedown", handleStart); return;
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;
} }
}, [ }, [
shapes, stageDragState,
width, mapDragPosition,
height, selectedToolId,
pointerPosition, selectedToolSettings,
isPointerDown, isEditing,
selectedTool,
drawingShape,
gridSize, 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 (
<Line
points={shape.data.points.reduce(
(acc, point) => [...acc, point.x * mapWidth, point.y * mapHeight],
[]
)}
stroke={colors[shape.color] || shape.color}
tension={0.5}
closed={shape.pathType === "fill"}
fillEnabled={shape.pathType === "fill"}
lineCap="round"
strokeWidth={getStrokeWidth(
shape.strokeWidth,
gridSize,
mapWidth,
mapHeight
)}
{...defaultProps}
/>
);
} else if (shape.type === "shape") {
if (shape.shapeType === "rectangle") {
return (
<Rect
x={shape.data.x * mapWidth}
y={shape.data.y * mapHeight}
width={shape.data.width * mapWidth}
height={shape.data.height * mapHeight}
{...defaultProps}
/>
);
} else if (shape.shapeType === "circle") {
const minSide = mapWidth < mapHeight ? mapWidth : mapHeight;
return (
<Circle
x={shape.data.x * mapWidth}
y={shape.data.y * mapHeight}
radius={shape.data.radius * minSide}
{...defaultProps}
/>
);
} else if (shape.shapeType === "triangle") {
return (
<Line
points={shape.data.points.reduce(
(acc, point) => [...acc, point.x * mapWidth, point.y * mapHeight],
[]
)}
closed={true}
{...defaultProps}
/>
);
}
}
}
return ( return (
<div <Group>
style={{ {shapes.map(renderShape)}
position: "absolute", {drawingShape && renderShape(drawingShape)}
top: 0, </Group>
left: 0,
right: 0,
bottom: 0,
pointerEvents: "none",
}}
ref={containerRef}
>
<canvas
ref={canvasRef}
width={width}
height={height}
style={{ width: "100%", height: "100%" }}
/>
</div>
); );
} }

View File

@ -18,7 +18,7 @@ const zoomSpeed = -0.001;
const minZoom = 0.1; const minZoom = 0.1;
const maxZoom = 5; const maxZoom = 5;
function MapInteraction({ map, children, controls }) { function MapInteraction({ map, children, controls, selectedToolId }) {
const mapSource = useDataSource(map, defaultMapSources); const mapSource = useDataSource(map, defaultMapSources);
const [mapSourceImage] = useImage(mapSource); const [mapSourceImage] = useImage(mapSource);
@ -26,7 +26,10 @@ function MapInteraction({ map, children, controls }) {
const [stageHeight, setStageHeight] = useState(1); const [stageHeight, setStageHeight] = useState(1);
const [stageScale, setStageScale] = useState(1); const [stageScale, setStageScale] = useState(1);
const [stageTranslate, setStageTranslate] = useState({ x: 0, y: 0 }); const [stageTranslate, setStageTranslate] = useState({ x: 0, y: 0 });
// "none" | "first" | "dragging" | "last"
const [stageDragState, setStageDragState] = useState("none");
const [preventMapInteraction, setPreventMapInteraction] = useState(false); const [preventMapInteraction, setPreventMapInteraction] = useState(false);
const [mapDragPosition, setMapDragPosition] = useState({ x: 0, y: 0 });
const stageWidthRef = useRef(stageWidth); const stageWidthRef = useRef(stageWidth);
const stageHeightRef = useRef(stageHeight); const stageHeightRef = useRef(stageHeight);
@ -40,6 +43,25 @@ function MapInteraction({ map, children, controls }) {
} }
}, [map]); }, [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({ const bind = useGesture({
onWheel: ({ delta }) => { onWheel: ({ delta }) => {
const newScale = Math.min( const newScale = Math.min(
@ -49,16 +71,25 @@ function MapInteraction({ map, children, controls }) {
setStageScale(newScale); setStageScale(newScale);
stageScaleRef.current = newScale; stageScaleRef.current = newScale;
}, },
onDrag: ({ delta }) => { onDrag: ({ delta, xy, first, last }) => {
if (!preventMapInteraction) { if (preventMapInteraction) {
return;
}
setMapDragPosition(getMapDragPosition(xy));
setStageDragState(first ? "first" : last ? "last" : "dragging");
const [dx, dy] = delta;
if (selectedToolId === "pan") {
const newTranslate = { const newTranslate = {
x: stageTranslate.x + delta[0] / stageScale, x: stageTranslate.x + dx / stageScale,
y: stageTranslate.y + delta[1] / stageScale, y: stageTranslate.y + dy / stageScale,
}; };
setStageTranslate(newTranslate); setStageTranslate(newTranslate);
stageTranslateRef.current = newTranslate; stageTranslateRef.current = newTranslate;
} }
}, },
onDragEnd: () => {
setStageDragState("none");
},
}); });
function handleResize(width, height) { function handleResize(width, height) {
@ -75,6 +106,7 @@ function MapInteraction({ map, children, controls }) {
const mapHeight = map ? stageWidth * (map.height / map.width) : stageHeight; const mapHeight = map ? stageWidth * (map.height / map.width) : stageHeight;
const mapStageRef = useContext(MapStageContext); const mapStageRef = useContext(MapStageContext);
const mapImageRef = useRef();
const auth = useContext(AuthContext); const auth = useContext(AuthContext);
@ -83,9 +115,11 @@ function MapInteraction({ map, children, controls }) {
stageScale, stageScale,
stageWidth, stageWidth,
stageHeight, stageHeight,
stageDragState,
setPreventMapInteraction, setPreventMapInteraction,
mapWidth, mapWidth,
mapHeight, mapHeight,
mapDragPosition,
}; };
return ( return (
@ -111,6 +145,7 @@ function MapInteraction({ map, children, controls }) {
width={mapWidth} width={mapWidth}
height={mapHeight} height={mapHeight}
id="mapImage" id="mapImage"
ref={mapImageRef}
/> />
{/* Forward auth context to konva elements */} {/* Forward auth context to konva elements */}
<AuthContext.Provider value={auth}> <AuthContext.Provider value={auth}>

View File

@ -140,13 +140,13 @@ export function getUpdatedShapeData(type, data, brushPosition, gridSize) {
} }
} }
const defaultStrokeSize = 1 / 10; const defaultStrokeWidth = 1 / 10;
export function getStrokeSize(multiplier, gridSize, canvasWidth, canvasHeight) { export function getStrokeWidth(multiplier, gridSize, mapWidth, mapHeight) {
const gridPixelSize = Vector2.multiply(gridSize, { const gridPixelSize = Vector2.multiply(gridSize, {
x: canvasWidth, x: mapWidth,
y: canvasHeight, y: mapHeight,
}); });
return Vector2.min(gridPixelSize) * defaultStrokeSize * multiplier; return Vector2.min(gridPixelSize) * defaultStrokeWidth * multiplier;
} }
export function shapeHasFill(shape) { export function shapeHasFill(shape) {
@ -330,7 +330,7 @@ export function drawShape(shape, context, gridSize, canvasWidth, canvasHeight) {
context.strokeStyle = color; context.strokeStyle = color;
if (shape.strokeWidth > 0) { if (shape.strokeWidth > 0) {
context.lineCap = "round"; context.lineCap = "round";
context.lineWidth = getStrokeSize( context.lineWidth = getStrokeWidth(
shape.strokeWidth, shape.strokeWidth,
gridSize, gridSize,
canvasWidth, canvasWidth,