import { useState, useRef } from "react"; import { Image as KonvaImage, Group } from "react-konva"; import { useSpring, animated } from "@react-spring/konva"; import Konva from "konva"; import useImage from "use-image"; import usePrevious from "../../hooks/usePrevious"; import useGridSnapping from "../../hooks/useGridSnapping"; import { useUserId } from "../../contexts/UserIdContext"; import { useSetPreventMapInteraction, useMapWidth, useMapHeight, } from "../../contexts/MapInteractionContext"; import { useGridCellPixelSize } from "../../contexts/GridContext"; import { useDataURL } from "../../contexts/AssetsContext"; import TokenStatus from "../token/TokenStatus"; import TokenLabel from "../token/TokenLabel"; import TokenOutline from "../token/TokenOutline"; import { Intersection, getScaledOutline } from "../../helpers/token"; import Vector2 from "../../helpers/Vector2"; import { tokenSources } from "../../tokens"; import { TokenState } from "../../types/TokenState"; import { Map } from "../../types/Map"; import { TokenMenuOpenChangeEventHandler, TokenStateChangeEventHandler, } from "../../types/Events"; type MapTokenProps = { tokenState: TokenState; onTokenStateChange: TokenStateChangeEventHandler; onTokenMenuOpen: TokenMenuOpenChangeEventHandler; onTokenDragStart: (event: Konva.KonvaEventObject) => void; onTokenDragEnd: (event: Konva.KonvaEventObject) => void; draggable: boolean; fadeOnHover: boolean; map: Map; }; function MapToken({ tokenState, onTokenStateChange, onTokenMenuOpen, onTokenDragStart, onTokenDragEnd, draggable, fadeOnHover, map, }: MapTokenProps) { const userId = useUserId(); const mapWidth = useMapWidth(); const mapHeight = useMapHeight(); const setPreventMapInteraction = useSetPreventMapInteraction(); const gridCellPixelSize = useGridCellPixelSize(); const tokenURL = useDataURL(tokenState, tokenSources); const [tokenImage] = useImage(tokenURL || ""); const tokenAspectRatio = tokenState.width / tokenState.height; const snapPositionToGrid = useGridSnapping(); const intersectingTokensRef = useRef([]); const previousDragPositionRef = useRef({ x: 0, y: 0 }); function handleDragStart(event: Konva.KonvaEventObject) { const tokenGroup = event.target; if (tokenState.category === "vehicle") { previousDragPositionRef.current = tokenGroup.position(); const tokenIntersection = new Intersection( getScaledOutline(tokenState, tokenWidth, tokenHeight), { x: tokenX - tokenWidth / 2, y: tokenY - tokenHeight / 2 }, { x: tokenX, y: tokenY }, tokenState.rotation ); // Find all other tokens on the map const layer = tokenGroup.getLayer() as Konva.Layer; const tokens = layer.find(".character"); for (let other of tokens) { if (other === tokenGroup) { continue; } if (tokenIntersection.intersects(other.position())) { intersectingTokensRef.current.push(other); } } } onTokenDragStart(event); } function handleDragMove(event: Konva.KonvaEventObject) { const tokenGroup = event.target; // Snap to corners of grid if (map.snapToGrid) { tokenGroup.position(snapPositionToGrid(tokenGroup.position())); } if (tokenState.category === "vehicle") { const deltaPosition = Vector2.subtract( tokenGroup.position(), previousDragPositionRef.current ); for (let other of intersectingTokensRef.current) { other.position(Vector2.add(other.position(), deltaPosition)); } previousDragPositionRef.current = tokenGroup.position(); } } function handleDragEnd(event: Konva.KonvaEventObject) { const tokenGroup = event.target; const mountChanges: Record> = {}; if (tokenState.category === "vehicle") { for (let other of intersectingTokensRef.current) { mountChanges[other.id()] = { x: other.x() / mapWidth, y: other.y() / mapHeight, lastModifiedBy: userId, lastModified: Date.now(), }; } intersectingTokensRef.current = []; } setPreventMapInteraction(false); onTokenStateChange({ ...mountChanges, [tokenState.id]: { x: tokenGroup.x() / mapWidth, y: tokenGroup.y() / mapHeight, lastModifiedBy: userId, lastModified: Date.now(), }, }); onTokenDragEnd(event); } function handleClick(event: Konva.KonvaEventObject) { if (draggable) { const tokenImage = event.target; onTokenMenuOpen(tokenState.id, tokenImage); } } const [tokenOpacity, setTokenOpacity] = useState(1); // Store token pointer down time to check for a click when token is locked const tokenPointerDownTimeRef = useRef(0); function handlePointerDown(event: Konva.KonvaEventObject) { if (draggable) { setPreventMapInteraction(true); } if (tokenState.locked && map.owner === userId) { tokenPointerDownTimeRef.current = event.evt.timeStamp; } } function handlePointerUp(event: Konva.KonvaEventObject) { if (draggable) { setPreventMapInteraction(false); } // Check token click when locked and we are the map owner // We can't use onClick because that doesn't check pointer distance if (tokenState.locked && map.owner === userId) { // If down and up time is small trigger a click const delta = event.evt.timeStamp - tokenPointerDownTimeRef.current; if (delta < 300) { const tokenImage = event.target; onTokenMenuOpen(tokenState.id, tokenImage); } } } function handlePointerEnter() { if (fadeOnHover) { setTokenOpacity(0.5); } } function handlePointerLeave() { if (tokenOpacity !== 1.0) { setTokenOpacity(1.0); } } const minCellSize = Math.min( gridCellPixelSize.width, gridCellPixelSize.height ); const tokenWidth = minCellSize * tokenState.size; const tokenHeight = (minCellSize / tokenAspectRatio) * tokenState.size; // Animate to new token positions if edited by others const tokenX = tokenState.x * mapWidth; const tokenY = tokenState.y * mapHeight; const previousWidth = usePrevious(mapWidth); const previousHeight = usePrevious(mapHeight); const resized = mapWidth !== previousWidth || mapHeight !== previousHeight; const skipAnimation = tokenState.lastModifiedBy === userId || resized; const props = useSpring({ x: tokenX, y: tokenY, immediate: skipAnimation, }); // When a token is hidden if you aren't the map owner hide it completely if (map && !tokenState.visible && map.owner !== userId) { return null; } // Token name is used by on click to find whether a token is a vehicle or prop let tokenName = ""; if (tokenState) { tokenName = tokenState.category; } if (tokenState && tokenState.locked) { tokenName = tokenName + "-locked"; } return ( {}} /> {tokenState.statuses?.length > 0 ? ( ) : null} {tokenState.label ? ( ) : null} ); } export default MapToken;