From fa62783b9c677dce956b2bfa3a1bdb5a9b3136b2 Mon Sep 17 00:00:00 2001 From: Mitchell McCaffrey Date: Mon, 19 Jul 2021 15:28:09 +1000 Subject: [PATCH] Refactor konva components and map tools --- src/components/konva/Drawing.tsx | 107 +++++ src/components/konva/Fog.tsx | 143 +++++++ src/components/{note => konva}/Note.tsx | 0 src/components/konva/Pointer.tsx | 169 ++++++++ src/components/konva/Tick.tsx | 48 +++ .../{map/MapToken.tsx => konva/Token.tsx} | 19 +- .../{token => konva}/TokenLabel.tsx | 0 src/components/konva/TokenOutline.tsx | 47 +++ .../{token => konva}/TokenStatus.tsx | 0 src/components/map/Map.tsx | 285 ++++---------- src/components/map/MapTokens.tsx | 115 ------ src/components/token/TokenImage.tsx | 2 +- src/components/token/TokenOutline.tsx | 46 +-- .../MapDrawing.tsx => tools/DrawingTool.tsx} | 114 ++---- .../{map/MapFog.tsx => tools/FogTool.tsx} | 40 +- .../MapMeasure.tsx => tools/MeasureTool.tsx} | 4 +- .../{map/MapNotes.tsx => tools/NoteTool.tsx} | 6 +- .../MapPointer.tsx => tools/PointerTool.tsx} | 15 +- .../MapSelect.tsx => tools/SelectTool.tsx} | 4 +- src/helpers/konva.ts | 64 +++ src/helpers/konva.tsx | 372 ------------------ src/hooks/useMapNotes.tsx | 118 ++++++ src/hooks/useMapTokens.tsx | 160 ++++++++ src/network/NetworkedMapPointer.tsx | 4 +- src/types/Events.ts | 4 + src/types/Token.ts | 3 +- 26 files changed, 995 insertions(+), 894 deletions(-) create mode 100644 src/components/konva/Drawing.tsx create mode 100644 src/components/konva/Fog.tsx rename src/components/{note => konva}/Note.tsx (100%) create mode 100644 src/components/konva/Pointer.tsx create mode 100644 src/components/konva/Tick.tsx rename src/components/{map/MapToken.tsx => konva/Token.tsx} (95%) rename src/components/{token => konva}/TokenLabel.tsx (100%) create mode 100644 src/components/konva/TokenOutline.tsx rename src/components/{token => konva}/TokenStatus.tsx (100%) delete mode 100644 src/components/map/MapTokens.tsx rename src/components/{map/MapDrawing.tsx => tools/DrawingTool.tsx} (69%) rename src/components/{map/MapFog.tsx => tools/FogTool.tsx} (97%) rename src/components/{map/MapMeasure.tsx => tools/MeasureTool.tsx} (98%) rename src/components/{map/MapNotes.tsx => tools/NoteTool.tsx} (98%) rename src/components/{map/MapPointer.tsx => tools/PointerTool.tsx} (91%) rename src/components/{map/MapSelect.tsx => tools/SelectTool.tsx} (98%) create mode 100644 src/helpers/konva.ts delete mode 100644 src/helpers/konva.tsx create mode 100644 src/hooks/useMapNotes.tsx create mode 100644 src/hooks/useMapTokens.tsx diff --git a/src/components/konva/Drawing.tsx b/src/components/konva/Drawing.tsx new file mode 100644 index 0000000..d665004 --- /dev/null +++ b/src/components/konva/Drawing.tsx @@ -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 ( + [ + ...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 ( + + ); + } else if (drawing.shapeType === "circle") { + const minSide = mapWidth < mapHeight ? mapWidth : mapHeight; + return ( + + ); + } else if (drawing.shapeType === "triangle") { + return ( + [ + ...acc, + point.x * mapWidth, + point.y * mapHeight, + ], + [] + )} + closed={true} + {...defaultProps} + {...props} + /> + ); + } else if (drawing.shapeType === "line") { + return ( + [ + ...acc, + point.x * mapWidth, + point.y * mapHeight, + ], + [] + )} + stroke={colors[drawing.color] || drawing.color} + lineCap="round" + {...defaultProps} + /> + ); + } + } + + return null; +} + +export default Drawing; diff --git a/src/components/konva/Fog.tsx b/src/components/konva/Fog.tsx new file mode 100644 index 0000000..28fd92d --- /dev/null +++ b/src/components/konva/Fog.tsx @@ -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 ( + + ); +} + +export default Fog; diff --git a/src/components/note/Note.tsx b/src/components/konva/Note.tsx similarity index 100% rename from src/components/note/Note.tsx rename to src/components/konva/Note.tsx diff --git a/src/components/konva/Pointer.tsx b/src/components/konva/Pointer.tsx new file mode 100644 index 0000000..cadbbbb --- /dev/null +++ b/src/components/konva/Pointer.tsx @@ -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(null); + const pointsRef = useRef([]); + const prevPositionRef = useRef(position); + const positionRef = useRef(position); + const circleRef = useRef(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 ( + + + + + ); +} + +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; diff --git a/src/components/konva/Tick.tsx b/src/components/konva/Tick.tsx new file mode 100644 index 0000000..cd39f1e --- /dev/null +++ b/src/components/konva/Tick.tsx @@ -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) => 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 ( + + + + + ); +} + +export default Tick; diff --git a/src/components/map/MapToken.tsx b/src/components/konva/Token.tsx similarity index 95% rename from src/components/map/MapToken.tsx rename to src/components/konva/Token.tsx index b784882..b273468 100644 --- a/src/components/map/MapToken.tsx +++ b/src/components/konva/Token.tsx @@ -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) => void; - onTokenDragEnd: (event: Konva.KonvaEventObject) => 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) { @@ -142,7 +143,7 @@ function MapToken({ lastModified: Date.now(), }, }); - onTokenDragEnd(event); + onTokenDragEnd(event, tokenState.id); } function handleClick(event: Konva.KonvaEventObject) { @@ -292,4 +293,4 @@ function MapToken({ ); } -export default MapToken; +export default Token; diff --git a/src/components/token/TokenLabel.tsx b/src/components/konva/TokenLabel.tsx similarity index 100% rename from src/components/token/TokenLabel.tsx rename to src/components/konva/TokenLabel.tsx diff --git a/src/components/konva/TokenOutline.tsx b/src/components/konva/TokenOutline.tsx new file mode 100644 index 0000000..3f006e1 --- /dev/null +++ b/src/components/konva/TokenOutline.tsx @@ -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 ( + + ); + } else if (outline.type === "circle") { + return ( + + ); + } else { + return ( + + ); + } +} + +export default TokenOutline; diff --git a/src/components/token/TokenStatus.tsx b/src/components/konva/TokenStatus.tsx similarity index 100% rename from src/components/token/TokenStatus.tsx rename to src/components/konva/TokenStatus.tsx diff --git a/src/components/map/Map.tsx b/src/components/map/Map.tsx index f4d3bc4..c5d44f4 100644 --- a/src/components/map/Map.tsx +++ b/src/components/map/Map.tsx @@ -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 = ( - + const { tokens, tokenMenu, tokenDragOverlay } = useMapTokens( + map, + mapState, + onMapTokenStateChange, + onMapTokenStateRemove, + selectedToolId, + disabledTokens ); - const [isTokenMenuOpen, setIsTokenMenuOpen] = useState(false); - const [tokenMenuOptions, setTokenMenuOptions] = useState(); - const [tokenDraggingOptions, setTokenDraggingOptions] = - useState(); - function handleTokenMenuOpen(tokenStateId: string, tokenImage: Konva.Node) { - setTokenMenuOptions({ tokenStateId, tokenImage }); - setIsTokenMenuOpen(true); - } - - const mapTokens = map && mapState && ( - - ); - - const tokenMenu = ( - setIsTokenMenuOpen(false)} - onTokenStateChange={onMapTokenStateChange} - tokenState={ - tokenMenuOptions && mapState?.tokens[tokenMenuOptions.tokenStateId] - } - tokenImage={tokenMenuOptions && tokenMenuOptions.tokenImage} - map={map} - /> - ); - - const tokenDragOverlay = tokenDraggingOptions && ( - { - onMapTokenStateRemove(state); - setTokenDraggingOptions(undefined); - }} - tokenState={tokenDraggingOptions && tokenDraggingOptions.tokenState} - tokenNode={tokenDraggingOptions && tokenDraggingOptions.tokenNode} - dragging={!!(tokenDraggingOptions && tokenDraggingOptions.dragging)} - /> - ); - - const mapDrawing = ( - - ); - - const mapFog = ( - - ); - - const mapGrid = map && map.showGrid && ; - - const mapMeasure = ( - - ); - - const mapPointer = ( - - ); - - const [isNoteMenuOpen, setIsNoteMenuOpen] = useState(false); - const [noteMenuOptions, setNoteMenuOptions] = useState(); - const [noteDraggingOptions, setNoteDraggingOptions] = - useState(); - 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 = ( - - 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 = ( - setIsNoteMenuOpen(false)} - onNoteChange={onMapNoteChange} - note={noteMenuOptions && mapState?.notes[noteMenuOptions.noteId]} - noteNode={noteMenuOptions?.noteNode} - map={map} - /> - ); - - const noteDragOverlay = noteDraggingOptions ? ( - { - onMapNoteRemove(noteId); - setNoteDraggingOptions(undefined); - }} - /> - ) : null; - - const mapSelect = ( - + const { notes, noteMenu, noteDragOverlay } = useMapNotes( + map, + mapState, + onMapNoteChange, + onMapNoteRemove, + selectedToolId, + allowNoteEditing ); return ( @@ -400,7 +219,19 @@ function Map({ mapState={mapState} controls={ <> - {mapControls} + {tokenMenu} {noteMenu} {tokenDragOverlay} @@ -411,14 +242,38 @@ function Map({ onSelectedToolChange={setSelectedToolId} disabledControls={disabledControls} > - {mapGrid} - {mapDrawing} - {mapNotes} - {mapTokens} - {mapFog} - {mapPointer} - {mapMeasure} - {mapSelect} + {map && map.showGrid && } + + {notes} + {tokens} + + + + ); diff --git a/src/components/map/MapTokens.tsx b/src/components/map/MapTokens.tsx deleted file mode 100644 index c37b069..0000000 --- a/src/components/map/MapTokens.tsx +++ /dev/null @@ -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; -}; - -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 ( - - {Object.values(mapState.tokens) - .sort((a, b) => sortMapTokenStates(a, b, tokenDraggingOptions)) - .map((tokenState) => ( - - 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} - /> - ))} - - ); -} - -export default MapTokens; diff --git a/src/components/token/TokenImage.tsx b/src/components/token/TokenImage.tsx index 73f7e94..02de716 100644 --- a/src/components/token/TokenImage.tsx +++ b/src/components/token/TokenImage.tsx @@ -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; diff --git a/src/components/token/TokenOutline.tsx b/src/components/token/TokenOutline.tsx index 12f5638..541d3da 100644 --- a/src/components/token/TokenOutline.tsx +++ b/src/components/token/TokenOutline.tsx @@ -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 ( - - ); - } else if (outline.type === "circle") { - return ( - - ); - } else { - return ( - - ); - } -} - -export default TokenOutline; +export default TokenOutlineSVG; diff --git a/src/components/map/MapDrawing.tsx b/src/components/tools/DrawingTool.tsx similarity index 69% rename from src/components/map/MapDrawing.tsx rename to src/components/tools/DrawingTool.tsx index 778a1a6..085a743 100644 --- a/src/components/map/MapDrawing.tsx +++ b/src/components/tools/DrawingTool.tsx @@ -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 ( - [ - ...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 ( - - ); - } else if (shape.shapeType === "circle") { - const minSide = mapWidth < mapHeight ? mapWidth : mapHeight; - return ( - - ); - } else if (shape.shapeType === "triangle") { - return ( - [ - ...acc, - point.x * mapWidth, - point.y * mapHeight, - ], - [] - )} - closed={true} - {...defaultProps} - /> - ); - } else if (shape.shapeType === "line") { - return ( - [ - ...acc, - point.x * mapWidth, - point.y * mapHeight, - ], - [] - )} - strokeWidth={gridStrokeWidth * shape.strokeWidth} - stroke={colors[shape.color] || shape.color} - lineCap="round" - {...defaultProps} - /> - ); - } - } + return ( + 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; diff --git a/src/components/map/MapFog.tsx b/src/components/tools/FogTool.tsx similarity index 97% rename from src/components/map/MapFog.tsx rename to src/components/tools/FogTool.tsx index a2598c7..a7a2e4d 100644 --- a/src/components/map/MapFog.tsx +++ b/src/components/tools/FogTool.tsx @@ -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 ( - 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; diff --git a/src/components/map/MapMeasure.tsx b/src/components/tools/MeasureTool.tsx similarity index 98% rename from src/components/map/MapMeasure.tsx rename to src/components/tools/MeasureTool.tsx index 6d9cb64..3c73dc1 100644 --- a/src/components/map/MapMeasure.tsx +++ b/src/components/tools/MeasureTool.tsx @@ -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 {drawingShapeData && renderShape(drawingShapeData)}; } -export default MapMeasure; +export default MeasureTool; diff --git a/src/components/map/MapNotes.tsx b/src/components/tools/NoteTool.tsx similarity index 98% rename from src/components/map/MapNotes.tsx rename to src/components/tools/NoteTool.tsx index 24f6918..0fa98df 100644 --- a/src/components/map/MapNotes.tsx +++ b/src/components/tools/NoteTool.tsx @@ -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; diff --git a/src/components/map/MapPointer.tsx b/src/components/tools/PointerTool.tsx similarity index 91% rename from src/components/map/MapPointer.tsx rename to src/components/tools/PointerTool.tsx index c467350..de30bf0 100644 --- a/src/components/map/MapPointer.tsx +++ b/src/components/tools/PointerTool.tsx @@ -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 ( {visible && ( - {selection && renderSelection(selection)}; } -export default MapSelect; +export default SelectTool; diff --git a/src/helpers/konva.ts b/src/helpers/konva.ts new file mode 100644 index 0000000..4cdd25b --- /dev/null +++ b/src/helpers/konva.ts @@ -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))); +} diff --git a/src/helpers/konva.tsx b/src/helpers/konva.tsx deleted file mode 100644 index daaceaf..0000000 --- a/src/helpers/konva.tsx +++ /dev/null @@ -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 ; -} - -type TickProps = { - x: number; - y: number; - scale: number; - onClick: (evt: Konva.KonvaEventObject) => 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 ( - - - - - ); -} - -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(null); - const pointsRef = useRef([]); - const prevPositionRef = useRef(position); - const positionRef = useRef(position); - const circleRef = useRef(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 ( - - - - - ); -} - -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; - }, []); -} diff --git a/src/hooks/useMapNotes.tsx b/src/hooks/useMapNotes.tsx new file mode 100644 index 0000000..aa6f99c --- /dev/null +++ b/src/hooks/useMapNotes.tsx @@ -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(false); + const [noteMenuOptions, setNoteMenuOptions] = useState(); + const [noteDraggingOptions, setNoteDraggingOptions] = + useState(); + function handleNoteMenuOpen(noteId: string, noteNode: Konva.Node) { + setNoteMenuOptions({ noteId, noteNode }); + setIsNoteMenuOpen(true); + } + + function handleNoteDragStart( + event: KonvaEventObject, + 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 = ( + + sortNotes(a, b, noteDraggingOptions) + ) + : [] + } + onNoteMenuOpen={handleNoteMenuOpen} + draggable={ + allowNoteEditing && + (selectedToolId === "note" || selectedToolId === "move") + } + onNoteDragStart={handleNoteDragStart} + onNoteDragEnd={handleNoteDragEnd} + fadeOnHover={selectedToolId === "drawing"} + /> + ); + + const noteMenu = ( + setIsNoteMenuOpen(false)} + onNoteChange={onNoteChange} + note={noteMenuOptions && mapState?.notes[noteMenuOptions.noteId]} + noteNode={noteMenuOptions?.noteNode} + map={map} + /> + ); + + const noteDragOverlay = noteDraggingOptions ? ( + + ) : 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; + } +} diff --git a/src/hooks/useMapTokens.tsx b/src/hooks/useMapTokens.tsx new file mode 100644 index 0000000..ba26a16 --- /dev/null +++ b/src/hooks/useMapTokens.tsx @@ -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 +) { + const [isTokenMenuOpen, setIsTokenMenuOpen] = useState(false); + const [tokenMenuOptions, setTokenMenuOptions] = useState(); + const [tokenDraggingOptions, setTokenDraggingOptions] = + useState(); + + function handleTokenMenuOpen(tokenStateId: string, tokenImage: Konva.Node) { + setTokenMenuOptions({ tokenStateId, tokenImage }); + setIsTokenMenuOpen(true); + } + + function handleTokenDragStart( + event: KonvaEventObject, + 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 && ( + + {Object.values(mapState.tokens) + .sort((a, b) => sortMapTokenStates(a, b, tokenDraggingOptions)) + .map((tokenState) => ( + + ))} + + ); + + const tokenMenu = ( + 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 && ( + + ); + + 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; + } +} diff --git a/src/network/NetworkedMapPointer.tsx b/src/network/NetworkedMapPointer.tsx index 9fe38b8..cd44086 100644 --- a/src/network/NetworkedMapPointer.tsx +++ b/src/network/NetworkedMapPointer.tsx @@ -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 ( {Object.values(localPointerState).map((pointer) => ( - void; export type TokenSettingsChangeEventHandler = (change: Partial) => void; +export type TokenDragEventHandler = ( + event: Konva.KonvaEventObject, + tokenStateId: string +) => void; export type NoteAddEventHander = (note: Note) => void; export type NoteRemoveEventHander = (noteId: string) => void; diff --git a/src/types/Token.ts b/src/types/Token.ts index d1ba5c3..90657e4 100644 --- a/src/types/Token.ts +++ b/src/types/Token.ts @@ -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; };