diff --git a/package.json b/package.json index 56f83e2..d942b02 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "dependencies": { "@babylonjs/core": "^4.2.0", "@babylonjs/loaders": "^4.2.0", - "@dnd-kit/core": "3.0.0", + "@dnd-kit/core": "^3.0.2", + "@dnd-kit/sortable": "^3.0.1", "@mitchemmc/dexie-export-import": "^1.0.1", "@msgpack/msgpack": "^2.4.1", "@sentry/react": "^6.2.2", diff --git a/src/components/Draggable.js b/src/components/Draggable.js index f007fea..3c92929 100644 --- a/src/components/Draggable.js +++ b/src/components/Draggable.js @@ -8,19 +8,15 @@ function Draggable({ id, children, data }) { }); const style = { - border: "none", - background: "transparent", - margin: "0px", - padding: "0px", cursor: "pointer", touchAction: "none", opacity: isDragging ? 0.5 : undefined, }; return ( - + ); } diff --git a/src/components/Modal.js b/src/components/Modal.js index eb847e6..924c0e2 100644 --- a/src/components/Modal.js +++ b/src/components/Modal.js @@ -17,15 +17,16 @@ function StyledModal({ isOpen={isOpen} onRequestClose={onRequestClose} style={{ - overlay: { backgroundColor: "rgba(0, 0, 0, 0.73)", zIndex: 100 }, + overlay: { + backgroundColor: "rgba(0, 0, 0, 0.73)", + zIndex: 100, + display: "flex", + alignItems: "center", + justifyContent: "center", + }, content: { backgroundColor: theme.colors.background, - top: "50%", - left: "50%", - right: "auto", - bottom: "auto", - marginRight: "-50%", - transform: "translate(-50%, -50%)", + inset: "initial", maxHeight: "100%", ...style, }, diff --git a/src/components/Sortable.js b/src/components/Sortable.js new file mode 100644 index 0000000..b0a44b6 --- /dev/null +++ b/src/components/Sortable.js @@ -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 ( +
+ {children} +
+ ); +} + +export default Sortable; diff --git a/src/components/Tile.js b/src/components/Tile.js index 1331b0e..d7a5037 100644 --- a/src/components/Tile.js +++ b/src/components/Tile.js @@ -10,37 +10,17 @@ function Tile({ onSelect, onEdit, onDoubleClick, - size, canEdit, badges, editTitle, }) { - let width; - let margin; - switch (size) { - case "small": - width = "24%"; - margin = "0.5%"; - break; - case "medium": - width = "32%"; - margin = `${2 / 3}%`; - break; - case "large": - width = "48%"; - margin = "1%"; - break; - default: - width = "32%"; - margin = `${2 / 3}%`; - } return ( { e.stopPropagation(); diff --git a/src/components/dice/DiceTile.js b/src/components/dice/DiceTile.js index 101e390..230b3ad 100644 --- a/src/components/dice/DiceTile.js +++ b/src/components/dice/DiceTile.js @@ -2,7 +2,7 @@ import React from "react"; import Tile from "../Tile"; -function DiceTile({ dice, isSelected, onDiceSelect, onDone, size }) { +function DiceTile({ dice, isSelected, onDiceSelect, onDone }) { return ( onDiceSelect(dice)} onDoubleClick={() => onDone(dice)} - size={size} /> ); } diff --git a/src/components/dice/DiceTiles.js b/src/components/dice/DiceTiles.js index 86ef58a..7bb5a54 100644 --- a/src/components/dice/DiceTiles.js +++ b/src/components/dice/DiceTiles.js @@ -1,5 +1,5 @@ import React from "react"; -import { Flex } from "theme-ui"; +import { Grid } from "theme-ui"; import SimpleBar from "simplebar-react"; import DiceTile from "./DiceTile"; @@ -13,16 +13,16 @@ function DiceTiles({ dice, onDiceSelect, selectedDice, onDone }) { - {dice.map((dice) => ( ))} - + ); } diff --git a/src/components/map/MapTile.js b/src/components/map/MapTile.js index 2016809..ed6ebcf 100644 --- a/src/components/map/MapTile.js +++ b/src/components/map/MapTile.js @@ -11,7 +11,6 @@ function MapTile({ onMapSelect, onMapEdit, onDone, - size, canEdit, badges, }) { @@ -30,7 +29,6 @@ function MapTile({ onSelect={() => onMapSelect(map)} onEdit={() => onMapEdit(map.id)} onDoubleClick={() => canEdit && onDone()} - size={size} canEdit={canEdit} badges={badges} editTitle="Edit Map" diff --git a/src/components/map/MapTiles.js b/src/components/map/MapTiles.js index 3b86589..1f6ebba 100644 --- a/src/components/map/MapTiles.js +++ b/src/components/map/MapTiles.js @@ -1,15 +1,16 @@ import React from "react"; -import { Flex, Box, Text, IconButton, Close, Label } from "theme-ui"; +import { Flex, Box, Text, IconButton, Close, Grid } from "theme-ui"; import SimpleBar from "simplebar-react"; -import Case from "case"; +import { DndContext } from "@dnd-kit/core"; +import { SortableContext, arrayMove } from "@dnd-kit/sortable"; import RemoveMapIcon from "../../icons/RemoveMapIcon"; import ResetMapIcon from "../../icons/ResetMapIcon"; -import GroupIcon from "../../icons/GroupIcon"; import MapTile from "./MapTile"; import Link from "../Link"; import FilterBar from "../FilterBar"; +import Sortable from "../Sortable"; import { useDatabase } from "../../contexts/DatabaseContext"; @@ -73,107 +74,111 @@ function MapTiles({ const multipleSelected = selectedMaps.length > 1; + function handleDragEnd({ active, over }) { + if (active && over && active.id !== over.id) { + const oldIndex = groups.indexOf(active.id); + const newIndex = groups.indexOf(over.id); + onMapsGroup(arrayMove(groups, oldIndex, newIndex)); + } + } + return ( - - onMapSelect()} - search={search} - onSearchChange={onSearchChange} - selectMode={selectMode} - onSelectModeChange={onSelectModeChange} - onAdd={onMapAdd} - addTitle="Add Map" - /> - - onMapSelect()} - > - {groups.map((group) => ( - - - {maps[group].map(mapToTile)} - - ))} - - - {databaseStatus === "disabled" && ( - - - Map saving is unavailable. See FAQ for - more information. - - - )} - {selectedMaps.length > 0 && ( - - onMapSelect()} + + + + onMapSelect()} + search={search} + onSearchChange={onSearchChange} + selectMode={selectMode} + onSelectModeChange={onSelectModeChange} + onAdd={onMapAdd} + addTitle="Add Map" /> - - onMapsGroup()} - disabled={hasSelectedDefaultMap} + + onMapSelect()} > - - - onMapsReset()} - disabled={!hasMapState} + {groups.map((mapId) => ( + + {mapToTile(maps.find((map) => map.id === mapId))} + + ))} + + + {databaseStatus === "disabled" && ( + - - - onMapsRemove()} - disabled={hasSelectedDefaultMap} + + Map saving is unavailable. See FAQ{" "} + for more information. + + + )} + {selectedMaps.length > 0 && ( + - - - - - )} - + onMapSelect()} + /> + + onMapsReset()} + disabled={!hasMapState} + > + + + onMapsRemove()} + disabled={hasSelectedDefaultMap} + > + + + + + )} + + + ); } diff --git a/src/components/token/TokenBar.js b/src/components/token/TokenBar.js index ec01f07..d103402 100644 --- a/src/components/token/TokenBar.js +++ b/src/components/token/TokenBar.js @@ -1,8 +1,8 @@ -import React from "react"; +import React, { useState } from "react"; import { createPortal } from "react-dom"; import { Box, Flex } from "theme-ui"; import SimpleBar from "simplebar-react"; -import { DragOverlay } from "@dnd-kit/core"; +import { DragOverlay, DndContext } from "@dnd-kit/core"; import ListToken from "./ListToken"; import SelectTokensButton from "./SelectTokensButton"; @@ -11,67 +11,82 @@ import Draggable from "../Draggable"; import useSetting from "../../hooks/useSetting"; import { useTokenData } from "../../contexts/TokenDataContext"; -import { useDragId } from "../../contexts/DragContext"; +import { useAuth } from "../../contexts/AuthContext"; -function TokenBar() { - const { ownedTokens } = useTokenData(); +import { createTokenState } from "../../helpers/token"; + +function TokenBar({ onMapTokenStateCreate }) { + const { userId } = useAuth(); + const { tokensById, tokenGroups } = useTokenData(); const [fullScreen] = useSetting("map.fullScreen"); - const activeDragId = useDragId(); + const [dragId, setDragId] = useState(); + + function handleDragStart({ active }) { + setDragId(active.id); + } + + function handleDragEnd({ active }) { + setDragId(null); + const token = tokensById[active.id]; + if (token) { + // TODO: Get drag position + const tokenState = createTokenState(token, { x: 0, y: 0 }, userId); + onMapTokenStateCreate(tokenState); + } + } + const tokens = tokenGroups + .map((tokenId) => tokensById[tokenId]) + .filter((token) => !token.hideInSidebar) + .map((token) => ( + + + + )); return ( - - - {ownedTokens - .filter((token) => !token.hideInSidebar) - .map((token) => ( - - - - ))} - - - - - {createPortal( - - {activeDragId && ( - `sidebar-${token.id}` === activeDragId - )} - /> - )} - , - document.body - )} - + + {tokens} + + + + + {createPortal( + + {dragId && } + , + document.body + )} + + ); } diff --git a/src/components/token/TokenTile.js b/src/components/token/TokenTile.js index 9d7cdae..b0bc0f7 100644 --- a/src/components/token/TokenTile.js +++ b/src/components/token/TokenTile.js @@ -11,7 +11,6 @@ function TokenTile({ isSelected, onTokenSelect, onTokenEdit, - size, canEdit, badges, }) { @@ -29,7 +28,6 @@ function TokenTile({ isSelected={isSelected} onSelect={() => onTokenSelect(token)} onEdit={() => onTokenEdit(token.id)} - size={size} canEdit={canEdit} badges={badges} editTitle="Edit Token" diff --git a/src/components/token/TokenTiles.js b/src/components/token/TokenTiles.js index 73d69ab..5854517 100644 --- a/src/components/token/TokenTiles.js +++ b/src/components/token/TokenTiles.js @@ -1,16 +1,17 @@ import React from "react"; -import { Flex, Box, Text, IconButton, Close, Label } from "theme-ui"; +import { Flex, Box, Text, IconButton, Close, Grid } from "theme-ui"; import SimpleBar from "simplebar-react"; -import Case from "case"; +import { DndContext } from "@dnd-kit/core"; +import { SortableContext, arrayMove } from "@dnd-kit/sortable"; import RemoveTokenIcon from "../../icons/RemoveTokenIcon"; -import GroupIcon from "../../icons/GroupIcon"; import TokenHideIcon from "../../icons/TokenHideIcon"; import TokenShowIcon from "../../icons/TokenShowIcon"; import TokenTile from "./TokenTile"; import Link from "../Link"; import FilterBar from "../FilterBar"; +import Sortable from "../Sortable"; import { useDatabase } from "../../contexts/DatabaseContext"; @@ -48,7 +49,6 @@ function TokenTiles({ isSelected={isSelected} onTokenSelect={onTokenSelect} onTokenEdit={onTokenEdit} - size={layout.tileSize} canEdit={ isSelected && token.type !== "default" && @@ -77,107 +77,112 @@ function TokenTiles({ } } + function handleDragEnd({ active, over }) { + if (active && over && active.id !== over.id) { + const oldIndex = groups.indexOf(active.id); + const newIndex = groups.indexOf(over.id); + onTokensGroup(arrayMove(groups, oldIndex, newIndex)); + } + } + return ( - - onTokenSelect()} - search={search} - onSearchChange={onSearchChange} - selectMode={selectMode} - onSelectModeChange={onSelectModeChange} - onAdd={onTokenAdd} - addTitle="Add Token" - /> - - onTokenSelect()} - > - {groups.map((group) => ( - - - {tokens[group].map(tokenToTile)} - - ))} - - - {databaseStatus === "disabled" && ( - - - Token saving is unavailable. See FAQ{" "} - for more information. - - - )} - {selectedTokens.length > 0 && ( - - onTokenSelect()} + + + + onTokenSelect()} + search={search} + onSearchChange={onSearchChange} + selectMode={selectMode} + onSelectModeChange={onSelectModeChange} + onAdd={onTokenAdd} + addTitle="Add Token" /> - - onTokensHide(allTokensVisible)} + + onTokenSelect()} > - {allTokensVisible ? : } - - onTokensGroup()} - disabled={hasSelectedDefaultToken} + {groups.map((tokenId) => ( + + {tokenToTile(tokens.find((token) => token.id === tokenId))} + + ))} + + + {databaseStatus === "disabled" && ( + - - - onTokensRemove()} - disabled={hasSelectedDefaultToken} + + Token saving is unavailable. See{" "} + FAQ for more information. + + + )} + {selectedTokens.length > 0 && ( + - - - - - )} - + onTokenSelect()} + /> + + onTokensHide(allTokensVisible)} + > + {allTokensVisible ? : } + + onTokensRemove()} + disabled={hasSelectedDefaultToken} + > + + + + + )} + + + ); } diff --git a/src/contexts/DragContext.js b/src/contexts/DragContext.js deleted file mode 100644 index faf7d59..0000000 --- a/src/contexts/DragContext.js +++ /dev/null @@ -1,36 +0,0 @@ -import React, { useState, useContext } from "react"; -import { DndContext } from "@dnd-kit/core"; - -/** - * @type {React.Context} - */ -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 ( - - - {children} - - - ); -} - -export function useDragId() { - const context = useContext(DragIdContext); - if (context === undefined) { - throw new Error("useDragId must be used within a DragProvider"); - } - return context; -} diff --git a/src/contexts/MapDataContext.js b/src/contexts/MapDataContext.js index 7a3fc03..2577d29 100644 --- a/src/contexts/MapDataContext.js +++ b/src/contexts/MapDataContext.js @@ -22,6 +22,7 @@ export function MapDataProvider({ children }) { const [maps, setMaps] = useState([]); const [mapStates, setMapStates] = useState([]); const [mapsLoading, setMapsLoading] = useState(true); + const [mapGroups, setMapGroups] = useState([]); // Load maps from the database and ensure state is properly setup useEffect(() => { @@ -42,11 +43,12 @@ export function MapDataProvider({ children }) { storedMaps.push(map); }); } - // TODO: remove sort when groups are added - const sortedMaps = storedMaps.sort((a, b) => b.created - a.created); - setMaps(sortedMaps); + setMaps(storedMaps); const storedStates = await database.table("states").toArray(); setMapStates(storedStates); + const group = await database.table("groups").get("maps"); + const storedGroups = group.data; + setMapGroups(storedGroups); setMapsLoading(false); } @@ -70,7 +72,7 @@ export function MapDataProvider({ children }) { ); /** - * Adds a map to the database, also adds an assosiated state for that map + * Adds a map to the database, also adds an assosiated state and group for that map * @param {Object} map map to add */ const addMap = useCallback( @@ -79,6 +81,10 @@ export function MapDataProvider({ children }) { const state = { ...defaultMapState, mapId: map.id }; await database.table("maps").add(map); await database.table("states").add(state); + const group = await database.table("groups").get("maps"); + await database + .table("groups") + .update("maps", { data: [map.id, ...group.data] }); }, [database] ); @@ -136,6 +142,15 @@ export function MapDataProvider({ children }) { [database] ); + const updateMapGroups = useCallback( + async (groups) => { + // Update group state immediately to avoid animation delay + setMapGroups(groups); + await database.table("groups").update("maps", { data: groups }); + }, + [database] + ); + // Create DB observable to sync creating and deleting useEffect(() => { if (!database || databaseStatus === "loading") { @@ -190,6 +205,11 @@ export function MapDataProvider({ children }) { }); } } + if (change.table === "groups") { + if (change.type === 2 && change.key === "maps") { + setMapGroups(change.obj.data); + } + } } } @@ -200,12 +220,10 @@ export function MapDataProvider({ children }) { }; }, [database, databaseStatus]); - const ownedMaps = maps.filter((map) => map.owner === userId); - const value = { maps, - ownedMaps, mapStates, + mapGroups, addMap, removeMaps, resetMap, @@ -215,6 +233,7 @@ export function MapDataProvider({ children }) { getMap, mapsLoading, getMapState, + updateMapGroups, }; return ( {children} diff --git a/src/contexts/TokenDataContext.js b/src/contexts/TokenDataContext.js index 21d8c43..d3a40c0 100644 --- a/src/contexts/TokenDataContext.js +++ b/src/contexts/TokenDataContext.js @@ -12,6 +12,7 @@ export function TokenDataProvider({ children }) { const [tokens, setTokens] = useState([]); const [tokensLoading, setTokensLoading] = useState(true); + const [tokenGroups, setTokenGroups] = useState([]); useEffect(() => { if (!userId || !database || databaseStatus === "loading") { @@ -30,8 +31,10 @@ export function TokenDataProvider({ children }) { storedTokens.push(token); }); } - const sortedTokens = storedTokens.sort((a, b) => b.created - a.created); - setTokens(sortedTokens); + setTokens(storedTokens); + const group = await database.table("groups").get("tokens"); + const storedGroups = group.data; + setTokenGroups(storedGroups); setTokensLoading(false); } @@ -46,9 +49,14 @@ export function TokenDataProvider({ children }) { [database] ); + // Add token and add it to the token group const addToken = useCallback( async (token) => { await database.table("tokens").add(token); + const group = await database.table("groups").get("tokens"); + await database + .table("groups") + .update("tokens", { data: [token.id, ...group.data] }); }, [database] ); @@ -87,6 +95,15 @@ export function TokenDataProvider({ children }) { [database] ); + const updateTokenGroups = useCallback( + async (groups) => { + // Update group state immediately to avoid animation delay + setTokenGroups(groups); + await database.table("groups").update("tokens", { data: groups }); + }, + [database] + ); + // Create DB observable to sync creating and deleting useEffect(() => { if (!database || databaseStatus === "loading") { @@ -120,6 +137,11 @@ export function TokenDataProvider({ children }) { }); } } + if (change.table === "groups") { + if (change.type === 2 && change.key === "tokens") { + setTokenGroups(change.obj.data); + } + } } } @@ -130,8 +152,6 @@ export function TokenDataProvider({ children }) { }; }, [database, databaseStatus]); - const ownedTokens = tokens.filter((token) => token.owner === userId); - const tokensById = tokens.reduce((obj, token) => { obj[token.id] = token; return obj; @@ -139,14 +159,15 @@ export function TokenDataProvider({ children }) { const value = { tokens, - ownedTokens, addToken, + tokenGroups, removeTokens, updateToken, updateTokens, tokensById, tokensLoading, getToken, + updateTokenGroups, }; return ( diff --git a/src/helpers/map.js b/src/helpers/map.js index b41e029..c493698 100644 --- a/src/helpers/map.js +++ b/src/helpers/map.js @@ -1,3 +1,33 @@ +import { v4 as uuid } from "uuid"; +import Case from "case"; + +import blobToBuffer from "./blobToBuffer"; +import { resizeImage, createThumbnail } from "./image"; +import { + getGridDefaultInset, + getGridSizeFromImage, + gridSizeVaild, +} from "./grid"; +import Vector2 from "./Vector2"; + +const defaultMapProps = { + showGrid: false, + snapToGrid: true, + quality: "original", + group: "", +}; + +const mapResolutions = [ + { + size: 30, // Pixels per grid + quality: 0.5, // JPEG compression quality + id: "low", + }, + { size: 70, quality: 0.6, id: "medium" }, + { size: 140, quality: 0.7, id: "high" }, + { size: 300, quality: 0.8, id: "ultra" }, +]; + /** * Get the asset id of the preview file to send for a map * @param {any} map @@ -25,3 +55,141 @@ export function getMapPreviewAsset(map) { return; } } + +export async function createMapFromFile(file, userId) { + let image = new Image(); + + const buffer = await blobToBuffer(file); + // Copy file to avoid permissions issues + const blob = new Blob([buffer]); + // Create and load the image temporarily to get its dimensions + const url = URL.createObjectURL(blob); + + return new Promise((resolve, reject) => { + image.onload = async function () { + // Find name and grid size + let gridSize; + let name = "Unknown Map"; + if (file.name) { + if (file.name.matchAll) { + // Match against a regex to find the grid size in the file name + // e.g. Cave 22x23 will return [["22x22", "22", "x", "23"]] + const gridMatches = [...file.name.matchAll(/(\d+) ?(x|X) ?(\d+)/g)]; + for (let match of gridMatches) { + const matchX = parseInt(match[1]); + const matchY = parseInt(match[3]); + if ( + !isNaN(matchX) && + !isNaN(matchY) && + gridSizeVaild(matchX, matchY) + ) { + gridSize = { x: matchX, y: matchY }; + } + } + } + + if (!gridSize) { + gridSize = await getGridSizeFromImage(image); + } + + // Remove file extension + name = file.name.replace(/\.[^/.]+$/, ""); + // Removed grid size expression + name = name.replace(/(\[ ?|\( ?)?\d+ ?(x|X) ?\d+( ?\]| ?\))?/, ""); + // Clean string + name = name.replace(/ +/g, " "); + name = name.trim(); + // Capitalize and remove underscores + name = Case.capital(name); + } + + if (!gridSize) { + gridSize = { x: 22, y: 22 }; + } + + let assets = []; + + // Create resolutions + const resolutions = {}; + for (let resolution of mapResolutions) { + const resolutionPixelSize = Vector2.multiply(gridSize, resolution.size); + if ( + image.width >= resolutionPixelSize.x && + image.height >= resolutionPixelSize.y + ) { + const resized = await resizeImage( + image, + Vector2.max(resolutionPixelSize), + file.type, + resolution.quality + ); + if (resized.blob) { + const assetId = uuid(); + resolutions[resolution.id] = assetId; + const resizedBuffer = await blobToBuffer(resized.blob); + const asset = { + file: resizedBuffer, + width: resized.width, + height: resized.height, + id: assetId, + mime: file.type, + owner: userId, + }; + assets.push(asset); + } + } + } + // Create thumbnail + const thumbnailImage = await createThumbnail(image, file.type); + const thumbnail = { + ...thumbnailImage, + id: uuid(), + owner: userId, + }; + assets.push(thumbnail); + + const fileAsset = { + id: uuid(), + file: buffer, + width: image.width, + height: image.height, + mime: file.type, + owner: userId, + }; + assets.push(fileAsset); + + const map = { + name, + resolutions, + file: fileAsset.id, + thumbnail: thumbnail.id, + type: "file", + grid: { + size: gridSize, + inset: getGridDefaultInset( + { size: gridSize, type: "square" }, + image.width, + image.height + ), + type: "square", + measurement: { + type: "chebyshev", + scale: "5ft", + }, + }, + width: image.width, + height: image.height, + id: uuid(), + created: Date.now(), + lastModified: Date.now(), + owner: userId, + ...defaultMapProps, + }; + + URL.revokeObjectURL(url); + resolve({ map, assets }); + }; + image.onerror = reject; + image.src = url; + }); +} diff --git a/src/helpers/select.js b/src/helpers/select.js index eb81b95..1eefe05 100644 --- a/src/helpers/select.js +++ b/src/helpers/select.js @@ -35,6 +35,7 @@ export function useSearch(items, search) { return [filteredItems, filteredItemScores]; } +// TODO: Rework group support // Helper for grouping items export function useGroup(items, filteredItems, useFiltered, filteredScores) { const itemsByGroup = groupBy(useFiltered ? filteredItems : items, "group"); @@ -92,41 +93,43 @@ export function handleItemSelect( }); break; case "range": - // Create items array - let items = itemGroups.reduce( - (acc, group) => [...acc, ...itemsByGroup[group]], - [] - ); + /// TODO: Fix when new groups system is added + return; + // Create items array + // let items = itemGroups.reduce( + // (acc, group) => [...acc, ...itemsByGroup[group]], + // [] + // ); - // Add all items inbetween the previous selected item and the current selected - if (selectedIds.length > 0) { - const mapIndex = items.findIndex((m) => m.id === item.id); - const lastIndex = items.findIndex( - (m) => m.id === selectedIds[selectedIds.length - 1] - ); - let idsToAdd = []; - let idsToRemove = []; - const direction = mapIndex > lastIndex ? 1 : -1; - for ( - let i = lastIndex + direction; - direction < 0 ? i >= mapIndex : i <= mapIndex; - i += direction - ) { - const itemId = items[i].id; - if (selectedIds.includes(itemId)) { - idsToRemove.push(itemId); - } else { - idsToAdd.push(itemId); - } - } - setSelectedIds((prev) => { - let ids = [...prev, ...idsToAdd]; - return ids.filter((id) => !idsToRemove.includes(id)); - }); - } else { - setSelectedIds([item.id]); - } - break; + // // Add all items inbetween the previous selected item and the current selected + // if (selectedIds.length > 0) { + // const mapIndex = items.findIndex((m) => m.id === item.id); + // const lastIndex = items.findIndex( + // (m) => m.id === selectedIds[selectedIds.length - 1] + // ); + // let idsToAdd = []; + // let idsToRemove = []; + // const direction = mapIndex > lastIndex ? 1 : -1; + // for ( + // let i = lastIndex + direction; + // direction < 0 ? i >= mapIndex : i <= mapIndex; + // i += direction + // ) { + // const itemId = items[i].id; + // if (selectedIds.includes(itemId)) { + // idsToRemove.push(itemId); + // } else { + // idsToAdd.push(itemId); + // } + // } + // setSelectedIds((prev) => { + // let ids = [...prev, ...idsToAdd]; + // return ids.filter((id) => !idsToRemove.includes(id)); + // }); + // } else { + // setSelectedIds([item.id]); + // } + // break; default: setSelectedIds([]); } diff --git a/src/helpers/token.js b/src/helpers/token.js new file mode 100644 index 0000000..efc060e --- /dev/null +++ b/src/helpers/token.js @@ -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; + }); +} diff --git a/src/hooks/useResponsiveLayout.js b/src/hooks/useResponsiveLayout.js index cb9e583..c50b3e6 100644 --- a/src/hooks/useResponsiveLayout.js +++ b/src/hooks/useResponsiveLayout.js @@ -21,7 +21,13 @@ function useResponsiveLayout() { ? "medium" : "large"; - return { screenSize, modalSize, tileSize }; + const gridTemplate = isLargeScreen + ? "1fr 1fr 1fr 1fr" + : isMediumScreen + ? "1fr 1fr 1fr" + : "1fr 1fr"; + + return { screenSize, modalSize, tileSize, gridTemplate }; } export default useResponsiveLayout; diff --git a/src/modals/SelectMapModal.js b/src/modals/SelectMapModal.js index 2752a36..bf07783 100644 --- a/src/modals/SelectMapModal.js +++ b/src/modals/SelectMapModal.js @@ -1,11 +1,8 @@ import React, { useRef, useState } from "react"; import { Button, Flex, Label } from "theme-ui"; -import { v4 as uuid } from "uuid"; -import Case from "case"; import { useToasts } from "react-toast-notifications"; import EditMapModal from "./EditMapModal"; -import EditGroupModal from "./EditGroupModal"; import ConfirmModal from "./ConfirmModal"; import Modal from "../components/Modal"; @@ -13,15 +10,8 @@ import MapTiles from "../components/map/MapTiles"; import ImageDrop from "../components/ImageDrop"; import LoadingOverlay from "../components/LoadingOverlay"; -import blobToBuffer from "../helpers/blobToBuffer"; -import { resizeImage, createThumbnail } from "../helpers/image"; -import { useSearch, useGroup, handleItemSelect } from "../helpers/select"; -import { - getGridDefaultInset, - getGridSizeFromImage, - gridSizeVaild, -} from "../helpers/grid"; -import Vector2 from "../helpers/Vector2"; +import { handleItemSelect } from "../helpers/select"; +import { createMapFromFile } from "../helpers/map"; import useResponsiveLayout from "../hooks/useResponsiveLayout"; @@ -32,24 +22,6 @@ import { useAssets } from "../contexts/AssetsContext"; import shortcuts from "../shortcuts"; -const defaultMapProps = { - showGrid: false, - snapToGrid: true, - quality: "original", - group: "", -}; - -const mapResolutions = [ - { - size: 30, // Pixels per grid - quality: 0.5, // JPEG compression quality - id: "low", - }, - { size: 70, quality: 0.6, id: "medium" }, - { size: 140, quality: 0.7, id: "high" }, - { size: 300, quality: 0.8, id: "ultra" }, -]; - function SelectMapModal({ isOpen, onDone, @@ -62,14 +34,15 @@ function SelectMapModal({ const { userId } = useAuth(); const { - ownedMaps, + maps, mapStates, + mapGroups, addMap, removeMaps, resetMap, - updateMaps, mapsLoading, getMapState, + updateMapGroups, } = useMapData(); const { addAssets } = useAssets(); @@ -77,31 +50,13 @@ function SelectMapModal({ * Search */ const [search, setSearch] = useState(""); - const [filteredMaps, filteredMapScores] = useSearch(ownedMaps, search); + // TODO: Add back with new group support + // const [filteredMaps, filteredMapScores] = useSearch(ownedMaps, search); function handleSearchChange(event) { setSearch(event.target.value); } - /** - * Group - */ - const [isGroupModalOpen, setIsGroupModalOpen] = useState(false); - - async function handleMapsGroup(group) { - setIsLoading(true); - setIsGroupModalOpen(false); - await updateMaps(selectedMapIds, { group }); - setIsLoading(false); - } - - const [mapsByGroup, mapGroups] = useGroup( - ownedMaps, - filteredMaps, - !!search, - filteredMapScores - ); - /** * Image Upload */ @@ -167,150 +122,12 @@ function SelectMapModal({ } async function handleImageUpload(file) { - if (!file) { - return Promise.reject(); - } - let image = new Image(); setIsLoading(true); - - const buffer = await blobToBuffer(file); - // Copy file to avoid permissions issues - const blob = new Blob([buffer]); - // Create and load the image temporarily to get its dimensions - const url = URL.createObjectURL(blob); - - return new Promise((resolve, reject) => { - image.onload = async function () { - // Find name and grid size - let gridSize; - let name = "Unknown Map"; - if (file.name) { - if (file.name.matchAll) { - // Match against a regex to find the grid size in the file name - // e.g. Cave 22x23 will return [["22x22", "22", "x", "23"]] - const gridMatches = [...file.name.matchAll(/(\d+) ?(x|X) ?(\d+)/g)]; - for (let match of gridMatches) { - const matchX = parseInt(match[1]); - const matchY = parseInt(match[3]); - if ( - !isNaN(matchX) && - !isNaN(matchY) && - gridSizeVaild(matchX, matchY) - ) { - gridSize = { x: matchX, y: matchY }; - } - } - } - - if (!gridSize) { - gridSize = await getGridSizeFromImage(image); - } - - // Remove file extension - name = file.name.replace(/\.[^/.]+$/, ""); - // Removed grid size expression - name = name.replace(/(\[ ?|\( ?)?\d+ ?(x|X) ?\d+( ?\]| ?\))?/, ""); - // Clean string - name = name.replace(/ +/g, " "); - name = name.trim(); - // Capitalize and remove underscores - name = Case.capital(name); - } - - if (!gridSize) { - gridSize = { x: 22, y: 22 }; - } - - let assets = []; - - // Create resolutions - const resolutions = {}; - for (let resolution of mapResolutions) { - const resolutionPixelSize = Vector2.multiply( - gridSize, - resolution.size - ); - if ( - image.width >= resolutionPixelSize.x && - image.height >= resolutionPixelSize.y - ) { - const resized = await resizeImage( - image, - Vector2.max(resolutionPixelSize), - file.type, - resolution.quality - ); - if (resized.blob) { - const assetId = uuid(); - resolutions[resolution.id] = assetId; - const resizedBuffer = await blobToBuffer(resized.blob); - const asset = { - file: resizedBuffer, - width: resized.width, - height: resized.height, - id: assetId, - mime: file.type, - owner: userId, - }; - assets.push(asset); - } - } - } - // Create thumbnail - const thumbnailImage = await createThumbnail(image, file.type); - const thumbnail = { - ...thumbnailImage, - id: uuid(), - owner: userId, - }; - assets.push(thumbnail); - - const fileAsset = { - id: uuid(), - file: buffer, - width: image.width, - height: image.height, - mime: file.type, - owner: userId, - }; - assets.push(fileAsset); - - const map = { - name, - resolutions, - file: fileAsset.id, - thumbnail: thumbnail.id, - type: "file", - grid: { - size: gridSize, - inset: getGridDefaultInset( - { size: gridSize, type: "square" }, - image.width, - image.height - ), - type: "square", - measurement: { - type: "chebyshev", - scale: "5ft", - }, - }, - width: image.width, - height: image.height, - id: uuid(), - created: Date.now(), - lastModified: Date.now(), - owner: userId, - ...defaultMapProps, - }; - - handleMapAdd(map, assets); - setIsLoading(false); - URL.revokeObjectURL(url); - resolve(); - }; - image.onerror = reject; - image.src = url; - }); + const { map, assets } = await createMapFromFile(file, userId); + await addMap(map); + await addAssets(assets); + setSelectedMapIds([map.id]); + setIsLoading(false); } function openImageDialog() { @@ -326,19 +143,11 @@ function SelectMapModal({ // The map selected in the modal const [selectedMapIds, setSelectedMapIds] = useState([]); - const selectedMaps = ownedMaps.filter((map) => - selectedMapIds.includes(map.id) - ); + const selectedMaps = maps.filter((map) => selectedMapIds.includes(map.id)); const selectedMapStates = mapStates.filter((state) => selectedMapIds.includes(state.mapId) ); - async function handleMapAdd(map, assets) { - await addMap(map); - await addAssets(assets); - setSelectedMapIds([map.id]); - } - const [isMapsRemoveModalOpen, setIsMapsRemoveModalOpen] = useState(false); async function handleMapsRemove() { setIsLoading(true); @@ -374,9 +183,8 @@ function SelectMapModal({ map, selectMode, selectedMapIds, - setSelectedMapIds, - mapsByGroup, - mapGroups + setSelectedMapIds + // TODO: Add new group support ); } @@ -424,7 +232,6 @@ function SelectMapModal({ !selectedMaps.some((map) => map.type === "default") ) { // Ensure all other modals are closed - setIsGroupModalOpen(false); setIsEditModalOpen(false); setIsMapsResetModalOpen(false); setIsMapsRemoveModalOpen(true); @@ -479,7 +286,7 @@ function SelectMapModal({ Select or import a map setIsEditModalOpen(true)} @@ -493,7 +300,7 @@ function SelectMapModal({ onSelectModeChange={setSelectMode} search={search} onSearchChange={handleSearchChange} - onMapsGroup={() => setIsGroupModalOpen(true)} + onMapsGroup={updateMapGroups} />