(
+
+ {content}
+
+ )}
+ overlayElement={(props, content) => (
+ {
+ // Prevent drag event from triggering with a modal open
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ {...props}
+ >
+ {content}
+
+ )}
{...props}
>
{children}
diff --git a/src/components/NumberInput.js b/src/components/NumberInput.js
deleted file mode 100644
index e6985f8..0000000
--- a/src/components/NumberInput.js
+++ /dev/null
@@ -1,59 +0,0 @@
-import React from "react";
-import { Box, Flex, IconButton, Text } from "theme-ui";
-
-function NumberInput({ value, onChange, title, min, max }) {
- return (
-
-
- {title}
-
-
- value > min && onChange(value - 1)}
- >
-
-
-
- {value}
-
- value < max && onChange(value + 1)}
- >
-
-
-
-
- );
-}
-
-NumberInput.defaultProps = {
- value: 1,
- onChange: () => {},
- title: "Number",
- min: 0,
- max: 10,
-};
-
-export default NumberInput;
diff --git a/src/components/Select.js b/src/components/Select.js
index 413df18..d39c797 100644
--- a/src/components/Select.js
+++ b/src/components/Select.js
@@ -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}
/>
);
diff --git a/src/components/TextareaAutoSize.css b/src/components/TextareaAutoSize.css
new file mode 100644
index 0000000..4868f44
--- /dev/null
+++ b/src/components/TextareaAutoSize.css
@@ -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;
+}
diff --git a/src/components/TextareaAutoSize.js b/src/components/TextareaAutoSize.js
new file mode 100644
index 0000000..53e58aa
--- /dev/null
+++ b/src/components/TextareaAutoSize.js
@@ -0,0 +1,8 @@
+import TextareaAutosize from "react-textarea-autosize";
+import "./TextareaAutoSize.css";
+
+function StyledTextareaAutoSize(props) {
+ return ;
+}
+
+export default StyledTextareaAutoSize;
diff --git a/src/components/UpgradingLoadingOverlay.js b/src/components/UpgradingLoadingOverlay.js
new file mode 100644
index 0000000..c284cbb
--- /dev/null
+++ b/src/components/UpgradingLoadingOverlay.js
@@ -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 bear’s 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 (
+
+
+ Database upgrading, please wait...
+
+ {subText && (
+ <>
+
+ We're still working on the upgrade. In the meantime, did you know?
+
+
+ {subText}
+
+ >
+ )}
+
+ );
+}
+
+export default UpgradingLoadingOverlay;
diff --git a/src/components/dice/DiceTile.js b/src/components/dice/DiceTile.js
index 101e390..b9e1eb1 100644
--- a/src/components/dice/DiceTile.js
+++ b/src/components/dice/DiceTile.js
@@ -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 (
- onDiceSelect(dice)}
- onDoubleClick={() => onDone(dice)}
- size={size}
- />
+
+ onDiceSelect(dice)}
+ onDoubleClick={() => onDone(dice)}
+ >
+
+
+
);
}
diff --git a/src/components/dice/DiceTiles.js b/src/components/dice/DiceTiles.js
index 86ef58a..671848b 100644
--- a/src/components/dice/DiceTiles.js
+++ b/src/components/dice/DiceTiles.js
@@ -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 (
-
-
+
{dice.map((dice) => (
))}
-
+
);
}
diff --git a/src/components/drag/Draggable.js b/src/components/drag/Draggable.js
new file mode 100644
index 0000000..e138e94
--- /dev/null
+++ b/src/components/drag/Draggable.js
@@ -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 (
+
+ {children}
+
+ );
+}
+
+export default Draggable;
diff --git a/src/components/drag/Droppable.js b/src/components/drag/Droppable.js
new file mode 100644
index 0000000..d11d00f
--- /dev/null
+++ b/src/components/drag/Droppable.js
@@ -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 (
+
+ {children}
+
+ );
+}
+
+Droppable.defaultProps = {
+ disabled: false,
+};
+
+export default Droppable;
diff --git a/src/components/image/GlobalImageDrop.js b/src/components/image/GlobalImageDrop.js
new file mode 100644
index 0000000..f36ad2f
--- /dev/null
+++ b/src/components/image/GlobalImageDrop.js
@@ -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 (
+
+ {children}
+ {dragging && (
+
+
+
+
+ Drop as map
+
+
+
+
+
+ Drop as token
+
+
+
+ )}
+
+ {isLoading && }
+
+ );
+}
+
+export default GlobalImageDrop;
diff --git a/src/components/image/ImageDrop.js b/src/components/image/ImageDrop.js
new file mode 100644
index 0000000..d46e14f
--- /dev/null
+++ b/src/components/image/ImageDrop.js
@@ -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 (
+
+ {children}
+ {dragging && (
+
+
+ {dropText || "Drop image to import"}
+
+
+ )}
+
+ );
+}
+
+export default ImageDrop;
diff --git a/src/components/DragOverlay.js b/src/components/map/DragOverlay.js
similarity index 97%
rename from src/components/DragOverlay.js
rename to src/components/map/DragOverlay.js
index a89f02d..57f140e 100644
--- a/src/components/DragOverlay.js
+++ b/src/components/map/DragOverlay.js
@@ -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);
diff --git a/src/components/map/Map.js b/src/components/map/Map.js
index 340749e..af5e9bc 100644
--- a/src/components/map/Map.js
+++ b/src/components/map/Map.js
@@ -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 (
-
- {mapControls}
- {tokenMenu}
- {noteMenu}
- {tokenDragOverlay}
- {noteDragOverlay}
- >
- }
- selectedToolId={selectedToolId}
- onSelectedToolChange={setSelectedToolId}
- disabledControls={disabledControls}
- >
- {mapGrid}
- {mapDrawing}
- {mapNotes}
- {mapTokens}
- {mapFog}
- {mapPointer}
- {mapMeasure}
-
+
+
+ {mapControls}
+ {tokenMenu}
+ {noteMenu}
+ {tokenDragOverlay}
+ {noteDragOverlay}
+ >
+ }
+ selectedToolId={selectedToolId}
+ onSelectedToolChange={setSelectedToolId}
+ disabledControls={disabledControls}
+ >
+ {mapGrid}
+ {mapDrawing}
+ {mapNotes}
+ {mapTokens}
+ {mapFog}
+ {mapPointer}
+ {mapMeasure}
+
+
);
}
diff --git a/src/components/map/MapDrawing.js b/src/components/map/MapDrawing.js
index 54e1234..ccce9fd 100644
--- a/src/components/map/MapDrawing.js
+++ b/src/components/map/MapDrawing.js
@@ -119,8 +119,7 @@ function MapDrawing({
}
const simplified = simplifyPoints(
[...prevPoints, brushPosition],
- gridCellNormalizedSize,
- stageScale
+ 1 / 1000 / stageScale
);
return {
...prevShape,
diff --git a/src/components/map/MapEditBar.js b/src/components/map/MapEditBar.js
new file mode 100644
index 0000000..c1ec6a4
--- /dev/null
+++ b/src/components/map/MapEditBar.js
@@ -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 (
+
+ onGroupSelect()}
+ />
+
+ setIsMapsResetModalOpen(true)}
+ disabled={!hasMapState}
+ >
+
+
+ setIsMapsRemoveModalOpen(true)}
+ >
+
+
+
+ setIsMapsResetModalOpen(false)}
+ onConfirm={handleMapsReset}
+ confirmText="Reset"
+ label="Reset Selected Map(s)"
+ description="This will remove all fog, drawings and tokens from the selected maps."
+ />
+ setIsMapsRemoveModalOpen(false)}
+ onConfirm={handleMapsRemove}
+ confirmText="Remove"
+ label="Remove Selected Map(s)"
+ description="This operation cannot be undone."
+ />
+
+ );
+}
+
+export default MapEditBar;
diff --git a/src/components/map/MapEditor.js b/src/components/map/MapEditor.js
index f8dbb8a..1040468 100644
--- a/src/components/map/MapEditor.js
+++ b/src/components/map/MapEditor.js
@@ -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 }) {
)}
>
-
- {showGridControls && canEditGrid && (
+
+ {showGridControls && gridValid && (
<>
@@ -146,7 +142,7 @@ function MapEditor({ map, onSettingsChange }) {
- {gridChanged && (
+ {gridChanged && gridValid && (
)}
- {canEditGrid && (
- setShowGridControls(!showGridControls)}
- bg="overlay"
- sx={{
- borderRadius: "50%",
- position: "absolute",
- bottom: 0,
- right: 0,
- }}
- m={2}
- p="6px"
- >
- {showGridControls ? : }
-
- )}
+ setShowGridControls(!showGridControls)}
+ bg="overlay"
+ sx={{
+ borderRadius: "50%",
+ position: "absolute",
+ bottom: 0,
+ right: 0,
+ }}
+ m={2}
+ p="6px"
+ >
+ {showGridControls ? : }
+
diff --git a/src/components/map/MapFog.js b/src/components/map/MapFog.js
index 95527c2..7614328 100644
--- a/src/components/map/MapFog.js
+++ b/src/components/map/MapFog.js
@@ -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 (
-
- {/* Render a blank shape so cache works with no fog shapes */}
-
- {fogShapes.map(renderShape)}
-
+ {fogShapes.map(renderShape)}
{shouldRenderGuides && renderGuides()}
{drawingShape && renderShape(drawingShape)}
{drawingShape &&
diff --git a/src/components/map/MapGrid.js b/src/components/map/MapGrid.js
index 5cfbf94..be5e9ba 100644
--- a/src/components/map/MapGrid.js
+++ b/src/components/map/MapGrid.js
@@ -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);
diff --git a/src/components/map/MapGridEditor.js b/src/components/map/MapGridEditor.js
index 8962654..a059180 100644
--- a/src/components/map/MapGridEditor.js
+++ b/src/components/map/MapGridEditor.js
@@ -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;
diff --git a/src/components/map/MapImage.js b/src/components/map/MapImage.js
new file mode 100644
index 0000000..c9c71f8
--- /dev/null
+++ b/src/components/map/MapImage.js
@@ -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 ;
+});
+
+export default MapTileImage;
diff --git a/src/components/map/MapInteraction.js b/src/components/map/MapInteraction.js
index e1bb369..61ba335 100644
--- a/src/components/map/MapInteraction.js
+++ b/src/components/map/MapInteraction.js
@@ -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({
{
if (!active) {
diff --git a/src/components/map/MapNotes.js b/src/components/map/MapNotes.js
index c2706c8..d28f364 100644
--- a/src/components/map/MapNotes.js
+++ b/src/components/map/MapNotes.js
@@ -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);
diff --git a/src/components/map/MapSettings.js b/src/components/map/MapSettings.js
index c8f3276..8f05c3b 100644
--- a/src/components/map/MapSettings.js
+++ b/src/components/map/MapSettings.js
@@ -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}
/>
- {showMore && (
- <>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {!mapEmpty && map.type !== "default" && (
-
-
-
-
-
-
- )}
-
-
-
-
-
-
-
-
-
-
- >
- )}
- {
- 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"}
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {!mapEmpty && map.type !== "default" && (
+
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/src/components/map/MapTest.js b/src/components/map/MapTest.js
deleted file mode 100644
index 25a914b..0000000
--- a/src/components/map/MapTest.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import React from "react";
-
-function MapTest() {}
-
-export default MapTest;
diff --git a/src/components/map/MapTile.js b/src/components/map/MapTile.js
index f09a620..c061b19 100644
--- a/src/components/map/MapTile.js
+++ b/src/components/map/MapTile.js
@@ -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 (
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"
- />
+ >
+
+
);
}
diff --git a/src/components/map/MapTileGroup.js b/src/components/map/MapTileGroup.js
new file mode 100644
index 0000000..753fc63
--- /dev/null
+++ b/src/components/map/MapTileGroup.js
@@ -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 (
+ onSelect(group.id)}
+ onDoubleClick={onDoubleClick}
+ >
+
+ {maps
+ .slice(0, layout.groupGridColumns * layout.groupGridColumns)
+ .map((map) => (
+
+ ))}
+
+
+ );
+}
+
+export default MapTileGroup;
diff --git a/src/components/map/MapTiles.js b/src/components/map/MapTiles.js
index 3b86589..811bb0c 100644
--- a/src/components/map/MapTiles.js
+++ b/src/components/map/MapTiles.js
@@ -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 (
+ 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 (
+ 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 (
-
- );
- }
-
- const multipleSelected = selectedMaps.length > 1;
-
return (
-
- onMapSelect()}
- search={search}
- onSearchChange={onSearchChange}
- selectMode={selectMode}
- onSelectModeChange={onSelectModeChange}
- onAdd={onMapAdd}
- addTitle="Add Map"
- />
-
- onMapSelect()}
- >
- {groups.map((group) => (
-
-
- {maps[group].map(mapToTile)}
-
- ))}
-
-
- {databaseStatus === "disabled" && (
-
-
- Map saving is unavailable. See FAQ for
- more information.
-
-
- )}
- {selectedMaps.length > 0 && (
-
- onMapSelect()}
- />
-
- onMapsGroup()}
- disabled={hasSelectedDefaultMap}
- >
-
-
- onMapsReset()}
- disabled={!hasMapState}
- >
-
-
- onMapsRemove()}
- disabled={hasSelectedDefaultMap}
- >
-
-
-
-
- )}
-
+ <>
+
+
+ >
);
}
diff --git a/src/components/map/MapToken.js b/src/components/map/MapToken.js
index 1f7258f..9a4d6a5 100644
--- a/src/components/map/MapToken.js
+++ b/src/components/map/MapToken.js
@@ -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}
>
-
+
+
+ {}}
/>
-
-
+ {tokenState.statuses?.length > 0 ? (
+
+ ) : null}
+ {tokenState.label ? (
+
+ ) : null}
);
diff --git a/src/components/map/MapTokens.js b/src/components/map/MapTokens.js
index 426dcfe..957d038 100644
--- a/src/components/map/MapTokens.js
+++ b/src/components/map/MapTokens.js
@@ -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) => (
1}
+ onClick={handleSelectClick}
+ mt={2}
+ >
+ Select
+
+ );
+}
+
+export default SelectMapSelectButton;
diff --git a/src/components/note/Note.js b/src/components/note/Note.js
index 461d8b1..a5cd5af 100644
--- a/src/components/note/Note.js
+++ b/src/components/note/Note.js
@@ -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 */}
diff --git a/src/components/note/NoteDragOverlay.js b/src/components/note/NoteDragOverlay.js
index 9df0713..bcd2179 100644
--- a/src/components/note/NoteDragOverlay.js
+++ b/src/components/note/NoteDragOverlay.js
@@ -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() {
diff --git a/src/components/note/NoteMenu.js b/src/components/note/NoteMenu.js
index 9724c2c..8060fd8 100644
--- a/src/components/note/NoteMenu.js
+++ b/src/components/note/NoteMenu.js
@@ -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" }}
>
-
+ {inView ? (
+ children
+ ) : (
+
+ )}
+
+ );
+}
+
+export default LazyTile;
diff --git a/src/components/tile/SortableTile.js b/src/components/tile/SortableTile.js
new file mode 100644
index 0000000..cc9c8a0
--- /dev/null
+++ b/src/components/tile/SortableTile.js
@@ -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 (
+
+
+ {children}
+
+
+
+
+
+
+ );
+}
+
+SortableTile.defaultProps = {
+ cursor: "pointer",
+};
+
+export default SortableTile;
diff --git a/src/components/tile/SortableTiles.js b/src/components/tile/SortableTiles.js
new file mode 100644
index 0000000..a42bfbd
--- /dev/null
+++ b/src/components/tile/SortableTiles.js
@@ -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 (
+
+
+ {renderSortableGroup(group, selectedGroups)}
+
+
+ );
+ });
+ }
+
+ return (
+
+ {renderTiles()}
+
+ );
+}
+
+export default SortableTiles;
diff --git a/src/components/tile/SortableTilesDragOverlay.js b/src/components/tile/SortableTilesDragOverlay.js
new file mode 100644
index 0000000..848b793
--- /dev/null
+++ b/src/components/tile/SortableTilesDragOverlay.js
@@ -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) => (
+
+
+
+ {renderTile(group)}
+ {index === selectedIndices.length - 1 &&
+ selectedGroupIds.length > 1 && (
+
+ {selectedGroupIds.length}
+
+ )}
+
+
+
+ ));
+ }
+
+ return createPortal(dragId && renderDragOverlays(), document.body);
+}
+
+export default SortableTilesDragOverlay;
diff --git a/src/components/Tile.js b/src/components/tile/Tile.js
similarity index 71%
rename from src/components/Tile.js
rename to src/components/tile/Tile.js
index 3640c37..c550657 100644
--- a/src/components/Tile.js
+++ b/src/components/tile/Tile.js
@@ -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 (
- {
e.stopPropagation();
onSelect();
}}
onDoubleClick={onDoubleClick}
+ aria-label={title}
>
-
+ >
+ {children}
+
-
+
{badges.map((badge, i) => (
-
+
{badge}
))}
-
+
{canEdit && (
)}
-
+
);
}
Tile.defaultProps = {
- src: "",
title: "",
isSelected: false,
onSelect: () => {},
@@ -146,6 +132,7 @@ Tile.defaultProps = {
canEdit: false,
badges: [],
editTitle: "Edit",
+ columns: "1fr",
};
export default Tile;
diff --git a/src/components/FilterBar.js b/src/components/tile/TileActionBar.js
similarity index 66%
rename from src/components/FilterBar.js
rename to src/components/tile/TileActionBar.js
index cb6aa1d..5865a61 100644
--- a/src/components/FilterBar.js
+++ b/src/components/tile/TileActionBar.js
@@ -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 (
onGroupSelect()}
>
-
+ onFilterChange(e.target.value)} />
+ onGroupSelect()}
+ >
+
+
+ {children}
+
+
+ >
+ );
+}
+
+export default TilesContainer;
diff --git a/src/components/tile/TilesOverlay.js b/src/components/tile/TilesOverlay.js
new file mode 100644
index 0000000..9d024b0
--- /dev/null
+++ b/src/components/tile/TilesOverlay.js
@@ -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 (
+ <>
+
+
+ openGroupId && onGroupClose()}
+ >
+ e.stopPropagation()}
+ >
+
+
+ {group?.name}
+
+ setIsGroupNameModalOpen(true)}
+ >
+
+
+
+ onGroupSelect()}
+ >
+
+
+
+ {children}
+
+
+ onGroupClose()}
+ sx={{ position: "absolute", top: 0, right: 0 }}
+ />
+
+
+
+ setIsGroupNameModalOpen(false)}
+ />
+ >
+ );
+}
+
+export default TilesOverlay;
diff --git a/src/components/token/ListToken.js b/src/components/token/ListToken.js
deleted file mode 100644
index 9a094e3..0000000
--- a/src/components/token/ListToken.js
+++ /dev/null
@@ -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 (
-
-
-
- );
-}
-
-export default ListToken;
diff --git a/src/components/token/ProxyToken.js b/src/components/token/ProxyToken.js
deleted file mode 100644
index 989e8fe..0000000
--- a/src/components/token/ProxyToken.js
+++ /dev/null
@@ -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(
-
-
-
-
- ,
- proxyContainer
- );
-}
-
-ProxyToken.defaultProps = {
- tokens: {},
-};
-
-export default ProxyToken;
diff --git a/src/components/token/SelectTokensButton.js b/src/components/token/SelectTokensButton.js
index 2792b02..398aef7 100644
--- a/src/components/token/SelectTokensButton.js
+++ b/src/components/token/SelectTokensButton.js
@@ -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}
/>
>
);
diff --git a/src/components/token/TokenBar.js b/src/components/token/TokenBar.js
new file mode 100644
index 0000000..e01013e
--- /dev/null
+++ b/src/components/token/TokenBar.js
@@ -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 (
+
+
+
+ );
+ } else {
+ return ;
+ }
+ }
+ } 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 (
+
+ );
+ }
+ }
+ }
+
+ return (
+
+
+
+
+ {tokenGroups.map((group) => renderToken(group))}
+
+
+
+
+
+ {createPortal(
+
+ {dragId && renderToken(findGroup(tokenGroups, dragId), false)}
+ ,
+ document.body
+ )}
+
+
+ );
+}
+
+export default TokenBar;
diff --git a/src/components/token/TokenBarToken.js b/src/components/token/TokenBarToken.js
new file mode 100644
index 0000000..cd89691
--- /dev/null
+++ b/src/components/token/TokenBarToken.js
@@ -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 (
+
+ {inView && (
+
+ )}
+
+ );
+}
+
+export default TokenBarToken;
diff --git a/src/components/token/TokenBarTokenGroup.js b/src/components/token/TokenBarTokenGroup.js
new file mode 100644
index 0000000..7b36d60
--- /dev/null
+++ b/src/components/token/TokenBarTokenGroup.js
@@ -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 (
+
+
+
+ );
+ } else {
+ return ;
+ }
+ }
+
+ function renderTokens() {
+ if (isOpen) {
+ return (
+
+ handleOpenClick(e, false)}
+ key="group"
+ title={group.name}
+ {...listeners}
+ {...attributes}
+ >
+
+
+ {tokens.map(renderToken)}
+
+ );
+ } else {
+ return (
+
+ {tokens.slice(0, 4).map((token) => (
+
+ ))}
+
+ );
+ }
+ }
+
+ // 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 (
+
+ !isOpen && handleOpenClick(e, true)}
+ >
+ {renderTokens()}
+
+
+ );
+}
+
+export default TokenBarTokenGroup;
diff --git a/src/components/token/TokenDragOverlay.js b/src/components/token/TokenDragOverlay.js
index d5d9348..f484a0c 100644
--- a/src/components/token/TokenDragOverlay.js
+++ b/src/components/token/TokenDragOverlay.js
@@ -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();
diff --git a/src/components/token/TokenEditBar.js b/src/components/token/TokenEditBar.js
new file mode 100644
index 0000000..273d733
--- /dev/null
+++ b/src/components/token/TokenEditBar.js
@@ -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 (
+
+ onGroupSelect()}
+ />
+
+ handleTokensHide(allTokensVisible)}
+ >
+ {allTokensVisible ? : }
+
+ setIsTokensRemoveModalOpen(true)}
+ >
+
+
+
+ setIsTokensRemoveModalOpen(false)}
+ onConfirm={handleTokensRemove}
+ confirmText="Remove"
+ label="Remove Selected Token(s)"
+ description="This operation cannot be undone."
+ />
+
+ );
+}
+
+export default TokenEditBar;
diff --git a/src/components/token/TokenHiddenBadge.js b/src/components/token/TokenHiddenBadge.js
new file mode 100644
index 0000000..a7d633f
--- /dev/null
+++ b/src/components/token/TokenHiddenBadge.js
@@ -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 (
+
+ {hidden ? : }
+
+ );
+}
+
+export default TokenHiddenBadge;
diff --git a/src/components/token/TokenImage.js b/src/components/token/TokenImage.js
new file mode 100644
index 0000000..44c2a77
--- /dev/null
+++ b/src/components/token/TokenImage.js
@@ -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 && (
+
+
+
+ )}
+ setShowOutline(false)}
+ src={tokenURL}
+ ref={ref}
+ style={showOutline ? { display: "none" } : props.style}
+ {...props}
+ />
+ >
+ );
+});
+
+export default TokenImage;
diff --git a/src/components/token/TokenLabel.js b/src/components/token/TokenLabel.js
index e11918e..b178149 100644
--- a/src/components/token/TokenLabel.js
+++ b/src/components/token/TokenLabel.js
@@ -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 (
-
+
- {}}
+ cornerRadius={(defaultFontSize * fontScale + paddingY) / 2}
/>
+
+ {}}
+ />
+
{/* Use an invisible text block to work out text sizing */}
+
+
+ );
+ } else if (outline.type === "circle") {
+ return (
+
+ );
+ } else {
+ let points = [];
+ for (let i = 0; i < outline.points.length; i += 2) {
+ points.push(`${outline.points[i]}, ${outline.points[i + 1]}`);
+ }
+ return (
+
+ );
+ }
+}
+
+function TokenOutline({ outline, hidden }) {
+ const sharedProps = {
+ fill: colors.black,
+ opacity: hidden ? 0 : 0.8,
+ };
+ if (outline.type === "rect") {
+ return (
+
+ );
+ } else if (outline.type === "circle") {
+ return (
+
+ );
+ } else {
+ return (
+
+ );
+ }
+}
+
+export default TokenOutline;
diff --git a/src/components/token/TokenPreview.js b/src/components/token/TokenPreview.js
index cb23670..4ceab47 100644
--- a/src/components/token/TokenPreview.js
+++ b/src/components/token/TokenPreview.js
@@ -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);
diff --git a/src/components/token/TokenSettings.js b/src/components/token/TokenSettings.js
index 2d5147d..47e20df 100644
--- a/src/components/token/TokenSettings.js
+++ b/src/components/token/TokenSettings.js
@@ -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}
/>
-
+
-
-
-
-
- onSettingsChange("defaultSize", parseFloat(e.target.value))
- }
- disabled={tokenEmpty || token.type === "default"}
- min={1}
- my={1}
- />
-
-
+
+
+
+ onSettingsChange("defaultSize", parseFloat(e.target.value))
+ }
+ disabled={tokenEmpty}
+ min={1}
+ my={1}
+ />
+
+
+
+ onSettingsChange("defaultLabel", e.target.value)}
+ disabled={tokenEmpty}
+ my={1}
+ />
+
);
}
diff --git a/src/components/token/TokenTile.js b/src/components/token/TokenTile.js
index 29be8b2..1146b24 100644
--- a/src/components/token/TokenTile.js
+++ b/src/components/token/TokenTile.js
@@ -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 (
onTokenSelect(token)}
+ onSelect={() => onSelect(token.id)}
onEdit={() => onTokenEdit(token.id)}
- size={size}
canEdit={canEdit}
badges={badges}
editTitle="Edit Token"
- />
+ >
+
+
);
}
diff --git a/src/components/token/TokenTileGroup.js b/src/components/token/TokenTileGroup.js
new file mode 100644
index 0000000..7390d43
--- /dev/null
+++ b/src/components/token/TokenTileGroup.js
@@ -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 (
+ onSelect(group.id)}
+ onDoubleClick={onDoubleClick}
+ >
+
+ {tokens
+ .slice(0, layout.groupGridColumns * layout.groupGridColumns)
+ .map((token) => (
+
+ ))}
+
+
+ );
+}
+
+export default TokenTileGroup;
diff --git a/src/components/token/TokenTiles.js b/src/components/token/TokenTiles.js
index 73d69ab..55b5948 100644
--- a/src/components/token/TokenTiles.js
+++ b/src/components/token/TokenTiles.js
@@ -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 (
-
- );
- }
+ selectedGroupIds.length === 1;
- const multipleSelected = selectedTokens.length > 1;
-
- let hideTitle = "";
- if (multipleSelected) {
- if (allTokensVisible) {
- hideTitle = "Hide Tokens in Sidebar";
+ return (
+ ,
+ ]}
+ />
+ );
+ }
} 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 (
+ tokensById[item.id])}
+ isSelected={isSelected}
+ onSelect={onGroupSelect}
+ onDoubleClick={() => canOpen && onGroupOpen(group.id)}
+ />
+ );
}
}
return (
-
- onTokenSelect()}
- search={search}
- onSearchChange={onSearchChange}
- selectMode={selectMode}
- onSelectModeChange={onSelectModeChange}
- onAdd={onTokenAdd}
- addTitle="Add Token"
- />
-
- onTokenSelect()}
- >
- {groups.map((group) => (
-
-
- {tokens[group].map(tokenToTile)}
-
- ))}
-
-
- {databaseStatus === "disabled" && (
-
-
- Token saving is unavailable. See FAQ{" "}
- for more information.
-
-
- )}
- {selectedTokens.length > 0 && (
-
- onTokenSelect()}
- />
-
- onTokensHide(allTokensVisible)}
- >
- {allTokensVisible ? : }
-
- onTokensGroup()}
- disabled={hasSelectedDefaultToken}
- >
-
-
- onTokensRemove()}
- disabled={hasSelectedDefaultToken}
- >
-
-
-
-
- )}
-
+ <>
+
+
+ >
);
}
diff --git a/src/components/token/Tokens.js b/src/components/token/Tokens.js
deleted file mode 100644
index c56feec..0000000
--- a/src/components/token/Tokens.js
+++ /dev/null
@@ -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 (
- <>
-
-
- {ownedTokens
- .filter((token) => !token.hideInSidebar)
- .map((token) => (
-
- ))}
-
-
-
-
-
- [token.id, token]))}
- />
- >
- );
-}
-
-export default Tokens;
diff --git a/src/contexts/AssetsContext.js b/src/contexts/AssetsContext.js
new file mode 100644
index 0000000..c1828a5
--- /dev/null
+++ b/src/contexts/AssetsContext.js
@@ -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}
+ */
+
+/**
+ * @callback addAssets
+ * @param {Asset[]} assets
+ */
+
+/**
+ * @callback putAsset
+ * @param {Asset} asset
+ */
+
+/**
+ * @typedef AssetsContext
+ * @property {getAsset} getAsset
+ * @property {addAssets} addAssets
+ * @property {putAsset} putAsset
+ */
+
+/**
+ * @type {React.Context}
+ */
+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 (
+ {children}
+ );
+}
+
+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>
+ */
+export const AssetURLsStateContext = React.createContext();
+
+/**
+ * @type React.Context>>
+ */
+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 (
+
+
+ {children}
+
+
+ );
+}
+
+/**
+ * Helper function to load either file or default asset into a URL
+ * @param {string} assetId
+ * @param {"file"|"default"} type
+ * @param {Object.} 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.=} 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.} 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;
diff --git a/src/contexts/AuthContext.js b/src/contexts/AuthContext.js
index 868fe7e..cd0b4e9 100644
--- a/src/contexts/AuthContext.js
+++ b/src/contexts/AuthContext.js
@@ -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,
};
diff --git a/src/contexts/DatabaseContext.js b/src/contexts/DatabaseContext.js
index 5e1e3b5..64714ee 100644
--- a/src/contexts/DatabaseContext.js
+++ b/src/contexts/DatabaseContext.js
@@ -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}
+ */
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);
diff --git a/src/contexts/DragContext.js b/src/contexts/DragContext.js
new file mode 100644
index 0000000..2614b0f
--- /dev/null
+++ b/src/contexts/DragContext.js
@@ -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 (
+
+
+ {children}
+
+
+ );
+}
+
+export default DragContext;
diff --git a/src/contexts/GroupContext.js b/src/contexts/GroupContext.js
new file mode 100644
index 0000000..fa123d9
--- /dev/null
+++ b/src/contexts/GroupContext.js
@@ -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 (
+ {children}
+ );
+}
+
+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;
diff --git a/src/contexts/ImageSourceContext.js b/src/contexts/ImageSourceContext.js
deleted file mode 100644
index b558383..0000000
--- a/src/contexts/ImageSourceContext.js
+++ /dev/null
@@ -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 (
-
-
- {children}
-
-
- );
-}
-
-/**
- * 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;
-}
diff --git a/src/contexts/MapDataContext.js b/src/contexts/MapDataContext.js
index ddd8dce..ebf95bc 100644
--- a/src/contexts/MapDataContext.js
+++ b/src/contexts/MapDataContext.js
@@ -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 (
{children}
diff --git a/src/contexts/MapLoadingContext.js b/src/contexts/MapLoadingContext.js
index 94cb5b6..3c12990 100644
--- a/src/contexts/MapLoadingContext.js
+++ b/src/contexts/MapLoadingContext.js
@@ -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,
diff --git a/src/contexts/PlayerContext.js b/src/contexts/PlayerContext.js
index 26cd95c..ca90483 100644
--- a/src/contexts/PlayerContext.js
+++ b/src/contexts/PlayerContext.js
@@ -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")
diff --git a/src/contexts/TileDragContext.js b/src/contexts/TileDragContext.js
new file mode 100644
index 0000000..3663f5a
--- /dev/null
+++ b/src/contexts/TileDragContext.js
@@ -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 (
+
+
+
+
+ {children}
+
+
+
+
+ );
+}
+
+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;
+}
diff --git a/src/contexts/TokenDataContext.js b/src/contexts/TokenDataContext.js
index 329a509..b4bd33c 100644
--- a/src/contexts/TokenDataContext.js
+++ b/src/contexts/TokenDataContext.js
@@ -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 (
diff --git a/src/contexts/UserIdContext.js b/src/contexts/UserIdContext.js
new file mode 100644
index 0000000..72d3bb8
--- /dev/null
+++ b/src/contexts/UserIdContext.js
@@ -0,0 +1,36 @@
+import React, { useEffect, useState, useContext } from "react";
+
+import { useDatabase } from "./DatabaseContext";
+/**
+ * @type {React.Context}
+ */
+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 (
+ {children}
+ );
+}
+
+export function useUserId() {
+ return useContext(UserIdContext);
+}
+
+export default UserIdContext;
diff --git a/src/database.js b/src/database.js
index 5ed9ed2..a5251f1 100644
--- a/src/database.js
+++ b/src/database.js
@@ -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.}
- */
-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;
}
diff --git a/src/docs/assets/GroupAndRemovingTokens.mp4 b/src/docs/assets/GroupAndRemovingTokens.mp4
index c5c7f23..277fc41 100644
Binary files a/src/docs/assets/GroupAndRemovingTokens.mp4 and b/src/docs/assets/GroupAndRemovingTokens.mp4 differ
diff --git a/src/docs/assets/ResetAndRemovingMaps.mp4 b/src/docs/assets/ResetAndRemovingMaps.mp4
index 70b933b..8a4414d 100644
Binary files a/src/docs/assets/ResetAndRemovingMaps.mp4 and b/src/docs/assets/ResetAndRemovingMaps.mp4 differ
diff --git a/src/docs/assets/Settings.jpg b/src/docs/assets/Settings.jpg
index 016ce61..95d8e03 100644
Binary files a/src/docs/assets/Settings.jpg and b/src/docs/assets/Settings.jpg differ
diff --git a/src/docs/howTo/overview.md b/src/docs/howTo/overview.md
index 1d1f5cb..15613c3 100644
--- a/src/docs/howTo/overview.md
+++ b/src/docs/howTo/overview.md
@@ -1 +1 @@
-[embed:](https://www.youtube.com/embed/KLUsOZA-SHI)
+![embed:](https://www.youtube.com/embed/KLUsOZA-SHI)
diff --git a/src/docs/howTo/settings.md b/src/docs/howTo/settings.md
index e32094b..02f11d1 100644
--- a/src/docs/howTo/settings.md
+++ b/src/docs/howTo/settings.md
@@ -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.
diff --git a/src/docs/howTo/sharingMaps.md b/src/docs/howTo/sharingMaps.md
index a693886..79341c0 100644
--- a/src/docs/howTo/sharingMaps.md
+++ b/src/docs/howTo/sharingMaps.md
@@ -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
diff --git a/src/docs/howTo/usingDice.md b/src/docs/howTo/usingDice.md
index 4260b92..5079516 100644
--- a/src/docs/howTo/usingDice.md
+++ b/src/docs/howTo/usingDice.md
@@ -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.
diff --git a/src/docs/howTo/usingDrawing.md b/src/docs/howTo/usingDrawing.md
index 6e25daf..8cd9db6 100644
--- a/src/docs/howTo/usingDrawing.md
+++ b/src/docs/howTo/usingDrawing.md
@@ -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.
diff --git a/src/docs/howTo/usingFog.md b/src/docs/howTo/usingFog.md
index f9bd7f1..cc60364 100644
--- a/src/docs/howTo/usingFog.md
+++ b/src/docs/howTo/usingFog.md
@@ -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.
diff --git a/src/docs/howTo/usingMeasure.md b/src/docs/howTo/usingMeasure.md
index 74de911..b14ba34 100644
--- a/src/docs/howTo/usingMeasure.md
+++ b/src/docs/howTo/usingMeasure.md
@@ -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)
diff --git a/src/docs/howTo/usingTokens.md b/src/docs/howTo/usingTokens.md
index a80eefd..b37ace6 100644
--- a/src/docs/howTo/usingTokens.md
+++ b/src/docs/howTo/usingTokens.md
@@ -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`
diff --git a/src/docs/releaseNotes/v1.1.0.md b/src/docs/releaseNotes/v1.1.0.md
index 65827c5..24d2761 100644
--- a/src/docs/releaseNotes/v1.1.0.md
+++ b/src/docs/releaseNotes/v1.1.0.md
@@ -1,4 +1,4 @@
-[embed:](https://www.youtube.com/embed/aOTvQOrpNo4)
+![embed:](https://www.youtube.com/embed/aOTvQOrpNo4)
## Major Changes
diff --git a/src/docs/releaseNotes/v1.2.0.md b/src/docs/releaseNotes/v1.2.0.md
index d15dea3..7ffd49e 100644
--- a/src/docs/releaseNotes/v1.2.0.md
+++ b/src/docs/releaseNotes/v1.2.0.md
@@ -1,4 +1,4 @@
-[embed:](https://www.youtube.com/embed/IhSS24d4zlM)
+![embed:](https://www.youtube.com/embed/IhSS24d4zlM)
## Major Changes
diff --git a/src/docs/releaseNotes/v1.3.0.md b/src/docs/releaseNotes/v1.3.0.md
index f6d0c44..8b37690 100644
--- a/src/docs/releaseNotes/v1.3.0.md
+++ b/src/docs/releaseNotes/v1.3.0.md
@@ -1,4 +1,4 @@
-[embed:](https://www.youtube.com/embed/Y7sEgoopz4E)
+![embed:](https://www.youtube.com/embed/Y7sEgoopz4E)
## Major Changes
diff --git a/src/docs/releaseNotes/v1.4.0.md b/src/docs/releaseNotes/v1.4.0.md
index d3a8960..c17daf8 100644
--- a/src/docs/releaseNotes/v1.4.0.md
+++ b/src/docs/releaseNotes/v1.4.0.md
@@ -1,4 +1,4 @@
-[embed:](https://www.youtube.com/embed/vtNpj-449B8)
+![embed:](https://www.youtube.com/embed/vtNpj-449B8)
## Major Changes
diff --git a/src/docs/releaseNotes/v1.5.0.md b/src/docs/releaseNotes/v1.5.0.md
index 3909220..3fac2aa 100644
--- a/src/docs/releaseNotes/v1.5.0.md
+++ b/src/docs/releaseNotes/v1.5.0.md
@@ -1,4 +1,4 @@
-[embed:](https://www.youtube.com/embed/i4JvZboAPhQ)
+![embed:](https://www.youtube.com/embed/i4JvZboAPhQ)
## Major Changes
diff --git a/src/docs/releaseNotes/v1.6.0.md b/src/docs/releaseNotes/v1.6.0.md
index e7d1d32..b068f12 100644
--- a/src/docs/releaseNotes/v1.6.0.md
+++ b/src/docs/releaseNotes/v1.6.0.md
@@ -1,4 +1,4 @@
-[embed:](https://www.youtube.com/embed/TIhCAJoTeAU)
+![embed:](https://www.youtube.com/embed/TIhCAJoTeAU)
## Major Changes
diff --git a/src/docs/releaseNotes/v1.7.0.md b/src/docs/releaseNotes/v1.7.0.md
index 6b89316..09a0726 100644
--- a/src/docs/releaseNotes/v1.7.0.md
+++ b/src/docs/releaseNotes/v1.7.0.md
@@ -1,4 +1,4 @@
-[embed:](https://www.youtube.com/embed/MWbfbN3Brhw)
+![embed:](https://www.youtube.com/embed/MWbfbN3Brhw)
## Major Changes
diff --git a/src/docs/releaseNotes/v1.8.0.md b/src/docs/releaseNotes/v1.8.0.md
index f4f6900..35eb589 100644
--- a/src/docs/releaseNotes/v1.8.0.md
+++ b/src/docs/releaseNotes/v1.8.0.md
@@ -1,4 +1,4 @@
-[embed:](https://www.youtube.com/embed/MBy0VLsesL0)
+![embed:](https://www.youtube.com/embed/MBy0VLsesL0)
## Major Changes
diff --git a/src/docs/releaseNotes/v1.9.0.md b/src/docs/releaseNotes/v1.9.0.md
new file mode 100644
index 0000000..a3232e2
--- /dev/null
+++ b/src/docs/releaseNotes/v1.9.0.md
@@ -0,0 +1,55 @@
+![embed:](https://www.youtube.com/embed/hFUo8LzHI6s)
+
+## Major Changes
+
+This release focuses on improving the workflow for managing assets as well as increasing performance of fog and tokens. This release also brings a lot of under the hood changes to how data is structured, stored and saved.
+
+### Drag and Drop Asset Management
+
+All assets (tokens and maps) now offer a lot more flexibility for organisation with a new drag and drop interface for rearrangement, grouping and adding to a game.
+
+To access these features with mouse and keyboard simply drag any asset you have selected or with touch devices tap and hold to pickup the assets. You can then drag to the edge of another asset to re-order the element, drag over another asset to create a new group or drag out of the select screen to add that asset to the game.
+
+With this feature we now also support the ability to drag and drop images from your computer directly into a game without the need to first add the asset into the appropriate asset import screen. To use this you can simply drag an image file into a game. Two new import boxes will then be shown where you can drag the images into the top box to import them as a map or the bottom box to import them as a token. When importing a single map this way the game will automatically switch to show this map in the shared scene. When importing tokens this way they will automatically be placed onto a shared map if one is shown.
+
+### Improved Group Support
+
+Grouped assets now have a more structured interface with a separate group view as well as full support for replicating token groups in the token sidebar.
+
+Once a group has been created you can open it by double clicking the group in the select screen or by single clicking in the token sidebar.
+
+Groups also support the new drag and drop interface which also includes dragging a token group into your game to add all tokens to a map at once. This is also supported with groups in the token sidebar.
+
+### Token Previews
+
+The generic token missing or loading image has been replaced by per-token loading previews.
+These manifest as a transparent black shape that represents a simplified version of the token that is loading.
+
+This helps when using large tokens such as an initiative tracker which will no longer take over the screen as it loads.
+This also helps greatly to optimise our token rendering. Specifically these simplified shapes are now used for our collision detection for detecting whether you have selected a token. Moving to this method means we no longer need to render a token multiple times to work out whether you are interacting with it. In our tests token rendering is now up to 70% faster when using over 100 tokens on screen. This change should also help fix token selection issues in browsers like Brave that modifier Canvas data.
+
+A downside to this method however is token selection will now be less accurate for transparent tokens with holes such as spell effects. We plan to keep an eye on this however to see whether this has adverse effects on real world usage.
+
+### New Rendering Technique for Fog Editing
+
+While in edit mode we now render fog much more efficiently. In testing we have seen just shy of a 70% decrease in rendering time when using 50+ fog shapes. While this will help with the usability of the tool on more complex maps, this should also fix issues with browsers like Firefox and Safari which would stop updating token movement if fog rendering was taking too long.
+
+## Minor Changes
+
+- The progress loading bar will now pool consecutive assets together to avoid showing each asset separately.
+- All default maps and tokens are now fully customisable. This includes adjusting settings, import/export and even deleting.
+- Removed grid from default map images to allow changing grid size and type.
+- Modals have a new transition animation to match the new group UI.
+- Cursors now better represent drag and drop actions.
+- Tokens in the select token screen now show an indicator for whether they are hidden in the sidebar.
+- Added a new default label setting to tokens.
+- Fixed a bug with the fog brush tool not working properly on maps with smaller grid sizes.
+- Added better file type handling for image import screens with more informative error notifications.
+- Fixed a bug with vehicle tokens not picking up tokens hidden outside of view.
+- Added a notification for when using the cut fog tool on places with no fog.
+- Added the ability for the notes text input area to automatically resize to show more lines of text.
+- Updated the light theme to be more readable in some cases.
+- Added the ability to specify token sizes in input files by using width x height syntax.
+- Updated measure tool to snap to grid cell centers to prevent issues with measuring diagonals.
+- Updated measure tool euclidean option to use pixel coordinates to be more precise.
+- Fixed a bug with note and label kerning on Firefox for Windows.
diff --git a/src/helpers/KonvaBridge.js b/src/helpers/KonvaBridge.js
index b8fb3db..c71e9a4 100644
--- a/src/helpers/KonvaBridge.js
+++ b/src/helpers/KonvaBridge.js
@@ -19,14 +19,14 @@ import {
useDebouncedStageScale,
} from "../contexts/MapInteractionContext";
import { MapStageProvider, useMapStage } from "../contexts/MapStageContext";
-import AuthContext, { useAuth } from "../contexts/AuthContext";
+import UserIdContext, { useUserId } from "../contexts/UserIdContext";
import SettingsContext, { useSettings } from "../contexts/SettingsContext";
import KeyboardContext from "../contexts/KeyboardContext";
-import TokenDataContext, { useTokenData } from "../contexts/TokenDataContext";
-import {
- ImageSourcesStateContext,
- ImageSourcesUpdaterContext,
-} from "../contexts/ImageSourceContext";
+import AssetsContext, {
+ AssetURLsStateContext,
+ AssetURLsUpdaterContext,
+ useAssets,
+} from "../contexts/AssetsContext";
import {
useGrid,
useGridCellPixelSize,
@@ -43,17 +43,18 @@ import {
GridStrokeWidthContext,
GridCellPixelOffsetContext,
} from "../contexts/GridContext";
+import DatabaseContext, { useDatabase } from "../contexts/DatabaseContext";
/**
* Provide a bridge for konva that forwards our contexts
*/
function KonvaBridge({ stageRender, children }) {
const mapStageRef = useMapStage();
- const auth = useAuth();
+ const userId = useUserId();
const settings = useSettings();
- const tokenData = useTokenData();
- const imageSources = useContext(ImageSourcesStateContext);
- const setImageSources = useContext(ImageSourcesUpdaterContext);
+ const assets = useAssets();
+ const assetURLs = useContext(AssetURLsStateContext);
+ const setAssetURLs = useContext(AssetURLsUpdaterContext);
const keyboardValue = useContext(KeyboardContext);
const stageScale = useStageScale();
@@ -73,70 +74,74 @@ function KonvaBridge({ stageRender, children }) {
const gridCellPixelOffset = useGridCellPixelOffset();
const gridOffset = useGridOffset();
+ const database = useDatabase();
+
return stageRender(
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
- {children}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+ {children}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/src/helpers/Vector2.js b/src/helpers/Vector2.js
index ee3ccf3..b6da555 100644
--- a/src/helpers/Vector2.js
+++ b/src/helpers/Vector2.js
@@ -508,6 +508,66 @@ class Vector2 {
return { x: -p.y, y: p.x };
}
}
+
+ /**
+ * Returns the centroid of the given points
+ * @param {Vector2[]} points
+ * @returns {Vector2}
+ */
+ static centroid(points) {
+ let center = { x: 0, y: 0 };
+ for (let point of points) {
+ center.x += point.x;
+ center.y += point.y;
+ }
+ if (points.length > 0) {
+ center = { x: center.x / points.length, y: center.y / points.length };
+ }
+ return center;
+ }
+
+ /**
+ * Determine whether given points are rectangular
+ * @param {Vector2[]} points
+ * @returns {boolean}
+ */
+ static rectangular(points) {
+ if (points.length !== 4) {
+ return false;
+ }
+ // Check whether distance to the center is the same for all four points
+ const centroid = this.centroid(points);
+ let prevDist;
+ for (let point of points) {
+ const dist = this.distance(point, centroid);
+ if (prevDist && dist !== prevDist) {
+ return false;
+ } else {
+ prevDist = dist;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Determine whether given points are circular
+ * @param {Vector2[]} points
+ * @returns {boolean}
+ */
+ static circular(points, threshold = 0.1) {
+ const centroid = this.centroid(points);
+ let distances = [];
+ for (let point of points) {
+ distances.push(this.distance(point, centroid));
+ }
+ if (distances.length > 0) {
+ const maxDistance = Math.max(...distances);
+ const minDistance = Math.min(...distances);
+ return maxDistance - minDistance < threshold;
+ } else {
+ return false;
+ }
+ }
}
export default Vector2;
diff --git a/src/helpers/actions.js b/src/helpers/actions.js
index 44c51b7..90d882b 100644
--- a/src/helpers/actions.js
+++ b/src/helpers/actions.js
@@ -42,3 +42,11 @@ export function addPolygonIntersectionToShapes(shape, intersection, shapes) {
};
}
}
+
+export function shapeToGeometry(shape) {
+ const shapePoints = shape.data.points.map(({ x, y }) => [x, y]);
+ const shapeHoles = shape.data.holes.map((hole) =>
+ hole.map(({ x, y }) => [x, y])
+ );
+ return [[shapePoints, ...shapeHoles]];
+}
diff --git a/src/helpers/dexie.js b/src/helpers/dexie.js
new file mode 100644
index 0000000..9c82927
--- /dev/null
+++ b/src/helpers/dexie.js
@@ -0,0 +1,48 @@
+import set from "lodash.set";
+import unset from "lodash.unset";
+import cloneDeep from "lodash.clonedeep";
+
+/**
+ * Remove all empty values from an object recursively
+ * @param {Object} obj
+ */
+function trimArraysInObject(obj) {
+ for (let key in obj) {
+ const value = obj[key];
+ if (Array.isArray(value)) {
+ let arr = [];
+ for (let i = 0; i < value.length; i++) {
+ const el = value[i];
+ if (typeof el === "object") {
+ arr.push(trimArraysInObject(el));
+ } else if (el !== undefined) {
+ arr.push(el);
+ }
+ }
+ obj[key] = arr;
+ } else if (typeof obj[key] === "object") {
+ obj[key] = trimArraysInObject(obj[key]);
+ }
+ }
+ return obj;
+}
+
+export function applyObservableChange(change) {
+ // Custom application of dexie change to fix issue with array indices being wrong
+ // https://github.com/dfahlander/Dexie.js/issues/1176
+ // TODO: Fix dexie observable source
+ let obj = cloneDeep(change.oldObj);
+ const changes = Object.entries(change.mods).reverse();
+ for (let [key, value] of changes) {
+ if (value === null) {
+ unset(obj, key);
+ } else {
+ obj = set(obj, key, value);
+ }
+ }
+
+ // Trim empty values from calling unset on arrays
+ obj = trimArraysInObject(obj);
+
+ return obj;
+}
diff --git a/src/helpers/drawing.js b/src/helpers/drawing.js
index 4491d61..00966a3 100644
--- a/src/helpers/drawing.js
+++ b/src/helpers/drawing.js
@@ -192,18 +192,13 @@ export function getUpdatedShapeData(
}
}
-const defaultSimplifySize = 1 / 100;
/**
* Simplify points to a grid size
* @param {Vector2[]} points
- * @param {Vector2} gridCellSize
- * @param {number} scale
+ * @param {number} tolerance
*/
-export function simplifyPoints(points, gridCellSize, scale) {
- return simplify(
- points,
- (Vector2.min(gridCellSize) * defaultSimplifySize) / scale
- );
+export function simplifyPoints(points, tolerance) {
+ return simplify(points, tolerance);
}
/**
diff --git a/src/helpers/grid.js b/src/helpers/grid.js
index 2b3d642..57ecd2a 100644
--- a/src/helpers/grid.js
+++ b/src/helpers/grid.js
@@ -49,6 +49,9 @@ export function getGridPixelSize(grid, baseWidth, baseHeight) {
* @returns {Size}
*/
export function getCellPixelSize(grid, gridWidth, gridHeight) {
+ if (grid.size.x === 0 || grid.size.y === 0) {
+ return new Size(0, 0);
+ }
switch (grid.type) {
case "square":
return new Size(gridWidth / grid.size.x, gridHeight / grid.size.y);
@@ -209,7 +212,10 @@ export function getGridDefaultInset(grid, mapWidth, mapHeight) {
* @returns {GridInset}
*/
export function getGridUpdatedInset(grid, mapWidth, mapHeight) {
- let inset = grid.inset;
+ let inset = {
+ topLeft: { ...grid.inset.topLeft },
+ bottomRight: { ...grid.inset.bottomRight },
+ };
// Take current inset width and use it to calculate the new height
if (grid.size.x > 0 && grid.size.x > 0) {
// Convert to px relative to map size
@@ -283,10 +289,7 @@ export function gridDistance(grid, a, b, cellSize) {
const bCoord = getNearestCellCoordinates(grid, b.x, b.y, cellSize);
if (grid.type === "square") {
if (grid.measurement.type === "chebyshev") {
- return Math.max(
- Math.abs(aCoord.x - bCoord.x),
- Math.abs(aCoord.y - bCoord.y)
- );
+ return Vector2.max(Vector2.abs(Vector2.subtract(aCoord, bCoord)));
} else if (grid.measurement.type === "alternating") {
// Alternating diagonal distance like D&D 3.5 and Pathfinder
const delta = Vector2.abs(Vector2.subtract(aCoord, bCoord));
@@ -294,7 +297,7 @@ export function gridDistance(grid, a, b, cellSize) {
const min = Vector2.min(delta);
return max - min + Math.floor(1.5 * min);
} else if (grid.measurement.type === "euclidean") {
- return Vector2.distance(aCoord, bCoord);
+ return Vector2.length(Vector2.divide(Vector2.subtract(a, b), cellSize));
} else if (grid.measurement.type === "manhattan") {
return Math.abs(aCoord.x - bCoord.x) + Math.abs(aCoord.y - bCoord.y);
}
@@ -310,7 +313,7 @@ export function gridDistance(grid, a, b, cellSize) {
2
);
} else if (grid.measurement.type === "euclidean") {
- return Vector2.distance(aCoord, bCoord);
+ return Vector2.length(Vector2.divide(Vector2.subtract(a, b), cellSize));
}
}
}
diff --git a/src/helpers/group.js b/src/helpers/group.js
new file mode 100644
index 0000000..ba62aa4
--- /dev/null
+++ b/src/helpers/group.js
@@ -0,0 +1,266 @@
+import { v4 as uuid } from "uuid";
+import cloneDeep from "lodash.clonedeep";
+
+import { keyBy } from "./shared";
+
+/**
+ * @typedef GroupItem
+ * @property {string} id
+ * @property {"item"} type
+ */
+
+/**
+ * @typedef GroupContainer
+ * @property {string} id
+ * @property {"group"} type
+ * @property {GroupItem[]} items
+ * @property {string} name
+ */
+
+/**
+ * @typedef {GroupItem|GroupContainer} Group
+ */
+
+/**
+ * Transform an array of group ids to their groups
+ * @param {string[]} groupIds
+ * @param {Group[]} groups
+ * @return {Group[[]}
+ */
+export function groupsFromIds(groupIds, groups) {
+ const groupsByIds = keyBy(groups, "id");
+ const filteredGroups = [];
+ for (let groupId of groupIds) {
+ filteredGroups.push(groupsByIds[groupId]);
+ }
+ return filteredGroups;
+}
+
+/**
+ * Get all items from a group including all sub groups
+ * @param {Group} group
+ * @return {GroupItem[]}
+ */
+export function getGroupItems(group) {
+ if (group.type === "group") {
+ let groups = [];
+ for (let item of group.items) {
+ groups.push(...getGroupItems(item));
+ }
+ return groups;
+ } else {
+ return [group];
+ }
+}
+
+/**
+ * Transform an array of groups into their assosiated items
+ * @param {Group[]} groups
+ * @param {any[]} allItems
+ * @param {string} itemKey
+ * @returns {any[]}
+ */
+export function itemsFromGroups(groups, allItems, itemKey = "id") {
+ const allItemsById = keyBy(allItems, itemKey);
+ const groupedItems = [];
+
+ for (let group of groups) {
+ const groupItems = getGroupItems(group);
+ const items = groupItems.map((item) => allItemsById[item.id]);
+ groupedItems.push(...items);
+ }
+
+ return groupedItems;
+}
+
+/**
+ * Combine two groups
+ * @param {Group} a
+ * @param {Group} b
+ * @returns {GroupContainer}
+ */
+export function combineGroups(a, b) {
+ if (a.type === "item") {
+ return {
+ id: uuid(),
+ type: "group",
+ items: [a, b],
+ name: "",
+ };
+ }
+ if (a.type === "group") {
+ return {
+ id: a.id,
+ type: "group",
+ items: [...a.items, b],
+ name: a.name,
+ };
+ }
+}
+
+/**
+ * Immutably move group at indices `indices` into group at index `into`
+ * @param {Group[]} groups
+ * @param {number} into
+ * @param {number[]} indices
+ * @returns {Group[]}
+ */
+export function moveGroupsInto(groups, into, indices) {
+ const newGroups = cloneDeep(groups);
+
+ const intoGroup = newGroups[into];
+ let fromGroups = [];
+ for (let i of indices) {
+ fromGroups.push(newGroups[i]);
+ }
+
+ let combined = intoGroup;
+ for (let fromGroup of fromGroups) {
+ combined = combineGroups(combined, fromGroup);
+ }
+
+ // Replace and remove old groups
+ newGroups[into] = combined;
+ for (let fromGroup of fromGroups) {
+ const i = newGroups.findIndex((group) => group.id === fromGroup.id);
+ newGroups.splice(i, 1);
+ }
+
+ return newGroups;
+}
+
+/**
+ * Immutably move group at indices `indices` to index `to`
+ * @param {Group[]} groups
+ * @param {number} into
+ * @param {number[]} indices
+ * @returns {Group[]}
+ */
+export function moveGroups(groups, to, indices) {
+ const newGroups = cloneDeep(groups);
+
+ let fromGroups = [];
+ for (let i of indices) {
+ fromGroups.push(newGroups[i]);
+ }
+
+ // Remove old groups
+ for (let fromGroup of fromGroups) {
+ const i = newGroups.findIndex((group) => group.id === fromGroup.id);
+ newGroups.splice(i, 1);
+ }
+
+ // Add back at new index
+ newGroups.splice(to, 0, ...fromGroups);
+
+ return newGroups;
+}
+
+/**
+ * Move items from a sub group to the start of the base group
+ * @param {Group[]} groups
+ * @param {string} fromId The id of the group to move from
+ * @param {number[]} indices The indices of the items in the group
+ */
+export function ungroup(groups, fromId, indices) {
+ const newGroups = cloneDeep(groups);
+
+ let fromIndex = newGroups.findIndex((group) => group.id === fromId);
+
+ let items = [];
+ for (let i of indices) {
+ items.push(newGroups[fromIndex].items[i]);
+ }
+
+ // Remove items from previous group
+ for (let item of items) {
+ const i = newGroups[fromIndex].items.findIndex((el) => el.id === item.id);
+ newGroups[fromIndex].items.splice(i, 1);
+ }
+
+ // If we have no more items in the group delete it
+ if (newGroups[fromIndex].items.length === 0) {
+ newGroups.splice(fromIndex, 1);
+ }
+
+ // Add to base group
+ newGroups.splice(0, 0, ...items);
+
+ return newGroups;
+}
+
+/**
+ * Recursively find a group within a group array
+ * @param {Group[]} groups
+ * @param {string} groupId
+ * @returns {Group}
+ */
+export function findGroup(groups, groupId) {
+ for (let group of groups) {
+ if (group.id === groupId) {
+ return group;
+ }
+ const items = getGroupItems(group);
+ for (let item of items) {
+ if (item.id === groupId) {
+ return item;
+ }
+ }
+ }
+}
+
+/**
+ * Transform and item array to a record of item ids to item names
+ * @param {any[]} items
+ * @param {string=} itemKey
+ */
+export function getItemNames(items, itemKey = "id") {
+ let names = {};
+ for (let item of items) {
+ names[item[itemKey]] = item.name;
+ }
+ return names;
+}
+
+/**
+ * Immutably rename a group
+ * @param {Group[]} groups
+ * @param {string} groupId
+ * @param {string} newName
+ */
+export function renameGroup(groups, groupId, newName) {
+ let newGroups = cloneDeep(groups);
+ const groupIndex = newGroups.findIndex((group) => group.id === groupId);
+ if (groupIndex >= 0) {
+ newGroups[groupIndex].name = newName;
+ }
+ return newGroups;
+}
+
+/**
+ * Remove items from groups including sub groups
+ * @param {Group[]} groups
+ * @param {string[]} itemIds
+ */
+export function removeGroupsItems(groups, itemIds) {
+ let newGroups = cloneDeep(groups);
+
+ for (let i = newGroups.length - 1; i >= 0; i--) {
+ const group = newGroups[i];
+ if (group.type === "item") {
+ if (itemIds.includes(group.id)) {
+ newGroups.splice(i, 1);
+ }
+ } else {
+ const items = group.items;
+ for (let j = items.length - 1; j >= 0; j--) {
+ const item = items[j];
+ if (itemIds.includes(item.id)) {
+ newGroups[i].items.splice(j, 1);
+ }
+ }
+ }
+ }
+
+ return newGroups;
+}
diff --git a/src/helpers/image.js b/src/helpers/image.js
index fbcf7a8..2223741 100644
--- a/src/helpers/image.js
+++ b/src/helpers/image.js
@@ -1,4 +1,7 @@
+import imageOutline from "image-outline";
+
import blobToBuffer from "./blobToBuffer";
+import Vector2 from "./Vector2";
const lightnessDetectionOffset = 0.1;
@@ -88,12 +91,11 @@ export async function resizeImage(image, size, type, quality) {
}
/**
- * @typedef ImageFile
- * @property {Uint8Array|null} file
+ * @typedef ImageAsset
* @property {number} width
* @property {number} height
- * @property {"file"} type
- * @property {string} id
+ * @property {Uint8Array} file
+ * @property {string} mime
*/
/**
@@ -102,7 +104,7 @@ export async function resizeImage(image, size, type, quality) {
* @param {string} type the mime type of the image
* @param {number} size the width and height of the thumbnail
* @param {number} quality if image is a jpeg or webp this is the quality setting
- * @returns {Promise}
+ * @returns {Promise}
*/
export async function createThumbnail(image, type, size = 300, quality = 0.5) {
let canvas = document.createElement("canvas");
@@ -150,7 +152,101 @@ export async function createThumbnail(image, type, size = 300, quality = 0.5) {
file: thumbnailBuffer,
width: thumbnailImage.width,
height: thumbnailImage.height,
- type: "file",
- id: "thumbnail",
+ mime: type,
};
}
+
+/**
+ * @typedef CircleOutline
+ * @property {"circle"} type
+ * @property {number} x - Center X of the circle
+ * @property {number} y - Center Y of the circle
+ * @property {number} radius
+ */
+
+/**
+ * @typedef RectOutline
+ * @property {"rect"} type
+ * @property {number} width
+ * @property {number} height
+ * @property {number} x - Leftmost X position of the rect
+ * @property {number} y - Topmost Y position of the rect
+ */
+
+/**
+ * @typedef PathOutline
+ * @property {"path"} type
+ * @property {number[]} points - Alternating x, y coordinates zipped together
+ */
+
+/**
+ * @typedef {CircleOutline|RectOutline|PathOutline} Outline
+ */
+
+/**
+ * Get the outline of an image
+ * @param {HTMLImageElement} image
+ * @returns {Outline}
+ */
+export function getImageOutline(image, maxPoints = 100) {
+ // Basic rect outline for fail conditions
+ const defaultOutline = {
+ type: "rect",
+ x: 0,
+ y: 0,
+ width: image.width,
+ height: image.height,
+ };
+ try {
+ let outlinePoints = imageOutline(image, {
+ opacityThreshold: 1, // Allow everything except full transparency
+ });
+
+ if (outlinePoints) {
+ if (outlinePoints.length > maxPoints) {
+ outlinePoints = Vector2.resample(outlinePoints, maxPoints);
+ }
+ const bounds = Vector2.getBoundingBox(outlinePoints);
+
+ // Reject outline if it's area is less than 5% of the image
+ const imageArea = image.width * image.height;
+ const area = bounds.width * bounds.height;
+ if (area < imageArea * 0.05) {
+ return defaultOutline;
+ }
+
+ // Detect if the outline is a rectangle or circle
+ if (Vector2.rectangular(outlinePoints)) {
+ return {
+ type: "rect",
+ x: Math.round(bounds.min.x),
+ y: Math.round(bounds.min.y),
+ width: Math.round(bounds.width),
+ height: Math.round(bounds.height),
+ };
+ } else if (
+ Vector2.circular(
+ outlinePoints,
+ Math.max(bounds.width / 10, bounds.height / 10)
+ )
+ ) {
+ return {
+ type: "circle",
+ x: Math.round(bounds.center.x),
+ y: Math.round(bounds.center.y),
+ radius: Math.round(Math.min(bounds.width, bounds.height) / 2),
+ };
+ } else {
+ // Flatten and round outline to save on storage size
+ const points = outlinePoints
+ .map(({ x, y }) => [Math.round(x), Math.round(y)])
+ .flat();
+ return { type: "path", points };
+ }
+ } else {
+ return defaultOutline;
+ }
+ } catch {
+ return defaultOutline;
+ }
+}
diff --git a/src/helpers/map.js b/src/helpers/map.js
new file mode 100644
index 0000000..c493698
--- /dev/null
+++ b/src/helpers/map.js
@@ -0,0 +1,195 @@
+import { v4 as uuid } from "uuid";
+import Case from "case";
+
+import blobToBuffer from "./blobToBuffer";
+import { resizeImage, createThumbnail } from "./image";
+import {
+ getGridDefaultInset,
+ getGridSizeFromImage,
+ gridSizeVaild,
+} from "./grid";
+import Vector2 from "./Vector2";
+
+const defaultMapProps = {
+ showGrid: false,
+ snapToGrid: true,
+ quality: "original",
+ group: "",
+};
+
+const mapResolutions = [
+ {
+ size: 30, // Pixels per grid
+ quality: 0.5, // JPEG compression quality
+ id: "low",
+ },
+ { size: 70, quality: 0.6, id: "medium" },
+ { size: 140, quality: 0.7, id: "high" },
+ { size: 300, quality: 0.8, id: "ultra" },
+];
+
+/**
+ * Get the asset id of the preview file to send for a map
+ * @param {any} map
+ * @returns {undefined|string}
+ */
+export function getMapPreviewAsset(map) {
+ const res = map.resolutions;
+ switch (map.quality) {
+ case "low":
+ return;
+ case "medium":
+ return res.low;
+ case "high":
+ return res.medium;
+ case "ultra":
+ return res.medium;
+ case "original":
+ if (res.medium) {
+ return res.medium;
+ } else if (res.low) {
+ return res.low;
+ }
+ return;
+ default:
+ return;
+ }
+}
+
+export async function createMapFromFile(file, userId) {
+ let image = new Image();
+
+ const buffer = await blobToBuffer(file);
+ // Copy file to avoid permissions issues
+ const blob = new Blob([buffer]);
+ // Create and load the image temporarily to get its dimensions
+ const url = URL.createObjectURL(blob);
+
+ return new Promise((resolve, reject) => {
+ image.onload = async function () {
+ // Find name and grid size
+ let gridSize;
+ let name = "Unknown Map";
+ if (file.name) {
+ if (file.name.matchAll) {
+ // Match against a regex to find the grid size in the file name
+ // e.g. Cave 22x23 will return [["22x22", "22", "x", "23"]]
+ const gridMatches = [...file.name.matchAll(/(\d+) ?(x|X) ?(\d+)/g)];
+ for (let match of gridMatches) {
+ const matchX = parseInt(match[1]);
+ const matchY = parseInt(match[3]);
+ if (
+ !isNaN(matchX) &&
+ !isNaN(matchY) &&
+ gridSizeVaild(matchX, matchY)
+ ) {
+ gridSize = { x: matchX, y: matchY };
+ }
+ }
+ }
+
+ if (!gridSize) {
+ gridSize = await getGridSizeFromImage(image);
+ }
+
+ // Remove file extension
+ name = file.name.replace(/\.[^/.]+$/, "");
+ // Removed grid size expression
+ name = name.replace(/(\[ ?|\( ?)?\d+ ?(x|X) ?\d+( ?\]| ?\))?/, "");
+ // Clean string
+ name = name.replace(/ +/g, " ");
+ name = name.trim();
+ // Capitalize and remove underscores
+ name = Case.capital(name);
+ }
+
+ if (!gridSize) {
+ gridSize = { x: 22, y: 22 };
+ }
+
+ let assets = [];
+
+ // Create resolutions
+ const resolutions = {};
+ for (let resolution of mapResolutions) {
+ const resolutionPixelSize = Vector2.multiply(gridSize, resolution.size);
+ if (
+ image.width >= resolutionPixelSize.x &&
+ image.height >= resolutionPixelSize.y
+ ) {
+ const resized = await resizeImage(
+ image,
+ Vector2.max(resolutionPixelSize),
+ file.type,
+ resolution.quality
+ );
+ if (resized.blob) {
+ const assetId = uuid();
+ resolutions[resolution.id] = assetId;
+ const resizedBuffer = await blobToBuffer(resized.blob);
+ const asset = {
+ file: resizedBuffer,
+ width: resized.width,
+ height: resized.height,
+ id: assetId,
+ mime: file.type,
+ owner: userId,
+ };
+ assets.push(asset);
+ }
+ }
+ }
+ // Create thumbnail
+ const thumbnailImage = await createThumbnail(image, file.type);
+ const thumbnail = {
+ ...thumbnailImage,
+ id: uuid(),
+ owner: userId,
+ };
+ assets.push(thumbnail);
+
+ const fileAsset = {
+ id: uuid(),
+ file: buffer,
+ width: image.width,
+ height: image.height,
+ mime: file.type,
+ owner: userId,
+ };
+ assets.push(fileAsset);
+
+ const map = {
+ name,
+ resolutions,
+ file: fileAsset.id,
+ thumbnail: thumbnail.id,
+ type: "file",
+ grid: {
+ size: gridSize,
+ inset: getGridDefaultInset(
+ { size: gridSize, type: "square" },
+ image.width,
+ image.height
+ ),
+ type: "square",
+ measurement: {
+ type: "chebyshev",
+ scale: "5ft",
+ },
+ },
+ width: image.width,
+ height: image.height,
+ id: uuid(),
+ created: Date.now(),
+ lastModified: Date.now(),
+ owner: userId,
+ ...defaultMapProps,
+ };
+
+ URL.revokeObjectURL(url);
+ resolve({ map, assets });
+ };
+ image.onerror = reject;
+ image.src = url;
+ });
+}
diff --git a/src/helpers/select.js b/src/helpers/select.js
deleted file mode 100644
index eb81b95..0000000
--- a/src/helpers/select.js
+++ /dev/null
@@ -1,133 +0,0 @@
-import { useEffect, useState } from "react";
-import Fuse from "fuse.js";
-
-import { groupBy } from "./shared";
-
-/**
- * Helpers for the SelectMapModal and SelectTokenModal
- */
-
-// Helper for generating search results for items
-export function useSearch(items, search) {
- const [filteredItems, setFilteredItems] = useState([]);
- const [filteredItemScores, setFilteredItemScores] = useState({});
- const [fuse, setFuse] = useState();
-
- // Update search index when items change
- useEffect(() => {
- setFuse(new Fuse(items, { keys: ["name", "group"], includeScore: true }));
- }, [items]);
-
- // Perform search when search changes
- useEffect(() => {
- if (search) {
- const query = fuse.search(search);
- setFilteredItems(query.map((result) => result.item));
- setFilteredItemScores(
- query.reduce(
- (acc, value) => ({ ...acc, [value.item.id]: value.score }),
- {}
- )
- );
- }
- }, [search, items, fuse]);
-
- return [filteredItems, filteredItemScores];
-}
-
-// Helper for grouping items
-export function useGroup(items, filteredItems, useFiltered, filteredScores) {
- const itemsByGroup = groupBy(useFiltered ? filteredItems : items, "group");
- // Get the groups of the items sorting by the average score if we're filtering or the alphabetical order
- // with "" at the start and "default" at the end if not
- let itemGroups = Object.keys(itemsByGroup);
- if (useFiltered) {
- itemGroups.sort((a, b) => {
- const aScore = itemsByGroup[a].reduce(
- (acc, item) => (acc + filteredScores[item.id]) / 2
- );
- const bScore = itemsByGroup[b].reduce(
- (acc, item) => (acc + filteredScores[item.id]) / 2
- );
- return aScore - bScore;
- });
- } else {
- itemGroups.sort((a, b) => {
- if (a === "" || b === "default") {
- return -1;
- }
- if (b === "" || a === "default") {
- return 1;
- }
- return a.localeCompare(b);
- });
- }
- return [itemsByGroup, itemGroups];
-}
-
-// Helper for handling selecting items
-export function handleItemSelect(
- item,
- selectMode,
- selectedIds,
- setSelectedIds,
- itemsByGroup,
- itemGroups
-) {
- if (!item) {
- setSelectedIds([]);
- return;
- }
- switch (selectMode) {
- case "single":
- setSelectedIds([item.id]);
- break;
- case "multiple":
- setSelectedIds((prev) => {
- if (prev.includes(item.id)) {
- return prev.filter((id) => id !== item.id);
- } else {
- return [...prev, item.id];
- }
- });
- break;
- case "range":
- // Create items array
- let items = itemGroups.reduce(
- (acc, group) => [...acc, ...itemsByGroup[group]],
- []
- );
-
- // Add all items inbetween the previous selected item and the current selected
- if (selectedIds.length > 0) {
- const mapIndex = items.findIndex((m) => m.id === item.id);
- const lastIndex = items.findIndex(
- (m) => m.id === selectedIds[selectedIds.length - 1]
- );
- let idsToAdd = [];
- let idsToRemove = [];
- const direction = mapIndex > lastIndex ? 1 : -1;
- for (
- let i = lastIndex + direction;
- direction < 0 ? i >= mapIndex : i <= mapIndex;
- i += direction
- ) {
- const itemId = items[i].id;
- if (selectedIds.includes(itemId)) {
- idsToRemove.push(itemId);
- } else {
- idsToAdd.push(itemId);
- }
- }
- setSelectedIds((prev) => {
- let ids = [...prev, ...idsToAdd];
- return ids.filter((id) => !idsToRemove.includes(id));
- });
- } else {
- setSelectedIds([item.id]);
- }
- break;
- default:
- setSelectedIds([]);
- }
-}
diff --git a/src/helpers/shared.js b/src/helpers/shared.js
index a9ed206..5940429 100644
--- a/src/helpers/shared.js
+++ b/src/helpers/shared.js
@@ -75,3 +75,24 @@ export function groupBy(array, key) {
}
export const isMacLike = /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform);
+
+export function shuffle(array) {
+ let temp = [...array];
+ var currentIndex = temp.length,
+ randomIndex;
+
+ // While there remain elements to shuffle...
+ while (0 !== currentIndex) {
+ // Pick a remaining element...
+ randomIndex = Math.floor(Math.random() * currentIndex);
+ currentIndex--;
+
+ // And swap it with the current element.
+ [temp[currentIndex], temp[randomIndex]] = [
+ temp[randomIndex],
+ temp[currentIndex],
+ ];
+ }
+
+ return temp;
+}
diff --git a/src/helpers/token.js b/src/helpers/token.js
new file mode 100644
index 0000000..d5eefc4
--- /dev/null
+++ b/src/helpers/token.js
@@ -0,0 +1,264 @@
+import { v4 as uuid } from "uuid";
+import Case from "case";
+
+import blobToBuffer from "./blobToBuffer";
+import { createThumbnail, getImageOutline } from "./image";
+import Vector2 from "./Vector2";
+
+export function createTokenState(token, position, userId) {
+ let tokenState = {
+ id: uuid(),
+ tokenId: token.id,
+ owner: userId,
+ size: token.defaultSize,
+ category: token.defaultCategory,
+ label: token.defaultLabel,
+ statuses: [],
+ x: position.x,
+ y: position.y,
+ lastModifiedBy: userId,
+ lastModified: Date.now(),
+ rotation: 0,
+ locked: false,
+ visible: true,
+ type: token.type,
+ outline: token.outline,
+ width: token.width,
+ height: token.height,
+ };
+ if (token.type === "file") {
+ tokenState.file = token.file;
+ } else if (token.type === "default") {
+ tokenState.key = token.key;
+ }
+ return tokenState;
+}
+
+export async function createTokenFromFile(file, userId) {
+ if (!file) {
+ return Promise.reject();
+ }
+ let name = "Unknown Token";
+ let defaultSize = 1;
+ if (file.name) {
+ if (file.name.matchAll) {
+ // Match against a regex to find the grid size in the file name
+ // e.g. Cave 22x23 will return [["22x22", "22", "x", "23"]]
+ const sizeMatches = [...file.name.matchAll(/(\d+) ?(x|X) ?(\d+)/g)];
+ for (let match of sizeMatches) {
+ const matchX = parseInt(match[1]);
+ const matchY = parseInt(match[3]);
+ if (
+ !isNaN(matchX) &&
+ !isNaN(matchY) &&
+ matchX < 256 // Add check to test match isn't resolution
+ ) {
+ defaultSize = matchX;
+ }
+ }
+ }
+ // Remove file extension
+ name = file.name.replace(/\.[^/.]+$/, "");
+ // Removed grid size expression
+ name = name.replace(/(\[ ?|\( ?)?\d+ ?(x|X) ?\d+( ?\]| ?\))?/, "");
+ // Clean string
+ name = name.replace(/ +/g, " ");
+ name = name.trim();
+ // Capitalize and remove underscores
+ name = Case.capital(name);
+ }
+ let image = new Image();
+ const buffer = await blobToBuffer(file);
+
+ // Copy file to avoid permissions issues
+ const blob = new Blob([buffer]);
+ // Create and load the image temporarily to get its dimensions
+ const url = URL.createObjectURL(blob);
+
+ return new Promise((resolve, reject) => {
+ image.onload = async function () {
+ let assets = [];
+ const thumbnailImage = await createThumbnail(image, file.type);
+ const thumbnail = { ...thumbnailImage, id: uuid(), owner: userId };
+ assets.push(thumbnail);
+
+ const fileAsset = {
+ id: uuid(),
+ file: buffer,
+ width: image.width,
+ height: image.height,
+ mime: file.type,
+ owner: userId,
+ };
+ assets.push(fileAsset);
+
+ const outline = getImageOutline(image);
+
+ const token = {
+ name,
+ defaultSize,
+ thumbnail: thumbnail.id,
+ file: fileAsset.id,
+ id: uuid(),
+ type: "file",
+ created: Date.now(),
+ lastModified: Date.now(),
+ owner: userId,
+ defaultCategory: "character",
+ defaultLabel: "",
+ hideInSidebar: false,
+ group: "",
+ width: image.width,
+ height: image.height,
+ outline,
+ };
+
+ URL.revokeObjectURL(url);
+ resolve({ token, assets });
+ };
+ image.onerror = reject;
+ image.src = url;
+ });
+}
+
+export function clientPositionToMapPosition(
+ mapStage,
+ clientPosition,
+ checkMapBounds = true
+) {
+ const mapImage = mapStage.findOne("#mapImage");
+ const map = document.querySelector(".map");
+ const mapRect = map.getBoundingClientRect();
+
+ // Check map bounds
+ if (
+ checkMapBounds &&
+ (clientPosition.x < mapRect.left ||
+ clientPosition.x > mapRect.right ||
+ clientPosition.y < mapRect.top ||
+ clientPosition.y > mapRect.bottom)
+ ) {
+ return;
+ }
+
+ // Convert relative to map rect
+ const mapPosition = {
+ x: clientPosition.x - mapRect.left,
+ y: clientPosition.y - mapRect.top,
+ };
+
+ // Convert relative to map image
+ const transform = mapImage.getAbsoluteTransform().copy().invert();
+ const relativePosition = transform.point(mapPosition);
+ const normalizedPosition = {
+ x: relativePosition.x / mapImage.width(),
+ y: relativePosition.y / mapImage.height(),
+ };
+
+ return normalizedPosition;
+}
+
+export function getScaledOutline(tokenState, tokenWidth, tokenHeight) {
+ let outline = tokenState.outline;
+ if (outline.type === "rect") {
+ return {
+ ...outline,
+ x: (outline.x / tokenState.width) * tokenWidth,
+ y: (outline.y / tokenState.height) * tokenHeight,
+ width: (outline.width / tokenState.width) * tokenWidth,
+ height: (outline.height / tokenState.height) * tokenHeight,
+ };
+ } else if (outline.type === "circle") {
+ return {
+ ...outline,
+ x: (outline.x / tokenState.width) * tokenWidth,
+ y: (outline.y / tokenState.height) * tokenHeight,
+ radius: (outline.radius / tokenState.width) * tokenWidth,
+ };
+ } else {
+ let points = [...outline.points]; // Copy array so we can edit it imutably
+ for (let i = 0; i < points.length; i += 2) {
+ // Scale outline to the token
+ points[i] = (points[i] / tokenState.width) * tokenWidth;
+ points[i + 1] = (points[i + 1] / tokenState.height) * tokenHeight;
+ }
+ return { ...outline, points };
+ }
+}
+
+export class Intersection {
+ /**
+ *
+ * @param {Outline} outline
+ * @param {Vector2} position - Top left position of the token
+ * @param {Vector2} center - Center position of the token
+ * @param {number} rotation - Rotation of the token in degrees
+ */
+ constructor(outline, position, center, rotation) {
+ this.outline = outline;
+ this.position = position;
+ this.center = center;
+ this.rotation = rotation;
+ // Save points for intersection
+ if (outline.type === "rect") {
+ this.points = [
+ Vector2.rotate(
+ Vector2.add(new Vector2(outline.x, outline.y), position),
+ center,
+ rotation
+ ),
+ Vector2.rotate(
+ Vector2.add(
+ new Vector2(outline.x + outline.width, outline.y),
+ position
+ ),
+ center,
+ rotation
+ ),
+ Vector2.rotate(
+ Vector2.add(
+ new Vector2(outline.x + outline.width, outline.y + outline.height),
+ position
+ ),
+ center,
+ rotation
+ ),
+ Vector2.rotate(
+ Vector2.add(
+ new Vector2(outline.x, outline.y + outline.height),
+ position
+ ),
+ center,
+ rotation
+ ),
+ ];
+ } else if (outline.type === "path") {
+ this.points = [];
+ for (let i = 0; i < outline.points.length; i += 2) {
+ this.points.push(
+ Vector2.rotate(
+ Vector2.add(
+ new Vector2(outline.points[i], outline.points[i + 1]),
+ position
+ ),
+ center,
+ rotation
+ )
+ );
+ }
+ }
+ }
+
+ /**
+ * @param {Vector2} point
+ * @returns {boolean}
+ */
+ intersects(point) {
+ if (this.outline.type === "rect" || this.outline.type === "path") {
+ return Vector2.pointInPolygon(point, this.points);
+ } else if (this.outline.type === "circle") {
+ return Vector2.distance(this.center, point) < this.outline.radius;
+ }
+ return false;
+ }
+}
diff --git a/src/hooks/useGridSnapping.js b/src/hooks/useGridSnapping.js
index 9c1a85c..aa52040 100644
--- a/src/hooks/useGridSnapping.js
+++ b/src/hooks/useGridSnapping.js
@@ -17,12 +17,16 @@ import {
/**
* Returns a function that when called will snap a node to the current grid
* @param {number=} snappingSensitivity 1 = Always snap, 0 = never snap if undefined the default user setting will be used
+ * @param {boolean=} useCorners Snap to grid cell corners
*/
-function useGridSnapping(snappingSensitivity) {
+function useGridSnapping(snappingSensitivity, useCorners = true) {
const [defaultSnappingSensitivity] = useSetting(
"map.gridSnappingSensitivity"
);
- snappingSensitivity = snappingSensitivity || defaultSnappingSensitivity;
+ snappingSensitivity =
+ snappingSensitivity === undefined
+ ? defaultSnappingSensitivity
+ : snappingSensitivity;
const grid = useGrid();
const gridOffset = useGridOffset();
@@ -57,7 +61,10 @@ function useGridSnapping(snappingSensitivity) {
gridCellPixelSize
);
- const snapPoints = [cellPosition, ...cellCorners];
+ const snapPoints = [cellPosition];
+ if (useCorners) {
+ snapPoints.push(...cellCorners);
+ }
for (let snapPoint of snapPoints) {
const distanceToSnapPoint = Vector2.distance(offsetPosition, snapPoint);
diff --git a/src/hooks/useImageDrop.js b/src/hooks/useImageDrop.js
new file mode 100644
index 0000000..bf38044
--- /dev/null
+++ b/src/hooks/useImageDrop.js
@@ -0,0 +1,85 @@
+import { useState } from "react";
+import { useToasts } from "react-toast-notifications";
+
+import Vector2 from "../helpers/Vector2";
+
+function useImageDrop(
+ onImageDrop,
+ supportFileTypes = ["image/jpeg", "image/gif", "image/png", "image/webp"]
+) {
+ const { addToast } = useToasts();
+
+ const [dragging, setDragging] = useState(false);
+ function onDragEnter(event) {
+ event.preventDefault();
+ event.stopPropagation();
+ setDragging(true);
+ }
+
+ function onDragLeave(event) {
+ event.preventDefault();
+ event.stopPropagation();
+ setDragging(false);
+ }
+
+ function onDragOver(event) {
+ event.preventDefault();
+ event.stopPropagation();
+ event.dataTransfer.dropEffect = "copy";
+ }
+
+ async function onDrop(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("&", "&"); // 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;
+ if (supportFileTypes.includes(file.type)) {
+ imageFiles.push(file);
+ } else {
+ addToast(`Unsupported file type for ${file.name}`);
+ }
+ }
+ } catch (e) {
+ if (e.message === "Failed to fetch") {
+ addToast("Unable to import image: failed to fetch");
+ } else {
+ addToast("Unable to import image");
+ }
+ }
+ }
+
+ const files = event.dataTransfer.files;
+ for (let file of files) {
+ if (supportFileTypes.includes(file.type)) {
+ imageFiles.push(file);
+ } else {
+ addToast(`Unsupported file type for ${file.name}`);
+ }
+ }
+ const dropPosition = new Vector2(event.clientX, event.clientY);
+ onImageDrop(imageFiles, dropPosition);
+ setDragging(false);
+ }
+
+ const containerListeners = { onDragEnter };
+ const overlayListeners = { onDragLeave, onDragOver, onDrop };
+
+ return { dragging, containerListeners, overlayListeners };
+}
+
+export default useImageDrop;
diff --git a/src/hooks/useMapImage.js b/src/hooks/useMapImage.js
index a67dc2b..02cf5b6 100644
--- a/src/hooks/useMapImage.js
+++ b/src/hooks/useMapImage.js
@@ -1,23 +1,23 @@
import { 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";
function useMapImage(map) {
- const mapSource = useImageSource(map, defaultMapSources);
- const [mapSourceImage, mapSourceImageStatus] = useImage(mapSource);
+ const mapURL = useDataURL(map, defaultMapSources);
+ const [mapImage, mapImageStatus] = useImage(mapURL);
// Create a map source that only updates when the image is fully loaded
- const [loadedMapSourceImage, setLoadedMapSourceImage] = useState();
+ const [loadedMapImage, setLoadedMapImage] = useState();
useEffect(() => {
- if (mapSourceImageStatus === "loaded") {
- setLoadedMapSourceImage(mapSourceImage);
+ if (mapImageStatus === "loaded") {
+ setLoadedMapImage(mapImage);
}
- }, [mapSourceImage, mapSourceImageStatus]);
+ }, [mapImage, mapImageStatus]);
- return [loadedMapSourceImage, mapSourceImageStatus];
+ return [loadedMapImage, mapImageStatus];
}
export default useMapImage;
diff --git a/src/hooks/useNetworkedState.js b/src/hooks/useNetworkedState.js
index 7dbf356..99ef54d 100644
--- a/src/hooks/useNetworkedState.js
+++ b/src/hooks/useNetworkedState.js
@@ -1,4 +1,5 @@
import { useEffect, useState, useRef, useCallback } from "react";
+import cloneDeep from "lodash.clonedeep";
import useDebounce from "./useDebounce";
import { diff, applyChanges } from "../helpers/diff";
@@ -70,7 +71,7 @@ function useNetworkedState(
}
dirtyRef.current = false;
forceUpdateRef.current = false;
- lastSyncedStateRef.current = debouncedState;
+ lastSyncedStateRef.current = cloneDeep(debouncedState);
}
}, [
session.socket,
diff --git a/src/hooks/usePreventSelect.js b/src/hooks/usePreventSelect.js
new file mode 100644
index 0000000..82d36bd
--- /dev/null
+++ b/src/hooks/usePreventSelect.js
@@ -0,0 +1,22 @@
+function usePreventSelect() {
+ function clearSelection() {
+ if (window.getSelection) {
+ window.getSelection().removeAllRanges();
+ }
+ if (document.selection) {
+ document.selection.empty();
+ }
+ }
+ function preventSelect() {
+ clearSelection();
+ document.body.classList.add("no-select");
+ }
+
+ function resumeSelect() {
+ document.body.classList.remove("no-select");
+ }
+
+ return [preventSelect, resumeSelect];
+}
+
+export default usePreventSelect;
diff --git a/src/hooks/useResponsiveLayout.js b/src/hooks/useResponsiveLayout.js
index cb9e583..0f393c9 100644
--- a/src/hooks/useResponsiveLayout.js
+++ b/src/hooks/useResponsiveLayout.js
@@ -21,7 +21,20 @@ function useResponsiveLayout() {
? "medium"
: "large";
- return { screenSize, modalSize, tileSize };
+ const tileGridColumns = isLargeScreen ? 4 : isMediumScreen ? 3 : 2;
+
+ const groupGridColumns = isLargeScreen ? 3 : 2;
+
+ const tileContainerHeight = isLargeScreen ? "600px" : "400px";
+
+ return {
+ screenSize,
+ modalSize,
+ tileSize,
+ tileGridColumns,
+ tileContainerHeight,
+ groupGridColumns,
+ };
}
export default useResponsiveLayout;
diff --git a/src/images/DiagonalPattern.png b/src/images/DiagonalPattern.png
index 55b3b17..31bc88d 100644
Binary files a/src/images/DiagonalPattern.png and b/src/images/DiagonalPattern.png differ
diff --git a/src/index.css b/src/index.css
index fb96b26..38bf6b3 100644
--- a/src/index.css
+++ b/src/index.css
@@ -13,3 +13,7 @@ html {
input[type="checkbox"]:disabled ~ svg {
opacity: 0.1;
}
+
+.no-select div {
+ user-select: none;
+}
diff --git a/src/index.js b/src/index.js
index d21cd5b..e5b82b0 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,6 +1,7 @@
import React from "react";
import ReactDOM from "react-dom";
import * as Sentry from "@sentry/react";
+import { Dedupe } from "@sentry/integrations";
import App from "./App";
import Modal from "react-modal";
@@ -16,10 +17,16 @@ if (!("PointerEvent" in window)) {
import("pepjs");
}
+// Intersection observer polyfill
+if (!("IntersectionObserver" in window)) {
+ import("intersection-observer");
+}
+
if (process.env.REACT_APP_LOGGING === "true") {
Sentry.init({
dsn: process.env.REACT_APP_SENTRY_DSN,
release: "owlbear-rodeo@" + process.env.REACT_APP_VERSION,
+ integrations: [new Dedupe()],
// Ignore resize error as it is triggered by going fullscreen on slower computers
// Ignore quota error
// Ignore XDR encoding failure bug in Firefox https://bugzilla.mozilla.org/show_bug.cgi?id=1678243
@@ -35,6 +42,8 @@ if (process.env.REACT_APP_LOGGING === "true") {
new RegExp(
"([InvalidStateError:\\s]*Failed to execute 'transaction' on 'IDBDatabase': The database connection is closing([.]*[\\s]*))+"
),
+ "Browser is shutting down",
+ "An internal error was encountered in the Indexed Database server",
// Random plugins/extensions
"top.GLOBALS",
// See: http://blog.errorception.com/2012/03/tale-of-unfindable-js-error.html
diff --git a/src/maps/Blank Grid 22x22.jpg b/src/maps/Blank Grid 22x22.jpg
deleted file mode 100755
index 55da0c9..0000000
Binary files a/src/maps/Blank Grid 22x22.jpg and /dev/null differ
diff --git a/src/maps/Blank.jpg b/src/maps/Blank.jpg
new file mode 100755
index 0000000..ebec3c5
Binary files /dev/null and b/src/maps/Blank.jpg differ
diff --git a/src/maps/Grass Grid 22x22.jpg b/src/maps/Grass Grid 22x22.jpg
deleted file mode 100755
index 592f5f0..0000000
Binary files a/src/maps/Grass Grid 22x22.jpg and /dev/null differ
diff --git a/src/maps/Grass.jpg b/src/maps/Grass.jpg
new file mode 100755
index 0000000..0bb8a23
Binary files /dev/null and b/src/maps/Grass.jpg differ
diff --git a/src/maps/Sand Grid 22x22.jpg b/src/maps/Sand Grid 22x22.jpg
deleted file mode 100755
index 9985172..0000000
Binary files a/src/maps/Sand Grid 22x22.jpg and /dev/null differ
diff --git a/src/maps/Sand.jpg b/src/maps/Sand.jpg
new file mode 100755
index 0000000..b76b982
Binary files /dev/null and b/src/maps/Sand.jpg differ
diff --git a/src/maps/Stone Grid 22x22.jpg b/src/maps/Stone Grid 22x22.jpg
deleted file mode 100755
index 7e3be20..0000000
Binary files a/src/maps/Stone Grid 22x22.jpg and /dev/null differ
diff --git a/src/maps/Stone.jpg b/src/maps/Stone.jpg
new file mode 100755
index 0000000..f300325
Binary files /dev/null and b/src/maps/Stone.jpg differ
diff --git a/src/maps/Unknown Grid 22x22.jpg b/src/maps/Unknown.jpg
similarity index 100%
rename from src/maps/Unknown Grid 22x22.jpg
rename to src/maps/Unknown.jpg
diff --git a/src/maps/Water Grid 22x22.jpg b/src/maps/Water Grid 22x22.jpg
deleted file mode 100755
index 1525aac..0000000
Binary files a/src/maps/Water Grid 22x22.jpg and /dev/null differ
diff --git a/src/maps/Water.jpg b/src/maps/Water.jpg
new file mode 100755
index 0000000..8cceaa0
Binary files /dev/null and b/src/maps/Water.jpg differ
diff --git a/src/maps/Wood Grid 22x22.jpg b/src/maps/Wood Grid 22x22.jpg
deleted file mode 100755
index a1074f3..0000000
Binary files a/src/maps/Wood Grid 22x22.jpg and /dev/null differ
diff --git a/src/maps/Wood.jpg b/src/maps/Wood.jpg
new file mode 100755
index 0000000..9ba25be
Binary files /dev/null and b/src/maps/Wood.jpg differ
diff --git a/src/maps/index.js b/src/maps/index.js
index 2b55878..99c4192 100644
--- a/src/maps/index.js
+++ b/src/maps/index.js
@@ -1,13 +1,13 @@
import Case from "case";
-import blankImage from "./Blank Grid 22x22.jpg";
-import grassImage from "./Grass Grid 22x22.jpg";
-import sandImage from "./Sand Grid 22x22.jpg";
-import stoneImage from "./Stone Grid 22x22.jpg";
-import waterImage from "./Water Grid 22x22.jpg";
-import woodImage from "./Wood Grid 22x22.jpg";
+import blankImage from "./Blank.jpg";
+import grassImage from "./Grass.jpg";
+import sandImage from "./Sand.jpg";
+import stoneImage from "./Stone.jpg";
+import waterImage from "./Water.jpg";
+import woodImage from "./Wood.jpg";
-import unknownImage from "./Unknown Grid 22x22.jpg";
+import unknownImage from "./Unknown.jpg";
export const mapSources = {
blank: blankImage,
@@ -18,18 +18,46 @@ export const mapSources = {
wood: woodImage,
};
-export const maps = Object.keys(mapSources).map((key) => ({
- key,
- name: Case.capital(key),
- grid: {
- size: { x: 22, y: 22 },
- inset: { topLeft: { x: 0, y: 0 }, bottomRight: { x: 1, y: 1 } },
- type: "square",
- measurement: { type: "chebyshev", scale: "5ft" },
- },
- width: 1024,
- height: 1024,
- type: "default",
-}));
+export function getDefaultMaps(userId) {
+ const mapKeys = Object.keys(mapSources);
+ let maps = [];
+ let mapStates = [];
+ for (let i = 0; i < mapKeys.length; i++) {
+ const key = mapKeys[i];
+ const name = Case.capital(key);
+ const id = `__default-${name}`;
+ const map = {
+ id,
+ key,
+ name,
+ owner: userId,
+ grid: {
+ size: { x: 22, y: 22 },
+ inset: { topLeft: { x: 0, y: 0 }, bottomRight: { x: 1, y: 1 } },
+ type: "square",
+ measurement: { type: "chebyshev", scale: "5ft" },
+ },
+ width: 1024,
+ height: 1024,
+ type: "default",
+ created: mapKeys.length - i,
+ lastModified: Date.now(),
+ showGrid: key !== "stone",
+ snapToGrid: true,
+ group: "",
+ };
+ maps.push(map);
+ const state = {
+ mapId: id,
+ tokens: {},
+ drawShapes: {},
+ fogShapes: {},
+ editFlags: ["drawing", "tokens", "notes"],
+ notes: {},
+ };
+ mapStates.push(state);
+ }
+ return { maps, mapStates };
+}
export const unknownSource = unknownImage;
diff --git a/src/modals/EditGroupModal.js b/src/modals/EditGroupModal.js
deleted file mode 100644
index a990bd0..0000000
--- a/src/modals/EditGroupModal.js
+++ /dev/null
@@ -1,65 +0,0 @@
-import React, { useEffect, useState } from "react";
-import { Box, Button, Label, Flex } from "theme-ui";
-
-import Modal from "../components/Modal";
-import Select from "../components/Select";
-
-function EditGroupModal({
- isOpen,
- onRequestClose,
- onChange,
- groups,
- defaultGroup,
-}) {
- const [value, setValue] = useState();
- const [options, setOptions] = useState([]);
-
- useEffect(() => {
- if (defaultGroup) {
- setValue({ value: defaultGroup, label: defaultGroup });
- } else {
- setValue();
- }
- }, [defaultGroup]);
-
- useEffect(() => {
- setOptions(groups.map((group) => ({ value: group, label: group })));
- }, [groups]);
-
- function handleCreate(group) {
- const newOption = { value: group, label: group };
- setValue(newOption);
- setOptions((prev) => [...prev, newOption]);
- }
-
- function handleChange() {
- onChange(value ? value.value : "");
- }
-
- return (
-
-
-
-
-
-
-
-
-
- );
-}
-
-export default EditGroupModal;
diff --git a/src/modals/EditMapModal.js b/src/modals/EditMapModal.js
index ad38c8c..4582b05 100644
--- a/src/modals/EditMapModal.js
+++ b/src/modals/EditMapModal.js
@@ -1,52 +1,25 @@
-import React, { useEffect, useState } from "react";
-import { Button, Flex, Label } from "theme-ui";
+import React, { useState } from "react";
+import { Button, Flex, Label, useThemeUI } from "theme-ui";
+import SimpleBar from "simplebar-react";
import Modal from "../components/Modal";
import MapSettings from "../components/map/MapSettings";
import MapEditor from "../components/map/MapEditor";
-import LoadingOverlay from "../components/LoadingOverlay";
-
-import { useMapData } from "../contexts/MapDataContext";
import { isEmpty } from "../helpers/shared";
import { getGridDefaultInset } from "../helpers/grid";
import useResponsiveLayout from "../hooks/useResponsiveLayout";
-function EditMapModal({ isOpen, onDone, mapId }) {
- const {
- updateMap,
- updateMapState,
- getMap,
- getMapFromDB,
- getMapStateFromDB,
- } = useMapData();
-
- const [isLoading, setIsLoading] = useState(true);
- const [map, setMap] = useState();
- const [mapState, setMapState] = useState();
- // Load full map when modal is opened
- useEffect(() => {
- async function loadMap() {
- setIsLoading(true);
- let loadingMap = getMap(mapId);
- // Ensure file is loaded for map
- if (loadingMap?.type === "file" && !loadingMap?.file) {
- loadingMap = await getMapFromDB(mapId);
- }
- const mapState = await getMapStateFromDB(mapId);
- setMap(loadingMap);
- setMapState(mapState);
- setIsLoading(false);
- }
-
- if (isOpen && mapId) {
- loadMap();
- } else {
- setMap();
- setMapState();
- }
- }, [isOpen, mapId, getMapFromDB, getMapStateFromDB, getMap]);
+function EditMapModal({
+ isOpen,
+ onDone,
+ map,
+ mapState,
+ onUpdateMap,
+ onUpdateMapState,
+}) {
+ const { theme } = useThemeUI();
function handleClose() {
setMapSettingChanges({});
@@ -112,8 +85,8 @@ function EditMapModal({ isOpen, onDone, mapId }) {
}
}
}
- await updateMap(map.id, mapSettingChanges);
- await updateMapState(map.id, mapStateSettingChanges);
+ await onUpdateMap(map.id, mapSettingChanges);
+ await onUpdateMapState(map.id, mapStateSettingChanges);
setMapSettingChanges({});
setMapStateSettingChanges({});
@@ -129,50 +102,56 @@ function EditMapModal({ isOpen, onDone, mapId }) {
...mapStateSettingChanges,
};
- const [showMoreSettings, setShowMoreSettings] = useState(true);
-
const layout = useResponsiveLayout();
+ if (!map) {
+ return null;
+ }
+
return (
-
);
diff --git a/src/modals/EditTokenModal.js b/src/modals/EditTokenModal.js
index 4fe8ba8..322f075 100644
--- a/src/modals/EditTokenModal.js
+++ b/src/modals/EditTokenModal.js
@@ -1,35 +1,17 @@
-import React, { useState, useEffect } from "react";
-import { Button, Flex, Label } from "theme-ui";
+import React, { useState } from "react";
+import { Button, Flex, Label, useThemeUI } from "theme-ui";
+import SimpleBar from "simplebar-react";
import Modal from "../components/Modal";
import TokenSettings from "../components/token/TokenSettings";
import TokenPreview from "../components/token/TokenPreview";
-import LoadingOverlay from "../components/LoadingOverlay";
-
-import { useTokenData } from "../contexts/TokenDataContext";
import { isEmpty } from "../helpers/shared";
import useResponsiveLayout from "../hooks/useResponsiveLayout";
-function EditTokenModal({ isOpen, onDone, tokenId }) {
- const { updateToken, getTokenFromDB } = useTokenData();
-
- const [isLoading, setIsLoading] = useState(true);
- const [token, setToken] = useState();
- useEffect(() => {
- async function loadToken() {
- setIsLoading(true);
- setToken(await getTokenFromDB(tokenId));
- setIsLoading(false);
- }
-
- if (isOpen && tokenId) {
- loadToken();
- } else {
- setToken();
- }
- }, [isOpen, tokenId, getTokenFromDB]);
+function EditTokenModal({ isOpen, onDone, token, onUpdateToken }) {
+ const { theme } = useThemeUI();
function handleClose() {
setTokenSettingChanges({});
@@ -55,7 +37,7 @@ function EditTokenModal({ isOpen, onDone, tokenId }) {
verifiedChanges.defaultSize = verifiedChanges.defaultSize || 1;
}
- await updateToken(token.id, verifiedChanges);
+ await onUpdateToken(token.id, verifiedChanges);
setTokenSettingChanges({});
}
}
@@ -67,6 +49,10 @@ function EditTokenModal({ isOpen, onDone, tokenId }) {
const layout = useResponsiveLayout();
+ if (!token) {
+ return null;
+ }
+
return (
-
+
Edit token
- {isLoading || !token ? (
-
-
-
- ) : (
+
- )}
-
-
+
+
+
);
diff --git a/src/modals/GroupNameModal.js b/src/modals/GroupNameModal.js
new file mode 100644
index 0000000..f410256
--- /dev/null
+++ b/src/modals/GroupNameModal.js
@@ -0,0 +1,51 @@
+import React, { useState, useRef, useEffect } from "react";
+import { Box, Input, Button, Label, Flex } from "theme-ui";
+
+import Modal from "../components/Modal";
+
+function GroupNameModal({ isOpen, name, onSubmit, onRequestClose }) {
+ const [tmpName, setTempName] = useState(name);
+
+ useEffect(() => {
+ setTempName(name);
+ }, [name]);
+
+ function handleChange(event) {
+ setTempName(event.target.value);
+ }
+
+ function handleSubmit(event) {
+ event.preventDefault();
+ onSubmit(tmpName);
+ }
+
+ const inputRef = useRef();
+ function focusInput() {
+ inputRef.current && inputRef.current.focus();
+ }
+
+ return (
+
+
+
+ Group Name
+
+
+
+
+
+
+
+ );
+}
+
+export default GroupNameModal;
diff --git a/src/modals/ImportExportModal.js b/src/modals/ImportExportModal.js
index b6e083c..b0f92e4 100644
--- a/src/modals/ImportExportModal.js
+++ b/src/modals/ImportExportModal.js
@@ -3,6 +3,7 @@ import { Box, Label, Text, Button, Flex } from "theme-ui";
import { saveAs } from "file-saver";
import * as Comlink from "comlink";
import shortid from "shortid";
+import { v4 as uuid } from "uuid";
import { useToasts } from "react-toast-notifications";
import Modal from "../components/Modal";
@@ -10,7 +11,7 @@ import LoadingOverlay from "../components/LoadingOverlay";
import LoadingBar from "../components/LoadingBar";
import ErrorBanner from "../components/banner/ErrorBanner";
-import { useAuth } from "../contexts/AuthContext";
+import { useUserId } from "../contexts/UserIdContext";
import { useDatabase } from "../contexts/DatabaseContext";
import SelectDataModal from "./SelectDataModal";
@@ -21,7 +22,7 @@ const importDBName = "OwlbearRodeoImportDB";
function ImportExportModal({ isOpen, onRequestClose }) {
const { worker } = useDatabase();
- const { userId } = useAuth();
+ const userId = useUserId();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState();
@@ -121,7 +122,12 @@ function ImportExportModal({ isOpen, onRequestClose }) {
setShowImportSelector(false);
}
- async function handleImportSelectorConfirm(checkedMaps, checkedTokens) {
+ async function handleImportSelectorConfirm(
+ checkedMaps,
+ checkedTokens,
+ checkedMapGroups,
+ checkedTokenGroups
+ ) {
setIsLoading(true);
backgroundTaskRunningRef.current = true;
setShowImportSelector(false);
@@ -132,15 +138,36 @@ function ImportExportModal({ isOpen, onRequestClose }) {
try {
// Keep track of a mapping of old token ids to new ones to apply them to the map states
let newTokenIds = {};
+ // Mapping of old asset ids to new asset ids
+ let newAssetIds = {};
+ // Mapping of old maps ids to new map ids
+ let newMapIds = {};
if (checkedTokens.length > 0) {
const tokenIds = checkedTokens.map((token) => token.id);
const tokensToAdd = await importDB.table("tokens").bulkGet(tokenIds);
let newTokens = [];
for (let token of tokensToAdd) {
- const newId = shortid.generate();
+ // Generate new ids
+ const newId = uuid();
newTokenIds[token.id] = newId;
- // Generate new id and change owner
- newTokens.push({ ...token, id: newId, owner: userId });
+
+ if (token.type === "default") {
+ newTokens.push({ ...token, id: newId, owner: userId });
+ } else {
+ const newFileId = uuid();
+ const newThumbnailId = uuid();
+ newAssetIds[token.file] = newFileId;
+ newAssetIds[token.thumbnail] = newThumbnailId;
+
+ // Change ids and owner
+ newTokens.push({
+ ...token,
+ id: newId,
+ owner: userId,
+ file: newFileId,
+ thumbnail: newThumbnailId,
+ });
+ }
}
await db.table("tokens").bulkAdd(newTokens);
}
@@ -158,15 +185,104 @@ function ImportExportModal({ isOpen, onRequestClose }) {
state.tokens[tokenState.id].tokenId =
newTokenIds[tokenState.tokenId];
}
+ // Change token state file asset id
+ if (tokenState.type === "file" && tokenState.file in newAssetIds) {
+ state.tokens[tokenState.id].file = newAssetIds[tokenState.file];
+ }
+ // Change token state owner if owned by the user of the map
+ if (tokenState.owner === map.owner) {
+ state.tokens[tokenState.id].owner = userId;
+ }
}
- const newId = shortid.generate();
- // Generate new id and change owner
- newMaps.push({ ...map, id: newId, owner: userId });
+ // Generate new ids
+ const newId = uuid();
+ newMapIds[map.id] = newId;
+
+ if (map.type === "default") {
+ newMaps.push({ ...map, id: newId, owner: userId });
+ } else {
+ const newFileId = uuid();
+ const newThumbnailId = uuid();
+ newAssetIds[map.file] = newFileId;
+ newAssetIds[map.thumbnail] = newThumbnailId;
+ const newResolutionIds = {};
+ for (let res of Object.keys(map.resolutions)) {
+ newResolutionIds[res] = uuid();
+ newAssetIds[map.resolutions[res]] = newResolutionIds[res];
+ }
+ // Change ids and owner
+ newMaps.push({
+ ...map,
+ id: newId,
+ owner: userId,
+ file: newFileId,
+ thumbnail: newThumbnailId,
+ resolutions: newResolutionIds,
+ });
+ }
+
newStates.push({ ...state, mapId: newId });
}
await db.table("maps").bulkAdd(newMaps);
await db.table("states").bulkAdd(newStates);
}
+
+ // Add assets with new ids
+ const assetsToAdd = await importDB
+ .table("assets")
+ .bulkGet(Object.keys(newAssetIds));
+ let assets = [];
+ for (let asset of assetsToAdd) {
+ assets.push({ ...asset, id: newAssetIds[asset.id] });
+ }
+ await db.table("assets").bulkAdd(assets);
+
+ // Add map groups with new ids
+ if (checkedMapGroups.length > 0) {
+ const mapGroup = await db.table("groups").get("maps");
+ let newMapGroups = [];
+ for (let group of checkedMapGroups) {
+ if (group.type === "item") {
+ newMapGroups.push({ ...group, id: newMapIds[group.id] });
+ } else {
+ newMapGroups.push({
+ ...group,
+ id: uuid(),
+ items: group.items.map((item) => ({
+ ...item,
+ id: newMapIds[item.id],
+ })),
+ });
+ }
+ }
+ await db
+ .table("groups")
+ .update("maps", { items: [...newMapGroups, ...mapGroup.items] });
+ }
+
+ // Add token groups with new ids
+ if (checkedTokenGroups.length > 0) {
+ const tokenGroup = await db.table("groups").get("tokens");
+ let newTokenGroups = [];
+ for (let group of checkedTokenGroups) {
+ if (group.type === "item") {
+ newTokenGroups.push({ ...group, id: newTokenIds[group.id] });
+ } else {
+ newTokenGroups.push({
+ ...group,
+ id: uuid(),
+ items: group.items.map((item) => ({
+ ...item,
+ id: newTokenIds[item.id],
+ })),
+ });
+ }
+ }
+ await db.table("groups").update("tokens", {
+ items: [...newTokenGroups, ...tokenGroup.items],
+ });
+ }
+
addSuccessToast("Imported", checkedMaps, checkedTokens);
} catch (e) {
console.error(e);
@@ -179,18 +295,13 @@ function ImportExportModal({ isOpen, onRequestClose }) {
backgroundTaskRunningRef.current = false;
}
- function exportSelectorFilter(table, value) {
- // Only show owned maps and tokens
- if (table === "maps" || table === "tokens") {
- if (value.owner === userId) {
- return true;
- }
- }
- // Allow all states so tokens can be checked against maps
- if (table === "states") {
- return true;
- }
- return false;
+ function exportSelectorFilter(table) {
+ return (
+ table === "maps" ||
+ table === "tokens" ||
+ table === "states" ||
+ table === "groups"
+ );
}
async function handleExportSelectorClose() {
@@ -215,6 +326,7 @@ function ImportExportModal({ isOpen, onRequestClose }) {
saveAs(blob, `${shortid.generate()}.owlbear`);
addSuccessToast("Exported", checkedMaps, checkedTokens);
} catch (e) {
+ console.error(e);
setError(e);
}
setIsLoading(false);
diff --git a/src/modals/SelectDataModal.js b/src/modals/SelectDataModal.js
index 5cd94ff..bfe6d46 100644
--- a/src/modals/SelectDataModal.js
+++ b/src/modals/SelectDataModal.js
@@ -1,9 +1,10 @@
import React, { useEffect, useState } from "react";
-import { Box, Label, Flex, Button, Text, Checkbox, Divider } from "theme-ui";
+import { Box, Label, Flex, Button, Text, Checkbox } from "theme-ui";
import SimpleBar from "simplebar-react";
import Modal from "../components/Modal";
import LoadingOverlay from "../components/LoadingOverlay";
+import Divider from "../components/Divider";
import { getDatabase } from "../database";
@@ -17,8 +18,10 @@ function SelectDataModal({
filter,
}) {
const [maps, setMaps] = useState({});
+ const [mapGroups, setMapGroups] = useState([]);
const [tokensByMap, setTokensByMap] = useState({});
const [tokens, setTokens] = useState({});
+ const [tokenGroups, setTokenGroups] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const hasMaps = Object.values(maps).length > 0;
@@ -36,7 +39,12 @@ function SelectDataModal({
.table("maps")
.filter((map) => filter("maps", map, map.id))
.each((map) => {
- loadedMaps[map.id] = { name: map.name, id: map.id, checked: true };
+ loadedMaps[map.id] = {
+ name: map.name,
+ id: map.id,
+ type: map.type,
+ checked: true,
+ };
});
await db
.table("states")
@@ -56,17 +64,27 @@ function SelectDataModal({
loadedTokens[token.id] = {
name: token.name,
id: token.id,
+ type: token.type,
checked: true,
};
});
+
+ const mapGroup = await db.table("groups").get("maps");
+ const tokenGroup = await db.table("groups").get("tokens");
+
db.close();
setMaps(loadedMaps);
+ setMapGroups(mapGroup.items);
setTokensByMap(loadedTokensByMap);
+ setTokenGroups(tokenGroup.items);
setTokens(loadedTokens);
setIsLoading(false);
} else {
setMaps({});
setTokens({});
+ setTokenGroups([]);
+ setMapGroups([]);
+ setTokensByMap({});
}
}
loadData();
@@ -92,7 +110,7 @@ function SelectDataModal({
setTokens((prevTokens) => {
let newTokens = { ...prevTokens };
for (let id in newTokens) {
- if (id in tokensUsed) {
+ if (id in tokensUsed && newTokens[id].type !== "default") {
newTokens[id].checked = true;
}
}
@@ -100,17 +118,42 @@ function SelectDataModal({
});
}, [maps, tokensByMap]);
+ function getCheckedGroups(groups, data) {
+ let checkedGroups = [];
+ for (let group of groups) {
+ if (group.type === "item") {
+ if (data[group.id] && data[group.id].checked) {
+ checkedGroups.push(group);
+ }
+ } else {
+ let items = [];
+ for (let item of group.items) {
+ if (data[item.id] && data[item.id].checked) {
+ items.push(item);
+ }
+ }
+ if (items.length > 0) {
+ checkedGroups.push({ ...group, items });
+ }
+ }
+ }
+ return checkedGroups;
+ }
+
function handleConfirm() {
let checkedMaps = Object.values(maps).filter((map) => map.checked);
let checkedTokens = Object.values(tokens).filter((token) => token.checked);
- onConfirm(checkedMaps, checkedTokens);
+ let checkedMapGroups = getCheckedGroups(mapGroups, maps);
+ let checkedTokenGroups = getCheckedGroups(tokenGroups, tokens);
+
+ onConfirm(checkedMaps, checkedTokens, checkedMapGroups, checkedTokenGroups);
}
- function handleSelectMapsChanged(event) {
+ function handleMapsChanged(event, maps) {
setMaps((prevMaps) => {
let newMaps = { ...prevMaps };
- for (let id in newMaps) {
- newMaps[id].checked = event.target.checked;
+ for (let map of maps) {
+ newMaps[map.id].checked = event.target.checked;
}
return newMaps;
});
@@ -118,26 +161,17 @@ function SelectDataModal({
if (!event.target.checked && !tokensSelectChecked) {
setTokens((prevTokens) => {
let newTokens = { ...prevTokens };
+ let tempUsedCount = { ...tokenUsedCount };
for (let id in newTokens) {
- newTokens[id].checked = false;
- }
- return newTokens;
- });
- }
- }
-
- function handleMapChange(event, map) {
- setMaps((prevMaps) => ({
- ...prevMaps,
- [map.id]: { ...map, checked: event.target.checked },
- }));
- // If all token select is unchecked then ensure tokens assosiated to this map are unchecked
- if (!event.target.checked && !tokensSelectChecked) {
- setTokens((prevTokens) => {
- let newTokens = { ...prevTokens };
- for (let id in newTokens) {
- if (tokensByMap[map.id].has(id) && tokenUsedCount[id] === 1) {
- newTokens[id].checked = false;
+ for (let map of maps) {
+ if (tokensByMap[map.id].has(id)) {
+ if (tempUsedCount[id] > 1) {
+ tempUsedCount[id] -= 1;
+ } else if (tempUsedCount[id] === 1) {
+ tempUsedCount[id] = 0;
+ newTokens[id].checked = false;
+ }
+ }
}
}
return newTokens;
@@ -145,31 +179,140 @@ function SelectDataModal({
}
}
- function handleSelectTokensChange(event) {
+ function handleTokensChanged(event, tokens) {
setTokens((prevTokens) => {
let newTokens = { ...prevTokens };
- for (let id in newTokens) {
- if (!(id in tokenUsedCount)) {
- newTokens[id].checked = event.target.checked;
+ for (let token of tokens) {
+ if (!(token.id in tokenUsedCount) || token.type === "default") {
+ newTokens[token.id].checked = event.target.checked;
}
}
return newTokens;
});
}
- function handleTokenChange(event, token) {
- setTokens((prevTokens) => ({
- ...prevTokens,
- [token.id]: { ...token, checked: event.target.checked },
- }));
- }
-
// Some tokens are checked not by maps or all tokens are checked by maps
const tokensSelectChecked =
Object.values(tokens).some(
(token) => !(token.id in tokenUsedCount) && token.checked
) || Object.values(tokens).every((token) => token.id in tokenUsedCount);
+ function renderGroupContainer(group, checked, renderItem, onGroupChange) {
+ return (
+
+
+ onGroupChange(e, group)}
+ />
+ {group.name}
+
+
+ {group.items.map(renderItem)}
+
+
+
+
+
+ );
+ }
+
+ function renderMapGroup(group) {
+ if (group.type === "item") {
+ const map = maps[group.id];
+ if (map) {
+ return (
+
+ handleMapsChanged(e, [map])}
+ />
+ {map.name}
+
+ );
+ }
+ } else {
+ if (group.items.some((item) => item.id in maps)) {
+ return renderGroupContainer(
+ group,
+ group.items.some((item) => maps[item.id]?.checked),
+ renderMapGroup,
+ (e, group) =>
+ handleMapsChanged(
+ e,
+ group.items
+ .filter((group) => group.id in maps)
+ .map((group) => maps[group.id])
+ )
+ );
+ }
+ }
+ }
+
+ function renderTokenGroup(group) {
+ if (group.type === "item") {
+ const token = tokens[group.id];
+ if (token) {
+ return (
+
+
+ handleTokensChanged(e, [token])}
+ disabled={
+ token.type !== "default" && token.id in tokenUsedCount
+ }
+ />
+ {token.name}
+
+ {token.id in tokenUsedCount && token.type !== "default" && (
+
+ Token used in {tokenUsedCount[token.id]} selected map
+ {tokenUsedCount[token.id] > 1 && "s"}
+
+ )}
+
+ );
+ }
+ } else {
+ if (group.items.some((item) => item.id in tokens)) {
+ const checked =
+ group.items.some(
+ (item) => !(item.id in tokenUsedCount) && tokens[item.id]?.checked
+ ) || group.items.every((item) => item.id in tokenUsedCount);
+ return renderGroupContainer(
+ group,
+ checked,
+ renderTokenGroup,
+ (e, group) =>
+ handleTokensChanged(
+ e,
+ group.items
+ .filter((group) => group.id in tokens)
+ .map((group) => tokens[group.id])
+ )
+ );
+ }
+ }
+ }
+
return (
map.checked)}
- onChange={handleSelectMapsChanged}
+ onChange={(e) =>
+ handleMapsChanged(e, Object.values(maps))
+ }
/>
Maps
- {Object.values(maps).map((map) => (
-
- handleMapChange(e, map)}
- />
- {map.name}
-
- ))}
+ {mapGroups.map(renderMapGroup)}
>
)}
- {hasMaps && hasTokens && }
+ {hasMaps && hasTokens && }
{hasTokens && (
<>
+ handleTokensChanged(e, Object.values(tokens))
+ }
/>
Tokens
- {Object.values(tokens).map((token) => (
-
-
- handleTokenChange(e, token)}
- disabled={token.id in tokenUsedCount}
- />
- {token.name}
-
- {token.id in tokenUsedCount && (
-
- Token used in {tokenUsedCount[token.id]} selected map
- {tokenUsedCount[token.id] > 1 && "s"}
-
- )}
-
- ))}
+ {tokenGroups.map(renderTokenGroup)}
>
)}
diff --git a/src/modals/SelectMapModal.js b/src/modals/SelectMapModal.js
index 517bc45..46a69a1 100644
--- a/src/modals/SelectMapModal.js
+++ b/src/modals/SelectMapModal.js
@@ -1,53 +1,34 @@
-import React, { useRef, useState } from "react";
-import { Button, Flex, Label } from "theme-ui";
-import shortid from "shortid";
-import Case from "case";
+import React, { useRef, useState, useEffect } from "react";
+import { Flex, Label, Box } from "theme-ui";
import { useToasts } from "react-toast-notifications";
+import ReactResizeDetector from "react-resize-detector";
import EditMapModal from "./EditMapModal";
-import EditGroupModal from "./EditGroupModal";
import ConfirmModal from "./ConfirmModal";
import Modal from "../components/Modal";
-import MapTiles from "../components/map/MapTiles";
-import ImageDrop from "../components/ImageDrop";
import LoadingOverlay from "../components/LoadingOverlay";
-import blobToBuffer from "../helpers/blobToBuffer";
-import { resizeImage, createThumbnail } from "../helpers/image";
-import { useSearch, useGroup, handleItemSelect } from "../helpers/select";
-import {
- getGridDefaultInset,
- getGridSizeFromImage,
- gridSizeVaild,
-} from "../helpers/grid";
-import Vector2 from "../helpers/Vector2";
+import ImageDrop from "../components/image/ImageDrop";
+
+import MapTiles from "../components/map/MapTiles";
+import MapEditBar from "../components/map/MapEditBar";
+import SelectMapSelectButton from "../components/map/SelectMapSelectButton";
+
+import TilesOverlay from "../components/tile/TilesOverlay";
+import TilesContainer from "../components/tile/TilesContainer";
+import TileActionBar from "../components/tile/TileActionBar";
+
+import { findGroup, getItemNames } from "../helpers/group";
+import { createMapFromFile } from "../helpers/map";
import useResponsiveLayout from "../hooks/useResponsiveLayout";
import { useMapData } from "../contexts/MapDataContext";
-import { useAuth } from "../contexts/AuthContext";
-import { useKeyboard, useBlur } from "../contexts/KeyboardContext";
-
-import shortcuts from "../shortcuts";
-
-const defaultMapProps = {
- showGrid: false,
- snapToGrid: true,
- quality: "original",
- group: "",
-};
-
-const mapResolutions = [
- {
- size: 30, // Pixels per grid
- quality: 0.5, // JPEG compression quality
- id: "low",
- },
- { size: 70, quality: 0.6, id: "medium" },
- { size: 140, quality: 0.7, id: "high" },
- { size: 300, quality: 0.8, id: "ultra" },
-];
+import { useUserId } from "../contexts/UserIdContext";
+import { useAssets } from "../contexts/AssetsContext";
+import { GroupProvider } from "../contexts/GroupContext";
+import { TileDragProvider } from "../contexts/TileDragContext";
function SelectMapModal({
isOpen,
@@ -59,48 +40,27 @@ function SelectMapModal({
}) {
const { addToast } = useToasts();
- const { userId } = useAuth();
+ const userId = useUserId();
const {
- ownedMaps,
+ maps,
mapStates,
+ mapGroups,
addMap,
- removeMaps,
- resetMap,
- updateMap,
- updateMaps,
mapsLoading,
- getMapFromDB,
- getMapStateFromDB,
+ getMapState,
+ getMap,
+ updateMapGroups,
+ updateMap,
+ updateMapState,
+ mapsById,
} = useMapData();
+ const { addAssets } = useAssets();
- /**
- * Search
- */
- const [search, setSearch] = useState("");
- const [filteredMaps, filteredMapScores] = useSearch(ownedMaps, search);
-
- function handleSearchChange(event) {
- setSearch(event.target.value);
- }
-
- /**
- * Group
- */
- const [isGroupModalOpen, setIsGroupModalOpen] = useState(false);
-
- async function handleMapsGroup(group) {
- setIsLoading(true);
- setIsGroupModalOpen(false);
- await updateMaps(selectedMapIds, { group });
- setIsLoading(false);
- }
-
- const [mapsByGroup, mapGroups] = useGroup(
- ownedMaps,
- filteredMaps,
- !!search,
- filteredMapScores
- );
+ // Get map names for group filtering
+ const [mapNames, setMapNames] = useState(getItemNames(maps));
+ useEffect(() => {
+ setMapNames(getItemNames(maps));
+ }, [maps]);
/**
* Image Upload
@@ -150,6 +110,12 @@ function SelectMapModal({
}
}
+ function openImageDialog() {
+ if (fileInputRef.current) {
+ fileInputRef.current.click();
+ }
+ }
+
function handleLargeImageWarningCancel() {
largeImageWarningFiles.current = undefined;
setShowLargeImageWarning(false);
@@ -167,196 +133,13 @@ function SelectMapModal({
}
async function handleImageUpload(file) {
- if (!file) {
- return Promise.reject();
- }
- let image = new Image();
setIsLoading(true);
-
- const buffer = await blobToBuffer(file);
- // Copy file to avoid permissions issues
- const blob = new Blob([buffer]);
- // Create and load the image temporarily to get its dimensions
- const url = URL.createObjectURL(blob);
-
- return new Promise((resolve, reject) => {
- image.onload = async function () {
- // Find name and grid size
- let gridSize;
- let name = "Unknown Map";
- if (file.name) {
- if (file.name.matchAll) {
- // Match against a regex to find the grid size in the file name
- // e.g. Cave 22x23 will return [["22x22", "22", "x", "23"]]
- const gridMatches = [...file.name.matchAll(/(\d+) ?(x|X) ?(\d+)/g)];
- for (let match of gridMatches) {
- const matchX = parseInt(match[1]);
- const matchY = parseInt(match[3]);
- if (
- !isNaN(matchX) &&
- !isNaN(matchY) &&
- gridSizeVaild(matchX, matchY)
- ) {
- gridSize = { x: matchX, y: matchY };
- }
- }
- }
-
- if (!gridSize) {
- gridSize = await getGridSizeFromImage(image);
- }
-
- // Remove file extension
- name = file.name.replace(/\.[^/.]+$/, "");
- // Removed grid size expression
- name = name.replace(/(\[ ?|\( ?)?\d+ ?(x|X) ?\d+( ?\]| ?\))?/, "");
- // Clean string
- name = name.replace(/ +/g, " ");
- name = name.trim();
- // Capitalize and remove underscores
- name = Case.capital(name);
- }
-
- if (!gridSize) {
- gridSize = { x: 22, y: 22 };
- }
-
- // Create resolutions
- const resolutions = {};
- for (let resolution of mapResolutions) {
- const resolutionPixelSize = Vector2.multiply(
- gridSize,
- resolution.size
- );
- if (
- image.width >= resolutionPixelSize.x &&
- image.height >= resolutionPixelSize.y
- ) {
- const resized = await resizeImage(
- image,
- Vector2.max(resolutionPixelSize),
- file.type,
- resolution.quality
- );
- if (resized.blob) {
- const resizedBuffer = await blobToBuffer(resized.blob);
- resolutions[resolution.id] = {
- file: resizedBuffer,
- width: resized.width,
- height: resized.height,
- type: "file",
- id: resolution.id,
- };
- }
- }
- }
- // Create thumbnail
- const thumbnail = await createThumbnail(image, file.type);
-
- handleMapAdd({
- // Save as a buffer to send with msgpack
- file: buffer,
- resolutions,
- thumbnail,
- name,
- type: "file",
- grid: {
- size: gridSize,
- inset: getGridDefaultInset(
- { size: gridSize, type: "square" },
- image.width,
- image.height
- ),
- type: "square",
- measurement: {
- type: "chebyshev",
- scale: "5ft",
- },
- },
- width: image.width,
- height: image.height,
- id: shortid.generate(),
- created: Date.now(),
- lastModified: Date.now(),
- lastUsed: Date.now(),
- owner: userId,
- ...defaultMapProps,
- });
- setIsLoading(false);
- URL.revokeObjectURL(url);
- resolve();
- };
- image.onerror = reject;
- image.src = url;
- });
- }
-
- function openImageDialog() {
- if (fileInputRef.current) {
- fileInputRef.current.click();
- }
- }
-
- /**
- * Map Controls
- */
- const [isEditModalOpen, setIsEditModalOpen] = useState(false);
- // The map selected in the modal
- const [selectedMapIds, setSelectedMapIds] = useState([]);
-
- const selectedMaps = ownedMaps.filter((map) =>
- selectedMapIds.includes(map.id)
- );
- const selectedMapStates = mapStates.filter((state) =>
- selectedMapIds.includes(state.mapId)
- );
-
- async function handleMapAdd(map) {
+ const { map, assets } = await createMapFromFile(file, userId);
await addMap(map);
- setSelectedMapIds([map.id]);
- }
-
- const [isMapsRemoveModalOpen, setIsMapsRemoveModalOpen] = useState(false);
- async function handleMapsRemove() {
- setIsLoading(true);
- setIsMapsRemoveModalOpen(false);
- await removeMaps(selectedMapIds);
- setSelectedMapIds([]);
- // Removed the map from the map screen if needed
- if (currentMap && selectedMapIds.includes(currentMap.id)) {
- onMapChange(null, null);
- }
+ await addAssets(assets);
setIsLoading(false);
}
- const [isMapsResetModalOpen, setIsMapsResetModalOpen] = useState(false);
- async function handleMapsReset() {
- setIsLoading(true);
- setIsMapsResetModalOpen(false);
- for (let id of selectedMapIds) {
- const newState = await resetMap(id);
- // Reset the state of the current map if needed
- if (currentMap && currentMap.id === id) {
- onMapReset(newState);
- }
- }
- setIsLoading(false);
- }
-
- // Either single, multiple or range
- const [selectMode, setSelectMode] = useState("single");
-
- function handleMapSelect(map) {
- handleItemSelect(
- map,
- selectMode,
- selectedMapIds,
- setSelectedMapIds,
- mapsByGroup,
- mapGroups
- );
- }
-
/**
* Modal Controls
*/
@@ -365,171 +148,148 @@ function SelectMapModal({
onDone();
}
- async function handleDone() {
+ /**
+ * Map Controls
+ */
+ async function handleMapSelect(mapId) {
if (isLoading) {
return;
}
- if (selectedMapIds.length === 1) {
- // Update last used for cache invalidation
- const lastUsed = Date.now();
- const map = selectedMaps[0];
- const mapState = await getMapStateFromDB(map.id);
- if (map.type === "file") {
- setIsLoading(true);
- await updateMap(map.id, { lastUsed });
- const updatedMap = await getMapFromDB(map.id);
- onMapChange(updatedMap, mapState);
- setIsLoading(false);
- } else {
- onMapChange(map, mapState);
- }
+ if (mapId) {
+ setIsLoading(true);
+ const map = await getMap(mapId);
+ const mapState = await getMapState(mapId);
+ onMapChange(map, mapState);
+ setIsLoading(false);
} else {
onMapChange(null, null);
}
onDone();
}
- /**
- * Shortcuts
- */
- function handleKeyDown(event) {
- if (!isOpen) {
- return;
- }
- if (shortcuts.selectRange(event)) {
- setSelectMode("range");
- }
- if (shortcuts.selectMultiple(event)) {
- setSelectMode("multiple");
- }
- if (shortcuts.delete(event)) {
- // Selected maps and none are default
- if (
- selectedMapIds.length > 0 &&
- !selectedMaps.some((map) => map.type === "default")
- ) {
- // Ensure all other modals are closed
- setIsGroupModalOpen(false);
- setIsEditModalOpen(false);
- setIsMapsResetModalOpen(false);
- setIsMapsRemoveModalOpen(true);
- }
+ const [editingMapId, setEditingMapId] = useState();
+
+ const [isDraggingMap, setIsDraggingMap] = useState(false);
+
+ const [canAddDraggedMap, setCanAddDraggedMap] = useState(false);
+ function handleGroupsSelect(groupIds) {
+ if (groupIds.length === 1) {
+ // Only allow adding a map from dragging if there is a single group item selected
+ const group = findGroup(mapGroups, groupIds[0]);
+ setCanAddDraggedMap(group && group.type === "item");
+ } else {
+ setCanAddDraggedMap(false);
}
}
- function handleKeyUp(event) {
- if (!isOpen) {
- return;
- }
- if (shortcuts.selectRange(event) && selectMode === "range") {
- setSelectMode("single");
- }
- if (shortcuts.selectMultiple(event) && selectMode === "multiple") {
- setSelectMode("single");
+ function handleDragAdd(groupIds) {
+ if (groupIds.length === 1) {
+ handleMapSelect(groupIds[0]);
}
}
- useKeyboard(handleKeyDown, handleKeyUp);
-
- // Set select mode to single when cmd+tabing
- function handleBlur() {
- setSelectMode("single");
- }
-
- useBlur(handleBlur);
-
const layout = useResponsiveLayout();
+ const [modalSize, setModalSize] = useState({ width: 0, height: 0 });
+ function handleModalResize(width, height) {
+ setModalSize({ width, height });
+ }
+
return (
-
+
handleImagesUpload(event.target.files)}
type="file"
- accept="image/*"
+ accept="image/jpeg, image/gif, image/png, image/webp"
style={{ display: "none" }}
multiple
ref={fileInputRef}
/>
-
-
- Select or import a map
-
- setIsEditModalOpen(true)}
- onMapsReset={() => setIsMapsResetModalOpen(true)}
- onMapsRemove={() => setIsMapsRemoveModalOpen(true)}
- selectedMaps={selectedMaps}
- selectedMapStates={selectedMapStates}
- onMapSelect={handleMapSelect}
- onDone={handleDone}
- selectMode={selectMode}
- onSelectModeChange={setSelectMode}
- search={search}
- onSearchChange={handleSearchChange}
- onMapsGroup={() => setIsGroupModalOpen(true)}
- />
-
-
+
+
+ Select or import a map
+
+
+
+ setIsDraggingMap(true)}
+ onDragEnd={() => setIsDraggingMap(false)}
+ onDragCancel={() => setIsDraggingMap(false)}
+ >
+
+
+
+
+ setIsDraggingMap(true)}
+ onDragEnd={() => setIsDraggingMap(false)}
+ onDragCancel={() => setIsDraggingMap(false)}
+ >
+
+
+
+
+
+
+
+
+
+
{(isLoading || mapsLoading) && }
setIsEditModalOpen(false)}
- mapId={selectedMaps.length === 1 && selectedMaps[0].id}
- />
- group !== "" && group !== "default"
- )}
- onRequestClose={() => setIsGroupModalOpen(false)}
- // Select the default group by testing whether all selected maps are the same
- defaultGroup={
- selectedMaps.length > 0 &&
- selectedMaps
- .map((map) => map.group)
- .reduce((prev, curr) => (prev === curr ? curr : undefined))
+ isOpen={!!editingMapId}
+ onDone={() => setEditingMapId()}
+ map={editingMapId && maps.find((map) => map.id === editingMapId)}
+ mapState={
+ editingMapId &&
+ mapStates.find((state) => state.mapId === editingMapId)
}
- />
- setIsMapsResetModalOpen(false)}
- onConfirm={handleMapsReset}
- confirmText="Reset"
- label={`Reset ${selectedMapIds.length} Map${
- selectedMapIds.length > 1 ? "s" : ""
- }`}
- description="This will remove all fog, drawings and tokens from the selected maps."
- />
- setIsMapsRemoveModalOpen(false)}
- onConfirm={handleMapsRemove}
- confirmText="Remove"
- label={`Remove ${selectedMapIds.length} Map${
- selectedMapIds.length > 1 ? "s" : ""
- }`}
- description="This operation cannot be undone."
+ onUpdateMap={updateMap}
+ onUpdateMapState={updateMapState}
/>
{
+ setTokenNames(getItemNames(tokens));
+ }, [tokens]);
/**
* Image Upload
@@ -78,12 +68,6 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
);
const largeImageWarningFiles = useRef();
- function openImageDialog() {
- if (fileInputRef.current) {
- fileInputRef.current.click();
- }
- }
-
async function handleImagesUpload(files) {
if (navigator.storage) {
// Attempt to enable persistant storage
@@ -120,6 +104,12 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
}
}
+ function openImageDialog() {
+ if (fileInputRef.current) {
+ fileInputRef.current.click();
+ }
+ }
+
function handleLargeImageWarningCancel() {
largeImageWarningFiles.current = undefined;
setShowLargeImageWarning(false);
@@ -137,227 +127,167 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
}
async function handleImageUpload(file) {
- let name = "Unknown Token";
- if (file.name) {
- // Remove file extension
- name = file.name.replace(/\.[^/.]+$/, "");
- // Removed grid size expression
- name = name.replace(/(\[ ?|\( ?)?\d+ ?(x|X) ?\d+( ?\]| ?\))?/, "");
- // Clean string
- name = name.replace(/ +/g, " ");
- name = name.trim();
- // Capitalize and remove underscores
- name = Case.capital(name);
- }
- let image = new Image();
setIsLoading(true);
- const buffer = await blobToBuffer(file);
-
- // Copy file to avoid permissions issues
- const blob = new Blob([buffer]);
- // Create and load the image temporarily to get its dimensions
- const url = URL.createObjectURL(blob);
-
- return new Promise((resolve, reject) => {
- image.onload = async function () {
- const thumbnail = await createThumbnail(image, file.type);
-
- handleTokenAdd({
- file: buffer,
- thumbnail,
- name,
- id: shortid.generate(),
- type: "file",
- created: Date.now(),
- lastModified: Date.now(),
- lastUsed: Date.now(),
- owner: userId,
- defaultSize: 1,
- category: "character",
- hideInSidebar: false,
- group: "",
- width: image.width,
- height: image.height,
- });
- setIsLoading(false);
- resolve();
- };
- image.onerror = reject;
- image.src = url;
- });
+ const { token, assets } = await createTokenFromFile(file, userId);
+ await addToken(token);
+ await addAssets(assets);
+ setIsLoading(false);
}
/**
* Token controls
*/
- const [isEditModalOpen, setIsEditModalOpen] = useState(false);
- const [selectedTokenIds, setSelectedTokenIds] = useState([]);
- const selectedTokens = ownedTokens.filter((token) =>
- selectedTokenIds.includes(token.id)
- );
+ const [editingTokenId, setEditingTokenId] = useState();
- function handleTokenAdd(token) {
- addToken(token);
- setSelectedTokenIds([token.id]);
- }
+ const [isDraggingToken, setIsDraggingToken] = useState(false);
- const [isTokensRemoveModalOpen, setIsTokensRemoveModalOpen] = useState(false);
- async function handleTokensRemove() {
- setIsLoading(true);
- setIsTokensRemoveModalOpen(false);
- await removeTokens(selectedTokenIds);
- setSelectedTokenIds([]);
- setIsLoading(false);
- }
-
- async function handleTokensHide(hideInSidebar) {
- setIsLoading(true);
- await updateTokens(selectedTokenIds, { hideInSidebar });
- setIsLoading(false);
- }
-
- // Either single, multiple or range
- const [selectMode, setSelectMode] = useState("single");
-
- async function handleTokenSelect(token) {
- handleItemSelect(
- token,
- selectMode,
- selectedTokenIds,
- setSelectedTokenIds,
- tokensByGroup,
- tokenGroups
+ const mapStageRef = useMapStage();
+ function handleTokensAddToMap(groupIds, rect) {
+ let clientPosition = new Vector2(
+ rect.width / 2 + rect.left,
+ rect.height / 2 + rect.top
);
- }
-
- /**
- * Shortcuts
- */
- function handleKeyDown(event) {
- if (!isOpen) {
+ const mapStage = mapStageRef.current;
+ if (!mapStage) {
return;
}
- if (shortcuts.selectRange(event)) {
- setSelectMode("range");
+
+ let position = clientPositionToMapPosition(mapStage, clientPosition, false);
+ if (!position) {
+ return;
}
- if (shortcuts.selectMultiple(event)) {
- setSelectMode("multiple");
- }
- if (shortcuts.delete(event)) {
- // Selected tokens and none are default
- if (
- selectedTokenIds.length > 0 &&
- !selectedTokens.some((token) => token.type === "default")
- ) {
- // Ensure all other modals are closed
- setIsEditModalOpen(false);
- setIsGroupModalOpen(false);
- setIsTokensRemoveModalOpen(true);
+
+ let newTokenStates = [];
+
+ for (let id of groupIds) {
+ if (id in tokensById) {
+ newTokenStates.push(createTokenState(tokensById[id], position, userId));
+ position = Vector2.add(position, 0.01);
+ } else {
+ // Check if a group is selected
+ const group = tokenGroups.find(
+ (group) => group.id === id && group.type === "group"
+ );
+ if (group) {
+ // Add all tokens of group
+ const items = getGroupItems(group);
+ for (let item of items) {
+ if (item.id in tokensById) {
+ newTokenStates.push(
+ createTokenState(tokensById[item.id], position, userId)
+ );
+ position = Vector2.add(position, 0.01);
+ }
+ }
+ }
}
}
- }
- function handleKeyUp(event) {
- if (!isOpen) {
- return;
- }
- if (shortcuts.selectRange(event) && selectMode === "range") {
- setSelectMode("single");
- }
- if (shortcuts.selectMultiple(event) && selectMode === "multiple") {
- setSelectMode("single");
+ if (newTokenStates.length > 0) {
+ onMapTokensStateCreate(newTokenStates);
}
}
- useKeyboard(handleKeyDown, handleKeyUp);
-
- // Set select mode to single when cmd+tabing
- function handleBlur() {
- setSelectMode("single");
- }
-
- useBlur(handleBlur);
-
const layout = useResponsiveLayout();
+ const [modalSize, setModalSize] = useState({ width: 0, height: 0 });
+ function handleModalResize(width, height) {
+ setModalSize({ width, height });
+ }
+
return (
-
+
handleImagesUpload(event.target.files)}
type="file"
- accept="image/*"
+ accept="image/jpeg, image/gif, image/png, image/webp"
style={{ display: "none" }}
ref={fileInputRef}
multiple
/>
-
-
- Edit or import a token
-
- setIsEditModalOpen(true)}
- onTokensRemove={() => setIsTokensRemoveModalOpen(true)}
- selectedTokens={selectedTokens}
- onTokenSelect={handleTokenSelect}
- selectMode={selectMode}
- onSelectModeChange={setSelectMode}
- search={search}
- onSearchChange={handleSearchChange}
- onTokensGroup={() => setIsGroupModalOpen(true)}
- onTokensHide={handleTokensHide}
- />
-
-
+
+
+ Edit or import a token
+
+
+
+ setIsDraggingToken(true)}
+ onDragEnd={() => setIsDraggingToken(false)}
+ onDragCancel={() => setIsDraggingToken(false)}
+ >
+
+
+
+
+ setIsDraggingToken(true)}
+ onDragEnd={() => setIsDraggingToken(false)}
+ onDragCancel={() => setIsDraggingToken(false)}
+ >
+
+
+
+
+
+
+
+
+
+
{(isLoading || tokensLoading) && }
setIsEditModalOpen(false)}
- tokenId={selectedTokens.length === 1 && selectedTokens[0].id}
- />
- group !== "" && group !== "default"
- )}
- onRequestClose={() => setIsGroupModalOpen(false)}
- // Select the default group by testing whether all selected tokens are the same
- defaultGroup={
- selectedTokens.length > 0 &&
- selectedTokens
- .map((map) => map.group)
- .reduce((prev, curr) => (prev === curr ? curr : undefined))
+ isOpen={!!editingTokenId}
+ onDone={() => setEditingTokenId()}
+ token={
+ editingTokenId && tokens.find((token) => token.id === editingTokenId)
}
- />
- setIsTokensRemoveModalOpen(false)}
- onConfirm={handleTokensRemove}
- confirmText="Remove"
- label={`Remove ${selectedTokenIds.length} Token${
- selectedTokenIds.length > 1 ? "s" : ""
- }`}
- description="This operation cannot be undone."
+ onUpdateToken={updateToken}
/>
MAX_BUFFER_SIZE) {
- const chunks = this.chunk(packedData);
- for (let chunk of chunks) {
- if (this.dataChannels[channel]) {
- this.dataChannels[channel].write(encode(chunk));
- } else {
- this.write(encode(chunk));
- }
- }
- return;
- } else {
+ const chunks = this.chunk(packedData, chunkId);
+ for (let chunk of chunks) {
if (this.dataChannels[channel]) {
- this.dataChannels[channel].write(packedData);
+ this.dataChannels[channel].write(encode(chunk));
} else {
- this.write(packedData);
+ this.write(encode(chunk));
}
}
} catch (error) {
@@ -100,11 +96,17 @@ class Connection extends SimplePeer {
}
// Converted from https://github.com/peers/peerjs/
- chunk(data) {
+ /**
+ * Chunk byte array
+ * @param {Uint8Array} data
+ * @param {string=} chunkId
+ * @returns {Uint8Array[]}
+ */
+ chunk(data, chunkId) {
const chunks = [];
const size = data.byteLength;
const total = Math.ceil(size / MAX_BUFFER_SIZE);
- const id = shortid.generate();
+ const id = chunkId || shortid.generate();
let index = 0;
let start = 0;
diff --git a/src/network/NetworkedMapAndTokens.js b/src/network/NetworkedMapAndTokens.js
index d38413a..9caf9b3 100644
--- a/src/network/NetworkedMapAndTokens.js
+++ b/src/network/NetworkedMapAndTokens.js
@@ -1,12 +1,12 @@
import React, { useState, useEffect, useRef } from "react";
import { useToasts } from "react-toast-notifications";
-import { useTokenData } from "../contexts/TokenDataContext";
import { useMapData } from "../contexts/MapDataContext";
import { useMapLoading } from "../contexts/MapLoadingContext";
-import { useAuth } from "../contexts/AuthContext";
+import { useUserId } from "../contexts/UserIdContext";
import { useDatabase } from "../contexts/DatabaseContext";
import { useParty } from "../contexts/PartyContext";
+import { useAssets } from "../contexts/AssetsContext";
import { omit } from "../helpers/shared";
@@ -18,7 +18,9 @@ import useNetworkedState from "../hooks/useNetworkedState";
import Session from "./Session";
import Map from "../components/map/Map";
-import Tokens from "../components/token/Tokens";
+import TokenBar from "../components/token/TokenBar";
+
+import GlobalImageDrop from "../components/image/GlobalImageDrop";
const defaultMapActions = {
mapDrawActions: [],
@@ -37,17 +39,12 @@ const defaultMapActions = {
*/
function NetworkedMapAndTokens({ session }) {
const { addToast } = useToasts();
- const { userId } = useAuth();
+ const userId = useUserId();
const partyState = useParty();
- const {
- assetLoadStart,
- assetLoadFinish,
- assetProgressUpdate,
- isLoading,
- } = useMapLoading();
+ const { assetLoadStart, assetProgressUpdate, isLoading } = useMapLoading();
- const { putToken, getTokenFromDB } = useTokenData();
- const { putMap, updateMap, getMapFromDB, updateMapState } = useMapData();
+ const { updateMapState } = useMapData();
+ const { getAsset, putAsset } = useAssets();
const [currentMap, setCurrentMap] = useState(null);
const [currentMapState, setCurrentMapState] = useNetworkedState(
@@ -69,56 +66,41 @@ function NetworkedMapAndTokens({ session }) {
async function loadAssetManifestFromMap(map, mapState) {
const assets = {};
- if (map.type === "file") {
- const { id, lastModified, owner } = map;
- assets[`map-${id}`] = { type: "map", id, lastModified, owner };
- }
+ const { owner } = map;
let processedTokens = new Set();
for (let tokenState of Object.values(mapState.tokens)) {
- const token = await getTokenFromDB(tokenState.tokenId);
- if (
- token &&
- token.type === "file" &&
- !processedTokens.has(tokenState.tokenId)
- ) {
- processedTokens.add(tokenState.tokenId);
- // Omit file from token peer will request file if needed
- const { id, lastModified, owner } = token;
- assets[`token-${id}`] = { type: "token", id, lastModified, owner };
+ if (tokenState.file && !processedTokens.has(tokenState.file)) {
+ processedTokens.add(tokenState.file);
+ assets[tokenState.file] = {
+ id: tokenState.file,
+ owner: tokenState.owner,
+ };
+ }
+ }
+ if (map.type === "file") {
+ assets[map.thumbnail] = { id: map.thumbnail, owner };
+ const qualityId = map.resolutions[map.quality];
+ if (qualityId) {
+ assets[qualityId] = { id: qualityId, owner };
+ } else {
+ assets[map.file] = { id: map.file, owner };
}
}
setAssetManifest({ mapId: map.id, assets }, true, true);
}
- function compareAssets(a, b) {
- return a.type === b.type && a.id === b.id;
- }
-
- // Return true if an asset is out of date
- function assetNeedsUpdate(oldAsset, newAsset) {
- return (
- compareAssets(oldAsset, newAsset) &&
- oldAsset.lastModified < newAsset.lastModified
- );
- }
-
- function addAssetIfNeeded(asset) {
+ function addAssetsIfNeeded(assets) {
setAssetManifest((prevManifest) => {
if (prevManifest?.assets) {
- const id =
- asset.type === "map" ? `map-${asset.id}` : `token-${asset.id}`;
- const exists = id in prevManifest.assets;
- const needsUpdate =
- exists && assetNeedsUpdate(prevManifest.assets[id], asset);
- if (!exists || needsUpdate) {
- return {
- ...prevManifest,
- assets: {
- ...prevManifest.assets,
- [id]: asset,
- },
- };
+ let newAssets = { ...prevManifest.assets };
+ for (let asset of assets) {
+ const id = asset.id;
+ const exists = id in newAssets;
+ if (!exists) {
+ newAssets[id] = asset;
+ }
}
+ return { ...prevManifest, assets: newAssets };
}
return prevManifest;
});
@@ -128,7 +110,7 @@ function NetworkedMapAndTokens({ session }) {
const requestingAssetsRef = useRef(new Set());
useEffect(() => {
- if (!assetManifest) {
+ if (!assetManifest || !userId) {
return;
}
@@ -145,33 +127,25 @@ function NetworkedMapAndTokens({ session }) {
(player) => player.userId === asset.owner
);
+ // Ensure requests are added before any async operation to prevent them from sending twice
+ requestingAssetsRef.current.add(asset.id);
+
+ const cachedAsset = await getAsset(asset.id);
if (!owner) {
- // Add no owner toast if asset is a map and we don't have it in out cache
- if (asset.type === "map") {
- const cachedMap = await getMapFromDB(asset.id);
- if (!cachedMap) {
- addToast("Unable to find owner for map");
- }
+ // Add no owner toast if we don't have asset in out cache
+ if (!cachedAsset) {
+ // TODO: Stop toast from appearing multiple times
+ addToast("Unable to find owner for asset");
}
+ requestingAssetsRef.current.delete(asset.id);
continue;
}
- requestingAssetsRef.current.add(asset.id);
-
- if (asset.type === "map") {
- const cachedMap = await getMapFromDB(asset.id);
- if (cachedMap && cachedMap.lastModified === asset.lastModified) {
- requestingAssetsRef.current.delete(asset.id);
- } else {
- session.sendTo(owner.sessionId, "mapRequest", asset.id);
- }
- } else if (asset.type === "token") {
- const cachedToken = await getTokenFromDB(asset.id);
- if (cachedToken && cachedToken.lastModified === asset.lastModified) {
- requestingAssetsRef.current.delete(asset.id);
- } else {
- session.sendTo(owner.sessionId, "tokenRequest", asset.id);
- }
+ if (cachedAsset) {
+ requestingAssetsRef.current.delete(asset.id);
+ } else {
+ assetLoadStart(asset.id);
+ session.sendTo(owner.sessionId, "assetRequest", asset);
}
}
}
@@ -181,11 +155,10 @@ function NetworkedMapAndTokens({ session }) {
assetManifest,
partyState,
session,
- getMapFromDB,
- getTokenFromDB,
- updateMap,
userId,
addToast,
+ getAsset,
+ assetLoadStart,
]);
/**
@@ -215,12 +188,8 @@ function NetworkedMapAndTokens({ session }) {
setCurrentMapState(newMapState, true, true);
setCurrentMap(newMap);
- if (newMap && newMap.type === "file") {
- const { file, resolutions, thumbnail, ...rest } = newMap;
- session.socket?.emit("map", rest);
- } else {
- session.socket?.emit("map", newMap);
- }
+ session.socket?.emit("map", newMap);
+
if (!newMap || !newMapState) {
setAssetManifest(null, true, true);
return;
@@ -367,23 +336,28 @@ function NetworkedMapAndTokens({ session }) {
* Token state
*/
- async function handleMapTokenStateCreate(tokenState) {
+ async function handleMapTokensStateCreate(tokenStates) {
if (!currentMap || !currentMapState) {
return;
}
- // If file type token send the token to the other peers
- const token = await getTokenFromDB(tokenState.tokenId);
- if (token && token.type === "file") {
- const { id, lastModified, owner } = token;
- addAssetIfNeeded({ type: "token", id, lastModified, owner });
+
+ let assets = [];
+ for (let tokenState of tokenStates) {
+ if (tokenState.type === "file") {
+ assets.push({ id: tokenState.file, owner: tokenState.owner });
+ }
}
- setCurrentMapState((prevMapState) => ({
- ...prevMapState,
- tokens: {
- ...prevMapState.tokens,
- [tokenState.id]: tokenState,
- },
- }));
+ if (assets.length > 0) {
+ addAssetsIfNeeded(assets);
+ }
+
+ setCurrentMapState((prevMapState) => {
+ let newMapTokens = { ...prevMapState.tokens };
+ for (let tokenState of tokenStates) {
+ newMapTokens[tokenState.id] = tokenState;
+ }
+ return { ...prevMapState, tokens: newMapTokens };
+ });
}
function handleMapTokenStateChange(change) {
@@ -414,114 +388,34 @@ function NetworkedMapAndTokens({ session }) {
useEffect(() => {
async function handlePeerData({ id, data, reply }) {
- if (id === "mapRequest") {
- const map = await getMapFromDB(data);
- function replyWithMap(preview, resolution) {
- let response = {
- ...map,
- resolutions: undefined,
- file: undefined,
- thumbnail: undefined,
- // Remove last modified so if there is an error
- // during the map request the cache is invalid
- lastModified: 0,
- // Add last used for cache invalidation
- lastUsed: Date.now(),
- };
- // Send preview if available
- if (map.resolutions[preview]) {
- response.resolutions = { [preview]: map.resolutions[preview] };
- reply("mapResponse", response, "map");
- }
- // Send full map at the desired resolution if available
- if (map.resolutions[resolution]) {
- response.file = map.resolutions[resolution].file;
- } else if (map.file) {
- // The resolution might not exist for other users so send the file instead
- response.file = map.file;
- } else {
- return;
- }
- // Add last modified back to file to set cache as valid
- response.lastModified = map.lastModified;
- reply("mapResponse", response, "map");
- }
-
- switch (map.quality) {
- case "low":
- replyWithMap(undefined, "low");
- break;
- case "medium":
- replyWithMap("low", "medium");
- break;
- case "high":
- replyWithMap("medium", "high");
- break;
- case "ultra":
- replyWithMap("medium", "ultra");
- break;
- case "original":
- if (map.resolutions) {
- if (map.resolutions.medium) {
- replyWithMap("medium");
- } else if (map.resolutions.low) {
- replyWithMap("low");
- } else {
- replyWithMap();
- }
- } else {
- replyWithMap();
- }
- break;
- default:
- replyWithMap();
+ if (id === "assetRequest") {
+ const asset = await getAsset(data.id);
+ if (asset) {
+ reply("assetResponseSuccess", asset, undefined, data.id);
+ } else {
+ reply("assetResponseFail", data.id, undefined, data.id);
}
}
- if (id === "mapResponse") {
- const newMap = data;
- if (newMap?.id) {
- setCurrentMap(newMap);
- await putMap(newMap);
- // If we have the final map resolution
- if (newMap.lastModified > 0) {
- requestingAssetsRef.current.delete(newMap.id);
- }
- }
- assetLoadFinish();
+ if (id === "assetResponseSuccess") {
+ const asset = data;
+ await putAsset(asset);
+ requestingAssetsRef.current.delete(asset.id);
}
- if (id === "tokenRequest") {
- const token = await getTokenFromDB(data);
- // Add a last used property for cache invalidation
- reply("tokenResponse", { ...token, lastUsed: Date.now() }, "token");
- }
- if (id === "tokenResponse") {
- const newToken = data;
- if (newToken?.id) {
- await putToken(newToken);
- requestingAssetsRef.current.delete(newToken.id);
- }
- assetLoadFinish();
+ if (id === "assetResponseFail") {
+ const assetId = data;
+ requestingAssetsRef.current.delete(assetId);
}
}
function handlePeerDataProgress({ id, total, count }) {
- if (count === 1) {
- // Corresponding asset load finished called in token and map response
- assetLoadStart();
- }
assetProgressUpdate({ id, total, count });
}
async function handleSocketMap(map) {
if (map) {
- if (map.type === "file") {
- const fullMap = await getMapFromDB(map.id);
- setCurrentMap(fullMap || map);
- } else {
- setCurrentMap(map);
- }
+ setCurrentMap(map);
} else {
setCurrentMap(null);
}
@@ -574,7 +468,10 @@ function NetworkedMapAndTokens({ session }) {
}
return (
- <>
+
-
- >
+
+
);
}
diff --git a/src/network/NetworkedMapPointer.js b/src/network/NetworkedMapPointer.js
index 6398eb1..87b5437 100644
--- a/src/network/NetworkedMapPointer.js
+++ b/src/network/NetworkedMapPointer.js
@@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef } from "react";
import { Group } from "react-konva";
-import { useAuth } from "../contexts/AuthContext";
+import { useUserId } from "../contexts/UserIdContext";
import MapPointer from "../components/map/MapPointer";
import { isEmpty } from "../helpers/shared";
@@ -13,7 +13,7 @@ import useSetting from "../hooks/useSetting";
const sendTickRate = 50;
function NetworkedMapPointer({ session, active }) {
- const { userId } = useAuth();
+ const userId = useUserId();
const [localPointerState, setLocalPointerState] = useState({});
const [pointerColor] = useSetting("pointer.color");
diff --git a/src/network/Session.js b/src/network/Session.js
index b2973cc..ce2ad57 100644
--- a/src/network/Session.js
+++ b/src/network/Session.js
@@ -19,7 +19,8 @@ import { logError } from "../helpers/logging";
* @callback peerReply
* @param {string} id - The id of the event
* @param {object} data - The data to send
- * @param {string} channel - The channel to send to
+ * @param {string=} channel - The channel to send to
+ * @param {string=} chunkId
*/
/**
@@ -111,7 +112,7 @@ class Session extends EventEmitter {
}
disconnect() {
- this.socket.disconnect();
+ this.socket?.disconnect();
}
/**
@@ -120,9 +121,10 @@ class Session extends EventEmitter {
* @param {string} sessionId - The socket id of the player to send to
* @param {string} eventId - The id of the event to send
* @param {object} data
- * @param {string} channel
+ * @param {string=} channel
+ * @param {string=} chunkId
*/
- sendTo(sessionId, eventId, data, channel) {
+ sendTo(sessionId, eventId, data, channel, chunkId) {
if (!(sessionId in this.peers)) {
if (!this._addPeer(sessionId, true)) {
return;
@@ -133,7 +135,8 @@ class Session extends EventEmitter {
this.peers[sessionId].connection.once("connect", () => {
this.peers[sessionId].connection.sendObject(
{ id: eventId, data },
- channel
+ channel,
+ chunkId
);
});
} else {
@@ -198,7 +201,12 @@ class Session extends EventEmitter {
this._gameId = gameId;
this._password = password;
- this.socket.emit("join_game", gameId, password, process.env.REACT_APP_VERSION);
+ this.socket.emit(
+ "join_game",
+ gameId,
+ password,
+ process.env.REACT_APP_VERSION
+ );
this.emit("status", "joining");
}
@@ -221,8 +229,8 @@ class Session extends EventEmitter {
const peer = { id, connection, initiator, ready: false };
- function sendPeer(id, data, channel) {
- peer.connection.sendObject({ id, data }, channel);
+ function reply(id, data, channel, chunkId) {
+ peer.connection.sendObject({ id, data }, channel, chunkId);
}
function handleSignal(signal) {
@@ -241,7 +249,7 @@ class Session extends EventEmitter {
* @property {SessionPeer} peer
* @property {peerReply} reply
*/
- this.emit("peerConnect", { peer, reply: sendPeer });
+ this.emit("peerConnect", { peer, reply });
}
function handleDataComplete(data) {
@@ -259,7 +267,7 @@ class Session extends EventEmitter {
peer,
id: data.id,
data: data.data,
- reply: sendPeer,
+ reply,
});
}
@@ -269,7 +277,7 @@ class Session extends EventEmitter {
id,
count,
total,
- reply: sendPeer,
+ reply,
});
}
diff --git a/src/routes/Game.js b/src/routes/Game.js
index 190fb86..78043b3 100644
--- a/src/routes/Game.js
+++ b/src/routes/Game.js
@@ -8,6 +8,7 @@ import OfflineBanner from "../components/banner/OfflineBanner";
import LoadingOverlay from "../components/LoadingOverlay";
import Link from "../components/Link";
import MapLoadingOverlay from "../components/map/MapLoadingOverlay";
+import UpgradingLoadingOverlay from "../components/UpgradingLoadingOverlay";
import AuthModal from "../modals/AuthModal";
import GameExpiredModal from "../modals/GameExpiredModal";
@@ -18,6 +19,10 @@ import { MapStageProvider } from "../contexts/MapStageContext";
import { useDatabase } from "../contexts/DatabaseContext";
import { PlayerProvider } from "../contexts/PlayerContext";
import { PartyProvider } from "../contexts/PartyContext";
+import { AssetsProvider, AssetURLsProvider } from "../contexts/AssetsContext";
+import { MapDataProvider } from "../contexts/MapDataContext";
+import { TokenDataProvider } from "../contexts/TokenDataContext";
+import { MapLoadingProvider } from "../contexts/MapLoadingContext";
import NetworkedMapAndTokens from "../network/NetworkedMapAndTokens";
import NetworkedParty from "../network/NetworkedParty";
@@ -84,16 +89,18 @@ function Game() {
};
}, [session]);
-
// Join game
useEffect(() => {
- if (sessionStatus === "ready" && databaseStatus !== "loading") {
+ if (
+ sessionStatus === "ready" &&
+ (databaseStatus === "loaded" || databaseStatus === "disabled")
+ ) {
session.joinGame(gameId, password);
}
}, [gameId, password, databaseStatus, session, sessionStatus]);
function handleAuthSubmit(newPassword) {
- if (databaseStatus !== "loading") {
+ if (databaseStatus === "loaded" || databaseStatus === "disabled") {
session.joinGame(gameId, newPassword);
}
}
@@ -103,50 +110,63 @@ function Game() {
const mapStageRef = useRef();
return (
-
-
-
-
-
-
-
-
-
- setPeerError(null)}
- >
-
-
- {peerError} See FAQ for more
- information.
-
-
-
-
-
-
- setGameExpired(false)}
- />
-
- {!sessionStatus && }
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+ setPeerError(null)}
+ >
+
+
+ {peerError} See FAQ{" "}
+ for more information.
+
+
+
+
+
+
+ setGameExpired(false)}
+ />
+
+ {!sessionStatus && }
+ {sessionStatus && databaseStatus === "upgrading" && (
+
+ )}
+
+
+
+
+
+
+
+
+
);
}
diff --git a/src/routes/ReleaseNotes.js b/src/routes/ReleaseNotes.js
index da984ba..54633f1 100644
--- a/src/routes/ReleaseNotes.js
+++ b/src/routes/ReleaseNotes.js
@@ -26,6 +26,7 @@ const v162 = raw("../docs/releaseNotes/v1.6.2.md");
const v170 = raw("../docs/releaseNotes/v1.7.0.md");
const v180 = raw("../docs/releaseNotes/v1.8.0.md");
const v181 = raw("../docs/releaseNotes/v1.8.1.md");
+const v190 = raw("../docs/releaseNotes/v1.9.0.md");
function ReleaseNotes() {
const location = useLocation();
@@ -50,13 +51,18 @@ function ReleaseNotes() {
Release Notes
+
diff --git a/src/theme.js b/src/theme.js
index 305dcbb..b3baef4 100644
--- a/src/theme.js
+++ b/src/theme.js
@@ -16,7 +16,7 @@ const theme = {
background: "hsl(10, 10%, 98%)",
primary: "hsl(260, 100%, 80%)",
secondary: "hsl(290, 100%, 80%)",
- highlight: "hsl(260, 20%, 40%)",
+ highlight: "hsl(260, 20%, 70%)",
muted: "hsla(230, 20%, 60%, 20%)",
overlay: "hsla(230, 100%, 97%, 80%)",
border: "hsla(10, 20%, 20%, 0.5)",
@@ -188,6 +188,14 @@ const theme = {
},
},
textarea: {
+ "&:focus": {
+ outlineColor: "primary",
+ },
+ "&:disabled": {
+ backgroundColor: "muted",
+ opacity: 0.5,
+ borderColor: "text",
+ },
fontFamily: "body2",
},
},
diff --git a/src/tokens/index.js b/src/tokens/index.js
index 5a16e82..baf607d 100644
--- a/src/tokens/index.js
+++ b/src/tokens/index.js
@@ -80,15 +80,31 @@ function getDefaultTokenSize(key) {
}
}
-export const tokens = Object.keys(tokenSources).map((key) => ({
- key,
- name: Case.capital(key),
- type: "default",
- defaultSize: getDefaultTokenSize(key),
- category: "character",
- hideInSidebar: false,
- width: 256,
- height: 256,
-}));
+export function getDefaultTokens(userId) {
+ const tokenKeys = Object.keys(tokenSources);
+ let tokens = [];
+ for (let i = 0; i < tokenKeys.length; i++) {
+ const key = tokenKeys[i];
+ const name = Case.capital(key);
+ const token = {
+ key,
+ name,
+ id: `__default-${name}`,
+ type: "default",
+ defaultSize: getDefaultTokenSize(key),
+ defaultLabel: "",
+ defaultCategory: "character",
+ hideInSidebar: false,
+ width: 256,
+ height: 256,
+ outline: { type: "circle", x: 128, y: 128, radius: 128 },
+ owner: userId,
+ created: tokenKeys.length - i,
+ lastModified: Date.now(),
+ };
+ tokens.push(token);
+ }
+ return tokens;
+}
export const unknownSource = unknown;
diff --git a/src/upgrade.js b/src/upgrade.js
new file mode 100644
index 0000000..f7d3b99
--- /dev/null
+++ b/src/upgrade.js
@@ -0,0 +1,873 @@
+// eslint-disable-next-line no-unused-vars
+import Dexie, { Version } from "dexie";
+import shortid from "shortid";
+import { v4 as uuid } from "uuid";
+import Case from "case";
+
+import blobToBuffer from "./helpers/blobToBuffer";
+import { getGridDefaultInset } from "./helpers/grid";
+import { createThumbnail, getImageOutline } from "./helpers/image";
+import {
+ AddShapeAction,
+ EditShapeAction,
+ RemoveShapeAction,
+ SubtractShapeAction,
+ CutShapeAction,
+} from "./actions";
+import { getDefaultMaps } from "./maps";
+import { getDefaultTokens } from "./tokens";
+
+/**
+ * @callback OnUpgrade
+ * @param {number} versionNumber
+ */
+
+/**
+ * @callback VersionCallback
+ * @param {Version} version
+ * @param {OnUpgrade=} onUpgrade
+ */
+
+/**
+ * Mapping of version number to their upgrade function
+ * @type {Object.}
+ */
+export 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, onUpgrade) {
+ v.stores({}).upgrade(async (tx) => {
+ onUpgrade?.(2);
+ 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, onUpgrade) {
+ v.stores({}).upgrade((tx) => {
+ onUpgrade?.(3);
+ 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, onUpgrade) {
+ v.stores({}).upgrade((tx) => {
+ onUpgrade?.(4);
+ return tx
+ .table("maps")
+ .toCollection()
+ .modify((map) => {
+ map.showGrid = false;
+ });
+ });
+ },
+ // v1.4.0 - Added fog subtraction
+ 5(v, onUpgrade) {
+ v.stores({}).upgrade((tx) => {
+ onUpgrade?.(5);
+ 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, onUpgrade) {
+ v.stores({}).upgrade((tx) => {
+ onUpgrade?.(6);
+ return tx
+ .table("maps")
+ .toCollection()
+ .modify((map) => {
+ map.resolutions = {};
+ map.quality = "original";
+ });
+ });
+ },
+ // v1.5.0 - Fixed default token rogue spelling
+ 7(v, onUpgrade) {
+ v.stores({}).upgrade((tx) => {
+ onUpgrade?.(7);
+ 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, onUpgrade) {
+ v.stores({}).upgrade((tx) => {
+ onUpgrade?.(8);
+ return tx
+ .table("maps")
+ .toCollection()
+ .modify((map) => {
+ map.snapToGrid = true;
+ });
+ });
+ },
+ // v1.5.1 - Added lock, visibility and modified to tokens
+ 9(v, onUpgrade) {
+ v.stores({}).upgrade((tx) => {
+ onUpgrade?.(9);
+ 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, onUpgrade) {
+ v.stores({}).upgrade((tx) => {
+ onUpgrade?.(10);
+ 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, onUpgrade) {
+ v.stores({}).upgrade((tx) => {
+ onUpgrade?.(11);
+ return tx
+ .table("maps")
+ .toCollection()
+ .modify((map) => {
+ map.lastUsed = map.lastModified;
+ });
+ });
+ },
+ // v1.5.2 - Added automatic cache invalidation to tokens
+ 12(v, onUpgrade) {
+ v.stores({}).upgrade((tx) => {
+ onUpgrade?.(12);
+ return tx
+ .table("tokens")
+ .toCollection()
+ .modify((token) => {
+ token.lastUsed = token.lastModified;
+ });
+ });
+ },
+ // v1.6.0 - Added map grouping and grid scale and offset
+ 13(v, onUpgrade) {
+ v.stores({}).upgrade((tx) => {
+ onUpgrade?.(13);
+ 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, onUpgrade) {
+ v.stores({}).upgrade((tx) => {
+ onUpgrade?.(14);
+ return tx
+ .table("tokens")
+ .toCollection()
+ .modify((token) => {
+ token.group = "";
+ });
+ });
+ },
+ // v1.6.1 - Added width and height to tokens
+ 15(v, onUpgrade) {
+ v.stores({}).upgrade(async (tx) => {
+ onUpgrade?.(15);
+ 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, onUpgrade) {
+ v.stores({}).upgrade((tx) => {
+ onUpgrade?.(16);
+ 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, onUpgrade) {
+ v.stores({}).upgrade((tx) => {
+ onUpgrade?.(17);
+ 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, onUpgrade) {
+ v.stores({}).upgrade((tx) => {
+ onUpgrade?.(18);
+ 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, onUpgrade) {
+ v.stores({}).upgrade(async (tx) => {
+ onUpgrade?.(19);
+ 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, onUpgrade) {
+ v.stores({}).upgrade(async (tx) => {
+ onUpgrade?.(20);
+ 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, onUpgrade) {
+ v.stores({}).upgrade((tx) => {
+ onUpgrade?.(22);
+ 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];
+ }
+ });
+ });
+ },
+ // v1.9.0 - Add outlines to tokens
+ 23(v, onUpgrade) {
+ v.stores({}).upgrade(async (tx) => {
+ onUpgrade?.(23);
+ const tokens = await Dexie.waitFor(tx.table("tokens").toArray());
+ const tokenOutlines = await Dexie.waitFor(
+ Promise.all(tokens.map(createDataOutline))
+ );
+
+ return tx
+ .table("tokens")
+ .toCollection()
+ .modify((token) => {
+ const tokenOutline = tokenOutlines.find((el) => el.id === token.id);
+ if (tokenOutline) {
+ token.outline = tokenOutline.outline;
+ } else {
+ token.outline = {
+ type: "rect",
+ width: token.width,
+ height: token.height,
+ x: 0,
+ y: 0,
+ };
+ }
+ });
+ });
+ },
+ // v1.9.0 - Move map assets into new table
+ 24(v, onUpgrade) {
+ v.stores({ assets: "id, owner" }).upgrade((tx) => {
+ onUpgrade?.(24);
+ tx.table("maps").each((map) => {
+ let assets = [];
+ assets.push({
+ id: uuid(),
+ owner: map.owner,
+ file: map.file,
+ width: map.width,
+ height: map.height,
+ mime: "",
+ prevId: map.id,
+ prevType: "map",
+ });
+
+ for (let resolution in map.resolutions) {
+ const mapRes = map.resolutions[resolution];
+ assets.push({
+ id: uuid(),
+ owner: map.owner,
+ file: mapRes.file,
+ width: mapRes.width,
+ height: mapRes.height,
+ mime: "",
+ prevId: map.id,
+ prevType: "mapResolution",
+ resolution,
+ });
+ }
+
+ assets.push({
+ id: uuid(),
+ owner: map.owner,
+ file: map.thumbnail.file,
+ width: map.thumbnail.width,
+ height: map.thumbnail.height,
+ mime: "",
+ prevId: map.id,
+ prevType: "mapThumbnail",
+ });
+
+ tx.table("assets").bulkAdd(assets);
+ });
+ });
+ },
+ // v1.9.0 - Move token assets into new table
+ 25(v, onUpgrade) {
+ v.stores({}).upgrade((tx) => {
+ onUpgrade?.(25);
+ tx.table("tokens").each((token) => {
+ let assets = [];
+ assets.push({
+ id: uuid(),
+ owner: token.owner,
+ file: token.file,
+ width: token.width,
+ height: token.height,
+ mime: "",
+ prevId: token.id,
+ prevType: "token",
+ });
+ assets.push({
+ id: uuid(),
+ owner: token.owner,
+ file: token.thumbnail.file,
+ width: token.thumbnail.width,
+ height: token.thumbnail.height,
+ mime: "",
+ prevId: token.id,
+ prevType: "tokenThumbnail",
+ });
+ tx.table("assets").bulkAdd(assets);
+ });
+ });
+ },
+ // v1.9.0 - Create foreign keys for assets
+ 26(v, onUpgrade) {
+ v.stores({}).upgrade((tx) => {
+ onUpgrade?.(26);
+ tx.table("assets").each((asset) => {
+ if (asset.prevType === "map") {
+ tx.table("maps").update(asset.prevId, {
+ file: asset.id,
+ });
+ } else if (asset.prevType === "token") {
+ tx.table("tokens").update(asset.prevId, {
+ file: asset.id,
+ });
+ } else if (asset.prevType === "mapThumbnail") {
+ tx.table("maps").update(asset.prevId, { thumbnail: asset.id });
+ } else if (asset.prevType === "tokenThumbnail") {
+ tx.table("tokens").update(asset.prevId, { thumbnail: asset.id });
+ } else if (asset.prevType === "mapResolution") {
+ tx.table("maps").update(asset.prevId, {
+ resolutions: undefined,
+ [asset.resolution]: asset.id,
+ });
+ }
+ });
+ });
+ },
+ // v1.9.0 - Remove asset migration helpers
+ 27(v, onUpgrade) {
+ v.stores({}).upgrade((tx) => {
+ onUpgrade?.(27);
+ tx.table("assets")
+ .toCollection()
+ .modify((asset) => {
+ delete asset.prevId;
+ if (asset.prevType === "mapResolution") {
+ delete asset.resolution;
+ }
+ delete asset.prevType;
+ });
+ });
+ },
+ // v1.9.0 - Remap map resolution assets
+ 28(v, onUpgrade) {
+ v.stores({}).upgrade((tx) => {
+ onUpgrade?.(28);
+ tx.table("maps")
+ .toCollection()
+ .modify((map) => {
+ const resolutions = ["low", "medium", "high", "ultra"];
+ map.resolutions = {};
+ for (let res of resolutions) {
+ if (res in map) {
+ map.resolutions[res] = map[res];
+ delete map[res];
+ }
+ }
+ delete map.lastUsed;
+ });
+ });
+ },
+ // v1.9.0 - Move tokens to use more defaults
+ 29(v, onUpgrade) {
+ v.stores({}).upgrade((tx) => {
+ onUpgrade?.(29);
+ tx.table("tokens")
+ .toCollection()
+ .modify(async (token) => {
+ token.defaultCategory = token.category;
+ delete token.category;
+ token.defaultLabel = "";
+ delete token.lastUsed;
+ });
+ });
+ },
+ // v1.9.0 - Move tokens to use more defaults and add token outline to token states
+ 30(v, onUpgrade) {
+ v.stores({}).upgrade(async (tx) => {
+ onUpgrade?.(30);
+ const tokens = await Dexie.waitFor(tx.table("tokens").toArray());
+ tx.table("states")
+ .toCollection()
+ .modify((state) => {
+ for (let id in state.tokens) {
+ if (!state.tokens[id].tokenId.startsWith("__default")) {
+ const token = tokens.find(
+ (token) => token.id === state.tokens[id].tokenId
+ );
+ if (token) {
+ state.tokens[id].category = token.defaultCategory;
+ state.tokens[id].file = token.file;
+ state.tokens[id].type = "file";
+ state.tokens[id].outline = token.outline;
+ state.tokens[id].width = token.width;
+ state.tokens[id].height = token.height;
+ state.tokens[id].owner = token.owner;
+ } else {
+ state.tokens[id].category = "character";
+ state.tokens[id].type = "file";
+ state.tokens[id].file = "";
+ state.tokens[id].outline = {
+ type: "rect",
+ width: 256,
+ height: 256,
+ x: 0,
+ y: 0,
+ };
+ state.tokens[id].width = 256;
+ state.tokens[id].height = 256;
+ }
+ } else {
+ state.tokens[id].category = "character";
+ state.tokens[id].type = "default";
+ state.tokens[id].key = Case.camel(
+ state.tokens[id].tokenId.slice(10)
+ );
+ state.tokens[id].outline = {
+ type: "circle",
+ x: 128,
+ y: 128,
+ radius: 128,
+ };
+ state.tokens[id].width = 256;
+ state.tokens[id].height = 256;
+ }
+ }
+ });
+ });
+ },
+ // v1.9.0 - Remove maps not owned by user as cache is now done on the asset level
+ 31(v, onUpgrade) {
+ v.stores({}).upgrade(async (tx) => {
+ onUpgrade?.(31);
+ const userId = (await Dexie.waitFor(tx.table("user").get("userId")))
+ ?.value;
+ if (userId) {
+ tx.table("maps").where("owner").notEqual(userId).delete();
+ }
+ });
+ },
+ // v1.9.0 - Remove tokens not owned by user as cache is now done on the asset level
+ 32(v, onUpgrade) {
+ v.stores({}).upgrade(async (tx) => {
+ onUpgrade?.(32);
+ const userId = (await Dexie.waitFor(tx.table("user").get("userId")))
+ ?.value;
+ if (userId) {
+ tx.table("tokens").where("owner").notEqual(userId).delete();
+ }
+ });
+ },
+ // v1.9.0 - Store default maps and tokens in db
+ 33(v, onUpgrade) {
+ v.stores({}).upgrade(async (tx) => {
+ onUpgrade?.(33);
+ const userId = (await Dexie.waitFor(tx.table("user").get("userId")))
+ ?.value;
+ if (!userId) {
+ return;
+ }
+ const { maps } = getDefaultMaps(userId);
+ tx.table("maps").bulkAdd(maps);
+ const tokens = getDefaultTokens(userId);
+ tx.table("tokens").bulkAdd(tokens);
+ });
+ },
+ // v1.9.0 - Add new group table
+ 34(v, onUpgrade) {
+ v.stores({ groups: "id" }).upgrade(async (tx) => {
+ onUpgrade?.(34);
+ function groupItems(items) {
+ let groups = [];
+ let subGroups = {};
+ for (let item of items) {
+ if (!item.group) {
+ groups.push({ id: item.id, type: "item" });
+ } else if (item.group in subGroups) {
+ subGroups[item.group].items.push({ id: item.id, type: "item" });
+ } else {
+ subGroups[item.group] = {
+ id: uuid(),
+ type: "group",
+ name: item.group,
+ items: [{ id: item.id, type: "item" }],
+ };
+ }
+ }
+ groups.push(...Object.values(subGroups));
+ return groups;
+ }
+
+ let maps = await Dexie.waitFor(tx.table("maps").toArray());
+ maps = maps.sort((a, b) => b.created - a.created);
+ const mapGroupItems = groupItems(maps);
+ tx.table("groups").add({ id: "maps", items: mapGroupItems });
+
+ let tokens = await Dexie.waitFor(tx.table("tokens").toArray());
+ tokens = tokens.sort((a, b) => b.created - a.created);
+ const tokenGroupItems = groupItems(tokens);
+ tx.table("groups").add({ id: "tokens", items: tokenGroupItems });
+ });
+ },
+ // v1.9.0 - Remove map and token group in respective tables
+ 35(v, onUpgrade) {
+ v.stores({}).upgrade((tx) => {
+ onUpgrade?.(35);
+ tx.table("maps")
+ .toCollection()
+ .modify((map) => {
+ delete map.group;
+ });
+ tx.table("tokens")
+ .toCollection()
+ .modify((token) => {
+ delete token.group;
+ });
+ });
+ },
+};
+
+export const latestVersion = 35;
+
+/**
+ * Load versions onto a database up to a specific version number
+ * @param {Dexie} db
+ * @param {number=} upTo version number to load up to, latest version if undefined
+ * @param {OnUpgrade=} onUpgrade
+ */
+export function loadVersions(db, upTo = latestVersion, onUpgrade = undefined) {
+ for (let versionNumber = 1; versionNumber <= upTo; versionNumber++) {
+ versions[versionNumber](db.version(versionNumber), onUpgrade);
+ }
+}
+
+/**
+ * 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
+ */
+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;
+}
+
+// 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({
+ file: thumbnail.file,
+ width: thumbnail.width,
+ height: thumbnail.height,
+ type: "file",
+ id: "thumbnail",
+ });
+ };
+ image.src = url;
+ }),
+ 60000 * 10 // 10 minute timeout
+ );
+}
+
+async function createDataOutline(data) {
+ const url = URL.createObjectURL(new Blob([data.file]));
+ return await Dexie.waitFor(
+ new Promise((resolve) => {
+ let image = new Image();
+ image.onload = async () => {
+ resolve({ id: data.id, outline: getImageOutline(image) });
+ };
+ image.onerror = () => {
+ resolve({
+ id: data.id,
+ outline: {
+ type: "rect",
+ width: data.width,
+ height: data.height,
+ x: 0,
+ y: 0,
+ },
+ });
+ };
+ image.src = url;
+ })
+ );
+}
diff --git a/src/workers/DatabaseWorker.js b/src/workers/DatabaseWorker.js
index 28d6c9c..8b53785 100644
--- a/src/workers/DatabaseWorker.js
+++ b/src/workers/DatabaseWorker.js
@@ -15,26 +15,21 @@ let service = {
* Load either a whole table or individual item from the DB
* @param {string} table Table to load from
* @param {string=} key Optional database key to load, if undefined whole table will be loaded
- * @param {bool} excludeFiles Optional exclude files from loaded data when using whole table loading
*/
- async loadData(table, key, excludeFiles = true) {
+ async loadData(table, key) {
try {
let db = getDatabase({});
if (key) {
// Load specific item
const data = await db.table(table).get(key);
- return data;
+ const packed = encode(data);
+ return Comlink.transfer(packed, [packed.buffer]);
} else {
// Load entire table
let items = [];
// Use a cursor instead of toArray to prevent IPC max size error
await db.table(table).each((item) => {
- if (excludeFiles) {
- const { file, resolutions, ...rest } = item;
- items.push(rest);
- } else {
- items.push(item);
- }
+ items.push(item);
});
// Pack data with msgpack so we can use transfer to avoid memory issues
@@ -48,17 +43,12 @@ let service = {
* Put data into table encoded by msgpack
* @param {Uint8Array} data
* @param {string} table
- * @param {boolean} wait Whether to wait for the put to finish
*/
- async putData(data, table, wait = true) {
+ async putData(data, table) {
try {
let db = getDatabase({});
const decoded = decode(data);
- if (wait) {
- await db.table(table).put(decoded);
- } else {
- db.table(table).put(decoded);
- }
+ await db.table(table).put(decoded);
return true;
} catch {
return false;
@@ -68,22 +58,54 @@ let service = {
/**
* Export current database
* @param {function} progressCallback
- * @param {string[]} maps An array of map ids to export
- * @param {string[]} tokens An array of token ids to export
+ * @param {string[]} mapIds An array of map ids to export
+ * @param {string[]} tokenIds An array of token ids to export
*/
- async exportData(progressCallback, maps, tokens) {
+ async exportData(progressCallback, mapIds, tokenIds) {
let db = getDatabase({});
+ // Add assets for selected maps and tokens
+ const maps = await db.table("maps").where("id").anyOf(mapIds).toArray();
+ const tokens = await db
+ .table("tokens")
+ .where("id")
+ .anyOf(tokenIds)
+ .toArray();
+ const 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);
+ }
+ }
+ }
+ for (let token of tokens) {
+ if (token.type === "file") {
+ assetIds.push(token.file);
+ assetIds.push(token.thumbnail);
+ }
+ }
+
const filter = (table, value) => {
if (table === "maps") {
- return maps.includes(value.id);
+ return mapIds.includes(value.id);
}
if (table === "states") {
- return maps.includes(value.mapId);
+ return mapIds.includes(value.mapId);
}
if (table === "tokens") {
- return tokens.includes(value.id);
+ return tokenIds.includes(value.id);
}
+ if (table === "assets") {
+ return assetIds.includes(value.id);
+ }
+ // Always include groups table
+ if (table === "groups") {
+ return true;
+ }
+
return false;
};
@@ -131,7 +153,8 @@ let service = {
importDB = getDatabase(
{ addons: [] },
databaseName,
- importMeta.data.databaseVersion
+ importMeta.data.databaseVersion,
+ false
);
await importInto(importDB, data, {
progressCallback,
@@ -151,6 +174,43 @@ let service = {
});
importDB.close();
},
+
+ /**
+ * Ensure the asset cache doesn't go over `maxCacheSize` by removing cached assets
+ * Removes largest assets first
+ * @param {number} maxCacheSize Max size of cache in bytes
+ */
+ async cleanAssetCache(maxCacheSize) {
+ try {
+ let db = getDatabase({});
+ const userId = (await db.table("user").get("userId")).value;
+ const cachedAssets = await db
+ .table("assets")
+ .where("owner")
+ .notEqual(userId)
+ .toArray();
+ const totalSize = cachedAssets.reduce(
+ (acc, cur) => acc + cur.file.byteLength,
+ 0
+ );
+ if (totalSize > maxCacheSize) {
+ // Remove largest assets first
+ const largestAssets = cachedAssets.sort(
+ (a, b) => b.file.byteLength - a.file.byteLength
+ );
+ let assetsToDelete = [];
+ let deletedBytes = 0;
+ for (let asset of largestAssets) {
+ assetsToDelete.push(asset.id);
+ deletedBytes += asset.file.byteLength;
+ if (totalSize - deletedBytes < maxCacheSize) {
+ break;
+ }
+ }
+ await db.table("assets").bulkDelete(assetsToDelete);
+ }
+ } catch {}
+ },
};
Comlink.expose(service);
diff --git a/yarn.lock b/yarn.lock
index 439014e..9907962 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1796,6 +1796,37 @@
resolved "https://registry.yarnpkg.com/@csstools/normalize.css/-/normalize.css-10.1.0.tgz#f0950bba18819512d42f7197e56c518aa491cf18"
integrity sha512-ij4wRiunFfaJxjB0BdrYHIH8FxBJpOwNPhhAcunlmPdXudL1WQV1qoP9un6JsEBAgQH+7UXyyjh0g7jTxXK6tg==
+"@dnd-kit/accessibility@^3.0.0":
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/@dnd-kit/accessibility/-/accessibility-3.0.0.tgz#b56e3750414fd907b7d6972b3116aa8f96d07fde"
+ integrity sha512-QwaQ1IJHQHMMuAGOOYHQSx7h7vMZPfO97aDts8t5N/MY7n2QTDSnW+kF7uRQ1tVBkr6vJ+BqHWG5dlgGvwVjow==
+ dependencies:
+ tslib "^2.0.0"
+
+"@dnd-kit/core@^3.0.4":
+ version "3.0.4"
+ resolved "https://registry.yarnpkg.com/@dnd-kit/core/-/core-3.0.4.tgz#b10a0ffc9300a665108a4f7387b1c55dc6bf9c7f"
+ integrity sha512-EoUNyRWnRm8l0okwqG0iUwB0zrPkqBfJrIPdb3kMrTjoG/+70i4RnIuAhdKDnOxldFynWahPfJV3YBoDgeudgg==
+ dependencies:
+ "@dnd-kit/accessibility" "^3.0.0"
+ "@dnd-kit/utilities" "^2.0.0"
+ tslib "^2.0.0"
+
+"@dnd-kit/sortable@^3.1.0":
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/@dnd-kit/sortable/-/sortable-3.1.0.tgz#4638ea3d9202287ffb186679b593b3407c7e737d"
+ integrity sha512-BwnNgqMTqwIASdu9/x5PdqnAaFakx4HrflDP/zHfZAnGm912+Y545PlztVtAdxK+U6L3pXPR1BQaZO7JQjDxmg==
+ dependencies:
+ "@dnd-kit/utilities" "^2.0.0"
+ tslib "^2.0.0"
+
+"@dnd-kit/utilities@^2.0.0":
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/@dnd-kit/utilities/-/utilities-2.0.0.tgz#a8462dff65c6f606ecbe95273c7e263b14a1ab97"
+ integrity sha512-bjs49yMNzMM+BYRsBUhTqhTk6HEvhuY3leFt6Em6NaYGgygaMbtGbbXof/UXBv7rqyyi0OkmBBnrCCcxqS2t/g==
+ dependencies:
+ tslib "^2.0.0"
+
"@emotion/cache@^10.0.27":
version "10.0.29"
resolved "https://registry.yarnpkg.com/@emotion/cache/-/cache-10.0.29.tgz#87e7e64f412c060102d589fe7c6dc042e6f9d1e0"
@@ -2008,11 +2039,6 @@
dependencies:
"@hapi/hoek" "^8.3.0"
-"@interactjs/types@1.10.8":
- version "1.10.8"
- resolved "https://registry.yarnpkg.com/@interactjs/types/-/types-1.10.8.tgz#098da479de9c5ac9c8ba97d113746b7dcd9c2204"
- integrity sha512-qU2QfnN7r8AU4mSd2W3XmRtR0d35R1PReIT9b5YzpNLX9S0OQgNBLrEEFyXpa9alq/9h6wYNIwPCVAsknF5uZw==
-
"@istanbuljs/load-nyc-config@^1.0.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced"
@@ -2286,68 +2312,78 @@
estree-walker "^1.0.1"
picomatch "^2.2.2"
-"@sentry/browser@6.2.2":
- version "6.2.2"
- resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-6.2.2.tgz#4df4ad7026b269d85b63b79a75387ce5370bc705"
- integrity sha512-K5UGyEePtVPZIFMoiRafhd4Ov0M1kdozVsVKIPZrOpJyjQdPNX+fYDNL/h0nVmgOlE2S/uu4fl4mEfe/6aLShw==
+"@sentry/browser@6.3.0":
+ version "6.3.0"
+ resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-6.3.0.tgz#523430667bff290220909fb96f35706ff808fed6"
+ integrity sha512-Rse9j5XwN9n7GnfW1mNscTS4YQ0oiBNJcaSk3Mw/vQT872Wh60yKyx5wxAw5GujFZI0NgdyPlZwZ/tGQwirRxA==
dependencies:
- "@sentry/core" "6.2.2"
- "@sentry/types" "6.2.2"
- "@sentry/utils" "6.2.2"
+ "@sentry/core" "6.3.0"
+ "@sentry/types" "6.3.0"
+ "@sentry/utils" "6.3.0"
tslib "^1.9.3"
-"@sentry/core@6.2.2":
- version "6.2.2"
- resolved "https://registry.yarnpkg.com/@sentry/core/-/core-6.2.2.tgz#ec86b5769f8855f43cb58e839f81f87074ec9a3f"
- integrity sha512-qqWbvvXtymfXh7N5eEvk97MCnMURuyFIgqWdVD4MQM6yIfDCy36CyGfuQ3ViHTLZGdIfEOhLL9/f4kzf1RzqBA==
+"@sentry/core@6.3.0":
+ version "6.3.0"
+ resolved "https://registry.yarnpkg.com/@sentry/core/-/core-6.3.0.tgz#3b8db24918a00c0b77f1663fc6d9be925f66bb3e"
+ integrity sha512-voot/lJ9gRXB6bx6tVqbEbD6jOd4Sx6Rfmm6pzfpom9C0q+fjIZTatTLq8GdXj8DzxaH1MBDSwtaq/eC3NqYpA==
dependencies:
- "@sentry/hub" "6.2.2"
- "@sentry/minimal" "6.2.2"
- "@sentry/types" "6.2.2"
- "@sentry/utils" "6.2.2"
+ "@sentry/hub" "6.3.0"
+ "@sentry/minimal" "6.3.0"
+ "@sentry/types" "6.3.0"
+ "@sentry/utils" "6.3.0"
tslib "^1.9.3"
-"@sentry/hub@6.2.2":
- version "6.2.2"
- resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-6.2.2.tgz#f451d8d3ad207e81556b4846d810226693e0444e"
- integrity sha512-VR6uQGRYt6RP633FHShlSLj0LUKGVrlTeSlwCoooWM5FR9lmi6akAaweuxpG78/kZvXrAWpjX6/nuYwHKGwzGA==
+"@sentry/hub@6.3.0":
+ version "6.3.0"
+ resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-6.3.0.tgz#4225b3b0f31fe47f24d80753b257a4b57de5d651"
+ integrity sha512-lAnW3Om66t9IR+t1wya1NpOF9lGbvYG6Ca8wxJJGJ1t2PxKwyxpZKzRx0q8M1QFhlZ5cETCzxmM7lBEZ4QVCBg==
dependencies:
- "@sentry/types" "6.2.2"
- "@sentry/utils" "6.2.2"
+ "@sentry/types" "6.3.0"
+ "@sentry/utils" "6.3.0"
tslib "^1.9.3"
-"@sentry/minimal@6.2.2":
- version "6.2.2"
- resolved "https://registry.yarnpkg.com/@sentry/minimal/-/minimal-6.2.2.tgz#01f41e0a6a6a2becfc99f6bb6f9c4bddf54f8dae"
- integrity sha512-l0IgoGQgg1lTd4qDU8bQn25sbZBg8PwIHfuTLbGMlRr1flDXHOM1UXajWK/UKbAPelnU7M2JBSVzgl7PwjprzA==
+"@sentry/integrations@^6.3.0":
+ version "6.3.0"
+ resolved "https://registry.yarnpkg.com/@sentry/integrations/-/integrations-6.3.0.tgz#2091889d1d4a319d48e84ebce43405c6a7fee5b7"
+ integrity sha512-/bl0wykJr+7zJHmnAulI+/J1kT5AI/019jWSXX7nmfIhp2sRXNUw0jeNVh+xfwrbR6Ik6IleAyzwHNYKzedGVQ==
dependencies:
- "@sentry/hub" "6.2.2"
- "@sentry/types" "6.2.2"
+ "@sentry/types" "6.3.0"
+ "@sentry/utils" "6.3.0"
+ localforage "^1.8.1"
tslib "^1.9.3"
-"@sentry/react@^6.2.2":
- version "6.2.2"
- resolved "https://registry.yarnpkg.com/@sentry/react/-/react-6.2.2.tgz#6a93fa1013b2b9e37a8c0bc16cf0cbf4353de4c6"
- integrity sha512-yDuxPOD4j2WE5nX1p48GIqXwrrmwkjryFjtYvLgzGJkiGWLmGTrxrSqtUKrbqahJpKt3mi24Nkg0cMlsFB178g==
+"@sentry/minimal@6.3.0":
+ version "6.3.0"
+ resolved "https://registry.yarnpkg.com/@sentry/minimal/-/minimal-6.3.0.tgz#e64d87c92a4676a11168672a96589f46985f2b22"
+ integrity sha512-ZdPUwdPQkaKroy67NkwQRqmnfKyd/C1OyouM9IqYKyBjAInjOijwwc/Rd91PMHalvCOGfp1scNZYbZ+YFs/qQQ==
dependencies:
- "@sentry/browser" "6.2.2"
- "@sentry/minimal" "6.2.2"
- "@sentry/types" "6.2.2"
- "@sentry/utils" "6.2.2"
+ "@sentry/hub" "6.3.0"
+ "@sentry/types" "6.3.0"
+ tslib "^1.9.3"
+
+"@sentry/react@^6.3.0":
+ version "6.3.0"
+ resolved "https://registry.yarnpkg.com/@sentry/react/-/react-6.3.0.tgz#97763bf006991460126fa7235ebc662cd340f804"
+ integrity sha512-5+Q2p65WMxslaW96209wUp0kfqT0HTyVV+4TTCIOA6Aj3rnKesQaR44mXHXlQVTQh2/8fk1PTkMEsvWJdSPkjA==
+ dependencies:
+ "@sentry/browser" "6.3.0"
+ "@sentry/minimal" "6.3.0"
+ "@sentry/types" "6.3.0"
+ "@sentry/utils" "6.3.0"
hoist-non-react-statics "^3.3.2"
tslib "^1.9.3"
-"@sentry/types@6.2.2":
- version "6.2.2"
- resolved "https://registry.yarnpkg.com/@sentry/types/-/types-6.2.2.tgz#9fc7795156680d3da5fc6ecc66702d8f7917f2b1"
- integrity sha512-Y/1sRtw3a5JU4YdNBig8lLSVJ1UdYtuge+QP1CVLcLSAbq07Ok1bvF+Z+BlNcnHqle2Fl8aKuryG5Yu86enOyQ==
+"@sentry/types@6.3.0":
+ version "6.3.0"
+ resolved "https://registry.yarnpkg.com/@sentry/types/-/types-6.3.0.tgz#919cc1870f34b7126546c77e3c695052795d3add"
+ integrity sha512-xWyCYDmFPjS5ex60kxOOHbHEs4vs00qHbm0iShQfjl4OSg9S2azkcWofDmX8Xbn0FSOUXgdPCjNJW1B0bPVhCA==
-"@sentry/utils@6.2.2":
- version "6.2.2"
- resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-6.2.2.tgz#69f7151db74e65a010cec062cc9ab3e30bf2c80a"
- integrity sha512-qaee6X6VDNZ8HeO83/veaKw0KuhDE7j1R+Yryme3PywFzsoTzutDrEQjb7gvcHAhBaAYX8IHUBHgxcFI9BxI+w==
+"@sentry/utils@6.3.0":
+ version "6.3.0"
+ resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-6.3.0.tgz#e28645b6d4acd03a478e58bfe112ea02f81e94a0"
+ integrity sha512-NZzw4oLelgvCsVBG2e+ZtFtaBvgA7rZYtcGFbZTphhAlYoJ6JMCQUzYk0iwJK79yR1quh510x4UE0jynvvToWg==
dependencies:
- "@sentry/types" "6.2.2"
+ "@sentry/types" "6.3.0"
tslib "^1.9.3"
"@sinonjs/commons@^1.7.0":
@@ -4301,15 +4337,10 @@ caniuse-api@^3.0.0:
lodash.memoize "^4.1.2"
lodash.uniq "^4.5.0"
-caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000981, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001125, caniuse-lite@^1.0.30001157:
- version "1.0.30001159"
- resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001159.tgz#bebde28f893fa9594dadcaa7d6b8e2aa0299df20"
- integrity sha512-w9Ph56jOsS8RL20K9cLND3u/+5WASWdhC/PPrf+V3/HsM3uHOavWOR1Xzakbv4Puo/srmPHudkmCRWM7Aq+/UA==
-
-caniuse-lite@^1.0.30001181:
- version "1.0.30001200"
- resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001200.tgz#25435af6ba907c2a9c86d21ce84950d4824e6620"
- integrity sha512-ic/jXfa6tgiPBAISWk16jRI2q8YfjxHnSG7ddSL1ptrIP8Uy11SayFrjXRAk3NumHpDb21fdTkbTxb/hOrFrnQ==
+caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000981, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001125, caniuse-lite@^1.0.30001157, caniuse-lite@^1.0.30001181:
+ version "1.0.30001237"
+ resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001237.tgz"
+ integrity sha512-pDHgRndit6p1NR2GhzMbQ6CkRrp4VKuSsqbcLeOQppYPKOYkKT/6ZvZDvKJUqcmtyWIAHuZq3SVS2vc1egCZzw==
capture-exit@^2.0.0:
version "2.0.0"
@@ -5119,6 +5150,13 @@ csstype@^3.0.2:
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.5.tgz#7fdec6a28a67ae18647c51668a9ff95bb2fa7bb8"
integrity sha512-uVDi8LpBUKQj6sdxNaTetL6FpeCqTjOvAQuQUa/qAqq8oOd4ivkbhgnqayl0dnPal8Tb/yB1tF+gOvCBiicaiQ==
+cwise-compiler@^1.1.2:
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/cwise-compiler/-/cwise-compiler-1.1.3.tgz#f4d667410e850d3a313a7d2db7b1e505bb034cc5"
+ integrity sha1-9NZnQQ6FDToxOn0tt7HlBbsDTMU=
+ dependencies:
+ uniq "^1.0.0"
+
cyclist@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9"
@@ -5144,6 +5182,11 @@ dashdash@^1.12.0:
dependencies:
assert-plus "^1.0.0"
+data-uri-to-buffer@0.0.3:
+ version "0.0.3"
+ resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-0.0.3.tgz#18ae979a6a0ca994b0625853916d2662bbae0b1a"
+ integrity sha1-GK6XmmoMqZSwYlhTkW0mYruuCxo=
+
data-urls@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-2.0.0.tgz#156485a72963a970f5d5821aaf642bef2bf2db9b"
@@ -5357,6 +5400,11 @@ dir-glob@^3.0.1:
dependencies:
path-type "^4.0.0"
+distance-to-line-segment@^0.2.0:
+ version "0.2.0"
+ resolved "https://registry.yarnpkg.com/distance-to-line-segment/-/distance-to-line-segment-0.2.0.tgz#e45427bb8360eb59a86ffd9814a7578dd2c3d679"
+ integrity sha1-5FQnu4Ng61mob/2YFKdXjdLD1nk=
+
dns-equal@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/dns-equal/-/dns-equal-1.0.0.tgz#b39e7f1da6eb0a75ba9c17324b34753c47e0654d"
@@ -5680,6 +5728,11 @@ entities@^2.0.0:
resolved "https://registry.yarnpkg.com/entities/-/entities-2.1.0.tgz#992d3129cf7df6870b96c57858c249a120f8b8b5"
integrity sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==
+err-code@^2.0.3:
+ version "2.0.3"
+ resolved "https://registry.yarnpkg.com/err-code/-/err-code-2.0.3.tgz#23c2f3b756ffdfc608d30e27c9a941024807e7f9"
+ integrity sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==
+
err-code@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/err-code/-/err-code-3.0.1.tgz#a444c7b992705f2b120ee320b09972eef331c920"
@@ -6623,6 +6676,23 @@ get-package-type@^0.1.0:
resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a"
integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==
+get-pixels@^3.2.3:
+ version "3.3.2"
+ resolved "https://registry.yarnpkg.com/get-pixels/-/get-pixels-3.3.2.tgz#3f62fb8811932c69f262bba07cba72b692b4ff03"
+ integrity sha512-6ar+8yPxRd1pskEcl2GSEu1La0+xYRjjnkby6AYiRDDwZ0tJbPQmHnSeH9fGLskT8kvR0OukVgtZLcsENF9YKQ==
+ dependencies:
+ data-uri-to-buffer "0.0.3"
+ jpeg-js "^0.3.2"
+ mime-types "^2.0.1"
+ ndarray "^1.0.13"
+ ndarray-pack "^1.1.1"
+ node-bitmap "0.0.1"
+ omggif "^1.0.5"
+ parse-data-uri "^0.2.0"
+ pngjs "^3.3.3"
+ request "^2.44.0"
+ through "^2.3.4"
+
get-stream@^4.0.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5"
@@ -7121,6 +7191,23 @@ ignore@^5.1.4:
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.8.tgz#f150a8b50a34289b33e22f5889abd4d8016f0e57"
integrity sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==
+image-outline@^0.1.0:
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/image-outline/-/image-outline-0.1.0.tgz#b7818788e2ad30b4d8d57151e6e147d93e3ce9e3"
+ integrity sha1-t4GHiOKtMLTY1XFR5uFH2T486eM=
+ dependencies:
+ extend "^3.0.0"
+ get-pixels "^3.2.3"
+ line-simplify-rdp "^0.4.0"
+ marching-squares "^0.2.0"
+ minimist "^1.2.0"
+ ndarray "^1.0.18"
+
+immediate@~3.0.5:
+ version "3.0.6"
+ resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b"
+ integrity sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=
+
immer@8.0.1:
version "8.0.1"
resolved "https://registry.yarnpkg.com/immer/-/immer-8.0.1.tgz#9c73db683e2b3975c424fb0572af5889877ae656"
@@ -7228,13 +7315,6 @@ ini@^1.3.5:
resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927"
integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==
-interactjs@^1.10.8:
- version "1.10.8"
- resolved "https://registry.yarnpkg.com/interactjs/-/interactjs-1.10.8.tgz#a85b6e89ebf2ed88ea1678287ffcf0becf0dfb1c"
- integrity sha512-hIU82lF9mplmAHVTUmZbHMHKm96AwlD0zWGuf9krKt2dhALHsMOdU+yVilPqIv1VpNAGV66F9B14Rfs4ulS2nA==
- dependencies:
- "@interactjs/types" "1.10.8"
-
internal-ip@^4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/internal-ip/-/internal-ip-4.3.0.tgz#845452baad9d2ca3b69c635a137acb9a0dad0907"
@@ -7252,6 +7332,11 @@ internal-slot@^1.0.2:
has "^1.0.3"
side-channel "^1.0.2"
+intersection-observer@^0.12.0:
+ version "0.12.0"
+ resolved "https://registry.yarnpkg.com/intersection-observer/-/intersection-observer-0.12.0.tgz#6c84628f67ce8698e5f9ccf857d97718745837aa"
+ integrity sha512-2Vkz8z46Dv401zTWudDGwO7KiGHNDkMv417T5ItcNYfmvHR/1qCTVBO9vwH8zZmQ0WkA/1ARwpysR9bsnop4NQ==
+
invariant@^2.2.2:
version "2.2.4"
resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
@@ -7259,6 +7344,11 @@ invariant@^2.2.2:
dependencies:
loose-envify "^1.0.0"
+iota-array@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/iota-array/-/iota-array-1.0.0.tgz#81ef57fe5d05814cd58c2483632a99c30a0e8087"
+ integrity sha1-ge9X/l0FgUzVjCSDYyqZwwoOgIc=
+
ip-regex@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-2.1.0.tgz#fa78bf5d2e6913c911ce9f819ee5146bb6d844e9"
@@ -7340,7 +7430,7 @@ is-binary-path@~2.1.0:
dependencies:
binary-extensions "^2.0.0"
-is-buffer@^1.1.4, is-buffer@^1.1.5:
+is-buffer@^1.0.2, is-buffer@^1.1.4, is-buffer@^1.1.5:
version "1.1.6"
resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be"
integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==
@@ -8155,6 +8245,11 @@ jest@26.6.0:
import-local "^3.0.2"
jest-cli "^26.6.0"
+jpeg-js@^0.3.2:
+ version "0.3.7"
+ resolved "https://registry.yarnpkg.com/jpeg-js/-/jpeg-js-0.3.7.tgz#471a89d06011640592d314158608690172b1028d"
+ integrity sha512-9IXdWudL61npZjvLuVe/ktHiA41iE8qFyLB+4VDTblEsWBzeg8WQTlktdUK4CdncUqtUgUg0bbOmTE2bKBKaBQ==
+
"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
@@ -8390,6 +8485,13 @@ levn@~0.3.0:
prelude-ls "~1.1.2"
type-check "~0.3.2"
+lie@3.1.1:
+ version "3.1.1"
+ resolved "https://registry.yarnpkg.com/lie/-/lie-3.1.1.tgz#9a436b2cc7746ca59de7a41fa469b3efb76bd87e"
+ integrity sha1-mkNrLMd0bKWd56QfpGmz77dr2H4=
+ dependencies:
+ immediate "~3.0.5"
+
line-column@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/line-column/-/line-column-1.0.2.tgz#d25af2936b6f4849172b312e4792d1d987bc34a2"
@@ -8398,6 +8500,13 @@ line-column@^1.0.2:
isarray "^1.0.0"
isobject "^2.0.0"
+line-simplify-rdp@^0.4.0:
+ version "0.4.0"
+ resolved "https://registry.yarnpkg.com/line-simplify-rdp/-/line-simplify-rdp-0.4.0.tgz#b9632e870d2f9bc3c696b8b9de4a6259a05fa816"
+ integrity sha1-uWMuhw0vm8PGlri53kpiWaBfqBY=
+ dependencies:
+ distance-to-line-segment "^0.2.0"
+
lines-and-columns@^1.1.6:
version "1.1.6"
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00"
@@ -8445,6 +8554,13 @@ loader-utils@^1.1.0, loader-utils@^1.2.3, loader-utils@^1.4.0:
emojis-list "^3.0.0"
json5 "^1.0.1"
+localforage@^1.8.1:
+ version "1.9.0"
+ resolved "https://registry.yarnpkg.com/localforage/-/localforage-1.9.0.tgz#f3e4d32a8300b362b4634cc4e066d9d00d2f09d1"
+ integrity sha512-rR1oyNrKulpe+VM9cYmcFn6tsHuokyVHFaCM3+osEmxaHTbEk8oQu6eGDfS6DQLWi/N67XRmB8ECG37OES368g==
+ dependencies:
+ lie "3.1.1"
+
locate-path@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e"
@@ -8538,6 +8654,11 @@ lodash.uniq@^4.5.0:
resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=
+lodash.unset@^4.5.2:
+ version "4.5.2"
+ resolved "https://registry.yarnpkg.com/lodash.unset/-/lodash.unset-4.5.2.tgz#370d1d3e85b72a7e1b0cdf2d272121306f23e4ed"
+ integrity sha1-Nw0dPoW3Kn4bDN8tJyEhMG8j5O0=
+
"lodash@>=3.5 <5", lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.5:
version "4.17.20"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52"
@@ -8627,6 +8748,11 @@ map-visit@^1.0.0:
dependencies:
object-visit "^1.0.0"
+marching-squares@^0.2.0:
+ version "0.2.0"
+ resolved "https://registry.yarnpkg.com/marching-squares/-/marching-squares-0.2.0.tgz#28086170bf77d8dbd578459d997a90526cf5faf0"
+ integrity sha1-KAhhcL932NvVeEWdmXqQUmz1+vA=
+
markdown-escapes@^1.0.0:
version "1.0.4"
resolved "https://registry.yarnpkg.com/markdown-escapes/-/markdown-escapes-1.0.4.tgz#c95415ef451499d7602b91095f3c8e8975f78535"
@@ -8749,11 +8875,23 @@ mime-db@1.44.0:
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.44.0.tgz#fa11c5eb0aca1334b4233cb4d52f10c5a6272f92"
integrity sha512-/NOTfLrsPBVeH7YtFPgsVWveuL+4SjjYxaQ1xtM1KMFj7HdxlBlxeyNLzhyJVx7r4rZGJAZ/6lkKCitSc/Nmpg==
+mime-db@1.47.0:
+ version "1.47.0"
+ resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.47.0.tgz#8cb313e59965d3c05cfbf898915a267af46a335c"
+ integrity sha512-QBmA/G2y+IfeS4oktet3qRZ+P5kPhCKRXxXnQEudYqUaEioAU1/Lq2us3D/t1Jfo4hE9REQPrbB7K5sOczJVIw==
+
"mime-db@>= 1.43.0 < 2":
version "1.45.0"
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.45.0.tgz#cceeda21ccd7c3a745eba2decd55d4b73e7879ea"
integrity sha512-CkqLUxUk15hofLoLyljJSrukZi8mAtgd+yE5uO4tqRZsdsAJKv0O+rFMhVDRJgozy+yG6md5KwuXhD4ocIoP+w==
+mime-types@^2.0.1:
+ version "2.1.30"
+ resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.30.tgz#6e7be8b4c479825f85ed6326695db73f9305d62d"
+ integrity sha512-crmjA4bLtR8m9qLpHvgxSChT+XoSlZi8J4n/aIdn3z92e/U47Z0V/yl+Wh9W046GgFVAmoNR/fmdbZYcSSIUeg==
+ dependencies:
+ mime-db "1.47.0"
+
mime-types@^2.1.12, mime-types@^2.1.27, mime-types@~2.1.17, mime-types@~2.1.19, mime-types@~2.1.24:
version "2.1.27"
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.27.tgz#47949f98e279ea53119f5722e0f34e529bec009f"
@@ -8977,6 +9115,22 @@ natural-compare@^1.4.0:
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=
+ndarray-pack@^1.1.1:
+ version "1.2.1"
+ resolved "https://registry.yarnpkg.com/ndarray-pack/-/ndarray-pack-1.2.1.tgz#8caebeaaa24d5ecf70ff86020637977da8ee585a"
+ integrity sha1-jK6+qqJNXs9w/4YCBjeXfajuWFo=
+ dependencies:
+ cwise-compiler "^1.1.2"
+ ndarray "^1.0.13"
+
+ndarray@^1.0.13, ndarray@^1.0.18:
+ version "1.0.19"
+ resolved "https://registry.yarnpkg.com/ndarray/-/ndarray-1.0.19.tgz#6785b5f5dfa58b83e31ae5b2a058cfd1ab3f694e"
+ integrity sha512-B4JHA4vdyZU30ELBw3g7/p9bZupyew5a7tX1Y/gGeF2hafrPaQZhgrGQfsvgfYbgdFZjYwuEcnaobeM/WMW+HQ==
+ dependencies:
+ iota-array "^1.0.0"
+ is-buffer "^1.0.2"
+
negotiator@0.6.2:
version "0.6.2"
resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb"
@@ -9005,6 +9159,11 @@ no-case@^3.0.3:
lower-case "^2.0.1"
tslib "^1.10.0"
+node-bitmap@0.0.1:
+ version "0.0.1"
+ resolved "https://registry.yarnpkg.com/node-bitmap/-/node-bitmap-0.0.1.tgz#180eac7003e0c707618ef31368f62f84b2a69091"
+ integrity sha1-GA6scAPgxwdhjvMTaPYvhLKmkJE=
+
node-fetch@~2.6.1:
version "2.6.1"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052"
@@ -9262,6 +9421,11 @@ obuf@^1.0.0, obuf@^1.1.2:
resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e"
integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==
+omggif@^1.0.5:
+ version "1.0.10"
+ resolved "https://registry.yarnpkg.com/omggif/-/omggif-1.0.10.tgz#ddaaf90d4a42f532e9e7cb3a95ecdd47f17c7b19"
+ integrity sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==
+
on-finished@~2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"
@@ -9476,6 +9640,13 @@ parse-asn1@^5.0.0, parse-asn1@^5.1.5:
pbkdf2 "^3.0.3"
safe-buffer "^5.1.1"
+parse-data-uri@^0.2.0:
+ version "0.2.0"
+ resolved "https://registry.yarnpkg.com/parse-data-uri/-/parse-data-uri-0.2.0.tgz#bf04d851dd5c87b0ab238e5d01ace494b604b4c9"
+ integrity sha1-vwTYUd1ch7CrI45dAazklLYEtMk=
+ dependencies:
+ data-uri-to-buffer "0.0.3"
+
parse-entities@^1.1.0:
version "1.2.2"
resolved "https://registry.yarnpkg.com/parse-entities/-/parse-entities-1.2.2.tgz#c31bf0f653b6661354f8973559cb86dd1d5edf50"
@@ -9698,6 +9869,11 @@ pkg-up@3.1.0:
dependencies:
find-up "^3.0.0"
+pngjs@^3.3.3:
+ version "3.4.0"
+ resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-3.4.0.tgz#99ca7d725965fb655814eaf65f38f12bbdbf555f"
+ integrity sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==
+
pnp-webpack-plugin@1.6.4:
version "1.6.4"
resolved "https://registry.yarnpkg.com/pnp-webpack-plugin/-/pnp-webpack-plugin-1.6.4.tgz#c9711ac4dc48a685dabafc86f8b6dd9f8df84149"
@@ -10715,6 +10891,11 @@ react-input-autosize@^3.0.0:
dependencies:
prop-types "^15.5.8"
+react-intersection-observer@^8.32.0:
+ version "8.32.0"
+ resolved "https://registry.yarnpkg.com/react-intersection-observer/-/react-intersection-observer-8.32.0.tgz#47249332e12e8bb99ed35a10bb7dd10446445a7b"
+ integrity sha512-RlC6FvS3MFShxTn4FHAy904bVjX5Nn4/eTjUkurW0fHK+M/fyQdXuyCy9+L7yjA+YMGogzzSJNc7M4UtfSKvtw==
+
react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.6:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
@@ -10920,6 +11101,15 @@ react-spring@^8.0.27:
"@babel/runtime" "^7.3.1"
prop-types "^15.5.8"
+react-textarea-autosize@^8.3.3:
+ version "8.3.3"
+ resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-8.3.3.tgz#f70913945369da453fd554c168f6baacd1fa04d8"
+ integrity sha512-2XlHXK2TDxS6vbQaoPbMOfQ8GK7+irc2fVK6QFIcC8GOnH3zI/v481n+j1L0WaPVvKxwesnY93fEfH++sus2rQ==
+ dependencies:
+ "@babel/runtime" "^7.10.2"
+ use-composed-ref "^1.0.0"
+ use-latest "^1.0.0"
+
react-toast-notifications@^2.4.3:
version "2.4.3"
resolved "https://registry.yarnpkg.com/react-toast-notifications/-/react-toast-notifications-2.4.3.tgz#ebf2ee776615a97906cef214352cfd9fe800c583"
@@ -11202,7 +11392,7 @@ request-promise-native@^1.0.8:
stealthy-require "^1.1.1"
tough-cookie "^2.3.3"
-request@^2.88.2:
+request@^2.44.0, request@^2.88.2:
version "2.88.2"
resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3"
integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==
@@ -11756,6 +11946,7 @@ simple-peer@feross/simple-peer#694/head:
resolved "https://codeload.github.com/feross/simple-peer/tar.gz/0d08d07b83ff3b8c60401688d80642d24dfeffe2"
dependencies:
debug "^4.0.1"
+ err-code "^2.0.3"
get-browser-rtc "^1.0.0"
queue-microtask "^1.1.0"
randombytes "^2.0.3"
@@ -12538,6 +12729,11 @@ through2@^2.0.0:
readable-stream "~2.3.6"
xtend "~4.0.1"
+through@^2.3.4:
+ version "2.3.8"
+ resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
+ integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=
+
thunky@^1.0.2:
version "1.1.0"
resolved "https://registry.yarnpkg.com/thunky/-/thunky-1.1.0.tgz#5abaf714a9405db0504732bbccd2cedd9ef9537d"
@@ -12668,6 +12864,11 @@ tryer@^1.0.1:
resolved "https://registry.yarnpkg.com/tryer/-/tryer-1.0.1.tgz#f2c85406800b9b0f74c9f7465b81eaad241252f8"
integrity sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA==
+ts-essentials@^2.0.3:
+ version "2.0.12"
+ resolved "https://registry.yarnpkg.com/ts-essentials/-/ts-essentials-2.0.12.tgz#c9303f3d74f75fa7528c3d49b80e089ab09d8745"
+ integrity sha512-3IVX4nI6B5cc31/GFFE+i8ey/N2eA0CZDbo6n0yrz0zDX8ZJ8djmU1p+XRz7G3is0F3bB3pu2pAroFdAWQKU3w==
+
ts-pnp@1.2.0, ts-pnp@^1.1.6:
version "1.2.0"
resolved "https://registry.yarnpkg.com/ts-pnp/-/ts-pnp-1.2.0.tgz#a500ad084b0798f1c3071af391e65912c86bca92"
@@ -12693,6 +12894,11 @@ tslib@^1.10.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
+tslib@^2.0.0:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.2.0.tgz#fb2c475977e35e241311ede2693cee1ec6698f5c"
+ integrity sha512-gS9GVHRU+RGn5KQM2rllAlR3dU6m7AcpJKdtH8gFvQiC4Otgk98XnmMU+nZenHt/+VhnBPWwgrJsyrdcw6i23w==
+
tsutils@^3.17.1:
version "3.17.1"
resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.17.1.tgz#ed719917f11ca0dee586272b2ac49e015a2dd759"
@@ -12853,7 +13059,7 @@ union-value@^1.0.0:
is-extendable "^0.1.1"
set-value "^2.0.1"
-uniq@^1.0.1:
+uniq@^1.0.0, uniq@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/uniq/-/uniq-1.0.1.tgz#b31c5ae8254844a3a8281541ce2b04b865a734ff"
integrity sha1-sxxa6CVIRKOoKBVBzisEuGWnNP8=
@@ -13003,11 +13209,30 @@ url@^0.11.0:
punycode "1.3.2"
querystring "0.2.0"
+use-composed-ref@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/use-composed-ref/-/use-composed-ref-1.1.0.tgz#9220e4e94a97b7b02d7d27eaeab0b37034438bbc"
+ integrity sha512-my1lNHGWsSDAhhVAT4MKs6IjBUtG6ZG11uUqexPH9PptiIZDQOzaF4f5tEbJ2+7qvNbtXNBbU3SfmN+fXlWDhg==
+ dependencies:
+ ts-essentials "^2.0.3"
+
use-image@^1.0.7:
version "1.0.7"
resolved "https://registry.yarnpkg.com/use-image/-/use-image-1.0.7.tgz#b2c42b78d32868762a37631673eb169a244d5ad1"
integrity sha512-Z8hB8+lAGe25iqO3YaHO7bSvBSGErEakYQ6RGyRrPZoMDLKBIuZ67ikzn8f5ydjWorqFzeX+U3vVwnXoE1Q56Q==
+use-isomorphic-layout-effect@^1.0.0:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.1.tgz#7bb6589170cd2987a152042f9084f9effb75c225"
+ integrity sha512-L7Evj8FGcwo/wpbv/qvSfrkHFtOpCzvM5yl2KVyDJoylVuSvzphiiasmjgQPttIGBAy2WKiBNR98q8w7PiNgKQ==
+
+use-latest@^1.0.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/use-latest/-/use-latest-1.2.0.tgz#a44f6572b8288e0972ec411bdd0840ada366f232"
+ integrity sha512-d2TEuG6nSLKQLAfW3By8mKr8HurOlTkul0sOpxbClIv4SQ4iOd7BYr7VIzdbktUCnv7dua/60xzd8igMU6jmyw==
+ dependencies:
+ use-isomorphic-layout-effect "^1.0.0"
+
use@^3.1.0:
version "3.1.1"
resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"
@@ -13070,6 +13295,11 @@ uuid@^8.3.0:
resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.1.tgz#2ba2e6ca000da60fce5a196954ab241131e05a31"
integrity sha512-FOmRr+FmWEIG8uhZv6C2bTgEVXsHk08kE7mPlrBbEe+c3r9pjceVPgupIfNIhc4yx55H69OXANrUaSuu9eInKg==
+uuid@^8.3.2:
+ version "8.3.2"
+ resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"
+ integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==
+
v8-compile-cache@^2.0.3:
version "2.2.0"
resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.2.0.tgz#9471efa3ef9128d2f7c6a7ca39c4dd6b5055b132"