feat: Enhance PDF splitting functionality and operation history tracking

- Improved the useEnhancedProcessedFiles hook to only update processed files when content changes.
- Added preview functionality for split PDF files in the HomePage component.
- Implemented a new PDFExportService with enhanced error handling and file validation.
- Updated Split tool to generate and display thumbnails for split PDF files.
- Introduced a comprehensive FileOperationHistory component to track and display file operations.
- Enhanced file context types to support new operation types and history management.
- Created a ZipFileService for validating and extracting PDF files from ZIP archives.
This commit is contained in:
Reece 2025-07-10 17:51:50 +01:00
parent c7dce8a68f
commit 83b8c9be09
17 changed files with 1496 additions and 174 deletions

View File

@ -25,6 +25,7 @@
"i18next": "^25.2.1",
"i18next-browser-languagedetector": "^8.1.0",
"i18next-http-backend": "^3.0.2",
"jszip": "^3.10.1",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^3.11.174",
"react": "^19.1.0",
@ -2689,6 +2690,11 @@
"node": ">=18"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
},
"node_modules/cosmiconfig": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
@ -3450,6 +3456,11 @@
"cross-fetch": "4.0.0"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@ -3491,8 +3502,7 @@
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC",
"optional": true
"license": "ISC"
},
"node_modules/is-arrayish": {
"version": "0.2.1",
@ -3571,6 +3581,11 @@
"node": ">=0.12.0"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
},
"node_modules/jiti": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
@ -3630,6 +3645,52 @@
"graceful-fs": "^4.1.6"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/jszip/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/jszip/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
},
"node_modules/jszip/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lightningcss": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
@ -4729,6 +4790,11 @@
"node": ">= 0.8"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@ -5237,6 +5303,11 @@
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
"license": "MIT"
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="
},
"node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
@ -5697,7 +5768,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/vite": {

View File

@ -21,6 +21,7 @@
"i18next": "^25.2.1",
"i18next-browser-languagedetector": "^8.1.0",
"i18next-http-backend": "^3.0.2",
"jszip": "^3.10.1",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^3.11.174",
"react": "^19.1.0",

View File

@ -7,8 +7,10 @@ import { Dropzone } from '@mantine/dropzone';
import { useTranslation } from 'react-i18next';
import UploadFileIcon from '@mui/icons-material/UploadFile';
import { useFileContext } from '../../contexts/FileContext';
import { FileOperation } from '../../types/fileContext';
import { fileStorage } from '../../services/fileStorage';
import { generateThumbnailForFile } from '../../utils/thumbnailUtils';
import { zipFileService } from '../../services/zipFileService';
import styles from '../pageEditor/PageEditor.module.css';
import FileThumbnail from '../pageEditor/FileThumbnail';
import DragDropGrid from '../pageEditor/DragDropGrid';
@ -56,7 +58,9 @@ const FileEditor = ({
isProcessing,
addFiles,
removeFiles,
setCurrentView
setCurrentView,
recordOperation,
markOperationApplied
} = fileContext;
const [files, setFiles] = useState<FileItem[]>([]);
@ -78,11 +82,29 @@ const FileEditor = ({
const [isAnimating, setIsAnimating] = useState(false);
const [showFilePickerModal, setShowFilePickerModal] = useState(false);
const [conversionProgress, setConversionProgress] = useState(0);
const [zipExtractionProgress, setZipExtractionProgress] = useState<{
isExtracting: boolean;
currentFile: string;
progress: number;
extractedCount: number;
totalFiles: number;
}>({
isExtracting: false,
currentFile: '',
progress: 0,
extractedCount: 0,
totalFiles: 0
});
const fileRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const lastActiveFilesRef = useRef<string[]>([]);
const lastProcessedFilesRef = useRef<number>(0);
// Map context selected file names to local file IDs
// Defensive programming: ensure selectedFileIds is always an array
const safeSelectedFileIds = Array.isArray(selectedFileIds) ? selectedFileIds : [];
const localSelectedFiles = files
.filter(file => selectedFileIds.includes(file.name))
.filter(file => safeSelectedFileIds.includes(file.name))
.map(file => file.id);
// Convert shared files to FileEditor format
@ -102,7 +124,23 @@ const FileEditor = ({
// Convert activeFiles to FileItem format using context (async to avoid blocking)
useEffect(() => {
// Check if the actual content has changed, not just references
const currentActiveFileNames = activeFiles.map(f => f.name);
const currentProcessedFilesSize = processedFiles.size;
const activeFilesChanged = JSON.stringify(currentActiveFileNames) !== JSON.stringify(lastActiveFilesRef.current);
const processedFilesChanged = currentProcessedFilesSize !== lastProcessedFilesRef.current;
if (!activeFilesChanged && !processedFilesChanged) {
return;
}
// Update refs
lastActiveFilesRef.current = currentActiveFileNames;
lastProcessedFilesRef.current = currentProcessedFilesSize;
const convertActiveFiles = async () => {
if (activeFiles.length > 0) {
setLocalLoading(true);
try {
@ -170,25 +208,146 @@ const FileEditor = ({
setError(null);
try {
const validFiles = uploadedFiles.filter(file => {
if (file.type !== 'application/pdf') {
setError('Please upload only PDF files');
return false;
const allExtractedFiles: File[] = [];
const errors: string[] = [];
for (const file of uploadedFiles) {
if (file.type === 'application/pdf') {
// Handle PDF files normally
allExtractedFiles.push(file);
} else if (file.type === 'application/zip' || file.type === 'application/x-zip-compressed' || file.name.toLowerCase().endsWith('.zip')) {
// Handle ZIP files
try {
// Validate ZIP file first
const validation = await zipFileService.validateZipFile(file);
if (!validation.isValid) {
errors.push(`ZIP file "${file.name}": ${validation.errors.join(', ')}`);
continue;
}
return true;
// Extract PDF files from ZIP
setZipExtractionProgress({
isExtracting: true,
currentFile: file.name,
progress: 0,
extractedCount: 0,
totalFiles: validation.fileCount
});
if (validFiles.length > 0) {
const extractionResult = await zipFileService.extractPdfFiles(file, (progress) => {
setZipExtractionProgress({
isExtracting: true,
currentFile: progress.currentFile,
progress: progress.progress,
extractedCount: progress.extractedCount,
totalFiles: progress.totalFiles
});
});
// Reset extraction progress
setZipExtractionProgress({
isExtracting: false,
currentFile: '',
progress: 0,
extractedCount: 0,
totalFiles: 0
});
if (extractionResult.success) {
allExtractedFiles.push(...extractionResult.extractedFiles);
// Record ZIP extraction operation
const operationId = `zip-extract-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const operation: FileOperation = {
id: operationId,
type: 'convert',
timestamp: Date.now(),
fileIds: extractionResult.extractedFiles.map(f => f.name),
status: 'pending',
metadata: {
originalFileName: file.name,
outputFileNames: extractionResult.extractedFiles.map(f => f.name),
fileSize: file.size,
parameters: {
extractionType: 'zip',
extractedCount: extractionResult.extractedCount,
totalFiles: extractionResult.totalFiles
}
}
};
recordOperation(file.name, operation);
markOperationApplied(file.name, operationId);
if (extractionResult.errors.length > 0) {
errors.push(...extractionResult.errors);
}
} else {
errors.push(`Failed to extract ZIP file "${file.name}": ${extractionResult.errors.join(', ')}`);
}
} catch (zipError) {
errors.push(`Failed to process ZIP file "${file.name}": ${zipError instanceof Error ? zipError.message : 'Unknown error'}`);
setZipExtractionProgress({
isExtracting: false,
currentFile: '',
progress: 0,
extractedCount: 0,
totalFiles: 0
});
}
} else {
errors.push(`Unsupported file type: ${file.name} (${file.type})`);
}
}
// Show any errors
if (errors.length > 0) {
setError(errors.join('\n'));
}
// Process all extracted files
if (allExtractedFiles.length > 0) {
// Record upload operations for PDF files
for (const file of allExtractedFiles) {
const operationId = `upload-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const operation: FileOperation = {
id: operationId,
type: 'upload',
timestamp: Date.now(),
fileIds: [file.name],
status: 'pending',
metadata: {
originalFileName: file.name,
fileSize: file.size,
parameters: {
uploadMethod: 'drag-drop'
}
}
};
recordOperation(file.name, operation);
markOperationApplied(file.name, operationId);
}
// Add files to context (they will be processed automatically)
await addFiles(validFiles);
setStatus(`Added ${validFiles.length} files`);
await addFiles(allExtractedFiles);
setStatus(`Added ${allExtractedFiles.length} files`);
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to process files';
setError(errorMessage);
console.error('File processing error:', err);
// Reset extraction progress on error
setZipExtractionProgress({
isExtracting: false,
currentFile: '',
progress: 0,
extractedCount: 0,
totalFiles: 0
});
}
}, [addFiles]);
}, [addFiles, recordOperation, markOperationApplied]);
const selectAll = useCallback(() => {
setContextSelectedFiles(files.map(f => f.name)); // Use file name as ID for context
@ -196,12 +355,45 @@ const FileEditor = ({
const deselectAll = useCallback(() => setContextSelectedFiles([]), [setContextSelectedFiles]);
const closeAllFiles = useCallback(() => {
if (activeFiles.length === 0) return;
// Record close all operation for each file
activeFiles.forEach(file => {
const operationId = `close-all-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const operation: FileOperation = {
id: operationId,
type: 'remove',
timestamp: Date.now(),
fileIds: [file.name],
status: 'pending',
metadata: {
originalFileName: file.name,
fileSize: file.size,
parameters: {
action: 'close_all',
reason: 'user_request'
}
}
};
recordOperation(file.name, operation);
markOperationApplied(file.name, operationId);
});
// Remove all files from context but keep in storage
removeFiles(activeFiles.map(f => f.name), false);
// Clear selections
setContextSelectedFiles([]);
}, [activeFiles, removeFiles, setContextSelectedFiles, recordOperation, markOperationApplied]);
const toggleFile = useCallback((fileId: string) => {
const fileName = files.find(f => f.id === fileId)?.name || fileId;
if (!multiSelect) {
// Single select mode for tools - toggle on/off
const isCurrentlySelected = selectedFileIds.includes(fileName);
const isCurrentlySelected = safeSelectedFileIds.includes(fileName);
if (isCurrentlySelected) {
// Deselect the file
setContextSelectedFiles([]);
@ -218,21 +410,22 @@ const FileEditor = ({
}
} else {
// Multi select mode (default)
setContextSelectedFiles(prev =>
prev.includes(fileName)
? prev.filter(id => id !== fileName)
: [...prev, fileName]
);
setContextSelectedFiles(prev => {
const safePrev = Array.isArray(prev) ? prev : [];
return safePrev.includes(fileName)
? safePrev.filter(id => id !== fileName)
: [...safePrev, fileName];
});
// Notify parent with selected files
if (onFileSelect) {
const selectedFiles = files
.filter(f => selectedFileIds.includes(f.name) || f.name === fileName)
.filter(f => safeSelectedFileIds.includes(f.name) || f.name === fileName)
.map(f => f.file);
onFileSelect(selectedFiles);
}
}
}, [files, setContextSelectedFiles, multiSelect, onFileSelect, selectedFileIds]);
}, [files, setContextSelectedFiles, multiSelect, onFileSelect, safeSelectedFileIds]);
const toggleSelectionMode = useCallback(() => {
setSelectionMode(prev => {
@ -354,14 +547,53 @@ const FileEditor = ({
// File operations using context
const handleDeleteFile = useCallback((fileId: string) => {
console.log('handleDeleteFile called with fileId:', fileId);
const file = files.find(f => f.id === fileId);
console.log('Found file:', file);
if (file) {
// Remove from context
removeFiles([file.name]);
// Remove from context selections
setContextSelectedFiles(prev => prev.filter(id => id !== file.name));
console.log('Attempting to remove file:', file.name);
console.log('Actual file object:', file.file);
console.log('Actual file.file.name:', file.file.name);
// Record close operation
const actualFileName = file.file.name;
const operationId = `close-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const operation: FileOperation = {
id: operationId,
type: 'remove',
timestamp: Date.now(),
fileIds: [actualFileName],
status: 'pending',
metadata: {
originalFileName: actualFileName,
fileSize: file.size,
parameters: {
action: 'close',
reason: 'user_request'
}
}, [files, removeFiles, setContextSelectedFiles]);
}
};
recordOperation(actualFileName, operation);
// Remove file from context but keep in storage (close, don't delete)
// Use the actual file name (with extension) not the display name
console.log('Calling removeFiles with:', [actualFileName]);
removeFiles([actualFileName], false);
// Remove from context selections
setContextSelectedFiles(prev => {
const safePrev = Array.isArray(prev) ? prev : [];
return safePrev.filter(id => id !== actualFileName);
});
// Mark operation as applied
markOperationApplied(actualFileName, operationId);
} else {
console.log('File not found for fileId:', fileId);
}
}, [files, removeFiles, setContextSelectedFiles, recordOperation, markOperationApplied]);
const handleViewFile = useCallback((fileId: string) => {
const file = files.find(f => f.id === fileId);
@ -419,6 +651,9 @@ const FileEditor = ({
<>
<Button onClick={selectAll} variant="light">Select All</Button>
<Button onClick={deselectAll} variant="light">Deselect All</Button>
<Button onClick={closeAllFiles} variant="light" color="orange">
Close All
</Button>
</>
)}
@ -435,7 +670,7 @@ const FileEditor = ({
<Dropzone
onDrop={handleFileUpload}
accept={["application/pdf"]}
accept={["application/pdf", "application/zip", "application/x-zip-compressed"]}
multiple={true}
maxSize={2 * 1024 * 1024 * 1024}
style={{ display: 'contents' }}
@ -449,19 +684,50 @@ const FileEditor = ({
</Group>
{files.length === 0 && !localLoading ? (
{files.length === 0 && !localLoading && !zipExtractionProgress.isExtracting ? (
<Center h="60vh">
<Stack align="center" gap="md">
<Text size="lg" c="dimmed">📁</Text>
<Text c="dimmed">No files loaded</Text>
<Text size="sm" c="dimmed">Upload files or load from storage to get started</Text>
<Text size="sm" c="dimmed">Upload PDF files, ZIP archives, or load from storage to get started</Text>
</Stack>
</Center>
) : files.length === 0 && localLoading ? (
) : files.length === 0 && (localLoading || zipExtractionProgress.isExtracting) ? (
<Box>
<SkeletonLoader type="controls" />
{/* ZIP Extraction Progress */}
{zipExtractionProgress.isExtracting && (
<Box mb="md" p="sm" style={{ backgroundColor: 'var(--mantine-color-orange-0)', borderRadius: 8 }}>
<Group justify="space-between" mb="xs">
<Text size="sm" fw={500}>Extracting ZIP archive...</Text>
<Text size="sm" c="dimmed">{Math.round(zipExtractionProgress.progress)}%</Text>
</Group>
<Text size="xs" c="dimmed" mb="xs">
{zipExtractionProgress.currentFile || 'Processing files...'}
</Text>
<Text size="xs" c="dimmed" mb="xs">
{zipExtractionProgress.extractedCount} of {zipExtractionProgress.totalFiles} files extracted
</Text>
<div style={{
width: '100%',
height: '4px',
backgroundColor: 'var(--mantine-color-gray-2)',
borderRadius: '2px',
overflow: 'hidden'
}}>
<div style={{
width: `${Math.round(zipExtractionProgress.progress)}%`,
height: '100%',
backgroundColor: 'var(--mantine-color-orange-6)',
transition: 'width 0.3s ease'
}} />
</div>
</Box>
)}
{/* Processing indicator */}
{localLoading && (
<Box mb="md" p="sm" style={{ backgroundColor: 'var(--mantine-color-blue-0)', borderRadius: 8 }}>
<Group justify="space-between" mb="xs">
<Text size="sm" fw={500}>Loading files...</Text>
@ -482,6 +748,7 @@ const FileEditor = ({
}} />
</div>
</Box>
)}
<SkeletonLoader type="fileGrid" count={6} />
</Box>

View File

@ -0,0 +1,177 @@
import React from 'react';
import {
Stack,
Paper,
Text,
Badge,
Group,
Collapse,
Box,
ScrollArea,
Code,
Divider
} from '@mantine/core';
import { useFileContext } from '../../contexts/FileContext';
import { FileOperation, FileOperationHistory as FileOperationHistoryType } from '../../types/fileContext';
import { PageOperation } from '../../types/pageEditor';
interface FileOperationHistoryProps {
fileId: string;
showOnlyApplied?: boolean;
maxHeight?: number;
}
const FileOperationHistory: React.FC<FileOperationHistoryProps> = ({
fileId,
showOnlyApplied = false,
maxHeight = 400
}) => {
const { getFileHistory, getAppliedOperations } = useFileContext();
const history = getFileHistory(fileId);
const operations = showOnlyApplied ? getAppliedOperations(fileId) : history?.operations || [];
const formatTimestamp = (timestamp: number) => {
return new Date(timestamp).toLocaleString();
};
const getOperationIcon = (type: string) => {
switch (type) {
case 'split': return '✂️';
case 'merge': return '🔗';
case 'compress': return '🗜️';
case 'rotate': return '🔄';
case 'delete': return '🗑️';
case 'move': return '↕️';
case 'insert': return '📄';
case 'upload': return '⬆️';
case 'add': return '';
case 'remove': return '';
case 'replace': return '🔄';
case 'convert': return '🔄';
default: return '⚙️';
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'applied': return 'green';
case 'failed': return 'red';
case 'pending': return 'yellow';
default: return 'gray';
}
};
const renderOperationDetails = (operation: FileOperation | PageOperation) => {
if ('metadata' in operation && operation.metadata) {
const { metadata } = operation;
return (
<Box mt="xs">
{metadata.parameters && (
<Text size="xs" c="dimmed">
Parameters: <Code>{JSON.stringify(metadata.parameters, null, 2)}</Code>
</Text>
)}
{metadata.originalFileName && (
<Text size="xs" c="dimmed">
Original file: {metadata.originalFileName}
</Text>
)}
{metadata.outputFileNames && (
<Text size="xs" c="dimmed">
Output files: {metadata.outputFileNames.join(', ')}
</Text>
)}
{metadata.fileSize && (
<Text size="xs" c="dimmed">
File size: {(metadata.fileSize / 1024 / 1024).toFixed(2)} MB
</Text>
)}
{metadata.pageCount && (
<Text size="xs" c="dimmed">
Pages: {metadata.pageCount}
</Text>
)}
{metadata.error && (
<Text size="xs" c="red">
Error: {metadata.error}
</Text>
)}
</Box>
);
}
return null;
};
if (!history || operations.length === 0) {
return (
<Paper p="md" withBorder>
<Text c="dimmed" ta="center">
{showOnlyApplied ? 'No applied operations found' : 'No operation history available'}
</Text>
</Paper>
);
}
return (
<Paper p="md" withBorder>
<Group justify="space-between" mb="md">
<Text fw={500}>
{showOnlyApplied ? 'Applied Operations' : 'Operation History'}
</Text>
<Badge variant="light" color="blue">
{operations.length} operations
</Badge>
</Group>
<ScrollArea h={maxHeight}>
<Stack gap="sm">
{operations.map((operation, index) => (
<Paper key={operation.id} p="sm" withBorder radius="sm" bg="gray.0">
<Group justify="space-between" align="start">
<Group gap="xs">
<Text span size="lg">
{getOperationIcon(operation.type)}
</Text>
<Box>
<Text fw={500} size="sm">
{operation.type.charAt(0).toUpperCase() + operation.type.slice(1)}
</Text>
<Text size="xs" c="dimmed">
{formatTimestamp(operation.timestamp)}
</Text>
</Box>
</Group>
<Badge
variant="filled"
color={getStatusColor(operation.status)}
size="sm"
>
{operation.status}
</Badge>
</Group>
{renderOperationDetails(operation)}
{index < operations.length - 1 && <Divider mt="sm" />}
</Paper>
))}
</Stack>
</ScrollArea>
{history && (
<Group justify="space-between" mt="sm" pt="sm" style={{ borderTop: '1px solid var(--mantine-color-gray-3)' }}>
<Text size="xs" c="dimmed">
Created: {formatTimestamp(history.createdAt)}
</Text>
<Text size="xs" c="dimmed">
Last modified: {formatTimestamp(history.lastModified)}
</Text>
</Group>
)}
</Paper>
);
};
export default FileOperationHistory;

View File

@ -1,11 +1,13 @@
import React from 'react';
import { Text, Checkbox, Tooltip, ActionIcon, Badge } from '@mantine/core';
import DeleteIcon from '@mui/icons-material/Delete';
import React, { useState } from 'react';
import { Text, Checkbox, Tooltip, ActionIcon, Badge, Modal } from '@mantine/core';
import CloseIcon from '@mui/icons-material/Close';
import VisibilityIcon from '@mui/icons-material/Visibility';
import MergeIcon from '@mui/icons-material/Merge';
import SplitscreenIcon from '@mui/icons-material/Splitscreen';
import HistoryIcon from '@mui/icons-material/History';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import styles from './PageEditor.module.css';
import FileOperationHistory from '../history/FileOperationHistory';
interface FileItem {
id: string;
@ -65,6 +67,8 @@ const FileThumbnail = ({
onSetStatus,
toolMode = false,
}: FileThumbnailProps) => {
const [showHistory, setShowHistory] = useState(false);
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
@ -289,18 +293,33 @@ const FileThumbnail = ({
</>
)}
<Tooltip label="Delete File">
<Tooltip label="View History">
<ActionIcon
size="md"
variant="subtle"
c="red"
c="white"
onClick={(e) => {
e.stopPropagation();
setShowHistory(true);
onSetStatus(`Viewing history for ${file.name}`);
}}
>
<HistoryIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
<Tooltip label="Close File">
<ActionIcon
size="md"
variant="subtle"
c="orange"
onClick={(e) => {
e.stopPropagation();
onDeleteFile(file.id);
onSetStatus(`Deleted ${file.name}`);
onSetStatus(`Closed ${file.name}`);
}}
>
<DeleteIcon style={{ fontSize: 20 }} />
<CloseIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
</div>
@ -326,6 +345,21 @@ const FileThumbnail = ({
{formatFileSize(file.size)}
</Text>
</div>
{/* History Modal */}
<Modal
opened={showHistory}
onClose={() => setShowHistory(false)}
title={`Operation History - ${file.name}`}
size="lg"
scrollAreaComponent="div"
>
<FileOperationHistory
fileId={file.name}
showOnlyApplied={true}
maxHeight={500}
/>
</Modal>
</div>
);
};

View File

@ -36,7 +36,7 @@ const FileUploadSelector = ({
sharedFiles = [],
onFileSelect,
onFilesSelect,
accept = ["application/pdf"],
accept = ["application/pdf", "application/zip", "application/x-zip-compressed"],
loading = false,
disabled = false,
showRecentFiles = true,
@ -212,7 +212,9 @@ const FileUploadSelector = ({
{t("fileUpload.dropFilesHere", "Drop files here or click to upload")}
</Text>
<Text size="sm" c="dimmed">
{accept.includes('application/pdf')
{accept.includes('application/pdf') && accept.includes('application/zip')
? t("fileUpload.pdfAndZipFiles", "PDF and ZIP files")
: accept.includes('application/pdf')
? t("fileUpload.pdfFilesOnly", "PDF files only")
: t("fileUpload.supportedFileTypes", "Supported file types")
}

View File

@ -91,7 +91,9 @@ const TopControls = ({
return (
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
<div className="absolute left-4 top-1/2 -translate-y-1/2 pointer-events-auto flex gap-2 items-center">
<div className={`absolute left-4 pointer-events-auto flex gap-2 items-center ${
isToolSelected ? 'top-4' : 'top-1/2 -translate-y-1/2'
}`}>
<Button
onClick={toggleTheme}
variant="subtle"

View File

@ -11,6 +11,7 @@ interface ToolRendererProps {
toolParams: any;
updateParams: (params: any) => void;
toolSelectedFiles?: File[];
onPreviewFile?: (file: File | null) => void;
}
const ToolRenderer = ({
@ -23,6 +24,7 @@ const ToolRenderer = ({
toolParams,
updateParams,
toolSelectedFiles = [],
onPreviewFile,
}: ToolRendererProps) => {
if (!selectedTool || !selectedTool.component) {
return <div>Tool not found</div>;
@ -38,6 +40,7 @@ const ToolRenderer = ({
params={toolParams}
updateParams={updateParams}
selectedFiles={toolSelectedFiles}
onPreviewFile={onPreviewFile}
/>
);
case "compress":

View File

@ -1,5 +1,5 @@
import React, { useEffect, useState, useRef } from "react";
import { Paper, Stack, Text, ScrollArea, Loader, Center, Button, Group, NumberInput, useMantineTheme } from "@mantine/core";
import { Paper, Stack, Text, ScrollArea, Loader, Center, Button, Group, NumberInput, useMantineTheme, ActionIcon, Box } from "@mantine/core";
import { getDocument, GlobalWorkerOptions } from "pdfjs-dist";
import { useTranslation } from "react-i18next";
import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew";
@ -9,6 +9,7 @@ import LastPageIcon from "@mui/icons-material/LastPage";
import ViewSidebarIcon from "@mui/icons-material/ViewSidebar";
import ViewWeekIcon from "@mui/icons-material/ViewWeek"; // for dual page (book)
import DescriptionIcon from "@mui/icons-material/Description"; // for single page
import CloseIcon from "@mui/icons-material/Close";
import { useLocalStorage } from "@mantine/hooks";
import { fileStorage } from "../../services/fileStorage";
import SkeletonLoader from '../shared/SkeletonLoader';
@ -135,6 +136,8 @@ export interface ViewerProps {
setPdfFile: (file: { file: File; url: string } | null) => void;
sidebarsVisible: boolean;
setSidebarsVisible: (v: boolean) => void;
onClose?: () => void;
previewFile?: File; // For preview mode - bypasses context
}
const Viewer = ({
@ -142,6 +145,8 @@ const Viewer = ({
setPdfFile,
sidebarsVisible,
setSidebarsVisible,
onClose,
previewFile,
}: ViewerProps) => {
const { t } = useTranslation();
const theme = useMantineTheme();
@ -152,6 +157,61 @@ const Viewer = ({
const [dualPage, setDualPage] = useState(false);
const [zoom, setZoom] = useState(1); // 1 = 100%
const pageRefs = useRef<(HTMLImageElement | null)[]>([]);
// Store preview URL ref to manage lifecycle properly
const previewUrlRef = useRef<string | null>(null);
const currentPreviewFileRef = useRef<File | null>(null);
// Use preview file if available, otherwise use context file
const effectiveFile = React.useMemo(() => {
if (previewFile) {
// Clean up previous preview URL if it's different
if (previewUrlRef.current && currentPreviewFileRef.current !== previewFile) {
URL.revokeObjectURL(previewUrlRef.current);
previewUrlRef.current = null;
}
// Validate the preview file
if (!(previewFile instanceof File)) {
console.error('Preview file is not a valid File object:', previewFile);
return null;
}
if (previewFile.size === 0) {
console.error('Preview file is empty:', previewFile.name);
return null;
}
if (previewFile.type !== 'application/pdf') {
console.warn('Preview file is not a PDF:', previewFile.type);
}
// For preview files, we'll handle loading differently (no URL needed)
// but we still need to return a consistent structure
currentPreviewFileRef.current = previewFile;
console.log('Preview file set:', { name: previewFile.name, size: previewFile.size, type: previewFile.type });
return { file: previewFile, url: null };
} else {
// Clean up preview URL when switching away from preview
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current);
previewUrlRef.current = null;
currentPreviewFileRef.current = null;
}
return pdfFile;
}
}, [previewFile, pdfFile]);
// Cleanup preview file URL on unmount only
useEffect(() => {
return () => {
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current);
previewUrlRef.current = null;
}
};
}, []);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const userInitiatedRef = useRef(false);
const suppressScrollRef = useRef(false);
@ -162,7 +222,7 @@ const Viewer = ({
// Function to render a specific page on-demand
const renderPage = async (pageIndex: number): Promise<string | null> => {
if (!pdfFile || !pdfDocRef.current || renderingPagesRef.current.has(pageIndex)) {
if (!pdfDocRef.current || renderingPagesRef.current.has(pageIndex)) {
return null;
}
@ -315,18 +375,27 @@ const Viewer = ({
useEffect(() => {
let cancelled = false;
async function loadPdfInfo() {
if (!pdfFile || !pdfFile.url) {
console.log('Loading PDF info:', { effectiveFile, previewFile: !!previewFile });
if (!effectiveFile) {
console.log('No effective file');
setNumPages(0);
setPageImages([]);
return;
}
setLoading(true);
try {
let pdfUrl = pdfFile.url;
let pdfData;
// For preview files, use ArrayBuffer directly to avoid blob URL issues
if (previewFile && effectiveFile.file === previewFile) {
console.log('Loading preview file as ArrayBuffer');
const arrayBuffer = await previewFile.arrayBuffer();
pdfData = { data: arrayBuffer };
console.log('Preview file ArrayBuffer created:', arrayBuffer.byteLength, 'bytes');
}
// Handle special IndexedDB URLs for large files
if (pdfFile.url.startsWith('indexeddb:')) {
const fileId = pdfFile.url.replace('indexeddb:', '');
else if (effectiveFile.url?.startsWith('indexeddb:')) {
const fileId = effectiveFile.url.replace('indexeddb:', '');
console.log('Loading large file from IndexedDB:', fileId);
// Get data directly from IndexedDB
@ -337,19 +406,16 @@ const Viewer = ({
// Store reference for cleanup
currentArrayBufferRef.current = arrayBuffer;
// Use ArrayBuffer directly instead of creating blob URL
const pdf = await getDocument({ data: arrayBuffer }).promise;
pdfDocRef.current = pdf;
setNumPages(pdf.numPages);
if (!cancelled) {
setPageImages(new Array(pdf.numPages).fill(null));
// Start progressive preloading after a short delay
setTimeout(() => startProgressivePreload(), 100);
}
} else {
pdfData = { data: arrayBuffer };
} else if (effectiveFile.url) {
// Standard blob URL or regular URL
const pdf = await getDocument(pdfUrl).promise;
console.log('Loading PDF from URL:', effectiveFile.url);
pdfData = effectiveFile.url;
} else {
throw new Error('No valid PDF source available');
}
const pdf = await getDocument(pdfData).promise;
pdfDocRef.current = pdf;
setNumPages(pdf.numPages);
if (!cancelled) {
@ -357,7 +423,6 @@ const Viewer = ({
// Start progressive preloading after a short delay
setTimeout(() => startProgressivePreload(), 100);
}
}
} catch (error) {
console.error('Failed to load PDF:', error);
if (!cancelled) {
@ -375,7 +440,7 @@ const Viewer = ({
// Cleanup ArrayBuffer reference to help garbage collection
currentArrayBufferRef.current = null;
};
}, [pdfFile]);
}, [effectiveFile, previewFile]);
useEffect(() => {
const viewport = scrollAreaRef.current;
@ -388,8 +453,27 @@ const Viewer = ({
}, [pageImages]);
return (
<>
{!pdfFile ? (
<Box style={{ position: 'relative', height: '100%', display: 'flex', flexDirection: 'column' }}>
{/* Close Button - Only show in preview mode */}
{onClose && previewFile && (
<ActionIcon
variant="filled"
color="gray"
size="lg"
style={{
position: 'absolute',
top: '1rem',
right: '1rem',
zIndex: 1000,
borderRadius: '50%',
}}
onClick={onClose}
>
<CloseIcon />
</ActionIcon>
)}
{!effectiveFile ? (
<Center style={{ flex: 1 }}>
<Stack align="center">
<Text c="dimmed">{t("viewer.noPdfLoaded", "No PDF loaded. Click to upload a PDF.")}</Text>
@ -609,7 +693,7 @@ const Viewer = ({
</ScrollArea>
)}
</>
</Box>
);
};

View File

@ -12,6 +12,7 @@ import {
ToolType,
FileOperation,
FileEditHistory,
FileOperationHistory,
ViewerConfig,
FileContextUrlParams
} from '../types/fileContext';
@ -38,6 +39,7 @@ const initialState: FileContextState = {
currentTool: null, // Legacy field
fileEditHistory: new Map(),
globalFileOperations: [],
fileOperationHistory: new Map(),
selectedFileIds: [],
selectedPageNumbers: [],
viewerConfig: initialViewerConfig,
@ -66,6 +68,10 @@ type FileContextAction =
| { type: 'UPDATE_VIEWER_CONFIG'; payload: Partial<ViewerConfig> }
| { type: 'ADD_PAGE_OPERATIONS'; payload: { fileId: string; operations: PageOperation[] } }
| { type: 'ADD_FILE_OPERATION'; payload: 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: string }
| { type: 'SET_EXPORT_CONFIG'; payload: FileContextState['lastExportConfig'] }
| { type: 'SET_UNSAVED_CHANGES'; payload: boolean }
| { type: 'SET_PENDING_NAVIGATION'; payload: (() => void) | null }
@ -94,10 +100,11 @@ function fileContextReducer(state: FileContextState, action: FileContextAction):
const remainingFiles = state.activeFiles.filter(file =>
!action.payload.includes(file.name) // Simple ID for now, could use file.name or generate IDs
);
const safeSelectedFileIds = Array.isArray(state.selectedFileIds) ? state.selectedFileIds : [];
return {
...state,
activeFiles: remainingFiles,
selectedFileIds: state.selectedFileIds.filter(id => !action.payload.includes(id))
selectedFileIds: safeSelectedFileIds.filter(id => !action.payload.includes(id))
};
case 'SET_PROCESSED_FILES':
@ -200,6 +207,90 @@ function fileContextReducer(state: FileContextState, action: FileContextAction):
globalFileOperations: [...state.globalFileOperations, action.payload]
};
case 'RECORD_OPERATION':
const { fileId, operation } = action.payload;
const newOperationHistory = new Map(state.fileOperationHistory);
const existingHistory = newOperationHistory.get(fileId);
if (existingHistory) {
// Add operation to existing history
newOperationHistory.set(fileId, {
...existingHistory,
operations: [...existingHistory.operations, operation],
lastModified: Date.now()
});
} else {
// Create new history for this file
newOperationHistory.set(fileId, {
fileId,
fileName: fileId, // Will be updated with actual filename when available
operations: [operation],
createdAt: Date.now(),
lastModified: Date.now()
});
}
return {
...state,
fileOperationHistory: newOperationHistory
};
case 'MARK_OPERATION_APPLIED':
const appliedHistory = new Map(state.fileOperationHistory);
const appliedFileHistory = appliedHistory.get(action.payload.fileId);
if (appliedFileHistory) {
const updatedOperations = appliedFileHistory.operations.map(op =>
op.id === action.payload.operationId
? { ...op, status: 'applied' as const }
: op
);
appliedHistory.set(action.payload.fileId, {
...appliedFileHistory,
operations: updatedOperations,
lastModified: Date.now()
});
}
return {
...state,
fileOperationHistory: appliedHistory
};
case 'MARK_OPERATION_FAILED':
const failedHistory = new Map(state.fileOperationHistory);
const failedFileHistory = failedHistory.get(action.payload.fileId);
if (failedFileHistory) {
const updatedOperations = failedFileHistory.operations.map(op =>
op.id === action.payload.operationId
? {
...op,
status: 'failed' as const,
metadata: { ...op.metadata, error: action.payload.error }
}
: op
);
failedHistory.set(action.payload.fileId, {
...failedFileHistory,
operations: updatedOperations,
lastModified: Date.now()
});
}
return {
...state,
fileOperationHistory: failedHistory
};
case 'CLEAR_FILE_HISTORY':
const clearedHistory = new Map(state.fileOperationHistory);
clearedHistory.delete(action.payload);
return {
...state,
fileOperationHistory: clearedHistory
};
case 'SET_EXPORT_CONFIG':
return {
...state,
@ -413,7 +504,7 @@ export function FileContextProvider({
}
}, [enablePersistence]);
const removeFiles = useCallback((fileIds: string[]) => {
const removeFiles = useCallback((fileIds: string[], deleteFromStorage: boolean = true) => {
// FULL cleanup for actually removed files (including cache)
fileIds.forEach(fileId => {
// Cancel processing and clear caches when file is actually removed
@ -423,8 +514,8 @@ export function FileContextProvider({
dispatch({ type: 'REMOVE_FILES', payload: fileIds });
// Remove from IndexedDB
if (enablePersistence) {
// Remove from IndexedDB only if requested
if (enablePersistence && deleteFromStorage) {
fileIds.forEach(async (fileId) => {
try {
await fileStorage.removeFile(fileId);
@ -435,6 +526,7 @@ export function FileContextProvider({
}
}, [enablePersistence, cleanupFile]);
const replaceFile = useCallback(async (oldFileId: string, newFile: File) => {
// Remove old file and add new one
removeFiles([oldFileId]);
@ -551,6 +643,32 @@ export function FileContextProvider({
dispatch({ type: 'SET_EXPORT_CONFIG', payload: config });
}, []);
// Operation history management functions
const recordOperation = useCallback((fileId: string, operation: FileOperation | PageOperation) => {
dispatch({ type: 'RECORD_OPERATION', payload: { fileId, operation } });
}, []);
const markOperationApplied = useCallback((fileId: string, operationId: string) => {
dispatch({ type: 'MARK_OPERATION_APPLIED', payload: { fileId, operationId } });
}, []);
const markOperationFailed = useCallback((fileId: string, operationId: string, error: string) => {
dispatch({ type: 'MARK_OPERATION_FAILED', payload: { fileId, operationId, error } });
}, []);
const getFileHistory = useCallback((fileId: string): FileOperationHistory | undefined => {
return state.fileOperationHistory.get(fileId);
}, [state.fileOperationHistory]);
const getAppliedOperations = useCallback((fileId: string): (FileOperation | PageOperation)[] => {
const history = state.fileOperationHistory.get(fileId);
return history ? history.operations.filter(op => op.status === 'applied') : [];
}, [state.fileOperationHistory]);
const clearFileHistory = useCallback((fileId: string) => {
dispatch({ type: 'CLEAR_FILE_HISTORY', payload: fileId });
}, []);
// Utility functions
const getFileById = useCallback((fileId: string): File | undefined => {
return state.activeFiles.find(file => file.name === fileId); // Simple ID matching
@ -663,6 +781,14 @@ export function FileContextProvider({
loadContext,
resetContext,
// Operation history management
recordOperation,
markOperationApplied,
markOperationFailed,
getFileHistory,
getAppliedOperations,
clearFileHistory,
// Navigation guard system
setHasUnsavedChanges,
requestNavigation,

View File

@ -95,8 +95,11 @@ export function useEnhancedProcessedFiles(
}
}
// Update processed files (hash mapping is updated via ref)
if (newProcessedFiles.size > 0 || processedFiles.size > 0) {
// Only update if the content actually changed
const hasChanged = newProcessedFiles.size !== processedFiles.size ||
Array.from(newProcessedFiles.keys()).some(file => !processedFiles.has(file));
if (hasChanged) {
setProcessedFiles(newProcessedFiles);
}
};

View File

@ -62,6 +62,7 @@ export default function HomePage() {
const [pageEditorFunctions, setPageEditorFunctions] = useState<any>(null);
const [toolSelectedFiles, setToolSelectedFiles] = useState<File[]>([]);
const [toolParams, setToolParams] = useState<Record<string, any>>({});
const [previewFile, setPreviewFile] = useState<File | null>(null);
// Tool registry
const toolRegistry: ToolRegistry = {
@ -181,6 +182,7 @@ export default function HomePage() {
const handleViewChange = useCallback((view: string) => {
setCurrentView(view as any);
}, [setCurrentView]);
const addToActiveFiles = useCallback(async (file: File) => {
const exists = activeFiles.some(f => f.name === file.name && f.size === file.size);
if (!exists) {
@ -385,6 +387,7 @@ export default function HomePage() {
toolParams={getToolParams(selectedToolKey)}
updateParams={(newParams) => updateToolParams(selectedToolKey, newParams)}
toolSelectedFiles={toolSelectedFiles}
onPreviewFile={setPreviewFile}
/>
</div>
</div>
@ -457,6 +460,21 @@ export default function HomePage() {
}}
sidebarsVisible={sidebarsVisible}
setSidebarsVisible={setSidebarsVisible}
previewFile={previewFile}
{...(previewFile && {
onClose: () => {
setPreviewFile(null); // Clear preview file
const previousMode = sessionStorage.getItem('previousMode');
if (previousMode === 'split') {
setSelectedToolKey('split');
setCurrentView('split');
setLeftPanelView('toolContent');
sessionStorage.removeItem('previousMode');
} else {
setCurrentView('fileEditor');
}
}
})}
/>
) : currentView === "pageEditor" ? (
<>

View File

@ -185,7 +185,7 @@ export class PDFExportService {
* Download multiple files as a ZIP
*/
async downloadAsZip(blobs: Blob[], filenames: string[], zipFilename: string): Promise<void> {
// For now, download files individually
// For now, download files wherindividually
blobs.forEach((blob, index) => {
setTimeout(() => {
this.downloadFile(blob, filenames[index]);

View File

@ -0,0 +1,300 @@
import JSZip from 'jszip';
export interface ZipExtractionResult {
success: boolean;
extractedFiles: File[];
errors: string[];
totalFiles: number;
extractedCount: number;
}
export interface ZipValidationResult {
isValid: boolean;
fileCount: number;
totalSizeBytes: number;
containsPDFs: boolean;
errors: string[];
}
export interface ZipExtractionProgress {
currentFile: string;
extractedCount: number;
totalFiles: number;
progress: number;
}
export class ZipFileService {
private readonly maxFileSize = 100 * 1024 * 1024; // 100MB per file
private readonly maxTotalSize = 500 * 1024 * 1024; // 500MB total extraction limit
private readonly supportedExtensions = ['.pdf'];
/**
* Validate a ZIP file without extracting it
*/
async validateZipFile(file: File): Promise<ZipValidationResult> {
const result: ZipValidationResult = {
isValid: false,
fileCount: 0,
totalSizeBytes: 0,
containsPDFs: false,
errors: []
};
try {
// Check file size
if (file.size > this.maxTotalSize) {
result.errors.push(`ZIP file too large: ${this.formatFileSize(file.size)} (max: ${this.formatFileSize(this.maxTotalSize)})`);
return result;
}
// Check file type
if (!this.isZipFile(file)) {
result.errors.push('File is not a valid ZIP archive');
return result;
}
// Load and validate ZIP contents
const zip = new JSZip();
const zipContents = await zip.loadAsync(file);
let totalSize = 0;
let fileCount = 0;
let containsPDFs = false;
// Analyze ZIP contents
for (const [filename, zipEntry] of Object.entries(zipContents.files)) {
if (zipEntry.dir) {
continue; // Skip directories
}
fileCount++;
const uncompressedSize = zipEntry._data?.uncompressedSize || 0;
totalSize += uncompressedSize;
// Check if file is a PDF
if (this.isPdfFile(filename)) {
containsPDFs = true;
}
// Check individual file size
if (uncompressedSize > this.maxFileSize) {
result.errors.push(`File "${filename}" too large: ${this.formatFileSize(uncompressedSize)} (max: ${this.formatFileSize(this.maxFileSize)})`);
}
}
// Check total uncompressed size
if (totalSize > this.maxTotalSize) {
result.errors.push(`Total uncompressed size too large: ${this.formatFileSize(totalSize)} (max: ${this.formatFileSize(this.maxTotalSize)})`);
}
result.fileCount = fileCount;
result.totalSizeBytes = totalSize;
result.containsPDFs = containsPDFs;
result.isValid = result.errors.length === 0 && containsPDFs;
if (!containsPDFs) {
result.errors.push('ZIP file does not contain any PDF files');
}
return result;
} catch (error) {
result.errors.push(`Failed to validate ZIP file: ${error instanceof Error ? error.message : 'Unknown error'}`);
return result;
}
}
/**
* Extract PDF files from a ZIP archive
*/
async extractPdfFiles(
file: File,
onProgress?: (progress: ZipExtractionProgress) => void
): Promise<ZipExtractionResult> {
const result: ZipExtractionResult = {
success: false,
extractedFiles: [],
errors: [],
totalFiles: 0,
extractedCount: 0
};
try {
// Validate ZIP file first
const validation = await this.validateZipFile(file);
if (!validation.isValid) {
result.errors = validation.errors;
return result;
}
// Load ZIP contents
const zip = new JSZip();
const zipContents = await zip.loadAsync(file);
// Get all PDF files
const pdfFiles = Object.entries(zipContents.files).filter(([filename, zipEntry]) =>
!zipEntry.dir && this.isPdfFile(filename)
);
result.totalFiles = pdfFiles.length;
// Extract each PDF file
for (let i = 0; i < pdfFiles.length; i++) {
const [filename, zipEntry] = pdfFiles[i];
try {
// Report progress
if (onProgress) {
onProgress({
currentFile: filename,
extractedCount: i,
totalFiles: pdfFiles.length,
progress: (i / pdfFiles.length) * 100
});
}
// Extract file content
const content = await zipEntry.async('uint8array');
// Create File object
const extractedFile = new File([content], this.sanitizeFilename(filename), {
type: 'application/pdf',
lastModified: zipEntry.date?.getTime() || Date.now()
});
// Validate extracted PDF
if (await this.isValidPdfFile(extractedFile)) {
result.extractedFiles.push(extractedFile);
result.extractedCount++;
} else {
result.errors.push(`File "${filename}" is not a valid PDF`);
}
} catch (error) {
result.errors.push(`Failed to extract "${filename}": ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
// Final progress report
if (onProgress) {
onProgress({
currentFile: '',
extractedCount: result.extractedCount,
totalFiles: result.totalFiles,
progress: 100
});
}
result.success = result.extractedCount > 0;
return result;
} catch (error) {
result.errors.push(`Failed to extract ZIP file: ${error instanceof Error ? error.message : 'Unknown error'}`);
return result;
}
}
/**
* Check if a file is a ZIP file based on type and extension
*/
private isZipFile(file: File): boolean {
const validTypes = [
'application/zip',
'application/x-zip-compressed',
'application/x-zip',
'application/octet-stream' // Some browsers use this for ZIP files
];
const validExtensions = ['.zip'];
const hasValidType = validTypes.includes(file.type);
const hasValidExtension = validExtensions.some(ext =>
file.name.toLowerCase().endsWith(ext)
);
return hasValidType || hasValidExtension;
}
/**
* Check if a filename indicates a PDF file
*/
private isPdfFile(filename: string): boolean {
return filename.toLowerCase().endsWith('.pdf');
}
/**
* Validate that a file is actually a PDF by checking its header
*/
private async isValidPdfFile(file: File): Promise<boolean> {
try {
// Read first few bytes to check PDF header
const buffer = await file.slice(0, 8).arrayBuffer();
const bytes = new Uint8Array(buffer);
// Check for PDF header: %PDF-
return bytes[0] === 0x25 && // %
bytes[1] === 0x50 && // P
bytes[2] === 0x44 && // D
bytes[3] === 0x46 && // F
bytes[4] === 0x2D; // -
} catch (error) {
return false;
}
}
/**
* Sanitize filename for safe use
*/
private sanitizeFilename(filename: string): string {
// Remove directory path and get just the filename
const basename = filename.split('/').pop() || filename;
// Remove or replace unsafe characters
return basename
.replace(/[<>:"/\\|?*]/g, '_') // Replace unsafe chars with underscore
.replace(/\s+/g, '_') // Replace spaces with underscores
.replace(/_{2,}/g, '_') // Replace multiple underscores with single
.replace(/^_|_$/g, ''); // Remove leading/trailing underscores
}
/**
* Format file size for display
*/
private formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
/**
* Get file extension from filename
*/
private getFileExtension(filename: string): string {
return filename.substring(filename.lastIndexOf('.')).toLowerCase();
}
/**
* Check if ZIP file contains password protection
*/
private async isPasswordProtected(file: File): Promise<boolean> {
try {
const zip = new JSZip();
await zip.loadAsync(file);
// Check if any files are encrypted
for (const [filename, zipEntry] of Object.entries(zip.files)) {
if (zipEntry.options?.compression === 'STORE' && zipEntry._data?.compressedSize === 0) {
// This might indicate encryption, but JSZip doesn't provide direct encryption detection
// We'll handle this in the extraction phase
}
}
return false; // JSZip will throw an error if password is required
} catch (error) {
// If we can't load the ZIP, it might be password protected
const errorMessage = error instanceof Error ? error.message : '';
return errorMessage.includes('password') || errorMessage.includes('encrypted');
}
}
}
// Export singleton instance
export const zipFileService = new ZipFileService();

View File

@ -12,10 +12,17 @@ import {
Alert,
Box,
Group,
Grid,
Image,
Loader,
Center,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import DownloadIcon from "@mui/icons-material/Download";
import { useFileContext } from "../contexts/FileContext";
import { FileOperation } from "../types/fileContext";
import { zipFileService } from "../services/zipFileService";
import { generateThumbnailForFile } from "../utils/thumbnailUtils";
import FileEditor from "../components/fileEditor/FileEditor";
export interface SplitPdfPanelProps {
@ -33,21 +40,33 @@ export interface SplitPdfPanelProps {
};
updateParams: (newParams: Partial<SplitPdfPanelProps["params"]>) => void;
selectedFiles?: File[];
onPreviewFile?: (file: File | null) => void;
}
const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
params,
updateParams,
selectedFiles = [],
onPreviewFile,
}) => {
const { t } = useTranslation();
const fileContext = useFileContext();
const { activeFiles, selectedFileIds, updateProcessedFile } = fileContext;
const { activeFiles, selectedFileIds, updateProcessedFile, recordOperation, markOperationApplied, markOperationFailed, setCurrentMode } = fileContext;
const [status, setStatus] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [splitResults, setSplitResults] = useState<{
files: File[];
thumbnails: string[];
isGeneratingThumbnails: boolean;
}>({
files: [],
thumbnails: [],
isGeneratingThumbnails: false
});
const [previewFile, setPreviewFile] = useState<File | null>(null);
const {
mode,
@ -68,8 +87,15 @@ const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
setDownloadUrl(null);
setStatus("");
}
// Reset step 2 completion when parameters change (but not when just status/loading changes)
setStep2Completed(false);
// Clear split results and preview file
setSplitResults({
files: [],
thumbnails: [],
isGeneratingThumbnails: false
});
setPreviewFile(null);
onPreviewFile?.(null);
// Parameters changed - results will be cleared automatically
}, [mode, pages, hDiv, vDiv, merge, splitType, splitValue, bookmarkLevel, includeMetadata, allowDuplicates, selectedFiles]);
@ -118,6 +144,36 @@ const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
return;
}
// Record the operation before starting
const operationId = `split-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const fileId = selectedFiles[0].name; // Use first file's name as primary ID
const operation: FileOperation = {
id: operationId,
type: 'split',
timestamp: Date.now(),
fileIds: selectedFiles.map(f => f.name),
status: 'pending',
metadata: {
originalFileName: selectedFiles[0].name,
parameters: {
mode,
pages: mode === 'byPages' ? pages : undefined,
hDiv: mode === 'bySections' ? hDiv : undefined,
vDiv: mode === 'bySections' ? vDiv : undefined,
merge: mode === 'bySections' ? merge : undefined,
splitType: mode === 'bySizeOrCount' ? splitType : undefined,
splitValue: mode === 'bySizeOrCount' ? splitValue : undefined,
bookmarkLevel: mode === 'byChapters' ? bookmarkLevel : undefined,
includeMetadata: mode === 'byChapters' ? includeMetadata : undefined,
allowDuplicates: mode === 'byChapters' ? allowDuplicates : undefined,
},
fileSize: selectedFiles[0].size
}
};
recordOperation(fileId, operation);
setStatus(t("loading"));
setIsLoading(true);
setErrorMessage(null);
@ -128,6 +184,47 @@ const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
const url = window.URL.createObjectURL(blob);
setDownloadUrl(url);
setStatus(t("downloadComplete"));
// Extract files from ZIP response for preview
try {
// Create a File object from the blob to use with zipFileService
const zipFile = new File([blob], "split_result.zip", { type: "application/zip" });
// Extract PDF files for preview
const extractionResult = await zipFileService.extractPdfFiles(zipFile);
if (extractionResult.success && extractionResult.extractedFiles.length > 0) {
setSplitResults(prev => ({
...prev,
files: extractionResult.extractedFiles,
isGeneratingThumbnails: true
}));
// Generate thumbnails for preview
const thumbnails = await Promise.all(
extractionResult.extractedFiles.map(async (file) => {
try {
return await generateThumbnailForFile(file);
} catch (error) {
console.warn(`Failed to generate thumbnail for ${file.name}:`, error);
return ''; // Empty string for failed thumbnails
}
})
);
setSplitResults(prev => ({
...prev,
thumbnails,
isGeneratingThumbnails: false
}));
}
} catch (extractError) {
console.warn('Failed to extract files for preview:', extractError);
// Don't fail the whole operation just because preview extraction failed
}
// Mark operation as applied on success
markOperationApplied(fileId, operationId);
} catch (error: any) {
console.error(error);
let errorMsg = t("error.pdfPassword", "An error occurred while splitting the PDF.");
@ -138,6 +235,9 @@ const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
}
setErrorMessage(errorMsg);
setStatus(t("error._value", "Split failed."));
// Mark operation as failed
markOperationFailed(fileId, operationId, errorMsg);
} finally {
setIsLoading(false);
}
@ -148,10 +248,26 @@ const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
return currentMode && currentMode !== ""; // All modes need some params
};
// Step 2 completion state
const [step2Completed, setStep2Completed] = useState(false);
// Handle thumbnail click to open in viewer
const handleThumbnailClick = (file: File) => {
try {
// Set as preview file (no context pollution)
setPreviewFile(file);
onPreviewFile?.(file);
// Check if step 2 settings are valid (for enabling Done button)
// Store that we came from Split tool for return navigation
sessionStorage.setItem('previousMode', 'split');
// Switch to viewer mode
setCurrentMode('viewer');
} catch (error) {
console.error('Failed to open file in viewer:', error);
}
};
// No longer needed - step completion is determined by split results
// Check if step 2 settings are valid (for enabling Split button)
const step2SettingsValid = (() => {
if (!mode) return false;
@ -172,11 +288,11 @@ const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
// Determine what steps to show
const showStep1 = true; // Always show - Files
const showStep2 = selectedFiles.length > 0; // Settings (mode + params)
const showStep3 = step2Completed; // Review (apply & continue vs export)
const showStep3 = downloadUrl !== null; // Review (show results after split)
// Determine if steps are collapsed (completed)
const step1Collapsed = selectedFiles.length > 0;
const step2Collapsed = step2Completed;
const step2Collapsed = downloadUrl !== null;
return (
<Box h="100%" p="md" style={{ overflow: 'auto' }}>
@ -208,14 +324,26 @@ const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
transition: 'opacity 0.2s ease'
}}
onClick={step2Collapsed ? () => {
// Go back to step 2
setStep2Completed(false);
// Reset to allow changing settings
setDownloadUrl(null);
setSplitResults({
files: [],
thumbnails: [],
isGeneratingThumbnails: false
});
setStatus("");
setErrorMessage(null);
// Clear any active preview and return to previous view
setPreviewFile(null);
onPreviewFile?.(null);
// Return to the Split tool view
setCurrentMode('split');
} : undefined}
>
<Text fw={500} size="lg" mb="sm">2. Settings</Text>
{step2Collapsed ? (
<Text size="sm" c="green">
Settings configured <Text span c="dimmed" size="xs">(click to change)</Text>
Split completed <Text span c="dimmed" size="xs">(click to change settings)</Text>
</Text>
) : (
<Stack gap="md">
@ -312,41 +440,34 @@ const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
</Stack>
)}
{/* Done Button */}
{/* Split Button */}
{mode && (
<form onSubmit={handleSubmit}>
<Button
type="submit"
fullWidth
mt="md"
disabled={!step2SettingsValid}
onClick={() => setStep2Completed(true)}
loading={isLoading}
disabled={!step2SettingsValid || selectedFiles.length === 0}
>
Done
{isLoading ? t("loading") : t("split.submit", "Split PDF")}
</Button>
</form>
)}
</Stack>
)}
</Paper>
)}
{/* Step 3: Review */}
{/* Step 3: Results */}
{showStep3 && (
<Paper p="md" withBorder>
<Text fw={500} size="lg" mb="sm">3. Review</Text>
<Text fw={500} size="lg" mb="sm">3. Results</Text>
<form onSubmit={handleSubmit}>
<Button
type="submit"
loading={isLoading}
fullWidth
disabled={selectedFiles.length === 0}
>
{isLoading ? t("loading") : t("split.submit", "Split PDF")}
</Button>
{status && <Text size="xs" c="dimmed" mt="xs">{status}</Text>}
{status && <Text size="sm" c="dimmed" mb="md">{status}</Text>}
{errorMessage && (
<Notification color="red" title={t("error._value", "Error")} onClose={() => setErrorMessage(null)} mt="sm">
<Notification color="red" title={t("error._value", "Error")} onClose={() => setErrorMessage(null)} mb="md">
{errorMessage}
</Notification>
)}
@ -359,12 +480,88 @@ const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
leftSection={<DownloadIcon />}
color="green"
fullWidth
mt="sm"
mb="md"
>
{t("downloadPdf", "Download Split PDF")}
</Button>
)}
</form>
{/* Split Results Preview */}
{(splitResults.files.length > 0 || splitResults.isGeneratingThumbnails) && (
<Box mt="lg" p="md" style={{ backgroundColor: 'var(--mantine-color-gray-0)', borderRadius: 8 }}>
<Text fw={500} size="md" mb="sm">
Split Results ({splitResults.files.length} files)
</Text>
{splitResults.isGeneratingThumbnails ? (
<Center p="lg">
<Stack align="center" gap="sm">
<Loader size="sm" />
<Text size="sm" c="dimmed">Generating previews...</Text>
</Stack>
</Center>
) : (
<Grid>
{splitResults.files.map((file, index) => (
<Grid.Col span={{ base: 6, sm: 4, md: 3 }} key={index}>
<Paper
p="xs"
withBorder
style={{
textAlign: 'center',
height: '200px',
display: 'flex',
flexDirection: 'column',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
onClick={() => handleThumbnailClick(file)}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.02)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.1)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
e.currentTarget.style.boxShadow = '';
}}
>
<Box style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{splitResults.thumbnails[index] ? (
<Image
src={splitResults.thumbnails[index]}
alt={`Preview of ${file.name}`}
style={{
maxWidth: '100%',
maxHeight: '140px',
objectFit: 'contain'
}}
/>
) : (
<Text size="xs" c="dimmed">No preview</Text>
)}
</Box>
<Text
size="xs"
c="dimmed"
mt="xs"
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
title={file.name}
>
{file.name}
</Text>
<Text size="xs" c="dimmed">
{(file.size / 1024).toFixed(1)} KB
</Text>
</Paper>
</Grid.Col>
))}
</Grid>
)}
</Box>
)}
</Paper>
)}
</Stack>

View File

@ -13,10 +13,27 @@ export type ToolType = 'merge' | 'split' | 'compress' | null;
export interface FileOperation {
id: string;
type: 'merge' | 'add' | 'remove' | 'replace';
type: 'merge' | 'split' | 'compress' | 'add' | 'remove' | 'replace' | 'convert' | 'upload';
timestamp: number;
fileIds: string[];
status: 'pending' | 'applied' | 'failed';
data?: any;
metadata?: {
originalFileName?: string;
outputFileNames?: string[];
parameters?: Record<string, any>;
fileSize?: number;
pageCount?: number;
error?: string;
};
}
export interface FileOperationHistory {
fileId: string;
fileName: string;
operations: (FileOperation | PageOperation)[];
createdAt: number;
lastModified: number;
}
export interface ViewerConfig {
@ -46,6 +63,8 @@ export interface FileContextState {
// Edit history and state
fileEditHistory: Map<string, FileEditHistory>;
globalFileOperations: FileOperation[];
// New comprehensive operation history
fileOperationHistory: Map<string, FileOperationHistory>;
// UI state that persists across views
selectedFileIds: string[];
@ -72,7 +91,7 @@ export interface FileContextState {
export interface FileContextActions {
// File management
addFiles: (files: File[]) => Promise<void>;
removeFiles: (fileIds: string[]) => void;
removeFiles: (fileIds: string[], deleteFromStorage?: boolean) => void;
replaceFile: (oldFileId: string, newFile: File) => Promise<void>;
clearAllFiles: () => void;
@ -93,6 +112,14 @@ export interface FileContextActions {
applyFileOperation: (operation: FileOperation) => void;
undoLastOperation: (fileId?: string) => void;
// Operation history management
recordOperation: (fileId: string, operation: FileOperation | PageOperation) => void;
markOperationApplied: (fileId: string, operationId: string) => void;
markOperationFailed: (fileId: string, operationId: string, error: string) => void;
getFileHistory: (fileId: string) => FileOperationHistory | undefined;
getAppliedOperations: (fileId: string) => (FileOperation | PageOperation)[];
clearFileHistory: (fileId: string) => void;
// Viewer state
updateViewerConfig: (config: Partial<ViewerConfig>) => void;

View File

@ -16,9 +16,20 @@ export interface PDFDocument {
}
export interface PageOperation {
type: 'rotate' | 'delete' | 'move' | 'split' | 'insert';
id: string;
type: 'rotate' | 'delete' | 'move' | 'split' | 'insert' | 'reorder';
pageIds: string[];
timestamp: number;
status: 'pending' | 'applied' | 'failed';
data?: any;
metadata?: {
rotation?: number;
fromPosition?: number;
toPosition?: number;
splitType?: string;
insertAfterPage?: number;
error?: string;
};
}
export interface UndoRedoState {