pixi-essentials-transformer-extended
Version:
Modern Canva-style transformer for PixiJS - Interactive display-object editor with beautiful handles (Extended version of @pixi-essentials/transformer)
2,078 lines (1,500 loc) • 88 kB
JavaScript
/* eslint-disable */
/*!
* pixi-essentials-transformer-extended - v1.0.9-alpha.6
* Compiled Sat, 25 Oct 2025 08:40:14 UTC
*
* pixi-essentials-transformer-extended is licensed under the MIT License.
* http://www.opensource.org/licenses/mit-license
*
* Copyright 2019-2020, Irfan Khan (khanzzirfan) - Modern Canva-style upgrade, All Rights Reserved
*/
import { Container } from '@pixi/display';
import { Point, Matrix, Transform, Rectangle } from '@pixi/math';
import { OrientedBounds } from '@pixi-essentials/bounds';
import { ObjectPoolFactory } from '@pixi-essentials/object-pool';
import { Graphics } from '@pixi/graphics';
import { Texture } from '@pixi/core';
import { Sprite } from '@pixi/sprite';
/** @see TransformerHandle#style */
/**
* The default transformer handle style.
*
* @ignore
*/
const DEFAULT_HANDLE_STYLE = {
color: 0xffffff,
outlineColor: 0x6366f1,
outlineThickness: 2.5,
radius: 7,
shape: "circle",
glowColor: 0x6366f1,
glowIntensity: 0.15,
};
const Graphics_ = Graphics
;
/**
* Create rotator SVG as a data URL - scalable version that adapts to handle size
*/
function createRotatorSVG(
backgroundColor = "#00BCD4",
arrowColor = "#FFFFFF",
size = 28
) {
// Scale the SVG based on handle size, but keep it proportional
const scale = Math.max(1, size / 28); // Minimum scale of 1
const svgSize = Math.round(24 * scale); // Base size is 24, scale it
return `data:image/svg+xml;base64,${btoa(
`
<svg xmlns="http://www.w3.org/2000/svg" width="${svgSize}" height="${svgSize}" viewBox="0 0 24 24" fill="none">
<!-- circular cyan background -->
<circle cx="12" cy="12" r="10" fill="${backgroundColor}"/>
<!-- white arrow paths -->
<path fill-rule="evenodd" clip-rule="evenodd"
d="M5.46056 11.0833C5.83331 7.79988 8.62404 5.25 12.0096 5.25C14.148 5.25 16.0489 6.26793 17.2521 7.84246C17.5036 8.17158 17.4406 8.64227 17.1115 8.89376C16.7824 9.14526 16.3117 9.08233 16.0602 8.7532C15.1289 7.53445 13.6613 6.75 12.0096 6.75C9.45213 6.75 7.33639 8.63219 6.9733 11.0833H7.33652C7.63996 11.0833 7.9135 11.2662 8.02953 11.5466C8.14556 11.8269 8.0812 12.1496 7.86649 12.364L6.69823 13.5307C6.40542 13.8231 5.9311 13.8231 5.63829 13.5307L4.47003 12.364C4.25532 12.1496 4.19097 11.8269 4.30699 11.5466C4.42302 11.2662 4.69656 11.0833 5 11.0833H5.46056Z"
fill="${arrowColor}"/>
<path fill-rule="evenodd" clip-rule="evenodd"
d="M18.3617 10.4693C18.0689 10.1769 17.5946 10.1769 17.3018 10.4693L16.1335 11.636C15.9188 11.8504 15.8545 12.1731 15.9705 12.4534C16.0865 12.7338 16.3601 12.9167 16.6635 12.9167H17.0267C16.6636 15.3678 14.5479 17.25 11.9905 17.25C10.3464 17.25 8.88484 16.4729 7.9529 15.2638C7.70002 14.9358 7.22908 14.8748 6.90101 15.1277C6.57295 15.3806 6.512 15.8515 6.76487 16.1796C7.96886 17.7416 9.86205 18.75 11.9905 18.75C15.376 18.75 18.1667 16.2001 18.5395 12.9167H19C19.3035 12.9167 19.577 12.7338 19.693 12.4534C19.8091 12.1731 19.7447 11.8504 19.53 11.636L18.3617 10.4693Z"
fill="${arrowColor}"/>
</svg>
`.trim()
)}`;
}
/**
* The transfomer handle base implementation.
*
* @extends PIXI.Graphics
*/
class TransformerHandle extends Graphics_ {
__init() {this._rotatorSprite = null;}
/**
* @param {Transformer} transformer
* @param {string} handle - the type of handle being drawn
* @param {object} styleOpts - styling options passed by the user
* @param {function} handler - handler for drag events, it receives the pointer position; used by {@code onDrag}.
* @param {function} commit - handler for drag-end events.
* @param {string}[cursor='move'] - a custom cursor to be applied on this handle
*/
constructor(
transformer,
handle,
styleOpts = {},
handler,
commit,
cursor
) {
super();this.transformer = transformer;TransformerHandle.prototype.__init.call(this);
const style = Object.assign(
{},
DEFAULT_HANDLE_STYLE,
styleOpts
);
this._handle = handle;
this._style = style;
this.onHandleDelta = handler;
this.onHandleCommit = commit;
/**
* This flags whether this handle should be redrawn in the next frame due to style changes.
*/
this._dirty = true;
// Pointer events
this.interactive = true;
this.cursor = cursor || "move";
this._pointerDown = false;
this._pointerDragging = false;
this._pointerPosition = new Point();
this._pointerMoveTarget = null;
this.onPointerDown = this.onPointerDown.bind(this);
this.onPointerMove = this.onPointerMove.bind(this);
this.onPointerUp = this.onPointerUp.bind(this);
this.onpointerdown = this.onPointerDown;
this.onpointermove = this.onPointerMove;
this.onpointerup = this.onPointerUp;
this.onpointerupoutside = this.onPointerUp;
}
get handle() {
return this._handle;
}
set handle(handle) {
this._handle = handle;
this._dirty = true;
}
/**
* The currently applied handle style.
*/
get style() {
return this._style;
}
set style(value) {
this._style = Object.assign({}, DEFAULT_HANDLE_STYLE, value);
this._dirty = true;
}
render(renderer) {
if (this._dirty) {
this.draw();
this._dirty = false;
}
super.render(renderer);
}
/**
* Redraws the handle's geometry. This is called on a `render` if {@code this._dirty} is true.
*/
draw() {
const handle = this._handle;
const style = this._style;
this.clear();
// Check handle type for different shapes
const isMiddleHandle = handle === "middleLeft" || handle === "middleRight";
const isTopBottomHandle =
handle === "topCenter" || handle === "bottomCenter";
const isRotator = handle === "rotator";
// Draw glow effect if enabled
if (
style.glowColor &&
style.glowIntensity &&
style.glowIntensity > 0 &&
!isRotator
) {
this.drawGlowEffect(style, isMiddleHandle || isTopBottomHandle);
}
// Draw handle based on type
if (isRotator) {
this.drawRotatorHandle(style);
} else if (isMiddleHandle || isTopBottomHandle) {
this.drawPillHandle(handle, style);
} else {
this.drawCornerHandle(style);
}
}
/**
* Draws glow effect for handles
*/
drawGlowEffect(
style,
isPill
) {
const glowSteps = 3;
const baseRadius = style.radius || 7;
const scaleFactor = baseRadius / 7; // Scale relative to default 7px radius
if (isPill) {
// Scale pill dimensions based on handle radius
const width = 10 * scaleFactor;
const height = 20 * scaleFactor;
const radius = 5 * scaleFactor;
const glowOffset = 2 * scaleFactor;
for (let i = 0; i < glowSteps; i++) {
const alpha = (style.glowIntensity || 0.15) * (1 - i / glowSteps) * 0.3;
const offset = i * glowOffset;
this.beginFill(style.glowColor || 0x6366f1, alpha)
.drawRoundedRect(
-width / 2 - offset,
-height / 2 - offset,
width + offset * 2,
height + offset * 2,
radius + offset
)
.endFill();
}
} else {
const glowRadius = baseRadius * 1.5;
for (let i = 0; i < glowSteps; i++) {
const alpha = (style.glowIntensity || 0.15) * (1 - i / glowSteps) * 0.3;
const currentRadius =
baseRadius + (glowRadius - baseRadius) * (i / glowSteps);
this.beginFill(style.glowColor || 0x6366f1, alpha)
.drawCircle(0, 0, currentRadius)
.endFill();
}
}
}
/**
* Draws corner handle (circle)
*/
drawCornerHandle(style) {
const radius = style.radius || 7;
// Draw white circle
this.beginFill(0xffffff).drawCircle(0, 0, radius).endFill();
// Draw colored border
this.lineStyle(
style.outlineThickness || 2.5,
style.outlineColor || 0x6366f1
).drawCircle(0, 0, radius);
}
/**
* Draws pill-shaped handle for middle handles
*/
drawPillHandle(handle, style) {
const baseRadius = style.radius || 7;
const scaleFactor = baseRadius / 7; // Scale relative to default 7px radius
const isVertical = handle === "topCenter" || handle === "bottomCenter";
const width = (isVertical ? 20 : 10) * scaleFactor;
const height = (isVertical ? 10 : 20) * scaleFactor;
const radius = 5 * scaleFactor;
// Draw white rounded rectangle
this.beginFill(style.color || 0xffffff)
.drawRoundedRect(-width / 2, -height / 2, width, height, radius)
.endFill();
// Draw colored border
this.lineStyle(
style.outlineThickness || 2.5,
style.outlineColor || 0x6366f1
).drawRoundedRect(-width / 2, -height / 2, width, height, radius);
}
/**
* Draws rotator handle using SVG sprite
*/
drawRotatorHandle(style) {
// Remove old sprite if exists
if (this._rotatorSprite) {
this.removeChild(this._rotatorSprite);
this._rotatorSprite.destroy();
this._rotatorSprite = null;
}
// Determine icon color (explicit override > outlineColor > default)
const iconColor = style.outlineColor || 0x6366f1;
const colorHex = "#" + iconColor.toString(16).padStart(6, "0");
const rotatorArrowColorHex = style.rotatorIconColor
? "#" + style.rotatorIconColor.toString(16).padStart(6, "0")
: "#FFFFFF";
// Calculate sprite size based on handle radius
const baseRadius = style.radius || 7;
const scaleFactor = baseRadius / 7; // Scale relative to default 7px radius
const spriteSize = Math.round(28 * scaleFactor); // Scale the 28px base size
// Create SVG data URL with scaled size and dual colors
const svgDataUrl = createRotatorSVG(
colorHex,
rotatorArrowColorHex,
spriteSize
);
// Create texture from SVG
const texture = Texture.from(svgDataUrl);
// Create sprite
this._rotatorSprite = new Sprite(texture);
this._rotatorSprite.anchor.set(0.5, 0.5);
this._rotatorSprite.width = spriteSize;
this._rotatorSprite.height = spriteSize;
this.addChild(this._rotatorSprite);
}
/**
* Handles the `pointerdown` event. You must call the super implementation.
*
* @param e
*/
onPointerDown(e) {
this._pointerDown = true;
this._pointerDragging = false;
e.stopPropagation();
if (this._pointerMoveTarget) {
this._pointerMoveTarget.removeEventListener(
"globalpointermove",
this.onPointerMove
);
this._pointerMoveTarget = null;
}
this._pointerMoveTarget = (this.transformer.stage ||
this) ;
this._pointerMoveTarget.addEventListener(
"globalpointermove",
this.onPointerMove
);
}
/**
* Handles the `pointermove` event. You must call the super implementation.
*
* @param e
*/
onPointerMove(e) {
if (!this._pointerDown) {
return;
}
if (this._pointerDragging) {
this.onDrag(e);
} else {
this.onDragStart(e);
}
e.stopPropagation();
}
/**
* Handles the `pointerup` and `pointerupoutside` events. You must call the super implementation.
*
* @param e
*/
onPointerUp(e) {
if (this._pointerDragging) {
this.onDragEnd(e);
}
this._pointerDown = false;
if (this._pointerMoveTarget) {
this._pointerMoveTarget.removeEventListener(
"globalpointermove",
this.onPointerMove
);
this._pointerMoveTarget = null;
}
}
/**
* Called on the first `pointermove` when {@code this._pointerDown} is true. You must call the super implementation.
*
* @param e
*/
onDragStart(e) {
this._pointerPosition.copyFrom(e.data.global);
this._pointerDragging = true;
}
/**
* Called on a `pointermove` when {@code this._pointerDown} & {@code this._pointerDragging}.
*
* @param e
*/
onDrag(e) {
const currentPosition = e.data.global;
// Callback handles the rest!
if (this.onHandleDelta) {
this.onHandleDelta(currentPosition);
}
this._pointerPosition.copyFrom(currentPosition);
}
/**
* Called on a `pointerup` or `pointerupoutside` & {@code this._pointerDragging} was true.
*
* @param _
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onDragEnd(_) {
this._pointerDragging = false;
if (this.onHandleCommit) {
this.onHandleCommit();
}
}
}
/**
* Calculates the distance of (x,y) from the line through l0 and l1.
*
* @ignore
*/
function distanceToLine(h, k, l0, l1)
{
const { x: x0, y: y0 } = l0;
const { x: x1, y: y1 } = l1;
if (Math.abs(x1 - x0) < 0.01)
{
return Math.abs(h - x0);
}
if (Math.abs(y1 - y0) < 0.01)
{
return Math.abs(k - y0);
}
const m = (y1 - y0) / (x1 - x0);
// Equation of line: mx - y - (y₁ - mx₁) = 0
// Distance to line from (h,k): |(mh - k + (y₁ - mx₁)) / √(m²+1)|
return Math.abs(((m * h) - k + (y1 - (m * x1))) / Math.sqrt((m * m) + 1));
}
const pointPool = ObjectPoolFactory.build(Point);
const tempHull = [new Point(), new Point(), new Point(), new Point()];
const tempMatrix = new Matrix();
const tempPoint = new Point();
/**
* Box rotation region for the top-left corner, normalized to 1-unit tolerance
* and positioned at the origin.
*
* @ignore
* @internal
*/
const boxRotationRegionTopLeft = [0, 0, 0, 1, -1, 1, -1, -1, 1, -1, 1, 0];
/**
* Box rotation region for the top-right corner, normalized to 1-unit tolerance
* and positioned at the origin.
*
* @ignore
* @internal
*/
const boxRotationRegionTopRight = [0, 0, -1, 0, -1, -1, 1, -1, 1, 1, 0, 1];
/**
* Box rotation region for the bottom-left corner, normalized to 1-unit tolerance
* and positioned at the origin.
*
* @ignore
* @internal
*/
const boxRotationRegionBottomLeft = [0, 0, 1, 0, 1, 1, -1, 1, -1, -1, 0, -1];
/**
* Box rotation region for the bottom-right corner, normalized to 1-unit tolerance
* and positioned at the origin.
*
* @ignore
* @internal
*/
const boxRotationRegionBottomRight = [0, 0, 0, -1, 1, -1, 1, 1, -1, 1, -1, 0];
/**
* Array used to store transformed box rotation region geometries.
*
* @ignore
* @internal
*/
const boxRotationTemp = new Array(12);
/**
* Box rotation region geometries in one array.
*
* @ignore
* @internal
*/
const boxRotationRegions = [
boxRotationRegionTopLeft,
boxRotationRegionTopRight,
boxRotationRegionBottomLeft,
boxRotationRegionBottomRight,
];
const Graphics_$1 = Graphics
;
/**
* The transformer's wireframe is drawn using this class.
*
* @ignore
* @public
* @extends PIXI.Graphics
*/
class TransformerWireframe extends Graphics_$1 {
/**
* The four scaling "edges" (or wide handles) for box-scaling. {@link TransformerWireframe#drawBoxScalingTolerance}
* should draw into these.
*
* @type {PIXI.Graphics[]}
*/
constructor(transformer) {
super();
this.transformer = transformer;
this.boxScalingHandles = [
this.addChild(new Graphics()),
this.addChild(new Graphics()),
this.addChild(new Graphics()),
this.addChild(new Graphics()),
] ;
this.boxScalingHandles.forEach((scalingHandle) => {
scalingHandle.interactive = true;
});
this.boxScalingHandles[0].cursor = HANDLE_TO_CURSOR.topCenter;
this.boxScalingHandles[1].cursor = HANDLE_TO_CURSOR.middleRight;
this.boxScalingHandles[2].cursor = HANDLE_TO_CURSOR.bottomCenter;
this.boxScalingHandles[3].cursor = HANDLE_TO_CURSOR.middleLeft;
}
/**
* Detects which type of box-handle, if any, the pointer clicked on in the wireframe.
*
* @param groupBounds
* @param projectionTransform
* @param pointerPosition
*/
hitHandleType(
groupBounds,
projectionTransform,
pointerPosition
) {
const {
boxRotationEnabled,
boxRotationTolerance,
boxScalingEnabled,
boxScalingTolerance,
} = this.transformer;
const [topLeft, topRight, bottomRight, bottomLeft] = groupBounds.hull;
const { x, y } = projectionTransform.applyInverse(
pointerPosition,
tempPoint
);
if (boxScalingEnabled) {
const topProximity =
distanceToLine(x, y, topLeft, topRight) * projectionTransform.d;
const leftProximity =
distanceToLine(x, y, topLeft, bottomLeft) * projectionTransform.a;
const rightProximity =
distanceToLine(x, y, topRight, bottomRight) * projectionTransform.a;
const bottomProximity =
distanceToLine(x, y, bottomLeft, bottomRight) * projectionTransform.d;
const minProximity = Math.min(
topProximity,
leftProximity,
rightProximity,
bottomProximity
);
if (minProximity < boxScalingTolerance) {
switch (minProximity) {
case topProximity:
return "topCenter";
case leftProximity:
return "middleLeft";
case rightProximity:
return "middleRight";
case bottomProximity:
return "bottomCenter";
}
}
}
if (boxRotationEnabled && !groupBounds.contains(x, y)) {
const tlProximity = Math.sqrt(
(topLeft.x - x) ** 2 + (topLeft.y - y) ** 2
);
const trProximity = Math.sqrt(
(topRight.x - x) ** 2 + (topRight.y - y) ** 2
);
const blProximity = Math.sqrt(
(bottomLeft.x - x) ** 2 + (bottomLeft.y - y) ** 2
);
const brProximity = Math.sqrt(
(bottomRight.x - x) ** 2 + (bottomRight.y - y) ** 2
);
const minProximity = Math.min(
tlProximity,
trProximity,
blProximity,
brProximity
);
// The box-rotation handles are squares, that mean they extend boxRotationTolerance√2
if (minProximity < boxRotationTolerance * 1.45) {
switch (minProximity) {
case tlProximity:
return "boxRotateTopLeft";
case trProximity:
return "boxRotateTopRight";
case blProximity:
return "boxRotateBottomLeft";
case brProximity:
return "boxRotateBottomRight";
}
}
}
return null;
}
/**
* Draws the bounding box into the wireframe.
*
* @param bounds
*/
drawBounds(bounds) {
const hull = tempHull;
// Bring hull into local-space
for (let i = 0; i < 4; i++) {
this.transformer.projectToLocal(bounds.hull[i], hull[i]);
}
// Fill polygon with ultra-low alpha to capture pointer events.
this.drawPolygon(hull);
}
/**
* Draws around edges of the bounding box to capture pointer events within
* {@link Transformer#boxScalingTolerance}.
*
* @param bounds
* @param boxScalingTolerance
*/
drawBoxScalingTolerance(
bounds,
boxScalingTolerance = this.transformer.boxScalingTolerance
) {
bounds.innerBounds.pad(-boxScalingTolerance);
// Inner four corners
const innerHull = pointPool.allocateArray(4);
innerHull.forEach((innerCorner, i) => {
this.projectToLocal(bounds.hull[i], innerCorner);
});
// A little extra tolerance outside because of arrow cursors being longer
bounds.innerBounds.pad(2.5 * boxScalingTolerance);
// Outer four corners
const outerHull = pointPool.allocateArray(4);
outerHull.forEach((outerCorner, i) => {
this.projectToLocal(bounds.hull[i], outerCorner);
});
// Leave at original
bounds.innerBounds.pad(-1.5 * this.transformer.boxScalingTolerance);
for (let i = 0; i < 4; i++) {
const innerStart = innerHull[i];
const innerEnd = innerHull[(i + 1) % 4];
const outerStart = outerHull[i];
const outerEnd = outerHull[(i + 1) % 4];
const boxScalingHandle = this.boxScalingHandles[i];
boxScalingHandle
.clear()
.beginFill(0xffffff, 1e-4)
.drawPolygon(innerStart, outerStart, outerEnd, innerEnd)
.endFill();
}
}
/**
* Draws square-shaped tolerance regions for capturing pointer events within {@link Transformer#boxRotationTolernace}
* of the four corners of the group bounding box. The square are cut in the interior region of the group bounds.
*/
drawBoxRotationTolerance() {
const {
boxRotateTopLeft: tl,
boxRotateTopRight: tr,
boxRotateBottomLeft: bl,
boxRotateBottomRight: br,
} = this.transformer.handleAnchors;
// 2x because half of the square's width & height is inside
const t = this.transformer.boxRotationTolerance * 2;
// Expand box rotation regions to the given tolerance, and then rotate to align with
// the group bounds. The position is added manually.
const matrix = tempMatrix
.identity()
.scale(t, t)
.rotate(this.transformer.getGroupBounds().rotation);
for (let i = 0; i < 4; i++) {
const region = boxRotationRegions[i];
let position;
switch (i) {
case 0:
position = tl;
break;
case 1:
position = tr;
break;
case 2:
position = bl;
break;
case 3:
position = br;
break;
}
for (let j = 0; j < region.length; j += 2) {
const x = region[j];
const y = region[j + 1];
tempPoint.set(x, y);
matrix.apply(tempPoint, tempPoint);
boxRotationTemp[j] = tempPoint.x + position.x;
boxRotationTemp[j + 1] = tempPoint.y + position.y;
}
this.drawPolygon(boxRotationTemp.slice());
}
}
/**
* Alias for {@link Transformer#projectToLocal}. The transform of the wireframe should equal that
* of the transformer itself.
*
* @param input
* @param output
*/
projectToLocal(input, output) {
return this.transformer.projectToLocal(input, output);
}
}
const tempMatrix$1 = new Matrix();
/**
* @ignore
* @param angle
* @returns a horizontal skew matrix
*/
function createHorizontalSkew(angle)
{
const matrix = tempMatrix$1.identity();
matrix.c = Math.tan(angle);
return matrix;
}
/**
* @ignore
* @param angle
* @returns a vertical skew matrix
*/
function createVerticalSkew(angle)
{
const matrix = tempMatrix$1.identity();
matrix.b = Math.tan(angle);
return matrix;
}
/**
* Decomposes the matrix into transform, while preserving rotation & the pivot.
*
* @ignore
* @param transform
* @param matrix
* @param rotation
* @param pivot
*/
function decomposeTransform(
transform,
matrix,
rotation,
pivot = transform.pivot,
)
{
const a = matrix.a;
const b = matrix.b;
const c = matrix.c;
const d = matrix.d;
const skewX = -Math.atan2(-c, d);
const skewY = Math.atan2(b, a);
rotation = rotation !== undefined && rotation !== null ? rotation : skewY;
// set pivot
transform.pivot.set(pivot.x, pivot.y);
// next set rotation, skew angles
transform.rotation = rotation;
transform.skew.x = rotation + skewX;
transform.skew.y = -rotation + skewY;
// next set scale
transform.scale.x = Math.sqrt((a * a) + (b * b));
transform.scale.y = Math.sqrt((c * c) + (d * d));
// next set position
transform.position.x = matrix.tx + ((pivot.x * matrix.a) + (pivot.y * matrix.c));
transform.position.y = matrix.ty + ((pivot.x * matrix.b) + (pivot.y * matrix.d));
return transform;
}
const tempMatrix$2 = new Matrix();
const tempParentMatrix = new Matrix();
/**
* Multiplies the transformation matrix {@code transform} to the display-object's transform.
*
* @ignore
* @param displayObject
* @param transform
* @param skipUpdate
*/
function multiplyTransform(displayObject, transform, skipUpdate)
{
if (!skipUpdate)
{
const parent = !displayObject.parent ? displayObject.enableTempParent() : displayObject.parent;
displayObject.updateTransform();
displayObject.disableTempParent(parent);
}
const worldTransform = displayObject.worldTransform;
const parentTransform = displayObject.parent
? tempParentMatrix.copyFrom(displayObject.parent.worldTransform)
: Matrix.IDENTITY;
tempMatrix$2.copyFrom(worldTransform);
tempMatrix$2.prepend(transform);
tempMatrix$2.prepend(parentTransform.invert());// gets new "local" transform
decomposeTransform(displayObject.transform, tempMatrix$2);
}
// Preallocated objects
const tempTransform = new Transform();
const tempCorners = [
new Point(),
new Point(),
new Point(),
new Point(),
];
const tempMatrix$3 = new Matrix();
const tempPoint$1 = new Point();
const tempBounds = new OrientedBounds();
const tempRect = new Rectangle();
const tempHull$1 = [new Point(), new Point(), new Point(), new Point()];
const tempPointer = new Point();
const emitMatrix = new Matrix(); // Used to pass to event handlers
// Pool for allocating an arbitrary number of points
const pointPool$1 = ObjectPoolFactory.build(Point );
/**
* The handles used for rotation.
*
* @public
* @ignore
*/
/**
* Specific cursors for each handle
*
* @ignore
*/
const HANDLE_TO_CURSOR = {
topLeft: "nw-resize",
topCenter: "n-resize",
topRight: "ne-resize",
middleLeft: "w-resize",
middleRight: "e-resize",
bottomLeft: "sw-resize",
bottomCenter: "s-resize",
bottomRight: "se-resize",
};
/**
* An array of all {@link ScaleHandle} values.
*
* @internal
* @ignore
*/
const SCALE_HANDLES = [
"topLeft",
"topCenter",
"topRight",
"middleLeft",
"middleCenter",
"middleRight",
"bottomLeft",
"bottomCenter",
"bottomRight",
];
/**
* This maps each scaling handle to the directions in which the x, y components are outward. A value of
* zero means that no scaling occurs along that component's axis.
*
* @internal
* @ignore
*/
const SCALE_COMPONENTS
= {
topLeft: { x: -1, y: -1 },
topCenter: { x: 0, y: -1 },
topRight: { x: 1, y: -1 },
middleLeft: { x: -1, y: 0 },
middleCenter: { x: 0, y: 0 },
middleRight: { x: 1, y: 0 },
bottomLeft: { x: -1, y: 1 },
bottomCenter: { x: 0, y: 1 },
bottomRight: { x: 1, y: 1 },
};
/**
* All possible values of {@link Handle}.
*
* @ignore
*/
const HANDLES = [...SCALE_HANDLES, "rotator", "skewHorizontal", "skewVertical"];
/**
* The default tolerance for scaling by dragging the bounding-box edges.
*
* @ignore
*/
const DEFAULT_BOX_SCALING_TOLERANCE = 4;
/**
* The default tolerance for box-rotation handles.
*
* @ignore
*/
const DEFUALT_BOX_ROTATION_TOLERANCE = 16;
/**
* The default snap angles for rotation, in radians.
*
* @ignore
*/
const DEFAULT_ROTATION_SNAPS = [
Math.PI / 4,
Math.PI / 2,
(Math.PI * 3) / 4,
Math.PI,
0,
-Math.PI / 4,
-Math.PI / 2,
(-Math.PI * 3) / 4,
-Math.PI,
];
/**
* The default snap tolerance, i.e. the maximum angle b/w the pointer & nearest snap ray for snapping.
*
* @ignore
*/
const DEFAULT_ROTATION_SNAP_TOLERANCE = Math.PI / 90;
/**
* The default snap angles for skewing, in radians.
*
* @ignore
*/
const DEFAULT_SKEW_SNAPS = [Math.PI / 4, -Math.PI / 4];
/**
* The default snap tolerance for skewing.
*
* @ignore
*/
const DEFAULT_SKEW_SNAP_TOLERANCE = Math.PI / 90;
/**
* @ignore
*/
/**
* The default wireframe style for modern Canva-style transformer
*
* @ignore
*/
const DEFAULT_WIREFRAME_STYLE = {
color: 0x6366f1,
thickness: 2,
};
/**
* @public
*/
// api-extractor-disable-next-line: [ae-forgotten-export]
const Container_ = Container
;
/**
* {@code Transformer} provides an interactive interface for editing the transforms in a group. It supports translating,
* scaling, rotating, and skewing display-objects both through interaction and code.
*
* A transformer operates in world-space, and it is best to not position, scale, rotate, or skew one. If you do so, the
* wireframe itself will not distort (i.e. will adapt _against_ your transforms). However, the wireframe may become
* thinner/thicker and the handles will scale & rotate. For example, setting `transformer.scale.set(2)` will make the handles
* twice as big, but will not scale the wireframe (assuming the display-object group itself has not been
* scaled up).
*
* To enable scaling via dragging the edges of the wireframe, set `boxScalingEnabled` to `true`.
*
* NOTE: The transformer needs to capture all interaction events that would otherwise go to the display-objects in the
* group. Hence, it must be placed after them in the scene graph.
*
* @extends PIXI.Container
*/
class Transformer extends Container_ {
/** The group of display-objects under transformation. */
/** Getter for the group property */
get group() {
return this._group;
}
/** Setter for the group property - handles React prop updates */
set group(value) {
this._group = value || [];
// Reset focus when group changes
this._focusedElementIndex = this._group.length > 0 ? 0 : -1;
// Update element index map and setup interactions
this.updateElementIndexMap();
// Force redraw
this.lazyDirty = true;
}
/**
* Specify which bounding boxes should be drawn in the wireframe.
*
* "groupOnly" won't show individual bounding boxes. "none" will not render anything visible.
*
* @default "all"
*/
/** Set this to enable rotation at the four corners */
/** The thickness of the box rotation area */
/** Set this to enable scaling by dragging at the edges of the bounding box */
/** The padding around the bounding-box to capture dragging on the edges. */
/** This will prevent the wireframe's center from shifting on scaling. */
/** Cursors to use in the transformer */
/** Color for individual element borders */
/** Thickness for individual element borders */
/** Alpha for individual element borders */
/** Enable nested selection (click to select individual elements within group) */
/** Color for focused element border */
/** Thickness for focused element border */
/** Show borders for non-focused elements */
/**
* Flags whether the transformer should **not** redraw each frame (good for performance)
*
* @default false
*/
/** Set this when you want the transformer to redraw when using {@link Transformer#lazyMode lazyMode}. */
/** Lock aspect ratio when using one of the corner handles. */
/**
* This is used when the display-object group are rendered through a projection transformation (i.e. are disconnected
* from the transformer in the scene graph). The transformer project itself into their frame-of-reference using this
* transform.
*
* Specifically, the projection-transform converts points from the group's world space to the transformer's world
* space. If you are not applying a projection on the transformer itself, this means it is the group's
* world-to-screen transformation.
*/
/** The angles at which rotation should snap. */
/** The maximum angular difference for snapping rotation. */
/** The distance of skewing handles from the group's center. */
/** The angles at which both the horizontal & vertical skew handles should snap. */
/**
* The maximum angular difference for snapping skew handles.
*/
/**
* The root object in your scene in which objects can move.
*
* {@code Transformer} will subscribe to this object for `pointermove` events, if provided. This
* should be used when:
*
* * {@link InteractionManager.moveWhenInside moveWhenInside} is enabled on the interaction plugin.
* * {@link EventBoundary.moveOnAll moveOnAll} is not turned off when using the new {@link EventSystem}.
*
* Otherwise, the transformer will receive **not** `pointermove` events when the user drags fast enough that
* the cursor leaves the transformer's bounds.
*
* The stage must be fully interactive in the area you want objects to move. Generally, this is the
* whole canvas:
*
* ```ts
* stage.interactive = true;
* stage.hitArea = renderer.screen;// or pass custom rect for the canvas dimensions
* ```
*/
/**
* This will enable translation on dragging the transformer. By default, it is turned on.
*
* @default true
*/
/**
* This will reset the rotation angle after the user finishes rotating a group with more than one display-object.
*
* @default true
*/
/** The last calculated bounds of the whole group being transformed */
/** Object mapping handle-names to the handle display-objects. */
/**
* Positions of the various handles
*
* @internal
* @ignore
*/
/** Draws the bounding boxes */
/** @see Transformer#enabledHandles */
/** @see Transformer#rotateEnabled */
/** @see Transformer#scaleEnabled */
/** @see Transformer#skewEnabled */
/** The horizontal skew value. Rotating the group by 𝜽 will also change this value by 𝜽. */
/** The vertical skew value. Rotating the group by 𝜽 will also change this value by 𝜽. */
/** The currently grabbed handle. This can be used to get the type of transformation. */
/** The current type of transform being applied by the user. */
/** The style applied on transformer handles */
/** The wireframe style applied on the transformer */
/** The color theme applied on the transformer */
/** The rotator anchor configuration */
/** Index of currently focused element within the group (-1 = none focused) */
/** Map to track which display object corresponds to which index */
/* eslint-disable max-len */
/**
* | Handle | Type | Notes |
* | --------------------- | ------------------------ | ----- |
* | rotator | Rotate | |
* | boxRotateTopLeft | Rotate | Invisible |
* | boxRotateTopRight | Rotate | Invisible |
* | boxRotateBottomLeft | Rotate | Invisible |
* | boxRotateBottomRight | Rotate | Invisible |
* | topLeft | Scale | |
* | topCenter | Scale | |
* | topRight | Scale | |
* | middleLeft | Scale | |
* | middleCenter | Scale | This cannot be enabled! |
* | middleRight | Scale | |
* | bottomLeft | Scale | |
* | bottomCenter | Scale | |
* | bottomRight | Scale | |
* | skewHorizontal | Skew | Applies vertical shear. Handle segment is horizontal at skew.y = 0! |
* | skewVertical | Skew | Applied horizontal shear. Handle segment is vertical at skew.x = 0! |
*/
constructor(options = {}) {
/* eslint-enable max-len */
super();Transformer.prototype.__init.call(this);Transformer.prototype.__init2.call(this);Transformer.prototype.__init3.call(this);Transformer.prototype.__init4.call(this);Transformer.prototype.__init5.call(this);
this.interactive = true;
this.cursors = Object.assign({ default: "move" }, options.cursors);
this.cursor = this.cursors.default;
this.boundingBoxes = options.boundingBoxes || "all";
this._group = options.group || [];
this.boxRotationTolerance =
options.boxRotationTolerance || DEFUALT_BOX_ROTATION_TOLERANCE;
this.boxScalingTolerance =
options.boxScalingTolerance || DEFAULT_BOX_SCALING_TOLERANCE;
this.centeredScaling = !!options.centeredScaling;
this.projectionTransform = new Matrix();
this.lockAspectRatio = options.lockAspectRatio === true;
this.rotationSnaps = options.rotationSnaps || DEFAULT_ROTATION_SNAPS;
this.rotationSnapTolerance =
options.rotationSnapTolerance !== undefined
? options.rotationSnapTolerance
: DEFAULT_ROTATION_SNAP_TOLERANCE;
this.skewRadius = options.skewRadius || 64;
this.skewSnaps = options.skewSnaps || DEFAULT_SKEW_SNAPS;
this.skewSnapTolerance =
options.skewSnapTolerance !== undefined
? options.skewSnapTolerance
: DEFAULT_SKEW_SNAP_TOLERANCE;
this.boxRotationEnabled = options.boxRotationEnabled === true;
this.boxScalingEnabled = options.boxScalingEnabled === true;
this._rotateEnabled = options.rotateEnabled !== false;
this._scaleEnabled = options.scaleEnabled !== false;
this._skewEnabled = options.skewEnabled === true;
this.translateEnabled = options.translateEnabled !== false;
this.transientGroupTilt =
options.transientGroupTilt !== undefined
? options.transientGroupTilt
: true;
this.wireframe = this.addChild(new TransformerWireframe(this));
this.wireframe.cursor = "none";
this.stage = options.stage || null;
this._skewX = 0;
this._skewY = 0;
this._transformType = "none";
// Initialize color theme with defaults
this._colorTheme = {
primary: 0x6366f1,
secondary: 0x4f46e5,
background: 0xffffff,
glow: 0x6366f1,
glowIntensity: 0.15,
...options.colorTheme,
};
// Initialize rotator anchor configuration with defaults
this._rotatorAnchorConfig = {
enabled: true,
startPosition: 0.45,
segmentLength: 0.005,
thickness: undefined, // Will use wireframe thickness
color: undefined, // Will use theme color
style: "solid",
dashPattern: [8, 4],
dotPattern: [2, 4],
...options.rotatorAnchor,
};
// Initialize individual border styling (Canva-style)
this.individualBorderColor =
options.individualBorderColor !== undefined
? options.individualBorderColor
: 0x00d4ff; // Cyan like Canva
this.individualBorderThickness = options.individualBorderThickness;
this.individualBorderAlpha =
options.individualBorderAlpha !== undefined
? options.individualBorderAlpha
: 1.0;
// Initialize nested selection (Canva-style behavior)
this.nestedSelectionEnabled =
options.nestedSelectionEnabled !== undefined
? options.nestedSelectionEnabled
: true; // Enable by default for Canva-style behavior
this.focusedElementBorderColor =
options.focusedElementBorderColor !== undefined
? options.focusedElementBorderColor
: 0x8b5cf6; // Purple for focused element
this.focusedElementBorderThickness = options.focusedElementBorderThickness;
this.showNonFocusedBorders =
options.showNonFocusedBorders !== undefined
? options.showNonFocusedBorders
: false; // Hide non-focused by default
// ALWAYS start with first element focused if we have a group
this._focusedElementIndex = this._group.length > 0 ? 0 : -1;
this._elementIndexMap = new WeakMap();
// Update element index map
this.updateElementIndexMap();
// Initialize wireframe style with color theme
this._wireframeStyle = Object.assign(
{},
DEFAULT_WIREFRAME_STYLE,
{ color: this._colorTheme.primary },
options.wireframeStyle || {}
);
const HandleConstructor = options.handleConstructor || TransformerHandle;
const handleStyle = {
outlineColor: this._colorTheme.primary,
glowColor: this._colorTheme.glow,
glowIntensity: this._colorTheme.glowIntensity,
...options.handleStyle,
};
this._handleStyle = handleStyle;
// Initialize transformer handles
const rotatorHandles = {
rotator: this.addChild(
new HandleConstructor(
this,
"rotator",
handleStyle,
(pointerPosition) => {
// The origin is the rotator handle's position, yes.
this.rotateGroup("rotator", pointerPosition);
},
this.commitGroup
)
),
};
const scaleHandles = SCALE_HANDLES.reduce(
(scaleHandles, handleKey) => {
const handle = new HandleConstructor(
this,
handleKey,
handleStyle,
null,
this.commitGroup,
HANDLE_TO_CURSOR[handleKey]
);
handle.onHandleDelta = (pointerPosition) => {
// Scale handles can be swapped with each other, i.e. handle.handle can change!
this.scaleGroup(handle.handle , pointerPosition);
};
handle.visible = this._scaleEnabled;
scaleHandles[handleKey] = handle;
this.addChild(scaleHandles[handleKey]);
return scaleHandles;
},
{}
);
const skewHandles = {
skewHorizontal: this.addChild(
new HandleConstructor(
this,
"skewHorizontal",
handleStyle,
(pointerPosition) => {
this.skewGroup("skewHorizontal", pointerPosition);
},
this.commitGroup,
"pointer"
)
),
skewVertical: this.addChild(
new HandleConstructor(
this,
"skewVertical",
handleStyle,
(pointerPosition) => {
this.skewGroup("skewVertical", pointerPosition);
},
this.commitGroup,
"pointer"
)
),
};
// Scale handles have higher priority
this.handles = Object.assign(
{},
scaleHandles,
rotatorHandles,
skewHandles
) ;
this.handles.middleCenter.visible = false;
this.handles.skewHorizontal.visible = this._skewEnabled;
this.handles.skewVertical.visible = this._skewEnabled;
this.handleAnchors = {
rotator: new Point(),
boxRotateTopLeft: new Point(),
boxRotateTopRight: new Point(),
boxRotateBottomLeft: new Point(),
boxRotateBottomRight: new Point(),
topLeft: new Point(),
topCenter: new Point(),
topRight: new Point(),
middleLeft: new Point(),
middleCenter: new Point(),
middleRight: new Point(),
bottomLeft: new Point(),
bottomCenter: new Point(),
bottomRight: new Point(),
skewHorizontal: new Point(),
skewVertical: new Point(),
};
// Update groupBounds immediately. This is because mouse events can propagate before the next animation frame.
this.groupBounds = new OrientedBounds();
this.updateGroupBounds();
// Pointer events
this._pointerDown = false;
this._pointerDragging = false;
this._pointerPosition = new Point();
this._pointerMoveTarget = null;
this.onPointerDown = this.onPointerDown.bind(this);
this.onPointerMove = this.onPointerMove.bind(this);
this.onPointerUp = this.onPointerUp.bind(this);
this.addEventListener("pointerdown", this.onPointerDown);
this.addEventListener("pointerup", this.onPointerUp);
this.addEventListener("pointerupoutside", this.onPointerUp);
}
/** The list of enabled handles, if applied manually. */
get enabledHandles() {
return this._enabledHandles;
}
set enabledHandles(value) {
if (!this._enabledHandles && !value) {
return;
}
this._enabledHandles = value;
HANDLES.forEach((handleKey) => {
this.handles[handleKey].visible = false;
});
if (value) {
value.forEach((handleKey) => {
this.handles[handleKey].visible = true;
});
} else {
this.handles.rotator.visible = this._rotateEnabled;
this.handles.skewHorizontal.visible = this._skewEnabled;
this.handles.skewVertical.visible = this._skewEnabled;
SCALE_HANDLES.forEach((handleKey) => {
if (handleKey === "middleCenter") return;
this.handles[handleKey].visible = this._scaleEnabled;
});
}
}
/** The currently applied handle style. If you have edited the transformer handles directly, this may be inaccurate. */
get handleStyle() {
return this._handleStyle;
}
set handleStyle(value) {
const handles = this.handles;
for (const handleKey in handles) {
(handles[handleKey] ).style = value;
}
this._handleStyle = value;
}
/** This will enable the rotate handles. */
get rotateEnabled() {
return this._rotateEnabled;
}
set rotateEnabled(value) {
if (this._rotateEnabled !== value) {
this._rotateEnabled = value;
if (this._enabledHandles) {
return;
}
this.handles.rotator.visible = value;
}
}
/** This will enable the scale handles. */
get scaleEnabled() {
return this._scaleEnabled;
}
set scaleEnabled(value) {
if (this._scaleEnabled !== value) {
this._scaleEnabled = value;
if (this._enabledHandles) {
return;
}
SCALE_HANDLES.forEach((handleKey) => {
if (handleKey === "middleCenter") {
return;
}
this.handles[handleKey].visible = value;
});
}
}
/** This will enable the skew handles. */
get skewEnabled() {
return this._skewEnabled;
}
set skewEnabled(value) {
if (this._skewEnabled !== value) {
this._skewEnabled = value;
if (this._enabledHandles) {
return;
}
this.handles.skewHorizontal.visible = value;
this.handles.skewVertical.visible = value;
}
}
/**
* This is the type of transformation being applied by the user on the group. It can be inaccurate if you call one of
* `translateGroup`, `scaleGroup`, `rotateGroup`, `skewGroup` without calling `commitGroup` afterwards.
*
* @readonly
*/
get transformType() {
return this._transformType;
}
/** The currently applied wireframe style. */
get wireframeStyle() {
return this._wireframeStyle;
}
set wireframeStyle(value) {
this._wireframeStyle = Object.assign({}, DEFAULT_WIREFRAME_STYLE, value);
}
/** The currently applied color theme. */
get colorTheme() {
return this._colorTheme;
}
set colorTheme(value) {
this._colorTheme = {
primary: 0x6366f1,
secondary: 0x4f46e5,
background: 0xffffff,
glow: 0x6366f1,
glowIntensity: 0.15,
...value,
};
// Update handle styles with new colors
this._handleStyle = {
...this._handleStyle,
outlineColor: this._colorTheme.primary,
glowColor: this._colorTheme.glow,
glowIntensity: this._colorTheme.glowIntensity,
};
// Update wireframe color
this._wireframeStyle = {
...this._wireframeStyle,
color: this._colorTheme.primary,
};
// Force redraw of all handles with new colors
this.redrawAllHandles();
}
/** The currently applied rotator anchor configuration. */
get rotatorAnchor() {
return this._rotatorAnchorConfig;
}
set rotatorAnchor(value) {
this._rotatorAnchorConfig = {
enabled: true,
startPosition: 0.45,
segmentLength: 0.005,
thickness: undefined,
color: undefined,
style: "solid",
dashPattern: [8, 4],
dotPattern: [2, 4],
...value,
};
}
/**
* Get the currently focused element within the group
*/
get focusedElement() {
if (
this._focusedElementIndex >= 0 &&
this._focusedElementIndex < this._group.length
) {
return this._group[this._focusedElementIndex];
}
return null;
}
/**
* Set the focused element by index
*/
set focusedElementIndex(index) {
if (index >= -1 && index < this._group.length) {
const oldIndex = this._focusedElementIndex;
this._focusedElementIndex = index;
this.lazyDirty = true;
// Emit events when focus changes programmatically
if (oldIndex !== index) {
if (index >= 0 && index < this._group.length) {
this.emit("elementfocused", this._group[index], index);
} else {
this.emit("elementfocuscleared");
}
}
}
}
get focusedElementIndex() {
return this._focusedElementIndex;
}
/**
* Focus a specific element by reference
*/
focusElement(element) {
const index = this._elementIndexMap.get(element);
if (index !== undefined) {
this._focusedElementIndex = index;
this.lazyDirty = true;
this.emit("elementfocused", element, index);
}
}
/**
* Clear element focus (show all individual borders)
*/
clearElementFocus() {
this._focusedElementIndex = -1;
this.lazyDirty = true;
this.emit("elementfocuscleared");
}
/**
* Cycle to next element in group
*/
focusNextElement() {
if (this._group.length === 0) return;
this._focusedElementIndex =
(this._focusedElementIndex + 1) % this._group.length;
this.lazyDirty = true;
this.emit("elementfocused", this.focusedElement, this._focusedElementIndex);
}
/**
* Update the element index map when group changes
*/
updateElementIndexMap() {
this._elementIndexMap = new WeakMap();
this._group.forEach((element, index) => {
this._elementIndexMap.set(element, index);
this.setupElementInteraction(element, index);
});
}
/**
* Set up or remove interaction for individual elements
*/
setupElementInteraction(element, index) {
// Remove any existing handlers
element.off("pointerdown");
// Make individual elements interactive for nested selection
if (this.nestedSelectionEnabled && this._group.length > 1) {
element.interactive = true;
element.eventMode = "static";
// Add click handler to individual element
element.on("pointerdown", (e) => {
e.stopPropagation(); // Prevent transformer from handling this
if (this._focusedElementIndex === index) {
// Click on already focused element: keep it focused (don't cycle)
// This maintains the current focus without changing
thi