feat: frontend accepts PDF uploads, UploadManager detects PDF type

This commit is contained in:
Hiren Kangad
2026-03-14 11:26:29 +05:30
parent e118ad632a
commit 3bd6270990
2 changed files with 65 additions and 36 deletions
+59 -31
View File
@@ -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 { Viewport } from 'pixi-viewport';
import type { SceneManager } from './SceneManager'; import type { SceneManager } from './SceneManager';
import type { SelectionManager } from './SelectionManager'; import type { SelectionManager } from './SelectionManager';
@@ -22,10 +22,11 @@ const ALLOWED_MIME_TYPES = new Set([
'video/mp4', 'video/mp4',
'video/webm', 'video/webm',
'video/quicktime', 'video/quicktime',
'application/pdf',
]); ]);
/** Fallback: accepted file extensions (for when browser reports empty/wrong MIME). */ /** 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). */ /** Check if a file should be accepted (MIME or extension fallback). */
function isFileAllowed(file: File): boolean { function isFileAllowed(file: File): boolean {
@@ -63,17 +64,37 @@ function extFromMime(mime: string): string {
function createPlaceholder(viewport: Viewport, x: number, y: number): Graphics { function createPlaceholder(viewport: Viewport, x: number, y: number): Graphics {
const g = new Graphics(); const g = new Graphics();
g.rect(0, 0, 200, 150); g.rect(0, 0, 200, 150);
g.fill({ color: 0x3d3d3d }); g.fill({ color: 0x2a2a2a });
g.stroke({ width: 2, color: 0x4a9eff }); g.stroke({ width: 2, color: 0x4a9eff });
g.position.set(x, y); g.position.set(x, y);
g.interactive = false; 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); viewport.addChild(g);
return g; return g;
} }
function removePlaceholder(viewport: Viewport, g: Graphics) { function removePlaceholder(viewport: Viewport, g: Graphics) {
(g as any)._pulseCleanup?.();
viewport.removeChild(g); 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 world = viewport.toWorld(e.clientX - rect.left, e.clientY - rect.top);
const GAP = 20; const GAP = 20;
// Count valid media files to determine grid columns // ── Pass 1: validate files, create all jobs upfront so queue is visible ──
let mediaCount = 0; interface QueuedFile { file: File; jobId: string | undefined; }
for (let i = 0; i < files.length; i++) { const queued: QueuedFile[] = [];
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[] = [];
for (let i = 0; i < files.length; i++) { for (let i = 0; i < files.length; i++) {
const file = files[i]; const file = files[i];
@@ -222,29 +234,32 @@ export function setupDragDrop(
if (!isFileAllowed(file)) { if (!isFileAllowed(file)) {
if (isUnsupportedMedia(file)) { if (isUnsupportedMedia(file)) {
const ext = file.name?.match(/\.(\w+)$/)?.[1] || file.type.split('/')[1] || 'unknown'; const ext = file.name?.match(/\.(\w+)$/)?.[1] || file.type.split('/')[1] || 'unknown';
uploads?.addRejected( uploads?.addRejected(file.name || 'unknown', `Unsupported format: .${ext}`);
file.name || 'unknown',
`Unsupported format: .${ext}`,
);
} }
continue; 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 // Client-side size validation
if (file.size > MAX_FILE_SIZE) { if (file.size > MAX_FILE_SIZE) {
const jobId = uploads?.addJob(file, boardId); 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)`); 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; continue;
} }
const jobId = uploads?.addJob(file, boardId); 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) // Check if job was cancelled while queued (user clicked cancel)
if (jobId && uploads?.isCancelled(jobId)) { if (jobId && uploads?.isCancelled(jobId)) {
col++; col++;
@@ -252,6 +267,10 @@ export function setupDragDrop(
continue; 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); const placeholder = createPlaceholder(viewport, cursorX, cursorY);
if (jobId) uploads?.startUpload(jobId); if (jobId) uploads?.startUpload(jobId);
@@ -264,7 +283,6 @@ export function setupDragDrop(
newItemIds.push(id); newItemIds.push(id);
if (placedW > (colWidths[col] || 0)) colWidths[col] = placedW; if (placedW > (colWidths[col] || 0)) colWidths[col] = placedW;
if (placedH > rowMaxH) rowMaxH = placedH; if (placedH > rowMaxH) rowMaxH = placedH;
// Link upload job to DB image for processing tracking
const imgData = res.data.image || res.data; const imgData = res.data.image || res.data;
if (jobId) uploads?.uploadComplete(jobId, imgData.id); if (jobId) uploads?.uploadComplete(jobId, imgData.id);
} catch (err: any) { } catch (err: any) {
@@ -330,6 +348,16 @@ export function setupPaste(
onShortTextPaste?: (text: string) => void; onShortTextPaste?: (text: string) => void;
}, },
): () => 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) { async function onPaste(e: ClipboardEvent) {
// If focus is inside a contentEditable (e.g. BlockNote editor), let native paste through // If focus is inside a contentEditable (e.g. BlockNote editor), let native paste through
const active = document.activeElement; const active = document.activeElement;
@@ -398,10 +426,9 @@ export function setupPaste(
continue; continue;
} }
// Place at viewport center (world coords) // Place at cursor position if known, otherwise fall back to viewport center
const center = viewport.center; const cx = lastWorldX ?? viewport.center.x;
const cx = center.x; const cy = lastWorldY ?? viewport.center.y;
const cy = center.y;
const placeholder = createPlaceholder(viewport, cx - 100, cy - 75); const placeholder = createPlaceholder(viewport, cx - 100, cy - 75);
const jobId = uploads?.addJob(file, boardId); const jobId = uploads?.addJob(file, boardId);
@@ -434,5 +461,6 @@ export function setupPaste(
document.addEventListener('paste', onPaste); document.addEventListener('paste', onPaste);
return () => { return () => {
document.removeEventListener('paste', onPaste); document.removeEventListener('paste', onPaste);
viewport.off('pointermove', onPointerMove);
}; };
} }
+6 -5
View File
@@ -4,7 +4,7 @@ export interface UploadJob {
id: string; id: string;
fileName: string; fileName: string;
fileSize: number; fileSize: number;
mediaType: 'image' | 'video'; mediaType: 'image' | 'video' | 'pdf';
status: UploadStatus; status: UploadStatus;
progress: number; // 0-1 for upload phase progress: number; // 0-1 for upload phase
error?: string; error?: string;
@@ -35,12 +35,13 @@ export class UploadManager {
/** Create a new upload job from a local file. Returns the job ID. */ /** Create a new upload job from a local file. Returns the job ID. */
addJob(file: File, boardId: string): string { addJob(file: File, boardId: string): string {
const id = crypto.randomUUID(); 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, { this.jobs.set(id, {
id, id,
fileName: file.name || (isVideo ? 'video' : 'image'), fileName: file.name || (isPdf ? 'document' : isVideo ? 'video' : 'image'),
fileSize: file.size, fileSize: file.size,
mediaType: isVideo ? 'video' : 'image', mediaType: isPdf ? 'pdf' : isVideo ? 'video' : 'image',
status: 'queued', status: 'queued',
progress: 0, progress: 0,
file, file,
@@ -75,7 +76,7 @@ export class UploadManager {
} }
/** Create a job for a URL-based import (no File object, unknown size). */ /** 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(); const id = crypto.randomUUID();
this.jobs.set(id, { this.jobs.set(id, {
id, id,