feat: frontend accepts PDF uploads, UploadManager detects PDF type
This commit is contained in:
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user