Refactor konva components and map tools
This commit is contained in:
parent
fa2190dd7d
commit
fa62783b9c
107
src/components/konva/Drawing.tsx
Normal file
107
src/components/konva/Drawing.tsx
Normal 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;
|
143
src/components/konva/Fog.tsx
Normal file
143
src/components/konva/Fog.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
import Konva from "konva";
|
||||
import { Line } from "react-konva";
|
||||
|
||||
import { scaleAndFlattenPoints } from "../../helpers/konva";
|
||||
|
||||
import { Fog as FogType } from "../../types/Fog";
|
||||
import {
|
||||
useMapHeight,
|
||||
useMapWidth,
|
||||
} from "../../contexts/MapInteractionContext";
|
||||
import Vector2 from "../../helpers/Vector2";
|
||||
|
||||
type FogProps = {
|
||||
fog: FogType;
|
||||
} & Konva.LineConfig;
|
||||
|
||||
// Holes should be wound in the opposite direction as the containing points array
|
||||
function Fog({ fog, opacity, ...props }: FogProps) {
|
||||
const mapWidth = useMapWidth();
|
||||
const mapHeight = useMapHeight();
|
||||
const mapSize = new Vector2(mapWidth, mapHeight);
|
||||
|
||||
const points = scaleAndFlattenPoints(fog.data.points, mapSize);
|
||||
const holes = fog.data.holes.map((hole) =>
|
||||
scaleAndFlattenPoints(hole, mapSize)
|
||||
);
|
||||
|
||||
// Converted from https://github.com/rfestag/konva/blob/master/src/shapes/Line.ts
|
||||
function drawLine(
|
||||
points: number[],
|
||||
context: Konva.Context,
|
||||
shape: Konva.Line
|
||||
) {
|
||||
const length = points.length;
|
||||
const tension = shape.tension();
|
||||
const closed = shape.closed();
|
||||
const bezier = shape.bezier();
|
||||
|
||||
if (!length) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.moveTo(points[0], points[1]);
|
||||
|
||||
if (tension !== 0 && length > 4) {
|
||||
const tensionPoints = shape.getTensionPoints();
|
||||
const tensionLength = tensionPoints.length;
|
||||
let n = closed ? 0 : 4;
|
||||
|
||||
if (!closed) {
|
||||
context.quadraticCurveTo(
|
||||
tensionPoints[0],
|
||||
tensionPoints[1],
|
||||
tensionPoints[2],
|
||||
tensionPoints[3]
|
||||
);
|
||||
}
|
||||
|
||||
while (n < tensionLength - 2) {
|
||||
context.bezierCurveTo(
|
||||
tensionPoints[n++],
|
||||
tensionPoints[n++],
|
||||
tensionPoints[n++],
|
||||
tensionPoints[n++],
|
||||
tensionPoints[n++],
|
||||
tensionPoints[n++]
|
||||
);
|
||||
}
|
||||
|
||||
if (!closed) {
|
||||
context.quadraticCurveTo(
|
||||
tensionPoints[tensionLength - 2],
|
||||
tensionPoints[tensionLength - 1],
|
||||
points[length - 2],
|
||||
points[length - 1]
|
||||
);
|
||||
}
|
||||
} else if (bezier) {
|
||||
// no tension but bezier
|
||||
let n = 2;
|
||||
|
||||
while (n < length) {
|
||||
context.bezierCurveTo(
|
||||
points[n++],
|
||||
points[n++],
|
||||
points[n++],
|
||||
points[n++],
|
||||
points[n++],
|
||||
points[n++]
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// no tension
|
||||
for (let n = 2; n < length; n += 2) {
|
||||
context.lineTo(points[n], points[n + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw points and holes
|
||||
function sceneFunc(context: Konva.Context, shape: Konva.Line) {
|
||||
const points = shape.points();
|
||||
const closed = shape.closed();
|
||||
|
||||
if (!points.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.beginPath();
|
||||
drawLine(points, context, shape);
|
||||
|
||||
context.beginPath();
|
||||
drawLine(points, context, shape);
|
||||
|
||||
// closed e.g. polygons and blobs
|
||||
if (closed) {
|
||||
context.closePath();
|
||||
if (holes && holes.length) {
|
||||
for (let hole of holes) {
|
||||
drawLine(hole, context, shape);
|
||||
context.closePath();
|
||||
}
|
||||
}
|
||||
context.fillStrokeShape(shape);
|
||||
} else {
|
||||
// open e.g. lines and splines
|
||||
context.strokeShape(shape);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Line
|
||||
points={points}
|
||||
closed
|
||||
lineCap="round"
|
||||
lineJoin="round"
|
||||
{...props}
|
||||
sceneFunc={sceneFunc as any}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default Fog;
|
169
src/components/konva/Pointer.tsx
Normal file
169
src/components/konva/Pointer.tsx
Normal file
@ -0,0 +1,169 @@
|
||||
import Color from "color";
|
||||
import Konva from "konva";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Circle, Group, Line } from "react-konva";
|
||||
import Vector2 from "../../helpers/Vector2";
|
||||
|
||||
interface PointerPoint extends Vector2 {
|
||||
lifetime: number;
|
||||
}
|
||||
|
||||
type PointerProps = {
|
||||
position: Vector2;
|
||||
size: number;
|
||||
duration: number;
|
||||
segments: number;
|
||||
color: string;
|
||||
};
|
||||
|
||||
function Pointer({ position, size, duration, segments, color }: PointerProps) {
|
||||
const trailRef = useRef<Konva.Line>(null);
|
||||
const pointsRef = useRef<PointerPoint[]>([]);
|
||||
const prevPositionRef = useRef(position);
|
||||
const positionRef = useRef(position);
|
||||
const circleRef = useRef<Konva.Circle>(null);
|
||||
// Color of the end of the trail
|
||||
const transparentColorRef = useRef(
|
||||
Color(color).lighten(0.5).alpha(0).string()
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Lighten color to give it a `glow` effect
|
||||
transparentColorRef.current = Color(color).lighten(0.5).alpha(0).string();
|
||||
}, [color]);
|
||||
|
||||
// Keep track of position so we can use it in the trail animation
|
||||
useEffect(() => {
|
||||
positionRef.current = position;
|
||||
}, [position]);
|
||||
|
||||
// Add a new point every time position is changed
|
||||
useEffect(() => {
|
||||
if (Vector2.compare(position, prevPositionRef.current, 0.0001)) {
|
||||
return;
|
||||
}
|
||||
pointsRef.current.push({ ...position, lifetime: duration });
|
||||
prevPositionRef.current = position;
|
||||
}, [position, duration]);
|
||||
|
||||
// Advance lifetime of trail
|
||||
useEffect(() => {
|
||||
let prevTime = performance.now();
|
||||
let request = requestAnimationFrame(animate);
|
||||
function animate(time: number) {
|
||||
request = requestAnimationFrame(animate);
|
||||
const deltaTime = time - prevTime;
|
||||
prevTime = time;
|
||||
|
||||
if (pointsRef.current.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let expired = 0;
|
||||
for (let point of pointsRef.current) {
|
||||
point.lifetime -= deltaTime;
|
||||
if (point.lifetime < 0) {
|
||||
expired++;
|
||||
}
|
||||
}
|
||||
if (expired > 0) {
|
||||
pointsRef.current = pointsRef.current.slice(expired);
|
||||
}
|
||||
|
||||
// Update the circle position to keep it in sync with the trail
|
||||
if (circleRef && circleRef.current) {
|
||||
circleRef.current.x(positionRef.current.x);
|
||||
circleRef.current.y(positionRef.current.y);
|
||||
}
|
||||
|
||||
if (trailRef && trailRef.current) {
|
||||
trailRef.current.getLayer()?.draw();
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(request);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Custom scene function for drawing a trail from a line
|
||||
function sceneFunc(context: Konva.Context) {
|
||||
// Resample points to ensure a smooth trail
|
||||
const resampledPoints = Vector2.resample(pointsRef.current, segments);
|
||||
if (resampledPoints.length === 0) {
|
||||
return;
|
||||
}
|
||||
// Draws a line offset in the direction perpendicular to its travel direction
|
||||
const drawOffsetLine = (from: Vector2, to: Vector2, alpha: number) => {
|
||||
const forward = Vector2.normalize(Vector2.subtract(from, to));
|
||||
// Rotate the forward vector 90 degrees based off of the direction
|
||||
const side = Vector2.rotate90(forward);
|
||||
|
||||
// Offset the `to` position by the size of the point and in the side direction
|
||||
const toSize = (alpha * size) / 2;
|
||||
const toOffset = Vector2.add(to, Vector2.multiply(side, toSize));
|
||||
|
||||
context.lineTo(toOffset.x, toOffset.y);
|
||||
};
|
||||
context.beginPath();
|
||||
// Sample the points starting from the tail then traverse counter clockwise drawing each point
|
||||
// offset to make a taper, stops at the base of the trail
|
||||
context.moveTo(resampledPoints[0].x, resampledPoints[0].y);
|
||||
for (let i = 1; i < resampledPoints.length; i++) {
|
||||
const from = resampledPoints[i - 1];
|
||||
const to = resampledPoints[i];
|
||||
drawOffsetLine(from, to, i / resampledPoints.length);
|
||||
}
|
||||
// Start from the base of the trail and continue drawing down back to the end of the tail
|
||||
for (let i = resampledPoints.length - 2; i >= 0; i--) {
|
||||
const from = resampledPoints[i + 1];
|
||||
const to = resampledPoints[i];
|
||||
drawOffsetLine(from, to, i / resampledPoints.length);
|
||||
}
|
||||
context.lineTo(resampledPoints[0].x, resampledPoints[0].y);
|
||||
context.closePath();
|
||||
|
||||
// Create a radial gradient from the center of the trail to the tail
|
||||
const gradientCenter = resampledPoints[resampledPoints.length - 1];
|
||||
const gradientEnd = resampledPoints[0];
|
||||
const gradientRadius = Vector2.magnitude(
|
||||
Vector2.subtract(gradientCenter, gradientEnd)
|
||||
);
|
||||
let gradient = context.createRadialGradient(
|
||||
gradientCenter.x,
|
||||
gradientCenter.y,
|
||||
0,
|
||||
gradientCenter.x,
|
||||
gradientCenter.y,
|
||||
gradientRadius
|
||||
);
|
||||
gradient.addColorStop(0, color);
|
||||
gradient.addColorStop(1, transparentColorRef.current);
|
||||
// @ts-ignore
|
||||
context.fillStyle = gradient;
|
||||
context.fill();
|
||||
}
|
||||
|
||||
return (
|
||||
<Group>
|
||||
<Line sceneFunc={sceneFunc} ref={trailRef} />
|
||||
<Circle
|
||||
x={position.x}
|
||||
y={position.y}
|
||||
fill={color}
|
||||
width={size}
|
||||
height={size}
|
||||
ref={circleRef}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
Pointer.defaultProps = {
|
||||
// Duration of each point in milliseconds
|
||||
duration: 200,
|
||||
// Number of segments in the trail, resampled from the points
|
||||
segments: 20,
|
||||
};
|
||||
|
||||
export default Pointer;
|
48
src/components/konva/Tick.tsx
Normal file
48
src/components/konva/Tick.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import Konva from "konva";
|
||||
import { useState } from "react";
|
||||
import { Circle, Group, Path } from "react-konva";
|
||||
|
||||
type TickProps = {
|
||||
x: number;
|
||||
y: number;
|
||||
scale: number;
|
||||
onClick: (evt: Konva.KonvaEventObject<MouseEvent>) => void;
|
||||
cross: boolean;
|
||||
};
|
||||
|
||||
export function Tick({ x, y, scale, onClick, cross }: TickProps) {
|
||||
const [fill, setFill] = useState("white");
|
||||
function handleEnter() {
|
||||
setFill("hsl(260, 100%, 80%)");
|
||||
}
|
||||
|
||||
function handleLeave() {
|
||||
setFill("white");
|
||||
}
|
||||
return (
|
||||
<Group
|
||||
x={x}
|
||||
y={y}
|
||||
scaleX={scale}
|
||||
scaleY={scale}
|
||||
onMouseEnter={handleEnter}
|
||||
onMouseLeave={handleLeave}
|
||||
onClick={onClick}
|
||||
onTap={onClick}
|
||||
>
|
||||
<Circle radius={12} fill="hsla(230, 25%, 18%, 0.8)" />
|
||||
<Path
|
||||
offsetX={12}
|
||||
offsetY={12}
|
||||
fill={fill}
|
||||
data={
|
||||
cross
|
||||
? "M18.3 5.71c-.39-.39-1.02-.39-1.41 0L12 10.59 7.11 5.7c-.39-.39-1.02-.39-1.41 0-.39.39-.39 1.02 0 1.41L10.59 12 5.7 16.89c-.39.39-.39 1.02 0 1.41.39.39 1.02.39 1.41 0L12 13.41l4.89 4.89c.39.39 1.02.39 1.41 0 .39-.39.39-1.02 0-1.41L13.41 12l4.89-4.89c.38-.38.38-1.02 0-1.4z"
|
||||
: "M9 16.2l-3.5-3.5c-.39-.39-1.01-.39-1.4 0-.39.39-.39 1.01 0 1.4l4.19 4.19c.39.39 1.02.39 1.41 0L20.3 7.7c.39-.39.39-1.01 0-1.4-.39-.39-1.01-.39-1.4 0L9 16.2z"
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default Tick;
|
@ -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;
|
47
src/components/konva/TokenOutline.tsx
Normal file
47
src/components/konva/TokenOutline.tsx
Normal 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;
|
@ -4,28 +4,26 @@ import { useToasts } from "react-toast-notifications";
|
||||
|
||||
import MapControls from "./MapControls";
|
||||
import MapInteraction from "./MapInteraction";
|
||||
import MapTokens from "./MapTokens";
|
||||
import MapDrawing from "./MapDrawing";
|
||||
import MapFog from "./MapFog";
|
||||
import MapGrid from "./MapGrid";
|
||||
import MapMeasure from "./MapMeasure";
|
||||
|
||||
import DrawingTool from "../tools/DrawingTool";
|
||||
import FogTool from "../tools/FogTool";
|
||||
import MeasureTool from "../tools/MeasureTool";
|
||||
import NetworkedMapPointer from "../../network/NetworkedMapPointer";
|
||||
import MapNotes from "./MapNotes";
|
||||
import SelectTool from "../tools/SelectTool";
|
||||
|
||||
import { useSettings } from "../../contexts/SettingsContext";
|
||||
|
||||
import TokenMenu from "../token/TokenMenu";
|
||||
import TokenDragOverlay from "../token/TokenDragOverlay";
|
||||
import NoteMenu from "../note/NoteMenu";
|
||||
import NoteDragOverlay from "../note/NoteDragOverlay";
|
||||
|
||||
import Action from "../../actions/Action";
|
||||
import {
|
||||
AddStatesAction,
|
||||
CutFogAction,
|
||||
EditStatesAction,
|
||||
RemoveStatesAction,
|
||||
} from "../../actions";
|
||||
|
||||
import Session from "../../network/Session";
|
||||
|
||||
import { Drawing, DrawingState } from "../../types/Drawing";
|
||||
import { Fog, FogState } from "../../types/Fog";
|
||||
import { Map as MapType, MapActions, MapToolId } from "../../types/Map";
|
||||
@ -39,11 +37,9 @@ import {
|
||||
NoteRemoveEventHander,
|
||||
TokenStateChangeEventHandler,
|
||||
} from "../../types/Events";
|
||||
import Action from "../../actions/Action";
|
||||
import Konva from "konva";
|
||||
import { TokenDraggingOptions, TokenMenuOptions } from "../../types/Token";
|
||||
import { Note, NoteDraggingOptions, NoteMenuOptions } from "../../types/Note";
|
||||
import MapSelect from "./MapSelect";
|
||||
|
||||
import useMapTokens from "../../hooks/useMapTokens";
|
||||
import useMapNotes from "../../hooks/useMapNotes";
|
||||
|
||||
type MapProps = {
|
||||
map: MapType | null;
|
||||
@ -198,199 +194,22 @@ function Map({
|
||||
disabledSettings.fog.push("redo");
|
||||
}
|
||||
|
||||
const mapControls = (
|
||||
<MapControls
|
||||
onMapChange={onMapChange}
|
||||
onMapReset={onMapReset}
|
||||
currentMap={map}
|
||||
currentMapState={mapState}
|
||||
onSelectedToolChange={setSelectedToolId}
|
||||
selectedToolId={selectedToolId}
|
||||
toolSettings={settings}
|
||||
onToolSettingChange={handleToolSettingChange}
|
||||
onToolAction={handleToolAction}
|
||||
disabledControls={disabledControls}
|
||||
disabledSettings={disabledSettings}
|
||||
/>
|
||||
const { tokens, tokenMenu, tokenDragOverlay } = useMapTokens(
|
||||
map,
|
||||
mapState,
|
||||
onMapTokenStateChange,
|
||||
onMapTokenStateRemove,
|
||||
selectedToolId,
|
||||
disabledTokens
|
||||
);
|
||||
|
||||
const [isTokenMenuOpen, setIsTokenMenuOpen] = useState<boolean>(false);
|
||||
const [tokenMenuOptions, setTokenMenuOptions] = useState<TokenMenuOptions>();
|
||||
const [tokenDraggingOptions, setTokenDraggingOptions] =
|
||||
useState<TokenDraggingOptions>();
|
||||
function handleTokenMenuOpen(tokenStateId: string, tokenImage: Konva.Node) {
|
||||
setTokenMenuOptions({ tokenStateId, tokenImage });
|
||||
setIsTokenMenuOpen(true);
|
||||
}
|
||||
|
||||
const mapTokens = map && mapState && (
|
||||
<MapTokens
|
||||
map={map}
|
||||
mapState={mapState}
|
||||
tokenDraggingOptions={tokenDraggingOptions}
|
||||
setTokenDraggingOptions={setTokenDraggingOptions}
|
||||
onMapTokenStateChange={onMapTokenStateChange}
|
||||
onTokenMenuOpen={handleTokenMenuOpen}
|
||||
selectedToolId={selectedToolId}
|
||||
disabledTokens={disabledTokens}
|
||||
/>
|
||||
);
|
||||
|
||||
const tokenMenu = (
|
||||
<TokenMenu
|
||||
isOpen={isTokenMenuOpen}
|
||||
onRequestClose={() => setIsTokenMenuOpen(false)}
|
||||
onTokenStateChange={onMapTokenStateChange}
|
||||
tokenState={
|
||||
tokenMenuOptions && mapState?.tokens[tokenMenuOptions.tokenStateId]
|
||||
}
|
||||
tokenImage={tokenMenuOptions && tokenMenuOptions.tokenImage}
|
||||
map={map}
|
||||
/>
|
||||
);
|
||||
|
||||
const tokenDragOverlay = tokenDraggingOptions && (
|
||||
<TokenDragOverlay
|
||||
onTokenStateRemove={(state) => {
|
||||
onMapTokenStateRemove(state);
|
||||
setTokenDraggingOptions(undefined);
|
||||
}}
|
||||
tokenState={tokenDraggingOptions && tokenDraggingOptions.tokenState}
|
||||
tokenNode={tokenDraggingOptions && tokenDraggingOptions.tokenNode}
|
||||
dragging={!!(tokenDraggingOptions && tokenDraggingOptions.dragging)}
|
||||
/>
|
||||
);
|
||||
|
||||
const mapDrawing = (
|
||||
<MapDrawing
|
||||
map={map}
|
||||
drawings={drawShapes}
|
||||
onDrawingAdd={handleMapShapeAdd}
|
||||
onDrawingsRemove={handleMapShapesRemove}
|
||||
active={selectedToolId === "drawing"}
|
||||
toolSettings={settings.drawing}
|
||||
/>
|
||||
);
|
||||
|
||||
const mapFog = (
|
||||
<MapFog
|
||||
map={map}
|
||||
shapes={fogShapes}
|
||||
onShapesAdd={handleFogShapesAdd}
|
||||
onShapesCut={handleFogShapesCut}
|
||||
onShapesRemove={handleFogShapesRemove}
|
||||
onShapesEdit={handleFogShapesEdit}
|
||||
onShapeError={addToast}
|
||||
active={selectedToolId === "fog"}
|
||||
toolSettings={settings.fog}
|
||||
editable={allowFogDrawing && !settings.fog.preview}
|
||||
/>
|
||||
);
|
||||
|
||||
const mapGrid = map && map.showGrid && <MapGrid map={map} />;
|
||||
|
||||
const mapMeasure = (
|
||||
<MapMeasure map={map} active={selectedToolId === "measure"} />
|
||||
);
|
||||
|
||||
const mapPointer = (
|
||||
<NetworkedMapPointer
|
||||
active={selectedToolId === "pointer"}
|
||||
session={session}
|
||||
/>
|
||||
);
|
||||
|
||||
const [isNoteMenuOpen, setIsNoteMenuOpen] = useState<boolean>(false);
|
||||
const [noteMenuOptions, setNoteMenuOptions] = useState<NoteMenuOptions>();
|
||||
const [noteDraggingOptions, setNoteDraggingOptions] =
|
||||
useState<NoteDraggingOptions>();
|
||||
function handleNoteMenuOpen(noteId: string, noteNode: Konva.Node) {
|
||||
setNoteMenuOptions({ noteId, noteNode });
|
||||
setIsNoteMenuOpen(true);
|
||||
}
|
||||
|
||||
function sortNotes(
|
||||
a: Note,
|
||||
b: Note,
|
||||
noteDraggingOptions?: NoteDraggingOptions
|
||||
) {
|
||||
if (
|
||||
noteDraggingOptions &&
|
||||
noteDraggingOptions.dragging &&
|
||||
noteDraggingOptions.noteId === a.id
|
||||
) {
|
||||
// If dragging token `a` move above
|
||||
return 1;
|
||||
} else if (
|
||||
noteDraggingOptions &&
|
||||
noteDraggingOptions.dragging &&
|
||||
noteDraggingOptions.noteId === b.id
|
||||
) {
|
||||
// If dragging token `b` move above
|
||||
return -1;
|
||||
} else {
|
||||
// Else sort so last modified is on top
|
||||
return a.lastModified - b.lastModified;
|
||||
}
|
||||
}
|
||||
|
||||
const mapNotes = (
|
||||
<MapNotes
|
||||
map={map}
|
||||
active={selectedToolId === "note"}
|
||||
onNoteAdd={onMapNoteChange}
|
||||
onNoteChange={onMapNoteChange}
|
||||
notes={
|
||||
mapState
|
||||
? Object.values(mapState.notes).sort((a, b) =>
|
||||
sortNotes(a, b, noteDraggingOptions)
|
||||
)
|
||||
: []
|
||||
}
|
||||
onNoteMenuOpen={handleNoteMenuOpen}
|
||||
draggable={
|
||||
allowNoteEditing &&
|
||||
(selectedToolId === "note" || selectedToolId === "move")
|
||||
}
|
||||
onNoteDragStart={(e, noteId) =>
|
||||
setNoteDraggingOptions({ dragging: true, noteId, noteGroup: e.target })
|
||||
}
|
||||
onNoteDragEnd={() =>
|
||||
noteDraggingOptions &&
|
||||
setNoteDraggingOptions({ ...noteDraggingOptions, dragging: false })
|
||||
}
|
||||
fadeOnHover={selectedToolId === "drawing"}
|
||||
/>
|
||||
);
|
||||
|
||||
const noteMenu = (
|
||||
<NoteMenu
|
||||
isOpen={isNoteMenuOpen}
|
||||
onRequestClose={() => setIsNoteMenuOpen(false)}
|
||||
onNoteChange={onMapNoteChange}
|
||||
note={noteMenuOptions && mapState?.notes[noteMenuOptions.noteId]}
|
||||
noteNode={noteMenuOptions?.noteNode}
|
||||
map={map}
|
||||
/>
|
||||
);
|
||||
|
||||
const noteDragOverlay = noteDraggingOptions ? (
|
||||
<NoteDragOverlay
|
||||
dragging={noteDraggingOptions.dragging}
|
||||
noteGroup={noteDraggingOptions.noteGroup}
|
||||
noteId={noteDraggingOptions.noteId}
|
||||
onNoteRemove={(noteId) => {
|
||||
onMapNoteRemove(noteId);
|
||||
setNoteDraggingOptions(undefined);
|
||||
}}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const mapSelect = (
|
||||
<MapSelect
|
||||
active={selectedToolId === "select"}
|
||||
toolSettings={settings.select}
|
||||
/>
|
||||
const { notes, noteMenu, noteDragOverlay } = useMapNotes(
|
||||
map,
|
||||
mapState,
|
||||
onMapNoteChange,
|
||||
onMapNoteRemove,
|
||||
selectedToolId,
|
||||
allowNoteEditing
|
||||
);
|
||||
|
||||
return (
|
||||
@ -400,7 +219,19 @@ function Map({
|
||||
mapState={mapState}
|
||||
controls={
|
||||
<>
|
||||
{mapControls}
|
||||
<MapControls
|
||||
onMapChange={onMapChange}
|
||||
onMapReset={onMapReset}
|
||||
currentMap={map}
|
||||
currentMapState={mapState}
|
||||
onSelectedToolChange={setSelectedToolId}
|
||||
selectedToolId={selectedToolId}
|
||||
toolSettings={settings}
|
||||
onToolSettingChange={handleToolSettingChange}
|
||||
onToolAction={handleToolAction}
|
||||
disabledControls={disabledControls}
|
||||
disabledSettings={disabledSettings}
|
||||
/>
|
||||
{tokenMenu}
|
||||
{noteMenu}
|
||||
{tokenDragOverlay}
|
||||
@ -411,14 +242,38 @@ function Map({
|
||||
onSelectedToolChange={setSelectedToolId}
|
||||
disabledControls={disabledControls}
|
||||
>
|
||||
{mapGrid}
|
||||
{mapDrawing}
|
||||
{mapNotes}
|
||||
{mapTokens}
|
||||
{mapFog}
|
||||
{mapPointer}
|
||||
{mapMeasure}
|
||||
{mapSelect}
|
||||
{map && map.showGrid && <MapGrid map={map} />}
|
||||
<DrawingTool
|
||||
map={map}
|
||||
drawings={drawShapes}
|
||||
onDrawingAdd={handleMapShapeAdd}
|
||||
onDrawingsRemove={handleMapShapesRemove}
|
||||
active={selectedToolId === "drawing"}
|
||||
toolSettings={settings.drawing}
|
||||
/>
|
||||
{notes}
|
||||
{tokens}
|
||||
<FogTool
|
||||
map={map}
|
||||
shapes={fogShapes}
|
||||
onShapesAdd={handleFogShapesAdd}
|
||||
onShapesCut={handleFogShapesCut}
|
||||
onShapesRemove={handleFogShapesRemove}
|
||||
onShapesEdit={handleFogShapesEdit}
|
||||
onShapeError={addToast}
|
||||
active={selectedToolId === "fog"}
|
||||
toolSettings={settings.fog}
|
||||
editable={allowFogDrawing && !settings.fog.preview}
|
||||
/>
|
||||
<NetworkedMapPointer
|
||||
active={selectedToolId === "pointer"}
|
||||
session={session}
|
||||
/>
|
||||
<MeasureTool map={map} active={selectedToolId === "measure"} />
|
||||
<SelectTool
|
||||
active={selectedToolId === "select"}
|
||||
toolSettings={settings.select}
|
||||
/>
|
||||
</MapInteraction>
|
||||
</Box>
|
||||
);
|
||||
|
@ -1,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;
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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
64
src/helpers/konva.ts
Normal 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)));
|
||||
}
|
@ -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
118
src/hooks/useMapNotes.tsx
Normal 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
160
src/hooks/useMapTokens.tsx
Normal 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;
|
||||
}
|
||||
}
|
@ -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}
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user