- Batch broadcastTransform: sends all selected items in one emit instead of throttle-dropping all but the first (multi-select drag now syncs all) - TransformBox onDragEnd: persist+sync final state after resize/rotate (was only broadcasting ephemeral transforms, never the final position) - Layer lock toggle: add missing onCanvasChange() call for sync+save - Receiver handles both batched and legacy single-item transform formats
463 lines
15 KiB
TypeScript
463 lines
15 KiB
TypeScript
/**
|
|
* SelectionManager — click, shift-click, and rubber-band selection for scene items.
|
|
*
|
|
* Manages the selected set, rubber-band rectangle visual, hit testing,
|
|
* and coordinates with TransformBox to show resize/rotate handles.
|
|
*/
|
|
|
|
import { Container, Graphics, FederatedPointerEvent } from 'pixi.js';
|
|
import type { Viewport } from 'pixi-viewport';
|
|
import { SceneManager, SceneItem, getItemWorldBounds } from './SceneManager';
|
|
import { TransformBox } from './TransformBox';
|
|
import { SnapGuides } from './SnapGuides';
|
|
import { ImageSprite } from './sprites/ImageSprite';
|
|
import { VideoSprite } from './sprites/VideoSprite';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const BAND_FILL = 0x4a90d9;
|
|
const BAND_FILL_ALPHA = 0.15;
|
|
const BAND_STROKE = 0x4a90d9;
|
|
const BAND_STROKE_ALPHA = 0.6;
|
|
const BAND_STROKE_WIDTH = 1;
|
|
const RUBBER_BAND_THRESHOLD = 5; // px in screen space before rubber band activates
|
|
const DRAG_THRESHOLD = 5; // px in screen space before object drag activates
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SelectionManager
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export class SelectionManager {
|
|
readonly selectedIds: Set<string> = new Set();
|
|
readonly transformBox: TransformBox;
|
|
private _snapGuides: SnapGuides;
|
|
|
|
private _viewport: Viewport;
|
|
private _scene: SceneManager;
|
|
private _overlay: Container;
|
|
private _bandGfx: Graphics;
|
|
private _onSelectionChange: ((ids: string[]) => void) | null = null;
|
|
private _onItemTransform: ((item: SceneItem) => void) | null = null;
|
|
private _onItemsTransform: ((items: SceneItem[]) => void) | null = null;
|
|
|
|
// Pointer state
|
|
private _pointerDown = false;
|
|
private _downScreenX = 0;
|
|
private _downScreenY = 0;
|
|
private _downWorldX = 0;
|
|
private _downWorldY = 0;
|
|
private _rubberBanding = false;
|
|
private _hitItemOnDown: SceneItem | null = null;
|
|
|
|
// Object drag state
|
|
private _objectDragging = false;
|
|
private _lastDragWorldX = 0;
|
|
private _lastDragWorldY = 0;
|
|
|
|
// Double-click detection
|
|
private _lastClickTime = 0;
|
|
private _lastClickItemId: string | null = null;
|
|
private static readonly DOUBLE_CLICK_MS = 400;
|
|
|
|
private _onDoubleClickText: ((item: SceneItem) => void) | null = null;
|
|
|
|
private _enabled = true;
|
|
|
|
get snapGuides(): SnapGuides { return this._snapGuides; }
|
|
|
|
constructor(viewport: Viewport, scene: SceneManager) {
|
|
this._viewport = viewport;
|
|
this._scene = scene;
|
|
|
|
// Overlay container (lives on top of everything in viewport)
|
|
this._overlay = new Container();
|
|
this._overlay.label = '__selection_overlay';
|
|
viewport.addChild(this._overlay);
|
|
|
|
// Rubber band graphic
|
|
this._bandGfx = new Graphics();
|
|
this._bandGfx.visible = false;
|
|
this._overlay.addChild(this._bandGfx);
|
|
|
|
// Transform box
|
|
this.transformBox = new TransformBox();
|
|
this.transformBox.setViewport(viewport);
|
|
this._overlay.addChild(this.transformBox);
|
|
|
|
// Snap guides
|
|
this._snapGuides = new SnapGuides(scene, this._overlay);
|
|
|
|
// Bind events on viewport
|
|
viewport.on('pointerdown', this._onPointerDown, this);
|
|
viewport.on('globalpointermove', this._onPointerMove, this);
|
|
viewport.on('pointerup', this._onPointerUp, this);
|
|
viewport.on('pointerupoutside', this._onPointerUp, this);
|
|
}
|
|
|
|
/** Enable/disable selection interaction (disable during draw/text/eraser tools). */
|
|
setEnabled(enabled: boolean): void {
|
|
this._enabled = enabled;
|
|
if (!enabled) {
|
|
this._pointerDown = false;
|
|
this._rubberBanding = false;
|
|
this._bandGfx.visible = false;
|
|
}
|
|
}
|
|
|
|
// -- Public API -----------------------------------------------------------
|
|
|
|
set onSelectionChange(fn: (ids: string[]) => void) {
|
|
this._onSelectionChange = fn;
|
|
}
|
|
|
|
/** Called during drag with each moved item for live sync broadcast. */
|
|
set onItemTransform(fn: (item: SceneItem) => void) {
|
|
this._onItemTransform = fn;
|
|
}
|
|
|
|
/** Called during drag with ALL moved items for batched sync broadcast. */
|
|
set onItemsTransform(fn: (items: SceneItem[]) => void) {
|
|
this._onItemsTransform = fn;
|
|
}
|
|
|
|
/** Called on double-click of a text item (for inline editing). */
|
|
set onDoubleClickText(fn: (item: SceneItem) => void) {
|
|
this._onDoubleClickText = fn;
|
|
}
|
|
|
|
/** Select only this item, deselecting everything else. */
|
|
selectOnly(id: string): void {
|
|
this.selectedIds.clear();
|
|
this.selectedIds.add(id);
|
|
this._emitChange();
|
|
}
|
|
|
|
/** Toggle an item in/out of the selection. */
|
|
toggle(id: string): void {
|
|
if (this.selectedIds.has(id)) {
|
|
this.selectedIds.delete(id);
|
|
} else {
|
|
this.selectedIds.add(id);
|
|
}
|
|
this._emitChange();
|
|
}
|
|
|
|
/** Select all unlocked, visible items. */
|
|
selectAll(): void {
|
|
this.selectedIds.clear();
|
|
for (const item of this._scene.getTopLevelItems()) {
|
|
if (!item.data.locked && item.data.visible) {
|
|
this.selectedIds.add(item.id);
|
|
}
|
|
}
|
|
this._emitChange();
|
|
}
|
|
|
|
/** Clear the entire selection. */
|
|
clear(): void {
|
|
if (this.selectedIds.size === 0) return;
|
|
this.selectedIds.clear();
|
|
this._emitChange();
|
|
}
|
|
|
|
/** Return the SceneItems for the current selection. */
|
|
getSelectedItems(): SceneItem[] {
|
|
const items: SceneItem[] = [];
|
|
for (const id of this.selectedIds) {
|
|
const item = this._scene.getById(id);
|
|
if (item) items.push(item);
|
|
}
|
|
return items;
|
|
}
|
|
|
|
// -- Hit Testing ----------------------------------------------------------
|
|
|
|
/** Check all scene items in reverse z-order; return first whose world bounds contain (wx, wy). */
|
|
_hitTest(wx: number, wy: number): SceneItem | null {
|
|
const all = this._scene.getTopLevelItems();
|
|
// Sort by z descending (topmost first)
|
|
all.sort((a, b) => b.data.z - a.data.z);
|
|
|
|
for (const item of all) {
|
|
if (item.data.locked) continue;
|
|
if (!item.data.visible) continue;
|
|
|
|
const { x: ix, y: iy, w: iw, h: ih } = getItemWorldBounds(item);
|
|
|
|
if (ix <= wx && wx <= ix + iw && iy <= wy && wy <= iy + ih) {
|
|
return item;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// -- Pointer Handlers -----------------------------------------------------
|
|
|
|
private _onPointerDown(e: FederatedPointerEvent): void {
|
|
if (!this._enabled) return;
|
|
// Only handle primary button
|
|
if (e.button !== 0) return;
|
|
|
|
const world = this._viewport.toWorld(e.global.x, e.global.y);
|
|
this._pointerDown = true;
|
|
this._downScreenX = e.global.x;
|
|
this._downScreenY = e.global.y;
|
|
this._downWorldX = world.x;
|
|
this._downWorldY = world.y;
|
|
this._rubberBanding = false;
|
|
|
|
this._hitItemOnDown = this._hitTest(world.x, world.y);
|
|
|
|
if (this._hitItemOnDown) {
|
|
// Clicked on an object
|
|
const item = this._hitItemOnDown;
|
|
if (e.shiftKey) {
|
|
this.toggle(item.id);
|
|
} else if (!this.selectedIds.has(item.id)) {
|
|
this.selectOnly(item.id);
|
|
}
|
|
// If already selected and no shift, keep selection (for potential drag)
|
|
}
|
|
// Empty space click handling deferred to pointerup (to distinguish from rubber band)
|
|
}
|
|
|
|
private _onPointerMove(e: FederatedPointerEvent): void {
|
|
if (!this._pointerDown) return;
|
|
|
|
const dx = e.global.x - this._downScreenX;
|
|
const dy = e.global.y - this._downScreenY;
|
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
|
|
// Object drag: clicked on a selected item
|
|
if (this._hitItemOnDown && this.selectedIds.has(this._hitItemOnDown.id)) {
|
|
if (!this._objectDragging && dist >= DRAG_THRESHOLD) {
|
|
this._objectDragging = true;
|
|
this._lastDragWorldX = this._downWorldX;
|
|
this._lastDragWorldY = this._downWorldY;
|
|
|
|
// Pause viewport drag so object drag works
|
|
this._viewport.plugins.pause('drag');
|
|
|
|
// Lift shadow + spring scale on all selected image sprites
|
|
this._applyLift();
|
|
|
|
// Begin snap guide session
|
|
this._snapGuides.beginSession(this.selectedIds);
|
|
}
|
|
|
|
if (this._objectDragging) {
|
|
const currentWorld = this._viewport.toWorld(e.global.x, e.global.y);
|
|
let ddx = currentWorld.x - this._lastDragWorldX;
|
|
let ddy = currentWorld.y - this._lastDragWorldY;
|
|
this._lastDragWorldX = currentWorld.x;
|
|
this._lastDragWorldY = currentWorld.y;
|
|
|
|
// Compute combined bounds of selected items after applying delta
|
|
const selected = this.getSelectedItems();
|
|
const prospective = this._getSelectionBounds(selected);
|
|
prospective.x += ddx;
|
|
prospective.y += ddy;
|
|
|
|
// Snap to alignment guides
|
|
const snap = this._snapGuides.computeSnap(prospective, this._viewport);
|
|
ddx += snap.dx;
|
|
ddy += snap.dy;
|
|
this._snapGuides.drawGuides(snap.guides, this._viewport);
|
|
|
|
// Move all selected items by corrected delta and broadcast all together
|
|
for (const item of selected) {
|
|
item.displayObject.x += ddx;
|
|
item.displayObject.y += ddy;
|
|
item.data.x = item.displayObject.x;
|
|
item.data.y = item.displayObject.y;
|
|
}
|
|
if (this._onItemsTransform) {
|
|
this._onItemsTransform(selected);
|
|
} else if (this._onItemTransform) {
|
|
for (const item of selected) this._onItemTransform(item);
|
|
}
|
|
this.transformBox.update(selected);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Rubber band: clicked on empty space
|
|
if (this._hitItemOnDown) return;
|
|
|
|
if (!this._rubberBanding && dist >= RUBBER_BAND_THRESHOLD) {
|
|
this._rubberBanding = true;
|
|
// Pause viewport drag so rubber band works
|
|
this._viewport.plugins.pause('drag');
|
|
}
|
|
|
|
if (this._rubberBanding) {
|
|
const currentWorld = this._viewport.toWorld(e.global.x, e.global.y);
|
|
this._drawRubberBand(this._downWorldX, this._downWorldY, currentWorld.x, currentWorld.y);
|
|
}
|
|
}
|
|
|
|
private _onPointerUp(e: FederatedPointerEvent): void {
|
|
if (!this._pointerDown) return;
|
|
this._pointerDown = false;
|
|
|
|
if (this._objectDragging) {
|
|
// End object drag — drop shadow + spring scale back
|
|
this._applyDrop();
|
|
this._snapGuides.endSession();
|
|
this._objectDragging = false;
|
|
|
|
// Resume viewport drag
|
|
this._viewport.plugins.resume('drag');
|
|
|
|
// Notify change (positions updated)
|
|
this._emitChange();
|
|
} else if (this._rubberBanding) {
|
|
// Finish rubber band selection
|
|
const currentWorld = this._viewport.toWorld(e.global.x, e.global.y);
|
|
this._finishRubberBand(this._downWorldX, this._downWorldY, currentWorld.x, currentWorld.y, e.shiftKey);
|
|
this._bandGfx.clear();
|
|
this._bandGfx.visible = false;
|
|
this._rubberBanding = false;
|
|
|
|
// Resume viewport drag
|
|
this._viewport.plugins.resume('drag');
|
|
} else if (!this._hitItemOnDown) {
|
|
// Clicked on empty space without dragging → deselect all
|
|
if (!e.shiftKey) {
|
|
this.clear();
|
|
}
|
|
} else if (this._hitItemOnDown && !this._objectDragging) {
|
|
// Click (no drag) on an item — check for double-click
|
|
const now = Date.now();
|
|
const item = this._hitItemOnDown;
|
|
if (
|
|
this._lastClickItemId === item.id &&
|
|
now - this._lastClickTime < SelectionManager.DOUBLE_CLICK_MS
|
|
) {
|
|
// Double-click detected
|
|
if (item.displayObject instanceof VideoSprite) {
|
|
item.displayObject.togglePlayPause();
|
|
} else if (item.type === 'text') {
|
|
this._onDoubleClickText?.(item);
|
|
}
|
|
this._lastClickTime = 0;
|
|
this._lastClickItemId = null;
|
|
} else {
|
|
this._lastClickTime = now;
|
|
this._lastClickItemId = item.id;
|
|
}
|
|
}
|
|
|
|
this._hitItemOnDown = null;
|
|
}
|
|
|
|
// -- Drag Shadow Lift / Drop ----------------------------------------------
|
|
|
|
/** Lift shadows on selected items for drag. */
|
|
private _applyLift(): void {
|
|
for (const item of this.getSelectedItems()) {
|
|
const obj = item.displayObject;
|
|
if (obj instanceof ImageSprite || obj instanceof VideoSprite) {
|
|
obj.liftShadow();
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Drop shadows on selected items after drag. */
|
|
private _applyDrop(): void {
|
|
for (const item of this.getSelectedItems()) {
|
|
const obj = item.displayObject;
|
|
if (obj instanceof ImageSprite || obj instanceof VideoSprite) {
|
|
obj.dropShadow();
|
|
}
|
|
}
|
|
}
|
|
|
|
// -- Rubber Band ----------------------------------------------------------
|
|
|
|
private _drawRubberBand(x1: number, y1: number, x2: number, y2: number): void {
|
|
const rx = Math.min(x1, x2);
|
|
const ry = Math.min(y1, y2);
|
|
const rw = Math.abs(x2 - x1);
|
|
const rh = Math.abs(y2 - y1);
|
|
|
|
this._bandGfx.clear();
|
|
this._bandGfx.rect(rx, ry, rw, rh);
|
|
this._bandGfx.fill({ color: BAND_FILL, alpha: BAND_FILL_ALPHA });
|
|
this._bandGfx.stroke({ color: BAND_STROKE, width: BAND_STROKE_WIDTH, alpha: BAND_STROKE_ALPHA });
|
|
this._bandGfx.visible = true;
|
|
}
|
|
|
|
private _finishRubberBand(
|
|
x1: number, y1: number, x2: number, y2: number, additive: boolean,
|
|
): void {
|
|
const rx = Math.min(x1, x2);
|
|
const ry = Math.min(y1, y2);
|
|
const rw = Math.abs(x2 - x1);
|
|
const rh = Math.abs(y2 - y1);
|
|
|
|
if (!additive) {
|
|
this.selectedIds.clear();
|
|
}
|
|
|
|
for (const item of this._scene.getTopLevelItems()) {
|
|
if (item.data.locked || !item.data.visible) continue;
|
|
|
|
const { x: ix, y: iy, w: iw, h: ih } = getItemWorldBounds(item);
|
|
|
|
// Check intersection between rubber band rect and item world bounds
|
|
const intersects =
|
|
rx < ix + iw &&
|
|
rx + rw > ix &&
|
|
ry < iy + ih &&
|
|
ry + rh > iy;
|
|
|
|
if (intersects) {
|
|
this.selectedIds.add(item.id);
|
|
}
|
|
}
|
|
|
|
this._emitChange();
|
|
}
|
|
|
|
// -- Selection Bounds Helper -----------------------------------------------
|
|
|
|
/** Compute the combined world bounding rect of the given items. */
|
|
private _getSelectionBounds(items: SceneItem[]): { x: number; y: number; w: number; h: number } {
|
|
let minX = Infinity;
|
|
let minY = Infinity;
|
|
let maxX = -Infinity;
|
|
let maxY = -Infinity;
|
|
|
|
for (const item of items) {
|
|
const { x, y, w, h } = getItemWorldBounds(item);
|
|
if (x < minX) minX = x;
|
|
if (y < minY) minY = y;
|
|
if (x + w > maxX) maxX = x + w;
|
|
if (y + h > maxY) maxY = y + h;
|
|
}
|
|
|
|
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
|
|
}
|
|
|
|
// -- Change Notification --------------------------------------------------
|
|
|
|
private _emitChange(): void {
|
|
const items = this.getSelectedItems();
|
|
this.transformBox.update(items);
|
|
this._onSelectionChange?.(Array.from(this.selectedIds));
|
|
}
|
|
|
|
// -- Cleanup --------------------------------------------------------------
|
|
|
|
destroy(): void {
|
|
this._viewport.off('pointerdown', this._onPointerDown, this);
|
|
this._viewport.off('globalpointermove', this._onPointerMove, this);
|
|
this._viewport.off('pointerup', this._onPointerUp, this);
|
|
this._viewport.off('pointerupoutside', this._onPointerUp, this);
|
|
this._overlay.destroy({ children: true });
|
|
}
|
|
}
|