Moved maps and tokens to a data source model

This will allow for easier custom token support as well as changing default tokens
This commit is contained in:
Mitchell McCaffrey 2020-04-24 15:50:05 +10:00
parent 98798235c9
commit ed8f3bd283
12 changed files with 194 additions and 113 deletions

View File

@ -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}
/>
</Box>
);
@ -323,10 +327,12 @@ function Map({
<ProxyToken
tokenClassName={mapTokenProxyClassName}
onProxyDragEnd={handleProxyDragEnd}
tokens={mapState && mapState.tokens}
/>
<TokenMenu
tokenClassName={mapTokenMenuClassName}
onTokenChange={onMapTokenChange}
tokens={mapState && mapState.tokens}
/>
</>
);

View File

@ -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({
>
<UIImage
sx={{ width: "100%", height: "100%", objectFit: "contain" }}
src={map.source}
src={mapSource}
/>
{/* Show expand button only if both reset and remove is available */}
{isSelected && (
<Box sx={{ position: "absolute", top: 0, right: 0 }}>
{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)}
</Box>
)}
{/* Tile menu for two actions */}
{!map.default && isMapTileMenuOpen && isSelected && (
{!isDefault && isMapTileMenuOpen && isSelected && (
<Flex
sx={{
position: "absolute",
@ -145,7 +149,7 @@ function MapTile({
bg="muted"
onClick={() => setIsTileMenuOpen(false)}
>
{!map.default && removeButton(map)}
{!isDefault && removeButton(map)}
{hasMapState && resetButton(map)}
</Flex>
)}

View File

@ -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 && <TokenStatus statuses={token.status.split(" ")} />}
{token.statuses && <TokenStatus statuses={token.statuses} />}
{token.label && <TokenLabel label={token.label} />}
</Box>
</Box>

View File

@ -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 (
<Image
src={image}
ref={imageRef}
className={className}
sx={{ userSelect: "none", touchAction: "none" }}
/>
<Box my={2} mx={3} sx={{ width: "48px", height: "48px" }}>
<Image
src={imageSource}
ref={imageRef}
className={className}
sx={{ userSelect: "none", touchAction: "none" }}
// pass id into the dom element which is then used by the ProxyToken
data-id={token.id}
/>
</Box>
);
}

View File

@ -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 && <TokenStatus statuses={status.split(" ")} />}
{label && <TokenLabel label={label} />}
{tokens[tokenId] && tokens[tokenId].statuses && (
<TokenStatus statuses={tokens[tokenId].statuses} />
)}
{tokens[tokenId] && tokens[tokenId].label && (
<TokenLabel label={tokens[tokenId].label} />
)}
</Box>
</Box>,
proxyContainer
);
}
ProxyToken.defaultProps = {
tokens: {},
};
export default ProxyToken;

View File

@ -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) && (
<Box
sx={{
width: "100%",

View File

@ -1,17 +1,28 @@
import React, { useState } from "react";
import React, { useState, useEffect } from "react";
import { Box } from "theme-ui";
import shortid from "shortid";
import SimpleBar from "simplebar-react";
import * as tokens from "../../tokens";
import { tokens as defaultTokens } from "../../tokens";
import ListToken from "./ListToken";
import ProxyToken from "./ProxyToken";
import NumberInput from "../NumberInput";
import { fromEntries } from "../../helpers/shared";
const listTokenClassName = "list-token";
function Tokens({ onCreateMapToken }) {
const [tokens, setTokens] = useState([]);
useEffect(() => {
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 }) {
}}
>
<SimpleBar style={{ height: "calc(100% - 58px)", overflowX: "hidden" }}>
{Object.entries(tokens).map(([id, image]) => (
<Box key={id} my={2} mx={3} sx={{ width: "48px", height: "48px" }}>
<ListToken image={image} className={listTokenClassName} />
</Box>
{tokens.map((token) => (
<ListToken
key={token.id}
token={token}
className={listTokenClassName}
/>
))}
</SimpleBar>
<Box pt={1} bg="muted" sx={{ height: "58px" }}>
@ -57,6 +70,7 @@ function Tokens({ onCreateMapToken }) {
<ProxyToken
tokenClassName={listTokenClassName}
onProxyDragEnd={handleProxyDragEnd}
tokens={fromEntries(tokens.map((token) => [token.id, token]))}
/>
</>
);

View File

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

View File

@ -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",
}));

View File

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

View File

@ -63,6 +63,9 @@ function Game() {
}
async function handleMapTokenChange(token) {
if (mapState === null) {
return;
}
setMapState((prevMapState) => ({
...prevMapState,
tokens: {

View File

@ -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",
}));