refboard: add corner rotation grips
This commit is contained in:
@@ -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
|
* Shows 8 resize handles (corners + edge midpoints) around the combined
|
||||||
* bounding rect of selected items. Each handle is draggable and applies
|
* bounding rect of selected items, plus corner rotation grips offset slightly
|
||||||
* scale transforms to the items.
|
* 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';
|
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_COLOR = 0x4a90d9;
|
||||||
const BORDER_WIDTH = 1.5;
|
const BORDER_WIDTH = 1.5;
|
||||||
const HANDLE_SIZE = 8;
|
const HANDLE_SIZE = 8;
|
||||||
|
const ROTATE_HANDLE_SIZE = 10;
|
||||||
|
const ROTATE_HANDLE_OFFSET = 16;
|
||||||
const HANDLE_FILL = 0xffffff;
|
const HANDLE_FILL = 0xffffff;
|
||||||
const HANDLE_STROKE = 0x4a90d9;
|
const HANDLE_STROKE = 0x4a90d9;
|
||||||
|
const ROTATE_HANDLE_FILL = 0x4a90d9;
|
||||||
|
|
||||||
type HandleId = 'tl' | 'tc' | 'tr' | 'ml' | 'mr' | 'bl' | 'bc' | 'br';
|
type HandleId = 'tl' | 'tc' | 'tr' | 'ml' | 'mr' | 'bl' | 'bc' | 'br';
|
||||||
|
type RotateHandleId = 'tl' | 'tr' | 'bl' | 'br';
|
||||||
|
|
||||||
const HANDLE_CURSORS: Record<HandleId, string> = {
|
const HANDLE_CURSORS: Record<HandleId, string> = {
|
||||||
tl: 'nwse-resize',
|
tl: 'nwse-resize',
|
||||||
@@ -43,11 +48,15 @@ const HANDLE_CURSORS: Record<HandleId, string> = {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface DragState {
|
interface DragState {
|
||||||
|
mode: 'resize' | 'rotate';
|
||||||
handleId: HandleId;
|
handleId: HandleId;
|
||||||
startX: number;
|
startX: number;
|
||||||
startY: number;
|
startY: number;
|
||||||
origBounds: { x: number; y: number; w: number; h: 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 } }>;
|
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 {
|
export class TransformBox extends Container {
|
||||||
private _border: Graphics;
|
private _border: Graphics;
|
||||||
private _handles: Map<HandleId, Graphics> = new Map();
|
private _handles: Map<HandleId, Graphics> = new Map();
|
||||||
|
private _rotateHandles: Map<RotateHandleId, Graphics> = new Map();
|
||||||
private _items: SceneItem[] = [];
|
private _items: SceneItem[] = [];
|
||||||
private _bounds = { x: 0, y: 0, w: 0, h: 0 };
|
private _bounds = { x: 0, y: 0, w: 0, h: 0 };
|
||||||
private _drag: DragState | null = null;
|
private _drag: DragState | null = null;
|
||||||
@@ -127,6 +137,22 @@ export class TransformBox extends Container {
|
|||||||
this._handles.set(id, handle);
|
this._handles.set(id, handle);
|
||||||
this.addChild(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 -----------------------------------------------------------
|
// -- Public API -----------------------------------------------------------
|
||||||
@@ -202,6 +228,9 @@ export class TransformBox extends Container {
|
|||||||
// Counter-scale handles so they stay the same screen size at any zoom
|
// Counter-scale handles so they stay the same screen size at any zoom
|
||||||
const s = HANDLE_SIZE / zoom;
|
const s = HANDLE_SIZE / zoom;
|
||||||
const half = s / 2;
|
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) {
|
for (const [id, handle] of this._handles) {
|
||||||
// No resize handles for sticky-only selections (fixed width, auto height)
|
// 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);
|
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 ----------------------------------------------------------
|
// -- Handle drag ----------------------------------------------------------
|
||||||
@@ -248,6 +294,7 @@ export class TransformBox extends Container {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this._drag = {
|
this._drag = {
|
||||||
|
mode: 'resize',
|
||||||
handleId: id,
|
handleId: id,
|
||||||
startX: e.global.x,
|
startX: e.global.x,
|
||||||
startY: e.global.y,
|
startY: e.global.y,
|
||||||
@@ -260,12 +307,103 @@ export class TransformBox extends Container {
|
|||||||
// this._snapGuides?.beginSession(itemIds);
|
// 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 {
|
private _onHandleMove(e: FederatedPointerEvent): void {
|
||||||
if (!this._drag) return;
|
if (!this._drag) return;
|
||||||
|
|
||||||
// Convert mouse position to world space
|
// Convert mouse position to world space
|
||||||
const world = this._viewport!.toWorld(e.global.x, e.global.y);
|
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
|
// Compute scale factors: new size / original size
|
||||||
// Each handle has a fixed edge — the opposite side stays put
|
// Each handle has a fixed edge — the opposite side stays put
|
||||||
|
|||||||
Reference in New Issue
Block a user