Merge pull request #43 from mitchemmc/release/v1.9.0

Release/v1.9.0
This commit is contained in:
Mitchell McCaffrey 2021-06-24 18:36:14 +10:00 committed by GitHub
commit 718f07c3bf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
160 changed files with 7607 additions and 4084 deletions

View File

@ -6,4 +6,4 @@ REACT_APP_VERSION=$npm_package_version
REACT_APP_PREVIEW=false
REACT_APP_LOGGING=true
REACT_APP_FATHOM_SITE_ID=VMSHBPKD
REACT_APP_SENTRY_DSN=https://5257021c3a114649baa5e3b8ba775bfe@o467475.ingest.sentry.io/5493956
REACT_APP_SENTRY_DSN=https://d6d22c5233b54c4d91df8fa29d5ffeb0@o467475.ingest.sentry.io/5493956

View File

@ -1,13 +1,16 @@
{
"name": "owlbear-rodeo",
"version": "1.8.1",
"version": "1.9.0",
"private": true,
"dependencies": {
"@babylonjs/core": "^4.2.0",
"@babylonjs/loaders": "^4.2.0",
"@dnd-kit/core": "^3.0.4",
"@dnd-kit/sortable": "^3.1.0",
"@mitchemmc/dexie-export-import": "^1.0.1",
"@msgpack/msgpack": "^2.4.1",
"@sentry/react": "^6.2.2",
"@sentry/integrations": "^6.3.0",
"@sentry/react": "^6.3.0",
"@stripe/stripe-js": "^1.13.1",
"@tensorflow/tfjs": "^3.3.0",
"@testing-library/jest-dom": "^5.11.9",
@ -24,11 +27,13 @@
"fake-indexeddb": "^3.1.2",
"file-saver": "^2.0.5",
"fuse.js": "^6.4.6",
"interactjs": "^1.10.8",
"image-outline": "^0.1.0",
"intersection-observer": "^0.12.0",
"konva": "^7.2.5",
"lodash.clonedeep": "^4.5.0",
"lodash.get": "^4.4.2",
"lodash.set": "^4.3.2",
"lodash.unset": "^4.5.2",
"normalize-wheel": "^1.0.1",
"pepjs": "^0.5.3",
"polygon-clipping": "^0.15.2",
@ -36,6 +41,7 @@
"raw.macro": "^0.4.2",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-intersection-observer": "^8.32.0",
"react-konva": "^17.0.1-3",
"react-markdown": "4",
"react-media": "^2.0.0-rc.1",
@ -46,6 +52,7 @@
"react-scripts": "^4.0.3",
"react-select": "^4.2.1",
"react-spring": "^8.0.27",
"react-textarea-autosize": "^8.3.3",
"react-toast-notifications": "^2.4.3",
"react-use-gesture": "^9.1.3",
"shortid": "^2.2.15",
@ -57,6 +64,7 @@
"source-map-explorer": "^2.5.2",
"theme-ui": "^0.3.1",
"use-image": "^1.0.7",
"uuid": "^8.3.2",
"webrtc-adapter": "^7.7.1"
},
"resolutions": {

View File

@ -12,63 +12,54 @@ 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";
import { KeyboardProvider } from "./contexts/KeyboardContext";
import { ImageSourcesProvider } from "./contexts/ImageSourceContext";
import { DatabaseProvider } from "./contexts/DatabaseContext";
import { UserIdProvider } from "./contexts/UserIdContext";
import { ToastProvider } from "./components/Toast";
function App() {
return (
<ThemeProvider theme={theme}>
<DatabaseProvider>
<SettingsProvider>
<AuthProvider>
<KeyboardProvider>
<ToastProvider>
<ImageSourcesProvider>
<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>
</ImageSourcesProvider>
</ToastProvider>
</KeyboardProvider>
</AuthProvider>
</SettingsProvider>
</DatabaseProvider>
<SettingsProvider>
<AuthProvider>
<KeyboardProvider>
<ToastProvider>
<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">
<DatabaseProvider>
<UserIdProvider>
<Game />
</UserIdProvider>
</DatabaseProvider>
</Route>
<Route path="/">
<Home />
</Route>
</Switch>
</Router>
</ToastProvider>
</KeyboardProvider>
</AuthProvider>
</SettingsProvider>
</ThemeProvider>
);
}

View File

@ -4,27 +4,25 @@ import Action from "./Action";
import {
addPolygonDifferenceToShapes,
addPolygonIntersectionToShapes,
shapeToGeometry,
} from "../helpers/actions";
class CutShapeAction extends Action {
constructor(shapes) {
super();
this.update = (shapesById) => {
const actionGeom = shapes.map((actionShape) => [
actionShape.data.points.map(({ x, y }) => [x, y]),
]);
let actionGeom = shapes.map(shapeToGeometry);
let cutShapes = {};
for (let shape of Object.values(shapesById)) {
const shapePoints = shape.data.points.map(({ x, y }) => [x, y]);
const shapeHoles = shape.data.holes.map((hole) =>
hole.map(({ x, y }) => [x, y])
);
let shapeGeom = [[shapePoints, ...shapeHoles]];
const shapeGeom = shapeToGeometry(shape);
try {
const difference = polygonClipping.difference(shapeGeom, actionGeom);
const difference = polygonClipping.difference(
shapeGeom,
...actionGeom
);
const intersection = polygonClipping.intersection(
shapeGeom,
actionGeom
...actionGeom
);
addPolygonDifferenceToShapes(shape, difference, cutShapes);
addPolygonIntersectionToShapes(shape, intersection, cutShapes);

View File

@ -1,24 +1,24 @@
import polygonClipping from "polygon-clipping";
import Action from "./Action";
import { addPolygonDifferenceToShapes } from "../helpers/actions";
import {
addPolygonDifferenceToShapes,
shapeToGeometry,
} from "../helpers/actions";
class SubtractShapeAction extends Action {
constructor(shapes) {
super();
this.update = (shapesById) => {
const actionGeom = shapes.map((actionShape) => [
actionShape.data.points.map(({ x, y }) => [x, y]),
]);
const actionGeom = shapes.map(shapeToGeometry);
let subtractedShapes = {};
for (let shape of Object.values(shapesById)) {
const shapePoints = shape.data.points.map(({ x, y }) => [x, y]);
const shapeHoles = shape.data.holes.map((hole) =>
hole.map(({ x, y }) => [x, y])
);
let shapeGeom = [[shapePoints, ...shapeHoles]];
const shapeGeom = shapeToGeometry(shape);
try {
const difference = polygonClipping.difference(shapeGeom, actionGeom);
const difference = polygonClipping.difference(
shapeGeom,
...actionGeom
);
addPolygonDifferenceToShapes(shape, difference, subtractedShapes);
} catch {
console.error("Unable to find difference for shapes");

View File

@ -4,43 +4,6 @@ import EditShapeAction from "./EditShapeAction";
import RemoveShapeAction from "./RemoveShapeAction";
import SubtractShapeAction from "./SubtractShapeAction";
/**
* Convert from the previous representation of actions (1.7.0) to the new representation (1.8.0)
* and combine into shapes
* @param {Array} actions
* @param {number} actionIndex
*/
export function convertOldActionsToShapes(actions, actionIndex) {
let newShapes = {};
for (let i = 0; i <= actionIndex; i++) {
const action = actions[i];
if (!action) {
continue;
}
let newAction;
if (action.shapes) {
if (action.type === "add") {
newAction = new AddShapeAction(action.shapes);
} else if (action.type === "edit") {
newAction = new EditShapeAction(action.shapes);
} else if (action.type === "remove") {
newAction = new RemoveShapeAction(action.shapes);
} else if (action.type === "subtract") {
newAction = new SubtractShapeAction(action.shapes);
} else if (action.type === "cut") {
newAction = new CutShapeAction(action.shapes);
}
} else if (action.type === "remove" && action.shapeIds) {
newAction = new RemoveShapeAction(action.shapeIds);
}
if (newAction) {
newShapes = newAction.execute(newShapes);
}
}
return newShapes;
}
export {
AddShapeAction,
CutShapeAction,

View File

@ -1,86 +0,0 @@
import React, { useState } from "react";
import { Box, Flex, Text } from "theme-ui";
function ImageDrop({ onDrop, dropText, children }) {
const [dragging, setDragging] = useState(false);
function handleImageDragEnter(event) {
event.preventDefault();
event.stopPropagation();
setDragging(true);
}
function handleImageDragLeave(event) {
event.preventDefault();
event.stopPropagation();
setDragging(false);
}
async function handleImageDrop(event) {
event.preventDefault();
event.stopPropagation();
let imageFiles = [];
// Check if the dropped image is from a URL
const html = event.dataTransfer.getData("text/html");
if (html) {
try {
const urlMatch = html.match(/src="?([^"\s]+)"?\s*/);
const url = urlMatch[1].replace("&amp;", "&"); // Reverse html encoding of url parameters
let name = "";
const altMatch = html.match(/alt="?([^"]+)"?\s*/);
if (altMatch && altMatch.length > 1) {
name = altMatch[1];
}
const response = await fetch(url);
if (response.ok) {
const file = await response.blob();
file.name = name;
imageFiles.push(file);
}
} catch {}
}
const files = event.dataTransfer.files;
for (let file of files) {
if (file.type.startsWith("image")) {
imageFiles.push(file);
}
}
onDrop(imageFiles);
setDragging(false);
}
return (
<Box onDragEnter={handleImageDragEnter}>
{children}
{dragging && (
<Flex
bg="overlay"
sx={{
position: "absolute",
top: 0,
right: 0,
left: 0,
bottom: 0,
justifyContent: "center",
alignItems: "center",
cursor: "copy",
}}
onDragLeave={handleImageDragLeave}
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "copy";
}}
onDrop={handleImageDrop}
>
<Text sx={{ pointerEvents: "none" }}>
{dropText || "Drop image to upload"}
</Text>
</Flex>
)}
</Box>
);
}
export default ImageDrop;

View File

@ -3,7 +3,7 @@ import { Box } from "theme-ui";
import Spinner from "./Spinner";
function LoadingOverlay({ bg }) {
function LoadingOverlay({ bg, children }) {
return (
<Box
sx={{
@ -21,6 +21,7 @@ function LoadingOverlay({ bg }) {
bg={bg}
>
<Spinner />
{children}
</Box>
);
}

View File

@ -9,7 +9,7 @@ import {
import ReactMarkdown from "react-markdown";
function Paragraph(props) {
return <Text variant="body2" {...props} />;
return <Text as="p" my={2} variant="body2" {...props} />;
}
function Heading({ level, ...props }) {
@ -27,6 +27,9 @@ function Heading({ level, ...props }) {
}
function Image(props) {
if (props.alt === "embed:") {
return <Embed as="span" sx={{ display: "block" }} src={props.src} my={2} />;
}
if (props.src.endsWith(".mp4")) {
return (
<video
@ -125,12 +128,7 @@ function TableCell({ children }) {
}
function Link({ href, children }) {
const linkText = children[0].props.value;
if (linkText === "embed:") {
return <Embed src={href} my={2} />;
} else {
return <UILink href={href}>{children}</UILink>;
}
return <UILink href={href}>{children}</UILink>;
}
function Markdown({ source, assets }) {
@ -151,7 +149,7 @@ function Markdown({ source, assets }) {
<ReactMarkdown
source={source}
renderers={renderers}
transformImageUri={(uri) => assets[uri]}
transformImageUri={(uri) => assets[uri] || uri}
/>
);
}

View File

@ -2,6 +2,8 @@ import React from "react";
import Modal from "react-modal";
import { useThemeUI, Close } from "theme-ui";
import { useSpring, animated, config } from "react-spring";
function StyledModal({
isOpen,
onRequestClose,
@ -12,24 +14,51 @@ function StyledModal({
}) {
const { theme } = useThemeUI();
const openAnimation = useSpring({
opacity: isOpen ? 1 : 0,
transform: isOpen ? "scale(1)" : "scale(0.99)",
config: config.default,
});
return (
<Modal
isOpen={isOpen}
onRequestClose={onRequestClose}
style={{
overlay: { backgroundColor: "rgba(0, 0, 0, 0.73)", zIndex: 100 },
overlay: {
backgroundColor: "rgba(0, 0, 0, 0.73)",
zIndex: 100,
display: "flex",
alignItems: "center",
justifyContent: "center",
},
content: {
backgroundColor: theme.colors.background,
top: "50%",
left: "50%",
right: "auto",
bottom: "auto",
marginRight: "-50%",
transform: "translate(-50%, -50%)",
top: "initial",
left: "initial",
bottom: "initial",
right: "initial",
maxHeight: "100%",
...style,
},
}}
contentElement={(props, content) => (
<animated.div {...props} style={{ ...props.style, ...openAnimation }}>
{content}
</animated.div>
)}
overlayElement={(props, content) => (
<div
onDragEnter={(e) => {
// Prevent drag event from triggering with a modal open
e.preventDefault();
e.stopPropagation();
}}
{...props}
>
{content}
</div>
)}
{...props}
>
{children}

View File

@ -1,59 +0,0 @@
import React from "react";
import { Box, Flex, IconButton, Text } from "theme-ui";
function NumberInput({ value, onChange, title, min, max }) {
return (
<Box>
<Text sx={{ textAlign: "center" }} variant="heading" as="h1">
{title}
</Text>
<Flex sx={{ alignItems: "center", justifyContent: "center" }}>
<IconButton
aria-label={`Decrease ${title}`}
title={`Decrease ${title}`}
onClick={() => value > min && onChange(value - 1)}
>
<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 13H6c-.55 0-1-.45-1-1s.45-1 1-1h12c.55 0 1 .45 1 1s-.45 1-1 1z" />
</svg>
</IconButton>
<Text as="p" aria-label={`Current ${title}`}>
{value}
</Text>
<IconButton
aria-label={`Increase ${title}`}
title={`Increase ${title}`}
onClick={() => value < max && onChange(value + 1)}
>
<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 13h-5v5c0 .55-.45 1-1 1s-1-.45-1-1v-5H6c-.55 0-1-.45-1-1s.45-1 1-1h5V6c0-.55.45-1 1-1s1 .45 1 1v5h5c.55 0 1 .45 1 1s-.45 1-1 1z" />
</svg>
</IconButton>
</Flex>
</Box>
);
}
NumberInput.defaultProps = {
value: 1,
onChange: () => {},
title: "Number",
min: 0,
max: 10,
};
export default NumberInput;

View File

@ -24,7 +24,7 @@ function Select({ creatable, ...props }) {
}),
control: (provided, state) => ({
...provided,
backgroundColor: theme.colors.background,
backgroundColor: "transparent",
color: theme.colors.text,
borderColor: theme.colors.text,
opacity: state.isDisabled ? 0.5 : 1,
@ -53,6 +53,10 @@ function Select({ creatable, ...props }) {
color: theme.colors.text,
opacity: state.isDisabled ? 0.5 : 1,
}),
container: (provided) => ({
...provided,
margin: "4px 0",
}),
}}
theme={(t) => ({
...t,
@ -63,6 +67,7 @@ function Select({ creatable, ...props }) {
primary25: theme.colors.highlight,
},
})}
captureMenuScroll={false}
{...props}
/>
);

View File

@ -0,0 +1,22 @@
.textarea-auto-size {
box-sizing: border-box;
margin: 0;
min-width: 0;
display: block;
width: 100%;
appearance: none;
font-size: inherit;
line-height: inherit;
border-radius: 4px;
color: inherit;
background-color: transparent;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", sans-serif;
padding: 4px;
border: none;
resize: none;
}
.textarea-auto-size:focus {
outline: none;
}

View File

@ -0,0 +1,8 @@
import TextareaAutosize from "react-textarea-autosize";
import "./TextareaAutoSize.css";
function StyledTextareaAutoSize(props) {
return <TextareaAutosize className="textarea-auto-size" {...props} />;
}
export default StyledTextareaAutoSize;

View File

@ -0,0 +1,80 @@
import React, { useState, useEffect } from "react";
import { Text } from "theme-ui";
import LoadingOverlay from "./LoadingOverlay";
import { shuffle } from "../helpers/shared";
const facts = [
"Owls can rotate their necks 270 degrees",
"Not all owls hoot",
"Owl flight is almost completely silent",
"Owls are used to represent the Goddess Athena in Greek mythology",
"Owls have the best night vision of any animal",
"Bears can run up to 40 mi (~64 km) per hour ",
"A hibernating bears heart beats at 8 bpm",
"Bears can see in colour",
"Koala bears are not bears",
"A polar bear can swim up to 100 mi (~161 km) without resting",
"A group of bears is called a sleuth or sloth",
"Not all bears hibernate",
];
function UpgradingLoadingOverlay() {
const [subText, setSubText] = useState();
useEffect(() => {
let index = 0;
let randomFacts = shuffle(facts);
function updateFact() {
setSubText(randomFacts[index % (randomFacts.length - 1)]);
index += 1;
}
// Show first fact after 10 seconds then every 20 seconds after that
let interval;
let timeout = setTimeout(() => {
updateFact();
interval = setInterval(() => {
updateFact();
}, 20 * 1000);
}, 10 * 1000);
return () => {
clearTimeout(timeout);
if (interval) {
clearInterval(interval);
}
};
}, []);
return (
<LoadingOverlay>
<Text as="p" variant="body2" m={1}>
Database upgrading, please wait...
</Text>
{subText && (
<>
<Text
sx={{ maxWidth: "200px", textAlign: "center" }}
as="p"
variant="caption"
m={1}
>
We're still working on the upgrade. In the meantime, did you know?
</Text>
<Text
sx={{ maxWidth: "200px", textAlign: "center" }}
as="p"
variant="body2"
>
{subText}
</Text>
</>
)}
</LoadingOverlay>
);
}
export default UpgradingLoadingOverlay;

View File

@ -1,17 +1,20 @@
import React from "react";
import { Image } from "theme-ui";
import Tile from "../Tile";
import Tile from "../tile/Tile";
function DiceTile({ dice, isSelected, onDiceSelect, onDone, size }) {
function DiceTile({ dice, isSelected, onDiceSelect, onDone }) {
return (
<Tile
src={dice.preview}
title={dice.name}
isSelected={isSelected}
onSelect={() => onDiceSelect(dice)}
onDoubleClick={() => onDone(dice)}
size={size}
/>
<div style={{ cursor: "pointer" }}>
<Tile
title={dice.name}
isSelected={isSelected}
onSelect={() => onDiceSelect(dice)}
onDoubleClick={() => onDone(dice)}
>
<Image src={dice.preview}></Image>
</Tile>
</div>
);
}

View File

@ -1,5 +1,5 @@
import React from "react";
import { Flex } from "theme-ui";
import { Grid } from "theme-ui";
import SimpleBar from "simplebar-react";
import DiceTile from "./DiceTile";
@ -10,19 +10,17 @@ function DiceTiles({ dice, onDiceSelect, selectedDice, onDone }) {
const layout = useResponsiveLayout();
return (
<SimpleBar
style={{ height: layout.screenSize === "large" ? "600px" : "400px" }}
>
<Flex
<SimpleBar style={{ height: layout.tileContainerHeight }}>
<Grid
p={2}
pb={4}
bg="muted"
sx={{
flexWrap: "wrap",
borderRadius: "4px",
minHeight: layout.screenSize === "large" ? "600px" : "400px",
alignContent: "flex-start",
}}
gap={2}
columns={`repeat(${layout.tileGridColumns}, 1fr)`}
>
{dice.map((dice) => (
<DiceTile
@ -34,7 +32,7 @@ function DiceTiles({ dice, onDiceSelect, selectedDice, onDone }) {
size={layout.tileSize}
/>
))}
</Flex>
</Grid>
</SimpleBar>
);
}

View File

@ -0,0 +1,23 @@
import React from "react";
import { useDraggable } from "@dnd-kit/core";
function Draggable({ id, children, data }) {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id,
data,
});
const style = {
cursor: isDragging ? "grabbing" : "grab",
touchAction: "none",
opacity: isDragging ? 0.5 : undefined,
};
return (
<div ref={setNodeRef} style={style} {...listeners} {...attributes}>
{children}
</div>
);
}
export default Draggable;

View File

@ -0,0 +1,18 @@
import React from "react";
import { useDroppable } from "@dnd-kit/core";
function Droppable({ id, children, disabled, ...props }) {
const { setNodeRef } = useDroppable({ id, disabled });
return (
<div ref={setNodeRef} {...props}>
{children}
</div>
);
}
Droppable.defaultProps = {
disabled: false,
};
export default Droppable;

View File

@ -0,0 +1,248 @@
import React, { useState, useRef } from "react";
import { Box, Flex, Text } from "theme-ui";
import { useToasts } from "react-toast-notifications";
import LoadingOverlay from "../LoadingOverlay";
import ConfirmModal from "../../modals/ConfirmModal";
import { createMapFromFile } from "../../helpers/map";
import { createTokenFromFile } from "../../helpers/token";
import {
createTokenState,
clientPositionToMapPosition,
} from "../../helpers/token";
import Vector2 from "../../helpers/Vector2";
import { useUserId } from "../../contexts/UserIdContext";
import { useMapData } from "../../contexts/MapDataContext";
import { useTokenData } from "../../contexts/TokenDataContext";
import { useAssets } from "../../contexts/AssetsContext";
import { useMapStage } from "../../contexts/MapStageContext";
import useImageDrop from "../../hooks/useImageDrop";
function GlobalImageDrop({ children, onMapChange, onMapTokensStateCreate }) {
const { addToast } = useToasts();
const userId = useUserId();
const { addMap, getMapState } = useMapData();
const { addToken } = useTokenData();
const { addAssets } = useAssets();
const mapStageRef = useMapStage();
const [isLargeImageWarningModalOpen, setShowLargeImageWarning] = useState(
false
);
const [isLoading, setIsLoading] = useState(false);
const droppedImagesRef = useRef();
const dropPositionRef = useRef();
// maps or tokens
const [droppingType, setDroppingType] = useState("maps");
async function handleDrop(files, dropPosition) {
if (navigator.storage) {
// Attempt to enable persistant storage
await navigator.storage.persist();
}
dropPositionRef.current = dropPosition;
droppedImagesRef.current = [];
for (let file of files) {
if (file.size > 5e7) {
addToast(`Unable to import image ${file.name} as it is over 50MB`);
} else {
droppedImagesRef.current.push(file);
}
}
// Any file greater than 20MB
if (droppedImagesRef.current.some((file) => file.size > 2e7)) {
setShowLargeImageWarning(true);
return;
}
if (droppingType === "maps") {
await handleMaps();
} else {
await handleTokens();
}
}
function handleLargeImageWarningCancel() {
droppedImagesRef.current = undefined;
setShowLargeImageWarning(false);
}
async function handleLargeImageWarningConfirm() {
setShowLargeImageWarning(false);
if (droppingType === "maps") {
await handleMaps();
} else {
await handleTokens();
}
}
async function handleMaps() {
setIsLoading(true);
let maps = [];
for (let file of droppedImagesRef.current) {
const { map, assets } = await createMapFromFile(file, userId);
await addMap(map);
await addAssets(assets);
maps.push(map);
}
// Change map if only 1 dropped
if (maps.length === 1) {
const mapState = await getMapState(maps[0].id);
onMapChange(maps[0], mapState);
}
setIsLoading(false);
droppedImagesRef.current = undefined;
}
async function handleTokens() {
setIsLoading(true);
// Keep track of tokens so we can add them to the map
let tokens = [];
for (let file of droppedImagesRef.current) {
const { token, assets } = await createTokenFromFile(file, userId);
await addToken(token);
await addAssets(assets);
tokens.push(token);
}
setIsLoading(false);
droppedImagesRef.current = undefined;
const dropPosition = dropPositionRef.current;
const mapStage = mapStageRef.current;
if (mapStage && dropPosition) {
const mapPosition = clientPositionToMapPosition(mapStage, dropPosition);
if (mapPosition) {
let tokenStates = [];
let offset = new Vector2(0, 0);
for (let token of tokens) {
if (token) {
tokenStates.push(
createTokenState(token, Vector2.add(mapPosition, offset), userId)
);
offset = Vector2.add(offset, 0.01);
}
}
if (tokenStates.length > 0) {
onMapTokensStateCreate(tokenStates);
}
}
}
}
function handleMapsOver() {
setDroppingType("maps");
}
function handleTokensOver() {
setDroppingType("tokens");
}
const { dragging, containerListeners, overlayListeners } = useImageDrop(
handleDrop
);
return (
<Flex sx={{ height: "100%", flexGrow: 1 }} {...containerListeners}>
{children}
{dragging && (
<Flex
sx={{
position: "absolute",
top: 0,
right: 0,
left: 0,
bottom: 0,
cursor: "copy",
flexDirection: "column",
zIndex: 100,
}}
{...overlayListeners}
>
<Flex
sx={{
height: "10%",
justifyContent: "center",
alignItems: "center",
color: droppingType === "maps" ? "primary" : "text",
opacity: droppingType === "maps" ? 1 : 0.8,
width: "100%",
position: "relative",
}}
onDragEnter={handleMapsOver}
>
<Box
bg="overlay"
sx={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
margin: "4px 16px",
border: "1px dashed",
borderRadius: "12px",
pointerEvents: "none",
}}
/>
<Text sx={{ pointerEvents: "none", userSelect: "none" }}>
Drop as map
</Text>
</Flex>
<Flex
sx={{
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
color: droppingType === "tokens" ? "primary" : "text",
opacity: droppingType === "tokens" ? 1 : 0.8,
width: "100%",
position: "relative",
}}
onDragEnter={handleTokensOver}
>
<Box
bg="overlay"
sx={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
margin: "4px 16px",
border: "1px dashed",
borderRadius: "12px",
pointerEvents: "none",
}}
/>
<Text sx={{ pointerEvents: "none", userSelect: "none" }}>
Drop as token
</Text>
</Flex>
</Flex>
)}
<ConfirmModal
isOpen={isLargeImageWarningModalOpen}
onRequestClose={handleLargeImageWarningCancel}
onConfirm={handleLargeImageWarningConfirm}
confirmText="Continue"
label="Warning"
description="An imported image is larger than 20MB, this may cause slowness. Continue?"
/>
{isLoading && <LoadingOverlay bg="overlay" />}
</Flex>
);
}
export default GlobalImageDrop;

View File

@ -0,0 +1,37 @@
import React from "react";
import { Box, Flex, Text } from "theme-ui";
import useImageDrop from "../../hooks/useImageDrop";
function ImageDrop({ onDrop, dropText, children }) {
const { dragging, containerListeners, overlayListeners } = useImageDrop(
onDrop
);
return (
<Box {...containerListeners}>
{children}
{dragging && (
<Flex
bg="overlay"
sx={{
position: "absolute",
top: 0,
right: 0,
left: 0,
bottom: 0,
justifyContent: "center",
alignItems: "center",
cursor: "copy",
}}
{...overlayListeners}
>
<Text sx={{ pointerEvents: "none", color: "primary" }}>
{dropText || "Drop image to import"}
</Text>
</Flex>
)}
</Box>
);
}
export default ImageDrop;

View File

@ -1,7 +1,7 @@
import React, { useEffect, useRef, useState } from "react";
import { Box, IconButton } from "theme-ui";
import RemoveTokenIcon from "../icons/RemoveTokenIcon";
import RemoveTokenIcon from "../../icons/RemoveTokenIcon";
function DragOverlay({ dragging, node, onRemove }) {
const [isRemoveHovered, setIsRemoveHovered] = useState(false);

View File

@ -1,4 +1,6 @@
import React, { useState } from "react";
import { Box } from "theme-ui";
import { useToasts } from "react-toast-notifications";
import MapControls from "./MapControls";
import MapInteraction from "./MapInteraction";
@ -48,6 +50,8 @@ function Map({
disabledTokens,
session,
}) {
const { addToast } = useToasts();
const { tokensById } = useTokenData();
const [selectedToolId, setSelectedToolId] = useState("move");
@ -231,6 +235,7 @@ function Map({
onShapesCut={handleFogShapesCut}
onShapesRemove={handleFogShapesRemove}
onShapesEdit={handleFogShapesEdit}
onShapeError={addToast}
active={selectedToolId === "fog"}
toolSettings={settings.fog}
editable={allowFogDrawing && !settings.fog.preview}
@ -336,30 +341,32 @@ function Map({
);
return (
<MapInteraction
map={map}
mapState={mapState}
controls={
<>
{mapControls}
{tokenMenu}
{noteMenu}
{tokenDragOverlay}
{noteDragOverlay}
</>
}
selectedToolId={selectedToolId}
onSelectedToolChange={setSelectedToolId}
disabledControls={disabledControls}
>
{mapGrid}
{mapDrawing}
{mapNotes}
{mapTokens}
{mapFog}
{mapPointer}
{mapMeasure}
</MapInteraction>
<Box sx={{ flexGrow: 1 }}>
<MapInteraction
map={map}
mapState={mapState}
controls={
<>
{mapControls}
{tokenMenu}
{noteMenu}
{tokenDragOverlay}
{noteDragOverlay}
</>
}
selectedToolId={selectedToolId}
onSelectedToolChange={setSelectedToolId}
disabledControls={disabledControls}
>
{mapGrid}
{mapDrawing}
{mapNotes}
{mapTokens}
{mapFog}
{mapPointer}
{mapMeasure}
</MapInteraction>
</Box>
);
}

View File

@ -119,8 +119,7 @@ function MapDrawing({
}
const simplified = simplifyPoints(
[...prevPoints, brushPosition],
gridCellNormalizedSize,
stageScale
1 / 1000 / stageScale
);
return {
...prevShape,

View File

@ -0,0 +1,159 @@
import React, { useState, useEffect } from "react";
import { Flex, Close, IconButton } from "theme-ui";
import { groupsFromIds, itemsFromGroups } from "../../helpers/group";
import ConfirmModal from "../../modals/ConfirmModal";
import ResetMapIcon from "../../icons/ResetMapIcon";
import RemoveMapIcon from "../../icons/RemoveMapIcon";
import { useGroup } from "../../contexts/GroupContext";
import { useMapData } from "../../contexts/MapDataContext";
import { useKeyboard } from "../../contexts/KeyboardContext";
import shortcuts from "../../shortcuts";
function MapEditBar({ currentMap, disabled, onMapChange, onMapReset, onLoad }) {
const [hasMapState, setHasMapState] = useState(false);
const { maps, mapStates, removeMaps, resetMap } = useMapData();
const { activeGroups, selectedGroupIds, onGroupSelect } = useGroup();
useEffect(() => {
const selectedGroups = groupsFromIds(selectedGroupIds, activeGroups);
const selectedMapStates = itemsFromGroups(
selectedGroups,
mapStates,
"mapId"
);
let _hasMapState = false;
for (let state of selectedMapStates) {
if (
Object.values(state.tokens).length > 0 ||
Object.values(state.drawShapes).length > 0 ||
Object.values(state.fogShapes).length > 0 ||
Object.values(state.notes).length > 0
) {
_hasMapState = true;
break;
}
}
setHasMapState(_hasMapState);
}, [selectedGroupIds, mapStates, activeGroups]);
function getSelectedMaps() {
const selectedGroups = groupsFromIds(selectedGroupIds, activeGroups);
return itemsFromGroups(selectedGroups, maps);
}
const [isMapsRemoveModalOpen, setIsMapsRemoveModalOpen] = useState(false);
async function handleMapsRemove() {
onLoad(true);
setIsMapsRemoveModalOpen(false);
const selectedMaps = getSelectedMaps();
const selectedMapIds = selectedMaps.map((map) => map.id);
onGroupSelect();
await removeMaps(selectedMapIds);
// Removed the map from the map screen if needed
if (currentMap && selectedMapIds.includes(currentMap.id)) {
onMapChange(null, null);
}
onLoad(false);
}
const [isMapsResetModalOpen, setIsMapsResetModalOpen] = useState(false);
async function handleMapsReset() {
onLoad(true);
setIsMapsResetModalOpen(false);
const selectedMaps = getSelectedMaps();
const selectedMapIds = selectedMaps.map((map) => map.id);
for (let id of selectedMapIds) {
const newState = await resetMap(id);
// Reset the state of the current map if needed
if (currentMap && currentMap.id === id) {
onMapReset(newState);
}
}
onLoad(false);
}
/**
* Shortcuts
*/
function handleKeyDown(event) {
if (disabled) {
return;
}
if (shortcuts.delete(event)) {
const selectedMaps = getSelectedMaps();
if (selectedMaps.length > 0) {
setIsMapsResetModalOpen(false);
setIsMapsRemoveModalOpen(true);
}
}
}
useKeyboard(handleKeyDown);
if (selectedGroupIds.length === 0) {
return null;
}
return (
<Flex
sx={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
justifyContent: "space-between",
}}
bg="overlay"
>
<Close
title="Clear Selection"
aria-label="Clear Selection"
onClick={() => onGroupSelect()}
/>
<Flex>
<IconButton
aria-label="Reset Selected Map(s)"
title="Reset Selected Map(s)"
onClick={() => setIsMapsResetModalOpen(true)}
disabled={!hasMapState}
>
<ResetMapIcon />
</IconButton>
<IconButton
aria-label="Remove Selected Map(s)"
title="Remove Selected Map(s)"
onClick={() => setIsMapsRemoveModalOpen(true)}
>
<RemoveMapIcon />
</IconButton>
</Flex>
<ConfirmModal
isOpen={isMapsResetModalOpen}
onRequestClose={() => setIsMapsResetModalOpen(false)}
onConfirm={handleMapsReset}
confirmText="Reset"
label="Reset Selected Map(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 Selected Map(s)"
description="This operation cannot be undone."
/>
</Flex>
);
}
export default MapEditBar;

View File

@ -23,7 +23,7 @@ import MapGrid from "./MapGrid";
import MapGridEditor from "./MapGridEditor";
function MapEditor({ map, onSettingsChange }) {
const [mapImageSource] = useMapImage(map);
const [mapImage] = useMapImage(map);
const [stageWidth, setStageWidth] = useState(1);
const [stageHeight, setStageHeight] = useState(1);
@ -93,14 +93,14 @@ function MapEditor({ map, onSettingsChange }) {
interactionEmitter: null,
};
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;
const gridValid = map.grid.size.x !== 0 && map.grid.size.y !== 0;
const layout = useResponsiveLayout();
return (
@ -132,12 +132,8 @@ function MapEditor({ map, onSettingsChange }) {
)}
>
<Layer ref={mapLayerRef}>
<Image
image={mapImageSource}
width={mapWidth}
height={mapHeight}
/>
{showGridControls && canEditGrid && (
<Image image={mapImage} width={mapWidth} height={mapHeight} />
{showGridControls && gridValid && (
<>
<MapGrid map={map} />
<MapGridEditor map={map} onGridChange={handleGridChange} />
@ -146,7 +142,7 @@ function MapEditor({ map, onSettingsChange }) {
</Layer>
</KonvaBridge>
</ReactResizeDetector>
{gridChanged && (
{gridChanged && gridValid && (
<IconButton
title="Reset Grid"
aria-label="Reset Grid"
@ -163,28 +159,26 @@ function MapEditor({ map, onSettingsChange }) {
<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>
)}
<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>
</GridProvider>
</MapInteractionProvider>

View File

@ -1,7 +1,8 @@
import React, { useState, useEffect, useCallback, useRef } from "react";
import React, { useState, useEffect, useCallback } from "react";
import shortid from "shortid";
import { Group, Rect, Line } from "react-konva";
import { Group, Line } from "react-konva";
import useImage from "use-image";
import Color from "color";
import diagonalPattern from "../../images/DiagonalPattern.png";
@ -37,8 +38,10 @@ import {
Tick,
getRelativePointerPosition,
} from "../../helpers/konva";
import { keyBy } from "../../helpers/shared";
import SubtractShapeAction from "../../actions/SubtractShapeAction";
import CutShapeAction from "../../actions/CutShapeAction";
import useSetting from "../../hooks/useSetting";
@ -51,6 +54,7 @@ function MapFog({
onShapesCut,
onShapesRemove,
onShapesEdit,
onShapeError,
active,
toolSettings,
editable,
@ -175,8 +179,7 @@ function MapFog({
}
const simplified = simplifyPoints(
[...prevPoints, brushPosition],
gridCellNormalizedSize,
stageScale / 4
1 / 1000 / stageScale
);
return {
...prevShape,
@ -214,6 +217,8 @@ function MapFog({
) {
const cut = toolSettings.useFogCut;
let drawingShapes = [drawingShape];
// Filter out hidden or visible shapes if single layer enabled
if (!toolSettings.multilayer) {
const shapesToSubtract = shapes.filter((shape) =>
cut ? !shape.visible : shape.visible
@ -228,22 +233,32 @@ function MapFog({
}
if (drawingShapes.length > 0) {
drawingShapes = drawingShapes.map((shape) => {
if (cut) {
return {
id: shape.id,
type: shape.type,
data: shape.data,
};
} else {
return { ...shape, color: "black" };
}
});
if (cut) {
onShapesCut(drawingShapes);
// Run a pre-emptive cut action to check whether we've cut anything
const cutAction = new CutShapeAction(drawingShapes);
const state = cutAction.execute(keyBy(shapes, "id"));
if (Object.keys(state).length === shapes.length) {
onShapeError("No fog to cut");
} else {
onShapesCut(
drawingShapes.map((shape) => ({
id: shape.id,
type: shape.type,
data: shape.data,
}))
);
}
} else {
onShapesAdd(drawingShapes);
onShapesAdd(
drawingShapes.map((shape) => ({ ...shape, color: "black" }))
);
}
} else {
if (cut) {
onShapeError("Fog already cut");
} else {
onShapeError("Fog already placed");
}
}
setDrawingShape(null);
@ -373,6 +388,7 @@ function MapFog({
};
let polygonShapes = [polygonShape];
// Filter out hidden or visible shapes if single layer enabled
if (!toolSettings.multilayer) {
const shapesToSubtract = shapes.filter((shape) =>
cut ? !shape.visible : shape.visible
@ -388,7 +404,15 @@ function MapFog({
if (polygonShapes.length > 0) {
if (cut) {
onShapesCut(polygonShapes);
// Run a pre-emptive cut action to check whether we've cut anything
const cutAction = new CutShapeAction(polygonShapes);
const state = cutAction.execute(keyBy(shapes, "id"));
if (Object.keys(state).length === shapes.length) {
onShapeError("No fog to cut");
} else {
onShapesCut(polygonShapes);
}
} else {
onShapesAdd(
polygonShapes.map((shape) => ({
@ -399,10 +423,23 @@ function MapFog({
}))
);
}
} else {
if (cut) {
onShapeError("Fog already cut");
} else {
onShapeError("Fog already placed");
}
}
setDrawingShape(null);
}, [toolSettings, drawingShape, onShapesCut, onShapesAdd, shapes]);
}, [
toolSettings,
drawingShape,
onShapesCut,
onShapesAdd,
onShapeError,
shapes,
]);
// Add keyboard shortcuts
function handleKeyDown(event) {
@ -489,6 +526,15 @@ function MapFog({
const holes =
shape.data.holes &&
shape.data.holes.map((hole) => hole.reduce(reducePoints, []));
const opacity = editable ? editOpacity : 1;
// Control opacity only on fill as using opacity with stroke leads to performance issues
const fill = new Color(colors[shape.color] || shape.color)
.alpha(opacity)
.string();
const stroke =
editable && active
? colors.lightGray
: colors[shape.color] || shape.color;
return (
<HoleyLine
key={shape.id}
@ -499,19 +545,12 @@ function MapFog({
onMouseUp={eraseHoveredShapes}
onTouchEnd={eraseHoveredShapes}
points={points}
stroke={
editable && active
? colors.lightGray
: colors[shape.color] || shape.color
}
fill={colors[shape.color] || shape.color}
stroke={stroke}
fill={fill}
closed
lineCap="round"
lineJoin="round"
strokeWidth={gridStrokeWidth * shape.strokeWidth}
opacity={
editable ? (!shape.visible ? editOpacity / 2 : editOpacity) : 1
}
fillPatternImage={patternImage}
fillPriority={editable && !shape.visible ? "pattern" : "color"}
holes={holes}
@ -590,15 +629,9 @@ function MapFog({
}
}, [shapes, editable, active, toolSettings, shouldRenderGuides]);
const fogGroupRef = useRef();
return (
<Group>
<Group ref={fogGroupRef}>
{/* Render a blank shape so cache works with no fog shapes */}
<Rect width={1} height={1} />
{fogShapes.map(renderShape)}
</Group>
<Group>{fogShapes.map(renderShape)}</Group>
{shouldRenderGuides && renderGuides()}
{drawingShape && renderShape(drawingShape)}
{drawingShape &&

View File

@ -1,7 +1,7 @@
import React, { useEffect, useState } from "react";
import useImage from "use-image";
import { useImageSource } from "../../contexts/ImageSourceContext";
import { useDataURL } from "../../contexts/AssetsContext";
import { mapSources as defaultMapSources } from "../../maps";
@ -11,15 +11,13 @@ import Grid from "../Grid";
function MapGrid({ map }) {
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 = useImageSource(mapSourceMap, defaultMapSources);
const [mapImage, mapLoadingStatus] = useImage(mapSource);
const mapURL = useDataURL(
mapSourceMap,
defaultMapSources,
undefined,
map.type === "file"
);
const [mapImage, mapLoadingStatus] = useImage(mapURL);
const [isImageLight, setIsImageLight] = useState(true);

View File

@ -77,7 +77,10 @@ function MapGridEditor({ map, onGridChange }) {
Vector2.subtract(position, previousPosition)
);
const inset = map.grid.inset;
const inset = {
topLeft: { ...map.grid.inset.topLeft },
bottomRight: { ...map.grid.inset.bottomRight },
};
if (direction.x === 0 && direction.y === 0) {
return inset;

View File

@ -0,0 +1,18 @@
import React from "react";
import { Image } from "theme-ui";
import { useDataURL } from "../../contexts/AssetsContext";
import { mapSources as defaultMapSources } from "../../maps";
const MapTileImage = React.forwardRef(({ map, ...props }, ref) => {
const mapURL = useDataURL(
map,
defaultMapSources,
undefined,
map.type === "file"
);
return <Image src={mapURL} ref={ref} {...props} />;
});
export default MapTileImage;

View File

@ -28,22 +28,16 @@ function MapInteraction({
onSelectedToolChange,
disabledControls,
}) {
const [mapImageSource, mapImageSourceStatus] = useMapImage(map);
const [mapImage, mapImageStatus] = useMapImage(map);
// Map loaded taking in to account different resolutions
const [mapLoaded, setMapLoaded] = useState(false);
useEffect(() => {
if (
!map ||
!mapState ||
(map.type === "file" && !map.file && !map.resolutions) ||
mapState.mapId !== map.id
) {
if (!map || !mapState || mapState.mapId !== map.id) {
setMapLoaded(false);
} else if (mapImageSourceStatus === "loaded") {
} else if (mapImageStatus === "loaded") {
setMapLoaded(true);
}
}, [mapImageSourceStatus, map, mapState]);
}, [mapImageStatus, map, mapState]);
const [stageWidth, setStageWidth] = useState(1);
const [stageHeight, setStageHeight] = useState(1);
@ -187,11 +181,12 @@ function MapInteraction({
<GridProvider grid={map?.grid} width={mapWidth} height={mapHeight}>
<Box
sx={{
flexGrow: 1,
position: "relative",
cursor: getCursorForTool(selectedToolId),
touchAction: "none",
outline: "none",
width: "100%",
height: "100%",
}}
ref={containerRef}
className="map"
@ -211,7 +206,7 @@ function MapInteraction({
>
<Layer ref={mapLayerRef}>
<Image
image={mapLoaded && mapImageSource}
image={mapLoaded && mapImage}
width={mapWidth}
height={mapHeight}
id="mapImage"

View File

@ -44,7 +44,10 @@ function MapMeasure({ map, active }) {
const gridScale = parseGridScale(active && grid.measurement.scale);
const snapPositionToGrid = useGridSnapping();
const snapPositionToGrid = useGridSnapping(
grid.measurement.type === "euclidean" ? 0 : 1,
false
);
useEffect(() => {
if (!active) {

View File

@ -4,7 +4,7 @@ import { Group } from "react-konva";
import { useInteractionEmitter } from "../../contexts/MapInteractionContext";
import { useMapStage } from "../../contexts/MapStageContext";
import { useAuth } from "../../contexts/AuthContext";
import { useUserId } from "../../contexts/UserIdContext";
import Vector2 from "../../helpers/Vector2";
import { getRelativePointerPosition } from "../../helpers/konva";
@ -28,7 +28,7 @@ function MapNotes({
fadeOnHover,
}) {
const interactionEmitter = useInteractionEmitter();
const { userId } = useAuth();
const userId = useUserId();
const mapStageRef = useMapStage();
const [isBrushDown, setIsBrushDown] = useState(false);
const [noteData, setNoteData] = useState(null);

View File

@ -1,11 +1,12 @@
import React from "react";
import { Flex, Box, Label, Input, Checkbox, IconButton } from "theme-ui";
import ExpandMoreIcon from "../../icons/ExpandMoreIcon";
import React, { useEffect, useState } from "react";
import { Flex, Box, Label, Input, Checkbox } from "theme-ui";
import { isEmpty } from "../../helpers/shared";
import { getGridUpdatedInset } from "../../helpers/grid";
import { useDataURL } from "../../contexts/AssetsContext";
import { mapSources as defaultMapSources } from "../../maps";
import Divider from "../Divider";
import Select from "../Select";
@ -40,8 +41,6 @@ function MapSettings({
mapState,
onSettingsChange,
onStateSettingsChange,
showMore,
onShowMoreChange,
}) {
function handleFlagChange(event, flag) {
if (event.target.checked) {
@ -116,16 +115,22 @@ function MapSettings({
onSettingsChange("grid", grid);
}
function getMapSize() {
let size = 0;
if (map.quality === "original") {
size = map.file.length;
} else {
size = map.resolutions[map.quality].file.length;
const mapURL = useDataURL(map, defaultMapSources);
const [mapSize, setMapSize] = useState(0);
useEffect(() => {
async function updateMapSize() {
if (mapURL) {
const response = await fetch(mapURL);
const blob = await response.blob();
let size = blob.size;
size /= 1000000; // Bytes to Megabytes
setMapSize(size.toFixed(2));
} else {
setMapSize(0);
}
}
size /= 1000000; // Bytes to Megabytes
return `${size.toFixed(2)}MB`;
}
updateMapSize();
}, [mapURL]);
const mapEmpty = !map || isEmpty(map);
const mapStateEmpty = !mapState || isEmpty(mapState);
@ -140,7 +145,7 @@ function MapSettings({
name="gridX"
value={`${(map && map.grid.size.x) || 0}`}
onChange={handleGridSizeXChange}
disabled={mapEmpty || map.type === "default"}
disabled={mapEmpty}
min={1}
my={1}
/>
@ -152,7 +157,7 @@ function MapSettings({
name="gridY"
value={`${(map && map.grid.size.y) || 0}`}
onChange={handleGridSizeYChange}
disabled={mapEmpty || map.type === "default"}
disabled={mapEmpty}
min={1}
my={1}
/>
@ -164,176 +169,146 @@ function MapSettings({
name="name"
value={(map && map.name) || ""}
onChange={(e) => onSettingsChange("name", e.target.value)}
disabled={mapEmpty || map.type === "default"}
disabled={mapEmpty}
my={1}
/>
</Box>
{showMore && (
<>
<Flex
mt={2}
mb={mapEmpty || map.type === "default" ? 2 : 0}
sx={{ flexDirection: "column" }}
>
<Flex sx={{ alignItems: "flex-end" }}>
<Box mb={1} sx={{ width: "50%" }}>
<Label mb={1}>Grid Type</Label>
<Select
isDisabled={mapEmpty || map.type === "default"}
options={gridTypeSettings}
value={
!mapEmpty &&
gridTypeSettings.find((s) => s.value === map.grid.type)
}
onChange={handleGridTypeChange}
isSearchable={false}
/>
</Box>
<Flex sx={{ width: "50%", flexDirection: "column" }} ml={2}>
<Label>
<Checkbox
checked={!mapEmpty && map.showGrid}
disabled={mapEmpty || map.type === "default"}
onChange={(e) =>
onSettingsChange("showGrid", e.target.checked)
}
/>
Draw Grid
</Label>
<Label>
<Checkbox
checked={!mapEmpty && map.snapToGrid}
disabled={mapEmpty || map.type === "default"}
onChange={(e) =>
onSettingsChange("snapToGrid", e.target.checked)
}
/>
Snap to Grid
</Label>
</Flex>
</Flex>
<Flex sx={{ alignItems: "flex-end" }}>
<Box my={2} sx={{ width: "50%" }}>
<Label mb={1}>Grid Measurement</Label>
<Select
isDisabled={mapEmpty || map.type === "default"}
options={
map && map.grid.type === "square"
? gridSquareMeasurementTypeSettings
: gridHexMeasurementTypeSettings
}
value={
!mapEmpty &&
gridSquareMeasurementTypeSettings.find(
(s) => s.value === map.grid.measurement.type
)
}
onChange={handleGridMeasurementTypeChange}
isSearchable={false}
/>
</Box>
<Box mb={1} mx={2} sx={{ flexGrow: 1 }}>
<Label htmlFor="gridMeasurementScale">Grid Scale</Label>
<Input
name="gridMeasurementScale"
value={`${map && map.grid.measurement.scale}`}
onChange={handleGridMeasurementScaleChange}
disabled={mapEmpty || map.type === "default"}
min={1}
my={1}
autoComplete="off"
/>
</Box>
</Flex>
</Flex>
{!mapEmpty && map.type !== "default" && (
<Flex my={2} sx={{ alignItems: "center" }}>
<Box mb={1} sx={{ width: "50%" }}>
<Label mb={1}>Quality</Label>
<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()}
</Label>
</Flex>
)}
<Divider fill />
<Box my={2} sx={{ flexGrow: 1 }}>
<Label>Allow Others to Edit</Label>
<Flex my={1}>
<Label>
<Checkbox
checked={!mapStateEmpty && mapState.editFlags.includes("fog")}
disabled={mapStateEmpty}
onChange={(e) => handleFlagChange(e, "fog")}
/>
Fog
</Label>
<Label>
<Checkbox
checked={
!mapStateEmpty && mapState.editFlags.includes("drawing")
}
disabled={mapStateEmpty}
onChange={(e) => handleFlagChange(e, "drawing")}
/>
Drawings
</Label>
<Label>
<Checkbox
checked={
!mapStateEmpty && mapState.editFlags.includes("tokens")
}
disabled={mapStateEmpty}
onChange={(e) => handleFlagChange(e, "tokens")}
/>
Tokens
</Label>
<Label>
<Checkbox
checked={
!mapStateEmpty && mapState.editFlags.includes("notes")
}
disabled={mapStateEmpty}
onChange={(e) => handleFlagChange(e, "notes")}
/>
Notes
</Label>
</Flex>
</Box>
</>
)}
<IconButton
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onShowMoreChange(!showMore);
}}
sx={{
transform: `rotate(${showMore ? "180deg" : "0"})`,
alignSelf: "center",
}}
aria-label={showMore ? "Show Less" : "Show More"}
title={showMore ? "Show Less" : "Show More"}
<Flex
mt={2}
mb={mapEmpty || map.type === "default" ? 2 : 0}
sx={{ flexDirection: "column" }}
>
<ExpandMoreIcon />
</IconButton>
<Flex sx={{ alignItems: "flex-end" }}>
<Box sx={{ width: "50%" }}>
<Label>Grid Type</Label>
<Select
isDisabled={mapEmpty}
options={gridTypeSettings}
value={
!mapEmpty &&
gridTypeSettings.find((s) => s.value === map.grid.type)
}
onChange={handleGridTypeChange}
isSearchable={false}
/>
</Box>
<Flex sx={{ flexGrow: 1, flexDirection: "column" }} ml={2}>
<Label>
<Checkbox
checked={!mapEmpty && map.showGrid}
disabled={mapEmpty}
onChange={(e) => onSettingsChange("showGrid", e.target.checked)}
/>
Draw Grid
</Label>
<Label>
<Checkbox
checked={!mapEmpty && map.snapToGrid}
disabled={mapEmpty}
onChange={(e) =>
onSettingsChange("snapToGrid", e.target.checked)
}
/>
Snap to Grid
</Label>
</Flex>
</Flex>
<Flex sx={{ alignItems: "flex-end" }}>
<Box my={2} sx={{ width: "50%" }}>
<Label>Grid Measurement</Label>
<Select
isDisabled={mapEmpty}
options={
map && map.grid.type === "square"
? gridSquareMeasurementTypeSettings
: gridHexMeasurementTypeSettings
}
value={
!mapEmpty &&
gridSquareMeasurementTypeSettings.find(
(s) => s.value === map.grid.measurement.type
)
}
onChange={handleGridMeasurementTypeChange}
isSearchable={false}
/>
</Box>
<Box m={2} mr={0} sx={{ flexGrow: 1 }}>
<Label htmlFor="gridMeasurementScale">Grid Scale</Label>
<Input
name="gridMeasurementScale"
value={`${map && map.grid.measurement.scale}`}
onChange={handleGridMeasurementScaleChange}
disabled={mapEmpty}
min={1}
my={1}
autoComplete="off"
/>
</Box>
</Flex>
</Flex>
{!mapEmpty && map.type !== "default" && (
<Flex my={2} sx={{ alignItems: "center" }}>
<Box mb={1} sx={{ width: "50%" }}>
<Label>Quality</Label>
<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: {mapSize > 0 && `${mapSize}MB`}
</Label>
</Flex>
)}
<Divider fill />
<Box my={2} sx={{ flexGrow: 1 }}>
<Label>Allow Others to Edit</Label>
<Flex my={1}>
<Label>
<Checkbox
checked={!mapStateEmpty && mapState.editFlags.includes("fog")}
disabled={mapStateEmpty}
onChange={(e) => handleFlagChange(e, "fog")}
/>
Fog
</Label>
<Label>
<Checkbox
checked={!mapStateEmpty && mapState.editFlags.includes("drawing")}
disabled={mapStateEmpty}
onChange={(e) => handleFlagChange(e, "drawing")}
/>
Drawings
</Label>
<Label>
<Checkbox
checked={!mapStateEmpty && mapState.editFlags.includes("tokens")}
disabled={mapStateEmpty}
onChange={(e) => handleFlagChange(e, "tokens")}
/>
Tokens
</Label>
<Label>
<Checkbox
checked={!mapStateEmpty && mapState.editFlags.includes("notes")}
disabled={mapStateEmpty}
onChange={(e) => handleFlagChange(e, "notes")}
/>
Notes
</Label>
</Flex>
</Box>
</Flex>
);
}

View File

@ -1,5 +0,0 @@
import React from "react";
function MapTest() {}
export default MapTest;

View File

@ -1,40 +1,30 @@
import React from "react";
import Tile from "../Tile";
import { useImageSource } from "../../contexts/ImageSourceContext";
import { mapSources as defaultMapSources, unknownSource } from "../../maps";
import Tile from "../tile/Tile";
import MapImage from "./MapImage";
function MapTile({
map,
isSelected,
onMapSelect,
onMapEdit,
onDone,
size,
onSelect,
onEdit,
onDoubleClick,
canEdit,
badges,
}) {
const mapSource = useImageSource(
map,
defaultMapSources,
unknownSource,
map.type === "file"
);
return (
<Tile
src={mapSource}
title={map.name}
isSelected={isSelected}
onSelect={() => onMapSelect(map)}
onEdit={() => onMapEdit(map.id)}
onDoubleClick={() => canEdit && onDone()}
size={size}
onSelect={() => onSelect(map.id)}
onEdit={() => onEdit(map.id)}
onDoubleClick={() => canEdit && onDoubleClick()}
canEdit={canEdit}
badges={badges}
editTitle="Edit Map"
/>
>
<MapImage map={map} />
</Tile>
);
}

View File

@ -0,0 +1,41 @@
import React from "react";
import { Grid } from "theme-ui";
import Tile from "../tile/Tile";
import MapImage from "./MapImage";
import useResponsiveLayout from "../../hooks/useResponsiveLayout";
function MapTileGroup({ group, maps, isSelected, onSelect, onDoubleClick }) {
const layout = useResponsiveLayout();
return (
<Tile
title={group.name}
isSelected={isSelected}
onSelect={() => onSelect(group.id)}
onDoubleClick={onDoubleClick}
>
<Grid
columns={`repeat(${layout.groupGridColumns}, 1fr)`}
p={2}
sx={{
gridGap: 2,
gridTemplateRows: `repeat(${layout.groupGridColumns}, 1fr)`,
}}
>
{maps
.slice(0, layout.groupGridColumns * layout.groupGridColumns)
.map((map) => (
<MapImage
sx={{ borderRadius: "8px" }}
map={map}
key={`${map.id}-group-tile`}
/>
))}
</Grid>
</Tile>
);
}
export default MapTileGroup;

View File

@ -1,179 +1,68 @@
import React from "react";
import { Flex, Box, Text, IconButton, Close, Label } from "theme-ui";
import SimpleBar from "simplebar-react";
import Case from "case";
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 MapTileGroup from "./MapTileGroup";
import { useDatabase } from "../../contexts/DatabaseContext";
import SortableTiles from "../tile/SortableTiles";
import SortableTilesDragOverlay from "../tile/SortableTilesDragOverlay";
import useResponsiveLayout from "../../hooks/useResponsiveLayout";
import { getGroupItems } from "../../helpers/group";
function MapTiles({
maps,
groups,
selectedMaps,
selectedMapStates,
onMapSelect,
onMapsRemove,
onMapsReset,
onMapAdd,
onMapEdit,
onDone,
selectMode,
onSelectModeChange,
search,
onSearchChange,
onMapsGroup,
}) {
const { databaseStatus } = useDatabase();
const layout = useResponsiveLayout();
import { useGroup } from "../../contexts/GroupContext";
let hasMapState = false;
for (let state of selectedMapStates) {
if (
Object.values(state.tokens).length > 0 ||
Object.values(state.drawShapes).length > 0 ||
Object.values(state.fogShapes).length > 0 ||
Object.values(state.notes).length > 0
) {
hasMapState = true;
break;
function MapTiles({ mapsById, onMapEdit, onMapSelect, subgroup }) {
const {
selectedGroupIds,
selectMode,
onGroupOpen,
onGroupSelect,
} = useGroup();
function renderTile(group) {
if (group.type === "item") {
const map = mapsById[group.id];
if (map) {
const isSelected = selectedGroupIds.includes(group.id);
const canEdit =
isSelected &&
selectMode === "single" &&
selectedGroupIds.length === 1;
return (
<MapTile
key={map.id}
map={map}
isSelected={isSelected}
onSelect={onGroupSelect}
onEdit={onMapEdit}
onDoubleClick={() => canEdit && onMapSelect(group.id)}
canEdit={canEdit}
badges={[`${map.grid.size.x}x${map.grid.size.y}`]}
/>
);
}
} else {
const isSelected = selectedGroupIds.includes(group.id);
const items = getGroupItems(group);
const canOpen =
isSelected && selectMode === "single" && selectedGroupIds.length === 1;
return (
<MapTileGroup
key={group.id}
group={group}
maps={items.map((item) => mapsById[item.id])}
isSelected={isSelected}
onSelect={onGroupSelect}
onDoubleClick={() => canOpen && onGroupOpen(group.id)}
/>
);
}
}
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}
size={layout.tileSize}
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" }}>
<FilterBar
onFocus={() => onMapSelect()}
search={search}
onSearchChange={onSearchChange}
selectMode={selectMode}
onSelectModeChange={onSelectModeChange}
onAdd={onMapAdd}
addTitle="Add Map"
/>
<SimpleBar
style={{ height: layout.screenSize === "large" ? "600px" : "400px" }}
>
<Flex
p={2}
pb={4}
pt={databaseStatus === "disabled" ? 4 : 2}
bg="muted"
sx={{
flexWrap: "wrap",
borderRadius: "4px",
minHeight: layout.screenSize === "large" ? "600px" : "400px",
alignContent: "flex-start",
}}
onClick={() => onMapSelect()}
>
{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" && (
<Box
sx={{
position: "absolute",
top: "39px",
left: 0,
right: 0,
textAlign: "center",
borderRadius: "2px",
}}
bg="highlight"
p={1}
>
<Text as="p" variant="body2">
Map saving is unavailable. See <Link to="/faq#saving">FAQ</Link> for
more information.
</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>
<>
<SortableTiles renderTile={renderTile} subgroup={subgroup} />
<SortableTilesDragOverlay renderTile={renderTile} subgroup={subgroup} />
</>
);
}

View File

@ -1,30 +1,29 @@
import React, { useState, useEffect, useRef } from "react";
import React, { useState, useRef } from "react";
import { Image as KonvaImage, Group } from "react-konva";
import { useSpring, animated } from "react-spring/konva";
import useImage from "use-image";
import Konva from "konva";
import useDebounce from "../../hooks/useDebounce";
import usePrevious from "../../hooks/usePrevious";
import useGridSnapping from "../../hooks/useGridSnapping";
import { useAuth } from "../../contexts/AuthContext";
import { useUserId } from "../../contexts/UserIdContext";
import {
useSetPreventMapInteraction,
useMapWidth,
useMapHeight,
useDebouncedStageScale,
} from "../../contexts/MapInteractionContext";
import { useGridCellPixelSize } from "../../contexts/GridContext";
import { useImageSource } from "../../contexts/ImageSourceContext";
import { useDataURL } from "../../contexts/AssetsContext";
import TokenStatus from "../token/TokenStatus";
import TokenLabel from "../token/TokenLabel";
import TokenOutline from "../token/TokenOutline";
import { tokenSources, unknownSource } from "../../tokens";
import { Intersection, getScaledOutline } from "../../helpers/token";
import { tokenSources } from "../../tokens";
function MapToken({
token,
tokenState,
onTokenStateChange,
onTokenMenuOpen,
@ -34,34 +33,31 @@ function MapToken({
fadeOnHover,
map,
}) {
const { userId } = useAuth();
const userId = useUserId();
const stageScale = useDebouncedStageScale();
const mapWidth = useMapWidth();
const mapHeight = useMapHeight();
const setPreventMapInteraction = useSetPreventMapInteraction();
const gridCellPixelSize = useGridCellPixelSize();
const tokenSource = useImageSource(token, tokenSources, unknownSource);
const [tokenSourceImage, tokenSourceStatus] = useImage(tokenSource);
const [tokenAspectRatio, setTokenAspectRatio] = useState(1);
const tokenURL = useDataURL(tokenState, tokenSources);
const [tokenImage] = useImage(tokenURL);
useEffect(() => {
if (tokenSourceImage) {
setTokenAspectRatio(tokenSourceImage.width / tokenSourceImage.height);
}
}, [tokenSourceImage]);
const tokenAspectRatio = tokenState.width / tokenState.height;
const snapPositionToGrid = useGridSnapping();
function handleDragStart(event) {
const tokenGroup = event.target;
const tokenImage = imageRef.current;
if (token && token.category === "vehicle") {
// Enable hit detection for .intersects() function
Konva.hitOnDragEnabled = true;
if (tokenState.category === "vehicle") {
const tokenIntersection = new Intersection(
getScaledOutline(tokenState, tokenWidth, tokenHeight),
{ x: tokenX - tokenWidth / 2, y: tokenY - tokenHeight / 2 },
{ x: tokenX, y: tokenY },
tokenState.rotation
);
// Find all other tokens on the map
const layer = tokenGroup.getLayer();
@ -70,12 +66,7 @@ function MapToken({
if (other === tokenGroup) {
continue;
}
const otherRect = other.getClientRect();
const otherCenter = {
x: otherRect.x + otherRect.width / 2,
y: otherRect.y + otherRect.height / 2,
};
if (tokenImage.intersects(otherCenter)) {
if (tokenIntersection.intersects(other.position())) {
// Save and restore token position after moving layer
const position = other.absolutePosition();
other.moveTo(tokenGroup);
@ -99,9 +90,7 @@ function MapToken({
const tokenGroup = event.target;
const mountChanges = {};
if (token && token.category === "vehicle") {
Konva.hitOnDragEnabled = false;
if (tokenState.category === "vehicle") {
const parent = tokenGroup.getParent();
const mountedTokens = tokenGroup.find(".character");
for (let mountedToken of mountedTokens) {
@ -185,33 +174,6 @@ function MapToken({
const tokenWidth = minCellSize * tokenState.size;
const tokenHeight = (minCellSize / tokenAspectRatio) * tokenState.size;
const debouncedStageScale = useDebounce(stageScale, 50);
const imageRef = useRef();
useEffect(() => {
const image = imageRef.current;
if (!image) {
return;
}
const canvas = image.getCanvas();
const pixelRatio = canvas.pixelRatio || 1;
if (tokenSourceStatus === "loaded" && tokenWidth > 0 && tokenHeight > 0) {
const maxImageSize = token ? Math.max(token.width, token.height) : 512; // Default to 512px
const maxTokenSize = Math.max(tokenWidth, tokenHeight);
// Constrain image buffer to original image size
const maxRatio = maxImageSize / maxTokenSize;
image.cache({
pixelRatio: Math.min(
Math.max(debouncedStageScale * pixelRatio, 1),
maxRatio
),
});
image.drawHitFromCache();
}
}, [debouncedStageScale, tokenWidth, tokenHeight, tokenSourceStatus, token]);
// Animate to new token positions if edited by others
const tokenX = tokenState.x * mapWidth;
const tokenY = tokenState.y * mapHeight;
@ -232,8 +194,8 @@ function MapToken({
// Token name is used by on click to find whether a token is a vehicle or prop
let tokenName = "";
if (token) {
tokenName = token.category;
if (tokenState) {
tokenName = tokenState.category;
}
if (tokenState && tokenState.locked) {
tokenName = tokenName + "-locked";
@ -260,28 +222,46 @@ function MapToken({
name={tokenName}
id={tokenState.id}
>
<KonvaImage
ref={imageRef}
<Group
width={tokenWidth}
height={tokenHeight}
x={0}
y={0}
image={tokenSourceImage}
rotation={tokenState.rotation}
offsetX={tokenWidth / 2}
offsetY={tokenHeight / 2}
>
<TokenOutline
outline={getScaledOutline(tokenState, tokenWidth, tokenHeight)}
hidden={!!tokenImage}
/>
</Group>
<KonvaImage
width={tokenWidth}
height={tokenHeight}
x={0}
y={0}
image={tokenImage}
rotation={tokenState.rotation}
offsetX={tokenWidth / 2}
offsetY={tokenHeight / 2}
hitFunc={() => {}}
/>
<Group offsetX={tokenWidth / 2} offsetY={tokenHeight / 2}>
<TokenStatus
tokenState={tokenState}
width={tokenWidth}
height={tokenHeight}
/>
<TokenLabel
tokenState={tokenState}
width={tokenWidth}
height={tokenHeight}
/>
{tokenState.statuses?.length > 0 ? (
<TokenStatus
tokenState={tokenState}
width={tokenWidth}
height={tokenHeight}
/>
) : null}
{tokenState.label ? (
<TokenLabel
tokenState={tokenState}
width={tokenWidth}
height={tokenHeight}
/>
) : null}
</Group>
</animated.Group>
);

View File

@ -1,10 +1,8 @@
import React, { useEffect } from "react";
import React from "react";
import { Group } from "react-konva";
import MapToken from "./MapToken";
import { useTokenData } from "../../contexts/TokenDataContext";
function MapTokens({
map,
mapState,
@ -15,31 +13,6 @@ function MapTokens({
selectedToolId,
disabledTokens,
}) {
const { tokensById, loadTokens } = useTokenData();
// Ensure tokens files have been loaded into the token data
useEffect(() => {
async function loadFileTokens() {
const tokenIds = new Set(
Object.values(mapState.tokens).map((state) => state.tokenId)
);
const tokensToLoad = [];
for (let tokenId of tokenIds) {
const token = tokensById[tokenId];
if (token && token.type === "file" && !token.file) {
tokensToLoad.push(tokenId);
}
}
if (tokensToLoad.length > 0) {
await loadTokens(tokensToLoad);
}
}
if (mapState) {
loadFileTokens();
}
}, [mapState, tokensById, loadTokens]);
function getMapTokenCategoryWeight(category) {
switch (category) {
case "character":
@ -55,38 +28,28 @@ function MapTokens({
// Sort so vehicles render below other tokens
function sortMapTokenStates(a, b, tokenDraggingOptions) {
const tokenA = tokensById[a.tokenId];
const tokenB = tokensById[b.tokenId];
if (tokenA && tokenB) {
// If categories are different sort in order "prop", "vehicle", "character"
if (tokenB.category !== tokenA.category) {
const aWeight = getMapTokenCategoryWeight(tokenA.category);
const bWeight = getMapTokenCategoryWeight(tokenB.category);
return bWeight - aWeight;
} else if (
tokenDraggingOptions &&
tokenDraggingOptions.dragging &&
tokenDraggingOptions.tokenState.id === a.id
) {
// If dragging token a move above
return 1;
} else if (
tokenDraggingOptions &&
tokenDraggingOptions.dragging &&
tokenDraggingOptions.tokenState.id === b.id
) {
// If dragging token b move above
return -1;
} else {
// Else sort so last modified is on top
return a.lastModified - b.lastModified;
}
} else if (tokenA) {
// If categories are different sort in order "prop", "vehicle", "character"
if (b.category !== a.category) {
const aWeight = getMapTokenCategoryWeight(a.category);
const bWeight = getMapTokenCategoryWeight(b.category);
return bWeight - aWeight;
} else if (
tokenDraggingOptions &&
tokenDraggingOptions.dragging &&
tokenDraggingOptions.tokenState.id === a.id
) {
// If dragging token a move above
return 1;
} else if (tokenB) {
} else if (
tokenDraggingOptions &&
tokenDraggingOptions.dragging &&
tokenDraggingOptions.tokenState.id === b.id
) {
// If dragging token b move above
return -1;
} else {
return 0;
// Else sort so last modified is on top
return a.lastModified - b.lastModified;
}
}
@ -97,7 +60,6 @@ function MapTokens({
.map((tokenState) => (
<MapToken
key={tokenState.id}
token={tokensById[tokenState.tokenId]}
tokenState={tokenState}
onTokenStateChange={onMapTokenStateChange}
onTokenMenuOpen={handleTokenMenuOpen}

View File

@ -5,7 +5,7 @@ import SelectMapModal from "../../modals/SelectMapModal";
import SelectMapIcon from "../../icons/SelectMapIcon";
import { useMapData } from "../../contexts/MapDataContext";
import { useAuth } from "../../contexts/AuthContext";
import { useUserId } from "../../contexts/UserIdContext";
function SelectMapButton({
onMapChange,
@ -17,7 +17,7 @@ function SelectMapButton({
const [isModalOpen, setIsModalOpen] = useState(false);
const { updateMapState } = useMapData();
const { userId } = useAuth();
const userId = useUserId();
function openModal() {
if (currentMapState && currentMap && currentMap.owner === userId) {
updateMapState(currentMapState.mapId, currentMapState);

View File

@ -0,0 +1,32 @@
import React from "react";
import { Button } from "theme-ui";
import { useGroup } from "../../contexts/GroupContext";
import { findGroup } from "../../helpers/group";
function SelectMapSelectButton({ onMapSelect, disabled }) {
const { activeGroups, selectedGroupIds } = useGroup();
function handleSelectClick() {
if (selectedGroupIds.length === 1) {
const group = findGroup(activeGroups, selectedGroupIds[0]);
if (group && group.type === "item") {
onMapSelect(group.id);
}
}
}
return (
<Button
variant="primary"
disabled={disabled || selectedGroupIds.length > 1}
onClick={handleSelectClick}
mt={2}
>
Select
</Button>
);
}
export default SelectMapSelectButton;

View File

@ -2,7 +2,7 @@ import React, { useEffect, useState, useRef } from "react";
import { Rect, Text } from "react-konva";
import { useSpring, animated } from "react-spring/konva";
import { useAuth } from "../../contexts/AuthContext";
import { useUserId } from "../../contexts/UserIdContext";
import {
useSetPreventMapInteraction,
useMapWidth,
@ -15,7 +15,7 @@ import colors from "../../helpers/colors";
import usePrevious from "../../hooks/usePrevious";
import useGridSnapping from "../../hooks/useGridSnapping";
const minTextSize = 16;
const defaultFontSize = 16;
function Note({
note,
@ -27,7 +27,7 @@ function Note({
onNoteDragEnd,
fadeOnHover,
}) {
const { userId } = useAuth();
const userId = useUserId();
const mapWidth = useMapWidth();
const mapHeight = useMapHeight();
@ -118,7 +118,7 @@ function Note({
}
}
const [fontSize, setFontSize] = useState(1);
const [fontScale, setFontScale] = useState(1);
useEffect(() => {
const text = textRef.current;
@ -127,10 +127,10 @@ function Note({
}
function findFontSize() {
// Create an array from 1 / minTextSize of the note height to the full note height
const sizes = Array.from(
// Create an array from 1 / defaultFontSize of the note height to the full note height
let sizes = Array.from(
{ length: Math.ceil(noteHeight - notePadding * 2) },
(_, i) => i + Math.ceil(noteHeight / minTextSize)
(_, i) => i + Math.ceil(noteHeight / defaultFontSize)
);
if (sizes.length > 0) {
@ -144,8 +144,7 @@ function Note({
return prev;
}
});
setFontSize(size);
setFontScale(size / defaultFontSize);
}
}
@ -215,11 +214,14 @@ function Note({
}
align="left"
verticalAlign="middle"
padding={notePadding}
fontSize={fontSize}
padding={notePadding / fontScale}
fontSize={defaultFontSize}
// Scale font instead of changing font size to avoid kerning issues with Firefox
scaleX={fontScale}
scaleY={fontScale}
width={noteWidth / fontScale}
height={note.textOnly ? undefined : noteHeight / fontScale}
wrap="word"
width={noteWidth}
height={note.textOnly ? undefined : noteHeight}
/>
{/* Use an invisible text block to work out text sizing */}
<Text visible={false} ref={textRef} text={note.text} wrap="none" />

View File

@ -1,6 +1,6 @@
import React from "react";
import DragOverlay from "../DragOverlay";
import DragOverlay from "../map/DragOverlay";
function NoteDragOverlay({ onNoteRemove, noteId, noteGroup, dragging }) {
function handleNoteRemove() {

View File

@ -1,7 +1,8 @@
import React, { useEffect, useState } from "react";
import { Box, Flex, Text, IconButton, Textarea } from "theme-ui";
import { Box, Flex, Text, IconButton } from "theme-ui";
import Slider from "../Slider";
import TextareaAutosize from "../TextareaAutoSize";
import MapMenu from "../map/MapMenu";
@ -16,7 +17,7 @@ import HideIcon from "../../icons/TokenHideIcon";
import NoteIcon from "../../icons/NoteToolIcon";
import TextIcon from "../../icons/NoteTextIcon";
import { useAuth } from "../../contexts/AuthContext";
import { useUserId } from "../../contexts/UserIdContext";
const defaultNoteMaxSize = 6;
@ -28,7 +29,7 @@ function NoteMenu({
onNoteChange,
map,
}) {
const { userId } = useAuth();
const userId = useUserId();
const wasOpen = usePrevious(isOpen);
@ -128,20 +129,12 @@ function NoteMenu({
}}
sx={{ alignItems: "center" }}
>
<Textarea
<TextareaAutosize
id="changeNoteText"
onChange={handleTextChange}
value={(note && note.text) || ""}
sx={{
padding: "4px",
border: "none",
":focus": {
outline: "none",
},
resize: "none",
}}
rows={1}
onKeyPress={handleTextKeyPress}
maxRows={4}
/>
</Flex>
<Box

View File

@ -0,0 +1,33 @@
import React from "react";
import { Box } from "theme-ui";
import { useInView } from "react-intersection-observer";
function LazyTile({ children }) {
const [ref, inView] = useInView({ triggerOnce: false });
const sx = inView
? {}
: { width: "100%", height: "0", paddingTop: "100%", position: "relative" };
return (
<Box sx={sx} ref={ref}>
{inView ? (
children
) : (
<Box
sx={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
borderRadius: "4px",
}}
bg="background"
/>
)}
</Box>
);
}
export default LazyTile;

View File

@ -0,0 +1,100 @@
import React from "react";
import { Box } from "theme-ui";
import { useDroppable } from "@dnd-kit/core";
import { useSortable } from "@dnd-kit/sortable";
import { animated, useSpring } from "react-spring";
import { GROUP_ID_PREFIX } from "../../contexts/TileDragContext";
function SortableTile({
id,
disableGrouping,
disableSorting,
hidden,
children,
isDragging,
cursor,
}) {
const {
attributes,
listeners,
setDroppableNodeRef,
setDraggableNodeRef,
over,
active,
} = useSortable({ id });
const { setNodeRef: setGroupNodeRef } = useDroppable({
id: `${GROUP_ID_PREFIX}${id}`,
disabled: disableGrouping,
});
const dragStyle = {
cursor,
opacity: isDragging ? 0.25 : undefined,
};
// Sort div left aligned
const sortDropStyle = {
position: "absolute",
left: "-5px",
top: 0,
width: "2px",
height: "100%",
borderRadius: "2px",
visibility: over?.id === id && !disableSorting ? "visible" : "hidden",
};
// Group div center aligned
const groupDropStyle = {
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
borderWidth: "4px",
borderRadius: "4px",
borderStyle:
over?.id === `${GROUP_ID_PREFIX}${id}` && active.id !== id
? "solid"
: "none",
};
const { opacity } = useSpring({ opacity: hidden ? 0 : 1 });
return (
<animated.div style={{ opacity, position: "relative" }}>
<Box
ref={setDraggableNodeRef}
style={dragStyle}
{...listeners}
{...attributes}
>
{children}
</Box>
<Box
sx={{
width: "100%",
height: 0,
paddingTop: "100%",
pointerEvents: "none",
position: "absolute",
top: 0,
}}
>
<Box ref={setDroppableNodeRef} style={sortDropStyle} bg="primary" />
<Box
ref={setGroupNodeRef}
style={groupDropStyle}
sx={{ borderColor: "primary" }}
/>
</Box>
</animated.div>
);
}
SortableTile.defaultProps = {
cursor: "pointer",
};
export default SortableTile;

View File

@ -0,0 +1,103 @@
import React from "react";
import { SortableContext } from "@dnd-kit/sortable";
import { moveGroupsInto } from "../../helpers/group";
import { keyBy } from "../../helpers/shared";
import SortableTile from "./SortableTile";
import LazyTile from "./LazyTile";
import {
useTileDragId,
useTileDragCursor,
useTileOverGroupId,
BASE_SORTABLE_ID,
GROUP_SORTABLE_ID,
} from "../../contexts/TileDragContext";
import { useGroup } from "../../contexts/GroupContext";
function SortableTiles({ renderTile, subgroup }) {
const dragId = useTileDragId();
const dragCursor = useTileDragCursor();
const overGroupId = useTileOverGroupId();
const {
groups,
selectedGroupIds: allSelectedIds,
filter,
openGroupId,
openGroupItems,
filteredGroupItems,
} = useGroup();
const activeGroups = subgroup
? openGroupItems
: filter
? filteredGroupItems
: groups;
const sortableId = subgroup ? GROUP_SORTABLE_ID : BASE_SORTABLE_ID;
// Only populate selected groups if needed
let selectedGroupIds = [];
if ((subgroup && openGroupId) || (!subgroup && !openGroupId)) {
selectedGroupIds = allSelectedIds;
}
const disableSorting = (openGroupId && !subgroup) || filter;
const disableGrouping = subgroup || disableSorting || filter;
function renderSortableGroup(group, selectedGroups) {
if (overGroupId === group.id && dragId && group.id !== dragId) {
// If dragging over a group render a preview of that group
const previewGroup = moveGroupsInto(
[group, ...selectedGroups],
0,
selectedGroups.map((_, i) => i + 1)
)[0];
return renderTile(previewGroup);
}
return renderTile(group);
}
function renderTiles() {
const groupsByIds = keyBy(activeGroups, "id");
const selectedGroupIdsSet = new Set(selectedGroupIds);
let selectedGroups = [];
let hasSelectedContainerGroup = false;
for (let groupId of selectedGroupIds) {
const group = groupsByIds[groupId];
if (group) {
selectedGroups.push(group);
if (group.type === "group") {
hasSelectedContainerGroup = true;
}
}
}
return activeGroups.map((group) => {
const isDragging = dragId && selectedGroupIdsSet.has(group.id);
const disableTileGrouping =
disableGrouping || isDragging || hasSelectedContainerGroup;
return (
<LazyTile key={group.id}>
<SortableTile
id={group.id}
disableGrouping={disableTileGrouping}
disableSorting={disableSorting}
hidden={group.id === openGroupId}
isDragging={isDragging}
cursor={dragCursor}
>
{renderSortableGroup(group, selectedGroups)}
</SortableTile>
</LazyTile>
);
});
}
return (
<SortableContext items={activeGroups} id={sortableId}>
{renderTiles()}
</SortableContext>
);
}
export default SortableTiles;

View File

@ -0,0 +1,93 @@
import React from "react";
import { createPortal } from "react-dom";
import { DragOverlay } from "@dnd-kit/core";
import { animated, useSpring, config } from "react-spring";
import { Badge } from "theme-ui";
import Vector2 from "../../helpers/Vector2";
import { useTileDragId } from "../../contexts/TileDragContext";
import { useGroup } from "../../contexts/GroupContext";
function SortableTilesDragOverlay({ renderTile, subgroup }) {
const dragId = useTileDragId();
const {
groups,
selectedGroupIds: allSelectedIds,
filter,
openGroupId,
openGroupItems,
filteredGroupItems,
} = useGroup();
const activeGroups = subgroup
? openGroupItems
: filter
? filteredGroupItems
: groups;
// Only populate selected groups if needed
let selectedGroupIds = [];
if ((subgroup && openGroupId) || (!subgroup && !openGroupId)) {
selectedGroupIds = allSelectedIds;
}
const dragBounce = useSpring({
transform: !!dragId ? "scale(0.9)" : "scale(1)",
config: config.wobbly,
position: "relative",
});
function renderDragOverlays() {
let selectedIndices = selectedGroupIds.map((groupId) =>
activeGroups.findIndex((group) => group.id === groupId)
);
const activeIndex = activeGroups.findIndex((group) => group.id === dragId);
// Sort so the draging tile is the first element
selectedIndices = selectedIndices.sort((a, b) =>
a === activeIndex ? -1 : b === activeIndex ? 1 : 0
);
selectedIndices = selectedIndices.slice(0, 5);
let coords = selectedIndices.map(
(_, index) => new Vector2(5 * index, 5 * index)
);
// Reverse so the first element is rendered on top
selectedIndices = selectedIndices.reverse();
coords = coords.reverse();
const selectedGroups = selectedIndices.map((index) => activeGroups[index]);
return selectedGroups.map((group, index) => (
<DragOverlay dropAnimation={null} key={group.id}>
<div
style={{
transform: `translate(${coords[index].x}%, ${coords[index].y}%)`,
}}
>
<animated.div style={dragBounce}>
{renderTile(group)}
{index === selectedIndices.length - 1 &&
selectedGroupIds.length > 1 && (
<Badge
sx={{
position: "absolute",
top: 0,
right: 0,
transform: "translate(25%, -25%)",
}}
>
{selectedGroupIds.length}
</Badge>
)}
</animated.div>
</div>
</DragOverlay>
));
}
return createPortal(dragId && renderDragOverlays(), document.body);
}
export default SortableTilesDragOverlay;

View File

@ -1,74 +1,49 @@
import React from "react";
import { Flex, Image as UIImage, IconButton, Box, Text, Badge } from "theme-ui";
import { Flex, IconButton, Box, Text, Badge } from "theme-ui";
import EditTileIcon from "../icons/EditTileIcon";
import EditTileIcon from "../../icons/EditTileIcon";
function Tile({
src,
title,
isSelected,
onSelect,
onEdit,
onDoubleClick,
size,
canEdit,
badges,
editTitle,
children,
}) {
let width;
let margin;
switch (size) {
case "small":
width = "24%";
margin = "0.5%";
break;
case "medium":
width = "32%";
margin = `${2 / 3}%`;
break;
case "large":
width = "48%";
margin = "1%";
break;
default:
width = "32%";
margin = `${2 / 3}%`;
}
return (
<Flex
<Box
sx={{
position: "relative",
width: width,
width: "100%",
height: "0",
paddingTop: width,
paddingTop: "100%",
borderRadius: "4px",
justifyContent: "center",
alignItems: "center",
cursor: "pointer",
overflow: "hidden",
userSelect: "none",
}}
my={1}
mx={margin}
bg="muted"
bg="background"
onClick={(e) => {
e.stopPropagation();
onSelect();
}}
onDoubleClick={onDoubleClick}
aria-label={title}
>
<UIImage
<Box
sx={{
width: "100%",
height: "100%",
objectFit: "contain",
position: "absolute",
top: 0,
left: 0,
}}
src={src}
alt={title}
/>
>
{children}
</Box>
<Flex
sx={{
position: "absolute",
@ -106,13 +81,25 @@ function Tile({
borderRadius: "4px",
}}
/>
<Box sx={{ position: "absolute", top: 0, left: 0 }}>
<Flex
sx={{
position: "absolute",
top: "6px",
left: "6px",
}}
>
{badges.map((badge, i) => (
<Badge m={2} key={i} bg="overlay">
<Badge
m="2px"
key={i}
bg="overlay"
color="text"
sx={{ width: "fit-content" }}
>
{badge}
</Badge>
))}
</Box>
</Flex>
{canEdit && (
<Box sx={{ position: "absolute", top: 0, right: 0 }}>
<IconButton
@ -131,12 +118,11 @@ function Tile({
</IconButton>
</Box>
)}
</Flex>
</Box>
);
}
Tile.defaultProps = {
src: "",
title: "",
isSelected: false,
onSelect: () => {},
@ -146,6 +132,7 @@ Tile.defaultProps = {
canEdit: false,
badges: [],
editTitle: "Edit",
columns: "1fr",
};
export default Tile;

View File

@ -1,22 +1,24 @@
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 AddIcon from "../../icons/AddIcon";
import SelectMultipleIcon from "../../icons/SelectMultipleIcon";
import SelectSingleIcon from "../../icons/SelectSingleIcon";
import Search from "./Search";
import RadioIconButton from "./RadioIconButton";
import Search from "../Search";
import RadioIconButton from "../RadioIconButton";
import { useGroup } from "../../contexts/GroupContext";
function TileActionBar({ onAdd, addTitle }) {
const {
selectMode,
onSelectModeChange,
onGroupSelect,
filter,
onFilterChange,
} = useGroup();
function FilterBar({
onFocus,
search,
onSearchChange,
selectMode,
onSelectModeChange,
onAdd,
addTitle,
}) {
return (
<Flex
bg="muted"
@ -31,9 +33,9 @@ function FilterBar({
outlineOffset: "0px",
},
}}
onFocus={onFocus}
onFocus={() => onGroupSelect()}
>
<Search value={search} onChange={onSearchChange} />
<Search value={filter} onChange={(e) => onFilterChange(e.target.value)} />
<Flex
mr={1}
px={1}
@ -66,4 +68,4 @@ function FilterBar({
);
}
export default FilterBar;
export default TileActionBar;

View File

@ -0,0 +1,57 @@
import React from "react";
import { Grid, useThemeUI } from "theme-ui";
import SimpleBar from "simplebar-react";
import { useGroup } from "../../contexts/GroupContext";
import { ADD_TO_MAP_ID } from "../../contexts/TileDragContext";
import useResponsiveLayout from "../../hooks/useResponsiveLayout";
import Droppable from "../drag/Droppable";
function TilesContainer({ children }) {
const { onGroupSelect } = useGroup();
const { theme } = useThemeUI();
const layout = useResponsiveLayout();
return (
<>
<SimpleBar
style={{
height: layout.tileContainerHeight,
backgroundColor: theme.colors.muted,
}}
onClick={() => onGroupSelect()}
>
<Grid
p={3}
pb={4}
sx={{
borderRadius: "4px",
overflow: "hidden",
position: "relative",
}}
gap={2}
columns={`repeat(${layout.tileGridColumns}, 1fr)`}
>
<Droppable
id={ADD_TO_MAP_ID}
style={{
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
zIndex: -1,
}}
/>
{children}
</Grid>
</SimpleBar>
</>
);
}
export default TilesContainer;

View File

@ -0,0 +1,190 @@
import React, { useState } from "react";
import { Box, Close, Grid, useThemeUI, IconButton, Text, Flex } from "theme-ui";
import { useSpring, animated, config } from "react-spring";
import ReactResizeDetector from "react-resize-detector";
import SimpleBar from "simplebar-react";
import { useGroup } from "../../contexts/GroupContext";
import { UNGROUP_ID, ADD_TO_MAP_ID } from "../../contexts/TileDragContext";
import useResponsiveLayout from "../../hooks/useResponsiveLayout";
import ChangeNicknameIcon from "../../icons/ChangeNicknameIcon";
import GroupNameModal from "../../modals/GroupNameModal";
import { renameGroup } from "../../helpers/group";
import Droppable from "../drag/Droppable";
function TilesOverlay({ modalSize, children }) {
const {
groups,
openGroupId,
onGroupClose,
onGroupSelect,
onGroupsChange,
} = useGroup();
const { theme } = useThemeUI();
const layout = useResponsiveLayout();
const openAnimation = useSpring({
opacity: openGroupId ? 1 : 0,
transform: openGroupId ? "scale(1)" : "scale(0.99)",
config: config.gentle,
});
const [containerSize, setContinerSize] = useState({ width: 0, height: 0 });
function handleContainerResize(width, height) {
const size = Math.min(width, height) - 16;
setContinerSize({ width: size, height: size });
}
const [isGroupNameModalOpen, setIsGroupNameModalOpen] = useState(false);
function handleGroupNameChange(name) {
onGroupsChange(renameGroup(groups, openGroupId, name));
setIsGroupNameModalOpen(false);
}
const group = groups.find((group) => group.id === openGroupId);
if (!openGroupId) {
return null;
}
return (
<>
<Box
sx={{
position: "absolute",
width: "100%",
height: "100%",
top: 0,
}}
bg="overlay"
/>
<ReactResizeDetector
handleWidth
handleHeight
onResize={handleContainerResize}
>
<animated.div
style={{
...openAnimation,
position: "absolute",
width: "100%",
height: "100%",
top: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
pointerEvents: openGroupId ? undefined : "none",
}}
onClick={() => openGroupId && onGroupClose()}
>
<Box
sx={{
width: containerSize.width,
height: containerSize.height,
borderRadius: "8px",
border: "1px solid",
borderColor: "border",
cursor: "default",
display: "flex",
justifyContent: "flex-start",
alignItems: "center",
position: "relative",
flexDirection: "column",
}}
bg="background"
onClick={(e) => e.stopPropagation()}
>
<Flex my={1} sx={{ position: "relative" }}>
<Text as="p" my="2px">
{group?.name}
</Text>
<IconButton
sx={{
width: "24px",
height: "24px",
position: group?.name ? "absolute" : "relative",
left: group?.name ? "100%" : 0,
}}
title="Edit Group"
aria-label="Edit Group"
onClick={() => setIsGroupNameModalOpen(true)}
>
<ChangeNicknameIcon />
</IconButton>
</Flex>
<SimpleBar
style={{
width: containerSize.width - 16,
height: containerSize.height - 48,
marginBottom: "8px",
backgroundColor: theme.colors.muted,
}}
onClick={() => onGroupSelect()}
>
<Grid
sx={{
borderRadius: "4px",
overflow: "hidden",
position: "relative",
}}
gap={2}
columns={`repeat(${layout.groupGridColumns}, 1fr)`}
p={3}
>
<Droppable
id={ADD_TO_MAP_ID}
style={{
position: "absolute",
width: modalSize.width,
height: `calc(100% + ${
modalSize.height - containerSize.height + 48
}px)`,
left: `-${
(modalSize.width - containerSize.width) / 2 + 8
}px`,
top: `-${
(modalSize.height - containerSize.height) / 2 + 48
}px`,
zIndex: -1,
}}
/>
<Droppable
id={UNGROUP_ID}
style={{
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
zIndex: -1,
}}
/>
{children}
</Grid>
</SimpleBar>
<Close
onClick={() => onGroupClose()}
sx={{ position: "absolute", top: 0, right: 0 }}
/>
</Box>
</animated.div>
</ReactResizeDetector>
<GroupNameModal
isOpen={isGroupNameModalOpen}
name={group?.name}
onSubmit={handleGroupNameChange}
onRequestClose={() => setIsGroupNameModalOpen(false)}
/>
</>
);
}
export default TilesOverlay;

View File

@ -1,44 +0,0 @@
import React, { useRef } from "react";
import { Box, Image } from "theme-ui";
import usePreventTouch from "../../hooks/usePreventTouch";
import { useImageSource } from "../../contexts/ImageSourceContext";
import { tokenSources, unknownSource } from "../../tokens";
function ListToken({ token, className }) {
const tokenSource = useImageSource(
token,
tokenSources,
unknownSource,
token.type === "file"
);
const imageRef = useRef();
// Stop touch to prevent 3d touch gesutre on iOS
usePreventTouch(imageRef);
return (
<Box my={2} mx={3} sx={{ width: "48px", height: "48px" }}>
<Image
src={tokenSource}
ref={imageRef}
className={className}
sx={{
userSelect: "none",
touchAction: "none",
width: "100%",
height: "100%",
objectFit: "cover",
}}
// pass id into the dom element which is then used by the ProxyToken
data-id={token.id}
alt={token.name}
title={token.name}
/>
</Box>
);
}
export default ListToken;

View File

@ -1,172 +0,0 @@
import React, { useEffect, useRef, useState } from "react";
import ReactDOM from "react-dom";
import { Image, Box } from "theme-ui";
import interact from "interactjs";
import usePortal from "../../hooks/usePortal";
import { useMapStage } from "../../contexts/MapStageContext";
/**
* @callback onProxyDragEnd
* @param {boolean} isOnMap whether the token was dropped on the map
* @param {Object} token the token that was dropped
*/
/**
*
* @param {string} tokenClassName The class name to attach the interactjs handler to
* @param {onProxyDragEnd} onProxyDragEnd Called when the proxy token is dropped
* @param {Object} tokens An optional mapping of tokens to use as a base when calling OnProxyDragEnd
*/
function ProxyToken({ tokenClassName, onProxyDragEnd, tokens }) {
const proxyContainer = usePortal("root");
const [imageSource, setImageSource] = useState("");
const proxyRef = useRef();
// Store the tokens in a ref and access in the interactjs loop
// This is needed to stop interactjs from creating multiple listeners
const tokensRef = useRef(tokens);
useEffect(() => {
tokensRef.current = tokens;
}, [tokens]);
const proxyOnMap = useRef(false);
const mapStageRef = useMapStage();
useEffect(() => {
interact(`.${tokenClassName}`).draggable({
listeners: {
start: (event) => {
let target = event.target;
// Hide the token and copy it's image to the proxy
target.parentElement.style.opacity = "0.25";
setImageSource(target.src);
let proxy = proxyRef.current;
if (proxy) {
// Find and set the initial offset of the token to the proxy
const proxyRect = proxy.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
const xOffset = targetRect.left - proxyRect.left;
const yOffset = targetRect.top - proxyRect.top;
proxy.style.transform = `translate(${xOffset}px, ${yOffset}px)`;
proxy.setAttribute("data-x", xOffset);
proxy.setAttribute("data-y", yOffset);
// Copy width and height of target
proxy.style.width = `${targetRect.width}px`;
proxy.style.height = `${targetRect.height}px`;
}
},
move: (event) => {
let proxy = proxyRef.current;
// Move the proxy based off of the movment of the token
if (proxy) {
// keep the dragged position in the data-x/data-y attributes
const x =
(parseFloat(proxy.getAttribute("data-x")) || 0) + event.dx;
const y =
(parseFloat(proxy.getAttribute("data-y")) || 0) + event.dy;
proxy.style.transform = `translate(${x}px, ${y}px)`;
// Check whether the proxy is on the right or left hand side of the screen
// if not set proxyOnMap to true
const proxyRect = proxy.getBoundingClientRect();
const map = document.querySelector(".map");
const mapRect = map.getBoundingClientRect();
proxyOnMap.current =
proxyRect.left > mapRect.left && proxyRect.right < mapRect.right;
// update the posiion attributes
proxy.setAttribute("data-x", x);
proxy.setAttribute("data-y", y);
}
},
end: (event) => {
let target = event.target;
const id = target.dataset.id;
let proxy = proxyRef.current;
if (proxy) {
const mapStage = mapStageRef.current;
if (onProxyDragEnd && mapStage) {
const mapImage = mapStage.findOne("#mapImage");
const map = document.querySelector(".map");
const mapRect = map.getBoundingClientRect();
const position = {
x: event.clientX - mapRect.left,
y: event.clientY - mapRect.top,
};
const transform = mapImage.getAbsoluteTransform().copy().invert();
const relativePosition = transform.point(position);
const normalizedPosition = {
x: relativePosition.x / mapImage.width(),
y: relativePosition.y / mapImage.height(),
};
// Get the token from the supplied tokens if it exists
const token = tokensRef.current[id] || {};
onProxyDragEnd(proxyOnMap.current, {
...token,
x: normalizedPosition.x,
y: normalizedPosition.y,
});
}
// Reset the proxy position
proxy.style.transform = "translate(0px, 0px)";
proxy.setAttribute("data-x", 0);
proxy.setAttribute("data-y", 0);
}
// Show the token
target.parentElement.style.opacity = "1";
setImageSource("");
},
},
});
}, [onProxyDragEnd, tokenClassName, proxyContainer, mapStageRef]);
if (!imageSource) {
return null;
}
// Create a portal to allow the proxy to move past the bounds of the token
return ReactDOM.createPortal(
<Box
sx={{
position: "absolute",
overflow: "hidden",
top: 0,
left: 0,
bottom: 0,
right: 0,
}}
>
<Box
sx={{ position: "absolute", display: "flex", flexDirection: "column" }}
ref={proxyRef}
>
<Image
src={imageSource}
sx={{
touchAction: "none",
userSelect: "none",
width: "100%",
}}
/>
</Box>
</Box>,
proxyContainer
);
}
ProxyToken.defaultProps = {
tokens: {},
};
export default ProxyToken;

View File

@ -5,7 +5,7 @@ import SelectTokensIcon from "../../icons/SelectTokensIcon";
import SelectTokensModal from "../../modals/SelectTokensModal";
function SelectTokensButton() {
function SelectTokensButton({ onMapTokensStateCreate }) {
const [isModalOpen, setIsModalOpen] = useState(false);
function openModal() {
setIsModalOpen(true);
@ -30,6 +30,7 @@ function SelectTokensButton() {
isOpen={isModalOpen}
onRequestClose={closeModal}
onDone={handleDone}
onMapTokensStateCreate={onMapTokensStateCreate}
/>
</>
);

View File

@ -0,0 +1,200 @@
import React, { useState } from "react";
import { createPortal } from "react-dom";
import { Box, Flex, Grid } from "theme-ui";
import SimpleBar from "simplebar-react";
import {
DragOverlay,
MouseSensor,
TouchSensor,
KeyboardSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import TokenBarToken from "./TokenBarToken";
import TokenBarTokenGroup from "./TokenBarTokenGroup";
import SelectTokensButton from "./SelectTokensButton";
import Draggable from "../drag/Draggable";
import useSetting from "../../hooks/useSetting";
import usePreventSelect from "../../hooks/usePreventSelect";
import { useTokenData } from "../../contexts/TokenDataContext";
import { useUserId } from "../../contexts/UserIdContext";
import { useMapStage } from "../../contexts/MapStageContext";
import DragContext from "../../contexts/DragContext";
import {
createTokenState,
clientPositionToMapPosition,
} from "../../helpers/token";
import { findGroup } from "../../helpers/group";
import Vector2 from "../../helpers/Vector2";
function TokenBar({ onMapTokensStateCreate }) {
const userId = useUserId();
const { tokensById, tokenGroups } = useTokenData();
const [fullScreen] = useSetting("map.fullScreen");
const [dragId, setDragId] = useState();
const mapStageRef = useMapStage();
const mouseSensor = useSensor(MouseSensor, {
activationConstraint: { distance: 5 },
});
const touchSensor = useSensor(TouchSensor, {
activationConstraint: { distance: 5 },
});
const keyboardSensor = useSensor(KeyboardSensor);
const sensors = useSensors(mouseSensor, touchSensor, keyboardSensor);
const [preventSelect, resumeSelect] = usePreventSelect();
function handleDragStart({ active }) {
setDragId(active.id);
preventSelect();
}
function handleDragEnd({ active, overlayNodeClientRect }) {
setDragId(null);
const mapStage = mapStageRef.current;
if (mapStage) {
const dragRect = overlayNodeClientRect;
const dragPosition = {
x: dragRect.left + dragRect.width / 2,
y: dragRect.top + dragRect.height / 2,
};
const mapPosition = clientPositionToMapPosition(mapStage, dragPosition);
const group = findGroup(tokenGroups, active.id);
if (group && mapPosition) {
if (group.type === "item") {
const token = tokensById[group.id];
const tokenState = createTokenState(token, mapPosition, userId);
onMapTokensStateCreate([tokenState]);
} else {
let tokenStates = [];
let offset = new Vector2(0, 0);
for (let item of group.items) {
const token = tokensById[item.id];
if (token && !token.hideInSidebar) {
tokenStates.push(
createTokenState(
token,
Vector2.add(mapPosition, offset),
userId
)
);
offset = Vector2.add(offset, 0.01);
}
}
if (tokenStates.length > 0) {
onMapTokensStateCreate(tokenStates);
}
}
}
}
resumeSelect();
}
function handleDragCancel() {
setDragId(null);
resumeSelect();
}
function renderToken(group, draggable = true) {
if (group.type === "item") {
const token = tokensById[group.id];
if (token && !token.hideInSidebar) {
if (draggable) {
return (
<Draggable id={token.id} key={token.id}>
<TokenBarToken token={token} />
</Draggable>
);
} else {
return <TokenBarToken token={token} key={token.id} />;
}
}
} else {
const groupTokens = [];
for (let item of group.items) {
const token = tokensById[item.id];
if (token && !token.hideInSidebar) {
groupTokens.push(token);
}
}
if (groupTokens.length > 0) {
return (
<TokenBarTokenGroup
group={group}
tokens={groupTokens}
key={group.id}
draggable={draggable}
/>
);
}
}
}
return (
<DragContext
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
autoScroll={false}
sensors={sensors}
>
<Box
sx={{
height: "100%",
width: "80px",
minWidth: "80px",
overflowY: "hidden",
overflowX: "hidden",
display: fullScreen ? "none" : "block",
}}
>
<SimpleBar
style={{
height: "calc(100% - 48px)",
overflowX: "hidden",
padding: "0 16px",
}}
>
<Grid
columns="1fr"
gap={2}
py={2}
// Prevent selection on 3D touch for iOS
onTouchStart={preventSelect}
onTouchEnd={resumeSelect}
>
{tokenGroups.map((group) => renderToken(group))}
</Grid>
</SimpleBar>
<Flex
bg="muted"
sx={{
justifyContent: "center",
height: "48px",
alignItems: "center",
}}
>
<SelectTokensButton onMapTokensStateCreate={onMapTokensStateCreate} />
</Flex>
{createPortal(
<DragOverlay dropAnimation={null}>
{dragId && renderToken(findGroup(tokenGroups, dragId), false)}
</DragOverlay>,
document.body
)}
</Box>
</DragContext>
);
}
export default TokenBar;

View File

@ -0,0 +1,31 @@
import React from "react";
import { Box } from "theme-ui";
import { useInView } from "react-intersection-observer";
import TokenImage from "./TokenImage";
function TokenBarToken({ token }) {
const [ref, inView] = useInView({ triggerOnce: true });
return (
<Box ref={ref} sx={{ width: "48px", height: "48px" }} title={token.name}>
{inView && (
<TokenImage
token={token}
sx={{
userSelect: "none",
touchAction: "none",
width: "100%",
height: "100%",
objectFit: "cover",
pointerEvents: "none",
}}
alt={token.name}
title={token.name}
/>
)}
</Box>
);
}
export default TokenBarToken;

View File

@ -0,0 +1,135 @@
import React, { useState, useRef } from "react";
import { Grid, Flex, Box } from "theme-ui";
import { useSpring, animated } from "react-spring";
import { useDraggable } from "@dnd-kit/core";
import TokenImage from "./TokenImage";
import TokenBarToken from "./TokenBarToken";
import Draggable from "../drag/Draggable";
import Vector2 from "../../helpers/Vector2";
import GroupIcon from "../../icons/GroupIcon";
function TokenBarTokenGroup({ group, tokens, draggable }) {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: draggable && group.id,
disabled: !draggable,
});
const [isOpen, setIsOpen] = useState(false);
const { height } = useSpring({
height: isOpen ? (tokens.length + 1) * 56 : 56,
});
function renderToken(token) {
if (draggable) {
return (
<Draggable id={token.id} key={token.id}>
<TokenBarToken token={token} />
</Draggable>
);
} else {
return <TokenBarToken token={token} key={token.id} />;
}
}
function renderTokens() {
if (isOpen) {
return (
<Grid
columns="1fr"
bg="muted"
sx={{ borderRadius: "8px" }}
p={0}
gap={2}
>
<Flex
sx={{
width: "48px",
height: "48px",
alignItems: "center",
justifyContent: "center",
cursor: isDragging ? "grabbing" : "pointer",
color: "primary",
}}
onClick={(e) => handleOpenClick(e, false)}
key="group"
title={group.name}
{...listeners}
{...attributes}
>
<GroupIcon />
</Flex>
{tokens.map(renderToken)}
</Grid>
);
} else {
return (
<Grid
columns="1fr 1fr"
bg="muted"
sx={{
borderRadius: "8px",
gridGap: "4px",
height: "48px",
gridTemplateRows: "1fr 1fr",
}}
p="2px"
alt={group.name}
title={group.name}
{...listeners}
{...attributes}
>
{tokens.slice(0, 4).map((token) => (
<TokenImage
token={token}
key={token.id}
sx={{
userSelect: "none",
touchAction: "none",
pointerEvents: "none",
}}
/>
))}
</Grid>
);
}
}
// Reject the opening of a group if the pointer has moved
const clickDownPositionRef = useRef(new Vector2(0, 0));
function handleOpenDown(event) {
clickDownPositionRef.current = new Vector2(event.clientX, event.clientY);
}
function handleOpenClick(event, newOpen) {
const clickPosition = new Vector2(event.clientX, event.clientY);
const distance = Vector2.distance(
clickPosition,
clickDownPositionRef.current
);
if (distance < 5) {
setIsOpen(newOpen);
}
}
return (
<Box ref={setNodeRef}>
<animated.div
style={{
padding: "4px 0",
width: "48px",
height,
cursor: isOpen ? "default" : isDragging ? "grabbing" : "pointer",
}}
onPointerDown={handleOpenDown}
onClick={(e) => !isOpen && handleOpenClick(e, true)}
>
{renderTokens()}
</animated.div>
</Box>
);
}
export default TokenBarTokenGroup;

View File

@ -1,12 +1,12 @@
import React from "react";
import { useAuth } from "../../contexts/AuthContext";
import { useUserId } from "../../contexts/UserIdContext";
import {
useMapWidth,
useMapHeight,
} from "../../contexts/MapInteractionContext";
import DragOverlay from "../DragOverlay";
import DragOverlay from "../map/DragOverlay";
function TokenDragOverlay({
onTokenStateRemove,
@ -16,7 +16,7 @@ function TokenDragOverlay({
tokenGroup,
dragging,
}) {
const { userId } = useAuth();
const userId = useUserId();
const mapWidth = useMapWidth();
const mapHeight = useMapHeight();

View File

@ -0,0 +1,134 @@
import React, { useState, useEffect } from "react";
import { Flex, Close, IconButton } from "theme-ui";
import { groupsFromIds, itemsFromGroups } from "../../helpers/group";
import ConfirmModal from "../../modals/ConfirmModal";
import TokenShowIcon from "../../icons/TokenShowIcon";
import TokenHideIcon from "../../icons/TokenHideIcon";
import RemoveTokenIcon from "../../icons/RemoveTokenIcon";
import { useGroup } from "../../contexts/GroupContext";
import { useTokenData } from "../../contexts/TokenDataContext";
import { useKeyboard } from "../../contexts/KeyboardContext";
import shortcuts from "../../shortcuts";
function TokenEditBar({ disabled, onLoad }) {
const { tokens, removeTokens, updateTokensHidden } = useTokenData();
const { activeGroups, selectedGroupIds, onGroupSelect } = useGroup();
const [allTokensVisible, setAllTokensVisisble] = useState(false);
useEffect(() => {
const selectedGroups = groupsFromIds(selectedGroupIds, activeGroups);
const selectedTokens = itemsFromGroups(selectedGroups, tokens);
setAllTokensVisisble(selectedTokens.every((token) => !token.hideInSidebar));
}, [selectedGroupIds, tokens, activeGroups]);
function getSelectedTokens() {
const selectedGroups = groupsFromIds(selectedGroupIds, activeGroups);
return itemsFromGroups(selectedGroups, tokens);
}
const [isTokensRemoveModalOpen, setIsTokensRemoveModalOpen] = useState(false);
async function handleTokensRemove() {
onLoad(true);
setIsTokensRemoveModalOpen(false);
const selectedTokens = getSelectedTokens();
const selectedTokenIds = selectedTokens.map((token) => token.id);
onGroupSelect();
await removeTokens(selectedTokenIds);
onLoad(false);
}
async function handleTokensHide(hideInSidebar) {
const selectedTokens = getSelectedTokens();
const selectedTokenIds = selectedTokens.map((token) => token.id);
// Show loading indicator if hiding more than 10 tokens
if (selectedTokenIds.length > 10) {
onLoad(true);
await updateTokensHidden(selectedTokenIds, hideInSidebar);
onLoad(false);
} else {
updateTokensHidden(selectedTokenIds, hideInSidebar);
}
}
/**
* Shortcuts
*/
function handleKeyDown(event) {
if (disabled) {
return;
}
if (shortcuts.delete(event)) {
const selectedTokens = getSelectedTokens();
if (selectedTokens.length > 0) {
// Ensure all other modals are closed
setIsTokensRemoveModalOpen(true);
}
}
}
useKeyboard(handleKeyDown);
if (selectedGroupIds.length === 0) {
return null;
}
let hideTitle = "";
if (allTokensVisible) {
hideTitle = "Hide Selected Token(s) in Sidebar";
} else {
hideTitle = "Show Selected Token(s) in Sidebar";
}
return (
<Flex
sx={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
justifyContent: "space-between",
}}
bg="overlay"
>
<Close
title="Clear Selection"
aria-label="Clear Selection"
onClick={() => onGroupSelect()}
/>
<Flex>
<IconButton
aria-label={hideTitle}
title={hideTitle}
onClick={() => handleTokensHide(allTokensVisible)}
>
{allTokensVisible ? <TokenShowIcon /> : <TokenHideIcon />}
</IconButton>
<IconButton
aria-label="Remove Selected Token(s)"
title="Remove Selected Token(s)"
onClick={() => setIsTokensRemoveModalOpen(true)}
>
<RemoveTokenIcon />
</IconButton>
</Flex>
<ConfirmModal
isOpen={isTokensRemoveModalOpen}
onRequestClose={() => setIsTokensRemoveModalOpen(false)}
onConfirm={handleTokensRemove}
confirmText="Remove"
label="Remove Selected Token(s)"
description="This operation cannot be undone."
/>
</Flex>
);
}
export default TokenEditBar;

View File

@ -0,0 +1,21 @@
import React from "react";
import { Flex } from "theme-ui";
import TokenShowIcon from "../../icons/TokenShowIcon";
import TokenHideIcon from "../../icons/TokenHideIcon";
function TokenHiddenBadge({ hidden }) {
return (
<Flex
sx={{
height: "15px",
width: "15px",
alignItems: "center",
}}
>
{hidden ? <TokenHideIcon /> : <TokenShowIcon />}
</Flex>
);
}
export default TokenHiddenBadge;

View File

@ -0,0 +1,46 @@
import React, { useState } from "react";
import { Image, Box } from "theme-ui";
import { useDataURL } from "../../contexts/AssetsContext";
import { tokenSources as defaultTokenSources } from "../../tokens";
import { TokenOutlineSVG } from "./TokenOutline";
const TokenImage = React.forwardRef(({ token, ...props }, ref) => {
const tokenURL = useDataURL(
token,
defaultTokenSources,
undefined,
token.type === "file"
);
const [showOutline, setShowOutline] = useState(true);
return (
<>
{showOutline && (
<Box
title={props.alt}
aria-label={props.alt}
sx={{ width: "100%", height: "100%", minHeight: 0 }}
>
<TokenOutlineSVG
outline={token.outline}
width={token.width}
height={token.height}
/>
</Box>
)}
<Image
onLoad={() => setShowOutline(false)}
src={tokenURL}
ref={ref}
style={showOutline ? { display: "none" } : props.style}
{...props}
/>
</>
);
});
export default TokenImage;

View File

@ -4,6 +4,7 @@ import { Rect, Text, Group } from "react-konva";
import useSetting from "../../hooks/useSetting";
const maxTokenSize = 3;
const defaultFontSize = 16;
function TokenLabel({ tokenState, width, height }) {
const [labelSize] = useSetting("map.labelSize");
@ -13,7 +14,7 @@ function TokenLabel({ tokenState, width, height }) {
const paddingX =
(height / 8 / tokenState.size) * Math.min(tokenState.size, maxTokenSize);
const [fontSize, setFontSize] = useState(1);
const [fontScale, setFontScale] = useState(0);
useEffect(() => {
const text = textSizerRef.current;
@ -22,15 +23,14 @@ function TokenLabel({ tokenState, width, height }) {
}
let fontSizes = [];
for (let size = 10 * labelSize; size >= 6; size--) {
fontSizes.push(
(height / size / tokenState.size) *
Math.min(tokenState.size, maxTokenSize) *
labelSize
);
for (let size = 20 * labelSize; size >= 6; size--) {
const verticalSize = height / size / tokenState.size;
const tokenSize = Math.min(tokenState.size, maxTokenSize);
const fontSize = verticalSize * tokenSize * labelSize;
fontSizes.push(fontSize);
}
function findFontSize() {
function findFontScale() {
const size = fontSizes.reduce((prev, curr) => {
text.fontSize(curr);
const textWidth = text.getTextWidth() + paddingX * 2;
@ -39,12 +39,12 @@ function TokenLabel({ tokenState, width, height }) {
} else {
return prev;
}
});
}, 1);
setFontSize(size);
setFontScale(size / defaultFontSize);
}
findFontSize();
findFontScale();
}, [
tokenState.label,
tokenState.visible,
@ -56,44 +56,47 @@ function TokenLabel({ tokenState, width, height }) {
]);
const [rectWidth, setRectWidth] = useState(0);
const [textWidth, setTextWidth] = useState(0);
useEffect(() => {
const text = textRef.current;
if (text && tokenState.label) {
setRectWidth(text.getTextWidth() + paddingX * 2);
setRectWidth(text.getTextWidth() * fontScale + paddingX * 2);
setTextWidth(text.getTextWidth() * fontScale);
} else {
setRectWidth(0);
setTextWidth(0);
}
}, [tokenState.label, paddingX, width, fontSize]);
}, [tokenState.label, paddingX, width, fontScale]);
const textRef = useRef();
const textSizerRef = useRef();
return (
<Group y={height - (fontSize + paddingY) / 2}>
<Group y={height - (defaultFontSize * fontScale + paddingY) / 2}>
<Rect
y={-paddingY / 2}
width={rectWidth}
offsetX={width / 2}
x={width - rectWidth / 2}
height={fontSize + paddingY}
height={defaultFontSize * fontScale + paddingY}
fill="hsla(230, 25%, 18%, 0.8)"
cornerRadius={(fontSize + paddingY) / 2}
/>
<Text
ref={textRef}
width={width}
text={tokenState.label}
fontSize={fontSize}
lineHeight={1}
align="center"
verticalAlign="bottom"
fill="white"
paddingX={paddingX}
paddingY={paddingY}
wrap="none"
ellipsis={false}
hitFunc={() => {}}
cornerRadius={(defaultFontSize * fontScale + paddingY) / 2}
/>
<Group offsetX={(textWidth - width) / 2}>
<Text
ref={textRef}
text={tokenState.label}
fontSize={defaultFontSize}
lineHeight={1}
// Scale font instead of changing font size to avoid kerning issues with Firefox
scaleX={fontScale}
scaleY={fontScale}
fill="white"
wrap="none"
ellipsis={false}
hitFunc={() => {}}
/>
</Group>
{/* Use an invisible text block to work out text sizing */}
<Text
visible={false}

View File

@ -14,7 +14,7 @@ import UnlockIcon from "../../icons/TokenUnlockIcon";
import ShowIcon from "../../icons/TokenShowIcon";
import HideIcon from "../../icons/TokenHideIcon";
import { useAuth } from "../../contexts/AuthContext";
import { useUserId } from "../../contexts/UserIdContext";
const defaultTokenMaxSize = 6;
function TokenMenu({
@ -25,7 +25,7 @@ function TokenMenu({
onTokenStateChange,
map,
}) {
const { userId } = useAuth();
const userId = useUserId();
const wasOpen = usePrevious(isOpen);
@ -50,7 +50,7 @@ function TokenMenu({
}, [isOpen, tokenState, wasOpen, tokenImage]);
function handleLabelChange(event) {
const label = event.target.value.substring(0, 144);
const label = event.target.value.substring(0, 48);
tokenState && onTokenStateChange({ [tokenState.id]: { label: label } });
}

View File

@ -0,0 +1,94 @@
import React from "react";
import { Rect, Circle, Line } from "react-konva";
import colors from "../../helpers/colors";
export function TokenOutlineSVG({ outline, width, height }) {
if (outline.type === "rect") {
return (
<svg
width="100%"
height="100%"
xmlns="http://www.w3.org/2000/svg"
fill="rgba(0, 0, 0, 0.3)"
viewBox={`0, 0, ${width} ${height}`}
preserveAspectRatio="xMidYMid slice"
>
<rect
x={outline.x}
y={outline.y}
width={outline.width}
height={outline.height}
/>
</svg>
);
} else if (outline.type === "circle") {
return (
<svg
width="100%"
height="100%"
xmlns="http://www.w3.org/2000/svg"
fill="rgba(0, 0, 0, 0.3)"
viewBox={`0, 0, ${width} ${height}`}
preserveAspectRatio="xMidYMid slice"
>
<circle r={outline.radius} cx={outline.x} cy={outline.y} />
</svg>
);
} else {
let points = [];
for (let i = 0; i < outline.points.length; i += 2) {
points.push(`${outline.points[i]}, ${outline.points[i + 1]}`);
}
return (
<svg
width="100%"
height="100%"
xmlns="http://www.w3.org/2000/svg"
fill="rgba(0, 0, 0, 0.3)"
viewBox={`0, 0, ${width} ${height}`}
preserveAspectRatio="xMidYMid slice"
>
<polygon points={points.join(" ")} />
</svg>
);
}
}
function TokenOutline({ outline, hidden }) {
const sharedProps = {
fill: colors.black,
opacity: hidden ? 0 : 0.8,
};
if (outline.type === "rect") {
return (
<Rect
width={outline.width}
height={outline.height}
x={outline.x}
y={outline.y}
{...sharedProps}
/>
);
} else if (outline.type === "circle") {
return (
<Circle
radius={outline.radius}
x={outline.x}
y={outline.y}
{...sharedProps}
/>
);
} else {
return (
<Line
points={outline.points}
closed
tension={outline.points < 200 ? 0 : 0.33}
{...sharedProps}
/>
);
}
}
export default TokenOutline;

View File

@ -10,12 +10,12 @@ import useImageCenter from "../../hooks/useImageCenter";
import useResponsiveLayout from "../../hooks/useResponsiveLayout";
import { GridProvider } from "../../contexts/GridContext";
import { useImageSource } from "../../contexts/ImageSourceContext";
import { useDataURL } from "../../contexts/AssetsContext";
import GridOnIcon from "../../icons/GridOnIcon";
import GridOffIcon from "../../icons/GridOffIcon";
import { tokenSources, unknownSource } from "../../tokens";
import { tokenSources } from "../../tokens";
import Grid from "../Grid";
@ -27,12 +27,8 @@ function TokenPreview({ token }) {
}
}, [token, tokenSourceData]);
const tokenSource = useImageSource(
tokenSourceData,
tokenSources,
unknownSource
);
const [tokenSourceImage] = useImage(tokenSource);
const tokenURL = useDataURL(tokenSourceData, tokenSources);
const [tokenSourceImage] = useImage(tokenURL);
const [stageWidth, setStageWidth] = useState(1);
const [stageHeight, setStageHeight] = useState(1);

View File

@ -21,39 +21,49 @@ function TokenSettings({ token, onSettingsChange }) {
name="name"
value={(token && token.name) || ""}
onChange={(e) => onSettingsChange("name", e.target.value)}
disabled={tokenEmpty || token.type === "default"}
disabled={tokenEmpty}
my={1}
/>
</Box>
<Box mt={2}>
<Label mb={1}>Category</Label>
<Label>Default Category</Label>
<Select
options={categorySettings}
value={
!tokenEmpty &&
categorySettings.find((s) => s.value === token.category)
categorySettings.find((s) => s.value === token.defaultCategory)
}
isDisabled={tokenEmpty}
onChange={(option) =>
onSettingsChange("defaultCategory", option.value)
}
isDisabled={tokenEmpty || token.type === "default"}
onChange={(option) => onSettingsChange("category", option.value)}
isSearchable={false}
/>
</Box>
<Flex>
<Box my={2} sx={{ flexGrow: 1 }}>
<Label htmlFor="tokenSize">Default Size</Label>
<Input
type="number"
name="tokenSize"
value={`${(token && token.defaultSize) || 0}`}
onChange={(e) =>
onSettingsChange("defaultSize", parseFloat(e.target.value))
}
disabled={tokenEmpty || token.type === "default"}
min={1}
my={1}
/>
</Box>
</Flex>
<Box mt={2} sx={{ flexGrow: 1 }}>
<Label htmlFor="tokenSize">Default Size</Label>
<Input
type="number"
name="tokenSize"
value={`${(token && token.defaultSize) || 0}`}
onChange={(e) =>
onSettingsChange("defaultSize", parseFloat(e.target.value))
}
disabled={tokenEmpty}
min={1}
my={1}
/>
</Box>
<Box my={2} mb={3} sx={{ flexGrow: 1 }}>
<Label htmlFor="label">Default Label</Label>
<Input
name="label"
value={(token && token.defaultLabel) || ""}
onChange={(e) => onSettingsChange("defaultLabel", e.target.value)}
disabled={tokenEmpty}
my={1}
/>
</Box>
</Flex>
);
}

View File

@ -1,42 +1,28 @@
import React from "react";
import Tile from "../Tile";
import { useImageSource } from "../../contexts/ImageSourceContext";
import {
tokenSources as defaultTokenSources,
unknownSource,
} from "../../tokens";
import Tile from "../tile/Tile";
import TokenImage from "./TokenImage";
function TokenTile({
token,
isSelected,
onTokenSelect,
onSelect,
onTokenEdit,
size,
canEdit,
badges,
}) {
const tokenSource = useImageSource(
token,
defaultTokenSources,
unknownSource,
token.type === "file"
);
return (
<Tile
src={tokenSource}
title={token.name}
isSelected={isSelected}
onSelect={() => onTokenSelect(token)}
onSelect={() => onSelect(token.id)}
onEdit={() => onTokenEdit(token.id)}
size={size}
canEdit={canEdit}
badges={badges}
editTitle="Edit Token"
/>
>
<TokenImage token={token} />
</Tile>
);
}

View File

@ -0,0 +1,48 @@
import React from "react";
import { Grid } from "theme-ui";
import Tile from "../tile/Tile";
import TokenImage from "./TokenImage";
import useResponsiveLayout from "../../hooks/useResponsiveLayout";
function TokenTileGroup({
group,
tokens,
isSelected,
onSelect,
onDoubleClick,
}) {
const layout = useResponsiveLayout();
return (
<Tile
title={group.name}
isSelected={isSelected}
onSelect={() => onSelect(group.id)}
onDoubleClick={onDoubleClick}
>
<Grid
columns={`repeat(${layout.groupGridColumns}, 1fr)`}
p={2}
gap={2}
sx={{
height: "100%",
gridTemplateRows: `repeat(${layout.groupGridColumns}, 1fr)`,
}}
>
{tokens
.slice(0, layout.groupGridColumns * layout.groupGridColumns)
.map((token) => (
<TokenImage
sx={{ borderRadius: "8px" }}
token={token}
key={`${token.id}-group-tile`}
/>
))}
</Grid>
</Tile>
);
}
export default TokenTileGroup;

View File

@ -1,183 +1,72 @@
import React from "react";
import { Flex, Box, Text, IconButton, Close, Label } from "theme-ui";
import SimpleBar from "simplebar-react";
import Case from "case";
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 TokenTileGroup from "./TokenTileGroup";
import TokenHiddenBadge from "./TokenHiddenBadge";
import { useDatabase } from "../../contexts/DatabaseContext";
import SortableTiles from "../tile/SortableTiles";
import SortableTilesDragOverlay from "../tile/SortableTilesDragOverlay";
import useResponsiveLayout from "../../hooks/useResponsiveLayout";
import { getGroupItems } from "../../helpers/group";
function TokenTiles({
tokens,
groups,
onTokenAdd,
onTokenEdit,
onTokenSelect,
selectedTokens,
onTokensRemove,
selectMode,
onSelectModeChange,
search,
onSearchChange,
onTokensGroup,
onTokensHide,
}) {
const { databaseStatus } = useDatabase();
const layout = useResponsiveLayout();
import { useGroup } from "../../contexts/GroupContext";
let hasSelectedDefaultToken = selectedTokens.some(
(token) => token.type === "default"
);
let allTokensVisible = selectedTokens.every((token) => !token.hideInSidebar);
function TokenTiles({ tokensById, onTokenEdit, subgroup }) {
const {
selectedGroupIds,
selectMode,
onGroupOpen,
onGroupSelect,
} = useGroup();
function tokenToTile(token) {
const isSelected = selectedTokens.includes(token);
return (
<TokenTile
key={token.id}
token={token}
isSelected={isSelected}
onTokenSelect={onTokenSelect}
onTokenEdit={onTokenEdit}
size={layout.tileSize}
canEdit={
function renderTile(group) {
if (group.type === "item") {
const token = tokensById[group.id];
if (token) {
const isSelected = selectedGroupIds.includes(group.id);
const canEdit =
isSelected &&
token.type !== "default" &&
selectMode === "single" &&
selectedTokens.length === 1
}
badges={[`${token.defaultSize}x`]}
/>
);
}
selectedGroupIds.length === 1;
const multipleSelected = selectedTokens.length > 1;
let hideTitle = "";
if (multipleSelected) {
if (allTokensVisible) {
hideTitle = "Hide Tokens in Sidebar";
return (
<TokenTile
key={token.id}
token={token}
isSelected={isSelected}
onSelect={onGroupSelect}
onTokenEdit={onTokenEdit}
canEdit={canEdit}
badges={[
`${token.defaultSize}x`,
<TokenHiddenBadge hidden={token.hideInSidebar} />,
]}
/>
);
}
} else {
hideTitle = "Show Tokens in Sidebar";
}
} else {
if (allTokensVisible) {
hideTitle = "Hide Token in Sidebar";
} else {
hideTitle = "Show Token in Sidebar";
const isSelected = selectedGroupIds.includes(group.id);
const items = getGroupItems(group);
const canOpen =
isSelected && selectMode === "single" && selectedGroupIds.length === 1;
return (
<TokenTileGroup
key={group.id}
group={group}
tokens={items.map((item) => tokensById[item.id])}
isSelected={isSelected}
onSelect={onGroupSelect}
onDoubleClick={() => canOpen && onGroupOpen(group.id)}
/>
);
}
}
return (
<Box sx={{ position: "relative" }}>
<FilterBar
onFocus={() => onTokenSelect()}
search={search}
onSearchChange={onSearchChange}
selectMode={selectMode}
onSelectModeChange={onSelectModeChange}
onAdd={onTokenAdd}
addTitle="Add Token"
/>
<SimpleBar
style={{ height: layout.screenSize === "large" ? "600px" : "400px" }}
>
<Flex
p={2}
pb={4}
pt={databaseStatus === "disabled" ? 4 : 2}
bg="muted"
sx={{
flexWrap: "wrap",
borderRadius: "4px",
minHeight: layout.screenSize === "large" ? "600px" : "400px",
alignContent: "flex-start",
}}
onClick={() => onTokenSelect()}
>
{groups.map((group) => (
<React.Fragment key={group}>
<Label mx={1} mt={2}>
{Case.capital(group)}
</Label>
{tokens[group].map(tokenToTile)}
</React.Fragment>
))}
</Flex>
</SimpleBar>
{databaseStatus === "disabled" && (
<Box
sx={{
position: "absolute",
top: "39px",
left: 0,
right: 0,
textAlign: "center",
borderRadius: "2px",
}}
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>
)}
{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>
<>
<SortableTiles renderTile={renderTile} subgroup={subgroup} />
<SortableTilesDragOverlay renderTile={renderTile} subgroup={subgroup} />
</>
);
}

View File

@ -1,94 +0,0 @@
import React from "react";
import { Box, Flex } from "theme-ui";
import shortid from "shortid";
import SimpleBar from "simplebar-react";
import ListToken from "./ListToken";
import ProxyToken from "./ProxyToken";
import SelectTokensButton from "./SelectTokensButton";
import { fromEntries } from "../../helpers/shared";
import useSetting from "../../hooks/useSetting";
import { useAuth } from "../../contexts/AuthContext";
import { useTokenData } from "../../contexts/TokenDataContext";
const listTokenClassName = "list-token";
function Tokens({ onMapTokenStateCreate }) {
const { userId } = useAuth();
const { ownedTokens, tokens, updateToken } = useTokenData();
const [fullScreen] = useSetting("map.fullScreen");
function handleProxyDragEnd(isOnMap, token) {
if (isOnMap && onMapTokenStateCreate) {
// Create a token state from the dragged token
onMapTokenStateCreate({
id: shortid.generate(),
tokenId: token.id,
owner: userId,
size: token.defaultSize,
label: "",
statuses: [],
x: token.x,
y: token.y,
lastModifiedBy: userId,
lastModified: Date.now(),
rotation: 0,
locked: false,
visible: true,
});
// Update last used for cache invalidation
// Keep last modified the same
updateToken(token.id, {
lastUsed: Date.now(),
lastModified: token.lastModified,
});
}
}
return (
<>
<Box
sx={{
height: "100%",
width: "80px",
minWidth: "80px",
overflow: "hidden",
display: fullScreen ? "none" : "block",
}}
>
<SimpleBar style={{ height: "calc(100% - 48px)", overflowX: "hidden" }}>
{ownedTokens
.filter((token) => !token.hideInSidebar)
.map((token) => (
<ListToken
key={token.id}
token={token}
className={listTokenClassName}
/>
))}
</SimpleBar>
<Flex
bg="muted"
sx={{
justifyContent: "center",
height: "48px",
alignItems: "center",
}}
>
<SelectTokensButton />
</Flex>
</Box>
<ProxyToken
tokenClassName={listTokenClassName}
onProxyDragEnd={handleProxyDragEnd}
tokens={fromEntries(tokens.map((token) => [token.id, token]))}
/>
</>
);
}
export default Tokens;

View File

@ -0,0 +1,364 @@
import React, { useState, useContext, useCallback, useEffect } from "react";
import * as Comlink from "comlink";
import { encode } from "@msgpack/msgpack";
import { useDatabase } from "./DatabaseContext";
import useDebounce from "../hooks/useDebounce";
import { omit } from "../helpers/shared";
/**
* @typedef Asset
* @property {string} id
* @property {number} width
* @property {number} height
* @property {Uint8Array} file
* @property {string} mime
* @property {string} owner
*/
/**
* @callback getAsset
* @param {string} assetId
* @returns {Promise<Asset|undefined>}
*/
/**
* @callback addAssets
* @param {Asset[]} assets
*/
/**
* @callback putAsset
* @param {Asset} asset
*/
/**
* @typedef AssetsContext
* @property {getAsset} getAsset
* @property {addAssets} addAssets
* @property {putAsset} putAsset
*/
/**
* @type {React.Context<undefined|AssetsContext>}
*/
const AssetsContext = React.createContext();
// 100 MB max cache size
const maxCacheSize = 1e8;
export function AssetsProvider({ children }) {
const { worker, database, databaseStatus } = useDatabase();
useEffect(() => {
if (databaseStatus === "loaded") {
worker.cleanAssetCache(maxCacheSize);
}
}, [worker, databaseStatus]);
const getAsset = useCallback(
async (assetId) => {
return await database.table("assets").get(assetId);
},
[database]
);
const addAssets = useCallback(
async (assets) => {
await database.table("assets").bulkAdd(assets);
},
[database]
);
const putAsset = useCallback(
async (asset) => {
// Attempt to use worker to put map to avoid UI lockup
const packedAsset = encode(asset);
const success = await worker.putData(
Comlink.transfer(packedAsset, [packedAsset.buffer]),
"assets"
);
if (!success) {
await database.table("assets").put(asset);
}
},
[database, worker]
);
const value = {
getAsset,
addAssets,
putAsset,
};
return (
<AssetsContext.Provider value={value}>{children}</AssetsContext.Provider>
);
}
export function useAssets() {
const context = useContext(AssetsContext);
if (context === undefined) {
throw new Error("useAssets must be used within a AssetsProvider");
}
return context;
}
/**
* @typedef AssetURL
* @property {string} url
* @property {string} id
* @property {number} references
*/
/**
* @type React.Context<undefined|Object.<string, AssetURL>>
*/
export const AssetURLsStateContext = React.createContext();
/**
* @type React.Context<undefined|React.Dispatch<React.SetStateAction<{}>>>
*/
export const AssetURLsUpdaterContext = React.createContext();
/**
* Helper to manage sharing of custom image sources between uses of useAssetURL
*/
export function AssetURLsProvider({ children }) {
const [assetURLs, setAssetURLs] = useState({});
// Clean up asset URLs every minute
const debouncedAssetURLs = useDebounce(assetURLs, 60 * 1000);
// Revoke url when no more references
useEffect(() => {
setAssetURLs((prevURLs) => {
let urlsToCleanup = [];
for (let url of Object.values(prevURLs)) {
if (url.references <= 0) {
URL.revokeObjectURL(url.url);
urlsToCleanup.push(url.id);
}
}
if (urlsToCleanup.length > 0) {
return omit(prevURLs, urlsToCleanup);
} else {
return prevURLs;
}
});
}, [debouncedAssetURLs]);
return (
<AssetURLsStateContext.Provider value={assetURLs}>
<AssetURLsUpdaterContext.Provider value={setAssetURLs}>
{children}
</AssetURLsUpdaterContext.Provider>
</AssetURLsStateContext.Provider>
);
}
/**
* Helper function to load either file or default asset into a URL
* @param {string} assetId
* @param {"file"|"default"} type
* @param {Object.<string, string>} defaultSources
* @param {string|undefined} unknownSource
* @returns {string|undefined}
*/
export function useAssetURL(assetId, type, defaultSources, unknownSource) {
const assetURLs = useContext(AssetURLsStateContext);
if (assetURLs === undefined) {
throw new Error("useAssetURL must be used within a AssetURLsProvider");
}
const setAssetURLs = useContext(AssetURLsUpdaterContext);
if (setAssetURLs === undefined) {
throw new Error("useAssetURL must be used within a AssetURLsProvider");
}
const { getAsset } = useAssets();
const { database, databaseStatus } = useDatabase();
useEffect(() => {
if (
!assetId ||
type !== "file" ||
!database ||
databaseStatus === "loading"
) {
return;
}
function updateAssetURL() {
function increaseReferences(prevURLs) {
return {
...prevURLs,
[assetId]: {
...prevURLs[assetId],
references: prevURLs[assetId].references + 1,
},
};
}
function createURL(prevURLs, asset) {
const url = URL.createObjectURL(
new Blob([asset.file], { type: asset.mime })
);
return {
...prevURLs,
[assetId]: { url, id: assetId, references: 1 },
};
}
setAssetURLs((prevURLs) => {
if (assetId in prevURLs) {
// Check if the asset url is already added and increase references
return increaseReferences(prevURLs);
} else {
getAsset(assetId).then((asset) => {
if (!asset) {
return;
}
setAssetURLs((prevURLs) => {
if (assetId in prevURLs) {
// Check again if it exists
return increaseReferences(prevURLs);
} else {
// Create url if the asset doesn't have a url
return createURL(prevURLs, asset);
}
});
});
return prevURLs;
}
});
}
updateAssetURL();
// Update the url when the asset is added to the db after the hook is used
function handleAssetChanges(changes) {
for (let change of changes) {
const id = change.key;
if (
change.table === "assets" &&
id === assetId &&
(change.type === 1 || change.type === 2)
) {
const asset = change.obj;
setAssetURLs((prevURLs) => {
if (!(assetId in prevURLs)) {
const url = URL.createObjectURL(
new Blob([asset.file], { type: asset.mime })
);
return {
...prevURLs,
[assetId]: { url, id: assetId, references: 1 },
};
} else {
return prevURLs;
}
});
}
}
}
database.on("changes", handleAssetChanges);
return () => {
database.on("changes").unsubscribe(handleAssetChanges);
// Decrease references
setAssetURLs((prevURLs) => {
if (assetId in prevURLs) {
return {
...prevURLs,
[assetId]: {
...prevURLs[assetId],
references: prevURLs[assetId].references - 1,
},
};
} else {
return prevURLs;
}
});
};
}, [assetId, setAssetURLs, getAsset, type, database, databaseStatus]);
if (!assetId) {
return unknownSource;
}
if (type === "default") {
return defaultSources[assetId];
}
if (type === "file") {
return assetURLs[assetId]?.url || unknownSource;
}
return unknownSource;
}
/**
* @typedef FileData
* @property {string} file
* @property {"file"} type
* @property {string} thumbnail
* @property {string=} quality
* @property {Object.<string, string>=} resolutions
*/
/**
* @typedef DefaultData
* @property {string} key
* @property {"default"} type
*/
/**
* Load a map or token into a URL taking into account a thumbnail and multiple resolutions
* @param {FileData|DefaultData} data
* @param {Object.<string, string>} defaultSources
* @param {string|undefined} unknownSource
* @param {boolean} thumbnail
* @returns {string|undefined}
*/
export function useDataURL(
data,
defaultSources,
unknownSource,
thumbnail = false
) {
const [assetId, setAssetId] = useState();
useEffect(() => {
if (!data) {
return;
}
function loadAssetId() {
if (data.type === "default") {
setAssetId(data.key);
} else {
if (thumbnail) {
setAssetId(data.thumbnail);
} else if (data.resolutions && data.quality !== "original") {
setAssetId(data.resolutions[data.quality]);
} else {
setAssetId(data.file);
}
}
}
loadAssetId();
}, [data, thumbnail]);
const assetURL = useAssetURL(
assetId,
data?.type,
defaultSources,
unknownSource
);
return assetURL;
}
export default AssetsContext;

View File

@ -1,7 +1,4 @@
import React, { useState, useEffect, useContext } from "react";
import shortid from "shortid";
import { useDatabase } from "./DatabaseContext";
import FakeStorage from "../helpers/FakeStorage";
@ -18,35 +15,13 @@ try {
}
export function AuthProvider({ children }) {
const { database, databaseStatus } = useDatabase();
const [password, setPassword] = useState(storage.getItem("auth") || "");
useEffect(() => {
storage.setItem("auth", password);
}, [password]);
const [userId, setUserId] = useState();
useEffect(() => {
if (!database || databaseStatus === "loading") {
return;
}
async function loadUserId() {
const storedUserId = await database.table("user").get("userId");
if (storedUserId) {
setUserId(storedUserId.value);
} else {
const id = shortid.generate();
setUserId(id);
database.table("user").add({ key: "userId", value: id });
}
}
loadUserId();
}, [database, databaseStatus]);
const value = {
userId,
password,
setPassword,
};

View File

@ -1,4 +1,6 @@
import React, { useState, useEffect, useContext } from "react";
// eslint-disable-next-line no-unused-vars
import Dexie from "dexie";
import * as Comlink from "comlink";
import ErrorBanner from "../components/banner/ErrorBanner";
@ -7,12 +9,24 @@ import { getDatabase } from "../database";
import DatabaseWorker from "worker-loader!../workers/DatabaseWorker"; // eslint-disable-line import/no-webpack-loader-syntax
/**
* @typedef DatabaseContext
* @property {Dexie|undefined} database
* @property {any} worker
* @property {string} databaseStatus
* @property {Error|undefined} databaseError
*/
/**
* @type {React.Context<undefined|DatabaseContext>}
*/
const DatabaseContext = React.createContext();
const worker = Comlink.wrap(new DatabaseWorker());
export function DatabaseProvider({ children }) {
const [database, setDatabase] = useState();
// "loading" | "disabled" | "upgrading" | "loaded"
const [databaseStatus, setDatabaseStatus] = useState("loading");
const [databaseError, setDatabaseError] = useState();
@ -21,11 +35,23 @@ export function DatabaseProvider({ children }) {
let testDBRequest = window.indexedDB.open("__test");
testDBRequest.onsuccess = async function () {
testDBRequest.result.close();
let db = getDatabase({ autoOpen: false });
let db = getDatabase(
{ autoOpen: false },
undefined,
undefined,
true,
(v) => {
setDatabaseStatus("upgrading");
}
);
setDatabase(db);
db.on("ready", () => {
setDatabaseStatus("loaded");
});
db.on("versionchange", () => {
// When another tab loads a new version of the database refresh the page
window.location.reload();
});
await db.open();
window.indexedDB.deleteDatabase("__test");
};
@ -45,18 +71,18 @@ export function DatabaseProvider({ children }) {
function handleDatabaseError(event) {
event.preventDefault();
if (event.reason?.message.startsWith("QuotaExceededError")) {
if (event?.reason?.message?.startsWith("QuotaExceededError")) {
setDatabaseError({
name: event.reason.name,
name: event?.reason?.name,
message: "Storage Quota Exceeded Please Clear Space and Try Again.",
});
} else {
setDatabaseError({
name: event.reason.name,
name: event?.reason?.name,
message: "Something went wrong, please refresh your browser.",
});
}
console.error(event.reason);
console.error(event?.reason);
}
window.addEventListener("unhandledrejection", handleDatabaseError);

View File

@ -0,0 +1,75 @@
// eslint-disable-next-line no-unused-vars
import React, { useRef, ReactNode } from "react";
import {
DndContext,
useDndContext,
useDndMonitor,
// eslint-disable-next-line no-unused-vars
DragEndEvent,
} from "@dnd-kit/core";
/**
* Wrap a dnd-kit DndContext with a position monitor to get the
* active drag element on drag end
* TODO: use look into fixing this upstream
* Related: https://github.com/clauderic/dnd-kit/issues/238
*/
/**
* @typedef DragEndOverlayEvent
* @property {DOMRect} overlayNodeClientRect
*
* @typedef {DragEndEvent & DragEndOverlayEvent} DragEndWithOverlayProps
*/
/**
* @callback DragEndWithOverlayEvent
* @param {DragEndWithOverlayProps} props
*/
/**
* @typedef CustomDragProps
* @property {DragEndWithOverlayEvent=} onDragEnd
* @property {ReactNode} children
*/
/**
* @param {CustomDragProps} props
*/
function DragPositionMonitor({ children, onDragEnd }) {
const { overlayNode } = useDndContext();
const overlayNodeClientRectRef = useRef();
function handleDragMove() {
if (overlayNode?.nodeRef?.current) {
overlayNodeClientRectRef.current = overlayNode.nodeRef.current.getBoundingClientRect();
}
}
function handleDragEnd(props) {
onDragEnd &&
onDragEnd({
...props,
overlayNodeClientRect: overlayNodeClientRectRef.current,
});
}
useDndMonitor({ onDragEnd: handleDragEnd, onDragMove: handleDragMove });
return children;
}
/**
* TODO: Import Props interface from dnd-kit with conversion to Typescript
* @param {CustomDragProps} props
*/
function DragContext({ children, onDragEnd, ...props }) {
return (
<DndContext {...props}>
<DragPositionMonitor onDragEnd={onDragEnd}>
{children}
</DragPositionMonitor>
</DndContext>
);
}
export default DragContext;

View File

@ -0,0 +1,231 @@
import React, { useState, useContext, useEffect } from "react";
import cloneDeep from "lodash.clonedeep";
import Fuse from "fuse.js";
import { useKeyboard, useBlur } from "./KeyboardContext";
import { getGroupItems, groupsFromIds } from "../helpers/group";
import shortcuts from "../shortcuts";
const GroupContext = React.createContext();
export function GroupProvider({
groups,
itemNames,
onGroupsChange,
onGroupsSelect,
disabled,
children,
}) {
const [selectedGroupIds, setSelectedGroupIds] = useState([]);
// Either single, multiple or range
const [selectMode, setSelectMode] = useState("single");
/**
* Group Open
*/
const [openGroupId, setOpenGroupId] = useState();
const [openGroupItems, setOpenGroupItems] = useState([]);
useEffect(() => {
if (openGroupId) {
setOpenGroupItems(getGroupItems(groupsFromIds([openGroupId], groups)[0]));
} else {
setOpenGroupItems([]);
}
}, [openGroupId, groups]);
function handleGroupOpen(groupId) {
setSelectedGroupIds([]);
setOpenGroupId(groupId);
}
function handleGroupClose() {
setSelectedGroupIds([]);
setOpenGroupId();
}
/**
* Search
*/
const [filter, setFilter] = useState();
const [filteredGroupItems, setFilteredGroupItems] = useState([]);
const [fuse, setFuse] = useState();
// Update search index when items change
useEffect(() => {
let items = [];
for (let group of groups) {
const itemsToAdd = getGroupItems(group);
const namedItems = itemsToAdd.map((item) => ({
...item,
name: itemNames[item.id],
}));
items.push(...namedItems);
}
setFuse(new Fuse(items, { keys: ["name"] }));
}, [groups, itemNames]);
// Perform search when search changes
useEffect(() => {
if (filter) {
const query = fuse.search(filter);
setFilteredGroupItems(query.map((result) => result.item));
setOpenGroupId();
} else {
setFilteredGroupItems([]);
}
}, [filter, fuse]);
/**
* Handlers
*/
const activeGroups = openGroupId
? openGroupItems
: filter
? filteredGroupItems
: groups;
/**
* @param {string|undefined} groupId The group to apply changes to, leave undefined to replace the full group object
*/
function handleGroupsChange(newGroups, groupId) {
if (groupId) {
// If a group is specidifed then update that group with the new items
const groupIndex = groups.findIndex((group) => group.id === groupId);
let updatedGroups = cloneDeep(groups);
const group = updatedGroups[groupIndex];
updatedGroups[groupIndex] = { ...group, items: newGroups };
onGroupsChange(updatedGroups);
} else {
onGroupsChange(newGroups);
}
}
function handleGroupSelect(groupId) {
let groupIds = [];
if (groupId) {
switch (selectMode) {
case "single":
groupIds = [groupId];
break;
case "multiple":
if (selectedGroupIds.includes(groupId)) {
groupIds = selectedGroupIds.filter((id) => id !== groupId);
} else {
groupIds = [...selectedGroupIds, groupId];
}
break;
case "range":
if (selectedGroupIds.length > 0) {
const currentIndex = activeGroups.findIndex(
(g) => g.id === groupId
);
const lastIndex = activeGroups.findIndex(
(g) => g.id === selectedGroupIds[selectedGroupIds.length - 1]
);
let idsToAdd = [];
let idsToRemove = [];
const direction = currentIndex > lastIndex ? 1 : -1;
for (
let i = lastIndex + direction;
direction < 0 ? i >= currentIndex : i <= currentIndex;
i += direction
) {
const id = activeGroups[i].id;
if (selectedGroupIds.includes(id)) {
idsToRemove.push(id);
} else {
idsToAdd.push(id);
}
}
groupIds = [...selectedGroupIds, ...idsToAdd].filter(
(id) => !idsToRemove.includes(id)
);
} else {
groupIds = [groupId];
}
break;
default:
groupIds = [];
}
}
setSelectedGroupIds(groupIds);
onGroupsSelect(groupIds);
}
/**
* Shortcuts
*/
function handleKeyDown(event) {
if (disabled) {
return;
}
if (shortcuts.selectRange(event)) {
setSelectMode("range");
}
if (shortcuts.selectMultiple(event)) {
setSelectMode("multiple");
}
}
function handleKeyUp(event) {
if (disabled) {
return;
}
if (shortcuts.selectRange(event) && selectMode === "range") {
setSelectMode("single");
}
if (shortcuts.selectMultiple(event) && selectMode === "multiple") {
setSelectMode("single");
}
}
useKeyboard(handleKeyDown, handleKeyUp);
// Set select mode to single when cmd+tabing
function handleBlur() {
setSelectMode("single");
}
useBlur(handleBlur);
const value = {
groups,
activeGroups,
openGroupId,
openGroupItems,
filter,
filteredGroupItems,
selectedGroupIds,
selectMode,
onSelectModeChange: setSelectMode,
onGroupOpen: handleGroupOpen,
onGroupClose: handleGroupClose,
onGroupsChange: handleGroupsChange,
onGroupSelect: handleGroupSelect,
onFilterChange: setFilter,
};
return (
<GroupContext.Provider value={value}>{children}</GroupContext.Provider>
);
}
GroupProvider.defaultProps = {
groups: [],
itemNames: {},
onGroupsChange: () => {},
onGroupsSelect: () => {},
disabled: false,
};
export function useGroup() {
const context = useContext(GroupContext);
if (context === undefined) {
throw new Error("useGroup must be used within a GroupProvider");
}
return context;
}
export default GroupContext;

View File

@ -1,157 +0,0 @@
import React, { useContext, useState, useEffect } from "react";
import { omit } from "../helpers/shared";
export const ImageSourcesStateContext = React.createContext();
export const ImageSourcesUpdaterContext = React.createContext(() => {});
/**
* Helper to manage sharing of custom image sources between uses of useImageSource
*/
export function ImageSourcesProvider({ children }) {
const [imageSources, setImageSources] = useState({});
// Revoke url when no more references
useEffect(() => {
let sourcesToCleanup = [];
for (let source of Object.values(imageSources)) {
if (source.references <= 0) {
URL.revokeObjectURL(source.url);
sourcesToCleanup.push(source.id);
}
}
if (sourcesToCleanup.length > 0) {
setImageSources((prevSources) => omit(prevSources, sourcesToCleanup));
}
}, [imageSources]);
return (
<ImageSourcesStateContext.Provider value={imageSources}>
<ImageSourcesUpdaterContext.Provider value={setImageSources}>
{children}
</ImageSourcesUpdaterContext.Provider>
</ImageSourcesStateContext.Provider>
);
}
/**
* Get id from image data
*/
function getImageFileId(data, thumbnail) {
if (thumbnail) {
return `${data.id}-thumbnail`;
}
if (data.resolutions) {
// Check is a resolution is specified
if (data.quality && data.resolutions[data.quality]) {
return `${data.id}-${data.quality}`;
} else if (!data.file) {
// Fallback to the highest resolution
const resolutionArray = Object.keys(data.resolutions);
const resolution = resolutionArray[resolutionArray.length - 1];
return `${data.id}-${resolution.id}`;
}
}
return data.id;
}
/**
* Helper function to load either file or default image into a URL
*/
export function useImageSource(data, defaultSources, unknownSource, thumbnail) {
const imageSources = useContext(ImageSourcesStateContext);
if (imageSources === undefined) {
throw new Error(
"useImageSource must be used within a ImageSourcesProvider"
);
}
const setImageSources = useContext(ImageSourcesUpdaterContext);
if (setImageSources === undefined) {
throw new Error(
"useImageSource must be used within a ImageSourcesProvider"
);
}
useEffect(() => {
if (!data || data.type !== "file") {
return;
}
const id = getImageFileId(data, thumbnail);
function updateImageSource(file) {
if (file) {
setImageSources((prevSources) => {
if (id in prevSources) {
// Check if the image source is already added
return {
...prevSources,
[id]: {
...prevSources[id],
// Increase references
references: prevSources[id].references + 1,
},
};
} else {
const url = URL.createObjectURL(new Blob([file]));
return {
...prevSources,
[id]: { url, id, references: 1 },
};
}
});
}
}
if (thumbnail) {
updateImageSource(data.thumbnail.file);
} else if (data.resolutions) {
// Check is a resolution is specified
if (data.quality && data.resolutions[data.quality]) {
updateImageSource(data.resolutions[data.quality].file);
}
// If no file available fallback to the highest resolution
else if (!data.file) {
const resolutionArray = Object.keys(data.resolutions);
updateImageSource(
data.resolutions[resolutionArray[resolutionArray.length - 1]].file
);
} else {
updateImageSource(data.file);
}
} else {
updateImageSource(data.file);
}
return () => {
// Decrease references
setImageSources((prevSources) => {
if (id in prevSources) {
return {
...prevSources,
[id]: {
...prevSources[id],
references: prevSources[id].references - 1,
},
};
} else {
return prevSources;
}
});
};
}, [data, unknownSource, thumbnail, setImageSources]);
if (!data) {
return unknownSource;
}
if (data.type === "default") {
return defaultSources[data.key];
}
if (data.type === "file") {
const id = getImageFileId(data, thumbnail);
return imageSources[id]?.url;
}
return unknownSource;
}

View File

@ -1,23 +1,13 @@
import React, {
useEffect,
useState,
useContext,
useCallback,
useRef,
} from "react";
import * as Comlink from "comlink";
import { decode, encode } from "@msgpack/msgpack";
import React, { useEffect, useState, useContext, useCallback } from "react";
import { useAuth } from "./AuthContext";
import { useUserId } from "./UserIdContext";
import { useDatabase } from "./DatabaseContext";
import { maps as defaultMaps } from "../maps";
import { applyObservableChange } from "../helpers/dexie";
import { removeGroupsItems } from "../helpers/group";
const MapDataContext = React.createContext();
// Maximum number of maps to keep in the cache
const cachedMapMax = 15;
const defaultMapState = {
tokens: {},
drawShapes: {},
@ -28,80 +18,35 @@ const defaultMapState = {
};
export function MapDataProvider({ children }) {
const { database, databaseStatus, worker } = useDatabase();
const { userId } = useAuth();
const { database, databaseStatus } = useDatabase();
const userId = useUserId();
const [maps, setMaps] = useState([]);
const [mapStates, setMapStates] = useState([]);
const [mapsLoading, setMapsLoading] = useState(true);
const [mapGroups, setMapGroups] = useState([]);
// Load maps from the database and ensure state is properly setup
useEffect(() => {
if (!userId || !database || databaseStatus === "loading") {
return;
}
async function getDefaultMaps() {
const defaultMapsWithIds = [];
for (let i = 0; i < defaultMaps.length; i++) {
const defaultMap = defaultMaps[i];
const id = `__default-${defaultMap.name}`;
defaultMapsWithIds.push({
...defaultMap,
id,
owner: userId,
// Emulate the time increasing to avoid sort errors
created: Date.now() + i,
lastModified: Date.now() + i,
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);
if (!state) {
await database.table("states").add({ ...defaultMapState, mapId: id });
}
}
return defaultMapsWithIds;
}
// Loads maps without the file data to save memory
async function loadMaps() {
let storedMaps = [];
// Try to load maps with worker, fallback to database if failed
const packedMaps = await worker.loadData("maps");
// let packedMaps;
if (packedMaps) {
storedMaps = decode(packedMaps);
} else {
console.warn("Unable to load maps with worker, loading may be slow");
await database.table("maps").each((map) => {
const { file, resolutions, ...rest } = map;
storedMaps.push(rest);
});
}
const sortedMaps = storedMaps.sort((a, b) => b.created - a.created);
const defaultMapsWithIds = await getDefaultMaps();
const allMaps = [...sortedMaps, ...defaultMapsWithIds];
setMaps(allMaps);
const storedMaps = await database.table("maps").toArray();
setMaps(storedMaps);
const storedStates = await database.table("states").toArray();
setMapStates(storedStates);
const group = await database.table("groups").get("maps");
const storedGroups = group.items;
setMapGroups(storedGroups);
setMapsLoading(false);
}
loadMaps();
}, [userId, database, databaseStatus, worker]);
}, [userId, database, databaseStatus]);
const mapsRef = useRef(maps);
useEffect(() => {
mapsRef.current = maps;
}, [maps]);
const getMap = useCallback((mapId) => {
return mapsRef.current.find((map) => map.id === mapId);
}, []);
const getMapFromDB = useCallback(
const getMap = useCallback(
async (mapId) => {
let map = await database.table("maps").get(mapId);
return map;
@ -109,7 +54,7 @@ export function MapDataProvider({ children }) {
[database]
);
const getMapStateFromDB = useCallback(
const getMapState = useCallback(
async (mapId) => {
let mapState = await database.table("states").get(mapId);
return mapState;
@ -118,26 +63,7 @@ export function MapDataProvider({ children }) {
);
/**
* Keep up to cachedMapMax amount of maps that you don't own
* Sorted by when they we're last used
*/
const updateCache = useCallback(async () => {
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();
}
}, [database, userId]);
/**
* Adds a map to the database, also adds an assosiated state for that map
* Adds a map to the database, also adds an assosiated state and group for that map
* @param {Object} map map to add
*/
const addMap = useCallback(
@ -146,25 +72,36 @@ export function MapDataProvider({ children }) {
const state = { ...defaultMapState, mapId: map.id };
await database.table("maps").add(map);
await database.table("states").add(state);
if (map.owner !== userId) {
await updateCache();
}
},
[database, updateCache, userId]
);
const removeMap = useCallback(
async (id) => {
await database.table("maps").delete(id);
await database.table("states").delete(id);
const group = await database.table("groups").get("maps");
await database.table("groups").update("maps", {
items: [{ id: map.id, type: "item" }, ...group.items],
});
},
[database]
);
const removeMaps = useCallback(
async (ids) => {
const maps = await database.table("maps").bulkGet(ids);
// Remove assets linked with maps
let assetIds = [];
for (let map of maps) {
if (map.type === "file") {
assetIds.push(map.file);
assetIds.push(map.thumbnail);
for (let res of Object.values(map.resolutions)) {
assetIds.push(res);
}
}
}
const group = await database.table("groups").get("maps");
let items = removeGroupsItems(group.items, ids);
await database.table("groups").update("maps", { items });
await database.table("maps").bulkDelete(ids);
await database.table("states").bulkDelete(ids);
await database.table("assets").bulkDelete(assetIds);
},
[database]
);
@ -180,23 +117,7 @@ export function MapDataProvider({ children }) {
const updateMap = useCallback(
async (id, update) => {
// fake-indexeddb throws an error when updating maps in production.
// Catch that error and use put when it fails
try {
await database.table("maps").update(id, update);
} catch (error) {
const map = (await getMapFromDB(id)) || {};
await database.table("maps").put({ ...map, id, ...update });
}
},
[database, getMapFromDB]
);
const updateMaps = useCallback(
async (ids, update) => {
await Promise.all(
ids.map((id) => database.table("maps").update(id, update))
);
await database.table("maps").update(id, update);
},
[database]
);
@ -208,28 +129,13 @@ export function MapDataProvider({ children }) {
[database]
);
/**
* 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
*/
const putMap = useCallback(
async (map) => {
// Attempt to use worker to put map to avoid UI lockup
const packedMap = encode(map);
const success = await worker.putData(
Comlink.transfer(packedMap, [packedMap.buffer]),
"maps",
false
);
if (!success) {
await database.table("maps").put(map);
}
if (map.owner !== userId) {
await updateCache();
}
const updateMapGroups = useCallback(
async (groups) => {
// Update group state immediately to avoid animation delay
setMapGroups(groups);
await database.table("groups").update("maps", { items: groups });
},
[database, updateCache, userId, worker]
[database]
);
// Create DB observable to sync creating and deleting
@ -286,6 +192,13 @@ export function MapDataProvider({ children }) {
});
}
}
if (change.table === "groups") {
if (change.type === 2 && change.key === "maps") {
const group = applyObservableChange(change);
const groups = group.items.filter((item) => item !== null);
setMapGroups(groups);
}
}
}
}
@ -296,24 +209,30 @@ export function MapDataProvider({ children }) {
};
}, [database, databaseStatus]);
const ownedMaps = maps.filter((map) => map.owner === userId);
const [mapsById, setMapsById] = useState({});
useEffect(() => {
setMapsById(
maps.reduce((obj, map) => {
obj[map.id] = map;
return obj;
}, {})
);
}, [maps]);
const value = {
maps,
ownedMaps,
mapStates,
mapGroups,
addMap,
removeMap,
removeMaps,
resetMap,
updateMap,
updateMaps,
updateMapState,
putMap,
getMap,
getMapFromDB,
mapsLoading,
getMapStateFromDB,
getMapState,
updateMapGroups,
mapsById,
};
return (
<MapDataContext.Provider value={value}>{children}</MapDataContext.Provider>

View File

@ -1,46 +1,44 @@
import React, { useState, useRef, useContext } from "react";
import { omit, isEmpty } from "../helpers/shared";
import React, { useState, useRef, useContext, useCallback } from "react";
const MapLoadingContext = React.createContext();
export function MapLoadingProvider({ children }) {
const [loadingAssetCount, setLoadingAssetCount] = useState(0);
function assetLoadStart() {
setLoadingAssetCount((prevLoadingAssets) => prevLoadingAssets + 1);
}
function assetLoadFinish() {
setLoadingAssetCount((prevLoadingAssets) => prevLoadingAssets - 1);
}
const [isLoading, setIsLoading] = useState(false);
// Mapping from asset id to the count and total number of pieces loaded
const assetProgressRef = useRef({});
// Loading progress of all assets between 0 and 1
const loadingProgressRef = useRef(null);
function assetProgressUpdate({ id, count, total }) {
if (count === total) {
assetProgressRef.current = omit(assetProgressRef.current, [id]);
} else {
assetProgressRef.current = {
...assetProgressRef.current,
[id]: { count, total },
};
}
if (!isEmpty(assetProgressRef.current)) {
let total = 0;
let count = 0;
for (let progress of Object.values(assetProgressRef.current)) {
total += progress.total;
count += progress.count;
}
loadingProgressRef.current = count / total;
}
}
const isLoading = loadingAssetCount > 0;
const assetLoadStart = useCallback((id) => {
setIsLoading(true);
// Add asset at a 0% progress
assetProgressRef.current = {
...assetProgressRef.current,
[id]: { count: 0, total: 1 },
};
}, []);
const assetProgressUpdate = useCallback(({ id, count, total }) => {
assetProgressRef.current = {
...assetProgressRef.current,
[id]: { count, total },
};
// Update loading progress
let complete = 0;
const progresses = Object.values(assetProgressRef.current);
for (let progress of progresses) {
complete += progress.count / progress.total;
}
loadingProgressRef.current = complete / progresses.length;
// All loading is complete
if (loadingProgressRef.current === 1) {
setIsLoading(false);
assetProgressRef.current = {};
}
}, []);
const value = {
assetLoadStart,
assetLoadFinish,
isLoading,
assetProgressUpdate,
loadingProgressRef,

View File

@ -1,7 +1,7 @@
import React, { useEffect, useContext } from "react";
import { useDatabase } from "./DatabaseContext";
import { useAuth } from "./AuthContext";
import { useUserId } from "./UserIdContext";
import { getRandomMonster } from "../helpers/monsters";
@ -11,7 +11,7 @@ export const PlayerStateContext = React.createContext();
export const PlayerUpdaterContext = React.createContext(() => {});
export function PlayerProvider({ session, children }) {
const { userId } = useAuth();
const userId = useUserId();
const { database, databaseStatus } = useDatabase();
const [playerState, setPlayerState] = useNetworkedState(
@ -53,7 +53,7 @@ export function PlayerProvider({ session, children }) {
if (
playerState.nickname &&
database !== undefined &&
databaseStatus !== "loading"
(databaseStatus === "loaded" || databaseStatus === "disabled")
) {
database
.table("user")

View File

@ -0,0 +1,264 @@
import React, { useState, useContext, useEffect } from "react";
import {
MouseSensor,
TouchSensor,
KeyboardSensor,
useSensor,
useSensors,
closestCenter,
} from "@dnd-kit/core";
import DragContext from "./DragContext";
import { useGroup } from "./GroupContext";
import { moveGroupsInto, moveGroups, ungroup } from "../helpers/group";
import usePreventSelect from "../hooks/usePreventSelect";
const TileDragIdContext = React.createContext();
const TileOverGroupIdContext = React.createContext();
const TileDragCursorContext = React.createContext();
export const BASE_SORTABLE_ID = "__base__";
export const GROUP_SORTABLE_ID = "__group__";
export const GROUP_ID_PREFIX = "__group__";
export const UNGROUP_ID = "__ungroup__";
export const ADD_TO_MAP_ID = "__add__";
// Custom rectIntersect that takes a point
function rectIntersection(rects, point) {
for (let rect of rects) {
const [id, bounds] = rect;
if (
id &&
bounds &&
point.x > bounds.offsetLeft &&
point.x < bounds.offsetLeft + bounds.width &&
point.y > bounds.offsetTop &&
point.y < bounds.offsetTop + bounds.height
) {
return id;
}
}
return null;
}
export function TileDragProvider({
onDragAdd,
onDragStart,
onDragEnd,
onDragCancel,
children,
}) {
const {
groups,
activeGroups,
openGroupId,
selectedGroupIds,
onGroupsChange,
onGroupSelect,
onGroupClose,
filter,
} = useGroup();
const mouseSensor = useSensor(MouseSensor, {
activationConstraint: { distance: 3 },
});
const touchSensor = useSensor(TouchSensor, {
activationConstraint: { delay: 250, tolerance: 5 },
});
const keyboardSensor = useSensor(KeyboardSensor);
const sensors = useSensors(mouseSensor, touchSensor, keyboardSensor);
const [dragId, setDragId] = useState(null);
const [overId, setOverId] = useState(null);
const [dragCursor, setDragCursor] = useState("pointer");
const [preventSelect, resumeSelect] = usePreventSelect();
const [overGroupId, setOverGroupId] = useState(null);
useEffect(() => {
setOverGroupId(
(overId && overId.startsWith(GROUP_ID_PREFIX) && overId.slice(9)) || null
);
}, [overId]);
function handleDragStart(event) {
const { active, over } = event;
setDragId(active.id);
setOverId(over?.id || null);
if (!selectedGroupIds.includes(active.id)) {
onGroupSelect(active.id);
}
setDragCursor("grabbing");
onDragStart && onDragStart(event);
preventSelect();
}
function handleDragOver(event) {
const { over } = event;
setOverId(over?.id || null);
if (over) {
if (
over.id.startsWith(UNGROUP_ID) ||
over.id.startsWith(GROUP_ID_PREFIX)
) {
setDragCursor("alias");
} else if (over.id.startsWith(ADD_TO_MAP_ID)) {
setDragCursor(onDragAdd ? "copy" : "no-drop");
} else {
setDragCursor("grabbing");
}
}
}
function handleDragEnd(event) {
const { active, over, overlayNodeClientRect } = event;
setDragId(null);
setOverId(null);
setDragCursor("pointer");
if (active && over && active.id !== over.id) {
let selectedIndices = selectedGroupIds.map((groupId) =>
activeGroups.findIndex((group) => group.id === groupId)
);
// Maintain current group sorting
selectedIndices = selectedIndices.sort((a, b) => a - b);
if (over.id.startsWith(GROUP_ID_PREFIX)) {
onGroupSelect();
// Handle tile group
const overId = over.id.slice(9);
if (overId !== active.id) {
const overGroupIndex = activeGroups.findIndex(
(group) => group.id === overId
);
onGroupsChange(
moveGroupsInto(activeGroups, overGroupIndex, selectedIndices),
openGroupId
);
}
} else if (over.id === UNGROUP_ID) {
onGroupSelect();
// Handle tile ungroup
const newGroups = ungroup(groups, openGroupId, selectedIndices);
// Close group if it was removed
if (!newGroups.find((group) => group.id === openGroupId)) {
onGroupClose();
}
onGroupsChange(newGroups);
} else if (over.id === ADD_TO_MAP_ID) {
onDragAdd &&
overlayNodeClientRect &&
onDragAdd(selectedGroupIds, overlayNodeClientRect);
} else if (!filter) {
// Hanlde tile move only if we have no filter
const overGroupIndex = activeGroups.findIndex(
(group) => group.id === over.id
);
onGroupsChange(
moveGroups(activeGroups, overGroupIndex, selectedIndices),
openGroupId
);
}
}
resumeSelect();
onDragEnd && onDragEnd(event);
}
function handleDragCancel(event) {
setDragId(null);
setOverId(null);
setDragCursor("pointer");
resumeSelect();
onDragCancel && onDragCancel(event);
}
function customCollisionDetection(rects, rect) {
const rectCenter = {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
};
// Find whether out rect center is outside our add to map rect
const addRect = rects.find(([id]) => id === ADD_TO_MAP_ID);
if (addRect) {
const intersectingAddRect = rectIntersection([addRect], rectCenter);
if (!intersectingAddRect) {
return ADD_TO_MAP_ID;
}
}
// Find whether out rect center is outside our ungroup rect
if (openGroupId) {
const ungroupRect = rects.find(([id]) => id === UNGROUP_ID);
if (ungroupRect) {
const intersectingGroupRect = rectIntersection(
[ungroupRect],
rectCenter
);
if (!intersectingGroupRect) {
return UNGROUP_ID;
}
}
}
const otherRects = rects.filter(
([id]) => id !== ADD_TO_MAP_ID && id !== UNGROUP_ID
);
return closestCenter(otherRects, rect);
}
return (
<DragContext
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragCancel={handleDragCancel}
sensors={sensors}
collisionDetection={customCollisionDetection}
>
<TileDragIdContext.Provider value={dragId}>
<TileOverGroupIdContext.Provider value={overGroupId}>
<TileDragCursorContext.Provider value={dragCursor}>
{children}
</TileDragCursorContext.Provider>
</TileOverGroupIdContext.Provider>
</TileDragIdContext.Provider>
</DragContext>
);
}
export function useTileDragId() {
const context = useContext(TileDragIdContext);
if (context === undefined) {
throw new Error("useTileDrag must be used within a TileDragProvider");
}
return context;
}
export function useTileOverGroupId() {
const context = useContext(TileOverGroupIdContext);
if (context === undefined) {
throw new Error("useTileDrag must be used within a TileDragProvider");
}
return context;
}
export function useTileDragCursor() {
const context = useContext(TileDragCursorContext);
if (context === undefined) {
throw new Error("useTileDrag must be used within a TileDragProvider");
}
return context;
}

View File

@ -1,83 +1,39 @@
import React, {
useEffect,
useState,
useContext,
useCallback,
useRef,
} from "react";
import { decode } from "@msgpack/msgpack";
import React, { useEffect, useState, useContext, useCallback } from "react";
import { useAuth } from "./AuthContext";
import { useUserId } from "./UserIdContext";
import { useDatabase } from "./DatabaseContext";
import { tokens as defaultTokens } from "../tokens";
import { applyObservableChange } from "../helpers/dexie";
import { removeGroupsItems } from "../helpers/group";
const TokenDataContext = React.createContext();
const cachedTokenMax = 100;
export function TokenDataProvider({ children }) {
const { database, databaseStatus, worker } = useDatabase();
const { userId } = useAuth();
const { database, databaseStatus } = useDatabase();
const userId = useUserId();
/**
* Contains all tokens without any file data,
* to ensure file data is present call loadTokens
*/
const [tokens, setTokens] = useState([]);
const [tokensLoading, setTokensLoading] = useState(true);
const [tokenGroups, setTokenGroups] = useState([]);
useEffect(() => {
if (!userId || !database || databaseStatus === "loading") {
return;
}
function getDefaultTokens() {
const defaultTokensWithIds = [];
for (let defaultToken of defaultTokens) {
defaultTokensWithIds.push({
...defaultToken,
id: `__default-${defaultToken.name}`,
owner: userId,
group: "default",
});
}
return defaultTokensWithIds;
}
// Loads tokens without the file data to save memory
async function loadTokens() {
let storedTokens = [];
// Try to load tokens with worker, fallback to database if failed
const packedTokens = await worker.loadData("tokens");
if (packedTokens) {
storedTokens = decode(packedTokens);
} else {
console.warn("Unable to load tokens with worker, loading may be slow");
await database.table("tokens").each((token) => {
const { file, resolutions, ...rest } = token;
storedTokens.push(rest);
});
}
const sortedTokens = storedTokens.sort((a, b) => b.created - a.created);
const defaultTokensWithIds = getDefaultTokens();
const allTokens = [...sortedTokens, ...defaultTokensWithIds];
setTokens(allTokens);
const storedTokens = await database.table("tokens").toArray();
setTokens(storedTokens);
const group = await database.table("groups").get("tokens");
const storedGroups = group.items;
setTokenGroups(storedGroups);
setTokensLoading(false);
}
loadTokens();
}, [userId, database, databaseStatus, worker]);
}, [userId, database, databaseStatus]);
const tokensRef = useRef(tokens);
useEffect(() => {
tokensRef.current = tokens;
}, [tokens]);
const getToken = useCallback((tokenId) => {
return tokensRef.current.find((token) => token.id === tokenId);
}, []);
const getTokenFromDB = useCallback(
const getToken = useCallback(
async (tokenId) => {
let token = await database.table("tokens").get(tokenId);
return token;
@ -85,106 +41,73 @@ export function TokenDataProvider({ children }) {
[database]
);
/**
* Keep up to cachedTokenMax amount of tokens that you don't own
* Sorted by when they we're last used
*/
const updateCache = useCallback(async () => {
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();
}
}, [database, userId]);
// Add token and add it to the token group
const addToken = useCallback(
async (token) => {
await database.table("tokens").add(token);
if (token.owner !== userId) {
await updateCache();
}
},
[database, updateCache, userId]
);
const removeToken = useCallback(
async (id) => {
await database.table("tokens").delete(id);
const group = await database.table("groups").get("tokens");
await database.table("groups").update("tokens", {
items: [{ id: token.id, type: "item" }, ...group.items],
});
},
[database]
);
const removeTokens = useCallback(
async (ids) => {
const tokens = await database.table("tokens").bulkGet(ids);
let assetIds = [];
for (let token of tokens) {
if (token.type === "file") {
assetIds.push(token.file);
assetIds.push(token.thumbnail);
}
}
const group = await database.table("groups").get("tokens");
let items = removeGroupsItems(group.items, ids);
await database.table("groups").update("tokens", { items });
await database.table("tokens").bulkDelete(ids);
await database.table("assets").bulkDelete(assetIds);
},
[database]
);
const updateToken = useCallback(
async (id, update) => {
const change = { lastModified: Date.now(), ...update };
await database.table("tokens").update(id, change);
await database.table("tokens").update(id, update);
},
[database]
);
const updateTokens = useCallback(
async (ids, update) => {
const change = { lastModified: Date.now(), ...update };
const updateTokensHidden = useCallback(
async (ids, hideInSidebar) => {
// Update immediately to avoid UI delay
setTokens((prevTokens) => {
let newTokens = [...prevTokens];
for (let id of ids) {
const tokenIndex = newTokens.findIndex((token) => token.id === id);
newTokens[tokenIndex].hideInSidebar = hideInSidebar;
}
return newTokens;
});
await Promise.all(
ids.map((id) => database.table("tokens").update(id, change))
ids.map((id) => database.table("tokens").update(id, { hideInSidebar }))
);
},
[database]
);
const putToken = useCallback(
async (token) => {
await database.table("tokens").put(token);
if (token.owner !== userId) {
await updateCache();
}
},
[database, updateCache, userId]
);
const loadTokens = useCallback(
async (tokenIds) => {
const loadedTokens = await database.table("tokens").bulkGet(tokenIds);
const loadedTokensById = loadedTokens.reduce((obj, token) => {
obj[token.id] = token;
return obj;
}, {});
setTokens((prevTokens) => {
return prevTokens.map((prevToken) => {
if (prevToken.id in loadedTokensById) {
return loadedTokensById[prevToken.id];
} else {
return prevToken;
}
});
});
const updateTokenGroups = useCallback(
async (groups) => {
// Update group state immediately to avoid animation delay
setTokenGroups(groups);
await database.table("groups").update("tokens", { items: groups });
},
[database]
);
const unloadTokens = useCallback(async () => {
setTokens((prevTokens) => {
return prevTokens.map((prevToken) => {
const { file, ...rest } = prevToken;
return rest;
});
});
}, []);
// Create DB observable to sync creating and deleting
useEffect(() => {
if (!database || databaseStatus === "loading") {
@ -192,32 +115,50 @@ export function TokenDataProvider({ children }) {
}
function handleTokenChanges(changes) {
// Pool token changes together to call a single state update at the end
let tokensCreated = [];
let tokensUpdated = {};
let tokensDeleted = [];
for (let change of changes) {
if (change.table === "tokens") {
if (change.type === 1) {
// Created
const token = change.obj;
setTokens((prevTokens) => [token, ...prevTokens]);
tokensCreated.push(token);
} else if (change.type === 2) {
// Updated
const token = change.obj;
setTokens((prevTokens) => {
const newTokens = [...prevTokens];
const i = newTokens.findIndex((t) => t.id === token.id);
if (i > -1) {
newTokens[i] = token;
}
return newTokens;
});
tokensUpdated[token.id] = token;
} else if (change.type === 3) {
// Deleted
const id = change.key;
setTokens((prevTokens) => {
const filtered = prevTokens.filter((token) => token.id !== id);
return filtered;
});
tokensDeleted.push(id);
}
}
if (change.table === "groups") {
if (change.type === 2 && change.key === "tokens") {
const group = applyObservableChange(change);
const groups = group.items.filter((item) => item !== null);
setTokenGroups(groups);
}
}
}
const tokensUpdatedArray = Object.values(tokensUpdated);
if (
tokensCreated.length > 0 ||
tokensUpdatedArray.length > 0 ||
tokensDeleted.length > 0
) {
setTokens((prevTokens) => {
let newTokens = [...tokensCreated, ...prevTokens];
for (let token of tokensUpdatedArray) {
const tokenIndex = newTokens.findIndex((t) => t.id === token.id);
if (tokenIndex > -1) {
newTokens[tokenIndex] = token;
}
}
return newTokens.filter((token) => !tokensDeleted.includes(token.id));
});
}
}
@ -228,28 +169,27 @@ export function TokenDataProvider({ children }) {
};
}, [database, databaseStatus]);
const ownedTokens = tokens.filter((token) => token.owner === userId);
const tokensById = tokens.reduce((obj, token) => {
obj[token.id] = token;
return obj;
}, {});
const [tokensById, setTokensById] = useState({});
useEffect(() => {
setTokensById(
tokens.reduce((obj, token) => {
obj[token.id] = token;
return obj;
}, {})
);
}, [tokens]);
const value = {
tokens,
ownedTokens,
addToken,
removeToken,
tokenGroups,
removeTokens,
updateToken,
updateTokens,
putToken,
getToken,
tokensById,
tokensLoading,
getTokenFromDB,
loadTokens,
unloadTokens,
getToken,
updateTokenGroups,
updateTokensHidden,
};
return (

View File

@ -0,0 +1,36 @@
import React, { useEffect, useState, useContext } from "react";
import { useDatabase } from "./DatabaseContext";
/**
* @type {React.Context<string|undefined>}
*/
const UserIdContext = React.createContext();
export function UserIdProvider({ children }) {
const { database, databaseStatus } = useDatabase();
const [userId, setUserId] = useState();
useEffect(() => {
if (!database || databaseStatus === "loading") {
return;
}
async function loadUserId() {
const storedUserId = await database.table("user").get("userId");
if (storedUserId) {
setUserId(storedUserId.value);
}
}
loadUserId();
}, [database, databaseStatus]);
return (
<UserIdContext.Provider value={userId}>{children}</UserIdContext.Provider>
);
}
export function useUserId() {
return useContext(UserIdContext);
}
export default UserIdContext;

View File

@ -1,449 +1,33 @@
// eslint-disable-next-line no-unused-vars
import Dexie, { Version, DexieOptions } from "dexie";
import Dexie, { DexieOptions } from "dexie";
import { v4 as uuid } from "uuid";
import "dexie-observable";
import shortid from "shortid";
import blobToBuffer from "./helpers/blobToBuffer";
import { getGridDefaultInset } from "./helpers/grid";
import { convertOldActionsToShapes } from "./actions";
import { createThumbnail } from "./helpers/image";
// Helper to create a thumbnail for a file in a db
async function createDataThumbnail(data) {
let url;
if (data?.resolutions?.low?.file) {
url = URL.createObjectURL(new Blob([data.resolutions.low.file]));
} else {
url = URL.createObjectURL(new Blob([data.file]));
}
return await Dexie.waitFor(
new Promise((resolve) => {
let image = new Image();
image.onload = async () => {
const thumbnail = await createThumbnail(image);
resolve(thumbnail);
};
image.src = url;
}),
60000 * 10 // 10 minute timeout
);
}
import { loadVersions } from "./upgrade";
import { getDefaultMaps } from "./maps";
import { getDefaultTokens } from "./tokens";
/**
* @callback VersionCallback
* @param {Version} version
*/
/**
* Mapping of version number to their upgrade function
* @type {Object.<number, VersionCallback>}
*/
const versions = {
// v1.2.0
1(v) {
v.stores({
maps: "id, owner",
states: "mapId",
tokens: "id, owner",
user: "key",
});
},
// v1.2.1 - Move from blob files to array buffers
2(v) {
v.stores({}).upgrade(async (tx) => {
const maps = await Dexie.waitFor(tx.table("maps").toArray());
let mapBuffers = {};
for (let map of maps) {
mapBuffers[map.id] = await Dexie.waitFor(blobToBuffer(map.file));
}
return tx
.table("maps")
.toCollection()
.modify((map) => {
map.file = mapBuffers[map.id];
});
});
},
// v1.3.0 - Added new default tokens
3(v) {
v.stores({}).upgrade((tx) => {
return tx
.table("states")
.toCollection()
.modify((state) => {
function mapTokenId(id) {
switch (id) {
case "__default-Axes":
return "__default-Barbarian";
case "__default-Bird":
return "__default-Druid";
case "__default-Book":
return "__default-Wizard";
case "__default-Crown":
return "__default-Humanoid";
case "__default-Dragon":
return "__default-Dragon";
case "__default-Eye":
return "__default-Warlock";
case "__default-Fist":
return "__default-Monk";
case "__default-Horse":
return "__default-Fey";
case "__default-Leaf":
return "__default-Druid";
case "__default-Lion":
return "__default-Monstrosity";
case "__default-Money":
return "__default-Humanoid";
case "__default-Moon":
return "__default-Cleric";
case "__default-Potion":
return "__default-Sorcerer";
case "__default-Shield":
return "__default-Paladin";
case "__default-Skull":
return "__default-Undead";
case "__default-Snake":
return "__default-Beast";
case "__default-Sun":
return "__default-Cleric";
case "__default-Swords":
return "__default-Fighter";
case "__default-Tree":
return "__default-Plant";
case "__default-Triangle":
return "__default-Sorcerer";
default:
return "__default-Fighter";
}
}
for (let stateId in state.tokens) {
state.tokens[stateId].tokenId = mapTokenId(
state.tokens[stateId].tokenId
);
state.tokens[stateId].lastEditedBy = "";
state.tokens[stateId].rotation = 0;
}
});
});
},
// v1.3.1 - Added show grid option
4(v) {
v.stores({}).upgrade((tx) => {
return tx
.table("maps")
.toCollection()
.modify((map) => {
map.showGrid = false;
});
});
},
// v1.4.0 - Added fog subtraction
5(v) {
v.stores({}).upgrade((tx) => {
return tx
.table("states")
.toCollection()
.modify((state) => {
for (let fogAction of state.fogDrawActions) {
if (fogAction.type === "add" || fogAction.type === "edit") {
for (let shape of fogAction.shapes) {
shape.data.holes = [];
}
}
}
});
});
},
// v1.4.2 - Added map resolutions
6(v) {
v.stores({}).upgrade((tx) => {
return tx
.table("maps")
.toCollection()
.modify((map) => {
map.resolutions = {};
map.quality = "original";
});
});
},
// v1.5.0 - Fixed default token rogue spelling
7(v) {
v.stores({}).upgrade((tx) => {
return tx
.table("states")
.toCollection()
.modify((state) => {
for (let id in state.tokens) {
if (state.tokens[id].tokenId === "__default-Rouge") {
state.tokens[id].tokenId = "__default-Rogue";
}
}
});
});
},
// v1.5.0 - Added map snap to grid option
8(v) {
v.stores({}).upgrade((tx) => {
return tx
.table("maps")
.toCollection()
.modify((map) => {
map.snapToGrid = true;
});
});
},
// v1.5.1 - Added lock, visibility and modified to tokens
9(v) {
v.stores({}).upgrade((tx) => {
return tx
.table("states")
.toCollection()
.modify((state) => {
for (let id in state.tokens) {
state.tokens[id].lastModifiedBy = state.tokens[id].lastEditedBy;
delete state.tokens[id].lastEditedBy;
state.tokens[id].lastModified = Date.now();
state.tokens[id].locked = false;
state.tokens[id].visible = true;
}
});
});
},
// v1.5.1 - Added token prop category and remove isVehicle bool
10(v) {
v.stores({}).upgrade((tx) => {
return tx
.table("tokens")
.toCollection()
.modify((token) => {
token.category = token.isVehicle ? "vehicle" : "character";
delete token.isVehicle;
});
});
},
// v1.5.2 - Added automatic cache invalidation to maps
11(v) {
v.stores({}).upgrade((tx) => {
return tx
.table("maps")
.toCollection()
.modify((map) => {
map.lastUsed = map.lastModified;
});
});
},
// v1.5.2 - Added automatic cache invalidation to tokens
12(v) {
v.stores({}).upgrade((tx) => {
return tx
.table("tokens")
.toCollection()
.modify((token) => {
token.lastUsed = token.lastModified;
});
});
},
// v1.6.0 - Added map grouping and grid scale and offset
13(v) {
v.stores({}).upgrade((tx) => {
return tx
.table("maps")
.toCollection()
.modify((map) => {
map.group = "";
map.grid = {
size: { x: map.gridX, y: map.gridY },
inset: getGridDefaultInset(
{ size: { x: map.gridX, y: map.gridY }, type: "square" },
map.width,
map.height
),
type: "square",
};
delete map.gridX;
delete map.gridY;
delete map.gridType;
});
});
},
// v1.6.0 - Added token grouping
14(v) {
v.stores({}).upgrade((tx) => {
return tx
.table("tokens")
.toCollection()
.modify((token) => {
token.group = "";
});
});
},
// v1.6.1 - Added width and height to tokens
15(v) {
v.stores({}).upgrade(async (tx) => {
const tokens = await Dexie.waitFor(tx.table("tokens").toArray());
let tokenSizes = {};
for (let token of tokens) {
const url = URL.createObjectURL(new Blob([token.file]));
let image = new Image();
tokenSizes[token.id] = await Dexie.waitFor(
new Promise((resolve) => {
image.onload = () => {
resolve({ width: image.width, height: image.height });
};
image.src = url;
})
);
}
return tx
.table("tokens")
.toCollection()
.modify((token) => {
token.width = tokenSizes[token.id].width;
token.height = tokenSizes[token.id].height;
});
});
},
// v1.7.0 - Added note tool
16(v) {
v.stores({}).upgrade((tx) => {
return tx
.table("states")
.toCollection()
.modify((state) => {
state.notes = {};
state.editFlags = [...state.editFlags, "notes"];
});
});
},
// 1.7.0 (hotfix) - Optimized fog shape edits to only include needed data
17(v) {
v.stores({}).upgrade((tx) => {
return tx
.table("states")
.toCollection()
.modify((state) => {
for (let i = 0; i < state.fogDrawActions.length; i++) {
const action = state.fogDrawActions[i];
if (action && action.type === "edit") {
for (let j = 0; j < action.shapes.length; j++) {
const shape = action.shapes[j];
const temp = { ...shape };
state.fogDrawActions[i].shapes[j] = {
id: temp.id,
visible: temp.visible,
};
}
}
}
});
});
},
// 1.8.0 - Added note text only mode, converted draw and fog representations
18(v) {
v.stores({}).upgrade((tx) => {
return tx
.table("states")
.toCollection()
.modify((state) => {
for (let id in state.notes) {
state.notes[id].textOnly = false;
}
state.drawShapes = convertOldActionsToShapes(
state.mapDrawActions,
state.mapDrawActionIndex
);
state.fogShapes = convertOldActionsToShapes(
state.fogDrawActions,
state.fogDrawActionIndex
);
delete state.mapDrawActions;
delete state.mapDrawActionIndex;
delete state.fogDrawActions;
delete state.fogDrawActionIndex;
});
});
},
// 1.8.0 - Add thumbnail to maps and add measurement to grid
19(v) {
v.stores({}).upgrade(async (tx) => {
const userId = (await Dexie.waitFor(tx.table("user").get("userId")))
.value;
const maps = await Dexie.waitFor(tx.table("maps").toArray());
const thumbnails = {};
for (let map of maps) {
try {
if (map.owner === userId) {
thumbnails[map.id] = await createDataThumbnail(map);
}
} catch {}
}
return tx
.table("maps")
.toCollection()
.modify((map) => {
map.thumbnail = thumbnails[map.id];
map.grid.measurement = { type: "chebyshev", scale: "5ft" };
});
});
},
// 1.8.0 - Add thumbnail to tokens
20(v) {
v.stores({}).upgrade(async (tx) => {
const userId = (await Dexie.waitFor(tx.table("user").get("userId")))
.value;
const tokens = await Dexie.waitFor(tx.table("tokens").toArray());
const thumbnails = {};
for (let token of tokens) {
try {
if (token.owner === userId) {
thumbnails[token.id] = await createDataThumbnail(token);
}
} catch {}
}
return tx
.table("tokens")
.toCollection()
.modify((token) => {
token.thumbnail = thumbnails[token.id];
});
});
},
// 1.8.0 - Upgrade for Dexie.Observable
21(v) {
v.stores({});
},
// v1.8.1 - Shorten fog shape ids
22(v) {
v.stores({}).upgrade((tx) => {
return tx
.table("states")
.toCollection()
.modify((state) => {
for (let id of Object.keys(state.fogShapes)) {
const newId = shortid.generate();
state.fogShapes[newId] = state.fogShapes[id];
state.fogShapes[newId].id = newId;
delete state.fogShapes[id];
}
});
});
},
};
const latestVersion = 22;
/**
* Load versions onto a database up to a specific version number
* Populate DB with initial data
* @param {Dexie} db
* @param {number=} upTo version number to load up to, latest version if undefined
*/
export function loadVersions(db, upTo = latestVersion) {
for (let versionNumber = 1; versionNumber <= upTo; versionNumber++) {
versions[versionNumber](db.version(versionNumber));
}
function populate(db) {
db.on("populate", () => {
const userId = uuid();
db.table("user").add({ key: "userId", value: userId });
const { maps, mapStates } = getDefaultMaps(userId);
db.table("maps").bulkAdd(maps);
db.table("states").bulkAdd(mapStates);
const tokens = getDefaultTokens(userId);
db.table("tokens").bulkAdd(tokens);
db.table("groups").bulkAdd([
{ id: "maps", items: maps.map((map) => ({ id: map.id, type: "item" })) },
{
id: "tokens",
items: tokens.map((token) => ({ id: token.id, type: "item" })),
},
]);
});
}
/**
@ -451,14 +35,21 @@ export function loadVersions(db, upTo = latestVersion) {
* @param {DexieOptions} options
* @param {string=} name
* @param {number=} versionNumber
* @param {boolean=} populateData
* @param {import("./upgrade").OnUpgrade=} onUpgrade
* @returns {Dexie}
*/
export function getDatabase(
options,
name = "OwlbearRodeoDB",
versionNumber = latestVersion
versionNumber = undefined,
populateData = true,
onUpgrade = undefined
) {
let db = new Dexie(name, options);
loadVersions(db, versionNumber);
loadVersions(db, versionNumber, onUpgrade);
if (populateData) {
populate(db);
}
return db;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 42 KiB

View File

@ -1 +1 @@
[embed:](https://www.youtube.com/embed/KLUsOZA-SHI)
![embed:](https://www.youtube.com/embed/KLUsOZA-SHI)

View File

@ -6,6 +6,8 @@ To Access the settings screen click the Settings button in the bottom left of a
An overview of each setting is listed below:
- Light theme: Enables/disables the light theme.
- Show fog guides: Enables/disables the fog guide visual when editing fog.
- Fog edit opacity: Adjusts how visible fog is while the GM is editing it.
- Token label size: Changes the max label size for tokens.
- Grid snapping sensitivity: Changes how sensitive the grid snapping is. 0 = no grid snapping, 1 = full grid snapping.
- Clear cache: Clears the apps settings and any maps that other users have sent you. Does not remove any data you have added.

View File

@ -1,5 +1,3 @@
[embed:](https://www.youtube.com/embed/ztLDznOpmsg)
Once you have started a game you can share a map to all other party members by clicking the Select Map button then selecting the desired map to share and clicking the Done button.
## Default Maps
@ -41,7 +39,6 @@ Next you can set the name of your map shown in the Map Select Screen.
## 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)
@ -63,7 +60,7 @@ A brief summary of these settings is listed below.
- Tokens: Controls whether others can move tokens that they have not placed themselves (default enabled).
- Notes: Controls whether others can add or move notes (default enabled).
## Reseting, Removing and Grouping Maps
## Reseting, Removing, Organising Maps
With a map selected there are a couple of actions you can perform on them.
@ -75,7 +72,13 @@ Once a map has been used you can clear away all the tokens, fog and drawings by
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.
Maps can also be grouped to allow for better organisation. To do this with you can drag and drop a map onto another map to create a group.
Once a group has been created you can double click that group to open it.
To ungroup a map you can drag that map back out of the group into the main view when the group is open.
Maps can also be reordered by dragging them into the desired position.
## Filtering Maps

View File

@ -1,4 +1,4 @@
[embed:](https://www.youtube.com/embed/Er_grVmqpk0)
![embed:](https://www.youtube.com/embed/Er_grVmqpk0)
Owlbear Rodeo supports a physically simulated 3D dice tray and dice. To access these features click the Show Dice Tray icon in the top left of the map view.

View File

@ -1,4 +1,4 @@
[embed:](https://www.youtube.com/embed/2e07DtB-Xrc)
![embed:](https://www.youtube.com/embed/2e07DtB-Xrc)
The Drawing Tool allows you to draw on top of a map. To access the Drawing Tool click the Drawing Tool button in the top right of the map view.

View File

@ -1,4 +1,4 @@
[embed:](https://www.youtube.com/embed/1ra7DoIsas8)
![embed:](https://www.youtube.com/embed/1ra7DoIsas8)
The Fog Tool allows you to add hidden areas to control what the other party members can see on your map. To access the Fog Tool click the Fog Tool button in the top right of the map view.

View File

@ -1,3 +1,5 @@
The Measure Tool allows you to find how far one point on a map is from another point. To access the Measure Tool click the Measure Tool button in the top right of the map view.
To change the scale or type of measurement used by the tool you must edit the current map. See the Advanced section of the Sharing a Map How To for more information.
![Using Measure](usingMeasure)

View File

@ -1,5 +1,3 @@
[embed:](https://www.youtube.com/embed/j-9X9CF7_UY)
Once you have a map shared between a party all players can drag tokens from the Token List on the right hand side of the screen. Tokens can then be used to represent players, monsters or any other object that needs to be moved around the map.
## Default Tokens
@ -71,7 +69,13 @@ To remove a custom token select the token in the Edit Tokens Screen then click t
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.
Tokens can also be grouped to allow for better organisation. To do this with you can drag and drop a token onto another token to create a group.
Once a group has been created you can double click that group to open it.
To ungroup a token you can drag that token back out of the group into the main view when the group is open.
Tokens can also be reordered by dragging them into the desired position.
`Tip: You can select multiple tokens at the same time using the Select Multiple option or using the Ctrl/Cmd or Shift keyboard shortcuts`

View File

@ -1,4 +1,4 @@
[embed:](https://www.youtube.com/embed/aOTvQOrpNo4)
![embed:](https://www.youtube.com/embed/aOTvQOrpNo4)
## Major Changes

View File

@ -1,4 +1,4 @@
[embed:](https://www.youtube.com/embed/IhSS24d4zlM)
![embed:](https://www.youtube.com/embed/IhSS24d4zlM)
## Major Changes

View File

@ -1,4 +1,4 @@
[embed:](https://www.youtube.com/embed/Y7sEgoopz4E)
![embed:](https://www.youtube.com/embed/Y7sEgoopz4E)
## Major Changes

View File

@ -1,4 +1,4 @@
[embed:](https://www.youtube.com/embed/vtNpj-449B8)
![embed:](https://www.youtube.com/embed/vtNpj-449B8)
## Major Changes

Some files were not shown because too many files have changed in this diff Show More