Fix transform attachments affecting transform anchor positions

This commit is contained in:
Mitchell McCaffrey 2021-08-14 13:15:51 +10:00
parent 37f0ab3b26
commit 4eca2dcfc7
5 changed files with 464 additions and 239 deletions

View File

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

View File

@ -31,7 +31,7 @@ import {
TokenMenuCloseChangeEventHandler,
TokenMenuOpenChangeEventHandler,
TokenStateChangeEventHandler,
TokenTransformEventHandler,
CustomTransformEventHandler,
} from "../../types/Events";
import Transformer from "./Transformer";
import TokenAttachment from "./TokenAttachment";
@ -44,8 +44,8 @@ type MapTokenProps = {
onTokenMenuClose: TokenMenuCloseChangeEventHandler;
onTokenDragStart: TokenDragEventHandler;
onTokenDragEnd: TokenDragEventHandler;
onTokenTransformStart: TokenTransformEventHandler;
onTokenTransformEnd: TokenTransformEventHandler;
onTokenTransformStart: CustomTransformEventHandler;
onTokenTransformEnd: CustomTransformEventHandler;
transforming: boolean;
draggable: boolean;
selectable: boolean;
@ -332,15 +332,21 @@ function Token({
// Override transform active to always show this transformer when using it
const [overrideTransformActive, setOverrideTransformActive] = useState(false);
function handleTransformStart(event: Konva.KonvaEventObject<Event>) {
function handleTransformStart(
event: Konva.KonvaEventObject<Event>,
attachments: Konva.Node[]
) {
setOverrideTransformActive(true);
onTokenTransformStart(event);
onTokenTransformStart(event, attachments);
onTokenMenuClose();
}
function handleTransformEnd(event: Konva.KonvaEventObject<Event>) {
function handleTransformEnd(
event: Konva.KonvaEventObject<Event>,
attachments: Konva.Node[]
) {
const transformer = event.currentTarget as Konva.Transformer;
const nodes = transformer.nodes();
const nodes = [...transformer.nodes(), ...attachments];
const tokenChanges: Record<string, Partial<TokenState>> = {};
for (let node of nodes) {
const id = node.id();
@ -367,7 +373,7 @@ function Token({
onTokenMenuOpen(tokenState.id, transformRootRef.current, false);
}
setOverrideTransformActive(false);
onTokenTransformEnd(event);
onTokenTransformEnd(event, attachments);
}
const transformerActive = useMemo(
@ -375,20 +381,16 @@ function Token({
[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]
);
const transformerAttachments = useMemo(() => {
if (transformerActive) {
// Find attached transform roots
return getAttachedTokens().map((node) =>
(node as Konva.Group).findOne(".transform-root")
);
} else {
return [];
}
}, [getAttachedTokens, transformerActive]);
// When a token is hidden if you aren't the map owner hide it completely
if (map && !tokenState.visible && map.owner !== userId) {
@ -486,7 +488,12 @@ function Token({
</animated.Group>
<Transformer
active={transformerActive}
nodes={transformerNodes}
nodes={
transformRootRef.current
? [transformRootRef.current as Konva.Node]
: []
}
attachments={transformerAttachments}
onTransformEnd={handleTransformEnd}
onTransformStart={handleTransformStart}
gridScale={map.grid.measurement.scale}

View File

@ -1,35 +1,216 @@
import Konva from "konva";
import { Transform } from "konva/lib/Util";
import { useEffect, useRef, useState } from "react";
import { Transformer as KonvaTransformer } from "react-konva";
import { Portal } from "react-konva-utils";
import { useEffect, useMemo, useRef, useState } from "react";
import {
useGridCellPixelSize,
useGridSnappingSensitivity,
} from "../../contexts/GridContext";
import { useSetPreventMapInteraction } from "../../contexts/MapInteractionContext";
import { useMapStage } from "../../contexts/MapStageContext";
import { roundTo } from "../../helpers/shared";
import Vector2 from "../../helpers/Vector2";
import { parseGridScale } from "../../helpers/grid";
import scaleDark from "../../images/ScaleDark.png";
import rotateDark from "../../images/RotateDark.png";
import { parseGridScale } from "../../helpers/grid";
import { CustomTransformEventHandler } from "../../types/Events";
type TransformerProps = {
active: boolean;
nodes: () => Konva.Node[];
onTransformStart?: (event: Konva.KonvaEventObject<Event>) => void;
onTransformEnd?: (event: Konva.KonvaEventObject<Event>) => void;
nodes: Konva.Node[];
attachments: Konva.Node[];
onTransformStart?: CustomTransformEventHandler;
onTransform?: CustomTransformEventHandler;
onTransformEnd?: CustomTransformEventHandler;
gridScale: string;
portalSelector: string;
};
export class CustomTransformer extends Konva.Transformer {
attachments: Konva.Node[] = [];
// Override fitNodesInto applying transform to attachments as well
_fitNodesInto(newAttrs: any, evt?: any) {
var oldAttrs = this._getNodeRect();
const minSize = 1;
if (
Konva.Util._inRange(
newAttrs.width,
-this.padding() * 2 - minSize,
minSize
)
) {
this.update();
return;
}
if (
Konva.Util._inRange(
newAttrs.height,
-this.padding() * 2 - minSize,
minSize
)
) {
this.update();
return;
}
const allowNegativeScale = this.flipEnabled();
var t = new Transform();
t.rotate(this.rotation());
if (
this._movingAnchorName &&
newAttrs.width < 0 &&
this._movingAnchorName.indexOf("left") >= 0
) {
const offset = t.point({
x: -this.padding() * 2,
y: 0,
});
newAttrs.x += offset.x;
newAttrs.y += offset.y;
newAttrs.width += this.padding() * 2;
this._movingAnchorName = this._movingAnchorName.replace("left", "right");
this._anchorDragOffset.x -= offset.x;
this._anchorDragOffset.y -= offset.y;
if (!allowNegativeScale) {
this.update();
return;
}
} else if (
this._movingAnchorName &&
newAttrs.width < 0 &&
this._movingAnchorName.indexOf("right") >= 0
) {
const offset = t.point({
x: this.padding() * 2,
y: 0,
});
this._movingAnchorName = this._movingAnchorName.replace("right", "left");
this._anchorDragOffset.x -= offset.x;
this._anchorDragOffset.y -= offset.y;
newAttrs.width += this.padding() * 2;
if (!allowNegativeScale) {
this.update();
return;
}
}
if (
this._movingAnchorName &&
newAttrs.height < 0 &&
this._movingAnchorName.indexOf("top") >= 0
) {
const offset = t.point({
x: 0,
y: -this.padding() * 2,
});
newAttrs.x += offset.x;
newAttrs.y += offset.y;
this._movingAnchorName = this._movingAnchorName.replace("top", "bottom");
this._anchorDragOffset.x -= offset.x;
this._anchorDragOffset.y -= offset.y;
newAttrs.height += this.padding() * 2;
if (!allowNegativeScale) {
this.update();
return;
}
} else if (
this._movingAnchorName &&
newAttrs.height < 0 &&
this._movingAnchorName.indexOf("bottom") >= 0
) {
const offset = t.point({
x: 0,
y: this.padding() * 2,
});
this._movingAnchorName = this._movingAnchorName.replace("bottom", "top");
this._anchorDragOffset.x -= offset.x;
this._anchorDragOffset.y -= offset.y;
newAttrs.height += this.padding() * 2;
if (!allowNegativeScale) {
this.update();
return;
}
}
if (this.boundBoxFunc()) {
const bounded = this.boundBoxFunc()(oldAttrs, newAttrs);
if (bounded) {
newAttrs = bounded;
} else {
Konva.Util.warn(
"boundBoxFunc returned falsy. You should return new bound rect from it!"
);
}
}
// base size value doesn't really matter
// we just need to think about bounding boxes as transforms
// but how?
// the idea is that we have a transformed rectangle with the size of "baseSize"
const baseSize = 10000000;
const oldTr = new Transform();
oldTr.translate(oldAttrs.x, oldAttrs.y);
oldTr.rotate(oldAttrs.rotation);
oldTr.scale(oldAttrs.width / baseSize, oldAttrs.height / baseSize);
const newTr = new Transform();
newTr.translate(newAttrs.x, newAttrs.y);
newTr.rotate(newAttrs.rotation);
newTr.scale(newAttrs.width / baseSize, newAttrs.height / baseSize);
// now lets think we had [old transform] and n ow we have [new transform]
// Now, the questions is: how can we transform "parent" to go from [old transform] into [new transform]
// in equation it will be:
// [delta transform] * [old transform] = [new transform]
// that means that
// [delta transform] = [new transform] * [old transform inverted]
const delta = newTr.multiply(oldTr.invert());
[...this._nodes, ...this.attachments].forEach((node) => {
// for each node we have the same [delta transform]
// the equations is
// [delta transform] * [parent transform] * [old local transform] = [parent transform] * [new local transform]
// and we need to find [new local transform]
// [new local] = [parent inverted] * [delta] * [parent] * [old local]
const parentTransform = node.getParent().getAbsoluteTransform();
const localTransform = node.getTransform().copy();
// skip offset:
localTransform.translate(node.offsetX(), node.offsetY());
const newLocalTransform = new Transform();
newLocalTransform
.multiply(parentTransform.copy().invert())
.multiply(delta)
.multiply(parentTransform)
.multiply(localTransform);
const attrs = newLocalTransform.decompose();
node.setAttrs(attrs);
this._fire("transform", { evt: evt, target: node });
node._fire("transform", { evt: evt, target: node });
node.getLayer()?.batchDraw();
});
this.rotation(Konva.Util._getRotation(newAttrs.rotation));
this._resetTransformCache();
this.update();
this.getLayer()?.batchDraw();
}
}
function Transformer({
active,
nodes,
attachments,
onTransformStart,
onTransform,
onTransformEnd,
gridScale,
portalSelector,
}: TransformerProps) {
const setPreventMapInteraction = useSetPreventMapInteraction();
@ -42,13 +223,235 @@ function Transformer({
const snappingSensitivity = useGridSnappingSensitivity();
// Clamp snapping to 0 to accound for -1 snapping override
const gridSnappingSensitivity = Math.max(snappingSensitivity, 0);
const gridSnappingSensitivity = useMemo(
() => Math.max(snappingSensitivity, 0),
[snappingSensitivity]
);
const transformerRef = useRef<Konva.Transformer>(null);
const mapStageRef = useMapStage();
const transformerRef = useRef<CustomTransformer | null>(null);
useEffect(() => {
let transformer = transformerRef.current;
const stage = mapStageRef.current;
if (active && stage && !transformer) {
transformer = new CustomTransformer({
centeredScaling: true,
rotateAnchorOffset: 16,
enabledAnchors: ["middle-left", "middle-right"],
flipEnabled: false,
ignoreStroke: true,
borderStroke: "invisible",
anchorStroke: "invisible",
anchorCornerRadius: 24,
borderStrokeWidth: 0,
anchorSize: 48,
});
const portal = stage.findOne<Konva.Group>(portalSelector);
if (portal) {
portal.add(transformer);
transformerRef.current = transformer;
}
}
return () => {
if (stage && transformer) {
transformer.destroy();
transformerRef.current = null;
}
};
}, [mapStageRef, portalSelector, active]);
useEffect(() => {
transformerRef.current?.boundBoxFunc((oldBox, newBox) => {
let snapBox = { ...newBox };
const movingAnchor = movingAnchorRef.current;
if (movingAnchor === "middle-left" || movingAnchor === "middle-right") {
// Account for grid snapping
const nearestCellWidth = roundTo(
snapBox.width,
gridCellAbsoluteSizeRef.current.x
);
const distanceToSnap = Math.abs(snapBox.width - nearestCellWidth);
let snapping = false;
if (
distanceToSnap <
gridCellAbsoluteSizeRef.current.x * gridSnappingSensitivity
) {
snapBox.width = nearestCellWidth;
snapping = true;
}
const deltaWidth = snapBox.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;
// Snap x position if needed
if (snapping) {
const snapDelta = newBox.width - nearestCellWidth;
rotatedMin.x = rotatedMin.x + snapDelta / 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;
});
});
useEffect(() => {
transformerRef.current?.rotationSnaps(
snappingSensitivity === -1
? [] // Disabled rotation snapping if grid snapping disabled with shortcut
: [...Array(24).keys()].map((n) => n * 15)
);
});
const movingAnchorRef = useRef<string>();
const transformTextRef = useRef<Konva.Group>();
useEffect(() => {
function updateGridCellAbsoluteSize() {
if (active) {
const transformer = transformerRef.current;
const stage = transformer?.getStage();
const mapImage = stage?.findOne("#mapImage");
if (!mapImage) {
return;
}
// Use min side for hex grids
const minSize = Vector2.componentMin(gridCellPixelSize);
const size = new Vector2(minSize, minSize);
// Get grid cell size in screen coordinates
const mapTransform = mapImage.getAbsoluteTransform();
const absoluteSize = Vector2.subtract(
mapTransform.point(size),
mapTransform.point({ x: 0, y: 0 })
);
gridCellAbsoluteSizeRef.current = absoluteSize;
}
}
function handleTransformStart(e: Konva.KonvaEventObject<Event>) {
const transformer = transformerRef.current;
if (transformer) {
movingAnchorRef.current = transformer._movingAnchorName;
setPreventMapInteraction(true);
const transformText = new Konva.Label();
const stageScale = transformer.getStage()?.scale() || { x: 1, y: 1 };
transformText.scale(Vector2.divide({ x: 1, y: 1 }, stageScale));
const tag = new Konva.Tag();
tag.fill("hsla(230, 25%, 15%, 0.8)");
tag.cornerRadius(4);
// @ts-ignore
tag.pointerDirection("down");
tag.pointerHeight(4);
tag.pointerWidth(4);
const text = new Konva.Text();
text.fontSize(16);
text.padding(4);
text.fill("white");
transformText.add(tag);
transformText.add(text);
transformer.getLayer()?.add(transformText);
transformTextRef.current = transformText;
updateGridCellAbsoluteSize();
updateTransformText();
onTransformStart && onTransformStart(e, attachments);
}
}
function updateTransformText() {
const movingAnchor = movingAnchorRef.current;
const transformText = transformTextRef.current;
const transformer = transformerRef.current;
const node = transformer?.nodes()[0];
if (node && transformText && transformer) {
const text = transformText.getChildren()[1] as Konva.Text;
if (movingAnchor === "rotater") {
text.text(`${node.rotation().toFixed(0)}°`);
} else {
const nodeRect = node.getClientRect({ skipShadow: true });
const nodeScale = Vector2.divide(
{ x: nodeRect.width, y: nodeRect.height },
gridCellAbsoluteSizeRef.current
);
text.text(
`${(nodeScale.x * scale.multiplier).toFixed(scale.digits)}${
scale.unit
}`
);
}
const nodePosition = node.getStage()?.getPointerPosition();
if (nodePosition) {
transformText.absolutePosition({
x: nodePosition.x,
y: nodePosition.y,
});
}
}
}
function handleTransform(e: Konva.KonvaEventObject<Event>) {
updateTransformText();
onTransform?.(e, attachments);
}
function handleTransformEnd(e: Konva.KonvaEventObject<Event>) {
setPreventMapInteraction(false);
transformTextRef.current?.destroy();
transformTextRef.current = undefined;
onTransformEnd && onTransformEnd(e, attachments);
}
transformerRef.current?.on("transformstart", handleTransformStart);
transformerRef.current?.on("transform", handleTransform);
transformerRef.current?.on("transformend", handleTransformEnd);
return () => {
transformerRef.current?.off("transformstart", handleTransformStart);
transformerRef.current?.off("transform", handleTransform);
transformerRef.current?.off("transformend", handleTransformEnd);
};
});
const [anchorScale, anchorScaleStatus] = useAnchorImage(96, scaleDark);
const [anchorRotate, anchorRotateStatus] = useAnchorImage(96, rotateDark);
// Add nodes to transformer and setup
useEffect(() => {
const transformer = transformerRef.current;
if (
@ -57,14 +460,8 @@ function Transformer({
anchorScaleStatus === "loaded" &&
anchorRotateStatus === "loaded"
) {
// we need to attach transformer manually
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;
transformer.setNodes(nodes);
transformer.attachments = attachments;
const middleLeft = transformer.findOne<Konva.Rect>(".middle-left");
const middleRight = transformer.findOne<Konva.Rect>(".middle-right");
@ -93,207 +490,21 @@ function Transformer({
}, [
active,
nodes,
attachments,
anchorScale,
anchorRotate,
anchorScaleStatus,
anchorRotateStatus,
]);
function updateGridCellAbsoluteSize() {
if (active) {
const transformer = transformerRef.current;
const stage = transformer?.getStage();
const mapImage = stage?.findOne("#mapImage");
if (!mapImage) {
return;
}
// Use min side for hex grids
const minSize = Vector2.componentMin(gridCellPixelSize);
const size = new Vector2(minSize, minSize);
// Get grid cell size in screen coordinates
const mapTransform = mapImage.getAbsoluteTransform();
const absoluteSize = Vector2.subtract(
mapTransform.point(size),
mapTransform.point({ x: 0, y: 0 })
);
gridCellAbsoluteSizeRef.current = absoluteSize;
}
}
const movingAnchorRef = useRef<string>();
const transformTextRef = useRef<Konva.Group>();
function handleTransformStart(e: Konva.KonvaEventObject<Event>) {
const transformer = transformerRef.current;
if (transformer) {
movingAnchorRef.current = transformer._movingAnchorName;
setPreventMapInteraction(true);
const transformText = new Konva.Label();
const stageScale = transformer.getStage()?.scale() || { x: 1, y: 1 };
transformText.scale(Vector2.divide({ x: 1, y: 1 }, stageScale));
const tag = new Konva.Tag();
tag.fill("hsla(230, 25%, 15%, 0.8)");
tag.cornerRadius(4);
// @ts-ignore
tag.pointerDirection("down");
tag.pointerHeight(4);
tag.pointerWidth(4);
const text = new Konva.Text();
text.fontSize(16);
text.padding(4);
text.fill("white");
transformText.add(tag);
transformText.add(text);
transformer.getLayer()?.add(transformText);
transformTextRef.current = transformText;
updateGridCellAbsoluteSize();
updateTransformText();
onTransformStart && onTransformStart(e);
}
}
function updateTransformText() {
const movingAnchor = movingAnchorRef.current;
const transformText = transformTextRef.current;
const transformer = transformerRef.current;
const node = transformer?.nodes()[0];
if (node && transformText && transformer) {
const text = transformText.getChildren()[1] as Konva.Text;
if (movingAnchor === "rotater") {
text.text(`${node.rotation().toFixed(0)}°`);
} else {
const nodeRect = node.getClientRect({ skipShadow: true });
const nodeScale = Vector2.divide(
{ x: nodeRect.width, y: nodeRect.height },
gridCellAbsoluteSizeRef.current
);
text.text(
`${(nodeScale.x * scale.multiplier).toFixed(scale.digits)}${
scale.unit
}`
);
}
const nodePosition = node.getStage()?.getPointerPosition();
if (nodePosition) {
transformText.absolutePosition({
x: nodePosition.x,
y: nodePosition.y,
});
}
}
}
function handleTrasform() {
updateTransformText();
}
function handleTransformEnd(e: Konva.KonvaEventObject<Event>) {
setPreventMapInteraction(false);
transformTextRef.current?.destroy();
transformTextRef.current = undefined;
onTransformEnd && onTransformEnd(e);
}
if (!active) {
return null;
}
return (
<Portal selector="#portal" enabled>
<KonvaTransformer
ref={transformerRef}
boundBoxFunc={(oldBox, newBox) => {
let snapBox = { ...newBox };
const movingAnchor = movingAnchorRef.current;
if (
movingAnchor === "middle-left" ||
movingAnchor === "middle-right"
) {
// Account for grid snapping
const nearestCellWidth = roundTo(
snapBox.width,
gridCellAbsoluteSizeRef.current.x
);
const distanceToSnap = Math.abs(snapBox.width - nearestCellWidth);
let snapping = false;
if (
distanceToSnap <
gridCellAbsoluteSizeRef.current.x * gridSnappingSensitivity
) {
snapBox.width = nearestCellWidth;
snapping = true;
}
const deltaWidth = snapBox.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;
// Snap x position if needed
if (snapping) {
const snapDelta = newBox.width - nearestCellWidth;
rotatedMin.x = rotatedMin.x + snapDelta / 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}
onTransform={handleTrasform}
onTransformEnd={handleTransformEnd}
centeredScaling={true}
rotationSnaps={
snappingSensitivity === -1
? [] // Disabled rotation snapping if grid snapping disabled with shortcut
: [...Array(24).keys()].map((n) => n * 15)
}
rotateAnchorOffset={16}
enabledAnchors={["middle-left", "middle-right"]}
flipEnabled={false}
ignoreStroke={true}
borderStroke="invisible"
anchorStroke="invisible"
anchorCornerRadius={24}
borderStrokeWidth={0}
anchorSize={48}
/>
</Portal>
);
return null;
}
Transformer.defaultProps = {
portalSelector: "#portal",
attachments: [],
};
type AnchorImageStatus = "loading" | "loaded" | "failed";
function useAnchorImage(

View File

@ -120,10 +120,15 @@ function useMapTokens(
const [transformingTokensIds, setTransformingTokenIds] = useState<string[]>(
[]
);
function handleTokenTransformStart(event: Konva.KonvaEventObject<Event>) {
function handleTokenTransformStart(
event: Konva.KonvaEventObject<Event>,
attachments: Konva.Node[]
) {
const transformer = event.currentTarget as Konva.Transformer;
const nodes = transformer.nodes();
setTransformingTokenIds(nodes.map((node) => node.id()));
setTransformingTokenIds(
[...nodes, ...attachments].map((node) => node.id())
);
}
function handleTokenTransformEnd() {

View File

@ -38,9 +38,6 @@ export type TokenDragEventHandler = (
tokenStateId: string,
attachedTokenStateIds: string[]
) => void;
export type TokenTransformEventHandler = (
event: Konva.KonvaEventObject<Event>
) => void;
export type NoteCreateEventHander = (notes: Note[]) => void;
export type NoteRemoveEventHander = (noteIds: string[]) => void;
@ -79,3 +76,8 @@ export type SelectionItemsCreateEventHandler = (
tokenStates: TokenState[],
notes: Note[]
) => void;
export type CustomTransformEventHandler = (
event: Konva.KonvaEventObject<Event>,
attachments: Konva.Node[]
) => void;