commit
7bee253773
2
.env
2
.env
@ -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
|
@ -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
|
@ -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",
|
||||
|
66
src/App.js
66
src/App.js
@ -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>
|
||||
|
69
src/components/FilterBar.js
Normal file
69
src/components/FilterBar.js
Normal 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;
|
@ -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
72
src/components/Grid.js
Normal 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
39
src/components/Search.js
Normal 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
71
src/components/Select.js
Normal 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
135
src/components/Tile.js
Normal 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;
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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"];
|
||||
|
206
src/components/map/MapEditor.js
Normal file
206
src/components/map/MapEditor.js
Normal 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;
|
@ -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) {
|
||||
|
@ -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;
|
||||
|
256
src/components/map/MapGridEditor.js
Normal file
256
src/components/map/MapGridEditor.js
Normal 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;
|
@ -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>
|
||||
|
@ -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()}
|
||||
|
@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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 />}
|
||||
|
@ -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")}
|
||||
|
@ -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 />}
|
||||
|
@ -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 />}
|
||||
|
@ -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"}
|
||||
>
|
||||
|
@ -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 />,
|
||||
},
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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 }) {
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
186
src/components/token/TokenPreview.js
Normal file
186
src/components/token/TokenPreview.js
Normal 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;
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
42
src/contexts/KeyboardContext.js
Normal file
42
src/contexts/KeyboardContext.js
Normal 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;
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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.
BIN
src/docs/assets/EditingMapsAdvanced.jpg
Normal file
BIN
src/docs/assets/EditingMapsAdvanced.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 152 KiB |
BIN
src/docs/assets/FilteringMaps.mp4
Normal file
BIN
src/docs/assets/FilteringMaps.mp4
Normal file
Binary file not shown.
BIN
src/docs/assets/FilteringTokens.mp4
Normal file
BIN
src/docs/assets/FilteringTokens.mp4
Normal file
Binary file not shown.
BIN
src/docs/assets/GroupAndRemovingTokens.mp4
Normal file
BIN
src/docs/assets/GroupAndRemovingTokens.mp4
Normal file
Binary file not shown.
BIN
src/docs/assets/MapEditor.mp4
Normal file
BIN
src/docs/assets/MapEditor.mp4
Normal file
Binary file not shown.
BIN
src/docs/assets/ResetAndRemovingMaps.mp4
Normal file
BIN
src/docs/assets/ResetAndRemovingMaps.mp4
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/docs/assets/TokenEditor.mp4
Normal file
BIN
src/docs/assets/TokenEditor.mp4
Normal file
Binary file not shown.
@ -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,
|
||||
};
|
||||
|
@ -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.
|
||||
|
@ -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.
|
||||
|
@ -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.
|
||||
|
45
src/docs/releaseNotes/v1.6.0.md
Normal file
45
src/docs/releaseNotes/v1.6.0.md
Normal 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
|
@ -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
145
src/helpers/map.js
Normal 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
133
src/helpers/select.js
Normal 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([]);
|
||||
}
|
||||
}
|
@ -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);
|
||||
|
30
src/helpers/useKeyboard.js
Normal file
30
src/helpers/useKeyboard.js
Normal 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;
|
62
src/helpers/useMapImage.js
Normal file
62
src/helpers/useMapImage.js
Normal 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;
|
110
src/helpers/useStageInteraction.js
Normal file
110
src/helpers/useStageInteraction.js
Normal 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;
|
@ -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
19
src/icons/DonateIcon.js
Normal 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
18
src/icons/EditTileIcon.js
Normal 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
18
src/icons/GridOffIcon.js
Normal 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
18
src/icons/GridOnIcon.js
Normal 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
18
src/icons/GroupIcon.js
Normal 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
18
src/icons/SearchIcon.js
Normal 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;
|
@ -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;
|
||||
|
18
src/icons/SelectMultipleIcon.js
Normal file
18
src/icons/SelectMultipleIcon.js
Normal 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;
|
18
src/icons/SelectSingleIcon.js
Normal file
18
src/icons/SelectSingleIcon.js
Normal 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;
|
@ -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
35
src/ml/Model.js
Normal 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;
|
30
src/ml/gridSize/GridSizeModel.js
Normal file
30
src/ml/gridSize/GridSizeModel.js
Normal 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;
|
BIN
src/ml/gridSize/group1-shard1of1.bin
Executable file
BIN
src/ml/gridSize/group1-shard1of1.bin
Executable file
Binary file not shown.
1
src/ml/gridSize/model.json
Executable file
1
src/ml/gridSize/model.json
Executable file
File diff suppressed because one or more lines are too long
46
src/modals/ConfirmModal.js
Normal file
46
src/modals/ConfirmModal.js
Normal 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;
|
@ -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;
|
65
src/modals/EditGroupModal.js
Normal file
65
src/modals/EditGroupModal.js
Normal 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
133
src/modals/EditMapModal.js
Normal 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;
|
77
src/modals/EditTokenModal.js
Normal file
77
src/modals/EditTokenModal.js
Normal 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;
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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
170
src/routes/Donate.js
Normal 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;
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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
240
yarn.lock
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user