commit
0123cd0995
3
.gitignore
vendored
3
.gitignore
vendored
@ -21,3 +21,6 @@
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# typescript
|
||||
tsconfig.tsbuildinfo
|
81
package.json
81
package.json
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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";
|
@ -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);
|
@ -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;
|
21
src/actions/AddStatesAction.ts
Normal file
21
src/actions/AddStatesAction.ts
Normal 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;
|
41
src/actions/CutFogAction.ts
Normal file
41
src/actions/CutFogAction.ts
Normal 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;
|
@ -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;
|
@ -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;
|
23
src/actions/EditStatesAction.ts
Normal file
23
src/actions/EditStatesAction.ts
Normal 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;
|
@ -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;
|
21
src/actions/RemoveStatesAction.ts
Normal file
21
src/actions/RemoveStatesAction.ts
Normal 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;
|
32
src/actions/SubtractFogAction.ts
Normal file
32
src/actions/SubtractFogAction.ts
Normal 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;
|
@ -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;
|
@ -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
13
src/actions/index.ts
Normal 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,
|
||||
};
|
@ -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 (
|
24
src/components/DatabaseDisabledMessage.tsx
Normal file
24
src/components/DatabaseDisabledMessage.tsx
Normal 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;
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import { Flex } from "theme-ui";
|
||||
|
||||
import Link from "./Link";
|
@ -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} />
|
@ -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]);
|
@ -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={{
|
@ -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,
|
@ -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 }}>
|
@ -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>
|
@ -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
|
@ -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
83
src/components/Select.tsx
Normal 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;
|
@ -1,4 +1,4 @@
|
||||
import React, { useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { IconButton } from "theme-ui";
|
||||
|
||||
import SettingsIcon from "../icons/SettingsIcon";
|
@ -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;
|
@ -1,4 +1,3 @@
|
||||
import React from "react";
|
||||
import { Box } from "theme-ui";
|
||||
|
||||
import "./Spinner.css";
|
@ -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;
|
10
src/components/TextareaAutoSize.tsx
Normal file
10
src/components/TextareaAutoSize.tsx
Normal 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;
|
@ -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 }}
|
@ -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(() => {
|
@ -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%",
|
@ -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}>
|
@ -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}
|
@ -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}
|
@ -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>
|
||||
);
|
@ -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;
|
27
src/components/controls/PointerToolSettings.tsx
Normal file
27
src/components/controls/PointerToolSettings.tsx
Normal 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;
|
63
src/components/controls/SelectToolSettings.tsx
Normal file
63
src/components/controls/SelectToolSettings.tsx
Normal 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;
|
@ -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)"}
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
59
src/components/controls/shared/EraseAllButton.tsx
Normal file
59
src/components/controls/shared/EraseAllButton.tsx
Normal 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;
|
@ -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={
|
@ -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={
|
@ -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={
|
@ -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)`}
|
28
src/components/controls/shared/ShapeFillToggle.tsx
Normal file
28
src/components/controls/shared/ShapeFillToggle.tsx
Normal 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;
|
@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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)`}
|
@ -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}
|
@ -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={{
|
@ -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);
|
@ -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>
|
||||
);
|
||||
}
|
@ -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")
|
@ -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
|
@ -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>
|
@ -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 && (
|
@ -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;
|
@ -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,
|
@ -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 (
|
@ -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}>
|
@ -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}
|
92
src/components/konva/Drawing.tsx
Normal file
92
src/components/konva/Drawing.tsx
Normal 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;
|
143
src/components/konva/Fog.tsx
Normal file
143
src/components/konva/Fog.tsx
Normal 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;
|
@ -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 (
|
304
src/components/konva/Note.tsx
Normal file
304
src/components/konva/Note.tsx
Normal 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;
|
169
src/components/konva/Pointer.tsx
Normal file
169
src/components/konva/Pointer.tsx
Normal 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;
|
69
src/components/konva/Ruler.tsx
Normal file
69
src/components/konva/Ruler.tsx
Normal 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;
|
274
src/components/konva/Selection.tsx
Normal file
274
src/components/konva/Selection.tsx
Normal 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;
|
48
src/components/konva/Tick.tsx
Normal file
48
src/components/konva/Tick.tsx
Normal 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;
|
505
src/components/konva/Token.tsx
Normal file
505
src/components/konva/Token.tsx
Normal 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;
|
54
src/components/konva/TokenAttachment.tsx
Normal file
54
src/components/konva/TokenAttachment.tsx
Normal 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;
|
@ -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}>
|
51
src/components/konva/TokenOutline.tsx
Normal file
51
src/components/konva/TokenOutline.tsx
Normal 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;
|
@ -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 (
|
543
src/components/konva/Transformer.tsx
Normal file
543
src/components/konva/Transformer.tsx
Normal 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;
|
@ -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;
|
101
src/components/map/DragOverlay.tsx
Normal file
101
src/components/map/DragOverlay.tsx
Normal 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;
|
@ -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
243
src/components/map/Map.tsx
Normal 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;
|
@ -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;
|
390
src/components/map/MapControls.tsx
Normal file
390
src/components/map/MapControls.tsx
Normal 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;
|
@ -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;
|
@ -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
|
@ -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 }}
|
@ -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]);
|
@ -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);
|
||||
}
|
||||
|
@ -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>
|
@ -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;
|
@ -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;
|
@ -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,
|
@ -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}
|
||||
/>
|
@ -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}
|
@ -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 (
|
@ -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
Loading…
Reference in New Issue
Block a user