fix(refboard): coordinate space bugs + legacy texture fallback

- TransformBox: use item.data (world-space) instead of getBounds() (screen-space)
  for correct handle positioning regardless of viewport pan/zoom
- TransformBox: scale drag sensitivity relative to object size, not fixed 500px
- TransformBox: compensate drag deltas for viewport zoom level
- TransformBox: fix corner/edge handles to keep opposite edge fixed during resize
- SelectionManager: use world-space hit testing instead of screen-space getBounds()
- SelectionManager: fix rubber band selection to use world-space item bounds
- TextureManager: detect legacy asset keys (with file extension) vs new LOD keys
  and load original file directly for pre-migration images (GPU handles scaling)
This commit is contained in:
Hiren Kangad
2026-03-09 23:45:58 +05:30
parent 21b2a433c7
commit 9542daa6cc
3 changed files with 110 additions and 57 deletions
+19 -14
View File
@@ -69,6 +69,7 @@ export class SelectionManager {
// Transform box
this.transformBox = new TransformBox();
this.transformBox.setViewport(viewport);
this._overlay.addChild(this.transformBox);
// Bind events on viewport
@@ -131,7 +132,7 @@ export class SelectionManager {
// -- Hit Testing ----------------------------------------------------------
/** Check all scene items in reverse z-order; return first whose bounds contain (wx, wy). */
/** 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.getAllItems();
// Sort by z descending (topmost first)
@@ -141,13 +142,13 @@ export class SelectionManager {
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
) {
// Use item.data (world-space) instead of getBounds() (screen-space)
const ix = item.data.x;
const iy = item.data.y;
const iw = item.data.w * Math.abs(item.data.sx);
const ih = item.data.h * Math.abs(item.data.sy);
if (ix <= wx && wx <= ix + iw && iy <= wy && wy <= iy + ih) {
return item;
}
}
@@ -344,14 +345,18 @@ export class SelectionManager {
for (const item of this._scene.getAllItems()) {
if (item.data.locked || !item.data.visible) continue;
const bounds = item.displayObject.getBounds();
// Use world-space data instead of screen-space getBounds()
const ix = item.data.x;
const iy = item.data.y;
const iw = item.data.w * Math.abs(item.data.sx);
const ih = item.data.h * Math.abs(item.data.sy);
// Check intersection (not containment) between rubber band rect and item bounds
// Check intersection between rubber band rect and item world bounds
const intersects =
rx < bounds.x + bounds.width &&
rx + rw > bounds.x &&
ry < bounds.y + bounds.height &&
ry + rh > bounds.y;
rx < ix + iw &&
rx + rw > ix &&
ry < iy + ih &&
ry + rh > iy;
if (intersects) {
this.selectedIds.add(item.id);
+32 -5
View File
@@ -21,18 +21,39 @@ export class TextureManager {
return "medium";
}
/**
* Detect whether an asset key is a new LOD-format key (no extension)
* or an old pre-migration path (has file extension).
*/
private _isLegacyAsset(assetKey: string): boolean {
// New LOD keys look like: boards/{boardId}/{imageId} (no extension)
// Old keys look like: boards/{boardId}/{imageId}.png or full URLs
return /\.\w{2,5}$/.test(assetKey) || assetKey.startsWith('http') || assetKey.startsWith('/api/');
}
/** Build the URL for a given asset + tier. */
urlForAsset(assetKey: string, tier: LODTier): string {
if (this._isLegacyAsset(assetKey)) {
// Legacy: load original file directly (GPU handles scaling)
if (assetKey.startsWith('http') || assetKey.startsWith('/api/')) {
return assetKey;
}
return `/api/images/${assetKey}`;
}
// New LOD format: boards/{boardId}/{imageId}/thumb.webp
return `/api/images/${assetKey}/${tier}.webp`;
}
/**
* Load a texture for the given asset and LOD tier.
* Returns a cached texture if available, otherwise fetches it,
* estimates GPU memory, and evicts LRU entries if over budget.
* For legacy assets (pre-migration), loads the original file directly —
* the GPU handles downscaling naturally.
* For new assets, loads the appropriate LOD tier with fallback.
*/
async load(assetKey: string, tier: LODTier): Promise<Texture> {
const key = `${assetKey}:${tier}`;
// For legacy assets, all tiers resolve to the same URL
const effectiveTier = this._isLegacyAsset(assetKey) ? 'full' : tier;
const key = `${assetKey}:${effectiveTier}`;
const existing = this.cache.get(key);
if (existing) {
@@ -40,8 +61,14 @@ export class TextureManager {
return existing.texture;
}
const url = this.urlForAsset(assetKey, tier);
const texture: Texture = await Assets.load(url);
const url = this.urlForAsset(assetKey, effectiveTier);
let texture: Texture;
try {
texture = await Assets.load(url);
} catch {
console.warn(`[TextureManager] Failed to load ${url}`);
return Texture.EMPTY;
}
const w = texture.source.width ?? 256;
const h = texture.source.height ?? 256;
+59 -38
View File
@@ -7,6 +7,7 @@
*/
import { Container, Graphics, FederatedPointerEvent } from 'pixi.js';
import type { Viewport } from 'pixi-viewport';
import type { SceneItem } from './SceneManager';
// ---------------------------------------------------------------------------
@@ -58,6 +59,7 @@ export class TransformBox extends Container {
private _items: SceneItem[] = [];
private _bounds = { x: 0, y: 0, w: 0, h: 0 };
private _drag: DragState | null = null;
private _viewport: Viewport | null = null;
constructor() {
super();
@@ -92,6 +94,11 @@ export class TransformBox extends Container {
// -- Public API -----------------------------------------------------------
/** Set the viewport reference for zoom-aware drag calculations. */
setViewport(viewport: Viewport): void {
this._viewport = viewport;
}
/** Update the transform box to wrap the given items. Hides if empty. */
update(items: SceneItem[]): void {
this._items = items;
@@ -101,18 +108,22 @@ export class TransformBox extends Container {
return;
}
// Compute combined bounding rect in world space
// Compute combined bounding rect in WORLD space using item data
// (not getBounds() which returns screen-space and causes offset)
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const item of items) {
const b = item.displayObject.getBounds();
if (b.x < minX) minX = b.x;
if (b.y < minY) minY = b.y;
if (b.x + b.width > maxX) maxX = b.x + b.width;
if (b.y + b.height > maxY) maxY = b.y + b.height;
const ix = item.data.x;
const iy = item.data.y;
const iw = item.data.w * Math.abs(item.data.sx);
const ih = item.data.h * Math.abs(item.data.sy);
if (ix < minX) minX = ix;
if (iy < minY) minY = iy;
if (ix + iw > maxX) maxX = ix + iw;
if (iy + ih > maxY) maxY = iy + ih;
}
this._bounds = { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
@@ -200,76 +211,86 @@ export class TransformBox extends Container {
private _onHandleMove(e: FederatedPointerEvent): void {
if (!this._drag) return;
const dx = e.global.x - this._drag.startX;
const dy = e.global.y - this._drag.startY;
// Convert screen-space deltas to world-space by dividing by viewport zoom
const zoom = this._viewport?.scale.x ?? 1;
const dx = (e.global.x - this._drag.startX) / zoom;
const dy = (e.global.y - this._drag.startY) / zoom;
const { handleId, origTransforms } = this._drag;
// Use bounding box size as reference for scale sensitivity
const { w: bw, h: bh } = this._bounds;
const refSize = Math.max(bw, bh, 100); // avoid division by tiny numbers
for (const item of this._items) {
const orig = origTransforms.get(item.id);
if (!orig) continue;
switch (handleId) {
case 'br': {
// Proportional scale
const factor = 1 + (dx + dy) / 500;
const clampedFactor = Math.max(0.1, factor);
// Proportional scale — drag distance relative to object size
const factor = 1 + (dx + dy) / refSize;
const clampedFactor = Math.max(0.05, factor);
item.data.sx = orig.sx * clampedFactor;
item.data.sy = orig.sy * clampedFactor;
break;
}
case 'mr': {
// Horizontal scale only
const factor = 1 + dx / 500;
item.data.sx = orig.sx * Math.max(0.1, factor);
const factor = 1 + dx / (bw || refSize);
item.data.sx = orig.sx * Math.max(0.05, factor);
break;
}
case 'bc': {
// Vertical scale only
const factor = 1 + dy / 500;
item.data.sy = orig.sy * Math.max(0.1, factor);
const factor = 1 + dy / (bh || refSize);
item.data.sy = orig.sy * Math.max(0.05, factor);
break;
}
case 'tl': {
// Proportional scale + reposition (opposite corner stays fixed)
const factor = 1 - (dx + dy) / 500;
const clampedFactor = Math.max(0.1, factor);
// Proportional scale + reposition (bottom-right stays fixed)
const factor = 1 - (dx + dy) / refSize;
const clampedFactor = Math.max(0.05, factor);
item.data.sx = orig.sx * clampedFactor;
item.data.sy = orig.sy * clampedFactor;
// Shift position so bottom-right stays fixed
const dw = (item.data.sx - orig.sx) * (item.data.w ?? 0);
const dh = (item.data.sy - orig.sy) * (item.data.h ?? 0);
const dw = (item.data.sx - orig.sx) * item.data.w;
const dh = (item.data.sy - orig.sy) * item.data.h;
item.data.x = orig.x - dw;
item.data.y = orig.y - dh;
break;
}
case 'rot': {
// Rotation based on horizontal drag
item.data.angle = orig.angle + dx * 0.5;
// Rotation: 1 world pixel = ~0.3 degrees
item.data.angle = orig.angle + dx * 0.3;
break;
}
// Other handles: simple proportional for now
case 'tr': {
const factor = 1 + (dx - dy) / 500;
item.data.sx = orig.sx * Math.max(0.1, factor);
item.data.sy = orig.sy * Math.max(0.1, factor);
const factor = 1 + (dx - dy) / refSize;
const clampedFactor = Math.max(0.05, factor);
item.data.sx = orig.sx * clampedFactor;
item.data.sy = orig.sy * clampedFactor;
// Top-right: left edge stays fixed
item.data.y = orig.y - (item.data.sy - orig.sy) * item.data.h;
break;
}
case 'bl': {
const factor = 1 + (-dx + dy) / 500;
item.data.sx = orig.sx * Math.max(0.1, factor);
item.data.sy = orig.sy * Math.max(0.1, factor);
const factor = 1 + (-dx + dy) / refSize;
const clampedFactor = Math.max(0.05, factor);
item.data.sx = orig.sx * clampedFactor;
item.data.sy = orig.sy * clampedFactor;
// Bottom-left: right edge stays fixed
item.data.x = orig.x - (item.data.sx - orig.sx) * item.data.w;
break;
}
case 'tc': {
const factor = 1 - dy / 500;
item.data.sy = orig.sy * Math.max(0.1, factor);
item.data.y = orig.y + dy;
const factor = 1 - dy / (bh || refSize);
item.data.sy = orig.sy * Math.max(0.05, factor);
// Top-center: bottom edge stays fixed
item.data.y = orig.y + (orig.sy - item.data.sy) * item.data.h;
break;
}
case 'ml': {
const factor = 1 - dx / 500;
item.data.sx = orig.sx * Math.max(0.1, factor);
item.data.x = orig.x + dx;
const factor = 1 - dx / (bw || refSize);
item.data.sx = orig.sx * Math.max(0.05, factor);
// Middle-left: right edge stays fixed
item.data.x = orig.x + (orig.sx - item.data.sx) * item.data.w;
break;
}
}