From 2a73c009f20c8c80a8cc6e01c7aab62ff5f6be07 Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 3 May 2026 23:47:02 +0200 Subject: [PATCH 1/4] fix(canvas): stop transformer from jumping ellipse and snapping position Two related bugs in onTransformEnd surfaced via issue #2: 1. Konva's Ellipse uses its center as the origin while every other shape uses top-left, so writing node.x() straight into obj.x stored the center. On the next render, the ellipse's center was placed at (storedCenter + radius), producing a visible jump by (rx, ry). 2. snap() was applied to the position even on resize. With snap enabled, off-grid shapes (including QR codes and other barcodes) were pulled onto the grid as a side-effect of resizing, even though the user only intended to change the size. Adds two pure helpers (transformNodeTopLeft, transformPositionMoved) and their unit tests so the geometry stays testable. Closes #2. --- .../Canvas/hooks/useKonvaTransformer.ts | 21 +++++++++- .../Canvas/transformerGeometry.test.ts | 38 +++++++++++++++++++ src/components/Canvas/transformerGeometry.ts | 35 +++++++++++++++++ 3 files changed, 92 insertions(+), 2 deletions(-) diff --git a/src/components/Canvas/hooks/useKonvaTransformer.ts b/src/components/Canvas/hooks/useKonvaTransformer.ts index ac492fe6..13968e27 100644 --- a/src/components/Canvas/hooks/useKonvaTransformer.ts +++ b/src/components/Canvas/hooks/useKonvaTransformer.ts @@ -9,6 +9,8 @@ import { snapBoxHeight, pinBottomEdge, isTopAnchorResize, + transformNodeTopLeft, + transformPositionMoved, 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,23 @@ export function useKonvaTransformer({ transformAnchorRef.current = null; return; } + const topLeft = transformNodeTopLeft( + node.x(), + node.y(), + nodeWidth, + nodeHeight, + sx, + sy, + obj.type === "ellipse", + ); + 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: transformPositionMoved(rawX, obj.x) ? snap(rawX) : obj.x, + y: transformPositionMoved(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..7c8e931c 100644 --- a/src/components/Canvas/transformerGeometry.test.ts +++ b/src/components/Canvas/transformerGeometry.test.ts @@ -3,6 +3,8 @@ import { snapBoxHeight, pinBottomEdge, isTopAnchorResize, + transformNodeTopLeft, + transformPositionMoved, } 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("transformPositionMoved", () => { + it("returns false when the position matches within one dot", () => { + expect(transformPositionMoved(100, 100)).toBe(false); + expect(transformPositionMoved(100.4, 100)).toBe(false); + expect(transformPositionMoved(99, 100)).toBe(false); + }); + + it("returns true once the delta exceeds one dot", () => { + expect(transformPositionMoved(102, 100)).toBe(true); + expect(transformPositionMoved(80, 100)).toBe(true); + }); +}); diff --git a/src/components/Canvas/transformerGeometry.ts b/src/components/Canvas/transformerGeometry.ts index d5ac1203..32556a7f 100644 --- a/src/components/Canvas/transformerGeometry.ts +++ b/src/components/Canvas/transformerGeometry.ts @@ -32,3 +32,38 @@ 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 + * dot coordinate. Konva's Ellipse positions by its center; everything else + * by top-left. The visual radius after a Transformer drag is `nodeSize * s` + * because the node's intrinsic size is unchanged at this point — 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 }; +} + +/** + * 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 — applying snap to it would only pull off-grid shapes onto the + * grid as a side-effect of resizing. Tolerance is one dot to absorb float + * rounding from the screen-pixel <-> dot conversion. + */ +export function transformPositionMoved( + rawDots: number, + previousDots: number, +): boolean { + return Math.abs(rawDots - previousDots) > 1; +} From be84564445cd3d2d739175d21c24409b2d6ba548 Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 3 May 2026 23:58:08 +0200 Subject: [PATCH 2/4] style(canvas): drop em-dashes from transformer geometry docs --- src/components/Canvas/transformerGeometry.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/Canvas/transformerGeometry.ts b/src/components/Canvas/transformerGeometry.ts index 32556a7f..da79d3b1 100644 --- a/src/components/Canvas/transformerGeometry.ts +++ b/src/components/Canvas/transformerGeometry.ts @@ -36,9 +36,9 @@ export function isTopAnchorResize( /** * Translate a transformed Konva node's coordinate into the model's top-left * dot coordinate. Konva's Ellipse positions by its center; everything else - * by top-left. The visual radius after a Transformer drag is `nodeSize * s` - * because the node's intrinsic size is unchanged at this point — only the - * scale captured before reset reflects the post-drag dimensions. + * by top-left. The visual radius after a Transformer drag is `nodeSize * s`: + * 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, @@ -57,9 +57,9 @@ export function transformNodeTopLeft( /** * 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 — applying snap to it would only pull off-grid shapes onto the - * grid as a side-effect of resizing. Tolerance is one dot to absorb float - * rounding from the screen-pixel <-> dot conversion. + * unchanged. Without this guard, applying snap to it would pull off-grid + * shapes onto the grid as a side-effect of resizing. Tolerance is one dot + * to absorb float rounding from the screen-pixel <-> dot conversion. */ export function transformPositionMoved( rawDots: number, From 29d5763569f330d2f62b4eb4ef7efa984ccb4a4d Mon Sep 17 00:00:00 2001 From: u8array Date: Mon, 4 May 2026 00:01:33 +0200 Subject: [PATCH 3/4] refactor(canvas): lift node origin to registry, name the move tolerance Remove the hardcoded obj.type === "ellipse" check from useKonvaTransformer and replace it with an ObjectTypeDefinition.nodeOrigin flag. New center-anchored shape types now declare 'center' once in the registry instead of editing the transformer hook. Other small clean-ups: - POSITION_MOVE_TOLERANCE_DOTS replaces the magic number 1 - transformPositionMoved -> positionDidMove (idiomatic predicate name) --- .../Canvas/hooks/useKonvaTransformer.ts | 9 +++++---- .../Canvas/transformerGeometry.test.ts | 18 +++++++++--------- src/components/Canvas/transformerGeometry.ts | 14 ++++++++++---- src/registry/ellipse.tsx | 1 + src/types/ObjectType.ts | 7 +++++++ 5 files changed, 32 insertions(+), 17 deletions(-) diff --git a/src/components/Canvas/hooks/useKonvaTransformer.ts b/src/components/Canvas/hooks/useKonvaTransformer.ts index 13968e27..1c8f1de6 100644 --- a/src/components/Canvas/hooks/useKonvaTransformer.ts +++ b/src/components/Canvas/hooks/useKonvaTransformer.ts @@ -10,7 +10,7 @@ import { pinBottomEdge, isTopAnchorResize, transformNodeTopLeft, - transformPositionMoved, + positionDidMove, type BoundingBox, } from "../transformerGeometry"; @@ -141,6 +141,7 @@ export function useKonvaTransformer({ transformAnchorRef.current = null; return; } + const isCenterAnchored = ObjectRegistry[obj.type]?.nodeOrigin === "center"; const topLeft = transformNodeTopLeft( node.x(), node.y(), @@ -148,7 +149,7 @@ export function useKonvaTransformer({ nodeHeight, sx, sy, - obj.type === "ellipse", + isCenterAnchored, ); const rawX = pxToDots(topLeft.x - objectsOffsetX, scale, dpmm); const rawY = pxToDots(topLeft.y - labelOffsetY, scale, dpmm); @@ -156,8 +157,8 @@ export function useKonvaTransformer({ // (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: transformPositionMoved(rawX, obj.x) ? snap(rawX) : obj.x, - y: transformPositionMoved(rawY, obj.y) ? snap(rawY) : obj.y, + 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 7c8e931c..044d8c5a 100644 --- a/src/components/Canvas/transformerGeometry.test.ts +++ b/src/components/Canvas/transformerGeometry.test.ts @@ -4,7 +4,7 @@ import { pinBottomEdge, isTopAnchorResize, transformNodeTopLeft, - transformPositionMoved, + positionDidMove, } from "./transformerGeometry"; describe("snapBoxHeight", () => { @@ -81,15 +81,15 @@ describe("transformNodeTopLeft", () => { }); }); -describe("transformPositionMoved", () => { - it("returns false when the position matches within one dot", () => { - expect(transformPositionMoved(100, 100)).toBe(false); - expect(transformPositionMoved(100.4, 100)).toBe(false); - expect(transformPositionMoved(99, 100)).toBe(false); +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 one dot", () => { - expect(transformPositionMoved(102, 100)).toBe(true); - expect(transformPositionMoved(80, 100)).toBe(true); + 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 da79d3b1..9eed8404 100644 --- a/src/components/Canvas/transformerGeometry.ts +++ b/src/components/Canvas/transformerGeometry.ts @@ -54,16 +54,22 @@ export function transformNodeTopLeft( 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. Tolerance is one dot - * to absorb float rounding from the screen-pixel <-> dot conversion. + * shapes onto the grid as a side-effect of resizing. */ -export function transformPositionMoved( +export function positionDidMove( rawDots: number, previousDots: number, ): boolean { - return Math.abs(rawDots - previousDots) > 1; + 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 From 0391b53837268491f84646f156ecae0d0bbbacec Mon Sep 17 00:00:00 2001 From: u8array Date: Mon, 4 May 2026 00:05:52 +0200 Subject: [PATCH 4/4] docs(canvas): fix radius/diameter mix-up in transformNodeTopLeft jsdoc Gemini PR review correctly flagged that 'nodeSize * s' is the visual diameter (full width/height), not the radius. The implementation is unchanged; only the comment is corrected. Also clarifies that the function returns stage pixels, not dots. --- src/components/Canvas/transformerGeometry.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/Canvas/transformerGeometry.ts b/src/components/Canvas/transformerGeometry.ts index 9eed8404..1d554c03 100644 --- a/src/components/Canvas/transformerGeometry.ts +++ b/src/components/Canvas/transformerGeometry.ts @@ -35,10 +35,10 @@ export function isTopAnchorResize( /** * Translate a transformed Konva node's coordinate into the model's top-left - * dot coordinate. Konva's Ellipse positions by its center; everything else - * by top-left. The visual radius after a Transformer drag is `nodeSize * s`: - * the node's intrinsic size is unchanged at this point, so only the scale - * captured before reset reflects the post-drag dimensions. + * 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,