Added initial token label

Added token pop up menu
Added token label
Added better token positioning
Split tokens into list and map variants
Moved size input to more generic number input
Changed game handler names to be more consistent
This commit is contained in:
Mitchell McCaffrey 2020-04-13 00:24:03 +10:00
parent da0f80316c
commit cb93922d59
12 changed files with 388 additions and 123 deletions

View File

@ -0,0 +1,21 @@
import React, { useRef } from "react";
import { Image } from "theme-ui";
import usePreventTouch from "../helpers/usePreventTouch";
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" }}
/>
);
}
export default ListToken;

View File

@ -2,9 +2,10 @@ import React, { useRef, useEffect, useState } from "react";
import { Box, Image } from "theme-ui";
import interact from "interactjs";
import Token from "../components/Token";
import ProxyToken from "../components/ProxyToken";
import AddMapButton from "../components/AddMapButton";
import TokenMenu from "../components/TokenMenu";
import MapToken from "../components/MapToken";
const mapTokenClassName = "map-token";
const zoomSpeed = -0.005;
@ -15,13 +16,13 @@ function Map({
mapSource,
mapData,
tokens,
onMapTokenMove,
onMapTokenChange,
onMapTokenRemove,
onMapChanged,
onMapChange,
}) {
function handleProxyDragEnd(isOnMap, token) {
if (isOnMap && onMapTokenMove) {
onMapTokenMove(token);
if (isOnMap && onMapTokenChange) {
onMapTokenChange(token);
}
if (!isOnMap && onMapTokenRemove) {
@ -120,6 +121,64 @@ function Map({
const tokenSizePercent = (1 / rows) * 100;
const aspectRatio = (mapData && mapData.width / mapData.height) || 1;
const mapImage = (
<Box
sx={{
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
}}
>
<Image
ref={mapRef}
id="map"
sx={{
width: "100%",
userSelect: "none",
touchAction: "none",
}}
src={mapSource}
/>
</Box>
);
const mapTokens = (
<Box
sx={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
>
{Object.values(tokens).map((token) => (
<MapToken
key={token.id}
token={token}
tokenSizePercent={tokenSizePercent}
className={mapTokenClassName}
/>
))}
</Box>
);
const mapActions = (
<Box
p={2}
sx={{
position: "absolute",
top: "0",
left: "50%",
transform: "translateX(-50%)",
}}
>
<AddMapButton onMapChanged={onMapChange} />
</Box>
);
return (
<>
<Box
@ -155,78 +214,20 @@ function Map({
paddingBottom: `${(1 / aspectRatio) * 100}%`,
}}
/>
<Box
sx={{
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
}}
>
<Image
ref={mapRef}
id="map"
sx={{
width: "100%",
userSelect: "none",
touchAction: "none",
}}
src={mapSource}
/>
</Box>
<Box
sx={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
>
{Object.values(tokens).map((token) => (
<Box
key={token.id}
style={{
left: `${token.x * 100}%`,
top: `${token.y * 100}%`,
width: `${tokenSizePercent * (token.size || 1)}%`,
}}
sx={{
position: "absolute",
display: "flex",
}}
>
<Token
data={{
id: token.id,
size: token.size,
}}
image={token.image}
className={mapTokenClassName}
sx={{ position: "absolute" }}
/>
</Box>
))}
</Box>
{mapImage}
{mapTokens}
</Box>
</Box>
<Box
p={2}
sx={{
position: "absolute",
top: "0",
left: "50%",
transform: "translateX(-50%)",
}}
>
<AddMapButton onMapChanged={onMapChanged} />
</Box>
{mapActions}
</Box>
<ProxyToken
tokenClassName={mapTokenClassName}
onProxyDragEnd={handleProxyDragEnd}
/>
<TokenMenu
tokenClassName={mapTokenClassName}
onTokenChange={onMapTokenChange}
/>
</>
);
}

View File

@ -0,0 +1,57 @@
import React, { useRef } from "react";
import { Box, Image } from "theme-ui";
import TokenLabel from "./TokenLabel";
import usePreventTouch from "../helpers/usePreventTouch";
function MapToken({ token, tokenSizePercent, className }) {
const imageRef = useRef();
// Stop touch to prevent 3d touch gesutre on iOS
usePreventTouch(imageRef);
return (
<Box
style={{
transform: `translate(${token.x * 100}%, ${token.y * 100}%)`,
width: "100%",
height: "100%",
}}
sx={{
position: "absolute",
pointerEvents: "none",
}}
>
<Box
style={{
width: `${tokenSizePercent * (token.size || 1)}%`,
}}
sx={{
position: "absolute",
pointerEvents: "all",
}}
>
<Box sx={{ position: "absolute", display: "flex", width: "100%" }}>
<Image
className={className}
sx={{
userSelect: "none",
touchAction: "none",
width: "100%",
position: "absolute", // Fix image stretch in safari
}}
src={token.image}
// pass data into the dom element used to pass state to the ProxyToken
data-id={token.id}
data-size={token.size}
data-label={token.label}
ref={imageRef}
/>
{token.label && <TokenLabel label={token.label} />}
</Box>
</Box>
</Box>
);
}
export default MapToken;

View File

@ -1,17 +1,17 @@
import React from "react";
import { Box, Flex, IconButton, Text } from "theme-ui";
function SizeInput({ value, onChange }) {
function NumberInput({ value, onChange, title, min, max }) {
return (
<Box>
<Text sx={{ textAlign: "center" }} variant="heading" as="h1">
Size
{title}
</Text>
<Flex sx={{ alignItems: "center", justifyContent: "center" }}>
<IconButton
aria-label="Lower Token Size"
title="Lower Token Size"
onClick={() => value > 1 && onChange(value - 1)}
aria-label={`Decrease ${title}`}
title={`Decrease ${title}`}
onClick={() => value > min && onChange(value - 1)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
@ -24,13 +24,13 @@ function SizeInput({ value, onChange }) {
<path d="M18 13H6c-.55 0-1-.45-1-1s.45-1 1-1h12c.55 0 1 .45 1 1s-.45 1-1 1z" />
</svg>
</IconButton>
<Text as="p" aria-label="Current Token Size">
<Text as="p" aria-label={`Current ${title}`}>
{value}
</Text>
<IconButton
aria-label="Increase Token Size"
title="Increase Token Size"
onClick={() => onChange(value + 1)}
aria-label={`Increase ${title}`}
title={`Increase ${title}`}
onClick={() => value < max && onChange(value + 1)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
@ -48,9 +48,12 @@ function SizeInput({ value, onChange }) {
);
}
SizeInput.defaultProps = {
NumberInput.defaultProps = {
value: 1,
onChange: () => {},
title: "Number",
min: 0,
max: 10,
};
export default SizeInput;
export default NumberInput;

View File

@ -86,10 +86,11 @@ function ProxyToken({ tokenClassName, onProxyDragEnd }) {
x = x / (mapRect.right - mapRect.left);
y = y / (mapRect.bottom - mapRect.top);
target.setAttribute("data-x", x);
target.setAttribute("data-y", y);
onProxyDragEnd(proxyOnMap.current, {
image: imageSource,
x,
y,
// Pass in props stored as data- in the dom node
...target.dataset,
});

View File

@ -1,28 +0,0 @@
import React from "react";
import { Image } from "theme-ui";
import { fromEntries } from "../helpers/shared";
// The data prop is used to pass data into the dom element
// this can be used to pass state to the ProxyToken
function Token({ image, className, data, sx }) {
// Map the keys in data to have the `data-` prefix
const dataProps = fromEntries(
Object.entries(data).map(([key, value]) => [`data-${key}`, value])
);
return (
<Image
className={className}
sx={{ userSelect: "none", touchAction: "none", ...sx }}
src={image}
{...dataProps}
/>
);
}
Token.defaultProps = {
data: {},
sx: {},
};
export default Token;

View File

@ -0,0 +1,48 @@
import React from "react";
import { Image, Box, Text } from "theme-ui";
import tokenLabel from "../images/TokenLabel.png";
function TokenLabel({ label }) {
return (
<Box
sx={{
position: "absolute",
transform: "scale(0.3) translate(0, 20%)",
transformOrigin: "bottom center",
pointerEvents: "none",
}}
>
<Image src={tokenLabel} />
<svg
style={{
position: "absolute",
top: 0,
left: 0,
}}
viewBox="0 0 100 100"
xmlns="http://www.w3.org/2000/svg"
>
<foreignObject width="100%" height="100%">
<Text
as="p"
variant="heading"
sx={{
// This value is actually 66%
fontSize: "66px",
width: "100px",
height: "100px",
textAlign: "center",
verticalAlign: "middle",
lineHeight: 1.4,
}}
>
{label}
</Text>
</foreignObject>
</svg>
</Box>
);
}
export default TokenLabel;

129
src/components/TokenMenu.js Normal file
View File

@ -0,0 +1,129 @@
import React, { useEffect, useState } from "react";
import Modal from "react-modal";
import interact from "interactjs";
import { useThemeUI, Box, Input } from "theme-ui";
function TokenMenu({ tokenClassName, onTokenChange }) {
const [isOpen, setIsOpen] = useState(false);
function handleRequestClose() {
setIsOpen(false);
}
const [currentToken, setCurrentToken] = useState(0);
const [menuLeft, setMenuLeft] = useState(0);
const [menuTop, setMenuTop] = useState(0);
function handleLabelChange(event) {
// Slice to remove Label: text
const label = event.target.value.slice(7);
if (label.length <= 1) {
setCurrentToken((prevToken) => ({
...prevToken,
label: label,
}));
onTokenChange({ ...currentToken, label: label });
}
}
useEffect(() => {
function handleTokenMenuOpen(event) {
const target = event.target;
const dataset = (target && target.dataset) || {};
setCurrentToken({
image: target.src,
...dataset,
});
const targetRect = target.getBoundingClientRect();
setMenuLeft(targetRect.left);
setMenuTop(targetRect.bottom);
setIsOpen(true);
}
// Add listener for hold gesture
interact(`.${tokenClassName}`).on("hold", handleTokenMenuOpen);
function handleMapContextMenu(event) {
event.preventDefault();
if (event.target.classList.contains(tokenClassName)) {
handleTokenMenuOpen(event);
}
}
// Handle context menu on the map level as handling
// on the token level lead to the default menu still
// being displayed
const map = document.querySelector(".map");
map.addEventListener("contextmenu", handleMapContextMenu);
return () => {
map.removeEventListener("contextmenu", handleMapContextMenu);
};
}, [tokenClassName]);
const { theme } = useThemeUI();
function handleModalContent(node) {
if (node) {
console.log(node);
const tokenLabelInput = node.querySelector("#changeTokenLabel");
tokenLabelInput.focus();
// Highlight label section of input
tokenLabelInput.setSelectionRange(7, 8);
tokenLabelInput.onblur = () => {
setIsOpen(false);
};
// Check for wheel event to close modal as well
document.body.addEventListener(
"wheel",
() => {
setIsOpen(false);
},
{ once: true }
);
}
}
return (
<Modal
isOpen={isOpen}
onRequestClose={handleRequestClose}
style={{
overlay: { top: "0", bottom: "initial" },
content: {
backgroundColor: theme.colors.highlight,
top: `${menuTop}px`,
left: `${menuLeft}px`,
right: "initial",
bottom: "initial",
padding: 0,
borderRadius: "4px",
border: "none",
},
}}
contentRef={handleModalContent}
>
<Box
as="form"
bg="background"
onSubmit={(e) => {
e.preventDefault();
handleRequestClose();
}}
sx={{ width: "72px" }}
>
<Input
id="changeTokenLabel"
onChange={handleLabelChange}
value={`Label: ${currentToken.label}`}
sx={{ padding: "4px" }}
/>
</Box>
</Modal>
);
}
export default TokenMenu;

View File

@ -5,9 +5,9 @@ import SimpleBar from "simplebar-react";
import * as tokens from "../tokens";
import Token from "./Token";
import ListToken from "./ListToken";
import ProxyToken from "./ProxyToken";
import SizeInput from "./SizeInput";
import NumberInput from "./NumberInput";
const listTokenClassName = "list-token";
@ -17,7 +17,12 @@ function Tokens({ onCreateMapToken }) {
function handleProxyDragEnd(isOnMap, token) {
if (isOnMap && onCreateMapToken) {
// Give the token an id
onCreateMapToken({ ...token, id: shortid.generate(), size: tokenSize });
onCreateMapToken({
...token,
id: shortid.generate(),
size: tokenSize,
label: "",
});
}
}
@ -34,12 +39,18 @@ 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" }}>
<Token image={image} className={listTokenClassName} />
<ListToken image={image} className={listTokenClassName} />
</Box>
))}
</SimpleBar>
<Box pt={1} bg="muted" sx={{ height: "58px" }}>
<SizeInput value={tokenSize} onChange={setTokenSize} />
<NumberInput
value={tokenSize}
onChange={setTokenSize}
title="Size"
min={1}
max={9}
/>
</Box>
</Box>
<ProxyToken

View File

@ -0,0 +1,22 @@
import { useEffect } from "react";
function usePreventTouch(elementRef) {
useEffect(() => {
// Stop 3d touch
function prevent3DTouch(event) {
event.preventDefault();
}
const element = elementRef.current;
if (element) {
element.addEventListener("touchstart", prevent3DTouch, false);
}
return () => {
if (element) {
element.removeEventListener("touchstart", prevent3DTouch);
}
};
}, [elementRef.current]);
}
export default usePreventTouch;

BIN
src/images/TokenLabel.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

@ -28,7 +28,7 @@ function Game() {
const [mapSource, setMapSource] = useState(null);
const mapDataRef = useRef(null);
function handleMapChanged(mapData, mapSource) {
function handleMapChange(mapData, mapSource) {
mapDataRef.current = mapData;
setMapSource(mapSource);
for (let peer of Object.values(peers)) {
@ -38,7 +38,7 @@ function Game() {
const [mapTokens, setMapTokens] = useState({});
function handleEditMapToken(token) {
function handleMapTokenChange(token) {
if (!mapSource) {
return;
}
@ -52,7 +52,7 @@ function Game() {
}
}
function handleRemoveMapToken(token) {
function handleMapTokenRemove(token) {
setMapTokens((prevMapTokens) => {
const { [token.id]: old, ...rest } = prevMapTokens;
return rest;
@ -213,11 +213,11 @@ function Game() {
mapSource={mapSource}
mapData={mapDataRef.current}
tokens={mapTokens}
onMapTokenMove={handleEditMapToken}
onMapTokenRemove={handleRemoveMapToken}
onMapChanged={handleMapChanged}
onMapTokenChange={handleMapTokenChange}
onMapTokenRemove={handleMapTokenRemove}
onMapChange={handleMapChange}
/>
<Tokens onCreateMapToken={handleEditMapToken} />
<Tokens onCreateMapToken={handleMapTokenChange} />
</Flex>
</Flex>
<Banner isOpen={!!peerError} onRequestClose={() => setPeerError(null)}>