Refactored component folder structure to be a little clearer
This commit is contained in:
82
src/components/map/AddMapButton.js
Normal file
82
src/components/map/AddMapButton.js
Normal file
@@ -0,0 +1,82 @@
|
||||
import React, { useRef, useState, useEffect } from "react";
|
||||
import { IconButton } from "theme-ui";
|
||||
|
||||
import AddMapModal from "../../modals/AddMapModal";
|
||||
import AddMapIcon from "../../icons/AddMapIcon";
|
||||
|
||||
const defaultMapSize = 22;
|
||||
|
||||
function AddMapButton({ onMapChange }) {
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
function openModal() {
|
||||
setIsAddModalOpen(true);
|
||||
}
|
||||
function closeModal() {
|
||||
setIsAddModalOpen(false);
|
||||
}
|
||||
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
|
||||
const mapDataRef = useRef(null);
|
||||
const [mapSource, setMapSource] = useState(null);
|
||||
function handleImageUpload(file, fileGridX, fileGridY) {
|
||||
const url = URL.createObjectURL(file);
|
||||
let image = new Image();
|
||||
image.onload = function () {
|
||||
mapDataRef.current = {
|
||||
file,
|
||||
gridX: fileGridX || gridX,
|
||||
gridY: fileGridY || gridY,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
};
|
||||
setImageLoaded(true);
|
||||
};
|
||||
image.src = url;
|
||||
setMapSource(url);
|
||||
if (fileGridX) {
|
||||
setGridX(fileGridX);
|
||||
}
|
||||
if (fileGridY) {
|
||||
setGridY(fileGridY);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDone() {
|
||||
if (mapDataRef.current && mapSource) {
|
||||
onMapChange(mapDataRef.current, mapSource);
|
||||
}
|
||||
closeModal();
|
||||
}
|
||||
|
||||
const [gridX, setGridX] = useState(defaultMapSize);
|
||||
const [gridY, setGridY] = useState(defaultMapSize);
|
||||
useEffect(() => {
|
||||
if (mapDataRef.current) {
|
||||
mapDataRef.current.gridX = gridX;
|
||||
mapDataRef.current.gridY = gridY;
|
||||
}
|
||||
}, [gridX, gridY]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton aria-label="Add Map" title="Add Map" onClick={openModal}>
|
||||
<AddMapIcon />
|
||||
</IconButton>
|
||||
<AddMapModal
|
||||
isOpen={isAddModalOpen}
|
||||
onRequestClose={closeModal}
|
||||
onDone={handleDone}
|
||||
onImageUpload={handleImageUpload}
|
||||
gridX={gridX}
|
||||
onGridXChange={setGridX}
|
||||
gridY={gridY}
|
||||
onGridYChange={setGridY}
|
||||
imageLoaded={imageLoaded}
|
||||
mapSource={mapSource}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddMapButton;
|
||||
328
src/components/map/Map.js
Normal file
328
src/components/map/Map.js
Normal file
@@ -0,0 +1,328 @@
|
||||
import React, { useRef, useEffect, useState } from "react";
|
||||
import { Box, Image } from "theme-ui";
|
||||
import interact from "interactjs";
|
||||
|
||||
import ProxyToken from "../token/ProxyToken";
|
||||
import TokenMenu from "../token/TokenMenu";
|
||||
import MapToken from "./MapToken";
|
||||
import MapDrawing from "./MapDrawing";
|
||||
import MapControls from "./MapControls";
|
||||
|
||||
import { omit } from "../../helpers/shared";
|
||||
|
||||
const mapTokenProxyClassName = "map-token__proxy";
|
||||
const mapTokenMenuClassName = "map-token__menu";
|
||||
const zoomSpeed = -0.005;
|
||||
const minZoom = 0.1;
|
||||
const maxZoom = 5;
|
||||
|
||||
function Map({
|
||||
mapSource,
|
||||
mapData,
|
||||
tokens,
|
||||
onMapTokenChange,
|
||||
onMapTokenRemove,
|
||||
onMapChange,
|
||||
onMapDraw,
|
||||
onMapDrawUndo,
|
||||
onMapDrawRedo,
|
||||
drawActions,
|
||||
drawActionIndex,
|
||||
}) {
|
||||
function handleProxyDragEnd(isOnMap, token) {
|
||||
if (isOnMap && onMapTokenChange) {
|
||||
onMapTokenChange(token);
|
||||
}
|
||||
|
||||
if (!isOnMap && onMapTokenRemove) {
|
||||
onMapTokenRemove(token);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map drawing
|
||||
*/
|
||||
|
||||
const [selectedTool, setSelectedTool] = useState("pan");
|
||||
const [brushColor, setBrushColor] = useState("black");
|
||||
const [useBrushGridSnapping, setUseBrushGridSnapping] = useState(false);
|
||||
const [useBrushBlending, setUseBrushBlending] = useState(false);
|
||||
const [useBrushGesture, setUseBrushGesture] = useState(false);
|
||||
|
||||
const [drawnShapes, setDrawnShapes] = useState([]);
|
||||
function handleShapeAdd(shape) {
|
||||
onMapDraw({ type: "add", shapes: [shape] });
|
||||
}
|
||||
|
||||
function handleShapeRemove(shapeId) {
|
||||
onMapDraw({ type: "remove", shapeIds: [shapeId] });
|
||||
}
|
||||
|
||||
function handleShapeRemoveAll() {
|
||||
onMapDraw({ type: "remove", shapeIds: drawnShapes.map((s) => s.id) });
|
||||
}
|
||||
|
||||
// Replay the draw actions and convert them to shapes for the map drawing
|
||||
useEffect(() => {
|
||||
let shapesById = {};
|
||||
for (let i = 0; i <= drawActionIndex; i++) {
|
||||
const action = drawActions[i];
|
||||
if (action.type === "add") {
|
||||
for (let shape of action.shapes) {
|
||||
shapesById[shape.id] = shape;
|
||||
}
|
||||
}
|
||||
if (action.type === "remove") {
|
||||
shapesById = omit(shapesById, action.shapeIds);
|
||||
}
|
||||
}
|
||||
setDrawnShapes(Object.values(shapesById));
|
||||
}, [drawActions, drawActionIndex]);
|
||||
|
||||
const disabledTools = [];
|
||||
if (!mapData) {
|
||||
disabledTools.push("pan");
|
||||
disabledTools.push("brush");
|
||||
}
|
||||
if (drawnShapes.length === 0) {
|
||||
disabledTools.push("erase");
|
||||
}
|
||||
|
||||
/**
|
||||
* Map movement
|
||||
*/
|
||||
|
||||
const mapTranslateRef = useRef({ x: 0, y: 0 });
|
||||
const mapScaleRef = useRef(1);
|
||||
const mapMoveContainerRef = useRef();
|
||||
function setTranslateAndScale(newTranslate, newScale) {
|
||||
const moveContainer = mapMoveContainerRef.current;
|
||||
moveContainer.style.transform = `translate(${newTranslate.x}px, ${newTranslate.y}px) scale(${newScale})`;
|
||||
mapScaleRef.current = newScale;
|
||||
mapTranslateRef.current = newTranslate;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function handleMove(event, isGesture) {
|
||||
const scale = mapScaleRef.current;
|
||||
const translate = mapTranslateRef.current;
|
||||
|
||||
let newScale = scale;
|
||||
let newTranslate = translate;
|
||||
|
||||
if (isGesture) {
|
||||
newScale = Math.max(Math.min(scale + event.ds, maxZoom), minZoom);
|
||||
}
|
||||
|
||||
if (selectedTool === "pan" || isGesture) {
|
||||
newTranslate = {
|
||||
x: translate.x + event.dx,
|
||||
y: translate.y + event.dy,
|
||||
};
|
||||
}
|
||||
setTranslateAndScale(newTranslate, newScale);
|
||||
}
|
||||
const mapInteract = interact(".map")
|
||||
.gesturable({
|
||||
listeners: {
|
||||
move: (e) => handleMove(e, true),
|
||||
},
|
||||
})
|
||||
.draggable({
|
||||
inertia: true,
|
||||
listeners: {
|
||||
move: (e) => handleMove(e, false),
|
||||
},
|
||||
cursorChecker: () => {
|
||||
return selectedTool === "pan" && mapData ? "move" : "default";
|
||||
},
|
||||
})
|
||||
.on("doubletap", (event) => {
|
||||
event.preventDefault();
|
||||
if (selectedTool === "pan") {
|
||||
setTranslateAndScale({ x: 0, y: 0 }, 1);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mapInteract.unset();
|
||||
};
|
||||
}, [selectedTool, mapData]);
|
||||
|
||||
// Reset map transform when map changes
|
||||
useEffect(() => {
|
||||
setTranslateAndScale({ x: 0, y: 0 }, 1);
|
||||
}, [mapSource]);
|
||||
|
||||
// Bind the wheel event of the map via a ref
|
||||
// in order to support non-passive event listening
|
||||
// to allow the track pad zoom to be interrupted
|
||||
// see https://github.com/facebook/react/issues/14856
|
||||
useEffect(() => {
|
||||
const mapContainer = mapContainerRef.current;
|
||||
|
||||
function handleZoom(event) {
|
||||
// Stop overscroll on chrome and safari
|
||||
// also stop pinch to zoom on chrome
|
||||
event.preventDefault();
|
||||
|
||||
const scale = mapScaleRef.current;
|
||||
const translate = mapTranslateRef.current;
|
||||
|
||||
const deltaY = event.deltaY * zoomSpeed;
|
||||
const newScale = Math.max(Math.min(scale + deltaY, maxZoom), minZoom);
|
||||
|
||||
setTranslateAndScale(translate, newScale);
|
||||
}
|
||||
|
||||
if (mapContainer) {
|
||||
mapContainer.addEventListener("wheel", handleZoom, {
|
||||
passive: false,
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (mapContainer) {
|
||||
mapContainer.removeEventListener("wheel", handleZoom);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Member setup
|
||||
*/
|
||||
|
||||
const mapRef = useRef(null);
|
||||
const mapContainerRef = useRef();
|
||||
const gridX = mapData && mapData.gridX;
|
||||
const gridY = mapData && mapData.gridY;
|
||||
const gridSizeNormalized = { x: 1 / gridX || 0, y: 1 / gridY || 0 };
|
||||
const tokenSizePercent = gridSizeNormalized.x * 100;
|
||||
const aspectRatio = (mapData && mapData.width / mapData.height) || 1;
|
||||
|
||||
const mapImage = (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
ref={mapRef}
|
||||
className="mapImage"
|
||||
sx={{
|
||||
width: "100%",
|
||||
userSelect: "none",
|
||||
touchAction: "none",
|
||||
}}
|
||||
src={mapSource}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const mapTokens = (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{Object.values(tokens).map((token) => (
|
||||
<MapToken
|
||||
key={token.id}
|
||||
token={token}
|
||||
tokenSizePercent={tokenSizePercent}
|
||||
className={`${mapTokenProxyClassName} ${mapTokenMenuClassName}`}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
className="map"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.1)",
|
||||
userSelect: "none",
|
||||
touchAction: "none",
|
||||
}}
|
||||
bg="background"
|
||||
ref={mapContainerRef}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: "relative",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
}}
|
||||
>
|
||||
<Box ref={mapMoveContainerRef}>
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: 0,
|
||||
paddingBottom: `${(1 / aspectRatio) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
{mapImage}
|
||||
<MapDrawing
|
||||
width={mapData ? mapData.width : 0}
|
||||
height={mapData ? mapData.height : 0}
|
||||
selectedTool={selectedTool}
|
||||
shapes={drawnShapes}
|
||||
onShapeAdd={handleShapeAdd}
|
||||
onShapeRemove={handleShapeRemove}
|
||||
brushColor={brushColor}
|
||||
useGridSnapping={useBrushGridSnapping}
|
||||
gridSize={gridSizeNormalized}
|
||||
useBrushBlending={useBrushBlending}
|
||||
useBrushGesture={useBrushGesture}
|
||||
/>
|
||||
{mapTokens}
|
||||
</Box>
|
||||
</Box>
|
||||
<MapControls
|
||||
onMapChange={onMapChange}
|
||||
onToolChange={setSelectedTool}
|
||||
selectedTool={selectedTool}
|
||||
disabledTools={disabledTools}
|
||||
onUndo={onMapDrawUndo}
|
||||
onRedo={onMapDrawRedo}
|
||||
undoDisabled={drawActionIndex < 0}
|
||||
redoDisabled={drawActionIndex === drawActions.length - 1}
|
||||
brushColor={brushColor}
|
||||
onBrushColorChange={setBrushColor}
|
||||
onEraseAll={handleShapeRemoveAll}
|
||||
useBrushGridSnapping={useBrushGridSnapping}
|
||||
onBrushGridSnappingChange={setUseBrushGridSnapping}
|
||||
useBrushBlending={useBrushBlending}
|
||||
onBrushBlendingChange={setUseBrushBlending}
|
||||
useBrushGesture={useBrushGesture}
|
||||
onBrushGestureChange={setUseBrushGesture}
|
||||
/>
|
||||
</Box>
|
||||
<ProxyToken
|
||||
tokenClassName={mapTokenProxyClassName}
|
||||
onProxyDragEnd={handleProxyDragEnd}
|
||||
/>
|
||||
<TokenMenu
|
||||
tokenClassName={mapTokenMenuClassName}
|
||||
onTokenChange={onMapTokenChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Map;
|
||||
298
src/components/map/MapControls.js
Normal file
298
src/components/map/MapControls.js
Normal file
@@ -0,0 +1,298 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Flex, Box, IconButton, Label } from "theme-ui";
|
||||
|
||||
import AddMapButton from "./AddMapButton";
|
||||
import ExpandMoreIcon from "../../icons/ExpandMoreIcon";
|
||||
import PanToolIcon from "../../icons/PanToolIcon";
|
||||
import BrushToolIcon from "../../icons/BrushToolIcon";
|
||||
import EraseToolIcon from "../../icons/EraseToolIcon";
|
||||
import UndoIcon from "../../icons/UndoIcon";
|
||||
import RedoIcon from "../../icons/RedoIcon";
|
||||
import GridOnIcon from "../../icons/GridOnIcon";
|
||||
import GridOffIcon from "../../icons/GridOffIcon";
|
||||
import BlendOnIcon from "../../icons/BlendOnIcon";
|
||||
import BlendOffIcon from "../../icons/BlendOffIcon";
|
||||
import GestureOnIcon from "../../icons/GestureOnIcon";
|
||||
import GestureOffIcon from "../../icons/GestureOffIcon";
|
||||
|
||||
import colors, { colorOptions } from "../../helpers/colors";
|
||||
|
||||
import MapMenu from "./MapMenu";
|
||||
import EraseAllIcon from "../../icons/EraseAllIcon";
|
||||
|
||||
function MapControls({
|
||||
onMapChange,
|
||||
onToolChange,
|
||||
selectedTool,
|
||||
disabledTools,
|
||||
onUndo,
|
||||
onRedo,
|
||||
undoDisabled,
|
||||
redoDisabled,
|
||||
brushColor,
|
||||
onBrushColorChange,
|
||||
onEraseAll,
|
||||
useBrushGridSnapping,
|
||||
onBrushGridSnappingChange,
|
||||
useBrushBlending,
|
||||
onBrushBlendingChange,
|
||||
useBrushGesture,
|
||||
onBrushGestureChange,
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const subMenus = {
|
||||
brush: (
|
||||
<Box sx={{ width: "104px" }} p={1}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
{colorOptions.map((color) => (
|
||||
<Box
|
||||
key={color}
|
||||
sx={{
|
||||
width: "25%",
|
||||
paddingTop: "25%",
|
||||
borderRadius: "50%",
|
||||
transform: "scale(0.75)",
|
||||
backgroundColor: colors[color],
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => onBrushColorChange(color)}
|
||||
aria-label={`Brush Color ${color}`}
|
||||
>
|
||||
{brushColor === color && (
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
border: "2px solid white",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
borderRadius: "50%",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Flex sx={{ justifyContent: "space-between" }}>
|
||||
<IconButton
|
||||
aria-label={
|
||||
useBrushGridSnapping
|
||||
? "Disable Brush Grid Snapping"
|
||||
: "Enable Brush Grid Snapping"
|
||||
}
|
||||
title={
|
||||
useBrushGridSnapping
|
||||
? "Disable Brush Grid Snapping"
|
||||
: "Enable Brush Grid Snapping"
|
||||
}
|
||||
onClick={() => onBrushGridSnappingChange(!useBrushGridSnapping)}
|
||||
>
|
||||
{useBrushGridSnapping ? <GridOnIcon /> : <GridOffIcon />}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
aria-label={
|
||||
useBrushBlending
|
||||
? "Disable Brush Blending"
|
||||
: "Enable Brush Blending"
|
||||
}
|
||||
title={
|
||||
useBrushBlending
|
||||
? "Disable Brush Blending"
|
||||
: "Enable Brush Blending"
|
||||
}
|
||||
onClick={() => onBrushBlendingChange(!useBrushBlending)}
|
||||
>
|
||||
{useBrushBlending ? <BlendOnIcon /> : <BlendOffIcon />}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
aria-label={
|
||||
useBrushGesture
|
||||
? "Disable Gesture Detection"
|
||||
: "Enable Gesture Detection"
|
||||
}
|
||||
title={
|
||||
useBrushGesture
|
||||
? "Disable Gesture Detection"
|
||||
: "Enable Gesture Detection"
|
||||
}
|
||||
onClick={() => onBrushGestureChange(!useBrushGesture)}
|
||||
>
|
||||
{useBrushGesture ? <GestureOnIcon /> : <GestureOffIcon />}
|
||||
</IconButton>
|
||||
</Flex>
|
||||
</Box>
|
||||
),
|
||||
erase: (
|
||||
<Box p={1} pr={3}>
|
||||
<Label
|
||||
sx={{
|
||||
fontSize: 1,
|
||||
alignItems: "center",
|
||||
":hover": { color: "primary", cursor: "pointer" },
|
||||
":active": { color: "secondary" },
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
aria-label="Erase All"
|
||||
title="Erase All"
|
||||
onClick={() => {
|
||||
onEraseAll();
|
||||
setCurrentSubmenu(null);
|
||||
setCurrentSubmenuOptions({});
|
||||
}}
|
||||
>
|
||||
<EraseAllIcon />
|
||||
</IconButton>
|
||||
Erase All
|
||||
</Label>
|
||||
</Box>
|
||||
),
|
||||
};
|
||||
|
||||
const [currentSubmenu, setCurrentSubmenu] = useState(null);
|
||||
const [currentSubmenuOptions, setCurrentSubmenuOptions] = useState({});
|
||||
|
||||
function handleToolClick(event, tool) {
|
||||
if (tool !== selectedTool) {
|
||||
onToolChange(tool);
|
||||
} else if (currentSubmenu) {
|
||||
setCurrentSubmenu(null);
|
||||
setCurrentSubmenuOptions({});
|
||||
} else if (subMenus[tool]) {
|
||||
const toolRect = event.target.getBoundingClientRect();
|
||||
setCurrentSubmenu(tool);
|
||||
setCurrentSubmenuOptions({
|
||||
// Align the right of the submenu to the left of the tool and center vertically
|
||||
left: `${toolRect.left - 16}px`,
|
||||
top: `${toolRect.bottom - toolRect.height / 2}px`,
|
||||
style: { transform: "translate(-100%, -50%)" },
|
||||
// Exclude this node from the sub menus auto close
|
||||
excludeNode: event.target,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Detect when a tool becomes disabled and switch to to the pan tool
|
||||
useEffect(() => {
|
||||
if (disabledTools.includes(selectedTool)) {
|
||||
onToolChange("pan");
|
||||
}
|
||||
}, [selectedTool, disabledTools, onToolChange]);
|
||||
|
||||
const divider = (
|
||||
<Box
|
||||
my={2}
|
||||
bg="text"
|
||||
sx={{ height: "2px", width: "24px", borderRadius: "2px", opacity: 0.5 }}
|
||||
></Box>
|
||||
);
|
||||
|
||||
const expanedMenuRef = useRef();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
}}
|
||||
mx={1}
|
||||
>
|
||||
<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",
|
||||
backgroundColor: "overlay",
|
||||
borderRadius: "50%",
|
||||
}}
|
||||
m={2}
|
||||
>
|
||||
<ExpandMoreIcon />
|
||||
</IconButton>
|
||||
<Box
|
||||
sx={{
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
display: isExpanded ? "flex" : "none",
|
||||
backgroundColor: "overlay",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
p={2}
|
||||
ref={expanedMenuRef}
|
||||
>
|
||||
<AddMapButton onMapChange={onMapChange} />
|
||||
{divider}
|
||||
<IconButton
|
||||
aria-label="Pan Tool"
|
||||
title="Pan Tool"
|
||||
onClick={(e) => handleToolClick(e, "pan")}
|
||||
sx={{ color: selectedTool === "pan" ? "primary" : "text" }}
|
||||
disabled={disabledTools.includes("pan")}
|
||||
>
|
||||
<PanToolIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
aria-label="Brush Tool"
|
||||
title="Brush Tool"
|
||||
onClick={(e) => handleToolClick(e, "brush")}
|
||||
sx={{ color: selectedTool === "brush" ? "primary" : "text" }}
|
||||
disabled={disabledTools.includes("brush")}
|
||||
>
|
||||
<BrushToolIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
aria-label="Erase Tool"
|
||||
title="Erase Tool"
|
||||
onClick={(e) => handleToolClick(e, "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>
|
||||
<MapMenu
|
||||
isOpen={!!currentSubmenu}
|
||||
onRequestClose={() => {
|
||||
setCurrentSubmenu(null);
|
||||
setCurrentSubmenuOptions({});
|
||||
}}
|
||||
{...currentSubmenuOptions}
|
||||
>
|
||||
{currentSubmenu && subMenus[currentSubmenu]}
|
||||
</MapMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default MapControls;
|
||||
233
src/components/map/MapDrawing.js
Normal file
233
src/components/map/MapDrawing.js
Normal file
@@ -0,0 +1,233 @@
|
||||
import React, { useRef, useEffect, useState } from "react";
|
||||
import simplify from "simplify-js";
|
||||
import shortid from "shortid";
|
||||
|
||||
import colors from "../../helpers/colors";
|
||||
import { snapPositionToGrid } from "../../helpers/shared";
|
||||
|
||||
import { pointsToGesture, gestureToData } from "../../helpers/gestures";
|
||||
|
||||
function MapDrawing({
|
||||
width,
|
||||
height,
|
||||
selectedTool,
|
||||
shapes,
|
||||
onShapeAdd,
|
||||
onShapeRemove,
|
||||
brushColor,
|
||||
useGridSnapping,
|
||||
gridSize,
|
||||
useBrushBlending,
|
||||
useBrushGesture,
|
||||
}) {
|
||||
const canvasRef = useRef();
|
||||
const containerRef = useRef();
|
||||
|
||||
const [brushPoints, setBrushPoints] = useState([]);
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
const [pointerPosition, setPointerPosition] = useState({ x: -1, y: -1 });
|
||||
|
||||
// Reset pointer position when tool changes
|
||||
useEffect(() => {
|
||||
setPointerPosition({ x: -1, y: -1 });
|
||||
}, [selectedTool]);
|
||||
|
||||
function getRelativePointerPosition(event) {
|
||||
const container = containerRef.current;
|
||||
if (container) {
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const x = (event.clientX - containerRect.x) / containerRect.width;
|
||||
const y = (event.clientY - containerRect.y) / containerRect.height;
|
||||
return { x, y };
|
||||
}
|
||||
}
|
||||
|
||||
function handleStart(event) {
|
||||
if (event.touches && event.touches.length !== 1) {
|
||||
setIsDrawing(false);
|
||||
setBrushPoints([]);
|
||||
return;
|
||||
}
|
||||
const pointer = event.touches ? event.touches[0] : event;
|
||||
const position = getRelativePointerPosition(pointer);
|
||||
setPointerPosition(position);
|
||||
setIsDrawing(true);
|
||||
if (selectedTool === "brush") {
|
||||
const brushPosition = useGridSnapping
|
||||
? snapPositionToGrid(position, gridSize)
|
||||
: position;
|
||||
setBrushPoints([brushPosition]);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMove(event) {
|
||||
if (event.touches && event.touches.length !== 1) {
|
||||
return;
|
||||
}
|
||||
const pointer = event.touches ? event.touches[0] : event;
|
||||
const position = getRelativePointerPosition(pointer);
|
||||
if (selectedTool === "erase") {
|
||||
setPointerPosition(position);
|
||||
}
|
||||
if (isDrawing && selectedTool === "brush") {
|
||||
setPointerPosition(position);
|
||||
const brushPosition = useGridSnapping
|
||||
? snapPositionToGrid(position, gridSize)
|
||||
: position;
|
||||
setBrushPoints((prevPoints) => {
|
||||
if (prevPoints[prevPoints.length - 1] === brushPosition) {
|
||||
return prevPoints;
|
||||
}
|
||||
return [...prevPoints, brushPosition];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleStop(event) {
|
||||
if (event.touches && event.touches.length !== 0) {
|
||||
return;
|
||||
}
|
||||
setIsDrawing(false);
|
||||
if (selectedTool === "brush") {
|
||||
if (brushPoints.length > 1) {
|
||||
const simplifiedPoints = simplify(brushPoints, 0.001);
|
||||
const type = useBrushGesture
|
||||
? pointsToGesture(simplifiedPoints)
|
||||
: "path";
|
||||
|
||||
if (type !== null) {
|
||||
const data =
|
||||
type === "path"
|
||||
? { points: simplifiedPoints }
|
||||
: gestureToData(simplifiedPoints, type);
|
||||
|
||||
onShapeAdd({
|
||||
type,
|
||||
data,
|
||||
id: shortid.generate(),
|
||||
color: brushColor,
|
||||
blend: useBrushBlending,
|
||||
});
|
||||
}
|
||||
|
||||
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 circleToPath(x, y, radius) {
|
||||
const path = new Path2D();
|
||||
const minSide = width < height ? width : height;
|
||||
path.arc(x * width, y * height, radius * minSide, 0, 2 * Math.PI, true);
|
||||
return path;
|
||||
}
|
||||
|
||||
function rectangleToPath(x, y, w, h) {
|
||||
const path = new Path2D();
|
||||
path.rect(x * width, y * height, w * width, h * height);
|
||||
return path;
|
||||
}
|
||||
|
||||
function shapeToPath(shape) {
|
||||
const data = shape.data;
|
||||
if (shape.type === "path") {
|
||||
return pointsToPath(data.points);
|
||||
} else if (shape.type === "circle") {
|
||||
return circleToPath(data.x, data.y, data.radius);
|
||||
} else if (shape.type === "rectangle") {
|
||||
return rectangleToPath(data.x, data.y, data.width, data.height);
|
||||
} else if (shape.type === "triangle") {
|
||||
return pointsToPath(data.points);
|
||||
}
|
||||
}
|
||||
|
||||
function drawPath(path, color, blend, context) {
|
||||
context.globalAlpha = blend ? 0.5 : 1.0;
|
||||
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 hoveredShape = null;
|
||||
for (let shape of shapes) {
|
||||
const path = shapeToPath(shape);
|
||||
// Detect hover
|
||||
if (selectedTool === "erase") {
|
||||
if (
|
||||
context.isPointInPath(
|
||||
path,
|
||||
pointerPosition.x * width,
|
||||
pointerPosition.y * height
|
||||
)
|
||||
) {
|
||||
hoveredShape = shape;
|
||||
}
|
||||
}
|
||||
drawPath(path, colors[shape.color], shape.blend, context);
|
||||
}
|
||||
if (selectedTool === "brush" && brushPoints.length > 0) {
|
||||
const path = pointsToPath(brushPoints);
|
||||
drawPath(path, colors[brushColor], useBrushBlending, context);
|
||||
}
|
||||
if (hoveredShape) {
|
||||
const path = shapeToPath(hoveredShape);
|
||||
drawPath(path, "#BB99FF", true, context);
|
||||
}
|
||||
hoveredShapeRef.current = hoveredShape;
|
||||
}
|
||||
}, [
|
||||
shapes,
|
||||
width,
|
||||
height,
|
||||
pointerPosition,
|
||||
isDrawing,
|
||||
selectedTool,
|
||||
brushPoints,
|
||||
brushColor,
|
||||
useBrushGesture,
|
||||
useBrushBlending,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0 }}
|
||||
ref={containerRef}
|
||||
onMouseDown={handleStart}
|
||||
onMouseMove={handleMove}
|
||||
onMouseUp={handleStop}
|
||||
onTouchStart={handleStart}
|
||||
onTouchMove={handleMove}
|
||||
onTouchEnd={handleStop}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={width}
|
||||
height={height}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MapDrawing;
|
||||
97
src/components/map/MapMenu.js
Normal file
97
src/components/map/MapMenu.js
Normal file
@@ -0,0 +1,97 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Modal from "react-modal";
|
||||
|
||||
import { useThemeUI } from "theme-ui";
|
||||
|
||||
function MapMenu({
|
||||
isOpen,
|
||||
onRequestClose,
|
||||
onModalContent,
|
||||
top,
|
||||
left,
|
||||
bottom,
|
||||
right,
|
||||
children,
|
||||
style,
|
||||
// A node to exclude from the pointer event for closing
|
||||
excludeNode,
|
||||
}) {
|
||||
// Save modal node in state to ensure that the pointer listeners
|
||||
// are removed if the open state changed not from the onRequestClose
|
||||
// callback
|
||||
const [modalContentNode, setModalContentNode] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Close modal if interacting with any other element
|
||||
function handlePointerDown(event) {
|
||||
const path = event.composedPath();
|
||||
if (
|
||||
!path.includes(modalContentNode) &&
|
||||
!(excludeNode && path.includes(excludeNode))
|
||||
) {
|
||||
onRequestClose();
|
||||
document.body.removeEventListener("pointerdown", handlePointerDown);
|
||||
}
|
||||
}
|
||||
|
||||
if (modalContentNode) {
|
||||
document.body.addEventListener("pointerdown", handlePointerDown);
|
||||
// Check for wheel event to close modal as well
|
||||
document.body.addEventListener(
|
||||
"wheel",
|
||||
() => {
|
||||
onRequestClose();
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
}
|
||||
return () => {
|
||||
if (modalContentNode) {
|
||||
document.body.removeEventListener("pointerdown", handlePointerDown);
|
||||
}
|
||||
};
|
||||
}, [modalContentNode, excludeNode, onRequestClose]);
|
||||
|
||||
function handleModalContent(node) {
|
||||
setModalContentNode(node);
|
||||
onModalContent(node);
|
||||
}
|
||||
|
||||
const { theme } = useThemeUI();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onRequestClose={onRequestClose}
|
||||
style={{
|
||||
overlay: { top: "0", bottom: "initial" },
|
||||
content: {
|
||||
backgroundColor: theme.colors.overlay,
|
||||
top,
|
||||
left,
|
||||
right,
|
||||
bottom,
|
||||
padding: 0,
|
||||
borderRadius: "4px",
|
||||
border: "none",
|
||||
...style,
|
||||
},
|
||||
}}
|
||||
contentRef={handleModalContent}
|
||||
>
|
||||
{children}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
MapMenu.defaultProps = {
|
||||
onModalContent: () => {},
|
||||
top: "initial",
|
||||
left: "initial",
|
||||
right: "initial",
|
||||
bottom: "initial",
|
||||
style: {},
|
||||
excludeNode: null,
|
||||
};
|
||||
|
||||
export default MapMenu;
|
||||
66
src/components/map/MapToken.js
Normal file
66
src/components/map/MapToken.js
Normal file
@@ -0,0 +1,66 @@
|
||||
import React, { useRef } from "react";
|
||||
import { Box, Image } from "theme-ui";
|
||||
|
||||
import TokenLabel from "../token/TokenLabel";
|
||||
import TokenStatus from "../token/TokenStatus";
|
||||
|
||||
import usePreventTouch from "../../helpers/usePreventTouch";
|
||||
|
||||
function MapToken({ token, tokenSizePercent, className }) {
|
||||
const imageRef = useRef();
|
||||
// Stop touch to prevent 3d touch gesutre on iOS
|
||||
usePreventTouch(imageRef);
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
transform: `translate(${token.x * 100}%, ${token.y * 100}%)`,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: `${tokenSizePercent * (token.size || 1)}%`,
|
||||
}}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
pointerEvents: "all",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
display: "flex", // Set display to flex to fix height being calculated wrong
|
||||
width: "100%",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
className={className}
|
||||
sx={{
|
||||
userSelect: "none",
|
||||
touchAction: "none",
|
||||
width: "100%",
|
||||
}}
|
||||
src={token.image}
|
||||
// pass data into the dom element used to pass state to the ProxyToken
|
||||
data-id={token.id}
|
||||
data-size={token.size}
|
||||
data-label={token.label}
|
||||
data-status={token.status}
|
||||
ref={imageRef}
|
||||
/>
|
||||
{token.status && <TokenStatus statuses={token.status.split(" ")} />}
|
||||
{token.label && <TokenLabel label={token.label} />}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default MapToken;
|
||||
Reference in New Issue
Block a user