Files
refboard-ayon/frontend/src/canvas/SelectionManager.ts
T
Hiren Kangad bef0778804 Add SelectionManager with click, shift-click, and rubber-band selection
Implements Task 3.1: manages the selected item set with hit testing
in reverse z-order, rubber-band rectangle selection on empty-space drag,
shift-click toggle, and viewport drag-pause during rubber banding.
Integrates with TransformBox to show handles on selection change.
2026-03-09 23:14:15 +05:30

288 lines
8.8 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 } from './SceneManager';
import { TransformBox } from './TransformBox';
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
// SelectionManager
// ---------------------------------------------------------------------------
export class SelectionManager {
readonly selectedIds: Set<string> = new Set();
readonly transformBox: TransformBox;
private _viewport: Viewport;
private _scene: SceneManager;
private _overlay: Container;
private _bandGfx: Graphics;
private _onSelectionChange: ((ids: string[]) => 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;
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._overlay.addChild(this.transformBox);
// 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);
}
// -- Public API -----------------------------------------------------------
set onSelectionChange(fn: (ids: string[]) => void) {
this._onSelectionChange = 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.getAllItems()) {
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 bounds contain (wx, wy). */
_hitTest(wx: number, wy: number): SceneItem | null {
const all = this._scene.getAllItems();
// 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 bounds = item.displayObject.getBounds();
if (
bounds.x <= wx &&
wx <= bounds.x + bounds.width &&
bounds.y <= wy &&
wy <= bounds.y + bounds.height
) {
return item;
}
}
return null;
}
// -- Pointer Handlers -----------------------------------------------------
private _onPointerDown(e: FederatedPointerEvent): void {
// 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;
// If we clicked on an object, don't start rubber banding
if (this._hitItemOnDown) return;
const dx = e.global.x - this._downScreenX;
const dy = e.global.y - this._downScreenY;
const dist = Math.sqrt(dx * dx + dy * dy);
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._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();
}
}
this._hitItemOnDown = null;
}
// -- 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.getAllItems()) {
if (item.data.locked || !item.data.visible) continue;
const bounds = item.displayObject.getBounds();
// Check intersection (not containment) between rubber band rect and item bounds
const intersects =
rx < bounds.x + bounds.width &&
rx + rw > bounds.x &&
ry < bounds.y + bounds.height &&
ry + rh > bounds.y;
if (intersects) {
this.selectedIds.add(item.id);
}
}
this._emitChange();
}
// -- 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 });
}
}