Add custom transformer

This commit is contained in:
Mitchell McCaffrey 2021-07-25 13:48:50 +10:00
parent 97f1294a23
commit df1e59f666
4 changed files with 199 additions and 112 deletions

View File

@ -31,6 +31,7 @@ import {
TokenMenuOpenChangeEventHandler, TokenMenuOpenChangeEventHandler,
TokenStateChangeEventHandler, TokenStateChangeEventHandler,
} from "../../types/Events"; } from "../../types/Events";
import Transformer from "./Transformer";
type MapTokenProps = { type MapTokenProps = {
tokenState: TokenState; tokenState: TokenState;
@ -41,6 +42,7 @@ type MapTokenProps = {
draggable: boolean; draggable: boolean;
fadeOnHover: boolean; fadeOnHover: boolean;
map: Map; map: Map;
selected: boolean;
}; };
function Token({ function Token({
@ -52,6 +54,7 @@ function Token({
draggable, draggable,
fadeOnHover, fadeOnHover,
map, map,
selected,
}: MapTokenProps) { }: MapTokenProps) {
const userId = useUserId(); const userId = useUserId();
@ -193,6 +196,22 @@ function Token({
} }
} }
const tokenRef = useRef<Konva.Group>(null);
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);
}
}
const minCellSize = Math.min( const minCellSize = Math.min(
gridCellPixelSize.width, gridCellPixelSize.width,
gridCellPixelSize.height gridCellPixelSize.height
@ -228,68 +247,73 @@ function Token({
} }
return ( return (
<animated.Group <>
{...props} <animated.Group
width={tokenWidth} {...props}
height={tokenHeight}
draggable={draggable}
onMouseDown={handlePointerDown}
onMouseUp={handlePointerUp}
onMouseEnter={handlePointerEnter}
onMouseLeave={handlePointerLeave}
onTouchStart={handlePointerDown}
onTouchEnd={handlePointerUp}
onClick={handleClick}
onTap={handleClick}
onDragEnd={handleDragEnd}
onDragStart={handleDragStart}
onDragMove={handleDragMove}
opacity={tokenState.visible ? tokenOpacity : 0.5}
name={tokenName}
id={tokenState.id}
>
<Group
width={tokenWidth} width={tokenWidth}
height={tokenHeight} height={tokenHeight}
x={0} draggable={draggable}
y={0} onMouseDown={handlePointerDown}
rotation={tokenState.rotation} onMouseUp={handlePointerUp}
offsetX={tokenWidth / 2} onMouseEnter={handlePointerEnter}
offsetY={tokenHeight / 2} onMouseLeave={handlePointerLeave}
onTouchStart={handlePointerDown}
onTouchEnd={handlePointerUp}
onClick={handleClick}
onTap={handleClick}
onDragEnd={handleDragEnd}
onDragStart={handleDragStart}
onDragMove={handleDragMove}
opacity={tokenState.visible ? tokenOpacity : 0.5}
name={tokenName}
id={tokenState.id}
> >
<TokenOutline <Group
outline={getScaledOutline(tokenState, tokenWidth, tokenHeight)} ref={tokenRef}
hidden={!!tokenImage} rotation={tokenState.rotation}
/> offsetX={tokenWidth / 2}
</Group> offsetY={tokenHeight / 2}
<KonvaImage >
width={tokenWidth} <Group width={tokenWidth} height={tokenHeight} x={0} y={0}>
height={tokenHeight} <TokenOutline
x={0} outline={getScaledOutline(tokenState, tokenWidth, tokenHeight)}
y={0} hidden={!!tokenImage}
image={tokenImage} />
rotation={tokenState.rotation} </Group>
offsetX={tokenWidth / 2} <KonvaImage
offsetY={tokenHeight / 2} width={tokenWidth}
hitFunc={() => {}} height={tokenHeight}
x={0}
y={0}
image={tokenImage}
hitFunc={() => {}}
/>
<Group>
{tokenState.statuses?.length > 0 ? (
<TokenStatus
tokenState={tokenState}
width={tokenWidth}
height={tokenHeight}
/>
) : null}
</Group>
</Group>
<Group offsetX={tokenWidth / 2} offsetY={tokenHeight / 2}>
{tokenState.label ? (
<TokenLabel
tokenState={tokenState}
width={tokenWidth}
height={tokenHeight}
/>
) : null}
</Group>
</animated.Group>
<Transformer
active={selected}
nodeRef={tokenRef}
onTransformEnd={handleTransformEnd}
/> />
<Group offsetX={tokenWidth / 2} offsetY={tokenHeight / 2}> </>
{tokenState.statuses?.length > 0 ? (
<TokenStatus
tokenState={tokenState}
width={tokenWidth}
height={tokenHeight}
/>
) : null}
{tokenState.label ? (
<TokenLabel
tokenState={tokenState}
width={tokenWidth}
height={tokenHeight}
/>
) : null}
</Group>
</animated.Group>
); );
} }

View File

@ -0,0 +1,113 @@
import Konva from "konva";
import { Transform } from "konva/lib/Util";
import { useEffect, useRef } from "react";
import { Transformer as KonvaTransformer } from "react-konva";
import { useSetPreventMapInteraction } from "../../contexts/MapInteractionContext";
type ResizerProps = {
active: boolean;
nodeRef: React.RefObject<Konva.Node>;
onTransformStart?: (event: Konva.KonvaEventObject<Event>) => void;
onTransformEnd?: (event: Konva.KonvaEventObject<Event>) => void;
};
function Transformer({
active,
nodeRef,
onTransformStart,
onTransformEnd,
}: ResizerProps) {
const setPreventMapInteraction = useSetPreventMapInteraction();
const transformerRef = useRef<Konva.Transformer>(null);
useEffect(() => {
if (active && transformerRef.current && nodeRef.current) {
// we need to attach transformer manually
transformerRef.current.nodes([nodeRef.current]);
transformerRef.current.getLayer()?.batchDraw();
}
}, [active, nodeRef]);
const movingAnchorRef = useRef<string>();
function handleTransformStart(e: Konva.KonvaEventObject<Event>) {
if (transformerRef.current) {
movingAnchorRef.current = transformerRef.current._movingAnchorName;
if (active) {
setPreventMapInteraction(true);
}
onTransformStart && onTransformStart(e);
}
}
function handleTransformEnd(e: Konva.KonvaEventObject<Event>) {
if (active) {
setPreventMapInteraction(false);
}
onTransformEnd && onTransformEnd(e);
}
if (!active) {
return null;
}
return (
<KonvaTransformer
ref={transformerRef}
boundBoxFunc={(oldBox, newBox) => {
let snapBox = { ...newBox };
const movingAnchor = movingAnchorRef.current;
if (movingAnchor === "middle-left" || movingAnchor === "middle-right") {
const deltaWidth = newBox.width - oldBox.width;
// Account for node ratio
const inverseRatio =
Math.round(oldBox.height) / Math.round(oldBox.width);
const deltaHeight = inverseRatio * deltaWidth;
// Account for node rotation
// Create a transform to unrotate the x,y position of the Box
const rotator = new Transform();
rotator.rotate(-snapBox.rotation);
// Unrotate and add the resize amount
let rotatedMin = rotator.point({ x: snapBox.x, y: snapBox.y });
rotatedMin.y = rotatedMin.y - deltaHeight / 2;
// Rotated back
rotator.invert();
rotatedMin = rotator.point(rotatedMin);
snapBox = {
...snapBox,
height: snapBox.height + deltaHeight,
x: rotatedMin.x,
y: rotatedMin.y,
};
}
if (snapBox.width < 5 || snapBox.height < 5) {
return oldBox;
}
return snapBox;
}}
onTransformStart={handleTransformStart}
onTransformEnd={handleTransformEnd}
centeredScaling={true}
rotationSnaps={[...Array(24).keys()].map((n) => n * 15)}
rotateAnchorOffset={20}
anchorCornerRadius={10}
enabledAnchors={["middle-left", "middle-right"]}
flipEnabled={false}
ignoreStroke={true}
borderStroke="transparent"
anchorStroke="hsl(210, 50%, 96%"
anchorFill="hsla(230, 25%, 15%, 80%)"
anchorStrokeWidth={3}
borderStrokeWidth={2}
anchorSize={15}
useSingleNodeRotation={true}
/>
);
}
export default Transformer;

View File

@ -2,8 +2,6 @@ import React, { useEffect, useState } from "react";
import { Box, Input, Flex, Text, IconButton } from "theme-ui"; import { Box, Input, Flex, Text, IconButton } from "theme-ui";
import Konva from "konva"; import Konva from "konva";
import Slider from "../Slider";
import MapMenu from "../map/MapMenu"; import MapMenu from "../map/MapMenu";
import colors, { Color, colorOptions } from "../../helpers/colors"; import colors, { Color, colorOptions } from "../../helpers/colors";
@ -33,7 +31,6 @@ type TokenMenuProps = {
map: Map | null; map: Map | null;
}; };
const defaultTokenMaxSize = 6;
function TokenMenu({ function TokenMenu({
isOpen, isOpen,
onRequestClose, onRequestClose,
@ -46,12 +43,10 @@ function TokenMenu({
const wasOpen = usePrevious(isOpen); const wasOpen = usePrevious(isOpen);
const [tokenMaxSize, setTokenMaxSize] = useState(defaultTokenMaxSize);
const [menuLeft, setMenuLeft] = useState(0); const [menuLeft, setMenuLeft] = useState(0);
const [menuTop, setMenuTop] = useState(0); const [menuTop, setMenuTop] = useState(0);
useEffect(() => { useEffect(() => {
if (isOpen && !wasOpen && tokenState) { if (isOpen && !wasOpen && tokenState) {
setTokenMaxSize(Math.max(tokenState.size, defaultTokenMaxSize));
// Update menu position // Update menu position
if (tokenImage) { if (tokenImage) {
const imageRect = tokenImage.getClientRect(); const imageRect = tokenImage.getClientRect();
@ -89,19 +84,6 @@ function TokenMenu({
}); });
} }
function handleSizeChange(event: React.ChangeEvent<HTMLInputElement>) {
const newSize = parseFloat(event.target.value);
tokenState && onTokenStateChange({ [tokenState.id]: { size: newSize } });
}
function handleRotationChange(event: React.ChangeEvent<HTMLInputElement>) {
const newRotation = parseInt(event.target.value);
tokenState &&
onTokenStateChange({
[tokenState.id]: { rotation: newRotation },
});
}
function handleVisibleChange() { function handleVisibleChange() {
tokenState && tokenState &&
onTokenStateChange({ onTokenStateChange({
@ -223,42 +205,6 @@ function TokenMenu({
</Box> </Box>
))} ))}
</Box> </Box>
<Flex sx={{ alignItems: "center" }}>
<Text
as="label"
variant="body2"
sx={{ width: "40%", fontSize: "16px" }}
p={1}
>
Size:
</Text>
<Slider
value={(tokenState && tokenState.size) || 1}
onChange={handleSizeChange}
step={0.5}
min={0.5}
max={tokenMaxSize}
mr={1}
/>
</Flex>
<Flex sx={{ alignItems: "center" }}>
<Text
as="label"
variant="body2"
sx={{ width: "65%", fontSize: "16px" }}
p={1}
>
Rotate:
</Text>
<Slider
value={(tokenState && tokenState.rotation) || 0}
onChange={handleRotationChange}
step={15}
min={0}
max={360}
mr={1}
/>
</Flex>
{/* Only show hide and lock token actions to map owners */} {/* Only show hide and lock token actions to map owners */}
{map && map.owner === userId && ( {map && map.owner === userId && (
<Flex sx={{ alignItems: "center", justifyContent: "space-around" }}> <Flex sx={{ alignItems: "center", justifyContent: "space-around" }}>

View File

@ -96,6 +96,10 @@ function useMapTokens(
} }
fadeOnHover={selectedToolId === "drawing"} fadeOnHover={selectedToolId === "drawing"}
map={map} map={map}
selected={
!!tokenMenuOptions &&
tokenMenuOptions.tokenStateId === tokenState.id
}
/> />
))} ))}
</Group> </Group>