Files
refboard-ayon/frontend/src/canvas/SelectionManager.ts
T
Hiren Kangad de418b2ba5 feat: media processing pipeline, spatial indexing, canvas-based video rendering
- Background media worker: polls media_jobs table, runs ffprobe+ffmpeg
  with concurrency limit, generates video posters, emits socket events
- Non-blocking video upload: stores file + enqueues job, returns immediately
- Poster hydration on board load: GET /boards/:id injects poster/dimensions
  from DB into canvas_state video objects
- SpatialGrid: fixed-cell (512px) spatial hash for O(nearby) culling instead
  of O(all) item scanning, eliminates setTimeout violations
- Canvas-based video rendering: draws video frames to offscreen canvas then
  uploads to GPU, completely eliminates GL_INVALID_OPERATION errors from
  PixiJS VideoSource auto-update mechanism
- Server poster upgrade path: culling ticker and applyProcessedMedia() both
  upgrade client-captured posters to server posters when available
- Pause restores server poster (paused video behaves like an image)
- Selection drag-end persistence: onObjectDragEnd broadcasts + saves + undo
- Live media:job:update socket handler patches scene data + VideoSprite
  dimensions without broadcast fanout
2026-03-10 14:30:01 +05:30

470 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;
private _onObjectDragEnd: ((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;
// 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 when object drag ends (for persist/save/spatial refresh). */
set onObjectDragEnd(fn: (ids: string[]) => void) {
this._onObjectDragEnd = 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();
this._onObjectDragEnd?.(Array.from(this.selectedIds));
} 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 });
}
}