diff --git a/package.json b/package.json index 546abb7..56f83e2 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "dependencies": { "@babylonjs/core": "^4.2.0", "@babylonjs/loaders": "^4.2.0", + "@dnd-kit/core": "3.0.0", "@mitchemmc/dexie-export-import": "^1.0.1", "@msgpack/msgpack": "^2.4.1", "@sentry/react": "^6.2.2", @@ -25,7 +26,6 @@ "file-saver": "^2.0.5", "fuse.js": "^6.4.6", "image-outline": "^0.1.0", - "interactjs": "^1.10.8", "konva": "^7.2.5", "lodash.clonedeep": "^4.5.0", "lodash.get": "^4.4.2", diff --git a/src/App.js b/src/App.js index 7962f7b..ee51563 100644 --- a/src/App.js +++ b/src/App.js @@ -13,12 +13,8 @@ import Donate from "./routes/Donate"; import { AuthProvider } from "./contexts/AuthContext"; import { DatabaseProvider } from "./contexts/DatabaseContext"; -import { MapDataProvider } from "./contexts/MapDataContext"; -import { TokenDataProvider } from "./contexts/TokenDataContext"; -import { MapLoadingProvider } from "./contexts/MapLoadingContext"; import { SettingsProvider } from "./contexts/SettingsContext"; import { KeyboardProvider } from "./contexts/KeyboardContext"; -import { AssetsProvider, AssetURLsProvider } from "./contexts/AssetsContext"; import { ToastProvider } from "./components/Toast"; @@ -49,17 +45,7 @@ function App() { - - - - - - - - - - - + diff --git a/src/components/Draggable.js b/src/components/Draggable.js new file mode 100644 index 0000000..f007fea --- /dev/null +++ b/src/components/Draggable.js @@ -0,0 +1,27 @@ +import React from "react"; +import { useDraggable } from "@dnd-kit/core"; + +function Draggable({ id, children, data }) { + const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ + id, + data, + }); + + const style = { + border: "none", + background: "transparent", + margin: "0px", + padding: "0px", + cursor: "pointer", + touchAction: "none", + opacity: isDragging ? 0.5 : undefined, + }; + + return ( + + ); +} + +export default Draggable; diff --git a/src/components/Droppable.js b/src/components/Droppable.js new file mode 100644 index 0000000..c51c25e --- /dev/null +++ b/src/components/Droppable.js @@ -0,0 +1,18 @@ +import React from "react"; +import { useDroppable } from "@dnd-kit/core"; + +function Droppable({ id, children, disabled }) { + const { setNodeRef } = useDroppable({ id, disabled }); + + return ( +
+ {children} +
+ ); +} + +Droppable.defaultProps = { + disabled: false, +}; + +export default Droppable; diff --git a/src/components/DragOverlay.js b/src/components/map/DragOverlay.js similarity index 97% rename from src/components/DragOverlay.js rename to src/components/map/DragOverlay.js index a89f02d..57f140e 100644 --- a/src/components/DragOverlay.js +++ b/src/components/map/DragOverlay.js @@ -1,7 +1,7 @@ import React, { useEffect, useRef, useState } from "react"; import { Box, IconButton } from "theme-ui"; -import RemoveTokenIcon from "../icons/RemoveTokenIcon"; +import RemoveTokenIcon from "../../icons/RemoveTokenIcon"; function DragOverlay({ dragging, node, onRemove }) { const [isRemoveHovered, setIsRemoveHovered] = useState(false); diff --git a/src/components/map/Map.js b/src/components/map/Map.js index 340749e..ba0df02 100644 --- a/src/components/map/Map.js +++ b/src/components/map/Map.js @@ -1,4 +1,5 @@ import React, { useState } from "react"; +import { Box } from "theme-ui"; import MapControls from "./MapControls"; import MapInteraction from "./MapInteraction"; @@ -18,6 +19,8 @@ import TokenDragOverlay from "../token/TokenDragOverlay"; import NoteMenu from "../note/NoteMenu"; import NoteDragOverlay from "../note/NoteDragOverlay"; +import Droppable from "../Droppable"; + import { AddShapeAction, CutShapeAction, @@ -336,30 +339,34 @@ function Map({ ); return ( - - {mapControls} - {tokenMenu} - {noteMenu} - {tokenDragOverlay} - {noteDragOverlay} - - } - selectedToolId={selectedToolId} - onSelectedToolChange={setSelectedToolId} - disabledControls={disabledControls} - > - {mapGrid} - {mapDrawing} - {mapNotes} - {mapTokens} - {mapFog} - {mapPointer} - {mapMeasure} - + + + + {mapControls} + {tokenMenu} + {noteMenu} + {tokenDragOverlay} + {noteDragOverlay} + + } + selectedToolId={selectedToolId} + onSelectedToolChange={setSelectedToolId} + disabledControls={disabledControls} + > + {mapGrid} + {mapDrawing} + {mapNotes} + {mapTokens} + {mapFog} + {mapPointer} + {mapMeasure} + + + ); } diff --git a/src/components/map/MapInteraction.js b/src/components/map/MapInteraction.js index fc08eff..61ba335 100644 --- a/src/components/map/MapInteraction.js +++ b/src/components/map/MapInteraction.js @@ -181,11 +181,12 @@ function MapInteraction({ + {token.name} diff --git a/src/components/token/ProxyToken.js b/src/components/token/ProxyToken.js deleted file mode 100644 index 989e8fe..0000000 --- a/src/components/token/ProxyToken.js +++ /dev/null @@ -1,172 +0,0 @@ -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 "../../hooks/usePortal"; - -import { useMapStage } from "../../contexts/MapStageContext"; - -/** - * @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 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); - const mapStageRef = useMapStage(); - - 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); - - 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; - const id = target.dataset.id; - let proxy = proxyRef.current; - if (proxy) { - const mapStage = mapStageRef.current; - if (onProxyDragEnd && mapStage) { - const mapImage = mapStage.findOne("#mapImage"); - const map = document.querySelector(".map"); - const mapRect = map.getBoundingClientRect(); - const position = { - x: event.clientX - mapRect.left, - y: event.clientY - mapRect.top, - }; - const transform = mapImage.getAbsoluteTransform().copy().invert(); - const relativePosition = transform.point(position); - const normalizedPosition = { - x: relativePosition.x / mapImage.width(), - y: relativePosition.y / mapImage.height(), - }; - // Get the token from the supplied tokens if it exists - const token = tokensRef.current[id] || {}; - onProxyDragEnd(proxyOnMap.current, { - ...token, - x: normalizedPosition.x, - y: normalizedPosition.y, - }); - } - - // 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, mapStageRef]); - - if (!imageSource) { - return null; - } - - // Create a portal to allow the proxy to move past the bounds of the token - return ReactDOM.createPortal( - - - - - , - proxyContainer - ); -} - -ProxyToken.defaultProps = { - tokens: {}, -}; - -export default ProxyToken; diff --git a/src/components/token/TokenBar.js b/src/components/token/TokenBar.js index dfc9273..ec01f07 100644 --- a/src/components/token/TokenBar.js +++ b/src/components/token/TokenBar.js @@ -1,98 +1,77 @@ import React from "react"; +import { createPortal } from "react-dom"; import { Box, Flex } from "theme-ui"; -import shortid from "shortid"; import SimpleBar from "simplebar-react"; +import { DragOverlay } from "@dnd-kit/core"; import ListToken from "./ListToken"; -import ProxyToken from "./ProxyToken"; - import SelectTokensButton from "./SelectTokensButton"; - -import { fromEntries } from "../../helpers/shared"; +import Draggable from "../Draggable"; import useSetting from "../../hooks/useSetting"; -import { useAuth } from "../../contexts/AuthContext"; import { useTokenData } from "../../contexts/TokenDataContext"; +import { useDragId } from "../../contexts/DragContext"; -const listTokenClassName = "list-token"; - -function TokenBar({ onMapTokenStateCreate }) { - const { userId } = useAuth(); - const { ownedTokens, tokens } = useTokenData(); +function TokenBar() { + const { ownedTokens } = useTokenData(); const [fullScreen] = useSetting("map.fullScreen"); - function handleProxyDragEnd(isOnMap, token) { - if (isOnMap && onMapTokenStateCreate) { - // Create a token state from the dragged token - let tokenState = { - id: shortid.generate(), - tokenId: token.id, - owner: userId, - size: token.defaultSize, - category: token.defaultCategory, - label: token.defaultLabel, - statuses: [], - x: token.x, - y: token.y, - lastModifiedBy: userId, - lastModified: Date.now(), - rotation: 0, - locked: false, - visible: true, - type: token.type, - outline: token.outline, - width: token.width, - height: token.height, - }; - if (token.type === "file") { - tokenState.file = token.file; - } else if (token.type === "default") { - tokenState.key = token.key; - } - onMapTokenStateCreate(tokenState); - } - } + const activeDragId = useDragId(); return ( - <> - + - - {ownedTokens - .filter((token) => !token.hideInSidebar) - .map((token) => ( - - ))} - - - - - - [token.id, token]))} - /> - + {ownedTokens + .filter((token) => !token.hideInSidebar) + .map((token) => ( + + + + ))} + + + + + {createPortal( + + {activeDragId && ( + `sidebar-${token.id}` === activeDragId + )} + /> + )} + , + document.body + )} + ); } diff --git a/src/components/token/TokenDragOverlay.js b/src/components/token/TokenDragOverlay.js index d5d9348..731ae16 100644 --- a/src/components/token/TokenDragOverlay.js +++ b/src/components/token/TokenDragOverlay.js @@ -6,7 +6,7 @@ import { useMapHeight, } from "../../contexts/MapInteractionContext"; -import DragOverlay from "../DragOverlay"; +import DragOverlay from "../map/DragOverlay"; function TokenDragOverlay({ onTokenStateRemove, diff --git a/src/contexts/DragContext.js b/src/contexts/DragContext.js new file mode 100644 index 0000000..faf7d59 --- /dev/null +++ b/src/contexts/DragContext.js @@ -0,0 +1,36 @@ +import React, { useState, useContext } from "react"; +import { DndContext } from "@dnd-kit/core"; + +/** + * @type {React.Context} + */ +const DragIdContext = React.createContext(); + +export function DragProvider({ children, onDragEnd }) { + const [activeDragId, setActiveDragId] = useState(null); + + function handleDragStart({ active }) { + setActiveDragId(active.id); + } + + function handleDragEnd(event) { + setActiveDragId(null); + onDragEnd && onDragEnd(event); + } + + return ( + + + {children} + + + ); +} + +export function useDragId() { + const context = useContext(DragIdContext); + if (context === undefined) { + throw new Error("useDragId must be used within a DragProvider"); + } + return context; +} diff --git a/src/network/NetworkedMapAndTokens.js b/src/network/NetworkedMapAndTokens.js index 6517d25..37035f6 100644 --- a/src/network/NetworkedMapAndTokens.js +++ b/src/network/NetworkedMapAndTokens.js @@ -1,5 +1,6 @@ import React, { useState, useEffect, useRef } from "react"; import { useToasts } from "react-toast-notifications"; +import { v4 as uuid } from "uuid"; import { useMapData } from "../contexts/MapDataContext"; import { useMapLoading } from "../contexts/MapLoadingContext"; @@ -7,6 +8,9 @@ import { useAuth } from "../contexts/AuthContext"; import { useDatabase } from "../contexts/DatabaseContext"; import { useParty } from "../contexts/PartyContext"; import { useAssets } from "../contexts/AssetsContext"; +import { DragProvider } from "../contexts/DragContext"; +import { useTokenData } from "../contexts/TokenDataContext"; +import { useMapStage } from "../contexts/MapStageContext"; import { omit } from "../helpers/shared"; @@ -40,8 +44,10 @@ function NetworkedMapAndTokens({ session }) { const { userId } = useAuth(); const partyState = useParty(); const { assetLoadStart, assetProgressUpdate, isLoading } = useMapLoading(); + const mapStageRef = useMapStage(); const { updateMapState } = useMapData(); + const { tokensById } = useTokenData(); const { getAsset, putAsset } = useAssets(); const [currentMap, setCurrentMap] = useState(null); @@ -379,6 +385,53 @@ function NetworkedMapAndTokens({ session }) { }); } + function handleDragEnd({ active, over }) { + const tokenId = active?.data?.current?.tokenId; + const token = tokensById[tokenId]; + const mapStage = mapStageRef.current; + if (over?.id === "map" && tokenId && token && mapStage) { + const mapImage = mapStage.findOne("#mapImage"); + // TODO: Get proper pointer position when dnd-kit is updated + // https://github.com/clauderic/dnd-kit/issues/238 + const pointerPosition = { + x: over.rect.width / 2, + y: over.rect.height / 2, + }; + const transform = mapImage.getAbsoluteTransform().copy().invert(); + const relativePosition = transform.point(pointerPosition); + const normalizedPosition = { + x: relativePosition.x / mapImage.width(), + y: relativePosition.y / mapImage.height(), + }; + let tokenState = { + id: uuid(), + tokenId: token.id, + owner: userId, + size: token.defaultSize, + category: token.defaultCategory, + label: token.defaultLabel, + statuses: [], + x: normalizedPosition.x, + y: normalizedPosition.y, + lastModifiedBy: userId, + lastModified: Date.now(), + rotation: 0, + locked: false, + visible: true, + type: token.type, + outline: token.outline, + width: token.width, + height: token.height, + }; + if (token.type === "file") { + tokenState.file = token.file; + } else if (token.type === "default") { + tokenState.key = token.key; + } + handleMapTokenStateCreate(tokenState); + } + } + useEffect(() => { async function handlePeerData({ id, data, reply }) { if (id === "assetRequest") { @@ -451,7 +504,7 @@ function NetworkedMapAndTokens({ session }) { } return ( - <> + - - + + ); } diff --git a/src/routes/Game.js b/src/routes/Game.js index 190fb86..aa324c2 100644 --- a/src/routes/Game.js +++ b/src/routes/Game.js @@ -18,6 +18,10 @@ import { MapStageProvider } from "../contexts/MapStageContext"; import { useDatabase } from "../contexts/DatabaseContext"; import { PlayerProvider } from "../contexts/PlayerContext"; import { PartyProvider } from "../contexts/PartyContext"; +import { AssetsProvider, AssetURLsProvider } from "../contexts/AssetsContext"; +import { MapDataProvider } from "../contexts/MapDataContext"; +import { TokenDataProvider } from "../contexts/TokenDataContext"; +import { MapLoadingProvider } from "../contexts/MapLoadingContext"; import NetworkedMapAndTokens from "../network/NetworkedMapAndTokens"; import NetworkedParty from "../network/NetworkedParty"; @@ -84,7 +88,6 @@ function Game() { }; }, [session]); - // Join game useEffect(() => { if (sessionStatus === "ready" && databaseStatus !== "loading") { @@ -103,50 +106,62 @@ function Game() { const mapStageRef = useRef(); return ( - - - - - - - - - - setPeerError(null)} - > - - - {peerError} See FAQ for more - information. - - - - - - - setGameExpired(false)} - /> - - {!sessionStatus && } - - - - + + + + + + + + + + + + + + + setPeerError(null)} + > + + + {peerError} See FAQ{" "} + for more information. + + + + + + + setGameExpired(false)} + /> + + {!sessionStatus && } + + + + + + + + + ); } diff --git a/yarn.lock b/yarn.lock index fe9cb37..827f281 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1796,6 +1796,29 @@ resolved "https://registry.yarnpkg.com/@csstools/normalize.css/-/normalize.css-10.1.0.tgz#f0950bba18819512d42f7197e56c518aa491cf18" integrity sha512-ij4wRiunFfaJxjB0BdrYHIH8FxBJpOwNPhhAcunlmPdXudL1WQV1qoP9un6JsEBAgQH+7UXyyjh0g7jTxXK6tg== +"@dnd-kit/accessibility@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@dnd-kit/accessibility/-/accessibility-3.0.0.tgz#b56e3750414fd907b7d6972b3116aa8f96d07fde" + integrity sha512-QwaQ1IJHQHMMuAGOOYHQSx7h7vMZPfO97aDts8t5N/MY7n2QTDSnW+kF7uRQ1tVBkr6vJ+BqHWG5dlgGvwVjow== + dependencies: + tslib "^2.0.0" + +"@dnd-kit/core@3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@dnd-kit/core/-/core-3.0.0.tgz#96dadb6b2dba05ab177e0190b33ae219017bc167" + integrity sha512-QxHLfZHOLkQWK0FPbr5hefWZzsdZfDuluKPwIK1bT2lwp/4hmFXRA6ivqX3FT4g8T0d2de2C1jxYhKM4H3uMQw== + dependencies: + "@dnd-kit/accessibility" "^3.0.0" + "@dnd-kit/utilities" "^2.0.0" + tslib "^2.0.0" + +"@dnd-kit/utilities@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@dnd-kit/utilities/-/utilities-2.0.0.tgz#a8462dff65c6f606ecbe95273c7e263b14a1ab97" + integrity sha512-bjs49yMNzMM+BYRsBUhTqhTk6HEvhuY3leFt6Em6NaYGgygaMbtGbbXof/UXBv7rqyyi0OkmBBnrCCcxqS2t/g== + dependencies: + tslib "^2.0.0" + "@emotion/cache@^10.0.27": version "10.0.29" resolved "https://registry.yarnpkg.com/@emotion/cache/-/cache-10.0.29.tgz#87e7e64f412c060102d589fe7c6dc042e6f9d1e0" @@ -2008,11 +2031,6 @@ dependencies: "@hapi/hoek" "^8.3.0" -"@interactjs/types@1.10.8": - version "1.10.8" - resolved "https://registry.yarnpkg.com/@interactjs/types/-/types-1.10.8.tgz#098da479de9c5ac9c8ba97d113746b7dcd9c2204" - integrity sha512-qU2QfnN7r8AU4mSd2W3XmRtR0d35R1PReIT9b5YzpNLX9S0OQgNBLrEEFyXpa9alq/9h6wYNIwPCVAsknF5uZw== - "@istanbuljs/load-nyc-config@^1.0.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced" @@ -7274,13 +7292,6 @@ ini@^1.3.5: resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw== -interactjs@^1.10.8: - version "1.10.8" - resolved "https://registry.yarnpkg.com/interactjs/-/interactjs-1.10.8.tgz#a85b6e89ebf2ed88ea1678287ffcf0becf0dfb1c" - integrity sha512-hIU82lF9mplmAHVTUmZbHMHKm96AwlD0zWGuf9krKt2dhALHsMOdU+yVilPqIv1VpNAGV66F9B14Rfs4ulS2nA== - dependencies: - "@interactjs/types" "1.10.8" - internal-ip@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/internal-ip/-/internal-ip-4.3.0.tgz#845452baad9d2ca3b69c635a137acb9a0dad0907" @@ -12816,6 +12827,11 @@ tslib@^1.10.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== +tslib@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.2.0.tgz#fb2c475977e35e241311ede2693cee1ec6698f5c" + integrity sha512-gS9GVHRU+RGn5KQM2rllAlR3dU6m7AcpJKdtH8gFvQiC4Otgk98XnmMU+nZenHt/+VhnBPWwgrJsyrdcw6i23w== + tsutils@^3.17.1: version "3.17.1" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.17.1.tgz#ed719917f11ca0dee586272b2ac49e015a2dd759"