Added map search UI and initial multi-select

This commit is contained in:
Mitchell McCaffrey 2020-09-30 12:30:33 +10:00
parent 78c86e6194
commit 670f047049
8 changed files with 258 additions and 88 deletions

39
src/components/Search.js Normal file
View File

@ -0,0 +1,39 @@
import React from "react";
import { Box, Input } from "theme-ui";
import SearchIcon from "../icons/SearchIcon";
function Search(props) {
return (
<Box sx={{ position: "relative", flexGrow: 1 }}>
<Input
sx={{
borderRadius: "0",
border: "none",
borderRight: "1px solid",
":focus": {
outline: "none",
},
paddingRight: "36px",
}}
placeholder="Search"
{...props}
/>
<Box
sx={{
position: "absolute",
right: "8px",
top: "50%",
transform: "translateY(-50%)",
height: "24px",
width: "24px",
pointerEvents: "none",
}}
>
<SearchIcon />
</Box>
</Box>
);
}
export default Search;

View File

@ -6,7 +6,15 @@ import EditMapIcon from "../../icons/EditMapIcon";
import useDataSource from "../../helpers/useDataSource";
import { mapSources as defaultMapSources, unknownSource } from "../../maps";
function MapTile({ map, isSelected, onMapSelect, onMapEdit, onDone, large }) {
function MapTile({
map,
isSelected,
onMapSelect,
onMapEdit,
onDone,
large,
canEdit,
}) {
const [isMapTileMenuOpen, setIsTileMenuOpen] = useState(false);
const isDefault = map.type === "default";
const mapSource = useDataSource(
@ -39,9 +47,7 @@ function MapTile({ map, isSelected, onMapSelect, onMapEdit, onDone, large }) {
onClick={(e) => {
e.stopPropagation();
setIsTileMenuOpen(false);
if (!isSelected) {
onMapSelect(map);
}
onMapSelect(map);
}}
onDoubleClick={(e) => {
if (!isMapTileMenuOpen) {
@ -97,8 +103,7 @@ function MapTile({ map, isSelected, onMapSelect, onMapEdit, onDone, large }) {
borderRadius: "4px",
}}
/>
{/* Show expand button only if both reset and remove is available */}
{isSelected && (
{canEdit && (
<Box sx={{ position: "absolute", top: 0, right: 0 }}>
<IconButton
aria-label="Edit Map"

View File

@ -6,34 +6,88 @@ import { useMedia } from "react-media";
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 MapTile from "./MapTile";
import Link from "../Link";
import Search from "../Search";
import DatabaseContext from "../../contexts/DatabaseContext";
function MapTiles({
maps,
selectedMap,
selectedMapState,
selectedMaps,
selectedMapStates,
onMapSelect,
onMapRemove,
onMapReset,
onMapsRemove,
onMapsReset,
onMapAdd,
onMapEdit,
onDone,
selectMode,
onSelectModeChange,
}) {
const { databaseStatus } = useContext(DatabaseContext);
const isSmallScreen = useMedia({ query: "(max-width: 500px)" });
const hasMapState =
selectedMapState &&
(Object.values(selectedMapState.tokens).length > 0 ||
selectedMapState.mapDrawActions.length > 0 ||
selectedMapState.fogDrawActions.length > 0);
let hasMapState = false;
if (selectedMapStates.length > 0) {
for (let state of selectedMapStates) {
if (
Object.values(state.tokens).length > 0 ||
state.mapDrawActions.length > 0 ||
state.fogDrawActions.length > 0
) {
hasMapState = true;
break;
}
}
}
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",
},
}}
onFocus={() => onMapSelect()}
>
<Search />
<IconButton
onClick={() =>
onSelectModeChange(selectMode === "single" ? "multiple" : "single")
}
aria-label={
selectMode === "single" ? "Select Multiple" : "Select Single"
}
title={selectMode === "single" ? "Select Multiple" : "Select Single"}
ml={1}
>
{selectMode === "single" ? (
<SelectMultipleIcon />
) : (
<SelectSingleIcon />
)}
</IconButton>
<IconButton
onClick={onMapAdd}
aria-label="Add Map"
title="Add Map"
mr={1}
>
<AddIcon />
</IconButton>
</Flex>
<SimpleBar style={{ maxHeight: "400px" }}>
<Flex
p={2}
@ -43,49 +97,10 @@ function MapTiles({
flexWrap: "wrap",
borderRadius: "4px",
}}
onClick={() => onMapSelect(null)}
onClick={() => onMapSelect()}
>
<Flex
onClick={onMapAdd}
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 Map"
title="Add Map"
>
<Flex
sx={{
width: "100%",
height: "100%",
position: "absolute",
top: 0,
left: 0,
justifyContent: "center",
alignItems: "center",
}}
>
<AddIcon large />
</Flex>
</Flex>
{maps.map((map) => {
const isSelected = selectedMap && map.id === selectedMap.id;
const isSelected = selectedMaps.includes(map);
return (
<MapTile
key={map.id}
@ -95,6 +110,7 @@ function MapTiles({
onMapEdit={onMapEdit}
onDone={onDone}
large={isSmallScreen}
canEdit={isSelected && selectMode === "single"}
/>
);
})}
@ -118,7 +134,7 @@ function MapTiles({
</Text>
</Box>
)}
{selectedMap && (
{selectedMaps.length > 0 && (
<Flex
sx={{
position: "absolute",
@ -132,13 +148,13 @@ function MapTiles({
<Close
title="Clear Selection"
aria-label="Clear Selection"
onClick={() => onMapSelect(null)}
onClick={() => onMapSelect()}
/>
<Flex>
<IconButton
aria-label="Reset Map"
title="Reset Map"
onClick={() => onMapReset(selectedMap.id)}
onClick={() => onMapsReset()}
disabled={!hasMapState}
>
<ResetMapIcon />
@ -146,7 +162,7 @@ function MapTiles({
<IconButton
aria-label="Remove Map"
title="Remove Map"
onClick={() => onMapRemove(selectedMap.id)}
onClick={() => onMapsRemove()}
>
<RemoveMapIcon />
</IconButton>

18
src/icons/SearchIcon.js Normal file
View File

@ -0,0 +1,18 @@
import React from "react";
function SearchIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 0 24 24"
width="24"
fill="currentcolor"
>
<path d="M0 0h24v24H0z" fill="none" />
<path d="M15.5 14h-.79l-.28-.27c1.2-1.4 1.82-3.31 1.48-5.34-.47-2.78-2.79-5-5.59-5.34-4.23-.52-7.79 3.04-7.27 7.27.34 2.8 2.56 5.12 5.34 5.59 2.03.34 3.94-.28 5.34-1.48l.27.28v.79l4.25 4.25c.41.41 1.08.41 1.49 0 .41-.41.41-1.08 0-1.49L15.5 14zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z" />
</svg>
);
}
export default SearchIcon;

View File

@ -1,6 +1,6 @@
import React from "react";
function SelectMapIcon() {
function SelectDiceIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
@ -15,4 +15,4 @@ function SelectMapIcon() {
);
}
export default SelectMapIcon;
export default SelectDiceIcon;

View File

@ -0,0 +1,18 @@
import React from "react";
function SelectMultipleIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 0 24 24"
width="24"
fill="currentcolor"
>
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M3 5h2V3c-1.1 0-2 .9-2 2zm0 8h2v-2H3v2zm4 8h2v-2H7v2zM3 9h2V7H3v2zm10-6h-2v2h2V3zm6 0v2h2c0-1.1-.9-2-2-2zM5 21v-2H3c0 1.1.9 2 2 2zm-2-4h2v-2H3v2zM9 3H7v2h2V3zm2 18h2v-2h-2v2zm8-8h2v-2h-2v2zm0 8c1.1 0 2-.9 2-2h-2v2zm0-12h2V7h-2v2zm0 8h2v-2h-2v2zm-4 4h2v-2h-2v2zm0-16h2V3h-2v2zM8 17h8c.55 0 1-.45 1-1V8c0-.55-.45-1-1-1H8c-.55 0-1 .45-1 1v8c0 .55.45 1 1 1zm1-8h6v6H9V9z" />
</svg>
);
}
export default SelectMultipleIcon;

View File

@ -0,0 +1,18 @@
import React from "react";
function SelectSingleIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 0 24 24"
width="24"
fill="currentcolor"
>
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M18 4H6c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm-1 14H7c-.55 0-1-.45-1-1V7c0-.55.45-1 1-1h10c.55 0 1 .45 1 1v10c0 .55-.45 1-1 1z" />
</svg>
);
}
export default SelectSingleIcon;

View File

@ -54,11 +54,13 @@ function SelectMapModal({
const [imageLoading, setImageLoading] = useState(false);
// The map selected in the modal
const [selectedMapId, setSelectedMapId] = useState(null);
const [selectedMapIds, setSelectedMapIds] = useState([]);
const selectedMap = ownedMaps.find((map) => map.id === selectedMapId);
const selectedMapState = mapStates.find(
(state) => state.mapId === selectedMapId
const selectedMaps = ownedMaps.filter((map) =>
selectedMapIds.includes(map.id)
);
const selectedMapStates = mapStates.filter((state) =>
selectedMapIds.includes(state.mapId)
);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
@ -171,31 +173,83 @@ function SelectMapModal({
async function handleMapAdd(map) {
await addMap(map);
setSelectedMapId(map.id);
setSelectedMapIds([map.id]);
}
async function handleMapRemove(id) {
await removeMap(id);
setSelectedMapId(null);
async function handleMapsRemove() {
for (let id of selectedMapIds) {
await removeMap(id);
}
setSelectedMapIds([]);
// Removed the map from the map screen if needed
if (currentMap && currentMap.id === selectedMapId) {
if (currentMap && selectedMapIds.includes(currentMap.id)) {
onMapChange(null, null);
}
}
// Either single, multiple or range
const [selectMode, setSelectMode] = useState("single");
async function handleMapSelect(map) {
if (map) {
setSelectedMapId(map.id);
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":
// Add all items inbetween the previous selected map and the current selected
if (selectedMapIds.length > 0) {
const mapIndex = ownedMaps.findIndex((m) => m.id === map.id);
const lastIndex = ownedMaps.findIndex(
(m) => m.id === selectedMapIds[selectedMapIds.length - 1]
);
let idsToAdd = [];
let idsToRemove = [];
const direction = mapIndex > lastIndex ? 1 : -1;
for (
let i = mapIndex;
direction > 0 ? i >= lastIndex : i <= lastIndex;
i += direction
) {
const mapId = ownedMaps[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 {
setSelectedMapId(null);
setSelectedMapIds([]);
}
}
async function handleMapReset(id) {
const newState = await resetMap(id);
// Reset the state of the current map if needed
if (currentMap && currentMap.id === selectedMapId) {
onMapStateChange(newState);
async function handleMapsReset() {
for (let id of selectedMapIds) {
const newState = await resetMap(id);
// Reset the state of the current map if needed
if (currentMap && currentMap.id === id) {
onMapStateChange(newState);
}
}
}
@ -207,11 +261,11 @@ function SelectMapModal({
if (imageLoading) {
return;
}
if (selectedMapId) {
if (selectedMapIds.length === 1) {
// Update last used for cache invalidation
const lastUsed = Date.now();
await updateMap(selectedMapId, { lastUsed });
onMapChange({ ...selectedMap, lastUsed }, selectedMapState);
await updateMap(selectedMapIds[0], { lastUsed });
onMapChange({ ...selectedMaps[0], lastUsed }, selectedMapStates[0]);
} else {
onMapChange(null, null);
}
@ -245,16 +299,18 @@ function SelectMapModal({
maps={ownedMaps}
onMapAdd={openImageDialog}
onMapEdit={() => setIsEditModalOpen(true)}
onMapReset={handleMapReset}
onMapRemove={handleMapRemove}
selectedMap={selectedMap}
selectedMapState={selectedMapState}
onMapsReset={handleMapsReset}
onMapsRemove={handleMapsRemove}
selectedMaps={selectedMaps}
selectedMapStates={selectedMapStates}
onMapSelect={handleMapSelect}
onDone={handleDone}
selectMode={selectMode}
onSelectModeChange={setSelectMode}
/>
<Button
variant="primary"
disabled={imageLoading || !selectedMapId}
disabled={imageLoading || selectedMapIds.length !== 1}
onClick={handleDone}
mt={2}
>
@ -266,8 +322,8 @@ function SelectMapModal({
<EditMapModal
isOpen={isEditModalOpen}
onDone={() => setIsEditModalOpen(false)}
map={selectedMap}
mapState={selectedMapState}
map={selectedMaps.length === 1 && selectedMaps[0]}
mapState={selectedMapStates.length === 1 && selectedMapStates[0]}
/>
</Modal>
);