Merge pull request #47 from mitchemmc/release/1.10.0

Release/1.10.0
This commit is contained in:
Mitchell McCaffrey 2021-08-19 18:16:15 +10:00 committed by GitHub
commit 0123cd0995
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
415 changed files with 14646 additions and 7600 deletions

3
.gitignore vendored
View File

@ -21,3 +21,6 @@
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# typescript
tsconfig.tsbuildinfo

View File

@ -1,36 +1,37 @@
{
"name": "owlbear-rodeo",
"version": "1.9.0",
"version": "1.10.0",
"private": true,
"dependencies": {
"@babylonjs/core": "^4.2.0",
"@babylonjs/loaders": "^4.2.0",
"@dnd-kit/core": "^3.0.4",
"@dnd-kit/sortable": "^3.1.0",
"@dnd-kit/core": "^3.1.1",
"@dnd-kit/sortable": "^4.0.0",
"@mitchemmc/dexie-export-import": "^1.0.1",
"@msgpack/msgpack": "^2.4.1",
"@react-spring/konva": "^9.2.3",
"@sentry/integrations": "^6.3.0",
"@sentry/react": "^6.3.0",
"@stripe/stripe-js": "^1.13.1",
"@tensorflow/tfjs": "^3.3.0",
"@msgpack/msgpack": "^2.7.0",
"@react-spring/konva": "^9.2.4",
"@sentry/integrations": "^6.11.0",
"@sentry/react": "^6.11.0",
"@stripe/stripe-js": "^1.16.0",
"@tensorflow/tfjs": "^3.8.0",
"@testing-library/jest-dom": "^5.11.9",
"@testing-library/react": "^11.2.5",
"@testing-library/user-event": "^13.0.2",
"ammo.js": "kripken/ammo.js#aab297a4164779c3a9d8dc8d9da26958de3cb778",
"ajv": "^8.6.2",
"ammo.js": "kripken/ammo.js",
"case": "^1.6.3",
"color": "^3.1.3",
"comlink": "^4.3.0",
"color": "^3.2.1",
"comlink": "^4.3.1",
"deep-diff": "^1.0.2",
"dexie": "3.1.0-beta.13",
"dexie-react-hooks": "^1.0.6",
"dexie-react-hooks": "^1.0.7",
"err-code": "^3.0.1",
"fake-indexeddb": "^3.1.2",
"fake-indexeddb": "^3.1.3",
"file-saver": "^2.0.5",
"fuse.js": "^6.4.6",
"image-outline": "^0.1.0",
"intersection-observer": "^0.12.0",
"konva": "^8.1.1",
"konva": "^8.1.3",
"lodash.chunk": "^4.2.0",
"lodash.clonedeep": "^4.5.0",
"lodash.get": "^4.4.2",
@ -38,36 +39,37 @@
"lodash.unset": "^4.5.2",
"normalize-wheel": "^1.0.1",
"pepjs": "^0.5.3",
"polygon-clipping": "^0.15.2",
"polygon-clipping": "^0.15.3",
"pretty-bytes": "^5.6.0",
"raw.macro": "^0.4.2",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-intersection-observer": "^8.32.0",
"react-konva": "^17.0.2-5",
"react-konva-utils": "^0.1.7",
"react-markdown": "4",
"react-media": "^2.0.0-rc.1",
"react-modal": "^3.12.1",
"react-resize-detector": "4.2.3",
"react-modal": "^3.14.3",
"react-resize-detector": "^6.7.4",
"react-router-dom": "^5.1.2",
"react-router-hash-link": "^2.2.2",
"react-router-hash-link": "^2.4.3",
"react-scripts": "^4.0.3",
"react-select": "^4.2.1",
"react-spring": "^9.2.3",
"react-select": "^4.3.1",
"react-spring": "^9.2.4",
"react-textarea-autosize": "^8.3.3",
"react-toast-notifications": "^2.4.3",
"react-toast-notifications": "^2.5.1",
"react-use-gesture": "^9.1.3",
"shortid": "^2.2.15",
"simple-peer": "feross/simple-peer#694/head",
"simplebar-react": "^2.1.0",
"simple-peer": "^9.11.0",
"simplebar-react": "^2.3.5",
"simplify-js": "^1.2.4",
"socket.io-client": "^4.0.0",
"socket.io-client": "^4.1.3",
"socket.io-msgpack-parser": "^3.0.1",
"source-map-explorer": "^2.5.2",
"theme-ui": "^0.3.1",
"use-image": "^1.0.7",
"theme-ui": "^0.10.0",
"use-image": "^1.0.8",
"uuid": "^8.3.2",
"webrtc-adapter": "^7.7.1"
"webrtc-adapter": "^8.1.0"
},
"resolutions": {
"simple-peer/get-browser-rtc": "substack/get-browser-rtc#4/head"
@ -95,6 +97,25 @@
]
},
"devDependencies": {
"@types/color": "^3.0.1",
"@types/deep-diff": "^1.0.0",
"@types/file-saver": "^2.0.2",
"@types/jest": "^26.0.23",
"@types/lodash.chunk": "^4.2.6",
"@types/lodash.clonedeep": "^4.5.6",
"@types/lodash.get": "^4.4.6",
"@types/lodash.set": "^4.3.6",
"@types/node": "^15.6.0",
"@types/normalize-wheel": "^1.0.0",
"@types/react": "^17.0.6",
"@types/react-dom": "^17.0.5",
"@types/react-modal": "^3.12.0",
"@types/react-router-dom": "^5.1.7",
"@types/react-select": "^4.0.17",
"@types/shortid": "^0.0.29",
"@types/simple-peer": "^9.11.1",
"@types/uuid": "^8.3.1",
"typescript": "^4.2.4",
"worker-loader": "^3.0.8"
}
}

View File

@ -1,8 +1,7 @@
import React from "react";
import { ThemeProvider } from "theme-ui";
import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
import theme from "./theme.js";
import theme from "./theme";
import Home from "./routes/Home";
import Game from "./routes/Game";
import About from "./routes/About";

View File

@ -1,40 +1,31 @@
// Load Diff for auto complete
// eslint-disable-next-line no-unused-vars
import { Diff } from "deep-diff";
import { diff, revertChanges } from "../helpers/diff";
import cloneDeep from "lodash.clonedeep";
/**
* @callback ActionUpdate
* @param {any} state
*/
/**
* Implementation of the Command Pattern
* Wraps an update function with internal state to support undo
*/
class Action {
class Action<State> {
/**
* The update function called with the current state and should return the updated state
* This is implemented in the child class
*
* @type {ActionUpdate}
*/
update;
update(state: State): State {
return state;
}
/**
* The changes caused by the last state update
* @type {Diff}
*/
changes;
changes: Diff<State, State>[] | undefined;
/**
* Executes the action update on the state
* @param {any} state The current state to update
* @returns {any} The updated state
* @param {State} state The current state to update
*/
execute(state) {
execute(state: State): State {
if (state && this.update) {
let newState = this.update(cloneDeep(state));
this.changes = diff(state, newState);
@ -45,10 +36,10 @@ class Action {
/**
* Reverts the changes caused by the last call of `execute`
* @param {any} state The current state to perform the undo on
* @returns {any} The state with the last changes reverted
* @param {State} state The current state to perform the undo on
* @returns {State} The state with the last changes reverted
*/
undo(state) {
undo(state: State): State {
if (state && this.changes) {
let revertedState = cloneDeep(state);
revertChanges(revertedState, this.changes);

View File

@ -1,15 +0,0 @@
import Action from "./Action";
class AddShapeAction extends Action {
constructor(shapes) {
super();
this.update = (shapesById) => {
for (let shape of shapes) {
shapesById[shape.id] = shape;
}
return shapesById;
};
}
}
export default AddShapeAction;

View File

@ -0,0 +1,21 @@
import Action from "./Action";
import { ID } from "../types/Action";
class AddStatesAction<State extends ID> extends Action<Record<string, State>> {
states: State[];
constructor(states: State[]) {
super();
this.states = states;
}
update(statesById: Record<string, State>) {
for (let state of this.states) {
statesById[state.id] = state;
}
return statesById;
}
}
export default AddStatesAction;

View File

@ -0,0 +1,41 @@
import polygonClipping from "polygon-clipping";
import Action from "./Action";
import {
addPolygonDifferenceToFog,
addPolygonIntersectionToFog,
fogToGeometry,
} from "../helpers/actions";
import { Fog, FogState } from "../types/Fog";
class CutFogAction extends Action<FogState> {
fogs: Fog[];
constructor(fog: Fog[]) {
super();
this.fogs = fog;
}
update(fogsById: FogState): FogState {
let actionGeom = this.fogs.map(fogToGeometry);
let cutFogs: FogState = {};
for (let fog of Object.values(fogsById)) {
const fogGeom = fogToGeometry(fog);
try {
const difference = polygonClipping.difference(fogGeom, ...actionGeom);
const intersection = polygonClipping.intersection(
fogGeom,
...actionGeom
);
addPolygonDifferenceToFog(fog, difference, cutFogs);
addPolygonIntersectionToFog(fog, intersection, cutFogs);
} catch {
console.error("Unable to find intersection for fogs");
}
}
return cutFogs;
}
}
export default CutFogAction;

View File

@ -1,38 +0,0 @@
import polygonClipping from "polygon-clipping";
import Action from "./Action";
import {
addPolygonDifferenceToShapes,
addPolygonIntersectionToShapes,
shapeToGeometry,
} from "../helpers/actions";
class CutShapeAction extends Action {
constructor(shapes) {
super();
this.update = (shapesById) => {
let actionGeom = shapes.map(shapeToGeometry);
let cutShapes = {};
for (let shape of Object.values(shapesById)) {
const shapeGeom = shapeToGeometry(shape);
try {
const difference = polygonClipping.difference(
shapeGeom,
...actionGeom
);
const intersection = polygonClipping.intersection(
shapeGeom,
...actionGeom
);
addPolygonDifferenceToShapes(shape, difference, cutShapes);
addPolygonIntersectionToShapes(shape, intersection, cutShapes);
} catch {
console.error("Unable to find intersection for shapes");
}
}
return cutShapes;
};
}
}
export default CutShapeAction;

View File

@ -1,17 +0,0 @@
import Action from "./Action";
class EditShapeAction extends Action {
constructor(shapes) {
super();
this.update = (shapesById) => {
for (let edit of shapes) {
if (edit.id in shapesById) {
shapesById[edit.id] = { ...shapesById[edit.id], ...edit };
}
}
return shapesById;
};
}
}
export default EditShapeAction;

View File

@ -0,0 +1,23 @@
import Action from "./Action";
import { ID } from "../types/Action";
class EditStatesAction<State extends ID> extends Action<Record<string, State>> {
edits: Partial<State>[];
constructor(edits: Partial<State>[]) {
super();
this.edits = edits;
}
update(statesById: Record<string, State>) {
for (let edit of this.edits) {
if (edit.id !== undefined && edit.id in statesById) {
statesById[edit.id] = { ...statesById[edit.id], ...edit };
}
}
return statesById;
}
}
export default EditStatesAction;

View File

@ -1,13 +0,0 @@
import Action from "./Action";
import { omit } from "../helpers/shared";
class RemoveShapeAction extends Action {
constructor(shapeIds) {
super();
this.update = (shapesById) => {
return omit(shapesById, shapeIds);
};
}
}
export default RemoveShapeAction;

View File

@ -0,0 +1,21 @@
import Action from "./Action";
import { omit } from "../helpers/shared";
import { ID } from "../types/Action";
class RemoveStatesAction<State extends ID> extends Action<
Record<string, State>
> {
stateIds: string[];
constructor(stateIds: string[]) {
super();
this.stateIds = stateIds;
}
update(statesById: Record<string, State>) {
return omit(statesById, this.stateIds);
}
}
export default RemoveStatesAction;

View File

@ -0,0 +1,32 @@
import polygonClipping from "polygon-clipping";
import Action from "./Action";
import { addPolygonDifferenceToFog, fogToGeometry } from "../helpers/actions";
import { Fog, FogState } from "../types/Fog";
class SubtractFogAction extends Action<FogState> {
fogs: Fog[];
constructor(fogs: Fog[]) {
super();
this.fogs = fogs;
}
update(fogsById: FogState): FogState {
const actionGeom = this.fogs.map(fogToGeometry);
let subtractedFogs: FogState = {};
for (let fog of Object.values(fogsById)) {
const fogGeom = fogToGeometry(fog);
try {
const difference = polygonClipping.difference(fogGeom, ...actionGeom);
addPolygonDifferenceToFog(fog, difference, subtractedFogs);
} catch {
console.error("Unable to find difference for fogs");
}
}
return subtractedFogs;
}
}
export default SubtractFogAction;

View File

@ -1,32 +0,0 @@
import polygonClipping from "polygon-clipping";
import Action from "./Action";
import {
addPolygonDifferenceToShapes,
shapeToGeometry,
} from "../helpers/actions";
class SubtractShapeAction extends Action {
constructor(shapes) {
super();
this.update = (shapesById) => {
const actionGeom = shapes.map(shapeToGeometry);
let subtractedShapes = {};
for (let shape of Object.values(shapesById)) {
const shapeGeom = shapeToGeometry(shape);
try {
const difference = polygonClipping.difference(
shapeGeom,
...actionGeom
);
addPolygonDifferenceToShapes(shape, difference, subtractedShapes);
} catch {
console.error("Unable to find difference for shapes");
}
}
return subtractedShapes;
};
}
}
export default SubtractShapeAction;

View File

@ -1,13 +0,0 @@
import AddShapeAction from "./AddShapeAction";
import CutShapeAction from "./CutShapeAction";
import EditShapeAction from "./EditShapeAction";
import RemoveShapeAction from "./RemoveShapeAction";
import SubtractShapeAction from "./SubtractShapeAction";
export {
AddShapeAction,
CutShapeAction,
EditShapeAction,
RemoveShapeAction,
SubtractShapeAction,
};

13
src/actions/index.ts Normal file
View File

@ -0,0 +1,13 @@
import AddStatesAction from "./AddStatesAction";
import CutFogAction from "./CutFogAction";
import EditStatesAction from "./EditStatesAction";
import RemoveStatesAction from "./RemoveStatesAction";
import SubtractFogAction from "./SubtractFogAction";
export {
AddStatesAction,
CutFogAction,
EditStatesAction,
RemoveStatesAction,
SubtractFogAction,
};

View File

@ -3,7 +3,13 @@ import { Box, Flex, Text, IconButton, Divider } from "theme-ui";
import ExpandMoreIcon from "../icons/ExpandMoreIcon";
function Accordion({ heading, children, defaultOpen }) {
type AccordianProps = {
heading: string;
children: React.ReactNode;
defaultOpen: boolean;
};
function Accordion({ heading, children, defaultOpen }: AccordianProps) {
const [open, setOpen] = useState(defaultOpen);
return (

View File

@ -0,0 +1,24 @@
import { Box, Text } from "theme-ui";
import Link from "./Link";
function DatabaseDisabledMessage({ type }: { type: string }) {
return (
<Box
sx={{
textAlign: "center",
borderRadius: "2px",
gridArea: "1 / 1 / span 1 / span 4",
}}
bg="highlight"
p={1}
>
<Text as="p" variant="body2">
{type} saving is unavailable. See <Link to="/faq#database">FAQ</Link>{" "}
for more information.
</Text>
</Box>
);
}
export default DatabaseDisabledMessage;

View File

@ -1,7 +1,16 @@
import React from "react";
import { Divider } from "theme-ui";
import { Divider, DividerProps } from "theme-ui";
function StyledDivider({ vertical, color, fill }) {
type StyledDividerProps = {
vertical: boolean;
fill: boolean;
} & DividerProps;
function StyledDivider({
vertical,
color,
fill,
...props
}: StyledDividerProps) {
return (
<Divider
my={vertical ? 0 : 2}
@ -13,6 +22,7 @@ function StyledDivider({ vertical, color, fill }) {
borderRadius: "2px",
opacity: 0.5,
}}
{...props}
/>
);
}

View File

@ -1,4 +1,3 @@
import React from "react";
import { Flex } from "theme-ui";
import Link from "./Link";

View File

@ -1,8 +1,7 @@
import React from "react";
import { Link as ThemeLink } from "theme-ui";
import { Link as ThemeLink, LinkProps } from "theme-ui";
import { HashLink as RouterLink } from "react-router-hash-link";
function Link({ to, ...rest }) {
function Link({ to, ...rest }: { to: string } & LinkProps) {
return (
<RouterLink to={to}>
<ThemeLink as="span" {...rest} />

View File

@ -1,9 +1,14 @@
import React, { useEffect, useRef } from "react";
import { Progress } from "theme-ui";
function LoadingBar({ isLoading, loadingProgressRef }) {
const requestRef = useRef();
const progressBarRef = useRef();
type LoadingBarProps = {
isLoading: boolean;
loadingProgressRef: React.MutableRefObject<number>;
};
function LoadingBar({ isLoading, loadingProgressRef }: LoadingBarProps) {
const requestRef = useRef<number>();
const progressBarRef = useRef<HTMLProgressElement>(null);
// Use an animation frame to update the progress bar
// This bypasses react allowing the animation to be smooth
@ -21,7 +26,9 @@ function LoadingBar({ isLoading, loadingProgressRef }) {
requestRef.current = requestAnimationFrame(animate);
return () => {
cancelAnimationFrame(requestRef.current);
if (requestRef.current !== undefined) {
cancelAnimationFrame(requestRef.current);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isLoading]);

View File

@ -3,7 +3,12 @@ import { Box } from "theme-ui";
import Spinner from "./Spinner";
function LoadingOverlay({ bg, children }) {
type LoadingOverlayProps = {
bg: string;
children?: React.ReactNode;
};
function LoadingOverlay({ bg, children }: LoadingOverlayProps) {
return (
<Box
sx={{

View File

@ -1,24 +1,26 @@
import React from "react";
import {
Text,
TextProps,
Image as UIImage,
ImageProps,
Link as UILink,
Message,
Embed,
Box,
} from "theme-ui";
import ReactMarkdown from "react-markdown";
function Paragraph(props) {
function Paragraph(props: TextProps) {
return <Text as="p" my={2} variant="body2" {...props} />;
}
function Heading({ level, ...props }) {
function Heading({ level, ...props }: { level: number } & TextProps) {
const fontSize = level === 1 ? 5 : level === 2 ? 3 : 1;
return (
<Text
mt={2}
mb={1}
as={`h${level}`}
as={`h${level}` as React.ElementType}
sx={{ fontSize }}
variant="heading"
{...props}
@ -26,11 +28,37 @@ function Heading({ level, ...props }) {
);
}
function Image(props) {
function Image(props: ImageProps) {
if (props.alt === "embed:") {
return <Embed as="span" sx={{ display: "block" }} src={props.src} my={2} />;
return (
<Box
as="span"
sx={{
display: "block",
width: "100%",
height: 0,
paddingBottom: "56.25%",
position: "relative",
}}
my={2}
>
<iframe
src={props.src}
style={{
position: "absolute",
width: "100%",
height: "100%",
top: 0,
bottom: 0,
left: 0,
border: 0,
}}
title="Video"
/>
</Box>
);
}
if (props.src.endsWith(".mp4")) {
if (props.src?.endsWith(".mp4")) {
return (
<video
style={{ width: "100%", margin: "8px 0" }}
@ -39,7 +67,7 @@ function Image(props) {
playsInline
loop
controls
{...props}
src={props.src}
/>
);
}
@ -47,11 +75,17 @@ function Image(props) {
return <UIImage mt={2} sx={{ borderRadius: "4px" }} {...props} />;
}
function ListItem(props) {
function ListItem(props: TextProps) {
return <Text as="li" variant="body2" my={1} {...props} />;
}
function Code({ children, value }) {
function Code({
children,
value,
}: {
value: string;
children: React.ReactNode;
}) {
let variant = "";
if (value.startsWith("Warning:")) {
variant = "warning";
@ -71,7 +105,7 @@ function Code({ children, value }) {
);
}
function Table({ children }) {
function Table({ children }: { children: React.ReactNode }) {
return (
<Text
as="table"
@ -83,7 +117,7 @@ function Table({ children }) {
);
}
function TableHead(props) {
function TableHead(props: TextProps) {
return (
<Text
as="thead"
@ -94,7 +128,7 @@ function TableHead(props) {
);
}
function TableBody(props) {
function TableBody(props: TextProps) {
return (
<Text
as="tbody"
@ -105,7 +139,7 @@ function TableBody(props) {
);
}
function TableRow({ children }) {
function TableRow({ children }: { children: React.ReactNode }) {
return (
<Text
as="tr"
@ -119,7 +153,7 @@ function TableRow({ children }) {
);
}
function TableCell({ children }) {
function TableCell({ children }: { children: React.ReactNode }) {
return (
<Text as="td" p={2}>
{children}
@ -127,11 +161,17 @@ function TableCell({ children }) {
);
}
function Link({ href, children }) {
function Link({ href, children }: { href: string; children: React.ReactNode }) {
return <UILink href={href}>{children}</UILink>;
}
function Markdown({ source, assets }) {
function Markdown({
source,
assets,
}: {
source: string;
assets: Record<string, string>;
}) {
const renderers = {
paragraph: Paragraph,
heading: Heading,

View File

@ -1,8 +1,13 @@
import React from "react";
import Modal from "react-modal";
import React, { ReactChild } from "react";
import Modal, { Props } from "react-modal";
import { useThemeUI, Close } from "theme-ui";
import { useSpring, animated, config } from "react-spring";
import CSS from "csstype";
type ModalProps = Props & {
children: ReactChild | ReactChild[];
allowClose: boolean;
};
function StyledModal({
isOpen,
@ -11,7 +16,7 @@ function StyledModal({
allowClose,
style,
...props
}) {
}: ModalProps) {
const { theme } = useThemeUI();
const openAnimation = useSpring({
@ -31,16 +36,17 @@ function StyledModal({
display: "flex",
alignItems: "center",
justifyContent: "center",
...(style?.overlay || {}),
},
content: {
backgroundColor: theme.colors.background,
backgroundColor: theme.colors?.background as CSS.Property.Color,
top: "initial",
left: "initial",
bottom: "initial",
right: "initial",
maxHeight: "100%",
...style,
},
...(style?.content || {}),
} as React.CSSProperties,
}}
contentElement={(props, content) => (
<animated.div {...props} style={{ ...props.style, ...openAnimation }}>

View File

@ -1,7 +1,17 @@
import React from "react";
import { IconButton } from "theme-ui";
import { IconButton, IconButtonProps } from "theme-ui";
function RadioIconButton({ title, onClick, isSelected, disabled, children }) {
type RadioButttonProps = {
isSelected: boolean;
} & IconButtonProps;
function RadioIconButton({
title,
onClick,
isSelected,
disabled,
children,
...props
}: RadioButttonProps) {
return (
<IconButton
aria-label={title}
@ -9,6 +19,7 @@ function RadioIconButton({ title, onClick, isSelected, disabled, children }) {
onClick={onClick}
sx={{ color: isSelected ? "primary" : "text" }}
disabled={disabled}
{...props}
>
{children}
</IconButton>

View File

@ -1,9 +1,8 @@
import React from "react";
import { Box, Input } from "theme-ui";
import { Box, Input, InputProps } from "theme-ui";
import SearchIcon from "../icons/SearchIcon";
function Search(props) {
function Search(props: InputProps) {
return (
<Box sx={{ position: "relative", flexGrow: 1 }}>
<Input

View File

@ -1,76 +0,0 @@
import React from "react";
import ReactSelect from "react-select";
import Creatable from "react-select/creatable";
import { useThemeUI } from "theme-ui";
function Select({ creatable, ...props }) {
const { theme } = useThemeUI();
const Component = creatable ? Creatable : ReactSelect;
return (
<Component
styles={{
menu: (provided, state) => ({
...provided,
backgroundColor: theme.colors.background,
color: theme.colors.text,
borderRadius: "4px",
borderColor: theme.colors.gray,
borderStyle: "solid",
borderWidth: "1px",
fontFamily: theme.fonts.body2,
opacity: state.isDisabled ? 0.5 : 1,
}),
control: (provided, state) => ({
...provided,
backgroundColor: "transparent",
color: theme.colors.text,
borderColor: theme.colors.text,
opacity: state.isDisabled ? 0.5 : 1,
}),
singleValue: (provided) => ({
...provided,
color: theme.colors.text,
fontFamily: theme.fonts.body2,
}),
option: (provided, state) => ({
...provided,
color: theme.colors.text,
opacity: state.isDisabled ? 0.5 : 1,
}),
dropdownIndicator: (provided, state) => ({
...provided,
color: theme.colors.text,
":hover": {
color: state.isDisabled
? theme.colors.disabled
: theme.colors.primary,
},
}),
input: (provided, state) => ({
...provided,
color: theme.colors.text,
opacity: state.isDisabled ? 0.5 : 1,
}),
container: (provided) => ({
...provided,
margin: "4px 0",
}),
}}
theme={(t) => ({
...t,
colors: {
...t.colors,
primary: theme.colors.primary,
primary50: theme.colors.secondary,
primary25: theme.colors.highlight,
},
})}
captureMenuScroll={false}
{...props}
/>
);
}
export default Select;

83
src/components/Select.tsx Normal file
View File

@ -0,0 +1,83 @@
import ReactSelect, { Props } from "react-select";
import Creatable from "react-select/creatable";
import { useThemeUI } from "theme-ui";
type SelectProps = {
creatable?: boolean;
} & Props;
function Select({ creatable, ...props }: SelectProps) {
const { theme } = useThemeUI();
const Component: any = creatable ? Creatable : ReactSelect;
return (
<Component
styles={{
menu: (provided: any, state: any) => ({
...provided,
backgroundColor: theme.colors?.background,
color: theme.colors?.text,
borderRadius: "4px",
borderColor: theme.colors?.gray,
borderStyle: "solid",
borderWidth: "1px",
fontFamily: (theme.fonts as any)?.body2,
opacity: state.isDisabled ? 0.5 : 1,
}),
control: (provided: any, state: any) => ({
...provided,
backgroundColor: "transparent",
color: theme.colors?.text,
borderColor: theme.colors?.text,
opacity: state.isDisabled ? 0.5 : 1,
}),
singleValue: (provided: any) => ({
...provided,
color: theme.colors?.text,
fontFamily: (theme.fonts as any).body2,
}),
option: (provided: any, state: any) => ({
...provided,
color: theme.colors?.text,
opacity: state.isDisabled ? 0.5 : 1,
}),
dropdownIndicator: (provided: any, state: any) => ({
...provided,
color: theme.colors?.text,
":hover": {
color: state.isDisabled
? theme.colors?.disabled
: theme.colors?.primary,
},
}),
input: (provided: any, state: any) => ({
...provided,
color: theme.colors?.text,
opacity: state.isDisabled ? 0.5 : 1,
}),
container: (provided: any) => ({
...provided,
margin: "4px 0",
}),
}}
theme={(t: any) => ({
...t,
colors: {
...t.colors,
primary: theme.colors?.primary,
primary50: theme.colors?.secondary,
primary25: theme.colors?.highlight,
},
})}
captureMenuScroll={false}
{...props}
/>
);
}
Select.defaultProps = {
creatable: false,
};
export default Select;

View File

@ -1,4 +1,4 @@
import React, { useState } from "react";
import { useState } from "react";
import { IconButton } from "theme-ui";
import SettingsIcon from "../icons/SettingsIcon";

View File

@ -1,10 +1,25 @@
import React, { useState } from "react";
import { Box, Slider as ThemeSlider } from "theme-ui";
import { useState } from "react";
import { Box, Slider as ThemeSlider, SliderProps } from "theme-ui";
function Slider({ min, max, value, ml, mr, labelFunc, ...rest }) {
type SliderModalProps = SliderProps & {
min: number;
max: number;
value: number;
labelFunc: (value: number) => string;
};
function Slider({
min,
max,
value,
ml,
mr,
labelFunc,
...rest
}: SliderModalProps) {
const percentValue = ((value - min) * 100) / (max - min);
const [labelVisible, setLabelVisible] = useState(false);
const [labelVisible, setLabelVisible] = useState<boolean>(false);
return (
<Box sx={{ position: "relative" }} ml={ml} mr={mr}>
@ -63,7 +78,7 @@ Slider.defaultProps = {
value: 0,
ml: 0,
mr: 0,
labelFunc: (value) => value,
labelFunc: (value: number) => value,
};
export default Slider;

View File

@ -1,4 +1,3 @@
import React from "react";
import { Box } from "theme-ui";
import "./Spinner.css";

View File

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

View File

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

View File

@ -2,7 +2,7 @@ import React from "react";
import { Box, Text } from "theme-ui";
import { ToastProvider as DefaultToastProvider } from "react-toast-notifications";
function CustomToast({ children }) {
function CustomToast({ children }: { children?: React.ReactNode }) {
return (
<Box
m={2}
@ -17,7 +17,7 @@ function CustomToast({ children }) {
);
}
export function ToastProvider({ children }) {
export function ToastProvider({ children }: { children?: React.ReactNode }) {
return (
<DefaultToastProvider
components={{ Toast: CustomToast }}

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react";
import { useState, useEffect } from "react";
import { Text } from "theme-ui";
import LoadingOverlay from "./LoadingOverlay";
@ -21,7 +21,7 @@ const facts = [
];
function UpgradingLoadingOverlay() {
const [subText, setSubText] = useState();
const [subText, setSubText] = useState<string>();
useEffect(() => {
let index = 0;
@ -33,7 +33,7 @@ function UpgradingLoadingOverlay() {
}
// Show first fact after 10 seconds then every 20 seconds after that
let interval;
let interval: NodeJS.Timeout;
let timeout = setTimeout(() => {
updateFact();
interval = setInterval(() => {

View File

@ -1,6 +1,15 @@
import React from "react";
import Modal from "react-modal";
import { useThemeUI, Close } from "theme-ui";
import { RequestCloseEventHandler } from "../../types/Events";
import CSS from "csstype";
type BannerProps = {
isOpen: boolean;
onRequestClose: RequestCloseEventHandler;
children: React.ReactNode;
allowClose: boolean;
backgroundColor?: CSS.Property.Color;
};
function Banner({
isOpen,
@ -8,7 +17,7 @@ function Banner({
children,
allowClose,
backgroundColor,
}) {
}: BannerProps) {
const { theme } = useThemeUI();
return (
@ -18,7 +27,8 @@ function Banner({
style={{
overlay: { bottom: "0", top: "initial", zIndex: 2000 },
content: {
backgroundColor: backgroundColor || theme.colors.highlight,
backgroundColor:
backgroundColor || (theme.colors?.highlight as CSS.Property.Color),
color: "hsl(210, 50%, 96%)",
top: "initial",
left: "50%",

View File

@ -1,9 +1,15 @@
import React from "react";
import { Box, Text } from "theme-ui";
import Banner from "./Banner";
function ErrorBanner({ error, onRequestClose }) {
import { RequestCloseEventHandler } from "../../types/Events";
type ErrorBannerProps = {
error: Error | undefined;
onRequestClose: RequestCloseEventHandler;
};
function ErrorBanner({ error, onRequestClose }: ErrorBannerProps) {
return (
<Banner isOpen={!!error} onRequestClose={onRequestClose}>
<Box p={1}>

View File

@ -1,10 +1,9 @@
import React from "react";
import { Flex } from "theme-ui";
import Banner from "./Banner";
import OfflineIcon from "../../icons/OfflineIcon";
function OfflineBanner({ isOpen }) {
function OfflineBanner({ isOpen }: { isOpen: boolean }) {
return (
<Banner
isOpen={isOpen}

View File

@ -1,10 +1,9 @@
import React from "react";
import { Flex } from "theme-ui";
import Banner from "./Banner";
import ReconnectingIcon from "../../icons/ReconnectingIcon";
function ReconnectBanner({ isOpen }) {
function ReconnectBanner({ isOpen }: { isOpen: boolean }) {
return (
<Banner
isOpen={isOpen}

View File

@ -1,39 +1,49 @@
import React, { useEffect } from "react";
import { Flex, IconButton } from "theme-ui";
import { useEffect } from "react";
import { Flex } from "theme-ui";
import { useMedia } from "react-media";
import RadioIconButton from "../../RadioIconButton";
import RadioIconButton from "../RadioIconButton";
import ColorControl from "./ColorControl";
import AlphaBlendToggle from "./AlphaBlendToggle";
import ToolSection from "./ToolSection";
import ColorControl from "./shared/ColorControl";
import AlphaBlendToggle from "./shared/AlphaBlendToggle";
import ToolSection from "./shared/ToolSection";
import ShapeFillToggle from "./shared/ShapeFillToggle";
import BrushIcon from "../../../icons/BrushToolIcon";
import BrushPaintIcon from "../../../icons/BrushPaintIcon";
import BrushLineIcon from "../../../icons/BrushLineIcon";
import BrushRectangleIcon from "../../../icons/BrushRectangleIcon";
import BrushCircleIcon from "../../../icons/BrushCircleIcon";
import BrushTriangleIcon from "../../../icons/BrushTriangleIcon";
import EraseAllIcon from "../../../icons/EraseAllIcon";
import EraseIcon from "../../../icons/EraseToolIcon";
import BrushIcon from "../../icons/BrushToolIcon";
import BrushPaintIcon from "../../icons/BrushPaintIcon";
import BrushLineIcon from "../../icons/BrushLineIcon";
import BrushRectangleIcon from "../../icons/BrushRectangleIcon";
import BrushCircleIcon from "../../icons/BrushCircleIcon";
import BrushTriangleIcon from "../../icons/BrushTriangleIcon";
import EraseIcon from "../../icons/EraseToolIcon";
import UndoButton from "./UndoButton";
import RedoButton from "./RedoButton";
import Divider from "../Divider";
import Divider from "../../Divider";
import { useKeyboard } from "../../contexts/KeyboardContext";
import { useKeyboard } from "../../../contexts/KeyboardContext";
import shortcuts from "../../shortcuts";
import shortcuts from "../../../shortcuts";
import {
DrawingToolSettings as DrawingToolSettingsType,
DrawingToolType,
} from "../../types/Drawing";
import EraseAllButton from "./shared/EraseAllButton";
type DrawingToolSettingsProps = {
settings: DrawingToolSettingsType;
onSettingChange: (change: Partial<DrawingToolSettingsType>) => void;
onToolAction: (action: string) => void;
disabledActions: string[];
};
function DrawingToolSettings({
settings,
onSettingChange,
onToolAction,
disabledActions,
}) {
}: DrawingToolSettingsProps) {
// Keyboard shotcuts
function handleKeyDown(event) {
function handleKeyDown(event: KeyboardEvent) {
if (shortcuts.drawBrush(event)) {
onSettingChange({ type: "brush" });
} else if (shortcuts.drawPaint(event)) {
@ -50,10 +60,8 @@ function DrawingToolSettings({
onSettingChange({ type: "erase" });
} else if (shortcuts.drawBlend(event)) {
onSettingChange({ useBlending: !settings.useBlending });
} else if (shortcuts.redo(event) && !disabledActions.includes("redo")) {
onToolAction("mapRedo");
} else if (shortcuts.undo(event) && !disabledActions.includes("undo")) {
onToolAction("mapUndo");
} else if (shortcuts.drawFill(event)) {
onSettingChange({ useShapeFill: !settings.useShapeFill });
}
}
useKeyboard(handleKeyDown);
@ -111,11 +119,14 @@ function DrawingToolSettings({
<ColorControl
color={settings.color}
onColorChange={(color) => onSettingChange({ color })}
exclude={["primary"]}
/>
<Divider vertical />
<ToolSection
tools={tools}
onToolClick={(tool) => onSettingChange({ type: tool.id })}
onToolClick={(tool) =>
onSettingChange({ type: tool.id as DrawingToolType })
}
collapse={isSmallScreen}
/>
<Divider vertical />
@ -127,27 +138,18 @@ function DrawingToolSettings({
>
<EraseIcon />
</RadioIconButton>
<IconButton
aria-label="Erase All"
title="Erase All"
onClick={() => onToolAction("eraseAll")}
<EraseAllButton
onToolAction={onToolAction}
disabled={disabledActions.includes("erase")}
>
<EraseAllIcon />
</IconButton>
/>
<Divider vertical />
<AlphaBlendToggle
useBlending={settings.useBlending}
onBlendingChange={(useBlending) => onSettingChange({ useBlending })}
/>
<Divider vertical />
<UndoButton
onClick={() => onToolAction("mapUndo")}
disabled={disabledActions.includes("undo")}
/>
<RedoButton
onClick={() => onToolAction("mapRedo")}
disabled={disabledActions.includes("redo")}
<ShapeFillToggle
useShapeFill={settings.useShapeFill}
onShapeFillChange={(useShapeFill) => onSettingChange({ useShapeFill })}
/>
</Flex>
);

View File

@ -1,37 +1,39 @@
import React from "react";
import { Flex } from "theme-ui";
import { useMedia } from "react-media";
import RadioIconButton from "../../RadioIconButton";
import RadioIconButton from "../RadioIconButton";
import MultilayerToggle from "./MultilayerToggle";
import FogPreviewToggle from "./FogPreviewToggle";
import FogCutToggle from "./FogCutToggle";
import MultilayerToggle from "./shared/MultilayerToggle";
import FogPreviewToggle from "./shared/FogPreviewToggle";
import FogCutToggle from "./shared/FogCutToggle";
import FogBrushIcon from "../../../icons/FogBrushIcon";
import FogPolygonIcon from "../../../icons/FogPolygonIcon";
import FogRemoveIcon from "../../../icons/FogRemoveIcon";
import FogToggleIcon from "../../../icons/FogToggleIcon";
import FogRectangleIcon from "../../../icons/FogRectangleIcon";
import FogBrushIcon from "../../icons/FogBrushIcon";
import FogPolygonIcon from "../../icons/FogPolygonIcon";
import FogRemoveIcon from "../../icons/FogRemoveIcon";
import FogToggleIcon from "../../icons/FogToggleIcon";
import FogRectangleIcon from "../../icons/FogRectangleIcon";
import UndoButton from "./UndoButton";
import RedoButton from "./RedoButton";
import ToolSection from "./ToolSection";
import ToolSection from "./shared/ToolSection";
import Divider from "../../Divider";
import Divider from "../Divider";
import { useKeyboard } from "../../../contexts/KeyboardContext";
import { useKeyboard } from "../../contexts/KeyboardContext";
import shortcuts from "../../../shortcuts";
import shortcuts from "../../shortcuts";
function BrushToolSettings({
settings,
onSettingChange,
onToolAction,
disabledActions,
}) {
import {
FogToolSettings as FogToolSettingsType,
FogToolType,
} from "../../types/Fog";
type FogToolSettingsProps = {
settings: FogToolSettingsType;
onSettingChange: (change: Partial<FogToolSettingsType>) => void;
};
function FogToolSettings({ settings, onSettingChange }: FogToolSettingsProps) {
// Keyboard shortcuts
function handleKeyDown(event) {
function handleKeyDown(event: KeyboardEvent) {
if (shortcuts.fogPolygon(event)) {
onSettingChange({ type: "polygon" });
} else if (shortcuts.fogBrush(event)) {
@ -48,10 +50,6 @@ function BrushToolSettings({
onSettingChange({ useFogCut: !settings.useFogCut });
} else if (shortcuts.fogRectangle(event)) {
onSettingChange({ type: "rectangle" });
} else if (shortcuts.redo(event) && !disabledActions.includes("redo")) {
onToolAction("fogRedo");
} else if (shortcuts.undo(event) && !disabledActions.includes("undo")) {
onToolAction("fogUndo");
}
}
@ -86,7 +84,9 @@ function BrushToolSettings({
<Flex sx={{ alignItems: "center" }}>
<ToolSection
tools={drawTools}
onToolClick={(tool) => onSettingChange({ type: tool.id })}
onToolClick={(tool) =>
onSettingChange({ type: tool.id as FogToolType })
}
collapse={isSmallScreen}
/>
<Divider vertical />
@ -121,17 +121,8 @@ function BrushToolSettings({
useFogPreview={settings.preview}
onFogPreviewChange={(preview) => onSettingChange({ preview })}
/>
<Divider vertical />
<UndoButton
onClick={() => onToolAction("fogUndo")}
disabled={disabledActions.includes("undo")}
/>
<RedoButton
onClick={() => onToolAction("fogRedo")}
disabled={disabledActions.includes("redo")}
/>
</Flex>
);
}
export default BrushToolSettings;
export default FogToolSettings;

View File

@ -0,0 +1,27 @@
import { Flex } from "theme-ui";
import ColorControl from "./shared/ColorControl";
import { PointerToolSettings as PointerToolSettingsType } from "../../types/Pointer";
type PointerToolSettingsProps = {
settings: PointerToolSettingsType;
onSettingChange: (change: Partial<PointerToolSettingsType>) => void;
};
function PointerToolSettings({
settings,
onSettingChange,
}: PointerToolSettingsProps) {
return (
<Flex sx={{ alignItems: "center" }}>
<ColorControl
color={settings.color}
onColorChange={(color) => onSettingChange({ color })}
exclude={["black", "darkGray", "lightGray", "white", "primary"]}
/>
</Flex>
);
}
export default PointerToolSettings;

View File

@ -0,0 +1,63 @@
import { Flex } from "theme-ui";
import {
SelectToolSettings as SelectToolSettingsType,
SelectToolType,
} from "../../types/Select";
import { useKeyboard } from "../../contexts/KeyboardContext";
import ToolSection from "./shared/ToolSection";
import shortcuts from "../../shortcuts";
import RectIcon from "../../icons/SelectRectangleIcon";
import PathIcon from "../../icons/SelectPathIcon";
type SelectToolSettingsProps = {
settings: SelectToolSettingsType;
onSettingChange: (change: Partial<SelectToolSettingsType>) => void;
};
function SelectToolSettings({
settings,
onSettingChange,
}: SelectToolSettingsProps) {
// Keyboard shotcuts
function handleKeyDown(event: KeyboardEvent) {
if (shortcuts.selectPath(event)) {
onSettingChange({ type: "path" });
} else if (shortcuts.selectRect(event)) {
onSettingChange({ type: "rectangle" });
}
}
useKeyboard(handleKeyDown);
const tools = [
{
id: "rectangle",
title: "Rectangle Selection (R)",
isSelected: settings.type === "rectangle",
icon: <RectIcon />,
},
{
id: "path",
title: "Lasso Selection (L)",
isSelected: settings.type === "path",
icon: <PathIcon />,
},
];
return (
<Flex sx={{ alignItems: "center" }}>
<ToolSection
tools={tools}
onToolClick={(tool) =>
onSettingChange({ type: tool.id as SelectToolType })
}
/>
</Flex>
);
}
export default SelectToolSettings;

View File

@ -1,10 +1,17 @@
import React from "react";
import { IconButton } from "theme-ui";
import BlendOnIcon from "../../../icons/BlendOnIcon";
import BlendOffIcon from "../../../icons/BlendOffIcon";
function AlphaBlendToggle({ useBlending, onBlendingChange }) {
type AlphaBlendToggleProps = {
useBlending: boolean;
onBlendingChange: (useBlending: boolean) => void;
};
function AlphaBlendToggle({
useBlending,
onBlendingChange,
}: AlphaBlendToggleProps) {
return (
<IconButton
aria-label={useBlending ? "Disable Blending (O)" : "Enable Blending (O)"}

View File

@ -1,10 +1,16 @@
import React, { useState } from "react";
import { Box } from "theme-ui";
import { Box, SxProp } from "theme-ui";
import colors, { colorOptions } from "../../../helpers/colors";
import MapMenu from "../MapMenu";
import colors, { colorOptions, Color } from "../../../helpers/colors";
import MapMenu from "../../map/MapMenu";
function ColorCircle({ color, selected, onClick, sx }) {
type ColorCircleProps = {
color: Color;
selected: boolean;
onClick?: React.MouseEventHandler<HTMLDivElement>;
} & SxProp;
function ColorCircle({ color, selected, onClick, sx }: ColorCircleProps) {
return (
<Box
key={color}
@ -34,24 +40,30 @@ function ColorCircle({ color, selected, onClick, sx }) {
);
}
function ColorControl({ color, onColorChange, exclude }) {
type ColorControlProps = {
color: Color;
onColorChange: (newColor: Color) => void;
exclude: Color[];
};
function ColorControl({ color, onColorChange, exclude }: ColorControlProps) {
const [showColorMenu, setShowColorMenu] = useState(false);
const [colorMenuOptions, setColorMenuOptions] = useState({});
function handleControlClick(event) {
function handleControlClick(event: React.MouseEvent<HTMLDivElement>) {
if (showColorMenu) {
setShowColorMenu(false);
setColorMenuOptions({});
} else {
setShowColorMenu(true);
const rect = event.target.getBoundingClientRect();
const rect = event.currentTarget.getBoundingClientRect();
setColorMenuOptions({
// Align the right of the submenu to the left of the tool and center vertically
left: `${rect.left + rect.width / 2}px`,
top: `${rect.bottom + 16}px`,
style: { transform: "translateX(-50%)" },
// Exclude this node from the sub menus auto close
excludeNode: event.target,
excludeNode: event.currentTarget,
});
}
}

View File

@ -0,0 +1,59 @@
import { useEffect, useRef, useState } from "react";
import { Button, IconButton } from "theme-ui";
import EraseAllIcon from "../../../icons/EraseAllIcon";
import MapMenu from "../../map/MapMenu";
type EraseAllButtonProps = {
onToolAction: (action: string) => void;
disabled: boolean;
};
function EraseAllButton({ onToolAction, disabled }: EraseAllButtonProps) {
const [isEraseAllConfirmOpen, setIsEraseAllConfirmOpen] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
const [menuLeft, setMenuLeft] = useState(0);
const [menuTop, setMenuTop] = useState(0);
useEffect(() => {
const button = buttonRef.current;
if (isEraseAllConfirmOpen && button) {
const rect = button.getBoundingClientRect();
setMenuLeft(rect.left + rect.width / 2);
setMenuTop(rect.bottom + 8);
}
}, [isEraseAllConfirmOpen]);
return (
<>
<IconButton
aria-label="Erase All"
title="Erase All"
onClick={() => setIsEraseAllConfirmOpen(true)}
disabled={disabled}
ref={buttonRef}
>
<EraseAllIcon />
</IconButton>
<MapMenu
isOpen={isEraseAllConfirmOpen}
onRequestClose={() => setIsEraseAllConfirmOpen(false)}
left={menuLeft}
top={menuTop}
style={{ transform: "translateX(-50%)" }}
>
<Button
disabled={disabled}
onClick={() => {
setIsEraseAllConfirmOpen(false);
onToolAction("eraseAll");
}}
>
Erase All
</Button>
</MapMenu>
</>
);
}
export default EraseAllButton;

View File

@ -1,10 +1,19 @@
import React from "react";
import { IconButton } from "theme-ui";
import CutOnIcon from "../../../icons/FogCutOnIcon";
import CutOffIcon from "../../../icons/FogCutOffIcon";
function FogCutToggle({ useFogCut, onFogCutChange, disabled }) {
type FogCutToggleProps = {
useFogCut: boolean;
onFogCutChange: (useFogCut: boolean) => void;
disabled?: boolean;
};
function FogCutToggle({
useFogCut,
onFogCutChange,
disabled,
}: FogCutToggleProps) {
return (
<IconButton
aria-label={

View File

@ -1,10 +1,17 @@
import React from "react";
import { IconButton } from "theme-ui";
import PreviewOnIcon from "../../../icons/FogPreviewOnIcon";
import PreviewOffIcon from "../../../icons/FogPreviewOffIcon";
function FogPreviewToggle({ useFogPreview, onFogPreviewChange }) {
type FogPreviewToggleProps = {
useFogPreview: boolean;
onFogPreviewChange: (useFogCut: boolean) => void;
};
function FogPreviewToggle({
useFogPreview,
onFogPreviewChange,
}: FogPreviewToggleProps) {
return (
<IconButton
aria-label={

View File

@ -1,10 +1,19 @@
import React from "react";
import { IconButton } from "theme-ui";
import MultilayerOnIcon from "../../../icons/FogMultilayerOnIcon";
import MultilayerOffIcon from "../../../icons/FogMultilayerOffIcon";
function MultilayerToggle({ multilayer, onMultilayerChange, disabled }) {
type MultilayerToggleProps = {
multilayer: boolean;
onMultilayerChange: (multilayer: boolean) => void;
disabled?: boolean;
};
function MultilayerToggle({
multilayer,
onMultilayerChange,
disabled,
}: MultilayerToggleProps) {
return (
<IconButton
aria-label={

View File

@ -5,7 +5,12 @@ import RedoIcon from "../../../icons/RedoIcon";
import { isMacLike } from "../../../helpers/shared";
function RedoButton({ onClick, disabled }) {
type RedoButtonProps = {
onClick?: React.MouseEventHandler<HTMLButtonElement>;
disabled?: boolean;
};
function RedoButton({ onClick, disabled }: RedoButtonProps) {
return (
<IconButton
title={`Redo (${isMacLike ? "Cmd" : "Ctrl"} + Shift + Z)`}

View File

@ -0,0 +1,28 @@
import { IconButton } from "theme-ui";
import ShapeFillOnIcon from "../../../icons/ShapeFillOnIcon";
import ShapeFillOffIcon from "../../../icons/ShapeFillOffIcon";
type ShapeFillToggleProps = {
useShapeFill: boolean;
onShapeFillChange: (useShapeFill: boolean) => void;
};
function ShapeFillToggle({
useShapeFill,
onShapeFillChange,
}: ShapeFillToggleProps) {
return (
<IconButton
aria-label={
useShapeFill ? "Disable Shape Fill (G)" : "Enable Shape Fill (G)"
}
title={useShapeFill ? "Disable Shape Fill (G)" : "Enable Shape Fill (G)"}
onClick={() => onShapeFillChange(!useShapeFill)}
>
{useShapeFill ? <ShapeFillOnIcon /> : <ShapeFillOffIcon />}
</IconButton>
);
}
export default ShapeFillToggle;

View File

@ -3,10 +3,24 @@ import { Box, Flex } from "theme-ui";
import RadioIconButton from "../../RadioIconButton";
export type Tool = {
id: string;
title: string;
isSelected: boolean;
icon: React.ReactNode;
disabled?: boolean;
};
type ToolSectionProps = {
collapse: boolean;
tools: Tool[];
onToolClick: (tool: Tool) => void;
};
// Section of map tools with the option to collapse into a vertical list
function ToolSection({ collapse, tools, onToolClick }) {
function ToolSection({ collapse, tools, onToolClick }: ToolSectionProps) {
const [showMore, setShowMore] = useState(false);
const [collapsedTool, setCollapsedTool] = useState();
const [collapsedTool, setCollapsedTool] = useState<Tool>();
useEffect(() => {
const selectedTool = tools.find((tool) => tool.isSelected);
@ -20,7 +34,7 @@ function ToolSection({ collapse, tools, onToolClick }) {
}
}, [tools]);
function handleToolClick(tool) {
function handleToolClick(tool: Tool) {
if (collapse && tool.isSelected) {
setShowMore(!showMore);
} else if (collapse && !tool.isSelected) {
@ -29,7 +43,7 @@ function ToolSection({ collapse, tools, onToolClick }) {
onToolClick(tool);
}
function renderTool(tool) {
function renderTool(tool: Tool) {
return (
<RadioIconButton
title={tool.title}
@ -85,17 +99,21 @@ function ToolSection({ collapse, tools, onToolClick }) {
</Box>
);
} else {
return tools.map((tool) => (
<RadioIconButton
title={tool.title}
onClick={() => handleToolClick(tool)}
key={tool.id}
isSelected={tool.isSelected}
disabled={tool.disabled}
>
{tool.icon}
</RadioIconButton>
));
return (
<>
{tools.map((tool) => (
<RadioIconButton
title={tool.title}
onClick={() => handleToolClick(tool)}
key={tool.id}
isSelected={tool.isSelected}
disabled={tool.disabled}
>
{tool.icon}
</RadioIconButton>
))}
</>
);
}
}

View File

@ -5,7 +5,12 @@ import UndoIcon from "../../../icons/UndoIcon";
import { isMacLike } from "../../../helpers/shared";
function UndoButton({ onClick, disabled }) {
type UndoButtonProps = {
onClick?: React.MouseEventHandler<HTMLButtonElement>;
disabled?: boolean;
};
function UndoButton({ onClick, disabled }: UndoButtonProps) {
return (
<IconButton
title={`Undo (${isMacLike ? "Cmd" : "Ctrl"} + Z)`}

View File

@ -3,7 +3,21 @@ import { IconButton } from "theme-ui";
import Count from "./DiceButtonCount";
function DiceButton({ title, children, count, onClick, disabled }) {
type DiceButtonProps = {
title: string;
children: React.ReactNode;
count?: number;
onClick: React.MouseEventHandler<HTMLButtonElement>;
disabled: boolean;
};
function DiceButton({
title,
children,
count,
onClick,
disabled,
}: DiceButtonProps) {
return (
<IconButton
title={title}

View File

@ -1,7 +1,7 @@
import React from "react";
import { Box, Text } from "theme-ui";
function DiceButtonCount({ children }) {
function DiceButtonCount({ children }: { children: React.ReactNode }) {
return (
<Box
sx={{

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react";
import { useState, useEffect } from "react";
import { Flex, IconButton, Box } from "theme-ui";
import SimpleBar from "simplebar-react";
@ -18,9 +18,25 @@ import SelectDiceButton from "./SelectDiceButton";
import Divider from "../Divider";
import Dice from "../../dice/Dice";
import { dice } from "../../dice";
import useSetting from "../../hooks/useSetting";
import { DefaultDice, DiceRoll, DiceType } from "../../types/Dice";
import { DiceShareChangeEventHandler } from "../../types/Events";
type DiceButtonsProps = {
diceRolls: DiceRoll[];
onDiceAdd: (style: typeof Dice, type: DiceType) => void;
onDiceLoad: (dice: DefaultDice) => void;
diceTraySize: "single" | "double";
onDiceTraySizeChange: (newSize: "single" | "double") => void;
shareDice: boolean;
onShareDiceChange: DiceShareChangeEventHandler;
loading: boolean;
};
function DiceButtons({
diceRolls,
onDiceAdd,
@ -30,29 +46,32 @@ function DiceButtons({
shareDice,
onShareDiceChange,
loading,
}) {
}: DiceButtonsProps) {
const [currentDiceStyle, setCurrentDiceStyle] = useSetting("dice.style");
const [currentDice, setCurrentDice] = useState(
dice.find((d) => d.key === currentDiceStyle)
const [currentDice, setCurrentDice] = useState<DefaultDice>(
dice.find((d) => d.key === currentDiceStyle) || dice[0]
);
useEffect(() => {
const initialDice = dice.find((d) => d.key === currentDiceStyle);
onDiceLoad(initialDice);
setCurrentDice(initialDice);
if (initialDice) {
onDiceLoad(initialDice);
setCurrentDice(initialDice);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const diceCounts = {};
const diceCounts: Partial<Record<DiceType, number>> = {};
for (let dice of diceRolls) {
if (dice.type in diceCounts) {
diceCounts[dice.type] += 1;
// TODO: Check type
diceCounts[dice.type]! += 1;
} else {
diceCounts[dice.type] = 1;
}
}
async function handleDiceChange(dice) {
async function handleDiceChange(dice: DefaultDice) {
await onDiceLoad(dice);
setCurrentDice(dice);
setCurrentDiceStyle(dice.key);

View File

@ -1,10 +1,10 @@
import React, { useRef, useEffect, useState } from "react";
import { useRef, useEffect, useState } from "react";
import { Engine } from "@babylonjs/core/Engines/engine";
import { Scene } from "@babylonjs/core/scene";
import { Vector3, Color4, Matrix } from "@babylonjs/core/Maths/math";
import { AmmoJSPlugin } from "@babylonjs/core/Physics/Plugins/ammoJSPlugin";
import { TargetCamera } from "@babylonjs/core/Cameras/targetCamera";
import * as AMMO from "ammo.js";
import Ammo from "ammo.js";
import "@babylonjs/core/Physics/physicsEngineComponent";
import "@babylonjs/core/Lights/Shadows/shadowGeneratorSceneComponent";
@ -19,20 +19,44 @@ import ReactResizeDetector from "react-resize-detector";
import usePreventTouch from "../../hooks/usePreventTouch";
import ErrorBanner from "../banner/ErrorBanner";
import { AbstractMesh } from "@babylonjs/core/Meshes/abstractMesh";
const diceThrowSpeed = 2;
function DiceInteraction({ onSceneMount, onPointerDown, onPointerUp }) {
const [error, setError] = useState();
type SceneMountEvent = {
scene: Scene;
engine: Engine;
canvas: HTMLCanvasElement;
};
const sceneRef = useRef();
const engineRef = useRef();
const canvasRef = useRef();
const containerRef = useRef();
type SceneMountEventHandler = (event: SceneMountEvent) => void;
type DiceInteractionProps = {
onSceneMount?: SceneMountEventHandler;
onPointerDown: () => void;
onPointerUp: () => void;
};
function DiceInteraction({
onSceneMount,
onPointerDown,
onPointerUp,
}: DiceInteractionProps) {
const [error, setError] = useState<Error | undefined>();
const sceneRef = useRef<Scene>();
const engineRef = useRef<Engine>();
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) {
return;
}
try {
const canvas = canvasRef.current;
const engine = new Engine(canvas, true, {
preserveDrawingBuffer: true,
stencil: true,
@ -40,10 +64,9 @@ function DiceInteraction({ onSceneMount, onPointerDown, onPointerUp }) {
const scene = new Scene(engine);
scene.clearColor = new Color4(0, 0, 0, 0);
// Enable physics
scene.enablePhysics(
new Vector3(0, -98, 0),
new AmmoJSPlugin(false, AMMO)
);
Ammo().then(() => {
scene.enablePhysics(new Vector3(0, -98, 0), new AmmoJSPlugin(false));
});
let camera = new TargetCamera("camera", new Vector3(0, 33.5, 0), scene);
camera.fov = 0.65;
@ -86,21 +109,27 @@ function DiceInteraction({ onSceneMount, onPointerDown, onPointerUp }) {
}
}, [onSceneMount]);
const selectedMeshRef = useRef();
const selectedMeshVelocityWindowRef = useRef([]);
const selectedMeshRef = useRef<AbstractMesh | null>(null);
const selectedMeshVelocityWindowRef = useRef<Vector3[]>([]);
const selectedMeshVelocityWindowSize = 4;
const selectedMeshMassRef = useRef();
const selectedMeshMassRef = useRef<number>(0);
function handlePointerDown() {
const scene = sceneRef.current;
if (scene) {
const pickInfo = scene.pick(scene.pointerX, scene.pointerY);
if (pickInfo.hit && pickInfo.pickedMesh.name !== "dice_tray") {
pickInfo.pickedMesh.physicsImpostor.setLinearVelocity(Vector3.Zero());
pickInfo.pickedMesh.physicsImpostor.setAngularVelocity(Vector3.Zero());
if (
pickInfo &&
pickInfo.hit &&
pickInfo.pickedMesh &&
pickInfo.pickedMesh.name !== "dice_tray"
) {
pickInfo.pickedMesh.physicsImpostor?.setLinearVelocity(Vector3.Zero());
pickInfo.pickedMesh.physicsImpostor?.setAngularVelocity(Vector3.Zero());
// Save the meshes mass and set it to 0 so we can pick it up
selectedMeshMassRef.current = pickInfo.pickedMesh.physicsImpostor.mass;
pickInfo.pickedMesh.physicsImpostor.setMass(0);
selectedMeshMassRef.current =
pickInfo.pickedMesh.physicsImpostor?.mass || 0;
pickInfo.pickedMesh.physicsImpostor?.setMass(0);
selectedMeshRef.current = pickInfo.pickedMesh;
}
@ -123,27 +152,29 @@ function DiceInteraction({ onSceneMount, onPointerDown, onPointerUp }) {
}
// Re-apply the meshes mass
selectedMesh.physicsImpostor.setMass(selectedMeshMassRef.current);
selectedMesh.physicsImpostor.forceUpdate();
selectedMesh.physicsImpostor?.setMass(selectedMeshMassRef.current);
selectedMesh.physicsImpostor?.forceUpdate();
selectedMesh.physicsImpostor.applyImpulse(
selectedMesh.physicsImpostor?.applyImpulse(
velocity.scale(diceThrowSpeed * selectedMesh.physicsImpostor.mass),
selectedMesh.physicsImpostor.getObjectCenter()
);
}
selectedMeshRef.current = null;
selectedMeshVelocityWindowRef.current = [];
selectedMeshMassRef.current = null;
selectedMeshMassRef.current = 0;
onPointerUp();
}
function handleResize(width, height) {
const engine = engineRef.current;
if (engine) {
engine.resize();
canvasRef.current.width = width;
canvasRef.current.height = height;
function handleResize(width?: number, height?: number) {
if (width && height) {
const engine = engineRef.current;
if (engine && canvasRef.current) {
engine.resize();
canvasRef.current.width = width;
canvasRef.current.height = height;
}
}
}
@ -159,7 +190,12 @@ function DiceInteraction({ onSceneMount, onPointerDown, onPointerUp }) {
}}
ref={containerRef}
>
<ReactResizeDetector handleWidth handleHeight onResize={handleResize}>
<ReactResizeDetector
handleWidth
handleHeight
onResize={handleResize}
targetRef={containerRef}
>
<canvas
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
@ -169,7 +205,7 @@ function DiceInteraction({ onSceneMount, onPointerDown, onPointerUp }) {
style={{ outline: "none" }}
/>
</ReactResizeDetector>
<ErrorBanner error={error} onRequestClose={() => setError()} />
<ErrorBanner error={error} onRequestClose={() => setError(undefined)} />
</div>
);
}

View File

@ -5,17 +5,28 @@ import ClearDiceIcon from "../../icons/ClearDiceIcon";
import RerollDiceIcon from "../../icons/RerollDiceIcon";
import { getDiceRollTotal } from "../../helpers/dice";
import { DiceRoll } from "../../types/Dice";
const maxDiceRollsShown = 6;
function DiceResults({ diceRolls, onDiceClear, onDiceReroll }) {
type DiceResultsProps = {
diceRolls: DiceRoll[];
onDiceClear: () => void;
onDiceReroll: () => void;
};
function DiceResults({
diceRolls,
onDiceClear,
onDiceReroll,
}: DiceResultsProps) {
const [isExpanded, setIsExpanded] = useState(false);
if (diceRolls.length === 0) {
return null;
}
let rolls = [];
let rolls: React.ReactChild[] = [];
if (diceRolls.length > 1) {
rolls = diceRolls
.filter((dice) => dice.roll !== "unknown")

View File

@ -1,9 +1,17 @@
import React from "react";
import { Image } from "theme-ui";
import Tile from "../tile/Tile";
function DiceTile({ dice, isSelected, onDiceSelect, onDone }) {
import { DefaultDice } from "../../types/Dice";
type DiceTileProps = {
dice: DefaultDice;
isSelected: boolean;
onDiceSelect: (dice: DefaultDice) => void;
onDone: (dice: DefaultDice) => void;
};
function DiceTile({ dice, isSelected, onDiceSelect, onDone }: DiceTileProps) {
return (
<div style={{ cursor: "pointer" }}>
<Tile

View File

@ -1,12 +1,25 @@
import React from "react";
import { Grid } from "theme-ui";
import SimpleBar from "simplebar-react";
import DiceTile from "./DiceTile";
import useResponsiveLayout from "../../hooks/useResponsiveLayout";
import { DefaultDice } from "../../types/Dice";
import { DiceSelectEventHandler } from "../../types/Events";
function DiceTiles({ dice, onDiceSelect, selectedDice, onDone }) {
type DiceTileProps = {
dice: DefaultDice[];
onDiceSelect: DiceSelectEventHandler;
selectedDice: DefaultDice;
onDone: DiceSelectEventHandler;
};
function DiceTiles({
dice,
onDiceSelect,
selectedDice,
onDone,
}: DiceTileProps) {
const layout = useResponsiveLayout();
return (
@ -29,7 +42,6 @@ function DiceTiles({ dice, onDiceSelect, selectedDice, onDone }) {
isSelected={selectedDice && dice.key === selectedDice.key}
onDiceSelect={onDiceSelect}
onDone={onDone}
size={layout.tileSize}
/>
))}
</Grid>

View File

@ -1,10 +1,12 @@
import React, { useRef, useCallback, useEffect, useState } from "react";
import { useRef, useCallback, useEffect, useState } from "react";
import { Vector3 } from "@babylonjs/core/Maths/math";
import { DirectionalLight } from "@babylonjs/core/Lights/directionalLight";
import { ShadowGenerator } from "@babylonjs/core/Lights/Shadows/shadowGenerator";
import { CubeTexture } from "@babylonjs/core/Materials/Textures/cubeTexture";
import { Scene } from "@babylonjs/core";
import { Box } from "theme-ui";
// @ts-ignore
import environment from "../../dice/environment.dds";
import DiceInteraction from "./DiceInteraction";
@ -18,25 +20,41 @@ import DiceTray from "../../dice/diceTray/DiceTray";
import { useDiceLoading } from "../../contexts/DiceLoadingContext";
import { getDiceRoll } from "../../helpers/dice";
import useSetting from "../../hooks/useSetting";
import { DefaultDice, DiceMesh, DiceRoll, DiceType } from "../../types/Dice";
import {
DiceRollsChangeEventHandler,
DiceShareChangeEventHandler,
} from "../../types/Events";
type DiceTrayOverlayProps = {
isOpen: boolean;
shareDice: boolean;
onShareDiceChange: DiceShareChangeEventHandler;
diceRolls: DiceRoll[];
onDiceRollsChange: DiceRollsChangeEventHandler;
};
function DiceTrayOverlay({
isOpen,
shareDice,
onShareDiceChage,
onShareDiceChange,
diceRolls,
onDiceRollsChange,
}) {
const sceneRef = useRef();
const shadowGeneratorRef = useRef();
const diceRefs = useRef([]);
}: DiceTrayOverlayProps) {
const sceneRef = useRef<Scene>();
const shadowGeneratorRef = useRef<ShadowGenerator>();
const diceRefs = useRef<DiceMesh[]>([]);
const sceneVisibleRef = useRef(false);
const sceneInteractionRef = useRef(false);
// Add to the counter to ingore sleep values
const sceneKeepAwakeRef = useRef(0);
const diceTrayRef = useRef();
const diceTrayRef = useRef<DiceTray>();
const [diceTraySize, setDiceTraySize] = useState("single");
const [diceTraySize, setDiceTraySize] =
useState<"single" | "double">("single");
const { assetLoadStart, assetLoadFinish, isLoading } = useDiceLoading();
const [fullScreen] = useSetting("map.fullScreen");
@ -50,7 +68,7 @@ function DiceTrayOverlay({
}
// Forces rendering for 1 second
function forceRender() {
function forceRender(): () => void {
// Force rerender
sceneKeepAwakeRef.current++;
let triggered = false;
@ -97,7 +115,7 @@ function DiceTrayOverlay({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function initializeScene(scene) {
async function initializeScene(scene: Scene) {
handleAssetLoadStart();
let light = new DirectionalLight(
"DirectionalLight",
@ -124,16 +142,14 @@ function DiceTrayOverlay({
handleAssetLoadFinish();
}
function update(scene) {
function getDiceSpeed(dice) {
const diceSpeed = dice.instance.physicsImpostor
.getLinearVelocity()
.length();
function update(scene: Scene) {
function getDiceSpeed(dice: DiceMesh) {
const diceSpeed =
dice.instance.physicsImpostor?.getLinearVelocity()?.length() || 0;
// If the dice is a d100 check the d10 as well
if (dice.type === "d100") {
const d10Speed = dice.d10Instance.physicsImpostor
.getLinearVelocity()
.length();
if (dice.d10Instance) {
const d10Speed =
dice.d10Instance.physicsImpostor?.getLinearVelocity()?.length() || 0;
return Math.max(diceSpeed, d10Speed);
} else {
return diceSpeed;
@ -157,14 +173,14 @@ function DiceTrayOverlay({
const dice = die[i];
const speed = getDiceSpeed(dice);
// If the speed has been below 0.01 for 1s set dice to sleep
if (speed < 0.01 && !dice.sleepTimout) {
dice.sleepTimout = setTimeout(() => {
if (speed < 0.01 && !dice.sleepTimeout) {
dice.sleepTimeout = setTimeout(() => {
dice.asleep = true;
}, 1000);
} else if (speed > 0.5 && (dice.asleep || dice.sleepTimout)) {
} else if (speed > 0.5 && (dice.asleep || dice.sleepTimeout)) {
dice.asleep = false;
clearTimeout(dice.sleepTimout);
dice.sleepTimout = null;
dice.sleepTimeout && clearTimeout(dice.sleepTimeout);
dice.sleepTimeout = undefined;
}
}
@ -173,14 +189,14 @@ function DiceTrayOverlay({
}
}
function handleDiceAdd(style, type) {
function handleDiceAdd(style: typeof Dice, type: DiceType) {
const scene = sceneRef.current;
const shadowGenerator = shadowGeneratorRef.current;
if (scene && shadowGenerator) {
const instance = style.createInstance(type, scene);
shadowGenerator.addShadowCaster(instance);
Dice.roll(instance);
let dice = { type, instance, asleep: false };
style.roll(instance);
let dice: DiceMesh = { type, instance, asleep: false };
// If we have a d100 add a d10 as well
if (type === "d100") {
const d10Instance = style.createInstance("d10", scene);
@ -196,7 +212,7 @@ function DiceTrayOverlay({
const die = diceRefs.current;
for (let dice of die) {
dice.instance.dispose();
if (dice.type === "d100") {
if (dice.d10Instance) {
dice.d10Instance.dispose();
}
}
@ -208,14 +224,14 @@ function DiceTrayOverlay({
const die = diceRefs.current;
for (let dice of die) {
Dice.roll(dice.instance);
if (dice.type === "d100") {
if (dice.d10Instance) {
Dice.roll(dice.d10Instance);
}
dice.asleep = false;
}
}
async function handleDiceLoad(dice) {
async function handleDiceLoad(dice: DefaultDice) {
handleAssetLoadStart();
const scene = sceneRef.current;
if (scene) {
@ -230,10 +246,13 @@ function DiceTrayOverlay({
});
useEffect(() => {
let renderTimeout;
let renderCleanup;
let renderTimeout: NodeJS.Timeout;
let renderCleanup: () => void;
function handleResize() {
const map = document.querySelector(".map");
if (!map) {
return;
}
const mapRect = map.getBoundingClientRect();
const availableWidth = mapRect.width - 108; // Subtract padding
@ -283,7 +302,7 @@ function DiceTrayOverlay({
return;
}
let newRolls = [];
let newRolls: DiceRoll[] = [];
for (let i = 0; i < die.length; i++) {
const dice = die[i];
let roll = getDiceRoll(dice);
@ -345,7 +364,7 @@ function DiceTrayOverlay({
onDiceTraySizeChange={setDiceTraySize}
diceTraySize={diceTraySize}
shareDice={shareDice}
onShareDiceChange={onShareDiceChage}
onShareDiceChange={onShareDiceChange}
loading={isLoading}
/>
{isLoading && (

View File

@ -1,10 +1,22 @@
import React, { useState } from "react";
import { useState } from "react";
import { IconButton } from "theme-ui";
import SelectDiceIcon from "../../icons/SelectDiceIcon";
import SelectDiceModal from "../../modals/SelectDiceModal";
function SelectDiceButton({ onDiceChange, currentDice, disabled }) {
import { DefaultDice } from "../../types/Dice";
type SelectDiceButtonProps = {
onDiceChange: (dice: DefaultDice) => void;
currentDice: DefaultDice;
disabled: boolean;
};
function SelectDiceButton({
onDiceChange,
currentDice,
disabled,
}: SelectDiceButtonProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
function openModal() {
@ -14,7 +26,7 @@ function SelectDiceButton({ onDiceChange, currentDice, disabled }) {
setIsModalOpen(false);
}
function handleDone(dice) {
function handleDone(dice: DefaultDice) {
onDiceChange(dice);
closeModal();
}
@ -39,4 +51,8 @@ function SelectDiceButton({ onDiceChange, currentDice, disabled }) {
);
}
SelectDiceButton.defaultProps = {
disabled: false,
};
export default SelectDiceButton;

View File

@ -1,7 +1,14 @@
import React from "react";
import { useDraggable } from "@dnd-kit/core";
import { Data } from "@dnd-kit/core/dist/store/types";
function Draggable({ id, children, data }) {
type DraggableProps = {
id: string;
children: React.ReactNode;
data?: Data;
};
function Draggable({ id, children, data }: DraggableProps) {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id,
data,

View File

@ -1,7 +1,12 @@
import React from "react";
import { useDroppable } from "@dnd-kit/core";
function Droppable({ id, children, disabled, ...props }) {
type DroppableProps = React.HTMLAttributes<HTMLDivElement> & {
id: string;
disabled: boolean;
};
function Droppable({ id, children, disabled, ...props }: DroppableProps) {
const { setNodeRef } = useDroppable({ id, disabled });
return (

View File

@ -20,9 +20,23 @@ import { useTokenData } from "../../contexts/TokenDataContext";
import { useAssets } from "../../contexts/AssetsContext";
import { useMapStage } from "../../contexts/MapStageContext";
import useImageDrop from "../../hooks/useImageDrop";
import useImageDrop, { ImageDropEvent } from "../../hooks/useImageDrop";
function GlobalImageDrop({ children, onMapChange, onMapTokensStateCreate }) {
import { Map } from "../../types/Map";
import { MapState } from "../../types/MapState";
import { TokenState } from "../../types/TokenState";
type GlobalImageDropProps = {
children?: React.ReactNode;
onMapChange: (map: Map, mapState: MapState) => void;
onMapTokensStateCreate: (states: TokenState[]) => void;
};
function GlobalImageDrop({
children,
onMapChange,
onMapTokensStateCreate,
}: GlobalImageDropProps) {
const { addToast } = useToasts();
const userId = useUserId();
@ -32,17 +46,15 @@ function GlobalImageDrop({ children, onMapChange, onMapTokensStateCreate }) {
const mapStageRef = useMapStage();
const [isLargeImageWarningModalOpen, setShowLargeImageWarning] = useState(
false
);
const [isLargeImageWarningModalOpen, setShowLargeImageWarning] =
useState(false);
const [isLoading, setIsLoading] = useState(false);
const droppedImagesRef = useRef();
const dropPositionRef = useRef();
// maps or tokens
const [droppingType, setDroppingType] = useState("maps");
const droppedImagesRef = useRef<File[]>();
const dropPositionRef = useRef<Vector2>();
const [droppingType, setDroppingType] = useState<"maps" | "tokens">("maps");
async function handleDrop(files, dropPosition) {
async function handleDrop({ files, dropPosition }: ImageDropEvent) {
if (navigator.storage) {
// Attempt to enable persistant storage
await navigator.storage.persist();
@ -87,55 +99,65 @@ function GlobalImageDrop({ children, onMapChange, onMapTokensStateCreate }) {
}
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);
}
if (droppedImagesRef.current && userId) {
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);
}
// Change map if only 1 dropped
if (maps.length === 1) {
const mapState = await getMapState(maps[0].id);
if (mapState) {
onMapChange(maps[0], mapState);
}
}
setIsLoading(false);
droppedImagesRef.current = undefined;
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;
if (droppedImagesRef.current && userId) {
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);
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);
}
}
if (tokenStates.length > 0) {
onMapTokensStateCreate(tokenStates);
}
}
}
@ -149,9 +171,8 @@ function GlobalImageDrop({ children, onMapChange, onMapTokensStateCreate }) {
setDroppingType("tokens");
}
const { dragging, containerListeners, overlayListeners } = useImageDrop(
handleDrop
);
const { dragging, containerListeners, overlayListeners } =
useImageDrop(handleDrop);
return (
<Flex sx={{ height: "100%", flexGrow: 1 }} {...containerListeners}>

View File

@ -1,12 +1,17 @@
import React from "react";
import { Box, Flex, Text } from "theme-ui";
import useImageDrop from "../../hooks/useImageDrop";
import useImageDrop, { ImageDropEvent } from "../../hooks/useImageDrop";
function ImageDrop({ onDrop, dropText, children }) {
const { dragging, containerListeners, overlayListeners } = useImageDrop(
onDrop
);
type ImageDropProps = {
onDrop: (event: ImageDropEvent) => void;
dropText: string;
children?: React.ReactNode;
};
function ImageDrop({ onDrop, dropText, children }: ImageDropProps) {
const { dragging, containerListeners, overlayListeners } =
useImageDrop(onDrop);
return (
<Box {...containerListeners}>
{children}

View File

@ -0,0 +1,92 @@
import Konva from "konva";
import { Circle, Line, Rect } from "react-konva";
import {
useMapHeight,
useMapWidth,
} from "../../contexts/MapInteractionContext";
import colors from "../../helpers/colors";
import { scaleAndFlattenPoints } from "../../helpers/konva";
import Vector2 from "../../helpers/Vector2";
import { Drawing as DrawingType } from "../../types/Drawing";
type DrawingProps = {
drawing: DrawingType;
} & Konva.ShapeConfig;
function Drawing({ drawing, ...props }: DrawingProps) {
const mapWidth = useMapWidth();
const mapHeight = useMapHeight();
const mapSize = new Vector2(mapWidth, mapHeight);
const defaultProps = {
fill: colors[drawing.color] || drawing.color,
stroke: colors[drawing.color] || drawing.color,
opacity: drawing.blend ? 0.5 : 1,
id: drawing.id,
};
if (drawing.type === "path") {
return (
<Line
points={scaleAndFlattenPoints(drawing.data.points, mapSize)}
tension={0.5}
closed={drawing.pathType === "fill"}
fillEnabled={drawing.pathType === "fill"}
lineCap="round"
lineJoin="round"
{...defaultProps}
{...props}
/>
);
} else if (drawing.type === "shape") {
if (drawing.shapeType === "rectangle") {
return (
<Rect
x={drawing.data.x * mapWidth}
y={drawing.data.y * mapHeight}
width={drawing.data.width * mapWidth}
height={drawing.data.height * mapHeight}
fillEnabled={props.strokeWidth === 0}
{...defaultProps}
{...props}
/>
);
} else if (drawing.shapeType === "circle") {
const minSide = mapWidth < mapHeight ? mapWidth : mapHeight;
return (
<Circle
x={drawing.data.x * mapWidth}
y={drawing.data.y * mapHeight}
radius={drawing.data.radius * minSide}
fillEnabled={props.strokeWidth === 0}
{...defaultProps}
{...props}
/>
);
} else if (drawing.shapeType === "triangle") {
return (
<Line
points={scaleAndFlattenPoints(drawing.data.points, mapSize)}
closed={true}
fillEnabled={props.strokeWidth === 0}
{...defaultProps}
{...props}
/>
);
} else if (drawing.shapeType === "line") {
return (
<Line
points={scaleAndFlattenPoints(drawing.data.points, mapSize)}
lineCap="round"
{...defaultProps}
{...props}
/>
);
}
}
return null;
}
export default Drawing;

View File

@ -0,0 +1,143 @@
import Konva from "konva";
import { Line } from "react-konva";
import { scaleAndFlattenPoints } from "../../helpers/konva";
import { Fog as FogType } from "../../types/Fog";
import {
useMapHeight,
useMapWidth,
} from "../../contexts/MapInteractionContext";
import Vector2 from "../../helpers/Vector2";
type FogProps = {
fog: FogType;
} & Konva.LineConfig;
// Holes should be wound in the opposite direction as the containing points array
function Fog({ fog, opacity, ...props }: FogProps) {
const mapWidth = useMapWidth();
const mapHeight = useMapHeight();
const mapSize = new Vector2(mapWidth, mapHeight);
const points = scaleAndFlattenPoints(fog.data.points, mapSize);
const holes = fog.data.holes.map((hole) =>
scaleAndFlattenPoints(hole, mapSize)
);
// Converted from https://github.com/rfestag/konva/blob/master/src/shapes/Line.ts
function drawLine(
points: number[],
context: Konva.Context,
shape: Konva.Line
) {
const length = points.length;
const tension = shape.tension();
const closed = shape.closed();
const bezier = shape.bezier();
if (!length) {
return;
}
context.moveTo(points[0], points[1]);
if (tension !== 0 && length > 4) {
const tensionPoints = shape.getTensionPoints();
const tensionLength = tensionPoints.length;
let n = closed ? 0 : 4;
if (!closed) {
context.quadraticCurveTo(
tensionPoints[0],
tensionPoints[1],
tensionPoints[2],
tensionPoints[3]
);
}
while (n < tensionLength - 2) {
context.bezierCurveTo(
tensionPoints[n++],
tensionPoints[n++],
tensionPoints[n++],
tensionPoints[n++],
tensionPoints[n++],
tensionPoints[n++]
);
}
if (!closed) {
context.quadraticCurveTo(
tensionPoints[tensionLength - 2],
tensionPoints[tensionLength - 1],
points[length - 2],
points[length - 1]
);
}
} else if (bezier) {
// no tension but bezier
let n = 2;
while (n < length) {
context.bezierCurveTo(
points[n++],
points[n++],
points[n++],
points[n++],
points[n++],
points[n++]
);
}
} else {
// no tension
for (let n = 2; n < length; n += 2) {
context.lineTo(points[n], points[n + 1]);
}
}
}
// Draw points and holes
function sceneFunc(context: Konva.Context, shape: Konva.Line) {
const points = shape.points();
const closed = shape.closed();
if (!points.length) {
return;
}
context.beginPath();
drawLine(points, context, shape);
context.beginPath();
drawLine(points, context, shape);
// closed e.g. polygons and blobs
if (closed) {
context.closePath();
if (holes && holes.length) {
for (let hole of holes) {
drawLine(hole, context, shape);
context.closePath();
}
}
context.fillStrokeShape(shape);
} else {
// open e.g. lines and splines
context.strokeShape(shape);
}
}
return (
<Line
points={points}
closed
lineCap="round"
lineJoin="round"
{...props}
sceneFunc={sceneFunc as any}
/>
);
}
export default Fog;

View File

@ -1,22 +1,21 @@
import React from "react";
import { Group, Rect } from "react-konva";
import useImage from "use-image";
import Vector2 from "../helpers/Vector2";
import Vector2 from "../../helpers/Vector2";
import {
useGrid,
useGridPixelSize,
useGridOffset,
useGridCellPixelSize,
} from "../contexts/GridContext";
} from "../../contexts/GridContext";
import squarePatternDark from "../images/SquarePatternDark.png";
import squarePatternLight from "../images/SquarePatternLight.png";
import hexPatternDark from "../images/HexPatternDark.png";
import hexPatternLight from "../images/HexPatternLight.png";
import squarePatternDark from "../../images/SquarePatternDark.png";
import squarePatternLight from "../../images/SquarePatternLight.png";
import hexPatternDark from "../../images/HexPatternDark.png";
import hexPatternLight from "../../images/HexPatternLight.png";
function Grid({ stroke }) {
function Grid({ stroke }: { stroke: "black" | "white" }) {
const grid = useGrid();
const gridPixelSize = useGridPixelSize();
const gridOffset = useGridOffset();
@ -45,26 +44,32 @@ function Grid({ stroke }) {
const negativeGridOffset = Vector2.multiply(gridOffset, -1);
let patternProps = {};
let patternProps: Record<any, any> = {};
if (grid.type === "square") {
// Square grid pattern is 150 DPI
const scale = gridCellPixelSize.width / 300;
patternProps.fillPatternScaleX = scale;
patternProps.fillPatternScaleY = scale;
patternProps.fillPatternOffsetX = gridCellPixelSize.width / scale / 2;
patternProps.fillPatternOffsetY = gridCellPixelSize.height / scale / 2;
if (scale > 0) {
patternProps.fillPatternScaleX = scale;
patternProps.fillPatternScaleY = scale;
patternProps.fillPatternOffsetX = gridCellPixelSize.width / scale / 2;
patternProps.fillPatternOffsetY = gridCellPixelSize.height / scale / 2;
}
} else if (grid.type === "hexVertical") {
// Hex tile pattern is 153 DPI to better fit hex tiles
const scale = gridCellPixelSize.width / 153;
patternProps.fillPatternScaleX = scale;
patternProps.fillPatternScaleY = scale;
patternProps.fillPatternOffsetY = gridCellPixelSize.radius / scale / 2;
if (scale > 0) {
patternProps.fillPatternScaleX = scale;
patternProps.fillPatternScaleY = scale;
patternProps.fillPatternOffsetY = gridCellPixelSize.radius / scale / 2;
}
} else if (grid.type === "hexHorizontal") {
const scale = gridCellPixelSize.height / 153;
patternProps.fillPatternScaleX = scale;
patternProps.fillPatternScaleY = scale;
patternProps.fillPatternOffsetY = -gridCellPixelSize.radius / scale / 2;
patternProps.fillPatternRotation = 90;
if (scale > 0) {
patternProps.fillPatternScaleX = scale;
patternProps.fillPatternScaleY = scale;
patternProps.fillPatternOffsetY = -gridCellPixelSize.radius / scale / 2;
patternProps.fillPatternRotation = 90;
}
}
return (

View File

@ -0,0 +1,304 @@
import { useEffect, useState, useRef } from "react";
import { Rect, Text } from "react-konva";
import Konva from "konva";
import { useSpring, animated } from "@react-spring/konva";
import { useUserId } from "../../contexts/UserIdContext";
import {
useSetPreventMapInteraction,
useMapWidth,
useMapHeight,
} from "../../contexts/MapInteractionContext";
import { useGridCellPixelSize } from "../../contexts/GridContext";
import colors from "../../helpers/colors";
import usePrevious from "../../hooks/usePrevious";
import useGridSnapping from "../../hooks/useGridSnapping";
import { Note as NoteType } from "../../types/Note";
import {
NoteChangeEventHandler,
NoteDragEventHandler,
NoteMenuCloseEventHandler,
NoteMenuOpenEventHandler,
} from "../../types/Events";
import { Map } from "../../types/Map";
import Transformer from "./Transformer";
const defaultFontSize = 144;
const minFontSize = 16;
type NoteProps = {
note: NoteType;
map: Map | null;
onNoteChange?: NoteChangeEventHandler;
onNoteMenuOpen?: NoteMenuOpenEventHandler;
onNoteMenuClose?: NoteMenuCloseEventHandler;
draggable: boolean;
onNoteDragStart?: NoteDragEventHandler;
onNoteDragEnd?: NoteDragEventHandler;
fadeOnHover: boolean;
selected: boolean;
};
function Note({
note,
map,
onNoteChange,
onNoteMenuOpen,
onNoteMenuClose,
draggable,
onNoteDragStart,
onNoteDragEnd,
fadeOnHover,
selected,
}: NoteProps) {
const userId = useUserId();
const mapWidth = useMapWidth();
const mapHeight = useMapHeight();
const setPreventMapInteraction = useSetPreventMapInteraction();
const gridCellPixelSize = useGridCellPixelSize();
const minCellSize = Math.min(
gridCellPixelSize.width,
gridCellPixelSize.height
);
const noteWidth = minCellSize * note.size;
const noteHeight = noteWidth;
const notePadding = noteWidth / 10;
const snapPositionToGrid = useGridSnapping();
function handleDragStart(event: Konva.KonvaEventObject<DragEvent>) {
onNoteDragStart?.(event, note.id);
}
function handleDragMove(event: Konva.KonvaEventObject<DragEvent>) {
const noteGroup = event.target;
// Snap to corners of grid
if (map?.snapToGrid) {
noteGroup.position(snapPositionToGrid(noteGroup.position()));
}
}
function handleDragEnd(event: Konva.KonvaEventObject<DragEvent>) {
const noteGroup = event.target;
if (userId) {
onNoteChange?.({
[note.id]: {
x: noteGroup.x() / mapWidth,
y: noteGroup.y() / mapHeight,
lastModifiedBy: userId,
lastModified: Date.now(),
},
});
}
onNoteDragEnd?.(event, note.id);
setPreventMapInteraction(false);
}
function handleClick(event: Konva.KonvaEventObject<MouseEvent>) {
if (draggable) {
const noteNode = event.target;
onNoteMenuOpen && onNoteMenuOpen(note.id, noteNode, true);
}
}
// Store note pointer down time to check for a click when note is locked
const notePointerDownTimeRef = useRef<number>(0);
function handlePointerDown(event: Konva.KonvaEventObject<PointerEvent>) {
if (draggable) {
setPreventMapInteraction(true);
}
if (note.locked && map?.owner === userId) {
notePointerDownTimeRef.current = event.evt.timeStamp;
}
}
function handlePointerUp(event: Konva.KonvaEventObject<PointerEvent>) {
if (draggable) {
setPreventMapInteraction(false);
}
// Check note click when locked and we are the map owner
// We can't use onClick because that doesn't check pointer distance
if (note.locked && map?.owner === userId) {
// If down and up time is small trigger a click
const delta = event.evt.timeStamp - notePointerDownTimeRef.current;
if (delta < 300) {
const noteNode = event.target;
onNoteMenuOpen?.(note.id, noteNode, true);
}
}
}
const [noteOpacity, setNoteOpacity] = useState(1);
function handlePointerEnter() {
if (fadeOnHover) {
setNoteOpacity(0.5);
}
}
function handlePointerLeave() {
if (noteOpacity !== 1.0) {
setNoteOpacity(1.0);
}
}
const [fontScale, setFontScale] = useState(1);
useEffect(() => {
const text = textRef.current;
function findFontSize() {
if (!text) {
return;
}
// Create an array from 1 / minFontSize of the note height to the full note height
let sizes = Array.from(
{ length: Math.ceil(noteHeight - notePadding * 2) },
(_, i) => i + Math.ceil(noteHeight / minFontSize)
);
if (sizes.length > 0) {
const size = sizes.reduce((prev, curr) => {
text.fontSize(curr);
const width = text.getTextWidth() + notePadding * 2;
const height = text.height() + notePadding * 2;
if (width < noteWidth && height < noteHeight) {
return curr;
} else {
return prev;
}
});
setFontScale(size / defaultFontSize);
}
}
findFontSize();
}, [note, note.text, note.visible, noteWidth, noteHeight, notePadding]);
const textRef = useRef<Konva.Text>(null);
const noteRef = useRef<Konva.Group>(null);
const [isTransforming, setIsTransforming] = useState(false);
function handleTransformStart() {
setIsTransforming(true);
onNoteMenuClose?.();
}
function handleTransformEnd(event: Konva.KonvaEventObject<Event>) {
if (noteRef.current) {
const sizeChange = event.target.scaleX();
const rotation = event.target.rotation();
onNoteChange?.({
[note.id]: {
size: note.size * sizeChange,
rotation: rotation,
},
});
onNoteMenuOpen?.(note.id, noteRef.current, false);
noteRef.current.scaleX(1);
noteRef.current.scaleY(1);
}
setIsTransforming(false);
}
// Animate to new note positions if edited by others
const noteX = note.x * mapWidth;
const noteY = note.y * mapHeight;
const previousWidth = usePrevious(mapWidth);
const previousHeight = usePrevious(mapHeight);
const resized = mapWidth !== previousWidth || mapHeight !== previousHeight;
const skipAnimation = note.lastModifiedBy === userId || resized;
const props = useSpring({
x: noteX,
y: noteY,
immediate: skipAnimation,
});
// When a note is hidden if you aren't the map owner hide it completely
if (map && !note.visible && map.owner !== userId) {
return null;
}
const noteName = `note${note.locked ? "-locked" : ""}`;
return (
<>
<animated.Group
{...props}
id={note.id}
onClick={handleClick}
onTap={handleClick}
width={noteWidth}
height={note.textOnly ? undefined : noteHeight}
rotation={note.rotation}
offsetX={noteWidth / 2}
offsetY={noteHeight / 2}
draggable={draggable}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragMove={handleDragMove}
onMouseDown={handlePointerDown}
onMouseUp={handlePointerUp}
onTouchStart={handlePointerDown}
onTouchEnd={handlePointerUp}
onMouseEnter={handlePointerEnter}
onMouseLeave={handlePointerLeave}
opacity={note.visible ? noteOpacity : 0.5}
name={noteName}
ref={noteRef}
>
{!note.textOnly && (
<Rect
width={noteWidth}
height={noteHeight}
shadowColor="rgba(0, 0, 0, 0.16)"
shadowOffset={{ x: 0, y: 3 }}
shadowBlur={6}
cornerRadius={0.25}
fill={colors[note.color]}
/>
)}
<Text
text={note.text}
fill={
note.textOnly
? colors[note.color]
: note.color === "black" || note.color === "darkGray"
? "white"
: "black"
}
align="left"
verticalAlign="middle"
padding={notePadding / fontScale}
fontSize={defaultFontSize}
// Scale font instead of changing font size to avoid kerning issues with Firefox
scaleX={fontScale}
scaleY={fontScale}
width={noteWidth / fontScale}
height={note.textOnly ? undefined : noteHeight / fontScale}
wrap="word"
/>
{/* Use an invisible text block to work out text sizing */}
<Text visible={false} ref={textRef} text={note.text} wrap="none" />
</animated.Group>
<Transformer
active={(!note.locked && selected) || isTransforming}
nodes={noteRef.current ? [noteRef.current] : []}
onTransformEnd={handleTransformEnd}
onTransformStart={handleTransformStart}
gridScale={map?.grid.measurement.scale || ""}
/>
</>
);
}
Note.defaultProps = {
fadeOnHover: false,
draggable: false,
};
export default Note;

View File

@ -0,0 +1,169 @@
import Color from "color";
import Konva from "konva";
import { useEffect, useRef } from "react";
import { Circle, Group, Line } from "react-konva";
import Vector2 from "../../helpers/Vector2";
interface PointerPoint extends Vector2 {
lifetime: number;
}
type PointerProps = {
position: Vector2;
size: number;
duration: number;
segments: number;
color: string;
};
function Pointer({ position, size, duration, segments, color }: PointerProps) {
const trailRef = useRef<Konva.Line>(null);
const pointsRef = useRef<PointerPoint[]>([]);
const prevPositionRef = useRef(position);
const positionRef = useRef(position);
const circleRef = useRef<Konva.Circle>(null);
// Color of the end of the trail
const transparentColorRef = useRef(
Color(color).lighten(0.5).alpha(0).string()
);
useEffect(() => {
// Lighten color to give it a `glow` effect
transparentColorRef.current = Color(color).lighten(0.5).alpha(0).string();
}, [color]);
// Keep track of position so we can use it in the trail animation
useEffect(() => {
positionRef.current = position;
}, [position]);
// Add a new point every time position is changed
useEffect(() => {
if (Vector2.compare(position, prevPositionRef.current, 0.0001)) {
return;
}
pointsRef.current.push({ ...position, lifetime: duration });
prevPositionRef.current = position;
}, [position, duration]);
// Advance lifetime of trail
useEffect(() => {
let prevTime = performance.now();
let request = requestAnimationFrame(animate);
function animate(time: number) {
request = requestAnimationFrame(animate);
const deltaTime = time - prevTime;
prevTime = time;
if (pointsRef.current.length === 0) {
return;
}
let expired = 0;
for (let point of pointsRef.current) {
point.lifetime -= deltaTime;
if (point.lifetime < 0) {
expired++;
}
}
if (expired > 0) {
pointsRef.current = pointsRef.current.slice(expired);
}
// Update the circle position to keep it in sync with the trail
if (circleRef && circleRef.current) {
circleRef.current.x(positionRef.current.x);
circleRef.current.y(positionRef.current.y);
}
if (trailRef && trailRef.current) {
trailRef.current.getLayer()?.draw();
}
}
return () => {
cancelAnimationFrame(request);
};
}, []);
// Custom scene function for drawing a trail from a line
function sceneFunc(context: Konva.Context) {
// Resample points to ensure a smooth trail
const resampledPoints = Vector2.resample(pointsRef.current, segments);
if (resampledPoints.length === 0) {
return;
}
// Draws a line offset in the direction perpendicular to its travel direction
const drawOffsetLine = (from: Vector2, to: Vector2, alpha: number) => {
const forward = Vector2.normalize(Vector2.subtract(from, to));
// Rotate the forward vector 90 degrees based off of the direction
const side = Vector2.rotate90(forward);
// Offset the `to` position by the size of the point and in the side direction
const toSize = (alpha * size) / 2;
const toOffset = Vector2.add(to, Vector2.multiply(side, toSize));
context.lineTo(toOffset.x, toOffset.y);
};
context.beginPath();
// Sample the points starting from the tail then traverse counter clockwise drawing each point
// offset to make a taper, stops at the base of the trail
context.moveTo(resampledPoints[0].x, resampledPoints[0].y);
for (let i = 1; i < resampledPoints.length; i++) {
const from = resampledPoints[i - 1];
const to = resampledPoints[i];
drawOffsetLine(from, to, i / resampledPoints.length);
}
// Start from the base of the trail and continue drawing down back to the end of the tail
for (let i = resampledPoints.length - 2; i >= 0; i--) {
const from = resampledPoints[i + 1];
const to = resampledPoints[i];
drawOffsetLine(from, to, i / resampledPoints.length);
}
context.lineTo(resampledPoints[0].x, resampledPoints[0].y);
context.closePath();
// Create a radial gradient from the center of the trail to the tail
const gradientCenter = resampledPoints[resampledPoints.length - 1];
const gradientEnd = resampledPoints[0];
const gradientRadius = Vector2.magnitude(
Vector2.subtract(gradientCenter, gradientEnd)
);
let gradient = context.createRadialGradient(
gradientCenter.x,
gradientCenter.y,
0,
gradientCenter.x,
gradientCenter.y,
gradientRadius
);
gradient.addColorStop(0, color);
gradient.addColorStop(1, transparentColorRef.current);
// @ts-ignore
context.fillStyle = gradient;
context.fill();
}
return (
<Group>
<Line sceneFunc={sceneFunc} ref={trailRef} />
<Circle
x={position.x}
y={position.y}
fill={color}
width={size}
height={size}
ref={circleRef}
/>
</Group>
);
}
Pointer.defaultProps = {
// Duration of each point in milliseconds
duration: 200,
// Number of segments in the trail, resampled from the points
segments: 20,
};
export default Pointer;

View File

@ -0,0 +1,69 @@
import { Group, Label, Line, Tag, Text } from "react-konva";
import { useGridStrokeWidth } from "../../contexts/GridContext";
import {
useDebouncedStageScale,
useMapHeight,
useMapWidth,
} from "../../contexts/MapInteractionContext";
import { scaleAndFlattenPoints } from "../../helpers/konva";
import Vector2 from "../../helpers/Vector2";
import { GridScale } from "../../types/Grid";
type RulerProps = {
points: Vector2[];
scale: GridScale;
length: number;
};
function Ruler({ points, scale, length }: RulerProps) {
const stageScale = useDebouncedStageScale();
const mapWidth = useMapWidth();
const mapHeight = useMapHeight();
const mapSize = new Vector2(mapWidth, mapHeight);
const gridStrokeWidth = useGridStrokeWidth();
const linePoints = scaleAndFlattenPoints(points, mapSize);
const lineCenter = Vector2.multiply(Vector2.centroid(points), mapSize);
return (
<Group>
<Line
points={linePoints}
strokeWidth={1.5 * gridStrokeWidth}
stroke="hsla(230, 25%, 18%, 0.8)"
lineCap="round"
/>
<Line
points={linePoints}
strokeWidth={0.25 * gridStrokeWidth}
stroke="white"
lineCap="round"
/>
<Label
x={lineCenter.x}
y={lineCenter.y}
offsetX={26}
offsetY={26}
scaleX={1 / stageScale}
scaleY={1 / stageScale}
>
<Tag fill="hsla(230, 25%, 18%, 0.8)" cornerRadius={4} />
<Text
text={`${(length * scale.multiplier).toFixed(scale.digits)}${
scale.unit
}`}
fill="white"
fontSize={24}
padding={4}
/>
</Label>
</Group>
);
}
export default Ruler;

View File

@ -0,0 +1,274 @@
import Konva from "konva";
import { Line, Rect } from "react-konva";
import colors from "../../helpers/colors";
import { scaleAndFlattenPoints } from "../../helpers/konva";
import { useGridStrokeWidth } from "../../contexts/GridContext";
import {
useMapHeight,
useMapWidth,
useStageScale,
} from "../../contexts/MapInteractionContext";
import { useUserId } from "../../contexts/UserIdContext";
import {
Selection as SelectionType,
SelectionItemType,
} from "../../types/Select";
import { useEffect, useRef } from "react";
import Vector2 from "../../helpers/Vector2";
import { SelectionItemsChangeEventHandler } from "../../types/Events";
import { TokenState } from "../../types/TokenState";
import { Map } from "../../types/Map";
import { Note } from "../../types/Note";
import useGridSnapping from "../../hooks/useGridSnapping";
const dashAnimationSpeed = -0.01;
type SelectionProps = {
selection: SelectionType;
onSelectionChange: (selection: SelectionType | null) => void;
onSelectionItemsChange: SelectionItemsChangeEventHandler;
onPreventSelectionChange: (preventSelection: boolean) => void;
onSelectionDragStart: () => void;
onSelectionDragEnd: () => void;
map: Map;
} & Konva.ShapeConfig;
function Selection({
selection,
onSelectionChange,
onSelectionItemsChange,
onPreventSelectionChange,
onSelectionDragStart,
onSelectionDragEnd,
map,
...props
}: SelectionProps) {
const userId = useUserId();
const mapWidth = useMapWidth();
const mapHeight = useMapHeight();
const stageScale = useStageScale();
const gridStrokeWidth = useGridStrokeWidth();
const snapPositionToGrid = useGridSnapping();
const intersectingNodesRef = useRef<
{
type: SelectionItemType;
node: Konva.Node;
id: string;
initialX: number;
initialY: number;
}[]
>([]);
const initialDragPositionRef = useRef({ x: 0, y: 0 });
function handleDragStart(event: Konva.KonvaEventObject<DragEvent>) {
initialDragPositionRef.current = event.target.position();
const stage = event.target.getStage();
if (stage) {
for (let item of selection.items) {
const node = stage.findOne(`#${item.id}`);
// Don't drag locked nodes
if (node && !node.name().endsWith("-locked")) {
intersectingNodesRef.current.push({
...item,
node,
initialX: node.x(),
initialY: node.y(),
});
}
}
}
onSelectionDragStart();
}
function handleDragMove(event: Konva.KonvaEventObject<DragEvent>) {
const deltaPosition = Vector2.subtract(
event.target.position(),
initialDragPositionRef.current
);
for (let item of intersectingNodesRef.current) {
let itemPosition = Vector2.add(
{ x: item.initialX, y: item.initialY },
deltaPosition
);
if (map.snapToGrid) {
itemPosition = snapPositionToGrid(itemPosition);
}
item.node.position(itemPosition);
}
}
function handleDragEnd(event: Konva.KonvaEventObject<DragEvent>) {
const tokenChanges: Record<string, Partial<TokenState>> = {};
const noteChanges: Record<string, Partial<Note>> = {};
for (let item of intersectingNodesRef.current) {
if (item.type === "token") {
tokenChanges[item.id] = {
x: item.node.x() / mapWidth,
y: item.node.y() / mapHeight,
lastModifiedBy: userId,
lastModified: Date.now(),
};
} else {
noteChanges[item.id] = {
x: item.node.x() / mapWidth,
y: item.node.y() / mapHeight,
lastModifiedBy: userId,
lastModified: Date.now(),
};
}
}
onSelectionItemsChange(tokenChanges, noteChanges);
onSelectionChange({
...selection,
x: event.target.x() / mapWidth,
y: event.target.y() / mapHeight,
});
intersectingNodesRef.current = [];
onPreventSelectionChange(false);
onSelectionDragEnd();
}
function handlePointerDown() {
onPreventSelectionChange(true);
}
function handlePointerUp() {
onPreventSelectionChange(false);
}
const hasItems = selection.items.length > 0;
// Update cursor when mouse enter and out of selection
function handleMouseEnter(event: Konva.KonvaEventObject<MouseEvent>) {
let stage = event.target.getStage();
if (stage && hasItems) {
stage.content.style.cursor = "move";
}
}
function handleMouseOut(event: Konva.KonvaEventObject<MouseEvent>) {
let stage = event.target.getStage();
if (stage) {
stage.content.style.cursor = "";
}
}
// Update cursor to move when selection is made or removed
useEffect(() => {
var node: Konva.Node | null = null;
if (lineRef.current) {
node = lineRef.current;
} else if (rectRef.current) {
node = rectRef.current;
}
let stage = node?.getStage();
if (stage && hasItems) {
stage.content.style.cursor = "move";
}
return () => {
if (stage) {
stage.content.style.cursor = "";
}
};
}, [hasItems]);
const requestRef = useRef<number>();
const lineRef = useRef<Konva.Line>(null);
const rectRef = useRef<Konva.Rect>(null);
useEffect(() => {
let prevTime = performance.now();
function animate(time: number) {
const delta = time - prevTime;
prevTime = time;
if (!hasItems) {
return;
}
requestRef.current = requestAnimationFrame(animate);
if (lineRef.current) {
lineRef.current.dashOffset(
lineRef.current.dashOffset() + delta * dashAnimationSpeed
);
}
if (rectRef.current) {
rectRef.current.dashOffset(
rectRef.current.dashOffset() + delta * dashAnimationSpeed
);
}
}
requestRef.current = requestAnimationFrame(animate);
return () => {
if (requestRef.current !== undefined) {
cancelAnimationFrame(requestRef.current);
}
};
}, [hasItems]);
const strokeWidth = (gridStrokeWidth * 0.75) / stageScale;
const defaultProps = {
stroke: colors.primary,
strokeWidth: strokeWidth,
dash: hasItems ? [strokeWidth / 2, strokeWidth * 2] : [],
onDragStart: handleDragStart,
onDragMove: handleDragMove,
onDragEnd: handleDragEnd,
draggable: true,
onMouseDown: handlePointerDown,
onMouseUp: handlePointerUp,
onTouchStart: handlePointerDown,
onTouchEnd: handlePointerUp,
onMouseEnter: handleMouseEnter,
onMouseOut: handleMouseOut,
// Increase stroke width when drawing a selection to
// prevent deselection click event from firing
hitStrokeWidth: hasItems ? undefined : 100,
id: "selection",
};
const x = selection.x * mapWidth;
const y = selection.y * mapHeight;
if (selection.type === "path") {
return (
<Line
points={scaleAndFlattenPoints(selection.data.points, {
x: mapWidth,
y: mapHeight,
})}
tension={0.5}
closed={hasItems}
lineCap="round"
lineJoin="round"
x={x}
y={y}
{...defaultProps}
{...props}
ref={lineRef}
/>
);
} else {
return (
<Rect
x={x}
y={y}
offsetX={-selection.data.x * mapWidth}
offsetY={-selection.data.y * mapHeight}
width={selection.data.width * mapWidth}
height={selection.data.height * mapHeight}
lineCap="round"
lineJoin="round"
{...defaultProps}
{...props}
ref={rectRef}
/>
);
}
}
export default Selection;

View File

@ -0,0 +1,48 @@
import Konva from "konva";
import { useState } from "react";
import { Circle, Group, Path } from "react-konva";
type TickProps = {
x: number;
y: number;
scale: number;
onClick: (evt: Konva.KonvaEventObject<MouseEvent>) => void;
cross: boolean;
};
export function Tick({ x, y, scale, onClick, cross }: TickProps) {
const [fill, setFill] = useState("white");
function handleEnter() {
setFill("hsl(260, 100%, 80%)");
}
function handleLeave() {
setFill("white");
}
return (
<Group
x={x}
y={y}
scaleX={scale}
scaleY={scale}
onMouseEnter={handleEnter}
onMouseLeave={handleLeave}
onClick={onClick}
onTap={onClick}
>
<Circle radius={12} fill="hsla(230, 25%, 18%, 0.8)" />
<Path
offsetX={12}
offsetY={12}
fill={fill}
data={
cross
? "M18.3 5.71c-.39-.39-1.02-.39-1.41 0L12 10.59 7.11 5.7c-.39-.39-1.02-.39-1.41 0-.39.39-.39 1.02 0 1.41L10.59 12 5.7 16.89c-.39.39-.39 1.02 0 1.41.39.39 1.02.39 1.41 0L12 13.41l4.89 4.89c.39.39 1.02.39 1.41 0 .39-.39.39-1.02 0-1.41L13.41 12l4.89-4.89c.38-.38.38-1.02 0-1.4z"
: "M9 16.2l-3.5-3.5c-.39-.39-1.01-.39-1.4 0-.39.39-.39 1.01 0 1.4l4.19 4.19c.39.39 1.02.39 1.41 0L20.3 7.7c.39-.39.39-1.01 0-1.4-.39-.39-1.01-.39-1.4 0L9 16.2z"
}
/>
</Group>
);
}
export default Tick;

View File

@ -0,0 +1,505 @@
import { useState, useRef, useCallback, useMemo } from "react";
import { Image as KonvaImage, Group } from "react-konva";
import { useSpring, animated } from "@react-spring/konva";
import Konva from "konva";
import useImage from "use-image";
import usePrevious from "../../hooks/usePrevious";
import useGridSnapping from "../../hooks/useGridSnapping";
import { useUserId } from "../../contexts/UserIdContext";
import {
useSetPreventMapInteraction,
useMapWidth,
useMapHeight,
} from "../../contexts/MapInteractionContext";
import { useGridCellPixelSize } from "../../contexts/GridContext";
import { useDataURL } from "../../contexts/AssetsContext";
import TokenStatus from "./TokenStatus";
import TokenLabel from "./TokenLabel";
import TokenOutline from "./TokenOutline";
import { Intersection, getScaledOutline } from "../../helpers/token";
import Vector2 from "../../helpers/Vector2";
import { tokenSources } from "../../tokens";
import { TokenState } from "../../types/TokenState";
import { Map } from "../../types/Map";
import {
TokenDragEventHandler,
TokenMenuCloseChangeEventHandler,
TokenMenuOpenChangeEventHandler,
TokenStateChangeEventHandler,
CustomTransformEventHandler,
} from "../../types/Events";
import Transformer from "./Transformer";
import TokenAttachment from "./TokenAttachment";
import { MapState } from "../../types/MapState";
type MapTokenProps = {
tokenState: TokenState;
onTokenStateChange: TokenStateChangeEventHandler;
onTokenMenuOpen: TokenMenuOpenChangeEventHandler;
onTokenMenuClose: TokenMenuCloseChangeEventHandler;
onTokenDragStart: TokenDragEventHandler;
onTokenDragEnd: TokenDragEventHandler;
onTokenTransformStart: CustomTransformEventHandler;
onTokenTransformEnd: CustomTransformEventHandler;
transforming: boolean;
draggable: boolean;
selectable: boolean;
fadeOnHover: boolean;
map: Map;
mapState: MapState;
selected: boolean;
};
function Token({
tokenState,
onTokenStateChange,
onTokenMenuOpen,
onTokenMenuClose,
onTokenDragStart,
onTokenDragEnd,
onTokenTransformStart,
onTokenTransformEnd,
transforming,
draggable,
selectable,
fadeOnHover,
map,
mapState,
selected,
}: MapTokenProps) {
const userId = useUserId();
const mapWidth = useMapWidth();
const mapHeight = useMapHeight();
const setPreventMapInteraction = useSetPreventMapInteraction();
const gridCellPixelSize = useGridCellPixelSize();
const tokenURL = useDataURL(tokenState, tokenSources);
const [tokenImage] = useImage(tokenURL || "");
const tokenAspectRatio = tokenState.width / tokenState.height;
const snapPositionToGrid = useGridSnapping();
const [dragging, setDragging] = useState(false);
const previousDragPositionRef = useRef({ x: 0, y: 0 });
// Tokens that are attached to this token and should move when it moves
const attachedTokensRef = useRef<Konva.Node[]>([]);
// If this an attachment is it over a character
const [attachmentOverCharacter, setAttachmentOverCharacter] = useState(false);
// The characters that we're present when an attachment is dragged, used to highlight the attachment
const attachmentCharactersRef = useRef<Konva.Node[]>([]);
const attachmentThreshold = useMemo(
() => Vector2.componentMin(gridCellPixelSize) / 4,
[gridCellPixelSize]
);
function handleDragStart(event: Konva.KonvaEventObject<DragEvent>) {
const tokenGroup = event.target as Konva.Shape;
previousDragPositionRef.current = tokenGroup.position();
attachedTokensRef.current = getAttachedTokens();
if (tokenState.category === "attachment") {
// If we're dragging an attachment add all characters to the attachment characters
// So we can check for highlights
const characters = tokenGroup.getLayer()?.find(".character") || [];
attachmentCharactersRef.current = characters;
}
setDragging(true);
onTokenDragStart(
event,
tokenState.id,
attachedTokensRef.current.map((token) => token.id())
);
}
function handleDragMove(event: Konva.KonvaEventObject<DragEvent>) {
const tokenGroup = event.target;
// Snap to corners of grid
if (map.snapToGrid) {
tokenGroup.position(snapPositionToGrid(tokenGroup.position()));
}
if (attachedTokensRef.current.length > 0) {
const deltaPosition = Vector2.subtract(
tokenGroup.position(),
previousDragPositionRef.current
);
for (let other of attachedTokensRef.current) {
other.position(Vector2.add(other.position(), deltaPosition));
}
previousDragPositionRef.current = tokenGroup.position();
}
// Check whether an attachment is over a character
if (tokenState.category === "attachment") {
const characters = attachmentCharactersRef.current;
let overCharacter = false;
for (let character of characters) {
const distance = Vector2.distance(
tokenGroup.position(),
character.position()
);
if (distance < attachmentThreshold) {
overCharacter = true;
break;
}
}
if (attachmentOverCharacter !== overCharacter) {
setAttachmentOverCharacter(overCharacter);
}
}
}
function handleDragEnd(event: Konva.KonvaEventObject<DragEvent>) {
const tokenGroup = event.target;
const attachedTokenChanges: Record<string, Partial<TokenState>> = {};
if (attachedTokensRef.current.length > 0) {
for (let other of attachedTokensRef.current) {
attachedTokenChanges[other.id()] = {
x: other.x() / mapWidth,
y: other.y() / mapHeight,
lastModifiedBy: userId,
lastModified: Date.now(),
};
}
}
setPreventMapInteraction(false);
onTokenStateChange({
...attachedTokenChanges,
[tokenState.id]: {
x: tokenGroup.x() / mapWidth,
y: tokenGroup.y() / mapHeight,
lastModifiedBy: userId,
lastModified: Date.now(),
},
});
setDragging(false);
onTokenDragEnd(
event,
tokenState.id,
attachedTokensRef.current.map((token) => token.id())
);
attachmentCharactersRef.current = [];
attachedTokensRef.current = [];
setAttachmentOverCharacter(false);
}
function handleClick() {
if (selectable && draggable && transformRootRef.current) {
onTokenMenuOpen(tokenState.id, transformRootRef.current, true);
}
}
const [tokenOpacity, setTokenOpacity] = useState(1);
// Store token pointer down time to check for a click when token is locked
const tokenPointerDownTimeRef = useRef<number>(0);
function handlePointerDown(event: Konva.KonvaEventObject<PointerEvent>) {
if (draggable) {
setPreventMapInteraction(true);
}
if (tokenState.locked && selectable) {
tokenPointerDownTimeRef.current = event.evt.timeStamp;
}
}
function handlePointerUp(event: Konva.KonvaEventObject<PointerEvent>) {
if (draggable) {
setPreventMapInteraction(false);
}
// Check token click when locked and selectable
// We can't use onClick because that doesn't check pointer distance
if (tokenState.locked && selectable && transformRootRef.current) {
// If down and up time is small trigger a click
const delta = event.evt.timeStamp - tokenPointerDownTimeRef.current;
if (delta < 300) {
onTokenMenuOpen(tokenState.id, transformRootRef.current, true);
}
}
}
function handlePointerEnter() {
if (fadeOnHover) {
setTokenOpacity(0.5);
}
}
function handlePointerLeave() {
if (tokenOpacity !== 1.0) {
setTokenOpacity(1.0);
}
}
const transformRootRef = useRef<Konva.Group>(null);
const minCellSize = Math.min(
gridCellPixelSize.width,
gridCellPixelSize.height
);
const tokenWidth = minCellSize * tokenState.size;
const tokenHeight = (minCellSize / tokenAspectRatio) * tokenState.size;
// Animate to new token positions if edited by others
const tokenX = tokenState.x * mapWidth;
const tokenY = tokenState.y * mapHeight;
const previousWidth = usePrevious(mapWidth);
const previousHeight = usePrevious(mapHeight);
const resized = mapWidth !== previousWidth || mapHeight !== previousHeight;
const skipAnimation = tokenState.lastModifiedBy === userId || resized;
const props = useSpring({
x: tokenX,
y: tokenY,
immediate: skipAnimation,
});
const getAttachedTokens = useCallback(() => {
const transformRoot = transformRootRef.current;
const tokenGroup = transformRoot?.parent;
const layer = transformRoot?.getLayer();
let attachedTokens: Konva.Node[] = [];
if (tokenGroup && layer) {
if (tokenState.category === "vehicle") {
const tokenIntersection = new Intersection(
getScaledOutline(tokenState, tokenWidth, tokenHeight),
{ x: tokenX - tokenWidth / 2, y: tokenY - tokenHeight / 2 },
{ x: tokenX, y: tokenY },
tokenState.rotation
);
// Find all other characters on the map and check whether they're
// intersecting the vehicle
const characters = layer.find(".character");
const attachments = layer.find(".attachment");
const tokens = [...characters, ...attachments];
for (let other of tokens) {
const id = other.id();
if (id in mapState.tokens) {
const position = {
x: mapState.tokens[id].x * mapWidth,
y: mapState.tokens[id].y * mapHeight,
};
if (tokenIntersection.intersects(position)) {
attachedTokens.push(other);
}
}
}
}
if (tokenState.category === "character") {
// Find all attachments and check whether they are close to the center of this token
const attachments = layer.find(".attachment");
for (let attachment of attachments) {
const id = attachment.id();
if (id in mapState.tokens) {
const position = {
x: mapState.tokens[id].x * mapWidth,
y: mapState.tokens[id].y * mapHeight,
};
const distance = Vector2.distance(tokenGroup.position(), position);
if (distance < attachmentThreshold) {
attachedTokens.push(attachment);
}
}
}
}
}
return attachedTokens;
}, [
attachmentThreshold,
tokenHeight,
tokenWidth,
tokenState,
tokenX,
tokenY,
mapState,
mapWidth,
mapHeight,
]);
// Override transform active to always show this transformer when using it
const [overrideTransformActive, setOverrideTransformActive] = useState(false);
function handleTransformStart(
event: Konva.KonvaEventObject<Event>,
attachments: Konva.Node[]
) {
setOverrideTransformActive(true);
onTokenTransformStart(event, attachments);
onTokenMenuClose();
}
function handleTransformEnd(
event: Konva.KonvaEventObject<Event>,
attachments: Konva.Node[]
) {
const transformer = event.currentTarget as Konva.Transformer;
const nodes = [...transformer.nodes(), ...attachments];
const tokenChanges: Record<string, Partial<TokenState>> = {};
for (let node of nodes) {
const id = node.id();
if (id in mapState.tokens) {
const sizeChange = node.scaleX();
const rotation = node.rotation();
const xChange = node.x() / mapWidth;
const yChange = node.y() / mapHeight;
tokenChanges[id] = {
size: mapState.tokens[id].size * sizeChange,
rotation: rotation,
x: mapState.tokens[id].x + xChange,
y: mapState.tokens[id].y + yChange,
};
}
node.scaleX(1);
node.scaleY(1);
node.x(0);
node.y(0);
}
onTokenStateChange(tokenChanges);
if (transformRootRef.current) {
onTokenMenuOpen(tokenState.id, transformRootRef.current, false);
}
setOverrideTransformActive(false);
onTokenTransformEnd(event, attachments);
}
const transformerActive = useMemo(
() => (!tokenState.locked && selected) || overrideTransformActive,
[tokenState, selected, overrideTransformActive]
);
const transformerAttachments = useMemo(() => {
if (transformerActive) {
// Find attached transform roots
return getAttachedTokens().map((node) =>
(node as Konva.Group).findOne(".transform-root")
);
} else {
return [];
}
}, [getAttachedTokens, transformerActive]);
// When a token is hidden if you aren't the map owner hide it completely
if (map && !tokenState.visible && map.owner !== userId) {
return null;
}
// Token name is used by on click to find whether a token is a vehicle or prop
let tokenName = "";
if (tokenState) {
tokenName = tokenState.category;
}
if (tokenState && tokenState.locked) {
tokenName = tokenName + "-locked";
}
return (
<>
<animated.Group
{...props}
width={tokenWidth}
height={tokenHeight}
draggable={draggable}
onMouseDown={handlePointerDown}
onMouseUp={handlePointerUp}
onMouseEnter={handlePointerEnter}
onMouseLeave={handlePointerLeave}
onTouchStart={handlePointerDown}
onTouchEnd={handlePointerUp}
onClick={handleClick}
onTap={handleClick}
onDragEnd={handleDragEnd}
onDragStart={handleDragStart}
onDragMove={handleDragMove}
opacity={tokenState.visible ? tokenOpacity : 0.5}
name={tokenName}
id={tokenState.id}
>
<Group
ref={transformRootRef}
id={tokenState.id}
name="transform-root"
rotation={tokenState.rotation}
offsetX={tokenWidth / 2}
offsetY={tokenHeight / 2}
>
<Group width={tokenWidth} height={tokenHeight} x={0} y={0}>
<TokenOutline
outline={getScaledOutline(tokenState, tokenWidth, tokenHeight)}
hidden={!!tokenImage}
// Disable hit detection for attachments
hitFunc={
tokenState.category === "attachment" ? () => {} : undefined
}
/>
</Group>
<KonvaImage
width={tokenWidth}
height={tokenHeight}
x={0}
y={0}
image={tokenImage}
hitFunc={() => {}}
/>
</Group>
{!transforming ? (
<Group offsetX={tokenWidth / 2} offsetY={tokenHeight / 2}>
{tokenState.statuses?.length > 0 ? (
<TokenStatus
tokenState={tokenState}
width={tokenWidth}
height={tokenHeight}
/>
) : null}
{tokenState.category === "attachment" ? (
<Group offsetX={-tokenWidth / 2} offsetY={-tokenHeight / 2}>
<Group rotation={tokenState.rotation}>
<TokenAttachment
tokenHeight={tokenHeight}
dragging={dragging}
highlight={attachmentOverCharacter}
radius={attachmentThreshold * 2}
/>
</Group>
</Group>
) : null}
{tokenState.label ? (
<TokenLabel
tokenState={tokenState}
width={tokenWidth}
height={tokenHeight}
/>
) : null}
</Group>
) : null}
</animated.Group>
<Transformer
active={transformerActive}
nodes={
transformRootRef.current
? [transformRootRef.current as Konva.Node]
: []
}
attachments={transformerAttachments}
onTransformEnd={handleTransformEnd}
onTransformStart={handleTransformStart}
gridScale={map.grid.measurement.scale}
/>
</>
);
}
export default Token;

View File

@ -0,0 +1,54 @@
import { Circle, Group, Rect } from "react-konva";
type TokenAttachmentProps = {
tokenHeight: number;
dragging: boolean;
highlight: boolean;
radius: number;
};
function TokenAttachment({
tokenHeight,
dragging,
highlight,
radius,
}: TokenAttachmentProps) {
const width = radius / 3;
const height = radius / 8;
return (
<Group>
{/* Make a bigger hidden rect for hit registration */}
<Rect
width={width * 2}
height={height * 2}
x={-width}
y={tokenHeight / 2 - height}
cornerRadius={2.5}
fill="transparent"
/>
<Rect
width={width}
height={height}
x={-width / 2}
y={tokenHeight / 2 - height / 2}
cornerRadius={2.5}
fill="rgba(36, 39, 51, 0.8)"
shadowColor="rgba(0,0,0,0.5)"
shadowOffsetY={radius / 40}
shadowBlur={radius / 15}
hitFunc={() => {}}
/>
{dragging ? (
<Circle
radius={radius}
stroke={
highlight ? "hsl(260, 100%, 80%)" : "rgba(255, 255, 255, 0.85)"
}
strokeWidth={0.5}
/>
) : null}
</Group>
);
}
export default TokenAttachment;

View File

@ -1,13 +1,22 @@
import React, { useRef, useEffect, useState } from "react";
import Konva from "konva";
import { useRef, useEffect, useState } from "react";
import { Rect, Text, Group } from "react-konva";
import useSetting from "../../hooks/useSetting";
import { TokenState } from "../../types/TokenState";
const maxTokenSize = 3;
const defaultFontSize = 16;
const defaultFontSize = 144;
const minFontSize = 16;
function TokenLabel({ tokenState, width, height }) {
const [labelSize] = useSetting("map.labelSize");
type TokenLabelProps = {
tokenState: TokenState;
width: number;
height: number;
};
function TokenLabel({ tokenState, width, height }: TokenLabelProps) {
const [labelSize] = useSetting<number>("map.labelSize");
const paddingY =
(height / 12 / tokenState.size) * Math.min(tokenState.size, maxTokenSize);
@ -22,15 +31,15 @@ function TokenLabel({ tokenState, width, height }) {
return;
}
let fontSizes = [];
for (let size = 20 * labelSize; size >= 6; size--) {
let fontSizes: number[] = [];
for (let size = minFontSize * labelSize; size >= 6; size--) {
const verticalSize = height / size / tokenState.size;
const tokenSize = Math.min(tokenState.size, maxTokenSize);
const fontSize = verticalSize * tokenSize * labelSize;
fontSizes.push(fontSize);
}
function findFontScale() {
const findFontScale = () => {
const size = fontSizes.reduce((prev, curr) => {
text.fontSize(curr);
const textWidth = text.getTextWidth() + paddingX * 2;
@ -42,7 +51,7 @@ function TokenLabel({ tokenState, width, height }) {
}, 1);
setFontScale(size / defaultFontSize);
}
};
findFontScale();
}, [
@ -68,8 +77,8 @@ function TokenLabel({ tokenState, width, height }) {
}
}, [tokenState.label, paddingX, width, fontScale]);
const textRef = useRef();
const textSizerRef = useRef();
const textRef = useRef<Konva.Text>(null);
const textSizerRef = useRef<Konva.Text>(null);
return (
<Group y={height - (defaultFontSize * fontScale + paddingY) / 2}>

View File

@ -0,0 +1,51 @@
import Konva from "konva";
import { Rect, Circle, Line } from "react-konva";
import colors from "../../helpers/colors";
import { Outline } from "../../types/Outline";
type TokenOutlineProps = {
outline: Outline;
hidden: boolean;
} & Konva.ShapeConfig;
function TokenOutline({ outline, hidden, ...props }: TokenOutlineProps) {
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}
{...props}
/>
);
} else if (outline.type === "circle") {
return (
<Circle
radius={outline.radius}
x={outline.x}
y={outline.y}
{...sharedProps}
{...props}
/>
);
} else {
return (
<Line
points={outline.points}
closed
tension={outline.points.length < 200 ? 0 : 0.33}
{...sharedProps}
{...props}
/>
);
}
}
export default TokenOutline;

View File

@ -1,9 +1,15 @@
import React from "react";
import { Circle, Group } from "react-konva";
import colors from "../../helpers/colors";
import { TokenState } from "../../types/TokenState";
function TokenStatus({ tokenState, width, height }) {
type TokenStatusProps = {
tokenState: TokenState;
width: number;
height: number;
};
function TokenStatus({ tokenState, width, height }: TokenStatusProps) {
// Ensure statuses is an array and filter empty values
const statuses = [...new Set((tokenState?.statuses || []).filter((s) => s))];
return (

View File

@ -0,0 +1,543 @@
import Konva from "konva";
import { Transform } from "konva/lib/Util";
import { useEffect, useMemo, useRef, useState } from "react";
import {
useGridCellPixelSize,
useGridSnappingSensitivity,
} from "../../contexts/GridContext";
import { useSetPreventMapInteraction } from "../../contexts/MapInteractionContext";
import { useMapStage } from "../../contexts/MapStageContext";
import { roundTo } from "../../helpers/shared";
import Vector2 from "../../helpers/Vector2";
import { parseGridScale } from "../../helpers/grid";
import scaleDark from "../../images/ScaleDark.png";
import rotateDark from "../../images/RotateDark.png";
import { CustomTransformEventHandler } from "../../types/Events";
type TransformerProps = {
active: boolean;
nodes: Konva.Node[];
attachments: Konva.Node[];
onTransformStart?: CustomTransformEventHandler;
onTransform?: CustomTransformEventHandler;
onTransformEnd?: CustomTransformEventHandler;
gridScale: string;
portalSelector: string;
};
export class CustomTransformer extends Konva.Transformer {
attachments: Konva.Node[] = [];
// Override fitNodesInto applying transform to attachments as well
_fitNodesInto(newAttrs: any, evt?: any) {
var oldAttrs = this._getNodeRect();
const minSize = 1;
if (
Konva.Util._inRange(
newAttrs.width,
-this.padding() * 2 - minSize,
minSize
)
) {
this.update();
return;
}
if (
Konva.Util._inRange(
newAttrs.height,
-this.padding() * 2 - minSize,
minSize
)
) {
this.update();
return;
}
const allowNegativeScale = this.flipEnabled();
var t = new Transform();
t.rotate(this.rotation());
if (
this._movingAnchorName &&
newAttrs.width < 0 &&
this._movingAnchorName.indexOf("left") >= 0
) {
const offset = t.point({
x: -this.padding() * 2,
y: 0,
});
newAttrs.x += offset.x;
newAttrs.y += offset.y;
newAttrs.width += this.padding() * 2;
this._movingAnchorName = this._movingAnchorName.replace("left", "right");
this._anchorDragOffset.x -= offset.x;
this._anchorDragOffset.y -= offset.y;
if (!allowNegativeScale) {
this.update();
return;
}
} else if (
this._movingAnchorName &&
newAttrs.width < 0 &&
this._movingAnchorName.indexOf("right") >= 0
) {
const offset = t.point({
x: this.padding() * 2,
y: 0,
});
this._movingAnchorName = this._movingAnchorName.replace("right", "left");
this._anchorDragOffset.x -= offset.x;
this._anchorDragOffset.y -= offset.y;
newAttrs.width += this.padding() * 2;
if (!allowNegativeScale) {
this.update();
return;
}
}
if (
this._movingAnchorName &&
newAttrs.height < 0 &&
this._movingAnchorName.indexOf("top") >= 0
) {
const offset = t.point({
x: 0,
y: -this.padding() * 2,
});
newAttrs.x += offset.x;
newAttrs.y += offset.y;
this._movingAnchorName = this._movingAnchorName.replace("top", "bottom");
this._anchorDragOffset.x -= offset.x;
this._anchorDragOffset.y -= offset.y;
newAttrs.height += this.padding() * 2;
if (!allowNegativeScale) {
this.update();
return;
}
} else if (
this._movingAnchorName &&
newAttrs.height < 0 &&
this._movingAnchorName.indexOf("bottom") >= 0
) {
const offset = t.point({
x: 0,
y: this.padding() * 2,
});
this._movingAnchorName = this._movingAnchorName.replace("bottom", "top");
this._anchorDragOffset.x -= offset.x;
this._anchorDragOffset.y -= offset.y;
newAttrs.height += this.padding() * 2;
if (!allowNegativeScale) {
this.update();
return;
}
}
if (this.boundBoxFunc()) {
const bounded = this.boundBoxFunc()(oldAttrs, newAttrs);
if (bounded) {
newAttrs = bounded;
} else {
Konva.Util.warn(
"boundBoxFunc returned falsy. You should return new bound rect from it!"
);
}
}
// base size value doesn't really matter
// we just need to think about bounding boxes as transforms
// but how?
// the idea is that we have a transformed rectangle with the size of "baseSize"
const baseSize = 10000000;
const oldTr = new Transform();
oldTr.translate(oldAttrs.x, oldAttrs.y);
oldTr.rotate(oldAttrs.rotation);
oldTr.scale(oldAttrs.width / baseSize, oldAttrs.height / baseSize);
const newTr = new Transform();
newTr.translate(newAttrs.x, newAttrs.y);
newTr.rotate(newAttrs.rotation);
newTr.scale(newAttrs.width / baseSize, newAttrs.height / baseSize);
// now lets think we had [old transform] and n ow we have [new transform]
// Now, the questions is: how can we transform "parent" to go from [old transform] into [new transform]
// in equation it will be:
// [delta transform] * [old transform] = [new transform]
// that means that
// [delta transform] = [new transform] * [old transform inverted]
const delta = newTr.multiply(oldTr.invert());
[...this._nodes, ...this.attachments].forEach((node) => {
// for each node we have the same [delta transform]
// the equations is
// [delta transform] * [parent transform] * [old local transform] = [parent transform] * [new local transform]
// and we need to find [new local transform]
// [new local] = [parent inverted] * [delta] * [parent] * [old local]
const parentTransform = node.getParent().getAbsoluteTransform();
const localTransform = node.getTransform().copy();
// skip offset:
localTransform.translate(node.offsetX(), node.offsetY());
const newLocalTransform = new Transform();
newLocalTransform
.multiply(parentTransform.copy().invert())
.multiply(delta)
.multiply(parentTransform)
.multiply(localTransform);
const attrs = newLocalTransform.decompose();
node.setAttrs(attrs);
this._fire("transform", { evt: evt, target: node });
node._fire("transform", { evt: evt, target: node });
node.getLayer()?.batchDraw();
});
this.rotation(Konva.Util._getRotation(newAttrs.rotation));
this._resetTransformCache();
this.update();
this.getLayer()?.batchDraw();
}
}
function Transformer({
active,
nodes,
attachments,
onTransformStart,
onTransform,
onTransformEnd,
gridScale,
portalSelector,
}: TransformerProps) {
const setPreventMapInteraction = useSetPreventMapInteraction();
const gridCellPixelSize = useGridCellPixelSize();
const gridCellAbsoluteSizeRef = useRef({
x: 0,
y: 0,
});
const scale = parseGridScale(gridScale);
const snappingSensitivity = useGridSnappingSensitivity();
// Clamp snapping to 0 to accound for -1 snapping override
const gridSnappingSensitivity = useMemo(
() => Math.max(snappingSensitivity, 0),
[snappingSensitivity]
);
const mapStageRef = useMapStage();
const transformerRef = useRef<CustomTransformer | null>(null);
useEffect(() => {
let transformer = transformerRef.current;
const stage = mapStageRef.current;
if (active && stage && !transformer) {
transformer = new CustomTransformer({
centeredScaling: true,
rotateAnchorOffset: 16,
enabledAnchors: ["middle-left", "middle-right"],
flipEnabled: false,
ignoreStroke: true,
borderStroke: "invisible",
anchorStroke: "invisible",
anchorCornerRadius: 24,
borderStrokeWidth: 0,
anchorSize: 48,
});
const portal = stage.findOne<Konva.Group>(portalSelector);
if (portal) {
portal.add(transformer);
transformerRef.current = transformer;
}
}
return () => {
if (stage && transformer) {
transformer.destroy();
transformerRef.current = null;
}
};
}, [mapStageRef, portalSelector, active]);
useEffect(() => {
transformerRef.current?.boundBoxFunc((oldBox, newBox) => {
let snapBox = { ...newBox };
const movingAnchor = movingAnchorRef.current;
if (movingAnchor === "middle-left" || movingAnchor === "middle-right") {
// Account for grid snapping
const nearestCellWidth = roundTo(
snapBox.width,
gridCellAbsoluteSizeRef.current.x
);
const distanceToSnap = Math.abs(snapBox.width - nearestCellWidth);
let snapping = false;
if (
distanceToSnap <
gridCellAbsoluteSizeRef.current.x * gridSnappingSensitivity
) {
snapBox.width = nearestCellWidth;
snapping = true;
}
const deltaWidth = snapBox.width - oldBox.width;
// Account for node ratio
const inverseRatio =
Math.round(oldBox.height) / Math.round(oldBox.width);
const deltaHeight = inverseRatio * deltaWidth;
// Account for node rotation
// Create a transform to unrotate the x,y position of the Box
const rotator = new Transform();
rotator.rotate(-snapBox.rotation);
// Unrotate and add the resize amount
let rotatedMin = rotator.point({ x: snapBox.x, y: snapBox.y });
rotatedMin.y = rotatedMin.y - deltaHeight / 2;
// Snap x position if needed
if (snapping) {
const snapDelta = newBox.width - nearestCellWidth;
rotatedMin.x = rotatedMin.x + snapDelta / 2;
}
// Rotated back
rotator.invert();
rotatedMin = rotator.point(rotatedMin);
snapBox = {
...snapBox,
height: snapBox.height + deltaHeight,
x: rotatedMin.x,
y: rotatedMin.y,
};
}
if (snapBox.width < 5 || snapBox.height < 5) {
return oldBox;
}
return snapBox;
});
});
useEffect(() => {
transformerRef.current?.rotationSnaps(
snappingSensitivity === -1
? [] // Disabled rotation snapping if grid snapping disabled with shortcut
: [...Array(24).keys()].map((n) => n * 15)
);
});
const movingAnchorRef = useRef<string>();
const transformTextRef = useRef<Konva.Group>();
useEffect(() => {
function updateGridCellAbsoluteSize() {
if (active) {
const transformer = transformerRef.current;
const stage = transformer?.getStage();
const mapImage = stage?.findOne("#mapImage");
if (!mapImage) {
return;
}
// Use min side for hex grids
const minSize = Vector2.componentMin(gridCellPixelSize);
const size = new Vector2(minSize, minSize);
// Get grid cell size in screen coordinates
const mapTransform = mapImage.getAbsoluteTransform();
const absoluteSize = Vector2.subtract(
mapTransform.point(size),
mapTransform.point({ x: 0, y: 0 })
);
gridCellAbsoluteSizeRef.current = absoluteSize;
}
}
function handleTransformStart(e: Konva.KonvaEventObject<Event>) {
const transformer = transformerRef.current;
if (transformer) {
movingAnchorRef.current = transformer._movingAnchorName;
setPreventMapInteraction(true);
const transformText = new Konva.Label();
const stageScale = transformer.getStage()?.scale() || { x: 1, y: 1 };
transformText.scale(Vector2.divide({ x: 1, y: 1 }, stageScale));
const tag = new Konva.Tag();
tag.fill("hsla(230, 25%, 15%, 0.8)");
tag.cornerRadius(4);
// @ts-ignore
tag.pointerDirection("down");
tag.pointerHeight(4);
tag.pointerWidth(4);
const text = new Konva.Text();
text.fontSize(16);
text.padding(4);
text.fill("white");
transformText.add(tag);
transformText.add(text);
transformer.getLayer()?.add(transformText);
transformTextRef.current = transformText;
updateGridCellAbsoluteSize();
updateTransformText();
onTransformStart && onTransformStart(e, attachments);
}
}
function updateTransformText() {
const movingAnchor = movingAnchorRef.current;
const transformText = transformTextRef.current;
const transformer = transformerRef.current;
const node = transformer?.nodes()[0];
if (node && transformText && transformer) {
const text = transformText.getChildren()[1] as Konva.Text;
if (movingAnchor === "rotater") {
text.text(`${node.rotation().toFixed(0)}°`);
} else {
const nodeRect = node.getClientRect({ skipShadow: true });
const nodeScale = Vector2.divide(
{ x: nodeRect.width, y: nodeRect.height },
gridCellAbsoluteSizeRef.current
);
text.text(
`${(nodeScale.x * scale.multiplier).toFixed(scale.digits)}${
scale.unit
}`
);
}
const nodePosition = node.getStage()?.getPointerPosition();
if (nodePosition) {
transformText.absolutePosition({
x: nodePosition.x,
y: nodePosition.y,
});
}
}
}
function handleTransform(e: Konva.KonvaEventObject<Event>) {
updateTransformText();
onTransform?.(e, attachments);
}
function handleTransformEnd(e: Konva.KonvaEventObject<Event>) {
setPreventMapInteraction(false);
transformTextRef.current?.destroy();
transformTextRef.current = undefined;
onTransformEnd && onTransformEnd(e, attachments);
}
transformerRef.current?.on("transformstart", handleTransformStart);
transformerRef.current?.on("transform", handleTransform);
transformerRef.current?.on("transformend", handleTransformEnd);
return () => {
transformerRef.current?.off("transformstart", handleTransformStart);
transformerRef.current?.off("transform", handleTransform);
transformerRef.current?.off("transformend", handleTransformEnd);
};
});
const [anchorScale, anchorScaleStatus] = useAnchorImage(96, scaleDark);
const [anchorRotate, anchorRotateStatus] = useAnchorImage(96, rotateDark);
// Add nodes to transformer and setup
useEffect(() => {
const transformer = transformerRef.current;
if (
active &&
transformer &&
anchorScaleStatus === "loaded" &&
anchorRotateStatus === "loaded"
) {
transformer.setNodes(nodes);
transformer.attachments = attachments;
const middleLeft = transformer.findOne<Konva.Rect>(".middle-left");
const middleRight = transformer.findOne<Konva.Rect>(".middle-right");
const rotater = transformer.findOne<Konva.Rect>(".rotater");
middleLeft.fillPriority("pattern");
middleLeft.fillPatternImage(anchorScale);
middleLeft.strokeEnabled(false);
middleLeft.fillPatternScaleX(-0.5);
middleLeft.fillPatternScaleY(0.5);
middleRight.fillPriority("pattern");
middleRight.fillPatternImage(anchorScale);
middleRight.strokeEnabled(false);
middleRight.fillPatternScaleX(0.5);
middleRight.fillPatternScaleY(0.5);
rotater.fillPriority("pattern");
rotater.fillPatternImage(anchorRotate);
rotater.strokeEnabled(false);
rotater.fillPatternScaleX(0.5);
rotater.fillPatternScaleY(0.5);
transformer.getLayer()?.batchDraw();
}
}, [
active,
nodes,
attachments,
anchorScale,
anchorRotate,
anchorScaleStatus,
anchorRotateStatus,
]);
return null;
}
Transformer.defaultProps = {
portalSelector: "#portal",
attachments: [],
};
type AnchorImageStatus = "loading" | "loaded" | "failed";
function useAnchorImage(
size: number,
source: string
): [HTMLCanvasElement, AnchorImageStatus] {
const [canvas] = useState(document.createElement("canvas"));
const [status, setStatus] = useState<AnchorImageStatus>("loading");
useEffect(() => {
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
const image = new Image();
image.src = source;
image.onload = () => {
const imageRatio = image.width / image.height;
const imageWidth = canvas.height * imageRatio;
ctx?.drawImage(
image,
canvas.width / 2 - imageWidth / 2,
0,
imageWidth,
canvas.height
);
setStatus("loaded");
};
image.onerror = () => {
setStatus("failed");
};
}, [canvas, size, source]);
return [canvas, status];
}
export default Transformer;

View File

@ -1,89 +0,0 @@
import React, { useEffect, useRef, useState } from "react";
import { Box, IconButton } from "theme-ui";
import RemoveTokenIcon from "../../icons/RemoveTokenIcon";
function DragOverlay({ dragging, node, onRemove }) {
const [isRemoveHovered, setIsRemoveHovered] = useState(false);
const removeTokenRef = useRef();
// Detect token hover on remove icon manually to support touch devices
useEffect(() => {
const map = document.querySelector(".map");
const mapRect = map.getBoundingClientRect();
function detectRemoveHover() {
if (!node || !dragging || !removeTokenRef.current) {
return;
}
const stage = node.getStage();
if (!stage) {
return;
}
const pointerPosition = stage.getPointerPosition();
const screenSpacePointerPosition = {
x: pointerPosition.x + mapRect.left,
y: pointerPosition.y + mapRect.top,
};
const removeIconPosition = removeTokenRef.current.getBoundingClientRect();
if (
screenSpacePointerPosition.x > removeIconPosition.left &&
screenSpacePointerPosition.y > removeIconPosition.top &&
screenSpacePointerPosition.x < removeIconPosition.right &&
screenSpacePointerPosition.y < removeIconPosition.bottom
) {
if (!isRemoveHovered) {
setIsRemoveHovered(true);
}
} else if (isRemoveHovered) {
setIsRemoveHovered(false);
}
}
let handler;
if (node && dragging) {
handler = setInterval(detectRemoveHover, 100);
}
return () => {
if (handler) {
clearInterval(handler);
}
};
}, [isRemoveHovered, dragging, node]);
// Detect drag end of token image and remove it if it is over the remove icon
useEffect(() => {
if (!dragging && node && isRemoveHovered) {
onRemove();
}
});
return (
dragging && (
<Box
sx={{
position: "absolute",
bottom: "32px",
left: "50%",
borderRadius: "50%",
transform: isRemoveHovered
? "translateX(-50%) scale(2.0)"
: "translateX(-50%) scale(1.5)",
transition: "transform 250ms ease",
color: isRemoveHovered ? "primary" : "text",
pointerEvents: "none",
}}
bg="overlay"
ref={removeTokenRef}
>
<IconButton>
<RemoveTokenIcon />
</IconButton>
</Box>
)
);
}
export default DragOverlay;

View File

@ -0,0 +1,101 @@
import { useEffect, useRef, useState } from "react";
import { Box, IconButton } from "theme-ui";
import RemoveTokenIcon from "../../icons/RemoveTokenIcon";
import { useMapStage } from "../../contexts/MapStageContext";
type DragOverlayProps = {
dragging: boolean;
onRemove: () => void;
};
function DragOverlay({ dragging, onRemove }: DragOverlayProps) {
const [isRemoveHovered, setIsRemoveHovered] = useState(false);
const removeTokenRef = useRef<HTMLDivElement>(null);
const mapStageRef = useMapStage();
// Detect token hover on remove icon manually to support touch devices
useEffect(() => {
function detectRemoveHover() {
const mapStage = mapStageRef.current;
if (!mapStage || !dragging || !removeTokenRef.current) {
return;
}
const map = document.querySelector(".map");
if (!map) {
return;
}
const mapRect = map.getBoundingClientRect();
const pointerPosition = mapStage.getPointerPosition();
if (!pointerPosition) {
return;
}
const screenSpacePointerPosition = {
x: pointerPosition.x + mapRect.left,
y: pointerPosition.y + mapRect.top,
};
const removeIconPosition = removeTokenRef.current.getBoundingClientRect();
if (
screenSpacePointerPosition.x > removeIconPosition.left &&
screenSpacePointerPosition.y > removeIconPosition.top &&
screenSpacePointerPosition.x < removeIconPosition.right &&
screenSpacePointerPosition.y < removeIconPosition.bottom
) {
if (!isRemoveHovered) {
setIsRemoveHovered(true);
}
} else if (isRemoveHovered) {
setIsRemoveHovered(false);
}
}
let handler: NodeJS.Timeout;
if (dragging) {
handler = setInterval(detectRemoveHover, 100);
}
return () => {
if (handler) {
clearInterval(handler);
}
};
}, [isRemoveHovered, dragging, mapStageRef]);
// Detect drag end of token image and remove it if it is over the remove icon
useEffect(() => {
if (!dragging && isRemoveHovered) {
onRemove();
}
});
if (!dragging) {
return null;
}
return (
<Box
sx={{
position: "absolute",
bottom: "32px",
left: "50%",
borderRadius: "50%",
transform: isRemoveHovered
? "translateX(-50%) scale(2.0)"
: "translateX(-50%) scale(1.5)",
transition: "transform 250ms ease",
color: isRemoveHovered ? "primary" : "text",
pointerEvents: "none",
}}
bg="overlay"
ref={removeTokenRef}
>
<IconButton>
<RemoveTokenIcon />
</IconButton>
</Box>
);
}
export default DragOverlay;

View File

@ -1,373 +0,0 @@
import React, { useState } from "react";
import { Box } from "theme-ui";
import { useToasts } from "react-toast-notifications";
import MapControls from "./MapControls";
import MapInteraction from "./MapInteraction";
import MapTokens from "./MapTokens";
import MapDrawing from "./MapDrawing";
import MapFog from "./MapFog";
import MapGrid from "./MapGrid";
import MapMeasure from "./MapMeasure";
import NetworkedMapPointer from "../../network/NetworkedMapPointer";
import MapNotes from "./MapNotes";
import { useTokenData } from "../../contexts/TokenDataContext";
import { useSettings } from "../../contexts/SettingsContext";
import TokenMenu from "../token/TokenMenu";
import TokenDragOverlay from "../token/TokenDragOverlay";
import NoteMenu from "../note/NoteMenu";
import NoteDragOverlay from "../note/NoteDragOverlay";
import {
AddShapeAction,
CutShapeAction,
EditShapeAction,
RemoveShapeAction,
} from "../../actions";
function Map({
map,
mapState,
mapActions,
onMapTokenStateChange,
onMapTokenStateRemove,
onMapChange,
onMapReset,
onMapDraw,
onMapDrawUndo,
onMapDrawRedo,
onFogDraw,
onFogDrawUndo,
onFogDrawRedo,
onMapNoteChange,
onMapNoteRemove,
allowMapDrawing,
allowFogDrawing,
allowMapChange,
allowNoteEditing,
disabledTokens,
session,
}) {
const { addToast } = useToasts();
const { tokensById } = useTokenData();
const [selectedToolId, setSelectedToolId] = useState("move");
const { settings, setSettings } = useSettings();
function handleToolSettingChange(tool, change) {
setSettings((prevSettings) => ({
...prevSettings,
[tool]: {
...prevSettings[tool],
...change,
},
}));
}
const drawShapes = Object.values(mapState?.drawShapes || {});
const fogShapes = Object.values(mapState?.fogShapes || {});
function handleToolAction(action) {
if (action === "eraseAll") {
onMapDraw(new RemoveShapeAction(drawShapes.map((s) => s.id)));
}
if (action === "mapUndo") {
onMapDrawUndo();
}
if (action === "mapRedo") {
onMapDrawRedo();
}
if (action === "fogUndo") {
onFogDrawUndo();
}
if (action === "fogRedo") {
onFogDrawRedo();
}
}
function handleMapShapeAdd(shape) {
onMapDraw(new AddShapeAction([shape]));
}
function handleMapShapesRemove(shapeIds) {
onMapDraw(new RemoveShapeAction(shapeIds));
}
function handleFogShapesAdd(shapes) {
onFogDraw(new AddShapeAction(shapes));
}
function handleFogShapesCut(shapes) {
onFogDraw(new CutShapeAction(shapes));
}
function handleFogShapesRemove(shapeIds) {
onFogDraw(new RemoveShapeAction(shapeIds));
}
function handleFogShapesEdit(shapes) {
onFogDraw(new EditShapeAction(shapes));
}
const disabledControls = [];
if (!allowMapDrawing) {
disabledControls.push("drawing");
}
if (!map) {
disabledControls.push("move");
disabledControls.push("measure");
disabledControls.push("pointer");
}
if (!allowFogDrawing) {
disabledControls.push("fog");
}
if (!allowMapChange) {
disabledControls.push("map");
}
if (!allowNoteEditing) {
disabledControls.push("note");
}
const disabledSettings = { fog: [], drawing: [] };
if (drawShapes.length === 0) {
disabledSettings.drawing.push("erase");
}
if (!mapState || mapActions.mapDrawActionIndex < 0) {
disabledSettings.drawing.push("undo");
}
if (
!mapState ||
mapActions.mapDrawActionIndex === mapActions.mapDrawActions.length - 1
) {
disabledSettings.drawing.push("redo");
}
if (!mapState || mapActions.fogDrawActionIndex < 0) {
disabledSettings.fog.push("undo");
}
if (
!mapState ||
mapActions.fogDrawActionIndex === mapActions.fogDrawActions.length - 1
) {
disabledSettings.fog.push("redo");
}
const mapControls = (
<MapControls
onMapChange={onMapChange}
onMapReset={onMapReset}
currentMap={map}
currentMapState={mapState}
onSelectedToolChange={setSelectedToolId}
selectedToolId={selectedToolId}
toolSettings={settings}
onToolSettingChange={handleToolSettingChange}
onToolAction={handleToolAction}
disabledControls={disabledControls}
disabledSettings={disabledSettings}
/>
);
const [isTokenMenuOpen, setIsTokenMenuOpen] = useState(false);
const [tokenMenuOptions, setTokenMenuOptions] = useState({});
const [tokenDraggingOptions, setTokenDraggingOptions] = useState();
function handleTokenMenuOpen(tokenStateId, tokenImage) {
setTokenMenuOptions({ tokenStateId, tokenImage });
setIsTokenMenuOpen(true);
}
const mapTokens = map && mapState && (
<MapTokens
map={map}
mapState={mapState}
tokenDraggingOptions={tokenDraggingOptions}
setTokenDraggingOptions={setTokenDraggingOptions}
onMapTokenStateChange={onMapTokenStateChange}
handleTokenMenuOpen={handleTokenMenuOpen}
selectedToolId={selectedToolId}
disabledTokens={disabledTokens}
/>
);
const tokenMenu = (
<TokenMenu
isOpen={isTokenMenuOpen}
onRequestClose={() => setIsTokenMenuOpen(false)}
onTokenStateChange={onMapTokenStateChange}
tokenState={mapState && mapState.tokens[tokenMenuOptions.tokenStateId]}
tokenImage={tokenMenuOptions.tokenImage}
map={map}
/>
);
const tokenDragOverlay = tokenDraggingOptions && (
<TokenDragOverlay
onTokenStateRemove={(state) => {
onMapTokenStateRemove(state);
setTokenDraggingOptions(null);
}}
onTokenStateChange={onMapTokenStateChange}
tokenState={tokenDraggingOptions && tokenDraggingOptions.tokenState}
tokenGroup={tokenDraggingOptions && tokenDraggingOptions.tokenGroup}
dragging={!!(tokenDraggingOptions && tokenDraggingOptions.dragging)}
token={tokensById[tokenDraggingOptions.tokenState.tokenId]}
/>
);
const mapDrawing = (
<MapDrawing
map={map}
shapes={drawShapes}
onShapeAdd={handleMapShapeAdd}
onShapesRemove={handleMapShapesRemove}
active={selectedToolId === "drawing"}
toolSettings={settings.drawing}
/>
);
const mapFog = (
<MapFog
map={map}
shapes={fogShapes}
onShapesAdd={handleFogShapesAdd}
onShapesCut={handleFogShapesCut}
onShapesRemove={handleFogShapesRemove}
onShapesEdit={handleFogShapesEdit}
onShapeError={addToast}
active={selectedToolId === "fog"}
toolSettings={settings.fog}
editable={allowFogDrawing && !settings.fog.preview}
/>
);
const mapGrid = map && map.showGrid && <MapGrid map={map} />;
const mapMeasure = (
<MapMeasure
map={map}
active={selectedToolId === "measure"}
selectedToolSettings={settings[selectedToolId]}
/>
);
const mapPointer = (
<NetworkedMapPointer
active={selectedToolId === "pointer"}
session={session}
/>
);
const [isNoteMenuOpen, setIsNoteMenuOpen] = useState(false);
const [noteMenuOptions, setNoteMenuOptions] = useState({});
const [noteDraggingOptions, setNoteDraggingOptions] = useState();
function handleNoteMenuOpen(noteId, noteNode) {
setNoteMenuOptions({ noteId, noteNode });
setIsNoteMenuOpen(true);
}
function sortNotes(a, b, noteDraggingOptions) {
if (
noteDraggingOptions &&
noteDraggingOptions.dragging &&
noteDraggingOptions.noteId === a.id
) {
// If dragging token `a` move above
return 1;
} else if (
noteDraggingOptions &&
noteDraggingOptions.dragging &&
noteDraggingOptions.noteId === b.id
) {
// If dragging token `b` move above
return -1;
} else {
// Else sort so last modified is on top
return a.lastModified - b.lastModified;
}
}
const mapNotes = (
<MapNotes
map={map}
active={selectedToolId === "note"}
selectedToolSettings={settings[selectedToolId]}
onNoteAdd={onMapNoteChange}
onNoteChange={onMapNoteChange}
notes={
mapState
? Object.values(mapState.notes).sort((a, b) =>
sortNotes(a, b, noteDraggingOptions)
)
: []
}
onNoteMenuOpen={handleNoteMenuOpen}
draggable={
allowNoteEditing &&
(selectedToolId === "note" || selectedToolId === "move")
}
onNoteDragStart={(e, noteId) =>
setNoteDraggingOptions({ dragging: true, noteId, noteGroup: e.target })
}
onNoteDragEnd={() =>
setNoteDraggingOptions({ ...noteDraggingOptions, dragging: false })
}
fadeOnHover={selectedToolId === "drawing"}
/>
);
const noteMenu = (
<NoteMenu
isOpen={isNoteMenuOpen}
onRequestClose={() => setIsNoteMenuOpen(false)}
onNoteChange={onMapNoteChange}
note={mapState && mapState.notes[noteMenuOptions.noteId]}
noteNode={noteMenuOptions.noteNode}
map={map}
/>
);
const noteDragOverlay = (
<NoteDragOverlay
dragging={!!(noteDraggingOptions && noteDraggingOptions.dragging)}
noteGroup={noteDraggingOptions && noteDraggingOptions.noteGroup}
noteId={noteDraggingOptions && noteDraggingOptions.noteId}
onNoteRemove={(noteId) => {
onMapNoteRemove(noteId);
setNoteDraggingOptions(null);
}}
/>
);
return (
<Box sx={{ flexGrow: 1 }}>
<MapInteraction
map={map}
mapState={mapState}
controls={
<>
{mapControls}
{tokenMenu}
{noteMenu}
{tokenDragOverlay}
{noteDragOverlay}
</>
}
selectedToolId={selectedToolId}
onSelectedToolChange={setSelectedToolId}
disabledControls={disabledControls}
>
{mapGrid}
{mapDrawing}
{mapNotes}
{mapTokens}
{mapFog}
{mapPointer}
{mapMeasure}
</MapInteraction>
</Box>
);
}
export default Map;

243
src/components/map/Map.tsx Normal file
View File

@ -0,0 +1,243 @@
import { useState } from "react";
import { Box } from "theme-ui";
import { useToasts } from "react-toast-notifications";
import MapControls from "./MapControls";
import MapInteraction from "./MapInteraction";
import MapGrid from "./MapGrid";
import DrawingTool from "../tools/DrawingTool";
import FogTool from "../tools/FogTool";
import MeasureTool from "../tools/MeasureTool";
import NetworkedMapPointer from "../../network/NetworkedMapPointer";
import { useSettings } from "../../contexts/SettingsContext";
import { useUserId } from "../../contexts/UserIdContext";
import Action from "../../actions/Action";
import {
AddStatesAction,
CutFogAction,
EditStatesAction,
RemoveStatesAction,
} from "../../actions";
import Session from "../../network/Session";
import { Drawing, DrawingState } from "../../types/Drawing";
import { Fog, FogState } from "../../types/Fog";
import { Map as MapType, MapToolId } from "../../types/Map";
import { MapState } from "../../types/MapState";
import { Settings } from "../../types/Settings";
import {
MapChangeEventHandler,
MapResetEventHandler,
TokenStateRemoveHandler,
NoteChangeEventHandler,
NoteRemoveEventHander,
TokenStateChangeEventHandler,
NoteCreateEventHander,
SelectionItemsChangeEventHandler,
SelectionItemsRemoveEventHandler,
SelectionItemsCreateEventHandler,
TokensStateCreateHandler,
} from "../../types/Events";
import useMapTokens from "../../hooks/useMapTokens";
import useMapNotes from "../../hooks/useMapNotes";
import { MapActions } from "../../hooks/useMapActions";
import useMapSelection from "../../hooks/useMapSelection";
type MapProps = {
map: MapType | null;
mapState: MapState | null;
mapActions: MapActions;
onMapTokenStateChange: TokenStateChangeEventHandler;
onMapTokenStateRemove: TokenStateRemoveHandler;
onMapTokensStateCreate: TokensStateCreateHandler;
onSelectionItemsChange: SelectionItemsChangeEventHandler;
onSelectionItemsRemove: SelectionItemsRemoveEventHandler;
onSelectionItemsCreate: SelectionItemsCreateEventHandler;
onMapChange: MapChangeEventHandler;
onMapReset: MapResetEventHandler;
onMapDraw: (action: Action<DrawingState>) => void;
onFogDraw: (action: Action<FogState>) => void;
onMapNoteCreate: NoteCreateEventHander;
onMapNoteChange: NoteChangeEventHandler;
onMapNoteRemove: NoteRemoveEventHander;
allowMapChange: boolean;
session: Session;
onUndo: () => void;
onRedo: () => void;
};
function Map({
map,
mapState,
mapActions,
onMapTokenStateChange,
onMapTokenStateRemove,
onMapTokensStateCreate,
onSelectionItemsChange,
onSelectionItemsRemove,
onSelectionItemsCreate,
onMapChange,
onMapReset,
onMapDraw,
onFogDraw,
onMapNoteCreate,
onMapNoteChange,
onMapNoteRemove,
allowMapChange,
session,
onUndo,
onRedo,
}: MapProps) {
const { addToast } = useToasts();
const userId = useUserId();
const [selectedToolId, setSelectedToolId] = useState<MapToolId>("move");
const { settings, setSettings } = useSettings();
function handleToolSettingChange(change: Partial<Settings>) {
setSettings((prevSettings) => ({
...prevSettings,
...change,
}));
}
const drawShapes = Object.values(mapState?.drawings || {});
const fogShapes = Object.values(mapState?.fogs || {});
function handleToolAction(action: string) {
if (action === "eraseAll") {
onMapDraw(new RemoveStatesAction(drawShapes.map((s) => s.id)));
}
}
function handleMapShapeAdd(shape: Drawing) {
onMapDraw(new AddStatesAction([shape]));
}
function handleMapShapesRemove(shapeIds: string[]) {
onMapDraw(new RemoveStatesAction(shapeIds));
}
function handleFogShapesAdd(shapes: Fog[]) {
onFogDraw(new AddStatesAction(shapes));
}
function handleFogShapesCut(shapes: Fog[]) {
onFogDraw(new CutFogAction(shapes));
}
function handleFogShapesRemove(shapeIds: string[]) {
onFogDraw(new RemoveStatesAction(shapeIds));
}
function handleFogShapesEdit(shapes: Partial<Fog>[]) {
onFogDraw(new EditStatesAction(shapes));
}
const { tokens, propTokens, tokenMenu, tokenDragOverlay } = useMapTokens(
map,
mapState,
onMapTokenStateChange,
onMapTokenStateRemove,
onMapTokensStateCreate,
selectedToolId
);
const { notes, noteMenu, noteDragOverlay } = useMapNotes(
map,
mapState,
onMapNoteCreate,
onMapNoteChange,
onMapNoteRemove,
selectedToolId
);
const { selectionTool, selectionMenu, selectionDragOverlay } =
useMapSelection(
map,
mapState,
onSelectionItemsChange,
onSelectionItemsRemove,
onSelectionItemsCreate,
selectedToolId,
settings.select
);
return (
<Box sx={{ flexGrow: 1 }}>
<MapInteraction
map={map}
mapState={mapState}
controls={
<>
<MapControls
onMapChange={onMapChange}
onMapReset={onMapReset}
map={map}
mapState={mapState}
mapActions={mapActions}
allowMapChange={allowMapChange}
onSelectedToolChange={setSelectedToolId}
selectedToolId={selectedToolId}
toolSettings={settings}
onToolSettingChange={handleToolSettingChange}
onToolAction={handleToolAction}
onUndo={onUndo}
onRedo={onRedo}
/>
{tokenMenu}
{noteMenu}
{selectionMenu}
{tokenDragOverlay}
{noteDragOverlay}
{selectionDragOverlay}
</>
}
selectedToolId={selectedToolId}
onSelectedToolChange={setSelectedToolId}
>
{map && map.showGrid && <MapGrid map={map} />}
{propTokens}
<DrawingTool
map={map}
drawings={drawShapes}
onDrawingAdd={handleMapShapeAdd}
onDrawingsRemove={handleMapShapesRemove}
active={selectedToolId === "drawing"}
toolSettings={settings.drawing}
/>
{notes}
{tokens}
<FogTool
map={map}
shapes={fogShapes}
onShapesAdd={handleFogShapesAdd}
onShapesCut={handleFogShapesCut}
onShapesRemove={handleFogShapesRemove}
onShapesEdit={handleFogShapesEdit}
onShapeError={addToast}
active={selectedToolId === "fog"}
toolSettings={settings.fog}
editable={
!!(map?.owner === userId || mapState?.editFlags.includes("fog")) &&
!settings.fog.preview
}
/>
<NetworkedMapPointer
active={selectedToolId === "pointer"}
session={session}
/>
<MeasureTool map={map} active={selectedToolId === "measure"} />
{selectionTool}
</MapInteraction>
</Box>
);
}
export default Map;

View File

@ -1,226 +0,0 @@
import React, { useState, Fragment } from "react";
import { IconButton, Flex, Box } from "theme-ui";
import RadioIconButton from "../RadioIconButton";
import Divider from "../Divider";
import SelectMapButton from "./SelectMapButton";
import FogToolSettings from "./controls/FogToolSettings";
import DrawingToolSettings from "./controls/DrawingToolSettings";
import PointerToolSettings from "./controls/PointerToolSettings";
import MoveToolIcon from "../../icons/MoveToolIcon";
import FogToolIcon from "../../icons/FogToolIcon";
import BrushToolIcon from "../../icons/BrushToolIcon";
import MeasureToolIcon from "../../icons/MeasureToolIcon";
import ExpandMoreIcon from "../../icons/ExpandMoreIcon";
import PointerToolIcon from "../../icons/PointerToolIcon";
import FullScreenIcon from "../../icons/FullScreenIcon";
import FullScreenExitIcon from "../../icons/FullScreenExitIcon";
import NoteToolIcon from "../../icons/NoteToolIcon";
import useSetting from "../../hooks/useSetting";
function MapContols({
onMapChange,
onMapReset,
currentMap,
currentMapState,
selectedToolId,
onSelectedToolChange,
toolSettings,
onToolSettingChange,
onToolAction,
disabledControls,
disabledSettings,
}) {
const [isExpanded, setIsExpanded] = useState(true);
const [fullScreen, setFullScreen] = useSetting("map.fullScreen");
const toolsById = {
move: {
id: "move",
icon: <MoveToolIcon />,
title: "Move Tool (W)",
},
fog: {
id: "fog",
icon: <FogToolIcon />,
title: "Fog Tool (F)",
SettingsComponent: FogToolSettings,
},
drawing: {
id: "drawing",
icon: <BrushToolIcon />,
title: "Drawing Tool (D)",
SettingsComponent: DrawingToolSettings,
},
measure: {
id: "measure",
icon: <MeasureToolIcon />,
title: "Measure Tool (M)",
},
pointer: {
id: "pointer",
icon: <PointerToolIcon />,
title: "Pointer Tool (Q)",
SettingsComponent: PointerToolSettings,
},
note: {
id: "note",
icon: <NoteToolIcon />,
title: "Note Tool (N)",
},
};
const tools = ["move", "fog", "drawing", "measure", "pointer", "note"];
const sections = [
{
id: "map",
component: (
<SelectMapButton
onMapChange={onMapChange}
onMapReset={onMapReset}
currentMap={currentMap}
currentMapState={currentMapState}
disabled={disabledControls.includes("map")}
/>
),
},
{
id: "tools",
component: tools.map((tool) => (
<RadioIconButton
key={tool}
title={toolsById[tool].title}
onClick={() => onSelectedToolChange(tool)}
isSelected={selectedToolId === tool}
disabled={disabledControls.includes(tool)}
>
{toolsById[tool].icon}
</RadioIconButton>
)),
},
];
let controls = null;
if (sections.length === 1 && sections[0].id === "map") {
controls = (
<Box
sx={{
display: "block",
backgroundColor: "overlay",
borderRadius: "4px",
}}
m={2}
>
{sections[0].component}
</Box>
);
} else if (sections.length > 0) {
controls = (
<>
<IconButton
aria-label={isExpanded ? "Hide Map Controls" : "Show Map Controls"}
title={isExpanded ? "Hide Map Controls" : "Show Map Controls"}
onClick={() => setIsExpanded(!isExpanded)}
sx={{
transform: `rotate(${isExpanded ? "0" : "180deg"})`,
display: "block",
backgroundColor: "overlay",
borderRadius: "50%",
}}
m={2}
>
<ExpandMoreIcon />
</IconButton>
<Box
sx={{
flexDirection: "column",
alignItems: "center",
display: isExpanded ? "flex" : "none",
backgroundColor: "overlay",
borderRadius: "4px",
}}
p={2}
>
{sections.map((section, index) => (
<Fragment key={section.id}>
{section.component}
{index !== sections.length - 1 && <Divider />}
</Fragment>
))}
</Box>
</>
);
}
function getToolSettings() {
const Settings = toolsById[selectedToolId].SettingsComponent;
if (Settings) {
return (
<Box
sx={{
position: "absolute",
top: "4px",
left: "50%",
transform: "translateX(-50%)",
backgroundColor: "overlay",
borderRadius: "4px",
}}
p={1}
>
<Settings
settings={toolSettings[selectedToolId]}
onSettingChange={(change) =>
onToolSettingChange(selectedToolId, change)
}
onToolAction={onToolAction}
disabledActions={disabledSettings[selectedToolId]}
/>
</Box>
);
} else {
return null;
}
}
return (
<>
<Flex
sx={{
position: "absolute",
top: 0,
right: 0,
flexDirection: "column",
alignItems: "center",
}}
mx={1}
>
{controls}
</Flex>
{getToolSettings()}
<Box
sx={{
position: "absolute",
right: "4px",
bottom: 0,
backgroundColor: "overlay",
borderRadius: "50%",
}}
m={2}
>
<IconButton
onClick={() => setFullScreen(!fullScreen)}
aria-label={fullScreen ? "Exit Full Screen" : "Enter Full Screen"}
title={fullScreen ? "Exit Full Screen" : "Enter Full Screen"}
>
{fullScreen ? <FullScreenExitIcon /> : <FullScreenIcon />}
</IconButton>
</Box>
</>
);
}
export default MapContols;

View File

@ -0,0 +1,390 @@
import { useState, Fragment, useEffect, useMemo } from "react";
import { IconButton, Flex, Box } from "theme-ui";
import RadioIconButton from "../RadioIconButton";
import Divider from "../Divider";
import SelectMapButton from "./SelectMapButton";
import FogToolSettings from "../controls/FogToolSettings";
import DrawingToolSettings from "../controls/DrawingToolSettings";
import PointerToolSettings from "../controls/PointerToolSettings";
import SelectToolSettings from "../controls/SelectToolSettings";
import MoveToolIcon from "../../icons/MoveToolIcon";
import FogToolIcon from "../../icons/FogToolIcon";
import BrushToolIcon from "../../icons/BrushToolIcon";
import MeasureToolIcon from "../../icons/MeasureToolIcon";
import ExpandMoreIcon from "../../icons/ExpandMoreIcon";
import PointerToolIcon from "../../icons/PointerToolIcon";
import FullScreenIcon from "../../icons/FullScreenIcon";
import FullScreenExitIcon from "../../icons/FullScreenExitIcon";
import NoteToolIcon from "../../icons/NoteToolIcon";
import SelectToolIcon from "../../icons/SelectToolIcon";
import UndoButton from "../controls/shared/UndoButton";
import RedoButton from "../controls/shared/RedoButton";
import useSetting from "../../hooks/useSetting";
import { Map, MapTool, MapToolId } from "../../types/Map";
import { MapState } from "../../types/MapState";
import {
MapChangeEventHandler,
MapResetEventHandler,
} from "../../types/Events";
import { Settings } from "../../types/Settings";
import { useKeyboard } from "../../contexts/KeyboardContext";
import shortcuts from "../../shortcuts";
import { useUserId } from "../../contexts/UserIdContext";
import { isEmpty } from "../../helpers/shared";
import { MapActions } from "../../hooks/useMapActions";
type MapControlsProps = {
onMapChange: MapChangeEventHandler;
onMapReset: MapResetEventHandler;
map: Map | null;
mapState: MapState | null;
mapActions: MapActions;
allowMapChange: boolean;
selectedToolId: MapToolId;
onSelectedToolChange: (toolId: MapToolId) => void;
toolSettings: Settings;
onToolSettingChange: (change: Partial<Settings>) => void;
onToolAction: (actionId: string) => void;
onUndo: () => void;
onRedo: () => void;
};
function MapContols({
onMapChange,
onMapReset,
map,
mapState,
mapActions,
allowMapChange,
selectedToolId,
onSelectedToolChange,
toolSettings,
onToolSettingChange,
onToolAction,
onUndo,
onRedo,
}: MapControlsProps) {
const [isExpanded, setIsExpanded] = useState(true);
const [fullScreen, setFullScreen] = useSetting("map.fullScreen");
const userId = useUserId();
const disabledControls = useMemo(() => {
const isOwner = map && map.owner === userId;
const allowMapDrawing = isOwner || mapState?.editFlags.includes("drawing");
const allowFogDrawing = isOwner || mapState?.editFlags.includes("fog");
const allowNoteEditing = isOwner || mapState?.editFlags.includes("notes");
const disabled: MapToolId[] = [];
if (!allowMapChange) {
disabled.push("map");
}
if (!map) {
disabled.push("move");
disabled.push("measure");
disabled.push("pointer");
disabled.push("select");
}
if (!map || !allowMapDrawing) {
disabled.push("drawing");
}
if (!map || !allowFogDrawing) {
disabled.push("fog");
}
if (!map || !allowNoteEditing) {
disabled.push("note");
}
if (!map || mapActions.actionIndex < 0) {
disabled.push("undo");
}
if (!map || mapActions.actionIndex === mapActions.actions.length - 1) {
disabled.push("redo");
}
return disabled;
}, [map, mapState, mapActions, allowMapChange, userId]);
// Change back to move tool if selected tool becomes disabled
useEffect(() => {
if (disabledControls.includes(selectedToolId)) {
onSelectedToolChange("move");
}
}, [selectedToolId, disabledControls, onSelectedToolChange]);
const disabledSettings = useMemo(() => {
const disabled: Partial<Record<keyof Settings, string[]>> = {
drawing: [],
};
if (mapState && isEmpty(mapState.drawings)) {
disabled.drawing?.push("erase");
}
return disabled;
}, [mapState]);
const toolsById: Record<string, MapTool> = {
move: {
id: "move",
icon: <MoveToolIcon />,
title: "Move Tool (W)",
},
select: {
id: "select",
icon: <SelectToolIcon />,
title: "Select Tool (S)",
SettingsComponent: SelectToolSettings,
},
fog: {
id: "fog",
icon: <FogToolIcon />,
title: "Fog Tool (F)",
SettingsComponent: FogToolSettings,
},
drawing: {
id: "drawing",
icon: <BrushToolIcon />,
title: "Drawing Tool (D)",
SettingsComponent: DrawingToolSettings,
},
measure: {
id: "measure",
icon: <MeasureToolIcon />,
title: "Measure Tool (M)",
},
pointer: {
id: "pointer",
icon: <PointerToolIcon />,
title: "Pointer Tool (Q)",
SettingsComponent: PointerToolSettings,
},
note: {
id: "note",
icon: <NoteToolIcon />,
title: "Note Tool (N)",
},
};
const tools: MapToolId[] = [
"move",
"select",
"fog",
"drawing",
"measure",
"pointer",
"note",
];
const sections = [
{
id: "map",
component: (
<SelectMapButton
onMapChange={onMapChange}
onMapReset={onMapReset}
currentMap={map}
currentMapState={mapState}
disabled={disabledControls.includes("map")}
/>
),
},
{
id: "tools",
component: tools.map((tool) => (
<RadioIconButton
key={tool}
title={toolsById[tool].title}
onClick={() => onSelectedToolChange(tool)}
isSelected={selectedToolId === tool}
disabled={disabledControls.includes(tool)}
>
{toolsById[tool].icon}
</RadioIconButton>
)),
},
{
id: "history",
component: (
<>
<UndoButton
onClick={onUndo}
disabled={disabledControls.includes("undo")}
/>
<RedoButton
onClick={onRedo}
disabled={disabledControls.includes("redo")}
/>
</>
),
},
];
let controls = null;
if (sections.length === 1 && sections[0].id === "map") {
controls = (
<Box
sx={{
display: "block",
backgroundColor: "overlay",
borderRadius: "4px",
}}
m={2}
>
{sections[0].component}
</Box>
);
} else if (sections.length > 0) {
controls = (
<>
<IconButton
aria-label={isExpanded ? "Hide Map Controls" : "Show Map Controls"}
title={isExpanded ? "Hide Map Controls" : "Show Map Controls"}
onClick={() => setIsExpanded(!isExpanded)}
sx={{
transform: `rotate(${isExpanded ? "0" : "180deg"})`,
display: "block",
backgroundColor: "overlay",
borderRadius: "50%",
}}
m={2}
>
<ExpandMoreIcon />
</IconButton>
<Box
sx={{
flexDirection: "column",
alignItems: "center",
display: isExpanded ? "flex" : "none",
backgroundColor: "overlay",
borderRadius: "4px",
}}
p={2}
>
{sections.map((section, index) => (
<Fragment key={section.id}>
{section.component}
{index !== sections.length - 1 && <Divider />}
</Fragment>
))}
</Box>
</>
);
}
function getToolSettings() {
const Settings = toolsById[selectedToolId].SettingsComponent;
if (
!Settings ||
(selectedToolId !== "fog" &&
selectedToolId !== "drawing" &&
selectedToolId !== "pointer" &&
selectedToolId !== "select")
) {
return null;
}
return (
<Box
sx={{
position: "absolute",
top: "4px",
left: "50%",
transform: "translateX(-50%)",
backgroundColor: "overlay",
borderRadius: "4px",
}}
p={1}
>
<Settings
settings={toolSettings[selectedToolId]}
onSettingChange={(
change: Partial<Settings["fog" | "drawing" | "pointer" | "select"]>
) =>
onToolSettingChange({
[selectedToolId]: {
...toolSettings[selectedToolId],
...change,
},
})
}
onToolAction={onToolAction}
disabledActions={disabledSettings[selectedToolId]}
/>
</Box>
);
}
function handleKeyDown(event: KeyboardEvent) {
if (shortcuts.moveTool(event) && !disabledControls.includes("move")) {
onSelectedToolChange("move");
}
if (shortcuts.selectTool(event) && !disabledControls.includes("select")) {
onSelectedToolChange("select");
}
if (shortcuts.drawingTool(event) && !disabledControls.includes("drawing")) {
onSelectedToolChange("drawing");
}
if (shortcuts.fogTool(event) && !disabledControls.includes("fog")) {
onSelectedToolChange("fog");
}
if (shortcuts.measureTool(event) && !disabledControls.includes("measure")) {
onSelectedToolChange("measure");
}
if (shortcuts.pointerTool(event) && !disabledControls.includes("pointer")) {
onSelectedToolChange("pointer");
}
if (shortcuts.noteTool(event) && !disabledControls.includes("note")) {
onSelectedToolChange("note");
}
if (shortcuts.redo(event) && !disabledControls.includes("redo")) {
onRedo();
}
if (shortcuts.undo(event) && !disabledControls.includes("undo")) {
onUndo();
}
}
useKeyboard(handleKeyDown);
return (
<>
<Flex
sx={{
position: "absolute",
top: 0,
right: 0,
flexDirection: "column",
alignItems: "center",
}}
mx={1}
>
{controls}
</Flex>
{getToolSettings()}
<Box
sx={{
position: "absolute",
right: "4px",
bottom: 0,
backgroundColor: "overlay",
borderRadius: "50%",
}}
m={2}
>
<IconButton
onClick={() => setFullScreen(!fullScreen)}
aria-label={fullScreen ? "Exit Full Screen" : "Enter Full Screen"}
title={fullScreen ? "Exit Full Screen" : "Enter Full Screen"}
>
{fullScreen ? <FullScreenExitIcon /> : <FullScreenIcon />}
</IconButton>
</Box>
</>
);
}
export default MapContols;

View File

@ -1,282 +0,0 @@
import React, { useState, useEffect } from "react";
import shortid from "shortid";
import { Group, Line, Rect, Circle } from "react-konva";
import {
useDebouncedStageScale,
useMapWidth,
useMapHeight,
useInteractionEmitter,
} from "../../contexts/MapInteractionContext";
import { useMapStage } from "../../contexts/MapStageContext";
import {
useGridCellNormalizedSize,
useGridStrokeWidth,
} from "../../contexts/GridContext";
import Vector2 from "../../helpers/Vector2";
import {
getDefaultShapeData,
getUpdatedShapeData,
simplifyPoints,
} from "../../helpers/drawing";
import colors from "../../helpers/colors";
import { getRelativePointerPosition } from "../../helpers/konva";
import useGridSnapping from "../../hooks/useGridSnapping";
function MapDrawing({
map,
shapes,
onShapeAdd,
onShapesRemove,
active,
toolSettings,
}) {
const stageScale = useDebouncedStageScale();
const mapWidth = useMapWidth();
const mapHeight = useMapHeight();
const interactionEmitter = useInteractionEmitter();
const gridCellNormalizedSize = useGridCellNormalizedSize();
const gridStrokeWidth = useGridStrokeWidth();
const mapStageRef = useMapStage();
const [drawingShape, setDrawingShape] = useState(null);
const [isBrushDown, setIsBrushDown] = useState(false);
const [erasingShapes, setErasingShapes] = useState([]);
const shouldHover = toolSettings.type === "erase" && active;
const isBrush =
toolSettings.type === "brush" || toolSettings.type === "paint";
const isShape =
toolSettings.type === "line" ||
toolSettings.type === "rectangle" ||
toolSettings.type === "circle" ||
toolSettings.type === "triangle";
const snapPositionToGrid = useGridSnapping();
useEffect(() => {
if (!active) {
return;
}
const mapStage = mapStageRef.current;
function getBrushPosition() {
const mapImage = mapStage.findOne("#mapImage");
let position = getRelativePointerPosition(mapImage);
if (map.snapToGrid && isShape) {
position = snapPositionToGrid(position);
}
return Vector2.divide(position, {
x: mapImage.width(),
y: mapImage.height(),
});
}
function handleBrushDown() {
const brushPosition = getBrushPosition();
const commonShapeData = {
color: toolSettings.color,
blend: toolSettings.useBlending,
id: shortid.generate(),
};
if (isBrush) {
setDrawingShape({
type: "path",
pathType: toolSettings.type === "brush" ? "stroke" : "fill",
data: { points: [brushPosition] },
strokeWidth: toolSettings.type === "brush" ? 1 : 0,
...commonShapeData,
});
} else if (isShape) {
setDrawingShape({
type: "shape",
shapeType: toolSettings.type,
data: getDefaultShapeData(toolSettings.type, brushPosition),
strokeWidth: toolSettings.type === "line" ? 1 : 0,
...commonShapeData,
});
}
setIsBrushDown(true);
}
function handleBrushMove() {
const brushPosition = getBrushPosition();
if (isBrushDown && drawingShape) {
if (isBrush) {
setDrawingShape((prevShape) => {
const prevPoints = prevShape.data.points;
if (
Vector2.compare(
prevPoints[prevPoints.length - 1],
brushPosition,
0.001
)
) {
return prevShape;
}
const simplified = simplifyPoints(
[...prevPoints, brushPosition],
1 / 1000 / stageScale
);
return {
...prevShape,
data: { points: simplified },
};
});
} else if (isShape) {
setDrawingShape((prevShape) => ({
...prevShape,
data: getUpdatedShapeData(
prevShape.shapeType,
prevShape.data,
brushPosition,
gridCellNormalizedSize,
mapWidth,
mapHeight
),
}));
}
}
}
function handleBrushUp() {
if (isBrush && drawingShape) {
if (drawingShape.data.points.length > 1) {
onShapeAdd(drawingShape);
}
} else if (isShape && drawingShape) {
onShapeAdd(drawingShape);
}
eraseHoveredShapes();
setDrawingShape(null);
setIsBrushDown(false);
}
interactionEmitter.on("dragStart", handleBrushDown);
interactionEmitter.on("drag", handleBrushMove);
interactionEmitter.on("dragEnd", handleBrushUp);
return () => {
interactionEmitter.off("dragStart", handleBrushDown);
interactionEmitter.off("drag", handleBrushMove);
interactionEmitter.off("dragEnd", handleBrushUp);
};
});
function handleShapeOver(shape, isDown) {
if (shouldHover && isDown) {
if (erasingShapes.findIndex((s) => s.id === shape.id) === -1) {
setErasingShapes((prevShapes) => [...prevShapes, shape]);
}
}
}
function eraseHoveredShapes() {
if (erasingShapes.length > 0) {
onShapesRemove(erasingShapes.map((shape) => shape.id));
setErasingShapes([]);
}
}
function renderShape(shape) {
const defaultProps = {
key: shape.id,
onMouseMove: () => handleShapeOver(shape, isBrushDown),
onTouchOver: () => handleShapeOver(shape, isBrushDown),
onMouseDown: () => handleShapeOver(shape, true),
onTouchStart: () => handleShapeOver(shape, true),
onMouseUp: eraseHoveredShapes,
onTouchEnd: eraseHoveredShapes,
fill: colors[shape.color] || shape.color,
opacity: shape.blend ? 0.5 : 1,
id: shape.id,
};
if (shape.type === "path") {
return (
<Line
points={shape.data.points.reduce(
(acc, point) => [...acc, point.x * mapWidth, point.y * mapHeight],
[]
)}
stroke={colors[shape.color] || shape.color}
tension={0.5}
closed={shape.pathType === "fill"}
fillEnabled={shape.pathType === "fill"}
lineCap="round"
lineJoin="round"
strokeWidth={gridStrokeWidth * shape.strokeWidth}
{...defaultProps}
/>
);
} else if (shape.type === "shape") {
if (shape.shapeType === "rectangle") {
return (
<Rect
x={shape.data.x * mapWidth}
y={shape.data.y * mapHeight}
width={shape.data.width * mapWidth}
height={shape.data.height * mapHeight}
{...defaultProps}
/>
);
} else if (shape.shapeType === "circle") {
const minSide = mapWidth < mapHeight ? mapWidth : mapHeight;
return (
<Circle
x={shape.data.x * mapWidth}
y={shape.data.y * mapHeight}
radius={shape.data.radius * minSide}
{...defaultProps}
/>
);
} else if (shape.shapeType === "triangle") {
return (
<Line
points={shape.data.points.reduce(
(acc, point) => [...acc, point.x * mapWidth, point.y * mapHeight],
[]
)}
closed={true}
{...defaultProps}
/>
);
} else if (shape.shapeType === "line") {
return (
<Line
points={shape.data.points.reduce(
(acc, point) => [...acc, point.x * mapWidth, point.y * mapHeight],
[]
)}
strokeWidth={gridStrokeWidth * shape.strokeWidth}
stroke={colors[shape.color] || shape.color}
lineCap="round"
{...defaultProps}
/>
);
}
}
}
function renderErasingShape(shape) {
const eraseShape = {
...shape,
color: "#BB99FF",
};
return renderShape(eraseShape);
}
return (
<Group>
{shapes.map(renderShape)}
{drawingShape && renderShape(drawingShape)}
{erasingShapes.length > 0 && erasingShapes.map(renderErasingShape)}
</Group>
);
}
export default MapDrawing;

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react";
import { useState, useEffect } from "react";
import { Flex, Close, IconButton } from "theme-ui";
import { groupsFromIds, itemsFromGroups } from "../../helpers/group";
@ -13,13 +13,32 @@ import { useMapData } from "../../contexts/MapDataContext";
import { useKeyboard } from "../../contexts/KeyboardContext";
import shortcuts from "../../shortcuts";
import { Map } from "../../types/Map";
import {
MapChangeEventHandler,
MapResetEventHandler,
} from "../../types/Events";
function MapEditBar({ currentMap, disabled, onMapChange, onMapReset, onLoad }) {
type MapEditBarProps = {
currentMap: Map | null;
disabled: boolean;
onMapChange: MapChangeEventHandler;
onMapReset: MapResetEventHandler;
onLoad: (loading: boolean) => void;
};
function MapEditBar({
currentMap,
disabled,
onMapChange,
onMapReset,
onLoad,
}: MapEditBarProps) {
const [hasMapState, setHasMapState] = useState(false);
const { maps, mapStates, removeMaps, resetMap } = useMapData();
const { activeGroups, selectedGroupIds, onGroupSelect } = useGroup();
const { activeGroups, selectedGroupIds, onClearSelection } = useGroup();
useEffect(() => {
const selectedGroups = groupsFromIds(selectedGroupIds, activeGroups);
@ -33,8 +52,8 @@ function MapEditBar({ currentMap, disabled, onMapChange, onMapReset, onLoad }) {
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.drawings).length > 0 ||
Object.values(state.fogs).length > 0 ||
Object.values(state.notes).length > 0
) {
_hasMapState = true;
@ -56,7 +75,7 @@ function MapEditBar({ currentMap, disabled, onMapChange, onMapReset, onLoad }) {
setIsMapsRemoveModalOpen(false);
const selectedMaps = getSelectedMaps();
const selectedMapIds = selectedMaps.map((map) => map.id);
onGroupSelect();
onClearSelection();
await removeMaps(selectedMapIds);
// Removed the map from the map screen if needed
if (currentMap && selectedMapIds.includes(currentMap.id)) {
@ -84,7 +103,7 @@ function MapEditBar({ currentMap, disabled, onMapChange, onMapReset, onLoad }) {
/**
* Shortcuts
*/
function handleKeyDown(event) {
function handleKeyDown(event: KeyboardEvent) {
if (disabled) {
return;
}
@ -117,7 +136,7 @@ function MapEditBar({ currentMap, disabled, onMapChange, onMapReset, onLoad }) {
<Close
title="Clear Selection"
aria-label="Clear Selection"
onClick={() => onGroupSelect()}
onClick={() => onClearSelection()}
/>
<Flex>
<IconButton

View File

@ -1,6 +1,7 @@
import React, { useState, useRef } from "react";
import { Box, IconButton } from "theme-ui";
import { Stage, Layer, Image } from "react-konva";
import Konva from "konva";
import ReactResizeDetector from "react-resize-detector";
import useMapImage from "../../hooks/useMapImage";
@ -21,8 +22,16 @@ import GridOffIcon from "../../icons/GridOffIcon";
import MapGrid from "./MapGrid";
import MapGridEditor from "./MapGridEditor";
import { Map } from "../../types/Map";
import { GridInset } from "../../types/Grid";
import { MapSettingsChangeEventHandler } from "../../types/Events";
function MapEditor({ map, onSettingsChange }) {
type MapEditorProps = {
map: Map;
onSettingsChange: MapSettingsChangeEventHandler;
};
function MapEditor({ map, onSettingsChange }: MapEditorProps) {
const [mapImage] = useMapImage(map);
const [stageWidth, setStageWidth] = useState(1);
@ -32,16 +41,21 @@ function MapEditor({ map, onSettingsChange }) {
const defaultInset = getGridDefaultInset(map.grid, map.width, map.height);
const stageTranslateRef = useRef({ x: 0, y: 0 });
const mapStageRef = useRef();
const mapLayerRef = useRef();
const mapStageRef = useRef<Konva.Stage>(null);
const mapLayerRef = useRef<Konva.Layer>(null);
const [preventMapInteraction, setPreventMapInteraction] = useState(false);
function handleResize(width, height) {
setStageWidth(width);
setStageHeight(height);
function handleResize(width?: number, height?: number): void {
if (width) {
setStageWidth(width);
}
if (height) {
setStageHeight(height);
}
}
const containerRef = useRef();
const containerRef = useRef<HTMLDivElement>(null);
usePreventOverscroll(containerRef);
const [mapWidth, mapHeight] = useImageCenter(
@ -57,27 +71,31 @@ function MapEditor({ map, onSettingsChange }) {
);
useStageInteraction(
mapStageRef.current,
mapStageRef,
stageScale,
setStageScale,
stageTranslateRef,
mapLayerRef.current,
mapLayerRef,
getGridMaxZoom(map.grid),
"move",
preventMapInteraction
);
function handleGridChange(inset) {
onSettingsChange("grid", {
...map.grid,
inset,
function handleGridChange(inset: GridInset) {
onSettingsChange({
grid: {
...map.grid,
inset,
},
});
}
function handleMapReset() {
onSettingsChange("grid", {
...map.grid,
inset: defaultInset,
onSettingsChange({
grid: {
...map.grid,
inset: defaultInset,
},
});
}
@ -118,10 +136,16 @@ function MapEditor({ map, onSettingsChange }) {
bg="muted"
ref={containerRef}
>
<ReactResizeDetector handleWidth handleHeight onResize={handleResize}>
<ReactResizeDetector
handleWidth
handleHeight
onResize={handleResize}
targetRef={containerRef}
>
<KonvaBridge
stageRender={(children) => (
stageRender={(children: React.ReactNode) => (
<Stage
// @ts-ignore https://github.com/konvajs/react-konva/issues/342
width={stageWidth}
height={stageHeight}
scale={{ x: stageScale, y: stageScale }}

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react";
import { useEffect, useState } from "react";
import useImage from "use-image";
import { useDataURL } from "../../contexts/AssetsContext";
@ -7,9 +7,10 @@ import { mapSources as defaultMapSources } from "../../maps";
import { getImageLightness } from "../../helpers/image";
import Grid from "../Grid";
import Grid from "../konva/Grid";
import { Map } from "../../types/Map";
function MapGrid({ map }) {
function MapGrid({ map }: { map: Map }) {
let mapSourceMap = map;
const mapURL = useDataURL(
mapSourceMap,
@ -17,13 +18,14 @@ function MapGrid({ map }) {
undefined,
map.type === "file"
);
const [mapImage, mapLoadingStatus] = useImage(mapURL);
const [mapImage, mapLoadingStatus] = useImage(mapURL || "");
const [isImageLight, setIsImageLight] = useState(true);
// When the map changes find the average lightness of its pixels
useEffect(() => {
if (mapLoadingStatus === "loaded") {
if (mapLoadingStatus === "loaded" && mapImage) {
setIsImageLight(getImageLightness(mapImage));
}
}, [mapImage, mapLoadingStatus]);

View File

@ -1,5 +1,6 @@
import React, { useRef } from "react";
import { useRef } from "react";
import { Group, Circle, Rect } from "react-konva";
import Konva from "konva";
import {
useDebouncedStageScale,
@ -12,8 +13,15 @@ import { useKeyboard } from "../../contexts/KeyboardContext";
import Vector2 from "../../helpers/Vector2";
import shortcuts from "../../shortcuts";
import { Map } from "../../types/Map";
import { GridInset } from "../../types/Grid";
function MapGridEditor({ map, onGridChange }) {
type MapGridEditorProps = {
map: Map;
onGridChange: (inset: GridInset) => void;
};
function MapGridEditor({ map, onGridChange }: MapGridEditorProps) {
const stageScale = useDebouncedStageScale();
const mapWidth = useMapWidth();
const mapHeight = useMapHeight();
@ -39,21 +47,25 @@ function MapGridEditor({ map, onGridChange }) {
}
const handlePositions = getHandlePositions();
const handlePreviousPositionRef = useRef();
const handlePreviousPositionRef = useRef<Vector2>();
function handleScaleCircleDragStart(event) {
function handleScaleCircleDragStart(
event: Konva.KonvaEventObject<MouseEvent>
) {
const handle = event.target;
const position = getHandleNormalizedPosition(handle);
handlePreviousPositionRef.current = position;
}
function handleScaleCircleDragMove(event) {
function handleScaleCircleDragMove(
event: Konva.KonvaEventObject<MouseEvent>
) {
const handle = event.target;
onGridChange(getHandleInset(handle));
handlePreviousPositionRef.current = getHandleNormalizedPosition(handle);
}
function handleScaleCircleDragEnd(event) {
function handleScaleCircleDragEnd(event: Konva.KonvaEventObject<MouseEvent>) {
onGridChange(getHandleInset(event.target));
setPreventMapInteraction(false);
}
@ -66,11 +78,14 @@ function MapGridEditor({ map, onGridChange }) {
setPreventMapInteraction(false);
}
function getHandleInset(handle) {
function getHandleInset(handle: Konva.Node): GridInset {
const name = handle.name();
// Find distance and direction of dragging
const previousPosition = handlePreviousPositionRef.current;
if (!previousPosition) {
return map.grid.inset;
}
const position = getHandleNormalizedPosition(handle);
const distance = Vector2.distance(previousPosition, position);
const direction = Vector2.normalize(
@ -154,7 +169,7 @@ function MapGridEditor({ map, onGridChange }) {
}
}
function nudgeGrid(direction, scale) {
function nudgeGrid(direction: Vector2, scale: number) {
const inset = map.grid.inset;
const gridSizeNormalized = Vector2.divide(
Vector2.subtract(inset.bottomRight, inset.topLeft),
@ -170,7 +185,7 @@ function MapGridEditor({ map, onGridChange }) {
});
}
function handleKeyDown(event) {
function handleKeyDown(event: KeyboardEvent) {
const nudgeAmount = event.shiftKey ? 2 : 0.5;
if (shortcuts.gridNudgeUp(event)) {
// Stop arrow up/down scrolling if overflowing
@ -191,7 +206,7 @@ function MapGridEditor({ map, onGridChange }) {
useKeyboard(handleKeyDown);
function getHandleNormalizedPosition(handle) {
function getHandleNormalizedPosition(handle: Konva.Node) {
return Vector2.divide({ x: handle.x(), y: handle.y() }, mapSize);
}

View File

@ -1,7 +1,8 @@
import React, { useRef, useEffect, useState } from "react";
import { Box } from "theme-ui";
import ReactResizeDetector from "react-resize-detector";
import { Stage, Layer, Image } from "react-konva";
import { Stage, Layer, Image, Group } from "react-konva";
import Konva from "konva";
import { EventEmitter } from "events";
import useMapImage from "../../hooks/useMapImage";
@ -18,6 +19,19 @@ import { GridProvider } from "../../contexts/GridContext";
import { useKeyboard } from "../../contexts/KeyboardContext";
import shortcuts from "../../shortcuts";
import { Map, MapToolId } from "../../types/Map";
import { MapState } from "../../types/MapState";
type SelectedToolChangeEventHanlder = (tool: MapToolId) => void;
type MapInteractionProps = {
map: Map | null;
mapState: MapState | null;
children?: React.ReactNode;
controls: React.ReactNode;
selectedToolId: MapToolId;
onSelectedToolChange: SelectedToolChangeEventHanlder;
};
function MapInteraction({
map,
@ -26,8 +40,7 @@ function MapInteraction({
controls,
selectedToolId,
onSelectedToolChange,
disabledControls,
}) {
}: MapInteractionProps) {
const [mapImage, mapImageStatus] = useMapImage(map);
const [mapLoaded, setMapLoaded] = useState(false);
@ -47,17 +60,17 @@ function MapInteraction({
// Avoid state udpates when panning the map by using a ref and updating the konva element directly
const stageTranslateRef = useRef({ x: 0, y: 0 });
const mapStageRef = useMapStage();
const mapLayerRef = useRef();
const mapImageRef = useRef();
const mapLayerRef = useRef<Konva.Layer>(null);
const mapImageRef = useRef<Konva.Image>(null);
function handleResize(width, height) {
if (width > 0 && height > 0) {
function handleResize(width?: number, height?: number) {
if (width && height && width > 0 && height > 0) {
setStageWidth(width);
setStageHeight(height);
}
}
const containerRef = useRef();
const containerRef = useRef<HTMLDivElement>(null);
usePreventOverscroll(containerRef);
const [mapWidth, mapHeight] = useImageCenter(
@ -76,11 +89,11 @@ function MapInteraction({
const [interactionEmitter] = useState(new EventEmitter());
useStageInteraction(
mapStageRef.current,
mapStageRef,
stageScale,
setStageScale,
stageTranslateRef,
mapLayerRef.current,
mapLayerRef,
getGridMaxZoom(map?.grid),
selectedToolId,
preventMapInteraction,
@ -105,44 +118,23 @@ function MapInteraction({
}
);
function handleKeyDown(event) {
function handleKeyDown(event: KeyboardEvent) {
// Change to move tool when pressing space
if (shortcuts.move(event) && selectedToolId === "move") {
// Stop active state on move icon from being selected
event.preventDefault();
}
if (
shortcuts.move(event) &&
selectedToolId !== "move" &&
!disabledControls.includes("move")
) {
if (map && shortcuts.move(event) && selectedToolId !== "move") {
event.preventDefault();
previousSelectedToolRef.current = selectedToolId;
onSelectedToolChange("move");
}
// Basic keyboard shortcuts
if (shortcuts.moveTool(event) && !disabledControls.includes("move")) {
onSelectedToolChange("move");
}
if (shortcuts.drawingTool(event) && !disabledControls.includes("drawing")) {
onSelectedToolChange("drawing");
}
if (shortcuts.fogTool(event) && !disabledControls.includes("fog")) {
onSelectedToolChange("fog");
}
if (shortcuts.measureTool(event) && !disabledControls.includes("measure")) {
onSelectedToolChange("measure");
}
if (shortcuts.pointerTool(event) && !disabledControls.includes("pointer")) {
onSelectedToolChange("pointer");
}
if (shortcuts.noteTool(event) && !disabledControls.includes("note")) {
onSelectedToolChange("note");
if (!event.repeat && shortcuts.move(event) && selectedToolId === "move") {
previousSelectedToolRef.current = "move";
}
}
function handleKeyUp(event) {
function handleKeyUp(event: KeyboardEvent) {
if (shortcuts.move(event) && selectedToolId === "move") {
onSelectedToolChange(previousSelectedToolRef.current);
}
@ -150,7 +142,7 @@ function MapInteraction({
useKeyboard(handleKeyDown, handleKeyUp);
function getCursorForTool(tool) {
function getCursorForTool(tool: MapToolId) {
switch (tool) {
case "move":
return "move";
@ -160,6 +152,7 @@ function MapInteraction({
case "measure":
case "pointer":
case "note":
case "select":
return "crosshair";
default:
return "default";
@ -178,7 +171,11 @@ function MapInteraction({
return (
<MapInteractionProvider value={mapInteraction}>
<GridProvider grid={map?.grid} width={mapWidth} height={mapHeight}>
<GridProvider
grid={map?.grid || null}
width={mapWidth}
height={mapHeight}
>
<Box
sx={{
position: "relative",
@ -191,10 +188,16 @@ function MapInteraction({
ref={containerRef}
className="map"
>
<ReactResizeDetector handleWidth handleHeight onResize={handleResize}>
<ReactResizeDetector
handleWidth
handleHeight
onResize={handleResize}
targetRef={containerRef}
>
<KonvaBridge
stageRender={(children) => (
<Stage
// @ts-ignore
width={stageWidth}
height={stageHeight}
scale={{ x: stageScale, y: stageScale }}
@ -206,14 +209,14 @@ function MapInteraction({
>
<Layer ref={mapLayerRef}>
<Image
image={mapLoaded && mapImage}
image={(mapLoaded && mapImage) || undefined}
width={mapWidth}
height={mapHeight}
id="mapImage"
ref={mapImageRef}
/>
{mapLoaded && children}
<Group id="portal" />
</Layer>
</KonvaBridge>
</ReactResizeDetector>

View File

@ -1,4 +1,3 @@
import React from "react";
import { Box } from "theme-ui";
import { useMapLoading } from "../../contexts/MapLoadingContext";
@ -8,8 +7,11 @@ import LoadingBar from "../LoadingBar";
function MapLoadingOverlay() {
const { isLoading, loadingProgressRef } = useMapLoading();
if (!isLoading) {
return null;
}
return (
isLoading && (
<Box
sx={{
position: "absolute",
@ -29,8 +31,7 @@ function MapLoadingOverlay() {
loadingProgressRef={loadingProgressRef}
/>
</Box>
)
);
);
}
export default MapLoadingOverlay;

View File

@ -1,177 +0,0 @@
import React, { useState, useEffect } from "react";
import { Group, Line, Text, Label, Tag } from "react-konva";
import {
useDebouncedStageScale,
useMapWidth,
useMapHeight,
useInteractionEmitter,
} from "../../contexts/MapInteractionContext";
import { useMapStage } from "../../contexts/MapStageContext";
import {
useGrid,
useGridCellPixelSize,
useGridCellNormalizedSize,
useGridStrokeWidth,
useGridOffset,
} from "../../contexts/GridContext";
import {
getDefaultShapeData,
getUpdatedShapeData,
} from "../../helpers/drawing";
import Vector2 from "../../helpers/Vector2";
import { getRelativePointerPosition } from "../../helpers/konva";
import { parseGridScale, gridDistance } from "../../helpers/grid";
import useGridSnapping from "../../hooks/useGridSnapping";
function MapMeasure({ map, active }) {
const stageScale = useDebouncedStageScale();
const mapWidth = useMapWidth();
const mapHeight = useMapHeight();
const interactionEmitter = useInteractionEmitter();
const grid = useGrid();
const gridCellNormalizedSize = useGridCellNormalizedSize();
const gridCellPixelSize = useGridCellPixelSize();
const gridStrokeWidth = useGridStrokeWidth();
const gridOffset = useGridOffset();
const mapStageRef = useMapStage();
const [drawingShapeData, setDrawingShapeData] = useState(null);
const [isBrushDown, setIsBrushDown] = useState(false);
const gridScale = parseGridScale(active && grid.measurement.scale);
const snapPositionToGrid = useGridSnapping(
grid.measurement.type === "euclidean" ? 0 : 1,
false
);
useEffect(() => {
if (!active) {
return;
}
const mapStage = mapStageRef.current;
const mapImage = mapStage?.findOne("#mapImage");
function getBrushPosition() {
const mapImage = mapStage.findOne("#mapImage");
let position = getRelativePointerPosition(mapImage);
if (map.snapToGrid) {
position = snapPositionToGrid(position);
}
return Vector2.divide(position, {
x: mapImage.width(),
y: mapImage.height(),
});
}
function handleBrushDown() {
const brushPosition = getBrushPosition();
const { points } = getDefaultShapeData("line", brushPosition);
const length = 0;
setDrawingShapeData({ length, points });
setIsBrushDown(true);
}
function handleBrushMove() {
const brushPosition = getBrushPosition();
if (isBrushDown && drawingShapeData) {
const { points } = getUpdatedShapeData(
"line",
drawingShapeData,
brushPosition,
gridCellNormalizedSize
);
// Convert back to pixel values
const a = Vector2.subtract(
Vector2.multiply(points[0], {
x: mapImage.width(),
y: mapImage.height(),
}),
gridOffset
);
const b = Vector2.subtract(
Vector2.multiply(points[1], {
x: mapImage.width(),
y: mapImage.height(),
}),
gridOffset
);
const length = gridDistance(grid, a, b, gridCellPixelSize);
setDrawingShapeData({
length,
points,
});
}
}
function handleBrushUp() {
setDrawingShapeData(null);
setIsBrushDown(false);
}
interactionEmitter.on("dragStart", handleBrushDown);
interactionEmitter.on("drag", handleBrushMove);
interactionEmitter.on("dragEnd", handleBrushUp);
return () => {
interactionEmitter.off("dragStart", handleBrushDown);
interactionEmitter.off("drag", handleBrushMove);
interactionEmitter.off("dragEnd", handleBrushUp);
};
});
function renderShape(shapeData) {
const linePoints = shapeData.points.reduce(
(acc, point) => [...acc, point.x * mapWidth, point.y * mapHeight],
[]
);
const lineCenter = Vector2.multiply(
Vector2.divide(Vector2.add(shapeData.points[0], shapeData.points[1]), 2),
{ x: mapWidth, y: mapHeight }
);
return (
<Group>
<Line
points={linePoints}
strokeWidth={1.5 * gridStrokeWidth}
stroke="hsla(230, 25%, 18%, 0.8)"
lineCap="round"
/>
<Line
points={linePoints}
strokeWidth={0.25 * gridStrokeWidth}
stroke="white"
lineCap="round"
/>
<Label
x={lineCenter.x}
y={lineCenter.y}
offsetX={26}
offsetY={26}
scaleX={1 / stageScale}
scaleY={1 / stageScale}
>
<Tag fill="hsla(230, 25%, 18%, 0.8)" cornerRadius={4} />
<Text
text={`${(shapeData.length * gridScale.multiplier).toFixed(
gridScale.digits
)}${gridScale.unit}`}
fill="white"
fontSize={24}
padding={4}
/>
</Label>
</Group>
);
}
return <Group>{drawingShapeData && renderShape(drawingShapeData)}</Group>;
}
export default MapMeasure;

View File

@ -1,6 +1,23 @@
import React, { useEffect, useState } from "react";
import Modal from "react-modal";
import { useThemeUI } from "theme-ui";
import CSS from "csstype";
import { RequestCloseEventHandler } from "../../types/Events";
import { useInteractionEmitter } from "../../contexts/MapInteractionContext";
type MapMenuProps = {
isOpen: boolean;
onRequestClose: RequestCloseEventHandler;
onModalContent: (instance: HTMLDivElement) => void;
top: number | string;
left: number | string;
bottom: number | string;
right: number | string;
children: React.ReactNode;
style: React.CSSProperties;
excludeNode: Node | null;
};
function MapMenu({
isOpen,
@ -14,41 +31,59 @@ function MapMenu({
style,
// A node to exclude from the pointer event for closing
excludeNode,
}) {
}: MapMenuProps) {
// Save modal node in state to ensure that the pointer listeners
// are removed if the open state changed not from the onRequestClose
// callback
const [modalContentNode, setModalContentNode] = useState(null);
const [modalContentNode, setModalContentNode] = useState<Node | null>(null);
const interactionEmitter = useInteractionEmitter();
useEffect(() => {
if (!isOpen) {
return;
}
function handleDragMove() {
onRequestClose();
}
interactionEmitter?.on("dragStart", handleDragMove);
return () => {
interactionEmitter?.off("dragStart", handleDragMove);
};
});
useEffect(() => {
// Close modal if interacting with any other element
function handleInteraction(event) {
function handleInteraction(event: Event) {
const path = event.composedPath();
if (
modalContentNode &&
!path.includes(modalContentNode) &&
!(excludeNode && path.includes(excludeNode)) &&
!(event.target instanceof HTMLTextAreaElement)
) {
onRequestClose();
document.body.removeEventListener("pointerdown", handleInteraction);
document.body.removeEventListener("pointerup", handleInteraction);
document.body.removeEventListener("wheel", handleInteraction);
}
}
if (modalContentNode) {
document.body.addEventListener("pointerdown", handleInteraction);
document.body.addEventListener("pointerup", handleInteraction);
// Check for wheel event to close modal as well
document.body.addEventListener("wheel", handleInteraction);
}
return () => {
if (modalContentNode) {
document.body.removeEventListener("pointerdown", handleInteraction);
document.body.removeEventListener("pointerup", handleInteraction);
document.body.removeEventListener("wheel", handleInteraction);
}
};
}, [modalContentNode, excludeNode, onRequestClose]);
function handleModalContent(node) {
function handleModalContent(node: HTMLDivElement) {
setModalContentNode(node);
onModalContent(node);
}
@ -62,7 +97,7 @@ function MapMenu({
style={{
overlay: { top: "0", bottom: "initial" },
content: {
backgroundColor: theme.colors.overlay,
backgroundColor: theme.colors?.overlay as CSS.Property.Color,
top,
left,
right,

View File

@ -9,8 +9,16 @@ import { mapSources as defaultMapSources } from "../../maps";
import Divider from "../Divider";
import Select from "../Select";
import { Map, MapQuality } from "../../types/Map";
import { EditFlag, MapState } from "../../types/MapState";
import {
MapSettingsChangeEventHandler,
MapStateSettingsChangeEventHandler,
} from "../../types/Events";
import { Grid, GridMeasurementType, GridType } from "../../types/Grid";
const qualitySettings = [
type QualityTypeSetting = { value: MapQuality; label: string };
const qualitySettings: QualityTypeSetting[] = [
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
@ -18,42 +26,53 @@ const qualitySettings = [
{ value: "original", label: "Original" },
];
const gridTypeSettings = [
type GridTypeSetting = { value: GridType; label: string };
const gridTypeSettings: GridTypeSetting[] = [
{ value: "square", label: "Square" },
{ value: "hexVertical", label: "Hex Vertical" },
{ value: "hexHorizontal", label: "Hex Horizontal" },
];
const gridSquareMeasurementTypeSettings = [
type GridMeasurementTypeSetting = { value: GridMeasurementType; label: string };
const gridSquareMeasurementTypeSettings: GridMeasurementTypeSetting[] = [
{ value: "chebyshev", label: "Chessboard (D&D 5e)" },
{ value: "alternating", label: "Alternating Diagonal (D&D 3.5e)" },
{ value: "euclidean", label: "Euclidean" },
{ value: "manhattan", label: "Manhattan" },
];
const gridHexMeasurementTypeSettings = [
const gridHexMeasurementTypeSettings: GridMeasurementTypeSetting[] = [
{ value: "manhattan", label: "Manhattan" },
{ value: "euclidean", label: "Euclidean" },
];
type MapSettingsProps = {
map: Map;
mapState: MapState;
onSettingsChange: MapSettingsChangeEventHandler;
onStateSettingsChange: MapStateSettingsChangeEventHandler;
};
function MapSettings({
map,
mapState,
onSettingsChange,
onStateSettingsChange,
}) {
function handleFlagChange(event, flag) {
}: MapSettingsProps) {
function handleFlagChange(
event: React.ChangeEvent<HTMLInputElement>,
flag: EditFlag
) {
if (event.target.checked) {
onStateSettingsChange("editFlags", [...mapState.editFlags, flag]);
onStateSettingsChange({ editFlags: [...mapState.editFlags, flag] });
} else {
onStateSettingsChange(
"editFlags",
mapState.editFlags.filter((f) => f !== flag)
);
onStateSettingsChange({
editFlags: mapState.editFlags.filter((f) => f !== flag),
});
}
}
function handleGridSizeXChange(event) {
function handleGridSizeXChange(event: React.ChangeEvent<HTMLInputElement>) {
const value = parseInt(event.target.value) || 0;
let grid = {
...map.grid,
@ -63,10 +82,10 @@ function MapSettings({
},
};
grid.inset = getGridUpdatedInset(grid, map.width, map.height);
onSettingsChange("grid", grid);
onSettingsChange({ grid });
}
function handleGridSizeYChange(event) {
function handleGridSizeYChange(event: React.ChangeEvent<HTMLInputElement>) {
const value = parseInt(event.target.value) || 0;
let grid = {
...map.grid,
@ -76,12 +95,15 @@ function MapSettings({
},
};
grid.inset = getGridUpdatedInset(grid, map.width, map.height);
onSettingsChange("grid", grid);
onSettingsChange({ grid });
}
function handleGridTypeChange(option) {
function handleGridTypeChange(option: GridTypeSetting | null) {
if (!option) {
return;
}
const type = option.value;
let grid = {
let grid: Grid = {
...map.grid,
type,
measurement: {
@ -90,10 +112,15 @@ function MapSettings({
},
};
grid.inset = getGridUpdatedInset(grid, map.width, map.height);
onSettingsChange("grid", grid);
onSettingsChange({ grid });
}
function handleGridMeasurementTypeChange(option) {
function handleGridMeasurementTypeChange(
option: GridMeasurementTypeSetting | null
) {
if (!option) {
return;
}
const grid = {
...map.grid,
measurement: {
@ -101,10 +128,19 @@ function MapSettings({
type: option.value,
},
};
onSettingsChange("grid", grid);
onSettingsChange({ grid });
}
function handleGridMeasurementScaleChange(event) {
function handleQualityChange(option: QualityTypeSetting | null) {
if (!option) {
return;
}
onSettingsChange({ quality: option.value });
}
function handleGridMeasurementScaleChange(
event: React.ChangeEvent<HTMLInputElement>
) {
const grid = {
...map.grid,
measurement: {
@ -112,7 +148,7 @@ function MapSettings({
scale: event.target.value,
},
};
onSettingsChange("grid", grid);
onSettingsChange({ grid });
}
const mapURL = useDataURL(map, defaultMapSources);
@ -124,7 +160,7 @@ function MapSettings({
const blob = await response.blob();
let size = blob.size;
size /= 1000000; // Bytes to Megabytes
setMapSize(size.toFixed(2));
setMapSize(parseFloat(size.toFixed(2)));
} else {
setMapSize(0);
}
@ -168,7 +204,7 @@ function MapSettings({
<Input
name="name"
value={(map && map.name) || ""}
onChange={(e) => onSettingsChange("name", e.target.value)}
onChange={(e) => onSettingsChange({ name: e.target.value })}
disabled={mapEmpty}
my={1}
/>
@ -185,10 +221,11 @@ function MapSettings({
isDisabled={mapEmpty}
options={gridTypeSettings}
value={
!mapEmpty &&
gridTypeSettings.find((s) => s.value === map.grid.type)
mapEmpty
? undefined
: gridTypeSettings.find((s) => s.value === map.grid.type)
}
onChange={handleGridTypeChange}
onChange={handleGridTypeChange as any}
isSearchable={false}
/>
</Box>
@ -197,7 +234,9 @@ function MapSettings({
<Checkbox
checked={!mapEmpty && map.showGrid}
disabled={mapEmpty}
onChange={(e) => onSettingsChange("showGrid", e.target.checked)}
onChange={(e) =>
onSettingsChange({ showGrid: e.target.checked })
}
/>
Draw Grid
</Label>
@ -206,7 +245,7 @@ function MapSettings({
checked={!mapEmpty && map.snapToGrid}
disabled={mapEmpty}
onChange={(e) =>
onSettingsChange("snapToGrid", e.target.checked)
onSettingsChange({ snapToGrid: e.target.checked })
}
/>
Snap to Grid
@ -224,12 +263,13 @@ function MapSettings({
: gridHexMeasurementTypeSettings
}
value={
!mapEmpty &&
gridSquareMeasurementTypeSettings.find(
(s) => s.value === map.grid.measurement.type
)
mapEmpty
? undefined
: gridSquareMeasurementTypeSettings.find(
(s) => s.value === map.grid.measurement.type
)
}
onChange={handleGridMeasurementTypeChange}
onChange={handleGridMeasurementTypeChange as any}
isSearchable={false}
/>
</Box>
@ -254,14 +294,17 @@ function MapSettings({
<Select
options={qualitySettings}
value={
!mapEmpty &&
qualitySettings.find((s) => s.value === map.quality)
mapEmpty
? undefined
: 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])
onChange={handleQualityChange as any}
isOptionDisabled={
((option: QualityTypeSetting) =>
mapEmpty ||
(option.value !== "original" &&
!map.resolutions[option.value])) as any
}
isSearchable={false}
/>

View File

@ -1,7 +1,18 @@
import React from "react";
import { Map } from "../../types/Map";
import Tile from "../tile/Tile";
import MapImage from "./MapImage";
import MapImage from "./MapTileImage";
type MapTileProps = {
map: Map;
isSelected: boolean;
onSelect: (mapId: string) => void;
onEdit: (mapId: string) => void;
onDoubleClick: () => void;
canEdit: boolean;
badges: React.ReactChild[];
};
function MapTile({
map,
@ -11,7 +22,7 @@ function MapTile({
onDoubleClick,
canEdit,
badges,
}) {
}: MapTileProps) {
return (
<Tile
title={map.name}

View File

@ -1,12 +1,28 @@
import React from "react";
import { Grid } from "theme-ui";
import Tile from "../tile/Tile";
import MapImage from "./MapImage";
import MapImage from "./MapTileImage";
import useResponsiveLayout from "../../hooks/useResponsiveLayout";
function MapTileGroup({ group, maps, isSelected, onSelect, onDoubleClick }) {
import { Map } from "../../types/Map";
import { GroupContainer } from "../../types/Group";
type MapTileGroupProps = {
group: GroupContainer;
maps: Map[];
isSelected: boolean;
onSelect: (groupId: string) => void;
onDoubleClick: () => void;
};
function MapTileGroup({
group,
maps,
isSelected,
onSelect,
onDoubleClick,
}: MapTileGroupProps) {
const layout = useResponsiveLayout();
return (

View File

@ -1,10 +1,14 @@
import React from "react";
import { Image } from "theme-ui";
import { Image, ImageProps } from "theme-ui";
import { useDataURL } from "../../contexts/AssetsContext";
import { mapSources as defaultMapSources } from "../../maps";
import { Map } from "../../types/Map";
const MapTileImage = React.forwardRef(({ map, ...props }, ref) => {
type MapTileImageProps = {
map: Map;
} & ImageProps;
function MapTileImage({ map, ...props }: MapTileImageProps) {
const mapURL = useDataURL(
map,
defaultMapSources,
@ -12,7 +16,7 @@ const MapTileImage = React.forwardRef(({ map, ...props }, ref) => {
map.type === "file"
);
return <Image src={mapURL} ref={ref} {...props} />;
});
return <Image src={mapURL} {...props} />;
}
export default MapTileImage;

Some files were not shown because too many files have changed in this diff Show More