Merge branch 'master' into typescript

This commit is contained in:
Mitchell McCaffrey
2021-07-02 15:54:54 +10:00
157 changed files with 8114 additions and 4055 deletions

View File

@@ -1,59 +1,35 @@
import { useEffect, useState } from "react";
import { Button, Flex, Label } from "theme-ui";
import { useState } from "react";
import { Button, Flex, Label, useThemeUI } from "theme-ui";
import SimpleBar from "simplebar-react";
import Modal from "../components/Modal";
import MapSettings from "../components/map/MapSettings";
import MapEditor from "../components/map/MapEditor";
import LoadingOverlay from "../components/LoadingOverlay";
import { useMapData } from "../contexts/MapDataContext";
import { isEmpty } from "../helpers/shared";
import { getGridDefaultInset } from "../helpers/grid";
import useResponsiveLayout from "../hooks/useResponsiveLayout";
import { MapState } from "../components/map/Map";
import { Map, MapState } from "../components/map/Map";
type EditMapProps = {
isOpen: boolean,
onDone: any,
mapId: string
}
isOpen: boolean;
onDone: () => void;
map: Map;
mapState: MapState;
onUpdateMap: (id: string, update: Partial<Map>) => void;
onUpdateMapState: (id: string, update: Partial<MapState>) => void;
};
function EditMapModal({ isOpen, onDone, mapId }: EditMapProps) {
const {
updateMap,
updateMapState,
getMap,
getMapFromDB,
getMapStateFromDB,
} = useMapData();
const [isLoading, setIsLoading] = useState(true);
const [map, setMap] = useState<any>();
const [mapState, setMapState] = useState<MapState>();
// Load full map when modal is opened
useEffect(() => {
async function loadMap() {
setIsLoading(true);
let loadingMap = getMap(mapId);
// Ensure file is loaded for map
if (loadingMap?.type === "file" && !loadingMap?.file) {
loadingMap = await getMapFromDB(mapId);
}
const mapState = await getMapStateFromDB(mapId);
setMap(loadingMap);
setMapState(mapState as MapState);
setIsLoading(false);
}
if (isOpen && mapId) {
loadMap();
} else {
setMap(undefined);
setMapState(undefined);
}
}, [isOpen, mapId, getMapFromDB, getMapStateFromDB, getMap]);
function EditMapModal({
isOpen,
onDone,
map,
mapState,
onUpdateMap,
onUpdateMapState,
}: EditMapProps) {
const { theme } = useThemeUI();
function handleClose() {
setMapSettingChanges({});
@@ -119,8 +95,8 @@ function EditMapModal({ isOpen, onDone, mapId }: EditMapProps) {
}
}
}
await updateMap(map.id, mapSettingChanges);
await updateMapState(map.id, mapStateSettingChanges);
await onUpdateMap(map.id, mapSettingChanges);
await onUpdateMapState(map.id, mapStateSettingChanges);
setMapSettingChanges({});
setMapStateSettingChanges({});
@@ -136,50 +112,58 @@ function EditMapModal({ isOpen, onDone, mapId }: EditMapProps) {
...mapStateSettingChanges,
};
const [showMoreSettings, setShowMoreSettings] = useState(true);
const layout = useResponsiveLayout();
if (!map) {
return null;
}
return (
<Modal
isOpen={isOpen}
onRequestClose={handleClose}
style={{ content: {maxWidth: layout.modalSize, width: "calc(100% - 16px)"} }}
style={{
content: {
maxWidth: layout.modalSize,
width: "calc(100% - 16px)",
padding: 0,
display: "flex",
overflow: "hidden",
},
}}
>
<Flex
sx={{
flexDirection: "column",
width: "100%",
}}
>
<Label pt={2} pb={1}>
<Label pt={2} pb={1} px={3}>
Edit map
</Label>
{isLoading || !map ? (
<Flex
sx={{
width: "100%",
height: layout.screenSize === "large" ? "500px" : "300px",
position: "relative",
}}
bg="muted"
>
<LoadingOverlay />
</Flex>
) : (
<SimpleBar
style={{
minHeight: 0,
padding: "16px",
backgroundColor: theme.colors.muted,
margin: "0 8px",
height: "100%",
}}
>
<MapEditor
map={selectedMapWithChanges}
onSettingsChange={handleMapSettingsChange}
/>
)}
<MapSettings
map={selectedMapWithChanges}
mapState={selectedMapStateWithChanges}
onSettingsChange={handleMapSettingsChange}
onStateSettingsChange={handleMapStateSettingsChange}
showMore={showMoreSettings}
onShowMoreChange={setShowMoreSettings}
/>
<Button onClick={handleSave}>Save</Button>
<MapSettings
map={selectedMapWithChanges}
mapState={selectedMapStateWithChanges}
onSettingsChange={handleMapSettingsChange}
onStateSettingsChange={handleMapStateSettingsChange}
/>
</SimpleBar>
<Button m={3} onClick={handleSave}>
Save
</Button>
</Flex>
</Modal>
);

View File

@@ -1,42 +1,30 @@
import { useState, useEffect } from "react";
import { Button, Flex, Label } from "theme-ui";
import { useState } from "react";
import { Button, Flex, Label, useThemeUI } from "theme-ui";
import SimpleBar from "simplebar-react";
import Modal from "../components/Modal";
import TokenSettings from "../components/token/TokenSettings";
import TokenPreview from "../components/token/TokenPreview";
import LoadingOverlay from "../components/LoadingOverlay";
import { useTokenData } from "../contexts/TokenDataContext";
import { isEmpty } from "../helpers/shared";
import useResponsiveLayout from "../hooks/useResponsiveLayout";
import { Token } from "../tokens";
type EditModalProps = {
isOpen: boolean,
onDone: () => void,
tokenId: string,
type EditModalProps = {
isOpen: boolean;
onDone: () => void;
token: Token;
onUpdateToken: (id: string, update: Partial<Token>) => void;
};
function EditTokenModal({ isOpen, onDone, tokenId }: EditModalProps) {
const { updateToken, getTokenFromDB } = useTokenData();
const [isLoading, setIsLoading] = useState(true);
const [token, setToken] = useState<Token>();
useEffect(() => {
async function loadToken() {
setIsLoading(true);
setToken(await getTokenFromDB(tokenId));
setIsLoading(false);
}
if (isOpen && tokenId) {
loadToken();
} else {
setToken(undefined);
}
}, [isOpen, tokenId, getTokenFromDB]);
function EditTokenModal({
isOpen,
onDone,
token,
onUpdateToken,
}: EditModalProps) {
const { theme } = useThemeUI();
function handleClose() {
setTokenSettingChanges({});
@@ -48,9 +36,11 @@ function EditTokenModal({ isOpen, onDone, tokenId }: EditModalProps) {
onDone();
}
const [tokenSettingChanges, setTokenSettingChanges] = useState<any>({});
const [tokenSettingChanges, setTokenSettingChanges] = useState<
Partial<Token>
>({});
function handleTokenSettingsChange(key: any, value: any) {
function handleTokenSettingsChange(key: string, value: Pick<Token, any>) {
setTokenSettingChanges((prevChanges: any) => ({
...prevChanges,
[key]: value,
@@ -65,7 +55,7 @@ function EditTokenModal({ isOpen, onDone, tokenId }: EditModalProps) {
verifiedChanges.defaultSize = verifiedChanges.defaultSize || 1;
}
await updateToken(token.id, verifiedChanges);
await onUpdateToken(token.id, verifiedChanges);
setTokenSettingChanges({});
}
}
@@ -77,41 +67,51 @@ function EditTokenModal({ isOpen, onDone, tokenId }: EditModalProps) {
const layout = useResponsiveLayout();
if (!token) {
return null;
}
return (
<Modal
isOpen={isOpen}
onRequestClose={handleClose}
style={{
content: { maxWidth: layout.modalSize, width: "calc(100% - 16px)" },
content: {
maxWidth: layout.modalSize,
width: "calc(100% - 16px)",
padding: 0,
display: "flex",
overflow: "hidden",
},
}}
>
<Flex
sx={{
flexDirection: "column",
width: "100%",
}}
>
<Label pt={2} pb={1}>
<Label pt={2} pb={1} px={3}>
Edit token
</Label>
{isLoading || !token ? (
<Flex
sx={{
width: "100%",
height: layout.screenSize === "large" ? "500px" : "300px",
position: "relative",
}}
bg="muted"
>
<LoadingOverlay />
</Flex>
) : (
<SimpleBar
style={{
minHeight: 0,
padding: "16px",
backgroundColor: theme.colors.muted,
margin: "0 8px",
height: "100%",
}}
>
<TokenPreview token={selectedTokenWithChanges} />
)}
<TokenSettings
token={selectedTokenWithChanges}
onSettingsChange={handleTokenSettingsChange}
/>
<Button onClick={handleSave}>Save</Button>
<TokenSettings
token={selectedTokenWithChanges}
onSettingsChange={handleTokenSettingsChange}
/>
</SimpleBar>
<Button m={3} onClick={handleSave}>
Save
</Button>
</Flex>
</Modal>
);

View File

@@ -0,0 +1,51 @@
import React, { useState, useRef, useEffect } from "react";
import { Box, Input, Button, Label, Flex } from "theme-ui";
import Modal from "../components/Modal";
function GroupNameModal({ isOpen, name, onSubmit, onRequestClose }) {
const [tmpName, setTempName] = useState(name);
useEffect(() => {
setTempName(name);
}, [name]);
function handleChange(event) {
setTempName(event.target.value);
}
function handleSubmit(event) {
event.preventDefault();
onSubmit(tmpName);
}
const inputRef = useRef();
function focusInput() {
inputRef.current && inputRef.current.focus();
}
return (
<Modal
isOpen={isOpen}
onRequestClose={onRequestClose}
onAfterOpen={focusInput}
>
<Box as="form" onSubmit={handleSubmit}>
<Label py={2} htmlFor="name">
Group Name
</Label>
<Input
id="name"
value={tmpName}
onChange={handleChange}
ref={inputRef}
/>
<Flex py={2}>
<Button sx={{ flexGrow: 1 }}>Save</Button>
</Flex>
</Box>
</Modal>
);
}
export default GroupNameModal;

View File

@@ -3,6 +3,7 @@ import { Box, Label, Text, Button, Flex } from "theme-ui";
import { saveAs } from "file-saver";
import * as Comlink from "comlink";
import shortid from "shortid";
import { v4 as uuid } from "uuid";
import { useToasts } from "react-toast-notifications";
import Modal from "../components/Modal";
@@ -10,19 +11,33 @@ import LoadingOverlay from "../components/LoadingOverlay";
import LoadingBar from "../components/LoadingBar";
import ErrorBanner from "../components/banner/ErrorBanner";
import { useAuth } from "../contexts/AuthContext";
import { useUserId } from "../contexts/UserIdContext";
import { useDatabase } from "../contexts/DatabaseContext";
import SelectDataModal from "./SelectDataModal";
import { getDatabase } from "../database";
import { Map, MapState, TokenState } from "../components/map/Map";
import { Token } from "../tokens";
const importDBName = "OwlbearRodeoImportDB";
function ImportExportModal({ isOpen, onRequestClose }: { isOpen: boolean, onRequestClose: () => void}) {
class MissingAssetError extends Error {
constructor(message: string) {
super(message);
this.name = "MissingAssetError";
}
}
function ImportExportModal({
isOpen,
onRequestClose,
}: {
isOpen: boolean;
onRequestClose: () => void;
}) {
const { worker } = useDatabase();
const { userId } = useAuth();
const userId = useUserId();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error>();
@@ -34,7 +49,7 @@ function ImportExportModal({ isOpen, onRequestClose }: { isOpen: boolean, onRequ
const [showExportSelector, setShowExportSelector] = useState(false);
const { addToast } = useToasts();
function addSuccessToast(message: string, maps: any, tokens: TokenState[]) {
function addSuccessToast(message: string, maps: Map[], tokens: Token[]) {
const mapText = `${maps.length} map${maps.length > 1 ? "s" : ""}`;
const tokenText = `${tokens.length} token${tokens.length > 1 ? "s" : ""}`;
if (maps.length > 0 && tokens.length > 0) {
@@ -54,7 +69,13 @@ function ImportExportModal({ isOpen, onRequestClose }: { isOpen: boolean, onRequ
const loadingProgressRef = useRef(0);
function handleDBProgress({ completedRows, totalRows }: { completedRows: number, totalRows: number }) {
function handleDBProgress({
completedRows,
totalRows,
}: {
completedRows: number;
totalRows: number;
}) {
loadingProgressRef.current = completedRows / totalRows;
}
@@ -81,6 +102,7 @@ function ImportExportModal({ isOpen, onRequestClose }: { isOpen: boolean, onRequ
)
);
} else {
console.error(e);
setError(e);
}
}
@@ -122,7 +144,12 @@ function ImportExportModal({ isOpen, onRequestClose }: { isOpen: boolean, onRequ
setShowImportSelector(false);
}
async function handleImportSelectorConfirm(checkedMaps: any, checkedTokens: TokenState[]) {
async function handleImportSelectorConfirm(
checkedMaps: Map[],
checkedTokens: Token[],
checkedMapGroups: any[],
checkedTokenGroups: any[]
) {
setIsLoading(true);
backgroundTaskRunningRef.current = true;
setShowImportSelector(false);
@@ -132,25 +159,46 @@ function ImportExportModal({ isOpen, onRequestClose }: { isOpen: boolean, onRequ
const db = getDatabase({});
try {
// Keep track of a mapping of old token ids to new ones to apply them to the map states
let newTokenIds: {[id: string]: string} = {};
let newTokenIds: Record<string, string> = {};
// Mapping of old asset ids to new asset ids
let newAssetIds: Record<string, string> = {};
// Mapping of old maps ids to new map ids
let newMapIds: Record<string, string> = {};
let newTokens: Token[] = [];
if (checkedTokens.length > 0) {
const tokenIds = checkedTokens.map((token) => token.id);
const tokensToAdd: TokenState[] = await importDB.table("tokens").bulkGet(tokenIds);
let newTokens: TokenState[] = [];
const tokensToAdd = await importDB.table("tokens").bulkGet(tokenIds);
for (let token of tokensToAdd) {
const newId = shortid.generate();
// Generate new ids
const newId = uuid();
newTokenIds[token.id] = newId;
// Generate new id and change owner
newTokens.push({ ...token, id: newId, owner: userId });
if (token.type === "default") {
newTokens.push({ ...token, id: newId, owner: userId });
} else {
const newFileId = uuid();
const newThumbnailId = uuid();
newAssetIds[token.file] = newFileId;
newAssetIds[token.thumbnail] = newThumbnailId;
// Change ids and owner
newTokens.push({
...token,
id: newId,
owner: userId,
file: newFileId,
thumbnail: newThumbnailId,
});
}
}
await db.table("tokens").bulkAdd(newTokens);
}
let newMaps: Map[] = [];
let newStates: MapState[] = [];
if (checkedMaps.length > 0) {
const mapIds = checkedMaps.map((map: any) => map.id);
const mapsToAdd = await importDB.table("maps").bulkGet(mapIds);
let newMaps = [];
let newStates = [];
for (let map of mapsToAdd) {
let state: MapState = await importDB.table("states").get(map.id);
// Apply new token ids to imported state
@@ -159,19 +207,145 @@ function ImportExportModal({ isOpen, onRequestClose }: { isOpen: boolean, onRequ
state.tokens[tokenState.id].tokenId =
newTokenIds[tokenState.tokenId];
}
// Change token state file asset id
if (tokenState.type === "file" && tokenState.file in newAssetIds) {
state.tokens[tokenState.id].file = newAssetIds[tokenState.file];
}
// Change token state owner if owned by the user of the map
if (tokenState.owner === map.owner) {
state.tokens[tokenState.id].owner = userId;
}
}
const newId = shortid.generate();
// Generate new id and change owner
newMaps.push({ ...map, id: newId, owner: userId });
// Generate new ids
const newId = uuid();
newMapIds[map.id] = newId;
if (map.type === "default") {
newMaps.push({ ...map, id: newId, owner: userId });
} else {
const newFileId = uuid();
const newThumbnailId = uuid();
newAssetIds[map.file] = newFileId;
newAssetIds[map.thumbnail] = newThumbnailId;
const newResolutionIds: Record<string, string> = {};
for (let res of Object.keys(map.resolutions)) {
newResolutionIds[res] = uuid();
newAssetIds[map.resolutions[res]] = newResolutionIds[res];
}
// Change ids and owner
newMaps.push({
...map,
id: newId,
owner: userId,
file: newFileId,
thumbnail: newThumbnailId,
resolutions: newResolutionIds,
});
}
newStates.push({ ...state, mapId: newId });
}
await db.table("maps").bulkAdd(newMaps);
await db.table("states").bulkAdd(newStates);
}
// Add assets with new ids
const assetsToAdd = await importDB
.table("assets")
.bulkGet(Object.keys(newAssetIds));
let newAssets: any[] = [];
for (let asset of assetsToAdd) {
if (asset) {
newAssets.push({
...asset,
id: newAssetIds[asset.id],
owner: userId,
});
} else {
throw new MissingAssetError("Import missing assets");
}
}
// Add map groups with new ids
let newMapGroups: any[] = [];
if (checkedMapGroups.length > 0) {
for (let group of checkedMapGroups) {
if (group.type === "item") {
newMapGroups.push({ ...group, id: newMapIds[group.id] });
} else {
newMapGroups.push({
...group,
id: uuid(),
items: group.items.map((item) => ({
...item,
id: newMapIds[item.id],
})),
});
}
}
}
// Add token groups with new ids
let newTokenGroups: any[] = [];
if (checkedTokenGroups.length > 0) {
for (let group of checkedTokenGroups) {
if (group.type === "item") {
newTokenGroups.push({ ...group, id: newTokenIds[group.id] });
} else {
newTokenGroups.push({
...group,
id: uuid(),
items: group.items.map((item: any) => ({
...item,
id: newTokenIds[item.id],
})),
});
}
}
}
db.transaction(
"rw",
[
db.table("tokens"),
db.table("maps"),
db.table("states"),
db.table("assets"),
db.table("groups"),
],
async () => {
if (newTokens.length > 0) {
await db.table("tokens").bulkAdd(newTokens);
}
if (newMaps.length > 0) {
await db.table("maps").bulkAdd(newMaps);
}
if (newStates.length > 0) {
await db.table("states").bulkAdd(newStates);
}
if (newAssets.length > 0) {
await db.table("assets").bulkAdd(newAssets);
}
if (newMapGroups.length > 0) {
const mapGroup = await db.table("groups").get("maps");
await db
.table("groups")
.update("maps", { items: [...newMapGroups, ...mapGroup.items] });
}
if (newTokenGroups.length > 0) {
const tokenGroup = await db.table("groups").get("tokens");
await db.table("groups").update("tokens", {
items: [...newTokenGroups, ...tokenGroup.items],
});
}
}
);
addSuccessToast("Imported", checkedMaps, checkedTokens);
} catch (e) {
console.error(e);
setError(new Error("Unable to import data"));
if (e instanceof MissingAssetError) {
setError(e);
} else {
setError(new Error("Unable to import data"));
}
}
await importDB.delete();
importDB.close();
@@ -180,25 +354,23 @@ function ImportExportModal({ isOpen, onRequestClose }: { isOpen: boolean, onRequ
backgroundTaskRunningRef.current = false;
}
function exportSelectorFilter(table: any, value: Map | TokenState) {
// Only show owned maps and tokens
if (table === "maps" || table === "tokens") {
if (value.owner === userId) {
return true;
}
}
// Allow all states so tokens can be checked against maps
if (table === "states") {
return true;
}
return false;
function exportSelectorFilter(table: string) {
return (
table === "maps" ||
table === "tokens" ||
table === "states" ||
table === "groups"
);
}
async function handleExportSelectorClose() {
setShowExportSelector(false);
}
async function handleExportSelectorConfirm(checkedMaps: Map[], checkedTokens: TokenState[]) {
async function handleExportSelectorConfirm(
checkedMaps: Map[],
checkedTokens: TokenState[]
) {
setShowExportSelector(false);
setIsLoading(true);
backgroundTaskRunningRef.current = true;
@@ -216,6 +388,7 @@ function ImportExportModal({ isOpen, onRequestClose }: { isOpen: boolean, onRequ
saveAs(blob, `${shortid.generate()}.owlbear`);
addSuccessToast("Exported", checkedMaps, checkedTokens);
} catch (e) {
console.error(e);
setError(e);
}
setIsLoading(false);
@@ -239,7 +412,9 @@ function ImportExportModal({ isOpen, onRequestClose }: { isOpen: boolean, onRequ
Select import or export then select the data you wish to use
</Text>
<input
onChange={(event) => event.target.files && handleImportDatabase(event.target.files[0])}
onChange={(event) =>
event.target.files && handleImportDatabase(event.target.files[0])
}
type="file"
accept=".owlbear"
style={{ display: "none" }}

View File

@@ -1,20 +1,29 @@
import { ChangeEvent, useEffect, useState } from "react";
import { Box, Label, Flex, Button, Text, Checkbox, Divider } from "theme-ui";
import React, { useEffect, useState } from "react";
import { Box, Label, Flex, Button, Text, Checkbox } from "theme-ui";
import SimpleBar from "simplebar-react";
import Modal from "../components/Modal";
import LoadingOverlay from "../components/LoadingOverlay";
import Divider from "../components/Divider";
import { getDatabase } from "../database";
import { Props } from "react-modal";
type SelectDataProps = Props & {
onConfirm: any,
confirmText: string,
label: string,
databaseName: string,
filter: any,
}
type SelectDataProps = {
isOpen: boolean;
onRequestClose: () => void;
onConfirm: any;
confirmText: string;
label: string;
databaseName: string;
filter: any;
};
type SelectData = {
name: string;
id: string;
type: "default" | "file";
checked: boolean;
};
function SelectDataModal({
isOpen,
@@ -25,9 +34,13 @@ function SelectDataModal({
databaseName,
filter,
}: SelectDataProps) {
const [maps, setMaps] = useState<any>({});
const [tokensByMap, setTokensByMap] = useState<any>({});
const [tokens, setTokens] = useState<any>({});
const [maps, setMaps] = useState<Record<string, SelectData>>({});
const [mapGroups, setMapGroups] = useState<any[]>([]);
const [tokensByMap, setTokensByMap] = useState<Record<string, Set<string>>>(
{}
);
const [tokens, setTokens] = useState<Record<string, SelectData>>({});
const [tokenGroups, setTokenGroups] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(false);
const hasMaps = Object.values(maps).length > 0;
@@ -38,14 +51,19 @@ function SelectDataModal({
if (isOpen && databaseName) {
setIsLoading(true);
const db = getDatabase({ addons: [] }, databaseName);
let loadedMaps: any = [];
let loadedTokensByMap: any = {};
let loadedTokens: any = [];
let loadedMaps: Record<string, SelectData> = {};
let loadedTokensByMap: Record<string, Set<string>> = {};
let loadedTokens: Record<string, SelectData> = {};
await db
.table("maps")
.filter((map) => filter("maps", map, map.id))
.each((map) => {
loadedMaps[map.id] = { name: map.name, id: map.id, checked: true };
loadedMaps[map.id] = {
name: map.name,
id: map.id,
type: map.type,
checked: true,
};
});
await db
.table("states")
@@ -65,17 +83,27 @@ function SelectDataModal({
loadedTokens[token.id] = {
name: token.name,
id: token.id,
type: token.type,
checked: true,
};
});
const mapGroup = await db.table("groups").get("maps");
const tokenGroup = await db.table("groups").get("tokens");
db.close();
setMaps(loadedMaps);
setMapGroups(mapGroup.items);
setTokensByMap(loadedTokensByMap);
setTokenGroups(tokenGroup.items);
setTokens(loadedTokens);
setIsLoading(false);
} else {
setMaps({});
setTokens({});
setTokenGroups([]);
setMapGroups([]);
setTokensByMap({});
}
}
loadData();
@@ -101,7 +129,7 @@ function SelectDataModal({
setTokens((prevTokens: any) => {
let newTokens = { ...prevTokens };
for (let id in newTokens) {
if (id in tokensUsed) {
if (id in tokensUsed && newTokens[id].type !== "default") {
newTokens[id].checked = true;
}
}
@@ -109,17 +137,45 @@ function SelectDataModal({
});
}, [maps, tokensByMap]);
function handleConfirm() {
let checkedMaps = Object.values(maps).filter((map: any) => map.checked);
let checkedTokens = Object.values(tokens).filter((token: any) => token.checked);
onConfirm(checkedMaps, checkedTokens);
function getCheckedGroups(groups: any[], data: Record<string, SelectData>) {
let checkedGroups = [];
for (let group of groups) {
if (group.type === "item") {
if (data[group.id] && data[group.id].checked) {
checkedGroups.push(group);
}
} else {
let items = [];
for (let item of group.items) {
if (data[item.id] && data[item.id].checked) {
items.push(item);
}
}
if (items.length > 0) {
checkedGroups.push({ ...group, items });
}
}
}
return checkedGroups;
}
function handleSelectMapsChanged(event: ChangeEvent<HTMLInputElement>) {
setMaps((prevMaps: any) => {
function handleConfirm() {
let checkedMaps = Object.values(maps).filter((map) => map.checked);
let checkedTokens = Object.values(tokens).filter((token) => token.checked);
let checkedMapGroups = getCheckedGroups(mapGroups, maps);
let checkedTokenGroups = getCheckedGroups(tokenGroups, tokens);
onConfirm(checkedMaps, checkedTokens, checkedMapGroups, checkedTokenGroups);
}
function handleMapsChanged(
event: React.ChangeEvent<HTMLInputElement>,
maps: SelectData[]
) {
setMaps((prevMaps) => {
let newMaps = { ...prevMaps };
for (let id in newMaps) {
newMaps[id].checked = event.target.checked;
for (let map of maps) {
newMaps[map.id].checked = event.target.checked;
}
return newMaps;
});
@@ -127,26 +183,17 @@ function SelectDataModal({
if (!event.target.checked && !tokensSelectChecked) {
setTokens((prevTokens: any) => {
let newTokens = { ...prevTokens };
let tempUsedCount = { ...tokenUsedCount };
for (let id in newTokens) {
newTokens[id].checked = false;
}
return newTokens;
});
}
}
function handleMapChange(event: ChangeEvent<HTMLInputElement>, map: any) {
setMaps((prevMaps: any) => ({
...prevMaps,
[map.id]: { ...map, checked: event.target.checked },
}));
// If all token select is unchecked then ensure tokens assosiated to this map are unchecked
if (!event.target.checked && !tokensSelectChecked) {
setTokens((prevTokens: any) => {
let newTokens = { ...prevTokens };
for (let id in newTokens) {
if (tokensByMap[map.id].has(id) && tokenUsedCount[id] === 1) {
newTokens[id].checked = false;
for (let map of maps) {
if (tokensByMap[map.id].has(id)) {
if (tempUsedCount[id] > 1) {
tempUsedCount[id] -= 1;
} else if (tempUsedCount[id] === 1) {
tempUsedCount[id] = 0;
newTokens[id].checked = false;
}
}
}
}
return newTokens;
@@ -154,36 +201,164 @@ function SelectDataModal({
}
}
function handleSelectTokensChange(event: ChangeEvent<HTMLInputElement>) {
setTokens((prevTokens: any) => {
function handleTokensChanged(
event: React.ChangeEvent<HTMLInputElement>,
tokens: SelectData[]
) {
setTokens((prevTokens) => {
let newTokens = { ...prevTokens };
for (let id in newTokens) {
if (!(id in tokenUsedCount)) {
newTokens[id].checked = event.target.checked;
for (let token of tokens) {
if (!(token.id in tokenUsedCount) || token.type === "default") {
newTokens[token.id].checked = event.target.checked;
}
}
return newTokens;
});
}
function handleTokenChange(event: ChangeEvent<HTMLInputElement>, token: any) {
setTokens((prevTokens: any) => ({
...prevTokens,
[token.id]: { ...token, checked: event.target.checked },
}));
}
// Some tokens are checked not by maps or all tokens are checked by maps
const tokensSelectChecked =
Object.values(tokens).some(
(token: any) => !(token.id in tokenUsedCount) && token.checked
) || Object.values(tokens).every((token: any) => token.id in tokenUsedCount);
) ||
Object.values(tokens).every((token: any) => token.id in tokenUsedCount);
function renderGroupContainer(
group: any,
checked: boolean,
renderItem: (group: any) => React.ReactNode,
onGroupChange: (
event: React.ChangeEvent<HTMLInputElement>,
group: any
) => void
) {
return (
<Flex
ml={4}
sx={{
flexWrap: "wrap",
borderRadius: "4px",
flexDirection: "column",
position: "relative",
}}
key={group.id}
>
<Label my={1} sx={{ fontFamily: "body2" }}>
<Checkbox
checked={checked}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
onGroupChange(e, group)
}
/>
{group.name}
</Label>
<Flex
sx={{
flexWrap: "wrap",
flexDirection: "column",
position: "relative",
}}
>
{group.items.map(renderItem)}
<Box
sx={{ position: "absolute", left: "2px", top: 0, height: "100%" }}
>
<Divider vertical fill />
</Box>
</Flex>
</Flex>
);
}
function renderMapGroup(group: any) {
if (group.type === "item") {
const map = maps[group.id];
if (map) {
return (
<Label key={map.id} my={1} pl={4} sx={{ fontFamily: "body2" }}>
<Checkbox
checked={map.checked}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
handleMapsChanged(e, [map])
}
/>
{map.name}
</Label>
);
}
} else {
if (group.items.some((item: any) => item.id in maps)) {
return renderGroupContainer(
group,
group.items.some((item: any) => maps[item.id]?.checked),
renderMapGroup,
(e, group) =>
handleMapsChanged(
e,
group.items
.filter((group: any) => group.id in maps)
.map((group: any) => maps[group.id])
)
);
}
}
}
function renderTokenGroup(group: any) {
if (group.type === "item") {
const token = tokens[group.id];
if (token) {
return (
<Box pl={4} my={1} key={token.id}>
<Label sx={{ fontFamily: "body2" }}>
<Checkbox
checked={token.checked}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
handleTokensChanged(e, [token])
}
disabled={
token.type !== "default" && token.id in tokenUsedCount
}
/>
{token.name}
</Label>
{token.id in tokenUsedCount && token.type !== "default" && (
<Text as="p" variant="caption" ml={4}>
Token used in {tokenUsedCount[token.id]} selected map
{tokenUsedCount[token.id] > 1 && "s"}
</Text>
)}
</Box>
);
}
} else {
if (group.items.some((item: any) => item.id in tokens)) {
const checked =
group.items.some(
(item: any) =>
!(item.id in tokenUsedCount) && tokens[item.id]?.checked
) || group.items.every((item: any) => item.id in tokenUsedCount);
return renderGroupContainer(
group,
checked,
renderTokenGroup,
(e, group) =>
handleTokensChanged(
e,
group.items
.filter((group: any) => group.id in tokens)
.map((group: any) => tokens[group.id])
)
);
}
}
}
return (
<Modal
isOpen={isOpen}
onRequestClose={onRequestClose}
style={{ content: {maxWidth: "450px", width: "100%"} }}
style={{ content: { maxWidth: "450px", width: "100%" } }}
>
<Box
sx={{
@@ -214,56 +389,30 @@ function SelectDataModal({
<Flex>
<Label>
<Checkbox
checked={Object.values(maps).some((map: any) => map.checked)}
onChange={handleSelectMapsChanged}
checked={Object.values(maps).some((map) => map.checked)}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
handleMapsChanged(e, Object.values(maps))
}
/>
Maps
</Label>
</Flex>
{Object.values(maps).map((map: any) => (
<Label
key={map.id}
my={1}
pl={4}
sx={{ fontFamily: "body2" }}
>
<Checkbox
checked={map.checked}
onChange={(e) => handleMapChange(e, map)}
/>
{map.name}
</Label>
))}
{mapGroups.map(renderMapGroup)}
</>
)}
{hasMaps && hasTokens && <Divider bg="text" />}
{hasMaps && hasTokens && <Divider fill />}
{hasTokens && (
<>
<Label>
<Checkbox
checked={tokensSelectChecked}
onChange={handleSelectTokensChange}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
handleTokensChanged(e, Object.values(tokens))
}
/>
Tokens
</Label>
{Object.values(tokens).map((token: any) => (
<Box pl={4} my={1} key={token.id}>
<Label sx={{ fontFamily: "body2" }}>
<Checkbox
checked={token.checked}
onChange={(e) => handleTokenChange(e, token)}
disabled={token.id in tokenUsedCount}
/>
{token.name}
</Label>
{token.id in tokenUsedCount && (
<Text as="p" variant="caption" ml={4}>
Token used in {tokenUsedCount[token.id]} selected map
{tokenUsedCount[token.id] > 1 && "s"}
</Text>
)}
</Box>
))}
{tokenGroups.map(renderTokenGroup)}
</>
)}
</Flex>

View File

@@ -1,63 +1,43 @@
import { ChangeEvent, useRef, useState } from "react";
import { Button, Flex, Label } from "theme-ui";
import shortid from "shortid";
import Case from "case";
import { useRef, useState, useEffect } from "react";
import { Flex, Label, Box } from "theme-ui";
import { useToasts } from "react-toast-notifications";
import ReactResizeDetector from "react-resize-detector";
import EditMapModal from "./EditMapModal";
import EditGroupModal from "./EditGroupModal";
import ConfirmModal from "./ConfirmModal";
import Modal from "../components/Modal";
import MapTiles from "../components/map/MapTiles";
import ImageDrop from "../components/ImageDrop";
import LoadingOverlay from "../components/LoadingOverlay";
import blobToBuffer from "../helpers/blobToBuffer";
import { resizeImage, createThumbnail } from "../helpers/image";
import { useSearch, useGroup, handleItemSelect } from "../helpers/select";
import {
getGridDefaultInset,
getGridSizeFromImage,
gridSizeVaild,
} from "../helpers/grid";
import Vector2 from "../helpers/Vector2";
import ImageDrop from "../components/image/ImageDrop";
import MapTiles from "../components/map/MapTiles";
import MapEditBar from "../components/map/MapEditBar";
import SelectMapSelectButton from "../components/map/SelectMapSelectButton";
import TilesOverlay from "../components/tile/TilesOverlay";
import TilesContainer from "../components/tile/TilesContainer";
import TileActionBar from "../components/tile/TileActionBar";
import { findGroup, getItemNames } from "../helpers/group";
import { createMapFromFile } from "../helpers/map";
import useResponsiveLayout from "../hooks/useResponsiveLayout";
import { useMapData } from "../contexts/MapDataContext";
import { useAuth } from "../contexts/AuthContext";
import { useKeyboard, useBlur } from "../contexts/KeyboardContext";
import shortcuts from "../shortcuts";
import { Map, MapState } from "../components/map/Map";
import { useUserId } from "../contexts/UserIdContext";
import { useAssets } from "../contexts/AssetsContext";
import { GroupProvider } from "../contexts/GroupContext";
import { TileDragProvider } from "../contexts/TileDragContext";
type SelectMapProps = {
isOpen: boolean,
onDone: any,
onMapChange: any,
onMapReset: any,
currentMap: any
}
const defaultMapProps = {
showGrid: false,
snapToGrid: true,
quality: "original",
group: "",
isOpen: boolean;
onDone: any;
onMapChange: any;
onMapReset: any;
currentMap: any;
};
const mapResolutions = [
{
size: 30, // Pixels per grid
quality: 0.5, // JPEG compression quality
id: "low",
},
{ size: 70, quality: 0.6, id: "medium" },
{ size: 140, quality: 0.7, id: "high" },
{ size: 300, quality: 0.8, id: "ultra" },
];
function SelectMapModal({
isOpen,
onDone,
@@ -65,51 +45,30 @@ function SelectMapModal({
onMapReset,
// The map currently being view in the map screen
currentMap,
}: SelectMapProps ) {
}: SelectMapProps) {
const { addToast } = useToasts();
const { userId } = useAuth();
const userId = useUserId();
const {
ownedMaps,
maps,
mapStates,
mapGroups,
addMap,
removeMaps,
resetMap,
updateMap,
updateMaps,
mapsLoading,
getMapFromDB,
getMapStateFromDB,
getMapState,
getMap,
updateMapGroups,
updateMap,
updateMapState,
mapsById,
} = useMapData();
const { addAssets } = useAssets();
/**
* Search
*/
const [search, setSearch] = useState("");
const [filteredMaps, filteredMapScores] = useSearch(ownedMaps, search);
function handleSearchChange(event: ChangeEvent<HTMLInputElement>) {
setSearch(event.target.value);
}
/**
* Group
*/
const [isGroupModalOpen, setIsGroupModalOpen] = useState(false);
async function handleMapsGroup(group: any) {
setIsLoading(true);
setIsGroupModalOpen(false);
await updateMaps(selectedMapIds, { group });
setIsLoading(false);
}
const [mapsByGroup, mapGroups] = useGroup(
ownedMaps,
filteredMaps,
!!search,
filteredMapScores
);
// Get map names for group filtering
const [mapNames, setMapNames] = useState(getItemNames(maps));
useEffect(() => {
setMapNames(getItemNames(maps));
}, [maps]);
/**
* Image Upload
@@ -118,9 +77,8 @@ function SelectMapModal({
const fileInputRef = useRef<any>();
const [isLoading, setIsLoading] = useState(false);
const [isLargeImageWarningModalOpen, setShowLargeImageWarning] = useState(
false
);
const [isLargeImageWarningModalOpen, setShowLargeImageWarning] =
useState(false);
const largeImageWarningFiles = useRef<any>();
async function handleImagesUpload(files: any) {
@@ -159,6 +117,12 @@ function SelectMapModal({
}
}
function openImageDialog() {
if (fileInputRef.current) {
fileInputRef.current.click();
}
}
function handleLargeImageWarningCancel() {
largeImageWarningFiles.current = undefined;
setShowLargeImageWarning(false);
@@ -176,196 +140,13 @@ function SelectMapModal({
}
async function handleImageUpload(file: File) {
if (!file) {
return Promise.reject();
}
let image = new Image();
setIsLoading(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 () {
// Find name and grid size
let gridSize;
let name = "Unknown Map";
if (file.name) {
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)];
for (let match of gridMatches) {
const matchX = parseInt(match[1]);
const matchY = parseInt(match[3]);
if (
!isNaN(matchX) &&
!isNaN(matchY) &&
gridSizeVaild(matchX, matchY)
) {
gridSize = { x: matchX, y: matchY };
}
}
}
if (!gridSize) {
gridSize = await getGridSizeFromImage(image);
}
// 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();
// Capitalize and remove underscores
name = Case.capital(name);
}
if (!gridSize) {
gridSize = { x: 22, y: 22 };
}
// Create resolutions
const resolutions: any = {};
for (let resolution of mapResolutions) {
const resolutionPixelSize: Vector2 = Vector2.multiply(
gridSize,
resolution.size
);
if (
image.width >= resolutionPixelSize.x &&
image.height >= resolutionPixelSize.y
) {
const resized = await resizeImage(
image,
Vector2.max(resolutionPixelSize, undefined) as number,
file.type,
resolution.quality
);
if (resized.blob) {
const resizedBuffer = await blobToBuffer(resized.blob);
resolutions[resolution.id] = {
file: resizedBuffer,
width: resized.width,
height: resized.height,
type: "file",
id: resolution.id,
};
}
}
}
// Create thumbnail
const thumbnail = await createThumbnail(image, file.type);
handleMapAdd({
// Save as a buffer to send with msgpack
file: buffer,
resolutions,
thumbnail,
name,
type: "file",
grid: {
size: gridSize,
inset: getGridDefaultInset(
{ size: gridSize, type: "square" },
image.width,
image.height
),
type: "square",
measurement: {
type: "chebyshev",
scale: "5ft",
},
},
width: image.width,
height: image.height,
id: shortid.generate(),
created: Date.now(),
lastModified: Date.now(),
lastUsed: Date.now(),
owner: userId,
...defaultMapProps,
});
setIsLoading(false);
URL.revokeObjectURL(url);
resolve(undefined);
};
image.onerror = reject;
image.src = url;
});
}
function openImageDialog() {
if (fileInputRef.current) {
fileInputRef.current?.click();
}
}
/**
* Map Controls
*/
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
// The map selected in the modal
const [selectedMapIds, setSelectedMapIds] = useState<string[]>([]);
const selectedMaps: Map[] = ownedMaps.filter((map: Map) =>
selectedMapIds.includes(map.id)
);
const selectedMapStates = mapStates.filter((state: MapState) =>
selectedMapIds.includes(state.mapId)
);
async function handleMapAdd(map: any) {
const { map, assets } = await createMapFromFile(file, userId);
await addMap(map);
setSelectedMapIds([map.id]);
}
const [isMapsRemoveModalOpen, setIsMapsRemoveModalOpen] = useState(false);
async function handleMapsRemove() {
setIsLoading(true);
setIsMapsRemoveModalOpen(false);
await removeMaps(selectedMapIds);
setSelectedMapIds([]);
// Removed the map from the map screen if needed
if (currentMap && selectedMapIds.includes(currentMap.id)) {
onMapChange(null, null);
}
await addAssets(assets);
setIsLoading(false);
}
const [isMapsResetModalOpen, setIsMapsResetModalOpen] = useState(false);
async function handleMapsReset() {
setIsLoading(true);
setIsMapsResetModalOpen(false);
for (let id of selectedMapIds) {
const newState = await resetMap(id);
// Reset the state of the current map if needed
if (currentMap && currentMap.id === id) {
onMapReset(newState);
}
}
setIsLoading(false);
}
// Either single, multiple or range
const [selectMode, setSelectMode] = useState("single");
function handleMapSelect(map: any) {
handleItemSelect(
map,
selectMode,
selectedMapIds,
setSelectedMapIds,
mapsByGroup,
mapGroups
);
}
/**
* Modal Controls
*/
@@ -374,176 +155,157 @@ function SelectMapModal({
onDone();
}
async function handleDone() {
/**
* Map Controls
*/
async function handleMapSelect(mapId: string) {
if (isLoading) {
return;
}
if (selectedMapIds.length === 1) {
// Update last used for cache invalidation
const lastUsed = Date.now();
const map = selectedMaps[0];
const mapState = await getMapStateFromDB(map.id);
if (map.type === "file") {
setIsLoading(true);
await updateMap(map.id, { lastUsed });
const updatedMap = await getMapFromDB(map.id);
onMapChange(updatedMap, mapState);
setIsLoading(false);
} else {
onMapChange(map, mapState);
}
if (mapId) {
setIsLoading(true);
const map = await getMap(mapId);
const mapState = await getMapState(mapId);
onMapChange(map, mapState);
setIsLoading(false);
} else {
onMapChange(null, null);
}
onDone();
}
/**
* Shortcuts
*/
function handleKeyDown(event: KeyboardEvent): KeyboardEvent | void {
if (!isOpen) {
return;
}
if (shortcuts.selectRange(event)) {
setSelectMode("range");
}
if (shortcuts.selectMultiple(event)) {
setSelectMode("multiple");
}
if (shortcuts.delete(event)) {
// Selected maps and none are default
if (
selectedMapIds.length > 0 &&
!selectedMaps.some((map: any) => map.type === "default")
) {
// Ensure all other modals are closed
setIsGroupModalOpen(false);
setIsEditModalOpen(false);
setIsMapsResetModalOpen(false);
setIsMapsRemoveModalOpen(true);
}
const [editingMapId, setEditingMapId] = useState<string>();
const [isDraggingMap, setIsDraggingMap] = useState(false);
const [canAddDraggedMap, setCanAddDraggedMap] = useState(false);
function handleGroupsSelect(groupIds: string[]) {
if (groupIds.length === 1) {
// Only allow adding a map from dragging if there is a single group item selected
const group = findGroup(mapGroups, groupIds[0]);
setCanAddDraggedMap(group && group.type === "item");
} else {
setCanAddDraggedMap(false);
}
}
function handleKeyUp(event: KeyboardEvent) {
if (!isOpen) {
return;
}
if (shortcuts.selectRange(event) && selectMode === "range") {
setSelectMode("single");
}
if (shortcuts.selectMultiple(event) && selectMode === "multiple") {
setSelectMode("single");
function handleDragAdd(groupIds: string[]) {
if (groupIds.length === 1) {
handleMapSelect(groupIds[0]);
}
}
useKeyboard(handleKeyDown, handleKeyUp);
// Set select mode to single when cmd+tabing
function handleBlur() {
setSelectMode("single");
}
useBlur(handleBlur);
const layout = useResponsiveLayout();
const [modalSize, setModalSize] = useState({ width: 0, height: 0 });
function handleModalResize(width: number, height: number) {
setModalSize({ width, height });
}
const editingMap =
editingMapId && maps.find((map) => map.id === editingMapId);
const editingMapState =
editingMapId && mapStates.find((state) => state.mapId === editingMapId);
return (
<Modal
isOpen={isOpen}
onRequestClose={handleClose}
style={{ content: { maxWidth: layout.modalSize, width: "calc(100% - 16px)" } }}
style={{
content: { maxWidth: layout.modalSize, width: "calc(100% - 16px)" },
}}
shouldCloseOnEsc={!isDraggingMap}
>
<ImageDrop onDrop={handleImagesUpload} dropText="Drop map to upload">
<ImageDrop onDrop={handleImagesUpload} dropText="Drop map to import">
<input
onChange={(event) => handleImagesUpload(event.target.files)}
type="file"
accept="image/*"
accept="image/jpeg, image/gif, image/png, image/webp"
style={{ display: "none" }}
multiple
ref={fileInputRef}
/>
<Flex
sx={{
flexDirection: "column",
}}
<ReactResizeDetector
handleWidth
handleHeight
onResize={handleModalResize}
refreshMode="debounce"
>
<Label pt={2} pb={1}>
Select or import a map
</Label>
<MapTiles
maps={mapsByGroup}
<GroupProvider
groups={mapGroups}
onMapAdd={openImageDialog}
onMapEdit={() => setIsEditModalOpen(true)}
onMapsReset={() => setIsMapsResetModalOpen(true)}
onMapsRemove={() => setIsMapsRemoveModalOpen(true)}
selectedMaps={selectedMaps}
selectedMapStates={selectedMapStates}
onMapSelect={handleMapSelect}
onDone={handleDone}
selectMode={selectMode}
onSelectModeChange={setSelectMode}
search={search}
onSearchChange={handleSearchChange}
onMapsGroup={() => setIsGroupModalOpen(true)}
/>
<Button
variant="primary"
disabled={isLoading || selectedMapIds.length > 1}
onClick={handleDone}
mt={2}
itemNames={mapNames}
onGroupsChange={updateMapGroups}
onGroupsSelect={handleGroupsSelect}
disabled={!isOpen}
>
Select
</Button>
</Flex>
<Flex
sx={{
flexDirection: "column",
}}
>
<Label pt={2} pb={1}>
Select or import a map
</Label>
<TileActionBar onAdd={openImageDialog} addTitle="Import Map(s)" />
<Box sx={{ position: "relative" }}>
<TileDragProvider
onDragAdd={canAddDraggedMap && handleDragAdd}
onDragStart={() => setIsDraggingMap(true)}
onDragEnd={() => setIsDraggingMap(false)}
onDragCancel={() => setIsDraggingMap(false)}
>
<TilesContainer>
<MapTiles
mapsById={mapsById}
onMapEdit={setEditingMapId}
onMapSelect={handleMapSelect}
/>
</TilesContainer>
</TileDragProvider>
<TileDragProvider
onDragAdd={canAddDraggedMap && handleDragAdd}
onDragStart={() => setIsDraggingMap(true)}
onDragEnd={() => setIsDraggingMap(false)}
onDragCancel={() => setIsDraggingMap(false)}
>
<TilesOverlay modalSize={modalSize}>
<MapTiles
mapsById={mapsById}
onMapEdit={setEditingMapId}
onMapSelect={handleMapSelect}
subgroup
/>
</TilesOverlay>
</TileDragProvider>
<MapEditBar
currentMap={currentMap}
disabled={isLoading || editingMapId}
onMapChange={onMapChange}
onMapReset={onMapReset}
onLoad={setIsLoading}
/>
</Box>
<SelectMapSelectButton
onMapSelect={handleMapSelect}
disabled={isLoading}
/>
</Flex>
</GroupProvider>
</ReactResizeDetector>
</ImageDrop>
<>{(isLoading || mapsLoading) && <LoadingOverlay bg="overlay" />}</>
<>
{(isLoading || mapsLoading) && <LoadingOverlay bg="overlay" />}
</>
<EditMapModal
isOpen={isEditModalOpen}
onDone={() => setIsEditModalOpen(false)}
// TODO: check with Mitch what to do here if length > 1
//selectedMaps.length === 1 &&
mapId={selectedMaps[0].id}
/>
<EditGroupModal
isOpen={isGroupModalOpen}
onChange={handleMapsGroup}
groups={mapGroups.filter(
(group: any) => group !== "" && group !== "default"
{editingMap && editingMapState && (
<EditMapModal
isOpen={!!editingMapId}
onDone={() => setEditingMapId(undefined)}
map={editingMap}
mapState={editingMapState}
onUpdateMap={updateMap}
onUpdateMapState={updateMapState}
/>
)}
onRequestClose={() => setIsGroupModalOpen(false)}
// Select the default group by testing whether all selected maps are the same
defaultGroup={
selectedMaps.length > 0 &&
selectedMaps
.map((map: any) => map.group)
.reduce((prev: any, curr: any) => (prev === curr ? curr : undefined))
}
/>
<ConfirmModal
isOpen={isMapsResetModalOpen}
onRequestClose={() => setIsMapsResetModalOpen(false)}
onConfirm={handleMapsReset}
confirmText="Reset"
label={`Reset ${selectedMapIds.length} Map${
selectedMapIds.length > 1 ? "s" : ""
}`}
description="This will remove all fog, drawings and tokens from the selected maps."
/>
<ConfirmModal
isOpen={isMapsRemoveModalOpen}
onRequestClose={() => setIsMapsRemoveModalOpen(false)}
onConfirm={handleMapsRemove}
confirmText="Remove"
label={`Remove ${selectedMapIds.length} Map${
selectedMapIds.length > 1 ? "s" : ""
}`}
description="This operation cannot be undone."
/>
</>
<ConfirmModal
isOpen={isLargeImageWarningModalOpen}
onRequestClose={handleLargeImageWarningCancel}

View File

@@ -1,71 +1,72 @@
import { ChangeEvent, useRef, useState } from "react";
import { Flex, Label, Button } from "theme-ui";
import shortid from "shortid";
import Case from "case";
import React, { useRef, useState, useEffect } from "react";
import { Flex, Label, Button, Box } from "theme-ui";
import { useToasts } from "react-toast-notifications";
import ReactResizeDetector from "react-resize-detector";
import EditTokenModal from "./EditTokenModal";
import EditGroupModal from "./EditGroupModal";
import ConfirmModal from "./ConfirmModal";
import Modal from "../components/Modal";
import ImageDrop from "../components/ImageDrop";
import TokenTiles from "../components/token/TokenTiles";
import LoadingOverlay from "../components/LoadingOverlay";
import blobToBuffer from "../helpers/blobToBuffer";
import { useSearch, useGroup, handleItemSelect } from "../helpers/select";
import { createThumbnail } from "../helpers/image";
import ImageDrop from "../components/image/ImageDrop";
import TokenTiles from "../components/token/TokenTiles";
import TokenEditBar from "../components/token/TokenEditBar";
import TilesOverlay from "../components/tile/TilesOverlay";
import TilesContainer from "../components/tile/TilesContainer";
import TileActionBar from "../components/tile/TileActionBar";
import { getGroupItems, getItemNames } from "../helpers/group";
import {
createTokenFromFile,
createTokenState,
clientPositionToMapPosition,
} from "../helpers/token";
import Vector2 from "../helpers/Vector2";
import useResponsiveLayout from "../hooks/useResponsiveLayout";
import { useTokenData } from "../contexts/TokenDataContext";
import { useAuth } from "../contexts/AuthContext";
import { useKeyboard, useBlur } from "../contexts/KeyboardContext";
import { useUserId } from "../contexts/UserIdContext";
import { useAssets } from "../contexts/AssetsContext";
import { GroupProvider } from "../contexts/GroupContext";
import { TileDragProvider } from "../contexts/TileDragContext";
import { useMapStage } from "../contexts/MapStageContext";
import shortcuts from "../shortcuts";
import { FileToken, Token } from "../tokens";
import { TokenState } from "../components/map/Map";
function SelectTokensModal({ isOpen, onRequestClose }: { isOpen: boolean, onRequestClose: any }) {
type SelectTokensModalProps = {
isOpen: boolean;
onRequestClose: () => void;
onMapTokensStateCreate: (states: TokenState[]) => void;
};
function SelectTokensModal({
isOpen,
onRequestClose,
onMapTokensStateCreate,
}: SelectTokensModalProps) {
const { addToast } = useToasts();
const { userId } = useAuth();
const userId = useUserId();
const {
ownedTokens,
tokens,
addToken,
removeTokens,
updateTokens,
tokensLoading,
tokenGroups,
updateTokenGroups,
updateToken,
tokensById,
} = useTokenData();
const { addAssets } = useAssets();
/**
* Search
*/
const [search, setSearch] = useState("");
const [filteredTokens, filteredTokenScores] = useSearch(ownedTokens, search);
function handleSearchChange(event: ChangeEvent<HTMLInputElement>) {
setSearch(event.target.value);
}
/**
* Group
*/
const [isGroupModalOpen, setIsGroupModalOpen] = useState(false);
async function handleTokensGroup(group: string) {
setIsLoading(true);
setIsGroupModalOpen(false);
await updateTokens(selectedTokenIds, { group });
setIsLoading(false);
}
const [tokensByGroup, tokenGroups] = useGroup(
ownedTokens,
filteredTokens,
!!search,
filteredTokenScores
);
// Get token names for group filtering
const [tokenNames, setTokenNames] = useState(getItemNames(tokens));
useEffect(() => {
setTokenNames(getItemNames(tokens));
}, [tokens]);
/**
* Image Upload
@@ -74,18 +75,11 @@ function SelectTokensModal({ isOpen, onRequestClose }: { isOpen: boolean, onRequ
const fileInputRef = useRef<any>();
const [isLoading, setIsLoading] = useState(false);
const [isLargeImageWarningModalOpen, setShowLargeImageWarning] = useState(
false
);
const [isLargeImageWarningModalOpen, setShowLargeImageWarning] =
useState(false);
const largeImageWarningFiles = useRef<File[]>();
function openImageDialog() {
if (fileInputRef.current) {
fileInputRef.current.click();
}
}
async function handleImagesUpload(files: FileList | null) {
async function handleImagesUpload(files: FileList) {
if (navigator.storage) {
// Attempt to enable persistant storage
await navigator.storage.persist();
@@ -126,6 +120,12 @@ function SelectTokensModal({ isOpen, onRequestClose }: { isOpen: boolean, onRequ
}
}
function openImageDialog() {
if (fileInputRef.current) {
fileInputRef.current.click();
}
}
function handleLargeImageWarningCancel() {
largeImageWarningFiles.current = undefined;
setShowLargeImageWarning(false);
@@ -146,238 +146,177 @@ function SelectTokensModal({ isOpen, onRequestClose }: { isOpen: boolean, onRequ
}
async function handleImageUpload(file: File) {
let name = "Unknown Token";
if (file.name) {
// 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();
// Capitalize and remove underscores
name = Case.capital(name);
}
let image = new Image();
setIsLoading(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 () {
const thumbnail = await createThumbnail(image, file.type);
handleTokenAdd({
file: buffer,
thumbnail,
name,
id: shortid.generate(),
type: "file",
created: Date.now(),
lastModified: Date.now(),
lastUsed: Date.now(),
owner: userId,
defaultSize: 1,
category: "character",
hideInSidebar: false,
group: "",
width: image.width,
height: image.height,
});
setIsLoading(false);
resolve(undefined);
};
image.onerror = reject;
image.src = url;
});
const { token, assets } = await createTokenFromFile(file, userId);
await addToken(token);
await addAssets(assets);
setIsLoading(false);
}
/**
* Token controls
*/
const [isEditModalOpen, setIsEditModalOpen] = useState<boolean>(false);
const [selectedTokenIds, setSelectedTokenIds] = useState<string[]>([]);
const selectedTokens = ownedTokens.filter((token) =>
selectedTokenIds.includes(token.id)
);
const [editingTokenId, setEditingTokenId] = useState<string>();
function handleTokenAdd(token: FileToken) {
addToken(token);
setSelectedTokenIds([token.id]);
}
const [isDraggingToken, setIsDraggingToken] = useState(false);
const [isTokensRemoveModalOpen, setIsTokensRemoveModalOpen] = useState(false);
async function handleTokensRemove() {
setIsLoading(true);
setIsTokensRemoveModalOpen(false);
await removeTokens(selectedTokenIds);
setSelectedTokenIds([]);
setIsLoading(false);
}
async function handleTokensHide(hideInSidebar: boolean) {
setIsLoading(true);
await updateTokens(selectedTokenIds, { hideInSidebar });
setIsLoading(false);
}
// Either single, multiple or range
const [selectMode, setSelectMode] = useState("single");
async function handleTokenSelect(token: Token) {
handleItemSelect(
token,
selectMode,
selectedTokenIds,
setSelectedTokenIds,
tokensByGroup,
tokenGroups
const mapStageRef = useMapStage();
function handleTokensAddToMap(groupIds: string[], rect: any) {
let clientPosition = new Vector2(
rect.width / 2 + rect.left,
rect.height / 2 + rect.top
);
}
/**
* Shortcuts
*/
function handleKeyDown(event: KeyboardEvent) {
if (!isOpen) {
const mapStage = mapStageRef.current;
if (!mapStage) {
return;
}
if (shortcuts.selectRange(event)) {
setSelectMode("range");
let position = clientPositionToMapPosition(mapStage, clientPosition, false);
if (!position) {
return;
}
if (shortcuts.selectMultiple(event)) {
setSelectMode("multiple");
}
if (shortcuts.delete(event)) {
// Selected tokens and none are default
if (
selectedTokenIds.length > 0 &&
!selectedTokens.some((token) => token.type === "default")
) {
// Ensure all other modals are closed
setIsEditModalOpen(false);
setIsGroupModalOpen(false);
setIsTokensRemoveModalOpen(true);
let newTokenStates = [];
for (let id of groupIds) {
if (id in tokensById) {
newTokenStates.push(createTokenState(tokensById[id], position, userId));
position = Vector2.add(position, 0.01);
} else {
// Check if a group is selected
const group = tokenGroups.find(
(group) => group.id === id && group.type === "group"
);
if (group) {
// Add all tokens of group
const items = getGroupItems(group);
for (let item of items) {
if (item.id in tokensById) {
newTokenStates.push(
createTokenState(tokensById[item.id], position, userId)
);
position = Vector2.add(position, 0.01);
}
}
}
}
}
}
function handleKeyUp(event: KeyboardEvent) {
if (!isOpen) {
return;
}
if (shortcuts.selectRange(event) && selectMode === "range") {
setSelectMode("single");
}
if (shortcuts.selectMultiple(event) && selectMode === "multiple") {
setSelectMode("single");
if (newTokenStates.length > 0) {
onMapTokensStateCreate(newTokenStates);
}
}
useKeyboard(handleKeyDown, handleKeyUp);
// Set select mode to single when cmd+tabing
function handleBlur() {
setSelectMode("single");
}
useBlur(handleBlur);
const layout = useResponsiveLayout();
let tokenId;
if (selectedTokens.length === 1 && selectedTokens[0].id) {
tokenId = selectedTokens[0].id
} else {
// TODO: handle tokenId not found
tokenId = ""
const [modalSize, setModalSize] = useState({ width: 0, height: 0 });
function handleModalResize(width: number, height: number) {
setModalSize({ width, height });
}
const editingToken =
editingTokenId && tokens.find((token) => token.id === editingTokenId);
return (
<Modal
isOpen={isOpen}
onRequestClose={onRequestClose}
style={{ content: { maxWidth: layout.modalSize, width: "calc(100% - 16px)" } }}
style={{
content: { maxWidth: layout.modalSize, width: "calc(100% - 16px)" },
}}
shouldCloseOnEsc={!isDraggingToken}
>
<ImageDrop onDrop={handleImagesUpload} dropText="Drop token to upload">
<ImageDrop onDrop={handleImagesUpload} dropText="Drop token to import">
<input
onChange={(event) => handleImagesUpload(event.target.files)}
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
event.target.files && handleImagesUpload(event.target.files)
}
type="file"
accept="image/*"
accept="image/jpeg, image/gif, image/png, image/webp"
style={{ display: "none" }}
ref={fileInputRef}
multiple
/>
<Flex
sx={{
flexDirection: "column",
}}
<ReactResizeDetector
handleWidth
handleHeight
onResize={handleModalResize}
refreshMode="debounce"
>
<Label pt={2} pb={1}>
Edit or import a token
</Label>
<TokenTiles
tokens={tokensByGroup}
<GroupProvider
groups={tokenGroups}
onTokenAdd={openImageDialog}
onTokenEdit={() => setIsEditModalOpen(true)}
onTokensRemove={() => setIsTokensRemoveModalOpen(true)}
selectedTokens={selectedTokens}
onTokenSelect={handleTokenSelect}
selectMode={selectMode}
onSelectModeChange={setSelectMode}
search={search}
onSearchChange={handleSearchChange}
onTokensGroup={() => setIsGroupModalOpen(true)}
onTokensHide={handleTokensHide}
/>
<Button
variant="primary"
disabled={isLoading}
onClick={onRequestClose}
mt={2}
itemNames={tokenNames}
onGroupsChange={updateTokenGroups}
disabled={!isOpen}
>
Done
</Button>
</Flex>
<Flex
sx={{
flexDirection: "column",
}}
>
<Label pt={2} pb={1}>
Edit or import a token
</Label>
<TileActionBar
onAdd={openImageDialog}
addTitle="Import Token(s)"
/>
<Box sx={{ position: "relative" }}>
<TileDragProvider
onDragAdd={handleTokensAddToMap}
onDragStart={() => setIsDraggingToken(true)}
onDragEnd={() => setIsDraggingToken(false)}
onDragCancel={() => setIsDraggingToken(false)}
>
<TilesContainer>
<TokenTiles
tokensById={tokensById}
onTokenEdit={setEditingTokenId}
/>
</TilesContainer>
</TileDragProvider>
<TileDragProvider
onDragAdd={handleTokensAddToMap}
onDragStart={() => setIsDraggingToken(true)}
onDragEnd={() => setIsDraggingToken(false)}
onDragCancel={() => setIsDraggingToken(false)}
>
<TilesOverlay modalSize={modalSize}>
<TokenTiles
tokensById={tokensById}
onTokenEdit={setEditingTokenId}
subgroup
/>
</TilesOverlay>
</TileDragProvider>
<TokenEditBar
onLoad={setIsLoading}
disabled={isLoading || !isOpen}
/>
</Box>
<Button
variant="primary"
disabled={isLoading}
onClick={onRequestClose}
mt={2}
>
Done
</Button>
</Flex>
</GroupProvider>
</ReactResizeDetector>
</ImageDrop>
<>{(isLoading || tokensLoading) && <LoadingOverlay bg="overlay" />}</>
<>
{(isLoading || tokensLoading) && <LoadingOverlay bg="overlay" />}
</>
<EditTokenModal
isOpen={isEditModalOpen}
onDone={() => setIsEditModalOpen(false)}
tokenId={tokenId}
/>
<EditGroupModal
isOpen={isGroupModalOpen}
onChange={handleTokensGroup}
groups={tokenGroups.filter(
(group: string) => group !== "" && group !== "default"
{editingToken && (
<EditTokenModal
isOpen={!!editingTokenId}
onDone={() => setEditingTokenId(undefined)}
token={editingToken}
onUpdateToken={updateToken}
/>
)}
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))
}
/>
<ConfirmModal
isOpen={isTokensRemoveModalOpen}
onRequestClose={() => setIsTokensRemoveModalOpen(false)}
onConfirm={handleTokensRemove}
confirmText="Remove"
label={`Remove ${selectedTokenIds.length} Token${
selectedTokenIds.length > 1 ? "s" : ""
}`}
description="This operation cannot be undone."
/>
</>
<ConfirmModal
isOpen={isLargeImageWarningModalOpen}
onRequestClose={handleLargeImageWarningCancel}

View File

@@ -14,7 +14,7 @@ import Modal from "../components/Modal";
import Slider from "../components/Slider";
import LoadingOverlay from "../components/LoadingOverlay";
import { useAuth } from "../contexts/AuthContext";
import { useUserId } from "../contexts/UserIdContext";
import { useDatabase } from "../contexts/DatabaseContext";
import useSetting from "../hooks/useSetting";
@@ -23,9 +23,15 @@ import ConfirmModal from "./ConfirmModal";
import ImportExportModal from "./ImportExportModal";
import { MapState } from "../components/map/Map";
function SettingsModal({ isOpen, onRequestClose }: { isOpen: boolean, onRequestClose: () => void }) {
function SettingsModal({
isOpen,
onRequestClose,
}: {
isOpen: boolean;
onRequestClose: () => void;
}) {
const { database, databaseStatus } = useDatabase();
const { userId } = useAuth();
const userId = useUserId();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [labelSize, setLabelSize] = useSetting("map.labelSize");
const [gridSnappingSensitivity, setGridSnappingSensitivity] = useSetting(
@@ -58,9 +64,14 @@ function SettingsModal({ isOpen, onRequestClose }: { isOpen: boolean, onRequestC
async function handleEraseAllData() {
setIsLoading(true);
localStorage.clear();
await database?.delete();
window.location.reload();
try {
localStorage.clear();
database?.close();
await database?.delete();
} catch {
} finally {
window.location.reload();
}
}
async function handleClearCache() {
@@ -68,32 +79,28 @@ function SettingsModal({ isOpen, onRequestClose }: { isOpen: boolean, onRequestC
// Clear saved settings
localStorage.clear();
//TODO: handle id database is undefined
if (!database) {
return;
}
// Clear map cache
await database.table("maps").where("owner").notEqual(userId).delete();
// Find all other peoples tokens who aren't benig used in a map state and delete them
const tokens = await database
.table("tokens")
.where("owner")
.notEqual(userId)
.toArray();
const states: MapState[] = await database?.table("states").toArray();
for (let token of tokens) {
let inUse = false;
for (let state of states) {
for (let tokenState of Object.values(state.tokens)) {
if (token.id === tokenState.tokenId) {
inUse = true;
if (database && userId) {
const assets = await database
.table("assets")
.where("owner")
.notEqual(userId)
.toArray();
const states: MapState[] = await database.table("states").toArray();
for (let asset of assets) {
let inUse = false;
for (let state of states) {
for (let tokenState of Object.values(state.tokens)) {
if (tokenState.type === "file" && asset.id === tokenState.file) {
inUse = true;
}
}
}
}
if (!inUse) {
database.table("tokens").delete(token.id);
if (!inUse) {
await database.table("assets").delete(asset.id);
}
}
}
window.location.reload();
}
@@ -191,13 +198,14 @@ function SettingsModal({ isOpen, onRequestClose }: { isOpen: boolean, onRequestC
Import / Export Data
</Button>
</Flex>
{storageEstimate !&& (
{storageEstimate! && (
<Flex sx={{ justifyContent: "center" }}>
<Text variant="caption">
Storage Used: {prettyBytes(storageEstimate.usage as number)} of{" "}
{prettyBytes(storageEstimate.quota as number)} (
{Math.round(
(storageEstimate.usage as number / Math.max(storageEstimate.quota as number, 1)) *
((storageEstimate.usage as number) /
Math.max(storageEstimate.quota as number, 1)) *
100
)}
%)