mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-26 14:19:24 +00:00
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:
parent
507ad1dc61
commit
02f4f7abaf
@ -9,7 +9,8 @@
|
||||
"Bash(find:*)",
|
||||
"Bash(npm test)",
|
||||
"Bash(npm test:*)",
|
||||
"Bash(ls:*)"
|
||||
"Bash(ls:*)",
|
||||
"Bash(npm run dev:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
|
@ -391,6 +391,9 @@
|
||||
"title": "Compress",
|
||||
"desc": "Compress PDFs to reduce their file size."
|
||||
},
|
||||
"compress": {
|
||||
"title": "Compress"
|
||||
},
|
||||
"unlockPDFForms": {
|
||||
"title": "Unlock PDF Forms",
|
||||
"desc": "Remove read-only property of form fields in a PDF document."
|
||||
@ -1711,7 +1714,9 @@
|
||||
"uploadFiles": "Upload Files",
|
||||
"noFilesInStorage": "No files available in storage. Upload some files first.",
|
||||
"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": {
|
||||
"title": "Upload PDF Files",
|
||||
|
@ -48,7 +48,11 @@ export class RotatePagesCommand extends PageCommand {
|
||||
return page;
|
||||
});
|
||||
|
||||
this.setPdfDocument({ ...this.pdfDocument, pages: updatedPages });
|
||||
this.setPdfDocument({
|
||||
...this.pdfDocument,
|
||||
pages: updatedPages,
|
||||
totalPages: updatedPages.length
|
||||
});
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
@ -148,7 +152,11 @@ export class MovePagesCommand extends PageCommand {
|
||||
pageNumber: index + 1
|
||||
}));
|
||||
|
||||
this.setPdfDocument({ ...this.pdfDocument, pages: newPages });
|
||||
this.setPdfDocument({
|
||||
...this.pdfDocument,
|
||||
pages: newPages,
|
||||
totalPages: newPages.length
|
||||
});
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
@ -185,7 +193,11 @@ export class ReorderPageCommand extends PageCommand {
|
||||
pageNumber: index + 1
|
||||
}));
|
||||
|
||||
this.setPdfDocument({ ...this.pdfDocument, pages: updatedPages });
|
||||
this.setPdfDocument({
|
||||
...this.pdfDocument,
|
||||
pages: updatedPages,
|
||||
totalPages: updatedPages.length
|
||||
});
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
@ -224,7 +236,11 @@ export class ToggleSplitCommand extends PageCommand {
|
||||
return page;
|
||||
});
|
||||
|
||||
this.setPdfDocument({ ...this.pdfDocument, pages: updatedPages });
|
||||
this.setPdfDocument({
|
||||
...this.pdfDocument,
|
||||
pages: updatedPages,
|
||||
totalPages: updatedPages.length
|
||||
});
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
@ -236,7 +252,11 @@ export class ToggleSplitCommand extends PageCommand {
|
||||
return page;
|
||||
});
|
||||
|
||||
this.setPdfDocument({ ...this.pdfDocument, pages: updatedPages });
|
||||
this.setPdfDocument({
|
||||
...this.pdfDocument,
|
||||
pages: updatedPages,
|
||||
totalPages: updatedPages.length
|
||||
});
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
|
@ -6,9 +6,8 @@ import {
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||
import { useFileContext } from '../../contexts/FileContext';
|
||||
import { useFileSelection } from '../../contexts/FileSelectionContext';
|
||||
import { FileOperation } from '../../types/fileContext';
|
||||
import { useFileContext, useToolFileSelection, useProcessedFiles, useFileState, useFileManagement } from '../../contexts/FileContext';
|
||||
import { FileOperation, createStableFileId } from '../../types/fileContext';
|
||||
import { fileStorage } from '../../services/fileStorage';
|
||||
import { generateThumbnailForFile } from '../../utils/thumbnailUtils';
|
||||
import { zipFileService } from '../../services/zipFileService';
|
||||
@ -54,28 +53,34 @@ const FileEditor = ({
|
||||
return extension ? supportedExtensions.includes(extension) : false;
|
||||
}, [supportedExtensions]);
|
||||
|
||||
// Get file context
|
||||
const fileContext = useFileContext();
|
||||
const {
|
||||
activeFiles,
|
||||
processedFiles,
|
||||
selectedFileIds,
|
||||
setSelectedFiles: setContextSelectedFiles,
|
||||
isProcessing,
|
||||
addFiles,
|
||||
removeFiles,
|
||||
setCurrentView,
|
||||
recordOperation,
|
||||
markOperationApplied
|
||||
} = fileContext;
|
||||
// Use optimized FileContext hooks
|
||||
const { state } = useFileState();
|
||||
const { addFiles, removeFiles } = useFileManagement();
|
||||
const processedFiles = useProcessedFiles(); // Now gets real processed files
|
||||
|
||||
// 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 {
|
||||
selectedFiles: toolSelectedFiles,
|
||||
setSelectedFiles: setToolSelectedFiles,
|
||||
maxFiles,
|
||||
isToolMode
|
||||
} = useFileSelection();
|
||||
} = useToolFileSelection();
|
||||
|
||||
const [files, setFiles] = useState<FileItem[]>([]);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
@ -119,8 +124,8 @@ const FileEditor = ({
|
||||
// Map context selections to local file IDs for UI display
|
||||
const localSelectedIds = files
|
||||
.filter(file => {
|
||||
const fileId = (file.file as any).id || file.name;
|
||||
return contextSelectedIds.includes(fileId);
|
||||
const contextFileId = createStableFileId(file.file);
|
||||
return contextSelectedIds.includes(contextFileId);
|
||||
})
|
||||
.map(file => file.id);
|
||||
|
||||
@ -132,7 +137,7 @@ const FileEditor = ({
|
||||
return {
|
||||
id: sharedFile.id || `file-${Date.now()}-${Math.random()}`,
|
||||
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,
|
||||
size: sharedFile.file?.size || sharedFile.size || 0,
|
||||
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 = {
|
||||
id: `file-${Date.now()}-${Math.random()}`,
|
||||
id: createStableFileId(file), // Use same ID function as context
|
||||
name: file.name,
|
||||
pageCount: processedFile?.totalPages || Math.floor(Math.random() * 20) + 1,
|
||||
pageCount: pageCount,
|
||||
thumbnail,
|
||||
size: file.size,
|
||||
file,
|
||||
@ -290,8 +315,7 @@ const FileEditor = ({
|
||||
}
|
||||
};
|
||||
|
||||
recordOperation(file.name, operation);
|
||||
markOperationApplied(file.name, operationId);
|
||||
// Legacy operation tracking removed
|
||||
|
||||
if (extractionResult.errors.length > 0) {
|
||||
errors.push(...extractionResult.errors);
|
||||
@ -345,8 +369,7 @@ const FileEditor = ({
|
||||
}
|
||||
};
|
||||
|
||||
recordOperation(file.name, operation);
|
||||
markOperationApplied(file.name, operationId);
|
||||
// Legacy operation tracking removed
|
||||
}
|
||||
|
||||
// Add files to context (they will be processed automatically)
|
||||
@ -367,7 +390,7 @@ const FileEditor = ({
|
||||
totalFiles: 0
|
||||
});
|
||||
}
|
||||
}, [addFiles, recordOperation, markOperationApplied]);
|
||||
}, [addFiles]);
|
||||
|
||||
const selectAll = useCallback(() => {
|
||||
setContextSelectedFiles(files.map(f => (f.file as any).id || f.name));
|
||||
@ -397,8 +420,7 @@ const FileEditor = ({
|
||||
}
|
||||
};
|
||||
|
||||
recordOperation(file.name, operation);
|
||||
markOperationApplied(file.name, operationId);
|
||||
// Legacy operation tracking removed
|
||||
});
|
||||
|
||||
// Remove all files from context but keep in storage
|
||||
@ -406,13 +428,13 @@ const FileEditor = ({
|
||||
|
||||
// Clear selections
|
||||
setContextSelectedFiles([]);
|
||||
}, [activeFiles, removeFiles, setContextSelectedFiles, recordOperation, markOperationApplied]);
|
||||
}, [activeFiles, removeFiles, setContextSelectedFiles]);
|
||||
|
||||
const toggleFile = useCallback((fileId: string) => {
|
||||
const targetFile = files.find(f => f.id === fileId);
|
||||
if (!targetFile) return;
|
||||
|
||||
const contextFileId = (targetFile.file as any).id || targetFile.name;
|
||||
const contextFileId = createStableFileId(targetFile.file);
|
||||
const isSelected = contextSelectedIds.includes(contextFileId);
|
||||
|
||||
let newSelection: string[];
|
||||
@ -441,7 +463,7 @@ const FileEditor = ({
|
||||
if (isToolMode || toolMode) {
|
||||
const selectedFiles = files
|
||||
.filter(f => {
|
||||
const fId = (f.file as any).id || f.name;
|
||||
const fId = createStableFileId(f.file);
|
||||
return newSelection.includes(fId);
|
||||
})
|
||||
.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)
|
||||
console.log('Calling removeFiles with:', [fileId]);
|
||||
@ -609,24 +631,20 @@ const FileEditor = ({
|
||||
const safePrev = Array.isArray(prev) ? prev : [];
|
||||
return safePrev.filter(id => id !== fileId);
|
||||
});
|
||||
|
||||
// Mark operation as applied
|
||||
markOperationApplied(fileName, operationId);
|
||||
} else {
|
||||
console.log('File not found for fileId:', fileId);
|
||||
}
|
||||
}, [files, removeFiles, setContextSelectedFiles, recordOperation, markOperationApplied]);
|
||||
}, [files, removeFiles, setContextSelectedFiles]);
|
||||
|
||||
const handleViewFile = useCallback((fileId: string) => {
|
||||
const file = files.find(f => f.id === fileId);
|
||||
if (file) {
|
||||
// Set the file as selected in context and switch to page editor view
|
||||
const contextFileId = (file.file as any).id || file.name;
|
||||
// Set the file as selected in context and switch to viewer for preview
|
||||
const contextFileId = createStableFileId(file.file);
|
||||
setContextSelectedFiles([contextFileId]);
|
||||
setCurrentView('pageEditor');
|
||||
onOpenPageEditor?.(file.file);
|
||||
setCurrentView('viewer');
|
||||
}
|
||||
}, [files, setContextSelectedFiles, setCurrentView, onOpenPageEditor]);
|
||||
}, [files, setContextSelectedFiles, setCurrentView]);
|
||||
|
||||
const handleMergeFromHere = useCallback((fileId: string) => {
|
||||
const startIndex = files.findIndex(f => f.id === fileId);
|
||||
|
@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
|
||||
import { useWorkbenchState, useToolSelection } from '../../contexts/ToolWorkflowContext';
|
||||
import { useFileHandler } from '../../hooks/useFileHandler';
|
||||
import { useFileContext } from '../../contexts/FileContext';
|
||||
import { useFileState, useFileActions } from '../../contexts/FileContext';
|
||||
|
||||
import TopControls from '../shared/TopControls';
|
||||
import FileEditor from '../fileEditor/FileEditor';
|
||||
@ -20,7 +20,10 @@ export default function Workbench() {
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
|
||||
// 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 {
|
||||
previewFile,
|
||||
pageEditorFunctions,
|
||||
@ -47,12 +50,12 @@ export default function Workbench() {
|
||||
handleToolSelect('convert');
|
||||
sessionStorage.removeItem('previousMode');
|
||||
} else {
|
||||
setCurrentView('fileEditor' as any);
|
||||
actions.setMode('fileEditor');
|
||||
}
|
||||
};
|
||||
|
||||
const renderMainContent = () => {
|
||||
if (!activeFiles[0]) {
|
||||
if (activeFiles.length === 0) {
|
||||
return (
|
||||
<LandingPage
|
||||
/>
|
||||
@ -69,11 +72,11 @@ export default function Workbench() {
|
||||
supportedExtensions={selectedTool?.supportedFormats || ["pdf"]}
|
||||
{...(!selectedToolKey && {
|
||||
onOpenPageEditor: (file) => {
|
||||
setCurrentView("pageEditor" as any);
|
||||
actions.setMode("pageEditor");
|
||||
},
|
||||
onMergeFiles: (filesToMerge) => {
|
||||
filesToMerge.forEach(addToActiveFiles);
|
||||
setCurrentView("viewer" as any);
|
||||
actions.setMode("viewer");
|
||||
}
|
||||
})}
|
||||
/>
|
||||
@ -142,7 +145,7 @@ export default function Workbench() {
|
||||
{/* Top Controls */}
|
||||
<TopControls
|
||||
currentView={currentView}
|
||||
setCurrentView={setCurrentView}
|
||||
setCurrentView={actions.setMode}
|
||||
selectedToolKey={selectedToolKey}
|
||||
/>
|
||||
|
||||
|
@ -1,12 +1,11 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Text, Checkbox, Tooltip, ActionIcon, Badge, Modal } from '@mantine/core';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Text, Checkbox, Tooltip, ActionIcon, Badge } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
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 styles from './PageEditor.module.css';
|
||||
import FileOperationHistory from '../history/FileOperationHistory';
|
||||
|
||||
interface FileItem {
|
||||
id: string;
|
||||
@ -65,7 +64,6 @@ const FileThumbnail = ({
|
||||
isSupported = true,
|
||||
}: FileThumbnailProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
@ -75,15 +73,18 @@ const FileThumbnail = ({
|
||||
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 (
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
fileRefs.current.set(file.id, el);
|
||||
} else {
|
||||
fileRefs.current.delete(file.id);
|
||||
}
|
||||
}}
|
||||
ref={refCallback}
|
||||
data-file-id={file.id}
|
||||
data-testid="file-thumbnail"
|
||||
className={`
|
||||
@ -201,7 +202,7 @@ const FileThumbnail = ({
|
||||
zIndex: 3,
|
||||
}}
|
||||
>
|
||||
{file.pageCount} pages
|
||||
{file.pageCount} {file.pageCount === 1 ? 'page' : 'pages'}
|
||||
</Badge>
|
||||
|
||||
{/* Unsupported badge */}
|
||||
@ -286,18 +287,18 @@ const FileThumbnail = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
<Tooltip label="View History">
|
||||
<Tooltip label="Preview File">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c="white"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowHistory(true);
|
||||
onSetStatus(`Viewing history for ${file.name}`);
|
||||
onViewFile(file.id);
|
||||
onSetStatus(`Opening preview for ${file.name}`);
|
||||
}}
|
||||
>
|
||||
<HistoryIcon style={{ fontSize: 20 }} />
|
||||
<PreviewIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
@ -339,20 +340,6 @@ const FileThumbnail = ({
|
||||
</Text>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
@ -5,8 +5,8 @@ import {
|
||||
Stack, Group
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFileContext, useCurrentFile } from "../../contexts/FileContext";
|
||||
import { ViewType, ToolType } from "../../types/fileContext";
|
||||
import { useFileState, useFileActions, useCurrentFile, useProcessedFiles, useFileManagement, useFileSelection } from "../../contexts/FileContext";
|
||||
import { ModeType } from "../../types/fileContext";
|
||||
import { PDFDocument, PDFPage } from "../../types/pageEditor";
|
||||
import { ProcessedFile as EnhancedProcessedFile } from "../../types/processing";
|
||||
import { useUndoRedo } from "../../hooks/useUndoRedo";
|
||||
@ -53,23 +53,22 @@ const PageEditor = ({
|
||||
}: PageEditorProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Get file context
|
||||
const fileContext = useFileContext();
|
||||
// Use optimized FileContext hooks (no infinite loops)
|
||||
const { state } = useFileState();
|
||||
const { actions, dispatch } = useFileActions();
|
||||
const { addFiles, clearAllFiles } = useFileManagement();
|
||||
const { selectedFileIds, selectedPageNumbers, setSelectedFiles, setSelectedPages } = useFileSelection();
|
||||
const { file: currentFile, processedFile: currentProcessedFile } = useCurrentFile();
|
||||
const processedFiles = useProcessedFiles();
|
||||
|
||||
// Use file context state
|
||||
const {
|
||||
activeFiles,
|
||||
processedFiles,
|
||||
selectedPageNumbers,
|
||||
setSelectedPages,
|
||||
updateProcessedFile,
|
||||
setHasUnsavedChanges,
|
||||
hasUnsavedChanges,
|
||||
isProcessing: globalProcessing,
|
||||
processingProgress,
|
||||
clearAllFiles
|
||||
} = fileContext;
|
||||
// Extract needed state values (use stable memo)
|
||||
const activeFiles = useMemo(() =>
|
||||
state.files.ids.map(id => state.files.byId[id]?.file).filter(Boolean),
|
||||
[state.files.ids, state.files.byId]
|
||||
);
|
||||
const globalProcessing = state.ui.isProcessing;
|
||||
const processingProgress = state.ui.processingProgress;
|
||||
const hasUnsavedChanges = state.ui.hasUnsavedChanges;
|
||||
|
||||
// Edit state management
|
||||
const [editedDocument, setEditedDocument] = useState<PDFDocument | null>(null);
|
||||
@ -78,25 +77,53 @@ const PageEditor = ({
|
||||
const [foundDraft, setFoundDraft] = useState<any>(null);
|
||||
const autoSaveTimer = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Simple computed document from processed files (no caching needed)
|
||||
const mergedPdfDocument = useMemo(() => {
|
||||
if (activeFiles.length === 0) return null;
|
||||
/**
|
||||
* Create stable files signature to prevent infinite re-computation.
|
||||
* This signature only changes when files are actually added/removed or processing state changes.
|
||||
* Using this instead of direct file arrays prevents unnecessary re-renders.
|
||||
*/
|
||||
const filesSignature = useMemo(() => {
|
||||
const fileIds = state.files.ids.sort(); // Stable order
|
||||
return fileIds
|
||||
.map(id => {
|
||||
const record = state.files.byId[id];
|
||||
if (!record) return `${id}:missing`;
|
||||
const hasProcessed = record.processedFile ? 'processed' : 'pending';
|
||||
return `${id}:${record.name}:${record.size}:${record.lastModified}:${hasProcessed}`;
|
||||
})
|
||||
.join('|');
|
||||
}, [state.files.ids, state.files.byId]);
|
||||
|
||||
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
|
||||
const processedFile = processedFiles.get(activeFiles[0]);
|
||||
if (!processedFile) return null;
|
||||
const file = currentFiles[0];
|
||||
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 {
|
||||
id: processedFile.id,
|
||||
name: activeFiles[0].name,
|
||||
file: activeFiles[0],
|
||||
pages: processedFile.pages.map(page => ({
|
||||
...page,
|
||||
rotation: page.rotation || 0,
|
||||
splitBefore: page.splitBefore || false
|
||||
})),
|
||||
totalPages: processedFile.totalPages
|
||||
name: file.name,
|
||||
file: file,
|
||||
pages: pages,
|
||||
totalPages: pages.length // Always use actual pages array length
|
||||
};
|
||||
} else {
|
||||
// Multiple files - merge them
|
||||
@ -104,8 +131,12 @@ const PageEditor = ({
|
||||
let totalPages = 0;
|
||||
const filenames: string[] = [];
|
||||
|
||||
activeFiles.forEach((file, i) => {
|
||||
const processedFile = processedFiles.get(file);
|
||||
currentFiles.forEach((file, i) => {
|
||||
const record = state.files.ids
|
||||
.map(id => state.files.byId[id])
|
||||
.find(r => r?.file === file);
|
||||
|
||||
const processedFile = record?.processedFile;
|
||||
if (processedFile) {
|
||||
filenames.push(file.name.replace(/\.pdf$/i, ''));
|
||||
|
||||
@ -124,17 +155,19 @@ const PageEditor = ({
|
||||
}
|
||||
});
|
||||
|
||||
if (allPages.length === 0) return null;
|
||||
if (allPages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: `merged-${Date.now()}`,
|
||||
name: filenames.join(' + '),
|
||||
file: activeFiles[0], // Use first file as reference
|
||||
file: currentFiles[0], // Use first file as reference
|
||||
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
|
||||
const displayDocument = editedDocument || mergedPdfDocument;
|
||||
@ -144,6 +177,7 @@ const PageEditor = ({
|
||||
|
||||
// Page editor state (use context for selectedPages)
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [csvInput, setCsvInput] = useState<string>("");
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
|
||||
@ -188,16 +222,20 @@ const PageEditor = ({
|
||||
}
|
||||
|
||||
// Add files to context
|
||||
await fileContext.addFiles(uploadedFiles);
|
||||
await addFiles(uploadedFiles);
|
||||
setStatus(`Added ${uploadedFiles.length} file(s) for processing`);
|
||||
}, [fileContext]);
|
||||
}, [addFiles]);
|
||||
|
||||
|
||||
// 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 [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)
|
||||
const {
|
||||
@ -208,21 +246,16 @@ const PageEditor = ({
|
||||
destroyThumbnails
|
||||
} = useThumbnailGeneration();
|
||||
|
||||
// Start thumbnail generation process (separate from document loading)
|
||||
// Start thumbnail generation process (guards against re-entry)
|
||||
const startThumbnailGeneration = useCallback(() => {
|
||||
console.log('🎬 PageEditor: startThumbnailGeneration called');
|
||||
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');
|
||||
if (!mergedPdfDocument || activeFiles.length !== 1 || thumbnailGenerationStarted.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const file = activeFiles[0];
|
||||
const totalPages = mergedPdfDocument.totalPages;
|
||||
const totalPages = mergedPdfDocument.pages.length;
|
||||
|
||||
console.log('🎬 PageEditor: Starting thumbnail generation for', totalPages, 'pages');
|
||||
setThumbnailGenerationStarted(true);
|
||||
thumbnailGenerationStarted.current = true;
|
||||
|
||||
// Run everything asynchronously to avoid blocking the main thread
|
||||
setTimeout(async () => {
|
||||
@ -237,11 +270,8 @@ const PageEditor = ({
|
||||
return !page?.thumbnail; // Only generate for pages without thumbnails
|
||||
});
|
||||
|
||||
console.log(`🎬 PageEditor: Generating thumbnails for ${pageNumbers.length} pages (out of ${totalPages} total):`, pageNumbers.slice(0, 10), pageNumbers.length > 10 ? '...' : '');
|
||||
|
||||
// If no pages need thumbnails, we're done
|
||||
if (pageNumbers.length === 0) {
|
||||
console.log('🎬 PageEditor: All pages already have thumbnails, no generation needed');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -258,78 +288,59 @@ const PageEditor = ({
|
||||
batchSize: 15, // Smaller batches per worker for smoother UI
|
||||
parallelBatches: 3 // Use 3 Web Workers in parallel
|
||||
},
|
||||
// Progress callback (throttled for better performance)
|
||||
// Progress callback for thumbnail updates
|
||||
(progress) => {
|
||||
console.log(`🎬 PageEditor: Progress - ${progress.completed}/${progress.total} pages, ${progress.thumbnails.length} new thumbnails`);
|
||||
// Batch process thumbnails to reduce main thread work
|
||||
requestAnimationFrame(() => {
|
||||
progress.thumbnails.forEach(({ pageNumber, thumbnail }) => {
|
||||
// Check cache first, then send thumbnail
|
||||
const pageId = `${file.name}-page-${pageNumber}`;
|
||||
const cached = getThumbnailFromCache(pageId);
|
||||
|
||||
if (!cached) {
|
||||
// Cache and send to component
|
||||
addThumbnailToCache(pageId, thumbnail);
|
||||
|
||||
window.dispatchEvent(new CustomEvent('thumbnailReady', {
|
||||
detail: { pageNumber, thumbnail, pageId }
|
||||
}));
|
||||
console.log(`✓ PageEditor: Dispatched thumbnail for page ${pageNumber}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Handle completion properly
|
||||
// Handle completion
|
||||
generationPromise
|
||||
.then((allThumbnails) => {
|
||||
console.log(`✅ PageEditor: Thumbnail generation completed! Generated ${allThumbnails.length} thumbnails`);
|
||||
// Don't reset thumbnailGenerationStarted here - let it stay true to prevent restarts
|
||||
.then(() => {
|
||||
// Keep thumbnailGenerationStarted as true to prevent restarts
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('✗ PageEditor: Web Worker thumbnail generation failed:', error);
|
||||
setThumbnailGenerationStarted(false);
|
||||
console.error('PageEditor: Thumbnail generation failed:', error);
|
||||
thumbnailGenerationStarted.current = false;
|
||||
});
|
||||
|
||||
} catch (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
|
||||
}, [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(() => {
|
||||
console.log('🎬 PageEditor: Thumbnail generation effect triggered');
|
||||
console.log('🎬 Conditions - mergedPdfDocument:', !!mergedPdfDocument, 'started:', thumbnailGenerationStarted);
|
||||
|
||||
if (mergedPdfDocument && !thumbnailGenerationStarted) {
|
||||
// Check if ALL pages already have thumbnails from processed files
|
||||
if (mergedPdfDocument && !thumbnailGenerationStarted.current) {
|
||||
// Check if ALL pages already have thumbnails
|
||||
const totalPages = mergedPdfDocument.pages.length;
|
||||
const pagesWithThumbnails = mergedPdfDocument.pages.filter(page => page.thumbnail).length;
|
||||
const hasAllThumbnails = pagesWithThumbnails === totalPages;
|
||||
|
||||
console.log('🎬 PageEditor: Thumbnail status:', {
|
||||
totalPages,
|
||||
pagesWithThumbnails,
|
||||
hasAllThumbnails,
|
||||
missingThumbnails: totalPages - pagesWithThumbnails
|
||||
});
|
||||
|
||||
if (hasAllThumbnails) {
|
||||
console.log('🎬 PageEditor: Skipping generation - all thumbnails already exist');
|
||||
return; // Skip generation if ALL thumbnails already exist
|
||||
return; // Skip generation if thumbnails exist
|
||||
}
|
||||
|
||||
console.log('🎬 PageEditor: Some thumbnails missing, proceeding with generation');
|
||||
// Small delay to let document render, then start thumbnail generation
|
||||
console.log('🎬 PageEditor: Scheduling thumbnail generation in 500ms');
|
||||
// Small delay to let document render
|
||||
const timer = setTimeout(startThumbnailGeneration, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [mergedPdfDocument, startThumbnailGeneration, thumbnailGenerationStarted]);
|
||||
}, [filesSignature, startThumbnailGeneration]);
|
||||
|
||||
// Cleanup shared PDF instance when component unmounts (but preserve cache)
|
||||
useEffect(() => {
|
||||
@ -338,11 +349,9 @@ const PageEditor = ({
|
||||
sharedPdfInstance.destroy();
|
||||
setSharedPdfInstance(null);
|
||||
}
|
||||
setThumbnailGenerationStarted(false);
|
||||
// DON'T stop generation on file changes - preserve cache for view switching
|
||||
// stopGeneration();
|
||||
thumbnailGenerationStarted.current = false;
|
||||
};
|
||||
}, [sharedPdfInstance]); // Only depend on PDF instance, not activeFiles
|
||||
}, [sharedPdfInstance]);
|
||||
|
||||
// Clear selections when files change
|
||||
useEffect(() => {
|
||||
@ -432,14 +441,14 @@ const PageEditor = ({
|
||||
ranges.forEach(range => {
|
||||
if (range.includes('-')) {
|
||||
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) {
|
||||
pageNumbers.push(i);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const pageNum = parseInt(range);
|
||||
if (pageNum > 0 && pageNum <= mergedPdfDocument.totalPages) {
|
||||
if (pageNum > 0 && pageNum <= mergedPdfDocument.pages.length) {
|
||||
pageNumbers.push(pageNum);
|
||||
}
|
||||
}
|
||||
@ -527,25 +536,31 @@ const PageEditor = ({
|
||||
|
||||
// Update local edit state for immediate visual feedback
|
||||
setEditedDocument(updatedDoc);
|
||||
setHasUnsavedChanges(true); // Use global state
|
||||
actions.setHasUnsavedChanges(true); // Use actions from context
|
||||
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) {
|
||||
clearTimeout(autoSaveTimer.current);
|
||||
}
|
||||
|
||||
autoSaveTimer.current = setTimeout(() => {
|
||||
autoSaveTimer.current = setTimeout(async () => {
|
||||
if (hasUnsavedDraft) {
|
||||
saveDraftToIndexedDB(updatedDoc);
|
||||
setHasUnsavedDraft(false); // Mark draft as saved
|
||||
try {
|
||||
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
|
||||
|
||||
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) => {
|
||||
try {
|
||||
const draftKey = `draft-${doc.id || 'merged'}`;
|
||||
@ -555,41 +570,124 @@ const PageEditor = ({
|
||||
originalFiles: activeFiles.map(f => f.name)
|
||||
};
|
||||
|
||||
// Save to 'pdf-drafts' store in IndexedDB
|
||||
const request = indexedDB.open('stirling-pdf-drafts', 1);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains('drafts')) {
|
||||
db.createObjectStore('drafts');
|
||||
}
|
||||
};
|
||||
// Robust IndexedDB initialization with proper error handling
|
||||
const dbRequest = indexedDB.open('stirling-pdf-drafts', 1);
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
dbRequest.onerror = () => {
|
||||
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) {
|
||||
console.warn('Failed to auto-save draft:', error);
|
||||
console.warn('Draft save failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [activeFiles]);
|
||||
|
||||
// Clean up draft from IndexedDB
|
||||
// Enhanced draft cleanup with proper IndexedDB handling
|
||||
const cleanupDraft = useCallback(async () => {
|
||||
try {
|
||||
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) {
|
||||
console.warn('Failed to cleanup draft:', error);
|
||||
console.warn('Draft cleanup failed:', error);
|
||||
// Don't throw - cleanup failure shouldn't break the app
|
||||
}
|
||||
}, [mergedPdfDocument]);
|
||||
|
||||
@ -615,7 +713,17 @@ const PageEditor = ({
|
||||
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) {
|
||||
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
|
||||
setTimeout(() => {
|
||||
setEditedDocument(null);
|
||||
setHasUnsavedChanges(false);
|
||||
actions.setHasUnsavedChanges(false);
|
||||
setHasUnsavedDraft(false);
|
||||
cleanupDraft();
|
||||
setStatus('Changes applied successfully');
|
||||
@ -635,7 +743,7 @@ const PageEditor = ({
|
||||
console.error('Failed to apply changes:', error);
|
||||
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) => {
|
||||
if (!displayDocument || isAnimating) return;
|
||||
@ -941,53 +1049,60 @@ const PageEditor = ({
|
||||
|
||||
const closePdf = useCallback(() => {
|
||||
// Use global navigation guard system
|
||||
fileContext.requestNavigation(() => {
|
||||
actions.requestNavigation(() => {
|
||||
clearAllFiles(); // This now handles all cleanup centrally (including merged docs)
|
||||
setSelectedPages([]);
|
||||
});
|
||||
}, [fileContext, clearAllFiles, setSelectedPages]);
|
||||
}, [actions, clearAllFiles, setSelectedPages]);
|
||||
|
||||
// PageEditorControls needs onExportSelected and onExportAll
|
||||
const onExportSelected = useCallback(() => showExportPreview(true), [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(() => {
|
||||
if (onFunctionsReady) {
|
||||
onFunctionsReady({
|
||||
handleUndo,
|
||||
handleRedo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
handleRotate,
|
||||
handleDelete,
|
||||
handleSplit,
|
||||
showExportPreview,
|
||||
onExportSelected,
|
||||
onExportAll,
|
||||
exportLoading,
|
||||
selectionMode,
|
||||
selectedPages: selectedPageNumbers,
|
||||
closePdf,
|
||||
});
|
||||
const stableFunctions = {
|
||||
get handleUndo() { return pageEditorFunctionsRef.current.handleUndo; },
|
||||
get handleRedo() { return pageEditorFunctionsRef.current.handleRedo; },
|
||||
get canUndo() { return pageEditorFunctionsRef.current.canUndo; },
|
||||
get canRedo() { return pageEditorFunctionsRef.current.canRedo; },
|
||||
get handleRotate() { return pageEditorFunctionsRef.current.handleRotate; },
|
||||
get handleDelete() { return pageEditorFunctionsRef.current.handleDelete; },
|
||||
get handleSplit() { return pageEditorFunctionsRef.current.handleSplit; },
|
||||
get showExportPreview() { return pageEditorFunctionsRef.current.showExportPreview; },
|
||||
get onExportSelected() { return pageEditorFunctionsRef.current.onExportSelected; },
|
||||
get onExportAll() { return pageEditorFunctionsRef.current.onExportAll; },
|
||||
get exportLoading() { return pageEditorFunctionsRef.current.exportLoading; },
|
||||
get selectionMode() { return pageEditorFunctionsRef.current.selectionMode; },
|
||||
get selectedPages() { return pageEditorFunctionsRef.current.selectedPages; },
|
||||
get closePdf() { return pageEditorFunctionsRef.current.closePdf; },
|
||||
};
|
||||
onFunctionsReady(stableFunctions);
|
||||
}
|
||||
}, [
|
||||
onFunctionsReady,
|
||||
handleUndo,
|
||||
handleRedo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
handleRotate,
|
||||
handleDelete,
|
||||
handleSplit,
|
||||
showExportPreview,
|
||||
onExportSelected,
|
||||
onExportAll,
|
||||
exportLoading,
|
||||
selectionMode,
|
||||
selectedPageNumbers,
|
||||
closePdf
|
||||
]);
|
||||
}, [onFunctionsReady]);
|
||||
|
||||
// Show loading or empty state instead of blocking
|
||||
const showLoading = !mergedPdfDocument && (globalProcessing || activeFiles.length > 0);
|
||||
@ -1006,38 +1121,96 @@ const PageEditor = ({
|
||||
}
|
||||
}, [editedDocument, applyChanges, handleExport]);
|
||||
|
||||
// Check for existing drafts
|
||||
// Enhanced draft checking with proper IndexedDB handling
|
||||
const checkForDrafts = useCallback(async () => {
|
||||
if (!mergedPdfDocument) return;
|
||||
|
||||
try {
|
||||
const draftKey = `draft-${mergedPdfDocument.id || 'merged'}`;
|
||||
const request = indexedDB.open('stirling-pdf-drafts', 1);
|
||||
const dbRequest = indexedDB.open('stirling-pdf-drafts', 1);
|
||||
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains('drafts')) return;
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
dbRequest.onerror = () => {
|
||||
console.warn('Failed to open draft database for checking:', dbRequest.error);
|
||||
resolve(); // Don't fail if draft checking fails
|
||||
};
|
||||
|
||||
const transaction = db.transaction('drafts', 'readonly');
|
||||
const store = transaction.objectStore('drafts');
|
||||
const getRequest = store.get(draftKey);
|
||||
dbRequest.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
|
||||
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) {
|
||||
setFoundDraft(draft);
|
||||
setShowResumeModal(true);
|
||||
}
|
||||
// Create object store if it doesn't exist
|
||||
if (!db.objectStoreNames.contains('drafts')) {
|
||||
const store = db.createObjectStore('drafts');
|
||||
console.log('Created drafts object store during check');
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
dbRequest.onsuccess = () => {
|
||||
const db = dbRequest.result;
|
||||
|
||||
// Check if object store exists
|
||||
if (!db.objectStoreNames.contains('drafts')) {
|
||||
console.log('No drafts object store found, no drafts to check');
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const transaction = db.transaction('drafts', 'readonly');
|
||||
const store = transaction.objectStore('drafts');
|
||||
|
||||
transaction.onerror = () => {
|
||||
console.warn('Draft check transaction failed:', transaction.error);
|
||||
resolve(); // Don't fail if checking fails
|
||||
};
|
||||
|
||||
const getRequest = store.get(draftKey);
|
||||
|
||||
getRequest.onerror = () => {
|
||||
console.warn('Failed to get draft:', getRequest.error);
|
||||
resolve(); // Don't fail if get fails
|
||||
};
|
||||
|
||||
getRequest.onsuccess = () => {
|
||||
const draft = getRequest.result;
|
||||
if (draft && draft.timestamp) {
|
||||
// Check if draft is recent (within last 24 hours)
|
||||
const draftAge = Date.now() - draft.timestamp;
|
||||
const twentyFourHours = 24 * 60 * 60 * 1000;
|
||||
|
||||
if (draftAge < twentyFourHours) {
|
||||
console.log('Found recent draft, showing resume modal');
|
||||
setFoundDraft(draft);
|
||||
setShowResumeModal(true);
|
||||
} else {
|
||||
console.log('Draft found but too old, cleaning up');
|
||||
// Clean up old draft
|
||||
try {
|
||||
const cleanupTransaction = db.transaction('drafts', 'readwrite');
|
||||
const cleanupStore = cleanupTransaction.objectStore('drafts');
|
||||
cleanupStore.delete(draftKey);
|
||||
} catch (cleanupError) {
|
||||
console.warn('Failed to cleanup old draft:', cleanupError);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('No draft found');
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.warn('Draft check transaction creation failed:', error);
|
||||
resolve(); // Don't fail if transaction creation fails
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.warn('Failed to check for drafts:', error);
|
||||
console.warn('Draft check failed:', error);
|
||||
// Don't throw - draft checking failure shouldn't break the app
|
||||
}
|
||||
}, [mergedPdfDocument]);
|
||||
|
||||
@ -1045,12 +1218,12 @@ const PageEditor = ({
|
||||
const resumeWork = useCallback(() => {
|
||||
if (foundDraft && foundDraft.document) {
|
||||
setEditedDocument(foundDraft.document);
|
||||
setHasUnsavedChanges(true);
|
||||
actions.setHasUnsavedChanges(true); // Use context action
|
||||
setFoundDraft(null);
|
||||
setShowResumeModal(false);
|
||||
setStatus('Resumed previous work');
|
||||
}
|
||||
}, [foundDraft]);
|
||||
}, [foundDraft, actions]);
|
||||
|
||||
// Start fresh (ignore draft)
|
||||
const startFresh = useCallback(() => {
|
||||
@ -1065,8 +1238,6 @@ const PageEditor = ({
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
console.log('PageEditor unmounting - cleaning up resources');
|
||||
|
||||
// Clear auto-save timer
|
||||
if (autoSaveTimer.current) {
|
||||
clearTimeout(autoSaveTimer.current);
|
||||
@ -1342,7 +1513,7 @@ const PageEditor = ({
|
||||
loading={exportLoading}
|
||||
onClick={() => {
|
||||
setShowExportModal(false);
|
||||
const selectedOnly = exportPreview.pageCount < (mergedPdfDocument?.totalPages || 0);
|
||||
const selectedOnly = exportPreview.pageCount < (mergedPdfDocument?.pages.length || 0);
|
||||
handleExport(selectedOnly);
|
||||
}}
|
||||
>
|
||||
@ -1408,6 +1579,17 @@ const PageEditor = ({
|
||||
{status}
|
||||
</Notification>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Notification
|
||||
color="red"
|
||||
mt="md"
|
||||
onClose={() => setError(null)}
|
||||
style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }}
|
||||
>
|
||||
{error}
|
||||
</Notification>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Modal, Text, Button, Group, Stack } from '@mantine/core';
|
||||
import { useFileContext } from '../../contexts/FileContext';
|
||||
import { useFileState, useFileActions } from '../../contexts/FileContext';
|
||||
|
||||
interface NavigationWarningModalProps {
|
||||
onApplyAndContinue?: () => Promise<void>;
|
||||
@ -11,37 +11,34 @@ const NavigationWarningModal = ({
|
||||
onApplyAndContinue,
|
||||
onExportAndContinue
|
||||
}: NavigationWarningModalProps) => {
|
||||
const {
|
||||
showNavigationWarning,
|
||||
hasUnsavedChanges,
|
||||
confirmNavigation,
|
||||
cancelNavigation,
|
||||
setHasUnsavedChanges
|
||||
} = useFileContext();
|
||||
const { state } = useFileState();
|
||||
const { actions } = useFileActions();
|
||||
const showNavigationWarning = state.ui.showNavigationWarning;
|
||||
const hasUnsavedChanges = state.ui.hasUnsavedChanges;
|
||||
|
||||
const handleKeepWorking = () => {
|
||||
cancelNavigation();
|
||||
actions.cancelNavigation();
|
||||
};
|
||||
|
||||
const handleDiscardChanges = () => {
|
||||
setHasUnsavedChanges(false);
|
||||
confirmNavigation();
|
||||
actions.setHasUnsavedChanges(false);
|
||||
actions.confirmNavigation();
|
||||
};
|
||||
|
||||
const handleApplyAndContinue = async () => {
|
||||
if (onApplyAndContinue) {
|
||||
await onApplyAndContinue();
|
||||
}
|
||||
setHasUnsavedChanges(false);
|
||||
confirmNavigation();
|
||||
actions.setHasUnsavedChanges(false);
|
||||
actions.confirmNavigation();
|
||||
};
|
||||
|
||||
const handleExportAndContinue = async () => {
|
||||
if (onExportAndContinue) {
|
||||
await onExportAndContinue();
|
||||
}
|
||||
setHasUnsavedChanges(false);
|
||||
confirmNavigation();
|
||||
actions.setHasUnsavedChanges(false);
|
||||
actions.confirmNavigation();
|
||||
};
|
||||
|
||||
if (!hasUnsavedChanges) {
|
||||
|
@ -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 { useRainbowThemeContext } from "./RainbowThemeProvider";
|
||||
import LanguageSelector from "./LanguageSelector";
|
||||
@ -10,50 +10,18 @@ import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import EditNoteIcon from "@mui/icons-material/EditNote";
|
||||
import FolderIcon from "@mui/icons-material/Folder";
|
||||
import { Group } from "@mantine/core";
|
||||
import { ModeType } from '../../types/fileContext';
|
||||
|
||||
// This will be created inside the component to access switchingTo
|
||||
const createViewOptions = (switchingTo: string | null) => [
|
||||
{
|
||||
label: (
|
||||
<Group gap={5}>
|
||||
{switchingTo === "viewer" ? (
|
||||
<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",
|
||||
},
|
||||
];
|
||||
// Stable view option objects that don't recreate on every render
|
||||
const VIEW_OPTIONS_BASE = [
|
||||
{ value: "viewer", icon: VisibilityIcon },
|
||||
{ value: "pageEditor", icon: EditNoteIcon },
|
||||
{ value: "fileEditor", icon: FolderIcon },
|
||||
] as const;
|
||||
|
||||
interface TopControlsProps {
|
||||
currentView: string;
|
||||
setCurrentView: (view: string) => void;
|
||||
currentView: ModeType;
|
||||
setCurrentView: (view: ModeType) => void;
|
||||
selectedToolKey?: string | null;
|
||||
}
|
||||
|
||||
@ -68,6 +36,9 @@ const TopControls = ({
|
||||
const isToolSelected = selectedToolKey !== null;
|
||||
|
||||
const handleViewChange = useCallback((view: string) => {
|
||||
// Guard against redundant changes
|
||||
if (view === currentView) return;
|
||||
|
||||
// Show immediate feedback
|
||||
setSwitchingTo(view);
|
||||
|
||||
@ -75,13 +46,28 @@ const TopControls = ({
|
||||
requestAnimationFrame(() => {
|
||||
// Give the spinner one more frame to show
|
||||
requestAnimationFrame(() => {
|
||||
setCurrentView(view);
|
||||
setCurrentView(view as ModeType);
|
||||
|
||||
// Clear the loading state after view change completes
|
||||
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 = () => {
|
||||
if (isRainbowMode) return <AutoAwesomeIcon className={rainbowStyles.rainbowText} />;
|
||||
@ -117,7 +103,7 @@ const TopControls = ({
|
||||
{!isToolSelected && (
|
||||
<div className="flex justify-center items-center h-full pointer-events-auto">
|
||||
<SegmentedControl
|
||||
data={createViewOptions(switchingTo)}
|
||||
data={viewOptions}
|
||||
value={currentView}
|
||||
onChange={handleViewChange}
|
||||
color="blue"
|
||||
|
@ -4,8 +4,8 @@ import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMultipleEndpointsEnabled } from "../../../hooks/useEndpointConfig";
|
||||
import { isImageFormat, isWebFormat } from "../../../utils/convertUtils";
|
||||
import { useFileSelectionActions } from "../../../contexts/FileSelectionContext";
|
||||
import { useFileContext } from "../../../contexts/FileContext";
|
||||
import { useToolFileSelection } from "../../../contexts/FileContext";
|
||||
import { useFileState } from "../../../contexts/FileContext";
|
||||
import { detectFileExtension } from "../../../utils/fileUtils";
|
||||
import GroupedFormatDropdown from "./GroupedFormatDropdown";
|
||||
import ConvertToImageSettings from "./ConvertToImageSettings";
|
||||
@ -40,8 +40,9 @@ const ConvertSettings = ({
|
||||
const { t } = useTranslation();
|
||||
const theme = useMantineTheme();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const { setSelectedFiles } = useFileSelectionActions();
|
||||
const { activeFiles, setSelectedFiles: setContextSelectedFiles } = useFileContext();
|
||||
const { setSelectedFiles } = useToolFileSelection();
|
||||
const { state } = useFileState();
|
||||
const activeFiles = state.files.ids;
|
||||
|
||||
const allEndpoints = useMemo(() => {
|
||||
const endpoints = new Set<string>();
|
||||
@ -92,7 +93,7 @@ const ConvertSettings = ({
|
||||
}
|
||||
|
||||
return baseOptions;
|
||||
}, [getAvailableToExtensions, endpointStatus, parameters.fromExtension]);
|
||||
}, [parameters.fromExtension, endpointStatus]);
|
||||
|
||||
// Enhanced TO options with endpoint availability
|
||||
const enhancedToOptions = useMemo(() => {
|
||||
@ -103,7 +104,7 @@ const ConvertSettings = ({
|
||||
...option,
|
||||
enabled: isConversionAvailable(parameters.fromExtension, option.value)
|
||||
}));
|
||||
}, [parameters.fromExtension, getAvailableToExtensions, endpointStatus]);
|
||||
}, [parameters.fromExtension, endpointStatus]);
|
||||
|
||||
const resetParametersToDefaults = () => {
|
||||
onParameterChange('imageOptions', {
|
||||
@ -134,7 +135,8 @@ const ConvertSettings = ({
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
if (extension === 'any') {
|
||||
@ -149,8 +151,6 @@ const ConvertSettings = ({
|
||||
|
||||
const updateFileSelection = (files: File[]) => {
|
||||
setSelectedFiles(files);
|
||||
const fileIds = files.map(file => (file as any).id || file.name);
|
||||
setContextSelectedFiles(fileIds);
|
||||
};
|
||||
|
||||
const handleFromExtensionChange = (value: string) => {
|
||||
|
@ -176,6 +176,10 @@ const Viewer = ({
|
||||
const [zoom, setZoom] = useState(1); // 1 = 100%
|
||||
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
|
||||
const file0WithUrl = useFileWithUrl(activeFiles[0]);
|
||||
@ -499,7 +503,7 @@ const Viewer = ({
|
||||
isFirst={i === 0}
|
||||
renderPage={renderPage}
|
||||
pageImages={pageImages}
|
||||
setPageRef={(index, ref) => { pageRefs.current[index] = ref; }}
|
||||
setPageRef={setPageRef}
|
||||
/>
|
||||
{i * 2 + 1 < numPages && (
|
||||
<LazyPageImage
|
||||
@ -509,7 +513,7 @@ const Viewer = ({
|
||||
isFirst={i === 0}
|
||||
renderPage={renderPage}
|
||||
pageImages={pageImages}
|
||||
setPageRef={(index, ref) => { pageRefs.current[index] = ref; }}
|
||||
setPageRef={setPageRef}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
@ -523,7 +527,7 @@ const Viewer = ({
|
||||
isFirst={idx === 0}
|
||||
renderPage={renderPage}
|
||||
pageImages={pageImages}
|
||||
setPageRef={(index, ref) => { pageRefs.current[index] = ref; }}
|
||||
setPageRef={setPageRef}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -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 { StoredFile } from '../services/fileStorage';
|
||||
|
||||
@ -168,7 +168,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const contextValue: FileManagerContextValue = {
|
||||
const contextValue: FileManagerContextValue = useMemo(() => ({
|
||||
// State
|
||||
activeSource,
|
||||
selectedFileIds,
|
||||
@ -191,7 +191,25 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
recentFiles,
|
||||
isFileSupported,
|
||||
modalHeight,
|
||||
};
|
||||
}), [
|
||||
activeSource,
|
||||
selectedFileIds,
|
||||
searchTerm,
|
||||
selectedFiles,
|
||||
filteredFiles,
|
||||
fileInputRef,
|
||||
handleSourceChange,
|
||||
handleLocalFileClick,
|
||||
handleFileSelect,
|
||||
handleFileRemove,
|
||||
handleFileDoubleClick,
|
||||
handleOpenFiles,
|
||||
handleSearchChange,
|
||||
handleFileInputChange,
|
||||
recentFiles,
|
||||
isFileSupported,
|
||||
modalHeight,
|
||||
]);
|
||||
|
||||
return (
|
||||
<FileManagerContext.Provider value={contextValue}>
|
||||
|
@ -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 };
|
||||
}
|
@ -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';
|
||||
|
||||
interface FilesModalContextType {
|
||||
@ -41,7 +41,7 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch
|
||||
setOnModalClose(() => callback);
|
||||
}, []);
|
||||
|
||||
const contextValue: FilesModalContextType = {
|
||||
const contextValue: FilesModalContextType = useMemo(() => ({
|
||||
isFilesModalOpen,
|
||||
openFilesModal,
|
||||
closeFilesModal,
|
||||
@ -49,7 +49,15 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch
|
||||
onFilesSelect: handleFilesSelect,
|
||||
onModalClose,
|
||||
setOnModalClose: setModalCloseCallback,
|
||||
};
|
||||
}), [
|
||||
isFilesModalOpen,
|
||||
openFilesModal,
|
||||
closeFilesModal,
|
||||
handleFileSelect,
|
||||
handleFilesSelect,
|
||||
onModalClose,
|
||||
setModalCloseCallback,
|
||||
]);
|
||||
|
||||
return (
|
||||
<FilesModalContext.Provider value={contextValue}>
|
||||
|
@ -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';
|
||||
|
||||
const SidebarContext = createContext<SidebarContextValue | undefined>(undefined);
|
||||
@ -12,24 +12,24 @@ export function SidebarProvider({ children }: SidebarProviderProps) {
|
||||
const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker');
|
||||
const [readerMode, setReaderMode] = useState(false);
|
||||
|
||||
const sidebarState: SidebarState = {
|
||||
const sidebarState: SidebarState = useMemo(() => ({
|
||||
sidebarsVisible,
|
||||
leftPanelView,
|
||||
readerMode,
|
||||
};
|
||||
}), [sidebarsVisible, leftPanelView, readerMode]);
|
||||
|
||||
const sidebarRefs: SidebarRefs = {
|
||||
const sidebarRefs: SidebarRefs = useMemo(() => ({
|
||||
quickAccessRef,
|
||||
toolPanelRef,
|
||||
};
|
||||
}), [quickAccessRef, toolPanelRef]);
|
||||
|
||||
const contextValue: SidebarContextValue = {
|
||||
const contextValue: SidebarContextValue = useMemo(() => ({
|
||||
sidebarState,
|
||||
sidebarRefs,
|
||||
setSidebarsVisible,
|
||||
setLeftPanelView,
|
||||
setReaderMode,
|
||||
};
|
||||
}), [sidebarState, sidebarRefs, setSidebarsVisible, setLeftPanelView, setReaderMode]);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFileContext } from '../../../contexts/FileContext';
|
||||
import { useFileActions } from '../../../contexts/FileContext';
|
||||
import { useToolState, type ProcessingProgress } from './useToolState';
|
||||
import { useToolApiCalls, type ApiCallsConfig } from './useToolApiCalls';
|
||||
import { useToolResources } from './useToolResources';
|
||||
@ -112,7 +112,11 @@ export const useToolOperation = <TParams = void>(
|
||||
config: ToolOperationConfig<TParams>
|
||||
): ToolOperationHook<TParams> => {
|
||||
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
|
||||
const { state, actions } = useToolState();
|
||||
@ -215,7 +219,7 @@ export const useToolOperation = <TParams = void>(
|
||||
actions.setDownloadInfo(downloadInfo.url, downloadInfo.filename);
|
||||
|
||||
// Add to file context
|
||||
await addFiles(processedFiles);
|
||||
await fileActions.addFiles(processedFiles);
|
||||
|
||||
markOperationApplied(fileId, operationId);
|
||||
}
|
||||
@ -229,7 +233,7 @@ export const useToolOperation = <TParams = void>(
|
||||
actions.setLoading(false);
|
||||
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(() => {
|
||||
cancelApiCalls();
|
||||
|
@ -1,24 +1,32 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useFileContext } from '../contexts/FileContext';
|
||||
import { useFileState, useFileActions } from '../contexts/FileContext';
|
||||
import { createStableFileId } from '../types/fileContext';
|
||||
|
||||
export const useFileHandler = () => {
|
||||
const { activeFiles, addFiles } = useFileContext();
|
||||
const { state } = useFileState();
|
||||
const { actions } = useFileActions();
|
||||
|
||||
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) {
|
||||
await addFiles([file]);
|
||||
await actions.addFiles([file]);
|
||||
}
|
||||
}, [activeFiles, addFiles]);
|
||||
}, [state.files.byId, actions.addFiles]);
|
||||
|
||||
const addMultipleFiles = useCallback(async (files: File[]) => {
|
||||
const newFiles = files.filter(file =>
|
||||
!activeFiles.some(f => f.name === file.name && f.size === file.size)
|
||||
);
|
||||
// Filter out files that already exist using stable IDs
|
||||
const newFiles = files.filter(file => {
|
||||
const stableId = createStableFileId(file);
|
||||
return state.files.byId[stableId] === undefined;
|
||||
});
|
||||
|
||||
if (newFiles.length > 0) {
|
||||
await addFiles(newFiles);
|
||||
await actions.addFiles(newFiles);
|
||||
}
|
||||
}, [activeFiles, addFiles]);
|
||||
}, [state.files.byId, actions.addFiles]);
|
||||
|
||||
return {
|
||||
addToActiveFiles,
|
||||
|
@ -1,6 +1,5 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { useFileContext } from "../contexts/FileContext";
|
||||
import { FileSelectionProvider, useFileSelection } from "../contexts/FileSelectionContext";
|
||||
import { useFileActions, useToolFileSelection } from "../contexts/FileContext";
|
||||
import { ToolWorkflowProvider, useToolSelection } from "../contexts/ToolWorkflowContext";
|
||||
import { Group } from "@mantine/core";
|
||||
import { SidebarProvider, useSidebarContext } from "../contexts/SidebarContext";
|
||||
@ -18,7 +17,7 @@ function HomePageContent() {
|
||||
|
||||
const { quickAccessRef } = sidebarRefs;
|
||||
|
||||
const { setMaxFiles, setIsToolMode, setSelectedFiles } = useFileSelection();
|
||||
const { setMaxFiles, setIsToolMode, setSelectedFiles } = useToolFileSelection();
|
||||
|
||||
const { selectedTool } = useToolSelection();
|
||||
|
||||
@ -32,7 +31,7 @@ function HomePageContent() {
|
||||
setIsToolMode(false);
|
||||
setSelectedFiles([]);
|
||||
}
|
||||
}, [selectedTool, setMaxFiles, setIsToolMode, setSelectedFiles]);
|
||||
}, [selectedTool]); // Remove action dependencies to prevent loops
|
||||
|
||||
return (
|
||||
<Group
|
||||
@ -50,14 +49,12 @@ function HomePageContent() {
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const { setCurrentView } = useFileContext();
|
||||
const { actions } = useFileActions();
|
||||
return (
|
||||
<FileSelectionProvider>
|
||||
<ToolWorkflowProvider onViewChange={setCurrentView}>
|
||||
<SidebarProvider>
|
||||
<HomePageContent />
|
||||
</SidebarProvider>
|
||||
</ToolWorkflowProvider>
|
||||
</FileSelectionProvider>
|
||||
<ToolWorkflowProvider onViewChange={actions.setMode}>
|
||||
<SidebarProvider>
|
||||
<HomePageContent />
|
||||
</SidebarProvider>
|
||||
</ToolWorkflowProvider>
|
||||
);
|
||||
}
|
||||
|
@ -156,10 +156,10 @@ export const mantineTheme = createTheme({
|
||||
},
|
||||
option: {
|
||||
color: 'var(--text-primary)',
|
||||
'&[data-hovered]': {
|
||||
'&[dataHovered]': {
|
||||
backgroundColor: 'var(--hover-bg)',
|
||||
},
|
||||
'&[data-selected]': {
|
||||
'&[dataSelected]': {
|
||||
backgroundColor: 'var(--color-primary-100)',
|
||||
color: 'var(--color-primary-900)',
|
||||
},
|
||||
@ -189,10 +189,10 @@ export const mantineTheme = createTheme({
|
||||
},
|
||||
option: {
|
||||
color: 'var(--text-primary)',
|
||||
'&[data-hovered]': {
|
||||
'&[dataHovered]': {
|
||||
backgroundColor: 'var(--hover-bg)',
|
||||
},
|
||||
'&[data-selected]': {
|
||||
'&[dataSelected]': {
|
||||
backgroundColor: 'var(--color-primary-100)',
|
||||
color: 'var(--color-primary-900)',
|
||||
},
|
||||
|
@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
||||
import { useFileContext } from "../contexts/FileContext";
|
||||
import { useToolFileSelection } from "../contexts/FileSelectionContext";
|
||||
import { useToolFileSelection } from "../contexts/FileContext";
|
||||
|
||||
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
|
||||
import OperationButton from "../components/tools/shared/OperationButton";
|
||||
|
@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
||||
import { useFileContext } from "../contexts/FileContext";
|
||||
import { useToolFileSelection } from "../contexts/FileSelectionContext";
|
||||
import { useToolFileSelection } from "../contexts/FileContext";
|
||||
|
||||
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
|
||||
import OperationButton from "../components/tools/shared/OperationButton";
|
||||
|
@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
||||
import { useFileContext } from "../contexts/FileContext";
|
||||
import { useToolFileSelection } from "../contexts/FileSelectionContext";
|
||||
import { useToolFileSelection } from "../contexts/FileContext";
|
||||
|
||||
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
|
||||
import OperationButton from "../components/tools/shared/OperationButton";
|
||||
|
@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
||||
import { useFileContext } from "../contexts/FileContext";
|
||||
import { useToolFileSelection } from "../contexts/FileSelectionContext";
|
||||
import { useToolFileSelection } from "../contexts/FileContext";
|
||||
|
||||
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
|
||||
import OperationButton from "../components/tools/shared/OperationButton";
|
||||
|
@ -7,6 +7,63 @@ import { PDFDocument, PDFPage, PageOperation } from './pageEditor';
|
||||
|
||||
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 interface FileOperation {
|
||||
@ -48,86 +105,118 @@ export interface FileEditHistory {
|
||||
}
|
||||
|
||||
export interface FileContextState {
|
||||
// Core file management
|
||||
activeFiles: File[];
|
||||
processedFiles: Map<File, ProcessedFile>;
|
||||
// Core file management - normalized state
|
||||
files: FileContextNormalizedFiles;
|
||||
|
||||
// Current navigation state
|
||||
currentMode: ModeType;
|
||||
// UI state grouped for performance
|
||||
ui: {
|
||||
// Current navigation state
|
||||
currentMode: ModeType;
|
||||
|
||||
// Edit history and state
|
||||
fileEditHistory: Map<string, FileEditHistory>;
|
||||
globalFileOperations: FileOperation[];
|
||||
// New comprehensive operation history
|
||||
fileOperationHistory: Map<string, FileOperationHistory>;
|
||||
// UI state that persists across views
|
||||
selectedFileIds: string[];
|
||||
selectedPageNumbers: number[];
|
||||
viewerConfig: ViewerConfig;
|
||||
|
||||
// UI state that persists across views
|
||||
selectedFileIds: string[];
|
||||
selectedPageNumbers: number[];
|
||||
viewerConfig: ViewerConfig;
|
||||
// Tool selection state (replaces FileSelectionContext)
|
||||
toolMode: boolean;
|
||||
maxFiles: number; // 1=single, >1=limited, -1=unlimited
|
||||
currentTool?: string;
|
||||
|
||||
// Processing state
|
||||
isProcessing: boolean;
|
||||
processingProgress: number;
|
||||
// Processing state
|
||||
isProcessing: boolean;
|
||||
processingProgress: number;
|
||||
|
||||
// Export state
|
||||
lastExportConfig?: {
|
||||
filename: string;
|
||||
selectedOnly: boolean;
|
||||
splitDocuments: boolean;
|
||||
// Export state
|
||||
lastExportConfig?: {
|
||||
filename: string;
|
||||
selectedOnly: boolean;
|
||||
splitDocuments: boolean;
|
||||
};
|
||||
|
||||
// Navigation guard system
|
||||
hasUnsavedChanges: boolean;
|
||||
pendingNavigation: (() => void) | null;
|
||||
showNavigationWarning: boolean;
|
||||
};
|
||||
|
||||
// Navigation guard system
|
||||
hasUnsavedChanges: boolean;
|
||||
pendingNavigation: (() => void) | null;
|
||||
showNavigationWarning: boolean;
|
||||
// Edit history and state (less frequently accessed)
|
||||
history: {
|
||||
fileEditHistory: Map<string, FileEditHistory>;
|
||||
globalFileOperations: FileOperation[];
|
||||
fileOperationHistory: Map<string, FileOperationHistory>;
|
||||
};
|
||||
}
|
||||
|
||||
// Action types for reducer pattern
|
||||
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 {
|
||||
// File management
|
||||
addFiles: (files: File[]) => Promise<void>;
|
||||
addFiles: (files: File[]) => Promise<File[]>;
|
||||
removeFiles: (fileIds: string[], deleteFromStorage?: boolean) => void;
|
||||
replaceFile: (oldFileId: string, newFile: File) => Promise<void>;
|
||||
clearAllFiles: () => void;
|
||||
|
||||
// Navigation
|
||||
setCurrentMode: (mode: ModeType) => void;
|
||||
setMode: (mode: ModeType) => void;
|
||||
|
||||
// Selection management
|
||||
setSelectedFiles: (fileIds: string[]) => void;
|
||||
setSelectedPages: (pageNumbers: number[]) => void;
|
||||
updateProcessedFile: (file: File, processedFile: ProcessedFile) => void;
|
||||
clearSelections: () => void;
|
||||
|
||||
// Edit operations
|
||||
applyPageOperations: (fileId: string, operations: PageOperation[]) => void;
|
||||
applyFileOperation: (operation: FileOperation) => void;
|
||||
undoLastOperation: (fileId?: string) => void;
|
||||
// Tool selection management (replaces FileSelectionContext)
|
||||
setToolMode: (toolMode: boolean) => void;
|
||||
setMaxFiles: (maxFiles: number) => void;
|
||||
setCurrentTool: (currentTool?: string) => void;
|
||||
|
||||
// Operation history management
|
||||
recordOperation: (fileId: string, operation: FileOperation | PageOperation) => 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;
|
||||
// Processing state
|
||||
setProcessing: (isProcessing: boolean, progress: number) => void;
|
||||
|
||||
// Viewer state
|
||||
updateViewerConfig: (config: Partial<ViewerConfig>) => void;
|
||||
updateViewerConfig: (config: Partial<FileContextState['ui']['viewerConfig']>) => void;
|
||||
|
||||
// Export configuration
|
||||
setExportConfig: (config: FileContextState['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;
|
||||
setExportConfig: (config: FileContextState['ui']['lastExportConfig']) => void;
|
||||
|
||||
// Navigation guard system
|
||||
setHasUnsavedChanges: (hasChanges: boolean) => void;
|
||||
@ -135,14 +224,24 @@ export interface FileContextActions {
|
||||
confirmNavigation: () => void;
|
||||
cancelNavigation: () => void;
|
||||
|
||||
// Memory management
|
||||
trackBlobUrl: (url: string) => void;
|
||||
trackPdfDocument: (fileId: string, pdfDoc: any) => void;
|
||||
cleanupFile: (fileId: string) => Promise<void>;
|
||||
scheduleCleanup: (fileId: string, delay?: number) => void;
|
||||
// Context management
|
||||
resetContext: () => 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 {
|
||||
children: React.ReactNode;
|
||||
@ -156,6 +255,45 @@ export interface WithFileContext {
|
||||
fileContext: FileContextValue;
|
||||
}
|
||||
|
||||
// Selector types for split context pattern
|
||||
export interface FileContextSelectors {
|
||||
// File selectors
|
||||
getFileById: (id: FileId) => FileRecord | undefined;
|
||||
getFilesByIds: (ids: FileId[]) => FileRecord[];
|
||||
getAllFiles: () => FileRecord[];
|
||||
getSelectedFiles: () => FileRecord[];
|
||||
|
||||
// Convenience file helpers
|
||||
getFile: (id: FileId) => File | undefined;
|
||||
getFiles: (ids?: FileId[]) => File[];
|
||||
|
||||
// UI selectors
|
||||
getCurrentMode: () => ModeType;
|
||||
getSelectedFileIds: () => string[];
|
||||
getSelectedPageNumbers: () => number[];
|
||||
getViewerConfig: () => ViewerConfig;
|
||||
getProcessingState: () => { isProcessing: boolean; progress: number };
|
||||
|
||||
// Navigation guard selectors
|
||||
getHasUnsavedChanges: () => boolean;
|
||||
getShowNavigationWarning: () => boolean;
|
||||
|
||||
// History selectors (legacy - moved to selectors from actions)
|
||||
getFileHistory: (fileId: string) => FileOperationHistory | undefined;
|
||||
getAppliedOperations: (fileId: string) => (FileOperation | PageOperation)[];
|
||||
}
|
||||
|
||||
// Split context value types
|
||||
export interface FileContextStateValue {
|
||||
state: FileContextState;
|
||||
selectors: FileContextSelectors;
|
||||
}
|
||||
|
||||
export interface FileContextActionsValue {
|
||||
actions: FileContextActions;
|
||||
dispatch: (action: FileContextAction) => void;
|
||||
}
|
||||
|
||||
// URL parameter types for deep linking
|
||||
export interface FileContextUrlParams {
|
||||
mode?: ModeType;
|
||||
|
@ -50,24 +50,3 @@ export interface 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 {}
|
Loading…
x
Reference in New Issue
Block a user