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(find:*)",
|
||||||
"Bash(npm test)",
|
"Bash(npm test)",
|
||||||
"Bash(npm test:*)",
|
"Bash(npm test:*)",
|
||||||
"Bash(ls:*)"
|
"Bash(ls:*)",
|
||||||
|
"Bash(npm run dev:*)"
|
||||||
],
|
],
|
||||||
"deny": []
|
"deny": []
|
||||||
}
|
}
|
||||||
|
@ -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",
|
||||||
|
@ -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 {
|
||||||
|
@ -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);
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
@ -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];
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
// Memoize ref callback to prevent infinite loop
|
||||||
<div
|
const refCallback = useCallback((el: HTMLDivElement | null) => {
|
||||||
ref={(el) => {
|
|
||||||
if (el) {
|
if (el) {
|
||||||
fileRefs.current.set(file.id, el);
|
fileRefs.current.set(file.id, el);
|
||||||
} else {
|
} else {
|
||||||
fileRefs.current.delete(file.id);
|
fileRefs.current.delete(file.id);
|
||||||
}
|
}
|
||||||
}}
|
}, [file.id, fileRefs]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={refCallback}
|
||||||
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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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)
|
/**
|
||||||
|
* Create stable files signature to prevent infinite re-computation.
|
||||||
|
* This signature only changes when files are actually added/removed or processing state changes.
|
||||||
|
* Using this instead of direct file arrays prevents unnecessary re-renders.
|
||||||
|
*/
|
||||||
|
const filesSignature = useMemo(() => {
|
||||||
|
const fileIds = state.files.ids.sort(); // Stable order
|
||||||
|
return fileIds
|
||||||
|
.map(id => {
|
||||||
|
const record = state.files.byId[id];
|
||||||
|
if (!record) return `${id}:missing`;
|
||||||
|
const hasProcessed = record.processedFile ? 'processed' : 'pending';
|
||||||
|
return `${id}:${record.name}:${record.size}:${record.lastModified}:${hasProcessed}`;
|
||||||
|
})
|
||||||
|
.join('|');
|
||||||
|
}, [state.files.ids, state.files.byId]);
|
||||||
|
|
||||||
|
// Compute merged document with stable signature (prevents infinite loops)
|
||||||
const mergedPdfDocument = useMemo(() => {
|
const mergedPdfDocument = useMemo(() => {
|
||||||
if (activeFiles.length === 0) return null;
|
const currentFiles = state.files.ids.map(id => state.files.byId[id]?.file).filter(Boolean);
|
||||||
|
|
||||||
if (activeFiles.length === 1) {
|
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);
|
||||||
|
|
||||||
return {
|
const processedFile = record?.processedFile;
|
||||||
id: processedFile.id,
|
if (!processedFile) {
|
||||||
name: activeFiles[0].name,
|
return null;
|
||||||
file: activeFiles[0],
|
}
|
||||||
pages: processedFile.pages.map(page => ({
|
|
||||||
|
const pages = processedFile.pages.map(page => ({
|
||||||
...page,
|
...page,
|
||||||
rotation: page.rotation || 0,
|
rotation: page.rotation || 0,
|
||||||
splitBefore: page.splitBefore || false
|
splitBefore: page.splitBefore || false
|
||||||
})),
|
}));
|
||||||
totalPages: processedFile.totalPages
|
|
||||||
|
return {
|
||||||
|
id: processedFile.id,
|
||||||
|
name: file.name,
|
||||||
|
file: file,
|
||||||
|
pages: pages,
|
||||||
|
totalPages: pages.length // Always use actual pages array length
|
||||||
};
|
};
|
||||||
} 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 {
|
||||||
|
await saveDraftToIndexedDB(updatedDoc);
|
||||||
setHasUnsavedDraft(false); // Mark draft as saved
|
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) => {
|
||||||
|
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')) {
|
if (!db.objectStoreNames.contains('drafts')) {
|
||||||
db.createObjectStore('drafts');
|
const store = db.createObjectStore('drafts');
|
||||||
|
console.log('Created drafts object store');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
request.onsuccess = () => {
|
dbRequest.onsuccess = () => {
|
||||||
const db = request.result;
|
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 transaction = db.transaction('drafts', 'readwrite');
|
||||||
const store = transaction.objectStore('drafts');
|
const store = transaction.objectStore('drafts');
|
||||||
store.put(draftData, draftKey);
|
|
||||||
console.log('Draft auto-saved to IndexedDB');
|
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) {
|
} catch (error) {
|
||||||
console.warn('Failed to auto-save draft:', error);
|
console.warn('Transaction creation failed:', error);
|
||||||
|
reject(error);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (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);
|
||||||
|
|
||||||
request.onsuccess = () => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
const db = request.result;
|
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 transaction = db.transaction('drafts', 'readwrite');
|
||||||
const store = transaction.objectStore('drafts');
|
const store = transaction.objectStore('drafts');
|
||||||
store.delete(draftKey);
|
|
||||||
|
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) {
|
} catch (error) {
|
||||||
console.warn('Failed to cleanup draft:', error);
|
console.warn('Draft cleanup transaction creation failed:', error);
|
||||||
|
resolve(); // Don't fail if cleanup fails
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (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,22 +1121,56 @@ 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
|
||||||
|
};
|
||||||
|
|
||||||
|
dbRequest.onupgradeneeded = (event) => {
|
||||||
|
const db = (event.target as IDBOpenDBRequest).result;
|
||||||
|
|
||||||
|
// Create object store if it doesn't exist
|
||||||
|
if (!db.objectStoreNames.contains('drafts')) {
|
||||||
|
const store = db.createObjectStore('drafts');
|
||||||
|
console.log('Created drafts object store during check');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
dbRequest.onsuccess = () => {
|
||||||
|
const db = dbRequest.result;
|
||||||
|
|
||||||
|
// Check if object store exists
|
||||||
|
if (!db.objectStoreNames.contains('drafts')) {
|
||||||
|
console.log('No drafts object store found, no drafts to check');
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const transaction = db.transaction('drafts', 'readonly');
|
const transaction = db.transaction('drafts', 'readonly');
|
||||||
const store = transaction.objectStore('drafts');
|
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);
|
const getRequest = store.get(draftKey);
|
||||||
|
|
||||||
|
getRequest.onerror = () => {
|
||||||
|
console.warn('Failed to get draft:', getRequest.error);
|
||||||
|
resolve(); // Don't fail if get fails
|
||||||
|
};
|
||||||
|
|
||||||
getRequest.onsuccess = () => {
|
getRequest.onsuccess = () => {
|
||||||
const draft = getRequest.result;
|
const draft = getRequest.result;
|
||||||
if (draft && draft.timestamp) {
|
if (draft && draft.timestamp) {
|
||||||
@ -1030,14 +1179,38 @@ const PageEditor = ({
|
|||||||
const twentyFourHours = 24 * 60 * 60 * 1000;
|
const twentyFourHours = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
if (draftAge < twentyFourHours) {
|
if (draftAge < twentyFourHours) {
|
||||||
|
console.log('Found recent draft, showing resume modal');
|
||||||
setFoundDraft(draft);
|
setFoundDraft(draft);
|
||||||
setShowResumeModal(true);
|
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) {
|
} catch (error) {
|
||||||
console.warn('Failed to check for drafts:', error);
|
console.warn('Draft check transaction creation failed:', error);
|
||||||
|
resolve(); // Don't fail if transaction creation fails
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Draft check failed:', error);
|
||||||
|
// Don't throw - draft checking failure shouldn't break the app
|
||||||
}
|
}
|
||||||
}, [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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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) {
|
||||||
|
@ -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"
|
||||||
|
@ -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) => {
|
||||||
|
@ -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
@ -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}>
|
||||||
|
@ -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';
|
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}>
|
||||||
|
@ -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}>
|
||||||
|
@ -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();
|
||||||
|
@ -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,
|
||||||
|
@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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)',
|
||||||
},
|
},
|
||||||
|
@ -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";
|
||||||
|
@ -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";
|
||||||
|
@ -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";
|
||||||
|
@ -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";
|
||||||
|
@ -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,24 +105,24 @@ 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>;
|
|
||||||
|
|
||||||
|
// UI state grouped for performance
|
||||||
|
ui: {
|
||||||
// Current navigation state
|
// Current navigation state
|
||||||
currentMode: ModeType;
|
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
|
// UI state that persists across views
|
||||||
selectedFileIds: string[];
|
selectedFileIds: string[];
|
||||||
selectedPageNumbers: number[];
|
selectedPageNumbers: number[];
|
||||||
viewerConfig: ViewerConfig;
|
viewerConfig: ViewerConfig;
|
||||||
|
|
||||||
|
// Tool selection state (replaces FileSelectionContext)
|
||||||
|
toolMode: boolean;
|
||||||
|
maxFiles: number; // 1=single, >1=limited, -1=unlimited
|
||||||
|
currentTool?: string;
|
||||||
|
|
||||||
// Processing state
|
// Processing state
|
||||||
isProcessing: boolean;
|
isProcessing: boolean;
|
||||||
processingProgress: number;
|
processingProgress: number;
|
||||||
@ -81,53 +138,85 @@ export interface FileContextState {
|
|||||||
hasUnsavedChanges: boolean;
|
hasUnsavedChanges: boolean;
|
||||||
pendingNavigation: (() => void) | null;
|
pendingNavigation: (() => void) | null;
|
||||||
showNavigationWarning: boolean;
|
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 {
|
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;
|
||||||
|
@ -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 {}
|
|
Loading…
x
Reference in New Issue
Block a user