Merge branch 'master' into typescript

This commit is contained in:
Mitchell McCaffrey
2021-07-02 15:54:54 +10:00
157 changed files with 8114 additions and 4055 deletions

View File

@@ -1,86 +0,0 @@
import React, { useState } from "react";
import { Box, Flex, Text } from "theme-ui";
function ImageDrop({ onDrop, dropText, children }) {
const [dragging, setDragging] = useState(false);
function handleImageDragEnter(event) {
event.preventDefault();
event.stopPropagation();
setDragging(true);
}
function handleImageDragLeave(event) {
event.preventDefault();
event.stopPropagation();
setDragging(false);
}
async function handleImageDrop(event) {
event.preventDefault();
event.stopPropagation();
let imageFiles = [];
// Check if the dropped image is from a URL
const html = event.dataTransfer.getData("text/html");
if (html) {
try {
const urlMatch = html.match(/src="?([^"\s]+)"?\s*/);
const url = urlMatch[1].replace("&", "&"); // Reverse html encoding of url parameters
let name = "";
const altMatch = html.match(/alt="?([^"]+)"?\s*/);
if (altMatch && altMatch.length > 1) {
name = altMatch[1];
}
const response = await fetch(url);
if (response.ok) {
const file = await response.blob();
file.name = name;
imageFiles.push(file);
}
} catch {}
}
const files = event.dataTransfer.files;
for (let file of files) {
if (file.type.startsWith("image")) {
imageFiles.push(file);
}
}
onDrop(imageFiles);
setDragging(false);
}
return (
<Box onDragEnter={handleImageDragEnter}>
{children}
{dragging && (
<Flex
bg="overlay"
sx={{
position: "absolute",
top: 0,
right: 0,
left: 0,
bottom: 0,
justifyContent: "center",
alignItems: "center",
cursor: "copy",
}}
onDragLeave={handleImageDragLeave}
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "copy";
}}
onDrop={handleImageDrop}
>
<Text sx={{ pointerEvents: "none" }}>
{dropText || "Drop image to upload"}
</Text>
</Flex>
)}
</Box>
);
}
export default ImageDrop;

View File

@@ -1,8 +1,15 @@
import React from "react";
import { Box } from "theme-ui";
import Spinner from "./Spinner";
function LoadingOverlay({ bg }: any ) {
function LoadingOverlay({
bg,
children,
}: {
bg: string;
children?: React.ReactNode;
}) {
return (
<Box
sx={{
@@ -20,6 +27,7 @@ function LoadingOverlay({ bg }: any ) {
bg={bg}
>
<Spinner />
{children}
</Box>
);
}

View File

@@ -9,7 +9,7 @@ import {
import ReactMarkdown from "react-markdown";
function Paragraph(props) {
return <Text variant="body2" {...props} />;
return <Text as="p" my={2} variant="body2" {...props} />;
}
function Heading({ level, ...props }) {
@@ -27,6 +27,9 @@ function Heading({ level, ...props }) {
}
function Image(props) {
if (props.alt === "embed:") {
return <Embed as="span" sx={{ display: "block" }} src={props.src} my={2} />;
}
if (props.src.endsWith(".mp4")) {
return (
<video
@@ -125,12 +128,7 @@ function TableCell({ children }) {
}
function Link({ href, children }) {
const linkText = children[0].props.value;
if (linkText === "embed:") {
return <Embed src={href} my={2} />;
} else {
return <UILink href={href}>{children}</UILink>;
}
return <UILink href={href}>{children}</UILink>;
}
function Markdown({ source, assets }) {
@@ -151,7 +149,7 @@ function Markdown({ source, assets }) {
<ReactMarkdown
source={source}
renderers={renderers}
transformImageUri={(uri) => assets[uri]}
transformImageUri={(uri) => assets[uri] || uri}
/>
);
}

View File

@@ -1,11 +1,13 @@
import React, { ReactChild } from "react";
import Modal, { Props } from "react-modal";
import { useThemeUI, Close } from "theme-ui";
import { useSpring, animated, config } from "react-spring";
type ModalProps = Props & {
children: ReactChild | ReactChild[],
allowClose: boolean
}
children: ReactChild | ReactChild[];
allowClose: boolean;
};
function StyledModal({
isOpen,
onRequestClose,
@@ -13,27 +15,55 @@ function StyledModal({
allowClose,
style,
...props
}: ModalProps ) {
}: ModalProps) {
const { theme } = useThemeUI();
const openAnimation = useSpring({
opacity: isOpen ? 1 : 0,
transform: isOpen ? "scale(1)" : "scale(0.99)",
config: config.default,
});
return (
<Modal
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",
...(style?.overlay || {}),
},
content: {
backgroundColor: theme.colors?.background,
top: "50%",
left: "50%",
right: "auto",
bottom: "auto",
marginRight: "-50%",
transform: "translate(-50%, -50%)",
backgroundColor: theme.colors.background,
top: "initial",
left: "initial",
bottom: "initial",
right: "initial",
maxHeight: "100%",
...style,
...(style?.content || {}),
} as React.CSSProperties,
}}
contentElement={(props, content) => (
<animated.div {...props} style={{ ...props.style, ...openAnimation }}>
{content}
</animated.div>
)}
overlayElement={(props, content) => (
<div
onDragEnter={(e) => {
// Prevent drag event from triggering with a modal open
e.preventDefault();
e.stopPropagation();
}}
{...props}
>
{content}
</div>
)}
{...props}
>
{children}
@@ -50,7 +80,7 @@ function StyledModal({
StyledModal.defaultProps = {
allowClose: true,
style: {}
style: {},
};
export default StyledModal;

View File

@@ -1,59 +0,0 @@
import React from "react";
import { Box, Flex, IconButton, Text } from "theme-ui";
function NumberInput({ value, onChange, title, min, max }) {
return (
<Box>
<Text sx={{ textAlign: "center" }} variant="heading" as="h1">
{title}
</Text>
<Flex sx={{ alignItems: "center", justifyContent: "center" }}>
<IconButton
aria-label={`Decrease ${title}`}
title={`Decrease ${title}`}
onClick={() => value > min && onChange(value - 1)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 0 24 24"
width="24"
fill="currentcolor"
>
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M18 13H6c-.55 0-1-.45-1-1s.45-1 1-1h12c.55 0 1 .45 1 1s-.45 1-1 1z" />
</svg>
</IconButton>
<Text as="p" aria-label={`Current ${title}`}>
{value}
</Text>
<IconButton
aria-label={`Increase ${title}`}
title={`Increase ${title}`}
onClick={() => value < max && onChange(value + 1)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
viewBox="0 0 24 24"
width="24"
fill="currentcolor"
>
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M18 13h-5v5c0 .55-.45 1-1 1s-1-.45-1-1v-5H6c-.55 0-1-.45-1-1s.45-1 1-1h5V6c0-.55.45-1 1-1s1 .45 1 1v5h5c.55 0 1 .45 1 1s-.45 1-1 1z" />
</svg>
</IconButton>
</Flex>
</Box>
);
}
NumberInput.defaultProps = {
value: 1,
onChange: () => {},
title: "Number",
min: 0,
max: 10,
};
export default NumberInput;

View File

@@ -24,7 +24,7 @@ function Select({ creatable, ...props }) {
}),
control: (provided, state) => ({
...provided,
backgroundColor: theme.colors.background,
backgroundColor: "transparent",
color: theme.colors.text,
borderColor: theme.colors.text,
opacity: state.isDisabled ? 0.5 : 1,
@@ -53,6 +53,10 @@ function Select({ creatable, ...props }) {
color: theme.colors.text,
opacity: state.isDisabled ? 0.5 : 1,
}),
container: (provided) => ({
...provided,
margin: "4px 0",
}),
}}
theme={(t) => ({
...t,
@@ -63,6 +67,7 @@ function Select({ creatable, ...props }) {
primary25: theme.colors.highlight,
},
})}
captureMenuScroll={false}
{...props}
/>
);

View File

@@ -0,0 +1,22 @@
.textarea-auto-size {
box-sizing: border-box;
margin: 0;
min-width: 0;
display: block;
width: 100%;
appearance: none;
font-size: inherit;
line-height: inherit;
border-radius: 4px;
color: inherit;
background-color: transparent;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", sans-serif;
padding: 4px;
border: none;
resize: none;
}
.textarea-auto-size:focus {
outline: none;
}

View File

@@ -0,0 +1,8 @@
import TextareaAutosize from "react-textarea-autosize";
import "./TextareaAutoSize.css";
function StyledTextareaAutoSize(props) {
return <TextareaAutosize className="textarea-auto-size" {...props} />;
}
export default StyledTextareaAutoSize;

View File

@@ -0,0 +1,80 @@
import React, { useState, useEffect } from "react";
import { Text } from "theme-ui";
import LoadingOverlay from "./LoadingOverlay";
import { shuffle } from "../helpers/shared";
const facts = [
"Owls can rotate their necks 270 degrees",
"Not all owls hoot",
"Owl flight is almost completely silent",
"Owls are used to represent the Goddess Athena in Greek mythology",
"Owls have the best night vision of any animal",
"Bears can run up to 40 mi (~64 km) per hour ",
"A hibernating bears heart beats at 8 bpm",
"Bears can see in colour",
"Koala bears are not bears",
"A polar bear can swim up to 100 mi (~161 km) without resting",
"A group of bears is called a sleuth or sloth",
"Not all bears hibernate",
];
function UpgradingLoadingOverlay() {
const [subText, setSubText] = useState();
useEffect(() => {
let index = 0;
let randomFacts = shuffle(facts);
function updateFact() {
setSubText(randomFacts[index % (randomFacts.length - 1)]);
index += 1;
}
// Show first fact after 10 seconds then every 20 seconds after that
let interval;
let timeout = setTimeout(() => {
updateFact();
interval = setInterval(() => {
updateFact();
}, 20 * 1000);
}, 10 * 1000);
return () => {
clearTimeout(timeout);
if (interval) {
clearInterval(interval);
}
};
}, []);
return (
<LoadingOverlay>
<Text as="p" variant="body2" m={1}>
Database upgrading, please wait...
</Text>
{subText && (
<>
<Text
sx={{ maxWidth: "200px", textAlign: "center" }}
as="p"
variant="caption"
m={1}
>
We're still working on the upgrade. In the meantime, did you know?
</Text>
<Text
sx={{ maxWidth: "200px", textAlign: "center" }}
as="p"
variant="body2"
>
{subText}
</Text>
</>
)}
</LoadingOverlay>
);
}
export default UpgradingLoadingOverlay;

View File

@@ -1,17 +1,20 @@
import React from "react";
import { Image } from "theme-ui";
import Tile from "../Tile";
import Tile from "../tile/Tile";
function DiceTile({ dice, isSelected, onDiceSelect, onDone, size }) {
function DiceTile({ dice, isSelected, onDiceSelect, onDone }) {
return (
<Tile
src={dice.preview}
title={dice.name}
isSelected={isSelected}
onSelect={() => onDiceSelect(dice)}
onDoubleClick={() => onDone(dice)}
size={size}
/>
<div style={{ cursor: "pointer" }}>
<Tile
title={dice.name}
isSelected={isSelected}
onSelect={() => onDiceSelect(dice)}
onDoubleClick={() => onDone(dice)}
>
<Image src={dice.preview}></Image>
</Tile>
</div>
);
}

View File

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

View File

@@ -0,0 +1,23 @@
import React from "react";
import { useDraggable } from "@dnd-kit/core";
function Draggable({ id, children, data }) {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id,
data,
});
const style = {
cursor: isDragging ? "grabbing" : "grab",
touchAction: "none",
opacity: isDragging ? 0.5 : undefined,
};
return (
<div ref={setNodeRef} style={style} {...listeners} {...attributes}>
{children}
</div>
);
}
export default Draggable;

View File

@@ -0,0 +1,18 @@
import React from "react";
import { useDroppable } from "@dnd-kit/core";
function Droppable({ id, children, disabled, ...props }) {
const { setNodeRef } = useDroppable({ id, disabled });
return (
<div ref={setNodeRef} {...props}>
{children}
</div>
);
}
Droppable.defaultProps = {
disabled: false,
};
export default Droppable;

View File

@@ -0,0 +1,248 @@
import React, { useState, useRef } from "react";
import { Box, Flex, Text } from "theme-ui";
import { useToasts } from "react-toast-notifications";
import LoadingOverlay from "../LoadingOverlay";
import ConfirmModal from "../../modals/ConfirmModal";
import { createMapFromFile } from "../../helpers/map";
import { createTokenFromFile } from "../../helpers/token";
import {
createTokenState,
clientPositionToMapPosition,
} from "../../helpers/token";
import Vector2 from "../../helpers/Vector2";
import { useUserId } from "../../contexts/UserIdContext";
import { useMapData } from "../../contexts/MapDataContext";
import { useTokenData } from "../../contexts/TokenDataContext";
import { useAssets } from "../../contexts/AssetsContext";
import { useMapStage } from "../../contexts/MapStageContext";
import useImageDrop from "../../hooks/useImageDrop";
function GlobalImageDrop({ children, onMapChange, onMapTokensStateCreate }) {
const { addToast } = useToasts();
const userId = useUserId();
const { addMap, getMapState } = useMapData();
const { addToken } = useTokenData();
const { addAssets } = useAssets();
const mapStageRef = useMapStage();
const [isLargeImageWarningModalOpen, setShowLargeImageWarning] = useState(
false
);
const [isLoading, setIsLoading] = useState(false);
const droppedImagesRef = useRef();
const dropPositionRef = useRef();
// maps or tokens
const [droppingType, setDroppingType] = useState("maps");
async function handleDrop(files, dropPosition) {
if (navigator.storage) {
// Attempt to enable persistant storage
await navigator.storage.persist();
}
dropPositionRef.current = dropPosition;
droppedImagesRef.current = [];
for (let file of files) {
if (file.size > 5e7) {
addToast(`Unable to import image ${file.name} as it is over 50MB`);
} else {
droppedImagesRef.current.push(file);
}
}
// Any file greater than 20MB
if (droppedImagesRef.current.some((file) => file.size > 2e7)) {
setShowLargeImageWarning(true);
return;
}
if (droppingType === "maps") {
await handleMaps();
} else {
await handleTokens();
}
}
function handleLargeImageWarningCancel() {
droppedImagesRef.current = undefined;
setShowLargeImageWarning(false);
}
async function handleLargeImageWarningConfirm() {
setShowLargeImageWarning(false);
if (droppingType === "maps") {
await handleMaps();
} else {
await handleTokens();
}
}
async function handleMaps() {
setIsLoading(true);
let maps = [];
for (let file of droppedImagesRef.current) {
const { map, assets } = await createMapFromFile(file, userId);
await addMap(map);
await addAssets(assets);
maps.push(map);
}
// Change map if only 1 dropped
if (maps.length === 1) {
const mapState = await getMapState(maps[0].id);
onMapChange(maps[0], mapState);
}
setIsLoading(false);
droppedImagesRef.current = undefined;
}
async function handleTokens() {
setIsLoading(true);
// Keep track of tokens so we can add them to the map
let tokens = [];
for (let file of droppedImagesRef.current) {
const { token, assets } = await createTokenFromFile(file, userId);
await addToken(token);
await addAssets(assets);
tokens.push(token);
}
setIsLoading(false);
droppedImagesRef.current = undefined;
const dropPosition = dropPositionRef.current;
const mapStage = mapStageRef.current;
if (mapStage && dropPosition) {
const mapPosition = clientPositionToMapPosition(mapStage, dropPosition);
if (mapPosition) {
let tokenStates = [];
let offset = new Vector2(0, 0);
for (let token of tokens) {
if (token) {
tokenStates.push(
createTokenState(token, Vector2.add(mapPosition, offset), userId)
);
offset = Vector2.add(offset, 0.01);
}
}
if (tokenStates.length > 0) {
onMapTokensStateCreate(tokenStates);
}
}
}
}
function handleMapsOver() {
setDroppingType("maps");
}
function handleTokensOver() {
setDroppingType("tokens");
}
const { dragging, containerListeners, overlayListeners } = useImageDrop(
handleDrop
);
return (
<Flex sx={{ height: "100%", flexGrow: 1 }} {...containerListeners}>
{children}
{dragging && (
<Flex
sx={{
position: "absolute",
top: 0,
right: 0,
left: 0,
bottom: 0,
cursor: "copy",
flexDirection: "column",
zIndex: 100,
}}
{...overlayListeners}
>
<Flex
sx={{
height: "10%",
justifyContent: "center",
alignItems: "center",
color: droppingType === "maps" ? "primary" : "text",
opacity: droppingType === "maps" ? 1 : 0.8,
width: "100%",
position: "relative",
}}
onDragEnter={handleMapsOver}
>
<Box
bg="overlay"
sx={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
margin: "4px 16px",
border: "1px dashed",
borderRadius: "12px",
pointerEvents: "none",
}}
/>
<Text sx={{ pointerEvents: "none", userSelect: "none" }}>
Drop as map
</Text>
</Flex>
<Flex
sx={{
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
color: droppingType === "tokens" ? "primary" : "text",
opacity: droppingType === "tokens" ? 1 : 0.8,
width: "100%",
position: "relative",
}}
onDragEnter={handleTokensOver}
>
<Box
bg="overlay"
sx={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
margin: "4px 16px",
border: "1px dashed",
borderRadius: "12px",
pointerEvents: "none",
}}
/>
<Text sx={{ pointerEvents: "none", userSelect: "none" }}>
Drop as token
</Text>
</Flex>
</Flex>
)}
<ConfirmModal
isOpen={isLargeImageWarningModalOpen}
onRequestClose={handleLargeImageWarningCancel}
onConfirm={handleLargeImageWarningConfirm}
confirmText="Continue"
label="Warning"
description="An imported image is larger than 20MB, this may cause slowness. Continue?"
/>
{isLoading && <LoadingOverlay bg="overlay" />}
</Flex>
);
}
export default GlobalImageDrop;

View File

@@ -0,0 +1,37 @@
import React from "react";
import { Box, Flex, Text } from "theme-ui";
import useImageDrop from "../../hooks/useImageDrop";
function ImageDrop({ onDrop, dropText, children }) {
const { dragging, containerListeners, overlayListeners } = useImageDrop(
onDrop
);
return (
<Box {...containerListeners}>
{children}
{dragging && (
<Flex
bg="overlay"
sx={{
position: "absolute",
top: 0,
right: 0,
left: 0,
bottom: 0,
justifyContent: "center",
alignItems: "center",
cursor: "copy",
}}
{...overlayListeners}
>
<Text sx={{ pointerEvents: "none", color: "primary" }}>
{dropText || "Drop image to import"}
</Text>
</Flex>
)}
</Box>
);
}
export default ImageDrop;

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useRef, useState } from "react";
import { Box, IconButton } from "theme-ui";
import RemoveTokenIcon from "../icons/RemoveTokenIcon";
import RemoveTokenIcon from "../../icons/RemoveTokenIcon";
function DragOverlay({ dragging, node, onRemove }) {
const [isRemoveHovered, setIsRemoveHovered] = useState(false);

View File

@@ -1,4 +1,6 @@
import React, { useState } from "react";
import { Box } from "theme-ui";
import { useToasts } from "react-toast-notifications";
import MapControls from "./MapControls";
import MapInteraction from "./MapInteraction";
@@ -141,6 +143,8 @@ function Map({
disabledTokens: any,
session: Session
}) {
const { addToast } = useToasts();
const { tokensById } = useTokenData();
const [selectedToolId, setSelectedToolId] = useState("move");
@@ -324,6 +328,7 @@ function Map({
onShapesCut={handleFogShapesCut}
onShapesRemove={handleFogShapesRemove}
onShapesEdit={handleFogShapesEdit}
onShapeError={addToast}
active={selectedToolId === "fog"}
toolSettings={settings.fog}
editable={allowFogDrawing && !settings.fog.preview}
@@ -427,30 +432,32 @@ function Map({
);
return (
<MapInteraction
map={map}
mapState={mapState}
controls={
<>
{mapControls}
{tokenMenu}
{noteMenu}
{tokenDragOverlay}
{noteDragOverlay}
</>
}
selectedToolId={selectedToolId}
onSelectedToolChange={setSelectedToolId}
disabledControls={disabledControls}
>
{mapGrid}
{mapDrawing}
{mapNotes}
{mapTokens}
{mapFog}
{mapPointer}
{mapMeasure}
</MapInteraction>
<Box sx={{ flexGrow: 1 }}>
<MapInteraction
map={map}
mapState={mapState}
controls={
<>
{mapControls}
{tokenMenu}
{noteMenu}
{tokenDragOverlay}
{noteDragOverlay}
</>
}
selectedToolId={selectedToolId}
onSelectedToolChange={setSelectedToolId}
disabledControls={disabledControls}
>
{mapGrid}
{mapDrawing}
{mapNotes}
{mapTokens}
{mapFog}
{mapPointer}
{mapMeasure}
</MapInteraction>
</Box>
);
}

View File

@@ -119,8 +119,7 @@ function MapDrawing({
}
const simplified = simplifyPoints(
[...prevPoints, brushPosition],
gridCellNormalizedSize,
stageScale
1 / 1000 / stageScale
);
return {
...prevShape,

View File

@@ -0,0 +1,159 @@
import React, { useState, useEffect } from "react";
import { Flex, Close, IconButton } from "theme-ui";
import { groupsFromIds, itemsFromGroups } from "../../helpers/group";
import ConfirmModal from "../../modals/ConfirmModal";
import ResetMapIcon from "../../icons/ResetMapIcon";
import RemoveMapIcon from "../../icons/RemoveMapIcon";
import { useGroup } from "../../contexts/GroupContext";
import { useMapData } from "../../contexts/MapDataContext";
import { useKeyboard } from "../../contexts/KeyboardContext";
import shortcuts from "../../shortcuts";
function MapEditBar({ currentMap, disabled, onMapChange, onMapReset, onLoad }) {
const [hasMapState, setHasMapState] = useState(false);
const { maps, mapStates, removeMaps, resetMap } = useMapData();
const { activeGroups, selectedGroupIds, onGroupSelect } = useGroup();
useEffect(() => {
const selectedGroups = groupsFromIds(selectedGroupIds, activeGroups);
const selectedMapStates = itemsFromGroups(
selectedGroups,
mapStates,
"mapId"
);
let _hasMapState = false;
for (let state of selectedMapStates) {
if (
Object.values(state.tokens).length > 0 ||
Object.values(state.drawShapes).length > 0 ||
Object.values(state.fogShapes).length > 0 ||
Object.values(state.notes).length > 0
) {
_hasMapState = true;
break;
}
}
setHasMapState(_hasMapState);
}, [selectedGroupIds, mapStates, activeGroups]);
function getSelectedMaps() {
const selectedGroups = groupsFromIds(selectedGroupIds, activeGroups);
return itemsFromGroups(selectedGroups, maps);
}
const [isMapsRemoveModalOpen, setIsMapsRemoveModalOpen] = useState(false);
async function handleMapsRemove() {
onLoad(true);
setIsMapsRemoveModalOpen(false);
const selectedMaps = getSelectedMaps();
const selectedMapIds = selectedMaps.map((map) => map.id);
onGroupSelect();
await removeMaps(selectedMapIds);
// Removed the map from the map screen if needed
if (currentMap && selectedMapIds.includes(currentMap.id)) {
onMapChange(null, null);
}
onLoad(false);
}
const [isMapsResetModalOpen, setIsMapsResetModalOpen] = useState(false);
async function handleMapsReset() {
onLoad(true);
setIsMapsResetModalOpen(false);
const selectedMaps = getSelectedMaps();
const selectedMapIds = selectedMaps.map((map) => map.id);
for (let id of selectedMapIds) {
const newState = await resetMap(id);
// Reset the state of the current map if needed
if (currentMap && currentMap.id === id) {
onMapReset(newState);
}
}
onLoad(false);
}
/**
* Shortcuts
*/
function handleKeyDown(event) {
if (disabled) {
return;
}
if (shortcuts.delete(event)) {
const selectedMaps = getSelectedMaps();
if (selectedMaps.length > 0) {
setIsMapsResetModalOpen(false);
setIsMapsRemoveModalOpen(true);
}
}
}
useKeyboard(handleKeyDown);
if (selectedGroupIds.length === 0) {
return null;
}
return (
<Flex
sx={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
justifyContent: "space-between",
}}
bg="overlay"
>
<Close
title="Clear Selection"
aria-label="Clear Selection"
onClick={() => onGroupSelect()}
/>
<Flex>
<IconButton
aria-label="Reset Selected Map(s)"
title="Reset Selected Map(s)"
onClick={() => setIsMapsResetModalOpen(true)}
disabled={!hasMapState}
>
<ResetMapIcon />
</IconButton>
<IconButton
aria-label="Remove Selected Map(s)"
title="Remove Selected Map(s)"
onClick={() => setIsMapsRemoveModalOpen(true)}
>
<RemoveMapIcon />
</IconButton>
</Flex>
<ConfirmModal
isOpen={isMapsResetModalOpen}
onRequestClose={() => setIsMapsResetModalOpen(false)}
onConfirm={handleMapsReset}
confirmText="Reset"
label="Reset Selected Map(s)"
description="This will remove all fog, drawings and tokens from the selected maps."
/>
<ConfirmModal
isOpen={isMapsRemoveModalOpen}
onRequestClose={() => setIsMapsRemoveModalOpen(false)}
onConfirm={handleMapsRemove}
confirmText="Remove"
label="Remove Selected Map(s)"
description="This operation cannot be undone."
/>
</Flex>
);
}
export default MapEditBar;

View File

@@ -23,7 +23,7 @@ import MapGrid from "./MapGrid";
import MapGridEditor from "./MapGridEditor";
function MapEditor({ map, onSettingsChange }) {
const [mapImageSource] = useMapImage(map);
const [mapImage] = useMapImage(map);
const [stageWidth, setStageWidth] = useState(1);
const [stageHeight, setStageHeight] = useState(1);
@@ -93,14 +93,14 @@ function MapEditor({ map, onSettingsChange }) {
interactionEmitter: null,
};
const canEditGrid = map.type !== "default";
const gridChanged =
map.grid.inset.topLeft.x !== defaultInset.topLeft.x ||
map.grid.inset.topLeft.y !== defaultInset.topLeft.y ||
map.grid.inset.bottomRight.x !== defaultInset.bottomRight.x ||
map.grid.inset.bottomRight.y !== defaultInset.bottomRight.y;
const gridValid = map.grid.size.x !== 0 && map.grid.size.y !== 0;
const layout = useResponsiveLayout();
return (
@@ -132,12 +132,8 @@ function MapEditor({ map, onSettingsChange }) {
)}
>
<Layer ref={mapLayerRef}>
<Image
image={mapImageSource}
width={mapWidth}
height={mapHeight}
/>
{showGridControls && canEditGrid && (
<Image image={mapImage} width={mapWidth} height={mapHeight} />
{showGridControls && gridValid && (
<>
<MapGrid map={map} />
<MapGridEditor map={map} onGridChange={handleGridChange} />
@@ -146,7 +142,7 @@ function MapEditor({ map, onSettingsChange }) {
</Layer>
</KonvaBridge>
</ReactResizeDetector>
{gridChanged && (
{gridChanged && gridValid && (
<IconButton
title="Reset Grid"
aria-label="Reset Grid"
@@ -163,28 +159,26 @@ function MapEditor({ map, onSettingsChange }) {
<ResetMapIcon />
</IconButton>
)}
{canEditGrid && (
<IconButton
title={
showGridControls ? "Hide Grid Controls" : "Show Grid Controls"
}
aria-label={
showGridControls ? "Hide Grid Controls" : "Show Grid Controls"
}
onClick={() => setShowGridControls(!showGridControls)}
bg="overlay"
sx={{
borderRadius: "50%",
position: "absolute",
bottom: 0,
right: 0,
}}
m={2}
p="6px"
>
{showGridControls ? <GridOnIcon /> : <GridOffIcon />}
</IconButton>
)}
<IconButton
title={
showGridControls ? "Hide Grid Controls" : "Show Grid Controls"
}
aria-label={
showGridControls ? "Hide Grid Controls" : "Show Grid Controls"
}
onClick={() => setShowGridControls(!showGridControls)}
bg="overlay"
sx={{
borderRadius: "50%",
position: "absolute",
bottom: 0,
right: 0,
}}
m={2}
p="6px"
>
{showGridControls ? <GridOnIcon /> : <GridOffIcon />}
</IconButton>
</Box>
</GridProvider>
</MapInteractionProvider>

View File

@@ -1,7 +1,8 @@
import React, { useState, useEffect, useCallback, useRef } from "react";
import React, { useState, useEffect, useCallback } from "react";
import shortid from "shortid";
import { Group, Rect, Line } from "react-konva";
import { Group, Line } from "react-konva";
import useImage from "use-image";
import Color from "color";
import diagonalPattern from "../../images/DiagonalPattern.png";
@@ -37,8 +38,10 @@ import {
Tick,
getRelativePointerPosition,
} from "../../helpers/konva";
import { keyBy } from "../../helpers/shared";
import SubtractShapeAction from "../../actions/SubtractShapeAction";
import CutShapeAction from "../../actions/CutShapeAction";
import useSetting from "../../hooks/useSetting";
@@ -51,6 +54,7 @@ function MapFog({
onShapesCut,
onShapesRemove,
onShapesEdit,
onShapeError,
active,
toolSettings,
editable,
@@ -175,8 +179,7 @@ function MapFog({
}
const simplified = simplifyPoints(
[...prevPoints, brushPosition],
gridCellNormalizedSize,
stageScale / 4
1 / 1000 / stageScale
);
return {
...prevShape,
@@ -214,6 +217,8 @@ function MapFog({
) {
const cut = toolSettings.useFogCut;
let drawingShapes = [drawingShape];
// Filter out hidden or visible shapes if single layer enabled
if (!toolSettings.multilayer) {
const shapesToSubtract = shapes.filter((shape) =>
cut ? !shape.visible : shape.visible
@@ -228,22 +233,32 @@ function MapFog({
}
if (drawingShapes.length > 0) {
drawingShapes = drawingShapes.map((shape) => {
if (cut) {
return {
id: shape.id,
type: shape.type,
data: shape.data,
};
} else {
return { ...shape, color: "black" };
}
});
if (cut) {
onShapesCut(drawingShapes);
// Run a pre-emptive cut action to check whether we've cut anything
const cutAction = new CutShapeAction(drawingShapes);
const state = cutAction.execute(keyBy(shapes, "id"));
if (Object.keys(state).length === shapes.length) {
onShapeError("No fog to cut");
} else {
onShapesCut(
drawingShapes.map((shape) => ({
id: shape.id,
type: shape.type,
data: shape.data,
}))
);
}
} else {
onShapesAdd(drawingShapes);
onShapesAdd(
drawingShapes.map((shape) => ({ ...shape, color: "black" }))
);
}
} else {
if (cut) {
onShapeError("Fog already cut");
} else {
onShapeError("Fog already placed");
}
}
setDrawingShape(null);
@@ -373,6 +388,7 @@ function MapFog({
};
let polygonShapes = [polygonShape];
// Filter out hidden or visible shapes if single layer enabled
if (!toolSettings.multilayer) {
const shapesToSubtract = shapes.filter((shape) =>
cut ? !shape.visible : shape.visible
@@ -388,7 +404,15 @@ function MapFog({
if (polygonShapes.length > 0) {
if (cut) {
onShapesCut(polygonShapes);
// Run a pre-emptive cut action to check whether we've cut anything
const cutAction = new CutShapeAction(polygonShapes);
const state = cutAction.execute(keyBy(shapes, "id"));
if (Object.keys(state).length === shapes.length) {
onShapeError("No fog to cut");
} else {
onShapesCut(polygonShapes);
}
} else {
onShapesAdd(
polygonShapes.map((shape) => ({
@@ -399,10 +423,23 @@ function MapFog({
}))
);
}
} else {
if (cut) {
onShapeError("Fog already cut");
} else {
onShapeError("Fog already placed");
}
}
setDrawingShape(null);
}, [toolSettings, drawingShape, onShapesCut, onShapesAdd, shapes]);
}, [
toolSettings,
drawingShape,
onShapesCut,
onShapesAdd,
onShapeError,
shapes,
]);
// Add keyboard shortcuts
function handleKeyDown(event) {
@@ -489,6 +526,15 @@ function MapFog({
const holes =
shape.data.holes &&
shape.data.holes.map((hole) => hole.reduce(reducePoints, []));
const opacity = editable ? editOpacity : 1;
// Control opacity only on fill as using opacity with stroke leads to performance issues
const fill = new Color(colors[shape.color] || shape.color)
.alpha(opacity)
.string();
const stroke =
editable && active
? colors.lightGray
: colors[shape.color] || shape.color;
return (
<HoleyLine
key={shape.id}
@@ -499,19 +545,12 @@ function MapFog({
onMouseUp={eraseHoveredShapes}
onTouchEnd={eraseHoveredShapes}
points={points}
stroke={
editable && active
? colors.lightGray
: colors[shape.color] || shape.color
}
fill={colors[shape.color] || shape.color}
stroke={stroke}
fill={fill}
closed
lineCap="round"
lineJoin="round"
strokeWidth={gridStrokeWidth * shape.strokeWidth}
opacity={
editable ? (!shape.visible ? editOpacity / 2 : editOpacity) : 1
}
fillPatternImage={patternImage}
fillPriority={editable && !shape.visible ? "pattern" : "color"}
holes={holes}
@@ -590,15 +629,9 @@ function MapFog({
}
}, [shapes, editable, active, toolSettings, shouldRenderGuides]);
const fogGroupRef = useRef();
return (
<Group>
<Group ref={fogGroupRef}>
{/* Render a blank shape so cache works with no fog shapes */}
<Rect width={1} height={1} />
{fogShapes.map(renderShape)}
</Group>
<Group>{fogShapes.map(renderShape)}</Group>
{shouldRenderGuides && renderGuides()}
{drawingShape && renderShape(drawingShape)}
{drawingShape &&

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState } from "react";
import useImage from "use-image";
import { useImageSource } from "../../contexts/ImageSourceContext";
import { useDataURL } from "../../contexts/AssetsContext";
import { mapSources as defaultMapSources } from "../../maps";
@@ -11,15 +11,13 @@ import Grid from "../Grid";
function MapGrid({ map }) {
let mapSourceMap = map;
// Use lowest resolution for grid lightness
if (map && map.type === "file" && map.resolutions) {
const resolutionArray = Object.keys(map.resolutions);
if (resolutionArray.length > 0) {
mapSourceMap = map.resolutions[resolutionArray[0]];
}
}
const mapSource = useImageSource(mapSourceMap, defaultMapSources);
const [mapImage, mapLoadingStatus] = useImage(mapSource);
const mapURL = useDataURL(
mapSourceMap,
defaultMapSources,
undefined,
map.type === "file"
);
const [mapImage, mapLoadingStatus] = useImage(mapURL);
const [isImageLight, setIsImageLight] = useState(true);

View File

@@ -77,7 +77,10 @@ function MapGridEditor({ map, onGridChange }) {
Vector2.subtract(position, previousPosition)
);
const inset = map.grid.inset;
const inset = {
topLeft: { ...map.grid.inset.topLeft },
bottomRight: { ...map.grid.inset.bottomRight },
};
if (direction.x === 0 && direction.y === 0) {
return inset;

View File

@@ -0,0 +1,18 @@
import React from "react";
import { Image } from "theme-ui";
import { useDataURL } from "../../contexts/AssetsContext";
import { mapSources as defaultMapSources } from "../../maps";
const MapTileImage = React.forwardRef(({ map, ...props }, ref) => {
const mapURL = useDataURL(
map,
defaultMapSources,
undefined,
map.type === "file"
);
return <Image src={mapURL} ref={ref} {...props} />;
});
export default MapTileImage;

View File

@@ -28,22 +28,16 @@ function MapInteraction({
onSelectedToolChange,
disabledControls,
}) {
const [mapImageSource, mapImageSourceStatus] = useMapImage(map);
const [mapImage, mapImageStatus] = useMapImage(map);
// Map loaded taking in to account different resolutions
const [mapLoaded, setMapLoaded] = useState(false);
useEffect(() => {
if (
!map ||
!mapState ||
(map.type === "file" && !map.file && !map.resolutions) ||
mapState.mapId !== map.id
) {
if (!map || !mapState || mapState.mapId !== map.id) {
setMapLoaded(false);
} else if (mapImageSourceStatus === "loaded") {
} else if (mapImageStatus === "loaded") {
setMapLoaded(true);
}
}, [mapImageSourceStatus, map, mapState]);
}, [mapImageStatus, map, mapState]);
const [stageWidth, setStageWidth] = useState(1);
const [stageHeight, setStageHeight] = useState(1);
@@ -187,11 +181,12 @@ function MapInteraction({
<GridProvider grid={map?.grid} width={mapWidth} height={mapHeight}>
<Box
sx={{
flexGrow: 1,
position: "relative",
cursor: getCursorForTool(selectedToolId),
touchAction: "none",
outline: "none",
width: "100%",
height: "100%",
}}
ref={containerRef}
className="map"
@@ -211,7 +206,7 @@ function MapInteraction({
>
<Layer ref={mapLayerRef}>
<Image
image={mapLoaded && mapImageSource}
image={mapLoaded && mapImage}
width={mapWidth}
height={mapHeight}
id="mapImage"

View File

@@ -44,7 +44,10 @@ function MapMeasure({ map, active }) {
const gridScale = parseGridScale(active && grid.measurement.scale);
const snapPositionToGrid = useGridSnapping();
const snapPositionToGrid = useGridSnapping(
grid.measurement.type === "euclidean" ? 0 : 1,
false
);
useEffect(() => {
if (!active) {

View File

@@ -4,7 +4,7 @@ import { Group } from "react-konva";
import { useInteractionEmitter } from "../../contexts/MapInteractionContext";
import { useMapStage } from "../../contexts/MapStageContext";
import { useAuth } from "../../contexts/AuthContext";
import { useUserId } from "../../contexts/UserIdContext";
import Vector2 from "../../helpers/Vector2";
import { getRelativePointerPosition } from "../../helpers/konva";
@@ -28,7 +28,7 @@ function MapNotes({
fadeOnHover,
}) {
const interactionEmitter = useInteractionEmitter();
const { userId } = useAuth();
const userId = useUserId();
const mapStageRef = useMapStage();
const [isBrushDown, setIsBrushDown] = useState(false);
const [noteData, setNoteData] = useState(null);

View File

@@ -1,11 +1,12 @@
import React from "react";
import { Flex, Box, Label, Input, Checkbox, IconButton } from "theme-ui";
import ExpandMoreIcon from "../../icons/ExpandMoreIcon";
import React, { useEffect, useState } from "react";
import { Flex, Box, Label, Input, Checkbox } from "theme-ui";
import { isEmpty } from "../../helpers/shared";
import { getGridUpdatedInset } from "../../helpers/grid";
import { useDataURL } from "../../contexts/AssetsContext";
import { mapSources as defaultMapSources } from "../../maps";
import Divider from "../Divider";
import Select from "../Select";
@@ -40,8 +41,6 @@ function MapSettings({
mapState,
onSettingsChange,
onStateSettingsChange,
showMore,
onShowMoreChange,
}) {
function handleFlagChange(event, flag) {
if (event.target.checked) {
@@ -116,16 +115,22 @@ function MapSettings({
onSettingsChange("grid", grid);
}
function getMapSize() {
let size = 0;
if (map.quality === "original") {
size = map.file.length;
} else {
size = map.resolutions[map.quality].file.length;
const mapURL = useDataURL(map, defaultMapSources);
const [mapSize, setMapSize] = useState(0);
useEffect(() => {
async function updateMapSize() {
if (mapURL) {
const response = await fetch(mapURL);
const blob = await response.blob();
let size = blob.size;
size /= 1000000; // Bytes to Megabytes
setMapSize(size.toFixed(2));
} else {
setMapSize(0);
}
}
size /= 1000000; // Bytes to Megabytes
return `${size.toFixed(2)}MB`;
}
updateMapSize();
}, [mapURL]);
const mapEmpty = !map || isEmpty(map);
const mapStateEmpty = !mapState || isEmpty(mapState);
@@ -140,7 +145,7 @@ function MapSettings({
name="gridX"
value={`${(map && map.grid.size.x) || 0}`}
onChange={handleGridSizeXChange}
disabled={mapEmpty || map.type === "default"}
disabled={mapEmpty}
min={1}
my={1}
/>
@@ -152,7 +157,7 @@ function MapSettings({
name="gridY"
value={`${(map && map.grid.size.y) || 0}`}
onChange={handleGridSizeYChange}
disabled={mapEmpty || map.type === "default"}
disabled={mapEmpty}
min={1}
my={1}
/>
@@ -164,176 +169,146 @@ function MapSettings({
name="name"
value={(map && map.name) || ""}
onChange={(e) => onSettingsChange("name", e.target.value)}
disabled={mapEmpty || map.type === "default"}
disabled={mapEmpty}
my={1}
/>
</Box>
{showMore && (
<>
<Flex
mt={2}
mb={mapEmpty || map.type === "default" ? 2 : 0}
sx={{ flexDirection: "column" }}
>
<Flex sx={{ alignItems: "flex-end" }}>
<Box mb={1} sx={{ width: "50%" }}>
<Label mb={1}>Grid Type</Label>
<Select
isDisabled={mapEmpty || map.type === "default"}
options={gridTypeSettings}
value={
!mapEmpty &&
gridTypeSettings.find((s) => s.value === map.grid.type)
}
onChange={handleGridTypeChange}
isSearchable={false}
/>
</Box>
<Flex sx={{ width: "50%", flexDirection: "column" }} ml={2}>
<Label>
<Checkbox
checked={!mapEmpty && map.showGrid}
disabled={mapEmpty || map.type === "default"}
onChange={(e) =>
onSettingsChange("showGrid", e.target.checked)
}
/>
Draw Grid
</Label>
<Label>
<Checkbox
checked={!mapEmpty && map.snapToGrid}
disabled={mapEmpty || map.type === "default"}
onChange={(e) =>
onSettingsChange("snapToGrid", e.target.checked)
}
/>
Snap to Grid
</Label>
</Flex>
</Flex>
<Flex sx={{ alignItems: "flex-end" }}>
<Box my={2} sx={{ width: "50%" }}>
<Label mb={1}>Grid Measurement</Label>
<Select
isDisabled={mapEmpty || map.type === "default"}
options={
map && map.grid.type === "square"
? gridSquareMeasurementTypeSettings
: gridHexMeasurementTypeSettings
}
value={
!mapEmpty &&
gridSquareMeasurementTypeSettings.find(
(s) => s.value === map.grid.measurement.type
)
}
onChange={handleGridMeasurementTypeChange}
isSearchable={false}
/>
</Box>
<Box mb={1} mx={2} sx={{ flexGrow: 1 }}>
<Label htmlFor="gridMeasurementScale">Grid Scale</Label>
<Input
name="gridMeasurementScale"
value={`${map && map.grid.measurement.scale}`}
onChange={handleGridMeasurementScaleChange}
disabled={mapEmpty || map.type === "default"}
min={1}
my={1}
autoComplete="off"
/>
</Box>
</Flex>
</Flex>
{!mapEmpty && map.type !== "default" && (
<Flex my={2} sx={{ alignItems: "center" }}>
<Box mb={1} sx={{ width: "50%" }}>
<Label mb={1}>Quality</Label>
<Select
options={qualitySettings}
value={
!mapEmpty &&
qualitySettings.find((s) => s.value === map.quality)
}
isDisabled={mapEmpty}
onChange={(option) =>
onSettingsChange("quality", option.value)
}
isOptionDisabled={(option) =>
mapEmpty ||
(option.value !== "original" &&
!map.resolutions[option.value])
}
isSearchable={false}
/>
</Box>
<Label sx={{ width: "50%" }} ml={2}>
Size: {getMapSize()}
</Label>
</Flex>
)}
<Divider fill />
<Box my={2} sx={{ flexGrow: 1 }}>
<Label>Allow Others to Edit</Label>
<Flex my={1}>
<Label>
<Checkbox
checked={!mapStateEmpty && mapState.editFlags.includes("fog")}
disabled={mapStateEmpty}
onChange={(e) => handleFlagChange(e, "fog")}
/>
Fog
</Label>
<Label>
<Checkbox
checked={
!mapStateEmpty && mapState.editFlags.includes("drawing")
}
disabled={mapStateEmpty}
onChange={(e) => handleFlagChange(e, "drawing")}
/>
Drawings
</Label>
<Label>
<Checkbox
checked={
!mapStateEmpty && mapState.editFlags.includes("tokens")
}
disabled={mapStateEmpty}
onChange={(e) => handleFlagChange(e, "tokens")}
/>
Tokens
</Label>
<Label>
<Checkbox
checked={
!mapStateEmpty && mapState.editFlags.includes("notes")
}
disabled={mapStateEmpty}
onChange={(e) => handleFlagChange(e, "notes")}
/>
Notes
</Label>
</Flex>
</Box>
</>
)}
<IconButton
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onShowMoreChange(!showMore);
}}
sx={{
transform: `rotate(${showMore ? "180deg" : "0"})`,
alignSelf: "center",
}}
aria-label={showMore ? "Show Less" : "Show More"}
title={showMore ? "Show Less" : "Show More"}
<Flex
mt={2}
mb={mapEmpty || map.type === "default" ? 2 : 0}
sx={{ flexDirection: "column" }}
>
<ExpandMoreIcon />
</IconButton>
<Flex sx={{ alignItems: "flex-end" }}>
<Box sx={{ width: "50%" }}>
<Label>Grid Type</Label>
<Select
isDisabled={mapEmpty}
options={gridTypeSettings}
value={
!mapEmpty &&
gridTypeSettings.find((s) => s.value === map.grid.type)
}
onChange={handleGridTypeChange}
isSearchable={false}
/>
</Box>
<Flex sx={{ flexGrow: 1, flexDirection: "column" }} ml={2}>
<Label>
<Checkbox
checked={!mapEmpty && map.showGrid}
disabled={mapEmpty}
onChange={(e) => onSettingsChange("showGrid", e.target.checked)}
/>
Draw Grid
</Label>
<Label>
<Checkbox
checked={!mapEmpty && map.snapToGrid}
disabled={mapEmpty}
onChange={(e) =>
onSettingsChange("snapToGrid", e.target.checked)
}
/>
Snap to Grid
</Label>
</Flex>
</Flex>
<Flex sx={{ alignItems: "flex-end" }}>
<Box my={2} sx={{ width: "50%" }}>
<Label>Grid Measurement</Label>
<Select
isDisabled={mapEmpty}
options={
map && map.grid.type === "square"
? gridSquareMeasurementTypeSettings
: gridHexMeasurementTypeSettings
}
value={
!mapEmpty &&
gridSquareMeasurementTypeSettings.find(
(s) => s.value === map.grid.measurement.type
)
}
onChange={handleGridMeasurementTypeChange}
isSearchable={false}
/>
</Box>
<Box m={2} mr={0} sx={{ flexGrow: 1 }}>
<Label htmlFor="gridMeasurementScale">Grid Scale</Label>
<Input
name="gridMeasurementScale"
value={`${map && map.grid.measurement.scale}`}
onChange={handleGridMeasurementScaleChange}
disabled={mapEmpty}
min={1}
my={1}
autoComplete="off"
/>
</Box>
</Flex>
</Flex>
{!mapEmpty && map.type !== "default" && (
<Flex my={2} sx={{ alignItems: "center" }}>
<Box mb={1} sx={{ width: "50%" }}>
<Label>Quality</Label>
<Select
options={qualitySettings}
value={
!mapEmpty &&
qualitySettings.find((s) => s.value === map.quality)
}
isDisabled={mapEmpty}
onChange={(option) => onSettingsChange("quality", option.value)}
isOptionDisabled={(option) =>
mapEmpty ||
(option.value !== "original" && !map.resolutions[option.value])
}
isSearchable={false}
/>
</Box>
<Label sx={{ width: "50%" }} ml={2}>
Size: {mapSize > 0 && `${mapSize}MB`}
</Label>
</Flex>
)}
<Divider fill />
<Box my={2} sx={{ flexGrow: 1 }}>
<Label>Allow Others to Edit</Label>
<Flex my={1}>
<Label>
<Checkbox
checked={!mapStateEmpty && mapState.editFlags.includes("fog")}
disabled={mapStateEmpty}
onChange={(e) => handleFlagChange(e, "fog")}
/>
Fog
</Label>
<Label>
<Checkbox
checked={!mapStateEmpty && mapState.editFlags.includes("drawing")}
disabled={mapStateEmpty}
onChange={(e) => handleFlagChange(e, "drawing")}
/>
Drawings
</Label>
<Label>
<Checkbox
checked={!mapStateEmpty && mapState.editFlags.includes("tokens")}
disabled={mapStateEmpty}
onChange={(e) => handleFlagChange(e, "tokens")}
/>
Tokens
</Label>
<Label>
<Checkbox
checked={!mapStateEmpty && mapState.editFlags.includes("notes")}
disabled={mapStateEmpty}
onChange={(e) => handleFlagChange(e, "notes")}
/>
Notes
</Label>
</Flex>
</Box>
</Flex>
);
}

View File

@@ -1,5 +0,0 @@
import React from "react";
function MapTest() {}
export default MapTest;

View File

@@ -1,40 +1,30 @@
import React from "react";
import Tile from "../Tile";
import { useImageSource } from "../../contexts/ImageSourceContext";
import { mapSources as defaultMapSources, unknownSource } from "../../maps";
import Tile from "../tile/Tile";
import MapImage from "./MapImage";
function MapTile({
map,
isSelected,
onMapSelect,
onMapEdit,
onDone,
size,
onSelect,
onEdit,
onDoubleClick,
canEdit,
badges,
}) {
const mapSource = useImageSource(
map,
defaultMapSources,
unknownSource,
map.type === "file"
);
return (
<Tile
src={mapSource}
title={map.name}
isSelected={isSelected}
onSelect={() => onMapSelect(map)}
onEdit={() => onMapEdit(map.id)}
onDoubleClick={() => canEdit && onDone()}
size={size}
onSelect={() => onSelect(map.id)}
onEdit={() => onEdit(map.id)}
onDoubleClick={() => canEdit && onDoubleClick()}
canEdit={canEdit}
badges={badges}
editTitle="Edit Map"
/>
>
<MapImage map={map} />
</Tile>
);
}

View File

@@ -0,0 +1,41 @@
import React from "react";
import { Grid } from "theme-ui";
import Tile from "../tile/Tile";
import MapImage from "./MapImage";
import useResponsiveLayout from "../../hooks/useResponsiveLayout";
function MapTileGroup({ group, maps, isSelected, onSelect, onDoubleClick }) {
const layout = useResponsiveLayout();
return (
<Tile
title={group.name}
isSelected={isSelected}
onSelect={() => onSelect(group.id)}
onDoubleClick={onDoubleClick}
>
<Grid
columns={`repeat(${layout.groupGridColumns}, 1fr)`}
p={2}
sx={{
gridGap: 2,
gridTemplateRows: `repeat(${layout.groupGridColumns}, 1fr)`,
}}
>
{maps
.slice(0, layout.groupGridColumns * layout.groupGridColumns)
.map((map) => (
<MapImage
sx={{ borderRadius: "8px" }}
map={map}
key={`${map.id}-group-tile`}
/>
))}
</Grid>
</Tile>
);
}
export default MapTileGroup;

View File

@@ -1,179 +1,68 @@
import React from "react";
import { Flex, Box, Text, IconButton, Close, Label } from "theme-ui";
import SimpleBar from "simplebar-react";
import Case from "case";
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 MapTileGroup from "./MapTileGroup";
import { useDatabase } from "../../contexts/DatabaseContext";
import SortableTiles from "../tile/SortableTiles";
import SortableTilesDragOverlay from "../tile/SortableTilesDragOverlay";
import useResponsiveLayout from "../../hooks/useResponsiveLayout";
import { getGroupItems } from "../../helpers/group";
function MapTiles({
maps,
groups,
selectedMaps,
selectedMapStates,
onMapSelect,
onMapsRemove,
onMapsReset,
onMapAdd,
onMapEdit,
onDone,
selectMode,
onSelectModeChange,
search,
onSearchChange,
onMapsGroup,
}) {
const { databaseStatus } = useDatabase();
const layout = useResponsiveLayout();
import { useGroup } from "../../contexts/GroupContext";
let hasMapState = false;
for (let state of selectedMapStates) {
if (
Object.values(state.tokens).length > 0 ||
Object.values(state.drawShapes).length > 0 ||
Object.values(state.fogShapes).length > 0 ||
Object.values(state.notes).length > 0
) {
hasMapState = true;
break;
function MapTiles({ mapsById, onMapEdit, onMapSelect, subgroup }) {
const {
selectedGroupIds,
selectMode,
onGroupOpen,
onGroupSelect,
} = useGroup();
function renderTile(group) {
if (group.type === "item") {
const map = mapsById[group.id];
if (map) {
const isSelected = selectedGroupIds.includes(group.id);
const canEdit =
isSelected &&
selectMode === "single" &&
selectedGroupIds.length === 1;
return (
<MapTile
key={map.id}
map={map}
isSelected={isSelected}
onSelect={onGroupSelect}
onEdit={onMapEdit}
onDoubleClick={() => canEdit && onMapSelect(group.id)}
canEdit={canEdit}
badges={[`${map.grid.size.x}x${map.grid.size.y}`]}
/>
);
}
} else {
const isSelected = selectedGroupIds.includes(group.id);
const items = getGroupItems(group);
const canOpen =
isSelected && selectMode === "single" && selectedGroupIds.length === 1;
return (
<MapTileGroup
key={group.id}
group={group}
maps={items.map((item) => mapsById[item.id])}
isSelected={isSelected}
onSelect={onGroupSelect}
onDoubleClick={() => canOpen && onGroupOpen(group.id)}
/>
);
}
}
let hasSelectedDefaultMap = selectedMaps.some(
(map) => map.type === "default"
);
function mapToTile(map) {
const isSelected = selectedMaps.includes(map);
return (
<MapTile
key={map.id}
map={map}
isSelected={isSelected}
onMapSelect={onMapSelect}
onMapEdit={onMapEdit}
onDone={onDone}
size={layout.tileSize}
canEdit={
isSelected && selectMode === "single" && selectedMaps.length === 1
}
badges={[`${map.grid.size.x}x${map.grid.size.y}`]}
/>
);
}
const multipleSelected = selectedMaps.length > 1;
return (
<Box sx={{ position: "relative" }}>
<FilterBar
onFocus={() => onMapSelect()}
search={search}
onSearchChange={onSearchChange}
selectMode={selectMode}
onSelectModeChange={onSelectModeChange}
onAdd={onMapAdd}
addTitle="Add Map"
/>
<SimpleBar
style={{ height: layout.screenSize === "large" ? "600px" : "400px" }}
>
<Flex
p={2}
pb={4}
pt={databaseStatus === "disabled" ? 4 : 2}
bg="muted"
sx={{
flexWrap: "wrap",
borderRadius: "4px",
minHeight: layout.screenSize === "large" ? "600px" : "400px",
alignContent: "flex-start",
}}
onClick={() => onMapSelect()}
>
{groups.map((group) => (
<React.Fragment key={group}>
<Label mx={1} mt={2}>
{Case.capital(group)}
</Label>
{maps[group].map(mapToTile)}
</React.Fragment>
))}
</Flex>
</SimpleBar>
{databaseStatus === "disabled" && (
<Box
sx={{
position: "absolute",
top: "39px",
left: 0,
right: 0,
textAlign: "center",
borderRadius: "2px",
}}
bg="highlight"
p={1}
>
<Text as="p" variant="body2">
Map saving is unavailable. See <Link to="/faq#saving">FAQ</Link> for
more information.
</Text>
</Box>
)}
{selectedMaps.length > 0 && (
<Flex
sx={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
justifyContent: "space-between",
}}
bg="overlay"
>
<Close
title="Clear Selection"
aria-label="Clear Selection"
onClick={() => onMapSelect()}
/>
<Flex>
<IconButton
aria-label={multipleSelected ? "Group Maps" : "Group Map"}
title={multipleSelected ? "Group Maps" : "Group Map"}
onClick={() => onMapsGroup()}
disabled={hasSelectedDefaultMap}
>
<GroupIcon />
</IconButton>
<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>
<>
<SortableTiles renderTile={renderTile} subgroup={subgroup} />
<SortableTilesDragOverlay renderTile={renderTile} subgroup={subgroup} />
</>
);
}

View File

@@ -1,30 +1,29 @@
import React, { useState, useEffect, useRef } from "react";
import React, { useState, useRef } from "react";
import { Image as KonvaImage, Group } from "react-konva";
import { useSpring, animated } from "react-spring/konva";
import useImage from "use-image";
import Konva from "konva";
import useDebounce from "../../hooks/useDebounce";
import usePrevious from "../../hooks/usePrevious";
import useGridSnapping from "../../hooks/useGridSnapping";
import { useAuth } from "../../contexts/AuthContext";
import { useUserId } from "../../contexts/UserIdContext";
import {
useSetPreventMapInteraction,
useMapWidth,
useMapHeight,
useDebouncedStageScale,
} from "../../contexts/MapInteractionContext";
import { useGridCellPixelSize } from "../../contexts/GridContext";
import { useImageSource } from "../../contexts/ImageSourceContext";
import { useDataURL } from "../../contexts/AssetsContext";
import TokenStatus from "../token/TokenStatus";
import TokenLabel from "../token/TokenLabel";
import TokenOutline from "../token/TokenOutline";
import { tokenSources, unknownSource } from "../../tokens";
import { Intersection, getScaledOutline } from "../../helpers/token";
import { tokenSources } from "../../tokens";
function MapToken({
token,
tokenState,
onTokenStateChange,
onTokenMenuOpen,
@@ -34,34 +33,31 @@ function MapToken({
fadeOnHover,
map,
}) {
const { userId } = useAuth();
const userId = useUserId();
const stageScale = useDebouncedStageScale();
const mapWidth = useMapWidth();
const mapHeight = useMapHeight();
const setPreventMapInteraction = useSetPreventMapInteraction();
const gridCellPixelSize = useGridCellPixelSize();
const tokenSource = useImageSource(token, tokenSources, unknownSource);
const [tokenSourceImage, tokenSourceStatus] = useImage(tokenSource);
const [tokenAspectRatio, setTokenAspectRatio] = useState(1);
const tokenURL = useDataURL(tokenState, tokenSources);
const [tokenImage] = useImage(tokenURL);
useEffect(() => {
if (tokenSourceImage) {
setTokenAspectRatio(tokenSourceImage.width / tokenSourceImage.height);
}
}, [tokenSourceImage]);
const tokenAspectRatio = tokenState.width / tokenState.height;
const snapPositionToGrid = useGridSnapping();
function handleDragStart(event) {
const tokenGroup = event.target;
const tokenImage = imageRef.current;
if (token && token.category === "vehicle") {
// Enable hit detection for .intersects() function
Konva.hitOnDragEnabled = true;
if (tokenState.category === "vehicle") {
const tokenIntersection = new Intersection(
getScaledOutline(tokenState, tokenWidth, tokenHeight),
{ x: tokenX - tokenWidth / 2, y: tokenY - tokenHeight / 2 },
{ x: tokenX, y: tokenY },
tokenState.rotation
);
// Find all other tokens on the map
const layer = tokenGroup.getLayer();
@@ -70,12 +66,7 @@ function MapToken({
if (other === tokenGroup) {
continue;
}
const otherRect = other.getClientRect();
const otherCenter = {
x: otherRect.x + otherRect.width / 2,
y: otherRect.y + otherRect.height / 2,
};
if (tokenImage.intersects(otherCenter)) {
if (tokenIntersection.intersects(other.position())) {
// Save and restore token position after moving layer
const position = other.absolutePosition();
other.moveTo(tokenGroup);
@@ -99,9 +90,7 @@ function MapToken({
const tokenGroup = event.target;
const mountChanges = {};
if (token && token.category === "vehicle") {
Konva.hitOnDragEnabled = false;
if (tokenState.category === "vehicle") {
const parent = tokenGroup.getParent();
const mountedTokens = tokenGroup.find(".character");
for (let mountedToken of mountedTokens) {
@@ -185,33 +174,6 @@ function MapToken({
const tokenWidth = minCellSize * tokenState.size;
const tokenHeight = (minCellSize / tokenAspectRatio) * tokenState.size;
const debouncedStageScale = useDebounce(stageScale, 50);
const imageRef = useRef();
useEffect(() => {
const image = imageRef.current;
if (!image) {
return;
}
const canvas = image.getCanvas();
const pixelRatio = canvas.pixelRatio || 1;
if (tokenSourceStatus === "loaded" && tokenWidth > 0 && tokenHeight > 0) {
const maxImageSize = token ? Math.max(token.width, token.height) : 512; // Default to 512px
const maxTokenSize = Math.max(tokenWidth, tokenHeight);
// Constrain image buffer to original image size
const maxRatio = maxImageSize / maxTokenSize;
image.cache({
pixelRatio: Math.min(
Math.max(debouncedStageScale * pixelRatio, 1),
maxRatio
),
});
image.drawHitFromCache();
}
}, [debouncedStageScale, tokenWidth, tokenHeight, tokenSourceStatus, token]);
// Animate to new token positions if edited by others
const tokenX = tokenState.x * mapWidth;
const tokenY = tokenState.y * mapHeight;
@@ -232,8 +194,8 @@ function MapToken({
// Token name is used by on click to find whether a token is a vehicle or prop
let tokenName = "";
if (token) {
tokenName = token.category;
if (tokenState) {
tokenName = tokenState.category;
}
if (tokenState && tokenState.locked) {
tokenName = tokenName + "-locked";
@@ -260,28 +222,46 @@ function MapToken({
name={tokenName}
id={tokenState.id}
>
<KonvaImage
ref={imageRef}
<Group
width={tokenWidth}
height={tokenHeight}
x={0}
y={0}
image={tokenSourceImage}
rotation={tokenState.rotation}
offsetX={tokenWidth / 2}
offsetY={tokenHeight / 2}
>
<TokenOutline
outline={getScaledOutline(tokenState, tokenWidth, tokenHeight)}
hidden={!!tokenImage}
/>
</Group>
<KonvaImage
width={tokenWidth}
height={tokenHeight}
x={0}
y={0}
image={tokenImage}
rotation={tokenState.rotation}
offsetX={tokenWidth / 2}
offsetY={tokenHeight / 2}
hitFunc={() => {}}
/>
<Group offsetX={tokenWidth / 2} offsetY={tokenHeight / 2}>
<TokenStatus
tokenState={tokenState}
width={tokenWidth}
height={tokenHeight}
/>
<TokenLabel
tokenState={tokenState}
width={tokenWidth}
height={tokenHeight}
/>
{tokenState.statuses?.length > 0 ? (
<TokenStatus
tokenState={tokenState}
width={tokenWidth}
height={tokenHeight}
/>
) : null}
{tokenState.label ? (
<TokenLabel
tokenState={tokenState}
width={tokenWidth}
height={tokenHeight}
/>
) : null}
</Group>
</animated.Group>
);

View File

@@ -1,10 +1,8 @@
import React, { useEffect } from "react";
import React from "react";
import { Group } from "react-konva";
import MapToken from "./MapToken";
import { useTokenData } from "../../contexts/TokenDataContext";
function MapTokens({
map,
mapState,
@@ -15,31 +13,6 @@ function MapTokens({
selectedToolId,
disabledTokens,
}) {
const { tokensById, loadTokens } = useTokenData();
// Ensure tokens files have been loaded into the token data
useEffect(() => {
async function loadFileTokens() {
const tokenIds = new Set(
Object.values(mapState.tokens).map((state) => state.tokenId)
);
const tokensToLoad = [];
for (let tokenId of tokenIds) {
const token = tokensById[tokenId];
if (token && token.type === "file" && !token.file) {
tokensToLoad.push(tokenId);
}
}
if (tokensToLoad.length > 0) {
await loadTokens(tokensToLoad);
}
}
if (mapState) {
loadFileTokens();
}
}, [mapState, tokensById, loadTokens]);
function getMapTokenCategoryWeight(category) {
switch (category) {
case "character":
@@ -55,38 +28,28 @@ function MapTokens({
// Sort so vehicles render below other tokens
function sortMapTokenStates(a, b, tokenDraggingOptions) {
const tokenA = tokensById[a.tokenId];
const tokenB = tokensById[b.tokenId];
if (tokenA && tokenB) {
// If categories are different sort in order "prop", "vehicle", "character"
if (tokenB.category !== tokenA.category) {
const aWeight = getMapTokenCategoryWeight(tokenA.category);
const bWeight = getMapTokenCategoryWeight(tokenB.category);
return bWeight - aWeight;
} else if (
tokenDraggingOptions &&
tokenDraggingOptions.dragging &&
tokenDraggingOptions.tokenState.id === a.id
) {
// If dragging token a move above
return 1;
} else if (
tokenDraggingOptions &&
tokenDraggingOptions.dragging &&
tokenDraggingOptions.tokenState.id === b.id
) {
// If dragging token b move above
return -1;
} else {
// Else sort so last modified is on top
return a.lastModified - b.lastModified;
}
} else if (tokenA) {
// If categories are different sort in order "prop", "vehicle", "character"
if (b.category !== a.category) {
const aWeight = getMapTokenCategoryWeight(a.category);
const bWeight = getMapTokenCategoryWeight(b.category);
return bWeight - aWeight;
} else if (
tokenDraggingOptions &&
tokenDraggingOptions.dragging &&
tokenDraggingOptions.tokenState.id === a.id
) {
// If dragging token a move above
return 1;
} else if (tokenB) {
} else if (
tokenDraggingOptions &&
tokenDraggingOptions.dragging &&
tokenDraggingOptions.tokenState.id === b.id
) {
// If dragging token b move above
return -1;
} else {
return 0;
// Else sort so last modified is on top
return a.lastModified - b.lastModified;
}
}
@@ -97,7 +60,6 @@ function MapTokens({
.map((tokenState) => (
<MapToken
key={tokenState.id}
token={tokensById[tokenState.tokenId]}
tokenState={tokenState}
onTokenStateChange={onMapTokenStateChange}
onTokenMenuOpen={handleTokenMenuOpen}

View File

@@ -5,7 +5,7 @@ import SelectMapModal from "../../modals/SelectMapModal";
import SelectMapIcon from "../../icons/SelectMapIcon";
import { useMapData } from "../../contexts/MapDataContext";
import { useAuth } from "../../contexts/AuthContext";
import { useUserId } from "../../contexts/UserIdContext";
function SelectMapButton({
onMapChange,
@@ -17,7 +17,7 @@ function SelectMapButton({
const [isModalOpen, setIsModalOpen] = useState(false);
const { updateMapState } = useMapData();
const { userId } = useAuth();
const userId = useUserId();
function openModal() {
if (currentMapState && currentMap && currentMap.owner === userId) {
updateMapState(currentMapState.mapId, currentMapState);

View File

@@ -0,0 +1,32 @@
import React from "react";
import { Button } from "theme-ui";
import { useGroup } from "../../contexts/GroupContext";
import { findGroup } from "../../helpers/group";
function SelectMapSelectButton({ onMapSelect, disabled }) {
const { activeGroups, selectedGroupIds } = useGroup();
function handleSelectClick() {
if (selectedGroupIds.length === 1) {
const group = findGroup(activeGroups, selectedGroupIds[0]);
if (group && group.type === "item") {
onMapSelect(group.id);
}
}
}
return (
<Button
variant="primary"
disabled={disabled || selectedGroupIds.length > 1}
onClick={handleSelectClick}
mt={2}
>
Select
</Button>
);
}
export default SelectMapSelectButton;

View File

@@ -2,7 +2,7 @@ import React, { useEffect, useState, useRef } from "react";
import { Rect, Text } from "react-konva";
import { useSpring, animated } from "react-spring/konva";
import { useAuth } from "../../contexts/AuthContext";
import { useUserId } from "../../contexts/UserIdContext";
import {
useSetPreventMapInteraction,
useMapWidth,
@@ -15,7 +15,7 @@ import colors from "../../helpers/colors";
import usePrevious from "../../hooks/usePrevious";
import useGridSnapping from "../../hooks/useGridSnapping";
const minTextSize = 16;
const defaultFontSize = 16;
function Note({
note,
@@ -27,7 +27,7 @@ function Note({
onNoteDragEnd,
fadeOnHover,
}) {
const { userId } = useAuth();
const userId = useUserId();
const mapWidth = useMapWidth();
const mapHeight = useMapHeight();
@@ -118,7 +118,7 @@ function Note({
}
}
const [fontSize, setFontSize] = useState(1);
const [fontScale, setFontScale] = useState(1);
useEffect(() => {
const text = textRef.current;
@@ -127,10 +127,10 @@ function Note({
}
function findFontSize() {
// Create an array from 1 / minTextSize of the note height to the full note height
const sizes = Array.from(
// Create an array from 1 / defaultFontSize of the note height to the full note height
let sizes = Array.from(
{ length: Math.ceil(noteHeight - notePadding * 2) },
(_, i) => i + Math.ceil(noteHeight / minTextSize)
(_, i) => i + Math.ceil(noteHeight / defaultFontSize)
);
if (sizes.length > 0) {
@@ -144,8 +144,7 @@ function Note({
return prev;
}
});
setFontSize(size);
setFontScale(size / defaultFontSize);
}
}
@@ -215,11 +214,14 @@ function Note({
}
align="left"
verticalAlign="middle"
padding={notePadding}
fontSize={fontSize}
padding={notePadding / fontScale}
fontSize={defaultFontSize}
// Scale font instead of changing font size to avoid kerning issues with Firefox
scaleX={fontScale}
scaleY={fontScale}
width={noteWidth / fontScale}
height={note.textOnly ? undefined : noteHeight / fontScale}
wrap="word"
width={noteWidth}
height={note.textOnly ? undefined : noteHeight}
/>
{/* Use an invisible text block to work out text sizing */}
<Text visible={false} ref={textRef} text={note.text} wrap="none" />

View File

@@ -1,6 +1,6 @@
import React from "react";
import DragOverlay from "../DragOverlay";
import DragOverlay from "../map/DragOverlay";
function NoteDragOverlay({ onNoteRemove, noteId, noteGroup, dragging }) {
function handleNoteRemove() {

View File

@@ -1,7 +1,8 @@
import React, { useEffect, useState } from "react";
import { Box, Flex, Text, IconButton, Textarea } from "theme-ui";
import { Box, Flex, Text, IconButton } from "theme-ui";
import Slider from "../Slider";
import TextareaAutosize from "../TextareaAutoSize";
import MapMenu from "../map/MapMenu";
@@ -16,7 +17,7 @@ import HideIcon from "../../icons/TokenHideIcon";
import NoteIcon from "../../icons/NoteToolIcon";
import TextIcon from "../../icons/NoteTextIcon";
import { useAuth } from "../../contexts/AuthContext";
import { useUserId } from "../../contexts/UserIdContext";
const defaultNoteMaxSize = 6;
@@ -28,7 +29,7 @@ function NoteMenu({
onNoteChange,
map,
}) {
const { userId } = useAuth();
const userId = useUserId();
const wasOpen = usePrevious(isOpen);
@@ -128,20 +129,12 @@ function NoteMenu({
}}
sx={{ alignItems: "center" }}
>
<Textarea
<TextareaAutosize
id="changeNoteText"
onChange={handleTextChange}
value={(note && note.text) || ""}
sx={{
padding: "4px",
border: "none",
":focus": {
outline: "none",
},
resize: "none",
}}
rows={1}
onKeyPress={handleTextKeyPress}
maxRows={4}
/>
</Flex>
<Box

View File

@@ -0,0 +1,33 @@
import React from "react";
import { Box } from "theme-ui";
import { useInView } from "react-intersection-observer";
function LazyTile({ children }) {
const [ref, inView] = useInView({ triggerOnce: false });
const sx = inView
? {}
: { width: "100%", height: "0", paddingTop: "100%", position: "relative" };
return (
<Box sx={sx} ref={ref}>
{inView ? (
children
) : (
<Box
sx={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
borderRadius: "4px",
}}
bg="background"
/>
)}
</Box>
);
}
export default LazyTile;

View File

@@ -0,0 +1,100 @@
import React from "react";
import { Box } from "theme-ui";
import { useDroppable } from "@dnd-kit/core";
import { useSortable } from "@dnd-kit/sortable";
import { animated, useSpring } from "react-spring";
import { GROUP_ID_PREFIX } from "../../contexts/TileDragContext";
function SortableTile({
id,
disableGrouping,
disableSorting,
hidden,
children,
isDragging,
cursor,
}) {
const {
attributes,
listeners,
setDroppableNodeRef,
setDraggableNodeRef,
over,
active,
} = useSortable({ id });
const { setNodeRef: setGroupNodeRef } = useDroppable({
id: `${GROUP_ID_PREFIX}${id}`,
disabled: disableGrouping,
});
const dragStyle = {
cursor,
opacity: isDragging ? 0.25 : undefined,
};
// Sort div left aligned
const sortDropStyle = {
position: "absolute",
left: "-5px",
top: 0,
width: "2px",
height: "100%",
borderRadius: "2px",
visibility: over?.id === id && !disableSorting ? "visible" : "hidden",
};
// Group div center aligned
const groupDropStyle = {
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
borderWidth: "4px",
borderRadius: "4px",
borderStyle:
over?.id === `${GROUP_ID_PREFIX}${id}` && active.id !== id
? "solid"
: "none",
};
const { opacity } = useSpring({ opacity: hidden ? 0 : 1 });
return (
<animated.div style={{ opacity, position: "relative" }}>
<Box
ref={setDraggableNodeRef}
style={dragStyle}
{...listeners}
{...attributes}
>
{children}
</Box>
<Box
sx={{
width: "100%",
height: 0,
paddingTop: "100%",
pointerEvents: "none",
position: "absolute",
top: 0,
}}
>
<Box ref={setDroppableNodeRef} style={sortDropStyle} bg="primary" />
<Box
ref={setGroupNodeRef}
style={groupDropStyle}
sx={{ borderColor: "primary" }}
/>
</Box>
</animated.div>
);
}
SortableTile.defaultProps = {
cursor: "pointer",
};
export default SortableTile;

View File

@@ -0,0 +1,103 @@
import React from "react";
import { SortableContext } from "@dnd-kit/sortable";
import { moveGroupsInto } from "../../helpers/group";
import { keyBy } from "../../helpers/shared";
import SortableTile from "./SortableTile";
import LazyTile from "./LazyTile";
import {
useTileDragId,
useTileDragCursor,
useTileOverGroupId,
BASE_SORTABLE_ID,
GROUP_SORTABLE_ID,
} from "../../contexts/TileDragContext";
import { useGroup } from "../../contexts/GroupContext";
function SortableTiles({ renderTile, subgroup }) {
const dragId = useTileDragId();
const dragCursor = useTileDragCursor();
const overGroupId = useTileOverGroupId();
const {
groups,
selectedGroupIds: allSelectedIds,
filter,
openGroupId,
openGroupItems,
filteredGroupItems,
} = useGroup();
const activeGroups = subgroup
? openGroupItems
: filter
? filteredGroupItems
: groups;
const sortableId = subgroup ? GROUP_SORTABLE_ID : BASE_SORTABLE_ID;
// Only populate selected groups if needed
let selectedGroupIds = [];
if ((subgroup && openGroupId) || (!subgroup && !openGroupId)) {
selectedGroupIds = allSelectedIds;
}
const disableSorting = (openGroupId && !subgroup) || filter;
const disableGrouping = subgroup || disableSorting || filter;
function renderSortableGroup(group, selectedGroups) {
if (overGroupId === group.id && dragId && group.id !== dragId) {
// If dragging over a group render a preview of that group
const previewGroup = moveGroupsInto(
[group, ...selectedGroups],
0,
selectedGroups.map((_, i) => i + 1)
)[0];
return renderTile(previewGroup);
}
return renderTile(group);
}
function renderTiles() {
const groupsByIds = keyBy(activeGroups, "id");
const selectedGroupIdsSet = new Set(selectedGroupIds);
let selectedGroups = [];
let hasSelectedContainerGroup = false;
for (let groupId of selectedGroupIds) {
const group = groupsByIds[groupId];
if (group) {
selectedGroups.push(group);
if (group.type === "group") {
hasSelectedContainerGroup = true;
}
}
}
return activeGroups.map((group) => {
const isDragging = dragId && selectedGroupIdsSet.has(group.id);
const disableTileGrouping =
disableGrouping || isDragging || hasSelectedContainerGroup;
return (
<LazyTile key={group.id}>
<SortableTile
id={group.id}
disableGrouping={disableTileGrouping}
disableSorting={disableSorting}
hidden={group.id === openGroupId}
isDragging={isDragging}
cursor={dragCursor}
>
{renderSortableGroup(group, selectedGroups)}
</SortableTile>
</LazyTile>
);
});
}
return (
<SortableContext items={activeGroups} id={sortableId}>
{renderTiles()}
</SortableContext>
);
}
export default SortableTiles;

View File

@@ -0,0 +1,93 @@
import React from "react";
import { createPortal } from "react-dom";
import { DragOverlay } from "@dnd-kit/core";
import { animated, useSpring, config } from "react-spring";
import { Badge } from "theme-ui";
import Vector2 from "../../helpers/Vector2";
import { useTileDragId } from "../../contexts/TileDragContext";
import { useGroup } from "../../contexts/GroupContext";
function SortableTilesDragOverlay({ renderTile, subgroup }) {
const dragId = useTileDragId();
const {
groups,
selectedGroupIds: allSelectedIds,
filter,
openGroupId,
openGroupItems,
filteredGroupItems,
} = useGroup();
const activeGroups = subgroup
? openGroupItems
: filter
? filteredGroupItems
: groups;
// Only populate selected groups if needed
let selectedGroupIds = [];
if ((subgroup && openGroupId) || (!subgroup && !openGroupId)) {
selectedGroupIds = allSelectedIds;
}
const dragBounce = useSpring({
transform: !!dragId ? "scale(0.9)" : "scale(1)",
config: config.wobbly,
position: "relative",
});
function renderDragOverlays() {
let selectedIndices = selectedGroupIds.map((groupId) =>
activeGroups.findIndex((group) => group.id === groupId)
);
const activeIndex = activeGroups.findIndex((group) => group.id === dragId);
// Sort so the draging tile is the first element
selectedIndices = selectedIndices.sort((a, b) =>
a === activeIndex ? -1 : b === activeIndex ? 1 : 0
);
selectedIndices = selectedIndices.slice(0, 5);
let coords = selectedIndices.map(
(_, index) => new Vector2(5 * index, 5 * index)
);
// Reverse so the first element is rendered on top
selectedIndices = selectedIndices.reverse();
coords = coords.reverse();
const selectedGroups = selectedIndices.map((index) => activeGroups[index]);
return selectedGroups.map((group, index) => (
<DragOverlay dropAnimation={null} key={group.id}>
<div
style={{
transform: `translate(${coords[index].x}%, ${coords[index].y}%)`,
}}
>
<animated.div style={dragBounce}>
{renderTile(group)}
{index === selectedIndices.length - 1 &&
selectedGroupIds.length > 1 && (
<Badge
sx={{
position: "absolute",
top: 0,
right: 0,
transform: "translate(25%, -25%)",
}}
>
{selectedGroupIds.length}
</Badge>
)}
</animated.div>
</div>
</DragOverlay>
));
}
return createPortal(dragId && renderDragOverlays(), document.body);
}
export default SortableTilesDragOverlay;

View File

@@ -1,74 +1,49 @@
import React from "react";
import { Flex, Image as UIImage, IconButton, Box, Text, Badge } from "theme-ui";
import { Flex, IconButton, Box, Text, Badge } from "theme-ui";
import EditTileIcon from "../icons/EditTileIcon";
import EditTileIcon from "../../icons/EditTileIcon";
function Tile({
src,
title,
isSelected,
onSelect,
onEdit,
onDoubleClick,
size,
canEdit,
badges,
editTitle,
children,
}) {
let width;
let margin;
switch (size) {
case "small":
width = "24%";
margin = "0.5%";
break;
case "medium":
width = "32%";
margin = `${2 / 3}%`;
break;
case "large":
width = "48%";
margin = "1%";
break;
default:
width = "32%";
margin = `${2 / 3}%`;
}
return (
<Flex
<Box
sx={{
position: "relative",
width: width,
width: "100%",
height: "0",
paddingTop: width,
paddingTop: "100%",
borderRadius: "4px",
justifyContent: "center",
alignItems: "center",
cursor: "pointer",
overflow: "hidden",
userSelect: "none",
}}
my={1}
mx={margin}
bg="muted"
bg="background"
onClick={(e) => {
e.stopPropagation();
onSelect();
}}
onDoubleClick={onDoubleClick}
aria-label={title}
>
<UIImage
<Box
sx={{
width: "100%",
height: "100%",
objectFit: "contain",
position: "absolute",
top: 0,
left: 0,
}}
src={src}
alt={title}
/>
>
{children}
</Box>
<Flex
sx={{
position: "absolute",
@@ -106,13 +81,25 @@ function Tile({
borderRadius: "4px",
}}
/>
<Box sx={{ position: "absolute", top: 0, left: 0 }}>
<Flex
sx={{
position: "absolute",
top: "6px",
left: "6px",
}}
>
{badges.map((badge, i) => (
<Badge m={2} key={i} bg="overlay">
<Badge
m="2px"
key={i}
bg="overlay"
color="text"
sx={{ width: "fit-content" }}
>
{badge}
</Badge>
))}
</Box>
</Flex>
{canEdit && (
<Box sx={{ position: "absolute", top: 0, right: 0 }}>
<IconButton
@@ -131,12 +118,11 @@ function Tile({
</IconButton>
</Box>
)}
</Flex>
</Box>
);
}
Tile.defaultProps = {
src: "",
title: "",
isSelected: false,
onSelect: () => {},
@@ -146,6 +132,7 @@ Tile.defaultProps = {
canEdit: false,
badges: [],
editTitle: "Edit",
columns: "1fr",
};
export default Tile;

View File

@@ -1,22 +1,24 @@
import React from "react";
import { Flex, IconButton } from "theme-ui";
import AddIcon from "../icons/AddIcon";
import SelectMultipleIcon from "../icons/SelectMultipleIcon";
import SelectSingleIcon from "../icons/SelectSingleIcon";
import AddIcon from "../../icons/AddIcon";
import SelectMultipleIcon from "../../icons/SelectMultipleIcon";
import SelectSingleIcon from "../../icons/SelectSingleIcon";
import Search from "./Search";
import RadioIconButton from "./RadioIconButton";
import Search from "../Search";
import RadioIconButton from "../RadioIconButton";
import { useGroup } from "../../contexts/GroupContext";
function TileActionBar({ onAdd, addTitle }) {
const {
selectMode,
onSelectModeChange,
onGroupSelect,
filter,
onFilterChange,
} = useGroup();
function FilterBar({
onFocus,
search,
onSearchChange,
selectMode,
onSelectModeChange,
onAdd,
addTitle,
}) {
return (
<Flex
bg="muted"
@@ -31,9 +33,9 @@ function FilterBar({
outlineOffset: "0px",
},
}}
onFocus={onFocus}
onFocus={() => onGroupSelect()}
>
<Search value={search} onChange={onSearchChange} />
<Search value={filter} onChange={(e) => onFilterChange(e.target.value)} />
<Flex
mr={1}
px={1}
@@ -66,4 +68,4 @@ function FilterBar({
);
}
export default FilterBar;
export default TileActionBar;

View File

@@ -0,0 +1,57 @@
import React from "react";
import { Grid, useThemeUI } from "theme-ui";
import SimpleBar from "simplebar-react";
import { useGroup } from "../../contexts/GroupContext";
import { ADD_TO_MAP_ID } from "../../contexts/TileDragContext";
import useResponsiveLayout from "../../hooks/useResponsiveLayout";
import Droppable from "../drag/Droppable";
function TilesContainer({ children }) {
const { onGroupSelect } = useGroup();
const { theme } = useThemeUI();
const layout = useResponsiveLayout();
return (
<>
<SimpleBar
style={{
height: layout.tileContainerHeight,
backgroundColor: theme.colors.muted,
}}
onClick={() => onGroupSelect()}
>
<Grid
p={3}
pb={4}
sx={{
borderRadius: "4px",
overflow: "hidden",
position: "relative",
}}
gap={2}
columns={`repeat(${layout.tileGridColumns}, 1fr)`}
>
<Droppable
id={ADD_TO_MAP_ID}
style={{
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
zIndex: -1,
}}
/>
{children}
</Grid>
</SimpleBar>
</>
);
}
export default TilesContainer;

View File

@@ -0,0 +1,190 @@
import React, { useState } from "react";
import { Box, Close, Grid, useThemeUI, IconButton, Text, Flex } from "theme-ui";
import { useSpring, animated, config } from "react-spring";
import ReactResizeDetector from "react-resize-detector";
import SimpleBar from "simplebar-react";
import { useGroup } from "../../contexts/GroupContext";
import { UNGROUP_ID, ADD_TO_MAP_ID } from "../../contexts/TileDragContext";
import useResponsiveLayout from "../../hooks/useResponsiveLayout";
import ChangeNicknameIcon from "../../icons/ChangeNicknameIcon";
import GroupNameModal from "../../modals/GroupNameModal";
import { renameGroup } from "../../helpers/group";
import Droppable from "../drag/Droppable";
function TilesOverlay({ modalSize, children }) {
const {
groups,
openGroupId,
onGroupClose,
onGroupSelect,
onGroupsChange,
} = useGroup();
const { theme } = useThemeUI();
const layout = useResponsiveLayout();
const openAnimation = useSpring({
opacity: openGroupId ? 1 : 0,
transform: openGroupId ? "scale(1)" : "scale(0.99)",
config: config.gentle,
});
const [containerSize, setContinerSize] = useState({ width: 0, height: 0 });
function handleContainerResize(width, height) {
const size = Math.min(width, height) - 16;
setContinerSize({ width: size, height: size });
}
const [isGroupNameModalOpen, setIsGroupNameModalOpen] = useState(false);
function handleGroupNameChange(name) {
onGroupsChange(renameGroup(groups, openGroupId, name));
setIsGroupNameModalOpen(false);
}
const group = groups.find((group) => group.id === openGroupId);
if (!openGroupId) {
return null;
}
return (
<>
<Box
sx={{
position: "absolute",
width: "100%",
height: "100%",
top: 0,
}}
bg="overlay"
/>
<ReactResizeDetector
handleWidth
handleHeight
onResize={handleContainerResize}
>
<animated.div
style={{
...openAnimation,
position: "absolute",
width: "100%",
height: "100%",
top: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
pointerEvents: openGroupId ? undefined : "none",
}}
onClick={() => openGroupId && onGroupClose()}
>
<Box
sx={{
width: containerSize.width,
height: containerSize.height,
borderRadius: "8px",
border: "1px solid",
borderColor: "border",
cursor: "default",
display: "flex",
justifyContent: "flex-start",
alignItems: "center",
position: "relative",
flexDirection: "column",
}}
bg="background"
onClick={(e) => e.stopPropagation()}
>
<Flex my={1} sx={{ position: "relative" }}>
<Text as="p" my="2px">
{group?.name}
</Text>
<IconButton
sx={{
width: "24px",
height: "24px",
position: group?.name ? "absolute" : "relative",
left: group?.name ? "100%" : 0,
}}
title="Edit Group"
aria-label="Edit Group"
onClick={() => setIsGroupNameModalOpen(true)}
>
<ChangeNicknameIcon />
</IconButton>
</Flex>
<SimpleBar
style={{
width: containerSize.width - 16,
height: containerSize.height - 48,
marginBottom: "8px",
backgroundColor: theme.colors.muted,
}}
onClick={() => onGroupSelect()}
>
<Grid
sx={{
borderRadius: "4px",
overflow: "hidden",
position: "relative",
}}
gap={2}
columns={`repeat(${layout.groupGridColumns}, 1fr)`}
p={3}
>
<Droppable
id={ADD_TO_MAP_ID}
style={{
position: "absolute",
width: modalSize.width,
height: `calc(100% + ${
modalSize.height - containerSize.height + 48
}px)`,
left: `-${
(modalSize.width - containerSize.width) / 2 + 8
}px`,
top: `-${
(modalSize.height - containerSize.height) / 2 + 48
}px`,
zIndex: -1,
}}
/>
<Droppable
id={UNGROUP_ID}
style={{
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
zIndex: -1,
}}
/>
{children}
</Grid>
</SimpleBar>
<Close
onClick={() => onGroupClose()}
sx={{ position: "absolute", top: 0, right: 0 }}
/>
</Box>
</animated.div>
</ReactResizeDetector>
<GroupNameModal
isOpen={isGroupNameModalOpen}
name={group?.name}
onSubmit={handleGroupNameChange}
onRequestClose={() => setIsGroupNameModalOpen(false)}
/>
</>
);
}
export default TilesOverlay;

View File

@@ -1,44 +0,0 @@
import React, { useRef } from "react";
import { Box, Image } from "theme-ui";
import usePreventTouch from "../../hooks/usePreventTouch";
import { useImageSource } from "../../contexts/ImageSourceContext";
import { tokenSources, unknownSource } from "../../tokens";
function ListToken({ token, className }) {
const tokenSource = useImageSource(
token,
tokenSources,
unknownSource,
token.type === "file"
);
const imageRef = useRef();
// Stop touch to prevent 3d touch gesutre on iOS
usePreventTouch(imageRef);
return (
<Box my={2} mx={3} sx={{ width: "48px", height: "48px" }}>
<Image
src={tokenSource}
ref={imageRef}
className={className}
sx={{
userSelect: "none",
touchAction: "none",
width: "100%",
height: "100%",
objectFit: "cover",
}}
// pass id into the dom element which is then used by the ProxyToken
data-id={token.id}
alt={token.name}
title={token.name}
/>
</Box>
);
}
export default ListToken;

View File

@@ -1,172 +0,0 @@
import React, { useEffect, useRef, useState } from "react";
import ReactDOM from "react-dom";
import { Image, Box } from "theme-ui";
import interact from "interactjs";
import usePortal from "../../hooks/usePortal";
import { useMapStage } from "../../contexts/MapStageContext";
/**
* @callback onProxyDragEnd
* @param {boolean} isOnMap whether the token was dropped on the map
* @param {Object} token the token that was dropped
*/
/**
*
* @param {string} tokenClassName The class name to attach the interactjs handler to
* @param {onProxyDragEnd} onProxyDragEnd Called when the proxy token is dropped
* @param {Object} tokens An optional mapping of tokens to use as a base when calling OnProxyDragEnd
*/
function ProxyToken({ tokenClassName, onProxyDragEnd, tokens }) {
const proxyContainer = usePortal("root");
const [imageSource, setImageSource] = useState("");
const proxyRef = useRef();
// Store the tokens in a ref and access in the interactjs loop
// This is needed to stop interactjs from creating multiple listeners
const tokensRef = useRef(tokens);
useEffect(() => {
tokensRef.current = tokens;
}, [tokens]);
const proxyOnMap = useRef(false);
const mapStageRef = useMapStage();
useEffect(() => {
interact(`.${tokenClassName}`).draggable({
listeners: {
start: (event) => {
let target = event.target;
// Hide the token and copy it's image to the proxy
target.parentElement.style.opacity = "0.25";
setImageSource(target.src);
let proxy = proxyRef.current;
if (proxy) {
// Find and set the initial offset of the token to the proxy
const proxyRect = proxy.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
const xOffset = targetRect.left - proxyRect.left;
const yOffset = targetRect.top - proxyRect.top;
proxy.style.transform = `translate(${xOffset}px, ${yOffset}px)`;
proxy.setAttribute("data-x", xOffset);
proxy.setAttribute("data-y", yOffset);
// Copy width and height of target
proxy.style.width = `${targetRect.width}px`;
proxy.style.height = `${targetRect.height}px`;
}
},
move: (event) => {
let proxy = proxyRef.current;
// Move the proxy based off of the movment of the token
if (proxy) {
// keep the dragged position in the data-x/data-y attributes
const x =
(parseFloat(proxy.getAttribute("data-x")) || 0) + event.dx;
const y =
(parseFloat(proxy.getAttribute("data-y")) || 0) + event.dy;
proxy.style.transform = `translate(${x}px, ${y}px)`;
// Check whether the proxy is on the right or left hand side of the screen
// if not set proxyOnMap to true
const proxyRect = proxy.getBoundingClientRect();
const map = document.querySelector(".map");
const mapRect = map.getBoundingClientRect();
proxyOnMap.current =
proxyRect.left > mapRect.left && proxyRect.right < mapRect.right;
// update the posiion attributes
proxy.setAttribute("data-x", x);
proxy.setAttribute("data-y", y);
}
},
end: (event) => {
let target = event.target;
const id = target.dataset.id;
let proxy = proxyRef.current;
if (proxy) {
const mapStage = mapStageRef.current;
if (onProxyDragEnd && mapStage) {
const mapImage = mapStage.findOne("#mapImage");
const map = document.querySelector(".map");
const mapRect = map.getBoundingClientRect();
const position = {
x: event.clientX - mapRect.left,
y: event.clientY - mapRect.top,
};
const transform = mapImage.getAbsoluteTransform().copy().invert();
const relativePosition = transform.point(position);
const normalizedPosition = {
x: relativePosition.x / mapImage.width(),
y: relativePosition.y / mapImage.height(),
};
// Get the token from the supplied tokens if it exists
const token = tokensRef.current[id] || {};
onProxyDragEnd(proxyOnMap.current, {
...token,
x: normalizedPosition.x,
y: normalizedPosition.y,
});
}
// Reset the proxy position
proxy.style.transform = "translate(0px, 0px)";
proxy.setAttribute("data-x", 0);
proxy.setAttribute("data-y", 0);
}
// Show the token
target.parentElement.style.opacity = "1";
setImageSource("");
},
},
});
}, [onProxyDragEnd, tokenClassName, proxyContainer, mapStageRef]);
if (!imageSource) {
return null;
}
// Create a portal to allow the proxy to move past the bounds of the token
return ReactDOM.createPortal(
<Box
sx={{
position: "absolute",
overflow: "hidden",
top: 0,
left: 0,
bottom: 0,
right: 0,
}}
>
<Box
sx={{ position: "absolute", display: "flex", flexDirection: "column" }}
ref={proxyRef}
>
<Image
src={imageSource}
sx={{
touchAction: "none",
userSelect: "none",
width: "100%",
}}
/>
</Box>
</Box>,
proxyContainer
);
}
ProxyToken.defaultProps = {
tokens: {},
};
export default ProxyToken;

View File

@@ -5,7 +5,7 @@ import SelectTokensIcon from "../../icons/SelectTokensIcon";
import SelectTokensModal from "../../modals/SelectTokensModal";
function SelectTokensButton() {
function SelectTokensButton({ onMapTokensStateCreate }) {
const [isModalOpen, setIsModalOpen] = useState(false);
function openModal() {
setIsModalOpen(true);
@@ -30,6 +30,7 @@ function SelectTokensButton() {
isOpen={isModalOpen}
onRequestClose={closeModal}
onDone={handleDone}
onMapTokensStateCreate={onMapTokensStateCreate}
/>
</>
);

View File

@@ -0,0 +1,200 @@
import React, { useState } from "react";
import { createPortal } from "react-dom";
import { Box, Flex, Grid } from "theme-ui";
import SimpleBar from "simplebar-react";
import {
DragOverlay,
MouseSensor,
TouchSensor,
KeyboardSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import TokenBarToken from "./TokenBarToken";
import TokenBarTokenGroup from "./TokenBarTokenGroup";
import SelectTokensButton from "./SelectTokensButton";
import Draggable from "../drag/Draggable";
import useSetting from "../../hooks/useSetting";
import usePreventSelect from "../../hooks/usePreventSelect";
import { useTokenData } from "../../contexts/TokenDataContext";
import { useUserId } from "../../contexts/UserIdContext";
import { useMapStage } from "../../contexts/MapStageContext";
import DragContext from "../../contexts/DragContext";
import {
createTokenState,
clientPositionToMapPosition,
} from "../../helpers/token";
import { findGroup } from "../../helpers/group";
import Vector2 from "../../helpers/Vector2";
function TokenBar({ onMapTokensStateCreate }) {
const userId = useUserId();
const { tokensById, tokenGroups } = useTokenData();
const [fullScreen] = useSetting("map.fullScreen");
const [dragId, setDragId] = useState();
const mapStageRef = useMapStage();
const mouseSensor = useSensor(MouseSensor, {
activationConstraint: { distance: 5 },
});
const touchSensor = useSensor(TouchSensor, {
activationConstraint: { distance: 5 },
});
const keyboardSensor = useSensor(KeyboardSensor);
const sensors = useSensors(mouseSensor, touchSensor, keyboardSensor);
const [preventSelect, resumeSelect] = usePreventSelect();
function handleDragStart({ active }) {
setDragId(active.id);
preventSelect();
}
function handleDragEnd({ active, overlayNodeClientRect }) {
setDragId(null);
const mapStage = mapStageRef.current;
if (mapStage && overlayNodeClientRect) {
const dragRect = overlayNodeClientRect;
const dragPosition = {
x: dragRect.left + dragRect.width / 2,
y: dragRect.top + dragRect.height / 2,
};
const mapPosition = clientPositionToMapPosition(mapStage, dragPosition);
const group = findGroup(tokenGroups, active.id);
if (group && mapPosition) {
if (group.type === "item") {
const token = tokensById[group.id];
const tokenState = createTokenState(token, mapPosition, userId);
onMapTokensStateCreate([tokenState]);
} else {
let tokenStates = [];
let offset = new Vector2(0, 0);
for (let item of group.items) {
const token = tokensById[item.id];
if (token && !token.hideInSidebar) {
tokenStates.push(
createTokenState(
token,
Vector2.add(mapPosition, offset),
userId
)
);
offset = Vector2.add(offset, 0.01);
}
}
if (tokenStates.length > 0) {
onMapTokensStateCreate(tokenStates);
}
}
}
}
resumeSelect();
}
function handleDragCancel() {
setDragId(null);
resumeSelect();
}
function renderToken(group, draggable = true) {
if (group.type === "item") {
const token = tokensById[group.id];
if (token && !token.hideInSidebar) {
if (draggable) {
return (
<Draggable id={token.id} key={token.id}>
<TokenBarToken token={token} />
</Draggable>
);
} else {
return <TokenBarToken token={token} key={token.id} />;
}
}
} else {
const groupTokens = [];
for (let item of group.items) {
const token = tokensById[item.id];
if (token && !token.hideInSidebar) {
groupTokens.push(token);
}
}
if (groupTokens.length > 0) {
return (
<TokenBarTokenGroup
group={group}
tokens={groupTokens}
key={group.id}
draggable={draggable}
/>
);
}
}
}
return (
<DragContext
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
autoScroll={false}
sensors={sensors}
>
<Box
sx={{
height: "100%",
width: "80px",
minWidth: "80px",
overflowY: "hidden",
overflowX: "hidden",
display: fullScreen ? "none" : "block",
}}
>
<SimpleBar
style={{
height: "calc(100% - 48px)",
overflowX: "hidden",
padding: "0 16px",
}}
>
<Grid
columns="1fr"
gap={2}
py={2}
// Prevent selection on 3D touch for iOS
onTouchStart={preventSelect}
onTouchEnd={resumeSelect}
>
{tokenGroups.map((group) => renderToken(group))}
</Grid>
</SimpleBar>
<Flex
bg="muted"
sx={{
justifyContent: "center",
height: "48px",
alignItems: "center",
}}
>
<SelectTokensButton onMapTokensStateCreate={onMapTokensStateCreate} />
</Flex>
{createPortal(
<DragOverlay dropAnimation={null}>
{dragId && renderToken(findGroup(tokenGroups, dragId), false)}
</DragOverlay>,
document.body
)}
</Box>
</DragContext>
);
}
export default TokenBar;

View File

@@ -0,0 +1,31 @@
import React from "react";
import { Box } from "theme-ui";
import { useInView } from "react-intersection-observer";
import TokenImage from "./TokenImage";
function TokenBarToken({ token }) {
const [ref, inView] = useInView({ triggerOnce: true });
return (
<Box ref={ref} sx={{ width: "48px", height: "48px" }} title={token.name}>
{inView && (
<TokenImage
token={token}
sx={{
userSelect: "none",
touchAction: "none",
width: "100%",
height: "100%",
objectFit: "cover",
pointerEvents: "none",
}}
alt={token.name}
title={token.name}
/>
)}
</Box>
);
}
export default TokenBarToken;

View File

@@ -0,0 +1,135 @@
import React, { useState, useRef } from "react";
import { Grid, Flex, Box } from "theme-ui";
import { useSpring, animated } from "react-spring";
import { useDraggable } from "@dnd-kit/core";
import TokenImage from "./TokenImage";
import TokenBarToken from "./TokenBarToken";
import Draggable from "../drag/Draggable";
import Vector2 from "../../helpers/Vector2";
import GroupIcon from "../../icons/GroupIcon";
function TokenBarTokenGroup({ group, tokens, draggable }) {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: draggable && group.id,
disabled: !draggable,
});
const [isOpen, setIsOpen] = useState(false);
const { height } = useSpring({
height: isOpen ? (tokens.length + 1) * 56 : 56,
});
function renderToken(token) {
if (draggable) {
return (
<Draggable id={token.id} key={token.id}>
<TokenBarToken token={token} />
</Draggable>
);
} else {
return <TokenBarToken token={token} key={token.id} />;
}
}
function renderTokens() {
if (isOpen) {
return (
<Grid
columns="1fr"
bg="muted"
sx={{ borderRadius: "8px" }}
p={0}
gap={2}
>
<Flex
sx={{
width: "48px",
height: "48px",
alignItems: "center",
justifyContent: "center",
cursor: isDragging ? "grabbing" : "pointer",
color: "primary",
}}
onClick={(e) => handleOpenClick(e, false)}
key="group"
title={group.name}
{...listeners}
{...attributes}
>
<GroupIcon />
</Flex>
{tokens.map(renderToken)}
</Grid>
);
} else {
return (
<Grid
columns="1fr 1fr"
bg="muted"
sx={{
borderRadius: "8px",
gridGap: "4px",
height: "48px",
gridTemplateRows: "1fr 1fr",
}}
p="2px"
alt={group.name}
title={group.name}
{...listeners}
{...attributes}
>
{tokens.slice(0, 4).map((token) => (
<TokenImage
token={token}
key={token.id}
sx={{
userSelect: "none",
touchAction: "none",
pointerEvents: "none",
}}
/>
))}
</Grid>
);
}
}
// Reject the opening of a group if the pointer has moved
const clickDownPositionRef = useRef(new Vector2(0, 0));
function handleOpenDown(event) {
clickDownPositionRef.current = new Vector2(event.clientX, event.clientY);
}
function handleOpenClick(event, newOpen) {
const clickPosition = new Vector2(event.clientX, event.clientY);
const distance = Vector2.distance(
clickPosition,
clickDownPositionRef.current
);
if (distance < 5) {
setIsOpen(newOpen);
}
}
return (
<Box ref={setNodeRef}>
<animated.div
style={{
padding: "4px 0",
width: "48px",
height,
cursor: isOpen ? "default" : isDragging ? "grabbing" : "pointer",
}}
onPointerDown={handleOpenDown}
onClick={(e) => !isOpen && handleOpenClick(e, true)}
>
{renderTokens()}
</animated.div>
</Box>
);
}
export default TokenBarTokenGroup;

View File

@@ -1,12 +1,12 @@
import React from "react";
import { useAuth } from "../../contexts/AuthContext";
import { useUserId } from "../../contexts/UserIdContext";
import {
useMapWidth,
useMapHeight,
} from "../../contexts/MapInteractionContext";
import DragOverlay from "../DragOverlay";
import DragOverlay from "../map/DragOverlay";
function TokenDragOverlay({
onTokenStateRemove,
@@ -16,7 +16,7 @@ function TokenDragOverlay({
tokenGroup,
dragging,
}) {
const { userId } = useAuth();
const userId = useUserId();
const mapWidth = useMapWidth();
const mapHeight = useMapHeight();

View File

@@ -0,0 +1,134 @@
import React, { useState, useEffect } from "react";
import { Flex, Close, IconButton } from "theme-ui";
import { groupsFromIds, itemsFromGroups } from "../../helpers/group";
import ConfirmModal from "../../modals/ConfirmModal";
import TokenShowIcon from "../../icons/TokenShowIcon";
import TokenHideIcon from "../../icons/TokenHideIcon";
import RemoveTokenIcon from "../../icons/RemoveTokenIcon";
import { useGroup } from "../../contexts/GroupContext";
import { useTokenData } from "../../contexts/TokenDataContext";
import { useKeyboard } from "../../contexts/KeyboardContext";
import shortcuts from "../../shortcuts";
function TokenEditBar({ disabled, onLoad }) {
const { tokens, removeTokens, updateTokensHidden } = useTokenData();
const { activeGroups, selectedGroupIds, onGroupSelect } = useGroup();
const [allTokensVisible, setAllTokensVisisble] = useState(false);
useEffect(() => {
const selectedGroups = groupsFromIds(selectedGroupIds, activeGroups);
const selectedTokens = itemsFromGroups(selectedGroups, tokens);
setAllTokensVisisble(selectedTokens.every((token) => !token.hideInSidebar));
}, [selectedGroupIds, tokens, activeGroups]);
function getSelectedTokens() {
const selectedGroups = groupsFromIds(selectedGroupIds, activeGroups);
return itemsFromGroups(selectedGroups, tokens);
}
const [isTokensRemoveModalOpen, setIsTokensRemoveModalOpen] = useState(false);
async function handleTokensRemove() {
onLoad(true);
setIsTokensRemoveModalOpen(false);
const selectedTokens = getSelectedTokens();
const selectedTokenIds = selectedTokens.map((token) => token.id);
onGroupSelect();
await removeTokens(selectedTokenIds);
onLoad(false);
}
async function handleTokensHide(hideInSidebar) {
const selectedTokens = getSelectedTokens();
const selectedTokenIds = selectedTokens.map((token) => token.id);
// Show loading indicator if hiding more than 10 tokens
if (selectedTokenIds.length > 10) {
onLoad(true);
await updateTokensHidden(selectedTokenIds, hideInSidebar);
onLoad(false);
} else {
updateTokensHidden(selectedTokenIds, hideInSidebar);
}
}
/**
* Shortcuts
*/
function handleKeyDown(event) {
if (disabled) {
return;
}
if (shortcuts.delete(event)) {
const selectedTokens = getSelectedTokens();
if (selectedTokens.length > 0) {
// Ensure all other modals are closed
setIsTokensRemoveModalOpen(true);
}
}
}
useKeyboard(handleKeyDown);
if (selectedGroupIds.length === 0) {
return null;
}
let hideTitle = "";
if (allTokensVisible) {
hideTitle = "Hide Selected Token(s) in Sidebar";
} else {
hideTitle = "Show Selected Token(s) in Sidebar";
}
return (
<Flex
sx={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
justifyContent: "space-between",
}}
bg="overlay"
>
<Close
title="Clear Selection"
aria-label="Clear Selection"
onClick={() => onGroupSelect()}
/>
<Flex>
<IconButton
aria-label={hideTitle}
title={hideTitle}
onClick={() => handleTokensHide(allTokensVisible)}
>
{allTokensVisible ? <TokenShowIcon /> : <TokenHideIcon />}
</IconButton>
<IconButton
aria-label="Remove Selected Token(s)"
title="Remove Selected Token(s)"
onClick={() => setIsTokensRemoveModalOpen(true)}
>
<RemoveTokenIcon />
</IconButton>
</Flex>
<ConfirmModal
isOpen={isTokensRemoveModalOpen}
onRequestClose={() => setIsTokensRemoveModalOpen(false)}
onConfirm={handleTokensRemove}
confirmText="Remove"
label="Remove Selected Token(s)"
description="This operation cannot be undone."
/>
</Flex>
);
}
export default TokenEditBar;

View File

@@ -0,0 +1,21 @@
import React from "react";
import { Flex } from "theme-ui";
import TokenShowIcon from "../../icons/TokenShowIcon";
import TokenHideIcon from "../../icons/TokenHideIcon";
function TokenHiddenBadge({ hidden }) {
return (
<Flex
sx={{
height: "15px",
width: "15px",
alignItems: "center",
}}
>
{hidden ? <TokenHideIcon /> : <TokenShowIcon />}
</Flex>
);
}
export default TokenHiddenBadge;

View File

@@ -0,0 +1,46 @@
import React, { useState } from "react";
import { Image, Box } from "theme-ui";
import { useDataURL } from "../../contexts/AssetsContext";
import { tokenSources as defaultTokenSources } from "../../tokens";
import { TokenOutlineSVG } from "./TokenOutline";
const TokenImage = React.forwardRef(({ token, ...props }, ref) => {
const tokenURL = useDataURL(
token,
defaultTokenSources,
undefined,
token.type === "file"
);
const [showOutline, setShowOutline] = useState(true);
return (
<>
{showOutline && (
<Box
title={props.alt}
aria-label={props.alt}
sx={{ width: "100%", height: "100%", minHeight: 0 }}
>
<TokenOutlineSVG
outline={token.outline}
width={token.width}
height={token.height}
/>
</Box>
)}
<Image
onLoad={() => setShowOutline(false)}
src={tokenURL}
ref={ref}
style={showOutline ? { display: "none" } : props.style}
{...props}
/>
</>
);
});
export default TokenImage;

View File

@@ -4,6 +4,7 @@ import { Rect, Text, Group } from "react-konva";
import useSetting from "../../hooks/useSetting";
const maxTokenSize = 3;
const defaultFontSize = 16;
function TokenLabel({ tokenState, width, height }) {
const [labelSize] = useSetting("map.labelSize");
@@ -13,7 +14,7 @@ function TokenLabel({ tokenState, width, height }) {
const paddingX =
(height / 8 / tokenState.size) * Math.min(tokenState.size, maxTokenSize);
const [fontSize, setFontSize] = useState(1);
const [fontScale, setFontScale] = useState(0);
useEffect(() => {
const text = textSizerRef.current;
@@ -22,15 +23,14 @@ function TokenLabel({ tokenState, width, height }) {
}
let fontSizes = [];
for (let size = 10 * labelSize; size >= 6; size--) {
fontSizes.push(
(height / size / tokenState.size) *
Math.min(tokenState.size, maxTokenSize) *
labelSize
);
for (let size = 20 * labelSize; size >= 6; size--) {
const verticalSize = height / size / tokenState.size;
const tokenSize = Math.min(tokenState.size, maxTokenSize);
const fontSize = verticalSize * tokenSize * labelSize;
fontSizes.push(fontSize);
}
function findFontSize() {
function findFontScale() {
const size = fontSizes.reduce((prev, curr) => {
text.fontSize(curr);
const textWidth = text.getTextWidth() + paddingX * 2;
@@ -39,12 +39,12 @@ function TokenLabel({ tokenState, width, height }) {
} else {
return prev;
}
});
}, 1);
setFontSize(size);
setFontScale(size / defaultFontSize);
}
findFontSize();
findFontScale();
}, [
tokenState.label,
tokenState.visible,
@@ -56,44 +56,47 @@ function TokenLabel({ tokenState, width, height }) {
]);
const [rectWidth, setRectWidth] = useState(0);
const [textWidth, setTextWidth] = useState(0);
useEffect(() => {
const text = textRef.current;
if (text && tokenState.label) {
setRectWidth(text.getTextWidth() + paddingX * 2);
setRectWidth(text.getTextWidth() * fontScale + paddingX * 2);
setTextWidth(text.getTextWidth() * fontScale);
} else {
setRectWidth(0);
setTextWidth(0);
}
}, [tokenState.label, paddingX, width, fontSize]);
}, [tokenState.label, paddingX, width, fontScale]);
const textRef = useRef();
const textSizerRef = useRef();
return (
<Group y={height - (fontSize + paddingY) / 2}>
<Group y={height - (defaultFontSize * fontScale + paddingY) / 2}>
<Rect
y={-paddingY / 2}
width={rectWidth}
offsetX={width / 2}
x={width - rectWidth / 2}
height={fontSize + paddingY}
height={defaultFontSize * fontScale + paddingY}
fill="hsla(230, 25%, 18%, 0.8)"
cornerRadius={(fontSize + paddingY) / 2}
/>
<Text
ref={textRef}
width={width}
text={tokenState.label}
fontSize={fontSize}
lineHeight={1}
align="center"
verticalAlign="bottom"
fill="white"
paddingX={paddingX}
paddingY={paddingY}
wrap="none"
ellipsis={false}
hitFunc={() => {}}
cornerRadius={(defaultFontSize * fontScale + paddingY) / 2}
/>
<Group offsetX={(textWidth - width) / 2}>
<Text
ref={textRef}
text={tokenState.label}
fontSize={defaultFontSize}
lineHeight={1}
// Scale font instead of changing font size to avoid kerning issues with Firefox
scaleX={fontScale}
scaleY={fontScale}
fill="white"
wrap="none"
ellipsis={false}
hitFunc={() => {}}
/>
</Group>
{/* Use an invisible text block to work out text sizing */}
<Text
visible={false}

View File

@@ -14,7 +14,7 @@ import UnlockIcon from "../../icons/TokenUnlockIcon";
import ShowIcon from "../../icons/TokenShowIcon";
import HideIcon from "../../icons/TokenHideIcon";
import { useAuth } from "../../contexts/AuthContext";
import { useUserId } from "../../contexts/UserIdContext";
const defaultTokenMaxSize = 6;
function TokenMenu({
@@ -25,7 +25,7 @@ function TokenMenu({
onTokenStateChange,
map,
}) {
const { userId } = useAuth();
const userId = useUserId();
const wasOpen = usePrevious(isOpen);
@@ -50,7 +50,7 @@ function TokenMenu({
}, [isOpen, tokenState, wasOpen, tokenImage]);
function handleLabelChange(event) {
const label = event.target.value.substring(0, 144);
const label = event.target.value.substring(0, 48);
tokenState && onTokenStateChange({ [tokenState.id]: { label: label } });
}

View File

@@ -0,0 +1,94 @@
import React from "react";
import { Rect, Circle, Line } from "react-konva";
import colors from "../../helpers/colors";
export function TokenOutlineSVG({ outline, width, height }) {
if (outline.type === "rect") {
return (
<svg
width="100%"
height="100%"
xmlns="http://www.w3.org/2000/svg"
fill="rgba(0, 0, 0, 0.3)"
viewBox={`0, 0, ${width} ${height}`}
preserveAspectRatio="xMidYMid slice"
>
<rect
x={outline.x}
y={outline.y}
width={outline.width}
height={outline.height}
/>
</svg>
);
} else if (outline.type === "circle") {
return (
<svg
width="100%"
height="100%"
xmlns="http://www.w3.org/2000/svg"
fill="rgba(0, 0, 0, 0.3)"
viewBox={`0, 0, ${width} ${height}`}
preserveAspectRatio="xMidYMid slice"
>
<circle r={outline.radius} cx={outline.x} cy={outline.y} />
</svg>
);
} else {
let points = [];
for (let i = 0; i < outline.points.length; i += 2) {
points.push(`${outline.points[i]}, ${outline.points[i + 1]}`);
}
return (
<svg
width="100%"
height="100%"
xmlns="http://www.w3.org/2000/svg"
fill="rgba(0, 0, 0, 0.3)"
viewBox={`0, 0, ${width} ${height}`}
preserveAspectRatio="xMidYMid slice"
>
<polygon points={points.join(" ")} />
</svg>
);
}
}
function TokenOutline({ outline, hidden }) {
const sharedProps = {
fill: colors.black,
opacity: hidden ? 0 : 0.8,
};
if (outline.type === "rect") {
return (
<Rect
width={outline.width}
height={outline.height}
x={outline.x}
y={outline.y}
{...sharedProps}
/>
);
} else if (outline.type === "circle") {
return (
<Circle
radius={outline.radius}
x={outline.x}
y={outline.y}
{...sharedProps}
/>
);
} else {
return (
<Line
points={outline.points}
closed
tension={outline.points < 200 ? 0 : 0.33}
{...sharedProps}
/>
);
}
}
export default TokenOutline;

View File

@@ -10,12 +10,12 @@ import useImageCenter from "../../hooks/useImageCenter";
import useResponsiveLayout from "../../hooks/useResponsiveLayout";
import { GridProvider } from "../../contexts/GridContext";
import { useImageSource } from "../../contexts/ImageSourceContext";
import { useDataURL } from "../../contexts/AssetsContext";
import GridOnIcon from "../../icons/GridOnIcon";
import GridOffIcon from "../../icons/GridOffIcon";
import { tokenSources, unknownSource } from "../../tokens";
import { tokenSources } from "../../tokens";
import Grid from "../Grid";
@@ -27,12 +27,8 @@ function TokenPreview({ token }) {
}
}, [token, tokenSourceData]);
const tokenSource = useImageSource(
tokenSourceData,
tokenSources,
unknownSource
);
const [tokenSourceImage] = useImage(tokenSource);
const tokenURL = useDataURL(tokenSourceData, tokenSources);
const [tokenSourceImage] = useImage(tokenURL);
const [stageWidth, setStageWidth] = useState(1);
const [stageHeight, setStageHeight] = useState(1);

View File

@@ -21,39 +21,49 @@ function TokenSettings({ token, onSettingsChange }) {
name="name"
value={(token && token.name) || ""}
onChange={(e) => onSettingsChange("name", e.target.value)}
disabled={tokenEmpty || token.type === "default"}
disabled={tokenEmpty}
my={1}
/>
</Box>
<Box mt={2}>
<Label mb={1}>Category</Label>
<Label>Default Category</Label>
<Select
options={categorySettings}
value={
!tokenEmpty &&
categorySettings.find((s) => s.value === token.category)
categorySettings.find((s) => s.value === token.defaultCategory)
}
isDisabled={tokenEmpty}
onChange={(option) =>
onSettingsChange("defaultCategory", option.value)
}
isDisabled={tokenEmpty || token.type === "default"}
onChange={(option) => onSettingsChange("category", option.value)}
isSearchable={false}
/>
</Box>
<Flex>
<Box my={2} sx={{ flexGrow: 1 }}>
<Label htmlFor="tokenSize">Default Size</Label>
<Input
type="number"
name="tokenSize"
value={`${(token && token.defaultSize) || 0}`}
onChange={(e) =>
onSettingsChange("defaultSize", parseFloat(e.target.value))
}
disabled={tokenEmpty || token.type === "default"}
min={1}
my={1}
/>
</Box>
</Flex>
<Box mt={2} sx={{ flexGrow: 1 }}>
<Label htmlFor="tokenSize">Default Size</Label>
<Input
type="number"
name="tokenSize"
value={`${(token && token.defaultSize) || 0}`}
onChange={(e) =>
onSettingsChange("defaultSize", parseFloat(e.target.value))
}
disabled={tokenEmpty}
min={1}
my={1}
/>
</Box>
<Box my={2} mb={3} sx={{ flexGrow: 1 }}>
<Label htmlFor="label">Default Label</Label>
<Input
name="label"
value={(token && token.defaultLabel) || ""}
onChange={(e) => onSettingsChange("defaultLabel", e.target.value)}
disabled={tokenEmpty}
my={1}
/>
</Box>
</Flex>
);
}

View File

@@ -1,42 +1,28 @@
import React from "react";
import Tile from "../Tile";
import { useImageSource } from "../../contexts/ImageSourceContext";
import {
tokenSources as defaultTokenSources,
unknownSource,
} from "../../tokens";
import Tile from "../tile/Tile";
import TokenImage from "./TokenImage";
function TokenTile({
token,
isSelected,
onTokenSelect,
onSelect,
onTokenEdit,
size,
canEdit,
badges,
}) {
const tokenSource = useImageSource(
token,
defaultTokenSources,
unknownSource,
token.type === "file"
);
return (
<Tile
src={tokenSource}
title={token.name}
isSelected={isSelected}
onSelect={() => onTokenSelect(token)}
onSelect={() => onSelect(token.id)}
onEdit={() => onTokenEdit(token.id)}
size={size}
canEdit={canEdit}
badges={badges}
editTitle="Edit Token"
/>
>
<TokenImage token={token} />
</Tile>
);
}

View File

@@ -0,0 +1,48 @@
import React from "react";
import { Grid } from "theme-ui";
import Tile from "../tile/Tile";
import TokenImage from "./TokenImage";
import useResponsiveLayout from "../../hooks/useResponsiveLayout";
function TokenTileGroup({
group,
tokens,
isSelected,
onSelect,
onDoubleClick,
}) {
const layout = useResponsiveLayout();
return (
<Tile
title={group.name}
isSelected={isSelected}
onSelect={() => onSelect(group.id)}
onDoubleClick={onDoubleClick}
>
<Grid
columns={`repeat(${layout.groupGridColumns}, 1fr)`}
p={2}
gap={2}
sx={{
height: "100%",
gridTemplateRows: `repeat(${layout.groupGridColumns}, 1fr)`,
}}
>
{tokens
.slice(0, layout.groupGridColumns * layout.groupGridColumns)
.map((token) => (
<TokenImage
sx={{ borderRadius: "8px" }}
token={token}
key={`${token.id}-group-tile`}
/>
))}
</Grid>
</Tile>
);
}
export default TokenTileGroup;

View File

@@ -1,183 +1,72 @@
import React from "react";
import { Flex, Box, Text, IconButton, Close, Label } from "theme-ui";
import SimpleBar from "simplebar-react";
import Case from "case";
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 TokenTileGroup from "./TokenTileGroup";
import TokenHiddenBadge from "./TokenHiddenBadge";
import { useDatabase } from "../../contexts/DatabaseContext";
import SortableTiles from "../tile/SortableTiles";
import SortableTilesDragOverlay from "../tile/SortableTilesDragOverlay";
import useResponsiveLayout from "../../hooks/useResponsiveLayout";
import { getGroupItems } from "../../helpers/group";
function TokenTiles({
tokens,
groups,
onTokenAdd,
onTokenEdit,
onTokenSelect,
selectedTokens,
onTokensRemove,
selectMode,
onSelectModeChange,
search,
onSearchChange,
onTokensGroup,
onTokensHide,
}) {
const { databaseStatus } = useDatabase();
const layout = useResponsiveLayout();
import { useGroup } from "../../contexts/GroupContext";
let hasSelectedDefaultToken = selectedTokens.some(
(token) => token.type === "default"
);
let allTokensVisible = selectedTokens.every((token) => !token.hideInSidebar);
function TokenTiles({ tokensById, onTokenEdit, subgroup }) {
const {
selectedGroupIds,
selectMode,
onGroupOpen,
onGroupSelect,
} = useGroup();
function tokenToTile(token) {
const isSelected = selectedTokens.includes(token);
return (
<TokenTile
key={token.id}
token={token}
isSelected={isSelected}
onTokenSelect={onTokenSelect}
onTokenEdit={onTokenEdit}
size={layout.tileSize}
canEdit={
function renderTile(group) {
if (group.type === "item") {
const token = tokensById[group.id];
if (token) {
const isSelected = selectedGroupIds.includes(group.id);
const canEdit =
isSelected &&
token.type !== "default" &&
selectMode === "single" &&
selectedTokens.length === 1
}
badges={[`${token.defaultSize}x`]}
/>
);
}
selectedGroupIds.length === 1;
const multipleSelected = selectedTokens.length > 1;
let hideTitle = "";
if (multipleSelected) {
if (allTokensVisible) {
hideTitle = "Hide Tokens in Sidebar";
return (
<TokenTile
key={token.id}
token={token}
isSelected={isSelected}
onSelect={onGroupSelect}
onTokenEdit={onTokenEdit}
canEdit={canEdit}
badges={[
`${token.defaultSize}x`,
<TokenHiddenBadge hidden={token.hideInSidebar} />,
]}
/>
);
}
} else {
hideTitle = "Show Tokens in Sidebar";
}
} else {
if (allTokensVisible) {
hideTitle = "Hide Token in Sidebar";
} else {
hideTitle = "Show Token in Sidebar";
const isSelected = selectedGroupIds.includes(group.id);
const items = getGroupItems(group);
const canOpen =
isSelected && selectMode === "single" && selectedGroupIds.length === 1;
return (
<TokenTileGroup
key={group.id}
group={group}
tokens={items.map((item) => tokensById[item.id])}
isSelected={isSelected}
onSelect={onGroupSelect}
onDoubleClick={() => canOpen && onGroupOpen(group.id)}
/>
);
}
}
return (
<Box sx={{ position: "relative" }}>
<FilterBar
onFocus={() => onTokenSelect()}
search={search}
onSearchChange={onSearchChange}
selectMode={selectMode}
onSelectModeChange={onSelectModeChange}
onAdd={onTokenAdd}
addTitle="Add Token"
/>
<SimpleBar
style={{ height: layout.screenSize === "large" ? "600px" : "400px" }}
>
<Flex
p={2}
pb={4}
pt={databaseStatus === "disabled" ? 4 : 2}
bg="muted"
sx={{
flexWrap: "wrap",
borderRadius: "4px",
minHeight: layout.screenSize === "large" ? "600px" : "400px",
alignContent: "flex-start",
}}
onClick={() => onTokenSelect()}
>
{groups.map((group) => (
<React.Fragment key={group}>
<Label mx={1} mt={2}>
{Case.capital(group)}
</Label>
{tokens[group].map(tokenToTile)}
</React.Fragment>
))}
</Flex>
</SimpleBar>
{databaseStatus === "disabled" && (
<Box
sx={{
position: "absolute",
top: "39px",
left: 0,
right: 0,
textAlign: "center",
borderRadius: "2px",
}}
bg="highlight"
p={1}
>
<Text as="p" variant="body2">
Token saving is unavailable. See <Link to="/faq#saving">FAQ</Link>{" "}
for more information.
</Text>
</Box>
)}
{selectedTokens.length > 0 && (
<Flex
sx={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
justifyContent: "space-between",
}}
bg="overlay"
>
<Close
title="Clear Selection"
aria-label="Clear Selection"
onClick={() => onTokenSelect()}
/>
<Flex>
<IconButton
aria-label={hideTitle}
title={hideTitle}
disabled={hasSelectedDefaultToken}
onClick={() => onTokensHide(allTokensVisible)}
>
{allTokensVisible ? <TokenShowIcon /> : <TokenHideIcon />}
</IconButton>
<IconButton
aria-label={multipleSelected ? "Group Tokens" : "Group Token"}
title={multipleSelected ? "Group Tokens" : "Group Token"}
onClick={() => onTokensGroup()}
disabled={hasSelectedDefaultToken}
>
<GroupIcon />
</IconButton>
<IconButton
aria-label={multipleSelected ? "Remove Tokens" : "Remove Token"}
title={multipleSelected ? "Remove Tokens" : "Remove Token"}
onClick={() => onTokensRemove()}
disabled={hasSelectedDefaultToken}
>
<RemoveTokenIcon />
</IconButton>
</Flex>
</Flex>
)}
</Box>
<>
<SortableTiles renderTile={renderTile} subgroup={subgroup} />
<SortableTilesDragOverlay renderTile={renderTile} subgroup={subgroup} />
</>
);
}

View File

@@ -1,94 +0,0 @@
import React from "react";
import { Box, Flex } from "theme-ui";
import shortid from "shortid";
import SimpleBar from "simplebar-react";
import ListToken from "./ListToken";
import ProxyToken from "./ProxyToken";
import SelectTokensButton from "./SelectTokensButton";
import { fromEntries } from "../../helpers/shared";
import useSetting from "../../hooks/useSetting";
import { useAuth } from "../../contexts/AuthContext";
import { useTokenData } from "../../contexts/TokenDataContext";
const listTokenClassName = "list-token";
function Tokens({ onMapTokenStateCreate }) {
const { userId } = useAuth();
const { ownedTokens, tokens, updateToken } = useTokenData();
const [fullScreen] = useSetting("map.fullScreen");
function handleProxyDragEnd(isOnMap, token) {
if (isOnMap && onMapTokenStateCreate) {
// Create a token state from the dragged token
onMapTokenStateCreate({
id: shortid.generate(),
tokenId: token.id,
owner: userId,
size: token.defaultSize,
label: "",
statuses: [],
x: token.x,
y: token.y,
lastModifiedBy: userId,
lastModified: Date.now(),
rotation: 0,
locked: false,
visible: true,
});
// Update last used for cache invalidation
// Keep last modified the same
updateToken(token.id, {
lastUsed: Date.now(),
lastModified: token.lastModified,
});
}
}
return (
<>
<Box
sx={{
height: "100%",
width: "80px",
minWidth: "80px",
overflow: "hidden",
display: fullScreen ? "none" : "block",
}}
>
<SimpleBar style={{ height: "calc(100% - 48px)", overflowX: "hidden" }}>
{ownedTokens
.filter((token) => !token.hideInSidebar)
.map((token) => (
<ListToken
key={token.id}
token={token}
className={listTokenClassName}
/>
))}
</SimpleBar>
<Flex
bg="muted"
sx={{
justifyContent: "center",
height: "48px",
alignItems: "center",
}}
>
<SelectTokensButton />
</Flex>
</Box>
<ProxyToken
tokenClassName={listTokenClassName}
onProxyDragEnd={handleProxyDragEnd}
tokens={fromEntries(tokens.map((token) => [token.id, token]))}
/>
</>
);
}
export default Tokens;