Optimising pageeditor

This commit is contained in:
Reece 2025-06-27 18:11:23 +01:00
parent 42abe83385
commit 3730429153
6 changed files with 580 additions and 69 deletions

View File

@ -0,0 +1,136 @@
// Web Worker for parallel thumbnail generation
console.log('🔧 Thumbnail worker starting up...');
let pdfJsLoaded = false;
// Import PDF.js properly for worker context
try {
console.log('📦 Attempting to load PDF.js from CDN...');
importScripts('https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js');
if (self.pdfjsLib) {
// Set up PDF.js worker
self.pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
pdfJsLoaded = true;
console.log('✓ PDF.js loaded successfully from CDN');
} else {
throw new Error('pdfjsLib not available after import');
}
} catch (error) {
console.warn('⚠️ Failed to load PDF.js from CDN:', error);
console.error('✗ PDF.js CDN loading failed - worker will not be available');
pdfJsLoaded = false;
// Note: Local PDF.js fallback removed as pdf.js file is not available
// The main thread fallback will handle thumbnail generation instead
}
// Log the final status
if (pdfJsLoaded) {
console.log('✅ Thumbnail worker ready for PDF processing');
} else {
console.log('❌ Thumbnail worker failed to initialize - PDF.js not available');
}
self.onmessage = async function(e) {
const { type, data, jobId } = e.data;
try {
// Handle PING for worker health check
if (type === 'PING') {
console.log('🏓 Worker received PING, sending PONG...');
// Check if PDF.js is loaded before responding
if (pdfJsLoaded && self.pdfjsLib) {
self.postMessage({ type: 'PONG', jobId });
console.log('✓ PONG sent - worker is ready for thumbnail generation');
} else {
console.error('✗ PDF.js not loaded - worker not ready');
self.postMessage({
type: 'ERROR',
jobId,
data: { error: 'PDF.js not loaded in worker' }
});
}
return;
}
if (type === 'GENERATE_THUMBNAILS') {
console.log(`🖼️ Worker starting thumbnail generation for ${data.pageNumbers?.length || 0} pages`);
if (!pdfJsLoaded || !self.pdfjsLib) {
throw new Error('PDF.js not available in worker');
}
const { pdfArrayBuffer, pageNumbers, scale = 0.2, quality = 0.8 } = data;
// Load PDF in worker using imported PDF.js
const pdf = await self.pdfjsLib.getDocument({ data: pdfArrayBuffer }).promise;
const thumbnails = [];
// Process pages in smaller batches for smoother UI
const batchSize = 3; // Process 3 pages at once for smoother UI
for (let i = 0; i < pageNumbers.length; i += batchSize) {
const batch = pageNumbers.slice(i, i + batchSize);
const batchPromises = batch.map(async (pageNumber) => {
try {
const page = await pdf.getPage(pageNumber);
const viewport = page.getViewport({ scale });
// Create OffscreenCanvas for better performance
const canvas = new OffscreenCanvas(viewport.width, viewport.height);
const context = canvas.getContext('2d');
await page.render({ canvasContext: context, viewport }).promise;
// Convert to blob then to base64 (more efficient than toDataURL)
const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality });
const arrayBuffer = await blob.arrayBuffer();
const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
const thumbnail = `data:image/jpeg;base64,${base64}`;
return { pageNumber, thumbnail, success: true };
} catch (error) {
return { pageNumber, error: error.message, success: false };
}
});
const batchResults = await Promise.all(batchPromises);
thumbnails.push(...batchResults);
// Send progress update
self.postMessage({
type: 'PROGRESS',
jobId,
data: {
completed: thumbnails.length,
total: pageNumbers.length,
thumbnails: batchResults.filter(r => r.success)
}
});
// Small delay between batches to keep UI smooth
if (i + batchSize < pageNumbers.length) {
await new Promise(resolve => setTimeout(resolve, 100)); // Increased to 100ms pause between batches for smoother scrolling
}
}
// Clean up
pdf.destroy();
self.postMessage({
type: 'COMPLETE',
jobId,
data: { thumbnails: thumbnails.filter(r => r.success) }
});
}
} catch (error) {
self.postMessage({
type: 'ERROR',
jobId,
data: { error: error.message }
});
}
};

View File

@ -77,7 +77,13 @@ const DragDropGrid = <T extends DragDropItem>({
flexWrap: 'wrap',
gap: '1.5rem',
justifyContent: 'flex-start',
paddingBottom: '100px'
paddingBottom: '100px',
// Performance optimizations for smooth scrolling
willChange: 'scroll-position',
transform: 'translateZ(0)', // Force hardware acceleration
backfaceVisibility: 'hidden',
// Use containment for better rendering performance
contain: 'layout style paint',
}}
>
{items.map((item, index) => (

View File

@ -1,10 +1,14 @@
/* Page container hover effects */
/* Page container hover effects - optimized for smooth scrolling */
.pageContainer {
transition: transform 0.2s ease-in-out;
/* Enable hardware acceleration for smoother scrolling */
will-change: transform;
transform: translateZ(0);
backface-visibility: hidden;
}
.pageContainer:hover {
transform: scale(1.02);
transform: scale(1.02) translateZ(0);
}
.pageContainer:hover .pageNumber {

View File

@ -17,6 +17,7 @@ import {
ToggleSplitCommand
} from "../../commands/pageCommands";
import { pdfExportService } from "../../services/pdfExportService";
import { thumbnailGenerationService } from "../../services/thumbnailGenerationService";
import './pageEditor.module.css';
import PageThumbnail from './PageThumbnail';
import BulkSelectionPanel from './BulkSelectionPanel';
@ -283,62 +284,78 @@ const PageEditor = ({
}, []);
// Start thumbnail generation process (separate from document loading)
const startThumbnailGeneration = useCallback(async () => {
const startThumbnailGeneration = useCallback(() => {
if (!mergedPdfDocument || activeFiles.length !== 1 || thumbnailGenerationStarted) return;
const file = activeFiles[0];
const totalPages = mergedPdfDocument.totalPages;
console.log(`Starting thumbnail generation for ${totalPages} pages`);
console.log(`Starting Web Worker thumbnail generation for ${totalPages} pages`);
setThumbnailGenerationStarted(true);
try {
// Load PDF ONCE for thumbnail generation (separate from document structure loading)
const arrayBuffer = await file.arrayBuffer();
const { getDocument } = await import('pdfjs-dist');
const pdf = await getDocument({ data: arrayBuffer }).promise;
setSharedPdfInstance(pdf);
console.log('Shared PDF loaded, starting progressive thumbnail generation');
// Process pages in batches
let currentPage = 1;
const batchSize = totalPages > 500 ? 1 : 2; // Slower for massive files
const batchDelay = totalPages > 500 ? 300 : 200; // More delay for massive files
const processBatch = async () => {
const endPage = Math.min(currentPage + batchSize - 1, totalPages);
console.log(`Generating thumbnails for pages ${currentPage}-${endPage}`);
// Run everything asynchronously to avoid blocking the main thread
setTimeout(async () => {
try {
console.log('📖 Loading PDF array buffer...');
for (let i = currentPage; i <= endPage; i++) {
// Send the shared PDF instance and cache functions to components
window.dispatchEvent(new CustomEvent('generateThumbnail', {
detail: {
pageNumber: i,
sharedPdf: pdf,
getThumbnailFromCache,
addThumbnailToCache
}
}));
}
// Load PDF array buffer for Web Workers
const arrayBuffer = await file.arrayBuffer();
currentPage += batchSize;
console.log('✅ PDF array buffer loaded, starting Web Workers...');
if (currentPage <= totalPages) {
setTimeout(processBatch, batchDelay);
} else {
console.log('Progressive thumbnail generation completed');
}
};
// Start generating thumbnails immediately
processBatch();
} catch (error) {
console.error('Failed to start thumbnail generation:', error);
setThumbnailGenerationStarted(false);
}
}, [mergedPdfDocument, activeFiles, thumbnailGenerationStarted]);
// Generate all page numbers
const pageNumbers = Array.from({ length: totalPages }, (_, i) => i + 1);
// Start parallel thumbnail generation WITHOUT blocking the main thread
thumbnailGenerationService.generateThumbnails(
arrayBuffer,
pageNumbers,
{
scale: 0.2, // Low quality for page editor
quality: 0.8,
batchSize: 15, // Smaller batches per worker for smoother UI
parallelBatches: 3 // Use 3 Web Workers in parallel
},
// Progress callback (throttled for better performance)
(progress) => {
// Reduce console spam - only log every 10 completions
if (progress.completed % 10 === 0) {
console.log(`Thumbnail progress: ${progress.completed}/${progress.total} completed`);
}
// Batch process thumbnails to reduce main thread work
requestAnimationFrame(() => {
progress.thumbnails.forEach(({ pageNumber, thumbnail }) => {
// Check cache first, then send thumbnail
const pageId = `${file.name}-page-${pageNumber}`;
const cached = getThumbnailFromCache(pageId);
if (!cached) {
// Cache and send to component
addThumbnailToCache(pageId, thumbnail);
window.dispatchEvent(new CustomEvent('thumbnailReady', {
detail: { pageNumber, thumbnail, pageId }
}));
}
});
});
}
).then(thumbnails => {
console.log(`🎉 Web Worker thumbnail generation completed: ${thumbnails.length} thumbnails generated`);
}).catch(error => {
console.error('❌ Web Worker thumbnail generation failed:', error);
setThumbnailGenerationStarted(false);
});
} catch (error) {
console.error('Failed to start Web Worker thumbnail generation:', error);
setThumbnailGenerationStarted(false);
}
}, 0); // setTimeout with 0ms to defer to next tick
console.log('🚀 Thumbnail generation queued - UI remains responsive');
}, [mergedPdfDocument, activeFiles, thumbnailGenerationStarted, getThumbnailFromCache, addThumbnailToCache]);
// Start thumbnail generation after document loads and UI settles
useEffect(() => {
@ -349,7 +366,7 @@ const PageEditor = ({
}
}, [mergedPdfDocument, startThumbnailGeneration, thumbnailGenerationStarted]);
// Cleanup shared PDF instance and cache when component unmounts or files change
// Cleanup shared PDF instance, workers, and cache when component unmounts or files change
useEffect(() => {
return () => {
if (sharedPdfInstance) {
@ -358,6 +375,9 @@ const PageEditor = ({
}
setThumbnailGenerationStarted(false);
clearThumbnailCache(); // Clear cache when leaving/changing documents
// Cancel any ongoing Web Worker operations
thumbnailGenerationService.destroy();
};
}, [activeFiles, clearThumbnailCache]);

View File

@ -46,7 +46,7 @@ interface PageThumbnailProps {
setPdfDocument: any;
}
const PageThumbnail = ({
const PageThumbnail = React.memo(({
page,
index,
totalPages,
@ -78,28 +78,22 @@ const PageThumbnail = ({
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
const [isLoadingThumbnail, setIsLoadingThumbnail] = useState(false);
// Listen for progressive thumbnail generation events
// Listen for ready thumbnails from Web Workers (optimized)
useEffect(() => {
const handleThumbnailGeneration = (event: CustomEvent) => {
const { pageNumber, sharedPdf, getThumbnailFromCache, addThumbnailToCache } = event.detail;
if (pageNumber === page.pageNumber && !thumbnailUrl && !isLoadingThumbnail) {
// Check cache first
const cachedThumbnail = getThumbnailFromCache(page.id);
if (cachedThumbnail) {
console.log(`Using cached thumbnail for page ${page.pageNumber}`);
setThumbnailUrl(cachedThumbnail);
return;
const handleThumbnailReady = (event: CustomEvent) => {
const { pageNumber, thumbnail, pageId } = event.detail;
if (pageNumber === page.pageNumber && pageId === page.id && !thumbnailUrl) {
// Reduce console spam during scrolling
if (pageNumber % 20 === 0) {
console.log(`Received Web Worker thumbnail for page ${page.pageNumber}`);
}
// Generate new thumbnail and cache it
loadThumbnailFromSharedPdf(sharedPdf, addThumbnailToCache);
setThumbnailUrl(thumbnail);
}
};
window.addEventListener('generateThumbnail', handleThumbnailGeneration as EventListener);
return () => window.removeEventListener('generateThumbnail', handleThumbnailGeneration as EventListener);
}, [page.pageNumber, page.id, thumbnailUrl, isLoadingThumbnail]);
window.addEventListener('thumbnailReady', handleThumbnailReady as EventListener);
return () => window.removeEventListener('thumbnailReady', handleThumbnailReady as EventListener);
}, [page.pageNumber, page.id, thumbnailUrl]);
const loadThumbnailFromSharedPdf = async (sharedPdf: any, addThumbnailToCache?: (pageId: string, thumbnail: string) => void) => {
if (isLoadingThumbnail || thumbnailUrl) return;
@ -441,6 +435,20 @@ const PageThumbnail = ({
</div>
</div>
);
};
}, (prevProps, nextProps) => {
// Only re-render if essential props change
return (
prevProps.page.id === nextProps.page.id &&
prevProps.page.pageNumber === nextProps.page.pageNumber &&
prevProps.page.rotation === nextProps.page.rotation &&
prevProps.page.thumbnail === nextProps.page.thumbnail &&
prevProps.selectedPages.includes(prevProps.page.id) === nextProps.selectedPages.includes(nextProps.page.id) &&
prevProps.selectionMode === nextProps.selectionMode &&
prevProps.draggedPage === nextProps.draggedPage &&
prevProps.dropTarget === nextProps.dropTarget &&
prevProps.movingPage === nextProps.movingPage &&
prevProps.isAnimating === nextProps.isAnimating
);
});
export default PageThumbnail;

View File

@ -0,0 +1,337 @@
/**
* High-performance thumbnail generation service using Web Workers
*/
interface ThumbnailResult {
pageNumber: number;
thumbnail: string;
success: boolean;
error?: string;
}
interface ThumbnailGenerationOptions {
scale?: number;
quality?: number;
batchSize?: number;
parallelBatches?: number;
}
export class ThumbnailGenerationService {
private workers: Worker[] = [];
private activeJobs = new Map<string, { resolve: Function; reject: Function; onProgress?: Function }>();
private jobCounter = 0;
private isGenerating = false;
constructor(private maxWorkers: number = 3) {
this.initializeWorkers();
}
private initializeWorkers(): void {
const workerPromises: Promise<Worker | null>[] = [];
for (let i = 0; i < this.maxWorkers; i++) {
const workerPromise = new Promise<Worker | null>((resolve) => {
try {
console.log(`Attempting to create worker ${i}...`);
const worker = new Worker('/thumbnailWorker.js');
let workerReady = false;
let pingTimeout: NodeJS.Timeout;
worker.onmessage = (e) => {
const { type, data, jobId } = e.data;
// Handle PONG response to confirm worker is ready
if (type === 'PONG') {
workerReady = true;
clearTimeout(pingTimeout);
console.log(`✓ Worker ${i} is ready and responsive`);
resolve(worker);
return;
}
const job = this.activeJobs.get(jobId);
if (!job) return;
switch (type) {
case 'PROGRESS':
if (job.onProgress) {
job.onProgress(data);
}
break;
case 'COMPLETE':
job.resolve(data.thumbnails);
this.activeJobs.delete(jobId);
break;
case 'ERROR':
job.reject(new Error(data.error));
this.activeJobs.delete(jobId);
break;
}
};
worker.onerror = (error) => {
console.error(`✗ Worker ${i} failed with error:`, error);
clearTimeout(pingTimeout);
worker.terminate();
resolve(null);
};
// Test worker with timeout
pingTimeout = setTimeout(() => {
if (!workerReady) {
console.warn(`✗ Worker ${i} timed out (no PONG response)`);
worker.terminate();
resolve(null);
}
}, 3000); // Reduced timeout for faster feedback
// Send PING to test worker
try {
worker.postMessage({ type: 'PING' });
} catch (pingError) {
console.error(`✗ Failed to send PING to worker ${i}:`, pingError);
clearTimeout(pingTimeout);
worker.terminate();
resolve(null);
}
} catch (error) {
console.error(`✗ Failed to create worker ${i}:`, error);
resolve(null);
}
});
workerPromises.push(workerPromise);
}
// Wait for all workers to initialize or fail
Promise.all(workerPromises).then((workers) => {
this.workers = workers.filter((w): w is Worker => w !== null);
const successCount = this.workers.length;
const failCount = this.maxWorkers - successCount;
console.log(`🔧 Worker initialization complete: ${successCount}/${this.maxWorkers} workers ready`);
if (failCount > 0) {
console.warn(`⚠️ ${failCount} workers failed to initialize - will use main thread fallback`);
}
if (successCount === 0) {
console.warn('🚨 No Web Workers available - all thumbnail generation will use main thread');
}
});
}
/**
* Generate thumbnails for multiple pages using Web Workers
*/
async generateThumbnails(
pdfArrayBuffer: ArrayBuffer,
pageNumbers: number[],
options: ThumbnailGenerationOptions = {},
onProgress?: (progress: { completed: number; total: number; thumbnails: ThumbnailResult[] }) => void
): Promise<ThumbnailResult[]> {
if (this.isGenerating) {
throw new Error('Thumbnail generation already in progress');
}
this.isGenerating = true;
const {
scale = 0.2,
quality = 0.8,
batchSize = 20, // Pages per worker
parallelBatches = this.maxWorkers
} = options;
try {
// Check if workers are available, fallback to main thread if not
if (this.workers.length === 0) {
console.warn('No Web Workers available, falling back to main thread processing');
return await this.generateThumbnailsMainThread(pdfArrayBuffer, pageNumbers, scale, quality, onProgress);
}
// Split pages across workers
const workerBatches = this.distributeWork(pageNumbers, this.workers.length);
const jobPromises: Promise<ThumbnailResult[]>[] = [];
for (let i = 0; i < workerBatches.length; i++) {
const batch = workerBatches[i];
if (batch.length === 0) continue;
const worker = this.workers[i % this.workers.length];
const jobId = `job-${++this.jobCounter}`;
const promise = new Promise<ThumbnailResult[]>((resolve, reject) => {
this.activeJobs.set(jobId, { resolve, reject, onProgress });
// Add timeout for worker jobs
const timeout = setTimeout(() => {
this.activeJobs.delete(jobId);
reject(new Error(`Worker job ${jobId} timed out`));
}, 60000); // 1 minute timeout
// Clear timeout when job completes
const originalResolve = resolve;
const originalReject = reject;
this.activeJobs.set(jobId, {
resolve: (result: any) => { clearTimeout(timeout); originalResolve(result); },
reject: (error: any) => { clearTimeout(timeout); originalReject(error); },
onProgress
});
worker.postMessage({
type: 'GENERATE_THUMBNAILS',
jobId,
data: {
pdfArrayBuffer,
pageNumbers: batch,
scale,
quality
}
});
});
jobPromises.push(promise);
}
// Wait for all workers to complete
const results = await Promise.all(jobPromises);
// Flatten and sort results by page number
const allThumbnails = results.flat().sort((a, b) => a.pageNumber - b.pageNumber);
return allThumbnails;
} catch (error) {
console.error('Web Worker thumbnail generation failed, falling back to main thread:', error);
return await this.generateThumbnailsMainThread(pdfArrayBuffer, pageNumbers, scale, quality, onProgress);
} finally {
this.isGenerating = false;
}
}
/**
* Fallback thumbnail generation on main thread
*/
private async generateThumbnailsMainThread(
pdfArrayBuffer: ArrayBuffer,
pageNumbers: number[],
scale: number,
quality: number,
onProgress?: (progress: { completed: number; total: number; thumbnails: ThumbnailResult[] }) => void
): Promise<ThumbnailResult[]> {
// Import PDF.js dynamically for main thread
const { getDocument } = await import('pdfjs-dist');
// Load PDF once
const pdf = await getDocument({ data: pdfArrayBuffer }).promise;
const allResults: ThumbnailResult[] = [];
let completed = 0;
const batchSize = 5; // Small batches for UI responsiveness
// Process pages in small batches
for (let i = 0; i < pageNumbers.length; i += batchSize) {
const batch = pageNumbers.slice(i, i + batchSize);
// Process batch sequentially (to avoid canvas conflicts)
for (const pageNumber of batch) {
try {
const page = await pdf.getPage(pageNumber);
const viewport = page.getViewport({ scale });
const canvas = document.createElement('canvas');
canvas.width = viewport.width;
canvas.height = viewport.height;
const context = canvas.getContext('2d');
if (!context) {
throw new Error('Could not get canvas context');
}
await page.render({ canvasContext: context, viewport }).promise;
const thumbnail = canvas.toDataURL('image/jpeg', quality);
allResults.push({ pageNumber, thumbnail, success: true });
} catch (error) {
console.error(`Failed to generate thumbnail for page ${pageNumber}:`, error);
allResults.push({
pageNumber,
thumbnail: '',
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
});
}
}
completed += batch.length;
// Report progress
if (onProgress) {
onProgress({
completed,
total: pageNumbers.length,
thumbnails: allResults.slice(-batch.length).filter(r => r.success)
});
}
// Small delay to keep UI responsive
if (i + batchSize < pageNumbers.length) {
await new Promise(resolve => setTimeout(resolve, 10));
}
}
// Clean up
pdf.destroy();
return allResults.filter(r => r.success);
}
/**
* Distribute work evenly across workers
*/
private distributeWork(pageNumbers: number[], numWorkers: number): number[][] {
const batches: number[][] = Array(numWorkers).fill(null).map(() => []);
pageNumbers.forEach((pageNum, index) => {
const workerIndex = index % numWorkers;
batches[workerIndex].push(pageNum);
});
return batches;
}
/**
* Generate a single thumbnail (fallback for individual pages)
*/
async generateSingleThumbnail(
pdfArrayBuffer: ArrayBuffer,
pageNumber: number,
options: ThumbnailGenerationOptions = {}
): Promise<string> {
const results = await this.generateThumbnails(pdfArrayBuffer, [pageNumber], options);
if (results.length === 0 || !results[0].success) {
throw new Error(`Failed to generate thumbnail for page ${pageNumber}`);
}
return results[0].thumbnail;
}
/**
* Terminate all workers and stop generation
*/
destroy(): void {
this.workers.forEach(worker => worker.terminate());
this.workers = [];
this.activeJobs.clear();
this.isGenerating = false;
}
}
// Export singleton instance
export const thumbnailGenerationService = new ThumbnailGenerationService();