Merge pull request #22 from mitchemmc/release/v1.6.0

Release/v1.6.0
This commit is contained in:
Mitchell McCaffrey 2020-10-17 21:58:09 +11:00 committed by GitHub
commit 7bee253773
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
97 changed files with 4174 additions and 1533 deletions

2
.env
View File

@ -1,3 +1,5 @@
REACT_APP_BROKER_URL=http://localhost:9000
REACT_APP_ICE_SERVERS_URL=http://localhost:9000/iceservers
REACT_APP_STRIPE_API_KEY=pk_test_8M3NHrF1eI2b84ubF4F8rSTe0095R3f0My
REACT_APP_STRIPE_URL=http://localhost:9000
REACT_APP_VERSION=$npm_package_version

View File

@ -1,3 +1,5 @@
REACT_APP_BROKER_URL=https://connect.owlbear.rodeo
REACT_APP_ICE_SERVERS_URL=https://connect.owlbear.rodeo/iceservers
REACT_APP_STRIPE_API_KEY=pk_live_MJjzi5djj524Y7h3fL5PNh4e00a852XD51
REACT_APP_STRIPE_URL=https://payment.owlbear.rodeo
REACT_APP_VERSION=$npm_package_version

View File

@ -1,12 +1,13 @@
{
"name": "owlbear-rodeo",
"version": "1.5.2",
"version": "1.6.0",
"private": true,
"dependencies": {
"@babylonjs/core": "^4.1.0",
"@babylonjs/loaders": "^4.1.0",
"@msgpack/msgpack": "^1.12.1",
"@stripe/stripe-js": "^1.3.2",
"@tensorflow/tfjs": "^2.6.0",
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2",
@ -14,6 +15,7 @@
"case": "^1.6.3",
"dexie": "^2.0.4",
"fake-indexeddb": "^3.0.0",
"fuse.js": "^6.4.1",
"interactjs": "^1.9.7",
"konva": "^6.0.0",
"lodash.get": "^4.4.2",
@ -31,6 +33,7 @@
"react-router-dom": "^5.1.2",
"react-router-hash-link": "^1.2.2",
"react-scripts": "3.4.0",
"react-select": "^3.1.0",
"react-spring": "^8.0.27",
"react-use-gesture": "^7.0.15",
"shortid": "^2.2.15",

View File

@ -9,13 +9,15 @@ import About from "./routes/About";
import FAQ from "./routes/FAQ";
import ReleaseNotes from "./routes/ReleaseNotes";
import HowTo from "./routes/HowTo";
import Donate from "./routes/Donate";
import { AuthProvider } from "./contexts/AuthContext";
import { DatabaseProvider } from "./contexts/DatabaseContext";
import { MapDataProvider } from "./contexts/MapDataContext";
import { TokenDataProvider } from "./contexts/TokenDataContext";
import { MapLoadingProvider } from "./contexts/MapLoadingContext";
import { SettingsProvider } from "./contexts/SettingsContext.js";
import { SettingsProvider } from "./contexts/SettingsContext";
import { KeyboardProvider } from "./contexts/KeyboardContext";
function App() {
return (
@ -23,34 +25,40 @@ function App() {
<DatabaseProvider>
<SettingsProvider>
<AuthProvider>
<Router>
<Switch>
<Route path="/howTo">
<HowTo />
</Route>
<Route path="/releaseNotes">
<ReleaseNotes />
</Route>
<Route path="/about">
<About />
</Route>
<Route path="/faq">
<FAQ />
</Route>
<Route path="/game/:id">
<MapLoadingProvider>
<MapDataProvider>
<TokenDataProvider>
<Game />
</TokenDataProvider>
</MapDataProvider>
</MapLoadingProvider>
</Route>
<Route path="/">
<Home />
</Route>
</Switch>
</Router>
<KeyboardProvider>
<Router>
<Switch>
<Route path="/donate">
<Donate />
</Route>
{/* Legacy support camel case routes */}
<Route path={["/howTo", "/how-to"]}>
<HowTo />
</Route>
<Route path={["/releaseNotes", "/release-notes"]}>
<ReleaseNotes />
</Route>
<Route path="/about">
<About />
</Route>
<Route path="/faq">
<FAQ />
</Route>
<Route path="/game/:id">
<MapLoadingProvider>
<MapDataProvider>
<TokenDataProvider>
<Game />
</TokenDataProvider>
</MapDataProvider>
</MapLoadingProvider>
</Route>
<Route path="/">
<Home />
</Route>
</Switch>
</Router>
</KeyboardProvider>
</AuthProvider>
</SettingsProvider>
</DatabaseProvider>

View File

@ -0,0 +1,69 @@
import React from "react";
import { Flex, IconButton } from "theme-ui";
import AddIcon from "../icons/AddIcon";
import SelectMultipleIcon from "../icons/SelectMultipleIcon";
import SelectSingleIcon from "../icons/SelectSingleIcon";
import Search from "./Search";
import RadioIconButton from "./RadioIconButton";
function FilterBar({
onFocus,
search,
onSearchChange,
selectMode,
onSelectModeChange,
onAdd,
addTitle,
}) {
return (
<Flex
bg="muted"
sx={{
border: "1px solid",
borderColor: "text",
borderRadius: "4px",
alignItems: "center",
":focus-within": {
outline: "1px auto",
outlineColor: "primary",
outlineOffset: "0px",
},
}}
onFocus={onFocus}
>
<Search value={search} onChange={onSearchChange} />
<Flex
mr={1}
px={1}
sx={{
borderRight: "1px solid",
borderColor: "text",
height: "36px",
alignItems: "center",
}}
>
<RadioIconButton
title="Select Single"
onClick={() => onSelectModeChange("single")}
isSelected={selectMode === "single"}
>
<SelectSingleIcon />
</RadioIconButton>
<RadioIconButton
title="Select Multiple"
onClick={() => onSelectModeChange("multiple")}
isSelected={selectMode === "multiple" || selectMode === "range"}
>
<SelectMultipleIcon />
</RadioIconButton>
</Flex>
<IconButton onClick={onAdd} aria-label={addTitle} title={addTitle} mr={1}>
<AddIcon />
</IconButton>
</Flex>
);
}
export default FilterBar;

View File

@ -24,10 +24,10 @@ function Footer() {
<Link m={2} to="/faq" variant="footer">
FAQ
</Link>
<Link m={2} to="/releaseNotes" variant="footer">
<Link m={2} to="/release-notes" variant="footer">
Release Notes
</Link>
<Link m={2} to="/howTo" variant="footer">
<Link m={2} to="/how-to" variant="footer">
How To
</Link>
</Flex>

72
src/components/Grid.js Normal file
View File

@ -0,0 +1,72 @@
import React from "react";
import { Line, Group } from "react-konva";
import { getStrokeWidth } from "../helpers/drawing";
function Grid({ gridX, gridY, gridInset, strokeWidth, width, height, stroke }) {
if (!gridX || !gridY) {
return null;
}
const gridSizeNormalized = {
x: (gridInset.bottomRight.x - gridInset.topLeft.x) / gridX,
y: (gridInset.bottomRight.y - gridInset.topLeft.y) / gridY,
};
const insetWidth = (gridInset.bottomRight.x - gridInset.topLeft.x) * width;
const insetHeight = (gridInset.bottomRight.y - gridInset.topLeft.y) * height;
const lineSpacingX = insetWidth / gridX;
const lineSpacingY = insetHeight / gridY;
const offsetX = gridInset.topLeft.x * width * -1;
const offsetY = gridInset.topLeft.y * height * -1;
const lines = [];
for (let x = 1; x < gridX; x++) {
lines.push(
<Line
key={`grid_x_${x}`}
points={[x * lineSpacingX, 0, x * lineSpacingX, insetHeight]}
stroke={stroke}
strokeWidth={getStrokeWidth(
strokeWidth,
gridSizeNormalized,
width,
height
)}
opacity={0.5}
offsetX={offsetX}
offsetY={offsetY}
/>
);
}
for (let y = 1; y < gridY; y++) {
lines.push(
<Line
key={`grid_y_${y}`}
points={[0, y * lineSpacingY, insetWidth, y * lineSpacingY]}
stroke={stroke}
strokeWidth={getStrokeWidth(
strokeWidth,
gridSizeNormalized,
width,
height
)}
opacity={0.5}
offsetX={offsetX}
offsetY={offsetY}
/>
);
}
return <Group>{lines}</Group>;
}
Grid.defaultProps = {
strokeWidth: 0.1,
gridInset: { topLeft: { x: 0, y: 0 }, bottomRight: { x: 1, y: 1 } },
stroke: "white",
};
export default Grid;

39
src/components/Search.js Normal file
View File

@ -0,0 +1,39 @@
import React from "react";
import { Box, Input } from "theme-ui";
import SearchIcon from "../icons/SearchIcon";
function Search(props) {
return (
<Box sx={{ position: "relative", flexGrow: 1 }}>
<Input
sx={{
borderRadius: "0",
border: "none",
borderRight: "1px solid",
":focus": {
outline: "none",
},
paddingRight: "36px",
}}
placeholder="Search"
{...props}
/>
<Box
sx={{
position: "absolute",
right: "8px",
top: "50%",
transform: "translateY(-50%)",
height: "24px",
width: "24px",
pointerEvents: "none",
}}
>
<SearchIcon />
</Box>
</Box>
);
}
export default Search;

71
src/components/Select.js Normal file
View File

@ -0,0 +1,71 @@
import React from "react";
import ReactSelect from "react-select";
import Creatable from "react-select/creatable";
import { useThemeUI } from "theme-ui";
function Select({ creatable, ...props }) {
const { theme } = useThemeUI();
const Component = creatable ? Creatable : ReactSelect;
return (
<Component
styles={{
menu: (provided, state) => ({
...provided,
backgroundColor: theme.colors.background,
color: theme.colors.text,
borderRadius: "4px",
borderColor: theme.colors.gray,
borderStyle: "solid",
borderWidth: "1px",
fontFamily: theme.fonts.body2,
opacity: state.isDisabled ? 0.5 : 1,
}),
control: (provided, state) => ({
...provided,
backgroundColor: theme.colors.background,
color: theme.colors.text,
borderColor: theme.colors.text,
opacity: state.isDisabled ? 0.5 : 1,
}),
singleValue: (provided) => ({
...provided,
color: theme.colors.text,
fontFamily: theme.fonts.body2,
}),
option: (provided, state) => ({
...provided,
color: theme.colors.text,
opacity: state.isDisabled ? 0.5 : 1,
}),
dropdownIndicator: (provided, state) => ({
...provided,
color: theme.colors.text,
":hover": {
color: state.isDisabled
? theme.colors.disabled
: theme.colors.primary,
},
}),
input: (provided, state) => ({
...provided,
color: theme.colors.text,
opacity: state.isDisabled ? 0.5 : 1,
}),
}}
theme={(t) => ({
...t,
colors: {
...t.colors,
primary: theme.colors.primary,
primary50: theme.colors.secondary,
primary25: theme.colors.highlight,
},
})}
{...props}
/>
);
}
export default Select;

135
src/components/Tile.js Normal file
View File

@ -0,0 +1,135 @@
import React from "react";
import { Flex, Image as UIImage, IconButton, Box, Text, Badge } from "theme-ui";
import EditTileIcon from "../icons/EditTileIcon";
function Tile({
src,
title,
isSelected,
onSelect,
onEdit,
onDoubleClick,
large,
canEdit,
badges,
editTitle,
}) {
return (
<Flex
sx={{
position: "relative",
width: large ? "48%" : "32%",
height: "0",
paddingTop: large ? "48%" : "32%",
borderRadius: "4px",
justifyContent: "center",
alignItems: "center",
cursor: "pointer",
overflow: "hidden",
userSelect: "none",
}}
my={1}
mx={`${large ? 1 : 2 / 3}%`}
bg="muted"
onClick={(e) => {
e.stopPropagation();
onSelect();
}}
onDoubleClick={(e) => {
if (canEdit) {
onDoubleClick(e);
}
}}
>
<UIImage
sx={{
width: "100%",
height: "100%",
objectFit: "contain",
position: "absolute",
top: 0,
left: 0,
}}
src={src}
/>
<Flex
sx={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
background:
"linear-gradient(to bottom, rgba(0,0,0,0) 70%, rgba(0,0,0,0.65) 100%);",
alignItems: "flex-end",
justifyContent: "center",
}}
p={2}
>
<Text
as="p"
variant="heading"
color="hsl(210, 50%, 96%)"
sx={{ textAlign: "center" }}
>
{title}
</Text>
</Flex>
<Box
sx={{
width: "100%",
height: "100%",
position: "absolute",
top: 0,
left: 0,
borderColor: "primary",
borderStyle: isSelected ? "solid" : "none",
borderWidth: "4px",
pointerEvents: "none",
borderRadius: "4px",
}}
/>
<Box sx={{ position: "absolute", top: 0, left: 0 }}>
{badges.map((badge, i) => (
<Badge m={2} key={i} bg="overlay">
{badge}
</Badge>
))}
</Box>
{canEdit && (
<Box sx={{ position: "absolute", top: 0, right: 0 }}>
<IconButton
aria-label={editTitle}
title={editTitle}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onEdit();
}}
bg="overlay"
sx={{ borderRadius: "50%" }}
m={2}
>
<EditTileIcon />
</IconButton>
</Box>
)}
</Flex>
);
}
Tile.defaultProps = {
src: "",
title: "",
isSelected: false,
onSelect: () => {},
onEdit: () => {},
onDoubleClick: () => {},
large: false,
canEdit: false,
badges: [],
editTitle: "Edit",
};
export default Tile;

View File

@ -1,74 +1,17 @@
import React from "react";
import { Flex, Image, Text, Box } from "theme-ui";
import Tile from "../Tile";
function DiceTile({ dice, isSelected, onDiceSelect, onDone, large }) {
return (
<Flex
onClick={() => onDiceSelect(dice)}
sx={{
position: "relative",
width: large ? "48%" : "32%",
height: "0",
paddingTop: large ? "48%" : "32%",
borderRadius: "4px",
justifyContent: "center",
alignItems: "center",
cursor: "pointer",
}}
my={1}
mx={`${large ? 1 : 2 / 3}%`}
bg="muted"
<Tile
src={dice.preview}
title={dice.name}
isSelected={isSelected}
onSelect={() => onDiceSelect(dice)}
onDoubleClick={() => onDone(dice)}
>
<Image
sx={{
width: "100%",
height: "100%",
objectFit: "contain",
position: "absolute",
top: 0,
left: 0,
}}
src={dice.preview}
/>
<Flex
sx={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
background:
"linear-gradient(to bottom, rgba(0,0,0,0) 70%, rgba(0,0,0,0.65) 100%);",
alignItems: "flex-end",
justifyContent: "center",
}}
p={2}
>
<Text
as="p"
variant="heading"
color="hsl(210, 50%, 96%)"
sx={{ textAlign: "center" }}
>
{dice.name}
</Text>
</Flex>
<Box
sx={{
width: "100%",
height: "100%",
position: "absolute",
top: 0,
left: 0,
borderColor: "primary",
borderStyle: isSelected ? "solid" : "none",
borderWidth: "4px",
pointerEvents: "none",
borderRadius: "4px",
}}
/>
</Flex>
large={large}
/>
);
}

View File

@ -40,11 +40,12 @@ function Map({
}) {
const { tokensById } = useContext(TokenDataContext);
const gridX = map && map.gridX;
const gridY = map && map.gridY;
const gridX = map && map.grid.size.x;
const gridY = map && map.grid.size.y;
const inset = map && map.grid.inset;
const gridSizeNormalized = {
x: gridX ? 1 / gridX : 0,
y: gridY ? 1 / gridY : 0,
x: gridX ? (inset.bottomRight.x - inset.topLeft.x) / gridX : 0,
y: gridY ? (inset.bottomRight.y - inset.topLeft.y) / gridY : 0,
};
const tokenSizePercent = gridSizeNormalized.x;
@ -330,9 +331,7 @@ function Map({
/>
);
const mapGrid = map && map.showGrid && (
<MapGrid map={map} gridSize={gridSizeNormalized} />
);
const mapGrid = map && map.showGrid && <MapGrid map={map} />;
const mapMeasure = (
<MapMeasure

View File

@ -1,7 +1,7 @@
import React, { useState, Fragment } from "react";
import { IconButton, Flex, Box } from "theme-ui";
import RadioIconButton from "./controls/RadioIconButton";
import RadioIconButton from "../RadioIconButton";
import Divider from "../Divider";
import SelectMapButton from "./SelectMapButton";
@ -41,30 +41,30 @@ function MapContols({
pan: {
id: "pan",
icon: <PanToolIcon />,
title: "Pan Tool",
title: "Pan Tool (W)",
},
fog: {
id: "fog",
icon: <FogToolIcon />,
title: "Fog Tool",
title: "Fog Tool (F)",
SettingsComponent: FogToolSettings,
},
drawing: {
id: "drawing",
icon: <BrushToolIcon />,
title: "Drawing Tool",
title: "Drawing Tool (D)",
SettingsComponent: DrawingToolSettings,
},
measure: {
id: "measure",
icon: <MeasureToolIcon />,
title: "Measure Tool",
title: "Measure Tool (M)",
SettingsComponent: MeasureToolSettings,
},
pointer: {
id: "pointer",
icon: <PointerToolIcon />,
title: "Pointer Tool",
title: "Pointer Tool (Q)",
},
};
const tools = ["pan", "fog", "drawing", "measure", "pointer"];

View File

@ -0,0 +1,206 @@
import React, { useState, useRef, useEffect, useContext } from "react";
import { Box, IconButton } from "theme-ui";
import { Stage, Layer, Image } from "react-konva";
import ReactResizeDetector from "react-resize-detector";
import useMapImage from "../../helpers/useMapImage";
import usePreventOverscroll from "../../helpers/usePreventOverscroll";
import useStageInteraction from "../../helpers/useStageInteraction";
import { getMapDefaultInset } from "../../helpers/map";
import { MapInteractionProvider } from "../../contexts/MapInteractionContext";
import KeyboardContext from "../../contexts/KeyboardContext";
import ResetMapIcon from "../../icons/ResetMapIcon";
import GridOnIcon from "../../icons/GridOnIcon";
import GridOffIcon from "../../icons/GridOffIcon";
import MapGrid from "./MapGrid";
import MapGridEditor from "./MapGridEditor";
function MapEditor({ map, onSettingsChange }) {
const [mapImageSource] = useMapImage(map);
const [stageWidth, setStageWidth] = useState(1);
const [stageHeight, setStageHeight] = useState(1);
const [stageScale, setStageScale] = useState(1);
const stageRatio = stageWidth / stageHeight;
const mapRatio = map ? map.width / map.height : 1;
let mapWidth;
let mapHeight;
if (stageRatio > mapRatio) {
mapWidth = map ? stageHeight / (map.height / map.width) : stageWidth;
mapHeight = stageHeight;
} else {
mapWidth = stageWidth;
mapHeight = map ? stageWidth * (map.height / map.width) : stageHeight;
}
const defaultInset = getMapDefaultInset(
map.width,
map.height,
map.grid.size.x,
map.grid.size.y
);
const stageTranslateRef = useRef({ x: 0, y: 0 });
const mapLayerRef = useRef();
const [preventMapInteraction, setPreventMapInteraction] = useState(false);
function handleResize(width, height) {
setStageWidth(width);
setStageHeight(height);
}
// Reset map translate and scale
useEffect(() => {
const layer = mapLayerRef.current;
const containerRect = containerRef.current.getBoundingClientRect();
if (layer) {
let newTranslate;
if (stageRatio > mapRatio) {
newTranslate = {
x: -(mapWidth - containerRect.width) / 2,
y: 0,
};
} else {
newTranslate = {
x: 0,
y: -(mapHeight - containerRect.height) / 2,
};
}
layer.x(newTranslate.x);
layer.y(newTranslate.y);
layer.draw();
stageTranslateRef.current = newTranslate;
setStageScale(1);
}
}, [map.id, mapWidth, mapHeight, stageRatio, mapRatio]);
const bind = useStageInteraction(
mapLayerRef.current,
stageScale,
setStageScale,
stageTranslateRef,
"pan",
preventMapInteraction
);
const containerRef = useRef();
usePreventOverscroll(containerRef);
function handleGridChange(inset) {
onSettingsChange("grid", {
...map.grid,
inset,
});
}
function handleMapReset() {
onSettingsChange("grid", {
...map.grid,
inset: defaultInset,
});
}
const [showGridControls, setShowGridControls] = useState(true);
const mapInteraction = {
stageScale,
stageWidth,
stageHeight,
setPreventMapInteraction,
mapWidth,
mapHeight,
};
// Get keyboard context to pass to Konva
const keyboardValue = useContext(KeyboardContext);
const canEditGrid = map.type !== "default";
const gridChanged =
map.grid.inset.topLeft.x !== defaultInset.topLeft.x ||
map.grid.inset.topLeft.y !== defaultInset.topLeft.y ||
map.grid.inset.bottomRight.x !== defaultInset.bottomRight.x ||
map.grid.inset.bottomRight.y !== defaultInset.bottomRight.y;
return (
<Box
sx={{
width: "100%",
height: "300px",
cursor: "move",
touchAction: "none",
outline: "none",
position: "relative",
}}
bg="muted"
ref={containerRef}
{...bind()}
>
<ReactResizeDetector handleWidth handleHeight onResize={handleResize}>
<Stage
width={stageWidth}
height={stageHeight}
scale={{ x: stageScale, y: stageScale }}
x={stageWidth / 2}
y={stageHeight / 2}
offset={{ x: stageWidth / 2, y: stageHeight / 2 }}
>
<Layer ref={mapLayerRef}>
<Image image={mapImageSource} width={mapWidth} height={mapHeight} />
<KeyboardContext.Provider value={keyboardValue}>
<MapInteractionProvider value={mapInteraction}>
{showGridControls && canEditGrid && (
<MapGrid map={map} strokeWidth={0.5} />
)}
{showGridControls && canEditGrid && (
<MapGridEditor map={map} onGridChange={handleGridChange} />
)}
</MapInteractionProvider>
</KeyboardContext.Provider>
</Layer>
</Stage>
</ReactResizeDetector>
{gridChanged && (
<IconButton
title="Reset Grid"
aria-label="Reset Grid"
onClick={handleMapReset}
bg="overlay"
sx={{ borderRadius: "50%", position: "absolute", bottom: 0, left: 0 }}
m={2}
>
<ResetMapIcon />
</IconButton>
)}
{canEditGrid && (
<IconButton
title={showGridControls ? "Hide Grid Controls" : "Show Grid Controls"}
aria-label={
showGridControls ? "Hide Grid Controls" : "Show Grid Controls"
}
onClick={() => setShowGridControls(!showGridControls)}
bg="overlay"
sx={{
borderRadius: "50%",
position: "absolute",
bottom: 0,
right: 0,
}}
m={2}
p="6px"
>
{showGridControls ? <GridOnIcon /> : <GridOffIcon />}
</IconButton>
)}
</Box>
);
}
export default MapEditor;

View File

@ -20,6 +20,7 @@ import {
getRelativePointerPositionNormalized,
Tick,
} from "../../helpers/konva";
import useKeyboard from "../../helpers/useKeyboard";
function MapFog({
map,
@ -248,44 +249,37 @@ function MapFog({
}, [toolSettings, drawingShape, onShapeSubtract, onShapeAdd]);
// Add keyboard shortcuts
useEffect(() => {
function handleKeyDown({ key }) {
if (key === "Enter" && toolSettings.type === "polygon" && drawingShape) {
finishDrawingPolygon();
}
if (key === "Escape" && drawingShape) {
setDrawingShape(null);
}
if (key === "Alt" && drawingShape) {
updateShapeColor();
}
function handleKeyDown({ key }) {
if (key === "Enter" && toolSettings.type === "polygon" && drawingShape) {
finishDrawingPolygon();
}
if (key === "Escape" && drawingShape) {
setDrawingShape(null);
}
if (key === "Alt" && drawingShape) {
updateShapeColor();
}
}
function handleKeyUp({ key }) {
if (key === "Alt" && drawingShape) {
updateShapeColor();
function handleKeyUp({ key }) {
if (key === "Alt" && drawingShape) {
updateShapeColor();
}
}
function updateShapeColor() {
setDrawingShape((prevShape) => {
if (!prevShape) {
return;
}
}
return {
...prevShape,
color: toolSettings.useFogSubtract ? "black" : "red",
};
});
}
function updateShapeColor() {
setDrawingShape((prevShape) => {
if (!prevShape) {
return;
}
return {
...prevShape,
color: toolSettings.useFogSubtract ? "black" : "red",
};
});
}
interactionEmitter.on("keyDown", handleKeyDown);
interactionEmitter.on("keyUp", handleKeyUp);
return () => {
interactionEmitter.off("keyDown", handleKeyDown);
interactionEmitter.off("keyUp", handleKeyUp);
};
}, [finishDrawingPolygon, interactionEmitter, drawingShape, toolSettings]);
useKeyboard(handleKeyDown, handleKeyUp);
function handleShapeOver(shape, isDown) {
if (shouldHover && isDown) {

View File

@ -1,5 +1,4 @@
import React, { useContext, useEffect, useState } from "react";
import { Line, Group } from "react-konva";
import useImage from "use-image";
import MapInteractionContext from "../../contexts/MapInteractionContext";
@ -7,20 +6,23 @@ import MapInteractionContext from "../../contexts/MapInteractionContext";
import useDataSource from "../../helpers/useDataSource";
import { mapSources as defaultMapSources } from "../../maps";
import { getStrokeWidth } from "../../helpers/drawing";
import { getImageLightness } from "../../helpers/image";
function MapGrid({ map, gridSize }) {
const mapSource = useDataSource(map, defaultMapSources);
const [mapImage, mapLoadingStatus] = useImage(mapSource);
const gridX = map && map.gridX;
const gridY = map && map.gridY;
import Grid from "../Grid";
function MapGrid({ map, strokeWidth }) {
const { mapWidth, mapHeight } = useContext(MapInteractionContext);
const lineSpacingX = mapWidth / gridX;
const lineSpacingY = mapHeight / gridY;
let mapSourceMap = map;
// Use lowest resolution for grid lightness
if (map && map.type === "file" && map.resolutions) {
const resolutionArray = Object.keys(map.resolutions);
if (resolutionArray.length > 0) {
mapSourceMap = map.resolutions[resolutionArray[0]];
}
}
const mapSource = useDataSource(mapSourceMap, defaultMapSources);
const [mapImage, mapLoadingStatus] = useImage(mapSource);
const [isImageLight, setIsImageLight] = useState(true);
@ -31,31 +33,26 @@ function MapGrid({ map, gridSize }) {
}
}, [mapImage, mapLoadingStatus]);
const lines = [];
for (let x = 1; x < gridX; x++) {
lines.push(
<Line
key={`grid_x_${x}`}
points={[x * lineSpacingX, 0, x * lineSpacingX, mapHeight]}
stroke={isImageLight ? "black" : "white"}
strokeWidth={getStrokeWidth(0.1, gridSize, mapWidth, mapHeight)}
opacity={0.5}
/>
);
}
for (let y = 1; y < gridY; y++) {
lines.push(
<Line
key={`grid_y_${y}`}
points={[0, y * lineSpacingY, mapWidth, y * lineSpacingY]}
stroke={isImageLight ? "black" : "white"}
strokeWidth={getStrokeWidth(0.1, gridSize, mapWidth, mapHeight)}
opacity={0.5}
/>
);
}
const gridX = map && map.grid.size.x;
const gridY = map && map.grid.size.y;
return <Group>{lines}</Group>;
const gridInset = map && map.grid.inset;
return (
<Grid
gridX={gridX}
gridY={gridY}
gridInset={gridInset}
strokeWidth={strokeWidth}
width={mapWidth}
height={mapHeight}
stroke={isImageLight ? "black" : "white"}
/>
);
}
MapGrid.defaultProps = {
strokeWidth: 0.1,
};
export default MapGrid;

View File

@ -0,0 +1,256 @@
import React, { useContext, useRef } from "react";
import { Group, Circle, Rect } from "react-konva";
import MapInteractionContext from "../../contexts/MapInteractionContext";
import * as Vector2 from "../../helpers/vector2";
import useKeyboard from "../../helpers/useKeyboard";
function MapGridEditor({ map, onGridChange }) {
const {
mapWidth,
mapHeight,
stageScale,
setPreventMapInteraction,
} = useContext(MapInteractionContext);
const mapSize = { x: mapWidth, y: mapHeight };
function getHandlePositions() {
const topLeft = Vector2.multiply(map.grid.inset.topLeft, mapSize);
const bottomRight = Vector2.multiply(map.grid.inset.bottomRight, mapSize);
const size = Vector2.subtract(bottomRight, topLeft);
const offset = Vector2.multiply(topLeft, -1);
return {
topLeft,
topRight: { x: bottomRight.x, y: topLeft.y },
bottomRight,
bottomLeft: { x: topLeft.x, y: bottomRight.y },
size,
offset,
};
}
const handlePositions = getHandlePositions();
const handlePreviousPositionRef = useRef();
function handleScaleCircleDragStart(event) {
const handle = event.target;
const position = getHandleNormalizedPosition(handle);
handlePreviousPositionRef.current = position;
}
function handleScaleCircleDragMove(event) {
const handle = event.target;
onGridChange(getHandleInset(handle));
handlePreviousPositionRef.current = getHandleNormalizedPosition(handle);
}
function handleScaleCircleDragEnd(event) {
onGridChange(getHandleInset(event.target));
setPreventMapInteraction(false);
}
function handleInteractivePointerDown() {
setPreventMapInteraction(true);
}
function handleInteractivePointerUp() {
setPreventMapInteraction(false);
}
function getHandleInset(handle) {
const name = handle.name();
// Find distance and direction of dragging
const previousPosition = handlePreviousPositionRef.current;
const position = getHandleNormalizedPosition(handle);
const distance = Vector2.distance(previousPosition, position, "euclidean");
const direction = Vector2.normalize(
Vector2.subtract(position, previousPosition)
);
const inset = map.grid.inset;
if (direction.x === 0 && direction.y === 0) {
return inset;
}
// Scale the grid direction by the distance dragged and the
// dot product between the drag direction and the grid direction
// This drags the handle while keeping the aspect ratio
if (name === "topLeft") {
// Top left to bottom right
const gridDirection = Vector2.normalize(
Vector2.subtract(inset.topLeft, inset.bottomRight)
);
const dot = Vector2.dot(direction, gridDirection);
const offset = Vector2.multiply(gridDirection, distance * dot);
const newPosition = Vector2.add(previousPosition, offset);
return {
topLeft: newPosition,
bottomRight: inset.bottomRight,
};
} else if (name === "topRight") {
// Top right to bottom left
const gridDirection = Vector2.normalize(
Vector2.subtract(
{ x: inset.bottomRight.x, y: inset.topLeft.y },
{ x: inset.topLeft.x, y: inset.bottomRight.y }
)
);
const dot = Vector2.dot(direction, gridDirection);
const offset = Vector2.multiply(gridDirection, distance * dot);
const newPosition = Vector2.add(previousPosition, offset);
return {
topLeft: { x: inset.topLeft.x, y: newPosition.y },
bottomRight: { x: newPosition.x, y: inset.bottomRight.y },
};
} else if (name === "bottomRight") {
// Bottom right to top left
const gridDirection = Vector2.normalize(
Vector2.subtract(inset.bottomRight, inset.topLeft)
);
const dot = Vector2.dot(direction, gridDirection);
const offset = Vector2.multiply(gridDirection, distance * dot);
const newPosition = Vector2.add(previousPosition, offset);
return {
topLeft: inset.topLeft,
bottomRight: newPosition,
};
} else if (name === "bottomLeft") {
// Bottom left to top right
const gridDirection = Vector2.normalize(
Vector2.subtract(
{ x: inset.topLeft.x, y: inset.bottomRight.y },
{ x: inset.bottomRight.x, y: inset.topLeft.y }
)
);
const dot = Vector2.dot(direction, gridDirection);
const offset = Vector2.multiply(gridDirection, distance * dot);
const newPosition = Vector2.add(previousPosition, offset);
return {
topLeft: { x: newPosition.x, y: inset.topLeft.y },
bottomRight: { x: inset.bottomRight.x, y: newPosition.y },
};
} else if (name === "center") {
const offset = Vector2.subtract(position, previousPosition);
return {
topLeft: Vector2.add(inset.topLeft, offset),
bottomRight: Vector2.add(inset.bottomRight, offset),
};
} else {
return inset;
}
}
function nudgeGrid(direction, scale) {
const inset = map.grid.inset;
const gridSizeNormalized = Vector2.divide(
Vector2.subtract(inset.bottomRight, inset.topLeft),
map.grid.size
);
const offset = Vector2.multiply(
Vector2.multiply(direction, gridSizeNormalized),
Math.min(scale / (stageScale * stageScale), 1)
);
onGridChange({
topLeft: Vector2.add(inset.topLeft, offset),
bottomRight: Vector2.add(inset.bottomRight, offset),
});
}
function handleKeyDown({ key, shiftKey }) {
const nudgeAmount = shiftKey ? 2 : 0.5;
if (key === "ArrowUp") {
nudgeGrid({ x: 0, y: -1 }, nudgeAmount);
}
if (key === "ArrowLeft") {
nudgeGrid({ x: -1, y: 0 }, nudgeAmount);
}
if (key === "ArrowRight") {
nudgeGrid({ x: 1, y: 0 }, nudgeAmount);
}
if (key === "ArrowDown") {
nudgeGrid({ x: 0, y: 1 }, nudgeAmount);
}
}
useKeyboard(handleKeyDown);
function getHandleNormalizedPosition(handle) {
return Vector2.divide({ x: handle.x(), y: handle.y() }, mapSize);
}
const editCircleRadius = Math.max(
(Math.min(mapWidth, mapHeight) / 30) * Math.max(1 / stageScale, 1),
1
);
const editCircleProps = {
radius: editCircleRadius,
fill: "rgba(0, 0, 0, 0.5)",
stroke: "white",
strokeWidth: editCircleRadius / 5,
draggable: true,
onDragStart: handleScaleCircleDragStart,
onDragMove: handleScaleCircleDragMove,
onDragEnd: handleScaleCircleDragEnd,
onMouseDown: handleInteractivePointerDown,
onMouseUp: handleInteractivePointerUp,
onTouchStart: handleInteractivePointerDown,
onTouchEnd: handleInteractivePointerUp,
};
const editRectProps = {
fill: "transparent",
stroke: "rgba(255, 255, 255, 0.75)",
strokeWidth: editCircleRadius / 10,
};
return (
<Group>
<Rect
width={handlePositions.size.x}
height={handlePositions.size.y}
offset={handlePositions.offset}
{...editRectProps}
/>
<Circle
x={handlePositions.topLeft.x}
y={handlePositions.topLeft.y}
name="topLeft"
{...editCircleProps}
/>
<Circle
x={handlePositions.topRight.x}
y={handlePositions.topRight.y}
name="topRight"
{...editCircleProps}
/>
<Circle
x={handlePositions.bottomRight.x}
y={handlePositions.bottomRight.y}
name="bottomRight"
{...editCircleProps}
/>
<Circle
x={handlePositions.bottomLeft.x}
y={handlePositions.bottomLeft.y}
name="bottomLeft"
{...editCircleProps}
/>
<Circle
x={(handlePositions.topLeft.x + handlePositions.bottomRight.x) / 2}
y={(handlePositions.topLeft.y + handlePositions.bottomRight.y) / 2}
name="center"
{...editCircleProps}
radius={editCircleRadius / 1.5}
/>
</Group>
);
}
export default MapGridEditor;

View File

@ -1,16 +1,13 @@
import React, { useRef, useEffect, useState, useContext } from "react";
import { Box } from "theme-ui";
import { useGesture } from "react-use-gesture";
import ReactResizeDetector from "react-resize-detector";
import useImage from "use-image";
import { Stage, Layer, Image } from "react-konva";
import { EventEmitter } from "events";
import normalizeWheel from "normalize-wheel";
import useMapImage from "../../helpers/useMapImage";
import usePreventOverscroll from "../../helpers/usePreventOverscroll";
import useDataSource from "../../helpers/useDataSource";
import { mapSources as defaultMapSources } from "../../maps";
import useKeyboard from "../../helpers/useKeyboard";
import useStageInteraction from "../../helpers/useStageInteraction";
import { MapInteractionProvider } from "../../contexts/MapInteractionContext";
import MapStageContext, {
@ -18,11 +15,7 @@ import MapStageContext, {
} from "../../contexts/MapStageContext";
import AuthContext from "../../contexts/AuthContext";
import SettingsContext from "../../contexts/SettingsContext";
const wheelZoomSpeed = -0.001;
const touchZoomSpeed = 0.005;
const minZoom = 0.1;
const maxZoom = 5;
import KeyboardContext from "../../contexts/KeyboardContext";
function MapInteraction({
map,
@ -32,40 +25,17 @@ function MapInteraction({
onSelectedToolChange,
disabledControls,
}) {
let mapSourceMap = map;
if (map && map.type === "file" && map.resolutions) {
// Set to the quality if available
if (map.quality !== "original" && map.resolutions[map.quality]) {
mapSourceMap = map.resolutions[map.quality];
} else if (!map.file) {
// If no file fallback to the highest resolution
for (let resolution in map.resolutions) {
mapSourceMap = map.resolutions[resolution];
}
}
}
const mapSource = useDataSource(mapSourceMap, defaultMapSources);
const [mapSourceImage, mapSourceImageStatus] = useImage(mapSource);
// Create a map source that only updates when the image is fully loaded
const [loadedMapSourceImage, setLoadedMapSourceImage] = useState();
useEffect(() => {
if (mapSourceImageStatus === "loaded") {
setLoadedMapSourceImage(mapSourceImage);
}
}, [mapSourceImage, mapSourceImageStatus]);
const [mapImageSource, mapImageSourceStatus] = useMapImage(map);
// Map loaded taking in to account different resolutions
const [mapLoaded, setMapLoaded] = useState(false);
useEffect(() => {
if (map === null) {
setMapLoaded(false);
}
if (mapSourceImageStatus === "loaded") {
} else if (mapImageSourceStatus === "loaded") {
setMapLoaded(true);
}
}, [mapSourceImageStatus, map]);
}, [mapImageSourceStatus, map]);
const [stageWidth, setStageWidth] = useState(1);
const [stageHeight, setStageHeight] = useState(1);
@ -98,107 +68,6 @@ function MapInteraction({
previousMapIdRef.current = map && map.id;
}, [map]);
const pinchPreviousDistanceRef = useRef();
const pinchPreviousOriginRef = useRef();
const isInteractingWithCanvas = useRef(false);
const previousSelectedToolRef = useRef(selectedToolId);
const [interactionEmitter] = useState(new EventEmitter());
const bind = useGesture({
onWheelStart: ({ event }) => {
isInteractingWithCanvas.current =
event.target === mapLayerRef.current.getCanvas()._canvas;
},
onWheel: ({ event }) => {
event.persist();
const { pixelY } = normalizeWheel(event);
if (preventMapInteraction || !isInteractingWithCanvas.current) {
return;
}
const newScale = Math.min(
Math.max(stageScale + pixelY * wheelZoomSpeed, minZoom),
maxZoom
);
setStageScale(newScale);
},
onPinchStart: () => {
// Change to pan tool when pinching and zooming
previousSelectedToolRef.current = selectedToolId;
onSelectedToolChange("pan");
},
onPinch: ({ da, origin, first }) => {
const [distance] = da;
const [originX, originY] = origin;
if (first) {
pinchPreviousDistanceRef.current = distance;
pinchPreviousOriginRef.current = { x: originX, y: originY };
}
// Apply scale
const distanceDelta = distance - pinchPreviousDistanceRef.current;
const originXDelta = originX - pinchPreviousOriginRef.current.x;
const originYDelta = originY - pinchPreviousOriginRef.current.y;
const newScale = Math.min(
Math.max(stageScale + distanceDelta * touchZoomSpeed, minZoom),
maxZoom
);
setStageScale(newScale);
// Apply translate
const stageTranslate = stageTranslateRef.current;
const layer = mapLayerRef.current;
const newTranslate = {
x: stageTranslate.x + originXDelta / newScale,
y: stageTranslate.y + originYDelta / newScale,
};
layer.x(newTranslate.x);
layer.y(newTranslate.y);
layer.draw();
stageTranslateRef.current = newTranslate;
pinchPreviousDistanceRef.current = distance;
pinchPreviousOriginRef.current = { x: originX, y: originY };
},
onPinchEnd: () => {
onSelectedToolChange(previousSelectedToolRef.current);
},
onDragStart: ({ event }) => {
isInteractingWithCanvas.current =
event.target === mapLayerRef.current.getCanvas()._canvas;
},
onDrag: ({ delta, first, last, pinching }) => {
if (
preventMapInteraction ||
pinching ||
!isInteractingWithCanvas.current
) {
return;
}
const [dx, dy] = delta;
const stageTranslate = stageTranslateRef.current;
const layer = mapLayerRef.current;
if (selectedToolId === "pan") {
const newTranslate = {
x: stageTranslate.x + dx / stageScale,
y: stageTranslate.y + dy / stageScale,
};
layer.x(newTranslate.x);
layer.y(newTranslate.y);
layer.draw();
stageTranslateRef.current = newTranslate;
}
if (first) {
interactionEmitter.emit("dragStart");
} else if (last) {
interactionEmitter.emit("dragEnd");
} else {
interactionEmitter.emit("drag");
}
},
});
function handleResize(width, height) {
setStageWidth(width);
setStageHeight(height);
@ -206,88 +75,85 @@ function MapInteraction({
stageHeightRef.current = height;
}
// Added key events to interaction emitter
useEffect(() => {
function handleKeyDown(event) {
// Ignore text input
if (event.target instanceof HTMLInputElement) {
return;
}
interactionEmitter.emit("keyDown", event);
}
const mapStageRef = useContext(MapStageContext);
const mapLayerRef = useRef();
const mapImageRef = useRef();
function handleKeyUp(event) {
// Ignore text input
if (event.target instanceof HTMLInputElement) {
return;
}
interactionEmitter.emit("keyUp", event);
}
const previousSelectedToolRef = useRef(selectedToolId);
document.body.addEventListener("keydown", handleKeyDown);
document.body.addEventListener("keyup", handleKeyUp);
document.body.tabIndex = 1;
return () => {
document.body.removeEventListener("keydown", handleKeyDown);
document.body.removeEventListener("keyup", handleKeyUp);
document.body.tabIndex = 0;
};
}, [interactionEmitter]);
const [interactionEmitter] = useState(new EventEmitter());
// Create default keyboard shortcuts
useEffect(() => {
function handleKeyDown(event) {
// Change to pan tool when pressing space
if (event.key === " " && selectedToolId === "pan") {
// Stop active state on pan icon from being selected
event.preventDefault();
}
if (
event.key === " " &&
selectedToolId !== "pan" &&
!disabledControls.includes("pan")
) {
event.preventDefault();
const bind = useStageInteraction(
mapLayerRef.current,
stageScale,
setStageScale,
stageTranslateRef,
selectedToolId,
preventMapInteraction,
{
onPinchStart: () => {
// Change to pan tool when pinching and zooming
previousSelectedToolRef.current = selectedToolId;
onSelectedToolChange("pan");
}
// Basic keyboard shortcuts
if (event.key === "w" && !disabledControls.includes("pan")) {
onSelectedToolChange("pan");
}
if (event.key === "d" && !disabledControls.includes("drawing")) {
onSelectedToolChange("drawing");
}
if (event.key === "f" && !disabledControls.includes("fog")) {
onSelectedToolChange("fog");
}
if (event.key === "m" && !disabledControls.includes("measure")) {
onSelectedToolChange("measure");
}
if (event.key === "q" && !disabledControls.includes("pointer")) {
onSelectedToolChange("pointer");
}
}
function handleKeyUp(event) {
if (event.key === " " && selectedToolId === "pan") {
},
onPinchEnd: () => {
onSelectedToolChange(previousSelectedToolRef.current);
}
},
onDrag: ({ first, last }) => {
if (first) {
interactionEmitter.emit("dragStart");
} else if (last) {
interactionEmitter.emit("dragEnd");
} else {
interactionEmitter.emit("drag");
}
},
}
);
function handleKeyDown(event) {
// Change to pan tool when pressing space
if (event.key === " " && selectedToolId === "pan") {
// Stop active state on pan icon from being selected
event.preventDefault();
}
if (
event.key === " " &&
selectedToolId !== "pan" &&
!disabledControls.includes("pan")
) {
event.preventDefault();
previousSelectedToolRef.current = selectedToolId;
onSelectedToolChange("pan");
}
interactionEmitter.on("keyDown", handleKeyDown);
interactionEmitter.on("keyUp", handleKeyUp);
return () => {
interactionEmitter.off("keyDown", handleKeyDown);
interactionEmitter.off("keyUp", handleKeyUp);
};
}, [
interactionEmitter,
onSelectedToolChange,
disabledControls,
selectedToolId,
]);
// Basic keyboard shortcuts
if (event.key === "w" && !disabledControls.includes("pan")) {
onSelectedToolChange("pan");
}
if (event.key === "d" && !disabledControls.includes("drawing")) {
onSelectedToolChange("drawing");
}
if (event.key === "f" && !disabledControls.includes("fog")) {
onSelectedToolChange("fog");
}
if (event.key === "m" && !disabledControls.includes("measure")) {
onSelectedToolChange("measure");
}
if (event.key === "q" && !disabledControls.includes("pointer")) {
onSelectedToolChange("pointer");
}
}
function handleKeyUp(event) {
if (event.key === " " && selectedToolId === "pan") {
onSelectedToolChange(previousSelectedToolRef.current);
}
}
useKeyboard(handleKeyDown, handleKeyUp);
// Get keyboard context to pass to Konva
const keyboardValue = useContext(KeyboardContext);
function getCursorForTool(tool) {
switch (tool) {
@ -309,10 +175,6 @@ function MapInteraction({
const mapWidth = stageWidth;
const mapHeight = map ? stageWidth * (map.height / map.width) : stageHeight;
const mapStageRef = useContext(MapStageContext);
const mapLayerRef = useRef();
const mapImageRef = useRef();
const auth = useContext(AuthContext);
const settings = useContext(SettingsContext);
@ -351,7 +213,7 @@ function MapInteraction({
>
<Layer ref={mapLayerRef}>
<Image
image={mapLoaded && loadedMapSourceImage}
image={mapLoaded && mapImageSource}
width={mapWidth}
height={mapHeight}
id="mapImage"
@ -360,11 +222,13 @@ function MapInteraction({
{/* Forward auth context to konva elements */}
<AuthContext.Provider value={auth}>
<SettingsContext.Provider value={settings}>
<MapInteractionProvider value={mapInteraction}>
<MapStageProvider value={mapStageRef}>
{mapLoaded && children}
</MapStageProvider>
</MapInteractionProvider>
<KeyboardContext.Provider value={keyboardValue}>
<MapInteractionProvider value={mapInteraction}>
<MapStageProvider value={mapStageRef}>
{mapLoaded && children}
</MapStageProvider>
</MapInteractionProvider>
</KeyboardContext.Provider>
</SettingsContext.Provider>
</AuthContext.Provider>
</Layer>

View File

@ -1,26 +1,19 @@
import React from "react";
import {
Flex,
Box,
Label,
Input,
Checkbox,
IconButton,
Select,
} from "theme-ui";
import { Flex, Box, Label, Input, Checkbox, IconButton } from "theme-ui";
import ExpandMoreIcon from "../../icons/ExpandMoreIcon";
import { isEmpty } from "../../helpers/shared";
import Divider from "../Divider";
import Select from "../Select";
const qualitySettings = [
{ id: "low", name: "Low" },
{ id: "medium", name: "Medium" },
{ id: "high", name: "High" },
{ id: "ultra", name: "Ultra High" },
{ id: "original", name: "Original" },
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
{ value: "ultra", label: "Ultra High" },
{ value: "original", label: "Original" },
];
function MapSettings({
@ -42,6 +35,50 @@ function MapSettings({
}
}
function handleGridSizeXChange(event) {
const value = parseInt(event.target.value) || 0;
const gridY = map.grid.size.y;
let inset = map.grid.inset;
if (value > 0) {
const gridScale =
((inset.bottomRight.x - inset.topLeft.x) * map.width) / value;
inset.bottomRight.y = inset.topLeft.y + (gridY * gridScale) / map.height;
}
onSettingsChange("grid", {
...map.grid,
inset,
size: {
...map.grid.size,
x: value,
},
});
}
function handleGridSizeYChange(event) {
const value = parseInt(event.target.value) || 0;
const gridX = map.grid.size.x;
let inset = map.grid.inset;
if (gridX > 0) {
const gridScale =
((inset.bottomRight.x - inset.topLeft.x) * map.width) / gridX;
inset.bottomRight.y = inset.topLeft.y + (value * gridScale) / map.height;
}
onSettingsChange("grid", {
...map.grid,
inset,
size: {
...map.grid.size,
y: value,
},
});
}
function getMapSize() {
let size = 0;
if (map.quality === "original") {
@ -64,10 +101,8 @@ function MapSettings({
<Input
type="number"
name="gridX"
value={`${(map && map.gridX) || 0}`}
onChange={(e) =>
onSettingsChange("gridX", parseInt(e.target.value))
}
value={`${(map && map.grid.size.x) || 0}`}
onChange={handleGridSizeXChange}
disabled={mapEmpty || map.type === "default"}
min={1}
my={1}
@ -78,43 +113,43 @@ function MapSettings({
<Input
type="number"
name="gridY"
value={`${(map && map.gridY) || 0}`}
onChange={(e) =>
onSettingsChange("gridY", parseInt(e.target.value))
}
value={`${(map && map.grid.size.y) || 0}`}
onChange={handleGridSizeYChange}
disabled={mapEmpty || map.type === "default"}
min={1}
my={1}
/>
</Box>
</Flex>
<Box mt={2} sx={{ flexGrow: 1 }}>
<Label htmlFor="name">Name</Label>
<Input
name="name"
value={(map && map.name) || ""}
onChange={(e) => onSettingsChange("name", e.target.value)}
disabled={mapEmpty || map.type === "default"}
my={1}
/>
</Box>
{showMore && (
<>
<Box mt={2} sx={{ flexGrow: 1 }}>
<Label htmlFor="name">Name</Label>
<Input
name="name"
value={(map && map.name) || ""}
onChange={(e) => onSettingsChange("name", e.target.value)}
disabled={mapEmpty || map.type === "default"}
my={1}
/>
</Box>
<Flex
mt={2}
mb={mapEmpty || map.type === "default" ? 2 : 0}
sx={{ alignItems: "flex-end" }}
>
<Box sx={{ width: "50%" }}>
<Label>Grid Type</Label>
<Box mb={1} sx={{ width: "50%" }}>
<Label mb={1}>Grid Type</Label>
<Select
defaultValue="Square"
my={1}
disabled={mapEmpty || map.type === "default"}
>
<option>Square</option>
<option disabled>Hex (Coming Soon)</option>
</Select>
defaultValue={{ value: "square", label: "Square" }}
isDisabled={mapEmpty || map.type === "default"}
options={[
{ value: "square", label: "Square" },
{ value: "hex", label: "Hex (Coming Soon)" },
]}
isOptionDisabled={(option) => option.value === "hex"}
isSearchable={false}
/>
</Box>
<Flex sx={{ width: "50%", flexDirection: "column" }} ml={2}>
<Label>
@ -141,28 +176,25 @@ function MapSettings({
</Flex>
{!mapEmpty && map.type !== "default" && (
<Flex my={2} sx={{ alignItems: "center" }}>
<Box sx={{ width: "50%" }}>
<Label>Quality</Label>
<Box mb={1} sx={{ width: "50%" }}>
<Label mb={1}>Quality</Label>
<Select
my={1}
value={!mapEmpty && map.quality}
disabled={mapEmpty}
onChange={(e) => onSettingsChange("quality", e.target.value)}
>
{qualitySettings.map((quality) => (
<option
key={quality.id}
value={quality.id}
disabled={
mapEmpty ||
(quality.id !== "original" &&
!map.resolutions[quality.id])
}
>
{quality.name}
</option>
))}
</Select>
options={qualitySettings}
value={
!mapEmpty &&
qualitySettings.find((s) => s.value === map.quality)
}
isDisabled={mapEmpty}
onChange={(option) =>
onSettingsChange("quality", option.value)
}
isOptionDisabled={(option) =>
mapEmpty ||
(option.value !== "original" &&
!map.resolutions[option.value])
}
isSearchable={false}
/>
</Box>
<Label sx={{ width: "50%" }} ml={2}>
Size: {getMapSize()}

View File

@ -1,24 +1,20 @@
import React, { useState } from "react";
import { Flex, Image as UIImage, IconButton, Box, Text } from "theme-ui";
import React from "react";
import RemoveMapIcon from "../../icons/RemoveMapIcon";
import ResetMapIcon from "../../icons/ResetMapIcon";
import ExpandMoreDotIcon from "../../icons/ExpandMoreDotIcon";
import Tile from "../Tile";
import useDataSource from "../../helpers/useDataSource";
import { mapSources as defaultMapSources, unknownSource } from "../../maps";
function MapTile({
map,
mapState,
isSelected,
onMapSelect,
onMapRemove,
onMapReset,
onMapEdit,
onDone,
large,
canEdit,
badges,
}) {
const [isMapTileMenuOpen, setIsTileMenuOpen] = useState(false);
const isDefault = map.type === "default";
const mapSource = useDataSource(
isDefault
@ -30,175 +26,19 @@ function MapTile({
unknownSource
);
const hasMapState =
mapState &&
(Object.values(mapState.tokens).length > 0 ||
mapState.mapDrawActions.length > 0 ||
mapState.fogDrawActions.length > 0);
const expandButton = (
<IconButton
aria-label="Show Map Actions"
title="Show Map Actions"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsTileMenuOpen(true);
}}
bg="overlay"
sx={{ borderRadius: "50%" }}
m={2}
>
<ExpandMoreDotIcon />
</IconButton>
);
function removeButton(map) {
return (
<IconButton
aria-label="Remove Map"
title="Remove Map"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsTileMenuOpen(false);
onMapRemove(map.id);
}}
bg="overlay"
sx={{ borderRadius: "50%" }}
m={2}
>
<RemoveMapIcon />
</IconButton>
);
}
function resetButton(map) {
return (
<IconButton
aria-label="Reset Map"
title="Reset Map"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsTileMenuOpen(false);
onMapReset(map.id);
}}
bg="overlay"
sx={{ borderRadius: "50%" }}
m={2}
>
<ResetMapIcon />
</IconButton>
);
}
return (
<Flex
key={map.id}
sx={{
position: "relative",
width: large ? "48%" : "32%",
height: "0",
paddingTop: large ? "48%" : "32%",
borderRadius: "4px",
justifyContent: "center",
alignItems: "center",
cursor: "pointer",
overflow: "hidden",
}}
my={1}
mx={`${large ? 1 : 2 / 3}%`}
bg="muted"
onClick={(e) => {
e.stopPropagation();
setIsTileMenuOpen(false);
if (!isSelected) {
onMapSelect(map);
}
}}
onDoubleClick={(e) => {
if (!isMapTileMenuOpen) {
onDone(e);
}
}}
>
<UIImage
sx={{
width: "100%",
height: "100%",
objectFit: "contain",
position: "absolute",
top: 0,
left: 0,
}}
src={mapSource}
/>
<Flex
sx={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
background:
"linear-gradient(to bottom, rgba(0,0,0,0) 70%, rgba(0,0,0,0.65) 100%);",
alignItems: "flex-end",
justifyContent: "center",
}}
p={2}
>
<Text
as="p"
variant="heading"
color="hsl(210, 50%, 96%)"
sx={{ textAlign: "center" }}
>
{map.name}
</Text>
</Flex>
<Box
sx={{
width: "100%",
height: "100%",
position: "absolute",
top: 0,
left: 0,
borderColor: "primary",
borderStyle: isSelected ? "solid" : "none",
borderWidth: "4px",
pointerEvents: "none",
borderRadius: "4px",
}}
/>
{/* Show expand button only if both reset and remove is available */}
{isSelected && (
<Box sx={{ position: "absolute", top: 0, right: 0 }}>
{isDefault && hasMapState && resetButton(map)}
{!isDefault && hasMapState && !isMapTileMenuOpen && expandButton}
{!isDefault && !hasMapState && removeButton(map)}
</Box>
)}
{/* Tile menu for two actions */}
{!isDefault && isMapTileMenuOpen && isSelected && (
<Flex
sx={{
position: "absolute",
top: 0,
left: 0,
bottom: 0,
right: 0,
alignItems: "center",
justifyContent: "center",
}}
bg="muted"
onClick={() => setIsTileMenuOpen(false)}
>
{!isDefault && removeButton(map)}
{hasMapState && resetButton(map)}
</Flex>
)}
</Flex>
<Tile
src={mapSource}
title={map.name}
isSelected={isSelected}
onSelect={() => onMapSelect(map)}
onEdit={() => onMapEdit(map.id)}
onDoubleClick={onDone}
large={large}
canEdit={canEdit}
badges={badges}
editTitle="Edit Map"
/>
);
}

View File

@ -1,97 +1,108 @@
import React, { useContext } from "react";
import { Flex, Box, Text } from "theme-ui";
import { Flex, Box, Text, IconButton, Close, Label } from "theme-ui";
import SimpleBar from "simplebar-react";
import { useMedia } from "react-media";
import Case from "case";
import AddIcon from "../../icons/AddIcon";
import RemoveMapIcon from "../../icons/RemoveMapIcon";
import ResetMapIcon from "../../icons/ResetMapIcon";
import GroupIcon from "../../icons/GroupIcon";
import MapTile from "./MapTile";
import Link from "../Link";
import FilterBar from "../FilterBar";
import DatabaseContext from "../../contexts/DatabaseContext";
function MapTiles({
maps,
selectedMap,
selectedMapState,
groups,
selectedMaps,
selectedMapStates,
onMapSelect,
onMapsRemove,
onMapsReset,
onMapAdd,
onMapRemove,
onMapReset,
onMapEdit,
onDone,
selectMode,
onSelectModeChange,
search,
onSearchChange,
onMapsGroup,
}) {
const { databaseStatus } = useContext(DatabaseContext);
const isSmallScreen = useMedia({ query: "(max-width: 500px)" });
let hasMapState = false;
for (let state of selectedMapStates) {
if (
Object.values(state.tokens).length > 0 ||
state.mapDrawActions.length > 0 ||
state.fogDrawActions.length > 0
) {
hasMapState = true;
break;
}
}
let hasSelectedDefaultMap = selectedMaps.some(
(map) => map.type === "default"
);
function mapToTile(map) {
const isSelected = selectedMaps.includes(map);
return (
<MapTile
key={map.id}
map={map}
isSelected={isSelected}
onMapSelect={onMapSelect}
onMapEdit={onMapEdit}
onDone={onDone}
large={isSmallScreen}
canEdit={
isSelected && selectMode === "single" && selectedMaps.length === 1
}
badges={[`${map.grid.size.x}x${map.grid.size.y}`]}
/>
);
}
const multipleSelected = selectedMaps.length > 1;
return (
<Box sx={{ position: "relative" }}>
<SimpleBar style={{ maxHeight: "300px" }}>
<FilterBar
onFocus={() => onMapSelect()}
search={search}
onSearchChange={onSearchChange}
selectMode={selectMode}
onSelectModeChange={onSelectModeChange}
onAdd={onMapAdd}
addTitle="Add Map"
/>
<SimpleBar style={{ height: "400px" }}>
<Flex
p={2}
pb={4}
bg="muted"
sx={{
flexWrap: "wrap",
borderRadius: "4px",
minHeight: "400px",
alignContent: "flex-start",
}}
onClick={() => onMapSelect(null)}
onClick={() => onMapSelect()}
>
<Flex
onClick={onMapAdd}
sx={{
":hover": {
color: "primary",
},
":focus": {
outline: "none",
},
":active": {
color: "secondary",
},
width: isSmallScreen ? "48%" : "32%",
height: "0",
paddingTop: isSmallScreen ? "48%" : "32%",
borderRadius: "4px",
position: "relative",
cursor: "pointer",
}}
my={1}
mx={`${isSmallScreen ? 1 : 2 / 3}%`}
bg="muted"
aria-label="Add Map"
title="Add Map"
>
<Flex
sx={{
width: "100%",
height: "100%",
position: "absolute",
top: 0,
left: 0,
justifyContent: "center",
alignItems: "center",
}}
>
<AddIcon large />
</Flex>
</Flex>
{maps.map((map) => {
const isSelected = selectedMap && map.id === selectedMap.id;
return (
<MapTile
key={map.id}
// TODO: Move to selected map here and fix url error
// when done is clicked
map={map}
mapState={isSelected && selectedMapState}
isSelected={isSelected}
onMapSelect={onMapSelect}
onMapRemove={onMapRemove}
onMapReset={onMapReset}
onDone={onDone}
large={isSmallScreen}
/>
);
})}
{groups.map((group) => (
<React.Fragment key={group}>
<Label mx={1} mt={2}>
{Case.capital(group)}
</Label>
{maps[group].map(mapToTile)}
</React.Fragment>
))}
</Flex>
</SimpleBar>
{databaseStatus === "disabled" && (
@ -112,6 +123,50 @@ function MapTiles({
</Text>
</Box>
)}
{selectedMaps.length > 0 && (
<Flex
sx={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
justifyContent: "space-between",
}}
bg="overlay"
>
<Close
title="Clear Selection"
aria-label="Clear Selection"
onClick={() => onMapSelect()}
/>
<Flex>
<IconButton
aria-label={multipleSelected ? "Group Maps" : "Group Map"}
title={multipleSelected ? "Group Maps" : "Group Map"}
onClick={() => onMapsGroup()}
disabled={hasSelectedDefaultMap}
>
<GroupIcon />
</IconButton>
<IconButton
aria-label={multipleSelected ? "Reset Maps" : "Reset Map"}
title={multipleSelected ? "Reset Maps" : "Reset Map"}
onClick={() => onMapsReset()}
disabled={!hasMapState}
>
<ResetMapIcon />
</IconButton>
<IconButton
aria-label={multipleSelected ? "Remove Maps" : "Remove Map"}
title={multipleSelected ? "Remove Maps" : "Remove Map"}
onClick={() => onMapsRemove()}
disabled={hasSelectedDefaultMap}
>
<RemoveMapIcon />
</IconButton>
</Flex>
</Flex>
)}
</Box>
);
}

View File

@ -82,12 +82,29 @@ function MapToken({
const tokenGroup = event.target;
// Snap to corners of grid
if (map.snapToGrid) {
const offset = Vector2.multiply(map.grid.inset.topLeft, {
x: mapWidth,
y: mapHeight,
});
const position = {
x: tokenGroup.x() + tokenGroup.width() / 2,
y: tokenGroup.y() + tokenGroup.height() / 2,
};
const gridSize = { x: mapWidth / map.gridX, y: mapHeight / map.gridY };
const gridSnap = Vector2.roundTo(position, gridSize);
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) {

View File

@ -7,8 +7,8 @@ import BlendOffIcon from "../../../icons/BlendOffIcon";
function AlphaBlendToggle({ useBlending, onBlendingChange }) {
return (
<IconButton
aria-label={useBlending ? "Disable Blending" : "Enable Blending"}
title={useBlending ? "Disable Blending" : "Enable Blending"}
aria-label={useBlending ? "Disable Blending (O)" : "Enable Blending (O)"}
title={useBlending ? "Disable Blending (O)" : "Enable Blending (O)"}
onClick={() => onBlendingChange(!useBlending)}
>
{useBlending ? <BlendOnIcon /> : <BlendOffIcon />}

View File

@ -1,10 +1,11 @@
import React, { useEffect, useContext } from "react";
import React, { useEffect } from "react";
import { Flex, IconButton } from "theme-ui";
import { useMedia } from "react-media";
import RadioIconButton from "../../RadioIconButton";
import ColorControl from "./ColorControl";
import AlphaBlendToggle from "./AlphaBlendToggle";
import RadioIconButton from "./RadioIconButton";
import ToolSection from "./ToolSection";
import BrushIcon from "../../../icons/BrushToolIcon";
@ -21,7 +22,7 @@ import RedoButton from "./RedoButton";
import Divider from "../../Divider";
import MapInteractionContext from "../../../contexts/MapInteractionContext";
import useKeyboard from "../../../helpers/useKeyboard";
function DrawingToolSettings({
settings,
@ -29,49 +30,41 @@ function DrawingToolSettings({
onToolAction,
disabledActions,
}) {
const { interactionEmitter } = useContext(MapInteractionContext);
// Keyboard shotcuts
useEffect(() => {
function handleKeyDown({ key, ctrlKey, metaKey, shiftKey }) {
if (key === "b") {
onSettingChange({ type: "brush" });
} else if (key === "p") {
onSettingChange({ type: "paint" });
} else if (key === "l") {
onSettingChange({ type: "line" });
} else if (key === "r") {
onSettingChange({ type: "rectangle" });
} else if (key === "c") {
onSettingChange({ type: "circle" });
} else if (key === "t") {
onSettingChange({ type: "triangle" });
} else if (key === "e") {
onSettingChange({ type: "erase" });
} else if (key === "o") {
onSettingChange({ useBlending: !settings.useBlending });
} else if (
(key === "z" || key === "Z") &&
(ctrlKey || metaKey) &&
shiftKey &&
!disabledActions.includes("redo")
) {
onToolAction("mapRedo");
} else if (
key === "z" &&
(ctrlKey || metaKey) &&
!shiftKey &&
!disabledActions.includes("undo")
) {
onToolAction("mapUndo");
}
function handleKeyDown({ key, ctrlKey, metaKey, shiftKey }) {
if (key === "b") {
onSettingChange({ type: "brush" });
} else if (key === "p") {
onSettingChange({ type: "paint" });
} else if (key === "l") {
onSettingChange({ type: "line" });
} else if (key === "r") {
onSettingChange({ type: "rectangle" });
} else if (key === "c") {
onSettingChange({ type: "circle" });
} else if (key === "t") {
onSettingChange({ type: "triangle" });
} else if (key === "e") {
onSettingChange({ type: "erase" });
} else if (key === "o") {
onSettingChange({ useBlending: !settings.useBlending });
} else if (
(key === "z" || key === "Z") &&
(ctrlKey || metaKey) &&
shiftKey &&
!disabledActions.includes("redo")
) {
onToolAction("mapRedo");
} else if (
key === "z" &&
(ctrlKey || metaKey) &&
!shiftKey &&
!disabledActions.includes("undo")
) {
onToolAction("mapUndo");
}
interactionEmitter.on("keyDown", handleKeyDown);
return () => {
interactionEmitter.off("keyDown", handleKeyDown);
};
});
}
useKeyboard(handleKeyDown);
// Change to brush if on erase and it gets disabled
useEffect(() => {
@ -85,37 +78,37 @@ function DrawingToolSettings({
const tools = [
{
id: "brush",
title: "Brush",
title: "Brush (B)",
isSelected: settings.type === "brush",
icon: <BrushIcon />,
},
{
id: "paint",
title: "Paint",
title: "Paint (P)",
isSelected: settings.type === "paint",
icon: <BrushPaintIcon />,
},
{
id: "line",
title: "Line",
title: "Line (L)",
isSelected: settings.type === "line",
icon: <BrushLineIcon />,
},
{
id: "rectangle",
title: "Rectangle",
title: "Rectangle (R)",
isSelected: settings.type === "rectangle",
icon: <BrushRectangleIcon />,
},
{
id: "circle",
title: "Circle",
title: "Circle (C)",
isSelected: settings.type === "circle",
icon: <BrushCircleIcon />,
},
{
id: "triangle",
title: "Triangle",
title: "Triangle (T)",
isSelected: settings.type === "triangle",
icon: <BrushTriangleIcon />,
},
@ -135,7 +128,7 @@ function DrawingToolSettings({
/>
<Divider vertical />
<RadioIconButton
title="Erase"
title="Erase (E)"
onClick={() => onSettingChange({ type: "erase" })}
isSelected={settings.type === "erase"}
disabled={disabledActions.includes("erase")}

View File

@ -8,9 +8,15 @@ function EdgeSnappingToggle({ useEdgeSnapping, onEdgeSnappingChange }) {
return (
<IconButton
aria-label={
useEdgeSnapping ? "Disable Edge Snapping" : "Enable Edge Snapping"
useEdgeSnapping
? "Disable Edge Snapping (S)"
: "Enable Edge Snapping (S)"
}
title={
useEdgeSnapping
? "Disable Edge Snapping (S)"
: "Enable Edge Snapping (S)"
}
title={useEdgeSnapping ? "Disable Edge Snapping" : "Enable Edge Snapping"}
onClick={() => onEdgeSnappingChange(!useEdgeSnapping)}
>
{useEdgeSnapping ? <SnappingOnIcon /> : <SnappingOffIcon />}

View File

@ -7,8 +7,12 @@ import PreviewOffIcon from "../../../icons/FogPreviewOffIcon";
function FogPreviewToggle({ useFogPreview, onFogPreviewChange }) {
return (
<IconButton
aria-label={useFogPreview ? "Disable Fog Preview" : "Enable Fog Preview"}
title={useFogPreview ? "Disable Fog Preview" : "Enable Fog Preview"}
aria-label={
useFogPreview ? "Disable Fog Preview (F)" : "Enable Fog Preview (F)"
}
title={
useFogPreview ? "Disable Fog Preview (F)" : "Enable Fog Preview (F)"
}
onClick={() => onFogPreviewChange(!useFogPreview)}
>
{useFogPreview ? <PreviewOnIcon /> : <PreviewOffIcon />}

View File

@ -1,9 +1,10 @@
import React, { useContext, useEffect } from "react";
import React from "react";
import { Flex } from "theme-ui";
import { useMedia } from "react-media";
import RadioIconButton from "../../RadioIconButton";
import EdgeSnappingToggle from "./EdgeSnappingToggle";
import RadioIconButton from "./RadioIconButton";
import FogPreviewToggle from "./FogPreviewToggle";
import FogBrushIcon from "../../../icons/FogBrushIcon";
@ -15,11 +16,11 @@ import FogSubtractIcon from "../../../icons/FogSubtractIcon";
import UndoButton from "./UndoButton";
import RedoButton from "./RedoButton";
import ToolSection from "./ToolSection";
import Divider from "../../Divider";
import MapInteractionContext from "../../../contexts/MapInteractionContext";
import ToolSection from "./ToolSection";
import useKeyboard from "../../../helpers/useKeyboard";
function BrushToolSettings({
settings,
@ -27,67 +28,58 @@ function BrushToolSettings({
onToolAction,
disabledActions,
}) {
const { interactionEmitter } = useContext(MapInteractionContext);
// Keyboard shortcuts
useEffect(() => {
function handleKeyDown({ key, ctrlKey, metaKey, shiftKey }) {
if (key === "Alt") {
onSettingChange({ useFogSubtract: !settings.useFogSubtract });
} else if (key === "p") {
onSettingChange({ type: "polygon" });
} else if (key === "b") {
onSettingChange({ type: "brush" });
} else if (key === "t") {
onSettingChange({ type: "toggle" });
} else if (key === "r") {
onSettingChange({ type: "remove" });
} else if (key === "s") {
onSettingChange({ useEdgeSnapping: !settings.useEdgeSnapping });
} else if (key === "f") {
onSettingChange({ preview: !settings.preview });
} else if (
(key === "z" || key === "Z") &&
(ctrlKey || metaKey) &&
shiftKey &&
!disabledActions.includes("redo")
) {
onToolAction("fogRedo");
} else if (
key === "z" &&
(ctrlKey || metaKey) &&
!shiftKey &&
!disabledActions.includes("undo")
) {
onToolAction("fogUndo");
}
function handleKeyDown({ key, ctrlKey, metaKey, shiftKey }) {
if (key === "Alt") {
onSettingChange({ useFogSubtract: !settings.useFogSubtract });
} else if (key === "p") {
onSettingChange({ type: "polygon" });
} else if (key === "b") {
onSettingChange({ type: "brush" });
} else if (key === "t") {
onSettingChange({ type: "toggle" });
} else if (key === "r") {
onSettingChange({ type: "remove" });
} else if (key === "s") {
onSettingChange({ useEdgeSnapping: !settings.useEdgeSnapping });
} else if (key === "f") {
onSettingChange({ preview: !settings.preview });
} else if (
(key === "z" || key === "Z") &&
(ctrlKey || metaKey) &&
shiftKey &&
!disabledActions.includes("redo")
) {
onToolAction("fogRedo");
} else if (
key === "z" &&
(ctrlKey || metaKey) &&
!shiftKey &&
!disabledActions.includes("undo")
) {
onToolAction("fogUndo");
}
}
function handleKeyUp({ key }) {
if (key === "Alt") {
onSettingChange({ useFogSubtract: !settings.useFogSubtract });
}
function handleKeyUp({ key }) {
if (key === "Alt") {
onSettingChange({ useFogSubtract: !settings.useFogSubtract });
}
}
interactionEmitter.on("keyDown", handleKeyDown);
interactionEmitter.on("keyUp", handleKeyUp);
return () => {
interactionEmitter.off("keyDown", handleKeyDown);
interactionEmitter.off("keyUp", handleKeyUp);
};
});
useKeyboard(handleKeyDown, handleKeyUp);
const isSmallScreen = useMedia({ query: "(max-width: 799px)" });
const drawTools = [
{
id: "polygon",
title: "Fog Polygon",
title: "Fog Polygon (P)",
isSelected: settings.type === "polygon",
icon: <FogPolygonIcon />,
},
{
id: "brush",
title: "Fog Brush",
title: "Fog Brush (B)",
isSelected: settings.type === "brush",
icon: <FogBrushIcon />,
},
@ -117,14 +109,14 @@ function BrushToolSettings({
/>
<Divider vertical />
<RadioIconButton
title="Toggle Fog"
title="Toggle Fog (T)"
onClick={() => onSettingChange({ type: "toggle" })}
isSelected={settings.type === "toggle"}
>
<FogToggleIcon />
</RadioIconButton>
<RadioIconButton
title="Remove Fog"
title="Remove Fog (R)"
onClick={() => onSettingChange({ type: "remove" })}
isSelected={settings.type === "remove"}
>

View File

@ -1,4 +1,4 @@
import React, { useEffect, useContext } from "react";
import React from "react";
import { Flex, Input, Text } from "theme-ui";
import ToolSection from "./ToolSection";
@ -8,45 +8,38 @@ import MeasureManhattanIcon from "../../../icons/MeasureManhattanIcon";
import Divider from "../../Divider";
import MapInteractionContext from "../../../contexts/MapInteractionContext";
import useKeyboard from "../../../helpers/useKeyboard";
function MeasureToolSettings({ settings, onSettingChange }) {
const { interactionEmitter } = useContext(MapInteractionContext);
// Keyboard shortcuts
useEffect(() => {
function handleKeyDown({ key }) {
if (key === "g") {
onSettingChange({ type: "chebyshev" });
} else if (key === "l") {
onSettingChange({ type: "euclidean" });
} else if (key === "c") {
onSettingChange({ type: "manhattan" });
}
function handleKeyDown({ key }) {
if (key === "g") {
onSettingChange({ type: "chebyshev" });
} else if (key === "l") {
onSettingChange({ type: "euclidean" });
} else if (key === "c") {
onSettingChange({ type: "manhattan" });
}
interactionEmitter.on("keyDown", handleKeyDown);
}
return () => {
interactionEmitter.off("keyDown", handleKeyDown);
};
});
useKeyboard(handleKeyDown);
const tools = [
{
id: "chebyshev",
title: "Grid Distance",
title: "Grid Distance (G)",
isSelected: settings.type === "chebyshev",
icon: <MeasureChebyshevIcon />,
},
{
id: "euclidean",
title: "Line Distance",
title: "Line Distance (L)",
isSelected: settings.type === "euclidean",
icon: <MeasureEuclideanIcon />,
},
{
id: "manhattan",
title: "City Block Distance",
title: "City Block Distance (C)",
isSelected: settings.type === "manhattan",
icon: <MeasureManhattanIcon />,
},

View File

@ -3,9 +3,16 @@ import { IconButton } from "theme-ui";
import RedoIcon from "../../../icons/RedoIcon";
import { isMacLike } from "../../../helpers/shared";
function RedoButton({ onClick, disabled }) {
return (
<IconButton onClick={onClick} disabled={disabled}>
<IconButton
title={`Redo (${isMacLike ? "Cmd" : "Ctrl"} + Shift + Z)`}
aria-label={`Redo (${isMacLike ? "Cmd" : "Ctrl"} + Shift + Z)`}
onClick={onClick}
disabled={disabled}
>
<RedoIcon />
</IconButton>
);

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect } from "react";
import { Box, Flex } from "theme-ui";
import RadioIconButton from "./RadioIconButton";
import RadioIconButton from "../../RadioIconButton";
// Section of map tools with the option to collapse into a vertical list
function ToolSection({ collapse, tools, onToolClick }) {

View File

@ -3,9 +3,16 @@ import { IconButton } from "theme-ui";
import UndoIcon from "../../../icons/UndoIcon";
import { isMacLike } from "../../../helpers/shared";
function UndoButton({ onClick, disabled }) {
return (
<IconButton onClick={onClick} disabled={disabled}>
<IconButton
title={`Undo (${isMacLike ? "Cmd" : "Ctrl"} + Z)`}
aria-label={`Undo (${isMacLike ? "Cmd" : "Ctrl"} + Z)`}
onClick={onClick}
disabled={disabled}
>
<UndoIcon />
</IconButton>
);

View File

@ -22,7 +22,7 @@ function StartStreamButton({ onStreamStart, onStreamEnd, stream }) {
Browser not supported for audio sharing.
<br />
<br />
See <Link to="/howTo#sharingAudio">How To</Link> for more information.
See <Link to="/how-to#sharingAudio">How To</Link> for more information.
</Text>
</Box>
);
@ -35,7 +35,7 @@ function StartStreamButton({ onStreamStart, onStreamEnd, stream }) {
Ensure "Share audio" is selected when sharing.
<br />
<br />
See <Link to="/howTo#sharingAudio">How To</Link> for more information.
See <Link to="/how-to#sharingAudio">How To</Link> for more information.
</Text>
</Box>
);
@ -77,8 +77,8 @@ function StartStreamButton({ onStreamStart, onStreamEnd, stream }) {
<>
<IconButton
m={1}
aria-label="Start Radio Stream"
title="Start Radio Stream"
aria-label="Share Audio"
title="Share Audio"
onClick={openModal}
>
<svg

View File

@ -10,8 +10,8 @@ function Stream({ stream, nickname }) {
const [showStreamInteractBanner, setShowStreamInteractBanner] = useState(
false
);
const [streamMuted, setStreamMuted] = useState(false);
const audioRef = useRef();
const streamMuted = streamVolume === 0;
useEffect(() => {
if (audioRef.current) {
@ -24,7 +24,7 @@ function Stream({ stream, nickname }) {
})
.catch(() => {
// Unable to autoplay
setStreamVolume(0);
setStreamMuted(true);
setShowStreamInteractBanner(true);
});
}
@ -34,11 +34,11 @@ function Stream({ stream, nickname }) {
if (audioRef.current) {
if (streamMuted) {
audioRef.current.play().then(() => {
setStreamVolume(1);
setStreamMuted(false);
setShowStreamInteractBanner(false);
});
} else {
setStreamVolume(0);
setStreamMuted(true);
}
}
}
@ -48,11 +48,36 @@ function Stream({ stream, nickname }) {
setStreamVolume(volume);
}
// Platforms like iOS don't allow you to control audio volume
// Detect this by trying to change the audio volume
const [isVolumeControlAvailable, setIsVolumeControlAvailable] = useState(
true
);
useEffect(() => {
let audio = audioRef.current;
function checkVolumeControlAvailable() {
const prevVolume = audio.volume;
// Set volume to 0.5, then check if the value actually stuck 100ms later
audio.volume = 0.5;
setTimeout(() => {
setIsVolumeControlAvailable(audio.volume === 0.5);
audio.volume = prevVolume;
}, [100]);
}
audio.addEventListener("playing", checkVolumeControlAvailable);
return () => {
audio.removeEventListener("playing", checkVolumeControlAvailable);
};
}, []);
// Use an audio context gain node to control volume to go past 100%
const audioGainRef = useRef();
useEffect(() => {
if (stream) {
let audioContext = new AudioContext();
let audioContext;
if (stream && !streamMuted && isVolumeControlAvailable) {
audioContext = new AudioContext();
let source = audioContext.createMediaStreamSource(stream);
let gainNode = audioContext.createGain();
gainNode.gain.value = 0;
@ -60,21 +85,11 @@ function Stream({ stream, nickname }) {
gainNode.connect(audioContext.destination);
audioGainRef.current = gainNode;
}
}, [stream]);
// Platforms like iOS don't allow you to control audio volume
// Detect this by trying to change the audio volume
const [isVolumeControlAvailable, setIsVolumeControlAvailable] = useState(
true
);
useEffect(() => {
if (audioRef.current) {
const prevVolume = audioRef.current.volume;
audioRef.current.volume = 0.5;
setIsVolumeControlAvailable(audioRef.current.volume !== 0.5);
audioRef.current.volume = prevVolume;
}
}, [stream]);
return () => {
audioContext && audioContext.close();
};
}, [stream, streamMuted, isVolumeControlAvailable]);
useEffect(() => {
if (audioGainRef.current && audioRef.current) {
@ -103,12 +118,12 @@ function Stream({ stream, nickname }) {
<StreamMuteIcon muted={streamMuted} />
</IconButton>
<Slider
value={streamVolume}
value={streamMuted ? 0 : streamVolume}
min={0}
max={2}
step={0.1}
onChange={handleVolumeChange}
disabled={!isVolumeControlAvailable}
disabled={!isVolumeControlAvailable || streamMuted}
/>
{stream && <audio ref={audioRef} playsInline muted={streamMuted} />}
</Flex>

View File

@ -0,0 +1,186 @@
import React, { useState, useRef, useEffect } from "react";
import { Box, IconButton } from "theme-ui";
import { Stage, Layer, Image, Rect, Group } from "react-konva";
import ReactResizeDetector from "react-resize-detector";
import useImage from "use-image";
import usePreventOverscroll from "../../helpers/usePreventOverscroll";
import useStageInteraction from "../../helpers/useStageInteraction";
import useDataSource from "../../helpers/useDataSource";
import GridOnIcon from "../../icons/GridOnIcon";
import GridOffIcon from "../../icons/GridOffIcon";
import { tokenSources, unknownSource } from "../../tokens";
import Grid from "../Grid";
function TokenPreview({ token }) {
const [tokenSourceData, setTokenSourceData] = useState({});
useEffect(() => {
if (token.id !== tokenSourceData.id) {
setTokenSourceData(token);
}
}, [token, tokenSourceData]);
const tokenSource = useDataSource(
tokenSourceData,
tokenSources,
unknownSource
);
const [tokenSourceImage] = useImage(tokenSource);
const [tokenRatio, setTokenRatio] = useState(1);
useEffect(() => {
if (tokenSourceImage) {
setTokenRatio(tokenSourceImage.width / tokenSourceImage.height);
}
}, [tokenSourceImage]);
const [stageWidth, setStageWidth] = useState(1);
const [stageHeight, setStageHeight] = useState(1);
const [stageScale, setStageScale] = useState(1);
const stageRatio = stageWidth / stageHeight;
let tokenWidth;
let tokenHeight;
if (stageRatio > tokenRatio) {
tokenWidth = tokenSourceImage
? stageHeight / (tokenSourceImage.height / tokenSourceImage.width)
: stageWidth;
tokenHeight = stageHeight;
} else {
tokenWidth = stageWidth;
tokenHeight = tokenSourceImage
? stageWidth * (tokenSourceImage.height / tokenSourceImage.width)
: stageHeight;
}
const stageTranslateRef = useRef({ x: 0, y: 0 });
const mapLayerRef = useRef();
function handleResize(width, height) {
setStageWidth(width);
setStageHeight(height);
}
// Reset map translate and scale
useEffect(() => {
const layer = mapLayerRef.current;
const containerRect = containerRef.current.getBoundingClientRect();
if (layer) {
let newTranslate;
if (stageRatio > tokenRatio) {
newTranslate = {
x: -(tokenWidth - containerRect.width) / 2,
y: 0,
};
} else {
newTranslate = {
x: 0,
y: -(tokenHeight - containerRect.height) / 2,
};
}
layer.x(newTranslate.x);
layer.y(newTranslate.y);
layer.draw();
stageTranslateRef.current = newTranslate;
setStageScale(1);
}
}, [token.id, tokenWidth, tokenHeight, stageRatio, tokenRatio]);
const bind = useStageInteraction(
mapLayerRef.current,
stageScale,
setStageScale,
stageTranslateRef,
"pan"
);
const containerRef = useRef();
usePreventOverscroll(containerRef);
const [showGridPreview, setShowGridPreview] = useState(true);
const gridWidth = tokenWidth;
const gridX = token.defaultSize;
const gridSize = gridWidth / gridX;
const gridY = Math.ceil(tokenHeight / gridSize);
const gridHeight = gridY > 0 ? gridY * gridSize : tokenHeight;
const borderWidth = Math.max(
(Math.min(tokenWidth, gridHeight) / 200) * Math.max(1 / stageScale, 1),
1
);
return (
<Box
sx={{
width: "100%",
height: "300px",
cursor: "move",
touchAction: "none",
outline: "none",
position: "relative",
}}
bg="muted"
ref={containerRef}
{...bind()}
>
<ReactResizeDetector handleWidth handleHeight onResize={handleResize}>
<Stage
width={stageWidth}
height={stageHeight}
scale={{ x: stageScale, y: stageScale }}
x={stageWidth / 2}
y={stageHeight / 2}
offset={{ x: stageWidth / 2, y: stageHeight / 2 }}
>
<Layer ref={mapLayerRef}>
<Image
image={tokenSourceImage}
width={tokenWidth}
height={tokenHeight}
/>
{showGridPreview && (
<Group offsetY={gridHeight - tokenHeight}>
<Grid
gridX={gridX}
gridY={gridY}
width={gridWidth}
height={gridHeight}
/>
<Rect
width={gridWidth}
height={gridHeight}
fill="transparent"
stroke="rgba(255, 255, 255, 0.75)"
strokeWidth={borderWidth}
/>
</Group>
)}
</Layer>
</Stage>
</ReactResizeDetector>
<IconButton
title={showGridPreview ? "Hide Grid Preview" : "Show Grid Preview"}
aria-label={showGridPreview ? "Hide Grid Preview" : "Show Grid Preview"}
onClick={() => setShowGridPreview(!showGridPreview)}
bg="overlay"
sx={{
borderRadius: "50%",
position: "absolute",
bottom: 0,
right: 0,
}}
m={2}
p="6px"
>
{showGridPreview ? <GridOnIcon /> : <GridOffIcon />}
</IconButton>
</Box>
);
}
export default TokenPreview;

View File

@ -1,34 +1,45 @@
import React from "react";
import {
Flex,
Box,
Input,
IconButton,
Label,
Checkbox,
Select,
} from "theme-ui";
import { Flex, Box, Input, Label } from "theme-ui";
import ExpandMoreIcon from "../../icons/ExpandMoreIcon";
import { isEmpty } from "../../helpers/shared";
import Select from "../Select";
const categorySettings = [
{ id: "character", name: "Character" },
{ id: "prop", name: "Prop" },
{ id: "vehicle", name: "Vehicle / Mount" },
{ value: "character", label: "Character" },
{ value: "prop", label: "Prop" },
{ value: "vehicle", label: "Vehicle / Mount" },
];
function TokenSettings({
token,
onSettingsChange,
showMore,
onShowMoreChange,
}) {
function TokenSettings({ token, onSettingsChange }) {
const tokenEmpty = !token || isEmpty(token);
return (
<Flex sx={{ flexDirection: "column" }}>
<Box mt={2} sx={{ flexGrow: 1 }}>
<Label htmlFor="name">Name</Label>
<Input
name="name"
value={(token && token.name) || ""}
onChange={(e) => onSettingsChange("name", e.target.value)}
disabled={tokenEmpty || token.type === "default"}
my={1}
/>
</Box>
<Box mt={2}>
<Label mb={1}>Category</Label>
<Select
options={categorySettings}
value={
!tokenEmpty &&
categorySettings.find((s) => s.value === token.category)
}
isDisabled={tokenEmpty || token.type === "default"}
onChange={(option) => onSettingsChange("category", option.value)}
isSearchable={false}
/>
</Box>
<Flex>
<Box mt={2} sx={{ flexGrow: 1 }}>
<Box my={2} sx={{ flexGrow: 1 }}>
<Label htmlFor="tokenSize">Default Size</Label>
<Input
type="number"
@ -43,64 +54,6 @@ function TokenSettings({
/>
</Box>
</Flex>
{showMore && (
<>
<Box mt={2} sx={{ flexGrow: 1 }}>
<Label htmlFor="name">Name</Label>
<Input
name="name"
value={(token && token.name) || ""}
onChange={(e) => onSettingsChange("name", e.target.value)}
disabled={tokenEmpty || token.type === "default"}
my={1}
/>
</Box>
<Flex my={2}>
<Box sx={{ flexGrow: 1 }}>
<Label>Category</Label>
<Select
my={1}
value={!tokenEmpty && token.category}
disabled={tokenEmpty || token.type === "default"}
onChange={(e) => onSettingsChange("category", e.target.value)}
>
{categorySettings.map((category) => (
<option key={category.id} value={category.id}>
{category.name}
</option>
))}
</Select>
</Box>
<Flex sx={{ flexGrow: 1, alignItems: "center" }} ml={2}>
<Label>
<Checkbox
checked={token && token.hideInSidebar}
disabled={tokenEmpty || token.type === "default"}
onChange={(e) =>
onSettingsChange("hideInSidebar", e.target.checked)
}
/>
Hide in Sidebar
</Label>
</Flex>
</Flex>
</>
)}
<IconButton
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onShowMoreChange(!showMore);
}}
sx={{
transform: `rotate(${showMore ? "180deg" : "0"})`,
alignSelf: "center",
}}
aria-label={showMore ? "Show Less" : "Show More"}
title={showMore ? "Show Less" : "Show More"}
>
<ExpandMoreIcon />
</IconButton>
</Flex>
);
}

View File

@ -1,7 +1,6 @@
import React from "react";
import { Flex, Image, Text, Box, IconButton } from "theme-ui";
import RemoveTokenIcon from "../../icons/RemoveTokenIcon";
import Tile from "../Tile";
import useDataSource from "../../helpers/useDataSource";
import {
@ -9,93 +8,29 @@ import {
unknownSource,
} from "../../tokens";
function TokenTile({ token, isSelected, onTokenSelect, onTokenRemove, large }) {
function TokenTile({
token,
isSelected,
onTokenSelect,
onTokenEdit,
large,
canEdit,
badges,
}) {
const tokenSource = useDataSource(token, defaultTokenSources, unknownSource);
const isDefault = token.type === "default";
return (
<Flex
onClick={() => onTokenSelect(token)}
sx={{
position: "relative",
width: large ? "48%" : "32%",
height: "0",
paddingTop: large ? "48%" : "32%",
borderRadius: "4px",
justifyContent: "center",
alignItems: "center",
cursor: "pointer",
overflow: "hidden",
}}
my={1}
mx={`${large ? 1 : 2 / 3}%`}
bg="muted"
>
<Image
sx={{
width: "100%",
height: "100%",
objectFit: "contain",
position: "absolute",
top: 0,
left: 0,
}}
src={tokenSource}
/>
<Flex
sx={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
background:
"linear-gradient(to bottom, rgba(0,0,0,0) 70%, rgba(0,0,0,0.65) 100%);",
alignItems: "flex-end",
justifyContent: "center",
}}
p={2}
>
<Text
as="p"
variant="heading"
color="hsl(210, 50%, 96%)"
sx={{ textAlign: "center" }}
>
{token.name}
</Text>
</Flex>
<Box
sx={{
width: "100%",
height: "100%",
position: "absolute",
top: 0,
left: 0,
borderColor: "primary",
borderStyle: isSelected ? "solid" : "none",
borderWidth: "4px",
pointerEvents: "none",
borderRadius: "4px",
}}
/>
{isSelected && !isDefault && (
<Box sx={{ position: "absolute", top: 0, right: 0 }}>
<IconButton
aria-label="Remove Token"
title="Remove Token"
onClick={() => {
onTokenRemove(token.id);
}}
bg="overlay"
sx={{ borderRadius: "50%" }}
m={2}
>
<RemoveTokenIcon />
</IconButton>
</Box>
)}
</Flex>
<Tile
src={tokenSource}
title={token.name}
isSelected={isSelected}
onSelect={() => onTokenSelect(token)}
onEdit={() => onTokenEdit(token.id)}
large={large}
canEdit={canEdit}
badges={badges}
editTitle="Edit Token"
/>
);
}

View File

@ -1,84 +1,112 @@
import React, { useContext } from "react";
import { Flex, Box, Text } from "theme-ui";
import { Flex, Box, Text, IconButton, Close, Label } from "theme-ui";
import SimpleBar from "simplebar-react";
import { useMedia } from "react-media";
import Case from "case";
import AddIcon from "../../icons/AddIcon";
import RemoveTokenIcon from "../../icons/RemoveTokenIcon";
import GroupIcon from "../../icons/GroupIcon";
import TokenHideIcon from "../../icons/TokenHideIcon";
import TokenShowIcon from "../../icons/TokenShowIcon";
import TokenTile from "./TokenTile";
import Link from "../Link";
import FilterBar from "../FilterBar";
import DatabaseContext from "../../contexts/DatabaseContext";
function TokenTiles({
tokens,
groups,
onTokenAdd,
onTokenEdit,
onTokenSelect,
selectedToken,
onTokenRemove,
selectedTokens,
onTokensRemove,
selectMode,
onSelectModeChange,
search,
onSearchChange,
onTokensGroup,
onTokensHide,
}) {
const { databaseStatus } = useContext(DatabaseContext);
const isSmallScreen = useMedia({ query: "(max-width: 500px)" });
let hasSelectedDefaultToken = selectedTokens.some(
(token) => token.type === "default"
);
let allTokensVisible = selectedTokens.every((token) => !token.hideInSidebar);
function tokenToTile(token) {
const isSelected = selectedTokens.includes(token);
return (
<TokenTile
key={token.id}
token={token}
isSelected={isSelected}
onTokenSelect={onTokenSelect}
onTokenEdit={onTokenEdit}
large={isSmallScreen}
canEdit={
isSelected &&
token.type !== "default" &&
selectMode === "single" &&
selectedTokens.length === 1
}
badges={[`${token.defaultSize}x`]}
/>
);
}
const multipleSelected = selectedTokens.length > 1;
let hideTitle = "";
if (multipleSelected) {
if (allTokensVisible) {
hideTitle = "Hide Tokens in Sidebar";
} else {
hideTitle = "Show Tokens in Sidebar";
}
} else {
if (allTokensVisible) {
hideTitle = "Hide Token in Sidebar";
} else {
hideTitle = "Show Token in Sidebar";
}
}
return (
<Box sx={{ position: "relative" }}>
<SimpleBar style={{ maxHeight: "300px" }}>
<FilterBar
onFocus={() => onTokenSelect()}
search={search}
onSearchChange={onSearchChange}
selectMode={selectMode}
onSelectModeChange={onSelectModeChange}
onAdd={onTokenAdd}
addTitle="Add Token"
/>
<SimpleBar style={{ height: "400px" }}>
<Flex
p={2}
pb={4}
bg="muted"
sx={{
flexWrap: "wrap",
borderRadius: "4px",
minHeight: "400px",
alignContent: "flex-start",
}}
onClick={() => onTokenSelect()}
>
<Box
onClick={onTokenAdd}
sx={{
":hover": {
color: "primary",
},
":focus": {
outline: "none",
},
":active": {
color: "secondary",
},
width: isSmallScreen ? "48%" : "32%",
height: "0",
paddingTop: isSmallScreen ? "48%" : "32%",
borderRadius: "4px",
position: "relative",
cursor: "pointer",
}}
my={1}
mx={`${isSmallScreen ? 1 : 2 / 3}%`}
bg="muted"
aria-label="Add Token"
title="Add Token"
>
<Flex
sx={{
width: "100%",
height: "100%",
position: "absolute",
top: 0,
left: 0,
justifyContent: "center",
alignItems: "center",
}}
>
<AddIcon large />
</Flex>
</Box>
{tokens.map((token) => (
<TokenTile
key={token.id}
token={token}
isSelected={selectedToken && token.id === selectedToken.id}
onTokenSelect={onTokenSelect}
onTokenRemove={onTokenRemove}
large={isSmallScreen}
/>
{groups.map((group) => (
<React.Fragment key={group}>
<Label mx={1} mt={2}>
{Case.capital(group)}
</Label>
{tokens[group].map(tokenToTile)}
</React.Fragment>
))}
</Flex>
</SimpleBar>
@ -100,6 +128,50 @@ function TokenTiles({
</Text>
</Box>
)}
{selectedTokens.length > 0 && (
<Flex
sx={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
justifyContent: "space-between",
}}
bg="overlay"
>
<Close
title="Clear Selection"
aria-label="Clear Selection"
onClick={() => onTokenSelect()}
/>
<Flex>
<IconButton
aria-label={hideTitle}
title={hideTitle}
disabled={hasSelectedDefaultToken}
onClick={() => onTokensHide(allTokensVisible)}
>
{allTokensVisible ? <TokenShowIcon /> : <TokenHideIcon />}
</IconButton>
<IconButton
aria-label={multipleSelected ? "Group Tokens" : "Group Token"}
title={multipleSelected ? "Group Tokens" : "Group Token"}
onClick={() => onTokensGroup()}
disabled={hasSelectedDefaultToken}
>
<GroupIcon />
</IconButton>
<IconButton
aria-label={multipleSelected ? "Remove Tokens" : "Remove Token"}
title={multipleSelected ? "Remove Tokens" : "Remove Token"}
onClick={() => onTokensRemove()}
disabled={hasSelectedDefaultToken}
>
<RemoveTokenIcon />
</IconButton>
</Flex>
</Flex>
)}
</Box>
);
}

View File

@ -0,0 +1,42 @@
import React, { useEffect, useState } from "react";
import { EventEmitter } from "events";
const KeyboardContext = React.createContext({ keyEmitter: new EventEmitter() });
export function KeyboardProvider({ children }) {
const [keyEmitter] = useState(new EventEmitter());
useEffect(() => {
function handleKeyDown(event) {
// Ignore text input
if (event.target instanceof HTMLInputElement) {
return;
}
keyEmitter.emit("keyDown", event);
}
function handleKeyUp(event) {
// Ignore text input
if (event.target instanceof HTMLInputElement) {
return;
}
keyEmitter.emit("keyUp", event);
}
document.body.addEventListener("keydown", handleKeyDown);
document.body.addEventListener("keyup", handleKeyUp);
document.body.tabIndex = 1;
return () => {
document.body.removeEventListener("keydown", handleKeyDown);
document.body.removeEventListener("keyup", handleKeyUp);
document.body.tabIndex = 0;
};
}, [keyEmitter]);
return (
<KeyboardContext.Provider value={{ keyEmitter }}>
{children}
</KeyboardContext.Provider>
);
}
export default KeyboardContext;

View File

@ -45,9 +45,9 @@ export function MapDataProvider({ children }) {
// Emulate the time increasing to avoid sort errors
created: Date.now() + i,
lastModified: Date.now() + i,
gridType: "grid",
showGrid: false,
snapToGrid: true,
group: "default",
});
// Add a state for the map if there isn't one already
const state = await database.table("states").get(id);
@ -101,6 +101,21 @@ export function MapDataProvider({ children }) {
});
}
async function removeMaps(ids) {
await database.table("maps").bulkDelete(ids);
await database.table("states").bulkDelete(ids);
setMaps((prevMaps) => {
const filtered = prevMaps.filter((map) => !ids.includes(map.id));
return filtered;
});
setMapStates((prevMapsStates) => {
const filtered = prevMapsStates.filter(
(state) => !ids.includes(state.mapId)
);
return filtered;
});
}
async function resetMap(id) {
const state = { ...defaultMapState, mapId: id };
await database.table("states").put(state);
@ -127,6 +142,22 @@ export function MapDataProvider({ children }) {
});
}
async function updateMaps(ids, update) {
await Promise.all(
ids.map((id) => database.table("maps").update(id, update))
);
setMaps((prevMaps) => {
const newMaps = [...prevMaps];
for (let id of ids) {
const i = newMaps.findIndex((map) => map.id === id);
if (i > -1) {
newMaps[i] = { ...newMaps[i], ...update };
}
}
return newMaps;
});
}
async function updateMapState(id, update) {
await database.table("states").update(id, update);
setMapStates((prevMapStates) => {
@ -199,8 +230,10 @@ export function MapDataProvider({ children }) {
mapStates,
addMap,
removeMap,
removeMaps,
resetMap,
updateMap,
updateMaps,
updateMapState,
putMap,
getMap,

View File

@ -26,6 +26,7 @@ export function TokenDataProvider({ children }) {
...defaultToken,
id: `__default-${defaultToken.name}`,
owner: userId,
group: "default",
});
}
return defaultTokensWithIds;
@ -60,6 +61,14 @@ export function TokenDataProvider({ children }) {
});
}
async function removeTokens(ids) {
await database.table("tokens").bulkDelete(ids);
setTokens((prevTokens) => {
const filtered = prevTokens.filter((token) => !ids.includes(token.id));
return filtered;
});
}
async function updateToken(id, update) {
const change = { ...update, lastModified: Date.now() };
await database.table("tokens").update(id, change);
@ -73,6 +82,23 @@ export function TokenDataProvider({ children }) {
});
}
async function updateTokens(ids, update) {
const change = { ...update, lastModified: Date.now() };
await Promise.all(
ids.map((id) => database.table("tokens").update(id, change))
);
setTokens((prevTokens) => {
const newTokens = [...prevTokens];
for (let id of ids) {
const i = newTokens.findIndex((token) => token.id === id);
if (i > -1) {
newTokens[i] = { ...newTokens[i], ...change };
}
}
return newTokens;
});
}
async function putToken(token) {
await database.table("tokens").put(token);
setTokens((prevTokens) => {
@ -128,7 +154,9 @@ export function TokenDataProvider({ children }) {
ownedTokens,
addToken,
removeToken,
removeTokens,
updateToken,
updateTokens,
putToken,
getToken,
tokensById,

View File

@ -1,6 +1,7 @@
import Dexie from "dexie";
import blobToBuffer from "./helpers/blobToBuffer";
import { getMapDefaultInset } from "./helpers/map";
function loadVersions(db) {
// v1.2.0
@ -187,7 +188,7 @@ function loadVersions(db) {
// v1.5.2 - Added automatic cache invalidation to maps
db.version(11)
.stores({})
.upgrade(async (tx) => {
.upgrade((tx) => {
return tx
.table("maps")
.toCollection()
@ -198,7 +199,7 @@ function loadVersions(db) {
// v1.5.2 - Added automatic cache invalidation to tokens
db.version(12)
.stores({})
.upgrade(async (tx) => {
.upgrade((tx) => {
return tx
.table("tokens")
.toCollection()
@ -206,6 +207,41 @@ function loadVersions(db) {
token.lastUsed = token.lastModified;
});
});
// v1.6.0 - Added map grouping and grid scale and offset
db.version(13)
.stores({})
.upgrade((tx) => {
return tx
.table("maps")
.toCollection()
.modify((map) => {
map.group = "";
map.grid = {
size: { x: map.gridX, y: map.gridY },
inset: getMapDefaultInset(
map.width,
map.height,
map.gridX,
map.gridY
),
type: "square",
};
delete map.gridX;
delete map.gridY;
delete map.gridType;
});
});
// v1.6.0 - Added token grouping
db.version(14)
.stores({})
.upgrade((tx) => {
return tx
.table("tokens")
.toCollection()
.modify((token) => {
token.group = "";
});
});
}
// Get the dexie database used in DatabaseContext

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,7 +1,7 @@
import defaultMaps from "./DefaultMaps.mp4";
import customMaps from "./CustomMaps.mp4";
import customMapsAdvanced from "./CustomMapsAdvanced.jpg";
import resetingAndRemovingMaps from "./ResetingAndRemovingMaps.mp4";
import editingMapsAdvanced from "./EditingMapsAdvanced.jpg";
import resetAndRemovingMaps from "./ResetAndRemovingMaps.mp4";
import usingDrawing from "./UsingDrawing.mp4";
import openDiceTray from "./OpenDiceTray.mp4";
import diceRolling from "./DiceRolling.mp4";
@ -13,7 +13,7 @@ import defaultTokens from "./DefaultTokens.mp4";
import workingWithTokens from "./WorkingWithTokens.mp4";
import deletingTokens from "./DeletingTokens.mp4";
import customTokens from "./CustomTokens.mp4";
import customTokensAdvanced from "./CustomTokensAdvanced.jpg";
import tokenEditor from "./TokenEditor.mp4";
import addPartyMember from "./AddPartyMember.mp4";
import changeNickname from "./ChangeNickname.mp4";
import sharingAudio from "./SharingAudio.mp4";
@ -21,12 +21,16 @@ import startGame from "./StartGame.mp4";
import diceSharing from "./DiceSharing.mp4";
import usingTimer from "./UsingTimer.mp4";
import usingPointer from "./UsingPointer.mp4";
import mapEditor from "./MapEditor.mp4";
import filteringMaps from "./FilteringMaps.mp4";
import groupAndRemovingTokens from "./GroupAndRemovingTokens.mp4";
import filteringTokens from "./FilteringTokens.mp4";
export default {
defaultMaps,
customMaps,
customMapsAdvanced,
resetingAndRemovingMaps,
editingMapsAdvanced,
resetAndRemovingMaps,
usingDrawing,
openDiceTray,
diceRolling,
@ -38,7 +42,7 @@ export default {
workingWithTokens,
deletingTokens,
customTokens,
customTokensAdvanced,
tokenEditor,
addPartyMember,
changeNickname,
sharingAudio,
@ -46,4 +50,8 @@ export default {
diceSharing,
usingTimer,
usingPointer,
mapEditor,
filteringMaps,
groupAndRemovingTokens,
filteringTokens,
};

View File

@ -4,10 +4,10 @@ To accomplish this Owlbear Rodeo uses the audio portion of a browsers screen sha
`Note: Even though sharing audio requires a supported browser, receiving audio works on all browsers`
To use audio sharing click the Start Radio Stream button in the bottom left to open the Radio Screen then click Start Radio. The browser will then ask to share your screen, click on the Chrome Tab option and select your tab. Ensure to select the Share Audio Checkbox and finally click Share.
To use audio sharing click the Share Audio button in the bottom left to open the Audio Sharing Screen then click Start Sharing. The browser will then ask to share your screen, click on the Chrome Tab option and select your tab. Ensure to select the Share Audio Checkbox and finally click Share.
![Sharing Audio](sharingAudio)
`Note: Although Owlbear Rodeo uses the screen sharing functionality, only your audio is sent to the other players in the party`
To stop sharing your audio you can either click Stop on the share banner at the top of the screen or open the Radio Screen and click Stop Radio.
To stop sharing your audio you can either click Stop on the share banner at the top of the screen or open the Audio Sharing Screen and click Stop Sharing.

View File

@ -16,26 +16,39 @@ When the default maps don't suit your needs you can upload a custom map.
![Custom Maps](customMaps)
To do this open the Map Select Screen and then either click the Add Map button in the top left or simply drag an image from your computer into the list of maps.
To do this open the Map Select Screen and then either click the Add Map button in the top right or simply drag an image from your computer into the list of maps.
Once a custom map has been added you must configure the size of the map.
To do this there are the Column and Row properties. Columns represent how many grid cells your map has in the horizontal direction while Rows represents the amount of cells in the vertical direction.
`Tip: Owlbear Rodeo can automatically fill the Column and Row properties for you if you include them in the file name of the uploaded map. E.g. River [10x15] will create a map named River with 10 columns and 15 rows`
Once a custom map has been added Owlbear Rodeo will use its machine learning based grid detection to set up your map for you.
`Note: When uploading a custom map keep the file size in mind. Maps are shared between users in Owlbear Rodeo so if a map is taking too long to load for other party members consider changing the Quality option in the advanced map settings.`
## Custom Maps (Advanced)
## Editing Maps
Once a custom map has been uploaded there are a few advanced settings available.
To get access to these settings, with the desired map selected, click the Show More button under the Rows and Columns in the Map Select Screen.
![Map Editor](mapEditor)
![Custom Maps Advanced](customMapsAdvanced)
Once a custom map has been added there are a few settings you can edit if you wish.
To get access to these settings you can click the Edit Map button while a map is selected.
The topmost view of the Edit Map Screen is a grid editor. This allows you to visually set up any inset your map's grid may have.
`Tip: When editing a grid you can use the arrow keys on your keyboard to nudge the grid in the vertical or horizontal direction.`
Next there are the Column and Row properties. Columns represent how many grid cells your map has in the horizontal direction while Rows represents the amount of cells in the vertical direction.
Next you can set the name of your map shown in the Map Select Screen.
`Tip: If Owlbear Rodeo's grid detection feature fails to automatically fill the Column and Row properties for you, you can also include them in the file name of the uploaded map. E.g. River [10x15] will create a map named River with 10 columns and 15 rows.`
## Editing Maps (Advanced)
When editing a map there are also a few more advanced settings available.
To get access to these settings, click the Show More button under the Name input in the Map Edit Screen.
![Editing Maps Advanced](editingMapsAdvanced)
A brief summary of these settings is listed below.
- Name: The name of the map shown in the Map Select Screen.
- Grid Type: Change the type of grid to use for the map. Currently only the Square type is supported however Hex will be added in a future release.
- Show Grid: When enabled Owlbear Rodeo will draw a grid on top of your map, this is useful if a custom map you have uploaded doesn't include a grid.
- Snap to Grid: When enabled tokens, drawing, fog and measurements will attempt to snap to the grid.
@ -45,13 +58,22 @@ A brief summary of these settings is listed below.
- Drawings: Controls whether others can add drawings to the map (default enabled).
- Tokens: Controls whether others can move tokens that they have not placed themselves (default enabled).
## Reseting and Removing a Map
## Reseting, Removing and Grouping Maps
With a map selected there are a couple of actions you can perform on them.
![Reseting and Removing Maps](resetingAndRemovingMaps)
`Tip: You can select multiple maps at the same time using the Select Multiple option or using the Ctrl/Cmd or Shift keyboard shortcuts`
Once a map has been used you can clear away all the tokens, fog and drawings by selecting the map in the Select Map Screen and then on the selected tile click the Reset Map button.
![Reseting, Removing and Grouping Maps](resetAndRemovingMaps)
To remove a custom map select the map in the Map Select Screen then on the selected tile click the Remove Map button.
`Warning: This operation cannot be undone`
Once a map has been used you can clear away all the tokens, fog and drawings by selecting the map in the Select Map Screen and then clicking the Reset Map button.
To remove a custom map select the map in the Map Select Screen then click the Remove Map button or use the Delete keyboard shortcut.
Maps can also be grouped to allow for better organisation. To do this with a map selected click on the Group Map button then select or create a new group.
## Filtering Maps
![Filtering Maps](filteringMaps)
In the Select Map Screen you can filter the maps that are being shown by entering a term in the Search Bar. This will show maps whose names or groups best match your search term.

View File

@ -8,7 +8,7 @@ Owlbear Rodeo comes with a variety of default tokens that represent various play
![Default Tokens](defaultTokens)
Currently there are default tokens representing these types: Barbarian, Bard, Cleric, Druid, Fighter, Monk, Paladin, Ranger, Rouge, Sorcerer, Warlock, Wizard, Artificer, Blood Hunder, Aberration, Beast, Celestial, Construct, Dragon, Elemental, Fey, Fiend, Giant, Goblinoid, Humanoid, Monstrosity, Ooze, Plant, Shapechanger, Titan and Undead.
Currently there are default tokens representing these types: Barbarian, Bard, Cleric, Druid, Fighter, Monk, Paladin, Ranger, Rogue, Sorcerer, Warlock, Wizard, Artificer, Blood Hunder, Aberration, Beast, Celestial, Construct, Dragon, Elemental, Fey, Fiend, Giant, Goblinoid, Humanoid, Monstrosity, Ooze, Plant, Shapechanger, Titan and Undead.
## Working With Tokens
@ -35,35 +35,48 @@ When you need more then the default tokens Owlbear Rodeo allows you to upload a
![Custom Tokens](customTokens)
To upload a custom token select the Edit Tokens Button at the bottom of the Token List. This will open the Edit Token Screen which allows you to upload and edit tokens.
To upload a custom token select the Edit Tokens Button at the bottom of the Token List. This will open the Edit Tokens Screen which allows you to upload and edit tokens.
To upload a new token either click the Add Token Button or drag an image into the Edit Token Screen.
Once a token has been uploaded you can adjust the default size that is used when adding the token to the map by adjusting the Default Size Input.
`Note: The size input for a non-square image represents the number of grid cells a token takes up on the horizontal axis. The number of cells in the vertical axis is determined by the aspect ratio of the uploaded image.`
To upload a new token either click the Add Token Button or drag an image into the Edit Tokens Screen.
`Tip: Owlbear Rodeo has full transparency support for tokens. This means that players can only interact with visible parts of a token so feel free to upload creatures that might have large extended areas like wings.`
## Custom Tokens (Advanced)
## Editing Tokens
When uploading a custom token there are a couple of more advanced options that may come in handy.
![Token Editor](tokenEditor)
To get access to these settings select the desired token in the Edit Token Screen and click the Show More Button under the Default Size Input.
Once a custom token has been added there are a few settings you can edit if you wish.
![Custom Tokens Advanced](customTokensAdvanced)
To get access to these settings you can click the Edit Token button while a token is selected.
A brief summary of these settings is listed below.
The topmost view of the Edit Token Screen is a preview of what the token will look like on a map.
- Name: The name of the custom token.
- Category:
- Character - when selected this token will render on top of all other tokens. Used for things like players or enemies.
- Prop - when selected this token will render beneath all other tokens. Used for things like items or markers.
- Vehicle / Mount - when selected this token will render beneath characters but above props and when moved a character on top of this token will also be moved.
- Hide in Sidebar: When enabled the token will not show up in the Token List on the right side of the screen.
The first setting available is the Name input which allows you to change the name of the token that shows up in the Edit Tokens Screen.
## Removing a Custom Token
Next you can change the Category of your token, a summary of the options is below.
To remove a custom token open the Token Edit Screen, select the desired token and click the Remove Token Button on the token tile.
- Character - when selected this token will render on top of all other tokens. Used for things like players or enemies.
- Prop - when selected this token will render beneath all other tokens. Used for things like items or markers.
- Vehicle / Mount - when selected this token will render beneath characters but above props and when moved a character on top of this token will also be moved.
`Warning: This operation cannot be undone`
Lastly you can adjust the default size that is used when adding the token to the map by adjusting the Default Size Input.
`Note: The size input for a non-square image represents the number of grid cells a token takes up on the horizontal axis. The number of cells in the vertical axis is determined by the aspect ratio of the uploaded image.`
## Removing, Grouping and Hiding Tokens
![Hiding, Removing and Grouping Tokens](groupAndRemovingTokens)
To remove a custom token select the token in the Edit Tokens Screen then click the Remove Token button or use the Delete keyboard shortcut.
Once a token has been added you can use the Hide/Show Token in Sidebar toggle to prevent it from taking up room in the Token List on the right side of your screen.
Tokens can also be grouped to allow for better organisation. To do this with a token selected click on the Group Token button then select or create a new group.
`Tip: You can select multiple tokens at the same time using the Select Multiple option or using the Ctrl/Cmd or Shift keyboard shortcuts`
## Filtering Tokens
![Filtering Tokens](filteringTokens)
In the Edit Tokens Screen you can filter the tokens that are being shown by entering a term in the Search Bar. This will show tokens whose names or groups best match your search term.

View File

@ -0,0 +1,45 @@
[embed:](https://www.youtube.com/embed/TIhCAJoTeAU)
## Major Changes
### Reworked Map and Token Select Screens
In this update we're looking to enhance the experience for GMs. The first aspect of this is an overhauled map and token select screen.
- Groups - Maps and tokens can now be organised into groups.
- Multiselect - Multiple maps and tokens can now be selected at once. This makes it easier to remove and reset maps and hide and show tokens. This also helps the usability of the new groups feature.
- Search - A new search box allows you to filter your maps and tokens by their names or groups.
### New Map and Token Edit Screens
Maps and tokens now have a new edit screen that allows you to adjust their various settings.
- Map Grid Editor - When editing a map you can now see an overlay of your grid settings right in the edit screen.
- Map Grid Inset Support - The Map Grid Editor can also be used to set up an inset to your grid settings. This means that maps that have borders or don't have perfectly aligned grids are now easily usable in Owlbear Rodeo.
- Token Grid Preview - Similar to the Map Grid Editor the token edit screen also shows you a preview of how many grid cells your token will take up.
### Automatic Grid Detection
This feature has been in the works for months and I'm incredibly happy to finally be able to share it with everyone. One of the questions we get asked most often is what is the best way to find the grid size of a map? To answer this we have spent the last few months building a machine learning model that is able to automatically work out the grid size for you. Trained on thousands of battle maps the new neural network is run locally in your browser every time you upload a new map and will automatically find and fill out the columns and rows properties for you.
To get the best out of this feature maps should have somewhat of a visible grid on them, but if they don't have a grid it will still make the best guess it can.
Also this is definitely not the final version of automatic grid detection in Owlbear Rodeo, so expect this feature to get better and better as time goes on.
## Minor Changes
- Unified drop down menus to be consistent across browsers and platforms.
- Increased the maximum zoom amount of maps.
- Fixed disabled visuals for inputs in Safari.
- Fixed Audio Sharing volume control for desktop platforms.
- Moved the donation page into its own URL and made the donation button more visually distinct.
- Added option for custom donation amounts.
- Unified Audio Sharing naming.
- Added keyboard shortcut hint to tooltips.
[Reddit]()
[Twitter]()
---
Oct 17 2020

View File

@ -14,6 +14,7 @@ export function getBrushPositionForTool(
shapes
) {
let position = brushPosition;
const useGridSnappning =
map.snapToGrid &&
((tool === "drawing" &&
@ -25,13 +26,26 @@ export function getBrushPositionForTool(
if (useGridSnappning) {
// Snap to corners of grid
const gridSnap = Vector2.roundTo(position, gridSize);
// Subtract offset to transform into offset space then add it back transform back
const offset = map.grid.inset.topLeft;
const gridSnap = Vector2.add(
Vector2.roundTo(Vector2.subtract(position, offset), gridSize),
offset
);
const gridDistance = Vector2.length(Vector2.subtract(gridSnap, position));
// Snap to center of grid
// Subtract offset and half size to transform it into offset half space then transform it back
const halfSize = Vector2.multiply(gridSize, 0.5);
const centerSnap = Vector2.add(
Vector2.roundTo(position, gridSize),
Vector2.multiply(gridSize, 0.5)
Vector2.add(
Vector2.roundTo(
Vector2.subtract(Vector2.subtract(position, offset), halfSize),
gridSize
),
halfSize
),
offset
);
const centerDistance = Vector2.length(
Vector2.subtract(centerSnap, position)

145
src/helpers/map.js Normal file
View File

@ -0,0 +1,145 @@
import GridSizeModel from "../ml/gridSize/GridSizeModel";
export function getMapDefaultInset(width, height, gridX, gridY) {
// Max the width
const gridScale = width / gridX;
const y = gridY * gridScale;
const yNorm = y / height;
return { topLeft: { x: 0, y: 0 }, bottomRight: { x: 1, y: yNorm } };
}
// Get all factors of a number
function factors(n) {
const numbers = Array.from(Array(n + 1), (_, i) => i);
return numbers.filter((i) => n % i === 0);
}
// Greatest common divisor
// Euclidean algorithm https://en.wikipedia.org/wiki/Euclidean_algorithm
function gcd(a, b) {
while (b !== 0) {
const t = b;
b = a % b;
a = t;
}
return a;
}
// Find all dividers that fit into two numbers
function dividers(a, b) {
const d = gcd(a, b);
return factors(d);
}
// The mean and standard deviation of > 1500 maps from the web
const gridSizeMean = { x: 31.567792, y: 32.597987 };
const gridSizeStd = { x: 14.438842, y: 15.582376 };
// Most grid sizes are above 10 and below 200
const minGridSize = 10;
const maxGridSize = 200;
function gridSizeVaild(x, y) {
return (
x > minGridSize && y > minGridSize && x < maxGridSize && y < maxGridSize
);
}
function gridSizeHeuristic(image, candidates) {
const width = image.width;
const height = image.height;
// Find the best candidate by comparing the absolute z-scores of each axis
let bestX = 1;
let bestY = 1;
let bestScore = Number.MAX_VALUE;
for (let scale of candidates) {
const x = Math.floor(width / scale);
const y = Math.floor(height / scale);
const xScore = Math.abs((x - gridSizeMean.x) / gridSizeStd.x);
const yScore = Math.abs((y - gridSizeMean.y) / gridSizeStd.y);
if (xScore < bestScore || yScore < bestScore) {
bestX = x;
bestY = y;
bestScore = Math.min(xScore, yScore);
}
}
if (gridSizeVaild(bestX, bestY)) {
return { x: bestX, y: bestY };
} else {
return null;
}
}
async function gridSizeML(image, candidates) {
const width = image.width;
const height = image.height;
const ratio = width / height;
let canvas = document.createElement("canvas");
let context = canvas.getContext("2d");
canvas.width = 2048;
canvas.height = Math.floor(2048 / ratio);
context.drawImage(image, 0, 0, canvas.width, canvas.height);
let imageData = context.getImageData(
0,
Math.floor(canvas.height / 2) - 16,
2048,
32
);
for (let i = 0; i < imageData.data.length; i += 4) {
const r = imageData.data[i];
const g = imageData.data[i + 1];
const b = imageData.data[i + 2];
// ITU-R 601-2 Luma Transform
const luma = (r * 299) / 1000 + (g * 587) / 1000 + (b * 114) / 1000;
imageData.data[i] = imageData.data[i + 1] = imageData.data[i + 2] = luma;
}
const model = new GridSizeModel();
const prediction = await model.predict(imageData);
// Find the candidate that is closest to the prediction
let bestScale = 1;
let bestScore = Number.MAX_VALUE;
for (let scale of candidates) {
const x = Math.floor(width / scale);
const score = Math.abs(x - prediction);
if (score < bestScore && x > minGridSize && x < maxGridSize) {
bestScale = scale;
bestScore = score;
}
}
let x = Math.floor(width / bestScale);
let y = Math.floor(height / bestScale);
if (gridSizeVaild(x, y)) {
return { x, y };
} else {
// Fallback to raw prediction
x = Math.round(prediction);
y = Math.floor(x / ratio);
}
if (gridSizeVaild(x, y)) {
return { x, y };
} else {
return null;
}
}
export async function getGridSize(image) {
const candidates = dividers(image.width, image.height);
let prediction = await gridSizeML(image, candidates);
if (!prediction) {
prediction = gridSizeHeuristic(image, candidates);
}
if (!prediction) {
prediction = { x: 22, y: 22 };
}
return prediction;
}

133
src/helpers/select.js Normal file
View File

@ -0,0 +1,133 @@
import { useEffect, useState } from "react";
import Fuse from "fuse.js";
import { groupBy } from "./shared";
/**
* Helpers for the SelectMapModal and SelectTokenModal
*/
// Helper for generating search results for items
export function useSearch(items, search) {
const [filteredItems, setFilteredItems] = useState([]);
const [filteredItemScores, setFilteredItemScores] = useState({});
const [fuse, setFuse] = useState();
// Update search index when items change
useEffect(() => {
setFuse(new Fuse(items, { keys: ["name", "group"], includeScore: true }));
}, [items]);
// Perform search when search changes
useEffect(() => {
if (search) {
const query = fuse.search(search);
setFilteredItems(query.map((result) => result.item));
setFilteredItemScores(
query.reduce(
(acc, value) => ({ ...acc, [value.item.id]: value.score }),
{}
)
);
}
}, [search, items, fuse]);
return [filteredItems, filteredItemScores];
}
// Helper for grouping items
export function useGroup(items, filteredItems, useFiltered, filteredScores) {
const itemsByGroup = groupBy(useFiltered ? filteredItems : items, "group");
// Get the groups of the items sorting by the average score if we're filtering or the alphabetical order
// with "" at the start and "default" at the end if not
let itemGroups = Object.keys(itemsByGroup);
if (useFiltered) {
itemGroups.sort((a, b) => {
const aScore = itemsByGroup[a].reduce(
(acc, item) => (acc + filteredScores[item.id]) / 2
);
const bScore = itemsByGroup[b].reduce(
(acc, item) => (acc + filteredScores[item.id]) / 2
);
return aScore - bScore;
});
} else {
itemGroups.sort((a, b) => {
if (a === "" || b === "default") {
return -1;
}
if (b === "" || a === "default") {
return 1;
}
return a.localeCompare(b);
});
}
return [itemsByGroup, itemGroups];
}
// Helper for handling selecting items
export function handleItemSelect(
item,
selectMode,
selectedIds,
setSelectedIds,
itemsByGroup,
itemGroups
) {
if (!item) {
setSelectedIds([]);
return;
}
switch (selectMode) {
case "single":
setSelectedIds([item.id]);
break;
case "multiple":
setSelectedIds((prev) => {
if (prev.includes(item.id)) {
return prev.filter((id) => id !== item.id);
} else {
return [...prev, item.id];
}
});
break;
case "range":
// Create items array
let items = itemGroups.reduce(
(acc, group) => [...acc, ...itemsByGroup[group]],
[]
);
// Add all items inbetween the previous selected item and the current selected
if (selectedIds.length > 0) {
const mapIndex = items.findIndex((m) => m.id === item.id);
const lastIndex = items.findIndex(
(m) => m.id === selectedIds[selectedIds.length - 1]
);
let idsToAdd = [];
let idsToRemove = [];
const direction = mapIndex > lastIndex ? 1 : -1;
for (
let i = lastIndex + direction;
direction < 0 ? i >= mapIndex : i <= mapIndex;
i += direction
) {
const itemId = items[i].id;
if (selectedIds.includes(itemId)) {
idsToRemove.push(itemId);
} else {
idsToAdd.push(itemId);
}
}
setSelectedIds((prev) => {
let ids = [...prev, ...idsToAdd];
return ids.filter((id) => !idsToRemove.includes(id));
});
} else {
setSelectedIds([item.id]);
}
break;
default:
setSelectedIds([]);
}
}

View File

@ -54,3 +54,20 @@ export function logImage(url, width, height) {
export function isEmpty(obj) {
return Object.keys(obj).length === 0 && obj.constructor === Object;
}
export function keyBy(array, key) {
return array.reduce(
(prev, current) => ({ ...prev, [key ? current[key] : current]: current }),
{}
);
}
export function groupBy(array, key) {
return array.reduce((prev, current) => {
const k = current[key];
(prev[k] || (prev[k] = [])).push(current);
return prev;
}, {});
}
export const isMacLike = /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform);

View File

@ -0,0 +1,30 @@
import { useEffect, useContext } from "react";
import KeyboardContext from "../contexts/KeyboardContext";
/**
* @param {KeyboardEvent} onKeyDown
* @param {KeyboardEvent} onKeyUp
*/
function useKeyboard(onKeyDown, onKeyUp) {
const { keyEmitter } = useContext(KeyboardContext);
useEffect(() => {
if (onKeyDown) {
keyEmitter.on("keyDown", onKeyDown);
}
if (onKeyUp) {
keyEmitter.on("keyUp", onKeyUp);
}
return () => {
if (onKeyDown) {
keyEmitter.off("keyDown", onKeyDown);
}
if (onKeyUp) {
keyEmitter.off("keyUp", onKeyUp);
}
};
});
}
export default useKeyboard;

View File

@ -0,0 +1,62 @@
import { useEffect, useState } from "react";
import useImage from "use-image";
import useDataSource from "./useDataSource";
import { isEmpty } from "./shared";
import { mapSources as defaultMapSources } from "../maps";
function useMapImage(map) {
const [mapSourceMap, setMapSourceMap] = useState({});
// 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);
// Create a map source that only updates when the image is fully loaded
const [loadedMapSourceImage, setLoadedMapSourceImage] = useState();
useEffect(() => {
if (mapSourceImageStatus === "loaded") {
setLoadedMapSourceImage(mapSourceImage);
}
}, [mapSourceImage, mapSourceImageStatus]);
return [loadedMapSourceImage, mapSourceImageStatus];
}
export default useMapImage;

View File

@ -0,0 +1,110 @@
import { useRef } from "react";
import { useGesture } from "react-use-gesture";
import normalizeWheel from "normalize-wheel";
const wheelZoomSpeed = -0.001;
const touchZoomSpeed = 0.005;
const minZoom = 0.1;
const maxZoom = 10;
function useStageInteraction(
layer,
stageScale,
onStageScaleChange,
stageTranslateRef,
tool = "pan",
preventInteraction = false,
gesture = {}
) {
const isInteractingWithCanvas = useRef(false);
const pinchPreviousDistanceRef = useRef();
const pinchPreviousOriginRef = useRef();
const bind = useGesture({
...gesture,
onWheelStart: (props) => {
const { event } = props;
isInteractingWithCanvas.current =
event.target === layer.getCanvas()._canvas;
gesture.onWheelStart && gesture.onWheelStart(props);
},
onWheel: (props) => {
const { event } = props;
event.persist();
const { pixelY } = normalizeWheel(event);
if (preventInteraction || !isInteractingWithCanvas.current) {
return;
}
const newScale = Math.min(
Math.max(stageScale + pixelY * wheelZoomSpeed, minZoom),
maxZoom
);
onStageScaleChange(newScale);
gesture.onWheel && gesture.onWheel(props);
},
onPinch: (props) => {
const { da, origin, first } = props;
const [distance] = da;
const [originX, originY] = origin;
if (first) {
pinchPreviousDistanceRef.current = distance;
pinchPreviousOriginRef.current = { x: originX, y: originY };
}
// Apply scale
const distanceDelta = distance - pinchPreviousDistanceRef.current;
const originXDelta = originX - pinchPreviousOriginRef.current.x;
const originYDelta = originY - pinchPreviousOriginRef.current.y;
const newScale = Math.min(
Math.max(stageScale + distanceDelta * touchZoomSpeed, minZoom),
maxZoom
);
onStageScaleChange(newScale);
// Apply translate
const stageTranslate = stageTranslateRef.current;
const newTranslate = {
x: stageTranslate.x + originXDelta / newScale,
y: stageTranslate.y + originYDelta / newScale,
};
layer.x(newTranslate.x);
layer.y(newTranslate.y);
layer.draw();
stageTranslateRef.current = newTranslate;
pinchPreviousDistanceRef.current = distance;
pinchPreviousOriginRef.current = { x: originX, y: originY };
gesture.onPinch && gesture.onPinch(props);
},
onDragStart: (props) => {
const { event } = props;
isInteractingWithCanvas.current =
event.target === layer.getCanvas()._canvas;
gesture.onDragStart && gesture.onDragStart(props);
},
onDrag: (props) => {
const { delta, pinching } = props;
if (preventInteraction || pinching || !isInteractingWithCanvas.current) {
return;
}
const [dx, dy] = delta;
const stageTranslate = stageTranslateRef.current;
if (tool === "pan") {
const newTranslate = {
x: stageTranslate.x + dx / stageScale,
y: stageTranslate.y + dy / stageScale,
};
layer.x(newTranslate.x);
layer.y(newTranslate.y);
layer.draw();
stageTranslateRef.current = newTranslate;
}
gesture.onDrag && gesture.onDrag(props);
},
});
return bind;
}
export default useStageInteraction;

View File

@ -4,14 +4,33 @@ import {
lerp as lerpNumber,
} from "./shared";
/**
* Vector class with x and y
* @typedef {Object} Vector2
* @property {number} x - X component of the vector
* @property {number} y - Y component of the vector
*/
/**
* @param {Vector2} p
* @returns {number} Length squared of `p`
*/
export function lengthSquared(p) {
return p.x * p.x + p.y * p.y;
}
/**
* @param {Vector2} p
* @returns {number} Length of `p`
*/
export function length(p) {
return Math.sqrt(lengthSquared(p));
}
/**
* @param {Vector2} p
* @returns {Vector2} `p` normalized, if length of `p` is 0 `{x: 0, y: 0}` is returned
*/
export function normalize(p) {
const l = length(p);
if (l === 0) {
@ -20,10 +39,20 @@ export function normalize(p) {
return divide(p, l);
}
/**
* @param {Vector2} a
* @param {Vector2} b
* @returns {number} Dot product between `a` and `b`
*/
export function dot(a, b) {
return a.x * b.x + a.y * b.y;
}
/**
* @param {Vector2} a
* @param {(Vector2 | number)} b
* @returns {Vector2} a - b
*/
export function subtract(a, b) {
if (typeof b === "number") {
return { x: a.x - b, y: a.y - b };
@ -32,6 +61,11 @@ export function subtract(a, b) {
}
}
/**
* @param {Vector2} a
* @param {(Vector2 | number)} b
* @returns {Vector2} a + b
*/
export function add(a, b) {
if (typeof b === "number") {
return { x: a.x + b, y: a.y + b };
@ -40,6 +74,11 @@ export function add(a, b) {
}
}
/**
* @param {Vector2} a
* @param {(Vector2 | number)} b
* @returns {Vector2} a * b
*/
export function multiply(a, b) {
if (typeof b === "number") {
return { x: a.x * b, y: a.y * b };
@ -48,6 +87,11 @@ export function multiply(a, b) {
}
}
/**
* @param {Vector2} a
* @param {(Vector2 | number)} b
* @returns {Vector2} a / b
*/
export function divide(a, b) {
if (typeof b === "number") {
return { x: a.x / b, y: a.y / b };
@ -56,6 +100,13 @@ export function divide(a, b) {
}
}
/**
* Rotates a point around a given origin by an angle in degrees
* @param {Vector2} point Point to rotate
* @param {Vector2} origin Origin of the rotation
* @param {number} angle Angle of rotation in degrees
* @returns {Vector2} Rotated point
*/
export function rotate(point, origin, angle) {
const cos = Math.cos(toRadians(angle));
const sin = Math.sin(toRadians(angle));
@ -66,18 +117,53 @@ export function rotate(point, origin, angle) {
};
}
/**
* Rotates a direction by a given angle in degrees
* @param {Vector2} direction Direction to rotate
* @param {number} angle Angle of rotation in degrees
* @returns {Vector2} Rotated direction
*/
export function rotateDirection(direction, angle) {
return rotate(direction, { x: 0, y: 0 }, angle);
}
export function min(a) {
return a.x < a.y ? a.x : a.y;
}
export function max(a) {
return a.x > a.y ? a.x : a.y;
/**
* Returns the min of `value` and `minimum`, if `minimum` is undefined component wise min is returned instead
* @param {Vector2} a
* @param {(Vector2 | number)} [minimum] Value to compare
* @returns {(Vector2 | number)}
*/
export function min(a, minimum) {
if (minimum === undefined) {
return a.x < a.y ? a.x : a.y;
} else if (typeof minimum === "number") {
return { x: Math.min(a.x, minimum), y: Math.min(a.y, minimum) };
} else {
return { x: Math.min(a.x, minimum.x), y: Math.min(a.y, minimum.y) };
}
}
/**
* Returns the max of `a` and `maximum`, if `maximum` is undefined component wise max is returned instead
* @param {Vector2} a
* @param {(Vector2 | number)} [maximum] Value to compare
* @returns {(Vector2 | number)}
*/
export function max(a, maximum) {
if (maximum === undefined) {
return a.x > a.y ? a.x : a.y;
} else if (typeof maximum === "number") {
return { x: Math.max(a.x, maximum), y: Math.max(a.y, maximum) };
} else {
return { x: Math.max(a.x, maximum.x), y: Math.max(a.y, maximum.y) };
}
}
/**
* Rounds `p` to the nearest value of `to`
* @param {Vector2} p
* @param {Vector2} to
* @returns {Vector2}
*/
export function roundTo(p, to) {
return {
x: roundToNumber(p.x, to.x),
@ -85,14 +171,27 @@ export function roundTo(p, to) {
};
}
/**
* @param {Vector2} a
* @returns {Vector2} The component wise sign of `a`
*/
export function sign(a) {
return { x: Math.sign(a.x), y: Math.sign(a.y) };
}
/**
* @param {Vector2} a
* @returns {Vector2} The component wise absolute of `a`
*/
export function abs(a) {
return { x: Math.abs(a.x), y: Math.abs(a.y) };
}
/**
* @param {Vector2} a
* @param {(Vector2 | number)} b
* @returns {Vector2} `a` to the power of `b`
*/
export function pow(a, b) {
if (typeof b === "number") {
return { x: Math.pow(a.x, b), y: Math.pow(a.y, b) };
@ -101,10 +200,21 @@ export function pow(a, b) {
}
}
/**
* @param {Vector2} a
* @returns {number} The dot product between `a` and `a`
*/
export function dot2(a) {
return dot(a, a);
}
/**
* Clamps `a` between `min` and `max`
* @param {Vector2} a
* @param {number} min
* @param {number} max
* @returns {Vector2}
*/
export function clamp(a, min, max) {
return {
x: Math.min(Math.max(a.x, min), max),
@ -112,7 +222,14 @@ export function clamp(a, min, max) {
};
}
// https://iquilezles.org/www/articles/distfunctions2d/distfunctions2d
/**
* Calculates the distance between a point and a line segment
* See more at {@link https://www.iquilezles.org/www/articles/distfunctions2d/distfunctions2d.htm}
* @param {Vector2} p Point
* @param {Vector2} a Start of the line
* @param {Vector2} b End of the line
* @returns {Object} The distance to and the closest point on the line segment
*/
export function distanceToLine(p, a, b) {
const pa = subtract(p, a);
const ba = subtract(b, a);
@ -122,8 +239,16 @@ export function distanceToLine(p, a, b) {
return { distance, point };
}
// TODO: Fix the robustness of this to allow smoothing on fog layers
// https://www.shadertoy.com/view/MlKcDD
/**
* Calculates the distance between a point and a quadratic bezier curve
* See more at {@link https://www.shadertoy.com/view/MlKcDD}
* @todo Fix the robustness of this to allow smoothing on fog layers
* @param {Vector2} pos Position
* @param {Vector2} A Start of the curve
* @param {Vector2} B Control point of the curve
* @param {Vector2} C End of the curve
* @returns {Object} The distance to and the closest point on the curve
*/
export function distanceToQuadraticBezier(pos, A, B, C) {
let distance = 0;
let point = { x: pos.x, y: pos.y };
@ -174,6 +299,11 @@ export function distanceToQuadraticBezier(pos, A, B, C) {
return { distance: Math.sqrt(distance), point: point };
}
/**
* Calculates an axis-aligned bounding box around an array of point
* @param {Vector2[]} points
* @returns {Object}
*/
export function getBounds(points) {
let minX = Number.MAX_VALUE;
let maxX = Number.MIN_VALUE;
@ -188,9 +318,14 @@ export function getBounds(points) {
return { minX, maxX, minY, maxY };
}
// Check bounds then use ray casting algorithm
// https://en.wikipedia.org/wiki/Point_in_polygon#Ray_casting_algorithm
// https://stackoverflow.com/questions/217578/how-can-i-determine-whether-a-2d-point-is-within-a-polygon/2922778
/**
* Checks to see if a point is in a polygon using ray casting
* See more at {@link https://en.wikipedia.org/wiki/Point_in_polygon#Ray_casting_algorithm}
* and {@link https://stackoverflow.com/questions/217578/how-can-i-determine-whether-a-2d-point-is-within-a-polygon/2922778}
* @param {Vector2} p
* @param {Vector2[]} points
* @returns {boolean}
*/
export function pointInPolygon(p, points) {
const { minX, maxX, minY, maxY } = getBounds(points);
if (p.x < minX || p.x > maxX || p.y < minY || p.y > maxY) {
@ -215,7 +350,7 @@ export function pointInPolygon(p, points) {
}
/**
* Returns true if a the distance between a and b is under threshold
* Returns true if a the distance between `a` and `b` is under `threshold`
* @param {Vector2} a
* @param {Vector2} b
* @param {number} threshold
@ -228,7 +363,7 @@ export function compare(a, b, threshold) {
* Returns the distance between two vectors
* @param {Vector2} a
* @param {Vector2} b
* @param {string} type - "chebyshev" | "euclidean" | "manhattan"
* @param {string} type - `chebyshev | euclidean | manhattan`
*/
export function distance(a, b, type) {
switch (type) {
@ -243,13 +378,20 @@ export function distance(a, b, type) {
}
}
/**
* Linear interpolate between `a` and `b` by `alpha`
* @param {Vector2} a
* @param {Vector2} b
* @param {number} alpha
* @returns {Vector2}
*/
export function lerp(a, b, alpha) {
return { x: lerpNumber(a.x, b.x, alpha), y: lerpNumber(a.y, b.y, alpha) };
}
/**
* Returns total length of a an array of points treated as a path
* @param {Array} points the array of points in the path
* @param {Vector2[]} points the array of points in the path
*/
export function pathLength(points) {
let l = 0;
@ -262,7 +404,7 @@ export function pathLength(points) {
/**
* Resample a path to n number of evenly distributed points
* based off of http://depts.washington.edu/acelab/proj/dollar/index.html
* @param {Array} points the points to resample
* @param {Vector2[]} points the points to resample
* @param {number} n the number of new points
*/
export function resample(points, n) {

19
src/icons/DonateIcon.js Normal file
View File

@ -0,0 +1,19 @@
import React from "react";
function DonateIcon() {
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="M13.35 20.13c-.76.69-1.93.69-2.69-.01l-.11-.1C5.3 15.27 1.87 12.16 2 8.28c.06-1.7.93-3.33 2.34-4.29 2.64-1.8 5.9-.96 7.66 1.1 1.76-2.06 5.02-2.91 7.66-1.1 1.41.96 2.28 2.59 2.34 4.29.14 3.88-3.3 6.99-8.55 11.76l-.1.09z" />
</svg>
);
}
export default DonateIcon;

18
src/icons/EditTileIcon.js Normal file
View File

@ -0,0 +1,18 @@
import React from "react";
function EditTileIcon() {
return (
<svg
width="24"
height="24"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentcolor"
>
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M3 17.46v3.04c0 .28.22.5.5.5h3.04c.13 0 .26-.05.35-.15L17.81 9.94l-3.75-3.75L3.15 17.1c-.1.1-.15.22-.15.36zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" />
</svg>
);
}
export default EditTileIcon;

18
src/icons/GridOffIcon.js Normal file
View File

@ -0,0 +1,18 @@
import React from "react";
function GridOffIcon() {
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="M8 4v.89l2 2V4h4v4h-2.89l2 2H14v.89l2 2V10h4v4h-2.89l2 2H20v.89l2 2V4c0-1.1-.9-2-2-2H5.11l2 2H8zm8 0h3c.55 0 1 .45 1 1v3h-4V4zm6.16 17.88L2.12 1.84c-.39-.39-1.02-.39-1.41 0-.39.39-.39 1.02 0 1.41L2 4.55V20c0 1.1.9 2 2 2h15.45l1.3 1.3c.39.39 1.02.39 1.41 0 .39-.39.39-1.03 0-1.42zM10 12.55L11.45 14H10v-1.45zm-6-6L5.45 8H4V6.55zM8 20H5c-.55 0-1-.45-1-1v-3h4v4zm0-6H4v-4h3.45l.55.55V14zm6 6h-4v-4h3.45l.55.55V20zm2 0v-1.45L17.45 20H16z" />
</svg>
);
}
export default GridOffIcon;

18
src/icons/GridOnIcon.js Normal file
View File

@ -0,0 +1,18 @@
import React from "react";
function GridOnIcon() {
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="M20 2H4c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM8 20H5c-.55 0-1-.45-1-1v-3h4v4zm0-6H4v-4h4v4zm0-6H4V5c0-.55.45-1 1-1h3v4zm6 12h-4v-4h4v4zm0-6h-4v-4h4v4zm0-6h-4V4h4v4zm5 12h-3v-4h4v3c0 .55-.45 1-1 1zm1-6h-4v-4h4v4zm0-6h-4V4h3c.55 0 1 .45 1 1v3z" />
</svg>
);
}
export default GridOnIcon;

18
src/icons/GroupIcon.js Normal file
View File

@ -0,0 +1,18 @@
import React from "react";
function GroupIcon() {
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="M20 6h-8l-1.41-1.41C10.21 4.21 9.7 4 9.17 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm-1 12H5c-.55 0-1-.45-1-1V9c0-.55.45-1 1-1h14c.55 0 1 .45 1 1v8c0 .55-.45 1-1 1z" />
</svg>
);
}
export default GroupIcon;

18
src/icons/SearchIcon.js Normal file
View File

@ -0,0 +1,18 @@
import React from "react";
function SearchIcon() {
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.5 14h-.79l-.28-.27c1.2-1.4 1.82-3.31 1.48-5.34-.47-2.78-2.79-5-5.59-5.34-4.23-.52-7.79 3.04-7.27 7.27.34 2.8 2.56 5.12 5.34 5.59 2.03.34 3.94-.28 5.34-1.48l.27.28v.79l4.25 4.25c.41.41 1.08.41 1.49 0 .41-.41.41-1.08 0-1.49L15.5 14zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z" />
</svg>
);
}
export default SearchIcon;

View File

@ -1,6 +1,6 @@
import React from "react";
function SelectMapIcon() {
function SelectDiceIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
@ -15,4 +15,4 @@ function SelectMapIcon() {
);
}
export default SelectMapIcon;
export default SelectDiceIcon;

View File

@ -0,0 +1,18 @@
import React from "react";
function SelectMultipleIcon() {
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="M3 5h2V3c-1.1 0-2 .9-2 2zm0 8h2v-2H3v2zm4 8h2v-2H7v2zM3 9h2V7H3v2zm10-6h-2v2h2V3zm6 0v2h2c0-1.1-.9-2-2-2zM5 21v-2H3c0 1.1.9 2 2 2zm-2-4h2v-2H3v2zM9 3H7v2h2V3zm2 18h2v-2h-2v2zm8-8h2v-2h-2v2zm0 8c1.1 0 2-.9 2-2h-2v2zm0-12h2V7h-2v2zm0 8h2v-2h-2v2zm-4 4h2v-2h-2v2zm0-16h2V3h-2v2zM8 17h8c.55 0 1-.45 1-1V8c0-.55-.45-1-1-1H8c-.55 0-1 .45-1 1v8c0 .55.45 1 1 1zm1-8h6v6H9V9z" />
</svg>
);
}
export default SelectMultipleIcon;

View File

@ -0,0 +1,18 @@
import React from "react";
function SelectSingleIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 0 24 24"
width="24"
fill="currentcolor"
>
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M18 4H6c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm-1 14H7c-.55 0-1-.45-1-1V7c0-.55.45-1 1-1h10c.55 0 1 .45 1 1v10c0 .55-.45 1-1 1z" />
</svg>
);
}
export default SelectSingleIcon;

View File

@ -21,8 +21,11 @@ export const mapSources = {
export const maps = Object.keys(mapSources).map((key) => ({
key,
name: Case.capital(key),
gridX: 22,
gridY: 22,
grid: {
size: { x: 22, y: 22 },
inset: { topLeft: { x: 0, y: 0 }, bottomRight: { x: 1, y: 1 } },
type: "square",
},
width: 1024,
height: 1024,
type: "default",

35
src/ml/Model.js Normal file
View File

@ -0,0 +1,35 @@
import blobToBuffer from "../helpers/blobToBuffer";
class Model {
constructor(config, weightsMapping) {
this.config = config;
this.weightsMapping = weightsMapping;
}
async load() {
// Load weights from the manifest then fetch them into an ArrayBuffer
let buffers = [];
const manifest = this.config.weightsManifest[0];
for (let path of manifest.paths) {
const url = this.weightsMapping[path];
const response = await fetch(url);
const buffer = await response.arrayBuffer();
buffers.push(buffer);
}
const merged = new Blob(buffers);
const weightData = await blobToBuffer(merged);
const weightSpecs = manifest.weights;
const modelArtifacts = {
modelTopology: this.config.modelTopology,
format: this.config.format,
generatedBy: this.config.generatedBy,
convertedBy: this.config.convertedBy,
weightData,
weightSpecs,
};
return modelArtifacts;
}
}
export default Model;

View File

@ -0,0 +1,30 @@
import * as tf from "@tensorflow/tfjs";
import Model from "../Model";
import config from "./model.json";
import weights from "./group1-shard1of1.bin";
class GridSizeModel extends Model {
// Store model as static to prevent extra network requests
static model;
constructor() {
super(config, { "group1-shard1of1.bin": weights });
}
async predict(imageData) {
if (!GridSizeModel.model) {
GridSizeModel.model = await tf.loadLayersModel(this);
}
const prediction = tf.tidy(() => {
const image = tf.browser.fromPixels(imageData, 1).toFloat();
const normalized = image.div(tf.scalar(255.0));
const batched = tf.expandDims(normalized);
return GridSizeModel.model.predict(batched);
});
const data = await prediction.data();
return data[0];
}
}
export default GridSizeModel;

Binary file not shown.

1
src/ml/gridSize/model.json Executable file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,46 @@
import React from "react";
import { Box, Label, Flex, Button, Text } from "theme-ui";
import Modal from "../components/Modal";
function ConfirmModal({
isOpen,
onRequestClose,
onConfirm,
confirmText,
label,
description,
}) {
return (
<Modal
isOpen={isOpen}
onRequestClose={onRequestClose}
style={{ maxWidth: "300px" }}
>
<Box>
<Label py={2}>{label}</Label>
{description && (
<Text as="p" mb={2} variant="caption">
{description}
</Text>
)}
<Flex py={2}>
<Button sx={{ flexGrow: 1 }} m={1} ml={0} onClick={onRequestClose}>
Cancel
</Button>
<Button sx={{ flexGrow: 1 }} m={1} mr={0} onClick={onConfirm}>
{confirmText}
</Button>
</Flex>
</Box>
</Modal>
);
}
ConfirmModal.defaultProps = {
label: "Are you sure?",
description: "",
confirmText: "Yes",
};
export default ConfirmModal;

View File

@ -1,134 +0,0 @@
import React, { useState, useEffect } from "react";
import { Box, Label, Button, Flex, Radio, Text } from "theme-ui";
import { useLocation, useHistory } from "react-router-dom";
import Modal from "../components/Modal";
import LoadingOverlay from "../components/LoadingOverlay";
import Banner from "../components/Banner";
const skus = [
{ sku: "sku_H6DhHS1MimRPR9", price: "$5.00 AUD", name: "Small" },
{ sku: "sku_H6DhiQfHUkYUKd", price: "$15.00 AUD", name: "Medium" },
{ sku: "sku_H6DhbO2oUn9Sda", price: "$30.00 AUD", name: "Large" },
];
function DonationModal({ isOpen, onRequestClose }) {
// Handle callback from stripe
const location = useLocation();
const history = useHistory();
const query = new URLSearchParams(location.search);
const hasDonated = query.has("donated");
const showDonationForm = isOpen || query.get("donated") === "false";
const [loading, setLoading] = useState(showDonationForm);
const [error, setError] = useState(null);
const [stripe, setStripe] = useState();
useEffect(() => {
if (showDonationForm) {
import("@stripe/stripe-js").then(({ loadStripe }) => {
loadStripe("pk_live_MJjzi5djj524Y7h3fL5PNh4e00a852XD51")
.then((stripe) => {
setStripe(stripe);
setLoading(false);
})
.catch((err) => {
setError(err.message);
setLoading(false);
});
});
}
}, [showDonationForm]);
function handleClose() {
if (hasDonated) {
history.push(location.pathname);
}
onRequestClose();
}
function handleSubmit(event) {
event.preventDefault();
if (!stripe) {
return;
}
setLoading(true);
stripe
.redirectToCheckout({
items: [{ sku: selectedSku, quantity: 1 }],
successUrl: `${window.location.href}?donated=true`,
cancelUrl: `${window.location.href}?donated=false`,
submitType: "donate",
})
.then((response) => {
setLoading(false);
if (response.error) {
setError(response.error.message);
}
});
}
const [selectedSku, setSelectedSku] = useState("sku_H6DhiQfHUkYUKd");
function handleSkuChange(event) {
setSelectedSku(event.target.value);
}
const donationSuccessful = (
<Box>
<Text my={2} variant="heading" as="h1" sx={{ fontSize: 3 }}>
Thanks for donating! ʕʔ
</Text>
</Box>
);
const donationForm = (
<Box as="form" onSubmit={handleSubmit}>
<Label py={2}>Support us with a donation</Label>
<Text as="p" mb={2} variant="caption">
One time donation
</Text>
{skus.map((sku) => (
<Label key={sku.sku}>
<Radio
name="donation"
checked={selectedSku === sku.sku}
value={sku.sku}
onChange={handleSkuChange}
/>
{sku.name} - {sku.price}
</Label>
))}
<Flex mt={3}>
<Button sx={{ flexGrow: 1 }} disabled={!stripe || loading}>
Donate
</Button>
</Flex>
</Box>
);
return (
<Modal isOpen={isOpen || hasDonated} onRequestClose={handleClose}>
<Flex
sx={{
flexDirection: "column",
justifyContent: "center",
maxWidth: "300px",
flexGrow: 1,
}}
m={2}
>
{query.get("donated") === "true" ? donationSuccessful : donationForm}
{loading && <LoadingOverlay />}
<Banner isOpen={!!error} onRequestClose={() => setError(null)}>
<Box p={1}>
<Text as="p" variant="body2">
{error}
</Text>
</Box>
</Banner>
</Flex>
</Modal>
);
}
export default DonationModal;

View File

@ -0,0 +1,65 @@
import React, { useEffect, useState } from "react";
import { Box, Button, Label, Flex } from "theme-ui";
import Modal from "../components/Modal";
import Select from "../components/Select";
function EditGroupModal({
isOpen,
onRequestClose,
onChange,
groups,
defaultGroup,
}) {
const [value, setValue] = useState();
const [options, setOptions] = useState([]);
useEffect(() => {
if (defaultGroup) {
setValue({ value: defaultGroup, label: defaultGroup });
} else {
setValue();
}
}, [defaultGroup]);
useEffect(() => {
setOptions(groups.map((group) => ({ value: group, label: group })));
}, [groups]);
function handleCreate(group) {
const newOption = { value: group, label: group };
setValue(newOption);
setOptions((prev) => [...prev, newOption]);
}
function handleChange() {
onChange(value ? value.value : "");
}
return (
<Modal
isOpen={isOpen}
onRequestClose={onRequestClose}
style={{ overflow: "visible" }}
>
<Box onSubmit={handleChange} sx={{ width: "300px" }}>
<Label py={2}>Select or add a group</Label>
<Select
creatable
options={options}
value={value}
onChange={setValue}
onCreateOption={handleCreate}
placeholder=""
/>
<Flex py={2}>
<Button sx={{ flexGrow: 1 }} onClick={handleChange}>
Save
</Button>
</Flex>
</Box>
</Modal>
);
}
export default EditGroupModal;

133
src/modals/EditMapModal.js Normal file
View File

@ -0,0 +1,133 @@
import React, { useState, useContext } from "react";
import { Button, Flex, Label } from "theme-ui";
import Modal from "../components/Modal";
import MapSettings from "../components/map/MapSettings";
import MapEditor from "../components/map/MapEditor";
import MapDataContext from "../contexts/MapDataContext";
import { isEmpty } from "../helpers/shared";
import { getMapDefaultInset } from "../helpers/map";
function EditMapModal({ isOpen, onDone, map, mapState }) {
const { updateMap, updateMapState } = useContext(MapDataContext);
function handleClose() {
setMapSettingChanges({});
setMapStateSettingChanges({});
onDone();
}
async function handleSave() {
await applyMapChanges();
onDone();
}
/**
* Map settings
*/
// Local cache of map setting changes
// Applied when done is clicked or map selection is changed
const [mapSettingChanges, setMapSettingChanges] = useState({});
const [mapStateSettingChanges, setMapStateSettingChanges] = useState({});
function handleMapSettingsChange(key, value) {
setMapSettingChanges((prevChanges) => ({
...prevChanges,
[key]: value,
lastModified: Date.now(),
}));
}
function handleMapStateSettingsChange(key, value) {
setMapStateSettingChanges((prevChanges) => ({
...prevChanges,
[key]: value,
}));
}
async function applyMapChanges() {
if (!isEmpty(mapSettingChanges) || !isEmpty(mapStateSettingChanges)) {
// Ensure grid values are positive
let verifiedChanges = { ...mapSettingChanges };
if ("grid" in verifiedChanges && "size" in verifiedChanges.grid) {
verifiedChanges.grid.size.x = verifiedChanges.grid.size.x || 1;
verifiedChanges.grid.size.y = verifiedChanges.grid.size.y || 1;
}
// Ensure inset isn't flipped
if ("grid" in verifiedChanges && "inset" in verifiedChanges.grid) {
const inset = verifiedChanges.grid.inset;
if (
inset.topLeft.x > inset.bottomRight.x ||
inset.topLeft.y > inset.bottomRight.y
) {
if ("size" in verifiedChanges.grid) {
verifiedChanges.grid.inset = getMapDefaultInset(
map.width,
map.height,
verifiedChanges.grid.size.x,
verifiedChanges.grid.size.y
);
} else {
verifiedChanges.grid.inset = getMapDefaultInset(
map.width,
map.height,
map.grid.size.x,
map.grid.size.y
);
}
}
}
await updateMap(map.id, mapSettingChanges);
await updateMapState(map.id, mapStateSettingChanges);
setMapSettingChanges({});
setMapStateSettingChanges({});
}
}
const selectedMapWithChanges = map && {
...map,
...mapSettingChanges,
};
const selectedMapStateWithChanges = mapState && {
...mapState,
...mapStateSettingChanges,
};
const [showMoreSettings, setShowMoreSettings] = useState(false);
return (
<Modal
isOpen={isOpen}
onRequestClose={handleClose}
style={{ maxWidth: "542px", width: "calc(100% - 16px)" }}
>
<Flex
sx={{
flexDirection: "column",
}}
>
<Label pt={2} pb={1}>
Edit map
</Label>
<MapEditor
map={selectedMapWithChanges}
onSettingsChange={handleMapSettingsChange}
/>
<MapSettings
map={selectedMapWithChanges}
mapState={selectedMapStateWithChanges}
onSettingsChange={handleMapSettingsChange}
onStateSettingsChange={handleMapStateSettingsChange}
showMore={showMoreSettings}
onShowMoreChange={setShowMoreSettings}
/>
<Button onClick={handleSave}>Save</Button>
</Flex>
</Modal>
);
}
export default EditMapModal;

View File

@ -0,0 +1,77 @@
import React, { useState, useContext } from "react";
import { Button, Flex, Label } from "theme-ui";
import Modal from "../components/Modal";
import TokenSettings from "../components/token/TokenSettings";
import TokenPreview from "../components/token/TokenPreview";
import TokenDataContext from "../contexts/TokenDataContext";
import { isEmpty } from "../helpers/shared";
function EditTokenModal({ isOpen, onDone, token }) {
const { updateToken } = useContext(TokenDataContext);
function handleClose() {
setTokenSettingChanges({});
onDone();
}
async function handleSave() {
await applyTokenChanges();
onDone();
}
const [tokenSettingChanges, setTokenSettingChanges] = useState({});
function handleTokenSettingsChange(key, value) {
setTokenSettingChanges((prevChanges) => ({ ...prevChanges, [key]: value }));
}
async function applyTokenChanges() {
if (token && !isEmpty(tokenSettingChanges)) {
// Ensure size value is positive
let verifiedChanges = { ...tokenSettingChanges };
if ("defaultSize" in verifiedChanges) {
verifiedChanges.defaultSize = verifiedChanges.defaultSize || 1;
}
await updateToken(token.id, verifiedChanges);
setTokenSettingChanges({});
}
}
const selectedTokenWithChanges = {
...token,
...tokenSettingChanges,
};
return (
<Modal
isOpen={isOpen}
onRequestClose={handleClose}
style={{
maxWidth: "542px",
width: "calc(100% - 16px)",
}}
>
<Flex
sx={{
flexDirection: "column",
}}
>
<Label pt={2} pb={1}>
Edit token
</Label>
<TokenPreview token={selectedTokenWithChanges} />
<TokenSettings
token={selectedTokenWithChanges}
onSettingsChange={handleTokenSettingsChange}
/>
<Button onClick={handleSave}>Save</Button>
</Flex>
</Modal>
);
}
export default EditTokenModal;

View File

@ -29,7 +29,7 @@ function SelectDiceModal({ isOpen, onRequestClose, onDone, defaultDice }) {
onDone={onDone}
/>
<Button my={2} variant="primary" onClick={() => onDone(selectedDice)}>
Done
Select
</Button>
</Flex>
</Modal>

View File

@ -1,29 +1,32 @@
import React, { useRef, useState, useContext } from "react";
import React, { useRef, useState, useContext, useEffect } from "react";
import { Button, Flex, Label } from "theme-ui";
import shortid from "shortid";
import Case from "case";
import EditMapModal from "./EditMapModal";
import EditGroupModal from "./EditGroupModal";
import ConfirmModal from "./ConfirmModal";
import Modal from "../components/Modal";
import MapTiles from "../components/map/MapTiles";
import MapSettings from "../components/map/MapSettings";
import ImageDrop from "../components/ImageDrop";
import LoadingOverlay from "../components/LoadingOverlay";
import blobToBuffer from "../helpers/blobToBuffer";
import useKeyboard from "../helpers/useKeyboard";
import { resizeImage } from "../helpers/image";
import { useSearch, useGroup, handleItemSelect } from "../helpers/select";
import { getMapDefaultInset, getGridSize } from "../helpers/map";
import MapDataContext from "../contexts/MapDataContext";
import AuthContext from "../contexts/AuthContext";
import { isEmpty } from "../helpers/shared";
import { resizeImage } from "../helpers/image";
const defaultMapSize = 22;
const defaultMapProps = {
// Grid type
// TODO: add support for hex horizontal and hex vertical
gridType: "grid",
showGrid: false,
snapToGrid: true,
quality: "original",
group: "",
};
const mapResolutions = [
@ -46,23 +49,45 @@ function SelectMapModal({
ownedMaps,
mapStates,
addMap,
removeMap,
removeMaps,
resetMap,
updateMap,
updateMapState,
updateMaps,
} = useContext(MapDataContext);
const [imageLoading, setImageLoading] = useState(false);
/**
* Search
*/
const [search, setSearch] = useState("");
const [filteredMaps, filteredMapScores] = useSearch(ownedMaps, search);
// The map selected in the modal
const [selectedMapId, setSelectedMapId] = useState(null);
function handleSearchChange(event) {
setSearch(event.target.value);
}
const selectedMap = ownedMaps.find((map) => map.id === selectedMapId);
const selectedMapState = mapStates.find(
(state) => state.mapId === selectedMapId
/**
* Group
*/
const [isGroupModalOpen, setIsGroupModalOpen] = useState(false);
async function handleMapsGroup(group) {
setIsGroupModalOpen(false);
updateMaps(selectedMapIds, { group });
}
const [mapsByGroup, mapGroups] = useGroup(
ownedMaps,
filteredMaps,
!!search,
filteredMapScores
);
/**
* Image Upload
*/
const fileInputRef = useRef();
const [imageLoading, setImageLoading] = useState(false);
async function handleImagesUpload(files) {
for (let file of files) {
@ -76,34 +101,6 @@ function SelectMapModal({
if (!file) {
return Promise.reject();
}
let fileGridX = defaultMapSize;
let fileGridY = defaultMapSize;
let name = "Unknown Map";
if (file.name) {
// TODO: match all not supported on safari, find alternative
if (file.name.matchAll) {
// Match against a regex to find the grid size in the file name
// e.g. Cave 22x23 will return [["22x22", "22", "x", "23"]]
const gridMatches = [...file.name.matchAll(/(\d+) ?(x|X) ?(\d+)/g)];
if (gridMatches.length > 0) {
const lastMatch = gridMatches[gridMatches.length - 1];
const matchX = parseInt(lastMatch[1]);
const matchY = parseInt(lastMatch[3]);
if (!isNaN(matchX) && !isNaN(matchY)) {
fileGridX = matchX;
fileGridY = matchY;
}
}
}
// Remove file extension
name = file.name.replace(/\.[^/.]+$/, "");
// Removed grid size expression
name = name.replace(/(\[ ?|\( ?)?\d+ ?(x|X) ?\d+( ?\]| ?\))?/, "");
// Clean string
name = name.replace(/ +/g, " ");
name = name.trim();
}
let image = new Image();
setImageLoading(true);
@ -115,6 +112,39 @@ function SelectMapModal({
return new Promise((resolve, reject) => {
image.onload = async function () {
// Find name and grid size
let gridSize;
let name = "Unknown Map";
if (file.name) {
if (file.name.matchAll) {
// Match against a regex to find the grid size in the file name
// e.g. Cave 22x23 will return [["22x22", "22", "x", "23"]]
const gridMatches = [...file.name.matchAll(/(\d+) ?(x|X) ?(\d+)/g)];
if (gridMatches.length > 0) {
const lastMatch = gridMatches[gridMatches.length - 1];
const matchX = parseInt(lastMatch[1]);
const matchY = parseInt(lastMatch[3]);
if (!isNaN(matchX) && !isNaN(matchY)) {
gridSize = { x: matchX, y: matchY };
}
}
}
if (!gridSize) {
gridSize = await getGridSize(image);
}
// Remove file extension
name = file.name.replace(/\.[^/.]+$/, "");
// Removed grid size expression
name = name.replace(/(\[ ?|\( ?)?\d+ ?(x|X) ?\d+( ?\]| ?\))?/, "");
// Clean string
name = name.replace(/ +/g, " ");
name = name.trim();
// Capitalize and remove underscores
name = Case.capital(name);
}
// Create resolutions
const resolutions = {};
for (let resolution of mapResolutions) {
@ -142,8 +172,16 @@ function SelectMapModal({
resolutions,
name,
type: "file",
gridX: fileGridX,
gridY: fileGridY,
grid: {
size: gridSize,
inset: getMapDefaultInset(
image.width,
image.height,
gridSize.x,
gridSize.y
),
type: "square",
},
width: image.width,
height: image.height,
id: shortid.generate(),
@ -168,43 +206,67 @@ function SelectMapModal({
}
}
/**
* Map Controls
*/
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
// The map selected in the modal
const [selectedMapIds, setSelectedMapIds] = useState([]);
const selectedMaps = ownedMaps.filter((map) =>
selectedMapIds.includes(map.id)
);
const selectedMapStates = mapStates.filter((state) =>
selectedMapIds.includes(state.mapId)
);
async function handleMapAdd(map) {
await addMap(map);
setSelectedMapId(map.id);
setSelectedMapIds([map.id]);
}
async function handleMapRemove(id) {
await removeMap(id);
setMapSettingChanges({});
setMapStateSettingChanges({});
setSelectedMapId(null);
const [isMapsRemoveModalOpen, setIsMapsRemoveModalOpen] = useState(false);
async function handleMapsRemove() {
setIsMapsRemoveModalOpen(false);
await removeMaps(selectedMapIds);
setSelectedMapIds([]);
// Removed the map from the map screen if needed
if (currentMap && currentMap.id === selectedMapId) {
if (currentMap && selectedMapIds.includes(currentMap.id)) {
onMapChange(null, null);
}
}
async function handleMapSelect(map) {
await applyMapChanges();
if (map) {
setSelectedMapId(map.id);
} else {
setSelectedMapId(null);
const [isMapsResetModalOpen, setIsMapsResetModalOpen] = useState(false);
async function handleMapsReset() {
setIsMapsResetModalOpen(false);
for (let id of selectedMapIds) {
const newState = await resetMap(id);
// Reset the state of the current map if needed
if (currentMap && currentMap.id === id) {
onMapStateChange(newState);
}
}
}
async function handleMapReset(id) {
const newState = await resetMap(id);
// Reset the state of the current map if needed
if (currentMap && currentMap.id === selectedMapId) {
onMapStateChange(newState);
}
// Either single, multiple or range
const [selectMode, setSelectMode] = useState("single");
function handleMapSelect(map) {
handleItemSelect(
map,
selectMode,
selectedMapIds,
setSelectedMapIds,
mapsByGroup,
mapGroups
);
}
/**
* Modal Controls
*/
async function handleClose() {
if (selectedMapId) {
await applyMapChanges();
}
onDone();
}
@ -212,15 +274,11 @@ function SelectMapModal({
if (imageLoading) {
return;
}
if (selectedMapId) {
await applyMapChanges();
if (selectedMapIds.length === 1) {
// Update last used for cache invalidation
const lastUsed = Date.now();
await updateMap(selectedMapId, { lastUsed });
onMapChange(
{ ...selectedMapWithChanges, lastUsed },
selectedMapStateWithChanges
);
await updateMap(selectedMapIds[0], { lastUsed });
onMapChange({ ...selectedMaps[0], lastUsed }, selectedMapStates[0]);
} else {
onMapChange(null, null);
}
@ -228,58 +286,54 @@ function SelectMapModal({
}
/**
* Map settings
* Shortcuts
*/
const [showMoreSettings, setShowMoreSettings] = useState(false);
// Local cache of map setting changes
// Applied when done is clicked or map selection is changed
const [mapSettingChanges, setMapSettingChanges] = useState({});
const [mapStateSettingChanges, setMapStateSettingChanges] = useState({});
function handleMapSettingsChange(key, value) {
setMapSettingChanges((prevChanges) => ({
...prevChanges,
[key]: value,
lastModified: Date.now(),
}));
}
function handleMapStateSettingsChange(key, value) {
setMapStateSettingChanges((prevChanges) => ({
...prevChanges,
[key]: value,
}));
}
async function applyMapChanges() {
if (
selectedMapId &&
(!isEmpty(mapSettingChanges) || !isEmpty(mapStateSettingChanges))
) {
// Ensure grid values are positive
let verifiedChanges = { ...mapSettingChanges };
if ("gridX" in verifiedChanges) {
verifiedChanges.gridX = verifiedChanges.gridX || 1;
function handleKeyDown({ key }) {
if (!isOpen) {
return;
}
if (key === "Shift") {
setSelectMode("range");
}
if (key === "Control" || key === "Meta") {
setSelectMode("multiple");
}
if (key === "Backspace" || key === "Delete") {
// Selected maps and none are default
if (
selectedMapIds.length > 0 &&
!selectedMaps.some((map) => map.type === "default")
) {
setIsMapsRemoveModalOpen(true);
}
if ("gridY" in verifiedChanges) {
verifiedChanges.gridY = verifiedChanges.gridY || 1;
}
await updateMap(selectedMapId, verifiedChanges);
await updateMapState(selectedMapId, mapStateSettingChanges);
setMapSettingChanges({});
setMapStateSettingChanges({});
}
}
const selectedMapWithChanges = selectedMap && {
...selectedMap,
...mapSettingChanges,
};
const selectedMapStateWithChanges = selectedMapState && {
...selectedMapState,
...mapStateSettingChanges,
};
function handleKeyUp({ key }) {
if (!isOpen) {
return;
}
if (key === "Shift" && selectMode === "range") {
setSelectMode("single");
}
if ((key === "Control" || key === "Meta") && selectMode === "multiple") {
setSelectMode("single");
}
}
useKeyboard(handleKeyDown, handleKeyUp);
// Set select mode to single when alt+tabing
useEffect(() => {
function handleBlur() {
setSelectMode("single");
}
window.addEventListener("blur", handleBlur);
return () => {
window.removeEventListener("blur", handleBlur);
};
}, []);
return (
<Modal
@ -305,33 +359,74 @@ function SelectMapModal({
Select or import a map
</Label>
<MapTiles
maps={ownedMaps}
maps={mapsByGroup}
groups={mapGroups}
onMapAdd={openImageDialog}
onMapRemove={handleMapRemove}
selectedMap={selectedMapWithChanges}
selectedMapState={selectedMapStateWithChanges}
onMapEdit={() => setIsEditModalOpen(true)}
onMapsReset={() => setIsMapsResetModalOpen(true)}
onMapsRemove={() => setIsMapsRemoveModalOpen(true)}
selectedMaps={selectedMaps}
selectedMapStates={selectedMapStates}
onMapSelect={handleMapSelect}
onMapReset={handleMapReset}
onDone={handleDone}
/>
<MapSettings
map={selectedMapWithChanges}
mapState={selectedMapStateWithChanges}
onSettingsChange={handleMapSettingsChange}
onStateSettingsChange={handleMapStateSettingsChange}
showMore={showMoreSettings}
onShowMoreChange={setShowMoreSettings}
selectMode={selectMode}
onSelectModeChange={setSelectMode}
search={search}
onSearchChange={handleSearchChange}
onMapsGroup={() => setIsGroupModalOpen(true)}
/>
<Button
variant="primary"
disabled={imageLoading}
disabled={imageLoading || selectedMapIds.length !== 1}
onClick={handleDone}
mt={2}
>
Done
Select
</Button>
</Flex>
</ImageDrop>
{imageLoading && <LoadingOverlay bg="overlay" />}
<EditMapModal
isOpen={isEditModalOpen}
onDone={() => setIsEditModalOpen(false)}
map={selectedMaps.length === 1 && selectedMaps[0]}
mapState={selectedMapStates.length === 1 && selectedMapStates[0]}
/>
<EditGroupModal
isOpen={isGroupModalOpen}
onChange={handleMapsGroup}
groups={mapGroups.filter(
(group) => group !== "" && group !== "default"
)}
onRequestClose={() => setIsGroupModalOpen(false)}
// Select the default group by testing whether all selected maps are the same
defaultGroup={
selectedMaps.length > 0 &&
selectedMaps
.map((map) => map.group)
.reduce((prev, curr) => (prev === curr ? curr : undefined))
}
/>
<ConfirmModal
isOpen={isMapsResetModalOpen}
onRequestClose={() => setIsMapsResetModalOpen(false)}
onConfirm={handleMapsReset}
confirmText="Reset"
label={`Reset ${selectedMapIds.length} Map${
selectedMapIds.length > 1 ? "s" : ""
}`}
description="This will remove all fog, drawings and tokens from the selected maps."
/>
<ConfirmModal
isOpen={isMapsRemoveModalOpen}
onRequestClose={() => setIsMapsRemoveModalOpen(false)}
onConfirm={handleMapsRemove}
confirmText="Remove"
label={`Remove ${selectedMapIds.length} Map${
selectedMapIds.length > 1 ? "s" : ""
}`}
description="This operation cannot be undone."
/>
</Modal>
);
}

View File

@ -1,43 +1,69 @@
import React, { useRef, useContext, useState } from "react";
import React, { useRef, useContext, useState, useEffect } from "react";
import { Flex, Label, Button } from "theme-ui";
import shortid from "shortid";
import Case from "case";
import EditTokenModal from "./EditTokenModal";
import EditGroupModal from "./EditGroupModal";
import ConfirmModal from "./ConfirmModal";
import Modal from "../components/Modal";
import ImageDrop from "../components/ImageDrop";
import TokenTiles from "../components/token/TokenTiles";
import TokenSettings from "../components/token/TokenSettings";
import blobToBuffer from "../helpers/blobToBuffer";
import useKeyboard from "../helpers/useKeyboard";
import { useSearch, useGroup, handleItemSelect } from "../helpers/select";
import TokenDataContext from "../contexts/TokenDataContext";
import AuthContext from "../contexts/AuthContext";
import { isEmpty } from "../helpers/shared";
function SelectTokensModal({ isOpen, onRequestClose }) {
const { userId } = useContext(AuthContext);
const { ownedTokens, addToken, removeToken, updateToken } = useContext(
const { ownedTokens, addToken, removeTokens, updateTokens } = useContext(
TokenDataContext
);
const fileInputRef = useRef();
const [imageLoading, setImageLoading] = useState(false);
/**
* Search
*/
const [search, setSearch] = useState("");
const [filteredTokens, filteredTokenScores] = useSearch(ownedTokens, search);
const [selectedTokenId, setSelectedTokenId] = useState(null);
const selectedToken = ownedTokens.find(
(token) => token.id === selectedTokenId
function handleSearchChange(event) {
setSearch(event.target.value);
}
/**
* Group
*/
const [isGroupModalOpen, setIsGroupModalOpen] = useState(false);
async function handleTokensGroup(group) {
setIsGroupModalOpen(false);
await updateTokens(selectedTokenIds, { group });
}
const [tokensByGroup, tokenGroups] = useGroup(
ownedTokens,
filteredTokens,
!!search,
filteredTokenScores
);
/**
* Image Upload
*/
const fileInputRef = useRef();
const [imageLoading, setImageLoading] = useState(false);
function openImageDialog() {
if (fileInputRef.current) {
fileInputRef.current.click();
}
}
function handleTokenAdd(token) {
addToken(token);
setSelectedTokenId(token.id);
}
async function handleImagesUpload(files) {
for (let file of files) {
await handleImageUpload(file);
@ -56,6 +82,8 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
// Clean string
name = name.replace(/ +/g, " ");
name = name.trim();
// Capitalize and remove underscores
name = Case.capital(name);
}
let image = new Image();
setImageLoading(true);
@ -80,6 +108,7 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
defaultSize: 1,
category: "character",
hideInSidebar: false,
group: "",
});
setImageLoading(false);
resolve();
@ -89,52 +118,99 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
});
}
async function handleTokenSelect(token) {
await applyTokenChanges();
setSelectedTokenId(token.id);
/**
* Token controls
*/
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [selectedTokenIds, setSelectedTokenIds] = useState([]);
const selectedTokens = ownedTokens.filter((token) =>
selectedTokenIds.includes(token.id)
);
function handleTokenAdd(token) {
addToken(token);
setSelectedTokenIds([token.id]);
}
async function handleTokenRemove(id) {
await removeToken(id);
setSelectedTokenId(null);
setTokenSettingChanges({});
const [isTokensRemoveModalOpen, setIsTokensRemoveModalOpen] = useState(false);
async function handleTokensRemove() {
setIsTokensRemoveModalOpen(false);
await removeTokens(selectedTokenIds);
setSelectedTokenIds([]);
}
async function handleTokensHide(hideInSidebar) {
await updateTokens(selectedTokenIds, { hideInSidebar });
}
// Either single, multiple or range
const [selectMode, setSelectMode] = useState("single");
async function handleTokenSelect(token) {
handleItemSelect(
token,
selectMode,
selectedTokenIds,
setSelectedTokenIds,
tokensByGroup,
tokenGroups
);
}
/**
* Token settings
* Shortcuts
*/
const [showMoreSettings, setShowMoreSettings] = useState(false);
const [tokenSettingChanges, setTokenSettingChanges] = useState({});
function handleTokenSettingsChange(key, value) {
setTokenSettingChanges((prevChanges) => ({ ...prevChanges, [key]: value }));
}
async function applyTokenChanges() {
if (selectedTokenId && !isEmpty(tokenSettingChanges)) {
// Ensure size value is positive
let verifiedChanges = { ...tokenSettingChanges };
if ("defaultSize" in verifiedChanges) {
verifiedChanges.defaultSize = verifiedChanges.defaultSize || 1;
function handleKeyDown({ key }) {
if (!isOpen) {
return;
}
if (key === "Shift") {
setSelectMode("range");
}
if (key === "Control" || key === "Meta") {
setSelectMode("multiple");
}
if (key === "Backspace" || key === "Delete") {
// Selected tokens and none are default
if (
selectedTokenIds.length > 0 &&
!selectedTokens.some((token) => token.type === "default")
) {
setIsTokensRemoveModalOpen(true);
}
await updateToken(selectedTokenId, verifiedChanges);
setTokenSettingChanges({});
}
}
async function handleRequestClose() {
await applyTokenChanges();
onRequestClose();
function handleKeyUp({ key }) {
if (!isOpen) {
return;
}
if (key === "Shift" && selectMode === "range") {
setSelectMode("single");
}
if ((key === "Control" || key === "Meta") && selectMode === "multiple") {
setSelectMode("single");
}
}
const selectedTokenWithChanges = { ...selectedToken, ...tokenSettingChanges };
useKeyboard(handleKeyDown, handleKeyUp);
// Set select mode to single when alt+tabing
useEffect(() => {
function handleBlur() {
setSelectMode("single");
}
window.addEventListener("blur", handleBlur);
return () => {
window.removeEventListener("blur", handleBlur);
};
}, []);
return (
<Modal
isOpen={isOpen}
onRequestClose={handleRequestClose}
onRequestClose={onRequestClose}
style={{ maxWidth: "542px", width: "calc(100% - 16px)" }}
>
<ImageDrop onDrop={handleImagesUpload} dropText="Drop token to upload">
@ -155,27 +231,59 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
Edit or import a token
</Label>
<TokenTiles
tokens={ownedTokens}
tokens={tokensByGroup}
groups={tokenGroups}
onTokenAdd={openImageDialog}
selectedToken={selectedTokenWithChanges}
onTokenEdit={() => setIsEditModalOpen(true)}
onTokensRemove={() => setIsTokensRemoveModalOpen(true)}
selectedTokens={selectedTokens}
onTokenSelect={handleTokenSelect}
onTokenRemove={handleTokenRemove}
/>
<TokenSettings
token={selectedTokenWithChanges}
showMore={showMoreSettings}
onSettingsChange={handleTokenSettingsChange}
onShowMoreChange={setShowMoreSettings}
selectMode={selectMode}
onSelectModeChange={setSelectMode}
search={search}
onSearchChange={handleSearchChange}
onTokensGroup={() => setIsGroupModalOpen(true)}
onTokensHide={handleTokensHide}
/>
<Button
variant="primary"
disabled={imageLoading}
onClick={handleRequestClose}
onClick={onRequestClose}
>
Done
</Button>
</Flex>
</ImageDrop>
<EditTokenModal
isOpen={isEditModalOpen}
onDone={() => setIsEditModalOpen(false)}
token={selectedTokens.length === 1 && selectedTokens[0]}
/>
<EditGroupModal
isOpen={isGroupModalOpen}
onChange={handleTokensGroup}
groups={tokenGroups.filter(
(group) => group !== "" && group !== "default"
)}
onRequestClose={() => setIsGroupModalOpen(false)}
// Select the default group by testing whether all selected tokens are the same
defaultGroup={
selectedTokens.length > 0 &&
selectedTokens
.map((map) => map.group)
.reduce((prev, curr) => (prev === curr ? curr : undefined))
}
/>
<ConfirmModal
isOpen={isTokensRemoveModalOpen}
onRequestClose={() => setIsTokensRemoveModalOpen(false)}
onConfirm={handleTokensRemove}
confirmText="Remove"
label={`Remove ${selectedTokenIds.length} Token${
selectedTokenIds.length > 1 ? "s" : ""
}`}
description="This operation cannot be undone."
/>
</Modal>
);
}

View File

@ -1,6 +1,5 @@
import React, { useState, useContext } from "react";
import {
Box,
Label,
Flex,
Button,
@ -17,6 +16,8 @@ import DatabaseContext from "../contexts/DatabaseContext";
import useSetting from "../helpers/useSetting";
import ConfirmModal from "./ConfirmModal";
function SettingsModal({ isOpen, onRequestClose }) {
const { database } = useContext(DatabaseContext);
const { userId } = useContext(AuthContext);
@ -65,7 +66,7 @@ function SettingsModal({ isOpen, onRequestClose }) {
<Flex sx={{ flexDirection: "column" }}>
<Label py={2}>Settings</Label>
<Divider bg="text" />
<Label py={2}>Accesibility:</Label>
<Label py={2}>Accessibility:</Label>
<Label py={2}>
<span style={{ marginRight: "4px" }}>Light theme</span>
<Checkbox
@ -103,26 +104,14 @@ function SettingsModal({ isOpen, onRequestClose }) {
</Flex>
</Flex>
</Modal>
<Modal
<ConfirmModal
isOpen={isDeleteModalOpen}
onRequestClose={() => setIsDeleteModalOpen(false)}
>
<Box>
<Label py={2}>Are you sure?</Label>
<Flex py={2}>
<Button
sx={{ flexGrow: 1 }}
m={1}
onClick={() => setIsDeleteModalOpen(false)}
>
Cancel
</Button>
<Button m={1} sx={{ flexGrow: 1 }} onClick={handleEraseAllData}>
Erase
</Button>
</Flex>
</Box>
</Modal>
onConfirm={handleEraseAllData}
label="Erase All Content?"
description="This will remove all data including saved maps and tokens."
confirmText="Erase"
/>
</>
);
}

View File

@ -18,7 +18,7 @@ function StartStreamModal({
<Modal isOpen={isOpen} onRequestClose={onRequestClose}>
<Box>
<Label pt={2} pb={1}>
Radio (experimental)
Audio Sharing (experimental)
</Label>
<Text as="p" mb={2} variant="caption">
Share your computers audio with the party
@ -28,12 +28,12 @@ function StartStreamModal({
<Flex py={2}>
{isSupported && !stream && (
<Button sx={{ flexGrow: 1 }} onClick={onStreamStart}>
Start Radio
Start Sharing
</Button>
)}
{isSupported && stream && (
<Button sx={{ flexGrow: 1 }} onClick={() => onStreamEnd(stream)}>
Stop Radio
Stop Sharing
</Button>
)}
</Flex>

View File

@ -22,7 +22,7 @@ function About() {
m={4}
>
<Text my={2} variant="heading" as="h1" sx={{ fontSize: 5 }}>
About ʕʔ
About <span aria-hidden="true">ʕʔ</span>
</Text>
<Text my={1} mt={2} variant="heading" as="h3" sx={{ fontSize: 3 }}>
The Goal

170
src/routes/Donate.js Normal file
View File

@ -0,0 +1,170 @@
import React, { useEffect, useState } from "react";
import {
Box,
Flex,
Text,
Message,
Button,
Input,
Label,
Radio,
} from "theme-ui";
import { useLocation } from "react-router-dom";
import Footer from "../components/Footer";
import Banner from "../components/Banner";
import LoadingOverlay from "../components/LoadingOverlay";
const prices = [
{ price: "$5.00", name: "Small", value: 5 },
{ price: "$15.00", name: "Medium", value: 15 },
{ price: "$30.00", name: "Large", value: 30 },
];
function Donate() {
const location = useLocation();
const query = new URLSearchParams(location.search);
const hasDonated = query.has("success");
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [stripe, setStripe] = useState();
useEffect(() => {
import("@stripe/stripe-js").then(({ loadStripe }) => {
loadStripe(process.env.REACT_APP_STRIPE_API_KEY)
.then((stripe) => {
setStripe(stripe);
setLoading(false);
})
.catch((err) => {
setError(err.message);
setLoading(false);
});
});
}, []);
async function handleSubmit(event) {
event.preventDefault();
if (loading) {
return;
}
const response = await fetch(
process.env.REACT_APP_STRIPE_URL + "/create-checkout-session",
{
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ currency: "usd", amount: value * 100 }),
}
);
const session = await response.json();
const result = await stripe.redirectToCheckout({ sessionId: session.id });
if (result.error) {
setError(result.error.message);
}
}
const [selectedPrice, setSelectedPrice] = useState("Medium");
const [value, setValue] = useState(15);
function handlePriceChange(price) {
setValue(price.value);
setSelectedPrice(price.name);
}
return (
<Flex
sx={{
flexDirection: "column",
justifyContent: "space-between",
minHeight: "100%",
alignItems: "center",
}}
>
<Flex
sx={{
flexDirection: "column",
maxWidth: "350px",
width: "100%",
flexGrow: 1,
}}
m={4}
as="form"
onSubmit={handleSubmit}
>
<Text my={2} variant="heading" as="h1" sx={{ fontSize: 5 }}>
Donate
</Text>
{hasDonated ? (
<Message my={2}>Thanks for donating!</Message>
) : (
<Text variant="body2" as="p">
In order to keep Owlbear Rodeo running any donation is greatly
appreciated.
</Text>
)}
<Text
my={4}
variant="heading"
as="h1"
sx={{ fontSize: 5, alignSelf: "center" }}
aria-hidden="true"
>
()*:
</Text>
<Text as="p" mb={2} variant="caption">
One time donation (USD)
</Text>
<Box sx={{ display: "flex", flexWrap: "wrap" }}>
{prices.map((price) => (
<Label mx={1} key={price.name} sx={{ width: "initial" }}>
<Radio
name="donation"
checked={selectedPrice === price.name}
onChange={() => handlePriceChange(price)}
/>
{price.price}
</Label>
))}
<Label mx={1} sx={{ width: "initial" }}>
<Radio
name="donation"
checked={selectedPrice === "Custom"}
onChange={() => handlePriceChange({ value, name: "Custom" })}
/>
Custom
</Label>
</Box>
{selectedPrice === "Custom" && (
<Box>
<Label htmlFor="donation">Amount ($)</Label>
<Input
type="number"
name="donation"
min={1}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
</Box>
)}
<Button my={3} disabled={loading || !value}>
Go to Payment
</Button>
</Flex>
<Footer />
{loading && <LoadingOverlay />}
<Banner isOpen={!!error} onRequestClose={() => setError(null)}>
<Box p={1}>
<Text as="p" variant="body2">
{error}
</Text>
</Box>
</Banner>
</Flex>
);
}
export default Donate;

View File

@ -1,24 +1,24 @@
import React, { useState, useEffect, useContext } from "react";
import { Flex, Button, Image, Text, IconButton, Link } from "theme-ui";
import { useHistory } from "react-router-dom";
import Footer from "../components/Footer";
import StartModal from "../modals/StartModal";
import JoinModal from "../modals/JoinModal";
import DonateModal from "../modals/DonationModal";
import AuthContext from "../contexts/AuthContext";
import RedditIcon from "../icons/SocialRedditIcon";
import TwitterIcon from "../icons/SocialTwitterIcon";
import YouTubeIcon from "../icons/SocialYouTubeIcon";
import DonateIcon from "../icons/DonateIcon";
import owlington from "../images/Owlington.png";
function Home() {
const [isStartModalOpen, setIsStartModalOpen] = useState(false);
const [isJoinModalOpen, setIsJoinModalOpen] = useState(false);
const [isDonateModalOpen, setIsDonateModalOpen] = useState(false);
// Reset password on visiting home
const { setPassword } = useContext(AuthContext);
@ -26,6 +26,8 @@ function Home() {
setPassword("");
}, [setPassword]);
const history = useHistory();
return (
<Flex
sx={{
@ -58,13 +60,23 @@ function Home() {
Beta v{process.env.REACT_APP_VERSION}
</Text>
<Button
m={2}
onClick={() => setIsDonateModalOpen(true)}
variant="secondary"
as="a"
href="/donate"
my={4}
mx={2}
onClick={(e) => {
e.preventDefault();
history.push("/donate");
}}
sx={{
display: "flex",
alignItems: "flex-end",
justifyContent: "center",
}}
>
Support Us
Donate <DonateIcon />
</Button>
<Flex sx={{ justifyContent: "center" }}>
<Flex mb={4} mt={0} sx={{ justifyContent: "center" }}>
<Link href="https://www.reddit.com/r/OwlbearRodeo/">
<IconButton title="Reddit" aria-label="Reddit">
<RedditIcon />
@ -89,10 +101,6 @@ function Home() {
isOpen={isStartModalOpen}
onRequestClose={() => setIsStartModalOpen(false)}
/>
<DonateModal
isOpen={isDonateModalOpen}
onRequestClose={() => setIsDonateModalOpen(false)}
/>
</Flex>
<Footer />
</Flex>

View File

@ -20,6 +20,7 @@ const v142 = raw("../docs/releaseNotes/v1.4.2.md");
const v150 = raw("../docs/releaseNotes/v1.5.0.md");
const v151 = raw("../docs/releaseNotes/v1.5.1.md");
const v152 = raw("../docs/releaseNotes/v1.5.2.md");
const v160 = raw("../docs/releaseNotes/v1.6.0.md");
function ReleaseNotes() {
const location = useLocation();
@ -45,17 +46,22 @@ function ReleaseNotes() {
Release Notes
</Text>
<div id="v152">
<Accordion heading="v1.5.2" defaultOpen>
<Accordion heading="v1.6.0" defaultOpen>
<Markdown source={v160} />
</Accordion>
</div>
<div id="v152">
<Accordion heading="v1.5.2" defaultOpen={location.hash === "#v152"}>
<Markdown source={v152} />
</Accordion>
</div>
<div id="v151">
<Accordion heading="v1.5.1" defaultOpen>
<Accordion heading="v1.5.1" defaultOpen={location.hash === "#v151"}>
<Markdown source={v151} />
</Accordion>
</div>
<div id="v150">
<Accordion heading="v1.5.0" defaultOpen>
<Accordion heading="v1.5.0" defaultOpen={location.hash === "#v150"}>
<Markdown source={v150} />
</Accordion>
</div>

View File

@ -24,12 +24,12 @@ export default {
},
},
fonts: {
body: "'Bree Serif', serif",
body: "'Bree Serif', Georgia, serif",
body2:
"system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif",
heading: "'Bree Serif', serif",
monospace: "Menlo, monospace",
display: "'Pacifico', cursive",
display: "'Pacifico', Helvetica, sans-serif",
},
fontSizes: [12, 14, 16, 20, 24, 32, 48, 64, 72],
fontWeights: {
@ -175,9 +175,10 @@ export default {
},
"&:disabled": {
backgroundColor: "muted",
color: "gray",
opacity: 0.5,
borderColor: "text",
},
fontFamily: "body2",
},
slider: {
"&:disabled": {

240
yarn.lock
View File

@ -1131,7 +1131,7 @@
dependencies:
regenerator-runtime "^0.13.4"
"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.3.1", "@babel/runtime@^7.3.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.1", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.6":
"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.3.1", "@babel/runtime@^7.3.4", "@babel/runtime@^7.4.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.1", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.6":
version "7.11.2"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.11.2.tgz#f549c13c754cc40b87644b9fa9f09a6a95fe0736"
integrity sha512-TeWkU52so0mPtDcaCTxNBI/IHiz0pZgr8VEFqXFtZWpYD08ZB6FaSwVAS8MKRQAP3bYKiVjwysOJgMFY28o6Tw==
@ -1205,7 +1205,7 @@
resolved "https://registry.yarnpkg.com/@csstools/normalize.css/-/normalize.css-10.1.0.tgz#f0950bba18819512d42f7197e56c518aa491cf18"
integrity sha512-ij4wRiunFfaJxjB0BdrYHIH8FxBJpOwNPhhAcunlmPdXudL1WQV1qoP9un6JsEBAgQH+7UXyyjh0g7jTxXK6tg==
"@emotion/cache@^10.0.27":
"@emotion/cache@^10.0.27", "@emotion/cache@^10.0.9":
version "10.0.29"
resolved "https://registry.yarnpkg.com/@emotion/cache/-/cache-10.0.29.tgz#87e7e64f412c060102d589fe7c6dc042e6f9d1e0"
integrity sha512-fU2VtSVlHiF27empSbxi1O2JFdNWZO+2NFHfwO0pxgTep6Xa3uGb+3pVKfLww2l/IBGLNEZl5Xf/++A4wAYDYQ==
@ -1227,7 +1227,19 @@
"@emotion/sheet" "0.9.4"
"@emotion/utils" "0.11.3"
"@emotion/css@^10.0.27":
"@emotion/core@^10.0.9":
version "10.0.35"
resolved "https://registry.yarnpkg.com/@emotion/core/-/core-10.0.35.tgz#513fcf2e22cd4dfe9d3894ed138c9d7a859af9b3"
integrity sha512-sH++vJCdk025fBlRZSAhkRlSUoqSqgCzYf5fMOmqqi3bM6how+sQpg3hkgJonj8GxXM4WbD7dRO+4tegDB9fUw==
dependencies:
"@babel/runtime" "^7.5.5"
"@emotion/cache" "^10.0.27"
"@emotion/css" "^10.0.27"
"@emotion/serialize" "^0.11.15"
"@emotion/sheet" "0.9.4"
"@emotion/utils" "0.11.3"
"@emotion/css@^10.0.27", "@emotion/css@^10.0.9":
version "10.0.27"
resolved "https://registry.yarnpkg.com/@emotion/css/-/css-10.0.27.tgz#3a7458198fbbebb53b01b2b87f64e5e21241e14c"
integrity sha512-6wZjsvYeBhyZQYNrGoR5yPMYbMBNEnanDrqmsqS1mzDm1cOTu12shvl2j4QHNS36UaTE0USIJawCH9C8oW34Zw==
@ -1737,6 +1749,73 @@
"@svgr/plugin-svgo" "^4.3.1"
loader-utils "^1.2.3"
"@tensorflow/tfjs-backend-cpu@2.6.0":
version "2.6.0"
resolved "https://registry.yarnpkg.com/@tensorflow/tfjs-backend-cpu/-/tfjs-backend-cpu-2.6.0.tgz#bd0923ca438e945c4c9347a76e301a4fb1890d33"
integrity sha512-essk82VoET77tuFX5Sa9zv9F8d/2DxjEQ2RavoU+ugs0l64DTbdTpv3WdQwUihv1gNN7/16fUjJ6cG80SnS8/g==
dependencies:
"@types/seedrandom" "2.4.27"
seedrandom "2.4.3"
"@tensorflow/tfjs-backend-webgl@2.6.0":
version "2.6.0"
resolved "https://registry.yarnpkg.com/@tensorflow/tfjs-backend-webgl/-/tfjs-backend-webgl-2.6.0.tgz#3855c254a86daf28511530c36bb61938acf26740"
integrity sha512-j1eNYKIpO06CTSRXiIWdpZ2iPDBkx7PPl7K/1BtCEW/9FP7Q0q3doHKNmTdOPvuw7Dt1nNHEMnba0YB2lc5S7Q==
dependencies:
"@tensorflow/tfjs-backend-cpu" "2.6.0"
"@types/offscreencanvas" "~2019.3.0"
"@types/seedrandom" "2.4.27"
"@types/webgl-ext" "0.0.30"
"@types/webgl2" "0.0.4"
seedrandom "2.4.3"
"@tensorflow/tfjs-converter@2.6.0":
version "2.6.0"
resolved "https://registry.yarnpkg.com/@tensorflow/tfjs-converter/-/tfjs-converter-2.6.0.tgz#0de5c4c014c25d695ad17ca19e3b654dbcb84cea"
integrity sha512-TzL4ULidZ26iVqfLmv5G6dfnJyJt5HttU1VZoBYCbxUcWQYk1Z4D9wqLVwfdcJz01XEKpmsECh8HBF0hwYlrkA==
"@tensorflow/tfjs-core@2.6.0":
version "2.6.0"
resolved "https://registry.yarnpkg.com/@tensorflow/tfjs-core/-/tfjs-core-2.6.0.tgz#ab2f2c9e8f46990643076d7d5bdb912885282bd2"
integrity sha512-akUB1iz663UCUdOfEUu91XeHzGpdYtdtMPxjsGEdF0CwENzSAcvHzQrEVoPBRD+RKpxrVXvQBoOd7GYBxMIIKQ==
dependencies:
"@types/offscreencanvas" "~2019.3.0"
"@types/seedrandom" "2.4.27"
"@types/webgl-ext" "0.0.30"
"@types/webgl2" "0.0.4"
node-fetch "~2.6.1"
seedrandom "2.4.3"
"@tensorflow/tfjs-data@2.6.0":
version "2.6.0"
resolved "https://registry.yarnpkg.com/@tensorflow/tfjs-data/-/tfjs-data-2.6.0.tgz#005fb204822322bc652ddd968c15a6b1a295652a"
integrity sha512-/x/j/A4Quiyc21xEYyBC82mqyssbFHRuHez7pYVJA/28TOesAfWPWo2I+wkeOTt91UerUeZMSq2FV3HOnPInhQ==
dependencies:
"@types/node-fetch" "^2.1.2"
node-fetch "~2.6.1"
"@tensorflow/tfjs-layers@2.6.0":
version "2.6.0"
resolved "https://registry.yarnpkg.com/@tensorflow/tfjs-layers/-/tfjs-layers-2.6.0.tgz#fed57ff6514f3fbb78fbcd2d40a09e9bd8d52cfc"
integrity sha512-nU9WNSGpEU6GzKo5bvJBMa/OZRe1bR5Z2W6T0XiEY8CBiPNS+oJFJNm0NY8kQj/WnDS0Hfue38P46q7gV/9XMA==
"@tensorflow/tfjs@^2.6.0":
version "2.6.0"
resolved "https://registry.yarnpkg.com/@tensorflow/tfjs/-/tfjs-2.6.0.tgz#ddc420fbb0d9561f46d3d02ba3ba2d4c15861837"
integrity sha512-f70NAt480+/NH6ueAdKhwgN3BzeBWrvuAZ591pH44nuVlmUHtih7pSMVv2wREPOgA4ciAufops4FtTaqNamxZw==
dependencies:
"@tensorflow/tfjs-backend-cpu" "2.6.0"
"@tensorflow/tfjs-backend-webgl" "2.6.0"
"@tensorflow/tfjs-converter" "2.6.0"
"@tensorflow/tfjs-core" "2.6.0"
"@tensorflow/tfjs-data" "2.6.0"
"@tensorflow/tfjs-layers" "2.6.0"
argparse "^1.0.10"
chalk "^4.1.0"
core-js "3"
regenerator-runtime "^0.13.5"
yargs "^16.0.3"
"@testing-library/dom@*":
version "7.22.1"
resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-7.22.1.tgz#b66861fb7751287bda63a55f5c72ca808c63043c"
@ -1931,11 +2010,24 @@
resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d"
integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==
"@types/node-fetch@^2.1.2":
version "2.5.7"
resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.5.7.tgz#20a2afffa882ab04d44ca786449a276f9f6bbf3c"
integrity sha512-o2WVNf5UhWRkxlf6eq+jMZDu7kjgpgJfl4xVNlvryc95O/6F2ld8ztKX+qu+Rjyet93WAWm5LjeX9H5FGkODvw==
dependencies:
"@types/node" "*"
form-data "^3.0.0"
"@types/node@*":
version "14.0.27"
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.0.27.tgz#a151873af5a5e851b51b3b065c9e63390a9e0eb1"
integrity sha512-kVrqXhbclHNHGu9ztnAwSncIgJv/FaxmzXJvGXNdcCpV1b8u1/Mi6z6m0vwy0LzKeXFTPLH0NzwmoJ3fNCIq0g==
"@types/offscreencanvas@~2019.3.0":
version "2019.3.0"
resolved "https://registry.yarnpkg.com/@types/offscreencanvas/-/offscreencanvas-2019.3.0.tgz#3336428ec7e9180cf4566dfea5da04eb586a6553"
integrity sha512-esIJx9bQg+QYF0ra8GnvfianIY8qWB0GBx54PK5Eps6m+xTj86KLavHv6qDhzKcu5UUOgNfJ2pWaIIV7TRUd9Q==
"@types/parse-json@^4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0"
@ -1966,6 +2058,11 @@
"@types/prop-types" "*"
csstype "^3.0.2"
"@types/seedrandom@2.4.27":
version "2.4.27"
resolved "https://registry.yarnpkg.com/@types/seedrandom/-/seedrandom-2.4.27.tgz#9db563937dd86915f69092bc43259d2f48578e41"
integrity sha1-nbVjk33YaRX2kJK8QyWdL0hXjkE=
"@types/stack-utils@^1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e"
@ -1994,6 +2091,16 @@
"@types/testing-library__dom" "*"
pretty-format "^25.1.0"
"@types/webgl-ext@0.0.30":
version "0.0.30"
resolved "https://registry.yarnpkg.com/@types/webgl-ext/-/webgl-ext-0.0.30.tgz#0ce498c16a41a23d15289e0b844d945b25f0fb9d"
integrity sha512-LKVgNmBxN0BbljJrVUwkxwRYqzsAEPcZOe6S2T6ZaBDIrFp0qu4FNlpc5sM1tGbXUYFgdVQIoeLk1Y1UoblyEg==
"@types/webgl2@0.0.4":
version "0.0.4"
resolved "https://registry.yarnpkg.com/@types/webgl2/-/webgl2-0.0.4.tgz#c3b0f9d6b465c66138e84e64cb3bdf8373c2c279"
integrity sha512-PACt1xdErJbMUOUweSrbVM7gSIYm1vTncW2hF6Os/EeWi6TXYAYMPp+8v6rzHmypE5gHrxaxZNXgMkJVIdZpHw==
"@types/yargs-parser@*":
version "15.0.0"
resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-15.0.0.tgz#cb3f9f741869e20cce330ffbeb9271590483882d"
@ -2399,7 +2506,7 @@ aproba@^1.1.1:
resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a"
integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==
argparse@^1.0.7:
argparse@^1.0.10, argparse@^1.0.7:
version "1.0.10"
resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911"
integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==
@ -3444,6 +3551,15 @@ cliui@^6.0.0:
strip-ansi "^6.0.0"
wrap-ansi "^6.2.0"
cliui@^7.0.0:
version "7.0.1"
resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.1.tgz#a4cb67aad45cd83d8d05128fc9f4d8fbb887e6b3"
integrity sha512-rcvHOWyGyid6I1WjT/3NatKj2kDt9OdSHSXpyLXaMWFbKpGACNW8pRhhdPUq9MWUOdwn8Rz9AVETjF4105rZZQ==
dependencies:
string-width "^4.2.0"
strip-ansi "^6.0.0"
wrap-ansi "^7.0.0"
clone-deep@^0.2.4:
version "0.2.4"
resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-0.2.4.tgz#4e73dd09e9fb971cc38670c5dced9c1896481cc6"
@ -3541,7 +3657,7 @@ colorette@^1.2.1:
resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.2.1.tgz#4d0b921325c14faf92633086a536db6e89564b1b"
integrity sha512-puCDz0CzydiSYOrnXpz/PKd69zRrribezjtE9yd4zvytoRc8+RY/KJPvtPFKZS3E3wP6neGyMe0vOTlHO5L3Pw==
combined-stream@^1.0.6, combined-stream@~1.0.6:
combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.6:
version "1.0.8"
resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==
@ -3719,16 +3835,16 @@ core-js-pure@^3.0.0:
resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.6.5.tgz#c79e75f5e38dbc85a662d91eea52b8256d53b813"
integrity sha512-lacdXOimsiD0QyNf9BC/mxivNJ/ybBGJXQFKzRekp1WTHoVUWsUHEn+2T8GJAzzIhyOuXA+gOxCVN3l+5PLPUA==
core-js@3, core-js@^3.0.1, core-js@^3.5.0:
version "3.6.5"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.6.5.tgz#7395dc273af37fb2e50e9bd3d9fe841285231d1a"
integrity sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==
core-js@^2.4.0, core-js@^2.5.3:
version "2.6.11"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.11.tgz#38831469f9922bded8ee21c9dc46985e0399308c"
integrity sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg==
core-js@^3.0.1, core-js@^3.5.0:
version "3.6.5"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.6.5.tgz#7395dc273af37fb2e50e9bd3d9fe841285231d1a"
integrity sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==
core-util-is@1.0.2, core-util-is@~1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
@ -4325,6 +4441,14 @@ dom-converter@^0.2:
dependencies:
utila "~0.4"
dom-helpers@^5.0.1:
version "5.2.0"
resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.0.tgz#57fd054c5f8f34c52a3eeffdb7e7e93cd357d95b"
integrity sha512-Ru5o9+V8CpunKnz5LGgWXkmrH/20cGKwcHwS4m73zIvs54CN9epEmT/HLqFJW3kXpakAFkEdzgy1hzlJe3E4OQ==
dependencies:
"@babel/runtime" "^7.8.7"
csstype "^3.0.2"
dom-serializer@0, dom-serializer@^0.2.1:
version "0.2.2"
resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.2.2.tgz#1afb81f533717175d478655debc5e332d9f9bb51"
@ -5308,6 +5432,15 @@ fork-ts-checker-webpack-plugin@3.1.1:
tapable "^1.0.0"
worker-rpc "^0.1.0"
form-data@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.0.tgz#31b7e39c85f1355b7139ee0c647cf0de7f83c682"
integrity sha512-CKMFDglpbMi6PyN+brwB9Q/GOw0eAnsrEZDgcsH5Krhz5Od/haKHAX0NmQfha2zPPz0JpWzA7GJHGSnvCRLWsg==
dependencies:
asynckit "^0.4.0"
combined-stream "^1.0.8"
mime-types "^2.1.12"
form-data@~2.3.2:
version "2.3.3"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6"
@ -5419,6 +5552,11 @@ functional-red-black-tree@^1.0.1:
resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327"
integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=
fuse.js@^6.4.1:
version "6.4.1"
resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-6.4.1.tgz#76f1b4ab9cd021b854a68381b35628033d27507e"
integrity sha512-+hAS7KYgLXontDh/vqffs7wIBw0ceb9Sx8ywZQhOsiQGcSO5zInGhttWOUYQYlvV/yYMJOacQ129Xs3mP3+oZQ==
gensync@^1.0.0-beta.1:
version "1.0.0-beta.1"
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.1.tgz#58f4361ff987e5ff6e1e7a210827aa371eaac269"
@ -5434,7 +5572,7 @@ get-caller-file@^1.0.1:
resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.3.tgz#f978fa4c90d1dfe7ff2d6beda2a515e713bdcf4a"
integrity sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w==
get-caller-file@^2.0.1:
get-caller-file@^2.0.1, get-caller-file@^2.0.5:
version "2.0.5"
resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
@ -7473,6 +7611,11 @@ mem@^4.0.0:
mimic-fn "^2.0.0"
p-is-promise "^2.0.0"
memoize-one@^5.0.0:
version "5.1.1"
resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.1.1.tgz#047b6e3199b508eaec03504de71229b8eb1d75c0"
integrity sha512-HKeeBpWvqiVJD57ZUAsJNm71eHTykffzcLZVYWiVfQeI1rJtuEaS7hQiEpWfVVk18donPwJEcFKIkCmPJNOhHA==
memory-fs@^0.4.1:
version "0.4.1"
resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.4.1.tgz#3a9a20b8462523e447cfbc7e8bb80ed667bfc552"
@ -7794,6 +7937,11 @@ no-case@^3.0.3:
lower-case "^2.0.1"
tslib "^1.10.0"
node-fetch@~2.6.1:
version "2.6.1"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052"
integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==
node-forge@0.9.0:
version "0.9.0"
resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.9.0.tgz#d624050edbb44874adca12bb9a52ec63cb782579"
@ -9524,6 +9672,13 @@ react-error-overlay@^6.0.7:
resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.7.tgz#1dcfb459ab671d53f660a991513cb2f0a0553108"
integrity sha512-TAv1KJFh3RhqxNvhzxj6LeT5NWklP6rDr2a0jaTfsZ5wSZWHOGeqQyejUp3xxLfPt2UpyJEcVQB/zyPcmonNFA==
react-input-autosize@^2.2.2:
version "2.2.2"
resolved "https://registry.yarnpkg.com/react-input-autosize/-/react-input-autosize-2.2.2.tgz#fcaa7020568ec206bc04be36f4eb68e647c4d8c2"
integrity sha512-jQJgYCA3S0j+cuOwzuCd1OjmBmnZLdqQdiLKRYrsMMzbjUrVDS5RvJUDwJqA7sKuksDuzFtm6hZGKFu7Mjk5aw==
dependencies:
prop-types "^15.5.8"
react-is@^16.12.0, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.4, react-is@^16.8.6:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
@ -9693,6 +9848,20 @@ react-scripts@3.4.0:
optionalDependencies:
fsevents "2.1.2"
react-select@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/react-select/-/react-select-3.1.0.tgz#ab098720b2e9fe275047c993f0d0caf5ded17c27"
integrity sha512-wBFVblBH1iuCBprtpyGtd1dGMadsG36W5/t2Aj8OE6WbByDg5jIFyT7X5gT+l0qmT5TqWhxX+VsKJvCEl2uL9g==
dependencies:
"@babel/runtime" "^7.4.4"
"@emotion/cache" "^10.0.9"
"@emotion/core" "^10.0.9"
"@emotion/css" "^10.0.9"
memoize-one "^5.0.0"
prop-types "^15.6.0"
react-input-autosize "^2.2.2"
react-transition-group "^4.3.0"
react-spring@^8.0.27:
version "8.0.27"
resolved "https://registry.yarnpkg.com/react-spring/-/react-spring-8.0.27.tgz#97d4dee677f41e0b2adcb696f3839680a3aa356a"
@ -9701,6 +9870,16 @@ react-spring@^8.0.27:
"@babel/runtime" "^7.3.1"
prop-types "^15.5.8"
react-transition-group@^4.3.0:
version "4.4.1"
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.1.tgz#63868f9325a38ea5ee9535d828327f85773345c9"
integrity sha512-Djqr7OQ2aPUiYurhPalTrVy9ddmFCCzwhqQmtN+J3+3DzLO209Fdr70QrN8Z3DsglWql6iY1lDWAfpFiBtuKGw==
dependencies:
"@babel/runtime" "^7.5.5"
dom-helpers "^5.0.1"
loose-envify "^1.4.0"
prop-types "^15.6.2"
react-use-gesture@^7.0.15:
version "7.0.15"
resolved "https://registry.yarnpkg.com/react-use-gesture/-/react-use-gesture-7.0.15.tgz#93c651e916a31cfb12d079e7fa1543d5b0511e07"
@ -9836,7 +10015,7 @@ regenerator-runtime@^0.11.0:
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9"
integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==
regenerator-runtime@^0.13.3, regenerator-runtime@^0.13.4:
regenerator-runtime@^0.13.3, regenerator-runtime@^0.13.4, regenerator-runtime@^0.13.5:
version "0.13.7"
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz#cac2dacc8a1ea675feaabaeb8ae833898ae46f55"
integrity sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==
@ -10279,6 +10458,11 @@ sdp@^2.12.0, sdp@^2.6.0:
resolved "https://registry.yarnpkg.com/sdp/-/sdp-2.12.0.tgz#338a106af7560c86e4523f858349680350d53b22"
integrity sha512-jhXqQAQVM+8Xj5EjJGVweuEzgtGWb3tmEEpl3CLP3cStInSbVHSg0QWOGQzNq8pSID4JkpeV2mPqlMDLrm0/Vw==
seedrandom@2.4.3:
version "2.4.3"
resolved "https://registry.yarnpkg.com/seedrandom/-/seedrandom-2.4.3.tgz#2438504dad33917314bff18ac4d794f16d6aaecc"
integrity sha1-JDhQTa0zkXMUv/GKxNeU8W1qrsw=
select-hose@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca"
@ -12146,6 +12330,15 @@ wrap-ansi@^6.2.0:
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrappy@1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
@ -12218,6 +12411,11 @@ xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.1:
resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b"
integrity sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==
y18n@^5.0.1:
version "5.0.2"
resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.2.tgz#48218df5da2731b4403115c39a1af709c873f829"
integrity sha512-CkwaeZw6dQgqgPGeTWKMXCRmMcBgETFlTml1+ZOO+q7kGst8NREJ+eWwFNPVUQ4QGdAaklbqCZHH6Zuep1RjiA==
yallist@^3.0.2:
version "3.1.1"
resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd"
@ -12257,6 +12455,11 @@ yargs-parser@^18.1.2:
camelcase "^5.0.0"
decamelize "^1.2.0"
yargs-parser@^20.0.0:
version "20.2.1"
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.1.tgz#28f3773c546cdd8a69ddae68116b48a5da328e77"
integrity sha512-yYsjuSkjbLMBp16eaOt7/siKTjNVjMm3SoJnIg3sEh/JsvqVVDyjRKmaJV4cl+lNIgq6QEco2i3gDebJl7/vLA==
yargs@12.0.5:
version "12.0.5"
resolved "https://registry.yarnpkg.com/yargs/-/yargs-12.0.5.tgz#05f5997b609647b64f66b81e3b4b10a368e7ad13"
@ -12308,6 +12511,19 @@ yargs@^15.3.1:
y18n "^4.0.0"
yargs-parser "^18.1.2"
yargs@^16.0.3:
version "16.0.3"
resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.0.3.tgz#7a919b9e43c90f80d4a142a89795e85399a7e54c"
integrity sha512-6+nLw8xa9uK1BOEOykaiYAJVh6/CjxWXK/q9b5FpRgNslt8s22F2xMBqVIKgCRjNgGvGPBy8Vog7WN7yh4amtA==
dependencies:
cliui "^7.0.0"
escalade "^3.0.2"
get-caller-file "^2.0.5"
require-directory "^2.1.1"
string-width "^4.2.0"
y18n "^5.0.1"
yargs-parser "^20.0.0"
yeast@0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/yeast/-/yeast-0.1.2.tgz#008e06d8094320c372dbc2f8ed76a0ca6c8ac419"