Move tokenState to work without backing token, add asset sync

This commit is contained in:
Mitchell McCaffrey 2021-04-23 17:10:10 +10:00
parent a023ef61ed
commit 245a9cee43
13 changed files with 198 additions and 285 deletions

View File

@ -24,7 +24,6 @@ import TokenLabel from "../token/TokenLabel";
import { tokenSources, unknownSource } from "../../tokens";
function MapToken({
token,
tokenState,
onTokenStateChange,
onTokenMenuOpen,
@ -43,7 +42,7 @@ function MapToken({
const gridCellPixelSize = useGridCellPixelSize();
const tokenSource = useDataURL(token, tokenSources, unknownSource);
const tokenSource = useDataURL(tokenState, tokenSources, unknownSource);
const [tokenSourceImage, tokenSourceStatus] = useImage(tokenSource);
const [tokenAspectRatio, setTokenAspectRatio] = useState(1);
@ -59,7 +58,7 @@ function MapToken({
const tokenGroup = event.target;
const tokenImage = imageRef.current;
if (token && token.category === "vehicle") {
if (tokenState.category === "vehicle") {
// Enable hit detection for .intersects() function
Konva.hitOnDragEnabled = true;
@ -99,7 +98,7 @@ function MapToken({
const tokenGroup = event.target;
const mountChanges = {};
if (token && token.category === "vehicle") {
if (tokenState.category === "vehicle") {
Konva.hitOnDragEnabled = false;
const parent = tokenGroup.getParent();
@ -196,8 +195,16 @@ function MapToken({
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
if (
tokenSourceStatus === "loaded" &&
tokenWidth > 0 &&
tokenHeight > 0 &&
tokenSourceImage
) {
const maxImageSize = Math.max(
tokenSourceImage.width,
tokenSourceImage.height
);
const maxTokenSize = Math.max(tokenWidth, tokenHeight);
// Constrain image buffer to original image size
const maxRatio = maxImageSize / maxTokenSize;
@ -210,7 +217,13 @@ function MapToken({
});
image.drawHitFromCache();
}
}, [debouncedStageScale, tokenWidth, tokenHeight, tokenSourceStatus, token]);
}, [
debouncedStageScale,
tokenWidth,
tokenHeight,
tokenSourceStatus,
tokenSourceImage,
]);
// Animate to new token positions if edited by others
const tokenX = tokenState.x * mapWidth;
@ -232,8 +245,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";

View File

@ -1,10 +1,8 @@
import React, { useEffect } from "react";
import React from "react";
import { Group } from "react-konva";
import MapToken from "./MapToken";
import { useTokenData } from "../../contexts/TokenDataContext";
function MapTokens({
map,
mapState,
@ -15,31 +13,6 @@ function MapTokens({
selectedToolId,
disabledTokens,
}) {
const { tokensById, loadTokens } = useTokenData();
// Ensure tokens files have been loaded into the token data
useEffect(() => {
async function loadFileTokens() {
const tokenIds = new Set(
Object.values(mapState.tokens).map((state) => state.tokenId)
);
const tokensToLoad = [];
for (let tokenId of tokenIds) {
const token = tokensById[tokenId];
if (token && token.type === "file" && !token.file) {
tokensToLoad.push(tokenId);
}
}
if (tokensToLoad.length > 0) {
await loadTokens(tokensToLoad);
}
}
if (mapState) {
loadFileTokens();
}
}, [mapState, tokensById, loadTokens]);
function getMapTokenCategoryWeight(category) {
switch (category) {
case "character":
@ -55,13 +28,10 @@ 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);
if (b.category !== a.category) {
const aWeight = getMapTokenCategoryWeight(a.category);
const bWeight = getMapTokenCategoryWeight(b.category);
return bWeight - aWeight;
} else if (
tokenDraggingOptions &&
@ -81,13 +51,6 @@ function MapTokens({
// 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 (
@ -97,7 +60,6 @@ function MapTokens({
.map((tokenState) => (
<MapToken
key={tokenState.id}
token={tokensById[tokenState.tokenId]}
tokenState={tokenState}
onTokenStateChange={onMapTokenStateChange}
onTokenMenuOpen={handleTokenMenuOpen}

View File

@ -26,15 +26,17 @@ function TokenSettings({ token, onSettingsChange }) {
/>
</Box>
<Box mt={2}>
<Label mb={1}>Category</Label>
<Label mb={1}>Default Category</Label>
<Select
options={categorySettings}
value={
!tokenEmpty &&
categorySettings.find((s) => s.value === token.category)
categorySettings.find((s) => s.value === token.defaultCategory)
}
isDisabled={tokenEmpty || token.type === "default"}
onChange={(option) => onSettingsChange("category", option.value)}
onChange={(option) =>
onSettingsChange("defaultCategory", option.value)
}
isSearchable={false}
/>
</Box>

View File

@ -25,12 +25,13 @@ function Tokens({ onMapTokenStateCreate }) {
function handleProxyDragEnd(isOnMap, token) {
if (isOnMap && onMapTokenStateCreate) {
// Create a token state from the dragged token
onMapTokenStateCreate({
let tokenState = {
id: shortid.generate(),
tokenId: token.id,
owner: userId,
size: token.defaultSize,
label: "",
category: token.defaultCategory,
label: token.defaultLabel,
statuses: [],
x: token.x,
y: token.y,
@ -39,7 +40,15 @@ function Tokens({ onMapTokenStateCreate }) {
rotation: 0,
locked: false,
visible: true,
});
type: token.type,
};
if (token.type === "file") {
tokenState.file = token.file;
} else if (token.type === "default") {
tokenState.key = token.key;
}
onMapTokenStateCreate(tokenState);
// TODO: Remove when cache is moved to assets
// Update last used for cache invalidation
// Keep last modified the same
updateToken(token.id, {

View File

@ -12,6 +12,7 @@ import { omit } from "../helpers/shared";
* @property {number} height
* @property {Uint8Array} file
* @property {string} mime
* @property {string} owner
*/
/**
@ -25,10 +26,16 @@ import { omit } from "../helpers/shared";
* @param {Asset[]} assets
*/
/**
* @callback putAsset
* @param {Asset} asset
*/
/**
* @typedef AssetsContext
* @property {getAsset} getAsset
* @property {addAssets} addAssets
* @property {putAsset} putAsset
*/
/**
@ -54,9 +61,17 @@ export function AssetsProvider({ children }) {
[database]
);
const putAsset = useCallback(
async (asset) => {
return database.table("assets").put(asset);
},
[database]
);
const value = {
getAsset,
addAssets,
putAsset,
};
return (

View File

@ -20,10 +20,6 @@ export function TokenDataProvider({ children }) {
const { database, databaseStatus, worker } = 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);
@ -44,7 +40,6 @@ 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
@ -156,35 +151,6 @@ 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;
});
});
}, []);
// Create DB observable to sync creating and deleting
useEffect(() => {
if (!database || databaseStatus === "loading") {
@ -248,8 +214,6 @@ export function TokenDataProvider({ children }) {
tokensById,
tokensLoading,
getTokenFromDB,
loadTokens,
unloadTokens,
};
return (

View File

@ -3,6 +3,7 @@ import Dexie, { Version, DexieOptions } from "dexie";
import "dexie-observable";
import shortid from "shortid";
import { v4 as uuid } from "uuid";
import Case from "case";
import blobToBuffer from "./helpers/blobToBuffer";
import { getGridDefaultInset } from "./helpers/grid";
@ -434,7 +435,7 @@ const versions = {
},
// v1.9.0 - Move map assets into new table
23(v) {
v.stores({ assets: "id" }).upgrade((tx) => {
v.stores({ assets: "id, owner" }).upgrade((tx) => {
tx.table("maps").each((map) => {
let assets = [];
assets.push({
@ -558,9 +559,46 @@ const versions = {
});
});
},
28(v) {
v.stores().upgrade((tx) => {
tx.table("tokens")
.toCollection()
.modify((token) => {
token.defaultCategory = token.category;
delete token.category;
token.defaultLabel = "";
});
});
},
29(v) {
v.stores().upgrade((tx) => {
tx.table("states")
.toCollection()
.modify(async (state) => {
for (let tokenState of Object.values(state.tokens)) {
if (!tokenState.tokenId.startsWith("__default")) {
const token = await tx.table("tokens").get(tokenState.tokenId);
if (token) {
tokenState.category = token.defaultCategory;
tokenState.file = token.file;
tokenState.type = "file";
} else {
tokenState.category = "character";
tokenState.type = "file";
tokenState.file = "";
}
} else {
tokenState.category = "character";
tokenState.type = "default";
tokenState.key = Case.camel(tokenState.tokenId.slice(10));
}
}
});
});
},
};
const latestVersion = 27;
const latestVersion = 29;
/**
* Load versions onto a database up to a specific version number

View File

@ -1,5 +1,3 @@
import { v4 as uuid } from "uuid";
import blobToBuffer from "./blobToBuffer";
const lightnessDetectionOffset = 0.1;
@ -90,8 +88,7 @@ export async function resizeImage(image, size, type, quality) {
}
/**
* @typedef Asset
* @property {string} id
* @typedef ImageAsset
* @property {number} width
* @property {number} height
* @property {Uint8Array} file
@ -104,7 +101,7 @@ export async function resizeImage(image, size, type, quality) {
* @param {string} type the mime type of the image
* @param {number} size the width and height of the thumbnail
* @param {number} quality if image is a jpeg or webp this is the quality setting
* @returns {Promise<Asset>}
* @returns {Promise<ImageAsset>}
*/
export async function createThumbnail(image, type, size = 300, quality = 0.5) {
let canvas = document.createElement("canvas");
@ -153,6 +150,5 @@ export async function createThumbnail(image, type, size = 300, quality = 0.5) {
width: thumbnailImage.width,
height: thumbnailImage.height,
mime: type,
id: uuid(),
};
}

27
src/helpers/map.js Normal file
View File

@ -0,0 +1,27 @@
/**
* Get the asset id of the preview file to send for a map
* @param {any} map
* @returns {undefined|string}
*/
export function getMapPreviewAsset(map) {
const res = map.resolutions;
switch (map.quality) {
case "low":
return;
case "medium":
return res.low;
case "high":
return res.medium;
case "ultra":
return res.medium;
case "original":
if (res.medium) {
return res.medium;
} else if (res.low) {
return res.low;
}
return;
default:
return;
}
}

View File

@ -252,13 +252,15 @@ function SelectMapModal({
height: resized.height,
id: assetId,
mime: file.type,
owner: userId,
};
assets.push(asset);
}
}
}
// Create thumbnail
const thumbnail = await createThumbnail(image, file.type);
const thumbnailImage = await createThumbnail(image, file.type);
const thumbnail = { ...thumbnailImage, id: uuid(), owner: userId };
assets.push(thumbnail);
const fileAsset = {
@ -267,6 +269,7 @@ function SelectMapModal({
width: image.width,
height: image.height,
mime: file.type,
owner: userId,
};
assets.push(fileAsset);

View File

@ -163,7 +163,8 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
return new Promise((resolve, reject) => {
image.onload = async function () {
let assets = [];
const thumbnail = await createThumbnail(image, file.type);
const thumbnailImage = await createThumbnail(image, file.type);
const thumbnail = { ...thumbnailImage, id: uuid(), owner: userId };
assets.push(thumbnail);
const fileAsset = {
@ -172,6 +173,7 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
width: image.width,
height: image.height,
mime: file.type,
owner: userId,
};
assets.push(fileAsset);
@ -186,7 +188,8 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
lastUsed: Date.now(),
owner: userId,
defaultSize: 1,
category: "character",
defaultCategory: "character",
defaultLabel: "",
hideInSidebar: false,
group: "",
width: image.width,

View File

@ -1,14 +1,15 @@
import React, { useState, useEffect, useRef } from "react";
import { useToasts } from "react-toast-notifications";
import { useTokenData } from "../contexts/TokenDataContext";
import { useMapData } from "../contexts/MapDataContext";
import { useMapLoading } from "../contexts/MapLoadingContext";
import { useAuth } from "../contexts/AuthContext";
import { useDatabase } from "../contexts/DatabaseContext";
import { useParty } from "../contexts/PartyContext";
import { useAssets } from "../contexts/AssetsContext";
import { omit } from "../helpers/shared";
import { getMapPreviewAsset } from "../helpers/map";
import useDebounce from "../hooks/useDebounce";
import useNetworkedState from "../hooks/useNetworkedState";
@ -46,8 +47,8 @@ function NetworkedMapAndTokens({ session }) {
isLoading,
} = useMapLoading();
const { putToken, getTokenFromDB } = useTokenData();
const { putMap, updateMap, getMapFromDB, updateMapState } = useMapData();
const { updateMapState } = useMapData();
const { getAsset, putAsset } = useAssets();
const [currentMap, setCurrentMap] = useState(null);
const [currentMapState, setCurrentMapState] = useNetworkedState(
@ -69,48 +70,39 @@ function NetworkedMapAndTokens({ session }) {
async function loadAssetManifestFromMap(map, mapState) {
const assets = {};
const { owner } = map;
if (map.type === "file") {
const { id, lastModified, owner } = map;
assets[`map-${id}`] = { type: "map", id, lastModified, owner };
const previewId = getMapPreviewAsset(map);
if (previewId) {
assets[previewId] = { id: previewId, owner };
}
const qualityId = map.resolutions[map.quality];
if (qualityId) {
assets[qualityId] = { id: qualityId, owner };
} else {
assets[map.file] = { id: map.file, owner };
}
}
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)
tokenState.file &&
!processedTokens.has(tokenState.file) &&
tokenState.owner === owner
) {
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 };
processedTokens.add(tokenState.file);
assets[tokenState.file] = { id: tokenState.file, owner };
}
}
setAssetManifest({ mapId: map.id, assets }, true, true);
}
function compareAssets(a, b) {
return a.type === b.type && a.id === b.id;
}
// Return true if an asset is out of date
function assetNeedsUpdate(oldAsset, newAsset) {
return (
compareAssets(oldAsset, newAsset) &&
oldAsset.lastModified < newAsset.lastModified
);
}
function addAssetIfNeeded(asset) {
setAssetManifest((prevManifest) => {
if (prevManifest?.assets) {
const id =
asset.type === "map" ? `map-${asset.id}` : `token-${asset.id}`;
const id = asset.id;
const exists = id in prevManifest.assets;
const needsUpdate =
exists && assetNeedsUpdate(prevManifest.assets[id], asset);
if (!exists || needsUpdate) {
if (!exists) {
return {
...prevManifest,
assets: {
@ -145,48 +137,28 @@ function NetworkedMapAndTokens({ session }) {
(player) => player.userId === asset.owner
);
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");
}
continue;
}
requestingAssetsRef.current.add(asset.id);
if (asset.type === "map") {
const cachedMap = await getMapFromDB(asset.id);
if (cachedMap && cachedMap.lastModified === asset.lastModified) {
if (cachedAsset) {
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);
}
session.sendTo(owner.sessionId, "assetRequest", asset.id);
}
}
}
requestAssetsIfNeeded();
}, [
assetManifest,
partyState,
session,
getMapFromDB,
getTokenFromDB,
updateMap,
userId,
addToast,
]);
}, [assetManifest, partyState, session, userId, addToast, getAsset]);
/**
* Map state
@ -215,12 +187,8 @@ function NetworkedMapAndTokens({ session }) {
setCurrentMapState(newMapState, true, true);
setCurrentMap(newMap);
if (newMap && newMap.type === "file") {
const { file, resolutions, thumbnail, ...rest } = newMap;
session.socket?.emit("map", rest);
} else {
session.socket?.emit("map", newMap);
}
if (!newMap || !newMapState) {
setAssetManifest(null, true, true);
return;
@ -371,11 +339,8 @@ function NetworkedMapAndTokens({ session }) {
if (!currentMap || !currentMapState) {
return;
}
// If file type token send the token to the other peers
const token = await getTokenFromDB(tokenState.tokenId);
if (token && token.type === "file") {
const { id, lastModified, owner } = token;
addAssetIfNeeded({ type: "token", id, lastModified, owner });
if (tokenState.file) {
addAssetIfNeeded({ id: tokenState.file, owner: tokenState.owner });
}
setCurrentMapState((prevMapState) => ({
...prevMapState,
@ -414,101 +379,21 @@ function NetworkedMapAndTokens({ session }) {
useEffect(() => {
async function handlePeerData({ id, data, reply }) {
if (id === "mapRequest") {
const map = await getMapFromDB(data);
function replyWithMap(preview, resolution) {
let response = {
...map,
resolutions: undefined,
file: undefined,
thumbnail: undefined,
// Remove last modified so if there is an error
// during the map request the cache is invalid
lastModified: 0,
// Add last used for cache invalidation
lastUsed: Date.now(),
};
// Send preview if available
if (map.resolutions[preview]) {
response.resolutions = { [preview]: map.resolutions[preview] };
reply("mapResponse", response, "map");
}
// Send full map at the desired resolution if available
if (map.resolutions[resolution]) {
response.file = map.resolutions[resolution].file;
} else if (map.file) {
// The resolution might not exist for other users so send the file instead
response.file = map.file;
} else {
return;
}
// Add last modified back to file to set cache as valid
response.lastModified = map.lastModified;
reply("mapResponse", response, "map");
if (id === "assetRequest") {
const asset = await getAsset(data);
reply("assetResponse", asset);
}
switch (map.quality) {
case "low":
replyWithMap(undefined, "low");
break;
case "medium":
replyWithMap("low", "medium");
break;
case "high":
replyWithMap("medium", "high");
break;
case "ultra":
replyWithMap("medium", "ultra");
break;
case "original":
if (map.resolutions) {
if (map.resolutions.medium) {
replyWithMap("medium");
} else if (map.resolutions.low) {
replyWithMap("low");
} else {
replyWithMap();
}
} else {
replyWithMap();
}
break;
default:
replyWithMap();
}
}
if (id === "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 === "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);
}
if (id === "assetResponse") {
await putAsset(data);
requestingAssetsRef.current.delete(data.id);
assetLoadFinish();
}
}
function handlePeerDataProgress({ id, total, count }) {
if (count === 1) {
// Corresponding asset load finished called in token and map response
// Corresponding asset load finished called in asset response
assetLoadStart();
}
assetProgressUpdate({ id, total, count });
@ -516,12 +401,7 @@ function NetworkedMapAndTokens({ session }) {
async function handleSocketMap(map) {
if (map) {
if (map.type === "file") {
const fullMap = await getMapFromDB(map.id);
setCurrentMap(fullMap || map);
} else {
setCurrentMap(map);
}
} else {
setCurrentMap(null);
}

View File

@ -85,7 +85,8 @@ export const tokens = Object.keys(tokenSources).map((key) => ({
name: Case.capital(key),
type: "default",
defaultSize: getDefaultTokenSize(key),
category: "character",
defaultLabel: "",
defaultCategory: "character",
hideInSidebar: false,
width: 256,
height: 256,