Add ability to drag and drop a tile onto the map and added better drag cursors

This commit is contained in:
Mitchell McCaffrey 2021-05-28 17:06:20 +10:00
parent f84baab6fb
commit bac3101886
12 changed files with 400 additions and 186 deletions

View File

@ -13,6 +13,7 @@ function SortableTile({
hidden,
children,
isDragging,
cursor,
}) {
const {
attributes,
@ -29,7 +30,7 @@ function SortableTile({
});
const dragStyle = {
cursor: "pointer",
cursor,
opacity: isDragging ? 0.25 : undefined,
};
@ -92,4 +93,8 @@ function SortableTile({
);
}
SortableTile.defaultProps = {
cursor: "pointer",
};
export default SortableTile;

View File

@ -20,7 +20,7 @@ import {
import { useGroup } from "../../contexts/GroupContext";
function SortableTiles({ renderTile, subgroup }) {
const { dragId, overId } = useTileDrag();
const { dragId, overId, dragCursor } = useTileDrag();
const {
groups: allGroups,
selectedGroupIds: allSelectedIds,
@ -88,6 +88,7 @@ function SortableTiles({ renderTile, subgroup }) {
<div
style={{
transform: `translate(${coords[index].x}%, ${coords[index].y}%)`,
cursor: dragCursor,
}}
>
<animated.div style={dragBounce}>
@ -137,6 +138,7 @@ function SortableTiles({ renderTile, subgroup }) {
disableSorting={disableSorting}
hidden={group.id === openGroupId}
isDragging={isDragging}
cursor={dragCursor}
>
{renderSortableGroup(group, selectedGroups)}
</SortableTile>

View File

@ -24,7 +24,6 @@ function Tile({
borderRadius: "4px",
justifyContent: "center",
alignItems: "center",
cursor: "pointer",
overflow: "hidden",
userSelect: "none",
}}

View File

@ -0,0 +1,54 @@
import React from "react";
import { createPortal } from "react-dom";
import Droppable from "../drag/Droppable";
import { ADD_TO_MAP_ID_PREFIX } from "../../contexts/TileDragContext";
function TilesAddDroppable({ containerSize }) {
return createPortal(
<div>
<Droppable
id={`${ADD_TO_MAP_ID_PREFIX}-1`}
style={{
width: "100vw",
height: `calc(50vh - ${containerSize.height / 2}px)`,
position: "absolute",
top: 0,
}}
/>
<Droppable
id={`${ADD_TO_MAP_ID_PREFIX}-2`}
style={{
width: "100vw",
height: `calc(50vh - ${containerSize.height / 2}px)`,
position: "absolute",
bottom: 0,
}}
/>
<Droppable
id={`${ADD_TO_MAP_ID_PREFIX}-3`}
style={{
width: `calc(50vw - ${containerSize.width / 2}px)`,
height: "100vh",
position: "absolute",
top: 0,
left: 0,
}}
/>
<Droppable
id={`${ADD_TO_MAP_ID_PREFIX}-4`}
style={{
width: `calc(50vw - ${containerSize.width / 2}px)`,
height: "100vh",
position: "absolute",
top: 0,
right: 0,
}}
/>
</div>,
document.body
);
}
export default TilesAddDroppable;

View File

@ -1,14 +1,12 @@
import React, { useState } from "react";
import { createPortal } from "react-dom";
import { Box, Close, Grid, useThemeUI } from "theme-ui";
import { useSpring, animated, config } from "react-spring";
import ReactResizeDetector from "react-resize-detector";
import SimpleBar from "simplebar-react";
import { useGroup } from "../../contexts/GroupContext";
import { UNGROUP_ID_PREFIX } from "../../contexts/TileDragContext";
import Droppable from "../drag/Droppable";
import TilesUngroupDroppable from "./TilesUngroupDroppable";
import useResponsiveLayout from "../../hooks/useResponsiveLayout";
@ -25,73 +23,47 @@ function TilesOverlay({ children }) {
config: config.gentle,
});
const [containerSize, setContinerSize] = useState(0);
function handleResize(width, height) {
const [containerSize, setContinerSize] = useState({ width: 0, height: 0 });
function handleContainerResize(width, height) {
const size = Math.min(width, height) - 16;
setContinerSize(size);
setContinerSize({ width: size, height: size });
}
function renderUngroupBoxes() {
return createPortal(
<div>
<Droppable
id={`${UNGROUP_ID_PREFIX}-1`}
style={{
width: "100vw",
height: `calc(50vh - ${containerSize / 2}px)`,
position: "absolute",
top: 0,
}}
/>
<Droppable
id={`${UNGROUP_ID_PREFIX}-2`}
style={{
width: "100vw",
height: `calc(50vh - ${containerSize / 2}px)`,
position: "absolute",
bottom: 0,
}}
/>
<Droppable
id={`${UNGROUP_ID_PREFIX}-3`}
style={{
width: `calc(50vw - ${containerSize / 2}px)`,
height: "100vh",
position: "absolute",
top: 0,
left: 0,
}}
/>
<Droppable
id={`${UNGROUP_ID_PREFIX}-4`}
style={{
width: `calc(50vw - ${containerSize / 2}px)`,
height: "100vh",
position: "absolute",
top: 0,
right: 0,
}}
/>
</div>,
document.body
);
const [overlaySize, setOverlaySize] = useState({ width: 0, height: 0 });
function handleOverlayResize(width, height) {
setOverlaySize({ width, height });
}
return (
<>
{openGroupId && renderUngroupBoxes()}
{openGroupId && (
<Box
sx={{
position: "absolute",
width: "100%",
height: "100%",
top: 0,
}}
bg="overlay"
<TilesUngroupDroppable
innerContainerSize={containerSize}
outerContainerSize={overlaySize}
/>
)}
<ReactResizeDetector handleWidth handleHeight onResize={handleResize}>
{openGroupId && (
<ReactResizeDetector
handleWidth
handleHeight
onResize={handleOverlayResize}
>
<Box
sx={{
position: "absolute",
width: "100%",
height: "100%",
top: 0,
}}
bg="overlay"
/>
</ReactResizeDetector>
)}
<ReactResizeDetector
handleWidth
handleHeight
onResize={handleContainerResize}
>
<animated.div
style={{
...openAnimation,
@ -109,8 +81,8 @@ function TilesOverlay({ children }) {
>
<Box
sx={{
width: containerSize,
height: containerSize,
width: containerSize.width,
height: containerSize.height,
borderRadius: "8px",
border: "1px solid",
borderColor: "border",
@ -125,8 +97,8 @@ function TilesOverlay({ children }) {
>
<SimpleBar
style={{
width: containerSize - 16,
height: containerSize - 48,
width: containerSize.width - 16,
height: containerSize.height - 48,
marginBottom: "8px",
backgroundColor: theme.colors.muted,
}}

View File

@ -0,0 +1,59 @@
import React from "react";
import { createPortal } from "react-dom";
import Droppable from "../drag/Droppable";
import { UNGROUP_ID_PREFIX } from "../../contexts/TileDragContext";
function TilesUngroupDroppable({ outerContainerSize, innerContainerSize }) {
const width = (outerContainerSize.width - innerContainerSize.width) / 2;
const height = (outerContainerSize.height - innerContainerSize.height) / 2;
return createPortal(
<div>
<Droppable
id={`${UNGROUP_ID_PREFIX}-1`}
style={{
width: outerContainerSize.width,
height,
position: "absolute",
top: `calc(50% - ${innerContainerSize.height / 2 + height}px)`,
left: `calc(50% - ${outerContainerSize.width / 2}px)`,
}}
/>
<Droppable
id={`${UNGROUP_ID_PREFIX}-2`}
style={{
width: outerContainerSize.width,
height,
position: "absolute",
top: `calc(50% + ${innerContainerSize.height / 2}px)`,
left: `calc(50% - ${outerContainerSize.width / 2}px)`,
}}
/>
<Droppable
id={`${UNGROUP_ID_PREFIX}-3`}
style={{
width,
height: outerContainerSize.height,
position: "absolute",
top: `calc(50% - ${outerContainerSize.height / 2}px)`,
left: `calc(50% - ${innerContainerSize.width / 2 + width}px)`,
}}
/>
<Droppable
id={`${UNGROUP_ID_PREFIX}-4`}
style={{
width,
height: outerContainerSize.height,
position: "absolute",
top: `calc(50% - ${outerContainerSize.height / 2}px)`,
left: `calc(50% + ${innerContainerSize.width / 2}px)`,
}}
/>
</div>,
document.body
);
}
export default TilesUngroupDroppable;

View File

@ -5,7 +5,7 @@ import SelectTokensIcon from "../../icons/SelectTokensIcon";
import SelectTokensModal from "../../modals/SelectTokensModal";
function SelectTokensButton() {
function SelectTokensButton({ onMapTokenStateCreate }) {
const [isModalOpen, setIsModalOpen] = useState(false);
function openModal() {
setIsModalOpen(true);
@ -30,6 +30,7 @@ function SelectTokensButton() {
isOpen={isModalOpen}
onRequestClose={closeModal}
onDone={handleDone}
onMapTokenStateCreate={onMapTokenStateCreate}
/>
</>
);

View File

@ -16,7 +16,10 @@ import { useTokenData } from "../../contexts/TokenDataContext";
import { useAuth } from "../../contexts/AuthContext";
import { useMapStage } from "../../contexts/MapStageContext";
import { createTokenState } from "../../helpers/token";
import {
createTokenState,
clientPositionToMapPosition,
} from "../../helpers/token";
function TokenBar({ onMapTokenStateCreate }) {
const { userId } = useAuth();
@ -41,38 +44,15 @@ function TokenBar({ onMapTokenStateCreate }) {
const mapStage = mapStageRef.current;
const dragOverlay = dragOverlayRef.current;
if (mapStage && dragOverlay) {
const mapImage = mapStage.findOne("#mapImage");
const map = document.querySelector(".map");
const mapRect = map.getBoundingClientRect();
const dragRect = dragOverlay.getBoundingClientRect();
const dragPosition = {
x: dragRect.left + dragRect.width / 2,
y: dragRect.top + dragRect.height / 2,
};
// Check map bounds
if (dragPosition.x < mapRect.left || dragPosition.x > mapRect.right) {
return;
}
// Convert relative to map rect
const mapPosition = {
x: dragPosition.x - mapRect.left,
y: dragPosition.y - mapRect.top,
};
// Convert relative to map image
const transform = mapImage.getAbsoluteTransform().copy().invert();
const relativePosition = transform.point(mapPosition);
const normalizedPosition = {
x: relativePosition.x / mapImage.width(),
y: relativePosition.y / mapImage.height(),
};
const mapPosition = clientPositionToMapPosition(mapStage, dragPosition);
const token = tokensById[active.id];
if (token) {
const tokenState = createTokenState(token, normalizedPosition, userId);
if (token && mapPosition) {
const tokenState = createTokenState(token, mapPosition, userId);
onMapTokenStateCreate(tokenState);
}
}
@ -143,7 +123,7 @@ function TokenBar({ onMapTokenStateCreate }) {
alignItems: "center",
}}
>
<SelectTokensButton />
<SelectTokensButton onMapTokenStateCreate={onMapTokenStateCreate} />
</Flex>
{createPortal(
<DragOverlay

View File

@ -19,8 +19,9 @@ export const BASE_SORTABLE_ID = "__base__";
export const GROUP_SORTABLE_ID = "__group__";
export const GROUP_ID_PREFIX = "__group__";
export const UNGROUP_ID_PREFIX = "__ungroup__";
export const ADD_TO_MAP_ID_PREFIX = "__add__";
export function TileDragProvider({ children }) {
export function TileDragProvider({ onDragAdd, children }) {
const {
groups: allGroups,
openGroupId,
@ -46,6 +47,7 @@ export function TileDragProvider({ children }) {
const [dragId, setDragId] = useState();
const [overId, setOverId] = useState();
const [dragCursor, setDragCursor] = useState("pointer");
function handleDragStart({ active, over }) {
setDragId(active.id);
@ -57,11 +59,21 @@ export function TileDragProvider({ children }) {
function handleDragOver({ over }) {
setOverId(over?.id);
if (over) {
if (over.id.startsWith(UNGROUP_ID_PREFIX)) {
setDragCursor("alias");
} else if (over.id.startsWith(ADD_TO_MAP_ID_PREFIX)) {
setDragCursor("copy");
} else {
setDragCursor("pointer");
}
}
}
function handleDragEnd({ active, over }) {
setDragId();
setOverId();
setDragCursor("pointer");
if (!active || !over || active.id === over.id) {
return;
}
@ -94,6 +106,8 @@ export function TileDragProvider({ children }) {
}
onGroupsChange(newGroups);
onGroupSelect();
} else if (over.id.startsWith(ADD_TO_MAP_ID_PREFIX)) {
onDragAdd && onDragAdd(selectedGroupIds, over.rect);
} else {
// Hanlde tile move
const overGroupIndex = groups.findIndex((group) => group.id === over.id);
@ -105,6 +119,7 @@ export function TileDragProvider({ children }) {
}
function customCollisionDetection(rects, rect) {
// Handle group rects
if (groupOpen) {
const ungroupRects = rects.filter(([id]) =>
id.startsWith(UNGROUP_ID_PREFIX)
@ -115,12 +130,21 @@ export function TileDragProvider({ children }) {
}
}
// Handle add to map rects
const addRects = rects.filter(([id]) =>
id.startsWith(ADD_TO_MAP_ID_PREFIX)
);
const intersectingAddRect = rectIntersection(addRects, rect);
if (intersectingAddRect) {
return intersectingAddRect;
}
const otherRects = rects.filter(([id]) => id !== UNGROUP_ID_PREFIX);
return closestCenter(otherRects, rect);
}
const value = { dragId, overId };
const value = { dragId, overId, dragCursor };
return (
<DndContext

View File

@ -111,3 +111,37 @@ export async function createTokenFromFile(file, userId) {
image.src = url;
});
}
export function clientPositionToMapPosition(
mapStage,
clientPosition,
checkMapBounds = true
) {
const mapImage = mapStage.findOne("#mapImage");
const map = document.querySelector(".map");
const mapRect = map.getBoundingClientRect();
// Check map bounds
if (
checkMapBounds &&
(clientPosition.x < mapRect.left || clientPosition.x > mapRect.right)
) {
return;
}
// Convert relative to map rect
const mapPosition = {
x: clientPosition.x - mapRect.left,
y: clientPosition.y - mapRect.top,
};
// Convert relative to map image
const transform = mapImage.getAbsoluteTransform().copy().invert();
const relativePosition = transform.point(mapPosition);
const normalizedPosition = {
x: relativePosition.x / mapImage.width(),
y: relativePosition.y / mapImage.height(),
};
return normalizedPosition;
}

View File

@ -1,6 +1,7 @@
import React, { useRef, useState } from "react";
import { Button, Flex, Label, Box } from "theme-ui";
import { useToasts } from "react-toast-notifications";
import ReactResizeDetector from "react-resize-detector";
import EditMapModal from "./EditMapModal";
import ConfirmModal from "./ConfirmModal";
@ -13,6 +14,7 @@ import MapTiles from "../components/map/MapTiles";
import TilesOverlay from "../components/tile/TilesOverlay";
import TilesContainer from "../components/tile/TilesContainer";
import TilesAddDroppable from "../components/tile/TilesAddDroppable";
import { groupsFromIds, itemsFromGroups, findGroup } from "../helpers/group";
import { createMapFromFile } from "../helpers/map";
@ -230,6 +232,11 @@ function SelectMapModal({
const layout = useResponsiveLayout();
const [modalSize, setModalSize] = useState({ width: 0, height: 0 });
function handleModalResize(width, height) {
setModalSize({ width, height });
}
return (
<Modal
isOpen={isOpen}
@ -245,51 +252,59 @@ function SelectMapModal({
multiple
ref={fileInputRef}
/>
<Flex
sx={{
flexDirection: "column",
}}
<ReactResizeDetector
handleWidth
handleHeight
onResize={handleModalResize}
>
<Label pt={2} pb={1}>
Select or import a map
</Label>
<Box sx={{ position: "relative" }}>
<GroupProvider
groups={mapGroups}
onGroupsChange={updateMapGroups}
onGroupsSelect={setSelectedGroupIds}
disabled={!isOpen}
>
<TileDragProvider>
<TilesContainer>
<MapTiles
maps={maps}
onMapEdit={() => setIsEditModalOpen(true)}
onMapSelect={handleMapSelect}
/>
</TilesContainer>
</TileDragProvider>
<TileDragProvider>
<TilesOverlay>
<MapTiles
maps={maps}
onMapEdit={() => setIsEditModalOpen(true)}
onMapSelect={handleMapSelect}
subgroup
/>
</TilesOverlay>
</TileDragProvider>
</GroupProvider>
</Box>
<Button
variant="primary"
disabled={isLoading || selectedGroupIds.length > 1}
onClick={handleSelectClick}
mt={2}
<Flex
sx={{
flexDirection: "column",
}}
>
Select
</Button>
</Flex>
<Label pt={2} pb={1}>
Select or import a map
</Label>
<Box sx={{ position: "relative" }}>
<GroupProvider
groups={mapGroups}
onGroupsChange={updateMapGroups}
onGroupsSelect={setSelectedGroupIds}
disabled={!isOpen}
>
<TileDragProvider onDragAdd={handleSelectClick}>
<TilesAddDroppable containerSize={modalSize} />
<TilesContainer>
<MapTiles
maps={maps}
onMapEdit={() => setIsEditModalOpen(true)}
onMapSelect={handleMapSelect}
/>
</TilesContainer>
</TileDragProvider>
<TileDragProvider onDragAdd={handleSelectClick}>
<TilesAddDroppable containerSize={modalSize} />
<TilesOverlay>
<MapTiles
maps={maps}
onMapEdit={() => setIsEditModalOpen(true)}
onMapSelect={handleMapSelect}
subgroup
/>
</TilesOverlay>
</TileDragProvider>
</GroupProvider>
</Box>
<Button
variant="primary"
disabled={isLoading || selectedGroupIds.length > 1}
onClick={handleSelectClick}
mt={2}
>
Select
</Button>
</Flex>
</ReactResizeDetector>
</ImageDrop>
{(isLoading || mapsLoading) && <LoadingOverlay bg="overlay" />}
<EditMapModal

View File

@ -1,6 +1,7 @@
import React, { useRef, useState } from "react";
import { Flex, Label, Button, Box } from "theme-ui";
import { useToasts } from "react-toast-notifications";
import ReactResizeDetector from "react-resize-detector";
import EditTokenModal from "./EditTokenModal";
import ConfirmModal from "./ConfirmModal";
@ -13,9 +14,19 @@ import TokenTiles from "../components/token/TokenTiles";
import TilesOverlay from "../components/tile/TilesOverlay";
import TilesContainer from "../components/tile/TilesContainer";
import TilesAddDroppable from "../components/tile/TilesAddDroppable";
import { groupsFromIds, itemsFromGroups } from "../helpers/group";
import { createTokenFromFile } from "../helpers/token";
import {
groupsFromIds,
itemsFromGroups,
getGroupItems,
} from "../helpers/group";
import {
createTokenFromFile,
createTokenState,
clientPositionToMapPosition,
} from "../helpers/token";
import Vector2 from "../helpers/Vector2";
import useResponsiveLayout from "../hooks/useResponsiveLayout";
@ -25,10 +36,11 @@ import { useKeyboard } from "../contexts/KeyboardContext";
import { useAssets } from "../contexts/AssetsContext";
import { GroupProvider } from "../contexts/GroupContext";
import { TileDragProvider } from "../contexts/TileDragContext";
import { useMapStage } from "../contexts/MapStageContext";
import shortcuts from "../shortcuts";
function SelectTokensModal({ isOpen, onRequestClose }) {
function SelectTokensModal({ isOpen, onRequestClose, onMapTokenStateCreate }) {
const { addToast } = useToasts();
const { userId } = useAuth();
@ -41,6 +53,7 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
tokenGroups,
updateTokenGroups,
updateToken,
tokensById,
} = useTokenData();
const { addAssets } = useAssets();
@ -146,6 +159,49 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
// setIsLoading(false);
// }
const mapStageRef = useMapStage();
function handleTokensAddToMap(groupIds, rect) {
let clientPosition = new Vector2(
rect.width / 2 + rect.offsetLeft,
rect.height / 2 + rect.offsetTop
);
const mapStage = mapStageRef.current;
if (!mapStage) {
return;
}
let position = clientPositionToMapPosition(mapStage, clientPosition, false);
if (!position) {
return;
}
for (let id of groupIds) {
if (id in tokensById) {
onMapTokenStateCreate(
createTokenState(tokensById[id], position, userId)
);
position = Vector2.add(position, 0.01);
} else {
// Check if a group is selected
const group = tokenGroups.find(
(group) => group.id === id && group.type === "group"
);
if (group) {
// Add all tokens of group
const items = getGroupItems(group);
for (let item of items) {
if (item.id in tokensById) {
onMapTokenStateCreate(
createTokenState(tokensById[item.id], position, userId)
);
position = Vector2.add(position, 0.01);
}
}
}
}
}
}
/**
* Shortcuts
*/
@ -171,6 +227,11 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
const layout = useResponsiveLayout();
const [modalSize, setModalSize] = useState({ width: 0, height: 0 });
function handleModalResize(width, height) {
setModalSize({ width, height });
}
return (
<Modal
isOpen={isOpen}
@ -186,50 +247,58 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
ref={fileInputRef}
multiple
/>
<Flex
sx={{
flexDirection: "column",
}}
<ReactResizeDetector
handleWidth
handleHeight
onResize={handleModalResize}
>
<Label pt={2} pb={1}>
Edit or import a token
</Label>
<Box sx={{ position: "relative" }}>
<GroupProvider
groups={tokenGroups}
onGroupsChange={updateTokenGroups}
onGroupsSelect={setSelectedGroupIds}
disabled={!isOpen}
>
<TileDragProvider>
<TilesContainer>
<TokenTiles
tokens={tokens}
onTokenEdit={() => setIsEditModalOpen(true)}
/>
</TilesContainer>
</TileDragProvider>
<TileDragProvider>
<TilesOverlay>
<TokenTiles
tokens={tokens}
onTokenEdit={() => setIsEditModalOpen(true)}
subgroup
/>
</TilesOverlay>
</TileDragProvider>
</GroupProvider>
</Box>
<Button
variant="primary"
disabled={isLoading}
onClick={onRequestClose}
mt={2}
<Flex
sx={{
flexDirection: "column",
}}
>
Done
</Button>
</Flex>
<Label pt={2} pb={1}>
Edit or import a token
</Label>
<Box sx={{ position: "relative" }}>
<GroupProvider
groups={tokenGroups}
onGroupsChange={updateTokenGroups}
onGroupsSelect={setSelectedGroupIds}
disabled={!isOpen}
>
<TileDragProvider onDragAdd={handleTokensAddToMap}>
<TilesAddDroppable containerSize={modalSize} />
<TilesContainer>
<TokenTiles
tokens={tokens}
onTokenEdit={() => setIsEditModalOpen(true)}
/>
</TilesContainer>
</TileDragProvider>
<TileDragProvider onDragAdd={handleTokensAddToMap}>
<TilesAddDroppable containerSize={modalSize} />
<TilesOverlay>
<TokenTiles
tokens={tokens}
onTokenEdit={() => setIsEditModalOpen(true)}
subgroup
/>
</TilesOverlay>
</TileDragProvider>
</GroupProvider>
</Box>
<Button
variant="primary"
disabled={isLoading}
onClick={onRequestClose}
mt={2}
>
Done
</Button>
</Flex>
</ReactResizeDetector>
</ImageDrop>
{(isLoading || tokensLoading) && <LoadingOverlay bg="overlay" />}
<EditTokenModal