Refactor konva components and map tools
This commit is contained in:
@@ -4,28 +4,26 @@ 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 DrawingTool from "../tools/DrawingTool";
|
||||
import FogTool from "../tools/FogTool";
|
||||
import MeasureTool from "../tools/MeasureTool";
|
||||
import NetworkedMapPointer from "../../network/NetworkedMapPointer";
|
||||
import MapNotes from "./MapNotes";
|
||||
import SelectTool from "../tools/SelectTool";
|
||||
|
||||
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 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, MapActions, MapToolId } from "../../types/Map";
|
||||
@@ -39,11 +37,9 @@ import {
|
||||
NoteRemoveEventHander,
|
||||
TokenStateChangeEventHandler,
|
||||
} from "../../types/Events";
|
||||
import Action from "../../actions/Action";
|
||||
import Konva from "konva";
|
||||
import { TokenDraggingOptions, TokenMenuOptions } from "../../types/Token";
|
||||
import { Note, NoteDraggingOptions, NoteMenuOptions } from "../../types/Note";
|
||||
import MapSelect from "./MapSelect";
|
||||
|
||||
import useMapTokens from "../../hooks/useMapTokens";
|
||||
import useMapNotes from "../../hooks/useMapNotes";
|
||||
|
||||
type MapProps = {
|
||||
map: MapType | null;
|
||||
@@ -198,199 +194,22 @@ function Map({
|
||||
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 { tokens, tokenMenu, tokenDragOverlay } = useMapTokens(
|
||||
map,
|
||||
mapState,
|
||||
onMapTokenStateChange,
|
||||
onMapTokenStateRemove,
|
||||
selectedToolId,
|
||||
disabledTokens
|
||||
);
|
||||
|
||||
const [isTokenMenuOpen, setIsTokenMenuOpen] = useState<boolean>(false);
|
||||
const [tokenMenuOptions, setTokenMenuOptions] = useState<TokenMenuOptions>();
|
||||
const [tokenDraggingOptions, setTokenDraggingOptions] =
|
||||
useState<TokenDraggingOptions>();
|
||||
function handleTokenMenuOpen(tokenStateId: string, tokenImage: Konva.Node) {
|
||||
setTokenMenuOptions({ tokenStateId, tokenImage });
|
||||
setIsTokenMenuOpen(true);
|
||||
}
|
||||
|
||||
const mapTokens = map && mapState && (
|
||||
<MapTokens
|
||||
map={map}
|
||||
mapState={mapState}
|
||||
tokenDraggingOptions={tokenDraggingOptions}
|
||||
setTokenDraggingOptions={setTokenDraggingOptions}
|
||||
onMapTokenStateChange={onMapTokenStateChange}
|
||||
onTokenMenuOpen={handleTokenMenuOpen}
|
||||
selectedToolId={selectedToolId}
|
||||
disabledTokens={disabledTokens}
|
||||
/>
|
||||
);
|
||||
|
||||
const tokenMenu = (
|
||||
<TokenMenu
|
||||
isOpen={isTokenMenuOpen}
|
||||
onRequestClose={() => setIsTokenMenuOpen(false)}
|
||||
onTokenStateChange={onMapTokenStateChange}
|
||||
tokenState={
|
||||
tokenMenuOptions && mapState?.tokens[tokenMenuOptions.tokenStateId]
|
||||
}
|
||||
tokenImage={tokenMenuOptions && tokenMenuOptions.tokenImage}
|
||||
map={map}
|
||||
/>
|
||||
);
|
||||
|
||||
const tokenDragOverlay = tokenDraggingOptions && (
|
||||
<TokenDragOverlay
|
||||
onTokenStateRemove={(state) => {
|
||||
onMapTokenStateRemove(state);
|
||||
setTokenDraggingOptions(undefined);
|
||||
}}
|
||||
tokenState={tokenDraggingOptions && tokenDraggingOptions.tokenState}
|
||||
tokenNode={tokenDraggingOptions && tokenDraggingOptions.tokenNode}
|
||||
dragging={!!(tokenDraggingOptions && tokenDraggingOptions.dragging)}
|
||||
/>
|
||||
);
|
||||
|
||||
const mapDrawing = (
|
||||
<MapDrawing
|
||||
map={map}
|
||||
drawings={drawShapes}
|
||||
onDrawingAdd={handleMapShapeAdd}
|
||||
onDrawingsRemove={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"} />
|
||||
);
|
||||
|
||||
const mapPointer = (
|
||||
<NetworkedMapPointer
|
||||
active={selectedToolId === "pointer"}
|
||||
session={session}
|
||||
/>
|
||||
);
|
||||
|
||||
const [isNoteMenuOpen, setIsNoteMenuOpen] = useState<boolean>(false);
|
||||
const [noteMenuOptions, setNoteMenuOptions] = useState<NoteMenuOptions>();
|
||||
const [noteDraggingOptions, setNoteDraggingOptions] =
|
||||
useState<NoteDraggingOptions>();
|
||||
function handleNoteMenuOpen(noteId: string, noteNode: Konva.Node) {
|
||||
setNoteMenuOptions({ noteId, noteNode });
|
||||
setIsNoteMenuOpen(true);
|
||||
}
|
||||
|
||||
function sortNotes(
|
||||
a: Note,
|
||||
b: Note,
|
||||
noteDraggingOptions?: 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"}
|
||||
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={() =>
|
||||
noteDraggingOptions &&
|
||||
setNoteDraggingOptions({ ...noteDraggingOptions, dragging: false })
|
||||
}
|
||||
fadeOnHover={selectedToolId === "drawing"}
|
||||
/>
|
||||
);
|
||||
|
||||
const noteMenu = (
|
||||
<NoteMenu
|
||||
isOpen={isNoteMenuOpen}
|
||||
onRequestClose={() => setIsNoteMenuOpen(false)}
|
||||
onNoteChange={onMapNoteChange}
|
||||
note={noteMenuOptions && mapState?.notes[noteMenuOptions.noteId]}
|
||||
noteNode={noteMenuOptions?.noteNode}
|
||||
map={map}
|
||||
/>
|
||||
);
|
||||
|
||||
const noteDragOverlay = noteDraggingOptions ? (
|
||||
<NoteDragOverlay
|
||||
dragging={noteDraggingOptions.dragging}
|
||||
noteGroup={noteDraggingOptions.noteGroup}
|
||||
noteId={noteDraggingOptions.noteId}
|
||||
onNoteRemove={(noteId) => {
|
||||
onMapNoteRemove(noteId);
|
||||
setNoteDraggingOptions(undefined);
|
||||
}}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const mapSelect = (
|
||||
<MapSelect
|
||||
active={selectedToolId === "select"}
|
||||
toolSettings={settings.select}
|
||||
/>
|
||||
const { notes, noteMenu, noteDragOverlay } = useMapNotes(
|
||||
map,
|
||||
mapState,
|
||||
onMapNoteChange,
|
||||
onMapNoteRemove,
|
||||
selectedToolId,
|
||||
allowNoteEditing
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -400,7 +219,19 @@ function Map({
|
||||
mapState={mapState}
|
||||
controls={
|
||||
<>
|
||||
{mapControls}
|
||||
<MapControls
|
||||
onMapChange={onMapChange}
|
||||
onMapReset={onMapReset}
|
||||
currentMap={map}
|
||||
currentMapState={mapState}
|
||||
onSelectedToolChange={setSelectedToolId}
|
||||
selectedToolId={selectedToolId}
|
||||
toolSettings={settings}
|
||||
onToolSettingChange={handleToolSettingChange}
|
||||
onToolAction={handleToolAction}
|
||||
disabledControls={disabledControls}
|
||||
disabledSettings={disabledSettings}
|
||||
/>
|
||||
{tokenMenu}
|
||||
{noteMenu}
|
||||
{tokenDragOverlay}
|
||||
@@ -411,14 +242,38 @@ function Map({
|
||||
onSelectedToolChange={setSelectedToolId}
|
||||
disabledControls={disabledControls}
|
||||
>
|
||||
{mapGrid}
|
||||
{mapDrawing}
|
||||
{mapNotes}
|
||||
{mapTokens}
|
||||
{mapFog}
|
||||
{mapPointer}
|
||||
{mapMeasure}
|
||||
{mapSelect}
|
||||
{map && map.showGrid && <MapGrid map={map} />}
|
||||
<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={allowFogDrawing && !settings.fog.preview}
|
||||
/>
|
||||
<NetworkedMapPointer
|
||||
active={selectedToolId === "pointer"}
|
||||
session={session}
|
||||
/>
|
||||
<MeasureTool map={map} active={selectedToolId === "measure"} />
|
||||
<SelectTool
|
||||
active={selectedToolId === "select"}
|
||||
toolSettings={settings.select}
|
||||
/>
|
||||
</MapInteraction>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,336 +0,0 @@
|
||||
import { 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";
|
||||
|
||||
import { Map } from "../../types/Map";
|
||||
import {
|
||||
Drawing,
|
||||
DrawingToolSettings,
|
||||
drawingToolIsShape,
|
||||
Shape,
|
||||
} from "../../types/Drawing";
|
||||
|
||||
export type DrawingAddEventHanlder = (drawing: Drawing) => void;
|
||||
export type DrawingsRemoveEventHandler = (drawingIds: string[]) => void;
|
||||
|
||||
type MapDrawingProps = {
|
||||
map: Map | null;
|
||||
drawings: Drawing[];
|
||||
onDrawingAdd: DrawingAddEventHanlder;
|
||||
onDrawingsRemove: DrawingsRemoveEventHandler;
|
||||
active: boolean;
|
||||
toolSettings: DrawingToolSettings;
|
||||
};
|
||||
|
||||
function MapDrawing({
|
||||
map,
|
||||
drawings,
|
||||
onDrawingAdd: onShapeAdd,
|
||||
onDrawingsRemove: onShapesRemove,
|
||||
active,
|
||||
toolSettings,
|
||||
}: MapDrawingProps) {
|
||||
const stageScale = useDebouncedStageScale();
|
||||
const mapWidth = useMapWidth();
|
||||
const mapHeight = useMapHeight();
|
||||
const interactionEmitter = useInteractionEmitter();
|
||||
|
||||
const gridCellNormalizedSize = useGridCellNormalizedSize();
|
||||
const gridStrokeWidth = useGridStrokeWidth();
|
||||
|
||||
const mapStageRef = useMapStage();
|
||||
const [drawing, setDrawing] = useState<Drawing | null>(null);
|
||||
const [isBrushDown, setIsBrushDown] = useState(false);
|
||||
const [erasingDrawings, setErasingDrawings] = useState<Drawing[]>([]);
|
||||
|
||||
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() {
|
||||
if (!mapStage || !map) {
|
||||
return;
|
||||
}
|
||||
const mapImage = mapStage.findOne("#mapImage");
|
||||
let position = getRelativePointerPosition(mapImage);
|
||||
if (!position) {
|
||||
return;
|
||||
}
|
||||
if (map.snapToGrid && isShape) {
|
||||
position = snapPositionToGrid(position);
|
||||
}
|
||||
return Vector2.divide(position, {
|
||||
x: mapImage.width(),
|
||||
y: mapImage.height(),
|
||||
});
|
||||
}
|
||||
|
||||
function handleBrushDown() {
|
||||
const brushPosition = getBrushPosition();
|
||||
if (!brushPosition) {
|
||||
return;
|
||||
}
|
||||
const commonShapeData = {
|
||||
color: toolSettings.color,
|
||||
blend: toolSettings.useBlending,
|
||||
id: shortid.generate(),
|
||||
};
|
||||
const type = toolSettings.type;
|
||||
if (isBrush) {
|
||||
setDrawing({
|
||||
type: "path",
|
||||
pathType: type === "brush" ? "stroke" : "fill",
|
||||
data: { points: [brushPosition] },
|
||||
strokeWidth: type === "brush" ? 1 : 0,
|
||||
...commonShapeData,
|
||||
});
|
||||
} else if (isShape && drawingToolIsShape(type)) {
|
||||
setDrawing({
|
||||
type: "shape",
|
||||
shapeType: type,
|
||||
data: getDefaultShapeData(type, brushPosition),
|
||||
strokeWidth: toolSettings.type === "line" ? 1 : 0,
|
||||
...commonShapeData,
|
||||
} as Shape);
|
||||
}
|
||||
setIsBrushDown(true);
|
||||
}
|
||||
|
||||
function handleBrushMove() {
|
||||
const brushPosition = getBrushPosition();
|
||||
if (!brushPosition) {
|
||||
return;
|
||||
}
|
||||
if (isBrushDown && drawing) {
|
||||
if (isBrush) {
|
||||
setDrawing((prevShape) => {
|
||||
if (prevShape?.type !== "path") {
|
||||
return 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) {
|
||||
setDrawing((prevShape) => {
|
||||
if (prevShape?.type !== "shape") {
|
||||
return prevShape;
|
||||
}
|
||||
return {
|
||||
...prevShape,
|
||||
data: getUpdatedShapeData(
|
||||
prevShape.shapeType,
|
||||
prevShape.data,
|
||||
brushPosition,
|
||||
gridCellNormalizedSize,
|
||||
mapWidth,
|
||||
mapHeight
|
||||
),
|
||||
} as Shape;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleBrushUp() {
|
||||
if (isBrush && drawing && drawing.type === "path") {
|
||||
if (drawing.data.points.length > 1) {
|
||||
onShapeAdd(drawing);
|
||||
}
|
||||
} else if (isShape && drawing) {
|
||||
onShapeAdd(drawing);
|
||||
}
|
||||
|
||||
eraseHoveredShapes();
|
||||
|
||||
setDrawing(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: Drawing, isDown: boolean) {
|
||||
if (shouldHover && isDown) {
|
||||
if (erasingDrawings.findIndex((s) => s.id === shape.id) === -1) {
|
||||
setErasingDrawings((prevShapes) => [...prevShapes, shape]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function eraseHoveredShapes() {
|
||||
if (erasingDrawings.length > 0) {
|
||||
onShapesRemove(erasingDrawings.map((shape) => shape.id));
|
||||
setErasingDrawings([]);
|
||||
}
|
||||
}
|
||||
|
||||
function renderDrawing(shape: Drawing) {
|
||||
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: number[], 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: number[], point) => [
|
||||
...acc,
|
||||
point.x * mapWidth,
|
||||
point.y * mapHeight,
|
||||
],
|
||||
[]
|
||||
)}
|
||||
closed={true}
|
||||
{...defaultProps}
|
||||
/>
|
||||
);
|
||||
} else if (shape.shapeType === "line") {
|
||||
return (
|
||||
<Line
|
||||
points={shape.data.points.reduce(
|
||||
(acc: number[], point) => [
|
||||
...acc,
|
||||
point.x * mapWidth,
|
||||
point.y * mapHeight,
|
||||
],
|
||||
[]
|
||||
)}
|
||||
strokeWidth={gridStrokeWidth * shape.strokeWidth}
|
||||
stroke={colors[shape.color] || shape.color}
|
||||
lineCap="round"
|
||||
{...defaultProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderErasingDrawing(drawing: Drawing) {
|
||||
const eraseShape: Drawing = {
|
||||
...drawing,
|
||||
color: "primary",
|
||||
};
|
||||
return renderDrawing(eraseShape);
|
||||
}
|
||||
|
||||
return (
|
||||
<Group>
|
||||
{drawings.map(renderDrawing)}
|
||||
{drawing && renderDrawing(drawing)}
|
||||
{erasingDrawings.length > 0 && erasingDrawings.map(renderErasingDrawing)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default MapDrawing;
|
||||
@@ -1,701 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import shortid from "shortid";
|
||||
import { Group, Line } from "react-konva";
|
||||
import useImage from "use-image";
|
||||
import Color from "color";
|
||||
|
||||
import diagonalPattern from "../../images/DiagonalPattern.png";
|
||||
|
||||
import {
|
||||
useDebouncedStageScale,
|
||||
useMapWidth,
|
||||
useMapHeight,
|
||||
useInteractionEmitter,
|
||||
} from "../../contexts/MapInteractionContext";
|
||||
import { useMapStage } from "../../contexts/MapStageContext";
|
||||
import {
|
||||
useGrid,
|
||||
useGridCellPixelSize,
|
||||
useGridCellNormalizedSize,
|
||||
useGridStrokeWidth,
|
||||
useGridCellPixelOffset,
|
||||
useGridOffset,
|
||||
} from "../../contexts/GridContext";
|
||||
import { useKeyboard } from "../../contexts/KeyboardContext";
|
||||
|
||||
import Vector2, { BoundingBox } from "../../helpers/Vector2";
|
||||
import {
|
||||
simplifyPoints,
|
||||
mergeFogShapes,
|
||||
getFogShapesBoundingBoxes,
|
||||
getGuidesFromBoundingBoxes,
|
||||
getGuidesFromGridCell,
|
||||
findBestGuides,
|
||||
Guide,
|
||||
} from "../../helpers/drawing";
|
||||
import colors from "../../helpers/colors";
|
||||
import {
|
||||
HoleyLine,
|
||||
Tick,
|
||||
getRelativePointerPosition,
|
||||
} from "../../helpers/konva";
|
||||
import { keyBy } from "../../helpers/shared";
|
||||
|
||||
import SubtractFogAction from "../../actions/SubtractFogAction";
|
||||
import CutFogAction from "../../actions/CutFogAction";
|
||||
|
||||
import useSetting from "../../hooks/useSetting";
|
||||
|
||||
import shortcuts from "../../shortcuts";
|
||||
|
||||
import { Map } from "../../types/Map";
|
||||
import { Fog, FogToolSettings } from "../../types/Fog";
|
||||
|
||||
type FogAddEventHandler = (fog: Fog[]) => void;
|
||||
type FogCutEventHandler = (fog: Fog[]) => void;
|
||||
type FogRemoveEventHandler = (fogId: string[]) => void;
|
||||
type FogEditEventHandler = (edit: Partial<Fog>[]) => void;
|
||||
type FogErrorEventHandler = (message: string) => void;
|
||||
|
||||
type MapFogProps = {
|
||||
map: Map | null;
|
||||
shapes: Fog[];
|
||||
onShapesAdd: FogAddEventHandler;
|
||||
onShapesCut: FogCutEventHandler;
|
||||
onShapesRemove: FogRemoveEventHandler;
|
||||
onShapesEdit: FogEditEventHandler;
|
||||
onShapeError: FogErrorEventHandler;
|
||||
active: boolean;
|
||||
toolSettings: FogToolSettings;
|
||||
editable: boolean;
|
||||
};
|
||||
|
||||
function MapFog({
|
||||
map,
|
||||
shapes,
|
||||
onShapesAdd,
|
||||
onShapesCut,
|
||||
onShapesRemove,
|
||||
onShapesEdit,
|
||||
onShapeError,
|
||||
active,
|
||||
toolSettings,
|
||||
editable,
|
||||
}: MapFogProps) {
|
||||
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 gridCellPixelOffset = useGridCellPixelOffset();
|
||||
const gridOffset = useGridOffset();
|
||||
|
||||
const [gridSnappingSensitivity] = useSetting<number>(
|
||||
"map.gridSnappingSensitivity"
|
||||
);
|
||||
const [showFogGuides] = useSetting<boolean>("fog.showGuides");
|
||||
const [editOpacity] = useSetting<number>("fog.editOpacity");
|
||||
const mapStageRef = useMapStage();
|
||||
|
||||
const [drawingShape, setDrawingShape] = useState<Fog | null>(null);
|
||||
const [isBrushDown, setIsBrushDown] = useState(false);
|
||||
const [editingShapes, setEditingShapes] = useState<Fog[]>([]);
|
||||
|
||||
// Shapes that have been merged for fog
|
||||
const [fogShapes, setFogShapes] = useState(shapes);
|
||||
// Bounding boxes for guides
|
||||
const [fogShapeBoundingBoxes, setFogShapeBoundingBoxes] = useState<
|
||||
BoundingBox[]
|
||||
>([]);
|
||||
const [guides, setGuides] = useState<Guide[]>([]);
|
||||
|
||||
const shouldHover =
|
||||
active &&
|
||||
editable &&
|
||||
(toolSettings.type === "toggle" || toolSettings.type === "remove");
|
||||
|
||||
const shouldUseGuides =
|
||||
active &&
|
||||
editable &&
|
||||
(toolSettings.type === "rectangle" || toolSettings.type === "polygon");
|
||||
|
||||
const shouldRenderGuides = shouldUseGuides && showFogGuides;
|
||||
|
||||
const [patternImage] = useImage(diagonalPattern);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !editable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mapStage = mapStageRef.current;
|
||||
|
||||
function getBrushPosition(snapping = true) {
|
||||
if (!mapStage) {
|
||||
return;
|
||||
}
|
||||
const mapImage = mapStage.findOne("#mapImage");
|
||||
let position = getRelativePointerPosition(mapImage);
|
||||
if (!position) {
|
||||
return;
|
||||
}
|
||||
if (shouldUseGuides && snapping) {
|
||||
for (let guide of guides) {
|
||||
if (guide.orientation === "vertical") {
|
||||
position.x = guide.start.x * mapWidth;
|
||||
}
|
||||
if (guide.orientation === "horizontal") {
|
||||
position.y = guide.start.y * mapHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Vector2.divide(position, {
|
||||
x: mapImage.width(),
|
||||
y: mapImage.height(),
|
||||
});
|
||||
}
|
||||
|
||||
function handleBrushDown() {
|
||||
if (toolSettings.type === "brush") {
|
||||
const brushPosition = getBrushPosition();
|
||||
if (!brushPosition) {
|
||||
return;
|
||||
}
|
||||
setDrawingShape({
|
||||
type: "fog",
|
||||
data: {
|
||||
points: [brushPosition],
|
||||
holes: [],
|
||||
},
|
||||
strokeWidth: 0.5,
|
||||
color: toolSettings.useFogCut ? "red" : "black",
|
||||
id: shortid.generate(),
|
||||
visible: true,
|
||||
});
|
||||
}
|
||||
if (toolSettings.type === "rectangle") {
|
||||
const brushPosition = getBrushPosition();
|
||||
if (!brushPosition) {
|
||||
return;
|
||||
}
|
||||
setDrawingShape({
|
||||
type: "fog",
|
||||
data: {
|
||||
points: [
|
||||
brushPosition,
|
||||
brushPosition,
|
||||
brushPosition,
|
||||
brushPosition,
|
||||
],
|
||||
holes: [],
|
||||
},
|
||||
strokeWidth: 0.5,
|
||||
color: toolSettings.useFogCut ? "red" : "black",
|
||||
id: shortid.generate(),
|
||||
visible: true,
|
||||
});
|
||||
}
|
||||
setIsBrushDown(true);
|
||||
}
|
||||
|
||||
function handleBrushMove() {
|
||||
if (toolSettings.type === "brush" && isBrushDown && drawingShape) {
|
||||
const brushPosition = getBrushPosition();
|
||||
if (!brushPosition) {
|
||||
return;
|
||||
}
|
||||
setDrawingShape((prevShape) => {
|
||||
if (!prevShape) {
|
||||
return 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: {
|
||||
...prevShape.data,
|
||||
points: simplified,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
if (toolSettings.type === "rectangle" && isBrushDown && drawingShape) {
|
||||
const prevPoints = drawingShape.data.points;
|
||||
const brushPosition = getBrushPosition();
|
||||
if (!brushPosition) {
|
||||
return;
|
||||
}
|
||||
setDrawingShape((prevShape) => {
|
||||
if (!prevShape) {
|
||||
return prevShape;
|
||||
}
|
||||
return {
|
||||
...prevShape,
|
||||
data: {
|
||||
...prevShape.data,
|
||||
points: [
|
||||
prevPoints[0],
|
||||
{ x: brushPosition.x, y: prevPoints[1].y },
|
||||
brushPosition,
|
||||
{ x: prevPoints[3].x, y: brushPosition.y },
|
||||
],
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleBrushUp() {
|
||||
if (
|
||||
(toolSettings.type === "brush" || toolSettings.type === "rectangle") &&
|
||||
drawingShape
|
||||
) {
|
||||
const cut = toolSettings.useFogCut;
|
||||
let drawingShapes = [drawingShape];
|
||||
|
||||
// Filter out hidden or visible shapes if single layer enabled
|
||||
if (!toolSettings.multilayer) {
|
||||
const shapesToSubtract = shapes.filter((shape) =>
|
||||
cut ? !shape.visible : shape.visible
|
||||
);
|
||||
const subtractAction = new SubtractFogAction(shapesToSubtract);
|
||||
const state = subtractAction.execute({
|
||||
[drawingShape.id]: drawingShape,
|
||||
});
|
||||
drawingShapes = Object.values(state)
|
||||
.filter((shape) => shape.data.points.length > 2)
|
||||
.map((shape) => ({ ...shape, id: shortid.generate() }));
|
||||
}
|
||||
|
||||
if (drawingShapes.length > 0) {
|
||||
if (cut) {
|
||||
// Run a pre-emptive cut action to check whether we've cut anything
|
||||
const cutAction = new CutFogAction(drawingShapes);
|
||||
const state = cutAction.execute(keyBy(shapes, "id"));
|
||||
|
||||
if (Object.keys(state).length === shapes.length) {
|
||||
onShapeError("No fog to cut");
|
||||
} else {
|
||||
onShapesCut(drawingShapes);
|
||||
}
|
||||
} else {
|
||||
onShapesAdd(
|
||||
drawingShapes.map((shape) => ({ ...shape, color: "black" }))
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (cut) {
|
||||
onShapeError("Fog already cut");
|
||||
} else {
|
||||
onShapeError("Fog already placed");
|
||||
}
|
||||
}
|
||||
setDrawingShape(null);
|
||||
}
|
||||
|
||||
eraseHoveredShapes();
|
||||
|
||||
setIsBrushDown(false);
|
||||
}
|
||||
|
||||
function handlePointerClick() {
|
||||
if (toolSettings.type === "polygon") {
|
||||
const brushPosition = getBrushPosition();
|
||||
if (brushPosition) {
|
||||
setDrawingShape((prevDrawingShape) => {
|
||||
if (prevDrawingShape) {
|
||||
return {
|
||||
...prevDrawingShape,
|
||||
data: {
|
||||
...prevDrawingShape.data,
|
||||
points: [...prevDrawingShape.data.points, brushPosition],
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: "fog",
|
||||
data: {
|
||||
points: [brushPosition, brushPosition],
|
||||
holes: [],
|
||||
},
|
||||
strokeWidth: 0.5,
|
||||
color: toolSettings.useFogCut ? "red" : "black",
|
||||
id: shortid.generate(),
|
||||
visible: true,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerMove() {
|
||||
if (shouldUseGuides) {
|
||||
let guides: Guide[] = [];
|
||||
const brushPosition = getBrushPosition(false);
|
||||
if (brushPosition) {
|
||||
const absoluteBrushPosition = Vector2.multiply(brushPosition, {
|
||||
x: mapWidth,
|
||||
y: mapHeight,
|
||||
});
|
||||
if (map?.snapToGrid) {
|
||||
guides.push(
|
||||
...getGuidesFromGridCell(
|
||||
absoluteBrushPosition,
|
||||
grid,
|
||||
gridCellPixelSize,
|
||||
gridOffset,
|
||||
gridCellPixelOffset,
|
||||
gridSnappingSensitivity,
|
||||
{ x: mapWidth, y: mapHeight }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
guides.push(
|
||||
...getGuidesFromBoundingBoxes(
|
||||
brushPosition,
|
||||
fogShapeBoundingBoxes,
|
||||
gridCellNormalizedSize,
|
||||
gridSnappingSensitivity
|
||||
)
|
||||
);
|
||||
|
||||
setGuides(findBestGuides(brushPosition, guides));
|
||||
}
|
||||
}
|
||||
if (toolSettings.type === "polygon") {
|
||||
const brushPosition = getBrushPosition();
|
||||
if (toolSettings.type === "polygon" && drawingShape && brushPosition) {
|
||||
setDrawingShape((prevShape) => {
|
||||
if (!prevShape) {
|
||||
return prevShape;
|
||||
}
|
||||
return {
|
||||
...prevShape,
|
||||
data: {
|
||||
...prevShape.data,
|
||||
points: [...prevShape.data.points.slice(0, -1), brushPosition],
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handelTouchEnd() {
|
||||
setGuides([]);
|
||||
}
|
||||
|
||||
interactionEmitter?.on("dragStart", handleBrushDown);
|
||||
interactionEmitter?.on("drag", handleBrushMove);
|
||||
interactionEmitter?.on("dragEnd", handleBrushUp);
|
||||
// Use mouse events for polygon and erase to allow for single clicks
|
||||
mapStage?.on("mousedown touchstart", handlePointerMove);
|
||||
mapStage?.on("mousemove touchmove", handlePointerMove);
|
||||
mapStage?.on("click tap", handlePointerClick);
|
||||
mapStage?.on("touchend", handelTouchEnd);
|
||||
|
||||
return () => {
|
||||
interactionEmitter?.off("dragStart", handleBrushDown);
|
||||
interactionEmitter?.off("drag", handleBrushMove);
|
||||
interactionEmitter?.off("dragEnd", handleBrushUp);
|
||||
mapStage?.off("mousedown touchstart", handlePointerMove);
|
||||
mapStage?.off("mousemove touchmove", handlePointerMove);
|
||||
mapStage?.off("click tap", handlePointerClick);
|
||||
mapStage?.off("touchend", handelTouchEnd);
|
||||
};
|
||||
});
|
||||
|
||||
const finishDrawingPolygon = useCallback(() => {
|
||||
const cut = toolSettings.useFogCut;
|
||||
if (!drawingShape) {
|
||||
return;
|
||||
}
|
||||
let polygonShape = {
|
||||
...drawingShape,
|
||||
data: {
|
||||
...drawingShape.data,
|
||||
// Remove the last point as it hasn't been placed yet
|
||||
points: drawingShape.data.points.slice(0, -1),
|
||||
},
|
||||
};
|
||||
|
||||
let polygonShapes = [polygonShape];
|
||||
// Filter out hidden or visible shapes if single layer enabled
|
||||
if (!toolSettings.multilayer) {
|
||||
const shapesToSubtract = shapes.filter((shape) =>
|
||||
cut ? !shape.visible : shape.visible
|
||||
);
|
||||
const subtractAction = new SubtractFogAction(shapesToSubtract);
|
||||
const state = subtractAction.execute({
|
||||
[polygonShape.id]: polygonShape,
|
||||
});
|
||||
polygonShapes = Object.values(state)
|
||||
.filter((shape) => shape.data.points.length > 2)
|
||||
.map((shape) => ({ ...shape, id: shortid.generate() }));
|
||||
}
|
||||
|
||||
if (polygonShapes.length > 0) {
|
||||
if (cut) {
|
||||
// Run a pre-emptive cut action to check whether we've cut anything
|
||||
const cutAction = new CutFogAction(polygonShapes);
|
||||
const state = cutAction.execute(keyBy(shapes, "id"));
|
||||
|
||||
if (Object.keys(state).length === shapes.length) {
|
||||
onShapeError("No fog to cut");
|
||||
} else {
|
||||
onShapesCut(polygonShapes);
|
||||
}
|
||||
} else {
|
||||
onShapesAdd(
|
||||
polygonShapes.map((shape) => ({
|
||||
...drawingShape,
|
||||
data: shape.data,
|
||||
id: shape.id,
|
||||
color: "black",
|
||||
}))
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (cut) {
|
||||
onShapeError("Fog already cut");
|
||||
} else {
|
||||
onShapeError("Fog already placed");
|
||||
}
|
||||
}
|
||||
|
||||
setDrawingShape(null);
|
||||
}, [
|
||||
toolSettings,
|
||||
drawingShape,
|
||||
onShapesCut,
|
||||
onShapesAdd,
|
||||
onShapeError,
|
||||
shapes,
|
||||
]);
|
||||
|
||||
// Add keyboard shortcuts
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (
|
||||
shortcuts.fogFinishPolygon(event) &&
|
||||
toolSettings.type === "polygon" &&
|
||||
drawingShape
|
||||
) {
|
||||
finishDrawingPolygon();
|
||||
}
|
||||
if (shortcuts.fogCancelPolygon(event) && drawingShape) {
|
||||
setDrawingShape(null);
|
||||
}
|
||||
// Remove last point from polygon shape if delete pressed
|
||||
if (
|
||||
shortcuts.delete(event) &&
|
||||
drawingShape &&
|
||||
toolSettings.type === "polygon"
|
||||
) {
|
||||
if (drawingShape.data.points.length > 2) {
|
||||
setDrawingShape((prevShape) => {
|
||||
if (!prevShape) {
|
||||
return prevShape;
|
||||
}
|
||||
return {
|
||||
...prevShape,
|
||||
data: {
|
||||
...prevShape.data,
|
||||
points: [
|
||||
// Shift last point to previous point
|
||||
...prevShape.data.points.slice(0, -2),
|
||||
...prevShape.data.points.slice(-1),
|
||||
],
|
||||
},
|
||||
};
|
||||
});
|
||||
} else {
|
||||
setDrawingShape(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useKeyboard(handleKeyDown);
|
||||
|
||||
// Update shape color when useFogCut changes
|
||||
useEffect(() => {
|
||||
setDrawingShape((prevShape) => {
|
||||
if (!prevShape) {
|
||||
return prevShape;
|
||||
}
|
||||
return {
|
||||
...prevShape,
|
||||
color: toolSettings.useFogCut ? "red" : "black",
|
||||
};
|
||||
});
|
||||
}, [toolSettings.useFogCut]);
|
||||
|
||||
function eraseHoveredShapes() {
|
||||
// Erase
|
||||
if (editingShapes.length > 0) {
|
||||
if (toolSettings.type === "remove") {
|
||||
onShapesRemove(editingShapes.map((shape) => shape.id));
|
||||
} else if (toolSettings.type === "toggle") {
|
||||
onShapesEdit(
|
||||
editingShapes.map((shape) => ({
|
||||
id: shape.id,
|
||||
visible: !shape.visible,
|
||||
}))
|
||||
);
|
||||
}
|
||||
setEditingShapes([]);
|
||||
}
|
||||
}
|
||||
|
||||
function handleShapeOver(shape: Fog, isDown: boolean) {
|
||||
if (shouldHover && isDown) {
|
||||
if (editingShapes.findIndex((s) => s.id === shape.id) === -1) {
|
||||
setEditingShapes((prevShapes) => [...prevShapes, shape]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function reducePoints(acc: number[], point: Vector2) {
|
||||
return [...acc, point.x * mapWidth, point.y * mapHeight];
|
||||
}
|
||||
|
||||
function renderShape(shape: Fog) {
|
||||
const points = shape.data.points.reduce(reducePoints, []);
|
||||
const holes =
|
||||
shape.data.holes &&
|
||||
shape.data.holes.map((hole) => hole.reduce(reducePoints, []));
|
||||
const opacity = editable ? editOpacity : 1;
|
||||
// Control opacity only on fill as using opacity with stroke leads to performance issues
|
||||
const fill = new Color(colors[shape.color] || shape.color)
|
||||
.alpha(opacity)
|
||||
.string();
|
||||
const stroke =
|
||||
editable && active
|
||||
? colors.lightGray
|
||||
: colors[shape.color] || shape.color;
|
||||
return (
|
||||
<HoleyLine
|
||||
key={shape.id}
|
||||
onMouseMove={() => handleShapeOver(shape, isBrushDown)}
|
||||
onTouchOver={() => handleShapeOver(shape, isBrushDown)}
|
||||
onMouseDown={() => handleShapeOver(shape, true)}
|
||||
onTouchStart={() => handleShapeOver(shape, true)}
|
||||
onMouseUp={eraseHoveredShapes}
|
||||
onTouchEnd={eraseHoveredShapes}
|
||||
points={points}
|
||||
stroke={stroke}
|
||||
fill={fill}
|
||||
closed
|
||||
lineCap="round"
|
||||
lineJoin="round"
|
||||
strokeWidth={gridStrokeWidth * shape.strokeWidth}
|
||||
fillPatternImage={patternImage}
|
||||
fillPriority={editable && !shape.visible ? "pattern" : "color"}
|
||||
holes={holes}
|
||||
// Disable collision if the fog is transparent and we're not editing it
|
||||
// This allows tokens to be moved under the fog
|
||||
hitFunc={editable && !active ? () => {} : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderEditingShape(shape: Fog) {
|
||||
const editingShape: Fog = {
|
||||
...shape,
|
||||
color: "primary",
|
||||
};
|
||||
return renderShape(editingShape);
|
||||
}
|
||||
|
||||
function renderPolygonAcceptTick(shape: Fog) {
|
||||
if (shape.data.points.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const isCross = shape.data.points.length < 4;
|
||||
return (
|
||||
<Tick
|
||||
x={shape.data.points[0].x * mapWidth}
|
||||
y={shape.data.points[0].y * mapHeight}
|
||||
scale={1 / stageScale}
|
||||
cross={isCross}
|
||||
onClick={(e) => {
|
||||
e.cancelBubble = true;
|
||||
if (isCross) {
|
||||
setDrawingShape(null);
|
||||
} else {
|
||||
finishDrawingPolygon();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderGuides() {
|
||||
return guides.map((guide, index) => (
|
||||
<Line
|
||||
points={[
|
||||
guide.start.x * mapWidth,
|
||||
guide.start.y * mapHeight,
|
||||
guide.end.x * mapWidth,
|
||||
guide.end.y * mapHeight,
|
||||
]}
|
||||
stroke="hsl(260, 100%, 80%)"
|
||||
key={index}
|
||||
strokeWidth={gridStrokeWidth * 0.25}
|
||||
lineCap="round"
|
||||
lineJoin="round"
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function shapeVisible(shape: Fog) {
|
||||
return (active && !toolSettings.preview) || shape.visible;
|
||||
}
|
||||
|
||||
if (editable) {
|
||||
const visibleShapes = shapes.filter(shapeVisible);
|
||||
// Only use bounding box guides when rendering them
|
||||
if (shouldRenderGuides) {
|
||||
setFogShapeBoundingBoxes(getFogShapesBoundingBoxes(visibleShapes, 5));
|
||||
} else {
|
||||
setFogShapeBoundingBoxes([]);
|
||||
}
|
||||
setFogShapes(visibleShapes);
|
||||
} else {
|
||||
setFogShapes(mergeFogShapes(shapes));
|
||||
}
|
||||
}, [shapes, editable, active, toolSettings, shouldRenderGuides]);
|
||||
|
||||
return (
|
||||
<Group>
|
||||
<Group>{fogShapes.map(renderShape)}</Group>
|
||||
{shouldRenderGuides && renderGuides()}
|
||||
{drawingShape && renderShape(drawingShape)}
|
||||
{drawingShape &&
|
||||
toolSettings &&
|
||||
toolSettings.type === "polygon" &&
|
||||
renderPolygonAcceptTick(drawingShape)}
|
||||
{editingShapes.length > 0 && editingShapes.map(renderEditingShape)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default MapFog;
|
||||
@@ -1,204 +0,0 @@
|
||||
import { 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";
|
||||
import { Map } from "../../types/Map";
|
||||
import { PointsData } from "../../types/Drawing";
|
||||
|
||||
type MapMeasureProps = {
|
||||
map: Map | null;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
type MeasureData = { length: number; points: Vector2[] };
|
||||
|
||||
function MapMeasure({ map, active }: MapMeasureProps) {
|
||||
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<MeasureData | null>(null);
|
||||
const [isBrushDown, setIsBrushDown] = useState(false);
|
||||
|
||||
const gridScale = parseGridScale(active ? grid.measurement.scale : null);
|
||||
|
||||
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() {
|
||||
if (!mapImage) {
|
||||
return;
|
||||
}
|
||||
let position = getRelativePointerPosition(mapImage);
|
||||
if (!position) {
|
||||
return;
|
||||
}
|
||||
if (map?.snapToGrid) {
|
||||
position = snapPositionToGrid(position);
|
||||
}
|
||||
return Vector2.divide(position, {
|
||||
x: mapImage.width(),
|
||||
y: mapImage.height(),
|
||||
});
|
||||
}
|
||||
|
||||
function handleBrushDown() {
|
||||
const brushPosition = getBrushPosition();
|
||||
if (!brushPosition) {
|
||||
return;
|
||||
}
|
||||
const { points } = getDefaultShapeData(
|
||||
"line",
|
||||
brushPosition
|
||||
) as PointsData;
|
||||
const length = 0;
|
||||
setDrawingShapeData({ length, points });
|
||||
setIsBrushDown(true);
|
||||
}
|
||||
|
||||
function handleBrushMove() {
|
||||
const brushPosition = getBrushPosition();
|
||||
if (isBrushDown && drawingShapeData && brushPosition && mapImage) {
|
||||
const { points } = getUpdatedShapeData(
|
||||
"line",
|
||||
drawingShapeData,
|
||||
brushPosition,
|
||||
gridCellNormalizedSize,
|
||||
1,
|
||||
1
|
||||
) as PointsData;
|
||||
// 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: MeasureData) {
|
||||
const linePoints = shapeData.points.reduce(
|
||||
(acc: number[], 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,170 +0,0 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import shortid from "shortid";
|
||||
import { Group } from "react-konva";
|
||||
import Konva from "konva";
|
||||
|
||||
import { useInteractionEmitter } from "../../contexts/MapInteractionContext";
|
||||
import { useMapStage } from "../../contexts/MapStageContext";
|
||||
import { useUserId } from "../../contexts/UserIdContext";
|
||||
|
||||
import Vector2 from "../../helpers/Vector2";
|
||||
import { getRelativePointerPosition } from "../../helpers/konva";
|
||||
|
||||
import useGridSnapping from "../../hooks/useGridSnapping";
|
||||
|
||||
import Note from "../note/Note";
|
||||
|
||||
import { Map } from "../../types/Map";
|
||||
import { Note as NoteType } from "../../types/Note";
|
||||
import {
|
||||
NoteAddEventHander,
|
||||
NoteChangeEventHandler,
|
||||
NoteDragEventHandler,
|
||||
NoteMenuOpenEventHandler,
|
||||
} from "../../types/Events";
|
||||
|
||||
const defaultNoteSize = 2;
|
||||
|
||||
type MapNoteProps = {
|
||||
map: Map | null;
|
||||
active: boolean;
|
||||
onNoteAdd: NoteAddEventHander;
|
||||
onNoteChange: NoteChangeEventHandler;
|
||||
notes: NoteType[];
|
||||
onNoteMenuOpen: NoteMenuOpenEventHandler;
|
||||
draggable: boolean;
|
||||
onNoteDragStart: NoteDragEventHandler;
|
||||
onNoteDragEnd: NoteDragEventHandler;
|
||||
fadeOnHover: boolean;
|
||||
};
|
||||
|
||||
function MapNotes({
|
||||
map,
|
||||
active,
|
||||
onNoteAdd,
|
||||
onNoteChange,
|
||||
notes,
|
||||
onNoteMenuOpen,
|
||||
draggable,
|
||||
onNoteDragStart,
|
||||
onNoteDragEnd,
|
||||
fadeOnHover,
|
||||
}: MapNoteProps) {
|
||||
const interactionEmitter = useInteractionEmitter();
|
||||
const userId = useUserId();
|
||||
const mapStageRef = useMapStage();
|
||||
const [isBrushDown, setIsBrushDown] = useState(false);
|
||||
const [noteData, setNoteData] = useState<NoteType | null>(null);
|
||||
|
||||
const creatingNoteRef = useRef<Konva.Group>(null);
|
||||
|
||||
const snapPositionToGrid = useGridSnapping();
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
const mapStage = mapStageRef.current;
|
||||
|
||||
function getBrushPosition() {
|
||||
if (!mapStage) {
|
||||
return;
|
||||
}
|
||||
const mapImage = mapStage.findOne("#mapImage");
|
||||
let position = getRelativePointerPosition(mapImage);
|
||||
if (!position) {
|
||||
return;
|
||||
}
|
||||
if (map?.snapToGrid) {
|
||||
position = snapPositionToGrid(position);
|
||||
}
|
||||
return Vector2.divide(position, {
|
||||
x: mapImage.width(),
|
||||
y: mapImage.height(),
|
||||
});
|
||||
}
|
||||
|
||||
function handleBrushDown() {
|
||||
const brushPosition = getBrushPosition();
|
||||
if (!brushPosition || !userId) {
|
||||
return;
|
||||
}
|
||||
setNoteData({
|
||||
x: brushPosition.x,
|
||||
y: brushPosition.y,
|
||||
size: defaultNoteSize,
|
||||
text: "",
|
||||
id: shortid.generate(),
|
||||
lastModified: Date.now(),
|
||||
lastModifiedBy: userId,
|
||||
visible: true,
|
||||
locked: false,
|
||||
color: "yellow",
|
||||
textOnly: false,
|
||||
});
|
||||
setIsBrushDown(true);
|
||||
}
|
||||
|
||||
function handleBrushMove() {
|
||||
if (noteData) {
|
||||
const brushPosition = getBrushPosition();
|
||||
if (!brushPosition) {
|
||||
return;
|
||||
}
|
||||
setNoteData((prev) => {
|
||||
if (!prev) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
x: brushPosition.x,
|
||||
y: brushPosition.y,
|
||||
};
|
||||
});
|
||||
setIsBrushDown(true);
|
||||
}
|
||||
}
|
||||
|
||||
function handleBrushUp() {
|
||||
if (noteData && creatingNoteRef.current) {
|
||||
onNoteAdd(noteData);
|
||||
onNoteMenuOpen(noteData.id, creatingNoteRef.current);
|
||||
}
|
||||
setNoteData(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);
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Group>
|
||||
{notes.map((note) => (
|
||||
<Note
|
||||
note={note}
|
||||
map={map}
|
||||
key={note.id}
|
||||
onNoteMenuOpen={onNoteMenuOpen}
|
||||
draggable={draggable && !note.locked}
|
||||
onNoteChange={onNoteChange}
|
||||
onNoteDragStart={onNoteDragStart}
|
||||
onNoteDragEnd={onNoteDragEnd}
|
||||
fadeOnHover={fadeOnHover}
|
||||
/>
|
||||
))}
|
||||
<Group ref={creatingNoteRef}>
|
||||
{isBrushDown && noteData && <Note note={noteData} map={map} />}
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default MapNotes;
|
||||
@@ -1,106 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { Group } from "react-konva";
|
||||
|
||||
import {
|
||||
useMapWidth,
|
||||
useMapHeight,
|
||||
useInteractionEmitter,
|
||||
} from "../../contexts/MapInteractionContext";
|
||||
import { useMapStage } from "../../contexts/MapStageContext";
|
||||
import { useGridStrokeWidth } from "../../contexts/GridContext";
|
||||
|
||||
import {
|
||||
getRelativePointerPositionNormalized,
|
||||
Trail,
|
||||
} from "../../helpers/konva";
|
||||
import Vector2 from "../../helpers/Vector2";
|
||||
|
||||
import colors, { Color } from "../../helpers/colors";
|
||||
|
||||
type MapPointerProps = {
|
||||
active: boolean;
|
||||
position: Vector2;
|
||||
onPointerDown?: (position: Vector2) => void;
|
||||
onPointerMove?: (position: Vector2) => void;
|
||||
onPointerUp?: (position: Vector2) => void;
|
||||
visible: boolean;
|
||||
color: Color;
|
||||
};
|
||||
|
||||
function MapPointer({
|
||||
active,
|
||||
position,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
visible,
|
||||
color,
|
||||
}: MapPointerProps) {
|
||||
const mapWidth = useMapWidth();
|
||||
const mapHeight = useMapHeight();
|
||||
const interactionEmitter = useInteractionEmitter();
|
||||
const gridStrokeWidth = useGridStrokeWidth();
|
||||
const mapStageRef = useMapStage();
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mapStage = mapStageRef.current;
|
||||
|
||||
function getBrushPosition() {
|
||||
if (!mapStage) {
|
||||
return;
|
||||
}
|
||||
const mapImage = mapStage.findOne("#mapImage");
|
||||
return getRelativePointerPositionNormalized(mapImage);
|
||||
}
|
||||
|
||||
function handleBrushDown() {
|
||||
const brushPosition = getBrushPosition();
|
||||
brushPosition && onPointerDown?.(brushPosition);
|
||||
}
|
||||
|
||||
function handleBrushMove() {
|
||||
const brushPosition = getBrushPosition();
|
||||
brushPosition && visible && onPointerMove?.(brushPosition);
|
||||
}
|
||||
|
||||
function handleBrushUp() {
|
||||
const brushPosition = getBrushPosition();
|
||||
brushPosition && onPointerUp?.(brushPosition);
|
||||
}
|
||||
|
||||
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);
|
||||
};
|
||||
});
|
||||
|
||||
const size = 2 * gridStrokeWidth;
|
||||
|
||||
return (
|
||||
<Group>
|
||||
{visible && (
|
||||
<Trail
|
||||
position={Vector2.multiply(position, { x: mapWidth, y: mapHeight })}
|
||||
color={colors[color]}
|
||||
size={size}
|
||||
duration={200}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
MapPointer.defaultProps = {
|
||||
color: "red",
|
||||
};
|
||||
|
||||
export default MapPointer;
|
||||
@@ -1,195 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Group, Line, Rect } from "react-konva";
|
||||
|
||||
import {
|
||||
useDebouncedStageScale,
|
||||
useMapWidth,
|
||||
useMapHeight,
|
||||
useInteractionEmitter,
|
||||
} from "../../contexts/MapInteractionContext";
|
||||
import { useMapStage } from "../../contexts/MapStageContext";
|
||||
|
||||
import {
|
||||
getDefaultShapeData,
|
||||
getUpdatedShapeData,
|
||||
simplifyPoints,
|
||||
} from "../../helpers/drawing";
|
||||
import Vector2 from "../../helpers/Vector2";
|
||||
import colors from "../../helpers/colors";
|
||||
import { getRelativePointerPosition } from "../../helpers/konva";
|
||||
|
||||
import { Selection, SelectToolSettings } from "../../types/Select";
|
||||
import { RectData } from "../../types/Drawing";
|
||||
import {
|
||||
useGridCellNormalizedSize,
|
||||
useGridStrokeWidth,
|
||||
} from "../../contexts/GridContext";
|
||||
|
||||
type MapSelectProps = {
|
||||
active: boolean;
|
||||
toolSettings: SelectToolSettings;
|
||||
};
|
||||
|
||||
function MapSelect({ active, toolSettings }: MapSelectProps) {
|
||||
const stageScale = useDebouncedStageScale();
|
||||
const mapWidth = useMapWidth();
|
||||
const mapHeight = useMapHeight();
|
||||
const interactionEmitter = useInteractionEmitter();
|
||||
|
||||
const gridCellNormalizedSize = useGridCellNormalizedSize();
|
||||
const gridStrokeWidth = useGridStrokeWidth();
|
||||
|
||||
const mapStageRef = useMapStage();
|
||||
const [selection, setSelection] = useState<Selection | null>(null);
|
||||
const [isBrushDown, setIsBrushDown] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
const mapStage = mapStageRef.current;
|
||||
const mapImage = mapStage?.findOne("#mapImage");
|
||||
|
||||
function getBrushPosition() {
|
||||
if (!mapImage) {
|
||||
return;
|
||||
}
|
||||
let position = getRelativePointerPosition(mapImage);
|
||||
if (!position) {
|
||||
return;
|
||||
}
|
||||
return Vector2.divide(position, {
|
||||
x: mapImage.width(),
|
||||
y: mapImage.height(),
|
||||
});
|
||||
}
|
||||
|
||||
function handleBrushDown() {
|
||||
const brushPosition = getBrushPosition();
|
||||
if (!brushPosition) {
|
||||
return;
|
||||
}
|
||||
if (toolSettings.type === "path") {
|
||||
setSelection({
|
||||
type: "path",
|
||||
nodes: [],
|
||||
data: { points: [brushPosition] },
|
||||
});
|
||||
} else {
|
||||
setSelection({
|
||||
type: "rectangle",
|
||||
nodes: [],
|
||||
data: getDefaultShapeData("rectangle", brushPosition) as RectData,
|
||||
});
|
||||
}
|
||||
setIsBrushDown(true);
|
||||
}
|
||||
|
||||
function handleBrushMove() {
|
||||
const brushPosition = getBrushPosition();
|
||||
if (isBrushDown && selection && brushPosition && mapImage) {
|
||||
if (selection.type === "path") {
|
||||
setSelection((prevSelection) => {
|
||||
if (prevSelection?.type !== "path") {
|
||||
return prevSelection;
|
||||
}
|
||||
const prevPoints = prevSelection.data.points;
|
||||
if (
|
||||
Vector2.compare(
|
||||
prevPoints[prevPoints.length - 1],
|
||||
brushPosition,
|
||||
0.001
|
||||
)
|
||||
) {
|
||||
return prevSelection;
|
||||
}
|
||||
const simplified = simplifyPoints(
|
||||
[...prevPoints, brushPosition],
|
||||
1 / 1000 / stageScale
|
||||
);
|
||||
return {
|
||||
...prevSelection,
|
||||
data: { points: simplified },
|
||||
};
|
||||
});
|
||||
} else {
|
||||
setSelection((prevSelection) => {
|
||||
if (prevSelection?.type !== "rectangle") {
|
||||
return prevSelection;
|
||||
}
|
||||
return {
|
||||
...prevSelection,
|
||||
data: getUpdatedShapeData(
|
||||
"rectangle",
|
||||
prevSelection.data,
|
||||
brushPosition,
|
||||
gridCellNormalizedSize,
|
||||
mapWidth,
|
||||
mapHeight
|
||||
) as RectData,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleBrushUp() {
|
||||
setSelection(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 renderSelection(selection: Selection) {
|
||||
const strokeWidth = gridStrokeWidth / stageScale;
|
||||
const defaultProps = {
|
||||
stroke: colors.primary,
|
||||
strokeWidth: strokeWidth,
|
||||
dash: [strokeWidth / 2, strokeWidth * 2],
|
||||
};
|
||||
if (selection.type === "path") {
|
||||
return (
|
||||
<Line
|
||||
points={selection.data.points.reduce(
|
||||
(acc: number[], point) => [
|
||||
...acc,
|
||||
point.x * mapWidth,
|
||||
point.y * mapHeight,
|
||||
],
|
||||
[]
|
||||
)}
|
||||
tension={0.5}
|
||||
closed={false}
|
||||
lineCap="round"
|
||||
lineJoin="round"
|
||||
{...defaultProps}
|
||||
/>
|
||||
);
|
||||
} else if (selection.type === "rectangle") {
|
||||
return (
|
||||
<Rect
|
||||
x={selection.data.x * mapWidth}
|
||||
y={selection.data.y * mapHeight}
|
||||
width={selection.data.width * mapWidth}
|
||||
height={selection.data.height * mapHeight}
|
||||
lineCap="round"
|
||||
lineJoin="round"
|
||||
{...defaultProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return <Group>{selection && renderSelection(selection)}</Group>;
|
||||
}
|
||||
|
||||
export default MapSelect;
|
||||
@@ -1,295 +0,0 @@
|
||||
import { useState, useRef } 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 "../token/TokenStatus";
|
||||
import TokenLabel from "../token/TokenLabel";
|
||||
import TokenOutline from "../token/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 {
|
||||
TokenMenuOpenChangeEventHandler,
|
||||
TokenStateChangeEventHandler,
|
||||
} from "../../types/Events";
|
||||
|
||||
type MapTokenProps = {
|
||||
tokenState: TokenState;
|
||||
onTokenStateChange: TokenStateChangeEventHandler;
|
||||
onTokenMenuOpen: TokenMenuOpenChangeEventHandler;
|
||||
onTokenDragStart: (event: Konva.KonvaEventObject<DragEvent>) => void;
|
||||
onTokenDragEnd: (event: Konva.KonvaEventObject<DragEvent>) => void;
|
||||
draggable: boolean;
|
||||
fadeOnHover: boolean;
|
||||
map: Map;
|
||||
};
|
||||
|
||||
function MapToken({
|
||||
tokenState,
|
||||
onTokenStateChange,
|
||||
onTokenMenuOpen,
|
||||
onTokenDragStart,
|
||||
onTokenDragEnd,
|
||||
draggable,
|
||||
fadeOnHover,
|
||||
map,
|
||||
}: 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 intersectingTokensRef = useRef<Konva.Node[]>([]);
|
||||
const previousDragPositionRef = useRef({ x: 0, y: 0 });
|
||||
|
||||
function handleDragStart(event: Konva.KonvaEventObject<DragEvent>) {
|
||||
const tokenGroup = event.target;
|
||||
|
||||
if (tokenState.category === "vehicle") {
|
||||
previousDragPositionRef.current = tokenGroup.position();
|
||||
const tokenIntersection = new Intersection(
|
||||
getScaledOutline(tokenState, tokenWidth, tokenHeight),
|
||||
{ x: tokenX - tokenWidth / 2, y: tokenY - tokenHeight / 2 },
|
||||
{ x: tokenX, y: tokenY },
|
||||
tokenState.rotation
|
||||
);
|
||||
|
||||
// Find all other tokens on the map
|
||||
const layer = tokenGroup.getLayer() as Konva.Layer;
|
||||
const tokens = layer.find(".character");
|
||||
for (let other of tokens) {
|
||||
if (other === tokenGroup) {
|
||||
continue;
|
||||
}
|
||||
if (tokenIntersection.intersects(other.position())) {
|
||||
intersectingTokensRef.current.push(other);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onTokenDragStart(event);
|
||||
}
|
||||
|
||||
function handleDragMove(event: Konva.KonvaEventObject<DragEvent>) {
|
||||
const tokenGroup = event.target;
|
||||
// Snap to corners of grid
|
||||
if (map.snapToGrid) {
|
||||
tokenGroup.position(snapPositionToGrid(tokenGroup.position()));
|
||||
}
|
||||
if (tokenState.category === "vehicle") {
|
||||
const deltaPosition = Vector2.subtract(
|
||||
tokenGroup.position(),
|
||||
previousDragPositionRef.current
|
||||
);
|
||||
for (let other of intersectingTokensRef.current) {
|
||||
other.position(Vector2.add(other.position(), deltaPosition));
|
||||
}
|
||||
previousDragPositionRef.current = tokenGroup.position();
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragEnd(event: Konva.KonvaEventObject<DragEvent>) {
|
||||
const tokenGroup = event.target;
|
||||
|
||||
const mountChanges: Record<string, Partial<TokenState>> = {};
|
||||
if (tokenState.category === "vehicle") {
|
||||
for (let other of intersectingTokensRef.current) {
|
||||
mountChanges[other.id()] = {
|
||||
x: other.x() / mapWidth,
|
||||
y: other.y() / mapHeight,
|
||||
lastModifiedBy: userId,
|
||||
lastModified: Date.now(),
|
||||
};
|
||||
}
|
||||
intersectingTokensRef.current = [];
|
||||
}
|
||||
|
||||
setPreventMapInteraction(false);
|
||||
onTokenStateChange({
|
||||
...mountChanges,
|
||||
[tokenState.id]: {
|
||||
x: tokenGroup.x() / mapWidth,
|
||||
y: tokenGroup.y() / mapHeight,
|
||||
lastModifiedBy: userId,
|
||||
lastModified: Date.now(),
|
||||
},
|
||||
});
|
||||
onTokenDragEnd(event);
|
||||
}
|
||||
|
||||
function handleClick(event: Konva.KonvaEventObject<MouseEvent>) {
|
||||
if (draggable) {
|
||||
const tokenImage = event.target;
|
||||
onTokenMenuOpen(tokenState.id, tokenImage);
|
||||
}
|
||||
}
|
||||
|
||||
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 && map.owner === userId) {
|
||||
tokenPointerDownTimeRef.current = event.evt.timeStamp;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerUp(event: Konva.KonvaEventObject<PointerEvent>) {
|
||||
if (draggable) {
|
||||
setPreventMapInteraction(false);
|
||||
}
|
||||
// Check token click when locked and we are the map owner
|
||||
// We can't use onClick because that doesn't check pointer distance
|
||||
if (tokenState.locked && map.owner === userId) {
|
||||
// If down and up time is small trigger a click
|
||||
const delta = event.evt.timeStamp - tokenPointerDownTimeRef.current;
|
||||
if (delta < 300) {
|
||||
const tokenImage = event.target;
|
||||
onTokenMenuOpen(tokenState.id, tokenImage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerEnter() {
|
||||
if (fadeOnHover) {
|
||||
setTokenOpacity(0.5);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerLeave() {
|
||||
if (tokenOpacity !== 1.0) {
|
||||
setTokenOpacity(1.0);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
// 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
|
||||
width={tokenWidth}
|
||||
height={tokenHeight}
|
||||
x={0}
|
||||
y={0}
|
||||
rotation={tokenState.rotation}
|
||||
offsetX={tokenWidth / 2}
|
||||
offsetY={tokenHeight / 2}
|
||||
>
|
||||
<TokenOutline
|
||||
outline={getScaledOutline(tokenState, tokenWidth, tokenHeight)}
|
||||
hidden={!!tokenImage}
|
||||
/>
|
||||
</Group>
|
||||
<KonvaImage
|
||||
width={tokenWidth}
|
||||
height={tokenHeight}
|
||||
x={0}
|
||||
y={0}
|
||||
image={tokenImage}
|
||||
rotation={tokenState.rotation}
|
||||
offsetX={tokenWidth / 2}
|
||||
offsetY={tokenHeight / 2}
|
||||
hitFunc={() => {}}
|
||||
/>
|
||||
<Group offsetX={tokenWidth / 2} offsetY={tokenHeight / 2}>
|
||||
{tokenState.statuses?.length > 0 ? (
|
||||
<TokenStatus
|
||||
tokenState={tokenState}
|
||||
width={tokenWidth}
|
||||
height={tokenHeight}
|
||||
/>
|
||||
) : null}
|
||||
{tokenState.label ? (
|
||||
<TokenLabel
|
||||
tokenState={tokenState}
|
||||
width={tokenWidth}
|
||||
height={tokenHeight}
|
||||
/>
|
||||
) : null}
|
||||
</Group>
|
||||
</animated.Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default MapToken;
|
||||
@@ -1,115 +0,0 @@
|
||||
import { Group } from "react-konva";
|
||||
import {
|
||||
TokenMenuOpenChangeEventHandler,
|
||||
TokenStateChangeEventHandler,
|
||||
} from "../../types/Events";
|
||||
import { Map, MapToolId } from "../../types/Map";
|
||||
import { MapState } from "../../types/MapState";
|
||||
import { TokenCategory, TokenDraggingOptions } from "../../types/Token";
|
||||
import { TokenState } from "../../types/TokenState";
|
||||
|
||||
import MapToken from "./MapToken";
|
||||
|
||||
type MapTokensProps = {
|
||||
map: Map;
|
||||
mapState: MapState;
|
||||
tokenDraggingOptions?: TokenDraggingOptions;
|
||||
setTokenDraggingOptions: (options: TokenDraggingOptions) => void;
|
||||
onMapTokenStateChange: TokenStateChangeEventHandler;
|
||||
onTokenMenuOpen: TokenMenuOpenChangeEventHandler;
|
||||
selectedToolId: MapToolId;
|
||||
disabledTokens: Record<string, boolean>;
|
||||
};
|
||||
|
||||
function MapTokens({
|
||||
map,
|
||||
mapState,
|
||||
tokenDraggingOptions,
|
||||
setTokenDraggingOptions,
|
||||
onMapTokenStateChange,
|
||||
onTokenMenuOpen,
|
||||
selectedToolId,
|
||||
disabledTokens,
|
||||
}: MapTokensProps) {
|
||||
function getMapTokenCategoryWeight(category: TokenCategory) {
|
||||
switch (category) {
|
||||
case "character":
|
||||
return 0;
|
||||
case "vehicle":
|
||||
return 1;
|
||||
case "prop":
|
||||
return 2;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort so vehicles render below other tokens
|
||||
function sortMapTokenStates(
|
||||
a: TokenState,
|
||||
b: TokenState,
|
||||
tokenDraggingOptions?: TokenDraggingOptions
|
||||
) {
|
||||
// If categories are different sort in order "prop", "vehicle", "character"
|
||||
if (b.category !== a.category) {
|
||||
const aWeight = getMapTokenCategoryWeight(a.category);
|
||||
const bWeight = getMapTokenCategoryWeight(b.category);
|
||||
return bWeight - aWeight;
|
||||
} else if (
|
||||
tokenDraggingOptions &&
|
||||
tokenDraggingOptions.dragging &&
|
||||
tokenDraggingOptions.tokenState.id === a.id
|
||||
) {
|
||||
// If dragging token a move above
|
||||
return 1;
|
||||
} else if (
|
||||
tokenDraggingOptions &&
|
||||
tokenDraggingOptions.dragging &&
|
||||
tokenDraggingOptions.tokenState.id === b.id
|
||||
) {
|
||||
// If dragging token b move above
|
||||
return -1;
|
||||
} else {
|
||||
// Else sort so last modified is on top
|
||||
return a.lastModified - b.lastModified;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Group>
|
||||
{Object.values(mapState.tokens)
|
||||
.sort((a, b) => sortMapTokenStates(a, b, tokenDraggingOptions))
|
||||
.map((tokenState) => (
|
||||
<MapToken
|
||||
key={tokenState.id}
|
||||
tokenState={tokenState}
|
||||
onTokenStateChange={onMapTokenStateChange}
|
||||
onTokenMenuOpen={onTokenMenuOpen}
|
||||
onTokenDragStart={(e) =>
|
||||
setTokenDraggingOptions({
|
||||
dragging: true,
|
||||
tokenState,
|
||||
tokenNode: e.target,
|
||||
})
|
||||
}
|
||||
onTokenDragEnd={() =>
|
||||
tokenDraggingOptions &&
|
||||
setTokenDraggingOptions({
|
||||
...tokenDraggingOptions,
|
||||
dragging: false,
|
||||
})
|
||||
}
|
||||
draggable={
|
||||
selectedToolId === "move" &&
|
||||
!(tokenState.id in disabledTokens) &&
|
||||
!tokenState.locked
|
||||
}
|
||||
fadeOnHover={selectedToolId === "drawing"}
|
||||
map={map}
|
||||
/>
|
||||
))}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default MapTokens;
|
||||
Reference in New Issue
Block a user