diff --git a/src/components/token/TokenTile.js b/src/components/token/TokenTile.js index 1eb9e75..78df16b 100644 --- a/src/components/token/TokenTile.js +++ b/src/components/token/TokenTile.js @@ -1,15 +1,18 @@ import React from "react"; -import { Flex, Image, Text } from "theme-ui"; +import { Flex, Image, Text, Box, IconButton } from "theme-ui"; + +import RemoveMapIcon from "../../icons/RemoveMapIcon"; import useDataSource from "../../helpers/useDataSource"; - import { tokenSources as defaultTokenSources } from "../../tokens"; -function TokenTile({ token, isSelected }) { +function TokenTile({ token, isSelected, onTokenSelect, onTokenRemove }) { const tokenSource = useDataSource(token, defaultTokenSources); + const isDefault = token.type === "default"; return ( onTokenSelect(token)} sx={{ borderColor: "primary", borderStyle: isSelected ? "solid" : "none", @@ -52,6 +55,22 @@ function TokenTile({ token, isSelected }) { {token.name} + {isSelected && !isDefault && ( + + { + onTokenRemove(token.id); + }} + bg="overlay" + sx={{ borderRadius: "50%" }} + m={1} + > + + + + )} ); } diff --git a/src/components/token/TokenTiles.js b/src/components/token/TokenTiles.js index e9f77f1..f9ae7e2 100644 --- a/src/components/token/TokenTiles.js +++ b/src/components/token/TokenTiles.js @@ -6,7 +6,13 @@ import AddIcon from "../../icons/AddIcon"; import TokenTile from "./TokenTile"; -function TokenTiles({ tokens, onTokenAdd }) { +function TokenTiles({ + tokens, + onTokenAdd, + onTokenSelect, + selectedToken, + onTokenRemove, +}) { return ( {tokens.map((token) => ( - + ))} diff --git a/src/components/token/Tokens.js b/src/components/token/Tokens.js index 20405f8..3eb3ad1 100644 --- a/src/components/token/Tokens.js +++ b/src/components/token/Tokens.js @@ -18,7 +18,7 @@ const listTokenClassName = "list-token"; function Tokens({ onMapTokenStateCreate }) { const { userId } = useContext(AuthContext); - const { tokens } = useContext(TokenDataContext); + const { ownedTokens, tokens } = useContext(TokenDataContext); const [tokenSize, setTokenSize] = useState(1); @@ -28,6 +28,7 @@ function Tokens({ onMapTokenStateCreate }) { onMapTokenStateCreate({ id: shortid.generate(), tokenId: token.id, + tokenType: token.type, owner: userId, size: tokenSize, label: "", @@ -49,13 +50,15 @@ function Tokens({ onMapTokenStateCreate }) { }} > - {tokens.map((token) => ( - - ))} + {ownedTokens + .filter((token) => token.owner === userId) + .map((token) => ( + + ))} { - if (!userId) { + if (!userId || !database) { return; } - const defaultTokensWithIds = []; - for (let defaultToken of defaultTokens) { - defaultTokensWithIds.push({ - ...defaultToken, - id: `__default-${defaultToken.key}`, - owner: userId, - }); + function getDefaultTokes() { + const defaultTokensWithIds = []; + for (let defaultToken of defaultTokens) { + defaultTokensWithIds.push({ + ...defaultToken, + id: `__default-${defaultToken.key}`, + owner: userId, + }); + } + return defaultTokensWithIds; } - setTokens(defaultTokensWithIds); - }, [userId]); - const value = { tokens }; + async function loadTokens() { + let storedTokens = await database.table("tokens").toArray(); + const sortedTokens = storedTokens.sort((a, b) => b.created - a.created); + const defaultTokensWithIds = getDefaultTokes(); + const allTokens = [...sortedTokens, ...defaultTokensWithIds]; + setTokens(allTokens); + } + + loadTokens(); + }, [userId, database]); + + async function addToken(token) { + await database.table("tokens").add(token); + setTokens((prevTokens) => [token, ...prevTokens]); + } + + async function removeToken(id) { + // TODO when removing token also remove it from all states that reference it and replicate + await database.table("tokens").delete(id); + setTokens((prevTokens) => { + const filtered = prevTokens.filter((token) => token.id !== id); + return filtered; + }); + } + + async function updateToken(id, update) { + const change = { ...update, lastModified: Date.now() }; + await database.table("tokens").update(id, change); + setTokens((prevTokens) => { + const newTokens = [...prevTokens]; + const i = newTokens.findIndex((token) => token.id === id); + if (i > -1) { + newTokens[i] = { ...newTokens[i], ...change }; + } + return newTokens; + }); + } + + async function putToken(token) { + if (tokens.includes((t) => t.id === token.id)) { + await updateToken(token.id, token); + } else { + await addToken(token); + } + } + + const ownedTokens = tokens.filter((token) => token.owner === userId); + + const value = { + tokens, + ownedTokens, + addToken, + removeToken, + updateToken, + putToken, + }; return ( diff --git a/src/modals/SelectTokensModal.js b/src/modals/SelectTokensModal.js index 09a8133..189257b 100644 --- a/src/modals/SelectTokensModal.js +++ b/src/modals/SelectTokensModal.js @@ -1,24 +1,82 @@ -import React, { useRef, useContext } from "react"; +import React, { useRef, useContext, useState } from "react"; import { Flex, Label } from "theme-ui"; +import shortid from "shortid"; import Modal from "../components/Modal"; import ImageDrop from "../components/ImageDrop"; - import TokenTiles from "../components/token/TokenTiles"; +import blobToBuffer from "../helpers/blobToBuffer"; + import TokenDataContext from "../contexts/TokenDataContext"; +import AuthContext from "../contexts/AuthContext"; function SelectTokensModal({ isOpen, onRequestClose }) { - const { tokens } = useContext(TokenDataContext); + const { userId } = useContext(AuthContext); + const { ownedTokens, addToken, removeToken } = useContext(TokenDataContext); const fileInputRef = useRef(); + const [imageLoading, setImageLoading] = useState(false); + + const [selectedTokenId, setSelectedTokenId] = useState(null); + const selectedToken = ownedTokens.find( + (token) => token.id === selectedTokenId + ); + function openImageDialog() { if (fileInputRef.current) { fileInputRef.current.click(); } } - function handleImageUpload(image) {} + function handleTokenAdd(token) { + addToken(token); + } + + function handleImageUpload(file) { + let name = "Unknown Map"; + 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(); + } + let image = new Image(); + setImageLoading(true); + blobToBuffer(file).then((buffer) => { + // 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); + image.onload = function () { + handleTokenAdd({ + file: buffer, + name, + type: "file", + id: shortid.generate(), + created: Date.now(), + lastModified: Date.now(), + owner: userId, + }); + }; + image.src = url; + + // Set file input to null to allow adding the same image 2 times in a row + fileInputRef.current.value = null; + }); + } + + function handleTokenSelect(token) { + setSelectedTokenId(token.id); + } + + async function handleTokenRemove(id) { + await removeToken(id); + setSelectedTokenId(null); + } return ( @@ -38,7 +96,13 @@ function SelectTokensModal({ isOpen, onRequestClose }) { - + diff --git a/src/routes/Game.js b/src/routes/Game.js index 298e581..cb55b96 100644 --- a/src/routes/Game.js +++ b/src/routes/Game.js @@ -17,6 +17,7 @@ import AuthModal from "../modals/AuthModal"; import AuthContext from "../contexts/AuthContext"; import DatabaseContext from "../contexts/DatabaseContext"; +import TokenDataContext from "../contexts/TokenDataContext"; function Game() { const { database } = useContext(DatabaseContext); @@ -93,6 +94,7 @@ function Game() { peer.connection.send({ id: "map", data: null }); peer.connection.send({ id: "mapState", data: newMapState }); sendMapDataToPeer(peer, newMap); + sendTokensToPeer(peer, newMapState); } } @@ -114,34 +116,6 @@ function Game() { } } - function handleMapTokenStateChange(token) { - if (mapState === null) { - return; - } - setMapState((prevMapState) => ({ - ...prevMapState, - tokens: { - ...prevMapState.tokens, - [token.id]: token, - }, - })); - for (let peer of Object.values(peers)) { - const data = { [token.id]: token }; - peer.connection.send({ id: "tokenStateEdit", data }); - } - } - - function handleMapTokenStateRemove(token) { - setMapState((prevMapState) => { - const { [token.id]: old, ...rest } = prevMapState.tokens; - return { ...prevMapState, tokens: rest }; - }); - for (let peer of Object.values(peers)) { - const data = { [token.id]: token }; - peer.connection.send({ id: "tokenStateRemove", data }); - } - } - function addMapDrawActions(actions, indexKey, actionsKey) { setMapState((prevMapState) => { const newActions = [ @@ -228,6 +202,50 @@ function Game() { } } + /** + * Token state + */ + + async function handleMapTokenStateCreate(tokenState) { + // If file type token send the token to the other peers + if (tokenState.tokenType === "file") { + const token = await database.table("tokens").get(tokenState.tokenId); + const { file, ...rest } = token; + for (let peer of Object.values(peers)) { + peer.connection.send({ id: "token", data: rest }); + } + } + handleMapTokenStateChange(tokenState); + } + + function handleMapTokenStateChange(tokenState) { + if (mapState === null) { + return; + } + setMapState((prevMapState) => ({ + ...prevMapState, + tokens: { + ...prevMapState.tokens, + [tokenState.id]: tokenState, + }, + })); + for (let peer of Object.values(peers)) { + const data = { [tokenState.id]: tokenState }; + peer.connection.send({ id: "tokenStateEdit", data }); + } + } + + function handleMapTokenStateRemove(tokenState) { + setMapState((prevMapState) => { + const { [tokenState.id]: old, ...rest } = prevMapState.tokens; + return { ...prevMapState, tokens: rest }; + }); + for (let peer of Object.values(peers)) { + const data = { [tokenState.id]: tokenState }; + peer.connection.send({ id: "tokenStateRemove", data }); + } + } + /** * Party state */ @@ -255,10 +273,32 @@ function Game() { * Peer handlers */ + const { putToken } = useContext(TokenDataContext); + + function sendTokensToPeer(peer, state) { + let sentTokens = {}; + for (let tokenState of Object.values(state.tokens)) { + if ( + tokenState.tokenType === "file" && + !(tokenState.tokenId in sentTokens) + ) { + sentTokens[tokenState.tokenId] = true; + database + .table("tokens") + .get(tokenState.tokenId) + .then((token) => { + const { file, ...rest } = token; + peer.connection.send({ id: "token", data: rest }); + }); + } + } + } + function handlePeerData({ data, peer }) { if (data.id === "sync") { if (mapState) { peer.connection.send({ id: "mapState", data: mapState }); + sendTokensToPeer(peer, mapState); } if (map) { sendMapDataToPeer(peer, map); @@ -306,6 +346,41 @@ function Game() { if (data.id === "mapState") { setMapState(data.data); } + if (data.id === "token") { + const newToken = data.data; + if (newToken && newToken.type === "file") { + database + .table("tokens") + .get(newToken.id) + .then((cachedToken) => { + if ( + !cachedToken || + cachedToken.lastModified !== newToken.lastModified + ) { + setMapLoading(true); + peer.connection.send({ + id: "tokenRequest", + data: newToken.id, + }); + } + }); + } + } + if (data.id === "tokenRequest") { + database + .table("tokens") + .get(data.data) + .then((token) => { + peer.connection.send({ id: "tokenResponse", data: token }); + }); + } + if (data.id === "tokenResponse") { + setMapLoading(false); + const newToken = data.data; + if (newToken && newToken.type === "file") { + putToken(newToken); + } + } if (data.id === "tokenStateEdit") { setMapState((prevMapState) => ({ ...prevMapState, @@ -474,7 +549,7 @@ function Game() { allowFogDrawing={canEditFogDrawing} disabledTokens={disabledMapTokens} /> - + setPeerError(null)}>