Merge branch 'master' into typescript
This commit is contained in:
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
22
src/components/TextareaAutoSize.css
Normal file
22
src/components/TextareaAutoSize.css
Normal 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;
|
||||
}
|
||||
8
src/components/TextareaAutoSize.js
Normal file
8
src/components/TextareaAutoSize.js
Normal 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;
|
||||
80
src/components/UpgradingLoadingOverlay.js
Normal file
80
src/components/UpgradingLoadingOverlay.js
Normal 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 bear’s 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
23
src/components/drag/Draggable.js
Normal file
23
src/components/drag/Draggable.js
Normal 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;
|
||||
18
src/components/drag/Droppable.js
Normal file
18
src/components/drag/Droppable.js
Normal 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;
|
||||
248
src/components/image/GlobalImageDrop.js
Normal file
248
src/components/image/GlobalImageDrop.js
Normal 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;
|
||||
37
src/components/image/ImageDrop.js
Normal file
37
src/components/image/ImageDrop.js
Normal 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;
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -119,8 +119,7 @@ function MapDrawing({
|
||||
}
|
||||
const simplified = simplifyPoints(
|
||||
[...prevPoints, brushPosition],
|
||||
gridCellNormalizedSize,
|
||||
stageScale
|
||||
1 / 1000 / stageScale
|
||||
);
|
||||
return {
|
||||
...prevShape,
|
||||
|
||||
159
src/components/map/MapEditBar.js
Normal file
159
src/components/map/MapEditBar.js
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
18
src/components/map/MapImage.js
Normal file
18
src/components/map/MapImage.js
Normal 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;
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
function MapTest() {}
|
||||
|
||||
export default MapTest;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
41
src/components/map/MapTileGroup.js
Normal file
41
src/components/map/MapTileGroup.js
Normal 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;
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
32
src/components/map/SelectMapSelectButton.js
Normal file
32
src/components/map/SelectMapSelectButton.js
Normal 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;
|
||||
@@ -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" />
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
33
src/components/tile/LazyTile.js
Normal file
33
src/components/tile/LazyTile.js
Normal 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;
|
||||
100
src/components/tile/SortableTile.js
Normal file
100
src/components/tile/SortableTile.js
Normal 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;
|
||||
103
src/components/tile/SortableTiles.js
Normal file
103
src/components/tile/SortableTiles.js
Normal 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;
|
||||
93
src/components/tile/SortableTilesDragOverlay.js
Normal file
93
src/components/tile/SortableTilesDragOverlay.js
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
57
src/components/tile/TilesContainer.js
Normal file
57
src/components/tile/TilesContainer.js
Normal 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;
|
||||
190
src/components/tile/TilesOverlay.js
Normal file
190
src/components/tile/TilesOverlay.js
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
200
src/components/token/TokenBar.js
Normal file
200
src/components/token/TokenBar.js
Normal 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;
|
||||
31
src/components/token/TokenBarToken.js
Normal file
31
src/components/token/TokenBarToken.js
Normal 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;
|
||||
135
src/components/token/TokenBarTokenGroup.js
Normal file
135
src/components/token/TokenBarTokenGroup.js
Normal 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;
|
||||
@@ -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();
|
||||
|
||||
134
src/components/token/TokenEditBar.js
Normal file
134
src/components/token/TokenEditBar.js
Normal 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;
|
||||
21
src/components/token/TokenHiddenBadge.js
Normal file
21
src/components/token/TokenHiddenBadge.js
Normal 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;
|
||||
46
src/components/token/TokenImage.js
Normal file
46
src/components/token/TokenImage.js
Normal 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;
|
||||
@@ -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}
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
|
||||
|
||||
94
src/components/token/TokenOutline.js
Normal file
94
src/components/token/TokenOutline.js
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
48
src/components/token/TokenTileGroup.js
Normal file
48
src/components/token/TokenTileGroup.js
Normal 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;
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user