refboard: add corner rotation grips

This commit is contained in:
Hiren Kangad
2026-03-13 15:10:45 +05:30
parent 8d34d42377
commit c21a0c386d
+142 -4
View File
@@ -1,9 +1,10 @@
/**
* TransformBox — resize handles around selected scene items.
* TransformBox — resize and rotation handles around selected scene items.
*
* Shows 8 resize handles (corners + edge midpoints) around the combined
* bounding rect of selected items. Each handle is draggable and applies
* scale transforms to the items.
* bounding rect of selected items, plus corner rotation grips offset slightly
* outward. All interactions derive from the same visible-bounds geometry so
* crop/flip/rotate stay aligned.
*/
import { Container, Graphics, FederatedPointerEvent, Text, TextStyle } from 'pixi.js';
@@ -22,10 +23,14 @@ import type { ImageObject } from './scene-format';
const BORDER_COLOR = 0x4a90d9;
const BORDER_WIDTH = 1.5;
const HANDLE_SIZE = 8;
const ROTATE_HANDLE_SIZE = 10;
const ROTATE_HANDLE_OFFSET = 16;
const HANDLE_FILL = 0xffffff;
const HANDLE_STROKE = 0x4a90d9;
const ROTATE_HANDLE_FILL = 0x4a90d9;
type HandleId = 'tl' | 'tc' | 'tr' | 'ml' | 'mr' | 'bl' | 'bc' | 'br';
type RotateHandleId = 'tl' | 'tr' | 'bl' | 'br';
const HANDLE_CURSORS: Record<HandleId, string> = {
tl: 'nwse-resize',
@@ -43,11 +48,15 @@ const HANDLE_CURSORS: Record<HandleId, string> = {
// ---------------------------------------------------------------------------
interface DragState {
mode: 'resize' | 'rotate';
handleId: HandleId;
startX: number;
startY: number;
origBounds: { x: number; y: number; w: number; h: number };
origTransforms: Map<string, { sx: number; sy: number; angle: number; x: number; y: number; w: number; bounds: { x: number; y: number; w: number; h: number } }>;
centerX?: number;
centerY?: number;
startAngleDeg?: number;
}
// ---------------------------------------------------------------------------
@@ -57,6 +66,7 @@ interface DragState {
export class TransformBox extends Container {
private _border: Graphics;
private _handles: Map<HandleId, Graphics> = new Map();
private _rotateHandles: Map<RotateHandleId, Graphics> = new Map();
private _items: SceneItem[] = [];
private _bounds = { x: 0, y: 0, w: 0, h: 0 };
private _drag: DragState | null = null;
@@ -127,6 +137,22 @@ export class TransformBox extends Container {
this._handles.set(id, handle);
this.addChild(handle);
}
const rotateIds: RotateHandleId[] = ['tl', 'tr', 'bl', 'br'];
for (const id of rotateIds) {
const handle = new Graphics();
handle.eventMode = 'static';
handle.cursor = 'grab';
handle.label = `rotate_handle_${id}`;
handle.on('pointerdown', (e: FederatedPointerEvent) => this._onRotateHandleDown(e, id));
handle.on('globalpointermove', (e: FederatedPointerEvent) => this._onHandleMove(e));
handle.on('pointerup', () => this._onHandleUp());
handle.on('pointerupoutside', () => this._onHandleUp());
this._rotateHandles.set(id, handle);
this.addChild(handle);
}
}
// -- Public API -----------------------------------------------------------
@@ -202,6 +228,9 @@ export class TransformBox extends Container {
// Counter-scale handles so they stay the same screen size at any zoom
const s = HANDLE_SIZE / zoom;
const half = s / 2;
const rotateSize = ROTATE_HANDLE_SIZE / zoom;
const rotateHalf = rotateSize / 2;
const rotateOffset = ROTATE_HANDLE_OFFSET / zoom;
for (const [id, handle] of this._handles) {
// No resize handles for sticky-only selections (fixed width, auto height)
@@ -227,6 +256,23 @@ export class TransformBox extends Container {
handle.position.set(pos.px, pos.py);
}
const rotatePositions: Record<RotateHandleId, { px: number; py: number }> = {
tl: { px: x - rotateOffset, py: y - rotateOffset },
tr: { px: x + w + rotateOffset, py: y - rotateOffset },
bl: { px: x - rotateOffset, py: y + h + rotateOffset },
br: { px: x + w + rotateOffset, py: y + h + rotateOffset },
};
for (const [id, handle] of this._rotateHandles) {
handle.visible = true;
handle.eventMode = 'static';
handle.clear();
handle.circle(0, 0, rotateHalf);
handle.fill({ color: ROTATE_HANDLE_FILL, alpha: 0.9 });
handle.stroke({ color: HANDLE_FILL, width: 1 / zoom });
handle.position.set(rotatePositions[id].px, rotatePositions[id].py);
}
}
// -- Handle drag ----------------------------------------------------------
@@ -248,6 +294,7 @@ export class TransformBox extends Container {
}
this._drag = {
mode: 'resize',
handleId: id,
startX: e.global.x,
startY: e.global.y,
@@ -260,12 +307,103 @@ export class TransformBox extends Container {
// this._snapGuides?.beginSession(itemIds);
}
private _onRotateHandleDown(e: FederatedPointerEvent, id: RotateHandleId): void {
e.stopPropagation();
const origTransforms = new Map<string, { sx: number; sy: number; angle: number; x: number; y: number; w: number; bounds: { x: number; y: number; w: number; h: number } }>();
for (const item of this._items) {
origTransforms.set(item.id, {
sx: item.data.sx,
sy: item.data.sy,
angle: item.data.angle,
x: item.data.x,
y: item.data.y,
w: item.data.w,
bounds: getItemWorldBounds(item),
});
}
const centerX = this._bounds.x + this._bounds.w / 2;
const centerY = this._bounds.y + this._bounds.h / 2;
const world = this._viewport!.toWorld(e.global.x, e.global.y);
const startAngleDeg = Math.atan2(world.y - centerY, world.x - centerX) * (180 / Math.PI);
this._drag = {
mode: 'rotate',
handleId: id,
startX: e.global.x,
startY: e.global.y,
origBounds: { ...this._bounds },
origTransforms,
centerX,
centerY,
startAngleDeg,
};
}
private _onHandleMove(e: FederatedPointerEvent): void {
if (!this._drag) return;
// Convert mouse position to world space
const world = this._viewport!.toWorld(e.global.x, e.global.y);
const { handleId, origBounds: ob, origTransforms } = this._drag;
const { mode, handleId, origBounds: ob, origTransforms } = this._drag;
if (mode === 'rotate') {
const centerX = this._drag.centerX ?? (ob.x + ob.w / 2);
const centerY = this._drag.centerY ?? (ob.y + ob.h / 2);
const startAngleDeg = this._drag.startAngleDeg ?? 0;
let currentAngleDeg = Math.atan2(world.y - centerY, world.x - centerX) * (180 / Math.PI);
let deltaDeg = currentAngleDeg - startAngleDeg;
if (e.shiftKey) deltaDeg = Math.round(deltaDeg / 15) * 15;
const rad = (deltaDeg * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
for (const item of this._items) {
const orig = origTransforms.get(item.id);
if (!orig) continue;
const origBounds = orig.bounds;
const itemCenterX = origBounds.x + origBounds.w / 2;
const itemCenterY = origBounds.y + origBounds.h / 2;
const dx = itemCenterX - centerX;
const dy = itemCenterY - centerY;
const nextCenterX = centerX + dx * cos - dy * sin;
const nextCenterY = centerY + dx * sin + dy * cos;
item.data.x = orig.x;
item.data.y = orig.y;
item.data.sx = orig.sx;
item.data.sy = orig.sy;
item.data.angle = ((orig.angle + deltaDeg) % 360 + 360) % 360;
if (item.type === 'image') {
applyImageDisplayTransform(item.displayObject, item.data as ImageObject);
} else {
item.displayObject.position.set(item.data.x, item.data.y);
item.displayObject.scale.set(item.data.sx, item.data.sy);
item.displayObject.angle = item.data.angle;
}
const currentBounds = getItemWorldBounds(item);
item.data.x += nextCenterX - (currentBounds.x + currentBounds.w / 2);
item.data.y += nextCenterY - (currentBounds.y + currentBounds.h / 2);
if (item.type === 'image') {
applyImageDisplayTransform(item.displayObject, item.data as ImageObject);
} else {
item.displayObject.position.set(item.data.x, item.data.y);
item.displayObject.scale.set(item.data.sx, item.data.sy);
item.displayObject.angle = item.data.angle;
}
this._onItemTransform?.(item);
}
this.update(this._items);
return;
}
// Compute scale factors: new size / original size
// Each handle has a fixed edge — the opposite side stays put