commit
0123cd0995
3
.gitignore
vendored
3
.gitignore
vendored
@ -21,3 +21,6 @@
|
|||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
tsconfig.tsbuildinfo
|
81
package.json
81
package.json
@ -1,36 +1,37 @@
|
|||||||
{
|
{
|
||||||
"name": "owlbear-rodeo",
|
"name": "owlbear-rodeo",
|
||||||
"version": "1.9.0",
|
"version": "1.10.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babylonjs/core": "^4.2.0",
|
"@babylonjs/core": "^4.2.0",
|
||||||
"@babylonjs/loaders": "^4.2.0",
|
"@babylonjs/loaders": "^4.2.0",
|
||||||
"@dnd-kit/core": "^3.0.4",
|
"@dnd-kit/core": "^3.1.1",
|
||||||
"@dnd-kit/sortable": "^3.1.0",
|
"@dnd-kit/sortable": "^4.0.0",
|
||||||
"@mitchemmc/dexie-export-import": "^1.0.1",
|
"@mitchemmc/dexie-export-import": "^1.0.1",
|
||||||
"@msgpack/msgpack": "^2.4.1",
|
"@msgpack/msgpack": "^2.7.0",
|
||||||
"@react-spring/konva": "^9.2.3",
|
"@react-spring/konva": "^9.2.4",
|
||||||
"@sentry/integrations": "^6.3.0",
|
"@sentry/integrations": "^6.11.0",
|
||||||
"@sentry/react": "^6.3.0",
|
"@sentry/react": "^6.11.0",
|
||||||
"@stripe/stripe-js": "^1.13.1",
|
"@stripe/stripe-js": "^1.16.0",
|
||||||
"@tensorflow/tfjs": "^3.3.0",
|
"@tensorflow/tfjs": "^3.8.0",
|
||||||
"@testing-library/jest-dom": "^5.11.9",
|
"@testing-library/jest-dom": "^5.11.9",
|
||||||
"@testing-library/react": "^11.2.5",
|
"@testing-library/react": "^11.2.5",
|
||||||
"@testing-library/user-event": "^13.0.2",
|
"@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",
|
"case": "^1.6.3",
|
||||||
"color": "^3.1.3",
|
"color": "^3.2.1",
|
||||||
"comlink": "^4.3.0",
|
"comlink": "^4.3.1",
|
||||||
"deep-diff": "^1.0.2",
|
"deep-diff": "^1.0.2",
|
||||||
"dexie": "3.1.0-beta.13",
|
"dexie": "3.1.0-beta.13",
|
||||||
"dexie-react-hooks": "^1.0.6",
|
"dexie-react-hooks": "^1.0.7",
|
||||||
"err-code": "^3.0.1",
|
"err-code": "^3.0.1",
|
||||||
"fake-indexeddb": "^3.1.2",
|
"fake-indexeddb": "^3.1.3",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"fuse.js": "^6.4.6",
|
"fuse.js": "^6.4.6",
|
||||||
"image-outline": "^0.1.0",
|
"image-outline": "^0.1.0",
|
||||||
"intersection-observer": "^0.12.0",
|
"intersection-observer": "^0.12.0",
|
||||||
"konva": "^8.1.1",
|
"konva": "^8.1.3",
|
||||||
"lodash.chunk": "^4.2.0",
|
"lodash.chunk": "^4.2.0",
|
||||||
"lodash.clonedeep": "^4.5.0",
|
"lodash.clonedeep": "^4.5.0",
|
||||||
"lodash.get": "^4.4.2",
|
"lodash.get": "^4.4.2",
|
||||||
@ -38,36 +39,37 @@
|
|||||||
"lodash.unset": "^4.5.2",
|
"lodash.unset": "^4.5.2",
|
||||||
"normalize-wheel": "^1.0.1",
|
"normalize-wheel": "^1.0.1",
|
||||||
"pepjs": "^0.5.3",
|
"pepjs": "^0.5.3",
|
||||||
"polygon-clipping": "^0.15.2",
|
"polygon-clipping": "^0.15.3",
|
||||||
"pretty-bytes": "^5.6.0",
|
"pretty-bytes": "^5.6.0",
|
||||||
"raw.macro": "^0.4.2",
|
"raw.macro": "^0.4.2",
|
||||||
"react": "^17.0.1",
|
"react": "^17.0.2",
|
||||||
"react-dom": "^17.0.1",
|
"react-dom": "^17.0.2",
|
||||||
"react-intersection-observer": "^8.32.0",
|
"react-intersection-observer": "^8.32.0",
|
||||||
"react-konva": "^17.0.2-5",
|
"react-konva": "^17.0.2-5",
|
||||||
|
"react-konva-utils": "^0.1.7",
|
||||||
"react-markdown": "4",
|
"react-markdown": "4",
|
||||||
"react-media": "^2.0.0-rc.1",
|
"react-media": "^2.0.0-rc.1",
|
||||||
"react-modal": "^3.12.1",
|
"react-modal": "^3.14.3",
|
||||||
"react-resize-detector": "4.2.3",
|
"react-resize-detector": "^6.7.4",
|
||||||
"react-router-dom": "^5.1.2",
|
"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-scripts": "^4.0.3",
|
||||||
"react-select": "^4.2.1",
|
"react-select": "^4.3.1",
|
||||||
"react-spring": "^9.2.3",
|
"react-spring": "^9.2.4",
|
||||||
"react-textarea-autosize": "^8.3.3",
|
"react-textarea-autosize": "^8.3.3",
|
||||||
"react-toast-notifications": "^2.4.3",
|
"react-toast-notifications": "^2.5.1",
|
||||||
"react-use-gesture": "^9.1.3",
|
"react-use-gesture": "^9.1.3",
|
||||||
"shortid": "^2.2.15",
|
"shortid": "^2.2.15",
|
||||||
"simple-peer": "feross/simple-peer#694/head",
|
"simple-peer": "^9.11.0",
|
||||||
"simplebar-react": "^2.1.0",
|
"simplebar-react": "^2.3.5",
|
||||||
"simplify-js": "^1.2.4",
|
"simplify-js": "^1.2.4",
|
||||||
"socket.io-client": "^4.0.0",
|
"socket.io-client": "^4.1.3",
|
||||||
"socket.io-msgpack-parser": "^3.0.1",
|
"socket.io-msgpack-parser": "^3.0.1",
|
||||||
"source-map-explorer": "^2.5.2",
|
"source-map-explorer": "^2.5.2",
|
||||||
"theme-ui": "^0.3.1",
|
"theme-ui": "^0.10.0",
|
||||||
"use-image": "^1.0.7",
|
"use-image": "^1.0.8",
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2",
|
||||||
"webrtc-adapter": "^7.7.1"
|
"webrtc-adapter": "^8.1.0"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"simple-peer/get-browser-rtc": "substack/get-browser-rtc#4/head"
|
"simple-peer/get-browser-rtc": "substack/get-browser-rtc#4/head"
|
||||||
@ -95,6 +97,25 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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"
|
"worker-loader": "^3.0.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
import React from "react";
|
|
||||||
import { ThemeProvider } from "theme-ui";
|
import { ThemeProvider } from "theme-ui";
|
||||||
import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
|
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 Home from "./routes/Home";
|
||||||
import Game from "./routes/Game";
|
import Game from "./routes/Game";
|
||||||
import About from "./routes/About";
|
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 } from "deep-diff";
|
||||||
|
|
||||||
import { diff, revertChanges } from "../helpers/diff";
|
import { diff, revertChanges } from "../helpers/diff";
|
||||||
import cloneDeep from "lodash.clonedeep";
|
import cloneDeep from "lodash.clonedeep";
|
||||||
|
|
||||||
/**
|
|
||||||
* @callback ActionUpdate
|
|
||||||
* @param {any} state
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Implementation of the Command Pattern
|
* Implementation of the Command Pattern
|
||||||
* Wraps an update function with internal state to support undo
|
* 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
|
* The update function called with the current state and should return the updated state
|
||||||
* This is implemented in the child class
|
* This is implemented in the child class
|
||||||
*
|
|
||||||
* @type {ActionUpdate}
|
|
||||||
*/
|
*/
|
||||||
update;
|
update(state: State): State {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The changes caused by the last state update
|
* The changes caused by the last state update
|
||||||
* @type {Diff}
|
|
||||||
*/
|
*/
|
||||||
changes;
|
changes: Diff<State, State>[] | undefined;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes the action update on the state
|
* Executes the action update on the state
|
||||||
* @param {any} state The current state to update
|
* @param {State} state The current state to update
|
||||||
* @returns {any} The updated state
|
|
||||||
*/
|
*/
|
||||||
execute(state) {
|
execute(state: State): State {
|
||||||
if (state && this.update) {
|
if (state && this.update) {
|
||||||
let newState = this.update(cloneDeep(state));
|
let newState = this.update(cloneDeep(state));
|
||||||
this.changes = diff(state, newState);
|
this.changes = diff(state, newState);
|
||||||
@ -45,10 +36,10 @@ class Action {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Reverts the changes caused by the last call of `execute`
|
* Reverts the changes caused by the last call of `execute`
|
||||||
* @param {any} state The current state to perform the undo on
|
* @param {State} state The current state to perform the undo on
|
||||||
* @returns {any} The state with the last changes reverted
|
* @returns {State} The state with the last changes reverted
|
||||||
*/
|
*/
|
||||||
undo(state) {
|
undo(state: State): State {
|
||||||
if (state && this.changes) {
|
if (state && this.changes) {
|
||||||
let revertedState = cloneDeep(state);
|
let revertedState = cloneDeep(state);
|
||||||
revertChanges(revertedState, this.changes);
|
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";
|
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);
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
|
||||||
return (
|
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, DividerProps } from "theme-ui";
|
||||||
import { Divider } from "theme-ui";
|
|
||||||
|
|
||||||
function StyledDivider({ vertical, color, fill }) {
|
type StyledDividerProps = {
|
||||||
|
vertical: boolean;
|
||||||
|
fill: boolean;
|
||||||
|
} & DividerProps;
|
||||||
|
|
||||||
|
function StyledDivider({
|
||||||
|
vertical,
|
||||||
|
color,
|
||||||
|
fill,
|
||||||
|
...props
|
||||||
|
}: StyledDividerProps) {
|
||||||
return (
|
return (
|
||||||
<Divider
|
<Divider
|
||||||
my={vertical ? 0 : 2}
|
my={vertical ? 0 : 2}
|
||||||
@ -13,6 +22,7 @@ function StyledDivider({ vertical, color, fill }) {
|
|||||||
borderRadius: "2px",
|
borderRadius: "2px",
|
||||||
opacity: 0.5,
|
opacity: 0.5,
|
||||||
}}
|
}}
|
||||||
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import { Flex } from "theme-ui";
|
import { Flex } from "theme-ui";
|
||||||
|
|
||||||
import Link from "./Link";
|
import Link from "./Link";
|
@ -1,8 +1,7 @@
|
|||||||
import React from "react";
|
import { Link as ThemeLink, LinkProps } from "theme-ui";
|
||||||
import { Link as ThemeLink } from "theme-ui";
|
|
||||||
import { HashLink as RouterLink } from "react-router-hash-link";
|
import { HashLink as RouterLink } from "react-router-hash-link";
|
||||||
|
|
||||||
function Link({ to, ...rest }) {
|
function Link({ to, ...rest }: { to: string } & LinkProps) {
|
||||||
return (
|
return (
|
||||||
<RouterLink to={to}>
|
<RouterLink to={to}>
|
||||||
<ThemeLink as="span" {...rest} />
|
<ThemeLink as="span" {...rest} />
|
@ -1,9 +1,14 @@
|
|||||||
import React, { useEffect, useRef } from "react";
|
import React, { useEffect, useRef } from "react";
|
||||||
import { Progress } from "theme-ui";
|
import { Progress } from "theme-ui";
|
||||||
|
|
||||||
function LoadingBar({ isLoading, loadingProgressRef }) {
|
type LoadingBarProps = {
|
||||||
const requestRef = useRef();
|
isLoading: boolean;
|
||||||
const progressBarRef = useRef();
|
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
|
// Use an animation frame to update the progress bar
|
||||||
// This bypasses react allowing the animation to be smooth
|
// This bypasses react allowing the animation to be smooth
|
||||||
@ -21,7 +26,9 @@ function LoadingBar({ isLoading, loadingProgressRef }) {
|
|||||||
requestRef.current = requestAnimationFrame(animate);
|
requestRef.current = requestAnimationFrame(animate);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
if (requestRef.current !== undefined) {
|
||||||
cancelAnimationFrame(requestRef.current);
|
cancelAnimationFrame(requestRef.current);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isLoading]);
|
}, [isLoading]);
|
@ -3,7 +3,12 @@ import { Box } from "theme-ui";
|
|||||||
|
|
||||||
import Spinner from "./Spinner";
|
import Spinner from "./Spinner";
|
||||||
|
|
||||||
function LoadingOverlay({ bg, children }) {
|
type LoadingOverlayProps = {
|
||||||
|
bg: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
function LoadingOverlay({ bg, children }: LoadingOverlayProps) {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
@ -1,24 +1,26 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import {
|
import {
|
||||||
Text,
|
Text,
|
||||||
|
TextProps,
|
||||||
Image as UIImage,
|
Image as UIImage,
|
||||||
|
ImageProps,
|
||||||
Link as UILink,
|
Link as UILink,
|
||||||
Message,
|
Message,
|
||||||
Embed,
|
Box,
|
||||||
} from "theme-ui";
|
} from "theme-ui";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
|
|
||||||
function Paragraph(props) {
|
function Paragraph(props: TextProps) {
|
||||||
return <Text as="p" my={2} variant="body2" {...props} />;
|
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;
|
const fontSize = level === 1 ? 5 : level === 2 ? 3 : 1;
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
mt={2}
|
mt={2}
|
||||||
mb={1}
|
mb={1}
|
||||||
as={`h${level}`}
|
as={`h${level}` as React.ElementType}
|
||||||
sx={{ fontSize }}
|
sx={{ fontSize }}
|
||||||
variant="heading"
|
variant="heading"
|
||||||
{...props}
|
{...props}
|
||||||
@ -26,11 +28,37 @@ function Heading({ level, ...props }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Image(props) {
|
function Image(props: ImageProps) {
|
||||||
if (props.alt === "embed:") {
|
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 (
|
return (
|
||||||
<video
|
<video
|
||||||
style={{ width: "100%", margin: "8px 0" }}
|
style={{ width: "100%", margin: "8px 0" }}
|
||||||
@ -39,7 +67,7 @@ function Image(props) {
|
|||||||
playsInline
|
playsInline
|
||||||
loop
|
loop
|
||||||
controls
|
controls
|
||||||
{...props}
|
src={props.src}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -47,11 +75,17 @@ function Image(props) {
|
|||||||
return <UIImage mt={2} sx={{ borderRadius: "4px" }} {...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} />;
|
return <Text as="li" variant="body2" my={1} {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Code({ children, value }) {
|
function Code({
|
||||||
|
children,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
let variant = "";
|
let variant = "";
|
||||||
if (value.startsWith("Warning:")) {
|
if (value.startsWith("Warning:")) {
|
||||||
variant = "warning";
|
variant = "warning";
|
||||||
@ -71,7 +105,7 @@ function Code({ children, value }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Table({ children }) {
|
function Table({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
as="table"
|
as="table"
|
||||||
@ -83,7 +117,7 @@ function Table({ children }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableHead(props) {
|
function TableHead(props: TextProps) {
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
as="thead"
|
as="thead"
|
||||||
@ -94,7 +128,7 @@ function TableHead(props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableBody(props) {
|
function TableBody(props: TextProps) {
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
as="tbody"
|
as="tbody"
|
||||||
@ -105,7 +139,7 @@ function TableBody(props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableRow({ children }) {
|
function TableRow({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
as="tr"
|
as="tr"
|
||||||
@ -119,7 +153,7 @@ function TableRow({ children }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TableCell({ children }) {
|
function TableCell({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<Text as="td" p={2}>
|
<Text as="td" p={2}>
|
||||||
{children}
|
{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>;
|
return <UILink href={href}>{children}</UILink>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Markdown({ source, assets }) {
|
function Markdown({
|
||||||
|
source,
|
||||||
|
assets,
|
||||||
|
}: {
|
||||||
|
source: string;
|
||||||
|
assets: Record<string, string>;
|
||||||
|
}) {
|
||||||
const renderers = {
|
const renderers = {
|
||||||
paragraph: Paragraph,
|
paragraph: Paragraph,
|
||||||
heading: Heading,
|
heading: Heading,
|
@ -1,8 +1,13 @@
|
|||||||
import React from "react";
|
import React, { ReactChild } from "react";
|
||||||
import Modal from "react-modal";
|
import Modal, { Props } from "react-modal";
|
||||||
import { useThemeUI, Close } from "theme-ui";
|
import { useThemeUI, Close } from "theme-ui";
|
||||||
|
|
||||||
import { useSpring, animated, config } from "react-spring";
|
import { useSpring, animated, config } from "react-spring";
|
||||||
|
import CSS from "csstype";
|
||||||
|
|
||||||
|
type ModalProps = Props & {
|
||||||
|
children: ReactChild | ReactChild[];
|
||||||
|
allowClose: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
function StyledModal({
|
function StyledModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
@ -11,7 +16,7 @@ function StyledModal({
|
|||||||
allowClose,
|
allowClose,
|
||||||
style,
|
style,
|
||||||
...props
|
...props
|
||||||
}) {
|
}: ModalProps) {
|
||||||
const { theme } = useThemeUI();
|
const { theme } = useThemeUI();
|
||||||
|
|
||||||
const openAnimation = useSpring({
|
const openAnimation = useSpring({
|
||||||
@ -31,16 +36,17 @@ function StyledModal({
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
...(style?.overlay || {}),
|
||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
backgroundColor: theme.colors.background,
|
backgroundColor: theme.colors?.background as CSS.Property.Color,
|
||||||
top: "initial",
|
top: "initial",
|
||||||
left: "initial",
|
left: "initial",
|
||||||
bottom: "initial",
|
bottom: "initial",
|
||||||
right: "initial",
|
right: "initial",
|
||||||
maxHeight: "100%",
|
maxHeight: "100%",
|
||||||
...style,
|
...(style?.content || {}),
|
||||||
},
|
} as React.CSSProperties,
|
||||||
}}
|
}}
|
||||||
contentElement={(props, content) => (
|
contentElement={(props, content) => (
|
||||||
<animated.div {...props} style={{ ...props.style, ...openAnimation }}>
|
<animated.div {...props} style={{ ...props.style, ...openAnimation }}>
|
@ -1,7 +1,17 @@
|
|||||||
import React from "react";
|
import { IconButton, IconButtonProps } from "theme-ui";
|
||||||
import { IconButton } 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 (
|
return (
|
||||||
<IconButton
|
<IconButton
|
||||||
aria-label={title}
|
aria-label={title}
|
||||||
@ -9,6 +19,7 @@ function RadioIconButton({ title, onClick, isSelected, disabled, children }) {
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
sx={{ color: isSelected ? "primary" : "text" }}
|
sx={{ color: isSelected ? "primary" : "text" }}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</IconButton>
|
</IconButton>
|
@ -1,9 +1,8 @@
|
|||||||
import React from "react";
|
import { Box, Input, InputProps } from "theme-ui";
|
||||||
import { Box, Input } from "theme-ui";
|
|
||||||
|
|
||||||
import SearchIcon from "../icons/SearchIcon";
|
import SearchIcon from "../icons/SearchIcon";
|
||||||
|
|
||||||
function Search(props) {
|
function Search(props: InputProps) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ position: "relative", flexGrow: 1 }}>
|
<Box sx={{ position: "relative", flexGrow: 1 }}>
|
||||||
<Input
|
<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 { IconButton } from "theme-ui";
|
||||||
|
|
||||||
import SettingsIcon from "../icons/SettingsIcon";
|
import SettingsIcon from "../icons/SettingsIcon";
|
@ -1,10 +1,25 @@
|
|||||||
import React, { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Box, Slider as ThemeSlider } from "theme-ui";
|
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 percentValue = ((value - min) * 100) / (max - min);
|
||||||
|
|
||||||
const [labelVisible, setLabelVisible] = useState(false);
|
const [labelVisible, setLabelVisible] = useState<boolean>(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ position: "relative" }} ml={ml} mr={mr}>
|
<Box sx={{ position: "relative" }} ml={ml} mr={mr}>
|
||||||
@ -63,7 +78,7 @@ Slider.defaultProps = {
|
|||||||
value: 0,
|
value: 0,
|
||||||
ml: 0,
|
ml: 0,
|
||||||
mr: 0,
|
mr: 0,
|
||||||
labelFunc: (value) => value,
|
labelFunc: (value: number) => value,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Slider;
|
export default Slider;
|
@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import { Box } from "theme-ui";
|
import { Box } from "theme-ui";
|
||||||
|
|
||||||
import "./Spinner.css";
|
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 { Box, Text } from "theme-ui";
|
||||||
import { ToastProvider as DefaultToastProvider } from "react-toast-notifications";
|
import { ToastProvider as DefaultToastProvider } from "react-toast-notifications";
|
||||||
|
|
||||||
function CustomToast({ children }) {
|
function CustomToast({ children }: { children?: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
m={2}
|
m={2}
|
||||||
@ -17,7 +17,7 @@ function CustomToast({ children }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ToastProvider({ children }) {
|
export function ToastProvider({ children }: { children?: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<DefaultToastProvider
|
<DefaultToastProvider
|
||||||
components={{ Toast: CustomToast }}
|
components={{ Toast: CustomToast }}
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Text } from "theme-ui";
|
import { Text } from "theme-ui";
|
||||||
|
|
||||||
import LoadingOverlay from "./LoadingOverlay";
|
import LoadingOverlay from "./LoadingOverlay";
|
||||||
@ -21,7 +21,7 @@ const facts = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
function UpgradingLoadingOverlay() {
|
function UpgradingLoadingOverlay() {
|
||||||
const [subText, setSubText] = useState();
|
const [subText, setSubText] = useState<string>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let index = 0;
|
let index = 0;
|
||||||
@ -33,7 +33,7 @@ function UpgradingLoadingOverlay() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Show first fact after 10 seconds then every 20 seconds after that
|
// Show first fact after 10 seconds then every 20 seconds after that
|
||||||
let interval;
|
let interval: NodeJS.Timeout;
|
||||||
let timeout = setTimeout(() => {
|
let timeout = setTimeout(() => {
|
||||||
updateFact();
|
updateFact();
|
||||||
interval = setInterval(() => {
|
interval = setInterval(() => {
|
@ -1,6 +1,15 @@
|
|||||||
import React from "react";
|
|
||||||
import Modal from "react-modal";
|
import Modal from "react-modal";
|
||||||
import { useThemeUI, Close } from "theme-ui";
|
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({
|
function Banner({
|
||||||
isOpen,
|
isOpen,
|
||||||
@ -8,7 +17,7 @@ function Banner({
|
|||||||
children,
|
children,
|
||||||
allowClose,
|
allowClose,
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
}) {
|
}: BannerProps) {
|
||||||
const { theme } = useThemeUI();
|
const { theme } = useThemeUI();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -18,7 +27,8 @@ function Banner({
|
|||||||
style={{
|
style={{
|
||||||
overlay: { bottom: "0", top: "initial", zIndex: 2000 },
|
overlay: { bottom: "0", top: "initial", zIndex: 2000 },
|
||||||
content: {
|
content: {
|
||||||
backgroundColor: backgroundColor || theme.colors.highlight,
|
backgroundColor:
|
||||||
|
backgroundColor || (theme.colors?.highlight as CSS.Property.Color),
|
||||||
color: "hsl(210, 50%, 96%)",
|
color: "hsl(210, 50%, 96%)",
|
||||||
top: "initial",
|
top: "initial",
|
||||||
left: "50%",
|
left: "50%",
|
@ -1,9 +1,15 @@
|
|||||||
import React from "react";
|
|
||||||
import { Box, Text } from "theme-ui";
|
import { Box, Text } from "theme-ui";
|
||||||
|
|
||||||
import Banner from "./Banner";
|
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 (
|
return (
|
||||||
<Banner isOpen={!!error} onRequestClose={onRequestClose}>
|
<Banner isOpen={!!error} onRequestClose={onRequestClose}>
|
||||||
<Box p={1}>
|
<Box p={1}>
|
@ -1,10 +1,9 @@
|
|||||||
import React from "react";
|
|
||||||
import { Flex } from "theme-ui";
|
import { Flex } from "theme-ui";
|
||||||
|
|
||||||
import Banner from "./Banner";
|
import Banner from "./Banner";
|
||||||
import OfflineIcon from "../../icons/OfflineIcon";
|
import OfflineIcon from "../../icons/OfflineIcon";
|
||||||
|
|
||||||
function OfflineBanner({ isOpen }) {
|
function OfflineBanner({ isOpen }: { isOpen: boolean }) {
|
||||||
return (
|
return (
|
||||||
<Banner
|
<Banner
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
@ -1,10 +1,9 @@
|
|||||||
import React from "react";
|
|
||||||
import { Flex } from "theme-ui";
|
import { Flex } from "theme-ui";
|
||||||
|
|
||||||
import Banner from "./Banner";
|
import Banner from "./Banner";
|
||||||
import ReconnectingIcon from "../../icons/ReconnectingIcon";
|
import ReconnectingIcon from "../../icons/ReconnectingIcon";
|
||||||
|
|
||||||
function ReconnectBanner({ isOpen }) {
|
function ReconnectBanner({ isOpen }: { isOpen: boolean }) {
|
||||||
return (
|
return (
|
||||||
<Banner
|
<Banner
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
@ -1,39 +1,49 @@
|
|||||||
import React, { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Flex, IconButton } from "theme-ui";
|
import { Flex } from "theme-ui";
|
||||||
import { useMedia } from "react-media";
|
import { useMedia } from "react-media";
|
||||||
|
|
||||||
import RadioIconButton from "../../RadioIconButton";
|
import RadioIconButton from "../RadioIconButton";
|
||||||
|
|
||||||
import ColorControl from "./ColorControl";
|
import ColorControl from "./shared/ColorControl";
|
||||||
import AlphaBlendToggle from "./AlphaBlendToggle";
|
import AlphaBlendToggle from "./shared/AlphaBlendToggle";
|
||||||
import ToolSection from "./ToolSection";
|
import ToolSection from "./shared/ToolSection";
|
||||||
|
import ShapeFillToggle from "./shared/ShapeFillToggle";
|
||||||
|
|
||||||
import BrushIcon from "../../../icons/BrushToolIcon";
|
import BrushIcon from "../../icons/BrushToolIcon";
|
||||||
import BrushPaintIcon from "../../../icons/BrushPaintIcon";
|
import BrushPaintIcon from "../../icons/BrushPaintIcon";
|
||||||
import BrushLineIcon from "../../../icons/BrushLineIcon";
|
import BrushLineIcon from "../../icons/BrushLineIcon";
|
||||||
import BrushRectangleIcon from "../../../icons/BrushRectangleIcon";
|
import BrushRectangleIcon from "../../icons/BrushRectangleIcon";
|
||||||
import BrushCircleIcon from "../../../icons/BrushCircleIcon";
|
import BrushCircleIcon from "../../icons/BrushCircleIcon";
|
||||||
import BrushTriangleIcon from "../../../icons/BrushTriangleIcon";
|
import BrushTriangleIcon from "../../icons/BrushTriangleIcon";
|
||||||
import EraseAllIcon from "../../../icons/EraseAllIcon";
|
import EraseIcon from "../../icons/EraseToolIcon";
|
||||||
import EraseIcon from "../../../icons/EraseToolIcon";
|
|
||||||
|
|
||||||
import UndoButton from "./UndoButton";
|
import Divider from "../Divider";
|
||||||
import RedoButton from "./RedoButton";
|
|
||||||
|
|
||||||
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({
|
function DrawingToolSettings({
|
||||||
settings,
|
settings,
|
||||||
onSettingChange,
|
onSettingChange,
|
||||||
onToolAction,
|
onToolAction,
|
||||||
disabledActions,
|
disabledActions,
|
||||||
}) {
|
}: DrawingToolSettingsProps) {
|
||||||
// Keyboard shotcuts
|
// Keyboard shotcuts
|
||||||
function handleKeyDown(event) {
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
if (shortcuts.drawBrush(event)) {
|
if (shortcuts.drawBrush(event)) {
|
||||||
onSettingChange({ type: "brush" });
|
onSettingChange({ type: "brush" });
|
||||||
} else if (shortcuts.drawPaint(event)) {
|
} else if (shortcuts.drawPaint(event)) {
|
||||||
@ -50,10 +60,8 @@ function DrawingToolSettings({
|
|||||||
onSettingChange({ type: "erase" });
|
onSettingChange({ type: "erase" });
|
||||||
} else if (shortcuts.drawBlend(event)) {
|
} else if (shortcuts.drawBlend(event)) {
|
||||||
onSettingChange({ useBlending: !settings.useBlending });
|
onSettingChange({ useBlending: !settings.useBlending });
|
||||||
} else if (shortcuts.redo(event) && !disabledActions.includes("redo")) {
|
} else if (shortcuts.drawFill(event)) {
|
||||||
onToolAction("mapRedo");
|
onSettingChange({ useShapeFill: !settings.useShapeFill });
|
||||||
} else if (shortcuts.undo(event) && !disabledActions.includes("undo")) {
|
|
||||||
onToolAction("mapUndo");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
useKeyboard(handleKeyDown);
|
useKeyboard(handleKeyDown);
|
||||||
@ -111,11 +119,14 @@ function DrawingToolSettings({
|
|||||||
<ColorControl
|
<ColorControl
|
||||||
color={settings.color}
|
color={settings.color}
|
||||||
onColorChange={(color) => onSettingChange({ color })}
|
onColorChange={(color) => onSettingChange({ color })}
|
||||||
|
exclude={["primary"]}
|
||||||
/>
|
/>
|
||||||
<Divider vertical />
|
<Divider vertical />
|
||||||
<ToolSection
|
<ToolSection
|
||||||
tools={tools}
|
tools={tools}
|
||||||
onToolClick={(tool) => onSettingChange({ type: tool.id })}
|
onToolClick={(tool) =>
|
||||||
|
onSettingChange({ type: tool.id as DrawingToolType })
|
||||||
|
}
|
||||||
collapse={isSmallScreen}
|
collapse={isSmallScreen}
|
||||||
/>
|
/>
|
||||||
<Divider vertical />
|
<Divider vertical />
|
||||||
@ -127,27 +138,18 @@ function DrawingToolSettings({
|
|||||||
>
|
>
|
||||||
<EraseIcon />
|
<EraseIcon />
|
||||||
</RadioIconButton>
|
</RadioIconButton>
|
||||||
<IconButton
|
<EraseAllButton
|
||||||
aria-label="Erase All"
|
onToolAction={onToolAction}
|
||||||
title="Erase All"
|
|
||||||
onClick={() => onToolAction("eraseAll")}
|
|
||||||
disabled={disabledActions.includes("erase")}
|
disabled={disabledActions.includes("erase")}
|
||||||
>
|
/>
|
||||||
<EraseAllIcon />
|
|
||||||
</IconButton>
|
|
||||||
<Divider vertical />
|
<Divider vertical />
|
||||||
<AlphaBlendToggle
|
<AlphaBlendToggle
|
||||||
useBlending={settings.useBlending}
|
useBlending={settings.useBlending}
|
||||||
onBlendingChange={(useBlending) => onSettingChange({ useBlending })}
|
onBlendingChange={(useBlending) => onSettingChange({ useBlending })}
|
||||||
/>
|
/>
|
||||||
<Divider vertical />
|
<ShapeFillToggle
|
||||||
<UndoButton
|
useShapeFill={settings.useShapeFill}
|
||||||
onClick={() => onToolAction("mapUndo")}
|
onShapeFillChange={(useShapeFill) => onSettingChange({ useShapeFill })}
|
||||||
disabled={disabledActions.includes("undo")}
|
|
||||||
/>
|
|
||||||
<RedoButton
|
|
||||||
onClick={() => onToolAction("mapRedo")}
|
|
||||||
disabled={disabledActions.includes("redo")}
|
|
||||||
/>
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
@ -1,37 +1,39 @@
|
|||||||
import React from "react";
|
|
||||||
import { Flex } from "theme-ui";
|
import { Flex } from "theme-ui";
|
||||||
import { useMedia } from "react-media";
|
import { useMedia } from "react-media";
|
||||||
|
|
||||||
import RadioIconButton from "../../RadioIconButton";
|
import RadioIconButton from "../RadioIconButton";
|
||||||
|
|
||||||
import MultilayerToggle from "./MultilayerToggle";
|
import MultilayerToggle from "./shared/MultilayerToggle";
|
||||||
import FogPreviewToggle from "./FogPreviewToggle";
|
import FogPreviewToggle from "./shared/FogPreviewToggle";
|
||||||
import FogCutToggle from "./FogCutToggle";
|
import FogCutToggle from "./shared/FogCutToggle";
|
||||||
|
|
||||||
import FogBrushIcon from "../../../icons/FogBrushIcon";
|
import FogBrushIcon from "../../icons/FogBrushIcon";
|
||||||
import FogPolygonIcon from "../../../icons/FogPolygonIcon";
|
import FogPolygonIcon from "../../icons/FogPolygonIcon";
|
||||||
import FogRemoveIcon from "../../../icons/FogRemoveIcon";
|
import FogRemoveIcon from "../../icons/FogRemoveIcon";
|
||||||
import FogToggleIcon from "../../../icons/FogToggleIcon";
|
import FogToggleIcon from "../../icons/FogToggleIcon";
|
||||||
import FogRectangleIcon from "../../../icons/FogRectangleIcon";
|
import FogRectangleIcon from "../../icons/FogRectangleIcon";
|
||||||
|
|
||||||
import UndoButton from "./UndoButton";
|
import ToolSection from "./shared/ToolSection";
|
||||||
import RedoButton from "./RedoButton";
|
|
||||||
import ToolSection from "./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({
|
import {
|
||||||
settings,
|
FogToolSettings as FogToolSettingsType,
|
||||||
onSettingChange,
|
FogToolType,
|
||||||
onToolAction,
|
} from "../../types/Fog";
|
||||||
disabledActions,
|
|
||||||
}) {
|
type FogToolSettingsProps = {
|
||||||
|
settings: FogToolSettingsType;
|
||||||
|
onSettingChange: (change: Partial<FogToolSettingsType>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function FogToolSettings({ settings, onSettingChange }: FogToolSettingsProps) {
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
function handleKeyDown(event) {
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
if (shortcuts.fogPolygon(event)) {
|
if (shortcuts.fogPolygon(event)) {
|
||||||
onSettingChange({ type: "polygon" });
|
onSettingChange({ type: "polygon" });
|
||||||
} else if (shortcuts.fogBrush(event)) {
|
} else if (shortcuts.fogBrush(event)) {
|
||||||
@ -48,10 +50,6 @@ function BrushToolSettings({
|
|||||||
onSettingChange({ useFogCut: !settings.useFogCut });
|
onSettingChange({ useFogCut: !settings.useFogCut });
|
||||||
} else if (shortcuts.fogRectangle(event)) {
|
} else if (shortcuts.fogRectangle(event)) {
|
||||||
onSettingChange({ type: "rectangle" });
|
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" }}>
|
<Flex sx={{ alignItems: "center" }}>
|
||||||
<ToolSection
|
<ToolSection
|
||||||
tools={drawTools}
|
tools={drawTools}
|
||||||
onToolClick={(tool) => onSettingChange({ type: tool.id })}
|
onToolClick={(tool) =>
|
||||||
|
onSettingChange({ type: tool.id as FogToolType })
|
||||||
|
}
|
||||||
collapse={isSmallScreen}
|
collapse={isSmallScreen}
|
||||||
/>
|
/>
|
||||||
<Divider vertical />
|
<Divider vertical />
|
||||||
@ -121,17 +121,8 @@ function BrushToolSettings({
|
|||||||
useFogPreview={settings.preview}
|
useFogPreview={settings.preview}
|
||||||
onFogPreviewChange={(preview) => onSettingChange({ preview })}
|
onFogPreviewChange={(preview) => onSettingChange({ preview })}
|
||||||
/>
|
/>
|
||||||
<Divider vertical />
|
|
||||||
<UndoButton
|
|
||||||
onClick={() => onToolAction("fogUndo")}
|
|
||||||
disabled={disabledActions.includes("undo")}
|
|
||||||
/>
|
|
||||||
<RedoButton
|
|
||||||
onClick={() => onToolAction("fogRedo")}
|
|
||||||
disabled={disabledActions.includes("redo")}
|
|
||||||
/>
|
|
||||||
</Flex>
|
</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 { IconButton } from "theme-ui";
|
||||||
|
|
||||||
import BlendOnIcon from "../../../icons/BlendOnIcon";
|
import BlendOnIcon from "../../../icons/BlendOnIcon";
|
||||||
import BlendOffIcon from "../../../icons/BlendOffIcon";
|
import BlendOffIcon from "../../../icons/BlendOffIcon";
|
||||||
|
|
||||||
function AlphaBlendToggle({ useBlending, onBlendingChange }) {
|
type AlphaBlendToggleProps = {
|
||||||
|
useBlending: boolean;
|
||||||
|
onBlendingChange: (useBlending: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function AlphaBlendToggle({
|
||||||
|
useBlending,
|
||||||
|
onBlendingChange,
|
||||||
|
}: AlphaBlendToggleProps) {
|
||||||
return (
|
return (
|
||||||
<IconButton
|
<IconButton
|
||||||
aria-label={useBlending ? "Disable Blending (O)" : "Enable Blending (O)"}
|
aria-label={useBlending ? "Disable Blending (O)" : "Enable Blending (O)"}
|
@ -1,10 +1,16 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Box } from "theme-ui";
|
import { Box, SxProp } from "theme-ui";
|
||||||
|
|
||||||
import colors, { colorOptions } from "../../../helpers/colors";
|
import colors, { colorOptions, Color } from "../../../helpers/colors";
|
||||||
import MapMenu from "../MapMenu";
|
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 (
|
return (
|
||||||
<Box
|
<Box
|
||||||
key={color}
|
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 [showColorMenu, setShowColorMenu] = useState(false);
|
||||||
const [colorMenuOptions, setColorMenuOptions] = useState({});
|
const [colorMenuOptions, setColorMenuOptions] = useState({});
|
||||||
|
|
||||||
function handleControlClick(event) {
|
function handleControlClick(event: React.MouseEvent<HTMLDivElement>) {
|
||||||
if (showColorMenu) {
|
if (showColorMenu) {
|
||||||
setShowColorMenu(false);
|
setShowColorMenu(false);
|
||||||
setColorMenuOptions({});
|
setColorMenuOptions({});
|
||||||
} else {
|
} else {
|
||||||
setShowColorMenu(true);
|
setShowColorMenu(true);
|
||||||
const rect = event.target.getBoundingClientRect();
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
setColorMenuOptions({
|
setColorMenuOptions({
|
||||||
// Align the right of the submenu to the left of the tool and center vertically
|
// Align the right of the submenu to the left of the tool and center vertically
|
||||||
left: `${rect.left + rect.width / 2}px`,
|
left: `${rect.left + rect.width / 2}px`,
|
||||||
top: `${rect.bottom + 16}px`,
|
top: `${rect.bottom + 16}px`,
|
||||||
style: { transform: "translateX(-50%)" },
|
style: { transform: "translateX(-50%)" },
|
||||||
// Exclude this node from the sub menus auto close
|
// 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 { IconButton } from "theme-ui";
|
||||||
|
|
||||||
import CutOnIcon from "../../../icons/FogCutOnIcon";
|
import CutOnIcon from "../../../icons/FogCutOnIcon";
|
||||||
import CutOffIcon from "../../../icons/FogCutOffIcon";
|
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 (
|
return (
|
||||||
<IconButton
|
<IconButton
|
||||||
aria-label={
|
aria-label={
|
@ -1,10 +1,17 @@
|
|||||||
import React from "react";
|
|
||||||
import { IconButton } from "theme-ui";
|
import { IconButton } from "theme-ui";
|
||||||
|
|
||||||
import PreviewOnIcon from "../../../icons/FogPreviewOnIcon";
|
import PreviewOnIcon from "../../../icons/FogPreviewOnIcon";
|
||||||
import PreviewOffIcon from "../../../icons/FogPreviewOffIcon";
|
import PreviewOffIcon from "../../../icons/FogPreviewOffIcon";
|
||||||
|
|
||||||
function FogPreviewToggle({ useFogPreview, onFogPreviewChange }) {
|
type FogPreviewToggleProps = {
|
||||||
|
useFogPreview: boolean;
|
||||||
|
onFogPreviewChange: (useFogCut: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function FogPreviewToggle({
|
||||||
|
useFogPreview,
|
||||||
|
onFogPreviewChange,
|
||||||
|
}: FogPreviewToggleProps) {
|
||||||
return (
|
return (
|
||||||
<IconButton
|
<IconButton
|
||||||
aria-label={
|
aria-label={
|
@ -1,10 +1,19 @@
|
|||||||
import React from "react";
|
|
||||||
import { IconButton } from "theme-ui";
|
import { IconButton } from "theme-ui";
|
||||||
|
|
||||||
import MultilayerOnIcon from "../../../icons/FogMultilayerOnIcon";
|
import MultilayerOnIcon from "../../../icons/FogMultilayerOnIcon";
|
||||||
import MultilayerOffIcon from "../../../icons/FogMultilayerOffIcon";
|
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 (
|
return (
|
||||||
<IconButton
|
<IconButton
|
||||||
aria-label={
|
aria-label={
|
@ -5,7 +5,12 @@ import RedoIcon from "../../../icons/RedoIcon";
|
|||||||
|
|
||||||
import { isMacLike } from "../../../helpers/shared";
|
import { isMacLike } from "../../../helpers/shared";
|
||||||
|
|
||||||
function RedoButton({ onClick, disabled }) {
|
type RedoButtonProps = {
|
||||||
|
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function RedoButton({ onClick, disabled }: RedoButtonProps) {
|
||||||
return (
|
return (
|
||||||
<IconButton
|
<IconButton
|
||||||
title={`Redo (${isMacLike ? "Cmd" : "Ctrl"} + Shift + Z)`}
|
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";
|
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
|
// 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 [showMore, setShowMore] = useState(false);
|
||||||
const [collapsedTool, setCollapsedTool] = useState();
|
const [collapsedTool, setCollapsedTool] = useState<Tool>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const selectedTool = tools.find((tool) => tool.isSelected);
|
const selectedTool = tools.find((tool) => tool.isSelected);
|
||||||
@ -20,7 +34,7 @@ function ToolSection({ collapse, tools, onToolClick }) {
|
|||||||
}
|
}
|
||||||
}, [tools]);
|
}, [tools]);
|
||||||
|
|
||||||
function handleToolClick(tool) {
|
function handleToolClick(tool: Tool) {
|
||||||
if (collapse && tool.isSelected) {
|
if (collapse && tool.isSelected) {
|
||||||
setShowMore(!showMore);
|
setShowMore(!showMore);
|
||||||
} else if (collapse && !tool.isSelected) {
|
} else if (collapse && !tool.isSelected) {
|
||||||
@ -29,7 +43,7 @@ function ToolSection({ collapse, tools, onToolClick }) {
|
|||||||
onToolClick(tool);
|
onToolClick(tool);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTool(tool) {
|
function renderTool(tool: Tool) {
|
||||||
return (
|
return (
|
||||||
<RadioIconButton
|
<RadioIconButton
|
||||||
title={tool.title}
|
title={tool.title}
|
||||||
@ -85,7 +99,9 @@ function ToolSection({ collapse, tools, onToolClick }) {
|
|||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return tools.map((tool) => (
|
return (
|
||||||
|
<>
|
||||||
|
{tools.map((tool) => (
|
||||||
<RadioIconButton
|
<RadioIconButton
|
||||||
title={tool.title}
|
title={tool.title}
|
||||||
onClick={() => handleToolClick(tool)}
|
onClick={() => handleToolClick(tool)}
|
||||||
@ -95,7 +111,9 @@ function ToolSection({ collapse, tools, onToolClick }) {
|
|||||||
>
|
>
|
||||||
{tool.icon}
|
{tool.icon}
|
||||||
</RadioIconButton>
|
</RadioIconButton>
|
||||||
));
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -5,7 +5,12 @@ import UndoIcon from "../../../icons/UndoIcon";
|
|||||||
|
|
||||||
import { isMacLike } from "../../../helpers/shared";
|
import { isMacLike } from "../../../helpers/shared";
|
||||||
|
|
||||||
function UndoButton({ onClick, disabled }) {
|
type UndoButtonProps = {
|
||||||
|
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function UndoButton({ onClick, disabled }: UndoButtonProps) {
|
||||||
return (
|
return (
|
||||||
<IconButton
|
<IconButton
|
||||||
title={`Undo (${isMacLike ? "Cmd" : "Ctrl"} + Z)`}
|
title={`Undo (${isMacLike ? "Cmd" : "Ctrl"} + Z)`}
|
@ -3,7 +3,21 @@ import { IconButton } from "theme-ui";
|
|||||||
|
|
||||||
import Count from "./DiceButtonCount";
|
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 (
|
return (
|
||||||
<IconButton
|
<IconButton
|
||||||
title={title}
|
title={title}
|
@ -1,7 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Box, Text } from "theme-ui";
|
import { Box, Text } from "theme-ui";
|
||||||
|
|
||||||
function DiceButtonCount({ children }) {
|
function DiceButtonCount({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Flex, IconButton, Box } from "theme-ui";
|
import { Flex, IconButton, Box } from "theme-ui";
|
||||||
import SimpleBar from "simplebar-react";
|
import SimpleBar from "simplebar-react";
|
||||||
|
|
||||||
@ -18,9 +18,25 @@ import SelectDiceButton from "./SelectDiceButton";
|
|||||||
|
|
||||||
import Divider from "../Divider";
|
import Divider from "../Divider";
|
||||||
|
|
||||||
|
import Dice from "../../dice/Dice";
|
||||||
|
|
||||||
import { dice } from "../../dice";
|
import { dice } from "../../dice";
|
||||||
import useSetting from "../../hooks/useSetting";
|
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({
|
function DiceButtons({
|
||||||
diceRolls,
|
diceRolls,
|
||||||
onDiceAdd,
|
onDiceAdd,
|
||||||
@ -30,29 +46,32 @@ function DiceButtons({
|
|||||||
shareDice,
|
shareDice,
|
||||||
onShareDiceChange,
|
onShareDiceChange,
|
||||||
loading,
|
loading,
|
||||||
}) {
|
}: DiceButtonsProps) {
|
||||||
const [currentDiceStyle, setCurrentDiceStyle] = useSetting("dice.style");
|
const [currentDiceStyle, setCurrentDiceStyle] = useSetting("dice.style");
|
||||||
const [currentDice, setCurrentDice] = useState(
|
const [currentDice, setCurrentDice] = useState<DefaultDice>(
|
||||||
dice.find((d) => d.key === currentDiceStyle)
|
dice.find((d) => d.key === currentDiceStyle) || dice[0]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initialDice = dice.find((d) => d.key === currentDiceStyle);
|
const initialDice = dice.find((d) => d.key === currentDiceStyle);
|
||||||
|
if (initialDice) {
|
||||||
onDiceLoad(initialDice);
|
onDiceLoad(initialDice);
|
||||||
setCurrentDice(initialDice);
|
setCurrentDice(initialDice);
|
||||||
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const diceCounts = {};
|
const diceCounts: Partial<Record<DiceType, number>> = {};
|
||||||
for (let dice of diceRolls) {
|
for (let dice of diceRolls) {
|
||||||
if (dice.type in diceCounts) {
|
if (dice.type in diceCounts) {
|
||||||
diceCounts[dice.type] += 1;
|
// TODO: Check type
|
||||||
|
diceCounts[dice.type]! += 1;
|
||||||
} else {
|
} else {
|
||||||
diceCounts[dice.type] = 1;
|
diceCounts[dice.type] = 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDiceChange(dice) {
|
async function handleDiceChange(dice: DefaultDice) {
|
||||||
await onDiceLoad(dice);
|
await onDiceLoad(dice);
|
||||||
setCurrentDice(dice);
|
setCurrentDice(dice);
|
||||||
setCurrentDiceStyle(dice.key);
|
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 { Engine } from "@babylonjs/core/Engines/engine";
|
||||||
import { Scene } from "@babylonjs/core/scene";
|
import { Scene } from "@babylonjs/core/scene";
|
||||||
import { Vector3, Color4, Matrix } from "@babylonjs/core/Maths/math";
|
import { Vector3, Color4, Matrix } from "@babylonjs/core/Maths/math";
|
||||||
import { AmmoJSPlugin } from "@babylonjs/core/Physics/Plugins/ammoJSPlugin";
|
import { AmmoJSPlugin } from "@babylonjs/core/Physics/Plugins/ammoJSPlugin";
|
||||||
import { TargetCamera } from "@babylonjs/core/Cameras/targetCamera";
|
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/Physics/physicsEngineComponent";
|
||||||
import "@babylonjs/core/Lights/Shadows/shadowGeneratorSceneComponent";
|
import "@babylonjs/core/Lights/Shadows/shadowGeneratorSceneComponent";
|
||||||
@ -19,20 +19,44 @@ import ReactResizeDetector from "react-resize-detector";
|
|||||||
import usePreventTouch from "../../hooks/usePreventTouch";
|
import usePreventTouch from "../../hooks/usePreventTouch";
|
||||||
|
|
||||||
import ErrorBanner from "../banner/ErrorBanner";
|
import ErrorBanner from "../banner/ErrorBanner";
|
||||||
|
import { AbstractMesh } from "@babylonjs/core/Meshes/abstractMesh";
|
||||||
|
|
||||||
const diceThrowSpeed = 2;
|
const diceThrowSpeed = 2;
|
||||||
|
|
||||||
function DiceInteraction({ onSceneMount, onPointerDown, onPointerUp }) {
|
type SceneMountEvent = {
|
||||||
const [error, setError] = useState();
|
scene: Scene;
|
||||||
|
engine: Engine;
|
||||||
|
canvas: HTMLCanvasElement;
|
||||||
|
};
|
||||||
|
|
||||||
const sceneRef = useRef();
|
type SceneMountEventHandler = (event: SceneMountEvent) => void;
|
||||||
const engineRef = useRef();
|
|
||||||
const canvasRef = useRef();
|
type DiceInteractionProps = {
|
||||||
const containerRef = useRef();
|
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(() => {
|
useEffect(() => {
|
||||||
try {
|
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
|
|
||||||
|
if (!canvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const engine = new Engine(canvas, true, {
|
const engine = new Engine(canvas, true, {
|
||||||
preserveDrawingBuffer: true,
|
preserveDrawingBuffer: true,
|
||||||
stencil: true,
|
stencil: true,
|
||||||
@ -40,10 +64,9 @@ function DiceInteraction({ onSceneMount, onPointerDown, onPointerUp }) {
|
|||||||
const scene = new Scene(engine);
|
const scene = new Scene(engine);
|
||||||
scene.clearColor = new Color4(0, 0, 0, 0);
|
scene.clearColor = new Color4(0, 0, 0, 0);
|
||||||
// Enable physics
|
// Enable physics
|
||||||
scene.enablePhysics(
|
Ammo().then(() => {
|
||||||
new Vector3(0, -98, 0),
|
scene.enablePhysics(new Vector3(0, -98, 0), new AmmoJSPlugin(false));
|
||||||
new AmmoJSPlugin(false, AMMO)
|
});
|
||||||
);
|
|
||||||
|
|
||||||
let camera = new TargetCamera("camera", new Vector3(0, 33.5, 0), scene);
|
let camera = new TargetCamera("camera", new Vector3(0, 33.5, 0), scene);
|
||||||
camera.fov = 0.65;
|
camera.fov = 0.65;
|
||||||
@ -86,21 +109,27 @@ function DiceInteraction({ onSceneMount, onPointerDown, onPointerUp }) {
|
|||||||
}
|
}
|
||||||
}, [onSceneMount]);
|
}, [onSceneMount]);
|
||||||
|
|
||||||
const selectedMeshRef = useRef();
|
const selectedMeshRef = useRef<AbstractMesh | null>(null);
|
||||||
const selectedMeshVelocityWindowRef = useRef([]);
|
const selectedMeshVelocityWindowRef = useRef<Vector3[]>([]);
|
||||||
const selectedMeshVelocityWindowSize = 4;
|
const selectedMeshVelocityWindowSize = 4;
|
||||||
const selectedMeshMassRef = useRef();
|
const selectedMeshMassRef = useRef<number>(0);
|
||||||
function handlePointerDown() {
|
function handlePointerDown() {
|
||||||
const scene = sceneRef.current;
|
const scene = sceneRef.current;
|
||||||
if (scene) {
|
if (scene) {
|
||||||
const pickInfo = scene.pick(scene.pointerX, scene.pointerY);
|
const pickInfo = scene.pick(scene.pointerX, scene.pointerY);
|
||||||
if (pickInfo.hit && pickInfo.pickedMesh.name !== "dice_tray") {
|
if (
|
||||||
pickInfo.pickedMesh.physicsImpostor.setLinearVelocity(Vector3.Zero());
|
pickInfo &&
|
||||||
pickInfo.pickedMesh.physicsImpostor.setAngularVelocity(Vector3.Zero());
|
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
|
// Save the meshes mass and set it to 0 so we can pick it up
|
||||||
selectedMeshMassRef.current = pickInfo.pickedMesh.physicsImpostor.mass;
|
selectedMeshMassRef.current =
|
||||||
pickInfo.pickedMesh.physicsImpostor.setMass(0);
|
pickInfo.pickedMesh.physicsImpostor?.mass || 0;
|
||||||
|
pickInfo.pickedMesh.physicsImpostor?.setMass(0);
|
||||||
|
|
||||||
selectedMeshRef.current = pickInfo.pickedMesh;
|
selectedMeshRef.current = pickInfo.pickedMesh;
|
||||||
}
|
}
|
||||||
@ -123,29 +152,31 @@ function DiceInteraction({ onSceneMount, onPointerDown, onPointerUp }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Re-apply the meshes mass
|
// Re-apply the meshes mass
|
||||||
selectedMesh.physicsImpostor.setMass(selectedMeshMassRef.current);
|
selectedMesh.physicsImpostor?.setMass(selectedMeshMassRef.current);
|
||||||
selectedMesh.physicsImpostor.forceUpdate();
|
selectedMesh.physicsImpostor?.forceUpdate();
|
||||||
|
|
||||||
selectedMesh.physicsImpostor.applyImpulse(
|
selectedMesh.physicsImpostor?.applyImpulse(
|
||||||
velocity.scale(diceThrowSpeed * selectedMesh.physicsImpostor.mass),
|
velocity.scale(diceThrowSpeed * selectedMesh.physicsImpostor.mass),
|
||||||
selectedMesh.physicsImpostor.getObjectCenter()
|
selectedMesh.physicsImpostor.getObjectCenter()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
selectedMeshRef.current = null;
|
selectedMeshRef.current = null;
|
||||||
selectedMeshVelocityWindowRef.current = [];
|
selectedMeshVelocityWindowRef.current = [];
|
||||||
selectedMeshMassRef.current = null;
|
selectedMeshMassRef.current = 0;
|
||||||
|
|
||||||
onPointerUp();
|
onPointerUp();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleResize(width, height) {
|
function handleResize(width?: number, height?: number) {
|
||||||
|
if (width && height) {
|
||||||
const engine = engineRef.current;
|
const engine = engineRef.current;
|
||||||
if (engine) {
|
if (engine && canvasRef.current) {
|
||||||
engine.resize();
|
engine.resize();
|
||||||
canvasRef.current.width = width;
|
canvasRef.current.width = width;
|
||||||
canvasRef.current.height = height;
|
canvasRef.current.height = height;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
usePreventTouch(containerRef);
|
usePreventTouch(containerRef);
|
||||||
|
|
||||||
@ -159,7 +190,12 @@ function DiceInteraction({ onSceneMount, onPointerDown, onPointerUp }) {
|
|||||||
}}
|
}}
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
>
|
>
|
||||||
<ReactResizeDetector handleWidth handleHeight onResize={handleResize}>
|
<ReactResizeDetector
|
||||||
|
handleWidth
|
||||||
|
handleHeight
|
||||||
|
onResize={handleResize}
|
||||||
|
targetRef={containerRef}
|
||||||
|
>
|
||||||
<canvas
|
<canvas
|
||||||
onPointerDown={handlePointerDown}
|
onPointerDown={handlePointerDown}
|
||||||
onPointerUp={handlePointerUp}
|
onPointerUp={handlePointerUp}
|
||||||
@ -169,7 +205,7 @@ function DiceInteraction({ onSceneMount, onPointerDown, onPointerUp }) {
|
|||||||
style={{ outline: "none" }}
|
style={{ outline: "none" }}
|
||||||
/>
|
/>
|
||||||
</ReactResizeDetector>
|
</ReactResizeDetector>
|
||||||
<ErrorBanner error={error} onRequestClose={() => setError()} />
|
<ErrorBanner error={error} onRequestClose={() => setError(undefined)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
@ -5,17 +5,28 @@ import ClearDiceIcon from "../../icons/ClearDiceIcon";
|
|||||||
import RerollDiceIcon from "../../icons/RerollDiceIcon";
|
import RerollDiceIcon from "../../icons/RerollDiceIcon";
|
||||||
|
|
||||||
import { getDiceRollTotal } from "../../helpers/dice";
|
import { getDiceRollTotal } from "../../helpers/dice";
|
||||||
|
import { DiceRoll } from "../../types/Dice";
|
||||||
|
|
||||||
const maxDiceRollsShown = 6;
|
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);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
if (diceRolls.length === 0) {
|
if (diceRolls.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let rolls = [];
|
let rolls: React.ReactChild[] = [];
|
||||||
if (diceRolls.length > 1) {
|
if (diceRolls.length > 1) {
|
||||||
rolls = diceRolls
|
rolls = diceRolls
|
||||||
.filter((dice) => dice.roll !== "unknown")
|
.filter((dice) => dice.roll !== "unknown")
|
@ -1,9 +1,17 @@
|
|||||||
import React from "react";
|
|
||||||
import { Image } from "theme-ui";
|
import { Image } from "theme-ui";
|
||||||
|
|
||||||
import Tile from "../tile/Tile";
|
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 (
|
return (
|
||||||
<div style={{ cursor: "pointer" }}>
|
<div style={{ cursor: "pointer" }}>
|
||||||
<Tile
|
<Tile
|
@ -1,12 +1,25 @@
|
|||||||
import React from "react";
|
|
||||||
import { Grid } from "theme-ui";
|
import { Grid } from "theme-ui";
|
||||||
import SimpleBar from "simplebar-react";
|
import SimpleBar from "simplebar-react";
|
||||||
|
|
||||||
import DiceTile from "./DiceTile";
|
import DiceTile from "./DiceTile";
|
||||||
|
|
||||||
import useResponsiveLayout from "../../hooks/useResponsiveLayout";
|
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();
|
const layout = useResponsiveLayout();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -29,7 +42,6 @@ function DiceTiles({ dice, onDiceSelect, selectedDice, onDone }) {
|
|||||||
isSelected={selectedDice && dice.key === selectedDice.key}
|
isSelected={selectedDice && dice.key === selectedDice.key}
|
||||||
onDiceSelect={onDiceSelect}
|
onDiceSelect={onDiceSelect}
|
||||||
onDone={onDone}
|
onDone={onDone}
|
||||||
size={layout.tileSize}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Grid>
|
</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 { Vector3 } from "@babylonjs/core/Maths/math";
|
||||||
import { DirectionalLight } from "@babylonjs/core/Lights/directionalLight";
|
import { DirectionalLight } from "@babylonjs/core/Lights/directionalLight";
|
||||||
import { ShadowGenerator } from "@babylonjs/core/Lights/Shadows/shadowGenerator";
|
import { ShadowGenerator } from "@babylonjs/core/Lights/Shadows/shadowGenerator";
|
||||||
import { CubeTexture } from "@babylonjs/core/Materials/Textures/cubeTexture";
|
import { CubeTexture } from "@babylonjs/core/Materials/Textures/cubeTexture";
|
||||||
|
import { Scene } from "@babylonjs/core";
|
||||||
import { Box } from "theme-ui";
|
import { Box } from "theme-ui";
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
import environment from "../../dice/environment.dds";
|
import environment from "../../dice/environment.dds";
|
||||||
|
|
||||||
import DiceInteraction from "./DiceInteraction";
|
import DiceInteraction from "./DiceInteraction";
|
||||||
@ -18,25 +20,41 @@ import DiceTray from "../../dice/diceTray/DiceTray";
|
|||||||
import { useDiceLoading } from "../../contexts/DiceLoadingContext";
|
import { useDiceLoading } from "../../contexts/DiceLoadingContext";
|
||||||
|
|
||||||
import { getDiceRoll } from "../../helpers/dice";
|
import { getDiceRoll } from "../../helpers/dice";
|
||||||
|
|
||||||
import useSetting from "../../hooks/useSetting";
|
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({
|
function DiceTrayOverlay({
|
||||||
isOpen,
|
isOpen,
|
||||||
shareDice,
|
shareDice,
|
||||||
onShareDiceChage,
|
onShareDiceChange,
|
||||||
diceRolls,
|
diceRolls,
|
||||||
onDiceRollsChange,
|
onDiceRollsChange,
|
||||||
}) {
|
}: DiceTrayOverlayProps) {
|
||||||
const sceneRef = useRef();
|
const sceneRef = useRef<Scene>();
|
||||||
const shadowGeneratorRef = useRef();
|
const shadowGeneratorRef = useRef<ShadowGenerator>();
|
||||||
const diceRefs = useRef([]);
|
const diceRefs = useRef<DiceMesh[]>([]);
|
||||||
const sceneVisibleRef = useRef(false);
|
const sceneVisibleRef = useRef(false);
|
||||||
const sceneInteractionRef = useRef(false);
|
const sceneInteractionRef = useRef(false);
|
||||||
// Add to the counter to ingore sleep values
|
// Add to the counter to ingore sleep values
|
||||||
const sceneKeepAwakeRef = useRef(0);
|
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 { assetLoadStart, assetLoadFinish, isLoading } = useDiceLoading();
|
||||||
const [fullScreen] = useSetting("map.fullScreen");
|
const [fullScreen] = useSetting("map.fullScreen");
|
||||||
|
|
||||||
@ -50,7 +68,7 @@ function DiceTrayOverlay({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Forces rendering for 1 second
|
// Forces rendering for 1 second
|
||||||
function forceRender() {
|
function forceRender(): () => void {
|
||||||
// Force rerender
|
// Force rerender
|
||||||
sceneKeepAwakeRef.current++;
|
sceneKeepAwakeRef.current++;
|
||||||
let triggered = false;
|
let triggered = false;
|
||||||
@ -97,7 +115,7 @@ function DiceTrayOverlay({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function initializeScene(scene) {
|
async function initializeScene(scene: Scene) {
|
||||||
handleAssetLoadStart();
|
handleAssetLoadStart();
|
||||||
let light = new DirectionalLight(
|
let light = new DirectionalLight(
|
||||||
"DirectionalLight",
|
"DirectionalLight",
|
||||||
@ -124,16 +142,14 @@ function DiceTrayOverlay({
|
|||||||
handleAssetLoadFinish();
|
handleAssetLoadFinish();
|
||||||
}
|
}
|
||||||
|
|
||||||
function update(scene) {
|
function update(scene: Scene) {
|
||||||
function getDiceSpeed(dice) {
|
function getDiceSpeed(dice: DiceMesh) {
|
||||||
const diceSpeed = dice.instance.physicsImpostor
|
const diceSpeed =
|
||||||
.getLinearVelocity()
|
dice.instance.physicsImpostor?.getLinearVelocity()?.length() || 0;
|
||||||
.length();
|
|
||||||
// If the dice is a d100 check the d10 as well
|
// If the dice is a d100 check the d10 as well
|
||||||
if (dice.type === "d100") {
|
if (dice.d10Instance) {
|
||||||
const d10Speed = dice.d10Instance.physicsImpostor
|
const d10Speed =
|
||||||
.getLinearVelocity()
|
dice.d10Instance.physicsImpostor?.getLinearVelocity()?.length() || 0;
|
||||||
.length();
|
|
||||||
return Math.max(diceSpeed, d10Speed);
|
return Math.max(diceSpeed, d10Speed);
|
||||||
} else {
|
} else {
|
||||||
return diceSpeed;
|
return diceSpeed;
|
||||||
@ -157,14 +173,14 @@ function DiceTrayOverlay({
|
|||||||
const dice = die[i];
|
const dice = die[i];
|
||||||
const speed = getDiceSpeed(dice);
|
const speed = getDiceSpeed(dice);
|
||||||
// If the speed has been below 0.01 for 1s set dice to sleep
|
// If the speed has been below 0.01 for 1s set dice to sleep
|
||||||
if (speed < 0.01 && !dice.sleepTimout) {
|
if (speed < 0.01 && !dice.sleepTimeout) {
|
||||||
dice.sleepTimout = setTimeout(() => {
|
dice.sleepTimeout = setTimeout(() => {
|
||||||
dice.asleep = true;
|
dice.asleep = true;
|
||||||
}, 1000);
|
}, 1000);
|
||||||
} else if (speed > 0.5 && (dice.asleep || dice.sleepTimout)) {
|
} else if (speed > 0.5 && (dice.asleep || dice.sleepTimeout)) {
|
||||||
dice.asleep = false;
|
dice.asleep = false;
|
||||||
clearTimeout(dice.sleepTimout);
|
dice.sleepTimeout && clearTimeout(dice.sleepTimeout);
|
||||||
dice.sleepTimout = null;
|
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 scene = sceneRef.current;
|
||||||
const shadowGenerator = shadowGeneratorRef.current;
|
const shadowGenerator = shadowGeneratorRef.current;
|
||||||
if (scene && shadowGenerator) {
|
if (scene && shadowGenerator) {
|
||||||
const instance = style.createInstance(type, scene);
|
const instance = style.createInstance(type, scene);
|
||||||
shadowGenerator.addShadowCaster(instance);
|
shadowGenerator.addShadowCaster(instance);
|
||||||
Dice.roll(instance);
|
style.roll(instance);
|
||||||
let dice = { type, instance, asleep: false };
|
let dice: DiceMesh = { type, instance, asleep: false };
|
||||||
// If we have a d100 add a d10 as well
|
// If we have a d100 add a d10 as well
|
||||||
if (type === "d100") {
|
if (type === "d100") {
|
||||||
const d10Instance = style.createInstance("d10", scene);
|
const d10Instance = style.createInstance("d10", scene);
|
||||||
@ -196,7 +212,7 @@ function DiceTrayOverlay({
|
|||||||
const die = diceRefs.current;
|
const die = diceRefs.current;
|
||||||
for (let dice of die) {
|
for (let dice of die) {
|
||||||
dice.instance.dispose();
|
dice.instance.dispose();
|
||||||
if (dice.type === "d100") {
|
if (dice.d10Instance) {
|
||||||
dice.d10Instance.dispose();
|
dice.d10Instance.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -208,14 +224,14 @@ function DiceTrayOverlay({
|
|||||||
const die = diceRefs.current;
|
const die = diceRefs.current;
|
||||||
for (let dice of die) {
|
for (let dice of die) {
|
||||||
Dice.roll(dice.instance);
|
Dice.roll(dice.instance);
|
||||||
if (dice.type === "d100") {
|
if (dice.d10Instance) {
|
||||||
Dice.roll(dice.d10Instance);
|
Dice.roll(dice.d10Instance);
|
||||||
}
|
}
|
||||||
dice.asleep = false;
|
dice.asleep = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDiceLoad(dice) {
|
async function handleDiceLoad(dice: DefaultDice) {
|
||||||
handleAssetLoadStart();
|
handleAssetLoadStart();
|
||||||
const scene = sceneRef.current;
|
const scene = sceneRef.current;
|
||||||
if (scene) {
|
if (scene) {
|
||||||
@ -230,10 +246,13 @@ function DiceTrayOverlay({
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let renderTimeout;
|
let renderTimeout: NodeJS.Timeout;
|
||||||
let renderCleanup;
|
let renderCleanup: () => void;
|
||||||
function handleResize() {
|
function handleResize() {
|
||||||
const map = document.querySelector(".map");
|
const map = document.querySelector(".map");
|
||||||
|
if (!map) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const mapRect = map.getBoundingClientRect();
|
const mapRect = map.getBoundingClientRect();
|
||||||
|
|
||||||
const availableWidth = mapRect.width - 108; // Subtract padding
|
const availableWidth = mapRect.width - 108; // Subtract padding
|
||||||
@ -283,7 +302,7 @@ function DiceTrayOverlay({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let newRolls = [];
|
let newRolls: DiceRoll[] = [];
|
||||||
for (let i = 0; i < die.length; i++) {
|
for (let i = 0; i < die.length; i++) {
|
||||||
const dice = die[i];
|
const dice = die[i];
|
||||||
let roll = getDiceRoll(dice);
|
let roll = getDiceRoll(dice);
|
||||||
@ -345,7 +364,7 @@ function DiceTrayOverlay({
|
|||||||
onDiceTraySizeChange={setDiceTraySize}
|
onDiceTraySizeChange={setDiceTraySize}
|
||||||
diceTraySize={diceTraySize}
|
diceTraySize={diceTraySize}
|
||||||
shareDice={shareDice}
|
shareDice={shareDice}
|
||||||
onShareDiceChange={onShareDiceChage}
|
onShareDiceChange={onShareDiceChange}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
/>
|
/>
|
||||||
{isLoading && (
|
{isLoading && (
|
@ -1,10 +1,22 @@
|
|||||||
import React, { useState } from "react";
|
import { useState } from "react";
|
||||||
import { IconButton } from "theme-ui";
|
import { IconButton } from "theme-ui";
|
||||||
|
|
||||||
import SelectDiceIcon from "../../icons/SelectDiceIcon";
|
import SelectDiceIcon from "../../icons/SelectDiceIcon";
|
||||||
import SelectDiceModal from "../../modals/SelectDiceModal";
|
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);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
|
||||||
function openModal() {
|
function openModal() {
|
||||||
@ -14,7 +26,7 @@ function SelectDiceButton({ onDiceChange, currentDice, disabled }) {
|
|||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDone(dice) {
|
function handleDone(dice: DefaultDice) {
|
||||||
onDiceChange(dice);
|
onDiceChange(dice);
|
||||||
closeModal();
|
closeModal();
|
||||||
}
|
}
|
||||||
@ -39,4 +51,8 @@ function SelectDiceButton({ onDiceChange, currentDice, disabled }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SelectDiceButton.defaultProps = {
|
||||||
|
disabled: false,
|
||||||
|
};
|
||||||
|
|
||||||
export default SelectDiceButton;
|
export default SelectDiceButton;
|
@ -1,7 +1,14 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { useDraggable } from "@dnd-kit/core";
|
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({
|
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||||
id,
|
id,
|
||||||
data,
|
data,
|
@ -1,7 +1,12 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { useDroppable } from "@dnd-kit/core";
|
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 });
|
const { setNodeRef } = useDroppable({ id, disabled });
|
||||||
|
|
||||||
return (
|
return (
|
@ -20,9 +20,23 @@ import { useTokenData } from "../../contexts/TokenDataContext";
|
|||||||
import { useAssets } from "../../contexts/AssetsContext";
|
import { useAssets } from "../../contexts/AssetsContext";
|
||||||
import { useMapStage } from "../../contexts/MapStageContext";
|
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 { addToast } = useToasts();
|
||||||
|
|
||||||
const userId = useUserId();
|
const userId = useUserId();
|
||||||
@ -32,17 +46,15 @@ function GlobalImageDrop({ children, onMapChange, onMapTokensStateCreate }) {
|
|||||||
|
|
||||||
const mapStageRef = useMapStage();
|
const mapStageRef = useMapStage();
|
||||||
|
|
||||||
const [isLargeImageWarningModalOpen, setShowLargeImageWarning] = useState(
|
const [isLargeImageWarningModalOpen, setShowLargeImageWarning] =
|
||||||
false
|
useState(false);
|
||||||
);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
const droppedImagesRef = useRef();
|
const droppedImagesRef = useRef<File[]>();
|
||||||
const dropPositionRef = useRef();
|
const dropPositionRef = useRef<Vector2>();
|
||||||
// maps or tokens
|
const [droppingType, setDroppingType] = useState<"maps" | "tokens">("maps");
|
||||||
const [droppingType, setDroppingType] = useState("maps");
|
|
||||||
|
|
||||||
async function handleDrop(files, dropPosition) {
|
async function handleDrop({ files, dropPosition }: ImageDropEvent) {
|
||||||
if (navigator.storage) {
|
if (navigator.storage) {
|
||||||
// Attempt to enable persistant storage
|
// Attempt to enable persistant storage
|
||||||
await navigator.storage.persist();
|
await navigator.storage.persist();
|
||||||
@ -87,6 +99,7 @@ function GlobalImageDrop({ children, onMapChange, onMapTokensStateCreate }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleMaps() {
|
async function handleMaps() {
|
||||||
|
if (droppedImagesRef.current && userId) {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
let maps = [];
|
let maps = [];
|
||||||
for (let file of droppedImagesRef.current) {
|
for (let file of droppedImagesRef.current) {
|
||||||
@ -99,14 +112,18 @@ function GlobalImageDrop({ children, onMapChange, onMapTokensStateCreate }) {
|
|||||||
// Change map if only 1 dropped
|
// Change map if only 1 dropped
|
||||||
if (maps.length === 1) {
|
if (maps.length === 1) {
|
||||||
const mapState = await getMapState(maps[0].id);
|
const mapState = await getMapState(maps[0].id);
|
||||||
|
if (mapState) {
|
||||||
onMapChange(maps[0], mapState);
|
onMapChange(maps[0], mapState);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
droppedImagesRef.current = undefined;
|
droppedImagesRef.current = undefined;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleTokens() {
|
async function handleTokens() {
|
||||||
|
if (droppedImagesRef.current && userId) {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
// Keep track of tokens so we can add them to the map
|
// Keep track of tokens so we can add them to the map
|
||||||
let tokens = [];
|
let tokens = [];
|
||||||
@ -129,7 +146,11 @@ function GlobalImageDrop({ children, onMapChange, onMapTokensStateCreate }) {
|
|||||||
for (let token of tokens) {
|
for (let token of tokens) {
|
||||||
if (token) {
|
if (token) {
|
||||||
tokenStates.push(
|
tokenStates.push(
|
||||||
createTokenState(token, Vector2.add(mapPosition, offset), userId)
|
createTokenState(
|
||||||
|
token,
|
||||||
|
Vector2.add(mapPosition, offset),
|
||||||
|
userId
|
||||||
|
)
|
||||||
);
|
);
|
||||||
offset = Vector2.add(offset, 0.01);
|
offset = Vector2.add(offset, 0.01);
|
||||||
}
|
}
|
||||||
@ -140,6 +161,7 @@ function GlobalImageDrop({ children, onMapChange, onMapTokensStateCreate }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleMapsOver() {
|
function handleMapsOver() {
|
||||||
setDroppingType("maps");
|
setDroppingType("maps");
|
||||||
@ -149,9 +171,8 @@ function GlobalImageDrop({ children, onMapChange, onMapTokensStateCreate }) {
|
|||||||
setDroppingType("tokens");
|
setDroppingType("tokens");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { dragging, containerListeners, overlayListeners } = useImageDrop(
|
const { dragging, containerListeners, overlayListeners } =
|
||||||
handleDrop
|
useImageDrop(handleDrop);
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex sx={{ height: "100%", flexGrow: 1 }} {...containerListeners}>
|
<Flex sx={{ height: "100%", flexGrow: 1 }} {...containerListeners}>
|
@ -1,12 +1,17 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Box, Flex, Text } from "theme-ui";
|
import { Box, Flex, Text } from "theme-ui";
|
||||||
|
|
||||||
import useImageDrop from "../../hooks/useImageDrop";
|
import useImageDrop, { ImageDropEvent } from "../../hooks/useImageDrop";
|
||||||
|
|
||||||
function ImageDrop({ onDrop, dropText, children }) {
|
type ImageDropProps = {
|
||||||
const { dragging, containerListeners, overlayListeners } = useImageDrop(
|
onDrop: (event: ImageDropEvent) => void;
|
||||||
onDrop
|
dropText: string;
|
||||||
);
|
children?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ImageDrop({ onDrop, dropText, children }: ImageDropProps) {
|
||||||
|
const { dragging, containerListeners, overlayListeners } =
|
||||||
|
useImageDrop(onDrop);
|
||||||
return (
|
return (
|
||||||
<Box {...containerListeners}>
|
<Box {...containerListeners}>
|
||||||
{children}
|
{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 { Group, Rect } from "react-konva";
|
||||||
import useImage from "use-image";
|
import useImage from "use-image";
|
||||||
|
|
||||||
import Vector2 from "../helpers/Vector2";
|
import Vector2 from "../../helpers/Vector2";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
useGrid,
|
useGrid,
|
||||||
useGridPixelSize,
|
useGridPixelSize,
|
||||||
useGridOffset,
|
useGridOffset,
|
||||||
useGridCellPixelSize,
|
useGridCellPixelSize,
|
||||||
} from "../contexts/GridContext";
|
} from "../../contexts/GridContext";
|
||||||
|
|
||||||
import squarePatternDark from "../images/SquarePatternDark.png";
|
import squarePatternDark from "../../images/SquarePatternDark.png";
|
||||||
import squarePatternLight from "../images/SquarePatternLight.png";
|
import squarePatternLight from "../../images/SquarePatternLight.png";
|
||||||
import hexPatternDark from "../images/HexPatternDark.png";
|
import hexPatternDark from "../../images/HexPatternDark.png";
|
||||||
import hexPatternLight from "../images/HexPatternLight.png";
|
import hexPatternLight from "../../images/HexPatternLight.png";
|
||||||
|
|
||||||
function Grid({ stroke }) {
|
function Grid({ stroke }: { stroke: "black" | "white" }) {
|
||||||
const grid = useGrid();
|
const grid = useGrid();
|
||||||
const gridPixelSize = useGridPixelSize();
|
const gridPixelSize = useGridPixelSize();
|
||||||
const gridOffset = useGridOffset();
|
const gridOffset = useGridOffset();
|
||||||
@ -45,27 +44,33 @@ function Grid({ stroke }) {
|
|||||||
|
|
||||||
const negativeGridOffset = Vector2.multiply(gridOffset, -1);
|
const negativeGridOffset = Vector2.multiply(gridOffset, -1);
|
||||||
|
|
||||||
let patternProps = {};
|
let patternProps: Record<any, any> = {};
|
||||||
if (grid.type === "square") {
|
if (grid.type === "square") {
|
||||||
// Square grid pattern is 150 DPI
|
// Square grid pattern is 150 DPI
|
||||||
const scale = gridCellPixelSize.width / 300;
|
const scale = gridCellPixelSize.width / 300;
|
||||||
|
if (scale > 0) {
|
||||||
patternProps.fillPatternScaleX = scale;
|
patternProps.fillPatternScaleX = scale;
|
||||||
patternProps.fillPatternScaleY = scale;
|
patternProps.fillPatternScaleY = scale;
|
||||||
patternProps.fillPatternOffsetX = gridCellPixelSize.width / scale / 2;
|
patternProps.fillPatternOffsetX = gridCellPixelSize.width / scale / 2;
|
||||||
patternProps.fillPatternOffsetY = gridCellPixelSize.height / scale / 2;
|
patternProps.fillPatternOffsetY = gridCellPixelSize.height / scale / 2;
|
||||||
|
}
|
||||||
} else if (grid.type === "hexVertical") {
|
} else if (grid.type === "hexVertical") {
|
||||||
// Hex tile pattern is 153 DPI to better fit hex tiles
|
// Hex tile pattern is 153 DPI to better fit hex tiles
|
||||||
const scale = gridCellPixelSize.width / 153;
|
const scale = gridCellPixelSize.width / 153;
|
||||||
|
if (scale > 0) {
|
||||||
patternProps.fillPatternScaleX = scale;
|
patternProps.fillPatternScaleX = scale;
|
||||||
patternProps.fillPatternScaleY = scale;
|
patternProps.fillPatternScaleY = scale;
|
||||||
patternProps.fillPatternOffsetY = gridCellPixelSize.radius / scale / 2;
|
patternProps.fillPatternOffsetY = gridCellPixelSize.radius / scale / 2;
|
||||||
|
}
|
||||||
} else if (grid.type === "hexHorizontal") {
|
} else if (grid.type === "hexHorizontal") {
|
||||||
const scale = gridCellPixelSize.height / 153;
|
const scale = gridCellPixelSize.height / 153;
|
||||||
|
if (scale > 0) {
|
||||||
patternProps.fillPatternScaleX = scale;
|
patternProps.fillPatternScaleX = scale;
|
||||||
patternProps.fillPatternScaleY = scale;
|
patternProps.fillPatternScaleY = scale;
|
||||||
patternProps.fillPatternOffsetY = -gridCellPixelSize.radius / scale / 2;
|
patternProps.fillPatternOffsetY = -gridCellPixelSize.radius / scale / 2;
|
||||||
patternProps.fillPatternRotation = 90;
|
patternProps.fillPatternRotation = 90;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group>
|
<Group>
|
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 { Rect, Text, Group } from "react-konva";
|
||||||
|
|
||||||
import useSetting from "../../hooks/useSetting";
|
import useSetting from "../../hooks/useSetting";
|
||||||
|
import { TokenState } from "../../types/TokenState";
|
||||||
|
|
||||||
const maxTokenSize = 3;
|
const maxTokenSize = 3;
|
||||||
const defaultFontSize = 16;
|
const defaultFontSize = 144;
|
||||||
|
const minFontSize = 16;
|
||||||
|
|
||||||
function TokenLabel({ tokenState, width, height }) {
|
type TokenLabelProps = {
|
||||||
const [labelSize] = useSetting("map.labelSize");
|
tokenState: TokenState;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function TokenLabel({ tokenState, width, height }: TokenLabelProps) {
|
||||||
|
const [labelSize] = useSetting<number>("map.labelSize");
|
||||||
|
|
||||||
const paddingY =
|
const paddingY =
|
||||||
(height / 12 / tokenState.size) * Math.min(tokenState.size, maxTokenSize);
|
(height / 12 / tokenState.size) * Math.min(tokenState.size, maxTokenSize);
|
||||||
@ -22,15 +31,15 @@ function TokenLabel({ tokenState, width, height }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let fontSizes = [];
|
let fontSizes: number[] = [];
|
||||||
for (let size = 20 * labelSize; size >= 6; size--) {
|
for (let size = minFontSize * labelSize; size >= 6; size--) {
|
||||||
const verticalSize = height / size / tokenState.size;
|
const verticalSize = height / size / tokenState.size;
|
||||||
const tokenSize = Math.min(tokenState.size, maxTokenSize);
|
const tokenSize = Math.min(tokenState.size, maxTokenSize);
|
||||||
const fontSize = verticalSize * tokenSize * labelSize;
|
const fontSize = verticalSize * tokenSize * labelSize;
|
||||||
fontSizes.push(fontSize);
|
fontSizes.push(fontSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
function findFontScale() {
|
const findFontScale = () => {
|
||||||
const size = fontSizes.reduce((prev, curr) => {
|
const size = fontSizes.reduce((prev, curr) => {
|
||||||
text.fontSize(curr);
|
text.fontSize(curr);
|
||||||
const textWidth = text.getTextWidth() + paddingX * 2;
|
const textWidth = text.getTextWidth() + paddingX * 2;
|
||||||
@ -42,7 +51,7 @@ function TokenLabel({ tokenState, width, height }) {
|
|||||||
}, 1);
|
}, 1);
|
||||||
|
|
||||||
setFontScale(size / defaultFontSize);
|
setFontScale(size / defaultFontSize);
|
||||||
}
|
};
|
||||||
|
|
||||||
findFontScale();
|
findFontScale();
|
||||||
}, [
|
}, [
|
||||||
@ -68,8 +77,8 @@ function TokenLabel({ tokenState, width, height }) {
|
|||||||
}
|
}
|
||||||
}, [tokenState.label, paddingX, width, fontScale]);
|
}, [tokenState.label, paddingX, width, fontScale]);
|
||||||
|
|
||||||
const textRef = useRef();
|
const textRef = useRef<Konva.Text>(null);
|
||||||
const textSizerRef = useRef();
|
const textSizerRef = useRef<Konva.Text>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group y={height - (defaultFontSize * fontScale + paddingY) / 2}>
|
<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 { Circle, Group } from "react-konva";
|
||||||
|
|
||||||
import colors from "../../helpers/colors";
|
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
|
// Ensure statuses is an array and filter empty values
|
||||||
const statuses = [...new Set((tokenState?.statuses || []).filter((s) => s))];
|
const statuses = [...new Set((tokenState?.statuses || []).filter((s) => s))];
|
||||||
return (
|
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 { Flex, Close, IconButton } from "theme-ui";
|
||||||
|
|
||||||
import { groupsFromIds, itemsFromGroups } from "../../helpers/group";
|
import { groupsFromIds, itemsFromGroups } from "../../helpers/group";
|
||||||
@ -13,13 +13,32 @@ import { useMapData } from "../../contexts/MapDataContext";
|
|||||||
import { useKeyboard } from "../../contexts/KeyboardContext";
|
import { useKeyboard } from "../../contexts/KeyboardContext";
|
||||||
|
|
||||||
import shortcuts from "../../shortcuts";
|
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 [hasMapState, setHasMapState] = useState(false);
|
||||||
|
|
||||||
const { maps, mapStates, removeMaps, resetMap } = useMapData();
|
const { maps, mapStates, removeMaps, resetMap } = useMapData();
|
||||||
|
|
||||||
const { activeGroups, selectedGroupIds, onGroupSelect } = useGroup();
|
const { activeGroups, selectedGroupIds, onClearSelection } = useGroup();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const selectedGroups = groupsFromIds(selectedGroupIds, activeGroups);
|
const selectedGroups = groupsFromIds(selectedGroupIds, activeGroups);
|
||||||
@ -33,8 +52,8 @@ function MapEditBar({ currentMap, disabled, onMapChange, onMapReset, onLoad }) {
|
|||||||
for (let state of selectedMapStates) {
|
for (let state of selectedMapStates) {
|
||||||
if (
|
if (
|
||||||
Object.values(state.tokens).length > 0 ||
|
Object.values(state.tokens).length > 0 ||
|
||||||
Object.values(state.drawShapes).length > 0 ||
|
Object.values(state.drawings).length > 0 ||
|
||||||
Object.values(state.fogShapes).length > 0 ||
|
Object.values(state.fogs).length > 0 ||
|
||||||
Object.values(state.notes).length > 0
|
Object.values(state.notes).length > 0
|
||||||
) {
|
) {
|
||||||
_hasMapState = true;
|
_hasMapState = true;
|
||||||
@ -56,7 +75,7 @@ function MapEditBar({ currentMap, disabled, onMapChange, onMapReset, onLoad }) {
|
|||||||
setIsMapsRemoveModalOpen(false);
|
setIsMapsRemoveModalOpen(false);
|
||||||
const selectedMaps = getSelectedMaps();
|
const selectedMaps = getSelectedMaps();
|
||||||
const selectedMapIds = selectedMaps.map((map) => map.id);
|
const selectedMapIds = selectedMaps.map((map) => map.id);
|
||||||
onGroupSelect();
|
onClearSelection();
|
||||||
await removeMaps(selectedMapIds);
|
await removeMaps(selectedMapIds);
|
||||||
// Removed the map from the map screen if needed
|
// Removed the map from the map screen if needed
|
||||||
if (currentMap && selectedMapIds.includes(currentMap.id)) {
|
if (currentMap && selectedMapIds.includes(currentMap.id)) {
|
||||||
@ -84,7 +103,7 @@ function MapEditBar({ currentMap, disabled, onMapChange, onMapReset, onLoad }) {
|
|||||||
/**
|
/**
|
||||||
* Shortcuts
|
* Shortcuts
|
||||||
*/
|
*/
|
||||||
function handleKeyDown(event) {
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
if (disabled) {
|
if (disabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -117,7 +136,7 @@ function MapEditBar({ currentMap, disabled, onMapChange, onMapReset, onLoad }) {
|
|||||||
<Close
|
<Close
|
||||||
title="Clear Selection"
|
title="Clear Selection"
|
||||||
aria-label="Clear Selection"
|
aria-label="Clear Selection"
|
||||||
onClick={() => onGroupSelect()}
|
onClick={() => onClearSelection()}
|
||||||
/>
|
/>
|
||||||
<Flex>
|
<Flex>
|
||||||
<IconButton
|
<IconButton
|
@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useRef } from "react";
|
import React, { useState, useRef } from "react";
|
||||||
import { Box, IconButton } from "theme-ui";
|
import { Box, IconButton } from "theme-ui";
|
||||||
import { Stage, Layer, Image } from "react-konva";
|
import { Stage, Layer, Image } from "react-konva";
|
||||||
|
import Konva from "konva";
|
||||||
import ReactResizeDetector from "react-resize-detector";
|
import ReactResizeDetector from "react-resize-detector";
|
||||||
|
|
||||||
import useMapImage from "../../hooks/useMapImage";
|
import useMapImage from "../../hooks/useMapImage";
|
||||||
@ -21,8 +22,16 @@ import GridOffIcon from "../../icons/GridOffIcon";
|
|||||||
|
|
||||||
import MapGrid from "./MapGrid";
|
import MapGrid from "./MapGrid";
|
||||||
import MapGridEditor from "./MapGridEditor";
|
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 [mapImage] = useMapImage(map);
|
||||||
|
|
||||||
const [stageWidth, setStageWidth] = useState(1);
|
const [stageWidth, setStageWidth] = useState(1);
|
||||||
@ -32,16 +41,21 @@ function MapEditor({ map, onSettingsChange }) {
|
|||||||
const defaultInset = getGridDefaultInset(map.grid, map.width, map.height);
|
const defaultInset = getGridDefaultInset(map.grid, map.width, map.height);
|
||||||
|
|
||||||
const stageTranslateRef = useRef({ x: 0, y: 0 });
|
const stageTranslateRef = useRef({ x: 0, y: 0 });
|
||||||
const mapStageRef = useRef();
|
const mapStageRef = useRef<Konva.Stage>(null);
|
||||||
const mapLayerRef = useRef();
|
const mapLayerRef = useRef<Konva.Layer>(null);
|
||||||
const [preventMapInteraction, setPreventMapInteraction] = useState(false);
|
const [preventMapInteraction, setPreventMapInteraction] = useState(false);
|
||||||
|
|
||||||
function handleResize(width, height) {
|
function handleResize(width?: number, height?: number): void {
|
||||||
|
if (width) {
|
||||||
setStageWidth(width);
|
setStageWidth(width);
|
||||||
setStageHeight(height);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const containerRef = useRef();
|
if (height) {
|
||||||
|
setStageHeight(height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
usePreventOverscroll(containerRef);
|
usePreventOverscroll(containerRef);
|
||||||
|
|
||||||
const [mapWidth, mapHeight] = useImageCenter(
|
const [mapWidth, mapHeight] = useImageCenter(
|
||||||
@ -57,27 +71,31 @@ function MapEditor({ map, onSettingsChange }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useStageInteraction(
|
useStageInteraction(
|
||||||
mapStageRef.current,
|
mapStageRef,
|
||||||
stageScale,
|
stageScale,
|
||||||
setStageScale,
|
setStageScale,
|
||||||
stageTranslateRef,
|
stageTranslateRef,
|
||||||
mapLayerRef.current,
|
mapLayerRef,
|
||||||
getGridMaxZoom(map.grid),
|
getGridMaxZoom(map.grid),
|
||||||
"move",
|
"move",
|
||||||
preventMapInteraction
|
preventMapInteraction
|
||||||
);
|
);
|
||||||
|
|
||||||
function handleGridChange(inset) {
|
function handleGridChange(inset: GridInset) {
|
||||||
onSettingsChange("grid", {
|
onSettingsChange({
|
||||||
|
grid: {
|
||||||
...map.grid,
|
...map.grid,
|
||||||
inset,
|
inset,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMapReset() {
|
function handleMapReset() {
|
||||||
onSettingsChange("grid", {
|
onSettingsChange({
|
||||||
|
grid: {
|
||||||
...map.grid,
|
...map.grid,
|
||||||
inset: defaultInset,
|
inset: defaultInset,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -118,10 +136,16 @@ function MapEditor({ map, onSettingsChange }) {
|
|||||||
bg="muted"
|
bg="muted"
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
>
|
>
|
||||||
<ReactResizeDetector handleWidth handleHeight onResize={handleResize}>
|
<ReactResizeDetector
|
||||||
|
handleWidth
|
||||||
|
handleHeight
|
||||||
|
onResize={handleResize}
|
||||||
|
targetRef={containerRef}
|
||||||
|
>
|
||||||
<KonvaBridge
|
<KonvaBridge
|
||||||
stageRender={(children) => (
|
stageRender={(children: React.ReactNode) => (
|
||||||
<Stage
|
<Stage
|
||||||
|
// @ts-ignore https://github.com/konvajs/react-konva/issues/342
|
||||||
width={stageWidth}
|
width={stageWidth}
|
||||||
height={stageHeight}
|
height={stageHeight}
|
||||||
scale={{ x: stageScale, y: stageScale }}
|
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 useImage from "use-image";
|
||||||
|
|
||||||
import { useDataURL } from "../../contexts/AssetsContext";
|
import { useDataURL } from "../../contexts/AssetsContext";
|
||||||
@ -7,9 +7,10 @@ import { mapSources as defaultMapSources } from "../../maps";
|
|||||||
|
|
||||||
import { getImageLightness } from "../../helpers/image";
|
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;
|
let mapSourceMap = map;
|
||||||
const mapURL = useDataURL(
|
const mapURL = useDataURL(
|
||||||
mapSourceMap,
|
mapSourceMap,
|
||||||
@ -17,13 +18,14 @@ function MapGrid({ map }) {
|
|||||||
undefined,
|
undefined,
|
||||||
map.type === "file"
|
map.type === "file"
|
||||||
);
|
);
|
||||||
const [mapImage, mapLoadingStatus] = useImage(mapURL);
|
|
||||||
|
const [mapImage, mapLoadingStatus] = useImage(mapURL || "");
|
||||||
|
|
||||||
const [isImageLight, setIsImageLight] = useState(true);
|
const [isImageLight, setIsImageLight] = useState(true);
|
||||||
|
|
||||||
// When the map changes find the average lightness of its pixels
|
// When the map changes find the average lightness of its pixels
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mapLoadingStatus === "loaded") {
|
if (mapLoadingStatus === "loaded" && mapImage) {
|
||||||
setIsImageLight(getImageLightness(mapImage));
|
setIsImageLight(getImageLightness(mapImage));
|
||||||
}
|
}
|
||||||
}, [mapImage, mapLoadingStatus]);
|
}, [mapImage, mapLoadingStatus]);
|
@ -1,5 +1,6 @@
|
|||||||
import React, { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { Group, Circle, Rect } from "react-konva";
|
import { Group, Circle, Rect } from "react-konva";
|
||||||
|
import Konva from "konva";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
useDebouncedStageScale,
|
useDebouncedStageScale,
|
||||||
@ -12,8 +13,15 @@ import { useKeyboard } from "../../contexts/KeyboardContext";
|
|||||||
import Vector2 from "../../helpers/Vector2";
|
import Vector2 from "../../helpers/Vector2";
|
||||||
|
|
||||||
import shortcuts from "../../shortcuts";
|
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 stageScale = useDebouncedStageScale();
|
||||||
const mapWidth = useMapWidth();
|
const mapWidth = useMapWidth();
|
||||||
const mapHeight = useMapHeight();
|
const mapHeight = useMapHeight();
|
||||||
@ -39,21 +47,25 @@ function MapGridEditor({ map, onGridChange }) {
|
|||||||
}
|
}
|
||||||
const handlePositions = getHandlePositions();
|
const handlePositions = getHandlePositions();
|
||||||
|
|
||||||
const handlePreviousPositionRef = useRef();
|
const handlePreviousPositionRef = useRef<Vector2>();
|
||||||
|
|
||||||
function handleScaleCircleDragStart(event) {
|
function handleScaleCircleDragStart(
|
||||||
|
event: Konva.KonvaEventObject<MouseEvent>
|
||||||
|
) {
|
||||||
const handle = event.target;
|
const handle = event.target;
|
||||||
const position = getHandleNormalizedPosition(handle);
|
const position = getHandleNormalizedPosition(handle);
|
||||||
handlePreviousPositionRef.current = position;
|
handlePreviousPositionRef.current = position;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleScaleCircleDragMove(event) {
|
function handleScaleCircleDragMove(
|
||||||
|
event: Konva.KonvaEventObject<MouseEvent>
|
||||||
|
) {
|
||||||
const handle = event.target;
|
const handle = event.target;
|
||||||
onGridChange(getHandleInset(handle));
|
onGridChange(getHandleInset(handle));
|
||||||
handlePreviousPositionRef.current = getHandleNormalizedPosition(handle);
|
handlePreviousPositionRef.current = getHandleNormalizedPosition(handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleScaleCircleDragEnd(event) {
|
function handleScaleCircleDragEnd(event: Konva.KonvaEventObject<MouseEvent>) {
|
||||||
onGridChange(getHandleInset(event.target));
|
onGridChange(getHandleInset(event.target));
|
||||||
setPreventMapInteraction(false);
|
setPreventMapInteraction(false);
|
||||||
}
|
}
|
||||||
@ -66,11 +78,14 @@ function MapGridEditor({ map, onGridChange }) {
|
|||||||
setPreventMapInteraction(false);
|
setPreventMapInteraction(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHandleInset(handle) {
|
function getHandleInset(handle: Konva.Node): GridInset {
|
||||||
const name = handle.name();
|
const name = handle.name();
|
||||||
|
|
||||||
// Find distance and direction of dragging
|
// Find distance and direction of dragging
|
||||||
const previousPosition = handlePreviousPositionRef.current;
|
const previousPosition = handlePreviousPositionRef.current;
|
||||||
|
if (!previousPosition) {
|
||||||
|
return map.grid.inset;
|
||||||
|
}
|
||||||
const position = getHandleNormalizedPosition(handle);
|
const position = getHandleNormalizedPosition(handle);
|
||||||
const distance = Vector2.distance(previousPosition, position);
|
const distance = Vector2.distance(previousPosition, position);
|
||||||
const direction = Vector2.normalize(
|
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 inset = map.grid.inset;
|
||||||
const gridSizeNormalized = Vector2.divide(
|
const gridSizeNormalized = Vector2.divide(
|
||||||
Vector2.subtract(inset.bottomRight, inset.topLeft),
|
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;
|
const nudgeAmount = event.shiftKey ? 2 : 0.5;
|
||||||
if (shortcuts.gridNudgeUp(event)) {
|
if (shortcuts.gridNudgeUp(event)) {
|
||||||
// Stop arrow up/down scrolling if overflowing
|
// Stop arrow up/down scrolling if overflowing
|
||||||
@ -191,7 +206,7 @@ function MapGridEditor({ map, onGridChange }) {
|
|||||||
|
|
||||||
useKeyboard(handleKeyDown);
|
useKeyboard(handleKeyDown);
|
||||||
|
|
||||||
function getHandleNormalizedPosition(handle) {
|
function getHandleNormalizedPosition(handle: Konva.Node) {
|
||||||
return Vector2.divide({ x: handle.x(), y: handle.y() }, mapSize);
|
return Vector2.divide({ x: handle.x(), y: handle.y() }, mapSize);
|
||||||
}
|
}
|
||||||
|
|
@ -1,7 +1,8 @@
|
|||||||
import React, { useRef, useEffect, useState } from "react";
|
import React, { useRef, useEffect, useState } from "react";
|
||||||
import { Box } from "theme-ui";
|
import { Box } from "theme-ui";
|
||||||
import ReactResizeDetector from "react-resize-detector";
|
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 { EventEmitter } from "events";
|
||||||
|
|
||||||
import useMapImage from "../../hooks/useMapImage";
|
import useMapImage from "../../hooks/useMapImage";
|
||||||
@ -18,6 +19,19 @@ import { GridProvider } from "../../contexts/GridContext";
|
|||||||
import { useKeyboard } from "../../contexts/KeyboardContext";
|
import { useKeyboard } from "../../contexts/KeyboardContext";
|
||||||
|
|
||||||
import shortcuts from "../../shortcuts";
|
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({
|
function MapInteraction({
|
||||||
map,
|
map,
|
||||||
@ -26,8 +40,7 @@ function MapInteraction({
|
|||||||
controls,
|
controls,
|
||||||
selectedToolId,
|
selectedToolId,
|
||||||
onSelectedToolChange,
|
onSelectedToolChange,
|
||||||
disabledControls,
|
}: MapInteractionProps) {
|
||||||
}) {
|
|
||||||
const [mapImage, mapImageStatus] = useMapImage(map);
|
const [mapImage, mapImageStatus] = useMapImage(map);
|
||||||
|
|
||||||
const [mapLoaded, setMapLoaded] = useState(false);
|
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
|
// 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 stageTranslateRef = useRef({ x: 0, y: 0 });
|
||||||
const mapStageRef = useMapStage();
|
const mapStageRef = useMapStage();
|
||||||
const mapLayerRef = useRef();
|
const mapLayerRef = useRef<Konva.Layer>(null);
|
||||||
const mapImageRef = useRef();
|
const mapImageRef = useRef<Konva.Image>(null);
|
||||||
|
|
||||||
function handleResize(width, height) {
|
function handleResize(width?: number, height?: number) {
|
||||||
if (width > 0 && height > 0) {
|
if (width && height && width > 0 && height > 0) {
|
||||||
setStageWidth(width);
|
setStageWidth(width);
|
||||||
setStageHeight(height);
|
setStageHeight(height);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const containerRef = useRef();
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
usePreventOverscroll(containerRef);
|
usePreventOverscroll(containerRef);
|
||||||
|
|
||||||
const [mapWidth, mapHeight] = useImageCenter(
|
const [mapWidth, mapHeight] = useImageCenter(
|
||||||
@ -76,11 +89,11 @@ function MapInteraction({
|
|||||||
const [interactionEmitter] = useState(new EventEmitter());
|
const [interactionEmitter] = useState(new EventEmitter());
|
||||||
|
|
||||||
useStageInteraction(
|
useStageInteraction(
|
||||||
mapStageRef.current,
|
mapStageRef,
|
||||||
stageScale,
|
stageScale,
|
||||||
setStageScale,
|
setStageScale,
|
||||||
stageTranslateRef,
|
stageTranslateRef,
|
||||||
mapLayerRef.current,
|
mapLayerRef,
|
||||||
getGridMaxZoom(map?.grid),
|
getGridMaxZoom(map?.grid),
|
||||||
selectedToolId,
|
selectedToolId,
|
||||||
preventMapInteraction,
|
preventMapInteraction,
|
||||||
@ -105,44 +118,23 @@ function MapInteraction({
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
function handleKeyDown(event) {
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
// Change to move tool when pressing space
|
// Change to move tool when pressing space
|
||||||
if (shortcuts.move(event) && selectedToolId === "move") {
|
if (shortcuts.move(event) && selectedToolId === "move") {
|
||||||
// Stop active state on move icon from being selected
|
// Stop active state on move icon from being selected
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
if (
|
if (map && shortcuts.move(event) && selectedToolId !== "move") {
|
||||||
shortcuts.move(event) &&
|
|
||||||
selectedToolId !== "move" &&
|
|
||||||
!disabledControls.includes("move")
|
|
||||||
) {
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
previousSelectedToolRef.current = selectedToolId;
|
previousSelectedToolRef.current = selectedToolId;
|
||||||
onSelectedToolChange("move");
|
onSelectedToolChange("move");
|
||||||
}
|
}
|
||||||
|
if (!event.repeat && shortcuts.move(event) && selectedToolId === "move") {
|
||||||
// Basic keyboard shortcuts
|
previousSelectedToolRef.current = "move";
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyUp(event) {
|
function handleKeyUp(event: KeyboardEvent) {
|
||||||
if (shortcuts.move(event) && selectedToolId === "move") {
|
if (shortcuts.move(event) && selectedToolId === "move") {
|
||||||
onSelectedToolChange(previousSelectedToolRef.current);
|
onSelectedToolChange(previousSelectedToolRef.current);
|
||||||
}
|
}
|
||||||
@ -150,7 +142,7 @@ function MapInteraction({
|
|||||||
|
|
||||||
useKeyboard(handleKeyDown, handleKeyUp);
|
useKeyboard(handleKeyDown, handleKeyUp);
|
||||||
|
|
||||||
function getCursorForTool(tool) {
|
function getCursorForTool(tool: MapToolId) {
|
||||||
switch (tool) {
|
switch (tool) {
|
||||||
case "move":
|
case "move":
|
||||||
return "move";
|
return "move";
|
||||||
@ -160,6 +152,7 @@ function MapInteraction({
|
|||||||
case "measure":
|
case "measure":
|
||||||
case "pointer":
|
case "pointer":
|
||||||
case "note":
|
case "note":
|
||||||
|
case "select":
|
||||||
return "crosshair";
|
return "crosshair";
|
||||||
default:
|
default:
|
||||||
return "default";
|
return "default";
|
||||||
@ -178,7 +171,11 @@ function MapInteraction({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<MapInteractionProvider value={mapInteraction}>
|
<MapInteractionProvider value={mapInteraction}>
|
||||||
<GridProvider grid={map?.grid} width={mapWidth} height={mapHeight}>
|
<GridProvider
|
||||||
|
grid={map?.grid || null}
|
||||||
|
width={mapWidth}
|
||||||
|
height={mapHeight}
|
||||||
|
>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
position: "relative",
|
position: "relative",
|
||||||
@ -191,10 +188,16 @@ function MapInteraction({
|
|||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="map"
|
className="map"
|
||||||
>
|
>
|
||||||
<ReactResizeDetector handleWidth handleHeight onResize={handleResize}>
|
<ReactResizeDetector
|
||||||
|
handleWidth
|
||||||
|
handleHeight
|
||||||
|
onResize={handleResize}
|
||||||
|
targetRef={containerRef}
|
||||||
|
>
|
||||||
<KonvaBridge
|
<KonvaBridge
|
||||||
stageRender={(children) => (
|
stageRender={(children) => (
|
||||||
<Stage
|
<Stage
|
||||||
|
// @ts-ignore
|
||||||
width={stageWidth}
|
width={stageWidth}
|
||||||
height={stageHeight}
|
height={stageHeight}
|
||||||
scale={{ x: stageScale, y: stageScale }}
|
scale={{ x: stageScale, y: stageScale }}
|
||||||
@ -206,14 +209,14 @@ function MapInteraction({
|
|||||||
>
|
>
|
||||||
<Layer ref={mapLayerRef}>
|
<Layer ref={mapLayerRef}>
|
||||||
<Image
|
<Image
|
||||||
image={mapLoaded && mapImage}
|
image={(mapLoaded && mapImage) || undefined}
|
||||||
width={mapWidth}
|
width={mapWidth}
|
||||||
height={mapHeight}
|
height={mapHeight}
|
||||||
id="mapImage"
|
id="mapImage"
|
||||||
ref={mapImageRef}
|
ref={mapImageRef}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{mapLoaded && children}
|
{mapLoaded && children}
|
||||||
|
<Group id="portal" />
|
||||||
</Layer>
|
</Layer>
|
||||||
</KonvaBridge>
|
</KonvaBridge>
|
||||||
</ReactResizeDetector>
|
</ReactResizeDetector>
|
@ -1,4 +1,3 @@
|
|||||||
import React from "react";
|
|
||||||
import { Box } from "theme-ui";
|
import { Box } from "theme-ui";
|
||||||
|
|
||||||
import { useMapLoading } from "../../contexts/MapLoadingContext";
|
import { useMapLoading } from "../../contexts/MapLoadingContext";
|
||||||
@ -8,8 +7,11 @@ import LoadingBar from "../LoadingBar";
|
|||||||
function MapLoadingOverlay() {
|
function MapLoadingOverlay() {
|
||||||
const { isLoading, loadingProgressRef } = useMapLoading();
|
const { isLoading, loadingProgressRef } = useMapLoading();
|
||||||
|
|
||||||
|
if (!isLoading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
isLoading && (
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
@ -29,7 +31,6 @@ function MapLoadingOverlay() {
|
|||||||
loadingProgressRef={loadingProgressRef}
|
loadingProgressRef={loadingProgressRef}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -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 React, { useEffect, useState } from "react";
|
||||||
import Modal from "react-modal";
|
import Modal from "react-modal";
|
||||||
import { useThemeUI } from "theme-ui";
|
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({
|
function MapMenu({
|
||||||
isOpen,
|
isOpen,
|
||||||
@ -14,41 +31,59 @@ function MapMenu({
|
|||||||
style,
|
style,
|
||||||
// A node to exclude from the pointer event for closing
|
// A node to exclude from the pointer event for closing
|
||||||
excludeNode,
|
excludeNode,
|
||||||
}) {
|
}: MapMenuProps) {
|
||||||
// Save modal node in state to ensure that the pointer listeners
|
// Save modal node in state to ensure that the pointer listeners
|
||||||
// are removed if the open state changed not from the onRequestClose
|
// are removed if the open state changed not from the onRequestClose
|
||||||
// callback
|
// 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(() => {
|
useEffect(() => {
|
||||||
// Close modal if interacting with any other element
|
// Close modal if interacting with any other element
|
||||||
function handleInteraction(event) {
|
function handleInteraction(event: Event) {
|
||||||
const path = event.composedPath();
|
const path = event.composedPath();
|
||||||
if (
|
if (
|
||||||
|
modalContentNode &&
|
||||||
!path.includes(modalContentNode) &&
|
!path.includes(modalContentNode) &&
|
||||||
!(excludeNode && path.includes(excludeNode)) &&
|
!(excludeNode && path.includes(excludeNode)) &&
|
||||||
!(event.target instanceof HTMLTextAreaElement)
|
!(event.target instanceof HTMLTextAreaElement)
|
||||||
) {
|
) {
|
||||||
onRequestClose();
|
onRequestClose();
|
||||||
document.body.removeEventListener("pointerdown", handleInteraction);
|
document.body.removeEventListener("pointerup", handleInteraction);
|
||||||
document.body.removeEventListener("wheel", handleInteraction);
|
document.body.removeEventListener("wheel", handleInteraction);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (modalContentNode) {
|
if (modalContentNode) {
|
||||||
document.body.addEventListener("pointerdown", handleInteraction);
|
document.body.addEventListener("pointerup", handleInteraction);
|
||||||
// Check for wheel event to close modal as well
|
// Check for wheel event to close modal as well
|
||||||
document.body.addEventListener("wheel", handleInteraction);
|
document.body.addEventListener("wheel", handleInteraction);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (modalContentNode) {
|
if (modalContentNode) {
|
||||||
document.body.removeEventListener("pointerdown", handleInteraction);
|
document.body.removeEventListener("pointerup", handleInteraction);
|
||||||
|
document.body.removeEventListener("wheel", handleInteraction);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [modalContentNode, excludeNode, onRequestClose]);
|
}, [modalContentNode, excludeNode, onRequestClose]);
|
||||||
|
|
||||||
function handleModalContent(node) {
|
function handleModalContent(node: HTMLDivElement) {
|
||||||
setModalContentNode(node);
|
setModalContentNode(node);
|
||||||
onModalContent(node);
|
onModalContent(node);
|
||||||
}
|
}
|
||||||
@ -62,7 +97,7 @@ function MapMenu({
|
|||||||
style={{
|
style={{
|
||||||
overlay: { top: "0", bottom: "initial" },
|
overlay: { top: "0", bottom: "initial" },
|
||||||
content: {
|
content: {
|
||||||
backgroundColor: theme.colors.overlay,
|
backgroundColor: theme.colors?.overlay as CSS.Property.Color,
|
||||||
top,
|
top,
|
||||||
left,
|
left,
|
||||||
right,
|
right,
|
@ -9,8 +9,16 @@ import { mapSources as defaultMapSources } from "../../maps";
|
|||||||
|
|
||||||
import Divider from "../Divider";
|
import Divider from "../Divider";
|
||||||
import Select from "../Select";
|
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: "low", label: "Low" },
|
||||||
{ value: "medium", label: "Medium" },
|
{ value: "medium", label: "Medium" },
|
||||||
{ value: "high", label: "High" },
|
{ value: "high", label: "High" },
|
||||||
@ -18,42 +26,53 @@ const qualitySettings = [
|
|||||||
{ value: "original", label: "Original" },
|
{ value: "original", label: "Original" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const gridTypeSettings = [
|
type GridTypeSetting = { value: GridType; label: string };
|
||||||
|
const gridTypeSettings: GridTypeSetting[] = [
|
||||||
{ value: "square", label: "Square" },
|
{ value: "square", label: "Square" },
|
||||||
{ value: "hexVertical", label: "Hex Vertical" },
|
{ value: "hexVertical", label: "Hex Vertical" },
|
||||||
{ value: "hexHorizontal", label: "Hex Horizontal" },
|
{ value: "hexHorizontal", label: "Hex Horizontal" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const gridSquareMeasurementTypeSettings = [
|
type GridMeasurementTypeSetting = { value: GridMeasurementType; label: string };
|
||||||
|
const gridSquareMeasurementTypeSettings: GridMeasurementTypeSetting[] = [
|
||||||
{ value: "chebyshev", label: "Chessboard (D&D 5e)" },
|
{ value: "chebyshev", label: "Chessboard (D&D 5e)" },
|
||||||
{ value: "alternating", label: "Alternating Diagonal (D&D 3.5e)" },
|
{ value: "alternating", label: "Alternating Diagonal (D&D 3.5e)" },
|
||||||
{ value: "euclidean", label: "Euclidean" },
|
{ value: "euclidean", label: "Euclidean" },
|
||||||
{ value: "manhattan", label: "Manhattan" },
|
{ value: "manhattan", label: "Manhattan" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const gridHexMeasurementTypeSettings = [
|
const gridHexMeasurementTypeSettings: GridMeasurementTypeSetting[] = [
|
||||||
{ value: "manhattan", label: "Manhattan" },
|
{ value: "manhattan", label: "Manhattan" },
|
||||||
{ value: "euclidean", label: "Euclidean" },
|
{ value: "euclidean", label: "Euclidean" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
type MapSettingsProps = {
|
||||||
|
map: Map;
|
||||||
|
mapState: MapState;
|
||||||
|
onSettingsChange: MapSettingsChangeEventHandler;
|
||||||
|
onStateSettingsChange: MapStateSettingsChangeEventHandler;
|
||||||
|
};
|
||||||
|
|
||||||
function MapSettings({
|
function MapSettings({
|
||||||
map,
|
map,
|
||||||
mapState,
|
mapState,
|
||||||
onSettingsChange,
|
onSettingsChange,
|
||||||
onStateSettingsChange,
|
onStateSettingsChange,
|
||||||
}) {
|
}: MapSettingsProps) {
|
||||||
function handleFlagChange(event, flag) {
|
function handleFlagChange(
|
||||||
|
event: React.ChangeEvent<HTMLInputElement>,
|
||||||
|
flag: EditFlag
|
||||||
|
) {
|
||||||
if (event.target.checked) {
|
if (event.target.checked) {
|
||||||
onStateSettingsChange("editFlags", [...mapState.editFlags, flag]);
|
onStateSettingsChange({ editFlags: [...mapState.editFlags, flag] });
|
||||||
} else {
|
} else {
|
||||||
onStateSettingsChange(
|
onStateSettingsChange({
|
||||||
"editFlags",
|
editFlags: mapState.editFlags.filter((f) => f !== flag),
|
||||||
mapState.editFlags.filter((f) => f !== flag)
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleGridSizeXChange(event) {
|
function handleGridSizeXChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const value = parseInt(event.target.value) || 0;
|
const value = parseInt(event.target.value) || 0;
|
||||||
let grid = {
|
let grid = {
|
||||||
...map.grid,
|
...map.grid,
|
||||||
@ -63,10 +82,10 @@ function MapSettings({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
grid.inset = getGridUpdatedInset(grid, map.width, map.height);
|
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;
|
const value = parseInt(event.target.value) || 0;
|
||||||
let grid = {
|
let grid = {
|
||||||
...map.grid,
|
...map.grid,
|
||||||
@ -76,12 +95,15 @@ function MapSettings({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
grid.inset = getGridUpdatedInset(grid, map.width, map.height);
|
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;
|
const type = option.value;
|
||||||
let grid = {
|
let grid: Grid = {
|
||||||
...map.grid,
|
...map.grid,
|
||||||
type,
|
type,
|
||||||
measurement: {
|
measurement: {
|
||||||
@ -90,10 +112,15 @@ function MapSettings({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
grid.inset = getGridUpdatedInset(grid, map.width, map.height);
|
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 = {
|
const grid = {
|
||||||
...map.grid,
|
...map.grid,
|
||||||
measurement: {
|
measurement: {
|
||||||
@ -101,10 +128,19 @@ function MapSettings({
|
|||||||
type: option.value,
|
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 = {
|
const grid = {
|
||||||
...map.grid,
|
...map.grid,
|
||||||
measurement: {
|
measurement: {
|
||||||
@ -112,7 +148,7 @@ function MapSettings({
|
|||||||
scale: event.target.value,
|
scale: event.target.value,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
onSettingsChange("grid", grid);
|
onSettingsChange({ grid });
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapURL = useDataURL(map, defaultMapSources);
|
const mapURL = useDataURL(map, defaultMapSources);
|
||||||
@ -124,7 +160,7 @@ function MapSettings({
|
|||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
let size = blob.size;
|
let size = blob.size;
|
||||||
size /= 1000000; // Bytes to Megabytes
|
size /= 1000000; // Bytes to Megabytes
|
||||||
setMapSize(size.toFixed(2));
|
setMapSize(parseFloat(size.toFixed(2)));
|
||||||
} else {
|
} else {
|
||||||
setMapSize(0);
|
setMapSize(0);
|
||||||
}
|
}
|
||||||
@ -168,7 +204,7 @@ function MapSettings({
|
|||||||
<Input
|
<Input
|
||||||
name="name"
|
name="name"
|
||||||
value={(map && map.name) || ""}
|
value={(map && map.name) || ""}
|
||||||
onChange={(e) => onSettingsChange("name", e.target.value)}
|
onChange={(e) => onSettingsChange({ name: e.target.value })}
|
||||||
disabled={mapEmpty}
|
disabled={mapEmpty}
|
||||||
my={1}
|
my={1}
|
||||||
/>
|
/>
|
||||||
@ -185,10 +221,11 @@ function MapSettings({
|
|||||||
isDisabled={mapEmpty}
|
isDisabled={mapEmpty}
|
||||||
options={gridTypeSettings}
|
options={gridTypeSettings}
|
||||||
value={
|
value={
|
||||||
!mapEmpty &&
|
mapEmpty
|
||||||
gridTypeSettings.find((s) => s.value === map.grid.type)
|
? undefined
|
||||||
|
: gridTypeSettings.find((s) => s.value === map.grid.type)
|
||||||
}
|
}
|
||||||
onChange={handleGridTypeChange}
|
onChange={handleGridTypeChange as any}
|
||||||
isSearchable={false}
|
isSearchable={false}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@ -197,7 +234,9 @@ function MapSettings({
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
checked={!mapEmpty && map.showGrid}
|
checked={!mapEmpty && map.showGrid}
|
||||||
disabled={mapEmpty}
|
disabled={mapEmpty}
|
||||||
onChange={(e) => onSettingsChange("showGrid", e.target.checked)}
|
onChange={(e) =>
|
||||||
|
onSettingsChange({ showGrid: e.target.checked })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
Draw Grid
|
Draw Grid
|
||||||
</Label>
|
</Label>
|
||||||
@ -206,7 +245,7 @@ function MapSettings({
|
|||||||
checked={!mapEmpty && map.snapToGrid}
|
checked={!mapEmpty && map.snapToGrid}
|
||||||
disabled={mapEmpty}
|
disabled={mapEmpty}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onSettingsChange("snapToGrid", e.target.checked)
|
onSettingsChange({ snapToGrid: e.target.checked })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
Snap to Grid
|
Snap to Grid
|
||||||
@ -224,12 +263,13 @@ function MapSettings({
|
|||||||
: gridHexMeasurementTypeSettings
|
: gridHexMeasurementTypeSettings
|
||||||
}
|
}
|
||||||
value={
|
value={
|
||||||
!mapEmpty &&
|
mapEmpty
|
||||||
gridSquareMeasurementTypeSettings.find(
|
? undefined
|
||||||
|
: gridSquareMeasurementTypeSettings.find(
|
||||||
(s) => s.value === map.grid.measurement.type
|
(s) => s.value === map.grid.measurement.type
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
onChange={handleGridMeasurementTypeChange}
|
onChange={handleGridMeasurementTypeChange as any}
|
||||||
isSearchable={false}
|
isSearchable={false}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@ -254,14 +294,17 @@ function MapSettings({
|
|||||||
<Select
|
<Select
|
||||||
options={qualitySettings}
|
options={qualitySettings}
|
||||||
value={
|
value={
|
||||||
!mapEmpty &&
|
mapEmpty
|
||||||
qualitySettings.find((s) => s.value === map.quality)
|
? undefined
|
||||||
|
: qualitySettings.find((s) => s.value === map.quality)
|
||||||
}
|
}
|
||||||
isDisabled={mapEmpty}
|
isDisabled={mapEmpty}
|
||||||
onChange={(option) => onSettingsChange("quality", option.value)}
|
onChange={handleQualityChange as any}
|
||||||
isOptionDisabled={(option) =>
|
isOptionDisabled={
|
||||||
|
((option: QualityTypeSetting) =>
|
||||||
mapEmpty ||
|
mapEmpty ||
|
||||||
(option.value !== "original" && !map.resolutions[option.value])
|
(option.value !== "original" &&
|
||||||
|
!map.resolutions[option.value])) as any
|
||||||
}
|
}
|
||||||
isSearchable={false}
|
isSearchable={false}
|
||||||
/>
|
/>
|
@ -1,7 +1,18 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { Map } from "../../types/Map";
|
||||||
|
|
||||||
import Tile from "../tile/Tile";
|
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({
|
function MapTile({
|
||||||
map,
|
map,
|
||||||
@ -11,7 +22,7 @@ function MapTile({
|
|||||||
onDoubleClick,
|
onDoubleClick,
|
||||||
canEdit,
|
canEdit,
|
||||||
badges,
|
badges,
|
||||||
}) {
|
}: MapTileProps) {
|
||||||
return (
|
return (
|
||||||
<Tile
|
<Tile
|
||||||
title={map.name}
|
title={map.name}
|
@ -1,12 +1,28 @@
|
|||||||
import React from "react";
|
|
||||||
import { Grid } from "theme-ui";
|
import { Grid } from "theme-ui";
|
||||||
|
|
||||||
import Tile from "../tile/Tile";
|
import Tile from "../tile/Tile";
|
||||||
import MapImage from "./MapImage";
|
import MapImage from "./MapTileImage";
|
||||||
|
|
||||||
import useResponsiveLayout from "../../hooks/useResponsiveLayout";
|
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();
|
const layout = useResponsiveLayout();
|
||||||
|
|
||||||
return (
|
return (
|
@ -1,10 +1,14 @@
|
|||||||
import React from "react";
|
import { Image, ImageProps } from "theme-ui";
|
||||||
import { Image } from "theme-ui";
|
|
||||||
|
|
||||||
import { useDataURL } from "../../contexts/AssetsContext";
|
import { useDataURL } from "../../contexts/AssetsContext";
|
||||||
import { mapSources as defaultMapSources } from "../../maps";
|
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(
|
const mapURL = useDataURL(
|
||||||
map,
|
map,
|
||||||
defaultMapSources,
|
defaultMapSources,
|
||||||
@ -12,7 +16,7 @@ const MapTileImage = React.forwardRef(({ map, ...props }, ref) => {
|
|||||||
map.type === "file"
|
map.type === "file"
|
||||||
);
|
);
|
||||||
|
|
||||||
return <Image src={mapURL} ref={ref} {...props} />;
|
return <Image src={mapURL} {...props} />;
|
||||||
});
|
}
|
||||||
|
|
||||||
export default MapTileImage;
|
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