Added search and groups to token select, refactored map search and group
This commit is contained in:
parent
0174acbde0
commit
f28d1b6690
69
src/components/FilterBar.js
Normal file
69
src/components/FilterBar.js
Normal file
@ -0,0 +1,69 @@
|
||||
import React from "react";
|
||||
import { Flex, IconButton } from "theme-ui";
|
||||
|
||||
import AddIcon from "../icons/AddIcon";
|
||||
import SelectMultipleIcon from "../icons/SelectMultipleIcon";
|
||||
import SelectSingleIcon from "../icons/SelectSingleIcon";
|
||||
|
||||
import Search from "./Search";
|
||||
import RadioIconButton from "./RadioIconButton";
|
||||
|
||||
function FilterBar({
|
||||
onFocus,
|
||||
search,
|
||||
onSearchChange,
|
||||
selectMode,
|
||||
onSelectModeChange,
|
||||
onAdd,
|
||||
addTitle,
|
||||
}) {
|
||||
return (
|
||||
<Flex
|
||||
bg="muted"
|
||||
sx={{
|
||||
border: "1px solid",
|
||||
borderColor: "text",
|
||||
borderRadius: "4px",
|
||||
alignItems: "center",
|
||||
":focus-within": {
|
||||
outline: "1px auto",
|
||||
outlineColor: "primary",
|
||||
outlineOffset: "0px",
|
||||
},
|
||||
}}
|
||||
onFocus={onFocus}
|
||||
>
|
||||
<Search value={search} onChange={onSearchChange} />
|
||||
<Flex
|
||||
mr={1}
|
||||
px={1}
|
||||
sx={{
|
||||
borderRight: "1px solid",
|
||||
borderColor: "text",
|
||||
height: "36px",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<RadioIconButton
|
||||
title="Select Single"
|
||||
onClick={() => onSelectModeChange("single")}
|
||||
isSelected={selectMode === "single"}
|
||||
>
|
||||
<SelectSingleIcon />
|
||||
</RadioIconButton>
|
||||
<RadioIconButton
|
||||
title="Select Multiple"
|
||||
onClick={() => onSelectModeChange("multiple")}
|
||||
isSelected={selectMode === "multiple" || selectMode === "range"}
|
||||
>
|
||||
<SelectMultipleIcon />
|
||||
</RadioIconButton>
|
||||
</Flex>
|
||||
<IconButton onClick={onAdd} aria-label={addTitle} title={addTitle} mr={1}>
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default FilterBar;
|
@ -1,7 +1,7 @@
|
||||
import React, { useState, Fragment } from "react";
|
||||
import { IconButton, Flex, Box } from "theme-ui";
|
||||
|
||||
import RadioIconButton from "./controls/RadioIconButton";
|
||||
import RadioIconButton from "../RadioIconButton";
|
||||
import Divider from "../Divider";
|
||||
|
||||
import SelectMapButton from "./SelectMapButton";
|
||||
|
@ -4,18 +4,13 @@ import SimpleBar from "simplebar-react";
|
||||
import { useMedia } from "react-media";
|
||||
import Case from "case";
|
||||
|
||||
import AddIcon from "../../icons/AddIcon";
|
||||
import RemoveMapIcon from "../../icons/RemoveMapIcon";
|
||||
import ResetMapIcon from "../../icons/ResetMapIcon";
|
||||
import SelectMultipleIcon from "../../icons/SelectMultipleIcon";
|
||||
import SelectSingleIcon from "../../icons/SelectSingleIcon";
|
||||
import GroupIcon from "../../icons/GroupIcon";
|
||||
|
||||
import RadioIconButton from "./controls/RadioIconButton";
|
||||
|
||||
import MapTile from "./MapTile";
|
||||
import Link from "../Link";
|
||||
import Search from "../Search";
|
||||
import FilterBar from "../FilterBar";
|
||||
|
||||
import DatabaseContext from "../../contexts/DatabaseContext";
|
||||
|
||||
@ -82,56 +77,15 @@ function MapTiles({
|
||||
|
||||
return (
|
||||
<Box sx={{ position: "relative" }}>
|
||||
<Flex
|
||||
bg="muted"
|
||||
sx={{
|
||||
border: "1px solid",
|
||||
borderColor: "text",
|
||||
borderRadius: "4px",
|
||||
alignItems: "center",
|
||||
":focus-within": {
|
||||
outline: "1px auto",
|
||||
outlineColor: "primary",
|
||||
outlineOffset: "0px",
|
||||
},
|
||||
}}
|
||||
<FilterBar
|
||||
onFocus={() => onMapSelect()}
|
||||
>
|
||||
<Search value={search} onChange={onSearchChange} />
|
||||
<Flex
|
||||
mr={1}
|
||||
px={1}
|
||||
sx={{
|
||||
borderRight: "1px solid",
|
||||
borderColor: "text",
|
||||
height: "36px",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<RadioIconButton
|
||||
title="Select Single"
|
||||
onClick={() => onSelectModeChange("single")}
|
||||
isSelected={selectMode === "single"}
|
||||
>
|
||||
<SelectSingleIcon />
|
||||
</RadioIconButton>
|
||||
<RadioIconButton
|
||||
title="Select Multiple"
|
||||
onClick={() => onSelectModeChange("multiple")}
|
||||
isSelected={selectMode === "multiple" || selectMode === "range"}
|
||||
>
|
||||
<SelectMultipleIcon />
|
||||
</RadioIconButton>
|
||||
</Flex>
|
||||
<IconButton
|
||||
onClick={onMapAdd}
|
||||
aria-label="Add Map"
|
||||
title="Add Map"
|
||||
mr={1}
|
||||
>
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
</Flex>
|
||||
search={search}
|
||||
onSearchChange={onSearchChange}
|
||||
selectMode={selectMode}
|
||||
onSelectModeChange={onSelectModeChange}
|
||||
onAdd={onMapAdd}
|
||||
addTitle="Add Map"
|
||||
/>
|
||||
<SimpleBar style={{ height: "400px" }}>
|
||||
<Flex
|
||||
p={2}
|
||||
@ -145,7 +99,6 @@ function MapTiles({
|
||||
}}
|
||||
onClick={() => onMapSelect()}
|
||||
>
|
||||
{/* Render ungrouped maps, grouped maps then default maps */}
|
||||
{groups.map((group) => (
|
||||
<React.Fragment key={group}>
|
||||
<Label mx={1} mt={2}>
|
||||
|
@ -2,9 +2,10 @@ import React, { useEffect } from "react";
|
||||
import { Flex, IconButton } from "theme-ui";
|
||||
import { useMedia } from "react-media";
|
||||
|
||||
import RadioIconButton from "../../RadioIconButton";
|
||||
|
||||
import ColorControl from "./ColorControl";
|
||||
import AlphaBlendToggle from "./AlphaBlendToggle";
|
||||
import RadioIconButton from "./RadioIconButton";
|
||||
import ToolSection from "./ToolSection";
|
||||
|
||||
import BrushIcon from "../../../icons/BrushToolIcon";
|
||||
|
@ -2,8 +2,9 @@ import React from "react";
|
||||
import { Flex } from "theme-ui";
|
||||
import { useMedia } from "react-media";
|
||||
|
||||
import RadioIconButton from "../../RadioIconButton";
|
||||
|
||||
import EdgeSnappingToggle from "./EdgeSnappingToggle";
|
||||
import RadioIconButton from "./RadioIconButton";
|
||||
import FogPreviewToggle from "./FogPreviewToggle";
|
||||
|
||||
import FogBrushIcon from "../../../icons/FogBrushIcon";
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Box, Flex } from "theme-ui";
|
||||
|
||||
import RadioIconButton from "./RadioIconButton";
|
||||
import RadioIconButton from "../../RadioIconButton";
|
||||
|
||||
// Section of map tools with the option to collapse into a vertical list
|
||||
function ToolSection({ collapse, tools, onToolClick }) {
|
||||
|
@ -1,7 +1,6 @@
|
||||
import React from "react";
|
||||
import { Flex, Image, Text, Box, IconButton } from "theme-ui";
|
||||
|
||||
import RemoveTokenIcon from "../../icons/RemoveTokenIcon";
|
||||
import Tile from "../Tile";
|
||||
|
||||
import useDataSource from "../../helpers/useDataSource";
|
||||
import {
|
||||
@ -9,93 +8,29 @@ import {
|
||||
unknownSource,
|
||||
} from "../../tokens";
|
||||
|
||||
function TokenTile({ token, isSelected, onTokenSelect, onTokenRemove, large }) {
|
||||
function TokenTile({
|
||||
token,
|
||||
isSelected,
|
||||
onTokenSelect,
|
||||
onTokenEdit,
|
||||
large,
|
||||
canEdit,
|
||||
badges,
|
||||
}) {
|
||||
const tokenSource = useDataSource(token, defaultTokenSources, unknownSource);
|
||||
const isDefault = token.type === "default";
|
||||
|
||||
return (
|
||||
<Flex
|
||||
onClick={() => onTokenSelect(token)}
|
||||
sx={{
|
||||
position: "relative",
|
||||
width: large ? "48%" : "32%",
|
||||
height: "0",
|
||||
paddingTop: large ? "48%" : "32%",
|
||||
borderRadius: "4px",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
cursor: "pointer",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
my={1}
|
||||
mx={`${large ? 1 : 2 / 3}%`}
|
||||
bg="muted"
|
||||
>
|
||||
<Image
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
}}
|
||||
src={tokenSource}
|
||||
/>
|
||||
<Flex
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background:
|
||||
"linear-gradient(to bottom, rgba(0,0,0,0) 70%, rgba(0,0,0,0.65) 100%);",
|
||||
alignItems: "flex-end",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
p={2}
|
||||
>
|
||||
<Text
|
||||
as="p"
|
||||
variant="heading"
|
||||
color="hsl(210, 50%, 96%)"
|
||||
sx={{ textAlign: "center" }}
|
||||
>
|
||||
{token.name}
|
||||
</Text>
|
||||
</Flex>
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
borderColor: "primary",
|
||||
borderStyle: isSelected ? "solid" : "none",
|
||||
borderWidth: "4px",
|
||||
pointerEvents: "none",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
/>
|
||||
{isSelected && !isDefault && (
|
||||
<Box sx={{ position: "absolute", top: 0, right: 0 }}>
|
||||
<IconButton
|
||||
aria-label="Remove Token"
|
||||
title="Remove Token"
|
||||
onClick={() => {
|
||||
onTokenRemove(token.id);
|
||||
}}
|
||||
bg="overlay"
|
||||
sx={{ borderRadius: "50%" }}
|
||||
m={2}
|
||||
>
|
||||
<RemoveTokenIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
</Flex>
|
||||
<Tile
|
||||
src={tokenSource}
|
||||
title={token.name}
|
||||
isSelected={isSelected}
|
||||
onSelect={() => onTokenSelect(token)}
|
||||
onEdit={() => onTokenEdit(token.id)}
|
||||
large={large}
|
||||
canEdit={canEdit}
|
||||
badges={badges}
|
||||
editTitle="Edit Token"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -1,84 +1,97 @@
|
||||
import React, { useContext } from "react";
|
||||
import { Flex, Box, Text } from "theme-ui";
|
||||
import { Flex, Box, Text, IconButton, Close, Label } from "theme-ui";
|
||||
import SimpleBar from "simplebar-react";
|
||||
import { useMedia } from "react-media";
|
||||
import Case from "case";
|
||||
|
||||
import AddIcon from "../../icons/AddIcon";
|
||||
import RemoveTokenIcon from "../../icons/RemoveTokenIcon";
|
||||
import GroupIcon from "../../icons/GroupIcon";
|
||||
|
||||
import TokenTile from "./TokenTile";
|
||||
import Link from "../Link";
|
||||
import FilterBar from "../FilterBar";
|
||||
|
||||
import DatabaseContext from "../../contexts/DatabaseContext";
|
||||
|
||||
function TokenTiles({
|
||||
tokens,
|
||||
groups,
|
||||
onTokenAdd,
|
||||
onTokenEdit,
|
||||
onTokenSelect,
|
||||
selectedToken,
|
||||
onTokenRemove,
|
||||
selectedTokens,
|
||||
onTokensRemove,
|
||||
selectMode,
|
||||
onSelectModeChange,
|
||||
search,
|
||||
onSearchChange,
|
||||
onTokensGroup,
|
||||
}) {
|
||||
const { databaseStatus } = useContext(DatabaseContext);
|
||||
const isSmallScreen = useMedia({ query: "(max-width: 500px)" });
|
||||
|
||||
let hasSelectedDefaultToken = false;
|
||||
for (let token of selectedTokens) {
|
||||
if (token.type === "default") {
|
||||
hasSelectedDefaultToken = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function tokenToTile(token) {
|
||||
const isSelected = selectedTokens.includes(token);
|
||||
return (
|
||||
<TokenTile
|
||||
key={token.id}
|
||||
token={token}
|
||||
isSelected={isSelected}
|
||||
onTokenSelect={onTokenSelect}
|
||||
onTokenEdit={onTokenEdit}
|
||||
large={isSmallScreen}
|
||||
canEdit={
|
||||
isSelected &&
|
||||
token.type !== "default" &&
|
||||
selectMode === "single" &&
|
||||
selectedTokens.length === 1
|
||||
}
|
||||
badges={[`${token.defaultSize}x`]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const multipleSelected = selectedTokens.length > 1;
|
||||
|
||||
return (
|
||||
<Box sx={{ position: "relative" }}>
|
||||
<SimpleBar style={{ maxHeight: "300px" }}>
|
||||
<FilterBar
|
||||
onFocus={() => onTokenSelect()}
|
||||
search={search}
|
||||
onSearchChange={onSearchChange}
|
||||
selectMode={selectMode}
|
||||
onSelectModeChange={onSelectModeChange}
|
||||
onAdd={onTokenAdd}
|
||||
addTitle="Add Token"
|
||||
/>
|
||||
<SimpleBar style={{ height: "400px" }}>
|
||||
<Flex
|
||||
p={2}
|
||||
pb={4}
|
||||
bg="muted"
|
||||
sx={{
|
||||
flexWrap: "wrap",
|
||||
borderRadius: "4px",
|
||||
minHeight: "400px",
|
||||
alignContent: "flex-start",
|
||||
}}
|
||||
onClick={() => onTokenSelect()}
|
||||
>
|
||||
<Box
|
||||
onClick={onTokenAdd}
|
||||
sx={{
|
||||
":hover": {
|
||||
color: "primary",
|
||||
},
|
||||
":focus": {
|
||||
outline: "none",
|
||||
},
|
||||
":active": {
|
||||
color: "secondary",
|
||||
},
|
||||
width: isSmallScreen ? "48%" : "32%",
|
||||
height: "0",
|
||||
paddingTop: isSmallScreen ? "48%" : "32%",
|
||||
borderRadius: "4px",
|
||||
position: "relative",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
my={1}
|
||||
mx={`${isSmallScreen ? 1 : 2 / 3}%`}
|
||||
bg="muted"
|
||||
aria-label="Add Token"
|
||||
title="Add Token"
|
||||
>
|
||||
<Flex
|
||||
sx={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<AddIcon large />
|
||||
</Flex>
|
||||
</Box>
|
||||
{tokens.map((token) => (
|
||||
<TokenTile
|
||||
key={token.id}
|
||||
token={token}
|
||||
isSelected={selectedToken && token.id === selectedToken.id}
|
||||
onTokenSelect={onTokenSelect}
|
||||
onTokenRemove={onTokenRemove}
|
||||
large={isSmallScreen}
|
||||
/>
|
||||
{groups.map((group) => (
|
||||
<React.Fragment key={group}>
|
||||
<Label mx={1} mt={2}>
|
||||
{Case.capital(group)}
|
||||
</Label>
|
||||
{tokens[group].map(tokenToTile)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Flex>
|
||||
</SimpleBar>
|
||||
@ -100,6 +113,42 @@ function TokenTiles({
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{selectedTokens.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={() => onTokenSelect()}
|
||||
/>
|
||||
<Flex>
|
||||
<IconButton
|
||||
aria-label={multipleSelected ? "Group Tokens" : "Group Token"}
|
||||
title={multipleSelected ? "Group Tokens" : "Group Token"}
|
||||
onClick={() => onTokensGroup()}
|
||||
disabled={hasSelectedDefaultToken}
|
||||
>
|
||||
<GroupIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
aria-label={multipleSelected ? "Remove Tokens" : "Remove Token"}
|
||||
title={multipleSelected ? "Remove Tokens" : "Remove Token"}
|
||||
onClick={() => onTokensRemove()}
|
||||
disabled={hasSelectedDefaultToken}
|
||||
>
|
||||
<RemoveTokenIcon />
|
||||
</IconButton>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
@ -143,6 +143,22 @@ export function MapDataProvider({ children }) {
|
||||
});
|
||||
}
|
||||
|
||||
async function updateMaps(ids, update) {
|
||||
await Promise.all(
|
||||
ids.map((id) => database.table("maps").update(id, update))
|
||||
);
|
||||
setMaps((prevMaps) => {
|
||||
const newMaps = [...prevMaps];
|
||||
for (let id of ids) {
|
||||
const i = newMaps.findIndex((map) => map.id === id);
|
||||
if (i > -1) {
|
||||
newMaps[i] = { ...newMaps[i], ...update };
|
||||
}
|
||||
}
|
||||
return newMaps;
|
||||
});
|
||||
}
|
||||
|
||||
async function updateMapState(id, update) {
|
||||
await database.table("states").update(id, update);
|
||||
setMapStates((prevMapStates) => {
|
||||
@ -218,6 +234,7 @@ export function MapDataProvider({ children }) {
|
||||
removeMaps,
|
||||
resetMap,
|
||||
updateMap,
|
||||
updateMaps,
|
||||
updateMapState,
|
||||
putMap,
|
||||
getMap,
|
||||
|
@ -26,6 +26,7 @@ export function TokenDataProvider({ children }) {
|
||||
...defaultToken,
|
||||
id: `__default-${defaultToken.name}`,
|
||||
owner: userId,
|
||||
group: "default",
|
||||
});
|
||||
}
|
||||
return defaultTokensWithIds;
|
||||
@ -60,6 +61,14 @@ export function TokenDataProvider({ children }) {
|
||||
});
|
||||
}
|
||||
|
||||
async function removeTokens(ids) {
|
||||
await database.table("tokens").bulkDelete(ids);
|
||||
setTokens((prevTokens) => {
|
||||
const filtered = prevTokens.filter((token) => !ids.includes(token.id));
|
||||
return filtered;
|
||||
});
|
||||
}
|
||||
|
||||
async function updateToken(id, update) {
|
||||
const change = { ...update, lastModified: Date.now() };
|
||||
await database.table("tokens").update(id, change);
|
||||
@ -73,6 +82,23 @@ export function TokenDataProvider({ children }) {
|
||||
});
|
||||
}
|
||||
|
||||
async function updateTokens(ids, update) {
|
||||
const change = { ...update, lastModified: Date.now() };
|
||||
await Promise.all(
|
||||
ids.map((id) => database.table("tokens").update(id, change))
|
||||
);
|
||||
setTokens((prevTokens) => {
|
||||
const newTokens = [...prevTokens];
|
||||
for (let id of ids) {
|
||||
const i = newTokens.findIndex((token) => token.id === id);
|
||||
if (i > -1) {
|
||||
newTokens[i] = { ...newTokens[i], ...change };
|
||||
}
|
||||
}
|
||||
return newTokens;
|
||||
});
|
||||
}
|
||||
|
||||
async function putToken(token) {
|
||||
await database.table("tokens").put(token);
|
||||
setTokens((prevTokens) => {
|
||||
@ -128,7 +154,9 @@ export function TokenDataProvider({ children }) {
|
||||
ownedTokens,
|
||||
addToken,
|
||||
removeToken,
|
||||
removeTokens,
|
||||
updateToken,
|
||||
updateTokens,
|
||||
putToken,
|
||||
getToken,
|
||||
tokensById,
|
||||
|
@ -217,6 +217,17 @@ function loadVersions(db) {
|
||||
map.group = "";
|
||||
});
|
||||
});
|
||||
// v1.6.0 - Added token grouping
|
||||
db.version(14)
|
||||
.stores({})
|
||||
.upgrade((tx) => {
|
||||
return tx
|
||||
.table("tokens")
|
||||
.toCollection()
|
||||
.modify((token) => {
|
||||
token.group = "";
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Get the dexie database used in DatabaseContext
|
||||
|
133
src/helpers/select.js
Normal file
133
src/helpers/select.js
Normal file
@ -0,0 +1,133 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Fuse from "fuse.js";
|
||||
|
||||
import { groupBy } 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];
|
||||
}
|
||||
|
||||
// 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":
|
||||
// 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([]);
|
||||
}
|
||||
}
|
@ -9,7 +9,7 @@ import MapDataContext from "../contexts/MapDataContext";
|
||||
|
||||
import { isEmpty } from "../helpers/shared";
|
||||
|
||||
function SelectMapModal({ isOpen, onDone, map, mapState }) {
|
||||
function EditMapModal({ isOpen, onDone, map, mapState }) {
|
||||
const { updateMap, updateMapState } = useContext(MapDataContext);
|
||||
|
||||
function handleClose() {
|
||||
@ -102,4 +102,4 @@ function SelectMapModal({ isOpen, onDone, map, mapState }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default SelectMapModal;
|
||||
export default EditMapModal;
|
||||
|
75
src/modals/EditTokenModal.js
Normal file
75
src/modals/EditTokenModal.js
Normal file
@ -0,0 +1,75 @@
|
||||
import React, { useState, useContext } from "react";
|
||||
import { Button, Flex, Label } from "theme-ui";
|
||||
|
||||
import Modal from "../components/Modal";
|
||||
import TokenSettings from "../components/token/TokenSettings";
|
||||
|
||||
import TokenDataContext from "../contexts/TokenDataContext";
|
||||
|
||||
import { isEmpty } from "../helpers/shared";
|
||||
|
||||
function EditTokenModal({ isOpen, onDone, token }) {
|
||||
const { updateToken } = useContext(TokenDataContext);
|
||||
|
||||
function handleClose() {
|
||||
onDone();
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
await applyTokenChanges();
|
||||
onDone();
|
||||
}
|
||||
|
||||
const [tokenSettingChanges, setTokenSettingChanges] = useState({});
|
||||
|
||||
function handleTokenSettingsChange(key, value) {
|
||||
setTokenSettingChanges((prevChanges) => ({ ...prevChanges, [key]: value }));
|
||||
}
|
||||
|
||||
async function applyTokenChanges() {
|
||||
if (token && !isEmpty(tokenSettingChanges)) {
|
||||
// Ensure size value is positive
|
||||
let verifiedChanges = { ...tokenSettingChanges };
|
||||
if ("defaultSize" in verifiedChanges) {
|
||||
verifiedChanges.defaultSize = verifiedChanges.defaultSize || 1;
|
||||
}
|
||||
|
||||
await updateToken(token.id, verifiedChanges);
|
||||
setTokenSettingChanges({});
|
||||
}
|
||||
}
|
||||
|
||||
const selectedTokenWithChanges = {
|
||||
...token,
|
||||
...tokenSettingChanges,
|
||||
};
|
||||
|
||||
const [showMoreSettings, setShowMoreSettings] = useState(false);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onRequestClose={handleClose}
|
||||
style={{ maxWidth: "542px", width: "calc(100% - 16px)" }}
|
||||
>
|
||||
<Flex
|
||||
sx={{
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Label pt={2} pb={1}>
|
||||
Edit token
|
||||
</Label>
|
||||
<TokenSettings
|
||||
token={selectedTokenWithChanges}
|
||||
showMore={showMoreSettings}
|
||||
onSettingsChange={handleTokenSettingsChange}
|
||||
onShowMoreChange={setShowMoreSettings}
|
||||
/>
|
||||
<Button onClick={handleSave}>Save</Button>
|
||||
</Flex>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditTokenModal;
|
@ -1,7 +1,6 @@
|
||||
import React, { useRef, useState, useContext, useEffect } from "react";
|
||||
import React, { useRef, useState, useContext } from "react";
|
||||
import { Button, Flex, Label } from "theme-ui";
|
||||
import shortid from "shortid";
|
||||
import Fuse from "fuse.js";
|
||||
|
||||
import EditMapModal from "./EditMapModal";
|
||||
import EditGroupModal from "./EditGroupModal";
|
||||
@ -13,13 +12,12 @@ import LoadingOverlay from "../components/LoadingOverlay";
|
||||
|
||||
import blobToBuffer from "../helpers/blobToBuffer";
|
||||
import useKeyboard from "../helpers/useKeyboard";
|
||||
import { resizeImage } from "../helpers/image";
|
||||
import { useSearch, useGroup, handleItemSelect } from "../helpers/select";
|
||||
|
||||
import MapDataContext from "../contexts/MapDataContext";
|
||||
import AuthContext from "../contexts/AuthContext";
|
||||
|
||||
import { resizeImage } from "../helpers/image";
|
||||
import { groupBy } from "../helpers/shared";
|
||||
|
||||
const defaultMapSize = 22;
|
||||
const defaultMapProps = {
|
||||
// Grid type
|
||||
@ -54,33 +52,14 @@ function SelectMapModal({
|
||||
removeMaps,
|
||||
resetMap,
|
||||
updateMap,
|
||||
updateMaps,
|
||||
} = useContext(MapDataContext);
|
||||
|
||||
/**
|
||||
* Search
|
||||
*/
|
||||
const [filteredMaps, setFilteredMaps] = useState([]);
|
||||
const [filteredMapScores, setFilteredMapScores] = useState({});
|
||||
const [fuse, setFuse] = useState();
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
// Update search index when maps change
|
||||
useEffect(() => {
|
||||
setFuse(
|
||||
new Fuse(ownedMaps, { keys: ["name", "group"], includeScore: true })
|
||||
);
|
||||
}, [ownedMaps]);
|
||||
|
||||
// Perform search when search changes
|
||||
useEffect(() => {
|
||||
if (search) {
|
||||
const query = fuse.search(search);
|
||||
setFilteredMaps(query.map((result) => result.item));
|
||||
setFilteredMapScores(
|
||||
query.reduce((acc, value) => ({ ...acc, [value.item.id]: value.score }))
|
||||
);
|
||||
}
|
||||
}, [search, ownedMaps, fuse]);
|
||||
const [filteredMaps, filteredMapScores] = useSearch(ownedMaps, search);
|
||||
|
||||
function handleSearchChange(event) {
|
||||
setSearch(event.target.value);
|
||||
@ -93,36 +72,15 @@ function SelectMapModal({
|
||||
|
||||
async function handleMapsGroup(group) {
|
||||
setIsGroupModalOpen(false);
|
||||
for (let id of selectedMapIds) {
|
||||
await updateMap(id, { group });
|
||||
}
|
||||
updateMaps(selectedMapIds, { group });
|
||||
}
|
||||
|
||||
const mapsByGroup = groupBy(search ? filteredMaps : ownedMaps, "group");
|
||||
// Get the groups of the maps 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 mapGroups = Object.keys(mapsByGroup);
|
||||
if (search) {
|
||||
mapGroups.sort((a, b) => {
|
||||
const aScore = mapsByGroup[a].reduce(
|
||||
(acc, map) => (acc + filteredMapScores[map.id]) / 2
|
||||
);
|
||||
const bScore = mapsByGroup[b].reduce(
|
||||
(acc, map) => (acc + filteredMapScores[map.id]) / 2
|
||||
);
|
||||
return aScore - bScore;
|
||||
});
|
||||
} else {
|
||||
mapGroups.sort((a, b) => {
|
||||
if (a === "" || b === "default") {
|
||||
return -1;
|
||||
}
|
||||
if (b === "" || a === "default") {
|
||||
return 1;
|
||||
}
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
}
|
||||
const [mapsByGroup, mapGroups] = useGroup(
|
||||
ownedMaps,
|
||||
filteredMaps,
|
||||
!!search,
|
||||
filteredMapScores
|
||||
);
|
||||
|
||||
/**
|
||||
* Image Upload
|
||||
@ -276,63 +234,15 @@ function SelectMapModal({
|
||||
// Either single, multiple or range
|
||||
const [selectMode, setSelectMode] = useState("single");
|
||||
|
||||
async function handleMapSelect(map) {
|
||||
if (map) {
|
||||
switch (selectMode) {
|
||||
case "single":
|
||||
setSelectedMapIds([map.id]);
|
||||
break;
|
||||
case "multiple":
|
||||
setSelectedMapIds((prev) => {
|
||||
if (prev.includes(map.id)) {
|
||||
return prev.filter((id) => id !== map.id);
|
||||
} else {
|
||||
return [...prev, map.id];
|
||||
}
|
||||
});
|
||||
break;
|
||||
case "range":
|
||||
// Create maps array
|
||||
let maps = mapGroups.reduce(
|
||||
(acc, group) => [...acc, ...mapsByGroup[group]],
|
||||
[]
|
||||
);
|
||||
|
||||
// Add all items inbetween the previous selected map and the current selected
|
||||
if (selectedMapIds.length > 0) {
|
||||
const mapIndex = maps.findIndex((m) => m.id === map.id);
|
||||
const lastIndex = maps.findIndex(
|
||||
(m) => m.id === selectedMapIds[selectedMapIds.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 mapId = maps[i].id;
|
||||
if (selectedMapIds.includes(mapId)) {
|
||||
idsToRemove.push(mapId);
|
||||
} else {
|
||||
idsToAdd.push(mapId);
|
||||
}
|
||||
}
|
||||
setSelectedMapIds((prev) => {
|
||||
let ids = [...prev, ...idsToAdd];
|
||||
return ids.filter((id) => !idsToRemove.includes(id));
|
||||
});
|
||||
} else {
|
||||
setSelectedMapIds([map.id]);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
setSelectedMapIds([]);
|
||||
}
|
||||
} else {
|
||||
setSelectedMapIds([]);
|
||||
}
|
||||
function handleMapSelect(map) {
|
||||
handleItemSelect(
|
||||
map,
|
||||
selectMode,
|
||||
selectedMapIds,
|
||||
setSelectedMapIds,
|
||||
mapsByGroup,
|
||||
mapGroups
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -2,42 +2,66 @@ import React, { useRef, useContext, useState } from "react";
|
||||
import { Flex, Label, Button } from "theme-ui";
|
||||
import shortid from "shortid";
|
||||
|
||||
import EditTokenModal from "./EditTokenModal";
|
||||
import EditGroupModal from "./EditGroupModal";
|
||||
|
||||
import Modal from "../components/Modal";
|
||||
import ImageDrop from "../components/ImageDrop";
|
||||
import TokenTiles from "../components/token/TokenTiles";
|
||||
import TokenSettings from "../components/token/TokenSettings";
|
||||
|
||||
import blobToBuffer from "../helpers/blobToBuffer";
|
||||
import useKeyboard from "../helpers/useKeyboard";
|
||||
import { useSearch, useGroup, handleItemSelect } from "../helpers/select";
|
||||
|
||||
import TokenDataContext from "../contexts/TokenDataContext";
|
||||
import AuthContext from "../contexts/AuthContext";
|
||||
import { isEmpty } from "../helpers/shared";
|
||||
|
||||
function SelectTokensModal({ isOpen, onRequestClose }) {
|
||||
const { userId } = useContext(AuthContext);
|
||||
const { ownedTokens, addToken, removeToken, updateToken } = useContext(
|
||||
const { ownedTokens, addToken, removeTokens, updateTokens } = useContext(
|
||||
TokenDataContext
|
||||
);
|
||||
const fileInputRef = useRef();
|
||||
|
||||
const [imageLoading, setImageLoading] = useState(false);
|
||||
/**
|
||||
* Search
|
||||
*/
|
||||
const [search, setSearch] = useState("");
|
||||
const [filteredTokens, filteredTokenScores] = useSearch(ownedTokens, search);
|
||||
|
||||
const [selectedTokenId, setSelectedTokenId] = useState(null);
|
||||
const selectedToken = ownedTokens.find(
|
||||
(token) => token.id === selectedTokenId
|
||||
function handleSearchChange(event) {
|
||||
setSearch(event.target.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Group
|
||||
*/
|
||||
const [isGroupModalOpen, setIsGroupModalOpen] = useState(false);
|
||||
|
||||
async function handleTokensGroup(group) {
|
||||
setIsGroupModalOpen(false);
|
||||
await updateTokens(selectedTokenIds, { group });
|
||||
}
|
||||
|
||||
const [tokensByGroup, tokenGroups] = useGroup(
|
||||
ownedTokens,
|
||||
filteredTokens,
|
||||
!!search,
|
||||
filteredTokenScores
|
||||
);
|
||||
|
||||
/**
|
||||
* Image Upload
|
||||
*/
|
||||
|
||||
const fileInputRef = useRef();
|
||||
const [imageLoading, setImageLoading] = useState(false);
|
||||
|
||||
function openImageDialog() {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
}
|
||||
|
||||
function handleTokenAdd(token) {
|
||||
addToken(token);
|
||||
setSelectedTokenId(token.id);
|
||||
}
|
||||
|
||||
async function handleImagesUpload(files) {
|
||||
for (let file of files) {
|
||||
await handleImageUpload(file);
|
||||
@ -80,6 +104,7 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
|
||||
defaultSize: 1,
|
||||
category: "character",
|
||||
hideInSidebar: false,
|
||||
group: "",
|
||||
});
|
||||
setImageLoading(false);
|
||||
resolve();
|
||||
@ -89,52 +114,72 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
|
||||
});
|
||||
}
|
||||
|
||||
async function handleTokenSelect(token) {
|
||||
await applyTokenChanges();
|
||||
setSelectedTokenId(token.id);
|
||||
/**
|
||||
* Token controls
|
||||
*/
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [selectedTokenIds, setSelectedTokenIds] = useState([]);
|
||||
const selectedTokens = ownedTokens.filter((token) =>
|
||||
selectedTokenIds.includes(token.id)
|
||||
);
|
||||
|
||||
function handleTokenAdd(token) {
|
||||
addToken(token);
|
||||
setSelectedTokenIds([token.id]);
|
||||
}
|
||||
|
||||
async function handleTokenRemove(id) {
|
||||
await removeToken(id);
|
||||
setSelectedTokenId(null);
|
||||
setTokenSettingChanges({});
|
||||
async function handleTokensRemove() {
|
||||
await removeTokens(selectedTokenIds);
|
||||
setSelectedTokenIds([]);
|
||||
}
|
||||
|
||||
// Either single, multiple or range
|
||||
const [selectMode, setSelectMode] = useState("single");
|
||||
|
||||
async function handleTokenSelect(token) {
|
||||
handleItemSelect(
|
||||
token,
|
||||
selectMode,
|
||||
selectedTokenIds,
|
||||
setSelectedTokenIds,
|
||||
tokensByGroup,
|
||||
tokenGroups
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Token settings
|
||||
* Shortcuts
|
||||
*/
|
||||
const [showMoreSettings, setShowMoreSettings] = useState(false);
|
||||
|
||||
const [tokenSettingChanges, setTokenSettingChanges] = useState({});
|
||||
|
||||
function handleTokenSettingsChange(key, value) {
|
||||
setTokenSettingChanges((prevChanges) => ({ ...prevChanges, [key]: value }));
|
||||
}
|
||||
|
||||
async function applyTokenChanges() {
|
||||
if (selectedTokenId && !isEmpty(tokenSettingChanges)) {
|
||||
// Ensure size value is positive
|
||||
let verifiedChanges = { ...tokenSettingChanges };
|
||||
if ("defaultSize" in verifiedChanges) {
|
||||
verifiedChanges.defaultSize = verifiedChanges.defaultSize || 1;
|
||||
}
|
||||
|
||||
await updateToken(selectedTokenId, verifiedChanges);
|
||||
setTokenSettingChanges({});
|
||||
function handleKeyDown({ key }) {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
if (key === "Shift") {
|
||||
setSelectMode("range");
|
||||
}
|
||||
if (key === "Control" || key === "Meta") {
|
||||
setSelectMode("multiple");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRequestClose() {
|
||||
await applyTokenChanges();
|
||||
onRequestClose();
|
||||
function handleKeyUp({ key }) {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
if (key === "Shift" && selectMode === "range") {
|
||||
setSelectMode("single");
|
||||
}
|
||||
if ((key === "Control" || key === "Meta") && selectMode === "multiple") {
|
||||
setSelectMode("single");
|
||||
}
|
||||
}
|
||||
|
||||
const selectedTokenWithChanges = { ...selectedToken, ...tokenSettingChanges };
|
||||
useKeyboard(handleKeyDown, handleKeyUp);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onRequestClose={handleRequestClose}
|
||||
onRequestClose={onRequestClose}
|
||||
style={{ maxWidth: "542px", width: "calc(100% - 16px)" }}
|
||||
>
|
||||
<ImageDrop onDrop={handleImagesUpload} dropText="Drop token to upload">
|
||||
@ -155,27 +200,48 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
|
||||
Edit or import a token
|
||||
</Label>
|
||||
<TokenTiles
|
||||
tokens={ownedTokens}
|
||||
tokens={tokensByGroup}
|
||||
groups={tokenGroups}
|
||||
onTokenAdd={openImageDialog}
|
||||
selectedToken={selectedTokenWithChanges}
|
||||
onTokenEdit={() => setIsEditModalOpen(true)}
|
||||
onTokensRemove={handleTokensRemove}
|
||||
selectedTokens={selectedTokens}
|
||||
onTokenSelect={handleTokenSelect}
|
||||
onTokenRemove={handleTokenRemove}
|
||||
/>
|
||||
<TokenSettings
|
||||
token={selectedTokenWithChanges}
|
||||
showMore={showMoreSettings}
|
||||
onSettingsChange={handleTokenSettingsChange}
|
||||
onShowMoreChange={setShowMoreSettings}
|
||||
selectMode={selectMode}
|
||||
onSelectModeChange={setSelectMode}
|
||||
search={search}
|
||||
onSearchChange={handleSearchChange}
|
||||
onTokensGroup={() => setIsGroupModalOpen(true)}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={imageLoading}
|
||||
onClick={handleRequestClose}
|
||||
onClick={onRequestClose}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</Flex>
|
||||
</ImageDrop>
|
||||
<EditTokenModal
|
||||
isOpen={isEditModalOpen}
|
||||
onDone={() => setIsEditModalOpen(false)}
|
||||
token={selectedTokens.length === 1 && selectedTokens[0]}
|
||||
/>
|
||||
<EditGroupModal
|
||||
isOpen={isGroupModalOpen}
|
||||
onChange={handleTokensGroup}
|
||||
groups={tokenGroups.filter(
|
||||
(group) => group !== "" && group !== "default"
|
||||
)}
|
||||
onRequestClose={() => setIsGroupModalOpen(false)}
|
||||
// Select the default group by testing whether all selected tokens are the same
|
||||
defaultGroup={
|
||||
selectedTokens.length > 0 &&
|
||||
selectedTokens
|
||||
.map((map) => map.group)
|
||||
.reduce((prev, curr) => (prev === curr ? curr : undefined))
|
||||
}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user