commit
4f716eda09
5
.env
5
.env
@ -2,4 +2,7 @@ REACT_APP_BROKER_URL=http://localhost:9000
|
|||||||
REACT_APP_ICE_SERVERS_URL=http://localhost:9000/iceservers
|
REACT_APP_ICE_SERVERS_URL=http://localhost:9000/iceservers
|
||||||
REACT_APP_STRIPE_API_KEY=pk_test_8M3NHrF1eI2b84ubF4F8rSTe0095R3f0My
|
REACT_APP_STRIPE_API_KEY=pk_test_8M3NHrF1eI2b84ubF4F8rSTe0095R3f0My
|
||||||
REACT_APP_STRIPE_URL=http://localhost:9000
|
REACT_APP_STRIPE_URL=http://localhost:9000
|
||||||
REACT_APP_VERSION=$npm_package_version
|
REACT_APP_VERSION=$npm_package_version
|
||||||
|
REACT_APP_PREVIEW=false
|
||||||
|
REACT_APP_LOGGING=false
|
||||||
|
REACT_APP_FATHOM_SITE_ID=VMSHBPKD
|
@ -1,5 +1,8 @@
|
|||||||
REACT_APP_BROKER_URL=https://connect.owlbear.rodeo
|
REACT_APP_BROKER_URL=https://test.owlbear.rodeo
|
||||||
REACT_APP_ICE_SERVERS_URL=https://connect.owlbear.rodeo/iceservers
|
REACT_APP_ICE_SERVERS_URL=https://test.owlbear.rodeo/iceservers
|
||||||
REACT_APP_STRIPE_API_KEY=pk_live_MJjzi5djj524Y7h3fL5PNh4e00a852XD51
|
REACT_APP_STRIPE_API_KEY=pk_live_MJjzi5djj524Y7h3fL5PNh4e00a852XD51
|
||||||
REACT_APP_STRIPE_URL=https://payment.owlbear.rodeo
|
REACT_APP_STRIPE_URL=https://payment.owlbear.rodeo
|
||||||
REACT_APP_VERSION=$npm_package_version
|
REACT_APP_VERSION=$npm_package_version
|
||||||
|
REACT_APP_PREVIEW=true
|
||||||
|
REACT_APP_LOGGING=true
|
||||||
|
REACT_APP_FATHOM_SITE_ID=VMSHBPKD
|
12
package.json
12
package.json
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "owlbear-rodeo",
|
"name": "owlbear-rodeo",
|
||||||
"version": "1.6.2",
|
"version": "1.7.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babylonjs/core": "^4.2.0",
|
"@babylonjs/core": "^4.2.0",
|
||||||
@ -14,6 +14,8 @@
|
|||||||
"@testing-library/user-event": "^12.2.2",
|
"@testing-library/user-event": "^12.2.2",
|
||||||
"ammo.js": "kripken/ammo.js#aab297a4164779c3a9d8dc8d9da26958de3cb778",
|
"ammo.js": "kripken/ammo.js#aab297a4164779c3a9d8dc8d9da26958de3cb778",
|
||||||
"case": "^1.6.3",
|
"case": "^1.6.3",
|
||||||
|
"comlink": "^4.3.0",
|
||||||
|
"deep-diff": "^1.0.2",
|
||||||
"dexie": "^3.0.3",
|
"dexie": "^3.0.3",
|
||||||
"err-code": "^2.0.3",
|
"err-code": "^2.0.3",
|
||||||
"fake-indexeddb": "^3.1.2",
|
"fake-indexeddb": "^3.1.2",
|
||||||
@ -24,6 +26,7 @@
|
|||||||
"lodash.set": "^4.3.2",
|
"lodash.set": "^4.3.2",
|
||||||
"normalize-wheel": "^1.0.1",
|
"normalize-wheel": "^1.0.1",
|
||||||
"polygon-clipping": "^0.15.1",
|
"polygon-clipping": "^0.15.1",
|
||||||
|
"pretty-bytes": "^5.4.1",
|
||||||
"raw.macro": "^0.4.2",
|
"raw.macro": "^0.4.2",
|
||||||
"react": "^17.0.1",
|
"react": "^17.0.1",
|
||||||
"react-dom": "^17.0.1",
|
"react-dom": "^17.0.1",
|
||||||
@ -43,7 +46,7 @@
|
|||||||
"simple-peer": "feross/simple-peer#694/head",
|
"simple-peer": "feross/simple-peer#694/head",
|
||||||
"simplebar-react": "^2.1.0",
|
"simplebar-react": "^2.1.0",
|
||||||
"simplify-js": "^1.2.4",
|
"simplify-js": "^1.2.4",
|
||||||
"socket.io-client": "^2.3.0",
|
"socket.io-client": "^3.0.3",
|
||||||
"source-map-explorer": "^2.4.2",
|
"source-map-explorer": "^2.4.2",
|
||||||
"theme-ui": "^0.3.1",
|
"theme-ui": "^0.3.1",
|
||||||
"use-image": "^1.0.5",
|
"use-image": "^1.0.5",
|
||||||
@ -52,7 +55,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"analyze": "source-map-explorer 'build/static/js/*.js'",
|
"analyze": "source-map-explorer 'build/static/js/*.js'",
|
||||||
"start": "react-scripts start",
|
"start": "react-scripts start",
|
||||||
"build": "react-scripts build",
|
"build": "react-scripts --max_old_space_size=4096 build",
|
||||||
"test": "react-scripts test",
|
"test": "react-scripts test",
|
||||||
"eject": "react-scripts eject"
|
"eject": "react-scripts eject"
|
||||||
},
|
},
|
||||||
@ -70,5 +73,8 @@
|
|||||||
"last 1 firefox version",
|
"last 1 firefox version",
|
||||||
"last 1 safari version"
|
"last 1 safari version"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"worker-loader": "^3.0.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -28,7 +28,7 @@
|
|||||||
<meta property="og:image" content="%PUBLIC_URL%/thumbnail.jpg" />
|
<meta property="og:image" content="%PUBLIC_URL%/thumbnail.jpg" />
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
<!-- Fathom -->
|
<!-- Fathom -->
|
||||||
<script src="https://cdn.usefathom.com/script.js" data-spa="auto" data-site="VMSHBPKD" defer></script>
|
<script src="https://cdn.usefathom.com/script.js" data-spa="auto" data-site="%REACT_APP_FATHOM_SITE_ID%" defer></script>
|
||||||
<!-- / Fathom -->
|
<!-- / Fathom -->
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
89
src/components/DragOverlay.js
Normal file
89
src/components/DragOverlay.js
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import { Box, IconButton } from "theme-ui";
|
||||||
|
|
||||||
|
import RemoveTokenIcon from "../icons/RemoveTokenIcon";
|
||||||
|
|
||||||
|
function DragOverlay({ dragging, node, onRemove }) {
|
||||||
|
const [isRemoveHovered, setIsRemoveHovered] = useState(false);
|
||||||
|
const removeTokenRef = useRef();
|
||||||
|
|
||||||
|
// Detect token hover on remove icon manually to support touch devices
|
||||||
|
useEffect(() => {
|
||||||
|
const map = document.querySelector(".map");
|
||||||
|
const mapRect = map.getBoundingClientRect();
|
||||||
|
|
||||||
|
function detectRemoveHover() {
|
||||||
|
if (!node || !dragging || !removeTokenRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stage = node.getStage();
|
||||||
|
if (!stage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pointerPosition = stage.getPointerPosition();
|
||||||
|
const screenSpacePointerPosition = {
|
||||||
|
x: pointerPosition.x + mapRect.left,
|
||||||
|
y: pointerPosition.y + mapRect.top,
|
||||||
|
};
|
||||||
|
const removeIconPosition = removeTokenRef.current.getBoundingClientRect();
|
||||||
|
|
||||||
|
if (
|
||||||
|
screenSpacePointerPosition.x > removeIconPosition.left &&
|
||||||
|
screenSpacePointerPosition.y > removeIconPosition.top &&
|
||||||
|
screenSpacePointerPosition.x < removeIconPosition.right &&
|
||||||
|
screenSpacePointerPosition.y < removeIconPosition.bottom
|
||||||
|
) {
|
||||||
|
if (!isRemoveHovered) {
|
||||||
|
setIsRemoveHovered(true);
|
||||||
|
}
|
||||||
|
} else if (isRemoveHovered) {
|
||||||
|
setIsRemoveHovered(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let handler;
|
||||||
|
if (node && dragging) {
|
||||||
|
handler = setInterval(detectRemoveHover, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (handler) {
|
||||||
|
clearInterval(handler);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isRemoveHovered, dragging, node]);
|
||||||
|
|
||||||
|
// Detect drag end of token image and remove it if it is over the remove icon
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dragging && node && isRemoveHovered) {
|
||||||
|
onRemove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
dragging && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: "32px",
|
||||||
|
left: "50%",
|
||||||
|
borderRadius: "50%",
|
||||||
|
transform: isRemoveHovered
|
||||||
|
? "translateX(-50%) scale(2.0)"
|
||||||
|
: "translateX(-50%) scale(1.5)",
|
||||||
|
transition: "transform 250ms ease",
|
||||||
|
color: isRemoveHovered ? "primary" : "text",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
bg="overlay"
|
||||||
|
ref={removeTokenRef}
|
||||||
|
>
|
||||||
|
<IconButton>
|
||||||
|
<RemoveTokenIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DragOverlay;
|
@ -15,11 +15,32 @@ function ImageDrop({ onDrop, dropText, children }) {
|
|||||||
setDragging(false);
|
setDragging(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleImageDrop(event) {
|
async function handleImageDrop(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const files = event.dataTransfer.files;
|
|
||||||
let imageFiles = [];
|
let imageFiles = [];
|
||||||
|
|
||||||
|
// Check if the dropped image is from a URL
|
||||||
|
const html = event.dataTransfer.getData("text/html");
|
||||||
|
if (html) {
|
||||||
|
try {
|
||||||
|
const urlMatch = html.match(/src="?([^"\s]+)"?\s*/);
|
||||||
|
const url = urlMatch[1].replace("&", "&"); // Reverse html encoding of url parameters
|
||||||
|
let name = "";
|
||||||
|
const altMatch = html.match(/alt="?([^"]+)"?\s*/);
|
||||||
|
if (altMatch && altMatch.length > 1) {
|
||||||
|
name = altMatch[1];
|
||||||
|
}
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (response.ok) {
|
||||||
|
const file = await response.blob();
|
||||||
|
file.name = name;
|
||||||
|
imageFiles.push(file);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = event.dataTransfer.files;
|
||||||
for (let file of files) {
|
for (let file of files) {
|
||||||
if (file.type.startsWith("image")) {
|
if (file.type.startsWith("image")) {
|
||||||
imageFiles.push(file);
|
imageFiles.push(file);
|
||||||
|
@ -45,7 +45,7 @@ function Image(props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ListItem(props) {
|
function ListItem(props) {
|
||||||
return <Text as="li" variant="body2" {...props} />;
|
return <Text as="li" variant="body2" my={1} {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Code({ children, value }) {
|
function Code({ children, value }) {
|
||||||
@ -157,4 +157,8 @@ function Markdown({ source, assets }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Markdown.defaultProps = {
|
||||||
|
assets: {},
|
||||||
|
};
|
||||||
|
|
||||||
export default Markdown;
|
export default Markdown;
|
||||||
|
69
src/components/Slider.js
Normal file
69
src/components/Slider.js
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { Box, Slider as ThemeSlider } from "theme-ui";
|
||||||
|
|
||||||
|
function Slider({ min, max, value, ml, mr, labelFunc, ...rest }) {
|
||||||
|
const percentValue = ((value - min) * 100) / (max - min);
|
||||||
|
|
||||||
|
const [labelVisible, setLabelVisible] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ position: "relative" }} ml={ml} mr={mr}>
|
||||||
|
{labelVisible && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "-42px",
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
left: `calc(${percentValue}% + ${-8 - percentValue * 0.15}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: "32px",
|
||||||
|
height: "32px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderRadius: "50% 50% 50% 0%",
|
||||||
|
transform: "rotate(-45deg)",
|
||||||
|
}}
|
||||||
|
bg="primary"
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
fontFamily: "body2",
|
||||||
|
fontWeight: "caption",
|
||||||
|
fontSize: 0,
|
||||||
|
transform: "rotate(45deg)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{labelFunc(value)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<ThemeSlider
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
value={value}
|
||||||
|
onMouseDown={() => setLabelVisible(true)}
|
||||||
|
onMouseUp={() => setLabelVisible(false)}
|
||||||
|
onTouchStart={() => setLabelVisible(true)}
|
||||||
|
onTouchEnd={() => setLabelVisible(false)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Slider.defaultProps = {
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
value: 0,
|
||||||
|
ml: 0,
|
||||||
|
mr: 0,
|
||||||
|
labelFunc: (value) => value,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Slider;
|
@ -10,18 +10,37 @@ function Tile({
|
|||||||
onSelect,
|
onSelect,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDoubleClick,
|
onDoubleClick,
|
||||||
large,
|
size,
|
||||||
canEdit,
|
canEdit,
|
||||||
badges,
|
badges,
|
||||||
editTitle,
|
editTitle,
|
||||||
}) {
|
}) {
|
||||||
|
let width;
|
||||||
|
let margin;
|
||||||
|
switch (size) {
|
||||||
|
case "small":
|
||||||
|
width = "24%";
|
||||||
|
margin = "0.5%";
|
||||||
|
break;
|
||||||
|
case "medium":
|
||||||
|
width = "32%";
|
||||||
|
margin = `${2 / 3}%`;
|
||||||
|
break;
|
||||||
|
case "large":
|
||||||
|
width = "48%";
|
||||||
|
margin = "1%";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
width = "32%";
|
||||||
|
margin = `${2 / 3}%`;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Flex
|
<Flex
|
||||||
sx={{
|
sx={{
|
||||||
position: "relative",
|
position: "relative",
|
||||||
width: large ? "48%" : "32%",
|
width: width,
|
||||||
height: "0",
|
height: "0",
|
||||||
paddingTop: large ? "48%" : "32%",
|
paddingTop: width,
|
||||||
borderRadius: "4px",
|
borderRadius: "4px",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@ -30,7 +49,7 @@ function Tile({
|
|||||||
userSelect: "none",
|
userSelect: "none",
|
||||||
}}
|
}}
|
||||||
my={1}
|
my={1}
|
||||||
mx={`${large ? 1 : 2 / 3}%`}
|
mx={margin}
|
||||||
bg="muted"
|
bg="muted"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@ -126,7 +145,7 @@ Tile.defaultProps = {
|
|||||||
onSelect: () => {},
|
onSelect: () => {},
|
||||||
onEdit: () => {},
|
onEdit: () => {},
|
||||||
onDoubleClick: () => {},
|
onDoubleClick: () => {},
|
||||||
large: false,
|
size: "medium",
|
||||||
canEdit: false,
|
canEdit: false,
|
||||||
badges: [],
|
badges: [],
|
||||||
editTitle: "Edit",
|
editTitle: "Edit",
|
||||||
|
@ -8,14 +8,16 @@ import MapDrawing from "./MapDrawing";
|
|||||||
import MapFog from "./MapFog";
|
import MapFog from "./MapFog";
|
||||||
import MapGrid from "./MapGrid";
|
import MapGrid from "./MapGrid";
|
||||||
import MapMeasure from "./MapMeasure";
|
import MapMeasure from "./MapMeasure";
|
||||||
import MapLoadingOverlay from "./MapLoadingOverlay";
|
|
||||||
import NetworkedMapPointer from "../../network/NetworkedMapPointer";
|
import NetworkedMapPointer from "../../network/NetworkedMapPointer";
|
||||||
|
import MapNotes from "./MapNotes";
|
||||||
|
|
||||||
import TokenDataContext from "../../contexts/TokenDataContext";
|
import TokenDataContext from "../../contexts/TokenDataContext";
|
||||||
import SettingsContext from "../../contexts/SettingsContext";
|
import SettingsContext from "../../contexts/SettingsContext";
|
||||||
|
|
||||||
import TokenMenu from "../token/TokenMenu";
|
import TokenMenu from "../token/TokenMenu";
|
||||||
import TokenDragOverlay from "../token/TokenDragOverlay";
|
import TokenDragOverlay from "../token/TokenDragOverlay";
|
||||||
|
import NoteMenu from "../note/NoteMenu";
|
||||||
|
import NoteDragOverlay from "../note/NoteDragOverlay";
|
||||||
|
|
||||||
import { drawActionsToShapes } from "../../helpers/drawing";
|
import { drawActionsToShapes } from "../../helpers/drawing";
|
||||||
|
|
||||||
@ -32,9 +34,12 @@ function Map({
|
|||||||
onFogDraw,
|
onFogDraw,
|
||||||
onFogDrawUndo,
|
onFogDrawUndo,
|
||||||
onFogDrawRedo,
|
onFogDrawRedo,
|
||||||
|
onMapNoteChange,
|
||||||
|
onMapNoteRemove,
|
||||||
allowMapDrawing,
|
allowMapDrawing,
|
||||||
allowFogDrawing,
|
allowFogDrawing,
|
||||||
allowMapChange,
|
allowMapChange,
|
||||||
|
allowNoteEditing,
|
||||||
disabledTokens,
|
disabledTokens,
|
||||||
session,
|
session,
|
||||||
}) {
|
}) {
|
||||||
@ -100,8 +105,8 @@ function Map({
|
|||||||
onFogDraw({ type: "add", shapes: [shape] });
|
onFogDraw({ type: "add", shapes: [shape] });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFogShapeSubtract(shape) {
|
function handleFogShapeCut(shape) {
|
||||||
onFogDraw({ type: "subtract", shapes: [shape] });
|
onFogDraw({ type: "cut", shapes: [shape] });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFogShapesRemove(shapeIds) {
|
function handleFogShapesRemove(shapeIds) {
|
||||||
@ -140,6 +145,9 @@ function Map({
|
|||||||
if (!allowMapChange) {
|
if (!allowMapChange) {
|
||||||
disabledControls.push("map");
|
disabledControls.push("map");
|
||||||
}
|
}
|
||||||
|
if (!allowNoteEditing) {
|
||||||
|
disabledControls.push("note");
|
||||||
|
}
|
||||||
|
|
||||||
const disabledSettings = { fog: [], drawing: [] };
|
const disabledSettings = { fog: [], drawing: [] };
|
||||||
if (mapShapes.length === 0) {
|
if (mapShapes.length === 0) {
|
||||||
@ -182,7 +190,7 @@ function Map({
|
|||||||
|
|
||||||
const [isTokenMenuOpen, setIsTokenMenuOpen] = useState(false);
|
const [isTokenMenuOpen, setIsTokenMenuOpen] = useState(false);
|
||||||
const [tokenMenuOptions, setTokenMenuOptions] = useState({});
|
const [tokenMenuOptions, setTokenMenuOptions] = useState({});
|
||||||
const [draggingTokenOptions, setDraggingTokenOptions] = useState();
|
const [tokenDraggingOptions, setTokenDraggingOptions] = useState();
|
||||||
function handleTokenMenuOpen(tokenStateId, tokenImage) {
|
function handleTokenMenuOpen(tokenStateId, tokenImage) {
|
||||||
setTokenMenuOptions({ tokenStateId, tokenImage });
|
setTokenMenuOptions({ tokenStateId, tokenImage });
|
||||||
setIsTokenMenuOpen(true);
|
setIsTokenMenuOpen(true);
|
||||||
@ -202,7 +210,7 @@ function Map({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sort so vehicles render below other tokens
|
// Sort so vehicles render below other tokens
|
||||||
function sortMapTokenStates(a, b, draggingTokenOptions) {
|
function sortMapTokenStates(a, b, tokenDraggingOptions) {
|
||||||
const tokenA = tokensById[a.tokenId];
|
const tokenA = tokensById[a.tokenId];
|
||||||
const tokenB = tokensById[b.tokenId];
|
const tokenB = tokensById[b.tokenId];
|
||||||
if (tokenA && tokenB) {
|
if (tokenA && tokenB) {
|
||||||
@ -212,16 +220,16 @@ function Map({
|
|||||||
const bWeight = getMapTokenCategoryWeight(tokenB.category);
|
const bWeight = getMapTokenCategoryWeight(tokenB.category);
|
||||||
return bWeight - aWeight;
|
return bWeight - aWeight;
|
||||||
} else if (
|
} else if (
|
||||||
draggingTokenOptions &&
|
tokenDraggingOptions &&
|
||||||
draggingTokenOptions.dragging &&
|
tokenDraggingOptions.dragging &&
|
||||||
draggingTokenOptions.tokenState.id === a.id
|
tokenDraggingOptions.tokenState.id === a.id
|
||||||
) {
|
) {
|
||||||
// If dragging token a move above
|
// If dragging token a move above
|
||||||
return 1;
|
return 1;
|
||||||
} else if (
|
} else if (
|
||||||
draggingTokenOptions &&
|
tokenDraggingOptions &&
|
||||||
draggingTokenOptions.dragging &&
|
tokenDraggingOptions.dragging &&
|
||||||
draggingTokenOptions.tokenState.id === b.id
|
tokenDraggingOptions.tokenState.id === b.id
|
||||||
) {
|
) {
|
||||||
// If dragging token b move above
|
// If dragging token b move above
|
||||||
return -1;
|
return -1;
|
||||||
@ -241,7 +249,7 @@ function Map({
|
|||||||
const mapTokens = map && mapState && (
|
const mapTokens = map && mapState && (
|
||||||
<Group>
|
<Group>
|
||||||
{Object.values(mapState.tokens)
|
{Object.values(mapState.tokens)
|
||||||
.sort((a, b) => sortMapTokenStates(a, b, draggingTokenOptions))
|
.sort((a, b) => sortMapTokenStates(a, b, tokenDraggingOptions))
|
||||||
.map((tokenState) => (
|
.map((tokenState) => (
|
||||||
<MapToken
|
<MapToken
|
||||||
key={tokenState.id}
|
key={tokenState.id}
|
||||||
@ -251,15 +259,15 @@ function Map({
|
|||||||
onTokenStateChange={onMapTokenStateChange}
|
onTokenStateChange={onMapTokenStateChange}
|
||||||
onTokenMenuOpen={handleTokenMenuOpen}
|
onTokenMenuOpen={handleTokenMenuOpen}
|
||||||
onTokenDragStart={(e) =>
|
onTokenDragStart={(e) =>
|
||||||
setDraggingTokenOptions({
|
setTokenDraggingOptions({
|
||||||
dragging: true,
|
dragging: true,
|
||||||
tokenState,
|
tokenState,
|
||||||
tokenGroup: e.target,
|
tokenGroup: e.target,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
onTokenDragEnd={() =>
|
onTokenDragEnd={() =>
|
||||||
setDraggingTokenOptions({
|
setTokenDraggingOptions({
|
||||||
...draggingTokenOptions,
|
...tokenDraggingOptions,
|
||||||
dragging: false,
|
dragging: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -287,17 +295,17 @@ function Map({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const tokenDragOverlay = draggingTokenOptions && (
|
const tokenDragOverlay = tokenDraggingOptions && (
|
||||||
<TokenDragOverlay
|
<TokenDragOverlay
|
||||||
onTokenStateRemove={(state) => {
|
onTokenStateRemove={(state) => {
|
||||||
onMapTokenStateRemove(state);
|
onMapTokenStateRemove(state);
|
||||||
setDraggingTokenOptions(null);
|
setTokenDraggingOptions(null);
|
||||||
}}
|
}}
|
||||||
onTokenStateChange={onMapTokenStateChange}
|
onTokenStateChange={onMapTokenStateChange}
|
||||||
tokenState={draggingTokenOptions && draggingTokenOptions.tokenState}
|
tokenState={tokenDraggingOptions && tokenDraggingOptions.tokenState}
|
||||||
tokenGroup={draggingTokenOptions && draggingTokenOptions.tokenGroup}
|
tokenGroup={tokenDraggingOptions && tokenDraggingOptions.tokenGroup}
|
||||||
dragging={draggingTokenOptions && draggingTokenOptions.dragging}
|
dragging={!!(tokenDraggingOptions && tokenDraggingOptions.dragging)}
|
||||||
token={tokensById[draggingTokenOptions.tokenState.tokenId]}
|
token={tokensById[tokenDraggingOptions.tokenState.tokenId]}
|
||||||
mapState={mapState}
|
mapState={mapState}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@ -309,7 +317,6 @@ function Map({
|
|||||||
onShapeAdd={handleMapShapeAdd}
|
onShapeAdd={handleMapShapeAdd}
|
||||||
onShapesRemove={handleMapShapesRemove}
|
onShapesRemove={handleMapShapesRemove}
|
||||||
active={selectedToolId === "drawing"}
|
active={selectedToolId === "drawing"}
|
||||||
toolId="drawing"
|
|
||||||
toolSettings={settings.drawing}
|
toolSettings={settings.drawing}
|
||||||
gridSize={gridSizeNormalized}
|
gridSize={gridSizeNormalized}
|
||||||
/>
|
/>
|
||||||
@ -320,14 +327,13 @@ function Map({
|
|||||||
map={map}
|
map={map}
|
||||||
shapes={fogShapes}
|
shapes={fogShapes}
|
||||||
onShapeAdd={handleFogShapeAdd}
|
onShapeAdd={handleFogShapeAdd}
|
||||||
onShapeSubtract={handleFogShapeSubtract}
|
onShapeCut={handleFogShapeCut}
|
||||||
onShapesRemove={handleFogShapesRemove}
|
onShapesRemove={handleFogShapesRemove}
|
||||||
onShapesEdit={handleFogShapesEdit}
|
onShapesEdit={handleFogShapesEdit}
|
||||||
active={selectedToolId === "fog"}
|
active={selectedToolId === "fog"}
|
||||||
toolId="fog"
|
|
||||||
toolSettings={settings.fog}
|
toolSettings={settings.fog}
|
||||||
gridSize={gridSizeNormalized}
|
gridSize={gridSizeNormalized}
|
||||||
transparent={allowFogDrawing && !settings.fog.preview}
|
editable={allowFogDrawing && !settings.fog.preview}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -350,15 +356,98 @@ function Map({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [isNoteMenuOpen, setIsNoteMenuOpen] = useState(false);
|
||||||
|
const [noteMenuOptions, setNoteMenuOptions] = useState({});
|
||||||
|
const [noteDraggingOptions, setNoteDraggingOptions] = useState();
|
||||||
|
function handleNoteMenuOpen(noteId, noteNode) {
|
||||||
|
setNoteMenuOptions({ noteId, noteNode });
|
||||||
|
setIsNoteMenuOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortNotes(a, b, noteDraggingOptions) {
|
||||||
|
if (
|
||||||
|
noteDraggingOptions &&
|
||||||
|
noteDraggingOptions.dragging &&
|
||||||
|
noteDraggingOptions.noteId === a.id
|
||||||
|
) {
|
||||||
|
// If dragging token `a` move above
|
||||||
|
return 1;
|
||||||
|
} else if (
|
||||||
|
noteDraggingOptions &&
|
||||||
|
noteDraggingOptions.dragging &&
|
||||||
|
noteDraggingOptions.noteId === b.id
|
||||||
|
) {
|
||||||
|
// If dragging token `b` move above
|
||||||
|
return -1;
|
||||||
|
} else {
|
||||||
|
// Else sort so last modified is on top
|
||||||
|
return a.lastModified - b.lastModified;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapNotes = (
|
||||||
|
<MapNotes
|
||||||
|
map={map}
|
||||||
|
active={selectedToolId === "note"}
|
||||||
|
gridSize={gridSizeNormalized}
|
||||||
|
selectedToolSettings={settings[selectedToolId]}
|
||||||
|
onNoteAdd={onMapNoteChange}
|
||||||
|
onNoteChange={onMapNoteChange}
|
||||||
|
notes={
|
||||||
|
mapState
|
||||||
|
? Object.values(mapState.notes).sort((a, b) =>
|
||||||
|
sortNotes(a, b, noteDraggingOptions)
|
||||||
|
)
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
onNoteMenuOpen={handleNoteMenuOpen}
|
||||||
|
draggable={
|
||||||
|
allowNoteEditing &&
|
||||||
|
(selectedToolId === "note" || selectedToolId === "pan")
|
||||||
|
}
|
||||||
|
onNoteDragStart={(e, noteId) =>
|
||||||
|
setNoteDraggingOptions({ dragging: true, noteId, noteGroup: e.target })
|
||||||
|
}
|
||||||
|
onNoteDragEnd={() =>
|
||||||
|
setNoteDraggingOptions({ ...noteDraggingOptions, dragging: false })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const noteMenu = (
|
||||||
|
<NoteMenu
|
||||||
|
isOpen={isNoteMenuOpen}
|
||||||
|
onRequestClose={() => setIsNoteMenuOpen(false)}
|
||||||
|
onNoteChange={onMapNoteChange}
|
||||||
|
note={mapState && mapState.notes[noteMenuOptions.noteId]}
|
||||||
|
noteNode={noteMenuOptions.noteNode}
|
||||||
|
map={map}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const noteDragOverlay = (
|
||||||
|
<NoteDragOverlay
|
||||||
|
dragging={!!(noteDraggingOptions && noteDraggingOptions.dragging)}
|
||||||
|
noteGroup={noteDraggingOptions && noteDraggingOptions.noteGroup}
|
||||||
|
noteId={noteDraggingOptions && noteDraggingOptions.noteId}
|
||||||
|
onNoteRemove={(noteId) => {
|
||||||
|
onMapNoteRemove(noteId);
|
||||||
|
setNoteDraggingOptions(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MapInteraction
|
<MapInteraction
|
||||||
map={map}
|
map={map}
|
||||||
|
mapState={mapState}
|
||||||
controls={
|
controls={
|
||||||
<>
|
<>
|
||||||
{mapControls}
|
{mapControls}
|
||||||
{tokenMenu}
|
{tokenMenu}
|
||||||
|
{noteMenu}
|
||||||
{tokenDragOverlay}
|
{tokenDragOverlay}
|
||||||
<MapLoadingOverlay />
|
{noteDragOverlay}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
selectedToolId={selectedToolId}
|
selectedToolId={selectedToolId}
|
||||||
@ -366,6 +455,7 @@ function Map({
|
|||||||
disabledControls={disabledControls}
|
disabledControls={disabledControls}
|
||||||
>
|
>
|
||||||
{mapGrid}
|
{mapGrid}
|
||||||
|
{mapNotes}
|
||||||
{mapDrawing}
|
{mapDrawing}
|
||||||
{mapTokens}
|
{mapTokens}
|
||||||
{mapFog}
|
{mapFog}
|
||||||
|
@ -18,6 +18,7 @@ import ExpandMoreIcon from "../../icons/ExpandMoreIcon";
|
|||||||
import PointerToolIcon from "../../icons/PointerToolIcon";
|
import PointerToolIcon from "../../icons/PointerToolIcon";
|
||||||
import FullScreenIcon from "../../icons/FullScreenIcon";
|
import FullScreenIcon from "../../icons/FullScreenIcon";
|
||||||
import FullScreenExitIcon from "../../icons/FullScreenExitIcon";
|
import FullScreenExitIcon from "../../icons/FullScreenExitIcon";
|
||||||
|
import NoteToolIcon from "../../icons/NoteToolIcon";
|
||||||
|
|
||||||
import useSetting from "../../helpers/useSetting";
|
import useSetting from "../../helpers/useSetting";
|
||||||
|
|
||||||
@ -66,8 +67,13 @@ function MapContols({
|
|||||||
icon: <PointerToolIcon />,
|
icon: <PointerToolIcon />,
|
||||||
title: "Pointer Tool (Q)",
|
title: "Pointer Tool (Q)",
|
||||||
},
|
},
|
||||||
|
note: {
|
||||||
|
id: "note",
|
||||||
|
icon: <NoteToolIcon />,
|
||||||
|
title: "Note Tool (N)",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const tools = ["pan", "fog", "drawing", "measure", "pointer"];
|
const tools = ["pan", "fog", "drawing", "measure", "pointer", "note"];
|
||||||
|
|
||||||
const sections = [
|
const sections = [
|
||||||
{
|
{
|
||||||
|
@ -23,7 +23,6 @@ function MapDrawing({
|
|||||||
onShapeAdd,
|
onShapeAdd,
|
||||||
onShapesRemove,
|
onShapesRemove,
|
||||||
active,
|
active,
|
||||||
toolId,
|
|
||||||
toolSettings,
|
toolSettings,
|
||||||
gridSize,
|
gridSize,
|
||||||
}) {
|
}) {
|
||||||
@ -35,7 +34,7 @@ function MapDrawing({
|
|||||||
const [isBrushDown, setIsBrushDown] = useState(false);
|
const [isBrushDown, setIsBrushDown] = useState(false);
|
||||||
const [erasingShapes, setErasingShapes] = useState([]);
|
const [erasingShapes, setErasingShapes] = useState([]);
|
||||||
|
|
||||||
const shouldHover = toolSettings.type === "erase";
|
const shouldHover = toolSettings.type === "erase" && active;
|
||||||
const isBrush =
|
const isBrush =
|
||||||
toolSettings.type === "brush" || toolSettings.type === "paint";
|
toolSettings.type === "brush" || toolSettings.type === "paint";
|
||||||
const isShape =
|
const isShape =
|
||||||
@ -55,8 +54,8 @@ function MapDrawing({
|
|||||||
return getBrushPositionForTool(
|
return getBrushPositionForTool(
|
||||||
map,
|
map,
|
||||||
getRelativePointerPositionNormalized(mapImage),
|
getRelativePointerPositionNormalized(mapImage),
|
||||||
toolId,
|
map.snapToGrid && isShape,
|
||||||
toolSettings,
|
false,
|
||||||
gridSize,
|
gridSize,
|
||||||
shapes
|
shapes
|
||||||
);
|
);
|
||||||
|
@ -8,6 +8,7 @@ import usePreventOverscroll from "../../helpers/usePreventOverscroll";
|
|||||||
import useStageInteraction from "../../helpers/useStageInteraction";
|
import useStageInteraction from "../../helpers/useStageInteraction";
|
||||||
import useImageCenter from "../../helpers/useImageCenter";
|
import useImageCenter from "../../helpers/useImageCenter";
|
||||||
import { getMapDefaultInset, getMapMaxZoom } from "../../helpers/map";
|
import { getMapDefaultInset, getMapMaxZoom } from "../../helpers/map";
|
||||||
|
import useResponsiveLayout from "../../helpers/useResponsiveLayout";
|
||||||
|
|
||||||
import { MapInteractionProvider } from "../../contexts/MapInteractionContext";
|
import { MapInteractionProvider } from "../../contexts/MapInteractionContext";
|
||||||
import KeyboardContext from "../../contexts/KeyboardContext";
|
import KeyboardContext from "../../contexts/KeyboardContext";
|
||||||
@ -104,11 +105,14 @@ function MapEditor({ map, onSettingsChange }) {
|
|||||||
map.grid.inset.topLeft.y !== defaultInset.topLeft.y ||
|
map.grid.inset.topLeft.y !== defaultInset.topLeft.y ||
|
||||||
map.grid.inset.bottomRight.x !== defaultInset.bottomRight.x ||
|
map.grid.inset.bottomRight.x !== defaultInset.bottomRight.x ||
|
||||||
map.grid.inset.bottomRight.y !== defaultInset.bottomRight.y;
|
map.grid.inset.bottomRight.y !== defaultInset.bottomRight.y;
|
||||||
|
|
||||||
|
const layout = useResponsiveLayout();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "300px",
|
height: layout.screenSize === "large" ? "500px" : "300px",
|
||||||
cursor: "move",
|
cursor: "move",
|
||||||
touchAction: "none",
|
touchAction: "none",
|
||||||
outline: "none",
|
outline: "none",
|
||||||
|
@ -1,6 +1,12 @@
|
|||||||
import React, { useContext, useState, useEffect, useCallback } from "react";
|
import React, {
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useCallback,
|
||||||
|
useRef,
|
||||||
|
} from "react";
|
||||||
import shortid from "shortid";
|
import shortid from "shortid";
|
||||||
import { Group } from "react-konva";
|
import { Group, Rect } from "react-konva";
|
||||||
import useImage from "use-image";
|
import useImage from "use-image";
|
||||||
|
|
||||||
import diagonalPattern from "../../images/DiagonalPattern.png";
|
import diagonalPattern from "../../images/DiagonalPattern.png";
|
||||||
@ -13,6 +19,7 @@ import {
|
|||||||
getBrushPositionForTool,
|
getBrushPositionForTool,
|
||||||
simplifyPoints,
|
simplifyPoints,
|
||||||
getStrokeWidth,
|
getStrokeWidth,
|
||||||
|
mergeShapes,
|
||||||
} from "../../helpers/drawing";
|
} from "../../helpers/drawing";
|
||||||
import colors from "../../helpers/colors";
|
import colors from "../../helpers/colors";
|
||||||
import {
|
import {
|
||||||
@ -21,19 +28,19 @@ import {
|
|||||||
Tick,
|
Tick,
|
||||||
} from "../../helpers/konva";
|
} from "../../helpers/konva";
|
||||||
import useKeyboard from "../../helpers/useKeyboard";
|
import useKeyboard from "../../helpers/useKeyboard";
|
||||||
|
import useDebounce from "../../helpers/useDebounce";
|
||||||
|
|
||||||
function MapFog({
|
function MapFog({
|
||||||
map,
|
map,
|
||||||
shapes,
|
shapes,
|
||||||
onShapeAdd,
|
onShapeAdd,
|
||||||
onShapeSubtract,
|
onShapeCut,
|
||||||
onShapesRemove,
|
onShapesRemove,
|
||||||
onShapesEdit,
|
onShapesEdit,
|
||||||
active,
|
active,
|
||||||
toolId,
|
|
||||||
toolSettings,
|
toolSettings,
|
||||||
gridSize,
|
gridSize,
|
||||||
transparent,
|
editable,
|
||||||
}) {
|
}) {
|
||||||
const { stageScale, mapWidth, mapHeight, interactionEmitter } = useContext(
|
const { stageScale, mapWidth, mapHeight, interactionEmitter } = useContext(
|
||||||
MapInteractionContext
|
MapInteractionContext
|
||||||
@ -45,12 +52,13 @@ function MapFog({
|
|||||||
|
|
||||||
const shouldHover =
|
const shouldHover =
|
||||||
active &&
|
active &&
|
||||||
|
editable &&
|
||||||
(toolSettings.type === "toggle" || toolSettings.type === "remove");
|
(toolSettings.type === "toggle" || toolSettings.type === "remove");
|
||||||
|
|
||||||
const [patternImage] = useImage(diagonalPattern);
|
const [patternImage] = useImage(diagonalPattern);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!active) {
|
if (!active || !editable) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,8 +69,10 @@ function MapFog({
|
|||||||
return getBrushPositionForTool(
|
return getBrushPositionForTool(
|
||||||
map,
|
map,
|
||||||
getRelativePointerPositionNormalized(mapImage),
|
getRelativePointerPositionNormalized(mapImage),
|
||||||
toolId,
|
map.snapToGrid &&
|
||||||
toolSettings,
|
(toolSettings.type === "polygon" ||
|
||||||
|
toolSettings.type === "rectangle"),
|
||||||
|
toolSettings.useEdgeSnapping,
|
||||||
gridSize,
|
gridSize,
|
||||||
shapes
|
shapes
|
||||||
);
|
);
|
||||||
@ -78,8 +88,25 @@ function MapFog({
|
|||||||
holes: [],
|
holes: [],
|
||||||
},
|
},
|
||||||
strokeWidth: 0.5,
|
strokeWidth: 0.5,
|
||||||
color: toolSettings.useFogSubtract ? "red" : "black",
|
color: toolSettings.useFogCut ? "red" : "black",
|
||||||
blend: false,
|
id: shortid.generate(),
|
||||||
|
visible: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (toolSettings.type === "rectangle") {
|
||||||
|
setDrawingShape({
|
||||||
|
type: "fog",
|
||||||
|
data: {
|
||||||
|
points: [
|
||||||
|
brushPosition,
|
||||||
|
brushPosition,
|
||||||
|
brushPosition,
|
||||||
|
brushPosition,
|
||||||
|
],
|
||||||
|
holes: [],
|
||||||
|
},
|
||||||
|
strokeWidth: 0.5,
|
||||||
|
color: toolSettings.useFogCut ? "red" : "black",
|
||||||
id: shortid.generate(),
|
id: shortid.generate(),
|
||||||
visible: true,
|
visible: true,
|
||||||
});
|
});
|
||||||
@ -110,15 +137,35 @@ function MapFog({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (toolSettings.type === "rectangle" && isBrushDown && drawingShape) {
|
||||||
|
const brushPosition = getBrushPosition();
|
||||||
|
setDrawingShape((prevShape) => {
|
||||||
|
const prevPoints = prevShape.data.points;
|
||||||
|
return {
|
||||||
|
...prevShape,
|
||||||
|
data: {
|
||||||
|
...prevShape.data,
|
||||||
|
points: [
|
||||||
|
prevPoints[0],
|
||||||
|
{ x: brushPosition.x, y: prevPoints[1].y },
|
||||||
|
brushPosition,
|
||||||
|
{ x: prevPoints[3].x, y: brushPosition.y },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleBrushUp() {
|
function handleBrushUp() {
|
||||||
if (toolSettings.type === "brush" && drawingShape) {
|
if (
|
||||||
const subtract = toolSettings.useFogSubtract;
|
toolSettings.type === "brush" ||
|
||||||
|
(toolSettings.type === "rectangle" && drawingShape)
|
||||||
|
) {
|
||||||
|
const cut = toolSettings.useFogCut;
|
||||||
if (drawingShape.data.points.length > 1) {
|
if (drawingShape.data.points.length > 1) {
|
||||||
let shapeData = {};
|
let shapeData = {};
|
||||||
if (subtract) {
|
if (cut) {
|
||||||
shapeData = { id: drawingShape.id, type: drawingShape.type };
|
shapeData = { id: drawingShape.id, type: drawingShape.type };
|
||||||
} else {
|
} else {
|
||||||
shapeData = { ...drawingShape, color: "black" };
|
shapeData = { ...drawingShape, color: "black" };
|
||||||
@ -135,8 +182,8 @@ function MapFog({
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
if (subtract) {
|
if (cut) {
|
||||||
onShapeSubtract(shape);
|
onShapeCut(shape);
|
||||||
} else {
|
} else {
|
||||||
onShapeAdd(shape);
|
onShapeAdd(shape);
|
||||||
}
|
}
|
||||||
@ -169,8 +216,7 @@ function MapFog({
|
|||||||
holes: [],
|
holes: [],
|
||||||
},
|
},
|
||||||
strokeWidth: 0.5,
|
strokeWidth: 0.5,
|
||||||
color: toolSettings.useFogSubtract ? "red" : "black",
|
color: toolSettings.useFogCut ? "red" : "black",
|
||||||
blend: false,
|
|
||||||
id: shortid.generate(),
|
id: shortid.generate(),
|
||||||
visible: true,
|
visible: true,
|
||||||
};
|
};
|
||||||
@ -216,14 +262,14 @@ function MapFog({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const finishDrawingPolygon = useCallback(() => {
|
const finishDrawingPolygon = useCallback(() => {
|
||||||
const subtract = toolSettings.useFogSubtract;
|
const cut = toolSettings.useFogCut;
|
||||||
const data = {
|
const data = {
|
||||||
...drawingShape.data,
|
...drawingShape.data,
|
||||||
// Remove the last point as it hasn't been placed yet
|
// Remove the last point as it hasn't been placed yet
|
||||||
points: drawingShape.data.points.slice(0, -1),
|
points: drawingShape.data.points.slice(0, -1),
|
||||||
};
|
};
|
||||||
if (subtract) {
|
if (cut) {
|
||||||
onShapeSubtract({
|
onShapeCut({
|
||||||
id: drawingShape.id,
|
id: drawingShape.id,
|
||||||
type: drawingShape.type,
|
type: drawingShape.type,
|
||||||
data: data,
|
data: data,
|
||||||
@ -233,7 +279,7 @@ function MapFog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setDrawingShape(null);
|
setDrawingShape(null);
|
||||||
}, [toolSettings, drawingShape, onShapeSubtract, onShapeAdd]);
|
}, [toolSettings, drawingShape, onShapeCut, onShapeAdd]);
|
||||||
|
|
||||||
// Add keyboard shortcuts
|
// Add keyboard shortcuts
|
||||||
function handleKeyDown({ key }) {
|
function handleKeyDown({ key }) {
|
||||||
@ -243,30 +289,22 @@ function MapFog({
|
|||||||
if (key === "Escape" && drawingShape) {
|
if (key === "Escape" && drawingShape) {
|
||||||
setDrawingShape(null);
|
setDrawingShape(null);
|
||||||
}
|
}
|
||||||
if (key === "Alt" && drawingShape) {
|
|
||||||
updateShapeColor();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyUp({ key }) {
|
useKeyboard(handleKeyDown);
|
||||||
if (key === "Alt" && drawingShape) {
|
|
||||||
updateShapeColor();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateShapeColor() {
|
// Update shape color when useFogCut changes
|
||||||
|
useEffect(() => {
|
||||||
setDrawingShape((prevShape) => {
|
setDrawingShape((prevShape) => {
|
||||||
if (!prevShape) {
|
if (!prevShape) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...prevShape,
|
...prevShape,
|
||||||
color: toolSettings.useFogSubtract ? "black" : "red",
|
color: toolSettings.useFogCut ? "red" : "black",
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}, [toolSettings.useFogCut]);
|
||||||
|
|
||||||
useKeyboard(handleKeyDown, handleKeyUp);
|
|
||||||
|
|
||||||
function eraseHoveredShapes() {
|
function eraseHoveredShapes() {
|
||||||
// Erase
|
// Erase
|
||||||
@ -323,14 +361,16 @@ function MapFog({
|
|||||||
mapWidth,
|
mapWidth,
|
||||||
mapHeight
|
mapHeight
|
||||||
)}
|
)}
|
||||||
visible={(active && !toolSettings.preview) || shape.visible}
|
opacity={editable ? 0.5 : 1}
|
||||||
opacity={transparent ? 0.5 : 1}
|
|
||||||
fillPatternImage={patternImage}
|
fillPatternImage={patternImage}
|
||||||
fillPriority={active && !shape.visible ? "pattern" : "color"}
|
fillPriority={active && !shape.visible ? "pattern" : "color"}
|
||||||
holes={holes}
|
holes={holes}
|
||||||
// Disable collision if the fog is transparent and we're not editing it
|
// Disable collision if the fog is transparent and we're not editing it
|
||||||
// This allows tokens to be moved under the fog
|
// This allows tokens to be moved under the fog
|
||||||
hitFunc={transparent && !active ? () => {} : undefined}
|
hitFunc={editable && !active ? () => {} : undefined}
|
||||||
|
shadowColor={editable ? "rgba(0, 0, 0, 0)" : "rgba(0, 0, 0, 0.33)"}
|
||||||
|
shadowOffset={{ x: 0, y: 5 }}
|
||||||
|
shadowBlur={10}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -366,9 +406,51 @@ function MapFog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [fogShapes, setFogShapes] = useState(shapes);
|
||||||
|
useEffect(() => {
|
||||||
|
function shapeVisible(shape) {
|
||||||
|
return (active && !toolSettings.preview) || shape.visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editable) {
|
||||||
|
setFogShapes(shapes.filter(shapeVisible));
|
||||||
|
} else {
|
||||||
|
setFogShapes(mergeShapes(shapes));
|
||||||
|
}
|
||||||
|
}, [shapes, editable, active, toolSettings]);
|
||||||
|
|
||||||
|
const fogGroupRef = useRef();
|
||||||
|
const debouncedStageScale = useDebounce(stageScale, 50);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fogGroup = fogGroupRef.current;
|
||||||
|
|
||||||
|
const canvas = fogGroup.getChildren()[0].getCanvas();
|
||||||
|
const pixelRatio = canvas.pixelRatio || 1;
|
||||||
|
|
||||||
|
// Constrain fog buffer to the map resolution
|
||||||
|
const fogRect = fogGroup.getClientRect();
|
||||||
|
const maxMapSize = map ? Math.max(map.width, map.height) : 4096; // Default to 4096
|
||||||
|
const maxFogSize =
|
||||||
|
Math.max(fogRect.width, fogRect.height) / debouncedStageScale;
|
||||||
|
const maxPixelRatio = maxMapSize / maxFogSize;
|
||||||
|
|
||||||
|
fogGroup.cache({
|
||||||
|
pixelRatio: Math.min(
|
||||||
|
Math.max(debouncedStageScale * pixelRatio, 1),
|
||||||
|
maxPixelRatio
|
||||||
|
),
|
||||||
|
});
|
||||||
|
fogGroup.getLayer().draw();
|
||||||
|
}, [fogShapes, editable, active, debouncedStageScale, mapWidth, map]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group>
|
<Group>
|
||||||
{shapes.map(renderShape)}
|
<Group ref={fogGroupRef}>
|
||||||
|
{/* Render a blank shape so cache works with no fog shapes */}
|
||||||
|
<Rect width={1} height={1} />
|
||||||
|
{fogShapes.map(renderShape)}
|
||||||
|
</Group>
|
||||||
{drawingShape && renderShape(drawingShape)}
|
{drawingShape && renderShape(drawingShape)}
|
||||||
{drawingShape &&
|
{drawingShape &&
|
||||||
toolSettings &&
|
toolSettings &&
|
||||||
|
@ -21,6 +21,7 @@ import KeyboardContext from "../../contexts/KeyboardContext";
|
|||||||
|
|
||||||
function MapInteraction({
|
function MapInteraction({
|
||||||
map,
|
map,
|
||||||
|
mapState,
|
||||||
children,
|
children,
|
||||||
controls,
|
controls,
|
||||||
selectedToolId,
|
selectedToolId,
|
||||||
@ -32,12 +33,17 @@ function MapInteraction({
|
|||||||
// Map loaded taking in to account different resolutions
|
// Map loaded taking in to account different resolutions
|
||||||
const [mapLoaded, setMapLoaded] = useState(false);
|
const [mapLoaded, setMapLoaded] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (map === null) {
|
if (
|
||||||
|
!map ||
|
||||||
|
!mapState ||
|
||||||
|
(map.type === "file" && !map.file && !map.resolutions) ||
|
||||||
|
mapState.mapId !== map.id
|
||||||
|
) {
|
||||||
setMapLoaded(false);
|
setMapLoaded(false);
|
||||||
} else if (mapImageSourceStatus === "loaded") {
|
} else if (mapImageSourceStatus === "loaded") {
|
||||||
setMapLoaded(true);
|
setMapLoaded(true);
|
||||||
}
|
}
|
||||||
}, [mapImageSourceStatus, map]);
|
}, [mapImageSourceStatus, map, mapState]);
|
||||||
|
|
||||||
const [stageWidth, setStageWidth] = useState(1);
|
const [stageWidth, setStageWidth] = useState(1);
|
||||||
const [stageHeight, setStageHeight] = useState(1);
|
const [stageHeight, setStageHeight] = useState(1);
|
||||||
@ -51,8 +57,10 @@ function MapInteraction({
|
|||||||
const mapImageRef = useRef();
|
const mapImageRef = useRef();
|
||||||
|
|
||||||
function handleResize(width, height) {
|
function handleResize(width, height) {
|
||||||
setStageWidth(width);
|
if (width > 0 && height > 0) {
|
||||||
setStageHeight(height);
|
setStageWidth(width);
|
||||||
|
setStageHeight(height);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const containerRef = useRef();
|
const containerRef = useRef();
|
||||||
@ -135,6 +143,9 @@ function MapInteraction({
|
|||||||
if (event.key === "q" && !disabledControls.includes("pointer")) {
|
if (event.key === "q" && !disabledControls.includes("pointer")) {
|
||||||
onSelectedToolChange("pointer");
|
onSelectedToolChange("pointer");
|
||||||
}
|
}
|
||||||
|
if (event.key === "n" && !disabledControls.includes("note")) {
|
||||||
|
onSelectedToolChange("note");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyUp(event) {
|
function handleKeyUp(event) {
|
||||||
@ -153,8 +164,12 @@ function MapInteraction({
|
|||||||
return "move";
|
return "move";
|
||||||
case "fog":
|
case "fog":
|
||||||
case "drawing":
|
case "drawing":
|
||||||
|
return settings.settings[tool].type === "move"
|
||||||
|
? "pointer"
|
||||||
|
: "crosshair";
|
||||||
case "measure":
|
case "measure":
|
||||||
case "pointer":
|
case "pointer":
|
||||||
|
case "note":
|
||||||
return "crosshair";
|
return "crosshair";
|
||||||
default:
|
default:
|
||||||
return "default";
|
return "default";
|
||||||
|
@ -38,10 +38,10 @@ function MapLoadingOverlay() {
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
left: "8px",
|
top: 0,
|
||||||
bottom: "8px",
|
left: 0,
|
||||||
|
right: 0,
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
borderRadius: "28px",
|
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
}}
|
}}
|
||||||
bg="overlay"
|
bg="overlay"
|
||||||
@ -50,8 +50,9 @@ function MapLoadingOverlay() {
|
|||||||
ref={progressBarRef}
|
ref={progressBarRef}
|
||||||
max={1}
|
max={1}
|
||||||
value={0}
|
value={0}
|
||||||
m={2}
|
m={0}
|
||||||
sx={{ width: "32px" }}
|
sx={{ width: "100%", borderRadius: 0, height: "4px" }}
|
||||||
|
color="primary"
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
@ -21,11 +21,26 @@ function MapMeasure({ map, selectedToolSettings, active, gridSize }) {
|
|||||||
const [drawingShapeData, setDrawingShapeData] = useState(null);
|
const [drawingShapeData, setDrawingShapeData] = useState(null);
|
||||||
const [isBrushDown, setIsBrushDown] = useState(false);
|
const [isBrushDown, setIsBrushDown] = useState(false);
|
||||||
|
|
||||||
const toolScale =
|
function parseToolScale(scale) {
|
||||||
active && selectedToolSettings.scale.match(/(\d*)([a-zA-Z]*)/);
|
if (typeof scale === "string") {
|
||||||
const toolMultiplier =
|
const match = scale.match(/(\d*)(\.\d*)?([a-zA-Z]*)/);
|
||||||
active && !isNaN(parseInt(toolScale[1])) ? parseInt(toolScale[1]) : 1;
|
const integer = parseFloat(match[1]);
|
||||||
const toolUnit = active && toolScale[2];
|
const fractional = parseFloat(match[2]);
|
||||||
|
const unit = match[3] || "";
|
||||||
|
if (!isNaN(integer) && !isNaN(fractional)) {
|
||||||
|
return {
|
||||||
|
multiplier: integer + fractional,
|
||||||
|
unit: unit,
|
||||||
|
digits: match[2].length - 1,
|
||||||
|
};
|
||||||
|
} else if (!isNaN(integer) && isNaN(fractional)) {
|
||||||
|
return { multiplier: integer, unit: unit, digits: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { multiplier: 1, unit: "", digits: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const measureScale = parseToolScale(active && selectedToolSettings.scale);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!active) {
|
if (!active) {
|
||||||
@ -38,8 +53,8 @@ function MapMeasure({ map, selectedToolSettings, active, gridSize }) {
|
|||||||
return getBrushPositionForTool(
|
return getBrushPositionForTool(
|
||||||
map,
|
map,
|
||||||
getRelativePointerPositionNormalized(mapImage),
|
getRelativePointerPositionNormalized(mapImage),
|
||||||
"drawing",
|
map.snapToGrid,
|
||||||
{ type: "line" },
|
false,
|
||||||
gridSize,
|
gridSize,
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
@ -62,9 +77,11 @@ function MapMeasure({ map, selectedToolSettings, active, gridSize }) {
|
|||||||
brushPosition,
|
brushPosition,
|
||||||
gridSize
|
gridSize
|
||||||
);
|
);
|
||||||
|
// Round the grid positions to the nearest 0.1 to aviod floating point issues
|
||||||
|
const precision = { x: 0.1, y: 0.1 };
|
||||||
const length = Vector2.distance(
|
const length = Vector2.distance(
|
||||||
Vector2.divide(points[0], gridSize),
|
Vector2.roundTo(Vector2.divide(points[0], gridSize), precision),
|
||||||
Vector2.divide(points[1], gridSize),
|
Vector2.roundTo(Vector2.divide(points[1], gridSize), precision),
|
||||||
selectedToolSettings.type
|
selectedToolSettings.type
|
||||||
);
|
);
|
||||||
setDrawingShapeData({
|
setDrawingShapeData({
|
||||||
@ -125,9 +142,9 @@ function MapMeasure({ map, selectedToolSettings, active, gridSize }) {
|
|||||||
>
|
>
|
||||||
<Tag fill="hsla(230, 25%, 18%, 0.8)" cornerRadius={4} />
|
<Tag fill="hsla(230, 25%, 18%, 0.8)" cornerRadius={4} />
|
||||||
<Text
|
<Text
|
||||||
text={`${(shapeData.length * toolMultiplier).toFixed(
|
text={`${(shapeData.length * measureScale.multiplier).toFixed(
|
||||||
2
|
measureScale.digits
|
||||||
)}${toolUnit}`}
|
)}${measureScale.unit}`}
|
||||||
fill="white"
|
fill="white"
|
||||||
fontSize={24}
|
fontSize={24}
|
||||||
padding={4}
|
padding={4}
|
||||||
|
@ -22,32 +22,28 @@ function MapMenu({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Close modal if interacting with any other element
|
// Close modal if interacting with any other element
|
||||||
function handlePointerDown(event) {
|
function handleInteraction(event) {
|
||||||
const path = event.composedPath();
|
const path = event.composedPath();
|
||||||
if (
|
if (
|
||||||
!path.includes(modalContentNode) &&
|
!path.includes(modalContentNode) &&
|
||||||
!(excludeNode && path.includes(excludeNode))
|
!(excludeNode && path.includes(excludeNode)) &&
|
||||||
|
!(event.target instanceof HTMLTextAreaElement)
|
||||||
) {
|
) {
|
||||||
onRequestClose();
|
onRequestClose();
|
||||||
document.body.removeEventListener("pointerdown", handlePointerDown);
|
document.body.removeEventListener("pointerdown", handleInteraction);
|
||||||
|
document.body.removeEventListener("wheel", handleInteraction);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (modalContentNode) {
|
if (modalContentNode) {
|
||||||
document.body.addEventListener("pointerdown", handlePointerDown);
|
document.body.addEventListener("pointerdown", handleInteraction);
|
||||||
// Check for wheel event to close modal as well
|
// Check for wheel event to close modal as well
|
||||||
document.body.addEventListener(
|
document.body.addEventListener("wheel", handleInteraction);
|
||||||
"wheel",
|
|
||||||
() => {
|
|
||||||
onRequestClose();
|
|
||||||
},
|
|
||||||
{ once: true }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (modalContentNode) {
|
if (modalContentNode) {
|
||||||
document.body.removeEventListener("pointerdown", handlePointerDown);
|
document.body.removeEventListener("pointerdown", handleInteraction);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [modalContentNode, excludeNode, onRequestClose]);
|
}, [modalContentNode, excludeNode, onRequestClose]);
|
||||||
|
126
src/components/map/MapNotes.js
Normal file
126
src/components/map/MapNotes.js
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import React, { useContext, useState, useEffect, useRef } from "react";
|
||||||
|
import shortid from "shortid";
|
||||||
|
import { Group } from "react-konva";
|
||||||
|
|
||||||
|
import MapInteractionContext from "../../contexts/MapInteractionContext";
|
||||||
|
import MapStageContext from "../../contexts/MapStageContext";
|
||||||
|
import AuthContext from "../../contexts/AuthContext";
|
||||||
|
|
||||||
|
import { getBrushPositionForTool } from "../../helpers/drawing";
|
||||||
|
import { getRelativePointerPositionNormalized } from "../../helpers/konva";
|
||||||
|
|
||||||
|
import Note from "../note/Note";
|
||||||
|
|
||||||
|
const defaultNoteSize = 2;
|
||||||
|
|
||||||
|
function MapNotes({
|
||||||
|
map,
|
||||||
|
active,
|
||||||
|
gridSize,
|
||||||
|
onNoteAdd,
|
||||||
|
onNoteChange,
|
||||||
|
notes,
|
||||||
|
onNoteMenuOpen,
|
||||||
|
draggable,
|
||||||
|
onNoteDragStart,
|
||||||
|
onNoteDragEnd,
|
||||||
|
}) {
|
||||||
|
const { interactionEmitter } = useContext(MapInteractionContext);
|
||||||
|
const { userId } = useContext(AuthContext);
|
||||||
|
const mapStageRef = useContext(MapStageContext);
|
||||||
|
const [isBrushDown, setIsBrushDown] = useState(false);
|
||||||
|
const [noteData, setNoteData] = useState(null);
|
||||||
|
|
||||||
|
const creatingNoteRef = useRef();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mapStage = mapStageRef.current;
|
||||||
|
|
||||||
|
function getBrushPosition() {
|
||||||
|
const mapImage = mapStage.findOne("#mapImage");
|
||||||
|
return getBrushPositionForTool(
|
||||||
|
map,
|
||||||
|
getRelativePointerPositionNormalized(mapImage),
|
||||||
|
map.snapToGrid,
|
||||||
|
false,
|
||||||
|
gridSize,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBrushDown() {
|
||||||
|
const brushPosition = getBrushPosition();
|
||||||
|
setNoteData({
|
||||||
|
x: brushPosition.x,
|
||||||
|
y: brushPosition.y,
|
||||||
|
size: defaultNoteSize,
|
||||||
|
text: "",
|
||||||
|
id: shortid.generate(),
|
||||||
|
lastModified: Date.now(),
|
||||||
|
lastModifiedBy: userId,
|
||||||
|
visible: true,
|
||||||
|
locked: false,
|
||||||
|
color: "yellow",
|
||||||
|
});
|
||||||
|
setIsBrushDown(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBrushMove() {
|
||||||
|
if (noteData) {
|
||||||
|
const brushPosition = getBrushPosition();
|
||||||
|
setNoteData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
x: brushPosition.x,
|
||||||
|
y: brushPosition.y,
|
||||||
|
}));
|
||||||
|
setIsBrushDown(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBrushUp() {
|
||||||
|
if (noteData) {
|
||||||
|
onNoteAdd(noteData);
|
||||||
|
onNoteMenuOpen(noteData.id, creatingNoteRef.current);
|
||||||
|
}
|
||||||
|
setNoteData(null);
|
||||||
|
setIsBrushDown(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
interactionEmitter.on("dragStart", handleBrushDown);
|
||||||
|
interactionEmitter.on("drag", handleBrushMove);
|
||||||
|
interactionEmitter.on("dragEnd", handleBrushUp);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
interactionEmitter.off("dragStart", handleBrushDown);
|
||||||
|
interactionEmitter.off("drag", handleBrushMove);
|
||||||
|
interactionEmitter.off("dragEnd", handleBrushUp);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group>
|
||||||
|
{notes.map((note) => (
|
||||||
|
<Note
|
||||||
|
note={note}
|
||||||
|
map={map}
|
||||||
|
key={note.id}
|
||||||
|
onNoteMenuOpen={onNoteMenuOpen}
|
||||||
|
draggable={draggable && !note.locked}
|
||||||
|
onNoteChange={onNoteChange}
|
||||||
|
onNoteDragStart={onNoteDragStart}
|
||||||
|
onNoteDragEnd={onNoteDragEnd}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<Group ref={creatingNoteRef}>
|
||||||
|
{isBrushDown && noteData && (
|
||||||
|
<Note note={noteData} map={map} draggable={false} />
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MapNotes;
|
@ -233,6 +233,16 @@ function MapSettings({
|
|||||||
/>
|
/>
|
||||||
Tokens
|
Tokens
|
||||||
</Label>
|
</Label>
|
||||||
|
<Label>
|
||||||
|
<Checkbox
|
||||||
|
checked={
|
||||||
|
!mapStateEmpty && mapState.editFlags.includes("notes")
|
||||||
|
}
|
||||||
|
disabled={mapStateEmpty}
|
||||||
|
onChange={(e) => handleFlagChange(e, "notes")}
|
||||||
|
/>
|
||||||
|
Notes
|
||||||
|
</Label>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
|
@ -11,7 +11,7 @@ function MapTile({
|
|||||||
onMapSelect,
|
onMapSelect,
|
||||||
onMapEdit,
|
onMapEdit,
|
||||||
onDone,
|
onDone,
|
||||||
large,
|
size,
|
||||||
canEdit,
|
canEdit,
|
||||||
badges,
|
badges,
|
||||||
}) {
|
}) {
|
||||||
@ -34,7 +34,7 @@ function MapTile({
|
|||||||
onSelect={() => onMapSelect(map)}
|
onSelect={() => onMapSelect(map)}
|
||||||
onEdit={() => onMapEdit(map.id)}
|
onEdit={() => onMapEdit(map.id)}
|
||||||
onDoubleClick={onDone}
|
onDoubleClick={onDone}
|
||||||
large={large}
|
size={size}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
badges={badges}
|
badges={badges}
|
||||||
editTitle="Edit Map"
|
editTitle="Edit Map"
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import React, { useContext } from "react";
|
import React, { useContext } from "react";
|
||||||
import { Flex, Box, Text, IconButton, Close, Label } from "theme-ui";
|
import { Flex, Box, Text, IconButton, Close, Label } from "theme-ui";
|
||||||
import SimpleBar from "simplebar-react";
|
import SimpleBar from "simplebar-react";
|
||||||
import { useMedia } from "react-media";
|
|
||||||
import Case from "case";
|
import Case from "case";
|
||||||
|
|
||||||
import RemoveMapIcon from "../../icons/RemoveMapIcon";
|
import RemoveMapIcon from "../../icons/RemoveMapIcon";
|
||||||
@ -14,6 +13,8 @@ import FilterBar from "../FilterBar";
|
|||||||
|
|
||||||
import DatabaseContext from "../../contexts/DatabaseContext";
|
import DatabaseContext from "../../contexts/DatabaseContext";
|
||||||
|
|
||||||
|
import useResponsiveLayout from "../../helpers/useResponsiveLayout";
|
||||||
|
|
||||||
function MapTiles({
|
function MapTiles({
|
||||||
maps,
|
maps,
|
||||||
groups,
|
groups,
|
||||||
@ -32,14 +33,15 @@ function MapTiles({
|
|||||||
onMapsGroup,
|
onMapsGroup,
|
||||||
}) {
|
}) {
|
||||||
const { databaseStatus } = useContext(DatabaseContext);
|
const { databaseStatus } = useContext(DatabaseContext);
|
||||||
const isSmallScreen = useMedia({ query: "(max-width: 500px)" });
|
const layout = useResponsiveLayout();
|
||||||
|
|
||||||
let hasMapState = false;
|
let hasMapState = false;
|
||||||
for (let state of selectedMapStates) {
|
for (let state of selectedMapStates) {
|
||||||
if (
|
if (
|
||||||
Object.values(state.tokens).length > 0 ||
|
Object.values(state.tokens).length > 0 ||
|
||||||
state.mapDrawActions.length > 0 ||
|
state.mapDrawActions.length > 0 ||
|
||||||
state.fogDrawActions.length > 0
|
state.fogDrawActions.length > 0 ||
|
||||||
|
Object.values(state.notes).length > 0
|
||||||
) {
|
) {
|
||||||
hasMapState = true;
|
hasMapState = true;
|
||||||
break;
|
break;
|
||||||
@ -60,7 +62,7 @@ function MapTiles({
|
|||||||
onMapSelect={onMapSelect}
|
onMapSelect={onMapSelect}
|
||||||
onMapEdit={onMapEdit}
|
onMapEdit={onMapEdit}
|
||||||
onDone={onDone}
|
onDone={onDone}
|
||||||
large={isSmallScreen}
|
size={layout.tileSize}
|
||||||
canEdit={
|
canEdit={
|
||||||
isSelected && selectMode === "single" && selectedMaps.length === 1
|
isSelected && selectMode === "single" && selectedMaps.length === 1
|
||||||
}
|
}
|
||||||
@ -82,15 +84,18 @@ function MapTiles({
|
|||||||
onAdd={onMapAdd}
|
onAdd={onMapAdd}
|
||||||
addTitle="Add Map"
|
addTitle="Add Map"
|
||||||
/>
|
/>
|
||||||
<SimpleBar style={{ height: "400px" }}>
|
<SimpleBar
|
||||||
|
style={{ height: layout.screenSize === "large" ? "600px" : "400px" }}
|
||||||
|
>
|
||||||
<Flex
|
<Flex
|
||||||
p={2}
|
p={2}
|
||||||
pb={4}
|
pb={4}
|
||||||
|
pt={databaseStatus === "disabled" ? 4 : 2}
|
||||||
bg="muted"
|
bg="muted"
|
||||||
sx={{
|
sx={{
|
||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
borderRadius: "4px",
|
borderRadius: "4px",
|
||||||
minHeight: "400px",
|
minHeight: layout.screenSize === "large" ? "600px" : "400px",
|
||||||
alignContent: "flex-start",
|
alignContent: "flex-start",
|
||||||
}}
|
}}
|
||||||
onClick={() => onMapSelect()}
|
onClick={() => onMapSelect()}
|
||||||
@ -113,6 +118,7 @@ function MapTiles({
|
|||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
|
borderRadius: "2px",
|
||||||
}}
|
}}
|
||||||
bg="highlight"
|
bg="highlight"
|
||||||
p={1}
|
p={1}
|
||||||
|
@ -7,7 +7,7 @@ import Konva from "konva";
|
|||||||
import useDataSource from "../../helpers/useDataSource";
|
import useDataSource from "../../helpers/useDataSource";
|
||||||
import useDebounce from "../../helpers/useDebounce";
|
import useDebounce from "../../helpers/useDebounce";
|
||||||
import usePrevious from "../../helpers/usePrevious";
|
import usePrevious from "../../helpers/usePrevious";
|
||||||
import * as Vector2 from "../../helpers/vector2";
|
import { snapNodeToMap } from "../../helpers/map";
|
||||||
|
|
||||||
import AuthContext from "../../contexts/AuthContext";
|
import AuthContext from "../../contexts/AuthContext";
|
||||||
import MapInteractionContext from "../../contexts/MapInteractionContext";
|
import MapInteractionContext from "../../contexts/MapInteractionContext";
|
||||||
@ -17,9 +17,6 @@ import TokenLabel from "../token/TokenLabel";
|
|||||||
|
|
||||||
import { tokenSources, unknownSource } from "../../tokens";
|
import { tokenSources, unknownSource } from "../../tokens";
|
||||||
|
|
||||||
// Enable hit detection on drag to allow for vehicle tokens
|
|
||||||
Konva.hitOnDragEnabled = true;
|
|
||||||
|
|
||||||
const snappingThreshold = 1 / 7;
|
const snappingThreshold = 1 / 7;
|
||||||
|
|
||||||
function MapToken({
|
function MapToken({
|
||||||
@ -58,6 +55,9 @@ function MapToken({
|
|||||||
const tokenImage = imageRef.current;
|
const tokenImage = imageRef.current;
|
||||||
|
|
||||||
if (token && token.category === "vehicle") {
|
if (token && token.category === "vehicle") {
|
||||||
|
// Enable hit detection for .intersects() function
|
||||||
|
Konva.hitOnDragEnabled = true;
|
||||||
|
|
||||||
// Find all other tokens on the map
|
// Find all other tokens on the map
|
||||||
const layer = tokenGroup.getLayer();
|
const layer = tokenGroup.getLayer();
|
||||||
const tokens = layer.find(".character");
|
const tokens = layer.find(".character");
|
||||||
@ -86,35 +86,7 @@ function MapToken({
|
|||||||
const tokenGroup = event.target;
|
const tokenGroup = event.target;
|
||||||
// Snap to corners of grid
|
// Snap to corners of grid
|
||||||
if (map.snapToGrid) {
|
if (map.snapToGrid) {
|
||||||
const offset = Vector2.multiply(map.grid.inset.topLeft, {
|
snapNodeToMap(map, mapWidth, mapHeight, tokenGroup, snappingThreshold);
|
||||||
x: mapWidth,
|
|
||||||
y: mapHeight,
|
|
||||||
});
|
|
||||||
const position = {
|
|
||||||
x: tokenGroup.x() + tokenGroup.width() / 2,
|
|
||||||
y: tokenGroup.y() + tokenGroup.height() / 2,
|
|
||||||
};
|
|
||||||
const gridSize = {
|
|
||||||
x:
|
|
||||||
(mapWidth *
|
|
||||||
(map.grid.inset.bottomRight.x - map.grid.inset.topLeft.x)) /
|
|
||||||
map.grid.size.x,
|
|
||||||
y:
|
|
||||||
(mapHeight *
|
|
||||||
(map.grid.inset.bottomRight.y - map.grid.inset.topLeft.y)) /
|
|
||||||
map.grid.size.y,
|
|
||||||
};
|
|
||||||
// Transform into offset space, round, then transform back
|
|
||||||
const gridSnap = Vector2.add(
|
|
||||||
Vector2.roundTo(Vector2.subtract(position, offset), gridSize),
|
|
||||||
offset
|
|
||||||
);
|
|
||||||
const gridDistance = Vector2.length(Vector2.subtract(gridSnap, position));
|
|
||||||
const minGrid = Vector2.min(gridSize);
|
|
||||||
if (gridDistance < minGrid * snappingThreshold) {
|
|
||||||
tokenGroup.x(gridSnap.x - tokenGroup.width() / 2);
|
|
||||||
tokenGroup.y(gridSnap.y - tokenGroup.height() / 2);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -123,6 +95,8 @@ function MapToken({
|
|||||||
|
|
||||||
const mountChanges = {};
|
const mountChanges = {};
|
||||||
if (token && token.category === "vehicle") {
|
if (token && token.category === "vehicle") {
|
||||||
|
Konva.hitOnDragEnabled = false;
|
||||||
|
|
||||||
const parent = tokenGroup.getParent();
|
const parent = tokenGroup.getParent();
|
||||||
const mountedTokens = tokenGroup.find(".character");
|
const mountedTokens = tokenGroup.find(".character");
|
||||||
for (let mountedToken of mountedTokens) {
|
for (let mountedToken of mountedTokens) {
|
||||||
@ -209,20 +183,30 @@ function MapToken({
|
|||||||
const imageRef = useRef();
|
const imageRef = useRef();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const image = imageRef.current;
|
const image = imageRef.current;
|
||||||
if (
|
if (!image) {
|
||||||
image &&
|
return;
|
||||||
tokenSourceStatus === "loaded" &&
|
}
|
||||||
tokenWidth > 0 &&
|
|
||||||
tokenHeight > 0
|
const canvas = image.getCanvas();
|
||||||
) {
|
const pixelRatio = canvas.pixelRatio || 1;
|
||||||
|
|
||||||
|
if (tokenSourceStatus === "loaded" && tokenWidth > 0 && tokenHeight > 0) {
|
||||||
|
const maxImageSize = token ? Math.max(token.width, token.height) : 512; // Default to 512px
|
||||||
|
const maxTokenSize = Math.max(tokenWidth, tokenHeight);
|
||||||
|
// Constrain image buffer to original image size
|
||||||
|
const maxRatio = maxImageSize / maxTokenSize;
|
||||||
|
|
||||||
image.cache({
|
image.cache({
|
||||||
pixelRatio: debouncedStageScale * window.devicePixelRatio,
|
pixelRatio: Math.min(
|
||||||
|
Math.max(debouncedStageScale * pixelRatio, 1),
|
||||||
|
maxRatio
|
||||||
|
),
|
||||||
});
|
});
|
||||||
image.drawHitFromCache();
|
image.drawHitFromCache();
|
||||||
// Force redraw
|
// Force redraw
|
||||||
image.getLayer().draw();
|
image.getLayer().draw();
|
||||||
}
|
}
|
||||||
}, [debouncedStageScale, tokenWidth, tokenHeight, tokenSourceStatus]);
|
}, [debouncedStageScale, tokenWidth, tokenHeight, tokenSourceStatus, token]);
|
||||||
|
|
||||||
// Animate to new token positions if edited by others
|
// Animate to new token positions if edited by others
|
||||||
const tokenX = tokenState.x * mapWidth;
|
const tokenX = tokenState.x * mapWidth;
|
||||||
|
@ -4,7 +4,11 @@ import { IconButton } from "theme-ui";
|
|||||||
import SnappingOnIcon from "../../../icons/SnappingOnIcon";
|
import SnappingOnIcon from "../../../icons/SnappingOnIcon";
|
||||||
import SnappingOffIcon from "../../../icons/SnappingOffIcon";
|
import SnappingOffIcon from "../../../icons/SnappingOffIcon";
|
||||||
|
|
||||||
function EdgeSnappingToggle({ useEdgeSnapping, onEdgeSnappingChange }) {
|
function EdgeSnappingToggle({
|
||||||
|
useEdgeSnapping,
|
||||||
|
onEdgeSnappingChange,
|
||||||
|
disabled,
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<IconButton
|
<IconButton
|
||||||
aria-label={
|
aria-label={
|
||||||
@ -18,6 +22,7 @@ function EdgeSnappingToggle({ useEdgeSnapping, onEdgeSnappingChange }) {
|
|||||||
: "Enable Edge Snapping (S)"
|
: "Enable Edge Snapping (S)"
|
||||||
}
|
}
|
||||||
onClick={() => onEdgeSnappingChange(!useEdgeSnapping)}
|
onClick={() => onEdgeSnappingChange(!useEdgeSnapping)}
|
||||||
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
{useEdgeSnapping ? <SnappingOnIcon /> : <SnappingOffIcon />}
|
{useEdgeSnapping ? <SnappingOnIcon /> : <SnappingOffIcon />}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
22
src/components/map/controls/FogCutToggle.js
Normal file
22
src/components/map/controls/FogCutToggle.js
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { IconButton } from "theme-ui";
|
||||||
|
|
||||||
|
import CutOnIcon from "../../../icons/FogCutOnIcon";
|
||||||
|
import CutOffIcon from "../../../icons/FogCutOffIcon";
|
||||||
|
|
||||||
|
function FogCutToggle({ useFogCut, onFogCutChange, disabled }) {
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
aria-label={
|
||||||
|
useFogCut ? "Disable Fog Cutting (C)" : "Enable Fog Cutting (C)"
|
||||||
|
}
|
||||||
|
title={useFogCut ? "Disable Fog Cutting (C)" : "Enable Fog Cutting (C)"}
|
||||||
|
onClick={() => onFogCutChange(!useFogCut)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{useFogCut ? <CutOnIcon /> : <CutOffIcon />}
|
||||||
|
</IconButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FogCutToggle;
|
@ -6,13 +6,13 @@ import RadioIconButton from "../../RadioIconButton";
|
|||||||
|
|
||||||
import EdgeSnappingToggle from "./EdgeSnappingToggle";
|
import EdgeSnappingToggle from "./EdgeSnappingToggle";
|
||||||
import FogPreviewToggle from "./FogPreviewToggle";
|
import FogPreviewToggle from "./FogPreviewToggle";
|
||||||
|
import FogCutToggle from "./FogCutToggle";
|
||||||
|
|
||||||
import FogBrushIcon from "../../../icons/FogBrushIcon";
|
import FogBrushIcon from "../../../icons/FogBrushIcon";
|
||||||
import FogPolygonIcon from "../../../icons/FogPolygonIcon";
|
import FogPolygonIcon from "../../../icons/FogPolygonIcon";
|
||||||
import FogRemoveIcon from "../../../icons/FogRemoveIcon";
|
import FogRemoveIcon from "../../../icons/FogRemoveIcon";
|
||||||
import FogToggleIcon from "../../../icons/FogToggleIcon";
|
import FogToggleIcon from "../../../icons/FogToggleIcon";
|
||||||
import FogAddIcon from "../../../icons/FogAddIcon";
|
import FogRectangleIcon from "../../../icons/FogRectangleIcon";
|
||||||
import FogSubtractIcon from "../../../icons/FogSubtractIcon";
|
|
||||||
|
|
||||||
import UndoButton from "./UndoButton";
|
import UndoButton from "./UndoButton";
|
||||||
import RedoButton from "./RedoButton";
|
import RedoButton from "./RedoButton";
|
||||||
@ -31,19 +31,23 @@ function BrushToolSettings({
|
|||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
function handleKeyDown({ key, ctrlKey, metaKey, shiftKey }) {
|
function handleKeyDown({ key, ctrlKey, metaKey, shiftKey }) {
|
||||||
if (key === "Alt") {
|
if (key === "Alt") {
|
||||||
onSettingChange({ useFogSubtract: !settings.useFogSubtract });
|
onSettingChange({ useFogCut: !settings.useFogCut });
|
||||||
} else if (key === "p") {
|
} else if (key === "p") {
|
||||||
onSettingChange({ type: "polygon" });
|
onSettingChange({ type: "polygon" });
|
||||||
} else if (key === "b") {
|
} else if (key === "b") {
|
||||||
onSettingChange({ type: "brush" });
|
onSettingChange({ type: "brush" });
|
||||||
} else if (key === "t") {
|
} else if (key === "t") {
|
||||||
onSettingChange({ type: "toggle" });
|
onSettingChange({ type: "toggle" });
|
||||||
} else if (key === "r") {
|
} else if (key === "e") {
|
||||||
onSettingChange({ type: "remove" });
|
onSettingChange({ type: "remove" });
|
||||||
} else if (key === "s") {
|
} else if (key === "s") {
|
||||||
onSettingChange({ useEdgeSnapping: !settings.useEdgeSnapping });
|
onSettingChange({ useEdgeSnapping: !settings.useEdgeSnapping });
|
||||||
} else if (key === "f") {
|
} else if (key === "f") {
|
||||||
onSettingChange({ preview: !settings.preview });
|
onSettingChange({ preview: !settings.preview });
|
||||||
|
} else if (key === "c") {
|
||||||
|
onSettingChange({ useFogCut: !settings.useFogCut });
|
||||||
|
} else if (key === "r") {
|
||||||
|
onSettingChange({ type: "rectangle" });
|
||||||
} else if (
|
} else if (
|
||||||
(key === "z" || key === "Z") &&
|
(key === "z" || key === "Z") &&
|
||||||
(ctrlKey || metaKey) &&
|
(ctrlKey || metaKey) &&
|
||||||
@ -63,7 +67,7 @@ function BrushToolSettings({
|
|||||||
|
|
||||||
function handleKeyUp({ key }) {
|
function handleKeyUp({ key }) {
|
||||||
if (key === "Alt") {
|
if (key === "Alt") {
|
||||||
onSettingChange({ useFogSubtract: !settings.useFogSubtract });
|
onSettingChange({ useFogCut: !settings.useFogCut });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -76,27 +80,21 @@ function BrushToolSettings({
|
|||||||
title: "Fog Polygon (P)",
|
title: "Fog Polygon (P)",
|
||||||
isSelected: settings.type === "polygon",
|
isSelected: settings.type === "polygon",
|
||||||
icon: <FogPolygonIcon />,
|
icon: <FogPolygonIcon />,
|
||||||
|
disabled: settings.preview,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rectangle",
|
||||||
|
title: "Fog Rectangle (R)",
|
||||||
|
isSelected: settings.type === "rectangle",
|
||||||
|
icon: <FogRectangleIcon />,
|
||||||
|
disabled: settings.preview,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "brush",
|
id: "brush",
|
||||||
title: "Fog Brush (B)",
|
title: "Fog Brush (B)",
|
||||||
isSelected: settings.type === "brush",
|
isSelected: settings.type === "brush",
|
||||||
icon: <FogBrushIcon />,
|
icon: <FogBrushIcon />,
|
||||||
},
|
disabled: settings.preview,
|
||||||
];
|
|
||||||
|
|
||||||
const modeTools = [
|
|
||||||
{
|
|
||||||
id: "add",
|
|
||||||
title: "Add Fog",
|
|
||||||
isSelected: !settings.useFogSubtract,
|
|
||||||
icon: <FogAddIcon />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "subtract",
|
|
||||||
title: "Subtract Fog",
|
|
||||||
isSelected: settings.useFogSubtract,
|
|
||||||
icon: <FogSubtractIcon />,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -112,30 +110,30 @@ function BrushToolSettings({
|
|||||||
title="Toggle Fog (T)"
|
title="Toggle Fog (T)"
|
||||||
onClick={() => onSettingChange({ type: "toggle" })}
|
onClick={() => onSettingChange({ type: "toggle" })}
|
||||||
isSelected={settings.type === "toggle"}
|
isSelected={settings.type === "toggle"}
|
||||||
|
disabled={settings.preview}
|
||||||
>
|
>
|
||||||
<FogToggleIcon />
|
<FogToggleIcon />
|
||||||
</RadioIconButton>
|
</RadioIconButton>
|
||||||
<RadioIconButton
|
<RadioIconButton
|
||||||
title="Remove Fog (R)"
|
title="Erase Fog (E)"
|
||||||
onClick={() => onSettingChange({ type: "remove" })}
|
onClick={() => onSettingChange({ type: "remove" })}
|
||||||
isSelected={settings.type === "remove"}
|
isSelected={settings.type === "remove"}
|
||||||
|
disabled={settings.preview}
|
||||||
>
|
>
|
||||||
<FogRemoveIcon />
|
<FogRemoveIcon />
|
||||||
</RadioIconButton>
|
</RadioIconButton>
|
||||||
<Divider vertical />
|
<Divider vertical />
|
||||||
<ToolSection
|
<FogCutToggle
|
||||||
tools={modeTools}
|
useFogCut={settings.useFogCut}
|
||||||
onToolClick={(tool) =>
|
onFogCutChange={(useFogCut) => onSettingChange({ useFogCut })}
|
||||||
onSettingChange({ useFogSubtract: tool.id === "subtract" })
|
disabled={settings.preview}
|
||||||
}
|
|
||||||
collapse={isSmallScreen}
|
|
||||||
/>
|
/>
|
||||||
<Divider vertical />
|
|
||||||
<EdgeSnappingToggle
|
<EdgeSnappingToggle
|
||||||
useEdgeSnapping={settings.useEdgeSnapping}
|
useEdgeSnapping={settings.useEdgeSnapping}
|
||||||
onEdgeSnappingChange={(useEdgeSnapping) =>
|
onEdgeSnappingChange={(useEdgeSnapping) =>
|
||||||
onSettingChange({ useEdgeSnapping })
|
onSettingChange({ useEdgeSnapping })
|
||||||
}
|
}
|
||||||
|
disabled={settings.preview}
|
||||||
/>
|
/>
|
||||||
<FogPreviewToggle
|
<FogPreviewToggle
|
||||||
useFogPreview={settings.preview}
|
useFogPreview={settings.preview}
|
||||||
|
@ -5,6 +5,7 @@ import ToolSection from "./ToolSection";
|
|||||||
import MeasureChebyshevIcon from "../../../icons/MeasureChebyshevIcon";
|
import MeasureChebyshevIcon from "../../../icons/MeasureChebyshevIcon";
|
||||||
import MeasureEuclideanIcon from "../../../icons/MeasureEuclideanIcon";
|
import MeasureEuclideanIcon from "../../../icons/MeasureEuclideanIcon";
|
||||||
import MeasureManhattanIcon from "../../../icons/MeasureManhattanIcon";
|
import MeasureManhattanIcon from "../../../icons/MeasureManhattanIcon";
|
||||||
|
import MeasureAlternatingIcon from "../../../icons/MeasureAlternatingIcon";
|
||||||
|
|
||||||
import Divider from "../../Divider";
|
import Divider from "../../Divider";
|
||||||
|
|
||||||
@ -19,6 +20,8 @@ function MeasureToolSettings({ settings, onSettingChange }) {
|
|||||||
onSettingChange({ type: "euclidean" });
|
onSettingChange({ type: "euclidean" });
|
||||||
} else if (key === "c") {
|
} else if (key === "c") {
|
||||||
onSettingChange({ type: "manhattan" });
|
onSettingChange({ type: "manhattan" });
|
||||||
|
} else if (key === "a") {
|
||||||
|
onSettingChange({ type: "alternating" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -31,6 +34,12 @@ function MeasureToolSettings({ settings, onSettingChange }) {
|
|||||||
isSelected: settings.type === "chebyshev",
|
isSelected: settings.type === "chebyshev",
|
||||||
icon: <MeasureChebyshevIcon />,
|
icon: <MeasureChebyshevIcon />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "alternating",
|
||||||
|
title: "Alternating Diagonal Distance (A)",
|
||||||
|
isSelected: settings.type === "alternating",
|
||||||
|
icon: <MeasureAlternatingIcon />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "euclidean",
|
id: "euclidean",
|
||||||
title: "Line Distance (L)",
|
title: "Line Distance (L)",
|
||||||
|
@ -36,6 +36,7 @@ function ToolSection({ collapse, tools, onToolClick }) {
|
|||||||
onClick={() => handleToolClick(tool)}
|
onClick={() => handleToolClick(tool)}
|
||||||
key={tool.id}
|
key={tool.id}
|
||||||
isSelected={tool.isSelected}
|
isSelected={tool.isSelected}
|
||||||
|
disabled={tool.disabled}
|
||||||
>
|
>
|
||||||
{tool.icon}
|
{tool.icon}
|
||||||
</RadioIconButton>
|
</RadioIconButton>
|
||||||
@ -90,6 +91,7 @@ function ToolSection({ collapse, tools, onToolClick }) {
|
|||||||
onClick={() => handleToolClick(tool)}
|
onClick={() => handleToolClick(tool)}
|
||||||
key={tool.id}
|
key={tool.id}
|
||||||
isSelected={tool.isSelected}
|
isSelected={tool.isSelected}
|
||||||
|
disabled={tool.disabled}
|
||||||
>
|
>
|
||||||
{tool.icon}
|
{tool.icon}
|
||||||
</RadioIconButton>
|
</RadioIconButton>
|
||||||
|
195
src/components/note/Note.js
Normal file
195
src/components/note/Note.js
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
import React, { useContext, useEffect, useState, useRef } from "react";
|
||||||
|
import { Rect, Text } from "react-konva";
|
||||||
|
import { useSpring, animated } from "react-spring/konva";
|
||||||
|
|
||||||
|
import AuthContext from "../../contexts/AuthContext";
|
||||||
|
import MapInteractionContext from "../../contexts/MapInteractionContext";
|
||||||
|
|
||||||
|
import { snapNodeToMap } from "../../helpers/map";
|
||||||
|
import colors from "../../helpers/colors";
|
||||||
|
import usePrevious from "../../helpers/usePrevious";
|
||||||
|
|
||||||
|
const snappingThreshold = 1 / 5;
|
||||||
|
|
||||||
|
function Note({
|
||||||
|
note,
|
||||||
|
map,
|
||||||
|
onNoteChange,
|
||||||
|
onNoteMenuOpen,
|
||||||
|
draggable,
|
||||||
|
onNoteDragStart,
|
||||||
|
onNoteDragEnd,
|
||||||
|
}) {
|
||||||
|
const { userId } = useContext(AuthContext);
|
||||||
|
const { mapWidth, mapHeight, setPreventMapInteraction } = useContext(
|
||||||
|
MapInteractionContext
|
||||||
|
);
|
||||||
|
|
||||||
|
const noteWidth = map && (mapWidth / map.grid.size.x) * note.size;
|
||||||
|
const noteHeight = noteWidth;
|
||||||
|
const notePadding = noteWidth / 10;
|
||||||
|
|
||||||
|
function handleDragStart(event) {
|
||||||
|
onNoteDragStart && onNoteDragStart(event, note.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragMove(event) {
|
||||||
|
const noteGroup = event.target;
|
||||||
|
// Snap to corners of grid
|
||||||
|
if (map.snapToGrid) {
|
||||||
|
snapNodeToMap(map, mapWidth, mapHeight, noteGroup, snappingThreshold);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragEnd(event) {
|
||||||
|
const noteGroup = event.target;
|
||||||
|
onNoteChange &&
|
||||||
|
onNoteChange({
|
||||||
|
...note,
|
||||||
|
x: noteGroup.x() / mapWidth,
|
||||||
|
y: noteGroup.y() / mapHeight,
|
||||||
|
lastModifiedBy: userId,
|
||||||
|
lastModified: Date.now(),
|
||||||
|
});
|
||||||
|
onNoteDragEnd && onNoteDragEnd(note.id);
|
||||||
|
setPreventMapInteraction(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClick(event) {
|
||||||
|
if (draggable) {
|
||||||
|
const noteNode = event.target;
|
||||||
|
onNoteMenuOpen && onNoteMenuOpen(note.id, noteNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store note pointer down time to check for a click when note is locked
|
||||||
|
const notePointerDownTimeRef = useRef();
|
||||||
|
function handlePointerDown(event) {
|
||||||
|
if (draggable) {
|
||||||
|
setPreventMapInteraction(true);
|
||||||
|
}
|
||||||
|
if (note.locked && map.owner === userId) {
|
||||||
|
notePointerDownTimeRef.current = event.evt.timeStamp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerUp(event) {
|
||||||
|
if (draggable) {
|
||||||
|
setPreventMapInteraction(false);
|
||||||
|
}
|
||||||
|
// Check note click when locked and we are the map owner
|
||||||
|
// We can't use onClick because that doesn't check pointer distance
|
||||||
|
if (note.locked && map.owner === userId) {
|
||||||
|
// If down and up time is small trigger a click
|
||||||
|
const delta = event.evt.timeStamp - notePointerDownTimeRef.current;
|
||||||
|
if (delta < 300) {
|
||||||
|
const noteNode = event.target;
|
||||||
|
onNoteMenuOpen(note.id, noteNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [fontSize, setFontSize] = useState(1);
|
||||||
|
useEffect(() => {
|
||||||
|
const text = textRef.current;
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findFontSize() {
|
||||||
|
// Create an array from 1 / 10 of the note height to the full note height
|
||||||
|
const sizes = Array.from(
|
||||||
|
{ length: Math.ceil(noteHeight - notePadding * 2) },
|
||||||
|
(_, i) => i + Math.ceil(noteHeight / 10)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sizes.length > 0) {
|
||||||
|
const size = sizes.reduce((prev, curr) => {
|
||||||
|
text.fontSize(curr);
|
||||||
|
const width = text.getTextWidth() + notePadding * 2;
|
||||||
|
const height = text.height() + notePadding * 2;
|
||||||
|
if (width < noteWidth && height < noteHeight) {
|
||||||
|
return curr;
|
||||||
|
} else {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setFontSize(size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
findFontSize();
|
||||||
|
}, [note, note.text, noteWidth, noteHeight, notePadding]);
|
||||||
|
|
||||||
|
const textRef = useRef();
|
||||||
|
|
||||||
|
// Animate to new note positions if edited by others
|
||||||
|
const noteX = note.x * mapWidth;
|
||||||
|
const noteY = note.y * mapHeight;
|
||||||
|
const previousWidth = usePrevious(mapWidth);
|
||||||
|
const previousHeight = usePrevious(mapHeight);
|
||||||
|
const resized = mapWidth !== previousWidth || mapHeight !== previousHeight;
|
||||||
|
const skipAnimation = note.lastModifiedBy === userId || resized;
|
||||||
|
const props = useSpring({
|
||||||
|
x: noteX,
|
||||||
|
y: noteY,
|
||||||
|
immediate: skipAnimation,
|
||||||
|
});
|
||||||
|
|
||||||
|
// When a note is hidden if you aren't the map owner hide it completely
|
||||||
|
if (map && !note.visible && map.owner !== userId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<animated.Group
|
||||||
|
{...props}
|
||||||
|
onClick={handleClick}
|
||||||
|
onTap={handleClick}
|
||||||
|
width={noteWidth}
|
||||||
|
height={noteHeight}
|
||||||
|
offsetX={noteWidth / 2}
|
||||||
|
offsetY={noteHeight / 2}
|
||||||
|
draggable={draggable}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragMove={handleDragMove}
|
||||||
|
onMouseDown={handlePointerDown}
|
||||||
|
onMouseUp={handlePointerUp}
|
||||||
|
onTouchStart={handlePointerDown}
|
||||||
|
onTouchEnd={handlePointerUp}
|
||||||
|
opacity={note.visible ? 1.0 : 0.5}
|
||||||
|
>
|
||||||
|
<Rect
|
||||||
|
width={noteWidth}
|
||||||
|
height={noteHeight}
|
||||||
|
shadowColor="rgba(0, 0, 0, 0.16)"
|
||||||
|
shadowOffset={{ x: 0, y: 3 }}
|
||||||
|
shadowBlur={6}
|
||||||
|
cornerRadius={0.25}
|
||||||
|
fill={colors[note.color]}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
text={note.text}
|
||||||
|
fill={
|
||||||
|
note.color === "black" || note.color === "darkGray"
|
||||||
|
? "white"
|
||||||
|
: "black"
|
||||||
|
}
|
||||||
|
align="center"
|
||||||
|
verticalAlign="middle"
|
||||||
|
padding={notePadding}
|
||||||
|
fontSize={fontSize}
|
||||||
|
wrap="word"
|
||||||
|
width={noteWidth}
|
||||||
|
height={noteHeight}
|
||||||
|
/>
|
||||||
|
{/* Use an invisible text block to work out text sizing */}
|
||||||
|
<Text visible={false} ref={textRef} text={note.text} wrap="none" />
|
||||||
|
</animated.Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Note;
|
19
src/components/note/NoteDragOverlay.js
Normal file
19
src/components/note/NoteDragOverlay.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import DragOverlay from "../DragOverlay";
|
||||||
|
|
||||||
|
function NoteDragOverlay({ onNoteRemove, noteId, noteGroup, dragging }) {
|
||||||
|
function handleNoteRemove() {
|
||||||
|
onNoteRemove(noteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DragOverlay
|
||||||
|
dragging={dragging}
|
||||||
|
onRemove={handleNoteRemove}
|
||||||
|
node={noteGroup}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NoteDragOverlay;
|
219
src/components/note/NoteMenu.js
Normal file
219
src/components/note/NoteMenu.js
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
import React, { useEffect, useState, useContext } from "react";
|
||||||
|
import { Box, Flex, Text, IconButton, Textarea } from "theme-ui";
|
||||||
|
|
||||||
|
import Slider from "../Slider";
|
||||||
|
|
||||||
|
import MapMenu from "../map/MapMenu";
|
||||||
|
|
||||||
|
import colors, { colorOptions } from "../../helpers/colors";
|
||||||
|
|
||||||
|
import usePrevious from "../../helpers/usePrevious";
|
||||||
|
|
||||||
|
import LockIcon from "../../icons/TokenLockIcon";
|
||||||
|
import UnlockIcon from "../../icons/TokenUnlockIcon";
|
||||||
|
import ShowIcon from "../../icons/TokenShowIcon";
|
||||||
|
import HideIcon from "../../icons/TokenHideIcon";
|
||||||
|
|
||||||
|
import AuthContext from "../../contexts/AuthContext";
|
||||||
|
|
||||||
|
const defaultNoteMaxSize = 6;
|
||||||
|
|
||||||
|
function NoteMenu({
|
||||||
|
isOpen,
|
||||||
|
onRequestClose,
|
||||||
|
note,
|
||||||
|
noteNode,
|
||||||
|
onNoteChange,
|
||||||
|
map,
|
||||||
|
}) {
|
||||||
|
const { userId } = useContext(AuthContext);
|
||||||
|
|
||||||
|
const wasOpen = usePrevious(isOpen);
|
||||||
|
|
||||||
|
const [noteMaxSize, setNoteMaxSize] = useState(defaultNoteMaxSize);
|
||||||
|
const [menuLeft, setMenuLeft] = useState(0);
|
||||||
|
const [menuTop, setMenuTop] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && !wasOpen && note) {
|
||||||
|
setNoteMaxSize(Math.max(note.size, defaultNoteMaxSize));
|
||||||
|
// Update menu position
|
||||||
|
if (noteNode) {
|
||||||
|
const nodeRect = noteNode.getClientRect();
|
||||||
|
const mapElement = document.querySelector(".map");
|
||||||
|
const mapRect = mapElement.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Center X for the menu which is 156px wide
|
||||||
|
setMenuLeft(mapRect.left + nodeRect.x + nodeRect.width / 2 - 156 / 2);
|
||||||
|
// Y 12px from the bottom
|
||||||
|
setMenuTop(mapRect.top + nodeRect.y + nodeRect.height + 12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isOpen, note, wasOpen, noteNode]);
|
||||||
|
|
||||||
|
function handleTextChange(event) {
|
||||||
|
const text = event.target.value.substring(0, 144);
|
||||||
|
note && onNoteChange({ ...note, text: text });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleColorChange(color) {
|
||||||
|
if (!note) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onNoteChange({ ...note, color: color });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSizeChange(event) {
|
||||||
|
const newSize = parseFloat(event.target.value);
|
||||||
|
note && onNoteChange({ ...note, size: newSize });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleVisibleChange() {
|
||||||
|
note && onNoteChange({ ...note, visible: !note.visible });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLockChange() {
|
||||||
|
note && onNoteChange({ ...note, locked: !note.locked });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleModalContent(node) {
|
||||||
|
if (node) {
|
||||||
|
// Focus input
|
||||||
|
const tokenLabelInput = node.querySelector("#changeNoteText");
|
||||||
|
tokenLabelInput.focus();
|
||||||
|
tokenLabelInput.select();
|
||||||
|
|
||||||
|
// Ensure menu is in bounds
|
||||||
|
const nodeRect = node.getBoundingClientRect();
|
||||||
|
const mapElement = document.querySelector(".map");
|
||||||
|
const mapRect = mapElement.getBoundingClientRect();
|
||||||
|
setMenuLeft((prevLeft) =>
|
||||||
|
Math.min(
|
||||||
|
mapRect.right - nodeRect.width,
|
||||||
|
Math.max(mapRect.left, prevLeft)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setMenuTop((prevTop) =>
|
||||||
|
Math.min(mapRect.bottom - nodeRect.height, prevTop)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTextKeyPress(e) {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
onRequestClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MapMenu
|
||||||
|
isOpen={isOpen}
|
||||||
|
onRequestClose={onRequestClose}
|
||||||
|
top={`${menuTop}px`}
|
||||||
|
left={`${menuLeft}px`}
|
||||||
|
onModalContent={handleModalContent}
|
||||||
|
>
|
||||||
|
<Box sx={{ width: "156px" }} p={1}>
|
||||||
|
<Flex
|
||||||
|
as="form"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onRequestClose();
|
||||||
|
}}
|
||||||
|
sx={{ alignItems: "center" }}
|
||||||
|
>
|
||||||
|
<Textarea
|
||||||
|
id="changeNoteText"
|
||||||
|
onChange={handleTextChange}
|
||||||
|
value={(note && note.text) || ""}
|
||||||
|
sx={{
|
||||||
|
padding: "4px",
|
||||||
|
border: "none",
|
||||||
|
":focus": {
|
||||||
|
outline: "none",
|
||||||
|
},
|
||||||
|
resize: "none",
|
||||||
|
}}
|
||||||
|
rows={1}
|
||||||
|
onKeyPress={handleTextKeyPress}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{colorOptions.map((color) => (
|
||||||
|
<Box
|
||||||
|
key={color}
|
||||||
|
sx={{
|
||||||
|
width: "16.66%",
|
||||||
|
paddingTop: "16.66%",
|
||||||
|
borderRadius: "50%",
|
||||||
|
transform: "scale(0.75)",
|
||||||
|
backgroundColor: colors[color],
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
onClick={() => handleColorChange(color)}
|
||||||
|
aria-label={`Note label Color ${color}`}
|
||||||
|
>
|
||||||
|
{note && note.color === color && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
border: "2px solid white",
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
borderRadius: "50%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
<Flex sx={{ alignItems: "center" }}>
|
||||||
|
<Text
|
||||||
|
as="label"
|
||||||
|
variant="body2"
|
||||||
|
sx={{ width: "40%", fontSize: "16px" }}
|
||||||
|
p={1}
|
||||||
|
>
|
||||||
|
Size:
|
||||||
|
</Text>
|
||||||
|
<Slider
|
||||||
|
value={(note && note.size) || 1}
|
||||||
|
onChange={handleSizeChange}
|
||||||
|
step={0.5}
|
||||||
|
min={0.5}
|
||||||
|
max={noteMaxSize}
|
||||||
|
mr={1}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
{/* Only show hide and lock token actions to map owners */}
|
||||||
|
{map && map.owner === userId && (
|
||||||
|
<Flex sx={{ alignItems: "center", justifyContent: "space-around" }}>
|
||||||
|
<IconButton
|
||||||
|
onClick={handleVisibleChange}
|
||||||
|
title={note && note.visible ? "Hide Note" : "Show Note"}
|
||||||
|
aria-label={note && note.visible ? "Hide Note" : "Show Note"}
|
||||||
|
>
|
||||||
|
{note && note.visible ? <ShowIcon /> : <HideIcon />}
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
onClick={handleLockChange}
|
||||||
|
title={note && note.locked ? "Unlock Note" : "Lock Note"}
|
||||||
|
aria-label={note && note.locked ? "Unlock Note" : "Lock Note"}
|
||||||
|
>
|
||||||
|
{note && note.locked ? <LockIcon /> : <UnlockIcon />}
|
||||||
|
</IconButton>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</MapMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NoteMenu;
|
@ -36,7 +36,6 @@ function DiceRolls({ rolls }) {
|
|||||||
</Flex>
|
</Flex>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<Flex
|
<Flex
|
||||||
bg="overlay"
|
|
||||||
sx={{
|
sx={{
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
}}
|
}}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React from "react";
|
import React, { useContext, useEffect } from "react";
|
||||||
import { Flex, Box, Text } from "theme-ui";
|
import { Flex, Box, Text } from "theme-ui";
|
||||||
import SimpleBar from "simplebar-react";
|
import SimpleBar from "simplebar-react";
|
||||||
|
|
||||||
@ -13,26 +13,80 @@ import DiceTrayButton from "./DiceTrayButton";
|
|||||||
|
|
||||||
import useSetting from "../../helpers/useSetting";
|
import useSetting from "../../helpers/useSetting";
|
||||||
|
|
||||||
function Party({
|
import PartyContext from "../../contexts/PartyContext";
|
||||||
nickname,
|
import {
|
||||||
partyNicknames,
|
PlayerUpdaterContext,
|
||||||
gameId,
|
PlayerStateContext,
|
||||||
onNicknameChange,
|
} from "../../contexts/PlayerContext";
|
||||||
stream,
|
|
||||||
partyStreams,
|
function Party({ gameId, stream, partyStreams, onStreamStart, onStreamEnd }) {
|
||||||
onStreamStart,
|
const setPlayerState = useContext(PlayerUpdaterContext);
|
||||||
onStreamEnd,
|
const playerState = useContext(PlayerStateContext);
|
||||||
timer,
|
const partyState = useContext(PartyContext);
|
||||||
partyTimers,
|
|
||||||
onTimerStart,
|
|
||||||
onTimerStop,
|
|
||||||
shareDice,
|
|
||||||
onShareDiceChage,
|
|
||||||
diceRolls,
|
|
||||||
onDiceRollsChange,
|
|
||||||
partyDiceRolls,
|
|
||||||
}) {
|
|
||||||
const [fullScreen] = useSetting("map.fullScreen");
|
const [fullScreen] = useSetting("map.fullScreen");
|
||||||
|
const [shareDice, setShareDice] = useSetting("dice.shareDice");
|
||||||
|
|
||||||
|
function handleTimerStart(newTimer) {
|
||||||
|
setPlayerState((prevState) => ({ ...prevState, timer: newTimer }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTimerStop() {
|
||||||
|
setPlayerState((prevState) => ({ ...prevState, timer: null }));
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let prevTime = performance.now();
|
||||||
|
let request = requestAnimationFrame(update);
|
||||||
|
let counter = 0;
|
||||||
|
function update(time) {
|
||||||
|
request = requestAnimationFrame(update);
|
||||||
|
const deltaTime = time - prevTime;
|
||||||
|
prevTime = time;
|
||||||
|
|
||||||
|
if (playerState.timer) {
|
||||||
|
counter += deltaTime;
|
||||||
|
// Update timer every second
|
||||||
|
if (counter > 1000) {
|
||||||
|
const newTimer = {
|
||||||
|
...playerState.timer,
|
||||||
|
current: playerState.timer.current - counter,
|
||||||
|
};
|
||||||
|
if (newTimer.current < 0) {
|
||||||
|
setPlayerState((prevState) => ({ ...prevState, timer: null }));
|
||||||
|
} else {
|
||||||
|
setPlayerState((prevState) => ({ ...prevState, timer: newTimer }));
|
||||||
|
}
|
||||||
|
counter = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(request);
|
||||||
|
};
|
||||||
|
}, [playerState.timer, setPlayerState]);
|
||||||
|
|
||||||
|
function handleNicknameChange(newNickname) {
|
||||||
|
setPlayerState((prevState) => ({ ...prevState, nickname: newNickname }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDiceRollsChange(newDiceRolls) {
|
||||||
|
setPlayerState(
|
||||||
|
(prevState) => ({
|
||||||
|
...prevState,
|
||||||
|
dice: { share: shareDice, rolls: newDiceRolls },
|
||||||
|
}),
|
||||||
|
shareDice
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleShareDiceChange(newShareDice) {
|
||||||
|
setShareDice(newShareDice);
|
||||||
|
setPlayerState((prevState) => ({
|
||||||
|
...prevState,
|
||||||
|
dice: { ...prevState.dice, share: newShareDice },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@ -74,31 +128,33 @@ function Party({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Nickname
|
<Nickname
|
||||||
nickname={`${nickname} (you)`}
|
nickname={`${playerState.nickname} (you)`}
|
||||||
diceRolls={shareDice && diceRolls}
|
diceRolls={shareDice && playerState.dice.rolls}
|
||||||
/>
|
/>
|
||||||
{Object.entries(partyNicknames).map(([id, partyNickname]) => (
|
{Object.entries(partyState).map(([id, { nickname, dice }]) => (
|
||||||
<Nickname
|
<Nickname
|
||||||
nickname={partyNickname}
|
nickname={nickname}
|
||||||
key={id}
|
key={id}
|
||||||
stream={partyStreams[id]}
|
stream={partyStreams[id]}
|
||||||
diceRolls={partyDiceRolls[id]}
|
diceRolls={dice.share && dice.rolls}
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{timer && <Timer timer={timer} index={0} />}
|
|
||||||
{Object.entries(partyTimers).map(([id, partyTimer], index) => (
|
|
||||||
<Timer
|
|
||||||
timer={partyTimer}
|
|
||||||
key={id}
|
|
||||||
// Put party timers above your timer if there is one
|
|
||||||
index={timer ? index + 1 : index}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{playerState.timer && <Timer timer={playerState.timer} index={0} />}
|
||||||
|
{Object.entries(partyState)
|
||||||
|
.filter(([_, { timer }]) => timer)
|
||||||
|
.map(([id, { timer }], index) => (
|
||||||
|
<Timer
|
||||||
|
timer={timer}
|
||||||
|
key={id}
|
||||||
|
// Put party timers above your timer if there is one
|
||||||
|
index={playerState.timer ? index + 1 : index}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</SimpleBar>
|
</SimpleBar>
|
||||||
<Flex sx={{ flexDirection: "column" }}>
|
<Flex sx={{ flexDirection: "column" }}>
|
||||||
<ChangeNicknameButton
|
<ChangeNicknameButton
|
||||||
nickname={nickname}
|
nickname={playerState.nickname}
|
||||||
onChange={onNicknameChange}
|
onChange={handleNicknameChange}
|
||||||
/>
|
/>
|
||||||
<AddPartyMemberButton gameId={gameId} />
|
<AddPartyMemberButton gameId={gameId} />
|
||||||
<StartStreamButton
|
<StartStreamButton
|
||||||
@ -107,18 +163,18 @@ function Party({
|
|||||||
stream={stream}
|
stream={stream}
|
||||||
/>
|
/>
|
||||||
<StartTimerButton
|
<StartTimerButton
|
||||||
onTimerStart={onTimerStart}
|
onTimerStart={handleTimerStart}
|
||||||
onTimerStop={onTimerStop}
|
onTimerStop={handleTimerStop}
|
||||||
timer={timer}
|
timer={playerState.timer}
|
||||||
/>
|
/>
|
||||||
<SettingsButton />
|
<SettingsButton />
|
||||||
</Flex>
|
</Flex>
|
||||||
</Box>
|
</Box>
|
||||||
<DiceTrayButton
|
<DiceTrayButton
|
||||||
shareDice={shareDice}
|
shareDice={shareDice}
|
||||||
onShareDiceChage={onShareDiceChage}
|
onShareDiceChage={handleShareDiceChange}
|
||||||
diceRolls={diceRolls}
|
diceRolls={(playerState.dice && playerState.dice.rolls) || []}
|
||||||
onDiceRollsChange={onDiceRollsChange}
|
onDiceRollsChange={handleDiceRollsChange}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
import React, { useState, useRef, useEffect } from "react";
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
import { Text, IconButton, Box, Slider, Flex } from "theme-ui";
|
import { Text, IconButton, Box, Flex } from "theme-ui";
|
||||||
|
|
||||||
import StreamMuteIcon from "../../icons/StreamMuteIcon";
|
import StreamMuteIcon from "../../icons/StreamMuteIcon";
|
||||||
|
|
||||||
import Banner from "../Banner";
|
import Banner from "../Banner";
|
||||||
|
import Slider from "../Slider";
|
||||||
|
|
||||||
function Stream({ stream, nickname }) {
|
function Stream({ stream, nickname }) {
|
||||||
const [streamVolume, setStreamVolume] = useState(1);
|
const [streamVolume, setStreamVolume] = useState(1);
|
||||||
|
@ -1,11 +1,10 @@
|
|||||||
import React, { useContext, useEffect, useRef, useState } from "react";
|
import React, { useContext } from "react";
|
||||||
import { Box, IconButton } from "theme-ui";
|
|
||||||
|
|
||||||
import RemoveTokenIcon from "../../icons/RemoveTokenIcon";
|
|
||||||
|
|
||||||
import AuthContext from "../../contexts/AuthContext";
|
import AuthContext from "../../contexts/AuthContext";
|
||||||
import MapInteractionContext from "../../contexts/MapInteractionContext";
|
import MapInteractionContext from "../../contexts/MapInteractionContext";
|
||||||
|
|
||||||
|
import DragOverlay from "../DragOverlay";
|
||||||
|
|
||||||
function TokenDragOverlay({
|
function TokenDragOverlay({
|
||||||
onTokenStateRemove,
|
onTokenStateRemove,
|
||||||
onTokenStateChange,
|
onTokenStateChange,
|
||||||
@ -16,114 +15,38 @@ function TokenDragOverlay({
|
|||||||
mapState,
|
mapState,
|
||||||
}) {
|
}) {
|
||||||
const { userId } = useContext(AuthContext);
|
const { userId } = useContext(AuthContext);
|
||||||
const { setPreventMapInteraction, mapWidth, mapHeight } = useContext(
|
const { mapWidth, mapHeight } = useContext(MapInteractionContext);
|
||||||
MapInteractionContext
|
|
||||||
);
|
|
||||||
|
|
||||||
const [isRemoveHovered, setIsRemoveHovered] = useState(false);
|
function handleTokenRemove() {
|
||||||
const removeTokenRef = useRef();
|
// Handle other tokens when a vehicle gets deleted
|
||||||
|
if (token && token.category === "vehicle") {
|
||||||
// Detect token hover on remove icon manually to support touch devices
|
const layer = tokenGroup.getLayer();
|
||||||
useEffect(() => {
|
const mountedTokens = tokenGroup.find(".token");
|
||||||
const map = document.querySelector(".map");
|
for (let mountedToken of mountedTokens) {
|
||||||
const mapRect = map.getBoundingClientRect();
|
// Save and restore token position after moving layer
|
||||||
|
const position = mountedToken.absolutePosition();
|
||||||
function detectRemoveHover() {
|
mountedToken.moveTo(layer);
|
||||||
if (!tokenGroup) {
|
mountedToken.absolutePosition(position);
|
||||||
return;
|
onTokenStateChange({
|
||||||
}
|
[mountedToken.id()]: {
|
||||||
|
...mapState.tokens[mountedToken.id()],
|
||||||
const pointerPosition = tokenGroup.getStage().getPointerPosition();
|
x: mountedToken.x() / mapWidth,
|
||||||
const screenSpacePointerPosition = {
|
y: mountedToken.y() / mapHeight,
|
||||||
x: pointerPosition.x + mapRect.left,
|
lastModifiedBy: userId,
|
||||||
y: pointerPosition.y + mapRect.top,
|
lastModified: Date.now(),
|
||||||
};
|
},
|
||||||
if (!removeTokenRef.current) {
|
});
|
||||||
return;
|
|
||||||
}
|
|
||||||
const removeIconPosition = removeTokenRef.current.getBoundingClientRect();
|
|
||||||
|
|
||||||
if (
|
|
||||||
screenSpacePointerPosition.x > removeIconPosition.left &&
|
|
||||||
screenSpacePointerPosition.y > removeIconPosition.top &&
|
|
||||||
screenSpacePointerPosition.x < removeIconPosition.right &&
|
|
||||||
screenSpacePointerPosition.y < removeIconPosition.bottom
|
|
||||||
) {
|
|
||||||
if (!isRemoveHovered) {
|
|
||||||
setIsRemoveHovered(true);
|
|
||||||
}
|
|
||||||
} else if (isRemoveHovered) {
|
|
||||||
setIsRemoveHovered(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
onTokenStateRemove(tokenState);
|
||||||
let handler;
|
}
|
||||||
if (tokenState && tokenGroup && dragging) {
|
|
||||||
handler = setInterval(detectRemoveHover, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (handler) {
|
|
||||||
clearInterval(handler);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [tokenState, tokenGroup, isRemoveHovered, dragging]);
|
|
||||||
|
|
||||||
// Detect drag end of token image and remove it if it is over the remove icon
|
|
||||||
useEffect(() => {
|
|
||||||
function handleTokenDragEnd() {
|
|
||||||
// Handle other tokens when a vehicle gets deleted
|
|
||||||
if (token && token.category === "vehicle") {
|
|
||||||
const layer = tokenGroup.getLayer();
|
|
||||||
const mountedTokens = tokenGroup.find(".token");
|
|
||||||
for (let mountedToken of mountedTokens) {
|
|
||||||
// Save and restore token position after moving layer
|
|
||||||
const position = mountedToken.absolutePosition();
|
|
||||||
mountedToken.moveTo(layer);
|
|
||||||
mountedToken.absolutePosition(position);
|
|
||||||
onTokenStateChange({
|
|
||||||
[mountedToken.id()]: {
|
|
||||||
...mapState.tokens[mountedToken.id()],
|
|
||||||
x: mountedToken.x() / mapWidth,
|
|
||||||
y: mountedToken.y() / mapHeight,
|
|
||||||
lastModifiedBy: userId,
|
|
||||||
lastModified: Date.now(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onTokenStateRemove(tokenState);
|
|
||||||
setPreventMapInteraction(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!dragging && tokenState && isRemoveHovered) {
|
|
||||||
handleTokenDragEnd();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
dragging && (
|
<DragOverlay
|
||||||
<Box
|
dragging={dragging}
|
||||||
sx={{
|
onRemove={handleTokenRemove}
|
||||||
position: "absolute",
|
node={tokenGroup}
|
||||||
bottom: "32px",
|
/>
|
||||||
left: "50%",
|
|
||||||
borderRadius: "50%",
|
|
||||||
transform: isRemoveHovered
|
|
||||||
? "translateX(-50%) scale(2.0)"
|
|
||||||
: "translateX(-50%) scale(1.5)",
|
|
||||||
transition: "transform 250ms ease",
|
|
||||||
color: isRemoveHovered ? "primary" : "text",
|
|
||||||
pointerEvents: "none",
|
|
||||||
}}
|
|
||||||
bg="overlay"
|
|
||||||
ref={removeTokenRef}
|
|
||||||
>
|
|
||||||
<IconButton>
|
|
||||||
<RemoveTokenIcon />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import React, { useEffect, useState, useContext } from "react";
|
import React, { useEffect, useState, useContext } from "react";
|
||||||
import { Box, Input, Slider, Flex, Text, IconButton } from "theme-ui";
|
import { Box, Input, Flex, Text, IconButton } from "theme-ui";
|
||||||
|
|
||||||
|
import Slider from "../Slider";
|
||||||
|
|
||||||
import MapMenu from "../map/MapMenu";
|
import MapMenu from "../map/MapMenu";
|
||||||
|
|
||||||
@ -48,7 +50,7 @@ function TokenMenu({
|
|||||||
}, [isOpen, tokenState, wasOpen, tokenImage]);
|
}, [isOpen, tokenState, wasOpen, tokenImage]);
|
||||||
|
|
||||||
function handleLabelChange(event) {
|
function handleLabelChange(event) {
|
||||||
const label = event.target.value;
|
const label = event.target.value.substring(0, 144);
|
||||||
tokenState &&
|
tokenState &&
|
||||||
onTokenStateChange({ [tokenState.id]: { ...tokenState, label: label } });
|
onTokenStateChange({ [tokenState.id]: { ...tokenState, label: label } });
|
||||||
}
|
}
|
||||||
@ -70,7 +72,7 @@ function TokenMenu({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleSizeChange(event) {
|
function handleSizeChange(event) {
|
||||||
const newSize = parseInt(event.target.value);
|
const newSize = parseFloat(event.target.value);
|
||||||
tokenState &&
|
tokenState &&
|
||||||
onTokenStateChange({ [tokenState.id]: { ...tokenState, size: newSize } });
|
onTokenStateChange({ [tokenState.id]: { ...tokenState, size: newSize } });
|
||||||
}
|
}
|
||||||
@ -209,8 +211,8 @@ function TokenMenu({
|
|||||||
<Slider
|
<Slider
|
||||||
value={(tokenState && tokenState.size) || 1}
|
value={(tokenState && tokenState.size) || 1}
|
||||||
onChange={handleSizeChange}
|
onChange={handleSizeChange}
|
||||||
step={1}
|
step={0.5}
|
||||||
min={1}
|
min={0.5}
|
||||||
max={tokenMaxSize}
|
max={tokenMaxSize}
|
||||||
mr={1}
|
mr={1}
|
||||||
/>
|
/>
|
||||||
|
@ -8,6 +8,7 @@ import usePreventOverscroll from "../../helpers/usePreventOverscroll";
|
|||||||
import useStageInteraction from "../../helpers/useStageInteraction";
|
import useStageInteraction from "../../helpers/useStageInteraction";
|
||||||
import useDataSource from "../../helpers/useDataSource";
|
import useDataSource from "../../helpers/useDataSource";
|
||||||
import useImageCenter from "../../helpers/useImageCenter";
|
import useImageCenter from "../../helpers/useImageCenter";
|
||||||
|
import useResponsiveLayout from "../../helpers/useResponsiveLayout";
|
||||||
|
|
||||||
import GridOnIcon from "../../icons/GridOnIcon";
|
import GridOnIcon from "../../icons/GridOnIcon";
|
||||||
import GridOffIcon from "../../icons/GridOffIcon";
|
import GridOffIcon from "../../icons/GridOffIcon";
|
||||||
@ -71,18 +72,20 @@ function TokenPreview({ token }) {
|
|||||||
const gridWidth = tokenWidth;
|
const gridWidth = tokenWidth;
|
||||||
const gridX = token.defaultSize;
|
const gridX = token.defaultSize;
|
||||||
const gridSize = gridWidth / gridX;
|
const gridSize = gridWidth / gridX;
|
||||||
const gridY = Math.ceil(tokenHeight / gridSize);
|
const gridY = Math.round(tokenHeight / gridSize);
|
||||||
const gridHeight = gridY > 0 ? gridY * gridSize : tokenHeight;
|
const gridHeight = gridY > 0 ? gridY * gridSize : tokenHeight;
|
||||||
const borderWidth = Math.max(
|
const borderWidth = Math.max(
|
||||||
(Math.min(tokenWidth, gridHeight) / 200) * Math.max(1 / stageScale, 1),
|
(Math.min(tokenWidth, gridHeight) / 200) * Math.max(1 / stageScale, 1),
|
||||||
1
|
1
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const layout = useResponsiveLayout();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "300px",
|
height: layout.screenSize === "large" ? "500px" : "300px",
|
||||||
cursor: "move",
|
cursor: "move",
|
||||||
touchAction: "none",
|
touchAction: "none",
|
||||||
outline: "none",
|
outline: "none",
|
||||||
|
@ -46,7 +46,7 @@ function TokenSettings({ token, onSettingsChange }) {
|
|||||||
name="tokenSize"
|
name="tokenSize"
|
||||||
value={`${(token && token.defaultSize) || 0}`}
|
value={`${(token && token.defaultSize) || 0}`}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onSettingsChange("defaultSize", parseInt(e.target.value))
|
onSettingsChange("defaultSize", parseFloat(e.target.value))
|
||||||
}
|
}
|
||||||
disabled={tokenEmpty || token.type === "default"}
|
disabled={tokenEmpty || token.type === "default"}
|
||||||
min={1}
|
min={1}
|
||||||
|
@ -13,7 +13,7 @@ function TokenTile({
|
|||||||
isSelected,
|
isSelected,
|
||||||
onTokenSelect,
|
onTokenSelect,
|
||||||
onTokenEdit,
|
onTokenEdit,
|
||||||
large,
|
size,
|
||||||
canEdit,
|
canEdit,
|
||||||
badges,
|
badges,
|
||||||
}) {
|
}) {
|
||||||
@ -26,7 +26,7 @@ function TokenTile({
|
|||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
onSelect={() => onTokenSelect(token)}
|
onSelect={() => onTokenSelect(token)}
|
||||||
onEdit={() => onTokenEdit(token.id)}
|
onEdit={() => onTokenEdit(token.id)}
|
||||||
large={large}
|
size={size}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
badges={badges}
|
badges={badges}
|
||||||
editTitle="Edit Token"
|
editTitle="Edit Token"
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import React, { useContext } from "react";
|
import React, { useContext } from "react";
|
||||||
import { Flex, Box, Text, IconButton, Close, Label } from "theme-ui";
|
import { Flex, Box, Text, IconButton, Close, Label } from "theme-ui";
|
||||||
import SimpleBar from "simplebar-react";
|
import SimpleBar from "simplebar-react";
|
||||||
import { useMedia } from "react-media";
|
|
||||||
import Case from "case";
|
import Case from "case";
|
||||||
|
|
||||||
import RemoveTokenIcon from "../../icons/RemoveTokenIcon";
|
import RemoveTokenIcon from "../../icons/RemoveTokenIcon";
|
||||||
@ -15,6 +14,8 @@ import FilterBar from "../FilterBar";
|
|||||||
|
|
||||||
import DatabaseContext from "../../contexts/DatabaseContext";
|
import DatabaseContext from "../../contexts/DatabaseContext";
|
||||||
|
|
||||||
|
import useResponsiveLayout from "../../helpers/useResponsiveLayout";
|
||||||
|
|
||||||
function TokenTiles({
|
function TokenTiles({
|
||||||
tokens,
|
tokens,
|
||||||
groups,
|
groups,
|
||||||
@ -31,7 +32,7 @@ function TokenTiles({
|
|||||||
onTokensHide,
|
onTokensHide,
|
||||||
}) {
|
}) {
|
||||||
const { databaseStatus } = useContext(DatabaseContext);
|
const { databaseStatus } = useContext(DatabaseContext);
|
||||||
const isSmallScreen = useMedia({ query: "(max-width: 500px)" });
|
const layout = useResponsiveLayout();
|
||||||
|
|
||||||
let hasSelectedDefaultToken = selectedTokens.some(
|
let hasSelectedDefaultToken = selectedTokens.some(
|
||||||
(token) => token.type === "default"
|
(token) => token.type === "default"
|
||||||
@ -47,7 +48,7 @@ function TokenTiles({
|
|||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
onTokenSelect={onTokenSelect}
|
onTokenSelect={onTokenSelect}
|
||||||
onTokenEdit={onTokenEdit}
|
onTokenEdit={onTokenEdit}
|
||||||
large={isSmallScreen}
|
size={layout.tileSize}
|
||||||
canEdit={
|
canEdit={
|
||||||
isSelected &&
|
isSelected &&
|
||||||
token.type !== "default" &&
|
token.type !== "default" &&
|
||||||
@ -87,15 +88,18 @@ function TokenTiles({
|
|||||||
onAdd={onTokenAdd}
|
onAdd={onTokenAdd}
|
||||||
addTitle="Add Token"
|
addTitle="Add Token"
|
||||||
/>
|
/>
|
||||||
<SimpleBar style={{ height: "400px" }}>
|
<SimpleBar
|
||||||
|
style={{ height: layout.screenSize === "large" ? "600px" : "400px" }}
|
||||||
|
>
|
||||||
<Flex
|
<Flex
|
||||||
p={2}
|
p={2}
|
||||||
pb={4}
|
pb={4}
|
||||||
|
pt={databaseStatus === "disabled" ? 4 : 2}
|
||||||
bg="muted"
|
bg="muted"
|
||||||
sx={{
|
sx={{
|
||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
borderRadius: "4px",
|
borderRadius: "4px",
|
||||||
minHeight: "400px",
|
minHeight: layout.screenSize === "large" ? "600px" : "400px",
|
||||||
alignContent: "flex-start",
|
alignContent: "flex-start",
|
||||||
}}
|
}}
|
||||||
onClick={() => onTokenSelect()}
|
onClick={() => onTokenSelect()}
|
||||||
@ -118,6 +122,7 @@ function TokenTiles({
|
|||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
|
borderRadius: "2px",
|
||||||
}}
|
}}
|
||||||
bg="highlight"
|
bg="highlight"
|
||||||
p={1}
|
p={1}
|
||||||
|
@ -3,7 +3,6 @@ import shortid from "shortid";
|
|||||||
|
|
||||||
import DatabaseContext from "./DatabaseContext";
|
import DatabaseContext from "./DatabaseContext";
|
||||||
|
|
||||||
import { getRandomMonster } from "../helpers/monsters";
|
|
||||||
import FakeStorage from "../helpers/FakeStorage";
|
import FakeStorage from "../helpers/FakeStorage";
|
||||||
|
|
||||||
const AuthContext = React.createContext();
|
const AuthContext = React.createContext();
|
||||||
@ -48,39 +47,8 @@ export function AuthProvider({ children }) {
|
|||||||
loadUserId();
|
loadUserId();
|
||||||
}, [database, databaseStatus]);
|
}, [database, databaseStatus]);
|
||||||
|
|
||||||
const [nickname, setNickname] = useState("");
|
|
||||||
useEffect(() => {
|
|
||||||
if (!database || databaseStatus === "loading") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
async function loadNickname() {
|
|
||||||
const storedNickname = await database.table("user").get("nickname");
|
|
||||||
if (storedNickname) {
|
|
||||||
setNickname(storedNickname.value);
|
|
||||||
} else {
|
|
||||||
const name = getRandomMonster();
|
|
||||||
setNickname(name);
|
|
||||||
database.table("user").add({ key: "nickname", value: name });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadNickname();
|
|
||||||
}, [database, databaseStatus]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
nickname !== undefined &&
|
|
||||||
database !== undefined &&
|
|
||||||
databaseStatus !== "loading"
|
|
||||||
) {
|
|
||||||
database.table("user").update("nickname", { value: nickname });
|
|
||||||
}
|
|
||||||
}, [nickname, database, databaseStatus]);
|
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
userId,
|
userId,
|
||||||
nickname,
|
|
||||||
setNickname,
|
|
||||||
password,
|
password,
|
||||||
setPassword,
|
setPassword,
|
||||||
authenticationStatus,
|
authenticationStatus,
|
||||||
|
@ -1,4 +1,7 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Box, Text } from "theme-ui";
|
||||||
|
|
||||||
|
import Banner from "../components/Banner";
|
||||||
|
|
||||||
import { getDatabase } from "../database";
|
import { getDatabase } from "../database";
|
||||||
|
|
||||||
@ -7,6 +10,7 @@ const DatabaseContext = React.createContext();
|
|||||||
export function DatabaseProvider({ children }) {
|
export function DatabaseProvider({ children }) {
|
||||||
const [database, setDatabase] = useState();
|
const [database, setDatabase] = useState();
|
||||||
const [databaseStatus, setDatabaseStatus] = useState("loading");
|
const [databaseStatus, setDatabaseStatus] = useState("loading");
|
||||||
|
const [databaseError, setDatabaseError] = useState();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Create a test database and open it to see if indexedDB is enabled
|
// Create a test database and open it to see if indexedDB is enabled
|
||||||
@ -34,15 +38,43 @@ export function DatabaseProvider({ children }) {
|
|||||||
await db.open();
|
await db.open();
|
||||||
window.indexedDB.deleteDatabase("__test");
|
window.indexedDB.deleteDatabase("__test");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function handleDatabaseError(event) {
|
||||||
|
if (event.reason.name === "QuotaExceededError") {
|
||||||
|
event.preventDefault();
|
||||||
|
setDatabaseError({
|
||||||
|
name: event.reason.name,
|
||||||
|
message: "Storage Quota Exceeded Please Clear Space and Try Again.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("unhandledrejection", handleDatabaseError);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("unhandledrejection", handleDatabaseError);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
database,
|
database,
|
||||||
databaseStatus,
|
databaseStatus,
|
||||||
|
databaseError,
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<DatabaseContext.Provider value={value}>
|
<DatabaseContext.Provider value={value}>
|
||||||
{children}
|
<>
|
||||||
|
{children}
|
||||||
|
<Banner
|
||||||
|
isOpen={!!databaseError}
|
||||||
|
onRequestClose={() => setDatabaseError()}
|
||||||
|
>
|
||||||
|
<Box p={1}>
|
||||||
|
<Text as="p" variant="body2">
|
||||||
|
{databaseError && databaseError.message}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Banner>
|
||||||
|
</>
|
||||||
</DatabaseContext.Provider>
|
</DatabaseContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -8,7 +8,10 @@ export function KeyboardProvider({ children }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleKeyDown(event) {
|
function handleKeyDown(event) {
|
||||||
// Ignore text input
|
// Ignore text input
|
||||||
if (event.target instanceof HTMLInputElement) {
|
if (
|
||||||
|
event.target instanceof HTMLInputElement ||
|
||||||
|
event.target instanceof HTMLTextAreaElement
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
keyEmitter.emit("keyDown", event);
|
keyEmitter.emit("keyDown", event);
|
||||||
@ -16,7 +19,10 @@ export function KeyboardProvider({ children }) {
|
|||||||
|
|
||||||
function handleKeyUp(event) {
|
function handleKeyUp(event) {
|
||||||
// Ignore text input
|
// Ignore text input
|
||||||
if (event.target instanceof HTMLInputElement) {
|
if (
|
||||||
|
event.target instanceof HTMLInputElement ||
|
||||||
|
event.target instanceof HTMLTextAreaElement
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
keyEmitter.emit("keyUp", event);
|
keyEmitter.emit("keyUp", event);
|
||||||
|
@ -1,8 +1,11 @@
|
|||||||
import React, { useEffect, useState, useContext } from "react";
|
import React, { useEffect, useState, useContext } from "react";
|
||||||
|
import * as Comlink from "comlink";
|
||||||
|
|
||||||
import AuthContext from "./AuthContext";
|
import AuthContext from "./AuthContext";
|
||||||
import DatabaseContext from "./DatabaseContext";
|
import DatabaseContext from "./DatabaseContext";
|
||||||
|
|
||||||
|
import DatabaseWorker from "worker-loader!../workers/DatabaseWorker"; // eslint-disable-line import/no-webpack-loader-syntax
|
||||||
|
|
||||||
import { maps as defaultMaps } from "../maps";
|
import { maps as defaultMaps } from "../maps";
|
||||||
|
|
||||||
const MapDataContext = React.createContext();
|
const MapDataContext = React.createContext();
|
||||||
@ -19,7 +22,8 @@ const defaultMapState = {
|
|||||||
fogDrawActionIndex: -1,
|
fogDrawActionIndex: -1,
|
||||||
fogDrawActions: [],
|
fogDrawActions: [],
|
||||||
// Flags to determine what other people can edit
|
// Flags to determine what other people can edit
|
||||||
editFlags: ["drawing", "tokens"],
|
editFlags: ["drawing", "tokens", "notes"],
|
||||||
|
notes: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function MapDataProvider({ children }) {
|
export function MapDataProvider({ children }) {
|
||||||
@ -28,6 +32,8 @@ export function MapDataProvider({ children }) {
|
|||||||
|
|
||||||
const [maps, setMaps] = useState([]);
|
const [maps, setMaps] = useState([]);
|
||||||
const [mapStates, setMapStates] = useState([]);
|
const [mapStates, setMapStates] = useState([]);
|
||||||
|
const [mapsLoading, setMapsLoading] = useState(true);
|
||||||
|
|
||||||
// Load maps from the database and ensure state is properly setup
|
// Load maps from the database and ensure state is properly setup
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!userId || !database || databaseStatus === "loading") {
|
if (!userId || !database || databaseStatus === "loading") {
|
||||||
@ -59,15 +65,16 @@ export function MapDataProvider({ children }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadMaps() {
|
async function loadMaps() {
|
||||||
let storedMaps = [];
|
const worker = Comlink.wrap(new DatabaseWorker());
|
||||||
// Use a cursor instead of toArray to prevent IPC max size error
|
await worker.loadData("maps");
|
||||||
await database.table("maps").each((map) => storedMaps.push(map));
|
const storedMaps = await worker.data;
|
||||||
const sortedMaps = storedMaps.sort((a, b) => b.created - a.created);
|
const sortedMaps = storedMaps.sort((a, b) => b.created - a.created);
|
||||||
const defaultMapsWithIds = await getDefaultMaps();
|
const defaultMapsWithIds = await getDefaultMaps();
|
||||||
const allMaps = [...sortedMaps, ...defaultMapsWithIds];
|
const allMaps = [...sortedMaps, ...defaultMapsWithIds];
|
||||||
setMaps(allMaps);
|
setMaps(allMaps);
|
||||||
const storedStates = await database.table("states").toArray();
|
const storedStates = await database.table("states").toArray();
|
||||||
setMapStates(storedStates);
|
setMapStates(storedStates);
|
||||||
|
setMapsLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
loadMaps();
|
loadMaps();
|
||||||
@ -136,8 +143,10 @@ export function MapDataProvider({ children }) {
|
|||||||
try {
|
try {
|
||||||
await database.table("maps").update(id, update);
|
await database.table("maps").update(id, update);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// if (error.name !== "QuotaExceededError") {
|
||||||
const map = (await getMapFromDB(id)) || {};
|
const map = (await getMapFromDB(id)) || {};
|
||||||
await database.table("maps").put({ ...map, id, ...update });
|
await database.table("maps").put({ ...map, id, ...update });
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
setMaps((prevMaps) => {
|
setMaps((prevMaps) => {
|
||||||
const newMaps = [...prevMaps];
|
const newMaps = [...prevMaps];
|
||||||
@ -246,6 +255,7 @@ export function MapDataProvider({ children }) {
|
|||||||
putMap,
|
putMap,
|
||||||
getMap,
|
getMap,
|
||||||
getMapFromDB,
|
getMapFromDB,
|
||||||
|
mapsLoading,
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<MapDataContext.Provider value={value}>{children}</MapDataContext.Provider>
|
<MapDataContext.Provider value={value}>{children}</MapDataContext.Provider>
|
||||||
|
30
src/contexts/PartyContext.js
Normal file
30
src/contexts/PartyContext.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
const PartyContext = React.createContext();
|
||||||
|
|
||||||
|
export function PartyProvider({ session, children }) {
|
||||||
|
const [partyState, setPartyState] = useState({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleSocketPartyState(partyState) {
|
||||||
|
if (partyState) {
|
||||||
|
const { [session.id]: _, ...otherMembersState } = partyState;
|
||||||
|
setPartyState(otherMembersState);
|
||||||
|
} else {
|
||||||
|
setPartyState({});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session.socket?.on("party_state", handleSocketPartyState);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
session.socket?.off("party_state", handleSocketPartyState);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PartyContext.Provider value={partyState}>{children}</PartyContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PartyContext;
|
94
src/contexts/PlayerContext.js
Normal file
94
src/contexts/PlayerContext.js
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import React, { useEffect, useContext } from "react";
|
||||||
|
|
||||||
|
import useNetworkedState from "../helpers/useNetworkedState";
|
||||||
|
import DatabaseContext from "./DatabaseContext";
|
||||||
|
import AuthContext from "./AuthContext";
|
||||||
|
|
||||||
|
import { getRandomMonster } from "../helpers/monsters";
|
||||||
|
|
||||||
|
export const PlayerStateContext = React.createContext();
|
||||||
|
export const PlayerUpdaterContext = React.createContext(() => {});
|
||||||
|
|
||||||
|
export function PlayerProvider({ session, children }) {
|
||||||
|
const { userId } = useContext(AuthContext);
|
||||||
|
const { database, databaseStatus } = useContext(DatabaseContext);
|
||||||
|
|
||||||
|
const [playerState, setPlayerState] = useNetworkedState(
|
||||||
|
{
|
||||||
|
nickname: "",
|
||||||
|
timer: null,
|
||||||
|
dice: { share: false, rolls: [] },
|
||||||
|
sessionId: null,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
session,
|
||||||
|
"player_state",
|
||||||
|
100,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!database || databaseStatus === "loading") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
async function loadNickname() {
|
||||||
|
const storedNickname = await database.table("user").get("nickname");
|
||||||
|
if (storedNickname !== undefined) {
|
||||||
|
setPlayerState((prevState) => ({
|
||||||
|
...prevState,
|
||||||
|
nickname: storedNickname.value,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
const name = getRandomMonster();
|
||||||
|
setPlayerState((prevState) => ({ ...prevState, nickname: name }));
|
||||||
|
database.table("user").add({ key: "nickname", value: name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadNickname();
|
||||||
|
}, [database, databaseStatus, setPlayerState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
playerState.nickname &&
|
||||||
|
database !== undefined &&
|
||||||
|
databaseStatus !== "loading"
|
||||||
|
) {
|
||||||
|
database
|
||||||
|
.table("user")
|
||||||
|
.update("nickname", { value: playerState.nickname });
|
||||||
|
}
|
||||||
|
}, [playerState, database, databaseStatus]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPlayerState((prevState) => ({
|
||||||
|
...prevState,
|
||||||
|
userId,
|
||||||
|
}));
|
||||||
|
}, [userId, setPlayerState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleSocketConnect() {
|
||||||
|
// Set the player state to trigger a sync
|
||||||
|
setPlayerState({ ...playerState, sessionId: session.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
session.on("connected", handleSocketConnect);
|
||||||
|
session.socket?.on("connect", handleSocketConnect);
|
||||||
|
session.socket?.on("reconnect", handleSocketConnect);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
session.off("connected", handleSocketConnect);
|
||||||
|
session.socket?.off("connect", handleSocketConnect);
|
||||||
|
session.socket?.off("reconnect", handleSocketConnect);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PlayerStateContext.Provider value={playerState}>
|
||||||
|
<PlayerUpdaterContext.Provider value={setPlayerState}>
|
||||||
|
{children}
|
||||||
|
</PlayerUpdaterContext.Provider>
|
||||||
|
</PlayerStateContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
@ -1,8 +1,11 @@
|
|||||||
import React, { useEffect, useState, useContext } from "react";
|
import React, { useEffect, useState, useContext } from "react";
|
||||||
|
import * as Comlink from "comlink";
|
||||||
|
|
||||||
import AuthContext from "./AuthContext";
|
import AuthContext from "./AuthContext";
|
||||||
import DatabaseContext from "./DatabaseContext";
|
import DatabaseContext from "./DatabaseContext";
|
||||||
|
|
||||||
|
import DatabaseWorker from "worker-loader!../workers/DatabaseWorker"; // eslint-disable-line import/no-webpack-loader-syntax
|
||||||
|
|
||||||
import { tokens as defaultTokens } from "../tokens";
|
import { tokens as defaultTokens } from "../tokens";
|
||||||
|
|
||||||
const TokenDataContext = React.createContext();
|
const TokenDataContext = React.createContext();
|
||||||
@ -14,6 +17,7 @@ export function TokenDataProvider({ children }) {
|
|||||||
const { userId } = useContext(AuthContext);
|
const { userId } = useContext(AuthContext);
|
||||||
|
|
||||||
const [tokens, setTokens] = useState([]);
|
const [tokens, setTokens] = useState([]);
|
||||||
|
const [tokensLoading, setTokensLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!userId || !database || databaseStatus === "loading") {
|
if (!userId || !database || databaseStatus === "loading") {
|
||||||
@ -33,13 +37,14 @@ export function TokenDataProvider({ children }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadTokens() {
|
async function loadTokens() {
|
||||||
let storedTokens = [];
|
const worker = Comlink.wrap(new DatabaseWorker());
|
||||||
// Use a cursor instead of toArray to prevent IPC max size error
|
await worker.loadData("tokens");
|
||||||
await database.table("tokens").each((token) => storedTokens.push(token));
|
const storedTokens = await worker.data;
|
||||||
const sortedTokens = storedTokens.sort((a, b) => b.created - a.created);
|
const sortedTokens = storedTokens.sort((a, b) => b.created - a.created);
|
||||||
const defaultTokensWithIds = getDefaultTokes();
|
const defaultTokensWithIds = getDefaultTokes();
|
||||||
const allTokens = [...sortedTokens, ...defaultTokensWithIds];
|
const allTokens = [...sortedTokens, ...defaultTokensWithIds];
|
||||||
setTokens(allTokens);
|
setTokens(allTokens);
|
||||||
|
setTokensLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
loadTokens();
|
loadTokens();
|
||||||
@ -160,6 +165,7 @@ export function TokenDataProvider({ children }) {
|
|||||||
putToken,
|
putToken,
|
||||||
getToken,
|
getToken,
|
||||||
tokensById,
|
tokensById,
|
||||||
|
tokensLoading,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -268,6 +268,18 @@ function loadVersions(db) {
|
|||||||
token.height = tokenSizes[token.id].height;
|
token.height = tokenSizes[token.id].height;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
// v1.7.0 - Added note tool
|
||||||
|
db.version(16)
|
||||||
|
.stores({})
|
||||||
|
.upgrade((tx) => {
|
||||||
|
return tx
|
||||||
|
.table("states")
|
||||||
|
.toCollection()
|
||||||
|
.modify((state) => {
|
||||||
|
state.notes = {};
|
||||||
|
state.editFlags = [...state.editFlags, "notes"];
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the dexie database used in DatabaseContext
|
// Get the dexie database used in DatabaseContext
|
||||||
|
@ -28,7 +28,7 @@ class GemstoneDice extends Dice {
|
|||||||
pbr.useMetallnessFromMetallicTextureBlue = true;
|
pbr.useMetallnessFromMetallicTextureBlue = true;
|
||||||
|
|
||||||
pbr.subSurface.isTranslucencyEnabled = true;
|
pbr.subSurface.isTranslucencyEnabled = true;
|
||||||
pbr.subSurface.translucencyIntensity = 1.0;
|
pbr.subSurface.translucencyIntensity = 0.2;
|
||||||
pbr.subSurface.minimumThickness = 5;
|
pbr.subSurface.minimumThickness = 5;
|
||||||
pbr.subSurface.maximumThickness = 10;
|
pbr.subSurface.maximumThickness = 10;
|
||||||
pbr.subSurface.tintColor = new Color3(190 / 255, 0, 220 / 255);
|
pbr.subSurface.tintColor = new Color3(190 / 255, 0, 220 / 255);
|
||||||
|
@ -26,9 +26,9 @@ class GlassDice extends Dice {
|
|||||||
pbr.metallic = 0;
|
pbr.metallic = 0;
|
||||||
pbr.subSurface.isRefractionEnabled = true;
|
pbr.subSurface.isRefractionEnabled = true;
|
||||||
pbr.subSurface.indexOfRefraction = 2.0;
|
pbr.subSurface.indexOfRefraction = 2.0;
|
||||||
pbr.subSurface.refractionIntensity = 1.2;
|
pbr.subSurface.refractionIntensity = 1.0;
|
||||||
pbr.subSurface.isTranslucencyEnabled = true;
|
pbr.subSurface.isTranslucencyEnabled = true;
|
||||||
pbr.subSurface.translucencyIntensity = 2.5;
|
pbr.subSurface.translucencyIntensity = 0.5;
|
||||||
pbr.subSurface.minimumThickness = 10;
|
pbr.subSurface.minimumThickness = 10;
|
||||||
pbr.subSurface.maximumThickness = 10;
|
pbr.subSurface.maximumThickness = 10;
|
||||||
pbr.subSurface.tintColor = new Color3(43 / 255, 1, 115 / 255);
|
pbr.subSurface.tintColor = new Color3(43 / 255, 1, 115 / 255);
|
||||||
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
src/docs/assets/UsingNotes.mp4
Normal file
BIN
src/docs/assets/UsingNotes.mp4
Normal file
Binary file not shown.
Binary file not shown.
@ -25,6 +25,7 @@ import mapEditor from "./MapEditor.mp4";
|
|||||||
import filteringMaps from "./FilteringMaps.mp4";
|
import filteringMaps from "./FilteringMaps.mp4";
|
||||||
import groupAndRemovingTokens from "./GroupAndRemovingTokens.mp4";
|
import groupAndRemovingTokens from "./GroupAndRemovingTokens.mp4";
|
||||||
import filteringTokens from "./FilteringTokens.mp4";
|
import filteringTokens from "./FilteringTokens.mp4";
|
||||||
|
import usingNotes from "./UsingNotes.mp4";
|
||||||
|
|
||||||
const assets = {
|
const assets = {
|
||||||
defaultMaps,
|
defaultMaps,
|
||||||
@ -54,6 +55,7 @@ const assets = {
|
|||||||
filteringMaps,
|
filteringMaps,
|
||||||
groupAndRemovingTokens,
|
groupAndRemovingTokens,
|
||||||
filteringTokens,
|
filteringTokens,
|
||||||
|
usingNotes,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default assets;
|
export default assets;
|
||||||
|
16
src/docs/faq/audio-sharing.md
Normal file
16
src/docs/faq/audio-sharing.md
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
|
||||||
|
## Audio Sharing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### How do I use Audio Sharing?
|
||||||
|
|
||||||
|
You can find out how to use Audio Sharing in our how-to [docs](https://www.owlbear.rodeo/how-to#sharingAudio).
|
||||||
|
|
||||||
|
### Why isn’t audio sharing working?
|
||||||
|
|
||||||
|
If you see "Your browser doesn’t support audio sharing":
|
||||||
|
- Be sure that you are using Chrome for sharing audio
|
||||||
|
|
||||||
|
If you see "No audio found when sharing audio":
|
||||||
|
- Be sure that you have the Share audio checkbox ticked when clicking the Share button.
|
@ -1,23 +0,0 @@
|
|||||||
## Connection
|
|
||||||
|
|
||||||
### Connection failure.
|
|
||||||
|
|
||||||
If you are getting a Connection failed error when trying to connect to a game try these following things.
|
|
||||||
|
|
||||||
- Ensure your internet connection is working.
|
|
||||||
- If you are using an incognito or private browsing tab try using normal browsing.
|
|
||||||
- If both computers are on the same network try connecting them to separate networks. For more info see below.
|
|
||||||
|
|
||||||
Owlbear Rodeo uses peer to peer connections to send data between the players. Specifically the [WebRTC API](https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API) is used. WebRTC allows the sending of two types of data, the first is media such as a camera or microphone and the second is raw data such as chat messages or in this case the state of the game map.
|
|
||||||
|
|
||||||
As at this time we don't support voice or video chat as such we only use the raw data feature of WebRTC. This however can lead to connection issues, specifically with the Safari web browser and connecting between two devices on the same network. This is due a decision made by the Safari team to only allow fully peer to peer connections when the user grants camera permission to the website. Unfortunately that means in order to fully support Safari we would need to ask for camera permission even though we wouldn't be using it. To us that is a bad user experience so we have decided against it at this time.
|
|
||||||
|
|
||||||
The good news is that Safari will still work if the two devices are connected to a separate network as we make use of [TURN](https://en.wikipedia.org/wiki/Traversal_Using_Relays_around_NAT) servers which will handle the IP sharing and are not blocked by Safari. So if you're seeing errors and are on the same network as the other person if possible switch to separate networks and try again. For more information about Safari's restrictions on WebRTC see this [bug report](https://bugs.webkit.org/show_bug.cgi?id=173052) on the Webkit site or this [blog post](https://webkit.org/blog/7763/a-closer-look-into-webrtc/).
|
|
||||||
|
|
||||||
### WebRTC not supported.
|
|
||||||
|
|
||||||
Owlbear Rodeo uses WebRTC to communicate between players. Ensure your browser supports WebRTC. A list of supported browsers can be found [here](https://caniuse.com/#feat=rtcpeerconnection).
|
|
||||||
|
|
||||||
### Unable to connect to party.
|
|
||||||
|
|
||||||
This can happen when your internet connection is stable but a peer to peer connection wasn't able to be established between party members. Refreshing the page can help in fixing this.
|
|
@ -1,5 +1,7 @@
|
|||||||
## Saving
|
## Database
|
||||||
|
|
||||||
### Database is disabled.
|
---
|
||||||
|
|
||||||
Owlbear Rodeo uses a local database to store saved data. If you are seeing a database is disabled message this usually means you have data storage disabled. The most common occurrences of this is if you are using Private Browsing modes or in Firefox have the Never Remember History option enabled. The site will still function in these cases however all data will be lost when the page closes or reloads.
|
### Database is diasbled.
|
||||||
|
|
||||||
|
Owlbear Rodeo uses a local database to store saved data. If you are seeing a database is disabled message this usually means you have data storage disabled. The most common occurrences of this is if you are using Private Browsing modes or in Firefox have the Never Remember History option enabled. The site will still function in these cases however all data will be lost when the page closes or reloads.
|
9
src/docs/faq/general.md
Normal file
9
src/docs/faq/general.md
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
## General
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Can I self host Owlbear Rodeo
|
||||||
|
At this time we have no plans to offer a self hosted version of Owlbear Rodeo.
|
||||||
|
|
||||||
|
### Is Owlbear Rodeo open source
|
||||||
|
Owlbear Rodeo is not open source at the moment.
|
33
src/docs/faq/maps.md
Normal file
33
src/docs/faq/maps.md
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
## Games & Maps
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### I’ve added a map to my game, why can’t my players see it?
|
||||||
|
|
||||||
|
This could be because it’s taking a while to upload. Ensure that your players can see a loading bar at the top of the site. Owlbear Rodeo will do its best to load a lower quality map, before continuing to load in higher quality. See 'How big can my maps be?' in our faqs.
|
||||||
|
|
||||||
|
If your players do not see a loading bar at the top of their screen ensure that you don't have an extension that is blocking WebRTC connections. Some VPN extensions can causes this.
|
||||||
|
|
||||||
|
### Why do some of my tokens have a question mark on them?
|
||||||
|
|
||||||
|
This means that the token hasn’t loaded in just yet. If they still haven’t loaded in after some time, try giving the page a refresh. You should see load progress on the loading bar at the top of the website.
|
||||||
|
|
||||||
|
### How big can my maps be?
|
||||||
|
|
||||||
|
Owlbear Rodeo doesn't impose a limit on map sizes but keep in mind the larger the map you upload the longer it will take for your players to load. We recommend trying to keep your maps under 10MB with a good internet connection and under 5MB with slower internet. If you accidently upload a map that is too big you can use the quality option in the map's settings to lower the size without needing to re-upload your map.
|
||||||
|
|
||||||
|
### Where are my maps stored?
|
||||||
|
|
||||||
|
Your maps are stored on your local device. This means that clearing your site data will delete your maps, so please only do so if you have no other options.
|
||||||
|
|
||||||
|
### Why am I being prompted for a password when I didn't set one?
|
||||||
|
|
||||||
|
If you're game link is over 24hrs old then this could be why you are being prompted for a password. Creating a new game won't affect any maps you have used or prepared previously. See 'How long does a game I create last?' in our faqs.
|
||||||
|
|
||||||
|
### How long does a game I create last?
|
||||||
|
|
||||||
|
We encourage users to create games every 24hrs. Any maps you have added or made edits to will not be affected and will still be available when you create another game.
|
||||||
|
|
||||||
|
### I can join my game but the spinner is constantly loading, why?
|
||||||
|
|
||||||
|
This could mean that the service is currently down. Please visit us on Twitter or Reddit and let us know.
|
11
src/docs/howTo/gettingStarted.md
Normal file
11
src/docs/howTo/gettingStarted.md
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
1. Start a game to generate a unique URL that can connect you and
|
||||||
|
your players.
|
||||||
|
|
||||||
|
Each game is recycled after 24 hours so make sure you create a new game when you play your next session.
|
||||||
|
|
||||||
|
2. Invite players with your unique URL from step 1.
|
||||||
|
3. Share a map, roll dice or share audio with your players.
|
||||||
|
|
||||||
|
All data is saved automatically to your computer so next session simply use the same computer and all your maps and tokens will be ready to go.
|
||||||
|
|
||||||
|
That's it, no accounts, no paywalls, no ads, just a virtual tabletop.
|
@ -8,15 +8,15 @@ The Fog Tool allows you to add hidden areas to control what the other party memb
|
|||||||
|
|
||||||
A summary of the Fog Tool options are listed below.
|
A summary of the Fog Tool options are listed below.
|
||||||
|
|
||||||
| Option | Description | Shortcut |
|
| Option | Description | Shortcut |
|
||||||
| ------------- | -------------------------------------------------------------------- | ---------------------------------------------- |
|
| ------------- | -------------------------------------------------- | ---------------------------------------------- |
|
||||||
| Fog Polygon | Click to add points to a fog shape | P, Enter (Accept Shape), Escape (Cancel Shape) |
|
| Fog Polygon | Click to add points to a fog shape | P, Enter (Accept Shape), Escape (Cancel Shape) |
|
||||||
| Fog Brush | Drag to add a free form fog shape | B |
|
| Fog Rectangle | Drag to add a rectangle fog shape | R |
|
||||||
| Toggle Fog | Click a fog shape to hide/show it | T |
|
| Fog Brush | Drag to add a free form fog shape | B |
|
||||||
| Remove Fog | Click a fog shape to remove it | R |
|
| Toggle Fog | Click a fog shape to hide/show it | T |
|
||||||
| Add Fog | When selected drawing a fog shape will add it to the scene | Alt (Toggle) |
|
| Erase Fog | Click a fog shape to remove it | E |
|
||||||
| Subtract Fog | When selected drawing a fog shape will subtract it from other shapes | Alt (Toggle) |
|
| Fog Cutting | Enables/Disables fog cutting | C, Alt (Toggle) |
|
||||||
| Edge Snapping | Enables/Disables edge snapping | S |
|
| Edge Snapping | Enables/Disables edge snapping | S |
|
||||||
| Fog Preview | Enables/Disables a preview of the final fog shapes | F |
|
| Fog Preview | Enables/Disables a preview of the final fog shapes | F |
|
||||||
| Undo | Undo a fog action | Ctrl + Z |
|
| Undo | Undo a fog action | Ctrl + Z |
|
||||||
| Redo | Redo a fog action | Ctrl + Shift + Z |
|
| Redo | Redo a fog action | Ctrl + Shift + Z |
|
||||||
|
@ -4,9 +4,10 @@ The Measure Tool allows you to find how far one point on a map is from another p
|
|||||||
|
|
||||||
A summary of the Measure Tool options are listed below.
|
A summary of the Measure Tool options are listed below.
|
||||||
|
|
||||||
| Option | Description | Shortcut |
|
| Option | Description | Shortcut |
|
||||||
| ------------------- | ---------------------------------------------------------------------------------- | -------- |
|
| ----------------------------- | ---------------------------------------------------------------------------------------- | -------- |
|
||||||
| Grid Distance | This is the distance on a grid and is the metric used in D&D | G |
|
| Grid Distance | This is the distance on a grid and is the metric used in D&D | G |
|
||||||
| Line Distance | This is the actual distance between the two points of the measure tool | L |
|
| Alternating Diagonal Distance | This is the distance on a grid with diagonals alternating between 1 square and 2 squares | A |
|
||||||
| City Block Distance | This is the distance when only travelling in the horizontal or vertical directions | C |
|
| Line Distance | This is the actual distance between the two points of the measure tool | L |
|
||||||
| Scale | This allows you to enter a custom scale and unit value to apply | |
|
| City Block Distance | This is the distance when only travelling in the horizontal or vertical directions | C |
|
||||||
|
| Scale | This allows you to enter a custom scale and unit value to apply | |
|
||||||
|
3
src/docs/howTo/usingNotes.md
Normal file
3
src/docs/howTo/usingNotes.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
The notes tool allows you to write and share notes for other players to see.
|
||||||
|
|
||||||
|
![Using Notes](usingNotes)
|
61
src/docs/releaseNotes/v1.7.0.md
Normal file
61
src/docs/releaseNotes/v1.7.0.md
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
[embed:](https://www.youtube.com/embed/MWbfbN3Brhw)
|
||||||
|
|
||||||
|
## Major Changes
|
||||||
|
|
||||||
|
### Sticky Notes
|
||||||
|
|
||||||
|
Easily add text to a map with the new Notes tool.
|
||||||
|
|
||||||
|
- Select the Notes tool and click and drag to place a note on a map.
|
||||||
|
- Add text, change colours or if you're the GM lock/hide them.
|
||||||
|
|
||||||
|
### Network Rewrite
|
||||||
|
|
||||||
|
This update brings a complete rewrite of the network layer used to connect players together.
|
||||||
|
We now use a hybrid server/peer model compared to the fully peer-peer connection model used previously.
|
||||||
|
This has a few benefits:
|
||||||
|
|
||||||
|
- Connections should be more reliable as the server reduces the load on the players internet connection.
|
||||||
|
- Better support for larger player groups.
|
||||||
|
- Lessens the chance that the game state gets out of sync between players.
|
||||||
|
|
||||||
|
### Fog Workflow Changes
|
||||||
|
|
||||||
|
In this update the fog tool has been changed to hopefully help work better with fog cutting workflows.
|
||||||
|
|
||||||
|
- Add and subtract fog options have been replaced with a single fog cut option.
|
||||||
|
- Fog cutting works similarly to the previous fog subtraction however the cut shape is no longer automatically deleted. This allows you to draw a large fog shape then cut out sections but still allows you to toggle them back on if needed.
|
||||||
|
- New rectangle tool for drawing fog shapes.
|
||||||
|
- Fog has been heavily optimised to limit performance issues.
|
||||||
|
- Fog now has a new look with a more paper-like black colour and a drop shadow to visually separate it from the map.
|
||||||
|
|
||||||
|
## Minor Changes
|
||||||
|
|
||||||
|
- New edit flag for maps to disable note editing.
|
||||||
|
- Sliders now have a label above them when dragged which show the current value of the slider.
|
||||||
|
- Token sizes no longer need to be integers and can now be decimal numbers.
|
||||||
|
- Token resizing on map is now handled in 0.5 increments and allows going down to 0.5x size.
|
||||||
|
- Added decimal support to ruler scale.
|
||||||
|
- Added precision support to ruler scale. For example a scale of 5ft will limit the measurements to integers whereas a scale of 5.0ft will limit to decimal measurements with one decimal place.
|
||||||
|
- New alternating diagonals measurement option for the ruler. This works by alternating diagonal distances between 1x and 2x allowing D&D 3.5 edition style measurements. (Thanks to /u/pspeter3 on Reddit for the suggestion and example code)
|
||||||
|
- Changed loading indicator for maps and tokens to be more visible.
|
||||||
|
- Changed local storage to use the persistent storage API. This means that on FireFox the hidden 2GB storage limit will no longer be an issue.
|
||||||
|
- Added an indicator to how much storage is being used in the settings screen for browsers that support it.
|
||||||
|
- Added multi-threading to initial map and token loading which should help remove lag with large amounts of data.
|
||||||
|
- Changed line height of body text to be more readable.
|
||||||
|
- Added a getting started modal to the home screen that should help with basic usage of the site.
|
||||||
|
- Added saving to use password option for starting a game.
|
||||||
|
- Added support for dragging and dropping images into the map or token screens that originate from a website. This is useful for dragging tokens from the Avrae Discord bot into the token select screen.
|
||||||
|
- Added support for a larger layout for map/token selection and editing for bigger displays.
|
||||||
|
- Changed map automatic quality options to better represent map details.
|
||||||
|
- Updated automatic grid detection model to be more accurate and better handle lower resolution images.
|
||||||
|
- Updated FAQ to actually have frequently asked questions.
|
||||||
|
- Fixed crash when sometimes interacting with the page while it is loading.
|
||||||
|
- Fixed crash when sometimes zooming out too far with a custom token on the map.
|
||||||
|
- Fixed a bug where the drawing erase tool could still be used outside of the drawing tool.
|
||||||
|
- Fixed a bug causing the colour of the glass and gemstone dice to be wrong.
|
||||||
|
- Fixed a bug that would cause custom tokens to not load until a refresh.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Jan 20 2021
|
@ -1,7 +1,7 @@
|
|||||||
import FakeStorage from "./FakeStorage";
|
import FakeStorage from "./FakeStorage";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An interface to a local storage back settings store with a versioning mechanism
|
* An interface to a local storage backed settings store with a versioning mechanism
|
||||||
*/
|
*/
|
||||||
class Settings {
|
class Settings {
|
||||||
name;
|
name;
|
||||||
|
@ -8,7 +8,7 @@ const colors = {
|
|||||||
green: "rgb(133, 255, 102)",
|
green: "rgb(133, 255, 102)",
|
||||||
pink: "rgb(235, 138, 255)",
|
pink: "rgb(235, 138, 255)",
|
||||||
teal: "rgb(68, 224, 241)",
|
teal: "rgb(68, 224, 241)",
|
||||||
black: "rgb(0, 0, 0)",
|
black: "rgb(34, 34, 34)",
|
||||||
darkGray: "rgb(90, 90, 90)",
|
darkGray: "rgb(90, 90, 90)",
|
||||||
lightGray: "rgb(179, 179, 179)",
|
lightGray: "rgb(179, 179, 179)",
|
||||||
white: "rgb(255, 255, 255)",
|
white: "rgb(255, 255, 255)",
|
||||||
|
9
src/helpers/diff.js
Normal file
9
src/helpers/diff.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { applyChange, diff as deepDiff } from "deep-diff";
|
||||||
|
|
||||||
|
export function applyChanges(target, changes) {
|
||||||
|
for (let change of changes) {
|
||||||
|
applyChange(target, true, change);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const diff = deepDiff;
|
@ -8,22 +8,13 @@ const snappingThreshold = 1 / 5;
|
|||||||
export function getBrushPositionForTool(
|
export function getBrushPositionForTool(
|
||||||
map,
|
map,
|
||||||
brushPosition,
|
brushPosition,
|
||||||
tool,
|
useGridSnappning,
|
||||||
toolSettings,
|
useEdgeSnapping,
|
||||||
gridSize,
|
gridSize,
|
||||||
shapes
|
shapes
|
||||||
) {
|
) {
|
||||||
let position = brushPosition;
|
let position = brushPosition;
|
||||||
|
|
||||||
const useGridSnappning =
|
|
||||||
map.snapToGrid &&
|
|
||||||
((tool === "drawing" &&
|
|
||||||
(toolSettings.type === "line" ||
|
|
||||||
toolSettings.type === "rectangle" ||
|
|
||||||
toolSettings.type === "circle" ||
|
|
||||||
toolSettings.type === "triangle")) ||
|
|
||||||
(tool === "fog" && toolSettings.type === "polygon"));
|
|
||||||
|
|
||||||
if (useGridSnappning) {
|
if (useGridSnappning) {
|
||||||
// Snap to corners of grid
|
// Snap to corners of grid
|
||||||
// Subtract offset to transform into offset space then add it back transform back
|
// Subtract offset to transform into offset space then add it back transform back
|
||||||
@ -58,8 +49,6 @@ export function getBrushPositionForTool(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const useEdgeSnapping = tool === "fog" && toolSettings.useEdgeSnapping;
|
|
||||||
|
|
||||||
if (useEdgeSnapping) {
|
if (useEdgeSnapping) {
|
||||||
const minGrid = Vector2.min(gridSize);
|
const minGrid = Vector2.min(gridSize);
|
||||||
let closestDistance = Number.MAX_VALUE;
|
let closestDistance = Number.MAX_VALUE;
|
||||||
@ -239,28 +228,110 @@ export function drawActionsToShapes(actions, actionIndex) {
|
|||||||
);
|
);
|
||||||
let shapeGeom = [[shapePoints, ...shapeHoles]];
|
let shapeGeom = [[shapePoints, ...shapeHoles]];
|
||||||
const difference = polygonClipping.difference(shapeGeom, actionGeom);
|
const difference = polygonClipping.difference(shapeGeom, actionGeom);
|
||||||
for (let i = 0; i < difference.length; i++) {
|
addPolygonDifferenceToShapes(shape, difference, subtractedShapes);
|
||||||
let newId = difference.length > 1 ? `${shape.id}-${i}` : shape.id;
|
|
||||||
// Holes detected
|
|
||||||
let holes = [];
|
|
||||||
if (difference[i].length > 1) {
|
|
||||||
for (let j = 1; j < difference[i].length; j++) {
|
|
||||||
holes.push(difference[i][j].map(([x, y]) => ({ x, y })));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
subtractedShapes[newId] = {
|
|
||||||
...shape,
|
|
||||||
id: newId,
|
|
||||||
data: {
|
|
||||||
points: difference[i][0].map(([x, y]) => ({ x, y })),
|
|
||||||
holes,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
shapesById = subtractedShapes;
|
shapesById = subtractedShapes;
|
||||||
}
|
}
|
||||||
|
if (action.type === "cut") {
|
||||||
|
const actionGeom = action.shapes.map((actionShape) => [
|
||||||
|
actionShape.data.points.map(({ x, y }) => [x, y]),
|
||||||
|
]);
|
||||||
|
let cutShapes = {};
|
||||||
|
for (let shape of Object.values(shapesById)) {
|
||||||
|
const shapePoints = shape.data.points.map(({ x, y }) => [x, y]);
|
||||||
|
const shapeHoles = shape.data.holes.map((hole) =>
|
||||||
|
hole.map(({ x, y }) => [x, y])
|
||||||
|
);
|
||||||
|
let shapeGeom = [[shapePoints, ...shapeHoles]];
|
||||||
|
const difference = polygonClipping.difference(shapeGeom, actionGeom);
|
||||||
|
const intersection = polygonClipping.intersection(
|
||||||
|
shapeGeom,
|
||||||
|
actionGeom
|
||||||
|
);
|
||||||
|
addPolygonDifferenceToShapes(shape, difference, cutShapes);
|
||||||
|
addPolygonIntersectionToShapes(shape, intersection, cutShapes);
|
||||||
|
}
|
||||||
|
shapesById = cutShapes;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return Object.values(shapesById);
|
return Object.values(shapesById);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addPolygonDifferenceToShapes(shape, difference, shapes) {
|
||||||
|
for (let i = 0; i < difference.length; i++) {
|
||||||
|
let newId = `${shape.id}-dif-${i}`;
|
||||||
|
// Holes detected
|
||||||
|
let holes = [];
|
||||||
|
if (difference[i].length > 1) {
|
||||||
|
for (let j = 1; j < difference[i].length; j++) {
|
||||||
|
holes.push(difference[i][j].map(([x, y]) => ({ x, y })));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
shapes[newId] = {
|
||||||
|
...shape,
|
||||||
|
id: newId,
|
||||||
|
data: {
|
||||||
|
points: difference[i][0].map(([x, y]) => ({ x, y })),
|
||||||
|
holes,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPolygonIntersectionToShapes(shape, intersection, shapes) {
|
||||||
|
for (let i = 0; i < intersection.length; i++) {
|
||||||
|
let newId = `${shape.id}-int-${i}`;
|
||||||
|
shapes[newId] = {
|
||||||
|
...shape,
|
||||||
|
id: newId,
|
||||||
|
data: {
|
||||||
|
points: intersection[i][0].map(([x, y]) => ({ x, y })),
|
||||||
|
holes: [],
|
||||||
|
},
|
||||||
|
// Default intersection visibility to false
|
||||||
|
visible: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeShapes(shapes) {
|
||||||
|
if (shapes.length === 0) {
|
||||||
|
return shapes;
|
||||||
|
}
|
||||||
|
let geometries = [];
|
||||||
|
for (let shape of shapes) {
|
||||||
|
if (!shape.visible) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const shapePoints = shape.data.points.map(({ x, y }) => [x, y]);
|
||||||
|
const shapeHoles = shape.data.holes.map((hole) =>
|
||||||
|
hole.map(({ x, y }) => [x, y])
|
||||||
|
);
|
||||||
|
let shapeGeom = [[shapePoints, ...shapeHoles]];
|
||||||
|
geometries.push(shapeGeom);
|
||||||
|
}
|
||||||
|
if (geometries.length === 0) {
|
||||||
|
return geometries;
|
||||||
|
}
|
||||||
|
let union = polygonClipping.union(...geometries);
|
||||||
|
let merged = [];
|
||||||
|
for (let i = 0; i < union.length; i++) {
|
||||||
|
let holes = [];
|
||||||
|
if (union[i].length > 1) {
|
||||||
|
for (let j = 1; j < union[i].length; j++) {
|
||||||
|
holes.push(union[i][j].map(([x, y]) => ({ x, y })));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
merged.push({
|
||||||
|
// Use the data of the first visible shape as the merge
|
||||||
|
...shapes.find((shape) => shape.visible),
|
||||||
|
id: `merged-${i}`,
|
||||||
|
data: {
|
||||||
|
points: union[i][0].map(([x, y]) => ({ x, y })),
|
||||||
|
holes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
@ -243,3 +243,17 @@ export function getRelativePointerPositionNormalized(node) {
|
|||||||
y: relativePosition.y / node.height(),
|
y: relativePosition.y / node.height(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts points from alternating array form to vector array form
|
||||||
|
* @param {number[]} points points in an x, y alternating array
|
||||||
|
* @returns {Vector2[]} a `Vector2` array
|
||||||
|
*/
|
||||||
|
export function convertPointArray(points) {
|
||||||
|
return points.reduce((acc, _, i, arr) => {
|
||||||
|
if (i % 2 === 0) {
|
||||||
|
acc.push({ x: arr[i], y: arr[i + 1] });
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
@ -2,7 +2,7 @@ import { captureException } from "@sentry/react";
|
|||||||
|
|
||||||
export function logError(error) {
|
export function logError(error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
if (process.env.NODE_ENV === "production") {
|
if (process.env.REACT_APP_LOGGING === "true") {
|
||||||
captureException(error);
|
captureException(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import GridSizeModel from "../ml/gridSize/GridSizeModel";
|
import GridSizeModel from "../ml/gridSize/GridSizeModel";
|
||||||
|
import * as Vector2 from "./vector2";
|
||||||
|
|
||||||
import { logError } from "./logging";
|
import { logError } from "./logging";
|
||||||
|
|
||||||
@ -138,7 +139,6 @@ export async function getGridSize(image) {
|
|||||||
let prediction;
|
let prediction;
|
||||||
|
|
||||||
// Try and use ML grid detection
|
// Try and use ML grid detection
|
||||||
// TODO: Fix possible error on Android
|
|
||||||
try {
|
try {
|
||||||
prediction = await gridSizeML(image, candidates);
|
prediction = await gridSizeML(image, candidates);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -160,5 +160,66 @@ export function getMapMaxZoom(map) {
|
|||||||
return 10;
|
return 10;
|
||||||
}
|
}
|
||||||
// Return max grid size / 2
|
// Return max grid size / 2
|
||||||
return Math.max(Math.min(map.grid.size.x, map.grid.size.y) / 2, 5);
|
return Math.max(Math.max(map.grid.size.x, map.grid.size.y) / 2, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function snapNodeToMap(
|
||||||
|
map,
|
||||||
|
mapWidth,
|
||||||
|
mapHeight,
|
||||||
|
node,
|
||||||
|
snappingThreshold
|
||||||
|
) {
|
||||||
|
const offset = Vector2.multiply(map.grid.inset.topLeft, {
|
||||||
|
x: mapWidth,
|
||||||
|
y: mapHeight,
|
||||||
|
});
|
||||||
|
const gridSize = {
|
||||||
|
x:
|
||||||
|
(mapWidth * (map.grid.inset.bottomRight.x - map.grid.inset.topLeft.x)) /
|
||||||
|
map.grid.size.x,
|
||||||
|
y:
|
||||||
|
(mapHeight * (map.grid.inset.bottomRight.y - map.grid.inset.topLeft.y)) /
|
||||||
|
map.grid.size.y,
|
||||||
|
};
|
||||||
|
|
||||||
|
const position = node.position();
|
||||||
|
const halfSize = Vector2.divide({ x: node.width(), y: node.height() }, 2);
|
||||||
|
|
||||||
|
// Offsets to tranform the centered position into the four corners
|
||||||
|
const cornerOffsets = [
|
||||||
|
halfSize,
|
||||||
|
{ x: -halfSize.x, y: -halfSize.y },
|
||||||
|
{ x: halfSize.x, y: -halfSize.y },
|
||||||
|
{ x: -halfSize.x, y: halfSize.y },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Minimum distance from a corner to the grid
|
||||||
|
let minCornerGridDistance = Number.MAX_VALUE;
|
||||||
|
// Minimum component of the difference between the min corner and the grid
|
||||||
|
let minCornerMinComponent;
|
||||||
|
// Closest grid value
|
||||||
|
let minGridSnap;
|
||||||
|
|
||||||
|
// Find the closest corner to the grid
|
||||||
|
for (let cornerOffset of cornerOffsets) {
|
||||||
|
const corner = Vector2.add(position, cornerOffset);
|
||||||
|
// Transform into offset space, round, then transform back
|
||||||
|
const gridSnap = Vector2.add(
|
||||||
|
Vector2.roundTo(Vector2.subtract(corner, offset), gridSize),
|
||||||
|
offset
|
||||||
|
);
|
||||||
|
const gridDistance = Vector2.length(Vector2.subtract(gridSnap, corner));
|
||||||
|
const minComponent = Vector2.min(gridSize);
|
||||||
|
if (gridDistance < minCornerGridDistance) {
|
||||||
|
minCornerGridDistance = gridDistance;
|
||||||
|
minCornerMinComponent = minComponent;
|
||||||
|
// Move the grid value back to the center
|
||||||
|
minGridSnap = Vector2.subtract(gridSnap, cornerOffset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minCornerGridDistance < minCornerMinComponent * snappingThreshold) {
|
||||||
|
node.position(minGridSnap);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -11,7 +11,28 @@ function useDataSource(data, defaultSources, unknownSource) {
|
|||||||
}
|
}
|
||||||
let url = unknownSource;
|
let url = unknownSource;
|
||||||
if (data.type === "file") {
|
if (data.type === "file") {
|
||||||
url = URL.createObjectURL(new Blob([data.file]));
|
if (data.resolutions) {
|
||||||
|
// Check is a resolution is specified
|
||||||
|
if (data.quality && data.resolutions[data.quality]) {
|
||||||
|
url = URL.createObjectURL(
|
||||||
|
new Blob([data.resolutions[data.quality].file])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// If no file available fallback to the highest resolution
|
||||||
|
else if (!data.file) {
|
||||||
|
const resolutionArray = Object.keys(data.resolutions);
|
||||||
|
url = URL.createObjectURL(
|
||||||
|
new Blob([
|
||||||
|
data.resolutions[resolutionArray[resolutionArray.length - 1]]
|
||||||
|
.file,
|
||||||
|
])
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
url = URL.createObjectURL(new Blob([data.file]));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
url = URL.createObjectURL(new Blob([data.file]));
|
||||||
|
}
|
||||||
} else if (data.type === "default") {
|
} else if (data.type === "default") {
|
||||||
url = defaultSources[data.key];
|
url = defaultSources[data.key];
|
||||||
}
|
}
|
||||||
@ -19,7 +40,10 @@ function useDataSource(data, defaultSources, unknownSource) {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (data.type === "file" && url) {
|
if (data.type === "file" && url) {
|
||||||
URL.revokeObjectURL(url);
|
// Remove file url after 5 seconds as we still may be using it while the next image loads
|
||||||
|
setTimeout(() => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, 5000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [data, defaultSources, unknownSource]);
|
}, [data, defaultSources, unknownSource]);
|
||||||
|
@ -3,49 +3,10 @@ import useImage from "use-image";
|
|||||||
|
|
||||||
import useDataSource from "./useDataSource";
|
import useDataSource from "./useDataSource";
|
||||||
|
|
||||||
import { isEmpty } from "./shared";
|
|
||||||
|
|
||||||
import { mapSources as defaultMapSources } from "../maps";
|
import { mapSources as defaultMapSources } from "../maps";
|
||||||
|
|
||||||
function useMapImage(map) {
|
function useMapImage(map) {
|
||||||
const [mapSourceMap, setMapSourceMap] = useState({});
|
const mapSource = useDataSource(map, defaultMapSources);
|
||||||
// Update source map data when either the map or map quality changes
|
|
||||||
useEffect(() => {
|
|
||||||
function updateMapSource() {
|
|
||||||
if (map && map.type === "file" && map.resolutions) {
|
|
||||||
// If quality is set and the quality is available
|
|
||||||
if (map.quality !== "original" && map.resolutions[map.quality]) {
|
|
||||||
setMapSourceMap({
|
|
||||||
...map.resolutions[map.quality],
|
|
||||||
id: map.id,
|
|
||||||
quality: map.quality,
|
|
||||||
});
|
|
||||||
} else if (!map.file) {
|
|
||||||
// If no file fallback to the highest resolution
|
|
||||||
const resolutionArray = Object.keys(map.resolutions);
|
|
||||||
setMapSourceMap({
|
|
||||||
...map.resolutions[resolutionArray[resolutionArray.length - 1]],
|
|
||||||
id: map.id,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setMapSourceMap(map);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setMapSourceMap(map);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (map && map.id !== mapSourceMap.id) {
|
|
||||||
updateMapSource();
|
|
||||||
} else if (map && map.type === "file") {
|
|
||||||
if (map.file && map.quality !== mapSourceMap.quality) {
|
|
||||||
updateMapSource();
|
|
||||||
}
|
|
||||||
} else if (!map && !isEmpty(mapSourceMap)) {
|
|
||||||
setMapSourceMap({});
|
|
||||||
}
|
|
||||||
}, [map, mapSourceMap]);
|
|
||||||
|
|
||||||
const mapSource = useDataSource(mapSourceMap, defaultMapSources);
|
|
||||||
const [mapSourceImage, mapSourceImageStatus] = useImage(mapSource);
|
const [mapSourceImage, mapSourceImageStatus] = useImage(mapSource);
|
||||||
|
|
||||||
// Create a map source that only updates when the image is fully loaded
|
// Create a map source that only updates when the image is fully loaded
|
||||||
|
113
src/helpers/useNetworkedState.js
Normal file
113
src/helpers/useNetworkedState.js
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import { useEffect, useState, useRef, useCallback } from "react";
|
||||||
|
|
||||||
|
import useDebounce from "./useDebounce";
|
||||||
|
import { diff, applyChanges } from "./diff";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @callback setNetworkedState
|
||||||
|
* @param {any} update The updated state or a state function passed into setState
|
||||||
|
* @param {boolean} sync Whether to sync the update with the session
|
||||||
|
* @param {boolean} force Whether to force a full update, usefull when partialUpdates is enabled
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to sync a react state to a `Session`
|
||||||
|
*
|
||||||
|
* @param {any} initialState
|
||||||
|
* @param {Session} session `Session` instance
|
||||||
|
* @param {string} eventName Name of the event to send to the session
|
||||||
|
* @param {number} debounceRate Amount to debounce before sending to the session (ms)
|
||||||
|
* @param {boolean} partialUpdates Allow sending of partial updates to the session
|
||||||
|
* @param {string} partialUpdatesKey Key to lookup in the state to identify a partial update
|
||||||
|
*
|
||||||
|
* @returns {[any, setNetworkedState]}
|
||||||
|
*/
|
||||||
|
function useNetworkedState(
|
||||||
|
initialState,
|
||||||
|
session,
|
||||||
|
eventName,
|
||||||
|
debounceRate = 100,
|
||||||
|
partialUpdates = true,
|
||||||
|
partialUpdatesKey = "id"
|
||||||
|
) {
|
||||||
|
const [state, _setState] = useState(initialState);
|
||||||
|
// Used to control whether the state needs to be sent to the socket
|
||||||
|
const dirtyRef = useRef(false);
|
||||||
|
|
||||||
|
// Used to force a full update
|
||||||
|
const forceUpdateRef = useRef(false);
|
||||||
|
|
||||||
|
// Update dirty at the same time as state
|
||||||
|
const setState = useCallback((update, sync = true, force = false) => {
|
||||||
|
dirtyRef.current = sync;
|
||||||
|
forceUpdateRef.current = force;
|
||||||
|
_setState(update);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const eventNameRef = useRef(eventName);
|
||||||
|
useEffect(() => {
|
||||||
|
eventNameRef.current = eventName;
|
||||||
|
}, [eventName]);
|
||||||
|
|
||||||
|
const debouncedState = useDebounce(state, debounceRate);
|
||||||
|
const lastSyncedStateRef = useRef();
|
||||||
|
useEffect(() => {
|
||||||
|
if (session.socket && dirtyRef.current) {
|
||||||
|
// If partial updates enabled, send just the changes to the socket
|
||||||
|
if (
|
||||||
|
lastSyncedStateRef.current &&
|
||||||
|
debouncedState &&
|
||||||
|
partialUpdates &&
|
||||||
|
!forceUpdateRef.current
|
||||||
|
) {
|
||||||
|
const changes = diff(lastSyncedStateRef.current, debouncedState);
|
||||||
|
if (changes) {
|
||||||
|
const update = { id: debouncedState[partialUpdatesKey], changes };
|
||||||
|
session.socket.emit(`${eventName}_update`, update);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
session.socket.emit(eventName, debouncedState);
|
||||||
|
}
|
||||||
|
dirtyRef.current = false;
|
||||||
|
forceUpdateRef.current = false;
|
||||||
|
lastSyncedStateRef.current = debouncedState;
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
session.socket,
|
||||||
|
eventName,
|
||||||
|
debouncedState,
|
||||||
|
partialUpdates,
|
||||||
|
partialUpdatesKey,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleSocketEvent(data) {
|
||||||
|
_setState(data);
|
||||||
|
lastSyncedStateRef.current = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSocketUpdateEvent(update) {
|
||||||
|
_setState((prevState) => {
|
||||||
|
if (prevState[partialUpdatesKey] === update.id) {
|
||||||
|
let newState = { ...prevState };
|
||||||
|
applyChanges(newState, update.changes);
|
||||||
|
lastSyncedStateRef.current = newState;
|
||||||
|
return newState;
|
||||||
|
} else {
|
||||||
|
return prevState;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
session.socket?.on(eventName, handleSocketEvent);
|
||||||
|
session.socket?.on(`${eventName}_update`, handleSocketUpdateEvent);
|
||||||
|
return () => {
|
||||||
|
session.socket?.off(eventName, handleSocketEvent);
|
||||||
|
session.socket?.off(`${eventName}_update`, handleSocketUpdateEvent);
|
||||||
|
};
|
||||||
|
}, [session.socket, eventName, partialUpdatesKey]);
|
||||||
|
|
||||||
|
return [state, setState];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useNetworkedState;
|
27
src/helpers/useResponsiveLayout.js
Normal file
27
src/helpers/useResponsiveLayout.js
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { useMedia } from "react-media";
|
||||||
|
|
||||||
|
function useResponsiveLayout() {
|
||||||
|
const isMediumScreen = useMedia({ query: "(min-width: 500px)" });
|
||||||
|
const isLargeScreen = useMedia({ query: "(min-width: 1500px)" });
|
||||||
|
const screenSize = isLargeScreen
|
||||||
|
? "large"
|
||||||
|
: isMediumScreen
|
||||||
|
? "medium"
|
||||||
|
: "small";
|
||||||
|
|
||||||
|
const modalSize = isLargeScreen
|
||||||
|
? "842px"
|
||||||
|
: isMediumScreen
|
||||||
|
? "642px"
|
||||||
|
: "500px";
|
||||||
|
|
||||||
|
const tileSize = isLargeScreen
|
||||||
|
? "small"
|
||||||
|
: isMediumScreen
|
||||||
|
? "medium"
|
||||||
|
: "large";
|
||||||
|
|
||||||
|
return { screenSize, modalSize, tileSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useResponsiveLayout;
|
@ -27,7 +27,7 @@ function useStageInteraction(
|
|||||||
onWheelStart: (props) => {
|
onWheelStart: (props) => {
|
||||||
const { event } = props;
|
const { event } = props;
|
||||||
isInteractingWithCanvas.current =
|
isInteractingWithCanvas.current =
|
||||||
event.target === layer.getCanvas()._canvas;
|
layer && event.target === layer.getCanvas()._canvas;
|
||||||
gesture.onWheelStart && gesture.onWheelStart(props);
|
gesture.onWheelStart && gesture.onWheelStart(props);
|
||||||
},
|
},
|
||||||
onWheel: (props) => {
|
onWheel: (props) => {
|
||||||
@ -62,7 +62,7 @@ function useStageInteraction(
|
|||||||
onPinchStart: (props) => {
|
onPinchStart: (props) => {
|
||||||
const { event } = props;
|
const { event } = props;
|
||||||
isInteractingWithCanvas.current =
|
isInteractingWithCanvas.current =
|
||||||
event.target === layer.getCanvas()._canvas;
|
layer && event.target === layer.getCanvas()._canvas;
|
||||||
const { da, origin } = props;
|
const { da, origin } = props;
|
||||||
const [distance] = da;
|
const [distance] = da;
|
||||||
const [originX, originY] = origin;
|
const [originX, originY] = origin;
|
||||||
@ -124,7 +124,7 @@ function useStageInteraction(
|
|||||||
onDragStart: (props) => {
|
onDragStart: (props) => {
|
||||||
const { event } = props;
|
const { event } = props;
|
||||||
isInteractingWithCanvas.current =
|
isInteractingWithCanvas.current =
|
||||||
event.target === layer.getCanvas()._canvas;
|
layer && event.target === layer.getCanvas()._canvas;
|
||||||
gesture.onDragStart && gesture.onDragStart(props);
|
gesture.onDragStart && gesture.onDragStart(props);
|
||||||
},
|
},
|
||||||
onDrag: (props) => {
|
onDrag: (props) => {
|
||||||
|
@ -363,7 +363,7 @@ export function compare(a, b, threshold) {
|
|||||||
* Returns the distance between two vectors
|
* Returns the distance between two vectors
|
||||||
* @param {Vector2} a
|
* @param {Vector2} a
|
||||||
* @param {Vector2} b
|
* @param {Vector2} b
|
||||||
* @param {string} type - `chebyshev | euclidean | manhattan`
|
* @param {string} type - `chebyshev | euclidean | manhattan | alternating`
|
||||||
*/
|
*/
|
||||||
export function distance(a, b, type) {
|
export function distance(a, b, type) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
@ -373,6 +373,12 @@ export function distance(a, b, type) {
|
|||||||
return length(subtract(a, b));
|
return length(subtract(a, b));
|
||||||
case "manhattan":
|
case "manhattan":
|
||||||
return Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
|
return Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
|
||||||
|
case "alternating":
|
||||||
|
// Alternating diagonal distance like D&D 3.5 and Pathfinder
|
||||||
|
const delta = abs(subtract(a, b));
|
||||||
|
const ma = max(delta);
|
||||||
|
const mi = min(delta);
|
||||||
|
return ma - mi + Math.floor(1.5 * mi);
|
||||||
default:
|
default:
|
||||||
return length(subtract(a, b));
|
return length(subtract(a, b));
|
||||||
}
|
}
|
||||||
|
@ -1,18 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
function FogAddIcon() {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="24"
|
|
||||||
fill="currentcolor"
|
|
||||||
>
|
|
||||||
<path d="M0 0h24v24H0z" fill="none" />
|
|
||||||
<path d="M19.35 10.04A7.49 7.49 0 0012 4C9.11 4 6.6 5.64 5.35 8.04A5.994 5.994 0 000 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM16 14h-3v3c0 .55-.45 1-1 1s-1-.45-1-1v-3H8c-.55 0-1-.45-1-1s.45-1 1-1h3V9c0-.55.45-1 1-1s1 .45 1 1v3h3c.55 0 1 .45 1 1s-.45 1-1 1z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default FogAddIcon;
|
|
18
src/icons/FogCutOffIcon.js
Normal file
18
src/icons/FogCutOffIcon.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
function FogCutOffIcon() {
|
||||||
|
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="M5.28 3.68l15.56 15.56a1 1 0 01-1.42 1.41l-6.21-6.21-.58 1.2a4 4 0 11-6.17 2.14A4 4 0 118.6 11.6l1.2-.58L3.87 5.1a1 1 0 111.41-1.4zm3.6 13.79a2 2 0 102.83 2.83 2 2 0 00-2.82-2.83zm-4.94-4.95a2 2 0 102.83 2.83 2 2 0 00-2.83-2.83zm6.01 1.76l-.65.32a4 4 0 01-.08.35l-.03.09a4 4 0 01.44-.1l.32-.66zM22.9 6.31l.02.04.02.05c.33.74.01 1.6-.72 1.96l-6.68 3.22-2.24-2.24 7.66-3.7c.72-.35 1.6-.05 1.94.67zM17.84 1.3l.04.01a1.45 1.45 0 01.72 1.97l-1.54 3.18-4.25 1.9L15.88 2a1.5 1.5 0 011.96-.71z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FogCutOffIcon;
|
18
src/icons/FogCutOnIcon.js
Normal file
18
src/icons/FogCutOnIcon.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
function FogCutOnIcon() {
|
||||||
|
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="M2.52 11.1a4 4 0 016.08.5l12.36-5.96c.72-.35 1.6-.05 1.94.67l.02.04.02.05c.33.74.01 1.6-.72 1.96l-7.93 3.83-1.66 3.44a4 4 0 11-6.17 2.14 4 4 0 01-3.94-6.67zm6.37 6.37a2 2 0 102.82 2.83 2 2 0 00-2.82-2.83zm2.82-5.66c-.2.2-.2.51 0 .7.2.2.51.2.71 0 .2-.19.2-.5 0-.7a.5.5 0 00-.7 0zM9.3 14.6a4 4 0 01-.08.35l-.03.09a4 4 0 01.44-.1l.32-.66-.65.32zm8.54-13.3l.04.01a1.45 1.45 0 01.72 1.97l-1.54 3.18-4.25 1.9L15.88 2a1.5 1.5 0 011.96-.71zM3.94 12.52a2 2 0 102.83 2.83 2 2 0 00-2.83-2.83z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FogCutOnIcon;
|
18
src/icons/FogRectangleIcon.js
Normal file
18
src/icons/FogRectangleIcon.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
function FogRectangleIcon() {
|
||||||
|
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 FogRectangleIcon;
|
@ -1,18 +0,0 @@
|
|||||||
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;
|
|
19
src/icons/HelpIcon.js
Normal file
19
src/icons/HelpIcon.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
function HelpIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentcolor"
|
||||||
|
style={{ margin: "0 4px" }}
|
||||||
|
>
|
||||||
|
<path d="M0 0h24v24H0z" 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 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-1-4h2v2h-2zm1.61-9.96c-2.06-.3-3.88.97-4.43 2.79-.18.58.26 1.17.87 1.17h.2c.41 0 .74-.29.88-.67.32-.89 1.27-1.5 2.3-1.28.95.2 1.65 1.13 1.57 2.1-.1 1.34-1.62 1.63-2.45 2.88 0 .01-.01.01-.01.02-.01.02-.02.03-.03.05-.09.15-.18.32-.25.5-.01.03-.03.05-.04.08-.01.02-.01.04-.02.07-.12.34-.2.75-.2 1.25h2c0-.42.11-.77.28-1.07.02-.03.03-.06.05-.09.08-.14.18-.27.28-.39.01-.01.02-.03.03-.04.1-.12.21-.23.33-.34.96-.91 2.26-1.65 1.99-3.56-.24-1.74-1.61-3.21-3.35-3.47z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default HelpIcon;
|
18
src/icons/MeasureAlternatingIcon.js
Normal file
18
src/icons/MeasureAlternatingIcon.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
function MeasureAlternatingIcon() {
|
||||||
|
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="M15.54 4.93a2.5 2.5 0 1 1 .85 4.1l-.94.94a4 4 0 0 1-5.48 5.48l-.95.94a2.5 2.5 0 1 1-1.41-1.41l.94-.95a4 4 0 0 1 5.48-5.48l.95-.94a2.5 2.5 0 0 1 .56-2.68z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MeasureAlternatingIcon;
|
19
src/icons/MoveIcon.js
Normal file
19
src/icons/MoveIcon.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
function MoveIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
fill="currentcolor"
|
||||||
|
transform="scale(-1 1)"
|
||||||
|
>
|
||||||
|
<path d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M8.79,9.24V5.5c0-1.38,1.12-2.5,2.5-2.5s2.5,1.12,2.5,2.5v3.74c1.21-0.81,2-2.18,2-3.74c0-2.49-2.01-4.5-4.5-4.5 s-4.5,2.01-4.5,4.5C6.79,7.06,7.58,8.43,8.79,9.24z M14.29,11.71c-0.28-0.14-0.58-0.21-0.89-0.21h-0.61v-6 c0-0.83-0.67-1.5-1.5-1.5s-1.5,0.67-1.5,1.5v10.74l-3.44-0.72c-0.37-0.08-0.76,0.04-1.03,0.31c-0.43,0.44-0.43,1.14,0,1.58 l4.01,4.01C9.71,21.79,10.22,22,10.75,22h6.1c1,0,1.84-0.73,1.98-1.72l0.63-4.47c0.12-0.85-0.32-1.69-1.09-2.07L14.29,11.71z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MoveIcon;
|
19
src/icons/NoteToolIcon.js
Normal file
19
src/icons/NoteToolIcon.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
function NoteToolIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
fill="currentcolor"
|
||||||
|
transform="scale(-1 1)"
|
||||||
|
>
|
||||||
|
<path d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M19,3H4.99C3.89,3,3,3.9,3,5l0.01,14c0,1.1,0.89,2,1.99,2h10l6-6V5C21,3.9,20.1,3,19,3z M8,8h8c0.55,0,1,0.45,1,1v0 c0,0.55-0.45,1-1,1H8c-0.55,0-1-0.45-1-1v0C7,8.45,7.45,8,8,8z M11,14H8c-0.55,0-1-0.45-1-1v0c0-0.55,0.45-1,1-1h3 c0.55,0,1,0.45,1,1v0C12,13.55,11.55,14,11,14z M14,19.5V15c0-0.55,0.45-1,1-1h4.5L14,19.5z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NoteToolIcon;
|
@ -10,7 +10,7 @@ function SnappingOffIcon() {
|
|||||||
fill="currentcolor"
|
fill="currentcolor"
|
||||||
>
|
>
|
||||||
<path d="M24 24H0V0h24z" fill="none" />
|
<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" />
|
<path d="M10 5.59L6.62 2.2a1 1 0 00-.2-.15c.14-.03.27-.05.4-.05L7 2h.48c1.06 0 2.4.83 2.51 1.86L10 4v1.59zm10.88 10.88L16 11.59V4a2 2 0 011.85-2H19a3 3 0 013 2.82v6.87c0 1.69-.37 3.34-1.12 4.78zM13 21c-5.36 0-8.87-3.7-9-8.72V4.83l-.9-.9A1 1 0 014.51 2.5l.37.37a3 3 0 01.04-.04v.08l16.15 16.15a1 1 0 01-1.41 1.42l-1.17-1.17A8.98 8.98 0 0113 21zm5-12.97h.28c.55 0 1.72-.01 1.72-.03V5a1 1 0 00-.88-1H18v4.03zM6 8h.06L7.17 8 6 6.83V8zM13 15a3 3 0 00.98-.18L10 10.83V12c0 1.98 1.72 2.99 3 2.99z" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -10,7 +10,7 @@ function SnappingOnIcon() {
|
|||||||
fill="currentcolor"
|
fill="currentcolor"
|
||||||
>
|
>
|
||||||
<path d="M24 24H0V0h24z" fill="none" />
|
<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" />
|
<path d="M13 21c-5.46 0-9-3.83-9-9V5a3 3 0 013-3h.48C8.58 2 10 2.9 10 4v8c0 1.98 1.72 2.99 3 2.99S16 14 16 12V4c0-1.1.9-2 2-2h1a3 3 0 013 3v6.69c0 4.79-3 9.31-9 9.31zm5-12.97h.28c.55 0 1.72-.01 1.72-.03V5a1 1 0 00-.88-1H18v4.03zM6 8h.06L8 8V4.2h-.01l-.02-.01c-.13-.1-.3-.17-.41-.2H7a1 1 0 00-1 .88v3.13z" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
Binary file not shown.
Before Width: | Height: | Size: 31 KiB |
@ -11,7 +11,7 @@ import * as serviceWorker from "./serviceWorker";
|
|||||||
|
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
if (process.env.NODE_ENV === "production") {
|
if (process.env.REACT_APP_LOGGING === "true") {
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
dsn:
|
dsn:
|
||||||
"https://bc1e2edfe7ca453f8e7357a48693979e@o467475.ingest.sentry.io/5493956",
|
"https://bc1e2edfe7ca453f8e7357a48693979e@o467475.ingest.sentry.io/5493956",
|
||||||
@ -19,10 +19,12 @@ if (process.env.NODE_ENV === "production") {
|
|||||||
// Ignore resize error as it is triggered by going fullscreen on slower computers
|
// Ignore resize error as it is triggered by going fullscreen on slower computers
|
||||||
// Ignore quota error
|
// Ignore quota error
|
||||||
// Ignore XDR encoding failure bug in Firefox https://bugzilla.mozilla.org/show_bug.cgi?id=1678243
|
// Ignore XDR encoding failure bug in Firefox https://bugzilla.mozilla.org/show_bug.cgi?id=1678243
|
||||||
|
// Ignore LastPass extension text error
|
||||||
ignoreErrors: [
|
ignoreErrors: [
|
||||||
"ResizeObserver loop limit exceeded",
|
"ResizeObserver loop limit exceeded",
|
||||||
"QuotaExceededError",
|
"QuotaExceededError",
|
||||||
"XDR encoding failure",
|
"XDR encoding failure",
|
||||||
|
"Assertion failed: Input argument is not an HTMLInputElement",
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
Binary file not shown.
File diff suppressed because one or more lines are too long
@ -9,6 +9,7 @@ import MapDataContext from "../contexts/MapDataContext";
|
|||||||
|
|
||||||
import { isEmpty } from "../helpers/shared";
|
import { isEmpty } from "../helpers/shared";
|
||||||
import { getMapDefaultInset } from "../helpers/map";
|
import { getMapDefaultInset } from "../helpers/map";
|
||||||
|
import useResponsiveLayout from "../helpers/useResponsiveLayout";
|
||||||
|
|
||||||
function EditMapModal({ isOpen, onDone, map, mapState }) {
|
function EditMapModal({ isOpen, onDone, map, mapState }) {
|
||||||
const { updateMap, updateMapState } = useContext(MapDataContext);
|
const { updateMap, updateMapState } = useContext(MapDataContext);
|
||||||
@ -98,11 +99,13 @@ function EditMapModal({ isOpen, onDone, map, mapState }) {
|
|||||||
|
|
||||||
const [showMoreSettings, setShowMoreSettings] = useState(true);
|
const [showMoreSettings, setShowMoreSettings] = useState(true);
|
||||||
|
|
||||||
|
const layout = useResponsiveLayout();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onRequestClose={handleClose}
|
onRequestClose={handleClose}
|
||||||
style={{ maxWidth: "542px", width: "calc(100% - 16px)" }}
|
style={{ maxWidth: layout.modalSize, width: "calc(100% - 16px)" }}
|
||||||
>
|
>
|
||||||
<Flex
|
<Flex
|
||||||
sx={{
|
sx={{
|
||||||
|
@ -8,6 +8,7 @@ import TokenPreview from "../components/token/TokenPreview";
|
|||||||
import TokenDataContext from "../contexts/TokenDataContext";
|
import TokenDataContext from "../contexts/TokenDataContext";
|
||||||
|
|
||||||
import { isEmpty } from "../helpers/shared";
|
import { isEmpty } from "../helpers/shared";
|
||||||
|
import useResponsiveLayout from "../helpers/useResponsiveLayout";
|
||||||
|
|
||||||
function EditTokenModal({ isOpen, onDone, token }) {
|
function EditTokenModal({ isOpen, onDone, token }) {
|
||||||
const { updateToken } = useContext(TokenDataContext);
|
const { updateToken } = useContext(TokenDataContext);
|
||||||
@ -46,12 +47,14 @@ function EditTokenModal({ isOpen, onDone, token }) {
|
|||||||
...tokenSettingChanges,
|
...tokenSettingChanges,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const layout = useResponsiveLayout();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onRequestClose={handleClose}
|
onRequestClose={handleClose}
|
||||||
style={{
|
style={{
|
||||||
maxWidth: "542px",
|
maxWidth: layout.modalSize,
|
||||||
width: "calc(100% - 16px)",
|
width: "calc(100% - 16px)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user