Add sort to token and map tiles and refactor
This commit is contained in:
parent
e1f5cb3014
commit
140ac7d61c
|
@ -5,7 +5,8 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babylonjs/core": "^4.2.0",
|
"@babylonjs/core": "^4.2.0",
|
||||||
"@babylonjs/loaders": "^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",
|
"@mitchemmc/dexie-export-import": "^1.0.1",
|
||||||
"@msgpack/msgpack": "^2.4.1",
|
"@msgpack/msgpack": "^2.4.1",
|
||||||
"@sentry/react": "^6.2.2",
|
"@sentry/react": "^6.2.2",
|
||||||
|
|
|
@ -8,19 +8,15 @@ function Draggable({ id, children, data }) {
|
||||||
});
|
});
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
border: "none",
|
|
||||||
background: "transparent",
|
|
||||||
margin: "0px",
|
|
||||||
padding: "0px",
|
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
touchAction: "none",
|
touchAction: "none",
|
||||||
opacity: isDragging ? 0.5 : undefined,
|
opacity: isDragging ? 0.5 : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button ref={setNodeRef} style={style} {...listeners} {...attributes}>
|
<div ref={setNodeRef} style={style} {...listeners} {...attributes}>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -17,15 +17,16 @@ function StyledModal({
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onRequestClose={onRequestClose}
|
onRequestClose={onRequestClose}
|
||||||
style={{
|
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: {
|
content: {
|
||||||
backgroundColor: theme.colors.background,
|
backgroundColor: theme.colors.background,
|
||||||
top: "50%",
|
inset: "initial",
|
||||||
left: "50%",
|
|
||||||
right: "auto",
|
|
||||||
bottom: "auto",
|
|
||||||
marginRight: "-50%",
|
|
||||||
transform: "translate(-50%, -50%)",
|
|
||||||
maxHeight: "100%",
|
maxHeight: "100%",
|
||||||
...style,
|
...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,
|
onSelect,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDoubleClick,
|
onDoubleClick,
|
||||||
size,
|
|
||||||
canEdit,
|
canEdit,
|
||||||
badges,
|
badges,
|
||||||
editTitle,
|
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 (
|
return (
|
||||||
<Flex
|
<Flex
|
||||||
sx={{
|
sx={{
|
||||||
position: "relative",
|
position: "relative",
|
||||||
width: width,
|
width: "100%",
|
||||||
height: "0",
|
height: "0",
|
||||||
paddingTop: width,
|
paddingTop: "100%",
|
||||||
borderRadius: "4px",
|
borderRadius: "4px",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
@ -48,8 +28,6 @@ function Tile({
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
userSelect: "none",
|
userSelect: "none",
|
||||||
}}
|
}}
|
||||||
my={1}
|
|
||||||
mx={margin}
|
|
||||||
bg="muted"
|
bg="muted"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
|
@ -2,7 +2,7 @@ import React from "react";
|
||||||
|
|
||||||
import Tile from "../Tile";
|
import Tile from "../Tile";
|
||||||
|
|
||||||
function DiceTile({ dice, isSelected, onDiceSelect, onDone, size }) {
|
function DiceTile({ dice, isSelected, onDiceSelect, onDone }) {
|
||||||
return (
|
return (
|
||||||
<Tile
|
<Tile
|
||||||
src={dice.preview}
|
src={dice.preview}
|
||||||
|
@ -10,7 +10,6 @@ function DiceTile({ dice, isSelected, onDiceSelect, onDone, size }) {
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
onSelect={() => onDiceSelect(dice)}
|
onSelect={() => onDiceSelect(dice)}
|
||||||
onDoubleClick={() => onDone(dice)}
|
onDoubleClick={() => onDone(dice)}
|
||||||
size={size}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Flex } from "theme-ui";
|
import { Grid } from "theme-ui";
|
||||||
import SimpleBar from "simplebar-react";
|
import SimpleBar from "simplebar-react";
|
||||||
|
|
||||||
import DiceTile from "./DiceTile";
|
import DiceTile from "./DiceTile";
|
||||||
|
@ -13,16 +13,16 @@ function DiceTiles({ dice, onDiceSelect, selectedDice, onDone }) {
|
||||||
<SimpleBar
|
<SimpleBar
|
||||||
style={{ height: layout.screenSize === "large" ? "600px" : "400px" }}
|
style={{ height: layout.screenSize === "large" ? "600px" : "400px" }}
|
||||||
>
|
>
|
||||||
<Flex
|
<Grid
|
||||||
p={2}
|
p={2}
|
||||||
pb={4}
|
pb={4}
|
||||||
bg="muted"
|
bg="muted"
|
||||||
sx={{
|
sx={{
|
||||||
flexWrap: "wrap",
|
|
||||||
borderRadius: "4px",
|
borderRadius: "4px",
|
||||||
minHeight: layout.screenSize === "large" ? "600px" : "400px",
|
minHeight: layout.screenSize === "large" ? "600px" : "400px",
|
||||||
alignContent: "flex-start",
|
|
||||||
}}
|
}}
|
||||||
|
gap={2}
|
||||||
|
columns={layout.gridTemplate}
|
||||||
>
|
>
|
||||||
{dice.map((dice) => (
|
{dice.map((dice) => (
|
||||||
<DiceTile
|
<DiceTile
|
||||||
|
@ -34,7 +34,7 @@ function DiceTiles({ dice, onDiceSelect, selectedDice, onDone }) {
|
||||||
size={layout.tileSize}
|
size={layout.tileSize}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Flex>
|
</Grid>
|
||||||
</SimpleBar>
|
</SimpleBar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,7 +11,6 @@ function MapTile({
|
||||||
onMapSelect,
|
onMapSelect,
|
||||||
onMapEdit,
|
onMapEdit,
|
||||||
onDone,
|
onDone,
|
||||||
size,
|
|
||||||
canEdit,
|
canEdit,
|
||||||
badges,
|
badges,
|
||||||
}) {
|
}) {
|
||||||
|
@ -30,7 +29,6 @@ function MapTile({
|
||||||
onSelect={() => onMapSelect(map)}
|
onSelect={() => onMapSelect(map)}
|
||||||
onEdit={() => onMapEdit(map.id)}
|
onEdit={() => onMapEdit(map.id)}
|
||||||
onDoubleClick={() => canEdit && onDone()}
|
onDoubleClick={() => canEdit && onDone()}
|
||||||
size={size}
|
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
badges={badges}
|
badges={badges}
|
||||||
editTitle="Edit Map"
|
editTitle="Edit Map"
|
||||||
|
|
|
@ -1,15 +1,16 @@
|
||||||
import React from "react";
|
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 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 RemoveMapIcon from "../../icons/RemoveMapIcon";
|
||||||
import ResetMapIcon from "../../icons/ResetMapIcon";
|
import ResetMapIcon from "../../icons/ResetMapIcon";
|
||||||
import GroupIcon from "../../icons/GroupIcon";
|
|
||||||
|
|
||||||
import MapTile from "./MapTile";
|
import MapTile from "./MapTile";
|
||||||
import Link from "../Link";
|
import Link from "../Link";
|
||||||
import FilterBar from "../FilterBar";
|
import FilterBar from "../FilterBar";
|
||||||
|
import Sortable from "../Sortable";
|
||||||
|
|
||||||
import { useDatabase } from "../../contexts/DatabaseContext";
|
import { useDatabase } from "../../contexts/DatabaseContext";
|
||||||
|
|
||||||
|
@ -73,107 +74,111 @@ function MapTiles({
|
||||||
|
|
||||||
const multipleSelected = selectedMaps.length > 1;
|
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 (
|
return (
|
||||||
<Box sx={{ position: "relative" }}>
|
<DndContext onDragEnd={handleDragEnd}>
|
||||||
<FilterBar
|
<SortableContext items={groups}>
|
||||||
onFocus={() => onMapSelect()}
|
<Box sx={{ position: "relative" }}>
|
||||||
search={search}
|
<FilterBar
|
||||||
onSearchChange={onSearchChange}
|
onFocus={() => onMapSelect()}
|
||||||
selectMode={selectMode}
|
search={search}
|
||||||
onSelectModeChange={onSelectModeChange}
|
onSearchChange={onSearchChange}
|
||||||
onAdd={onMapAdd}
|
selectMode={selectMode}
|
||||||
addTitle="Add Map"
|
onSelectModeChange={onSelectModeChange}
|
||||||
/>
|
onAdd={onMapAdd}
|
||||||
<SimpleBar
|
addTitle="Add Map"
|
||||||
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()}
|
|
||||||
/>
|
/>
|
||||||
<Flex>
|
<SimpleBar
|
||||||
<IconButton
|
style={{
|
||||||
aria-label={multipleSelected ? "Group Maps" : "Group Map"}
|
height: layout.screenSize === "large" ? "600px" : "400px",
|
||||||
title={multipleSelected ? "Group Maps" : "Group Map"}
|
}}
|
||||||
onClick={() => onMapsGroup()}
|
>
|
||||||
disabled={hasSelectedDefaultMap}
|
<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 />
|
{groups.map((mapId) => (
|
||||||
</IconButton>
|
<Sortable id={mapId} key={mapId}>
|
||||||
<IconButton
|
{mapToTile(maps.find((map) => map.id === mapId))}
|
||||||
aria-label={multipleSelected ? "Reset Maps" : "Reset Map"}
|
</Sortable>
|
||||||
title={multipleSelected ? "Reset Maps" : "Reset Map"}
|
))}
|
||||||
onClick={() => onMapsReset()}
|
</Grid>
|
||||||
disabled={!hasMapState}
|
</SimpleBar>
|
||||||
|
{databaseStatus === "disabled" && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "39px",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
textAlign: "center",
|
||||||
|
borderRadius: "2px",
|
||||||
|
}}
|
||||||
|
bg="highlight"
|
||||||
|
p={1}
|
||||||
>
|
>
|
||||||
<ResetMapIcon />
|
<Text as="p" variant="body2">
|
||||||
</IconButton>
|
Map saving is unavailable. See <Link to="/faq#saving">FAQ</Link>{" "}
|
||||||
<IconButton
|
for more information.
|
||||||
aria-label={multipleSelected ? "Remove Maps" : "Remove Map"}
|
</Text>
|
||||||
title={multipleSelected ? "Remove Maps" : "Remove Map"}
|
</Box>
|
||||||
onClick={() => onMapsRemove()}
|
)}
|
||||||
disabled={hasSelectedDefaultMap}
|
{selectedMaps.length > 0 && (
|
||||||
|
<Flex
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
bg="overlay"
|
||||||
>
|
>
|
||||||
<RemoveMapIcon />
|
<Close
|
||||||
</IconButton>
|
title="Clear Selection"
|
||||||
</Flex>
|
aria-label="Clear Selection"
|
||||||
</Flex>
|
onClick={() => onMapSelect()}
|
||||||
)}
|
/>
|
||||||
</Box>
|
<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 { createPortal } from "react-dom";
|
||||||
import { Box, Flex } from "theme-ui";
|
import { Box, Flex } from "theme-ui";
|
||||||
import SimpleBar from "simplebar-react";
|
import SimpleBar from "simplebar-react";
|
||||||
import { DragOverlay } from "@dnd-kit/core";
|
import { DragOverlay, DndContext } from "@dnd-kit/core";
|
||||||
|
|
||||||
import ListToken from "./ListToken";
|
import ListToken from "./ListToken";
|
||||||
import SelectTokensButton from "./SelectTokensButton";
|
import SelectTokensButton from "./SelectTokensButton";
|
||||||
|
@ -11,67 +11,82 @@ import Draggable from "../Draggable";
|
||||||
import useSetting from "../../hooks/useSetting";
|
import useSetting from "../../hooks/useSetting";
|
||||||
|
|
||||||
import { useTokenData } from "../../contexts/TokenDataContext";
|
import { useTokenData } from "../../contexts/TokenDataContext";
|
||||||
import { useDragId } from "../../contexts/DragContext";
|
import { useAuth } from "../../contexts/AuthContext";
|
||||||
|
|
||||||
function TokenBar() {
|
import { createTokenState } from "../../helpers/token";
|
||||||
const { ownedTokens } = useTokenData();
|
|
||||||
|
function TokenBar({ onMapTokenStateCreate }) {
|
||||||
|
const { userId } = useAuth();
|
||||||
|
const { tokensById, tokenGroups } = useTokenData();
|
||||||
const [fullScreen] = useSetting("map.fullScreen");
|
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 (
|
return (
|
||||||
<Box
|
<DndContext
|
||||||
sx={{
|
onDragStart={handleDragStart}
|
||||||
height: "100%",
|
onDragEnd={handleDragEnd}
|
||||||
width: "80px",
|
autoScroll={false}
|
||||||
minWidth: "80px",
|
|
||||||
overflowY: "scroll",
|
|
||||||
overflowX: "hidden",
|
|
||||||
display: fullScreen ? "none" : "block",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<SimpleBar
|
<Box
|
||||||
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"
|
|
||||||
sx={{
|
sx={{
|
||||||
justifyContent: "center",
|
height: "100%",
|
||||||
height: "48px",
|
width: "80px",
|
||||||
alignItems: "center",
|
minWidth: "80px",
|
||||||
|
overflowY: "scroll",
|
||||||
|
overflowX: "hidden",
|
||||||
|
display: fullScreen ? "none" : "block",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTokensButton />
|
<SimpleBar
|
||||||
</Flex>
|
style={{
|
||||||
{createPortal(
|
height: "calc(100% - 48px)",
|
||||||
<DragOverlay>
|
overflowX: "hidden",
|
||||||
{activeDragId && (
|
padding: "0 16px",
|
||||||
<ListToken
|
}}
|
||||||
token={ownedTokens.find(
|
>
|
||||||
(token) => `sidebar-${token.id}` === activeDragId
|
{tokens}
|
||||||
)}
|
</SimpleBar>
|
||||||
/>
|
<Flex
|
||||||
)}
|
bg="muted"
|
||||||
</DragOverlay>,
|
sx={{
|
||||||
document.body
|
justifyContent: "center",
|
||||||
)}
|
height: "48px",
|
||||||
</Box>
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTokensButton />
|
||||||
|
</Flex>
|
||||||
|
{createPortal(
|
||||||
|
<DragOverlay>
|
||||||
|
{dragId && <ListToken token={tokensById[dragId]} />}
|
||||||
|
</DragOverlay>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</DndContext>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,6 @@ function TokenTile({
|
||||||
isSelected,
|
isSelected,
|
||||||
onTokenSelect,
|
onTokenSelect,
|
||||||
onTokenEdit,
|
onTokenEdit,
|
||||||
size,
|
|
||||||
canEdit,
|
canEdit,
|
||||||
badges,
|
badges,
|
||||||
}) {
|
}) {
|
||||||
|
@ -29,7 +28,6 @@ function TokenTile({
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
onSelect={() => onTokenSelect(token)}
|
onSelect={() => onTokenSelect(token)}
|
||||||
onEdit={() => onTokenEdit(token.id)}
|
onEdit={() => onTokenEdit(token.id)}
|
||||||
size={size}
|
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
badges={badges}
|
badges={badges}
|
||||||
editTitle="Edit Token"
|
editTitle="Edit Token"
|
||||||
|
|
|
@ -1,16 +1,17 @@
|
||||||
import React from "react";
|
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 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 RemoveTokenIcon from "../../icons/RemoveTokenIcon";
|
||||||
import GroupIcon from "../../icons/GroupIcon";
|
|
||||||
import TokenHideIcon from "../../icons/TokenHideIcon";
|
import TokenHideIcon from "../../icons/TokenHideIcon";
|
||||||
import TokenShowIcon from "../../icons/TokenShowIcon";
|
import TokenShowIcon from "../../icons/TokenShowIcon";
|
||||||
|
|
||||||
import TokenTile from "./TokenTile";
|
import TokenTile from "./TokenTile";
|
||||||
import Link from "../Link";
|
import Link from "../Link";
|
||||||
import FilterBar from "../FilterBar";
|
import FilterBar from "../FilterBar";
|
||||||
|
import Sortable from "../Sortable";
|
||||||
|
|
||||||
import { useDatabase } from "../../contexts/DatabaseContext";
|
import { useDatabase } from "../../contexts/DatabaseContext";
|
||||||
|
|
||||||
|
@ -48,7 +49,6 @@ function TokenTiles({
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
onTokenSelect={onTokenSelect}
|
onTokenSelect={onTokenSelect}
|
||||||
onTokenEdit={onTokenEdit}
|
onTokenEdit={onTokenEdit}
|
||||||
size={layout.tileSize}
|
|
||||||
canEdit={
|
canEdit={
|
||||||
isSelected &&
|
isSelected &&
|
||||||
token.type !== "default" &&
|
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 (
|
return (
|
||||||
<Box sx={{ position: "relative" }}>
|
<DndContext onDragEnd={handleDragEnd}>
|
||||||
<FilterBar
|
<SortableContext items={groups}>
|
||||||
onFocus={() => onTokenSelect()}
|
<Box sx={{ position: "relative" }}>
|
||||||
search={search}
|
<FilterBar
|
||||||
onSearchChange={onSearchChange}
|
onFocus={() => onTokenSelect()}
|
||||||
selectMode={selectMode}
|
search={search}
|
||||||
onSelectModeChange={onSelectModeChange}
|
onSearchChange={onSearchChange}
|
||||||
onAdd={onTokenAdd}
|
selectMode={selectMode}
|
||||||
addTitle="Add Token"
|
onSelectModeChange={onSelectModeChange}
|
||||||
/>
|
onAdd={onTokenAdd}
|
||||||
<SimpleBar
|
addTitle="Add Token"
|
||||||
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()}
|
|
||||||
/>
|
/>
|
||||||
<Flex>
|
<SimpleBar
|
||||||
<IconButton
|
style={{
|
||||||
aria-label={hideTitle}
|
height: layout.screenSize === "large" ? "600px" : "400px",
|
||||||
title={hideTitle}
|
}}
|
||||||
disabled={hasSelectedDefaultToken}
|
>
|
||||||
onClick={() => onTokensHide(allTokensVisible)}
|
<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 />}
|
{groups.map((tokenId) => (
|
||||||
</IconButton>
|
<Sortable id={tokenId} key={tokenId}>
|
||||||
<IconButton
|
{tokenToTile(tokens.find((token) => token.id === tokenId))}
|
||||||
aria-label={multipleSelected ? "Group Tokens" : "Group Token"}
|
</Sortable>
|
||||||
title={multipleSelected ? "Group Tokens" : "Group Token"}
|
))}
|
||||||
onClick={() => onTokensGroup()}
|
</Grid>
|
||||||
disabled={hasSelectedDefaultToken}
|
</SimpleBar>
|
||||||
|
{databaseStatus === "disabled" && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "39px",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
textAlign: "center",
|
||||||
|
borderRadius: "2px",
|
||||||
|
}}
|
||||||
|
bg="highlight"
|
||||||
|
p={1}
|
||||||
>
|
>
|
||||||
<GroupIcon />
|
<Text as="p" variant="body2">
|
||||||
</IconButton>
|
Token saving is unavailable. See{" "}
|
||||||
<IconButton
|
<Link to="/faq#saving">FAQ</Link> for more information.
|
||||||
aria-label={multipleSelected ? "Remove Tokens" : "Remove Token"}
|
</Text>
|
||||||
title={multipleSelected ? "Remove Tokens" : "Remove Token"}
|
</Box>
|
||||||
onClick={() => onTokensRemove()}
|
)}
|
||||||
disabled={hasSelectedDefaultToken}
|
{selectedTokens.length > 0 && (
|
||||||
|
<Flex
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
bg="overlay"
|
||||||
>
|
>
|
||||||
<RemoveTokenIcon />
|
<Close
|
||||||
</IconButton>
|
title="Clear Selection"
|
||||||
</Flex>
|
aria-label="Clear Selection"
|
||||||
</Flex>
|
onClick={() => onTokenSelect()}
|
||||||
)}
|
/>
|
||||||
</Box>
|
<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 [maps, setMaps] = useState([]);
|
||||||
const [mapStates, setMapStates] = useState([]);
|
const [mapStates, setMapStates] = useState([]);
|
||||||
const [mapsLoading, setMapsLoading] = useState(true);
|
const [mapsLoading, setMapsLoading] = useState(true);
|
||||||
|
const [mapGroups, setMapGroups] = useState([]);
|
||||||
|
|
||||||
// Load maps from the database and ensure state is properly setup
|
// Load maps from the database and ensure state is properly setup
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -42,11 +43,12 @@ export function MapDataProvider({ children }) {
|
||||||
storedMaps.push(map);
|
storedMaps.push(map);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// TODO: remove sort when groups are added
|
setMaps(storedMaps);
|
||||||
const sortedMaps = storedMaps.sort((a, b) => b.created - a.created);
|
|
||||||
setMaps(sortedMaps);
|
|
||||||
const storedStates = await database.table("states").toArray();
|
const storedStates = await database.table("states").toArray();
|
||||||
setMapStates(storedStates);
|
setMapStates(storedStates);
|
||||||
|
const group = await database.table("groups").get("maps");
|
||||||
|
const storedGroups = group.data;
|
||||||
|
setMapGroups(storedGroups);
|
||||||
setMapsLoading(false);
|
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
|
* @param {Object} map map to add
|
||||||
*/
|
*/
|
||||||
const addMap = useCallback(
|
const addMap = useCallback(
|
||||||
|
@ -79,6 +81,10 @@ export function MapDataProvider({ children }) {
|
||||||
const state = { ...defaultMapState, mapId: map.id };
|
const state = { ...defaultMapState, mapId: map.id };
|
||||||
await database.table("maps").add(map);
|
await database.table("maps").add(map);
|
||||||
await database.table("states").add(state);
|
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]
|
[database]
|
||||||
);
|
);
|
||||||
|
@ -136,6 +142,15 @@ export function MapDataProvider({ children }) {
|
||||||
[database]
|
[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
|
// Create DB observable to sync creating and deleting
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!database || databaseStatus === "loading") {
|
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]);
|
}, [database, databaseStatus]);
|
||||||
|
|
||||||
const ownedMaps = maps.filter((map) => map.owner === userId);
|
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
maps,
|
maps,
|
||||||
ownedMaps,
|
|
||||||
mapStates,
|
mapStates,
|
||||||
|
mapGroups,
|
||||||
addMap,
|
addMap,
|
||||||
removeMaps,
|
removeMaps,
|
||||||
resetMap,
|
resetMap,
|
||||||
|
@ -215,6 +233,7 @@ export function MapDataProvider({ children }) {
|
||||||
getMap,
|
getMap,
|
||||||
mapsLoading,
|
mapsLoading,
|
||||||
getMapState,
|
getMapState,
|
||||||
|
updateMapGroups,
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<MapDataContext.Provider value={value}>{children}</MapDataContext.Provider>
|
<MapDataContext.Provider value={value}>{children}</MapDataContext.Provider>
|
||||||
|
|
|
@ -12,6 +12,7 @@ export function TokenDataProvider({ children }) {
|
||||||
|
|
||||||
const [tokens, setTokens] = useState([]);
|
const [tokens, setTokens] = useState([]);
|
||||||
const [tokensLoading, setTokensLoading] = useState(true);
|
const [tokensLoading, setTokensLoading] = useState(true);
|
||||||
|
const [tokenGroups, setTokenGroups] = useState([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!userId || !database || databaseStatus === "loading") {
|
if (!userId || !database || databaseStatus === "loading") {
|
||||||
|
@ -30,8 +31,10 @@ export function TokenDataProvider({ children }) {
|
||||||
storedTokens.push(token);
|
storedTokens.push(token);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const sortedTokens = storedTokens.sort((a, b) => b.created - a.created);
|
setTokens(storedTokens);
|
||||||
setTokens(sortedTokens);
|
const group = await database.table("groups").get("tokens");
|
||||||
|
const storedGroups = group.data;
|
||||||
|
setTokenGroups(storedGroups);
|
||||||
setTokensLoading(false);
|
setTokensLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -46,9 +49,14 @@ export function TokenDataProvider({ children }) {
|
||||||
[database]
|
[database]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Add token and add it to the token group
|
||||||
const addToken = useCallback(
|
const addToken = useCallback(
|
||||||
async (token) => {
|
async (token) => {
|
||||||
await database.table("tokens").add(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]
|
[database]
|
||||||
);
|
);
|
||||||
|
@ -87,6 +95,15 @@ export function TokenDataProvider({ children }) {
|
||||||
[database]
|
[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
|
// Create DB observable to sync creating and deleting
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!database || databaseStatus === "loading") {
|
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]);
|
}, [database, databaseStatus]);
|
||||||
|
|
||||||
const ownedTokens = tokens.filter((token) => token.owner === userId);
|
|
||||||
|
|
||||||
const tokensById = tokens.reduce((obj, token) => {
|
const tokensById = tokens.reduce((obj, token) => {
|
||||||
obj[token.id] = token;
|
obj[token.id] = token;
|
||||||
return obj;
|
return obj;
|
||||||
|
@ -139,14 +159,15 @@ export function TokenDataProvider({ children }) {
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
tokens,
|
tokens,
|
||||||
ownedTokens,
|
|
||||||
addToken,
|
addToken,
|
||||||
|
tokenGroups,
|
||||||
removeTokens,
|
removeTokens,
|
||||||
updateToken,
|
updateToken,
|
||||||
updateTokens,
|
updateTokens,
|
||||||
tokensById,
|
tokensById,
|
||||||
tokensLoading,
|
tokensLoading,
|
||||||
getToken,
|
getToken,
|
||||||
|
updateTokenGroups,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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
|
* Get the asset id of the preview file to send for a map
|
||||||
* @param {any} map
|
* @param {any} map
|
||||||
|
@ -25,3 +55,141 @@ export function getMapPreviewAsset(map) {
|
||||||
return;
|
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];
|
return [filteredItems, filteredItemScores];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Rework group support
|
||||||
// Helper for grouping items
|
// Helper for grouping items
|
||||||
export function useGroup(items, filteredItems, useFiltered, filteredScores) {
|
export function useGroup(items, filteredItems, useFiltered, filteredScores) {
|
||||||
const itemsByGroup = groupBy(useFiltered ? filteredItems : items, "group");
|
const itemsByGroup = groupBy(useFiltered ? filteredItems : items, "group");
|
||||||
|
@ -92,41 +93,43 @@ export function handleItemSelect(
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case "range":
|
case "range":
|
||||||
// Create items array
|
/// TODO: Fix when new groups system is added
|
||||||
let items = itemGroups.reduce(
|
return;
|
||||||
(acc, group) => [...acc, ...itemsByGroup[group]],
|
// Create items array
|
||||||
[]
|
// let items = itemGroups.reduce(
|
||||||
);
|
// (acc, group) => [...acc, ...itemsByGroup[group]],
|
||||||
|
// []
|
||||||
|
// );
|
||||||
|
|
||||||
// Add all items inbetween the previous selected item and the current selected
|
// // Add all items inbetween the previous selected item and the current selected
|
||||||
if (selectedIds.length > 0) {
|
// if (selectedIds.length > 0) {
|
||||||
const mapIndex = items.findIndex((m) => m.id === item.id);
|
// const mapIndex = items.findIndex((m) => m.id === item.id);
|
||||||
const lastIndex = items.findIndex(
|
// const lastIndex = items.findIndex(
|
||||||
(m) => m.id === selectedIds[selectedIds.length - 1]
|
// (m) => m.id === selectedIds[selectedIds.length - 1]
|
||||||
);
|
// );
|
||||||
let idsToAdd = [];
|
// let idsToAdd = [];
|
||||||
let idsToRemove = [];
|
// let idsToRemove = [];
|
||||||
const direction = mapIndex > lastIndex ? 1 : -1;
|
// const direction = mapIndex > lastIndex ? 1 : -1;
|
||||||
for (
|
// for (
|
||||||
let i = lastIndex + direction;
|
// let i = lastIndex + direction;
|
||||||
direction < 0 ? i >= mapIndex : i <= mapIndex;
|
// direction < 0 ? i >= mapIndex : i <= mapIndex;
|
||||||
i += direction
|
// i += direction
|
||||||
) {
|
// ) {
|
||||||
const itemId = items[i].id;
|
// const itemId = items[i].id;
|
||||||
if (selectedIds.includes(itemId)) {
|
// if (selectedIds.includes(itemId)) {
|
||||||
idsToRemove.push(itemId);
|
// idsToRemove.push(itemId);
|
||||||
} else {
|
// } else {
|
||||||
idsToAdd.push(itemId);
|
// idsToAdd.push(itemId);
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
setSelectedIds((prev) => {
|
// setSelectedIds((prev) => {
|
||||||
let ids = [...prev, ...idsToAdd];
|
// let ids = [...prev, ...idsToAdd];
|
||||||
return ids.filter((id) => !idsToRemove.includes(id));
|
// return ids.filter((id) => !idsToRemove.includes(id));
|
||||||
});
|
// });
|
||||||
} else {
|
// } else {
|
||||||
setSelectedIds([item.id]);
|
// setSelectedIds([item.id]);
|
||||||
}
|
// }
|
||||||
break;
|
// break;
|
||||||
default:
|
default:
|
||||||
setSelectedIds([]);
|
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"
|
? "medium"
|
||||||
: "large";
|
: "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;
|
export default useResponsiveLayout;
|
||||||
|
|
|
@ -1,11 +1,8 @@
|
||||||
import React, { useRef, useState } from "react";
|
import React, { useRef, useState } from "react";
|
||||||
import { Button, Flex, Label } from "theme-ui";
|
import { Button, Flex, Label } from "theme-ui";
|
||||||
import { v4 as uuid } from "uuid";
|
|
||||||
import Case from "case";
|
|
||||||
import { useToasts } from "react-toast-notifications";
|
import { useToasts } from "react-toast-notifications";
|
||||||
|
|
||||||
import EditMapModal from "./EditMapModal";
|
import EditMapModal from "./EditMapModal";
|
||||||
import EditGroupModal from "./EditGroupModal";
|
|
||||||
import ConfirmModal from "./ConfirmModal";
|
import ConfirmModal from "./ConfirmModal";
|
||||||
|
|
||||||
import Modal from "../components/Modal";
|
import Modal from "../components/Modal";
|
||||||
|
@ -13,15 +10,8 @@ import MapTiles from "../components/map/MapTiles";
|
||||||
import ImageDrop from "../components/ImageDrop";
|
import ImageDrop from "../components/ImageDrop";
|
||||||
import LoadingOverlay from "../components/LoadingOverlay";
|
import LoadingOverlay from "../components/LoadingOverlay";
|
||||||
|
|
||||||
import blobToBuffer from "../helpers/blobToBuffer";
|
import { handleItemSelect } from "../helpers/select";
|
||||||
import { resizeImage, createThumbnail } from "../helpers/image";
|
import { createMapFromFile } from "../helpers/map";
|
||||||
import { useSearch, useGroup, handleItemSelect } from "../helpers/select";
|
|
||||||
import {
|
|
||||||
getGridDefaultInset,
|
|
||||||
getGridSizeFromImage,
|
|
||||||
gridSizeVaild,
|
|
||||||
} from "../helpers/grid";
|
|
||||||
import Vector2 from "../helpers/Vector2";
|
|
||||||
|
|
||||||
import useResponsiveLayout from "../hooks/useResponsiveLayout";
|
import useResponsiveLayout from "../hooks/useResponsiveLayout";
|
||||||
|
|
||||||
|
@ -32,24 +22,6 @@ import { useAssets } from "../contexts/AssetsContext";
|
||||||
|
|
||||||
import shortcuts from "../shortcuts";
|
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({
|
function SelectMapModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
onDone,
|
onDone,
|
||||||
|
@ -62,14 +34,15 @@ function SelectMapModal({
|
||||||
|
|
||||||
const { userId } = useAuth();
|
const { userId } = useAuth();
|
||||||
const {
|
const {
|
||||||
ownedMaps,
|
maps,
|
||||||
mapStates,
|
mapStates,
|
||||||
|
mapGroups,
|
||||||
addMap,
|
addMap,
|
||||||
removeMaps,
|
removeMaps,
|
||||||
resetMap,
|
resetMap,
|
||||||
updateMaps,
|
|
||||||
mapsLoading,
|
mapsLoading,
|
||||||
getMapState,
|
getMapState,
|
||||||
|
updateMapGroups,
|
||||||
} = useMapData();
|
} = useMapData();
|
||||||
const { addAssets } = useAssets();
|
const { addAssets } = useAssets();
|
||||||
|
|
||||||
|
@ -77,31 +50,13 @@ function SelectMapModal({
|
||||||
* Search
|
* Search
|
||||||
*/
|
*/
|
||||||
const [search, setSearch] = useState("");
|
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) {
|
function handleSearchChange(event) {
|
||||||
setSearch(event.target.value);
|
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
|
* Image Upload
|
||||||
*/
|
*/
|
||||||
|
@ -167,150 +122,12 @@ function SelectMapModal({
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleImageUpload(file) {
|
async function handleImageUpload(file) {
|
||||||
if (!file) {
|
|
||||||
return Promise.reject();
|
|
||||||
}
|
|
||||||
let image = new Image();
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
const { map, assets } = await createMapFromFile(file, userId);
|
||||||
const buffer = await blobToBuffer(file);
|
await addMap(map);
|
||||||
// Copy file to avoid permissions issues
|
await addAssets(assets);
|
||||||
const blob = new Blob([buffer]);
|
setSelectedMapIds([map.id]);
|
||||||
// Create and load the image temporarily to get its dimensions
|
setIsLoading(false);
|
||||||
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;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openImageDialog() {
|
function openImageDialog() {
|
||||||
|
@ -326,19 +143,11 @@ function SelectMapModal({
|
||||||
// The map selected in the modal
|
// The map selected in the modal
|
||||||
const [selectedMapIds, setSelectedMapIds] = useState([]);
|
const [selectedMapIds, setSelectedMapIds] = useState([]);
|
||||||
|
|
||||||
const selectedMaps = ownedMaps.filter((map) =>
|
const selectedMaps = maps.filter((map) => selectedMapIds.includes(map.id));
|
||||||
selectedMapIds.includes(map.id)
|
|
||||||
);
|
|
||||||
const selectedMapStates = mapStates.filter((state) =>
|
const selectedMapStates = mapStates.filter((state) =>
|
||||||
selectedMapIds.includes(state.mapId)
|
selectedMapIds.includes(state.mapId)
|
||||||
);
|
);
|
||||||
|
|
||||||
async function handleMapAdd(map, assets) {
|
|
||||||
await addMap(map);
|
|
||||||
await addAssets(assets);
|
|
||||||
setSelectedMapIds([map.id]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [isMapsRemoveModalOpen, setIsMapsRemoveModalOpen] = useState(false);
|
const [isMapsRemoveModalOpen, setIsMapsRemoveModalOpen] = useState(false);
|
||||||
async function handleMapsRemove() {
|
async function handleMapsRemove() {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
@ -374,9 +183,8 @@ function SelectMapModal({
|
||||||
map,
|
map,
|
||||||
selectMode,
|
selectMode,
|
||||||
selectedMapIds,
|
selectedMapIds,
|
||||||
setSelectedMapIds,
|
setSelectedMapIds
|
||||||
mapsByGroup,
|
// TODO: Add new group support
|
||||||
mapGroups
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -424,7 +232,6 @@ function SelectMapModal({
|
||||||
!selectedMaps.some((map) => map.type === "default")
|
!selectedMaps.some((map) => map.type === "default")
|
||||||
) {
|
) {
|
||||||
// Ensure all other modals are closed
|
// Ensure all other modals are closed
|
||||||
setIsGroupModalOpen(false);
|
|
||||||
setIsEditModalOpen(false);
|
setIsEditModalOpen(false);
|
||||||
setIsMapsResetModalOpen(false);
|
setIsMapsResetModalOpen(false);
|
||||||
setIsMapsRemoveModalOpen(true);
|
setIsMapsRemoveModalOpen(true);
|
||||||
|
@ -479,7 +286,7 @@ function SelectMapModal({
|
||||||
Select or import a map
|
Select or import a map
|
||||||
</Label>
|
</Label>
|
||||||
<MapTiles
|
<MapTiles
|
||||||
maps={mapsByGroup}
|
maps={maps}
|
||||||
groups={mapGroups}
|
groups={mapGroups}
|
||||||
onMapAdd={openImageDialog}
|
onMapAdd={openImageDialog}
|
||||||
onMapEdit={() => setIsEditModalOpen(true)}
|
onMapEdit={() => setIsEditModalOpen(true)}
|
||||||
|
@ -493,7 +300,7 @@ function SelectMapModal({
|
||||||
onSelectModeChange={setSelectMode}
|
onSelectModeChange={setSelectMode}
|
||||||
search={search}
|
search={search}
|
||||||
onSearchChange={handleSearchChange}
|
onSearchChange={handleSearchChange}
|
||||||
onMapsGroup={() => setIsGroupModalOpen(true)}
|
onMapsGroup={updateMapGroups}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
|
@ -512,21 +319,6 @@ function SelectMapModal({
|
||||||
map={selectedMaps.length === 1 && selectedMaps[0]}
|
map={selectedMaps.length === 1 && selectedMaps[0]}
|
||||||
mapState={selectedMapStates.length === 1 && selectedMapStates[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
|
<ConfirmModal
|
||||||
isOpen={isMapsResetModalOpen}
|
isOpen={isMapsResetModalOpen}
|
||||||
onRequestClose={() => setIsMapsResetModalOpen(false)}
|
onRequestClose={() => setIsMapsResetModalOpen(false)}
|
||||||
|
|
|
@ -1,12 +1,9 @@
|
||||||
import React, { useRef, useState } from "react";
|
import React, { useRef, useState } from "react";
|
||||||
import { Flex, Label, Button } from "theme-ui";
|
import { Flex, Label, Button } from "theme-ui";
|
||||||
import { v4 as uuid } from "uuid";
|
|
||||||
import Case from "case";
|
|
||||||
import { useToasts } from "react-toast-notifications";
|
import { useToasts } from "react-toast-notifications";
|
||||||
import imageOutline from "image-outline";
|
|
||||||
|
|
||||||
import EditTokenModal from "./EditTokenModal";
|
import EditTokenModal from "./EditTokenModal";
|
||||||
import EditGroupModal from "./EditGroupModal";
|
|
||||||
import ConfirmModal from "./ConfirmModal";
|
import ConfirmModal from "./ConfirmModal";
|
||||||
|
|
||||||
import Modal from "../components/Modal";
|
import Modal from "../components/Modal";
|
||||||
|
@ -14,10 +11,8 @@ import ImageDrop from "../components/ImageDrop";
|
||||||
import TokenTiles from "../components/token/TokenTiles";
|
import TokenTiles from "../components/token/TokenTiles";
|
||||||
import LoadingOverlay from "../components/LoadingOverlay";
|
import LoadingOverlay from "../components/LoadingOverlay";
|
||||||
|
|
||||||
import blobToBuffer from "../helpers/blobToBuffer";
|
import { handleItemSelect } from "../helpers/select";
|
||||||
import { useSearch, useGroup, handleItemSelect } from "../helpers/select";
|
import { createTokenFromFile } from "../helpers/token";
|
||||||
import { createThumbnail } from "../helpers/image";
|
|
||||||
import Vector2 from "../helpers/Vector2";
|
|
||||||
|
|
||||||
import useResponsiveLayout from "../hooks/useResponsiveLayout";
|
import useResponsiveLayout from "../hooks/useResponsiveLayout";
|
||||||
|
|
||||||
|
@ -33,11 +28,13 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
|
||||||
|
|
||||||
const { userId } = useAuth();
|
const { userId } = useAuth();
|
||||||
const {
|
const {
|
||||||
ownedTokens,
|
tokens,
|
||||||
addToken,
|
addToken,
|
||||||
removeTokens,
|
removeTokens,
|
||||||
updateTokens,
|
updateTokens,
|
||||||
tokensLoading,
|
tokensLoading,
|
||||||
|
tokenGroups,
|
||||||
|
updateTokenGroups,
|
||||||
} = useTokenData();
|
} = useTokenData();
|
||||||
const { addAssets } = useAssets();
|
const { addAssets } = useAssets();
|
||||||
|
|
||||||
|
@ -45,31 +42,12 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
|
||||||
* Search
|
* Search
|
||||||
*/
|
*/
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [filteredTokens, filteredTokenScores] = useSearch(ownedTokens, search);
|
// const [filteredTokens, filteredTokenScores] = useSearch(ownedTokens, search);
|
||||||
|
|
||||||
function handleSearchChange(event) {
|
function handleSearchChange(event) {
|
||||||
setSearch(event.target.value);
|
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
|
* Image Upload
|
||||||
*/
|
*/
|
||||||
|
@ -141,80 +119,12 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleImageUpload(file) {
|
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);
|
setIsLoading(true);
|
||||||
const buffer = await blobToBuffer(file);
|
const { token, assets } = await createTokenFromFile(file, userId);
|
||||||
|
await addToken(token);
|
||||||
// Copy file to avoid permissions issues
|
await addAssets(assets);
|
||||||
const blob = new Blob([buffer]);
|
setSelectedTokenIds([token.id]);
|
||||||
// Create and load the image temporarily to get its dimensions
|
setIsLoading(false);
|
||||||
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;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -222,16 +132,10 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
|
||||||
*/
|
*/
|
||||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||||
const [selectedTokenIds, setSelectedTokenIds] = useState([]);
|
const [selectedTokenIds, setSelectedTokenIds] = useState([]);
|
||||||
const selectedTokens = ownedTokens.filter((token) =>
|
const selectedTokens = tokens.filter((token) =>
|
||||||
selectedTokenIds.includes(token.id)
|
selectedTokenIds.includes(token.id)
|
||||||
);
|
);
|
||||||
|
|
||||||
async function handleTokenAdd(token, assets) {
|
|
||||||
await addToken(token);
|
|
||||||
await addAssets(assets);
|
|
||||||
setSelectedTokenIds([token.id]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [isTokensRemoveModalOpen, setIsTokensRemoveModalOpen] = useState(false);
|
const [isTokensRemoveModalOpen, setIsTokensRemoveModalOpen] = useState(false);
|
||||||
async function handleTokensRemove() {
|
async function handleTokensRemove() {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
@ -255,9 +159,8 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
|
||||||
token,
|
token,
|
||||||
selectMode,
|
selectMode,
|
||||||
selectedTokenIds,
|
selectedTokenIds,
|
||||||
setSelectedTokenIds,
|
setSelectedTokenIds
|
||||||
tokensByGroup,
|
// TODO: Rework group support
|
||||||
tokenGroups
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -282,7 +185,6 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
|
||||||
) {
|
) {
|
||||||
// Ensure all other modals are closed
|
// Ensure all other modals are closed
|
||||||
setIsEditModalOpen(false);
|
setIsEditModalOpen(false);
|
||||||
setIsGroupModalOpen(false);
|
|
||||||
setIsTokensRemoveModalOpen(true);
|
setIsTokensRemoveModalOpen(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -335,7 +237,7 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
|
||||||
Edit or import a token
|
Edit or import a token
|
||||||
</Label>
|
</Label>
|
||||||
<TokenTiles
|
<TokenTiles
|
||||||
tokens={tokensByGroup}
|
tokens={tokens}
|
||||||
groups={tokenGroups}
|
groups={tokenGroups}
|
||||||
onTokenAdd={openImageDialog}
|
onTokenAdd={openImageDialog}
|
||||||
onTokenEdit={() => setIsEditModalOpen(true)}
|
onTokenEdit={() => setIsEditModalOpen(true)}
|
||||||
|
@ -346,7 +248,7 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
|
||||||
onSelectModeChange={setSelectMode}
|
onSelectModeChange={setSelectMode}
|
||||||
search={search}
|
search={search}
|
||||||
onSearchChange={handleSearchChange}
|
onSearchChange={handleSearchChange}
|
||||||
onTokensGroup={() => setIsGroupModalOpen(true)}
|
onTokensGroup={updateTokenGroups}
|
||||||
onTokensHide={handleTokensHide}
|
onTokensHide={handleTokensHide}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
@ -365,21 +267,6 @@ function SelectTokensModal({ isOpen, onRequestClose }) {
|
||||||
onDone={() => setIsEditModalOpen(false)}
|
onDone={() => setIsEditModalOpen(false)}
|
||||||
tokenId={selectedTokens.length === 1 && selectedTokens[0].id}
|
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
|
<ConfirmModal
|
||||||
isOpen={isTokensRemoveModalOpen}
|
isOpen={isTokensRemoveModalOpen}
|
||||||
onRequestClose={() => setIsTokensRemoveModalOpen(false)}
|
onRequestClose={() => setIsTokensRemoveModalOpen(false)}
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import { useToasts } from "react-toast-notifications";
|
import { useToasts } from "react-toast-notifications";
|
||||||
import { v4 as uuid } from "uuid";
|
|
||||||
|
|
||||||
import { useMapData } from "../contexts/MapDataContext";
|
import { useMapData } from "../contexts/MapDataContext";
|
||||||
import { useMapLoading } from "../contexts/MapLoadingContext";
|
import { useMapLoading } from "../contexts/MapLoadingContext";
|
||||||
|
@ -8,9 +7,6 @@ import { useAuth } from "../contexts/AuthContext";
|
||||||
import { useDatabase } from "../contexts/DatabaseContext";
|
import { useDatabase } from "../contexts/DatabaseContext";
|
||||||
import { useParty } from "../contexts/PartyContext";
|
import { useParty } from "../contexts/PartyContext";
|
||||||
import { useAssets } from "../contexts/AssetsContext";
|
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";
|
import { omit } from "../helpers/shared";
|
||||||
|
|
||||||
|
@ -44,10 +40,8 @@ function NetworkedMapAndTokens({ session }) {
|
||||||
const { userId } = useAuth();
|
const { userId } = useAuth();
|
||||||
const partyState = useParty();
|
const partyState = useParty();
|
||||||
const { assetLoadStart, assetProgressUpdate, isLoading } = useMapLoading();
|
const { assetLoadStart, assetProgressUpdate, isLoading } = useMapLoading();
|
||||||
const mapStageRef = useMapStage();
|
|
||||||
|
|
||||||
const { updateMapState } = useMapData();
|
const { updateMapState } = useMapData();
|
||||||
const { tokensById } = useTokenData();
|
|
||||||
const { getAsset, putAsset } = useAssets();
|
const { getAsset, putAsset } = useAssets();
|
||||||
|
|
||||||
const [currentMap, setCurrentMap] = useState(null);
|
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(() => {
|
useEffect(() => {
|
||||||
async function handlePeerData({ id, data, reply }) {
|
async function handlePeerData({ id, data, reply }) {
|
||||||
if (id === "assetRequest") {
|
if (id === "assetRequest") {
|
||||||
|
@ -504,7 +451,7 @@ function NetworkedMapAndTokens({ session }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DragProvider onDragEnd={handleDragEnd}>
|
<>
|
||||||
<Map
|
<Map
|
||||||
map={currentMap}
|
map={currentMap}
|
||||||
mapState={currentMapState}
|
mapState={currentMapState}
|
||||||
|
@ -528,8 +475,8 @@ function NetworkedMapAndTokens({ session }) {
|
||||||
disabledTokens={disabledMapTokens}
|
disabledTokens={disabledMapTokens}
|
||||||
session={session}
|
session={session}
|
||||||
/>
|
/>
|
||||||
<TokenBar />
|
<TokenBar onMapTokenStateCreate={handleMapTokenStateCreate} />
|
||||||
</DragProvider>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -639,9 +639,23 @@ export const versions = {
|
||||||
tx.table("tokens").bulkAdd(tokens);
|
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
|
* Load versions onto a database up to a specific version number
|
||||||
|
|
17
yarn.lock
17
yarn.lock
|
@ -1803,15 +1803,24 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib "^2.0.0"
|
tslib "^2.0.0"
|
||||||
|
|
||||||
"@dnd-kit/core@3.0.0":
|
"@dnd-kit/core@^3.0.0", "@dnd-kit/core@^3.0.2":
|
||||||
version "3.0.0"
|
version "3.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/@dnd-kit/core/-/core-3.0.0.tgz#96dadb6b2dba05ab177e0190b33ae219017bc167"
|
resolved "https://registry.yarnpkg.com/@dnd-kit/core/-/core-3.0.2.tgz#e46ae11ef667aa5c31fddab21cf36ffd80d3ce5b"
|
||||||
integrity sha512-QxHLfZHOLkQWK0FPbr5hefWZzsdZfDuluKPwIK1bT2lwp/4hmFXRA6ivqX3FT4g8T0d2de2C1jxYhKM4H3uMQw==
|
integrity sha512-L+rGnDYBb4BfYKDylzIBeODRIlJ+YVvo2iL9pVXsh317Nq7c9irCvi3XK8JnWD5QBw/3WZ5FmbPmTE91EKwKeA==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@dnd-kit/accessibility" "^3.0.0"
|
"@dnd-kit/accessibility" "^3.0.0"
|
||||||
"@dnd-kit/utilities" "^2.0.0"
|
"@dnd-kit/utilities" "^2.0.0"
|
||||||
tslib "^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":
|
"@dnd-kit/utilities@^2.0.0":
|
||||||
version "2.0.0"
|
version "2.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/@dnd-kit/utilities/-/utilities-2.0.0.tgz#a8462dff65c6f606ecbe95273c7e263b14a1ab97"
|
resolved "https://registry.yarnpkg.com/@dnd-kit/utilities/-/utilities-2.0.0.tgz#a8462dff65c6f606ecbe95273c7e263b14a1ab97"
|
||||||
|
|
Loading…
Reference in New Issue
Block a user