🖌️

PixiJSでリサイズと回転が可能な描画オブジェクトを作る

2023/01/19に公開

概要

https://twitter.com/BaroqueEngine/status/1615322348013641730

図形を編集できるソフトウェアなどでよく見かける、矩形オブジェクトをリサイズしたり回転したりできる機能が作りたくて先日コードを書いてみたが、想像以上に難しかったのでハマりポイントを含めた制作手順をここに書くことにした。

制作環境

  • Vite
  • TypeScript
  • PixiJS

描画ライブラリは何でもいいのだが、描画領域をクリックしたらイベントが取れるライブラリだと楽なのでPixiJSを採用した。

環境の用意

npm create vite

Project name: ... sketch
√ Select a framework: » Vanilla
√ Select a variant: » TypeScript

ViteでTypeScript環境を用意する。

cd sketch
npm install
npm install pixi.js

Viteで作られたディレクトリに移動して、依存パッケージをインストール。ついでにPixiJSのパッケージもインストールしておく。

/src/main.ts
import * as PIXI from "pixi.js";

const app = new PIXI.Application({
  width: 600,
  height: 400,
  backgroundColor: 0x0d1c2f,
  antialias: true,
});
document.body.appendChild(app.view as any);

最低限のPixiJSのコードを書き、動作確認を行う。
実行は npm run dev で、localhost:5173 で動作確認ができる。

矩形を描く

次に x, y, width, height の矩形領域を渡すと、矩形を描くコードを書きたい。この先コードがややこしくなりそうなのでクラスとしてまとめることにする。

class Rectangle {
  public width: number;
  public height: number;
  public container: PIXI.Graphics;

  constructor(x: number, y: number, width: number, height: number) {
    this.width = width;
    this.height = height;

    this.container = new PIXI.Graphics();
    app.stage.addChild(this.container);
    this.container.x = x;
    this.container.y = y;

    this.draw();
  }

  public draw(): void {
    this.container.clear();
    this.container.beginFill(0xffffff);
    this.container.drawRect(-this.width / 2, -this.height / 2, this.width, this.height);
    this.container.endFill();

    this.container.beginFill(0x0d1c2f);
    this.container.drawRect(-11, -2, 20, 2);
    this.container.drawRect(-2, -11, 2, 20);
    this.container.endFill();
  }
}

new Rectangle(app.renderer.width / 2, app.renderer.height / 2, 200, 150);

this.container.clear();
this.container.beginFill(0xffffff);
this.container.drawRect(-this.width / 2, -this.height / 2, this.width, this.height);
this.container.endFill();

後に回転することを考えると、矩形中央が軸になると楽になりそうなので、(x, y) が矩形中央に来るように描画を行うことにした。

this.container.beginFill(0x0d1c2f);
this.container.drawRect(-11, -2, 20, 2);
this.container.drawRect(-2, -11, 2, 20);
this.container.endFill();

ついでに中心の軸がどこかわかりやすいように十字マークを描く。

ドラッグして移動可能にする

次に、ドラッグするとオブジェクトが移動できるようにしたい。

this.container.interactive = true;

const onPointerMove = (event: PIXI.FederatedPointerEvent) => {
  this.container.x = event.globalX;
  this.container.y = event.globalY;
};

this.container.on("pointerdown", (event: PIXI.FederatedPointerEvent) => {
  app.stage.on("pointermove", onPointerMove);
});

this.container.on("pointerup", (event: PIXI.FederatedPointerEvent) => {
  app.stage.off("pointermove", onPointerMove);
});

PixiJSの環境においてオブジェクトをインタラクティブ可能にするには interactive = true にする必要がある。

pointermove イベントを登録すると、マウスが動くたびに指定したリスナー関数が呼ばれる。引数である PIXI.FederatedPointerEvent(globalX, globalY) でグローバルのマウス座標が取れるので、それを矩形オブジェクトの位置に設定する。

この状態だと常に矩形オブジェクトがマウスに追随することになるので、pointerdown 中に pointermove を登録して、pointerup が呼ばれると pointermove を解除するようにする。

これで実行すると、動くには動くのだが、幾つか問題が発生した。

【問題点】マウスダウンしている位置が必ずオブジェクトの中央になる

const onPointerMove = (event: PIXI.FederatedPointerEvent) => {
  this.container.x = event.globalX;
  this.container.y = event.globalY;
};

マウス座標をオブジェクトの (x, y) に代入しているので、矩形領域のどの部分をマウスダウンしても、移動するとマウスボタンの下にオブジェクトの中央部分が来ることになる。

public pointerDownDiffX: number;
public pointerDownDiffY: number;

constructor(x: number, y: number, width: number, height: number) {
  // 省略

  this.pointerDownDiffX = 0;
  this.pointerDownDiffY = 0;

  const onPointerMove = (event: PIXI.FederatedPointerEvent) => {
    this.container.x = event.globalX - this.pointerDownDiffX;
    this.container.y = event.globalY - this.pointerDownDiffY;
  };

  this.container.on("pointerdown", (event: PIXI.FederatedPointerEvent) => {
    this.pointerDownDiffX = event.globalX - this.container.x;
    this.pointerDownDiffY = event.globalY - this.container.y;
    app.stage.on("pointermove", onPointerMove);
  });

  this.container.on("pointerup", (event: PIXI.FederatedPointerEvent) => {
    app.stage.off("pointermove", onPointerMove);
  });
}

解決策としては、マウスボタンが押された位置とオブジェクトの中心位置の差を pointerDownDiff という変数に入れておき、this.container.x = event.globalX - this.pointerDownDiffX のように、差分を調整して位置を決めるという方法を取った。

【問題点】ドラッグが途中で止まってしまう

実際に実行してみるとよく分かるのだが、矩形オブジェクトを掴んだまま適当にドラッグすると、マウスボタンを押したままなのにドラッグが中断されることがある。

今まで書いたコードの手順だと、

  1. マウスが移動する
  2. その位置にオブジェクトが移動する

なので、1.の処理中にマウスがオブジェクトの範囲外に出てしまうと、pointerup イベントが発生してしまいドラッグが途中で止まってしまうようだ。

これを解決するには、pointerup イベントを矩形領域ではなく、Canvas全体を管理するステージ領域で登録してマウスボタンを離したかどうかを管理すればいい。

ただし、今回実装する機能でドラッグできるのは矩形オブジェクトだけではなく、後に実装を行う回転ハンドルとリサイズハンドルにも必要である。

これらオブジェクトの pointermove ハンドラは別々の処理になるので、コードを大きく書き直す必要がある。

type DragItem = {
  target: PIXI.Graphics;
  onPointerMove: (event: PIXI.FederatedPointerEvent) => void;
};

let dragItem: DragItem | null = null;

const onPointerUp = (event: PIXI.FederatedPointerEvent) => {
  if (dragItem) {
    app.stage.off("pointermove", dragItem.onPointerMove);
    dragItem = null;
  }
};

app.stage.interactive = true;
app.stage.hitArea = app.screen;
app.stage.on("pointerup", onPointerUp);
type DragItem = {
  target: PIXI.Graphics;
  onPointerMove: (event: PIXI.FederatedPointerEvent) => void;
};

let dragItem: DragItem | null = null;

DragItem という型とオブジェクトを用意して、ここにはドラッグ中、つまりマウスボタンを押したオブジェクトの情報を格納する。
target が対象のオブジェクトで、onPointerMove がドラッグ中に呼ばれるハンドラになる。

app.stage.interactive = true;
app.stage.hitArea = app.screen;
app.stage.on("pointerup", onPointerUp);

Canvas全体に pointerup イベントを登録するには、app.stage からイベントを登録すればいい。本当は描画した範囲内だけイベントが発行されるのだが、hitArea に範囲を指定しておくと何も描画していなくても hitArea の範囲内だけイベントの発行対象になる。

const onPointerUp = (event: PIXI.FederatedPointerEvent) => {
  if (dragItem) {
    app.stage.off("pointermove", dragItem.onPointerMove);
    dragItem = null;
  }
};

Canvas全体のどこでマウスボタンを離してもこの onPointerUp が呼ばれるのだが、dragItem がある場合、つまりドラッグ中のオブジェクトがある場合、そのオブジェクトの pointermove イベントが解除される。

this.container.on("pointerdown", (event: PIXI.FederatedPointerEvent) => {
  this.pointerDownDiffX = event.globalX - this.container.x;
  this.pointerDownDiffY = event.globalY - this.container.y;

  app.stage.on("pointermove", onPointerMove);
  dragItem = { target: this.container, onPointerMove };
});

後は、矩形オブジェクトを押した際に、dragItem の登録をすればいい。

これでドラッグ中に意図しない場面でドラッグが終了することが無くなり、後に実装する複数のハンドルオブジェクトのドラッグ処理にも利用できる形になった。

矩形オブジェクトを回転をさせる

今の矩形オブジェクトは中央が軸になっているので、コードで回転をさせるのは簡単である。

this.container.rotation = 0.2;

PixiJSには描画オブジェクトの回転機能が付いており、rotation プロパティにラジアン単位の角度を入れれば回転が可能になる。

回転ができるハンドルを付ける

矩形オブジェクトの上部に赤いハンドルを描画して、そのハンドルをドラッグすると矩形オブジェクトを回転できるようにしたい。

具体的にはマウスボタンを押した位置から右に移動すると時計回りに、左に移動すると反時計回りに回転させたい。

public rotateHandle: PIXI.Graphics;
public rotateHandlePrevX: number;

constructor(x: number, y: number, width: number, height: number) {
  // 省略

  this.rotateHandle = new PIXI.Graphics();
  this.container.addChild(this.rotateHandle);
  this.rotateHandle.interactive = true;
  this.rotateHandle.beginFill(0xfa1f5e);
  this.rotateHandle.drawRect(-2, 0, 4, 60);
  this.rotateHandle.lineStyle(4, 0xfa1f5e);
  this.rotateHandle.beginFill(0x0d1c2f);
  this.rotateHandle.drawCircle(0, 0, 20);
  this.rotateHandle.endFill();
  this.rotateHandle.x = 0;
  this.rotateHandle.y = -this.height / 2 - 60;
  this.rotateHandlePrevX = 0;

  const onMouseMoveRotateHandle = (event: PIXI.FederatedPointerEvent) => {
    const dx = event.globalX - this.rotateHandlePrevX;
    this.container.rotation += dx / 150;

    this.rotateHandlePrevX = event.globalX;
    this.draw();
  };

  this.rotateHandle.on("pointerdown", (event: PIXI.FederatedPointerEvent) => {
    event.stopPropagation();

    this.rotateHandlePrevX = event.globalX;

    dragItem = { target: this.rotateHandle, onPointerMove: onMouseMoveRotateHandle };
    app.stage.on("pointermove", onMouseMoveRotateHandle);
  });

  // 省略
}

rotateHandle という描画オブジェクトを作り、ハンドルを描画して、矩形オブジェクト本体である container に貼り付ける。

rotateHandle にもマウスイベントを登録できるのだが、たとえば rotateHandle をクリックすると、親オブジェクトである container にもクリックイベントが伝わってしまう。これを対処しないと、ハンドルを回すたびに本体のドラッグ機能も実行されてしまうので、container 側にマウスイベントを伝えたくない。このために stopPropagation() という関数を実行すると、マウスイベントが親に伝わるのを止めることができる。

const dx = event.globalX - this.rotateHandlePrevX;
this.container.rotation += dx / 150;

ドラッグ中はマウスのX座標を毎回保持しておいて、前回との差分を見てどれだけ回転をするかを決めれば良い。差分をそのままラジアンに指定すると値が大きすぎるので、適当な定数で割って調整をする。

リサイズ用のハンドルを作る

次に、回転用のハンドルと同じようにリサイズ用のハンドルを描画して、矩形オブジェクトの右下に配置する。

constructor(x: number, y: number, width: number, height: number) {
  // 省略

  this.resizeHandle = new PIXI.Graphics();
  this.container.addChild(this.resizeHandle);
  this.resizeHandle.interactive = true;
  this.resizeHandle.beginFill(0xfa1f5e);
  this.resizeHandle.drawCircle(0, 0, 20);
  this.resizeHandle.endFill();
  this.resizeHandle.x = this.width / 2;
  this.resizeHandle.y = this.height / 2;

  // 省略
}

リサイズ用のハンドルを動かすと矩形オブジェクトのサイズを変える

const onMouseMoveResizeHandle = (event: PIXI.FederatedPointerEvent) => {
  this.width = (event.globalX - this.container.x) * 2;
  this.height = (event.globalY - this.container.y) * 2;

  this.resizeHandle.x = this.width / 2;
  this.resizeHandle.y = this.height / 2;
  this.rotateHandle.y = -this.height / 2 - 60;

  this.draw();
};

this.resizeHandle.on("pointerdown", (event: PIXI.FederatedPointerEvent) => {
  event.stopPropagation();

  dragItem = { target: this.resizeHandle, onPointerMove: onMouseMoveResizeHandle };
  app.stage.on("pointermove", onMouseMoveResizeHandle);
});

stopPropagation() で親にイベントを伝えないようにして、dragItem に必要データを入れ、対応する pointermove 関数を呼ぶところまでは回転ハンドルの処理と同じになる。

this.width = (event.globalX - this.resizeHandleDownDiffX - this.container.x) * 2;
this.height = (event.globalY - this.resizeHandleDownDiffY - this.container.y) * 2;

リサイズハンドルをドラッグしたとき、リサイズハンドルの位置に矩形オブジェクトの右下部分が来るようにしたい。そのため、X軸で考えると、event.globalX - this.container.x でマウスボタンがある位置のローカル座標を求めて、その2倍のサイズがオブジェクトの幅(width)になる。Y軸の処理も同様である。

【問題点】なにか動きがおかしい

この状態で実行して、リサイズハンドルをドラッグすると、たしかにマウスボタンがある位置に矩形オブジェクトの右下が来るようにサイズが調整されるのだが、なにか動きがおかしい。

よく見てみると、リサイズハンドルを動かすたびに、左右対称、あるいは上下対称にオブジェクトのリサイズが行われていることがわかる。

このような操作を行う場合、左上部分が動かないように固定しないと使いづらいと思われる。

更にいうと、リサイズハンドルのどの位置を押しても、この後、マウスボタンの位置にリサイズハンドルの中央部分が移動するので、ドラッグの場合と同じように微調整を行う必要がある。

リサイズハンドルを動かしたときに左上が固定されるようにする

まずは前者の対応から行う。

const onMouseMoveResizeHandle = (event: PIXI.FederatedPointerEvent) => {
  const localTopLeftX = this.container.x - this.width / 2;
  const localTopLeftY = this.container.y - this.height / 2;

  this.width = event.globalX - localTopLeftX;
  this.height = event.globalY - localTopLeftY;

  this.container.x = localTopLeftX + this.width / 2;
  this.container.y = localTopLeftY + this.height / 2;

  this.resizeHandle.x = this.width / 2;
  this.resizeHandle.y = this.height / 2;
  this.rotateHandle.y = -this.height / 2 - 60;

  this.draw();
};

矩形オブジェクトの左上を固定するために、まずは左上位置を計算して localTopLeft に入れておく。
新しい幅と高さは、現在のマウス座標と localTopLeft の差になるので、それを width, height に入れる。
そして、新しいオブジェクトの基準位置はX座標だと localTopLeftX + this.width / 2 になり、Y座標も同様の計算になる。

リサイズハンドル押下時の微調整

public resizeHandleDownDiffX: number;
public resizeHandleDownDiffY: number;

constructor(x: number, y: number, width: number, height: number) {
  // 省略

  this.resizeHandleDownDiffX = 0;
  this.resizeHandleDownDiffY = 0;

  const onMouseMoveResizeHandle = (event: PIXI.FederatedPointerEvent) => {
    const localTopLeftX = this.container.x - this.width / 2;
    const localTopLeftY = this.container.y - this.height / 2;

    this.width = event.globalX - this.resizeHandleDownDiffX - localTopLeftX;
    this.height = event.globalY - this.resizeHandleDownDiffY - localTopLeftY;

    this.container.x = localTopLeftX + this.width / 2;
    this.container.y = localTopLeftY + this.height / 2;

    this.resizeHandle.x = this.width / 2;
    this.resizeHandle.y = this.height / 2;
    this.rotateHandle.y = -this.height / 2 - 60;

    this.draw();
  };

  this.resizeHandle.on("pointerdown", (event: PIXI.FederatedPointerEvent) => {
    event.stopPropagation();

    this.resizeHandleDownDiffX = event.globalX - this.resizeHandle.toGlobal(app.stage).x;
    this.resizeHandleDownDiffY = event.globalY - this.resizeHandle.toGlobal(app.stage).y;

    dragItem = { target: this.resizeHandle, onPointerMove: onMouseMoveResizeHandle };
    app.stage.on("pointermove", onMouseMoveResizeHandle);
  });

  // 省略
}

矩形オブジェクトをドラッグするときの調整と同じで、リサイズハンドルが押されたときに、その位置とリサイズハンドルの中央部分の差を取っておき、幅と高さの設定を行う際に、その差を調整に加えればいい。

【問題点】 まだなにかおかしい

回転した状態でリサイズハンドルを動かすと、リサイズハンドルとは別の位置に右下部分が移動するので根本的にアルゴリズムが間違っていることがわかった。

動作を修正する

this.width = event.globalX - this.resizeHandleDownDiffX - localTopLeftX;
this.height = event.globalY - this.resizeHandleDownDiffY - localTopLeftY;

リサイズハンドルを動かしたときの幅と高さの計算は現在このようになっているが、これは回転した状態だと通用しないようだ。

たとえば A にあるハンドルを A' まで引っ張ったとき、幅と高さの変化量を A' - A として求めると上図の青の dx, dy になるが、実際に正しい変化は赤の dx, dy になる。

なので、矩形の変化量を求める際、回転したオブジェクトを無回転の状態に戻してみよう。そうすると青の変化量と赤の変化量が一致するので計算が合う。

type Point = {
  x: number;
  y: number;
};

const rotate = (x: number, y: number, cx: number, cy: number, angle: number): Point => {
  return {
    x: (x - cx) * Math.cos(angle) - (y - cy) * Math.sin(angle) + cx,
    y: (x - cx) * Math.sin(angle) + (y - cy) * Math.cos(angle) + cy,
  };
};

(cx, cy) を軸に、(x, y) の位置を angle ラジアン回転させた位置を返す、回転行列の関数を用意する。

const onMouseMoveResizeHandle = (event: PIXI.FederatedPointerEvent) => {
  const localTopLeft = rotate(-this.width / 2, -this.height / 2, 0, 0, this.container.rotation);
  const localBottomRightX = event.globalX - this.resizeHandleDownDiffX - this.container.x;
  const localBottomRightY = event.globalY - this.resizeHandleDownDiffY - this.container.y;

  const localCenterX = (localTopLeft.x + localBottomRightX) / 2;
  const localCenterY = (localTopLeft.y + localBottomRightY) / 2;

  const noRotationBottomRight = rotate(localBottomRightX, localBottomRightY, localCenterX, localCenterY, -this.container.rotation);
  const newWidth = (noRotationBottomRight.x - localCenterX) * 2;
  const newHeight = (noRotationBottomRight.y - localCenterY) * 2;
  this.width = newWidth;
  this.height = newHeight;
  this.container.x += localCenterX;
  this.container.y += localCenterY;

  this.resizeHandle.x = this.width / 2;
  this.resizeHandle.y = this.height / 2;
  this.rotateHandle.y = -this.height / 2 - 60;

  this.draw();
};

修正後の onMouseMoveResizeHandle になる。
コードが長いので一つ一つ見ていく。

const localTopLeft = rotate(-this.width / 2, -this.height / 2, 0, 0, this.container.rotation);

まずはリサイズ後のローカル左上座標を求めたい。ハンドルを動かしても左上座標は固定にする仕様なので、左上座標はリサイズ前と同じになる。なので、リサイズ前の無回転だった場合の座標 (-this.width / 2, -this.height / 2) を回転行列で (0, 0) を軸に回転させると目的の座標を計算できる。

const localBottomRightX = event.globalX - this.resizeHandleDownDiffX - this.container.x;
const localBottomRightY = event.globalY - this.resizeHandleDownDiffY - this.container.y;

次にリサイズ後のローカル右下座標を求める。

const localCenterX = (localTopLeft.x + localBottomRightX) / 2;
const localCenterY = (localTopLeft.y + localBottomRightY) / 2;

上記で計算した左上/右下座標を足して2で割ると、リサイズ後のローカル中央座標が求まる。

const noRotationBottomRight = rotate(localBottomRightX, localBottomRightY, localCenterX, localCenterY, -this.container.rotation);
const newWidth = (noRotationBottomRight.x - localCenterX) * 2;
const newHeight = (noRotationBottomRight.y - localCenterY) * 2;
this.width = newWidth;
this.height = newHeight;

リサイズ後の右下座標である (localBottomRightX, localBottomRightY) を リサイズ後の原点である (localCenterX, localCenterY) を軸に0度になるように回転して、無回転状態の右下座標である noRotationBottomRight を求める。

noRotationBottomRight から localCenter までの距離の2倍がリサイズ後の矩形範囲になる。

this.container.x += localCenterX;
this.container.y += localCenterY;

(localCenterX, localCenterY) はリサイズ後のローカル座標なので、現在のグローバル座標である (container.x, container.y) に足し合わせるとリサイズ後のグローバル座標になる。

完成

全体のコード

コード全体を見る
import * as PIXI from "pixi.js";

const app = new PIXI.Application({
  width: 600,
  height: 400,
  backgroundColor: 0x0d1c2f,
  antialias: true,
});
document.body.appendChild(app.view as any);

type DragItem = {
  target: PIXI.Graphics;
  onPointerMove: (event: PIXI.FederatedPointerEvent) => void;
};

let dragItem: DragItem | null = null;

const onPointerUp = (event: PIXI.FederatedPointerEvent) => {
  if (dragItem) {
    app.stage.off("pointermove", dragItem.onPointerMove);
    dragItem = null;
  }
};

app.stage.interactive = true;
app.stage.hitArea = app.screen;
app.stage.on("pointerup", onPointerUp);

type Point = {
  x: number;
  y: number;
};

const rotate = (x: number, y: number, cx: number, cy: number, angle: number): Point => {
  return {
    x: (x - cx) * Math.cos(angle) - (y - cy) * Math.sin(angle) + cx,
    y: (x - cx) * Math.sin(angle) + (y - cy) * Math.cos(angle) + cy,
  };
};

class Rectangle {
  public width: number;
  public height: number;
  public container: PIXI.Graphics;
  public pointerDownDiffX: number;
  public pointerDownDiffY: number;
  public rotateHandle: PIXI.Graphics;
  public rotateHandlePrevX: number;
  public resizeHandle: PIXI.Graphics;
  public resizeHandleDownDiffX: number;
  public resizeHandleDownDiffY: number;

  constructor(x: number, y: number, width: number, height: number) {
    this.width = width;
    this.height = height;

    this.container = new PIXI.Graphics();
    app.stage.addChild(this.container);
    this.container.x = x;
    this.container.y = y;

    this.container.interactive = true;

    this.pointerDownDiffX = 0;
    this.pointerDownDiffY = 0;

    const onPointerMove = (event: PIXI.FederatedPointerEvent) => {
      this.container.x = event.globalX - this.pointerDownDiffX;
      this.container.y = event.globalY - this.pointerDownDiffY;
    };

    this.container.on("pointerdown", (event: PIXI.FederatedPointerEvent) => {
      this.pointerDownDiffX = event.globalX - this.container.x;
      this.pointerDownDiffY = event.globalY - this.container.y;

      app.stage.on("pointermove", onPointerMove);
      dragItem = { target: this.container, onPointerMove };
    });

    this.rotateHandle = new PIXI.Graphics();
    this.container.addChild(this.rotateHandle);
    this.rotateHandle.interactive = true;
    this.rotateHandle.beginFill(0xfa1f5e);
    this.rotateHandle.drawRect(-2, 0, 4, 60);
    this.rotateHandle.lineStyle(4, 0xfa1f5e);
    this.rotateHandle.beginFill(0x0d1c2f);
    this.rotateHandle.drawCircle(0, 0, 20);
    this.rotateHandle.endFill();
    this.rotateHandle.x = 0;
    this.rotateHandle.y = -this.height / 2 - 60;
    this.rotateHandlePrevX = 0;

    const onMouseMoveRotateHandle = (event: PIXI.FederatedPointerEvent) => {
      const dx = event.globalX - this.rotateHandlePrevX;
      this.container.rotation += dx / 150;

      this.rotateHandlePrevX = event.globalX;
      this.draw();
    };

    this.rotateHandle.on("pointerdown", (event: PIXI.FederatedPointerEvent) => {
      event.stopPropagation();

      this.rotateHandlePrevX = event.globalX;

      dragItem = {
        target: this.rotateHandle,
        onPointerMove: onMouseMoveRotateHandle,
      };
      app.stage.on("pointermove", onMouseMoveRotateHandle);
    });

    this.resizeHandle = new PIXI.Graphics();
    this.container.addChild(this.resizeHandle);
    this.resizeHandle.interactive = true;
    this.resizeHandle.beginFill(0xfa1f5e);
    this.resizeHandle.drawCircle(0, 0, 20);
    this.resizeHandle.endFill();
    this.resizeHandle.x = this.width / 2;
    this.resizeHandle.y = this.height / 2;
    this.resizeHandleDownDiffX = 0;
    this.resizeHandleDownDiffY = 0;

    const onMouseMoveResizeHandle = (event: PIXI.FederatedPointerEvent) => {
      const localTopLeft = rotate(-this.width / 2, -this.height / 2, 0, 0, this.container.rotation);
      const localBottomRightX = event.globalX - this.resizeHandleDownDiffX - this.container.x;
      const localBottomRightY = event.globalY - this.resizeHandleDownDiffY - this.container.y;

      const localCenterX = (localTopLeft.x + localBottomRightX) / 2;
      const localCenterY = (localTopLeft.y + localBottomRightY) / 2;

      const noRotationBottomRight = rotate(localBottomRightX, localBottomRightY, localCenterX, localCenterY, -this.container.rotation);
      const newWidth = (noRotationBottomRight.x - localCenterX) * 2;
      const newHeight = (noRotationBottomRight.y - localCenterY) * 2;
      this.width = newWidth;
      this.height = newHeight;
      this.container.x += localCenterX;
      this.container.y += localCenterY;

      this.resizeHandle.x = this.width / 2;
      this.resizeHandle.y = this.height / 2;
      this.rotateHandle.y = -this.height / 2 - 60;

      this.draw();
    };

    this.resizeHandle.on("pointerdown", (event: PIXI.FederatedPointerEvent) => {
      event.stopPropagation();

      this.resizeHandleDownDiffX = event.globalX - this.resizeHandle.toGlobal(app.stage).x;
      this.resizeHandleDownDiffY = event.globalY - this.resizeHandle.toGlobal(app.stage).y;

      dragItem = {
        target: this.resizeHandle,
        onPointerMove: onMouseMoveResizeHandle,
      };
      app.stage.on("pointermove", onMouseMoveResizeHandle);
    });

    this.draw();
  }

  public draw(): void {
    this.container.clear();
    this.container.beginFill(0xffffff);
    this.container.drawRect(-this.width / 2, -this.height / 2, this.width, this.height);
    this.container.endFill();

    this.container.beginFill(0x0d1c2f);
    this.container.drawRect(-11, -2, 20, 2);
    this.container.drawRect(-2, -11, 2, 20);
    this.container.endFill();
  }
}

new Rectangle(app.renderer.width / 2, app.renderer.height / 2, 200, 150);

Discussion