refactor: Update PageThumbnail component to use page numbers instead of IDs for drag-and-drop functionality and selection management

- Changed selectedPages, draggedPage, dropTarget, and movingPage props from string to number type.
- Updated event handlers and state management to reflect the use of page numbers.
- Enhanced logging for better debugging during thumbnail generation and worker communication.

refactor: Simplify FileContext by removing mergedDocuments state and related actions

- Removed mergedDocuments from FileContextState and associated actions in the reducer.
- Updated selectedPageIds to selectedPageNumbers for consistency in page selection handling.

fix: Improve thumbnail generation service logging and error handling

- Added detailed logging for thumbnail generation progress and worker job management.
- Implemented better error handling for worker timeouts and main thread fallbacks.

style: Clean up FileGrid component for better readability and maintainability

- Adjusted formatting and spacing in the FileGrid component for improved code clarity.
This commit is contained in:
Reece 2025-07-08 00:02:48 +01:00
parent 2f9c88b000
commit 9b63bffb36
9 changed files with 806 additions and 478 deletions

View File

@ -8,16 +8,24 @@ try {
console.log('📦 Loading PDF.js locally...'); console.log('📦 Loading PDF.js locally...');
importScripts('/pdf.js'); importScripts('/pdf.js');
if (self.pdfjsLib) { // PDF.js exports to globalThis, check both self and globalThis
const pdfjsLib = self.pdfjsLib || globalThis.pdfjsLib;
if (pdfjsLib) {
// Make it available on self for consistency
self.pdfjsLib = pdfjsLib;
// Set up PDF.js worker // Set up PDF.js worker
self.pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdf.worker.js'; self.pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdf.worker.js';
pdfJsLoaded = true; pdfJsLoaded = true;
console.log('✓ PDF.js loaded successfully from local files'); console.log('✓ PDF.js loaded successfully from local files');
console.log('✓ PDF.js version:', self.pdfjsLib.version || 'unknown');
} else { } else {
throw new Error('pdfjsLib not available after import'); throw new Error('pdfjsLib not available after import - neither self.pdfjsLib nor globalThis.pdfjsLib found');
} }
} catch (error) { } catch (error) {
console.error('✗ Failed to load local PDF.js:', error); console.error('✗ Failed to load local PDF.js:', error.message || error);
console.error('✗ Available globals:', Object.keys(self).filter(key => key.includes('pdf')));
pdfJsLoaded = false; pdfJsLoaded = false;
} }
@ -34,12 +42,16 @@ self.onmessage = async function(e) {
try { try {
// Handle PING for worker health check // Handle PING for worker health check
if (type === 'PING') { if (type === 'PING') {
console.log('🏓 Worker PING received, checking PDF.js status...');
// Check if PDF.js is loaded before responding // Check if PDF.js is loaded before responding
if (pdfJsLoaded && self.pdfjsLib) { if (pdfJsLoaded && self.pdfjsLib) {
console.log('✓ Worker PONG - PDF.js ready');
self.postMessage({ type: 'PONG', jobId }); self.postMessage({ type: 'PONG', jobId });
} else { } else {
console.error('✗ PDF.js not loaded - worker not ready'); console.error('✗ PDF.js not loaded - worker not ready');
console.error('✗ pdfJsLoaded:', pdfJsLoaded);
console.error('✗ self.pdfjsLib:', !!self.pdfjsLib);
self.postMessage({ self.postMessage({
type: 'ERROR', type: 'ERROR',
jobId, jobId,
@ -50,14 +62,19 @@ self.onmessage = async function(e) {
} }
if (type === 'GENERATE_THUMBNAILS') { if (type === 'GENERATE_THUMBNAILS') {
console.log('🖼️ Starting thumbnail generation for', data.pageNumbers.length, 'pages');
if (!pdfJsLoaded || !self.pdfjsLib) { if (!pdfJsLoaded || !self.pdfjsLib) {
throw new Error('PDF.js not available in worker'); const error = 'PDF.js not available in worker';
console.error('✗', error);
throw new Error(error);
} }
const { pdfArrayBuffer, pageNumbers, scale = 0.2, quality = 0.8 } = data; const { pdfArrayBuffer, pageNumbers, scale = 0.2, quality = 0.8 } = data;
console.log('📄 Loading PDF document, size:', pdfArrayBuffer.byteLength, 'bytes');
// Load PDF in worker using imported PDF.js // Load PDF in worker using imported PDF.js
const pdf = await self.pdfjsLib.getDocument({ data: pdfArrayBuffer }).promise; const pdf = await self.pdfjsLib.getDocument({ data: pdfArrayBuffer }).promise;
console.log('✓ PDF loaded, total pages:', pdf.numPages);
const thumbnails = []; const thumbnails = [];
@ -68,24 +85,33 @@ self.onmessage = async function(e) {
const batchPromises = batch.map(async (pageNumber) => { const batchPromises = batch.map(async (pageNumber) => {
try { try {
console.log(`🎯 Processing page ${pageNumber}...`);
const page = await pdf.getPage(pageNumber); const page = await pdf.getPage(pageNumber);
const viewport = page.getViewport({ scale }); const viewport = page.getViewport({ scale });
console.log(`📐 Page ${pageNumber} viewport:`, viewport.width, 'x', viewport.height);
// Create OffscreenCanvas for better performance // Create OffscreenCanvas for better performance
const canvas = new OffscreenCanvas(viewport.width, viewport.height); const canvas = new OffscreenCanvas(viewport.width, viewport.height);
const context = canvas.getContext('2d'); const context = canvas.getContext('2d');
if (!context) {
throw new Error('Failed to get 2D context from OffscreenCanvas');
}
await page.render({ canvasContext: context, viewport }).promise; await page.render({ canvasContext: context, viewport }).promise;
console.log(`✓ Page ${pageNumber} rendered`);
// Convert to blob then to base64 (more efficient than toDataURL) // Convert to blob then to base64 (more efficient than toDataURL)
const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality }); const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality });
const arrayBuffer = await blob.arrayBuffer(); const arrayBuffer = await blob.arrayBuffer();
const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer))); const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
const thumbnail = `data:image/jpeg;base64,${base64}`; const thumbnail = `data:image/jpeg;base64,${base64}`;
console.log(`✓ Page ${pageNumber} thumbnail generated (${base64.length} chars)`);
return { pageNumber, thumbnail, success: true }; return { pageNumber, thumbnail, success: true };
} catch (error) { } catch (error) {
return { pageNumber, error: error.message, success: false }; console.error(`✗ Failed to generate thumbnail for page ${pageNumber}:`, error.message || error);
return { pageNumber, error: error.message || String(error), success: false };
} }
}); });
@ -93,6 +119,7 @@ self.onmessage = async function(e) {
thumbnails.push(...batchResults); thumbnails.push(...batchResults);
// Send progress update // Send progress update
console.log(`📊 Worker: Sending progress update - ${thumbnails.length}/${pageNumbers.length} completed, ${batchResults.filter(r => r.success).length} new thumbnails`);
self.postMessage({ self.postMessage({
type: 'PROGRESS', type: 'PROGRESS',
jobId, jobId,
@ -105,6 +132,7 @@ self.onmessage = async function(e) {
// Small delay between batches to keep UI smooth // Small delay between batches to keep UI smooth
if (i + batchSize < pageNumbers.length) { if (i + batchSize < pageNumbers.length) {
console.log(`⏸️ Worker: Pausing 100ms before next batch (${i + batchSize}/${pageNumbers.length})`);
await new Promise(resolve => setTimeout(resolve, 100)); // Increased to 100ms pause between batches for smoother scrolling await new Promise(resolve => setTimeout(resolve, 100)); // Increased to 100ms pause between batches for smoother scrolling
} }
} }

View File

@ -4,7 +4,7 @@ import { Paper, Group, TextInput, Button, Text } from '@mantine/core';
interface BulkSelectionPanelProps { interface BulkSelectionPanelProps {
csvInput: string; csvInput: string;
setCsvInput: (value: string) => void; setCsvInput: (value: string) => void;
selectedPages: string[]; selectedPages: number[];
onUpdatePagesFromCSV: () => void; onUpdatePagesFromCSV: () => void;
} }

View File

@ -9,21 +9,21 @@ interface DragDropItem {
interface DragDropGridProps<T extends DragDropItem> { interface DragDropGridProps<T extends DragDropItem> {
items: T[]; items: T[];
selectedItems: string[]; selectedItems: number[];
selectionMode: boolean; selectionMode: boolean;
isAnimating: boolean; isAnimating: boolean;
onDragStart: (itemId: string) => void; onDragStart: (pageNumber: number) => void;
onDragEnd: () => void; onDragEnd: () => void;
onDragOver: (e: React.DragEvent) => void; onDragOver: (e: React.DragEvent) => void;
onDragEnter: (itemId: string) => void; onDragEnter: (pageNumber: number) => void;
onDragLeave: () => void; onDragLeave: () => void;
onDrop: (e: React.DragEvent, targetId: string | 'end') => void; onDrop: (e: React.DragEvent, targetPageNumber: number | 'end') => void;
onEndZoneDragEnter: () => void; onEndZoneDragEnter: () => void;
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>) => React.ReactNode; renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>) => React.ReactNode;
renderSplitMarker?: (item: T, index: number) => React.ReactNode; renderSplitMarker?: (item: T, index: number) => React.ReactNode;
draggedItem: string | null; draggedItem: number | null;
dropTarget: string | null; dropTarget: number | null;
multiItemDrag: {itemIds: string[], count: number} | null; multiItemDrag: {pageNumbers: number[], count: number} | null;
dragPosition: {x: number, y: number} | null; dragPosition: {x: number, y: number} | null;
} }

File diff suppressed because it is too large Load Diff

View File

@ -16,6 +16,9 @@ import { getDocument, GlobalWorkerOptions } from 'pdfjs-dist';
// Ensure PDF.js worker is available // Ensure PDF.js worker is available
if (!GlobalWorkerOptions.workerSrc) { if (!GlobalWorkerOptions.workerSrc) {
GlobalWorkerOptions.workerSrc = '/pdf.worker.js'; GlobalWorkerOptions.workerSrc = '/pdf.worker.js';
console.log('📸 PageThumbnail: Set PDF.js worker source to /pdf.worker.js');
} else {
console.log('📸 PageThumbnail: PDF.js worker source already set to', GlobalWorkerOptions.workerSrc);
} }
interface PageThumbnailProps { interface PageThumbnailProps {
@ -23,24 +26,24 @@ interface PageThumbnailProps {
index: number; index: number;
totalPages: number; totalPages: number;
originalFile?: File; // For lazy thumbnail generation originalFile?: File; // For lazy thumbnail generation
selectedPages: string[]; selectedPages: number[];
selectionMode: boolean; selectionMode: boolean;
draggedPage: string | null; draggedPage: number | null;
dropTarget: string | null; dropTarget: number | null;
movingPage: string | null; movingPage: number | null;
isAnimating: boolean; isAnimating: boolean;
pageRefs: React.MutableRefObject<Map<string, HTMLDivElement>>; pageRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
onDragStart: (pageId: string) => void; onDragStart: (pageNumber: number) => void;
onDragEnd: () => void; onDragEnd: () => void;
onDragOver: (e: React.DragEvent) => void; onDragOver: (e: React.DragEvent) => void;
onDragEnter: (pageId: string) => void; onDragEnter: (pageNumber: number) => void;
onDragLeave: () => void; onDragLeave: () => void;
onDrop: (e: React.DragEvent, pageId: string) => void; onDrop: (e: React.DragEvent, pageNumber: number) => void;
onTogglePage: (pageId: string) => void; onTogglePage: (pageNumber: number) => void;
onAnimateReorder: (pageId: string, targetIndex: number) => void; onAnimateReorder: (pageNumber: number, targetIndex: number) => void;
onExecuteCommand: (command: Command) => void; onExecuteCommand: (command: Command) => void;
onSetStatus: (status: string) => void; onSetStatus: (status: string) => void;
onSetMovingPage: (pageId: string | null) => void; onSetMovingPage: (pageNumber: number | null) => void;
RotatePagesCommand: typeof RotatePagesCommand; RotatePagesCommand: typeof RotatePagesCommand;
DeletePagesCommand: typeof DeletePagesCommand; DeletePagesCommand: typeof DeletePagesCommand;
ToggleSplitCommand: typeof ToggleSplitCommand; ToggleSplitCommand: typeof ToggleSplitCommand;
@ -83,23 +86,35 @@ const PageThumbnail = React.memo(({
// Update thumbnail URL when page prop changes // Update thumbnail URL when page prop changes
useEffect(() => { useEffect(() => {
if (page.thumbnail && page.thumbnail !== thumbnailUrl) { if (page.thumbnail && page.thumbnail !== thumbnailUrl) {
console.log(`📸 PageThumbnail: Updating thumbnail URL for page ${page.pageNumber}`, page.thumbnail.substring(0, 50) + '...');
setThumbnailUrl(page.thumbnail); setThumbnailUrl(page.thumbnail);
} }
}, [page.thumbnail, page.pageNumber, page.id, thumbnailUrl]); }, [page.thumbnail, page.pageNumber, page.id, thumbnailUrl]);
// Listen for ready thumbnails from Web Workers (only if no existing thumbnail) // Listen for ready thumbnails from Web Workers (only if no existing thumbnail)
useEffect(() => { useEffect(() => {
if (thumbnailUrl) return; // Skip if we already have a thumbnail if (thumbnailUrl) {
console.log(`📸 PageThumbnail: Page ${page.pageNumber} already has thumbnail, skipping worker listener`);
return; // Skip if we already have a thumbnail
}
console.log(`📸 PageThumbnail: Setting up worker listener for page ${page.pageNumber} (${page.id})`);
const handleThumbnailReady = (event: CustomEvent) => { const handleThumbnailReady = (event: CustomEvent) => {
const { pageNumber, thumbnail, pageId } = event.detail; const { pageNumber, thumbnail, pageId } = event.detail;
console.log(`📸 PageThumbnail: Received worker thumbnail for page ${pageNumber}, looking for page ${page.pageNumber} (${page.id})`);
if (pageNumber === page.pageNumber && pageId === page.id) { if (pageNumber === page.pageNumber && pageId === page.id) {
console.log(`✓ PageThumbnail: Thumbnail matched for page ${page.pageNumber}, setting URL`);
setThumbnailUrl(thumbnail); setThumbnailUrl(thumbnail);
} }
}; };
window.addEventListener('thumbnailReady', handleThumbnailReady as EventListener); window.addEventListener('thumbnailReady', handleThumbnailReady as EventListener);
return () => window.removeEventListener('thumbnailReady', handleThumbnailReady as EventListener); return () => {
console.log(`📸 PageThumbnail: Cleaning up worker listener for page ${page.pageNumber}`);
window.removeEventListener('thumbnailReady', handleThumbnailReady as EventListener);
};
}, [page.pageNumber, page.id, thumbnailUrl]); }, [page.pageNumber, page.id, thumbnailUrl]);
@ -115,7 +130,7 @@ const PageThumbnail = React.memo(({
return ( return (
<div <div
ref={pageElementRef} ref={pageElementRef}
data-page-id={page.id} data-page-number={page.pageNumber}
className={` className={`
${styles.pageContainer} ${styles.pageContainer}
!rounded-lg !rounded-lg
@ -132,12 +147,12 @@ const PageThumbnail = React.memo(({
${selectionMode ${selectionMode
? 'bg-white hover:bg-gray-50' ? 'bg-white hover:bg-gray-50'
: 'bg-white hover:bg-gray-50'} : 'bg-white hover:bg-gray-50'}
${draggedPage === page.id ? 'opacity-50 scale-95' : ''} ${draggedPage === page.pageNumber ? 'opacity-50 scale-95' : ''}
${movingPage === page.id ? 'page-moving' : ''} ${movingPage === page.pageNumber ? 'page-moving' : ''}
`} `}
style={{ style={{
transform: (() => { transform: (() => {
if (!isAnimating && draggedPage && page.id !== draggedPage && dropTarget === page.id) { if (!isAnimating && draggedPage && page.pageNumber !== draggedPage && dropTarget === page.pageNumber) {
return 'translateX(20px)'; return 'translateX(20px)';
} }
return 'translateX(0)'; return 'translateX(0)';
@ -145,12 +160,12 @@ const PageThumbnail = React.memo(({
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out' transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out'
}} }}
draggable draggable
onDragStart={() => onDragStart(page.id)} onDragStart={() => onDragStart(page.pageNumber)}
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
onDragOver={onDragOver} onDragOver={onDragOver}
onDragEnter={() => onDragEnter(page.id)} onDragEnter={() => onDragEnter(page.pageNumber)}
onDragLeave={onDragLeave} onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, page.id)} onDrop={(e) => onDrop(e, page.pageNumber)}
> >
{selectionMode && ( {selectionMode && (
<div <div
@ -159,26 +174,31 @@ const PageThumbnail = React.memo(({
position: 'absolute', position: 'absolute',
top: 8, top: 8,
right: 8, right: 8,
zIndex: 4, zIndex: 10,
backgroundColor: 'white', backgroundColor: 'rgba(255, 255, 255, 0.95)',
border: '1px solid #ccc',
borderRadius: '4px', borderRadius: '4px',
padding: '2px', padding: '4px',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)', boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
pointerEvents: 'auto' pointerEvents: 'auto',
cursor: 'pointer'
}} }}
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
onDragStart={(e) => { onDragStart={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
}} }}
onClick={(e) => {
console.log('📸 Checkbox clicked for page', page.pageNumber);
e.stopPropagation();
onTogglePage(page.pageNumber);
}}
> >
<Checkbox <Checkbox
checked={selectedPages.includes(page.id)} checked={Array.isArray(selectedPages) ? selectedPages.includes(page.pageNumber) : false}
onChange={(event) => { onChange={() => {
event.stopPropagation(); // onChange is handled by the parent div click
onTogglePage(page.id);
}} }}
onClick={(e) => e.stopPropagation()}
size="sm" size="sm"
/> />
</div> </div>
@ -272,8 +292,8 @@ const PageThumbnail = React.memo(({
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (index > 0 && !movingPage && !isAnimating) { if (index > 0 && !movingPage && !isAnimating) {
onSetMovingPage(page.id); onSetMovingPage(page.pageNumber);
onAnimateReorder(page.id, index - 1); onAnimateReorder(page.pageNumber, index - 1);
setTimeout(() => onSetMovingPage(null), 500); setTimeout(() => onSetMovingPage(null), 500);
onSetStatus(`Moved page ${page.pageNumber} left`); onSetStatus(`Moved page ${page.pageNumber} left`);
} }
@ -292,8 +312,8 @@ const PageThumbnail = React.memo(({
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (index < totalPages - 1 && !movingPage && !isAnimating) { if (index < totalPages - 1 && !movingPage && !isAnimating) {
onSetMovingPage(page.id); onSetMovingPage(page.pageNumber);
onAnimateReorder(page.id, index + 1); onAnimateReorder(page.pageNumber, index + 1);
setTimeout(() => onSetMovingPage(null), 500); setTimeout(() => onSetMovingPage(null), 500);
onSetStatus(`Moved page ${page.pageNumber} right`); onSetStatus(`Moved page ${page.pageNumber} right`);
} }
@ -408,7 +428,7 @@ const PageThumbnail = React.memo(({
prevProps.page.pageNumber === nextProps.page.pageNumber && prevProps.page.pageNumber === nextProps.page.pageNumber &&
prevProps.page.rotation === nextProps.page.rotation && prevProps.page.rotation === nextProps.page.rotation &&
prevProps.page.thumbnail === nextProps.page.thumbnail && prevProps.page.thumbnail === nextProps.page.thumbnail &&
prevProps.selectedPages.includes(prevProps.page.id) === nextProps.selectedPages.includes(nextProps.page.id) && prevProps.selectedPages === nextProps.selectedPages && // Compare array reference - will re-render when selection changes
prevProps.selectionMode === nextProps.selectionMode && prevProps.selectionMode === nextProps.selectionMode &&
prevProps.draggedPage === nextProps.draggedPage && prevProps.draggedPage === nextProps.draggedPage &&
prevProps.dropTarget === nextProps.dropTarget && prevProps.dropTarget === nextProps.dropTarget &&

View File

@ -98,28 +98,11 @@ const FileGrid = ({
</Group> </Group>
)} )}
{/* File Count Badge */} {/* File Count Badge */}3
{(showSearch || showSort) && (
<Group mb="sm">
<Badge variant="light" size="sm">
{displayFiles.length} {displayFiles.length === 1 ? 'file' : 'files'}
{hasMoreFiles && ` (${sortedFiles.length - maxDisplay!} more)`}
</Badge>
</Group>
)}
{/* Files Grid */}
<Flex
wrap="wrap"
gap="lg"
justify="flex-start"
style={{
width: "100%",
// Responsive grid spacing
'@media (max-width: 768px)': {
gap: 'md' gap: 'md'
} }
}} }}
h="30rem" style={{ overflowY: "auto", width: "100%" }}
> >
{displayFiles.map((file, idx) => { {displayFiles.map((file, idx) => {
const originalIdx = files.findIndex(f => (f.id || f.name) === (file.id || file.name)); const originalIdx = files.findIndex(f => (f.id || f.name) === (file.id || file.name));

View File

@ -33,13 +33,12 @@ const initialViewerConfig: ViewerConfig = {
const initialState: FileContextState = { const initialState: FileContextState = {
activeFiles: [], activeFiles: [],
processedFiles: new Map(), processedFiles: new Map(),
mergedDocuments: new Map(),
currentView: 'fileEditor', currentView: 'fileEditor',
currentTool: null, currentTool: null,
fileEditHistory: new Map(), fileEditHistory: new Map(),
globalFileOperations: [], globalFileOperations: [],
selectedFileIds: [], selectedFileIds: [],
selectedPageIds: [], selectedPageNumbers: [],
viewerConfig: initialViewerConfig, viewerConfig: initialViewerConfig,
isProcessing: false, isProcessing: false,
processingProgress: 0, processingProgress: 0,
@ -52,12 +51,11 @@ type FileContextAction =
| { type: 'ADD_FILES'; payload: File[] } | { type: 'ADD_FILES'; payload: File[] }
| { type: 'REMOVE_FILES'; payload: string[] } | { type: 'REMOVE_FILES'; payload: string[] }
| { type: 'SET_PROCESSED_FILES'; payload: Map<File, ProcessedFile> } | { type: 'SET_PROCESSED_FILES'; payload: Map<File, ProcessedFile> }
| { type: 'SET_MERGED_DOCUMENT'; payload: { key: string; document: PDFDocument } } | { type: 'UPDATE_PROCESSED_FILE'; payload: { file: File; processedFile: ProcessedFile } }
| { type: 'CLEAR_MERGED_DOCUMENTS' }
| { type: 'SET_CURRENT_VIEW'; payload: ViewType } | { type: 'SET_CURRENT_VIEW'; payload: ViewType }
| { type: 'SET_CURRENT_TOOL'; payload: ToolType } | { type: 'SET_CURRENT_TOOL'; payload: ToolType }
| { type: 'SET_SELECTED_FILES'; payload: string[] } | { type: 'SET_SELECTED_FILES'; payload: string[] }
| { type: 'SET_SELECTED_PAGES'; payload: string[] } | { type: 'SET_SELECTED_PAGES'; payload: number[] }
| { type: 'CLEAR_SELECTIONS' } | { type: 'CLEAR_SELECTIONS' }
| { type: 'SET_PROCESSING'; payload: { isProcessing: boolean; progress: number } } | { type: 'SET_PROCESSING'; payload: { isProcessing: boolean; progress: number } }
| { type: 'UPDATE_VIEWER_CONFIG'; payload: Partial<ViewerConfig> } | { type: 'UPDATE_VIEWER_CONFIG'; payload: Partial<ViewerConfig> }
@ -75,7 +73,7 @@ function fileContextReducer(state: FileContextState, action: FileContextAction):
...state, ...state,
activeFiles: action.payload, activeFiles: action.payload,
selectedFileIds: [], // Clear selections when files change selectedFileIds: [], // Clear selections when files change
selectedPageIds: [] selectedPageNumbers: []
}; };
case 'ADD_FILES': case 'ADD_FILES':
@ -100,18 +98,12 @@ function fileContextReducer(state: FileContextState, action: FileContextAction):
processedFiles: action.payload processedFiles: action.payload
}; };
case 'SET_MERGED_DOCUMENT': case 'UPDATE_PROCESSED_FILE':
const newMergedDocuments = new Map(state.mergedDocuments); const updatedProcessedFiles = new Map(state.processedFiles);
newMergedDocuments.set(action.payload.key, action.payload.document); updatedProcessedFiles.set(action.payload.file, action.payload.processedFile);
return { return {
...state, ...state,
mergedDocuments: newMergedDocuments processedFiles: updatedProcessedFiles
};
case 'CLEAR_MERGED_DOCUMENTS':
return {
...state,
mergedDocuments: new Map()
}; };
case 'SET_CURRENT_VIEW': case 'SET_CURRENT_VIEW':
@ -137,14 +129,14 @@ function fileContextReducer(state: FileContextState, action: FileContextAction):
case 'SET_SELECTED_PAGES': case 'SET_SELECTED_PAGES':
return { return {
...state, ...state,
selectedPageIds: action.payload selectedPageNumbers: action.payload
}; };
case 'CLEAR_SELECTIONS': case 'CLEAR_SELECTIONS':
return { return {
...state, ...state,
selectedFileIds: [], selectedFileIds: [],
selectedPageIds: [] selectedPageNumbers: []
}; };
case 'SET_PROCESSING': case 'SET_PROCESSING':
@ -192,8 +184,7 @@ function fileContextReducer(state: FileContextState, action: FileContextAction):
case 'RESET_CONTEXT': case 'RESET_CONTEXT':
return { return {
...initialState, ...initialState
mergedDocuments: new Map() // Ensure clean state
}; };
case 'LOAD_STATE': case 'LOAD_STATE':
@ -260,7 +251,7 @@ export function FileContextProvider({
if (state.currentView !== 'fileEditor') params.view = state.currentView; if (state.currentView !== 'fileEditor') params.view = state.currentView;
if (state.currentTool) params.tool = state.currentTool; if (state.currentTool) params.tool = state.currentTool;
if (state.selectedFileIds.length > 0) params.fileIds = state.selectedFileIds; if (state.selectedFileIds.length > 0) params.fileIds = state.selectedFileIds;
if (state.selectedPageIds.length > 0) params.pageIds = state.selectedPageIds; // Note: selectedPageIds intentionally excluded from URL sync - page selection is transient UI state
if (state.viewerConfig.zoom !== 1.0) params.zoom = state.viewerConfig.zoom; if (state.viewerConfig.zoom !== 1.0) params.zoom = state.viewerConfig.zoom;
if (state.viewerConfig.currentPage !== 1) params.page = state.viewerConfig.currentPage; if (state.viewerConfig.currentPage !== 1) params.page = state.viewerConfig.currentPage;
@ -454,11 +445,6 @@ export function FileContextProvider({
dispatch({ type: 'REMOVE_FILES', payload: fileIds }); dispatch({ type: 'REMOVE_FILES', payload: fileIds });
// Clear merged documents that included removed files
// This is a simple approach - clear all merged docs when any file is removed
// Could be optimized to only clear affected merged documents
dispatch({ type: 'CLEAR_MERGED_DOCUMENTS' });
// Remove from IndexedDB // Remove from IndexedDB
if (enablePersistence) { if (enablePersistence) {
fileIds.forEach(async (fileId) => { fileIds.forEach(async (fileId) => {
@ -483,7 +469,6 @@ export function FileContextProvider({
dispatch({ type: 'SET_ACTIVE_FILES', payload: [] }); dispatch({ type: 'SET_ACTIVE_FILES', payload: [] });
dispatch({ type: 'CLEAR_SELECTIONS' }); dispatch({ type: 'CLEAR_SELECTIONS' });
dispatch({ type: 'CLEAR_MERGED_DOCUMENTS' });
}, [cleanupAllFiles]); }, [cleanupAllFiles]);
const setCurrentView = useCallback((view: ViewType) => { const setCurrentView = useCallback((view: ViewType) => {
@ -514,8 +499,12 @@ export function FileContextProvider({
dispatch({ type: 'SET_SELECTED_FILES', payload: fileIds }); dispatch({ type: 'SET_SELECTED_FILES', payload: fileIds });
}, []); }, []);
const setSelectedPages = useCallback((pageIds: string[]) => { const setSelectedPages = useCallback((pageNumbers: number[]) => {
dispatch({ type: 'SET_SELECTED_PAGES', payload: pageIds }); dispatch({ type: 'SET_SELECTED_PAGES', payload: pageNumbers });
}, []);
const updateProcessedFile = useCallback((file: File, processedFile: ProcessedFile) => {
dispatch({ type: 'UPDATE_PROCESSED_FILE', payload: { file, processedFile } });
}, []); }, []);
const clearSelections = useCallback(() => { const clearSelections = useCallback(() => {
@ -610,50 +599,6 @@ export function FileContextProvider({
} }
}, [enablePersistence]); }, [enablePersistence]);
// Merged document management functions
const generateMergedDocumentKey = useCallback((files: File[]): string => {
// Create stable key from file names and sizes
const fileDescriptors = files
.map(file => `${file.name}-${file.size}-${file.lastModified}`)
.sort() // Sort for consistent key regardless of order
.join('|');
return `merged:${fileDescriptors}`;
}, []);
const getMergedDocument = useCallback((fileIds: string[]): PDFDocument | undefined => {
// Convert fileIds to actual files to generate proper key
const files = fileIds.map(id => getFileById(id)).filter((f): f is File => f !== undefined);
if (files.length === 0) return undefined;
const key = generateMergedDocumentKey(files);
return state.mergedDocuments.get(key);
}, [state.mergedDocuments, getFileById, generateMergedDocumentKey]);
const setMergedDocument = useCallback((fileIds: string[], document: PDFDocument) => {
const files = fileIds.map(id => getFileById(id)).filter((f): f is File => f !== undefined);
if (files.length === 0) return;
const key = generateMergedDocumentKey(files);
dispatch({ type: 'SET_MERGED_DOCUMENT', payload: { key, document } });
}, [getFileById, generateMergedDocumentKey]);
const clearMergedDocuments = useCallback(() => {
dispatch({ type: 'CLEAR_MERGED_DOCUMENTS' });
}, []);
// Helper to get merged document from current active files
const getCurrentMergedDocument = useCallback((): PDFDocument | undefined => {
if (state.activeFiles.length === 0) return undefined;
const key = generateMergedDocumentKey(state.activeFiles);
return state.mergedDocuments.get(key);
}, [state.activeFiles, state.mergedDocuments, generateMergedDocumentKey]);
// Helper to set merged document for current active files
const setCurrentMergedDocument = useCallback((document: PDFDocument) => {
if (state.activeFiles.length === 0) return;
const key = generateMergedDocumentKey(state.activeFiles);
dispatch({ type: 'SET_MERGED_DOCUMENT', payload: { key, document } });
}, [state.activeFiles, generateMergedDocumentKey]);
// Auto-save context when it changes // Auto-save context when it changes
useEffect(() => { useEffect(() => {
@ -686,6 +631,7 @@ export function FileContextProvider({
setCurrentTool, setCurrentTool,
setSelectedFiles, setSelectedFiles,
setSelectedPages, setSelectedPages,
updateProcessedFile,
clearSelections, clearSelections,
applyPageOperations, applyPageOperations,
applyFileOperation, applyFileOperation,
@ -700,13 +646,6 @@ export function FileContextProvider({
loadContext, loadContext,
resetContext, resetContext,
// Merged document management
getMergedDocument,
setMergedDocument,
clearMergedDocuments,
getCurrentMergedDocument,
setCurrentMergedDocument,
// Memory management // Memory management
trackBlobUrl, trackBlobUrl,
trackPdfDocument, trackPdfDocument,

View File

@ -145,9 +145,11 @@ export class ThumbnailGenerationService {
onProgress?: (progress: { completed: number; total: number; thumbnails: ThumbnailResult[] }) => void onProgress?: (progress: { completed: number; total: number; thumbnails: ThumbnailResult[] }) => void
): Promise<ThumbnailResult[]> { ): Promise<ThumbnailResult[]> {
if (this.isGenerating) { if (this.isGenerating) {
console.warn('🚨 ThumbnailService: Thumbnail generation already in progress, rejecting new request');
throw new Error('Thumbnail generation already in progress'); throw new Error('Thumbnail generation already in progress');
} }
console.log(`🎬 ThumbnailService: Starting thumbnail generation for ${pageNumbers.length} pages`);
this.isGenerating = true; this.isGenerating = true;
const { const {
@ -166,6 +168,7 @@ export class ThumbnailGenerationService {
// Split pages across workers // Split pages across workers
const workerBatches = this.distributeWork(pageNumbers, this.workers.length); const workerBatches = this.distributeWork(pageNumbers, this.workers.length);
console.log(`🔧 ThumbnailService: Distributing ${pageNumbers.length} pages across ${this.workers.length} workers:`, workerBatches.map(batch => batch.length));
const jobPromises: Promise<ThumbnailResult[]>[] = []; const jobPromises: Promise<ThumbnailResult[]>[] = [];
for (let i = 0; i < workerBatches.length; i++) { for (let i = 0; i < workerBatches.length; i++) {
@ -174,23 +177,32 @@ export class ThumbnailGenerationService {
const worker = this.workers[i % this.workers.length]; const worker = this.workers[i % this.workers.length];
const jobId = `job-${++this.jobCounter}`; const jobId = `job-${++this.jobCounter}`;
console.log(`🔧 ThumbnailService: Sending job ${jobId} with ${batch.length} pages to worker ${i}:`, batch);
const promise = new Promise<ThumbnailResult[]>((resolve, reject) => { const promise = new Promise<ThumbnailResult[]>((resolve, reject) => {
this.activeJobs.set(jobId, { resolve, reject, onProgress });
// Add timeout for worker jobs // Add timeout for worker jobs
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
console.error(`⏰ ThumbnailService: Worker job ${jobId} timed out`);
this.activeJobs.delete(jobId); this.activeJobs.delete(jobId);
reject(new Error(`Worker job ${jobId} timed out`)); reject(new Error(`Worker job ${jobId} timed out`));
}, 60000); // 1 minute timeout }, 60000); // 1 minute timeout
// Clear timeout when job completes // Create job with timeout handling
const originalResolve = resolve;
const originalReject = reject;
this.activeJobs.set(jobId, { this.activeJobs.set(jobId, {
resolve: (result: any) => { clearTimeout(timeout); originalResolve(result); }, resolve: (result: any) => {
reject: (error: any) => { clearTimeout(timeout); originalReject(error); }, console.log(`✅ ThumbnailService: Job ${jobId} completed with ${result.length} thumbnails`);
onProgress clearTimeout(timeout);
resolve(result);
},
reject: (error: any) => {
console.error(`❌ ThumbnailService: Job ${jobId} failed:`, error);
clearTimeout(timeout);
reject(error);
},
onProgress: onProgress ? (progressData: any) => {
console.log(`📊 ThumbnailService: Job ${jobId} progress - ${progressData.completed}/${progressData.total} (${progressData.thumbnails.length} new)`);
onProgress(progressData);
} : undefined
}); });
worker.postMessage({ worker.postMessage({
@ -213,6 +225,7 @@ export class ThumbnailGenerationService {
// Flatten and sort results by page number // Flatten and sort results by page number
const allThumbnails = results.flat().sort((a, b) => a.pageNumber - b.pageNumber); const allThumbnails = results.flat().sort((a, b) => a.pageNumber - b.pageNumber);
console.log(`🎯 ThumbnailService: All workers completed, returning ${allThumbnails.length} thumbnails`);
return allThumbnails; return allThumbnails;
@ -220,6 +233,7 @@ export class ThumbnailGenerationService {
console.error('Web Worker thumbnail generation failed, falling back to main thread:', error); console.error('Web Worker thumbnail generation failed, falling back to main thread:', error);
return await this.generateThumbnailsMainThread(pdfArrayBuffer, pageNumbers, scale, quality, onProgress); return await this.generateThumbnailsMainThread(pdfArrayBuffer, pageNumbers, scale, quality, onProgress);
} finally { } finally {
console.log('🔄 ThumbnailService: Resetting isGenerating flag');
this.isGenerating = false; this.isGenerating = false;
} }
} }
@ -234,11 +248,15 @@ export class ThumbnailGenerationService {
quality: number, quality: number,
onProgress?: (progress: { completed: number; total: number; thumbnails: ThumbnailResult[] }) => void onProgress?: (progress: { completed: number; total: number; thumbnails: ThumbnailResult[] }) => void
): Promise<ThumbnailResult[]> { ): Promise<ThumbnailResult[]> {
console.log(`🔧 ThumbnailService: Fallback to main thread for ${pageNumbers.length} pages`);
// Import PDF.js dynamically for main thread // Import PDF.js dynamically for main thread
const { getDocument } = await import('pdfjs-dist'); const { getDocument } = await import('pdfjs-dist');
// Load PDF once // Load PDF once
const pdf = await getDocument({ data: pdfArrayBuffer }).promise; const pdf = await getDocument({ data: pdfArrayBuffer }).promise;
console.log(`✓ ThumbnailService: PDF loaded on main thread`);
const allResults: ThumbnailResult[] = []; const allResults: ThumbnailResult[] = [];
let completed = 0; let completed = 0;

View File

@ -35,9 +35,6 @@ export interface FileContextState {
activeFiles: File[]; activeFiles: File[];
processedFiles: Map<File, ProcessedFile>; processedFiles: Map<File, ProcessedFile>;
// Cached merged documents (for PageEditor performance)
mergedDocuments: Map<string, PDFDocument>;
// Current navigation state // Current navigation state
currentView: ViewType; currentView: ViewType;
currentTool: ToolType; currentTool: ToolType;
@ -48,7 +45,7 @@ export interface FileContextState {
// UI state that persists across views // UI state that persists across views
selectedFileIds: string[]; selectedFileIds: string[];
selectedPageIds: string[]; selectedPageNumbers: number[];
viewerConfig: ViewerConfig; viewerConfig: ViewerConfig;
// Processing state // Processing state
@ -76,7 +73,8 @@ export interface FileContextActions {
// Selection management // Selection management
setSelectedFiles: (fileIds: string[]) => void; setSelectedFiles: (fileIds: string[]) => void;
setSelectedPages: (pageIds: string[]) => void; setSelectedPages: (pageNumbers: number[]) => void;
updateProcessedFile: (file: File, processedFile: ProcessedFile) => void;
clearSelections: () => void; clearSelections: () => void;
// Edit operations // Edit operations
@ -90,10 +88,6 @@ export interface FileContextActions {
// Export configuration // Export configuration
setExportConfig: (config: FileContextState['lastExportConfig']) => void; setExportConfig: (config: FileContextState['lastExportConfig']) => void;
// Merged document management
getMergedDocument: (fileIds: string[]) => PDFDocument | undefined;
setMergedDocument: (fileIds: string[], document: PDFDocument) => void;
clearMergedDocuments: () => void;
// Utility // Utility
getFileById: (fileId: string) => File | undefined; getFileById: (fileId: string) => File | undefined;