Add sort to token and map tiles and refactor

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

View File

@ -5,7 +5,8 @@
"dependencies": { "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",

View File

@ -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>
); );
} }

View File

@ -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,
}, },

View File

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

View File

@ -10,37 +10,17 @@ function Tile({
onSelect, 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();

View File

@ -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}
/> />
); );
} }

View File

@ -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>
); );
} }

View File

@ -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"

View File

@ -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>
); );
} }

View File

@ -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>
); );
} }

View File

@ -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"

View File

@ -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>
); );
} }

View File

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

View File

@ -22,6 +22,7 @@ export function MapDataProvider({ children }) {
const [maps, setMaps] = useState([]); const [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>

View File

@ -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 (

View File

@ -1,3 +1,33 @@
import { v4 as uuid } from "uuid";
import Case from "case";
import blobToBuffer from "./blobToBuffer";
import { resizeImage, createThumbnail } from "./image";
import {
getGridDefaultInset,
getGridSizeFromImage,
gridSizeVaild,
} from "./grid";
import Vector2 from "./Vector2";
const defaultMapProps = {
showGrid: false,
snapToGrid: true,
quality: "original",
group: "",
};
const mapResolutions = [
{
size: 30, // Pixels per grid
quality: 0.5, // JPEG compression quality
id: "low",
},
{ size: 70, quality: 0.6, id: "medium" },
{ size: 140, quality: 0.7, id: "high" },
{ size: 300, quality: 0.8, id: "ultra" },
];
/** /**
* Get the asset id of the preview file to send for a map * 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;
});
}

View File

@ -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
View File

@ -0,0 +1,113 @@
import { v4 as uuid } from "uuid";
import Case from "case";
import imageOutline from "image-outline";
import blobToBuffer from "./blobToBuffer";
import { createThumbnail } from "./image";
import Vector2 from "./Vector2";
export function createTokenState(token, position, userId) {
let tokenState = {
id: uuid(),
tokenId: token.id,
owner: userId,
size: token.defaultSize,
category: token.defaultCategory,
label: token.defaultLabel,
statuses: [],
x: position.x,
y: position.y,
lastModifiedBy: userId,
lastModified: Date.now(),
rotation: 0,
locked: false,
visible: true,
type: token.type,
outline: token.outline,
width: token.width,
height: token.height,
};
if (token.type === "file") {
tokenState.file = token.file;
} else if (token.type === "default") {
tokenState.key = token.key;
}
return tokenState;
}
export async function createTokenFromFile(file, userId) {
if (!file) {
return Promise.reject();
}
let name = "Unknown Token";
if (file.name) {
// Remove file extension
name = file.name.replace(/\.[^/.]+$/, "");
// Removed grid size expression
name = name.replace(/(\[ ?|\( ?)?\d+ ?(x|X) ?\d+( ?\]| ?\))?/, "");
// Clean string
name = name.replace(/ +/g, " ");
name = name.trim();
// Capitalize and remove underscores
name = Case.capital(name);
}
let image = new Image();
const buffer = await blobToBuffer(file);
// Copy file to avoid permissions issues
const blob = new Blob([buffer]);
// Create and load the image temporarily to get its dimensions
const url = URL.createObjectURL(blob);
return new Promise((resolve, reject) => {
image.onload = async function () {
let assets = [];
const thumbnailImage = await createThumbnail(image, file.type);
const thumbnail = { ...thumbnailImage, id: uuid(), owner: userId };
assets.push(thumbnail);
const fileAsset = {
id: uuid(),
file: buffer,
width: image.width,
height: image.height,
mime: file.type,
owner: userId,
};
assets.push(fileAsset);
let outline = imageOutline(image);
if (outline.length > 100) {
outline = Vector2.resample(outline, 100);
}
// Flatten and round outline to save on storage size
outline = outline
.map(({ x, y }) => [Math.round(x), Math.round(y)])
.flat();
const token = {
name,
thumbnail: thumbnail.id,
file: fileAsset.id,
id: uuid(),
type: "file",
created: Date.now(),
lastModified: Date.now(),
owner: userId,
defaultSize: 1,
defaultCategory: "character",
defaultLabel: "",
hideInSidebar: false,
group: "",
width: image.width,
height: image.height,
outline,
};
URL.revokeObjectURL(url);
resolve({ token, assets });
};
image.onerror = reject;
image.src = url;
});
}

View File

@ -21,7 +21,13 @@ function useResponsiveLayout() {
? "medium" ? "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;

View File

@ -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)}

View File

@ -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)}

View File

@ -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> </>
); );
} }

View File

@ -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

View File

@ -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"