mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-09-08 17:51:20 +02:00
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:
parent
c7dce8a68f
commit
83b8c9be09
76
frontend/package-lock.json
generated
76
frontend/package-lock.json
generated
@ -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": {
|
||||
|
@ -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",
|
||||
|
@ -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;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
// Extract PDF files from ZIP
|
||||
setZipExtractionProgress({
|
||||
isExtracting: true,
|
||||
currentFile: file.name,
|
||||
progress: 0,
|
||||
extractedCount: 0,
|
||||
totalFiles: validation.fileCount
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
if (validFiles.length > 0) {
|
||||
// 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]);
|
||||
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'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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 => prev.filter(id => id !== file.name));
|
||||
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]);
|
||||
}, [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,39 +684,71 @@ 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 */}
|
||||
<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>
|
||||
<Text size="sm" c="dimmed">{Math.round(conversionProgress)}%</Text>
|
||||
</Group>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '4px',
|
||||
backgroundColor: 'var(--mantine-color-gray-2)',
|
||||
borderRadius: '2px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${Math.round(conversionProgress)}%`,
|
||||
height: '100%',
|
||||
backgroundColor: 'var(--mantine-color-blue-6)',
|
||||
transition: 'width 0.3s ease'
|
||||
}} />
|
||||
</div>
|
||||
</Box>
|
||||
{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>
|
||||
<Text size="sm" c="dimmed">{Math.round(conversionProgress)}%</Text>
|
||||
</Group>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '4px',
|
||||
backgroundColor: 'var(--mantine-color-gray-2)',
|
||||
borderRadius: '2px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${Math.round(conversionProgress)}%`,
|
||||
height: '100%',
|
||||
backgroundColor: 'var(--mantine-color-blue-6)',
|
||||
transition: 'width 0.3s ease'
|
||||
}} />
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<SkeletonLoader type="fileGrid" count={6} />
|
||||
</Box>
|
||||
|
177
frontend/src/components/history/FileOperationHistory.tsx
Normal file
177
frontend/src/components/history/FileOperationHistory.tsx
Normal 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;
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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")
|
||||
}
|
||||
|
@ -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"
|
||||
|
@ -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":
|
||||
|
@ -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,26 +406,22 @@ 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;
|
||||
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);
|
||||
}
|
||||
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) {
|
||||
setPageImages(new Array(pdf.numPages).fill(null));
|
||||
// Start progressive preloading after a short delay
|
||||
setTimeout(() => startProgressivePreload(), 100);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load PDF:', error);
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
@ -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" ? (
|
||||
<>
|
||||
|
@ -12,12 +12,12 @@ export class PDFExportService {
|
||||
* Export PDF document with applied operations
|
||||
*/
|
||||
async exportPDF(
|
||||
pdfDocument: PDFDocument,
|
||||
pdfDocument: PDFDocument,
|
||||
selectedPageIds: string[] = [],
|
||||
options: ExportOptions = {}
|
||||
): Promise<{ blob: Blob; filename: string } | { blobs: Blob[]; filenames: string[] }> {
|
||||
const { selectedOnly = false, filename, splitDocuments = false } = options;
|
||||
|
||||
|
||||
try {
|
||||
// Determine which pages to export
|
||||
const pagesToExport = selectedOnly && selectedPageIds.length > 0
|
||||
@ -57,16 +57,16 @@ export class PDFExportService {
|
||||
for (const page of pages) {
|
||||
// Get the original page from source document
|
||||
const sourcePageIndex = page.pageNumber - 1;
|
||||
|
||||
|
||||
if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) {
|
||||
// Copy the page
|
||||
const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]);
|
||||
|
||||
|
||||
// Apply rotation
|
||||
if (page.rotation !== 0) {
|
||||
copiedPage.setRotation(degrees(page.rotation));
|
||||
}
|
||||
|
||||
|
||||
newDoc.addPage(copiedPage);
|
||||
}
|
||||
}
|
||||
@ -108,20 +108,20 @@ export class PDFExportService {
|
||||
|
||||
for (const endIndex of splitPoints) {
|
||||
const segmentPages = pages.slice(startIndex, endIndex);
|
||||
|
||||
|
||||
if (segmentPages.length > 0) {
|
||||
const newDoc = await PDFLibDocument.create();
|
||||
|
||||
|
||||
for (const page of segmentPages) {
|
||||
const sourcePageIndex = page.pageNumber - 1;
|
||||
|
||||
|
||||
if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) {
|
||||
const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]);
|
||||
|
||||
|
||||
if (page.rotation !== 0) {
|
||||
copiedPage.setRotation(degrees(page.rotation));
|
||||
}
|
||||
|
||||
|
||||
newDoc.addPage(copiedPage);
|
||||
}
|
||||
}
|
||||
@ -130,16 +130,16 @@ export class PDFExportService {
|
||||
newDoc.setCreator('Stirling PDF');
|
||||
newDoc.setProducer('Stirling PDF');
|
||||
newDoc.setTitle(`${baseFilename} - Part ${partNumber}`);
|
||||
|
||||
|
||||
const pdfBytes = await newDoc.save();
|
||||
const blob = new Blob([pdfBytes], { type: 'application/pdf' });
|
||||
const filename = this.generateSplitFilename(baseFilename, partNumber);
|
||||
|
||||
|
||||
blobs.push(blob);
|
||||
filenames.push(filename);
|
||||
partNumber++;
|
||||
}
|
||||
|
||||
|
||||
startIndex = endIndex;
|
||||
}
|
||||
|
||||
@ -172,11 +172,11 @@ export class PDFExportService {
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.style.display = 'none';
|
||||
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
|
||||
// Clean up the URL after a short delay
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
@ -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]);
|
||||
@ -207,7 +207,7 @@ export class PDFExportService {
|
||||
errors.push('No pages available to export');
|
||||
}
|
||||
|
||||
const pagesToExport = selectedOnly
|
||||
const pagesToExport = selectedOnly
|
||||
? pdfDocument.pages.filter(page => selectedPageIds.includes(page.id))
|
||||
: pdfDocument.pages;
|
||||
|
||||
@ -226,7 +226,7 @@ export class PDFExportService {
|
||||
splitCount: number;
|
||||
estimatedSize: string;
|
||||
} {
|
||||
const pagesToExport = selectedOnly
|
||||
const pagesToExport = selectedOnly
|
||||
? pdfDocument.pages.filter(page => selectedPageIds.includes(page.id))
|
||||
: pdfDocument.pages;
|
||||
|
||||
@ -259,4 +259,4 @@ export class PDFExportService {
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const pdfExportService = new PDFExportService();
|
||||
export const pdfExportService = new PDFExportService();
|
||||
|
300
frontend/src/services/zipFileService.ts
Normal file
300
frontend/src/services/zipFileService.ts
Normal 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();
|
@ -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,13 +248,29 @@ 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;
|
||||
|
||||
|
||||
switch (mode) {
|
||||
case "byPages":
|
||||
return pages.trim() !== "";
|
||||
@ -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' }}>
|
||||
@ -199,23 +315,35 @@ const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
|
||||
|
||||
{/* Step 2: Settings */}
|
||||
{showStep2 && (
|
||||
<Paper
|
||||
p="md"
|
||||
<Paper
|
||||
p="md"
|
||||
withBorder
|
||||
style={{
|
||||
style={{
|
||||
cursor: step2Collapsed ? 'pointer' : 'default',
|
||||
opacity: step2Collapsed ? 0.8 : 1,
|
||||
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,59 +440,128 @@ const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* Done Button */}
|
||||
{/* Split Button */}
|
||||
{mode && (
|
||||
<Button
|
||||
fullWidth
|
||||
mt="md"
|
||||
disabled={!step2SettingsValid}
|
||||
onClick={() => setStep2Completed(true)}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
mt="md"
|
||||
loading={isLoading}
|
||||
disabled={!step2SettingsValid || selectedFiles.length === 0}
|
||||
>
|
||||
{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>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isLoading}
|
||||
<Text fw={500} size="lg" mb="sm">3. Results</Text>
|
||||
|
||||
{status && <Text size="sm" c="dimmed" mb="md">{status}</Text>}
|
||||
|
||||
{errorMessage && (
|
||||
<Notification color="red" title={t("error._value", "Error")} onClose={() => setErrorMessage(null)} mb="md">
|
||||
{errorMessage}
|
||||
</Notification>
|
||||
)}
|
||||
|
||||
{downloadUrl && (
|
||||
<Button
|
||||
component="a"
|
||||
href={downloadUrl}
|
||||
download="split_output.zip"
|
||||
leftSection={<DownloadIcon />}
|
||||
color="green"
|
||||
fullWidth
|
||||
disabled={selectedFiles.length === 0}
|
||||
mb="md"
|
||||
>
|
||||
{isLoading ? t("loading") : t("split.submit", "Split PDF")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status && <Text size="xs" c="dimmed" mt="xs">{status}</Text>}
|
||||
{/* 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>
|
||||
|
||||
{errorMessage && (
|
||||
<Notification color="red" title={t("error._value", "Error")} onClose={() => setErrorMessage(null)} mt="sm">
|
||||
{errorMessage}
|
||||
</Notification>
|
||||
)}
|
||||
|
||||
{downloadUrl && (
|
||||
<Button
|
||||
component="a"
|
||||
href={downloadUrl}
|
||||
download="split_output.zip"
|
||||
leftSection={<DownloadIcon />}
|
||||
color="green"
|
||||
fullWidth
|
||||
mt="sm"
|
||||
>
|
||||
{t("downloadPdf", "Download Split PDF")}
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
{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>
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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 {
|
||||
|
Loading…
Reference in New Issue
Block a user