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.get": "^4.4.2",
"lodash.set": "^4.3.2",
"lodash.unset": "^4.5.2",
"normalize-wheel": "^1.0.1",
"pepjs": "^0.5.3",
"polygon-clipping": "^0.15.2",

View File

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

View File

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

View File

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

View File

@ -4,25 +4,23 @@ import { useDroppable } from "@dnd-kit/core";
import { useSortable } from "@dnd-kit/sortable";
import { animated, useSpring } from "react-spring";
function SortableTile({ id, disableGrouping, hidden, children, type }) {
function SortableTile({ id, disableGrouping, hidden, children, isDragging }) {
const {
attributes,
listeners,
isDragging,
setDroppableNodeRef,
setDraggableNodeRef,
over,
active,
} = useSortable({ id, data: { type } });
} = useSortable({ id });
const { setNodeRef: setGroupNodeRef } = useDroppable({
id: `__group__${id}`,
disabled: disableGrouping || active?.data?.current?.type === "group",
disabled: disableGrouping,
});
const dragStyle = {
cursor: "pointer",
opacity: isDragging ? 0.25 : undefined,
zIndex: isDragging ? 100 : undefined,
};
// Sort div left aligned

View File

@ -12,17 +12,20 @@ import {
import { SortableContext, arrayMove } from "@dnd-kit/sortable";
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";
function SortableTiles({
groups,
selectedGroupIds,
onGroupChange,
renderTile,
onTileSelect,
disableGrouping,
openGroupId,
columns,
}) {
const mouseSensor = useSensor(MouseSensor, {
activationConstraint: { delay: 250, tolerance: 5 },
@ -39,7 +42,9 @@ function SortableTiles({
function handleDragStart({ active, over }) {
setDragId(active.id);
setOverId(over?.id);
onTileSelect(active.id);
if (!selectedGroupIds.includes(active.id)) {
onTileSelect(active.id);
}
}
function handleDragOver({ over }) {
@ -58,16 +63,24 @@ function SortableTiles({
if (overId === active.id) {
return;
}
const activeGroupIndex = groups.findIndex(
(group) => group.id === active.id
);
let newGroups = groups;
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();
} else if (active.id !== over.id) {
const oldIndex = groups.findIndex((group) => group.id === active.id);
const newIndex = groups.findIndex((group) => group.id === over.id);
onGroupChange(arrayMove(groups, oldIndex, newIndex));
let newGroups = groups;
for (let groupId of selectedGroupIds) {
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 =
overId && overId.startsWith("__group__") && overId.slice(9);
function renderSortableGroup(group) {
function renderSortableGroup(group, selectedGroups) {
if (overGroupId === group.id && dragId && group.id !== dragId) {
// If dragging over a group render a preview of that group
return renderTile(
combineGroups(
group,
groups.find((group) => group.id === dragId)
)
);
const previewGroup = moveGroups(
[group, ...selectedGroups],
0,
selectedGroups.map((_, i) => i + 1)
)[0];
return renderTile(previewGroup);
}
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 (
<DndContext
onDragStart={handleDragStart}
@ -101,27 +183,8 @@ function SortableTiles({
collisionDetection={closestCenter}
>
<SortableContext items={groups}>
{groups.map((group) => (
<SortableTile
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
)}
{renderTiles()}
{createPortal(dragId && renderDragOverlays(), document.body)}
</SortableContext>
</DndContext>
);

View File

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

View File

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

View File

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

View File

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

View File

@ -4,6 +4,8 @@ import { decode } from "@msgpack/msgpack";
import { useAuth } from "./AuthContext";
import { useDatabase } from "./DatabaseContext";
import { applyObservableChange } from "../helpers/dexie";
const MapDataContext = React.createContext();
const defaultMapState = {
@ -207,7 +209,9 @@ export function MapDataProvider({ children }) {
}
if (change.table === "groups") {
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 { useDatabase } from "./DatabaseContext";
import { applyObservableChange } from "../helpers/dexie";
const TokenDataContext = React.createContext();
export function TokenDataProvider({ children }) {
@ -139,7 +141,9 @@ export function TokenDataProvider({ children }) {
}
if (change.table === "groups") {
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 {number} aIndex
* @param {number} bIndex
* @param {number} to
* @param {number[]} from
* @returns {Group[]}
*/
export function moveGroups(groups, aIndex, bIndex) {
const aGroup = groups[aIndex];
const bGroup = groups[bIndex];
const newGroup = combineGroups(aGroup, bGroup);
export function moveGroups(groups, to, from) {
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;
}

View File

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

View File

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

View File

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

View File

@ -5724,6 +5724,11 @@ entities@^2.0.0:
resolved "https://registry.yarnpkg.com/entities/-/entities-2.1.0.tgz#992d3129cf7df6870b96c57858c249a120f8b8b5"
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:
version "3.0.1"
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"
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:
version "4.17.20"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52"