Added search and groups to token select, refactored map search and group

This commit is contained in:
Mitchell McCaffrey 2020-10-01 22:32:21 +10:00
parent 0174acbde0
commit f28d1b6690
17 changed files with 614 additions and 366 deletions

View 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;

View File

@ -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";

View File

@ -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}>

View File

@ -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";

View File

@ -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";

View File

@ -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 }) {

View File

@ -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,
}}
<Tile
src={tokenSource}
title={token.name}
isSelected={isSelected}
onSelect={() => onTokenSelect(token)}
onEdit={() => onTokenEdit(token.id)}
large={large}
canEdit={canEdit}
badges={badges}
editTitle="Edit Token"
/>
<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>
);
}

View File

@ -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>
);
}

View File

@ -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,

View File

@ -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,

View File

@ -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
View 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([]);
}
}

View File

@ -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;

View 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;

View File

@ -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 [mapsByGroup, mapGroups] = useGroup(
ownedMaps,
filteredMaps,
!!search,
filteredMapScores
);
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);
});
}
/**
* 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]],
[]
function handleMapSelect(map) {
handleItemSelect(
map,
selectMode,
selectedMapIds,
setSelectedMapIds,
mapsByGroup,
mapGroups
);
// 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([]);
}
}
/**

View File

@ -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 }));
function handleKeyDown({ key }) {
if (!isOpen) {
return;
}
async function applyTokenChanges() {
if (selectedTokenId && !isEmpty(tokenSettingChanges)) {
// Ensure size value is positive
let verifiedChanges = { ...tokenSettingChanges };
if ("defaultSize" in verifiedChanges) {
verifiedChanges.defaultSize = verifiedChanges.defaultSize || 1;
if (key === "Shift") {
setSelectMode("range");
}
await updateToken(selectedTokenId, verifiedChanges);
setTokenSettingChanges({});
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>
);
}