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
This commit is contained in:
@@ -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<PixiCanvasHandle, PixiCanvasProps>(
|
||||
const viewportRef = useRef<Viewport | null>(null);
|
||||
const sceneRef = useRef<SceneManager | null>(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<PixiCanvasHandle, PixiCanvasProps>(
|
||||
}
|
||||
|
||||
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<PixiCanvasHandle, PixiCanvasProps>(
|
||||
ease: 'easeOutQuad',
|
||||
});
|
||||
},
|
||||
isSceneLoading: () => sceneLoadingRef.current,
|
||||
}),
|
||||
[fitAll],
|
||||
);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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<HandleId, { px: number; py: number }> = {
|
||||
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<HandleId, { x: number; y: number }> = {
|
||||
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<RotateHandleId, { px: number; py: number }> = {
|
||||
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<RotateHandleId, { x: number; y: number }> = {
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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).
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<SaveStatus>('saved');
|
||||
const [onlineUsers, setOnlineUsers] = useState<OnlineUser[]>([]);
|
||||
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 (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh', background: '#1a1a1a', color: '#666', fontSize: '14px' }}>
|
||||
Loading board...
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100vh', background: '#1a1a1a', color: '#888', fontSize: '13px', gap: '12px', fontFamily: 'system-ui, sans-serif' }}>
|
||||
<div style={{ width: '24px', height: '24px', border: '2px solid #333', borderTopColor: '#4a9eff', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
|
||||
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
|
||||
Loading board
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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)) && (
|
||||
<div style={{
|
||||
position: 'absolute', bottom: '48px', left: '50%', transform: 'translateX(-50%)',
|
||||
pointerEvents: 'none', textAlign: 'center', color: '#888', userSelect: 'none',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px',
|
||||
}}>
|
||||
<div style={{ fontSize: '12px', fontFamily: 'system-ui, sans-serif' }}>
|
||||
{sceneLoading
|
||||
? `Loading ${loadProgress.total || '...'} items`
|
||||
: `Loading assets ${loadProgress.loaded}/${loadProgress.total}`}
|
||||
</div>
|
||||
{loadProgress.total > 0 && (
|
||||
<div style={{
|
||||
width: '160px', height: '3px', borderRadius: '2px',
|
||||
background: '#333', overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${Math.round((loadProgress.loaded / loadProgress.total) * 100)}%`,
|
||||
height: '100%', background: '#4a9eff', borderRadius: '2px',
|
||||
transition: 'width 0.3s ease',
|
||||
}} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{objectCount === 0 && !sceneLoading && (
|
||||
<div style={{
|
||||
position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)',
|
||||
pointerEvents: 'none', textAlign: 'center', color: '#555', userSelect: 'none',
|
||||
|
||||
Reference in New Issue
Block a user