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:
@@ -69,6 +69,7 @@ export class SelectionManager {
|
|||||||
|
|
||||||
// Transform box
|
// Transform box
|
||||||
this.transformBox = new TransformBox();
|
this.transformBox = new TransformBox();
|
||||||
|
this.transformBox.setViewport(viewport);
|
||||||
this._overlay.addChild(this.transformBox);
|
this._overlay.addChild(this.transformBox);
|
||||||
|
|
||||||
// Bind events on viewport
|
// Bind events on viewport
|
||||||
@@ -131,7 +132,7 @@ export class SelectionManager {
|
|||||||
|
|
||||||
// -- Hit Testing ----------------------------------------------------------
|
// -- 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 {
|
_hitTest(wx: number, wy: number): SceneItem | null {
|
||||||
const all = this._scene.getAllItems();
|
const all = this._scene.getAllItems();
|
||||||
// Sort by z descending (topmost first)
|
// Sort by z descending (topmost first)
|
||||||
@@ -141,13 +142,13 @@ export class SelectionManager {
|
|||||||
if (item.data.locked) continue;
|
if (item.data.locked) continue;
|
||||||
if (!item.data.visible) continue;
|
if (!item.data.visible) continue;
|
||||||
|
|
||||||
const bounds = item.displayObject.getBounds();
|
// Use item.data (world-space) instead of getBounds() (screen-space)
|
||||||
if (
|
const ix = item.data.x;
|
||||||
bounds.x <= wx &&
|
const iy = item.data.y;
|
||||||
wx <= bounds.x + bounds.width &&
|
const iw = item.data.w * Math.abs(item.data.sx);
|
||||||
bounds.y <= wy &&
|
const ih = item.data.h * Math.abs(item.data.sy);
|
||||||
wy <= bounds.y + bounds.height
|
|
||||||
) {
|
if (ix <= wx && wx <= ix + iw && iy <= wy && wy <= iy + ih) {
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -344,14 +345,18 @@ export class SelectionManager {
|
|||||||
for (const item of this._scene.getAllItems()) {
|
for (const item of this._scene.getAllItems()) {
|
||||||
if (item.data.locked || !item.data.visible) continue;
|
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 =
|
const intersects =
|
||||||
rx < bounds.x + bounds.width &&
|
rx < ix + iw &&
|
||||||
rx + rw > bounds.x &&
|
rx + rw > ix &&
|
||||||
ry < bounds.y + bounds.height &&
|
ry < iy + ih &&
|
||||||
ry + rh > bounds.y;
|
ry + rh > iy;
|
||||||
|
|
||||||
if (intersects) {
|
if (intersects) {
|
||||||
this.selectedIds.add(item.id);
|
this.selectedIds.add(item.id);
|
||||||
|
|||||||
@@ -21,18 +21,39 @@ export class TextureManager {
|
|||||||
return "medium";
|
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. */
|
/** Build the URL for a given asset + tier. */
|
||||||
urlForAsset(assetKey: string, tier: LODTier): string {
|
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`;
|
return `/api/images/${assetKey}/${tier}.webp`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load a texture for the given asset and LOD tier.
|
* Load a texture for the given asset and LOD tier.
|
||||||
* Returns a cached texture if available, otherwise fetches it,
|
* For legacy assets (pre-migration), loads the original file directly —
|
||||||
* estimates GPU memory, and evicts LRU entries if over budget.
|
* the GPU handles downscaling naturally.
|
||||||
|
* For new assets, loads the appropriate LOD tier with fallback.
|
||||||
*/
|
*/
|
||||||
async load(assetKey: string, tier: LODTier): Promise<Texture> {
|
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);
|
const existing = this.cache.get(key);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -40,8 +61,14 @@ export class TextureManager {
|
|||||||
return existing.texture;
|
return existing.texture;
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = this.urlForAsset(assetKey, tier);
|
const url = this.urlForAsset(assetKey, effectiveTier);
|
||||||
const texture: Texture = await Assets.load(url);
|
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 w = texture.source.width ?? 256;
|
||||||
const h = texture.source.height ?? 256;
|
const h = texture.source.height ?? 256;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Container, Graphics, FederatedPointerEvent } from 'pixi.js';
|
import { Container, Graphics, FederatedPointerEvent } from 'pixi.js';
|
||||||
|
import type { Viewport } from 'pixi-viewport';
|
||||||
import type { SceneItem } from './SceneManager';
|
import type { SceneItem } from './SceneManager';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -58,6 +59,7 @@ export class TransformBox extends Container {
|
|||||||
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;
|
||||||
|
private _viewport: Viewport | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
@@ -92,6 +94,11 @@ export class TransformBox extends Container {
|
|||||||
|
|
||||||
// -- Public API -----------------------------------------------------------
|
// -- 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 the transform box to wrap the given items. Hides if empty. */
|
||||||
update(items: SceneItem[]): void {
|
update(items: SceneItem[]): void {
|
||||||
this._items = items;
|
this._items = items;
|
||||||
@@ -101,18 +108,22 @@ export class TransformBox extends Container {
|
|||||||
return;
|
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 minX = Infinity;
|
||||||
let minY = Infinity;
|
let minY = Infinity;
|
||||||
let maxX = -Infinity;
|
let maxX = -Infinity;
|
||||||
let maxY = -Infinity;
|
let maxY = -Infinity;
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const b = item.displayObject.getBounds();
|
const ix = item.data.x;
|
||||||
if (b.x < minX) minX = b.x;
|
const iy = item.data.y;
|
||||||
if (b.y < minY) minY = b.y;
|
const iw = item.data.w * Math.abs(item.data.sx);
|
||||||
if (b.x + b.width > maxX) maxX = b.x + b.width;
|
const ih = item.data.h * Math.abs(item.data.sy);
|
||||||
if (b.y + b.height > maxY) maxY = b.y + b.height;
|
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 };
|
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 {
|
private _onHandleMove(e: FederatedPointerEvent): void {
|
||||||
if (!this._drag) return;
|
if (!this._drag) return;
|
||||||
|
|
||||||
const dx = e.global.x - this._drag.startX;
|
// Convert screen-space deltas to world-space by dividing by viewport zoom
|
||||||
const dy = e.global.y - this._drag.startY;
|
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;
|
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) {
|
for (const item of this._items) {
|
||||||
const orig = origTransforms.get(item.id);
|
const orig = origTransforms.get(item.id);
|
||||||
if (!orig) continue;
|
if (!orig) continue;
|
||||||
|
|
||||||
switch (handleId) {
|
switch (handleId) {
|
||||||
case 'br': {
|
case 'br': {
|
||||||
// Proportional scale
|
// Proportional scale — drag distance relative to object size
|
||||||
const factor = 1 + (dx + dy) / 500;
|
const factor = 1 + (dx + dy) / refSize;
|
||||||
const clampedFactor = Math.max(0.1, factor);
|
const clampedFactor = Math.max(0.05, factor);
|
||||||
item.data.sx = orig.sx * clampedFactor;
|
item.data.sx = orig.sx * clampedFactor;
|
||||||
item.data.sy = orig.sy * clampedFactor;
|
item.data.sy = orig.sy * clampedFactor;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'mr': {
|
case 'mr': {
|
||||||
// Horizontal scale only
|
const factor = 1 + dx / (bw || refSize);
|
||||||
const factor = 1 + dx / 500;
|
item.data.sx = orig.sx * Math.max(0.05, factor);
|
||||||
item.data.sx = orig.sx * Math.max(0.1, factor);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'bc': {
|
case 'bc': {
|
||||||
// Vertical scale only
|
const factor = 1 + dy / (bh || refSize);
|
||||||
const factor = 1 + dy / 500;
|
item.data.sy = orig.sy * Math.max(0.05, factor);
|
||||||
item.data.sy = orig.sy * Math.max(0.1, factor);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'tl': {
|
case 'tl': {
|
||||||
// Proportional scale + reposition (opposite corner stays fixed)
|
// Proportional scale + reposition (bottom-right stays fixed)
|
||||||
const factor = 1 - (dx + dy) / 500;
|
const factor = 1 - (dx + dy) / refSize;
|
||||||
const clampedFactor = Math.max(0.1, factor);
|
const clampedFactor = Math.max(0.05, factor);
|
||||||
item.data.sx = orig.sx * clampedFactor;
|
item.data.sx = orig.sx * clampedFactor;
|
||||||
item.data.sy = orig.sy * clampedFactor;
|
item.data.sy = orig.sy * clampedFactor;
|
||||||
// Shift position so bottom-right stays fixed
|
const dw = (item.data.sx - orig.sx) * item.data.w;
|
||||||
const dw = (item.data.sx - orig.sx) * (item.data.w ?? 0);
|
const dh = (item.data.sy - orig.sy) * item.data.h;
|
||||||
const dh = (item.data.sy - orig.sy) * (item.data.h ?? 0);
|
|
||||||
item.data.x = orig.x - dw;
|
item.data.x = orig.x - dw;
|
||||||
item.data.y = orig.y - dh;
|
item.data.y = orig.y - dh;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'rot': {
|
case 'rot': {
|
||||||
// Rotation based on horizontal drag
|
// Rotation: 1 world pixel = ~0.3 degrees
|
||||||
item.data.angle = orig.angle + dx * 0.5;
|
item.data.angle = orig.angle + dx * 0.3;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Other handles: simple proportional for now
|
|
||||||
case 'tr': {
|
case 'tr': {
|
||||||
const factor = 1 + (dx - dy) / 500;
|
const factor = 1 + (dx - dy) / refSize;
|
||||||
item.data.sx = orig.sx * Math.max(0.1, factor);
|
const clampedFactor = Math.max(0.05, factor);
|
||||||
item.data.sy = orig.sy * Math.max(0.1, 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;
|
break;
|
||||||
}
|
}
|
||||||
case 'bl': {
|
case 'bl': {
|
||||||
const factor = 1 + (-dx + dy) / 500;
|
const factor = 1 + (-dx + dy) / refSize;
|
||||||
item.data.sx = orig.sx * Math.max(0.1, factor);
|
const clampedFactor = Math.max(0.05, factor);
|
||||||
item.data.sy = orig.sy * Math.max(0.1, 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;
|
break;
|
||||||
}
|
}
|
||||||
case 'tc': {
|
case 'tc': {
|
||||||
const factor = 1 - dy / 500;
|
const factor = 1 - dy / (bh || refSize);
|
||||||
item.data.sy = orig.sy * Math.max(0.1, factor);
|
item.data.sy = orig.sy * Math.max(0.05, factor);
|
||||||
item.data.y = orig.y + dy;
|
// Top-center: bottom edge stays fixed
|
||||||
|
item.data.y = orig.y + (orig.sy - item.data.sy) * item.data.h;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'ml': {
|
case 'ml': {
|
||||||
const factor = 1 - dx / 500;
|
const factor = 1 - dx / (bw || refSize);
|
||||||
item.data.sx = orig.sx * Math.max(0.1, factor);
|
item.data.sx = orig.sx * Math.max(0.05, factor);
|
||||||
item.data.x = orig.x + dx;
|
// Middle-left: right edge stays fixed
|
||||||
|
item.data.x = orig.x + (orig.sx - item.data.sx) * item.data.w;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user