Add multi selection to tile grouping

This commit is contained in:
Mitchell McCaffrey 2021-05-25 15:47:52 +10:00
parent 68a084ebc9
commit 71b8aeec6c
18 changed files with 201 additions and 80 deletions

View File

@ -31,6 +31,7 @@
"lodash.clonedeep": "^4.5.0", "lodash.clonedeep": "^4.5.0",
"lodash.get": "^4.4.2", "lodash.get": "^4.4.2",
"lodash.set": "^4.3.2", "lodash.set": "^4.3.2",
"lodash.unset": "^4.5.2",
"normalize-wheel": "^1.0.1", "normalize-wheel": "^1.0.1",
"pepjs": "^0.5.3", "pepjs": "^0.5.3",
"polygon-clipping": "^0.15.2", "polygon-clipping": "^0.15.2",

View File

@ -22,7 +22,7 @@ function DiceTiles({ dice, onDiceSelect, selectedDice, onDone }) {
minHeight: layout.screenSize === "large" ? "600px" : "400px", minHeight: layout.screenSize === "large" ? "600px" : "400px",
}} }}
gap={2} gap={2}
columns={layout.gridTemplate} columns={`repeat${layout.tileGridColumns}, 1fr`}
> >
{dice.map((dice) => ( {dice.map((dice) => (
<DiceTile <DiceTile

View File

@ -16,8 +16,12 @@ function MapTileGroup({ group, maps, isSelected, onSelect, onDoubleClick }) {
onSelect={() => onSelect(group.id)} onSelect={() => onSelect(group.id)}
onDoubleClick={onDoubleClick} onDoubleClick={onDoubleClick}
> >
<Grid columns={layout.groupGridTemplate} p={2} sx={{ gridGap: 2 }}> <Grid
{maps.slice(0, 16).map((map) => ( columns={`repeat(${layout.groupGridColumns}, 1fr)`}
p={2}
sx={{ gridGap: 2 }}
>
{maps.slice(0, 9).map((map) => (
<MapTileImage <MapTileImage
sx={{ borderRadius: "8px" }} sx={{ borderRadius: "8px" }}
map={map} map={map}

View File

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

View File

@ -4,25 +4,23 @@ 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, type }) { function SortableTile({ id, disableGrouping, hidden, children, isDragging }) {
const { const {
attributes, attributes,
listeners, listeners,
isDragging,
setDroppableNodeRef, setDroppableNodeRef,
setDraggableNodeRef, setDraggableNodeRef,
over, over,
active, active,
} = useSortable({ id, data: { type } }); } = useSortable({ id });
const { setNodeRef: setGroupNodeRef } = useDroppable({ const { setNodeRef: setGroupNodeRef } = useDroppable({
id: `__group__${id}`, id: `__group__${id}`,
disabled: disableGrouping || active?.data?.current?.type === "group", disabled: disableGrouping,
}); });
const dragStyle = { const dragStyle = {
cursor: "pointer", cursor: "pointer",
opacity: isDragging ? 0.25 : undefined, opacity: isDragging ? 0.25 : undefined,
zIndex: isDragging ? 100 : undefined,
}; };
// Sort div left aligned // Sort div left aligned

View File

@ -12,17 +12,20 @@ import {
import { SortableContext, arrayMove } from "@dnd-kit/sortable"; import { SortableContext, arrayMove } from "@dnd-kit/sortable";
import { animated, useSpring, config } from "react-spring"; import { animated, useSpring, config } from "react-spring";
import { combineGroups, moveGroups } from "../../helpers/group"; import { moveGroups } from "../../helpers/group";
import { keyBy } from "../../helpers/shared";
import SortableTile from "./SortableTile"; import SortableTile from "./SortableTile";
function SortableTiles({ function SortableTiles({
groups, groups,
selectedGroupIds,
onGroupChange, onGroupChange,
renderTile, renderTile,
onTileSelect, onTileSelect,
disableGrouping, disableGrouping,
openGroupId, openGroupId,
columns,
}) { }) {
const mouseSensor = useSensor(MouseSensor, { const mouseSensor = useSensor(MouseSensor, {
activationConstraint: { delay: 250, tolerance: 5 }, activationConstraint: { delay: 250, tolerance: 5 },
@ -39,8 +42,10 @@ function SortableTiles({
function handleDragStart({ active, over }) { function handleDragStart({ active, over }) {
setDragId(active.id); setDragId(active.id);
setOverId(over?.id); setOverId(over?.id);
if (!selectedGroupIds.includes(active.id)) {
onTileSelect(active.id); onTileSelect(active.id);
} }
}
function handleDragOver({ over }) { function handleDragOver({ over }) {
setOverId(over?.id); setOverId(over?.id);
@ -58,16 +63,24 @@ function SortableTiles({
if (overId === active.id) { if (overId === active.id) {
return; return;
} }
const activeGroupIndex = groups.findIndex(
(group) => group.id === active.id let newGroups = groups;
);
const overGroupIndex = groups.findIndex((group) => group.id === overId); const overGroupIndex = groups.findIndex((group) => group.id === overId);
onGroupChange(moveGroups(groups, overGroupIndex, activeGroupIndex)); const selectedGroupIndices = selectedGroupIds.map((groupId) =>
groups.findIndex((group) => group.id === groupId)
);
onGroupChange(
moveGroups(newGroups, overGroupIndex, selectedGroupIndices)
);
onTileSelect(); onTileSelect();
} else if (active.id !== over.id) { } else if (active.id !== over.id) {
const oldIndex = groups.findIndex((group) => group.id === active.id); let newGroups = groups;
const newIndex = groups.findIndex((group) => group.id === over.id); for (let groupId of selectedGroupIds) {
onGroupChange(arrayMove(groups, oldIndex, newIndex)); const oldIndex = newGroups.findIndex((group) => group.id === groupId);
const newIndex = newGroups.findIndex((group) => group.id === over.id);
newGroups = arrayMove(newGroups, oldIndex, newIndex);
}
onGroupChange(newGroups);
} }
} }
@ -79,19 +92,88 @@ function SortableTiles({
const overGroupId = const overGroupId =
overId && overId.startsWith("__group__") && overId.slice(9); overId && overId.startsWith("__group__") && overId.slice(9);
function renderSortableGroup(group) { function renderSortableGroup(group, selectedGroups) {
if (overGroupId === group.id && dragId && group.id !== dragId) { if (overGroupId === group.id && dragId && group.id !== dragId) {
// If dragging over a group render a preview of that group // If dragging over a group render a preview of that group
return renderTile( const previewGroup = moveGroups(
combineGroups( [group, ...selectedGroups],
group, 0,
groups.find((group) => group.id === dragId) selectedGroups.map((_, i) => i + 1)
) )[0];
); return renderTile(previewGroup);
} }
return renderTile(group); return renderTile(group);
} }
function renderDragOverlays() {
let selectedIndices = selectedGroupIds.map((groupId) =>
groups.findIndex((group) => group.id === groupId)
);
let activeIndex = groups.findIndex((group) => group.id === dragId);
const coords = selectedIndices.map((index) => ({
x: index % columns,
y: Math.floor(index / columns),
}));
const activeCoord = {
x: activeIndex % columns,
y: Math.floor(activeIndex / columns),
};
const relativeCoords = coords.map(({ x, y }) => ({
x: x - activeCoord.x,
y: y - activeCoord.y,
}));
return selectedGroupIds.map((groupId, index) => (
<DragOverlay dropAnimation={null} key={groupId}>
<div
style={{
transform: `translate(${relativeCoords[index].x * 100}%, ${
relativeCoords[index].y * 100
}%)`,
}}
>
<animated.div style={dragBounce}>
{renderTile(groups.find((group) => group.id === groupId))}
</animated.div>
</div>
</DragOverlay>
));
}
function renderTiles() {
const groupsByIds = keyBy(groups, "id");
const selectedGroupIdsSet = new Set(selectedGroupIds);
let selectedGroups = [];
let hasSelectedContainerGroup = false;
for (let groupId of selectedGroupIds) {
const group = groupsByIds[groupId];
if (group) {
selectedGroups.push(group);
if (group.type === "group") {
hasSelectedContainerGroup = true;
}
}
}
return groups.map((group) => {
const isDragging = dragId && selectedGroupIdsSet.has(group.id);
const disableTileGrouping =
disableGrouping || isDragging || hasSelectedContainerGroup;
return (
<SortableTile
id={group.id}
key={group.id}
disableGrouping={disableTileGrouping}
hidden={group.id === openGroupId}
isDragging={isDragging}
>
{renderSortableGroup(group, selectedGroups)}
</SortableTile>
);
});
}
return ( return (
<DndContext <DndContext
onDragStart={handleDragStart} onDragStart={handleDragStart}
@ -101,27 +183,8 @@ function SortableTiles({
collisionDetection={closestCenter} collisionDetection={closestCenter}
> >
<SortableContext items={groups}> <SortableContext items={groups}>
{groups.map((group) => ( {renderTiles()}
<SortableTile {createPortal(dragId && renderDragOverlays(), document.body)}
id={group.id}
key={group.id}
disableGrouping={disableGrouping}
hidden={group.id === openGroupId}
type={group.type}
>
{renderSortableGroup(group)}
</SortableTile>
))}
{createPortal(
<DragOverlay dropAnimation={null}>
{dragId && (
<animated.div style={dragBounce}>
{renderTile(groups.find((group) => group.id === dragId))}
</animated.div>
)}
</DragOverlay>,
document.body
)}
</SortableContext> </SortableContext>
</DndContext> </DndContext>
); );

View File

@ -6,7 +6,7 @@ import { useGroup } from "../../contexts/GroupContext";
import useResponsiveLayout from "../../hooks/useResponsiveLayout"; import useResponsiveLayout from "../../hooks/useResponsiveLayout";
function TilesContainer({ children }) { function TilesContainer({ columns, children }) {
const { onGroupSelect } = useGroup(); const { onGroupSelect } = useGroup();
const layout = useResponsiveLayout(); const layout = useResponsiveLayout();
@ -31,7 +31,7 @@ function TilesContainer({ children }) {
overflow: "hidden", overflow: "hidden",
}} }}
gap={2} gap={2}
columns={layout.gridTemplate} columns={`repeat(${columns}, 1fr)`}
> >
{children} {children}
</Grid> </Grid>

View File

@ -6,9 +6,7 @@ import SimpleBar from "simplebar-react";
import { useGroup } from "../../contexts/GroupContext"; import { useGroup } from "../../contexts/GroupContext";
import useResponsiveLayout from "../../hooks/useResponsiveLayout"; function TilesOverlay({ columns, children }) {
function TilesOverlay({ children }) {
const { openGroupId, onGroupClose, onGroupSelect } = useGroup(); const { openGroupId, onGroupClose, onGroupSelect } = useGroup();
const openAnimation = useSpring({ const openAnimation = useSpring({
@ -23,8 +21,6 @@ function TilesOverlay({ children }) {
setContinerSize(size); setContinerSize(size);
} }
const layout = useResponsiveLayout();
return ( return (
<> <>
{openGroupId && ( {openGroupId && (
@ -92,7 +88,7 @@ function TilesOverlay({ children }) {
overflow: "hidden", overflow: "hidden",
}} }}
gap={2} gap={2}
columns={layout.groupGridTemplate} columns={`repeat(${columns}, 1fr)`}
px={3} px={3}
> >
{children} {children}

View File

@ -23,8 +23,12 @@ function TokenTileGroup({
onDoubleClick={onDoubleClick} onDoubleClick={onDoubleClick}
columns="1fr 1fr" columns="1fr 1fr"
> >
<Grid columns={layout.groupGridTemplate} p={2} sx={{ gridGap: 2 }}> <Grid
{tokens.slice(0, 16).map((token) => ( columns={`repeat(${layout.groupGridColumns}, 1fr)`}
p={2}
sx={{ gridGap: 2 }}
>
{tokens.slice(0, 9).map((token) => (
<TokenTileImage <TokenTileImage
sx={{ borderRadius: "8px" }} sx={{ borderRadius: "8px" }}
token={token} token={token}

View File

@ -9,7 +9,7 @@ import { getGroupItems } from "../../helpers/group";
import { useGroup } from "../../contexts/GroupContext"; import { useGroup } from "../../contexts/GroupContext";
function TokenTiles({ tokens, onTokenEdit, subgroup }) { function TokenTiles({ tokens, onTokenEdit, subgroup, columns }) {
const { const {
groups, groups,
selectedGroupIds, selectedGroupIds,
@ -65,11 +65,13 @@ function TokenTiles({ tokens, onTokenEdit, subgroup }) {
return ( return (
<SortableTiles <SortableTiles
groups={subgroup ? openGroupItems : groups} groups={subgroup ? openGroupItems : groups}
selectedGroupIds={selectedGroupIds}
onGroupChange={onGroupsChange} onGroupChange={onGroupsChange}
renderTile={renderTile} renderTile={renderTile}
onTileSelect={onGroupSelect} onTileSelect={onGroupSelect}
disableGrouping={subgroup} disableGrouping={subgroup}
openGroupId={openGroupId} openGroupId={openGroupId}
columns={columns}
/> />
); );
} }

View File

@ -4,6 +4,8 @@ import { decode } from "@msgpack/msgpack";
import { useAuth } from "./AuthContext"; import { useAuth } from "./AuthContext";
import { useDatabase } from "./DatabaseContext"; import { useDatabase } from "./DatabaseContext";
import { applyObservableChange } from "../helpers/dexie";
const MapDataContext = React.createContext(); const MapDataContext = React.createContext();
const defaultMapState = { const defaultMapState = {
@ -207,7 +209,9 @@ export function MapDataProvider({ children }) {
} }
if (change.table === "groups") { if (change.table === "groups") {
if (change.type === 2 && change.key === "maps") { if (change.type === 2 && change.key === "maps") {
setMapGroups(change.obj.items); const group = applyObservableChange(change);
const groups = group.items.filter((item) => item !== null);
setMapGroups(groups);
} }
} }
} }

View File

@ -4,6 +4,8 @@ import { decode } from "@msgpack/msgpack";
import { useAuth } from "./AuthContext"; import { useAuth } from "./AuthContext";
import { useDatabase } from "./DatabaseContext"; import { useDatabase } from "./DatabaseContext";
import { applyObservableChange } from "../helpers/dexie";
const TokenDataContext = React.createContext(); const TokenDataContext = React.createContext();
export function TokenDataProvider({ children }) { export function TokenDataProvider({ children }) {
@ -139,7 +141,9 @@ export function TokenDataProvider({ children }) {
} }
if (change.table === "groups") { if (change.table === "groups") {
if (change.type === 2 && change.key === "tokens") { if (change.type === 2 && change.key === "tokens") {
setTokenGroups(change.obj.items); const group = applyObservableChange(change);
const groups = group.items.filter((item) => item !== null);
setTokenGroups(groups);
} }
} }
} }

19
src/helpers/dexie.js Normal file
View File

@ -0,0 +1,19 @@
import set from "lodash.set";
import unset from "lodash.unset";
import cloneDeep from "lodash.clonedeep";
export function applyObservableChange(change) {
// Custom application of dexie change to fix issue with array indices being wrong
// https://github.com/dfahlander/Dexie.js/issues/1176
// TODO: Fix dexie observable source
let obj = cloneDeep(change.oldObj);
const changes = Object.entries(change.mods).reverse();
for (let [key, value] of changes) {
if (value === null) {
unset(obj, key);
} else {
obj = set(obj, key, value);
}
}
return obj;
}

View File

@ -99,19 +99,33 @@ export function combineGroups(a, b) {
} }
/** /**
* Immutably move group at `bIndex` into `aIndex` * Immutably move group at indices `from` into index `to`
* @param {Group[]} groups * @param {Group[]} groups
* @param {number} aIndex * @param {number} to
* @param {number} bIndex * @param {number[]} from
* @returns {Group[]} * @returns {Group[]}
*/ */
export function moveGroups(groups, aIndex, bIndex) { export function moveGroups(groups, to, from) {
const aGroup = groups[aIndex];
const bGroup = groups[bIndex];
const newGroup = combineGroups(aGroup, bGroup);
const newGroups = cloneDeep(groups); const newGroups = cloneDeep(groups);
newGroups[aIndex] = newGroup;
newGroups.splice(bIndex, 1); const toGroup = newGroups[to];
let fromGroups = [];
for (let i of from) {
fromGroups.push(newGroups[i]);
}
let combined = toGroup;
for (let fromGroup of fromGroups) {
combined = combineGroups(combined, fromGroup);
}
// Replace and remove old groups
newGroups[to] = combined;
for (let fromGroup of fromGroups) {
const i = newGroups.findIndex((group) => group.id === fromGroup.id);
newGroups.splice(i, 1);
}
return newGroups; return newGroups;
} }

View File

@ -21,13 +21,9 @@ function useResponsiveLayout() {
? "medium" ? "medium"
: "large"; : "large";
const gridTemplate = isLargeScreen const tileGridColumns = isLargeScreen ? 4 : isMediumScreen ? 3 : 2;
? "1fr 1fr 1fr 1fr"
: isMediumScreen
? "1fr 1fr 1fr"
: "1fr 1fr";
const groupGridTemplate = isLargeScreen ? "1fr 1fr 1fr" : "1fr 1fr"; const groupGridColumns = isLargeScreen ? 3 : 2;
const tileContainerHeight = isLargeScreen ? "600px" : "400px"; const tileContainerHeight = isLargeScreen ? "600px" : "400px";
@ -35,9 +31,9 @@ function useResponsiveLayout() {
screenSize, screenSize,
modalSize, modalSize,
tileSize, tileSize,
gridTemplate, tileGridColumns,
tileContainerHeight, tileContainerHeight,
groupGridTemplate, groupGridColumns,
}; };
} }

View File

@ -259,19 +259,21 @@ function SelectMapModal({
onGroupsSelect={setSelectedGroupIds} onGroupsSelect={setSelectedGroupIds}
disabled={!isOpen} disabled={!isOpen}
> >
<TilesContainer> <TilesContainer columns={layout.tileGridColumns}>
<MapTiles <MapTiles
maps={maps} maps={maps}
onMapEdit={() => setIsEditModalOpen(true)} onMapEdit={() => setIsEditModalOpen(true)}
onMapSelect={handleMapSelect} onMapSelect={handleMapSelect}
columns={layout.tileGridColumns}
/> />
</TilesContainer> </TilesContainer>
<TilesOverlay> <TilesOverlay columns={layout.groupGridColumns}>
<MapTiles <MapTiles
maps={maps} maps={maps}
onMapEdit={() => setIsEditModalOpen(true)} onMapEdit={() => setIsEditModalOpen(true)}
onMapSelect={handleMapSelect} onMapSelect={handleMapSelect}
subgroup subgroup
columns={layout.groupGridColumns}
/> />
</TilesOverlay> </TilesOverlay>
</GroupProvider> </GroupProvider>

View File

@ -200,17 +200,19 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
onGroupsSelect={setSelectedGroupIds} onGroupsSelect={setSelectedGroupIds}
disabled={!isOpen} disabled={!isOpen}
> >
<TilesContainer> <TilesContainer columns={layout.tileGridColumns}>
<TokenTiles <TokenTiles
tokens={tokens} tokens={tokens}
onTokenEdit={() => setIsEditModalOpen(true)} onTokenEdit={() => setIsEditModalOpen(true)}
columns={layout.tileGridColumns}
/> />
</TilesContainer> </TilesContainer>
<TilesOverlay> <TilesOverlay columns={layout.groupGridColumns}>
<TokenTiles <TokenTiles
tokens={tokens} tokens={tokens}
onTokenEdit={() => setIsEditModalOpen(true)} onTokenEdit={() => setIsEditModalOpen(true)}
subgroup subgroup
columns={layout.groupGridColumns}
/> />
</TilesOverlay> </TilesOverlay>
</GroupProvider> </GroupProvider>

View File

@ -5724,6 +5724,11 @@ entities@^2.0.0:
resolved "https://registry.yarnpkg.com/entities/-/entities-2.1.0.tgz#992d3129cf7df6870b96c57858c249a120f8b8b5" resolved "https://registry.yarnpkg.com/entities/-/entities-2.1.0.tgz#992d3129cf7df6870b96c57858c249a120f8b8b5"
integrity sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w== integrity sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==
err-code@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/err-code/-/err-code-2.0.3.tgz#23c2f3b756ffdfc608d30e27c9a941024807e7f9"
integrity sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==
err-code@^3.0.1: err-code@^3.0.1:
version "3.0.1" version "3.0.1"
resolved "https://registry.yarnpkg.com/err-code/-/err-code-3.0.1.tgz#a444c7b992705f2b120ee320b09972eef331c920" resolved "https://registry.yarnpkg.com/err-code/-/err-code-3.0.1.tgz#a444c7b992705f2b120ee320b09972eef331c920"
@ -8621,6 +8626,11 @@ lodash.uniq@^4.5.0:
resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M= integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=
lodash.unset@^4.5.2:
version "4.5.2"
resolved "https://registry.yarnpkg.com/lodash.unset/-/lodash.unset-4.5.2.tgz#370d1d3e85b72a7e1b0cdf2d272121306f23e4ed"
integrity sha1-Nw0dPoW3Kn4bDN8tJyEhMG8j5O0=
"lodash@>=3.5 <5", lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.5: "lodash@>=3.5 <5", lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.5:
version "4.17.20" version "4.17.20"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52"