From 0e14790990211f891e54fd877293a799dee7ec79 Mon Sep 17 00:00:00 2001 From: Hiren Kangad Date: Sat, 14 Mar 2026 11:07:03 +0530 Subject: [PATCH 01/16] build: add poppler-utils to Dockerfile for PDF support --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 7c6c366..4eef6d4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 From e1aedd721bd57e9a7d1af6c61e4a8c612e4c8a42 Mon Sep 17 00:00:00 2001 From: Hiren Kangad Date: Sat, 14 Mar 2026 11:08:45 +0530 Subject: [PATCH 02/16] feat(db): add pdf_pages table, priority column, and helper functions --- backend/db.js | 58 ++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 53 insertions(+), 5 deletions(-) diff --git a/backend/db.js b/backend/db.js index d74183b..98417a5 100644 --- a/backend/db.js +++ b/backend/db.js @@ -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, From d28998e4d7b4a6ae6387c61e0fa51dd209443595 Mon Sep 17 00:00:00 2001 From: Hiren Kangad Date: Sat, 14 Mar 2026 11:10:01 +0530 Subject: [PATCH 03/16] =?UTF-8?q?feat:=20add=20pdf-utils.js=20=E2=80=94=20?= =?UTF-8?q?pdfInfo,=20pdfRenderPage,=20bufferToTempFile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/pdf-utils.js | 134 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 backend/pdf-utils.js diff --git a/backend/pdf-utils.js b/backend/pdf-utils.js new file mode 100644 index 0000000..7c2a932 --- /dev/null +++ b/backend/pdf-utils.js @@ -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 }; From 0b56ec7f6fcb2311dab8a1f96e491be42dc1e149 Mon Sep 17 00:00:00 2001 From: Hiren Kangad Date: Sat, 14 Mar 2026 11:10:45 +0530 Subject: [PATCH 04/16] =?UTF-8?q?feat:=20accept=20PDF=20uploads=20?= =?UTF-8?q?=E2=80=94=20pdfinfo=20extraction,=20pdf=5Fpages=20rows,=20thumb?= =?UTF-8?q?nail=20job=20queuing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/minio.js | 1 + backend/routes/upload.js | 65 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/backend/minio.js b/backend/minio.js index af4aff9..0ffe49e 100644 --- a/backend/minio.js +++ b/backend/minio.js @@ -26,6 +26,7 @@ const MIME_TO_EXT = { 'video/mp4': '.mp4', 'video/webm': '.webm', 'video/quicktime': '.mov', + 'application/pdf': '.pdf', }; /** diff --git a/backend/routes/upload.js b/backend/routes/upload.js index c88f84e..88a6a68 100644 --- a/backend/routes/upload.js +++ b/backend/routes/upload.js @@ -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'; } @@ -153,6 +157,63 @@ router.post('/boards/:boardId/images', upload.single('image'), async (req, res) createMediaJob({ id: jobId, imageId, boardId: board.id, type: 'poster' }); } + // PDF: extract page info, create pdf_pages rows, queue thumbnail jobs + 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' }); + } + + 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(); + } + } + return res.status(201).json({ id: image.id, url: publicUrl, From 1250ad1b72be8d53b2423bd39b690a8122f2aeee Mon Sep 17 00:00:00 2001 From: Hiren Kangad Date: Sat, 14 Mar 2026 11:11:50 +0530 Subject: [PATCH 05/16] feat: add PDF page selection and lazy thumbnail endpoints --- backend/routes/pdf.js | 153 ++++++++++++++++++++++++++++++++++++++++++ backend/server.js | 2 + 2 files changed, 155 insertions(+) create mode 100644 backend/routes/pdf.js diff --git a/backend/routes/pdf.js b/backend/routes/pdf.js new file mode 100644 index 0000000..7ddcdde --- /dev/null +++ b/backend/routes/pdf.js @@ -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 LIKE ? AND status IN ('queued', 'processing')` + ).get(imageId, `%"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 LIKE ? AND status IN ('queued', 'processing')` + ).get(imageId, `%"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; diff --git a/backend/server.js b/backend/server.js index 379010d..7c161c4 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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) => { From 4b68e337cf3c7dd87fb2d6b30ef18af04597c626 Mon Sep 17 00:00:00 2001 From: Hiren Kangad Date: Sat, 14 Mar 2026 11:12:41 +0530 Subject: [PATCH 06/16] feat: media worker dispatches pdf-thumbnail and pdf-hires jobs --- backend/services/media-worker.js | 75 +++++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/backend/services/media-worker.js b/backend/services/media-worker.js index 41b1b5c..fb4a02e 100644 --- a/backend/services/media-worker.js +++ b/backend/services/media-worker.js @@ -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' }); @@ -152,6 +164,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. */ From 247145f5454ffc0518e4a52aa53eb78e7d45c383 Mon Sep 17 00:00:00 2001 From: Hiren Kangad Date: Sat, 14 Mar 2026 11:16:58 +0530 Subject: [PATCH 07/16] fix: early PDF validation, type-aware errors, exact job dedup - Move PDF page count check before MinIO upload and DB record creation to prevent orphaned objects when >500 page PDFs are rejected - Use job type label (PDF page / Video) in media-worker error messages instead of hardcoded "Video processing failed" - Replace LIKE-based idempotency check with exact JSON match to prevent page 1 matching page 11/12/etc substring collisions --- backend/routes/pdf.js | 8 ++-- backend/routes/upload.js | 73 ++++++++++++++++++++------------ backend/services/media-worker.js | 9 ++-- 3 files changed, 55 insertions(+), 35 deletions(-) diff --git a/backend/routes/pdf.js b/backend/routes/pdf.js index 7ddcdde..2be1dbe 100644 --- a/backend/routes/pdf.js +++ b/backend/routes/pdf.js @@ -67,8 +67,8 @@ router.post('/:boardId/pdf-pages', async (req, res) => { // 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 LIKE ? AND status IN ('queued', 'processing')` - ).get(imageId, `%"pageNumber":${pageNum}%`); + `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({ @@ -126,8 +126,8 @@ router.post('/:boardId/pdf-thumbnails', async (req, res) => { // 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 LIKE ? AND status IN ('queued', 'processing')` - ).get(imageId, `%"pageNumber":${pageNum}%`); + `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({ diff --git a/backend/routes/upload.js b/backend/routes/upload.js index 88a6a68..85d5a7b 100644 --- a/backend/routes/upload.js +++ b/backend/routes/upload.js @@ -131,33 +131,7 @@ router.post('/boards/:boardId/images', upload.single('image'), async (req, res) const { buffer, originalname, mimetype, size } = req.file; const mediaType = classifyMedia(mimetype); - const { assetKey, minioPath, width, height, isVideo } = await uploadMedia(board.id, imageId, buffer, mimetype); - const publicUrl = getImageUrl(minioPath); - - // Save image record first (media_jobs FK references images) - const image = createImage({ - id: imageId, - boardId: board.id, - filename: originalname, - mimeType: mimetype, - fileSize: size, - width, - height, - minioPath, - publicUrl, - uploadedBy: req.user.id, - assetKey, - mediaType, - }); - - // Enqueue background processing after image record exists - let jobId = null; - if (isVideo) { - jobId = uuidv4(); - createMediaJob({ id: jobId, imageId, boardId: board.id, type: 'poster' }); - } - - // PDF: extract page info, create pdf_pages rows, queue thumbnail jobs + // PDF: validate page count BEFORE uploading to MinIO or creating DB records if (mediaType === 'pdf') { const { tmpPath, cleanup } = bufferToTempFile(buffer, '.pdf'); try { @@ -166,6 +140,25 @@ router.post('/boards/:boardId/images', upload.single('image'), async (req, res) 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 @@ -214,6 +207,32 @@ router.post('/boards/:boardId/images', upload.single('image'), async (req, res) } } + const { assetKey, minioPath, width, height, isVideo } = await uploadMedia(board.id, imageId, buffer, mimetype); + const publicUrl = getImageUrl(minioPath); + + // Save image record first (media_jobs FK references images) + const image = createImage({ + id: imageId, + boardId: board.id, + filename: originalname, + mimeType: mimetype, + fileSize: size, + width, + height, + minioPath, + publicUrl, + uploadedBy: req.user.id, + assetKey, + mediaType, + }); + + // Enqueue background processing after image record exists + let jobId = null; + if (isVideo) { + jobId = uuidv4(); + createMediaJob({ id: jobId, imageId, boardId: board.id, type: 'poster' }); + } + return res.status(201).json({ id: image.id, url: publicUrl, diff --git a/backend/services/media-worker.js b/backend/services/media-worker.js index fb4a02e..1841775 100644 --- a/backend/services/media-worker.js +++ b/backend/services/media-worker.js @@ -144,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; From 3511c54658c25848f482dd279768143856eaaa5e Mon Sep 17 00:00:00 2001 From: Hiren Kangad Date: Sat, 14 Mar 2026 11:19:04 +0530 Subject: [PATCH 08/16] feat: add PdfPageObject type to scene format --- frontend/src/canvas/SceneManager.ts | 2 +- frontend/src/canvas/scene-format.ts | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/frontend/src/canvas/SceneManager.ts b/frontend/src/canvas/SceneManager.ts index 8a2dd54..09e3c46 100644 --- a/frontend/src/canvas/SceneManager.ts +++ b/frontend/src/canvas/SceneManager.ts @@ -58,7 +58,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; } diff --git a/frontend/src/canvas/scene-format.ts b/frontend/src/canvas/scene-format.ts index 24365ce..8dd214e 100644 --- a/frontend/src/canvas/scene-format.ts +++ b/frontend/src/canvas/scene-format.ts @@ -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; From 332f12cafe67adc4f639effd8ba360e0529f3486 Mon Sep 17 00:00:00 2001 From: Hiren Kangad Date: Sat, 14 Mar 2026 11:19:55 +0530 Subject: [PATCH 09/16] =?UTF-8?q?feat:=20add=20PdfPageSprite=20=E2=80=94?= =?UTF-8?q?=20placeholder,=20lazy=20texture,=20page=20badge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/canvas/sprites/PdfPageSprite.ts | 261 +++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 frontend/src/canvas/sprites/PdfPageSprite.ts diff --git a/frontend/src/canvas/sprites/PdfPageSprite.ts b/frontend/src/canvas/sprites/PdfPageSprite.ts new file mode 100644 index 0000000..daddbd6 --- /dev/null +++ b/frontend/src/canvas/sprites/PdfPageSprite.ts @@ -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 { + 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 { + 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 { + 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); + } +} From 07953ab18d6e71df821776032121fde5b2bd7b3f Mon Sep 17 00:00:00 2001 From: Hiren Kangad Date: Sat, 14 Mar 2026 11:21:08 +0530 Subject: [PATCH 10/16] feat: SceneManager creates pdf-page items with culling support --- frontend/src/canvas/PixiCanvas.tsx | 7 ++++-- frontend/src/canvas/SceneManager.ts | 37 +++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/frontend/src/canvas/PixiCanvas.tsx b/frontend/src/canvas/PixiCanvas.tsx index b8787fa..db0dcad 100644 --- a/frontend/src/canvas/PixiCanvas.tsx +++ b/frontend/src/canvas/PixiCanvas.tsx @@ -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( 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( 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( 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); diff --git a/frontend/src/canvas/SceneManager.ts b/frontend/src/canvas/SceneManager.ts index 09e3c46..7a7a7e2 100644 --- a/frontend/src/canvas/SceneManager.ts +++ b/frontend/src/canvas/SceneManager.ts @@ -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'; // --------------------------------------------------------------------------- @@ -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 -------------------------------- /** From e118ad632aa545fc8a1b5047b9e1adbdb993be1e Mon Sep 17 00:00:00 2001 From: Hiren Kangad Date: Sat, 14 Mar 2026 11:22:29 +0530 Subject: [PATCH 11/16] feat: block rotate/flip for pdf-page in context menu and shortcuts --- frontend/src/canvas/context-menu-items.ts | 8 +++---- frontend/src/canvas/shortcut-definitions.ts | 24 +++++++++++++++++---- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/frontend/src/canvas/context-menu-items.ts b/frontend/src/canvas/context-menu-items.ts index f1f581d..a6897e4 100644 --- a/frontend/src/canvas/context-menu-items.ts +++ b/frontend/src/canvas/context-menu-items.ts @@ -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 }, diff --git a/frontend/src/canvas/shortcut-definitions.ts b/frontend/src/canvas/shortcut-definitions.ts index df9b66b..b2c7a1f 100644 --- a/frontend/src/canvas/shortcut-definitions.ts +++ b/frontend/src/canvas/shortcut-definitions.ts @@ -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)); + }, }, // ═══════════════════════════════════════ From 3bd62709902ffc39a115b50cefec5e8be23eb988 Mon Sep 17 00:00:00 2001 From: Hiren Kangad Date: Sat, 14 Mar 2026 11:26:29 +0530 Subject: [PATCH 12/16] feat: frontend accepts PDF uploads, UploadManager detects PDF type --- frontend/src/canvas/image-drop.ts | 90 ++++++++++++++++++---------- frontend/src/stores/uploadManager.ts | 11 ++-- 2 files changed, 65 insertions(+), 36 deletions(-) diff --git a/frontend/src/canvas/image-drop.ts b/frontend/src/canvas/image-drop.ts index 09f6bc9..8e42176 100644 --- a/frontend/src/canvas/image-drop.ts +++ b/frontend/src/canvas/image-drop.ts @@ -1,4 +1,4 @@ -import { Graphics } from 'pixi.js'; +import { Graphics, Text, TextStyle, Ticker } from 'pixi.js'; import type { Viewport } from 'pixi-viewport'; import type { SceneManager } from './SceneManager'; import type { SelectionManager } from './SelectionManager'; @@ -22,10 +22,11 @@ const ALLOWED_MIME_TYPES = new Set([ 'video/mp4', 'video/webm', 'video/quicktime', + 'application/pdf', ]); /** Fallback: accepted file extensions (for when browser reports empty/wrong MIME). */ -const ALLOWED_EXTENSIONS = /\.(png|jpe?g|gif|webp|svg|mp4|webm|mov)$/i; +const ALLOWED_EXTENSIONS = /\.(png|jpe?g|gif|webp|svg|mp4|webm|mov|pdf)$/i; /** Check if a file should be accepted (MIME or extension fallback). */ function isFileAllowed(file: File): boolean { @@ -63,17 +64,37 @@ function extFromMime(mime: string): string { function createPlaceholder(viewport: Viewport, x: number, y: number): Graphics { const g = new Graphics(); g.rect(0, 0, 200, 150); - g.fill({ color: 0x3d3d3d }); + g.fill({ color: 0x2a2a2a }); g.stroke({ width: 2, color: 0x4a9eff }); g.position.set(x, y); g.interactive = false; + + // "Loading..." label + const label = new Text({ + text: 'Loading…', + style: new TextStyle({ fontSize: 14, fill: 0xaaaaaa, fontFamily: 'sans-serif' }), + }); + label.anchor.set(0.5); + label.position.set(100, 75); + g.addChild(label); + + // Pulsing border animation + let elapsed = 0; + const onTick = (ticker: Ticker) => { + elapsed += ticker.deltaMS; + g.alpha = 0.6 + 0.4 * Math.sin(elapsed * 0.005); + }; + Ticker.shared.add(onTick); + (g as any)._pulseCleanup = () => Ticker.shared.remove(onTick); + viewport.addChild(g); return g; } function removePlaceholder(viewport: Viewport, g: Graphics) { + (g as any)._pulseCleanup?.(); viewport.removeChild(g); - g.destroy(); + g.destroy({ children: true }); } /* ------------------------------------------------------------------ */ @@ -202,18 +223,9 @@ export function setupDragDrop( const world = viewport.toWorld(e.clientX - rect.left, e.clientY - rect.top); const GAP = 20; - // Count valid media files to determine grid columns - let mediaCount = 0; - for (let i = 0; i < files.length; i++) { - if (isFileAllowed(files[i])) mediaCount++; - } - const cols = Math.ceil(Math.sqrt(mediaCount)); // square-ish grid - let col = 0; - let row = 0; - let rowMaxH = 0; - let cursorY = world.y; - const colWidths: number[] = new Array(cols).fill(0); // track widest per column - const newItemIds: string[] = []; + // ── Pass 1: validate files, create all jobs upfront so queue is visible ── + interface QueuedFile { file: File; jobId: string | undefined; } + const queued: QueuedFile[] = []; for (let i = 0; i < files.length; i++) { const file = files[i]; @@ -222,29 +234,32 @@ export function setupDragDrop( if (!isFileAllowed(file)) { if (isUnsupportedMedia(file)) { const ext = file.name?.match(/\.(\w+)$/)?.[1] || file.type.split('/')[1] || 'unknown'; - uploads?.addRejected( - file.name || 'unknown', - `Unsupported format: .${ext}`, - ); + uploads?.addRejected(file.name || 'unknown', `Unsupported format: .${ext}`); } continue; } - // Compute x from column widths so far - let cursorX = world.x; - for (let c = 0; c < col; c++) cursorX += (colWidths[c] || 220) + GAP; - // Client-side size validation if (file.size > MAX_FILE_SIZE) { const jobId = uploads?.addJob(file, boardId); if (jobId) uploads?.setFailed(jobId, `File too large (${(file.size / 1024 / 1024).toFixed(0)}MB, max ${MAX_FILE_SIZE_MB}MB)`); - col++; - if (col >= cols) { col = 0; row++; cursorY += rowMaxH + GAP; rowMaxH = 0; } continue; } const jobId = uploads?.addJob(file, boardId); + queued.push({ file, jobId }); + } + // ── Pass 2: upload sequentially, placing in grid ── + const cols = Math.ceil(Math.sqrt(queued.length)) || 1; + let col = 0; + let row = 0; + let rowMaxH = 0; + let cursorY = world.y; + const colWidths: number[] = new Array(cols).fill(0); + const newItemIds: string[] = []; + + for (const { file, jobId } of queued) { // Check if job was cancelled while queued (user clicked cancel) if (jobId && uploads?.isCancelled(jobId)) { col++; @@ -252,6 +267,10 @@ export function setupDragDrop( continue; } + // Compute x from column widths so far + let cursorX = world.x; + for (let c = 0; c < col; c++) cursorX += (colWidths[c] || 220) + GAP; + const placeholder = createPlaceholder(viewport, cursorX, cursorY); if (jobId) uploads?.startUpload(jobId); @@ -264,7 +283,6 @@ export function setupDragDrop( newItemIds.push(id); if (placedW > (colWidths[col] || 0)) colWidths[col] = placedW; if (placedH > rowMaxH) rowMaxH = placedH; - // Link upload job to DB image for processing tracking const imgData = res.data.image || res.data; if (jobId) uploads?.uploadComplete(jobId, imgData.id); } catch (err: any) { @@ -330,6 +348,16 @@ export function setupPaste( onShortTextPaste?: (text: string) => void; }, ): () => void { + // Track last known cursor position in world coordinates for paste-at-cursor + let lastWorldX: number | null = null; + let lastWorldY: number | null = null; + const onPointerMove = (e: any) => { + const world = viewport.toWorld(e.global.x, e.global.y); + lastWorldX = world.x; + lastWorldY = world.y; + }; + viewport.on('pointermove', onPointerMove); + async function onPaste(e: ClipboardEvent) { // If focus is inside a contentEditable (e.g. BlockNote editor), let native paste through const active = document.activeElement; @@ -398,10 +426,9 @@ export function setupPaste( continue; } - // Place at viewport center (world coords) - const center = viewport.center; - const cx = center.x; - const cy = center.y; + // Place at cursor position if known, otherwise fall back to viewport center + const cx = lastWorldX ?? viewport.center.x; + const cy = lastWorldY ?? viewport.center.y; const placeholder = createPlaceholder(viewport, cx - 100, cy - 75); const jobId = uploads?.addJob(file, boardId); @@ -434,5 +461,6 @@ export function setupPaste( document.addEventListener('paste', onPaste); return () => { document.removeEventListener('paste', onPaste); + viewport.off('pointermove', onPointerMove); }; } diff --git a/frontend/src/stores/uploadManager.ts b/frontend/src/stores/uploadManager.ts index 97c4fec..4a0dcf2 100644 --- a/frontend/src/stores/uploadManager.ts +++ b/frontend/src/stores/uploadManager.ts @@ -4,7 +4,7 @@ export interface UploadJob { id: string; fileName: string; fileSize: number; - mediaType: 'image' | 'video'; + mediaType: 'image' | 'video' | 'pdf'; status: UploadStatus; progress: number; // 0-1 for upload phase error?: string; @@ -35,12 +35,13 @@ export class UploadManager { /** Create a new upload job from a local file. Returns the job ID. */ addJob(file: File, boardId: string): string { const id = crypto.randomUUID(); - const isVideo = file.type.startsWith('video/'); + const isPdf = file.type === 'application/pdf'; + const isVideo = !isPdf && file.type.startsWith('video/'); this.jobs.set(id, { id, - fileName: file.name || (isVideo ? 'video' : 'image'), + fileName: file.name || (isPdf ? 'document' : isVideo ? 'video' : 'image'), fileSize: file.size, - mediaType: isVideo ? 'video' : 'image', + mediaType: isPdf ? 'pdf' : isVideo ? 'video' : 'image', status: 'queued', progress: 0, file, @@ -75,7 +76,7 @@ export class UploadManager { } /** Create a job for a URL-based import (no File object, unknown size). */ - addUrlJob(fileName: string, mediaType: 'image' | 'video'): string { + addUrlJob(fileName: string, mediaType: 'image' | 'video' | 'pdf'): string { const id = crypto.randomUUID(); this.jobs.set(id, { id, From bbf9b9484a8921b24a0ceb2d8929c869a358566a Mon Sep 17 00:00:00 2001 From: Hiren Kangad Date: Sat, 14 Mar 2026 11:28:29 +0530 Subject: [PATCH 13/16] feat: PDF upload response forks to picker callback or direct placement --- frontend/src/canvas/image-drop.ts | 66 ++++++++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/frontend/src/canvas/image-drop.ts b/frontend/src/canvas/image-drop.ts index 8e42176..9dc0705 100644 --- a/frontend/src/canvas/image-drop.ts +++ b/frontend/src/canvas/image-drop.ts @@ -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; @@ -155,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(); @@ -279,6 +288,33 @@ 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; @@ -346,6 +382,7 @@ 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 @@ -377,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') || ''; } @@ -402,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(); @@ -438,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; From 656b0a987505655edd425f292c0feb015d4593e1 Mon Sep 17 00:00:00 2001 From: Hiren Kangad Date: Sat, 14 Mar 2026 11:29:31 +0530 Subject: [PATCH 14/16] =?UTF-8?q?feat:=20PdfPickerModal=20=E2=80=94=20page?= =?UTF-8?q?=20selection=20grid=20with=20lazy=20thumbnail=20loading?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/PdfPickerModal.tsx | 292 +++++++++++++++++++++ 1 file changed, 292 insertions(+) create mode 100644 frontend/src/components/PdfPickerModal.tsx diff --git a/frontend/src/components/PdfPickerModal.tsx b/frontend/src/components/PdfPickerModal.tsx new file mode 100644 index 0000000..437fde6 --- /dev/null +++ b/frontend/src/components/PdfPickerModal.tsx @@ -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>(new Set()); + const [thumbs, setThumbs] = useState>(new Map()); + const [thumbsLoaded, setThumbsLoaded] = useState(0); + const [requestedUpTo, setRequestedUpTo] = useState(0); + const scrollRef = useRef(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(); + 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/assets/${key}`; + + return ( +
+
e.stopPropagation()}> + {/* Header */} +
+
+
+ {fileName} +
+
+ {pageCount} page{pageCount !== 1 ? 's' : ''} +
+
+
+ + +
+
+ + {/* Sub-header: loading counter */} +
+ Loading thumbnails... ({thumbsLoaded}/{pageCount}) +
+ + {/* Scrollable thumbnail grid */} +
+
+ {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 ( +
togglePage(page)} + style={{ + position: 'relative', + cursor: 'pointer', + borderRadius: 4, + overflow: 'hidden', + border: isSelected ? '2px solid #4a9eff' : '2px solid transparent', + background: '#1a1a1a', + }} + > +
+ {thumbKey ? ( + {`Page + ) : ( +
+ )} +
+ {/* Page number badge */} +
+ {page} +
+ {/* Selection checkmark */} + {isSelected && ( +
+ ✓ +
+ )} +
+ ); + })} +
+
+ + {/* Footer */} +
+ + +
+
+ + {/* Keyframe animation for pulsing placeholder */} + +
+ ); +} + +// 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, +}; From 9c8b3cc5f55e94fff6cb9e800ea8827da7caccfe Mon Sep 17 00:00:00 2001 From: Hiren Kangad Date: Sat, 14 Mar 2026 11:31:45 +0530 Subject: [PATCH 15/16] feat: wire PdfPickerModal into Editor with hires texture upgrade via socket --- frontend/src/hooks/useCanvasSetup.ts | 45 ++++++++++- frontend/src/pages/Editor.tsx | 110 ++++++++++++++++++++++++++- 2 files changed, 148 insertions(+), 7 deletions(-) diff --git a/frontend/src/hooks/useCanvasSetup.ts b/frontend/src/hooks/useCanvasSetup.ts index 9d9deb0..2101bfd 100644 --- a/frontend/src/hooks/useCanvasSetup.ts +++ b/frontend/src/hooks/useCanvasSetup.ts @@ -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 }; } diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx index f341cba..3b79cfb 100644 --- a/frontend/src/pages/Editor.tsx +++ b/frontend/src/pages/Editor.tsx @@ -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 && ( + setPdfPicker(null)} + /> + )} + {/* Export dialog */} {showExport && ( Date: Sat, 14 Mar 2026 11:40:49 +0530 Subject: [PATCH 16/16] fix: PdfPickerModal thumbnail URL uses /api/images/ not /api/assets/ --- frontend/src/components/PdfPickerModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/PdfPickerModal.tsx b/frontend/src/components/PdfPickerModal.tsx index 437fde6..fa3b4f4 100644 --- a/frontend/src/components/PdfPickerModal.tsx +++ b/frontend/src/components/PdfPickerModal.tsx @@ -121,7 +121,7 @@ export default function PdfPickerModal({ }; // Build asset URL from key - const thumbUrl = (key: string) => `/api/assets/${key}`; + const thumbUrl = (key: string) => `/api/images/${key}`; return (