diff --git a/src/components/map/Map.js b/src/components/map/Map.js
index 6c06d5d..80e7d2f 100644
--- a/src/components/map/Map.js
+++ b/src/components/map/Map.js
@@ -3,7 +3,7 @@ import { Group } from "react-konva";
import MapControls from "./MapControls";
import MapInteraction from "./MapInteraction";
-import MapToken from "./MapToken";
+import MapTokens from "./MapTokens";
import MapDrawing from "./MapDrawing";
import MapFog from "./MapFog";
import MapGrid from "./MapGrid";
@@ -175,91 +175,17 @@ function Map({
setIsTokenMenuOpen(true);
}
- function getMapTokenCategoryWeight(category) {
- switch (category) {
- case "character":
- return 0;
- case "vehicle":
- return 1;
- case "prop":
- return 2;
- default:
- return 0;
- }
- }
-
- // 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) {
- return 1;
- } else if (tokenB) {
- return -1;
- } else {
- return 0;
- }
- }
-
const mapTokens = map && mapState && (
-
- {Object.values(mapState.tokens)
- .sort((a, b) => sortMapTokenStates(a, b, tokenDraggingOptions))
- .map((tokenState) => (
-
- setTokenDraggingOptions({
- dragging: true,
- tokenState,
- tokenGroup: e.target,
- })
- }
- onTokenDragEnd={() =>
- setTokenDraggingOptions({
- ...tokenDraggingOptions,
- dragging: false,
- })
- }
- draggable={
- selectedToolId === "pan" &&
- !(tokenState.id in disabledTokens) &&
- !tokenState.locked
- }
- mapState={mapState}
- fadeOnHover={selectedToolId === "drawing"}
- map={map}
- />
- ))}
-
+
);
const tokenMenu = (
diff --git a/src/components/map/MapInteraction.js b/src/components/map/MapInteraction.js
index 22650e3..03b7ff3 100644
--- a/src/components/map/MapInteraction.js
+++ b/src/components/map/MapInteraction.js
@@ -16,6 +16,9 @@ import { MapStageProvider, useMapStage } from "../../contexts/MapStageContext";
import AuthContext, { useAuth } from "../../contexts/AuthContext";
import SettingsContext, { useSettings } from "../../contexts/SettingsContext";
import KeyboardContext from "../../contexts/KeyboardContext";
+import TokenDataContext, {
+ useTokenData,
+} from "../../contexts/TokenDataContext";
import { GridProvider } from "../../contexts/GridContext";
import { useKeyboard } from "../../contexts/KeyboardContext";
@@ -178,6 +181,7 @@ function MapInteraction({
const auth = useAuth();
const settings = useSettings();
+ const tokenData = useTokenData();
const mapInteraction = {
stageScale,
@@ -227,7 +231,9 @@ function MapInteraction({
height={mapHeight}
>
- {mapLoaded && children}
+
+ {mapLoaded && children}
+
diff --git a/src/components/map/MapTile.js b/src/components/map/MapTile.js
index d143201..e8d6358 100644
--- a/src/components/map/MapTile.js
+++ b/src/components/map/MapTile.js
@@ -17,11 +17,7 @@ function MapTile({
}) {
const isDefault = map.type === "default";
const mapSource = useDataSource(
- isDefault
- ? map
- : map.resolutions && map.resolutions.low
- ? map.resolutions.low
- : map,
+ isDefault ? map : map.thumbnail,
defaultMapSources,
unknownSource
);
diff --git a/src/components/map/MapTokens.js b/src/components/map/MapTokens.js
new file mode 100644
index 0000000..33c07a3
--- /dev/null
+++ b/src/components/map/MapTokens.js
@@ -0,0 +1,131 @@
+import React, { useEffect } from "react";
+import { Group } from "react-konva";
+
+import MapToken from "./MapToken";
+
+import { useTokenData } from "../../contexts/TokenDataContext";
+
+function MapTokens({
+ map,
+ mapState,
+ tokenDraggingOptions,
+ setTokenDraggingOptions,
+ onMapTokenStateChange,
+ handleTokenMenuOpen,
+ 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":
+ return 0;
+ case "vehicle":
+ return 1;
+ case "prop":
+ return 2;
+ default:
+ return 0;
+ }
+ }
+
+ // 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) {
+ return 1;
+ } else if (tokenB) {
+ return -1;
+ } else {
+ return 0;
+ }
+ }
+
+ return (
+
+ {Object.values(mapState.tokens)
+ .sort((a, b) => sortMapTokenStates(a, b, tokenDraggingOptions))
+ .map((tokenState) => (
+
+ setTokenDraggingOptions({
+ dragging: true,
+ tokenState,
+ tokenGroup: e.target,
+ })
+ }
+ onTokenDragEnd={() =>
+ setTokenDraggingOptions({
+ ...tokenDraggingOptions,
+ dragging: false,
+ })
+ }
+ draggable={
+ selectedToolId === "pan" &&
+ !(tokenState.id in disabledTokens) &&
+ !tokenState.locked
+ }
+ mapState={mapState}
+ fadeOnHover={selectedToolId === "drawing"}
+ map={map}
+ />
+ ))}
+
+ );
+}
+
+export default MapTokens;
diff --git a/src/components/token/ListToken.js b/src/components/token/ListToken.js
index 3dc9d7d..d505c78 100644
--- a/src/components/token/ListToken.js
+++ b/src/components/token/ListToken.js
@@ -7,7 +7,12 @@ import useDataSource from "../../hooks/useDataSource";
import { tokenSources, unknownSource } from "../../tokens";
function ListToken({ token, className }) {
- const imageSource = useDataSource(token, tokenSources, unknownSource);
+ const isDefault = token.type === "default";
+ const tokenSource = useDataSource(
+ isDefault ? token : token.thumbnail,
+ tokenSources,
+ unknownSource
+ );
const imageRef = useRef();
// Stop touch to prevent 3d touch gesutre on iOS
@@ -16,7 +21,7 @@ function ListToken({ token, className }) {
return (
storedMaps.push(map));
+ await database.table("maps").each((map) => {
+ const { file, resolutions, ...rest } = map;
+ storedMaps.push(rest);
+ });
}
const sortedMaps = storedMaps.sort((a, b) => b.created - a.created);
const defaultMapsWithIds = await getDefaultMaps();
diff --git a/src/contexts/TokenDataContext.js b/src/contexts/TokenDataContext.js
index ae81e02..03c52fc 100644
--- a/src/contexts/TokenDataContext.js
+++ b/src/contexts/TokenDataContext.js
@@ -25,6 +25,10 @@ export function TokenDataProvider({ children }) {
const { database, databaseStatus } = useDatabase();
const { userId } = useAuth();
+ /**
+ * Contains all tokens without any file data,
+ * to ensure file data is present call loadTokens
+ */
const [tokens, setTokens] = useState([]);
const [tokensLoading, setTokensLoading] = useState(true);
@@ -32,7 +36,7 @@ export function TokenDataProvider({ children }) {
if (!userId || !database || databaseStatus === "loading") {
return;
}
- function getDefaultTokes() {
+ function getDefaultTokens() {
const defaultTokensWithIds = [];
for (let defaultToken of defaultTokens) {
defaultTokensWithIds.push({
@@ -45,6 +49,7 @@ export function TokenDataProvider({ children }) {
return defaultTokensWithIds;
}
+ // Loads tokens without the file data to save memory
async function loadTokens() {
let storedTokens = [];
// Try to load tokens with worker, fallback to database if failed
@@ -53,12 +58,13 @@ export function TokenDataProvider({ children }) {
storedTokens = decode(packedTokens);
} else {
console.warn("Unable to load tokens with worker, loading may be slow");
- await database
- .table("tokens")
- .each((token) => storedTokens.push(token));
+ await database.table("tokens").each((token) => {
+ const { file, resolutions, ...rest } = token;
+ storedTokens.push(rest);
+ });
}
const sortedTokens = storedTokens.sort((a, b) => b.created - a.created);
- const defaultTokensWithIds = getDefaultTokes();
+ const defaultTokensWithIds = getDefaultTokens();
const allTokens = [...sortedTokens, ...defaultTokensWithIds];
setTokens(allTokens);
setTokensLoading(false);
@@ -195,6 +201,35 @@ export function TokenDataProvider({ children }) {
[database, updateCache, userId]
);
+ const loadTokens = useCallback(
+ async (tokenIds) => {
+ const loadedTokens = await database.table("tokens").bulkGet(tokenIds);
+ const loadedTokensById = loadedTokens.reduce((obj, token) => {
+ obj[token.id] = token;
+ return obj;
+ }, {});
+ setTokens((prevTokens) => {
+ return prevTokens.map((prevToken) => {
+ if (prevToken.id in loadedTokensById) {
+ return loadedTokensById[prevToken.id];
+ } else {
+ return prevToken;
+ }
+ });
+ });
+ },
+ [database]
+ );
+
+ const unloadTokens = useCallback(async () => {
+ setTokens((prevTokens) => {
+ return prevTokens.map((prevToken) => {
+ const { file, ...rest } = prevToken;
+ return rest;
+ });
+ });
+ }, []);
+
const ownedTokens = tokens.filter((token) => token.owner === userId);
const tokensById = tokens.reduce((obj, token) => {
@@ -215,6 +250,8 @@ export function TokenDataProvider({ children }) {
tokensById,
tokensLoading,
getTokenFromDB,
+ loadTokens,
+ unloadTokens,
};
return (
diff --git a/src/database.js b/src/database.js
index 0757bbc..063df89 100644
--- a/src/database.js
+++ b/src/database.js
@@ -3,6 +3,7 @@ import Dexie from "dexie";
import blobToBuffer from "./helpers/blobToBuffer";
import { getGridDefaultInset } from "./helpers/grid";
import { convertOldActionsToShapes } from "./actions";
+import { createThumbnail } from "./helpers/image";
function loadVersions(db) {
// v1.2.0
@@ -332,6 +333,52 @@ function loadVersions(db) {
delete state.fogDrawActionIndex;
});
});
+
+ async function createDataThumbnail(data) {
+ const url = URL.createObjectURL(new Blob([data.file]));
+ return await Dexie.waitFor(
+ new Promise((resolve) => {
+ let image = new Image();
+ image.onload = async () => {
+ const thumbnail = await createThumbnail(image);
+ resolve(thumbnail);
+ };
+ image.src = url;
+ })
+ );
+ }
+
+ db.version(19)
+ .stores({})
+ .upgrade(async (tx) => {
+ const maps = await Dexie.waitFor(tx.table("maps").toArray());
+ const thumbnails = {};
+ for (let map of maps) {
+ thumbnails[map.id] = await createDataThumbnail(map);
+ }
+ return tx
+ .table("maps")
+ .toCollection()
+ .modify((map) => {
+ map.thumbnail = thumbnails[map.id];
+ });
+ });
+
+ db.version(20)
+ .stores({})
+ .upgrade(async (tx) => {
+ const tokens = await Dexie.waitFor(tx.table("tokens").toArray());
+ const thumbnails = {};
+ for (let token of tokens) {
+ thumbnails[token.id] = await createDataThumbnail(token);
+ }
+ return tx
+ .table("tokens")
+ .toCollection()
+ .modify((token) => {
+ token.thumbnail = thumbnails[token.id];
+ });
+ });
}
// Get the dexie database used in DatabaseContext
diff --git a/src/helpers/image.js b/src/helpers/image.js
index 7c5840f..df9868a 100644
--- a/src/helpers/image.js
+++ b/src/helpers/image.js
@@ -1,3 +1,5 @@
+import blobToBuffer from "./blobToBuffer";
+
const lightnessDetectionOffset = 0.1;
/**
@@ -35,11 +37,19 @@ export function getImageLightness(image) {
return norm + lightnessDetectionOffset >= 0;
}
+/**
+ * @typedef ResizedImage
+ * @property {Blob} blob
+ * @property {number} width
+ * @property {number} height
+ */
+
/**
* @param {HTMLImageElement} image the image to resize
* @param {number} size the size of the longest edge of the new image
* @param {string} type the mime type of the image
* @param {number} quality if image is a jpeg or webp this is the quality setting
+ * @returns {Promise}
*/
export async function resizeImage(image, size, type, quality) {
const width = image.width;
@@ -66,3 +76,25 @@ export async function resizeImage(image, size, type, quality) {
);
});
}
+
+export async function createThumbnail(
+ image,
+ type,
+ resolution = 300,
+ quality = 0.5
+) {
+ const thumbnailImage = await resizeImage(
+ image,
+ Math.min(resolution, image.width, image.height),
+ type,
+ quality
+ );
+ const thumbnailBuffer = await blobToBuffer(thumbnailImage.blob);
+ return {
+ file: thumbnailBuffer,
+ width: thumbnailImage.width,
+ height: thumbnailImage.height,
+ type: "file",
+ id: "thumbnail",
+ };
+}
diff --git a/src/modals/EditMapModal.js b/src/modals/EditMapModal.js
index dcfc4ed..e140e1b 100644
--- a/src/modals/EditMapModal.js
+++ b/src/modals/EditMapModal.js
@@ -1,9 +1,10 @@
-import React, { useState } from "react";
+import React, { useEffect, useState } from "react";
import { Button, Flex, Label } from "theme-ui";
import Modal from "../components/Modal";
import MapSettings from "../components/map/MapSettings";
import MapEditor from "../components/map/MapEditor";
+import LoadingOverlay from "../components/LoadingOverlay";
import { useMapData } from "../contexts/MapDataContext";
@@ -12,8 +13,28 @@ import { getGridDefaultInset } from "../helpers/grid";
import useResponsiveLayout from "../hooks/useResponsiveLayout";
-function EditMapModal({ isOpen, onDone, map, mapState }) {
- const { updateMap, updateMapState } = useMapData();
+function EditMapModal({ isOpen, onDone, mapId }) {
+ const { updateMap, updateMapState, getMapFromDB, mapStates } = useMapData();
+
+ const [isLoading, setIsLoading] = useState(true);
+ const [map, setMap] = useState();
+ const [mapState, setMapState] = useState();
+ // Load full map when modal is opened
+ useEffect(() => {
+ async function loadMap() {
+ setIsLoading(true);
+ setMap(await getMapFromDB(mapId));
+ setMapState(mapStates.find((state) => state.mapId === mapId));
+ setIsLoading(false);
+ }
+
+ if (isOpen && mapId) {
+ loadMap();
+ } else {
+ setMap();
+ setMapState();
+ }
+ }, [isOpen, mapId, getMapFromDB, mapStates]);
function handleClose() {
setMapSettingChanges({});
@@ -114,10 +135,23 @@ function EditMapModal({ isOpen, onDone, map, mapState }) {
-
+ {isLoading ? (
+
+
+
+ ) : (
+
+ )}
{
+ async function loadToken() {
+ setIsLoading(true);
+ setToken(await getTokenFromDB(tokenId));
+ setIsLoading(false);
+ }
+
+ if (isOpen && tokenId) {
+ loadToken();
+ } else {
+ setToken();
+ }
+ }, [isOpen, tokenId, getTokenFromDB]);
function handleClose() {
setTokenSettingChanges({});
@@ -67,7 +84,20 @@ function EditTokenModal({ isOpen, onDone, token }) {
-
+ {isLoading ? (
+
+
+
+ ) : (
+
+ )}
- {(imageLoading || mapsLoading) && }
+ {(isLoading || mapsLoading) && }
setIsEditModalOpen(false)}
- map={selectedMaps.length === 1 && selectedMaps[0]}
- mapState={selectedMapStates.length === 1 && selectedMapStates[0]}
+ mapId={selectedMaps.length === 1 && selectedMaps[0].id}
/>
{
- image.onload = function () {
+ image.onload = async function () {
+ const thumbnail = await createThumbnail(image, file.type);
+
handleTokenAdd({
file: buffer,
+ thumbnail,
name,
id: shortid.generate(),
type: "file",
@@ -126,7 +135,7 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
width: image.width,
height: image.height,
});
- setImageLoading(false);
+ setIsLoading(false);
resolve();
};
image.onerror = reject;
@@ -268,18 +277,18 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
/>
- {tokensLoading && }
+ {(isLoading || tokensLoading) && }
setIsEditModalOpen(false)}
- token={selectedTokens.length === 1 && selectedTokens[0]}
+ tokenId={selectedTokens.length === 1 && selectedTokens[0].id}
/>
items.push(item));
+ await db.table(table).each((item) => {
+ if (excludeFiles) {
+ const { file, resolutions, ...rest } = item;
+ items.push(rest);
+ } else {
+ items.push(item);
+ }
+ });
// Pack data with msgpack so we can use transfer to avoid memory issues
const packed = encode(items);