feat: add rotateAnchorAngle to Transformer for customizable rotation anchor positioning

This commit is contained in:
Anton Lavrevov
2026-01-14 20:42:21 -05:00
parent ccaa2f6b73
commit 50c0043084
3 changed files with 277 additions and 63 deletions

View File

@@ -23,6 +23,7 @@ export interface TransformerConfig extends ContainerConfig {
rotationSnaps?: Array<number>;
rotationSnapTolerance?: number;
rotateAnchorOffset?: number;
rotateAnchorAngle?: number;
rotateAnchorCursor?: string;
borderEnabled?: boolean;
borderStroke?: string;
@@ -55,6 +56,7 @@ const EVENTS_NAME = 'tr-konva';
const ATTR_CHANGE_LIST = [
'resizeEnabledChange',
'rotateAnchorOffsetChange',
'rotateAnchorAngleChange',
'rotateEnabledChange',
'enabledAnchorsChange',
'anchorSizeChange',
@@ -620,19 +622,54 @@ export class Transformer extends Group {
sceneFunc(ctx, shape) {
const tr = shape.getParent() as Transformer;
const padding = tr.padding();
const width = shape.width();
const height = shape.height();
ctx.beginPath();
ctx.rect(
-padding,
-padding,
shape.width() + padding * 2,
shape.height() + padding * 2
width + padding * 2,
height + padding * 2
);
ctx.moveTo(shape.width() / 2, -padding);
if (tr.rotateEnabled() && tr.rotateLineVisible()) {
ctx.lineTo(
shape.width() / 2,
-tr.rotateAnchorOffset() * Util._sign(shape.height()) - padding
);
// Calculate rotation line position based on rotateAnchorAngle
const rotateAnchorAngle = tr.rotateAnchorAngle();
const rotateAnchorOffset = tr.rotateAnchorOffset();
const rad = Util.degToRad(rotateAnchorAngle);
// Direction vector (0 degrees = up/top)
const dirX = Math.sin(rad);
const dirY = -Math.cos(rad);
// Center of the box
const cx = width / 2;
const cy = height / 2;
// Find intersection with box edge
let t = Infinity;
if (dirY < 0) {
t = Math.min(t, -cy / dirY);
} else if (dirY > 0) {
t = Math.min(t, (height - cy) / dirY);
}
if (dirX < 0) {
t = Math.min(t, -cx / dirX);
} else if (dirX > 0) {
t = Math.min(t, (width - cx) / dirX);
}
// Edge point (start of line)
const edgeX = cx + dirX * t;
const edgeY = cy + dirY * t;
// End point with offset
const sign = Util._sign(height);
const endX = edgeX + dirX * rotateAnchorOffset * sign;
const endY = edgeY + dirY * rotateAnchorOffset * sign;
ctx.moveTo(edgeX, edgeY);
ctx.lineTo(endX, endY);
}
ctx.fillStrokeShape(shape);
@@ -739,8 +776,10 @@ export class Transformer extends Group {
x = anchorNode.x() - attrs.width / 2;
y = -anchorNode.y() + attrs.height / 2;
// hor angle is changed?
let delta = Math.atan2(-y, x) + Math.PI / 2;
// Calculate angle from center to current anchor position
// Offset by rotateAnchorAngle so we measure rotation from the anchor's starting position
const rotateAnchorAngleRad = Konva.getAngle(this.rotateAnchorAngle());
let delta = Math.atan2(-y, x) + Math.PI / 2 - rotateAnchorAngleRad;
if (attrs.height < 0) {
delta -= Math.PI;
@@ -1244,9 +1283,47 @@ export class Transformer extends Group {
visible: resizeEnabled && enabledAnchors.indexOf('bottom-right') >= 0,
});
// Calculate rotation anchor position based on rotateAnchorAngle
const rotateAnchorAngle = this.rotateAnchorAngle();
const rotateAnchorOffset = this.rotateAnchorOffset();
const rad = Util.degToRad(rotateAnchorAngle);
// Direction vector (0 degrees = up/top)
const dirX = Math.sin(rad);
const dirY = -Math.cos(rad);
// Center of the box
const cx = width / 2;
const cy = height / 2;
// Find intersection with box edge
// Calculate time to hit each edge from center
let t = Infinity;
// Handle each direction
if (dirY < 0) {
// Moving up, check top edge (y = 0)
t = Math.min(t, -cy / dirY);
} else if (dirY > 0) {
// Moving down, check bottom edge (y = height)
t = Math.min(t, (height - cy) / dirY);
}
if (dirX < 0) {
// Moving left, check left edge (x = 0)
t = Math.min(t, -cx / dirX);
} else if (dirX > 0) {
// Moving right, check right edge (x = width)
t = Math.min(t, (width - cx) / dirX);
}
// Edge point
const edgeX = cx + dirX * t;
const edgeY = cy + dirY * t;
// Final position with offset (accounting for height sign and padding)
const sign = Util._sign(height);
this._batchChangeChild('.rotater', {
x: width / 2,
y: -this.rotateAnchorOffset() * Util._sign(height) - padding,
x: edgeX + dirX * rotateAnchorOffset * sign,
y: edgeY + dirY * rotateAnchorOffset * sign - padding * dirY,
visible: this.rotateEnabled(),
});
@@ -1332,6 +1409,7 @@ export class Transformer extends Group {
rotateEnabled: GetSet<boolean, this>;
rotateLineVisible: GetSet<boolean, this>;
rotateAnchorOffset: GetSet<number, this>;
rotateAnchorAngle: GetSet<number, this>;
rotationSnapTolerance: GetSet<number, this>;
rotateAnchorCursor: GetSet<string, this>;
padding: GetSet<number, this>;
@@ -1514,6 +1592,30 @@ Factory.addGetterSetter(
getNumberValidator()
);
/**
* get/set the angle (in degrees) of the rotation anchor position around the bounding box.
* 0 = top-center (default), 90 = middle-right, 180 = bottom-center, -90 = middle-left
* @name Konva.Transformer#rotateAnchorAngle
* @method
* @param {Number} angle
* @returns {Number}
* @example
* // get
* var rotateAnchorAngle = transformer.rotateAnchorAngle();
*
* // set rotation anchor to the right side
* transformer.rotateAnchorAngle(90);
*
* // set rotation anchor to the bottom
* transformer.rotateAnchorAngle(180);
*/
Factory.addGetterSetter(
Transformer,
'rotateAnchorAngle',
0,
getNumberValidator()
);
/**
* get/set rotation anchor cursor
* @name Konva.Transformer#rotateAnchorCursor

View File

@@ -32,60 +32,25 @@
const layer = new Konva.Layer();
stage.add(layer);
// const circle = new Konva.Circle({
// x: 100,
// y: 100,
// radius: 10,
// fill: 'red',
// });
// layer.add(circle);
// throw new Error('test');
const text = new Konva.Text({
x: 100,
y: 100,
text: 'Hello, world!',
fontSize: 20,
fontFamily: 'Arial',
fill: 'black',
});
layer.add(text);
// Given gridRows and gridCols, compute maximum possible radius and position circles
const gridRows = 1000;
const gridCols = 1000;
const width = window.innerWidth;
const height = window.innerHeight;
const transformer = new Konva.Transformer({
nodes: [text],
});
layer.add(transformer);
// Calculate spacing and radius so circles fit entire window
// There are (gridCols - 1) spaces between centers horizontally, and (gridRows - 1) vertically
// The available width is "width", so (gridCols) circles and (gridCols-1) spaces between
// The diameter must fit: gridCols * diameter + (gridCols - 1) * gap <= width
// But to maximize, gap should equal to at least 0, so best layout is circles tangent
// So the max diameter horizontally is width / gridCols
// Likewise for vertical
const cellWidth = width / gridCols;
const cellHeight = height / gridRows;
const radius = Math.min(cellWidth, cellHeight) / 2;
const circles = [];
// Create grid of circles - centers in each cell's center
console.time('create grid of circles');
for (let row = 0; row < gridRows; row++) {
for (let col = 0; col < gridCols; col++) {
const cx = cellWidth * col + cellWidth / 2;
const cy = cellHeight * row + cellHeight / 2;
const c = new Konva.Circle({
x: cx,
y: cy,
radius: radius,
fill: Konva.Util.getRandomColor(),
id: `circle-${row}-${col}`,
});
circles.push({
node: c,
x: cx,
y: cy,
row,
col,
});
layer.add(c);
}
}
console.timeEnd('create grid of circles');
const anim = new Konva.Animation((frame) => {
transformer.rotateAnchorAngle(frame.time * 0.01);
}, layer);
anim.start();
</script>
</body>
</html>

View File

@@ -5435,4 +5435,151 @@ describe('Transformer', function () {
'Back should be draggable when at least one node is draggable'
);
});
// ======================================================
// rotateAnchorAngle tests
// ======================================================
it('rotateAnchorAngle positions rotater correctly at different angles', function () {
var stage = addStage();
var layer = new Konva.Layer();
stage.add(layer);
var rect = new Konva.Rect({
x: 50,
y: 50,
draggable: true,
width: 100,
height: 100,
fill: 'yellow',
});
layer.add(rect);
var tr = new Konva.Transformer({
nodes: [rect],
});
layer.add(tr);
layer.draw();
var rotater = tr.findOne('.rotater')!;
var offset = tr.rotateAnchorOffset();
// 0 degrees (default): top-center
assertAlmostEqual(rotater.x(), 50);
assertAlmostEqual(rotater.y(), -offset);
// 90 degrees: middle-right
tr.rotateAnchorAngle(90);
assertAlmostEqual(rotater.x(), 100 + offset);
assertAlmostEqual(rotater.y(), 50);
// 180 degrees: bottom-center
tr.rotateAnchorAngle(180);
assertAlmostEqual(rotater.x(), 50);
assertAlmostEqual(rotater.y(), 100 + offset);
// -90 degrees: middle-left
tr.rotateAnchorAngle(-90);
assertAlmostEqual(rotater.x(), -offset);
assertAlmostEqual(rotater.y(), 50);
});
it('drag rotation works with rotateAnchorAngle at 90 (right side)', function () {
var stage = addStage();
var layer = new Konva.Layer();
stage.add(layer);
// Position rect so rotater at right side stays within stage bounds (578x200)
var rect = new Konva.Rect({
x: 50,
y: 50,
draggable: true,
width: 100,
height: 100,
fill: 'yellow',
});
layer.add(rect);
var tr = new Konva.Transformer({
nodes: [rect],
rotateAnchorAngle: 90,
});
layer.add(tr);
layer.draw();
var rotater = tr.findOne('.rotater')!;
var pos = rotater.getAbsolutePosition();
simulateMouseDown(tr, { x: pos.x, y: pos.y });
simulateMouseMove(tr, { x: pos.x - 100, y: pos.y + 100 });
simulateMouseUp(tr, { x: pos.x - 100, y: pos.y + 100 });
assert.equal(rect.rotation(), 90);
});
it('drag rotation works with rotateAnchorAngle at -90 (left side)', function () {
var stage = addStage();
var layer = new Konva.Layer();
stage.add(layer);
// Position rect so rotater at left side stays within stage bounds
var rect = new Konva.Rect({
x: 150,
y: 50,
draggable: true,
width: 100,
height: 100,
fill: 'yellow',
});
layer.add(rect);
var tr = new Konva.Transformer({
nodes: [rect],
rotateAnchorAngle: -90,
});
layer.add(tr);
layer.draw();
var rotater = tr.findOne('.rotater')!;
var pos = rotater.getAbsolutePosition();
simulateMouseDown(tr, { x: pos.x, y: pos.y });
simulateMouseMove(tr, { x: pos.x + 100, y: pos.y - 100 });
simulateMouseUp(tr, { x: pos.x + 100, y: pos.y - 100 });
assert.equal(rect.rotation(), 90);
});
it('drag rotation works with rotateAnchorAngle at 180 (bottom)', function () {
var stage = addStage();
var layer = new Konva.Layer();
stage.add(layer);
// Position rect so rotater at bottom stays within stage bounds (height=200)
var rect = new Konva.Rect({
x: 50,
y: 10,
draggable: true,
width: 100,
height: 100,
fill: 'yellow',
});
layer.add(rect);
var tr = new Konva.Transformer({
nodes: [rect],
rotateAnchorAngle: 180,
});
layer.add(tr);
layer.draw();
var rotater = tr.findOne('.rotater')!;
var pos = rotater.getAbsolutePosition();
simulateMouseDown(tr, { x: pos.x, y: pos.y });
simulateMouseMove(tr, { x: pos.x - 100, y: pos.y - 100 });
simulateMouseUp(tr, { x: pos.x - 100, y: pos.y - 100 });
assertAlmostEqual(rect.rotation(), 90);
});
});