commit
718f07c3bf
@ -6,4 +6,4 @@ REACT_APP_VERSION=$npm_package_version
|
||||
REACT_APP_PREVIEW=false
|
||||
REACT_APP_LOGGING=true
|
||||
REACT_APP_FATHOM_SITE_ID=VMSHBPKD
|
||||
REACT_APP_SENTRY_DSN=https://5257021c3a114649baa5e3b8ba775bfe@o467475.ingest.sentry.io/5493956
|
||||
REACT_APP_SENTRY_DSN=https://d6d22c5233b54c4d91df8fa29d5ffeb0@o467475.ingest.sentry.io/5493956
|
||||
|
14
package.json
14
package.json
@ -1,13 +1,16 @@
|
||||
{
|
||||
"name": "owlbear-rodeo",
|
||||
"version": "1.8.1",
|
||||
"version": "1.9.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@babylonjs/core": "^4.2.0",
|
||||
"@babylonjs/loaders": "^4.2.0",
|
||||
"@dnd-kit/core": "^3.0.4",
|
||||
"@dnd-kit/sortable": "^3.1.0",
|
||||
"@mitchemmc/dexie-export-import": "^1.0.1",
|
||||
"@msgpack/msgpack": "^2.4.1",
|
||||
"@sentry/react": "^6.2.2",
|
||||
"@sentry/integrations": "^6.3.0",
|
||||
"@sentry/react": "^6.3.0",
|
||||
"@stripe/stripe-js": "^1.13.1",
|
||||
"@tensorflow/tfjs": "^3.3.0",
|
||||
"@testing-library/jest-dom": "^5.11.9",
|
||||
@ -24,11 +27,13 @@
|
||||
"fake-indexeddb": "^3.1.2",
|
||||
"file-saver": "^2.0.5",
|
||||
"fuse.js": "^6.4.6",
|
||||
"interactjs": "^1.10.8",
|
||||
"image-outline": "^0.1.0",
|
||||
"intersection-observer": "^0.12.0",
|
||||
"konva": "^7.2.5",
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"lodash.get": "^4.4.2",
|
||||
"lodash.set": "^4.3.2",
|
||||
"lodash.unset": "^4.5.2",
|
||||
"normalize-wheel": "^1.0.1",
|
||||
"pepjs": "^0.5.3",
|
||||
"polygon-clipping": "^0.15.2",
|
||||
@ -36,6 +41,7 @@
|
||||
"raw.macro": "^0.4.2",
|
||||
"react": "^17.0.1",
|
||||
"react-dom": "^17.0.1",
|
||||
"react-intersection-observer": "^8.32.0",
|
||||
"react-konva": "^17.0.1-3",
|
||||
"react-markdown": "4",
|
||||
"react-media": "^2.0.0-rc.1",
|
||||
@ -46,6 +52,7 @@
|
||||
"react-scripts": "^4.0.3",
|
||||
"react-select": "^4.2.1",
|
||||
"react-spring": "^8.0.27",
|
||||
"react-textarea-autosize": "^8.3.3",
|
||||
"react-toast-notifications": "^2.4.3",
|
||||
"react-use-gesture": "^9.1.3",
|
||||
"shortid": "^2.2.15",
|
||||
@ -57,6 +64,7 @@
|
||||
"source-map-explorer": "^2.5.2",
|
||||
"theme-ui": "^0.3.1",
|
||||
"use-image": "^1.0.7",
|
||||
"uuid": "^8.3.2",
|
||||
"webrtc-adapter": "^7.7.1"
|
||||
},
|
||||
"resolutions": {
|
||||
|
89
src/App.js
89
src/App.js
@ -12,63 +12,54 @@ import HowTo from "./routes/HowTo";
|
||||
import Donate from "./routes/Donate";
|
||||
|
||||
import { AuthProvider } from "./contexts/AuthContext";
|
||||
import { DatabaseProvider } from "./contexts/DatabaseContext";
|
||||
import { MapDataProvider } from "./contexts/MapDataContext";
|
||||
import { TokenDataProvider } from "./contexts/TokenDataContext";
|
||||
import { MapLoadingProvider } from "./contexts/MapLoadingContext";
|
||||
import { SettingsProvider } from "./contexts/SettingsContext";
|
||||
import { KeyboardProvider } from "./contexts/KeyboardContext";
|
||||
import { ImageSourcesProvider } from "./contexts/ImageSourceContext";
|
||||
import { DatabaseProvider } from "./contexts/DatabaseContext";
|
||||
import { UserIdProvider } from "./contexts/UserIdContext";
|
||||
|
||||
import { ToastProvider } from "./components/Toast";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<DatabaseProvider>
|
||||
<SettingsProvider>
|
||||
<AuthProvider>
|
||||
<KeyboardProvider>
|
||||
<ToastProvider>
|
||||
<ImageSourcesProvider>
|
||||
<Router>
|
||||
<Switch>
|
||||
<Route path="/donate">
|
||||
<Donate />
|
||||
</Route>
|
||||
{/* Legacy support camel case routes */}
|
||||
<Route path={["/howTo", "/how-to"]}>
|
||||
<HowTo />
|
||||
</Route>
|
||||
<Route path={["/releaseNotes", "/release-notes"]}>
|
||||
<ReleaseNotes />
|
||||
</Route>
|
||||
<Route path="/about">
|
||||
<About />
|
||||
</Route>
|
||||
<Route path="/faq">
|
||||
<FAQ />
|
||||
</Route>
|
||||
<Route path="/game/:id">
|
||||
<MapLoadingProvider>
|
||||
<MapDataProvider>
|
||||
<TokenDataProvider>
|
||||
<Game />
|
||||
</TokenDataProvider>
|
||||
</MapDataProvider>
|
||||
</MapLoadingProvider>
|
||||
</Route>
|
||||
<Route path="/">
|
||||
<Home />
|
||||
</Route>
|
||||
</Switch>
|
||||
</Router>
|
||||
</ImageSourcesProvider>
|
||||
</ToastProvider>
|
||||
</KeyboardProvider>
|
||||
</AuthProvider>
|
||||
</SettingsProvider>
|
||||
</DatabaseProvider>
|
||||
<SettingsProvider>
|
||||
<AuthProvider>
|
||||
<KeyboardProvider>
|
||||
<ToastProvider>
|
||||
<Router>
|
||||
<Switch>
|
||||
<Route path="/donate">
|
||||
<Donate />
|
||||
</Route>
|
||||
{/* Legacy support camel case routes */}
|
||||
<Route path={["/howTo", "/how-to"]}>
|
||||
<HowTo />
|
||||
</Route>
|
||||
<Route path={["/releaseNotes", "/release-notes"]}>
|
||||
<ReleaseNotes />
|
||||
</Route>
|
||||
<Route path="/about">
|
||||
<About />
|
||||
</Route>
|
||||
<Route path="/faq">
|
||||
<FAQ />
|
||||
</Route>
|
||||
<Route path="/game/:id">
|
||||
<DatabaseProvider>
|
||||
<UserIdProvider>
|
||||
<Game />
|
||||
</UserIdProvider>
|
||||
</DatabaseProvider>
|
||||
</Route>
|
||||
<Route path="/">
|
||||
<Home />
|
||||
</Route>
|
||||
</Switch>
|
||||
</Router>
|
||||
</ToastProvider>
|
||||
</KeyboardProvider>
|
||||
</AuthProvider>
|
||||
</SettingsProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
@ -4,27 +4,25 @@ import Action from "./Action";
|
||||
import {
|
||||
addPolygonDifferenceToShapes,
|
||||
addPolygonIntersectionToShapes,
|
||||
shapeToGeometry,
|
||||
} from "../helpers/actions";
|
||||
|
||||
class CutShapeAction extends Action {
|
||||
constructor(shapes) {
|
||||
super();
|
||||
this.update = (shapesById) => {
|
||||
const actionGeom = shapes.map((actionShape) => [
|
||||
actionShape.data.points.map(({ x, y }) => [x, y]),
|
||||
]);
|
||||
let actionGeom = shapes.map(shapeToGeometry);
|
||||
let cutShapes = {};
|
||||
for (let shape of Object.values(shapesById)) {
|
||||
const shapePoints = shape.data.points.map(({ x, y }) => [x, y]);
|
||||
const shapeHoles = shape.data.holes.map((hole) =>
|
||||
hole.map(({ x, y }) => [x, y])
|
||||
);
|
||||
let shapeGeom = [[shapePoints, ...shapeHoles]];
|
||||
const shapeGeom = shapeToGeometry(shape);
|
||||
try {
|
||||
const difference = polygonClipping.difference(shapeGeom, actionGeom);
|
||||
const difference = polygonClipping.difference(
|
||||
shapeGeom,
|
||||
...actionGeom
|
||||
);
|
||||
const intersection = polygonClipping.intersection(
|
||||
shapeGeom,
|
||||
actionGeom
|
||||
...actionGeom
|
||||
);
|
||||
addPolygonDifferenceToShapes(shape, difference, cutShapes);
|
||||
addPolygonIntersectionToShapes(shape, intersection, cutShapes);
|
||||
|
@ -1,24 +1,24 @@
|
||||
import polygonClipping from "polygon-clipping";
|
||||
|
||||
import Action from "./Action";
|
||||
import { addPolygonDifferenceToShapes } from "../helpers/actions";
|
||||
import {
|
||||
addPolygonDifferenceToShapes,
|
||||
shapeToGeometry,
|
||||
} from "../helpers/actions";
|
||||
|
||||
class SubtractShapeAction extends Action {
|
||||
constructor(shapes) {
|
||||
super();
|
||||
this.update = (shapesById) => {
|
||||
const actionGeom = shapes.map((actionShape) => [
|
||||
actionShape.data.points.map(({ x, y }) => [x, y]),
|
||||
]);
|
||||
const actionGeom = shapes.map(shapeToGeometry);
|
||||
let subtractedShapes = {};
|
||||
for (let shape of Object.values(shapesById)) {
|
||||
const shapePoints = shape.data.points.map(({ x, y }) => [x, y]);
|
||||
const shapeHoles = shape.data.holes.map((hole) =>
|
||||
hole.map(({ x, y }) => [x, y])
|
||||
);
|
||||
let shapeGeom = [[shapePoints, ...shapeHoles]];
|
||||
const shapeGeom = shapeToGeometry(shape);
|
||||
try {
|
||||
const difference = polygonClipping.difference(shapeGeom, actionGeom);
|
||||
const difference = polygonClipping.difference(
|
||||
shapeGeom,
|
||||
...actionGeom
|
||||
);
|
||||
addPolygonDifferenceToShapes(shape, difference, subtractedShapes);
|
||||
} catch {
|
||||
console.error("Unable to find difference for shapes");
|
||||
|
@ -4,43 +4,6 @@ import EditShapeAction from "./EditShapeAction";
|
||||
import RemoveShapeAction from "./RemoveShapeAction";
|
||||
import SubtractShapeAction from "./SubtractShapeAction";
|
||||
|
||||
/**
|
||||
* Convert from the previous representation of actions (1.7.0) to the new representation (1.8.0)
|
||||
* and combine into shapes
|
||||
* @param {Array} actions
|
||||
* @param {number} actionIndex
|
||||
*/
|
||||
export function convertOldActionsToShapes(actions, actionIndex) {
|
||||
let newShapes = {};
|
||||
for (let i = 0; i <= actionIndex; i++) {
|
||||
const action = actions[i];
|
||||
if (!action) {
|
||||
continue;
|
||||
}
|
||||
let newAction;
|
||||
if (action.shapes) {
|
||||
if (action.type === "add") {
|
||||
newAction = new AddShapeAction(action.shapes);
|
||||
} else if (action.type === "edit") {
|
||||
newAction = new EditShapeAction(action.shapes);
|
||||
} else if (action.type === "remove") {
|
||||
newAction = new RemoveShapeAction(action.shapes);
|
||||
} else if (action.type === "subtract") {
|
||||
newAction = new SubtractShapeAction(action.shapes);
|
||||
} else if (action.type === "cut") {
|
||||
newAction = new CutShapeAction(action.shapes);
|
||||
}
|
||||
} else if (action.type === "remove" && action.shapeIds) {
|
||||
newAction = new RemoveShapeAction(action.shapeIds);
|
||||
}
|
||||
|
||||
if (newAction) {
|
||||
newShapes = newAction.execute(newShapes);
|
||||
}
|
||||
}
|
||||
return newShapes;
|
||||
}
|
||||
|
||||
export {
|
||||
AddShapeAction,
|
||||
CutShapeAction,
|
||||
|
@ -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;
|
@ -3,7 +3,7 @@ import { Box } from "theme-ui";
|
||||
|
||||
import Spinner from "./Spinner";
|
||||
|
||||
function LoadingOverlay({ bg }) {
|
||||
function LoadingOverlay({ bg, children }) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
@ -21,6 +21,7 @@ function LoadingOverlay({ bg }) {
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -2,6 +2,8 @@ import React from "react";
|
||||
import Modal from "react-modal";
|
||||
import { useThemeUI, Close } from "theme-ui";
|
||||
|
||||
import { useSpring, animated, config } from "react-spring";
|
||||
|
||||
function StyledModal({
|
||||
isOpen,
|
||||
onRequestClose,
|
||||
@ -12,24 +14,51 @@ function StyledModal({
|
||||
}) {
|
||||
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",
|
||||
},
|
||||
content: {
|
||||
backgroundColor: theme.colors.background,
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
right: "auto",
|
||||
bottom: "auto",
|
||||
marginRight: "-50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
top: "initial",
|
||||
left: "initial",
|
||||
bottom: "initial",
|
||||
right: "initial",
|
||||
maxHeight: "100%",
|
||||
...style,
|
||||
},
|
||||
}}
|
||||
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}
|
||||
|
@ -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";
|
||||
@ -48,6 +50,8 @@ function Map({
|
||||
disabledTokens,
|
||||
session,
|
||||
}) {
|
||||
const { addToast } = useToasts();
|
||||
|
||||
const { tokensById } = useTokenData();
|
||||
|
||||
const [selectedToolId, setSelectedToolId] = useState("move");
|
||||
@ -231,6 +235,7 @@ function Map({
|
||||
onShapesCut={handleFogShapesCut}
|
||||
onShapesRemove={handleFogShapesRemove}
|
||||
onShapesEdit={handleFogShapesEdit}
|
||||
onShapeError={addToast}
|
||||
active={selectedToolId === "fog"}
|
||||
toolSettings={settings.fog}
|
||||
editable={allowFogDrawing && !settings.fog.preview}
|
||||
@ -336,30 +341,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) {
|
||||
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;
|
364
src/contexts/AssetsContext.js
Normal file
364
src/contexts/AssetsContext.js
Normal file
@ -0,0 +1,364 @@
|
||||
import React, { useState, useContext, useCallback, useEffect } from "react";
|
||||
import * as Comlink from "comlink";
|
||||
import { encode } from "@msgpack/msgpack";
|
||||
|
||||
import { useDatabase } from "./DatabaseContext";
|
||||
|
||||
import useDebounce from "../hooks/useDebounce";
|
||||
|
||||
import { omit } from "../helpers/shared";
|
||||
|
||||
/**
|
||||
* @typedef Asset
|
||||
* @property {string} id
|
||||
* @property {number} width
|
||||
* @property {number} height
|
||||
* @property {Uint8Array} file
|
||||
* @property {string} mime
|
||||
* @property {string} owner
|
||||
*/
|
||||
|
||||
/**
|
||||
* @callback getAsset
|
||||
* @param {string} assetId
|
||||
* @returns {Promise<Asset|undefined>}
|
||||
*/
|
||||
|
||||
/**
|
||||
* @callback addAssets
|
||||
* @param {Asset[]} assets
|
||||
*/
|
||||
|
||||
/**
|
||||
* @callback putAsset
|
||||
* @param {Asset} asset
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef AssetsContext
|
||||
* @property {getAsset} getAsset
|
||||
* @property {addAssets} addAssets
|
||||
* @property {putAsset} putAsset
|
||||
*/
|
||||
|
||||
/**
|
||||
* @type {React.Context<undefined|AssetsContext>}
|
||||
*/
|
||||
const AssetsContext = React.createContext();
|
||||
|
||||
// 100 MB max cache size
|
||||
const maxCacheSize = 1e8;
|
||||
|
||||
export function AssetsProvider({ children }) {
|
||||
const { worker, database, databaseStatus } = useDatabase();
|
||||
|
||||
useEffect(() => {
|
||||
if (databaseStatus === "loaded") {
|
||||
worker.cleanAssetCache(maxCacheSize);
|
||||
}
|
||||
}, [worker, databaseStatus]);
|
||||
|
||||
const getAsset = useCallback(
|
||||
async (assetId) => {
|
||||
return await database.table("assets").get(assetId);
|
||||
},
|
||||
[database]
|
||||
);
|
||||
|
||||
const addAssets = useCallback(
|
||||
async (assets) => {
|
||||
await database.table("assets").bulkAdd(assets);
|
||||
},
|
||||
[database]
|
||||
);
|
||||
|
||||
const putAsset = useCallback(
|
||||
async (asset) => {
|
||||
// Attempt to use worker to put map to avoid UI lockup
|
||||
const packedAsset = encode(asset);
|
||||
const success = await worker.putData(
|
||||
Comlink.transfer(packedAsset, [packedAsset.buffer]),
|
||||
"assets"
|
||||
);
|
||||
if (!success) {
|
||||
await database.table("assets").put(asset);
|
||||
}
|
||||
},
|
||||
[database, worker]
|
||||
);
|
||||
|
||||
const value = {
|
||||
getAsset,
|
||||
addAssets,
|
||||
putAsset,
|
||||
};
|
||||
|
||||
return (
|
||||
<AssetsContext.Provider value={value}>{children}</AssetsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAssets() {
|
||||
const context = useContext(AssetsContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useAssets must be used within a AssetsProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef AssetURL
|
||||
* @property {string} url
|
||||
* @property {string} id
|
||||
* @property {number} references
|
||||
*/
|
||||
|
||||
/**
|
||||
* @type React.Context<undefined|Object.<string, AssetURL>>
|
||||
*/
|
||||
export const AssetURLsStateContext = React.createContext();
|
||||
|
||||
/**
|
||||
* @type React.Context<undefined|React.Dispatch<React.SetStateAction<{}>>>
|
||||
*/
|
||||
export const AssetURLsUpdaterContext = React.createContext();
|
||||
|
||||
/**
|
||||
* Helper to manage sharing of custom image sources between uses of useAssetURL
|
||||
*/
|
||||
export function AssetURLsProvider({ children }) {
|
||||
const [assetURLs, setAssetURLs] = useState({});
|
||||
|
||||
// Clean up asset URLs every minute
|
||||
const debouncedAssetURLs = useDebounce(assetURLs, 60 * 1000);
|
||||
|
||||
// Revoke url when no more references
|
||||
useEffect(() => {
|
||||
setAssetURLs((prevURLs) => {
|
||||
let urlsToCleanup = [];
|
||||
for (let url of Object.values(prevURLs)) {
|
||||
if (url.references <= 0) {
|
||||
URL.revokeObjectURL(url.url);
|
||||
urlsToCleanup.push(url.id);
|
||||
}
|
||||
}
|
||||
if (urlsToCleanup.length > 0) {
|
||||
return omit(prevURLs, urlsToCleanup);
|
||||
} else {
|
||||
return prevURLs;
|
||||
}
|
||||
});
|
||||
}, [debouncedAssetURLs]);
|
||||
|
||||
return (
|
||||
<AssetURLsStateContext.Provider value={assetURLs}>
|
||||
<AssetURLsUpdaterContext.Provider value={setAssetURLs}>
|
||||
{children}
|
||||
</AssetURLsUpdaterContext.Provider>
|
||||
</AssetURLsStateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to load either file or default asset into a URL
|
||||
* @param {string} assetId
|
||||
* @param {"file"|"default"} type
|
||||
* @param {Object.<string, string>} defaultSources
|
||||
* @param {string|undefined} unknownSource
|
||||
* @returns {string|undefined}
|
||||
*/
|
||||
export function useAssetURL(assetId, type, defaultSources, unknownSource) {
|
||||
const assetURLs = useContext(AssetURLsStateContext);
|
||||
if (assetURLs === undefined) {
|
||||
throw new Error("useAssetURL must be used within a AssetURLsProvider");
|
||||
}
|
||||
const setAssetURLs = useContext(AssetURLsUpdaterContext);
|
||||
if (setAssetURLs === undefined) {
|
||||
throw new Error("useAssetURL must be used within a AssetURLsProvider");
|
||||
}
|
||||
|
||||
const { getAsset } = useAssets();
|
||||
const { database, databaseStatus } = useDatabase();
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!assetId ||
|
||||
type !== "file" ||
|
||||
!database ||
|
||||
databaseStatus === "loading"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
function updateAssetURL() {
|
||||
function increaseReferences(prevURLs) {
|
||||
return {
|
||||
...prevURLs,
|
||||
[assetId]: {
|
||||
...prevURLs[assetId],
|
||||
references: prevURLs[assetId].references + 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createURL(prevURLs, asset) {
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([asset.file], { type: asset.mime })
|
||||
);
|
||||
return {
|
||||
...prevURLs,
|
||||
[assetId]: { url, id: assetId, references: 1 },
|
||||
};
|
||||
}
|
||||
setAssetURLs((prevURLs) => {
|
||||
if (assetId in prevURLs) {
|
||||
// Check if the asset url is already added and increase references
|
||||
return increaseReferences(prevURLs);
|
||||
} else {
|
||||
getAsset(assetId).then((asset) => {
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
setAssetURLs((prevURLs) => {
|
||||
if (assetId in prevURLs) {
|
||||
// Check again if it exists
|
||||
return increaseReferences(prevURLs);
|
||||
} else {
|
||||
// Create url if the asset doesn't have a url
|
||||
return createURL(prevURLs, asset);
|
||||
}
|
||||
});
|
||||
});
|
||||
return prevURLs;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateAssetURL();
|
||||
|
||||
// Update the url when the asset is added to the db after the hook is used
|
||||
function handleAssetChanges(changes) {
|
||||
for (let change of changes) {
|
||||
const id = change.key;
|
||||
if (
|
||||
change.table === "assets" &&
|
||||
id === assetId &&
|
||||
(change.type === 1 || change.type === 2)
|
||||
) {
|
||||
const asset = change.obj;
|
||||
setAssetURLs((prevURLs) => {
|
||||
if (!(assetId in prevURLs)) {
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([asset.file], { type: asset.mime })
|
||||
);
|
||||
return {
|
||||
...prevURLs,
|
||||
[assetId]: { url, id: assetId, references: 1 },
|
||||
};
|
||||
} else {
|
||||
return prevURLs;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
database.on("changes", handleAssetChanges);
|
||||
|
||||
return () => {
|
||||
database.on("changes").unsubscribe(handleAssetChanges);
|
||||
|
||||
// Decrease references
|
||||
setAssetURLs((prevURLs) => {
|
||||
if (assetId in prevURLs) {
|
||||
return {
|
||||
...prevURLs,
|
||||
[assetId]: {
|
||||
...prevURLs[assetId],
|
||||
references: prevURLs[assetId].references - 1,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return prevURLs;
|
||||
}
|
||||
});
|
||||
};
|
||||
}, [assetId, setAssetURLs, getAsset, type, database, databaseStatus]);
|
||||
|
||||
if (!assetId) {
|
||||
return unknownSource;
|
||||
}
|
||||
|
||||
if (type === "default") {
|
||||
return defaultSources[assetId];
|
||||
}
|
||||
|
||||
if (type === "file") {
|
||||
return assetURLs[assetId]?.url || unknownSource;
|
||||
}
|
||||
|
||||
return unknownSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef FileData
|
||||
* @property {string} file
|
||||
* @property {"file"} type
|
||||
* @property {string} thumbnail
|
||||
* @property {string=} quality
|
||||
* @property {Object.<string, string>=} resolutions
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef DefaultData
|
||||
* @property {string} key
|
||||
* @property {"default"} type
|
||||
*/
|
||||
|
||||
/**
|
||||
* Load a map or token into a URL taking into account a thumbnail and multiple resolutions
|
||||
* @param {FileData|DefaultData} data
|
||||
* @param {Object.<string, string>} defaultSources
|
||||
* @param {string|undefined} unknownSource
|
||||
* @param {boolean} thumbnail
|
||||
* @returns {string|undefined}
|
||||
*/
|
||||
export function useDataURL(
|
||||
data,
|
||||
defaultSources,
|
||||
unknownSource,
|
||||
thumbnail = false
|
||||
) {
|
||||
const [assetId, setAssetId] = useState();
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
function loadAssetId() {
|
||||
if (data.type === "default") {
|
||||
setAssetId(data.key);
|
||||
} else {
|
||||
if (thumbnail) {
|
||||
setAssetId(data.thumbnail);
|
||||
} else if (data.resolutions && data.quality !== "original") {
|
||||
setAssetId(data.resolutions[data.quality]);
|
||||
} else {
|
||||
setAssetId(data.file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadAssetId();
|
||||
}, [data, thumbnail]);
|
||||
|
||||
const assetURL = useAssetURL(
|
||||
assetId,
|
||||
data?.type,
|
||||
defaultSources,
|
||||
unknownSource
|
||||
);
|
||||
return assetURL;
|
||||
}
|
||||
|
||||
export default AssetsContext;
|
@ -1,7 +1,4 @@
|
||||
import React, { useState, useEffect, useContext } from "react";
|
||||
import shortid from "shortid";
|
||||
|
||||
import { useDatabase } from "./DatabaseContext";
|
||||
|
||||
import FakeStorage from "../helpers/FakeStorage";
|
||||
|
||||
@ -18,35 +15,13 @@ try {
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const { database, databaseStatus } = useDatabase();
|
||||
|
||||
const [password, setPassword] = useState(storage.getItem("auth") || "");
|
||||
|
||||
useEffect(() => {
|
||||
storage.setItem("auth", password);
|
||||
}, [password]);
|
||||
|
||||
const [userId, setUserId] = useState();
|
||||
useEffect(() => {
|
||||
if (!database || databaseStatus === "loading") {
|
||||
return;
|
||||
}
|
||||
async function loadUserId() {
|
||||
const storedUserId = await database.table("user").get("userId");
|
||||
if (storedUserId) {
|
||||
setUserId(storedUserId.value);
|
||||
} else {
|
||||
const id = shortid.generate();
|
||||
setUserId(id);
|
||||
database.table("user").add({ key: "userId", value: id });
|
||||
}
|
||||
}
|
||||
|
||||
loadUserId();
|
||||
}, [database, databaseStatus]);
|
||||
|
||||
const value = {
|
||||
userId,
|
||||
password,
|
||||
setPassword,
|
||||
};
|
||||
|
@ -1,4 +1,6 @@
|
||||
import React, { useState, useEffect, useContext } from "react";
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import Dexie from "dexie";
|
||||
import * as Comlink from "comlink";
|
||||
|
||||
import ErrorBanner from "../components/banner/ErrorBanner";
|
||||
@ -7,12 +9,24 @@ import { getDatabase } from "../database";
|
||||
|
||||
import DatabaseWorker from "worker-loader!../workers/DatabaseWorker"; // eslint-disable-line import/no-webpack-loader-syntax
|
||||
|
||||
/**
|
||||
* @typedef DatabaseContext
|
||||
* @property {Dexie|undefined} database
|
||||
* @property {any} worker
|
||||
* @property {string} databaseStatus
|
||||
* @property {Error|undefined} databaseError
|
||||
*/
|
||||
|
||||
/**
|
||||
* @type {React.Context<undefined|DatabaseContext>}
|
||||
*/
|
||||
const DatabaseContext = React.createContext();
|
||||
|
||||
const worker = Comlink.wrap(new DatabaseWorker());
|
||||
|
||||
export function DatabaseProvider({ children }) {
|
||||
const [database, setDatabase] = useState();
|
||||
// "loading" | "disabled" | "upgrading" | "loaded"
|
||||
const [databaseStatus, setDatabaseStatus] = useState("loading");
|
||||
const [databaseError, setDatabaseError] = useState();
|
||||
|
||||
@ -21,11 +35,23 @@ export function DatabaseProvider({ children }) {
|
||||
let testDBRequest = window.indexedDB.open("__test");
|
||||
testDBRequest.onsuccess = async function () {
|
||||
testDBRequest.result.close();
|
||||
let db = getDatabase({ autoOpen: false });
|
||||
let db = getDatabase(
|
||||
{ autoOpen: false },
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
(v) => {
|
||||
setDatabaseStatus("upgrading");
|
||||
}
|
||||
);
|
||||
setDatabase(db);
|
||||
db.on("ready", () => {
|
||||
setDatabaseStatus("loaded");
|
||||
});
|
||||
db.on("versionchange", () => {
|
||||
// When another tab loads a new version of the database refresh the page
|
||||
window.location.reload();
|
||||
});
|
||||
await db.open();
|
||||
window.indexedDB.deleteDatabase("__test");
|
||||
};
|
||||
@ -45,18 +71,18 @@ export function DatabaseProvider({ children }) {
|
||||
|
||||
function handleDatabaseError(event) {
|
||||
event.preventDefault();
|
||||
if (event.reason?.message.startsWith("QuotaExceededError")) {
|
||||
if (event?.reason?.message?.startsWith("QuotaExceededError")) {
|
||||
setDatabaseError({
|
||||
name: event.reason.name,
|
||||
name: event?.reason?.name,
|
||||
message: "Storage Quota Exceeded Please Clear Space and Try Again.",
|
||||
});
|
||||
} else {
|
||||
setDatabaseError({
|
||||
name: event.reason.name,
|
||||
name: event?.reason?.name,
|
||||
message: "Something went wrong, please refresh your browser.",
|
||||
});
|
||||
}
|
||||
console.error(event.reason);
|
||||
console.error(event?.reason);
|
||||
}
|
||||
window.addEventListener("unhandledrejection", handleDatabaseError);
|
||||
|
||||
|
75
src/contexts/DragContext.js
Normal file
75
src/contexts/DragContext.js
Normal file
@ -0,0 +1,75 @@
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import React, { useRef, ReactNode } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
useDndContext,
|
||||
useDndMonitor,
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
|
||||
/**
|
||||
* Wrap a dnd-kit DndContext with a position monitor to get the
|
||||
* active drag element on drag end
|
||||
* TODO: use look into fixing this upstream
|
||||
* Related: https://github.com/clauderic/dnd-kit/issues/238
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef DragEndOverlayEvent
|
||||
* @property {DOMRect} overlayNodeClientRect
|
||||
*
|
||||
* @typedef {DragEndEvent & DragEndOverlayEvent} DragEndWithOverlayProps
|
||||
*/
|
||||
|
||||
/**
|
||||
* @callback DragEndWithOverlayEvent
|
||||
* @param {DragEndWithOverlayProps} props
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef CustomDragProps
|
||||
* @property {DragEndWithOverlayEvent=} onDragEnd
|
||||
* @property {ReactNode} children
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {CustomDragProps} props
|
||||
*/
|
||||
function DragPositionMonitor({ children, onDragEnd }) {
|
||||
const { overlayNode } = useDndContext();
|
||||
|
||||
const overlayNodeClientRectRef = useRef();
|
||||
function handleDragMove() {
|
||||
if (overlayNode?.nodeRef?.current) {
|
||||
overlayNodeClientRectRef.current = overlayNode.nodeRef.current.getBoundingClientRect();
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragEnd(props) {
|
||||
onDragEnd &&
|
||||
onDragEnd({
|
||||
...props,
|
||||
overlayNodeClientRect: overlayNodeClientRectRef.current,
|
||||
});
|
||||
}
|
||||
useDndMonitor({ onDragEnd: handleDragEnd, onDragMove: handleDragMove });
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: Import Props interface from dnd-kit with conversion to Typescript
|
||||
* @param {CustomDragProps} props
|
||||
*/
|
||||
function DragContext({ children, onDragEnd, ...props }) {
|
||||
return (
|
||||
<DndContext {...props}>
|
||||
<DragPositionMonitor onDragEnd={onDragEnd}>
|
||||
{children}
|
||||
</DragPositionMonitor>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
export default DragContext;
|
231
src/contexts/GroupContext.js
Normal file
231
src/contexts/GroupContext.js
Normal file
@ -0,0 +1,231 @@
|
||||
import React, { useState, useContext, useEffect } from "react";
|
||||
import cloneDeep from "lodash.clonedeep";
|
||||
import Fuse from "fuse.js";
|
||||
|
||||
import { useKeyboard, useBlur } from "./KeyboardContext";
|
||||
|
||||
import { getGroupItems, groupsFromIds } from "../helpers/group";
|
||||
|
||||
import shortcuts from "../shortcuts";
|
||||
|
||||
const GroupContext = React.createContext();
|
||||
|
||||
export function GroupProvider({
|
||||
groups,
|
||||
itemNames,
|
||||
onGroupsChange,
|
||||
onGroupsSelect,
|
||||
disabled,
|
||||
children,
|
||||
}) {
|
||||
const [selectedGroupIds, setSelectedGroupIds] = useState([]);
|
||||
// Either single, multiple or range
|
||||
const [selectMode, setSelectMode] = useState("single");
|
||||
|
||||
/**
|
||||
* Group Open
|
||||
*/
|
||||
const [openGroupId, setOpenGroupId] = useState();
|
||||
const [openGroupItems, setOpenGroupItems] = useState([]);
|
||||
useEffect(() => {
|
||||
if (openGroupId) {
|
||||
setOpenGroupItems(getGroupItems(groupsFromIds([openGroupId], groups)[0]));
|
||||
} else {
|
||||
setOpenGroupItems([]);
|
||||
}
|
||||
}, [openGroupId, groups]);
|
||||
|
||||
function handleGroupOpen(groupId) {
|
||||
setSelectedGroupIds([]);
|
||||
setOpenGroupId(groupId);
|
||||
}
|
||||
|
||||
function handleGroupClose() {
|
||||
setSelectedGroupIds([]);
|
||||
setOpenGroupId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Search
|
||||
*/
|
||||
const [filter, setFilter] = useState();
|
||||
const [filteredGroupItems, setFilteredGroupItems] = useState([]);
|
||||
const [fuse, setFuse] = useState();
|
||||
// Update search index when items change
|
||||
useEffect(() => {
|
||||
let items = [];
|
||||
for (let group of groups) {
|
||||
const itemsToAdd = getGroupItems(group);
|
||||
const namedItems = itemsToAdd.map((item) => ({
|
||||
...item,
|
||||
name: itemNames[item.id],
|
||||
}));
|
||||
items.push(...namedItems);
|
||||
}
|
||||
setFuse(new Fuse(items, { keys: ["name"] }));
|
||||
}, [groups, itemNames]);
|
||||
|
||||
// Perform search when search changes
|
||||
useEffect(() => {
|
||||
if (filter) {
|
||||
const query = fuse.search(filter);
|
||||
setFilteredGroupItems(query.map((result) => result.item));
|
||||
setOpenGroupId();
|
||||
} else {
|
||||
setFilteredGroupItems([]);
|
||||
}
|
||||
}, [filter, fuse]);
|
||||
|
||||
/**
|
||||
* Handlers
|
||||
*/
|
||||
|
||||
const activeGroups = openGroupId
|
||||
? openGroupItems
|
||||
: filter
|
||||
? filteredGroupItems
|
||||
: groups;
|
||||
|
||||
/**
|
||||
* @param {string|undefined} groupId The group to apply changes to, leave undefined to replace the full group object
|
||||
*/
|
||||
function handleGroupsChange(newGroups, groupId) {
|
||||
if (groupId) {
|
||||
// If a group is specidifed then update that group with the new items
|
||||
const groupIndex = groups.findIndex((group) => group.id === groupId);
|
||||
let updatedGroups = cloneDeep(groups);
|
||||
const group = updatedGroups[groupIndex];
|
||||
updatedGroups[groupIndex] = { ...group, items: newGroups };
|
||||
onGroupsChange(updatedGroups);
|
||||
} else {
|
||||
onGroupsChange(newGroups);
|
||||
}
|
||||
}
|
||||
|
||||
function handleGroupSelect(groupId) {
|
||||
let groupIds = [];
|
||||
if (groupId) {
|
||||
switch (selectMode) {
|
||||
case "single":
|
||||
groupIds = [groupId];
|
||||
break;
|
||||
case "multiple":
|
||||
if (selectedGroupIds.includes(groupId)) {
|
||||
groupIds = selectedGroupIds.filter((id) => id !== groupId);
|
||||
} else {
|
||||
groupIds = [...selectedGroupIds, groupId];
|
||||
}
|
||||
break;
|
||||
case "range":
|
||||
if (selectedGroupIds.length > 0) {
|
||||
const currentIndex = activeGroups.findIndex(
|
||||
(g) => g.id === groupId
|
||||
);
|
||||
const lastIndex = activeGroups.findIndex(
|
||||
(g) => g.id === selectedGroupIds[selectedGroupIds.length - 1]
|
||||
);
|
||||
let idsToAdd = [];
|
||||
let idsToRemove = [];
|
||||
const direction = currentIndex > lastIndex ? 1 : -1;
|
||||
for (
|
||||
let i = lastIndex + direction;
|
||||
direction < 0 ? i >= currentIndex : i <= currentIndex;
|
||||
i += direction
|
||||
) {
|
||||
const id = activeGroups[i].id;
|
||||
if (selectedGroupIds.includes(id)) {
|
||||
idsToRemove.push(id);
|
||||
} else {
|
||||
idsToAdd.push(id);
|
||||
}
|
||||
}
|
||||
groupIds = [...selectedGroupIds, ...idsToAdd].filter(
|
||||
(id) => !idsToRemove.includes(id)
|
||||
);
|
||||
} else {
|
||||
groupIds = [groupId];
|
||||
}
|
||||
break;
|
||||
default:
|
||||
groupIds = [];
|
||||
}
|
||||
}
|
||||
setSelectedGroupIds(groupIds);
|
||||
onGroupsSelect(groupIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortcuts
|
||||
*/
|
||||
function handleKeyDown(event) {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
if (shortcuts.selectRange(event)) {
|
||||
setSelectMode("range");
|
||||
}
|
||||
if (shortcuts.selectMultiple(event)) {
|
||||
setSelectMode("multiple");
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyUp(event) {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
if (shortcuts.selectRange(event) && selectMode === "range") {
|
||||
setSelectMode("single");
|
||||
}
|
||||
if (shortcuts.selectMultiple(event) && selectMode === "multiple") {
|
||||
setSelectMode("single");
|
||||
}
|
||||
}
|
||||
|
||||
useKeyboard(handleKeyDown, handleKeyUp);
|
||||
|
||||
// Set select mode to single when cmd+tabing
|
||||
function handleBlur() {
|
||||
setSelectMode("single");
|
||||
}
|
||||
|
||||
useBlur(handleBlur);
|
||||
|
||||
const value = {
|
||||
groups,
|
||||
activeGroups,
|
||||
openGroupId,
|
||||
openGroupItems,
|
||||
filter,
|
||||
filteredGroupItems,
|
||||
selectedGroupIds,
|
||||
selectMode,
|
||||
onSelectModeChange: setSelectMode,
|
||||
onGroupOpen: handleGroupOpen,
|
||||
onGroupClose: handleGroupClose,
|
||||
onGroupsChange: handleGroupsChange,
|
||||
onGroupSelect: handleGroupSelect,
|
||||
onFilterChange: setFilter,
|
||||
};
|
||||
|
||||
return (
|
||||
<GroupContext.Provider value={value}>{children}</GroupContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
GroupProvider.defaultProps = {
|
||||
groups: [],
|
||||
itemNames: {},
|
||||
onGroupsChange: () => {},
|
||||
onGroupsSelect: () => {},
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
export function useGroup() {
|
||||
const context = useContext(GroupContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useGroup must be used within a GroupProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export default GroupContext;
|
@ -1,157 +0,0 @@
|
||||
import React, { useContext, useState, useEffect } from "react";
|
||||
|
||||
import { omit } from "../helpers/shared";
|
||||
|
||||
export const ImageSourcesStateContext = React.createContext();
|
||||
export const ImageSourcesUpdaterContext = React.createContext(() => {});
|
||||
|
||||
/**
|
||||
* Helper to manage sharing of custom image sources between uses of useImageSource
|
||||
*/
|
||||
export function ImageSourcesProvider({ children }) {
|
||||
const [imageSources, setImageSources] = useState({});
|
||||
|
||||
// Revoke url when no more references
|
||||
useEffect(() => {
|
||||
let sourcesToCleanup = [];
|
||||
for (let source of Object.values(imageSources)) {
|
||||
if (source.references <= 0) {
|
||||
URL.revokeObjectURL(source.url);
|
||||
sourcesToCleanup.push(source.id);
|
||||
}
|
||||
}
|
||||
if (sourcesToCleanup.length > 0) {
|
||||
setImageSources((prevSources) => omit(prevSources, sourcesToCleanup));
|
||||
}
|
||||
}, [imageSources]);
|
||||
|
||||
return (
|
||||
<ImageSourcesStateContext.Provider value={imageSources}>
|
||||
<ImageSourcesUpdaterContext.Provider value={setImageSources}>
|
||||
{children}
|
||||
</ImageSourcesUpdaterContext.Provider>
|
||||
</ImageSourcesStateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get id from image data
|
||||
*/
|
||||
function getImageFileId(data, thumbnail) {
|
||||
if (thumbnail) {
|
||||
return `${data.id}-thumbnail`;
|
||||
}
|
||||
if (data.resolutions) {
|
||||
// Check is a resolution is specified
|
||||
if (data.quality && data.resolutions[data.quality]) {
|
||||
return `${data.id}-${data.quality}`;
|
||||
} else if (!data.file) {
|
||||
// Fallback to the highest resolution
|
||||
const resolutionArray = Object.keys(data.resolutions);
|
||||
const resolution = resolutionArray[resolutionArray.length - 1];
|
||||
return `${data.id}-${resolution.id}`;
|
||||
}
|
||||
}
|
||||
return data.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to load either file or default image into a URL
|
||||
*/
|
||||
export function useImageSource(data, defaultSources, unknownSource, thumbnail) {
|
||||
const imageSources = useContext(ImageSourcesStateContext);
|
||||
if (imageSources === undefined) {
|
||||
throw new Error(
|
||||
"useImageSource must be used within a ImageSourcesProvider"
|
||||
);
|
||||
}
|
||||
const setImageSources = useContext(ImageSourcesUpdaterContext);
|
||||
if (setImageSources === undefined) {
|
||||
throw new Error(
|
||||
"useImageSource must be used within a ImageSourcesProvider"
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || data.type !== "file") {
|
||||
return;
|
||||
}
|
||||
const id = getImageFileId(data, thumbnail);
|
||||
|
||||
function updateImageSource(file) {
|
||||
if (file) {
|
||||
setImageSources((prevSources) => {
|
||||
if (id in prevSources) {
|
||||
// Check if the image source is already added
|
||||
return {
|
||||
...prevSources,
|
||||
[id]: {
|
||||
...prevSources[id],
|
||||
// Increase references
|
||||
references: prevSources[id].references + 1,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
const url = URL.createObjectURL(new Blob([file]));
|
||||
return {
|
||||
...prevSources,
|
||||
[id]: { url, id, references: 1 },
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (thumbnail) {
|
||||
updateImageSource(data.thumbnail.file);
|
||||
} else if (data.resolutions) {
|
||||
// Check is a resolution is specified
|
||||
if (data.quality && data.resolutions[data.quality]) {
|
||||
updateImageSource(data.resolutions[data.quality].file);
|
||||
}
|
||||
// If no file available fallback to the highest resolution
|
||||
else if (!data.file) {
|
||||
const resolutionArray = Object.keys(data.resolutions);
|
||||
updateImageSource(
|
||||
data.resolutions[resolutionArray[resolutionArray.length - 1]].file
|
||||
);
|
||||
} else {
|
||||
updateImageSource(data.file);
|
||||
}
|
||||
} else {
|
||||
updateImageSource(data.file);
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Decrease references
|
||||
setImageSources((prevSources) => {
|
||||
if (id in prevSources) {
|
||||
return {
|
||||
...prevSources,
|
||||
[id]: {
|
||||
...prevSources[id],
|
||||
references: prevSources[id].references - 1,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return prevSources;
|
||||
}
|
||||
});
|
||||
};
|
||||
}, [data, unknownSource, thumbnail, setImageSources]);
|
||||
|
||||
if (!data) {
|
||||
return unknownSource;
|
||||
}
|
||||
|
||||
if (data.type === "default") {
|
||||
return defaultSources[data.key];
|
||||
}
|
||||
|
||||
if (data.type === "file") {
|
||||
const id = getImageFileId(data, thumbnail);
|
||||
return imageSources[id]?.url;
|
||||
}
|
||||
|
||||
return unknownSource;
|
||||
}
|
@ -1,23 +1,13 @@
|
||||
import React, {
|
||||
useEffect,
|
||||
useState,
|
||||
useContext,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from "react";
|
||||
import * as Comlink from "comlink";
|
||||
import { decode, encode } from "@msgpack/msgpack";
|
||||
import React, { useEffect, useState, useContext, useCallback } from "react";
|
||||
|
||||
import { useAuth } from "./AuthContext";
|
||||
import { useUserId } from "./UserIdContext";
|
||||
import { useDatabase } from "./DatabaseContext";
|
||||
|
||||
import { maps as defaultMaps } from "../maps";
|
||||
import { applyObservableChange } from "../helpers/dexie";
|
||||
import { removeGroupsItems } from "../helpers/group";
|
||||
|
||||
const MapDataContext = React.createContext();
|
||||
|
||||
// Maximum number of maps to keep in the cache
|
||||
const cachedMapMax = 15;
|
||||
|
||||
const defaultMapState = {
|
||||
tokens: {},
|
||||
drawShapes: {},
|
||||
@ -28,80 +18,35 @@ const defaultMapState = {
|
||||
};
|
||||
|
||||
export function MapDataProvider({ children }) {
|
||||
const { database, databaseStatus, worker } = useDatabase();
|
||||
const { userId } = useAuth();
|
||||
const { database, databaseStatus } = useDatabase();
|
||||
const userId = useUserId();
|
||||
|
||||
const [maps, setMaps] = useState([]);
|
||||
const [mapStates, setMapStates] = useState([]);
|
||||
const [mapsLoading, setMapsLoading] = useState(true);
|
||||
const [mapGroups, setMapGroups] = useState([]);
|
||||
|
||||
// Load maps from the database and ensure state is properly setup
|
||||
useEffect(() => {
|
||||
if (!userId || !database || databaseStatus === "loading") {
|
||||
return;
|
||||
}
|
||||
async function getDefaultMaps() {
|
||||
const defaultMapsWithIds = [];
|
||||
for (let i = 0; i < defaultMaps.length; i++) {
|
||||
const defaultMap = defaultMaps[i];
|
||||
const id = `__default-${defaultMap.name}`;
|
||||
defaultMapsWithIds.push({
|
||||
...defaultMap,
|
||||
id,
|
||||
owner: userId,
|
||||
// Emulate the time increasing to avoid sort errors
|
||||
created: Date.now() + i,
|
||||
lastModified: Date.now() + i,
|
||||
showGrid: false,
|
||||
snapToGrid: true,
|
||||
group: "default",
|
||||
});
|
||||
// Add a state for the map if there isn't one already
|
||||
const state = await database.table("states").get(id);
|
||||
if (!state) {
|
||||
await database.table("states").add({ ...defaultMapState, mapId: id });
|
||||
}
|
||||
}
|
||||
return defaultMapsWithIds;
|
||||
}
|
||||
|
||||
// Loads maps without the file data to save memory
|
||||
async function loadMaps() {
|
||||
let storedMaps = [];
|
||||
// Try to load maps with worker, fallback to database if failed
|
||||
const packedMaps = await worker.loadData("maps");
|
||||
// let packedMaps;
|
||||
if (packedMaps) {
|
||||
storedMaps = decode(packedMaps);
|
||||
} else {
|
||||
console.warn("Unable to load maps with worker, loading may be slow");
|
||||
await database.table("maps").each((map) => {
|
||||
const { file, resolutions, ...rest } = map;
|
||||
storedMaps.push(rest);
|
||||
});
|
||||
}
|
||||
const sortedMaps = storedMaps.sort((a, b) => b.created - a.created);
|
||||
const defaultMapsWithIds = await getDefaultMaps();
|
||||
const allMaps = [...sortedMaps, ...defaultMapsWithIds];
|
||||
setMaps(allMaps);
|
||||
const storedMaps = await database.table("maps").toArray();
|
||||
setMaps(storedMaps);
|
||||
const storedStates = await database.table("states").toArray();
|
||||
setMapStates(storedStates);
|
||||
const group = await database.table("groups").get("maps");
|
||||
const storedGroups = group.items;
|
||||
setMapGroups(storedGroups);
|
||||
setMapsLoading(false);
|
||||
}
|
||||
|
||||
loadMaps();
|
||||
}, [userId, database, databaseStatus, worker]);
|
||||
}, [userId, database, databaseStatus]);
|
||||
|
||||
const mapsRef = useRef(maps);
|
||||
useEffect(() => {
|
||||
mapsRef.current = maps;
|
||||
}, [maps]);
|
||||
|
||||
const getMap = useCallback((mapId) => {
|
||||
return mapsRef.current.find((map) => map.id === mapId);
|
||||
}, []);
|
||||
|
||||
const getMapFromDB = useCallback(
|
||||
const getMap = useCallback(
|
||||
async (mapId) => {
|
||||
let map = await database.table("maps").get(mapId);
|
||||
return map;
|
||||
@ -109,7 +54,7 @@ export function MapDataProvider({ children }) {
|
||||
[database]
|
||||
);
|
||||
|
||||
const getMapStateFromDB = useCallback(
|
||||
const getMapState = useCallback(
|
||||
async (mapId) => {
|
||||
let mapState = await database.table("states").get(mapId);
|
||||
return mapState;
|
||||
@ -118,26 +63,7 @@ export function MapDataProvider({ children }) {
|
||||
);
|
||||
|
||||
/**
|
||||
* Keep up to cachedMapMax amount of maps that you don't own
|
||||
* Sorted by when they we're last used
|
||||
*/
|
||||
const updateCache = useCallback(async () => {
|
||||
const cachedMaps = await database
|
||||
.table("maps")
|
||||
.where("owner")
|
||||
.notEqual(userId)
|
||||
.sortBy("lastUsed");
|
||||
if (cachedMaps.length > cachedMapMax) {
|
||||
const cacheDeleteCount = cachedMaps.length - cachedMapMax;
|
||||
const idsToDelete = cachedMaps
|
||||
.slice(0, cacheDeleteCount)
|
||||
.map((map) => map.id);
|
||||
database.table("maps").where("id").anyOf(idsToDelete).delete();
|
||||
}
|
||||
}, [database, userId]);
|
||||
|
||||
/**
|
||||
* Adds a map to the database, also adds an assosiated state for that map
|
||||
* Adds a map to the database, also adds an assosiated state and group for that map
|
||||
* @param {Object} map map to add
|
||||
*/
|
||||
const addMap = useCallback(
|
||||
@ -146,25 +72,36 @@ export function MapDataProvider({ children }) {
|
||||
const state = { ...defaultMapState, mapId: map.id };
|
||||
await database.table("maps").add(map);
|
||||
await database.table("states").add(state);
|
||||
if (map.owner !== userId) {
|
||||
await updateCache();
|
||||
}
|
||||
},
|
||||
[database, updateCache, userId]
|
||||
);
|
||||
|
||||
const removeMap = useCallback(
|
||||
async (id) => {
|
||||
await database.table("maps").delete(id);
|
||||
await database.table("states").delete(id);
|
||||
const group = await database.table("groups").get("maps");
|
||||
await database.table("groups").update("maps", {
|
||||
items: [{ id: map.id, type: "item" }, ...group.items],
|
||||
});
|
||||
},
|
||||
[database]
|
||||
);
|
||||
|
||||
const removeMaps = useCallback(
|
||||
async (ids) => {
|
||||
const maps = await database.table("maps").bulkGet(ids);
|
||||
// Remove assets linked with maps
|
||||
let assetIds = [];
|
||||
for (let map of maps) {
|
||||
if (map.type === "file") {
|
||||
assetIds.push(map.file);
|
||||
assetIds.push(map.thumbnail);
|
||||
for (let res of Object.values(map.resolutions)) {
|
||||
assetIds.push(res);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const group = await database.table("groups").get("maps");
|
||||
let items = removeGroupsItems(group.items, ids);
|
||||
await database.table("groups").update("maps", { items });
|
||||
|
||||
await database.table("maps").bulkDelete(ids);
|
||||
await database.table("states").bulkDelete(ids);
|
||||
await database.table("assets").bulkDelete(assetIds);
|
||||
},
|
||||
[database]
|
||||
);
|
||||
@ -180,23 +117,7 @@ export function MapDataProvider({ children }) {
|
||||
|
||||
const updateMap = useCallback(
|
||||
async (id, update) => {
|
||||
// fake-indexeddb throws an error when updating maps in production.
|
||||
// Catch that error and use put when it fails
|
||||
try {
|
||||
await database.table("maps").update(id, update);
|
||||
} catch (error) {
|
||||
const map = (await getMapFromDB(id)) || {};
|
||||
await database.table("maps").put({ ...map, id, ...update });
|
||||
}
|
||||
},
|
||||
[database, getMapFromDB]
|
||||
);
|
||||
|
||||
const updateMaps = useCallback(
|
||||
async (ids, update) => {
|
||||
await Promise.all(
|
||||
ids.map((id) => database.table("maps").update(id, update))
|
||||
);
|
||||
await database.table("maps").update(id, update);
|
||||
},
|
||||
[database]
|
||||
);
|
||||
@ -208,28 +129,13 @@ export function MapDataProvider({ children }) {
|
||||
[database]
|
||||
);
|
||||
|
||||
/**
|
||||
* Adds a map to the database if none exists or replaces a map if it already exists
|
||||
* Note: this does not add a map state to do that use AddMap
|
||||
* @param {Object} map the map to put
|
||||
*/
|
||||
const putMap = useCallback(
|
||||
async (map) => {
|
||||
// Attempt to use worker to put map to avoid UI lockup
|
||||
const packedMap = encode(map);
|
||||
const success = await worker.putData(
|
||||
Comlink.transfer(packedMap, [packedMap.buffer]),
|
||||
"maps",
|
||||
false
|
||||
);
|
||||
if (!success) {
|
||||
await database.table("maps").put(map);
|
||||
}
|
||||
if (map.owner !== userId) {
|
||||
await updateCache();
|
||||
}
|
||||
const updateMapGroups = useCallback(
|
||||
async (groups) => {
|
||||
// Update group state immediately to avoid animation delay
|
||||
setMapGroups(groups);
|
||||
await database.table("groups").update("maps", { items: groups });
|
||||
},
|
||||
[database, updateCache, userId, worker]
|
||||
[database]
|
||||
);
|
||||
|
||||
// Create DB observable to sync creating and deleting
|
||||
@ -286,6 +192,13 @@ export function MapDataProvider({ children }) {
|
||||
});
|
||||
}
|
||||
}
|
||||
if (change.table === "groups") {
|
||||
if (change.type === 2 && change.key === "maps") {
|
||||
const group = applyObservableChange(change);
|
||||
const groups = group.items.filter((item) => item !== null);
|
||||
setMapGroups(groups);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -296,24 +209,30 @@ export function MapDataProvider({ children }) {
|
||||
};
|
||||
}, [database, databaseStatus]);
|
||||
|
||||
const ownedMaps = maps.filter((map) => map.owner === userId);
|
||||
const [mapsById, setMapsById] = useState({});
|
||||
useEffect(() => {
|
||||
setMapsById(
|
||||
maps.reduce((obj, map) => {
|
||||
obj[map.id] = map;
|
||||
return obj;
|
||||
}, {})
|
||||
);
|
||||
}, [maps]);
|
||||
|
||||
const value = {
|
||||
maps,
|
||||
ownedMaps,
|
||||
mapStates,
|
||||
mapGroups,
|
||||
addMap,
|
||||
removeMap,
|
||||
removeMaps,
|
||||
resetMap,
|
||||
updateMap,
|
||||
updateMaps,
|
||||
updateMapState,
|
||||
putMap,
|
||||
getMap,
|
||||
getMapFromDB,
|
||||
mapsLoading,
|
||||
getMapStateFromDB,
|
||||
getMapState,
|
||||
updateMapGroups,
|
||||
mapsById,
|
||||
};
|
||||
return (
|
||||
<MapDataContext.Provider value={value}>{children}</MapDataContext.Provider>
|
||||
|
@ -1,46 +1,44 @@
|
||||
import React, { useState, useRef, useContext } from "react";
|
||||
import { omit, isEmpty } from "../helpers/shared";
|
||||
import React, { useState, useRef, useContext, useCallback } from "react";
|
||||
|
||||
const MapLoadingContext = React.createContext();
|
||||
|
||||
export function MapLoadingProvider({ children }) {
|
||||
const [loadingAssetCount, setLoadingAssetCount] = useState(0);
|
||||
|
||||
function assetLoadStart() {
|
||||
setLoadingAssetCount((prevLoadingAssets) => prevLoadingAssets + 1);
|
||||
}
|
||||
|
||||
function assetLoadFinish() {
|
||||
setLoadingAssetCount((prevLoadingAssets) => prevLoadingAssets - 1);
|
||||
}
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
// Mapping from asset id to the count and total number of pieces loaded
|
||||
const assetProgressRef = useRef({});
|
||||
// Loading progress of all assets between 0 and 1
|
||||
const loadingProgressRef = useRef(null);
|
||||
function assetProgressUpdate({ id, count, total }) {
|
||||
if (count === total) {
|
||||
assetProgressRef.current = omit(assetProgressRef.current, [id]);
|
||||
} else {
|
||||
assetProgressRef.current = {
|
||||
...assetProgressRef.current,
|
||||
[id]: { count, total },
|
||||
};
|
||||
}
|
||||
if (!isEmpty(assetProgressRef.current)) {
|
||||
let total = 0;
|
||||
let count = 0;
|
||||
for (let progress of Object.values(assetProgressRef.current)) {
|
||||
total += progress.total;
|
||||
count += progress.count;
|
||||
}
|
||||
loadingProgressRef.current = count / total;
|
||||
}
|
||||
}
|
||||
|
||||
const isLoading = loadingAssetCount > 0;
|
||||
const assetLoadStart = useCallback((id) => {
|
||||
setIsLoading(true);
|
||||
// Add asset at a 0% progress
|
||||
assetProgressRef.current = {
|
||||
...assetProgressRef.current,
|
||||
[id]: { count: 0, total: 1 },
|
||||
};
|
||||
}, []);
|
||||
|
||||
const assetProgressUpdate = useCallback(({ id, count, total }) => {
|
||||
assetProgressRef.current = {
|
||||
...assetProgressRef.current,
|
||||
[id]: { count, total },
|
||||
};
|
||||
// Update loading progress
|
||||
let complete = 0;
|
||||
const progresses = Object.values(assetProgressRef.current);
|
||||
for (let progress of progresses) {
|
||||
complete += progress.count / progress.total;
|
||||
}
|
||||
loadingProgressRef.current = complete / progresses.length;
|
||||
// All loading is complete
|
||||
if (loadingProgressRef.current === 1) {
|
||||
setIsLoading(false);
|
||||
assetProgressRef.current = {};
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value = {
|
||||
assetLoadStart,
|
||||
assetLoadFinish,
|
||||
isLoading,
|
||||
assetProgressUpdate,
|
||||
loadingProgressRef,
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useContext } from "react";
|
||||
|
||||
import { useDatabase } from "./DatabaseContext";
|
||||
import { useAuth } from "./AuthContext";
|
||||
import { useUserId } from "./UserIdContext";
|
||||
|
||||
import { getRandomMonster } from "../helpers/monsters";
|
||||
|
||||
@ -11,7 +11,7 @@ export const PlayerStateContext = React.createContext();
|
||||
export const PlayerUpdaterContext = React.createContext(() => {});
|
||||
|
||||
export function PlayerProvider({ session, children }) {
|
||||
const { userId } = useAuth();
|
||||
const userId = useUserId();
|
||||
const { database, databaseStatus } = useDatabase();
|
||||
|
||||
const [playerState, setPlayerState] = useNetworkedState(
|
||||
@ -53,7 +53,7 @@ export function PlayerProvider({ session, children }) {
|
||||
if (
|
||||
playerState.nickname &&
|
||||
database !== undefined &&
|
||||
databaseStatus !== "loading"
|
||||
(databaseStatus === "loaded" || databaseStatus === "disabled")
|
||||
) {
|
||||
database
|
||||
.table("user")
|
||||
|
264
src/contexts/TileDragContext.js
Normal file
264
src/contexts/TileDragContext.js
Normal file
@ -0,0 +1,264 @@
|
||||
import React, { useState, useContext, useEffect } from "react";
|
||||
import {
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
KeyboardSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
closestCenter,
|
||||
} from "@dnd-kit/core";
|
||||
|
||||
import DragContext from "./DragContext";
|
||||
|
||||
import { useGroup } from "./GroupContext";
|
||||
|
||||
import { moveGroupsInto, moveGroups, ungroup } from "../helpers/group";
|
||||
|
||||
import usePreventSelect from "../hooks/usePreventSelect";
|
||||
|
||||
const TileDragIdContext = React.createContext();
|
||||
const TileOverGroupIdContext = React.createContext();
|
||||
const TileDragCursorContext = React.createContext();
|
||||
|
||||
export const BASE_SORTABLE_ID = "__base__";
|
||||
export const GROUP_SORTABLE_ID = "__group__";
|
||||
export const GROUP_ID_PREFIX = "__group__";
|
||||
export const UNGROUP_ID = "__ungroup__";
|
||||
export const ADD_TO_MAP_ID = "__add__";
|
||||
|
||||
// Custom rectIntersect that takes a point
|
||||
function rectIntersection(rects, point) {
|
||||
for (let rect of rects) {
|
||||
const [id, bounds] = rect;
|
||||
if (
|
||||
id &&
|
||||
bounds &&
|
||||
point.x > bounds.offsetLeft &&
|
||||
point.x < bounds.offsetLeft + bounds.width &&
|
||||
point.y > bounds.offsetTop &&
|
||||
point.y < bounds.offsetTop + bounds.height
|
||||
) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function TileDragProvider({
|
||||
onDragAdd,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragCancel,
|
||||
children,
|
||||
}) {
|
||||
const {
|
||||
groups,
|
||||
activeGroups,
|
||||
openGroupId,
|
||||
selectedGroupIds,
|
||||
onGroupsChange,
|
||||
onGroupSelect,
|
||||
onGroupClose,
|
||||
filter,
|
||||
} = useGroup();
|
||||
|
||||
const mouseSensor = useSensor(MouseSensor, {
|
||||
activationConstraint: { distance: 3 },
|
||||
});
|
||||
const touchSensor = useSensor(TouchSensor, {
|
||||
activationConstraint: { delay: 250, tolerance: 5 },
|
||||
});
|
||||
const keyboardSensor = useSensor(KeyboardSensor);
|
||||
|
||||
const sensors = useSensors(mouseSensor, touchSensor, keyboardSensor);
|
||||
|
||||
const [dragId, setDragId] = useState(null);
|
||||
const [overId, setOverId] = useState(null);
|
||||
const [dragCursor, setDragCursor] = useState("pointer");
|
||||
|
||||
const [preventSelect, resumeSelect] = usePreventSelect();
|
||||
|
||||
const [overGroupId, setOverGroupId] = useState(null);
|
||||
useEffect(() => {
|
||||
setOverGroupId(
|
||||
(overId && overId.startsWith(GROUP_ID_PREFIX) && overId.slice(9)) || null
|
||||
);
|
||||
}, [overId]);
|
||||
|
||||
function handleDragStart(event) {
|
||||
const { active, over } = event;
|
||||
setDragId(active.id);
|
||||
setOverId(over?.id || null);
|
||||
if (!selectedGroupIds.includes(active.id)) {
|
||||
onGroupSelect(active.id);
|
||||
}
|
||||
setDragCursor("grabbing");
|
||||
|
||||
onDragStart && onDragStart(event);
|
||||
|
||||
preventSelect();
|
||||
}
|
||||
|
||||
function handleDragOver(event) {
|
||||
const { over } = event;
|
||||
|
||||
setOverId(over?.id || null);
|
||||
if (over) {
|
||||
if (
|
||||
over.id.startsWith(UNGROUP_ID) ||
|
||||
over.id.startsWith(GROUP_ID_PREFIX)
|
||||
) {
|
||||
setDragCursor("alias");
|
||||
} else if (over.id.startsWith(ADD_TO_MAP_ID)) {
|
||||
setDragCursor(onDragAdd ? "copy" : "no-drop");
|
||||
} else {
|
||||
setDragCursor("grabbing");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragEnd(event) {
|
||||
const { active, over, overlayNodeClientRect } = event;
|
||||
|
||||
setDragId(null);
|
||||
setOverId(null);
|
||||
setDragCursor("pointer");
|
||||
if (active && over && active.id !== over.id) {
|
||||
let selectedIndices = selectedGroupIds.map((groupId) =>
|
||||
activeGroups.findIndex((group) => group.id === groupId)
|
||||
);
|
||||
// Maintain current group sorting
|
||||
selectedIndices = selectedIndices.sort((a, b) => a - b);
|
||||
|
||||
if (over.id.startsWith(GROUP_ID_PREFIX)) {
|
||||
onGroupSelect();
|
||||
// Handle tile group
|
||||
const overId = over.id.slice(9);
|
||||
if (overId !== active.id) {
|
||||
const overGroupIndex = activeGroups.findIndex(
|
||||
(group) => group.id === overId
|
||||
);
|
||||
onGroupsChange(
|
||||
moveGroupsInto(activeGroups, overGroupIndex, selectedIndices),
|
||||
openGroupId
|
||||
);
|
||||
}
|
||||
} else if (over.id === UNGROUP_ID) {
|
||||
onGroupSelect();
|
||||
// Handle tile ungroup
|
||||
const newGroups = ungroup(groups, openGroupId, selectedIndices);
|
||||
// Close group if it was removed
|
||||
if (!newGroups.find((group) => group.id === openGroupId)) {
|
||||
onGroupClose();
|
||||
}
|
||||
onGroupsChange(newGroups);
|
||||
} else if (over.id === ADD_TO_MAP_ID) {
|
||||
onDragAdd &&
|
||||
overlayNodeClientRect &&
|
||||
onDragAdd(selectedGroupIds, overlayNodeClientRect);
|
||||
} else if (!filter) {
|
||||
// Hanlde tile move only if we have no filter
|
||||
const overGroupIndex = activeGroups.findIndex(
|
||||
(group) => group.id === over.id
|
||||
);
|
||||
onGroupsChange(
|
||||
moveGroups(activeGroups, overGroupIndex, selectedIndices),
|
||||
openGroupId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
resumeSelect();
|
||||
|
||||
onDragEnd && onDragEnd(event);
|
||||
}
|
||||
|
||||
function handleDragCancel(event) {
|
||||
setDragId(null);
|
||||
setOverId(null);
|
||||
setDragCursor("pointer");
|
||||
|
||||
resumeSelect();
|
||||
|
||||
onDragCancel && onDragCancel(event);
|
||||
}
|
||||
|
||||
function customCollisionDetection(rects, rect) {
|
||||
const rectCenter = {
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top + rect.height / 2,
|
||||
};
|
||||
|
||||
// Find whether out rect center is outside our add to map rect
|
||||
const addRect = rects.find(([id]) => id === ADD_TO_MAP_ID);
|
||||
if (addRect) {
|
||||
const intersectingAddRect = rectIntersection([addRect], rectCenter);
|
||||
if (!intersectingAddRect) {
|
||||
return ADD_TO_MAP_ID;
|
||||
}
|
||||
}
|
||||
|
||||
// Find whether out rect center is outside our ungroup rect
|
||||
if (openGroupId) {
|
||||
const ungroupRect = rects.find(([id]) => id === UNGROUP_ID);
|
||||
if (ungroupRect) {
|
||||
const intersectingGroupRect = rectIntersection(
|
||||
[ungroupRect],
|
||||
rectCenter
|
||||
);
|
||||
if (!intersectingGroupRect) {
|
||||
return UNGROUP_ID;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const otherRects = rects.filter(
|
||||
([id]) => id !== ADD_TO_MAP_ID && id !== UNGROUP_ID
|
||||
);
|
||||
|
||||
return closestCenter(otherRects, rect);
|
||||
}
|
||||
|
||||
return (
|
||||
<DragContext
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
onDragCancel={handleDragCancel}
|
||||
sensors={sensors}
|
||||
collisionDetection={customCollisionDetection}
|
||||
>
|
||||
<TileDragIdContext.Provider value={dragId}>
|
||||
<TileOverGroupIdContext.Provider value={overGroupId}>
|
||||
<TileDragCursorContext.Provider value={dragCursor}>
|
||||
{children}
|
||||
</TileDragCursorContext.Provider>
|
||||
</TileOverGroupIdContext.Provider>
|
||||
</TileDragIdContext.Provider>
|
||||
</DragContext>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTileDragId() {
|
||||
const context = useContext(TileDragIdContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useTileDrag must be used within a TileDragProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useTileOverGroupId() {
|
||||
const context = useContext(TileOverGroupIdContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useTileDrag must be used within a TileDragProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useTileDragCursor() {
|
||||
const context = useContext(TileDragCursorContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useTileDrag must be used within a TileDragProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
@ -1,83 +1,39 @@
|
||||
import React, {
|
||||
useEffect,
|
||||
useState,
|
||||
useContext,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { decode } from "@msgpack/msgpack";
|
||||
import React, { useEffect, useState, useContext, useCallback } from "react";
|
||||
|
||||
import { useAuth } from "./AuthContext";
|
||||
import { useUserId } from "./UserIdContext";
|
||||
import { useDatabase } from "./DatabaseContext";
|
||||
|
||||
import { tokens as defaultTokens } from "../tokens";
|
||||
import { applyObservableChange } from "../helpers/dexie";
|
||||
import { removeGroupsItems } from "../helpers/group";
|
||||
|
||||
const TokenDataContext = React.createContext();
|
||||
|
||||
const cachedTokenMax = 100;
|
||||
|
||||
export function TokenDataProvider({ children }) {
|
||||
const { database, databaseStatus, worker } = useDatabase();
|
||||
const { userId } = useAuth();
|
||||
const { database, databaseStatus } = useDatabase();
|
||||
const userId = useUserId();
|
||||
|
||||
/**
|
||||
* Contains all tokens without any file data,
|
||||
* to ensure file data is present call loadTokens
|
||||
*/
|
||||
const [tokens, setTokens] = useState([]);
|
||||
const [tokensLoading, setTokensLoading] = useState(true);
|
||||
const [tokenGroups, setTokenGroups] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId || !database || databaseStatus === "loading") {
|
||||
return;
|
||||
}
|
||||
function getDefaultTokens() {
|
||||
const defaultTokensWithIds = [];
|
||||
for (let defaultToken of defaultTokens) {
|
||||
defaultTokensWithIds.push({
|
||||
...defaultToken,
|
||||
id: `__default-${defaultToken.name}`,
|
||||
owner: userId,
|
||||
group: "default",
|
||||
});
|
||||
}
|
||||
return defaultTokensWithIds;
|
||||
}
|
||||
|
||||
// Loads tokens without the file data to save memory
|
||||
async function loadTokens() {
|
||||
let storedTokens = [];
|
||||
// Try to load tokens with worker, fallback to database if failed
|
||||
const packedTokens = await worker.loadData("tokens");
|
||||
if (packedTokens) {
|
||||
storedTokens = decode(packedTokens);
|
||||
} else {
|
||||
console.warn("Unable to load tokens with worker, loading may be slow");
|
||||
await database.table("tokens").each((token) => {
|
||||
const { file, resolutions, ...rest } = token;
|
||||
storedTokens.push(rest);
|
||||
});
|
||||
}
|
||||
const sortedTokens = storedTokens.sort((a, b) => b.created - a.created);
|
||||
const defaultTokensWithIds = getDefaultTokens();
|
||||
const allTokens = [...sortedTokens, ...defaultTokensWithIds];
|
||||
setTokens(allTokens);
|
||||
const storedTokens = await database.table("tokens").toArray();
|
||||
setTokens(storedTokens);
|
||||
const group = await database.table("groups").get("tokens");
|
||||
const storedGroups = group.items;
|
||||
setTokenGroups(storedGroups);
|
||||
setTokensLoading(false);
|
||||
}
|
||||
|
||||
loadTokens();
|
||||
}, [userId, database, databaseStatus, worker]);
|
||||
}, [userId, database, databaseStatus]);
|
||||
|
||||
const tokensRef = useRef(tokens);
|
||||
useEffect(() => {
|
||||
tokensRef.current = tokens;
|
||||
}, [tokens]);
|
||||
|
||||
const getToken = useCallback((tokenId) => {
|
||||
return tokensRef.current.find((token) => token.id === tokenId);
|
||||
}, []);
|
||||
|
||||
const getTokenFromDB = useCallback(
|
||||
const getToken = useCallback(
|
||||
async (tokenId) => {
|
||||
let token = await database.table("tokens").get(tokenId);
|
||||
return token;
|
||||
@ -85,106 +41,73 @@ export function TokenDataProvider({ children }) {
|
||||
[database]
|
||||
);
|
||||
|
||||
/**
|
||||
* Keep up to cachedTokenMax amount of tokens that you don't own
|
||||
* Sorted by when they we're last used
|
||||
*/
|
||||
const updateCache = useCallback(async () => {
|
||||
const cachedTokens = await database
|
||||
.table("tokens")
|
||||
.where("owner")
|
||||
.notEqual(userId)
|
||||
.sortBy("lastUsed");
|
||||
if (cachedTokens.length > cachedTokenMax) {
|
||||
const cacheDeleteCount = cachedTokens.length - cachedTokenMax;
|
||||
const idsToDelete = cachedTokens
|
||||
.slice(0, cacheDeleteCount)
|
||||
.map((token) => token.id);
|
||||
database.table("tokens").where("id").anyOf(idsToDelete).delete();
|
||||
}
|
||||
}, [database, userId]);
|
||||
|
||||
// Add token and add it to the token group
|
||||
const addToken = useCallback(
|
||||
async (token) => {
|
||||
await database.table("tokens").add(token);
|
||||
if (token.owner !== userId) {
|
||||
await updateCache();
|
||||
}
|
||||
},
|
||||
[database, updateCache, userId]
|
||||
);
|
||||
|
||||
const removeToken = useCallback(
|
||||
async (id) => {
|
||||
await database.table("tokens").delete(id);
|
||||
const group = await database.table("groups").get("tokens");
|
||||
await database.table("groups").update("tokens", {
|
||||
items: [{ id: token.id, type: "item" }, ...group.items],
|
||||
});
|
||||
},
|
||||
[database]
|
||||
);
|
||||
|
||||
const removeTokens = useCallback(
|
||||
async (ids) => {
|
||||
const tokens = await database.table("tokens").bulkGet(ids);
|
||||
let assetIds = [];
|
||||
for (let token of tokens) {
|
||||
if (token.type === "file") {
|
||||
assetIds.push(token.file);
|
||||
assetIds.push(token.thumbnail);
|
||||
}
|
||||
}
|
||||
|
||||
const group = await database.table("groups").get("tokens");
|
||||
let items = removeGroupsItems(group.items, ids);
|
||||
await database.table("groups").update("tokens", { items });
|
||||
|
||||
await database.table("tokens").bulkDelete(ids);
|
||||
await database.table("assets").bulkDelete(assetIds);
|
||||
},
|
||||
[database]
|
||||
);
|
||||
|
||||
const updateToken = useCallback(
|
||||
async (id, update) => {
|
||||
const change = { lastModified: Date.now(), ...update };
|
||||
await database.table("tokens").update(id, change);
|
||||
await database.table("tokens").update(id, update);
|
||||
},
|
||||
[database]
|
||||
);
|
||||
|
||||
const updateTokens = useCallback(
|
||||
async (ids, update) => {
|
||||
const change = { lastModified: Date.now(), ...update };
|
||||
const updateTokensHidden = useCallback(
|
||||
async (ids, hideInSidebar) => {
|
||||
// Update immediately to avoid UI delay
|
||||
setTokens((prevTokens) => {
|
||||
let newTokens = [...prevTokens];
|
||||
for (let id of ids) {
|
||||
const tokenIndex = newTokens.findIndex((token) => token.id === id);
|
||||
newTokens[tokenIndex].hideInSidebar = hideInSidebar;
|
||||
}
|
||||
return newTokens;
|
||||
});
|
||||
await Promise.all(
|
||||
ids.map((id) => database.table("tokens").update(id, change))
|
||||
ids.map((id) => database.table("tokens").update(id, { hideInSidebar }))
|
||||
);
|
||||
},
|
||||
[database]
|
||||
);
|
||||
|
||||
const putToken = useCallback(
|
||||
async (token) => {
|
||||
await database.table("tokens").put(token);
|
||||
if (token.owner !== userId) {
|
||||
await updateCache();
|
||||
}
|
||||
},
|
||||
[database, updateCache, userId]
|
||||
);
|
||||
|
||||
const loadTokens = useCallback(
|
||||
async (tokenIds) => {
|
||||
const loadedTokens = await database.table("tokens").bulkGet(tokenIds);
|
||||
const loadedTokensById = loadedTokens.reduce((obj, token) => {
|
||||
obj[token.id] = token;
|
||||
return obj;
|
||||
}, {});
|
||||
setTokens((prevTokens) => {
|
||||
return prevTokens.map((prevToken) => {
|
||||
if (prevToken.id in loadedTokensById) {
|
||||
return loadedTokensById[prevToken.id];
|
||||
} else {
|
||||
return prevToken;
|
||||
}
|
||||
});
|
||||
});
|
||||
const updateTokenGroups = useCallback(
|
||||
async (groups) => {
|
||||
// Update group state immediately to avoid animation delay
|
||||
setTokenGroups(groups);
|
||||
await database.table("groups").update("tokens", { items: groups });
|
||||
},
|
||||
[database]
|
||||
);
|
||||
|
||||
const unloadTokens = useCallback(async () => {
|
||||
setTokens((prevTokens) => {
|
||||
return prevTokens.map((prevToken) => {
|
||||
const { file, ...rest } = prevToken;
|
||||
return rest;
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Create DB observable to sync creating and deleting
|
||||
useEffect(() => {
|
||||
if (!database || databaseStatus === "loading") {
|
||||
@ -192,32 +115,50 @@ export function TokenDataProvider({ children }) {
|
||||
}
|
||||
|
||||
function handleTokenChanges(changes) {
|
||||
// Pool token changes together to call a single state update at the end
|
||||
let tokensCreated = [];
|
||||
let tokensUpdated = {};
|
||||
let tokensDeleted = [];
|
||||
for (let change of changes) {
|
||||
if (change.table === "tokens") {
|
||||
if (change.type === 1) {
|
||||
// Created
|
||||
const token = change.obj;
|
||||
setTokens((prevTokens) => [token, ...prevTokens]);
|
||||
tokensCreated.push(token);
|
||||
} else if (change.type === 2) {
|
||||
// Updated
|
||||
const token = change.obj;
|
||||
setTokens((prevTokens) => {
|
||||
const newTokens = [...prevTokens];
|
||||
const i = newTokens.findIndex((t) => t.id === token.id);
|
||||
if (i > -1) {
|
||||
newTokens[i] = token;
|
||||
}
|
||||
return newTokens;
|
||||
});
|
||||
tokensUpdated[token.id] = token;
|
||||
} else if (change.type === 3) {
|
||||
// Deleted
|
||||
const id = change.key;
|
||||
setTokens((prevTokens) => {
|
||||
const filtered = prevTokens.filter((token) => token.id !== id);
|
||||
return filtered;
|
||||
});
|
||||
tokensDeleted.push(id);
|
||||
}
|
||||
}
|
||||
if (change.table === "groups") {
|
||||
if (change.type === 2 && change.key === "tokens") {
|
||||
const group = applyObservableChange(change);
|
||||
const groups = group.items.filter((item) => item !== null);
|
||||
setTokenGroups(groups);
|
||||
}
|
||||
}
|
||||
}
|
||||
const tokensUpdatedArray = Object.values(tokensUpdated);
|
||||
if (
|
||||
tokensCreated.length > 0 ||
|
||||
tokensUpdatedArray.length > 0 ||
|
||||
tokensDeleted.length > 0
|
||||
) {
|
||||
setTokens((prevTokens) => {
|
||||
let newTokens = [...tokensCreated, ...prevTokens];
|
||||
for (let token of tokensUpdatedArray) {
|
||||
const tokenIndex = newTokens.findIndex((t) => t.id === token.id);
|
||||
if (tokenIndex > -1) {
|
||||
newTokens[tokenIndex] = token;
|
||||
}
|
||||
}
|
||||
return newTokens.filter((token) => !tokensDeleted.includes(token.id));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -228,28 +169,27 @@ export function TokenDataProvider({ children }) {
|
||||
};
|
||||
}, [database, databaseStatus]);
|
||||
|
||||
const ownedTokens = tokens.filter((token) => token.owner === userId);
|
||||
|
||||
const tokensById = tokens.reduce((obj, token) => {
|
||||
obj[token.id] = token;
|
||||
return obj;
|
||||
}, {});
|
||||
const [tokensById, setTokensById] = useState({});
|
||||
useEffect(() => {
|
||||
setTokensById(
|
||||
tokens.reduce((obj, token) => {
|
||||
obj[token.id] = token;
|
||||
return obj;
|
||||
}, {})
|
||||
);
|
||||
}, [tokens]);
|
||||
|
||||
const value = {
|
||||
tokens,
|
||||
ownedTokens,
|
||||
addToken,
|
||||
removeToken,
|
||||
tokenGroups,
|
||||
removeTokens,
|
||||
updateToken,
|
||||
updateTokens,
|
||||
putToken,
|
||||
getToken,
|
||||
tokensById,
|
||||
tokensLoading,
|
||||
getTokenFromDB,
|
||||
loadTokens,
|
||||
unloadTokens,
|
||||
getToken,
|
||||
updateTokenGroups,
|
||||
updateTokensHidden,
|
||||
};
|
||||
|
||||
return (
|
||||
|
36
src/contexts/UserIdContext.js
Normal file
36
src/contexts/UserIdContext.js
Normal file
@ -0,0 +1,36 @@
|
||||
import React, { useEffect, useState, useContext } from "react";
|
||||
|
||||
import { useDatabase } from "./DatabaseContext";
|
||||
/**
|
||||
* @type {React.Context<string|undefined>}
|
||||
*/
|
||||
const UserIdContext = React.createContext();
|
||||
|
||||
export function UserIdProvider({ children }) {
|
||||
const { database, databaseStatus } = useDatabase();
|
||||
|
||||
const [userId, setUserId] = useState();
|
||||
useEffect(() => {
|
||||
if (!database || databaseStatus === "loading") {
|
||||
return;
|
||||
}
|
||||
async function loadUserId() {
|
||||
const storedUserId = await database.table("user").get("userId");
|
||||
if (storedUserId) {
|
||||
setUserId(storedUserId.value);
|
||||
}
|
||||
}
|
||||
|
||||
loadUserId();
|
||||
}, [database, databaseStatus]);
|
||||
|
||||
return (
|
||||
<UserIdContext.Provider value={userId}>{children}</UserIdContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useUserId() {
|
||||
return useContext(UserIdContext);
|
||||
}
|
||||
|
||||
export default UserIdContext;
|
473
src/database.js
473
src/database.js
@ -1,449 +1,33 @@
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import Dexie, { Version, DexieOptions } from "dexie";
|
||||
import Dexie, { DexieOptions } from "dexie";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import "dexie-observable";
|
||||
import shortid from "shortid";
|
||||
|
||||
import blobToBuffer from "./helpers/blobToBuffer";
|
||||
import { getGridDefaultInset } from "./helpers/grid";
|
||||
import { convertOldActionsToShapes } from "./actions";
|
||||
import { createThumbnail } from "./helpers/image";
|
||||
|
||||
// Helper to create a thumbnail for a file in a db
|
||||
async function createDataThumbnail(data) {
|
||||
let url;
|
||||
if (data?.resolutions?.low?.file) {
|
||||
url = URL.createObjectURL(new Blob([data.resolutions.low.file]));
|
||||
} else {
|
||||
url = URL.createObjectURL(new Blob([data.file]));
|
||||
}
|
||||
return await Dexie.waitFor(
|
||||
new Promise((resolve) => {
|
||||
let image = new Image();
|
||||
image.onload = async () => {
|
||||
const thumbnail = await createThumbnail(image);
|
||||
resolve(thumbnail);
|
||||
};
|
||||
image.src = url;
|
||||
}),
|
||||
60000 * 10 // 10 minute timeout
|
||||
);
|
||||
}
|
||||
import { loadVersions } from "./upgrade";
|
||||
import { getDefaultMaps } from "./maps";
|
||||
import { getDefaultTokens } from "./tokens";
|
||||
|
||||
/**
|
||||
* @callback VersionCallback
|
||||
* @param {Version} version
|
||||
*/
|
||||
|
||||
/**
|
||||
* Mapping of version number to their upgrade function
|
||||
* @type {Object.<number, VersionCallback>}
|
||||
*/
|
||||
const versions = {
|
||||
// v1.2.0
|
||||
1(v) {
|
||||
v.stores({
|
||||
maps: "id, owner",
|
||||
states: "mapId",
|
||||
tokens: "id, owner",
|
||||
user: "key",
|
||||
});
|
||||
},
|
||||
// v1.2.1 - Move from blob files to array buffers
|
||||
2(v) {
|
||||
v.stores({}).upgrade(async (tx) => {
|
||||
const maps = await Dexie.waitFor(tx.table("maps").toArray());
|
||||
let mapBuffers = {};
|
||||
for (let map of maps) {
|
||||
mapBuffers[map.id] = await Dexie.waitFor(blobToBuffer(map.file));
|
||||
}
|
||||
return tx
|
||||
.table("maps")
|
||||
.toCollection()
|
||||
.modify((map) => {
|
||||
map.file = mapBuffers[map.id];
|
||||
});
|
||||
});
|
||||
},
|
||||
// v1.3.0 - Added new default tokens
|
||||
3(v) {
|
||||
v.stores({}).upgrade((tx) => {
|
||||
return tx
|
||||
.table("states")
|
||||
.toCollection()
|
||||
.modify((state) => {
|
||||
function mapTokenId(id) {
|
||||
switch (id) {
|
||||
case "__default-Axes":
|
||||
return "__default-Barbarian";
|
||||
case "__default-Bird":
|
||||
return "__default-Druid";
|
||||
case "__default-Book":
|
||||
return "__default-Wizard";
|
||||
case "__default-Crown":
|
||||
return "__default-Humanoid";
|
||||
case "__default-Dragon":
|
||||
return "__default-Dragon";
|
||||
case "__default-Eye":
|
||||
return "__default-Warlock";
|
||||
case "__default-Fist":
|
||||
return "__default-Monk";
|
||||
case "__default-Horse":
|
||||
return "__default-Fey";
|
||||
case "__default-Leaf":
|
||||
return "__default-Druid";
|
||||
case "__default-Lion":
|
||||
return "__default-Monstrosity";
|
||||
case "__default-Money":
|
||||
return "__default-Humanoid";
|
||||
case "__default-Moon":
|
||||
return "__default-Cleric";
|
||||
case "__default-Potion":
|
||||
return "__default-Sorcerer";
|
||||
case "__default-Shield":
|
||||
return "__default-Paladin";
|
||||
case "__default-Skull":
|
||||
return "__default-Undead";
|
||||
case "__default-Snake":
|
||||
return "__default-Beast";
|
||||
case "__default-Sun":
|
||||
return "__default-Cleric";
|
||||
case "__default-Swords":
|
||||
return "__default-Fighter";
|
||||
case "__default-Tree":
|
||||
return "__default-Plant";
|
||||
case "__default-Triangle":
|
||||
return "__default-Sorcerer";
|
||||
default:
|
||||
return "__default-Fighter";
|
||||
}
|
||||
}
|
||||
for (let stateId in state.tokens) {
|
||||
state.tokens[stateId].tokenId = mapTokenId(
|
||||
state.tokens[stateId].tokenId
|
||||
);
|
||||
state.tokens[stateId].lastEditedBy = "";
|
||||
state.tokens[stateId].rotation = 0;
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
// v1.3.1 - Added show grid option
|
||||
4(v) {
|
||||
v.stores({}).upgrade((tx) => {
|
||||
return tx
|
||||
.table("maps")
|
||||
.toCollection()
|
||||
.modify((map) => {
|
||||
map.showGrid = false;
|
||||
});
|
||||
});
|
||||
},
|
||||
// v1.4.0 - Added fog subtraction
|
||||
5(v) {
|
||||
v.stores({}).upgrade((tx) => {
|
||||
return tx
|
||||
.table("states")
|
||||
.toCollection()
|
||||
.modify((state) => {
|
||||
for (let fogAction of state.fogDrawActions) {
|
||||
if (fogAction.type === "add" || fogAction.type === "edit") {
|
||||
for (let shape of fogAction.shapes) {
|
||||
shape.data.holes = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
// v1.4.2 - Added map resolutions
|
||||
6(v) {
|
||||
v.stores({}).upgrade((tx) => {
|
||||
return tx
|
||||
.table("maps")
|
||||
.toCollection()
|
||||
.modify((map) => {
|
||||
map.resolutions = {};
|
||||
map.quality = "original";
|
||||
});
|
||||
});
|
||||
},
|
||||
// v1.5.0 - Fixed default token rogue spelling
|
||||
7(v) {
|
||||
v.stores({}).upgrade((tx) => {
|
||||
return tx
|
||||
.table("states")
|
||||
.toCollection()
|
||||
.modify((state) => {
|
||||
for (let id in state.tokens) {
|
||||
if (state.tokens[id].tokenId === "__default-Rouge") {
|
||||
state.tokens[id].tokenId = "__default-Rogue";
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
// v1.5.0 - Added map snap to grid option
|
||||
8(v) {
|
||||
v.stores({}).upgrade((tx) => {
|
||||
return tx
|
||||
.table("maps")
|
||||
.toCollection()
|
||||
.modify((map) => {
|
||||
map.snapToGrid = true;
|
||||
});
|
||||
});
|
||||
},
|
||||
// v1.5.1 - Added lock, visibility and modified to tokens
|
||||
9(v) {
|
||||
v.stores({}).upgrade((tx) => {
|
||||
return tx
|
||||
.table("states")
|
||||
.toCollection()
|
||||
.modify((state) => {
|
||||
for (let id in state.tokens) {
|
||||
state.tokens[id].lastModifiedBy = state.tokens[id].lastEditedBy;
|
||||
delete state.tokens[id].lastEditedBy;
|
||||
state.tokens[id].lastModified = Date.now();
|
||||
state.tokens[id].locked = false;
|
||||
state.tokens[id].visible = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
// v1.5.1 - Added token prop category and remove isVehicle bool
|
||||
10(v) {
|
||||
v.stores({}).upgrade((tx) => {
|
||||
return tx
|
||||
.table("tokens")
|
||||
.toCollection()
|
||||
.modify((token) => {
|
||||
token.category = token.isVehicle ? "vehicle" : "character";
|
||||
delete token.isVehicle;
|
||||
});
|
||||
});
|
||||
},
|
||||
// v1.5.2 - Added automatic cache invalidation to maps
|
||||
11(v) {
|
||||
v.stores({}).upgrade((tx) => {
|
||||
return tx
|
||||
.table("maps")
|
||||
.toCollection()
|
||||
.modify((map) => {
|
||||
map.lastUsed = map.lastModified;
|
||||
});
|
||||
});
|
||||
},
|
||||
// v1.5.2 - Added automatic cache invalidation to tokens
|
||||
12(v) {
|
||||
v.stores({}).upgrade((tx) => {
|
||||
return tx
|
||||
.table("tokens")
|
||||
.toCollection()
|
||||
.modify((token) => {
|
||||
token.lastUsed = token.lastModified;
|
||||
});
|
||||
});
|
||||
},
|
||||
// v1.6.0 - Added map grouping and grid scale and offset
|
||||
13(v) {
|
||||
v.stores({}).upgrade((tx) => {
|
||||
return tx
|
||||
.table("maps")
|
||||
.toCollection()
|
||||
.modify((map) => {
|
||||
map.group = "";
|
||||
map.grid = {
|
||||
size: { x: map.gridX, y: map.gridY },
|
||||
inset: getGridDefaultInset(
|
||||
{ size: { x: map.gridX, y: map.gridY }, type: "square" },
|
||||
map.width,
|
||||
map.height
|
||||
),
|
||||
type: "square",
|
||||
};
|
||||
delete map.gridX;
|
||||
delete map.gridY;
|
||||
delete map.gridType;
|
||||
});
|
||||
});
|
||||
},
|
||||
// v1.6.0 - Added token grouping
|
||||
14(v) {
|
||||
v.stores({}).upgrade((tx) => {
|
||||
return tx
|
||||
.table("tokens")
|
||||
.toCollection()
|
||||
.modify((token) => {
|
||||
token.group = "";
|
||||
});
|
||||
});
|
||||
},
|
||||
// v1.6.1 - Added width and height to tokens
|
||||
15(v) {
|
||||
v.stores({}).upgrade(async (tx) => {
|
||||
const tokens = await Dexie.waitFor(tx.table("tokens").toArray());
|
||||
let tokenSizes = {};
|
||||
for (let token of tokens) {
|
||||
const url = URL.createObjectURL(new Blob([token.file]));
|
||||
let image = new Image();
|
||||
tokenSizes[token.id] = await Dexie.waitFor(
|
||||
new Promise((resolve) => {
|
||||
image.onload = () => {
|
||||
resolve({ width: image.width, height: image.height });
|
||||
};
|
||||
image.src = url;
|
||||
})
|
||||
);
|
||||
}
|
||||
return tx
|
||||
.table("tokens")
|
||||
.toCollection()
|
||||
.modify((token) => {
|
||||
token.width = tokenSizes[token.id].width;
|
||||
token.height = tokenSizes[token.id].height;
|
||||
});
|
||||
});
|
||||
},
|
||||
// v1.7.0 - Added note tool
|
||||
16(v) {
|
||||
v.stores({}).upgrade((tx) => {
|
||||
return tx
|
||||
.table("states")
|
||||
.toCollection()
|
||||
.modify((state) => {
|
||||
state.notes = {};
|
||||
state.editFlags = [...state.editFlags, "notes"];
|
||||
});
|
||||
});
|
||||
},
|
||||
// 1.7.0 (hotfix) - Optimized fog shape edits to only include needed data
|
||||
17(v) {
|
||||
v.stores({}).upgrade((tx) => {
|
||||
return tx
|
||||
.table("states")
|
||||
.toCollection()
|
||||
.modify((state) => {
|
||||
for (let i = 0; i < state.fogDrawActions.length; i++) {
|
||||
const action = state.fogDrawActions[i];
|
||||
if (action && action.type === "edit") {
|
||||
for (let j = 0; j < action.shapes.length; j++) {
|
||||
const shape = action.shapes[j];
|
||||
const temp = { ...shape };
|
||||
state.fogDrawActions[i].shapes[j] = {
|
||||
id: temp.id,
|
||||
visible: temp.visible,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
// 1.8.0 - Added note text only mode, converted draw and fog representations
|
||||
18(v) {
|
||||
v.stores({}).upgrade((tx) => {
|
||||
return tx
|
||||
.table("states")
|
||||
.toCollection()
|
||||
.modify((state) => {
|
||||
for (let id in state.notes) {
|
||||
state.notes[id].textOnly = false;
|
||||
}
|
||||
|
||||
state.drawShapes = convertOldActionsToShapes(
|
||||
state.mapDrawActions,
|
||||
state.mapDrawActionIndex
|
||||
);
|
||||
state.fogShapes = convertOldActionsToShapes(
|
||||
state.fogDrawActions,
|
||||
state.fogDrawActionIndex
|
||||
);
|
||||
|
||||
delete state.mapDrawActions;
|
||||
delete state.mapDrawActionIndex;
|
||||
delete state.fogDrawActions;
|
||||
delete state.fogDrawActionIndex;
|
||||
});
|
||||
});
|
||||
},
|
||||
// 1.8.0 - Add thumbnail to maps and add measurement to grid
|
||||
19(v) {
|
||||
v.stores({}).upgrade(async (tx) => {
|
||||
const userId = (await Dexie.waitFor(tx.table("user").get("userId")))
|
||||
.value;
|
||||
const maps = await Dexie.waitFor(tx.table("maps").toArray());
|
||||
const thumbnails = {};
|
||||
for (let map of maps) {
|
||||
try {
|
||||
if (map.owner === userId) {
|
||||
thumbnails[map.id] = await createDataThumbnail(map);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return tx
|
||||
.table("maps")
|
||||
.toCollection()
|
||||
.modify((map) => {
|
||||
map.thumbnail = thumbnails[map.id];
|
||||
map.grid.measurement = { type: "chebyshev", scale: "5ft" };
|
||||
});
|
||||
});
|
||||
},
|
||||
// 1.8.0 - Add thumbnail to tokens
|
||||
20(v) {
|
||||
v.stores({}).upgrade(async (tx) => {
|
||||
const userId = (await Dexie.waitFor(tx.table("user").get("userId")))
|
||||
.value;
|
||||
const tokens = await Dexie.waitFor(tx.table("tokens").toArray());
|
||||
const thumbnails = {};
|
||||
for (let token of tokens) {
|
||||
try {
|
||||
if (token.owner === userId) {
|
||||
thumbnails[token.id] = await createDataThumbnail(token);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return tx
|
||||
.table("tokens")
|
||||
.toCollection()
|
||||
.modify((token) => {
|
||||
token.thumbnail = thumbnails[token.id];
|
||||
});
|
||||
});
|
||||
},
|
||||
// 1.8.0 - Upgrade for Dexie.Observable
|
||||
21(v) {
|
||||
v.stores({});
|
||||
},
|
||||
// v1.8.1 - Shorten fog shape ids
|
||||
22(v) {
|
||||
v.stores({}).upgrade((tx) => {
|
||||
return tx
|
||||
.table("states")
|
||||
.toCollection()
|
||||
.modify((state) => {
|
||||
for (let id of Object.keys(state.fogShapes)) {
|
||||
const newId = shortid.generate();
|
||||
state.fogShapes[newId] = state.fogShapes[id];
|
||||
state.fogShapes[newId].id = newId;
|
||||
delete state.fogShapes[id];
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const latestVersion = 22;
|
||||
|
||||
/**
|
||||
* Load versions onto a database up to a specific version number
|
||||
* Populate DB with initial data
|
||||
* @param {Dexie} db
|
||||
* @param {number=} upTo version number to load up to, latest version if undefined
|
||||
*/
|
||||
export function loadVersions(db, upTo = latestVersion) {
|
||||
for (let versionNumber = 1; versionNumber <= upTo; versionNumber++) {
|
||||
versions[versionNumber](db.version(versionNumber));
|
||||
}
|
||||
function populate(db) {
|
||||
db.on("populate", () => {
|
||||
const userId = uuid();
|
||||
db.table("user").add({ key: "userId", value: userId });
|
||||
const { maps, mapStates } = getDefaultMaps(userId);
|
||||
db.table("maps").bulkAdd(maps);
|
||||
db.table("states").bulkAdd(mapStates);
|
||||
const tokens = getDefaultTokens(userId);
|
||||
db.table("tokens").bulkAdd(tokens);
|
||||
db.table("groups").bulkAdd([
|
||||
{ id: "maps", items: maps.map((map) => ({ id: map.id, type: "item" })) },
|
||||
{
|
||||
id: "tokens",
|
||||
items: tokens.map((token) => ({ id: token.id, type: "item" })),
|
||||
},
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -451,14 +35,21 @@ export function loadVersions(db, upTo = latestVersion) {
|
||||
* @param {DexieOptions} options
|
||||
* @param {string=} name
|
||||
* @param {number=} versionNumber
|
||||
* @param {boolean=} populateData
|
||||
* @param {import("./upgrade").OnUpgrade=} onUpgrade
|
||||
* @returns {Dexie}
|
||||
*/
|
||||
export function getDatabase(
|
||||
options,
|
||||
name = "OwlbearRodeoDB",
|
||||
versionNumber = latestVersion
|
||||
versionNumber = undefined,
|
||||
populateData = true,
|
||||
onUpgrade = undefined
|
||||
) {
|
||||
let db = new Dexie(name, options);
|
||||
loadVersions(db, versionNumber);
|
||||
loadVersions(db, versionNumber, onUpgrade);
|
||||
if (populateData) {
|
||||
populate(db);
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 42 KiB |
@ -1 +1 @@
|
||||
[embed:](https://www.youtube.com/embed/KLUsOZA-SHI)
|
||||
![embed:](https://www.youtube.com/embed/KLUsOZA-SHI)
|
||||
|
@ -6,6 +6,8 @@ To Access the settings screen click the Settings button in the bottom left of a
|
||||
An overview of each setting is listed below:
|
||||
|
||||
- Light theme: Enables/disables the light theme.
|
||||
- Show fog guides: Enables/disables the fog guide visual when editing fog.
|
||||
- Fog edit opacity: Adjusts how visible fog is while the GM is editing it.
|
||||
- Token label size: Changes the max label size for tokens.
|
||||
- Grid snapping sensitivity: Changes how sensitive the grid snapping is. 0 = no grid snapping, 1 = full grid snapping.
|
||||
- Clear cache: Clears the apps settings and any maps that other users have sent you. Does not remove any data you have added.
|
||||
|
@ -1,5 +1,3 @@
|
||||
[embed:](https://www.youtube.com/embed/ztLDznOpmsg)
|
||||
|
||||
Once you have started a game you can share a map to all other party members by clicking the Select Map button then selecting the desired map to share and clicking the Done button.
|
||||
|
||||
## Default Maps
|
||||
@ -41,7 +39,6 @@ Next you can set the name of your map shown in the Map Select Screen.
|
||||
## Editing Maps (Advanced)
|
||||
|
||||
When editing a map there are also a few more advanced settings available.
|
||||
To get access to these settings, click the Show More button under the Name input in the Map Edit Screen.
|
||||
|
||||
![Editing Maps Advanced](editingMapsAdvanced)
|
||||
|
||||
@ -63,7 +60,7 @@ A brief summary of these settings is listed below.
|
||||
- Tokens: Controls whether others can move tokens that they have not placed themselves (default enabled).
|
||||
- Notes: Controls whether others can add or move notes (default enabled).
|
||||
|
||||
## Reseting, Removing and Grouping Maps
|
||||
## Reseting, Removing, Organising Maps
|
||||
|
||||
With a map selected there are a couple of actions you can perform on them.
|
||||
|
||||
@ -75,7 +72,13 @@ Once a map has been used you can clear away all the tokens, fog and drawings by
|
||||
|
||||
To remove a custom map select the map in the Map Select Screen then click the Remove Map button or use the Delete keyboard shortcut.
|
||||
|
||||
Maps can also be grouped to allow for better organisation. To do this with a map selected click on the Group Map button then select or create a new group.
|
||||
Maps can also be grouped to allow for better organisation. To do this with you can drag and drop a map onto another map to create a group.
|
||||
|
||||
Once a group has been created you can double click that group to open it.
|
||||
|
||||
To ungroup a map you can drag that map back out of the group into the main view when the group is open.
|
||||
|
||||
Maps can also be reordered by dragging them into the desired position.
|
||||
|
||||
## Filtering Maps
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
[embed:](https://www.youtube.com/embed/Er_grVmqpk0)
|
||||
![embed:](https://www.youtube.com/embed/Er_grVmqpk0)
|
||||
|
||||
Owlbear Rodeo supports a physically simulated 3D dice tray and dice. To access these features click the Show Dice Tray icon in the top left of the map view.
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
[embed:](https://www.youtube.com/embed/2e07DtB-Xrc)
|
||||
![embed:](https://www.youtube.com/embed/2e07DtB-Xrc)
|
||||
|
||||
The Drawing Tool allows you to draw on top of a map. To access the Drawing Tool click the Drawing Tool button in the top right of the map view.
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
[embed:](https://www.youtube.com/embed/1ra7DoIsas8)
|
||||
![embed:](https://www.youtube.com/embed/1ra7DoIsas8)
|
||||
|
||||
The Fog Tool allows you to add hidden areas to control what the other party members can see on your map. To access the Fog Tool click the Fog Tool button in the top right of the map view.
|
||||
|
||||
|
@ -1,3 +1,5 @@
|
||||
The Measure Tool allows you to find how far one point on a map is from another point. To access the Measure Tool click the Measure Tool button in the top right of the map view.
|
||||
|
||||
To change the scale or type of measurement used by the tool you must edit the current map. See the Advanced section of the Sharing a Map How To for more information.
|
||||
|
||||
![Using Measure](usingMeasure)
|
||||
|
@ -1,5 +1,3 @@
|
||||
[embed:](https://www.youtube.com/embed/j-9X9CF7_UY)
|
||||
|
||||
Once you have a map shared between a party all players can drag tokens from the Token List on the right hand side of the screen. Tokens can then be used to represent players, monsters or any other object that needs to be moved around the map.
|
||||
|
||||
## Default Tokens
|
||||
@ -71,7 +69,13 @@ To remove a custom token select the token in the Edit Tokens Screen then click t
|
||||
|
||||
Once a token has been added you can use the Hide/Show Token in Sidebar toggle to prevent it from taking up room in the Token List on the right side of your screen.
|
||||
|
||||
Tokens can also be grouped to allow for better organisation. To do this with a token selected click on the Group Token button then select or create a new group.
|
||||
Tokens can also be grouped to allow for better organisation. To do this with you can drag and drop a token onto another token to create a group.
|
||||
|
||||
Once a group has been created you can double click that group to open it.
|
||||
|
||||
To ungroup a token you can drag that token back out of the group into the main view when the group is open.
|
||||
|
||||
Tokens can also be reordered by dragging them into the desired position.
|
||||
|
||||
`Tip: You can select multiple tokens at the same time using the Select Multiple option or using the Ctrl/Cmd or Shift keyboard shortcuts`
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
[embed:](https://www.youtube.com/embed/aOTvQOrpNo4)
|
||||
![embed:](https://www.youtube.com/embed/aOTvQOrpNo4)
|
||||
|
||||
## Major Changes
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
[embed:](https://www.youtube.com/embed/IhSS24d4zlM)
|
||||
![embed:](https://www.youtube.com/embed/IhSS24d4zlM)
|
||||
|
||||
## Major Changes
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
[embed:](https://www.youtube.com/embed/Y7sEgoopz4E)
|
||||
![embed:](https://www.youtube.com/embed/Y7sEgoopz4E)
|
||||
|
||||
## Major Changes
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
[embed:](https://www.youtube.com/embed/vtNpj-449B8)
|
||||
![embed:](https://www.youtube.com/embed/vtNpj-449B8)
|
||||
|
||||
## Major Changes
|
||||
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user