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 { 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);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user