Add sort to token and map tiles and refactor
This commit is contained in:
parent
e1f5cb3014
commit
140ac7d61c
@ -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",
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
},
|
||||
|
31
src/components/Sortable.js
Normal file
31
src/components/Sortable.js
Normal 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;
|
@ -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();
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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"
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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"
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
@ -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>
|
||||
|
@ -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 (
|
||||
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
@ -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
113
src/helpers/token.js
Normal 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;
|
||||
});
|
||||
}
|
@ -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;
|
||||
|
@ -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)}
|
||||
|
@ -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)}
|
||||
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
17
yarn.lock
17
yarn.lock
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user