(
+
+ {content}
+
+ )}
+ overlayElement={(props, content) => (
+ {
+ // Prevent drag event from triggering with a modal open
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ {...props}
+ >
+ {content}
+
+ )}
{...props}
>
{children}
@@ -50,7 +80,7 @@ function StyledModal({
StyledModal.defaultProps = {
allowClose: true,
- style: {}
+ style: {},
};
export default StyledModal;
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.tsx b/src/components/map/Map.tsx
index 90448d5..ab8906f 100644
--- a/src/components/map/Map.tsx
+++ b/src/components/map/Map.tsx
@@ -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";
@@ -141,6 +143,8 @@ function Map({
disabledTokens: any,
session: Session
}) {
+ const { addToast } = useToasts();
+
const { tokensById } = useTokenData();
const [selectedToolId, setSelectedToolId] = useState("move");
@@ -324,6 +328,7 @@ function Map({
onShapesCut={handleFogShapesCut}
onShapesRemove={handleFogShapesRemove}
onShapesEdit={handleFogShapesEdit}
+ onShapeError={addToast}
active={selectedToolId === "fog"}
toolSettings={settings.fog}
editable={allowFogDrawing && !settings.fog.preview}
@@ -427,30 +432,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..83947a7
--- /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 && overlayNodeClientRect) {
+ 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..4da276f
--- /dev/null
+++ b/src/contexts/AssetsContext.js
@@ -0,0 +1,367 @@
+import React, { useState, useContext, useCallback, useEffect } from "react";
+import * as Comlink from "comlink";
+import { encode } from "@msgpack/msgpack";
+import { useLiveQuery } from "dexie-react-hooks";
+
+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) => {
+ // Check for broadcast channel and attempt to use worker to put map to avoid UI lockup
+ // Safari doesn't support BC so fallback to single thread
+ if (window.BroadcastChannel) {
+ const packedAsset = encode(asset);
+ const success = await worker.putData(
+ Comlink.transfer(packedAsset, [packedAsset.buffer]),
+ "assets"
+ );
+ if (!success) {
+ await database.table("assets").put(asset);
+ }
+ } else {
+ 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({});
+ const { database } = useDatabase();
+
+ // Keep track of the assets that need to be loaded
+ const [assetKeys, setAssetKeys] = useState([]);
+
+ // Load assets after 100ms
+ const loadingDebouncedAssetURLs = useDebounce(assetURLs, 100);
+
+ // Update the asset keys to load when a url is added without an asset attached
+ useEffect(() => {
+ if (!loadingDebouncedAssetURLs) {
+ return;
+ }
+ let keysToLoad = [];
+ for (let url of Object.values(loadingDebouncedAssetURLs)) {
+ if (url.url === null) {
+ keysToLoad.push(url.id);
+ }
+ }
+ if (keysToLoad.length > 0) {
+ setAssetKeys(keysToLoad);
+ }
+ }, [loadingDebouncedAssetURLs]);
+
+ // Get the new assets whenever the keys change
+ const assets = useLiveQuery(
+ () => database?.table("assets").where("id").anyOf(assetKeys).toArray(),
+ [database, assetKeys]
+ );
+
+ // Update asset URLs when assets are loaded
+ useEffect(() => {
+ if (!assets || assets.length === 0) {
+ return;
+ }
+ // Assets are about to be loaded so clear the keys to load
+ setAssetKeys([]);
+
+ setAssetURLs((prevURLs) => {
+ let newURLs = { ...prevURLs };
+ for (let asset of assets) {
+ if (newURLs[asset.id]?.url === null) {
+ newURLs[asset.id] = {
+ ...newURLs[asset.id],
+ url: URL.createObjectURL(
+ new Blob([asset.file], { type: asset.mime })
+ ),
+ };
+ }
+ }
+ return newURLs;
+ });
+ }, [assets]);
+
+ // Clean up asset URLs every minute
+ const cleanUpDebouncedAssetURLs = 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;
+ }
+ });
+ }, [cleanUpDebouncedAssetURLs]);
+
+ 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");
+ }
+
+ useEffect(() => {
+ if (!assetId || type !== "file") {
+ return;
+ }
+
+ function updateAssetURL() {
+ function increaseReferences(prevURLs) {
+ return {
+ ...prevURLs,
+ [assetId]: {
+ ...prevURLs[assetId],
+ references: prevURLs[assetId].references + 1,
+ },
+ };
+ }
+
+ function createReference(prevURLs) {
+ return {
+ ...prevURLs,
+ [assetId]: { url: null, 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 {
+ return createReference(prevURLs);
+ }
+ });
+ }
+
+ updateAssetURL();
+
+ return () => {
+ // Decrease references
+ setAssetURLs((prevURLs) => {
+ if (assetId in prevURLs) {
+ return {
+ ...prevURLs,
+ [assetId]: {
+ ...prevURLs[assetId],
+ references: prevURLs[assetId].references - 1,
+ },
+ };
+ } else {
+ return prevURLs;
+ }
+ });
+ };
+ }, [assetId, setAssetURLs, type]);
+
+ 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.tsx b/src/contexts/AuthContext.tsx
index 70f44ea..3ebd255 100644
--- a/src/contexts/AuthContext.tsx
+++ b/src/contexts/AuthContext.tsx
@@ -1,11 +1,8 @@
-import React, { useState, useEffect, useContext, SetStateAction } from "react";
-import shortid from "shortid";
-
-import { useDatabase } from "./DatabaseContext";
+import React, { useState, useEffect, useContext } from "react";
import FakeStorage from "../helpers/FakeStorage";
-type AuthContext = { userId: string; password: string; setPassword: React.Dispatch; }
+type AuthContext = { password: string; setPassword: React.Dispatch };
// TODO: check what default value we want here
const AuthContext = React.createContext(undefined);
@@ -20,37 +17,16 @@ try {
storage = new FakeStorage();
}
-export function AuthProvider({ children }: { children: any }) {
- const { database, databaseStatus } = useDatabase();
-
- const [password, setPassword] = useState(storage.getItem("auth") || "");
+export function AuthProvider({ children }: { children: React.ReactNode }) {
+ const [password, setPassword] = useState(
+ storage.getItem("auth") || ""
+ );
useEffect(() => {
storage.setItem("auth", password);
}, [password]);
- // TODO: check pattern here -> undefined or empty default values
- const [userId, setUserId]: [ userId: string, setUserId: React.Dispatch> ] = 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.tsx b/src/contexts/DatabaseContext.tsx
index 8a9ee08..167ef6b 100644
--- a/src/contexts/DatabaseContext.tsx
+++ b/src/contexts/DatabaseContext.tsx
@@ -1,5 +1,6 @@
-import React, { useState, useEffect, useContext, SetStateAction } from "react";
-import Comlink, { Remote } from "comlink";
+import React, { useState, useEffect, useContext } from "react";
+import Dexie from "dexie";
+import * as Comlink from "comlink";
import ErrorBanner from "../components/banner/ErrorBanner";
@@ -7,30 +8,48 @@ import { getDatabase } from "../database";
//@ts-ignore
import DatabaseWorker from "worker-loader!../workers/DatabaseWorker"; // eslint-disable-line import/no-webpack-loader-syntax
-import Dexie from "dexie";
-type DatabaseContext = { database: Dexie | undefined; databaseStatus: any; databaseError: Error | undefined; worker: Remote; }
+type DatabaseContext = {
+ database: Dexie | undefined;
+ databaseStatus: any;
+ databaseError: Error | undefined;
+ worker: Comlink.Remote;
+};
// TODO: check what default we want here
-const DatabaseContext = React.createContext< DatabaseContext | undefined>(undefined);
+const DatabaseContext =
+ React.createContext(undefined);
const worker = Comlink.wrap(new DatabaseWorker());
-export function DatabaseProvider({ children }: { children: any}) {
- const [database, setDatabase]: [ database: Dexie | undefined, setDatabase: React.Dispatch>] = useState();
- const [databaseStatus, setDatabaseStatus]: [ datebaseStatus: any, setDatabaseStatus: React.Dispatch>] = useState("loading");
- const [databaseError, setDatabaseError]: [ databaseError: Error | undefined, setDatabaseError: React.Dispatch>] = useState();
+export function DatabaseProvider({ children }: { children: React.ReactNode }) {
+ const [database, setDatabase] = useState();
+ const [databaseStatus, setDatabaseStatus] =
+ useState<"loading" | "disabled" | "upgrading" | "loaded">("loading");
+ const [databaseError, setDatabaseError] = useState();
useEffect(() => {
// Create a test database and open it to see if indexedDB is enabled
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,
+ () => {
+ 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");
};
@@ -48,20 +67,35 @@ export function DatabaseProvider({ children }: { children: any}) {
window.indexedDB.deleteDatabase("__test");
};
- function handleDatabaseError(event: any) {
- event.preventDefault();
- if (event.reason?.message.startsWith("QuotaExceededError")) {
- setDatabaseError({
- name: event.reason.name,
- message: "Storage Quota Exceeded Please Clear Space and Try Again.",
- });
- } else {
- setDatabaseError({
- name: event.reason.name,
- message: "Something went wrong, please refresh your browser.",
- });
+ function handleDatabaseError(event: PromiseRejectionEvent) {
+ if (event) {
+ event.preventDefault();
+ if (event.reason instanceof Dexie.DexieError) {
+ if (event.reason?.inner?.name === "QuotaExceededError") {
+ setDatabaseError({
+ name: event.reason?.name,
+ message:
+ "Storage Quota Exceeded Please Clear Space and Try Again.",
+ });
+ } else if (event.reason?.inner?.name === "DatabaseClosedError") {
+ setDatabaseError({
+ name: event.reason?.name,
+ message: "Database closed, please refresh your browser.",
+ });
+ } else {
+ setDatabaseError({
+ name: event.reason?.name,
+ message: "Something went wrong, please refresh your browser.",
+ });
+ }
+ } else {
+ setDatabaseError({
+ 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..3b2138d
--- /dev/null
+++ b/src/contexts/GroupContext.js
@@ -0,0 +1,240 @@
+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) {
+ const openGroups = groupsFromIds([openGroupId], groups);
+ if (openGroups.length === 1) {
+ const openGroup = openGroups[0];
+ setOpenGroupItems(getGroupItems(openGroup));
+ } else {
+ // Close group if we can't find it
+ // This can happen if it was deleted or all it's items were deleted
+ setOpenGroupItems([]);
+ setOpenGroupId();
+ }
+ } 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/MapDataContext.tsx b/src/contexts/MapDataContext.tsx
index 1f6f83e..b913d00 100644
--- a/src/contexts/MapDataContext.tsx
+++ b/src/contexts/MapDataContext.tsx
@@ -3,189 +3,140 @@ import React, {
useState,
useContext,
useCallback,
- useRef,
- ReactChild,
+ useMemo,
} from "react";
-import * as Comlink from "comlink";
-import { decode, encode } from "@msgpack/msgpack";
+import { useLiveQuery } from "dexie-react-hooks";
-import { useAuth } from "./AuthContext";
import { useDatabase } from "./DatabaseContext";
-import { maps as defaultMaps } from "../maps";
-import { Map, MapState, Note, TokenState } from "../components/map/Map";
-import { Fog } from "../helpers/drawing";
+import { Map, MapState, Note } from "../components/map/Map";
+import { removeGroupsItems } from "../helpers/group";
// TODO: fix differences in types between default maps and imported maps
type MapDataContext = {
- maps: Array
- {Object.values(maps).map((map: any) => (
-
- ))}
+ {mapGroups.map(renderMapGroup)}
>
)}
- {hasMaps && hasTokens && }
+ {hasMaps && hasTokens && }
{hasTokens && (
<>
- {Object.values(tokens).map((token: any) => (
-
-
- {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.tsx b/src/modals/SelectMapModal.tsx
index 88ce28b..8528067 100644
--- a/src/modals/SelectMapModal.tsx
+++ b/src/modals/SelectMapModal.tsx
@@ -1,63 +1,43 @@
-import { ChangeEvent, useRef, useState } from "react";
-import { Button, Flex, Label } from "theme-ui";
-import shortid from "shortid";
-import Case from "case";
+import { 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";
-import { Map, MapState } from "../components/map/Map";
+import { useUserId } from "../contexts/UserIdContext";
+import { useAssets } from "../contexts/AssetsContext";
+import { GroupProvider } from "../contexts/GroupContext";
+import { TileDragProvider } from "../contexts/TileDragContext";
type SelectMapProps = {
- isOpen: boolean,
- onDone: any,
- onMapChange: any,
- onMapReset: any,
- currentMap: any
-}
-
-const defaultMapProps = {
- showGrid: false,
- snapToGrid: true,
- quality: "original",
- group: "",
+ isOpen: boolean;
+ onDone: any;
+ onMapChange: any;
+ onMapReset: any;
+ currentMap: any;
};
-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" },
-];
-
function SelectMapModal({
isOpen,
onDone,
@@ -65,51 +45,30 @@ function SelectMapModal({
onMapReset,
// The map currently being view in the map screen
currentMap,
-}: SelectMapProps ) {
+}: SelectMapProps) {
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: ChangeEvent) {
- setSearch(event.target.value);
- }
-
- /**
- * Group
- */
- const [isGroupModalOpen, setIsGroupModalOpen] = useState(false);
-
- async function handleMapsGroup(group: any) {
- 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
@@ -118,9 +77,8 @@ function SelectMapModal({
const fileInputRef = useRef();
const [isLoading, setIsLoading] = useState(false);
- const [isLargeImageWarningModalOpen, setShowLargeImageWarning] = useState(
- false
- );
+ const [isLargeImageWarningModalOpen, setShowLargeImageWarning] =
+ useState(false);
const largeImageWarningFiles = useRef();
async function handleImagesUpload(files: any) {
@@ -159,6 +117,12 @@ function SelectMapModal({
}
}
+ function openImageDialog() {
+ if (fileInputRef.current) {
+ fileInputRef.current.click();
+ }
+ }
+
function handleLargeImageWarningCancel() {
largeImageWarningFiles.current = undefined;
setShowLargeImageWarning(false);
@@ -176,196 +140,13 @@ function SelectMapModal({
}
async function handleImageUpload(file: 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: any = {};
- for (let resolution of mapResolutions) {
- const resolutionPixelSize: Vector2 = Vector2.multiply(
- gridSize,
- resolution.size
- );
- if (
- image.width >= resolutionPixelSize.x &&
- image.height >= resolutionPixelSize.y
- ) {
- const resized = await resizeImage(
- image,
- Vector2.max(resolutionPixelSize, undefined) as number,
- 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(undefined);
- };
- 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: Map[] = ownedMaps.filter((map: Map) =>
- selectedMapIds.includes(map.id)
- );
- const selectedMapStates = mapStates.filter((state: MapState) =>
- selectedMapIds.includes(state.mapId)
- );
-
- async function handleMapAdd(map: any) {
+ 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: any) {
- handleItemSelect(
- map,
- selectMode,
- selectedMapIds,
- setSelectedMapIds,
- mapsByGroup,
- mapGroups
- );
- }
-
/**
* Modal Controls
*/
@@ -374,176 +155,157 @@ function SelectMapModal({
onDone();
}
- async function handleDone() {
+ /**
+ * Map Controls
+ */
+ async function handleMapSelect(mapId: string) {
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: KeyboardEvent): KeyboardEvent | void {
- 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: any) => 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: string[]) {
+ 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: KeyboardEvent) {
- if (!isOpen) {
- return;
- }
- if (shortcuts.selectRange(event) && selectMode === "range") {
- setSelectMode("single");
- }
- if (shortcuts.selectMultiple(event) && selectMode === "multiple") {
- setSelectMode("single");
+ function handleDragAdd(groupIds: string[]) {
+ 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: number, height: number) {
+ setModalSize({ width, height });
+ }
+
+ const editingMap =
+ editingMapId && maps.find((map) => map.id === editingMapId);
+ const editingMapState =
+ editingMapId && mapStates.find((state) => state.mapId === editingMapId);
+
return (
-
+
handleImagesUpload(event.target.files)}
type="file"
- accept="image/*"
+ accept="image/jpeg, image/gif, image/png, image/webp"
style={{ display: "none" }}
multiple
ref={fileInputRef}
/>
-
-
- 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)}
- />
-
-
+
+
+
+
+ setIsDraggingMap(true)}
+ onDragEnd={() => setIsDraggingMap(false)}
+ onDragCancel={() => setIsDraggingMap(false)}
+ >
+
+
+
+
+ setIsDraggingMap(true)}
+ onDragEnd={() => setIsDraggingMap(false)}
+ onDragCancel={() => setIsDraggingMap(false)}
+ >
+
+
+
+
+
+
+
+
+
+
+ <>{(isLoading || mapsLoading) && }>
<>
- {(isLoading || mapsLoading) && }
- >
- setIsEditModalOpen(false)}
- // TODO: check with Mitch what to do here if length > 1
- //selectedMaps.length === 1 &&
- mapId={selectedMaps[0].id}
- />
- group !== "" && group !== "default"
+ {editingMap && editingMapState && (
+ setEditingMapId(undefined)}
+ map={editingMap}
+ mapState={editingMapState}
+ onUpdateMap={updateMap}
+ onUpdateMapState={updateMapState}
+ />
)}
- onRequestClose={() => setIsGroupModalOpen(false)}
- // Select the default group by testing whether all selected maps are the same
- defaultGroup={
- selectedMaps.length > 0 &&
- selectedMaps
- .map((map: any) => map.group)
- .reduce((prev: any, curr: any) => (prev === curr ? curr : undefined))
- }
- />
- 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."
- />
+ >
void;
+ onMapTokensStateCreate: (states: TokenState[]) => void;
+};
+
+function SelectTokensModal({
+ isOpen,
+ onRequestClose,
+ onMapTokensStateCreate,
+}: SelectTokensModalProps) {
const { addToast } = useToasts();
- const { userId } = useAuth();
+ const userId = useUserId();
const {
- ownedTokens,
+ tokens,
addToken,
- removeTokens,
- updateTokens,
tokensLoading,
+ tokenGroups,
+ updateTokenGroups,
+ updateToken,
+ tokensById,
} = useTokenData();
+ const { addAssets } = useAssets();
- /**
- * Search
- */
- const [search, setSearch] = useState("");
- const [filteredTokens, filteredTokenScores] = useSearch(ownedTokens, search);
-
- function handleSearchChange(event: ChangeEvent) {
- setSearch(event.target.value);
- }
-
- /**
- * Group
- */
- const [isGroupModalOpen, setIsGroupModalOpen] = useState(false);
-
- async function handleTokensGroup(group: string) {
- setIsLoading(true);
- setIsGroupModalOpen(false);
- await updateTokens(selectedTokenIds, { group });
- setIsLoading(false);
- }
-
- const [tokensByGroup, tokenGroups] = useGroup(
- ownedTokens,
- filteredTokens,
- !!search,
- filteredTokenScores
- );
+ // Get token names for group filtering
+ const [tokenNames, setTokenNames] = useState(getItemNames(tokens));
+ useEffect(() => {
+ setTokenNames(getItemNames(tokens));
+ }, [tokens]);
/**
* Image Upload
@@ -74,18 +75,11 @@ function SelectTokensModal({ isOpen, onRequestClose }: { isOpen: boolean, onRequ
const fileInputRef = useRef();
const [isLoading, setIsLoading] = useState(false);
- const [isLargeImageWarningModalOpen, setShowLargeImageWarning] = useState(
- false
- );
+ const [isLargeImageWarningModalOpen, setShowLargeImageWarning] =
+ useState(false);
const largeImageWarningFiles = useRef();
- function openImageDialog() {
- if (fileInputRef.current) {
- fileInputRef.current.click();
- }
- }
-
- async function handleImagesUpload(files: FileList | null) {
+ async function handleImagesUpload(files: FileList) {
if (navigator.storage) {
// Attempt to enable persistant storage
await navigator.storage.persist();
@@ -126,6 +120,12 @@ function SelectTokensModal({ isOpen, onRequestClose }: { isOpen: boolean, onRequ
}
}
+ function openImageDialog() {
+ if (fileInputRef.current) {
+ fileInputRef.current.click();
+ }
+ }
+
function handleLargeImageWarningCancel() {
largeImageWarningFiles.current = undefined;
setShowLargeImageWarning(false);
@@ -146,238 +146,177 @@ function SelectTokensModal({ isOpen, onRequestClose }: { isOpen: boolean, onRequ
}
async function handleImageUpload(file: 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(undefined);
- };
- 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: FileToken) {
- 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: boolean) {
- setIsLoading(true);
- await updateTokens(selectedTokenIds, { hideInSidebar });
- setIsLoading(false);
- }
-
- // Either single, multiple or range
- const [selectMode, setSelectMode] = useState("single");
-
- async function handleTokenSelect(token: Token) {
- handleItemSelect(
- token,
- selectMode,
- selectedTokenIds,
- setSelectedTokenIds,
- tokensByGroup,
- tokenGroups
+ const mapStageRef = useMapStage();
+ function handleTokensAddToMap(groupIds: string[], rect: any) {
+ let clientPosition = new Vector2(
+ rect.width / 2 + rect.left,
+ rect.height / 2 + rect.top
);
- }
-
- /**
- * Shortcuts
- */
- function handleKeyDown(event: KeyboardEvent) {
- 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: KeyboardEvent) {
- 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();
- let tokenId;
- if (selectedTokens.length === 1 && selectedTokens[0].id) {
- tokenId = selectedTokens[0].id
- } else {
- // TODO: handle tokenId not found
- tokenId = ""
+ const [modalSize, setModalSize] = useState({ width: 0, height: 0 });
+ function handleModalResize(width: number, height: number) {
+ setModalSize({ width, height });
}
+ const editingToken =
+ editingTokenId && tokens.find((token) => token.id === editingTokenId);
+
return (
-
+
handleImagesUpload(event.target.files)}
+ onChange={(event: React.ChangeEvent) =>
+ event.target.files && handleImagesUpload(event.target.files)
+ }
type="file"
- accept="image/*"
+ accept="image/jpeg, image/gif, image/png, image/webp"
style={{ display: "none" }}
ref={fileInputRef}
multiple
/>
-
-
- setIsEditModalOpen(true)}
- onTokensRemove={() => setIsTokensRemoveModalOpen(true)}
- selectedTokens={selectedTokens}
- onTokenSelect={handleTokenSelect}
- selectMode={selectMode}
- onSelectModeChange={setSelectMode}
- search={search}
- onSearchChange={handleSearchChange}
- onTokensGroup={() => setIsGroupModalOpen(true)}
- onTokensHide={handleTokensHide}
- />
-
-
+
+
+
+
+ setIsDraggingToken(true)}
+ onDragEnd={() => setIsDraggingToken(false)}
+ onDragCancel={() => setIsDraggingToken(false)}
+ >
+
+
+
+
+ setIsDraggingToken(true)}
+ onDragEnd={() => setIsDraggingToken(false)}
+ onDragCancel={() => setIsDraggingToken(false)}
+ >
+
+
+
+
+
+
+
+
+
+
+ <>{(isLoading || tokensLoading) && }>
<>
- {(isLoading || tokensLoading) && }
- >
- setIsEditModalOpen(false)}
- tokenId={tokenId}
- />
- group !== "" && group !== "default"
+ {editingToken && (
+ setEditingTokenId(undefined)}
+ token={editingToken}
+ onUpdateToken={updateToken}
+ />
)}
- 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))
- }
- />
- setIsTokensRemoveModalOpen(false)}
- onConfirm={handleTokensRemove}
- confirmText="Remove"
- label={`Remove ${selectedTokenIds.length} Token${
- selectedTokenIds.length > 1 ? "s" : ""
- }`}
- description="This operation cannot be undone."
- />
+ >
void }) {
+function SettingsModal({
+ isOpen,
+ onRequestClose,
+}: {
+ isOpen: boolean;
+ onRequestClose: () => void;
+}) {
const { database, databaseStatus } = useDatabase();
- const { userId } = useAuth();
+ const userId = useUserId();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [labelSize, setLabelSize] = useSetting("map.labelSize");
const [gridSnappingSensitivity, setGridSnappingSensitivity] = useSetting(
@@ -58,9 +64,14 @@ function SettingsModal({ isOpen, onRequestClose }: { isOpen: boolean, onRequestC
async function handleEraseAllData() {
setIsLoading(true);
- localStorage.clear();
- await database?.delete();
- window.location.reload();
+ try {
+ localStorage.clear();
+ database?.close();
+ await database?.delete();
+ } catch {
+ } finally {
+ window.location.reload();
+ }
}
async function handleClearCache() {
@@ -68,32 +79,28 @@ function SettingsModal({ isOpen, onRequestClose }: { isOpen: boolean, onRequestC
// Clear saved settings
localStorage.clear();
- //TODO: handle id database is undefined
- if (!database) {
- return;
- }
- // Clear map cache
- await database.table("maps").where("owner").notEqual(userId).delete();
- // Find all other peoples tokens who aren't benig used in a map state and delete them
- const tokens = await database
- .table("tokens")
- .where("owner")
- .notEqual(userId)
- .toArray();
- const states: MapState[] = await database?.table("states").toArray();
- for (let token of tokens) {
- let inUse = false;
- for (let state of states) {
- for (let tokenState of Object.values(state.tokens)) {
- if (token.id === tokenState.tokenId) {
- inUse = true;
+ if (database && userId) {
+ const assets = await database
+ .table("assets")
+ .where("owner")
+ .notEqual(userId)
+ .toArray();
+ const states: MapState[] = await database.table("states").toArray();
+ for (let asset of assets) {
+ let inUse = false;
+ for (let state of states) {
+ for (let tokenState of Object.values(state.tokens)) {
+ if (tokenState.type === "file" && asset.id === tokenState.file) {
+ inUse = true;
+ }
}
}
- }
- if (!inUse) {
- database.table("tokens").delete(token.id);
+ if (!inUse) {
+ await database.table("assets").delete(asset.id);
+ }
}
}
+
window.location.reload();
}
@@ -191,13 +198,14 @@ function SettingsModal({ isOpen, onRequestClose }: { isOpen: boolean, onRequestC
Import / Export Data
- {storageEstimate !&& (
+ {storageEstimate! && (
Storage Used: {prettyBytes(storageEstimate.usage as number)} of{" "}
{prettyBytes(storageEstimate.quota as number)} (
{Math.round(
- (storageEstimate.usage as number / Math.max(storageEstimate.quota as number, 1)) *
+ ((storageEstimate.usage as number) /
+ Math.max(storageEstimate.quota as number, 1)) *
100
)}
%)
diff --git a/src/network/Connection.ts b/src/network/Connection.ts
index dd2f3ea..5fc6814 100644
--- a/src/network/Connection.ts
+++ b/src/network/Connection.ts
@@ -58,26 +58,22 @@ class Connection extends SimplePeer {
}
}
- // Custom send function with encoding, chunking and data channel support
- // Uses `write` to send the data to allow for buffer / backpressure handling
- sendObject(object: any, channel: any) {
+ /**
+ * Custom send function with encoding, chunking and data channel support
+ * Uses `write` to send the data to allow for buffer / backpressure handling
+ * @param {any} object
+ * @param {string=} channel
+ * @param {string=} chunkId Optional ID to use for chunking
+ */
+ sendObject(object: any, channel?: string, chunkId?: string) {
try {
const packedData = encode(object);
- if (packedData.byteLength > 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) {
@@ -105,11 +101,17 @@ class Connection extends SimplePeer {
}
// Converted from https://github.com/peers/peerjs/
- chunk(data: any) {
+ /**
+ * Chunk byte array
+ * @param {Uint8Array} data
+ * @param {string=} chunkId
+ * @returns {Uint8Array[]}
+ */
+ chunk(data: Uint8Array, chunkId?: string) {
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.tsx b/src/network/NetworkedMapAndTokens.tsx
index b692616..5ce0f22 100644
--- a/src/network/NetworkedMapAndTokens.tsx
+++ b/src/network/NetworkedMapAndTokens.tsx
@@ -1,12 +1,12 @@
import { 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";
@@ -16,11 +16,16 @@ import useNetworkedState from "../hooks/useNetworkedState";
// Load session for auto complete
import Session from "./Session";
-import Map, { MapState, Resolutions, TokenState } from "../components/map/Map";
-import Tokens from "../components/token/Tokens";
-import { PartyState } from "../components/party/PartyState";
import Action from "../actions/Action";
-import { Token } from "../tokens";
+
+import Map, {
+ MapState,
+ Map as MapType,
+ TokenState,
+} from "../components/map/Map";
+import TokenBar from "../components/token/TokenBar";
+
+import GlobalImageDrop from "../components/image/GlobalImageDrop";
const defaultMapActions = {
mapDrawActions: [],
@@ -39,27 +44,18 @@ const defaultMapActions = {
*/
function NetworkedMapAndTokens({ session }: { session: Session }) {
const { addToast } = useToasts();
- const { userId } = useAuth();
- const partyState: PartyState = useParty();
- const {
- assetLoadStart,
- assetLoadFinish,
- assetProgressUpdate,
- isLoading,
- } = useMapLoading();
+ const userId = useUserId();
+ const partyState = useParty();
+ 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]: [ currentMapState: MapState, setCurrentMapState: any] = useNetworkedState(
- null,
- session,
- "map_state",
- 500,
- true,
- "mapId"
- );
+ const [currentMapState, setCurrentMapState]: [
+ currentMapState: MapState,
+ setCurrentMapState: any
+ ] = useNetworkedState(null, session, "map_state", 500, true, "mapId");
const [assetManifest, setAssetManifest] = useNetworkedState(
null,
session,
@@ -69,58 +65,43 @@ function NetworkedMapAndTokens({ session }: { session: Session }) {
"mapId"
);
- async function loadAssetManifestFromMap(map: any, mapState: MapState) {
- const assets: any = {};
- if (map.type === "file") {
- const { id, lastModified, owner } = map;
- assets[`map-${id}`] = { type: "map", id, lastModified, owner };
- }
+ async function loadAssetManifestFromMap(map: MapType, mapState: MapState) {
+ const assets = {};
+ 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: any, b: any) {
- return a.type === b.type && a.id === b.id;
- }
-
- // Return true if an asset is out of date
- function assetNeedsUpdate(oldAsset: any, newAsset: any) {
- return (
- compareAssets(oldAsset, newAsset) &&
- oldAsset.lastModified < newAsset.lastModified
- );
- }
-
- function addAssetIfNeeded(asset: any) {
+ function addAssetsIfNeeded(assets: any[]) {
setAssetManifest((prevManifest: any) => {
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;
});
@@ -130,7 +111,7 @@ function NetworkedMapAndTokens({ session }: { session: Session }) {
const requestingAssetsRef = useRef(new Set());
useEffect(() => {
- if (!assetManifest) {
+ if (!assetManifest || !userId) {
return;
}
@@ -147,33 +128,25 @@ function NetworkedMapAndTokens({ session }: { session: 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);
}
}
}
@@ -183,11 +156,10 @@ function NetworkedMapAndTokens({ session }: { session: Session }) {
assetManifest,
partyState,
session,
- getMapFromDB,
- getTokenFromDB,
- updateMap,
userId,
addToast,
+ getAsset,
+ assetLoadStart,
]);
/**
@@ -217,12 +189,8 @@ function NetworkedMapAndTokens({ session }: { session: 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;
@@ -238,7 +206,12 @@ function NetworkedMapAndTokens({ session }: { session: Session }) {
const [mapActions, setMapActions] = useState(defaultMapActions);
- function addMapActions(actions: Action[], indexKey: string, actionsKey: any, shapesKey: any) {
+ function addMapActions(
+ actions: Action[],
+ indexKey: string,
+ actionsKey: any,
+ shapesKey: any
+ ) {
setMapActions((prevMapActions: any) => {
const newActions = [
...prevMapActions[actionsKey].slice(0, prevMapActions[indexKey] + 1),
@@ -266,7 +239,12 @@ function NetworkedMapAndTokens({ session }: { session: Session }) {
});
}
- function updateActionIndex(change: any, indexKey: any, actionsKey: any, shapesKey: any) {
+ function updateActionIndex(
+ change: any,
+ indexKey: any,
+ actionsKey: any,
+ shapesKey: any
+ ) {
const prevIndex: any = mapActions[indexKey];
const newIndex = Math.min(
Math.max(mapActions[indexKey] + change, -1),
@@ -369,23 +347,28 @@ function NetworkedMapAndTokens({ session }: { session: Session }) {
* Token state
*/
- async function handleMapTokenStateCreate(tokenState: TokenState) {
+ async function handleMapTokensStateCreate(tokenStates: TokenState[]) {
if (!currentMap || !currentMapState) {
return;
}
- // If file type token send the token to the other peers
- const token: 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: any) => ({
- ...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: any) {
@@ -415,114 +398,51 @@ function NetworkedMapAndTokens({ session }: { session: Session }) {
}
useEffect(() => {
- // TODO: edit Map type with appropriate resolutions
- async function handlePeerData({ id, data, reply }: { id: string, data: any, reply: any}) {
- if (id === "mapRequest") {
- const map = await getMapFromDB(data);
- function replyWithMap(preview?: string | undefined, resolution?: any) {
- let response = {
- ...map,
- 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 (preview !== undefined && map.resolutions && map.resolutions[preview]) {
- response.resolutions = { [preview]: map.resolutions[preview] } as Resolutions;
- reply("mapResponse", response, "map");
- }
- // Send full map at the desired resolution if available
- if (map.resolutions && map.resolutions[resolution]) {
- response.file = map.resolutions[resolution].file as Uint8Array;
- } 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();
+ async function handlePeerData({
+ id,
+ data,
+ reply,
+ }: {
+ id: string;
+ data: any;
+ reply: any;
+ }) {
+ 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 }: { id: string, total: number, count: number}) {
- if (count === 1) {
- // Corresponding asset load finished called in token and map response
- assetLoadStart();
- }
+ function handlePeerDataProgress({
+ id,
+ total,
+ count,
+ }: {
+ id: string;
+ total: number;
+ count: number;
+ }) {
assetProgressUpdate({ id, total, count });
}
async function handleSocketMap(map: any) {
if (map) {
- if (map.type === "file") {
- const fullMap = await getMapFromDB(map.id);
- setCurrentMap(fullMap || map);
- } else {
- setCurrentMap(map);
- }
+ setCurrentMap(map);
} else {
setCurrentMap(null);
}
@@ -575,7 +495,10 @@ function NetworkedMapAndTokens({ session }: { session: Session }) {
}
return (
- <>
+
-
- >
+
+
);
}
diff --git a/src/network/NetworkedMapPointer.tsx b/src/network/NetworkedMapPointer.tsx
index 8b58304..2d81a78 100644
--- a/src/network/NetworkedMapPointer.tsx
+++ b/src/network/NetworkedMapPointer.tsx
@@ -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,8 +13,14 @@ import Session from "./Session";
// Send pointer updates every 50ms (20fps)
const sendTickRate = 50;
-function NetworkedMapPointer({ session, active }: { session: Session, active: boolean }) {
- const { userId } = useAuth();
+function NetworkedMapPointer({
+ session,
+ active,
+}: {
+ session: Session;
+ active: boolean;
+}) {
+ const userId = useUserId();
const [localPointerState, setLocalPointerState] = useState({});
const [pointerColor] = useSetting("pointer.color");
@@ -39,7 +45,9 @@ function NetworkedMapPointer({ session, active }: { session: Session, active: bo
// Send pointer updates every sendTickRate to peers to save on bandwidth
// We use requestAnimationFrame as setInterval was being blocked during
// re-renders on Chrome with Windows
- const ownPointerUpdateRef: React.MutableRefObject<{ position: any; visible: boolean; id: any; color: any; } | undefined | null > = useRef();
+ const ownPointerUpdateRef: React.MutableRefObject<
+ { position: any; visible: boolean; id: any; color: any } | undefined | null
+ > = useRef();
useEffect(() => {
let prevTime = performance.now();
let request = requestAnimationFrame(update);
diff --git a/src/network/Session.ts b/src/network/Session.ts
index 178745c..8621e5c 100644
--- a/src/network/Session.ts
+++ b/src/network/Session.ts
@@ -26,7 +26,8 @@ export type SessionPeer = {
* @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
*/
type peerReply = (id: string, data: SimplePeerData, channel: string) => void;
@@ -126,7 +127,7 @@ class Session extends EventEmitter {
}
disconnect() {
- this.socket.disconnect();
+ this.socket?.disconnect();
}
/**
@@ -135,9 +136,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: string, eventId: string, data: SimplePeerData, channel?: string) {
+ sendTo(sessionId: string, eventId: string, data: any, channel?: string, chunkId?: string) {
if (!(sessionId in this.peers)) {
if (!this._addPeer(sessionId, true)) {
return;
@@ -148,7 +150,8 @@ class Session extends EventEmitter {
this.peers[sessionId].connection.once("connect", () => {
this.peers[sessionId].connection.sendObject(
{ id: eventId, data },
- channel
+ channel,
+ chunkId
);
});
} else {
@@ -245,9 +248,9 @@ class Session extends EventEmitter {
const peer = { id, connection, initiator, ready: false };
- const sendPeer = (id: string, data: SimplePeerData, channel: any) => {
- peer.connection.sendObject({ id, data }, channel);
- };
+ function reply(id: string, data: any, channel?: string, chunkId?: string) {
+ peer.connection.sendObject({ id, data }, channel, chunkId);
+ }
const handleSignal = (signal: any) => {
this.socket.emit("signal", JSON.stringify({ to: peer.id, signal }));
@@ -265,12 +268,8 @@ class Session extends EventEmitter {
* @property {SessionPeer} peer
* @property {peerReply} reply
*/
- const peerConnectEvent: { peer: SessionPeer; reply: peerReply } = {
- peer,
- reply: sendPeer,
- };
- this.emit("peerConnect", peerConnectEvent);
- };
+ this.emit("peerConnect", { peer, reply });
+ }
const handleDataComplete = (data: any) => {
/**
@@ -292,7 +291,7 @@ class Session extends EventEmitter {
peer,
id: data.id,
data: data.data,
- reply: sendPeer,
+ reply: reply,
};
console.log(`Data: ${JSON.stringify(data)}`)
this.emit("peerData", peerDataEvent);
@@ -312,7 +311,7 @@ class Session extends EventEmitter {
id,
count,
total,
- reply: sendPeer,
+ reply,
});
};
diff --git a/src/routes/Game.tsx b/src/routes/Game.tsx
index 204fd3f..2301c56 100644
--- a/src/routes/Game.tsx
+++ b/src/routes/Game.tsx
@@ -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";
@@ -44,7 +49,10 @@ function Game() {
}, [session]);
// Handle session errors
- const [peerError, setPeerError]: [ peerError: any, setPeerError: React.Dispatch] = useState(null);
+ const [peerError, setPeerError]: [
+ peerError: any,
+ setPeerError: React.Dispatch
+ ] = useState(null);
useEffect(() => {
function handlePeerError({ error }: { error: any }) {
if (error.code === "ERR_WEBRTC_SUPPORT") {
@@ -84,16 +92,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: string) {
- if (databaseStatus !== "loading") {
+ if (databaseStatus === "loaded" || databaseStatus === "disabled") {
session.joinGame(gameId, newPassword);
}
}
@@ -103,50 +113,63 @@ function Game() {
const mapStageRef: React.MutableRefObject = 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.tsx b/src/routes/ReleaseNotes.tsx
index 7e0ccf0..bae6afb 100644
--- a/src/routes/ReleaseNotes.tsx
+++ b/src/routes/ReleaseNotes.tsx
@@ -25,6 +25,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();
@@ -49,13 +50,18 @@ function ReleaseNotes() {
Release Notes
+
diff --git a/src/theme.ts b/src/theme.ts
index 305dcbb..b3baef4 100644
--- a/src/theme.ts
+++ b/src/theme.ts
@@ -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.ts b/src/tokens/index.ts
index 9933d8f..2009a53 100644
--- a/src/tokens/index.ts
+++ b/src/tokens/index.ts
@@ -83,47 +83,59 @@ function getDefaultTokenSize(key: string) {
type TokenCategory = "character" | "vehicle" | "prop"
-export type Token = {
+export type BaseToken = {
id: string,
name: string,
defaultSize: number,
- category: TokenCategory,
+ defaultCategory: TokenCategory,
+ defaultLabel: string,
hideInSidebar: boolean,
width: number,
height: number,
owner: string,
- type: string,
- group: string | undefined,
created: number,
lastModified: number,
lastUsed: number,
}
-export interface DefaultToken extends Omit {
- id?: string,
- owner?: string,
- created?: number,
- lastModified?: number,
- lastUsed?: number,
+export interface DefaultToken extends BaseToken {
key: string,
type: "default",
- group: "default",
}
-export interface FileToken extends Token {
+
+export interface FileToken extends BaseToken {
file: Uint8Array,
thumbnail: ImageFile,
type: "file",
}
-export const tokens: DefaultToken[] = Object.keys(tokenSources).map((key) => ({
- key,
- name: Case.capital(key),
- type: "default",
- defaultSize: getDefaultTokenSize(key),
- category: "character",
- hideInSidebar: false,
- width: 256,
- height: 256,
- group: "default",
-}));
+
+export type Token = DefaultToken | FileToken;
+
+export function getDefaultTokens(userId: string) {
+ 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..a90dd5a
--- /dev/null
+++ b/src/upgrade.js
@@ -0,0 +1,948 @@
+// 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 chunk from "lodash.chunk";
+
+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(async (tx) => {
+ onUpgrade?.(24);
+
+ const primaryKeys = await Dexie.waitFor(
+ tx.table("maps").toCollection().primaryKeys()
+ );
+ const keyChunks = chunk(primaryKeys, 4);
+
+ for (let keys of keyChunks) {
+ let assets = [];
+ let maps = await Dexie.waitFor(tx.table("maps").bulkGet(keys));
+ while (maps.length > 0) {
+ const map = maps.pop();
+ 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,
+ });
+ }
+ if (map.thumbnail) {
+ 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",
+ });
+ }
+ }
+ maps = null;
+ await tx.table("assets").bulkAdd(assets);
+ assets = null;
+ }
+ });
+ },
+ // v1.9.0 - Move token assets into new table
+ 25(v, onUpgrade) {
+ v.stores({}).upgrade(async (tx) => {
+ onUpgrade?.(25);
+
+ const primaryKeys = await Dexie.waitFor(
+ tx.table("tokens").toCollection().primaryKeys()
+ );
+ const keyChunks = chunk(primaryKeys, 4);
+
+ for (let keys of keyChunks) {
+ let assets = [];
+ let tokens = await Dexie.waitFor(tx.table("tokens").bulkGet(keys));
+ while (tokens.length > 0) {
+ let token = tokens.pop();
+ assets.push({
+ id: uuid(),
+ owner: token.owner,
+ file: token.file,
+ width: token.width,
+ height: token.height,
+ mime: "",
+ prevId: token.id,
+ prevType: "token",
+ });
+ if (token.thumbnail) {
+ 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",
+ });
+ }
+ }
+ tokens = null;
+ await tx.table("assets").bulkAdd(assets);
+ assets = null;
+ }
+ });
+ },
+ // v1.9.0 - Create foreign keys for assets
+ 26(v, onUpgrade) {
+ v.stores({}).upgrade(async (tx) => {
+ onUpgrade?.(26);
+
+ let mapUpdates = {};
+ let tokenUpdates = {};
+
+ const primaryKeys = await Dexie.waitFor(
+ tx.table("assets").toCollection().primaryKeys()
+ );
+ const keyChunks = chunk(primaryKeys, 4);
+
+ for (let keys of keyChunks) {
+ let assets = await Dexie.waitFor(tx.table("assets").bulkGet(keys));
+ while (assets.length > 0) {
+ const asset = assets.pop();
+ const { prevId, id, prevType, resolution } = asset;
+ if (prevType === "token" || prevType === "tokenThumbnail") {
+ if (!(prevId in tokenUpdates)) {
+ tokenUpdates[prevId] = {};
+ }
+ } else {
+ if (!(prevId in mapUpdates)) {
+ mapUpdates[prevId] = {};
+ }
+ }
+
+ if (prevType === "map") {
+ mapUpdates[prevId].file = id;
+ } else if (prevType === "token") {
+ tokenUpdates[prevId].file = id;
+ } else if (prevType === "mapThumbnail") {
+ mapUpdates[prevId].thumbnail = id;
+ } else if (prevType === "tokenThumbnail") {
+ tokenUpdates[prevId].thumbnail = id;
+ } else if (prevType === "mapResolution") {
+ mapUpdates[prevId][resolution] = id;
+ }
+ }
+ assets = null;
+ }
+
+ await tx
+ .table("maps")
+ .toCollection()
+ .modify((map) => {
+ if (map.id in mapUpdates) {
+ for (let key in mapUpdates[map.id]) {
+ map[key] = mapUpdates[map.id][key];
+ }
+ }
+ delete map.resolutions;
+ });
+ await tx
+ .table("tokens")
+ .toCollection()
+ .modify((token) => {
+ if (token.id in tokenUpdates) {
+ for (let key in tokenUpdates[token.id]) {
+ token[key] = tokenUpdates[token.id][key];
+ }
+ }
+ });
+ });
+ },
+ // v1.9.0 - Remove asset migration helpers
+ 27(v, onUpgrade) {
+ v.stores({}).upgrade((tx) => {
+ onUpgrade?.(27);
+ tx.table("assets").toCollection().modify({
+ prevId: undefined,
+ prevType: undefined,
+ resolution: undefined,
+ });
+ });
+ },
+ // 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) {
+ delete state.tokens[id];
+ continue;
+ }
+ 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;
+ });
+ });
+ },
+ 36(v) {
+ v.stores({
+ _changes: null,
+ _intercomm: null,
+ _syncNodes: null,
+ _uncommittedChanges: null,
+ });
+ },
+};
+
+export const latestVersion = 36;
+
+/**
+ * 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.ts b/src/workers/DatabaseWorker.ts
index 6959aee..6499764 100644
--- a/src/workers/DatabaseWorker.ts
+++ b/src/workers/DatabaseWorker.ts
@@ -17,27 +17,22 @@ 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
+ * @param {string=} key Optional database key to load, if undefined whole table will be loaded
*/
- async loadData(table: string, key: string, excludeFiles: boolean = true) {
+ async loadData(table: string, key?: string) {
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: any[] = [];
// 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
@@ -53,17 +48,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: Uint8Array, table: string, wait: boolean = true) {
+ async putData(data: Uint8Array, table: string) : Promise {
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;
@@ -72,23 +62,55 @@ let service = {
/**
* Export current database
- * @param {ProgressCallback} progressCallback
- * @param {string[]} maps An array of map ids to export
- * @param {string[]} tokens An array of token ids to export
+ * @param {function} progressCallback
+ * @param {string[]} mapIds An array of map ids to export
+ * @param {string[]} tokenIds An array of token ids to export
*/
- async exportData(progressCallback: ProgressCallback, maps: string[], tokens: string[]) {
+ async exportData(progressCallback: any, mapIds: string[], tokenIds: string[]) {
let db = getDatabase({});
- const filter = (table: string, value: any) => {
+ // 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;
};
@@ -136,7 +158,8 @@ let service = {
importDB = getDatabase(
{ addons: [] },
databaseName,
- importMeta.data.databaseVersion
+ importMeta.data.databaseVersion,
+ false
);
await importInto(importDB, data, {
progressCallback,
@@ -156,6 +179,42 @@ 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 assetSizes = [];
+ await db
+ .table("assets")
+ .where("owner")
+ .notEqual(userId)
+ .each((asset) => {
+ assetSizes.push({ id: asset.id, size: asset.file.byteLength });
+ });
+ const totalSize = assetSizes.reduce((acc, cur) => acc + cur.size, 0);
+ if (totalSize > maxCacheSize) {
+ // Remove largest assets first
+ const largestAssets = assetSizes.sort((a, b) => b.size - a.size);
+ let assetsToDelete = [];
+ let deletedBytes = 0;
+ for (let asset of largestAssets) {
+ assetsToDelete.push(asset.id);
+ deletedBytes += asset.size;
+ if (totalSize - deletedBytes < maxCacheSize) {
+ break;
+ }
+ }
+ await db.table("assets").bulkDelete(assetsToDelete);
+ }
+ } catch {}
+ },
};
Comlink.expose(service);
diff --git a/yarn.lock b/yarn.lock
index 0b1a73e..3343c59 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1820,6 +1820,37 @@
find-root "^1.1.0"
source-map "^0.5.7"
stylis "^4.0.3"
+
+"@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"
@@ -2032,11 +2063,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"
@@ -2310,68 +2336,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":
@@ -4476,15 +4512,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"
@@ -5299,6 +5330,13 @@ csstype@^3.0.5:
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.8.tgz#d2266a792729fb227cd216fb572f43728e1ad340"
integrity sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw==
+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"
@@ -5324,6 +5362,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"
@@ -5506,12 +5549,17 @@ detect-port-alt@1.1.6:
address "^1.0.1"
debug "^2.6.0"
-dexie-observable@^3.0.0-beta.10:
- version "3.0.0-beta.10"
- resolved "https://registry.yarnpkg.com/dexie-observable/-/dexie-observable-3.0.0-beta.10.tgz#ad7a7e136defbb62f9eab9198a5cb9e10bce1c87"
- integrity sha512-GMPwQMLh1nYqM1MYsOZudsIwSMqDMrAOBxNuw+Y2ijsrQTBPi3nRF2CinY02IdlmffkaU7DsDfnlgdaMEaiHTQ==
+dexie-react-hooks@^1.0.6:
+ version "1.0.6"
+ resolved "https://registry.yarnpkg.com/dexie-react-hooks/-/dexie-react-hooks-1.0.6.tgz#a21d116addb2bb785507bf924cc6e963d858d5d5"
+ integrity sha512-OFoOBC4BQzkVGicuWl/cIMtlPp0wTAnUXwUJzq+l/zp0XVGmwEWkemRFq7JbudJLT0DINFVVzgVhGV7KOUK7uA==
-"dexie@^3.0.0-alpha.5 || ^2.0.4", dexie@^3.0.3:
+dexie@3.1.0-beta.13:
+ version "3.1.0-beta.13"
+ resolved "https://registry.yarnpkg.com/dexie/-/dexie-3.1.0-beta.13.tgz#54b3438e2aca3b60f87a823a535ce1b4313056ec"
+ integrity sha512-pUcX9YyX1VDjF1oMqiOys6N2zoXIA/CeTghB3P4Ee77U8n9q0qa2pmNYoHyyYPKLU58+gzsMJuOc6HLPJDQrQQ==
+
+"dexie@^3.0.0-alpha.5 || ^2.0.4":
version "3.0.3"
resolved "https://registry.yarnpkg.com/dexie/-/dexie-3.0.3.tgz#ede63849dfe5f07e13e99bb72a040e8ac1d29dab"
integrity sha512-BSFhGpngnCl1DOr+8YNwBDobRMH0ziJs2vts69VilwetHYOtEDcLqo7d/XiIphM0tJZ2rPPyAGd31lgH2Ln3nw==
@@ -5537,6 +5585,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"
@@ -6813,6 +6866,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"
@@ -7311,6 +7381,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"
@@ -7418,13 +7505,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"
@@ -7442,6 +7522,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"
@@ -7449,6 +7534,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"
@@ -7530,7 +7620,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==
@@ -8345,6 +8435,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"
@@ -8580,6 +8675,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"
@@ -8588,6 +8690,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"
@@ -8635,6 +8744,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"
@@ -8673,6 +8789,11 @@ lodash.camelcase@^4.3.0:
resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6"
integrity sha1-soqmKIorn8ZRA1x3EfZathkDMaY=
+lodash.chunk@^4.2.0:
+ version "4.2.0"
+ resolved "https://registry.yarnpkg.com/lodash.chunk/-/lodash.chunk-4.2.0.tgz#66e5ce1f76ed27b4303d8c6512e8d1216e8106bc"
+ integrity sha1-ZuXOH3btJ7QwPYxlEujRIW6BBrw=
+
lodash.clonedeep@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef"
@@ -8728,6 +8849,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"
@@ -8817,6 +8943,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"
@@ -8939,11 +9070,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"
@@ -9167,6 +9310,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"
@@ -9195,6 +9354,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"
@@ -9452,6 +9616,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"
@@ -9666,6 +9835,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"
@@ -9888,6 +10064,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"
@@ -10905,6 +11086,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"
@@ -11110,6 +11296,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"
@@ -11392,7 +11587,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==
@@ -12729,6 +12924,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"
@@ -12859,6 +13059,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"
@@ -12884,6 +13089,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"
@@ -13049,7 +13259,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=
@@ -13199,11 +13409,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"
@@ -13266,6 +13495,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"