Merge pull request #2 from mitchemmc/dev

v1.2.0
This commit is contained in:
Mitchell McCaffrey 2020-04-30 23:38:44 +10:00 committed by GitHub
commit 169cf66e74
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
95 changed files with 4153 additions and 2906 deletions

View File

@ -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",

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View 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
View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@ -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>

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@ -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);

View File

@ -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);

View File

@ -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>
);

View File

@ -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);

View File

@ -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);

View 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;

View File

@ -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;

View File

@ -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>

View File

@ -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%",

View File

@ -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 (

View File

@ -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]))}
/>
</>
);

View File

@ -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,

View 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
View 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;

View File

@ -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) {

View 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
View 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 };
}
}

View File

@ -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");
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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);
}

View 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;

View 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;

View File

@ -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;

View 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;

View File

@ -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,

View File

@ -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
View 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;

View File

@ -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;

View File

@ -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>
);
}

View 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;

View 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;

View 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
View 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;

View 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;

View 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
View 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;

View File

@ -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" />

View File

@ -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" />

View 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
View 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;

View 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
View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

BIN
src/maps/Blank Grid 22x22.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

BIN
src/maps/Grass Grid 22x22.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

BIN
src/maps/Sand Grid 22x22.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

BIN
src/maps/Stone Grid 22x22.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

BIN
src/maps/Water Grid 22x22.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

BIN
src/maps/Wood Grid 22x22.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

25
src/maps/index.js Normal file
View 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",
}));

View File

@ -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;

View 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;

View 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;

View File

@ -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)}>

View File

@ -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}

View File

@ -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",
},
},
},

View File

@ -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",
}));

View File

@ -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"