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:
+1
-1
@@ -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
@@ -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,
|
||||
|
||||
@@ -26,6 +26,7 @@ const MIME_TO_EXT = {
|
||||
'video/mp4': '.mp4',
|
||||
'video/webm': '.webm',
|
||||
'video/quicktime': '.mov',
|
||||
'application/pdf': '.pdf',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 --------------------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,7 +4,7 @@ export interface UploadJob {
|
||||
id: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
mediaType: 'image' | 'video';
|
||||
mediaType: 'image' | 'video' | 'pdf';
|
||||
status: UploadStatus;
|
||||
progress: number; // 0-1 for upload phase
|
||||
error?: string;
|
||||
@@ -35,12 +35,13 @@ export class UploadManager {
|
||||
/** Create a new upload job from a local file. Returns the job ID. */
|
||||
addJob(file: File, boardId: string): string {
|
||||
const id = crypto.randomUUID();
|
||||
const isVideo = file.type.startsWith('video/');
|
||||
const isPdf = file.type === 'application/pdf';
|
||||
const isVideo = !isPdf && file.type.startsWith('video/');
|
||||
this.jobs.set(id, {
|
||||
id,
|
||||
fileName: file.name || (isVideo ? 'video' : 'image'),
|
||||
fileName: file.name || (isPdf ? 'document' : isVideo ? 'video' : 'image'),
|
||||
fileSize: file.size,
|
||||
mediaType: isVideo ? 'video' : 'image',
|
||||
mediaType: isPdf ? 'pdf' : isVideo ? 'video' : 'image',
|
||||
status: 'queued',
|
||||
progress: 0,
|
||||
file,
|
||||
@@ -75,7 +76,7 @@ export class UploadManager {
|
||||
}
|
||||
|
||||
/** Create a job for a URL-based import (no File object, unknown size). */
|
||||
addUrlJob(fileName: string, mediaType: 'image' | 'video'): string {
|
||||
addUrlJob(fileName: string, mediaType: 'image' | 'video' | 'pdf'): string {
|
||||
const id = crypto.randomUUID();
|
||||
this.jobs.set(id, {
|
||||
id,
|
||||
|
||||
Reference in New Issue
Block a user