mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-26 14:19:24 +00:00
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:
parent
ffecaa9e1c
commit
f353d3404c
@ -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": []
|
||||
}
|
||||
|
9
frontend/package-lock.json
generated
9
frontend/package-lock.json
generated
@ -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"
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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' }
|
||||
});
|
||||
}
|
||||
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({
|
||||
|
@ -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,
|
||||
@ -128,6 +136,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
|
||||
.filter(file => {
|
||||
@ -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);
|
||||
});
|
||||
|
@ -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();
|
||||
|
@ -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 split contexts to prevent re-renders
|
||||
const { state, selectors } = useFileState();
|
||||
const { actions } = useFileActions();
|
||||
|
||||
// Use file context state
|
||||
const {
|
||||
activeFiles,
|
||||
processedFiles,
|
||||
selectedPageNumbers,
|
||||
setSelectedPages,
|
||||
updateProcessedFile,
|
||||
setHasUnsavedChanges,
|
||||
hasUnsavedChanges,
|
||||
isProcessing: globalProcessing,
|
||||
processingProgress,
|
||||
clearAllFiles
|
||||
} = fileContext;
|
||||
const processedFiles = useProcessedFiles();
|
||||
// Prefer IDs + selectors to avoid array identity churn
|
||||
const activeFileIds = state.files.ids;
|
||||
const primaryFileId = activeFileIds[0] ?? null;
|
||||
const selectedFiles = selectors.getSelectedFiles();
|
||||
|
||||
// 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]
|
||||
);
|
||||
// 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;
|
||||
const primaryFileRecord = primaryFileId ? selectors.getFileRecord(primaryFileId) : null;
|
||||
const primaryFile = primaryFileId ? selectors.getFile(primaryFileId) : 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) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
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: `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
|
||||
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, state.files.ids, state.files.byId]); // Stable dependency
|
||||
}, [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);
|
||||
// Access current values directly - avoid stale closures
|
||||
const currentDocument = mergedPdfDocument;
|
||||
const currentActiveFileIds = activeFileIds;
|
||||
const currentPrimaryFileId = primaryFileId;
|
||||
|
||||
if (!mergedPdfDocument || activeFiles.length !== 1 || thumbnailGenerationStarted.current) {
|
||||
console.log('🎬 PageEditor: Skipping thumbnail generation due to conditions');
|
||||
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)
|
||||
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 () => {
|
||||
try {
|
||||
const draftKey = `draft-${mergedPdfDocument?.id || 'merged'}`;
|
||||
|
||||
try {
|
||||
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 (activeFileIds.length === 1 && primaryFileId) {
|
||||
const file = selectors.getFile(primaryFileId);
|
||||
if (!file) return;
|
||||
|
||||
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) {
|
||||
// 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(() => {
|
||||
// Clear edit state immediately
|
||||
setEditedDocument(null);
|
||||
actions.setHasUnsavedChanges(false);
|
||||
setHasUnsavedDraft(false);
|
||||
cleanupDraft();
|
||||
setStatus('Changes applied successfully');
|
||||
}, 100);
|
||||
|
||||
} 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
@ -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]);
|
||||
|
@ -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) => {
|
||||
|
@ -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
@ -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();
|
||||
|
@ -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 => {
|
||||
setBlobUrls(prev => {
|
||||
prev.forEach(url => {
|
||||
try {
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.warn('Failed to revoke blob URL:', error);
|
||||
}
|
||||
});
|
||||
setBlobUrls([]);
|
||||
return [];
|
||||
});
|
||||
}, []); // No dependencies - use functional update pattern
|
||||
|
||||
// Cleanup on unmount - use ref to avoid dependency on blobUrls state
|
||||
const blobUrlsRef = useRef<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
blobUrlsRef.current = blobUrls;
|
||||
}, [blobUrls]);
|
||||
|
||||
// Cleanup on unmount
|
||||
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[] = [];
|
||||
|
@ -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[] = [];
|
||||
|
@ -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();
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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();
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
||||
|
||||
// UI state grouped for performance
|
||||
ui: {
|
||||
// Current navigation state
|
||||
currentMode: ModeType;
|
||||
|
||||
// UI state that persists across views
|
||||
selectedFileIds: string[];
|
||||
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;
|
||||
// Core file management - lightweight file IDs only
|
||||
files: {
|
||||
ids: FileId[];
|
||||
byId: Record<FileId, FileRecord>;
|
||||
};
|
||||
|
||||
// Navigation guard system
|
||||
// UI state - flat structure for performance
|
||||
ui: {
|
||||
currentMode: ModeType;
|
||||
selectedFileIds: FileId[];
|
||||
selectedPageNumbers: number[];
|
||||
isProcessing: boolean;
|
||||
processingProgress: number;
|
||||
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;
|
||||
|
Loading…
x
Reference in New Issue
Block a user