diff --git a/package.json b/package.json index 02264ad..21efebe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "owlbear-rodeo", - "version": "1.3.3", + "version": "1.4.0", "private": true, "dependencies": { "@msgpack/msgpack": "^1.12.1", @@ -16,12 +16,13 @@ "fake-indexeddb": "^3.0.0", "interactjs": "^1.9.7", "konva": "^6.0.0", - "normalize-wheel": "^1.0.1", + "polygon-clipping": "^0.14.3", "raw.macro": "^0.3.0", "react": "^16.13.0", "react-dom": "^16.13.0", "react-konva": "^16.13.0-3", "react-markdown": "^4.3.1", + "react-media": "^2.0.0-rc.1", "react-modal": "^3.11.2", "react-resize-detector": "^4.2.3", "react-router-dom": "^5.1.2", diff --git a/src/components/LoadingOverlay.js b/src/components/LoadingOverlay.js index eca0d0b..8cc4b9e 100644 --- a/src/components/LoadingOverlay.js +++ b/src/components/LoadingOverlay.js @@ -15,6 +15,7 @@ function LoadingOverlay() { alignItems: "center", top: 0, left: 0, + flexDirection: "column", }} bg="muted" > diff --git a/src/components/Modal.js b/src/components/Modal.js index 94b9eda..8133ef0 100644 --- a/src/components/Modal.js +++ b/src/components/Modal.js @@ -30,14 +30,20 @@ function StyledModal({ }} {...props} > - {children} - {allowClose && ( - - )} + {/* Stop keyboard events when modal is open to prevent shortcuts from triggering */} +
e.stopPropagation()} + onKeyUp={(e) => e.stopPropagation()} + > + {children} + {allowClose && ( + + )} +
); } diff --git a/src/components/map/Map.js b/src/components/map/Map.js index 38b54dc..56ae6a1 100644 --- a/src/components/map/Map.js +++ b/src/components/map/Map.js @@ -7,15 +7,15 @@ import MapDrawing from "./MapDrawing"; import MapFog from "./MapFog"; import MapDice from "./MapDice"; import MapGrid from "./MapGrid"; +import MapMeasure from "./MapMeasure"; +import MapLoadingOverlay from "./MapLoadingOverlay"; import TokenDataContext from "../../contexts/TokenDataContext"; -import MapLoadingContext from "../../contexts/MapLoadingContext"; import TokenMenu from "../token/TokenMenu"; import TokenDragOverlay from "../token/TokenDragOverlay"; -import LoadingOverlay from "../LoadingOverlay"; -import { omit } from "../../helpers/shared"; +import { drawActionsToShapes } from "../../helpers/drawing"; function Map({ map, @@ -35,7 +35,6 @@ function Map({ disabledTokens, }) { const { tokensById } = useContext(TokenDataContext); - const { isLoading } = useContext(MapLoadingContext); const gridX = map && map.gridX; const gridY = map && map.gridY; @@ -47,17 +46,15 @@ function Map({ const [selectedToolId, setSelectedToolId] = useState("pan"); const [toolSettings, setToolSettings] = useState({ - fog: { type: "add", useEdgeSnapping: true, useGridSnapping: false }, - brush: { - color: "darkGray", - type: "stroke", - useBlending: false, - }, - shape: { + fog: { type: "polygon", useEdgeSnapping: false, useFogCut: false }, + drawing: { color: "red", - type: "rectangle", + type: "brush", useBlending: true, }, + measure: { + type: "chebyshev", + }, }); function handleToolSettingChange(tool, change) { @@ -108,6 +105,10 @@ function Map({ onFogDraw({ type: "add", shapes: [shape] }); } + function handleFogShapeSubtract(shape) { + onFogDraw({ type: "subtract", shapes: [shape] }); + } + function handleFogShapesRemove(shapeIds) { onFogDraw({ type: "remove", shapeIds }); } @@ -121,59 +122,38 @@ function Map({ if (!mapState) { return; } - function actionsToShapes(actions, actionIndex) { - let shapesById = {}; - for (let i = 0; i <= actionIndex; i++) { - const action = actions[i]; - if (action.type === "add" || action.type === "edit") { - for (let shape of action.shapes) { - shapesById[shape.id] = shape; - } - } - if (action.type === "remove") { - shapesById = omit(shapesById, action.shapeIds); - } - } - return Object.values(shapesById); - } - setMapShapes( - actionsToShapes(mapState.mapDrawActions, mapState.mapDrawActionIndex) + drawActionsToShapes(mapState.mapDrawActions, mapState.mapDrawActionIndex) ); setFogShapes( - actionsToShapes(mapState.fogDrawActions, mapState.fogDrawActionIndex) + drawActionsToShapes(mapState.fogDrawActions, mapState.fogDrawActionIndex) ); }, [mapState]); const disabledControls = []; if (!allowMapDrawing) { - disabledControls.push("brush"); - disabledControls.push("shape"); - disabledControls.push("erase"); + disabledControls.push("drawing"); } if (!map) { disabledControls.push("pan"); - } - if (mapShapes.length === 0) { - disabledControls.push("erase"); + disabledControls.push("measure"); } if (!allowFogDrawing) { disabledControls.push("fog"); } - const disabledSettings = { fog: [], brush: [], shape: [], erase: [] }; + const disabledSettings = { fog: [], drawing: [] }; + if (mapShapes.length === 0) { + disabledSettings.drawing.push("erase"); + } if (!mapState || mapState.mapDrawActionIndex < 0) { - disabledSettings.brush.push("undo"); - disabledSettings.shape.push("undo"); - disabledSettings.erase.push("undo"); + disabledSettings.drawing.push("undo"); } if ( !mapState || mapState.mapDrawActionIndex === mapState.mapDrawActions.length - 1 ) { - disabledSettings.brush.push("redo"); - disabledSettings.shape.push("redo"); - disabledSettings.erase.push("redo"); + disabledSettings.drawing.push("redo"); } if (!mapState || mapState.fogDrawActionIndex < 0) { disabledSettings.fog.push("undo"); @@ -287,6 +267,7 @@ function Map({ ); + const mapMeasure = ( + + ); + return ( - {isLoading && } + } selectedToolId={selectedToolId} + onSelectedToolChange={setSelectedToolId} + disabledControls={disabledControls} > {mapGrid} {mapDrawing} {mapTokens} {mapFog} + {mapMeasure} ); } diff --git a/src/components/map/MapControls.js b/src/components/map/MapControls.js index 93c2a25..983bd0e 100644 --- a/src/components/map/MapControls.js +++ b/src/components/map/MapControls.js @@ -7,15 +7,13 @@ import Divider from "../Divider"; import SelectMapButton from "./SelectMapButton"; import FogToolSettings from "./controls/FogToolSettings"; -import BrushToolSettings from "./controls/BrushToolSettings"; -import ShapeToolSettings from "./controls/ShapeToolSettings"; -import EraseToolSettings from "./controls/EraseToolSettings"; +import DrawingToolSettings from "./controls/DrawingToolSettings"; +import MeasureToolSettings from "./controls/MeasureToolSettings"; import PanToolIcon from "../../icons/PanToolIcon"; import FogToolIcon from "../../icons/FogToolIcon"; import BrushToolIcon from "../../icons/BrushToolIcon"; -import ShapeToolIcon from "../../icons/ShapeToolIcon"; -import EraseToolIcon from "../../icons/EraseToolIcon"; +import MeasureToolIcon from "../../icons/MeasureToolIcon"; import ExpandMoreIcon from "../../icons/ExpandMoreIcon"; function MapContols({ @@ -45,26 +43,20 @@ function MapContols({ title: "Fog Tool", SettingsComponent: FogToolSettings, }, - brush: { - id: "brush", + drawing: { + id: "drawing", icon: , - title: "Brush Tool", - SettingsComponent: BrushToolSettings, + title: "Drawing Tool", + SettingsComponent: DrawingToolSettings, }, - shape: { - id: "shape", - icon: , - title: "Shape Tool", - SettingsComponent: ShapeToolSettings, - }, - erase: { - id: "erase", - icon: , - title: "Erase tool", - SettingsComponent: EraseToolSettings, + measure: { + id: "measure", + icon: , + title: "Measure Tool", + SettingsComponent: MeasureToolSettings, }, }; - const tools = ["pan", "fog", "brush", "shape", "erase"]; + const tools = ["pan", "fog", "drawing", "measure"]; const sections = [ { @@ -79,7 +71,7 @@ function MapContols({ ), }, { - id: "drawing", + id: "tools", component: tools.map((tool) => ( { - setIsBrushDown(false); - if (erasingShapes.length > 0) { - onShapesRemove(erasingShapes.map((shape) => shape.id)); - setErasingShapes([]); + useEffect(() => { + if (!isEditing) { + return; } - }, [erasingShapes, onShapesRemove]); + const mapStage = mapStageRef.current; - const handleShapeDraw = useCallback( - (brushState, mapBrushPosition) => { - function startShape() { - const brushPosition = getBrushPositionForTool( - mapBrushPosition, - selectedToolId, - selectedToolSettings, - gridSize, - shapes - ); - const commonShapeData = { - color: selectedToolSettings && selectedToolSettings.color, - blend: selectedToolSettings && selectedToolSettings.useBlending, - id: shortid.generate(), - }; - if (selectedToolId === "brush") { - setDrawingShape({ - type: "path", - pathType: selectedToolSettings.type, - data: { points: [brushPosition] }, - strokeWidth: selectedToolSettings.type === "stroke" ? 1 : 0, - ...commonShapeData, - }); - } else if (selectedToolId === "shape") { - setDrawingShape({ - type: "shape", - shapeType: selectedToolSettings.type, - data: getDefaultShapeData(selectedToolSettings.type, brushPosition), - strokeWidth: 0, - ...commonShapeData, - }); - } - setIsBrushDown(true); + function getBrushPosition() { + const mapImage = mapStage.findOne("#mapImage"); + return getBrushPositionForTool( + getRelativePointerPositionNormalized(mapImage), + selectedToolId, + selectedToolSettings, + gridSize, + shapes + ); + } + + function handleBrushDown() { + const brushPosition = getBrushPosition(); + const commonShapeData = { + color: selectedToolSettings && selectedToolSettings.color, + blend: selectedToolSettings && selectedToolSettings.useBlending, + id: shortid.generate(), + }; + if (isBrush) { + setDrawingShape({ + type: "path", + pathType: selectedToolSettings.type === "brush" ? "stroke" : "fill", + data: { points: [brushPosition] }, + strokeWidth: selectedToolSettings.type === "brush" ? 1 : 0, + ...commonShapeData, + }); + } else if (isShape) { + setDrawingShape({ + type: "shape", + shapeType: selectedToolSettings.type, + data: getDefaultShapeData(selectedToolSettings.type, brushPosition), + strokeWidth: selectedToolSettings.type === "line" ? 1 : 0, + ...commonShapeData, + }); } + setIsBrushDown(true); + } - function continueShape() { - const brushPosition = getBrushPositionForTool( - mapBrushPosition, - selectedToolId, - selectedToolSettings, - gridSize, - shapes - ); - if (selectedToolId === "brush") { + function handleBrushMove() { + const brushPosition = getBrushPosition(); + if (isBrushDown && drawingShape) { + if (isBrush) { setDrawingShape((prevShape) => { const prevPoints = prevShape.data.points; if ( @@ -108,7 +116,7 @@ function MapDrawing({ data: { points: simplified }, }; }); - } else if (selectedToolId === "shape") { + } else if (isShape) { setDrawingShape((prevShape) => ({ ...prevShape, data: getUpdatedShapeData( @@ -120,46 +128,52 @@ function MapDrawing({ })); } } + } - function endShape() { - if (selectedToolId === "brush" && drawingShape) { - if (drawingShape.data.points.length > 1) { - onShapeAdd(drawingShape); - } - } else if (selectedToolId === "shape" && drawingShape) { + function handleBrushUp() { + if (isBrush && drawingShape) { + if (drawingShape.data.points.length > 1) { onShapeAdd(drawingShape); } - setDrawingShape(null); - handleBrushUp(); + } else if (isShape && drawingShape) { + onShapeAdd(drawingShape); } - switch (brushState) { - case "first": - startShape(); - return; - case "drawing": - continueShape(); - return; - case "last": - endShape(); - return; - default: - return; + if (erasingShapes.length > 0) { + onShapesRemove(erasingShapes.map((shape) => shape.id)); + setErasingShapes([]); } - }, - [ - selectedToolId, - selectedToolSettings, - gridSize, - stageScale, - onShapeAdd, - shapes, - drawingShape, - handleBrushUp, - ] - ); - useMapBrush(isEditing, handleShapeDraw); + setDrawingShape(null); + setIsBrushDown(false); + } + + interactionEmitter.on("dragStart", handleBrushDown); + interactionEmitter.on("drag", handleBrushMove); + interactionEmitter.on("dragEnd", handleBrushUp); + + return () => { + interactionEmitter.off("dragStart", handleBrushDown); + interactionEmitter.off("drag", handleBrushMove); + interactionEmitter.off("dragEnd", handleBrushUp); + }; + }, [ + drawingShape, + erasingShapes, + gridSize, + isBrush, + isBrushDown, + isEditing, + isShape, + mapStageRef, + onShapeAdd, + onShapesRemove, + selectedToolId, + selectedToolSettings, + shapes, + stageScale, + interactionEmitter, + ]); function handleShapeOver(shape, isDown) { if (shouldHover && isDown) { @@ -178,6 +192,7 @@ function MapDrawing({ onTouchStart: () => handleShapeOver(shape, true), fill: colors[shape.color] || shape.color, opacity: shape.blend ? 0.5 : 1, + id: shape.id, }; if (shape.type === "path") { return ( @@ -232,6 +247,24 @@ function MapDrawing({ {...defaultProps} /> ); + } else if (shape.shapeType === "line") { + return ( + [...acc, point.x * mapWidth, point.y * mapHeight], + [] + )} + strokeWidth={getStrokeWidth( + shape.strokeWidth, + gridSize, + mapWidth, + mapHeight + )} + stroke={colors[shape.color] || shape.color} + lineCap="round" + {...defaultProps} + /> + ); } } } diff --git a/src/components/map/MapFog.js b/src/components/map/MapFog.js index 84dc929..7a03c81 100644 --- a/src/components/map/MapFog.js +++ b/src/components/map/MapFog.js @@ -1,11 +1,12 @@ -import React, { useContext, useState, useCallback } from "react"; +import React, { useContext, useState, useEffect, useCallback } from "react"; import shortid from "shortid"; -import { Group, Line } from "react-konva"; +import { Group } from "react-konva"; import useImage from "use-image"; import diagonalPattern from "../../images/DiagonalPattern.png"; import MapInteractionContext from "../../contexts/MapInteractionContext"; +import MapStageContext from "../../contexts/MapStageContext"; import { compare as comparePoints } from "../../helpers/vector2"; import { @@ -13,20 +14,27 @@ import { simplifyPoints, getStrokeWidth, } from "../../helpers/drawing"; - import colors from "../../helpers/colors"; -import useMapBrush from "../../helpers/useMapBrush"; +import { + HoleyLine, + getRelativePointerPositionNormalized, + Tick, +} from "../../helpers/konva"; function MapFog({ shapes, onShapeAdd, + onShapeSubtract, onShapesRemove, onShapesEdit, selectedToolId, selectedToolSettings, gridSize, }) { - const { stageScale, mapWidth, mapHeight } = useContext(MapInteractionContext); + const { stageScale, mapWidth, mapHeight, interactionEmitter } = useContext( + MapInteractionContext + ); + const mapStageRef = useContext(MapStageContext); const [drawingShape, setDrawingShape] = useState(null); const [isBrushDown, setIsBrushDown] = useState(false); const [editingShapes, setEditingShapes] = useState([]); @@ -39,120 +47,272 @@ function MapFog({ const [patternImage] = useImage(diagonalPattern); - const handleBrushUp = useCallback(() => { - setIsBrushDown(false); - if (editingShapes.length > 0) { - if (selectedToolSettings.type === "remove") { - onShapesRemove(editingShapes.map((shape) => shape.id)); - } else if (selectedToolSettings.type === "toggle") { - onShapesEdit( - editingShapes.map((shape) => ({ ...shape, visible: !shape.visible })) - ); - } - setEditingShapes([]); + useEffect(() => { + if (!isEditing) { + return; } - }, [editingShapes, onShapesRemove, onShapesEdit, selectedToolSettings]); - const handleShapeDraw = useCallback( - (brushState, mapBrushPosition) => { - function startShape() { - const brushPosition = getBrushPositionForTool( - mapBrushPosition, - selectedToolId, - selectedToolSettings, - gridSize, - shapes - ); - if (selectedToolSettings.type === "add") { - setDrawingShape({ - type: "fog", - data: { points: [brushPosition] }, - strokeWidth: 0.5, - color: "black", - blend: false, - id: shortid.generate(), - visible: true, - }); - } - setIsBrushDown(true); + const mapStage = mapStageRef.current; + + function getBrushPosition() { + const mapImage = mapStage.findOne("#mapImage"); + return getBrushPositionForTool( + getRelativePointerPositionNormalized(mapImage), + selectedToolId, + selectedToolSettings, + gridSize, + shapes + ); + } + + function handleBrushDown() { + const brushPosition = getBrushPosition(); + if (selectedToolSettings.type === "brush") { + setDrawingShape({ + type: "fog", + data: { + points: [brushPosition], + holes: [], + }, + strokeWidth: 0.5, + color: selectedToolSettings.useFogSubtract ? "red" : "black", + blend: false, + id: shortid.generate(), + visible: true, + }); } + setIsBrushDown(true); + } - function continueShape() { - const brushPosition = getBrushPositionForTool( - mapBrushPosition, - selectedToolId, - selectedToolSettings, - gridSize, - shapes - ); - if (selectedToolSettings.type === "add") { - setDrawingShape((prevShape) => { - const prevPoints = prevShape.data.points; - if ( - comparePoints( - prevPoints[prevPoints.length - 1], - brushPosition, - 0.001 - ) - ) { - return prevShape; - } - return { - ...prevShape, - data: { points: [...prevPoints, brushPosition] }, - }; - }); - } + function handleBrushMove() { + if ( + selectedToolSettings.type === "brush" && + isBrushDown && + drawingShape + ) { + const brushPosition = getBrushPosition(); + setDrawingShape((prevShape) => { + const prevPoints = prevShape.data.points; + if ( + comparePoints( + prevPoints[prevPoints.length - 1], + brushPosition, + 0.001 + ) + ) { + return prevShape; + } + return { + ...prevShape, + data: { + ...prevShape.data, + points: [...prevPoints, brushPosition], + }, + }; + }); } + } - function endShape() { - if (selectedToolSettings.type === "add" && drawingShape) { - if (drawingShape.data.points.length > 1) { - const shape = { - ...drawingShape, - data: { - points: simplifyPoints( - drawingShape.data.points, - gridSize, - // Downscale fog as smoothing doesn't currently work with edge snapping - stageScale / 2 - ), - }, - }; + function handleBrushUp() { + if (selectedToolSettings.type === "brush" && drawingShape) { + const subtract = selectedToolSettings.useFogSubtract; + + if (drawingShape.data.points.length > 1) { + let shapeData = {}; + if (subtract) { + shapeData = { id: drawingShape.id, type: drawingShape.type }; + } else { + shapeData = { ...drawingShape, color: "black" }; + } + const shape = { + ...shapeData, + data: { + ...drawingShape.data, + points: simplifyPoints( + drawingShape.data.points, + gridSize, + // Downscale fog as smoothing doesn't currently work with edge snapping + stageScale / 2 + ), + }, + }; + if (subtract) { + onShapeSubtract(shape); + } else { onShapeAdd(shape); } } setDrawingShape(null); - handleBrushUp(); } - switch (brushState) { - case "first": - startShape(); - return; - case "drawing": - continueShape(); - return; - case "last": - endShape(); - return; - default: - return; + // Erase + if (editingShapes.length > 0) { + if (selectedToolSettings.type === "remove") { + onShapesRemove(editingShapes.map((shape) => shape.id)); + } else if (selectedToolSettings.type === "toggle") { + onShapesEdit( + editingShapes.map((shape) => ({ + ...shape, + visible: !shape.visible, + })) + ); + } + setEditingShapes([]); } - }, - [ - selectedToolId, - selectedToolSettings, - gridSize, - stageScale, - onShapeAdd, - shapes, - drawingShape, - handleBrushUp, - ] - ); - useMapBrush(isEditing, handleShapeDraw); + setIsBrushDown(false); + } + + function handlePolygonClick() { + if (selectedToolSettings.type === "polygon") { + const brushPosition = getBrushPosition(); + setDrawingShape((prevDrawingShape) => { + if (prevDrawingShape) { + return { + ...prevDrawingShape, + data: { + ...prevDrawingShape.data, + points: [...prevDrawingShape.data.points, brushPosition], + }, + }; + } else { + return { + type: "fog", + data: { + points: [brushPosition, brushPosition], + holes: [], + }, + strokeWidth: 0.5, + color: selectedToolSettings.useFogSubtract ? "red" : "black", + blend: false, + id: shortid.generate(), + visible: true, + }; + } + }); + } + } + + function handlePolygonMove() { + if (selectedToolSettings.type === "polygon" && drawingShape) { + const brushPosition = getBrushPosition(); + setDrawingShape((prevShape) => { + if (!prevShape) { + return; + } + return { + ...prevShape, + data: { + ...prevShape.data, + points: [...prevShape.data.points.slice(0, -1), brushPosition], + }, + }; + }); + } + } + + interactionEmitter.on("dragStart", handleBrushDown); + interactionEmitter.on("drag", handleBrushMove); + interactionEmitter.on("dragEnd", handleBrushUp); + // Use mouse events for polygon and erase to allow for single clicks + mapStage.on("mousedown touchstart", handlePolygonMove); + mapStage.on("mousemove touchmove", handlePolygonMove); + mapStage.on("click tap", handlePolygonClick); + + return () => { + interactionEmitter.off("dragStart", handleBrushDown); + interactionEmitter.off("drag", handleBrushMove); + interactionEmitter.off("dragEnd", handleBrushUp); + mapStage.off("mousedown touchstart", handlePolygonMove); + mapStage.off("mousemove touchmove", handlePolygonMove); + mapStage.off("click tap", handlePolygonClick); + }; + }, [ + mapStageRef, + isEditing, + drawingShape, + editingShapes, + gridSize, + isBrushDown, + onShapeAdd, + onShapeSubtract, + onShapesEdit, + onShapesRemove, + selectedToolId, + selectedToolSettings, + shapes, + stageScale, + interactionEmitter, + ]); + + const finishDrawingPolygon = useCallback(() => { + const subtract = selectedToolSettings.useFogSubtract; + const data = { + ...drawingShape.data, + // Remove the last point as it hasn't been placed yet + points: drawingShape.data.points.slice(0, -1), + }; + if (subtract) { + onShapeSubtract({ + id: drawingShape.id, + type: drawingShape.type, + data: data, + }); + } else { + onShapeAdd({ ...drawingShape, data: data, color: "black" }); + } + + setDrawingShape(null); + }, [selectedToolSettings, drawingShape, onShapeSubtract, onShapeAdd]); + + // Add keyboard shortcuts + useEffect(() => { + function handleKeyDown({ key }) { + if ( + key === "Enter" && + selectedToolSettings.type === "polygon" && + drawingShape + ) { + finishDrawingPolygon(); + } + if (key === "Escape" && drawingShape) { + setDrawingShape(null); + } + if (key === "Alt" && drawingShape) { + updateShapeColor(); + } + } + + function handleKeyUp({ key }) { + if (key === "Alt" && drawingShape) { + updateShapeColor(); + } + } + + function updateShapeColor() { + setDrawingShape((prevShape) => { + if (!prevShape) { + return; + } + return { + ...prevShape, + color: selectedToolSettings.useFogSubtract ? "black" : "red", + }; + }); + } + + interactionEmitter.on("keyDown", handleKeyDown); + interactionEmitter.on("keyUp", handleKeyUp); + return () => { + interactionEmitter.off("keyDown", handleKeyDown); + interactionEmitter.off("keyUp", handleKeyUp); + }; + }, [ + finishDrawingPolygon, + interactionEmitter, + drawingShape, + selectedToolSettings, + ]); function handleShapeOver(shape, isDown) { if (shouldHover && isDown) { @@ -162,18 +322,23 @@ function MapFog({ } } + function reducePoints(acc, point) { + return [...acc, point.x * mapWidth, point.y * mapHeight]; + } + function renderShape(shape) { + const points = shape.data.points.reduce(reducePoints, []); + const holes = + shape.data.holes && + shape.data.holes.map((hole) => hole.reduce(reducePoints, [])); return ( - handleShapeOver(shape, isBrushDown)} onTouchOver={() => handleShapeOver(shape, isBrushDown)} onMouseDown={() => handleShapeOver(shape, true)} onTouchStart={() => handleShapeOver(shape, true)} - points={shape.data.points.reduce( - (acc, point) => [...acc, point.x * mapWidth, point.y * mapHeight], - [] - )} + points={points} stroke={colors[shape.color] || shape.color} fill={colors[shape.color] || shape.color} closed @@ -188,6 +353,7 @@ function MapFog({ opacity={isEditing ? 0.5 : 1} fillPatternImage={patternImage} fillPriority={isEditing && !shape.visible ? "pattern" : "color"} + holes={holes} /> ); } @@ -200,10 +366,37 @@ function MapFog({ return renderShape(editingShape); } + function renderPolygonAcceptTick(shape) { + if (shape.data.points.length === 0) { + return null; + } + const isCross = shape.data.points.length < 4; + return ( + { + e.cancelBubble = true; + if (isCross) { + setDrawingShape(null); + } else { + finishDrawingPolygon(); + } + }} + /> + ); + } + return ( {shapes.map(renderShape)} {drawingShape && renderShape(drawingShape)} + {drawingShape && + selectedToolSettings && + selectedToolSettings.type === "polygon" && + renderPolygonAcceptTick(drawingShape)} {editingShapes.length > 0 && editingShapes.map(renderEditingShape)} ); diff --git a/src/components/map/MapGrid.js b/src/components/map/MapGrid.js index 06df57a..c54bb51 100644 --- a/src/components/map/MapGrid.js +++ b/src/components/map/MapGrid.js @@ -71,7 +71,7 @@ function MapGrid({ map, gridSize }) { points={[x * lineSpacingX, 0, x * lineSpacingX, mapHeight]} stroke={isImageLight ? "black" : "white"} strokeWidth={getStrokeWidth(0.1, gridSize, mapWidth, mapHeight)} - opacity={0.8} + opacity={0.5} /> ); } @@ -82,7 +82,7 @@ function MapGrid({ map, gridSize }) { points={[0, y * lineSpacingY, mapWidth, y * lineSpacingY]} stroke={isImageLight ? "black" : "white"} strokeWidth={getStrokeWidth(0.1, gridSize, mapWidth, mapHeight)} - opacity={0.8} + opacity={0.5} /> ); } diff --git a/src/components/map/MapInteraction.js b/src/components/map/MapInteraction.js index 60e929a..f71b93e 100644 --- a/src/components/map/MapInteraction.js +++ b/src/components/map/MapInteraction.js @@ -4,6 +4,7 @@ import { useGesture } from "react-use-gesture"; import ReactResizeDetector from "react-resize-detector"; import useImage from "use-image"; import { Stage, Layer, Image } from "react-konva"; +import { EventEmitter } from "events"; import usePreventOverscroll from "../../helpers/usePreventOverscroll"; import useDataSource from "../../helpers/useDataSource"; @@ -11,7 +12,9 @@ import useDataSource from "../../helpers/useDataSource"; import { mapSources as defaultMapSources } from "../../maps"; import { MapInteractionProvider } from "../../contexts/MapInteractionContext"; -import MapStageContext from "../../contexts/MapStageContext"; +import MapStageContext, { + MapStageProvider, +} from "../../contexts/MapStageContext"; import AuthContext from "../../contexts/AuthContext"; const wheelZoomSpeed = -0.001; @@ -19,22 +22,26 @@ const touchZoomSpeed = 0.005; const minZoom = 0.1; const maxZoom = 5; -function MapInteraction({ map, children, controls, selectedToolId }) { +function MapInteraction({ + map, + children, + controls, + selectedToolId, + onSelectedToolChange, + disabledControls, +}) { const mapSource = useDataSource(map, defaultMapSources); const [mapSourceImage] = useImage(mapSource); const [stageWidth, setStageWidth] = useState(1); const [stageHeight, setStageHeight] = useState(1); const [stageScale, setStageScale] = useState(1); - // "none" | "first" | "dragging" | "last" - const [stageDragState, setStageDragState] = useState("none"); const [preventMapInteraction, setPreventMapInteraction] = useState(false); const stageWidthRef = useRef(stageWidth); const stageHeightRef = useRef(stageHeight); // Avoid state udpates when panning the map by using a ref and updating the konva element directly const stageTranslateRef = useRef({ x: 0, y: 0 }); - const mapDragPositionRef = useRef({ x: 0, y: 0 }); // Reset transform when map changes useEffect(() => { @@ -54,36 +61,20 @@ function MapInteraction({ map, children, controls, selectedToolId }) { } }, [map]); - // Convert a client space XY to be normalized to the map image - function getMapDragPosition(xy) { - const [x, y] = xy; - const container = containerRef.current; - const mapImage = mapImageRef.current; - if (container && mapImage) { - const containerRect = container.getBoundingClientRect(); - const mapRect = mapImage.getClientRect(); - - const offsetX = x - containerRect.left - mapRect.x; - const offsetY = y - containerRect.top - mapRect.y; - - const normalizedX = offsetX / mapRect.width; - const normalizedY = offsetY / mapRect.height; - - return { x: normalizedX, y: normalizedY }; - } - } - const pinchPreviousDistanceRef = useRef(); const pinchPreviousOriginRef = useRef(); - const isInteractingCanvas = useRef(false); + const isInteractingWithCanvas = useRef(false); + const previousSelectedToolRef = useRef(selectedToolId); + + const [interactionEmitter] = useState(new EventEmitter()); const bind = useGesture({ onWheelStart: ({ event }) => { - isInteractingCanvas.current = + isInteractingWithCanvas.current = event.target === mapLayerRef.current.getCanvas()._canvas; }, onWheel: ({ delta }) => { - if (preventMapInteraction || !isInteractingCanvas.current) { + if (preventMapInteraction || !isInteractingWithCanvas.current) { return; } const newScale = Math.min( @@ -92,6 +83,11 @@ function MapInteraction({ map, children, controls, selectedToolId }) { ); setStageScale(newScale); }, + onPinchStart: () => { + // Change to pan tool when pinching and zooming + previousSelectedToolRef.current = selectedToolId; + onSelectedToolChange("pan"); + }, onPinch: ({ da, origin, first }) => { const [distance] = da; const [originX, originY] = origin; @@ -125,12 +121,19 @@ function MapInteraction({ map, children, controls, selectedToolId }) { pinchPreviousDistanceRef.current = distance; pinchPreviousOriginRef.current = { x: originX, y: originY }; }, + onPinchEnd: () => { + onSelectedToolChange(previousSelectedToolRef.current); + }, onDragStart: ({ event }) => { - isInteractingCanvas.current = + isInteractingWithCanvas.current = event.target === mapLayerRef.current.getCanvas()._canvas; }, - onDrag: ({ delta, xy, first, last, pinching }) => { - if (preventMapInteraction || pinching || !isInteractingCanvas.current) { + onDrag: ({ delta, first, last, pinching }) => { + if ( + preventMapInteraction || + pinching || + !isInteractingWithCanvas.current + ) { return; } @@ -147,15 +150,14 @@ function MapInteraction({ map, children, controls, selectedToolId }) { layer.draw(); stageTranslateRef.current = newTranslate; } - mapDragPositionRef.current = getMapDragPosition(xy); - const newDragState = first ? "first" : last ? "last" : "dragging"; - if (stageDragState !== newDragState) { - setStageDragState(newDragState); + if (first) { + interactionEmitter.emit("dragStart"); + } else if (last) { + interactionEmitter.emit("dragEnd"); + } else { + interactionEmitter.emit("drag"); } }, - onDragEnd: () => { - setStageDragState("none"); - }, }); function handleResize(width, height) { @@ -165,13 +167,53 @@ function MapInteraction({ map, children, controls, selectedToolId }) { stageHeightRef.current = height; } + function handleKeyDown(event) { + // Change to pan tool when pressing space + if (event.key === " " && selectedToolId === "pan") { + // Stop active state on pan icon from being selected + event.preventDefault(); + } + if ( + event.key === " " && + selectedToolId !== "pan" && + !disabledControls.includes("pan") + ) { + event.preventDefault(); + previousSelectedToolRef.current = selectedToolId; + onSelectedToolChange("pan"); + } + + // Basic keyboard shortcuts + if (event.key === "w" && !disabledControls.includes("pan")) { + onSelectedToolChange("pan"); + } + if (event.key === "d" && !disabledControls.includes("drawing")) { + onSelectedToolChange("drawing"); + } + if (event.key === "f" && !disabledControls.includes("fog")) { + onSelectedToolChange("fog"); + } + if (event.key === "m" && !disabledControls.includes("measure")) { + onSelectedToolChange("measure"); + } + + interactionEmitter.emit("keyDown", event); + } + + function handleKeyUp(event) { + if (event.key === " " && selectedToolId === "pan") { + onSelectedToolChange(previousSelectedToolRef.current); + } + interactionEmitter.emit("keyUp", event); + } + function getCursorForTool(tool) { switch (tool) { case "pan": return "move"; case "fog": - case "brush": - case "shape": + case "drawing": + case "measure": return "crosshair"; default: return "default"; @@ -194,11 +236,10 @@ function MapInteraction({ map, children, controls, selectedToolId }) { stageScale, stageWidth, stageHeight, - stageDragState, setPreventMapInteraction, mapWidth, mapHeight, - mapDragPositionRef, + interactionEmitter, }; return ( @@ -208,10 +249,14 @@ function MapInteraction({ map, children, controls, selectedToolId }) { position: "relative", cursor: getCursorForTool(selectedToolId), touchAction: "none", + outline: "none", }} ref={containerRef} {...bind()} className="map" + tabIndex={1} + onKeyDown={handleKeyDown} + onKeyUp={handleKeyUp} > - {children} + + {children} + diff --git a/src/components/map/MapLoadingOverlay.js b/src/components/map/MapLoadingOverlay.js new file mode 100644 index 0000000..8374fc5 --- /dev/null +++ b/src/components/map/MapLoadingOverlay.js @@ -0,0 +1,63 @@ +import React, { useContext, useEffect, useRef } from "react"; +import { Box, Progress } from "theme-ui"; + +import Spinner from "../Spinner"; +import MapLoadingContext from "../../contexts/MapLoadingContext"; + +function MapLoadingOverlay() { + const { isLoading, loadingProgressRef } = useContext(MapLoadingContext); + + const requestRef = useRef(); + const progressBarRef = useRef(); + + // Use an animation frame to update the progress bar + // This bypasses react allowing the animation to be smooth + useEffect(() => { + function animate() { + if (!isLoading) { + return; + } + requestRef.current = requestAnimationFrame(animate); + if (progressBarRef.current) { + progressBarRef.current.value = loadingProgressRef.current; + } + } + + requestRef.current = requestAnimationFrame(animate); + + return () => { + cancelAnimationFrame(requestRef.current); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoading]); + + return ( + isLoading && ( + + + + + ) + ); +} + +export default MapLoadingOverlay; diff --git a/src/components/map/MapMeasure.js b/src/components/map/MapMeasure.js new file mode 100644 index 0000000..5ca4678 --- /dev/null +++ b/src/components/map/MapMeasure.js @@ -0,0 +1,142 @@ +import React, { useContext, useState, useEffect } from "react"; +import { Group, Line, Text, Label, Tag } from "react-konva"; + +import MapInteractionContext from "../../contexts/MapInteractionContext"; +import MapStageContext from "../../contexts/MapStageContext"; + +import { + getBrushPositionForTool, + getDefaultShapeData, + getUpdatedShapeData, + getStrokeWidth, +} from "../../helpers/drawing"; +import { getRelativePointerPositionNormalized } from "../../helpers/konva"; +import * as Vector2 from "../../helpers/vector2"; + +function MapMeasure({ selectedToolSettings, active, gridSize }) { + const { stageScale, mapWidth, mapHeight, interactionEmitter } = useContext( + MapInteractionContext + ); + const mapStageRef = useContext(MapStageContext); + const [drawingShapeData, setDrawingShapeData] = useState(null); + const [isBrushDown, setIsBrushDown] = useState(false); + + useEffect(() => { + if (!active) { + return; + } + const mapStage = mapStageRef.current; + + function getBrushPosition() { + const mapImage = mapStage.findOne("#mapImage"); + return getBrushPositionForTool( + getRelativePointerPositionNormalized(mapImage), + "drawing", + { type: "line" }, + gridSize, + [] + ); + } + + function handleBrushDown() { + const brushPosition = getBrushPosition(); + const { points } = getDefaultShapeData("line", brushPosition); + const length = 0; + setDrawingShapeData({ length, points }); + setIsBrushDown(true); + } + + function handleBrushMove() { + const brushPosition = getBrushPosition(); + if (isBrushDown && drawingShapeData) { + const { points } = getUpdatedShapeData( + "line", + drawingShapeData, + brushPosition, + gridSize + ); + const length = Vector2.distance( + Vector2.divide(points[0], gridSize), + Vector2.divide(points[1], gridSize), + selectedToolSettings.type + ); + setDrawingShapeData({ + length, + points, + }); + } + } + + function handleBrushUp() { + setDrawingShapeData(null); + setIsBrushDown(false); + } + + interactionEmitter.on("dragStart", handleBrushDown); + interactionEmitter.on("drag", handleBrushMove); + interactionEmitter.on("dragEnd", handleBrushUp); + + return () => { + interactionEmitter.off("dragStart", handleBrushDown); + interactionEmitter.off("drag", handleBrushMove); + interactionEmitter.off("dragEnd", handleBrushUp); + }; + }, [ + drawingShapeData, + gridSize, + isBrushDown, + mapStageRef, + interactionEmitter, + active, + selectedToolSettings, + ]); + + function renderShape(shapeData) { + const linePoints = shapeData.points.reduce( + (acc, point) => [...acc, point.x * mapWidth, point.y * mapHeight], + [] + ); + + const lineCenter = Vector2.multiply( + Vector2.divide(Vector2.add(shapeData.points[0], shapeData.points[1]), 2), + { x: mapWidth, y: mapHeight } + ); + + return ( + + + + + + ); + } + + return {drawingShapeData && renderShape(drawingShapeData)}; +} + +export default MapMeasure; diff --git a/src/components/map/MapMenu.js b/src/components/map/MapMenu.js index 2c79ddb..5fb8782 100644 --- a/src/components/map/MapMenu.js +++ b/src/components/map/MapMenu.js @@ -79,7 +79,13 @@ function MapMenu({ }} contentRef={handleModalContent} > - {children} + {/* Stop keyboard events when modal is open to prevent shortcuts from triggering */} +
e.stopPropagation()} + onKeyUp={(e) => e.stopPropagation()} + > + {children} +
); } diff --git a/src/components/map/MapSettings.js b/src/components/map/MapSettings.js index 1861bd4..bfabeec 100644 --- a/src/components/map/MapSettings.js +++ b/src/components/map/MapSettings.js @@ -3,6 +3,8 @@ import { Flex, Box, Label, Input, Checkbox, IconButton } from "theme-ui"; import ExpandMoreIcon from "../../icons/ExpandMoreIcon"; +import { isEmpty } from "../../helpers/shared"; + import Divider from "../Divider"; function MapSettings({ @@ -24,6 +26,9 @@ function MapSettings({ } } + const mapEmpty = !map || isEmpty(map); + const mapStateEmpty = !mapState || isEmpty(mapState); + return ( @@ -36,7 +41,7 @@ function MapSettings({ onChange={(e) => onSettingsChange("gridX", parseInt(e.target.value)) } - disabled={!map || map.type === "default"} + disabled={mapEmpty || map.type === "default"} min={1} my={1} /> @@ -50,7 +55,7 @@ function MapSettings({ onChange={(e) => onSettingsChange("gridY", parseInt(e.target.value)) } - disabled={!map || map.type === "default"} + disabled={mapEmpty || map.type === "default"} min={1} my={1} /> @@ -64,7 +69,7 @@ function MapSettings({ name="name" value={(map && map.name) || ""} onChange={(e) => onSettingsChange("name", e.target.value)} - disabled={!map || map.type === "default"} + disabled={mapEmpty || map.type === "default"} my={1} /> @@ -72,7 +77,7 @@ function MapSettings({ {databaseStatus === "disabled" && ( diff --git a/src/components/map/MapToken.js b/src/components/map/MapToken.js index f55dd4e..afaf796 100644 --- a/src/components/map/MapToken.js +++ b/src/components/map/MapToken.js @@ -140,7 +140,7 @@ function MapToken({ } function handlePointerOut() { - if (!draggable) { + if (tokenOpacity !== 1.0) { setTokenOpacity(1.0); } } diff --git a/src/components/map/SelectMapButton.js b/src/components/map/SelectMapButton.js index fb8c81f..799a1dd 100644 --- a/src/components/map/SelectMapButton.js +++ b/src/components/map/SelectMapButton.js @@ -19,12 +19,9 @@ function SelectMapButton({ currentMapState && updateMapState(currentMapState.mapId, currentMapState); setIsModalOpen(true); } - function closeModal() { - setIsModalOpen(false); - } function handleDone() { - closeModal(); + setIsModalOpen(false); } return ( @@ -38,7 +35,6 @@ function SelectMapButton({ - onSettingChange({ color })} - /> - - onSettingChange({ type: "stroke" })} - isSelected={settings.type === "stroke"} - > - - - onSettingChange({ type: "fill" })} - isSelected={settings.type === "fill"} - > - - - - onSettingChange({ useBlending })} - /> - - onToolAction("mapUndo")} - disabled={disabledActions.includes("undo")} - /> - onToolAction("mapRedo")} - disabled={disabledActions.includes("redo")} - /> - - ); -} - -export default BrushToolSettings; diff --git a/src/components/map/controls/DrawingToolSettings.js b/src/components/map/controls/DrawingToolSettings.js new file mode 100644 index 0000000..9f0bb6f --- /dev/null +++ b/src/components/map/controls/DrawingToolSettings.js @@ -0,0 +1,171 @@ +import React, { useEffect, useContext } from "react"; +import { Flex, IconButton } from "theme-ui"; +import { useMedia } from "react-media"; + +import ColorControl from "./ColorControl"; +import AlphaBlendToggle from "./AlphaBlendToggle"; +import RadioIconButton from "./RadioIconButton"; +import ToolSection from "./ToolSection"; + +import BrushIcon from "../../../icons/BrushToolIcon"; +import BrushPaintIcon from "../../../icons/BrushPaintIcon"; +import BrushLineIcon from "../../../icons/BrushLineIcon"; +import BrushRectangleIcon from "../../../icons/BrushRectangleIcon"; +import BrushCircleIcon from "../../../icons/BrushCircleIcon"; +import BrushTriangleIcon from "../../../icons/BrushTriangleIcon"; +import EraseAllIcon from "../../../icons/EraseAllIcon"; +import EraseIcon from "../../../icons/EraseToolIcon"; + +import UndoButton from "./UndoButton"; +import RedoButton from "./RedoButton"; + +import Divider from "../../Divider"; + +import MapInteractionContext from "../../../contexts/MapInteractionContext"; + +function DrawingToolSettings({ + settings, + onSettingChange, + onToolAction, + disabledActions, +}) { + const { interactionEmitter } = useContext(MapInteractionContext); + + // Keyboard shotcuts + useEffect(() => { + function handleKeyDown({ key, ctrlKey, metaKey, shiftKey }) { + if (key === "b") { + onSettingChange({ type: "brush" }); + } else if (key === "p") { + onSettingChange({ type: "paint" }); + } else if (key === "l") { + onSettingChange({ type: "line" }); + } else if (key === "r") { + onSettingChange({ type: "rectangle" }); + } else if (key === "c") { + onSettingChange({ type: "circle" }); + } else if (key === "t") { + onSettingChange({ type: "triangle" }); + } else if (key === "e") { + onSettingChange({ type: "erase" }); + } else if (key === "o") { + onSettingChange({ useBlending: !settings.useBlending }); + } else if ( + (key === "z" || key === "Z") && + (ctrlKey || metaKey) && + shiftKey && + !disabledActions.includes("redo") + ) { + onToolAction("mapRedo"); + } else if ( + key === "z" && + (ctrlKey || metaKey) && + !shiftKey && + !disabledActions.includes("undo") + ) { + onToolAction("mapUndo"); + } + } + + interactionEmitter.on("keyDown", handleKeyDown); + return () => { + interactionEmitter.off("keyDown", handleKeyDown); + }; + }); + + // Change to brush if on erase and it gets disabled + useEffect(() => { + if (settings.type === "erase" && disabledActions.includes("erase")) { + onSettingChange({ type: "brush" }); + } + }, [disabledActions, settings, onSettingChange]); + + const isSmallScreen = useMedia({ query: "(max-width: 799px)" }); + + const tools = [ + { + id: "brush", + title: "Brush", + isSelected: settings.type === "brush", + icon: , + }, + { + id: "paint", + title: "Paint", + isSelected: settings.type === "paint", + icon: , + }, + { + id: "line", + title: "Line", + isSelected: settings.type === "line", + icon: , + }, + { + id: "rectangle", + title: "Rectangle", + isSelected: settings.type === "rectangle", + icon: , + }, + { + id: "circle", + title: "Circle", + isSelected: settings.type === "circle", + icon: , + }, + { + id: "triangle", + title: "Triangle", + isSelected: settings.type === "triangle", + icon: , + }, + ]; + + return ( + + onSettingChange({ color })} + /> + + onSettingChange({ type: tool.id })} + collapse={isSmallScreen} + /> + + onSettingChange({ type: "erase" })} + isSelected={settings.type === "erase"} + disabled={disabledActions.includes("erase")} + > + + + onToolAction("eraseAll")} + disabled={disabledActions.includes("erase")} + > + + + + onSettingChange({ useBlending })} + /> + + onToolAction("mapUndo")} + disabled={disabledActions.includes("undo")} + /> + onToolAction("mapRedo")} + disabled={disabledActions.includes("redo")} + /> + + ); +} + +export default DrawingToolSettings; diff --git a/src/components/map/controls/EraseToolSettings.js b/src/components/map/controls/EraseToolSettings.js deleted file mode 100644 index c41b117..0000000 --- a/src/components/map/controls/EraseToolSettings.js +++ /dev/null @@ -1,34 +0,0 @@ -import React from "react"; -import { Flex, IconButton } from "theme-ui"; - -import EraseAllIcon from "../../../icons/EraseAllIcon"; - -import UndoButton from "./UndoButton"; -import RedoButton from "./RedoButton"; - -import Divider from "../../Divider"; - -function EraseToolSettings({ onToolAction, disabledActions }) { - return ( - - onToolAction("eraseAll")} - > - - - - onToolAction("mapUndo")} - disabled={disabledActions.includes("undo")} - /> - onToolAction("mapRedo")} - disabled={disabledActions.includes("redo")} - /> - - ); -} - -export default EraseToolSettings; diff --git a/src/components/map/controls/FogToolSettings.js b/src/components/map/controls/FogToolSettings.js index 96e22e2..f94b386 100644 --- a/src/components/map/controls/FogToolSettings.js +++ b/src/components/map/controls/FogToolSettings.js @@ -1,33 +1,124 @@ -import React from "react"; +import React, { useContext, useEffect } from "react"; import { Flex } from "theme-ui"; +import { useMedia } from "react-media"; import EdgeSnappingToggle from "./EdgeSnappingToggle"; import RadioIconButton from "./RadioIconButton"; -import GridSnappingToggle from "./GridSnappingToggle"; -import FogAddIcon from "../../../icons/FogAddIcon"; +import FogBrushIcon from "../../../icons/FogBrushIcon"; +import FogPolygonIcon from "../../../icons/FogPolygonIcon"; import FogRemoveIcon from "../../../icons/FogRemoveIcon"; import FogToggleIcon from "../../../icons/FogToggleIcon"; +import FogAddIcon from "../../../icons/FogAddIcon"; +import FogSubtractIcon from "../../../icons/FogSubtractIcon"; import UndoButton from "./UndoButton"; import RedoButton from "./RedoButton"; import Divider from "../../Divider"; +import MapInteractionContext from "../../../contexts/MapInteractionContext"; +import ToolSection from "./ToolSection"; + function BrushToolSettings({ settings, onSettingChange, onToolAction, disabledActions, }) { + const { interactionEmitter } = useContext(MapInteractionContext); + + // Keyboard shortcuts + useEffect(() => { + function handleKeyDown({ key, ctrlKey, metaKey, shiftKey }) { + if (key === "Alt") { + onSettingChange({ useFogSubtract: !settings.useFogSubtract }); + } else if (key === "p") { + onSettingChange({ type: "polygon" }); + } else if (key === "b") { + onSettingChange({ type: "brush" }); + } else if (key === "t") { + onSettingChange({ type: "toggle" }); + } else if (key === "r") { + onSettingChange({ type: "remove" }); + } else if (key === "s") { + onSettingChange({ useEdgeSnapping: !settings.useEdgeSnapping }); + } else if ( + (key === "z" || key === "Z") && + (ctrlKey || metaKey) && + shiftKey && + !disabledActions.includes("redo") + ) { + onToolAction("fogRedo"); + } else if ( + key === "z" && + (ctrlKey || metaKey) && + !shiftKey && + !disabledActions.includes("undo") + ) { + onToolAction("fogUndo"); + } + } + + function handleKeyUp({ key }) { + if (key === "Alt") { + onSettingChange({ useFogSubtract: !settings.useFogSubtract }); + } + } + + interactionEmitter.on("keyDown", handleKeyDown); + interactionEmitter.on("keyUp", handleKeyUp); + return () => { + interactionEmitter.off("keyDown", handleKeyDown); + interactionEmitter.off("keyUp", handleKeyUp); + }; + }); + + const isSmallScreen = useMedia({ query: "(max-width: 799px)" }); + const drawTools = [ + { + id: "polygon", + title: "Fog Polygon", + isSelected: settings.type === "polygon", + icon: , + }, + { + id: "brush", + title: "Fog Brush", + isSelected: settings.type === "brush", + icon: , + }, + ]; + + const modeTools = [ + { + id: "add", + title: "Add Fog", + isSelected: !settings.useFogSubtract, + icon: , + }, + { + id: "subtract", + title: "Subtract Fog", + isSelected: settings.useFogSubtract, + icon: , + }, + ]; + return ( + onSettingChange({ type: tool.id })} + collapse={isSmallScreen} + /> + onSettingChange({ type: "add" })} - isSelected={settings.type === "add"} + title="Toggle Fog" + onClick={() => onSettingChange({ type: "toggle" })} + isSelected={settings.type === "toggle"} > - + - onSettingChange({ type: "toggle" })} - isSelected={settings.type === "toggle"} - > - - + + + onSettingChange({ useFogSubtract: tool.id === "subtract" }) + } + collapse={isSmallScreen} + /> - - onSettingChange({ useGridSnapping }) - } - /> onToolAction("fogUndo")} diff --git a/src/components/map/controls/GridSnappingToggle.js b/src/components/map/controls/GridSnappingToggle.js deleted file mode 100644 index b09232a..0000000 --- a/src/components/map/controls/GridSnappingToggle.js +++ /dev/null @@ -1,21 +0,0 @@ -import React from "react"; -import { IconButton } from "theme-ui"; - -import GridOnIcon from "../../../icons/GridOnIcon"; -import GridOffIcon from "../../../icons/GridOffIcon"; - -function GridSnappingToggle({ useGridSnapping, onGridSnappingChange }) { - return ( - onGridSnappingChange(!useGridSnapping)} - > - {useGridSnapping ? : } - - ); -} - -export default GridSnappingToggle; diff --git a/src/components/map/controls/MeasureToolSettings.js b/src/components/map/controls/MeasureToolSettings.js new file mode 100644 index 0000000..d29f447 --- /dev/null +++ b/src/components/map/controls/MeasureToolSettings.js @@ -0,0 +1,65 @@ +import React, { useEffect, useContext } from "react"; +import { Flex } from "theme-ui"; + +import ToolSection from "./ToolSection"; +import MeasureChebyshevIcon from "../../../icons/MeasureChebyshevIcon"; +import MeasureEuclideanIcon from "../../../icons/MeasureEuclideanIcon"; +import MeasureManhattanIcon from "../../../icons/MeasureManhattanIcon"; + +import MapInteractionContext from "../../../contexts/MapInteractionContext"; + +function MeasureToolSettings({ settings, onSettingChange }) { + const { interactionEmitter } = useContext(MapInteractionContext); + + // Keyboard shortcuts + useEffect(() => { + function handleKeyDown({ key }) { + if (key === "g") { + onSettingChange({ type: "chebyshev" }); + } else if (key === "l") { + onSettingChange({ type: "euclidean" }); + } else if (key === "c") { + onSettingChange({ type: "manhattan" }); + } + } + interactionEmitter.on("keyDown", handleKeyDown); + + return () => { + interactionEmitter.off("keyDown", handleKeyDown); + }; + }); + + const tools = [ + { + id: "chebyshev", + title: "Grid Distance", + isSelected: settings.type === "chebyshev", + icon: , + }, + { + id: "euclidean", + title: "Line Distance", + isSelected: settings.type === "euclidean", + icon: , + }, + { + id: "manhattan", + title: "City Block Distance", + isSelected: settings.type === "manhattan", + icon: , + }, + ]; + + // TODO Add keyboard shortcuts + + return ( + + onSettingChange({ type: tool.id })} + /> + + ); +} + +export default MeasureToolSettings; diff --git a/src/components/map/controls/ShapeToolSettings.js b/src/components/map/controls/ShapeToolSettings.js deleted file mode 100644 index cd388b7..0000000 --- a/src/components/map/controls/ShapeToolSettings.js +++ /dev/null @@ -1,69 +0,0 @@ -import React from "react"; -import { Flex } from "theme-ui"; - -import ColorControl from "./ColorControl"; -import AlphaBlendToggle from "./AlphaBlendToggle"; -import RadioIconButton from "./RadioIconButton"; - -import ShapeRectangleIcon from "../../../icons/ShapeRectangleIcon"; -import ShapeCircleIcon from "../../../icons/ShapeCircleIcon"; -import ShapeTriangleIcon from "../../../icons/ShapeTriangleIcon"; - -import UndoButton from "./UndoButton"; -import RedoButton from "./RedoButton"; - -import Divider from "../../Divider"; - -function ShapeToolSettings({ - settings, - onSettingChange, - onToolAction, - disabledActions, -}) { - return ( - - onSettingChange({ color })} - /> - - onSettingChange({ type: "rectangle" })} - isSelected={settings.type === "rectangle"} - > - - - onSettingChange({ type: "circle" })} - isSelected={settings.type === "circle"} - > - - - onSettingChange({ type: "triangle" })} - isSelected={settings.type === "triangle"} - > - - - - onSettingChange({ useBlending })} - /> - - onToolAction("mapUndo")} - disabled={disabledActions.includes("undo")} - /> - onToolAction("mapRedo")} - disabled={disabledActions.includes("redo")} - /> - - ); -} - -export default ShapeToolSettings; diff --git a/src/components/map/controls/ToolSection.js b/src/components/map/controls/ToolSection.js new file mode 100644 index 0000000..30c24cb --- /dev/null +++ b/src/components/map/controls/ToolSection.js @@ -0,0 +1,103 @@ +import React, { useState, useEffect } from "react"; +import { Box, Flex } from "theme-ui"; + +import RadioIconButton from "./RadioIconButton"; + +// Section of map tools with the option to collapse into a vertical list +function ToolSection({ collapse, tools, onToolClick }) { + const [showMore, setShowMore] = useState(false); + const [collapsedTool, setCollapsedTool] = useState(); + + useEffect(() => { + const selectedTool = tools.find((tool) => tool.isSelected); + if (selectedTool) { + setCollapsedTool(selectedTool); + } else { + setCollapsedTool( + (prevTool) => prevTool && { ...prevTool, isSelected: false } + ); + } + }, [tools]); + + function handleToolClick(tool) { + if (collapse && tool.isSelected) { + setShowMore(!showMore); + } else if (collapse && !tool.isSelected) { + setShowMore(false); + } + onToolClick(tool); + } + + function renderTool(tool) { + return ( + handleToolClick(tool)} + key={tool.id} + isSelected={tool.isSelected} + > + {tool.icon} + + ); + } + + if (collapse) { + if (!collapsedTool) { + return null; + } + return ( + + {renderTool(collapsedTool)} + {/* Render chevron when more tools is available */} + + {showMore && ( + + {tools.filter((tool) => !tool.isSelected).map(renderTool)} + + )} + + ); + } else { + return tools.map((tool) => ( + handleToolClick(tool)} + key={tool.id} + isSelected={tool.isSelected} + > + {tool.icon} + + )); + } +} + +ToolSection.defaultProps = { + collapse: false, +}; + +export default ToolSection; diff --git a/src/components/token/TokenSettings.js b/src/components/token/TokenSettings.js index b01103a..92d2f20 100644 --- a/src/components/token/TokenSettings.js +++ b/src/components/token/TokenSettings.js @@ -2,6 +2,7 @@ import React from "react"; import { Flex, Box, Input, IconButton, Label, Checkbox } from "theme-ui"; import ExpandMoreIcon from "../../icons/ExpandMoreIcon"; +import { isEmpty } from "../../helpers/shared"; function TokenSettings({ token, @@ -9,6 +10,7 @@ function TokenSettings({ showMore, onShowMoreChange, }) { + const tokenEmpty = !token || isEmpty(token); return ( @@ -21,7 +23,7 @@ function TokenSettings({ onChange={(e) => onSettingsChange("defaultSize", parseInt(e.target.value)) } - disabled={!token || token.type === "default"} + disabled={tokenEmpty || token.type === "default"} min={1} my={1} /> @@ -35,7 +37,7 @@ function TokenSettings({ name="name" value={(token && token.name) || ""} onChange={(e) => onSettingsChange("name", e.target.value)} - disabled={!token || token.type === "default"} + disabled={tokenEmpty || token.type === "default"} my={1} /> @@ -44,7 +46,7 @@ function TokenSettings({