Merge pull request #19 from mitchemmc/release/v1.5.2

Release/v1.5.2
This commit is contained in:
Mitchell McCaffrey 2020-09-11 18:23:35 +10:00 committed by GitHub
commit e4b5551aa7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 657 additions and 182 deletions

View File

@ -1,6 +1,6 @@
{
"name": "owlbear-rodeo",
"version": "1.5.1",
"version": "1.5.2",
"private": true,
"dependencies": {
"@babylonjs/core": "^4.1.0",
@ -18,6 +18,7 @@
"konva": "^6.0.0",
"lodash.get": "^4.4.2",
"lodash.set": "^4.3.2",
"normalize-wheel": "^1.0.1",
"polygon-clipping": "^0.14.3",
"raw.macro": "^0.3.0",
"react": "^16.13.0",

View File

@ -7,6 +7,7 @@ function StyledModal({
onRequestClose,
children,
allowClose,
style,
...props
}) {
const { theme } = useThemeUI();
@ -26,6 +27,7 @@ function StyledModal({
marginRight: "-50%",
transform: "translate(-50%, -50%)",
maxHeight: "100%",
...style,
},
}}
{...props}
@ -44,6 +46,7 @@ function StyledModal({
StyledModal.defaultProps = {
allowClose: true,
style: {},
};
export default StyledModal;

View File

@ -1,28 +1,34 @@
import React from "react";
import { Flex, Image, Text } from "theme-ui";
import { Flex, Image, Text, Box } from "theme-ui";
function DiceTile({ dice, isSelected, onDiceSelect, onDone }) {
function DiceTile({ dice, isSelected, onDiceSelect, onDone, large }) {
return (
<Flex
onClick={() => onDiceSelect(dice)}
sx={{
borderColor: "primary",
borderStyle: isSelected ? "solid" : "none",
borderWidth: "4px",
position: "relative",
width: "150px",
height: "150px",
width: large ? "48%" : "32%",
height: "0",
paddingTop: large ? "48%" : "32%",
borderRadius: "4px",
justifyContent: "center",
alignItems: "center",
cursor: "pointer",
}}
m={2}
my={1}
mx={`${large ? 1 : 2 / 3}%`}
bg="muted"
onDoubleClick={() => onDone(dice)}
>
<Image
sx={{ width: "100%", height: "100%", objectFit: "contain" }}
sx={{
width: "100%",
height: "100%",
objectFit: "contain",
position: "absolute",
top: 0,
left: 0,
}}
src={dice.preview}
/>
<Flex
@ -48,6 +54,20 @@ function DiceTile({ dice, isSelected, onDiceSelect, onDone }) {
{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>
);
}

View File

@ -1,18 +1,20 @@
import React from "react";
import { Flex } from "theme-ui";
import SimpleBar from "simplebar-react";
import { useMedia } from "react-media";
import DiceTile from "./DiceTile";
function DiceTiles({ dice, onDiceSelect, selectedDice, onDone }) {
const isSmallScreen = useMedia({ query: "(max-width: 500px)" });
return (
<SimpleBar style={{ maxHeight: "300px", width: "500px" }}>
<SimpleBar style={{ maxHeight: "300px" }}>
<Flex
py={2}
p={2}
bg="muted"
sx={{
flexWrap: "wrap",
width: "500px",
borderRadius: "4px",
}}
>
@ -23,6 +25,7 @@ function DiceTiles({ dice, onDiceSelect, selectedDice, onDone }) {
isSelected={selectedDice && dice.key === selectedDice.key}
onDiceSelect={onDiceSelect}
onDone={onDone}
large={isSmallScreen}
/>
))}
</Flex>

View File

@ -24,6 +24,7 @@ import DiceTray from "../../dice/diceTray/DiceTray";
import DiceLoadingContext from "../../contexts/DiceLoadingContext";
import { getDiceRoll } from "../../helpers/dice";
import useSetting from "../../helpers/useSetting";
function DiceTrayOverlay({
isOpen,
@ -45,6 +46,7 @@ function DiceTrayOverlay({
const { assetLoadStart, assetLoadFinish, isLoading } = useContext(
DiceLoadingContext
);
const [fullScreen] = useSetting("map.fullScreen");
function handleAssetLoadStart() {
assetLoadStart();
@ -236,6 +238,8 @@ function DiceTrayOverlay({
});
useEffect(() => {
let renderTimeout;
let renderCleanup;
function handleResize() {
const map = document.querySelector(".map");
const mapRect = map.getBoundingClientRect();
@ -251,6 +255,11 @@ function DiceTrayOverlay({
height = diceTraySize === "single" ? width * 2 : width;
}
// Debounce a timeout to force re-rendering on resize
renderTimeout = setTimeout(() => {
renderCleanup = forceRender();
}, 100);
setTraySize({ width, height });
}
@ -260,8 +269,14 @@ function DiceTrayOverlay({
return () => {
window.removeEventListener("resize", handleResize);
if (renderTimeout) {
clearTimeout(renderTimeout);
}
if (renderCleanup) {
renderCleanup();
}
};
}, [diceTraySize]);
}, [diceTraySize, fullScreen, isOpen]);
// Update dice rolls
useEffect(() => {

View File

@ -237,7 +237,7 @@ function Map({
}
}
const mapTokens = mapState && (
const mapTokens = map && mapState && (
<Group>
{Object.values(mapState.tokens)
.sort((a, b) => sortMapTokenStates(a, b, draggingTokenOptions))

View File

@ -16,6 +16,10 @@ import BrushToolIcon from "../../icons/BrushToolIcon";
import MeasureToolIcon from "../../icons/MeasureToolIcon";
import ExpandMoreIcon from "../../icons/ExpandMoreIcon";
import PointerToolIcon from "../../icons/PointerToolIcon";
import FullScreenIcon from "../../icons/FullScreenIcon";
import FullScreenExitIcon from "../../icons/FullScreenExitIcon";
import useSetting from "../../helpers/useSetting";
function MapContols({
onMapChange,
@ -31,6 +35,7 @@ function MapContols({
disabledSettings,
}) {
const [isExpanded, setIsExpanded] = useState(true);
const [fullScreen, setFullScreen] = useSetting("map.fullScreen");
const toolsById = {
pan: {
@ -190,6 +195,24 @@ function MapContols({
{controls}
</Flex>
{getToolSettings()}
<Box
sx={{
position: "absolute",
right: "4px",
bottom: 0,
backgroundColor: "overlay",
borderRadius: "50%",
}}
m={2}
>
<IconButton
onClick={() => setFullScreen(!fullScreen)}
aria-label={fullScreen ? "Exit Full Screen" : "Enter Full Screen"}
title={fullScreen ? "Exit Full Screen" : "Enter Full Screen"}
>
{fullScreen ? <FullScreenExitIcon /> : <FullScreenIcon />}
</IconButton>
</Box>
</>
);
}

View File

@ -5,6 +5,7 @@ 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 usePreventOverscroll from "../../helpers/usePreventOverscroll";
import useDataSource from "../../helpers/useDataSource";
@ -16,6 +17,7 @@ import MapStageContext, {
MapStageProvider,
} from "../../contexts/MapStageContext";
import AuthContext from "../../contexts/AuthContext";
import SettingsContext from "../../contexts/SettingsContext";
const wheelZoomSpeed = -0.001;
const touchZoomSpeed = 0.005;
@ -108,12 +110,14 @@ function MapInteraction({
isInteractingWithCanvas.current =
event.target === mapLayerRef.current.getCanvas()._canvas;
},
onWheel: ({ delta }) => {
onWheel: ({ event }) => {
event.persist();
const { pixelY } = normalizeWheel(event);
if (preventMapInteraction || !isInteractingWithCanvas.current) {
return;
}
const newScale = Math.min(
Math.max(stageScale + delta[1] * wheelZoomSpeed, minZoom),
Math.max(stageScale + pixelY * wheelZoomSpeed, minZoom),
maxZoom
);
setStageScale(newScale);
@ -310,6 +314,7 @@ function MapInteraction({
const mapImageRef = useRef();
const auth = useContext(AuthContext);
const settings = useContext(SettingsContext);
const mapInteraction = {
stageScale,
@ -354,11 +359,13 @@ function MapInteraction({
/>
{/* Forward auth context to konva elements */}
<AuthContext.Provider value={auth}>
<MapInteractionProvider value={mapInteraction}>
<MapStageProvider value={mapStageRef}>
{mapLoaded && children}
</MapStageProvider>
</MapInteractionProvider>
<SettingsContext.Provider value={settings}>
<MapInteractionProvider value={mapInteraction}>
<MapStageProvider value={mapStageRef}>
{mapLoaded && children}
</MapStageProvider>
</MapInteractionProvider>
</SettingsContext.Provider>
</AuthContext.Provider>
</Layer>
</Stage>

View File

@ -102,7 +102,7 @@ function MapSettings({
</Box>
<Flex
mt={2}
mb={map.type === "default" ? 2 : 0}
mb={mapEmpty || map.type === "default" ? 2 : 0}
sx={{ alignItems: "flex-end" }}
>
<Box sx={{ width: "50%" }}>
@ -154,8 +154,9 @@ function MapSettings({
key={quality.id}
value={quality.id}
disabled={
quality.id !== "original" &&
!map.resolutions[quality.id]
mapEmpty ||
(quality.id !== "original" &&
!map.resolutions[quality.id])
}
>
{quality.name}

View File

@ -16,6 +16,7 @@ function MapTile({
onMapRemove,
onMapReset,
onDone,
large,
}) {
const [isMapTileMenuOpen, setIsTileMenuOpen] = useState(false);
const isDefault = map.type === "default";
@ -46,7 +47,7 @@ function MapTile({
}}
bg="overlay"
sx={{ borderRadius: "50%" }}
m={1}
m={2}
>
<ExpandMoreDotIcon />
</IconButton>
@ -65,7 +66,7 @@ function MapTile({
}}
bg="overlay"
sx={{ borderRadius: "50%" }}
m={1}
m={2}
>
<RemoveMapIcon />
</IconButton>
@ -85,7 +86,7 @@ function MapTile({
}}
bg="overlay"
sx={{ borderRadius: "50%" }}
m={1}
m={2}
>
<ResetMapIcon />
</IconButton>
@ -96,18 +97,18 @@ function MapTile({
<Flex
key={map.id}
sx={{
borderColor: "primary",
borderStyle: isSelected ? "solid" : "none",
borderWidth: "4px",
position: "relative",
width: "150px",
height: "150px",
width: large ? "48%" : "32%",
height: "0",
paddingTop: large ? "48%" : "32%",
borderRadius: "4px",
justifyContent: "center",
alignItems: "center",
cursor: "pointer",
overflow: "hidden",
}}
m={2}
my={1}
mx={`${large ? 1 : 2 / 3}%`}
bg="muted"
onClick={(e) => {
e.stopPropagation();
@ -123,7 +124,14 @@ function MapTile({
}}
>
<UIImage
sx={{ width: "100%", height: "100%", objectFit: "contain" }}
sx={{
width: "100%",
height: "100%",
objectFit: "contain",
position: "absolute",
top: 0,
left: 0,
}}
src={mapSource}
/>
<Flex
@ -149,6 +157,20 @@ function MapTile({
{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 }}>

View File

@ -1,6 +1,7 @@
import React, { useContext } from "react";
import { Flex, Box, Text } from "theme-ui";
import SimpleBar from "simplebar-react";
import { useMedia } from "react-media";
import AddIcon from "../../icons/AddIcon";
@ -20,15 +21,16 @@ function MapTiles({
onDone,
}) {
const { databaseStatus } = useContext(DatabaseContext);
const isSmallScreen = useMedia({ query: "(max-width: 500px)" });
return (
<Box sx={{ position: "relative" }}>
<SimpleBar style={{ maxHeight: "300px", width: "500px" }}>
<SimpleBar style={{ maxHeight: "300px" }}>
<Flex
py={2}
p={2}
bg="muted"
sx={{
flexWrap: "wrap",
width: "500px",
borderRadius: "4px",
}}
onClick={() => onMapSelect(null)}
@ -45,19 +47,32 @@ function MapTiles({
":active": {
color: "secondary",
},
width: "150px",
height: "150px",
width: isSmallScreen ? "48%" : "32%",
height: "0",
paddingTop: isSmallScreen ? "48%" : "32%",
borderRadius: "4px",
justifyContent: "center",
alignItems: "center",
position: "relative",
cursor: "pointer",
}}
m={2}
my={1}
mx={`${isSmallScreen ? 1 : 2 / 3}%`}
bg="muted"
aria-label="Add Map"
title="Add Map"
>
<AddIcon large />
<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;
@ -73,6 +88,7 @@ function MapTiles({
onMapRemove={onMapRemove}
onMapReset={onMapReset}
onDone={onDone}
large={isSmallScreen}
/>
);
})}

View File

@ -6,6 +6,8 @@ import DiceTrayOverlay from "../dice/DiceTrayOverlay";
import { DiceLoadingProvider } from "../../contexts/DiceLoadingContext";
import useSetting from "../../helpers/useSetting";
function DiceTrayButton({
shareDice,
onShareDiceChage,
@ -13,13 +15,14 @@ function DiceTrayButton({
onDiceRollsChange,
}) {
const [isExpanded, setIsExpanded] = useState(false);
const [fullScreen] = useSetting("map.fullScreen");
return (
<Flex
sx={{
position: "absolute",
top: 0,
left: "100%",
left: fullScreen ? "0" : "100%",
bottom: 0,
flexDirection: "column",
alignItems: "flex-start",

View File

@ -11,6 +11,8 @@ import StartTimerButton from "./StartTimerButton";
import Timer from "./Timer";
import DiceTrayButton from "./DiceTrayButton";
import useSetting from "../../helpers/useSetting";
function Party({
nickname,
partyNicknames,
@ -30,81 +32,95 @@ function Party({
onDiceRollsChange,
partyDiceRolls,
}) {
const [fullScreen] = useSetting("map.fullScreen");
return (
<Flex
p={3}
<Box
bg="background"
sx={{
flexDirection: "column",
overflow: "visible",
alignItems: "center",
position: "relative",
width: "112px",
minWidth: "112px",
// width: fullScreen ? "0" : "112px",
// minWidth: fullScreen ? "0" : "112px",
}}
>
<Box
sx={{
width: "100%",
}}
>
<Text mb={1} variant="heading" as="h1">
Party
</Text>
</Box>
<SimpleBar
style={{
flexGrow: 1,
width: "100%",
flexDirection: "column",
overflow: "visible",
alignItems: "center",
height: "100%",
display: fullScreen ? "none" : "flex",
width: "112px",
minWidth: "112px",
padding: "0 16px",
height: "calc(100% - 232px)",
}}
p={3}
>
<Nickname
nickname={`${nickname} (you)`}
diceRolls={shareDice && diceRolls}
/>
{Object.entries(partyNicknames).map(([id, partyNickname]) => (
<Box
sx={{
width: "100%",
}}
>
<Text mb={1} variant="heading" as="h1">
Party
</Text>
</Box>
<SimpleBar
style={{
flexGrow: 1,
width: "100%",
minWidth: "112px",
padding: "0 16px",
height: "calc(100% - 232px)",
}}
>
<Nickname
nickname={partyNickname}
key={id}
stream={partyStreams[id]}
diceRolls={partyDiceRolls[id]}
nickname={`${nickname} (you)`}
diceRolls={shareDice && diceRolls}
/>
))}
{timer && <Timer timer={timer} index={0} />}
{Object.entries(partyTimers).map(([id, partyTimer], index) => (
<Timer
timer={partyTimer}
key={id}
// Put party timers above your timer if there is one
index={timer ? index + 1 : index}
{Object.entries(partyNicknames).map(([id, partyNickname]) => (
<Nickname
nickname={partyNickname}
key={id}
stream={partyStreams[id]}
diceRolls={partyDiceRolls[id]}
/>
))}
{timer && <Timer timer={timer} index={0} />}
{Object.entries(partyTimers).map(([id, partyTimer], index) => (
<Timer
timer={partyTimer}
key={id}
// Put party timers above your timer if there is one
index={timer ? index + 1 : index}
/>
))}
</SimpleBar>
<Flex sx={{ flexDirection: "column" }}>
<ChangeNicknameButton
nickname={nickname}
onChange={onNicknameChange}
/>
))}
</SimpleBar>
<Flex sx={{ flexDirection: "column" }}>
<ChangeNicknameButton nickname={nickname} onChange={onNicknameChange} />
<AddPartyMemberButton gameId={gameId} />
<StartStreamButton
onStreamStart={onStreamStart}
onStreamEnd={onStreamEnd}
stream={stream}
/>
<StartTimerButton
onTimerStart={onTimerStart}
onTimerStop={onTimerStop}
timer={timer}
/>
<SettingsButton />
</Flex>
<AddPartyMemberButton gameId={gameId} />
<StartStreamButton
onStreamStart={onStreamStart}
onStreamEnd={onStreamEnd}
stream={stream}
/>
<StartTimerButton
onTimerStart={onTimerStart}
onTimerStop={onTimerStop}
timer={timer}
/>
<SettingsButton />
</Flex>
</Box>
<DiceTrayButton
shareDice={shareDice}
onShareDiceChage={onShareDiceChage}
diceRolls={diceRolls}
onDiceRollsChange={onDiceRollsChange}
/>
</Flex>
</Box>
);
}

View File

@ -34,6 +34,9 @@ function TokenDragOverlay({
x: pointerPosition.x + mapRect.left,
y: pointerPosition.y + mapRect.top,
};
if (!removeTokenRef.current) {
return;
}
const removeIconPosition = removeTokenRef.current.getBoundingClientRect();
if (

View File

@ -1,15 +1,25 @@
import React, { useRef, useEffect, useState } from "react";
import { Rect, Text, Group } from "react-konva";
import useSetting from "../../helpers/useSetting";
const maxTokenSize = 3;
function TokenLabel({ tokenState, width, height }) {
const [labelSize] = useSetting("map.labelSize");
const fontSize =
(height / 6 / tokenState.size) * Math.min(tokenState.size, maxTokenSize);
(height / 6 / tokenState.size) *
Math.min(tokenState.size, maxTokenSize) *
labelSize;
const paddingY =
(height / 16 / tokenState.size) * Math.min(tokenState.size, maxTokenSize);
(height / 16 / tokenState.size) *
Math.min(tokenState.size, maxTokenSize) *
labelSize;
const paddingX =
(height / 8 / tokenState.size) * Math.min(tokenState.size, maxTokenSize);
(height / 8 / tokenState.size) *
Math.min(tokenState.size, maxTokenSize) *
labelSize;
const [rectWidth, setRectWidth] = useState(0);
useEffect(() => {
@ -19,7 +29,7 @@ function TokenLabel({ tokenState, width, height }) {
} else {
setRectWidth(0);
}
}, [tokenState.label, paddingX]);
}, [tokenState.label, paddingX, width]);
const textRef = useRef();

View File

@ -9,7 +9,7 @@ import {
unknownSource,
} from "../../tokens";
function TokenTile({ token, isSelected, onTokenSelect, onTokenRemove }) {
function TokenTile({ token, isSelected, onTokenSelect, onTokenRemove, large }) {
const tokenSource = useDataSource(token, defaultTokenSources, unknownSource);
const isDefault = token.type === "default";
@ -17,22 +17,29 @@ function TokenTile({ token, isSelected, onTokenSelect, onTokenRemove }) {
<Flex
onClick={() => onTokenSelect(token)}
sx={{
borderColor: "primary",
borderStyle: isSelected ? "solid" : "none",
borderWidth: "4px",
position: "relative",
width: "150px",
height: "150px",
width: large ? "48%" : "32%",
height: "0",
paddingTop: large ? "48%" : "32%",
borderRadius: "4px",
justifyContent: "center",
alignItems: "center",
cursor: "pointer",
overflow: "hidden",
}}
m={2}
my={1}
mx={`${large ? 1 : 2 / 3}%`}
bg="muted"
>
<Image
sx={{ width: "100%", height: "100%", objectFit: "contain" }}
sx={{
width: "100%",
height: "100%",
objectFit: "contain",
position: "absolute",
top: 0,
left: 0,
}}
src={tokenSource}
/>
<Flex
@ -58,6 +65,20 @@ function TokenTile({ token, isSelected, onTokenSelect, onTokenRemove }) {
{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
@ -68,7 +89,7 @@ function TokenTile({ token, isSelected, onTokenSelect, onTokenRemove }) {
}}
bg="overlay"
sx={{ borderRadius: "50%" }}
m={1}
m={2}
>
<RemoveTokenIcon />
</IconButton>

View File

@ -1,10 +1,14 @@
import React from "react";
import { Flex } from "theme-ui";
import React, { useContext } from "react";
import { Flex, Box, Text } from "theme-ui";
import SimpleBar from "simplebar-react";
import { useMedia } from "react-media";
import AddIcon from "../../icons/AddIcon";
import TokenTile from "./TokenTile";
import Link from "../Link";
import DatabaseContext from "../../contexts/DatabaseContext";
function TokenTiles({
tokens,
@ -13,54 +17,90 @@ function TokenTiles({
selectedToken,
onTokenRemove,
}) {
const { databaseStatus } = useContext(DatabaseContext);
const isSmallScreen = useMedia({ query: "(max-width: 500px)" });
return (
<SimpleBar style={{ maxHeight: "300px", width: "500px" }}>
<Flex
py={2}
bg="muted"
sx={{
flexWrap: "wrap",
width: "500px",
borderRadius: "4px",
}}
>
<Box sx={{ position: "relative" }}>
<SimpleBar style={{ maxHeight: "300px" }}>
<Flex
onClick={onTokenAdd}
sx={{
":hover": {
color: "primary",
},
":focus": {
outline: "none",
},
":active": {
color: "secondary",
},
width: "150px",
height: "150px",
borderRadius: "4px",
justifyContent: "center",
alignItems: "center",
cursor: "pointer",
}}
m={2}
p={2}
bg="muted"
aria-label="Add Token"
title="Add Token"
sx={{
flexWrap: "wrap",
borderRadius: "4px",
}}
>
<AddIcon large />
<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}
/>
))}
</Flex>
{tokens.map((token) => (
<TokenTile
key={token.id}
token={token}
isSelected={selectedToken && token.id === selectedToken.id}
onTokenSelect={onTokenSelect}
onTokenRemove={onTokenRemove}
/>
))}
</Flex>
</SimpleBar>
</SimpleBar>
{databaseStatus === "disabled" && (
<Box
sx={{
position: "absolute",
top: 0,
left: 0,
right: 0,
textAlign: "center",
}}
bg="highlight"
p={1}
>
<Text as="p" variant="body2">
Token saving is unavailable. See <Link to="/faq#saving">FAQ</Link>{" "}
for more information.
</Text>
</Box>
)}
</Box>
);
}

View File

@ -9,6 +9,7 @@ import ProxyToken from "./ProxyToken";
import SelectTokensButton from "./SelectTokensButton";
import { fromEntries } from "../../helpers/shared";
import useSetting from "../../helpers/useSetting";
import AuthContext from "../../contexts/AuthContext";
import TokenDataContext from "../../contexts/TokenDataContext";
@ -17,7 +18,8 @@ const listTokenClassName = "list-token";
function Tokens({ onMapTokenStateCreate }) {
const { userId } = useContext(AuthContext);
const { ownedTokens, tokens } = useContext(TokenDataContext);
const { ownedTokens, tokens, updateToken } = useContext(TokenDataContext);
const [fullScreen] = useSetting("map.fullScreen");
function handleProxyDragEnd(isOnMap, token) {
if (isOnMap && onMapTokenStateCreate) {
@ -37,6 +39,8 @@ function Tokens({ onMapTokenStateCreate }) {
locked: false,
visible: true,
});
// Update last used for cache invalidation
updateToken(token.id, { lastUsed: Date.now() });
}
}
@ -48,6 +52,7 @@ function Tokens({ onMapTokenStateCreate }) {
width: "80px",
minWidth: "80px",
overflow: "hidden",
display: fullScreen ? "none" : "block",
}}
>
<SimpleBar style={{ height: "calc(100% - 48px)", overflowX: "hidden" }}>

View File

@ -4,18 +4,27 @@ import shortid from "shortid";
import DatabaseContext from "./DatabaseContext";
import { getRandomMonster } from "../helpers/monsters";
import FakeStorage from "../helpers/FakeStorage";
const AuthContext = React.createContext();
let storage;
try {
sessionStorage.setItem("__test", "__test");
sessionStorage.removeItem("__test");
storage = sessionStorage;
} catch (e) {
console.warn("Session storage is disabled, no authentication will be saved");
storage = new FakeStorage();
}
export function AuthProvider({ children }) {
const { database } = useContext(DatabaseContext);
const [password, setPassword] = useState(
sessionStorage.getItem("auth") || ""
);
const [password, setPassword] = useState(storage.getItem("auth") || "");
useEffect(() => {
sessionStorage.setItem("auth", password);
storage.setItem("auth", password);
}, [password]);
const [authenticationStatus, setAuthenticationStatus] = useState("unknown");

View File

@ -7,6 +7,9 @@ import { maps as defaultMaps } from "../maps";
const MapDataContext = React.createContext();
// Maximum number of maps to keep in the cache
const cachedMapMax = 15;
const defaultMapState = {
tokens: {},
// An index into the draw actions array to which only actions before the
@ -70,12 +73,19 @@ export function MapDataProvider({ children }) {
loadMaps();
}, [userId, database]);
/**
* Adds a map to the database, also adds an assosiated state for that map
* @param {Object} map map to add
*/
async function addMap(map) {
await database.table("maps").add(map);
const state = { ...defaultMapState, mapId: map.id };
await database.table("states").add(state);
setMaps((prevMaps) => [map, ...prevMaps]);
setMapStates((prevStates) => [state, ...prevStates]);
if (map.owner !== userId) {
await updateCache();
}
}
async function removeMap(id) {
@ -129,6 +139,11 @@ export function MapDataProvider({ children }) {
});
}
/**
* Adds a map to the database if none exists or replaces a map if it already exists
* Note: this does not add a map state to do that use AddMap
* @param {Object} map the map to put
*/
async function putMap(map) {
await database.table("maps").put(map);
setMaps((prevMaps) => {
@ -141,6 +156,31 @@ export function MapDataProvider({ children }) {
}
return newMaps;
});
if (map.owner !== userId) {
await updateCache();
}
}
/**
* Keep up to cachedMapMax amount of maps that you don't own
* Sorted by when they we're last used
*/
async function updateCache() {
const cachedMaps = await database
.table("maps")
.where("owner")
.notEqual(userId)
.sortBy("lastUsed");
if (cachedMaps.length > cachedMapMax) {
const cacheDeleteCount = cachedMaps.length - cachedMapMax;
const idsToDelete = cachedMaps
.slice(0, cacheDeleteCount)
.map((map) => map.id);
database.table("maps").where("id").anyOf(idsToDelete).delete();
setMaps((prevMaps) => {
return prevMaps.filter((map) => !idsToDelete.includes(map.id));
});
}
}
function getMap(mapId) {

View File

@ -7,6 +7,8 @@ import { tokens as defaultTokens } from "../tokens";
const TokenDataContext = React.createContext();
const cachedTokenMax = 100;
export function TokenDataProvider({ children }) {
const { database } = useContext(DatabaseContext);
const { userId } = useContext(AuthContext);
@ -45,6 +47,9 @@ export function TokenDataProvider({ children }) {
async function addToken(token) {
await database.table("tokens").add(token);
setTokens((prevTokens) => [token, ...prevTokens]);
if (token.owner !== userId) {
await updateCache();
}
}
async function removeToken(id) {
@ -80,6 +85,31 @@ export function TokenDataProvider({ children }) {
}
return newTokens;
});
if (token.owner !== userId) {
await updateCache();
}
}
/**
* Keep up to cachedTokenMax amount of tokens that you don't own
* Sorted by when they we're last used
*/
async function updateCache() {
const cachedTokens = await database
.table("tokens")
.where("owner")
.notEqual(userId)
.sortBy("lastUsed");
if (cachedTokens.length > cachedTokenMax) {
const cacheDeleteCount = cachedTokens.length - cachedTokenMax;
const idsToDelete = cachedTokens
.slice(0, cacheDeleteCount)
.map((token) => token.id);
database.table("tokens").where("id").anyOf(idsToDelete).delete();
setTokens((prevTokens) => {
return prevTokens.filter((token) => !idsToDelete.includes(token.id));
});
}
}
function getToken(tokenId) {

View File

@ -184,6 +184,28 @@ function loadVersions(db) {
delete token.isVehicle;
});
});
// v1.5.2 - Added automatic cache invalidation to maps
db.version(11)
.stores({})
.upgrade(async (tx) => {
return tx
.table("maps")
.toCollection()
.modify((map) => {
map.lastUsed = map.lastModified;
});
});
// v1.5.2 - Added automatic cache invalidation to tokens
db.version(12)
.stores({})
.upgrade(async (tx) => {
return tx
.table("tokens")
.toCollection()
.modify((token) => {
token.lastUsed = token.lastModified;
});
});
}
// Get the dexie database used in DatabaseContext

View File

@ -0,0 +1,20 @@
## Minor Changes
This release focuses on better support for small screens and adds more accessibility options.
- Map, token and dice select screens now scale properly to small screens.
- A new full screen button allows you to hide the party and token list. This helps with usability on small screens and fixes an issue where the dice tray couldn't be seen.
- Fixed map zoom speed on Firefox on Windows to align with the other browsers.
- Fixed a crash that would occur if the user had local storage disabled.
- Fixed a bug that would cause the token label width to not update when the token size changed.
- Added a new accessibility option in the game settings for Token Label Size. This is a scale applied to token labels locally and should help with those who use large maps or high resolution screens.
- Fixed a bug that caused a crash when a map was deleted with unsaved changes.
- Fixed an error that could happen when switching between two maps with custom tokens.
- Added automatic cache invalidation to custom maps and tokens. This is an attempt to keep the storage of maps from getting out of hand as people use the site more. Practically this means that there is now a limit of 100 unique custom tokens per map. I'm intending to keep an eye on this and if there are issues I can make this number bigger.
[Reddit]()
[Twitter]()
---
September 10 2020

View File

@ -0,0 +1,23 @@
/**
* A faked local or session storage used when the user has disabled storage
*/
class FakeStorage {
data = {};
key(index) {
return Object.keys(this.data)[index] || null;
}
getItem(keyName) {
return this.data[keyName] || null;
}
setItem(keyName, keyValue) {
this.data[keyName] = keyValue;
}
removeItem(keyName) {
delete this.data[keyName];
}
clear() {
this.data = {};
}
}
export default FakeStorage;

View File

@ -1,12 +1,24 @@
import FakeStorage from "./FakeStorage";
/**
* An interface to a local storage back settings store with a versioning mechanism
*/
class Settings {
name;
currentVersion;
storage;
constructor(name) {
this.name = name;
// Try and use local storage if it is available, if not mock it with an in memory storage
try {
localStorage.setItem("__test", "__test");
localStorage.removeItem("__test");
this.storage = localStorage;
} catch (e) {
console.warn("Local storage is disabled, no settings will be saved");
this.storage = new FakeStorage();
}
this.currentVersion = this.get("__version");
}
@ -18,7 +30,7 @@ class Settings {
}
getAll() {
return JSON.parse(localStorage.getItem(this.name));
return JSON.parse(this.storage.getItem(this.name));
}
get(key) {
@ -27,7 +39,7 @@ class Settings {
}
setAll(newSettings) {
localStorage.setItem(
this.storage.setItem(
this.name,
JSON.stringify({ ...newSettings, __version: this.currentVersion })
);

View File

@ -0,0 +1,18 @@
import React from "react";
function FullScreenIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 0 24 24"
width="24"
fill="currentcolor"
>
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M6 16h2v2c0 .55.45 1 1 1s1-.45 1-1v-3c0-.55-.45-1-1-1H6c-.55 0-1 .45-1 1s.45 1 1 1zm2-8H6c-.55 0-1 .45-1 1s.45 1 1 1h3c.55 0 1-.45 1-1V6c0-.55-.45-1-1-1s-1 .45-1 1v2zm7 11c.55 0 1-.45 1-1v-2h2c.55 0 1-.45 1-1s-.45-1-1-1h-3c-.55 0-1 .45-1 1v3c0 .55.45 1 1 1zm1-11V6c0-.55-.45-1-1-1s-1 .45-1 1v3c0 .55.45 1 1 1h3c.55 0 1-.45 1-1s-.45-1-1-1h-2z" />
</svg>
);
}
export default FullScreenIcon;

View File

@ -0,0 +1,18 @@
import React from "react";
function FullScreenIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 0 24 24"
width="24"
fill="currentcolor"
>
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M6 14c-.55 0-1 .45-1 1v3c0 .55.45 1 1 1h3c.55 0 1-.45 1-1s-.45-1-1-1H7v-2c0-.55-.45-1-1-1zm0-4c.55 0 1-.45 1-1V7h2c.55 0 1-.45 1-1s-.45-1-1-1H6c-.55 0-1 .45-1 1v3c0 .55.45 1 1 1zm11 7h-2c-.55 0-1 .45-1 1s.45 1 1 1h3c.55 0 1-.45 1-1v-3c0-.55-.45-1-1-1s-1 .45-1 1v2zM14 6c0 .55.45 1 1 1h2v2c0 .55.45 1 1 1s1-.45 1-1V6c0-.55-.45-1-1-1h-3c-.55 0-1 .45-1 1z" />
</svg>
);
}
export default FullScreenIcon;

View File

@ -9,7 +9,11 @@ import { dice } from "../dice";
function SelectDiceModal({ isOpen, onRequestClose, onDone, defaultDice }) {
const [selectedDice, setSelectedDice] = useState(defaultDice);
return (
<Modal isOpen={isOpen} onRequestClose={onRequestClose}>
<Modal
isOpen={isOpen}
onRequestClose={onRequestClose}
style={{ maxWidth: "542px", width: "calc(100% - 16px)" }}
>
<Flex
sx={{
flexDirection: "column",

View File

@ -149,6 +149,7 @@ function SelectMapModal({
id: shortid.generate(),
created: Date.now(),
lastModified: Date.now(),
lastUsed: Date.now(),
owner: userId,
...defaultMapProps,
});
@ -174,9 +175,9 @@ function SelectMapModal({
async function handleMapRemove(id) {
await removeMap(id);
setSelectedMapId(null);
setMapSettingChanges({});
setMapStateSettingChanges({});
setSelectedMapId(null);
// Removed the map from the map screen if needed
if (currentMap && currentMap.id === selectedMapId) {
onMapChange(null, null);
@ -213,7 +214,13 @@ function SelectMapModal({
}
if (selectedMapId) {
await applyMapChanges();
onMapChange(selectedMapWithChanges, selectedMapStateWithChanges);
// Update last used for cache invalidation
const lastUsed = Date.now();
await updateMap(selectedMapId, { lastUsed });
onMapChange(
{ ...selectedMapWithChanges, lastUsed },
selectedMapStateWithChanges
);
} else {
onMapChange(null, null);
}
@ -265,14 +272,21 @@ function SelectMapModal({
}
}
const selectedMapWithChanges = { ...selectedMap, ...mapSettingChanges };
const selectedMapStateWithChanges = {
const selectedMapWithChanges = selectedMap && {
...selectedMap,
...mapSettingChanges,
};
const selectedMapStateWithChanges = selectedMapState && {
...selectedMapState,
...mapStateSettingChanges,
};
return (
<Modal isOpen={isOpen} onRequestClose={handleClose}>
<Modal
isOpen={isOpen}
onRequestClose={handleClose}
style={{ maxWidth: "542px", width: "calc(100% - 16px)" }}
>
<ImageDrop onDrop={handleImagesUpload} dropText="Drop map to upload">
<input
onChange={(event) => handleImagesUpload(event.target.files)}

View File

@ -75,6 +75,7 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
type: "file",
created: Date.now(),
lastModified: Date.now(),
lastUsed: Date.now(),
owner: userId,
defaultSize: 1,
category: "character",
@ -131,7 +132,11 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
const selectedTokenWithChanges = { ...selectedToken, ...tokenSettingChanges };
return (
<Modal isOpen={isOpen} onRequestClose={handleRequestClose}>
<Modal
isOpen={isOpen}
onRequestClose={handleRequestClose}
style={{ maxWidth: "542px", width: "calc(100% - 16px)" }}
>
<ImageDrop onDrop={handleImagesUpload} dropText="Drop token to upload">
<input
onChange={(event) => handleImagesUpload(event.target.files)}

View File

@ -1,15 +1,27 @@
import React, { useState, useContext } from "react";
import { Box, Label, Flex, Button, useColorMode, Checkbox } from "theme-ui";
import {
Box,
Label,
Flex,
Button,
useColorMode,
Checkbox,
Slider,
Divider,
} from "theme-ui";
import Modal from "../components/Modal";
import AuthContext from "../contexts/AuthContext";
import DatabaseContext from "../contexts/DatabaseContext";
import useSetting from "../helpers/useSetting";
function SettingsModal({ isOpen, onRequestClose }) {
const { database } = useContext(DatabaseContext);
const { userId } = useContext(AuthContext);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [labelSize, setLabelSize] = useSetting("map.labelSize");
async function handleEraseAllData() {
localStorage.clear();
@ -52,16 +64,30 @@ function SettingsModal({ isOpen, onRequestClose }) {
<Modal isOpen={isOpen} onRequestClose={onRequestClose}>
<Flex sx={{ flexDirection: "column" }}>
<Label py={2}>Settings</Label>
<Divider bg="text" />
<Label py={2}>Accesibility:</Label>
<Label py={2}>
Light theme
<span style={{ marginRight: "4px" }}>Light theme</span>
<Checkbox
checked={colorMode === "light"}
onChange={(e) =>
setColorMode(e.target.checked ? "light" : "default")
}
pl={1}
/>
</Label>
<Label py={2}>
Token Label Size
<Slider
step={0.5}
min={1}
max={3}
ml={1}
sx={{ width: "initial" }}
value={labelSize}
onChange={(e) => setLabelSize(parseFloat(e.target.value))}
/>
</Label>
<Divider bg="text" />
<Flex py={2}>
<Button sx={{ flexGrow: 1 }} onClick={handleClearCache}>
Clear cache

View File

@ -32,7 +32,7 @@ function NetworkedMapAndTokens({ session }) {
isLoading,
} = useContext(MapLoadingContext);
const { putToken, getToken } = useContext(TokenDataContext);
const { putToken, getToken, updateToken } = useContext(TokenDataContext);
const { putMap, updateMap, getMapFromDB } = useContext(MapDataContext);
const [currentMap, setCurrentMap] = useState(null);
@ -245,11 +245,15 @@ function NetworkedMapAndTokens({ session }) {
if (newMap && newMap.type === "file") {
const cachedMap = await getMapFromDB(newMap.id);
if (cachedMap && cachedMap.lastModified >= newMap.lastModified) {
setCurrentMap(cachedMap);
// Update last used for cache invalidation
const lastUsed = Date.now();
await updateMap(cachedMap.id, { lastUsed });
setCurrentMap({ ...cachedMap, lastUsed });
} else {
// Save map data but remove last modified so if there is an error
// during the map request the cache is invalid
await putMap({ ...newMap, lastModified: 0 });
// during the map request the cache is invalid. Also add last used
// for cache invalidation
await putMap({ ...newMap, lastModified: 0, lastUsed: Date.now() });
reply("mapRequest", newMap.id, "map");
}
} else {
@ -326,16 +330,21 @@ function NetworkedMapAndTokens({ session }) {
if (newToken && newToken.type === "file") {
const cachedToken = getToken(newToken.id);
if (
!cachedToken ||
cachedToken.lastModified !== newToken.lastModified
cachedToken &&
cachedToken.lastModified >= newToken.lastModified
) {
// Update last used for cache invalidation
const lastUsed = Date.now();
await updateToken(cachedToken.id, { lastUsed });
} else {
reply("tokenRequest", newToken.id, "token");
}
}
}
if (id === "tokenRequest") {
const token = getToken(data);
reply("tokenResponse", token, "token");
// Add a last used property for cache invalidation
reply("tokenResponse", { ...token, lastUsed: Date.now() }, "token");
}
if (id === "tokenResponse") {
const newToken = data;

View File

@ -19,6 +19,7 @@ const v141 = raw("../docs/releaseNotes/v1.4.1.md");
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");
function ReleaseNotes() {
const location = useLocation();
@ -43,6 +44,11 @@ function ReleaseNotes() {
<Text mb={2} variant="heading" as="h1" sx={{ fontSize: 5 }}>
Release Notes
</Text>
<div id="v152">
<Accordion heading="v1.5.2" defaultOpen>
<Markdown source={v152} />
</Accordion>
</div>
<div id="v151">
<Accordion heading="v1.5.1" defaultOpen>
<Markdown source={v151} />

View File

@ -27,6 +27,11 @@ function loadVersions(settings) {
style: "galaxy",
},
}));
// v1.5.2 - Added full screen support for map and label size
settings.version(2, (prev) => ({
...prev,
map: { fullScreen: false, labelSize: 1 },
}));
}
export function getSettings() {

View File

@ -7896,6 +7896,11 @@ normalize-url@^3.0.0:
resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-3.3.0.tgz#b2e1c4dc4f7c6d57743df733a4f5978d18650559"
integrity sha512-U+JJi7duF1o+u2pynbp2zXDW2/PADgC30f0GsHZtRh+HOcXHnw137TrNlyxxRvWW5fjKd3bcLHPxofWuCjaeZg==
normalize-wheel@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/normalize-wheel/-/normalize-wheel-1.0.1.tgz#aec886affdb045070d856447df62ecf86146ec45"
integrity sha1-rsiGr/2wRQcNhWRH32Ls+GFG7EU=
npm-run-path@^2.0.0:
version "2.0.2"
resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f"