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);