import React, { useEffect, useState, useRef } from "react"; import interact from "interactjs"; import { Box, Input } from "theme-ui"; import MapMenu from "../map/MapMenu"; import colors, { colorOptions } from "../../helpers/colors"; /** * @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 * @param {Object} disabledTokens An optional mapping of tokens that shouldn't allow interaction */ function TokenMenu({ tokenClassName, onTokenChange, tokens, disabledTokens }) { 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); const disabledTokensRef = useRef(disabledTokens); useEffect(() => { tokensRef.current = tokens; disabledTokensRef.current = disabledTokens; }, [tokens, disabledTokens]); 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.statuses; let newStatuses = []; if (statuses.includes(status)) { newStatuses = statuses.filter((s) => s !== status); } else { newStatuses = [...statuses, status]; } setCurrentToken((prevToken) => ({ ...prevToken, statuses: newStatuses, })); onTokenChange({ ...currentToken, statuses: newStatuses }); } useEffect(() => { function handleTokenMenuOpen(event) { const target = event.target; const id = target.getAttribute("data-id"); if (id in disabledTokensRef.current) { return; } const token = tokensRef.current[id] || {}; setCurrentToken(token); 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 ( { e.preventDefault(); handleRequestClose(); }} > {colorOptions.map((color) => ( handleStatusChange(color)} aria-label={`Token label Color ${color}`} > {currentToken.statuses && currentToken.statuses.includes(color) && ( )} ))} ); } export default TokenMenu;