feat: PDF support — upload, rasterize, page picker, canvas placement

Upload PDFs → server-side page rasterization via pdftoppm (poppler-utils)
→ page picker modal for selecting pages → placed on canvas as pdf-page
objects with lazy texture loading and progressive high-res upgrades.

Key features:
- New pdf-page scene object type with source PDF metadata
- Page picker modal with lazy thumbnail loading (first 20 + on-scroll)
- Priority queue: hires jobs (priority 10) jump ahead of thumbnails (0)
- Single-page PDFs skip picker, place directly
- No crop/rotate/flip on PDF pages (enforced in TransformBox, context
  menu, and keyboard shortcuts)
- Per-page socket events for progressive texture upgrades
- 500-page cap, 50-page batch placement limit
- Early validation (before MinIO upload) prevents orphaned assets
This commit is contained in:
Hiren Kangad
2026-03-14 12:07:15 +05:30
19 changed files with 1416 additions and 71 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ RUN mkdir -p /app/data
# ffmpeg for video poster/metadata extraction at upload time
# Health check utility
RUN apk add --no-cache wget ffmpeg
RUN apk add --no-cache wget ffmpeg poppler-utils
EXPOSE 8000
+53 -5
View File
@@ -113,6 +113,20 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_media_jobs_status ON media_jobs(status);
CREATE INDEX IF NOT EXISTS idx_media_jobs_image ON media_jobs(image_id);
CREATE TABLE IF NOT EXISTS pdf_pages (
id TEXT PRIMARY KEY,
image_id TEXT NOT NULL REFERENCES images(id) ON DELETE CASCADE,
page_number INTEGER NOT NULL,
width INTEGER,
height INTEGER,
thumb_asset_key TEXT,
hires_asset_key TEXT,
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(image_id, page_number)
);
CREATE INDEX IF NOT EXISTS idx_pdf_pages_image ON pdf_pages(image_id);
`);
// ── Comment Threads & Comments ──
@@ -210,6 +224,11 @@ try {
db.exec("ALTER TABLE users ADD COLUMN mattermost_id TEXT");
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_mattermost_id ON users(mattermost_id)");
}
try { db.prepare('SELECT page_count FROM images LIMIT 0').get(); }
catch { db.exec('ALTER TABLE images ADD COLUMN page_count INTEGER'); }
try { db.prepare('SELECT priority FROM media_jobs LIMIT 0').get(); }
catch { db.exec('ALTER TABLE media_jobs ADD COLUMN priority INTEGER DEFAULT 0'); }
// ---------------------
// User helpers
@@ -504,11 +523,11 @@ function getImageByMmFileId(boardId, mmFileId) {
// ---------------------
// Media Jobs
// ---------------------
function createMediaJob({ id, imageId, boardId, type }) {
function createMediaJob({ id, imageId, boardId, type, priority, metaJson }) {
db.prepare(`
INSERT INTO media_jobs (id, image_id, board_id, type, status)
VALUES (?, ?, ?, ?, 'queued')
`).run(id, imageId, boardId, type || 'poster');
INSERT INTO media_jobs (id, image_id, board_id, type, status, priority, result_json)
VALUES (?, ?, ?, ?, 'queued', ?, ?)
`).run(id, imageId, boardId, type || 'poster', priority || 0, metaJson || null);
return db.prepare('SELECT * FROM media_jobs WHERE id = ?').get(id);
}
@@ -530,7 +549,7 @@ function getMediaJob(id) {
}
function getPendingMediaJobs(limit = 10) {
return db.prepare('SELECT * FROM media_jobs WHERE status IN (?, ?) ORDER BY created_at ASC LIMIT ?')
return db.prepare('SELECT * FROM media_jobs WHERE status IN (?, ?) ORDER BY priority DESC, created_at ASC LIMIT ?')
.all('queued', 'retry', limit);
}
@@ -541,6 +560,33 @@ function updateImageMedia(imageId, { posterAssetKey, duration, nativeWidth, nati
`).run(posterAssetKey || null, duration || null, nativeWidth || null, nativeHeight || null, imageId);
}
// ---------------------
// PDF Page helpers
// ---------------------
function createPdfPage(id, imageId, pageNumber, width, height) {
db.prepare(`INSERT INTO pdf_pages (id, image_id, page_number, width, height, status) VALUES (?, ?, ?, ?, ?, 'pending') ON CONFLICT(image_id, page_number) DO UPDATE SET width = ?, height = ?`).run(id, imageId, pageNumber, width, height, width, height);
}
function updatePdfPageThumb(imageId, pageNumber, thumbAssetKey) {
db.prepare(`UPDATE pdf_pages SET thumb_asset_key = ?, status = 'thumb_ready' WHERE image_id = ? AND page_number = ?`).run(thumbAssetKey, imageId, pageNumber);
}
function updatePdfPageHires(imageId, pageNumber, hiresAssetKey) {
db.prepare(`UPDATE pdf_pages SET hires_asset_key = ?, status = 'done' WHERE image_id = ? AND page_number = ?`).run(hiresAssetKey, imageId, pageNumber);
}
function getPdfPages(imageId) {
return db.prepare('SELECT * FROM pdf_pages WHERE image_id = ? ORDER BY page_number ASC').all(imageId);
}
function getPdfPage(imageId, pageNumber) {
return db.prepare('SELECT * FROM pdf_pages WHERE image_id = ? AND page_number = ?').get(imageId, pageNumber);
}
function updateImagePageCount(imageId, pageCount) {
db.prepare('UPDATE images SET page_count = ? WHERE id = ?').run(pageCount, imageId);
}
// ---------------------
// Thread helpers
// ---------------------
@@ -672,6 +718,8 @@ module.exports = {
getAllBoardChannelLinks, getImageByMmFileId,
// Media Jobs
createMediaJob, updateMediaJob, getMediaJob, getPendingMediaJobs, updateImageMedia,
// PDF Pages
createPdfPage, updatePdfPageThumb, updatePdfPageHires, getPdfPages, getPdfPage, updateImagePageCount,
// Threads
getThreadsByBoard, getThread, createThread, createThreadWithComment, updateThreadStatus, deleteThread,
incrementThreadCommentCount, decrementThreadCommentCount,
+1
View File
@@ -26,6 +26,7 @@ const MIME_TO_EXT = {
'video/mp4': '.mp4',
'video/webm': '.webm',
'video/quicktime': '.mov',
'application/pdf': '.pdf',
};
/**
+134
View File
@@ -0,0 +1,134 @@
/**
* pdf-utils.js — Thin wrappers around poppler-utils CLI tools.
*
* Uses pdfinfo + pdftoppm (from poppler-utils) for PDF metadata extraction
* and page rendering. No external Node dependencies required.
*/
const { execFile } = require('child_process');
const { promisify } = require('util');
const fs = require('fs');
const os = require('os');
const path = require('path');
const execFileAsync = promisify(execFile);
const TIMEOUT_MS = 60_000;
/**
* Write a buffer to a temporary file. Returns { tmpPath, cleanup }.
* Caller MUST call cleanup() when done.
*/
function bufferToTempFile(buffer, ext = '.bin') {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'refboard-pdf-'));
const tmpPath = path.join(tmpDir, `file${ext}`);
fs.writeFileSync(tmpPath, buffer);
return {
tmpPath,
cleanup() {
try { fs.unlinkSync(tmpPath); } catch {}
try { fs.rmdirSync(tmpDir); } catch {}
},
};
}
/**
* Extract PDF metadata via pdfinfo.
* Returns { pageCount, dimensions: [{ w, h }, ...] }
*/
async function pdfInfo(filePath) {
// Get basic info (page count)
const { stdout: basicOut } = await execFileAsync('pdfinfo', [filePath], { timeout: TIMEOUT_MS });
let pageCount = 0;
for (const line of basicOut.split('\n')) {
const match = line.match(/^Pages:\s+(\d+)/);
if (match) {
pageCount = parseInt(match[1], 10);
break;
}
}
if (pageCount === 0) {
throw new Error('Could not determine PDF page count');
}
// Get per-page dimensions
const dimensions = [];
const { stdout: dimOut } = await execFileAsync(
'pdfinfo',
['-f', '1', '-l', String(pageCount), filePath],
{ timeout: TIMEOUT_MS }
);
// Parse "Page N size: W x H pts" lines
for (const line of dimOut.split('\n')) {
const match = line.match(/^Page\s+\d+\s+size:\s+([\d.]+)\s+x\s+([\d.]+)/);
if (match) {
dimensions.push({
w: Math.round(parseFloat(match[1])),
h: Math.round(parseFloat(match[2])),
});
}
}
// Fallback: if no per-page sizes found, try global "Page size:"
if (dimensions.length === 0) {
for (const line of basicOut.split('\n')) {
const match = line.match(/^Page size:\s+([\d.]+)\s+x\s+([\d.]+)/);
if (match) {
const dim = {
w: Math.round(parseFloat(match[1])),
h: Math.round(parseFloat(match[2])),
};
for (let i = 0; i < pageCount; i++) {
dimensions.push(dim);
}
break;
}
}
}
return { pageCount, dimensions };
}
/**
* Render a single PDF page to PNG at the given DPI.
* Returns a PNG Buffer.
*/
async function pdfRenderPage(filePath, pageNum, dpi = 150) {
const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'refboard-render-'));
try {
const outPrefix = path.join(outDir, 'page');
await execFileAsync('pdftoppm', [
'-png',
'-r', String(dpi),
'-f', String(pageNum),
'-l', String(pageNum),
filePath,
outPrefix,
], { timeout: TIMEOUT_MS });
// pdftoppm names output like page-01.png, page-1.png, etc. — discover it
const files = fs.readdirSync(outDir).filter(f => f.endsWith('.png'));
if (files.length === 0) {
throw new Error(`pdftoppm produced no output for page ${pageNum}`);
}
const pngPath = path.join(outDir, files[0]);
const pngBuffer = fs.readFileSync(pngPath);
return pngBuffer;
} finally {
// Clean up temp dir
try {
for (const f of fs.readdirSync(outDir)) {
fs.unlinkSync(path.join(outDir, f));
}
fs.rmdirSync(outDir);
} catch {}
}
}
module.exports = { pdfInfo, pdfRenderPage, bufferToTempFile };
+153
View File
@@ -0,0 +1,153 @@
const { Router } = require('express');
const { v4: uuidv4 } = require('uuid');
const { authMiddleware } = require('../auth');
const { getBoard, getCollectionMember, getImage, getPdfPage, getPdfPages, createMediaJob } = require('../db');
const router = Router();
// All routes require auth
router.use(authMiddleware);
/**
* Helper: check board access for editor+
*/
function checkEditorAccess(req, res) {
const board = getBoard(req.params.boardId);
if (!board) {
res.status(404).json({ error: 'Board not found' });
return null;
}
const member = getCollectionMember(board.collection_id, req.user.id);
const hierarchy = { owner: 3, editor: 2, viewer: 1 };
if (!member || (hierarchy[member.role] || 0) < 2) {
res.status(403).json({ error: 'Editor or owner access required' });
return null;
}
return board;
}
/**
* POST /api/boards/:boardId/pdf-pages
* Request hi-res rendering for selected PDF pages.
* Body: { imageId, pages: [1, 2, 3, ...] }
* Max 50 pages per request.
*/
router.post('/:boardId/pdf-pages', async (req, res) => {
try {
const board = checkEditorAccess(req, res);
if (!board) return;
const { imageId, pages } = req.body;
if (!imageId || !Array.isArray(pages) || pages.length === 0) {
return res.status(400).json({ error: 'imageId and pages[] are required' });
}
if (pages.length > 50) {
return res.status(400).json({ error: 'Maximum 50 pages per request' });
}
const image = getImage(imageId);
if (!image || image.board_id !== board.id) {
return res.status(404).json({ error: 'Image not found on this board' });
}
const results = [];
for (const pageNum of pages) {
const page = getPdfPage(imageId, pageNum);
if (!page) continue;
// Skip if hires already done
if (page.hires_asset_key) {
results.push(page);
continue;
}
// Check for existing pending/processing hires job for this page
const { db } = require('../db');
const existingJob = db.prepare(
`SELECT id FROM media_jobs WHERE image_id = ? AND type = 'pdf-hires' AND result_json = ? AND status IN ('queued', 'processing')`
).get(imageId, JSON.stringify({ pageNumber: pageNum }));
if (!existingJob) {
createMediaJob({
id: uuidv4(),
imageId,
boardId: board.id,
type: 'pdf-hires',
priority: 10,
metaJson: JSON.stringify({ pageNumber: pageNum }),
});
}
results.push(page);
}
return res.json({ pages: results });
} catch (err) {
console.error('[pdf] pdf-pages error:', err);
return res.status(500).json({ error: 'Internal server error' });
}
});
/**
* POST /api/boards/:boardId/pdf-thumbnails
* Lazy-load thumbnails for PDF pages not yet rendered.
* Body: { imageId, pages: [1, 2, 3, ...] }
* Idempotent — skips pages with existing thumbs or pending jobs.
*/
router.post('/:boardId/pdf-thumbnails', async (req, res) => {
try {
const board = checkEditorAccess(req, res);
if (!board) return;
const { imageId, pages } = req.body;
if (!imageId || !Array.isArray(pages) || pages.length === 0) {
return res.status(400).json({ error: 'imageId and pages[] are required' });
}
const image = getImage(imageId);
if (!image || image.board_id !== board.id) {
return res.status(404).json({ error: 'Image not found on this board' });
}
const results = [];
for (const pageNum of pages) {
const page = getPdfPage(imageId, pageNum);
if (!page) continue;
// Skip if thumb already exists
if (page.thumb_asset_key) {
results.push(page);
continue;
}
// Skip if job already pending
const { db } = require('../db');
const existingJob = db.prepare(
`SELECT id FROM media_jobs WHERE image_id = ? AND type = 'pdf-thumbnail' AND result_json = ? AND status IN ('queued', 'processing')`
).get(imageId, JSON.stringify({ pageNumber: pageNum }));
if (!existingJob) {
createMediaJob({
id: uuidv4(),
imageId,
boardId: board.id,
type: 'pdf-thumbnail',
priority: 0,
metaJson: JSON.stringify({ pageNumber: pageNum }),
});
}
results.push(page);
}
return res.json({ pages: results });
} catch (err) {
console.error('[pdf] pdf-thumbnails error:', err);
return res.status(500).json({ error: 'Internal server error' });
}
});
module.exports = router;
+82 -2
View File
@@ -6,8 +6,9 @@ const https = require('https');
const http = require('http');
const { URL } = require('url');
const { authMiddleware } = require('../auth');
const { getBoard, getCollectionMember, createImage, createMediaJob } = require('../db');
const { getBoard, getCollectionMember, createImage, createMediaJob, createPdfPage, updateImagePageCount } = require('../db');
const { putBuffer, getImageUrl, MIME_TO_EXT, MAX_FILE_SIZE } = require('../minio');
const { pdfInfo, bufferToTempFile } = require('../pdf-utils');
const router = Router();
@@ -25,7 +26,9 @@ const VIDEO_MIME_TYPES = [
'video/quicktime',
];
const ALLOWED_MIME_TYPES = [...IMAGE_MIME_TYPES, ...VIDEO_MIME_TYPES];
const PDF_MIME_TYPES = ['application/pdf'];
const ALLOWED_MIME_TYPES = [...IMAGE_MIME_TYPES, ...VIDEO_MIME_TYPES, ...PDF_MIME_TYPES];
const MAX_FILE_SIZE_LABEL = `${MAX_FILE_SIZE / 1024 / 1024}MB`;
@@ -85,6 +88,7 @@ async function getImageDimensions(buffer, mimeType) {
*/
function classifyMedia(mimeType) {
if (VIDEO_MIME_TYPES.includes(mimeType)) return 'video';
if (PDF_MIME_TYPES.includes(mimeType)) return 'pdf';
return 'image';
}
@@ -127,6 +131,82 @@ router.post('/boards/:boardId/images', upload.single('image'), async (req, res)
const { buffer, originalname, mimetype, size } = req.file;
const mediaType = classifyMedia(mimetype);
// PDF: validate page count BEFORE uploading to MinIO or creating DB records
if (mediaType === 'pdf') {
const { tmpPath, cleanup } = bufferToTempFile(buffer, '.pdf');
try {
const info = await pdfInfo(tmpPath);
if (info.pageCount > 500) {
return res.status(400).json({ error: 'PDF exceeds 500 page limit' });
}
// Validation passed — now upload and create records
const { assetKey, minioPath, width, height } = await uploadMedia(board.id, imageId, buffer, mimetype);
const publicUrl = getImageUrl(minioPath);
const image = createImage({
id: imageId,
boardId: board.id,
filename: originalname,
mimeType: mimetype,
fileSize: size,
width,
height,
minioPath,
publicUrl,
uploadedBy: req.user.id,
assetKey,
mediaType,
});
updateImagePageCount(imageId, info.pageCount);
// Create pdf_pages rows for all pages
for (let i = 0; i < info.pageCount; i++) {
const dim = info.dimensions[i] || { w: null, h: null };
createPdfPage(uuidv4(), imageId, i + 1, dim.w, dim.h);
}
// Queue thumbnail jobs for first 20 pages
const thumbLimit = Math.min(info.pageCount, 20);
for (let i = 1; i <= thumbLimit; i++) {
createMediaJob({
id: uuidv4(),
imageId,
boardId: board.id,
type: 'pdf-thumbnail',
priority: 0,
metaJson: JSON.stringify({ pageNumber: i }),
});
}
// For single-page PDFs, also queue hires
if (info.pageCount === 1) {
createMediaJob({
id: uuidv4(),
imageId,
boardId: board.id,
type: 'pdf-hires',
priority: 10,
metaJson: JSON.stringify({ pageNumber: 1 }),
});
}
return res.status(201).json({
id: image.id,
media_type: 'pdf',
page_count: info.pageCount,
dimensions: info.dimensions,
asset_key: image.asset_key,
width: info.dimensions[0]?.w || null,
height: info.dimensions[0]?.h || null,
single_page: info.pageCount === 1,
});
} finally {
cleanup();
}
}
const { assetKey, minioPath, width, height, isVideo } = await uploadMedia(board.id, imageId, buffer, mimetype);
const publicUrl = getImageUrl(minioPath);
+2
View File
@@ -102,6 +102,7 @@ const uploadRoutes = require('./routes/upload');
const adminRoutes = require('./routes/admin');
const mmBridgeRoutes = require('./routes/mattermost-bridge');
const threadRoutes = require('./routes/threads');
const pdfRoutes = require('./routes/pdf');
app.use('/api/auth', authRoutes);
app.use('/api/auth', oauthRoutes);
@@ -111,6 +112,7 @@ app.use('/api/upload', uploadRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/boards', mmBridgeRoutes);
app.use('/api/boards', threadRoutes);
app.use('/api/boards', pdfRoutes);
// Public shared collection route (no auth required)
app.get('/api/c/:shareToken', (req, res) => {
+79 -5
View File
@@ -12,9 +12,12 @@ const {
updateMediaJob,
updateImageMedia,
getImage,
updatePdfPageThumb,
updatePdfPageHires,
} = require('../db');
const { probeVideo, extractPoster } = require('../video-utils');
const { putBuffer, minioClient, MINIO_BUCKET } = require('../minio');
const { pdfRenderPage, bufferToTempFile } = require('../pdf-utils');
const POLL_INTERVAL_MS = 3000;
const MAX_CONCURRENCY = 2;
@@ -81,7 +84,16 @@ async function processJob(job) {
updateMediaJob(jobId, { status: 'processing', startedAt: new Date().toISOString() });
emitJobUpdate(boardId, jobId, imageId, 'processing');
// Fetch the raw video from MinIO
// PDF jobs
if (job.type === 'pdf-thumbnail' || job.type === 'pdf-hires') {
const meta = JSON.parse(job.result_json);
const dpi = job.type === 'pdf-thumbnail' ? 72 : 200;
const variant = job.type === 'pdf-thumbnail' ? 'thumb' : 'hires';
await processPdfPage(job, meta.pageNumber, dpi, variant);
return;
}
// Video poster job (original logic)
const image = getImage(imageId);
if (!image) {
updateMediaJob(jobId, { status: 'failed', error: 'Image record not found' });
@@ -132,13 +144,14 @@ async function processJob(job) {
console.error('[media-worker] Job %s failed:', jobId, err.message);
// Classify error for user-facing message
let userError = 'Video processing failed';
const typeLabel = job.type.startsWith('pdf-') ? 'PDF page' : 'Video';
let userError = `${typeLabel} processing failed`;
if (err.killed || err.signal === 'SIGTERM') {
userError = 'Video processing timed out — file may be too large or corrupt';
userError = `${typeLabel} processing timed out — file may be too large or corrupt`;
} else if (err.message?.includes('ENOMEM') || err.message?.includes('Cannot allocate')) {
userError = 'Out of memory — video file is too large to process';
userError = `Out of memory — file is too large to process`;
} else if (err.message?.includes('Invalid data')) {
userError = 'Invalid or corrupt video file';
userError = `Invalid or corrupt ${typeLabel.toLowerCase()} file`;
}
const attempts = (job.attempts || 0) + 1;
@@ -152,6 +165,67 @@ async function processJob(job) {
}
}
/**
* Process a single PDF page: fetch PDF from MinIO, render page, upload PNG, update DB.
*/
async function processPdfPage(job, pageNumber, dpi, variant) {
const jobId = job.id;
const imageId = job.image_id;
const boardId = job.board_id;
const image = getImage(imageId);
if (!image) {
updateMediaJob(jobId, { status: 'failed', error: 'Image record not found' });
emitJobFailed(boardId, jobId, imageId, 'Image record not found');
return;
}
const pdfBuffer = await fetchFromMinio(image.minio_path);
if (!pdfBuffer) {
updateMediaJob(jobId, { status: 'failed', error: 'Failed to fetch PDF from storage' });
emitJobFailed(boardId, jobId, imageId, 'Failed to fetch PDF from storage');
return;
}
const { tmpPath, cleanup } = bufferToTempFile(pdfBuffer, '.pdf');
try {
const pngBuffer = await pdfRenderPage(tmpPath, pageNumber, dpi);
// Asset key: strip .pdf extension and append page/variant suffix
const basePath = image.minio_path.replace(/\.pdf$/i, '');
const suffix = variant === 'thumb' ? `_p${pageNumber}_thumb.png` : `_p${pageNumber}.png`;
const assetKey = basePath + suffix;
await putBuffer(assetKey, pngBuffer, 'image/png');
// Update pdf_pages record
if (variant === 'thumb') {
updatePdfPageThumb(imageId, pageNumber, assetKey);
} else {
updatePdfPageHires(imageId, pageNumber, assetKey);
}
updateMediaJob(jobId, {
status: 'done',
finishedAt: new Date().toISOString(),
});
const eventType = variant === 'thumb' ? 'pdf-thumbnail' : 'pdf-hires';
const resultKey = variant === 'thumb' ? 'thumbAssetKey' : 'hiresAssetKey';
emitJobUpdate(boardId, jobId, imageId, 'done', {
imageId,
type: eventType,
pageNumber,
[resultKey]: assetKey,
status: 'done',
});
console.log('[media-worker] Job %s done (pdf %s, image=%s, page=%d)', jobId, variant, imageId, pageNumber);
} finally {
cleanup();
}
}
/**
* Fetch object from MinIO as a Buffer.
*/
+5 -2
View File
@@ -22,6 +22,7 @@ import { TextureManager } from './TextureManager';
import { ImageSprite } from './sprites/ImageSprite';
import { VideoSprite } from './sprites/VideoSprite';
import { AnimatedGifSprite } from './sprites/AnimatedGifSprite';
import { PdfPageSprite } from './sprites/PdfPageSprite';
import { SpringManager } from './spring';
import { convertFabricToV2 } from './scene-format';
import type { SceneData } from './scene-format';
@@ -197,7 +198,7 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
const vcx = bounds.x + bounds.width / 2;
const vcy = bounds.y + bounds.height / 2;
const imageItems: { item: SceneItem; sprite: ImageSprite | AnimatedGifSprite; dist: number; near: boolean }[] = [];
const imageItems: { item: SceneItem; sprite: ImageSprite | AnimatedGifSprite | PdfPageSprite; dist: number; near: boolean }[] = [];
const videoItems: { item: SceneItem; sprite: VideoSprite; dist: number; near: boolean }[] = [];
// Spatial query: only check items within the extended viewport region
@@ -215,6 +216,8 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
if (item.type === 'image' && (item.displayObject instanceof ImageSprite || item.displayObject instanceof AnimatedGifSprite)) {
imageItems.push({ item, sprite: item.displayObject, dist, near: true });
} else if (item.type === 'pdf-page' && item.displayObject instanceof PdfPageSprite) {
imageItems.push({ item, sprite: item.displayObject, dist, near: true });
} else if (item.type === 'video' && item.displayObject instanceof VideoSprite) {
videoItems.push({ item, sprite: item.displayObject, dist, near: true });
}
@@ -257,7 +260,7 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
const item = scene.items.get(id);
if (!item) { loadedImages.delete(id); continue; }
const sprite = item.displayObject;
if ((sprite instanceof ImageSprite || sprite instanceof AnimatedGifSprite) && sprite.loaded) {
if ((sprite instanceof ImageSprite || sprite instanceof AnimatedGifSprite || sprite instanceof PdfPageSprite) && sprite.loaded) {
sprite.unloadTexture();
}
loadedImages.delete(id);
+38 -1
View File
@@ -15,6 +15,7 @@ import { DrawingSprite } from './sprites/DrawingSprite';
import { FrameSprite } from './sprites/FrameSprite';
import { StickySprite } from './sprites/StickySprite';
import { MarkdownSprite } from './sprites/MarkdownSprite';
import { PdfPageSprite } from './sprites/PdfPageSprite';
import { TextSprite } from './sprites/TextSprite';
import { SpringManager, Spring, PRESETS } from './spring';
import { reparentGroupChildren } from './grouping';
@@ -38,6 +39,7 @@ import type {
GroupObject,
StickyObject,
MarkdownObject,
PdfPageObject,
} from './scene-format';
// ---------------------------------------------------------------------------
@@ -58,7 +60,7 @@ function isGifAsset(asset: string): boolean {
export interface SceneItem {
id: string;
type: 'image' | 'video' | 'text' | 'drawing' | 'group' | 'sticky' | 'markdown';
type: 'image' | 'video' | 'text' | 'drawing' | 'group' | 'sticky' | 'markdown' | 'pdf-page';
displayObject: Container;
data: AnySceneObject;
}
@@ -374,6 +376,13 @@ export class SceneManager {
break;
}
case 'pdf-page': {
const d = data as PdfPageObject;
const sprite = new PdfPageSprite(d.thumb, d.asset, d.w, d.h, d.pageNumber, d.pageCount, this.textures);
displayObject = sprite;
break;
}
default:
displayObject = new Container();
break;
@@ -710,6 +719,34 @@ export class SceneManager {
return this.items.get(data.id)!;
}
/** Create a PdfPageObject from a PDF upload and add it to the scene. */
addPdfPageFromUpload(
thumbKey: string | null, assetKey: string | null,
w: number, h: number, x: number, y: number,
pdfImageId: string, pdfName: string,
pageNumber: number, pageCount: number,
): SceneItem {
const data: PdfPageObject = {
id: crypto.randomUUID(),
type: 'pdf-page',
x, y, w, h,
sx: 1, sy: 1, angle: 0,
z: this.nextZ(),
opacity: 1,
locked: false,
visible: true,
name: `${pdfName} p.${pageNumber}`,
asset: assetKey,
thumb: thumbKey,
pdfImageId, pdfName, pageNumber, pageCount,
nativeW: w, nativeH: h,
};
this._createItem(data);
this._applyZOrder();
this._onChange?.();
return this.items.get(data.id)!;
}
// -- Group / Ungroup with Spring Animation --------------------------------
/**
+4 -4
View File
@@ -157,10 +157,10 @@ export function buildContextMenuItems(ctx: MenuContext): MenuItem[] {
{ label: '', shortcut: '', onClick: () => {}, divider: true },
// -- Image --
{ label: 'Flip Horizontal', shortcut: 'Alt+Shift+H', onClick: () => { ops.flipHorizontal(selected); selection?.transformBox.update(selected); ctx.onChange(ids); }, disabled: !hasSel },
{ label: 'Flip Vertical', shortcut: 'Alt+Shift+V', onClick: () => { ops.flipVertical(selected); selection?.transformBox.update(selected); ctx.onChange(ids); }, disabled: !hasSel },
{ label: 'Rotate Clockwise', shortcut: 'R', onClick: () => { ops.rotate90(selected, true); selection?.transformBox.update(selected); ctx.onChange(ids); }, disabled: !hasSel },
{ label: 'Rotate Counter-Clockwise', shortcut: 'Shift+R', onClick: () => { ops.rotate90(selected, false); selection?.transformBox.update(selected); ctx.onChange(ids); }, disabled: !hasSel },
{ label: 'Flip Horizontal', shortcut: 'Alt+Shift+H', onClick: () => { ops.flipHorizontal(selected); selection?.transformBox.update(selected); ctx.onChange(ids); }, disabled: !hasSel || selected.some(i => ['pdf-page', 'sticky', 'markdown', 'text'].includes(i.data.type)) },
{ label: 'Flip Vertical', shortcut: 'Alt+Shift+V', onClick: () => { ops.flipVertical(selected); selection?.transformBox.update(selected); ctx.onChange(ids); }, disabled: !hasSel || selected.some(i => ['pdf-page', 'sticky', 'markdown', 'text'].includes(i.data.type)) },
{ label: 'Rotate Clockwise', shortcut: 'R', onClick: () => { ops.rotate90(selected, true); selection?.transformBox.update(selected); ctx.onChange(ids); }, disabled: !hasSel || selected.some(i => ['pdf-page', 'sticky', 'markdown', 'text'].includes(i.data.type)) },
{ label: 'Rotate Counter-Clockwise', shortcut: 'Shift+R', onClick: () => { ops.rotate90(selected, false); selection?.transformBox.update(selected); ctx.onChange(ids); }, disabled: !hasSel || selected.some(i => ['pdf-page', 'sticky', 'markdown', 'text'].includes(i.data.type)) },
{ label: 'Crop', shortcut: 'C', onClick: () => ctx.startCrop?.(), disabled: selected.length !== 1 || selected[0]?.data.type !== 'image' },
{ label: 'Reset Transform', shortcut: 'Ctrl+Shift+T', onClick: () => { ops.resetTransform(selected); selection?.transformBox.update(selected); ctx.onChange(ids); }, disabled: !hasSel },
{ label: '', shortcut: '', onClick: () => {}, divider: true },
+123 -33
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 { SceneManager } from './SceneManager';
import type { SelectionManager } from './SelectionManager';
@@ -8,6 +8,14 @@ import { wasRecentInternalPaste } from './shortcut-definitions';
type OnChange = () => void;
export interface PdfUploadedData {
imageId: string;
fileName: string;
pageCount: number;
dimensions: Array<{ w: number; h: number }>;
singlePage: boolean;
}
/** Client-side file size limit (matches backend MAX_FILE_SIZE_MB default) */
const MAX_FILE_SIZE_MB = 200;
const MAX_FILE_SIZE = MAX_FILE_SIZE_MB * 1024 * 1024;
@@ -22,10 +30,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 +72,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 });
}
/* ------------------------------------------------------------------ */
@@ -134,6 +163,7 @@ export function setupDragDrop(
onChange: OnChange,
selection?: SelectionManager | null,
uploads?: UploadManager | null,
onPdfUploaded?: (data: PdfUploadedData) => void,
): () => void {
function onDragOver(e: DragEvent) {
e.preventDefault();
@@ -202,18 +232,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 +243,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 +276,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);
@@ -260,11 +288,37 @@ export function setupDragDrop(
if (jobId) uploads?.setProgress(jobId, p);
});
removePlaceholder(viewport, placeholder);
// PDF fork — single-page goes directly on canvas, multi-page opens picker
const resData = res.data.image || res.data;
if (resData.media_type === 'pdf') {
if (jobId) uploads?.uploadComplete(jobId, resData.id);
if (resData.single_page) {
const item = sceneManager.addPdfPageFromUpload(
null, null, resData.width, resData.height,
cursorX, cursorY,
resData.id, file.name, 1, 1,
);
newItemIds.push(item.id);
onChange();
} else if (onPdfUploaded) {
onPdfUploaded({
imageId: resData.id,
fileName: file.name,
pageCount: resData.page_count,
dimensions: resData.dimensions,
singlePage: false,
});
}
col++;
if (col >= cols) { col = 0; row++; cursorY += rowMaxH + GAP; rowMaxH = 0; }
continue;
}
const { w: placedW, h: placedH, id } = handleUploadResult(res, viewport, sceneManager, cursorX, cursorY, onChange);
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) {
@@ -328,8 +382,19 @@ export function setupPaste(
opts?: {
onTextPaste?: (data: { text: string; html: string; hasImage: boolean }) => void;
onShortTextPaste?: (text: string) => void;
onPdfUploaded?: (data: PdfUploadedData) => 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;
@@ -349,7 +414,7 @@ export function setupPaste(
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type.startsWith('image/') || item.type.startsWith('video/')) hasMedia = true;
if (item.type.startsWith('image/') || item.type.startsWith('video/') || item.type === 'application/pdf') hasMedia = true;
if (item.type === 'text/plain') textContent = e.clipboardData?.getData('text/plain') || '';
if (item.type === 'text/html') htmlContent = e.clipboardData?.getData('text/html') || '';
}
@@ -374,7 +439,7 @@ export function setupPaste(
const item = items[i];
// Skip non-media clipboard items (text, html, etc.)
if (!item.type.startsWith('image/') && !item.type.startsWith('video/')) continue;
if (!item.type.startsWith('image/') && !item.type.startsWith('video/') && item.type !== 'application/pdf') continue;
e.preventDefault();
@@ -398,10 +463,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);
@@ -411,6 +475,31 @@ export function setupPaste(
if (jobId) uploads?.setProgress(jobId, p);
});
removePlaceholder(viewport, placeholder);
// PDF fork — single-page goes directly on canvas, multi-page opens picker
const resData = res.data.image || res.data;
if (resData.media_type === 'pdf') {
if (jobId) uploads?.uploadComplete(jobId, resData.id);
if (resData.single_page) {
const pdfItem = sceneManager.addPdfPageFromUpload(
null, null, resData.width, resData.height,
cx - 100, cy - 75,
resData.id, file.name, 1, 1,
);
newItemIds.push(pdfItem.id);
onChange();
} else if (opts?.onPdfUploaded) {
opts.onPdfUploaded({
imageId: resData.id,
fileName: file.name,
pageCount: resData.page_count,
dimensions: resData.dimensions,
singlePage: false,
});
}
continue;
}
const { id } = handleUploadResult(res, viewport, sceneManager, cx - 100, cy - 75, onChange);
newItemIds.push(id);
const imgData = res.data.image || res.data;
@@ -434,5 +523,6 @@ export function setupPaste(
document.addEventListener('paste', onPaste);
return () => {
document.removeEventListener('paste', onPaste);
viewport.off('pointermove', onPointerMove);
};
}
+14 -2
View File
@@ -4,7 +4,7 @@
export interface SceneObject {
id: string;
type: 'image' | 'video' | 'text' | 'group' | 'drawing' | 'sticky' | 'markdown';
type: 'image' | 'video' | 'text' | 'group' | 'drawing' | 'sticky' | 'markdown' | 'pdf-page';
x: number;
y: number;
w: number;
@@ -94,7 +94,19 @@ export interface MarkdownObject extends SceneObject {
cornerRadius: number; // border radius, default 10
}
export type AnySceneObject = ImageObject | VideoObject | TextObject | DrawingObject | GroupObject | StickyObject | MarkdownObject;
export interface PdfPageObject extends SceneObject {
type: 'pdf-page';
asset: string | null; // high-res asset key — null until pdf-hires job completes
thumb: string | null; // thumbnail asset key — null until pdf-thumbnail job completes
pdfImageId: string; // source PDF's image ID
pdfName: string; // original filename
pageNumber: number; // 1-indexed
pageCount: number; // total pages in source PDF
nativeW: number; // pixel dimensions at 200 DPI
nativeH: number;
}
export type AnySceneObject = ImageObject | VideoObject | TextObject | DrawingObject | GroupObject | StickyObject | MarkdownObject | PdfPageObject;
export interface SceneData {
v: 2;
+20 -4
View File
@@ -238,12 +238,20 @@ export const shortcuts: ShortcutDef[] = [
{
id: 'flip-h', keys: { key: 'h', alt: true, shift: true },
category: 'image', description: 'Flip horizontal', needsSelection: true,
handler: (ctx) => _opUpdate(ctx, ops.flipHorizontal),
handler: (ctx) => {
const items = ctx.selection.getSelectedItems();
if (items.some(i => ['pdf-page', 'sticky', 'markdown', 'text'].includes(i.data.type))) return;
_opUpdate(ctx, ops.flipHorizontal);
},
},
{
id: 'flip-v', keys: { key: 'v', alt: true, shift: true },
category: 'image', description: 'Flip vertical', needsSelection: true,
handler: (ctx) => _opUpdate(ctx, ops.flipVertical),
handler: (ctx) => {
const items = ctx.selection.getSelectedItems();
if (items.some(i => ['pdf-page', 'sticky', 'markdown', 'text'].includes(i.data.type))) return;
_opUpdate(ctx, ops.flipVertical);
},
},
{
id: 'reset-transform', keys: { key: 't', ctrl: true, shift: true },
@@ -316,12 +324,20 @@ export const shortcuts: ShortcutDef[] = [
{
id: 'rotate-cw', keys: { key: 'r' },
category: 'image', description: 'Rotate 90° clockwise', needsSelection: true,
handler: (ctx) => _opUpdate(ctx, (items) => ops.rotate90(items, true)),
handler: (ctx) => {
const items = ctx.selection.getSelectedItems();
if (items.some(i => ['pdf-page', 'sticky', 'markdown', 'text'].includes(i.data.type))) return;
_opUpdate(ctx, (items) => ops.rotate90(items, true));
},
},
{
id: 'rotate-ccw', keys: { key: 'r', shift: true },
category: 'image', description: 'Rotate 90° counter-clockwise', needsSelection: true,
handler: (ctx) => _opUpdate(ctx, (items) => ops.rotate90(items, false)),
handler: (ctx) => {
const items = ctx.selection.getSelectedItems();
if (items.some(i => ['pdf-page', 'sticky', 'markdown', 'text'].includes(i.data.type))) return;
_opUpdate(ctx, (items) => ops.rotate90(items, false));
},
},
// ═══════════════════════════════════════
@@ -0,0 +1,261 @@
import { Container, Sprite, Texture, Graphics, Text, TextStyle } from "pixi.js";
import { TextureManager } from "../TextureManager";
/**
* PdfPageSprite — a Container for rendering a single PDF page on the canvas.
*
* Follows the ImageSprite pattern: shadow + placeholder + lazy texture loading.
* Adds a page badge in the bottom-right corner showing "p.{N}/{total}".
*
* Textures are loaded/unloaded by the viewport culling system (PixiCanvas).
* The constructor does NOT start loading — call loadTexture() when near viewport.
*/
// Shadow defaults (resting state)
const SHADOW_REST = { offsetX: 3, offsetY: 3, alpha: 0.2 };
// Shadow lifted state (during drag)
const SHADOW_LIFT = { offsetX: 6, offsetY: 8, alpha: 0.3 };
export class PdfPageSprite extends Container {
private _thumbKey: string | null;
private _assetKey: string | null;
private _activeKey: string | null = null; // which key is currently loaded
private textures: TextureManager;
loaded = false;
private loading = false;
private placeholder: Graphics | null = null;
private _sprite: Sprite | null = null;
private _shadow: Graphics;
private _badge: Container;
private _naturalWidth: number;
private _naturalHeight: number;
private _shadowCfg = SHADOW_REST;
readonly pageNumber: number;
readonly pageCount: number;
constructor(
thumbKey: string | null,
assetKey: string | null,
w: number,
h: number,
pageNumber: number,
pageCount: number,
textures: TextureManager,
) {
super();
this._thumbKey = thumbKey;
this._assetKey = assetKey;
this._naturalWidth = w;
this._naturalHeight = h;
this.pageNumber = pageNumber;
this.pageCount = pageCount;
this.textures = textures;
// Shadow: simple dark rect behind the sprite (cheap, no GPU filter)
this._shadow = new Graphics();
this._drawShadow(SHADOW_REST);
this.addChild(this._shadow);
// Placeholder: dark rect shown until texture is loaded by culling system
const placeholder = new Graphics();
placeholder.rect(0, 0, w, h).fill(0x2a2a2a);
this.placeholder = placeholder;
this.addChild(placeholder);
// Page badge: bottom-right corner — "p.N/total"
this._badge = this._buildBadge(pageNumber, pageCount, w, h);
this.addChild(this._badge);
}
get naturalWidth(): number { return this._naturalWidth; }
get naturalHeight(): number { return this._naturalHeight; }
/** Best available asset key: prefer high-res, fall back to thumb. */
private get _bestKey(): string | null {
return this._assetKey ?? this._thumbKey;
}
private _drawShadow(cfg: { offsetX: number; offsetY: number; alpha: number }): void {
this._shadow.clear();
this._shadow.rect(cfg.offsetX, cfg.offsetY, this._naturalWidth, this._naturalHeight);
this._shadow.fill({ color: 0x000000, alpha: cfg.alpha });
}
/** Build the page badge container (rounded rect bg + text). */
private _buildBadge(page: number, total: number, w: number, h: number): Container {
const badge = new Container();
const style = new TextStyle({
fontSize: 12,
fill: 0xffffff,
fontFamily: 'sans-serif',
});
const label = new Text({ text: `p.${page}/${total}`, style });
const padX = 6;
const padY = 3;
const bw = label.width + padX * 2;
const bh = label.height + padY * 2;
const bg = new Graphics();
bg.roundRect(0, 0, bw, bh, 4);
bg.fill({ color: 0x000000, alpha: 0.6 });
label.position.set(padX, padY);
badge.addChild(bg);
badge.addChild(label);
// Position at bottom-right of the page
badge.position.set(w - bw - 6, h - bh - 6);
return badge;
}
/** Expand shadow for drag-lift effect. */
liftShadow(): void {
this._shadowCfg = SHADOW_LIFT;
this._drawShadow(this._shadowCfg);
}
/** Restore shadow to resting state. */
dropShadow(): void {
this._shadowCfg = SHADOW_REST;
this._drawShadow(this._shadowCfg);
}
/** The underlying sprite's texture. */
get texture(): Texture {
return this._sprite?.texture ?? Texture.EMPTY;
}
/** Load the best available texture. Called by viewport culling when near viewport. */
async loadTexture(): Promise<void> {
if (this.loaded || this.loading) return;
const key = this._bestKey;
if (!key) return;
this.loading = true;
try {
const tex = await this.textures.load(key);
if (this.destroyed) return;
// Create sprite with real texture and add to display tree
const sprite = new Sprite(tex);
sprite.width = this._naturalWidth;
sprite.height = this._naturalHeight;
this._sprite = sprite;
// Insert before placeholder (so placeholder is on top until removed)
this.addChild(sprite);
this._activeKey = key;
this.loaded = true;
// Remove placeholder after successful load
if (this.placeholder) {
this.removeChild(this.placeholder);
this.placeholder.destroy();
this.placeholder = null;
}
// Ensure badge stays on top
this.setChildIndex(this._badge, this.children.length - 1);
} catch (err) {
console.warn(
`[PdfPageSprite] Failed to load "${key}":`,
err,
);
} finally {
this.loading = false;
}
}
/** Swap to high-res texture once the pdf-hires job completes. */
async upgradeTexture(hiresAssetKey: string): Promise<void> {
this._assetKey = hiresAssetKey;
// If not loaded at all, the next loadTexture() will pick it up
if (!this.loaded || !this._sprite) return;
try {
const tex = await this.textures.load(hiresAssetKey);
if (this.destroyed) return;
// Release old key
if (this._activeKey && this._activeKey !== hiresAssetKey) {
this.textures.release(this._activeKey);
}
this._sprite.texture = tex;
this._activeKey = hiresAssetKey;
} catch (err) {
console.warn(`[PdfPageSprite] Failed to upgrade to hires "${hiresAssetKey}":`, err);
}
}
/** Set thumbnail key when it arrives after initial placement. */
async setThumb(thumbKey: string): Promise<void> {
this._thumbKey = thumbKey;
// If already loaded with hires, no need to do anything
if (this.loaded && this._assetKey && this._activeKey === this._assetKey) return;
// If not loaded at all, the next loadTexture() will pick up the thumb
if (!this.loaded) return;
// Currently loaded with nothing better — swap to thumb
try {
const tex = await this.textures.load(thumbKey);
if (this.destroyed) return;
if (this._activeKey && this._activeKey !== thumbKey) {
this.textures.release(this._activeKey);
}
if (!this._sprite) {
this._sprite = new Sprite(tex);
this._sprite.width = this._naturalWidth;
this._sprite.height = this._naturalHeight;
this.addChild(this._sprite);
this.setChildIndex(this._badge, this.children.length - 1);
} else {
this._sprite.texture = tex;
}
this._activeKey = thumbKey;
} catch (err) {
console.warn(`[PdfPageSprite] Failed to load thumb "${thumbKey}":`, err);
}
}
/** Unload texture to free GPU memory. Called by viewport culling when far from viewport. */
unloadTexture(): void {
if (!this.loaded) return;
if (this._activeKey) {
this.textures.release(this._activeKey);
this._activeKey = null;
}
// Remove sprite from display tree entirely — avoids PixiJS v8 render crash
if (this._sprite) {
this.removeChild(this._sprite);
this._sprite.destroy();
this._sprite = null;
}
this.loaded = false;
// Restore placeholder
if (!this.placeholder) {
const placeholder = new Graphics();
placeholder.rect(0, 0, this._naturalWidth, this._naturalHeight).fill(0x2a2a2a);
this.placeholder = placeholder;
this.addChild(placeholder);
}
// Ensure badge stays on top
this.setChildIndex(this._badge, this.children.length - 1);
}
}
+292
View File
@@ -0,0 +1,292 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import api from '../api';
import { getSocket } from '../socket';
interface PdfPickerModalProps {
imageId: string;
fileName: string;
pageCount: number;
dimensions: Array<{ w: number; h: number }>;
boardId: string;
onPlace: (selectedPages: number[]) => void;
onCancel: () => void;
}
const MAX_SELECTIONS = 50;
const THUMB_BATCH = 20;
const GRID_COLS = 5;
export default function PdfPickerModal({
imageId, fileName, pageCount, dimensions, boardId, onPlace, onCancel,
}: PdfPickerModalProps) {
const [selected, setSelected] = useState<Set<number>>(new Set());
const [thumbs, setThumbs] = useState<Map<number, string>>(new Map());
const [thumbsLoaded, setThumbsLoaded] = useState(0);
const [requestedUpTo, setRequestedUpTo] = useState(0);
const scrollRef = useRef<HTMLDivElement>(null);
const requestingRef = useRef(false);
// Request first batch of thumbnails on mount
useEffect(() => {
requestThumbnails(1, Math.min(THUMB_BATCH, pageCount));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Listen for socket thumbnail events
useEffect(() => {
const socket = getSocket();
if (!socket) return;
const handler = (data: any) => {
if (!data || data.imageId !== imageId) return;
if (data.type === 'pdf-thumbnail' && data.status === 'done') {
const page: number = data.pageNumber;
const key: string = data.thumbAssetKey;
if (page && key) {
setThumbs(prev => {
const next = new Map(prev);
next.set(page, key);
return next;
});
setThumbsLoaded(prev => prev + 1);
}
}
};
socket.on('media:job:update', handler);
return () => { socket.off('media:job:update', handler); };
}, [imageId]);
const requestThumbnails = useCallback(async (from: number, to: number) => {
if (requestingRef.current) return;
if (from > pageCount) return;
const clampedTo = Math.min(to, pageCount);
if (clampedTo <= requestedUpTo) return;
requestingRef.current = true;
const pages: number[] = [];
for (let p = Math.max(from, requestedUpTo + 1); p <= clampedTo; p++) pages.push(p);
if (pages.length > 0) {
try {
await api.post(`/api/boards/${boardId}/pdf-thumbnails`, {
imageId,
pages,
});
} catch (err) {
console.error('[PdfPickerModal] thumbnail request failed:', err);
}
}
setRequestedUpTo(clampedTo);
requestingRef.current = false;
}, [boardId, imageId, pageCount, requestedUpTo]);
// Lazy scroll loading — request next batch when near bottom
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 200;
if (nearBottom && requestedUpTo < pageCount) {
requestThumbnails(requestedUpTo + 1, requestedUpTo + THUMB_BATCH);
}
}, [requestThumbnails, requestedUpTo, pageCount]);
const togglePage = (page: number) => {
setSelected(prev => {
const next = new Set(prev);
if (next.has(page)) {
next.delete(page);
} else if (next.size < MAX_SELECTIONS) {
next.add(page);
}
return next;
});
};
const selectAll = () => {
const all = new Set<number>();
const max = Math.min(pageCount, MAX_SELECTIONS);
for (let i = 1; i <= max; i++) all.add(i);
setSelected(all);
};
const deselectAll = () => {
setSelected(new Set());
};
const handlePlace = () => {
if (selected.size === 0) return;
const sorted = Array.from(selected).sort((a, b) => a - b);
onPlace(sorted);
};
// Build asset URL from key
const thumbUrl = (key: string) => `/api/images/${key}`;
return (
<div style={overlayStyle} onClick={onCancel}>
<div style={panelStyle} onClick={e => e.stopPropagation()}>
{/* Header */}
<div style={headerStyle}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 600, color: '#e0e0e0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{fileName}
</div>
<div style={{ fontSize: 12, color: '#888', marginTop: 2 }}>
{pageCount} page{pageCount !== 1 ? 's' : ''}
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button style={smallBtnStyle} onClick={selectAll}>Select all</button>
<button style={smallBtnStyle} onClick={deselectAll}>Deselect all</button>
</div>
</div>
{/* Sub-header: loading counter */}
<div style={{ padding: '4px 20px 8px', fontSize: 12, color: '#666' }}>
Loading thumbnails... ({thumbsLoaded}/{pageCount})
</div>
{/* Scrollable thumbnail grid */}
<div
ref={scrollRef}
onScroll={handleScroll}
style={gridContainerStyle}
>
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${GRID_COLS}, 1fr)`, gap: 8 }}>
{Array.from({ length: pageCount }, (_, i) => {
const page = i + 1;
const dim = dimensions[i] || { w: 612, h: 792 };
const aspect = dim.h / dim.w;
const isSelected = selected.has(page);
const thumbKey = thumbs.get(page);
return (
<div
key={page}
onClick={() => togglePage(page)}
style={{
position: 'relative',
cursor: 'pointer',
borderRadius: 4,
overflow: 'hidden',
border: isSelected ? '2px solid #4a9eff' : '2px solid transparent',
background: '#1a1a1a',
}}
>
<div style={{ paddingTop: `${aspect * 100}%`, position: 'relative' }}>
{thumbKey ? (
<img
src={thumbUrl(thumbKey)}
alt={`Page ${page}`}
style={{
position: 'absolute',
top: 0, left: 0, width: '100%', height: '100%',
objectFit: 'cover',
}}
/>
) : (
<div style={{
position: 'absolute',
top: 0, left: 0, width: '100%', height: '100%',
background: '#2a2a2a',
animation: 'pdfPickerPulse 1.5s ease-in-out infinite',
}} />
)}
</div>
{/* Page number badge */}
<div style={{
position: 'absolute', bottom: 4, right: 4,
background: 'rgba(0,0,0,0.7)', color: '#ccc',
fontSize: 10, padding: '1px 5px', borderRadius: 3,
}}>
{page}
</div>
{/* Selection checkmark */}
{isSelected && (
<div style={{
position: 'absolute', top: 4, right: 4,
width: 20, height: 20, borderRadius: '50%',
background: '#4a9eff', display: 'flex',
alignItems: 'center', justifyContent: 'center',
fontSize: 12, color: '#fff',
}}>
</div>
)}
</div>
);
})}
</div>
</div>
{/* Footer */}
<div style={footerStyle}>
<button style={cancelBtnStyle} onClick={onCancel}>Cancel</button>
<button
style={{
...placeBtnStyle,
opacity: selected.size === 0 ? 0.4 : 1,
cursor: selected.size === 0 ? 'default' : 'pointer',
}}
disabled={selected.size === 0}
onClick={handlePlace}
>
Place {selected.size} page{selected.size !== 1 ? 's' : ''}
</button>
</div>
</div>
{/* Keyframe animation for pulsing placeholder */}
<style>{`
@keyframes pdfPickerPulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.8; }
}
`}</style>
</div>
);
}
// Styles
const overlayStyle: React.CSSProperties = {
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.6)', zIndex: 10000,
display: 'flex', alignItems: 'center', justifyContent: 'center',
};
const panelStyle: React.CSSProperties = {
width: 700, maxHeight: '80vh', background: '#242424',
borderRadius: 8, display: 'flex', flexDirection: 'column',
boxShadow: '0 8px 32px rgba(0,0,0,0.5)',
};
const headerStyle: React.CSSProperties = {
padding: '16px 20px 8px', display: 'flex', alignItems: 'center', gap: 12,
borderBottom: '1px solid #333',
};
const smallBtnStyle: React.CSSProperties = {
background: '#333', border: 'none', color: '#ccc',
padding: '4px 10px', borderRadius: 4, fontSize: 12, cursor: 'pointer',
};
const gridContainerStyle: React.CSSProperties = {
flex: 1, overflowY: 'auto', padding: '8px 20px',
minHeight: 0,
};
const footerStyle: React.CSSProperties = {
padding: '12px 20px', borderTop: '1px solid #333',
display: 'flex', justifyContent: 'flex-end', gap: 10,
};
const cancelBtnStyle: React.CSSProperties = {
background: 'transparent', border: '1px solid #555', color: '#aaa',
padding: '8px 16px', borderRadius: 4, cursor: 'pointer', fontSize: 13,
};
const placeBtnStyle: React.CSSProperties = {
background: '#4a9eff', border: 'none', color: '#fff',
padding: '8px 20px', borderRadius: 4, fontSize: 13, fontWeight: 600,
};
+41 -4
View File
@@ -9,6 +9,8 @@ import { InboxZone } from '../canvas/InboxZone';
import { LaserPointer } from '../canvas/LaserPointer';
import { VideoSprite } from '../canvas/sprites/VideoSprite';
import { ImageSprite } from '../canvas/sprites/ImageSprite';
import { PdfPageSprite } from '../canvas/sprites/PdfPageSprite';
import type { PdfUploadedData } from '../canvas/image-drop';
import { UploadManager } from '../stores/uploadManager';
import { AnnotationStore } from '../stores/annotationStore';
import { PinOverlay } from '../canvas/PinOverlay';
@@ -61,7 +63,9 @@ interface CanvasSetupDeps {
pasteOpts?: {
onTextPaste?: (data: { text: string; html: string; hasImage: boolean }) => void;
onShortTextPaste?: (text: string) => void;
onPdfUploaded?: (data: PdfUploadedData) => void;
};
onPdfUploaded?: (data: PdfUploadedData) => void;
onCropModeChange?: (active: boolean) => void;
}
@@ -74,7 +78,7 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
boardData, resolvedBoardId, user, isPublicView,
canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, canvasContainerRef,
uploadManager, onCanvasChange, showToast, setOnlineUsers, setSelectedLayerIds,
pasteOpts,
pasteOpts, onPdfUploaded,
onCropModeChange,
} = deps;
@@ -324,6 +328,38 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
// Update upload manager — transitions video jobs from "processing" to "done"
uploadManager.processingComplete(imageId);
// PDF thumbnail arrived — update placed pdf-page items
if (data.type === 'pdf-thumbnail') {
for (const item of scene.items.values()) {
if (item.data.type !== 'pdf-page') continue;
const d = item.data as any;
if (d.pdfImageId === imageId && d.pageNumber === data.pageNumber) {
d.thumb = data.thumbAssetKey;
if (item.displayObject instanceof PdfPageSprite) {
item.displayObject.setThumb(data.thumbAssetKey);
}
break;
}
}
return;
}
// PDF hires arrived — upgrade texture on placed pdf-page items
if (data.type === 'pdf-hires') {
for (const item of scene.items.values()) {
if (item.data.type !== 'pdf-page') continue;
const d = item.data as any;
if (d.pdfImageId === imageId && d.pageNumber === data.pageNumber) {
d.asset = data.hiresAssetKey;
if (item.displayObject instanceof PdfPageSprite) {
item.displayObject.upgradeTexture(data.hiresAssetKey);
}
break;
}
}
return;
}
// Find the video item by matching its DB image ID stored in scene data
for (const item of scene.items.values()) {
if (item.data.type !== 'video') continue;
@@ -513,9 +549,10 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
dropTarget = canvasEl?.parentElement ?? null;
}
if (dropTarget) {
dropCleanupRef.current = setupDragDrop(dropTarget, viewport, scene, resolvedBoardId, onCanvasChange, selection, uploadManager);
dropCleanupRef.current = setupDragDrop(dropTarget, viewport, scene, resolvedBoardId, onCanvasChange, selection, uploadManager, onPdfUploaded);
}
pasteCleanupRef.current = setupPaste(viewport, scene, resolvedBoardId, onCanvasChange, selection, uploadManager, pasteOpts);
const mergedPasteOpts = pasteOpts ? { ...pasteOpts, onPdfUploaded } : { onPdfUploaded };
pasteCleanupRef.current = setupPaste(viewport, scene, resolvedBoardId, onCanvasChange, selection, uploadManager, mergedPasteOpts);
}
}, 50);
@@ -552,7 +589,7 @@ export function useCanvasSetup(deps: CanvasSetupDeps) {
sharpnessCleanupRef.current = null;
disconnectSocket();
};
}, [boardData, resolvedBoardId, user, isPublicView, onCanvasChange, showToast, canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, uploadManager, setOnlineUsers, setSelectedLayerIds, pasteOpts, onCropModeChange]);
}, [boardData, resolvedBoardId, user, isPublicView, onCanvasChange, showToast, canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, uploadManager, setOnlineUsers, setSelectedLayerIds, pasteOpts, onPdfUploaded, onCropModeChange]);
return { annotationStore: annotationStoreRef.current, pinOverlay: pinOverlayRef.current, textEditor: textEditorRef.current, cropOverlayRef, mdOverlay: mdOverlayRef.current };
}
+107 -3
View File
@@ -46,7 +46,8 @@ import ReactDOM from 'react-dom';
import MarkdownReadView from '../components/MarkdownReadView';
import PasteChoicePopup from '../components/PasteChoicePopup';
import MarkdownFormatToolbar from '../components/MarkdownFormatToolbar';
import { saveCanvas } from '../api';
import PdfPickerModal from '../components/PdfPickerModal';
import api, { saveCanvas } from '../api';
const LazyMarkdownEditView = React.lazy(() => import('../components/MarkdownEditView'));
// Hooks
@@ -147,6 +148,10 @@ export default function Editor({ isPublicView }: EditorProps) {
const [minimapData, setMinimapData] = useState<{ items: any[]; viewportBounds: any; contentBounds: any }>({
items: [], viewportBounds: { x: 0, y: 0, w: 1, h: 1 }, contentBounds: { x: 0, y: 0, w: 1, h: 1 },
});
const [pdfPicker, setPdfPicker] = useState<{
imageId: string; fileName: string; pageCount: number;
dimensions: Array<{ w: number; h: number }>;
} | null>(null);
// Derived
const { boardData, loading, error } = useBoardLoader(boardId);
@@ -247,18 +252,104 @@ export default function Editor({ isPublicView }: EditorProps) {
onCanvasChange([textData.id]);
}, [canvasRef, onCanvasChange]);
// PDF upload callback — opens picker for multi-page PDFs
const onPdfUploaded = useCallback((data: { imageId: string; fileName: string; pageCount: number; dimensions: Array<{ w: number; h: number }>; singlePage: boolean }) => {
if (data.singlePage) return; // single-page PDFs are placed directly
setPdfPicker({
imageId: data.imageId,
fileName: data.fileName,
pageCount: data.pageCount,
dimensions: data.dimensions,
});
}, []);
// Handle placing selected PDF pages from the picker
const handlePdfPlace = useCallback(async (selectedPages: number[]) => {
if (!pdfPicker || !resolvedBoardId) return;
const scene = canvasRef.current?.getScene();
const viewport = canvasRef.current?.getViewport();
if (!scene || !viewport) return;
try {
// Request hires rendering for selected pages
await api.post(`/api/boards/${resolvedBoardId}/pdf-pages`, {
imageId: pdfPicker.imageId,
pages: selectedPages,
});
} catch (err) {
console.error('[Editor] pdf-pages request failed:', err);
}
// Place pages in a grid at viewport center
const cx = viewport.center.x;
const cy = viewport.center.y;
const GAP = 20;
const cols = Math.ceil(Math.sqrt(selectedPages.length)) || 1;
let col = 0;
let cursorY = cy;
let rowMaxH = 0;
const colWidths: number[] = new Array(cols).fill(0);
const newItemIds: string[] = [];
for (const page of selectedPages) {
const dim = pdfPicker.dimensions[page - 1] || { w: 612, h: 792 };
// Cap each page to 600px max dimension
const maxDim = 600;
let w = dim.w;
let h = dim.h;
if (w > maxDim || h > maxDim) {
const scale = maxDim / Math.max(w, h);
w = Math.round(w * scale);
h = Math.round(h * scale);
}
let cursorX = cx;
for (let c = 0; c < col; c++) cursorX += (colWidths[c] || 220) + GAP;
const item = scene.addPdfPageFromUpload(
null, null, w, h,
cursorX, cursorY,
pdfPicker.imageId, pdfPicker.fileName,
page, pdfPicker.pageCount,
);
newItemIds.push(item.id);
if (w > (colWidths[col] || 0)) colWidths[col] = w;
if (h > rowMaxH) rowMaxH = h;
col++;
if (col >= cols) {
col = 0;
cursorY += rowMaxH + GAP;
rowMaxH = 0;
}
}
// Select all placed items
const selection = selectionRef.current;
if (selection && newItemIds.length > 0) {
selection.selectOnly(newItemIds[0]);
for (let i = 1; i < newItemIds.length; i++) {
selection.toggle(newItemIds[i]);
}
}
onCanvasChange();
setPdfPicker(null);
}, [pdfPicker, resolvedBoardId, canvasRef, selectionRef, onCanvasChange]);
// Memoize pasteOpts so useCanvasSetup's effect doesn't re-run every render
const pasteOpts = useMemo(() => ({
onTextPaste: handleTextPaste,
onShortTextPaste: handleShortTextPaste,
}), [handleTextPaste, handleShortTextPaste]);
onPdfUploaded,
}), [handleTextPaste, handleShortTextPaste, onPdfUploaded]);
// Canvas setup (selection, undo, sync, socket, drag/drop, paste, inbox, annotations)
const { annotationStore, pinOverlay, textEditor, cropOverlayRef, mdOverlay } = useCanvasSetup({
boardData, resolvedBoardId, user, isPublicView,
canvasRef, selectionRef, undoRef, syncRef, inboxZoneRef, canvasContainerRef,
uploadManager, onCanvasChange, showToast, setOnlineUsers, setSelectedLayerIds,
pasteOpts,
pasteOpts, onPdfUploaded,
onCropModeChange: setCropModeActive,
});
@@ -1659,6 +1750,19 @@ export default function Editor({ isPublicView }: EditorProps) {
);
})}
{/* PDF page picker modal */}
{pdfPicker && resolvedBoardId && (
<PdfPickerModal
imageId={pdfPicker.imageId}
fileName={pdfPicker.fileName}
pageCount={pdfPicker.pageCount}
dimensions={pdfPicker.dimensions}
boardId={resolvedBoardId}
onPlace={handlePdfPlace}
onCancel={() => setPdfPicker(null)}
/>
)}
{/* Export dialog */}
{showExport && (
<ExportDialog
+6 -5
View File
@@ -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,