Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions src/components/Canvas/hooks/useKonvaTransformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
snapBoxHeight,
pinBottomEdge,
isTopAnchorResize,
transformNodeTopLeft,
positionDidMove,
type BoundingBox,
} from "../transformerGeometry";

Expand Down Expand Up @@ -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);
Expand All @@ -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) {
Expand Down
38 changes: 38 additions & 0 deletions src/components/Canvas/transformerGeometry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
snapBoxHeight,
pinBottomEdge,
isTopAnchorResize,
transformNodeTopLeft,
positionDidMove,
} from "./transformerGeometry";

describe("snapBoxHeight", () => {
Expand Down Expand Up @@ -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);
});
});
41 changes: 41 additions & 0 deletions src/components/Canvas/transformerGeometry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
u8array marked this conversation as resolved.

/**
* 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;
}
1 change: 1 addition & 0 deletions src/registry/ellipse.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const ellipse: ObjectTypeDefinition<EllipseProps> = {
color: 'B',
},
defaultSize: { width: 150, height: 100 },
nodeOrigin: 'center',

commitTransform: commitWidthHeightTransform,

Expand Down
7 changes: 7 additions & 0 deletions src/types/ObjectType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ export interface ObjectTypeDefinition<P extends object = object> {
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
Expand Down