@ -1,21 +1,21 @@
|
||||
{
|
||||
"name": "owlbear-rodeo",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@stripe/stripe-js": "^1.3.2",
|
||||
"@testing-library/jest-dom": "^4.2.4",
|
||||
"@testing-library/react": "^9.3.2",
|
||||
"@testing-library/user-event": "^7.1.2",
|
||||
"blob-to-buffer": "^1.2.8",
|
||||
"dexie": "^2.0.4",
|
||||
"interactjs": "^1.9.7",
|
||||
"js-binarypack": "^0.0.9",
|
||||
"normalize-wheel": "^1.0.1",
|
||||
"react": "^16.13.0",
|
||||
"react-dom": "^16.13.0",
|
||||
"react-modal": "^3.11.2",
|
||||
"react-router-dom": "^5.1.2",
|
||||
"react-scripts": "3.4.0",
|
||||
"shape-detector": "^0.2.1",
|
||||
"shortid": "^2.2.15",
|
||||
"simple-peer": "^9.6.2",
|
||||
"simplebar-react": "^2.1.0",
|
||||
|
@ -1,82 +0,0 @@
|
||||
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;
|
@ -1,21 +0,0 @@
|
||||
import React, { useRef } from "react";
|
||||
import { Image } from "theme-ui";
|
||||
|
||||
import usePreventTouch from "../helpers/usePreventTouch";
|
||||
|
||||
function ListToken({ image, className }) {
|
||||
const imageRef = useRef();
|
||||
// Stop touch to prevent 3d touch gesutre on iOS
|
||||
usePreventTouch(imageRef);
|
||||
|
||||
return (
|
||||
<Image
|
||||
src={image}
|
||||
ref={imageRef}
|
||||
className={className}
|
||||
sx={{ userSelect: "none", touchAction: "none" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ListToken;
|
@ -1,328 +0,0 @@
|
||||
import React, { useRef, useEffect, useState } from "react";
|
||||
import { Box, Image } from "theme-ui";
|
||||
import interact from "interactjs";
|
||||
|
||||
import ProxyToken from "./ProxyToken";
|
||||
import TokenMenu from "./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;
|
@ -1,298 +0,0 @@
|
||||
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;
|
@ -1,233 +0,0 @@
|
||||
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;
|
31
src/components/SettingsButton.js
Normal file
@ -0,0 +1,31 @@
|
||||
import React, { useState } from "react";
|
||||
import { IconButton } from "theme-ui";
|
||||
|
||||
import SettingsIcon from "../icons/SettingsIcon";
|
||||
import SettingsModal from "../modals/SettingsModal";
|
||||
|
||||
function SettingsButton() {
|
||||
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
|
||||
function openModal() {
|
||||
setIsSettingsModalOpen(true);
|
||||
}
|
||||
function closeModal() {
|
||||
setIsSettingsModalOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
m={1}
|
||||
aria-label="Settings"
|
||||
title="Settings"
|
||||
onClick={openModal}
|
||||
>
|
||||
<SettingsIcon />
|
||||
</IconButton>
|
||||
<SettingsModal isOpen={isSettingsModalOpen} onRequestClose={closeModal} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SettingsButton;
|
323
src/components/map/Map.js
Normal file
@ -0,0 +1,323 @@
|
||||
import React, { useRef, useEffect, useState } from "react";
|
||||
import { Box, Image } from "theme-ui";
|
||||
|
||||
import ProxyToken from "../token/ProxyToken";
|
||||
import TokenMenu from "../token/TokenMenu";
|
||||
import MapToken from "./MapToken";
|
||||
import MapDrawing from "./MapDrawing";
|
||||
import MapFog from "./MapFog";
|
||||
import MapControls from "./MapControls";
|
||||
|
||||
import { omit } from "../../helpers/shared";
|
||||
import useDataSource from "../../helpers/useDataSource";
|
||||
import MapInteraction from "./MapInteraction";
|
||||
|
||||
import { mapSources as defaultMapSources } from "../../maps";
|
||||
|
||||
const mapTokenProxyClassName = "map-token__proxy";
|
||||
const mapTokenMenuClassName = "map-token__menu";
|
||||
|
||||
function Map({
|
||||
map,
|
||||
mapState,
|
||||
tokens,
|
||||
onMapTokenStateChange,
|
||||
onMapTokenStateRemove,
|
||||
onMapChange,
|
||||
onMapStateChange,
|
||||
onMapDraw,
|
||||
onMapDrawUndo,
|
||||
onMapDrawRedo,
|
||||
onFogDraw,
|
||||
onFogDrawUndo,
|
||||
onFogDrawRedo,
|
||||
allowMapDrawing,
|
||||
allowFogDrawing,
|
||||
disabledTokens,
|
||||
loading,
|
||||
}) {
|
||||
const mapSource = useDataSource(map, defaultMapSources);
|
||||
|
||||
function handleProxyDragEnd(isOnMap, tokenState) {
|
||||
if (isOnMap && onMapTokenStateChange) {
|
||||
onMapTokenStateChange(tokenState);
|
||||
}
|
||||
|
||||
if (!isOnMap && onMapTokenStateRemove) {
|
||||
onMapTokenStateRemove(tokenState);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map drawing
|
||||
*/
|
||||
|
||||
const [selectedToolId, setSelectedToolId] = useState("pan");
|
||||
const [toolSettings, setToolSettings] = useState({
|
||||
fog: { type: "add", useEdgeSnapping: true, useGridSnapping: false },
|
||||
brush: {
|
||||
color: "darkGray",
|
||||
type: "stroke",
|
||||
useBlending: false,
|
||||
},
|
||||
shape: {
|
||||
color: "red",
|
||||
type: "rectangle",
|
||||
useBlending: true,
|
||||
},
|
||||
});
|
||||
|
||||
function handleToolSettingChange(tool, change) {
|
||||
setToolSettings((prevSettings) => ({
|
||||
...prevSettings,
|
||||
[tool]: {
|
||||
...prevSettings[tool],
|
||||
...change,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function handleToolAction(action) {
|
||||
if (action === "eraseAll") {
|
||||
onMapDraw({
|
||||
type: "remove",
|
||||
shapeIds: mapShapes.map((s) => s.id),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
if (action === "mapUndo") {
|
||||
onMapDrawUndo();
|
||||
}
|
||||
if (action === "mapRedo") {
|
||||
onMapDrawRedo();
|
||||
}
|
||||
if (action === "fogUndo") {
|
||||
onFogDrawUndo();
|
||||
}
|
||||
if (action === "fogRedo") {
|
||||
onFogDrawRedo();
|
||||
}
|
||||
}
|
||||
|
||||
const [mapShapes, setMapShapes] = useState([]);
|
||||
function handleMapShapeAdd(shape) {
|
||||
onMapDraw({ type: "add", shapes: [shape] });
|
||||
}
|
||||
|
||||
function handleMapShapeRemove(shapeId) {
|
||||
onMapDraw({ type: "remove", shapeIds: [shapeId] });
|
||||
}
|
||||
|
||||
const [fogShapes, setFogShapes] = useState([]);
|
||||
function handleFogShapeAdd(shape) {
|
||||
onFogDraw({ type: "add", shapes: [shape] });
|
||||
}
|
||||
|
||||
function handleFogShapeRemove(shapeId) {
|
||||
onFogDraw({ type: "remove", shapeIds: [shapeId] });
|
||||
}
|
||||
|
||||
function handleFogShapeEdit(shape) {
|
||||
onFogDraw({ type: "edit", shapes: [shape] });
|
||||
}
|
||||
|
||||
// Replay the draw actions and convert them to shapes for the map drawing
|
||||
useEffect(() => {
|
||||
if (!mapState) {
|
||||
return;
|
||||
}
|
||||
function actionsToShapes(actions, actionIndex) {
|
||||
let shapesById = {};
|
||||
for (let i = 0; i <= actionIndex; i++) {
|
||||
const action = actions[i];
|
||||
if (action.type === "add" || action.type === "edit") {
|
||||
for (let shape of action.shapes) {
|
||||
shapesById[shape.id] = shape;
|
||||
}
|
||||
}
|
||||
if (action.type === "remove") {
|
||||
shapesById = omit(shapesById, action.shapeIds);
|
||||
}
|
||||
}
|
||||
return Object.values(shapesById);
|
||||
}
|
||||
|
||||
setMapShapes(
|
||||
actionsToShapes(mapState.mapDrawActions, mapState.mapDrawActionIndex)
|
||||
);
|
||||
setFogShapes(
|
||||
actionsToShapes(mapState.fogDrawActions, mapState.fogDrawActionIndex)
|
||||
);
|
||||
}, [mapState]);
|
||||
|
||||
const disabledControls = [];
|
||||
if (!allowMapDrawing) {
|
||||
disabledControls.push("brush");
|
||||
disabledControls.push("shape");
|
||||
disabledControls.push("erase");
|
||||
}
|
||||
if (!map) {
|
||||
disabledControls.push("pan");
|
||||
}
|
||||
if (mapShapes.length === 0) {
|
||||
disabledControls.push("erase");
|
||||
}
|
||||
if (!allowFogDrawing) {
|
||||
disabledControls.push("fog");
|
||||
}
|
||||
|
||||
const disabledSettings = { fog: [], brush: [], shape: [], erase: [] };
|
||||
if (!mapState || mapState.mapDrawActionIndex < 0) {
|
||||
disabledSettings.brush.push("undo");
|
||||
disabledSettings.shape.push("undo");
|
||||
disabledSettings.erase.push("undo");
|
||||
}
|
||||
if (
|
||||
!mapState ||
|
||||
mapState.mapDrawActionIndex === mapState.mapDrawActions.length - 1
|
||||
) {
|
||||
disabledSettings.brush.push("redo");
|
||||
disabledSettings.shape.push("redo");
|
||||
disabledSettings.erase.push("redo");
|
||||
}
|
||||
if (fogShapes.length === 0) {
|
||||
disabledSettings.fog.push("undo");
|
||||
}
|
||||
if (
|
||||
!mapState ||
|
||||
mapState.fogDrawActionIndex === mapState.fogDrawActions.length - 1
|
||||
) {
|
||||
disabledSettings.fog.push("redo");
|
||||
}
|
||||
|
||||
/**
|
||||
* Member setup
|
||||
*/
|
||||
|
||||
const mapRef = useRef(null);
|
||||
|
||||
const gridX = map && map.gridX;
|
||||
const gridY = map && map.gridY;
|
||||
const gridSizeNormalized = { x: 1 / gridX || 0, y: 1 / gridY || 0 };
|
||||
const tokenSizePercent = gridSizeNormalized.x * 100;
|
||||
const aspectRatio = (map && map.width / map.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",
|
||||
}}
|
||||
>
|
||||
{mapState &&
|
||||
Object.values(mapState.tokens).map((tokenState) => (
|
||||
<MapToken
|
||||
key={tokenState.id}
|
||||
token={tokens.find((token) => token.id === tokenState.tokenId)}
|
||||
tokenState={tokenState}
|
||||
tokenSizePercent={tokenSizePercent}
|
||||
className={`${mapTokenProxyClassName} ${mapTokenMenuClassName}`}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const mapDrawing = (
|
||||
<MapDrawing
|
||||
width={map ? map.width : 0}
|
||||
height={map ? map.height : 0}
|
||||
selectedTool={selectedToolId !== "fog" ? selectedToolId : "none"}
|
||||
toolSettings={toolSettings[selectedToolId]}
|
||||
shapes={mapShapes}
|
||||
onShapeAdd={handleMapShapeAdd}
|
||||
onShapeRemove={handleMapShapeRemove}
|
||||
gridSize={gridSizeNormalized}
|
||||
/>
|
||||
);
|
||||
|
||||
const mapFog = (
|
||||
<MapFog
|
||||
width={map ? map.width : 0}
|
||||
height={map ? map.height : 0}
|
||||
isEditing={selectedToolId === "fog"}
|
||||
toolSettings={toolSettings["fog"]}
|
||||
shapes={fogShapes}
|
||||
onShapeAdd={handleFogShapeAdd}
|
||||
onShapeRemove={handleFogShapeRemove}
|
||||
onShapeEdit={handleFogShapeEdit}
|
||||
gridSize={gridSizeNormalized}
|
||||
/>
|
||||
);
|
||||
|
||||
const mapControls = (
|
||||
<MapControls
|
||||
onMapChange={onMapChange}
|
||||
onMapStateChange={onMapStateChange}
|
||||
currentMap={map}
|
||||
onSelectedToolChange={setSelectedToolId}
|
||||
selectedToolId={selectedToolId}
|
||||
toolSettings={toolSettings}
|
||||
onToolSettingChange={handleToolSettingChange}
|
||||
onToolAction={handleToolAction}
|
||||
disabledControls={disabledControls}
|
||||
disabledSettings={disabledSettings}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<MapInteraction
|
||||
map={map}
|
||||
aspectRatio={aspectRatio}
|
||||
isEnabled={selectedToolId === "pan"}
|
||||
controls={mapControls}
|
||||
loading={loading}
|
||||
>
|
||||
{map && mapImage}
|
||||
{map && mapDrawing}
|
||||
{map && mapFog}
|
||||
{map && mapTokens}
|
||||
</MapInteraction>
|
||||
<ProxyToken
|
||||
tokenClassName={mapTokenProxyClassName}
|
||||
onProxyDragEnd={handleProxyDragEnd}
|
||||
tokens={mapState && mapState.tokens}
|
||||
disabledTokens={disabledTokens}
|
||||
/>
|
||||
<TokenMenu
|
||||
tokenClassName={mapTokenMenuClassName}
|
||||
onTokenChange={onMapTokenStateChange}
|
||||
tokens={mapState && mapState.tokens}
|
||||
disabledTokens={disabledTokens}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Map;
|
230
src/components/map/MapControls.js
Normal file
@ -0,0 +1,230 @@
|
||||
import React, { useState, Fragment, useEffect, useRef } from "react";
|
||||
import { IconButton, Flex, Box } from "theme-ui";
|
||||
|
||||
import RadioIconButton from "./controls/RadioIconButton";
|
||||
import Divider from "./controls/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 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 ExpandMoreIcon from "../../icons/ExpandMoreIcon";
|
||||
|
||||
function MapContols({
|
||||
onMapChange,
|
||||
onMapStateChange,
|
||||
currentMap,
|
||||
selectedToolId,
|
||||
onSelectedToolChange,
|
||||
toolSettings,
|
||||
onToolSettingChange,
|
||||
onToolAction,
|
||||
disabledControls,
|
||||
disabledSettings,
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const toolsById = {
|
||||
pan: {
|
||||
id: "pan",
|
||||
icon: <PanToolIcon />,
|
||||
title: "Pan Tool",
|
||||
},
|
||||
fog: {
|
||||
id: "fog",
|
||||
icon: <FogToolIcon />,
|
||||
title: "Fog Tool",
|
||||
SettingsComponent: FogToolSettings,
|
||||
},
|
||||
brush: {
|
||||
id: "brush",
|
||||
icon: <BrushToolIcon />,
|
||||
title: "Brush Tool",
|
||||
SettingsComponent: BrushToolSettings,
|
||||
},
|
||||
shape: {
|
||||
id: "shape",
|
||||
icon: <ShapeToolIcon />,
|
||||
title: "Shape Tool",
|
||||
SettingsComponent: ShapeToolSettings,
|
||||
},
|
||||
erase: {
|
||||
id: "erase",
|
||||
icon: <EraseToolIcon />,
|
||||
title: "Erase tool",
|
||||
SettingsComponent: EraseToolSettings,
|
||||
},
|
||||
};
|
||||
const tools = ["pan", "fog", "brush", "shape", "erase"];
|
||||
|
||||
const sections = [
|
||||
{
|
||||
id: "map",
|
||||
component: (
|
||||
<SelectMapButton
|
||||
onMapChange={onMapChange}
|
||||
onMapStateChange={onMapStateChange}
|
||||
currentMap={currentMap}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "drawing",
|
||||
component: tools.map((tool) => (
|
||||
<RadioIconButton
|
||||
key={tool}
|
||||
title={toolsById[tool].title}
|
||||
onClick={() => onSelectedToolChange(tool)}
|
||||
isSelected={selectedToolId === tool}
|
||||
disabled={disabledControls.includes(tool)}
|
||||
>
|
||||
{toolsById[tool].icon}
|
||||
</RadioIconButton>
|
||||
)),
|
||||
},
|
||||
];
|
||||
|
||||
let controls = null;
|
||||
if (sections.length === 1 && sections[0].id === "map") {
|
||||
controls = (
|
||||
<Box
|
||||
sx={{
|
||||
display: "block",
|
||||
backgroundColor: "overlay",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
m={2}
|
||||
>
|
||||
{sections[0].component}
|
||||
</Box>
|
||||
);
|
||||
} else if (sections.length > 0) {
|
||||
controls = (
|
||||
<>
|
||||
<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}
|
||||
>
|
||||
{sections.map((section, index) => (
|
||||
<Fragment key={section.id}>
|
||||
{section.component}
|
||||
{index !== sections.length - 1 && <Divider />}
|
||||
</Fragment>
|
||||
))}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const controlsRef = useRef();
|
||||
const settingsRef = useRef();
|
||||
|
||||
function getToolSettings() {
|
||||
const Settings = toolsById[selectedToolId].SettingsComponent;
|
||||
if (Settings) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: "4px",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
backgroundColor: "overlay",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
p={1}
|
||||
ref={settingsRef}
|
||||
>
|
||||
<Settings
|
||||
settings={toolSettings[selectedToolId]}
|
||||
onSettingChange={(change) =>
|
||||
onToolSettingChange(selectedToolId, change)
|
||||
}
|
||||
onToolAction={onToolAction}
|
||||
disabledActions={disabledSettings[selectedToolId]}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Stop map drawing from happening when selecting controls
|
||||
// Not using react events as they seem to trigger after dom events
|
||||
useEffect(() => {
|
||||
function stopPropagation(e) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
const controls = controlsRef.current;
|
||||
if (controls) {
|
||||
controls.addEventListener("mousedown", stopPropagation);
|
||||
controls.addEventListener("touchstart", stopPropagation);
|
||||
}
|
||||
const settings = settingsRef.current;
|
||||
if (settings) {
|
||||
settings.addEventListener("mousedown", stopPropagation);
|
||||
settings.addEventListener("touchstart", stopPropagation);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (controls) {
|
||||
controls.removeEventListener("mousedown", stopPropagation);
|
||||
controls.removeEventListener("touchstart", stopPropagation);
|
||||
}
|
||||
if (settings) {
|
||||
settings.removeEventListener("mousedown", stopPropagation);
|
||||
settings.removeEventListener("touchstart", stopPropagation);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
}}
|
||||
mx={1}
|
||||
ref={controlsRef}
|
||||
>
|
||||
{controls}
|
||||
</Flex>
|
||||
{getToolSettings()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default MapContols;
|
261
src/components/map/MapDrawing.js
Normal file
@ -0,0 +1,261 @@
|
||||
import React, { useRef, useEffect, useState, useContext } from "react";
|
||||
import shortid from "shortid";
|
||||
|
||||
import { compare as comparePoints } from "../../helpers/vector2";
|
||||
import {
|
||||
getBrushPositionForTool,
|
||||
getDefaultShapeData,
|
||||
getUpdatedShapeData,
|
||||
isShapeHovered,
|
||||
drawShape,
|
||||
simplifyPoints,
|
||||
getRelativePointerPosition,
|
||||
} from "../../helpers/drawing";
|
||||
|
||||
import MapInteractionContext from "../../contexts/MapInteractionContext";
|
||||
|
||||
function MapDrawing({
|
||||
width,
|
||||
height,
|
||||
selectedTool,
|
||||
toolSettings,
|
||||
shapes,
|
||||
onShapeAdd,
|
||||
onShapeRemove,
|
||||
gridSize,
|
||||
}) {
|
||||
const canvasRef = useRef();
|
||||
const containerRef = useRef();
|
||||
|
||||
const [isPointerDown, setIsPointerDown] = useState(false);
|
||||
const [drawingShape, setDrawingShape] = useState(null);
|
||||
const [pointerPosition, setPointerPosition] = useState({ x: -1, y: -1 });
|
||||
|
||||
const shouldHover = selectedTool === "erase";
|
||||
const isEditing =
|
||||
selectedTool === "brush" ||
|
||||
selectedTool === "shape" ||
|
||||
selectedTool === "erase";
|
||||
|
||||
const { scaleRef } = useContext(MapInteractionContext);
|
||||
|
||||
// Reset pointer position when tool changes
|
||||
useEffect(() => {
|
||||
setPointerPosition({ x: -1, y: -1 });
|
||||
}, [selectedTool]);
|
||||
|
||||
function handleStart(event) {
|
||||
if (!isEditing) {
|
||||
return;
|
||||
}
|
||||
if (event.touches && event.touches.length !== 1) {
|
||||
setIsPointerDown(false);
|
||||
setDrawingShape(null);
|
||||
return;
|
||||
}
|
||||
const pointer = event.touches ? event.touches[0] : event;
|
||||
const position = getRelativePointerPosition(pointer, containerRef.current);
|
||||
setPointerPosition(position);
|
||||
setIsPointerDown(true);
|
||||
const brushPosition = getBrushPositionForTool(
|
||||
position,
|
||||
selectedTool,
|
||||
toolSettings,
|
||||
gridSize,
|
||||
shapes
|
||||
);
|
||||
const commonShapeData = {
|
||||
color: toolSettings && toolSettings.color,
|
||||
blend: toolSettings && toolSettings.useBlending,
|
||||
id: shortid.generate(),
|
||||
};
|
||||
if (selectedTool === "brush") {
|
||||
setDrawingShape({
|
||||
type: "path",
|
||||
pathType: toolSettings.type,
|
||||
data: { points: [brushPosition] },
|
||||
strokeWidth: toolSettings.type === "stroke" ? 1 : 0,
|
||||
...commonShapeData,
|
||||
});
|
||||
} else if (selectedTool === "shape") {
|
||||
setDrawingShape({
|
||||
type: "shape",
|
||||
shapeType: toolSettings.type,
|
||||
data: getDefaultShapeData(toolSettings.type, brushPosition),
|
||||
strokeWidth: 0,
|
||||
...commonShapeData,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleMove(event) {
|
||||
if (!isEditing) {
|
||||
return;
|
||||
}
|
||||
if (event.touches && event.touches.length !== 1) {
|
||||
return;
|
||||
}
|
||||
const pointer = event.touches ? event.touches[0] : event;
|
||||
// Set pointer position every frame for erase tool and fog
|
||||
if (shouldHover) {
|
||||
const position = getRelativePointerPosition(
|
||||
pointer,
|
||||
containerRef.current
|
||||
);
|
||||
setPointerPosition(position);
|
||||
}
|
||||
if (isPointerDown) {
|
||||
const position = getRelativePointerPosition(
|
||||
pointer,
|
||||
containerRef.current
|
||||
);
|
||||
setPointerPosition(position);
|
||||
const brushPosition = getBrushPositionForTool(
|
||||
position,
|
||||
selectedTool,
|
||||
toolSettings,
|
||||
gridSize,
|
||||
shapes
|
||||
);
|
||||
if (selectedTool === "brush") {
|
||||
setDrawingShape((prevShape) => {
|
||||
const prevPoints = prevShape.data.points;
|
||||
if (
|
||||
comparePoints(
|
||||
prevPoints[prevPoints.length - 1],
|
||||
brushPosition,
|
||||
0.001
|
||||
)
|
||||
) {
|
||||
return prevShape;
|
||||
}
|
||||
const simplified = simplifyPoints(
|
||||
[...prevPoints, brushPosition],
|
||||
gridSize,
|
||||
scaleRef.current
|
||||
);
|
||||
return {
|
||||
...prevShape,
|
||||
data: { points: simplified },
|
||||
};
|
||||
});
|
||||
} else if (selectedTool === "shape") {
|
||||
setDrawingShape((prevShape) => ({
|
||||
...prevShape,
|
||||
data: getUpdatedShapeData(
|
||||
prevShape.shapeType,
|
||||
prevShape.data,
|
||||
brushPosition,
|
||||
gridSize
|
||||
),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleStop(event) {
|
||||
if (!isEditing) {
|
||||
return;
|
||||
}
|
||||
if (event.touches && event.touches.length !== 0) {
|
||||
return;
|
||||
}
|
||||
if (selectedTool === "brush" && drawingShape) {
|
||||
if (drawingShape.data.points.length > 1) {
|
||||
onShapeAdd(drawingShape);
|
||||
}
|
||||
} else if (selectedTool === "shape" && drawingShape) {
|
||||
onShapeAdd(drawingShape);
|
||||
}
|
||||
|
||||
if (selectedTool === "erase" && hoveredShapeRef.current && isPointerDown) {
|
||||
onShapeRemove(hoveredShapeRef.current.id);
|
||||
}
|
||||
setIsPointerDown(false);
|
||||
setDrawingShape(null);
|
||||
}
|
||||
|
||||
// Add listeners for draw events on map to allow drawing past the bounds
|
||||
// of the container
|
||||
useEffect(() => {
|
||||
const map = document.querySelector(".map");
|
||||
map.addEventListener("mousedown", handleStart);
|
||||
map.addEventListener("mousemove", handleMove);
|
||||
map.addEventListener("mouseup", handleStop);
|
||||
map.addEventListener("touchstart", handleStart);
|
||||
map.addEventListener("touchmove", handleMove);
|
||||
map.addEventListener("touchend", handleStop);
|
||||
|
||||
return () => {
|
||||
map.removeEventListener("mousedown", handleStart);
|
||||
map.removeEventListener("mousemove", handleMove);
|
||||
map.removeEventListener("mouseup", handleStop);
|
||||
map.removeEventListener("touchstart", handleStart);
|
||||
map.removeEventListener("touchmove", handleMove);
|
||||
map.removeEventListener("touchend", handleStop);
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Rendering
|
||||
*/
|
||||
const hoveredShapeRef = useRef(null);
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
const context = canvas.getContext("2d");
|
||||
|
||||
context.clearRect(0, 0, width, height);
|
||||
let hoveredShape = null;
|
||||
for (let shape of shapes) {
|
||||
if (shouldHover) {
|
||||
if (isShapeHovered(shape, context, pointerPosition, width, height)) {
|
||||
hoveredShape = shape;
|
||||
}
|
||||
}
|
||||
drawShape(shape, context, gridSize, width, height);
|
||||
}
|
||||
if (drawingShape) {
|
||||
drawShape(drawingShape, context, gridSize, width, height);
|
||||
}
|
||||
if (hoveredShape) {
|
||||
const shape = { ...hoveredShape, color: "#BB99FF", blend: true };
|
||||
drawShape(shape, context, gridSize, width, height);
|
||||
}
|
||||
hoveredShapeRef.current = hoveredShape;
|
||||
}
|
||||
}, [
|
||||
shapes,
|
||||
width,
|
||||
height,
|
||||
pointerPosition,
|
||||
isPointerDown,
|
||||
selectedTool,
|
||||
drawingShape,
|
||||
gridSize,
|
||||
shouldHover,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
ref={containerRef}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={width}
|
||||
height={height}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MapDrawing;
|
276
src/components/map/MapFog.js
Normal file
@ -0,0 +1,276 @@
|
||||
import React, { useRef, useEffect, useState, useContext } from "react";
|
||||
import shortid from "shortid";
|
||||
|
||||
import { compare as comparePoints } from "../../helpers/vector2";
|
||||
import {
|
||||
getBrushPositionForTool,
|
||||
isShapeHovered,
|
||||
drawShape,
|
||||
simplifyPoints,
|
||||
getRelativePointerPosition,
|
||||
} from "../../helpers/drawing";
|
||||
|
||||
import MapInteractionContext from "../../contexts/MapInteractionContext";
|
||||
|
||||
import diagonalPattern from "../../images/DiagonalPattern.png";
|
||||
|
||||
function MapFog({
|
||||
width,
|
||||
height,
|
||||
isEditing,
|
||||
toolSettings,
|
||||
shapes,
|
||||
onShapeAdd,
|
||||
onShapeRemove,
|
||||
onShapeEdit,
|
||||
gridSize,
|
||||
}) {
|
||||
const canvasRef = useRef();
|
||||
const containerRef = useRef();
|
||||
|
||||
const [isPointerDown, setIsPointerDown] = useState(false);
|
||||
const [drawingShape, setDrawingShape] = useState(null);
|
||||
const [pointerPosition, setPointerPosition] = useState({ x: -1, y: -1 });
|
||||
|
||||
const shouldHover =
|
||||
isEditing &&
|
||||
(toolSettings.type === "toggle" || toolSettings.type === "remove");
|
||||
|
||||
const { scaleRef } = useContext(MapInteractionContext);
|
||||
|
||||
// Reset pointer position when tool changes
|
||||
useEffect(() => {
|
||||
setPointerPosition({ x: -1, y: -1 });
|
||||
}, [isEditing, toolSettings]);
|
||||
|
||||
function handleStart(event) {
|
||||
if (!isEditing) {
|
||||
return;
|
||||
}
|
||||
if (event.touches && event.touches.length !== 1) {
|
||||
setIsPointerDown(false);
|
||||
setDrawingShape(null);
|
||||
return;
|
||||
}
|
||||
const pointer = event.touches ? event.touches[0] : event;
|
||||
const position = getRelativePointerPosition(pointer, containerRef.current);
|
||||
setPointerPosition(position);
|
||||
setIsPointerDown(true);
|
||||
const brushPosition = getBrushPositionForTool(
|
||||
position,
|
||||
"fog",
|
||||
toolSettings,
|
||||
gridSize,
|
||||
shapes
|
||||
);
|
||||
if (isEditing && toolSettings.type === "add") {
|
||||
setDrawingShape({
|
||||
type: "fog",
|
||||
data: { points: [brushPosition] },
|
||||
strokeWidth: 0.5,
|
||||
color: "black",
|
||||
blend: true, // Blend while drawing
|
||||
id: shortid.generate(),
|
||||
visible: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleMove(event) {
|
||||
if (!isEditing) {
|
||||
return;
|
||||
}
|
||||
if (event.touches && event.touches.length !== 1) {
|
||||
return;
|
||||
}
|
||||
const pointer = event.touches ? event.touches[0] : event;
|
||||
const position = getRelativePointerPosition(pointer, containerRef.current);
|
||||
// Set pointer position every frame for erase tool and fog
|
||||
if (shouldHover) {
|
||||
setPointerPosition(position);
|
||||
}
|
||||
if (isPointerDown) {
|
||||
setPointerPosition(position);
|
||||
const brushPosition = getBrushPositionForTool(
|
||||
position,
|
||||
"fog",
|
||||
toolSettings,
|
||||
gridSize,
|
||||
shapes
|
||||
);
|
||||
if (isEditing && toolSettings.type === "add" && drawingShape) {
|
||||
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 handleStop(event) {
|
||||
if (!isEditing) {
|
||||
return;
|
||||
}
|
||||
if (event.touches && event.touches.length !== 0) {
|
||||
return;
|
||||
}
|
||||
if (isEditing && toolSettings.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
|
||||
scaleRef.current / 2
|
||||
),
|
||||
},
|
||||
blend: false,
|
||||
};
|
||||
onShapeAdd(shape);
|
||||
}
|
||||
}
|
||||
|
||||
if (hoveredShapeRef.current && isPointerDown) {
|
||||
if (toolSettings.type === "remove") {
|
||||
onShapeRemove(hoveredShapeRef.current.id);
|
||||
} else if (toolSettings.type === "toggle") {
|
||||
onShapeEdit({
|
||||
...hoveredShapeRef.current,
|
||||
visible: !hoveredShapeRef.current.visible,
|
||||
});
|
||||
}
|
||||
}
|
||||
setDrawingShape(null);
|
||||
setIsPointerDown(false);
|
||||
}
|
||||
|
||||
// Add listeners for draw events on map to allow drawing past the bounds
|
||||
// of the container
|
||||
useEffect(() => {
|
||||
const map = document.querySelector(".map");
|
||||
map.addEventListener("mousedown", handleStart);
|
||||
map.addEventListener("mousemove", handleMove);
|
||||
map.addEventListener("mouseup", handleStop);
|
||||
map.addEventListener("touchstart", handleStart);
|
||||
map.addEventListener("touchmove", handleMove);
|
||||
map.addEventListener("touchend", handleStop);
|
||||
|
||||
return () => {
|
||||
map.removeEventListener("mousedown", handleStart);
|
||||
map.removeEventListener("mousemove", handleMove);
|
||||
map.removeEventListener("mouseup", handleStop);
|
||||
map.removeEventListener("touchstart", handleStart);
|
||||
map.removeEventListener("touchmove", handleMove);
|
||||
map.removeEventListener("touchend", handleStop);
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Rendering
|
||||
*/
|
||||
const hoveredShapeRef = useRef(null);
|
||||
const diagonalPatternRef = useRef();
|
||||
|
||||
useEffect(() => {
|
||||
let image = new Image();
|
||||
image.src = diagonalPattern;
|
||||
diagonalPatternRef.current = image;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) {
|
||||
const context = canvas.getContext("2d");
|
||||
|
||||
context.clearRect(0, 0, width, height);
|
||||
let hoveredShape = null;
|
||||
if (isEditing) {
|
||||
const editPattern = context.createPattern(
|
||||
diagonalPatternRef.current,
|
||||
"repeat"
|
||||
);
|
||||
for (let shape of shapes) {
|
||||
if (shouldHover) {
|
||||
if (
|
||||
isShapeHovered(shape, context, pointerPosition, width, height)
|
||||
) {
|
||||
hoveredShape = shape;
|
||||
}
|
||||
}
|
||||
drawShape(
|
||||
{
|
||||
...shape,
|
||||
blend: true,
|
||||
color: shape.visible ? "black" : editPattern,
|
||||
},
|
||||
context,
|
||||
gridSize,
|
||||
width,
|
||||
height
|
||||
);
|
||||
}
|
||||
if (drawingShape) {
|
||||
drawShape(drawingShape, context, gridSize, width, height);
|
||||
}
|
||||
if (hoveredShape) {
|
||||
const shape = { ...hoveredShape, color: "#BB99FF", blend: true };
|
||||
drawShape(shape, context, gridSize, width, height);
|
||||
}
|
||||
} else {
|
||||
// Not editing
|
||||
for (let shape of shapes) {
|
||||
if (shape.visible) {
|
||||
drawShape(shape, context, gridSize, width, height);
|
||||
}
|
||||
}
|
||||
}
|
||||
hoveredShapeRef.current = hoveredShape;
|
||||
}
|
||||
}, [
|
||||
shapes,
|
||||
width,
|
||||
height,
|
||||
pointerPosition,
|
||||
isEditing,
|
||||
drawingShape,
|
||||
gridSize,
|
||||
shouldHover,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
ref={containerRef}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={width}
|
||||
height={height}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MapFog;
|
168
src/components/map/MapInteraction.js
Normal file
@ -0,0 +1,168 @@
|
||||
import React, { useRef, useEffect } from "react";
|
||||
import { Box } from "theme-ui";
|
||||
import interact from "interactjs";
|
||||
import normalizeWheel from "normalize-wheel";
|
||||
|
||||
import { MapInteractionProvider } from "../../contexts/MapInteractionContext";
|
||||
|
||||
import LoadingOverlay from "../LoadingOverlay";
|
||||
|
||||
const zoomSpeed = -0.001;
|
||||
const minZoom = 0.1;
|
||||
const maxZoom = 5;
|
||||
|
||||
function MapInteraction({
|
||||
map,
|
||||
aspectRatio,
|
||||
isEnabled,
|
||||
children,
|
||||
controls,
|
||||
loading,
|
||||
}) {
|
||||
const mapContainerRef = useRef();
|
||||
const mapMoveContainerRef = useRef();
|
||||
const mapTranslateRef = useRef({ x: 0, y: 0 });
|
||||
const mapScaleRef = useRef(1);
|
||||
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 (isEnabled || 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 isEnabled && map ? "move" : "default";
|
||||
},
|
||||
})
|
||||
.on("doubletap", (event) => {
|
||||
event.preventDefault();
|
||||
if (isEnabled) {
|
||||
setTranslateAndScale({ x: 0, y: 0 }, 1);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mapInteract.unset();
|
||||
};
|
||||
}, [isEnabled, map]);
|
||||
|
||||
// Reset map transform when map changes
|
||||
useEffect(() => {
|
||||
setTranslateAndScale({ x: 0, y: 0 }, 1);
|
||||
}, [map]);
|
||||
|
||||
// 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();
|
||||
|
||||
// Try and normalize the wheel event to prevent OS differences for zoom speed
|
||||
const normalized = normalizeWheel(event);
|
||||
|
||||
const scale = mapScaleRef.current;
|
||||
const translate = mapTranslateRef.current;
|
||||
|
||||
const deltaY = normalized.pixelY * 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);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
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}%`,
|
||||
}}
|
||||
/>
|
||||
<MapInteractionProvider
|
||||
value={{
|
||||
translateRef: mapTranslateRef,
|
||||
scaleRef: mapScaleRef,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MapInteractionProvider>
|
||||
</Box>
|
||||
</Box>
|
||||
{controls}
|
||||
{loading && <LoadingOverlay />}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default MapInteraction;
|
126
src/components/map/MapSettings.js
Normal file
@ -0,0 +1,126 @@
|
||||
import React from "react";
|
||||
import { Flex, Box, Label, Input, Checkbox, IconButton } from "theme-ui";
|
||||
|
||||
import ExpandMoreIcon from "../../icons/ExpandMoreIcon";
|
||||
|
||||
function MapSettings({
|
||||
map,
|
||||
mapState,
|
||||
onSettingsChange,
|
||||
onStateSettingsChange,
|
||||
showMore,
|
||||
onShowMoreChange,
|
||||
}) {
|
||||
function handleFlagChange(event, flag) {
|
||||
if (event.target.checked) {
|
||||
onStateSettingsChange("editFlags", [...mapState.editFlags, flag]);
|
||||
} else {
|
||||
onStateSettingsChange(
|
||||
"editFlags",
|
||||
mapState.editFlags.filter((f) => f !== flag)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex sx={{ flexDirection: "column" }}>
|
||||
<Flex>
|
||||
<Box mt={2} mr={1} sx={{ flexGrow: 1 }}>
|
||||
<Label htmlFor="gridX">Columns</Label>
|
||||
<Input
|
||||
type="number"
|
||||
name="gridX"
|
||||
value={(map && map.gridX) || 0}
|
||||
onChange={(e) =>
|
||||
onSettingsChange("gridX", parseInt(e.target.value))
|
||||
}
|
||||
disabled={map === null || map.type === "default"}
|
||||
min={1}
|
||||
my={1}
|
||||
/>
|
||||
</Box>
|
||||
<Box mt={2} ml={1} sx={{ flexGrow: 1 }}>
|
||||
<Label htmlFor="gridY">Rows</Label>
|
||||
<Input
|
||||
type="number"
|
||||
name="gridY"
|
||||
value={(map && map.gridY) || 0}
|
||||
onChange={(e) =>
|
||||
onSettingsChange("gridY", parseInt(e.target.value))
|
||||
}
|
||||
disabled={map === null || map.type === "default"}
|
||||
min={1}
|
||||
my={1}
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
{showMore && (
|
||||
<>
|
||||
<Box mt={2} sx={{ flexGrow: 1 }}>
|
||||
<Label>Allow others to edit</Label>
|
||||
<Flex my={1}>
|
||||
<Label>
|
||||
<Checkbox
|
||||
checked={
|
||||
mapState !== null && mapState.editFlags.includes("fog")
|
||||
}
|
||||
disabled={mapState === null}
|
||||
onChange={(e) => handleFlagChange(e, "fog")}
|
||||
/>
|
||||
Fog
|
||||
</Label>
|
||||
<Label>
|
||||
<Checkbox
|
||||
checked={
|
||||
mapState !== null && mapState.editFlags.includes("drawing")
|
||||
}
|
||||
disabled={mapState === null}
|
||||
onChange={(e) => handleFlagChange(e, "drawing")}
|
||||
/>
|
||||
Drawings
|
||||
</Label>
|
||||
<Label>
|
||||
<Checkbox
|
||||
checked={
|
||||
mapState !== null && mapState.editFlags.includes("tokens")
|
||||
}
|
||||
disabled={mapState === null}
|
||||
onChange={(e) => handleFlagChange(e, "tokens")}
|
||||
/>
|
||||
Tokens
|
||||
</Label>
|
||||
</Flex>
|
||||
</Box>
|
||||
<Box my={2} sx={{ flexGrow: 1 }}>
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
name="name"
|
||||
value={(map && map.name) || ""}
|
||||
onChange={(e) => onSettingsChange("name", e.target.value)}
|
||||
disabled={map === null || map.type === "default"}
|
||||
my={1}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onShowMoreChange(!showMore);
|
||||
}}
|
||||
sx={{
|
||||
transform: `rotate(${showMore ? "180deg" : "0"})`,
|
||||
alignSelf: "center",
|
||||
}}
|
||||
aria-label={showMore ? "Show Less" : "Show More"}
|
||||
title={showMore ? "Show Less" : "Show More"}
|
||||
disabled={map === null}
|
||||
>
|
||||
<ExpandMoreIcon />
|
||||
</IconButton>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default MapSettings;
|
173
src/components/map/MapTile.js
Normal file
@ -0,0 +1,173 @@
|
||||
import React, { useState } from "react";
|
||||
import { Flex, Image as UIImage, IconButton, Box, Text } from "theme-ui";
|
||||
|
||||
import RemoveMapIcon from "../../icons/RemoveMapIcon";
|
||||
import ResetMapIcon from "../../icons/ResetMapIcon";
|
||||
import ExpandMoreDotIcon from "../../icons/ExpandMoreDotIcon";
|
||||
|
||||
import useDataSource from "../../helpers/useDataSource";
|
||||
import { mapSources as defaultMapSources } from "../../maps";
|
||||
|
||||
function MapTile({
|
||||
map,
|
||||
mapState,
|
||||
isSelected,
|
||||
onMapSelect,
|
||||
onMapRemove,
|
||||
onMapReset,
|
||||
onSubmit,
|
||||
}) {
|
||||
const mapSource = useDataSource(map, defaultMapSources);
|
||||
const [isMapTileMenuOpen, setIsTileMenuOpen] = useState(false);
|
||||
const isDefault = map.type === "default";
|
||||
const hasMapState =
|
||||
mapState &&
|
||||
(Object.values(mapState.tokens).length > 0 ||
|
||||
mapState.mapDrawActions.length > 0 ||
|
||||
mapState.fogDrawActions.length > 0);
|
||||
|
||||
const expandButton = (
|
||||
<IconButton
|
||||
aria-label="Show Map Actions"
|
||||
title="Show Map Actions"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsTileMenuOpen(true);
|
||||
}}
|
||||
bg="overlay"
|
||||
sx={{ borderRadius: "50%" }}
|
||||
m={1}
|
||||
>
|
||||
<ExpandMoreDotIcon />
|
||||
</IconButton>
|
||||
);
|
||||
|
||||
function removeButton(map) {
|
||||
return (
|
||||
<IconButton
|
||||
aria-label="Remove Map"
|
||||
title="Remove Map"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsTileMenuOpen(false);
|
||||
onMapRemove(map.id);
|
||||
}}
|
||||
bg="overlay"
|
||||
sx={{ borderRadius: "50%" }}
|
||||
m={1}
|
||||
>
|
||||
<RemoveMapIcon />
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
function resetButton(map) {
|
||||
return (
|
||||
<IconButton
|
||||
aria-label="Reset Map"
|
||||
title="Reset Map"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsTileMenuOpen(false);
|
||||
onMapReset(map.id);
|
||||
}}
|
||||
bg="overlay"
|
||||
sx={{ borderRadius: "50%" }}
|
||||
m={1}
|
||||
>
|
||||
<ResetMapIcon />
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex
|
||||
key={map.id}
|
||||
sx={{
|
||||
borderColor: "primary",
|
||||
borderStyle: isSelected ? "solid" : "none",
|
||||
borderWidth: "4px",
|
||||
position: "relative",
|
||||
width: "150px",
|
||||
height: "150px",
|
||||
borderRadius: "4px",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
m={2}
|
||||
bg="muted"
|
||||
onClick={() => {
|
||||
setIsTileMenuOpen(false);
|
||||
if (!isSelected) {
|
||||
onMapSelect(map);
|
||||
}
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
if (!isMapTileMenuOpen) {
|
||||
onSubmit(e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<UIImage
|
||||
sx={{ width: "100%", height: "100%", objectFit: "contain" }}
|
||||
src={mapSource}
|
||||
/>
|
||||
<Flex
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background:
|
||||
"linear-gradient(to bottom, rgba(0,0,0,0) 70%, rgba(0,0,0,0.65) 100%);",
|
||||
alignItems: "flex-end",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
p={2}
|
||||
>
|
||||
<Text
|
||||
as="p"
|
||||
variant="heading"
|
||||
color="hsl(210, 50%, 96%)"
|
||||
sx={{ textAlign: "center" }}
|
||||
>
|
||||
{map.name}
|
||||
</Text>
|
||||
</Flex>
|
||||
{/* Show expand button only if both reset and remove is available */}
|
||||
{isSelected && (
|
||||
<Box sx={{ position: "absolute", top: 0, right: 0 }}>
|
||||
{isDefault && hasMapState && resetButton(map)}
|
||||
{!isDefault && hasMapState && !isMapTileMenuOpen && expandButton}
|
||||
{!isDefault && !hasMapState && removeButton(map)}
|
||||
</Box>
|
||||
)}
|
||||
{/* Tile menu for two actions */}
|
||||
{!isDefault && isMapTileMenuOpen && isSelected && (
|
||||
<Flex
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
bg="muted"
|
||||
onClick={() => setIsTileMenuOpen(false)}
|
||||
>
|
||||
{!isDefault && removeButton(map)}
|
||||
{hasMapState && resetButton(map)}
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default MapTile;
|
75
src/components/map/MapTiles.js
Normal file
@ -0,0 +1,75 @@
|
||||
import React from "react";
|
||||
import { Flex } from "theme-ui";
|
||||
import SimpleBar from "simplebar-react";
|
||||
|
||||
import AddIcon from "../../icons/AddIcon";
|
||||
|
||||
import MapTile from "./MapTile";
|
||||
|
||||
function MapTiles({
|
||||
maps,
|
||||
selectedMap,
|
||||
selectedMapState,
|
||||
onMapSelect,
|
||||
onMapAdd,
|
||||
onMapRemove,
|
||||
onMapReset,
|
||||
onSubmit,
|
||||
}) {
|
||||
return (
|
||||
<SimpleBar style={{ maxHeight: "300px", width: "500px" }}>
|
||||
<Flex
|
||||
py={2}
|
||||
bg="muted"
|
||||
sx={{
|
||||
flexWrap: "wrap",
|
||||
width: "500px",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
onClick={onMapAdd}
|
||||
sx={{
|
||||
":hover": {
|
||||
color: "primary",
|
||||
},
|
||||
":focus": {
|
||||
outline: "none",
|
||||
},
|
||||
":active": {
|
||||
color: "secondary",
|
||||
},
|
||||
width: "150px",
|
||||
height: "150px",
|
||||
borderRadius: "4px",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
m={2}
|
||||
bg="muted"
|
||||
aria-label="Add Map"
|
||||
title="Add Map"
|
||||
>
|
||||
<AddIcon large />
|
||||
</Flex>
|
||||
{maps.map((map) => (
|
||||
<MapTile
|
||||
key={map.id}
|
||||
map={map}
|
||||
mapState={
|
||||
selectedMap && map.id === selectedMap.id && selectedMapState
|
||||
}
|
||||
isSelected={selectedMap && map.id === selectedMap.id}
|
||||
onMapSelect={onMapSelect}
|
||||
onMapRemove={onMapRemove}
|
||||
onMapReset={onMapReset}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
</SimpleBar>
|
||||
);
|
||||
}
|
||||
|
||||
export default MapTiles;
|
@ -1,12 +1,17 @@
|
||||
import React, { useRef } from "react";
|
||||
import { Box, Image } from "theme-ui";
|
||||
|
||||
import TokenLabel from "./TokenLabel";
|
||||
import TokenStatus from "./TokenStatus";
|
||||
import TokenLabel from "../token/TokenLabel";
|
||||
import TokenStatus from "../token/TokenStatus";
|
||||
|
||||
import usePreventTouch from "../helpers/usePreventTouch";
|
||||
import usePreventTouch from "../../helpers/usePreventTouch";
|
||||
import useDataSource from "../../helpers/useDataSource";
|
||||
|
||||
import { tokenSources } from "../../tokens";
|
||||
|
||||
function MapToken({ token, tokenState, tokenSizePercent, className }) {
|
||||
const imageSource = useDataSource(token, tokenSources);
|
||||
|
||||
function MapToken({ token, tokenSizePercent, className }) {
|
||||
const imageRef = useRef();
|
||||
// Stop touch to prevent 3d touch gesutre on iOS
|
||||
usePreventTouch(imageRef);
|
||||
@ -14,7 +19,7 @@ function MapToken({ token, tokenSizePercent, className }) {
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
transform: `translate(${token.x * 100}%, ${token.y * 100}%)`,
|
||||
transform: `translate(${tokenState.x * 100}%, ${tokenState.y * 100}%)`,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
@ -25,7 +30,7 @@ function MapToken({ token, tokenSizePercent, className }) {
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: `${tokenSizePercent * (token.size || 1)}%`,
|
||||
width: `${tokenSizePercent * (tokenState.size || 1)}%`,
|
||||
}}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
@ -47,16 +52,15 @@ function MapToken({ token, tokenSizePercent, className }) {
|
||||
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}
|
||||
src={imageSource}
|
||||
// pass id into the dom element which is then used by the ProxyToken
|
||||
data-id={tokenState.id}
|
||||
ref={imageRef}
|
||||
/>
|
||||
{token.status && <TokenStatus statuses={token.status.split(" ")} />}
|
||||
{token.label && <TokenLabel label={token.label} />}
|
||||
{tokenState.statuses && (
|
||||
<TokenStatus statuses={tokenState.statuses} />
|
||||
)}
|
||||
{tokenState.label && <TokenLabel label={tokenState.label} />}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
41
src/components/map/SelectMapButton.js
Normal file
@ -0,0 +1,41 @@
|
||||
import React, { useState } from "react";
|
||||
import { IconButton } from "theme-ui";
|
||||
|
||||
import SelectMapModal from "../../modals/SelectMapModal";
|
||||
import SelectMapIcon from "../../icons/SelectMapIcon";
|
||||
|
||||
function SelectMapButton({ onMapChange, onMapStateChange, currentMap }) {
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
function openModal() {
|
||||
setIsAddModalOpen(true);
|
||||
}
|
||||
function closeModal() {
|
||||
setIsAddModalOpen(false);
|
||||
}
|
||||
|
||||
function handleDone() {
|
||||
closeModal();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
aria-label="Select Map"
|
||||
title="Select Map"
|
||||
onClick={openModal}
|
||||
>
|
||||
<SelectMapIcon />
|
||||
</IconButton>
|
||||
<SelectMapModal
|
||||
isOpen={isAddModalOpen}
|
||||
onRequestClose={closeModal}
|
||||
onDone={handleDone}
|
||||
onMapChange={onMapChange}
|
||||
onMapStateChange={onMapStateChange}
|
||||
currentMap={currentMap}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SelectMapButton;
|
19
src/components/map/controls/AlphaBlendToggle.js
Normal file
@ -0,0 +1,19 @@
|
||||
import React from "react";
|
||||
import { IconButton } from "theme-ui";
|
||||
|
||||
import BlendOnIcon from "../../../icons/BlendOnIcon";
|
||||
import BlendOffIcon from "../../../icons/BlendOffIcon";
|
||||
|
||||
function AlphaBlendToggle({ useBlending, onBlendingChange }) {
|
||||
return (
|
||||
<IconButton
|
||||
aria-label={useBlending ? "Disable Blending" : "Enable Blending"}
|
||||
title={useBlending ? "Disable Blending" : "Enable Blending"}
|
||||
onClick={() => onBlendingChange(!useBlending)}
|
||||
>
|
||||
{useBlending ? <BlendOnIcon /> : <BlendOffIcon />}
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
export default AlphaBlendToggle;
|
61
src/components/map/controls/BrushToolSettings.js
Normal file
@ -0,0 +1,61 @@
|
||||
import React from "react";
|
||||
import { Flex } from "theme-ui";
|
||||
|
||||
import ColorControl from "./ColorControl";
|
||||
import AlphaBlendToggle from "./AlphaBlendToggle";
|
||||
import RadioIconButton from "./RadioIconButton";
|
||||
|
||||
import BrushStrokeIcon from "../../../icons/BrushStrokeIcon";
|
||||
import BrushFillIcon from "../../../icons/BrushFillIcon";
|
||||
|
||||
import UndoButton from "./UndoButton";
|
||||
import RedoButton from "./RedoButton";
|
||||
|
||||
import Divider from "./Divider";
|
||||
|
||||
function BrushToolSettings({
|
||||
settings,
|
||||
onSettingChange,
|
||||
onToolAction,
|
||||
disabledActions,
|
||||
}) {
|
||||
return (
|
||||
<Flex sx={{ alignItems: "center" }}>
|
||||
<ColorControl
|
||||
color={settings.color}
|
||||
onColorChange={(color) => onSettingChange({ color })}
|
||||
/>
|
||||
<Divider vertical />
|
||||
<RadioIconButton
|
||||
title="Brush Type Stroke"
|
||||
onClick={() => onSettingChange({ type: "stroke" })}
|
||||
isSelected={settings.type === "stroke"}
|
||||
>
|
||||
<BrushStrokeIcon />
|
||||
</RadioIconButton>
|
||||
<RadioIconButton
|
||||
title="Brush Type Fill"
|
||||
onClick={() => onSettingChange({ type: "fill" })}
|
||||
isSelected={settings.type === "fill"}
|
||||
>
|
||||
<BrushFillIcon />
|
||||
</RadioIconButton>
|
||||
<Divider vertical />
|
||||
<AlphaBlendToggle
|
||||
useBlending={settings.useBlending}
|
||||
onBlendingChange={(useBlending) => onSettingChange({ useBlending })}
|
||||
/>
|
||||
<Divider vertical />
|
||||
<UndoButton
|
||||
onClick={() => onToolAction("mapUndo")}
|
||||
disabled={disabledActions.includes("undo")}
|
||||
/>
|
||||
<RedoButton
|
||||
onClick={() => onToolAction("mapRedo")}
|
||||
disabled={disabledActions.includes("redo")}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default BrushToolSettings;
|
107
src/components/map/controls/ColorControl.js
Normal file
@ -0,0 +1,107 @@
|
||||
import React, { useState } from "react";
|
||||
import { Box } from "theme-ui";
|
||||
|
||||
import colors, { colorOptions } from "../../../helpers/colors";
|
||||
import MapMenu from "../MapMenu";
|
||||
|
||||
function ColorCircle({ color, selected, onClick, sx }) {
|
||||
return (
|
||||
<Box
|
||||
key={color}
|
||||
sx={{
|
||||
borderRadius: "50%",
|
||||
transform: "scale(0.75)",
|
||||
backgroundColor: colors[color],
|
||||
cursor: "pointer",
|
||||
...sx,
|
||||
}}
|
||||
onClick={onClick}
|
||||
aria-label={`Brush Color ${color}`}
|
||||
>
|
||||
{selected && (
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
border: "2px solid white",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
borderRadius: "50%",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorControl({ color, onColorChange }) {
|
||||
const [showColorMenu, setShowColorMenu] = useState(false);
|
||||
const [colorMenuOptions, setColorMenuOptions] = useState({});
|
||||
|
||||
function handleControlClick(event) {
|
||||
if (showColorMenu) {
|
||||
setShowColorMenu(false);
|
||||
setColorMenuOptions({});
|
||||
} else {
|
||||
setShowColorMenu(true);
|
||||
const rect = event.target.getBoundingClientRect();
|
||||
setColorMenuOptions({
|
||||
// Align the right of the submenu to the left of the tool and center vertically
|
||||
left: `${rect.left + rect.width / 2}px`,
|
||||
top: `${rect.bottom + 16}px`,
|
||||
style: { transform: "translateX(-50%)" },
|
||||
// Exclude this node from the sub menus auto close
|
||||
excludeNode: event.target,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const colorMenu = (
|
||||
<MapMenu
|
||||
isOpen={showColorMenu}
|
||||
onRequestClose={() => {
|
||||
setShowColorMenu(false);
|
||||
setColorMenuOptions({});
|
||||
}}
|
||||
{...colorMenuOptions}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: "104px",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
p={1}
|
||||
>
|
||||
{colorOptions.map((c) => (
|
||||
<ColorCircle
|
||||
key={c}
|
||||
color={c}
|
||||
selected={c === color}
|
||||
onClick={() => {
|
||||
onColorChange(c);
|
||||
setShowColorMenu(false);
|
||||
setColorMenuOptions({});
|
||||
}}
|
||||
sx={{ width: "25%", paddingTop: "25%" }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</MapMenu>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ColorCircle
|
||||
color={color}
|
||||
selected
|
||||
onClick={handleControlClick}
|
||||
sx={{ width: "24px", height: "24px" }}
|
||||
/>
|
||||
{colorMenu}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ColorControl;
|
24
src/components/map/controls/Divider.js
Normal file
@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import { Divider } from "theme-ui";
|
||||
|
||||
function StyledDivider({ vertical }) {
|
||||
return (
|
||||
<Divider
|
||||
my={vertical ? 0 : 2}
|
||||
mx={vertical ? 2 : 0}
|
||||
bg="text"
|
||||
sx={{
|
||||
height: vertical ? "24px" : "2px",
|
||||
width: vertical ? "2px" : "24px",
|
||||
borderRadius: "2px",
|
||||
opacity: 0.5,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
StyledDivider.defaultProps = {
|
||||
vertical: false,
|
||||
};
|
||||
|
||||
export default StyledDivider;
|
21
src/components/map/controls/EdgeSnappingToggle.js
Normal file
@ -0,0 +1,21 @@
|
||||
import React from "react";
|
||||
import { IconButton } from "theme-ui";
|
||||
|
||||
import SnappingOnIcon from "../../../icons/SnappingOnIcon";
|
||||
import SnappingOffIcon from "../../../icons/SnappingOffIcon";
|
||||
|
||||
function EdgeSnappingToggle({ useEdgeSnapping, onEdgeSnappingChange }) {
|
||||
return (
|
||||
<IconButton
|
||||
aria-label={
|
||||
useEdgeSnapping ? "Disable Edge Snapping" : "Enable Edge Snapping"
|
||||
}
|
||||
title={useEdgeSnapping ? "Disable Edge Snapping" : "Enable Edge Snapping"}
|
||||
onClick={() => onEdgeSnappingChange(!useEdgeSnapping)}
|
||||
>
|
||||
{useEdgeSnapping ? <SnappingOnIcon /> : <SnappingOffIcon />}
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
export default EdgeSnappingToggle;
|
34
src/components/map/controls/EraseToolSettings.js
Normal file
@ -0,0 +1,34 @@
|
||||
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 (
|
||||
<Flex sx={{ alignItems: "center" }}>
|
||||
<IconButton
|
||||
aria-label="Erase All"
|
||||
title="Erase All"
|
||||
onClick={() => onToolAction("eraseAll")}
|
||||
>
|
||||
<EraseAllIcon />
|
||||
</IconButton>
|
||||
<Divider vertical />
|
||||
<UndoButton
|
||||
onClick={() => onToolAction("mapUndo")}
|
||||
disabled={disabledActions.includes("undo")}
|
||||
/>
|
||||
<RedoButton
|
||||
onClick={() => onToolAction("mapRedo")}
|
||||
disabled={disabledActions.includes("redo")}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default EraseToolSettings;
|
72
src/components/map/controls/FogToolSettings.js
Normal file
@ -0,0 +1,72 @@
|
||||
import React from "react";
|
||||
import { Flex } from "theme-ui";
|
||||
|
||||
import EdgeSnappingToggle from "./EdgeSnappingToggle";
|
||||
import RadioIconButton from "./RadioIconButton";
|
||||
import GridSnappingToggle from "./GridSnappingToggle";
|
||||
|
||||
import FogAddIcon from "../../../icons/FogAddIcon";
|
||||
import FogRemoveIcon from "../../../icons/FogRemoveIcon";
|
||||
import FogToggleIcon from "../../../icons/FogToggleIcon";
|
||||
|
||||
import UndoButton from "./UndoButton";
|
||||
import RedoButton from "./RedoButton";
|
||||
|
||||
import Divider from "./Divider";
|
||||
|
||||
function BrushToolSettings({
|
||||
settings,
|
||||
onSettingChange,
|
||||
onToolAction,
|
||||
disabledActions,
|
||||
}) {
|
||||
return (
|
||||
<Flex sx={{ alignItems: "center" }}>
|
||||
<RadioIconButton
|
||||
title="Add Fog"
|
||||
onClick={() => onSettingChange({ type: "add" })}
|
||||
isSelected={settings.type === "add"}
|
||||
>
|
||||
<FogAddIcon />
|
||||
</RadioIconButton>
|
||||
<RadioIconButton
|
||||
title="Remove Fog"
|
||||
onClick={() => onSettingChange({ type: "remove" })}
|
||||
isSelected={settings.type === "remove"}
|
||||
>
|
||||
<FogRemoveIcon />
|
||||
</RadioIconButton>
|
||||
<RadioIconButton
|
||||
title="Toggle Fog"
|
||||
onClick={() => onSettingChange({ type: "toggle" })}
|
||||
isSelected={settings.type === "toggle"}
|
||||
>
|
||||
<FogToggleIcon />
|
||||
</RadioIconButton>
|
||||
<Divider vertical />
|
||||
<EdgeSnappingToggle
|
||||
useEdgeSnapping={settings.useEdgeSnapping}
|
||||
onEdgeSnappingChange={(useEdgeSnapping) =>
|
||||
onSettingChange({ useEdgeSnapping })
|
||||
}
|
||||
/>
|
||||
<GridSnappingToggle
|
||||
useGridSnapping={settings.useGridSnapping}
|
||||
onGridSnappingChange={(useGridSnapping) =>
|
||||
onSettingChange({ useGridSnapping })
|
||||
}
|
||||
/>
|
||||
<Divider vertical />
|
||||
<UndoButton
|
||||
onClick={() => onToolAction("fogUndo")}
|
||||
disabled={disabledActions.includes("undo")}
|
||||
/>
|
||||
<RedoButton
|
||||
onClick={() => onToolAction("fogRedo")}
|
||||
disabled={disabledActions.includes("redo")}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default BrushToolSettings;
|
21
src/components/map/controls/GridSnappingToggle.js
Normal file
@ -0,0 +1,21 @@
|
||||
import React from "react";
|
||||
import { IconButton } from "theme-ui";
|
||||
|
||||
import GridOnIcon from "../../../icons/GridOnIcon";
|
||||
import GridOffIcon from "../../../icons/GridOffIcon";
|
||||
|
||||
function GridSnappingToggle({ useGridSnapping, onGridSnappingChange }) {
|
||||
return (
|
||||
<IconButton
|
||||
aria-label={
|
||||
useGridSnapping ? "Disable Grid Snapping" : "Enable Grid Snapping"
|
||||
}
|
||||
title={useGridSnapping ? "Disable Grid Snapping" : "Enable Grid Snapping"}
|
||||
onClick={() => onGridSnappingChange(!useGridSnapping)}
|
||||
>
|
||||
{useGridSnapping ? <GridOnIcon /> : <GridOffIcon />}
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
export default GridSnappingToggle;
|
22
src/components/map/controls/RadioIconButton.js
Normal file
@ -0,0 +1,22 @@
|
||||
import React from "react";
|
||||
import { IconButton } from "theme-ui";
|
||||
|
||||
function RadioIconButton({ title, onClick, isSelected, disabled, children }) {
|
||||
return (
|
||||
<IconButton
|
||||
aria-label={title}
|
||||
title={title}
|
||||
onClick={onClick}
|
||||
sx={{ color: isSelected ? "primary" : "text" }}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
RadioIconButton.defaultProps = {
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
export default RadioIconButton;
|
14
src/components/map/controls/RedoButton.js
Normal file
@ -0,0 +1,14 @@
|
||||
import React from "react";
|
||||
import { IconButton } from "theme-ui";
|
||||
|
||||
import RedoIcon from "../../../icons/RedoIcon";
|
||||
|
||||
function RedoButton({ onClick, disabled }) {
|
||||
return (
|
||||
<IconButton onClick={onClick} disabled={disabled}>
|
||||
<RedoIcon />
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
export default RedoButton;
|
69
src/components/map/controls/ShapeToolSettings.js
Normal file
@ -0,0 +1,69 @@
|
||||
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 (
|
||||
<Flex sx={{ alignItems: "center" }}>
|
||||
<ColorControl
|
||||
color={settings.color}
|
||||
onColorChange={(color) => onSettingChange({ color })}
|
||||
/>
|
||||
<Divider vertical />
|
||||
<RadioIconButton
|
||||
title="Shape Type Rectangle"
|
||||
onClick={() => onSettingChange({ type: "rectangle" })}
|
||||
isSelected={settings.type === "rectangle"}
|
||||
>
|
||||
<ShapeRectangleIcon />
|
||||
</RadioIconButton>
|
||||
<RadioIconButton
|
||||
title="Shape Type Circle"
|
||||
onClick={() => onSettingChange({ type: "circle" })}
|
||||
isSelected={settings.type === "circle"}
|
||||
>
|
||||
<ShapeCircleIcon />
|
||||
</RadioIconButton>
|
||||
<RadioIconButton
|
||||
title="Shape Type Triangle"
|
||||
onClick={() => onSettingChange({ type: "triangle" })}
|
||||
isSelected={settings.type === "triangle"}
|
||||
>
|
||||
<ShapeTriangleIcon />
|
||||
</RadioIconButton>
|
||||
<Divider vertical />
|
||||
<AlphaBlendToggle
|
||||
useBlending={settings.useBlending}
|
||||
onBlendingChange={(useBlending) => onSettingChange({ useBlending })}
|
||||
/>
|
||||
<Divider vertical />
|
||||
<UndoButton
|
||||
onClick={() => onToolAction("mapUndo")}
|
||||
disabled={disabledActions.includes("undo")}
|
||||
/>
|
||||
<RedoButton
|
||||
onClick={() => onToolAction("mapRedo")}
|
||||
disabled={disabledActions.includes("redo")}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default ShapeToolSettings;
|
14
src/components/map/controls/UndoButton.js
Normal file
@ -0,0 +1,14 @@
|
||||
import React from "react";
|
||||
import { IconButton } from "theme-ui";
|
||||
|
||||
import UndoIcon from "../../../icons/UndoIcon";
|
||||
|
||||
function UndoButton({ onClick, disabled }) {
|
||||
return (
|
||||
<IconButton onClick={onClick} disabled={disabled}>
|
||||
<UndoIcon />
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
export default UndoButton;
|
@ -1,8 +1,8 @@
|
||||
import React, { useState } from "react";
|
||||
import { IconButton } from "theme-ui";
|
||||
|
||||
import AddPartyMemberModal from "../modals/AddPartyMemberModal";
|
||||
import AddPartyMemberIcon from "../icons/AddPartyMemberIcon";
|
||||
import AddPartyMemberModal from "../../modals/AddPartyMemberModal";
|
||||
import AddPartyMemberIcon from "../../icons/AddPartyMemberIcon";
|
||||
|
||||
function AddPartyMemberButton({ gameId }) {
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
@ -1,8 +1,8 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { IconButton } from "theme-ui";
|
||||
|
||||
import ChangeNicknameModal from "../modals/ChangeNicknameModal";
|
||||
import ChangeNicknameIcon from "../icons/ChangeNicknameIcon";
|
||||
import ChangeNicknameModal from "../../modals/ChangeNicknameModal";
|
||||
import ChangeNicknameIcon from "../../icons/ChangeNicknameIcon";
|
||||
|
||||
function ChangeNicknameButton({ nickname, onChange }) {
|
||||
const [isChangeModalOpen, setIsChangeModalOpen] = useState(false);
|
||||
@ -13,7 +13,12 @@ function ChangeNicknameButton({ nickname, onChange }) {
|
||||
setIsChangeModalOpen(false);
|
||||
}
|
||||
|
||||
const [changedNickname, setChangedNickname] = useState(nickname);
|
||||
const [changedNickname, setChangedNickname] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setChangedNickname(nickname);
|
||||
}, [nickname]);
|
||||
|
||||
function handleChangeSubmit(event) {
|
||||
event.preventDefault();
|
||||
onChange(changedNickname);
|
@ -5,6 +5,7 @@ import AddPartyMemberButton from "./AddPartyMemberButton";
|
||||
import Nickname from "./Nickname";
|
||||
import ChangeNicknameButton from "./ChangeNicknameButton";
|
||||
import StartStreamButton from "./StartStreamButton";
|
||||
import SettingsButton from "../SettingsButton";
|
||||
|
||||
function Party({
|
||||
nickname,
|
||||
@ -54,12 +55,13 @@ function Party({
|
||||
</Box>
|
||||
<Flex sx={{ flexDirection: "column" }}>
|
||||
<ChangeNicknameButton nickname={nickname} onChange={onNicknameChange} />
|
||||
<AddPartyMemberButton gameId={gameId} />
|
||||
<StartStreamButton
|
||||
onStreamStart={onStreamStart}
|
||||
onStreamEnd={onStreamEnd}
|
||||
stream={stream}
|
||||
/>
|
||||
<AddPartyMemberButton gameId={gameId} />
|
||||
<SettingsButton />
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
@ -2,9 +2,9 @@ import React, { useState } from "react";
|
||||
import { IconButton, Box, Text } from "theme-ui";
|
||||
import adapter from "webrtc-adapter";
|
||||
|
||||
import Link from "../components/Link";
|
||||
import Link from "../Link";
|
||||
|
||||
import StartStreamModal from "../modals/StartStreamModal";
|
||||
import StartStreamModal from "../../modals/StartStreamModal";
|
||||
|
||||
function StartStreamButton({ onStreamStart, onStreamEnd, stream }) {
|
||||
const [isStreamModalOpoen, setIsStreamModalOpen] = useState(false);
|
@ -1,7 +1,7 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { Text, IconButton, Box } from "theme-ui";
|
||||
|
||||
import Banner from "./Banner";
|
||||
import Banner from "../Banner";
|
||||
|
||||
function Stream({ stream, nickname }) {
|
||||
const [streamMuted, setStreamMuted] = useState(false);
|
30
src/components/token/ListToken.js
Normal file
@ -0,0 +1,30 @@
|
||||
import React, { useRef } from "react";
|
||||
import { Box, Image } from "theme-ui";
|
||||
|
||||
import usePreventTouch from "../../helpers/usePreventTouch";
|
||||
import useDataSource from "../../helpers/useDataSource";
|
||||
|
||||
import { tokenSources } from "../../tokens";
|
||||
|
||||
function ListToken({ token, className }) {
|
||||
const imageSource = useDataSource(token, tokenSources);
|
||||
|
||||
const imageRef = useRef();
|
||||
// Stop touch to prevent 3d touch gesutre on iOS
|
||||
usePreventTouch(imageRef);
|
||||
|
||||
return (
|
||||
<Box my={2} mx={3} sx={{ width: "48px", height: "48px" }}>
|
||||
<Image
|
||||
src={imageSource}
|
||||
ref={imageRef}
|
||||
className={className}
|
||||
sx={{ userSelect: "none", touchAction: "none" }}
|
||||
// pass id into the dom element which is then used by the ProxyToken
|
||||
data-id={token.id}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default ListToken;
|
@ -3,19 +3,46 @@ import ReactDOM from "react-dom";
|
||||
import { Image, Box } from "theme-ui";
|
||||
import interact from "interactjs";
|
||||
|
||||
import usePortal from "../helpers/usePortal";
|
||||
import usePortal from "../../helpers/usePortal";
|
||||
|
||||
import TokenLabel from "./TokenLabel";
|
||||
import TokenStatus from "./TokenStatus";
|
||||
|
||||
function ProxyToken({ tokenClassName, onProxyDragEnd }) {
|
||||
/**
|
||||
* @callback onProxyDragEnd
|
||||
* @param {boolean} isOnMap whether the token was dropped on the map
|
||||
* @param {Object} token the token that was dropped
|
||||
*/
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} tokenClassName The class name to attach the interactjs handler to
|
||||
* @param {onProxyDragEnd} onProxyDragEnd Called when the proxy token is dropped
|
||||
* @param {Object} tokens An optional mapping of tokens to use as a base when calling OnProxyDragEnd
|
||||
* @param {Object} disabledTokens An optional mapping of tokens that shouldn't allow movement
|
||||
|
||||
*/
|
||||
function ProxyToken({
|
||||
tokenClassName,
|
||||
onProxyDragEnd,
|
||||
tokens,
|
||||
disabledTokens,
|
||||
}) {
|
||||
const proxyContainer = usePortal("root");
|
||||
|
||||
const [imageSource, setImageSource] = useState("");
|
||||
const [label, setLabel] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [tokenId, setTokenId] = useState(null);
|
||||
const proxyRef = useRef();
|
||||
|
||||
// Store the tokens in a ref and access in the interactjs loop
|
||||
// This is needed to stop interactjs from creating multiple listeners
|
||||
const tokensRef = useRef(tokens);
|
||||
const disabledTokensRef = useRef(disabledTokens);
|
||||
useEffect(() => {
|
||||
tokensRef.current = tokens;
|
||||
disabledTokensRef.current = disabledTokens;
|
||||
}, [tokens, disabledTokens]);
|
||||
|
||||
const proxyOnMap = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
@ -23,11 +50,15 @@ function ProxyToken({ tokenClassName, onProxyDragEnd }) {
|
||||
listeners: {
|
||||
start: (event) => {
|
||||
let target = event.target;
|
||||
const id = target.dataset.id;
|
||||
if (id in disabledTokensRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide the token and copy it's image to the proxy
|
||||
target.parentElement.style.opacity = "0.25";
|
||||
setImageSource(target.src);
|
||||
setLabel(target.dataset.label || "");
|
||||
setStatus(target.dataset.status || "");
|
||||
setTokenId(id);
|
||||
|
||||
let proxy = proxyRef.current;
|
||||
if (proxy) {
|
||||
@ -73,10 +104,14 @@ function ProxyToken({ tokenClassName, onProxyDragEnd }) {
|
||||
|
||||
end: (event) => {
|
||||
let target = event.target;
|
||||
const id = target.dataset.id;
|
||||
if (id in disabledTokensRef.current) {
|
||||
return;
|
||||
}
|
||||
let proxy = proxyRef.current;
|
||||
if (proxy) {
|
||||
if (onProxyDragEnd) {
|
||||
const mapImage = document.querySelector(".mapImage");
|
||||
const mapImage = document.querySelector(".mapImage");
|
||||
if (onProxyDragEnd && mapImage) {
|
||||
const mapImageRect = mapImage.getBoundingClientRect();
|
||||
|
||||
let x = parseFloat(proxy.getAttribute("data-x")) || 0;
|
||||
@ -88,13 +123,13 @@ function ProxyToken({ tokenClassName, onProxyDragEnd }) {
|
||||
x = x / (mapImageRect.right - mapImageRect.left);
|
||||
y = y / (mapImageRect.bottom - mapImageRect.top);
|
||||
|
||||
target.setAttribute("data-x", x);
|
||||
target.setAttribute("data-y", y);
|
||||
// Get the token from the supplied tokens if it exists
|
||||
const token = tokensRef.current[id] || {};
|
||||
|
||||
onProxyDragEnd(proxyOnMap.current, {
|
||||
image: target.src,
|
||||
// Pass in props stored as data- in the dom node
|
||||
...target.dataset,
|
||||
...token,
|
||||
x,
|
||||
y,
|
||||
});
|
||||
}
|
||||
|
||||
@ -140,12 +175,21 @@ function ProxyToken({ tokenClassName, onProxyDragEnd }) {
|
||||
width: "100%",
|
||||
}}
|
||||
/>
|
||||
{status && <TokenStatus statuses={status.split(" ")} />}
|
||||
{label && <TokenLabel label={label} />}
|
||||
{tokens[tokenId] && tokens[tokenId].statuses && (
|
||||
<TokenStatus statuses={tokens[tokenId].statuses} />
|
||||
)}
|
||||
{tokens[tokenId] && tokens[tokenId].label && (
|
||||
<TokenLabel label={tokens[tokenId].label} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>,
|
||||
proxyContainer
|
||||
);
|
||||
}
|
||||
|
||||
ProxyToken.defaultProps = {
|
||||
tokens: {},
|
||||
disabledTokens: {},
|
||||
};
|
||||
|
||||
export default ProxyToken;
|
@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { Image, Box, Text } from "theme-ui";
|
||||
|
||||
import tokenLabel from "../images/TokenLabel.png";
|
||||
import tokenLabel from "../../images/TokenLabel.png";
|
||||
|
||||
function TokenLabel({ label }) {
|
||||
return (
|
||||
@ -39,6 +39,7 @@ function TokenLabel({ label }) {
|
||||
verticalAlign: "middle",
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
color="hsl(210, 50%, 96%)"
|
||||
>
|
||||
{label}
|
||||
</Text>
|
@ -1,18 +1,39 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import interact from "interactjs";
|
||||
import { Box, Input } from "theme-ui";
|
||||
|
||||
import MapMenu from "./MapMenu";
|
||||
import MapMenu from "../map/MapMenu";
|
||||
|
||||
import colors, { colorOptions } from "../helpers/colors";
|
||||
import colors, { colorOptions } from "../../helpers/colors";
|
||||
|
||||
function TokenMenu({ tokenClassName, onTokenChange }) {
|
||||
/**
|
||||
* @callback onTokenChange
|
||||
* @param {Object} token the token that was changed
|
||||
*/
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} tokenClassName The class name to attach the interactjs handler to
|
||||
* @param {onProxyDragEnd} onTokenChange Called when the the token data is changed
|
||||
* @param {Object} tokens An mapping of tokens to use as a base when calling onTokenChange
|
||||
* @param {Object} disabledTokens An optional mapping of tokens that shouldn't allow interaction
|
||||
*/
|
||||
function TokenMenu({ tokenClassName, onTokenChange, tokens, disabledTokens }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
function handleRequestClose() {
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
// Store the tokens in a ref and access in the interactjs loop
|
||||
// This is needed to stop interactjs from creating multiple listeners
|
||||
const tokensRef = useRef(tokens);
|
||||
const disabledTokensRef = useRef(disabledTokens);
|
||||
useEffect(() => {
|
||||
tokensRef.current = tokens;
|
||||
disabledTokensRef.current = disabledTokens;
|
||||
}, [tokens, disabledTokens]);
|
||||
|
||||
const [currentToken, setCurrentToken] = useState({});
|
||||
const [menuLeft, setMenuLeft] = useState(0);
|
||||
const [menuTop, setMenuTop] = useState(0);
|
||||
@ -31,30 +52,29 @@ function TokenMenu({ tokenClassName, onTokenChange }) {
|
||||
}
|
||||
|
||||
function handleStatusChange(status) {
|
||||
const statuses =
|
||||
currentToken.status.split(" ").filter((s) => s !== "") || [];
|
||||
const statuses = currentToken.statuses;
|
||||
let newStatuses = [];
|
||||
if (statuses.includes(status)) {
|
||||
newStatuses = statuses.filter((s) => s !== status);
|
||||
} else {
|
||||
newStatuses = [...statuses, status];
|
||||
}
|
||||
const newStatus = newStatuses.join(" ");
|
||||
setCurrentToken((prevToken) => ({
|
||||
...prevToken,
|
||||
status: newStatus,
|
||||
statuses: newStatuses,
|
||||
}));
|
||||
onTokenChange({ ...currentToken, status: newStatus });
|
||||
onTokenChange({ ...currentToken, statuses: newStatuses });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function handleTokenMenuOpen(event) {
|
||||
const target = event.target;
|
||||
const dataset = (target && target.dataset) || {};
|
||||
setCurrentToken({
|
||||
image: target.src,
|
||||
...dataset,
|
||||
});
|
||||
const id = target.getAttribute("data-id");
|
||||
if (id in disabledTokensRef.current) {
|
||||
return;
|
||||
}
|
||||
const token = tokensRef.current[id] || {};
|
||||
setCurrentToken(token);
|
||||
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
setMenuLeft(targetRect.left);
|
||||
@ -162,7 +182,7 @@ function TokenMenu({ tokenClassName, onTokenChange }) {
|
||||
onClick={() => handleStatusChange(color)}
|
||||
aria-label={`Token label Color ${color}`}
|
||||
>
|
||||
{currentToken.status && currentToken.status.includes(color) && (
|
||||
{currentToken.statuses && currentToken.statuses.includes(color) && (
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { Box } from "theme-ui";
|
||||
|
||||
import colors from "../helpers/colors";
|
||||
import colors from "../../helpers/colors";
|
||||
|
||||
function TokenStatus({ statuses }) {
|
||||
return (
|
@ -1,28 +1,34 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useState, useContext } from "react";
|
||||
import { Box } from "theme-ui";
|
||||
import shortid from "shortid";
|
||||
import SimpleBar from "simplebar-react";
|
||||
|
||||
import * as tokens from "../tokens";
|
||||
|
||||
import ListToken from "./ListToken";
|
||||
import ProxyToken from "./ProxyToken";
|
||||
import NumberInput from "./NumberInput";
|
||||
import NumberInput from "../NumberInput";
|
||||
|
||||
import { fromEntries } from "../../helpers/shared";
|
||||
|
||||
import AuthContext from "../../contexts/AuthContext";
|
||||
|
||||
const listTokenClassName = "list-token";
|
||||
|
||||
function Tokens({ onCreateMapToken }) {
|
||||
function Tokens({ onCreateMapTokenState, tokens }) {
|
||||
const [tokenSize, setTokenSize] = useState(1);
|
||||
const { userId } = useContext(AuthContext);
|
||||
|
||||
function handleProxyDragEnd(isOnMap, token) {
|
||||
if (isOnMap && onCreateMapToken) {
|
||||
// Give the token an id
|
||||
onCreateMapToken({
|
||||
...token,
|
||||
if (isOnMap && onCreateMapTokenState) {
|
||||
// Create a token state from the dragged token
|
||||
onCreateMapTokenState({
|
||||
id: shortid.generate(),
|
||||
tokenId: token.id,
|
||||
owner: userId,
|
||||
size: tokenSize,
|
||||
label: "",
|
||||
status: "",
|
||||
statuses: [],
|
||||
x: token.x,
|
||||
y: token.y,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -38,10 +44,12 @@ function Tokens({ onCreateMapToken }) {
|
||||
}}
|
||||
>
|
||||
<SimpleBar style={{ height: "calc(100% - 58px)", overflowX: "hidden" }}>
|
||||
{Object.entries(tokens).map(([id, image]) => (
|
||||
<Box key={id} my={2} mx={3} sx={{ width: "48px", height: "48px" }}>
|
||||
<ListToken image={image} className={listTokenClassName} />
|
||||
</Box>
|
||||
{tokens.map((token) => (
|
||||
<ListToken
|
||||
key={token.id}
|
||||
token={token}
|
||||
className={listTokenClassName}
|
||||
/>
|
||||
))}
|
||||
</SimpleBar>
|
||||
<Box pt={1} bg="muted" sx={{ height: "58px" }}>
|
||||
@ -57,6 +65,7 @@ function Tokens({ onCreateMapToken }) {
|
||||
<ProxyToken
|
||||
tokenClassName={listTokenClassName}
|
||||
onProxyDragEnd={handleProxyDragEnd}
|
||||
tokens={fromEntries(tokens.map((token) => [token.id, token]))}
|
||||
/>
|
||||
</>
|
||||
);
|
@ -1,4 +1,9 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import shortid from "shortid";
|
||||
|
||||
import { getRandomMonster } from "../helpers/monsters";
|
||||
|
||||
import db from "../database";
|
||||
|
||||
const AuthContext = React.createContext();
|
||||
|
||||
@ -13,7 +18,48 @@ export function AuthProvider({ children }) {
|
||||
|
||||
const [authenticationStatus, setAuthenticationStatus] = useState("unknown");
|
||||
|
||||
const [userId, setUserId] = useState();
|
||||
useEffect(() => {
|
||||
async function loadUserId() {
|
||||
const storedUserId = await db.table("user").get("userId");
|
||||
if (storedUserId) {
|
||||
setUserId(storedUserId.value);
|
||||
} else {
|
||||
const id = shortid.generate();
|
||||
setUserId(id);
|
||||
db.table("user").add({ key: "userId", value: id });
|
||||
}
|
||||
}
|
||||
|
||||
loadUserId();
|
||||
}, []);
|
||||
|
||||
const [nickname, setNickname] = useState("");
|
||||
useEffect(() => {
|
||||
async function loadNickname() {
|
||||
const storedNickname = await db.table("user").get("nickname");
|
||||
if (storedNickname) {
|
||||
setNickname(storedNickname.value);
|
||||
} else {
|
||||
const name = getRandomMonster();
|
||||
setNickname(name);
|
||||
db.table("user").add({ key: "nickname", value: name });
|
||||
}
|
||||
}
|
||||
|
||||
loadNickname();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (nickname !== undefined) {
|
||||
db.table("user").update("nickname", { value: nickname });
|
||||
}
|
||||
}, [nickname]);
|
||||
|
||||
const value = {
|
||||
userId,
|
||||
nickname,
|
||||
setNickname,
|
||||
password,
|
||||
setPassword,
|
||||
authenticationStatus,
|
||||
|
9
src/contexts/MapInteractionContext.js
Normal file
@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
|
||||
const MapInteractionContext = React.createContext({
|
||||
translateRef: null,
|
||||
scaleRef: null,
|
||||
});
|
||||
export const MapInteractionProvider = MapInteractionContext.Provider;
|
||||
|
||||
export default MapInteractionContext;
|
11
src/database.js
Normal file
@ -0,0 +1,11 @@
|
||||
import Dexie from "dexie";
|
||||
|
||||
const db = new Dexie("OwlbearRodeoDB");
|
||||
db.version(1).stores({
|
||||
maps: "id, owner",
|
||||
states: "mapId",
|
||||
tokens: "id, owner",
|
||||
user: "key",
|
||||
});
|
||||
|
||||
export default db;
|
@ -1,8 +1,9 @@
|
||||
import SimplePeer from "simple-peer";
|
||||
import BinaryPack from "js-binarypack";
|
||||
import toBuffer from "blob-to-buffer";
|
||||
import shortid from "shortid";
|
||||
|
||||
import blobToBuffer from "./blobToBuffer";
|
||||
|
||||
// Limit buffer size to 16kb to avoid issues with chrome packet size
|
||||
// http://viblast.com/blog/2015/2/5/webrtc-data-channel-message-size/
|
||||
const MAX_BUFFER_SIZE = 16000;
|
||||
@ -40,13 +41,9 @@ class Peer extends SimplePeer {
|
||||
});
|
||||
}
|
||||
|
||||
sendPackedData(packedData) {
|
||||
toBuffer(packedData, (error, buffer) => {
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
super.send(buffer);
|
||||
});
|
||||
async sendPackedData(packedData) {
|
||||
const buffer = await blobToBuffer(packedData);
|
||||
super.send(buffer);
|
||||
}
|
||||
|
||||
send(data) {
|
||||
|
13
src/helpers/blobToBuffer.js
Normal file
@ -0,0 +1,13 @@
|
||||
async function blobToBuffer(blob) {
|
||||
if (blob.arrayBuffer) {
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
return buffer;
|
||||
} else {
|
||||
const arrayBuffer = await new Response(blob).arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
|
||||
export default blobToBuffer;
|
361
src/helpers/drawing.js
Normal file
@ -0,0 +1,361 @@
|
||||
import simplify from "simplify-js";
|
||||
|
||||
import * as Vector2 from "./vector2";
|
||||
import { toDegrees } from "./shared";
|
||||
import colors from "./colors";
|
||||
|
||||
const snappingThreshold = 1 / 5;
|
||||
export function getBrushPositionForTool(
|
||||
brushPosition,
|
||||
tool,
|
||||
toolSettings,
|
||||
gridSize,
|
||||
shapes
|
||||
) {
|
||||
let position = brushPosition;
|
||||
if (tool === "shape") {
|
||||
const snapped = Vector2.roundTo(position, gridSize);
|
||||
const minGrid = Vector2.min(gridSize);
|
||||
const distance = Vector2.length(Vector2.subtract(snapped, position));
|
||||
if (distance < minGrid * snappingThreshold) {
|
||||
position = snapped;
|
||||
}
|
||||
}
|
||||
if (tool === "fog" && toolSettings.type === "add") {
|
||||
if (toolSettings.useGridSnapping) {
|
||||
position = Vector2.roundTo(position, gridSize);
|
||||
}
|
||||
if (toolSettings.useEdgeSnapping) {
|
||||
const minGrid = Vector2.min(gridSize);
|
||||
let closestDistance = Number.MAX_VALUE;
|
||||
let closestPosition = position;
|
||||
// Find the closest point on all fog shapes
|
||||
for (let shape of shapes) {
|
||||
if (shape.type === "fog") {
|
||||
const points = shape.data.points;
|
||||
const isInShape = Vector2.pointInPolygon(position, points);
|
||||
// Find the closest point to each line of the shape
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const a = points[i];
|
||||
// Wrap around points to the start to account for closed shape
|
||||
const b = points[(i + 1) % points.length];
|
||||
|
||||
const {
|
||||
distance: distanceToLine,
|
||||
point: pointOnLine,
|
||||
} = Vector2.distanceToLine(position, a, b);
|
||||
const isCloseToShape = distanceToLine < minGrid * snappingThreshold;
|
||||
if (
|
||||
(isInShape || isCloseToShape) &&
|
||||
distanceToLine < closestDistance
|
||||
) {
|
||||
closestPosition = pointOnLine;
|
||||
closestDistance = distanceToLine;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
position = closestPosition;
|
||||
}
|
||||
}
|
||||
|
||||
return position;
|
||||
}
|
||||
|
||||
export function getDefaultShapeData(type, brushPosition) {
|
||||
if (type === "circle") {
|
||||
return { x: brushPosition.x, y: brushPosition.y, radius: 0 };
|
||||
} else if (type === "rectangle") {
|
||||
return {
|
||||
x: brushPosition.x,
|
||||
y: brushPosition.y,
|
||||
width: 0,
|
||||
height: 0,
|
||||
};
|
||||
} else if (type === "triangle") {
|
||||
return {
|
||||
points: [
|
||||
{ x: brushPosition.x, y: brushPosition.y },
|
||||
{ x: brushPosition.x, y: brushPosition.y },
|
||||
{ x: brushPosition.x, y: brushPosition.y },
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function getGridScale(gridSize) {
|
||||
if (gridSize.x < gridSize.y) {
|
||||
return { x: gridSize.y / gridSize.x, y: 1 };
|
||||
} else if (gridSize.y < gridSize.x) {
|
||||
return { x: 1, y: gridSize.x / gridSize.y };
|
||||
} else {
|
||||
return { x: 1, y: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
export function getUpdatedShapeData(type, data, brushPosition, gridSize) {
|
||||
const gridScale = getGridScale(gridSize);
|
||||
if (type === "circle") {
|
||||
const dif = Vector2.subtract(brushPosition, {
|
||||
x: data.x,
|
||||
y: data.y,
|
||||
});
|
||||
const scaled = Vector2.multiply(dif, gridScale);
|
||||
const distance = Vector2.length(scaled);
|
||||
return {
|
||||
...data,
|
||||
radius: distance,
|
||||
};
|
||||
} else if (type === "rectangle") {
|
||||
const dif = Vector2.subtract(brushPosition, { x: data.x, y: data.y });
|
||||
return {
|
||||
...data,
|
||||
width: dif.x,
|
||||
height: dif.y,
|
||||
};
|
||||
} else if (type === "triangle") {
|
||||
const points = data.points;
|
||||
const dif = Vector2.subtract(brushPosition, points[0]);
|
||||
// Scale the distance by the grid scale then unscale before adding
|
||||
const scaled = Vector2.multiply(dif, gridScale);
|
||||
const length = Vector2.length(scaled);
|
||||
const direction = Vector2.normalize(scaled);
|
||||
// Get the angle for a triangle who's width is the same as it's length
|
||||
const angle = Math.atan(length / 2 / length);
|
||||
const sideLength = length / Math.cos(angle);
|
||||
|
||||
const leftDir = Vector2.rotateDirection(direction, toDegrees(angle));
|
||||
const rightDir = Vector2.rotateDirection(direction, -toDegrees(angle));
|
||||
|
||||
const leftDirUnscaled = Vector2.divide(leftDir, gridScale);
|
||||
const rightDirUnscaled = Vector2.divide(rightDir, gridScale);
|
||||
|
||||
return {
|
||||
points: [
|
||||
points[0],
|
||||
Vector2.add(Vector2.multiply(leftDirUnscaled, sideLength), points[0]),
|
||||
Vector2.add(Vector2.multiply(rightDirUnscaled, sideLength), points[0]),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const defaultStrokeSize = 1 / 10;
|
||||
export function getStrokeSize(multiplier, gridSize, canvasWidth, canvasHeight) {
|
||||
const gridPixelSize = Vector2.multiply(gridSize, {
|
||||
x: canvasWidth,
|
||||
y: canvasHeight,
|
||||
});
|
||||
return Vector2.min(gridPixelSize) * defaultStrokeSize * multiplier;
|
||||
}
|
||||
|
||||
export function shapeHasFill(shape) {
|
||||
return (
|
||||
shape.type === "fog" ||
|
||||
shape.type === "shape" ||
|
||||
(shape.type === "path" && shape.pathType === "fill")
|
||||
);
|
||||
}
|
||||
|
||||
export function pointsToQuadraticBezier(points) {
|
||||
const quadraticPoints = [];
|
||||
|
||||
// Draw a smooth curve between the points where each control point
|
||||
// is the current point in the array and the next point is the center of
|
||||
// the current point and the next point
|
||||
for (let i = 1; i < points.length - 2; i++) {
|
||||
const start = points[i - 1];
|
||||
const controlPoint = points[i];
|
||||
const next = points[i + 1];
|
||||
const end = Vector2.divide(Vector2.add(controlPoint, next), 2);
|
||||
|
||||
quadraticPoints.push({ start, controlPoint, end });
|
||||
}
|
||||
// Curve through the last two points
|
||||
quadraticPoints.push({
|
||||
start: points[points.length - 2],
|
||||
controlPoint: points[points.length - 1],
|
||||
end: points[points.length - 1],
|
||||
});
|
||||
|
||||
return quadraticPoints;
|
||||
}
|
||||
|
||||
export function pointsToPathSmooth(points, close, canvasWidth, canvasHeight) {
|
||||
const path = new Path2D();
|
||||
if (points.length < 2) {
|
||||
return path;
|
||||
}
|
||||
path.moveTo(points[0].x * canvasWidth, points[0].y * canvasHeight);
|
||||
|
||||
const quadraticPoints = pointsToQuadraticBezier(points);
|
||||
for (let quadPoint of quadraticPoints) {
|
||||
const pointScaled = Vector2.multiply(quadPoint.end, {
|
||||
x: canvasWidth,
|
||||
y: canvasHeight,
|
||||
});
|
||||
const controlScaled = Vector2.multiply(quadPoint.controlPoint, {
|
||||
x: canvasWidth,
|
||||
y: canvasHeight,
|
||||
});
|
||||
path.quadraticCurveTo(
|
||||
controlScaled.x,
|
||||
controlScaled.y,
|
||||
pointScaled.x,
|
||||
pointScaled.y
|
||||
);
|
||||
}
|
||||
|
||||
if (close) {
|
||||
path.closePath();
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
export function pointsToPathSharp(points, close, canvasWidth, canvasHeight) {
|
||||
const path = new Path2D();
|
||||
path.moveTo(points[0].x * canvasWidth, points[0].y * canvasHeight);
|
||||
for (let point of points.slice(1)) {
|
||||
path.lineTo(point.x * canvasWidth, point.y * canvasHeight);
|
||||
}
|
||||
if (close) {
|
||||
path.closePath();
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
export function circleToPath(x, y, radius, canvasWidth, canvasHeight) {
|
||||
const path = new Path2D();
|
||||
const minSide = canvasWidth < canvasHeight ? canvasWidth : canvasHeight;
|
||||
path.arc(
|
||||
x * canvasWidth,
|
||||
y * canvasHeight,
|
||||
radius * minSide,
|
||||
0,
|
||||
2 * Math.PI,
|
||||
true
|
||||
);
|
||||
return path;
|
||||
}
|
||||
|
||||
export function rectangleToPath(
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
canvasWidth,
|
||||
canvasHeight
|
||||
) {
|
||||
const path = new Path2D();
|
||||
path.rect(
|
||||
x * canvasWidth,
|
||||
y * canvasHeight,
|
||||
width * canvasWidth,
|
||||
height * canvasHeight
|
||||
);
|
||||
return path;
|
||||
}
|
||||
|
||||
export function shapeToPath(shape, canvasWidth, canvasHeight) {
|
||||
const data = shape.data;
|
||||
if (shape.type === "path") {
|
||||
return pointsToPathSmooth(
|
||||
data.points,
|
||||
shape.pathType === "fill",
|
||||
canvasWidth,
|
||||
canvasHeight
|
||||
);
|
||||
} else if (shape.type === "shape") {
|
||||
if (shape.shapeType === "circle") {
|
||||
return circleToPath(
|
||||
data.x,
|
||||
data.y,
|
||||
data.radius,
|
||||
canvasWidth,
|
||||
canvasHeight
|
||||
);
|
||||
} else if (shape.shapeType === "rectangle") {
|
||||
return rectangleToPath(
|
||||
data.x,
|
||||
data.y,
|
||||
data.width,
|
||||
data.height,
|
||||
canvasWidth,
|
||||
canvasHeight
|
||||
);
|
||||
} else if (shape.shapeType === "triangle") {
|
||||
return pointsToPathSharp(data.points, true, canvasWidth, canvasHeight);
|
||||
}
|
||||
} else if (shape.type === "fog") {
|
||||
return pointsToPathSharp(
|
||||
shape.data.points,
|
||||
true,
|
||||
canvasWidth,
|
||||
canvasHeight
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function isShapeHovered(
|
||||
shape,
|
||||
context,
|
||||
hoverPosition,
|
||||
canvasWidth,
|
||||
canvasHeight
|
||||
) {
|
||||
const path = shapeToPath(shape, canvasWidth, canvasHeight);
|
||||
if (shapeHasFill(shape)) {
|
||||
return context.isPointInPath(
|
||||
path,
|
||||
hoverPosition.x * canvasWidth,
|
||||
hoverPosition.y * canvasHeight
|
||||
);
|
||||
} else {
|
||||
return context.isPointInStroke(
|
||||
path,
|
||||
hoverPosition.x * canvasWidth,
|
||||
hoverPosition.y * canvasHeight
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function drawShape(shape, context, gridSize, canvasWidth, canvasHeight) {
|
||||
const path = shapeToPath(shape, canvasWidth, canvasHeight);
|
||||
const color = colors[shape.color] || shape.color;
|
||||
const fill = shapeHasFill(shape);
|
||||
|
||||
context.globalAlpha = shape.blend ? 0.5 : 1.0;
|
||||
context.fillStyle = color;
|
||||
context.strokeStyle = color;
|
||||
if (shape.strokeWidth > 0) {
|
||||
context.lineCap = "round";
|
||||
context.lineWidth = getStrokeSize(
|
||||
shape.strokeWidth,
|
||||
gridSize,
|
||||
canvasWidth,
|
||||
canvasHeight
|
||||
);
|
||||
context.stroke(path);
|
||||
}
|
||||
if (fill) {
|
||||
context.fill(path);
|
||||
}
|
||||
}
|
||||
|
||||
const defaultSimplifySize = 1 / 100;
|
||||
export function simplifyPoints(points, gridSize, scale) {
|
||||
return simplify(
|
||||
points,
|
||||
(Vector2.min(gridSize) * defaultSimplifySize) / scale
|
||||
);
|
||||
}
|
||||
|
||||
export function getRelativePointerPosition(event, container) {
|
||||
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 };
|
||||
}
|
||||
}
|
@ -1,98 +0,0 @@
|
||||
import ShapeDetector from "shape-detector";
|
||||
import simplify from "simplify-js";
|
||||
import { normalize, subtract, dot, length } from "./vector2";
|
||||
|
||||
import gestures from "./gesturesData";
|
||||
|
||||
const detector = new ShapeDetector(gestures);
|
||||
|
||||
export function pointsToGesture(points) {
|
||||
return detector.spot(points).pattern;
|
||||
}
|
||||
|
||||
export function getBounds(points) {
|
||||
let minX = Number.MAX_VALUE;
|
||||
let maxX = Number.MIN_VALUE;
|
||||
let minY = Number.MAX_VALUE;
|
||||
let maxY = Number.MIN_VALUE;
|
||||
for (let point of points) {
|
||||
minX = point.x < minX ? point.x : minX;
|
||||
maxX = point.x > maxX ? point.x : maxX;
|
||||
minY = point.y < minY ? point.y : minY;
|
||||
maxY = point.y > maxY ? point.y : maxY;
|
||||
}
|
||||
return { minX, maxX, minY, maxY };
|
||||
}
|
||||
|
||||
function getTrianglePoints(points) {
|
||||
if (points.length < 3) {
|
||||
return points;
|
||||
}
|
||||
|
||||
// Simplify edges up to the average distance between points
|
||||
let perimeterDistance = 0;
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
perimeterDistance += length(subtract(points[i + 1], points[i]));
|
||||
}
|
||||
const averagePointDistance = perimeterDistance / points.length;
|
||||
const simplifiedPoints = simplify(points, averagePointDistance);
|
||||
|
||||
const edges = [];
|
||||
// Find edges of the simplified points that have the highest angular change
|
||||
for (let i = 0; i < simplifiedPoints.length; i++) {
|
||||
// Ensure index loops over to start and end of erray
|
||||
const prevIndex = i - 1 < 0 ? simplifiedPoints.length - i - 1 : i - 1;
|
||||
const nextIndex = (i + 1) % simplifiedPoints.length;
|
||||
|
||||
const prev = normalize(
|
||||
subtract(simplifiedPoints[i], simplifiedPoints[prevIndex])
|
||||
);
|
||||
const next = normalize(
|
||||
subtract(simplifiedPoints[nextIndex], simplifiedPoints[i])
|
||||
);
|
||||
|
||||
const similarity = dot(prev, next);
|
||||
if (similarity < 0.25) {
|
||||
edges.push({ similarity, point: simplifiedPoints[i] });
|
||||
}
|
||||
}
|
||||
|
||||
edges.sort((a, b) => a.similarity - b.similarity);
|
||||
const trianglePoints = edges.slice(0, 3).map((edge) => edge.point);
|
||||
// Return the points with the highest angular change or fallback to a heuristic
|
||||
if (trianglePoints.length === 3) {
|
||||
return trianglePoints;
|
||||
} else {
|
||||
return [
|
||||
{ x: points[0].x, y: points[0].y },
|
||||
{
|
||||
x: points[Math.floor(points.length / 2)].x,
|
||||
y: points[Math.floor(points.length / 2)].y,
|
||||
},
|
||||
{ x: points[points.length - 1].x, y: points[points.length - 1].y },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export function gestureToData(points, gesture) {
|
||||
const bounds = getBounds(points);
|
||||
const width = bounds.maxX - bounds.minX;
|
||||
const height = bounds.maxY - bounds.minY;
|
||||
const maxSide = width > height ? width : height;
|
||||
switch (gesture) {
|
||||
case "rectangle":
|
||||
return { x: bounds.minX, y: bounds.minY, width, height };
|
||||
case "triangle":
|
||||
return {
|
||||
points: getTrianglePoints(points),
|
||||
};
|
||||
case "circle":
|
||||
return {
|
||||
x: bounds.minX + width / 2,
|
||||
y: bounds.minY + height / 2,
|
||||
radius: maxSide / 2,
|
||||
};
|
||||
default:
|
||||
throw Error("Gesture not implemented");
|
||||
}
|
||||
}
|
@ -28,9 +28,10 @@ export function roundTo(x, to) {
|
||||
return Math.round(x / to) * to;
|
||||
}
|
||||
|
||||
export function snapPositionToGrid(position, gridSize) {
|
||||
return {
|
||||
x: roundTo(position.x, gridSize.x),
|
||||
y: roundTo(position.y, gridSize.y),
|
||||
};
|
||||
export function toRadians(angle) {
|
||||
return angle * (Math.PI / 180);
|
||||
}
|
||||
|
||||
export function toDegrees(angle) {
|
||||
return angle * (180 / Math.PI);
|
||||
}
|
||||
|
30
src/helpers/useDataSource.js
Normal file
@ -0,0 +1,30 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// Helper function to load either file or default data
|
||||
// into a URL and ensure that it is revoked if needed
|
||||
function useDataSource(data, defaultSources) {
|
||||
const [dataSource, setDataSource] = useState(null);
|
||||
useEffect(() => {
|
||||
if (!data) {
|
||||
setDataSource(null);
|
||||
return;
|
||||
}
|
||||
let url = null;
|
||||
if (data.type === "file") {
|
||||
url = URL.createObjectURL(data.file);
|
||||
} else if (data.type === "default") {
|
||||
url = defaultSources[data.key];
|
||||
}
|
||||
setDataSource(url);
|
||||
|
||||
return () => {
|
||||
if (data.type === "file" && url) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
};
|
||||
}, [data, defaultSources]);
|
||||
|
||||
return dataSource;
|
||||
}
|
||||
|
||||
export default useDataSource;
|
18
src/helpers/useDebounce.js
Normal file
@ -0,0 +1,18 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
function useDebounce(value, delay) {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
export default useDebounce;
|
@ -1,16 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getRandomMonster } from "./monsters";
|
||||
|
||||
function useNickname() {
|
||||
const [nickname, setNickname] = useState(
|
||||
localStorage.getItem("nickname") || getRandomMonster()
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("nickname", nickname);
|
||||
}, [nickname]);
|
||||
|
||||
return { nickname, setNickname };
|
||||
}
|
||||
|
||||
export default useNickname;
|
11
src/helpers/usePrevious.js
Normal file
@ -0,0 +1,11 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
function usePrevious(value) {
|
||||
const ref = useRef();
|
||||
useEffect(() => {
|
||||
ref.current = value;
|
||||
}, [value]);
|
||||
return ref.current;
|
||||
}
|
||||
|
||||
export default usePrevious;
|
@ -6,7 +6,7 @@ import Peer from "../helpers/Peer";
|
||||
|
||||
import AuthContext from "../contexts/AuthContext";
|
||||
|
||||
const socket = io("https://broker.owlbear.rodeo");
|
||||
const socket = io("https://agent.owlbear.rodeo");
|
||||
|
||||
function useSession(
|
||||
partyId,
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { toRadians, roundTo as roundToNumber } from "./shared";
|
||||
|
||||
export function lengthSquared(p) {
|
||||
return p.x * p.x + p.y * p.y;
|
||||
}
|
||||
@ -8,7 +10,7 @@ export function length(p) {
|
||||
|
||||
export function normalize(p) {
|
||||
const l = length(p);
|
||||
return { x: p.x / l, y: p.y / l };
|
||||
return divide(p, l);
|
||||
}
|
||||
|
||||
export function dot(a, b) {
|
||||
@ -16,5 +18,201 @@ export function dot(a, b) {
|
||||
}
|
||||
|
||||
export function subtract(a, b) {
|
||||
return { x: a.x - b.x, y: a.y - b.y };
|
||||
if (typeof b === "number") {
|
||||
return { x: a.x - b, y: a.y - b };
|
||||
} else {
|
||||
return { x: a.x - b.x, y: a.y - b.y };
|
||||
}
|
||||
}
|
||||
|
||||
export function add(a, b) {
|
||||
if (typeof b === "number") {
|
||||
return { x: a.x + b, y: a.y + b };
|
||||
} else {
|
||||
return { x: a.x + b.x, y: a.y + b.y };
|
||||
}
|
||||
}
|
||||
|
||||
export function multiply(a, b) {
|
||||
if (typeof b === "number") {
|
||||
return { x: a.x * b, y: a.y * b };
|
||||
} else {
|
||||
return { x: a.x * b.x, y: a.y * b.y };
|
||||
}
|
||||
}
|
||||
|
||||
export function divide(a, b) {
|
||||
if (typeof b === "number") {
|
||||
return { x: a.x / b, y: a.y / b };
|
||||
} else {
|
||||
return { x: a.x / b.x, y: a.y / b.y };
|
||||
}
|
||||
}
|
||||
|
||||
export function rotate(point, origin, angle) {
|
||||
const cos = Math.cos(toRadians(angle));
|
||||
const sin = Math.sin(toRadians(angle));
|
||||
const dif = subtract(point, origin);
|
||||
return {
|
||||
x: origin.x + cos * dif.x - sin * dif.y,
|
||||
y: origin.y + sin * dif.x + cos * dif.y,
|
||||
};
|
||||
}
|
||||
|
||||
export function rotateDirection(direction, angle) {
|
||||
return rotate(direction, { x: 0, y: 0 }, angle);
|
||||
}
|
||||
|
||||
export function min(a) {
|
||||
return a.x < a.y ? a.x : a.y;
|
||||
}
|
||||
|
||||
export function max(a) {
|
||||
return a.x > a.y ? a.x : a.y;
|
||||
}
|
||||
|
||||
export function roundTo(p, to) {
|
||||
return {
|
||||
x: roundToNumber(p.x, to.x),
|
||||
y: roundToNumber(p.y, to.y),
|
||||
};
|
||||
}
|
||||
|
||||
export function sign(a) {
|
||||
return { x: Math.sign(a.x), y: Math.sign(a.y) };
|
||||
}
|
||||
|
||||
export function abs(a) {
|
||||
return { x: Math.abs(a.x), y: Math.abs(a.y) };
|
||||
}
|
||||
|
||||
export function pow(a, b) {
|
||||
if (typeof b === "number") {
|
||||
return { x: Math.pow(a.x, b), y: Math.pow(a.y, b) };
|
||||
} else {
|
||||
return { x: Math.pow(a.x, b.x), y: Math.pow(a.y, b.y) };
|
||||
}
|
||||
}
|
||||
|
||||
export function dot2(a) {
|
||||
return dot(a, a);
|
||||
}
|
||||
|
||||
export function clamp(a, min, max) {
|
||||
return {
|
||||
x: Math.min(Math.max(a.x, min), max),
|
||||
y: Math.min(Math.max(a.y, min), max),
|
||||
};
|
||||
}
|
||||
|
||||
// https://iquilezles.org/www/articles/distfunctions2d/distfunctions2d
|
||||
export function distanceToLine(p, a, b) {
|
||||
const pa = subtract(p, a);
|
||||
const ba = subtract(b, a);
|
||||
const h = Math.min(Math.max(dot(pa, ba) / dot(ba, ba), 0), 1);
|
||||
const distance = length(subtract(pa, multiply(ba, h)));
|
||||
const point = add(a, multiply(ba, h));
|
||||
return { distance, point };
|
||||
}
|
||||
|
||||
// TODO: Fix the robustness of this to allow smoothing on fog layers
|
||||
// https://www.shadertoy.com/view/MlKcDD
|
||||
export function distanceToQuadraticBezier(pos, A, B, C) {
|
||||
let distance = 0;
|
||||
let point = { x: pos.x, y: pos.y };
|
||||
|
||||
const a = subtract(B, A);
|
||||
const b = add(subtract(A, multiply(B, 2)), C);
|
||||
const c = multiply(a, 2);
|
||||
const d = subtract(A, pos);
|
||||
|
||||
// Solve cubic roots to find closest points
|
||||
const kk = 1 / dot(b, b);
|
||||
const kx = kk * dot(a, b);
|
||||
const ky = (kk * (2 * dot(a, a) + dot(d, b))) / 3;
|
||||
const kz = kk * dot(d, a);
|
||||
|
||||
const p = ky - kx * kx;
|
||||
const p3 = p * p * p;
|
||||
const q = kx * (2 * kx * kx - 3 * ky) + kz;
|
||||
let h = q * q + 4 * p3;
|
||||
|
||||
if (h >= 0) {
|
||||
// 1 root
|
||||
h = Math.sqrt(h);
|
||||
const x = divide(subtract({ x: h, y: -h }, q), 2);
|
||||
const uv = multiply(sign(x), pow(abs(x), 1 / 3));
|
||||
const t = Math.min(Math.max(uv.x + uv.y - kx, 0), 1);
|
||||
point = add(A, multiply(add(c, multiply(b, t)), t));
|
||||
distance = dot2(add(d, multiply(add(c, multiply(b, t)), t)));
|
||||
} else {
|
||||
// 3 roots but ignore the 3rd one as it will never be closest
|
||||
// https://www.shadertoy.com/view/MdXBzB
|
||||
const z = Math.sqrt(-p);
|
||||
const v = Math.acos(q / (p * z * 2)) / 3;
|
||||
const m = Math.cos(v);
|
||||
const n = Math.sin(v) * 1.732050808;
|
||||
|
||||
const t = clamp(subtract(multiply({ x: m + m, y: -n - m }, z), kx), 0, 1);
|
||||
const d1 = dot2(add(d, multiply(add(c, multiply(b, t.x)), t.x)));
|
||||
const d2 = dot2(add(d, multiply(add(c, multiply(b, t.y)), t.y)));
|
||||
distance = Math.min(d1, d2);
|
||||
if (d1 < d2) {
|
||||
point = add(d, multiply(add(c, multiply(b, t.x)), t.x));
|
||||
} else {
|
||||
point = add(d, multiply(add(c, multiply(b, t.y)), t.y));
|
||||
}
|
||||
}
|
||||
|
||||
return { distance: Math.sqrt(distance), point: point };
|
||||
}
|
||||
|
||||
export function getBounds(points) {
|
||||
let minX = Number.MAX_VALUE;
|
||||
let maxX = Number.MIN_VALUE;
|
||||
let minY = Number.MAX_VALUE;
|
||||
let maxY = Number.MIN_VALUE;
|
||||
for (let point of points) {
|
||||
minX = point.x < minX ? point.x : minX;
|
||||
maxX = point.x > maxX ? point.x : maxX;
|
||||
minY = point.y < minY ? point.y : minY;
|
||||
maxY = point.y > maxY ? point.y : maxY;
|
||||
}
|
||||
return { minX, maxX, minY, maxY };
|
||||
}
|
||||
|
||||
// Check bounds then use ray casting algorithm
|
||||
// https://en.wikipedia.org/wiki/Point_in_polygon#Ray_casting_algorithm
|
||||
// https://stackoverflow.com/questions/217578/how-can-i-determine-whether-a-2d-point-is-within-a-polygon/2922778
|
||||
export function pointInPolygon(p, points) {
|
||||
const { minX, maxX, minY, maxY } = getBounds(points);
|
||||
if (p.x < minX || p.x > maxX || p.y < minY || p.y > maxY) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let isInside = false;
|
||||
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
|
||||
const a = points[i].y > p.y;
|
||||
const b = points[j].y > p.y;
|
||||
if (
|
||||
a !== b &&
|
||||
p.x <
|
||||
((points[j].x - points[i].x) * (p.y - points[i].y)) /
|
||||
(points[j].y - points[i].y) +
|
||||
points[i].x
|
||||
) {
|
||||
isInside = !isInside;
|
||||
}
|
||||
}
|
||||
return isInside;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if a the distance between a and b is under threshold
|
||||
* @param {Vector2} a
|
||||
* @param {Vector2} b
|
||||
* @param {number} threshold
|
||||
*/
|
||||
export function compare(a, b, threshold) {
|
||||
return lengthSquared(subtract(a, b)) < threshold * threshold;
|
||||
}
|
||||
|
20
src/icons/AddIcon.js
Normal file
@ -0,0 +1,20 @@
|
||||
import React from "react";
|
||||
|
||||
function AddIcon({ large }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height={large ? "32" : "24"}
|
||||
viewBox="0 0 24 24"
|
||||
width={large ? "32" : "24"}
|
||||
fill="currentcolor"
|
||||
>
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm4 11h-3v3c0 .55-.45 1-1 1s-1-.45-1-1v-3H8c-.55 0-1-.45-1-1s.45-1 1-1h3V8c0-.55.45-1 1-1s1 .45 1 1v3h3c.55 0 1 .45 1 1s-.45 1-1 1z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
AddIcon.defaultProps = { large: false };
|
||||
|
||||
export default AddIcon;
|
@ -1,18 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
function AddMapIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
fill="currentcolor"
|
||||
>
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
<path d="M21.02 5H19V2.98c0-.54-.44-.98-.98-.98h-.03c-.55 0-.99.44-.99.98V5h-2.01c-.54 0-.98.44-.99.98v.03c0 .55.44.99.99.99H17v2.01c0 .54.44.99.99.98h.03c.54 0 .98-.44.98-.98V7h2.02c.54 0 .98-.44.98-.98v-.04c0-.54-.44-.98-.98-.98zM16 9.01V8h-1.01c-.53 0-1.03-.21-1.41-.58-.37-.38-.58-.88-.58-1.44 0-.36.1-.69.27-.98H5c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2v-8.28c-.3.17-.64.28-1.02.28-1.09-.01-1.98-.9-1.98-1.99zM15.96 19H6c-.41 0-.65-.47-.4-.8l1.98-2.63c.21-.28.62-.26.82.02L10 18l2.61-3.48c.2-.26.59-.27.79-.01l2.95 3.68c.26.33.03.81-.39.81z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddMapIcon;
|
@ -9,8 +9,8 @@ function BlendOnIcon() {
|
||||
width="24"
|
||||
fill="currentcolor"
|
||||
>
|
||||
<path d="M24 0H0v24h24V0zm0 0H0v24h24V0zM0 24h24V0H0v24z" fill="none" />
|
||||
<path d="M17.66 8l-4.95-4.94c-.39-.39-1.02-.39-1.41 0L6.34 8C4.78 9.56 4 11.64 4 13.64s.78 4.11 2.34 5.67 3.61 2.35 5.66 2.35 4.1-.79 5.66-2.35S20 15.64 20 13.64 19.22 9.56 17.66 8zM6 14c.01-2 .62-3.27 1.76-4.4L12 5.27l4.24 4.38C17.38 10.77 17.99 12 18 14H6z" />
|
||||
<path d="M24 0H0v24h24V0z" fill="none" />
|
||||
<path d="M6.34 7.93c-3.12 3.12-3.12 8.19 0 11.31C7.9 20.8 9.95 21.58 12 21.58s4.1-.78 5.66-2.34c3.12-3.12 3.12-8.19 0-11.31l-4.95-4.95c-.39-.39-1.02-.39-1.41 0L6.34 7.93zM12 19.59c-1.6 0-3.11-.62-4.24-1.76C6.62 16.69 6 15.19 6 13.59s.62-3.11 1.76-4.24L12 5.1v14.49z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
18
src/icons/BrushFillIcon.js
Normal file
@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
|
||||
function BrushFillIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
fill="currentcolor"
|
||||
>
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
<path d="M18 4V3c0-.55-.45-1-1-1H5c-.55 0-1 .45-1 1v4c0 .55.45 1 1 1h12c.55 0 1-.45 1-1V6h1v4h-9c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h2c.55 0 1-.45 1-1v-9h7c.55 0 1-.45 1-1V5c0-.55-.45-1-1-1h-2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default BrushFillIcon;
|
18
src/icons/BrushStrokeIcon.js
Normal file
@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
|
||||
function BrushStrokeIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
fill="currentcolor"
|
||||
>
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
<path d="M7 14c-1.66 0-3 1.34-3 3 0 1.31-1.16 2-2 2 .92 1.22 2.49 2 4 2 2.21 0 4-1.79 4-4 0-1.66-1.34-3-3-3zm13.71-9.37l-1.34-1.34c-.39-.39-1.02-.39-1.41 0L9 12.25 11.75 15l8.96-8.96c.39-.39.39-1.02 0-1.41z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default BrushStrokeIcon;
|
18
src/icons/ExpandMoreDotIcon.js
Normal file
@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
|
||||
function ExpandMoreDotIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
fill="currentcolor"
|
||||
>
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExpandMoreDotIcon;
|
18
src/icons/FogAddIcon.js
Normal file
@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
|
||||
function FogAddIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
fill="currentcolor"
|
||||
>
|
||||
<path d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M19.35 10.04A7.49 7.49 0 0012 4C9.11 4 6.6 5.64 5.35 8.04A5.994 5.994 0 000 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM16 14h-3v3c0 .55-.45 1-1 1s-1-.45-1-1v-3H8c-.55 0-1-.45-1-1s.45-1 1-1h3V9c0-.55.45-1 1-1s1 .45 1 1v3h3c.55 0 1 .45 1 1s-.45 1-1 1z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default FogAddIcon;
|
18
src/icons/FogRemoveIcon.js
Normal file
@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
|
||||
function FogRemoveIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
fill="currentcolor"
|
||||
>
|
||||
<path d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M12 4a7.49 7.49 0 017.35 6.04c2.6.18 4.65 2.32 4.65 4.96 0 2.76-2.24 5-5 5H6c-3.31 0-6-2.69-6-6 0-3.09 2.34-5.64 5.35-5.96A7.496 7.496 0 0112 4zm4 8H8c-.55 0-1 .45-1 1s.45 1 1 1h8c.55 0 1-.45 1-1s-.45-1-1-1z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default FogRemoveIcon;
|
18
src/icons/FogToggleIcon.js
Normal file
@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
|
||||
function FogToggleIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
fill="currentcolor"
|
||||
>
|
||||
<path d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M12 4a7.49 7.49 0 017.35 6.04c2.6.18 4.65 2.32 4.65 4.96 0 2.76-2.24 5-5 5H6c-3.31 0-6-2.69-6-6 0-3.09 2.34-5.64 5.35-5.96A7.496 7.496 0 0112 4zm0 2a5.493 5.493 0 00-4.759 2.75l-.117.214-.496.951-1.067.114A3.994 3.994 0 002 14a4.005 4.005 0 003.8 3.995L6 18h6.042v-.103L12 6z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default FogToggleIcon;
|
18
src/icons/FogToolIcon.js
Normal file
@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
|
||||
function FogToolIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
fill="currentcolor"
|
||||
>
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
<path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default FogToolIcon;
|
@ -8,8 +8,6 @@ function GridOffIcon() {
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
fill="currentcolor"
|
||||
// Fixes bug with not firing click event when used in a button
|
||||
style={{ pointerEvents: "none" }}
|
||||
>
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
<path d="M8 4v.89l2 2V4h4v4h-2.89l2 2H14v.89l2 2V10h4v4h-2.89l2 2H20v.89l2 2V4c0-1.1-.9-2-2-2H5.11l2 2H8zm8 0h3c.55 0 1 .45 1 1v3h-4V4zm6.16 17.88L2.12 1.84c-.39-.39-1.02-.39-1.41 0-.39.39-.39 1.02 0 1.41L2 4.55V20c0 1.1.9 2 2 2h15.45l1.3 1.3c.39.39 1.02.39 1.41 0 .39-.39.39-1.03 0-1.42zM10 12.55L11.45 14H10v-1.45zm-6-6L5.45 8H4V6.55zM8 20H5c-.55 0-1-.45-1-1v-3h4v4zm0-6H4v-4h3.45l.55.55V14zm6 6h-4v-4h3.45l.55.55V20zm2 0v-1.45L17.45 20H16z" />
|
||||
|
@ -8,8 +8,6 @@ function GridOnIcon() {
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
fill="currentcolor"
|
||||
// Fixes bug with not firing click event when used in a button
|
||||
style={{ pointerEvents: "none" }}
|
||||
>
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
<path d="M20 2H4c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM8 20H5c-.55 0-1-.45-1-1v-3h4v4zm0-6H4v-4h4v4zm0-6H4V5c0-.55.45-1 1-1h3v4zm6 12h-4v-4h4v4zm0-6h-4v-4h4v4zm0-6h-4V4h4v4zm5 12h-3v-4h4v3c0 .55-.45 1-1 1zm1-6h-4v-4h4v4zm0-6h-4V4h3c.55 0 1 .45 1 1v3z" />
|
||||
|
18
src/icons/RemoveMapIcon.js
Normal file
@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
|
||||
function RemoveMapIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
fill="currentcolor"
|
||||
>
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V9c0-1.1-.9-2-2-2H8c-1.1 0-2 .9-2 2v10zM18 4h-2.5l-.71-.71c-.18-.18-.44-.29-.7-.29H9.91c-.26 0-.52.11-.7.29L8.5 4H6c-.55 0-1 .45-1 1s.45 1 1 1h12c.55 0 1-.45 1-1s-.45-1-1-1z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default RemoveMapIcon;
|
18
src/icons/ResetMapIcon.js
Normal file
@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
|
||||
function ResetMapIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
fill="currentcolor"
|
||||
>
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
<path d="M17.65 6.35c-1.63-1.63-3.94-2.57-6.48-2.31-3.67.37-6.69 3.35-7.1 7.02C3.52 15.91 7.27 20 12 20c3.19 0 5.93-1.87 7.21-4.56.32-.67-.16-1.44-.9-1.44-.37 0-.72.2-.88.53-1.13 2.43-3.84 3.97-6.8 3.31-2.22-.49-4.01-2.3-4.48-4.52C5.31 9.44 8.26 6 12 6c1.66 0 3.14.69 4.22 1.78l-1.51 1.51c-.63.63-.19 1.71.7 1.71H19c.55 0 1-.45 1-1V6.41c0-.89-1.08-1.34-1.71-.71l-.64.65z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default ResetMapIcon;
|
18
src/icons/SelectMapIcon.js
Normal file
@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
|
||||
function SelectMapIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
fill="currentcolor"
|
||||
>
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
<path d="M22 16V4c0-1.1-.9-2-2-2H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2zm-10.6-3.47l1.63 2.18 2.58-3.22c.2-.25.58-.25.78 0l2.96 3.7c.26.33.03.81-.39.81H9c-.41 0-.65-.47-.4-.8l2-2.67c.2-.26.6-.26.8 0zM2 7v13c0 1.1.9 2 2 2h13c.55 0 1-.45 1-1s-.45-1-1-1H5c-.55 0-1-.45-1-1V7c0-.55-.45-1-1-1s-1 .45-1 1z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default SelectMapIcon;
|
18
src/icons/SettingsIcon.js
Normal file
@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
|
||||
function SettingsIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
fill="currentcolor"
|
||||
>
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
<path d="M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.3-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.23.09.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default SettingsIcon;
|
18
src/icons/ShapeCircleIcon.js
Normal file
@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
|
||||
function ShapeCircleIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
fill="currentcolor"
|
||||
>
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default ShapeCircleIcon;
|
18
src/icons/ShapeRectangleIcon.js
Normal file
@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
|
||||
function ShapeRectangleIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
fill="currentcolor"
|
||||
>
|
||||
<path d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M19 3H5a2 2 0 00-2 2v14a2 2 0 002 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default ShapeRectangleIcon;
|
20
src/icons/ShapeToolIcon.js
Normal file
@ -0,0 +1,20 @@
|
||||
import React from "react";
|
||||
|
||||
function ShapeToolIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
fill="currentcolor"
|
||||
>
|
||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
||||
<path d="M11.15 3.4L7.43 9.48c-.41.66.07 1.52.85 1.52h7.43c.78 0 1.26-.86.85-1.52L12.85 3.4c-.39-.64-1.31-.64-1.7 0z" />
|
||||
<circle cx="17.5" cy="17.5" r="4.5" />
|
||||
<path d="M4 21.5h6c.55 0 1-.45 1-1v-6c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v6c0 .55.45 1 1 1z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default ShapeToolIcon;
|
18
src/icons/ShapeTriangleIcon.js
Normal file
@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
|
||||
function ShapeTriangleIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
fill="currentcolor"
|
||||
>
|
||||
<path d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M2.73 21h18.53c.77 0 1.25-.83.87-1.5l-9.27-16a.996.996 0 00-1.73 0l-9.27 16c-.38.67.1 1.5.87 1.5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default ShapeTriangleIcon;
|
18
src/icons/SnappingOffIcon.js
Normal file
@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
|
||||
function SnappingOffIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
fill="currentcolor"
|
||||
>
|
||||
<path d="M24 24H0V0h24z" fill="none" />
|
||||
<path d="M12 21c-5.364 0-8.873-3.694-8.997-8.724L3 12V6.224L1.105 4.661a1 1 0 111.273-1.543l18.519 15.266a1 1 0 01-1.273 1.543l-1.444-1.192C16.701 20.125 14.641 21 12 21zm0-6.014c.404 0 .85-.098 1.273-.294L9 11.17 9 12c0 1.983 1.716 2.986 3 2.986zM9 6.262l-1.999-1.64L7 4.213l-.012-.002-.02-.014a1.157 1.157 0 00-.411-.188L6.483 4h-.238L4.382 2.472c.42-.268.912-.435 1.441-.466L6 2h.483c1.054 0 2.4.827 2.51 1.864L9 4v2.262zm11.302 9.276L15 11.187V4c0-1.05.82-1.918 1.851-1.994L17 2h1c1.6 0 2.904 1.246 2.995 2.823L21 5v6.69c0 1.334-.233 2.647-.698 3.848zM17 4v4.027l1.6-.012c.236-.004.4-.01.4-.02V5a.996.996 0 00-.883-.993L18 4h-1z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default SnappingOffIcon;
|
18
src/icons/SnappingOnIcon.js
Normal file
@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
|
||||
function SnappingOnIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
fill="currentcolor"
|
||||
>
|
||||
<path d="M24 24H0V0h24z" fill="none" />
|
||||
<path d="M12 21c-5.462 0-9-3.83-9-9V5c0-1.66 1.34-3 3-3h.483C7.583 2 9 2.9 9 4v8c0 1.983 1.716 2.986 3 2.986S15 14 15 12V4c0-1.1.9-2 2-2h1c1.66 0 3 1.34 3 3v6.69c0 4.79-3 9.31-9 9.31zm5-12.973h.28c.55-.002 1.72-.008 1.72-.032V5a.996.996 0 00-.883-.993L18 4h-1v4.027zM5 8.014l.064-.001L7 7.987V4.213l-.012-.002-.02-.014a1.157 1.157 0 00-.411-.188L6.483 4H6a.996.996 0 00-.993.883L5 5v3.014z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default SnappingOnIcon;
|
BIN
src/images/DiagonalPattern.png
Normal file
After Width: | Height: | Size: 4.7 KiB |
BIN
src/maps/Blank Grid 22x22.jpg
Executable file
After Width: | Height: | Size: 89 KiB |
BIN
src/maps/Grass Grid 22x22.jpg
Executable file
After Width: | Height: | Size: 106 KiB |
BIN
src/maps/Sand Grid 22x22.jpg
Executable file
After Width: | Height: | Size: 110 KiB |
BIN
src/maps/Stone Grid 22x22.jpg
Executable file
After Width: | Height: | Size: 169 KiB |
BIN
src/maps/Water Grid 22x22.jpg
Executable file
After Width: | Height: | Size: 171 KiB |
BIN
src/maps/Wood Grid 22x22.jpg
Executable file
After Width: | Height: | Size: 180 KiB |
25
src/maps/index.js
Normal file
@ -0,0 +1,25 @@
|
||||
import blankImage from "./Blank Grid 22x22.jpg";
|
||||
import grassImage from "./Grass Grid 22x22.jpg";
|
||||
import sandImage from "./Sand Grid 22x22.jpg";
|
||||
import stoneImage from "./Stone Grid 22x22.jpg";
|
||||
import waterImage from "./Water Grid 22x22.jpg";
|
||||
import woodImage from "./Wood Grid 22x22.jpg";
|
||||
|
||||
export const mapSources = {
|
||||
blank: blankImage,
|
||||
grass: grassImage,
|
||||
sand: sandImage,
|
||||
stone: stoneImage,
|
||||
water: waterImage,
|
||||
wood: woodImage,
|
||||
};
|
||||
|
||||
export const maps = Object.keys(mapSources).map((key) => ({
|
||||
key,
|
||||
name: key.charAt(0).toUpperCase() + key.slice(1),
|
||||
gridX: 22,
|
||||
gridY: 22,
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
type: "default",
|
||||
}));
|
@ -1,179 +0,0 @@
|
||||
import React, { useRef, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Image as UIImage,
|
||||
Flex,
|
||||
Label,
|
||||
Input,
|
||||
Text,
|
||||
} from "theme-ui";
|
||||
|
||||
import Modal from "../components/Modal";
|
||||
|
||||
function AddMapModal({
|
||||
isOpen,
|
||||
onRequestClose,
|
||||
onDone,
|
||||
onImageUpload,
|
||||
gridX,
|
||||
onGridXChange,
|
||||
gridY,
|
||||
onGridYChange,
|
||||
imageLoaded,
|
||||
mapSource,
|
||||
}) {
|
||||
const fileInputRef = useRef();
|
||||
|
||||
function handleImageUpload(file) {
|
||||
if (file.name) {
|
||||
// Match against a regex to find the grid size in the file name
|
||||
// e.g. Cave 22x23 will return [["22x22", "22", "x", "23"]]
|
||||
const gridMatches = [...file.name.matchAll(/(\d+) ?(x|X) ?(\d+)/g)];
|
||||
if (gridMatches.length > 0) {
|
||||
const lastMatch = gridMatches[gridMatches.length - 1];
|
||||
const matchX = parseInt(lastMatch[1]);
|
||||
const matchY = parseInt(lastMatch[3]);
|
||||
if (!isNaN(matchX) && !isNaN(matchY)) {
|
||||
onImageUpload(file, matchX, matchY);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
onImageUpload(file);
|
||||
}
|
||||
|
||||
function openImageDialog() {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
}
|
||||
|
||||
const [dragging, setDragging] = useState(false);
|
||||
function handleImageDragEnter(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setDragging(true);
|
||||
}
|
||||
|
||||
function handleImageDragLeave(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setDragging(false);
|
||||
}
|
||||
|
||||
function handleImageDrop(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const file = event.dataTransfer.files[0];
|
||||
if (file && file.type.startsWith("image")) {
|
||||
handleImageUpload(file);
|
||||
}
|
||||
setDragging(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onRequestClose={onRequestClose}>
|
||||
<Box
|
||||
as="form"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onDone();
|
||||
}}
|
||||
onDragEnter={handleImageDragEnter}
|
||||
>
|
||||
<input
|
||||
onChange={(event) => handleImageUpload(event.target.files[0])}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
ref={fileInputRef}
|
||||
/>
|
||||
|
||||
<Flex
|
||||
sx={{
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Label pt={2} pb={1}>
|
||||
Add map
|
||||
</Label>
|
||||
<UIImage
|
||||
my={2}
|
||||
sx={{
|
||||
width: "500px",
|
||||
minHeight: "200px",
|
||||
maxHeight: "300px",
|
||||
objectFit: "contain",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
src={mapSource}
|
||||
onClick={openImageDialog}
|
||||
bg="muted"
|
||||
/>
|
||||
<Flex>
|
||||
<Box mb={2} mr={1} sx={{ flexGrow: 1 }}>
|
||||
<Label htmlFor="gridX">Columns</Label>
|
||||
<Input
|
||||
type="number"
|
||||
name="gridX"
|
||||
value={gridX}
|
||||
onChange={(e) => onGridXChange(e.target.value)}
|
||||
/>
|
||||
</Box>
|
||||
<Box mb={2} ml={1} sx={{ flexGrow: 1 }}>
|
||||
<Label htmlFor="gridY">Rows</Label>
|
||||
<Input
|
||||
type="number"
|
||||
name="gridY"
|
||||
value={gridY}
|
||||
onChange={(e) => onGridYChange(e.target.value)}
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
{mapSource ? (
|
||||
<Button variant="primary" disabled={!imageLoaded}>
|
||||
Done
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
varient="primary"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
openImageDialog();
|
||||
}}
|
||||
>
|
||||
Select Image
|
||||
</Button>
|
||||
)}
|
||||
{dragging && (
|
||||
<Flex
|
||||
bg="muted"
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
cursor: "copy",
|
||||
}}
|
||||
onDragLeave={handleImageDragLeave}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}}
|
||||
onDrop={handleImageDrop}
|
||||
>
|
||||
<Text sx={{ pointerEvents: "none" }}>Drop map to upload</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddMapModal;
|
346
src/modals/SelectMapModal.js
Normal file
@ -0,0 +1,346 @@
|
||||
import React, { useRef, useState, useEffect, useContext } from "react";
|
||||
import { Box, Button, Flex, Label, Text } from "theme-ui";
|
||||
import shortid from "shortid";
|
||||
|
||||
import db from "../database";
|
||||
|
||||
import Modal from "../components/Modal";
|
||||
import MapTiles from "../components/map/MapTiles";
|
||||
import MapSettings from "../components/map/MapSettings";
|
||||
|
||||
import AuthContext from "../contexts/AuthContext";
|
||||
|
||||
import usePrevious from "../helpers/usePrevious";
|
||||
|
||||
import { maps as defaultMaps } from "../maps";
|
||||
|
||||
const defaultMapSize = 22;
|
||||
const defaultMapState = {
|
||||
tokens: {},
|
||||
// An index into the draw actions array to which only actions before the
|
||||
// index will be performed (used in undo and redo)
|
||||
mapDrawActionIndex: -1,
|
||||
mapDrawActions: [],
|
||||
fogDrawActionIndex: -1,
|
||||
fogDrawActions: [],
|
||||
// Flags to determine what other people can edit
|
||||
editFlags: ["drawing", "tokens"],
|
||||
};
|
||||
|
||||
const defaultMapProps = {
|
||||
// Grid type
|
||||
// TODO: add support for hex horizontal and hex vertical
|
||||
gridType: "grid",
|
||||
};
|
||||
|
||||
function SelectMapModal({
|
||||
isOpen,
|
||||
onRequestClose,
|
||||
onDone,
|
||||
onMapChange,
|
||||
onMapStateChange,
|
||||
// The map currently being view in the map screen
|
||||
currentMap,
|
||||
}) {
|
||||
const { userId } = useContext(AuthContext);
|
||||
|
||||
const wasOpen = usePrevious(isOpen);
|
||||
|
||||
const [imageLoading, setImageLoading] = useState(false);
|
||||
|
||||
// The map selected in the modal
|
||||
const [selectedMap, setSelectedMap] = useState(null);
|
||||
const [selectedMapState, setSelectedMapState] = useState(null);
|
||||
const [maps, setMaps] = useState([]);
|
||||
// Load maps from the database and ensure state is properly setup
|
||||
useEffect(() => {
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
async function getDefaultMaps() {
|
||||
const defaultMapsWithIds = [];
|
||||
for (let i = 0; i < defaultMaps.length; i++) {
|
||||
const defaultMap = defaultMaps[i];
|
||||
const id = `__default-${defaultMap.name}`;
|
||||
defaultMapsWithIds.push({
|
||||
...defaultMap,
|
||||
id,
|
||||
owner: userId,
|
||||
// Emulate the time increasing to avoid sort errors
|
||||
created: Date.now() + i,
|
||||
lastModified: Date.now() + i,
|
||||
...defaultMapProps,
|
||||
});
|
||||
// Add a state for the map if there isn't one already
|
||||
const state = await db.table("states").get(id);
|
||||
if (!state) {
|
||||
await db.table("states").add({ ...defaultMapState, mapId: id });
|
||||
}
|
||||
}
|
||||
return defaultMapsWithIds;
|
||||
}
|
||||
|
||||
async function loadMaps() {
|
||||
let storedMaps = await db
|
||||
.table("maps")
|
||||
.where({ owner: userId })
|
||||
.toArray();
|
||||
const sortedMaps = storedMaps.sort((a, b) => b.created - a.created);
|
||||
const defaultMapsWithIds = await getDefaultMaps();
|
||||
const allMaps = [...sortedMaps, ...defaultMapsWithIds];
|
||||
setMaps(allMaps);
|
||||
|
||||
// reload map state as is may have changed while the modal was closed
|
||||
if (selectedMap) {
|
||||
const state = await db.table("states").get(selectedMap.id);
|
||||
if (state) {
|
||||
setSelectedMapState(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!wasOpen && isOpen) {
|
||||
loadMaps();
|
||||
}
|
||||
}, [userId, isOpen, wasOpen, selectedMap]);
|
||||
|
||||
const fileInputRef = useRef();
|
||||
|
||||
function handleImageUpload(file) {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
let fileGridX = defaultMapSize;
|
||||
let fileGridY = defaultMapSize;
|
||||
let name = "Unknown Map";
|
||||
if (file.name) {
|
||||
// TODO: match all not supported on safari, find alternative
|
||||
if (file.name.matchAll) {
|
||||
// Match against a regex to find the grid size in the file name
|
||||
// e.g. Cave 22x23 will return [["22x22", "22", "x", "23"]]
|
||||
const gridMatches = [...file.name.matchAll(/(\d+) ?(x|X) ?(\d+)/g)];
|
||||
if (gridMatches.length > 0) {
|
||||
const lastMatch = gridMatches[gridMatches.length - 1];
|
||||
const matchX = parseInt(lastMatch[1]);
|
||||
const matchY = parseInt(lastMatch[3]);
|
||||
if (!isNaN(matchX) && !isNaN(matchY)) {
|
||||
fileGridX = matchX;
|
||||
fileGridY = matchY;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove file extension
|
||||
name = file.name.replace(/\.[^/.]+$/, "");
|
||||
// Removed grid size expression
|
||||
name = name.replace(/(\[ ?|\( ?)?\d+ ?(x|X) ?\d+( ?\]| ?\))?/, "");
|
||||
// Clean string
|
||||
name = name.replace(/ +/g, " ");
|
||||
name = name.trim();
|
||||
}
|
||||
let image = new Image();
|
||||
setImageLoading(true);
|
||||
|
||||
// Copy file to avoid permissions issues
|
||||
const copy = new Blob([file], { type: file.type });
|
||||
// Create and load the image temporarily to get its dimensions
|
||||
const url = URL.createObjectURL(copy);
|
||||
image.onload = function () {
|
||||
handleMapAdd({
|
||||
file: copy,
|
||||
name,
|
||||
type: "file",
|
||||
gridX: fileGridX,
|
||||
gridY: fileGridY,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
id: shortid.generate(),
|
||||
created: Date.now(),
|
||||
lastModified: Date.now(),
|
||||
owner: userId,
|
||||
...defaultMapProps,
|
||||
});
|
||||
setImageLoading(false);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
image.src = url;
|
||||
// Set file input to null to allow adding the same image 2 times in a row
|
||||
fileInputRef.current.value = null;
|
||||
}
|
||||
|
||||
function openImageDialog() {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMapAdd(map) {
|
||||
await db.table("maps").add(map);
|
||||
const state = { ...defaultMapState, mapId: map.id };
|
||||
await db.table("states").add(state);
|
||||
setMaps((prevMaps) => [map, ...prevMaps]);
|
||||
setSelectedMap(map);
|
||||
setSelectedMapState(state);
|
||||
}
|
||||
|
||||
async function handleMapRemove(id) {
|
||||
await db.table("maps").delete(id);
|
||||
await db.table("states").delete(id);
|
||||
setMaps((prevMaps) => {
|
||||
const filtered = prevMaps.filter((map) => map.id !== id);
|
||||
setSelectedMap(filtered[0]);
|
||||
db.table("states").get(filtered[0].id).then(setSelectedMapState);
|
||||
return filtered;
|
||||
});
|
||||
// Removed the map from the map screen if needed
|
||||
if (currentMap && currentMap.id === selectedMap.id) {
|
||||
onMapChange(null, null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMapSelect(map) {
|
||||
const state = await db.table("states").get(map.id);
|
||||
setSelectedMapState(state);
|
||||
setSelectedMap(map);
|
||||
}
|
||||
|
||||
async function handleMapReset(id) {
|
||||
const state = { ...defaultMapState, mapId: id };
|
||||
await db.table("states").put(state);
|
||||
setSelectedMapState(state);
|
||||
// Reset the state of the current map if needed
|
||||
if (currentMap && currentMap.id === selectedMap.id) {
|
||||
onMapStateChange(state);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
if (selectedMap) {
|
||||
onMapChange(selectedMap, selectedMapState);
|
||||
onDone();
|
||||
}
|
||||
onDone();
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag and Drop
|
||||
*/
|
||||
const [dragging, setDragging] = useState(false);
|
||||
function handleImageDragEnter(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setDragging(true);
|
||||
}
|
||||
|
||||
function handleImageDragLeave(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setDragging(false);
|
||||
}
|
||||
|
||||
function handleImageDrop(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const file = event.dataTransfer.files[0];
|
||||
if (file && file.type.startsWith("image")) {
|
||||
handleImageUpload(file);
|
||||
}
|
||||
setDragging(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map settings
|
||||
*/
|
||||
const [showMoreSettings, setShowMoreSettings] = useState(false);
|
||||
|
||||
async function handleMapSettingsChange(key, value) {
|
||||
const change = { [key]: value, lastModified: Date.now() };
|
||||
db.table("maps").update(selectedMap.id, change);
|
||||
const newMap = { ...selectedMap, ...change };
|
||||
setMaps((prevMaps) => {
|
||||
const newMaps = [...prevMaps];
|
||||
const i = newMaps.findIndex((map) => map.id === selectedMap.id);
|
||||
if (i > -1) {
|
||||
newMaps[i] = newMap;
|
||||
}
|
||||
return newMaps;
|
||||
});
|
||||
setSelectedMap(newMap);
|
||||
}
|
||||
|
||||
async function handleMapStateSettingsChange(key, value) {
|
||||
db.table("states").update(selectedMap.id, { [key]: value });
|
||||
setSelectedMapState((prevState) => ({ ...prevState, [key]: value }));
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onRequestClose={onRequestClose}>
|
||||
<Box as="form" onSubmit={handleSubmit} onDragEnter={handleImageDragEnter}>
|
||||
<input
|
||||
onChange={(event) => handleImageUpload(event.target.files[0])}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
ref={fileInputRef}
|
||||
/>
|
||||
<Flex
|
||||
sx={{
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Label pt={2} pb={1}>
|
||||
Select or import a map
|
||||
</Label>
|
||||
<MapTiles
|
||||
maps={maps}
|
||||
onMapAdd={openImageDialog}
|
||||
onMapRemove={handleMapRemove}
|
||||
selectedMap={selectedMap}
|
||||
selectedMapState={selectedMapState}
|
||||
onMapSelect={handleMapSelect}
|
||||
onMapReset={handleMapReset}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
<MapSettings
|
||||
map={selectedMap}
|
||||
mapState={selectedMapState}
|
||||
onSettingsChange={handleMapSettingsChange}
|
||||
onStateSettingsChange={handleMapStateSettingsChange}
|
||||
showMore={showMoreSettings}
|
||||
onShowMoreChange={setShowMoreSettings}
|
||||
/>
|
||||
<Button variant="primary" disabled={imageLoading}>
|
||||
Done
|
||||
</Button>
|
||||
{dragging && (
|
||||
<Flex
|
||||
bg="muted"
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
cursor: "copy",
|
||||
}}
|
||||
onDragLeave={handleImageDragLeave}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}}
|
||||
onDrop={handleImageDrop}
|
||||
>
|
||||
<Text sx={{ pointerEvents: "none" }}>Drop map to upload</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default SelectMapModal;
|
81
src/modals/SettingsModal.js
Normal file
@ -0,0 +1,81 @@
|
||||
import React, { useState, useContext } from "react";
|
||||
import { Box, Label, Flex, Button, useColorMode, Checkbox } from "theme-ui";
|
||||
|
||||
import Modal from "../components/Modal";
|
||||
|
||||
import AuthContext from "../contexts/AuthContext";
|
||||
|
||||
import db from "../database";
|
||||
|
||||
function SettingsModal({ isOpen, onRequestClose }) {
|
||||
const { userId } = useContext(AuthContext);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
|
||||
async function handleEraseAllData() {
|
||||
await db.delete();
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
async function handleClearCache() {
|
||||
await db.table("maps").where("owner").notEqual(userId).delete();
|
||||
// TODO: With custom tokens look up all tokens that aren't being used in a state
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
const [colorMode, setColorMode] = useColorMode();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal isOpen={isOpen} onRequestClose={onRequestClose}>
|
||||
<Flex sx={{ flexDirection: "column" }}>
|
||||
<Label py={2}>Settings</Label>
|
||||
<Label py={2}>
|
||||
Light theme
|
||||
<Checkbox
|
||||
checked={colorMode === "light"}
|
||||
onChange={(e) =>
|
||||
setColorMode(e.target.checked ? "light" : "default")
|
||||
}
|
||||
pl={1}
|
||||
/>
|
||||
</Label>
|
||||
<Flex py={2}>
|
||||
<Button sx={{ flexGrow: 1 }} onClick={handleClearCache}>
|
||||
Clear cache
|
||||
</Button>
|
||||
</Flex>
|
||||
<Flex py={2}>
|
||||
<Button
|
||||
sx={{ flexGrow: 1 }}
|
||||
onClick={() => setIsDeleteModalOpen(true)}
|
||||
>
|
||||
Erase all content and reset
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Modal>
|
||||
<Modal
|
||||
isOpen={isDeleteModalOpen}
|
||||
onRequestClose={() => setIsDeleteModalOpen(false)}
|
||||
>
|
||||
<Box>
|
||||
<Label py={2}>Are you sure?</Label>
|
||||
<Flex py={2}>
|
||||
<Button
|
||||
sx={{ flexGrow: 1 }}
|
||||
m={1}
|
||||
onClick={() => setIsDeleteModalOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button m={1} sx={{ flexGrow: 1 }} onClick={handleEraseAllData}>
|
||||
Erase
|
||||
</Button>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SettingsModal;
|
@ -1,20 +1,16 @@
|
||||
import React, {
|
||||
useState,
|
||||
useRef,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useContext,
|
||||
} from "react";
|
||||
import React, { useState, useEffect, useCallback, useContext } from "react";
|
||||
import { Flex, Box, Text } from "theme-ui";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
import db from "../database";
|
||||
|
||||
import { omit, isStreamStopped } from "../helpers/shared";
|
||||
import useSession from "../helpers/useSession";
|
||||
import useNickname from "../helpers/useNickname";
|
||||
import useDebounce from "../helpers/useDebounce";
|
||||
|
||||
import Party from "../components/Party";
|
||||
import Tokens from "../components/Tokens";
|
||||
import Map from "../components/Map";
|
||||
import Party from "../components/party/Party";
|
||||
import Tokens from "../components/token/Tokens";
|
||||
import Map from "../components/map/Map";
|
||||
import Banner from "../components/Banner";
|
||||
import LoadingOverlay from "../components/LoadingOverlay";
|
||||
import Link from "../components/Link";
|
||||
@ -23,9 +19,13 @@ import AuthModal from "../modals/AuthModal";
|
||||
|
||||
import AuthContext from "../contexts/AuthContext";
|
||||
|
||||
import { tokens as defaultTokens } from "../tokens";
|
||||
|
||||
function Game() {
|
||||
const { id: gameId } = useParams();
|
||||
const { authenticationStatus } = useContext(AuthContext);
|
||||
const { authenticationStatus, userId, nickname, setNickname } = useContext(
|
||||
AuthContext
|
||||
);
|
||||
|
||||
const { peers, socket } = useSession(
|
||||
gameId,
|
||||
@ -41,83 +41,189 @@ function Game() {
|
||||
* Map state
|
||||
*/
|
||||
|
||||
const [mapSource, setMapSource] = useState(null);
|
||||
const mapDataRef = useRef(null);
|
||||
const [map, setMap] = useState(null);
|
||||
const [mapState, setMapState] = useState(null);
|
||||
const [mapLoading, setMapLoading] = useState(false);
|
||||
|
||||
function handleMapChange(mapData, mapSource) {
|
||||
mapDataRef.current = mapData;
|
||||
setMapSource(mapSource);
|
||||
for (let peer of Object.values(peers)) {
|
||||
peer.connection.send({ id: "map", data: mapDataRef.current });
|
||||
const canEditMapDrawing =
|
||||
map !== null &&
|
||||
mapState !== null &&
|
||||
(mapState.editFlags.includes("drawing") || map.owner === userId);
|
||||
|
||||
const canEditFogDrawing =
|
||||
map !== null &&
|
||||
mapState !== null &&
|
||||
(mapState.editFlags.includes("fog") || map.owner === userId);
|
||||
|
||||
const disabledMapTokens = {};
|
||||
// If we have a map and state and have the token permission disabled
|
||||
// and are not the map owner
|
||||
if (
|
||||
mapState !== null &&
|
||||
map !== null &&
|
||||
!mapState.editFlags.includes("tokens") &&
|
||||
map.owner !== userId
|
||||
) {
|
||||
for (let token of Object.values(mapState.tokens)) {
|
||||
if (token.owner !== userId) {
|
||||
disabledMapTokens[token.id] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [mapTokens, setMapTokens] = useState({});
|
||||
// Sync the map state to the database after 500ms of inactivity
|
||||
const debouncedMapState = useDebounce(mapState, 500);
|
||||
useEffect(() => {
|
||||
if (
|
||||
debouncedMapState &&
|
||||
debouncedMapState.mapId &&
|
||||
map &&
|
||||
map.owner === userId
|
||||
) {
|
||||
db.table("states").update(debouncedMapState.mapId, debouncedMapState);
|
||||
}
|
||||
}, [map, debouncedMapState, userId]);
|
||||
|
||||
function handleMapTokenChange(token) {
|
||||
if (!mapSource) {
|
||||
function handleMapChange(newMap, newMapState) {
|
||||
setMapState(newMapState);
|
||||
setMap(newMap);
|
||||
for (let peer of Object.values(peers)) {
|
||||
// Clear the map so the new map state isn't shown on an old map
|
||||
peer.connection.send({ id: "map", data: null });
|
||||
peer.connection.send({ id: "mapState", data: newMapState });
|
||||
sendMapDataToPeer(peer, newMap);
|
||||
}
|
||||
}
|
||||
|
||||
function sendMapDataToPeer(peer, mapData) {
|
||||
// Omit file from map change, receiver will request the file if
|
||||
// they have an outdated version
|
||||
if (mapData.type === "file") {
|
||||
const { file, ...rest } = mapData;
|
||||
peer.connection.send({ id: "map", data: rest });
|
||||
} else {
|
||||
peer.connection.send({ id: "map", data: mapData });
|
||||
}
|
||||
}
|
||||
|
||||
function handleMapStateChange(newMapState) {
|
||||
setMapState(newMapState);
|
||||
for (let peer of Object.values(peers)) {
|
||||
peer.connection.send({ id: "mapState", data: newMapState });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMapTokenStateChange(token) {
|
||||
if (mapState === null) {
|
||||
return;
|
||||
}
|
||||
setMapTokens((prevMapTokens) => ({
|
||||
...prevMapTokens,
|
||||
[token.id]: token,
|
||||
setMapState((prevMapState) => ({
|
||||
...prevMapState,
|
||||
tokens: {
|
||||
...prevMapState.tokens,
|
||||
[token.id]: token,
|
||||
},
|
||||
}));
|
||||
for (let peer of Object.values(peers)) {
|
||||
const data = { [token.id]: token };
|
||||
peer.connection.send({ id: "tokenEdit", data });
|
||||
peer.connection.send({ id: "tokenStateEdit", data });
|
||||
}
|
||||
}
|
||||
|
||||
function handleMapTokenRemove(token) {
|
||||
setMapTokens((prevMapTokens) => {
|
||||
const { [token.id]: old, ...rest } = prevMapTokens;
|
||||
return rest;
|
||||
function handleMapTokenStateRemove(token) {
|
||||
setMapState((prevMapState) => {
|
||||
const { [token.id]: old, ...rest } = prevMapState.tokens;
|
||||
return { ...prevMapState, tokens: rest };
|
||||
});
|
||||
for (let peer of Object.values(peers)) {
|
||||
const data = { [token.id]: token };
|
||||
peer.connection.send({ id: "tokenRemove", data });
|
||||
peer.connection.send({ id: "tokenStateRemove", data });
|
||||
}
|
||||
}
|
||||
|
||||
const [mapDrawActions, setMapDrawActions] = useState([]);
|
||||
// An index into the draw actions array to which only actions before the
|
||||
// index will be performed (used in undo and redo)
|
||||
const [mapDrawActionIndex, setMapDrawActionIndex] = useState(-1);
|
||||
function addNewMapDrawActions(actions) {
|
||||
setMapDrawActions((prevActions) => {
|
||||
function addMapDrawActions(actions, indexKey, actionsKey) {
|
||||
setMapState((prevMapState) => {
|
||||
const newActions = [
|
||||
...prevActions.slice(0, mapDrawActionIndex + 1),
|
||||
...prevMapState[actionsKey].slice(0, prevMapState[indexKey] + 1),
|
||||
...actions,
|
||||
];
|
||||
const newIndex = newActions.length - 1;
|
||||
setMapDrawActionIndex(newIndex);
|
||||
return newActions;
|
||||
return {
|
||||
...prevMapState,
|
||||
[actionsKey]: newActions,
|
||||
[indexKey]: newIndex,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function updateDrawActionIndex(change, indexKey, actionsKey, peerId) {
|
||||
const newIndex = Math.min(
|
||||
Math.max(mapState[indexKey] + change, -1),
|
||||
mapState[actionsKey].length - 1
|
||||
);
|
||||
|
||||
setMapState((prevMapState) => ({
|
||||
...prevMapState,
|
||||
[indexKey]: newIndex,
|
||||
}));
|
||||
return newIndex;
|
||||
}
|
||||
|
||||
function handleMapDraw(action) {
|
||||
addNewMapDrawActions([action]);
|
||||
addMapDrawActions([action], "mapDrawActionIndex", "mapDrawActions");
|
||||
for (let peer of Object.values(peers)) {
|
||||
peer.connection.send({ id: "mapDraw", data: [action] });
|
||||
}
|
||||
}
|
||||
|
||||
function handleMapDrawUndo() {
|
||||
const newIndex = Math.max(mapDrawActionIndex - 1, -1);
|
||||
setMapDrawActionIndex(newIndex);
|
||||
const index = updateDrawActionIndex(
|
||||
-1,
|
||||
"mapDrawActionIndex",
|
||||
"mapDrawActions"
|
||||
);
|
||||
for (let peer of Object.values(peers)) {
|
||||
peer.connection.send({ id: "mapDrawIndex", data: newIndex });
|
||||
peer.connection.send({ id: "mapDrawIndex", data: index });
|
||||
}
|
||||
}
|
||||
|
||||
function handleMapDrawRedo() {
|
||||
const newIndex = Math.min(
|
||||
mapDrawActionIndex + 1,
|
||||
mapDrawActions.length - 1
|
||||
const index = updateDrawActionIndex(
|
||||
1,
|
||||
"mapDrawActionIndex",
|
||||
"mapDrawActions"
|
||||
);
|
||||
setMapDrawActionIndex(newIndex);
|
||||
for (let peer of Object.values(peers)) {
|
||||
peer.connection.send({ id: "mapDrawIndex", data: newIndex });
|
||||
peer.connection.send({ id: "mapDrawIndex", data: index });
|
||||
}
|
||||
}
|
||||
|
||||
function handleFogDraw(action) {
|
||||
addMapDrawActions([action], "fogDrawActionIndex", "fogDrawActions");
|
||||
for (let peer of Object.values(peers)) {
|
||||
peer.connection.send({ id: "mapFog", data: [action] });
|
||||
}
|
||||
}
|
||||
|
||||
function handleFogDrawUndo() {
|
||||
const index = updateDrawActionIndex(
|
||||
-1,
|
||||
"fogDrawActionIndex",
|
||||
"fogDrawActions"
|
||||
);
|
||||
for (let peer of Object.values(peers)) {
|
||||
peer.connection.send({ id: "fogDrawIndex", data: index });
|
||||
}
|
||||
}
|
||||
|
||||
function handleFogDrawRedo() {
|
||||
const index = updateDrawActionIndex(
|
||||
1,
|
||||
"fogDrawActionIndex",
|
||||
"fogDrawActions"
|
||||
);
|
||||
for (let peer of Object.values(peers)) {
|
||||
peer.connection.send({ id: "fogDrawIndex", data: index });
|
||||
}
|
||||
}
|
||||
|
||||
@ -125,7 +231,6 @@ function Game() {
|
||||
* Party state
|
||||
*/
|
||||
|
||||
const { nickname, setNickname } = useNickname();
|
||||
const [partyNicknames, setPartyNicknames] = useState({});
|
||||
|
||||
function handleNicknameChange(nickname) {
|
||||
@ -151,34 +256,66 @@ function Game() {
|
||||
|
||||
function handlePeerData({ data, peer }) {
|
||||
if (data.id === "sync") {
|
||||
if (mapSource) {
|
||||
peer.connection.send({ id: "map", data: mapDataRef.current });
|
||||
if (mapState) {
|
||||
peer.connection.send({ id: "mapState", data: mapState });
|
||||
}
|
||||
if (mapTokens) {
|
||||
peer.connection.send({ id: "tokenEdit", data: mapTokens });
|
||||
}
|
||||
if (mapDrawActions) {
|
||||
peer.connection.send({ id: "mapDraw", data: mapDrawActions });
|
||||
}
|
||||
if (mapDrawActionIndex !== mapDrawActions.length - 1) {
|
||||
peer.connection.send({ id: "mapDrawIndex", data: mapDrawActionIndex });
|
||||
if (map) {
|
||||
sendMapDataToPeer(peer, map);
|
||||
}
|
||||
}
|
||||
if (data.id === "map") {
|
||||
const blob = new Blob([data.data.file]);
|
||||
mapDataRef.current = { ...data.data, file: blob };
|
||||
setMapSource(URL.createObjectURL(mapDataRef.current.file));
|
||||
const newMap = data.data;
|
||||
// If is a file map check cache and request the full file if outdated
|
||||
if (newMap && newMap.type === "file") {
|
||||
db.table("maps")
|
||||
.get(newMap.id)
|
||||
.then((cachedMap) => {
|
||||
if (cachedMap && cachedMap.lastModified === newMap.lastModified) {
|
||||
setMap(cachedMap);
|
||||
} else {
|
||||
setMapLoading(true);
|
||||
peer.connection.send({ id: "mapRequest" });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setMap(newMap);
|
||||
}
|
||||
}
|
||||
if (data.id === "tokenEdit") {
|
||||
setMapTokens((prevMapTokens) => ({
|
||||
...prevMapTokens,
|
||||
...data.data,
|
||||
// Send full map data including file
|
||||
if (data.id === "mapRequest") {
|
||||
peer.connection.send({ id: "mapResponse", data: map });
|
||||
}
|
||||
// A new map response with a file attached
|
||||
if (data.id === "mapResponse") {
|
||||
setMapLoading(false);
|
||||
if (data.data && data.data.type === "file") {
|
||||
// Convert file back to blob after peer transfer
|
||||
const file = new Blob([data.data.file]);
|
||||
const newMap = { ...data.data, file };
|
||||
// Store in db
|
||||
db.table("maps")
|
||||
.put(newMap)
|
||||
.then(() => {
|
||||
setMap(newMap);
|
||||
});
|
||||
} else {
|
||||
setMap(data.data);
|
||||
}
|
||||
}
|
||||
if (data.id === "mapState") {
|
||||
setMapState(data.data);
|
||||
}
|
||||
if (data.id === "tokenStateEdit") {
|
||||
setMapState((prevMapState) => ({
|
||||
...prevMapState,
|
||||
tokens: { ...prevMapState.tokens, ...data.data },
|
||||
}));
|
||||
}
|
||||
if (data.id === "tokenRemove") {
|
||||
setMapTokens((prevMapTokens) =>
|
||||
omit(prevMapTokens, Object.keys(data.data))
|
||||
);
|
||||
if (data.id === "tokenStateRemove") {
|
||||
setMapState((prevMapState) => ({
|
||||
...prevMapState,
|
||||
tokens: omit(prevMapState.tokens, Object.keys(data.data)),
|
||||
}));
|
||||
}
|
||||
if (data.id === "nickname") {
|
||||
setPartyNicknames((prevNicknames) => ({
|
||||
@ -187,10 +324,22 @@ function Game() {
|
||||
}));
|
||||
}
|
||||
if (data.id === "mapDraw") {
|
||||
addNewMapDrawActions(data.data);
|
||||
addMapDrawActions(data.data, "mapDrawActionIndex", "mapDrawActions");
|
||||
}
|
||||
if (data.id === "mapDrawIndex") {
|
||||
setMapDrawActionIndex(data.data);
|
||||
setMapState((prevMapState) => ({
|
||||
...prevMapState,
|
||||
mapDrawActionIndex: data.data,
|
||||
}));
|
||||
}
|
||||
if (data.id === "mapFog") {
|
||||
addMapDrawActions(data.data, "fogDrawActionIndex", "fogDrawActions");
|
||||
}
|
||||
if (data.id === "mapFogIndex") {
|
||||
setMapState((prevMapState) => ({
|
||||
...prevMapState,
|
||||
fogDrawActionIndex: data.data,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@ -286,6 +435,25 @@ function Game() {
|
||||
}
|
||||
}, [stream, peers, handleStreamEnd]);
|
||||
|
||||
/**
|
||||
* Token data
|
||||
*/
|
||||
const [tokens, setTokens] = useState([]);
|
||||
useEffect(() => {
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
const defaultTokensWithIds = [];
|
||||
for (let defaultToken of defaultTokens) {
|
||||
defaultTokensWithIds.push({
|
||||
...defaultToken,
|
||||
id: `__default-${defaultToken.name}`,
|
||||
owner: userId,
|
||||
});
|
||||
}
|
||||
setTokens(defaultTokensWithIds);
|
||||
}, [userId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex sx={{ flexDirection: "column", height: "100%" }}>
|
||||
@ -303,19 +471,28 @@ function Game() {
|
||||
onStreamEnd={handleStreamEnd}
|
||||
/>
|
||||
<Map
|
||||
mapSource={mapSource}
|
||||
mapData={mapDataRef.current}
|
||||
tokens={mapTokens}
|
||||
onMapTokenChange={handleMapTokenChange}
|
||||
onMapTokenRemove={handleMapTokenRemove}
|
||||
map={map}
|
||||
mapState={mapState}
|
||||
tokens={tokens}
|
||||
loading={mapLoading}
|
||||
onMapTokenStateChange={handleMapTokenStateChange}
|
||||
onMapTokenStateRemove={handleMapTokenStateRemove}
|
||||
onMapChange={handleMapChange}
|
||||
onMapStateChange={handleMapStateChange}
|
||||
onMapDraw={handleMapDraw}
|
||||
onMapDrawUndo={handleMapDrawUndo}
|
||||
onMapDrawRedo={handleMapDrawRedo}
|
||||
drawActions={mapDrawActions}
|
||||
drawActionIndex={mapDrawActionIndex}
|
||||
onFogDraw={handleFogDraw}
|
||||
onFogDrawUndo={handleFogDrawUndo}
|
||||
onFogDrawRedo={handleFogDrawRedo}
|
||||
allowMapDrawing={canEditMapDrawing}
|
||||
allowFogDrawing={canEditFogDrawing}
|
||||
disabledTokens={disabledMapTokens}
|
||||
/>
|
||||
<Tokens
|
||||
tokens={tokens}
|
||||
onCreateMapTokenState={handleMapTokenStateChange}
|
||||
/>
|
||||
<Tokens onCreateMapToken={handleMapTokenChange} />
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Banner isOpen={!!peerError} onRequestClose={() => setPeerError(null)}>
|
||||
|
@ -51,7 +51,7 @@ function Home() {
|
||||
Join Game
|
||||
</Button>
|
||||
<Text variant="caption" as="p" sx={{ textAlign: "center" }}>
|
||||
Beta v1.1.0
|
||||
Beta v1.2.0
|
||||
</Text>
|
||||
<Button
|
||||
m={2}
|
||||
|
13
src/theme.js
@ -9,6 +9,17 @@ export default {
|
||||
muted: "hsla(230, 20%, 0%, 20%)",
|
||||
gray: "hsl(0, 0%, 70%)",
|
||||
overlay: "hsla(230, 25%, 18%, 0.8)",
|
||||
modes: {
|
||||
light: {
|
||||
text: "hsl(10, 20%, 20%)",
|
||||
background: "hsl(10, 10%, 98%)",
|
||||
primary: "hsl(260, 100%, 80%)",
|
||||
secondary: "hsl(290, 100%, 80%)",
|
||||
highlight: "hsl(260, 20%, 40%)",
|
||||
muted: "hsla(230, 20%, 60%, 20%)",
|
||||
overlay: "hsla(230, 100%, 97%, 0.8)",
|
||||
},
|
||||
},
|
||||
},
|
||||
fonts: {
|
||||
body: "'Bree Serif', serif",
|
||||
@ -180,6 +191,8 @@ export default {
|
||||
},
|
||||
"&:disabled": {
|
||||
backgroundColor: "muted",
|
||||
color: "gray",
|
||||
borderColor: "text",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -19,7 +19,7 @@ import swords from "./Swords.png";
|
||||
import tree from "./Tree.png";
|
||||
import triangle from "./Triangle.png";
|
||||
|
||||
export {
|
||||
export const tokenSources = {
|
||||
axes,
|
||||
bird,
|
||||
book,
|
||||
@ -39,5 +39,11 @@ export {
|
||||
sun,
|
||||
swords,
|
||||
tree,
|
||||
triangle
|
||||
triangle,
|
||||
};
|
||||
|
||||
export const tokens = Object.keys(tokenSources).map((key) => ({
|
||||
key,
|
||||
name: key.charAt(0).toUpperCase() + key.slice(1),
|
||||
type: "default",
|
||||
}));
|
||||
|
20
yarn.lock
@ -2711,11 +2711,6 @@ bindings@^1.5.0:
|
||||
dependencies:
|
||||
file-uri-to-path "1.0.0"
|
||||
|
||||
blob-to-buffer@^1.2.8:
|
||||
version "1.2.8"
|
||||
resolved "https://registry.yarnpkg.com/blob-to-buffer/-/blob-to-buffer-1.2.8.tgz#78eeeb332f1280ed0ca6fb2b60693a8c6d36903a"
|
||||
integrity sha512-re0AIxakF504MgeMtIyJkVcZ8T5aUxtp/QmTMlmjyb3P44E1BEv5x3LATBGApWAJATyXHtkXRD+gWTmeyYLiQA==
|
||||
|
||||
blob@0.0.5:
|
||||
version "0.0.5"
|
||||
resolved "https://registry.yarnpkg.com/blob/-/blob-0.0.5.tgz#d680eeef25f8cd91ad533f5b01eed48e64caf683"
|
||||
@ -4005,6 +4000,11 @@ detect-port-alt@1.1.6:
|
||||
address "^1.0.1"
|
||||
debug "^2.6.0"
|
||||
|
||||
dexie@^2.0.4:
|
||||
version "2.0.4"
|
||||
resolved "https://registry.yarnpkg.com/dexie/-/dexie-2.0.4.tgz#6027a5e05879424e8f9979d8c14e7420f27e3a11"
|
||||
integrity sha512-aQ/s1U2wHxwBKRrt2Z/mwFNHMQWhESerFsMYzE+5P5OsIe5o1kgpFMWkzKTtkvkyyEni6mWr/T4HUJuY9xIHLA==
|
||||
|
||||
diff-sequences@^24.9.0:
|
||||
version "24.9.0"
|
||||
resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-24.9.0.tgz#5715d6244e2aa65f48bba0bc972db0b0b11e95b5"
|
||||
@ -7459,6 +7459,11 @@ normalize-url@^3.0.0:
|
||||
resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-3.3.0.tgz#b2e1c4dc4f7c6d57743df733a4f5978d18650559"
|
||||
integrity sha512-U+JJi7duF1o+u2pynbp2zXDW2/PADgC30f0GsHZtRh+HOcXHnw137TrNlyxxRvWW5fjKd3bcLHPxofWuCjaeZg==
|
||||
|
||||
normalize-wheel@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/normalize-wheel/-/normalize-wheel-1.0.1.tgz#aec886affdb045070d856447df62ecf86146ec45"
|
||||
integrity sha1-rsiGr/2wRQcNhWRH32Ls+GFG7EU=
|
||||
|
||||
npm-run-path@^2.0.0:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f"
|
||||
@ -9832,11 +9837,6 @@ shallow-clone@^3.0.0:
|
||||
dependencies:
|
||||
kind-of "^6.0.2"
|
||||
|
||||
shape-detector@^0.2.1:
|
||||
version "0.2.1"
|
||||
resolved "https://registry.yarnpkg.com/shape-detector/-/shape-detector-0.2.1.tgz#d69acf8a5f595100fee08b2d69d6b5c74d887e1e"
|
||||
integrity sha1-1prPil9ZUQD+4Istada1x02Ifh4=
|
||||
|
||||
shebang-command@^1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea"
|
||||
|