Updated transformer to also transform attached tokens

This commit is contained in:
Mitchell McCaffrey 2021-08-13 17:45:15 +10:00
parent 54debeb61c
commit 3ed6e63080
5 changed files with 201 additions and 103 deletions

View File

@ -287,7 +287,7 @@ function Note({
</animated.Group> </animated.Group>
<Transformer <Transformer
active={(!note.locked && selected) || isTransforming} active={(!note.locked && selected) || isTransforming}
nodeRef={noteRef} nodes={() => (noteRef.current ? [noteRef.current] : [])}
onTransformEnd={handleTransformEnd} onTransformEnd={handleTransformEnd}
onTransformStart={handleTransformStart} onTransformStart={handleTransformStart}
gridScale={map?.grid.measurement.scale || ""} gridScale={map?.grid.measurement.scale || ""}

View File

@ -1,4 +1,4 @@
import { useState, useRef } from "react"; import { useState, useRef, useCallback, useMemo } from "react";
import { Image as KonvaImage, Group } from "react-konva"; import { Image as KonvaImage, Group } from "react-konva";
import { useSpring, animated } from "@react-spring/konva"; import { useSpring, animated } from "@react-spring/konva";
import Konva from "konva"; import Konva from "konva";
@ -31,9 +31,11 @@ import {
TokenMenuCloseChangeEventHandler, TokenMenuCloseChangeEventHandler,
TokenMenuOpenChangeEventHandler, TokenMenuOpenChangeEventHandler,
TokenStateChangeEventHandler, TokenStateChangeEventHandler,
TokenTransformEventHandler,
} from "../../types/Events"; } from "../../types/Events";
import Transformer from "./Transformer"; import Transformer from "./Transformer";
import TokenAttachment from "./TokenAttachment"; import TokenAttachment from "./TokenAttachment";
import { MapState } from "../../types/MapState";
type MapTokenProps = { type MapTokenProps = {
tokenState: TokenState; tokenState: TokenState;
@ -42,10 +44,14 @@ type MapTokenProps = {
onTokenMenuClose: TokenMenuCloseChangeEventHandler; onTokenMenuClose: TokenMenuCloseChangeEventHandler;
onTokenDragStart: TokenDragEventHandler; onTokenDragStart: TokenDragEventHandler;
onTokenDragEnd: TokenDragEventHandler; onTokenDragEnd: TokenDragEventHandler;
onTokenTransformStart: TokenTransformEventHandler;
onTokenTransformEnd: TokenTransformEventHandler;
transforming: boolean;
draggable: boolean; draggable: boolean;
selectable: boolean; selectable: boolean;
fadeOnHover: boolean; fadeOnHover: boolean;
map: Map; map: Map;
mapState: MapState;
selected: boolean; selected: boolean;
}; };
@ -56,10 +62,14 @@ function Token({
onTokenMenuClose, onTokenMenuClose,
onTokenDragStart, onTokenDragStart,
onTokenDragEnd, onTokenDragEnd,
onTokenTransformStart,
onTokenTransformEnd,
transforming,
draggable, draggable,
selectable, selectable,
fadeOnHover, fadeOnHover,
map, map,
mapState,
selected, selected,
}: MapTokenProps) { }: MapTokenProps) {
const userId = useUserId(); const userId = useUserId();
@ -86,64 +96,24 @@ function Token({
const [attachmentOverCharacter, setAttachmentOverCharacter] = useState(false); const [attachmentOverCharacter, setAttachmentOverCharacter] = useState(false);
// The characters that we're present when an attachment is dragged, used to highlight the attachment // The characters that we're present when an attachment is dragged, used to highlight the attachment
const attachmentCharactersRef = useRef<Konva.Node[]>([]); const attachmentCharactersRef = useRef<Konva.Node[]>([]);
const attachmentThreshold = Vector2.componentMin(gridCellPixelSize) / 4; const attachmentThreshold = useMemo(
() => Vector2.componentMin(gridCellPixelSize) / 4,
[gridCellPixelSize]
);
function handleDragStart(event: Konva.KonvaEventObject<DragEvent>) { function handleDragStart(event: Konva.KonvaEventObject<DragEvent>) {
const tokenGroup = event.target as Konva.Shape; const tokenGroup = event.target as Konva.Shape;
const layer = tokenGroup.getLayer();
if (!layer) {
return;
}
previousDragPositionRef.current = tokenGroup.position(); previousDragPositionRef.current = tokenGroup.position();
if (tokenState.category === "vehicle") { attachedTokensRef.current = getAttachedTokens();
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 characters on the map and check whether they're
// intersecting the vehicle
const characters = layer.find(".character");
const attachments = layer.find(".attachment");
const tokens = [...characters, ...attachments];
for (let other of tokens) {
if (other === tokenGroup) {
continue;
}
if (tokenIntersection.intersects(other.position())) {
attachedTokensRef.current.push(other);
}
}
}
if (tokenState.category === "attachment") { if (tokenState.category === "attachment") {
// If we're dragging an attachment add all characters to the attachment characters // If we're dragging an attachment add all characters to the attachment characters
// So we can check for highlights // So we can check for highlights
previousDragPositionRef.current = tokenGroup.position(); const characters = tokenGroup.getLayer()?.find(".character") || [];
const characters = layer.find(".character");
attachmentCharactersRef.current = characters; attachmentCharactersRef.current = characters;
} }
if (tokenState.category === "character") {
// Find all attachments and check whether they are close to the center of this token
const attachments = layer.find(".attachment");
for (let attachment of attachments) {
if (attachment === tokenGroup) {
continue;
}
const distance = Vector2.distance(
tokenGroup.position(),
attachment.position()
);
if (distance < attachmentThreshold) {
attachedTokensRef.current.push(attachment);
}
}
}
setDragging(true); setDragging(true);
onTokenDragStart( onTokenDragStart(
event, event,
@ -228,8 +198,8 @@ function Token({
} }
function handleClick() { function handleClick() {
if (selectable && draggable && tokenRef.current) { if (selectable && draggable && transformRootRef.current) {
onTokenMenuOpen(tokenState.id, tokenRef.current, true); onTokenMenuOpen(tokenState.id, transformRootRef.current, true);
} }
} }
@ -251,11 +221,11 @@ function Token({
} }
// Check token click when locked and selectable // Check token click when locked and selectable
// We can't use onClick because that doesn't check pointer distance // We can't use onClick because that doesn't check pointer distance
if (tokenState.locked && selectable && tokenRef.current) { if (tokenState.locked && selectable && transformRootRef.current) {
// If down and up time is small trigger a click // If down and up time is small trigger a click
const delta = event.evt.timeStamp - tokenPointerDownTimeRef.current; const delta = event.evt.timeStamp - tokenPointerDownTimeRef.current;
if (delta < 300) { if (delta < 300) {
onTokenMenuOpen(tokenState.id, tokenRef.current, true); onTokenMenuOpen(tokenState.id, transformRootRef.current, true);
} }
} }
} }
@ -272,30 +242,7 @@ function Token({
} }
} }
const tokenRef = useRef<Konva.Group>(null); const transformRootRef = useRef<Konva.Group>(null);
const [isTransforming, setIsTransforming] = useState(false);
function handleTransformStart() {
setIsTransforming(true);
onTokenMenuClose();
}
function handleTransformEnd(event: Konva.KonvaEventObject<Event>) {
if (tokenRef.current) {
const sizeChange = event.target.scaleX();
const rotation = event.target.rotation();
onTokenStateChange({
[tokenState.id]: {
size: tokenState.size * sizeChange,
rotation: rotation,
},
});
tokenRef.current.scaleX(1);
tokenRef.current.scaleY(1);
onTokenMenuOpen(tokenState.id, tokenRef.current, false);
}
setIsTransforming(false);
}
const minCellSize = Math.min( const minCellSize = Math.min(
gridCellPixelSize.width, gridCellPixelSize.width,
@ -317,6 +264,132 @@ function Token({
immediate: skipAnimation, immediate: skipAnimation,
}); });
const getAttachedTokens = useCallback(() => {
const transformRoot = transformRootRef.current;
const tokenGroup = transformRoot?.parent;
const layer = transformRoot?.getLayer();
let attachedTokens: Konva.Node[] = [];
if (tokenGroup && layer) {
if (tokenState.category === "vehicle") {
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 characters on the map and check whether they're
// intersecting the vehicle
const characters = layer.find(".character");
const attachments = layer.find(".attachment");
const tokens = [...characters, ...attachments];
for (let other of tokens) {
const id = other.id();
if (id in mapState.tokens) {
const position = {
x: mapState.tokens[id].x * mapWidth,
y: mapState.tokens[id].y * mapHeight,
};
if (tokenIntersection.intersects(position)) {
attachedTokens.push(other);
}
}
}
}
if (tokenState.category === "character") {
// Find all attachments and check whether they are close to the center of this token
const attachments = layer.find(".attachment");
for (let attachment of attachments) {
const id = attachment.id();
if (id in mapState.tokens) {
const position = {
x: mapState.tokens[id].x * mapWidth,
y: mapState.tokens[id].y * mapHeight,
};
const distance = Vector2.distance(tokenGroup.position(), position);
if (distance < attachmentThreshold) {
attachedTokens.push(attachment);
}
}
}
}
}
return attachedTokens;
}, [
attachmentThreshold,
tokenHeight,
tokenWidth,
tokenState,
tokenX,
tokenY,
mapState,
mapWidth,
mapHeight,
]);
// Override transform active to always show this transformer when using it
const [overrideTransformActive, setOverrideTransformActive] = useState(false);
function handleTransformStart(event: Konva.KonvaEventObject<Event>) {
setOverrideTransformActive(true);
onTokenTransformStart(event);
onTokenMenuClose();
}
function handleTransformEnd(event: Konva.KonvaEventObject<Event>) {
const transformer = event.currentTarget as Konva.Transformer;
const nodes = transformer.nodes();
const tokenChanges: Record<string, Partial<TokenState>> = {};
for (let node of nodes) {
const id = node.id();
if (id in mapState.tokens) {
const sizeChange = node.scaleX();
const rotation = node.rotation();
const xChange = node.x() / mapWidth;
const yChange = node.y() / mapHeight;
tokenChanges[id] = {
size: mapState.tokens[id].size * sizeChange,
rotation: rotation,
x: mapState.tokens[id].x + xChange,
y: mapState.tokens[id].y + yChange,
};
}
node.scaleX(1);
node.scaleY(1);
node.x(0);
node.y(0);
}
onTokenStateChange(tokenChanges);
if (transformRootRef.current) {
onTokenMenuOpen(tokenState.id, transformRootRef.current, false);
}
setOverrideTransformActive(false);
onTokenTransformEnd(event);
}
const transformerActive = useMemo(
() => (!tokenState.locked && selected) || overrideTransformActive,
[tokenState, selected, overrideTransformActive]
);
const transformerNodes = useMemo(
() => () => {
if (transformRootRef.current) {
// Find attached transform roots
const attached = getAttachedTokens().map((node) =>
(node as Konva.Group).findOne(".transform-root")
);
return [transformRootRef.current, ...attached];
} else {
return [];
}
},
[getAttachedTokens]
);
// When a token is hidden if you aren't the map owner hide it completely // When a token is hidden if you aren't the map owner hide it completely
if (map && !tokenState.visible && map.owner !== userId) { if (map && !tokenState.visible && map.owner !== userId) {
return null; return null;
@ -354,7 +427,9 @@ function Token({
id={tokenState.id} id={tokenState.id}
> >
<Group <Group
ref={tokenRef} ref={transformRootRef}
id={tokenState.id}
name="transform-root"
rotation={tokenState.rotation} rotation={tokenState.rotation}
offsetX={tokenWidth / 2} offsetX={tokenWidth / 2}
offsetY={tokenHeight / 2} offsetY={tokenHeight / 2}
@ -378,7 +453,7 @@ function Token({
hitFunc={() => {}} hitFunc={() => {}}
/> />
</Group> </Group>
{!isTransforming ? ( {!transforming ? (
<Group offsetX={tokenWidth / 2} offsetY={tokenHeight / 2}> <Group offsetX={tokenWidth / 2} offsetY={tokenHeight / 2}>
{tokenState.statuses?.length > 0 ? ( {tokenState.statuses?.length > 0 ? (
<TokenStatus <TokenStatus
@ -410,8 +485,8 @@ function Token({
) : null} ) : null}
</animated.Group> </animated.Group>
<Transformer <Transformer
active={(!tokenState.locked && selected) || isTransforming} active={transformerActive}
nodeRef={tokenRef} nodes={transformerNodes}
onTransformEnd={handleTransformEnd} onTransformEnd={handleTransformEnd}
onTransformStart={handleTransformStart} onTransformStart={handleTransformStart}
gridScale={map.grid.measurement.scale} gridScale={map.grid.measurement.scale}

View File

@ -16,9 +16,9 @@ import scaleDark from "../../images/ScaleDark.png";
import rotateDark from "../../images/RotateDark.png"; import rotateDark from "../../images/RotateDark.png";
import { parseGridScale } from "../../helpers/grid"; import { parseGridScale } from "../../helpers/grid";
type ResizerProps = { type TransformerProps = {
active: boolean; active: boolean;
nodeRef: React.RefObject<Konva.Node>; nodes: () => Konva.Node[];
onTransformStart?: (event: Konva.KonvaEventObject<Event>) => void; onTransformStart?: (event: Konva.KonvaEventObject<Event>) => void;
onTransformEnd?: (event: Konva.KonvaEventObject<Event>) => void; onTransformEnd?: (event: Konva.KonvaEventObject<Event>) => void;
gridScale: string; gridScale: string;
@ -26,11 +26,11 @@ type ResizerProps = {
function Transformer({ function Transformer({
active, active,
nodeRef, nodes,
onTransformStart, onTransformStart,
onTransformEnd, onTransformEnd,
gridScale, gridScale,
}: ResizerProps) { }: TransformerProps) {
const setPreventMapInteraction = useSetPreventMapInteraction(); const setPreventMapInteraction = useSetPreventMapInteraction();
const gridCellPixelSize = useGridCellPixelSize(); const gridCellPixelSize = useGridCellPixelSize();
@ -50,21 +50,25 @@ function Transformer({
const [anchorRotate, anchorRotateStatus] = useAnchorImage(96, rotateDark); const [anchorRotate, anchorRotateStatus] = useAnchorImage(96, rotateDark);
useEffect(() => { useEffect(() => {
const transformer = transformerRef.current;
if ( if (
active && active &&
transformerRef.current && transformer &&
nodeRef.current &&
anchorScaleStatus === "loaded" && anchorScaleStatus === "loaded" &&
anchorRotateStatus === "loaded" anchorRotateStatus === "loaded"
) { ) {
// we need to attach transformer manually // we need to attach transformer manually
transformerRef.current.nodes([nodeRef.current]); const n = nodes();
// Slice the nodes to only attach a single node to allow useSingleNodeRotation to
// control the transformer rotation
transformer.setNodes(n.slice(0, 1));
// Update the private _nodes to allow transforming all the added nodes
// TODO: Look at subclassing Konva.Transformer and remove this hack
transformer._nodes = n;
const middleLeft = const middleLeft = transformer.findOne<Konva.Rect>(".middle-left");
transformerRef.current.findOne<Konva.Rect>(".middle-left"); const middleRight = transformer.findOne<Konva.Rect>(".middle-right");
const middleRight = const rotater = transformer.findOne<Konva.Rect>(".rotater");
transformerRef.current.findOne<Konva.Rect>(".middle-right");
const rotater = transformerRef.current.findOne<Konva.Rect>(".rotater");
middleLeft.fillPriority("pattern"); middleLeft.fillPriority("pattern");
middleLeft.fillPatternImage(anchorScale); middleLeft.fillPatternImage(anchorScale);
@ -84,11 +88,11 @@ function Transformer({
rotater.fillPatternScaleX(0.5); rotater.fillPatternScaleX(0.5);
rotater.fillPatternScaleY(0.5); rotater.fillPatternScaleY(0.5);
transformerRef.current.getLayer()?.batchDraw(); transformer.getLayer()?.batchDraw();
} }
}, [ }, [
active, active,
nodeRef, nodes,
anchorScale, anchorScale,
anchorRotate, anchorRotate,
anchorScaleStatus, anchorScaleStatus,
@ -97,8 +101,8 @@ function Transformer({
function updateGridCellAbsoluteSize() { function updateGridCellAbsoluteSize() {
if (active) { if (active) {
const node = nodeRef.current; const transformer = transformerRef.current;
const stage = node?.getStage(); const stage = transformer?.getStage();
const mapImage = stage?.findOne("#mapImage"); const mapImage = stage?.findOne("#mapImage");
if (!mapImage) { if (!mapImage) {
return; return;
@ -123,13 +127,12 @@ function Transformer({
const transformTextRef = useRef<Konva.Group>(); const transformTextRef = useRef<Konva.Group>();
function handleTransformStart(e: Konva.KonvaEventObject<Event>) { function handleTransformStart(e: Konva.KonvaEventObject<Event>) {
const transformer = transformerRef.current; const transformer = transformerRef.current;
const node = nodeRef.current; if (transformer) {
if (transformer && node) {
movingAnchorRef.current = transformer._movingAnchorName; movingAnchorRef.current = transformer._movingAnchorName;
setPreventMapInteraction(true); setPreventMapInteraction(true);
const transformText = new Konva.Label(); const transformText = new Konva.Label();
const stageScale = node.getStage()?.scale() || { x: 1, y: 1 }; const stageScale = transformer.getStage()?.scale() || { x: 1, y: 1 };
transformText.scale(Vector2.divide({ x: 1, y: 1 }, stageScale)); transformText.scale(Vector2.divide({ x: 1, y: 1 }, stageScale));
const tag = new Konva.Tag(); const tag = new Konva.Tag();
@ -148,7 +151,7 @@ function Transformer({
transformText.add(tag); transformText.add(tag);
transformText.add(text); transformText.add(text);
node.getLayer()?.add(transformText); transformer.getLayer()?.add(transformText);
transformTextRef.current = transformText; transformTextRef.current = transformText;
updateGridCellAbsoluteSize(); updateGridCellAbsoluteSize();
@ -159,10 +162,10 @@ function Transformer({
} }
function updateTransformText() { function updateTransformText() {
const node = nodeRef.current;
const movingAnchor = movingAnchorRef.current; const movingAnchor = movingAnchorRef.current;
const transformText = transformTextRef.current; const transformText = transformTextRef.current;
const transformer = transformerRef.current; const transformer = transformerRef.current;
const node = transformer?.nodes()[0];
if (node && transformText && transformer) { if (node && transformText && transformer) {
const text = transformText.getChildren()[1] as Konva.Text; const text = transformText.getChildren()[1] as Konva.Text;
if (movingAnchor === "rotater") { if (movingAnchor === "rotater") {
@ -286,7 +289,6 @@ function Transformer({
anchorCornerRadius={24} anchorCornerRadius={24}
borderStrokeWidth={0} borderStrokeWidth={0}
anchorSize={48} anchorSize={48}
useSingleNodeRotation={true}
/> />
</Portal> </Portal>
); );

View File

@ -117,9 +117,23 @@ function useMapTokens(
useKeyboard(handleKeyDown, handleKeyUp); useKeyboard(handleKeyDown, handleKeyUp);
useBlur(handleBlur); useBlur(handleBlur);
const [transformingTokensIds, setTransformingTokenIds] = useState<string[]>(
[]
);
function handleTokenTransformStart(event: Konva.KonvaEventObject<Event>) {
const transformer = event.currentTarget as Konva.Transformer;
const nodes = transformer.nodes();
setTransformingTokenIds(nodes.map((node) => node.id()));
}
function handleTokenTransformEnd() {
setTransformingTokenIds([]);
}
function tokenFromTokenState(tokenState: TokenState) { function tokenFromTokenState(tokenState: TokenState) {
return ( return (
map && ( map &&
mapState && (
<Token <Token
key={tokenState.id} key={tokenState.id}
tokenState={tokenState} tokenState={tokenState}
@ -128,6 +142,9 @@ function useMapTokens(
onTokenMenuClose={handleTokenMenuClose} onTokenMenuClose={handleTokenMenuClose}
onTokenDragStart={handleTokenDragStart} onTokenDragStart={handleTokenDragStart}
onTokenDragEnd={handleTokenDragEnd} onTokenDragEnd={handleTokenDragEnd}
onTokenTransformStart={handleTokenTransformStart}
onTokenTransformEnd={handleTokenTransformEnd}
transforming={transformingTokensIds.includes(tokenState.id)}
draggable={ draggable={
selectedToolId === "move" && selectedToolId === "move" &&
!(tokenState.id in disabledTokens) && !(tokenState.id in disabledTokens) &&
@ -142,6 +159,7 @@ function useMapTokens(
tokenState.category !== "prop" && selectedToolId === "drawing" tokenState.category !== "prop" && selectedToolId === "drawing"
} }
map={map} map={map}
mapState={mapState}
selected={ selected={
!!tokenMenuOptions && !!tokenMenuOptions &&
isTokenMenuOpen && isTokenMenuOpen &&

View File

@ -38,6 +38,9 @@ export type TokenDragEventHandler = (
tokenStateId: string, tokenStateId: string,
attachedTokenStateIds: string[] attachedTokenStateIds: string[]
) => void; ) => void;
export type TokenTransformEventHandler = (
event: Konva.KonvaEventObject<Event>
) => void;
export type NoteCreateEventHander = (notes: Note[]) => void; export type NoteCreateEventHander = (notes: Note[]) => void;
export type NoteRemoveEventHander = (noteIds: string[]) => void; export type NoteRemoveEventHander = (noteIds: string[]) => void;