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 { useDroppable } from "@dnd-kit/core";
function Droppable({ id, children, disabled }) {
function Droppable({ id, children, disabled, ...props }) {
const { setNodeRef } = useDroppable({ id, disabled });
return <div ref={setNodeRef}>{children}</div>;
return (
<div ref={setNodeRef} {...props}>
{children}
</div>
);
}
Droppable.defaultProps = {

View File

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

View File

@ -4,7 +4,16 @@ import { useDroppable } from "@dnd-kit/core";
import { useSortable } from "@dnd-kit/sortable";
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 {
attributes,
listeners,
@ -12,9 +21,10 @@ function SortableTile({ id, disableGrouping, hidden, children, isDragging }) {
setDraggableNodeRef,
over,
active,
} = useSortable({ id });
} = useSortable({ id, disabled: disableSorting });
const { setNodeRef: setGroupNodeRef } = useDroppable({
id: `__group__${id}`,
id: `${GROUP_ID_PREFIX}${id}`,
disabled: disableGrouping,
});
@ -44,7 +54,9 @@ function SortableTile({ id, disableGrouping, hidden, children, isDragging }) {
borderWidth: "4px",
borderRadius: "4px",
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 });

View File

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

View File

@ -1,10 +1,14 @@
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 useResponsiveLayout from "../../hooks/useResponsiveLayout";
@ -27,8 +31,55 @@ function TilesOverlay({ children }) {
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 (
<>
{openGroupId && renderUngroupBoxes()}
{openGroupId && (
<Box
sx={{

View File

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

View File

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

View File

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

View File

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