Files
grungnet/src/modals/SelectMapModal.js

377 lines
11 KiB
JavaScript
Raw Normal View History

2020-09-30 15:39:56 +10:00
import React, { useRef, useState, useContext, useEffect } from "react";
import { Button, Flex, Label } from "theme-ui";
import shortid from "shortid";
2020-09-30 15:39:56 +10:00
import Fuse from "fuse.js";
import EditMapModal from "./EditMapModal";
import Modal from "../components/Modal";
import MapTiles from "../components/map/MapTiles";
import ImageDrop from "../components/ImageDrop";
2020-07-17 16:19:59 +10:00
import LoadingOverlay from "../components/LoadingOverlay";
import blobToBuffer from "../helpers/blobToBuffer";
2020-09-30 13:58:43 +10:00
import useKeyboard from "../helpers/useKeyboard";
import MapDataContext from "../contexts/MapDataContext";
import AuthContext from "../contexts/AuthContext";
import { resizeImage } from "../helpers/image";
const defaultMapSize = 22;
const defaultMapProps = {
// Grid type
// TODO: add support for hex horizontal and hex vertical
gridType: "grid",
2020-05-31 16:25:05 +10:00
showGrid: false,
snapToGrid: true,
quality: "original",
};
const mapResolutions = [
{ size: 512, quality: 0.5, id: "low" },
{ size: 1024, quality: 0.6, id: "medium" },
{ size: 2048, quality: 0.7, id: "high" },
{ size: 4096, quality: 0.8, id: "ultra" },
];
function SelectMapModal({
isOpen,
onDone,
onMapChange,
onMapStateChange,
// The map currently being view in the map screen
currentMap,
}) {
const { userId } = useContext(AuthContext);
const {
ownedMaps,
mapStates,
addMap,
removeMaps,
resetMap,
updateMap,
} = useContext(MapDataContext);
2020-09-30 15:39:56 +10:00
const [filteredMaps, setFilteredMaps] = useState([]);
const [fuse, setFuse] = useState();
const [search, setSearch] = useState("");
// Update search index when maps change
useEffect(() => {
setFuse(new Fuse(ownedMaps, { keys: ["name"] }));
}, [ownedMaps]);
// Perform search when search changes
useEffect(() => {
if (search) {
setFilteredMaps(fuse.search(search).map((result) => result.item));
}
}, [search, ownedMaps, fuse]);
function handleSearchChange(event) {
setSearch(event.target.value);
}
const [imageLoading, setImageLoading] = useState(false);
// The map selected in the modal
const [selectedMapIds, setSelectedMapIds] = useState([]);
const selectedMaps = ownedMaps.filter((map) =>
selectedMapIds.includes(map.id)
);
const selectedMapStates = mapStates.filter((state) =>
selectedMapIds.includes(state.mapId)
);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const fileInputRef = useRef();
async function handleImagesUpload(files) {
for (let file of files) {
await handleImageUpload(file);
}
// Set file input to null to allow adding the same image 2 times in a row
fileInputRef.current.value = null;
}
async function handleImageUpload(file) {
if (!file) {
return Promise.reject();
}
let fileGridX = defaultMapSize;
let fileGridY = defaultMapSize;
let name = "Unknown Map";
if (file.name) {
// TODO: match all not supported on safari, find alternative
if (file.name.matchAll) {
// Match against a regex to find the grid size in the file name
// e.g. Cave 22x23 will return [["22x22", "22", "x", "23"]]
const gridMatches = [...file.name.matchAll(/(\d+) ?(x|X) ?(\d+)/g)];
if (gridMatches.length > 0) {
const lastMatch = gridMatches[gridMatches.length - 1];
const matchX = parseInt(lastMatch[1]);
const matchY = parseInt(lastMatch[3]);
if (!isNaN(matchX) && !isNaN(matchY)) {
fileGridX = matchX;
fileGridY = matchY;
}
}
}
// Remove file extension
name = file.name.replace(/\.[^/.]+$/, "");
// Removed grid size expression
name = name.replace(/(\[ ?|\( ?)?\d+ ?(x|X) ?\d+( ?\]| ?\))?/, "");
// Clean string
name = name.replace(/ +/g, " ");
name = name.trim();
}
let image = new Image();
setImageLoading(true);
const buffer = await blobToBuffer(file);
// Copy file to avoid permissions issues
const blob = new Blob([buffer]);
// Create and load the image temporarily to get its dimensions
const url = URL.createObjectURL(blob);
return new Promise((resolve, reject) => {
image.onload = async function () {
// Create resolutions
const resolutions = {};
for (let resolution of mapResolutions) {
if (Math.max(image.width, image.height) > resolution.size) {
const resized = await resizeImage(
image,
resolution.size,
file.type,
resolution.quality
);
const resizedBuffer = await blobToBuffer(resized.blob);
resolutions[resolution.id] = {
file: resizedBuffer,
width: resized.width,
height: resized.height,
type: "file",
id: resolution.id,
};
}
}
handleMapAdd({
// Save as a buffer to send with msgpack
file: buffer,
resolutions,
name,
type: "file",
gridX: fileGridX,
gridY: fileGridY,
width: image.width,
height: image.height,
id: shortid.generate(),
created: Date.now(),
lastModified: Date.now(),
lastUsed: Date.now(),
owner: userId,
...defaultMapProps,
});
setImageLoading(false);
URL.revokeObjectURL(url);
resolve();
};
image.onerror = reject;
image.src = url;
});
}
function openImageDialog() {
if (fileInputRef.current) {
fileInputRef.current.click();
}
}
async function handleMapAdd(map) {
await addMap(map);
setSelectedMapIds([map.id]);
}
async function handleMapsRemove() {
await removeMaps(selectedMapIds);
setSelectedMapIds([]);
// Removed the map from the map screen if needed
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) {
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":
2020-09-30 15:39:56 +10:00
// Use filtered maps if we have searched
const maps = search ? filteredMaps : ownedMaps;
// Add all items inbetween the previous selected map and the current selected
if (selectedMapIds.length > 0) {
2020-09-30 15:39:56 +10:00
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 (
2020-09-30 13:58:43 +10:00
let i = lastIndex + direction;
direction < 0 ? i >= mapIndex : i <= mapIndex;
i += direction
) {
2020-09-30 15:39:56 +10:00
const mapId = maps[i].id;
if (selectedMapIds.includes(mapId)) {
idsToRemove.push(mapId);
} else {
idsToAdd.push(mapId);
}
}
setSelectedMapIds((prev) => {
let ids = [...prev, ...idsToAdd];
2020-09-30 13:58:43 +10:00
return ids.filter((id) => !idsToRemove.includes(id));
});
} else {
setSelectedMapIds([map.id]);
}
break;
default:
setSelectedMapIds([]);
}
} else {
setSelectedMapIds([]);
}
}
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);
}
}
}
async function handleClose() {
onDone();
}
async function handleDone() {
2020-07-17 16:19:59 +10:00
if (imageLoading) {
return;
}
if (selectedMapIds.length === 1) {
// Update last used for cache invalidation
const lastUsed = Date.now();
await updateMap(selectedMapIds[0], { lastUsed });
onMapChange({ ...selectedMaps[0], lastUsed }, selectedMapStates[0]);
} else {
onMapChange(null, null);
2020-04-23 17:23:34 +10:00
}
onDone();
}
2020-09-30 13:58:43 +10:00
function handleKeyDown({ key }) {
if (key === "Shift") {
setSelectMode("range");
}
if (key === "Control" || key === "Meta") {
setSelectMode("multiple");
}
}
function handleKeyUp({ key }) {
if (key === "Shift" && selectMode === "range") {
setSelectMode("single");
}
if ((key === "Control" || key === "Meta") && selectMode === "multiple") {
setSelectMode("single");
}
}
useKeyboard(handleKeyDown, handleKeyUp);
return (
<Modal
isOpen={isOpen}
onRequestClose={handleClose}
style={{ maxWidth: "542px", width: "calc(100% - 16px)" }}
>
<ImageDrop onDrop={handleImagesUpload} dropText="Drop map to upload">
<input
onChange={(event) => handleImagesUpload(event.target.files)}
type="file"
accept="image/*"
style={{ display: "none" }}
multiple
ref={fileInputRef}
/>
<Flex
sx={{
flexDirection: "column",
}}
>
<Label pt={2} pb={1}>
Select or import a map
</Label>
<MapTiles
2020-09-30 15:39:56 +10:00
maps={search ? filteredMaps : ownedMaps}
2020-04-23 13:31:54 +10:00
onMapAdd={openImageDialog}
onMapEdit={() => setIsEditModalOpen(true)}
onMapsReset={handleMapsReset}
onMapsRemove={handleMapsRemove}
selectedMaps={selectedMaps}
selectedMapStates={selectedMapStates}
onMapSelect={handleMapSelect}
onDone={handleDone}
selectMode={selectMode}
onSelectModeChange={setSelectMode}
2020-09-30 15:39:56 +10:00
search={search}
onSearchChange={handleSearchChange}
2020-04-23 13:31:54 +10:00
/>
<Button
variant="primary"
disabled={imageLoading || selectedMapIds.length !== 1}
onClick={handleDone}
mt={2}
>
Select
</Button>
</Flex>
</ImageDrop>
2020-07-17 16:19:59 +10:00
{imageLoading && <LoadingOverlay bg="overlay" />}
<EditMapModal
isOpen={isEditModalOpen}
onDone={() => setIsEditModalOpen(false)}
map={selectedMaps.length === 1 && selectedMaps[0]}
mapState={selectedMapStates.length === 1 && selectedMapStates[0]}
/>
</Modal>
);
}
export default SelectMapModal;