Added undo and redo and map control validation

This commit is contained in:
Mitchell McCaffrey 2020-04-19 13:33:31 +10:00
parent 1f20959940
commit 24de41fee7
4 changed files with 213 additions and 75 deletions

View File

@ -8,6 +8,8 @@ import MapToken from "./MapToken";
import MapDrawing from "./MapDrawing";
import MapControls from "./MapControls";
import { omit } from "../helpers/shared";
const mapTokenClassName = "map-token";
const zoomSpeed = -0.005;
const minZoom = 0.1;
@ -31,11 +33,77 @@ function Map({
}
}
const [mapTranslate, setMapTranslate] = useState({ x: 0, y: 0 });
const [mapScale, setMapScale] = useState(1);
/**
* Map drawing
*/
const [selectedTool, setSelectedTool] = useState("pan");
const [drawnShapes, setDrawnShapes] = useState([]);
const [drawActions, setDrawActions] = useState([]);
const [drawActionIndex, setDrawActionIndex] = useState(-1);
function handleShapeAdd(shape) {
setDrawActions((prevActions) => {
const newActions = [
...prevActions.slice(0, drawActionIndex + 1),
{ type: "add", shape },
];
setDrawActionIndex(newActions.length - 1);
return newActions;
});
}
function handleShapeRemove(shapeId) {
setDrawActions((prevActions) => {
const newActions = [
...prevActions.slice(0, drawActionIndex + 1),
{ type: "remove", shapeId },
];
setDrawActionIndex(newActions.length - 1);
return newActions;
});
}
useEffect(() => {
let shapesById = {};
for (let i = 0; i <= drawActionIndex; i++) {
const action = drawActions[i];
if (action.type === "add") {
shapesById[action.shape.id] = action.shape;
}
if (action.type === "remove") {
shapesById = omit(shapesById, [action.shapeId]);
}
}
setDrawnShapes(Object.values(shapesById));
}, [drawActions, drawActionIndex]);
function handleDrawActionUndo() {
setDrawActionIndex((prevIndex) => Math.max(prevIndex - 1, -1));
}
function handleDrawActionRedo() {
setDrawActionIndex((prevIndex) =>
Math.min(prevIndex + 1, drawActions.length - 1)
);
}
const disabledTools = [];
if (!mapData) {
disabledTools.push("pan");
disabledTools.push("brush");
}
if (drawnShapes.length === 0) {
disabledTools.push("erase");
}
/**
* Map movement
*/
const [mapTranslate, setMapTranslate] = useState({ x: 0, y: 0 });
const [mapScale, setMapScale] = useState(1);
useEffect(() => {
interact(".map")
.gesturable({
@ -110,6 +178,10 @@ function Map({
};
}, []);
/**
* Member setup
*/
const mapRef = useRef(null);
const mapContainerRef = useRef();
const rows = mapData && mapData.rows;
@ -201,6 +273,9 @@ function Map({
width={mapData ? mapData.width : 0}
height={mapData ? mapData.height : 0}
selectedTool={selectedTool}
shapes={drawnShapes}
onShapeAdd={handleShapeAdd}
onShapeRemove={handleShapeRemove}
/>
</Box>
</Box>
@ -208,6 +283,11 @@ function Map({
onMapChange={onMapChange}
onToolChange={setSelectedTool}
selectedTool={selectedTool}
disabledTools={disabledTools}
onUndo={handleDrawActionUndo}
onRedo={handleDrawActionRedo}
undoDisabled={drawActionIndex < 0}
redoDisabled={drawActionIndex === drawActions.length - 1}
/>
</Box>
<ProxyToken

View File

@ -1,4 +1,4 @@
import React from "react";
import React, { useState } from "react";
import { Flex, Box, IconButton } from "theme-ui";
import AddMapButton from "./AddMapButton";
@ -13,9 +13,14 @@ function MapControls({
onMapChange,
onToolChange,
selectedTool,
disabledTools,
onUndo,
onRedo,
undoDisabled,
redoDisabled,
}) {
const [isExpanded, setIsExpanded] = useState(false);
const divider = (
<Box
my={2}
@ -34,42 +39,71 @@ function MapControls({
alignItems: "center",
}}
>
<IconButton aria-label="Expand More" title="Expand More">
<IconButton
aria-label={isExpanded ? "Hide Map Controls" : "Show Map Controls"}
title={isExpanded ? "Hide Map Controls" : "Show Map Controls"}
onClick={() => setIsExpanded(!isExpanded)}
sx={{
transform: `rotate(${isExpanded ? "0" : "180deg"})`,
display: "block",
}}
>
<ExpandMoreIcon />
</IconButton>
<AddMapButton onMapChange={onMapChange} />
{divider}
<IconButton
aria-label="Pan Tool"
title="Pan Tool"
onClick={() => onToolChange("pan")}
sx={{ color: selectedTool === "pan" ? "primary" : "text" }}
<Box
sx={{
flexDirection: "column",
alignItems: "center",
display: isExpanded ? "flex" : "none",
}}
>
<PanToolIcon />
</IconButton>
<IconButton
aria-label="Brush Tool"
title="Brush Tool"
onClick={() => onToolChange("brush")}
sx={{ color: selectedTool === "brush" ? "primary" : "text" }}
>
<BrushToolIcon />
</IconButton>
<IconButton
aria-label="Erase Tool"
title="Erase Tool"
onClick={() => onToolChange("erase")}
sx={{ color: selectedTool === "erase" ? "primary" : "text" }}
>
<EraseToolIcon />
</IconButton>
{divider}
<IconButton aria-label="Undo" title="Undo">
<UndoIcon />
</IconButton>
<IconButton aria-label="Redo" title="Redo">
<RedoIcon />
</IconButton>
<AddMapButton onMapChange={onMapChange} />
{divider}
<IconButton
aria-label="Pan Tool"
title="Pan Tool"
onClick={() => onToolChange("pan")}
sx={{ color: selectedTool === "pan" ? "primary" : "text" }}
disabled={disabledTools.includes("pan")}
>
<PanToolIcon />
</IconButton>
<IconButton
aria-label="Brush Tool"
title="Brush Tool"
onClick={() => onToolChange("brush")}
sx={{ color: selectedTool === "brush" ? "primary" : "text" }}
disabled={disabledTools.includes("brush")}
>
<BrushToolIcon />
</IconButton>
<IconButton
aria-label="Erase Tool"
title="Erase Tool"
onClick={() => onToolChange("erase")}
sx={{ color: selectedTool === "erase" ? "primary" : "text" }}
disabled={disabledTools.includes("erase")}
>
<EraseToolIcon />
</IconButton>
{divider}
<IconButton
aria-label="Undo"
title="Undo"
onClick={() => onUndo()}
disabled={undoDisabled}
>
<UndoIcon />
</IconButton>
<IconButton
aria-label="Redo"
title="Redo"
onClick={() => onRedo()}
disabled={redoDisabled}
>
<RedoIcon />
</IconButton>
</Box>
</Flex>
);
}

View File

@ -1,12 +1,18 @@
import React, { useRef, useEffect, useState } from "react";
import simplify from "simplify-js";
import shortid from "shortid";
function MapDrawing({ width, height, selectedTool }) {
function MapDrawing({
width,
height,
selectedTool,
shapes,
onShapeAdd,
onShapeRemove,
}) {
const canvasRef = useRef();
const containerRef = useRef();
const [shapes, setShapes] = useState([]);
function getMousePosition(event) {
const container = containerRef.current;
if (container) {
@ -17,12 +23,13 @@ function MapDrawing({ width, height, selectedTool }) {
}
}
const [brushPoints, setBrushPoints] = useState([]);
const [isMouseDown, setIsMouseDown] = useState(false);
function handleMouseDown(event) {
setIsMouseDown(true);
if (selectedTool === "brush") {
const position = getMousePosition(event);
setShapes((prevShapes) => [...prevShapes, { points: [position] }]);
setBrushPoints([position]);
}
}
@ -34,41 +41,50 @@ function MapDrawing({ width, height, selectedTool }) {
}
if (isMouseDown && selectedTool === "brush") {
setMousePosition(position);
setShapes((prevShapes) => {
const currentShape = prevShapes.slice(-1)[0];
const otherShapes = prevShapes.slice(0, -1);
return [...otherShapes, { points: [...currentShape.points, position] }];
});
setBrushPoints((prevPoints) => [...prevPoints, position]);
}
}
function handleMouseUp(event) {
setIsMouseDown(false);
if (selectedTool === "brush") {
setShapes((prevShapes) => {
const currentShape = prevShapes.slice(-1)[0];
const otherShapes = prevShapes.slice(0, -1);
const simplified = simplify(currentShape.points, 0.001);
return [...otherShapes, { points: simplified }];
});
const simplifiedPoints = simplify(brushPoints, 0.001);
onShapeAdd({ id: shortid.generate(), points: simplifiedPoints });
setBrushPoints([]);
}
if (selectedTool === "erase" && hoveredShapeRef.current) {
onShapeRemove(hoveredShapeRef.current.id);
}
}
const hoveredShapeRef = useRef(null);
useEffect(() => {
function pointsToPath(points) {
const path = new Path2D();
path.moveTo(points[0].x * width, points[0].y * height);
for (let point of points.slice(1)) {
path.lineTo(point.x * width, point.y * height);
}
path.closePath();
return path;
}
function drawPath(path, color, context) {
context.fillStyle = color;
context.strokeStyle = color;
context.stroke(path);
context.fill(path);
}
const canvas = canvasRef.current;
if (canvas) {
const context = canvas.getContext("2d");
context.clearRect(0, 0, width, height);
let erasedShapes = [];
for (let [index, shape] of shapes.entries()) {
const path = new Path2D();
path.moveTo(shape.points[0].x * width, shape.points[0].y * height);
for (let point of shape.points.slice(1)) {
path.lineTo(point.x * width, point.y * height);
}
path.closePath();
let color = "#000000";
let hoveredShape = null;
for (let shape of shapes) {
const path = pointsToPath(shape.points);
// Detect hover
if (selectedTool === "erase") {
if (
context.isPointInPath(
@ -77,26 +93,30 @@ function MapDrawing({ width, height, selectedTool }) {
mousePosition.y * height
)
) {
color = "#BB99FF";
if (isMouseDown) {
erasedShapes.push(index);
continue;
}
hoveredShape = shape;
}
}
context.fillStyle = color;
context.strokeStyle = color;
context.stroke(path);
context.fill(path);
drawPath(path, "#000000", context);
}
if (erasedShapes.length > 0) {
setShapes((prevShapes) =>
prevShapes.filter((_, i) => !erasedShapes.includes(i))
);
if (selectedTool === "brush" && brushPoints.length > 0) {
const path = pointsToPath(brushPoints);
drawPath(path, "#000000", context);
}
if (hoveredShape) {
const path = pointsToPath(hoveredShape.points);
drawPath(path, "#BB99FF", context);
}
hoveredShapeRef.current = hoveredShape;
}
}, [shapes, width, height, mousePosition, isMouseDown, selectedTool]);
}, [
shapes,
width,
height,
mousePosition,
isMouseDown,
selectedTool,
brushPoints,
]);
return (
<div

View File

@ -235,6 +235,10 @@ export default {
"&:active": {
color: "secondary",
},
"&:disabled": {
opacity: 0.5,
color: "text",
},
},
close: {
"&:hover": {