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

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

View File

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

View File

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

View File

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

View File

@ -16,9 +16,9 @@ import {
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 TokenStatus from "./TokenStatus";
import TokenLabel from "./TokenLabel";
import TokenOutline from "./TokenOutline";
import { Intersection, getScaledOutline } from "../../helpers/token";
import Vector2 from "../../helpers/Vector2";
@ -27,6 +27,7 @@ import { tokenSources } from "../../tokens";
import { TokenState } from "../../types/TokenState";
import { Map } from "../../types/Map";
import {
TokenDragEventHandler,
TokenMenuOpenChangeEventHandler,
TokenStateChangeEventHandler,
} from "../../types/Events";
@ -35,14 +36,14 @@ type MapTokenProps = {
tokenState: TokenState;
onTokenStateChange: TokenStateChangeEventHandler;
onTokenMenuOpen: TokenMenuOpenChangeEventHandler;
onTokenDragStart: (event: Konva.KonvaEventObject<DragEvent>) => void;
onTokenDragEnd: (event: Konva.KonvaEventObject<DragEvent>) => void;
onTokenDragStart: TokenDragEventHandler;
onTokenDragEnd: TokenDragEventHandler;
draggable: boolean;
fadeOnHover: boolean;
map: Map;
};
function MapToken({
function Token({
tokenState,
onTokenStateChange,
onTokenMenuOpen,
@ -95,7 +96,7 @@ function MapToken({
}
}
onTokenDragStart(event);
onTokenDragStart(event, tokenState.id);
}
function handleDragMove(event: Konva.KonvaEventObject<DragEvent>) {
@ -142,7 +143,7 @@ function MapToken({
lastModified: Date.now(),
},
});
onTokenDragEnd(event);
onTokenDragEnd(event, tokenState.id);
}
function handleClick(event: Konva.KonvaEventObject<MouseEvent>) {
@ -292,4 +293,4 @@ function MapToken({
);
}
export default MapToken;
export default Token;

View File

@ -0,0 +1,47 @@
import { Rect, Circle, Line } from "react-konva";
import colors from "../../helpers/colors";
import { Outline } from "../../types/Outline";
type TokenOutlineProps = {
outline: Outline;
hidden: boolean;
};
function TokenOutline({ outline, hidden }: TokenOutlineProps) {
const sharedProps = {
fill: colors.black,
opacity: hidden ? 0 : 0.8,
};
if (outline.type === "rect") {
return (
<Rect
width={outline.width}
height={outline.height}
x={outline.x}
y={outline.y}
{...sharedProps}
/>
);
} else if (outline.type === "circle") {
return (
<Circle
radius={outline.radius}
x={outline.x}
y={outline.y}
{...sharedProps}
/>
);
} else {
return (
<Line
points={outline.points}
closed
tension={outline.points.length < 200 ? 0 : 0.33}
{...sharedProps}
/>
);
}
}
export default TokenOutline;

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

View File

@ -6,7 +6,7 @@ import { useDataURL } from "../../contexts/AssetsContext";
import { tokenSources as defaultTokenSources } from "../../tokens";
import { Token } from "../../types/Token";
import { TokenOutlineSVG } from "./TokenOutline";
import TokenOutlineSVG from "./TokenOutline";
type TokenImageProps = {
token: Token;

View File

@ -1,6 +1,3 @@
import { Rect, Circle, Line } from "react-konva";
import colors from "../../helpers/colors";
import { Outline } from "../../types/Outline";
type TokenOutlineSVGProps = {
@ -65,45 +62,4 @@ export function TokenOutlineSVG({
}
}
type TokenOutlineProps = {
outline: Outline;
hidden: boolean;
};
function TokenOutline({ outline, hidden }: TokenOutlineProps) {
const sharedProps = {
fill: colors.black,
opacity: hidden ? 0 : 0.8,
};
if (outline.type === "rect") {
return (
<Rect
width={outline.width}
height={outline.height}
x={outline.x}
y={outline.y}
{...sharedProps}
/>
);
} else if (outline.type === "circle") {
return (
<Circle
radius={outline.radius}
x={outline.x}
y={outline.y}
{...sharedProps}
/>
);
} else {
return (
<Line
points={outline.points}
closed
tension={outline.points.length < 200 ? 0 : 0.33}
{...sharedProps}
/>
);
}
}
export default TokenOutline;
export default TokenOutlineSVG;

View File

@ -1,6 +1,6 @@
import { useState, useEffect } from "react";
import shortid from "shortid";
import { Group, Line, Rect, Circle } from "react-konva";
import { Group } from "react-konva";
import {
useDebouncedStageScale,
@ -20,11 +20,12 @@ import {
getUpdatedShapeData,
simplifyPoints,
} from "../../helpers/drawing";
import colors from "../../helpers/colors";
import { getRelativePointerPosition } from "../../helpers/konva";
import useGridSnapping from "../../hooks/useGridSnapping";
import DrawingShape from "../konva/Drawing";
import { Map } from "../../types/Map";
import {
Drawing,
@ -45,7 +46,7 @@ type MapDrawingProps = {
toolSettings: DrawingToolSettings;
};
function MapDrawing({
function DrawingTool({
map,
drawings,
onDrawingAdd: onShapeAdd,
@ -226,94 +227,23 @@ function MapDrawing({
}
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}
/>
);
}
}
return (
<DrawingShape
drawing={shape}
key={shape.id}
onMouseMove={() => handleShapeOver(shape, isBrushDown)}
onTouchOver={() => handleShapeOver(shape, isBrushDown)}
onMouseDown={() => handleShapeOver(shape, true)}
onTouchStart={() => handleShapeOver(shape, true)}
onMouseUp={eraseHoveredShapes}
onTouchEnd={eraseHoveredShapes}
strokeWidth={
shape.type === "path" || shape.shapeType === "line"
? gridStrokeWidth * shape.strokeWidth
: 0
}
/>
);
}
function renderErasingDrawing(drawing: Drawing) {
@ -333,4 +263,4 @@ function MapDrawing({
);
}
export default MapDrawing;
export default DrawingTool;

View File

@ -34,11 +34,7 @@ import {
Guide,
} from "../../helpers/drawing";
import colors from "../../helpers/colors";
import {
HoleyLine,
Tick,
getRelativePointerPosition,
} from "../../helpers/konva";
import { getRelativePointerPosition } from "../../helpers/konva";
import { keyBy } from "../../helpers/shared";
import SubtractFogAction from "../../actions/SubtractFogAction";
@ -51,6 +47,9 @@ import shortcuts from "../../shortcuts";
import { Map } from "../../types/Map";
import { Fog, FogToolSettings } from "../../types/Fog";
import FogShape from "../konva/Fog";
import Tick from "../konva/Tick";
type FogAddEventHandler = (fog: Fog[]) => void;
type FogCutEventHandler = (fog: Fog[]) => void;
type FogRemoveEventHandler = (fogId: string[]) => void;
@ -70,7 +69,7 @@ type MapFogProps = {
editable: boolean;
};
function MapFog({
function FogTool({
map,
shapes,
onShapesAdd,
@ -572,43 +571,32 @@ function MapFog({
}
}
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;
// 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();
return (
<HoleyLine
<FogShape
key={shape.id}
fog={shape}
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"
opacity={opacity}
strokeWidth={gridStrokeWidth * shape.strokeWidth}
fill={fill}
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}
@ -698,4 +686,4 @@ function MapFog({
);
}
export default MapFog;
export default FogTool;

View File

@ -35,7 +35,7 @@ type MapMeasureProps = {
type MeasureData = { length: number; points: Vector2[] };
function MapMeasure({ map, active }: MapMeasureProps) {
function MeasureTool({ map, active }: MapMeasureProps) {
const stageScale = useDebouncedStageScale();
const mapWidth = useMapWidth();
const mapHeight = useMapHeight();
@ -201,4 +201,4 @@ function MapMeasure({ map, active }: MapMeasureProps) {
return <Group>{drawingShapeData && renderShape(drawingShapeData)}</Group>;
}
export default MapMeasure;
export default MeasureTool;

View File

@ -12,7 +12,7 @@ import { getRelativePointerPosition } from "../../helpers/konva";
import useGridSnapping from "../../hooks/useGridSnapping";
import Note from "../note/Note";
import Note from "../konva/Note";
import { Map } from "../../types/Map";
import { Note as NoteType } from "../../types/Note";
@ -38,7 +38,7 @@ type MapNoteProps = {
fadeOnHover: boolean;
};
function MapNotes({
function NoteTool({
map,
active,
onNoteAdd,
@ -167,4 +167,4 @@ function MapNotes({
);
}
export default MapNotes;
export default NoteTool;

View File

@ -9,12 +9,11 @@ import {
import { useMapStage } from "../../contexts/MapStageContext";
import { useGridStrokeWidth } from "../../contexts/GridContext";
import {
getRelativePointerPositionNormalized,
Trail,
} from "../../helpers/konva";
import { getRelativePointerPositionNormalized } from "../../helpers/konva";
import Vector2 from "../../helpers/Vector2";
import Pointer from "../konva/Pointer";
import colors, { Color } from "../../helpers/colors";
type MapPointerProps = {
@ -27,7 +26,7 @@ type MapPointerProps = {
color: Color;
};
function MapPointer({
function PointerTool({
active,
position,
onPointerDown,
@ -88,7 +87,7 @@ function MapPointer({
return (
<Group>
{visible && (
<Trail
<Pointer
position={Vector2.multiply(position, { x: mapWidth, y: mapHeight })}
color={colors[color]}
size={size}
@ -99,8 +98,8 @@ function MapPointer({
);
}
MapPointer.defaultProps = {
PointerTool.defaultProps = {
color: "red",
};
export default MapPointer;
export default PointerTool;

View File

@ -30,7 +30,7 @@ type MapSelectProps = {
toolSettings: SelectToolSettings;
};
function MapSelect({ active, toolSettings }: MapSelectProps) {
function SelectTool({ active, toolSettings }: MapSelectProps) {
const stageScale = useDebouncedStageScale();
const mapWidth = useMapWidth();
const mapHeight = useMapHeight();
@ -192,4 +192,4 @@ function MapSelect({ active, toolSettings }: MapSelectProps) {
return <Group>{selection && renderSelection(selection)}</Group>;
}
export default MapSelect;
export default SelectTool;

64
src/helpers/konva.ts Normal file
View File

@ -0,0 +1,64 @@
import Konva from "konva";
import Vector2 from "./Vector2";
/**
* @param {Konva.Node} node
* @returns {Vector2}
*/
export function getRelativePointerPosition(
node: Konva.Node
): Vector2 | undefined {
let transform = node.getAbsoluteTransform().copy();
transform.invert();
let position = node.getStage()?.getPointerPosition();
if (!position) {
return;
}
return transform.point(position);
}
export function getRelativePointerPositionNormalized(
node: Konva.Node
): Vector2 | undefined {
const relativePosition = getRelativePointerPosition(node);
if (!relativePosition) {
return;
}
return {
x: relativePosition.x / node.width(),
y: relativePosition.y / node.height(),
};
}
/**
* Converts points from alternating array form to vector array form
* @param {number[]} numbers points in an x, y alternating array
* @returns {Vector2[]} a `Vector2` array
*/
export function convertNumbersToPoints(numbers: number[]): Vector2[] {
return numbers.reduce((acc: Vector2[], _, i, arr) => {
if (i % 2 === 0) {
acc.push({ x: arr[i], y: arr[i + 1] });
}
return acc;
}, []);
}
/**
* Converts points from vector array form to alternating number array form
* @param {Vector2[]} points
* @returns {number[]}
*/
export function convertPointsToNumbers(points: Vector2[]): number[] {
return points.reduce(
(acc: number[], point: Vector2) => [...acc, point.x, point.y],
[]
);
}
export function scaleAndFlattenPoints(
points: Vector2[],
scale: Vector2
): number[] {
return convertPointsToNumbers(points.map((p) => Vector2.multiply(p, scale)));
}

View File

@ -1,372 +0,0 @@
import { useState, useEffect, useRef } from "react";
import Konva from "konva";
import { Line, Group, Path, Circle } from "react-konva";
import Color from "color";
import Vector2 from "./Vector2";
type HoleyLineProps = {
holes: number[][];
} & Konva.LineConfig;
// Holes should be wound in the opposite direction as the containing points array
export function HoleyLine({ holes, ...props }: HoleyLineProps) {
// Converted from https://github.com/rfestag/konva/blob/master/src/shapes/Line.ts
function drawLine(
points: number[],
context: Konva.Context,
shape: Konva.Line
) {
const length = points.length;
const tension = shape.tension();
const closed = shape.closed();
const bezier = shape.bezier();
if (!length) {
return;
}
context.moveTo(points[0], points[1]);
if (tension !== 0 && length > 4) {
const tensionPoints = shape.getTensionPoints();
const tensionLength = tensionPoints.length;
let n = closed ? 0 : 4;
if (!closed) {
context.quadraticCurveTo(
tensionPoints[0],
tensionPoints[1],
tensionPoints[2],
tensionPoints[3]
);
}
while (n < tensionLength - 2) {
context.bezierCurveTo(
tensionPoints[n++],
tensionPoints[n++],
tensionPoints[n++],
tensionPoints[n++],
tensionPoints[n++],
tensionPoints[n++]
);
}
if (!closed) {
context.quadraticCurveTo(
tensionPoints[tensionLength - 2],
tensionPoints[tensionLength - 1],
points[length - 2],
points[length - 1]
);
}
} else if (bezier) {
// no tension but bezier
let n = 2;
while (n < length) {
context.bezierCurveTo(
points[n++],
points[n++],
points[n++],
points[n++],
points[n++],
points[n++]
);
}
} else {
// no tension
for (let n = 2; n < length; n += 2) {
context.lineTo(points[n], points[n + 1]);
}
}
}
// Draw points and holes
function sceneFunc(context: Konva.Context, shape: Konva.Line) {
const points = shape.points();
const closed = shape.closed();
if (!points.length) {
return;
}
context.beginPath();
drawLine(points, context, shape);
context.beginPath();
drawLine(points, context, shape);
// closed e.g. polygons and blobs
if (closed) {
context.closePath();
if (holes && holes.length) {
for (let hole of holes) {
drawLine(hole, context, shape);
context.closePath();
}
}
context.fillStrokeShape(shape);
} else {
// open e.g. lines and splines
context.strokeShape(shape);
}
}
return <Line {...props} sceneFunc={sceneFunc as any} />;
}
type TickProps = {
x: number;
y: number;
scale: number;
onClick: (evt: Konva.KonvaEventObject<MouseEvent>) => void;
cross: boolean;
};
export function Tick({ x, y, scale, onClick, cross }: TickProps) {
const [fill, setFill] = useState("white");
function handleEnter() {
setFill("hsl(260, 100%, 80%)");
}
function handleLeave() {
setFill("white");
}
return (
<Group
x={x}
y={y}
scaleX={scale}
scaleY={scale}
onMouseEnter={handleEnter}
onMouseLeave={handleLeave}
onClick={onClick}
onTap={onClick}
>
<Circle radius={12} fill="hsla(230, 25%, 18%, 0.8)" />
<Path
offsetX={12}
offsetY={12}
fill={fill}
data={
cross
? "M18.3 5.71c-.39-.39-1.02-.39-1.41 0L12 10.59 7.11 5.7c-.39-.39-1.02-.39-1.41 0-.39.39-.39 1.02 0 1.41L10.59 12 5.7 16.89c-.39.39-.39 1.02 0 1.41.39.39 1.02.39 1.41 0L12 13.41l4.89 4.89c.39.39 1.02.39 1.41 0 .39-.39.39-1.02 0-1.41L13.41 12l4.89-4.89c.38-.38.38-1.02 0-1.4z"
: "M9 16.2l-3.5-3.5c-.39-.39-1.01-.39-1.4 0-.39.39-.39 1.01 0 1.4l4.19 4.19c.39.39 1.02.39 1.41 0L20.3 7.7c.39-.39.39-1.01 0-1.4-.39-.39-1.01-.39-1.4 0L9 16.2z"
}
/>
</Group>
);
}
interface TrailPoint extends Vector2 {
lifetime: number;
}
type TrailProps = {
position: Vector2;
size: number;
duration: number;
segments: number;
color: string;
};
export function Trail({
position,
size,
duration,
segments,
color,
}: TrailProps) {
const trailRef = useRef<Konva.Line>(null);
const pointsRef = useRef<TrailPoint[]>([]);
const prevPositionRef = useRef(position);
const positionRef = useRef(position);
const circleRef = useRef<Konva.Circle>(null);
// Color of the end of the trail
const transparentColorRef = useRef(
Color(color).lighten(0.5).alpha(0).string()
);
useEffect(() => {
// Lighten color to give it a `glow` effect
transparentColorRef.current = Color(color).lighten(0.5).alpha(0).string();
}, [color]);
// Keep track of position so we can use it in the trail animation
useEffect(() => {
positionRef.current = position;
}, [position]);
// Add a new point every time position is changed
useEffect(() => {
if (Vector2.compare(position, prevPositionRef.current, 0.0001)) {
return;
}
pointsRef.current.push({ ...position, lifetime: duration });
prevPositionRef.current = position;
}, [position, duration]);
// Advance lifetime of trail
useEffect(() => {
let prevTime = performance.now();
let request = requestAnimationFrame(animate);
function animate(time: number) {
request = requestAnimationFrame(animate);
const deltaTime = time - prevTime;
prevTime = time;
if (pointsRef.current.length === 0) {
return;
}
let expired = 0;
for (let point of pointsRef.current) {
point.lifetime -= deltaTime;
if (point.lifetime < 0) {
expired++;
}
}
if (expired > 0) {
pointsRef.current = pointsRef.current.slice(expired);
}
// Update the circle position to keep it in sync with the trail
if (circleRef && circleRef.current) {
circleRef.current.x(positionRef.current.x);
circleRef.current.y(positionRef.current.y);
}
if (trailRef && trailRef.current) {
trailRef.current.getLayer()?.draw();
}
}
return () => {
cancelAnimationFrame(request);
};
}, []);
// Custom scene function for drawing a trail from a line
function sceneFunc(context: Konva.Context) {
// Resample points to ensure a smooth trail
const resampledPoints = Vector2.resample(pointsRef.current, segments);
if (resampledPoints.length === 0) {
return;
}
// Draws a line offset in the direction perpendicular to its travel direction
const drawOffsetLine = (from: Vector2, to: Vector2, alpha: number) => {
const forward = Vector2.normalize(Vector2.subtract(from, to));
// Rotate the forward vector 90 degrees based off of the direction
const side = Vector2.rotate90(forward);
// Offset the `to` position by the size of the point and in the side direction
const toSize = (alpha * size) / 2;
const toOffset = Vector2.add(to, Vector2.multiply(side, toSize));
context.lineTo(toOffset.x, toOffset.y);
};
context.beginPath();
// Sample the points starting from the tail then traverse counter clockwise drawing each point
// offset to make a taper, stops at the base of the trail
context.moveTo(resampledPoints[0].x, resampledPoints[0].y);
for (let i = 1; i < resampledPoints.length; i++) {
const from = resampledPoints[i - 1];
const to = resampledPoints[i];
drawOffsetLine(from, to, i / resampledPoints.length);
}
// Start from the base of the trail and continue drawing down back to the end of the tail
for (let i = resampledPoints.length - 2; i >= 0; i--) {
const from = resampledPoints[i + 1];
const to = resampledPoints[i];
drawOffsetLine(from, to, i / resampledPoints.length);
}
context.lineTo(resampledPoints[0].x, resampledPoints[0].y);
context.closePath();
// Create a radial gradient from the center of the trail to the tail
const gradientCenter = resampledPoints[resampledPoints.length - 1];
const gradientEnd = resampledPoints[0];
const gradientRadius = Vector2.magnitude(
Vector2.subtract(gradientCenter, gradientEnd)
);
let gradient = context.createRadialGradient(
gradientCenter.x,
gradientCenter.y,
0,
gradientCenter.x,
gradientCenter.y,
gradientRadius
);
gradient.addColorStop(0, color);
gradient.addColorStop(1, transparentColorRef.current);
// @ts-ignore
context.fillStyle = gradient;
context.fill();
}
return (
<Group>
<Line sceneFunc={sceneFunc} ref={trailRef} />
<Circle
x={position.x}
y={position.y}
fill={color}
width={size}
height={size}
ref={circleRef}
/>
</Group>
);
}
Trail.defaultProps = {
// Duration of each point in milliseconds
duration: 200,
// Number of segments in the trail, resampled from the points
segments: 20,
};
/**
* @param {Konva.Node} node
* @returns {Vector2}
*/
export function getRelativePointerPosition(
node: Konva.Node
): Vector2 | undefined {
let transform = node.getAbsoluteTransform().copy();
transform.invert();
let position = node.getStage()?.getPointerPosition();
if (!position) {
return;
}
return transform.point(position);
}
export function getRelativePointerPositionNormalized(
node: Konva.Node
): Vector2 | undefined {
const relativePosition = getRelativePointerPosition(node);
if (!relativePosition) {
return;
}
return {
x: relativePosition.x / node.width(),
y: relativePosition.y / node.height(),
};
}
/**
* Converts points from alternating array form to vector array form
* @param {number[]} points points in an x, y alternating array
* @returns {Vector2[]} a `Vector2` array
*/
export function convertPointArray(points: number[]): Vector2[] {
return points.reduce((acc: Vector2[], _, i, arr) => {
if (i % 2 === 0) {
acc.push({ x: arr[i], y: arr[i + 1] });
}
return acc;
}, []);
}

118
src/hooks/useMapNotes.tsx Normal file
View File

@ -0,0 +1,118 @@
import Konva from "konva";
import { KonvaEventObject } from "konva/lib/Node";
import { useState } from "react";
import NoteDragOverlay from "../components/note/NoteDragOverlay";
import NoteMenu from "../components/note/NoteMenu";
import NoteTool from "../components/tools/NoteTool";
import { NoteChangeEventHandler, NoteRemoveEventHander } from "../types/Events";
import { Map, MapToolId } from "../types/Map";
import { MapState } from "../types/MapState";
import { Note, NoteDraggingOptions, NoteMenuOptions } from "../types/Note";
function useMapNotes(
map: Map | null,
mapState: MapState | null,
onNoteChange: NoteChangeEventHandler,
onNoteRemove: NoteRemoveEventHander,
selectedToolId: MapToolId,
allowNoteEditing: boolean
) {
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 handleNoteDragStart(
event: KonvaEventObject<DragEvent>,
noteId: string
) {
setNoteDraggingOptions({ dragging: true, noteId, noteGroup: event.target });
}
function handleNoteDragEnd() {
noteDraggingOptions &&
setNoteDraggingOptions({ ...noteDraggingOptions, dragging: false });
}
function handleNoteRemove(noteId: string) {
onNoteRemove(noteId);
setNoteDraggingOptions(undefined);
}
const notes = (
<NoteTool
map={map}
active={selectedToolId === "note"}
onNoteAdd={onNoteChange}
onNoteChange={onNoteChange}
notes={
mapState
? Object.values(mapState.notes).sort((a, b) =>
sortNotes(a, b, noteDraggingOptions)
)
: []
}
onNoteMenuOpen={handleNoteMenuOpen}
draggable={
allowNoteEditing &&
(selectedToolId === "note" || selectedToolId === "move")
}
onNoteDragStart={handleNoteDragStart}
onNoteDragEnd={handleNoteDragEnd}
fadeOnHover={selectedToolId === "drawing"}
/>
);
const noteMenu = (
<NoteMenu
isOpen={isNoteMenuOpen}
onRequestClose={() => setIsNoteMenuOpen(false)}
onNoteChange={onNoteChange}
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={handleNoteRemove}
/>
) : null;
return { notes, noteMenu, noteDragOverlay };
}
export default useMapNotes;
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;
}
}

160
src/hooks/useMapTokens.tsx Normal file
View File

@ -0,0 +1,160 @@
import { Group } from "react-konva";
import { Map, MapToolId } from "../types/Map";
import { MapState } from "../types/MapState";
import {
TokenCategory,
TokenDraggingOptions,
TokenMenuOptions,
} from "../types/Token";
import { TokenState } from "../types/TokenState";
import {
MapTokenStateRemoveHandler,
TokenStateChangeEventHandler,
} from "../types/Events";
import { useState } from "react";
import Konva from "konva";
import Token from "../components/konva/Token";
import { KonvaEventObject } from "konva/lib/Node";
import TokenMenu from "../components/token/TokenMenu";
import TokenDragOverlay from "../components/token/TokenDragOverlay";
function useMapTokens(
map: Map | null,
mapState: MapState | null,
onTokenStateChange: TokenStateChangeEventHandler,
onTokenStateRemove: MapTokenStateRemoveHandler,
selectedToolId: MapToolId,
disabledTokens: Record<string, boolean>
) {
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);
}
function handleTokenDragStart(
event: KonvaEventObject<DragEvent>,
tokenStateId: string
) {
setTokenDraggingOptions({
dragging: true,
tokenStateId,
tokenNode: event.target,
});
}
function handleTokenDragEnd() {
tokenDraggingOptions &&
setTokenDraggingOptions({
...tokenDraggingOptions,
dragging: false,
});
}
function handleTokenStateRemove(tokenState: TokenState) {
onTokenStateRemove(tokenState);
setTokenDraggingOptions(undefined);
}
const tokens = map && mapState && (
<Group>
{Object.values(mapState.tokens)
.sort((a, b) => sortMapTokenStates(a, b, tokenDraggingOptions))
.map((tokenState) => (
<Token
key={tokenState.id}
tokenState={tokenState}
onTokenStateChange={onTokenStateChange}
onTokenMenuOpen={handleTokenMenuOpen}
onTokenDragStart={handleTokenDragStart}
onTokenDragEnd={handleTokenDragEnd}
draggable={
selectedToolId === "move" &&
!(tokenState.id in disabledTokens) &&
!tokenState.locked
}
fadeOnHover={selectedToolId === "drawing"}
map={map}
/>
))}
</Group>
);
const tokenMenu = (
<TokenMenu
isOpen={isTokenMenuOpen}
onRequestClose={() => setIsTokenMenuOpen(false)}
onTokenStateChange={onTokenStateChange}
tokenState={
tokenMenuOptions && mapState?.tokens[tokenMenuOptions.tokenStateId]
}
tokenImage={tokenMenuOptions?.tokenImage}
map={map}
/>
);
const tokenDraggingState =
tokenDraggingOptions && mapState?.tokens[tokenDraggingOptions.tokenStateId];
const tokenDragOverlay = tokenDraggingOptions && tokenDraggingState && (
<TokenDragOverlay
onTokenStateRemove={handleTokenStateRemove}
tokenState={tokenDraggingState}
tokenNode={tokenDraggingOptions.tokenNode}
dragging={!!(tokenDraggingOptions && tokenDraggingOptions.dragging)}
/>
);
return { tokens, tokenMenu, tokenDragOverlay };
}
export default useMapTokens;
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.tokenStateId === a.id
) {
// If dragging token a move above
return 1;
} else if (
tokenDraggingOptions &&
tokenDraggingOptions.dragging &&
tokenDraggingOptions.tokenStateId === b.id
) {
// If dragging token b move above
return -1;
} else {
// Else sort so last modified is on top
return a.lastModified - b.lastModified;
}
}

View File

@ -3,7 +3,7 @@ import { Group } from "react-konva";
import { useUserId } from "../contexts/UserIdContext";
import MapPointer from "../components/map/MapPointer";
import PointerTool from "../components/tools/PointerTool";
import { isEmpty } from "../helpers/shared";
import Vector2 from "../helpers/Vector2";
@ -213,7 +213,7 @@ function NetworkedMapPointer({ session, active }: NetworkedMapPointerProps) {
return (
<Group>
{Object.values(localPointerState).map((pointer) => (
<MapPointer
<PointerTool
key={pointer.id}
active={pointer.id === userId ? active : false}
position={pointer.position}

View File

@ -32,6 +32,10 @@ export type TokenMenuOpenChangeEventHandler = (
tokenImage: Konva.Node
) => void;
export type TokenSettingsChangeEventHandler = (change: Partial<Token>) => void;
export type TokenDragEventHandler = (
event: Konva.KonvaEventObject<DragEvent>,
tokenStateId: string
) => void;
export type NoteAddEventHander = (note: Note) => void;
export type NoteRemoveEventHander = (noteId: string) => void;

View File

@ -1,6 +1,5 @@
import Konva from "konva";
import { Outline } from "./Outline";
import { TokenState } from "./TokenState";
export type TokenCategory = "character" | "vehicle" | "prop";
@ -39,6 +38,6 @@ export type TokenMenuOptions = {
export type TokenDraggingOptions = {
dragging: boolean;
tokenState: TokenState;
tokenStateId: string;
tokenNode: Konva.Node;
};