Add support for ungrouping tiles and refactor tile drag functions

This commit is contained in:
Mitchell McCaffrey 2021-05-28 13:13:21 +10:00
parent 456f91c9ae
commit 57aafce938
11 changed files with 334 additions and 153 deletions

View File

@ -1,10 +1,14 @@
import React from "react"; import React from "react";
import { useDroppable } from "@dnd-kit/core"; import { useDroppable } from "@dnd-kit/core";
function Droppable({ id, children, disabled }) { function Droppable({ id, children, disabled, ...props }) {
const { setNodeRef } = useDroppable({ id, disabled }); const { setNodeRef } = useDroppable({ id, disabled });
return <div ref={setNodeRef}>{children}</div>; return (
<div ref={setNodeRef} {...props}>
{children}
</div>
);
} }
Droppable.defaultProps = { Droppable.defaultProps = {

View File

@ -9,15 +9,11 @@ import { getGroupItems } from "../../helpers/group";
import { useGroup } from "../../contexts/GroupContext"; import { useGroup } from "../../contexts/GroupContext";
function MapTiles({ maps, onMapEdit, onMapSelect, subgroup, columns }) { function MapTiles({ maps, onMapEdit, onMapSelect, subgroup }) {
const { const {
groups,
selectedGroupIds, selectedGroupIds,
openGroupId,
openGroupItems,
selectMode, selectMode,
onGroupOpen, onGroupOpen,
onGroupsChange,
onGroupSelect, onGroupSelect,
} = useGroup(); } = useGroup();
@ -57,17 +53,7 @@ function MapTiles({ maps, onMapEdit, onMapSelect, subgroup, columns }) {
} }
} }
return ( return <SortableTiles renderTile={renderTile} subgroup={subgroup} />;
<SortableTiles
groups={subgroup ? openGroupItems : groups}
selectedGroupIds={selectedGroupIds}
onGroupChange={onGroupsChange}
renderTile={renderTile}
onTileSelect={onGroupSelect}
disableGrouping={subgroup}
openGroupId={openGroupId}
/>
);
} }
export default MapTiles; export default MapTiles;

View File

@ -4,7 +4,16 @@ import { useDroppable } from "@dnd-kit/core";
import { useSortable } from "@dnd-kit/sortable"; import { useSortable } from "@dnd-kit/sortable";
import { animated, useSpring } from "react-spring"; import { animated, useSpring } from "react-spring";
function SortableTile({ id, disableGrouping, hidden, children, isDragging }) { import { GROUP_ID_PREFIX } from "../../contexts/TileDragContext";
function SortableTile({
id,
disableGrouping,
disableSorting,
hidden,
children,
isDragging,
}) {
const { const {
attributes, attributes,
listeners, listeners,
@ -12,9 +21,10 @@ function SortableTile({ id, disableGrouping, hidden, children, isDragging }) {
setDraggableNodeRef, setDraggableNodeRef,
over, over,
active, active,
} = useSortable({ id }); } = useSortable({ id, disabled: disableSorting });
const { setNodeRef: setGroupNodeRef } = useDroppable({ const { setNodeRef: setGroupNodeRef } = useDroppable({
id: `__group__${id}`, id: `${GROUP_ID_PREFIX}${id}`,
disabled: disableGrouping, disabled: disableGrouping,
}); });
@ -44,7 +54,9 @@ function SortableTile({ id, disableGrouping, hidden, children, isDragging }) {
borderWidth: "4px", borderWidth: "4px",
borderRadius: "4px", borderRadius: "4px",
borderStyle: borderStyle:
over?.id === `__group__${id}` && active.id !== id ? "solid" : "none", over?.id === `${GROUP_ID_PREFIX}${id}` && active.id !== id
? "solid"
: "none",
}; };
const { opacity } = useSpring({ opacity: hidden ? 0 : 1 }); const { opacity } = useSpring({ opacity: hidden ? 0 : 1 });

View File

@ -1,88 +1,43 @@
import React, { useState } from "react"; import React from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { import { DragOverlay } from "@dnd-kit/core";
DndContext,
DragOverlay,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
closestCenter,
} from "@dnd-kit/core";
import { SortableContext } from "@dnd-kit/sortable"; import { SortableContext } from "@dnd-kit/sortable";
import { animated, useSpring, config } from "react-spring"; import { animated, useSpring, config } from "react-spring";
import { Badge } from "theme-ui"; import { Badge } from "theme-ui";
import { moveGroupsInto, moveGroups } from "../../helpers/group"; import { moveGroupsInto } from "../../helpers/group";
import { keyBy } from "../../helpers/shared"; import { keyBy } from "../../helpers/shared";
import Vector2 from "../../helpers/Vector2"; import Vector2 from "../../helpers/Vector2";
import SortableTile from "./SortableTile"; import SortableTile from "./SortableTile";
function SortableTiles({ import {
groups, useTileDrag,
selectedGroupIds, BASE_SORTABLE_ID,
onGroupChange, GROUP_SORTABLE_ID,
renderTile, GROUP_ID_PREFIX,
onTileSelect, } from "../../contexts/TileDragContext";
disableGrouping, import { useGroup } from "../../contexts/GroupContext";
openGroupId,
}) {
const mouseSensor = useSensor(MouseSensor, {
activationConstraint: { delay: 250, tolerance: 5 },
});
const touchSensor = useSensor(TouchSensor, {
activationConstraint: { delay: 250, tolerance: 5 },
});
const sensors = useSensors(mouseSensor, touchSensor); function SortableTiles({ renderTile, subgroup }) {
const { dragId, overId } = useTileDrag();
const {
groups: allGroups,
selectedGroupIds: allSelectedIds,
openGroupId,
openGroupItems,
} = useGroup();
const [dragId, setDragId] = useState(); const sortableId = subgroup ? GROUP_SORTABLE_ID : BASE_SORTABLE_ID;
const [overId, setOverId] = useState();
function handleDragStart({ active, over }) { const groups = subgroup ? openGroupItems : allGroups;
setDragId(active.id); // Only populate selected groups if needed
setOverId(over?.id); let selectedGroupIds = [];
if (!selectedGroupIds.includes(active.id)) { if ((subgroup && openGroupId) || (!subgroup && !openGroupId)) {
onTileSelect(active.id); selectedGroupIds = allSelectedIds;
}
}
function handleDragOver({ over }) {
setOverId(over?.id);
}
function handleDragEnd({ active, over }) {
setDragId();
setOverId();
if (!active || !over) {
return;
}
let selectedIndices = selectedGroupIds.map((groupId) =>
groups.findIndex((group) => group.id === groupId)
);
// Maintain current group sorting
selectedIndices = selectedIndices.sort((a, b) => a - b);
if (over.id.startsWith("__group__")) {
const overId = over.id.slice(9);
if (overId === active.id) {
return;
}
const overGroupIndex = groups.findIndex((group) => group.id === overId);
onGroupChange(moveGroupsInto(groups, overGroupIndex, selectedIndices));
onTileSelect();
} else {
if (active.id === over.id) {
return;
}
const overGroupIndex = groups.findIndex((group) => group.id === over.id);
onGroupChange(moveGroups(groups, overGroupIndex, selectedIndices));
}
} }
const disableSorting = openGroupId && !subgroup;
const disableGrouping = subgroup || disableSorting;
const dragBounce = useSpring({ const dragBounce = useSpring({
transform: !!dragId ? "scale(0.9)" : "scale(1)", transform: !!dragId ? "scale(0.9)" : "scale(1)",
@ -91,7 +46,7 @@ function SortableTiles({
}); });
const overGroupId = const overGroupId =
overId && overId.startsWith("__group__") && overId.slice(9); overId && overId.startsWith(GROUP_ID_PREFIX) && overId.slice(9);
function renderSortableGroup(group, selectedGroups) { function renderSortableGroup(group, selectedGroups) {
if (overGroupId === group.id && dragId && group.id !== dragId) { if (overGroupId === group.id && dragId && group.id !== dragId) {
@ -179,6 +134,7 @@ function SortableTiles({
id={group.id} id={group.id}
key={group.id} key={group.id}
disableGrouping={disableTileGrouping} disableGrouping={disableTileGrouping}
disableSorting={disableSorting}
hidden={group.id === openGroupId} hidden={group.id === openGroupId}
isDragging={isDragging} isDragging={isDragging}
> >
@ -189,18 +145,10 @@ function SortableTiles({
} }
return ( return (
<DndContext <SortableContext items={groups} id={sortableId}>
onDragStart={handleDragStart} {renderTiles()}
onDragEnd={handleDragEnd} {createPortal(dragId && renderDragOverlays(), document.body)}
onDragOver={handleDragOver} </SortableContext>
sensors={sensors}
collisionDetection={closestCenter}
>
<SortableContext items={groups}>
{renderTiles()}
{createPortal(dragId && renderDragOverlays(), document.body)}
</SortableContext>
</DndContext>
); );
} }

View File

@ -1,10 +1,14 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { createPortal } from "react-dom";
import { Box, Close, Grid, useThemeUI } from "theme-ui"; import { Box, Close, Grid, useThemeUI } from "theme-ui";
import { useSpring, animated, config } from "react-spring"; import { useSpring, animated, config } from "react-spring";
import ReactResizeDetector from "react-resize-detector"; import ReactResizeDetector from "react-resize-detector";
import SimpleBar from "simplebar-react"; import SimpleBar from "simplebar-react";
import { useGroup } from "../../contexts/GroupContext"; import { useGroup } from "../../contexts/GroupContext";
import { UNGROUP_ID_PREFIX } from "../../contexts/TileDragContext";
import Droppable from "../drag/Droppable";
import useResponsiveLayout from "../../hooks/useResponsiveLayout"; import useResponsiveLayout from "../../hooks/useResponsiveLayout";
@ -27,8 +31,55 @@ function TilesOverlay({ children }) {
setContinerSize(size); setContinerSize(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
);
}
return ( return (
<> <>
{openGroupId && renderUngroupBoxes()}
{openGroupId && ( {openGroupId && (
<Box <Box
sx={{ sx={{

View File

@ -11,13 +11,9 @@ import { useGroup } from "../../contexts/GroupContext";
function TokenTiles({ tokens, onTokenEdit, subgroup }) { function TokenTiles({ tokens, onTokenEdit, subgroup }) {
const { const {
groups,
selectedGroupIds, selectedGroupIds,
openGroupId,
openGroupItems,
selectMode, selectMode,
onGroupOpen, onGroupOpen,
onGroupsChange,
onGroupSelect, onGroupSelect,
} = useGroup(); } = useGroup();
@ -62,17 +58,7 @@ function TokenTiles({ tokens, onTokenEdit, subgroup }) {
} }
} }
return ( return <SortableTiles renderTile={renderTile} subgroup={subgroup} />;
<SortableTiles
groups={subgroup ? openGroupItems : groups}
selectedGroupIds={selectedGroupIds}
onGroupChange={onGroupsChange}
renderTile={renderTile}
onTileSelect={onGroupSelect}
disableGrouping={subgroup}
openGroupId={openGroupId}
/>
);
} }
export default TokenTiles; export default TokenTiles;

View File

@ -40,10 +40,13 @@ export function GroupProvider({
setOpenGroupId(); setOpenGroupId();
} }
function handleGroupsChange(newGroups) { /**
if (openGroupId) { * @param {string|undefined} groupId The group to apply changes to, leave undefined to replace the full group object
// If a group is open then update that group with the new items */
const groupIndex = groups.findIndex((group) => group.id === openGroupId); function handleGroupsChange(newGroups, groupId) {
if (groupId) {
// If a group is specidifed then update that group with the new items
const groupIndex = groups.findIndex((group) => group.id === groupId);
let updatedGroups = cloneDeep(groups); let updatedGroups = cloneDeep(groups);
const group = updatedGroups[groupIndex]; const group = updatedGroups[groupIndex];
updatedGroups[groupIndex] = { ...group, items: newGroups }; updatedGroups[groupIndex] = { ...group, items: newGroups };

View File

@ -0,0 +1,148 @@
import React, { useState, useContext } from "react";
import {
DndContext,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
closestCenter,
rectIntersection,
} from "@dnd-kit/core";
import { useGroup } from "./GroupContext";
import { moveGroupsInto, moveGroups, ungroup } from "../helpers/group";
const TileDragContext = React.createContext();
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 function TileDragProvider({ children }) {
const {
groups: allGroups,
openGroupId,
openGroupItems,
selectedGroupIds,
onGroupsChange,
onGroupSelect,
onGroupClose,
} = useGroup();
const groupOpen = !!openGroupId;
const groups = groupOpen ? openGroupItems : allGroups;
const mouseSensor = useSensor(MouseSensor, {
activationConstraint: { delay: 250, tolerance: 5 },
});
const touchSensor = useSensor(TouchSensor, {
activationConstraint: { delay: 250, tolerance: 5 },
});
const sensors = useSensors(mouseSensor, touchSensor);
const [dragId, setDragId] = useState();
const [overId, setOverId] = useState();
function handleDragStart({ active, over }) {
setDragId(active.id);
setOverId(over?.id);
if (!selectedGroupIds.includes(active.id)) {
onGroupSelect(active.id);
}
}
function handleDragOver({ over }) {
setOverId(over?.id);
}
function handleDragEnd({ active, over }) {
setDragId();
setOverId();
if (!active || !over || active.id === over.id) {
return;
}
let selectedIndices = selectedGroupIds.map((groupId) =>
groups.findIndex((group) => group.id === groupId)
);
// Maintain current group sorting
selectedIndices = selectedIndices.sort((a, b) => a - b);
if (over.id.startsWith(GROUP_ID_PREFIX)) {
// Handle tile group
const overId = over.id.slice(9);
if (overId === active.id) {
return;
}
const overGroupIndex = groups.findIndex((group) => group.id === overId);
onGroupsChange(
moveGroupsInto(groups, overGroupIndex, selectedIndices),
openGroupId
);
onGroupSelect();
} else if (over.id.startsWith(UNGROUP_ID_PREFIX)) {
// Handle tile ungroup
const newGroups = ungroup(allGroups, openGroupId, selectedIndices);
// Close group if it was removed
if (!newGroups.find((group) => group.id === openGroupId)) {
onGroupClose();
}
onGroupsChange(newGroups);
onGroupSelect();
} else {
// Hanlde tile move
const overGroupIndex = groups.findIndex((group) => group.id === over.id);
onGroupsChange(
moveGroups(groups, overGroupIndex, selectedIndices),
openGroupId
);
}
}
function customCollisionDetection(rects, rect) {
if (groupOpen) {
const ungroupRects = rects.filter(([id]) =>
id.startsWith(UNGROUP_ID_PREFIX)
);
const intersectingGroupRect = rectIntersection(ungroupRects, rect);
if (intersectingGroupRect) {
return intersectingGroupRect;
}
}
const otherRects = rects.filter(([id]) => id !== UNGROUP_ID_PREFIX);
return closestCenter(otherRects, rect);
}
const value = { dragId, overId };
return (
<DndContext
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
sensors={sensors}
collisionDetection={customCollisionDetection}
>
<TileDragContext.Provider value={value}>
{children}
</TileDragContext.Provider>
</DndContext>
);
}
export function useTileDrag() {
const context = useContext(TileDragContext);
if (context === undefined) {
throw new Error("useTileDrag must be used within a TileDragProvider");
}
return context;
}
export default TileDragContext;

View File

@ -156,6 +156,39 @@ export function moveGroups(groups, to, indices) {
return newGroups; return newGroups;
} }
/**
* Move items from a sub group to the start of the base group
* @param {Group[]} groups
* @param {string} fromId The id of the group to move from
* @param {number[]} indices The indices of the items in the group
*/
export function ungroup(groups, fromId, indices) {
const newGroups = cloneDeep(groups);
let fromIndex = newGroups.findIndex((group) => group.id === fromId);
let items = [];
for (let i of indices) {
items.push(newGroups[fromIndex].items[i]);
}
// Remove items from previous group
for (let item of items) {
const i = newGroups[fromIndex].items.findIndex((el) => el.id === item.id);
newGroups[fromIndex].items.splice(i, 1);
}
// If we have no more items in the group delete it
if (newGroups[fromIndex].items.length === 0) {
newGroups.splice(fromIndex, 1);
}
// Add to base group
newGroups.splice(0, 0, ...items);
return newGroups;
}
/** /**
* Recursively find a group within a group array * Recursively find a group within a group array
* @param {Group[]} groups * @param {Group[]} groups

View File

@ -24,6 +24,7 @@ import { useAuth } from "../contexts/AuthContext";
import { useKeyboard } from "../contexts/KeyboardContext"; import { useKeyboard } from "../contexts/KeyboardContext";
import { useAssets } from "../contexts/AssetsContext"; import { useAssets } from "../contexts/AssetsContext";
import { GroupProvider } from "../contexts/GroupContext"; import { GroupProvider } from "../contexts/GroupContext";
import { TileDragProvider } from "../contexts/TileDragContext";
import shortcuts from "../shortcuts"; import shortcuts from "../shortcuts";
@ -259,21 +260,25 @@ function SelectMapModal({
onGroupsSelect={setSelectedGroupIds} onGroupsSelect={setSelectedGroupIds}
disabled={!isOpen} disabled={!isOpen}
> >
<TilesContainer> <TileDragProvider>
<MapTiles <TilesContainer>
maps={maps} <MapTiles
onMapEdit={() => setIsEditModalOpen(true)} maps={maps}
onMapSelect={handleMapSelect} onMapEdit={() => setIsEditModalOpen(true)}
/> onMapSelect={handleMapSelect}
</TilesContainer> />
<TilesOverlay> </TilesContainer>
<MapTiles </TileDragProvider>
maps={maps} <TileDragProvider>
onMapEdit={() => setIsEditModalOpen(true)} <TilesOverlay>
onMapSelect={handleMapSelect} <MapTiles
subgroup maps={maps}
/> onMapEdit={() => setIsEditModalOpen(true)}
</TilesOverlay> onMapSelect={handleMapSelect}
subgroup
/>
</TilesOverlay>
</TileDragProvider>
</GroupProvider> </GroupProvider>
</Box> </Box>
<Button <Button

View File

@ -24,6 +24,7 @@ import { useAuth } from "../contexts/AuthContext";
import { useKeyboard } from "../contexts/KeyboardContext"; import { useKeyboard } from "../contexts/KeyboardContext";
import { useAssets } from "../contexts/AssetsContext"; import { useAssets } from "../contexts/AssetsContext";
import { GroupProvider } from "../contexts/GroupContext"; import { GroupProvider } from "../contexts/GroupContext";
import { TileDragProvider } from "../contexts/TileDragContext";
import shortcuts from "../shortcuts"; import shortcuts from "../shortcuts";
@ -200,19 +201,23 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
onGroupsSelect={setSelectedGroupIds} onGroupsSelect={setSelectedGroupIds}
disabled={!isOpen} disabled={!isOpen}
> >
<TilesContainer> <TileDragProvider>
<TokenTiles <TilesContainer>
tokens={tokens} <TokenTiles
onTokenEdit={() => setIsEditModalOpen(true)} tokens={tokens}
/> onTokenEdit={() => setIsEditModalOpen(true)}
</TilesContainer> />
<TilesOverlay> </TilesContainer>
<TokenTiles </TileDragProvider>
tokens={tokens} <TileDragProvider>
onTokenEdit={() => setIsEditModalOpen(true)} <TilesOverlay>
subgroup <TokenTiles
/> tokens={tokens}
</TilesOverlay> onTokenEdit={() => setIsEditModalOpen(true)}
subgroup
/>
</TilesOverlay>
</TileDragProvider>
</GroupProvider> </GroupProvider>
</Box> </Box>