Refactor file management context and remove FileSelectionContext- Updated FileManagerContext to use useMemo for context value optimization.- Removed FileSelectionContext and integrated its functionality into FileContext.- Updated hooks and components to use new file context structure.- Refactored file handling logic to utilize stable file IDs for deduplication.- Adjusted UI state management for improved performance and clarity.- Updated theme variables for consistency in hover and selection states.- Ensured backward compatibility with legacy properties in FileContext.

This commit is contained in:
Reece Browne 2025-08-10 21:08:32 +01:00
parent 507ad1dc61
commit 02f4f7abaf
26 changed files with 1597 additions and 1375 deletions

View File

@ -9,7 +9,8 @@
"Bash(find:*)", "Bash(find:*)",
"Bash(npm test)", "Bash(npm test)",
"Bash(npm test:*)", "Bash(npm test:*)",
"Bash(ls:*)" "Bash(ls:*)",
"Bash(npm run dev:*)"
], ],
"deny": [] "deny": []
} }

View File

@ -391,6 +391,9 @@
"title": "Compress", "title": "Compress",
"desc": "Compress PDFs to reduce their file size." "desc": "Compress PDFs to reduce their file size."
}, },
"compress": {
"title": "Compress"
},
"unlockPDFForms": { "unlockPDFForms": {
"title": "Unlock PDF Forms", "title": "Unlock PDF Forms",
"desc": "Remove read-only property of form fields in a PDF document." "desc": "Remove read-only property of form fields in a PDF document."
@ -1711,7 +1714,9 @@
"uploadFiles": "Upload Files", "uploadFiles": "Upload Files",
"noFilesInStorage": "No files available in storage. Upload some files first.", "noFilesInStorage": "No files available in storage. Upload some files first.",
"selectFromStorage": "Select from Storage", "selectFromStorage": "Select from Storage",
"backToTools": "Back to Tools" "backToTools": "Back to Tools",
"addFiles": "Add Files",
"dragFilesInOrClick": "Drag files in or click \"Add Files\" to browse"
}, },
"fileManager": { "fileManager": {
"title": "Upload PDF Files", "title": "Upload PDF Files",

View File

@ -48,7 +48,11 @@ export class RotatePagesCommand extends PageCommand {
return page; return page;
}); });
this.setPdfDocument({ ...this.pdfDocument, pages: updatedPages }); this.setPdfDocument({
...this.pdfDocument,
pages: updatedPages,
totalPages: updatedPages.length
});
} }
get description(): string { get description(): string {
@ -148,7 +152,11 @@ export class MovePagesCommand extends PageCommand {
pageNumber: index + 1 pageNumber: index + 1
})); }));
this.setPdfDocument({ ...this.pdfDocument, pages: newPages }); this.setPdfDocument({
...this.pdfDocument,
pages: newPages,
totalPages: newPages.length
});
} }
get description(): string { get description(): string {
@ -185,7 +193,11 @@ export class ReorderPageCommand extends PageCommand {
pageNumber: index + 1 pageNumber: index + 1
})); }));
this.setPdfDocument({ ...this.pdfDocument, pages: updatedPages }); this.setPdfDocument({
...this.pdfDocument,
pages: updatedPages,
totalPages: updatedPages.length
});
} }
get description(): string { get description(): string {
@ -224,7 +236,11 @@ export class ToggleSplitCommand extends PageCommand {
return page; return page;
}); });
this.setPdfDocument({ ...this.pdfDocument, pages: updatedPages }); this.setPdfDocument({
...this.pdfDocument,
pages: updatedPages,
totalPages: updatedPages.length
});
} }
undo(): void { undo(): void {
@ -236,7 +252,11 @@ export class ToggleSplitCommand extends PageCommand {
return page; return page;
}); });
this.setPdfDocument({ ...this.pdfDocument, pages: updatedPages }); this.setPdfDocument({
...this.pdfDocument,
pages: updatedPages,
totalPages: updatedPages.length
});
} }
get description(): string { get description(): string {

View File

@ -6,9 +6,8 @@ import {
import { Dropzone } from '@mantine/dropzone'; import { Dropzone } from '@mantine/dropzone';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import UploadFileIcon from '@mui/icons-material/UploadFile'; import UploadFileIcon from '@mui/icons-material/UploadFile';
import { useFileContext } from '../../contexts/FileContext'; import { useFileContext, useToolFileSelection, useProcessedFiles, useFileState, useFileManagement } from '../../contexts/FileContext';
import { useFileSelection } from '../../contexts/FileSelectionContext'; import { FileOperation, createStableFileId } from '../../types/fileContext';
import { FileOperation } from '../../types/fileContext';
import { fileStorage } from '../../services/fileStorage'; import { fileStorage } from '../../services/fileStorage';
import { generateThumbnailForFile } from '../../utils/thumbnailUtils'; import { generateThumbnailForFile } from '../../utils/thumbnailUtils';
import { zipFileService } from '../../services/zipFileService'; import { zipFileService } from '../../services/zipFileService';
@ -54,28 +53,34 @@ const FileEditor = ({
return extension ? supportedExtensions.includes(extension) : false; return extension ? supportedExtensions.includes(extension) : false;
}, [supportedExtensions]); }, [supportedExtensions]);
// Get file context // Use optimized FileContext hooks
const fileContext = useFileContext(); const { state } = useFileState();
const { const { addFiles, removeFiles } = useFileManagement();
activeFiles, const processedFiles = useProcessedFiles(); // Now gets real processed files
processedFiles,
selectedFileIds,
setSelectedFiles: setContextSelectedFiles,
isProcessing,
addFiles,
removeFiles,
setCurrentView,
recordOperation,
markOperationApplied
} = fileContext;
// Get file selection context // Extract needed values from state
const activeFiles = state.files.ids.map(id => state.files.byId[id]?.file).filter(Boolean);
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);
};
const setCurrentView = (mode: any) => {
// Will be handled by parent component actions
console.log('FileEditor setCurrentView called with:', mode);
};
// Get tool file selection context (replaces FileSelectionContext)
const { const {
selectedFiles: toolSelectedFiles, selectedFiles: toolSelectedFiles,
setSelectedFiles: setToolSelectedFiles, setSelectedFiles: setToolSelectedFiles,
maxFiles, maxFiles,
isToolMode isToolMode
} = useFileSelection(); } = useToolFileSelection();
const [files, setFiles] = useState<FileItem[]>([]); const [files, setFiles] = useState<FileItem[]>([]);
const [status, setStatus] = useState<string | null>(null); const [status, setStatus] = useState<string | null>(null);
@ -119,8 +124,8 @@ const FileEditor = ({
// Map context selections to local file IDs for UI display // Map context selections to local file IDs for UI display
const localSelectedIds = files const localSelectedIds = files
.filter(file => { .filter(file => {
const fileId = (file.file as any).id || file.name; const contextFileId = createStableFileId(file.file);
return contextSelectedIds.includes(fileId); return contextSelectedIds.includes(contextFileId);
}) })
.map(file => file.id); .map(file => file.id);
@ -132,7 +137,7 @@ const FileEditor = ({
return { return {
id: sharedFile.id || `file-${Date.now()}-${Math.random()}`, id: sharedFile.id || `file-${Date.now()}-${Math.random()}`,
name: (sharedFile.file?.name || sharedFile.name || 'unknown'), name: (sharedFile.file?.name || sharedFile.name || 'unknown'),
pageCount: sharedFile.pageCount || Math.floor(Math.random() * 20) + 1, // Mock for now pageCount: sharedFile.pageCount || 1, // Default to 1 page if unknown
thumbnail, thumbnail,
size: sharedFile.file?.size || sharedFile.size || 0, size: sharedFile.file?.size || sharedFile.size || 0,
file: sharedFile.file || sharedFile, file: sharedFile.file || sharedFile,
@ -181,10 +186,30 @@ const FileEditor = ({
} }
} }
// Get actual page count from processed file
let pageCount = 1; // Default for non-PDFs
if (processedFile) {
pageCount = processedFile.pages?.length || processedFile.totalPages || 1;
} else if (file.type === 'application/pdf') {
// For PDFs without processed data, try to get a quick page count estimate
// If processing is taking too long, show a reasonable default
try {
// Quick and dirty page count using PDF structure analysis
const arrayBuffer = await file.arrayBuffer();
const text = new TextDecoder('latin1').decode(arrayBuffer);
const pageMatches = text.match(/\/Type\s*\/Page[^s]/g);
pageCount = pageMatches ? pageMatches.length : 1;
console.log(`📄 Quick page count for ${file.name}: ${pageCount} pages (estimated)`);
} catch (error) {
console.warn(`Failed to estimate page count for ${file.name}:`, error);
pageCount = 1; // Safe fallback
}
}
const convertedFile = { const convertedFile = {
id: `file-${Date.now()}-${Math.random()}`, id: createStableFileId(file), // Use same ID function as context
name: file.name, name: file.name,
pageCount: processedFile?.totalPages || Math.floor(Math.random() * 20) + 1, pageCount: pageCount,
thumbnail, thumbnail,
size: file.size, size: file.size,
file, file,
@ -290,8 +315,7 @@ const FileEditor = ({
} }
}; };
recordOperation(file.name, operation); // Legacy operation tracking removed
markOperationApplied(file.name, operationId);
if (extractionResult.errors.length > 0) { if (extractionResult.errors.length > 0) {
errors.push(...extractionResult.errors); errors.push(...extractionResult.errors);
@ -345,8 +369,7 @@ const FileEditor = ({
} }
}; };
recordOperation(file.name, operation); // Legacy operation tracking removed
markOperationApplied(file.name, operationId);
} }
// Add files to context (they will be processed automatically) // Add files to context (they will be processed automatically)
@ -367,7 +390,7 @@ const FileEditor = ({
totalFiles: 0 totalFiles: 0
}); });
} }
}, [addFiles, recordOperation, markOperationApplied]); }, [addFiles]);
const selectAll = useCallback(() => { const selectAll = useCallback(() => {
setContextSelectedFiles(files.map(f => (f.file as any).id || f.name)); setContextSelectedFiles(files.map(f => (f.file as any).id || f.name));
@ -397,8 +420,7 @@ const FileEditor = ({
} }
}; };
recordOperation(file.name, operation); // Legacy operation tracking removed
markOperationApplied(file.name, operationId);
}); });
// Remove all files from context but keep in storage // Remove all files from context but keep in storage
@ -406,13 +428,13 @@ const FileEditor = ({
// Clear selections // Clear selections
setContextSelectedFiles([]); setContextSelectedFiles([]);
}, [activeFiles, removeFiles, setContextSelectedFiles, recordOperation, markOperationApplied]); }, [activeFiles, removeFiles, setContextSelectedFiles]);
const toggleFile = useCallback((fileId: string) => { const toggleFile = useCallback((fileId: string) => {
const targetFile = files.find(f => f.id === fileId); const targetFile = files.find(f => f.id === fileId);
if (!targetFile) return; if (!targetFile) return;
const contextFileId = (targetFile.file as any).id || targetFile.name; const contextFileId = createStableFileId(targetFile.file);
const isSelected = contextSelectedIds.includes(contextFileId); const isSelected = contextSelectedIds.includes(contextFileId);
let newSelection: string[]; let newSelection: string[];
@ -441,7 +463,7 @@ const FileEditor = ({
if (isToolMode || toolMode) { if (isToolMode || toolMode) {
const selectedFiles = files const selectedFiles = files
.filter(f => { .filter(f => {
const fId = (f.file as any).id || f.name; const fId = createStableFileId(f.file);
return newSelection.includes(fId); return newSelection.includes(fId);
}) })
.map(f => f.file); .map(f => f.file);
@ -598,7 +620,7 @@ const FileEditor = ({
} }
}; };
recordOperation(fileName, operation); // Legacy operation tracking removed
// Remove file from context but keep in storage (close, don't delete) // Remove file from context but keep in storage (close, don't delete)
console.log('Calling removeFiles with:', [fileId]); console.log('Calling removeFiles with:', [fileId]);
@ -609,24 +631,20 @@ const FileEditor = ({
const safePrev = Array.isArray(prev) ? prev : []; const safePrev = Array.isArray(prev) ? prev : [];
return safePrev.filter(id => id !== fileId); return safePrev.filter(id => id !== fileId);
}); });
// Mark operation as applied
markOperationApplied(fileName, operationId);
} else { } else {
console.log('File not found for fileId:', fileId); console.log('File not found for fileId:', fileId);
} }
}, [files, removeFiles, setContextSelectedFiles, recordOperation, markOperationApplied]); }, [files, removeFiles, setContextSelectedFiles]);
const handleViewFile = useCallback((fileId: string) => { const handleViewFile = useCallback((fileId: string) => {
const file = files.find(f => f.id === fileId); const file = files.find(f => f.id === fileId);
if (file) { if (file) {
// Set the file as selected in context and switch to page editor view // Set the file as selected in context and switch to viewer for preview
const contextFileId = (file.file as any).id || file.name; const contextFileId = createStableFileId(file.file);
setContextSelectedFiles([contextFileId]); setContextSelectedFiles([contextFileId]);
setCurrentView('pageEditor'); setCurrentView('viewer');
onOpenPageEditor?.(file.file);
} }
}, [files, setContextSelectedFiles, setCurrentView, onOpenPageEditor]); }, [files, setContextSelectedFiles, setCurrentView]);
const handleMergeFromHere = useCallback((fileId: string) => { const handleMergeFromHere = useCallback((fileId: string) => {
const startIndex = files.findIndex(f => f.id === fileId); const startIndex = files.findIndex(f => f.id === fileId);

View File

@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider'; import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
import { useWorkbenchState, useToolSelection } from '../../contexts/ToolWorkflowContext'; import { useWorkbenchState, useToolSelection } from '../../contexts/ToolWorkflowContext';
import { useFileHandler } from '../../hooks/useFileHandler'; import { useFileHandler } from '../../hooks/useFileHandler';
import { useFileContext } from '../../contexts/FileContext'; import { useFileState, useFileActions } from '../../contexts/FileContext';
import TopControls from '../shared/TopControls'; import TopControls from '../shared/TopControls';
import FileEditor from '../fileEditor/FileEditor'; import FileEditor from '../fileEditor/FileEditor';
@ -20,7 +20,10 @@ export default function Workbench() {
const { isRainbowMode } = useRainbowThemeContext(); const { isRainbowMode } = useRainbowThemeContext();
// Use context-based hooks to eliminate all prop drilling // Use context-based hooks to eliminate all prop drilling
const { activeFiles, currentView, setCurrentView } = useFileContext(); const { state } = useFileState();
const { actions } = useFileActions();
const activeFiles = state.files.ids;
const currentView = state.ui.currentMode;
const { const {
previewFile, previewFile,
pageEditorFunctions, pageEditorFunctions,
@ -47,12 +50,12 @@ export default function Workbench() {
handleToolSelect('convert'); handleToolSelect('convert');
sessionStorage.removeItem('previousMode'); sessionStorage.removeItem('previousMode');
} else { } else {
setCurrentView('fileEditor' as any); actions.setMode('fileEditor');
} }
}; };
const renderMainContent = () => { const renderMainContent = () => {
if (!activeFiles[0]) { if (activeFiles.length === 0) {
return ( return (
<LandingPage <LandingPage
/> />
@ -69,11 +72,11 @@ export default function Workbench() {
supportedExtensions={selectedTool?.supportedFormats || ["pdf"]} supportedExtensions={selectedTool?.supportedFormats || ["pdf"]}
{...(!selectedToolKey && { {...(!selectedToolKey && {
onOpenPageEditor: (file) => { onOpenPageEditor: (file) => {
setCurrentView("pageEditor" as any); actions.setMode("pageEditor");
}, },
onMergeFiles: (filesToMerge) => { onMergeFiles: (filesToMerge) => {
filesToMerge.forEach(addToActiveFiles); filesToMerge.forEach(addToActiveFiles);
setCurrentView("viewer" as any); actions.setMode("viewer");
} }
})} })}
/> />
@ -142,7 +145,7 @@ export default function Workbench() {
{/* Top Controls */} {/* Top Controls */}
<TopControls <TopControls
currentView={currentView} currentView={currentView}
setCurrentView={setCurrentView} setCurrentView={actions.setMode}
selectedToolKey={selectedToolKey} selectedToolKey={selectedToolKey}
/> />

View File

@ -1,12 +1,11 @@
import React, { useState } from 'react'; import React, { useState, useCallback } from 'react';
import { Text, Checkbox, Tooltip, ActionIcon, Badge, Modal } from '@mantine/core'; import { Text, Checkbox, Tooltip, ActionIcon, Badge } from '@mantine/core';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from '@mui/icons-material/Close';
import VisibilityIcon from '@mui/icons-material/Visibility'; import VisibilityIcon from '@mui/icons-material/Visibility';
import HistoryIcon from '@mui/icons-material/History'; import PreviewIcon from '@mui/icons-material/Preview';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import styles from './PageEditor.module.css'; import styles from './PageEditor.module.css';
import FileOperationHistory from '../history/FileOperationHistory';
interface FileItem { interface FileItem {
id: string; id: string;
@ -65,7 +64,6 @@ const FileThumbnail = ({
isSupported = true, isSupported = true,
}: FileThumbnailProps) => { }: FileThumbnailProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [showHistory, setShowHistory] = useState(false);
const formatFileSize = (bytes: number) => { const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 B'; if (bytes === 0) return '0 B';
@ -75,15 +73,18 @@ const FileThumbnail = ({
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}; };
// Memoize ref callback to prevent infinite loop
const refCallback = useCallback((el: HTMLDivElement | null) => {
if (el) {
fileRefs.current.set(file.id, el);
} else {
fileRefs.current.delete(file.id);
}
}, [file.id, fileRefs]);
return ( return (
<div <div
ref={(el) => { ref={refCallback}
if (el) {
fileRefs.current.set(file.id, el);
} else {
fileRefs.current.delete(file.id);
}
}}
data-file-id={file.id} data-file-id={file.id}
data-testid="file-thumbnail" data-testid="file-thumbnail"
className={` className={`
@ -201,7 +202,7 @@ const FileThumbnail = ({
zIndex: 3, zIndex: 3,
}} }}
> >
{file.pageCount} pages {file.pageCount} {file.pageCount === 1 ? 'page' : 'pages'}
</Badge> </Badge>
{/* Unsupported badge */} {/* Unsupported badge */}
@ -286,18 +287,18 @@ const FileThumbnail = ({
</> </>
)} )}
<Tooltip label="View History"> <Tooltip label="Preview File">
<ActionIcon <ActionIcon
size="md" size="md"
variant="subtle" variant="subtle"
c="white" c="white"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setShowHistory(true); onViewFile(file.id);
onSetStatus(`Viewing history for ${file.name}`); onSetStatus(`Opening preview for ${file.name}`);
}} }}
> >
<HistoryIcon style={{ fontSize: 20 }} /> <PreviewIcon style={{ fontSize: 20 }} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
@ -339,20 +340,6 @@ const FileThumbnail = ({
</Text> </Text>
</div> </div>
{/* History Modal */}
<Modal
opened={showHistory}
onClose={() => setShowHistory(false)}
title={`Operation History - ${file.name}`}
size="lg"
scrollAreaComponent="div"
>
<FileOperationHistory
fileId={file.name}
showOnlyApplied={true}
maxHeight={500}
/>
</Modal>
</div> </div>
); );
}; };

View File

@ -5,8 +5,8 @@ import {
Stack, Group Stack, Group
} from "@mantine/core"; } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useFileContext, useCurrentFile } from "../../contexts/FileContext"; import { useFileState, useFileActions, useCurrentFile, useProcessedFiles, useFileManagement, useFileSelection } from "../../contexts/FileContext";
import { ViewType, ToolType } from "../../types/fileContext"; import { ModeType } from "../../types/fileContext";
import { PDFDocument, PDFPage } from "../../types/pageEditor"; import { PDFDocument, PDFPage } from "../../types/pageEditor";
import { ProcessedFile as EnhancedProcessedFile } from "../../types/processing"; import { ProcessedFile as EnhancedProcessedFile } from "../../types/processing";
import { useUndoRedo } from "../../hooks/useUndoRedo"; import { useUndoRedo } from "../../hooks/useUndoRedo";
@ -53,23 +53,22 @@ const PageEditor = ({
}: PageEditorProps) => { }: PageEditorProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
// Get file context // Use optimized FileContext hooks (no infinite loops)
const fileContext = useFileContext(); const { state } = useFileState();
const { actions, dispatch } = useFileActions();
const { addFiles, clearAllFiles } = useFileManagement();
const { selectedFileIds, selectedPageNumbers, setSelectedFiles, setSelectedPages } = useFileSelection();
const { file: currentFile, processedFile: currentProcessedFile } = useCurrentFile(); const { file: currentFile, processedFile: currentProcessedFile } = useCurrentFile();
const processedFiles = useProcessedFiles();
// Use file context state // Extract needed state values (use stable memo)
const { const activeFiles = useMemo(() =>
activeFiles, state.files.ids.map(id => state.files.byId[id]?.file).filter(Boolean),
processedFiles, [state.files.ids, state.files.byId]
selectedPageNumbers, );
setSelectedPages, const globalProcessing = state.ui.isProcessing;
updateProcessedFile, const processingProgress = state.ui.processingProgress;
setHasUnsavedChanges, const hasUnsavedChanges = state.ui.hasUnsavedChanges;
hasUnsavedChanges,
isProcessing: globalProcessing,
processingProgress,
clearAllFiles
} = fileContext;
// Edit state management // Edit state management
const [editedDocument, setEditedDocument] = useState<PDFDocument | null>(null); const [editedDocument, setEditedDocument] = useState<PDFDocument | null>(null);
@ -78,25 +77,53 @@ const PageEditor = ({
const [foundDraft, setFoundDraft] = useState<any>(null); const [foundDraft, setFoundDraft] = useState<any>(null);
const autoSaveTimer = useRef<NodeJS.Timeout | null>(null); const autoSaveTimer = useRef<NodeJS.Timeout | null>(null);
// Simple computed document from processed files (no caching needed) /**
const mergedPdfDocument = useMemo(() => { * Create stable files signature to prevent infinite re-computation.
if (activeFiles.length === 0) return null; * 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]);
if (activeFiles.length === 1) { // Compute merged document with stable signature (prevents infinite loops)
const mergedPdfDocument = useMemo(() => {
const currentFiles = state.files.ids.map(id => state.files.byId[id]?.file).filter(Boolean);
if (currentFiles.length === 0) {
return null;
} else if (currentFiles.length === 1) {
// Single file // Single file
const processedFile = processedFiles.get(activeFiles[0]); const file = currentFiles[0];
if (!processedFile) return null; const record = state.files.ids
.map(id => state.files.byId[id])
.find(r => r?.file === file);
const processedFile = record?.processedFile;
if (!processedFile) {
return null;
}
const pages = processedFile.pages.map(page => ({
...page,
rotation: page.rotation || 0,
splitBefore: page.splitBefore || false
}));
return { return {
id: processedFile.id, id: processedFile.id,
name: activeFiles[0].name, name: file.name,
file: activeFiles[0], file: file,
pages: processedFile.pages.map(page => ({ pages: pages,
...page, totalPages: pages.length // Always use actual pages array length
rotation: page.rotation || 0,
splitBefore: page.splitBefore || false
})),
totalPages: processedFile.totalPages
}; };
} else { } else {
// Multiple files - merge them // Multiple files - merge them
@ -104,8 +131,12 @@ const PageEditor = ({
let totalPages = 0; let totalPages = 0;
const filenames: string[] = []; const filenames: string[] = [];
activeFiles.forEach((file, i) => { currentFiles.forEach((file, i) => {
const processedFile = processedFiles.get(file); const record = state.files.ids
.map(id => state.files.byId[id])
.find(r => r?.file === file);
const processedFile = record?.processedFile;
if (processedFile) { if (processedFile) {
filenames.push(file.name.replace(/\.pdf$/i, '')); filenames.push(file.name.replace(/\.pdf$/i, ''));
@ -124,17 +155,19 @@ const PageEditor = ({
} }
}); });
if (allPages.length === 0) return null; if (allPages.length === 0) {
return null;
}
return { return {
id: `merged-${Date.now()}`, id: `merged-${Date.now()}`,
name: filenames.join(' + '), name: filenames.join(' + '),
file: activeFiles[0], // Use first file as reference file: currentFiles[0], // Use first file as reference
pages: allPages, pages: allPages,
totalPages: totalPages totalPages: allPages.length // Always use actual pages array length
}; };
} }
}, [activeFiles, processedFiles]); }, [filesSignature, state.files.ids, state.files.byId]); // Stable dependency
// Display document: Use edited version if exists, otherwise original // Display document: Use edited version if exists, otherwise original
const displayDocument = editedDocument || mergedPdfDocument; const displayDocument = editedDocument || mergedPdfDocument;
@ -144,6 +177,7 @@ const PageEditor = ({
// Page editor state (use context for selectedPages) // Page editor state (use context for selectedPages)
const [status, setStatus] = useState<string | null>(null); const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [csvInput, setCsvInput] = useState<string>(""); const [csvInput, setCsvInput] = useState<string>("");
const [selectionMode, setSelectionMode] = useState(false); const [selectionMode, setSelectionMode] = useState(false);
@ -188,16 +222,20 @@ const PageEditor = ({
} }
// Add files to context // Add files to context
await fileContext.addFiles(uploadedFiles); await addFiles(uploadedFiles);
setStatus(`Added ${uploadedFiles.length} file(s) for processing`); setStatus(`Added ${uploadedFiles.length} file(s) for processing`);
}, [fileContext]); }, [addFiles]);
// PageEditor no longer handles cleanup - it's centralized in FileContext // PageEditor no longer handles cleanup - it's centralized in FileContext
// Shared PDF instance for thumbnail generation // PDF thumbnail generation state
const [sharedPdfInstance, setSharedPdfInstance] = useState<any>(null); const [sharedPdfInstance, setSharedPdfInstance] = useState<any>(null);
const [thumbnailGenerationStarted, setThumbnailGenerationStarted] = useState(false); /**
* Using ref instead of state prevents infinite loops.
* State changes would trigger re-renders and effect re-runs.
*/
const thumbnailGenerationStarted = useRef(false);
// Thumbnail generation (opt-in for visual tools) // Thumbnail generation (opt-in for visual tools)
const { const {
@ -208,21 +246,16 @@ const PageEditor = ({
destroyThumbnails destroyThumbnails
} = useThumbnailGeneration(); } = useThumbnailGeneration();
// Start thumbnail generation process (separate from document loading) // Start thumbnail generation process (guards against re-entry)
const startThumbnailGeneration = useCallback(() => { const startThumbnailGeneration = useCallback(() => {
console.log('🎬 PageEditor: startThumbnailGeneration called'); if (!mergedPdfDocument || activeFiles.length !== 1 || thumbnailGenerationStarted.current) {
console.log('🎬 Conditions - mergedPdfDocument:', !!mergedPdfDocument, 'activeFiles:', activeFiles.length, 'started:', thumbnailGenerationStarted);
if (!mergedPdfDocument || activeFiles.length !== 1 || thumbnailGenerationStarted) {
console.log('🎬 PageEditor: Skipping thumbnail generation due to conditions');
return; return;
} }
const file = activeFiles[0]; const file = activeFiles[0];
const totalPages = mergedPdfDocument.totalPages; const totalPages = mergedPdfDocument.pages.length;
console.log('🎬 PageEditor: Starting thumbnail generation for', totalPages, 'pages'); thumbnailGenerationStarted.current = true;
setThumbnailGenerationStarted(true);
// Run everything asynchronously to avoid blocking the main thread // Run everything asynchronously to avoid blocking the main thread
setTimeout(async () => { setTimeout(async () => {
@ -237,11 +270,8 @@ const PageEditor = ({
return !page?.thumbnail; // Only generate for pages without thumbnails 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 no pages need thumbnails, we're done
if (pageNumbers.length === 0) { if (pageNumbers.length === 0) {
console.log('🎬 PageEditor: All pages already have thumbnails, no generation needed');
return; return;
} }
@ -258,78 +288,59 @@ const PageEditor = ({
batchSize: 15, // Smaller batches per worker for smoother UI batchSize: 15, // Smaller batches per worker for smoother UI
parallelBatches: 3 // Use 3 Web Workers in parallel parallelBatches: 3 // Use 3 Web Workers in parallel
}, },
// Progress callback (throttled for better performance) // Progress callback for thumbnail updates
(progress) => { (progress) => {
console.log(`🎬 PageEditor: Progress - ${progress.completed}/${progress.total} pages, ${progress.thumbnails.length} new thumbnails`);
// Batch process thumbnails to reduce main thread work // Batch process thumbnails to reduce main thread work
requestAnimationFrame(() => { requestAnimationFrame(() => {
progress.thumbnails.forEach(({ pageNumber, thumbnail }) => { progress.thumbnails.forEach(({ pageNumber, thumbnail }) => {
// Check cache first, then send thumbnail
const pageId = `${file.name}-page-${pageNumber}`; const pageId = `${file.name}-page-${pageNumber}`;
const cached = getThumbnailFromCache(pageId); const cached = getThumbnailFromCache(pageId);
if (!cached) { if (!cached) {
// Cache and send to component
addThumbnailToCache(pageId, thumbnail); addThumbnailToCache(pageId, thumbnail);
window.dispatchEvent(new CustomEvent('thumbnailReady', { window.dispatchEvent(new CustomEvent('thumbnailReady', {
detail: { pageNumber, thumbnail, pageId } detail: { pageNumber, thumbnail, pageId }
})); }));
console.log(`✓ PageEditor: Dispatched thumbnail for page ${pageNumber}`);
} }
}); });
}); });
} }
); );
// Handle completion properly // Handle completion
generationPromise generationPromise
.then((allThumbnails) => { .then(() => {
console.log(`✅ PageEditor: Thumbnail generation completed! Generated ${allThumbnails.length} thumbnails`); // Keep thumbnailGenerationStarted as true to prevent restarts
// Don't reset thumbnailGenerationStarted here - let it stay true to prevent restarts
}) })
.catch(error => { .catch(error => {
console.error('✗ PageEditor: Web Worker thumbnail generation failed:', error); console.error('PageEditor: Thumbnail generation failed:', error);
setThumbnailGenerationStarted(false); thumbnailGenerationStarted.current = false;
}); });
} catch (error) { } catch (error) {
console.error('Failed to start Web Worker thumbnail generation:', error); console.error('Failed to start Web Worker thumbnail generation:', error);
setThumbnailGenerationStarted(false); thumbnailGenerationStarted.current = false;
} }
}, 0); // setTimeout with 0ms to defer to next tick }, 0); // setTimeout with 0ms to defer to next tick
}, [mergedPdfDocument, activeFiles, thumbnailGenerationStarted, getThumbnailFromCache, addThumbnailToCache]); }, [mergedPdfDocument, activeFiles, getThumbnailFromCache, addThumbnailToCache]);
// Start thumbnail generation after document loads // Start thumbnail generation when files change (stable signature prevents loops)
useEffect(() => { useEffect(() => {
console.log('🎬 PageEditor: Thumbnail generation effect triggered'); if (mergedPdfDocument && !thumbnailGenerationStarted.current) {
console.log('🎬 Conditions - mergedPdfDocument:', !!mergedPdfDocument, 'started:', thumbnailGenerationStarted); // Check if ALL pages already have thumbnails
if (mergedPdfDocument && !thumbnailGenerationStarted) {
// Check if ALL pages already have thumbnails from processed files
const totalPages = mergedPdfDocument.pages.length; const totalPages = mergedPdfDocument.pages.length;
const pagesWithThumbnails = mergedPdfDocument.pages.filter(page => page.thumbnail).length; const pagesWithThumbnails = mergedPdfDocument.pages.filter(page => page.thumbnail).length;
const hasAllThumbnails = pagesWithThumbnails === totalPages; const hasAllThumbnails = pagesWithThumbnails === totalPages;
console.log('🎬 PageEditor: Thumbnail status:', {
totalPages,
pagesWithThumbnails,
hasAllThumbnails,
missingThumbnails: totalPages - pagesWithThumbnails
});
if (hasAllThumbnails) { if (hasAllThumbnails) {
console.log('🎬 PageEditor: Skipping generation - all thumbnails already exist'); return; // Skip generation if thumbnails exist
return; // Skip generation if ALL thumbnails already exist
} }
console.log('🎬 PageEditor: Some thumbnails missing, proceeding with generation'); // Small delay to let document render
// Small delay to let document render, then start thumbnail generation
console.log('🎬 PageEditor: Scheduling thumbnail generation in 500ms');
const timer = setTimeout(startThumbnailGeneration, 500); const timer = setTimeout(startThumbnailGeneration, 500);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
}, [mergedPdfDocument, startThumbnailGeneration, thumbnailGenerationStarted]); }, [filesSignature, startThumbnailGeneration]);
// Cleanup shared PDF instance when component unmounts (but preserve cache) // Cleanup shared PDF instance when component unmounts (but preserve cache)
useEffect(() => { useEffect(() => {
@ -338,11 +349,9 @@ const PageEditor = ({
sharedPdfInstance.destroy(); sharedPdfInstance.destroy();
setSharedPdfInstance(null); setSharedPdfInstance(null);
} }
setThumbnailGenerationStarted(false); thumbnailGenerationStarted.current = false;
// DON'T stop generation on file changes - preserve cache for view switching
// stopGeneration();
}; };
}, [sharedPdfInstance]); // Only depend on PDF instance, not activeFiles }, [sharedPdfInstance]);
// Clear selections when files change // Clear selections when files change
useEffect(() => { useEffect(() => {
@ -432,14 +441,14 @@ const PageEditor = ({
ranges.forEach(range => { ranges.forEach(range => {
if (range.includes('-')) { if (range.includes('-')) {
const [start, end] = range.split('-').map(n => parseInt(n.trim())); const [start, end] = range.split('-').map(n => parseInt(n.trim()));
for (let i = start; i <= end && i <= mergedPdfDocument.totalPages; i++) { for (let i = start; i <= end && i <= mergedPdfDocument.pages.length; i++) {
if (i > 0) { if (i > 0) {
pageNumbers.push(i); pageNumbers.push(i);
} }
} }
} else { } else {
const pageNum = parseInt(range); const pageNum = parseInt(range);
if (pageNum > 0 && pageNum <= mergedPdfDocument.totalPages) { if (pageNum > 0 && pageNum <= mergedPdfDocument.pages.length) {
pageNumbers.push(pageNum); pageNumbers.push(pageNum);
} }
} }
@ -527,25 +536,31 @@ const PageEditor = ({
// Update local edit state for immediate visual feedback // Update local edit state for immediate visual feedback
setEditedDocument(updatedDoc); setEditedDocument(updatedDoc);
setHasUnsavedChanges(true); // Use global state actions.setHasUnsavedChanges(true); // Use actions from context
setHasUnsavedDraft(true); // Mark that we have unsaved draft changes setHasUnsavedDraft(true); // Mark that we have unsaved draft changes
// Auto-save to drafts (debounced) - only if we have new changes // Enhanced auto-save to drafts with proper error handling
if (autoSaveTimer.current) { if (autoSaveTimer.current) {
clearTimeout(autoSaveTimer.current); clearTimeout(autoSaveTimer.current);
} }
autoSaveTimer.current = setTimeout(() => { autoSaveTimer.current = setTimeout(async () => {
if (hasUnsavedDraft) { if (hasUnsavedDraft) {
saveDraftToIndexedDB(updatedDoc); try {
setHasUnsavedDraft(false); // Mark draft as saved await saveDraftToIndexedDB(updatedDoc);
setHasUnsavedDraft(false); // Mark draft as saved
console.log('Auto-save completed successfully');
} catch (error) {
console.warn('Auto-save failed, will retry on next change:', error);
// Don't set hasUnsavedDraft to false so it will retry
}
} }
}, 30000); // Auto-save after 30 seconds of inactivity }, 30000); // Auto-save after 30 seconds of inactivity
return updatedDoc; return updatedDoc;
}, [setHasUnsavedChanges, hasUnsavedDraft]); }, [actions, hasUnsavedDraft]);
// Save draft to separate IndexedDB location // Enhanced draft save with proper IndexedDB handling
const saveDraftToIndexedDB = useCallback(async (doc: PDFDocument) => { const saveDraftToIndexedDB = useCallback(async (doc: PDFDocument) => {
try { try {
const draftKey = `draft-${doc.id || 'merged'}`; const draftKey = `draft-${doc.id || 'merged'}`;
@ -555,41 +570,124 @@ const PageEditor = ({
originalFiles: activeFiles.map(f => f.name) originalFiles: activeFiles.map(f => f.name)
}; };
// Save to 'pdf-drafts' store in IndexedDB // Robust IndexedDB initialization with proper error handling
const request = indexedDB.open('stirling-pdf-drafts', 1); const dbRequest = indexedDB.open('stirling-pdf-drafts', 1);
request.onupgradeneeded = () => {
const db = request.result; return new Promise<void>((resolve, reject) => {
if (!db.objectStoreNames.contains('drafts')) { dbRequest.onerror = () => {
db.createObjectStore('drafts'); console.warn('Failed to open draft database:', dbRequest.error);
} reject(dbRequest.error);
}; };
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');
}
};
dbRequest.onsuccess = () => {
const db = dbRequest.result;
// Verify object store exists before attempting transaction
if (!db.objectStoreNames.contains('drafts')) {
console.warn('Drafts object store not found, skipping save');
resolve();
return;
}
try {
const transaction = db.transaction('drafts', 'readwrite');
const store = transaction.objectStore('drafts');
transaction.onerror = () => {
console.warn('Draft save transaction failed:', transaction.error);
reject(transaction.error);
};
transaction.oncomplete = () => {
console.log('Draft auto-saved successfully');
resolve();
};
const putRequest = store.put(draftData, draftKey);
putRequest.onerror = () => {
console.warn('Failed to put draft data:', putRequest.error);
reject(putRequest.error);
};
} catch (error) {
console.warn('Transaction creation failed:', error);
reject(error);
} finally {
db.close();
}
};
});
request.onsuccess = () => {
const db = request.result;
const transaction = db.transaction('drafts', 'readwrite');
const store = transaction.objectStore('drafts');
store.put(draftData, draftKey);
console.log('Draft auto-saved to IndexedDB');
};
} catch (error) { } catch (error) {
console.warn('Failed to auto-save draft:', error); console.warn('Draft save failed:', error);
throw error;
} }
}, [activeFiles]); }, [activeFiles]);
// Clean up draft from IndexedDB // Enhanced draft cleanup with proper IndexedDB handling
const cleanupDraft = useCallback(async () => { const cleanupDraft = useCallback(async () => {
try { try {
const draftKey = `draft-${mergedPdfDocument?.id || 'merged'}`; const draftKey = `draft-${mergedPdfDocument?.id || 'merged'}`;
const request = indexedDB.open('stirling-pdf-drafts', 1); const dbRequest = indexedDB.open('stirling-pdf-drafts', 1);
return new Promise<void>((resolve, reject) => {
dbRequest.onerror = () => {
console.warn('Failed to open draft database for cleanup:', dbRequest.error);
resolve(); // Don't fail the whole operation if cleanup fails
};
dbRequest.onsuccess = () => {
const db = dbRequest.result;
// Check if object store exists before attempting cleanup
if (!db.objectStoreNames.contains('drafts')) {
console.log('No drafts object store found, nothing to cleanup');
resolve();
return;
}
try {
const transaction = db.transaction('drafts', 'readwrite');
const store = transaction.objectStore('drafts');
transaction.onerror = () => {
console.warn('Draft cleanup transaction failed:', transaction.error);
resolve(); // Don't fail if cleanup fails
};
transaction.oncomplete = () => {
console.log('Draft cleaned up successfully');
resolve();
};
const deleteRequest = store.delete(draftKey);
deleteRequest.onerror = () => {
console.warn('Failed to delete draft:', deleteRequest.error);
resolve(); // Don't fail if delete fails
};
} catch (error) {
console.warn('Draft cleanup transaction creation failed:', error);
resolve(); // Don't fail if cleanup fails
} finally {
db.close();
}
};
});
request.onsuccess = () => {
const db = request.result;
const transaction = db.transaction('drafts', 'readwrite');
const store = transaction.objectStore('drafts');
store.delete(draftKey);
};
} catch (error) { } catch (error) {
console.warn('Failed to cleanup draft:', error); console.warn('Draft cleanup failed:', error);
// Don't throw - cleanup failure shouldn't break the app
} }
}, [mergedPdfDocument]); }, [mergedPdfDocument]);
@ -615,7 +713,17 @@ const PageEditor = ({
lastModified: Date.now() 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) { } else if (activeFiles.length > 1) {
setStatus('Apply changes for multiple files not yet supported'); setStatus('Apply changes for multiple files not yet supported');
@ -625,7 +733,7 @@ const PageEditor = ({
// Wait for the processed file update to complete before clearing edit state // Wait for the processed file update to complete before clearing edit state
setTimeout(() => { setTimeout(() => {
setEditedDocument(null); setEditedDocument(null);
setHasUnsavedChanges(false); actions.setHasUnsavedChanges(false);
setHasUnsavedDraft(false); setHasUnsavedDraft(false);
cleanupDraft(); cleanupDraft();
setStatus('Changes applied successfully'); setStatus('Changes applied successfully');
@ -635,7 +743,7 @@ const PageEditor = ({
console.error('Failed to apply changes:', error); console.error('Failed to apply changes:', error);
setStatus('Failed to apply changes'); setStatus('Failed to apply changes');
} }
}, [editedDocument, mergedPdfDocument, processedFiles, activeFiles, updateProcessedFile, setHasUnsavedChanges, setStatus, cleanupDraft]); }, [editedDocument, mergedPdfDocument, processedFiles, activeFiles, state.files.ids, state.files.byId, actions, dispatch, cleanupDraft]);
const animateReorder = useCallback((pageNumber: number, targetIndex: number) => { const animateReorder = useCallback((pageNumber: number, targetIndex: number) => {
if (!displayDocument || isAnimating) return; if (!displayDocument || isAnimating) return;
@ -941,53 +1049,60 @@ const PageEditor = ({
const closePdf = useCallback(() => { const closePdf = useCallback(() => {
// Use global navigation guard system // Use global navigation guard system
fileContext.requestNavigation(() => { actions.requestNavigation(() => {
clearAllFiles(); // This now handles all cleanup centrally (including merged docs) clearAllFiles(); // This now handles all cleanup centrally (including merged docs)
setSelectedPages([]); setSelectedPages([]);
}); });
}, [fileContext, clearAllFiles, setSelectedPages]); }, [actions, clearAllFiles, setSelectedPages]);
// PageEditorControls needs onExportSelected and onExportAll // PageEditorControls needs onExportSelected and onExportAll
const onExportSelected = useCallback(() => showExportPreview(true), [showExportPreview]); const onExportSelected = useCallback(() => showExportPreview(true), [showExportPreview]);
const onExportAll = useCallback(() => showExportPreview(false), [showExportPreview]); const onExportAll = useCallback(() => showExportPreview(false), [showExportPreview]);
// Expose functions to parent component for PageEditorControls /**
* Stable function proxy pattern to prevent infinite loops.
*
* Problem: If we include selectedPages in useEffect dependencies, every page selection
* change triggers onFunctionsReady parent re-renders PageEditor unmounts/remounts infinite loop
*
* Solution: Create a stable proxy object that uses getters to access current values
* without triggering parent re-renders when values change.
*/
const pageEditorFunctionsRef = useRef({
handleUndo, handleRedo, canUndo, canRedo, handleRotate, handleDelete, handleSplit,
showExportPreview, onExportSelected, onExportAll, exportLoading, selectionMode,
selectedPages: selectedPageNumbers, closePdf,
});
// Update ref with current values (no parent notification)
pageEditorFunctionsRef.current = {
handleUndo, handleRedo, canUndo, canRedo, handleRotate, handleDelete, handleSplit,
showExportPreview, onExportSelected, onExportAll, exportLoading, selectionMode,
selectedPages: selectedPageNumbers, closePdf,
};
// Only call onFunctionsReady once - use stable proxy for live updates
useEffect(() => { useEffect(() => {
if (onFunctionsReady) { if (onFunctionsReady) {
onFunctionsReady({ const stableFunctions = {
handleUndo, get handleUndo() { return pageEditorFunctionsRef.current.handleUndo; },
handleRedo, get handleRedo() { return pageEditorFunctionsRef.current.handleRedo; },
canUndo, get canUndo() { return pageEditorFunctionsRef.current.canUndo; },
canRedo, get canRedo() { return pageEditorFunctionsRef.current.canRedo; },
handleRotate, get handleRotate() { return pageEditorFunctionsRef.current.handleRotate; },
handleDelete, get handleDelete() { return pageEditorFunctionsRef.current.handleDelete; },
handleSplit, get handleSplit() { return pageEditorFunctionsRef.current.handleSplit; },
showExportPreview, get showExportPreview() { return pageEditorFunctionsRef.current.showExportPreview; },
onExportSelected, get onExportSelected() { return pageEditorFunctionsRef.current.onExportSelected; },
onExportAll, get onExportAll() { return pageEditorFunctionsRef.current.onExportAll; },
exportLoading, get exportLoading() { return pageEditorFunctionsRef.current.exportLoading; },
selectionMode, get selectionMode() { return pageEditorFunctionsRef.current.selectionMode; },
selectedPages: selectedPageNumbers, get selectedPages() { return pageEditorFunctionsRef.current.selectedPages; },
closePdf, get closePdf() { return pageEditorFunctionsRef.current.closePdf; },
}); };
onFunctionsReady(stableFunctions);
} }
}, [ }, [onFunctionsReady]);
onFunctionsReady,
handleUndo,
handleRedo,
canUndo,
canRedo,
handleRotate,
handleDelete,
handleSplit,
showExportPreview,
onExportSelected,
onExportAll,
exportLoading,
selectionMode,
selectedPageNumbers,
closePdf
]);
// Show loading or empty state instead of blocking // Show loading or empty state instead of blocking
const showLoading = !mergedPdfDocument && (globalProcessing || activeFiles.length > 0); const showLoading = !mergedPdfDocument && (globalProcessing || activeFiles.length > 0);
@ -1006,38 +1121,96 @@ const PageEditor = ({
} }
}, [editedDocument, applyChanges, handleExport]); }, [editedDocument, applyChanges, handleExport]);
// Check for existing drafts // Enhanced draft checking with proper IndexedDB handling
const checkForDrafts = useCallback(async () => { const checkForDrafts = useCallback(async () => {
if (!mergedPdfDocument) return; if (!mergedPdfDocument) return;
try { try {
const draftKey = `draft-${mergedPdfDocument.id || 'merged'}`; const draftKey = `draft-${mergedPdfDocument.id || 'merged'}`;
const request = indexedDB.open('stirling-pdf-drafts', 1); const dbRequest = indexedDB.open('stirling-pdf-drafts', 1);
request.onsuccess = () => { return new Promise<void>((resolve, reject) => {
const db = request.result; dbRequest.onerror = () => {
if (!db.objectStoreNames.contains('drafts')) return; console.warn('Failed to open draft database for checking:', dbRequest.error);
resolve(); // Don't fail if draft checking fails
};
const transaction = db.transaction('drafts', 'readonly'); dbRequest.onupgradeneeded = (event) => {
const store = transaction.objectStore('drafts'); const db = (event.target as IDBOpenDBRequest).result;
const getRequest = store.get(draftKey);
getRequest.onsuccess = () => { // Create object store if it doesn't exist
const draft = getRequest.result; if (!db.objectStoreNames.contains('drafts')) {
if (draft && draft.timestamp) { const store = db.createObjectStore('drafts');
// Check if draft is recent (within last 24 hours) console.log('Created drafts object store during check');
const draftAge = Date.now() - draft.timestamp;
const twentyFourHours = 24 * 60 * 60 * 1000;
if (draftAge < twentyFourHours) {
setFoundDraft(draft);
setShowResumeModal(true);
}
} }
}; };
};
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) { } catch (error) {
console.warn('Failed to check for drafts:', error); console.warn('Draft check failed:', error);
// Don't throw - draft checking failure shouldn't break the app
} }
}, [mergedPdfDocument]); }, [mergedPdfDocument]);
@ -1045,12 +1218,12 @@ const PageEditor = ({
const resumeWork = useCallback(() => { const resumeWork = useCallback(() => {
if (foundDraft && foundDraft.document) { if (foundDraft && foundDraft.document) {
setEditedDocument(foundDraft.document); setEditedDocument(foundDraft.document);
setHasUnsavedChanges(true); actions.setHasUnsavedChanges(true); // Use context action
setFoundDraft(null); setFoundDraft(null);
setShowResumeModal(false); setShowResumeModal(false);
setStatus('Resumed previous work'); setStatus('Resumed previous work');
} }
}, [foundDraft]); }, [foundDraft, actions]);
// Start fresh (ignore draft) // Start fresh (ignore draft)
const startFresh = useCallback(() => { const startFresh = useCallback(() => {
@ -1065,8 +1238,6 @@ const PageEditor = ({
// Cleanup on unmount // Cleanup on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
console.log('PageEditor unmounting - cleaning up resources');
// Clear auto-save timer // Clear auto-save timer
if (autoSaveTimer.current) { if (autoSaveTimer.current) {
clearTimeout(autoSaveTimer.current); clearTimeout(autoSaveTimer.current);
@ -1342,7 +1513,7 @@ const PageEditor = ({
loading={exportLoading} loading={exportLoading}
onClick={() => { onClick={() => {
setShowExportModal(false); setShowExportModal(false);
const selectedOnly = exportPreview.pageCount < (mergedPdfDocument?.totalPages || 0); const selectedOnly = exportPreview.pageCount < (mergedPdfDocument?.pages.length || 0);
handleExport(selectedOnly); handleExport(selectedOnly);
}} }}
> >
@ -1408,6 +1579,17 @@ const PageEditor = ({
{status} {status}
</Notification> </Notification>
)} )}
{error && (
<Notification
color="red"
mt="md"
onClose={() => setError(null)}
style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }}
>
{error}
</Notification>
)}
</Box> </Box>
); );
}; };

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { Modal, Text, Button, Group, Stack } from '@mantine/core'; import { Modal, Text, Button, Group, Stack } from '@mantine/core';
import { useFileContext } from '../../contexts/FileContext'; import { useFileState, useFileActions } from '../../contexts/FileContext';
interface NavigationWarningModalProps { interface NavigationWarningModalProps {
onApplyAndContinue?: () => Promise<void>; onApplyAndContinue?: () => Promise<void>;
@ -11,37 +11,34 @@ const NavigationWarningModal = ({
onApplyAndContinue, onApplyAndContinue,
onExportAndContinue onExportAndContinue
}: NavigationWarningModalProps) => { }: NavigationWarningModalProps) => {
const { const { state } = useFileState();
showNavigationWarning, const { actions } = useFileActions();
hasUnsavedChanges, const showNavigationWarning = state.ui.showNavigationWarning;
confirmNavigation, const hasUnsavedChanges = state.ui.hasUnsavedChanges;
cancelNavigation,
setHasUnsavedChanges
} = useFileContext();
const handleKeepWorking = () => { const handleKeepWorking = () => {
cancelNavigation(); actions.cancelNavigation();
}; };
const handleDiscardChanges = () => { const handleDiscardChanges = () => {
setHasUnsavedChanges(false); actions.setHasUnsavedChanges(false);
confirmNavigation(); actions.confirmNavigation();
}; };
const handleApplyAndContinue = async () => { const handleApplyAndContinue = async () => {
if (onApplyAndContinue) { if (onApplyAndContinue) {
await onApplyAndContinue(); await onApplyAndContinue();
} }
setHasUnsavedChanges(false); actions.setHasUnsavedChanges(false);
confirmNavigation(); actions.confirmNavigation();
}; };
const handleExportAndContinue = async () => { const handleExportAndContinue = async () => {
if (onExportAndContinue) { if (onExportAndContinue) {
await onExportAndContinue(); await onExportAndContinue();
} }
setHasUnsavedChanges(false); actions.setHasUnsavedChanges(false);
confirmNavigation(); actions.confirmNavigation();
}; };
if (!hasUnsavedChanges) { if (!hasUnsavedChanges) {

View File

@ -1,4 +1,4 @@
import React, { useState, useCallback } from "react"; import React, { useState, useCallback, useMemo } from "react";
import { Button, SegmentedControl, Loader } from "@mantine/core"; import { Button, SegmentedControl, Loader } from "@mantine/core";
import { useRainbowThemeContext } from "./RainbowThemeProvider"; import { useRainbowThemeContext } from "./RainbowThemeProvider";
import LanguageSelector from "./LanguageSelector"; import LanguageSelector from "./LanguageSelector";
@ -10,50 +10,18 @@ import VisibilityIcon from "@mui/icons-material/Visibility";
import EditNoteIcon from "@mui/icons-material/EditNote"; import EditNoteIcon from "@mui/icons-material/EditNote";
import FolderIcon from "@mui/icons-material/Folder"; import FolderIcon from "@mui/icons-material/Folder";
import { Group } from "@mantine/core"; import { Group } from "@mantine/core";
import { ModeType } from '../../types/fileContext';
// This will be created inside the component to access switchingTo // Stable view option objects that don't recreate on every render
const createViewOptions = (switchingTo: string | null) => [ const VIEW_OPTIONS_BASE = [
{ { value: "viewer", icon: VisibilityIcon },
label: ( { value: "pageEditor", icon: EditNoteIcon },
<Group gap={5}> { value: "fileEditor", icon: FolderIcon },
{switchingTo === "viewer" ? ( ] as const;
<Loader size="xs" />
) : (
<VisibilityIcon fontSize="small" />
)}
</Group>
),
value: "viewer",
},
{
label: (
<Group gap={4}>
{switchingTo === "pageEditor" ? (
<Loader size="xs" />
) : (
<EditNoteIcon fontSize="small" />
)}
</Group>
),
value: "pageEditor",
},
{
label: (
<Group gap={4}>
{switchingTo === "fileEditor" ? (
<Loader size="xs" />
) : (
<FolderIcon fontSize="small" />
)}
</Group>
),
value: "fileEditor",
},
];
interface TopControlsProps { interface TopControlsProps {
currentView: string; currentView: ModeType;
setCurrentView: (view: string) => void; setCurrentView: (view: ModeType) => void;
selectedToolKey?: string | null; selectedToolKey?: string | null;
} }
@ -68,6 +36,9 @@ const TopControls = ({
const isToolSelected = selectedToolKey !== null; const isToolSelected = selectedToolKey !== null;
const handleViewChange = useCallback((view: string) => { const handleViewChange = useCallback((view: string) => {
// Guard against redundant changes
if (view === currentView) return;
// Show immediate feedback // Show immediate feedback
setSwitchingTo(view); setSwitchingTo(view);
@ -75,13 +46,28 @@ const TopControls = ({
requestAnimationFrame(() => { requestAnimationFrame(() => {
// Give the spinner one more frame to show // Give the spinner one more frame to show
requestAnimationFrame(() => { requestAnimationFrame(() => {
setCurrentView(view); setCurrentView(view as ModeType);
// Clear the loading state after view change completes // Clear the loading state after view change completes
setTimeout(() => setSwitchingTo(null), 300); setTimeout(() => setSwitchingTo(null), 300);
}); });
}); });
}, [setCurrentView]); }, [setCurrentView, currentView]);
// Memoize the SegmentedControl data with stable references
const viewOptions = useMemo(() =>
VIEW_OPTIONS_BASE.map(option => ({
value: option.value,
label: (
<Group gap={option.value === "viewer" ? 5 : 4}>
{switchingTo === option.value ? (
<Loader size="xs" />
) : (
<option.icon fontSize="small" />
)}
</Group>
)
})), [switchingTo]);
const getThemeIcon = () => { const getThemeIcon = () => {
if (isRainbowMode) return <AutoAwesomeIcon className={rainbowStyles.rainbowText} />; if (isRainbowMode) return <AutoAwesomeIcon className={rainbowStyles.rainbowText} />;
@ -117,7 +103,7 @@ const TopControls = ({
{!isToolSelected && ( {!isToolSelected && (
<div className="flex justify-center items-center h-full pointer-events-auto"> <div className="flex justify-center items-center h-full pointer-events-auto">
<SegmentedControl <SegmentedControl
data={createViewOptions(switchingTo)} data={viewOptions}
value={currentView} value={currentView}
onChange={handleViewChange} onChange={handleViewChange}
color="blue" color="blue"

View File

@ -4,8 +4,8 @@ import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMultipleEndpointsEnabled } from "../../../hooks/useEndpointConfig"; import { useMultipleEndpointsEnabled } from "../../../hooks/useEndpointConfig";
import { isImageFormat, isWebFormat } from "../../../utils/convertUtils"; import { isImageFormat, isWebFormat } from "../../../utils/convertUtils";
import { useFileSelectionActions } from "../../../contexts/FileSelectionContext"; import { useToolFileSelection } from "../../../contexts/FileContext";
import { useFileContext } from "../../../contexts/FileContext"; import { useFileState } from "../../../contexts/FileContext";
import { detectFileExtension } from "../../../utils/fileUtils"; import { detectFileExtension } from "../../../utils/fileUtils";
import GroupedFormatDropdown from "./GroupedFormatDropdown"; import GroupedFormatDropdown from "./GroupedFormatDropdown";
import ConvertToImageSettings from "./ConvertToImageSettings"; import ConvertToImageSettings from "./ConvertToImageSettings";
@ -40,8 +40,9 @@ const ConvertSettings = ({
const { t } = useTranslation(); const { t } = useTranslation();
const theme = useMantineTheme(); const theme = useMantineTheme();
const { colorScheme } = useMantineColorScheme(); const { colorScheme } = useMantineColorScheme();
const { setSelectedFiles } = useFileSelectionActions(); const { setSelectedFiles } = useToolFileSelection();
const { activeFiles, setSelectedFiles: setContextSelectedFiles } = useFileContext(); const { state } = useFileState();
const activeFiles = state.files.ids;
const allEndpoints = useMemo(() => { const allEndpoints = useMemo(() => {
const endpoints = new Set<string>(); const endpoints = new Set<string>();
@ -92,7 +93,7 @@ const ConvertSettings = ({
} }
return baseOptions; return baseOptions;
}, [getAvailableToExtensions, endpointStatus, parameters.fromExtension]); }, [parameters.fromExtension, endpointStatus]);
// Enhanced TO options with endpoint availability // Enhanced TO options with endpoint availability
const enhancedToOptions = useMemo(() => { const enhancedToOptions = useMemo(() => {
@ -103,7 +104,7 @@ const ConvertSettings = ({
...option, ...option,
enabled: isConversionAvailable(parameters.fromExtension, option.value) enabled: isConversionAvailable(parameters.fromExtension, option.value)
})); }));
}, [parameters.fromExtension, getAvailableToExtensions, endpointStatus]); }, [parameters.fromExtension, endpointStatus]);
const resetParametersToDefaults = () => { const resetParametersToDefaults = () => {
onParameterChange('imageOptions', { onParameterChange('imageOptions', {
@ -134,7 +135,8 @@ const ConvertSettings = ({
}; };
const filterFilesByExtension = (extension: string) => { const filterFilesByExtension = (extension: string) => {
return activeFiles.filter(file => { const files = activeFiles.map(fileId => state.files.byId[fileId]?.file).filter(Boolean);
return files.filter(file => {
const fileExtension = detectFileExtension(file.name); const fileExtension = detectFileExtension(file.name);
if (extension === 'any') { if (extension === 'any') {
@ -149,8 +151,6 @@ const ConvertSettings = ({
const updateFileSelection = (files: File[]) => { const updateFileSelection = (files: File[]) => {
setSelectedFiles(files); setSelectedFiles(files);
const fileIds = files.map(file => (file as any).id || file.name);
setContextSelectedFiles(fileIds);
}; };
const handleFromExtensionChange = (value: string) => { const handleFromExtensionChange = (value: string) => {

View File

@ -176,6 +176,10 @@ const Viewer = ({
const [zoom, setZoom] = useState(1); // 1 = 100% const [zoom, setZoom] = useState(1); // 1 = 100%
const pageRefs = useRef<(HTMLImageElement | null)[]>([]); const pageRefs = useRef<(HTMLImageElement | null)[]>([]);
// Memoize setPageRef to prevent infinite re-renders
const setPageRef = useCallback((index: number, ref: HTMLImageElement | null) => {
pageRefs.current[index] = ref;
}, []);
// Get files with URLs for tabs - we'll need to create these individually // Get files with URLs for tabs - we'll need to create these individually
const file0WithUrl = useFileWithUrl(activeFiles[0]); const file0WithUrl = useFileWithUrl(activeFiles[0]);
@ -499,7 +503,7 @@ const Viewer = ({
isFirst={i === 0} isFirst={i === 0}
renderPage={renderPage} renderPage={renderPage}
pageImages={pageImages} pageImages={pageImages}
setPageRef={(index, ref) => { pageRefs.current[index] = ref; }} setPageRef={setPageRef}
/> />
{i * 2 + 1 < numPages && ( {i * 2 + 1 < numPages && (
<LazyPageImage <LazyPageImage
@ -509,7 +513,7 @@ const Viewer = ({
isFirst={i === 0} isFirst={i === 0}
renderPage={renderPage} renderPage={renderPage}
pageImages={pageImages} pageImages={pageImages}
setPageRef={(index, ref) => { pageRefs.current[index] = ref; }} setPageRef={setPageRef}
/> />
)} )}
</Group> </Group>
@ -523,7 +527,7 @@ const Viewer = ({
isFirst={idx === 0} isFirst={idx === 0}
renderPage={renderPage} renderPage={renderPage}
pageImages={pageImages} pageImages={pageImages}
setPageRef={(index, ref) => { pageRefs.current[index] = ref; }} setPageRef={setPageRef}
/> />
))} ))}
</Stack> </Stack>

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
import React, { createContext, useContext, useState, useRef, useCallback, useEffect } from 'react'; import React, { createContext, useContext, useState, useRef, useCallback, useEffect, useMemo } from 'react';
import { FileWithUrl } from '../types/file'; import { FileWithUrl } from '../types/file';
import { StoredFile } from '../services/fileStorage'; import { StoredFile } from '../services/fileStorage';
@ -168,7 +168,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
} }
}, [isOpen]); }, [isOpen]);
const contextValue: FileManagerContextValue = { const contextValue: FileManagerContextValue = useMemo(() => ({
// State // State
activeSource, activeSource,
selectedFileIds, selectedFileIds,
@ -191,7 +191,25 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
recentFiles, recentFiles,
isFileSupported, isFileSupported,
modalHeight, modalHeight,
}; }), [
activeSource,
selectedFileIds,
searchTerm,
selectedFiles,
filteredFiles,
fileInputRef,
handleSourceChange,
handleLocalFileClick,
handleFileSelect,
handleFileRemove,
handleFileDoubleClick,
handleOpenFiles,
handleSearchChange,
handleFileInputChange,
recentFiles,
isFileSupported,
modalHeight,
]);
return ( return (
<FileManagerContext.Provider value={contextValue}> <FileManagerContext.Provider value={contextValue}>

View File

@ -1,86 +0,0 @@
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
import {
MaxFiles,
FileSelectionContextValue
} from '../types/tool';
interface FileSelectionProviderProps {
children: ReactNode;
}
const FileSelectionContext = createContext<FileSelectionContextValue | undefined>(undefined);
export function FileSelectionProvider({ children }: FileSelectionProviderProps) {
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [maxFiles, setMaxFiles] = useState<MaxFiles>(-1);
const [isToolMode, setIsToolMode] = useState<boolean>(false);
const clearSelection = useCallback(() => {
setSelectedFiles([]);
}, []);
const selectionCount = selectedFiles.length;
const canSelectMore = maxFiles === -1 || selectionCount < maxFiles;
const isAtLimit = maxFiles > 0 && selectionCount >= maxFiles;
const isMultiFileMode = maxFiles !== 1;
const contextValue: FileSelectionContextValue = {
selectedFiles,
maxFiles,
isToolMode,
setSelectedFiles,
setMaxFiles,
setIsToolMode,
clearSelection,
canSelectMore,
isAtLimit,
selectionCount,
isMultiFileMode
};
return (
<FileSelectionContext.Provider value={contextValue}>
{children}
</FileSelectionContext.Provider>
);
}
/**
* Access the file selection context.
* Throws if used outside a <FileSelectionProvider>.
*/
export function useFileSelection(): FileSelectionContextValue {
const context = useContext(FileSelectionContext);
if (!context) {
throw new Error('useFileSelection must be used within a FileSelectionProvider');
}
return context;
}
// Returns only the file selection values relevant for tools (e.g. merge, split, etc.)
// Use this in tool panels/components that need to know which files are selected and selection limits.
export function useToolFileSelection(): Pick<FileSelectionContextValue, 'selectedFiles' | 'maxFiles' | 'canSelectMore' | 'isAtLimit' | 'selectionCount'> {
const { selectedFiles, maxFiles, canSelectMore, isAtLimit, selectionCount } = useFileSelection();
return { selectedFiles, maxFiles, canSelectMore, isAtLimit, selectionCount };
}
// Returns actions for manipulating file selection state.
// Use this in components that need to update the selection, clear it, or change selection mode.
export function useFileSelectionActions(): Pick<FileSelectionContextValue, 'setSelectedFiles' | 'clearSelection' | 'setMaxFiles' | 'setIsToolMode'> {
const { setSelectedFiles, clearSelection, setMaxFiles, setIsToolMode } = useFileSelection();
return { setSelectedFiles, clearSelection, setMaxFiles, setIsToolMode };
}
// Returns the raw file selection state (selected files, max files, tool mode).
// Use this for low-level state access, e.g. in context-aware UI.
export function useFileSelectionState(): Pick<FileSelectionContextValue, 'selectedFiles' | 'maxFiles' | 'isToolMode'> {
const { selectedFiles, maxFiles, isToolMode } = useFileSelection();
return { selectedFiles, maxFiles, isToolMode };
}
// Returns computed values derived from file selection state.
// Use this for file selection UI logic (e.g. disabling buttons when at limit).
export function useFileSelectionComputed(): Pick<FileSelectionContextValue, 'canSelectMore' | 'isAtLimit' | 'selectionCount' | 'isMultiFileMode'> {
const { canSelectMore, isAtLimit, selectionCount, isMultiFileMode } = useFileSelection();
return { canSelectMore, isAtLimit, selectionCount, isMultiFileMode };
}

View File

@ -1,4 +1,4 @@
import React, { createContext, useContext, useState, useCallback } from 'react'; import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
import { useFileHandler } from '../hooks/useFileHandler'; import { useFileHandler } from '../hooks/useFileHandler';
interface FilesModalContextType { interface FilesModalContextType {
@ -41,7 +41,7 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch
setOnModalClose(() => callback); setOnModalClose(() => callback);
}, []); }, []);
const contextValue: FilesModalContextType = { const contextValue: FilesModalContextType = useMemo(() => ({
isFilesModalOpen, isFilesModalOpen,
openFilesModal, openFilesModal,
closeFilesModal, closeFilesModal,
@ -49,7 +49,15 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch
onFilesSelect: handleFilesSelect, onFilesSelect: handleFilesSelect,
onModalClose, onModalClose,
setOnModalClose: setModalCloseCallback, setOnModalClose: setModalCloseCallback,
}; }), [
isFilesModalOpen,
openFilesModal,
closeFilesModal,
handleFileSelect,
handleFilesSelect,
onModalClose,
setModalCloseCallback,
]);
return ( return (
<FilesModalContext.Provider value={contextValue}> <FilesModalContext.Provider value={contextValue}>

View File

@ -1,4 +1,4 @@
import React, { createContext, useContext, useState, useRef } from 'react'; import React, { createContext, useContext, useState, useRef, useMemo } from 'react';
import { SidebarState, SidebarRefs, SidebarContextValue, SidebarProviderProps } from '../types/sidebar'; import { SidebarState, SidebarRefs, SidebarContextValue, SidebarProviderProps } from '../types/sidebar';
const SidebarContext = createContext<SidebarContextValue | undefined>(undefined); const SidebarContext = createContext<SidebarContextValue | undefined>(undefined);
@ -12,24 +12,24 @@ export function SidebarProvider({ children }: SidebarProviderProps) {
const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker'); const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker');
const [readerMode, setReaderMode] = useState(false); const [readerMode, setReaderMode] = useState(false);
const sidebarState: SidebarState = { const sidebarState: SidebarState = useMemo(() => ({
sidebarsVisible, sidebarsVisible,
leftPanelView, leftPanelView,
readerMode, readerMode,
}; }), [sidebarsVisible, leftPanelView, readerMode]);
const sidebarRefs: SidebarRefs = { const sidebarRefs: SidebarRefs = useMemo(() => ({
quickAccessRef, quickAccessRef,
toolPanelRef, toolPanelRef,
}; }), [quickAccessRef, toolPanelRef]);
const contextValue: SidebarContextValue = { const contextValue: SidebarContextValue = useMemo(() => ({
sidebarState, sidebarState,
sidebarRefs, sidebarRefs,
setSidebarsVisible, setSidebarsVisible,
setLeftPanelView, setLeftPanelView,
setReaderMode, setReaderMode,
}; }), [sidebarState, sidebarRefs, setSidebarsVisible, setLeftPanelView, setReaderMode]);
return ( return (
<SidebarContext.Provider value={contextValue}> <SidebarContext.Provider value={contextValue}>

View File

@ -1,7 +1,7 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import axios from 'axios'; import axios from 'axios';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useFileContext } from '../../../contexts/FileContext'; import { useFileActions } from '../../../contexts/FileContext';
import { useToolState, type ProcessingProgress } from './useToolState'; import { useToolState, type ProcessingProgress } from './useToolState';
import { useToolApiCalls, type ApiCallsConfig } from './useToolApiCalls'; import { useToolApiCalls, type ApiCallsConfig } from './useToolApiCalls';
import { useToolResources } from './useToolResources'; import { useToolResources } from './useToolResources';
@ -112,7 +112,11 @@ export const useToolOperation = <TParams = void>(
config: ToolOperationConfig<TParams> config: ToolOperationConfig<TParams>
): ToolOperationHook<TParams> => { ): ToolOperationHook<TParams> => {
const { t } = useTranslation(); const { t } = useTranslation();
const { recordOperation, markOperationApplied, markOperationFailed, addFiles } = useFileContext(); 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
// Composed hooks // Composed hooks
const { state, actions } = useToolState(); const { state, actions } = useToolState();
@ -215,7 +219,7 @@ export const useToolOperation = <TParams = void>(
actions.setDownloadInfo(downloadInfo.url, downloadInfo.filename); actions.setDownloadInfo(downloadInfo.url, downloadInfo.filename);
// Add to file context // Add to file context
await addFiles(processedFiles); await fileActions.addFiles(processedFiles);
markOperationApplied(fileId, operationId); markOperationApplied(fileId, operationId);
} }
@ -229,7 +233,7 @@ export const useToolOperation = <TParams = void>(
actions.setLoading(false); actions.setLoading(false);
actions.setProgress(null); actions.setProgress(null);
} }
}, [t, config, actions, recordOperation, markOperationApplied, markOperationFailed, addFiles, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles]); }, [t, config, actions, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles, fileActions.addFiles]);
const cancelOperation = useCallback(() => { const cancelOperation = useCallback(() => {
cancelApiCalls(); cancelApiCalls();

View File

@ -1,24 +1,32 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useFileContext } from '../contexts/FileContext'; import { useFileState, useFileActions } from '../contexts/FileContext';
import { createStableFileId } from '../types/fileContext';
export const useFileHandler = () => { export const useFileHandler = () => {
const { activeFiles, addFiles } = useFileContext(); const { state } = useFileState();
const { actions } = useFileActions();
const addToActiveFiles = useCallback(async (file: File) => { const addToActiveFiles = useCallback(async (file: File) => {
const exists = activeFiles.some(f => f.name === file.name && f.size === file.size); // Use stable ID function for consistent deduplication
const stableId = createStableFileId(file);
const exists = state.files.byId[stableId] !== undefined;
if (!exists) { if (!exists) {
await addFiles([file]); await actions.addFiles([file]);
} }
}, [activeFiles, addFiles]); }, [state.files.byId, actions.addFiles]);
const addMultipleFiles = useCallback(async (files: File[]) => { const addMultipleFiles = useCallback(async (files: File[]) => {
const newFiles = files.filter(file => // Filter out files that already exist using stable IDs
!activeFiles.some(f => f.name === file.name && f.size === file.size) const newFiles = files.filter(file => {
); const stableId = createStableFileId(file);
return state.files.byId[stableId] === undefined;
});
if (newFiles.length > 0) { if (newFiles.length > 0) {
await addFiles(newFiles); await actions.addFiles(newFiles);
} }
}, [activeFiles, addFiles]); }, [state.files.byId, actions.addFiles]);
return { return {
addToActiveFiles, addToActiveFiles,

View File

@ -1,6 +1,5 @@
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { useFileContext } from "../contexts/FileContext"; import { useFileActions, useToolFileSelection } from "../contexts/FileContext";
import { FileSelectionProvider, useFileSelection } from "../contexts/FileSelectionContext";
import { ToolWorkflowProvider, useToolSelection } from "../contexts/ToolWorkflowContext"; import { ToolWorkflowProvider, useToolSelection } from "../contexts/ToolWorkflowContext";
import { Group } from "@mantine/core"; import { Group } from "@mantine/core";
import { SidebarProvider, useSidebarContext } from "../contexts/SidebarContext"; import { SidebarProvider, useSidebarContext } from "../contexts/SidebarContext";
@ -18,7 +17,7 @@ function HomePageContent() {
const { quickAccessRef } = sidebarRefs; const { quickAccessRef } = sidebarRefs;
const { setMaxFiles, setIsToolMode, setSelectedFiles } = useFileSelection(); const { setMaxFiles, setIsToolMode, setSelectedFiles } = useToolFileSelection();
const { selectedTool } = useToolSelection(); const { selectedTool } = useToolSelection();
@ -32,7 +31,7 @@ function HomePageContent() {
setIsToolMode(false); setIsToolMode(false);
setSelectedFiles([]); setSelectedFiles([]);
} }
}, [selectedTool, setMaxFiles, setIsToolMode, setSelectedFiles]); }, [selectedTool]); // Remove action dependencies to prevent loops
return ( return (
<Group <Group
@ -50,14 +49,12 @@ function HomePageContent() {
} }
export default function HomePage() { export default function HomePage() {
const { setCurrentView } = useFileContext(); const { actions } = useFileActions();
return ( return (
<FileSelectionProvider> <ToolWorkflowProvider onViewChange={actions.setMode}>
<ToolWorkflowProvider onViewChange={setCurrentView}> <SidebarProvider>
<SidebarProvider> <HomePageContent />
<HomePageContent /> </SidebarProvider>
</SidebarProvider> </ToolWorkflowProvider>
</ToolWorkflowProvider>
</FileSelectionProvider>
); );
} }

View File

@ -156,10 +156,10 @@ export const mantineTheme = createTheme({
}, },
option: { option: {
color: 'var(--text-primary)', color: 'var(--text-primary)',
'&[data-hovered]': { '&[dataHovered]': {
backgroundColor: 'var(--hover-bg)', backgroundColor: 'var(--hover-bg)',
}, },
'&[data-selected]': { '&[dataSelected]': {
backgroundColor: 'var(--color-primary-100)', backgroundColor: 'var(--color-primary-100)',
color: 'var(--color-primary-900)', color: 'var(--color-primary-900)',
}, },
@ -189,10 +189,10 @@ export const mantineTheme = createTheme({
}, },
option: { option: {
color: 'var(--text-primary)', color: 'var(--text-primary)',
'&[data-hovered]': { '&[dataHovered]': {
backgroundColor: 'var(--hover-bg)', backgroundColor: 'var(--hover-bg)',
}, },
'&[data-selected]': { '&[dataSelected]': {
backgroundColor: 'var(--color-primary-100)', backgroundColor: 'var(--color-primary-100)',
color: 'var(--color-primary-900)', color: 'var(--color-primary-900)',
}, },

View File

@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import DownloadIcon from "@mui/icons-material/Download"; import DownloadIcon from "@mui/icons-material/Download";
import { useEndpointEnabled } from "../hooks/useEndpointConfig"; import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileContext } from "../contexts/FileContext"; import { useFileContext } from "../contexts/FileContext";
import { useToolFileSelection } from "../contexts/FileSelectionContext"; import { useToolFileSelection } from "../contexts/FileContext";
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep"; import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
import OperationButton from "../components/tools/shared/OperationButton"; import OperationButton from "../components/tools/shared/OperationButton";

View File

@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import DownloadIcon from "@mui/icons-material/Download"; import DownloadIcon from "@mui/icons-material/Download";
import { useEndpointEnabled } from "../hooks/useEndpointConfig"; import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileContext } from "../contexts/FileContext"; import { useFileContext } from "../contexts/FileContext";
import { useToolFileSelection } from "../contexts/FileSelectionContext"; import { useToolFileSelection } from "../contexts/FileContext";
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep"; import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
import OperationButton from "../components/tools/shared/OperationButton"; import OperationButton from "../components/tools/shared/OperationButton";

View File

@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import DownloadIcon from "@mui/icons-material/Download"; import DownloadIcon from "@mui/icons-material/Download";
import { useEndpointEnabled } from "../hooks/useEndpointConfig"; import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileContext } from "../contexts/FileContext"; import { useFileContext } from "../contexts/FileContext";
import { useToolFileSelection } from "../contexts/FileSelectionContext"; import { useToolFileSelection } from "../contexts/FileContext";
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep"; import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
import OperationButton from "../components/tools/shared/OperationButton"; import OperationButton from "../components/tools/shared/OperationButton";

View File

@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import DownloadIcon from "@mui/icons-material/Download"; import DownloadIcon from "@mui/icons-material/Download";
import { useEndpointEnabled } from "../hooks/useEndpointConfig"; import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileContext } from "../contexts/FileContext"; import { useFileContext } from "../contexts/FileContext";
import { useToolFileSelection } from "../contexts/FileSelectionContext"; import { useToolFileSelection } from "../contexts/FileContext";
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep"; import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
import OperationButton from "../components/tools/shared/OperationButton"; import OperationButton from "../components/tools/shared/OperationButton";

View File

@ -7,6 +7,63 @@ 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';
// 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;
}
export interface FileContextNormalizedFiles {
ids: FileId[];
byId: Record<FileId, FileRecord>;
}
// Helper functions
export function createStableFileId(file: File): FileId {
// Use existing ID if file already has one, otherwise create stable ID from metadata
return (file as any).id || `${file.name}-${file.size}-${file.lastModified}`;
}
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,
lastModified: file.lastModified,
createdAt: Date.now()
};
}
export function revokeFileResources(record: FileRecord): void {
if (record.thumbnailUrl) {
URL.revokeObjectURL(record.thumbnailUrl);
}
if (record.blobUrl) {
URL.revokeObjectURL(record.blobUrl);
}
// Clean up processed file thumbnails
if (record.processedFile?.pages) {
record.processedFile.pages.forEach(page => {
if (page.thumbnail && page.thumbnail.startsWith('blob:')) {
URL.revokeObjectURL(page.thumbnail);
}
});
}
}
export type OperationType = 'merge' | 'split' | 'compress' | 'add' | 'remove' | 'replace' | 'convert' | 'upload' | 'ocr'; export type OperationType = 'merge' | 'split' | 'compress' | 'add' | 'remove' | 'replace' | 'convert' | 'upload' | 'ocr';
export interface FileOperation { export interface FileOperation {
@ -48,86 +105,118 @@ export interface FileEditHistory {
} }
export interface FileContextState { export interface FileContextState {
// Core file management // Core file management - normalized state
activeFiles: File[]; files: FileContextNormalizedFiles;
processedFiles: Map<File, ProcessedFile>;
// Current navigation state // UI state grouped for performance
currentMode: ModeType; ui: {
// Current navigation state
currentMode: ModeType;
// Edit history and state // UI state that persists across views
fileEditHistory: Map<string, FileEditHistory>; selectedFileIds: string[];
globalFileOperations: FileOperation[]; selectedPageNumbers: number[];
// New comprehensive operation history viewerConfig: ViewerConfig;
fileOperationHistory: Map<string, FileOperationHistory>;
// UI state that persists across views // Tool selection state (replaces FileSelectionContext)
selectedFileIds: string[]; toolMode: boolean;
selectedPageNumbers: number[]; maxFiles: number; // 1=single, >1=limited, -1=unlimited
viewerConfig: ViewerConfig; currentTool?: string;
// Processing state // Processing state
isProcessing: boolean; isProcessing: boolean;
processingProgress: number; processingProgress: number;
// Export state // Export state
lastExportConfig?: { lastExportConfig?: {
filename: string; filename: string;
selectedOnly: boolean; selectedOnly: boolean;
splitDocuments: boolean; splitDocuments: boolean;
};
// Navigation guard system
hasUnsavedChanges: boolean;
pendingNavigation: (() => void) | null;
showNavigationWarning: boolean;
}; };
// Navigation guard system // Edit history and state (less frequently accessed)
hasUnsavedChanges: boolean; history: {
pendingNavigation: (() => void) | null; fileEditHistory: Map<string, FileEditHistory>;
showNavigationWarning: boolean; globalFileOperations: FileOperation[];
fileOperationHistory: Map<string, FileOperationHistory>;
};
} }
// Action types for reducer pattern
export type FileContextAction =
// File management actions
| { 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_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> } };
export interface FileContextActions { export interface FileContextActions {
// File management // File management
addFiles: (files: File[]) => Promise<void>; addFiles: (files: File[]) => Promise<File[]>;
removeFiles: (fileIds: string[], deleteFromStorage?: boolean) => void; removeFiles: (fileIds: string[], deleteFromStorage?: boolean) => void;
replaceFile: (oldFileId: string, newFile: File) => Promise<void>; replaceFile: (oldFileId: string, newFile: File) => Promise<void>;
clearAllFiles: () => void; clearAllFiles: () => void;
// Navigation // Navigation
setCurrentMode: (mode: ModeType) => void; setMode: (mode: ModeType) => void;
// Selection management // Selection management
setSelectedFiles: (fileIds: string[]) => void; setSelectedFiles: (fileIds: string[]) => void;
setSelectedPages: (pageNumbers: number[]) => void; setSelectedPages: (pageNumbers: number[]) => void;
updateProcessedFile: (file: File, processedFile: ProcessedFile) => void;
clearSelections: () => void; clearSelections: () => void;
// Edit operations // Tool selection management (replaces FileSelectionContext)
applyPageOperations: (fileId: string, operations: PageOperation[]) => void; setToolMode: (toolMode: boolean) => void;
applyFileOperation: (operation: FileOperation) => void; setMaxFiles: (maxFiles: number) => void;
undoLastOperation: (fileId?: string) => void; setCurrentTool: (currentTool?: string) => void;
// Operation history management // Processing state
recordOperation: (fileId: string, operation: FileOperation | PageOperation) => void; setProcessing: (isProcessing: boolean, progress: number) => void;
markOperationApplied: (fileId: string, operationId: string) => void;
markOperationFailed: (fileId: string, operationId: string, error: string) => void;
getFileHistory: (fileId: string) => FileOperationHistory | undefined;
getAppliedOperations: (fileId: string) => (FileOperation | PageOperation)[];
clearFileHistory: (fileId: string) => void;
// Viewer state // Viewer state
updateViewerConfig: (config: Partial<ViewerConfig>) => void; updateViewerConfig: (config: Partial<FileContextState['ui']['viewerConfig']>) => void;
// Export configuration // Export configuration
setExportConfig: (config: FileContextState['lastExportConfig']) => void; setExportConfig: (config: FileContextState['ui']['lastExportConfig']) => void;
// Utility
getFileById: (fileId: string) => File | undefined;
getProcessedFileById: (fileId: string) => ProcessedFile | undefined;
getCurrentFile: () => File | undefined;
getCurrentProcessedFile: () => ProcessedFile | undefined;
// Context persistence
saveContext: () => Promise<void>;
loadContext: () => Promise<void>;
resetContext: () => void;
// Navigation guard system // Navigation guard system
setHasUnsavedChanges: (hasChanges: boolean) => void; setHasUnsavedChanges: (hasChanges: boolean) => void;
@ -135,14 +224,24 @@ export interface FileContextActions {
confirmNavigation: () => void; confirmNavigation: () => void;
cancelNavigation: () => void; cancelNavigation: () => void;
// Memory management // Context management
trackBlobUrl: (url: string) => void; resetContext: () => void;
trackPdfDocument: (fileId: string, pdfDoc: any) => void;
cleanupFile: (fileId: string) => Promise<void>;
scheduleCleanup: (fileId: string, delay?: number) => void;
} }
export interface FileContextValue extends FileContextState, FileContextActions {} // 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)[];
}
export interface FileContextProviderProps { export interface FileContextProviderProps {
children: React.ReactNode; children: React.ReactNode;
@ -156,6 +255,45 @@ export interface WithFileContext {
fileContext: FileContextValue; 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
export interface FileContextStateValue {
state: FileContextState;
selectors: FileContextSelectors;
}
export interface FileContextActionsValue {
actions: FileContextActions;
dispatch: (action: FileContextAction) => void;
}
// URL parameter types for deep linking // URL parameter types for deep linking
export interface FileContextUrlParams { export interface FileContextUrlParams {
mode?: ModeType; mode?: ModeType;

View File

@ -50,24 +50,3 @@ export interface Tool {
export type ToolRegistry = Record<string, Tool>; export type ToolRegistry = Record<string, Tool>;
export interface FileSelectionState {
selectedFiles: File[];
maxFiles: MaxFiles;
isToolMode: boolean;
}
export interface FileSelectionActions {
setSelectedFiles: (files: File[]) => void;
setMaxFiles: (maxFiles: MaxFiles) => void;
setIsToolMode: (isToolMode: boolean) => void;
clearSelection: () => void;
}
export interface FileSelectionComputed {
canSelectMore: boolean;
isAtLimit: boolean;
selectionCount: number;
isMultiFileMode: boolean;
}
export interface FileSelectionContextValue extends FileSelectionState, FileSelectionActions, FileSelectionComputed {}