Refactored group functions into common context and added group overlay

This commit is contained in:
Mitchell McCaffrey 2021-05-24 13:34:21 +10:00
parent 4b225e5c14
commit b776b86186
17 changed files with 465 additions and 700 deletions

View File

@ -1,7 +1,7 @@
import React from "react";
import { Image } from "theme-ui";
import Tile from "../Tile";
import Tile from "../tile/Tile";
function DiceTile({ dice, isSelected, onDiceSelect, onDone }) {
return (

View File

@ -1,6 +1,6 @@
import React from "react";
import Tile from "../Tile";
import Tile from "../tile/Tile";
import MapTileImage from "./MapTileImage";
function MapTile({
@ -8,7 +8,7 @@ function MapTile({
isSelected,
onSelect,
onEdit,
onDone,
onDoubleClick,
canEdit,
badges,
}) {
@ -16,9 +16,9 @@ function MapTile({
<Tile
title={map.name}
isSelected={isSelected}
onSelect={() => onSelect({ id: map.id })}
onSelect={() => onSelect(map.id)}
onEdit={() => onEdit(map.id)}
onDoubleClick={() => canEdit && onDone()}
onDoubleClick={() => canEdit && onDoubleClick()}
canEdit={canEdit}
badges={badges}
editTitle="Edit Map"

View File

@ -1,19 +1,23 @@
import React from "react";
import { Grid } from "theme-ui";
import Tile from "../Tile";
import Tile from "../tile/Tile";
import MapTileImage from "./MapTileImage";
function MapTileGroup({ group, maps, isSelected, onSelect, onOpen, canOpen }) {
import useResponsiveLayout from "../../hooks/useResponsiveLayout";
function MapTileGroup({ group, maps, isSelected, onSelect, onDoubleClick }) {
const layout = useResponsiveLayout();
return (
<Tile
title={group.name}
isSelected={isSelected}
onSelect={() => onSelect(group)}
onDoubleClick={() => canOpen && onOpen()}
onSelect={() => onSelect(group.id)}
onDoubleClick={onDoubleClick}
>
<Grid columns="1fr 1fr" p={2} sx={{ gridGap: 2 }}>
{maps.slice(0, 4).map((map) => (
<Grid columns={layout.gridTemplate} p={2} sx={{ gridGap: 2 }}>
{maps.slice(0, 16).map((map) => (
<MapTileImage sx={{ borderRadius: "8px" }} map={map} key={map.id} />
))}
</Grid>

View File

@ -1,214 +1,68 @@
import React, { useEffect, useState } from "react";
import { Flex, Box, Text, IconButton, Close, Grid } from "theme-ui";
import SimpleBar from "simplebar-react";
import RemoveMapIcon from "../../icons/RemoveMapIcon";
import ResetMapIcon from "../../icons/ResetMapIcon";
import React from "react";
import MapTile from "./MapTile";
import MapTileGroup from "./MapTileGroup";
import Link from "../Link";
import FilterBar from "../FilterBar";
import SortableTiles from "../drag/SortableTiles";
import SortableTiles from "../tile/SortableTiles";
import useResponsiveLayout from "../../hooks/useResponsiveLayout";
import { getGroupItems } from "../../helpers/select";
import {
groupsFromIds,
itemsFromGroups,
getGroupItems,
} from "../../helpers/select";
import { useGroup } from "../../contexts/GroupContext";
function MapTiles({
maps,
mapStates,
groups,
selectedGroupIds,
onTileSelect,
onMapsRemove,
onMapsReset,
onMapAdd,
onMapEdit,
onDone,
selectMode,
onSelectModeChange,
search,
onSearchChange,
onMapsGroup,
databaseDisabled,
}) {
const layout = useResponsiveLayout();
const [hasMapState, setHasMapState] = useState(false);
const [hasSelectedDefaultMap, setHasSelectedDefaultMap] = useState(false);
useEffect(() => {
const selectedGroups = groupsFromIds(selectedGroupIds, groups);
const selectedMaps = itemsFromGroups(selectedGroups, maps);
const selectedMapStates = itemsFromGroups(
selectedGroups,
mapStates,
"mapId"
);
setHasSelectedDefaultMap(
selectedMaps.some((map) => map.type === "default")
);
let _hasMapState = false;
for (let state of selectedMapStates) {
if (
Object.values(state.tokens).length > 0 ||
Object.values(state.drawShapes).length > 0 ||
Object.values(state.fogShapes).length > 0 ||
Object.values(state.notes).length > 0
) {
_hasMapState = true;
break;
}
}
setHasMapState(_hasMapState);
}, [selectedGroupIds, maps, mapStates, groups]);
function MapTiles({ maps, onMapEdit, onMapSelect, subgroup }) {
const {
groups,
selectedGroupIds,
openGroupItems,
selectMode,
onGroupOpen,
onGroupsChange,
onGroupSelect,
} = useGroup();
function renderTile(group) {
if (group.type === "item") {
const map = maps.find((map) => map.id === group.id);
const isSelected = selectedGroupIds.includes(group.id);
const canEdit =
isSelected && selectMode === "single" && selectedGroupIds.length === 1;
return (
<MapTile
key={map.id}
map={map}
isSelected={isSelected}
onSelect={onTileSelect}
onSelect={onGroupSelect}
onEdit={onMapEdit}
onDone={onDone}
canEdit={
isSelected &&
selectMode === "single" &&
selectedGroupIds.length === 1
}
onDoubleClick={() => canEdit && onMapSelect(group.id)}
canEdit={canEdit}
badges={[`${map.grid.size.x}x${map.grid.size.y}`]}
/>
);
} else {
const isSelected = selectedGroupIds.includes(group.id);
const items = getGroupItems(group);
const canOpen =
isSelected && selectMode === "single" && selectedGroupIds.length === 1;
return (
<MapTileGroup
key={group.id}
group={group}
maps={items.map((item) => maps.find((map) => map.id === item.id))}
isSelected={isSelected}
onSelect={onTileSelect}
onSelect={onGroupSelect}
onDoubleClick={() => canOpen && onGroupOpen(group.id)}
/>
);
}
}
const multipleSelected = selectedGroupIds.length > 1;
function renderTiles(tiles) {
return (
<Box sx={{ position: "relative" }}>
<FilterBar
onFocus={() => onTileSelect()}
search={search}
onSearchChange={onSearchChange}
selectMode={selectMode}
onSelectModeChange={onSelectModeChange}
onAdd={onMapAdd}
addTitle="Add Map"
/>
<SimpleBar
style={{
height: layout.screenSize === "large" ? "600px" : "400px",
}}
>
<Grid
p={2}
pb={4}
pt={databaseDisabled ? 4 : 2}
bg="muted"
sx={{
borderRadius: "4px",
minHeight: layout.screenSize === "large" ? "600px" : "400px",
overflow: "hidden",
}}
gap={2}
columns={layout.gridTemplate}
onClick={() => onTileSelect()}
>
{tiles}
</Grid>
</SimpleBar>
{databaseDisabled && (
<Box
sx={{
position: "absolute",
top: "39px",
left: 0,
right: 0,
textAlign: "center",
borderRadius: "2px",
}}
bg="highlight"
p={1}
>
<Text as="p" variant="body2">
Map saving is unavailable. See <Link to="/faq#saving">FAQ</Link>{" "}
for more information.
</Text>
</Box>
)}
{selectedGroupIds.length > 0 && (
<Flex
sx={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
justifyContent: "space-between",
}}
bg="overlay"
>
<Close
title="Clear Selection"
aria-label="Clear Selection"
onClick={() => onTileSelect()}
/>
<Flex>
<IconButton
aria-label={multipleSelected ? "Reset Maps" : "Reset Map"}
title={multipleSelected ? "Reset Maps" : "Reset Map"}
onClick={() => onMapsReset()}
disabled={!hasMapState}
>
<ResetMapIcon />
</IconButton>
<IconButton
aria-label={multipleSelected ? "Remove Maps" : "Remove Map"}
title={multipleSelected ? "Remove Maps" : "Remove Map"}
onClick={() => onMapsRemove()}
disabled={hasSelectedDefaultMap}
>
<RemoveMapIcon />
</IconButton>
</Flex>
</Flex>
)}
</Box>
);
}
return (
<SortableTiles
groups={groups}
onGroupChange={onMapsGroup}
groups={subgroup ? openGroupItems : groups}
onGroupChange={onGroupsChange}
renderTile={renderTile}
renderTiles={renderTiles}
onTileSelect={onTileSelect}
onTileSelect={onGroupSelect}
disableGrouping={subgroup}
/>
);
}

View File

@ -3,7 +3,7 @@ import { Box } from "theme-ui";
import { useDroppable } from "@dnd-kit/core";
import { useSortable } from "@dnd-kit/sortable";
function Sortable({ id, children }) {
function Sortable({ id, disableGrouping, children }) {
const {
attributes,
listeners,
@ -11,9 +11,11 @@ function Sortable({ id, children }) {
setDroppableNodeRef,
setDraggableNodeRef,
over,
active,
} = useSortable({ id });
const { setNodeRef: setGroupNodeRef } = useDroppable({
id: `__group__${id}`,
disabled: disableGrouping,
});
const dragStyle = {
@ -42,7 +44,8 @@ function Sortable({ id, children }) {
height: "100%",
borderWidth: "4px",
borderRadius: "4px",
borderStyle: over?.id === `__group__${id}` ? "solid" : "none",
borderStyle:
over?.id === `__group__${id}` && active.id !== id ? "solid" : "none",
};
return (

View File

@ -11,18 +11,23 @@ import {
} from "@dnd-kit/core";
import { SortableContext, arrayMove } from "@dnd-kit/sortable";
import { animated, useSpring, config } from "react-spring";
import { Grid } from "theme-ui";
import { combineGroups, moveGroups } from "../../helpers/select";
import useResponsiveLayout from "../../hooks/useResponsiveLayout";
import SortableTile from "./SortableTile";
function SortableTiles({
groups,
onGroupChange,
renderTile,
renderTiles,
onTileSelect,
disableGrouping,
}) {
const layout = useResponsiveLayout();
const mouseSensor = useSensor(MouseSensor, {
activationConstraint: { delay: 250, tolerance: 5 },
});
@ -38,6 +43,7 @@ function SortableTiles({
function handleDragStart({ active, over }) {
setDragId(active.id);
setOverId(over?.id);
onTileSelect(active.id);
}
function handleDragMove({ over }) {
@ -77,6 +83,19 @@ function SortableTiles({
const overGroupId =
overId && overId.startsWith("__group__") && overId.slice(9);
function renderSortableGroup(group) {
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)
)
);
}
return renderTile(group);
}
return (
<DndContext
onDragStart={handleDragStart}
@ -86,21 +105,27 @@ function SortableTiles({
collisionDetection={closestCenter}
>
<SortableContext items={groups}>
{renderTiles(
groups.map((group) => (
<SortableTile id={group.id} key={group.id}>
{dragId && overGroupId === group.id && group.id !== dragId
? // If over a group render a preview of that group
renderTile(
combineGroups(
group,
groups.find((group) => group.id === dragId)
)
)
: renderTile(group)}
<Grid
p={3}
pb={4}
sx={{
borderRadius: "4px",
overflow: "hidden",
}}
gap={2}
columns={layout.gridTemplate}
onClick={() => onTileSelect()}
>
{groups.map((group) => (
<SortableTile
id={group.id}
key={group.id}
disableGrouping={disableGrouping}
>
{renderSortableGroup(group)}
</SortableTile>
))
)}
))}
</Grid>
{createPortal(
<DragOverlay dropAnimation={null}>
{dragId && (

View File

@ -1,7 +1,7 @@
import React from "react";
import { Flex, IconButton, Box, Text, Badge } from "theme-ui";
import EditTileIcon from "../icons/EditTileIcon";
import EditTileIcon from "../../icons/EditTileIcon";
function Tile({
title,

View File

@ -0,0 +1,16 @@
import React from "react";
import SimpleBar from "simplebar-react";
import useResponsiveLayout from "../../hooks/useResponsiveLayout";
function TilesContainer({ children }) {
const layout = useResponsiveLayout();
return (
<SimpleBar style={{ height: layout.tileContainerHeight }}>
{children}
</SimpleBar>
);
}
export default TilesContainer;

View File

@ -0,0 +1,49 @@
import React from "react";
import { Box, Close } from "theme-ui";
import { useGroup } from "../../contexts/GroupContext";
function TilesOverlay({ children }) {
const { openGroupId, onGroupClose } = useGroup();
if (!openGroupId) {
return null;
}
return (
<Box
sx={{
position: "absolute",
width: "100%",
height: "100%",
top: 0,
cursor: "pointer",
}}
p={3}
bg="overlay"
onClick={() => onGroupClose()}
>
<Box
sx={{
width: "100%",
height: "100%",
borderRadius: "8px",
border: "1px solid",
borderColor: "border",
cursor: "default",
}}
bg="muted"
onClick={(e) => e.stopPropagation()}
p={3}
>
{children}
</Box>
<Close
onClick={() => onGroupClose()}
sx={{ position: "absolute", top: "16px", right: "16px" }}
/>
</Box>
);
}
export default TilesOverlay;

View File

@ -1,12 +1,12 @@
import React from "react";
import Tile from "../Tile";
import Tile from "../tile/Tile";
import TokenTileImage from "./TokenTileImage";
function TokenTile({
token,
isSelected,
onTokenSelect,
onSelect,
onTokenEdit,
canEdit,
badges,
@ -15,7 +15,7 @@ function TokenTile({
<Tile
title={token.name}
isSelected={isSelected}
onSelect={() => onTokenSelect(token)}
onSelect={() => onSelect(token.id)}
onEdit={() => onTokenEdit(token.id)}
canEdit={canEdit}
badges={badges}

View File

@ -1,29 +1,32 @@
import React from "react";
import { Grid } from "theme-ui";
import Tile from "../Tile";
import Tile from "../tile/Tile";
import TokenTileImage from "./TokenTileImage";
import useResponsiveLayout from "../../hooks/useResponsiveLayout";
function TokenTileGroup({
group,
tokens,
isSelected,
onSelect,
onOpen,
canOpen,
onDoubleClick,
}) {
const layout = useResponsiveLayout();
return (
<Tile
title={group.name}
isSelected={isSelected}
onSelect={() => onSelect(group)}
onDoubleClick={() => canOpen && onOpen()}
onSelect={() => onSelect(group.id)}
onDoubleClick={onDoubleClick}
columns="1fr 1fr"
>
<Grid columns="1fr 1fr" p={2} sx={{ gridGap: 2 }}>
{tokens.slice(0, 4).map((token) => (
<Grid columns={layout.gridTemplate} p={2} sx={{ gridGap: 2 }}>
{tokens.slice(0, 16).map((token) => (
<TokenTileImage
sx={{ padding: 1, borderRadius: "8px" }}
sx={{ borderRadius: "8px" }}
token={token}
key={token.id}
/>

View File

@ -1,80 +1,51 @@
import React, { useState, useEffect } from "react";
import { Flex, Box, Text, IconButton, Close, Grid } from "theme-ui";
import SimpleBar from "simplebar-react";
import RemoveTokenIcon from "../../icons/RemoveTokenIcon";
import TokenHideIcon from "../../icons/TokenHideIcon";
import TokenShowIcon from "../../icons/TokenShowIcon";
import React from "react";
import TokenTile from "./TokenTile";
import TokenTileGroup from "./TokenTileGroup";
import Link from "../Link";
import FilterBar from "../FilterBar";
import SortableTiles from "../drag/SortableTiles";
import SortableTiles from "../tile/SortableTiles";
import useResponsiveLayout from "../../hooks/useResponsiveLayout";
import { getGroupItems } from "../../helpers/select";
import {
groupsFromIds,
itemsFromGroups,
getGroupItems,
} from "../../helpers/select";
import { useGroup } from "../../contexts/GroupContext";
function TokenTiles({
tokens,
groups,
selectedGroupIds,
onTileSelect,
onTokenAdd,
onTokenEdit,
onTokensRemove,
selectMode,
onSelectModeChange,
search,
onSearchChange,
onTokensGroup,
onTokensHide,
databaseDisabled,
}) {
const layout = useResponsiveLayout();
const [hasSelectedDefaultToken, setHasSelectedDefaultToken] = useState(false);
const [allTokensVisible, setAllTokensVisisble] = useState(false);
useEffect(() => {
const selectedGroups = groupsFromIds(selectedGroupIds, groups);
const selectedTokens = itemsFromGroups(selectedGroups, tokens);
setHasSelectedDefaultToken(
selectedTokens.some((token) => token.type === "default")
);
setAllTokensVisisble(selectedTokens.every((token) => !token.hideInSidebar));
}, [selectedGroupIds, tokens, groups]);
function TokenTiles({ tokens, onTokenEdit, subgroup }) {
const {
groups,
selectedGroupIds,
openGroupItems,
selectMode,
onGroupOpen,
onGroupsChange,
onGroupSelect,
} = useGroup();
function renderTile(group) {
if (group.type === "item") {
const token = tokens.find((token) => token.id === group.id);
const isSelected = selectedGroupIds.includes(group.id);
const canEdit =
isSelected &&
token.type !== "default" &&
selectMode === "single" &&
selectedGroupIds.length === 1;
return (
<TokenTile
key={token.id}
token={token}
isSelected={isSelected}
onTokenSelect={onTileSelect}
onSelect={onGroupSelect}
onTokenEdit={onTokenEdit}
canEdit={
isSelected &&
token.type !== "default" &&
selectMode === "single" &&
selectedGroupIds.length === 1
}
canEdit={canEdit}
badges={[`${token.defaultSize}x`]}
/>
);
} else {
const isSelected = selectedGroupIds.includes(group.id);
const items = getGroupItems(group);
const canOpen =
isSelected && selectMode === "single" && selectedGroupIds.length === 1;
return (
<TokenTileGroup
key={group.id}
@ -83,128 +54,20 @@ function TokenTiles({
tokens.find((token) => token.id === item.id)
)}
isSelected={isSelected}
onSelect={onTileSelect}
onSelect={onGroupSelect}
onDoubleClick={() => canOpen && onGroupOpen(group.id)}
/>
);
}
}
const multipleSelected = selectedGroupIds.length > 1;
let hideTitle = "";
if (multipleSelected) {
if (allTokensVisible) {
hideTitle = "Hide Tokens in Sidebar";
} else {
hideTitle = "Show Tokens in Sidebar";
}
} else {
if (allTokensVisible) {
hideTitle = "Hide Token in Sidebar";
} else {
hideTitle = "Show Token in Sidebar";
}
}
function renderTiles(tiles) {
return (
<Box sx={{ position: "relative" }}>
<FilterBar
onFocus={() => onTileSelect()}
search={search}
onSearchChange={onSearchChange}
selectMode={selectMode}
onSelectModeChange={onSelectModeChange}
onAdd={onTokenAdd}
addTitle="Add Token"
/>
<SimpleBar
style={{
height: layout.screenSize === "large" ? "600px" : "400px",
}}
>
<Grid
p={2}
pb={4}
pt={databaseDisabled ? 4 : 2}
bg="muted"
sx={{
borderRadius: "4px",
minHeight: layout.screenSize === "large" ? "600px" : "400px",
}}
gap={2}
columns={layout.gridTemplate}
onClick={() => onTileSelect()}
>
{tiles}
</Grid>
</SimpleBar>
{databaseDisabled && (
<Box
sx={{
position: "absolute",
top: "39px",
left: 0,
right: 0,
textAlign: "center",
borderRadius: "2px",
}}
bg="highlight"
p={1}
>
<Text as="p" variant="body2">
Token saving is unavailable. See <Link to="/faq#saving">FAQ</Link>{" "}
for more information.
</Text>
</Box>
)}
{selectedGroupIds.length > 0 && (
<Flex
sx={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
justifyContent: "space-between",
}}
bg="overlay"
>
<Close
title="Clear Selection"
aria-label="Clear Selection"
onClick={() => onTileSelect()}
/>
<Flex>
<IconButton
aria-label={hideTitle}
title={hideTitle}
disabled={hasSelectedDefaultToken}
onClick={() => onTokensHide(allTokensVisible)}
>
{allTokensVisible ? <TokenShowIcon /> : <TokenHideIcon />}
</IconButton>
<IconButton
aria-label={multipleSelected ? "Remove Tokens" : "Remove Token"}
title={multipleSelected ? "Remove Tokens" : "Remove Token"}
onClick={() => onTokensRemove()}
disabled={hasSelectedDefaultToken}
>
<RemoveTokenIcon />
</IconButton>
</Flex>
</Flex>
)}
</Box>
);
}
return (
<SortableTiles
groups={groups}
onGroupChange={onTokensGroup}
groups={subgroup ? openGroupItems : groups}
onGroupChange={onGroupsChange}
renderTile={renderTile}
renderTiles={renderTiles}
onTileSelect={onTileSelect}
onTileSelect={onGroupSelect}
disableGrouping={subgroup}
/>
);
}

View File

@ -0,0 +1,142 @@
import React, { useState, useContext, useEffect } from "react";
import cloneDeep from "lodash.clonedeep";
import { useKeyboard, useBlur } from "./KeyboardContext";
import { getGroupItems, groupsFromIds } from "../helpers/select";
import shortcuts from "../shortcuts";
const GroupContext = React.createContext();
export function GroupProvider({
groups,
onGroupsChange,
onGroupsSelect,
disabled,
children,
}) {
const [selectedGroupIds, setSelectedGroupIds] = useState([]);
const [openGroupId, setOpenGroupId] = useState();
const [openGroupItems, setOpenGroupItems] = useState([]);
// Either single, multiple or range
const [selectMode, setSelectMode] = useState("single");
useEffect(() => {
if (openGroupId) {
setOpenGroupItems(getGroupItems(groupsFromIds([openGroupId], groups)[0]));
} else {
setOpenGroupItems([]);
}
}, [openGroupId, groups]);
function handleGroupOpen(groupId) {
setSelectedGroupIds([]);
setOpenGroupId(groupId);
}
function handleGroupClose() {
setSelectedGroupIds([]);
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);
let updatedGroups = cloneDeep(groups);
const group = updatedGroups[groupIndex];
updatedGroups[groupIndex] = { ...group, items: newGroups };
onGroupsChange(updatedGroups);
} else {
onGroupsChange(newGroups);
}
}
function handleGroupSelect(groupId) {
let groupIds = [];
if (groupId) {
switch (selectMode) {
case "single":
groupIds = [groupId];
break;
case "multiple":
if (selectedGroupIds.includes(groupId)) {
groupIds = selectedGroupIds.filter((id) => id !== groupId);
} else {
groupIds = [...selectedGroupIds, groupId];
}
break;
case "range":
/// TODO: Fix when new groups system is added
return;
default:
groupIds = [];
}
}
setSelectedGroupIds(groupIds);
onGroupsSelect(groupIds);
}
/**
* Shortcuts
*/
function handleKeyDown(event) {
if (disabled) {
return;
}
if (shortcuts.selectRange(event)) {
setSelectMode("range");
}
if (shortcuts.selectMultiple(event)) {
setSelectMode("multiple");
}
}
function handleKeyUp(event) {
if (disabled) {
return;
}
if (shortcuts.selectRange(event) && selectMode === "range") {
setSelectMode("single");
}
if (shortcuts.selectMultiple(event) && selectMode === "multiple") {
setSelectMode("single");
}
}
useKeyboard(handleKeyDown, handleKeyUp);
// Set select mode to single when cmd+tabing
function handleBlur() {
setSelectMode("single");
}
useBlur(handleBlur);
const value = {
groups,
openGroupId,
openGroupItems,
selectedGroupIds,
selectMode,
onGroupOpen: handleGroupOpen,
onGroupClose: handleGroupClose,
onGroupsChange: handleGroupsChange,
onGroupSelect: handleGroupSelect,
};
return (
<GroupContext.Provider value={value}>{children}</GroupContext.Provider>
);
}
export function useGroup() {
const context = useContext(GroupContext);
if (context === undefined) {
throw new Error("useGroup must be used within a GroupProvider");
}
return context;
}
export default GroupContext;

View File

@ -1,141 +1,7 @@
import { useEffect, useState } from "react";
import { v4 as uuid } from "uuid";
import Fuse from "fuse.js";
import cloneDeep from "lodash.clonedeep";
import { groupBy, keyBy } from "./shared";
/**
* Helpers for the SelectMapModal and SelectTokenModal
*/
// Helper for generating search results for items
export function useSearch(items, search) {
const [filteredItems, setFilteredItems] = useState([]);
const [filteredItemScores, setFilteredItemScores] = useState({});
const [fuse, setFuse] = useState();
// Update search index when items change
useEffect(() => {
setFuse(new Fuse(items, { keys: ["name", "group"], includeScore: true }));
}, [items]);
// Perform search when search changes
useEffect(() => {
if (search) {
const query = fuse.search(search);
setFilteredItems(query.map((result) => result.item));
setFilteredItemScores(
query.reduce(
(acc, value) => ({ ...acc, [value.item.id]: value.score }),
{}
)
);
}
}, [search, items, fuse]);
return [filteredItems, filteredItemScores];
}
// TODO: Rework group support
// Helper for grouping items
export function useGroup(items, filteredItems, useFiltered, filteredScores) {
const itemsByGroup = groupBy(useFiltered ? filteredItems : items, "group");
// Get the groups of the items sorting by the average score if we're filtering or the alphabetical order
// with "" at the start and "default" at the end if not
let itemGroups = Object.keys(itemsByGroup);
if (useFiltered) {
itemGroups.sort((a, b) => {
const aScore = itemsByGroup[a].reduce(
(acc, item) => (acc + filteredScores[item.id]) / 2
);
const bScore = itemsByGroup[b].reduce(
(acc, item) => (acc + filteredScores[item.id]) / 2
);
return aScore - bScore;
});
} else {
itemGroups.sort((a, b) => {
if (a === "" || b === "default") {
return -1;
}
if (b === "" || a === "default") {
return 1;
}
return a.localeCompare(b);
});
}
return [itemsByGroup, itemGroups];
}
// Helper for handling selecting items
export function handleItemSelect(
item,
selectMode,
selectedIds,
setSelectedIds,
itemsByGroup,
itemGroups
) {
if (!item) {
setSelectedIds([]);
return;
}
switch (selectMode) {
case "single":
setSelectedIds([item.id]);
break;
case "multiple":
setSelectedIds((prev) => {
if (prev.includes(item.id)) {
return prev.filter((id) => id !== item.id);
} else {
return [...prev, item.id];
}
});
break;
case "range":
/// TODO: Fix when new groups system is added
return;
// Create items array
// let items = itemGroups.reduce(
// (acc, group) => [...acc, ...itemsByGroup[group]],
// []
// );
// // Add all items inbetween the previous selected item and the current selected
// if (selectedIds.length > 0) {
// const mapIndex = items.findIndex((m) => m.id === item.id);
// const lastIndex = items.findIndex(
// (m) => m.id === selectedIds[selectedIds.length - 1]
// );
// let idsToAdd = [];
// let idsToRemove = [];
// const direction = mapIndex > lastIndex ? 1 : -1;
// for (
// let i = lastIndex + direction;
// direction < 0 ? i >= mapIndex : i <= mapIndex;
// i += direction
// ) {
// const itemId = items[i].id;
// if (selectedIds.includes(itemId)) {
// idsToRemove.push(itemId);
// } else {
// idsToAdd.push(itemId);
// }
// }
// setSelectedIds((prev) => {
// let ids = [...prev, ...idsToAdd];
// return ids.filter((id) => !idsToRemove.includes(id));
// });
// } else {
// setSelectedIds([item.id]);
// }
// break;
default:
setSelectedIds([]);
}
}
import { keyBy } from "./shared";
/**
* @typedef GroupItem
@ -232,6 +98,13 @@ export function combineGroups(a, b) {
}
}
/**
* Immutably move group at `bIndex` into `aIndex`
* @param {Group[]} groups
* @param {number} aIndex
* @param {number} bIndex
* @returns {Group[]}
*/
export function moveGroups(groups, aIndex, bIndex) {
const aGroup = groups[aIndex];
const bGroup = groups[bIndex];
@ -241,3 +114,23 @@ export function moveGroups(groups, aIndex, bIndex) {
newGroups.splice(bIndex, 1);
return newGroups;
}
/**
* Recursively find a group within a group array
* @param {Group[]} groups
* @param {string} groupId
* @returns {Group}
*/
export function findGroup(groups, groupId) {
for (let group of groups) {
if (group.id === groupId) {
return group;
}
const items = getGroupItems(group);
for (let item of items) {
if (item.id === groupId) {
return item;
}
}
}
}

View File

@ -27,7 +27,9 @@ function useResponsiveLayout() {
? "1fr 1fr 1fr"
: "1fr 1fr";
return { screenSize, modalSize, tileSize, gridTemplate };
const tileContainerHeight = isLargeScreen ? "600px" : "400px";
return { screenSize, modalSize, tileSize, gridTemplate, tileContainerHeight };
}
export default useResponsiveLayout;

View File

@ -1,29 +1,29 @@
import React, { useRef, useState } from "react";
import { Button, Flex, Label } from "theme-ui";
import { Button, Flex, Label, Box } from "theme-ui";
import { useToasts } from "react-toast-notifications";
import EditMapModal from "./EditMapModal";
import ConfirmModal from "./ConfirmModal";
import Modal from "../components/Modal";
import MapTiles from "../components/map/MapTiles";
import ImageDrop from "../components/ImageDrop";
import LoadingOverlay from "../components/LoadingOverlay";
import {
groupsFromIds,
handleItemSelect,
itemsFromGroups,
} from "../helpers/select";
import MapTiles from "../components/map/MapTiles";
import TilesOverlay from "../components/tile/TilesOverlay";
import TilesContainer from "../components/tile/TilesContainer";
import { groupsFromIds, itemsFromGroups, findGroup } from "../helpers/select";
import { createMapFromFile } from "../helpers/map";
import useResponsiveLayout from "../hooks/useResponsiveLayout";
import { useMapData } from "../contexts/MapDataContext";
import { useAuth } from "../contexts/AuthContext";
import { useKeyboard, useBlur } from "../contexts/KeyboardContext";
import { useKeyboard } from "../contexts/KeyboardContext";
import { useAssets } from "../contexts/AssetsContext";
import { useDatabase } from "../contexts/DatabaseContext";
import { GroupProvider } from "../contexts/GroupContext";
import shortcuts from "../shortcuts";
@ -52,20 +52,8 @@ function SelectMapModal({
updateMap,
updateMapState,
} = useMapData();
const { databaseStatus } = useDatabase();
const { addAssets } = useAssets();
/**
* Search
*/
const [search, setSearch] = useState("");
// TODO: Add back with new group support
// const [filteredMaps, filteredMapScores] = useSearch(ownedMaps, search);
function handleSearchChange(event) {
setSearch(event.target.value);
}
/**
* Image Upload
*/
@ -138,12 +126,6 @@ function SelectMapModal({
setIsLoading(false);
}
function openImageDialog() {
if (fileInputRef.current) {
fileInputRef.current.click();
}
}
/**
* Map Controls
*/
@ -186,19 +168,6 @@ function SelectMapModal({
setIsLoading(false);
}
// Either single, multiple or range
const [selectMode, setSelectMode] = useState("single");
function handleTileSelect(item) {
handleItemSelect(
item,
selectMode,
selectedGroupIds,
setSelectedGroupIds
// TODO: Add new group support
);
}
/**
* Modal Controls
*/
@ -207,21 +176,31 @@ function SelectMapModal({
onDone();
}
async function handleDone() {
async function handleMapSelect(mapId) {
if (isLoading) {
return;
}
const groups = groupsFromIds(selectedGroupIds, mapGroups);
if (groups.length === 1 && groups[0].type === "item") {
setIsLoading(true);
const map = await getMap(groups[0].id);
const mapState = await getMapState(groups[0].id);
onMapChange(map, mapState);
setIsLoading(false);
setIsLoading(true);
const map = await getMap(mapId);
const mapState = await getMapState(mapId);
onMapChange(map, mapState);
setIsLoading(false);
onDone();
}
function handleSelectClick() {
if (isLoading) {
return;
}
if (selectedGroupIds.length === 1) {
const group = findGroup(mapGroups, selectedGroupIds[0]);
if (group && group.type === "item") {
handleMapSelect(group.id);
}
} else {
onMapChange(null, null);
onDone();
}
onDone();
}
/**
@ -231,12 +210,6 @@ function SelectMapModal({
if (!isOpen) {
return;
}
if (shortcuts.selectRange(event)) {
setSelectMode("range");
}
if (shortcuts.selectMultiple(event)) {
setSelectMode("multiple");
}
if (shortcuts.delete(event)) {
const selectedMaps = getSelectedMaps();
// Selected maps and none are default
@ -252,26 +225,7 @@ function SelectMapModal({
}
}
function handleKeyUp(event) {
if (!isOpen) {
return;
}
if (shortcuts.selectRange(event) && selectMode === "range") {
setSelectMode("single");
}
if (shortcuts.selectMultiple(event) && selectMode === "multiple") {
setSelectMode("single");
}
}
useKeyboard(handleKeyDown, handleKeyUp);
// Set select mode to single when cmd+tabing
function handleBlur() {
setSelectMode("single");
}
useBlur(handleBlur);
useKeyboard(handleKeyDown);
const layout = useResponsiveLayout();
@ -298,28 +252,34 @@ function SelectMapModal({
<Label pt={2} pb={1}>
Select or import a map
</Label>
<MapTiles
maps={maps}
mapStates={mapStates}
groups={mapGroups}
selectedGroupIds={selectedGroupIds}
onMapAdd={openImageDialog}
onMapEdit={() => setIsEditModalOpen(true)}
onMapsReset={() => setIsMapsResetModalOpen(true)}
onMapsRemove={() => setIsMapsRemoveModalOpen(true)}
onTileSelect={handleTileSelect}
onDone={handleDone}
selectMode={selectMode}
onSelectModeChange={setSelectMode}
search={search}
onSearchChange={handleSearchChange}
onMapsGroup={updateMapGroups}
databaseDisabled={databaseStatus === "disabled"}
/>
<Box sx={{ position: "relative" }} bg="muted">
<GroupProvider
groups={mapGroups}
onGroupsChange={updateMapGroups}
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>
</GroupProvider>
</Box>
<Button
variant="primary"
disabled={isLoading || selectedGroupIds.length > 1}
onClick={handleDone}
onClick={handleSelectClick}
mt={2}
>
Select

View File

@ -1,6 +1,5 @@
import React, { useRef, useState } from "react";
import { Flex, Label, Button } from "theme-ui";
import { Flex, Label, Button, Box } from "theme-ui";
import { useToasts } from "react-toast-notifications";
import EditTokenModal from "./EditTokenModal";
@ -8,23 +7,23 @@ import ConfirmModal from "./ConfirmModal";
import Modal from "../components/Modal";
import ImageDrop from "../components/ImageDrop";
import TokenTiles from "../components/token/TokenTiles";
import LoadingOverlay from "../components/LoadingOverlay";
import {
groupsFromIds,
handleItemSelect,
itemsFromGroups,
} from "../helpers/select";
import TokenTiles from "../components/token/TokenTiles";
import TilesOverlay from "../components/tile/TilesOverlay";
import TilesContainer from "../components/tile/TilesContainer";
import { groupsFromIds, itemsFromGroups } from "../helpers/select";
import { createTokenFromFile } from "../helpers/token";
import useResponsiveLayout from "../hooks/useResponsiveLayout";
import { useTokenData } from "../contexts/TokenDataContext";
import { useAuth } from "../contexts/AuthContext";
import { useKeyboard, useBlur } from "../contexts/KeyboardContext";
import { useKeyboard } from "../contexts/KeyboardContext";
import { useAssets } from "../contexts/AssetsContext";
import { useDatabase } from "../contexts/DatabaseContext";
import { GroupProvider } from "../contexts/GroupContext";
import shortcuts from "../shortcuts";
@ -36,25 +35,14 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
tokens,
addToken,
removeTokens,
updateTokens,
// updateTokens,
tokensLoading,
tokenGroups,
updateTokenGroups,
updateToken,
} = useTokenData();
const { databaseStatus } = useDatabase();
const { addAssets } = useAssets();
/**
* Search
*/
const [search, setSearch] = useState("");
// const [filteredTokens, filteredTokenScores] = useSearch(ownedTokens, search);
function handleSearchChange(event) {
setSearch(event.target.value);
}
/**
* Image Upload
*/
@ -67,12 +55,6 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
);
const largeImageWarningFiles = useRef();
function openImageDialog() {
if (fileInputRef.current) {
fileInputRef.current.click();
}
}
async function handleImagesUpload(files) {
if (navigator.storage) {
// Attempt to enable persistant storage
@ -155,26 +137,13 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
setIsLoading(false);
}
async function handleTokensHide(hideInSidebar) {
setIsLoading(true);
const selectedTokens = getSelectedTokens();
const selectedTokenIds = selectedTokens.map((token) => token.id);
await updateTokens(selectedTokenIds, { hideInSidebar });
setIsLoading(false);
}
// Either single, multiple or range
const [selectMode, setSelectMode] = useState("single");
async function handleTileSelect(item) {
handleItemSelect(
item,
selectMode,
selectedGroupIds,
setSelectedGroupIds
// TODO: Rework group support
);
}
// async function handleTokensHide(hideInSidebar) {
// setIsLoading(true);
// const selectedTokens = getSelectedTokens();
// const selectedTokenIds = selectedTokens.map((token) => token.id);
// await updateTokens(selectedTokenIds, { hideInSidebar });
// setIsLoading(false);
// }
/**
* Shortcuts
@ -183,12 +152,6 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
if (!isOpen) {
return;
}
if (shortcuts.selectRange(event)) {
setSelectMode("range");
}
if (shortcuts.selectMultiple(event)) {
setSelectMode("multiple");
}
if (shortcuts.delete(event)) {
const selectedTokens = getSelectedTokens();
// Selected tokens and none are default
@ -203,26 +166,7 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
}
}
function handleKeyUp(event) {
if (!isOpen) {
return;
}
if (shortcuts.selectRange(event) && selectMode === "range") {
setSelectMode("single");
}
if (shortcuts.selectMultiple(event) && selectMode === "multiple") {
setSelectMode("single");
}
}
useKeyboard(handleKeyDown, handleKeyUp);
// Set select mode to single when cmd+tabing
function handleBlur() {
setSelectMode("single");
}
useBlur(handleBlur);
useKeyboard(handleKeyDown);
const layout = useResponsiveLayout();
@ -249,22 +193,29 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
<Label pt={2} pb={1}>
Edit or import a token
</Label>
<TokenTiles
tokens={tokens}
groups={tokenGroups}
selectedGroupIds={selectedGroupIds}
onTokenAdd={openImageDialog}
onTokenEdit={() => setIsEditModalOpen(true)}
onTokensRemove={() => setIsTokensRemoveModalOpen(true)}
onTileSelect={handleTileSelect}
selectMode={selectMode}
onSelectModeChange={setSelectMode}
search={search}
onSearchChange={handleSearchChange}
onTokensGroup={updateTokenGroups}
onTokensHide={handleTokensHide}
databaseDisabled={databaseStatus === "disabled"}
/>
<Box sx={{ position: "relative" }} bg="muted">
<GroupProvider
groups={tokenGroups}
onGroupsChange={updateTokenGroups}
onGroupsSelect={setSelectedGroupIds}
disabled={!isOpen}
>
<TilesContainer>
<TokenTiles
tokens={tokens}
onTokenEdit={() => setIsEditModalOpen(true)}
/>
</TilesContainer>
<TilesOverlay>
<TokenTiles
tokens={tokens}
onTokenEdit={() => setIsEditModalOpen(true)}
subgroup
/>
</TilesOverlay>
</GroupProvider>
</Box>
<Button
variant="primary"
disabled={isLoading}