diff --git a/src/components/Canvas/hooks/useKonvaTransformer.ts b/src/components/Canvas/hooks/useKonvaTransformer.ts index ac492fe6..1c8f1de6 100644 --- a/src/components/Canvas/hooks/useKonvaTransformer.ts +++ b/src/components/Canvas/hooks/useKonvaTransformer.ts @@ -9,6 +9,8 @@ import { snapBoxHeight, pinBottomEdge, isTopAnchorResize, + transformNodeTopLeft, + positionDidMove, type BoundingBox, } from "../transformerGeometry"; @@ -130,6 +132,7 @@ export function useKonvaTransformer({ if (!node) return; const sx = node.scaleX(); const sy = node.scaleY(); + const nodeWidth = node.width(); const nodeHeight = node.height(); node.scaleX(1); node.scaleY(1); @@ -138,9 +141,24 @@ export function useKonvaTransformer({ transformAnchorRef.current = null; return; } + const isCenterAnchored = ObjectRegistry[obj.type]?.nodeOrigin === "center"; + const topLeft = transformNodeTopLeft( + node.x(), + node.y(), + nodeWidth, + nodeHeight, + sx, + sy, + isCenterAnchored, + ); + const rawX = pxToDots(topLeft.x - objectsOffsetX, scale, dpmm); + const rawY = pxToDots(topLeft.y - labelOffsetY, scale, dpmm); + // Only apply snap to the position when the resize actually moved it + // (e.g. dragging the top-left handle). Anchored-corner drags must keep + // the original position so off-grid shapes don't snap as a side-effect. const pos = { - x: snap(pxToDots(node.x() - objectsOffsetX, scale, dpmm)), - y: snap(pxToDots(node.y() - labelOffsetY, scale, dpmm)), + x: positionDidMove(rawX, obj.x) ? snap(rawX) : obj.x, + y: positionDidMove(rawY, obj.y) ? snap(rawY) : obj.y, }; const commit = ObjectRegistry[obj.type]?.commitTransform; if (commit) { diff --git a/src/components/Canvas/transformerGeometry.test.ts b/src/components/Canvas/transformerGeometry.test.ts index 1aee1121..044d8c5a 100644 --- a/src/components/Canvas/transformerGeometry.test.ts +++ b/src/components/Canvas/transformerGeometry.test.ts @@ -3,6 +3,8 @@ import { snapBoxHeight, pinBottomEdge, isTopAnchorResize, + transformNodeTopLeft, + positionDidMove, } from "./transformerGeometry"; describe("snapBoxHeight", () => { @@ -55,3 +57,39 @@ describe("isTopAnchorResize", () => { expect(isTopAnchorResize(oldBox, { ...oldBox, height: 80 }, 1)).toBe(false); }); }); + +describe("transformNodeTopLeft", () => { + it("passes top-left-anchored nodes through unchanged", () => { + // Rect / Image / Text use their top-left as the Konva origin. + const result = transformNodeTopLeft(100, 50, 200, 100, 1.5, 1, false); + expect(result).toEqual({ x: 100, y: 50 }); + }); + + it("subtracts half the visual size for center-anchored nodes (Ellipse)", () => { + // Ellipse with intrinsic size 100x80, scaled 2x in both axes. + // node.x()/y() are the center; visual radius = nodeSize * scale / 2. + const result = transformNodeTopLeft(200, 100, 100, 80, 2, 2, true); + expect(result).toEqual({ x: 100, y: 20 }); + }); + + it("uses the captured (pre-reset) node size, not the post-reset one", () => { + // Even when scale is no longer 1 conceptually, the formula uses the + // intrinsic nodeWidth/nodeHeight times the scale to derive visual size. + const result = transformNodeTopLeft(150, 150, 50, 50, 4, 4, true); + expect(result.x).toBe(50); // 150 - (50 * 4) / 2 = 150 - 100 + expect(result.y).toBe(50); + }); +}); + +describe("positionDidMove", () => { + it("returns false when the position matches within the tolerance", () => { + expect(positionDidMove(100, 100)).toBe(false); + expect(positionDidMove(100.4, 100)).toBe(false); + expect(positionDidMove(99, 100)).toBe(false); + }); + + it("returns true once the delta exceeds the tolerance", () => { + expect(positionDidMove(102, 100)).toBe(true); + expect(positionDidMove(80, 100)).toBe(true); + }); +}); diff --git a/src/components/Canvas/transformerGeometry.ts b/src/components/Canvas/transformerGeometry.ts index d5ac1203..1d554c03 100644 --- a/src/components/Canvas/transformerGeometry.ts +++ b/src/components/Canvas/transformerGeometry.ts @@ -32,3 +32,44 @@ export function isTopAnchorResize( ): boolean { return Math.abs(newBox.y - oldBox.y) > thresholdPx; } + +/** + * Translate a transformed Konva node's coordinate into the model's top-left + * stage-pixel coordinate. Konva's Ellipse positions by its center; everything + * else by top-left. The visual size after a Transformer drag is `nodeSize * s` + * (full width / height): the node's intrinsic size is unchanged at this point, + * so only the scale captured before reset reflects the post-drag dimensions. + */ +export function transformNodeTopLeft( + nodeX: number, + nodeY: number, + nodeWidth: number, + nodeHeight: number, + sx: number, + sy: number, + isCenterAnchored: boolean, +): { x: number; y: number } { + const dx = isCenterAnchored ? (nodeWidth * sx) / 2 : 0; + const dy = isCenterAnchored ? (nodeHeight * sy) / 2 : 0; + return { x: nodeX - dx, y: nodeY - dy }; +} + +/** + * Tolerance for `positionDidMove`. Sized to absorb float rounding from the + * screen-pixel <-> dot conversion; anything within this margin counts as + * "did not move" so the original integer position is preserved. + */ +export const POSITION_MOVE_TOLERANCE_DOTS = 1; + +/** + * Decide whether the resize actually moved the object. When the user drags + * a handle whose opposite anchor is the top-left, the position is visually + * unchanged. Without this guard, applying snap to it would pull off-grid + * shapes onto the grid as a side-effect of resizing. + */ +export function positionDidMove( + rawDots: number, + previousDots: number, +): boolean { + return Math.abs(rawDots - previousDots) > POSITION_MOVE_TOLERANCE_DOTS; +} diff --git a/src/registry/ellipse.tsx b/src/registry/ellipse.tsx index 7da85f95..3cd4d427 100644 --- a/src/registry/ellipse.tsx +++ b/src/registry/ellipse.tsx @@ -24,6 +24,7 @@ export const ellipse: ObjectTypeDefinition = { color: 'B', }, defaultSize: { width: 150, height: 100 }, + nodeOrigin: 'center', commitTransform: commitWidthHeightTransform, diff --git a/src/types/ObjectType.ts b/src/types/ObjectType.ts index 62b60aa0..9149b665 100644 --- a/src/types/ObjectType.ts +++ b/src/types/ObjectType.ts @@ -50,6 +50,13 @@ export interface ObjectTypeDefinition

{ group: ObjectGroup; defaultProps: P; defaultSize: { width: number; height: number }; + /** + * Origin of the Konva node used to render this type. Defaults to 'top-left'. + * Set to 'center' for shapes whose Konva counterpart positions by their + * center (e.g. Ellipse), so the transformer can convert the node coordinate + * back to the model's top-left convention. + */ + nodeOrigin?: 'center' | 'top-left'; toZPL: (obj: LabelObjectBase & { props: P }) => string; /** * Optional hook to enforce type-specific invariants on incoming changes