Refactor konva components and map tools

This commit is contained in:
Mitchell McCaffrey
2021-07-19 15:28:09 +10:00
parent fa2190dd7d
commit fa62783b9c
26 changed files with 995 additions and 894 deletions

View File

@@ -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>
);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;