From a901833b7227421042fa37f6a6ac9f7c54afee8e Mon Sep 17 00:00:00 2001 From: Hiren Kangad Date: Fri, 13 Mar 2026 17:37:21 +0530 Subject: [PATCH] fix: rotated transform box, video texture crash, paste duplication, and loading UX - TransformBox rotates with single-item selection (Figma-style), with handles and resize math projected into rotated coordinate space - Fix VideoSprite alphaMode crash by swapping texture to EMPTY before destroying, preventing PixiJS render loop from reading null source - Fix Ctrl+V double-paste: internal clipboard now always takes priority over system clipboard PNG, with wasRecentInternalPaste() guard - Add asset loading progress bar and suppress "Drop images here" flash during initial scene load --- frontend/src/canvas/PixiCanvas.tsx | 10 +- frontend/src/canvas/SceneManager.ts | 18 ++ frontend/src/canvas/TransformBox.ts | 194 ++++++++++++++------ frontend/src/canvas/image-drop.ts | 5 + frontend/src/canvas/shortcut-definitions.ts | 22 ++- frontend/src/canvas/sprites/VideoSprite.ts | 9 + frontend/src/pages/Editor.tsx | 55 +++++- 7 files changed, 243 insertions(+), 70 deletions(-) diff --git a/frontend/src/canvas/PixiCanvas.tsx b/frontend/src/canvas/PixiCanvas.tsx index 4036fbe..b8787fa 100644 --- a/frontend/src/canvas/PixiCanvas.tsx +++ b/frontend/src/canvas/PixiCanvas.tsx @@ -40,6 +40,8 @@ export interface PixiCanvasHandle { fitAll: () => void; getZoom: () => number; setZoom: (zoom: number) => void; + /** True while the initial scene is being loaded (items being created). */ + isSceneLoading: () => boolean; } export interface PixiCanvasProps { @@ -106,6 +108,7 @@ const PixiCanvas = forwardRef( const viewportRef = useRef(null); const sceneRef = useRef(null); const initialLoadDone = useRef(false); + const sceneLoadingRef = useRef(false); const spaceHeld = useRef(false); const onChangeRef = useRef(onChange); const [pixiReady, setPixiReady] = useState(false); @@ -442,7 +445,11 @@ const PixiCanvas = forwardRef( } initialLoadDone.current = true; - sceneRef.current.loadScene(sceneData); + sceneLoadingRef.current = true; + sceneRef.current.loadScene(sceneData).then(() => { + sceneLoadingRef.current = false; + onChangeRef.current?.(); // trigger objectCount update + }); }, [canvasState, pixiReady]); // ── Space key for pan mode ──────────────────────────────────────── @@ -543,6 +550,7 @@ const PixiCanvas = forwardRef( ease: 'easeOutQuad', }); }, + isSceneLoading: () => sceneLoadingRef.current, }), [fitAll], ); diff --git a/frontend/src/canvas/SceneManager.ts b/frontend/src/canvas/SceneManager.ts index 7fb218f..8a2dd54 100644 --- a/frontend/src/canvas/SceneManager.ts +++ b/frontend/src/canvas/SceneManager.ts @@ -531,6 +531,24 @@ export class SceneManager { return Array.from(this.items.values()); } + /** Count how many image/video assets have loaded textures vs total. */ + getLoadProgress(): { loaded: number; total: number } { + let loaded = 0; + let total = 0; + for (const item of this.items.values()) { + if (item.type === 'image') { + total++; + const spr = item.displayObject; + if ((spr instanceof ImageSprite || spr instanceof AnimatedGifSprite) && spr.loaded) loaded++; + } else if (item.type === 'video') { + total++; + const spr = item.displayObject; + if (spr instanceof VideoSprite && (spr.hasPoster || spr.isPlaying)) loaded++; + } + } + return { loaded, total }; + } + /** Get only top-level items (excludes group children). Used for selection/hit testing. */ getTopLevelItems(): SceneItem[] { return Array.from(this.items.values()).filter((item) => !isGroupChild(item.id)); diff --git a/frontend/src/canvas/TransformBox.ts b/frontend/src/canvas/TransformBox.ts index cad42fb..3fb2985 100644 --- a/frontend/src/canvas/TransformBox.ts +++ b/frontend/src/canvas/TransformBox.ts @@ -13,7 +13,7 @@ import { MarkdownSprite } from './sprites/MarkdownSprite'; import type { MarkdownObject } from './scene-format'; import { type SceneItem, getItemWorldBounds } from './SceneManager'; import type { SnapGuides } from './SnapGuides'; -import { applyImageDisplayTransform } from './imageTransforms'; +import { applyImageDisplayTransform, getImageVisibleLocalRect } from './imageTransforms'; import type { ImageObject } from './scene-format'; // --------------------------------------------------------------------------- @@ -77,6 +77,10 @@ export class TransformBox extends Container { private _resizeConstraint: 'full' | 'horizontal' | 'none' = 'full'; /** Only images (and videos) support rotation. */ private _rotateAllowed = true; + /** Rotation angle of the transform box itself (matches single item rotation). */ + private _selectionAngle = 0; + /** Center of the rotated transform box in world space. */ + private _rotatedCenter = { x: 0, y: 0 }; private _dimLabel!: Text; private _dimLabelBg!: Graphics; @@ -179,55 +183,111 @@ export class TransformBox extends Container { return; } - // Compute combined bounding rect via canonical getItemWorldBounds() - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; - - for (const item of items) { - const { x: ix, y: iy, w: iw, h: ih } = getItemWorldBounds(item); - 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 }; const allSticky = items.length > 0 && items.every(i => i.type === 'sticky'); const allMarkdown = items.length > 0 && items.every(i => i.type === 'markdown'); this._resizeConstraint = allSticky ? 'none' : allMarkdown ? 'horizontal' : 'full'; // Only allow rotation for image/video selections const ROTATABLE_TYPES = new Set(['image', 'video']); this._rotateAllowed = items.length > 0 && items.every(i => ROTATABLE_TYPES.has(i.type)); + + // Single rotated item: use the item's local rect so the transform box + // wraps the unrotated shape and we rotate the box itself. + const singleItem = items.length === 1 ? items[0] : null; + const itemAngle = singleItem?.data.angle ?? 0; + + if (singleItem && Math.abs(itemAngle) > 0.01) { + this._selectionAngle = itemAngle; + // Compute the item's local (unrotated) visible rect in world units + let localW: number; + let localH: number; + if (singleItem.type === 'image') { + const vis = getImageVisibleLocalRect(singleItem.data as ImageObject); + localW = vis.w * Math.abs(singleItem.data.sx); + localH = vis.h * Math.abs(singleItem.data.sy); + } else { + localW = singleItem.data.w * Math.abs(singleItem.data.sx); + localH = singleItem.data.h * Math.abs(singleItem.data.sy); + } + // Get the AABB to find the center (center is invariant under rotation) + const aabb = getItemWorldBounds(singleItem); + const cx = aabb.x + aabb.w / 2; + const cy = aabb.y + aabb.h / 2; + this._rotatedCenter = { x: cx, y: cy }; + // Bounds in local (unrotated) space, centered on world center + this._bounds = { x: cx - localW / 2, y: cy - localH / 2, w: localW, h: localH }; + } else { + this._selectionAngle = 0; + // Multi-selection or unrotated: use axis-aligned world bounds + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const item of items) { + const { x: ix, y: iy, w: iw, h: ih } = getItemWorldBounds(item); + 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._rotatedCenter = { x: minX + (maxX - minX) / 2, y: minY + (maxY - minY) / 2 }; + } + this._draw(); this.visible = true; } // -- Drawing -------------------------------------------------------------- + /** Rotate a point around a center by an angle in degrees. */ + private _rotatePoint(px: number, py: number, cx: number, cy: number, angleDeg: number): { x: number; y: number } { + const rad = (angleDeg * Math.PI) / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + const dx = px - cx; + const dy = py - cy; + return { x: cx + dx * cos - dy * sin, y: cy + dx * sin + dy * cos }; + } + private _draw(): void { const { x, y, w, h } = this._bounds; const zoom = this._viewport?.scale.x ?? 1; + const angle = this._selectionAngle; + const rcx = this._rotatedCenter.x; + const rcy = this._rotatedCenter.y; // Border — constant screen-width stroke this._border.clear(); - this._border.rect(x, y, w, h); - this._border.stroke({ color: BORDER_COLOR, width: BORDER_WIDTH / zoom }); + if (Math.abs(angle) > 0.01) { + // Draw rotated rectangle as a polygon + const tl = this._rotatePoint(x, y, rcx, rcy, angle); + const tr = this._rotatePoint(x + w, y, rcx, rcy, angle); + const br = this._rotatePoint(x + w, y + h, rcx, rcy, angle); + const bl = this._rotatePoint(x, y + h, rcx, rcy, angle); + this._border.moveTo(tl.x, tl.y); + this._border.lineTo(tr.x, tr.y); + this._border.lineTo(br.x, br.y); + this._border.lineTo(bl.x, bl.y); + this._border.closePath(); + this._border.stroke({ color: BORDER_COLOR, width: BORDER_WIDTH / zoom }); + } else { + this._border.rect(x, y, w, h); + this._border.stroke({ color: BORDER_COLOR, width: BORDER_WIDTH / zoom }); + } - // Position handles + // Position handles (in local bounds space, then rotated) const cx = x + w / 2; const cy = y + h / 2; - const positions: Record = { - tl: { px: x, py: y }, - tc: { px: cx, py: y }, - tr: { px: x + w, py: y }, - ml: { px: x, py: cy }, - mr: { px: x + w, py: cy }, - bl: { px: x, py: y + h }, - bc: { px: cx, py: y + h }, - br: { px: x + w, py: y + h }, + const localPositions: Record = { + tl: { x, y }, + tc: { x: cx, y }, + tr: { x: x + w, y }, + ml: { x, y: cy }, + mr: { x: x + w, y: cy }, + bl: { x, y: y + h }, + bc: { x: cx, y: y + h }, + br: { x: x + w, y: y + h }, }; // Counter-scale handles so they stay the same screen size at any zoom @@ -252,21 +312,25 @@ export class TransformBox extends Container { handle.visible = true; handle.eventMode = 'static'; - const pos = positions[id]; + const lp = localPositions[id]; + const pos = Math.abs(angle) > 0.01 + ? this._rotatePoint(lp.x, lp.y, rcx, rcy, angle) + : lp; handle.clear(); handle.rect(-half, -half, s, s); handle.fill(HANDLE_FILL); handle.stroke({ color: HANDLE_STROKE, width: 1 / zoom }); - handle.position.set(pos.px, pos.py); + handle.position.set(pos.x, pos.y); + handle.rotation = (angle * Math.PI) / 180; } - const rotatePositions: Record = { - tl: { px: x - rotateOffset, py: y - rotateOffset }, - tr: { px: x + w + rotateOffset, py: y - rotateOffset }, - bl: { px: x - rotateOffset, py: y + h + rotateOffset }, - br: { px: x + w + rotateOffset, py: y + h + rotateOffset }, + const localRotatePositions: Record = { + tl: { x: x - rotateOffset, y: y - rotateOffset }, + tr: { x: x + w + rotateOffset, y: y - rotateOffset }, + bl: { x: x - rotateOffset, y: y + h + rotateOffset }, + br: { x: x + w + rotateOffset, y: y + h + rotateOffset }, }; for (const [id, handle] of this._rotateHandles) { @@ -281,7 +345,11 @@ export class TransformBox extends Container { handle.circle(0, 0, rotateHalf); handle.fill({ color: ROTATE_HANDLE_FILL, alpha: 0.9 }); handle.stroke({ color: HANDLE_FILL, width: 1 / zoom }); - handle.position.set(rotatePositions[id].px, rotatePositions[id].py); + const lp = localRotatePositions[id]; + const pos = Math.abs(angle) > 0.01 + ? this._rotatePoint(lp.x, lp.y, rcx, rcy, angle) + : lp; + handle.position.set(pos.x, pos.y); } } @@ -416,6 +484,13 @@ export class TransformBox extends Container { return; } + // If the transform box is rotated, project mouse into the local + // (unrotated) coordinate system so the scale math stays correct. + let localMouse = { x: world.x, y: world.y }; + if (Math.abs(this._selectionAngle) > 0.01) { + localMouse = this._rotatePoint(world.x, world.y, this._rotatedCenter.x, this._rotatedCenter.y, -this._selectionAngle); + } + // Compute scale factors: new size / original size // Each handle has a fixed edge — the opposite side stays put const MIN = 0.05; @@ -426,8 +501,8 @@ export class TransformBox extends Container { // --- Corner handles (proportional) --- case 'br': { // Fixed edge: top-left. New size = mouse - top-left. - fx = Math.max(MIN, (world.x - ob.x) / ob.w); - fy = Math.max(MIN, (world.y - ob.y) / ob.h); + fx = Math.max(MIN, (localMouse.x - ob.x) / ob.w); + fy = Math.max(MIN, (localMouse.y - ob.y) / ob.h); // Proportional: use average const f = (fx + fy) / 2; fx = f; fy = f; @@ -437,8 +512,8 @@ export class TransformBox extends Container { // Fixed edge: bottom-right const right = ob.x + ob.w; const bottom = ob.y + ob.h; - fx = Math.max(MIN, (right - world.x) / ob.w); - fy = Math.max(MIN, (bottom - world.y) / ob.h); + fx = Math.max(MIN, (right - localMouse.x) / ob.w); + fy = Math.max(MIN, (bottom - localMouse.y) / ob.h); const f = (fx + fy) / 2; fx = f; fy = f; break; @@ -446,8 +521,8 @@ export class TransformBox extends Container { case 'tr': { // Fixed edge: bottom-left const bottom = ob.y + ob.h; - fx = Math.max(MIN, (world.x - ob.x) / ob.w); - fy = Math.max(MIN, (bottom - world.y) / ob.h); + fx = Math.max(MIN, (localMouse.x - ob.x) / ob.w); + fy = Math.max(MIN, (bottom - localMouse.y) / ob.h); const f = (fx + fy) / 2; fx = f; fy = f; break; @@ -455,32 +530,32 @@ export class TransformBox extends Container { case 'bl': { // Fixed edge: top-right const right = ob.x + ob.w; - fx = Math.max(MIN, (right - world.x) / ob.w); - fy = Math.max(MIN, (world.y - ob.y) / ob.h); + fx = Math.max(MIN, (right - localMouse.x) / ob.w); + fy = Math.max(MIN, (localMouse.y - ob.y) / ob.h); const f = (fx + fy) / 2; fx = f; fy = f; break; } // --- Edge handles (proportional by default, hold Shift for free-form) --- case 'mr': { - fx = Math.max(MIN, (world.x - ob.x) / ob.w); + fx = Math.max(MIN, (localMouse.x - ob.x) / ob.w); if (!e.shiftKey && this._resizeConstraint !== 'horizontal') fy = fx; break; } case 'ml': { const right = ob.x + ob.w; - fx = Math.max(MIN, (right - world.x) / ob.w); + fx = Math.max(MIN, (right - localMouse.x) / ob.w); if (!e.shiftKey) fy = fx; break; } case 'bc': { - fy = Math.max(MIN, (world.y - ob.y) / ob.h); + fy = Math.max(MIN, (localMouse.y - ob.y) / ob.h); if (!e.shiftKey) fx = fy; break; } case 'tc': { const bottom = ob.y + ob.h; - fy = Math.max(MIN, (bottom - world.y) / ob.h); + fy = Math.max(MIN, (bottom - localMouse.y) / ob.h); if (!e.shiftKey) fx = fy; break; } @@ -555,16 +630,21 @@ export class TransformBox extends Container { // Show dimension label const bounds = this._bounds; const zoom = this._viewport?.scale.x ?? 1; - const w = Math.round(bounds.w); - const h = Math.round(bounds.h); - this._dimLabel.text = `${w} \u00d7 ${h}`; + const dimW = Math.round(bounds.w); + const dimH = Math.round(bounds.h); + this._dimLabel.text = `${dimW} \u00d7 ${dimH}`; this._dimLabel.scale.set(1 / zoom); // Stay fixed screen size - // Position below bottom-right corner with offset - const labelX = bounds.x + bounds.w; - const labelY = bounds.y + bounds.h + 12 / zoom; + // Position below bottom-right corner with offset (rotated if needed) + let labelAnchorX = bounds.x + bounds.w; + let labelAnchorY = bounds.y + bounds.h + 12 / zoom; + if (Math.abs(this._selectionAngle) > 0.01) { + const rotated = this._rotatePoint(labelAnchorX, labelAnchorY, this._rotatedCenter.x, this._rotatedCenter.y, this._selectionAngle); + labelAnchorX = rotated.x; + labelAnchorY = rotated.y; + } this._dimLabel.anchor.set(1, 0); // right-aligned - this._dimLabel.position.set(labelX, labelY); + this._dimLabel.position.set(labelAnchorX, labelAnchorY); // Background pill const pad = 4 / zoom; @@ -572,8 +652,8 @@ export class TransformBox extends Container { const textH = this._dimLabel.height; this._dimLabelBg.clear(); this._dimLabelBg.roundRect( - labelX - textW - pad, - labelY - pad / 2, + labelAnchorX - textW - pad, + labelAnchorY - pad / 2, textW + pad * 2, textH + pad, 3 / zoom, diff --git a/frontend/src/canvas/image-drop.ts b/frontend/src/canvas/image-drop.ts index 145a780..09f6bc9 100644 --- a/frontend/src/canvas/image-drop.ts +++ b/frontend/src/canvas/image-drop.ts @@ -4,6 +4,7 @@ import type { SceneManager } from './SceneManager'; import type { SelectionManager } from './SelectionManager'; import type { UploadManager } from '../stores/uploadManager'; import { uploadImage, uploadImageFromUrl } from '../api'; +import { wasRecentInternalPaste } from './shortcut-definitions'; type OnChange = () => void; @@ -334,6 +335,10 @@ export function setupPaste( const active = document.activeElement; if (active instanceof HTMLElement && (active.isContentEditable || active.closest('[contenteditable]'))) return; + // If an internal paste (scene item duplication) just happened via the shortcut + // handler, skip the native paste to avoid double-pasting the PNG screenshot. + if (wasRecentInternalPaste()) return; + const items = e.clipboardData?.items; if (!items) return; diff --git a/frontend/src/canvas/shortcut-definitions.ts b/frontend/src/canvas/shortcut-definitions.ts index 65ac990..df9b66b 100644 --- a/frontend/src/canvas/shortcut-definitions.ts +++ b/frontend/src/canvas/shortcut-definitions.ts @@ -22,12 +22,19 @@ import { onArrangeAnimationDone } from './operations'; // Tracks when the last internal copy happened so paste can decide // whether to use internal clipboard (just copied) vs system clipboard (external app). let _lastInternalCopyTime = 0; +// Tracks when the last internal paste happened so setupPaste can skip the native event. +let _lastInternalPasteTime = 0; /** Mark that an internal copy just happened. Called by copy/cut handlers. */ export function markInternalCopy(): void { _lastInternalCopyTime = Date.now(); } +/** Check if an internal paste just happened (within last 500ms). Used by setupPaste to skip. */ +export function wasRecentInternalPaste(): boolean { + return Date.now() - _lastInternalPasteTime < 500; +} + /** Collect selected items + their group children (for deep clone). */ function _collectGroupChildren(items: SceneItem[], scene: SceneManager): SceneItem[] { const all: SceneItem[] = []; @@ -494,19 +501,18 @@ export const shortcuts: ShortcutDef[] = [ category: 'editing', description: 'Paste', preventDefault: false, handler: async (ctx) => { - // Only handle recent internal copies here. - // For external clipboard content (images, text, HTML), do NOT call - // e.preventDefault() — let the native paste event fire through to setupPaste - // in image-drop.ts, which is the single path for all external clipboard content. - const timeSinceInternalCopy = Date.now() - _lastInternalCopyTime; + // If we have items in the internal clipboard, always prefer internal paste + // (duplicates the scene items with offset). This avoids re-uploading the + // PNG screenshot that Ctrl+C also wrote to the system clipboard. const hasInternalItems = ctx.clipboardRef.current.length > 0; - const recentInternalCopy = hasInternalItems && timeSinceInternalCopy < 500; - if (recentInternalCopy) { + if (hasInternalItems) { + _lastInternalPasteTime = Date.now(); await _pasteInternal(ctx); return; } - // For external clipboard content: do nothing, let native paste event fire through to setupPaste + // No internal items — let native paste event fire through to setupPaste + // in image-drop.ts for external clipboard content (images, text, HTML). }, }, { diff --git a/frontend/src/canvas/sprites/VideoSprite.ts b/frontend/src/canvas/sprites/VideoSprite.ts index 2068dd3..2bed7b3 100644 --- a/frontend/src/canvas/sprites/VideoSprite.ts +++ b/frontend/src/canvas/sprites/VideoSprite.ts @@ -231,6 +231,7 @@ export class VideoSprite extends Container { if (!this._hasPoster) return; if (this.posterTexture) { if (this._sprite && this._sprite.texture === this.posterTexture) { + this._sprite.texture = Texture.EMPTY; this._removeSpriteFromTree(); } // Release via TextureManager if server poster, otherwise destroy directly @@ -299,6 +300,7 @@ export class VideoSprite extends Container { // Drop poster while playing — don't keep both resident if (this._hasPoster && this.posterTexture) { + // Detach from sprite before destroying (sprite already swapped to videoTexture above) if (this._serverPosterLoaded && this.posterAssetKey && this.textures) { this.textures.release(this.posterAssetKey); } else { @@ -588,7 +590,10 @@ export class VideoSprite extends Container { private _destroyVideoTexture(): void { this._stopFrameLoop(); if (this.videoTexture) { + // Swap to EMPTY before destroying so PixiJS never reads a null source + // during its render-loop traversal (collectRenderables → alphaMode). if (this._sprite && this._sprite.texture === this.videoTexture) { + this._sprite.texture = Texture.EMPTY; this._removeSpriteFromTree(); this._restorePlaceholder(); } @@ -603,6 +608,10 @@ export class VideoSprite extends Container { this._stopFrameLoop(); this.pause(); this._destroyVideoEl(); + // Detach sprite from any texture before destroying them + if (this._sprite) { + this._sprite.texture = Texture.EMPTY; + } if (this.videoTexture) { this.videoTexture.destroy(true); this.videoTexture = null; } this._frameCanvas = null; this._frameCtx = null; diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx index 8479435..f341cba 100644 --- a/frontend/src/pages/Editor.tsx +++ b/frontend/src/pages/Editor.tsx @@ -103,6 +103,8 @@ export default function Editor({ isPublicView }: EditorProps) { const [strokeWidth, setStrokeWidth] = useState(4); const [zoom, setZoom] = useState(1); const [objectCount, setObjectCount] = useState(0); + const [sceneLoading, setSceneLoading] = useState(true); + const [loadProgress, setLoadProgress] = useState({ loaded: 0, total: 0 }); const [saveStatus, setSaveStatus] = useState('saved'); const [onlineUsers, setOnlineUsers] = useState([]); const [canUndo, setCanUndo] = useState(false); @@ -199,6 +201,9 @@ export default function Editor({ isPublicView }: EditorProps) { setZoom(z); const scene = canvasRef.current?.getScene(); if (scene) setObjectCount(scene.getAllItems().length); + // Update scene loading state + const isLoading = canvasRef.current?.isSceneLoading() ?? false; + setSceneLoading(isLoading); const vp = canvasRef.current?.getViewport(); if (vp) setCanvasTransform([vp.scale.x, 0, 0, vp.scale.y, vp.x, vp.y]); }, [scheduleSave]); @@ -310,6 +315,21 @@ export default function Editor({ isPublicView }: EditorProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [objectCount]); + // Track asset load progress during initial load + useEffect(() => { + if (!sceneLoading && loadProgress.total > 0 && loadProgress.loaded >= loadProgress.total) return; + const interval = setInterval(() => { + const scene = canvasRef.current?.getScene(); + if (!scene) return; + const progress = scene.getLoadProgress(); + setLoadProgress(progress); + // Also update sceneLoading flag + const isLoading = canvasRef.current?.isSceneLoading() ?? false; + setSceneLoading(isLoading); + }, 300); + return () => clearInterval(interval); + }, [sceneLoading, loadProgress]); + // Inline composer position — tracks viewport + scene changes for anchored placement const composerAnchor = draftPin ? { objectId: draftPin.objectId, pinX: draftPin.pinX, pinY: draftPin.pinY } : null; const composerPos = useAnchoredOverlayPosition( @@ -876,8 +896,10 @@ export default function Editor({ isPublicView }: EditorProps) { if (loading) { return ( -
- Loading board... +
+
+ + Loading board
); } @@ -1034,8 +1056,33 @@ export default function Editor({ isPublicView }: EditorProps) { canvasTransform={canvasTransform} /> - {/* Empty canvas guide */} - {objectCount === 0 && ( + {/* Loading / Empty canvas guide */} + {(sceneLoading || (objectCount > 0 && loadProgress.total > 0 && loadProgress.loaded < loadProgress.total)) && ( +
+
+ {sceneLoading + ? `Loading ${loadProgress.total || '...'} items` + : `Loading assets ${loadProgress.loaded}/${loadProgress.total}`} +
+ {loadProgress.total > 0 && ( +
+
+
+ )} +
+ )} + {objectCount === 0 && !sceneLoading && (