diff --git a/src/components/FilterBar.js b/src/components/FilterBar.js new file mode 100644 index 0000000..cb6aa1d --- /dev/null +++ b/src/components/FilterBar.js @@ -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 ( + + + + onSelectModeChange("single")} + isSelected={selectMode === "single"} + > + + + onSelectModeChange("multiple")} + isSelected={selectMode === "multiple" || selectMode === "range"} + > + + + + + + + + ); +} + +export default FilterBar; diff --git a/src/components/map/controls/RadioIconButton.js b/src/components/RadioIconButton.js similarity index 100% rename from src/components/map/controls/RadioIconButton.js rename to src/components/RadioIconButton.js diff --git a/src/components/map/MapControls.js b/src/components/map/MapControls.js index 59c8c87..fbd5cf2 100644 --- a/src/components/map/MapControls.js +++ b/src/components/map/MapControls.js @@ -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"; diff --git a/src/components/map/MapTiles.js b/src/components/map/MapTiles.js index 4198bc1..61915a3 100644 --- a/src/components/map/MapTiles.js +++ b/src/components/map/MapTiles.js @@ -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 ( - onMapSelect()} - > - - - onSelectModeChange("single")} - isSelected={selectMode === "single"} - > - - - onSelectModeChange("multiple")} - isSelected={selectMode === "multiple" || selectMode === "range"} - > - - - - - - - + search={search} + onSearchChange={onSearchChange} + selectMode={selectMode} + onSelectModeChange={onSelectModeChange} + onAdd={onMapAdd} + addTitle="Add Map" + /> onMapSelect()} > - {/* Render ungrouped maps, grouped maps then default maps */} {groups.map((group) => ( ); } diff --git a/src/contexts/MapDataContext.js b/src/contexts/MapDataContext.js index fe25424..fe81c14 100644 --- a/src/contexts/MapDataContext.js +++ b/src/contexts/MapDataContext.js @@ -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, diff --git a/src/contexts/TokenDataContext.js b/src/contexts/TokenDataContext.js index aee9f79..286fc1e 100644 --- a/src/contexts/TokenDataContext.js +++ b/src/contexts/TokenDataContext.js @@ -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, diff --git a/src/database.js b/src/database.js index 91be7af..d2c92e1 100644 --- a/src/database.js +++ b/src/database.js @@ -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 diff --git a/src/helpers/select.js b/src/helpers/select.js new file mode 100644 index 0000000..eb81b95 --- /dev/null +++ b/src/helpers/select.js @@ -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([]); + } +} diff --git a/src/modals/EditMapModal.js b/src/modals/EditMapModal.js index 7341529..6a11475 100644 --- a/src/modals/EditMapModal.js +++ b/src/modals/EditMapModal.js @@ -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; diff --git a/src/modals/EditTokenModal.js b/src/modals/EditTokenModal.js new file mode 100644 index 0000000..6bf2c37 --- /dev/null +++ b/src/modals/EditTokenModal.js @@ -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 ( + + + + + + + + ); +} + +export default EditTokenModal; diff --git a/src/modals/SelectMapModal.js b/src/modals/SelectMapModal.js index afdcf7d..86edd84 100644 --- a/src/modals/SelectMapModal.js +++ b/src/modals/SelectMapModal.js @@ -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 + ); } /** diff --git a/src/modals/SelectTokensModal.js b/src/modals/SelectTokensModal.js index d355f3c..82080a9 100644 --- a/src/modals/SelectTokensModal.js +++ b/src/modals/SelectTokensModal.js @@ -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 ( @@ -155,27 +200,48 @@ function SelectTokensModal({ isOpen, onRequestClose }) { Edit or import a token setIsEditModalOpen(true)} + onTokensRemove={handleTokensRemove} + selectedTokens={selectedTokens} onTokenSelect={handleTokenSelect} - onTokenRemove={handleTokenRemove} - /> - setIsGroupModalOpen(true)} /> + setIsEditModalOpen(false)} + token={selectedTokens.length === 1 && selectedTokens[0]} + /> + 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)) + } + /> ); }