Add sort to token and map tiles and refactor

This commit is contained in:
Mitchell McCaffrey 2021-05-09 12:04:31 +10:00
parent e1f5cb3014
commit 140ac7d61c
24 changed files with 771 additions and 801 deletions

View File

@ -5,7 +5,8 @@
"dependencies": {
"@babylonjs/core": "^4.2.0",
"@babylonjs/loaders": "^4.2.0",
"@dnd-kit/core": "3.0.0",
"@dnd-kit/core": "^3.0.2",
"@dnd-kit/sortable": "^3.0.1",
"@mitchemmc/dexie-export-import": "^1.0.1",
"@msgpack/msgpack": "^2.4.1",
"@sentry/react": "^6.2.2",

View File

@ -8,19 +8,15 @@ function Draggable({ id, children, data }) {
});
const style = {
border: "none",
background: "transparent",
margin: "0px",
padding: "0px",
cursor: "pointer",
touchAction: "none",
opacity: isDragging ? 0.5 : undefined,
};
return (
<button ref={setNodeRef} style={style} {...listeners} {...attributes}>
<div ref={setNodeRef} style={style} {...listeners} {...attributes}>
{children}
</button>
</div>
);
}

View File

@ -17,15 +17,16 @@ function StyledModal({
isOpen={isOpen}
onRequestClose={onRequestClose}
style={{
overlay: { backgroundColor: "rgba(0, 0, 0, 0.73)", zIndex: 100 },
overlay: {
backgroundColor: "rgba(0, 0, 0, 0.73)",
zIndex: 100,
display: "flex",
alignItems: "center",
justifyContent: "center",
},
content: {
backgroundColor: theme.colors.background,
top: "50%",
left: "50%",
right: "auto",
bottom: "auto",
marginRight: "-50%",
transform: "translate(-50%, -50%)",
inset: "initial",
maxHeight: "100%",
...style,
},

View File

@ -0,0 +1,31 @@
import React from "react";
import { useSortable } from "@dnd-kit/sortable";
function Sortable({ id, children }) {
const {
attributes,
listeners,
setNodeRef,
isDragging,
transform,
transition,
} = useSortable({ id });
const style = {
cursor: "pointer",
touchAction: "none",
opacity: isDragging ? 0.5 : undefined,
transform:
transform && `translate3d(${transform.x}px, ${transform.y}px, 0px)`,
zIndex: isDragging ? 100 : 0,
transition,
};
return (
<div ref={setNodeRef} style={style} {...listeners} {...attributes}>
{children}
</div>
);
}
export default Sortable;

View File

@ -10,37 +10,17 @@ function Tile({
onSelect,
onEdit,
onDoubleClick,
size,
canEdit,
badges,
editTitle,
}) {
let width;
let margin;
switch (size) {
case "small":
width = "24%";
margin = "0.5%";
break;
case "medium":
width = "32%";
margin = `${2 / 3}%`;
break;
case "large":
width = "48%";
margin = "1%";
break;
default:
width = "32%";
margin = `${2 / 3}%`;
}
return (
<Flex
sx={{
position: "relative",
width: width,
width: "100%",
height: "0",
paddingTop: width,
paddingTop: "100%",
borderRadius: "4px",
justifyContent: "center",
alignItems: "center",
@ -48,8 +28,6 @@ function Tile({
overflow: "hidden",
userSelect: "none",
}}
my={1}
mx={margin}
bg="muted"
onClick={(e) => {
e.stopPropagation();

View File

@ -2,7 +2,7 @@ import React from "react";
import Tile from "../Tile";
function DiceTile({ dice, isSelected, onDiceSelect, onDone, size }) {
function DiceTile({ dice, isSelected, onDiceSelect, onDone }) {
return (
<Tile
src={dice.preview}
@ -10,7 +10,6 @@ function DiceTile({ dice, isSelected, onDiceSelect, onDone, size }) {
isSelected={isSelected}
onSelect={() => onDiceSelect(dice)}
onDoubleClick={() => onDone(dice)}
size={size}
/>
);
}

View File

@ -1,5 +1,5 @@
import React from "react";
import { Flex } from "theme-ui";
import { Grid } from "theme-ui";
import SimpleBar from "simplebar-react";
import DiceTile from "./DiceTile";
@ -13,16 +13,16 @@ function DiceTiles({ dice, onDiceSelect, selectedDice, onDone }) {
<SimpleBar
style={{ height: layout.screenSize === "large" ? "600px" : "400px" }}
>
<Flex
<Grid
p={2}
pb={4}
bg="muted"
sx={{
flexWrap: "wrap",
borderRadius: "4px",
minHeight: layout.screenSize === "large" ? "600px" : "400px",
alignContent: "flex-start",
}}
gap={2}
columns={layout.gridTemplate}
>
{dice.map((dice) => (
<DiceTile
@ -34,7 +34,7 @@ function DiceTiles({ dice, onDiceSelect, selectedDice, onDone }) {
size={layout.tileSize}
/>
))}
</Flex>
</Grid>
</SimpleBar>
);
}

View File

@ -11,7 +11,6 @@ function MapTile({
onMapSelect,
onMapEdit,
onDone,
size,
canEdit,
badges,
}) {
@ -30,7 +29,6 @@ function MapTile({
onSelect={() => onMapSelect(map)}
onEdit={() => onMapEdit(map.id)}
onDoubleClick={() => canEdit && onDone()}
size={size}
canEdit={canEdit}
badges={badges}
editTitle="Edit Map"

View File

@ -1,15 +1,16 @@
import React from "react";
import { Flex, Box, Text, IconButton, Close, Label } from "theme-ui";
import { Flex, Box, Text, IconButton, Close, Grid } from "theme-ui";
import SimpleBar from "simplebar-react";
import Case from "case";
import { DndContext } from "@dnd-kit/core";
import { SortableContext, arrayMove } from "@dnd-kit/sortable";
import RemoveMapIcon from "../../icons/RemoveMapIcon";
import ResetMapIcon from "../../icons/ResetMapIcon";
import GroupIcon from "../../icons/GroupIcon";
import MapTile from "./MapTile";
import Link from "../Link";
import FilterBar from "../FilterBar";
import Sortable from "../Sortable";
import { useDatabase } from "../../contexts/DatabaseContext";
@ -73,107 +74,111 @@ function MapTiles({
const multipleSelected = selectedMaps.length > 1;
function handleDragEnd({ active, over }) {
if (active && over && active.id !== over.id) {
const oldIndex = groups.indexOf(active.id);
const newIndex = groups.indexOf(over.id);
onMapsGroup(arrayMove(groups, oldIndex, newIndex));
}
}
return (
<Box sx={{ position: "relative" }}>
<FilterBar
onFocus={() => onMapSelect()}
search={search}
onSearchChange={onSearchChange}
selectMode={selectMode}
onSelectModeChange={onSelectModeChange}
onAdd={onMapAdd}
addTitle="Add Map"
/>
<SimpleBar
style={{ height: layout.screenSize === "large" ? "600px" : "400px" }}
>
<Flex
p={2}
pb={4}
pt={databaseStatus === "disabled" ? 4 : 2}
bg="muted"
sx={{
flexWrap: "wrap",
borderRadius: "4px",
minHeight: layout.screenSize === "large" ? "600px" : "400px",
alignContent: "flex-start",
}}
onClick={() => onMapSelect()}
>
{groups.map((group) => (
<React.Fragment key={group}>
<Label mx={1} mt={2}>
{Case.capital(group)}
</Label>
{maps[group].map(mapToTile)}
</React.Fragment>
))}
</Flex>
</SimpleBar>
{databaseStatus === "disabled" && (
<Box
sx={{
position: "absolute",
top: "39px",
left: 0,
right: 0,
textAlign: "center",
borderRadius: "2px",
}}
bg="highlight"
p={1}
>
<Text as="p" variant="body2">
Map saving is unavailable. See <Link to="/faq#saving">FAQ</Link> for
more information.
</Text>
</Box>
)}
{selectedMaps.length > 0 && (
<Flex
sx={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
justifyContent: "space-between",
}}
bg="overlay"
>
<Close
title="Clear Selection"
aria-label="Clear Selection"
onClick={() => onMapSelect()}
<DndContext onDragEnd={handleDragEnd}>
<SortableContext items={groups}>
<Box sx={{ position: "relative" }}>
<FilterBar
onFocus={() => onMapSelect()}
search={search}
onSearchChange={onSearchChange}
selectMode={selectMode}
onSelectModeChange={onSelectModeChange}
onAdd={onMapAdd}
addTitle="Add Map"
/>
<Flex>
<IconButton
aria-label={multipleSelected ? "Group Maps" : "Group Map"}
title={multipleSelected ? "Group Maps" : "Group Map"}
onClick={() => onMapsGroup()}
disabled={hasSelectedDefaultMap}
<SimpleBar
style={{
height: layout.screenSize === "large" ? "600px" : "400px",
}}
>
<Grid
p={2}
pb={4}
pt={databaseStatus === "disabled" ? 4 : 2}
bg="muted"
sx={{
borderRadius: "4px",
minHeight: layout.screenSize === "large" ? "600px" : "400px",
overflow: "hidden",
}}
gap={2}
columns={layout.gridTemplate}
onClick={() => onMapSelect()}
>
<GroupIcon />
</IconButton>
<IconButton
aria-label={multipleSelected ? "Reset Maps" : "Reset Map"}
title={multipleSelected ? "Reset Maps" : "Reset Map"}
onClick={() => onMapsReset()}
disabled={!hasMapState}
{groups.map((mapId) => (
<Sortable id={mapId} key={mapId}>
{mapToTile(maps.find((map) => map.id === mapId))}
</Sortable>
))}
</Grid>
</SimpleBar>
{databaseStatus === "disabled" && (
<Box
sx={{
position: "absolute",
top: "39px",
left: 0,
right: 0,
textAlign: "center",
borderRadius: "2px",
}}
bg="highlight"
p={1}
>
<ResetMapIcon />
</IconButton>
<IconButton
aria-label={multipleSelected ? "Remove Maps" : "Remove Map"}
title={multipleSelected ? "Remove Maps" : "Remove Map"}
onClick={() => onMapsRemove()}
disabled={hasSelectedDefaultMap}
<Text as="p" variant="body2">
Map saving is unavailable. See <Link to="/faq#saving">FAQ</Link>{" "}
for more information.
</Text>
</Box>
)}
{selectedMaps.length > 0 && (
<Flex
sx={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
justifyContent: "space-between",
}}
bg="overlay"
>
<RemoveMapIcon />
</IconButton>
</Flex>
</Flex>
)}
</Box>
<Close
title="Clear Selection"
aria-label="Clear Selection"
onClick={() => onMapSelect()}
/>
<Flex>
<IconButton
aria-label={multipleSelected ? "Reset Maps" : "Reset Map"}
title={multipleSelected ? "Reset Maps" : "Reset Map"}
onClick={() => onMapsReset()}
disabled={!hasMapState}
>
<ResetMapIcon />
</IconButton>
<IconButton
aria-label={multipleSelected ? "Remove Maps" : "Remove Map"}
title={multipleSelected ? "Remove Maps" : "Remove Map"}
onClick={() => onMapsRemove()}
disabled={hasSelectedDefaultMap}
>
<RemoveMapIcon />
</IconButton>
</Flex>
</Flex>
)}
</Box>
</SortableContext>
</DndContext>
);
}

View File

@ -1,8 +1,8 @@
import React from "react";
import React, { useState } from "react";
import { createPortal } from "react-dom";
import { Box, Flex } from "theme-ui";
import SimpleBar from "simplebar-react";
import { DragOverlay } from "@dnd-kit/core";
import { DragOverlay, DndContext } from "@dnd-kit/core";
import ListToken from "./ListToken";
import SelectTokensButton from "./SelectTokensButton";
@ -11,67 +11,82 @@ import Draggable from "../Draggable";
import useSetting from "../../hooks/useSetting";
import { useTokenData } from "../../contexts/TokenDataContext";
import { useDragId } from "../../contexts/DragContext";
import { useAuth } from "../../contexts/AuthContext";
function TokenBar() {
const { ownedTokens } = useTokenData();
import { createTokenState } from "../../helpers/token";
function TokenBar({ onMapTokenStateCreate }) {
const { userId } = useAuth();
const { tokensById, tokenGroups } = useTokenData();
const [fullScreen] = useSetting("map.fullScreen");
const activeDragId = useDragId();
const [dragId, setDragId] = useState();
function handleDragStart({ active }) {
setDragId(active.id);
}
function handleDragEnd({ active }) {
setDragId(null);
const token = tokensById[active.id];
if (token) {
// TODO: Get drag position
const tokenState = createTokenState(token, { x: 0, y: 0 }, userId);
onMapTokenStateCreate(tokenState);
}
}
const tokens = tokenGroups
.map((tokenId) => tokensById[tokenId])
.filter((token) => !token.hideInSidebar)
.map((token) => (
<Draggable id={token.id} key={token.id}>
<ListToken token={token} />
</Draggable>
));
return (
<Box
sx={{
height: "100%",
width: "80px",
minWidth: "80px",
overflowY: "scroll",
overflowX: "hidden",
display: fullScreen ? "none" : "block",
}}
<DndContext
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
autoScroll={false}
>
<SimpleBar
style={{
height: "calc(100% - 48px)",
overflowX: "hidden",
padding: "0 16px",
}}
>
{ownedTokens
.filter((token) => !token.hideInSidebar)
.map((token) => (
<Draggable
id={`sidebar-${token.id}`}
key={token.id}
data={{ tokenId: token.id }}
>
<ListToken token={token} />
</Draggable>
))}
</SimpleBar>
<Flex
bg="muted"
<Box
sx={{
justifyContent: "center",
height: "48px",
alignItems: "center",
height: "100%",
width: "80px",
minWidth: "80px",
overflowY: "scroll",
overflowX: "hidden",
display: fullScreen ? "none" : "block",
}}
>
<SelectTokensButton />
</Flex>
{createPortal(
<DragOverlay>
{activeDragId && (
<ListToken
token={ownedTokens.find(
(token) => `sidebar-${token.id}` === activeDragId
)}
/>
)}
</DragOverlay>,
document.body
)}
</Box>
<SimpleBar
style={{
height: "calc(100% - 48px)",
overflowX: "hidden",
padding: "0 16px",
}}
>
{tokens}
</SimpleBar>
<Flex
bg="muted"
sx={{
justifyContent: "center",
height: "48px",
alignItems: "center",
}}
>
<SelectTokensButton />
</Flex>
{createPortal(
<DragOverlay>
{dragId && <ListToken token={tokensById[dragId]} />}
</DragOverlay>,
document.body
)}
</Box>
</DndContext>
);
}

View File

@ -11,7 +11,6 @@ function TokenTile({
isSelected,
onTokenSelect,
onTokenEdit,
size,
canEdit,
badges,
}) {
@ -29,7 +28,6 @@ function TokenTile({
isSelected={isSelected}
onSelect={() => onTokenSelect(token)}
onEdit={() => onTokenEdit(token.id)}
size={size}
canEdit={canEdit}
badges={badges}
editTitle="Edit Token"

View File

@ -1,16 +1,17 @@
import React from "react";
import { Flex, Box, Text, IconButton, Close, Label } from "theme-ui";
import { Flex, Box, Text, IconButton, Close, Grid } from "theme-ui";
import SimpleBar from "simplebar-react";
import Case from "case";
import { DndContext } from "@dnd-kit/core";
import { SortableContext, arrayMove } from "@dnd-kit/sortable";
import RemoveTokenIcon from "../../icons/RemoveTokenIcon";
import GroupIcon from "../../icons/GroupIcon";
import TokenHideIcon from "../../icons/TokenHideIcon";
import TokenShowIcon from "../../icons/TokenShowIcon";
import TokenTile from "./TokenTile";
import Link from "../Link";
import FilterBar from "../FilterBar";
import Sortable from "../Sortable";
import { useDatabase } from "../../contexts/DatabaseContext";
@ -48,7 +49,6 @@ function TokenTiles({
isSelected={isSelected}
onTokenSelect={onTokenSelect}
onTokenEdit={onTokenEdit}
size={layout.tileSize}
canEdit={
isSelected &&
token.type !== "default" &&
@ -77,107 +77,112 @@ function TokenTiles({
}
}
function handleDragEnd({ active, over }) {
if (active && over && active.id !== over.id) {
const oldIndex = groups.indexOf(active.id);
const newIndex = groups.indexOf(over.id);
onTokensGroup(arrayMove(groups, oldIndex, newIndex));
}
}
return (
<Box sx={{ position: "relative" }}>
<FilterBar
onFocus={() => onTokenSelect()}
search={search}
onSearchChange={onSearchChange}
selectMode={selectMode}
onSelectModeChange={onSelectModeChange}
onAdd={onTokenAdd}
addTitle="Add Token"
/>
<SimpleBar
style={{ height: layout.screenSize === "large" ? "600px" : "400px" }}
>
<Flex
p={2}
pb={4}
pt={databaseStatus === "disabled" ? 4 : 2}
bg="muted"
sx={{
flexWrap: "wrap",
borderRadius: "4px",
minHeight: layout.screenSize === "large" ? "600px" : "400px",
alignContent: "flex-start",
}}
onClick={() => onTokenSelect()}
>
{groups.map((group) => (
<React.Fragment key={group}>
<Label mx={1} mt={2}>
{Case.capital(group)}
</Label>
{tokens[group].map(tokenToTile)}
</React.Fragment>
))}
</Flex>
</SimpleBar>
{databaseStatus === "disabled" && (
<Box
sx={{
position: "absolute",
top: "39px",
left: 0,
right: 0,
textAlign: "center",
borderRadius: "2px",
}}
bg="highlight"
p={1}
>
<Text as="p" variant="body2">
Token saving is unavailable. See <Link to="/faq#saving">FAQ</Link>{" "}
for more information.
</Text>
</Box>
)}
{selectedTokens.length > 0 && (
<Flex
sx={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
justifyContent: "space-between",
}}
bg="overlay"
>
<Close
title="Clear Selection"
aria-label="Clear Selection"
onClick={() => onTokenSelect()}
<DndContext onDragEnd={handleDragEnd}>
<SortableContext items={groups}>
<Box sx={{ position: "relative" }}>
<FilterBar
onFocus={() => onTokenSelect()}
search={search}
onSearchChange={onSearchChange}
selectMode={selectMode}
onSelectModeChange={onSelectModeChange}
onAdd={onTokenAdd}
addTitle="Add Token"
/>
<Flex>
<IconButton
aria-label={hideTitle}
title={hideTitle}
disabled={hasSelectedDefaultToken}
onClick={() => onTokensHide(allTokensVisible)}
<SimpleBar
style={{
height: layout.screenSize === "large" ? "600px" : "400px",
}}
>
<Grid
p={2}
pb={4}
pt={databaseStatus === "disabled" ? 4 : 2}
bg="muted"
sx={{
borderRadius: "4px",
minHeight: layout.screenSize === "large" ? "600px" : "400px",
}}
gap={2}
columns={layout.gridTemplate}
onClick={() => onTokenSelect()}
>
{allTokensVisible ? <TokenShowIcon /> : <TokenHideIcon />}
</IconButton>
<IconButton
aria-label={multipleSelected ? "Group Tokens" : "Group Token"}
title={multipleSelected ? "Group Tokens" : "Group Token"}
onClick={() => onTokensGroup()}
disabled={hasSelectedDefaultToken}
{groups.map((tokenId) => (
<Sortable id={tokenId} key={tokenId}>
{tokenToTile(tokens.find((token) => token.id === tokenId))}
</Sortable>
))}
</Grid>
</SimpleBar>
{databaseStatus === "disabled" && (
<Box
sx={{
position: "absolute",
top: "39px",
left: 0,
right: 0,
textAlign: "center",
borderRadius: "2px",
}}
bg="highlight"
p={1}
>
<GroupIcon />
</IconButton>
<IconButton
aria-label={multipleSelected ? "Remove Tokens" : "Remove Token"}
title={multipleSelected ? "Remove Tokens" : "Remove Token"}
onClick={() => onTokensRemove()}
disabled={hasSelectedDefaultToken}
<Text as="p" variant="body2">
Token saving is unavailable. See{" "}
<Link to="/faq#saving">FAQ</Link> for more information.
</Text>
</Box>
)}
{selectedTokens.length > 0 && (
<Flex
sx={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
justifyContent: "space-between",
}}
bg="overlay"
>
<RemoveTokenIcon />
</IconButton>
</Flex>
</Flex>
)}
</Box>
<Close
title="Clear Selection"
aria-label="Clear Selection"
onClick={() => onTokenSelect()}
/>
<Flex>
<IconButton
aria-label={hideTitle}
title={hideTitle}
disabled={hasSelectedDefaultToken}
onClick={() => onTokensHide(allTokensVisible)}
>
{allTokensVisible ? <TokenShowIcon /> : <TokenHideIcon />}
</IconButton>
<IconButton
aria-label={
multipleSelected ? "Remove Tokens" : "Remove Token"
}
title={multipleSelected ? "Remove Tokens" : "Remove Token"}
onClick={() => onTokensRemove()}
disabled={hasSelectedDefaultToken}
>
<RemoveTokenIcon />
</IconButton>
</Flex>
</Flex>
)}
</Box>
</SortableContext>
</DndContext>
);
}

View File

@ -1,36 +0,0 @@
import React, { useState, useContext } from "react";
import { DndContext } from "@dnd-kit/core";
/**
* @type {React.Context<string|undefined>}
*/
const DragIdContext = React.createContext();
export function DragProvider({ children, onDragEnd }) {
const [activeDragId, setActiveDragId] = useState(null);
function handleDragStart({ active }) {
setActiveDragId(active.id);
}
function handleDragEnd(event) {
setActiveDragId(null);
onDragEnd && onDragEnd(event);
}
return (
<DndContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<DragIdContext.Provider value={activeDragId}>
{children}
</DragIdContext.Provider>
</DndContext>
);
}
export function useDragId() {
const context = useContext(DragIdContext);
if (context === undefined) {
throw new Error("useDragId must be used within a DragProvider");
}
return context;
}

View File

@ -22,6 +22,7 @@ export function MapDataProvider({ children }) {
const [maps, setMaps] = useState([]);
const [mapStates, setMapStates] = useState([]);
const [mapsLoading, setMapsLoading] = useState(true);
const [mapGroups, setMapGroups] = useState([]);
// Load maps from the database and ensure state is properly setup
useEffect(() => {
@ -42,11 +43,12 @@ export function MapDataProvider({ children }) {
storedMaps.push(map);
});
}
// TODO: remove sort when groups are added
const sortedMaps = storedMaps.sort((a, b) => b.created - a.created);
setMaps(sortedMaps);
setMaps(storedMaps);
const storedStates = await database.table("states").toArray();
setMapStates(storedStates);
const group = await database.table("groups").get("maps");
const storedGroups = group.data;
setMapGroups(storedGroups);
setMapsLoading(false);
}
@ -70,7 +72,7 @@ export function MapDataProvider({ children }) {
);
/**
* Adds a map to the database, also adds an assosiated state for that map
* Adds a map to the database, also adds an assosiated state and group for that map
* @param {Object} map map to add
*/
const addMap = useCallback(
@ -79,6 +81,10 @@ export function MapDataProvider({ children }) {
const state = { ...defaultMapState, mapId: map.id };
await database.table("maps").add(map);
await database.table("states").add(state);
const group = await database.table("groups").get("maps");
await database
.table("groups")
.update("maps", { data: [map.id, ...group.data] });
},
[database]
);
@ -136,6 +142,15 @@ export function MapDataProvider({ children }) {
[database]
);
const updateMapGroups = useCallback(
async (groups) => {
// Update group state immediately to avoid animation delay
setMapGroups(groups);
await database.table("groups").update("maps", { data: groups });
},
[database]
);
// Create DB observable to sync creating and deleting
useEffect(() => {
if (!database || databaseStatus === "loading") {
@ -190,6 +205,11 @@ export function MapDataProvider({ children }) {
});
}
}
if (change.table === "groups") {
if (change.type === 2 && change.key === "maps") {
setMapGroups(change.obj.data);
}
}
}
}
@ -200,12 +220,10 @@ export function MapDataProvider({ children }) {
};
}, [database, databaseStatus]);
const ownedMaps = maps.filter((map) => map.owner === userId);
const value = {
maps,
ownedMaps,
mapStates,
mapGroups,
addMap,
removeMaps,
resetMap,
@ -215,6 +233,7 @@ export function MapDataProvider({ children }) {
getMap,
mapsLoading,
getMapState,
updateMapGroups,
};
return (
<MapDataContext.Provider value={value}>{children}</MapDataContext.Provider>

View File

@ -12,6 +12,7 @@ export function TokenDataProvider({ children }) {
const [tokens, setTokens] = useState([]);
const [tokensLoading, setTokensLoading] = useState(true);
const [tokenGroups, setTokenGroups] = useState([]);
useEffect(() => {
if (!userId || !database || databaseStatus === "loading") {
@ -30,8 +31,10 @@ export function TokenDataProvider({ children }) {
storedTokens.push(token);
});
}
const sortedTokens = storedTokens.sort((a, b) => b.created - a.created);
setTokens(sortedTokens);
setTokens(storedTokens);
const group = await database.table("groups").get("tokens");
const storedGroups = group.data;
setTokenGroups(storedGroups);
setTokensLoading(false);
}
@ -46,9 +49,14 @@ export function TokenDataProvider({ children }) {
[database]
);
// Add token and add it to the token group
const addToken = useCallback(
async (token) => {
await database.table("tokens").add(token);
const group = await database.table("groups").get("tokens");
await database
.table("groups")
.update("tokens", { data: [token.id, ...group.data] });
},
[database]
);
@ -87,6 +95,15 @@ export function TokenDataProvider({ children }) {
[database]
);
const updateTokenGroups = useCallback(
async (groups) => {
// Update group state immediately to avoid animation delay
setTokenGroups(groups);
await database.table("groups").update("tokens", { data: groups });
},
[database]
);
// Create DB observable to sync creating and deleting
useEffect(() => {
if (!database || databaseStatus === "loading") {
@ -120,6 +137,11 @@ export function TokenDataProvider({ children }) {
});
}
}
if (change.table === "groups") {
if (change.type === 2 && change.key === "tokens") {
setTokenGroups(change.obj.data);
}
}
}
}
@ -130,8 +152,6 @@ export function TokenDataProvider({ children }) {
};
}, [database, databaseStatus]);
const ownedTokens = tokens.filter((token) => token.owner === userId);
const tokensById = tokens.reduce((obj, token) => {
obj[token.id] = token;
return obj;
@ -139,14 +159,15 @@ export function TokenDataProvider({ children }) {
const value = {
tokens,
ownedTokens,
addToken,
tokenGroups,
removeTokens,
updateToken,
updateTokens,
tokensById,
tokensLoading,
getToken,
updateTokenGroups,
};
return (

View File

@ -1,3 +1,33 @@
import { v4 as uuid } from "uuid";
import Case from "case";
import blobToBuffer from "./blobToBuffer";
import { resizeImage, createThumbnail } from "./image";
import {
getGridDefaultInset,
getGridSizeFromImage,
gridSizeVaild,
} from "./grid";
import Vector2 from "./Vector2";
const defaultMapProps = {
showGrid: false,
snapToGrid: true,
quality: "original",
group: "",
};
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" },
];
/**
* Get the asset id of the preview file to send for a map
* @param {any} map
@ -25,3 +55,141 @@ export function getMapPreviewAsset(map) {
return;
}
}
export async function createMapFromFile(file, userId) {
let image = new Image();
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 };
}
let assets = [];
// Create resolutions
const resolutions = {};
for (let resolution of mapResolutions) {
const resolutionPixelSize = Vector2.multiply(gridSize, resolution.size);
if (
image.width >= resolutionPixelSize.x &&
image.height >= resolutionPixelSize.y
) {
const resized = await resizeImage(
image,
Vector2.max(resolutionPixelSize),
file.type,
resolution.quality
);
if (resized.blob) {
const assetId = uuid();
resolutions[resolution.id] = assetId;
const resizedBuffer = await blobToBuffer(resized.blob);
const asset = {
file: resizedBuffer,
width: resized.width,
height: resized.height,
id: assetId,
mime: file.type,
owner: userId,
};
assets.push(asset);
}
}
}
// Create thumbnail
const thumbnailImage = await createThumbnail(image, file.type);
const thumbnail = {
...thumbnailImage,
id: uuid(),
owner: userId,
};
assets.push(thumbnail);
const fileAsset = {
id: uuid(),
file: buffer,
width: image.width,
height: image.height,
mime: file.type,
owner: userId,
};
assets.push(fileAsset);
const map = {
name,
resolutions,
file: fileAsset.id,
thumbnail: thumbnail.id,
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: uuid(),
created: Date.now(),
lastModified: Date.now(),
owner: userId,
...defaultMapProps,
};
URL.revokeObjectURL(url);
resolve({ map, assets });
};
image.onerror = reject;
image.src = url;
});
}

View File

@ -35,6 +35,7 @@ export function useSearch(items, search) {
return [filteredItems, filteredItemScores];
}
// TODO: Rework group support
// Helper for grouping items
export function useGroup(items, filteredItems, useFiltered, filteredScores) {
const itemsByGroup = groupBy(useFiltered ? filteredItems : items, "group");
@ -92,41 +93,43 @@ export function handleItemSelect(
});
break;
case "range":
// Create items array
let items = itemGroups.reduce(
(acc, group) => [...acc, ...itemsByGroup[group]],
[]
);
/// TODO: Fix when new groups system is added
return;
// 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;
// // 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([]);
}

113
src/helpers/token.js Normal file
View File

@ -0,0 +1,113 @@
import { v4 as uuid } from "uuid";
import Case from "case";
import imageOutline from "image-outline";
import blobToBuffer from "./blobToBuffer";
import { createThumbnail } from "./image";
import Vector2 from "./Vector2";
export function createTokenState(token, position, userId) {
let tokenState = {
id: uuid(),
tokenId: token.id,
owner: userId,
size: token.defaultSize,
category: token.defaultCategory,
label: token.defaultLabel,
statuses: [],
x: position.x,
y: position.y,
lastModifiedBy: userId,
lastModified: Date.now(),
rotation: 0,
locked: false,
visible: true,
type: token.type,
outline: token.outline,
width: token.width,
height: token.height,
};
if (token.type === "file") {
tokenState.file = token.file;
} else if (token.type === "default") {
tokenState.key = token.key;
}
return tokenState;
}
export async function createTokenFromFile(file, userId) {
if (!file) {
return Promise.reject();
}
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();
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 () {
let assets = [];
const thumbnailImage = await createThumbnail(image, file.type);
const thumbnail = { ...thumbnailImage, id: uuid(), owner: userId };
assets.push(thumbnail);
const fileAsset = {
id: uuid(),
file: buffer,
width: image.width,
height: image.height,
mime: file.type,
owner: userId,
};
assets.push(fileAsset);
let outline = imageOutline(image);
if (outline.length > 100) {
outline = Vector2.resample(outline, 100);
}
// Flatten and round outline to save on storage size
outline = outline
.map(({ x, y }) => [Math.round(x), Math.round(y)])
.flat();
const token = {
name,
thumbnail: thumbnail.id,
file: fileAsset.id,
id: uuid(),
type: "file",
created: Date.now(),
lastModified: Date.now(),
owner: userId,
defaultSize: 1,
defaultCategory: "character",
defaultLabel: "",
hideInSidebar: false,
group: "",
width: image.width,
height: image.height,
outline,
};
URL.revokeObjectURL(url);
resolve({ token, assets });
};
image.onerror = reject;
image.src = url;
});
}

View File

@ -21,7 +21,13 @@ function useResponsiveLayout() {
? "medium"
: "large";
return { screenSize, modalSize, tileSize };
const gridTemplate = isLargeScreen
? "1fr 1fr 1fr 1fr"
: isMediumScreen
? "1fr 1fr 1fr"
: "1fr 1fr";
return { screenSize, modalSize, tileSize, gridTemplate };
}
export default useResponsiveLayout;

View File

@ -1,11 +1,8 @@
import React, { useRef, useState } from "react";
import { Button, Flex, Label } from "theme-ui";
import { v4 as uuid } from "uuid";
import Case from "case";
import { useToasts } from "react-toast-notifications";
import EditMapModal from "./EditMapModal";
import EditGroupModal from "./EditGroupModal";
import ConfirmModal from "./ConfirmModal";
import Modal from "../components/Modal";
@ -13,15 +10,8 @@ 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 { handleItemSelect } from "../helpers/select";
import { createMapFromFile } from "../helpers/map";
import useResponsiveLayout from "../hooks/useResponsiveLayout";
@ -32,24 +22,6 @@ import { useAssets } from "../contexts/AssetsContext";
import shortcuts from "../shortcuts";
const defaultMapProps = {
showGrid: false,
snapToGrid: true,
quality: "original",
group: "",
};
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,
@ -62,14 +34,15 @@ function SelectMapModal({
const { userId } = useAuth();
const {
ownedMaps,
maps,
mapStates,
mapGroups,
addMap,
removeMaps,
resetMap,
updateMaps,
mapsLoading,
getMapState,
updateMapGroups,
} = useMapData();
const { addAssets } = useAssets();
@ -77,31 +50,13 @@ function SelectMapModal({
* Search
*/
const [search, setSearch] = useState("");
const [filteredMaps, filteredMapScores] = useSearch(ownedMaps, search);
// TODO: Add back with new group support
// const [filteredMaps, filteredMapScores] = useSearch(ownedMaps, search);
function handleSearchChange(event) {
setSearch(event.target.value);
}
/**
* Group
*/
const [isGroupModalOpen, setIsGroupModalOpen] = useState(false);
async function handleMapsGroup(group) {
setIsLoading(true);
setIsGroupModalOpen(false);
await updateMaps(selectedMapIds, { group });
setIsLoading(false);
}
const [mapsByGroup, mapGroups] = useGroup(
ownedMaps,
filteredMaps,
!!search,
filteredMapScores
);
/**
* Image Upload
*/
@ -167,150 +122,12 @@ function SelectMapModal({
}
async function handleImageUpload(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 };
}
let assets = [];
// Create resolutions
const resolutions = {};
for (let resolution of mapResolutions) {
const resolutionPixelSize = Vector2.multiply(
gridSize,
resolution.size
);
if (
image.width >= resolutionPixelSize.x &&
image.height >= resolutionPixelSize.y
) {
const resized = await resizeImage(
image,
Vector2.max(resolutionPixelSize),
file.type,
resolution.quality
);
if (resized.blob) {
const assetId = uuid();
resolutions[resolution.id] = assetId;
const resizedBuffer = await blobToBuffer(resized.blob);
const asset = {
file: resizedBuffer,
width: resized.width,
height: resized.height,
id: assetId,
mime: file.type,
owner: userId,
};
assets.push(asset);
}
}
}
// Create thumbnail
const thumbnailImage = await createThumbnail(image, file.type);
const thumbnail = {
...thumbnailImage,
id: uuid(),
owner: userId,
};
assets.push(thumbnail);
const fileAsset = {
id: uuid(),
file: buffer,
width: image.width,
height: image.height,
mime: file.type,
owner: userId,
};
assets.push(fileAsset);
const map = {
name,
resolutions,
file: fileAsset.id,
thumbnail: thumbnail.id,
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: uuid(),
created: Date.now(),
lastModified: Date.now(),
owner: userId,
...defaultMapProps,
};
handleMapAdd(map, assets);
setIsLoading(false);
URL.revokeObjectURL(url);
resolve();
};
image.onerror = reject;
image.src = url;
});
const { map, assets } = await createMapFromFile(file, userId);
await addMap(map);
await addAssets(assets);
setSelectedMapIds([map.id]);
setIsLoading(false);
}
function openImageDialog() {
@ -326,19 +143,11 @@ function SelectMapModal({
// The map selected in the modal
const [selectedMapIds, setSelectedMapIds] = useState([]);
const selectedMaps = ownedMaps.filter((map) =>
selectedMapIds.includes(map.id)
);
const selectedMaps = maps.filter((map) => selectedMapIds.includes(map.id));
const selectedMapStates = mapStates.filter((state) =>
selectedMapIds.includes(state.mapId)
);
async function handleMapAdd(map, assets) {
await addMap(map);
await addAssets(assets);
setSelectedMapIds([map.id]);
}
const [isMapsRemoveModalOpen, setIsMapsRemoveModalOpen] = useState(false);
async function handleMapsRemove() {
setIsLoading(true);
@ -374,9 +183,8 @@ function SelectMapModal({
map,
selectMode,
selectedMapIds,
setSelectedMapIds,
mapsByGroup,
mapGroups
setSelectedMapIds
// TODO: Add new group support
);
}
@ -424,7 +232,6 @@ function SelectMapModal({
!selectedMaps.some((map) => map.type === "default")
) {
// Ensure all other modals are closed
setIsGroupModalOpen(false);
setIsEditModalOpen(false);
setIsMapsResetModalOpen(false);
setIsMapsRemoveModalOpen(true);
@ -479,7 +286,7 @@ function SelectMapModal({
Select or import a map
</Label>
<MapTiles
maps={mapsByGroup}
maps={maps}
groups={mapGroups}
onMapAdd={openImageDialog}
onMapEdit={() => setIsEditModalOpen(true)}
@ -493,7 +300,7 @@ function SelectMapModal({
onSelectModeChange={setSelectMode}
search={search}
onSearchChange={handleSearchChange}
onMapsGroup={() => setIsGroupModalOpen(true)}
onMapsGroup={updateMapGroups}
/>
<Button
variant="primary"
@ -512,21 +319,6 @@ function SelectMapModal({
map={selectedMaps.length === 1 && selectedMaps[0]}
mapState={selectedMapStates.length === 1 && selectedMapStates[0]}
/>
<EditGroupModal
isOpen={isGroupModalOpen}
onChange={handleMapsGroup}
groups={mapGroups.filter(
(group) => group !== "" && group !== "default"
)}
onRequestClose={() => setIsGroupModalOpen(false)}
// Select the default group by testing whether all selected maps are the same
defaultGroup={
selectedMaps.length > 0 &&
selectedMaps
.map((map) => map.group)
.reduce((prev, curr) => (prev === curr ? curr : undefined))
}
/>
<ConfirmModal
isOpen={isMapsResetModalOpen}
onRequestClose={() => setIsMapsResetModalOpen(false)}

View File

@ -1,12 +1,9 @@
import React, { useRef, useState } from "react";
import { Flex, Label, Button } from "theme-ui";
import { v4 as uuid } from "uuid";
import Case from "case";
import { useToasts } from "react-toast-notifications";
import imageOutline from "image-outline";
import EditTokenModal from "./EditTokenModal";
import EditGroupModal from "./EditGroupModal";
import ConfirmModal from "./ConfirmModal";
import Modal from "../components/Modal";
@ -14,10 +11,8 @@ 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 Vector2 from "../helpers/Vector2";
import { handleItemSelect } from "../helpers/select";
import { createTokenFromFile } from "../helpers/token";
import useResponsiveLayout from "../hooks/useResponsiveLayout";
@ -33,11 +28,13 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
const { userId } = useAuth();
const {
ownedTokens,
tokens,
addToken,
removeTokens,
updateTokens,
tokensLoading,
tokenGroups,
updateTokenGroups,
} = useTokenData();
const { addAssets } = useAssets();
@ -45,31 +42,12 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
* Search
*/
const [search, setSearch] = useState("");
const [filteredTokens, filteredTokenScores] = useSearch(ownedTokens, search);
// const [filteredTokens, filteredTokenScores] = useSearch(ownedTokens, search);
function handleSearchChange(event) {
setSearch(event.target.value);
}
/**
* Group
*/
const [isGroupModalOpen, setIsGroupModalOpen] = useState(false);
async function handleTokensGroup(group) {
setIsLoading(true);
setIsGroupModalOpen(false);
await updateTokens(selectedTokenIds, { group });
setIsLoading(false);
}
const [tokensByGroup, tokenGroups] = useGroup(
ownedTokens,
filteredTokens,
!!search,
filteredTokenScores
);
/**
* Image Upload
*/
@ -141,80 +119,12 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
}
async function handleImageUpload(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 () {
let assets = [];
const thumbnailImage = await createThumbnail(image, file.type);
const thumbnail = { ...thumbnailImage, id: uuid(), owner: userId };
assets.push(thumbnail);
const fileAsset = {
id: uuid(),
file: buffer,
width: image.width,
height: image.height,
mime: file.type,
owner: userId,
};
assets.push(fileAsset);
let outline = imageOutline(image);
if (outline.length > 100) {
outline = Vector2.resample(outline, 100);
}
// Flatten and round outline to save on storage size
outline = outline
.map(({ x, y }) => [Math.round(x), Math.round(y)])
.flat();
const token = {
name,
thumbnail: thumbnail.id,
file: fileAsset.id,
id: uuid(),
type: "file",
created: Date.now(),
lastModified: Date.now(),
owner: userId,
defaultSize: 1,
defaultCategory: "character",
defaultLabel: "",
hideInSidebar: false,
group: "",
width: image.width,
height: image.height,
outline,
};
handleTokenAdd(token, assets);
setIsLoading(false);
URL.revokeObjectURL(url);
resolve();
};
image.onerror = reject;
image.src = url;
});
const { token, assets } = await createTokenFromFile(file, userId);
await addToken(token);
await addAssets(assets);
setSelectedTokenIds([token.id]);
setIsLoading(false);
}
/**
@ -222,16 +132,10 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
*/
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [selectedTokenIds, setSelectedTokenIds] = useState([]);
const selectedTokens = ownedTokens.filter((token) =>
const selectedTokens = tokens.filter((token) =>
selectedTokenIds.includes(token.id)
);
async function handleTokenAdd(token, assets) {
await addToken(token);
await addAssets(assets);
setSelectedTokenIds([token.id]);
}
const [isTokensRemoveModalOpen, setIsTokensRemoveModalOpen] = useState(false);
async function handleTokensRemove() {
setIsLoading(true);
@ -255,9 +159,8 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
token,
selectMode,
selectedTokenIds,
setSelectedTokenIds,
tokensByGroup,
tokenGroups
setSelectedTokenIds
// TODO: Rework group support
);
}
@ -282,7 +185,6 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
) {
// Ensure all other modals are closed
setIsEditModalOpen(false);
setIsGroupModalOpen(false);
setIsTokensRemoveModalOpen(true);
}
}
@ -335,7 +237,7 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
Edit or import a token
</Label>
<TokenTiles
tokens={tokensByGroup}
tokens={tokens}
groups={tokenGroups}
onTokenAdd={openImageDialog}
onTokenEdit={() => setIsEditModalOpen(true)}
@ -346,7 +248,7 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
onSelectModeChange={setSelectMode}
search={search}
onSearchChange={handleSearchChange}
onTokensGroup={() => setIsGroupModalOpen(true)}
onTokensGroup={updateTokenGroups}
onTokensHide={handleTokensHide}
/>
<Button
@ -365,21 +267,6 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
onDone={() => setIsEditModalOpen(false)}
tokenId={selectedTokens.length === 1 && selectedTokens[0].id}
/>
<EditGroupModal
isOpen={isGroupModalOpen}
onChange={handleTokensGroup}
groups={tokenGroups.filter(
(group) => group !== "" && group !== "default"
)}
onRequestClose={() => setIsGroupModalOpen(false)}
// Select the default group by testing whether all selected tokens are the same
defaultGroup={
selectedTokens.length > 0 &&
selectedTokens
.map((map) => map.group)
.reduce((prev, curr) => (prev === curr ? curr : undefined))
}
/>
<ConfirmModal
isOpen={isTokensRemoveModalOpen}
onRequestClose={() => setIsTokensRemoveModalOpen(false)}

View File

@ -1,6 +1,5 @@
import React, { useState, useEffect, useRef } from "react";
import { useToasts } from "react-toast-notifications";
import { v4 as uuid } from "uuid";
import { useMapData } from "../contexts/MapDataContext";
import { useMapLoading } from "../contexts/MapLoadingContext";
@ -8,9 +7,6 @@ import { useAuth } from "../contexts/AuthContext";
import { useDatabase } from "../contexts/DatabaseContext";
import { useParty } from "../contexts/PartyContext";
import { useAssets } from "../contexts/AssetsContext";
import { DragProvider } from "../contexts/DragContext";
import { useTokenData } from "../contexts/TokenDataContext";
import { useMapStage } from "../contexts/MapStageContext";
import { omit } from "../helpers/shared";
@ -44,10 +40,8 @@ function NetworkedMapAndTokens({ session }) {
const { userId } = useAuth();
const partyState = useParty();
const { assetLoadStart, assetProgressUpdate, isLoading } = useMapLoading();
const mapStageRef = useMapStage();
const { updateMapState } = useMapData();
const { tokensById } = useTokenData();
const { getAsset, putAsset } = useAssets();
const [currentMap, setCurrentMap] = useState(null);
@ -385,53 +379,6 @@ function NetworkedMapAndTokens({ session }) {
});
}
function handleDragEnd({ active, over }) {
const tokenId = active?.data?.current?.tokenId;
const token = tokensById[tokenId];
const mapStage = mapStageRef.current;
if (over?.id === "map" && tokenId && token && mapStage) {
const mapImage = mapStage.findOne("#mapImage");
// TODO: Get proper pointer position when dnd-kit is updated
// https://github.com/clauderic/dnd-kit/issues/238
const pointerPosition = {
x: over.rect.width / 2,
y: over.rect.height / 2,
};
const transform = mapImage.getAbsoluteTransform().copy().invert();
const relativePosition = transform.point(pointerPosition);
const normalizedPosition = {
x: relativePosition.x / mapImage.width(),
y: relativePosition.y / mapImage.height(),
};
let tokenState = {
id: uuid(),
tokenId: token.id,
owner: userId,
size: token.defaultSize,
category: token.defaultCategory,
label: token.defaultLabel,
statuses: [],
x: normalizedPosition.x,
y: normalizedPosition.y,
lastModifiedBy: userId,
lastModified: Date.now(),
rotation: 0,
locked: false,
visible: true,
type: token.type,
outline: token.outline,
width: token.width,
height: token.height,
};
if (token.type === "file") {
tokenState.file = token.file;
} else if (token.type === "default") {
tokenState.key = token.key;
}
handleMapTokenStateCreate(tokenState);
}
}
useEffect(() => {
async function handlePeerData({ id, data, reply }) {
if (id === "assetRequest") {
@ -504,7 +451,7 @@ function NetworkedMapAndTokens({ session }) {
}
return (
<DragProvider onDragEnd={handleDragEnd}>
<>
<Map
map={currentMap}
mapState={currentMapState}
@ -528,8 +475,8 @@ function NetworkedMapAndTokens({ session }) {
disabledTokens={disabledMapTokens}
session={session}
/>
<TokenBar />
</DragProvider>
<TokenBar onMapTokenStateCreate={handleMapTokenStateCreate} />
</>
);
}

View File

@ -639,9 +639,23 @@ export const versions = {
tx.table("tokens").bulkAdd(tokens);
});
},
// v1.9.0 -
33(v) {
v.stores({ groups: "id" }).upgrade(async (tx) => {
let maps = await Dexie.waitFor(tx.table("maps").toArray());
maps = maps.sort((a, b) => b.created - a.created);
const mapIds = maps.map((map) => map.id);
tx.table("groups").add({ id: "maps", data: mapIds });
let tokens = await Dexie.waitFor(tx.table("tokens").toArray());
tokens = tokens.sort((a, b) => b.created - a.created);
const tokenIds = tokens.map((token) => token.id);
tx.table("groups").add({ id: "tokens", data: tokenIds });
});
},
};
export const latestVersion = 32;
export const latestVersion = 33;
/**
* Load versions onto a database up to a specific version number

View File

@ -1803,15 +1803,24 @@
dependencies:
tslib "^2.0.0"
"@dnd-kit/core@3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@dnd-kit/core/-/core-3.0.0.tgz#96dadb6b2dba05ab177e0190b33ae219017bc167"
integrity sha512-QxHLfZHOLkQWK0FPbr5hefWZzsdZfDuluKPwIK1bT2lwp/4hmFXRA6ivqX3FT4g8T0d2de2C1jxYhKM4H3uMQw==
"@dnd-kit/core@^3.0.0", "@dnd-kit/core@^3.0.2":
version "3.0.2"
resolved "https://registry.yarnpkg.com/@dnd-kit/core/-/core-3.0.2.tgz#e46ae11ef667aa5c31fddab21cf36ffd80d3ce5b"
integrity sha512-L+rGnDYBb4BfYKDylzIBeODRIlJ+YVvo2iL9pVXsh317Nq7c9irCvi3XK8JnWD5QBw/3WZ5FmbPmTE91EKwKeA==
dependencies:
"@dnd-kit/accessibility" "^3.0.0"
"@dnd-kit/utilities" "^2.0.0"
tslib "^2.0.0"
"@dnd-kit/sortable@^3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@dnd-kit/sortable/-/sortable-3.0.1.tgz#a63c2bcecb57c48cd72abcc6364b7c35d3af351f"
integrity sha512-fRflFwkj1hXkNZTy/nA6zlgLryZCDKm0OaJnzcFWu9TNZ7hZ0Ja6EMQwhOu6aGuHyCTUGTToBho9ZyyVN671qw==
dependencies:
"@dnd-kit/core" "^3.0.0"
"@dnd-kit/utilities" "^2.0.0"
tslib "^2.0.0"
"@dnd-kit/utilities@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@dnd-kit/utilities/-/utilities-2.0.0.tgz#a8462dff65c6f606ecbe95273c7e263b14a1ab97"