diff --git a/frontend/src/canvas/image-drop.ts b/frontend/src/canvas/image-drop.ts index 09f6bc9..8e42176 100644 --- a/frontend/src/canvas/image-drop.ts +++ b/frontend/src/canvas/image-drop.ts @@ -1,4 +1,4 @@ -import { Graphics } from 'pixi.js'; +import { Graphics, Text, TextStyle, Ticker } from 'pixi.js'; import type { Viewport } from 'pixi-viewport'; import type { SceneManager } from './SceneManager'; import type { SelectionManager } from './SelectionManager'; @@ -22,10 +22,11 @@ const ALLOWED_MIME_TYPES = new Set([ 'video/mp4', 'video/webm', 'video/quicktime', + 'application/pdf', ]); /** Fallback: accepted file extensions (for when browser reports empty/wrong MIME). */ -const ALLOWED_EXTENSIONS = /\.(png|jpe?g|gif|webp|svg|mp4|webm|mov)$/i; +const ALLOWED_EXTENSIONS = /\.(png|jpe?g|gif|webp|svg|mp4|webm|mov|pdf)$/i; /** Check if a file should be accepted (MIME or extension fallback). */ function isFileAllowed(file: File): boolean { @@ -63,17 +64,37 @@ function extFromMime(mime: string): string { function createPlaceholder(viewport: Viewport, x: number, y: number): Graphics { const g = new Graphics(); g.rect(0, 0, 200, 150); - g.fill({ color: 0x3d3d3d }); + g.fill({ color: 0x2a2a2a }); g.stroke({ width: 2, color: 0x4a9eff }); g.position.set(x, y); g.interactive = false; + + // "Loading..." label + const label = new Text({ + text: 'Loading…', + style: new TextStyle({ fontSize: 14, fill: 0xaaaaaa, fontFamily: 'sans-serif' }), + }); + label.anchor.set(0.5); + label.position.set(100, 75); + g.addChild(label); + + // Pulsing border animation + let elapsed = 0; + const onTick = (ticker: Ticker) => { + elapsed += ticker.deltaMS; + g.alpha = 0.6 + 0.4 * Math.sin(elapsed * 0.005); + }; + Ticker.shared.add(onTick); + (g as any)._pulseCleanup = () => Ticker.shared.remove(onTick); + viewport.addChild(g); return g; } function removePlaceholder(viewport: Viewport, g: Graphics) { + (g as any)._pulseCleanup?.(); viewport.removeChild(g); - g.destroy(); + g.destroy({ children: true }); } /* ------------------------------------------------------------------ */ @@ -202,18 +223,9 @@ export function setupDragDrop( const world = viewport.toWorld(e.clientX - rect.left, e.clientY - rect.top); const GAP = 20; - // Count valid media files to determine grid columns - let mediaCount = 0; - for (let i = 0; i < files.length; i++) { - if (isFileAllowed(files[i])) mediaCount++; - } - const cols = Math.ceil(Math.sqrt(mediaCount)); // square-ish grid - let col = 0; - let row = 0; - let rowMaxH = 0; - let cursorY = world.y; - const colWidths: number[] = new Array(cols).fill(0); // track widest per column - const newItemIds: string[] = []; + // ── Pass 1: validate files, create all jobs upfront so queue is visible ── + interface QueuedFile { file: File; jobId: string | undefined; } + const queued: QueuedFile[] = []; for (let i = 0; i < files.length; i++) { const file = files[i]; @@ -222,29 +234,32 @@ export function setupDragDrop( if (!isFileAllowed(file)) { if (isUnsupportedMedia(file)) { const ext = file.name?.match(/\.(\w+)$/)?.[1] || file.type.split('/')[1] || 'unknown'; - uploads?.addRejected( - file.name || 'unknown', - `Unsupported format: .${ext}`, - ); + uploads?.addRejected(file.name || 'unknown', `Unsupported format: .${ext}`); } continue; } - // Compute x from column widths so far - let cursorX = world.x; - for (let c = 0; c < col; c++) cursorX += (colWidths[c] || 220) + GAP; - // Client-side size validation if (file.size > MAX_FILE_SIZE) { const jobId = uploads?.addJob(file, boardId); if (jobId) uploads?.setFailed(jobId, `File too large (${(file.size / 1024 / 1024).toFixed(0)}MB, max ${MAX_FILE_SIZE_MB}MB)`); - col++; - if (col >= cols) { col = 0; row++; cursorY += rowMaxH + GAP; rowMaxH = 0; } continue; } const jobId = uploads?.addJob(file, boardId); + queued.push({ file, jobId }); + } + // ── Pass 2: upload sequentially, placing in grid ── + const cols = Math.ceil(Math.sqrt(queued.length)) || 1; + let col = 0; + let row = 0; + let rowMaxH = 0; + let cursorY = world.y; + const colWidths: number[] = new Array(cols).fill(0); + const newItemIds: string[] = []; + + for (const { file, jobId } of queued) { // Check if job was cancelled while queued (user clicked cancel) if (jobId && uploads?.isCancelled(jobId)) { col++; @@ -252,6 +267,10 @@ export function setupDragDrop( continue; } + // Compute x from column widths so far + let cursorX = world.x; + for (let c = 0; c < col; c++) cursorX += (colWidths[c] || 220) + GAP; + const placeholder = createPlaceholder(viewport, cursorX, cursorY); if (jobId) uploads?.startUpload(jobId); @@ -264,7 +283,6 @@ export function setupDragDrop( newItemIds.push(id); if (placedW > (colWidths[col] || 0)) colWidths[col] = placedW; if (placedH > rowMaxH) rowMaxH = placedH; - // Link upload job to DB image for processing tracking const imgData = res.data.image || res.data; if (jobId) uploads?.uploadComplete(jobId, imgData.id); } catch (err: any) { @@ -330,6 +348,16 @@ export function setupPaste( onShortTextPaste?: (text: string) => void; }, ): () => void { + // Track last known cursor position in world coordinates for paste-at-cursor + let lastWorldX: number | null = null; + let lastWorldY: number | null = null; + const onPointerMove = (e: any) => { + const world = viewport.toWorld(e.global.x, e.global.y); + lastWorldX = world.x; + lastWorldY = world.y; + }; + viewport.on('pointermove', onPointerMove); + async function onPaste(e: ClipboardEvent) { // If focus is inside a contentEditable (e.g. BlockNote editor), let native paste through const active = document.activeElement; @@ -398,10 +426,9 @@ export function setupPaste( continue; } - // Place at viewport center (world coords) - const center = viewport.center; - const cx = center.x; - const cy = center.y; + // Place at cursor position if known, otherwise fall back to viewport center + const cx = lastWorldX ?? viewport.center.x; + const cy = lastWorldY ?? viewport.center.y; const placeholder = createPlaceholder(viewport, cx - 100, cy - 75); const jobId = uploads?.addJob(file, boardId); @@ -434,5 +461,6 @@ export function setupPaste( document.addEventListener('paste', onPaste); return () => { document.removeEventListener('paste', onPaste); + viewport.off('pointermove', onPointerMove); }; } diff --git a/frontend/src/stores/uploadManager.ts b/frontend/src/stores/uploadManager.ts index 97c4fec..4a0dcf2 100644 --- a/frontend/src/stores/uploadManager.ts +++ b/frontend/src/stores/uploadManager.ts @@ -4,7 +4,7 @@ export interface UploadJob { id: string; fileName: string; fileSize: number; - mediaType: 'image' | 'video'; + mediaType: 'image' | 'video' | 'pdf'; status: UploadStatus; progress: number; // 0-1 for upload phase error?: string; @@ -35,12 +35,13 @@ export class UploadManager { /** Create a new upload job from a local file. Returns the job ID. */ addJob(file: File, boardId: string): string { const id = crypto.randomUUID(); - const isVideo = file.type.startsWith('video/'); + const isPdf = file.type === 'application/pdf'; + const isVideo = !isPdf && file.type.startsWith('video/'); this.jobs.set(id, { id, - fileName: file.name || (isVideo ? 'video' : 'image'), + fileName: file.name || (isPdf ? 'document' : isVideo ? 'video' : 'image'), fileSize: file.size, - mediaType: isVideo ? 'video' : 'image', + mediaType: isPdf ? 'pdf' : isVideo ? 'video' : 'image', status: 'queued', progress: 0, file, @@ -75,7 +76,7 @@ export class UploadManager { } /** Create a job for a URL-based import (no File object, unknown size). */ - addUrlJob(fileName: string, mediaType: 'image' | 'video'): string { + addUrlJob(fileName: string, mediaType: 'image' | 'video' | 'pdf'): string { const id = crypto.randomUUID(); this.jobs.set(id, { id,