From ed8f3bd2835390e668fe3b8a16e9b217ef9ef731 Mon Sep 17 00:00:00 2001 From: Mitchell McCaffrey Date: Fri, 24 Apr 2020 15:50:05 +1000 Subject: [PATCH] Moved maps and tokens to a data source model This will allow for easier custom token support as well as changing default tokens --- src/components/map/Map.js | 8 ++++- src/components/map/MapTile.js | 18 +++++++---- src/components/map/MapToken.js | 14 ++++---- src/components/token/ListToken.js | 25 +++++++++----- src/components/token/ProxyToken.js | 50 +++++++++++++++++++++------- src/components/token/TokenMenu.js | 40 +++++++++++++++-------- src/components/token/Tokens.js | 28 ++++++++++++---- src/helpers/useDataSource.js | 29 +++++++++++++++++ src/maps/index.js | 52 ++++++++---------------------- src/modals/SelectMapModal.js | 31 ++++++++---------- src/routes/Game.js | 3 ++ src/tokens/index.js | 9 ++++-- 12 files changed, 194 insertions(+), 113 deletions(-) create mode 100644 src/helpers/useDataSource.js diff --git a/src/components/map/Map.js b/src/components/map/Map.js index af2e31f..12abc21 100644 --- a/src/components/map/Map.js +++ b/src/components/map/Map.js @@ -9,6 +9,8 @@ import MapDrawing from "./MapDrawing"; import MapControls from "./MapControls"; import { omit } from "../../helpers/shared"; +import useDataSource from "../../helpers/useDataSource"; +import { mapSources as defaultMapSources } from "../../maps"; const mapTokenProxyClassName = "map-token__proxy"; const mapTokenMenuClassName = "map-token__menu"; @@ -27,6 +29,8 @@ function Map({ onMapDrawUndo, onMapDrawRedo, }) { + const mapSource = useDataSource(map, defaultMapSources); + function handleProxyDragEnd(isOnMap, token) { if (isOnMap && onMapTokenChange) { onMapTokenChange(token); @@ -219,7 +223,7 @@ function Map({ userSelect: "none", touchAction: "none", }} - src={map && map.source} + src={mapSource} /> ); @@ -323,10 +327,12 @@ function Map({ ); diff --git a/src/components/map/MapTile.js b/src/components/map/MapTile.js index e6f2104..4ef6b41 100644 --- a/src/components/map/MapTile.js +++ b/src/components/map/MapTile.js @@ -7,6 +7,9 @@ import RemoveMapIcon from "../../icons/RemoveMapIcon"; import ResetMapIcon from "../../icons/ResetMapIcon"; import ExpandMoreDotIcon from "../../icons/ExpandMoreDotIcon"; +import useDataSource from "../../helpers/useDataSource"; +import { mapSources as defaultMapSources } from "../../maps"; + function MapTile({ map, isSelected, @@ -15,8 +18,10 @@ function MapTile({ onMapReset, onSubmit, }) { + const mapSource = useDataSource(map, defaultMapSources); const [isMapTileMenuOpen, setIsTileMenuOpen] = useState(false); const [hasMapState, setHasMapState] = useState(false); + const isDefault = map.type === "default"; useEffect(() => { async function checkForMapState() { @@ -28,7 +33,6 @@ function MapTile({ setHasMapState(true); } } - checkForMapState(); }, [map]); @@ -120,18 +124,18 @@ function MapTile({ > {/* Show expand button only if both reset and remove is available */} {isSelected && ( - {map.default && hasMapState && resetButton(map)} - {!map.default && hasMapState && !isMapTileMenuOpen && expandButton} - {!map.default && !hasMapState && removeButton(map)} + {isDefault && hasMapState && resetButton(map)} + {!isDefault && hasMapState && !isMapTileMenuOpen && expandButton} + {!isDefault && !hasMapState && removeButton(map)} )} {/* Tile menu for two actions */} - {!map.default && isMapTileMenuOpen && isSelected && ( + {!isDefault && isMapTileMenuOpen && isSelected && ( setIsTileMenuOpen(false)} > - {!map.default && removeButton(map)} + {!isDefault && removeButton(map)} {hasMapState && resetButton(map)} )} diff --git a/src/components/map/MapToken.js b/src/components/map/MapToken.js index 5dfb799..3fdceb0 100644 --- a/src/components/map/MapToken.js +++ b/src/components/map/MapToken.js @@ -5,8 +5,13 @@ import TokenLabel from "../token/TokenLabel"; import TokenStatus from "../token/TokenStatus"; import usePreventTouch from "../../helpers/usePreventTouch"; +import useDataSource from "../../helpers/useDataSource"; + +import { tokenSources } from "../../tokens"; function MapToken({ token, tokenSizePercent, className }) { + const imageSource = useDataSource(token, tokenSources); + const imageRef = useRef(); // Stop touch to prevent 3d touch gesutre on iOS usePreventTouch(imageRef); @@ -47,15 +52,12 @@ function MapToken({ token, tokenSizePercent, className }) { touchAction: "none", width: "100%", }} - src={token.image} - // pass data into the dom element used to pass state to the ProxyToken + src={imageSource} + // pass id into the dom element which is then used by the ProxyToken data-id={token.id} - data-size={token.size} - data-label={token.label} - data-status={token.status} ref={imageRef} /> - {token.status && } + {token.statuses && } {token.label && } diff --git a/src/components/token/ListToken.js b/src/components/token/ListToken.js index ad83430..bbdb3a2 100644 --- a/src/components/token/ListToken.js +++ b/src/components/token/ListToken.js @@ -1,20 +1,29 @@ import React, { useRef } from "react"; -import { Image } from "theme-ui"; +import { Box, Image } from "theme-ui"; import usePreventTouch from "../../helpers/usePreventTouch"; +import useDataSource from "../../helpers/useDataSource"; + +import { tokenSources } from "../../tokens"; + +function ListToken({ token, className }) { + const imageSource = useDataSource(token, tokenSources); -function ListToken({ image, className }) { const imageRef = useRef(); // Stop touch to prevent 3d touch gesutre on iOS usePreventTouch(imageRef); return ( - + + + ); } diff --git a/src/components/token/ProxyToken.js b/src/components/token/ProxyToken.js index 6a17c84..08e26c7 100644 --- a/src/components/token/ProxyToken.js +++ b/src/components/token/ProxyToken.js @@ -8,14 +8,32 @@ import usePortal from "../../helpers/usePortal"; import TokenLabel from "./TokenLabel"; import TokenStatus from "./TokenStatus"; -function ProxyToken({ tokenClassName, onProxyDragEnd }) { +/** + * @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 [label, setLabel] = useState(""); - const [status, setStatus] = useState(""); + const [tokenId, setTokenId] = useState(null); 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); useEffect(() => { @@ -26,8 +44,7 @@ function ProxyToken({ tokenClassName, onProxyDragEnd }) { // Hide the token and copy it's image to the proxy target.parentElement.style.opacity = "0.25"; setImageSource(target.src); - setLabel(target.dataset.label || ""); - setStatus(target.dataset.status || ""); + setTokenId(target.dataset.id); let proxy = proxyRef.current; if (proxy) { @@ -88,13 +105,14 @@ function ProxyToken({ tokenClassName, onProxyDragEnd }) { x = x / (mapImageRect.right - mapImageRect.left); y = y / (mapImageRect.bottom - mapImageRect.top); - target.setAttribute("data-x", x); - target.setAttribute("data-y", y); + // Get the token from the supplied tokens if it exists + const id = target.getAttribute("data-id"); + const token = tokensRef.current[id] || {}; onProxyDragEnd(proxyOnMap.current, { - image: target.src, - // Pass in props stored as data- in the dom node - ...target.dataset, + ...token, + x, + y, }); } @@ -140,12 +158,20 @@ function ProxyToken({ tokenClassName, onProxyDragEnd }) { width: "100%", }} /> - {status && } - {label && } + {tokens[tokenId] && tokens[tokenId].statuses && ( + + )} + {tokens[tokenId] && tokens[tokenId].label && ( + + )} , proxyContainer ); } +ProxyToken.defaultProps = { + tokens: {}, +}; + export default ProxyToken; diff --git a/src/components/token/TokenMenu.js b/src/components/token/TokenMenu.js index 90b555a..74c4eb2 100644 --- a/src/components/token/TokenMenu.js +++ b/src/components/token/TokenMenu.js @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, useRef } from "react"; import interact from "interactjs"; import { Box, Input } from "theme-ui"; @@ -6,13 +6,31 @@ import MapMenu from "../map/MapMenu"; import colors, { colorOptions } from "../../helpers/colors"; -function TokenMenu({ tokenClassName, onTokenChange }) { +/** + * @callback onTokenChange + * @param {Object} token the token that was changed + */ + +/** + * + * @param {string} tokenClassName The class name to attach the interactjs handler to + * @param {onProxyDragEnd} onTokenChange Called when the the token data is changed + * @param {Object} tokens An mapping of tokens to use as a base when calling onTokenChange + */ +function TokenMenu({ tokenClassName, onTokenChange, tokens }) { const [isOpen, setIsOpen] = useState(false); function handleRequestClose() { setIsOpen(false); } + // 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 [currentToken, setCurrentToken] = useState({}); const [menuLeft, setMenuLeft] = useState(0); const [menuTop, setMenuTop] = useState(0); @@ -31,30 +49,26 @@ function TokenMenu({ tokenClassName, onTokenChange }) { } function handleStatusChange(status) { - const statuses = - currentToken.status.split(" ").filter((s) => s !== "") || []; + const statuses = currentToken.statuses; let newStatuses = []; if (statuses.includes(status)) { newStatuses = statuses.filter((s) => s !== status); } else { newStatuses = [...statuses, status]; } - const newStatus = newStatuses.join(" "); setCurrentToken((prevToken) => ({ ...prevToken, - status: newStatus, + statuses: newStatuses, })); - onTokenChange({ ...currentToken, status: newStatus }); + onTokenChange({ ...currentToken, statuses: newStatuses }); } useEffect(() => { function handleTokenMenuOpen(event) { const target = event.target; - const dataset = (target && target.dataset) || {}; - setCurrentToken({ - image: target.src, - ...dataset, - }); + const id = target.getAttribute("data-id"); + const token = tokensRef.current[id] || {}; + setCurrentToken(token); const targetRect = target.getBoundingClientRect(); setMenuLeft(targetRect.left); @@ -162,7 +176,7 @@ function TokenMenu({ tokenClassName, onTokenChange }) { onClick={() => handleStatusChange(color)} aria-label={`Token label Color ${color}`} > - {currentToken.status && currentToken.status.includes(color) && ( + {currentToken.statuses && currentToken.statuses.includes(color) && ( { + const defaultTokensWithIds = []; + for (let defaultToken of defaultTokens) { + defaultTokensWithIds.push({ ...defaultToken, id: defaultToken.name }); + } + setTokens(defaultTokensWithIds); + }, []); + const [tokenSize, setTokenSize] = useState(1); function handleProxyDragEnd(isOnMap, token) { @@ -22,7 +33,7 @@ function Tokens({ onCreateMapToken }) { id: shortid.generate(), size: tokenSize, label: "", - status: "", + statuses: [], }); } } @@ -38,10 +49,12 @@ function Tokens({ onCreateMapToken }) { }} > - {Object.entries(tokens).map(([id, image]) => ( - - - + {tokens.map((token) => ( + ))} @@ -57,6 +70,7 @@ function Tokens({ onCreateMapToken }) { [token.id, token]))} /> ); diff --git a/src/helpers/useDataSource.js b/src/helpers/useDataSource.js new file mode 100644 index 0000000..a57f69d --- /dev/null +++ b/src/helpers/useDataSource.js @@ -0,0 +1,29 @@ +import { useEffect, useState } from "react"; + +// Helper function to load either file or default data +// into a URL and ensure that it is revoked if needed +function useDataSource(data, defaultSources) { + const [dataSource, setDataSource] = useState(null); + useEffect(() => { + if (!data) { + return; + } + let url = null; + if (data.type === "file") { + url = URL.createObjectURL(data.file); + } else if (data.type === "default") { + url = defaultSources[data.name]; + } + setDataSource(url); + + return () => { + if (data.type === "file" && url) { + URL.revokeObjectURL(url); + } + }; + }, [data, defaultSources]); + + return dataSource; +} + +export default useDataSource; diff --git a/src/maps/index.js b/src/maps/index.js index 1c0eb07..0b55b37 100644 --- a/src/maps/index.js +++ b/src/maps/index.js @@ -5,46 +5,20 @@ import stoneImage from "./Stone Grid 22x22.jpg"; import waterImage from "./Water Grid 22x22.jpg"; import woodImage from "./Wood Grid 22x22.jpg"; -const defaultProps = { +export const mapSources = { + blank: blankImage, + grass: grassImage, + sand: sandImage, + stone: stoneImage, + water: waterImage, + wood: woodImage, +}; + +export const maps = Object.keys(mapSources).map((name) => ({ + name, gridX: 22, gridY: 22, width: 1024, height: 1024, - default: true, -}; - -export const blank = { - ...defaultProps, - source: blankImage, - id: "__default_blank", -}; - -export const grass = { - ...defaultProps, - source: grassImage, - id: "__default_grass", -}; - -export const sand = { - ...defaultProps, - source: sandImage, - id: "__default_sand", -}; - -export const stone = { - ...defaultProps, - source: stoneImage, - id: "__default_stone", -}; - -export const water = { - ...defaultProps, - source: waterImage, - id: "__default_water", -}; - -export const wood = { - ...defaultProps, - source: woodImage, - id: "__default_wood", -}; + type: "default", +})); diff --git a/src/modals/SelectMapModal.js b/src/modals/SelectMapModal.js index 2718151..757d4f3 100644 --- a/src/modals/SelectMapModal.js +++ b/src/modals/SelectMapModal.js @@ -7,7 +7,7 @@ import db from "../database"; import Modal from "../components/Modal"; import MapTiles from "../components/map/MapTiles"; -import * as defaultMaps from "../maps"; +import { maps as defaultMaps } from "../maps"; const defaultMapSize = 22; const defaultMapState = { @@ -37,15 +37,15 @@ function SelectMapModal({ async function loadDefaultMaps() { const defaultMapsWithIds = []; const defaultMapStates = []; - // Store the default maps into the db in reverse so the whie map is first - // in the UI - const defaultMapArray = Object.values(defaultMaps).reverse(); - for (let i = 0; i < defaultMapArray.length; i++) { - const defaultMap = defaultMapArray[i]; - const id = `${defaultMap.id}--${shortid.generate()}`; + // Reverse maps to ensure the blank map is first in the list + const sortedMaps = [...defaultMaps].reverse(); + for (let i = 0; i < sortedMaps.length; i++) { + const defaultMap = sortedMaps[i]; + const id = `__default_${defaultMap.name}--${shortid.generate()}`; defaultMapsWithIds.push({ ...defaultMap, id, + // Emulate the time increasing to avoid sort errors timestamp: Date.now() + i, }); defaultMapStates.push({ ...defaultMapState, mapId: id }); @@ -64,12 +64,6 @@ function SelectMapModal({ } else { // Sort maps by the time they were added storedMaps.sort((a, b) => b.timestamp - a.timestamp); - for (let map of storedMaps) { - // Recreate image urls for file based maps - if (map.file) { - map.source = URL.createObjectURL(map.file); - } - } setMaps(storedMaps); } } @@ -101,21 +95,23 @@ function SelectMapModal({ } } } - const url = URL.createObjectURL(file); let image = new Image(); setImageLoading(true); + // Create and load the image temporarily to get its dimensions + const url = URL.createObjectURL(file); image.onload = function () { handleMapAdd({ file, + type: "file", gridX: fileGridX, gridY: fileGridY, width: image.width, height: image.height, - source: url, id: shortid.generate(), timestamp: Date.now(), }); setImageLoading(false); + URL.revokeObjectURL(url); }; image.src = url; } @@ -135,7 +131,6 @@ function SelectMapModal({ setGridY(map.gridY); } - // Keep track of removed maps async function handleMapRemove(id) { await db.table("maps").delete(id); await db.table("states").delete(id); @@ -145,7 +140,7 @@ function SelectMapModal({ return filtered; }); // Removed the map from the map screen if needed - if (currentMap.id === selectedMap.id) { + if (currentMap && currentMap.id === selectedMap.id) { onMapChange(null); } } @@ -160,7 +155,7 @@ function SelectMapModal({ const state = { ...defaultMapState, mapId: id }; await db.table("states").put(state); // Reset the state of the current map if needed - if (currentMap.id === selectedMap.id) { + if (currentMap && currentMap.id === selectedMap.id) { onMapStateChange(state); } } diff --git a/src/routes/Game.js b/src/routes/Game.js index 9621d63..4b512e8 100644 --- a/src/routes/Game.js +++ b/src/routes/Game.js @@ -63,6 +63,9 @@ function Game() { } async function handleMapTokenChange(token) { + if (mapState === null) { + return; + } setMapState((prevMapState) => ({ ...prevMapState, tokens: { diff --git a/src/tokens/index.js b/src/tokens/index.js index a6fa5ea..da44f26 100644 --- a/src/tokens/index.js +++ b/src/tokens/index.js @@ -19,7 +19,7 @@ import swords from "./Swords.png"; import tree from "./Tree.png"; import triangle from "./Triangle.png"; -export { +export const tokenSources = { axes, bird, book, @@ -39,5 +39,10 @@ export { sun, swords, tree, - triangle + triangle, }; + +export const tokens = Object.keys(tokenSources).map((name) => ({ + name, + type: "default", +}));