From 4eca2dcfc7afda3306f47902bd7fabd9b8d7ab0f Mon Sep 17 00:00:00 2001 From: Mitchell McCaffrey Date: Sat, 14 Aug 2021 13:15:51 +1000 Subject: [PATCH] Fix transform attachments affecting transform anchor positions --- src/components/konva/Note.tsx | 2 +- src/components/konva/Token.tsx | 53 ++- src/components/konva/Transformer.tsx | 631 ++++++++++++++++++--------- src/hooks/useMapTokens.tsx | 9 +- src/types/Events.ts | 8 +- 5 files changed, 464 insertions(+), 239 deletions(-) diff --git a/src/components/konva/Note.tsx b/src/components/konva/Note.tsx index 1f451f2..e961854 100644 --- a/src/components/konva/Note.tsx +++ b/src/components/konva/Note.tsx @@ -287,7 +287,7 @@ function Note({ (noteRef.current ? [noteRef.current] : [])} + nodes={noteRef.current ? [noteRef.current] : []} onTransformEnd={handleTransformEnd} onTransformStart={handleTransformStart} gridScale={map?.grid.measurement.scale || ""} diff --git a/src/components/konva/Token.tsx b/src/components/konva/Token.tsx index 5062e4a..5695a03 100644 --- a/src/components/konva/Token.tsx +++ b/src/components/konva/Token.tsx @@ -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) { + function handleTransformStart( + event: Konva.KonvaEventObject, + attachments: Konva.Node[] + ) { setOverrideTransformActive(true); - onTokenTransformStart(event); + onTokenTransformStart(event, attachments); onTokenMenuClose(); } - function handleTransformEnd(event: Konva.KonvaEventObject) { + function handleTransformEnd( + event: Konva.KonvaEventObject, + attachments: Konva.Node[] + ) { const transformer = event.currentTarget as Konva.Transformer; - const nodes = transformer.nodes(); + const nodes = [...transformer.nodes(), ...attachments]; const tokenChanges: Record> = {}; 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({ Konva.Node[]; - onTransformStart?: (event: Konva.KonvaEventObject) => void; - onTransformEnd?: (event: Konva.KonvaEventObject) => 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(null); + const mapStageRef = useMapStage(); + const transformerRef = useRef(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(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(); + const transformTextRef = useRef(); + + 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) { + 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) { + updateTransformText(); + onTransform?.(e, attachments); + } + + function handleTransformEnd(e: Konva.KonvaEventObject) { + 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(".middle-left"); const middleRight = transformer.findOne(".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(); - const transformTextRef = useRef(); - function handleTransformStart(e: Konva.KonvaEventObject) { - 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) { - setPreventMapInteraction(false); - transformTextRef.current?.destroy(); - transformTextRef.current = undefined; - onTransformEnd && onTransformEnd(e); - } - - if (!active) { - return null; - } - - return ( - - { - 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} - /> - - ); + return null; } +Transformer.defaultProps = { + portalSelector: "#portal", + attachments: [], +}; + type AnchorImageStatus = "loading" | "loaded" | "failed"; function useAnchorImage( diff --git a/src/hooks/useMapTokens.tsx b/src/hooks/useMapTokens.tsx index 232603f..5b3cb31 100644 --- a/src/hooks/useMapTokens.tsx +++ b/src/hooks/useMapTokens.tsx @@ -120,10 +120,15 @@ function useMapTokens( const [transformingTokensIds, setTransformingTokenIds] = useState( [] ); - function handleTokenTransformStart(event: Konva.KonvaEventObject) { + function handleTokenTransformStart( + event: Konva.KonvaEventObject, + 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() { diff --git a/src/types/Events.ts b/src/types/Events.ts index 6f683bb..ad4a825 100644 --- a/src/types/Events.ts +++ b/src/types/Events.ts @@ -38,9 +38,6 @@ export type TokenDragEventHandler = ( tokenStateId: string, attachedTokenStateIds: string[] ) => void; -export type TokenTransformEventHandler = ( - event: Konva.KonvaEventObject -) => 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, + attachments: Konva.Node[] +) => void;