Refactored component folder structure to be a little clearer
This commit is contained in:
21
src/components/token/ListToken.js
Normal file
21
src/components/token/ListToken.js
Normal 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;
|
||||
151
src/components/token/ProxyToken.js
Normal file
151
src/components/token/ProxyToken.js
Normal file
@@ -0,0 +1,151 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { Image, Box } from "theme-ui";
|
||||
import interact from "interactjs";
|
||||
|
||||
import usePortal from "../../helpers/usePortal";
|
||||
|
||||
import TokenLabel from "./TokenLabel";
|
||||
import TokenStatus from "./TokenStatus";
|
||||
|
||||
function ProxyToken({ tokenClassName, onProxyDragEnd }) {
|
||||
const proxyContainer = usePortal("root");
|
||||
|
||||
const [imageSource, setImageSource] = useState("");
|
||||
const [label, setLabel] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const proxyRef = useRef();
|
||||
|
||||
const proxyOnMap = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
interact(`.${tokenClassName}`).draggable({
|
||||
listeners: {
|
||||
start: (event) => {
|
||||
let target = event.target;
|
||||
// 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 || "");
|
||||
|
||||
let proxy = proxyRef.current;
|
||||
if (proxy) {
|
||||
// Find and set the initial offset of the token to the proxy
|
||||
const proxyRect = proxy.getBoundingClientRect();
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
const xOffset = targetRect.left - proxyRect.left;
|
||||
const yOffset = targetRect.top - proxyRect.top;
|
||||
proxy.style.transform = `translate(${xOffset}px, ${yOffset}px)`;
|
||||
proxy.setAttribute("data-x", xOffset);
|
||||
proxy.setAttribute("data-y", yOffset);
|
||||
|
||||
// Copy width and height of target
|
||||
proxy.style.width = `${targetRect.width}px`;
|
||||
proxy.style.height = `${targetRect.height}px`;
|
||||
}
|
||||
},
|
||||
|
||||
move: (event) => {
|
||||
let proxy = proxyRef.current;
|
||||
// Move the proxy based off of the movment of the token
|
||||
if (proxy) {
|
||||
// keep the dragged position in the data-x/data-y attributes
|
||||
const x =
|
||||
(parseFloat(proxy.getAttribute("data-x")) || 0) + event.dx;
|
||||
const y =
|
||||
(parseFloat(proxy.getAttribute("data-y")) || 0) + event.dy;
|
||||
proxy.style.transform = `translate(${x}px, ${y}px)`;
|
||||
|
||||
// Check whether the proxy is on the right or left hand side of the screen
|
||||
// if not set proxyOnMap to true
|
||||
const proxyRect = proxy.getBoundingClientRect();
|
||||
const map = document.querySelector(".map");
|
||||
const mapRect = map.getBoundingClientRect();
|
||||
proxyOnMap.current =
|
||||
proxyRect.left > mapRect.left && proxyRect.right < mapRect.right;
|
||||
|
||||
// update the posiion attributes
|
||||
proxy.setAttribute("data-x", x);
|
||||
proxy.setAttribute("data-y", y);
|
||||
}
|
||||
},
|
||||
|
||||
end: (event) => {
|
||||
let target = event.target;
|
||||
let proxy = proxyRef.current;
|
||||
if (proxy) {
|
||||
if (onProxyDragEnd) {
|
||||
const mapImage = document.querySelector(".mapImage");
|
||||
const mapImageRect = mapImage.getBoundingClientRect();
|
||||
|
||||
let x = parseFloat(proxy.getAttribute("data-x")) || 0;
|
||||
let y = parseFloat(proxy.getAttribute("data-y")) || 0;
|
||||
// Convert coordiantes to be relative to the map
|
||||
x = x - mapImageRect.left;
|
||||
y = y - mapImageRect.top;
|
||||
// Normalize to map width
|
||||
x = x / (mapImageRect.right - mapImageRect.left);
|
||||
y = y / (mapImageRect.bottom - mapImageRect.top);
|
||||
|
||||
target.setAttribute("data-x", x);
|
||||
target.setAttribute("data-y", y);
|
||||
|
||||
onProxyDragEnd(proxyOnMap.current, {
|
||||
image: target.src,
|
||||
// Pass in props stored as data- in the dom node
|
||||
...target.dataset,
|
||||
});
|
||||
}
|
||||
|
||||
// Reset the proxy position
|
||||
proxy.style.transform = "translate(0px, 0px)";
|
||||
proxy.setAttribute("data-x", 0);
|
||||
proxy.setAttribute("data-y", 0);
|
||||
}
|
||||
|
||||
// Show the token
|
||||
target.parentElement.style.opacity = "1";
|
||||
setImageSource("");
|
||||
},
|
||||
},
|
||||
});
|
||||
}, [onProxyDragEnd, tokenClassName, proxyContainer]);
|
||||
|
||||
if (!imageSource) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create a portal to allow the proxy to move past the bounds of the token
|
||||
return ReactDOM.createPortal(
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
overflow: "hidden",
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{ position: "absolute", display: "flex", flexDirection: "column" }}
|
||||
ref={proxyRef}
|
||||
>
|
||||
<Image
|
||||
src={imageSource}
|
||||
sx={{
|
||||
touchAction: "none",
|
||||
userSelect: "none",
|
||||
width: "100%",
|
||||
}}
|
||||
/>
|
||||
{status && <TokenStatus statuses={status.split(" ")} />}
|
||||
{label && <TokenLabel label={label} />}
|
||||
</Box>
|
||||
</Box>,
|
||||
proxyContainer
|
||||
);
|
||||
}
|
||||
|
||||
export default ProxyToken;
|
||||
51
src/components/token/TokenLabel.js
Normal file
51
src/components/token/TokenLabel.js
Normal file
@@ -0,0 +1,51 @@
|
||||
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",
|
||||
width: "100%",
|
||||
display: "flex", // Set display to flex to fix height being calculated wrong
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Image sx={{ width: "100%" }} 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;
|
||||
185
src/components/token/TokenMenu.js
Normal file
185
src/components/token/TokenMenu.js
Normal file
@@ -0,0 +1,185 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import interact from "interactjs";
|
||||
import { Box, Input } from "theme-ui";
|
||||
|
||||
import MapMenu from "../map/MapMenu";
|
||||
|
||||
import colors, { colorOptions } from "../../helpers/colors";
|
||||
|
||||
function TokenMenu({ tokenClassName, onTokenChange }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
function handleRequestClose() {
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
const [currentToken, setCurrentToken] = useState({});
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
function handleStatusChange(status) {
|
||||
const statuses =
|
||||
currentToken.status.split(" ").filter((s) => s !== "") || [];
|
||||
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,
|
||||
}));
|
||||
onTokenChange({ ...currentToken, status: newStatus });
|
||||
}
|
||||
|
||||
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 tap gesture
|
||||
const tokenInteract = interact(`.${tokenClassName}`).on(
|
||||
"tap",
|
||||
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);
|
||||
tokenInteract.unset();
|
||||
};
|
||||
}, [tokenClassName]);
|
||||
|
||||
function handleModalContent(node) {
|
||||
if (node) {
|
||||
// Focus input
|
||||
const tokenLabelInput = node.querySelector("#changeTokenLabel");
|
||||
tokenLabelInput.focus();
|
||||
tokenLabelInput.setSelectionRange(7, 8);
|
||||
|
||||
// Ensure menu is in bounds
|
||||
const nodeRect = node.getBoundingClientRect();
|
||||
const map = document.querySelector(".map");
|
||||
const mapRect = map.getBoundingClientRect();
|
||||
setMenuLeft((prevLeft) =>
|
||||
Math.min(
|
||||
mapRect.right - nodeRect.width,
|
||||
Math.max(mapRect.left, prevLeft)
|
||||
)
|
||||
);
|
||||
setMenuTop((prevTop) =>
|
||||
Math.min(mapRect.bottom - nodeRect.height, prevTop)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<MapMenu
|
||||
isOpen={isOpen}
|
||||
onRequestClose={handleRequestClose}
|
||||
top={`${menuTop}px`}
|
||||
left={`${menuLeft}px`}
|
||||
onModalContent={handleModalContent}
|
||||
>
|
||||
<Box sx={{ width: "104px" }} p={1}>
|
||||
<Box
|
||||
as="form"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleRequestClose();
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
id="changeTokenLabel"
|
||||
onChange={handleLabelChange}
|
||||
value={`Label: ${currentToken.label}`}
|
||||
sx={{
|
||||
padding: "4px",
|
||||
border: "none",
|
||||
":focus": {
|
||||
outline: "none",
|
||||
},
|
||||
}}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
{colorOptions.map((color) => (
|
||||
<Box
|
||||
key={color}
|
||||
sx={{
|
||||
width: "25%",
|
||||
paddingTop: "25%",
|
||||
borderRadius: "50%",
|
||||
transform: "scale(0.75)",
|
||||
backgroundColor: colors[color],
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={() => handleStatusChange(color)}
|
||||
aria-label={`Token label Color ${color}`}
|
||||
>
|
||||
{currentToken.status && currentToken.status.includes(color) && (
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
border: "2px solid white",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
borderRadius: "50%",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</MapMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export default TokenMenu;
|
||||
47
src/components/token/TokenStatus.js
Normal file
47
src/components/token/TokenStatus.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import React from "react";
|
||||
import { Box } from "theme-ui";
|
||||
|
||||
import colors from "../../helpers/colors";
|
||||
|
||||
function TokenStatus({ statuses }) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{statuses.map((status, index) => (
|
||||
<Box
|
||||
key={status}
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "absolute",
|
||||
opacity: 0.8,
|
||||
transform: `scale(${1 - index / 10})`,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
style={{ position: "absolute" }}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 100 100"
|
||||
>
|
||||
<circle
|
||||
r={47}
|
||||
cx={50}
|
||||
cy={50}
|
||||
fill="none"
|
||||
stroke={colors[status]}
|
||||
strokeWidth={4}
|
||||
/>
|
||||
</svg>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default TokenStatus;
|
||||
65
src/components/token/Tokens.js
Normal file
65
src/components/token/Tokens.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import React, { useState } from "react";
|
||||
import { Box } from "theme-ui";
|
||||
import shortid from "shortid";
|
||||
import SimpleBar from "simplebar-react";
|
||||
|
||||
import * as tokens from "../../tokens";
|
||||
|
||||
import ListToken from "./ListToken";
|
||||
import ProxyToken from "./ProxyToken";
|
||||
import NumberInput from "../NumberInput";
|
||||
|
||||
const listTokenClassName = "list-token";
|
||||
|
||||
function Tokens({ onCreateMapToken }) {
|
||||
const [tokenSize, setTokenSize] = useState(1);
|
||||
|
||||
function handleProxyDragEnd(isOnMap, token) {
|
||||
if (isOnMap && onCreateMapToken) {
|
||||
// Give the token an id
|
||||
onCreateMapToken({
|
||||
...token,
|
||||
id: shortid.generate(),
|
||||
size: tokenSize,
|
||||
label: "",
|
||||
status: "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
height: "100%",
|
||||
width: "80px",
|
||||
minWidth: "80px",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
</SimpleBar>
|
||||
<Box pt={1} bg="muted" sx={{ height: "58px" }}>
|
||||
<NumberInput
|
||||
value={tokenSize}
|
||||
onChange={setTokenSize}
|
||||
title="Size"
|
||||
min={1}
|
||||
max={9}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<ProxyToken
|
||||
tokenClassName={listTokenClassName}
|
||||
onProxyDragEnd={handleProxyDragEnd}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Tokens;
|
||||
Reference in New Issue
Block a user