diff --git a/package.json b/package.json index e9af296..602eebd 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/dice/DiceTiles.js b/src/components/dice/DiceTiles.js index 7bb5a54..dede3cb 100644 --- a/src/components/dice/DiceTiles.js +++ b/src/components/dice/DiceTiles.js @@ -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) => ( onSelect(group.id)} onDoubleClick={onDoubleClick} > - - {maps.slice(0, 16).map((map) => ( + + {maps.slice(0, 9).map((map) => ( ); } diff --git a/src/components/tile/SortableTile.js b/src/components/tile/SortableTile.js index 58ff0b8..87f8363 100644 --- a/src/components/tile/SortableTile.js +++ b/src/components/tile/SortableTile.js @@ -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 diff --git a/src/components/tile/SortableTiles.js b/src/components/tile/SortableTiles.js index 6ee2f52..c60496d 100644 --- a/src/components/tile/SortableTiles.js +++ b/src/components/tile/SortableTiles.js @@ -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) => ( + +
+ + {renderTile(groups.find((group) => group.id === groupId))} + +
+
+ )); + } + + 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 ( + + ); + }); + } + return ( - {groups.map((group) => ( - - ))} - {createPortal( - - {dragId && ( - - {renderTile(groups.find((group) => group.id === dragId))} - - )} - , - document.body - )} + {renderTiles()} + {createPortal(dragId && renderDragOverlays(), document.body)} ); diff --git a/src/components/tile/TilesContainer.js b/src/components/tile/TilesContainer.js index 16714bc..d5728a2 100644 --- a/src/components/tile/TilesContainer.js +++ b/src/components/tile/TilesContainer.js @@ -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}
diff --git a/src/components/tile/TilesOverlay.js b/src/components/tile/TilesOverlay.js index 0520cb9..24e35e3 100644 --- a/src/components/tile/TilesOverlay.js +++ b/src/components/tile/TilesOverlay.js @@ -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} diff --git a/src/components/token/TokenTileGroup.js b/src/components/token/TokenTileGroup.js index aa6133e..23776e5 100644 --- a/src/components/token/TokenTileGroup.js +++ b/src/components/token/TokenTileGroup.js @@ -23,8 +23,12 @@ function TokenTileGroup({ onDoubleClick={onDoubleClick} columns="1fr 1fr" > - - {tokens.slice(0, 16).map((token) => ( + + {tokens.slice(0, 9).map((token) => ( ); } diff --git a/src/contexts/MapDataContext.js b/src/contexts/MapDataContext.js index e56ce7e..4bdb1bd 100644 --- a/src/contexts/MapDataContext.js +++ b/src/contexts/MapDataContext.js @@ -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); } } } diff --git a/src/contexts/TokenDataContext.js b/src/contexts/TokenDataContext.js index 0b79d47..f012e0b 100644 --- a/src/contexts/TokenDataContext.js +++ b/src/contexts/TokenDataContext.js @@ -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); } } } diff --git a/src/helpers/dexie.js b/src/helpers/dexie.js new file mode 100644 index 0000000..10c41e5 --- /dev/null +++ b/src/helpers/dexie.js @@ -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; +} diff --git a/src/helpers/group.js b/src/helpers/group.js index b62cb66..12345f8 100644 --- a/src/helpers/group.js +++ b/src/helpers/group.js @@ -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; } diff --git a/src/hooks/useResponsiveLayout.js b/src/hooks/useResponsiveLayout.js index 3eb0e1c..0f393c9 100644 --- a/src/hooks/useResponsiveLayout.js +++ b/src/hooks/useResponsiveLayout.js @@ -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, }; } diff --git a/src/modals/SelectMapModal.js b/src/modals/SelectMapModal.js index 00e3d92..d2ef530 100644 --- a/src/modals/SelectMapModal.js +++ b/src/modals/SelectMapModal.js @@ -259,19 +259,21 @@ function SelectMapModal({ onGroupsSelect={setSelectedGroupIds} disabled={!isOpen} > - + setIsEditModalOpen(true)} onMapSelect={handleMapSelect} + columns={layout.tileGridColumns} /> - + setIsEditModalOpen(true)} onMapSelect={handleMapSelect} subgroup + columns={layout.groupGridColumns} /> diff --git a/src/modals/SelectTokensModal.js b/src/modals/SelectTokensModal.js index a515214..4e7dc40 100644 --- a/src/modals/SelectTokensModal.js +++ b/src/modals/SelectTokensModal.js @@ -200,17 +200,19 @@ function SelectTokensModal({ isOpen, onRequestClose }) { onGroupsSelect={setSelectedGroupIds} disabled={!isOpen} > - + setIsEditModalOpen(true)} + columns={layout.tileGridColumns} /> - + setIsEditModalOpen(true)} subgroup + columns={layout.groupGridColumns} /> diff --git a/yarn.lock b/yarn.lock index 2959dd7..1daacf4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"