Merge branch 'master' into typescript
This commit is contained in:
commit
62686136ab
@ -6,4 +6,4 @@ REACT_APP_VERSION=$npm_package_version
|
|||||||
REACT_APP_PREVIEW=false
|
REACT_APP_PREVIEW=false
|
||||||
REACT_APP_LOGGING=true
|
REACT_APP_LOGGING=true
|
||||||
REACT_APP_FATHOM_SITE_ID=VMSHBPKD
|
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
|
||||||
|
19
package.json
19
package.json
@ -1,13 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "owlbear-rodeo",
|
"name": "owlbear-rodeo",
|
||||||
"version": "1.8.1",
|
"version": "1.9.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babylonjs/core": "^4.2.0",
|
"@babylonjs/core": "^4.2.0",
|
||||||
"@babylonjs/loaders": "^4.2.0",
|
"@babylonjs/loaders": "^4.2.0",
|
||||||
|
"@dnd-kit/core": "^3.0.4",
|
||||||
|
"@dnd-kit/sortable": "^3.1.0",
|
||||||
"@mitchemmc/dexie-export-import": "^1.0.1",
|
"@mitchemmc/dexie-export-import": "^1.0.1",
|
||||||
"@msgpack/msgpack": "^2.4.1",
|
"@msgpack/msgpack": "^2.4.1",
|
||||||
"@sentry/react": "^6.2.2",
|
"@sentry/integrations": "^6.3.0",
|
||||||
|
"@sentry/react": "^6.3.0",
|
||||||
"@stripe/stripe-js": "^1.13.1",
|
"@stripe/stripe-js": "^1.13.1",
|
||||||
"@tensorflow/tfjs": "^3.6.0",
|
"@tensorflow/tfjs": "^3.6.0",
|
||||||
"@testing-library/jest-dom": "^5.11.9",
|
"@testing-library/jest-dom": "^5.11.9",
|
||||||
@ -18,17 +21,20 @@
|
|||||||
"color": "^3.1.3",
|
"color": "^3.1.3",
|
||||||
"comlink": "^4.3.0",
|
"comlink": "^4.3.0",
|
||||||
"deep-diff": "^1.0.2",
|
"deep-diff": "^1.0.2",
|
||||||
"dexie": "^3.0.3",
|
"dexie": "3.1.0-beta.13",
|
||||||
"dexie-observable": "^3.0.0-beta.10",
|
"dexie-react-hooks": "^1.0.6",
|
||||||
"err-code": "^3.0.1",
|
"err-code": "^3.0.1",
|
||||||
"fake-indexeddb": "^3.1.2",
|
"fake-indexeddb": "^3.1.2",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"fuse.js": "^6.4.6",
|
"fuse.js": "^6.4.6",
|
||||||
"interactjs": "^1.10.8",
|
"image-outline": "^0.1.0",
|
||||||
|
"intersection-observer": "^0.12.0",
|
||||||
"konva": "^7.2.5",
|
"konva": "^7.2.5",
|
||||||
|
"lodash.chunk": "^4.2.0",
|
||||||
"lodash.clonedeep": "^4.5.0",
|
"lodash.clonedeep": "^4.5.0",
|
||||||
"lodash.get": "^4.4.2",
|
"lodash.get": "^4.4.2",
|
||||||
"lodash.set": "^4.3.2",
|
"lodash.set": "^4.3.2",
|
||||||
|
"lodash.unset": "^4.5.2",
|
||||||
"normalize-wheel": "^1.0.1",
|
"normalize-wheel": "^1.0.1",
|
||||||
"pepjs": "^0.5.3",
|
"pepjs": "^0.5.3",
|
||||||
"polygon-clipping": "^0.15.2",
|
"polygon-clipping": "^0.15.2",
|
||||||
@ -36,6 +42,7 @@
|
|||||||
"raw.macro": "^0.4.2",
|
"raw.macro": "^0.4.2",
|
||||||
"react": "^17.0.2",
|
"react": "^17.0.2",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "^17.0.2",
|
||||||
|
"react-intersection-observer": "^8.32.0",
|
||||||
"react-konva": "^17.0.2-3",
|
"react-konva": "^17.0.2-3",
|
||||||
"react-markdown": "4",
|
"react-markdown": "4",
|
||||||
"react-media": "^2.0.0-rc.1",
|
"react-media": "^2.0.0-rc.1",
|
||||||
@ -46,6 +53,7 @@
|
|||||||
"react-scripts": "^4.0.3",
|
"react-scripts": "^4.0.3",
|
||||||
"react-select": "^4.2.1",
|
"react-select": "^4.2.1",
|
||||||
"react-spring": "^8.0.27",
|
"react-spring": "^8.0.27",
|
||||||
|
"react-textarea-autosize": "^8.3.3",
|
||||||
"react-toast-notifications": "^2.4.3",
|
"react-toast-notifications": "^2.4.3",
|
||||||
"react-use-gesture": "^9.1.3",
|
"react-use-gesture": "^9.1.3",
|
||||||
"shortid": "^2.2.15",
|
"shortid": "^2.2.15",
|
||||||
@ -57,6 +65,7 @@
|
|||||||
"source-map-explorer": "^2.5.2",
|
"source-map-explorer": "^2.5.2",
|
||||||
"theme-ui": "^0.8.4",
|
"theme-ui": "^0.8.4",
|
||||||
"use-image": "^1.0.7",
|
"use-image": "^1.0.7",
|
||||||
|
"uuid": "^8.3.2",
|
||||||
"webrtc-adapter": "^7.7.1"
|
"webrtc-adapter": "^7.7.1"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
|
89
src/App.tsx
89
src/App.tsx
@ -11,63 +11,54 @@ import HowTo from "./routes/HowTo";
|
|||||||
import Donate from "./routes/Donate";
|
import Donate from "./routes/Donate";
|
||||||
|
|
||||||
import { AuthProvider } from "./contexts/AuthContext";
|
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 { SettingsProvider } from "./contexts/SettingsContext";
|
||||||
import { KeyboardProvider } from "./contexts/KeyboardContext";
|
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";
|
import { ToastProvider } from "./components/Toast";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<DatabaseProvider>
|
<SettingsProvider>
|
||||||
<SettingsProvider>
|
<AuthProvider>
|
||||||
<AuthProvider>
|
<KeyboardProvider>
|
||||||
<KeyboardProvider>
|
<ToastProvider>
|
||||||
<ToastProvider>
|
<Router>
|
||||||
<ImageSourcesProvider>
|
<Switch>
|
||||||
<Router>
|
<Route path="/donate">
|
||||||
<Switch>
|
<Donate />
|
||||||
<Route path="/donate">
|
</Route>
|
||||||
<Donate />
|
{/* Legacy support camel case routes */}
|
||||||
</Route>
|
<Route path={["/howTo", "/how-to"]}>
|
||||||
{/* Legacy support camel case routes */}
|
<HowTo />
|
||||||
<Route path={["/howTo", "/how-to"]}>
|
</Route>
|
||||||
<HowTo />
|
<Route path={["/releaseNotes", "/release-notes"]}>
|
||||||
</Route>
|
<ReleaseNotes />
|
||||||
<Route path={["/releaseNotes", "/release-notes"]}>
|
</Route>
|
||||||
<ReleaseNotes />
|
<Route path="/about">
|
||||||
</Route>
|
<About />
|
||||||
<Route path="/about">
|
</Route>
|
||||||
<About />
|
<Route path="/faq">
|
||||||
</Route>
|
<FAQ />
|
||||||
<Route path="/faq">
|
</Route>
|
||||||
<FAQ />
|
<Route path="/game/:id">
|
||||||
</Route>
|
<DatabaseProvider>
|
||||||
<Route path="/game/:id">
|
<UserIdProvider>
|
||||||
<MapLoadingProvider>
|
<Game />
|
||||||
<MapDataProvider>
|
</UserIdProvider>
|
||||||
<TokenDataProvider>
|
</DatabaseProvider>
|
||||||
<Game />
|
</Route>
|
||||||
</TokenDataProvider>
|
<Route path="/">
|
||||||
</MapDataProvider>
|
<Home />
|
||||||
</MapLoadingProvider>
|
</Route>
|
||||||
</Route>
|
</Switch>
|
||||||
<Route path="/">
|
</Router>
|
||||||
<Home />
|
</ToastProvider>
|
||||||
</Route>
|
</KeyboardProvider>
|
||||||
</Switch>
|
</AuthProvider>
|
||||||
</Router>
|
</SettingsProvider>
|
||||||
</ImageSourcesProvider>
|
|
||||||
</ToastProvider>
|
|
||||||
</KeyboardProvider>
|
|
||||||
</AuthProvider>
|
|
||||||
</SettingsProvider>
|
|
||||||
</DatabaseProvider>
|
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -4,27 +4,25 @@ import Action from "./Action";
|
|||||||
import {
|
import {
|
||||||
addPolygonDifferenceToShapes,
|
addPolygonDifferenceToShapes,
|
||||||
addPolygonIntersectionToShapes,
|
addPolygonIntersectionToShapes,
|
||||||
|
shapeToGeometry,
|
||||||
} from "../helpers/actions";
|
} from "../helpers/actions";
|
||||||
|
|
||||||
class CutShapeAction extends Action {
|
class CutShapeAction extends Action {
|
||||||
constructor(shapes) {
|
constructor(shapes) {
|
||||||
super();
|
super();
|
||||||
this.update = (shapesById) => {
|
this.update = (shapesById) => {
|
||||||
const actionGeom = shapes.map((actionShape) => [
|
let actionGeom = shapes.map(shapeToGeometry);
|
||||||
actionShape.data.points.map(({ x, y }) => [x, y]),
|
|
||||||
]);
|
|
||||||
let cutShapes = {};
|
let cutShapes = {};
|
||||||
for (let shape of Object.values(shapesById)) {
|
for (let shape of Object.values(shapesById)) {
|
||||||
const shapePoints = shape.data.points.map(({ x, y }) => [x, y]);
|
const shapeGeom = shapeToGeometry(shape);
|
||||||
const shapeHoles = shape.data.holes.map((hole) =>
|
|
||||||
hole.map(({ x, y }) => [x, y])
|
|
||||||
);
|
|
||||||
let shapeGeom = [[shapePoints, ...shapeHoles]];
|
|
||||||
try {
|
try {
|
||||||
const difference = polygonClipping.difference(shapeGeom, actionGeom);
|
const difference = polygonClipping.difference(
|
||||||
|
shapeGeom,
|
||||||
|
...actionGeom
|
||||||
|
);
|
||||||
const intersection = polygonClipping.intersection(
|
const intersection = polygonClipping.intersection(
|
||||||
shapeGeom,
|
shapeGeom,
|
||||||
actionGeom
|
...actionGeom
|
||||||
);
|
);
|
||||||
addPolygonDifferenceToShapes(shape, difference, cutShapes);
|
addPolygonDifferenceToShapes(shape, difference, cutShapes);
|
||||||
addPolygonIntersectionToShapes(shape, intersection, cutShapes);
|
addPolygonIntersectionToShapes(shape, intersection, cutShapes);
|
||||||
|
@ -1,24 +1,24 @@
|
|||||||
import polygonClipping from "polygon-clipping";
|
import polygonClipping from "polygon-clipping";
|
||||||
|
|
||||||
import Action from "./Action";
|
import Action from "./Action";
|
||||||
import { addPolygonDifferenceToShapes } from "../helpers/actions";
|
import {
|
||||||
|
addPolygonDifferenceToShapes,
|
||||||
|
shapeToGeometry,
|
||||||
|
} from "../helpers/actions";
|
||||||
|
|
||||||
class SubtractShapeAction extends Action {
|
class SubtractShapeAction extends Action {
|
||||||
constructor(shapes) {
|
constructor(shapes) {
|
||||||
super();
|
super();
|
||||||
this.update = (shapesById) => {
|
this.update = (shapesById) => {
|
||||||
const actionGeom = shapes.map((actionShape) => [
|
const actionGeom = shapes.map(shapeToGeometry);
|
||||||
actionShape.data.points.map(({ x, y }) => [x, y]),
|
|
||||||
]);
|
|
||||||
let subtractedShapes = {};
|
let subtractedShapes = {};
|
||||||
for (let shape of Object.values(shapesById)) {
|
for (let shape of Object.values(shapesById)) {
|
||||||
const shapePoints = shape.data.points.map(({ x, y }) => [x, y]);
|
const shapeGeom = shapeToGeometry(shape);
|
||||||
const shapeHoles = shape.data.holes.map((hole) =>
|
|
||||||
hole.map(({ x, y }) => [x, y])
|
|
||||||
);
|
|
||||||
let shapeGeom = [[shapePoints, ...shapeHoles]];
|
|
||||||
try {
|
try {
|
||||||
const difference = polygonClipping.difference(shapeGeom, actionGeom);
|
const difference = polygonClipping.difference(
|
||||||
|
shapeGeom,
|
||||||
|
...actionGeom
|
||||||
|
);
|
||||||
addPolygonDifferenceToShapes(shape, difference, subtractedShapes);
|
addPolygonDifferenceToShapes(shape, difference, subtractedShapes);
|
||||||
} catch {
|
} catch {
|
||||||
console.error("Unable to find difference for shapes");
|
console.error("Unable to find difference for shapes");
|
||||||
|
@ -4,43 +4,6 @@ import EditShapeAction from "./EditShapeAction";
|
|||||||
import RemoveShapeAction from "./RemoveShapeAction";
|
import RemoveShapeAction from "./RemoveShapeAction";
|
||||||
import SubtractShapeAction from "./SubtractShapeAction";
|
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 {
|
export {
|
||||||
AddShapeAction,
|
AddShapeAction,
|
||||||
CutShapeAction,
|
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;
|
|
@ -1,8 +1,15 @@
|
|||||||
|
import React from "react";
|
||||||
import { Box } from "theme-ui";
|
import { Box } from "theme-ui";
|
||||||
|
|
||||||
import Spinner from "./Spinner";
|
import Spinner from "./Spinner";
|
||||||
|
|
||||||
function LoadingOverlay({ bg }: any ) {
|
function LoadingOverlay({
|
||||||
|
bg,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
bg: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@ -20,6 +27,7 @@ function LoadingOverlay({ bg }: any ) {
|
|||||||
bg={bg}
|
bg={bg}
|
||||||
>
|
>
|
||||||
<Spinner />
|
<Spinner />
|
||||||
|
{children}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -9,7 +9,7 @@ import {
|
|||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
|
|
||||||
function Paragraph(props) {
|
function Paragraph(props) {
|
||||||
return <Text variant="body2" {...props} />;
|
return <Text as="p" my={2} variant="body2" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Heading({ level, ...props }) {
|
function Heading({ level, ...props }) {
|
||||||
@ -27,6 +27,9 @@ function Heading({ level, ...props }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Image(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")) {
|
if (props.src.endsWith(".mp4")) {
|
||||||
return (
|
return (
|
||||||
<video
|
<video
|
||||||
@ -125,12 +128,7 @@ function TableCell({ children }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Link({ href, children }) {
|
function Link({ href, children }) {
|
||||||
const linkText = children[0].props.value;
|
return <UILink href={href}>{children}</UILink>;
|
||||||
if (linkText === "embed:") {
|
|
||||||
return <Embed src={href} my={2} />;
|
|
||||||
} else {
|
|
||||||
return <UILink href={href}>{children}</UILink>;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function Markdown({ source, assets }) {
|
function Markdown({ source, assets }) {
|
||||||
@ -151,7 +149,7 @@ function Markdown({ source, assets }) {
|
|||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
source={source}
|
source={source}
|
||||||
renderers={renderers}
|
renderers={renderers}
|
||||||
transformImageUri={(uri) => assets[uri]}
|
transformImageUri={(uri) => assets[uri] || uri}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
import React, { ReactChild } from "react";
|
import React, { ReactChild } from "react";
|
||||||
import Modal, { Props } from "react-modal";
|
import Modal, { Props } from "react-modal";
|
||||||
import { useThemeUI, Close } from "theme-ui";
|
import { useThemeUI, Close } from "theme-ui";
|
||||||
|
import { useSpring, animated, config } from "react-spring";
|
||||||
|
|
||||||
type ModalProps = Props & {
|
type ModalProps = Props & {
|
||||||
children: ReactChild | ReactChild[],
|
children: ReactChild | ReactChild[];
|
||||||
allowClose: boolean
|
allowClose: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
function StyledModal({
|
function StyledModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
onRequestClose,
|
onRequestClose,
|
||||||
@ -13,27 +15,55 @@ function StyledModal({
|
|||||||
allowClose,
|
allowClose,
|
||||||
style,
|
style,
|
||||||
...props
|
...props
|
||||||
}: ModalProps ) {
|
}: ModalProps) {
|
||||||
const { theme } = useThemeUI();
|
const { theme } = useThemeUI();
|
||||||
|
|
||||||
|
const openAnimation = useSpring({
|
||||||
|
opacity: isOpen ? 1 : 0,
|
||||||
|
transform: isOpen ? "scale(1)" : "scale(0.99)",
|
||||||
|
config: config.default,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onRequestClose={onRequestClose}
|
onRequestClose={onRequestClose}
|
||||||
style={{
|
style={{
|
||||||
overlay: { backgroundColor: "rgba(0, 0, 0, 0.73)", zIndex: 100 },
|
overlay: {
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.73)",
|
||||||
|
zIndex: 100,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
...(style?.overlay || {}),
|
||||||
|
},
|
||||||
content: {
|
content: {
|
||||||
backgroundColor: theme.colors?.background,
|
backgroundColor: theme.colors.background,
|
||||||
top: "50%",
|
top: "initial",
|
||||||
left: "50%",
|
left: "initial",
|
||||||
right: "auto",
|
bottom: "initial",
|
||||||
bottom: "auto",
|
right: "initial",
|
||||||
marginRight: "-50%",
|
|
||||||
transform: "translate(-50%, -50%)",
|
|
||||||
maxHeight: "100%",
|
maxHeight: "100%",
|
||||||
...style,
|
...(style?.content || {}),
|
||||||
} as React.CSSProperties,
|
} as React.CSSProperties,
|
||||||
}}
|
}}
|
||||||
|
contentElement={(props, content) => (
|
||||||
|
<animated.div {...props} style={{ ...props.style, ...openAnimation }}>
|
||||||
|
{content}
|
||||||
|
</animated.div>
|
||||||
|
)}
|
||||||
|
overlayElement={(props, content) => (
|
||||||
|
<div
|
||||||
|
onDragEnter={(e) => {
|
||||||
|
// Prevent drag event from triggering with a modal open
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@ -50,7 +80,7 @@ function StyledModal({
|
|||||||
|
|
||||||
StyledModal.defaultProps = {
|
StyledModal.defaultProps = {
|
||||||
allowClose: true,
|
allowClose: true,
|
||||||
style: {}
|
style: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default StyledModal;
|
export default StyledModal;
|
||||||
|
@ -1,59 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Box, Flex, IconButton, Text } from "theme-ui";
|
|
||||||
|
|
||||||
function NumberInput({ value, onChange, title, min, max }) {
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Text sx={{ textAlign: "center" }} variant="heading" as="h1">
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
<Flex sx={{ alignItems: "center", justifyContent: "center" }}>
|
|
||||||
<IconButton
|
|
||||||
aria-label={`Decrease ${title}`}
|
|
||||||
title={`Decrease ${title}`}
|
|
||||||
onClick={() => value > min && onChange(value - 1)}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="24"
|
|
||||||
fill="currentcolor"
|
|
||||||
>
|
|
||||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
|
||||||
<path d="M18 13H6c-.55 0-1-.45-1-1s.45-1 1-1h12c.55 0 1 .45 1 1s-.45 1-1 1z" />
|
|
||||||
</svg>
|
|
||||||
</IconButton>
|
|
||||||
<Text as="p" aria-label={`Current ${title}`}>
|
|
||||||
{value}
|
|
||||||
</Text>
|
|
||||||
<IconButton
|
|
||||||
aria-label={`Increase ${title}`}
|
|
||||||
title={`Increase ${title}`}
|
|
||||||
onClick={() => value < max && onChange(value + 1)}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
width="24"
|
|
||||||
fill="currentcolor"
|
|
||||||
>
|
|
||||||
<path d="M0 0h24v24H0V0z" fill="none" />
|
|
||||||
<path d="M18 13h-5v5c0 .55-.45 1-1 1s-1-.45-1-1v-5H6c-.55 0-1-.45-1-1s.45-1 1-1h5V6c0-.55.45-1 1-1s1 .45 1 1v5h5c.55 0 1 .45 1 1s-.45 1-1 1z" />
|
|
||||||
</svg>
|
|
||||||
</IconButton>
|
|
||||||
</Flex>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
NumberInput.defaultProps = {
|
|
||||||
value: 1,
|
|
||||||
onChange: () => {},
|
|
||||||
title: "Number",
|
|
||||||
min: 0,
|
|
||||||
max: 10,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NumberInput;
|
|
@ -24,7 +24,7 @@ function Select({ creatable, ...props }) {
|
|||||||
}),
|
}),
|
||||||
control: (provided, state) => ({
|
control: (provided, state) => ({
|
||||||
...provided,
|
...provided,
|
||||||
backgroundColor: theme.colors.background,
|
backgroundColor: "transparent",
|
||||||
color: theme.colors.text,
|
color: theme.colors.text,
|
||||||
borderColor: theme.colors.text,
|
borderColor: theme.colors.text,
|
||||||
opacity: state.isDisabled ? 0.5 : 1,
|
opacity: state.isDisabled ? 0.5 : 1,
|
||||||
@ -53,6 +53,10 @@ function Select({ creatable, ...props }) {
|
|||||||
color: theme.colors.text,
|
color: theme.colors.text,
|
||||||
opacity: state.isDisabled ? 0.5 : 1,
|
opacity: state.isDisabled ? 0.5 : 1,
|
||||||
}),
|
}),
|
||||||
|
container: (provided) => ({
|
||||||
|
...provided,
|
||||||
|
margin: "4px 0",
|
||||||
|
}),
|
||||||
}}
|
}}
|
||||||
theme={(t) => ({
|
theme={(t) => ({
|
||||||
...t,
|
...t,
|
||||||
@ -63,6 +67,7 @@ function Select({ creatable, ...props }) {
|
|||||||
primary25: theme.colors.highlight,
|
primary25: theme.colors.highlight,
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
|
captureMenuScroll={false}
|
||||||
{...props}
|
{...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 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 (
|
return (
|
||||||
<Tile
|
<div style={{ cursor: "pointer" }}>
|
||||||
src={dice.preview}
|
<Tile
|
||||||
title={dice.name}
|
title={dice.name}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
onSelect={() => onDiceSelect(dice)}
|
onSelect={() => onDiceSelect(dice)}
|
||||||
onDoubleClick={() => onDone(dice)}
|
onDoubleClick={() => onDone(dice)}
|
||||||
size={size}
|
>
|
||||||
/>
|
<Image src={dice.preview}></Image>
|
||||||
|
</Tile>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Flex } from "theme-ui";
|
import { Grid } from "theme-ui";
|
||||||
import SimpleBar from "simplebar-react";
|
import SimpleBar from "simplebar-react";
|
||||||
|
|
||||||
import DiceTile from "./DiceTile";
|
import DiceTile from "./DiceTile";
|
||||||
@ -10,19 +10,17 @@ function DiceTiles({ dice, onDiceSelect, selectedDice, onDone }) {
|
|||||||
const layout = useResponsiveLayout();
|
const layout = useResponsiveLayout();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SimpleBar
|
<SimpleBar style={{ height: layout.tileContainerHeight }}>
|
||||||
style={{ height: layout.screenSize === "large" ? "600px" : "400px" }}
|
<Grid
|
||||||
>
|
|
||||||
<Flex
|
|
||||||
p={2}
|
p={2}
|
||||||
pb={4}
|
pb={4}
|
||||||
bg="muted"
|
bg="muted"
|
||||||
sx={{
|
sx={{
|
||||||
flexWrap: "wrap",
|
|
||||||
borderRadius: "4px",
|
borderRadius: "4px",
|
||||||
minHeight: layout.screenSize === "large" ? "600px" : "400px",
|
minHeight: layout.screenSize === "large" ? "600px" : "400px",
|
||||||
alignContent: "flex-start",
|
|
||||||
}}
|
}}
|
||||||
|
gap={2}
|
||||||
|
columns={`repeat(${layout.tileGridColumns}, 1fr)`}
|
||||||
>
|
>
|
||||||
{dice.map((dice) => (
|
{dice.map((dice) => (
|
||||||
<DiceTile
|
<DiceTile
|
||||||
@ -34,7 +32,7 @@ function DiceTiles({ dice, onDiceSelect, selectedDice, onDone }) {
|
|||||||
size={layout.tileSize}
|
size={layout.tileSize}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Flex>
|
</Grid>
|
||||||
</SimpleBar>
|
</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 React, { useEffect, useRef, useState } from "react";
|
||||||
import { Box, IconButton } from "theme-ui";
|
import { Box, IconButton } from "theme-ui";
|
||||||
|
|
||||||
import RemoveTokenIcon from "../icons/RemoveTokenIcon";
|
import RemoveTokenIcon from "../../icons/RemoveTokenIcon";
|
||||||
|
|
||||||
function DragOverlay({ dragging, node, onRemove }) {
|
function DragOverlay({ dragging, node, onRemove }) {
|
||||||
const [isRemoveHovered, setIsRemoveHovered] = useState(false);
|
const [isRemoveHovered, setIsRemoveHovered] = useState(false);
|
@ -1,4 +1,6 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
import { Box } from "theme-ui";
|
||||||
|
import { useToasts } from "react-toast-notifications";
|
||||||
|
|
||||||
import MapControls from "./MapControls";
|
import MapControls from "./MapControls";
|
||||||
import MapInteraction from "./MapInteraction";
|
import MapInteraction from "./MapInteraction";
|
||||||
@ -141,6 +143,8 @@ function Map({
|
|||||||
disabledTokens: any,
|
disabledTokens: any,
|
||||||
session: Session
|
session: Session
|
||||||
}) {
|
}) {
|
||||||
|
const { addToast } = useToasts();
|
||||||
|
|
||||||
const { tokensById } = useTokenData();
|
const { tokensById } = useTokenData();
|
||||||
|
|
||||||
const [selectedToolId, setSelectedToolId] = useState("move");
|
const [selectedToolId, setSelectedToolId] = useState("move");
|
||||||
@ -324,6 +328,7 @@ function Map({
|
|||||||
onShapesCut={handleFogShapesCut}
|
onShapesCut={handleFogShapesCut}
|
||||||
onShapesRemove={handleFogShapesRemove}
|
onShapesRemove={handleFogShapesRemove}
|
||||||
onShapesEdit={handleFogShapesEdit}
|
onShapesEdit={handleFogShapesEdit}
|
||||||
|
onShapeError={addToast}
|
||||||
active={selectedToolId === "fog"}
|
active={selectedToolId === "fog"}
|
||||||
toolSettings={settings.fog}
|
toolSettings={settings.fog}
|
||||||
editable={allowFogDrawing && !settings.fog.preview}
|
editable={allowFogDrawing && !settings.fog.preview}
|
||||||
@ -427,30 +432,32 @@ function Map({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MapInteraction
|
<Box sx={{ flexGrow: 1 }}>
|
||||||
map={map}
|
<MapInteraction
|
||||||
mapState={mapState}
|
map={map}
|
||||||
controls={
|
mapState={mapState}
|
||||||
<>
|
controls={
|
||||||
{mapControls}
|
<>
|
||||||
{tokenMenu}
|
{mapControls}
|
||||||
{noteMenu}
|
{tokenMenu}
|
||||||
{tokenDragOverlay}
|
{noteMenu}
|
||||||
{noteDragOverlay}
|
{tokenDragOverlay}
|
||||||
</>
|
{noteDragOverlay}
|
||||||
}
|
</>
|
||||||
selectedToolId={selectedToolId}
|
}
|
||||||
onSelectedToolChange={setSelectedToolId}
|
selectedToolId={selectedToolId}
|
||||||
disabledControls={disabledControls}
|
onSelectedToolChange={setSelectedToolId}
|
||||||
>
|
disabledControls={disabledControls}
|
||||||
{mapGrid}
|
>
|
||||||
{mapDrawing}
|
{mapGrid}
|
||||||
{mapNotes}
|
{mapDrawing}
|
||||||
{mapTokens}
|
{mapNotes}
|
||||||
{mapFog}
|
{mapTokens}
|
||||||
{mapPointer}
|
{mapFog}
|
||||||
{mapMeasure}
|
{mapPointer}
|
||||||
</MapInteraction>
|
{mapMeasure}
|
||||||
|
</MapInteraction>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -119,8 +119,7 @@ function MapDrawing({
|
|||||||
}
|
}
|
||||||
const simplified = simplifyPoints(
|
const simplified = simplifyPoints(
|
||||||
[...prevPoints, brushPosition],
|
[...prevPoints, brushPosition],
|
||||||
gridCellNormalizedSize,
|
1 / 1000 / stageScale
|
||||||
stageScale
|
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
...prevShape,
|
...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";
|
import MapGridEditor from "./MapGridEditor";
|
||||||
|
|
||||||
function MapEditor({ map, onSettingsChange }) {
|
function MapEditor({ map, onSettingsChange }) {
|
||||||
const [mapImageSource] = useMapImage(map);
|
const [mapImage] = useMapImage(map);
|
||||||
|
|
||||||
const [stageWidth, setStageWidth] = useState(1);
|
const [stageWidth, setStageWidth] = useState(1);
|
||||||
const [stageHeight, setStageHeight] = useState(1);
|
const [stageHeight, setStageHeight] = useState(1);
|
||||||
@ -93,14 +93,14 @@ function MapEditor({ map, onSettingsChange }) {
|
|||||||
interactionEmitter: null,
|
interactionEmitter: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const canEditGrid = map.type !== "default";
|
|
||||||
|
|
||||||
const gridChanged =
|
const gridChanged =
|
||||||
map.grid.inset.topLeft.x !== defaultInset.topLeft.x ||
|
map.grid.inset.topLeft.x !== defaultInset.topLeft.x ||
|
||||||
map.grid.inset.topLeft.y !== defaultInset.topLeft.y ||
|
map.grid.inset.topLeft.y !== defaultInset.topLeft.y ||
|
||||||
map.grid.inset.bottomRight.x !== defaultInset.bottomRight.x ||
|
map.grid.inset.bottomRight.x !== defaultInset.bottomRight.x ||
|
||||||
map.grid.inset.bottomRight.y !== defaultInset.bottomRight.y;
|
map.grid.inset.bottomRight.y !== defaultInset.bottomRight.y;
|
||||||
|
|
||||||
|
const gridValid = map.grid.size.x !== 0 && map.grid.size.y !== 0;
|
||||||
|
|
||||||
const layout = useResponsiveLayout();
|
const layout = useResponsiveLayout();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -132,12 +132,8 @@ function MapEditor({ map, onSettingsChange }) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Layer ref={mapLayerRef}>
|
<Layer ref={mapLayerRef}>
|
||||||
<Image
|
<Image image={mapImage} width={mapWidth} height={mapHeight} />
|
||||||
image={mapImageSource}
|
{showGridControls && gridValid && (
|
||||||
width={mapWidth}
|
|
||||||
height={mapHeight}
|
|
||||||
/>
|
|
||||||
{showGridControls && canEditGrid && (
|
|
||||||
<>
|
<>
|
||||||
<MapGrid map={map} />
|
<MapGrid map={map} />
|
||||||
<MapGridEditor map={map} onGridChange={handleGridChange} />
|
<MapGridEditor map={map} onGridChange={handleGridChange} />
|
||||||
@ -146,7 +142,7 @@ function MapEditor({ map, onSettingsChange }) {
|
|||||||
</Layer>
|
</Layer>
|
||||||
</KonvaBridge>
|
</KonvaBridge>
|
||||||
</ReactResizeDetector>
|
</ReactResizeDetector>
|
||||||
{gridChanged && (
|
{gridChanged && gridValid && (
|
||||||
<IconButton
|
<IconButton
|
||||||
title="Reset Grid"
|
title="Reset Grid"
|
||||||
aria-label="Reset Grid"
|
aria-label="Reset Grid"
|
||||||
@ -163,28 +159,26 @@ function MapEditor({ map, onSettingsChange }) {
|
|||||||
<ResetMapIcon />
|
<ResetMapIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
{canEditGrid && (
|
<IconButton
|
||||||
<IconButton
|
title={
|
||||||
title={
|
showGridControls ? "Hide Grid Controls" : "Show Grid Controls"
|
||||||
showGridControls ? "Hide Grid Controls" : "Show Grid Controls"
|
}
|
||||||
}
|
aria-label={
|
||||||
aria-label={
|
showGridControls ? "Hide Grid Controls" : "Show Grid Controls"
|
||||||
showGridControls ? "Hide Grid Controls" : "Show Grid Controls"
|
}
|
||||||
}
|
onClick={() => setShowGridControls(!showGridControls)}
|
||||||
onClick={() => setShowGridControls(!showGridControls)}
|
bg="overlay"
|
||||||
bg="overlay"
|
sx={{
|
||||||
sx={{
|
borderRadius: "50%",
|
||||||
borderRadius: "50%",
|
position: "absolute",
|
||||||
position: "absolute",
|
bottom: 0,
|
||||||
bottom: 0,
|
right: 0,
|
||||||
right: 0,
|
}}
|
||||||
}}
|
m={2}
|
||||||
m={2}
|
p="6px"
|
||||||
p="6px"
|
>
|
||||||
>
|
{showGridControls ? <GridOnIcon /> : <GridOffIcon />}
|
||||||
{showGridControls ? <GridOnIcon /> : <GridOffIcon />}
|
</IconButton>
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
</GridProvider>
|
</GridProvider>
|
||||||
</MapInteractionProvider>
|
</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 shortid from "shortid";
|
||||||
import { Group, Rect, Line } from "react-konva";
|
import { Group, Line } from "react-konva";
|
||||||
import useImage from "use-image";
|
import useImage from "use-image";
|
||||||
|
import Color from "color";
|
||||||
|
|
||||||
import diagonalPattern from "../../images/DiagonalPattern.png";
|
import diagonalPattern from "../../images/DiagonalPattern.png";
|
||||||
|
|
||||||
@ -37,8 +38,10 @@ import {
|
|||||||
Tick,
|
Tick,
|
||||||
getRelativePointerPosition,
|
getRelativePointerPosition,
|
||||||
} from "../../helpers/konva";
|
} from "../../helpers/konva";
|
||||||
|
import { keyBy } from "../../helpers/shared";
|
||||||
|
|
||||||
import SubtractShapeAction from "../../actions/SubtractShapeAction";
|
import SubtractShapeAction from "../../actions/SubtractShapeAction";
|
||||||
|
import CutShapeAction from "../../actions/CutShapeAction";
|
||||||
|
|
||||||
import useSetting from "../../hooks/useSetting";
|
import useSetting from "../../hooks/useSetting";
|
||||||
|
|
||||||
@ -51,6 +54,7 @@ function MapFog({
|
|||||||
onShapesCut,
|
onShapesCut,
|
||||||
onShapesRemove,
|
onShapesRemove,
|
||||||
onShapesEdit,
|
onShapesEdit,
|
||||||
|
onShapeError,
|
||||||
active,
|
active,
|
||||||
toolSettings,
|
toolSettings,
|
||||||
editable,
|
editable,
|
||||||
@ -175,8 +179,7 @@ function MapFog({
|
|||||||
}
|
}
|
||||||
const simplified = simplifyPoints(
|
const simplified = simplifyPoints(
|
||||||
[...prevPoints, brushPosition],
|
[...prevPoints, brushPosition],
|
||||||
gridCellNormalizedSize,
|
1 / 1000 / stageScale
|
||||||
stageScale / 4
|
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
...prevShape,
|
...prevShape,
|
||||||
@ -214,6 +217,8 @@ function MapFog({
|
|||||||
) {
|
) {
|
||||||
const cut = toolSettings.useFogCut;
|
const cut = toolSettings.useFogCut;
|
||||||
let drawingShapes = [drawingShape];
|
let drawingShapes = [drawingShape];
|
||||||
|
|
||||||
|
// Filter out hidden or visible shapes if single layer enabled
|
||||||
if (!toolSettings.multilayer) {
|
if (!toolSettings.multilayer) {
|
||||||
const shapesToSubtract = shapes.filter((shape) =>
|
const shapesToSubtract = shapes.filter((shape) =>
|
||||||
cut ? !shape.visible : shape.visible
|
cut ? !shape.visible : shape.visible
|
||||||
@ -228,22 +233,32 @@ function MapFog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (drawingShapes.length > 0) {
|
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) {
|
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 {
|
} else {
|
||||||
onShapesAdd(drawingShapes);
|
onShapesAdd(
|
||||||
|
drawingShapes.map((shape) => ({ ...shape, color: "black" }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (cut) {
|
||||||
|
onShapeError("Fog already cut");
|
||||||
|
} else {
|
||||||
|
onShapeError("Fog already placed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setDrawingShape(null);
|
setDrawingShape(null);
|
||||||
@ -373,6 +388,7 @@ function MapFog({
|
|||||||
};
|
};
|
||||||
|
|
||||||
let polygonShapes = [polygonShape];
|
let polygonShapes = [polygonShape];
|
||||||
|
// Filter out hidden or visible shapes if single layer enabled
|
||||||
if (!toolSettings.multilayer) {
|
if (!toolSettings.multilayer) {
|
||||||
const shapesToSubtract = shapes.filter((shape) =>
|
const shapesToSubtract = shapes.filter((shape) =>
|
||||||
cut ? !shape.visible : shape.visible
|
cut ? !shape.visible : shape.visible
|
||||||
@ -388,7 +404,15 @@ function MapFog({
|
|||||||
|
|
||||||
if (polygonShapes.length > 0) {
|
if (polygonShapes.length > 0) {
|
||||||
if (cut) {
|
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 {
|
} else {
|
||||||
onShapesAdd(
|
onShapesAdd(
|
||||||
polygonShapes.map((shape) => ({
|
polygonShapes.map((shape) => ({
|
||||||
@ -399,10 +423,23 @@ function MapFog({
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
if (cut) {
|
||||||
|
onShapeError("Fog already cut");
|
||||||
|
} else {
|
||||||
|
onShapeError("Fog already placed");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setDrawingShape(null);
|
setDrawingShape(null);
|
||||||
}, [toolSettings, drawingShape, onShapesCut, onShapesAdd, shapes]);
|
}, [
|
||||||
|
toolSettings,
|
||||||
|
drawingShape,
|
||||||
|
onShapesCut,
|
||||||
|
onShapesAdd,
|
||||||
|
onShapeError,
|
||||||
|
shapes,
|
||||||
|
]);
|
||||||
|
|
||||||
// Add keyboard shortcuts
|
// Add keyboard shortcuts
|
||||||
function handleKeyDown(event) {
|
function handleKeyDown(event) {
|
||||||
@ -489,6 +526,15 @@ function MapFog({
|
|||||||
const holes =
|
const holes =
|
||||||
shape.data.holes &&
|
shape.data.holes &&
|
||||||
shape.data.holes.map((hole) => hole.reduce(reducePoints, []));
|
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 (
|
return (
|
||||||
<HoleyLine
|
<HoleyLine
|
||||||
key={shape.id}
|
key={shape.id}
|
||||||
@ -499,19 +545,12 @@ function MapFog({
|
|||||||
onMouseUp={eraseHoveredShapes}
|
onMouseUp={eraseHoveredShapes}
|
||||||
onTouchEnd={eraseHoveredShapes}
|
onTouchEnd={eraseHoveredShapes}
|
||||||
points={points}
|
points={points}
|
||||||
stroke={
|
stroke={stroke}
|
||||||
editable && active
|
fill={fill}
|
||||||
? colors.lightGray
|
|
||||||
: colors[shape.color] || shape.color
|
|
||||||
}
|
|
||||||
fill={colors[shape.color] || shape.color}
|
|
||||||
closed
|
closed
|
||||||
lineCap="round"
|
lineCap="round"
|
||||||
lineJoin="round"
|
lineJoin="round"
|
||||||
strokeWidth={gridStrokeWidth * shape.strokeWidth}
|
strokeWidth={gridStrokeWidth * shape.strokeWidth}
|
||||||
opacity={
|
|
||||||
editable ? (!shape.visible ? editOpacity / 2 : editOpacity) : 1
|
|
||||||
}
|
|
||||||
fillPatternImage={patternImage}
|
fillPatternImage={patternImage}
|
||||||
fillPriority={editable && !shape.visible ? "pattern" : "color"}
|
fillPriority={editable && !shape.visible ? "pattern" : "color"}
|
||||||
holes={holes}
|
holes={holes}
|
||||||
@ -590,15 +629,9 @@ function MapFog({
|
|||||||
}
|
}
|
||||||
}, [shapes, editable, active, toolSettings, shouldRenderGuides]);
|
}, [shapes, editable, active, toolSettings, shouldRenderGuides]);
|
||||||
|
|
||||||
const fogGroupRef = useRef();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group>
|
<Group>
|
||||||
<Group ref={fogGroupRef}>
|
<Group>{fogShapes.map(renderShape)}</Group>
|
||||||
{/* Render a blank shape so cache works with no fog shapes */}
|
|
||||||
<Rect width={1} height={1} />
|
|
||||||
{fogShapes.map(renderShape)}
|
|
||||||
</Group>
|
|
||||||
{shouldRenderGuides && renderGuides()}
|
{shouldRenderGuides && renderGuides()}
|
||||||
{drawingShape && renderShape(drawingShape)}
|
{drawingShape && renderShape(drawingShape)}
|
||||||
{drawingShape &&
|
{drawingShape &&
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import useImage from "use-image";
|
import useImage from "use-image";
|
||||||
|
|
||||||
import { useImageSource } from "../../contexts/ImageSourceContext";
|
import { useDataURL } from "../../contexts/AssetsContext";
|
||||||
|
|
||||||
import { mapSources as defaultMapSources } from "../../maps";
|
import { mapSources as defaultMapSources } from "../../maps";
|
||||||
|
|
||||||
@ -11,15 +11,13 @@ import Grid from "../Grid";
|
|||||||
|
|
||||||
function MapGrid({ map }) {
|
function MapGrid({ map }) {
|
||||||
let mapSourceMap = map;
|
let mapSourceMap = map;
|
||||||
// Use lowest resolution for grid lightness
|
const mapURL = useDataURL(
|
||||||
if (map && map.type === "file" && map.resolutions) {
|
mapSourceMap,
|
||||||
const resolutionArray = Object.keys(map.resolutions);
|
defaultMapSources,
|
||||||
if (resolutionArray.length > 0) {
|
undefined,
|
||||||
mapSourceMap = map.resolutions[resolutionArray[0]];
|
map.type === "file"
|
||||||
}
|
);
|
||||||
}
|
const [mapImage, mapLoadingStatus] = useImage(mapURL);
|
||||||
const mapSource = useImageSource(mapSourceMap, defaultMapSources);
|
|
||||||
const [mapImage, mapLoadingStatus] = useImage(mapSource);
|
|
||||||
|
|
||||||
const [isImageLight, setIsImageLight] = useState(true);
|
const [isImageLight, setIsImageLight] = useState(true);
|
||||||
|
|
||||||
|
@ -77,7 +77,10 @@ function MapGridEditor({ map, onGridChange }) {
|
|||||||
Vector2.subtract(position, previousPosition)
|
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) {
|
if (direction.x === 0 && direction.y === 0) {
|
||||||
return inset;
|
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,
|
onSelectedToolChange,
|
||||||
disabledControls,
|
disabledControls,
|
||||||
}) {
|
}) {
|
||||||
const [mapImageSource, mapImageSourceStatus] = useMapImage(map);
|
const [mapImage, mapImageStatus] = useMapImage(map);
|
||||||
|
|
||||||
// Map loaded taking in to account different resolutions
|
|
||||||
const [mapLoaded, setMapLoaded] = useState(false);
|
const [mapLoaded, setMapLoaded] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (!map || !mapState || mapState.mapId !== map.id) {
|
||||||
!map ||
|
|
||||||
!mapState ||
|
|
||||||
(map.type === "file" && !map.file && !map.resolutions) ||
|
|
||||||
mapState.mapId !== map.id
|
|
||||||
) {
|
|
||||||
setMapLoaded(false);
|
setMapLoaded(false);
|
||||||
} else if (mapImageSourceStatus === "loaded") {
|
} else if (mapImageStatus === "loaded") {
|
||||||
setMapLoaded(true);
|
setMapLoaded(true);
|
||||||
}
|
}
|
||||||
}, [mapImageSourceStatus, map, mapState]);
|
}, [mapImageStatus, map, mapState]);
|
||||||
|
|
||||||
const [stageWidth, setStageWidth] = useState(1);
|
const [stageWidth, setStageWidth] = useState(1);
|
||||||
const [stageHeight, setStageHeight] = useState(1);
|
const [stageHeight, setStageHeight] = useState(1);
|
||||||
@ -187,11 +181,12 @@ function MapInteraction({
|
|||||||
<GridProvider grid={map?.grid} width={mapWidth} height={mapHeight}>
|
<GridProvider grid={map?.grid} width={mapWidth} height={mapHeight}>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
flexGrow: 1,
|
|
||||||
position: "relative",
|
position: "relative",
|
||||||
cursor: getCursorForTool(selectedToolId),
|
cursor: getCursorForTool(selectedToolId),
|
||||||
touchAction: "none",
|
touchAction: "none",
|
||||||
outline: "none",
|
outline: "none",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
}}
|
}}
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="map"
|
className="map"
|
||||||
@ -211,7 +206,7 @@ function MapInteraction({
|
|||||||
>
|
>
|
||||||
<Layer ref={mapLayerRef}>
|
<Layer ref={mapLayerRef}>
|
||||||
<Image
|
<Image
|
||||||
image={mapLoaded && mapImageSource}
|
image={mapLoaded && mapImage}
|
||||||
width={mapWidth}
|
width={mapWidth}
|
||||||
height={mapHeight}
|
height={mapHeight}
|
||||||
id="mapImage"
|
id="mapImage"
|
||||||
|
@ -44,7 +44,10 @@ function MapMeasure({ map, active }) {
|
|||||||
|
|
||||||
const gridScale = parseGridScale(active && grid.measurement.scale);
|
const gridScale = parseGridScale(active && grid.measurement.scale);
|
||||||
|
|
||||||
const snapPositionToGrid = useGridSnapping();
|
const snapPositionToGrid = useGridSnapping(
|
||||||
|
grid.measurement.type === "euclidean" ? 0 : 1,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!active) {
|
if (!active) {
|
||||||
|
@ -4,7 +4,7 @@ import { Group } from "react-konva";
|
|||||||
|
|
||||||
import { useInteractionEmitter } from "../../contexts/MapInteractionContext";
|
import { useInteractionEmitter } from "../../contexts/MapInteractionContext";
|
||||||
import { useMapStage } from "../../contexts/MapStageContext";
|
import { useMapStage } from "../../contexts/MapStageContext";
|
||||||
import { useAuth } from "../../contexts/AuthContext";
|
import { useUserId } from "../../contexts/UserIdContext";
|
||||||
|
|
||||||
import Vector2 from "../../helpers/Vector2";
|
import Vector2 from "../../helpers/Vector2";
|
||||||
import { getRelativePointerPosition } from "../../helpers/konva";
|
import { getRelativePointerPosition } from "../../helpers/konva";
|
||||||
@ -28,7 +28,7 @@ function MapNotes({
|
|||||||
fadeOnHover,
|
fadeOnHover,
|
||||||
}) {
|
}) {
|
||||||
const interactionEmitter = useInteractionEmitter();
|
const interactionEmitter = useInteractionEmitter();
|
||||||
const { userId } = useAuth();
|
const userId = useUserId();
|
||||||
const mapStageRef = useMapStage();
|
const mapStageRef = useMapStage();
|
||||||
const [isBrushDown, setIsBrushDown] = useState(false);
|
const [isBrushDown, setIsBrushDown] = useState(false);
|
||||||
const [noteData, setNoteData] = useState(null);
|
const [noteData, setNoteData] = useState(null);
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
import React from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { Flex, Box, Label, Input, Checkbox, IconButton } from "theme-ui";
|
import { Flex, Box, Label, Input, Checkbox } from "theme-ui";
|
||||||
|
|
||||||
import ExpandMoreIcon from "../../icons/ExpandMoreIcon";
|
|
||||||
|
|
||||||
import { isEmpty } from "../../helpers/shared";
|
import { isEmpty } from "../../helpers/shared";
|
||||||
import { getGridUpdatedInset } from "../../helpers/grid";
|
import { getGridUpdatedInset } from "../../helpers/grid";
|
||||||
|
|
||||||
|
import { useDataURL } from "../../contexts/AssetsContext";
|
||||||
|
import { mapSources as defaultMapSources } from "../../maps";
|
||||||
|
|
||||||
import Divider from "../Divider";
|
import Divider from "../Divider";
|
||||||
import Select from "../Select";
|
import Select from "../Select";
|
||||||
|
|
||||||
@ -40,8 +41,6 @@ function MapSettings({
|
|||||||
mapState,
|
mapState,
|
||||||
onSettingsChange,
|
onSettingsChange,
|
||||||
onStateSettingsChange,
|
onStateSettingsChange,
|
||||||
showMore,
|
|
||||||
onShowMoreChange,
|
|
||||||
}) {
|
}) {
|
||||||
function handleFlagChange(event, flag) {
|
function handleFlagChange(event, flag) {
|
||||||
if (event.target.checked) {
|
if (event.target.checked) {
|
||||||
@ -116,16 +115,22 @@ function MapSettings({
|
|||||||
onSettingsChange("grid", grid);
|
onSettingsChange("grid", grid);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMapSize() {
|
const mapURL = useDataURL(map, defaultMapSources);
|
||||||
let size = 0;
|
const [mapSize, setMapSize] = useState(0);
|
||||||
if (map.quality === "original") {
|
useEffect(() => {
|
||||||
size = map.file.length;
|
async function updateMapSize() {
|
||||||
} else {
|
if (mapURL) {
|
||||||
size = map.resolutions[map.quality].file.length;
|
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
|
updateMapSize();
|
||||||
return `${size.toFixed(2)}MB`;
|
}, [mapURL]);
|
||||||
}
|
|
||||||
|
|
||||||
const mapEmpty = !map || isEmpty(map);
|
const mapEmpty = !map || isEmpty(map);
|
||||||
const mapStateEmpty = !mapState || isEmpty(mapState);
|
const mapStateEmpty = !mapState || isEmpty(mapState);
|
||||||
@ -140,7 +145,7 @@ function MapSettings({
|
|||||||
name="gridX"
|
name="gridX"
|
||||||
value={`${(map && map.grid.size.x) || 0}`}
|
value={`${(map && map.grid.size.x) || 0}`}
|
||||||
onChange={handleGridSizeXChange}
|
onChange={handleGridSizeXChange}
|
||||||
disabled={mapEmpty || map.type === "default"}
|
disabled={mapEmpty}
|
||||||
min={1}
|
min={1}
|
||||||
my={1}
|
my={1}
|
||||||
/>
|
/>
|
||||||
@ -152,7 +157,7 @@ function MapSettings({
|
|||||||
name="gridY"
|
name="gridY"
|
||||||
value={`${(map && map.grid.size.y) || 0}`}
|
value={`${(map && map.grid.size.y) || 0}`}
|
||||||
onChange={handleGridSizeYChange}
|
onChange={handleGridSizeYChange}
|
||||||
disabled={mapEmpty || map.type === "default"}
|
disabled={mapEmpty}
|
||||||
min={1}
|
min={1}
|
||||||
my={1}
|
my={1}
|
||||||
/>
|
/>
|
||||||
@ -164,176 +169,146 @@ function MapSettings({
|
|||||||
name="name"
|
name="name"
|
||||||
value={(map && map.name) || ""}
|
value={(map && map.name) || ""}
|
||||||
onChange={(e) => onSettingsChange("name", e.target.value)}
|
onChange={(e) => onSettingsChange("name", e.target.value)}
|
||||||
disabled={mapEmpty || map.type === "default"}
|
disabled={mapEmpty}
|
||||||
my={1}
|
my={1}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
{showMore && (
|
<Flex
|
||||||
<>
|
mt={2}
|
||||||
<Flex
|
mb={mapEmpty || map.type === "default" ? 2 : 0}
|
||||||
mt={2}
|
sx={{ flexDirection: "column" }}
|
||||||
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"}
|
|
||||||
>
|
>
|
||||||
<ExpandMoreIcon />
|
<Flex sx={{ alignItems: "flex-end" }}>
|
||||||
</IconButton>
|
<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>
|
</Flex>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,5 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
function MapTest() {}
|
|
||||||
|
|
||||||
export default MapTest;
|
|
@ -1,40 +1,30 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import Tile from "../Tile";
|
import Tile from "../tile/Tile";
|
||||||
|
import MapImage from "./MapImage";
|
||||||
import { useImageSource } from "../../contexts/ImageSourceContext";
|
|
||||||
import { mapSources as defaultMapSources, unknownSource } from "../../maps";
|
|
||||||
|
|
||||||
function MapTile({
|
function MapTile({
|
||||||
map,
|
map,
|
||||||
isSelected,
|
isSelected,
|
||||||
onMapSelect,
|
onSelect,
|
||||||
onMapEdit,
|
onEdit,
|
||||||
onDone,
|
onDoubleClick,
|
||||||
size,
|
|
||||||
canEdit,
|
canEdit,
|
||||||
badges,
|
badges,
|
||||||
}) {
|
}) {
|
||||||
const mapSource = useImageSource(
|
|
||||||
map,
|
|
||||||
defaultMapSources,
|
|
||||||
unknownSource,
|
|
||||||
map.type === "file"
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tile
|
<Tile
|
||||||
src={mapSource}
|
|
||||||
title={map.name}
|
title={map.name}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
onSelect={() => onMapSelect(map)}
|
onSelect={() => onSelect(map.id)}
|
||||||
onEdit={() => onMapEdit(map.id)}
|
onEdit={() => onEdit(map.id)}
|
||||||
onDoubleClick={() => canEdit && onDone()}
|
onDoubleClick={() => canEdit && onDoubleClick()}
|
||||||
size={size}
|
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
badges={badges}
|
badges={badges}
|
||||||
editTitle="Edit Map"
|
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 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 MapTile from "./MapTile";
|
||||||
import Link from "../Link";
|
import MapTileGroup from "./MapTileGroup";
|
||||||
import FilterBar from "../FilterBar";
|
|
||||||
|
|
||||||
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({
|
import { useGroup } from "../../contexts/GroupContext";
|
||||||
maps,
|
|
||||||
groups,
|
|
||||||
selectedMaps,
|
|
||||||
selectedMapStates,
|
|
||||||
onMapSelect,
|
|
||||||
onMapsRemove,
|
|
||||||
onMapsReset,
|
|
||||||
onMapAdd,
|
|
||||||
onMapEdit,
|
|
||||||
onDone,
|
|
||||||
selectMode,
|
|
||||||
onSelectModeChange,
|
|
||||||
search,
|
|
||||||
onSearchChange,
|
|
||||||
onMapsGroup,
|
|
||||||
}) {
|
|
||||||
const { databaseStatus } = useDatabase();
|
|
||||||
const layout = useResponsiveLayout();
|
|
||||||
|
|
||||||
let hasMapState = false;
|
function MapTiles({ mapsById, onMapEdit, onMapSelect, subgroup }) {
|
||||||
for (let state of selectedMapStates) {
|
const {
|
||||||
if (
|
selectedGroupIds,
|
||||||
Object.values(state.tokens).length > 0 ||
|
selectMode,
|
||||||
Object.values(state.drawShapes).length > 0 ||
|
onGroupOpen,
|
||||||
Object.values(state.fogShapes).length > 0 ||
|
onGroupSelect,
|
||||||
Object.values(state.notes).length > 0
|
} = useGroup();
|
||||||
) {
|
|
||||||
hasMapState = true;
|
function renderTile(group) {
|
||||||
break;
|
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 (
|
return (
|
||||||
<Box sx={{ position: "relative" }}>
|
<>
|
||||||
<FilterBar
|
<SortableTiles renderTile={renderTile} subgroup={subgroup} />
|
||||||
onFocus={() => onMapSelect()}
|
<SortableTilesDragOverlay renderTile={renderTile} subgroup={subgroup} />
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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 { Image as KonvaImage, Group } from "react-konva";
|
||||||
import { useSpring, animated } from "react-spring/konva";
|
import { useSpring, animated } from "react-spring/konva";
|
||||||
import useImage from "use-image";
|
import useImage from "use-image";
|
||||||
import Konva from "konva";
|
|
||||||
|
|
||||||
import useDebounce from "../../hooks/useDebounce";
|
|
||||||
import usePrevious from "../../hooks/usePrevious";
|
import usePrevious from "../../hooks/usePrevious";
|
||||||
import useGridSnapping from "../../hooks/useGridSnapping";
|
import useGridSnapping from "../../hooks/useGridSnapping";
|
||||||
|
|
||||||
import { useAuth } from "../../contexts/AuthContext";
|
import { useUserId } from "../../contexts/UserIdContext";
|
||||||
import {
|
import {
|
||||||
useSetPreventMapInteraction,
|
useSetPreventMapInteraction,
|
||||||
useMapWidth,
|
useMapWidth,
|
||||||
useMapHeight,
|
useMapHeight,
|
||||||
useDebouncedStageScale,
|
|
||||||
} from "../../contexts/MapInteractionContext";
|
} from "../../contexts/MapInteractionContext";
|
||||||
import { useGridCellPixelSize } from "../../contexts/GridContext";
|
import { useGridCellPixelSize } from "../../contexts/GridContext";
|
||||||
import { useImageSource } from "../../contexts/ImageSourceContext";
|
import { useDataURL } from "../../contexts/AssetsContext";
|
||||||
|
|
||||||
import TokenStatus from "../token/TokenStatus";
|
import TokenStatus from "../token/TokenStatus";
|
||||||
import TokenLabel from "../token/TokenLabel";
|
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({
|
function MapToken({
|
||||||
token,
|
|
||||||
tokenState,
|
tokenState,
|
||||||
onTokenStateChange,
|
onTokenStateChange,
|
||||||
onTokenMenuOpen,
|
onTokenMenuOpen,
|
||||||
@ -34,34 +33,31 @@ function MapToken({
|
|||||||
fadeOnHover,
|
fadeOnHover,
|
||||||
map,
|
map,
|
||||||
}) {
|
}) {
|
||||||
const { userId } = useAuth();
|
const userId = useUserId();
|
||||||
|
|
||||||
const stageScale = useDebouncedStageScale();
|
|
||||||
const mapWidth = useMapWidth();
|
const mapWidth = useMapWidth();
|
||||||
const mapHeight = useMapHeight();
|
const mapHeight = useMapHeight();
|
||||||
const setPreventMapInteraction = useSetPreventMapInteraction();
|
const setPreventMapInteraction = useSetPreventMapInteraction();
|
||||||
|
|
||||||
const gridCellPixelSize = useGridCellPixelSize();
|
const gridCellPixelSize = useGridCellPixelSize();
|
||||||
|
|
||||||
const tokenSource = useImageSource(token, tokenSources, unknownSource);
|
const tokenURL = useDataURL(tokenState, tokenSources);
|
||||||
const [tokenSourceImage, tokenSourceStatus] = useImage(tokenSource);
|
const [tokenImage] = useImage(tokenURL);
|
||||||
const [tokenAspectRatio, setTokenAspectRatio] = useState(1);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const tokenAspectRatio = tokenState.width / tokenState.height;
|
||||||
if (tokenSourceImage) {
|
|
||||||
setTokenAspectRatio(tokenSourceImage.width / tokenSourceImage.height);
|
|
||||||
}
|
|
||||||
}, [tokenSourceImage]);
|
|
||||||
|
|
||||||
const snapPositionToGrid = useGridSnapping();
|
const snapPositionToGrid = useGridSnapping();
|
||||||
|
|
||||||
function handleDragStart(event) {
|
function handleDragStart(event) {
|
||||||
const tokenGroup = event.target;
|
const tokenGroup = event.target;
|
||||||
const tokenImage = imageRef.current;
|
|
||||||
|
|
||||||
if (token && token.category === "vehicle") {
|
if (tokenState.category === "vehicle") {
|
||||||
// Enable hit detection for .intersects() function
|
const tokenIntersection = new Intersection(
|
||||||
Konva.hitOnDragEnabled = true;
|
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
|
// Find all other tokens on the map
|
||||||
const layer = tokenGroup.getLayer();
|
const layer = tokenGroup.getLayer();
|
||||||
@ -70,12 +66,7 @@ function MapToken({
|
|||||||
if (other === tokenGroup) {
|
if (other === tokenGroup) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const otherRect = other.getClientRect();
|
if (tokenIntersection.intersects(other.position())) {
|
||||||
const otherCenter = {
|
|
||||||
x: otherRect.x + otherRect.width / 2,
|
|
||||||
y: otherRect.y + otherRect.height / 2,
|
|
||||||
};
|
|
||||||
if (tokenImage.intersects(otherCenter)) {
|
|
||||||
// Save and restore token position after moving layer
|
// Save and restore token position after moving layer
|
||||||
const position = other.absolutePosition();
|
const position = other.absolutePosition();
|
||||||
other.moveTo(tokenGroup);
|
other.moveTo(tokenGroup);
|
||||||
@ -99,9 +90,7 @@ function MapToken({
|
|||||||
const tokenGroup = event.target;
|
const tokenGroup = event.target;
|
||||||
|
|
||||||
const mountChanges = {};
|
const mountChanges = {};
|
||||||
if (token && token.category === "vehicle") {
|
if (tokenState.category === "vehicle") {
|
||||||
Konva.hitOnDragEnabled = false;
|
|
||||||
|
|
||||||
const parent = tokenGroup.getParent();
|
const parent = tokenGroup.getParent();
|
||||||
const mountedTokens = tokenGroup.find(".character");
|
const mountedTokens = tokenGroup.find(".character");
|
||||||
for (let mountedToken of mountedTokens) {
|
for (let mountedToken of mountedTokens) {
|
||||||
@ -185,33 +174,6 @@ function MapToken({
|
|||||||
const tokenWidth = minCellSize * tokenState.size;
|
const tokenWidth = minCellSize * tokenState.size;
|
||||||
const tokenHeight = (minCellSize / tokenAspectRatio) * 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
|
// Animate to new token positions if edited by others
|
||||||
const tokenX = tokenState.x * mapWidth;
|
const tokenX = tokenState.x * mapWidth;
|
||||||
const tokenY = tokenState.y * mapHeight;
|
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
|
// Token name is used by on click to find whether a token is a vehicle or prop
|
||||||
let tokenName = "";
|
let tokenName = "";
|
||||||
if (token) {
|
if (tokenState) {
|
||||||
tokenName = token.category;
|
tokenName = tokenState.category;
|
||||||
}
|
}
|
||||||
if (tokenState && tokenState.locked) {
|
if (tokenState && tokenState.locked) {
|
||||||
tokenName = tokenName + "-locked";
|
tokenName = tokenName + "-locked";
|
||||||
@ -260,28 +222,46 @@ function MapToken({
|
|||||||
name={tokenName}
|
name={tokenName}
|
||||||
id={tokenState.id}
|
id={tokenState.id}
|
||||||
>
|
>
|
||||||
<KonvaImage
|
<Group
|
||||||
ref={imageRef}
|
|
||||||
width={tokenWidth}
|
width={tokenWidth}
|
||||||
height={tokenHeight}
|
height={tokenHeight}
|
||||||
x={0}
|
x={0}
|
||||||
y={0}
|
y={0}
|
||||||
image={tokenSourceImage}
|
|
||||||
rotation={tokenState.rotation}
|
rotation={tokenState.rotation}
|
||||||
offsetX={tokenWidth / 2}
|
offsetX={tokenWidth / 2}
|
||||||
offsetY={tokenHeight / 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}>
|
<Group offsetX={tokenWidth / 2} offsetY={tokenHeight / 2}>
|
||||||
<TokenStatus
|
{tokenState.statuses?.length > 0 ? (
|
||||||
tokenState={tokenState}
|
<TokenStatus
|
||||||
width={tokenWidth}
|
tokenState={tokenState}
|
||||||
height={tokenHeight}
|
width={tokenWidth}
|
||||||
/>
|
height={tokenHeight}
|
||||||
<TokenLabel
|
/>
|
||||||
tokenState={tokenState}
|
) : null}
|
||||||
width={tokenWidth}
|
{tokenState.label ? (
|
||||||
height={tokenHeight}
|
<TokenLabel
|
||||||
/>
|
tokenState={tokenState}
|
||||||
|
width={tokenWidth}
|
||||||
|
height={tokenHeight}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</Group>
|
</Group>
|
||||||
</animated.Group>
|
</animated.Group>
|
||||||
);
|
);
|
||||||
|
@ -1,10 +1,8 @@
|
|||||||
import React, { useEffect } from "react";
|
import React from "react";
|
||||||
import { Group } from "react-konva";
|
import { Group } from "react-konva";
|
||||||
|
|
||||||
import MapToken from "./MapToken";
|
import MapToken from "./MapToken";
|
||||||
|
|
||||||
import { useTokenData } from "../../contexts/TokenDataContext";
|
|
||||||
|
|
||||||
function MapTokens({
|
function MapTokens({
|
||||||
map,
|
map,
|
||||||
mapState,
|
mapState,
|
||||||
@ -15,31 +13,6 @@ function MapTokens({
|
|||||||
selectedToolId,
|
selectedToolId,
|
||||||
disabledTokens,
|
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) {
|
function getMapTokenCategoryWeight(category) {
|
||||||
switch (category) {
|
switch (category) {
|
||||||
case "character":
|
case "character":
|
||||||
@ -55,38 +28,28 @@ function MapTokens({
|
|||||||
|
|
||||||
// Sort so vehicles render below other tokens
|
// Sort so vehicles render below other tokens
|
||||||
function sortMapTokenStates(a, b, tokenDraggingOptions) {
|
function sortMapTokenStates(a, b, tokenDraggingOptions) {
|
||||||
const tokenA = tokensById[a.tokenId];
|
// If categories are different sort in order "prop", "vehicle", "character"
|
||||||
const tokenB = tokensById[b.tokenId];
|
if (b.category !== a.category) {
|
||||||
if (tokenA && tokenB) {
|
const aWeight = getMapTokenCategoryWeight(a.category);
|
||||||
// If categories are different sort in order "prop", "vehicle", "character"
|
const bWeight = getMapTokenCategoryWeight(b.category);
|
||||||
if (tokenB.category !== tokenA.category) {
|
return bWeight - aWeight;
|
||||||
const aWeight = getMapTokenCategoryWeight(tokenA.category);
|
} else if (
|
||||||
const bWeight = getMapTokenCategoryWeight(tokenB.category);
|
tokenDraggingOptions &&
|
||||||
return bWeight - aWeight;
|
tokenDraggingOptions.dragging &&
|
||||||
} else if (
|
tokenDraggingOptions.tokenState.id === a.id
|
||||||
tokenDraggingOptions &&
|
) {
|
||||||
tokenDraggingOptions.dragging &&
|
// If dragging token a move above
|
||||||
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) {
|
|
||||||
return 1;
|
return 1;
|
||||||
} else if (tokenB) {
|
} else if (
|
||||||
|
tokenDraggingOptions &&
|
||||||
|
tokenDraggingOptions.dragging &&
|
||||||
|
tokenDraggingOptions.tokenState.id === b.id
|
||||||
|
) {
|
||||||
|
// If dragging token b move above
|
||||||
return -1;
|
return -1;
|
||||||
} else {
|
} else {
|
||||||
return 0;
|
// Else sort so last modified is on top
|
||||||
|
return a.lastModified - b.lastModified;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -97,7 +60,6 @@ function MapTokens({
|
|||||||
.map((tokenState) => (
|
.map((tokenState) => (
|
||||||
<MapToken
|
<MapToken
|
||||||
key={tokenState.id}
|
key={tokenState.id}
|
||||||
token={tokensById[tokenState.tokenId]}
|
|
||||||
tokenState={tokenState}
|
tokenState={tokenState}
|
||||||
onTokenStateChange={onMapTokenStateChange}
|
onTokenStateChange={onMapTokenStateChange}
|
||||||
onTokenMenuOpen={handleTokenMenuOpen}
|
onTokenMenuOpen={handleTokenMenuOpen}
|
||||||
|
@ -5,7 +5,7 @@ import SelectMapModal from "../../modals/SelectMapModal";
|
|||||||
import SelectMapIcon from "../../icons/SelectMapIcon";
|
import SelectMapIcon from "../../icons/SelectMapIcon";
|
||||||
|
|
||||||
import { useMapData } from "../../contexts/MapDataContext";
|
import { useMapData } from "../../contexts/MapDataContext";
|
||||||
import { useAuth } from "../../contexts/AuthContext";
|
import { useUserId } from "../../contexts/UserIdContext";
|
||||||
|
|
||||||
function SelectMapButton({
|
function SelectMapButton({
|
||||||
onMapChange,
|
onMapChange,
|
||||||
@ -17,7 +17,7 @@ function SelectMapButton({
|
|||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
|
||||||
const { updateMapState } = useMapData();
|
const { updateMapState } = useMapData();
|
||||||
const { userId } = useAuth();
|
const userId = useUserId();
|
||||||
function openModal() {
|
function openModal() {
|
||||||
if (currentMapState && currentMap && currentMap.owner === userId) {
|
if (currentMapState && currentMap && currentMap.owner === userId) {
|
||||||
updateMapState(currentMapState.mapId, currentMapState);
|
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 { Rect, Text } from "react-konva";
|
||||||
import { useSpring, animated } from "react-spring/konva";
|
import { useSpring, animated } from "react-spring/konva";
|
||||||
|
|
||||||
import { useAuth } from "../../contexts/AuthContext";
|
import { useUserId } from "../../contexts/UserIdContext";
|
||||||
import {
|
import {
|
||||||
useSetPreventMapInteraction,
|
useSetPreventMapInteraction,
|
||||||
useMapWidth,
|
useMapWidth,
|
||||||
@ -15,7 +15,7 @@ import colors from "../../helpers/colors";
|
|||||||
import usePrevious from "../../hooks/usePrevious";
|
import usePrevious from "../../hooks/usePrevious";
|
||||||
import useGridSnapping from "../../hooks/useGridSnapping";
|
import useGridSnapping from "../../hooks/useGridSnapping";
|
||||||
|
|
||||||
const minTextSize = 16;
|
const defaultFontSize = 16;
|
||||||
|
|
||||||
function Note({
|
function Note({
|
||||||
note,
|
note,
|
||||||
@ -27,7 +27,7 @@ function Note({
|
|||||||
onNoteDragEnd,
|
onNoteDragEnd,
|
||||||
fadeOnHover,
|
fadeOnHover,
|
||||||
}) {
|
}) {
|
||||||
const { userId } = useAuth();
|
const userId = useUserId();
|
||||||
|
|
||||||
const mapWidth = useMapWidth();
|
const mapWidth = useMapWidth();
|
||||||
const mapHeight = useMapHeight();
|
const mapHeight = useMapHeight();
|
||||||
@ -118,7 +118,7 @@ function Note({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [fontSize, setFontSize] = useState(1);
|
const [fontScale, setFontScale] = useState(1);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const text = textRef.current;
|
const text = textRef.current;
|
||||||
|
|
||||||
@ -127,10 +127,10 @@ function Note({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function findFontSize() {
|
function findFontSize() {
|
||||||
// Create an array from 1 / minTextSize of the note height to the full note height
|
// Create an array from 1 / defaultFontSize of the note height to the full note height
|
||||||
const sizes = Array.from(
|
let sizes = Array.from(
|
||||||
{ length: Math.ceil(noteHeight - notePadding * 2) },
|
{ length: Math.ceil(noteHeight - notePadding * 2) },
|
||||||
(_, i) => i + Math.ceil(noteHeight / minTextSize)
|
(_, i) => i + Math.ceil(noteHeight / defaultFontSize)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (sizes.length > 0) {
|
if (sizes.length > 0) {
|
||||||
@ -144,8 +144,7 @@ function Note({
|
|||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
setFontScale(size / defaultFontSize);
|
||||||
setFontSize(size);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -215,11 +214,14 @@ function Note({
|
|||||||
}
|
}
|
||||||
align="left"
|
align="left"
|
||||||
verticalAlign="middle"
|
verticalAlign="middle"
|
||||||
padding={notePadding}
|
padding={notePadding / fontScale}
|
||||||
fontSize={fontSize}
|
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"
|
wrap="word"
|
||||||
width={noteWidth}
|
|
||||||
height={note.textOnly ? undefined : noteHeight}
|
|
||||||
/>
|
/>
|
||||||
{/* Use an invisible text block to work out text sizing */}
|
{/* Use an invisible text block to work out text sizing */}
|
||||||
<Text visible={false} ref={textRef} text={note.text} wrap="none" />
|
<Text visible={false} ref={textRef} text={note.text} wrap="none" />
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import DragOverlay from "../DragOverlay";
|
import DragOverlay from "../map/DragOverlay";
|
||||||
|
|
||||||
function NoteDragOverlay({ onNoteRemove, noteId, noteGroup, dragging }) {
|
function NoteDragOverlay({ onNoteRemove, noteId, noteGroup, dragging }) {
|
||||||
function handleNoteRemove() {
|
function handleNoteRemove() {
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
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 Slider from "../Slider";
|
||||||
|
import TextareaAutosize from "../TextareaAutoSize";
|
||||||
|
|
||||||
import MapMenu from "../map/MapMenu";
|
import MapMenu from "../map/MapMenu";
|
||||||
|
|
||||||
@ -16,7 +17,7 @@ import HideIcon from "../../icons/TokenHideIcon";
|
|||||||
import NoteIcon from "../../icons/NoteToolIcon";
|
import NoteIcon from "../../icons/NoteToolIcon";
|
||||||
import TextIcon from "../../icons/NoteTextIcon";
|
import TextIcon from "../../icons/NoteTextIcon";
|
||||||
|
|
||||||
import { useAuth } from "../../contexts/AuthContext";
|
import { useUserId } from "../../contexts/UserIdContext";
|
||||||
|
|
||||||
const defaultNoteMaxSize = 6;
|
const defaultNoteMaxSize = 6;
|
||||||
|
|
||||||
@ -28,7 +29,7 @@ function NoteMenu({
|
|||||||
onNoteChange,
|
onNoteChange,
|
||||||
map,
|
map,
|
||||||
}) {
|
}) {
|
||||||
const { userId } = useAuth();
|
const userId = useUserId();
|
||||||
|
|
||||||
const wasOpen = usePrevious(isOpen);
|
const wasOpen = usePrevious(isOpen);
|
||||||
|
|
||||||
@ -128,20 +129,12 @@ function NoteMenu({
|
|||||||
}}
|
}}
|
||||||
sx={{ alignItems: "center" }}
|
sx={{ alignItems: "center" }}
|
||||||
>
|
>
|
||||||
<Textarea
|
<TextareaAutosize
|
||||||
id="changeNoteText"
|
id="changeNoteText"
|
||||||
onChange={handleTextChange}
|
onChange={handleTextChange}
|
||||||
value={(note && note.text) || ""}
|
value={(note && note.text) || ""}
|
||||||
sx={{
|
|
||||||
padding: "4px",
|
|
||||||
border: "none",
|
|
||||||
":focus": {
|
|
||||||
outline: "none",
|
|
||||||
},
|
|
||||||
resize: "none",
|
|
||||||
}}
|
|
||||||
rows={1}
|
|
||||||
onKeyPress={handleTextKeyPress}
|
onKeyPress={handleTextKeyPress}
|
||||||
|
maxRows={4}
|
||||||
/>
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
<Box
|
<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 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({
|
function Tile({
|
||||||
src,
|
|
||||||
title,
|
title,
|
||||||
isSelected,
|
isSelected,
|
||||||
onSelect,
|
onSelect,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDoubleClick,
|
onDoubleClick,
|
||||||
size,
|
|
||||||
canEdit,
|
canEdit,
|
||||||
badges,
|
badges,
|
||||||
editTitle,
|
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 (
|
return (
|
||||||
<Flex
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
position: "relative",
|
position: "relative",
|
||||||
width: width,
|
width: "100%",
|
||||||
height: "0",
|
height: "0",
|
||||||
paddingTop: width,
|
paddingTop: "100%",
|
||||||
borderRadius: "4px",
|
borderRadius: "4px",
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
cursor: "pointer",
|
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
userSelect: "none",
|
userSelect: "none",
|
||||||
}}
|
}}
|
||||||
my={1}
|
bg="background"
|
||||||
mx={margin}
|
|
||||||
bg="muted"
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onSelect();
|
onSelect();
|
||||||
}}
|
}}
|
||||||
onDoubleClick={onDoubleClick}
|
onDoubleClick={onDoubleClick}
|
||||||
|
aria-label={title}
|
||||||
>
|
>
|
||||||
<UIImage
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
objectFit: "contain",
|
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
}}
|
}}
|
||||||
src={src}
|
>
|
||||||
alt={title}
|
{children}
|
||||||
/>
|
</Box>
|
||||||
<Flex
|
<Flex
|
||||||
sx={{
|
sx={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
@ -106,13 +81,25 @@ function Tile({
|
|||||||
borderRadius: "4px",
|
borderRadius: "4px",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Box sx={{ position: "absolute", top: 0, left: 0 }}>
|
<Flex
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "6px",
|
||||||
|
left: "6px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
{badges.map((badge, i) => (
|
{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}
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Flex>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<Box sx={{ position: "absolute", top: 0, right: 0 }}>
|
<Box sx={{ position: "absolute", top: 0, right: 0 }}>
|
||||||
<IconButton
|
<IconButton
|
||||||
@ -131,12 +118,11 @@ function Tile({
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Tile.defaultProps = {
|
Tile.defaultProps = {
|
||||||
src: "",
|
|
||||||
title: "",
|
title: "",
|
||||||
isSelected: false,
|
isSelected: false,
|
||||||
onSelect: () => {},
|
onSelect: () => {},
|
||||||
@ -146,6 +132,7 @@ Tile.defaultProps = {
|
|||||||
canEdit: false,
|
canEdit: false,
|
||||||
badges: [],
|
badges: [],
|
||||||
editTitle: "Edit",
|
editTitle: "Edit",
|
||||||
|
columns: "1fr",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Tile;
|
export default Tile;
|
@ -1,22 +1,24 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Flex, IconButton } from "theme-ui";
|
import { Flex, IconButton } from "theme-ui";
|
||||||
|
|
||||||
import AddIcon from "../icons/AddIcon";
|
import AddIcon from "../../icons/AddIcon";
|
||||||
import SelectMultipleIcon from "../icons/SelectMultipleIcon";
|
import SelectMultipleIcon from "../../icons/SelectMultipleIcon";
|
||||||
import SelectSingleIcon from "../icons/SelectSingleIcon";
|
import SelectSingleIcon from "../../icons/SelectSingleIcon";
|
||||||
|
|
||||||
import Search from "./Search";
|
import Search from "../Search";
|
||||||
import RadioIconButton from "./RadioIconButton";
|
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 (
|
return (
|
||||||
<Flex
|
<Flex
|
||||||
bg="muted"
|
bg="muted"
|
||||||
@ -31,9 +33,9 @@ function FilterBar({
|
|||||||
outlineOffset: "0px",
|
outlineOffset: "0px",
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
onFocus={onFocus}
|
onFocus={() => onGroupSelect()}
|
||||||
>
|
>
|
||||||
<Search value={search} onChange={onSearchChange} />
|
<Search value={filter} onChange={(e) => onFilterChange(e.target.value)} />
|
||||||
<Flex
|
<Flex
|
||||||
mr={1}
|
mr={1}
|
||||||
px={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";
|
import SelectTokensModal from "../../modals/SelectTokensModal";
|
||||||
|
|
||||||
function SelectTokensButton() {
|
function SelectTokensButton({ onMapTokensStateCreate }) {
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
function openModal() {
|
function openModal() {
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
@ -30,6 +30,7 @@ function SelectTokensButton() {
|
|||||||
isOpen={isModalOpen}
|
isOpen={isModalOpen}
|
||||||
onRequestClose={closeModal}
|
onRequestClose={closeModal}
|
||||||
onDone={handleDone}
|
onDone={handleDone}
|
||||||
|
onMapTokensStateCreate={onMapTokensStateCreate}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
200
src/components/token/TokenBar.js
Normal file
200
src/components/token/TokenBar.js
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { Box, Flex, Grid } from "theme-ui";
|
||||||
|
import SimpleBar from "simplebar-react";
|
||||||
|
import {
|
||||||
|
DragOverlay,
|
||||||
|
MouseSensor,
|
||||||
|
TouchSensor,
|
||||||
|
KeyboardSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
|
||||||
|
import TokenBarToken from "./TokenBarToken";
|
||||||
|
import TokenBarTokenGroup from "./TokenBarTokenGroup";
|
||||||
|
import SelectTokensButton from "./SelectTokensButton";
|
||||||
|
|
||||||
|
import Draggable from "../drag/Draggable";
|
||||||
|
|
||||||
|
import useSetting from "../../hooks/useSetting";
|
||||||
|
import usePreventSelect from "../../hooks/usePreventSelect";
|
||||||
|
|
||||||
|
import { useTokenData } from "../../contexts/TokenDataContext";
|
||||||
|
import { useUserId } from "../../contexts/UserIdContext";
|
||||||
|
import { useMapStage } from "../../contexts/MapStageContext";
|
||||||
|
import DragContext from "../../contexts/DragContext";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createTokenState,
|
||||||
|
clientPositionToMapPosition,
|
||||||
|
} from "../../helpers/token";
|
||||||
|
import { findGroup } from "../../helpers/group";
|
||||||
|
import Vector2 from "../../helpers/Vector2";
|
||||||
|
|
||||||
|
function TokenBar({ onMapTokensStateCreate }) {
|
||||||
|
const userId = useUserId();
|
||||||
|
const { tokensById, tokenGroups } = useTokenData();
|
||||||
|
const [fullScreen] = useSetting("map.fullScreen");
|
||||||
|
|
||||||
|
const [dragId, setDragId] = useState();
|
||||||
|
|
||||||
|
const mapStageRef = useMapStage();
|
||||||
|
|
||||||
|
const mouseSensor = useSensor(MouseSensor, {
|
||||||
|
activationConstraint: { distance: 5 },
|
||||||
|
});
|
||||||
|
const touchSensor = useSensor(TouchSensor, {
|
||||||
|
activationConstraint: { distance: 5 },
|
||||||
|
});
|
||||||
|
const keyboardSensor = useSensor(KeyboardSensor);
|
||||||
|
const sensors = useSensors(mouseSensor, touchSensor, keyboardSensor);
|
||||||
|
|
||||||
|
const [preventSelect, resumeSelect] = usePreventSelect();
|
||||||
|
|
||||||
|
function handleDragStart({ active }) {
|
||||||
|
setDragId(active.id);
|
||||||
|
preventSelect();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragEnd({ active, overlayNodeClientRect }) {
|
||||||
|
setDragId(null);
|
||||||
|
|
||||||
|
const mapStage = mapStageRef.current;
|
||||||
|
if (mapStage && overlayNodeClientRect) {
|
||||||
|
const dragRect = overlayNodeClientRect;
|
||||||
|
const dragPosition = {
|
||||||
|
x: dragRect.left + dragRect.width / 2,
|
||||||
|
y: dragRect.top + dragRect.height / 2,
|
||||||
|
};
|
||||||
|
const mapPosition = clientPositionToMapPosition(mapStage, dragPosition);
|
||||||
|
const group = findGroup(tokenGroups, active.id);
|
||||||
|
if (group && mapPosition) {
|
||||||
|
if (group.type === "item") {
|
||||||
|
const token = tokensById[group.id];
|
||||||
|
const tokenState = createTokenState(token, mapPosition, userId);
|
||||||
|
onMapTokensStateCreate([tokenState]);
|
||||||
|
} else {
|
||||||
|
let tokenStates = [];
|
||||||
|
let offset = new Vector2(0, 0);
|
||||||
|
for (let item of group.items) {
|
||||||
|
const token = tokensById[item.id];
|
||||||
|
if (token && !token.hideInSidebar) {
|
||||||
|
tokenStates.push(
|
||||||
|
createTokenState(
|
||||||
|
token,
|
||||||
|
Vector2.add(mapPosition, offset),
|
||||||
|
userId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
offset = Vector2.add(offset, 0.01);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tokenStates.length > 0) {
|
||||||
|
onMapTokensStateCreate(tokenStates);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resumeSelect();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragCancel() {
|
||||||
|
setDragId(null);
|
||||||
|
resumeSelect();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderToken(group, draggable = true) {
|
||||||
|
if (group.type === "item") {
|
||||||
|
const token = tokensById[group.id];
|
||||||
|
if (token && !token.hideInSidebar) {
|
||||||
|
if (draggable) {
|
||||||
|
return (
|
||||||
|
<Draggable id={token.id} key={token.id}>
|
||||||
|
<TokenBarToken token={token} />
|
||||||
|
</Draggable>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return <TokenBarToken token={token} key={token.id} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const groupTokens = [];
|
||||||
|
for (let item of group.items) {
|
||||||
|
const token = tokensById[item.id];
|
||||||
|
if (token && !token.hideInSidebar) {
|
||||||
|
groupTokens.push(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (groupTokens.length > 0) {
|
||||||
|
return (
|
||||||
|
<TokenBarTokenGroup
|
||||||
|
group={group}
|
||||||
|
tokens={groupTokens}
|
||||||
|
key={group.id}
|
||||||
|
draggable={draggable}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DragContext
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragCancel={handleDragCancel}
|
||||||
|
autoScroll={false}
|
||||||
|
sensors={sensors}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
height: "100%",
|
||||||
|
width: "80px",
|
||||||
|
minWidth: "80px",
|
||||||
|
overflowY: "hidden",
|
||||||
|
overflowX: "hidden",
|
||||||
|
display: fullScreen ? "none" : "block",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SimpleBar
|
||||||
|
style={{
|
||||||
|
height: "calc(100% - 48px)",
|
||||||
|
overflowX: "hidden",
|
||||||
|
padding: "0 16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Grid
|
||||||
|
columns="1fr"
|
||||||
|
gap={2}
|
||||||
|
py={2}
|
||||||
|
// Prevent selection on 3D touch for iOS
|
||||||
|
onTouchStart={preventSelect}
|
||||||
|
onTouchEnd={resumeSelect}
|
||||||
|
>
|
||||||
|
{tokenGroups.map((group) => renderToken(group))}
|
||||||
|
</Grid>
|
||||||
|
</SimpleBar>
|
||||||
|
<Flex
|
||||||
|
bg="muted"
|
||||||
|
sx={{
|
||||||
|
justifyContent: "center",
|
||||||
|
height: "48px",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTokensButton onMapTokensStateCreate={onMapTokensStateCreate} />
|
||||||
|
</Flex>
|
||||||
|
{createPortal(
|
||||||
|
<DragOverlay dropAnimation={null}>
|
||||||
|
{dragId && renderToken(findGroup(tokenGroups, dragId), false)}
|
||||||
|
</DragOverlay>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</DragContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TokenBar;
|
31
src/components/token/TokenBarToken.js
Normal file
31
src/components/token/TokenBarToken.js
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Box } from "theme-ui";
|
||||||
|
import { useInView } from "react-intersection-observer";
|
||||||
|
|
||||||
|
import TokenImage from "./TokenImage";
|
||||||
|
|
||||||
|
function TokenBarToken({ token }) {
|
||||||
|
const [ref, inView] = useInView({ triggerOnce: true });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box ref={ref} sx={{ width: "48px", height: "48px" }} title={token.name}>
|
||||||
|
{inView && (
|
||||||
|
<TokenImage
|
||||||
|
token={token}
|
||||||
|
sx={{
|
||||||
|
userSelect: "none",
|
||||||
|
touchAction: "none",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
objectFit: "cover",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
alt={token.name}
|
||||||
|
title={token.name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TokenBarToken;
|
135
src/components/token/TokenBarTokenGroup.js
Normal file
135
src/components/token/TokenBarTokenGroup.js
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import React, { useState, useRef } from "react";
|
||||||
|
import { Grid, Flex, Box } from "theme-ui";
|
||||||
|
import { useSpring, animated } from "react-spring";
|
||||||
|
import { useDraggable } from "@dnd-kit/core";
|
||||||
|
|
||||||
|
import TokenImage from "./TokenImage";
|
||||||
|
import TokenBarToken from "./TokenBarToken";
|
||||||
|
|
||||||
|
import Draggable from "../drag/Draggable";
|
||||||
|
|
||||||
|
import Vector2 from "../../helpers/Vector2";
|
||||||
|
|
||||||
|
import GroupIcon from "../../icons/GroupIcon";
|
||||||
|
|
||||||
|
function TokenBarTokenGroup({ group, tokens, draggable }) {
|
||||||
|
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||||
|
id: draggable && group.id,
|
||||||
|
disabled: !draggable,
|
||||||
|
});
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const { height } = useSpring({
|
||||||
|
height: isOpen ? (tokens.length + 1) * 56 : 56,
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderToken(token) {
|
||||||
|
if (draggable) {
|
||||||
|
return (
|
||||||
|
<Draggable id={token.id} key={token.id}>
|
||||||
|
<TokenBarToken token={token} />
|
||||||
|
</Draggable>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return <TokenBarToken token={token} key={token.id} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTokens() {
|
||||||
|
if (isOpen) {
|
||||||
|
return (
|
||||||
|
<Grid
|
||||||
|
columns="1fr"
|
||||||
|
bg="muted"
|
||||||
|
sx={{ borderRadius: "8px" }}
|
||||||
|
p={0}
|
||||||
|
gap={2}
|
||||||
|
>
|
||||||
|
<Flex
|
||||||
|
sx={{
|
||||||
|
width: "48px",
|
||||||
|
height: "48px",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
cursor: isDragging ? "grabbing" : "pointer",
|
||||||
|
color: "primary",
|
||||||
|
}}
|
||||||
|
onClick={(e) => handleOpenClick(e, false)}
|
||||||
|
key="group"
|
||||||
|
title={group.name}
|
||||||
|
{...listeners}
|
||||||
|
{...attributes}
|
||||||
|
>
|
||||||
|
<GroupIcon />
|
||||||
|
</Flex>
|
||||||
|
{tokens.map(renderToken)}
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<Grid
|
||||||
|
columns="1fr 1fr"
|
||||||
|
bg="muted"
|
||||||
|
sx={{
|
||||||
|
borderRadius: "8px",
|
||||||
|
gridGap: "4px",
|
||||||
|
height: "48px",
|
||||||
|
gridTemplateRows: "1fr 1fr",
|
||||||
|
}}
|
||||||
|
p="2px"
|
||||||
|
alt={group.name}
|
||||||
|
title={group.name}
|
||||||
|
{...listeners}
|
||||||
|
{...attributes}
|
||||||
|
>
|
||||||
|
{tokens.slice(0, 4).map((token) => (
|
||||||
|
<TokenImage
|
||||||
|
token={token}
|
||||||
|
key={token.id}
|
||||||
|
sx={{
|
||||||
|
userSelect: "none",
|
||||||
|
touchAction: "none",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject the opening of a group if the pointer has moved
|
||||||
|
const clickDownPositionRef = useRef(new Vector2(0, 0));
|
||||||
|
function handleOpenDown(event) {
|
||||||
|
clickDownPositionRef.current = new Vector2(event.clientX, event.clientY);
|
||||||
|
}
|
||||||
|
function handleOpenClick(event, newOpen) {
|
||||||
|
const clickPosition = new Vector2(event.clientX, event.clientY);
|
||||||
|
const distance = Vector2.distance(
|
||||||
|
clickPosition,
|
||||||
|
clickDownPositionRef.current
|
||||||
|
);
|
||||||
|
if (distance < 5) {
|
||||||
|
setIsOpen(newOpen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box ref={setNodeRef}>
|
||||||
|
<animated.div
|
||||||
|
style={{
|
||||||
|
padding: "4px 0",
|
||||||
|
width: "48px",
|
||||||
|
height,
|
||||||
|
cursor: isOpen ? "default" : isDragging ? "grabbing" : "pointer",
|
||||||
|
}}
|
||||||
|
onPointerDown={handleOpenDown}
|
||||||
|
onClick={(e) => !isOpen && handleOpenClick(e, true)}
|
||||||
|
>
|
||||||
|
{renderTokens()}
|
||||||
|
</animated.div>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TokenBarTokenGroup;
|
@ -1,12 +1,12 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { useAuth } from "../../contexts/AuthContext";
|
import { useUserId } from "../../contexts/UserIdContext";
|
||||||
import {
|
import {
|
||||||
useMapWidth,
|
useMapWidth,
|
||||||
useMapHeight,
|
useMapHeight,
|
||||||
} from "../../contexts/MapInteractionContext";
|
} from "../../contexts/MapInteractionContext";
|
||||||
|
|
||||||
import DragOverlay from "../DragOverlay";
|
import DragOverlay from "../map/DragOverlay";
|
||||||
|
|
||||||
function TokenDragOverlay({
|
function TokenDragOverlay({
|
||||||
onTokenStateRemove,
|
onTokenStateRemove,
|
||||||
@ -16,7 +16,7 @@ function TokenDragOverlay({
|
|||||||
tokenGroup,
|
tokenGroup,
|
||||||
dragging,
|
dragging,
|
||||||
}) {
|
}) {
|
||||||
const { userId } = useAuth();
|
const userId = useUserId();
|
||||||
|
|
||||||
const mapWidth = useMapWidth();
|
const mapWidth = useMapWidth();
|
||||||
const mapHeight = useMapHeight();
|
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";
|
import useSetting from "../../hooks/useSetting";
|
||||||
|
|
||||||
const maxTokenSize = 3;
|
const maxTokenSize = 3;
|
||||||
|
const defaultFontSize = 16;
|
||||||
|
|
||||||
function TokenLabel({ tokenState, width, height }) {
|
function TokenLabel({ tokenState, width, height }) {
|
||||||
const [labelSize] = useSetting("map.labelSize");
|
const [labelSize] = useSetting("map.labelSize");
|
||||||
@ -13,7 +14,7 @@ function TokenLabel({ tokenState, width, height }) {
|
|||||||
const paddingX =
|
const paddingX =
|
||||||
(height / 8 / tokenState.size) * Math.min(tokenState.size, maxTokenSize);
|
(height / 8 / tokenState.size) * Math.min(tokenState.size, maxTokenSize);
|
||||||
|
|
||||||
const [fontSize, setFontSize] = useState(1);
|
const [fontScale, setFontScale] = useState(0);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const text = textSizerRef.current;
|
const text = textSizerRef.current;
|
||||||
|
|
||||||
@ -22,15 +23,14 @@ function TokenLabel({ tokenState, width, height }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let fontSizes = [];
|
let fontSizes = [];
|
||||||
for (let size = 10 * labelSize; size >= 6; size--) {
|
for (let size = 20 * labelSize; size >= 6; size--) {
|
||||||
fontSizes.push(
|
const verticalSize = height / size / tokenState.size;
|
||||||
(height / size / tokenState.size) *
|
const tokenSize = Math.min(tokenState.size, maxTokenSize);
|
||||||
Math.min(tokenState.size, maxTokenSize) *
|
const fontSize = verticalSize * tokenSize * labelSize;
|
||||||
labelSize
|
fontSizes.push(fontSize);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function findFontSize() {
|
function findFontScale() {
|
||||||
const size = fontSizes.reduce((prev, curr) => {
|
const size = fontSizes.reduce((prev, curr) => {
|
||||||
text.fontSize(curr);
|
text.fontSize(curr);
|
||||||
const textWidth = text.getTextWidth() + paddingX * 2;
|
const textWidth = text.getTextWidth() + paddingX * 2;
|
||||||
@ -39,12 +39,12 @@ function TokenLabel({ tokenState, width, height }) {
|
|||||||
} else {
|
} else {
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
});
|
}, 1);
|
||||||
|
|
||||||
setFontSize(size);
|
setFontScale(size / defaultFontSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
findFontSize();
|
findFontScale();
|
||||||
}, [
|
}, [
|
||||||
tokenState.label,
|
tokenState.label,
|
||||||
tokenState.visible,
|
tokenState.visible,
|
||||||
@ -56,44 +56,47 @@ function TokenLabel({ tokenState, width, height }) {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const [rectWidth, setRectWidth] = useState(0);
|
const [rectWidth, setRectWidth] = useState(0);
|
||||||
|
const [textWidth, setTextWidth] = useState(0);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const text = textRef.current;
|
const text = textRef.current;
|
||||||
if (text && tokenState.label) {
|
if (text && tokenState.label) {
|
||||||
setRectWidth(text.getTextWidth() + paddingX * 2);
|
setRectWidth(text.getTextWidth() * fontScale + paddingX * 2);
|
||||||
|
setTextWidth(text.getTextWidth() * fontScale);
|
||||||
} else {
|
} else {
|
||||||
setRectWidth(0);
|
setRectWidth(0);
|
||||||
|
setTextWidth(0);
|
||||||
}
|
}
|
||||||
}, [tokenState.label, paddingX, width, fontSize]);
|
}, [tokenState.label, paddingX, width, fontScale]);
|
||||||
|
|
||||||
const textRef = useRef();
|
const textRef = useRef();
|
||||||
const textSizerRef = useRef();
|
const textSizerRef = useRef();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group y={height - (fontSize + paddingY) / 2}>
|
<Group y={height - (defaultFontSize * fontScale + paddingY) / 2}>
|
||||||
<Rect
|
<Rect
|
||||||
y={-paddingY / 2}
|
y={-paddingY / 2}
|
||||||
width={rectWidth}
|
width={rectWidth}
|
||||||
offsetX={width / 2}
|
offsetX={width / 2}
|
||||||
x={width - rectWidth / 2}
|
x={width - rectWidth / 2}
|
||||||
height={fontSize + paddingY}
|
height={defaultFontSize * fontScale + paddingY}
|
||||||
fill="hsla(230, 25%, 18%, 0.8)"
|
fill="hsla(230, 25%, 18%, 0.8)"
|
||||||
cornerRadius={(fontSize + paddingY) / 2}
|
cornerRadius={(defaultFontSize * fontScale + 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={() => {}}
|
|
||||||
/>
|
/>
|
||||||
|
<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 */}
|
{/* Use an invisible text block to work out text sizing */}
|
||||||
<Text
|
<Text
|
||||||
visible={false}
|
visible={false}
|
||||||
|
@ -14,7 +14,7 @@ import UnlockIcon from "../../icons/TokenUnlockIcon";
|
|||||||
import ShowIcon from "../../icons/TokenShowIcon";
|
import ShowIcon from "../../icons/TokenShowIcon";
|
||||||
import HideIcon from "../../icons/TokenHideIcon";
|
import HideIcon from "../../icons/TokenHideIcon";
|
||||||
|
|
||||||
import { useAuth } from "../../contexts/AuthContext";
|
import { useUserId } from "../../contexts/UserIdContext";
|
||||||
|
|
||||||
const defaultTokenMaxSize = 6;
|
const defaultTokenMaxSize = 6;
|
||||||
function TokenMenu({
|
function TokenMenu({
|
||||||
@ -25,7 +25,7 @@ function TokenMenu({
|
|||||||
onTokenStateChange,
|
onTokenStateChange,
|
||||||
map,
|
map,
|
||||||
}) {
|
}) {
|
||||||
const { userId } = useAuth();
|
const userId = useUserId();
|
||||||
|
|
||||||
const wasOpen = usePrevious(isOpen);
|
const wasOpen = usePrevious(isOpen);
|
||||||
|
|
||||||
@ -50,7 +50,7 @@ function TokenMenu({
|
|||||||
}, [isOpen, tokenState, wasOpen, tokenImage]);
|
}, [isOpen, tokenState, wasOpen, tokenImage]);
|
||||||
|
|
||||||
function handleLabelChange(event) {
|
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 } });
|
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 useResponsiveLayout from "../../hooks/useResponsiveLayout";
|
||||||
|
|
||||||
import { GridProvider } from "../../contexts/GridContext";
|
import { GridProvider } from "../../contexts/GridContext";
|
||||||
import { useImageSource } from "../../contexts/ImageSourceContext";
|
import { useDataURL } from "../../contexts/AssetsContext";
|
||||||
|
|
||||||
import GridOnIcon from "../../icons/GridOnIcon";
|
import GridOnIcon from "../../icons/GridOnIcon";
|
||||||
import GridOffIcon from "../../icons/GridOffIcon";
|
import GridOffIcon from "../../icons/GridOffIcon";
|
||||||
|
|
||||||
import { tokenSources, unknownSource } from "../../tokens";
|
import { tokenSources } from "../../tokens";
|
||||||
|
|
||||||
import Grid from "../Grid";
|
import Grid from "../Grid";
|
||||||
|
|
||||||
@ -27,12 +27,8 @@ function TokenPreview({ token }) {
|
|||||||
}
|
}
|
||||||
}, [token, tokenSourceData]);
|
}, [token, tokenSourceData]);
|
||||||
|
|
||||||
const tokenSource = useImageSource(
|
const tokenURL = useDataURL(tokenSourceData, tokenSources);
|
||||||
tokenSourceData,
|
const [tokenSourceImage] = useImage(tokenURL);
|
||||||
tokenSources,
|
|
||||||
unknownSource
|
|
||||||
);
|
|
||||||
const [tokenSourceImage] = useImage(tokenSource);
|
|
||||||
|
|
||||||
const [stageWidth, setStageWidth] = useState(1);
|
const [stageWidth, setStageWidth] = useState(1);
|
||||||
const [stageHeight, setStageHeight] = useState(1);
|
const [stageHeight, setStageHeight] = useState(1);
|
||||||
|
@ -21,39 +21,49 @@ function TokenSettings({ token, onSettingsChange }) {
|
|||||||
name="name"
|
name="name"
|
||||||
value={(token && token.name) || ""}
|
value={(token && token.name) || ""}
|
||||||
onChange={(e) => onSettingsChange("name", e.target.value)}
|
onChange={(e) => onSettingsChange("name", e.target.value)}
|
||||||
disabled={tokenEmpty || token.type === "default"}
|
disabled={tokenEmpty}
|
||||||
my={1}
|
my={1}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box mt={2}>
|
<Box mt={2}>
|
||||||
<Label mb={1}>Category</Label>
|
<Label>Default Category</Label>
|
||||||
<Select
|
<Select
|
||||||
options={categorySettings}
|
options={categorySettings}
|
||||||
value={
|
value={
|
||||||
!tokenEmpty &&
|
!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}
|
isSearchable={false}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Flex>
|
<Box mt={2} sx={{ flexGrow: 1 }}>
|
||||||
<Box my={2} sx={{ flexGrow: 1 }}>
|
<Label htmlFor="tokenSize">Default Size</Label>
|
||||||
<Label htmlFor="tokenSize">Default Size</Label>
|
<Input
|
||||||
<Input
|
type="number"
|
||||||
type="number"
|
name="tokenSize"
|
||||||
name="tokenSize"
|
value={`${(token && token.defaultSize) || 0}`}
|
||||||
value={`${(token && token.defaultSize) || 0}`}
|
onChange={(e) =>
|
||||||
onChange={(e) =>
|
onSettingsChange("defaultSize", parseFloat(e.target.value))
|
||||||
onSettingsChange("defaultSize", parseFloat(e.target.value))
|
}
|
||||||
}
|
disabled={tokenEmpty}
|
||||||
disabled={tokenEmpty || token.type === "default"}
|
min={1}
|
||||||
min={1}
|
my={1}
|
||||||
my={1}
|
/>
|
||||||
/>
|
</Box>
|
||||||
</Box>
|
<Box my={2} mb={3} sx={{ flexGrow: 1 }}>
|
||||||
</Flex>
|
<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>
|
</Flex>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,42 +1,28 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import Tile from "../Tile";
|
import Tile from "../tile/Tile";
|
||||||
|
import TokenImage from "./TokenImage";
|
||||||
import { useImageSource } from "../../contexts/ImageSourceContext";
|
|
||||||
|
|
||||||
import {
|
|
||||||
tokenSources as defaultTokenSources,
|
|
||||||
unknownSource,
|
|
||||||
} from "../../tokens";
|
|
||||||
|
|
||||||
function TokenTile({
|
function TokenTile({
|
||||||
token,
|
token,
|
||||||
isSelected,
|
isSelected,
|
||||||
onTokenSelect,
|
onSelect,
|
||||||
onTokenEdit,
|
onTokenEdit,
|
||||||
size,
|
|
||||||
canEdit,
|
canEdit,
|
||||||
badges,
|
badges,
|
||||||
}) {
|
}) {
|
||||||
const tokenSource = useImageSource(
|
|
||||||
token,
|
|
||||||
defaultTokenSources,
|
|
||||||
unknownSource,
|
|
||||||
token.type === "file"
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tile
|
<Tile
|
||||||
src={tokenSource}
|
|
||||||
title={token.name}
|
title={token.name}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
onSelect={() => onTokenSelect(token)}
|
onSelect={() => onSelect(token.id)}
|
||||||
onEdit={() => onTokenEdit(token.id)}
|
onEdit={() => onTokenEdit(token.id)}
|
||||||
size={size}
|
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
badges={badges}
|
badges={badges}
|
||||||
editTitle="Edit Token"
|
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 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 TokenTile from "./TokenTile";
|
||||||
import Link from "../Link";
|
import TokenTileGroup from "./TokenTileGroup";
|
||||||
import FilterBar from "../FilterBar";
|
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({
|
import { useGroup } from "../../contexts/GroupContext";
|
||||||
tokens,
|
|
||||||
groups,
|
|
||||||
onTokenAdd,
|
|
||||||
onTokenEdit,
|
|
||||||
onTokenSelect,
|
|
||||||
selectedTokens,
|
|
||||||
onTokensRemove,
|
|
||||||
selectMode,
|
|
||||||
onSelectModeChange,
|
|
||||||
search,
|
|
||||||
onSearchChange,
|
|
||||||
onTokensGroup,
|
|
||||||
onTokensHide,
|
|
||||||
}) {
|
|
||||||
const { databaseStatus } = useDatabase();
|
|
||||||
const layout = useResponsiveLayout();
|
|
||||||
|
|
||||||
let hasSelectedDefaultToken = selectedTokens.some(
|
function TokenTiles({ tokensById, onTokenEdit, subgroup }) {
|
||||||
(token) => token.type === "default"
|
const {
|
||||||
);
|
selectedGroupIds,
|
||||||
let allTokensVisible = selectedTokens.every((token) => !token.hideInSidebar);
|
selectMode,
|
||||||
|
onGroupOpen,
|
||||||
|
onGroupSelect,
|
||||||
|
} = useGroup();
|
||||||
|
|
||||||
function tokenToTile(token) {
|
function renderTile(group) {
|
||||||
const isSelected = selectedTokens.includes(token);
|
if (group.type === "item") {
|
||||||
return (
|
const token = tokensById[group.id];
|
||||||
<TokenTile
|
if (token) {
|
||||||
key={token.id}
|
const isSelected = selectedGroupIds.includes(group.id);
|
||||||
token={token}
|
const canEdit =
|
||||||
isSelected={isSelected}
|
|
||||||
onTokenSelect={onTokenSelect}
|
|
||||||
onTokenEdit={onTokenEdit}
|
|
||||||
size={layout.tileSize}
|
|
||||||
canEdit={
|
|
||||||
isSelected &&
|
isSelected &&
|
||||||
token.type !== "default" &&
|
|
||||||
selectMode === "single" &&
|
selectMode === "single" &&
|
||||||
selectedTokens.length === 1
|
selectedGroupIds.length === 1;
|
||||||
}
|
|
||||||
badges={[`${token.defaultSize}x`]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const multipleSelected = selectedTokens.length > 1;
|
return (
|
||||||
|
<TokenTile
|
||||||
let hideTitle = "";
|
key={token.id}
|
||||||
if (multipleSelected) {
|
token={token}
|
||||||
if (allTokensVisible) {
|
isSelected={isSelected}
|
||||||
hideTitle = "Hide Tokens in Sidebar";
|
onSelect={onGroupSelect}
|
||||||
|
onTokenEdit={onTokenEdit}
|
||||||
|
canEdit={canEdit}
|
||||||
|
badges={[
|
||||||
|
`${token.defaultSize}x`,
|
||||||
|
<TokenHiddenBadge hidden={token.hideInSidebar} />,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
hideTitle = "Show Tokens in Sidebar";
|
const isSelected = selectedGroupIds.includes(group.id);
|
||||||
}
|
const items = getGroupItems(group);
|
||||||
} else {
|
const canOpen =
|
||||||
if (allTokensVisible) {
|
isSelected && selectMode === "single" && selectedGroupIds.length === 1;
|
||||||
hideTitle = "Hide Token in Sidebar";
|
return (
|
||||||
} else {
|
<TokenTileGroup
|
||||||
hideTitle = "Show Token in Sidebar";
|
key={group.id}
|
||||||
|
group={group}
|
||||||
|
tokens={items.map((item) => tokensById[item.id])}
|
||||||
|
isSelected={isSelected}
|
||||||
|
onSelect={onGroupSelect}
|
||||||
|
onDoubleClick={() => canOpen && onGroupOpen(group.id)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ position: "relative" }}>
|
<>
|
||||||
<FilterBar
|
<SortableTiles renderTile={renderTile} subgroup={subgroup} />
|
||||||
onFocus={() => onTokenSelect()}
|
<SortableTilesDragOverlay renderTile={renderTile} subgroup={subgroup} />
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
|
367
src/contexts/AssetsContext.js
Normal file
367
src/contexts/AssetsContext.js
Normal file
@ -0,0 +1,367 @@
|
|||||||
|
import React, { useState, useContext, useCallback, useEffect } from "react";
|
||||||
|
import * as Comlink from "comlink";
|
||||||
|
import { encode } from "@msgpack/msgpack";
|
||||||
|
import { useLiveQuery } from "dexie-react-hooks";
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
// Check for broadcast channel and attempt to use worker to put map to avoid UI lockup
|
||||||
|
// Safari doesn't support BC so fallback to single thread
|
||||||
|
if (window.BroadcastChannel) {
|
||||||
|
const packedAsset = encode(asset);
|
||||||
|
const success = await worker.putData(
|
||||||
|
Comlink.transfer(packedAsset, [packedAsset.buffer]),
|
||||||
|
"assets"
|
||||||
|
);
|
||||||
|
if (!success) {
|
||||||
|
await database.table("assets").put(asset);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
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({});
|
||||||
|
const { database } = useDatabase();
|
||||||
|
|
||||||
|
// Keep track of the assets that need to be loaded
|
||||||
|
const [assetKeys, setAssetKeys] = useState([]);
|
||||||
|
|
||||||
|
// Load assets after 100ms
|
||||||
|
const loadingDebouncedAssetURLs = useDebounce(assetURLs, 100);
|
||||||
|
|
||||||
|
// Update the asset keys to load when a url is added without an asset attached
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loadingDebouncedAssetURLs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let keysToLoad = [];
|
||||||
|
for (let url of Object.values(loadingDebouncedAssetURLs)) {
|
||||||
|
if (url.url === null) {
|
||||||
|
keysToLoad.push(url.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (keysToLoad.length > 0) {
|
||||||
|
setAssetKeys(keysToLoad);
|
||||||
|
}
|
||||||
|
}, [loadingDebouncedAssetURLs]);
|
||||||
|
|
||||||
|
// Get the new assets whenever the keys change
|
||||||
|
const assets = useLiveQuery(
|
||||||
|
() => database?.table("assets").where("id").anyOf(assetKeys).toArray(),
|
||||||
|
[database, assetKeys]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update asset URLs when assets are loaded
|
||||||
|
useEffect(() => {
|
||||||
|
if (!assets || assets.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Assets are about to be loaded so clear the keys to load
|
||||||
|
setAssetKeys([]);
|
||||||
|
|
||||||
|
setAssetURLs((prevURLs) => {
|
||||||
|
let newURLs = { ...prevURLs };
|
||||||
|
for (let asset of assets) {
|
||||||
|
if (newURLs[asset.id]?.url === null) {
|
||||||
|
newURLs[asset.id] = {
|
||||||
|
...newURLs[asset.id],
|
||||||
|
url: URL.createObjectURL(
|
||||||
|
new Blob([asset.file], { type: asset.mime })
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newURLs;
|
||||||
|
});
|
||||||
|
}, [assets]);
|
||||||
|
|
||||||
|
// Clean up asset URLs every minute
|
||||||
|
const cleanUpDebouncedAssetURLs = 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [cleanUpDebouncedAssetURLs]);
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!assetId || type !== "file") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAssetURL() {
|
||||||
|
function increaseReferences(prevURLs) {
|
||||||
|
return {
|
||||||
|
...prevURLs,
|
||||||
|
[assetId]: {
|
||||||
|
...prevURLs[assetId],
|
||||||
|
references: prevURLs[assetId].references + 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createReference(prevURLs) {
|
||||||
|
return {
|
||||||
|
...prevURLs,
|
||||||
|
[assetId]: { url: null, 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 {
|
||||||
|
return createReference(prevURLs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAssetURL();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Decrease references
|
||||||
|
setAssetURLs((prevURLs) => {
|
||||||
|
if (assetId in prevURLs) {
|
||||||
|
return {
|
||||||
|
...prevURLs,
|
||||||
|
[assetId]: {
|
||||||
|
...prevURLs[assetId],
|
||||||
|
references: prevURLs[assetId].references - 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return prevURLs;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [assetId, setAssetURLs, type]);
|
||||||
|
|
||||||
|
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,11 +1,8 @@
|
|||||||
import React, { useState, useEffect, useContext, SetStateAction } from "react";
|
import React, { useState, useEffect, useContext } from "react";
|
||||||
import shortid from "shortid";
|
|
||||||
|
|
||||||
import { useDatabase } from "./DatabaseContext";
|
|
||||||
|
|
||||||
import FakeStorage from "../helpers/FakeStorage";
|
import FakeStorage from "../helpers/FakeStorage";
|
||||||
|
|
||||||
type AuthContext = { userId: string; password: string; setPassword: React.Dispatch<any>; }
|
type AuthContext = { password: string; setPassword: React.Dispatch<any> };
|
||||||
|
|
||||||
// TODO: check what default value we want here
|
// TODO: check what default value we want here
|
||||||
const AuthContext = React.createContext<AuthContext | undefined>(undefined);
|
const AuthContext = React.createContext<AuthContext | undefined>(undefined);
|
||||||
@ -20,37 +17,16 @@ try {
|
|||||||
storage = new FakeStorage();
|
storage = new FakeStorage();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: any }) {
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
const { database, databaseStatus } = useDatabase();
|
const [password, setPassword] = useState<string>(
|
||||||
|
storage.getItem("auth") || ""
|
||||||
const [password, setPassword] = useState<string>(storage.getItem("auth") || "");
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
storage.setItem("auth", password);
|
storage.setItem("auth", password);
|
||||||
}, [password]);
|
}, [password]);
|
||||||
|
|
||||||
// TODO: check pattern here -> undefined or empty default values
|
|
||||||
const [userId, setUserId]: [ userId: string, setUserId: React.Dispatch<SetStateAction<string>> ] = 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 = {
|
const value = {
|
||||||
userId,
|
|
||||||
password,
|
password,
|
||||||
setPassword,
|
setPassword,
|
||||||
};
|
};
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useEffect, useContext, SetStateAction } from "react";
|
import React, { useState, useEffect, useContext } from "react";
|
||||||
import Comlink, { Remote } from "comlink";
|
import Dexie from "dexie";
|
||||||
|
import * as Comlink from "comlink";
|
||||||
|
|
||||||
import ErrorBanner from "../components/banner/ErrorBanner";
|
import ErrorBanner from "../components/banner/ErrorBanner";
|
||||||
|
|
||||||
@ -7,30 +8,48 @@ import { getDatabase } from "../database";
|
|||||||
|
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
import DatabaseWorker from "worker-loader!../workers/DatabaseWorker"; // eslint-disable-line import/no-webpack-loader-syntax
|
import DatabaseWorker from "worker-loader!../workers/DatabaseWorker"; // eslint-disable-line import/no-webpack-loader-syntax
|
||||||
import Dexie from "dexie";
|
|
||||||
|
|
||||||
type DatabaseContext = { database: Dexie | undefined; databaseStatus: any; databaseError: Error | undefined; worker: Remote<any>; }
|
type DatabaseContext = {
|
||||||
|
database: Dexie | undefined;
|
||||||
|
databaseStatus: any;
|
||||||
|
databaseError: Error | undefined;
|
||||||
|
worker: Comlink.Remote<any>;
|
||||||
|
};
|
||||||
|
|
||||||
// TODO: check what default we want here
|
// TODO: check what default we want here
|
||||||
const DatabaseContext = React.createContext< DatabaseContext | undefined>(undefined);
|
const DatabaseContext =
|
||||||
|
React.createContext<DatabaseContext | undefined>(undefined);
|
||||||
|
|
||||||
const worker = Comlink.wrap(new DatabaseWorker());
|
const worker = Comlink.wrap(new DatabaseWorker());
|
||||||
|
|
||||||
export function DatabaseProvider({ children }: { children: any}) {
|
export function DatabaseProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [database, setDatabase]: [ database: Dexie | undefined, setDatabase: React.Dispatch<SetStateAction<Dexie | undefined>>] = useState();
|
const [database, setDatabase] = useState<Dexie>();
|
||||||
const [databaseStatus, setDatabaseStatus]: [ datebaseStatus: any, setDatabaseStatus: React.Dispatch<SetStateAction<string>>] = useState("loading");
|
const [databaseStatus, setDatabaseStatus] =
|
||||||
const [databaseError, setDatabaseError]: [ databaseError: Error | undefined, setDatabaseError: React.Dispatch<SetStateAction<Error | undefined>>] = useState();
|
useState<"loading" | "disabled" | "upgrading" | "loaded">("loading");
|
||||||
|
const [databaseError, setDatabaseError] = useState<Error>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Create a test database and open it to see if indexedDB is enabled
|
// Create a test database and open it to see if indexedDB is enabled
|
||||||
let testDBRequest = window.indexedDB.open("__test");
|
let testDBRequest = window.indexedDB.open("__test");
|
||||||
testDBRequest.onsuccess = async function () {
|
testDBRequest.onsuccess = async function () {
|
||||||
testDBRequest.result.close();
|
testDBRequest.result.close();
|
||||||
let db = getDatabase({ autoOpen: false });
|
let db = getDatabase(
|
||||||
|
{ autoOpen: false },
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
true,
|
||||||
|
() => {
|
||||||
|
setDatabaseStatus("upgrading");
|
||||||
|
}
|
||||||
|
);
|
||||||
setDatabase(db);
|
setDatabase(db);
|
||||||
db.on("ready", () => {
|
db.on("ready", () => {
|
||||||
setDatabaseStatus("loaded");
|
setDatabaseStatus("loaded");
|
||||||
});
|
});
|
||||||
|
db.on("versionchange", () => {
|
||||||
|
// When another tab loads a new version of the database refresh the page
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
await db.open();
|
await db.open();
|
||||||
window.indexedDB.deleteDatabase("__test");
|
window.indexedDB.deleteDatabase("__test");
|
||||||
};
|
};
|
||||||
@ -48,20 +67,35 @@ export function DatabaseProvider({ children }: { children: any}) {
|
|||||||
window.indexedDB.deleteDatabase("__test");
|
window.indexedDB.deleteDatabase("__test");
|
||||||
};
|
};
|
||||||
|
|
||||||
function handleDatabaseError(event: any) {
|
function handleDatabaseError(event: PromiseRejectionEvent) {
|
||||||
event.preventDefault();
|
if (event) {
|
||||||
if (event.reason?.message.startsWith("QuotaExceededError")) {
|
event.preventDefault();
|
||||||
setDatabaseError({
|
if (event.reason instanceof Dexie.DexieError) {
|
||||||
name: event.reason.name,
|
if (event.reason?.inner?.name === "QuotaExceededError") {
|
||||||
message: "Storage Quota Exceeded Please Clear Space and Try Again.",
|
setDatabaseError({
|
||||||
});
|
name: event.reason?.name,
|
||||||
} else {
|
message:
|
||||||
setDatabaseError({
|
"Storage Quota Exceeded Please Clear Space and Try Again.",
|
||||||
name: event.reason.name,
|
});
|
||||||
message: "Something went wrong, please refresh your browser.",
|
} else if (event.reason?.inner?.name === "DatabaseClosedError") {
|
||||||
});
|
setDatabaseError({
|
||||||
|
name: event.reason?.name,
|
||||||
|
message: "Database closed, please refresh your browser.",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setDatabaseError({
|
||||||
|
name: event.reason?.name,
|
||||||
|
message: "Something went wrong, please refresh your browser.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setDatabaseError({
|
||||||
|
name: event.reason?.name,
|
||||||
|
message: "Something went wrong, please refresh your browser.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.error(event.reason);
|
||||||
}
|
}
|
||||||
console.error(event.reason);
|
|
||||||
}
|
}
|
||||||
window.addEventListener("unhandledrejection", handleDatabaseError);
|
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;
|
240
src/contexts/GroupContext.js
Normal file
240
src/contexts/GroupContext.js
Normal file
@ -0,0 +1,240 @@
|
|||||||
|
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) {
|
||||||
|
const openGroups = groupsFromIds([openGroupId], groups);
|
||||||
|
if (openGroups.length === 1) {
|
||||||
|
const openGroup = openGroups[0];
|
||||||
|
setOpenGroupItems(getGroupItems(openGroup));
|
||||||
|
} else {
|
||||||
|
// Close group if we can't find it
|
||||||
|
// This can happen if it was deleted or all it's items were deleted
|
||||||
|
setOpenGroupItems([]);
|
||||||
|
setOpenGroupId();
|
||||||
|
}
|
||||||
|
} 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;
|
@ -3,189 +3,140 @@ import React, {
|
|||||||
useState,
|
useState,
|
||||||
useContext,
|
useContext,
|
||||||
useCallback,
|
useCallback,
|
||||||
useRef,
|
useMemo,
|
||||||
ReactChild,
|
|
||||||
} from "react";
|
} from "react";
|
||||||
import * as Comlink from "comlink";
|
import { useLiveQuery } from "dexie-react-hooks";
|
||||||
import { decode, encode } from "@msgpack/msgpack";
|
|
||||||
|
|
||||||
import { useAuth } from "./AuthContext";
|
|
||||||
import { useDatabase } from "./DatabaseContext";
|
import { useDatabase } from "./DatabaseContext";
|
||||||
|
|
||||||
import { maps as defaultMaps } from "../maps";
|
import { Map, MapState, Note } from "../components/map/Map";
|
||||||
import { Map, MapState, Note, TokenState } from "../components/map/Map";
|
|
||||||
import { Fog } from "../helpers/drawing";
|
|
||||||
|
|
||||||
|
import { removeGroupsItems } from "../helpers/group";
|
||||||
|
|
||||||
// TODO: fix differences in types between default maps and imported maps
|
// TODO: fix differences in types between default maps and imported maps
|
||||||
type MapDataContext = {
|
type MapDataContext = {
|
||||||
maps: Array<Map>,
|
maps: Array<Map>;
|
||||||
ownedMaps: Array<Map>
|
mapStates: MapState[];
|
||||||
mapStates: MapState[],
|
addMap: (map: Map) => void;
|
||||||
addMap: (map: Map) => void,
|
removeMaps: (ids: string[]) => void;
|
||||||
removeMap: (id: string) => void,
|
resetMap: (id: string) => void;
|
||||||
removeMaps: (ids: string[]) => void,
|
updateMap: (id: string, update: Partial<Map>) => void;
|
||||||
resetMap: (id: string) => void,
|
updateMapState: (id: string, update: Partial<MapState>) => void;
|
||||||
updateMap: (id: string, update: Partial<Map>) => void,
|
getMapState: (id: string) => Promise<MapState>;
|
||||||
updateMaps: (ids: string[], update: Partial<Map>) => void,
|
getMap: (id: string) => Promise<Map | undefined>;
|
||||||
updateMapState: (id: string, update: Partial<MapState>) => void,
|
mapsLoading: boolean;
|
||||||
putMap: (map: Map) => void,
|
updateMapGroups: (groups: any) => void;
|
||||||
getMap: (id: string) => Map | undefined,
|
mapsById: Record<string, Map>;
|
||||||
getMapFromDB: (id: string) => Promise<Map>,
|
mapGroups: any[];
|
||||||
mapsLoading: boolean,
|
};
|
||||||
getMapStateFromDB: (id: string) => Promise<MapState>,
|
|
||||||
}
|
|
||||||
|
|
||||||
const MapDataContext = React.createContext<MapDataContext | undefined>(undefined);
|
const MapDataContext =
|
||||||
|
React.createContext<MapDataContext | undefined>(undefined);
|
||||||
|
|
||||||
// Maximum number of maps to keep in the cache
|
const defaultMapState = {
|
||||||
const cachedMapMax = 15;
|
tokens: {},
|
||||||
|
drawShapes: {},
|
||||||
const defaultMapState: MapState = {
|
fogShapes: {},
|
||||||
mapId: "",
|
|
||||||
tokens: {} as Record<string, TokenState>,
|
|
||||||
drawShapes: {} as any,
|
|
||||||
fogShapes: {} as Fog[],
|
|
||||||
// Flags to determine what other people can edit
|
// Flags to determine what other people can edit
|
||||||
editFlags: ["drawing", "tokens", "notes", "fog"],
|
editFlags: ["drawing", "tokens", "notes", "fog"],
|
||||||
notes: {} as Note[],
|
notes: {} as Note[],
|
||||||
};
|
};
|
||||||
|
|
||||||
export function MapDataProvider({ children }: { children: ReactChild }) {
|
export function MapDataProvider({ children }: { children: React.ReactNode }) {
|
||||||
const { database, databaseStatus, worker } = useDatabase();
|
const { database } = useDatabase();
|
||||||
const { userId } = useAuth();
|
|
||||||
|
|
||||||
const [maps, setMaps] = useState<Array<Map>>([]);
|
const mapsQuery = useLiveQuery<Map[]>(
|
||||||
const [mapStates, setMapStates] = useState<MapState[]>([]);
|
() => database?.table("maps").toArray() || [],
|
||||||
const [mapsLoading, setMapsLoading] = useState<boolean>(true);
|
[database]
|
||||||
|
);
|
||||||
|
const mapStatesQuery = useLiveQuery<MapState[]>(
|
||||||
|
() => database?.table("states").toArray() || [],
|
||||||
|
[database]
|
||||||
|
);
|
||||||
|
|
||||||
// Load maps from the database and ensure state is properly seup
|
const maps = useMemo(() => mapsQuery || [], [mapsQuery]);
|
||||||
|
const mapStates = useMemo(() => mapStatesQuery || [], [mapStatesQuery]);
|
||||||
|
const mapsLoading = useMemo(
|
||||||
|
() => !mapsQuery || !mapStatesQuery,
|
||||||
|
[mapsQuery, mapStatesQuery]
|
||||||
|
);
|
||||||
|
|
||||||
|
const mapGroupQuery = useLiveQuery(
|
||||||
|
() => database?.table("groups").get("maps"),
|
||||||
|
[database]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [mapGroups, setMapGroups] = useState([]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!userId || !database || databaseStatus === "loading") {
|
async function updateMapGroups() {
|
||||||
return;
|
const group = await database?.table("groups").get("maps");
|
||||||
|
setMapGroups(group.items);
|
||||||
}
|
}
|
||||||
async function getDefaultMaps(): Promise<Map[]> {
|
if (database && mapGroupQuery) {
|
||||||
const defaultMapsWithIds: Array<Map> = [];
|
updateMapGroups();
|
||||||
for (let i = 0; i < defaultMaps.length; i++) {
|
|
||||||
const defaultMap = defaultMaps[i];
|
|
||||||
const mapId = `__default-${defaultMap.name}`;
|
|
||||||
defaultMapsWithIds.push({
|
|
||||||
...defaultMap,
|
|
||||||
lastUsed: Date.now() + i,
|
|
||||||
id: mapId,
|
|
||||||
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(mapId);
|
|
||||||
if (!state) {
|
|
||||||
await database?.table("states").add({ ...defaultMapState, mapId: mapId });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return defaultMapsWithIds;
|
|
||||||
}
|
}
|
||||||
|
}, [mapGroupQuery, database]);
|
||||||
|
|
||||||
// Loads maps without the file data to save memory
|
const getMap = useCallback(
|
||||||
async function loadMaps() {
|
async (mapId: string) => {
|
||||||
let storedMaps: Map[] = [];
|
let map = (await database?.table("maps").get(mapId)) as Map;
|
||||||
// Try to load maps with worker, fallback to database if failed
|
|
||||||
const packedMaps = await worker.loadData("maps");
|
|
||||||
// let packedMaps;
|
|
||||||
if (packedMaps) {
|
|
||||||
storedMaps = decode(packedMaps) as Map[];
|
|
||||||
} 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: Array<Map> = [...sortedMaps, ...defaultMapsWithIds];
|
|
||||||
setMaps(allMaps);
|
|
||||||
const storedStates = await database?.table("states").toArray() as MapState[];
|
|
||||||
setMapStates(storedStates);
|
|
||||||
setMapsLoading(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
loadMaps();
|
|
||||||
}, [userId, database, databaseStatus, worker]);
|
|
||||||
|
|
||||||
const mapsRef = useRef(maps);
|
|
||||||
useEffect(() => {
|
|
||||||
mapsRef.current = maps;
|
|
||||||
}, [maps]);
|
|
||||||
|
|
||||||
const getMap = useCallback((mapId) => {
|
|
||||||
return mapsRef.current.find((map) => map.id === mapId);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getMapFromDB = useCallback(
|
|
||||||
async (mapId) => {
|
|
||||||
let map = await database?.table("maps").get(mapId) as Map;
|
|
||||||
return map;
|
return map;
|
||||||
},
|
},
|
||||||
[database]
|
[database]
|
||||||
);
|
);
|
||||||
|
|
||||||
const getMapStateFromDB = useCallback(
|
const getMapState = useCallback(
|
||||||
async (mapId) => {
|
async (mapId) => {
|
||||||
let mapState = await database?.table("states").get(mapId) as MapState;
|
let mapState = (await database?.table("states").get(mapId)) as MapState;
|
||||||
return mapState;
|
return mapState;
|
||||||
},
|
},
|
||||||
[database]
|
[database]
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keep up to cachedMapMax amount of maps that you don't own
|
* Adds a map to the database, also adds an assosiated state and group for that map
|
||||||
* Sorted by when they we're last used
|
* @param {Object} map map to add
|
||||||
*/
|
|
||||||
const updateCache = useCallback(async () => {
|
|
||||||
const cachedMaps = await database?.table("maps").where("owner").notEqual(userId).sortBy("lastUsed") as Map[];
|
|
||||||
if (cachedMaps.length > cachedMapMax) {
|
|
||||||
const cacheDeleteCount = cachedMaps.length - cachedMapMax;
|
|
||||||
const idsToDelete = cachedMaps
|
|
||||||
.slice(0, cacheDeleteCount)
|
|
||||||
.map((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
|
|
||||||
* @param {Map} map map to add
|
|
||||||
*/
|
*/
|
||||||
const addMap = useCallback(
|
const addMap = useCallback(
|
||||||
async (map) => {
|
async (map) => {
|
||||||
// Just update map database as react state will be updated with an Observable
|
if (database) {
|
||||||
const state = { ...defaultMapState, mapId: map.id };
|
// Just update map database as react state will be updated with an Observable
|
||||||
await database?.table("maps").add(map);
|
const state = { ...defaultMapState, mapId: map.id };
|
||||||
await database?.table("states").add(state);
|
await database.table("maps").add(map);
|
||||||
if (map.owner !== userId) {
|
await database.table("states").add(state);
|
||||||
await updateCache();
|
const group = await database.table("groups").get("maps");
|
||||||
|
await database.table("groups").update("maps", {
|
||||||
|
items: [{ id: map.id, type: "item" }, ...group.items],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[database, updateCache, userId]
|
|
||||||
);
|
|
||||||
|
|
||||||
const removeMap = useCallback(
|
|
||||||
async (id) => {
|
|
||||||
await database?.table("maps").delete(id);
|
|
||||||
await database?.table("states").delete(id);
|
|
||||||
},
|
|
||||||
[database]
|
[database]
|
||||||
);
|
);
|
||||||
|
|
||||||
const removeMaps = useCallback(
|
const removeMaps = useCallback(
|
||||||
async (ids) => {
|
async (ids) => {
|
||||||
await database?.table("maps").bulkDelete(ids);
|
if (database) {
|
||||||
await database?.table("states").bulkDelete(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]
|
[database]
|
||||||
);
|
);
|
||||||
@ -201,23 +152,7 @@ export function MapDataProvider({ children }: { children: ReactChild }) {
|
|||||||
|
|
||||||
const updateMap = useCallback(
|
const updateMap = useCallback(
|
||||||
async (id, update) => {
|
async (id, update) => {
|
||||||
// fake-indexeddb throws an error when updating maps in production.
|
await database?.table("maps").update(id, update);
|
||||||
// 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: string) => database?.table("maps").update(id, update))
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
[database]
|
[database]
|
||||||
);
|
);
|
||||||
@ -229,112 +164,39 @@ export function MapDataProvider({ children }: { children: ReactChild }) {
|
|||||||
[database]
|
[database]
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
const updateMapGroups = useCallback(
|
||||||
* Adds a map to the database if none exists or replaces a map if it already exists
|
async (groups) => {
|
||||||
* Note: this does not add a map state to do that use AddMap
|
// Update group state immediately to avoid animation delay
|
||||||
* @param {Object} map the map to put
|
setMapGroups(groups);
|
||||||
*/
|
await database?.table("groups").update("maps", { items: groups });
|
||||||
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();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[database, updateCache, userId, worker]
|
[database]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create DB observable to sync creating and deleting
|
const [mapsById, setMapsById] = useState<Record<string, Map>>({});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!database || databaseStatus === "loading") {
|
setMapsById(
|
||||||
return;
|
maps.reduce((obj: Record<string, Map>, map) => {
|
||||||
}
|
obj[map.id] = map;
|
||||||
|
return obj;
|
||||||
function handleMapChanges(changes: any) {
|
}, {})
|
||||||
for (let change of changes) {
|
);
|
||||||
if (change.table === "maps") {
|
}, [maps]);
|
||||||
if (change.type === 1) {
|
|
||||||
// Created
|
|
||||||
const map: Map = change.obj;
|
|
||||||
const state: MapState = { ...defaultMapState, mapId: map.id };
|
|
||||||
setMaps((prevMaps) => [map, ...prevMaps]);
|
|
||||||
setMapStates((prevStates) => [state, ...prevStates]);
|
|
||||||
} else if (change.type === 2) {
|
|
||||||
const map = change.obj;
|
|
||||||
setMaps((prevMaps) => {
|
|
||||||
const newMaps = [...prevMaps];
|
|
||||||
const i = newMaps.findIndex((m) => m.id === map.id);
|
|
||||||
if (i > -1) {
|
|
||||||
newMaps[i] = map;
|
|
||||||
}
|
|
||||||
return newMaps;
|
|
||||||
});
|
|
||||||
} else if (change.type === 3) {
|
|
||||||
// Deleted
|
|
||||||
const id = change.key;
|
|
||||||
setMaps((prevMaps) => {
|
|
||||||
const filtered = prevMaps.filter((map) => map.id !== id);
|
|
||||||
return filtered;
|
|
||||||
});
|
|
||||||
setMapStates((prevMapsStates) => {
|
|
||||||
const filtered = prevMapsStates.filter(
|
|
||||||
(state) => state.mapId !== id
|
|
||||||
);
|
|
||||||
return filtered;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (change.table === "states") {
|
|
||||||
if (change.type === 2) {
|
|
||||||
// Update map state
|
|
||||||
const state = change.obj;
|
|
||||||
setMapStates((prevMapStates) => {
|
|
||||||
const newStates = [...prevMapStates];
|
|
||||||
const i = newStates.findIndex((s) => s.mapId === state.mapId);
|
|
||||||
if (i > -1) {
|
|
||||||
newStates[i] = state;
|
|
||||||
}
|
|
||||||
return newStates;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
database.on("changes", handleMapChanges);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
database.on("changes").unsubscribe(handleMapChanges);
|
|
||||||
};
|
|
||||||
}, [database, databaseStatus]);
|
|
||||||
|
|
||||||
const ownedMaps = maps.filter((map) => map.owner === userId);
|
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
maps,
|
maps,
|
||||||
ownedMaps,
|
|
||||||
mapStates,
|
mapStates,
|
||||||
|
mapGroups,
|
||||||
addMap,
|
addMap,
|
||||||
removeMap,
|
|
||||||
removeMaps,
|
removeMaps,
|
||||||
resetMap,
|
resetMap,
|
||||||
updateMap,
|
updateMap,
|
||||||
updateMaps,
|
|
||||||
updateMapState,
|
updateMapState,
|
||||||
putMap,
|
|
||||||
getMap,
|
getMap,
|
||||||
getMapFromDB,
|
|
||||||
mapsLoading,
|
mapsLoading,
|
||||||
getMapStateFromDB,
|
getMapState,
|
||||||
|
updateMapGroups,
|
||||||
|
mapsById,
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<MapDataContext.Provider value={value}>{children}</MapDataContext.Provider>
|
<MapDataContext.Provider value={value}>{children}</MapDataContext.Provider>
|
||||||
|
@ -1,46 +1,65 @@
|
|||||||
import React, { useState, useRef, useContext } from "react";
|
import React, { useState, useRef, useContext, useCallback } from "react";
|
||||||
import { omit, isEmpty } from "../helpers/shared";
|
|
||||||
|
|
||||||
const MapLoadingContext = React.createContext<any | undefined>(undefined);
|
type MapLoadingProgress = {
|
||||||
|
count: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
export function MapLoadingProvider({ children }: { children: any}) {
|
type MapLoadingProgressUpdate = MapLoadingProgress & {
|
||||||
const [loadingAssetCount, setLoadingAssetCount] = useState(0);
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
function assetLoadStart() {
|
type MapLoadingContext = {
|
||||||
setLoadingAssetCount((prevLoadingAssets) => prevLoadingAssets + 1);
|
isLoading: boolean;
|
||||||
}
|
assetLoadStart: (id: string) => void;
|
||||||
|
assetProgressUpdate: (update: MapLoadingProgressUpdate) => void;
|
||||||
|
loadingProgressRef: React.MutableRefObject<number | null>;
|
||||||
|
};
|
||||||
|
|
||||||
function assetLoadFinish() {
|
const MapLoadingContext =
|
||||||
setLoadingAssetCount((prevLoadingAssets) => prevLoadingAssets - 1);
|
React.createContext<MapLoadingContext | undefined>(undefined);
|
||||||
}
|
|
||||||
|
|
||||||
const assetProgressRef = useRef<any>({});
|
export function MapLoadingProvider({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
// Mapping from asset id to the count and total number of pieces loaded
|
||||||
|
const assetProgressRef = useRef<Record<string, MapLoadingProgress>>({});
|
||||||
|
// Loading progress of all assets between 0 and 1
|
||||||
const loadingProgressRef = useRef<number | null>(null);
|
const loadingProgressRef = useRef<number | null>(null);
|
||||||
function assetProgressUpdate({ id, count, total }: { id: string, count: number, total: number }) {
|
|
||||||
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) as any) {
|
|
||||||
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 = {
|
const value = {
|
||||||
assetLoadStart,
|
assetLoadStart,
|
||||||
assetLoadFinish,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
assetProgressUpdate,
|
assetProgressUpdate,
|
||||||
loadingProgressRef,
|
loadingProgressRef,
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useContext } from "react";
|
import React, { useEffect, useContext } from "react";
|
||||||
|
|
||||||
import { useDatabase } from "./DatabaseContext";
|
import { useDatabase } from "./DatabaseContext";
|
||||||
import { useAuth } from "./AuthContext";
|
import { useUserId } from "./UserIdContext";
|
||||||
|
|
||||||
import { getRandomMonster } from "../helpers/monsters";
|
import { getRandomMonster } from "../helpers/monsters";
|
||||||
|
|
||||||
@ -12,8 +12,14 @@ import { PlayerInfo } from "../components/party/PartyState";
|
|||||||
export const PlayerStateContext = React.createContext<any>(undefined);
|
export const PlayerStateContext = React.createContext<any>(undefined);
|
||||||
export const PlayerUpdaterContext = React.createContext<any>(() => {});
|
export const PlayerUpdaterContext = React.createContext<any>(() => {});
|
||||||
|
|
||||||
export function PlayerProvider({ session, children }: { session: Session, children: any}) {
|
export function PlayerProvider({
|
||||||
const { userId } = useAuth();
|
session,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
session: Session;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const userId = useUserId();
|
||||||
const { database, databaseStatus } = useDatabase();
|
const { database, databaseStatus } = useDatabase();
|
||||||
|
|
||||||
const [playerState, setPlayerState] = useNetworkedState(
|
const [playerState, setPlayerState] = useNetworkedState(
|
||||||
@ -55,7 +61,7 @@ export function PlayerProvider({ session, children }: { session: Session, childr
|
|||||||
if (
|
if (
|
||||||
playerState.nickname &&
|
playerState.nickname &&
|
||||||
database !== undefined &&
|
database !== undefined &&
|
||||||
databaseStatus !== "loading"
|
(databaseStatus === "loaded" || databaseStatus === "disabled")
|
||||||
) {
|
) {
|
||||||
database
|
database
|
||||||
.table("user")
|
.table("user")
|
||||||
|
259
src/contexts/TileDragContext.js
Normal file
259
src/contexts/TileDragContext.js
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
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,
|
||||||
|
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);
|
||||||
|
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;
|
||||||
|
}
|
@ -3,96 +3,59 @@ import React, {
|
|||||||
useState,
|
useState,
|
||||||
useContext,
|
useContext,
|
||||||
useCallback,
|
useCallback,
|
||||||
useRef,
|
useMemo,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { decode } from "@msgpack/msgpack";
|
import { useLiveQuery } from "dexie-react-hooks";
|
||||||
|
|
||||||
import { useAuth } from "./AuthContext";
|
|
||||||
import { useDatabase } from "./DatabaseContext";
|
import { useDatabase } from "./DatabaseContext";
|
||||||
|
|
||||||
import { DefaultToken, FileToken, Token, tokens as defaultTokens } from "../tokens";
|
import { Token } from "../tokens";
|
||||||
|
import { removeGroupsItems } from "../helpers/group";
|
||||||
|
|
||||||
type TokenDataContext = {
|
type TokenDataContext = {
|
||||||
tokens: Token[];
|
tokens: Token[];
|
||||||
ownedTokens: Token[];
|
|
||||||
addToken: (token: Token) => Promise<void>;
|
addToken: (token: Token) => Promise<void>;
|
||||||
removeToken: (id: string) => Promise<void>;
|
tokenGroups: any[];
|
||||||
removeTokens: (ids: string[]) => Promise<void>;
|
removeTokens: (ids: string[]) => Promise<void>;
|
||||||
updateToken: (id: string, update: Partial<Token>) => Promise<void>;
|
updateToken: (id: string, update: Partial<Token>) => Promise<void>;
|
||||||
updateTokens: (ids: string[], update: Partial<Token>) => Promise<void>;
|
getToken: (tokenId: string) => Promise<Token | undefined>;
|
||||||
putToken: (token: Token) => Promise<void>;
|
tokensById: Record<string, Token>;
|
||||||
getToken: (tokenId: string) => Token | undefined
|
|
||||||
tokensById: { [key: string]: Token; };
|
|
||||||
tokensLoading: boolean;
|
tokensLoading: boolean;
|
||||||
getTokenFromDB: (tokenId: string) => Promise<Token>;
|
updateTokenGroups: (groups: any[]) => void;
|
||||||
loadTokens: (tokenIds: string[]) => Promise<void>;
|
updateTokensHidden: (ids: string[], hideInSidebar: boolean) => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
const TokenDataContext = React.createContext<TokenDataContext | undefined>(undefined);
|
const TokenDataContext =
|
||||||
|
React.createContext<TokenDataContext | undefined>(undefined);
|
||||||
|
|
||||||
const cachedTokenMax = 100;
|
export function TokenDataProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const { database } = useDatabase();
|
||||||
|
|
||||||
export function TokenDataProvider({ children }: { children: any }) {
|
const tokensQuery = useLiveQuery<Token[]>(
|
||||||
const { database, databaseStatus, worker } = useDatabase();
|
() => database?.table("tokens").toArray() || [],
|
||||||
const { userId } = useAuth();
|
[database]
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
const tokens = useMemo(() => tokensQuery || [], [tokensQuery]);
|
||||||
* Contains all tokens without any file data,
|
const tokensLoading = useMemo(() => !tokensQuery, [tokensQuery]);
|
||||||
* to ensure file data is present call loadTokens
|
|
||||||
*/
|
|
||||||
const [tokens, setTokens] = useState<Token[]>([]);
|
|
||||||
const [tokensLoading, setTokensLoading] = useState(true);
|
|
||||||
|
|
||||||
|
const tokenGroupQuery = useLiveQuery(
|
||||||
|
() => database?.table("groups").get("tokens"),
|
||||||
|
[database]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [tokenGroups, setTokenGroups] = useState([]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!userId || !database || databaseStatus === "loading") {
|
async function updateTokenGroups() {
|
||||||
return;
|
const group = await database?.table("groups").get("tokens");
|
||||||
|
setTokenGroups(group.items);
|
||||||
}
|
}
|
||||||
function getDefaultTokens() {
|
if (database && tokenGroupQuery) {
|
||||||
const defaultTokensWithIds: Required<DefaultToken[]> = [];
|
updateTokenGroups();
|
||||||
for (let defaultToken of defaultTokens) {
|
|
||||||
defaultTokensWithIds.push({
|
|
||||||
...defaultToken,
|
|
||||||
id: `__default-${defaultToken.name}`,
|
|
||||||
owner: userId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return defaultTokensWithIds;
|
|
||||||
}
|
}
|
||||||
|
}, [tokenGroupQuery, database]);
|
||||||
|
|
||||||
// Loads tokens without the file data to save memory
|
const getToken = useCallback(
|
||||||
async function loadTokens() {
|
|
||||||
let storedTokens: any = [];
|
|
||||||
// Try to load tokens with worker, fallback to database if failed
|
|
||||||
const packedTokens: ArrayLike<number> | BufferSource = 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: FileToken) => {
|
|
||||||
const { file, ...rest } = token;
|
|
||||||
storedTokens.push(rest);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const sortedTokens = storedTokens.sort((a: any, b: any) => b.created - a.created);
|
|
||||||
const defaultTokensWithIds = getDefaultTokens();
|
|
||||||
const allTokens = [...sortedTokens, ...defaultTokensWithIds];
|
|
||||||
setTokens(allTokens);
|
|
||||||
setTokensLoading(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
loadTokens();
|
|
||||||
}, [userId, database, databaseStatus, worker]);
|
|
||||||
|
|
||||||
const tokensRef = useRef(tokens);
|
|
||||||
useEffect(() => {
|
|
||||||
tokensRef.current = tokens;
|
|
||||||
}, [tokens]);
|
|
||||||
|
|
||||||
const getToken = useCallback((tokenId) => {
|
|
||||||
return tokensRef.current.find((token) => token.id === tokenId);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getTokenFromDB = useCallback(
|
|
||||||
async (tokenId) => {
|
async (tokenId) => {
|
||||||
let token = await database?.table("tokens").get(tokenId);
|
let token = await database?.table("tokens").get(tokenId);
|
||||||
return token;
|
return token;
|
||||||
@ -100,165 +63,89 @@ export function TokenDataProvider({ children }: { children: any }) {
|
|||||||
[database]
|
[database]
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
// Add token and add it to the token group
|
||||||
* 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: Token[] | undefined = await database?.table("tokens").where("owner").notEqual(userId).sortBy("lastUsed");
|
|
||||||
// TODO: handle undefined cachedTokens
|
|
||||||
if (!cachedTokens) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
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]);
|
|
||||||
|
|
||||||
const addToken = useCallback(
|
const addToken = useCallback(
|
||||||
async (token) => {
|
async (token) => {
|
||||||
await database?.table("tokens").add(token);
|
if (database) {
|
||||||
if (token.owner !== userId) {
|
await database.table("tokens").add(token);
|
||||||
await updateCache();
|
const group = await database.table("groups").get("tokens");
|
||||||
|
await database.table("groups").update("tokens", {
|
||||||
|
items: [{ id: token.id, type: "item" }, ...group.items],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[database, updateCache, userId]
|
|
||||||
);
|
|
||||||
|
|
||||||
const removeToken = useCallback(
|
|
||||||
async (id: string) => {
|
|
||||||
await database?.table("tokens").delete(id);
|
|
||||||
},
|
|
||||||
[database]
|
[database]
|
||||||
);
|
);
|
||||||
|
|
||||||
const removeTokens = useCallback(
|
const removeTokens = useCallback(
|
||||||
async (ids: string[]) => {
|
async (ids) => {
|
||||||
await database?.table("tokens").bulkDelete(ids);
|
if (database) {
|
||||||
|
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]
|
[database]
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateToken = useCallback(
|
const updateToken = useCallback(
|
||||||
async (id: string, update: any) => {
|
async (id, update) => {
|
||||||
const change = { lastModified: Date.now(), ...update };
|
await database?.table("tokens").update(id, update);
|
||||||
await database?.table("tokens").update(id, change);
|
|
||||||
},
|
},
|
||||||
[database]
|
[database]
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateTokens = useCallback(
|
const updateTokensHidden = useCallback(
|
||||||
async (ids, update) => {
|
async (ids: string[], hideInSidebar: boolean) => {
|
||||||
const change = { lastModified: Date.now(), ...update };
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
ids.map((id: string) => database?.table("tokens").update(id, change))
|
ids.map((id) => database?.table("tokens").update(id, { hideInSidebar }))
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[database]
|
[database]
|
||||||
);
|
);
|
||||||
|
|
||||||
const putToken = useCallback(
|
const updateTokenGroups = useCallback(
|
||||||
async (token) => {
|
async (groups) => {
|
||||||
await database?.table("tokens").put(token);
|
// Update group state immediately to avoid animation delay
|
||||||
if (token.owner !== userId) {
|
setTokenGroups(groups);
|
||||||
await updateCache();
|
await database?.table("groups").update("tokens", { items: groups });
|
||||||
}
|
|
||||||
},
|
|
||||||
[database, updateCache, userId]
|
|
||||||
);
|
|
||||||
|
|
||||||
const loadTokens = useCallback(
|
|
||||||
async (tokenIds: string[]) => {
|
|
||||||
const loadedTokens: FileToken[] | undefined = await database?.table("tokens").bulkGet(tokenIds);
|
|
||||||
const loadedTokensById = loadedTokens?.reduce((obj: { [key: string]: FileToken }, token: FileToken) => {
|
|
||||||
obj[token.id] = token;
|
|
||||||
return obj;
|
|
||||||
}, {});
|
|
||||||
if (!loadedTokensById) {
|
|
||||||
// TODO: whatever
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setTokens((prevTokens: Token[]) => {
|
|
||||||
return prevTokens.map((prevToken) => {
|
|
||||||
if (prevToken.id in loadedTokensById) {
|
|
||||||
return loadedTokensById[prevToken.id];
|
|
||||||
} else {
|
|
||||||
return prevToken;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[database]
|
[database]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create DB observable to sync creating and deleting
|
const [tokensById, setTokensById] = useState({});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!database || databaseStatus === "loading") {
|
setTokensById(
|
||||||
return;
|
tokens.reduce((obj: Record<string, Token>, token: Token) => {
|
||||||
}
|
obj[token.id] = token;
|
||||||
|
return obj;
|
||||||
function handleTokenChanges(changes: any) {
|
}, {})
|
||||||
for (let change of changes) {
|
);
|
||||||
if (change.table === "tokens") {
|
}, [tokens]);
|
||||||
if (change.type === 1) {
|
|
||||||
// Created
|
|
||||||
const token = change.obj;
|
|
||||||
setTokens((prevTokens) => [token, ...prevTokens]);
|
|
||||||
} 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;
|
|
||||||
});
|
|
||||||
} else if (change.type === 3) {
|
|
||||||
// Deleted
|
|
||||||
const id = change.key;
|
|
||||||
setTokens((prevTokens) => {
|
|
||||||
const filtered = prevTokens.filter((token) => token.id !== id);
|
|
||||||
return filtered;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
database.on("changes", handleTokenChanges);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
database.on("changes").unsubscribe(handleTokenChanges);
|
|
||||||
};
|
|
||||||
}, [database, databaseStatus]);
|
|
||||||
|
|
||||||
const ownedTokens = tokens.filter((token) => token.owner === userId);
|
|
||||||
|
|
||||||
const tokensById: { [key: string]: Token; } = tokens.reduce((obj: { [key: string]: Token }, token) => {
|
|
||||||
obj[token.id] = token;
|
|
||||||
return obj;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
const value: TokenDataContext = {
|
const value: TokenDataContext = {
|
||||||
tokens,
|
tokens,
|
||||||
ownedTokens,
|
|
||||||
addToken,
|
addToken,
|
||||||
removeToken,
|
tokenGroups,
|
||||||
removeTokens,
|
removeTokens,
|
||||||
updateToken,
|
updateToken,
|
||||||
updateTokens,
|
|
||||||
putToken,
|
|
||||||
getToken,
|
|
||||||
tokensById,
|
tokensById,
|
||||||
tokensLoading,
|
tokensLoading,
|
||||||
getTokenFromDB,
|
getToken,
|
||||||
loadTokens,
|
updateTokenGroups,
|
||||||
|
updateTokensHidden,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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;
|
478
src/database.ts
478
src/database.ts
@ -1,453 +1,32 @@
|
|||||||
// eslint-disable-next-line no-unused-vars
|
// eslint-disable-next-line no-unused-vars
|
||||||
import Dexie, { Version, DexieOptions, Transaction } from "dexie";
|
import Dexie, { DexieOptions } from "dexie";
|
||||||
import "dexie-observable";
|
import { v4 as uuid } from "uuid";
|
||||||
import shortid from "shortid";
|
|
||||||
|
|
||||||
import blobToBuffer from "./helpers/blobToBuffer";
|
import { loadVersions } from "./upgrade";
|
||||||
import { getGridDefaultInset, Grid } from "./helpers/grid";
|
import { getDefaultMaps } from "./maps";
|
||||||
import { convertOldActionsToShapes } from "./actions";
|
import { getDefaultTokens } from "./tokens";
|
||||||
import { createThumbnail } from "./helpers/image";
|
|
||||||
|
|
||||||
// Helper to create a thumbnail for a file in a db
|
|
||||||
async function createDataThumbnail(data: any) {
|
|
||||||
let url: string;
|
|
||||||
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 () => {
|
|
||||||
// TODO: confirm parameter for type here
|
|
||||||
const thumbnail = await createThumbnail(image, "file");
|
|
||||||
resolve(thumbnail);
|
|
||||||
};
|
|
||||||
image.src = url;
|
|
||||||
}),
|
|
||||||
60000 * 10 // 10 minute timeout
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @callback VersionCallback
|
* Populate DB with initial data
|
||||||
* @param {Version} version
|
|
||||||
*/
|
|
||||||
|
|
||||||
type VersionCallback = (version: Version) => void
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mapping of version number to their upgrade function
|
|
||||||
* @type {Object.<number, VersionCallback>}
|
|
||||||
*/
|
|
||||||
|
|
||||||
const versions: Record<number, VersionCallback> = {
|
|
||||||
// v1.2.0
|
|
||||||
1(v: Version) {
|
|
||||||
v.stores({
|
|
||||||
maps: "id, owner",
|
|
||||||
states: "mapId",
|
|
||||||
tokens: "id, owner",
|
|
||||||
user: "key",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
// v1.2.1 - Move from blob files to array buffers
|
|
||||||
2(v: Version) {
|
|
||||||
v.stores({}).upgrade(async (tx: Transaction) => {
|
|
||||||
const maps = await Dexie.waitFor(tx.table("maps").toArray());
|
|
||||||
let mapBuffers: any = {};
|
|
||||||
for (let map of maps) {
|
|
||||||
mapBuffers[map.id] = await Dexie.waitFor(blobToBuffer(map.file));
|
|
||||||
}
|
|
||||||
return tx
|
|
||||||
.table("maps")
|
|
||||||
.toCollection()
|
|
||||||
.modify((map: any) => {
|
|
||||||
map.file = mapBuffers[map.id];
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
// v1.3.0 - Added new default tokens
|
|
||||||
3(v: Version) {
|
|
||||||
v.stores({}).upgrade((tx: Transaction) => {
|
|
||||||
return tx
|
|
||||||
.table("states")
|
|
||||||
.toCollection()
|
|
||||||
.modify((state: any) => {
|
|
||||||
function mapTokenId(id: any) {
|
|
||||||
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: Version) {
|
|
||||||
v.stores({}).upgrade((tx: Transaction) => {
|
|
||||||
return tx
|
|
||||||
.table("maps")
|
|
||||||
.toCollection()
|
|
||||||
.modify((map: any) => {
|
|
||||||
map.showGrid = false;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
// v1.4.0 - Added fog subtraction
|
|
||||||
5(v: Version) {
|
|
||||||
v.stores({}).upgrade((tx: Transaction) => {
|
|
||||||
return tx
|
|
||||||
.table("states")
|
|
||||||
.toCollection()
|
|
||||||
.modify((state: any) => {
|
|
||||||
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: Version) {
|
|
||||||
v.stores({}).upgrade((tx: Transaction) => {
|
|
||||||
return tx
|
|
||||||
.table("maps")
|
|
||||||
.toCollection()
|
|
||||||
.modify((map: any) => {
|
|
||||||
map.resolutions = {};
|
|
||||||
map.quality = "original";
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
// v1.5.0 - Fixed default token rogue spelling
|
|
||||||
7(v: Version) {
|
|
||||||
v.stores({}).upgrade((tx: Transaction) => {
|
|
||||||
return tx
|
|
||||||
.table("states")
|
|
||||||
.toCollection()
|
|
||||||
.modify((state: any) => {
|
|
||||||
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: Version) {
|
|
||||||
v.stores({}).upgrade((tx: Transaction) => {
|
|
||||||
return tx
|
|
||||||
.table("maps")
|
|
||||||
.toCollection()
|
|
||||||
.modify((map: any) => {
|
|
||||||
map.snapToGrid = true;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
// v1.5.1 - Added lock, visibility and modified to tokens
|
|
||||||
9(v: Version) {
|
|
||||||
v.stores({}).upgrade((tx: Transaction) => {
|
|
||||||
return tx
|
|
||||||
.table("states")
|
|
||||||
.toCollection()
|
|
||||||
.modify((state: any) => {
|
|
||||||
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: Version) {
|
|
||||||
v.stores({}).upgrade((tx: Transaction) => {
|
|
||||||
return tx
|
|
||||||
.table("tokens")
|
|
||||||
.toCollection()
|
|
||||||
.modify((token: any) => {
|
|
||||||
token.category = token.isVehicle ? "vehicle" : "character";
|
|
||||||
delete token.isVehicle;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
// v1.5.2 - Added automatic cache invalidation to maps
|
|
||||||
11(v: Version) {
|
|
||||||
v.stores({}).upgrade((tx: Transaction) => {
|
|
||||||
return tx
|
|
||||||
.table("maps")
|
|
||||||
.toCollection()
|
|
||||||
.modify((map: any) => {
|
|
||||||
map.lastUsed = map.lastModified;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
// v1.5.2 - Added automatic cache invalidation to tokens
|
|
||||||
12(v: Version) {
|
|
||||||
v.stores({}).upgrade((tx: Transaction) => {
|
|
||||||
return tx
|
|
||||||
.table("tokens")
|
|
||||||
.toCollection()
|
|
||||||
.modify((token: any) => {
|
|
||||||
token.lastUsed = token.lastModified;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
// v1.6.0 - Added map grouping and grid scale and offset
|
|
||||||
13(v: Version) {
|
|
||||||
v.stores({}).upgrade((tx: Transaction) => {
|
|
||||||
return tx
|
|
||||||
.table("maps")
|
|
||||||
.toCollection()
|
|
||||||
.modify((map: any) => {
|
|
||||||
map.group = "";
|
|
||||||
map.grid = {
|
|
||||||
size: { x: map.gridX, y: map.gridY },
|
|
||||||
inset: getGridDefaultInset(
|
|
||||||
{ size: { x: map.gridX, y: map.gridY }, type: "square" } as Grid,
|
|
||||||
map.width,
|
|
||||||
map.height
|
|
||||||
),
|
|
||||||
type: "square",
|
|
||||||
};
|
|
||||||
delete map.gridX;
|
|
||||||
delete map.gridY;
|
|
||||||
delete map.gridType;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
// v1.6.0 - Added token grouping
|
|
||||||
14(v: Version) {
|
|
||||||
v.stores({}).upgrade((tx: Transaction) => {
|
|
||||||
return tx
|
|
||||||
.table("tokens")
|
|
||||||
.toCollection()
|
|
||||||
.modify((token: any) => {
|
|
||||||
token.group = "";
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
// v1.6.1 - Added width and height to tokens
|
|
||||||
15(v: Version) {
|
|
||||||
v.stores({}).upgrade(async (tx: Transaction) => {
|
|
||||||
const tokens = await Dexie.waitFor(tx.table("tokens").toArray());
|
|
||||||
let tokenSizes: any = {};
|
|
||||||
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: any) => {
|
|
||||||
token.width = tokenSizes[token.id].width;
|
|
||||||
token.height = tokenSizes[token.id].height;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
// v1.7.0 - Added note tool
|
|
||||||
16(v: Version) {
|
|
||||||
v.stores({}).upgrade((tx: Transaction) => {
|
|
||||||
return tx
|
|
||||||
.table("states")
|
|
||||||
.toCollection()
|
|
||||||
.modify((state: any) => {
|
|
||||||
state.notes = {};
|
|
||||||
state.editFlags = [...state.editFlags, "notes"];
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
// 1.7.0 (hotfix) - Optimized fog shape edits to only include needed data
|
|
||||||
17(v: Version) {
|
|
||||||
v.stores({}).upgrade((tx: Transaction) => {
|
|
||||||
return tx
|
|
||||||
.table("states")
|
|
||||||
.toCollection()
|
|
||||||
.modify((state: any) => {
|
|
||||||
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: Version) {
|
|
||||||
v.stores({}).upgrade((tx: Transaction) => {
|
|
||||||
return tx
|
|
||||||
.table("states")
|
|
||||||
.toCollection()
|
|
||||||
.modify((state: any) => {
|
|
||||||
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: Version) {
|
|
||||||
v.stores({}).upgrade(async (tx: Transaction) => {
|
|
||||||
const userId = (await Dexie.waitFor(tx.table("user").get("userId")))
|
|
||||||
.value;
|
|
||||||
const maps = await Dexie.waitFor(tx.table("maps").toArray());
|
|
||||||
const thumbnails: any = {};
|
|
||||||
for (let map of maps) {
|
|
||||||
try {
|
|
||||||
if (map.owner === userId) {
|
|
||||||
thumbnails[map.id] = await createDataThumbnail(map);
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
return tx
|
|
||||||
.table("maps")
|
|
||||||
.toCollection()
|
|
||||||
.modify((map: any) => {
|
|
||||||
map.thumbnail = thumbnails[map.id];
|
|
||||||
map.grid.measurement = { type: "chebyshev", scale: "5ft" };
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
// 1.8.0 - Add thumbnail to tokens
|
|
||||||
20(v: Version) {
|
|
||||||
v.stores({}).upgrade(async (tx: Transaction) => {
|
|
||||||
const userId = (await Dexie.waitFor(tx.table("user").get("userId")))
|
|
||||||
.value;
|
|
||||||
const tokens = await Dexie.waitFor(tx.table("tokens").toArray());
|
|
||||||
const thumbnails: any = {};
|
|
||||||
for (let token of tokens) {
|
|
||||||
try {
|
|
||||||
if (token.owner === userId) {
|
|
||||||
thumbnails[token.id] = await createDataThumbnail(token);
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
return tx
|
|
||||||
.table("tokens")
|
|
||||||
.toCollection()
|
|
||||||
.modify((token: any) => {
|
|
||||||
token.thumbnail = thumbnails[token.id];
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
// 1.8.0 - Upgrade for Dexie.Observable
|
|
||||||
21(v: Version) {
|
|
||||||
v.stores({});
|
|
||||||
},
|
|
||||||
// v1.8.1 - Shorten fog shape ids
|
|
||||||
22(v: Version) {
|
|
||||||
v.stores({}).upgrade((tx: Transaction) => {
|
|
||||||
return tx
|
|
||||||
.table("states")
|
|
||||||
.toCollection()
|
|
||||||
.modify((state: any) => {
|
|
||||||
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
|
|
||||||
* @param {Dexie} db
|
* @param {Dexie} db
|
||||||
* @param {number=} upTo version number to load up to, latest version if undefined
|
|
||||||
*/
|
*/
|
||||||
export function loadVersions(db: Dexie, upTo = latestVersion) {
|
function populate(db) {
|
||||||
for (let versionNumber = 1; versionNumber <= upTo; versionNumber++) {
|
db.on("populate", () => {
|
||||||
versions[versionNumber](db.version(versionNumber));
|
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" })),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -455,14 +34,21 @@ export function loadVersions(db: Dexie, upTo = latestVersion) {
|
|||||||
* @param {DexieOptions} options
|
* @param {DexieOptions} options
|
||||||
* @param {string=} name
|
* @param {string=} name
|
||||||
* @param {number=} versionNumber
|
* @param {number=} versionNumber
|
||||||
|
* @param {boolean=} populateData
|
||||||
|
* @param {import("./upgrade").OnUpgrade=} onUpgrade
|
||||||
* @returns {Dexie}
|
* @returns {Dexie}
|
||||||
*/
|
*/
|
||||||
export function getDatabase(
|
export function getDatabase(
|
||||||
options: DexieOptions,
|
options: DexieOptions,
|
||||||
name = "OwlbearRodeoDB",
|
name = "OwlbearRodeoDB",
|
||||||
versionNumber = latestVersion
|
versionNumber = undefined,
|
||||||
|
populateData = true,
|
||||||
|
onUpgrade = undefined
|
||||||
) {
|
) {
|
||||||
let db = new Dexie(name, options);
|
let db = new Dexie(name, options);
|
||||||
loadVersions(db, versionNumber);
|
loadVersions(db, versionNumber, onUpgrade);
|
||||||
|
if (populateData) {
|
||||||
|
populate(db);
|
||||||
|
}
|
||||||
return 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:
|
An overview of each setting is listed below:
|
||||||
|
|
||||||
- Light theme: Enables/disables the light theme.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
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
|
## Default Maps
|
||||||
@ -41,7 +39,6 @@ Next you can set the name of your map shown in the Map Select Screen.
|
|||||||
## Editing Maps (Advanced)
|
## Editing Maps (Advanced)
|
||||||
|
|
||||||
When editing a map there are also a few more advanced settings available.
|
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)
|
![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).
|
- 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).
|
- 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.
|
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.
|
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
|
## 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.
|
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.
|
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.
|
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.
|
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)
|
![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.
|
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
|
## 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.
|
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`
|
`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
|
## Major Changes
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
[embed:](https://www.youtube.com/embed/IhSS24d4zlM)
|
![embed:](https://www.youtube.com/embed/IhSS24d4zlM)
|
||||||
|
|
||||||
## Major Changes
|
## Major Changes
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
[embed:](https://www.youtube.com/embed/Y7sEgoopz4E)
|
![embed:](https://www.youtube.com/embed/Y7sEgoopz4E)
|
||||||
|
|
||||||
## Major Changes
|
## Major Changes
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
[embed:](https://www.youtube.com/embed/vtNpj-449B8)
|
![embed:](https://www.youtube.com/embed/vtNpj-449B8)
|
||||||
|
|
||||||
## Major Changes
|
## Major Changes
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
[embed:](https://www.youtube.com/embed/i4JvZboAPhQ)
|
![embed:](https://www.youtube.com/embed/i4JvZboAPhQ)
|
||||||
|
|
||||||
## Major Changes
|
## 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