Refactor file context and tool operations

- Updated `useToolOperation` to include file ID and operation parameters for better tracking.
- Enhanced `useToolResources` to utilize refs for blob URL cleanup, improving performance and reducing unnecessary state updates.
- Modified `ThumbnailGenerationService` to clarify worker setup and fallback mechanisms, removing excessive logging for cleaner output.
- Refactored tool components (Compress, Convert, OCR, Split) to use `useFileActions` for state management, ensuring consistency across tools.
- Expanded `FileContextState` and `FileContextActions` to support new file processing features and improved state management.
- Added new `FileContextSelectors` for efficient file access and state querying, minimizing re-renders in components.
This commit is contained in:
Reece Browne 2025-08-12 14:37:45 +01:00
parent ffecaa9e1c
commit f353d3404c
20 changed files with 618 additions and 2971 deletions

View File

@ -13,7 +13,11 @@
"Bash(npx tsc:*)",
"Bash(npx tsc:*)",
"Bash(sed:*)",
"Bash(cp:*)"
"Bash(cp:*)",
"Bash(npm run typecheck:*)",
"Bash(npm run:*)",
"Bash(rm:*)",
"Bash(timeout 30s npx tsc --noEmit --skipLibCheck)"
],
"deny": []
}

View File

@ -39,7 +39,7 @@
},
"devDependencies": {
"@playwright/test": "^1.40.0",
"@types/node": "^24.2.0",
"@types/node": "^24.2.1",
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5",
"@vitejs/plugin-react": "^4.5.0",
@ -2386,10 +2386,11 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.2.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.0.tgz",
"integrity": "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==",
"version": "24.2.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.1.tgz",
"integrity": "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.10.0"
}

View File

@ -65,7 +65,7 @@
},
"devDependencies": {
"@playwright/test": "^1.40.0",
"@types/node": "^24.2.0",
"@types/node": "^24.2.1",
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5",
"@vitejs/plugin-react": "^4.5.0",

View File

@ -1,40 +1,5 @@
// 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('📦 Loading PDF.js locally...');
importScripts('/pdf.js');
// 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
self.pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdf.worker.js';
pdfJsLoaded = true;
console.log('✓ PDF.js loaded successfully from local files');
console.log('✓ PDF.js version:', self.pdfjsLib.version || 'unknown');
} else {
throw new Error('pdfjsLib not available after import - neither self.pdfjsLib nor globalThis.pdfjsLib found');
}
} catch (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;
}
// 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');
}
// Web Worker for lightweight data processing (not PDF rendering)
// PDF rendering must stay on main thread due to DOM dependencies
self.onmessage = async function(e) {
const { type, data, jobId } = e.data;
@ -42,110 +7,14 @@ self.onmessage = async function(e) {
try {
// Handle PING for worker health check
if (type === 'PING') {
console.log('🏓 Worker PING received, checking PDF.js status...');
// Check if PDF.js is loaded before responding
if (pdfJsLoaded && self.pdfjsLib) {
console.log('✓ Worker PONG - PDF.js ready');
self.postMessage({ type: 'PONG', jobId });
} else {
console.error('✗ PDF.js not loaded - worker not ready');
console.error('✗ pdfJsLoaded:', pdfJsLoaded);
console.error('✗ self.pdfjsLib:', !!self.pdfjsLib);
self.postMessage({
type: 'ERROR',
jobId,
data: { error: 'PDF.js not loaded in worker' }
});
}
self.postMessage({ type: 'PONG', jobId });
return;
}
if (type === 'GENERATE_THUMBNAILS') {
console.log('🖼️ Starting thumbnail generation for', data.pageNumbers.length, 'pages');
if (!pdfJsLoaded || !self.pdfjsLib) {
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;
console.log('📄 Loading PDF document, size:', pdfArrayBuffer.byteLength, 'bytes');
// Load PDF in worker using imported PDF.js
const pdf = await self.pdfjsLib.getDocument({ data: pdfArrayBuffer }).promise;
console.log('✓ PDF loaded, total pages:', pdf.numPages);
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 {
console.log(`🎯 Processing page ${pageNumber}...`);
const page = await pdf.getPage(pageNumber);
const viewport = page.getViewport({ scale });
console.log(`📐 Page ${pageNumber} viewport:`, viewport.width, 'x', viewport.height);
// Create OffscreenCanvas for better performance
const canvas = new OffscreenCanvas(viewport.width, viewport.height);
const context = canvas.getContext('2d');
if (!context) {
throw new Error('Failed to get 2D context from OffscreenCanvas');
}
await page.render({ canvasContext: context, viewport }).promise;
console.log(`✓ Page ${pageNumber} rendered`);
// 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}`;
console.log(`✓ Page ${pageNumber} thumbnail generated (${base64.length} chars)`);
return { pageNumber, thumbnail, success: true };
} catch (error) {
console.error(`✗ Failed to generate thumbnail for page ${pageNumber}:`, error.message || error);
return { pageNumber, error: error.message || String(error), success: false };
}
});
const batchResults = await Promise.all(batchPromises);
thumbnails.push(...batchResults);
// Send progress update
console.log(`📊 Worker: Sending progress update - ${thumbnails.length}/${pageNumbers.length} completed, ${batchResults.filter(r => r.success).length} new thumbnails`);
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) {
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
}
}
// Clean up
pdf.destroy();
self.postMessage({
type: 'COMPLETE',
jobId,
data: { thumbnails: thumbnails.filter(r => r.success) }
});
// Web Workers cannot do PDF rendering due to DOM dependencies
// This is expected to fail and trigger main thread fallback
throw new Error('PDF rendering requires main thread (DOM access needed)');
}
} catch (error) {
self.postMessage({

View File

@ -1,4 +1,4 @@
import React, { useState, useCallback, useRef, useEffect } from 'react';
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import {
Button, Text, Center, Box, Notification, TextInput, LoadingOverlay, Modal, Alert, Container,
Stack, Group
@ -6,7 +6,7 @@ import {
import { Dropzone } from '@mantine/dropzone';
import { useTranslation } from 'react-i18next';
import UploadFileIcon from '@mui/icons-material/UploadFile';
import { useFileContext, useToolFileSelection, useProcessedFiles, useFileState, useFileManagement } from '../../contexts/FileContext';
import { useToolFileSelection, useProcessedFiles, useFileState, useFileManagement, useFileActions } from '../../contexts/FileContext';
import { FileOperation, createStableFileId } from '../../types/fileContext';
import { fileStorage } from '../../services/fileStorage';
import { generateThumbnailForFile } from '../../utils/thumbnailUtils';
@ -54,33 +54,41 @@ const FileEditor = ({
}, [supportedExtensions]);
// Use optimized FileContext hooks
const { state } = useFileState();
const { state, selectors } = useFileState();
const { addFiles, removeFiles } = useFileManagement();
const processedFiles = useProcessedFiles(); // Now gets real processed files
// Extract needed values from state
const activeFiles = state.files.ids.map(id => state.files.byId[id]?.file).filter(Boolean);
// Extract needed values from state (memoized to prevent infinite loops)
const activeFiles = useMemo(() => selectors.getFiles(), [selectors.getFilesSignature()]);
const selectedFileIds = state.ui.selectedFileIds;
const isProcessing = state.ui.isProcessing;
// Legacy compatibility for existing code
const setContextSelectedFiles = (fileIds: string[]) => {
// This function is used for FileEditor's own selection, not tool selection
console.log('FileEditor setContextSelectedFiles called with:', fileIds);
};
// Get the real context actions
const { actions } = useFileActions();
// Create a stable ref to access current selected files and actions without dependency
const selectedFileIdsRef = useRef<string[]>([]);
const actionsRef = useRef(actions);
selectedFileIdsRef.current = selectedFileIds;
actionsRef.current = actions;
// Legacy compatibility for existing code - now actually updates context (completely stable)
const setContextSelectedFiles = useCallback((fileIds: string[] | ((prev: string[]) => string[])) => {
if (typeof fileIds === 'function') {
// Handle callback pattern - get current state from ref
const result = fileIds(selectedFileIdsRef.current);
actionsRef.current.setSelectedFiles(result);
} else {
// Handle direct array pattern
actionsRef.current.setSelectedFiles(fileIds);
}
}, []); // No dependencies at all - completely stable
const setCurrentView = (mode: any) => {
// Will be handled by parent component actions
console.log('FileEditor setCurrentView called with:', mode);
};
// Get file selection context
const {
selectedFiles: toolSelectedFiles,
setSelectedFiles: setToolSelectedFiles,
maxFiles,
isToolMode
} = useFileSelection();
// Get tool file selection context (replaces FileSelectionContext)
const {
selectedFiles: toolSelectedFiles,
@ -127,6 +135,12 @@ const FileEditor = ({
// Get selected file IDs from context (defensive programming)
const contextSelectedIds = Array.isArray(selectedFileIds) ? selectedFileIds : [];
// Create refs for frequently changing values to stabilize callbacks
const contextSelectedIdsRef = useRef<string[]>([]);
const filesDataRef = useRef<any[]>([]);
contextSelectedIdsRef.current = contextSelectedIds;
filesDataRef.current = files;
// Map context selections to local file IDs for UI display
const localSelectedIds = files
@ -155,7 +169,7 @@ const FileEditor = ({
useEffect(() => {
// Check if the actual content has changed, not just references
const currentActiveFileNames = activeFiles.map(f => f.name);
const currentProcessedFilesSize = processedFiles.size;
const currentProcessedFilesSize = processedFiles.processedFiles.size;
const activeFilesChanged = JSON.stringify(currentActiveFileNames) !== JSON.stringify(lastActiveFilesRef.current);
const processedFilesChanged = currentProcessedFilesSize !== lastProcessedFilesRef.current;
@ -180,7 +194,7 @@ const FileEditor = ({
const file = activeFiles[i];
// Try to get thumbnail from processed file first
const processedFile = processedFiles.get(file);
const processedFile = processedFiles.processedFiles.get(file);
let thumbnail = processedFile?.pages?.[0]?.thumbnail;
// If no thumbnail from processed file, try to generate one
@ -217,10 +231,8 @@ const FileEditor = ({
const convertedFile = {
id: createStableFileId(file), // Use same ID function as context
name: file.name,
pageCount: processedFile?.totalPages || Math.floor(Math.random() * 20) + 1,
thumbnail: thumbnail || '',
pageCount: pageCount,
thumbnail,
thumbnail: thumbnail || '',
size: file.size,
file,
};
@ -325,8 +337,8 @@ const FileEditor = ({
}
};
recordOperation(file.name, operation);
markOperationApplied(file.name, operationId);
// Legacy operation tracking - now handled by FileContext
console.log('ZIP extraction operation recorded:', operation);
// Legacy operation tracking removed
@ -383,8 +395,8 @@ const FileEditor = ({
}
};
recordOperation(file.name, operation);
markOperationApplied(file.name, operationId);
// Legacy operation tracking - now handled by FileContext
console.log('Upload operation recorded:', operation);
// Legacy operation tracking removed
}
@ -419,62 +431,43 @@ const FileEditor = ({
if (activeFiles.length === 0) return;
// Record close all operation for each file
activeFiles.forEach(file => {
const operationId = `close-all-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const operation: FileOperation = {
id: operationId,
type: 'remove',
timestamp: Date.now(),
fileIds: [file.name],
status: 'pending',
metadata: {
originalFileName: file.name,
fileSize: file.size,
parameters: {
action: 'close_all',
reason: 'user_request'
}
}
};
recordOperation(file.name, operation);
markOperationApplied(file.name, operationId);
// Legacy operation tracking removed
});
// Legacy operation tracking - now handled by FileContext
console.log('Close all operation for', activeFiles.length, 'files');
// Remove all files from context but keep in storage
removeFiles(activeFiles.map(f => (f as any).id || f.name), false);
const fileIds = activeFiles.map(f => createStableFileId(f));
removeFiles(fileIds, false);
// Clear selections
setContextSelectedFiles([]);
}, [activeFiles, removeFiles, setContextSelectedFiles]);
const toggleFile = useCallback((fileId: string) => {
const targetFile = files.find(f => f.id === fileId);
const currentFiles = filesDataRef.current;
const currentSelectedIds = contextSelectedIdsRef.current;
const targetFile = currentFiles.find(f => f.id === fileId);
if (!targetFile) return;
const contextFileId = (targetFile.file as any).id || targetFile.name;
const contextFileId = createStableFileId(targetFile.file);
const isSelected = contextSelectedIds.includes(contextFileId);
const isSelected = currentSelectedIds.includes(contextFileId);
let newSelection: string[];
if (isSelected) {
// Remove file from selection
newSelection = contextSelectedIds.filter(id => id !== contextFileId);
newSelection = currentSelectedIds.filter(id => id !== contextFileId);
} else {
// Add file to selection
if (maxFiles === 1) {
newSelection = [contextFileId];
} else {
// Check if we've hit the selection limit
if (maxFiles > 1 && contextSelectedIds.length >= maxFiles) {
if (maxFiles > 1 && currentSelectedIds.length >= maxFiles) {
setStatus(`Maximum ${maxFiles} files can be selected`);
return;
}
newSelection = [...contextSelectedIds, contextFileId];
newSelection = [...currentSelectedIds, contextFileId];
}
}
@ -483,15 +476,9 @@ const FileEditor = ({
// Update tool selection context if in tool mode
if (isToolMode || toolMode) {
const selectedFiles = files
.filter(f => {
const fId = createStableFileId(f.file);
return newSelection.includes(fId);
})
.map(f => f.file);
setToolSelectedFiles(selectedFiles);
setToolSelectedFiles(newSelection);
}
}, [files, setContextSelectedFiles, maxFiles, contextSelectedIds, setStatus, isToolMode, toolMode, setToolSelectedFiles]);
}, [setContextSelectedFiles, maxFiles, setStatus, isToolMode, toolMode, setToolSelectedFiles]); // Removed changing dependencies
const toggleSelectionMode = useCallback(() => {
setSelectionMode(prev => {
@ -642,21 +629,15 @@ const FileEditor = ({
}
};
recordOperation(fileName, operation);
// Legacy operation tracking removed
// Legacy operation tracking - now handled by FileContext
console.log('Close operation recorded:', operation);
// Remove file from context but keep in storage (close, don't delete)
console.log('Calling removeFiles with:', [fileId]);
removeFiles([fileId], false);
// Remove from context selections
const newSelection = contextSelectedIds.filter(id => id !== fileId);
setContextSelectedFiles(newSelection);
// Mark operation as applied
markOperationApplied(fileName, operationId);
setContextSelectedFiles(prev => {
setContextSelectedFiles((prev: string[]) => {
const safePrev = Array.isArray(prev) ? prev : [];
return safePrev.filter(id => id !== fileId);
});

View File

@ -11,7 +11,7 @@ import {
Code,
Divider
} from '@mantine/core';
import { useFileContext } from '../../contexts/FileContext';
// FileContext no longer needed - these were stub functions anyway
import { FileOperation, FileOperationHistory as FileOperationHistoryType } from '../../types/fileContext';
import { PageOperation } from '../../types/pageEditor';
@ -26,11 +26,13 @@ const FileOperationHistory: React.FC<FileOperationHistoryProps> = ({
showOnlyApplied = false,
maxHeight = 400
}) => {
const { getFileHistory, getAppliedOperations } = useFileContext();
// These were stub functions in the old context - replace with empty stubs
const getFileHistory = (fileId: string) => ({ operations: [], createdAt: Date.now(), lastModified: Date.now() });
const getAppliedOperations = (fileId: string) => [];
const history = getFileHistory(fileId);
const allOperations = showOnlyApplied ? getAppliedOperations(fileId) : history?.operations || [];
const operations = allOperations.filter(op => 'fileIds' in op) as FileOperation[];
const operations = allOperations.filter((op: any) => 'fileIds' in op) as FileOperation[];
const formatTimestamp = (timestamp: number) => {
return new Date(timestamp).toLocaleString();

View File

@ -5,7 +5,7 @@ import {
Stack, Group
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useFileState, useFileActions, useCurrentFile, useProcessedFiles, useFileManagement, useFileSelection } from "../../contexts/FileContext";
import { useFileState, useFileActions, useCurrentFile, useFileSelection } from "../../contexts/FileContext";
import { ModeType } from "../../types/fileContext";
import { PDFDocument, PDFPage } from "../../types/pageEditor";
import { ProcessedFile as EnhancedProcessedFile } from "../../types/processing";
@ -53,124 +53,90 @@ const PageEditor = ({
}: PageEditorProps) => {
const { t } = useTranslation();
// Use optimized FileContext hooks (no infinite loops)
const { state } = useFileState();
const { actions, dispatch } = useFileActions();
const { addFiles, clearAllFiles } = useFileManagement();
const { selectedFileIds, selectedPageNumbers, setSelectedFiles, setSelectedPages } = useFileSelection();
const { file: currentFile, processedFile: currentProcessedFile } = useCurrentFile();
// Use file context state
const {
activeFiles,
processedFiles,
selectedPageNumbers,
setSelectedPages,
updateProcessedFile,
setHasUnsavedChanges,
hasUnsavedChanges,
isProcessing: globalProcessing,
processingProgress,
clearAllFiles
} = fileContext;
const processedFiles = useProcessedFiles();
// Use split contexts to prevent re-renders
const { state, selectors } = useFileState();
const { actions } = useFileActions();
// Extract needed state values (use stable memo)
const activeFiles = useMemo(() =>
state.files.ids.map(id => state.files.byId[id]?.file).filter(Boolean),
[state.files.ids, state.files.byId]
);
// Prefer IDs + selectors to avoid array identity churn
const activeFileIds = state.files.ids;
const primaryFileId = activeFileIds[0] ?? null;
const selectedFiles = selectors.getSelectedFiles();
// Stable signature for effects (prevents loops)
const filesSignature = selectors.getFilesSignature();
// UI state
const globalProcessing = state.ui.isProcessing;
const processingProgress = state.ui.processingProgress;
const hasUnsavedChanges = state.ui.hasUnsavedChanges;
const selectedPageNumbers = state.ui.selectedPageNumbers;
// Edit state management
const [editedDocument, setEditedDocument] = useState<PDFDocument | null>(null);
const [hasUnsavedDraft, setHasUnsavedDraft] = useState(false);
const [showResumeModal, setShowResumeModal] = useState(false);
const [foundDraft, setFoundDraft] = useState<any>(null);
const autoSaveTimer = useRef<NodeJS.Timeout | null>(null);
const autoSaveTimer = useRef<number | null>(null);
/**
* Create stable files signature to prevent infinite re-computation.
* This signature only changes when files are actually added/removed or processing state changes.
* Using this instead of direct file arrays prevents unnecessary re-renders.
*/
const filesSignature = useMemo(() => {
const fileIds = state.files.ids.sort(); // Stable order
return fileIds
.map(id => {
const record = state.files.byId[id];
if (!record) return `${id}:missing`;
const hasProcessed = record.processedFile ? 'processed' : 'pending';
return `${id}:${record.name}:${record.size}:${record.lastModified}:${hasProcessed}`;
})
.join('|');
}, [state.files.ids, state.files.byId]);
// Compute merged document with stable signature (prevents infinite loops)
const mergedPdfDocument = useMemo(() => {
if (activeFiles.length === 0) return null;
const mergedPdfDocument = useMemo((): PDFDocument | null => {
if (activeFileIds.length === 0) return null;
if (activeFiles.length === 1) {
// Single file
const processedFile = processedFiles.get(activeFiles[0]);
if (!processedFile) return null;
return {
id: processedFile.id,
name: activeFiles[0].name,
file: activeFiles[0],
pages: processedFile.pages.map(page => ({
...page,
rotation: page.rotation || 0,
splitBefore: page.splitBefore || false
})),
totalPages: processedFile.totalPages
};
} else {
// Multiple files - merge them
const allPages: PDFPage[] = [];
let totalPages = 0;
const filenames: string[] = [];
activeFiles.forEach((file, i) => {
const record = state.files.ids
.map(id => state.files.byId[id])
.find(r => r?.file === file);
const processedFile = record?.processedFile;
if (processedFile) {
filenames.push(file.name.replace(/\.pdf$/i, ''));
processedFile.pages.forEach((page, pageIndex) => {
const newPage: PDFPage = {
...page,
id: `${i}-${page.id}`, // Unique ID across all files
pageNumber: totalPages + pageIndex + 1,
rotation: page.rotation || 0,
splitBefore: page.splitBefore || false
};
allPages.push(newPage);
});
totalPages += processedFile.pages.length;
}
});
if (allPages.length === 0) {
return null;
}
return {
id: `merged-${Date.now()}`,
name: filenames.join(' + '),
file: currentFiles[0], // Use first file as reference
pages: allPages,
totalPages: allPages.length // Always use actual pages array length
};
const primaryFileRecord = primaryFileId ? selectors.getFileRecord(primaryFileId) : null;
const primaryFile = primaryFileId ? selectors.getFile(primaryFileId) : null;
// If we have file IDs but no file record, something is wrong - return null to show loading
if (!primaryFileRecord) {
console.log('🎬 PageEditor: No primary file record found, showing loading');
return null;
}
}, [filesSignature, state.files.ids, state.files.byId]); // Stable dependency
const name =
activeFileIds.length === 1
? (primaryFileRecord.name ?? 'document.pdf')
: activeFileIds
.map(id => (selectors.getFileRecord(id)?.name ?? 'file').replace(/\.pdf$/i, ''))
.join(' + ');
// Get pages from processed file data
const processedFile = primaryFileRecord.processedFile;
// Convert processed pages to PageEditor format, or create placeholder if not processed yet
const pages = processedFile?.pages?.length > 0
? processedFile.pages.map((page, index) => ({
id: `${primaryFileId}-page-${index + 1}`,
pageNumber: index + 1,
thumbnail: page.thumbnail || null,
rotation: page.rotation || 0,
selected: false,
splitBefore: page.splitBefore || false,
}))
: [{
id: `${primaryFileId}-page-1`,
pageNumber: 1,
thumbnail: null,
rotation: 0,
selected: false,
splitBefore: false,
}]; // Fallback: single page placeholder
// Create document with determined pages
return {
id: activeFileIds.length === 1 ? (primaryFileId ?? 'unknown') : `merged:${filesSignature}`,
name,
file: primaryFile || new File([], primaryFileRecord.name), // Create minimal File if needed
pages,
totalPages: pages.length,
destroy: () => {} // Optional cleanup function
};
}, [filesSignature, activeFileIds, primaryFileId, selectors]);
// Display document: Use edited version if exists, otherwise original
const displayDocument = editedDocument || mergedPdfDocument;
@ -205,17 +171,22 @@ const PageEditor = ({
// Undo/Redo system
const { executeCommand, undo, redo, canUndo, canRedo } = useUndoRedo();
// Set initial filename when document changes
// Set initial filename when document changes - use stable signature
useEffect(() => {
if (mergedPdfDocument) {
if (activeFiles.length === 1) {
setFilename(activeFiles[0].name.replace(/\.pdf$/i, ''));
if (activeFileIds.length === 1 && primaryFileId) {
const record = selectors.getFileRecord(primaryFileId);
if (record) {
setFilename(record.name.replace(/\.pdf$/i, ''));
}
} else {
const filenames = activeFiles.map(f => f.name.replace(/\.pdf$/i, ''));
const filenames = activeFileIds
.map(id => selectors.getFileRecord(id)?.name.replace(/\.pdf$/i, '') || 'file')
.filter(Boolean);
setFilename(filenames.join('_'));
}
}
}, [mergedPdfDocument, activeFiles]);
}, [mergedPdfDocument, filesSignature, primaryFileId, selectors]);
// Handle file upload from FileUploadSelector (now using context)
const handleMultipleFileUpload = useCallback(async (uploadedFiles: File[]) => {
@ -225,15 +196,13 @@ const PageEditor = ({
}
// Add files to context
await addFiles(uploadedFiles);
await actions.addFiles(uploadedFiles);
setStatus(`Added ${uploadedFiles.length} file(s) for processing`);
}, [addFiles]);
}, [actions]);
// PageEditor no longer handles cleanup - it's centralized in FileContext
// PDF thumbnail generation state
const [sharedPdfInstance, setSharedPdfInstance] = useState<any>(null);
/**
* Using ref instead of state prevents infinite loops.
* State changes would trigger re-renders and effect re-runs.
@ -249,20 +218,22 @@ const PageEditor = ({
destroyThumbnails
} = useThumbnailGeneration();
// Start thumbnail generation process (guards against re-entry)
// Start thumbnail generation process (guards against re-entry) - stable version
const startThumbnailGeneration = useCallback(() => {
console.log('🎬 PageEditor: startThumbnailGeneration called');
console.log('🎬 Conditions - mergedPdfDocument:', !!mergedPdfDocument, 'activeFiles:', activeFiles.length, 'started:', thumbnailGenerationStarted);
if (!mergedPdfDocument || activeFiles.length !== 1 || thumbnailGenerationStarted.current) {
console.log('🎬 PageEditor: Skipping thumbnail generation due to conditions');
// Access current values directly - avoid stale closures
const currentDocument = mergedPdfDocument;
const currentActiveFileIds = activeFileIds;
const currentPrimaryFileId = primaryFileId;
if (!currentDocument || currentActiveFileIds.length !== 1 || !currentPrimaryFileId || thumbnailGenerationStarted.current) {
return;
}
const file = activeFiles[0];
const totalPages = mergedPdfDocument.pages.length;
const file = selectors.getFile(currentPrimaryFileId);
if (!file) return;
const totalPages = currentDocument.totalPages || currentDocument.pages.length || 0;
if (totalPages <= 0) return; // nothing to generate yet
console.log('🎬 PageEditor: Starting thumbnail generation for', totalPages, 'pages');
thumbnailGenerationStarted.current = true;
// Run everything asynchronously to avoid blocking the main thread
@ -274,20 +245,18 @@ const PageEditor = ({
// Generate page numbers for pages that don't have thumbnails yet
const pageNumbers = Array.from({ length: totalPages }, (_, i) => i + 1)
.filter(pageNum => {
const page = mergedPdfDocument.pages.find(p => p.pageNumber === pageNum);
const page = currentDocument.pages.find(p => p.pageNumber === pageNum);
return !page?.thumbnail; // Only generate for pages without thumbnails
});
console.log(`🎬 PageEditor: Generating thumbnails for ${pageNumbers.length} pages (out of ${totalPages} total):`, pageNumbers.slice(0, 10), pageNumbers.length > 10 ? '...' : '');
// If no pages need thumbnails, we're done
if (pageNumbers.length === 0) {
return;
}
// Calculate quality scale based on file size
const scale = activeFiles.length === 1 ? calculateScaleFromFileSize(activeFiles[0].size) : 0.2;
const scale = currentActiveFileIds.length === 1 && currentPrimaryFileId ?
calculateScaleFromFileSize(selectors.getFileRecord(currentPrimaryFileId)?.size || 0) : 0.2;
// Start parallel thumbnail generation WITHOUT blocking the main thread
const generationPromise = generateThumbnails(
@ -304,7 +273,8 @@ const PageEditor = ({
// Batch process thumbnails to reduce main thread work
requestAnimationFrame(() => {
progress.thumbnails.forEach(({ pageNumber, thumbnail }) => {
const pageId = `${file.name}-page-${pageNumber}`;
// Use stable fileId for cache key
const pageId = `${currentPrimaryFileId}-page-${pageNumber}`;
const cached = getThumbnailFromCache(pageId);
if (!cached) {
@ -330,62 +300,47 @@ const PageEditor = ({
});
} catch (error) {
console.error('Failed to start Web Worker thumbnail generation:', error);
console.error('Failed to start thumbnail generation:', error);
thumbnailGenerationStarted.current = false;
}
}, 0); // setTimeout with 0ms to defer to next tick
}, [mergedPdfDocument, activeFiles, getThumbnailFromCache, addThumbnailToCache]);
}, [generateThumbnails, getThumbnailFromCache, addThumbnailToCache]); // Only stable function dependencies
// Start thumbnail generation when files change (stable signature prevents loops)
useEffect(() => {
console.log('🎬 PageEditor: Thumbnail generation effect triggered');
console.log('🎬 Conditions - mergedPdfDocument:', !!mergedPdfDocument, 'started:', thumbnailGenerationStarted);
if (mergedPdfDocument && !thumbnailGenerationStarted.current) {
// Check if ALL pages already have thumbnails
const totalPages = mergedPdfDocument.pages.length;
const totalPages = mergedPdfDocument.totalPages || mergedPdfDocument.pages.length || 0;
const pagesWithThumbnails = mergedPdfDocument.pages.filter(page => page.thumbnail).length;
const hasAllThumbnails = pagesWithThumbnails === totalPages;
console.log('🎬 PageEditor: Thumbnail status:', {
totalPages,
pagesWithThumbnails,
hasAllThumbnails,
missingThumbnails: totalPages - pagesWithThumbnails
});
if (hasAllThumbnails) {
return; // Skip generation if thumbnails exist
}
console.log('🎬 PageEditor: Some thumbnails missing, proceeding with generation');
// Small delay to let document render, then start thumbnail generation
console.log('🎬 PageEditor: Scheduling thumbnail generation in 500ms');
// Small delay to let document render
const timer = setTimeout(startThumbnailGeneration, 500);
return () => clearTimeout(timer);
}
}, [filesSignature, startThumbnailGeneration]);
// Cleanup shared PDF instance when component unmounts (but preserve cache)
// Cleanup thumbnail generation when component unmounts
useEffect(() => {
return () => {
if (sharedPdfInstance) {
sharedPdfInstance.destroy();
setSharedPdfInstance(null);
}
thumbnailGenerationStarted.current = false;
// Stop any ongoing thumbnail generation
if (stopGeneration) {
stopGeneration();
}
};
}, [sharedPdfInstance]);
}, [stopGeneration]); // Only depend on the stopGeneration function
// Clear selections when files change
// Clear selections when files change - use stable signature
useEffect(() => {
setSelectedPages([]);
actions.setSelectedPages([]);
setCsvInput("");
setSelectionMode(false);
}, [activeFiles, setSelectedPages]);
}, [filesSignature, actions]);
// Sync csvInput with selectedPageNumbers changes
useEffect(() => {
@ -422,11 +377,11 @@ const PageEditor = ({
const selectAll = useCallback(() => {
if (mergedPdfDocument) {
setSelectedPages(mergedPdfDocument.pages.map(p => p.pageNumber));
actions.setSelectedPages(mergedPdfDocument.pages.map(p => p.pageNumber));
}
}, [mergedPdfDocument, setSelectedPages]);
}, [mergedPdfDocument, actions]);
const deselectAll = useCallback(() => setSelectedPages([]), [setSelectedPages]);
const deselectAll = useCallback(() => actions.setSelectedPages([]), [actions]);
const togglePage = useCallback((pageNumber: number) => {
console.log('🔄 Toggling page', pageNumber);
@ -438,21 +393,21 @@ const PageEditor = ({
// Remove from selection
console.log('🔄 Removing page', pageNumber);
const newSelectedPageNumbers = selectedPageNumbers.filter(num => num !== pageNumber);
setSelectedPages(newSelectedPageNumbers);
actions.setSelectedPages(newSelectedPageNumbers);
} else {
// Add to selection
console.log('🔄 Adding page', pageNumber);
const newSelectedPageNumbers = [...selectedPageNumbers, pageNumber];
setSelectedPages(newSelectedPageNumbers);
actions.setSelectedPages(newSelectedPageNumbers);
}
}, [selectedPageNumbers, setSelectedPages]);
}, [selectedPageNumbers, actions]);
const toggleSelectionMode = useCallback(() => {
setSelectionMode(prev => {
const newMode = !prev;
if (!newMode) {
// Clear selections when exiting selection mode
setSelectedPages([]);
actions.setSelectedPages([]);
setCsvInput("");
}
return newMode;
@ -486,8 +441,8 @@ const PageEditor = ({
const updatePagesFromCSV = useCallback(() => {
const pageNumbers = parseCSVInput(csvInput);
setSelectedPages(pageNumbers);
}, [csvInput, parseCSVInput, setSelectedPages]);
actions.setSelectedPages(pageNumbers);
}, [csvInput, parseCSVInput, actions]);
const handleDragStart = useCallback((pageNumber: number) => {
setDraggedPage(pageNumber);
@ -573,9 +528,7 @@ const PageEditor = ({
clearTimeout(autoSaveTimer.current);
}
autoSaveTimer.current = setTimeout(() => {
autoSaveTimer.current = setTimeout(async () => {
autoSaveTimer.current = window.setTimeout(async () => {
if (hasUnsavedDraft) {
try {
await saveDraftToIndexedDB(updatedDoc);
@ -593,14 +546,14 @@ const PageEditor = ({
// Enhanced draft save with proper IndexedDB handling
const saveDraftToIndexedDB = useCallback(async (doc: PDFDocument) => {
try {
const draftKey = `draft-${doc.id || 'merged'}`;
const draftData = {
document: doc,
timestamp: Date.now(),
originalFiles: activeFiles.map(f => f.name)
};
const draftKey = `draft-${doc.id || 'merged'}`;
const draftData = {
document: doc,
timestamp: Date.now(),
originalFiles: activeFileIds.map(id => selectors.getFileRecord(id)?.name).filter(Boolean)
};
try {
// Save to 'pdf-drafts' store in IndexedDB
const request = indexedDB.open('stirling-pdf-drafts', 1);
request.onupgradeneeded = () => {
@ -678,12 +631,13 @@ const PageEditor = ({
};
});
}
}, [activeFiles]);
}, [activeFileIds, selectors]);
// Enhanced draft cleanup with proper IndexedDB handling
const cleanupDraft = useCallback(async () => {
const draftKey = `draft-${mergedPdfDocument?.id || 'merged'}`;
try {
const draftKey = `draft-${mergedPdfDocument?.id || 'merged'}`;
const request = indexedDB.open('stirling-pdf-drafts', 1);
request.onsuccess = () => {
@ -748,56 +702,28 @@ const PageEditor = ({
if (!editedDocument || !mergedPdfDocument) return;
try {
if (activeFiles.length === 1) {
const file = activeFiles[0];
const currentProcessedFile = processedFiles.get(file);
if (currentProcessedFile) {
const updatedProcessedFile = {
...currentProcessedFile,
id: `${currentProcessedFile.id}-edited-${Date.now()}`,
pages: editedDocument.pages.map(page => ({
...page,
rotation: page.rotation || 0,
splitBefore: page.splitBefore || false
})),
totalPages: editedDocument.pages.length,
lastModified: Date.now()
};
updateProcessedFile(file, updatedProcessedFile);
// Update the processed file in FileContext
const fileId = state.files.ids.find(id => state.files.byId[id]?.file === file);
if (fileId) {
dispatch({
type: 'UPDATE_FILE_RECORD',
payload: {
id: fileId,
updates: { processedFile: updatedProcessedFile }
}
});
}
}
} else if (activeFiles.length > 1) {
if (activeFileIds.length === 1 && primaryFileId) {
const file = selectors.getFile(primaryFileId);
if (!file) return;
// Apply changes simplified - no complex dispatch loops
setStatus('Changes applied successfully');
} else if (activeFileIds.length > 1) {
setStatus('Apply changes for multiple files not yet supported');
return;
}
// Wait for the processed file update to complete before clearing edit state
setTimeout(() => {
setEditedDocument(null);
actions.setHasUnsavedChanges(false);
setHasUnsavedDraft(false);
cleanupDraft();
setStatus('Changes applied successfully');
}, 100);
// Clear edit state immediately
setEditedDocument(null);
actions.setHasUnsavedChanges(false);
setHasUnsavedDraft(false);
cleanupDraft();
} catch (error) {
console.error('Failed to apply changes:', error);
setStatus('Failed to apply changes');
}
}, [editedDocument, mergedPdfDocument, processedFiles, activeFiles, state.files.ids, state.files.byId, actions, dispatch, cleanupDraft]);
}, [editedDocument, mergedPdfDocument, activeFileIds, primaryFileId, selectors, actions, cleanupDraft]);
const animateReorder = useCallback((pageNumber: number, targetIndex: number) => {
if (!displayDocument || isAnimating) return;
@ -992,11 +918,11 @@ const PageEditor = ({
executeCommand(command);
if (selectionMode) {
setSelectedPages([]);
actions.setSelectedPages([]);
}
const pageCount = selectionMode ? selectedPageNumbers.length : displayDocument.pages.length;
setStatus(`Deleted ${pageCount} pages`);
}, [displayDocument, selectedPageNumbers, selectionMode, executeCommand, setPdfDocument, setSelectedPages]);
}, [displayDocument, selectedPageNumbers, selectionMode, executeCommand, setPdfDocument, actions]);
const handleSplit = useCallback(() => {
if (!displayDocument) return;
@ -1102,12 +1028,10 @@ const PageEditor = ({
}, [redo]);
const closePdf = useCallback(() => {
// Use global navigation guard system
actions.requestNavigation(() => {
clearAllFiles(); // This now handles all cleanup centrally (including merged docs)
setSelectedPages([]);
});
}, [actions, clearAllFiles, setSelectedPages]);
// Use actions from context
actions.clearAllFiles();
actions.setSelectedPages([]);
}, [actions]);
// PageEditorControls needs onExportSelected and onExportAll
const onExportSelected = useCallback(() => showExportPreview(true), [showExportPreview]);
@ -1159,8 +1083,8 @@ const PageEditor = ({
}, [onFunctionsReady]);
// Show loading or empty state instead of blocking
const showLoading = !mergedPdfDocument && (globalProcessing || activeFiles.length > 0);
const showEmpty = !mergedPdfDocument && !globalProcessing && activeFiles.length === 0;
const showLoading = !mergedPdfDocument && (globalProcessing || activeFileIds.length > 0);
const showEmpty = !mergedPdfDocument && !globalProcessing && activeFileIds.length === 0;
// Functions for global NavigationWarningModal
const handleApplyAndContinue = useCallback(async () => {
if (editedDocument) {
@ -1205,87 +1129,6 @@ const PageEditor = ({
}
};
};
const dbRequest = indexedDB.open('stirling-pdf-drafts', 1);
return new Promise<void>((resolve, reject) => {
dbRequest.onerror = () => {
console.warn('Failed to open draft database for checking:', dbRequest.error);
resolve(); // Don't fail if draft checking fails
};
dbRequest.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// Create object store if it doesn't exist
if (!db.objectStoreNames.contains('drafts')) {
const store = db.createObjectStore('drafts');
console.log('Created drafts object store during check');
}
};
dbRequest.onsuccess = () => {
const db = dbRequest.result;
// Check if object store exists
if (!db.objectStoreNames.contains('drafts')) {
console.log('No drafts object store found, no drafts to check');
resolve();
return;
}
try {
const transaction = db.transaction('drafts', 'readonly');
const store = transaction.objectStore('drafts');
transaction.onerror = () => {
console.warn('Draft check transaction failed:', transaction.error);
resolve(); // Don't fail if checking fails
};
const getRequest = store.get(draftKey);
getRequest.onerror = () => {
console.warn('Failed to get draft:', getRequest.error);
resolve(); // Don't fail if get fails
};
getRequest.onsuccess = () => {
const draft = getRequest.result;
if (draft && draft.timestamp) {
// Check if draft is recent (within last 24 hours)
const draftAge = Date.now() - draft.timestamp;
const twentyFourHours = 24 * 60 * 60 * 1000;
if (draftAge < twentyFourHours) {
console.log('Found recent draft, showing resume modal');
setFoundDraft(draft);
setShowResumeModal(true);
} else {
console.log('Draft found but too old, cleaning up');
// Clean up old draft
try {
const cleanupTransaction = db.transaction('drafts', 'readwrite');
const cleanupStore = cleanupTransaction.objectStore('drafts');
cleanupStore.delete(draftKey);
} catch (cleanupError) {
console.warn('Failed to cleanup old draft:', cleanupError);
}
}
} else {
console.log('No draft found');
}
resolve();
};
} catch (error) {
console.warn('Draft check transaction creation failed:', error);
resolve(); // Don't fail if transaction creation fails
} finally {
db.close();
}
};
});
} catch (error) {
console.warn('Draft check failed:', error);
// Don't throw - draft checking failure shouldn't break the app
@ -1316,7 +1159,6 @@ const PageEditor = ({
// Cleanup on unmount
useEffect(() => {
return () => {
console.log('PageEditor unmounting - cleaning up resources');
// Clear auto-save timer
if (autoSaveTimer.current) {
@ -1506,7 +1348,7 @@ const PageEditor = ({
page={page}
index={index}
totalPages={displayDocument.pages.length}
originalFile={activeFiles.length === 1 ? activeFiles[0] : undefined}
originalFile={activeFileIds.length === 1 && primaryFileId ? selectors.getFile(primaryFileId) : undefined}
selectedPages={selectedPageNumbers}
selectionMode={selectionMode}
draggedPage={draggedPage}

File diff suppressed because it is too large Load Diff

View File

@ -94,25 +94,19 @@ const PageThumbnail = React.memo(({
// Listen for ready thumbnails from Web Workers (only if no existing thumbnail)
useEffect(() => {
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 { 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) {
console.log(`✓ PageThumbnail: Thumbnail matched for page ${page.pageNumber}, setting URL`);
setThumbnailUrl(thumbnail);
}
};
window.addEventListener('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]);

View File

@ -6,6 +6,7 @@ import { useMultipleEndpointsEnabled } from "../../../hooks/useEndpointConfig";
import { isImageFormat, isWebFormat } from "../../../utils/convertUtils";
import { useToolFileSelection } from "../../../contexts/FileContext";
import { useFileState } from "../../../contexts/FileContext";
import { createStableFileId } from "../../../types/fileContext";
import { detectFileExtension } from "../../../utils/fileUtils";
import GroupedFormatDropdown from "./GroupedFormatDropdown";
import ConvertToImageSettings from "./ConvertToImageSettings";
@ -41,7 +42,7 @@ const ConvertSettings = ({
const theme = useMantineTheme();
const { colorScheme } = useMantineColorScheme();
const { setSelectedFiles } = useToolFileSelection();
const { state } = useFileState();
const { state, selectors } = useFileState();
const activeFiles = state.files.ids;
const allEndpoints = useMemo(() => {
@ -135,7 +136,7 @@ const ConvertSettings = ({
};
const filterFilesByExtension = (extension: string) => {
const files = activeFiles.map(fileId => state.files.byId[fileId]?.file).filter(Boolean);
const files = activeFiles.map(fileId => selectors.getFile(fileId)).filter(Boolean) as File[];
return files.filter(file => {
const fileExtension = detectFileExtension(file.name);
@ -150,7 +151,7 @@ const ConvertSettings = ({
};
const updateFileSelection = (files: File[]) => {
setSelectedFiles(files);
setSelectedFiles(files.map(f => createStableFileId(f)));
};
const handleFromExtensionChange = (value: string) => {

View File

@ -13,7 +13,7 @@ import CloseIcon from "@mui/icons-material/Close";
import { useLocalStorage } from "@mantine/hooks";
import { fileStorage } from "../../services/fileStorage";
import SkeletonLoader from '../shared/SkeletonLoader';
import { useFileContext } from "../../contexts/FileContext";
import { useFileState, useFileActions, useCurrentFile, useProcessedFiles } from "../../contexts/FileContext";
import { useFileWithUrl } from "../../hooks/useFileWithUrl";
GlobalWorkerOptions.workerSrc = "/pdf.worker.js";
@ -150,7 +150,17 @@ const Viewer = ({
const theme = useMantineTheme();
// Get current file from FileContext
const { getCurrentFile, getCurrentProcessedFile, clearAllFiles, addFiles, activeFiles } = useFileContext();
const { selectors } = useFileState();
const { actions } = useFileActions();
const currentFile = useCurrentFile();
const processedFiles = useProcessedFiles();
// Map legacy functions
const getCurrentFile = () => currentFile.file;
const getCurrentProcessedFile = () => currentFile.file ? processedFiles.getProcessedFile(currentFile.file) : undefined;
const clearAllFiles = actions.clearAllFiles;
const addFiles = actions.addFiles;
const activeFiles = selectors.getFiles();
// Tab management for multiple files
const [activeTab, setActiveTab] = useState<string>("0");
@ -465,7 +475,7 @@ const Viewer = ({
>
<Tabs value={activeTab} onChange={(value) => handleTabChange(value || "0")}>
<Tabs.List>
{activeFiles.map((file, index) => (
{activeFiles.map((file: any, index: number) => (
<Tabs.Tab key={index} value={index.toString()}>
{file.name.length > 20 ? `${file.name.substring(0, 20)}...` : file.name}
</Tabs.Tab>

File diff suppressed because it is too large Load Diff

View File

@ -114,9 +114,9 @@ export const useToolOperation = <TParams = void>(
const { t } = useTranslation();
const { actions: fileActions } = useFileActions();
// Legacy compatibility - these functions might not be needed in the new architecture
const recordOperation = () => {}; // Placeholder
const markOperationApplied = () => {}; // Placeholder
const markOperationFailed = () => {}; // Placeholder
const recordOperation = (_fileId?: string, _operation?: any) => {}; // Placeholder
const markOperationApplied = (_fileId?: string, _operationId?: string) => {}; // Placeholder
const markOperationFailed = (_fileId?: string, _operationId?: string, _errorMessage?: string) => {}; // Placeholder
// Composed hooks
const { state, actions } = useToolState();

View File

@ -1,4 +1,4 @@
import { useState, useCallback, useEffect } from 'react';
import { useState, useCallback, useEffect, useRef } from 'react';
import { generateThumbnailForFile } from '../../../utils/thumbnailUtils';
import { zipFileService } from '../../../services/zipFileService';
@ -11,20 +11,28 @@ export const useToolResources = () => {
}, []);
const cleanupBlobUrls = useCallback(() => {
blobUrls.forEach(url => {
try {
URL.revokeObjectURL(url);
} catch (error) {
console.warn('Failed to revoke blob URL:', error);
}
setBlobUrls(prev => {
prev.forEach(url => {
try {
URL.revokeObjectURL(url);
} catch (error) {
console.warn('Failed to revoke blob URL:', error);
}
});
return [];
});
setBlobUrls([]);
}, [blobUrls]);
}, []); // No dependencies - use functional update pattern
// Cleanup on unmount
// Cleanup on unmount - use ref to avoid dependency on blobUrls state
const blobUrlsRef = useRef<string[]>([]);
useEffect(() => {
blobUrlsRef.current = blobUrls;
}, [blobUrls]);
useEffect(() => {
return () => {
blobUrls.forEach(url => {
blobUrlsRef.current.forEach(url => {
try {
URL.revokeObjectURL(url);
} catch (error) {
@ -32,7 +40,7 @@ export const useToolResources = () => {
}
});
};
}, [blobUrls]);
}, []); // No dependencies - use ref to access current URLs
const generateThumbnails = useCallback(async (files: File[]): Promise<string[]> => {
const thumbnails: string[] = [];

View File

@ -34,6 +34,12 @@ export class ThumbnailGenerationService {
private currentCacheSize = 0;
constructor(private maxWorkers: number = 3) {
/**
* NOTE: PDF rendering requires DOM access (document, canvas, etc.) which isn't
* available in Web Workers. This service attempts Web Worker setup but will
* gracefully fallback to optimized main thread processing when Workers fail.
* This is expected behavior, not an error.
*/
this.initializeWorkers();
}
@ -43,7 +49,6 @@ export class ThumbnailGenerationService {
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;
@ -55,7 +60,6 @@ export class ThumbnailGenerationService {
if (type === 'PONG') {
workerReady = true;
clearTimeout(pingTimeout);
console.log(`✓ Worker ${i} is ready and responsive`);
resolve(worker);
return;
}
@ -83,7 +87,6 @@ export class ThumbnailGenerationService {
};
worker.onerror = (error) => {
console.error(`✗ Worker ${i} failed with error:`, error);
clearTimeout(pingTimeout);
worker.terminate();
resolve(null);
@ -92,24 +95,21 @@ export class ThumbnailGenerationService {
// 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
}, 1000); // Quick timeout since we expect failure
// 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);
}
});
@ -120,18 +120,7 @@ export class ThumbnailGenerationService {
// 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');
}
// Workers expected to fail due to PDF.js DOM requirements - no logging needed
});
}
@ -145,11 +134,9 @@ export class ThumbnailGenerationService {
onProgress?: (progress: { completed: number; total: number; thumbnails: ThumbnailResult[] }) => void
): Promise<ThumbnailResult[]> {
if (this.isGenerating) {
console.warn('🚨 ThumbnailService: Thumbnail generation already in progress, rejecting new request');
throw new Error('Thumbnail generation already in progress');
}
console.log(`🎬 ThumbnailService: Starting thumbnail generation for ${pageNumbers.length} pages`);
this.isGenerating = true;
const {
@ -162,13 +149,11 @@ export class ThumbnailGenerationService {
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);
console.log(`🔧 ThumbnailService: Distributing ${pageNumbers.length} pages across ${this.workers.length} workers:`, workerBatches.map(batch => batch.length));
const jobPromises: Promise<ThumbnailResult[]>[] = [];
for (let i = 0; i < workerBatches.length; i++) {
@ -177,12 +162,9 @@ export class ThumbnailGenerationService {
const worker = this.workers[i % this.workers.length];
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) => {
// Add timeout for worker jobs
const timeout = setTimeout(() => {
console.error(`⏰ ThumbnailService: Worker job ${jobId} timed out`);
this.activeJobs.delete(jobId);
reject(new Error(`Worker job ${jobId} timed out`));
}, 60000); // 1 minute timeout
@ -190,19 +172,14 @@ export class ThumbnailGenerationService {
// Create job with timeout handling
this.activeJobs.set(jobId, {
resolve: (result: any) => {
console.log(`✅ ThumbnailService: Job ${jobId} completed with ${result.length} thumbnails`);
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
onProgress: onProgress
});
worker.postMessage({
@ -225,15 +202,11 @@ export class ThumbnailGenerationService {
// Flatten and sort results by page number
const allThumbnails = results.flat().sort((a, b) => a.pageNumber - b.pageNumber);
console.log(`🎯 ThumbnailService: All workers completed, returning ${allThumbnails.length} thumbnails`);
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 {
console.log('🔄 ThumbnailService: Resetting isGenerating flag');
this.isGenerating = false;
}
}
@ -248,14 +221,11 @@ export class ThumbnailGenerationService {
quality: number,
onProgress?: (progress: { completed: number; total: number; thumbnails: ThumbnailResult[] }) => void
): Promise<ThumbnailResult[]> {
console.log(`🔧 ThumbnailService: Fallback to main thread for ${pageNumbers.length} pages`);
// Import PDF.js dynamically for main thread
const { getDocument } = await import('pdfjs-dist');
// Load PDF once
const pdf = await getDocument({ data: pdfArrayBuffer }).promise;
console.log(`✓ ThumbnailService: PDF loaded on main thread`);
const allResults: ThumbnailResult[] = [];

View File

@ -3,7 +3,7 @@ import { Button, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import DownloadIcon from "@mui/icons-material/Download";
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileContext } from "../contexts/FileContext";
import { useFileActions } from "../contexts/FileContext";
import { useToolFileSelection } from "../contexts/FileContext";
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
@ -21,7 +21,8 @@ import { CompressTips } from "../components/tooltips/CompressTips";
const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation();
const { setCurrentMode } = useFileContext();
const { actions } = useFileActions();
const setCurrentMode = actions.setCurrentMode;
const { selectedFiles } = useToolFileSelection();
const compressParams = useCompressParameters();

View File

@ -3,7 +3,7 @@ import { Button, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import DownloadIcon from "@mui/icons-material/Download";
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileContext } from "../contexts/FileContext";
import { useFileActions, useFileState } from "../contexts/FileContext";
import { useToolFileSelection } from "../contexts/FileContext";
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
@ -20,7 +20,10 @@ import { BaseToolProps } from "../types/tool";
const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation();
const { setCurrentMode, activeFiles } = useFileContext();
const { actions } = useFileActions();
const { selectors } = useFileState();
const setCurrentMode = actions.setCurrentMode;
const activeFiles = selectors.getFiles();
const { selectedFiles } = useToolFileSelection();
const scrollContainerRef = useRef<HTMLDivElement>(null);

View File

@ -3,7 +3,7 @@ import { Button, Stack, Text, Box } from "@mantine/core";
import { useTranslation } from "react-i18next";
import DownloadIcon from "@mui/icons-material/Download";
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileContext } from "../contexts/FileContext";
import { useFileActions } from "../contexts/FileContext";
import { useToolFileSelection } from "../contexts/FileContext";
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
@ -22,7 +22,8 @@ import { OcrTips } from "../components/tooltips/OCRTips";
const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation();
const { setCurrentMode } = useFileContext();
const { actions } = useFileActions();
const setCurrentMode = actions.setCurrentMode;
const { selectedFiles } = useToolFileSelection();
const ocrParams = useOCRParameters();

View File

@ -3,7 +3,7 @@ import { Button, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import DownloadIcon from "@mui/icons-material/Download";
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileContext } from "../contexts/FileContext";
import { useFileActions } from "../contexts/FileContext";
import { useToolFileSelection } from "../contexts/FileContext";
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
@ -19,7 +19,8 @@ import { BaseToolProps } from "../types/tool";
const Split = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation();
const { setCurrentMode } = useFileContext();
const { actions } = useFileActions();
const setCurrentMode = actions.setCurrentMode;
const { selectedFiles } = useToolFileSelection();
const splitParams = useSplitParameters();
@ -33,7 +34,7 @@ const Split = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
useEffect(() => {
splitOperation.resetResults();
onPreviewFile?.(null);
}, [splitParams.parameters, selectedFiles]);
}, [splitParams.parameters, selectedFiles]); // Keep dependencies minimal - functions should be stable
const handleSplit = async () => {
try {

View File

@ -5,22 +5,28 @@
import { ProcessedFile } from './processing';
import { PDFDocument, PDFPage, PageOperation } from './pageEditor';
export type ModeType = 'viewer' | 'pageEditor' | 'fileEditor' | 'merge' | 'split' | 'compress' | 'ocr';
export type ModeType = 'viewer' | 'pageEditor' | 'fileEditor' | 'merge' | 'split' | 'compress' | 'ocr' | 'convert';
// Normalized state types
export type FileId = string;
export interface FileRecord {
id: FileId;
file: File;
name: string;
size: number;
type: string;
lastModified: number;
thumbnailUrl?: string;
blobUrl?: string;
processedFile?: ProcessedFile;
createdAt: number;
processedFile?: {
pages: Array<{
thumbnail?: string;
[key: string]: any;
}>;
[key: string]: any;
};
// Note: File object stored in provider ref, not in state
}
export interface FileContextNormalizedFiles {
@ -38,7 +44,6 @@ export function toFileRecord(file: File, id?: FileId): FileRecord {
const fileId = id || createStableFileId(file);
return {
id: fileId,
file,
name: file.name,
size: file.size,
type: file.type,
@ -105,47 +110,23 @@ export interface FileEditHistory {
}
export interface FileContextState {
// Core file management - normalized state
files: FileContextNormalizedFiles;
// Core file management - lightweight file IDs only
files: {
ids: FileId[];
byId: Record<FileId, FileRecord>;
};
// UI state grouped for performance
// UI state - flat structure for performance
ui: {
// Current navigation state
currentMode: ModeType;
// UI state that persists across views
selectedFileIds: string[];
selectedFileIds: FileId[];
selectedPageNumbers: number[];
viewerConfig: ViewerConfig;
// Tool selection state (replaces FileSelectionContext)
toolMode: boolean;
maxFiles: number; // 1=single, >1=limited, -1=unlimited
currentTool?: string;
// Processing state
isProcessing: boolean;
processingProgress: number;
// Export state
lastExportConfig?: {
filename: string;
selectedOnly: boolean;
splitDocuments: boolean;
};
// Navigation guard system
hasUnsavedChanges: boolean;
pendingNavigation: (() => void) | null;
showNavigationWarning: boolean;
};
// Edit history and state (less frequently accessed)
history: {
fileEditHistory: Map<string, FileEditHistory>;
globalFileOperations: FileOperation[];
fileOperationHistory: Map<string, FileOperationHistory>;
};
}
// Action types for reducer pattern
@ -154,94 +135,68 @@ export type FileContextAction =
| { type: 'ADD_FILES'; payload: { files: File[] } }
| { type: 'REMOVE_FILES'; payload: { fileIds: FileId[] } }
| { type: 'UPDATE_FILE_RECORD'; payload: { id: FileId; updates: Partial<FileRecord> } }
| { type: 'CLEAR_ALL_FILES' }
// UI actions
| { type: 'SET_MODE'; payload: { mode: ModeType } }
| { type: 'SET_SELECTED_FILES'; payload: { fileIds: string[] } }
| { type: 'SET_CURRENT_MODE'; payload: ModeType }
| { type: 'SET_SELECTED_FILES'; payload: { fileIds: FileId[] } }
| { type: 'SET_SELECTED_PAGES'; payload: { pageNumbers: number[] } }
| { type: 'CLEAR_SELECTIONS' }
| { type: 'SET_PROCESSING'; payload: { isProcessing: boolean; progress: number } }
| { type: 'UPDATE_VIEWER_CONFIG'; payload: { config: Partial<ViewerConfig> } }
| { type: 'SET_EXPORT_CONFIG'; payload: { config: FileContextState['ui']['lastExportConfig'] } }
// Tool selection actions (replaces FileSelectionContext)
| { type: 'SET_TOOL_MODE'; payload: { toolMode: boolean } }
| { type: 'SET_MAX_FILES'; payload: { maxFiles: number } }
| { type: 'SET_CURRENT_TOOL'; payload: { currentTool?: string } }
// Navigation guard actions
| { type: 'SET_UNSAVED_CHANGES'; payload: { hasChanges: boolean } }
| { type: 'SET_PENDING_NAVIGATION'; payload: { navigationFn: (() => void) | null } }
| { type: 'SHOW_NAVIGATION_WARNING'; payload: { show: boolean } }
| { type: 'CONFIRM_NAVIGATION' }
| { type: 'CANCEL_NAVIGATION' }
// History actions
| { type: 'ADD_PAGE_OPERATIONS'; payload: { fileId: string; operations: PageOperation[] } }
| { type: 'ADD_FILE_OPERATION'; payload: { operation: FileOperation } }
| { type: 'RECORD_OPERATION'; payload: { fileId: string; operation: FileOperation | PageOperation } }
| { type: 'MARK_OPERATION_APPLIED'; payload: { fileId: string; operationId: string } }
| { type: 'MARK_OPERATION_FAILED'; payload: { fileId: string; operationId: string; error: string } }
| { type: 'CLEAR_FILE_HISTORY'; payload: { fileId: string } }
// Context management
| { type: 'RESET_CONTEXT' }
| { type: 'LOAD_STATE'; payload: { state: Partial<FileContextState> } };
| { type: 'RESET_CONTEXT' };
export interface FileContextActions {
// File management
// File management - lightweight actions only
addFiles: (files: File[]) => Promise<File[]>;
addFiles: (files: File[]) => Promise<File[]>;
removeFiles: (fileIds: string[], deleteFromStorage?: boolean) => void;
replaceFile: (oldFileId: string, newFile: File) => Promise<void>;
removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => void;
clearAllFiles: () => void;
// Navigation
setMode: (mode: ModeType) => void;
setCurrentMode: (mode: ModeType) => void;
// Selection management
setSelectedFiles: (fileIds: string[]) => void;
setSelectedFiles: (fileIds: FileId[]) => void;
setSelectedPages: (pageNumbers: number[]) => void;
clearSelections: () => void;
// Tool selection management (replaces FileSelectionContext)
setToolMode: (toolMode: boolean) => void;
setMaxFiles: (maxFiles: number) => void;
setCurrentTool: (currentTool?: string) => void;
// Processing state
setProcessing: (isProcessing: boolean, progress: number) => void;
// Viewer state
updateViewerConfig: (config: Partial<FileContextState['ui']['viewerConfig']>) => void;
// Export configuration
setExportConfig: (config: FileContextState['ui']['lastExportConfig']) => void;
// Processing state - simple flags only
setProcessing: (isProcessing: boolean, progress?: number) => void;
// Navigation guard system
setHasUnsavedChanges: (hasChanges: boolean) => void;
requestNavigation: (navigationFn: () => void) => boolean;
confirmNavigation: () => void;
cancelNavigation: () => void;
// Context management
resetContext: () => void;
// Legacy compatibility
setMode: (mode: ModeType) => void;
confirmNavigation: () => void;
cancelNavigation: () => void;
}
// Legacy compatibility interface - includes legacy properties expected by existing components
export interface FileContextValue extends FileContextState, FileContextActions {
// Legacy properties for backward compatibility
activeFiles?: File[];
selectedFileIds?: string[];
isProcessing?: boolean;
processedFiles?: Map<File, any>;
setCurrentView?: (mode: ModeType) => void;
setCurrentMode?: (mode: ModeType) => void;
recordOperation?: (fileId: string, operation: FileOperation) => void;
markOperationApplied?: (fileId: string, operationId: string) => void;
getFileHistory?: (fileId: string) => FileOperationHistory | undefined;
getAppliedOperations?: (fileId: string) => (FileOperation | PageOperation)[];
// File selectors (separate from actions to avoid re-renders)
export interface FileContextSelectors {
// File access - no state dependency, uses ref
getFile: (id: FileId) => File | undefined;
getFiles: (ids?: FileId[]) => File[];
// Record access - uses normalized state
getFileRecord: (id: FileId) => FileRecord | undefined;
getFileRecords: (ids?: FileId[]) => FileRecord[];
// Derived selectors
getAllFileIds: () => FileId[];
getSelectedFiles: () => File[];
getSelectedFileRecords: () => FileRecord[];
// Stable signature for effect dependencies
getFilesSignature: () => string;
}
export interface FileContextProviderProps {
@ -251,40 +206,7 @@ export interface FileContextProviderProps {
maxCacheSize?: number;
}
// Helper types for component props
export interface WithFileContext {
fileContext: FileContextValue;
}
// Selector types for split context pattern
export interface FileContextSelectors {
// File selectors
getFileById: (id: FileId) => FileRecord | undefined;
getFilesByIds: (ids: FileId[]) => FileRecord[];
getAllFiles: () => FileRecord[];
getSelectedFiles: () => FileRecord[];
// Convenience file helpers
getFile: (id: FileId) => File | undefined;
getFiles: (ids?: FileId[]) => File[];
// UI selectors
getCurrentMode: () => ModeType;
getSelectedFileIds: () => string[];
getSelectedPageNumbers: () => number[];
getViewerConfig: () => ViewerConfig;
getProcessingState: () => { isProcessing: boolean; progress: number };
// Navigation guard selectors
getHasUnsavedChanges: () => boolean;
getShowNavigationWarning: () => boolean;
// History selectors (legacy - moved to selectors from actions)
getFileHistory: (fileId: string) => FileOperationHistory | undefined;
getAppliedOperations: (fileId: string) => (FileOperation | PageOperation)[];
}
// Split context value types
// Split context values to minimize re-renders
export interface FileContextStateValue {
state: FileContextState;
selectors: FileContextSelectors;