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": "^25.2.1",
|
||||||
"i18next-browser-languagedetector": "^8.1.0",
|
"i18next-browser-languagedetector": "^8.1.0",
|
||||||
"i18next-http-backend": "^3.0.2",
|
"i18next-http-backend": "^3.0.2",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdfjs-dist": "^3.11.174",
|
"pdfjs-dist": "^3.11.174",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
@ -2689,6 +2690,11 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/cosmiconfig": {
|
||||||
"version": "7.1.0",
|
"version": "7.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
|
||||||
@ -3450,6 +3456,11 @@
|
|||||||
"cross-fetch": "4.0.0"
|
"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": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@ -3491,8 +3502,7 @@
|
|||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"license": "ISC",
|
"license": "ISC"
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"node_modules/is-arrayish": {
|
"node_modules/is-arrayish": {
|
||||||
"version": "0.2.1",
|
"version": "0.2.1",
|
||||||
@ -3571,6 +3581,11 @@
|
|||||||
"node": ">=0.12.0"
|
"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": {
|
"node_modules/jiti": {
|
||||||
"version": "2.4.2",
|
"version": "2.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
|
||||||
@ -3630,6 +3645,52 @@
|
|||||||
"graceful-fs": "^4.1.6"
|
"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": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.30.1",
|
"version": "1.30.1",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
|
||||||
@ -4729,6 +4790,11 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/prop-types": {
|
||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
@ -5237,6 +5303,11 @@
|
|||||||
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
|
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/signal-exit": {
|
||||||
"version": "3.0.7",
|
"version": "3.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||||
@ -5697,7 +5768,6 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
|
@ -21,6 +21,7 @@
|
|||||||
"i18next": "^25.2.1",
|
"i18next": "^25.2.1",
|
||||||
"i18next-browser-languagedetector": "^8.1.0",
|
"i18next-browser-languagedetector": "^8.1.0",
|
||||||
"i18next-http-backend": "^3.0.2",
|
"i18next-http-backend": "^3.0.2",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdfjs-dist": "^3.11.174",
|
"pdfjs-dist": "^3.11.174",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
|
@ -7,8 +7,10 @@ import { Dropzone } from '@mantine/dropzone';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||||
import { useFileContext } from '../../contexts/FileContext';
|
import { useFileContext } from '../../contexts/FileContext';
|
||||||
|
import { FileOperation } from '../../types/fileContext';
|
||||||
import { fileStorage } from '../../services/fileStorage';
|
import { fileStorage } from '../../services/fileStorage';
|
||||||
import { generateThumbnailForFile } from '../../utils/thumbnailUtils';
|
import { generateThumbnailForFile } from '../../utils/thumbnailUtils';
|
||||||
|
import { zipFileService } from '../../services/zipFileService';
|
||||||
import styles from '../pageEditor/PageEditor.module.css';
|
import styles from '../pageEditor/PageEditor.module.css';
|
||||||
import FileThumbnail from '../pageEditor/FileThumbnail';
|
import FileThumbnail from '../pageEditor/FileThumbnail';
|
||||||
import DragDropGrid from '../pageEditor/DragDropGrid';
|
import DragDropGrid from '../pageEditor/DragDropGrid';
|
||||||
@ -56,7 +58,9 @@ const FileEditor = ({
|
|||||||
isProcessing,
|
isProcessing,
|
||||||
addFiles,
|
addFiles,
|
||||||
removeFiles,
|
removeFiles,
|
||||||
setCurrentView
|
setCurrentView,
|
||||||
|
recordOperation,
|
||||||
|
markOperationApplied
|
||||||
} = fileContext;
|
} = fileContext;
|
||||||
|
|
||||||
const [files, setFiles] = useState<FileItem[]>([]);
|
const [files, setFiles] = useState<FileItem[]>([]);
|
||||||
@ -78,11 +82,29 @@ const FileEditor = ({
|
|||||||
const [isAnimating, setIsAnimating] = useState(false);
|
const [isAnimating, setIsAnimating] = useState(false);
|
||||||
const [showFilePickerModal, setShowFilePickerModal] = useState(false);
|
const [showFilePickerModal, setShowFilePickerModal] = useState(false);
|
||||||
const [conversionProgress, setConversionProgress] = useState(0);
|
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 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
|
// 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
|
const localSelectedFiles = files
|
||||||
.filter(file => selectedFileIds.includes(file.name))
|
.filter(file => safeSelectedFileIds.includes(file.name))
|
||||||
.map(file => file.id);
|
.map(file => file.id);
|
||||||
|
|
||||||
// Convert shared files to FileEditor format
|
// Convert shared files to FileEditor format
|
||||||
@ -102,7 +124,23 @@ const FileEditor = ({
|
|||||||
|
|
||||||
// Convert activeFiles to FileItem format using context (async to avoid blocking)
|
// Convert activeFiles to FileItem format using context (async to avoid blocking)
|
||||||
useEffect(() => {
|
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 () => {
|
const convertActiveFiles = async () => {
|
||||||
|
|
||||||
if (activeFiles.length > 0) {
|
if (activeFiles.length > 0) {
|
||||||
setLocalLoading(true);
|
setLocalLoading(true);
|
||||||
try {
|
try {
|
||||||
@ -170,25 +208,146 @@ const FileEditor = ({
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const validFiles = uploadedFiles.filter(file => {
|
const allExtractedFiles: File[] = [];
|
||||||
if (file.type !== 'application/pdf') {
|
const errors: string[] = [];
|
||||||
setError('Please upload only PDF files');
|
|
||||||
return false;
|
for (const file of uploadedFiles) {
|
||||||
}
|
if (file.type === 'application/pdf') {
|
||||||
return true;
|
// 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)
|
// Add files to context (they will be processed automatically)
|
||||||
await addFiles(validFiles);
|
await addFiles(allExtractedFiles);
|
||||||
setStatus(`Added ${validFiles.length} files`);
|
setStatus(`Added ${allExtractedFiles.length} files`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMessage = err instanceof Error ? err.message : 'Failed to process files';
|
const errorMessage = err instanceof Error ? err.message : 'Failed to process files';
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
console.error('File processing error:', err);
|
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(() => {
|
const selectAll = useCallback(() => {
|
||||||
setContextSelectedFiles(files.map(f => f.name)); // Use file name as ID for context
|
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 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 toggleFile = useCallback((fileId: string) => {
|
||||||
const fileName = files.find(f => f.id === fileId)?.name || fileId;
|
const fileName = files.find(f => f.id === fileId)?.name || fileId;
|
||||||
|
|
||||||
if (!multiSelect) {
|
if (!multiSelect) {
|
||||||
// Single select mode for tools - toggle on/off
|
// Single select mode for tools - toggle on/off
|
||||||
const isCurrentlySelected = selectedFileIds.includes(fileName);
|
const isCurrentlySelected = safeSelectedFileIds.includes(fileName);
|
||||||
if (isCurrentlySelected) {
|
if (isCurrentlySelected) {
|
||||||
// Deselect the file
|
// Deselect the file
|
||||||
setContextSelectedFiles([]);
|
setContextSelectedFiles([]);
|
||||||
@ -218,21 +410,22 @@ const FileEditor = ({
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Multi select mode (default)
|
// Multi select mode (default)
|
||||||
setContextSelectedFiles(prev =>
|
setContextSelectedFiles(prev => {
|
||||||
prev.includes(fileName)
|
const safePrev = Array.isArray(prev) ? prev : [];
|
||||||
? prev.filter(id => id !== fileName)
|
return safePrev.includes(fileName)
|
||||||
: [...prev, fileName]
|
? safePrev.filter(id => id !== fileName)
|
||||||
);
|
: [...safePrev, fileName];
|
||||||
|
});
|
||||||
|
|
||||||
// Notify parent with selected files
|
// Notify parent with selected files
|
||||||
if (onFileSelect) {
|
if (onFileSelect) {
|
||||||
const selectedFiles = files
|
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);
|
.map(f => f.file);
|
||||||
onFileSelect(selectedFiles);
|
onFileSelect(selectedFiles);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [files, setContextSelectedFiles, multiSelect, onFileSelect, selectedFileIds]);
|
}, [files, setContextSelectedFiles, multiSelect, onFileSelect, safeSelectedFileIds]);
|
||||||
|
|
||||||
const toggleSelectionMode = useCallback(() => {
|
const toggleSelectionMode = useCallback(() => {
|
||||||
setSelectionMode(prev => {
|
setSelectionMode(prev => {
|
||||||
@ -354,14 +547,53 @@ const FileEditor = ({
|
|||||||
|
|
||||||
// File operations using context
|
// File operations using context
|
||||||
const handleDeleteFile = useCallback((fileId: string) => {
|
const handleDeleteFile = useCallback((fileId: string) => {
|
||||||
|
console.log('handleDeleteFile called with fileId:', fileId);
|
||||||
const file = files.find(f => f.id === fileId);
|
const file = files.find(f => f.id === fileId);
|
||||||
|
console.log('Found file:', file);
|
||||||
|
|
||||||
if (file) {
|
if (file) {
|
||||||
// Remove from context
|
console.log('Attempting to remove file:', file.name);
|
||||||
removeFiles([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
|
// 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 handleViewFile = useCallback((fileId: string) => {
|
||||||
const file = files.find(f => f.id === fileId);
|
const file = files.find(f => f.id === fileId);
|
||||||
@ -419,6 +651,9 @@ const FileEditor = ({
|
|||||||
<>
|
<>
|
||||||
<Button onClick={selectAll} variant="light">Select All</Button>
|
<Button onClick={selectAll} variant="light">Select All</Button>
|
||||||
<Button onClick={deselectAll} variant="light">Deselect 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
|
<Dropzone
|
||||||
onDrop={handleFileUpload}
|
onDrop={handleFileUpload}
|
||||||
accept={["application/pdf"]}
|
accept={["application/pdf", "application/zip", "application/x-zip-compressed"]}
|
||||||
multiple={true}
|
multiple={true}
|
||||||
maxSize={2 * 1024 * 1024 * 1024}
|
maxSize={2 * 1024 * 1024 * 1024}
|
||||||
style={{ display: 'contents' }}
|
style={{ display: 'contents' }}
|
||||||
@ -449,39 +684,71 @@ const FileEditor = ({
|
|||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
|
||||||
{files.length === 0 && !localLoading ? (
|
{files.length === 0 && !localLoading && !zipExtractionProgress.isExtracting ? (
|
||||||
<Center h="60vh">
|
<Center h="60vh">
|
||||||
<Stack align="center" gap="md">
|
<Stack align="center" gap="md">
|
||||||
<Text size="lg" c="dimmed">📁</Text>
|
<Text size="lg" c="dimmed">📁</Text>
|
||||||
<Text c="dimmed">No files loaded</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>
|
</Stack>
|
||||||
</Center>
|
</Center>
|
||||||
) : files.length === 0 && localLoading ? (
|
) : files.length === 0 && (localLoading || zipExtractionProgress.isExtracting) ? (
|
||||||
<Box>
|
<Box>
|
||||||
<SkeletonLoader type="controls" />
|
<SkeletonLoader type="controls" />
|
||||||
|
|
||||||
{/* Processing indicator */}
|
{/* ZIP Extraction Progress */}
|
||||||
<Box mb="md" p="sm" style={{ backgroundColor: 'var(--mantine-color-blue-0)', borderRadius: 8 }}>
|
{zipExtractionProgress.isExtracting && (
|
||||||
<Group justify="space-between" mb="xs">
|
<Box mb="md" p="sm" style={{ backgroundColor: 'var(--mantine-color-orange-0)', borderRadius: 8 }}>
|
||||||
<Text size="sm" fw={500}>Loading files...</Text>
|
<Group justify="space-between" mb="xs">
|
||||||
<Text size="sm" c="dimmed">{Math.round(conversionProgress)}%</Text>
|
<Text size="sm" fw={500}>Extracting ZIP archive...</Text>
|
||||||
</Group>
|
<Text size="sm" c="dimmed">{Math.round(zipExtractionProgress.progress)}%</Text>
|
||||||
<div style={{
|
</Group>
|
||||||
width: '100%',
|
<Text size="xs" c="dimmed" mb="xs">
|
||||||
height: '4px',
|
{zipExtractionProgress.currentFile || 'Processing files...'}
|
||||||
backgroundColor: 'var(--mantine-color-gray-2)',
|
</Text>
|
||||||
borderRadius: '2px',
|
<Text size="xs" c="dimmed" mb="xs">
|
||||||
overflow: 'hidden'
|
{zipExtractionProgress.extractedCount} of {zipExtractionProgress.totalFiles} files extracted
|
||||||
}}>
|
</Text>
|
||||||
<div style={{
|
<div style={{
|
||||||
width: `${Math.round(conversionProgress)}%`,
|
width: '100%',
|
||||||
height: '100%',
|
height: '4px',
|
||||||
backgroundColor: 'var(--mantine-color-blue-6)',
|
backgroundColor: 'var(--mantine-color-gray-2)',
|
||||||
transition: 'width 0.3s ease'
|
borderRadius: '2px',
|
||||||
}} />
|
overflow: 'hidden'
|
||||||
</div>
|
}}>
|
||||||
</Box>
|
<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>
|
||||||
|
<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} />
|
<SkeletonLoader type="fileGrid" count={6} />
|
||||||
</Box>
|
</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 React, { useState } from 'react';
|
||||||
import { Text, Checkbox, Tooltip, ActionIcon, Badge } from '@mantine/core';
|
import { Text, Checkbox, Tooltip, ActionIcon, Badge, Modal } from '@mantine/core';
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||||
import MergeIcon from '@mui/icons-material/Merge';
|
import MergeIcon from '@mui/icons-material/Merge';
|
||||||
import SplitscreenIcon from '@mui/icons-material/Splitscreen';
|
import SplitscreenIcon from '@mui/icons-material/Splitscreen';
|
||||||
|
import HistoryIcon from '@mui/icons-material/History';
|
||||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||||
import styles from './PageEditor.module.css';
|
import styles from './PageEditor.module.css';
|
||||||
|
import FileOperationHistory from '../history/FileOperationHistory';
|
||||||
|
|
||||||
interface FileItem {
|
interface FileItem {
|
||||||
id: string;
|
id: string;
|
||||||
@ -65,6 +67,8 @@ const FileThumbnail = ({
|
|||||||
onSetStatus,
|
onSetStatus,
|
||||||
toolMode = false,
|
toolMode = false,
|
||||||
}: FileThumbnailProps) => {
|
}: FileThumbnailProps) => {
|
||||||
|
const [showHistory, setShowHistory] = useState(false);
|
||||||
|
|
||||||
const formatFileSize = (bytes: number) => {
|
const formatFileSize = (bytes: number) => {
|
||||||
if (bytes === 0) return '0 B';
|
if (bytes === 0) return '0 B';
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
@ -289,18 +293,33 @@ const FileThumbnail = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Tooltip label="Delete File">
|
<Tooltip label="View History">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
size="md"
|
size="md"
|
||||||
variant="subtle"
|
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) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onDeleteFile(file.id);
|
onDeleteFile(file.id);
|
||||||
onSetStatus(`Deleted ${file.name}`);
|
onSetStatus(`Closed ${file.name}`);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DeleteIcon style={{ fontSize: 20 }} />
|
<CloseIcon style={{ fontSize: 20 }} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@ -326,6 +345,21 @@ const FileThumbnail = ({
|
|||||||
{formatFileSize(file.size)}
|
{formatFileSize(file.size)}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* History Modal */}
|
||||||
|
<Modal
|
||||||
|
opened={showHistory}
|
||||||
|
onClose={() => setShowHistory(false)}
|
||||||
|
title={`Operation History - ${file.name}`}
|
||||||
|
size="lg"
|
||||||
|
scrollAreaComponent="div"
|
||||||
|
>
|
||||||
|
<FileOperationHistory
|
||||||
|
fileId={file.name}
|
||||||
|
showOnlyApplied={true}
|
||||||
|
maxHeight={500}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -36,7 +36,7 @@ const FileUploadSelector = ({
|
|||||||
sharedFiles = [],
|
sharedFiles = [],
|
||||||
onFileSelect,
|
onFileSelect,
|
||||||
onFilesSelect,
|
onFilesSelect,
|
||||||
accept = ["application/pdf"],
|
accept = ["application/pdf", "application/zip", "application/x-zip-compressed"],
|
||||||
loading = false,
|
loading = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
showRecentFiles = true,
|
showRecentFiles = true,
|
||||||
@ -212,7 +212,9 @@ const FileUploadSelector = ({
|
|||||||
{t("fileUpload.dropFilesHere", "Drop files here or click to upload")}
|
{t("fileUpload.dropFilesHere", "Drop files here or click to upload")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<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.pdfFilesOnly", "PDF files only")
|
||||||
: t("fileUpload.supportedFileTypes", "Supported file types")
|
: t("fileUpload.supportedFileTypes", "Supported file types")
|
||||||
}
|
}
|
||||||
|
@ -91,7 +91,9 @@ const TopControls = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
|
<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
|
<Button
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
|
@ -11,6 +11,7 @@ interface ToolRendererProps {
|
|||||||
toolParams: any;
|
toolParams: any;
|
||||||
updateParams: (params: any) => void;
|
updateParams: (params: any) => void;
|
||||||
toolSelectedFiles?: File[];
|
toolSelectedFiles?: File[];
|
||||||
|
onPreviewFile?: (file: File | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ToolRenderer = ({
|
const ToolRenderer = ({
|
||||||
@ -23,6 +24,7 @@ const ToolRenderer = ({
|
|||||||
toolParams,
|
toolParams,
|
||||||
updateParams,
|
updateParams,
|
||||||
toolSelectedFiles = [],
|
toolSelectedFiles = [],
|
||||||
|
onPreviewFile,
|
||||||
}: ToolRendererProps) => {
|
}: ToolRendererProps) => {
|
||||||
if (!selectedTool || !selectedTool.component) {
|
if (!selectedTool || !selectedTool.component) {
|
||||||
return <div>Tool not found</div>;
|
return <div>Tool not found</div>;
|
||||||
@ -38,6 +40,7 @@ const ToolRenderer = ({
|
|||||||
params={toolParams}
|
params={toolParams}
|
||||||
updateParams={updateParams}
|
updateParams={updateParams}
|
||||||
selectedFiles={toolSelectedFiles}
|
selectedFiles={toolSelectedFiles}
|
||||||
|
onPreviewFile={onPreviewFile}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "compress":
|
case "compress":
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState, useRef } from "react";
|
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 { getDocument, GlobalWorkerOptions } from "pdfjs-dist";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew";
|
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 ViewSidebarIcon from "@mui/icons-material/ViewSidebar";
|
||||||
import ViewWeekIcon from "@mui/icons-material/ViewWeek"; // for dual page (book)
|
import ViewWeekIcon from "@mui/icons-material/ViewWeek"; // for dual page (book)
|
||||||
import DescriptionIcon from "@mui/icons-material/Description"; // for single page
|
import DescriptionIcon from "@mui/icons-material/Description"; // for single page
|
||||||
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
import { useLocalStorage } from "@mantine/hooks";
|
import { useLocalStorage } from "@mantine/hooks";
|
||||||
import { fileStorage } from "../../services/fileStorage";
|
import { fileStorage } from "../../services/fileStorage";
|
||||||
import SkeletonLoader from '../shared/SkeletonLoader';
|
import SkeletonLoader from '../shared/SkeletonLoader';
|
||||||
@ -135,6 +136,8 @@ export interface ViewerProps {
|
|||||||
setPdfFile: (file: { file: File; url: string } | null) => void;
|
setPdfFile: (file: { file: File; url: string } | null) => void;
|
||||||
sidebarsVisible: boolean;
|
sidebarsVisible: boolean;
|
||||||
setSidebarsVisible: (v: boolean) => void;
|
setSidebarsVisible: (v: boolean) => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
previewFile?: File; // For preview mode - bypasses context
|
||||||
}
|
}
|
||||||
|
|
||||||
const Viewer = ({
|
const Viewer = ({
|
||||||
@ -142,6 +145,8 @@ const Viewer = ({
|
|||||||
setPdfFile,
|
setPdfFile,
|
||||||
sidebarsVisible,
|
sidebarsVisible,
|
||||||
setSidebarsVisible,
|
setSidebarsVisible,
|
||||||
|
onClose,
|
||||||
|
previewFile,
|
||||||
}: ViewerProps) => {
|
}: ViewerProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const theme = useMantineTheme();
|
const theme = useMantineTheme();
|
||||||
@ -152,6 +157,61 @@ const Viewer = ({
|
|||||||
const [dualPage, setDualPage] = useState(false);
|
const [dualPage, setDualPage] = useState(false);
|
||||||
const [zoom, setZoom] = useState(1); // 1 = 100%
|
const [zoom, setZoom] = useState(1); // 1 = 100%
|
||||||
const pageRefs = useRef<(HTMLImageElement | null)[]>([]);
|
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 scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||||
const userInitiatedRef = useRef(false);
|
const userInitiatedRef = useRef(false);
|
||||||
const suppressScrollRef = useRef(false);
|
const suppressScrollRef = useRef(false);
|
||||||
@ -162,7 +222,7 @@ const Viewer = ({
|
|||||||
|
|
||||||
// Function to render a specific page on-demand
|
// Function to render a specific page on-demand
|
||||||
const renderPage = async (pageIndex: number): Promise<string | null> => {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -315,18 +375,27 @@ const Viewer = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
async function loadPdfInfo() {
|
async function loadPdfInfo() {
|
||||||
if (!pdfFile || !pdfFile.url) {
|
console.log('Loading PDF info:', { effectiveFile, previewFile: !!previewFile });
|
||||||
|
if (!effectiveFile) {
|
||||||
|
console.log('No effective file');
|
||||||
setNumPages(0);
|
setNumPages(0);
|
||||||
setPageImages([]);
|
setPageImages([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
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
|
// Handle special IndexedDB URLs for large files
|
||||||
if (pdfFile.url.startsWith('indexeddb:')) {
|
else if (effectiveFile.url?.startsWith('indexeddb:')) {
|
||||||
const fileId = pdfFile.url.replace('indexeddb:', '');
|
const fileId = effectiveFile.url.replace('indexeddb:', '');
|
||||||
console.log('Loading large file from IndexedDB:', fileId);
|
console.log('Loading large file from IndexedDB:', fileId);
|
||||||
|
|
||||||
// Get data directly from IndexedDB
|
// Get data directly from IndexedDB
|
||||||
@ -337,26 +406,22 @@ const Viewer = ({
|
|||||||
|
|
||||||
// Store reference for cleanup
|
// Store reference for cleanup
|
||||||
currentArrayBufferRef.current = arrayBuffer;
|
currentArrayBufferRef.current = arrayBuffer;
|
||||||
|
pdfData = { data: arrayBuffer };
|
||||||
// Use ArrayBuffer directly instead of creating blob URL
|
} else if (effectiveFile.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 {
|
|
||||||
// Standard blob URL or regular URL
|
// Standard blob URL or regular URL
|
||||||
const pdf = await getDocument(pdfUrl).promise;
|
console.log('Loading PDF from URL:', effectiveFile.url);
|
||||||
pdfDocRef.current = pdf;
|
pdfData = effectiveFile.url;
|
||||||
setNumPages(pdf.numPages);
|
} else {
|
||||||
if (!cancelled) {
|
throw new Error('No valid PDF source available');
|
||||||
setPageImages(new Array(pdf.numPages).fill(null));
|
}
|
||||||
// Start progressive preloading after a short delay
|
|
||||||
setTimeout(() => startProgressivePreload(), 100);
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to load PDF:', error);
|
console.error('Failed to load PDF:', error);
|
||||||
@ -375,7 +440,7 @@ const Viewer = ({
|
|||||||
// Cleanup ArrayBuffer reference to help garbage collection
|
// Cleanup ArrayBuffer reference to help garbage collection
|
||||||
currentArrayBufferRef.current = null;
|
currentArrayBufferRef.current = null;
|
||||||
};
|
};
|
||||||
}, [pdfFile]);
|
}, [effectiveFile, previewFile]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const viewport = scrollAreaRef.current;
|
const viewport = scrollAreaRef.current;
|
||||||
@ -388,8 +453,27 @@ const Viewer = ({
|
|||||||
}, [pageImages]);
|
}, [pageImages]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Box style={{ position: 'relative', height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||||
{!pdfFile ? (
|
{/* 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 }}>
|
<Center style={{ flex: 1 }}>
|
||||||
<Stack align="center">
|
<Stack align="center">
|
||||||
<Text c="dimmed">{t("viewer.noPdfLoaded", "No PDF loaded. Click to upload a PDF.")}</Text>
|
<Text c="dimmed">{t("viewer.noPdfLoaded", "No PDF loaded. Click to upload a PDF.")}</Text>
|
||||||
@ -609,7 +693,7 @@ const Viewer = ({
|
|||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -12,6 +12,7 @@ import {
|
|||||||
ToolType,
|
ToolType,
|
||||||
FileOperation,
|
FileOperation,
|
||||||
FileEditHistory,
|
FileEditHistory,
|
||||||
|
FileOperationHistory,
|
||||||
ViewerConfig,
|
ViewerConfig,
|
||||||
FileContextUrlParams
|
FileContextUrlParams
|
||||||
} from '../types/fileContext';
|
} from '../types/fileContext';
|
||||||
@ -38,6 +39,7 @@ const initialState: FileContextState = {
|
|||||||
currentTool: null, // Legacy field
|
currentTool: null, // Legacy field
|
||||||
fileEditHistory: new Map(),
|
fileEditHistory: new Map(),
|
||||||
globalFileOperations: [],
|
globalFileOperations: [],
|
||||||
|
fileOperationHistory: new Map(),
|
||||||
selectedFileIds: [],
|
selectedFileIds: [],
|
||||||
selectedPageNumbers: [],
|
selectedPageNumbers: [],
|
||||||
viewerConfig: initialViewerConfig,
|
viewerConfig: initialViewerConfig,
|
||||||
@ -66,6 +68,10 @@ type FileContextAction =
|
|||||||
| { type: 'UPDATE_VIEWER_CONFIG'; payload: Partial<ViewerConfig> }
|
| { type: 'UPDATE_VIEWER_CONFIG'; payload: Partial<ViewerConfig> }
|
||||||
| { type: 'ADD_PAGE_OPERATIONS'; payload: { fileId: string; operations: PageOperation[] } }
|
| { type: 'ADD_PAGE_OPERATIONS'; payload: { fileId: string; operations: PageOperation[] } }
|
||||||
| { type: 'ADD_FILE_OPERATION'; payload: FileOperation }
|
| { 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_EXPORT_CONFIG'; payload: FileContextState['lastExportConfig'] }
|
||||||
| { type: 'SET_UNSAVED_CHANGES'; payload: boolean }
|
| { type: 'SET_UNSAVED_CHANGES'; payload: boolean }
|
||||||
| { type: 'SET_PENDING_NAVIGATION'; payload: (() => void) | null }
|
| { type: 'SET_PENDING_NAVIGATION'; payload: (() => void) | null }
|
||||||
@ -94,10 +100,11 @@ function fileContextReducer(state: FileContextState, action: FileContextAction):
|
|||||||
const remainingFiles = state.activeFiles.filter(file =>
|
const remainingFiles = state.activeFiles.filter(file =>
|
||||||
!action.payload.includes(file.name) // Simple ID for now, could use file.name or generate IDs
|
!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 {
|
return {
|
||||||
...state,
|
...state,
|
||||||
activeFiles: remainingFiles,
|
activeFiles: remainingFiles,
|
||||||
selectedFileIds: state.selectedFileIds.filter(id => !action.payload.includes(id))
|
selectedFileIds: safeSelectedFileIds.filter(id => !action.payload.includes(id))
|
||||||
};
|
};
|
||||||
|
|
||||||
case 'SET_PROCESSED_FILES':
|
case 'SET_PROCESSED_FILES':
|
||||||
@ -200,6 +207,90 @@ function fileContextReducer(state: FileContextState, action: FileContextAction):
|
|||||||
globalFileOperations: [...state.globalFileOperations, action.payload]
|
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':
|
case 'SET_EXPORT_CONFIG':
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
@ -413,7 +504,7 @@ export function FileContextProvider({
|
|||||||
}
|
}
|
||||||
}, [enablePersistence]);
|
}, [enablePersistence]);
|
||||||
|
|
||||||
const removeFiles = useCallback((fileIds: string[]) => {
|
const removeFiles = useCallback((fileIds: string[], deleteFromStorage: boolean = true) => {
|
||||||
// FULL cleanup for actually removed files (including cache)
|
// FULL cleanup for actually removed files (including cache)
|
||||||
fileIds.forEach(fileId => {
|
fileIds.forEach(fileId => {
|
||||||
// Cancel processing and clear caches when file is actually removed
|
// Cancel processing and clear caches when file is actually removed
|
||||||
@ -423,8 +514,8 @@ export function FileContextProvider({
|
|||||||
|
|
||||||
dispatch({ type: 'REMOVE_FILES', payload: fileIds });
|
dispatch({ type: 'REMOVE_FILES', payload: fileIds });
|
||||||
|
|
||||||
// Remove from IndexedDB
|
// Remove from IndexedDB only if requested
|
||||||
if (enablePersistence) {
|
if (enablePersistence && deleteFromStorage) {
|
||||||
fileIds.forEach(async (fileId) => {
|
fileIds.forEach(async (fileId) => {
|
||||||
try {
|
try {
|
||||||
await fileStorage.removeFile(fileId);
|
await fileStorage.removeFile(fileId);
|
||||||
@ -435,6 +526,7 @@ export function FileContextProvider({
|
|||||||
}
|
}
|
||||||
}, [enablePersistence, cleanupFile]);
|
}, [enablePersistence, cleanupFile]);
|
||||||
|
|
||||||
|
|
||||||
const replaceFile = useCallback(async (oldFileId: string, newFile: File) => {
|
const replaceFile = useCallback(async (oldFileId: string, newFile: File) => {
|
||||||
// Remove old file and add new one
|
// Remove old file and add new one
|
||||||
removeFiles([oldFileId]);
|
removeFiles([oldFileId]);
|
||||||
@ -551,6 +643,32 @@ export function FileContextProvider({
|
|||||||
dispatch({ type: 'SET_EXPORT_CONFIG', payload: config });
|
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
|
// Utility functions
|
||||||
const getFileById = useCallback((fileId: string): File | undefined => {
|
const getFileById = useCallback((fileId: string): File | undefined => {
|
||||||
return state.activeFiles.find(file => file.name === fileId); // Simple ID matching
|
return state.activeFiles.find(file => file.name === fileId); // Simple ID matching
|
||||||
@ -663,6 +781,14 @@ export function FileContextProvider({
|
|||||||
loadContext,
|
loadContext,
|
||||||
resetContext,
|
resetContext,
|
||||||
|
|
||||||
|
// Operation history management
|
||||||
|
recordOperation,
|
||||||
|
markOperationApplied,
|
||||||
|
markOperationFailed,
|
||||||
|
getFileHistory,
|
||||||
|
getAppliedOperations,
|
||||||
|
clearFileHistory,
|
||||||
|
|
||||||
// Navigation guard system
|
// Navigation guard system
|
||||||
setHasUnsavedChanges,
|
setHasUnsavedChanges,
|
||||||
requestNavigation,
|
requestNavigation,
|
||||||
|
@ -95,8 +95,11 @@ export function useEnhancedProcessedFiles(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update processed files (hash mapping is updated via ref)
|
// Only update if the content actually changed
|
||||||
if (newProcessedFiles.size > 0 || processedFiles.size > 0) {
|
const hasChanged = newProcessedFiles.size !== processedFiles.size ||
|
||||||
|
Array.from(newProcessedFiles.keys()).some(file => !processedFiles.has(file));
|
||||||
|
|
||||||
|
if (hasChanged) {
|
||||||
setProcessedFiles(newProcessedFiles);
|
setProcessedFiles(newProcessedFiles);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -62,6 +62,7 @@ export default function HomePage() {
|
|||||||
const [pageEditorFunctions, setPageEditorFunctions] = useState<any>(null);
|
const [pageEditorFunctions, setPageEditorFunctions] = useState<any>(null);
|
||||||
const [toolSelectedFiles, setToolSelectedFiles] = useState<File[]>([]);
|
const [toolSelectedFiles, setToolSelectedFiles] = useState<File[]>([]);
|
||||||
const [toolParams, setToolParams] = useState<Record<string, any>>({});
|
const [toolParams, setToolParams] = useState<Record<string, any>>({});
|
||||||
|
const [previewFile, setPreviewFile] = useState<File | null>(null);
|
||||||
|
|
||||||
// Tool registry
|
// Tool registry
|
||||||
const toolRegistry: ToolRegistry = {
|
const toolRegistry: ToolRegistry = {
|
||||||
@ -181,6 +182,7 @@ export default function HomePage() {
|
|||||||
const handleViewChange = useCallback((view: string) => {
|
const handleViewChange = useCallback((view: string) => {
|
||||||
setCurrentView(view as any);
|
setCurrentView(view as any);
|
||||||
}, [setCurrentView]);
|
}, [setCurrentView]);
|
||||||
|
|
||||||
const addToActiveFiles = useCallback(async (file: File) => {
|
const addToActiveFiles = useCallback(async (file: File) => {
|
||||||
const exists = activeFiles.some(f => f.name === file.name && f.size === file.size);
|
const exists = activeFiles.some(f => f.name === file.name && f.size === file.size);
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
@ -385,6 +387,7 @@ export default function HomePage() {
|
|||||||
toolParams={getToolParams(selectedToolKey)}
|
toolParams={getToolParams(selectedToolKey)}
|
||||||
updateParams={(newParams) => updateToolParams(selectedToolKey, newParams)}
|
updateParams={(newParams) => updateToolParams(selectedToolKey, newParams)}
|
||||||
toolSelectedFiles={toolSelectedFiles}
|
toolSelectedFiles={toolSelectedFiles}
|
||||||
|
onPreviewFile={setPreviewFile}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -457,6 +460,21 @@ export default function HomePage() {
|
|||||||
}}
|
}}
|
||||||
sidebarsVisible={sidebarsVisible}
|
sidebarsVisible={sidebarsVisible}
|
||||||
setSidebarsVisible={setSidebarsVisible}
|
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" ? (
|
) : currentView === "pageEditor" ? (
|
||||||
<>
|
<>
|
||||||
|
@ -185,7 +185,7 @@ export class PDFExportService {
|
|||||||
* Download multiple files as a ZIP
|
* Download multiple files as a ZIP
|
||||||
*/
|
*/
|
||||||
async downloadAsZip(blobs: Blob[], filenames: string[], zipFilename: string): Promise<void> {
|
async downloadAsZip(blobs: Blob[], filenames: string[], zipFilename: string): Promise<void> {
|
||||||
// For now, download files individually
|
// For now, download files wherindividually
|
||||||
blobs.forEach((blob, index) => {
|
blobs.forEach((blob, index) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.downloadFile(blob, filenames[index]);
|
this.downloadFile(blob, filenames[index]);
|
||||||
|
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,
|
Alert,
|
||||||
Box,
|
Box,
|
||||||
Group,
|
Group,
|
||||||
|
Grid,
|
||||||
|
Image,
|
||||||
|
Loader,
|
||||||
|
Center,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import DownloadIcon from "@mui/icons-material/Download";
|
import DownloadIcon from "@mui/icons-material/Download";
|
||||||
import { useFileContext } from "../contexts/FileContext";
|
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";
|
import FileEditor from "../components/fileEditor/FileEditor";
|
||||||
|
|
||||||
export interface SplitPdfPanelProps {
|
export interface SplitPdfPanelProps {
|
||||||
@ -33,21 +40,33 @@ export interface SplitPdfPanelProps {
|
|||||||
};
|
};
|
||||||
updateParams: (newParams: Partial<SplitPdfPanelProps["params"]>) => void;
|
updateParams: (newParams: Partial<SplitPdfPanelProps["params"]>) => void;
|
||||||
selectedFiles?: File[];
|
selectedFiles?: File[];
|
||||||
|
onPreviewFile?: (file: File | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
|
const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
|
||||||
params,
|
params,
|
||||||
updateParams,
|
updateParams,
|
||||||
selectedFiles = [],
|
selectedFiles = [],
|
||||||
|
onPreviewFile,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const fileContext = useFileContext();
|
const fileContext = useFileContext();
|
||||||
const { activeFiles, selectedFileIds, updateProcessedFile } = fileContext;
|
const { activeFiles, selectedFileIds, updateProcessedFile, recordOperation, markOperationApplied, markOperationFailed, setCurrentMode } = fileContext;
|
||||||
|
|
||||||
const [status, setStatus] = useState("");
|
const [status, setStatus] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
const [downloadUrl, setDownloadUrl] = 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 {
|
const {
|
||||||
mode,
|
mode,
|
||||||
@ -68,8 +87,15 @@ const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
|
|||||||
setDownloadUrl(null);
|
setDownloadUrl(null);
|
||||||
setStatus("");
|
setStatus("");
|
||||||
}
|
}
|
||||||
// Reset step 2 completion when parameters change (but not when just status/loading changes)
|
// Clear split results and preview file
|
||||||
setStep2Completed(false);
|
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]);
|
}, [mode, pages, hDiv, vDiv, merge, splitType, splitValue, bookmarkLevel, includeMetadata, allowDuplicates, selectedFiles]);
|
||||||
|
|
||||||
|
|
||||||
@ -118,6 +144,36 @@ const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
|
|||||||
return;
|
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"));
|
setStatus(t("loading"));
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
@ -128,6 +184,47 @@ const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
|
|||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
setDownloadUrl(url);
|
setDownloadUrl(url);
|
||||||
setStatus(t("downloadComplete"));
|
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) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
let errorMsg = t("error.pdfPassword", "An error occurred while splitting the PDF.");
|
let errorMsg = t("error.pdfPassword", "An error occurred while splitting the PDF.");
|
||||||
@ -138,6 +235,9 @@ const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
|
|||||||
}
|
}
|
||||||
setErrorMessage(errorMsg);
|
setErrorMessage(errorMsg);
|
||||||
setStatus(t("error._value", "Split failed."));
|
setStatus(t("error._value", "Split failed."));
|
||||||
|
|
||||||
|
// Mark operation as failed
|
||||||
|
markOperationFailed(fileId, operationId, errorMsg);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@ -148,10 +248,26 @@ const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
|
|||||||
return currentMode && currentMode !== ""; // All modes need some params
|
return currentMode && currentMode !== ""; // All modes need some params
|
||||||
};
|
};
|
||||||
|
|
||||||
// Step 2 completion state
|
// Handle thumbnail click to open in viewer
|
||||||
const [step2Completed, setStep2Completed] = useState(false);
|
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 = (() => {
|
const step2SettingsValid = (() => {
|
||||||
if (!mode) return false;
|
if (!mode) return false;
|
||||||
|
|
||||||
@ -172,11 +288,11 @@ const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
|
|||||||
// Determine what steps to show
|
// Determine what steps to show
|
||||||
const showStep1 = true; // Always show - Files
|
const showStep1 = true; // Always show - Files
|
||||||
const showStep2 = selectedFiles.length > 0; // Settings (mode + params)
|
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)
|
// Determine if steps are collapsed (completed)
|
||||||
const step1Collapsed = selectedFiles.length > 0;
|
const step1Collapsed = selectedFiles.length > 0;
|
||||||
const step2Collapsed = step2Completed;
|
const step2Collapsed = downloadUrl !== null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box h="100%" p="md" style={{ overflow: 'auto' }}>
|
<Box h="100%" p="md" style={{ overflow: 'auto' }}>
|
||||||
@ -208,14 +324,26 @@ const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
|
|||||||
transition: 'opacity 0.2s ease'
|
transition: 'opacity 0.2s ease'
|
||||||
}}
|
}}
|
||||||
onClick={step2Collapsed ? () => {
|
onClick={step2Collapsed ? () => {
|
||||||
// Go back to step 2
|
// Reset to allow changing settings
|
||||||
setStep2Completed(false);
|
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}
|
} : undefined}
|
||||||
>
|
>
|
||||||
<Text fw={500} size="lg" mb="sm">2. Settings</Text>
|
<Text fw={500} size="lg" mb="sm">2. Settings</Text>
|
||||||
{step2Collapsed ? (
|
{step2Collapsed ? (
|
||||||
<Text size="sm" c="green">
|
<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>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
@ -312,59 +440,128 @@ const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
|
|||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Done Button */}
|
{/* Split Button */}
|
||||||
{mode && (
|
{mode && (
|
||||||
<Button
|
<form onSubmit={handleSubmit}>
|
||||||
fullWidth
|
<Button
|
||||||
mt="md"
|
type="submit"
|
||||||
disabled={!step2SettingsValid}
|
fullWidth
|
||||||
onClick={() => setStep2Completed(true)}
|
mt="md"
|
||||||
>
|
loading={isLoading}
|
||||||
Done
|
disabled={!step2SettingsValid || selectedFiles.length === 0}
|
||||||
</Button>
|
>
|
||||||
|
{isLoading ? t("loading") : t("split.submit", "Split PDF")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 3: Review */}
|
{/* Step 3: Results */}
|
||||||
{showStep3 && (
|
{showStep3 && (
|
||||||
<Paper p="md" withBorder>
|
<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}>
|
{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
|
<Button
|
||||||
type="submit"
|
component="a"
|
||||||
loading={isLoading}
|
href={downloadUrl}
|
||||||
|
download="split_output.zip"
|
||||||
|
leftSection={<DownloadIcon />}
|
||||||
|
color="green"
|
||||||
fullWidth
|
fullWidth
|
||||||
disabled={selectedFiles.length === 0}
|
mb="md"
|
||||||
>
|
>
|
||||||
{isLoading ? t("loading") : t("split.submit", "Split PDF")}
|
|
||||||
</Button>
|
</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 && (
|
{splitResults.isGeneratingThumbnails ? (
|
||||||
<Notification color="red" title={t("error._value", "Error")} onClose={() => setErrorMessage(null)} mt="sm">
|
<Center p="lg">
|
||||||
{errorMessage}
|
<Stack align="center" gap="sm">
|
||||||
</Notification>
|
<Loader size="sm" />
|
||||||
)}
|
<Text size="sm" c="dimmed">Generating previews...</Text>
|
||||||
|
</Stack>
|
||||||
{downloadUrl && (
|
</Center>
|
||||||
<Button
|
) : (
|
||||||
component="a"
|
<Grid>
|
||||||
href={downloadUrl}
|
{splitResults.files.map((file, index) => (
|
||||||
download="split_output.zip"
|
<Grid.Col span={{ base: 6, sm: 4, md: 3 }} key={index}>
|
||||||
leftSection={<DownloadIcon />}
|
<Paper
|
||||||
color="green"
|
p="xs"
|
||||||
fullWidth
|
withBorder
|
||||||
mt="sm"
|
style={{
|
||||||
>
|
textAlign: 'center',
|
||||||
{t("downloadPdf", "Download Split PDF")}
|
height: '200px',
|
||||||
</Button>
|
display: 'flex',
|
||||||
)}
|
flexDirection: 'column',
|
||||||
</form>
|
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>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
@ -13,10 +13,27 @@ export type ToolType = 'merge' | 'split' | 'compress' | null;
|
|||||||
|
|
||||||
export interface FileOperation {
|
export interface FileOperation {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'merge' | 'add' | 'remove' | 'replace';
|
type: 'merge' | 'split' | 'compress' | 'add' | 'remove' | 'replace' | 'convert' | 'upload';
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
fileIds: string[];
|
fileIds: string[];
|
||||||
|
status: 'pending' | 'applied' | 'failed';
|
||||||
data?: any;
|
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 {
|
export interface ViewerConfig {
|
||||||
@ -46,6 +63,8 @@ export interface FileContextState {
|
|||||||
// Edit history and state
|
// Edit history and state
|
||||||
fileEditHistory: Map<string, FileEditHistory>;
|
fileEditHistory: Map<string, FileEditHistory>;
|
||||||
globalFileOperations: FileOperation[];
|
globalFileOperations: FileOperation[];
|
||||||
|
// New comprehensive operation history
|
||||||
|
fileOperationHistory: Map<string, FileOperationHistory>;
|
||||||
|
|
||||||
// UI state that persists across views
|
// UI state that persists across views
|
||||||
selectedFileIds: string[];
|
selectedFileIds: string[];
|
||||||
@ -72,7 +91,7 @@ export interface FileContextState {
|
|||||||
export interface FileContextActions {
|
export interface FileContextActions {
|
||||||
// File management
|
// File management
|
||||||
addFiles: (files: File[]) => Promise<void>;
|
addFiles: (files: File[]) => Promise<void>;
|
||||||
removeFiles: (fileIds: string[]) => void;
|
removeFiles: (fileIds: string[], deleteFromStorage?: boolean) => void;
|
||||||
replaceFile: (oldFileId: string, newFile: File) => Promise<void>;
|
replaceFile: (oldFileId: string, newFile: File) => Promise<void>;
|
||||||
clearAllFiles: () => void;
|
clearAllFiles: () => void;
|
||||||
|
|
||||||
@ -93,6 +112,14 @@ export interface FileContextActions {
|
|||||||
applyFileOperation: (operation: FileOperation) => void;
|
applyFileOperation: (operation: FileOperation) => void;
|
||||||
undoLastOperation: (fileId?: string) => 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
|
// Viewer state
|
||||||
updateViewerConfig: (config: Partial<ViewerConfig>) => void;
|
updateViewerConfig: (config: Partial<ViewerConfig>) => void;
|
||||||
|
|
||||||
|
@ -16,9 +16,20 @@ export interface PDFDocument {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PageOperation {
|
export interface PageOperation {
|
||||||
type: 'rotate' | 'delete' | 'move' | 'split' | 'insert';
|
id: string;
|
||||||
|
type: 'rotate' | 'delete' | 'move' | 'split' | 'insert' | 'reorder';
|
||||||
pageIds: string[];
|
pageIds: string[];
|
||||||
|
timestamp: number;
|
||||||
|
status: 'pending' | 'applied' | 'failed';
|
||||||
data?: any;
|
data?: any;
|
||||||
|
metadata?: {
|
||||||
|
rotation?: number;
|
||||||
|
fromPosition?: number;
|
||||||
|
toPosition?: number;
|
||||||
|
splitType?: string;
|
||||||
|
insertAfterPage?: number;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UndoRedoState {
|
export interface UndoRedoState {
|
||||||
|
Loading…
Reference in New Issue
Block a user