mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-07-27 07:35:22 +00:00
Stirling 2.0 (#3928)
# Description of Changes <!-- File context for managing files between tools and views Optimisation for large files Updated Split to work with new file system and match Matts stepped design closer --> --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
This commit is contained in:
parent
584e2ecee7
commit
922bbc9076
@ -5,7 +5,8 @@
|
||||
"Bash(mkdir:*)",
|
||||
"Bash(./gradlew:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(cat:*)"
|
||||
"Bash(cat:*)",
|
||||
"Bash(find:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
|
72
CLAUDE.md
72
CLAUDE.md
@ -25,23 +25,54 @@ Set `DOCKER_ENABLE_SECURITY=true` environment variable to enable security featur
|
||||
- **Proxy Configuration**: Vite proxies `/api/*` calls to backend (localhost:8080)
|
||||
- **Build Process**: DO NOT run build scripts manually - builds are handled by CI/CD pipelines
|
||||
- **Package Installation**: DO NOT run npm install commands - package management handled separately
|
||||
- **Deployment Options**:
|
||||
- **Desktop App**: `npm run tauri-build` (native desktop application)
|
||||
- **Web Server**: `npm run build` then serve dist/ folder
|
||||
- **Development**: `npm run tauri-dev` for desktop dev mode
|
||||
|
||||
#### Tailwind CSS Setup (if not already installed)
|
||||
```bash
|
||||
cd frontend
|
||||
npm install -D tailwindcss postcss autoprefixer
|
||||
npx tailwindcss init -p
|
||||
```
|
||||
#### Multi-Tool Workflow Architecture
|
||||
Frontend designed for **stateful document processing**:
|
||||
- Users upload PDFs once, then chain tools (split → merge → compress → view)
|
||||
- File state and processing results persist across tool switches
|
||||
- No file reloading between tools - performance critical for large PDFs (up to 100GB+)
|
||||
|
||||
#### FileContext - Central State Management
|
||||
**Location**: `src/contexts/FileContext.tsx`
|
||||
- **Active files**: Currently loaded PDFs and their variants
|
||||
- **Tool navigation**: Current mode (viewer/pageEditor/fileEditor/toolName)
|
||||
- **Memory management**: PDF document cleanup, blob URL lifecycle, Web Worker management
|
||||
- **IndexedDB persistence**: File storage with thumbnail caching
|
||||
- **Preview system**: Tools can preview results (e.g., Split → Viewer → back to Split) without context pollution
|
||||
|
||||
**Critical**: All file operations go through FileContext. Don't bypass with direct file handling.
|
||||
|
||||
#### Processing Services
|
||||
- **enhancedPDFProcessingService**: Background PDF parsing and manipulation
|
||||
- **thumbnailGenerationService**: Web Worker-based with main-thread fallback
|
||||
- **fileStorage**: IndexedDB with LRU cache management
|
||||
|
||||
#### Memory Management Strategy
|
||||
**Why manual cleanup exists**: Large PDFs (up to 100GB+) through multiple tools accumulate:
|
||||
- PDF.js documents that need explicit .destroy() calls
|
||||
- Blob URLs from tool outputs that need revocation
|
||||
- Web Workers that need termination
|
||||
Without cleanup: browser crashes with memory leaks.
|
||||
|
||||
#### Tool Development
|
||||
- **Pattern**: Follow `src/tools/Split.tsx` as reference implementation
|
||||
- **File Access**: Tools receive `selectedFiles` prop (computed from activeFiles based on user selection)
|
||||
- **File Selection**: Users select files in FileEditor (tool mode) → stored as IDs → computed to File objects for tools
|
||||
- **Integration**: All files are part of FileContext ecosystem - automatic memory management and operation tracking
|
||||
- **Parameters**: Tool parameter handling patterns still being standardized
|
||||
- **Preview Integration**: Tools can implement preview functionality (see Split tool's thumbnail preview)
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Project Structure
|
||||
- **Backend**: Spring Boot application with Thymeleaf templating
|
||||
- **Frontend**: React-based SPA in `/frontend` directory (replacing legacy Thymeleaf templates)
|
||||
- **Current Status**: Active development to replace Thymeleaf UI with modern React SPA
|
||||
- **Frontend**: React-based SPA in `/frontend` directory (Thymeleaf templates fully replaced)
|
||||
- **File Storage**: IndexedDB for client-side file persistence and thumbnails
|
||||
- **Internationalization**: JSON-based translations (converted from backend .properties)
|
||||
- **URL Parameters**: Deep linking support for tool states and configurations
|
||||
- **PDF Processing**: PDFBox for core PDF operations, LibreOffice for conversions, PDF.js for client-side rendering
|
||||
- **Security**: Spring Security with optional authentication (controlled by `DOCKER_ENABLE_SECURITY`)
|
||||
- **Configuration**: YAML-based configuration with environment variable overrides
|
||||
@ -59,9 +90,8 @@ npx tailwindcss init -p
|
||||
- **Pipeline System**: Automated PDF processing workflows via `PipelineController`
|
||||
- **Security Layer**: Authentication, authorization, and user management (when enabled)
|
||||
|
||||
### Template System (Legacy + Modern)
|
||||
- **Legacy Thymeleaf Templates**: Located in `src/main/resources/templates/` (being phased out)
|
||||
- **Modern React Components**: Located in `frontend/src/components/` and `frontend/src/tools/`
|
||||
### Component Architecture
|
||||
- **React Components**: Located in `frontend/src/components/` and `frontend/src/tools/`
|
||||
- **Static Assets**: CSS, JS, and resources in `src/main/resources/static/` (legacy) + `frontend/public/` (modern)
|
||||
- **Internationalization**:
|
||||
- Backend: `messages_*.properties` files
|
||||
@ -91,13 +121,14 @@ npx tailwindcss init -p
|
||||
- Frontend: Update JSON files in `frontend/public/locales/` or use conversion script
|
||||
5. **Documentation**: API docs auto-generated and available at `/swagger-ui/index.html`
|
||||
|
||||
## Frontend Migration Notes
|
||||
## Frontend Architecture Status
|
||||
|
||||
- **Current Branch**: `feature/react-overhaul` - Active React SPA development
|
||||
- **Migration Status**: Core tools (Split, Merge, Compress) converted to React with URL parameter support
|
||||
- **File Management**: Implemented IndexedDB storage with thumbnail generation using PDF.js
|
||||
- **Tools Architecture**: Each tool receives `params` and `updateParams` for URL state synchronization
|
||||
- **Remaining Work**: Convert remaining Thymeleaf templates to React components
|
||||
- **Core Status**: React SPA architecture complete with multi-tool workflow support
|
||||
- **State Management**: FileContext handles all file operations and tool navigation
|
||||
- **File Processing**: Production-ready with memory management for large PDF workflows (up to 100GB+)
|
||||
- **Tool Integration**: Standardized tool interface - see `src/tools/Split.tsx` as reference
|
||||
- **Preview System**: Tool results can be previewed without polluting file context (Split tool example)
|
||||
- **Performance**: Web Worker thumbnails, IndexedDB persistence, background processing
|
||||
|
||||
## Important Notes
|
||||
|
||||
@ -108,6 +139,11 @@ npx tailwindcss init -p
|
||||
- **Backend**: Designed to be stateless - files are processed in memory/temp locations only
|
||||
- **Frontend**: Uses IndexedDB for client-side file storage and caching (with thumbnails)
|
||||
- **Security**: When `DOCKER_ENABLE_SECURITY=false`, security-related classes are excluded from compilation
|
||||
- **FileContext**: All file operations MUST go through FileContext - never bypass with direct File handling
|
||||
- **Memory Management**: Manual cleanup required for PDF.js documents and blob URLs - don't remove cleanup code
|
||||
- **Tool Development**: New tools should follow Split tool pattern (`src/tools/Split.tsx`)
|
||||
- **Performance Target**: Must handle PDFs up to 100GB+ without browser crashes
|
||||
- **Preview System**: Tools can preview results without polluting main file context (see Split tool implementation)
|
||||
|
||||
## Communication Style
|
||||
- Be direct and to the point
|
||||
|
@ -1,6 +1,5 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import io.github.pixee.security.ZipSecurity;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
@ -21,6 +20,8 @@ import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.ZipSecurity;
|
||||
|
||||
import jakarta.annotation.PreDestroy;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@ -361,7 +362,8 @@ public class TaskManager {
|
||||
MultipartFile zipFile = fileStorage.retrieveFile(zipFileId);
|
||||
|
||||
try (ZipInputStream zipIn =
|
||||
ZipSecurity.createHardenedInputStream(new ByteArrayInputStream(zipFile.getBytes()))) {
|
||||
ZipSecurity.createHardenedInputStream(
|
||||
new ByteArrayInputStream(zipFile.getBytes()))) {
|
||||
ZipEntry entry;
|
||||
while ((entry = zipIn.getNextEntry()) != null) {
|
||||
if (!entry.isDirectory()) {
|
||||
|
76
frontend/package-lock.json
generated
76
frontend/package-lock.json
generated
@ -25,6 +25,7 @@
|
||||
"i18next": "^25.2.1",
|
||||
"i18next-browser-languagedetector": "^8.1.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "^3.11.174",
|
||||
"react": "^19.1.0",
|
||||
@ -2689,6 +2690,11 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
|
||||
},
|
||||
"node_modules/cosmiconfig": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
|
||||
@ -3450,6 +3456,11 @@
|
||||
"cross-fetch": "4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@ -3491,8 +3502,7 @@
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/is-arrayish": {
|
||||
"version": "0.2.1",
|
||||
@ -3571,6 +3581,11 @@
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
|
||||
@ -3630,6 +3645,52 @@
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||
"dependencies": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip/node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
|
||||
},
|
||||
"node_modules/jszip/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
|
||||
@ -4729,6 +4790,11 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
@ -5237,6 +5303,11 @@
|
||||
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="
|
||||
},
|
||||
"node_modules/signal-exit": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||
@ -5697,7 +5768,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
|
@ -21,6 +21,7 @@
|
||||
"i18next": "^25.2.1",
|
||||
"i18next-browser-languagedetector": "^8.1.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "^3.11.174",
|
||||
"react": "^19.1.0",
|
||||
|
22
frontend/public/pdf.js
Normal file
22
frontend/public/pdf.js
Normal file
File diff suppressed because one or more lines are too long
157
frontend/public/thumbnailWorker.js
Normal file
157
frontend/public/thumbnailWorker.js
Normal file
@ -0,0 +1,157 @@
|
||||
// Web Worker for parallel thumbnail generation
|
||||
console.log('🔧 Thumbnail worker starting up...');
|
||||
|
||||
let pdfJsLoaded = false;
|
||||
|
||||
// Import PDF.js properly for worker context
|
||||
try {
|
||||
console.log('📦 Loading PDF.js locally...');
|
||||
importScripts('/pdf.js');
|
||||
|
||||
// PDF.js exports to globalThis, check both self and globalThis
|
||||
const pdfjsLib = self.pdfjsLib || globalThis.pdfjsLib;
|
||||
|
||||
if (pdfjsLib) {
|
||||
// Make it available on self for consistency
|
||||
self.pdfjsLib = pdfjsLib;
|
||||
|
||||
// Set up PDF.js worker
|
||||
self.pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdf.worker.js';
|
||||
pdfJsLoaded = true;
|
||||
console.log('✓ PDF.js loaded successfully from local files');
|
||||
console.log('✓ PDF.js version:', self.pdfjsLib.version || 'unknown');
|
||||
} else {
|
||||
throw new Error('pdfjsLib not available after import - neither self.pdfjsLib nor globalThis.pdfjsLib found');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('✗ Failed to load local PDF.js:', error.message || error);
|
||||
console.error('✗ Available globals:', Object.keys(self).filter(key => key.includes('pdf')));
|
||||
pdfJsLoaded = false;
|
||||
}
|
||||
|
||||
// Log the final status
|
||||
if (pdfJsLoaded) {
|
||||
console.log('✅ Thumbnail worker ready for PDF processing');
|
||||
} else {
|
||||
console.log('❌ Thumbnail worker failed to initialize - PDF.js not available');
|
||||
}
|
||||
|
||||
self.onmessage = async function(e) {
|
||||
const { type, data, jobId } = e.data;
|
||||
|
||||
try {
|
||||
// Handle PING for worker health check
|
||||
if (type === 'PING') {
|
||||
console.log('🏓 Worker PING received, checking PDF.js status...');
|
||||
|
||||
// Check if PDF.js is loaded before responding
|
||||
if (pdfJsLoaded && self.pdfjsLib) {
|
||||
console.log('✓ Worker PONG - PDF.js ready');
|
||||
self.postMessage({ type: 'PONG', jobId });
|
||||
} else {
|
||||
console.error('✗ PDF.js not loaded - worker not ready');
|
||||
console.error('✗ pdfJsLoaded:', pdfJsLoaded);
|
||||
console.error('✗ self.pdfjsLib:', !!self.pdfjsLib);
|
||||
self.postMessage({
|
||||
type: 'ERROR',
|
||||
jobId,
|
||||
data: { error: 'PDF.js not loaded in worker' }
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'GENERATE_THUMBNAILS') {
|
||||
console.log('🖼️ Starting thumbnail generation for', data.pageNumbers.length, 'pages');
|
||||
|
||||
if (!pdfJsLoaded || !self.pdfjsLib) {
|
||||
const error = 'PDF.js not available in worker';
|
||||
console.error('✗', error);
|
||||
throw new Error(error);
|
||||
}
|
||||
const { pdfArrayBuffer, pageNumbers, scale = 0.2, quality = 0.8 } = data;
|
||||
|
||||
console.log('📄 Loading PDF document, size:', pdfArrayBuffer.byteLength, 'bytes');
|
||||
// Load PDF in worker using imported PDF.js
|
||||
const pdf = await self.pdfjsLib.getDocument({ data: pdfArrayBuffer }).promise;
|
||||
console.log('✓ PDF loaded, total pages:', pdf.numPages);
|
||||
|
||||
const thumbnails = [];
|
||||
|
||||
// Process pages in smaller batches for smoother UI
|
||||
const batchSize = 3; // Process 3 pages at once for smoother UI
|
||||
for (let i = 0; i < pageNumbers.length; i += batchSize) {
|
||||
const batch = pageNumbers.slice(i, i + batchSize);
|
||||
|
||||
const batchPromises = batch.map(async (pageNumber) => {
|
||||
try {
|
||||
console.log(`🎯 Processing page ${pageNumber}...`);
|
||||
const page = await pdf.getPage(pageNumber);
|
||||
const viewport = page.getViewport({ scale });
|
||||
console.log(`📐 Page ${pageNumber} viewport:`, viewport.width, 'x', viewport.height);
|
||||
|
||||
// Create OffscreenCanvas for better performance
|
||||
const canvas = new OffscreenCanvas(viewport.width, viewport.height);
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
if (!context) {
|
||||
throw new Error('Failed to get 2D context from OffscreenCanvas');
|
||||
}
|
||||
|
||||
await page.render({ canvasContext: context, viewport }).promise;
|
||||
console.log(`✓ Page ${pageNumber} rendered`);
|
||||
|
||||
// Convert to blob then to base64 (more efficient than toDataURL)
|
||||
const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality });
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
|
||||
const thumbnail = `data:image/jpeg;base64,${base64}`;
|
||||
console.log(`✓ Page ${pageNumber} thumbnail generated (${base64.length} chars)`);
|
||||
|
||||
return { pageNumber, thumbnail, success: true };
|
||||
} catch (error) {
|
||||
console.error(`✗ Failed to generate thumbnail for page ${pageNumber}:`, error.message || error);
|
||||
return { pageNumber, error: error.message || String(error), success: false };
|
||||
}
|
||||
});
|
||||
|
||||
const batchResults = await Promise.all(batchPromises);
|
||||
thumbnails.push(...batchResults);
|
||||
|
||||
// Send progress update
|
||||
console.log(`📊 Worker: Sending progress update - ${thumbnails.length}/${pageNumbers.length} completed, ${batchResults.filter(r => r.success).length} new thumbnails`);
|
||||
self.postMessage({
|
||||
type: 'PROGRESS',
|
||||
jobId,
|
||||
data: {
|
||||
completed: thumbnails.length,
|
||||
total: pageNumbers.length,
|
||||
thumbnails: batchResults.filter(r => r.success)
|
||||
}
|
||||
});
|
||||
|
||||
// Small delay between batches to keep UI smooth
|
||||
if (i + batchSize < pageNumbers.length) {
|
||||
console.log(`⏸️ Worker: Pausing 100ms before next batch (${i + batchSize}/${pageNumbers.length})`);
|
||||
await new Promise(resolve => setTimeout(resolve, 100)); // Increased to 100ms pause between batches for smoother scrolling
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
pdf.destroy();
|
||||
|
||||
self.postMessage({
|
||||
type: 'COMPLETE',
|
||||
jobId,
|
||||
data: { thumbnails: thumbnails.filter(r => r.success) }
|
||||
});
|
||||
|
||||
}
|
||||
} catch (error) {
|
||||
self.postMessage({
|
||||
type: 'ERROR',
|
||||
jobId,
|
||||
data: { error: error.message }
|
||||
});
|
||||
}
|
||||
};
|
@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { RainbowThemeProvider } from './components/shared/RainbowThemeProvider';
|
||||
import { FileContextProvider } from './contexts/FileContext';
|
||||
import HomePage from './pages/HomePage';
|
||||
|
||||
// Import global styles
|
||||
@ -9,7 +10,9 @@ import './index.css';
|
||||
export default function App() {
|
||||
return (
|
||||
<RainbowThemeProvider>
|
||||
<HomePage />
|
||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||
<HomePage />
|
||||
</FileContextProvider>
|
||||
</RainbowThemeProvider>
|
||||
);
|
||||
}
|
||||
|
@ -97,27 +97,8 @@ export class DeletePagesCommand extends PageCommand {
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
let restoredPages = [...this.pdfDocument.pages];
|
||||
|
||||
// Insert deleted pages back at their original positions
|
||||
this.deletedPages
|
||||
.sort((a, b) => (this.deletedPositions.get(a.id) || 0) - (this.deletedPositions.get(b.id) || 0))
|
||||
.forEach(page => {
|
||||
const originalIndex = this.deletedPositions.get(page.id) || 0;
|
||||
restoredPages.splice(originalIndex, 0, page);
|
||||
});
|
||||
|
||||
// Update page numbers
|
||||
restoredPages = restoredPages.map((page, index) => ({
|
||||
...page,
|
||||
pageNumber: index + 1
|
||||
}));
|
||||
|
||||
this.setPdfDocument({
|
||||
...this.pdfDocument,
|
||||
pages: restoredPages,
|
||||
totalPages: restoredPages.length
|
||||
});
|
||||
// Simply restore to the previous state (before deletion)
|
||||
this.setPdfDocument(this.previousState);
|
||||
}
|
||||
|
||||
get description(): string {
|
||||
|
858
frontend/src/components/fileEditor/FileEditor.tsx
Normal file
858
frontend/src/components/fileEditor/FileEditor.tsx
Normal file
@ -0,0 +1,858 @@
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import {
|
||||
Button, Text, Center, Box, Notification, TextInput, LoadingOverlay, Modal, Alert, Container,
|
||||
Stack, Group
|
||||
} from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||
import { useFileContext } from '../../contexts/FileContext';
|
||||
import { FileOperation } from '../../types/fileContext';
|
||||
import { fileStorage } from '../../services/fileStorage';
|
||||
import { generateThumbnailForFile } from '../../utils/thumbnailUtils';
|
||||
import { zipFileService } from '../../services/zipFileService';
|
||||
import styles from '../pageEditor/PageEditor.module.css';
|
||||
import FileThumbnail from '../pageEditor/FileThumbnail';
|
||||
import DragDropGrid from '../pageEditor/DragDropGrid';
|
||||
import FilePickerModal from '../shared/FilePickerModal';
|
||||
import SkeletonLoader from '../shared/SkeletonLoader';
|
||||
|
||||
interface FileItem {
|
||||
id: string;
|
||||
name: string;
|
||||
pageCount: number;
|
||||
thumbnail: string;
|
||||
size: number;
|
||||
file: File;
|
||||
splitBefore?: boolean;
|
||||
}
|
||||
|
||||
interface FileEditorProps {
|
||||
onOpenPageEditor?: (file: File) => void;
|
||||
onMergeFiles?: (files: File[]) => void;
|
||||
toolMode?: boolean;
|
||||
multiSelect?: boolean;
|
||||
showUpload?: boolean;
|
||||
showBulkActions?: boolean;
|
||||
onFileSelect?: (files: File[]) => void;
|
||||
}
|
||||
|
||||
const FileEditor = ({
|
||||
onOpenPageEditor,
|
||||
onMergeFiles,
|
||||
toolMode = false,
|
||||
multiSelect = true,
|
||||
showUpload = true,
|
||||
showBulkActions = true,
|
||||
onFileSelect
|
||||
}: FileEditorProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Get file context
|
||||
const fileContext = useFileContext();
|
||||
const {
|
||||
activeFiles,
|
||||
processedFiles,
|
||||
selectedFileIds,
|
||||
setSelectedFiles: setContextSelectedFiles,
|
||||
isProcessing,
|
||||
addFiles,
|
||||
removeFiles,
|
||||
setCurrentView,
|
||||
recordOperation,
|
||||
markOperationApplied
|
||||
} = fileContext;
|
||||
|
||||
const [files, setFiles] = useState<FileItem[]>([]);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [localLoading, setLocalLoading] = useState(false);
|
||||
const [selectionMode, setSelectionMode] = useState(toolMode);
|
||||
|
||||
// Enable selection mode automatically in tool mode
|
||||
React.useEffect(() => {
|
||||
if (toolMode) {
|
||||
setSelectionMode(true);
|
||||
}
|
||||
}, [toolMode]);
|
||||
const [draggedFile, setDraggedFile] = useState<string | null>(null);
|
||||
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
||||
const [multiFileDrag, setMultiFileDrag] = useState<{fileIds: string[], count: number} | null>(null);
|
||||
const [dragPosition, setDragPosition] = useState<{x: number, y: number} | null>(null);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const [showFilePickerModal, setShowFilePickerModal] = useState(false);
|
||||
const [conversionProgress, setConversionProgress] = useState(0);
|
||||
const [zipExtractionProgress, setZipExtractionProgress] = useState<{
|
||||
isExtracting: boolean;
|
||||
currentFile: string;
|
||||
progress: number;
|
||||
extractedCount: number;
|
||||
totalFiles: number;
|
||||
}>({
|
||||
isExtracting: false,
|
||||
currentFile: '',
|
||||
progress: 0,
|
||||
extractedCount: 0,
|
||||
totalFiles: 0
|
||||
});
|
||||
const fileRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const lastActiveFilesRef = useRef<string[]>([]);
|
||||
const lastProcessedFilesRef = useRef<number>(0);
|
||||
|
||||
// Map context selected file names to local file IDs
|
||||
// Defensive programming: ensure selectedFileIds is always an array
|
||||
const safeSelectedFileIds = Array.isArray(selectedFileIds) ? selectedFileIds : [];
|
||||
|
||||
const localSelectedFiles = files
|
||||
.filter(file => {
|
||||
const fileId = (file.file as any).id || file.name;
|
||||
return safeSelectedFileIds.includes(fileId);
|
||||
})
|
||||
.map(file => file.id);
|
||||
|
||||
// Convert shared files to FileEditor format
|
||||
const convertToFileItem = useCallback(async (sharedFile: any): Promise<FileItem> => {
|
||||
// Generate thumbnail if not already available
|
||||
const thumbnail = sharedFile.thumbnail || await generateThumbnailForFile(sharedFile.file || sharedFile);
|
||||
|
||||
return {
|
||||
id: sharedFile.id || `file-${Date.now()}-${Math.random()}`,
|
||||
name: (sharedFile.file?.name || sharedFile.name || 'unknown'),
|
||||
pageCount: sharedFile.pageCount || Math.floor(Math.random() * 20) + 1, // Mock for now
|
||||
thumbnail,
|
||||
size: sharedFile.file?.size || sharedFile.size || 0,
|
||||
file: sharedFile.file || sharedFile,
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Convert activeFiles to FileItem format using context (async to avoid blocking)
|
||||
useEffect(() => {
|
||||
// Check if the actual content has changed, not just references
|
||||
const currentActiveFileNames = activeFiles.map(f => f.name);
|
||||
const currentProcessedFilesSize = processedFiles.size;
|
||||
|
||||
const activeFilesChanged = JSON.stringify(currentActiveFileNames) !== JSON.stringify(lastActiveFilesRef.current);
|
||||
const processedFilesChanged = currentProcessedFilesSize !== lastProcessedFilesRef.current;
|
||||
|
||||
if (!activeFilesChanged && !processedFilesChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update refs
|
||||
lastActiveFilesRef.current = currentActiveFileNames;
|
||||
lastProcessedFilesRef.current = currentProcessedFilesSize;
|
||||
|
||||
const convertActiveFiles = async () => {
|
||||
|
||||
if (activeFiles.length > 0) {
|
||||
setLocalLoading(true);
|
||||
try {
|
||||
// Process files in chunks to avoid blocking UI
|
||||
const convertedFiles: FileItem[] = [];
|
||||
|
||||
for (let i = 0; i < activeFiles.length; i++) {
|
||||
const file = activeFiles[i];
|
||||
|
||||
// Try to get thumbnail from processed file first
|
||||
const processedFile = processedFiles.get(file);
|
||||
let thumbnail = processedFile?.pages?.[0]?.thumbnail;
|
||||
|
||||
// If no thumbnail from processed file, try to generate one
|
||||
if (!thumbnail) {
|
||||
try {
|
||||
thumbnail = await generateThumbnailForFile(file);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to generate thumbnail for ${file.name}:`, error);
|
||||
thumbnail = undefined; // Use placeholder
|
||||
}
|
||||
}
|
||||
|
||||
const convertedFile = {
|
||||
id: `file-${Date.now()}-${Math.random()}`,
|
||||
name: file.name,
|
||||
pageCount: processedFile?.totalPages || Math.floor(Math.random() * 20) + 1,
|
||||
thumbnail,
|
||||
size: file.size,
|
||||
file,
|
||||
};
|
||||
|
||||
convertedFiles.push(convertedFile);
|
||||
|
||||
// Update progress
|
||||
setConversionProgress(((i + 1) / activeFiles.length) * 100);
|
||||
|
||||
// Yield to main thread between files
|
||||
if (i < activeFiles.length - 1) {
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
setFiles(convertedFiles);
|
||||
} catch (err) {
|
||||
console.error('Error converting active files:', err);
|
||||
} finally {
|
||||
setLocalLoading(false);
|
||||
setConversionProgress(0);
|
||||
}
|
||||
} else {
|
||||
setFiles([]);
|
||||
setLocalLoading(false);
|
||||
setConversionProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
convertActiveFiles();
|
||||
}, [activeFiles, processedFiles]);
|
||||
|
||||
|
||||
// Process uploaded files using context
|
||||
const handleFileUpload = useCallback(async (uploadedFiles: File[]) => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const allExtractedFiles: File[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const file of uploadedFiles) {
|
||||
if (file.type === 'application/pdf') {
|
||||
// Handle PDF files normally
|
||||
allExtractedFiles.push(file);
|
||||
} else if (file.type === 'application/zip' || file.type === 'application/x-zip-compressed' || file.name.toLowerCase().endsWith('.zip')) {
|
||||
// Handle ZIP files
|
||||
try {
|
||||
// Validate ZIP file first
|
||||
const validation = await zipFileService.validateZipFile(file);
|
||||
if (!validation.isValid) {
|
||||
errors.push(`ZIP file "${file.name}": ${validation.errors.join(', ')}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract PDF files from ZIP
|
||||
setZipExtractionProgress({
|
||||
isExtracting: true,
|
||||
currentFile: file.name,
|
||||
progress: 0,
|
||||
extractedCount: 0,
|
||||
totalFiles: validation.fileCount
|
||||
});
|
||||
|
||||
const extractionResult = await zipFileService.extractPdfFiles(file, (progress) => {
|
||||
setZipExtractionProgress({
|
||||
isExtracting: true,
|
||||
currentFile: progress.currentFile,
|
||||
progress: progress.progress,
|
||||
extractedCount: progress.extractedCount,
|
||||
totalFiles: progress.totalFiles
|
||||
});
|
||||
});
|
||||
|
||||
// Reset extraction progress
|
||||
setZipExtractionProgress({
|
||||
isExtracting: false,
|
||||
currentFile: '',
|
||||
progress: 0,
|
||||
extractedCount: 0,
|
||||
totalFiles: 0
|
||||
});
|
||||
|
||||
if (extractionResult.success) {
|
||||
allExtractedFiles.push(...extractionResult.extractedFiles);
|
||||
|
||||
// Record ZIP extraction operation
|
||||
const operationId = `zip-extract-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const operation: FileOperation = {
|
||||
id: operationId,
|
||||
type: 'convert',
|
||||
timestamp: Date.now(),
|
||||
fileIds: extractionResult.extractedFiles.map(f => f.name),
|
||||
status: 'pending',
|
||||
metadata: {
|
||||
originalFileName: file.name,
|
||||
outputFileNames: extractionResult.extractedFiles.map(f => f.name),
|
||||
fileSize: file.size,
|
||||
parameters: {
|
||||
extractionType: 'zip',
|
||||
extractedCount: extractionResult.extractedCount,
|
||||
totalFiles: extractionResult.totalFiles
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
recordOperation(file.name, operation);
|
||||
markOperationApplied(file.name, operationId);
|
||||
|
||||
if (extractionResult.errors.length > 0) {
|
||||
errors.push(...extractionResult.errors);
|
||||
}
|
||||
} else {
|
||||
errors.push(`Failed to extract ZIP file "${file.name}": ${extractionResult.errors.join(', ')}`);
|
||||
}
|
||||
} catch (zipError) {
|
||||
errors.push(`Failed to process ZIP file "${file.name}": ${zipError instanceof Error ? zipError.message : 'Unknown error'}`);
|
||||
setZipExtractionProgress({
|
||||
isExtracting: false,
|
||||
currentFile: '',
|
||||
progress: 0,
|
||||
extractedCount: 0,
|
||||
totalFiles: 0
|
||||
});
|
||||
}
|
||||
} else {
|
||||
errors.push(`Unsupported file type: ${file.name} (${file.type})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Show any errors
|
||||
if (errors.length > 0) {
|
||||
setError(errors.join('\n'));
|
||||
}
|
||||
|
||||
// Process all extracted files
|
||||
if (allExtractedFiles.length > 0) {
|
||||
// Record upload operations for PDF files
|
||||
for (const file of allExtractedFiles) {
|
||||
const operationId = `upload-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const operation: FileOperation = {
|
||||
id: operationId,
|
||||
type: 'upload',
|
||||
timestamp: Date.now(),
|
||||
fileIds: [file.name],
|
||||
status: 'pending',
|
||||
metadata: {
|
||||
originalFileName: file.name,
|
||||
fileSize: file.size,
|
||||
parameters: {
|
||||
uploadMethod: 'drag-drop'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
recordOperation(file.name, operation);
|
||||
markOperationApplied(file.name, operationId);
|
||||
}
|
||||
|
||||
// Add files to context (they will be processed automatically)
|
||||
await addFiles(allExtractedFiles);
|
||||
setStatus(`Added ${allExtractedFiles.length} files`);
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to process files';
|
||||
setError(errorMessage);
|
||||
console.error('File processing error:', err);
|
||||
|
||||
// Reset extraction progress on error
|
||||
setZipExtractionProgress({
|
||||
isExtracting: false,
|
||||
currentFile: '',
|
||||
progress: 0,
|
||||
extractedCount: 0,
|
||||
totalFiles: 0
|
||||
});
|
||||
}
|
||||
}, [addFiles, recordOperation, markOperationApplied]);
|
||||
|
||||
const selectAll = useCallback(() => {
|
||||
setContextSelectedFiles(files.map(f => (f.file as any).id || f.name));
|
||||
}, [files, 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 as any).id || f.name), false);
|
||||
|
||||
// Clear selections
|
||||
setContextSelectedFiles([]);
|
||||
}, [activeFiles, removeFiles, setContextSelectedFiles, recordOperation, markOperationApplied]);
|
||||
|
||||
const toggleFile = useCallback((fileId: string) => {
|
||||
const targetFile = files.find(f => f.id === fileId);
|
||||
if (!targetFile) return;
|
||||
|
||||
const contextFileId = (targetFile.file as any).id || targetFile.name;
|
||||
|
||||
if (!multiSelect) {
|
||||
// Single select mode for tools - toggle on/off
|
||||
const isCurrentlySelected = safeSelectedFileIds.includes(contextFileId);
|
||||
if (isCurrentlySelected) {
|
||||
// Deselect the file
|
||||
setContextSelectedFiles([]);
|
||||
if (onFileSelect) {
|
||||
onFileSelect([]);
|
||||
}
|
||||
} else {
|
||||
// Select the file
|
||||
setContextSelectedFiles([contextFileId]);
|
||||
if (onFileSelect) {
|
||||
onFileSelect([targetFile.file]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Multi select mode (default)
|
||||
setContextSelectedFiles(prev => {
|
||||
const safePrev = Array.isArray(prev) ? prev : [];
|
||||
return safePrev.includes(contextFileId)
|
||||
? safePrev.filter(id => id !== contextFileId)
|
||||
: [...safePrev, contextFileId];
|
||||
});
|
||||
|
||||
// Notify parent with selected files
|
||||
if (onFileSelect) {
|
||||
const selectedFiles = files
|
||||
.filter(f => {
|
||||
const fId = (f.file as any).id || f.name;
|
||||
return safeSelectedFileIds.includes(fId) || fId === contextFileId;
|
||||
})
|
||||
.map(f => f.file);
|
||||
onFileSelect(selectedFiles);
|
||||
}
|
||||
}
|
||||
}, [files, setContextSelectedFiles, multiSelect, onFileSelect, safeSelectedFileIds]);
|
||||
|
||||
const toggleSelectionMode = useCallback(() => {
|
||||
setSelectionMode(prev => {
|
||||
const newMode = !prev;
|
||||
if (!newMode) {
|
||||
setContextSelectedFiles([]);
|
||||
}
|
||||
return newMode;
|
||||
});
|
||||
}, [setContextSelectedFiles]);
|
||||
|
||||
|
||||
// Drag and drop handlers
|
||||
const handleDragStart = useCallback((fileId: string) => {
|
||||
setDraggedFile(fileId);
|
||||
|
||||
if (selectionMode && localSelectedFiles.includes(fileId) && localSelectedFiles.length > 1) {
|
||||
setMultiFileDrag({
|
||||
fileIds: localSelectedFiles,
|
||||
count: localSelectedFiles.length
|
||||
});
|
||||
} else {
|
||||
setMultiFileDrag(null);
|
||||
}
|
||||
}, [selectionMode, localSelectedFiles]);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDraggedFile(null);
|
||||
setDropTarget(null);
|
||||
setMultiFileDrag(null);
|
||||
setDragPosition(null);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!draggedFile) return;
|
||||
|
||||
if (multiFileDrag) {
|
||||
setDragPosition({ x: e.clientX, y: e.clientY });
|
||||
}
|
||||
|
||||
const elementUnderCursor = document.elementFromPoint(e.clientX, e.clientY);
|
||||
if (!elementUnderCursor) return;
|
||||
|
||||
const fileContainer = elementUnderCursor.closest('[data-file-id]');
|
||||
if (fileContainer) {
|
||||
const fileId = fileContainer.getAttribute('data-file-id');
|
||||
if (fileId && fileId !== draggedFile) {
|
||||
setDropTarget(fileId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const endZone = elementUnderCursor.closest('[data-drop-zone="end"]');
|
||||
if (endZone) {
|
||||
setDropTarget('end');
|
||||
return;
|
||||
}
|
||||
|
||||
setDropTarget(null);
|
||||
}, [draggedFile, multiFileDrag]);
|
||||
|
||||
const handleDragEnter = useCallback((fileId: string) => {
|
||||
if (draggedFile && fileId !== draggedFile) {
|
||||
setDropTarget(fileId);
|
||||
}
|
||||
}, [draggedFile]);
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
// Let dragover handle this
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent, targetFileId: string | 'end') => {
|
||||
e.preventDefault();
|
||||
if (!draggedFile || draggedFile === targetFileId) return;
|
||||
|
||||
let targetIndex: number;
|
||||
if (targetFileId === 'end') {
|
||||
targetIndex = files.length;
|
||||
} else {
|
||||
targetIndex = files.findIndex(f => f.id === targetFileId);
|
||||
if (targetIndex === -1) return;
|
||||
}
|
||||
|
||||
const filesToMove = selectionMode && localSelectedFiles.includes(draggedFile)
|
||||
? localSelectedFiles
|
||||
: [draggedFile];
|
||||
|
||||
// Update the local files state and sync with activeFiles
|
||||
setFiles(prev => {
|
||||
const newFiles = [...prev];
|
||||
const movedFiles = filesToMove.map(id => newFiles.find(f => f.id === id)!).filter(Boolean);
|
||||
|
||||
// Remove moved files
|
||||
filesToMove.forEach(id => {
|
||||
const index = newFiles.findIndex(f => f.id === id);
|
||||
if (index !== -1) newFiles.splice(index, 1);
|
||||
});
|
||||
|
||||
// Insert at target position
|
||||
newFiles.splice(targetIndex, 0, ...movedFiles);
|
||||
|
||||
// TODO: Update context with reordered files (need to implement file reordering in context)
|
||||
// For now, just return the reordered local state
|
||||
return newFiles;
|
||||
});
|
||||
|
||||
const moveCount = multiFileDrag ? multiFileDrag.count : 1;
|
||||
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
|
||||
|
||||
}, [draggedFile, files, selectionMode, localSelectedFiles, multiFileDrag]);
|
||||
|
||||
const handleEndZoneDragEnter = useCallback(() => {
|
||||
if (draggedFile) {
|
||||
setDropTarget('end');
|
||||
}
|
||||
}, [draggedFile]);
|
||||
|
||||
// File operations using context
|
||||
const handleDeleteFile = useCallback((fileId: string) => {
|
||||
console.log('handleDeleteFile called with fileId:', fileId);
|
||||
const file = files.find(f => f.id === fileId);
|
||||
console.log('Found file:', file);
|
||||
|
||||
if (file) {
|
||||
console.log('Attempting to remove file:', file.name);
|
||||
console.log('Actual file object:', file.file);
|
||||
console.log('Actual file.file.name:', file.file.name);
|
||||
|
||||
// Record close operation
|
||||
const fileName = file.file.name;
|
||||
const fileId = (file.file as any).id || fileName;
|
||||
const operationId = `close-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const operation: FileOperation = {
|
||||
id: operationId,
|
||||
type: 'remove',
|
||||
timestamp: Date.now(),
|
||||
fileIds: [fileName],
|
||||
status: 'pending',
|
||||
metadata: {
|
||||
originalFileName: fileName,
|
||||
fileSize: file.size,
|
||||
parameters: {
|
||||
action: 'close',
|
||||
reason: 'user_request'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
recordOperation(fileName, operation);
|
||||
|
||||
// Remove file from context but keep in storage (close, don't delete)
|
||||
console.log('Calling removeFiles with:', [fileId]);
|
||||
removeFiles([fileId], false);
|
||||
|
||||
// Remove from context selections
|
||||
setContextSelectedFiles(prev => {
|
||||
const safePrev = Array.isArray(prev) ? prev : [];
|
||||
return safePrev.filter(id => id !== fileId);
|
||||
});
|
||||
|
||||
// Mark operation as applied
|
||||
markOperationApplied(fileName, operationId);
|
||||
} else {
|
||||
console.log('File not found for fileId:', fileId);
|
||||
}
|
||||
}, [files, removeFiles, setContextSelectedFiles, recordOperation, markOperationApplied]);
|
||||
|
||||
const handleViewFile = useCallback((fileId: string) => {
|
||||
const file = files.find(f => f.id === fileId);
|
||||
if (file) {
|
||||
// Set the file as selected in context and switch to page editor view
|
||||
const contextFileId = (file.file as any).id || file.name;
|
||||
setContextSelectedFiles([contextFileId]);
|
||||
setCurrentView('pageEditor');
|
||||
onOpenPageEditor?.(file.file);
|
||||
}
|
||||
}, [files, setContextSelectedFiles, setCurrentView, onOpenPageEditor]);
|
||||
|
||||
const handleMergeFromHere = useCallback((fileId: string) => {
|
||||
const startIndex = files.findIndex(f => f.id === fileId);
|
||||
if (startIndex === -1) return;
|
||||
|
||||
const filesToMerge = files.slice(startIndex).map(f => f.file);
|
||||
if (onMergeFiles) {
|
||||
onMergeFiles(filesToMerge);
|
||||
}
|
||||
}, [files, onMergeFiles]);
|
||||
|
||||
const handleSplitFile = useCallback((fileId: string) => {
|
||||
const file = files.find(f => f.id === fileId);
|
||||
if (file && onOpenPageEditor) {
|
||||
onOpenPageEditor(file.file);
|
||||
}
|
||||
}, [files, onOpenPageEditor]);
|
||||
|
||||
const handleLoadFromStorage = useCallback(async (selectedFiles: any[]) => {
|
||||
if (selectedFiles.length === 0) return;
|
||||
|
||||
setLocalLoading(true);
|
||||
try {
|
||||
const convertedFiles = await Promise.all(
|
||||
selectedFiles.map(convertToFileItem)
|
||||
);
|
||||
setFiles(prev => [...prev, ...convertedFiles]);
|
||||
setStatus(`Loaded ${selectedFiles.length} files from storage`);
|
||||
} catch (err) {
|
||||
console.error('Error loading files from storage:', err);
|
||||
setError('Failed to load some files from storage');
|
||||
} finally {
|
||||
setLocalLoading(false);
|
||||
}
|
||||
}, [convertToFileItem]);
|
||||
|
||||
|
||||
return (
|
||||
<Box pos="relative" h="100vh" style={{ overflow: 'auto' }}>
|
||||
<LoadingOverlay visible={false} />
|
||||
|
||||
<Box p="md" pt="xl">
|
||||
<Group mb="md">
|
||||
{showBulkActions && !toolMode && (
|
||||
<>
|
||||
<Button onClick={selectAll} variant="light">Select All</Button>
|
||||
<Button onClick={deselectAll} variant="light">Deselect All</Button>
|
||||
<Button onClick={closeAllFiles} variant="light" color="orange">
|
||||
Close All
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Load from storage and upload buttons */}
|
||||
{showUpload && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="blue"
|
||||
onClick={() => setShowFilePickerModal(true)}
|
||||
>
|
||||
Load from Storage
|
||||
</Button>
|
||||
|
||||
<Dropzone
|
||||
onDrop={handleFileUpload}
|
||||
accept={["application/pdf", "application/zip", "application/x-zip-compressed"]}
|
||||
multiple={true}
|
||||
maxSize={2 * 1024 * 1024 * 1024}
|
||||
style={{ display: 'contents' }}
|
||||
>
|
||||
<Button variant="outline" color="green">
|
||||
Upload Files
|
||||
</Button>
|
||||
</Dropzone>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
|
||||
{files.length === 0 && !localLoading && !zipExtractionProgress.isExtracting ? (
|
||||
<Center h="60vh">
|
||||
<Stack align="center" gap="md">
|
||||
<Text size="lg" c="dimmed">📁</Text>
|
||||
<Text c="dimmed">No files loaded</Text>
|
||||
<Text size="sm" c="dimmed">Upload PDF files, ZIP archives, or load from storage to get started</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
) : files.length === 0 && (localLoading || zipExtractionProgress.isExtracting) ? (
|
||||
<Box>
|
||||
<SkeletonLoader type="controls" />
|
||||
|
||||
{/* ZIP Extraction Progress */}
|
||||
{zipExtractionProgress.isExtracting && (
|
||||
<Box mb="md" p="sm" style={{ backgroundColor: 'var(--mantine-color-orange-0)', borderRadius: 8 }}>
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text size="sm" fw={500}>Extracting ZIP archive...</Text>
|
||||
<Text size="sm" c="dimmed">{Math.round(zipExtractionProgress.progress)}%</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
{zipExtractionProgress.currentFile || 'Processing files...'}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
{zipExtractionProgress.extractedCount} of {zipExtractionProgress.totalFiles} files extracted
|
||||
</Text>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '4px',
|
||||
backgroundColor: 'var(--mantine-color-gray-2)',
|
||||
borderRadius: '2px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${Math.round(zipExtractionProgress.progress)}%`,
|
||||
height: '100%',
|
||||
backgroundColor: 'var(--mantine-color-orange-6)',
|
||||
transition: 'width 0.3s ease'
|
||||
}} />
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Processing indicator */}
|
||||
{localLoading && (
|
||||
<Box mb="md" p="sm" style={{ backgroundColor: 'var(--mantine-color-blue-0)', borderRadius: 8 }}>
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text size="sm" fw={500}>Loading files...</Text>
|
||||
<Text size="sm" c="dimmed">{Math.round(conversionProgress)}%</Text>
|
||||
</Group>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '4px',
|
||||
backgroundColor: 'var(--mantine-color-gray-2)',
|
||||
borderRadius: '2px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${Math.round(conversionProgress)}%`,
|
||||
height: '100%',
|
||||
backgroundColor: 'var(--mantine-color-blue-6)',
|
||||
transition: 'width 0.3s ease'
|
||||
}} />
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<SkeletonLoader type="fileGrid" count={6} />
|
||||
</Box>
|
||||
) : (
|
||||
<DragDropGrid
|
||||
items={files}
|
||||
selectedItems={localSelectedFiles}
|
||||
selectionMode={selectionMode}
|
||||
isAnimating={isAnimating}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onEndZoneDragEnter={handleEndZoneDragEnter}
|
||||
draggedItem={draggedFile}
|
||||
dropTarget={dropTarget}
|
||||
multiItemDrag={multiFileDrag}
|
||||
dragPosition={dragPosition}
|
||||
renderItem={(file, index, refs) => (
|
||||
<FileThumbnail
|
||||
file={file}
|
||||
index={index}
|
||||
totalFiles={files.length}
|
||||
selectedFiles={localSelectedFiles}
|
||||
selectionMode={selectionMode}
|
||||
draggedFile={draggedFile}
|
||||
dropTarget={dropTarget}
|
||||
isAnimating={isAnimating}
|
||||
fileRefs={refs}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onToggleFile={toggleFile}
|
||||
onDeleteFile={handleDeleteFile}
|
||||
onViewFile={handleViewFile}
|
||||
onMergeFromHere={handleMergeFromHere}
|
||||
onSplitFile={handleSplitFile}
|
||||
onSetStatus={setStatus}
|
||||
toolMode={toolMode}
|
||||
/>
|
||||
)}
|
||||
renderSplitMarker={(file, index) => (
|
||||
<div
|
||||
style={{
|
||||
width: '2px',
|
||||
height: '24rem',
|
||||
borderLeft: '2px dashed #3b82f6',
|
||||
backgroundColor: 'transparent',
|
||||
marginLeft: '-0.75rem',
|
||||
marginRight: '-0.75rem',
|
||||
flexShrink: 0
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* File Picker Modal */}
|
||||
<FilePickerModal
|
||||
opened={showFilePickerModal}
|
||||
onClose={() => setShowFilePickerModal(false)}
|
||||
storedFiles={[]} // FileEditor doesn't have access to stored files, needs to be passed from parent
|
||||
onSelectFiles={handleLoadFromStorage}
|
||||
allowMultiple={true}
|
||||
/>
|
||||
|
||||
{status && (
|
||||
<Notification
|
||||
color="blue"
|
||||
mt="md"
|
||||
onClose={() => setStatus(null)}
|
||||
style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }}
|
||||
>
|
||||
{status}
|
||||
</Notification>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Notification
|
||||
color="red"
|
||||
mt="md"
|
||||
onClose={() => setError(null)}
|
||||
style={{ position: 'fixed', bottom: 80, right: 20, zIndex: 1000 }}
|
||||
>
|
||||
{error}
|
||||
</Notification>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileEditor;
|
@ -6,9 +6,9 @@ import StorageIcon from "@mui/icons-material/Storage";
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
|
||||
import { FileWithUrl } from "../../types/file";
|
||||
import { getFileSize, getFileDate } from "../../utils/fileUtils";
|
||||
import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail";
|
||||
import { fileStorage } from "../../services/fileStorage";
|
||||
|
||||
interface FileCardProps {
|
||||
file: FileWithUrl;
|
||||
|
@ -1,440 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Box, Flex, Text, Notification, Button, Group } from "@mantine/core";
|
||||
import { Dropzone, MIME_TYPES } from "@mantine/dropzone";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { GlobalWorkerOptions } from "pdfjs-dist";
|
||||
import { StorageStats } from "../../services/fileStorage";
|
||||
import { FileWithUrl, defaultStorageConfig, initializeStorageConfig, StorageConfig } from "../../types/file";
|
||||
|
||||
// Refactored imports
|
||||
import { fileOperationsService } from "../../services/fileOperationsService";
|
||||
import { checkStorageWarnings } from "../../utils/storageUtils";
|
||||
import StorageStatsCard from "./StorageStatsCard";
|
||||
import FileCard from "./FileCard";
|
||||
import FileUploadSelector from "../shared/FileUploadSelector";
|
||||
|
||||
GlobalWorkerOptions.workerSrc = "/pdf.worker.js";
|
||||
|
||||
interface FileManagerProps {
|
||||
files: FileWithUrl[];
|
||||
setFiles: React.Dispatch<React.SetStateAction<FileWithUrl[]>>;
|
||||
allowMultiple?: boolean;
|
||||
setCurrentView?: (view: string) => void;
|
||||
onOpenFileEditor?: (selectedFiles?: FileWithUrl[]) => void;
|
||||
onOpenPageEditor?: (selectedFiles?: FileWithUrl[]) => void;
|
||||
onLoadFileToActive?: (file: File) => void;
|
||||
}
|
||||
|
||||
const FileManager = ({
|
||||
files = [],
|
||||
setFiles,
|
||||
allowMultiple = true,
|
||||
setCurrentView,
|
||||
onOpenFileEditor,
|
||||
onOpenPageEditor,
|
||||
onLoadFileToActive,
|
||||
}: FileManagerProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [storageStats, setStorageStats] = useState<StorageStats | null>(null);
|
||||
const [notification, setNotification] = useState<string | null>(null);
|
||||
const [filesLoaded, setFilesLoaded] = useState(false);
|
||||
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
|
||||
const [storageConfig, setStorageConfig] = useState<StorageConfig>(defaultStorageConfig);
|
||||
|
||||
// Extract operations from service for cleaner code
|
||||
const {
|
||||
loadStorageStats,
|
||||
forceReloadFiles,
|
||||
loadExistingFiles,
|
||||
uploadFiles,
|
||||
removeFile,
|
||||
clearAllFiles,
|
||||
createBlobUrlForFile,
|
||||
checkForPurge,
|
||||
updateStorageStatsIncremental
|
||||
} = fileOperationsService;
|
||||
|
||||
// Add CSS for spinner animation
|
||||
useEffect(() => {
|
||||
if (!document.querySelector('#spinner-animation')) {
|
||||
const style = document.createElement('style');
|
||||
style.id = 'spinner-animation';
|
||||
style.textContent = `
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load existing files from IndexedDB on mount
|
||||
useEffect(() => {
|
||||
if (!filesLoaded) {
|
||||
handleLoadExistingFiles();
|
||||
}
|
||||
}, [filesLoaded]);
|
||||
|
||||
// Initialize storage configuration on mount
|
||||
useEffect(() => {
|
||||
const initStorage = async () => {
|
||||
try {
|
||||
const config = await initializeStorageConfig();
|
||||
setStorageConfig(config);
|
||||
console.log('Initialized storage config:', config);
|
||||
} catch (error) {
|
||||
console.warn('Failed to initialize storage config, using defaults:', error);
|
||||
}
|
||||
};
|
||||
|
||||
initStorage();
|
||||
}, []);
|
||||
|
||||
// Load storage stats and set up periodic updates
|
||||
useEffect(() => {
|
||||
handleLoadStorageStats();
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
await handleLoadStorageStats();
|
||||
await handleCheckForPurge();
|
||||
}, 10000); // Update every 10 seconds
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Sync UI with IndexedDB whenever storage stats change
|
||||
useEffect(() => {
|
||||
const syncWithStorage = async () => {
|
||||
if (storageStats && filesLoaded) {
|
||||
// If file counts don't match, force reload
|
||||
if (storageStats.fileCount !== files.length) {
|
||||
console.warn('File count mismatch: storage has', storageStats.fileCount, 'but UI shows', files.length, '- forcing reload');
|
||||
const reloadedFiles = await forceReloadFiles();
|
||||
setFiles(reloadedFiles);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
syncWithStorage();
|
||||
}, [storageStats, filesLoaded, files.length]);
|
||||
|
||||
// Handlers using extracted operations
|
||||
const handleLoadStorageStats = async () => {
|
||||
const stats = await loadStorageStats();
|
||||
if (stats) {
|
||||
setStorageStats(stats);
|
||||
|
||||
// Check for storage warnings
|
||||
const warning = checkStorageWarnings(stats);
|
||||
if (warning) {
|
||||
setNotification(warning);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadExistingFiles = async () => {
|
||||
try {
|
||||
const loadedFiles = await loadExistingFiles(filesLoaded, files);
|
||||
setFiles(loadedFiles);
|
||||
setFilesLoaded(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to load existing files:', error);
|
||||
setFilesLoaded(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckForPurge = async () => {
|
||||
try {
|
||||
const isPurged = await checkForPurge(files);
|
||||
if (isPurged) {
|
||||
console.warn('IndexedDB purge detected - forcing UI reload');
|
||||
setNotification(t("fileManager.storageCleared", "Browser cleared storage. Files have been removed. Please re-upload."));
|
||||
const reloadedFiles = await forceReloadFiles();
|
||||
setFiles(reloadedFiles);
|
||||
setFilesLoaded(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking for purge:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const validateStorageLimits = (filesToUpload: File[]): { valid: boolean; error?: string } => {
|
||||
// Check individual file sizes
|
||||
for (const file of filesToUpload) {
|
||||
if (file.size > storageConfig.maxFileSize) {
|
||||
const maxSizeMB = Math.round(storageConfig.maxFileSize / (1024 * 1024));
|
||||
return {
|
||||
valid: false,
|
||||
error: `${t("storage.fileTooLarge", "File too large. Maximum size per file is")} ${maxSizeMB}MB`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check total storage capacity
|
||||
if (storageStats) {
|
||||
const totalNewSize = filesToUpload.reduce((sum, file) => sum + file.size, 0);
|
||||
const projectedUsage = storageStats.totalSize + totalNewSize;
|
||||
|
||||
if (projectedUsage > storageConfig.maxTotalStorage) {
|
||||
return {
|
||||
valid: false,
|
||||
error: t("storage.storageQuotaExceeded", "Storage quota exceeded. Please remove some files before uploading more.")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
const handleDrop = async (uploadedFiles: File[]) => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Validate storage limits before uploading
|
||||
const validation = validateStorageLimits(uploadedFiles);
|
||||
if (!validation.valid) {
|
||||
setNotification(validation.error);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const newFiles = await uploadFiles(uploadedFiles, storageConfig.useIndexedDB);
|
||||
|
||||
// Update files state
|
||||
setFiles((prevFiles) => (allowMultiple ? [...prevFiles, ...newFiles] : newFiles));
|
||||
|
||||
// Update storage stats incrementally
|
||||
if (storageStats) {
|
||||
const updatedStats = updateStorageStatsIncremental(storageStats, 'add', newFiles);
|
||||
setStorageStats(updatedStats);
|
||||
|
||||
// Check for storage warnings
|
||||
const warning = checkStorageWarnings(updatedStats);
|
||||
if (warning) {
|
||||
setNotification(warning);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error handling file drop:', error);
|
||||
setNotification(t("fileManager.uploadError", "Failed to upload some files."));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFile = async (index: number) => {
|
||||
const file = files[index];
|
||||
|
||||
try {
|
||||
await removeFile(file);
|
||||
|
||||
// Update storage stats incrementally
|
||||
if (storageStats) {
|
||||
const updatedStats = updateStorageStatsIncremental(storageStats, 'remove', [file]);
|
||||
setStorageStats(updatedStats);
|
||||
}
|
||||
|
||||
setFiles((prevFiles) => prevFiles.filter((_, i) => i !== index));
|
||||
} catch (error) {
|
||||
console.error('Failed to remove file:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearAll = async () => {
|
||||
try {
|
||||
await clearAllFiles(files);
|
||||
|
||||
// Reset storage stats
|
||||
if (storageStats) {
|
||||
const clearedStats = updateStorageStatsIncremental(storageStats, 'clear');
|
||||
setStorageStats(clearedStats);
|
||||
}
|
||||
|
||||
setFiles([]);
|
||||
} catch (error) {
|
||||
console.error('Failed to clear all files:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReloadFiles = () => {
|
||||
setFilesLoaded(false);
|
||||
setFiles([]);
|
||||
};
|
||||
|
||||
const handleFileDoubleClick = async (file: FileWithUrl) => {
|
||||
try {
|
||||
// Reconstruct File object from storage and add to active files
|
||||
if (onLoadFileToActive) {
|
||||
const reconstructedFile = await reconstructFileFromStorage(file);
|
||||
onLoadFileToActive(reconstructedFile);
|
||||
setCurrentView && setCurrentView("viewer");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load file to active set:', error);
|
||||
setNotification(t("fileManager.failedToOpen", "Failed to open file. It may have been removed from storage."));
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileView = async (file: FileWithUrl) => {
|
||||
try {
|
||||
// Reconstruct File object from storage and add to active files
|
||||
if (onLoadFileToActive) {
|
||||
const reconstructedFile = await reconstructFileFromStorage(file);
|
||||
onLoadFileToActive(reconstructedFile);
|
||||
setCurrentView && setCurrentView("viewer");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load file to active set:', error);
|
||||
setNotification(t("fileManager.failedToOpen", "Failed to open file. It may have been removed from storage."));
|
||||
}
|
||||
};
|
||||
|
||||
const reconstructFileFromStorage = async (fileWithUrl: FileWithUrl): Promise<File> => {
|
||||
// If it's already a regular file, return it
|
||||
if (fileWithUrl instanceof File) {
|
||||
return fileWithUrl;
|
||||
}
|
||||
|
||||
// Reconstruct from IndexedDB
|
||||
const arrayBuffer = await createBlobUrlForFile(fileWithUrl);
|
||||
if (typeof arrayBuffer === 'string') {
|
||||
// createBlobUrlForFile returned a blob URL, we need the actual data
|
||||
const response = await fetch(arrayBuffer);
|
||||
const data = await response.arrayBuffer();
|
||||
return new File([data], fileWithUrl.name, {
|
||||
type: fileWithUrl.type || 'application/pdf',
|
||||
lastModified: fileWithUrl.lastModified || Date.now()
|
||||
});
|
||||
} else {
|
||||
return new File([arrayBuffer], fileWithUrl.name, {
|
||||
type: fileWithUrl.type || 'application/pdf',
|
||||
lastModified: fileWithUrl.lastModified || Date.now()
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileEdit = (file: FileWithUrl) => {
|
||||
if (onOpenFileEditor) {
|
||||
onOpenFileEditor([file]);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFileSelection = (fileId: string) => {
|
||||
setSelectedFiles(prev =>
|
||||
prev.includes(fileId)
|
||||
? prev.filter(id => id !== fileId)
|
||||
: [...prev, fileId]
|
||||
);
|
||||
};
|
||||
|
||||
const handleOpenSelectedInEditor = () => {
|
||||
if (onOpenFileEditor && selectedFiles.length > 0) {
|
||||
const selected = files.filter(f => selectedFiles.includes(f.id || f.name));
|
||||
onOpenFileEditor(selected);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenSelectedInPageEditor = () => {
|
||||
if (onOpenPageEditor && selectedFiles.length > 0) {
|
||||
const selected = files.filter(f => selectedFiles.includes(f.id || f.name));
|
||||
onOpenPageEditor(selected);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: "100%",
|
||||
justifyContent: "center",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
paddingTop: "3rem"
|
||||
}}>
|
||||
|
||||
{/* File upload is now handled by FileUploadSelector when no files exist */}
|
||||
|
||||
{/* Storage Stats Card */}
|
||||
<StorageStatsCard
|
||||
storageStats={storageStats}
|
||||
filesCount={files.length}
|
||||
onClearAll={handleClearAll}
|
||||
onReloadFiles={handleReloadFiles}
|
||||
storageConfig={storageConfig}
|
||||
/>
|
||||
|
||||
{/* Multi-selection controls */}
|
||||
{selectedFiles.length > 0 && (
|
||||
<Box mb="md" p="md" style={{ backgroundColor: 'var(--mantine-color-blue-0)', borderRadius: 8 }}>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm">
|
||||
{selectedFiles.length} {t("fileManager.filesSelected", "files selected")}
|
||||
</Text>
|
||||
<Group>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={() => setSelectedFiles([])}
|
||||
>
|
||||
{t("fileManager.clearSelection", "Clear Selection")}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
color="orange"
|
||||
onClick={handleOpenSelectedInEditor}
|
||||
disabled={selectedFiles.length === 0}
|
||||
>
|
||||
{t("fileManager.openInFileEditor", "Open in File Editor")}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
color="blue"
|
||||
onClick={handleOpenSelectedInPageEditor}
|
||||
disabled={selectedFiles.length === 0}
|
||||
>
|
||||
{t("fileManager.openInPageEditor", "Open in Page Editor")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
||||
<Flex
|
||||
wrap="wrap"
|
||||
gap="lg"
|
||||
justify="flex-start"
|
||||
style={{ width: "90%", marginTop: "1rem"}}
|
||||
>
|
||||
{files.map((file, idx) => (
|
||||
<FileCard
|
||||
key={file.id || file.name + idx}
|
||||
file={file}
|
||||
onRemove={() => handleRemoveFile(idx)}
|
||||
onDoubleClick={() => handleFileDoubleClick(file)}
|
||||
onView={() => handleFileView(file)}
|
||||
onEdit={() => handleFileEdit(file)}
|
||||
isSelected={selectedFiles.includes(file.id || file.name)}
|
||||
onSelect={() => toggleFileSelection(file.id || file.name)}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
|
||||
|
||||
{/* Notifications */}
|
||||
{notification && (
|
||||
<Notification
|
||||
color="blue"
|
||||
onClose={() => setNotification(null)}
|
||||
style={{ position: "fixed", bottom: 20, right: 20, zIndex: 1000 }}
|
||||
>
|
||||
{notification}
|
||||
</Notification>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileManager;
|
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;
|
@ -4,7 +4,7 @@ import { Paper, Group, TextInput, Button, Text } from '@mantine/core';
|
||||
interface BulkSelectionPanelProps {
|
||||
csvInput: string;
|
||||
setCsvInput: (value: string) => void;
|
||||
selectedPages: string[];
|
||||
selectedPages: number[];
|
||||
onUpdatePagesFromCSV: () => void;
|
||||
}
|
||||
|
||||
|
@ -9,21 +9,21 @@ interface DragDropItem {
|
||||
|
||||
interface DragDropGridProps<T extends DragDropItem> {
|
||||
items: T[];
|
||||
selectedItems: string[];
|
||||
selectedItems: number[];
|
||||
selectionMode: boolean;
|
||||
isAnimating: boolean;
|
||||
onDragStart: (itemId: string) => void;
|
||||
onDragStart: (pageNumber: number) => void;
|
||||
onDragEnd: () => void;
|
||||
onDragOver: (e: React.DragEvent) => void;
|
||||
onDragEnter: (itemId: string) => void;
|
||||
onDragEnter: (pageNumber: number) => void;
|
||||
onDragLeave: () => void;
|
||||
onDrop: (e: React.DragEvent, targetId: string | 'end') => void;
|
||||
onDrop: (e: React.DragEvent, targetPageNumber: number | 'end') => void;
|
||||
onEndZoneDragEnter: () => void;
|
||||
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>) => React.ReactNode;
|
||||
renderSplitMarker?: (item: T, index: number) => React.ReactNode;
|
||||
draggedItem: string | null;
|
||||
dropTarget: string | null;
|
||||
multiItemDrag: {itemIds: string[], count: number} | null;
|
||||
draggedItem: number | null;
|
||||
dropTarget: number | null;
|
||||
multiItemDrag: {pageNumbers: number[], count: number} | null;
|
||||
dragPosition: {x: number, y: number} | null;
|
||||
}
|
||||
|
||||
@ -77,7 +77,13 @@ const DragDropGrid = <T extends DragDropItem>({
|
||||
flexWrap: 'wrap',
|
||||
gap: '1.5rem',
|
||||
justifyContent: 'flex-start',
|
||||
paddingBottom: '100px'
|
||||
paddingBottom: '100px',
|
||||
// Performance optimizations for smooth scrolling
|
||||
willChange: 'scroll-position',
|
||||
transform: 'translateZ(0)', // Force hardware acceleration
|
||||
backfaceVisibility: 'hidden',
|
||||
// Use containment for better rendering performance
|
||||
contain: 'layout style paint',
|
||||
}}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
|
@ -1,561 +0,0 @@
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import {
|
||||
Button, Text, Center, Box, Notification, TextInput, LoadingOverlay, Modal, Alert, Container,
|
||||
Stack, Group
|
||||
} from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||
import { fileStorage } from '../../services/fileStorage';
|
||||
import { generateThumbnailForFile } from '../../utils/thumbnailUtils';
|
||||
import styles from './PageEditor.module.css';
|
||||
import FileThumbnail from './FileThumbnail';
|
||||
import BulkSelectionPanel from './BulkSelectionPanel';
|
||||
import DragDropGrid from './DragDropGrid';
|
||||
import FilePickerModal from '../shared/FilePickerModal';
|
||||
|
||||
interface FileItem {
|
||||
id: string;
|
||||
name: string;
|
||||
pageCount: number;
|
||||
thumbnail: string;
|
||||
size: number;
|
||||
file: File;
|
||||
splitBefore?: boolean;
|
||||
}
|
||||
|
||||
interface FileEditorProps {
|
||||
onOpenPageEditor?: (file: File) => void;
|
||||
onMergeFiles?: (files: File[]) => void;
|
||||
activeFiles?: File[];
|
||||
setActiveFiles?: (files: File[]) => void;
|
||||
preSelectedFiles?: { file: File; url: string }[];
|
||||
onClearPreSelection?: () => void;
|
||||
}
|
||||
|
||||
const FileEditor = ({
|
||||
onOpenPageEditor,
|
||||
onMergeFiles,
|
||||
activeFiles = [],
|
||||
setActiveFiles,
|
||||
preSelectedFiles = [],
|
||||
onClearPreSelection
|
||||
}: FileEditorProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [files, setFiles] = useState<FileItem[]>([]);
|
||||
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [csvInput, setCsvInput] = useState<string>('');
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [draggedFile, setDraggedFile] = useState<string | null>(null);
|
||||
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
||||
const [multiFileDrag, setMultiFileDrag] = useState<{fileIds: string[], count: number} | null>(null);
|
||||
const [dragPosition, setDragPosition] = useState<{x: number, y: number} | null>(null);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const [showFilePickerModal, setShowFilePickerModal] = useState(false);
|
||||
const fileRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
|
||||
// Convert shared files to FileEditor format
|
||||
const convertToFileItem = useCallback(async (sharedFile: any): Promise<FileItem> => {
|
||||
// Generate thumbnail if not already available
|
||||
const thumbnail = sharedFile.thumbnail || await generateThumbnailForFile(sharedFile.file || sharedFile);
|
||||
|
||||
return {
|
||||
id: sharedFile.id || `file-${Date.now()}-${Math.random()}`,
|
||||
name: (sharedFile.file?.name || sharedFile.name || 'unknown').replace(/\.pdf$/i, ''),
|
||||
pageCount: sharedFile.pageCount || Math.floor(Math.random() * 20) + 1, // Mock for now
|
||||
thumbnail,
|
||||
size: sharedFile.file?.size || sharedFile.size || 0,
|
||||
file: sharedFile.file || sharedFile,
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Convert activeFiles to FileItem format
|
||||
useEffect(() => {
|
||||
const convertActiveFiles = async () => {
|
||||
if (activeFiles.length > 0) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const convertedFiles = await Promise.all(
|
||||
activeFiles.map(async (file) => {
|
||||
const thumbnail = await generateThumbnailForFile(file);
|
||||
return {
|
||||
id: `file-${Date.now()}-${Math.random()}`,
|
||||
name: file.name.replace(/\.pdf$/i, ''),
|
||||
pageCount: Math.floor(Math.random() * 20) + 1, // Mock for now
|
||||
thumbnail,
|
||||
size: file.size,
|
||||
file,
|
||||
};
|
||||
})
|
||||
);
|
||||
setFiles(convertedFiles);
|
||||
} catch (err) {
|
||||
console.error('Error converting active files:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} else {
|
||||
setFiles([]);
|
||||
}
|
||||
};
|
||||
|
||||
convertActiveFiles();
|
||||
}, [activeFiles]);
|
||||
|
||||
// Only load shared files when explicitly passed (not on mount)
|
||||
useEffect(() => {
|
||||
const loadSharedFiles = async () => {
|
||||
// Only load if we have pre-selected files (coming from FileManager)
|
||||
if (preSelectedFiles.length > 0) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const convertedFiles = await Promise.all(
|
||||
preSelectedFiles.map(convertToFileItem)
|
||||
);
|
||||
if (setActiveFiles) {
|
||||
const updatedActiveFiles = convertedFiles.map(fileItem => fileItem.file);
|
||||
setActiveFiles(updatedActiveFiles);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error converting pre-selected files:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadSharedFiles();
|
||||
}, [preSelectedFiles, convertToFileItem]);
|
||||
|
||||
// Handle pre-selected files
|
||||
useEffect(() => {
|
||||
if (preSelectedFiles.length > 0) {
|
||||
const preSelectedIds = preSelectedFiles.map(f => f.id || f.name);
|
||||
setSelectedFiles(preSelectedIds);
|
||||
onClearPreSelection?.();
|
||||
}
|
||||
}, [preSelectedFiles, onClearPreSelection]);
|
||||
|
||||
// Process uploaded files
|
||||
const handleFileUpload = useCallback(async (uploadedFiles: File[]) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const newFiles: FileItem[] = [];
|
||||
|
||||
for (const file of uploadedFiles) {
|
||||
if (file.type !== 'application/pdf') {
|
||||
setError('Please upload only PDF files');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Generate thumbnail and get page count
|
||||
const thumbnail = await generateThumbnailForFile(file);
|
||||
|
||||
const fileItem: FileItem = {
|
||||
id: `file-${Date.now()}-${Math.random()}`,
|
||||
name: file.name.replace(/\.pdf$/i, ''),
|
||||
pageCount: Math.floor(Math.random() * 20) + 1, // Mock page count
|
||||
thumbnail,
|
||||
size: file.size,
|
||||
file,
|
||||
};
|
||||
|
||||
newFiles.push(fileItem);
|
||||
|
||||
// Store in IndexedDB
|
||||
await fileStorage.storeFile(file, thumbnail);
|
||||
}
|
||||
|
||||
if (setActiveFiles) {
|
||||
setActiveFiles(prev => [...prev, ...newFiles.map(f => f.file)]);
|
||||
}
|
||||
|
||||
setStatus(`Added ${newFiles.length} files`);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to process files';
|
||||
setError(errorMessage);
|
||||
console.error('File processing error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [setActiveFiles]);
|
||||
|
||||
const selectAll = useCallback(() => {
|
||||
setSelectedFiles(files.map(f => f.id));
|
||||
}, [files]);
|
||||
|
||||
const deselectAll = useCallback(() => setSelectedFiles([]), []);
|
||||
|
||||
const toggleFile = useCallback((fileId: string) => {
|
||||
setSelectedFiles(prev =>
|
||||
prev.includes(fileId)
|
||||
? prev.filter(id => id !== fileId)
|
||||
: [...prev, fileId]
|
||||
);
|
||||
}, []);
|
||||
|
||||
const toggleSelectionMode = useCallback(() => {
|
||||
setSelectionMode(prev => {
|
||||
const newMode = !prev;
|
||||
if (!newMode) {
|
||||
setSelectedFiles([]);
|
||||
setCsvInput('');
|
||||
}
|
||||
return newMode;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const parseCSVInput = useCallback((csv: string) => {
|
||||
const fileIds: string[] = [];
|
||||
const ranges = csv.split(',').map(s => s.trim()).filter(Boolean);
|
||||
|
||||
ranges.forEach(range => {
|
||||
if (range.includes('-')) {
|
||||
const [start, end] = range.split('-').map(n => parseInt(n.trim()));
|
||||
for (let i = start; i <= end && i <= files.length; i++) {
|
||||
if (i > 0) {
|
||||
const file = files[i - 1];
|
||||
if (file) fileIds.push(file.id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const fileIndex = parseInt(range);
|
||||
if (fileIndex > 0 && fileIndex <= files.length) {
|
||||
const file = files[fileIndex - 1];
|
||||
if (file) fileIds.push(file.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return fileIds;
|
||||
}, [files]);
|
||||
|
||||
const updateFilesFromCSV = useCallback(() => {
|
||||
const fileIds = parseCSVInput(csvInput);
|
||||
setSelectedFiles(fileIds);
|
||||
}, [csvInput, parseCSVInput]);
|
||||
|
||||
// Drag and drop handlers
|
||||
const handleDragStart = useCallback((fileId: string) => {
|
||||
setDraggedFile(fileId);
|
||||
|
||||
if (selectionMode && selectedFiles.includes(fileId) && selectedFiles.length > 1) {
|
||||
setMultiFileDrag({
|
||||
fileIds: selectedFiles,
|
||||
count: selectedFiles.length
|
||||
});
|
||||
} else {
|
||||
setMultiFileDrag(null);
|
||||
}
|
||||
}, [selectionMode, selectedFiles]);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDraggedFile(null);
|
||||
setDropTarget(null);
|
||||
setMultiFileDrag(null);
|
||||
setDragPosition(null);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!draggedFile) return;
|
||||
|
||||
if (multiFileDrag) {
|
||||
setDragPosition({ x: e.clientX, y: e.clientY });
|
||||
}
|
||||
|
||||
const elementUnderCursor = document.elementFromPoint(e.clientX, e.clientY);
|
||||
if (!elementUnderCursor) return;
|
||||
|
||||
const fileContainer = elementUnderCursor.closest('[data-file-id]');
|
||||
if (fileContainer) {
|
||||
const fileId = fileContainer.getAttribute('data-file-id');
|
||||
if (fileId && fileId !== draggedFile) {
|
||||
setDropTarget(fileId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const endZone = elementUnderCursor.closest('[data-drop-zone="end"]');
|
||||
if (endZone) {
|
||||
setDropTarget('end');
|
||||
return;
|
||||
}
|
||||
|
||||
setDropTarget(null);
|
||||
}, [draggedFile, multiFileDrag]);
|
||||
|
||||
const handleDragEnter = useCallback((fileId: string) => {
|
||||
if (draggedFile && fileId !== draggedFile) {
|
||||
setDropTarget(fileId);
|
||||
}
|
||||
}, [draggedFile]);
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
// Let dragover handle this
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent, targetFileId: string | 'end') => {
|
||||
e.preventDefault();
|
||||
if (!draggedFile || draggedFile === targetFileId) return;
|
||||
|
||||
let targetIndex: number;
|
||||
if (targetFileId === 'end') {
|
||||
targetIndex = files.length;
|
||||
} else {
|
||||
targetIndex = files.findIndex(f => f.id === targetFileId);
|
||||
if (targetIndex === -1) return;
|
||||
}
|
||||
|
||||
const filesToMove = selectionMode && selectedFiles.includes(draggedFile)
|
||||
? selectedFiles
|
||||
: [draggedFile];
|
||||
|
||||
if (setActiveFiles) {
|
||||
// Update the local files state and sync with activeFiles
|
||||
setFiles(prev => {
|
||||
const newFiles = [...prev];
|
||||
const movedFiles = filesToMove.map(id => newFiles.find(f => f.id === id)!).filter(Boolean);
|
||||
|
||||
// Remove moved files
|
||||
filesToMove.forEach(id => {
|
||||
const index = newFiles.findIndex(f => f.id === id);
|
||||
if (index !== -1) newFiles.splice(index, 1);
|
||||
});
|
||||
|
||||
// Insert at target position
|
||||
newFiles.splice(targetIndex, 0, ...movedFiles);
|
||||
|
||||
// Update activeFiles with the reordered File objects
|
||||
setActiveFiles(newFiles.map(f => f.file));
|
||||
|
||||
return newFiles;
|
||||
});
|
||||
}
|
||||
|
||||
const moveCount = multiFileDrag ? multiFileDrag.count : 1;
|
||||
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
|
||||
|
||||
handleDragEnd();
|
||||
}, [draggedFile, files, selectionMode, selectedFiles, multiFileDrag, handleDragEnd, setActiveFiles]);
|
||||
|
||||
const handleEndZoneDragEnter = useCallback(() => {
|
||||
if (draggedFile) {
|
||||
setDropTarget('end');
|
||||
}
|
||||
}, [draggedFile]);
|
||||
|
||||
// File operations
|
||||
const handleDeleteFile = useCallback((fileId: string) => {
|
||||
if (setActiveFiles) {
|
||||
// Remove from local files and sync with activeFiles
|
||||
setFiles(prev => {
|
||||
const newFiles = prev.filter(f => f.id !== fileId);
|
||||
setActiveFiles(newFiles.map(f => f.file));
|
||||
return newFiles;
|
||||
});
|
||||
}
|
||||
setSelectedFiles(prev => prev.filter(id => id !== fileId));
|
||||
}, [setActiveFiles]);
|
||||
|
||||
const handleViewFile = useCallback((fileId: string) => {
|
||||
const file = files.find(f => f.id === fileId);
|
||||
if (file && onOpenPageEditor) {
|
||||
onOpenPageEditor(file.file);
|
||||
}
|
||||
}, [files, onOpenPageEditor]);
|
||||
|
||||
const handleMergeFromHere = useCallback((fileId: string) => {
|
||||
const startIndex = files.findIndex(f => f.id === fileId);
|
||||
if (startIndex === -1) return;
|
||||
|
||||
const filesToMerge = files.slice(startIndex).map(f => f.file);
|
||||
if (onMergeFiles) {
|
||||
onMergeFiles(filesToMerge);
|
||||
}
|
||||
}, [files, onMergeFiles]);
|
||||
|
||||
const handleSplitFile = useCallback((fileId: string) => {
|
||||
const file = files.find(f => f.id === fileId);
|
||||
if (file && onOpenPageEditor) {
|
||||
onOpenPageEditor(file.file);
|
||||
}
|
||||
}, [files, onOpenPageEditor]);
|
||||
|
||||
const handleLoadFromStorage = useCallback(async (selectedFiles: any[]) => {
|
||||
if (selectedFiles.length === 0) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const convertedFiles = await Promise.all(
|
||||
selectedFiles.map(convertToFileItem)
|
||||
);
|
||||
setFiles(prev => [...prev, ...convertedFiles]);
|
||||
setStatus(`Loaded ${selectedFiles.length} files from storage`);
|
||||
} catch (err) {
|
||||
console.error('Error loading files from storage:', err);
|
||||
setError('Failed to load some files from storage');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [convertToFileItem]);
|
||||
|
||||
|
||||
return (
|
||||
<Box pos="relative" h="100vh" style={{ overflow: 'auto' }}>
|
||||
<LoadingOverlay visible={loading} />
|
||||
|
||||
<Box p="md" pt="xl">
|
||||
<Group mb="md">
|
||||
<Button
|
||||
onClick={toggleSelectionMode}
|
||||
variant={selectionMode ? "filled" : "outline"}
|
||||
color={selectionMode ? "blue" : "gray"}
|
||||
styles={{
|
||||
root: {
|
||||
transition: 'all 0.2s ease',
|
||||
...(selectionMode && {
|
||||
boxShadow: '0 2px 8px rgba(59, 130, 246, 0.3)',
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
{selectionMode ? "Exit Selection" : "Select Files"}
|
||||
</Button>
|
||||
{selectionMode && (
|
||||
<>
|
||||
<Button onClick={selectAll} variant="light">Select All</Button>
|
||||
<Button onClick={deselectAll} variant="light">Deselect All</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Load from storage and upload buttons */}
|
||||
<Button
|
||||
variant="outline"
|
||||
color="blue"
|
||||
onClick={() => setShowFilePickerModal(true)}
|
||||
>
|
||||
Load from Storage
|
||||
</Button>
|
||||
|
||||
<Dropzone
|
||||
onDrop={handleFileUpload}
|
||||
accept={["application/pdf"]}
|
||||
multiple={true}
|
||||
maxSize={2 * 1024 * 1024 * 1024}
|
||||
style={{ display: 'contents' }}
|
||||
>
|
||||
<Button variant="outline" color="green">
|
||||
Upload Files
|
||||
</Button>
|
||||
</Dropzone>
|
||||
</Group>
|
||||
|
||||
{selectionMode && (
|
||||
<BulkSelectionPanel
|
||||
csvInput={csvInput}
|
||||
setCsvInput={setCsvInput}
|
||||
selectedPages={selectedFiles}
|
||||
onUpdatePagesFromCSV={updateFilesFromCSV}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DragDropGrid
|
||||
items={files}
|
||||
selectedItems={selectedFiles}
|
||||
selectionMode={selectionMode}
|
||||
isAnimating={isAnimating}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onEndZoneDragEnter={handleEndZoneDragEnter}
|
||||
draggedItem={draggedFile}
|
||||
dropTarget={dropTarget}
|
||||
multiItemDrag={multiFileDrag}
|
||||
dragPosition={dragPosition}
|
||||
renderItem={(file, index, refs) => (
|
||||
<FileThumbnail
|
||||
file={file}
|
||||
index={index}
|
||||
totalFiles={files.length}
|
||||
selectedFiles={selectedFiles}
|
||||
selectionMode={selectionMode}
|
||||
draggedFile={draggedFile}
|
||||
dropTarget={dropTarget}
|
||||
isAnimating={isAnimating}
|
||||
fileRefs={refs}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onToggleFile={toggleFile}
|
||||
onDeleteFile={handleDeleteFile}
|
||||
onViewFile={handleViewFile}
|
||||
onMergeFromHere={handleMergeFromHere}
|
||||
onSplitFile={handleSplitFile}
|
||||
onSetStatus={setStatus}
|
||||
/>
|
||||
)}
|
||||
renderSplitMarker={(file, index) => (
|
||||
<div
|
||||
style={{
|
||||
width: '2px',
|
||||
height: '24rem',
|
||||
borderLeft: '2px dashed #3b82f6',
|
||||
backgroundColor: 'transparent',
|
||||
marginLeft: '-0.75rem',
|
||||
marginRight: '-0.75rem',
|
||||
flexShrink: 0
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* File Picker Modal */}
|
||||
<FilePickerModal
|
||||
opened={showFilePickerModal}
|
||||
onClose={() => setShowFilePickerModal(false)}
|
||||
storedFiles={[]} // FileEditor doesn't have access to stored files, needs to be passed from parent
|
||||
onSelectFiles={handleLoadFromStorage}
|
||||
allowMultiple={true}
|
||||
/>
|
||||
|
||||
{status && (
|
||||
<Notification
|
||||
color="blue"
|
||||
mt="md"
|
||||
onClose={() => setStatus(null)}
|
||||
style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }}
|
||||
>
|
||||
{status}
|
||||
</Notification>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Notification
|
||||
color="red"
|
||||
mt="md"
|
||||
onClose={() => setError(null)}
|
||||
style={{ position: 'fixed', bottom: 80, right: 20, zIndex: 1000 }}
|
||||
>
|
||||
{error}
|
||||
</Notification>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileEditor;
|
@ -1,11 +1,11 @@
|
||||
import React from 'react';
|
||||
import { Text, Checkbox, Tooltip, ActionIcon, Badge } from '@mantine/core';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import React, { useState } from 'react';
|
||||
import { Text, Checkbox, Tooltip, ActionIcon, Badge, Modal } from '@mantine/core';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import MergeIcon from '@mui/icons-material/Merge';
|
||||
import SplitscreenIcon from '@mui/icons-material/Splitscreen';
|
||||
import HistoryIcon from '@mui/icons-material/History';
|
||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||
import styles from './PageEditor.module.css';
|
||||
import FileOperationHistory from '../history/FileOperationHistory';
|
||||
|
||||
interface FileItem {
|
||||
id: string;
|
||||
@ -35,9 +35,8 @@ interface FileThumbnailProps {
|
||||
onToggleFile: (fileId: string) => void;
|
||||
onDeleteFile: (fileId: string) => void;
|
||||
onViewFile: (fileId: string) => void;
|
||||
onMergeFromHere: (fileId: string) => void;
|
||||
onSplitFile: (fileId: string) => void;
|
||||
onSetStatus: (status: string) => void;
|
||||
toolMode?: boolean;
|
||||
}
|
||||
|
||||
const FileThumbnail = ({
|
||||
@ -59,10 +58,11 @@ const FileThumbnail = ({
|
||||
onToggleFile,
|
||||
onDeleteFile,
|
||||
onViewFile,
|
||||
onMergeFromHere,
|
||||
onSplitFile,
|
||||
onSetStatus,
|
||||
toolMode = false,
|
||||
}: FileThumbnailProps) => {
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
@ -238,63 +238,53 @@ const FileThumbnail = ({
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
<Tooltip label="View File">
|
||||
{!toolMode && (
|
||||
<>
|
||||
<Tooltip label="View File">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c="white"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewFile(file.id);
|
||||
onSetStatus(`Opened ${file.name}`);
|
||||
}}
|
||||
>
|
||||
<VisibilityIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
</>
|
||||
)}
|
||||
|
||||
<Tooltip label="View History">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c="white"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewFile(file.id);
|
||||
onSetStatus(`Opened ${file.name}`);
|
||||
setShowHistory(true);
|
||||
onSetStatus(`Viewing history for ${file.name}`);
|
||||
}}
|
||||
>
|
||||
<VisibilityIcon style={{ fontSize: 20 }} />
|
||||
<HistoryIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Merge from here">
|
||||
<Tooltip label="Close File">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c="white"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMergeFromHere(file.id);
|
||||
onSetStatus(`Starting merge from ${file.name}`);
|
||||
}}
|
||||
>
|
||||
<MergeIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Split File">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c="white"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSplitFile(file.id);
|
||||
onSetStatus(`Opening ${file.name} in page editor`);
|
||||
}}
|
||||
>
|
||||
<SplitscreenIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Delete File">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c="red"
|
||||
c="orange"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteFile(file.id);
|
||||
onSetStatus(`Deleted ${file.name}`);
|
||||
onSetStatus(`Closed ${file.name}`);
|
||||
}}
|
||||
>
|
||||
<DeleteIcon style={{ fontSize: 20 }} />
|
||||
<CloseIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@ -320,6 +310,21 @@ const FileThumbnail = ({
|
||||
{formatFileSize(file.size)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* History Modal */}
|
||||
<Modal
|
||||
opened={showHistory}
|
||||
onClose={() => setShowHistory(false)}
|
||||
title={`Operation History - ${file.name}`}
|
||||
size="lg"
|
||||
scrollAreaComponent="div"
|
||||
>
|
||||
<FileOperationHistory
|
||||
fileId={file.name}
|
||||
showOnlyApplied={true}
|
||||
maxHeight={500}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -1,10 +1,14 @@
|
||||
/* Page container hover effects */
|
||||
/* Page container hover effects - optimized for smooth scrolling */
|
||||
.pageContainer {
|
||||
transition: transform 0.2s ease-in-out;
|
||||
/* Enable hardware acceleration for smoother scrolling */
|
||||
will-change: transform;
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.pageContainer:hover {
|
||||
transform: scale(1.02);
|
||||
transform: scale(1.02) translateZ(0);
|
||||
}
|
||||
|
||||
.pageContainer:hover .pageNumber {
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -56,7 +56,7 @@ const PageEditorControls = ({
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
bottom: '20px',
|
||||
transform: 'translateX(-50%)',
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { Text, Checkbox, Tooltip, ActionIcon } from '@mantine/core';
|
||||
import React, { useCallback, useState, useEffect, useRef } from 'react';
|
||||
import { Text, Checkbox, Tooltip, ActionIcon, Loader } from '@mantine/core';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||
import RotateLeftIcon from '@mui/icons-material/RotateLeft';
|
||||
@ -7,42 +7,55 @@ import RotateRightIcon from '@mui/icons-material/RotateRight';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import ContentCutIcon from '@mui/icons-material/ContentCut';
|
||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||
import { PDFPage } from '../../../types/pageEditor';
|
||||
import { PDFPage, PDFDocument } from '../../../types/pageEditor';
|
||||
import { RotatePagesCommand, DeletePagesCommand, ToggleSplitCommand } from '../../../commands/pageCommands';
|
||||
import { Command } from '../../../hooks/useUndoRedo';
|
||||
import styles from './PageEditor.module.css';
|
||||
import { getDocument, GlobalWorkerOptions } from 'pdfjs-dist';
|
||||
|
||||
// Ensure PDF.js worker is available
|
||||
if (!GlobalWorkerOptions.workerSrc) {
|
||||
GlobalWorkerOptions.workerSrc = '/pdf.worker.js';
|
||||
console.log('📸 PageThumbnail: Set PDF.js worker source to /pdf.worker.js');
|
||||
} else {
|
||||
console.log('📸 PageThumbnail: PDF.js worker source already set to', GlobalWorkerOptions.workerSrc);
|
||||
}
|
||||
|
||||
interface PageThumbnailProps {
|
||||
page: PDFPage;
|
||||
index: number;
|
||||
totalPages: number;
|
||||
selectedPages: string[];
|
||||
originalFile?: File; // For lazy thumbnail generation
|
||||
selectedPages: number[];
|
||||
selectionMode: boolean;
|
||||
draggedPage: string | null;
|
||||
dropTarget: string | null;
|
||||
movingPage: string | null;
|
||||
draggedPage: number | null;
|
||||
dropTarget: number | null;
|
||||
movingPage: number | null;
|
||||
isAnimating: boolean;
|
||||
pageRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
|
||||
onDragStart: (pageId: string) => void;
|
||||
onDragStart: (pageNumber: number) => void;
|
||||
onDragEnd: () => void;
|
||||
onDragOver: (e: React.DragEvent) => void;
|
||||
onDragEnter: (pageId: string) => void;
|
||||
onDragEnter: (pageNumber: number) => void;
|
||||
onDragLeave: () => void;
|
||||
onDrop: (e: React.DragEvent, pageId: string) => void;
|
||||
onTogglePage: (pageId: string) => void;
|
||||
onAnimateReorder: (pageId: string, targetIndex: number) => void;
|
||||
onExecuteCommand: (command: any) => void;
|
||||
onDrop: (e: React.DragEvent, pageNumber: number) => void;
|
||||
onTogglePage: (pageNumber: number) => void;
|
||||
onAnimateReorder: (pageNumber: number, targetIndex: number) => void;
|
||||
onExecuteCommand: (command: Command) => void;
|
||||
onSetStatus: (status: string) => void;
|
||||
onSetMovingPage: (pageId: string | null) => void;
|
||||
RotatePagesCommand: any;
|
||||
DeletePagesCommand: any;
|
||||
ToggleSplitCommand: any;
|
||||
pdfDocument: any;
|
||||
setPdfDocument: any;
|
||||
onSetMovingPage: (pageNumber: number | null) => void;
|
||||
RotatePagesCommand: typeof RotatePagesCommand;
|
||||
DeletePagesCommand: typeof DeletePagesCommand;
|
||||
ToggleSplitCommand: typeof ToggleSplitCommand;
|
||||
pdfDocument: PDFDocument;
|
||||
setPdfDocument: (doc: PDFDocument) => void;
|
||||
}
|
||||
|
||||
const PageThumbnail = ({
|
||||
const PageThumbnail = React.memo(({
|
||||
page,
|
||||
index,
|
||||
totalPages,
|
||||
originalFile,
|
||||
selectedPages,
|
||||
selectionMode,
|
||||
draggedPage,
|
||||
@ -67,6 +80,44 @@ const PageThumbnail = ({
|
||||
pdfDocument,
|
||||
setPdfDocument,
|
||||
}: PageThumbnailProps) => {
|
||||
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
|
||||
const [isLoadingThumbnail, setIsLoadingThumbnail] = useState(false);
|
||||
|
||||
// Update thumbnail URL when page prop changes
|
||||
useEffect(() => {
|
||||
if (page.thumbnail && page.thumbnail !== thumbnailUrl) {
|
||||
console.log(`📸 PageThumbnail: Updating thumbnail URL for page ${page.pageNumber}`, page.thumbnail.substring(0, 50) + '...');
|
||||
setThumbnailUrl(page.thumbnail);
|
||||
}
|
||||
}, [page.thumbnail, page.pageNumber, page.id, thumbnailUrl]);
|
||||
|
||||
// Listen for ready thumbnails from Web Workers (only if no existing thumbnail)
|
||||
useEffect(() => {
|
||||
if (thumbnailUrl) {
|
||||
console.log(`📸 PageThumbnail: Page ${page.pageNumber} already has thumbnail, skipping worker listener`);
|
||||
return; // Skip if we already have a thumbnail
|
||||
}
|
||||
|
||||
console.log(`📸 PageThumbnail: Setting up worker listener for page ${page.pageNumber} (${page.id})`);
|
||||
|
||||
const handleThumbnailReady = (event: CustomEvent) => {
|
||||
const { pageNumber, thumbnail, pageId } = event.detail;
|
||||
console.log(`📸 PageThumbnail: Received worker thumbnail for page ${pageNumber}, looking for page ${page.pageNumber} (${page.id})`);
|
||||
|
||||
if (pageNumber === page.pageNumber && pageId === page.id) {
|
||||
console.log(`✓ PageThumbnail: Thumbnail matched for page ${page.pageNumber}, setting URL`);
|
||||
setThumbnailUrl(thumbnail);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('thumbnailReady', handleThumbnailReady as EventListener);
|
||||
return () => {
|
||||
console.log(`📸 PageThumbnail: Cleaning up worker listener for page ${page.pageNumber}`);
|
||||
window.removeEventListener('thumbnailReady', handleThumbnailReady as EventListener);
|
||||
};
|
||||
}, [page.pageNumber, page.id, thumbnailUrl]);
|
||||
|
||||
|
||||
// Register this component with pageRefs for animations
|
||||
const pageElementRef = useCallback((element: HTMLDivElement | null) => {
|
||||
if (element) {
|
||||
@ -79,7 +130,7 @@ const PageThumbnail = ({
|
||||
return (
|
||||
<div
|
||||
ref={pageElementRef}
|
||||
data-page-id={page.id}
|
||||
data-page-number={page.pageNumber}
|
||||
className={`
|
||||
${styles.pageContainer}
|
||||
!rounded-lg
|
||||
@ -96,12 +147,12 @@ const PageThumbnail = ({
|
||||
${selectionMode
|
||||
? 'bg-white hover:bg-gray-50'
|
||||
: 'bg-white hover:bg-gray-50'}
|
||||
${draggedPage === page.id ? 'opacity-50 scale-95' : ''}
|
||||
${movingPage === page.id ? 'page-moving' : ''}
|
||||
${draggedPage === page.pageNumber ? 'opacity-50 scale-95' : ''}
|
||||
${movingPage === page.pageNumber ? 'page-moving' : ''}
|
||||
`}
|
||||
style={{
|
||||
transform: (() => {
|
||||
if (!isAnimating && draggedPage && page.id !== draggedPage && dropTarget === page.id) {
|
||||
if (!isAnimating && draggedPage && page.pageNumber !== draggedPage && dropTarget === page.pageNumber) {
|
||||
return 'translateX(20px)';
|
||||
}
|
||||
return 'translateX(0)';
|
||||
@ -109,12 +160,12 @@ const PageThumbnail = ({
|
||||
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out'
|
||||
}}
|
||||
draggable
|
||||
onDragStart={() => onDragStart(page.id)}
|
||||
onDragStart={() => onDragStart(page.pageNumber)}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={onDragOver}
|
||||
onDragEnter={() => onDragEnter(page.id)}
|
||||
onDragEnter={() => onDragEnter(page.pageNumber)}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={(e) => onDrop(e, page.id)}
|
||||
onDrop={(e) => onDrop(e, page.pageNumber)}
|
||||
>
|
||||
{selectionMode && (
|
||||
<div
|
||||
@ -123,26 +174,31 @@ const PageThumbnail = ({
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
zIndex: 4,
|
||||
backgroundColor: 'white',
|
||||
zIndex: 10,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
padding: '2px',
|
||||
padding: '4px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
pointerEvents: 'auto'
|
||||
pointerEvents: 'auto',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onDragStart={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={(e) => {
|
||||
console.log('📸 Checkbox clicked for page', page.pageNumber);
|
||||
e.stopPropagation();
|
||||
onTogglePage(page.pageNumber);
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedPages.includes(page.id)}
|
||||
onChange={(event) => {
|
||||
event.stopPropagation();
|
||||
onTogglePage(page.id);
|
||||
checked={Array.isArray(selectedPages) ? selectedPages.includes(page.pageNumber) : false}
|
||||
onChange={() => {
|
||||
// onChange is handled by the parent div click
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
@ -162,18 +218,30 @@ const PageThumbnail = ({
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={page.thumbnail}
|
||||
alt={`Page ${page.pageNumber}`}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
borderRadius: 2,
|
||||
transform: `rotate(${page.rotation}deg)`,
|
||||
transition: 'transform 0.3s ease-in-out'
|
||||
}}
|
||||
/>
|
||||
{thumbnailUrl ? (
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt={`Page ${page.pageNumber}`}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
borderRadius: 2,
|
||||
transform: `rotate(${page.rotation}deg)`,
|
||||
transition: 'transform 0.3s ease-in-out'
|
||||
}}
|
||||
/>
|
||||
) : isLoadingThumbnail ? (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Loader size="sm" />
|
||||
<Text size="xs" c="dimmed" mt={4}>Loading...</Text>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Text size="lg" c="dimmed">📄</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>Page {page.pageNumber}</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Text
|
||||
@ -224,8 +292,8 @@ const PageThumbnail = ({
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (index > 0 && !movingPage && !isAnimating) {
|
||||
onSetMovingPage(page.id);
|
||||
onAnimateReorder(page.id, index - 1);
|
||||
onSetMovingPage(page.pageNumber);
|
||||
onAnimateReorder(page.pageNumber, index - 1);
|
||||
setTimeout(() => onSetMovingPage(null), 500);
|
||||
onSetStatus(`Moved page ${page.pageNumber} left`);
|
||||
}
|
||||
@ -244,8 +312,8 @@ const PageThumbnail = ({
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (index < totalPages - 1 && !movingPage && !isAnimating) {
|
||||
onSetMovingPage(page.id);
|
||||
onAnimateReorder(page.id, index + 1);
|
||||
onSetMovingPage(page.pageNumber);
|
||||
onAnimateReorder(page.pageNumber, index + 1);
|
||||
setTimeout(() => onSetMovingPage(null), 500);
|
||||
onSetStatus(`Moved page ${page.pageNumber} right`);
|
||||
}
|
||||
@ -353,6 +421,20 @@ const PageThumbnail = ({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}, (prevProps, nextProps) => {
|
||||
// Only re-render if essential props change
|
||||
return (
|
||||
prevProps.page.id === nextProps.page.id &&
|
||||
prevProps.page.pageNumber === nextProps.page.pageNumber &&
|
||||
prevProps.page.rotation === nextProps.page.rotation &&
|
||||
prevProps.page.thumbnail === nextProps.page.thumbnail &&
|
||||
prevProps.selectedPages === nextProps.selectedPages && // Compare array reference - will re-render when selection changes
|
||||
prevProps.selectionMode === nextProps.selectionMode &&
|
||||
prevProps.draggedPage === nextProps.draggedPage &&
|
||||
prevProps.dropTarget === nextProps.dropTarget &&
|
||||
prevProps.movingPage === nextProps.movingPage &&
|
||||
prevProps.isAnimating === nextProps.isAnimating
|
||||
);
|
||||
});
|
||||
|
||||
export default PageThumbnail;
|
||||
|
168
frontend/src/components/shared/FileGrid.tsx
Normal file
168
frontend/src/components/shared/FileGrid.tsx
Normal file
@ -0,0 +1,168 @@
|
||||
import React, { useState } from "react";
|
||||
import { Box, Flex, Group, Text, Button, TextInput, Select, Badge } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import SortIcon from "@mui/icons-material/Sort";
|
||||
import FileCard from "../fileManagement/FileCard";
|
||||
import { FileWithUrl } from "../../types/file";
|
||||
|
||||
interface FileGridProps {
|
||||
files: FileWithUrl[];
|
||||
onRemove?: (index: number) => void;
|
||||
onDoubleClick?: (file: FileWithUrl) => void;
|
||||
onView?: (file: FileWithUrl) => void;
|
||||
onEdit?: (file: FileWithUrl) => void;
|
||||
onSelect?: (fileId: string) => void;
|
||||
selectedFiles?: string[];
|
||||
showSearch?: boolean;
|
||||
showSort?: boolean;
|
||||
maxDisplay?: number; // If set, shows only this many files with "Show All" option
|
||||
onShowAll?: () => void;
|
||||
showingAll?: boolean;
|
||||
onDeleteAll?: () => void;
|
||||
}
|
||||
|
||||
type SortOption = 'date' | 'name' | 'size';
|
||||
|
||||
const FileGrid = ({
|
||||
files,
|
||||
onRemove,
|
||||
onDoubleClick,
|
||||
onView,
|
||||
onEdit,
|
||||
onSelect,
|
||||
selectedFiles = [],
|
||||
showSearch = false,
|
||||
showSort = false,
|
||||
maxDisplay,
|
||||
onShowAll,
|
||||
showingAll = false,
|
||||
onDeleteAll
|
||||
}: FileGridProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [sortBy, setSortBy] = useState<SortOption>('date');
|
||||
|
||||
// Filter files based on search term
|
||||
const filteredFiles = files.filter(file =>
|
||||
file.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
// Sort files
|
||||
const sortedFiles = [...filteredFiles].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'date':
|
||||
return (b.lastModified || 0) - (a.lastModified || 0);
|
||||
case 'name':
|
||||
return a.name.localeCompare(b.name);
|
||||
case 'size':
|
||||
return (b.size || 0) - (a.size || 0);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
// Apply max display limit if specified
|
||||
const displayFiles = maxDisplay && !showingAll
|
||||
? sortedFiles.slice(0, maxDisplay)
|
||||
: sortedFiles;
|
||||
|
||||
const hasMoreFiles = maxDisplay && !showingAll && sortedFiles.length > maxDisplay;
|
||||
|
||||
return (
|
||||
<Box >
|
||||
{/* Search and Sort Controls */}
|
||||
{(showSearch || showSort || onDeleteAll) && (
|
||||
<Group mb="md" justify="space-between" wrap="wrap" gap="sm">
|
||||
<Group gap="sm">
|
||||
{showSearch && (
|
||||
<TextInput
|
||||
placeholder={t("fileManager.searchFiles", "Search files...")}
|
||||
leftSection={<SearchIcon size={16} />}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.currentTarget.value)}
|
||||
style={{ flexGrow: 1, maxWidth: 300, minWidth: 200 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showSort && (
|
||||
<Select
|
||||
data={[
|
||||
{ value: 'date', label: t("fileManager.sortByDate", "Sort by Date") },
|
||||
{ value: 'name', label: t("fileManager.sortByName", "Sort by Name") },
|
||||
{ value: 'size', label: t("fileManager.sortBySize", "Sort by Size") }
|
||||
]}
|
||||
value={sortBy}
|
||||
onChange={(value) => setSortBy(value as SortOption)}
|
||||
leftSection={<SortIcon size={16} />}
|
||||
style={{ minWidth: 150 }}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{onDeleteAll && (
|
||||
<Button
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={onDeleteAll}
|
||||
>
|
||||
{t("fileManager.deleteAll", "Delete All")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{/* File Grid */}
|
||||
<Flex
|
||||
direction="row"
|
||||
wrap="wrap"
|
||||
gap="md"
|
||||
h="30rem"
|
||||
style={{ overflowY: "auto", width: "100%" }}
|
||||
>
|
||||
{displayFiles.map((file, idx) => {
|
||||
const fileId = file.id || file.name;
|
||||
const originalIdx = files.findIndex(f => (f.id || f.name) === fileId);
|
||||
return (
|
||||
<FileCard
|
||||
key={fileId + idx}
|
||||
file={file}
|
||||
onRemove={onRemove ? () => onRemove(originalIdx) : undefined}
|
||||
onDoubleClick={onDoubleClick ? () => onDoubleClick(file) : undefined}
|
||||
onView={onView ? () => onView(file) : undefined}
|
||||
onEdit={onEdit ? () => onEdit(file) : undefined}
|
||||
isSelected={selectedFiles.includes(fileId)}
|
||||
onSelect={onSelect ? () => onSelect(fileId) : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
|
||||
{/* Show All Button */}
|
||||
{hasMoreFiles && onShowAll && (
|
||||
<Group justify="center" mt="md">
|
||||
<Button
|
||||
variant="light"
|
||||
onClick={onShowAll}
|
||||
>
|
||||
{t("fileManager.showAll", "Show All")} ({sortedFiles.length} files)
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{displayFiles.length === 0 && (
|
||||
<Box style={{ textAlign: 'center', padding: '2rem' }}>
|
||||
<Text c="dimmed">
|
||||
{searchTerm
|
||||
? t("fileManager.noFilesFound", "No files found matching your search")
|
||||
: t("fileManager.noFiles", "No files available")
|
||||
}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileGrid;
|
@ -1,9 +1,13 @@
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { Stack, Button, Text, Center } from '@mantine/core';
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { Stack, Button, Text, Center, Box, Divider } from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import FilePickerModal from './FilePickerModal';
|
||||
import { fileStorage } from '../../services/fileStorage';
|
||||
import { FileWithUrl } from '../../types/file';
|
||||
import FileGrid from './FileGrid';
|
||||
import MultiSelectControls from './MultiSelectControls';
|
||||
import { useFileManager } from '../../hooks/useFileManager';
|
||||
|
||||
interface FileUploadSelectorProps {
|
||||
// Appearance
|
||||
@ -20,6 +24,10 @@ interface FileUploadSelectorProps {
|
||||
// Loading state
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
|
||||
// Recent files
|
||||
showRecentFiles?: boolean;
|
||||
maxRecentFiles?: number;
|
||||
}
|
||||
|
||||
const FileUploadSelector = ({
|
||||
@ -29,50 +37,94 @@ const FileUploadSelector = ({
|
||||
sharedFiles = [],
|
||||
onFileSelect,
|
||||
onFilesSelect,
|
||||
accept = ["application/pdf"],
|
||||
accept = ["application/pdf", "application/zip", "application/x-zip-compressed"],
|
||||
loading = false,
|
||||
disabled = false,
|
||||
showRecentFiles = true,
|
||||
maxRecentFiles = 8,
|
||||
}: FileUploadSelectorProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [showFilePickerModal, setShowFilePickerModal] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileUpload = useCallback((uploadedFiles: File[]) => {
|
||||
const [recentFiles, setRecentFiles] = useState<FileWithUrl[]>([]);
|
||||
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
|
||||
|
||||
const { loadRecentFiles, handleRemoveFile, storeFile, convertToFile, createFileSelectionHandlers } = useFileManager();
|
||||
|
||||
const refreshRecentFiles = useCallback(async () => {
|
||||
const files = await loadRecentFiles();
|
||||
setRecentFiles(files);
|
||||
}, [loadRecentFiles]);
|
||||
|
||||
const handleNewFileUpload = useCallback(async (uploadedFiles: File[]) => {
|
||||
if (uploadedFiles.length === 0) return;
|
||||
|
||||
if (showRecentFiles) {
|
||||
try {
|
||||
for (const file of uploadedFiles) {
|
||||
await storeFile(file);
|
||||
}
|
||||
refreshRecentFiles();
|
||||
} catch (error) {
|
||||
console.error('Failed to save files to recent:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (onFilesSelect) {
|
||||
onFilesSelect(uploadedFiles);
|
||||
} else if (onFileSelect) {
|
||||
onFileSelect(uploadedFiles[0]);
|
||||
}
|
||||
}, [onFileSelect, onFilesSelect]);
|
||||
}, [onFileSelect, onFilesSelect, showRecentFiles, storeFile, refreshRecentFiles]);
|
||||
|
||||
const handleFileInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files;
|
||||
if (files && files.length > 0) {
|
||||
const fileArray = Array.from(files);
|
||||
console.log('File input change:', fileArray.length, 'files');
|
||||
handleFileUpload(fileArray);
|
||||
handleNewFileUpload(fileArray);
|
||||
}
|
||||
// Reset input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}, [handleFileUpload]);
|
||||
}, [handleNewFileUpload]);
|
||||
|
||||
const openFileDialog = useCallback(() => {
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const handleStorageSelection = useCallback((selectedFiles: File[]) => {
|
||||
if (selectedFiles.length === 0) return;
|
||||
|
||||
if (onFilesSelect) {
|
||||
onFilesSelect(selectedFiles);
|
||||
} else if (onFileSelect) {
|
||||
onFileSelect(selectedFiles[0]);
|
||||
const handleRecentFileSelection = useCallback(async (file: FileWithUrl) => {
|
||||
try {
|
||||
const fileObj = await convertToFile(file);
|
||||
if (onFilesSelect) {
|
||||
onFilesSelect([fileObj]);
|
||||
} else if (onFileSelect) {
|
||||
onFileSelect(fileObj);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load file from recent:', error);
|
||||
}
|
||||
}, [onFileSelect, onFilesSelect]);
|
||||
}, [onFileSelect, onFilesSelect, convertToFile]);
|
||||
|
||||
const selectionHandlers = createFileSelectionHandlers(selectedFiles, setSelectedFiles);
|
||||
|
||||
const handleSelectedRecentFiles = useCallback(async () => {
|
||||
if (onFilesSelect) {
|
||||
await selectionHandlers.selectMultipleFiles(recentFiles, onFilesSelect);
|
||||
}
|
||||
}, [recentFiles, onFilesSelect, selectionHandlers]);
|
||||
|
||||
const handleRemoveFileByIndex = useCallback(async (index: number) => {
|
||||
await handleRemoveFile(index, recentFiles, setRecentFiles);
|
||||
const file = recentFiles[index];
|
||||
setSelectedFiles(prev => prev.filter(id => id !== (file.id || file.name)));
|
||||
}, [handleRemoveFile, recentFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showRecentFiles) {
|
||||
refreshRecentFiles();
|
||||
}
|
||||
}, [showRecentFiles, refreshRecentFiles]);
|
||||
|
||||
// Get default title and subtitle from translations if not provided
|
||||
const displayTitle = title || t("fileUpload.selectFiles", "Select files");
|
||||
@ -80,7 +132,7 @@ const FileUploadSelector = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack align="center" gap="xl">
|
||||
<Stack align="center" gap="sm">
|
||||
{/* Title and description */}
|
||||
<Stack align="center" gap="md">
|
||||
<UploadFileIcon style={{ fontSize: 64 }} />
|
||||
@ -94,27 +146,14 @@ const FileUploadSelector = ({
|
||||
|
||||
{/* Action buttons */}
|
||||
<Stack align="center" gap="md" w="100%">
|
||||
<Button
|
||||
variant="filled"
|
||||
size="lg"
|
||||
onClick={() => setShowFilePickerModal(true)}
|
||||
disabled={disabled || sharedFiles.length === 0}
|
||||
loading={loading}
|
||||
>
|
||||
{loading ? "Loading..." : `Load from Storage (${sharedFiles.length} files available)`}
|
||||
</Button>
|
||||
|
||||
<Text size="md" c="dimmed">
|
||||
{t("fileUpload.or", "or")}
|
||||
</Text>
|
||||
|
||||
{showDropzone ? (
|
||||
<Dropzone
|
||||
onDrop={handleFileUpload}
|
||||
onDrop={handleNewFileUpload}
|
||||
accept={accept}
|
||||
multiple={true}
|
||||
disabled={disabled || loading}
|
||||
style={{ width: '100%', minHeight: 120 }}
|
||||
style={{ width: '100%', height: "5rem" }}
|
||||
activateOnClick={true}
|
||||
>
|
||||
<Center>
|
||||
@ -123,7 +162,9 @@ const FileUploadSelector = ({
|
||||
{t("fileUpload.dropFilesHere", "Drop files here or click to upload")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{accept.includes('application/pdf')
|
||||
{accept.includes('application/pdf') && accept.includes('application/zip')
|
||||
? t("fileUpload.pdfAndZipFiles", "PDF and ZIP files")
|
||||
: accept.includes('application/pdf')
|
||||
? t("fileUpload.pdfFilesOnly", "PDF files only")
|
||||
: t("fileUpload.supportedFileTypes", "Supported file types")
|
||||
}
|
||||
@ -142,7 +183,7 @@ const FileUploadSelector = ({
|
||||
>
|
||||
{t("fileUpload.uploadFiles", "Upload Files")}
|
||||
</Button>
|
||||
|
||||
|
||||
{/* Manual file input as backup */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
@ -155,15 +196,46 @@ const FileUploadSelector = ({
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{/* File Picker Modal */}
|
||||
<FilePickerModal
|
||||
opened={showFilePickerModal}
|
||||
onClose={() => setShowFilePickerModal(false)}
|
||||
storedFiles={sharedFiles}
|
||||
onSelectFiles={handleStorageSelection}
|
||||
/>
|
||||
{/* Recent Files Section */}
|
||||
{showRecentFiles && recentFiles.length > 0 && (
|
||||
<Box w="100%" >
|
||||
<Divider my="md" />
|
||||
<Text size="lg" fw={500} mb="md">
|
||||
{t("fileUpload.recentFiles", "Recent Files")}
|
||||
</Text>
|
||||
<MultiSelectControls
|
||||
selectedCount={selectedFiles.length}
|
||||
onClearSelection={selectionHandlers.clearSelection}
|
||||
onAddToUpload={handleSelectedRecentFiles}
|
||||
onDeleteAll={async () => {
|
||||
await Promise.all(recentFiles.map(async (file) => {
|
||||
await fileStorage.deleteFile(file.id || file.name);
|
||||
}));
|
||||
setRecentFiles([]);
|
||||
setSelectedFiles([]);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FileGrid
|
||||
files={recentFiles}
|
||||
onDoubleClick={handleRecentFileSelection}
|
||||
onSelect={selectionHandlers.toggleSelection}
|
||||
onRemove={handleRemoveFileByIndex}
|
||||
selectedFiles={selectedFiles}
|
||||
showSearch={true}
|
||||
showSort={true}
|
||||
onDeleteAll={async () => {
|
||||
await Promise.all(recentFiles.map(async (file) => {
|
||||
await fileStorage.deleteFile(file.id || file.name);
|
||||
}));
|
||||
setRecentFiles([]);
|
||||
setSelectedFiles([]);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
88
frontend/src/components/shared/MultiSelectControls.tsx
Normal file
88
frontend/src/components/shared/MultiSelectControls.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import React from "react";
|
||||
import { Box, Group, Text, Button } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface MultiSelectControlsProps {
|
||||
selectedCount: number;
|
||||
onClearSelection: () => void;
|
||||
onOpenInFileEditor?: () => void;
|
||||
onOpenInPageEditor?: () => void;
|
||||
onAddToUpload?: () => void;
|
||||
onDeleteAll?: () => void;
|
||||
}
|
||||
|
||||
const MultiSelectControls = ({
|
||||
selectedCount,
|
||||
onClearSelection,
|
||||
onOpenInFileEditor,
|
||||
onOpenInPageEditor,
|
||||
onAddToUpload,
|
||||
onDeleteAll
|
||||
}: MultiSelectControlsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (selectedCount === 0) return null;
|
||||
|
||||
return (
|
||||
<Box mb="md" p="md" style={{ backgroundColor: 'var(--mantine-color-blue-0)', borderRadius: 8 }}>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm">
|
||||
{selectedCount} {t("fileManager.filesSelected", "files selected")}
|
||||
</Text>
|
||||
<Group>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={onClearSelection}
|
||||
>
|
||||
{t("fileManager.clearSelection", "Clear Selection")}
|
||||
</Button>
|
||||
|
||||
{onAddToUpload && (
|
||||
<Button
|
||||
size="xs"
|
||||
color="green"
|
||||
onClick={onAddToUpload}
|
||||
>
|
||||
{t("fileManager.addToUpload", "Add to Upload")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onOpenInFileEditor && (
|
||||
<Button
|
||||
size="xs"
|
||||
color="orange"
|
||||
onClick={onOpenInFileEditor}
|
||||
disabled={selectedCount === 0}
|
||||
>
|
||||
{t("fileManager.openInFileEditor", "Open in File Editor")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onOpenInPageEditor && (
|
||||
<Button
|
||||
size="xs"
|
||||
color="blue"
|
||||
onClick={onOpenInPageEditor}
|
||||
disabled={selectedCount === 0}
|
||||
>
|
||||
{t("fileManager.openInPageEditor", "Open in Page Editor")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onDeleteAll && (
|
||||
<Button
|
||||
size="xs"
|
||||
color="red"
|
||||
onClick={onDeleteAll}
|
||||
>
|
||||
{t("fileManager.deleteAll", "Delete All")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiSelectControls;
|
106
frontend/src/components/shared/NavigationWarningModal.tsx
Normal file
106
frontend/src/components/shared/NavigationWarningModal.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
import React from 'react';
|
||||
import { Modal, Text, Button, Group, Stack } from '@mantine/core';
|
||||
import { useFileContext } from '../../contexts/FileContext';
|
||||
|
||||
interface NavigationWarningModalProps {
|
||||
onApplyAndContinue?: () => Promise<void>;
|
||||
onExportAndContinue?: () => Promise<void>;
|
||||
}
|
||||
|
||||
const NavigationWarningModal = ({
|
||||
onApplyAndContinue,
|
||||
onExportAndContinue
|
||||
}: NavigationWarningModalProps) => {
|
||||
const {
|
||||
showNavigationWarning,
|
||||
hasUnsavedChanges,
|
||||
confirmNavigation,
|
||||
cancelNavigation,
|
||||
setHasUnsavedChanges
|
||||
} = useFileContext();
|
||||
|
||||
const handleKeepWorking = () => {
|
||||
cancelNavigation();
|
||||
};
|
||||
|
||||
const handleDiscardChanges = () => {
|
||||
setHasUnsavedChanges(false);
|
||||
confirmNavigation();
|
||||
};
|
||||
|
||||
const handleApplyAndContinue = async () => {
|
||||
if (onApplyAndContinue) {
|
||||
await onApplyAndContinue();
|
||||
}
|
||||
setHasUnsavedChanges(false);
|
||||
confirmNavigation();
|
||||
};
|
||||
|
||||
const handleExportAndContinue = async () => {
|
||||
if (onExportAndContinue) {
|
||||
await onExportAndContinue();
|
||||
}
|
||||
setHasUnsavedChanges(false);
|
||||
confirmNavigation();
|
||||
};
|
||||
|
||||
if (!hasUnsavedChanges) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={showNavigationWarning}
|
||||
onClose={handleKeepWorking}
|
||||
title="Unsaved Changes"
|
||||
centered
|
||||
closeOnClickOutside={false}
|
||||
closeOnEscape={false}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text>
|
||||
You have unsaved changes to your PDF. What would you like to do?
|
||||
</Text>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="light"
|
||||
color="gray"
|
||||
onClick={handleKeepWorking}
|
||||
>
|
||||
Keep Working
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={handleDiscardChanges}
|
||||
>
|
||||
Discard Changes
|
||||
</Button>
|
||||
|
||||
{onApplyAndContinue && (
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
onClick={handleApplyAndContinue}
|
||||
>
|
||||
Apply & Continue
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onExportAndContinue && (
|
||||
<Button
|
||||
color="green"
|
||||
onClick={handleExportAndContinue}
|
||||
>
|
||||
Export & Continue
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavigationWarningModal;
|
104
frontend/src/components/shared/SkeletonLoader.tsx
Normal file
104
frontend/src/components/shared/SkeletonLoader.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
import React from 'react';
|
||||
import { Box, Group, Stack } from '@mantine/core';
|
||||
|
||||
interface SkeletonLoaderProps {
|
||||
type: 'pageGrid' | 'fileGrid' | 'controls' | 'viewer';
|
||||
count?: number;
|
||||
animated?: boolean;
|
||||
}
|
||||
|
||||
const SkeletonLoader: React.FC<SkeletonLoaderProps> = ({
|
||||
type,
|
||||
count = 8,
|
||||
animated = true
|
||||
}) => {
|
||||
const animationStyle = animated ? { animation: 'pulse 2s infinite' } : {};
|
||||
|
||||
const renderPageGridSkeleton = () => (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))',
|
||||
gap: '1rem'
|
||||
}}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
w="100%"
|
||||
h={240}
|
||||
bg="gray.1"
|
||||
style={{
|
||||
borderRadius: '8px',
|
||||
...animationStyle,
|
||||
animationDelay: animated ? `${i * 0.1}s` : undefined
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderFileGridSkeleton = () => (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||
gap: '1rem'
|
||||
}}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<Box
|
||||
key={i}
|
||||
w="100%"
|
||||
h={280}
|
||||
bg="gray.1"
|
||||
style={{
|
||||
borderRadius: '8px',
|
||||
...animationStyle,
|
||||
animationDelay: animated ? `${i * 0.1}s` : undefined
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderControlsSkeleton = () => (
|
||||
<Group mb="md">
|
||||
<Box w={150} h={36} bg="gray.1" style={{ borderRadius: 4, ...animationStyle }} />
|
||||
<Box w={120} h={36} bg="gray.1" style={{ borderRadius: 4, ...animationStyle }} />
|
||||
<Box w={100} h={36} bg="gray.1" style={{ borderRadius: 4, ...animationStyle }} />
|
||||
</Group>
|
||||
);
|
||||
|
||||
const renderViewerSkeleton = () => (
|
||||
<Stack gap="md" h="100%">
|
||||
{/* Toolbar skeleton */}
|
||||
<Group>
|
||||
<Box w={40} h={40} bg="gray.1" style={{ borderRadius: 4, ...animationStyle }} />
|
||||
<Box w={40} h={40} bg="gray.1" style={{ borderRadius: 4, ...animationStyle }} />
|
||||
<Box w={80} h={40} bg="gray.1" style={{ borderRadius: 4, ...animationStyle }} />
|
||||
<Box w={40} h={40} bg="gray.1" style={{ borderRadius: 4, ...animationStyle }} />
|
||||
</Group>
|
||||
{/* Main content skeleton */}
|
||||
<Box
|
||||
flex={1}
|
||||
bg="gray.1"
|
||||
style={{
|
||||
borderRadius: '8px',
|
||||
...animationStyle
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
switch (type) {
|
||||
case 'pageGrid':
|
||||
return renderPageGridSkeleton();
|
||||
case 'fileGrid':
|
||||
return renderFileGridSkeleton();
|
||||
case 'controls':
|
||||
return renderControlsSkeleton();
|
||||
case 'viewer':
|
||||
return renderViewerSkeleton();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default SkeletonLoader;
|
@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { Button, SegmentedControl } from "@mantine/core";
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { Button, SegmentedControl, Loader } from "@mantine/core";
|
||||
import { useRainbowThemeContext } from "./RainbowThemeProvider";
|
||||
import LanguageSelector from "./LanguageSelector";
|
||||
import rainbowStyles from '../../styles/rainbow.module.css';
|
||||
@ -8,15 +8,19 @@ import LightModeIcon from '@mui/icons-material/LightMode';
|
||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import EditNoteIcon from "@mui/icons-material/EditNote";
|
||||
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
|
||||
import FolderIcon from "@mui/icons-material/Folder";
|
||||
import { Group } from "@mantine/core";
|
||||
|
||||
const VIEW_OPTIONS = [
|
||||
// This will be created inside the component to access switchingTo
|
||||
const createViewOptions = (switchingTo: string | null) => [
|
||||
{
|
||||
label: (
|
||||
<Group gap={5}>
|
||||
<VisibilityIcon fontSize="small" />
|
||||
{switchingTo === "viewer" ? (
|
||||
<Loader size="xs" />
|
||||
) : (
|
||||
<VisibilityIcon fontSize="small" />
|
||||
)}
|
||||
</Group>
|
||||
),
|
||||
value: "viewer",
|
||||
@ -24,7 +28,11 @@ const VIEW_OPTIONS = [
|
||||
{
|
||||
label: (
|
||||
<Group gap={4}>
|
||||
<EditNoteIcon fontSize="small" />
|
||||
{switchingTo === "pageEditor" ? (
|
||||
<Loader size="xs" />
|
||||
) : (
|
||||
<EditNoteIcon fontSize="small" />
|
||||
)}
|
||||
</Group>
|
||||
),
|
||||
value: "pageEditor",
|
||||
@ -32,15 +40,11 @@ const VIEW_OPTIONS = [
|
||||
{
|
||||
label: (
|
||||
<Group gap={4}>
|
||||
<InsertDriveFileIcon fontSize="small" />
|
||||
</Group>
|
||||
),
|
||||
value: "fileManager",
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Group gap={4}>
|
||||
<FolderIcon fontSize="small" />
|
||||
{switchingTo === "fileEditor" ? (
|
||||
<Loader size="xs" />
|
||||
) : (
|
||||
<FolderIcon fontSize="small" />
|
||||
)}
|
||||
</Group>
|
||||
),
|
||||
value: "fileEditor",
|
||||
@ -50,13 +54,34 @@ const VIEW_OPTIONS = [
|
||||
interface TopControlsProps {
|
||||
currentView: string;
|
||||
setCurrentView: (view: string) => void;
|
||||
selectedToolKey?: string | null;
|
||||
}
|
||||
|
||||
const TopControls = ({
|
||||
currentView,
|
||||
setCurrentView,
|
||||
selectedToolKey,
|
||||
}: TopControlsProps) => {
|
||||
const { themeMode, isRainbowMode, isToggleDisabled, toggleTheme } = useRainbowThemeContext();
|
||||
const [switchingTo, setSwitchingTo] = useState<string | null>(null);
|
||||
|
||||
const isToolSelected = selectedToolKey !== null;
|
||||
|
||||
const handleViewChange = useCallback((view: string) => {
|
||||
// Show immediate feedback
|
||||
setSwitchingTo(view);
|
||||
|
||||
// Defer the heavy view change to next frame so spinner can render
|
||||
requestAnimationFrame(() => {
|
||||
// Give the spinner one more frame to show
|
||||
requestAnimationFrame(() => {
|
||||
setCurrentView(view);
|
||||
|
||||
// Clear the loading state after view change completes
|
||||
setTimeout(() => setSwitchingTo(null), 300);
|
||||
});
|
||||
});
|
||||
}, [setCurrentView]);
|
||||
|
||||
const getThemeIcon = () => {
|
||||
if (isRainbowMode) return <AutoAwesomeIcon className={rainbowStyles.rainbowText} />;
|
||||
@ -66,7 +91,9 @@ const TopControls = ({
|
||||
|
||||
return (
|
||||
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
|
||||
<div className="absolute left-4 top-1/2 -translate-y-1/2 pointer-events-auto flex gap-2 items-center">
|
||||
<div className={`absolute left-4 pointer-events-auto flex gap-2 items-center ${
|
||||
isToolSelected ? 'top-4' : 'top-1/2 -translate-y-1/2'
|
||||
}`}>
|
||||
<Button
|
||||
onClick={toggleTheme}
|
||||
variant="subtle"
|
||||
@ -87,18 +114,24 @@ const TopControls = ({
|
||||
</Button>
|
||||
<LanguageSelector />
|
||||
</div>
|
||||
<div className="flex justify-center items-center h-full pointer-events-auto">
|
||||
<SegmentedControl
|
||||
data={VIEW_OPTIONS}
|
||||
value={currentView}
|
||||
onChange={setCurrentView}
|
||||
color="blue"
|
||||
radius="xl"
|
||||
size="md"
|
||||
fullWidth
|
||||
className={isRainbowMode ? rainbowStyles.rainbowSegmentedControl : ''}
|
||||
/>
|
||||
</div>
|
||||
{!isToolSelected && (
|
||||
<div className="flex justify-center items-center h-full pointer-events-auto">
|
||||
<SegmentedControl
|
||||
data={createViewOptions(switchingTo)}
|
||||
value={currentView}
|
||||
onChange={handleViewChange}
|
||||
color="blue"
|
||||
radius="xl"
|
||||
size="md"
|
||||
fullWidth
|
||||
className={isRainbowMode ? rainbowStyles.rainbowSegmentedControl : ''}
|
||||
style={{
|
||||
transition: 'all 0.2s ease',
|
||||
opacity: switchingTo ? 0.8 : 1,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -12,7 +12,7 @@ type ToolRegistry = {
|
||||
};
|
||||
|
||||
interface ToolPickerProps {
|
||||
selectedToolKey: string;
|
||||
selectedToolKey: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
toolRegistry: ToolRegistry;
|
||||
}
|
||||
|
@ -1,29 +1,30 @@
|
||||
import React from "react";
|
||||
import { FileWithUrl } from "../../types/file";
|
||||
import { useToolManagement } from "../../hooks/useToolManagement";
|
||||
|
||||
interface ToolRendererProps {
|
||||
selectedToolKey: string;
|
||||
selectedTool: any;
|
||||
pdfFile: any;
|
||||
files: FileWithUrl[];
|
||||
downloadUrl: string | null;
|
||||
setDownloadUrl: (url: string | null) => void;
|
||||
toolParams: any;
|
||||
updateParams: (params: any) => void;
|
||||
toolSelectedFiles?: File[];
|
||||
onPreviewFile?: (file: File | null) => void;
|
||||
}
|
||||
|
||||
const ToolRenderer = ({
|
||||
selectedToolKey,
|
||||
selectedTool,
|
||||
pdfFile,
|
||||
files,
|
||||
downloadUrl,
|
||||
setDownloadUrl,
|
||||
files,
|
||||
toolParams,
|
||||
updateParams,
|
||||
toolSelectedFiles = [],
|
||||
onPreviewFile,
|
||||
}: ToolRendererProps) => {
|
||||
// Get the tool from registry
|
||||
const { toolRegistry } = useToolManagement();
|
||||
const selectedTool = toolRegistry[selectedToolKey];
|
||||
|
||||
if (!selectedTool || !selectedTool.component) {
|
||||
return <div>Tool not found</div>;
|
||||
return <div>Tool not found: {selectedToolKey}</div>;
|
||||
}
|
||||
|
||||
const ToolComponent = selectedTool.component;
|
||||
@ -33,19 +34,15 @@ const ToolRenderer = ({
|
||||
case "split":
|
||||
return (
|
||||
<ToolComponent
|
||||
file={pdfFile}
|
||||
downloadUrl={downloadUrl}
|
||||
setDownloadUrl={setDownloadUrl}
|
||||
params={toolParams}
|
||||
updateParams={updateParams}
|
||||
selectedFiles={toolSelectedFiles}
|
||||
onPreviewFile={onPreviewFile}
|
||||
/>
|
||||
);
|
||||
case "compress":
|
||||
return (
|
||||
<ToolComponent
|
||||
files={files}
|
||||
setDownloadUrl={setDownloadUrl}
|
||||
setLoading={(loading: boolean) => {}} // TODO: Add loading state
|
||||
setLoading={(loading: boolean) => {}}
|
||||
params={toolParams}
|
||||
updateParams={updateParams}
|
||||
/>
|
||||
@ -54,7 +51,6 @@ const ToolRenderer = ({
|
||||
return (
|
||||
<ToolComponent
|
||||
files={files}
|
||||
setDownloadUrl={setDownloadUrl}
|
||||
params={toolParams}
|
||||
updateParams={updateParams}
|
||||
/>
|
||||
@ -63,7 +59,6 @@ const ToolRenderer = ({
|
||||
return (
|
||||
<ToolComponent
|
||||
files={files}
|
||||
setDownloadUrl={setDownloadUrl}
|
||||
params={toolParams}
|
||||
updateParams={updateParams}
|
||||
/>
|
||||
@ -71,4 +66,4 @@ const ToolRenderer = ({
|
||||
}
|
||||
};
|
||||
|
||||
export default ToolRenderer;
|
||||
export default ToolRenderer;
|
||||
|
35
frontend/src/components/tools/shared/ErrorNotification.tsx
Normal file
35
frontend/src/components/tools/shared/ErrorNotification.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { Notification } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface ErrorNotificationProps {
|
||||
error: string | null;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
color?: string;
|
||||
mb?: string;
|
||||
}
|
||||
|
||||
const ErrorNotification = ({
|
||||
error,
|
||||
onClose,
|
||||
title,
|
||||
color = 'red',
|
||||
mb = 'md'
|
||||
}: ErrorNotificationProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!error) return null;
|
||||
|
||||
return (
|
||||
<Notification
|
||||
color={color}
|
||||
title={title || t("error._value", "Error")}
|
||||
onClose={onClose}
|
||||
mb={mb}
|
||||
>
|
||||
{error}
|
||||
</Notification>
|
||||
);
|
||||
}
|
||||
|
||||
export default ErrorNotification;
|
40
frontend/src/components/tools/shared/FileStatusIndicator.tsx
Normal file
40
frontend/src/components/tools/shared/FileStatusIndicator.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import { Text } from '@mantine/core';
|
||||
|
||||
export interface FileStatusIndicatorProps {
|
||||
selectedFiles?: File[];
|
||||
isCompleted?: boolean;
|
||||
placeholder?: string;
|
||||
showFileName?: boolean;
|
||||
}
|
||||
|
||||
const FileStatusIndicator = ({
|
||||
selectedFiles = [],
|
||||
isCompleted = false,
|
||||
placeholder = "Select a PDF file in the main view to get started",
|
||||
showFileName = true
|
||||
}: FileStatusIndicatorProps) => {
|
||||
if (selectedFiles.length === 0) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
{placeholder}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (isCompleted) {
|
||||
return (
|
||||
<Text size="sm" c="green">
|
||||
✓ Selected: {showFileName ? selectedFiles[0]?.name : `${selectedFiles.length} file${selectedFiles.length > 1 ? 's' : ''}`}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text size="sm" c="blue">
|
||||
Selected: {showFileName ? selectedFiles[0]?.name : `${selectedFiles.length} file${selectedFiles.length > 1 ? 's' : ''}`}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileStatusIndicator;
|
51
frontend/src/components/tools/shared/OperationButton.tsx
Normal file
51
frontend/src/components/tools/shared/OperationButton.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface OperationButtonProps {
|
||||
onClick?: () => void;
|
||||
isLoading?: boolean;
|
||||
disabled?: boolean;
|
||||
loadingText?: string;
|
||||
submitText?: string;
|
||||
variant?: 'filled' | 'outline' | 'subtle';
|
||||
color?: string;
|
||||
fullWidth?: boolean;
|
||||
mt?: string;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
}
|
||||
|
||||
const OperationButton = ({
|
||||
onClick,
|
||||
isLoading = false,
|
||||
disabled = false,
|
||||
loadingText,
|
||||
submitText,
|
||||
variant = 'filled',
|
||||
color = 'blue',
|
||||
fullWidth = true,
|
||||
mt = 'md',
|
||||
type = 'button'
|
||||
}: OperationButtonProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
fullWidth={fullWidth}
|
||||
mt={mt}
|
||||
loading={isLoading}
|
||||
disabled={disabled}
|
||||
variant={variant}
|
||||
color={color}
|
||||
>
|
||||
{isLoading
|
||||
? (loadingText || t("loading", "Loading..."))
|
||||
: (submitText || t("submit", "Submit"))
|
||||
}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default OperationButton;
|
112
frontend/src/components/tools/shared/ResultsPreview.tsx
Normal file
112
frontend/src/components/tools/shared/ResultsPreview.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
import { Grid, Paper, Box, Image, Text, Loader, Stack, Center } from '@mantine/core';
|
||||
|
||||
export interface ResultFile {
|
||||
file: File;
|
||||
thumbnail?: string;
|
||||
}
|
||||
|
||||
export interface ResultsPreviewProps {
|
||||
files: ResultFile[];
|
||||
isGeneratingThumbnails?: boolean;
|
||||
onFileClick?: (file: File) => void;
|
||||
title?: string;
|
||||
emptyMessage?: string;
|
||||
loadingMessage?: string;
|
||||
}
|
||||
|
||||
const ResultsPreview = ({
|
||||
files,
|
||||
isGeneratingThumbnails = false,
|
||||
onFileClick,
|
||||
title,
|
||||
emptyMessage = "No files to preview",
|
||||
loadingMessage = "Generating previews..."
|
||||
}: ResultsPreviewProps) => {
|
||||
const formatSize = (size: number) => {
|
||||
if (size > 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
if (size > 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||
return `${size} B`;
|
||||
};
|
||||
|
||||
if (files.length === 0 && !isGeneratingThumbnails) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
{emptyMessage}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box mt="lg" p="md" style={{ backgroundColor: 'var(--mantine-color-gray-0)', borderRadius: 8 }}>
|
||||
{title && (
|
||||
<Text fw={500} size="md" mb="sm">
|
||||
{title} ({files.length} files)
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{isGeneratingThumbnails ? (
|
||||
<Center p="lg">
|
||||
<Stack align="center" gap="sm">
|
||||
<Loader size="sm" />
|
||||
<Text size="sm" c="dimmed">{loadingMessage}</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
) : (
|
||||
<Grid>
|
||||
{files.map((result, index) => (
|
||||
<Grid.Col span={{ base: 6, sm: 4, md: 3 }} key={index}>
|
||||
<Paper
|
||||
p="xs"
|
||||
withBorder
|
||||
onClick={() => onFileClick?.(result.file)}
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
height: '10rem',
|
||||
width:'5rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
cursor: onFileClick ? 'pointer' : 'default',
|
||||
transition: 'all 0.2s ease'
|
||||
}}
|
||||
>
|
||||
<Box style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{result.thumbnail ? (
|
||||
<Image
|
||||
src={result.thumbnail}
|
||||
alt={`Preview of ${result.file.name}`}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '9rem',
|
||||
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={result.file.name}
|
||||
>
|
||||
{result.file.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatSize(result.file.size)}
|
||||
</Text>
|
||||
</Paper>
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default ResultsPreview;
|
120
frontend/src/components/tools/shared/ToolStep.tsx
Normal file
120
frontend/src/components/tools/shared/ToolStep.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
import React, { createContext, useContext, useMemo, useRef } from 'react';
|
||||
import { Paper, Text, Stack, Box } from '@mantine/core';
|
||||
|
||||
interface ToolStepContextType {
|
||||
visibleStepCount: number;
|
||||
getStepNumber: () => number;
|
||||
}
|
||||
|
||||
const ToolStepContext = createContext<ToolStepContextType | null>(null);
|
||||
|
||||
export interface ToolStepProps {
|
||||
title: string;
|
||||
isVisible?: boolean;
|
||||
isCollapsed?: boolean;
|
||||
isCompleted?: boolean;
|
||||
onCollapsedClick?: () => void;
|
||||
children?: React.ReactNode;
|
||||
completedMessage?: string;
|
||||
helpText?: string;
|
||||
showNumber?: boolean;
|
||||
}
|
||||
|
||||
const ToolStep = ({
|
||||
title,
|
||||
isVisible = true,
|
||||
isCollapsed = false,
|
||||
isCompleted = false,
|
||||
onCollapsedClick,
|
||||
children,
|
||||
completedMessage,
|
||||
helpText,
|
||||
showNumber
|
||||
}: ToolStepProps) => {
|
||||
if (!isVisible) return null;
|
||||
|
||||
// Auto-detect if we should show numbers based on sibling count
|
||||
const shouldShowNumber = useMemo(() => {
|
||||
if (showNumber !== undefined) return showNumber;
|
||||
const parent = useContext(ToolStepContext);
|
||||
return parent ? parent.visibleStepCount >= 3 : false;
|
||||
}, [showNumber]);
|
||||
|
||||
const stepNumber = useContext(ToolStepContext)?.getStepNumber?.() || 1;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
p="md"
|
||||
withBorder
|
||||
style={{
|
||||
cursor: isCollapsed && onCollapsedClick ? 'pointer' : 'default',
|
||||
opacity: isCollapsed ? 0.8 : 1,
|
||||
transition: 'opacity 0.2s ease'
|
||||
}}
|
||||
onClick={isCollapsed && onCollapsedClick ? onCollapsedClick : undefined}
|
||||
>
|
||||
<Text fw={500} size="lg" mb="sm">
|
||||
{shouldShowNumber ? `${stepNumber}. ` : ''}{title}
|
||||
</Text>
|
||||
|
||||
{isCollapsed ? (
|
||||
<Box>
|
||||
{isCompleted && completedMessage && (
|
||||
<Text size="sm" c="green">
|
||||
✓ {completedMessage}
|
||||
{onCollapsedClick && (
|
||||
<Text span c="dimmed" size="xs" ml="sm">
|
||||
(click to change)
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
{helpText && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{helpText}
|
||||
</Text>
|
||||
)}
|
||||
{children}
|
||||
</Stack>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ToolStepContainerProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ToolStepContainer = ({ children }: ToolStepContainerProps) => {
|
||||
const stepCounterRef = useRef(0);
|
||||
|
||||
// Count visible ToolStep children
|
||||
const visibleStepCount = useMemo(() => {
|
||||
let count = 0;
|
||||
React.Children.forEach(children, (child) => {
|
||||
if (React.isValidElement(child) && child.type === ToolStep) {
|
||||
const isVisible = child.props.isVisible !== false;
|
||||
if (isVisible) count++;
|
||||
}
|
||||
});
|
||||
return count;
|
||||
}, [children]);
|
||||
|
||||
const contextValue = useMemo(() => ({
|
||||
visibleStepCount,
|
||||
getStepNumber: () => ++stepCounterRef.current
|
||||
}), [visibleStepCount]);
|
||||
|
||||
stepCounterRef.current = 0;
|
||||
|
||||
return (
|
||||
<ToolStepContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</ToolStepContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default ToolStep;
|
148
frontend/src/components/tools/split/SplitSettings.tsx
Normal file
148
frontend/src/components/tools/split/SplitSettings.tsx
Normal file
@ -0,0 +1,148 @@
|
||||
import { Stack, TextInput, Select, Checkbox } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SPLIT_MODES, SPLIT_TYPES, type SplitMode, type SplitType } from '../../../constants/splitConstants';
|
||||
|
||||
export interface SplitParameters {
|
||||
pages: string;
|
||||
hDiv: string;
|
||||
vDiv: string;
|
||||
merge: boolean;
|
||||
splitType: SplitType | '';
|
||||
splitValue: string;
|
||||
bookmarkLevel: string;
|
||||
includeMetadata: boolean;
|
||||
allowDuplicates: boolean;
|
||||
}
|
||||
|
||||
export interface SplitSettingsProps {
|
||||
mode: SplitMode | '';
|
||||
onModeChange: (mode: SplitMode | '') => void;
|
||||
parameters: SplitParameters;
|
||||
onParameterChange: (parameter: keyof SplitParameters, value: string | boolean) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const SplitSettings = ({
|
||||
mode,
|
||||
onModeChange,
|
||||
parameters,
|
||||
onParameterChange,
|
||||
disabled = false
|
||||
}: SplitSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const renderByPagesForm = () => (
|
||||
<TextInput
|
||||
label={t("split.splitPages", "Pages")}
|
||||
placeholder={t("pageSelectionPrompt", "e.g. 1,3,5-10")}
|
||||
value={parameters.pages}
|
||||
onChange={(e) => onParameterChange('pages', e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderBySectionsForm = () => (
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label={t("split-by-sections.horizontal.label", "Horizontal Divisions")}
|
||||
type="number"
|
||||
min="0"
|
||||
max="300"
|
||||
value={parameters.hDiv}
|
||||
onChange={(e) => onParameterChange('hDiv', e.target.value)}
|
||||
placeholder={t("split-by-sections.horizontal.placeholder", "Enter number of horizontal divisions")}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("split-by-sections.vertical.label", "Vertical Divisions")}
|
||||
type="number"
|
||||
min="0"
|
||||
max="300"
|
||||
value={parameters.vDiv}
|
||||
onChange={(e) => onParameterChange('vDiv', e.target.value)}
|
||||
placeholder={t("split-by-sections.vertical.placeholder", "Enter number of vertical divisions")}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t("split-by-sections.merge", "Merge sections into one PDF")}
|
||||
checked={parameters.merge}
|
||||
onChange={(e) => onParameterChange('merge', e.currentTarget.checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const renderBySizeOrCountForm = () => (
|
||||
<Stack gap="sm">
|
||||
<Select
|
||||
label={t("split-by-size-or-count.type.label", "Split Type")}
|
||||
value={parameters.splitType}
|
||||
onChange={(v) => v && onParameterChange('splitType', v)}
|
||||
disabled={disabled}
|
||||
data={[
|
||||
{ value: SPLIT_TYPES.SIZE, label: t("split-by-size-or-count.type.size", "By Size") },
|
||||
{ value: SPLIT_TYPES.PAGES, label: t("split-by-size-or-count.type.pageCount", "By Page Count") },
|
||||
{ value: SPLIT_TYPES.DOCS, label: t("split-by-size-or-count.type.docCount", "By Document Count") },
|
||||
]}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("split-by-size-or-count.value.label", "Split Value")}
|
||||
placeholder={t("split-by-size-or-count.value.placeholder", "e.g. 10MB or 5 pages")}
|
||||
value={parameters.splitValue}
|
||||
onChange={(e) => onParameterChange('splitValue', e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const renderByChaptersForm = () => (
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label={t("splitByChapters.bookmarkLevel", "Bookmark Level")}
|
||||
type="number"
|
||||
value={parameters.bookmarkLevel}
|
||||
onChange={(e) => onParameterChange('bookmarkLevel', e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t("splitByChapters.includeMetadata", "Include Metadata")}
|
||||
checked={parameters.includeMetadata}
|
||||
onChange={(e) => onParameterChange('includeMetadata', e.currentTarget.checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t("splitByChapters.allowDuplicates", "Allow Duplicate Bookmarks")}
|
||||
checked={parameters.allowDuplicates}
|
||||
onChange={(e) => onParameterChange('allowDuplicates', e.currentTarget.checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Mode Selector */}
|
||||
<Select
|
||||
label="Choose split method"
|
||||
placeholder="Select how to split the PDF"
|
||||
value={mode}
|
||||
onChange={(v) => v && onModeChange(v)}
|
||||
disabled={disabled}
|
||||
data={[
|
||||
{ value: SPLIT_MODES.BY_PAGES, label: t("split.header", "Split by Pages") + " (e.g. 1,3,5-10)" },
|
||||
{ value: SPLIT_MODES.BY_SECTIONS, label: t("split-by-sections.title", "Split by Grid Sections") },
|
||||
{ value: SPLIT_MODES.BY_SIZE_OR_COUNT, label: t("split-by-size-or-count.title", "Split by Size or Count") },
|
||||
{ value: SPLIT_MODES.BY_CHAPTERS, label: t("splitByChapters.title", "Split by Chapters") },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Parameter Form */}
|
||||
{mode === SPLIT_MODES.BY_PAGES && renderByPagesForm()}
|
||||
{mode === SPLIT_MODES.BY_SECTIONS && renderBySectionsForm()}
|
||||
{mode === SPLIT_MODES.BY_SIZE_OR_COUNT && renderBySizeOrCountForm()}
|
||||
{mode === SPLIT_MODES.BY_CHAPTERS && renderByChaptersForm()}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default SplitSettings;
|
@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import { Paper, Stack, Text, ScrollArea, Loader, Center, Button, Group, NumberInput, useMantineTheme } from "@mantine/core";
|
||||
import React, { useEffect, useState, useRef, useCallback } from "react";
|
||||
import { Paper, Stack, Text, ScrollArea, Loader, Center, Button, Group, NumberInput, useMantineTheme, ActionIcon, Box, Tabs } from "@mantine/core";
|
||||
import { getDocument, GlobalWorkerOptions } from "pdfjs-dist";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew";
|
||||
@ -9,8 +9,12 @@ import LastPageIcon from "@mui/icons-material/LastPage";
|
||||
import ViewSidebarIcon from "@mui/icons-material/ViewSidebar";
|
||||
import ViewWeekIcon from "@mui/icons-material/ViewWeek"; // for dual page (book)
|
||||
import DescriptionIcon from "@mui/icons-material/Description"; // for single page
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import { useLocalStorage } from "@mantine/hooks";
|
||||
import { fileStorage } from "../../services/fileStorage";
|
||||
import SkeletonLoader from '../shared/SkeletonLoader';
|
||||
import { useFileContext } from "../../contexts/FileContext";
|
||||
import { useFileWithUrl } from "../../hooks/useFileWithUrl";
|
||||
|
||||
GlobalWorkerOptions.workerSrc = "/pdf.worker.js";
|
||||
|
||||
@ -29,7 +33,7 @@ const LazyPageImage = ({
|
||||
pageIndex, zoom, theme, isFirst, renderPage, pageImages, setPageRef
|
||||
}: LazyPageImageProps) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(pageImages[pageIndex]);
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@ -54,6 +58,13 @@ const LazyPageImage = ({
|
||||
return () => observer.disconnect();
|
||||
}, [imageUrl]);
|
||||
|
||||
// Update local state when pageImages changes (from preloading)
|
||||
useEffect(() => {
|
||||
if (pageImages[pageIndex]) {
|
||||
setImageUrl(pageImages[pageIndex]);
|
||||
}
|
||||
}, [pageImages, pageIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible && !imageUrl) {
|
||||
renderPage(pageIndex).then((url) => {
|
||||
@ -123,20 +134,40 @@ const LazyPageImage = ({
|
||||
};
|
||||
|
||||
export interface ViewerProps {
|
||||
pdfFile: { file: File; url: string } | null; // First file in the array
|
||||
setPdfFile: (file: { file: File; url: string } | null) => void;
|
||||
sidebarsVisible: boolean;
|
||||
setSidebarsVisible: (v: boolean) => void;
|
||||
onClose?: () => void;
|
||||
previewFile?: File; // For preview mode - bypasses context
|
||||
}
|
||||
|
||||
const Viewer = ({
|
||||
pdfFile,
|
||||
setPdfFile,
|
||||
sidebarsVisible,
|
||||
setSidebarsVisible,
|
||||
onClose,
|
||||
previewFile,
|
||||
}: ViewerProps) => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useMantineTheme();
|
||||
|
||||
// Get current file from FileContext
|
||||
const { getCurrentFile, getCurrentProcessedFile, clearAllFiles, addFiles, activeFiles } = useFileContext();
|
||||
const currentFile = getCurrentFile();
|
||||
const processedFile = getCurrentProcessedFile();
|
||||
|
||||
// Convert File to FileWithUrl format for viewer
|
||||
const pdfFile = useFileWithUrl(currentFile);
|
||||
|
||||
// Tab management for multiple files
|
||||
const [activeTab, setActiveTab] = useState<string>("0");
|
||||
|
||||
// Reset PDF state when switching tabs
|
||||
const handleTabChange = (newTab: string) => {
|
||||
setActiveTab(newTab);
|
||||
setNumPages(0);
|
||||
setPageImages([]);
|
||||
setCurrentPage(null);
|
||||
setLoading(true);
|
||||
};
|
||||
const [numPages, setNumPages] = useState<number>(0);
|
||||
const [pageImages, setPageImages] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
@ -144,16 +175,50 @@ const Viewer = ({
|
||||
const [dualPage, setDualPage] = useState(false);
|
||||
const [zoom, setZoom] = useState(1); // 1 = 100%
|
||||
const pageRefs = useRef<(HTMLImageElement | null)[]>([]);
|
||||
|
||||
|
||||
// Get files with URLs for tabs - we'll need to create these individually
|
||||
const file0WithUrl = useFileWithUrl(activeFiles[0]);
|
||||
const file1WithUrl = useFileWithUrl(activeFiles[1]);
|
||||
const file2WithUrl = useFileWithUrl(activeFiles[2]);
|
||||
const file3WithUrl = useFileWithUrl(activeFiles[3]);
|
||||
const file4WithUrl = useFileWithUrl(activeFiles[4]);
|
||||
|
||||
const filesWithUrls = React.useMemo(() => {
|
||||
return [file0WithUrl, file1WithUrl, file2WithUrl, file3WithUrl, file4WithUrl]
|
||||
.slice(0, activeFiles.length)
|
||||
.filter(Boolean);
|
||||
}, [file0WithUrl, file1WithUrl, file2WithUrl, file3WithUrl, file4WithUrl, activeFiles.length]);
|
||||
|
||||
// Use preview file if available, otherwise use active tab file
|
||||
const effectiveFile = React.useMemo(() => {
|
||||
if (previewFile) {
|
||||
// Validate the preview file
|
||||
if (!(previewFile instanceof File)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (previewFile.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { file: previewFile, url: null };
|
||||
} else {
|
||||
// Use the file from the active tab
|
||||
const tabIndex = parseInt(activeTab);
|
||||
return filesWithUrls[tabIndex] || null;
|
||||
}
|
||||
}, [previewFile, filesWithUrls, activeTab]);
|
||||
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
const userInitiatedRef = useRef(false);
|
||||
const suppressScrollRef = useRef(false);
|
||||
const pdfDocRef = useRef<any>(null);
|
||||
const renderingPagesRef = useRef<Set<number>>(new Set());
|
||||
const currentArrayBufferRef = useRef<ArrayBuffer | null>(null);
|
||||
const preloadingRef = useRef<boolean>(false);
|
||||
|
||||
// Function to render a specific page on-demand
|
||||
const renderPage = async (pageIndex: number): Promise<string | null> => {
|
||||
if (!pdfFile || !pdfDocRef.current || renderingPagesRef.current.has(pageIndex)) {
|
||||
if (!pdfDocRef.current || renderingPagesRef.current.has(pageIndex)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -194,70 +259,78 @@ const Viewer = ({
|
||||
return null;
|
||||
};
|
||||
|
||||
// Listen for hash changes and update currentPage
|
||||
useEffect(() => {
|
||||
function handleHashChange() {
|
||||
if (window.location.hash.startsWith("#page=")) {
|
||||
const page = parseInt(window.location.hash.replace("#page=", ""), 10);
|
||||
if (!isNaN(page) && page >= 1 && page <= numPages) {
|
||||
setCurrentPage(page);
|
||||
}
|
||||
// Progressive preloading function
|
||||
const startProgressivePreload = async () => {
|
||||
if (!pdfDocRef.current || preloadingRef.current || numPages === 0) return;
|
||||
|
||||
preloadingRef.current = true;
|
||||
|
||||
// Start with first few pages for immediate viewing
|
||||
const priorityPages = [0, 1, 2, 3, 4]; // First 5 pages
|
||||
|
||||
// Render priority pages first
|
||||
for (const pageIndex of priorityPages) {
|
||||
if (pageIndex < numPages && !pageImages[pageIndex]) {
|
||||
await renderPage(pageIndex);
|
||||
// Small delay to allow UI to update
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
userInitiatedRef.current = false;
|
||||
}
|
||||
window.addEventListener("hashchange", handleHashChange);
|
||||
handleHashChange(); // Run on mount
|
||||
return () => window.removeEventListener("hashchange", handleHashChange);
|
||||
}, [numPages]);
|
||||
|
||||
// Scroll to the current page when it changes
|
||||
useEffect(() => {
|
||||
if (currentPage && pageRefs.current[currentPage - 1]) {
|
||||
suppressScrollRef.current = true;
|
||||
const el = pageRefs.current[currentPage - 1];
|
||||
el?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
|
||||
// Try to use scrollend if supported
|
||||
const viewport = scrollAreaRef.current;
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
let scrollEndHandler: (() => void) | null = null;
|
||||
|
||||
if (viewport && "onscrollend" in viewport) {
|
||||
scrollEndHandler = () => {
|
||||
suppressScrollRef.current = false;
|
||||
viewport.removeEventListener("scrollend", scrollEndHandler!);
|
||||
};
|
||||
viewport.addEventListener("scrollend", scrollEndHandler);
|
||||
} else {
|
||||
// Fallback for non-Chromium browsers
|
||||
timeout = setTimeout(() => {
|
||||
suppressScrollRef.current = false;
|
||||
}, 1000);
|
||||
|
||||
// Then render remaining pages in background
|
||||
for (let pageIndex = 5; pageIndex < numPages; pageIndex++) {
|
||||
if (!pageImages[pageIndex]) {
|
||||
await renderPage(pageIndex);
|
||||
// Longer delay for background loading to not block UI
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (viewport && scrollEndHandler) {
|
||||
viewport.removeEventListener("scrollend", scrollEndHandler);
|
||||
}
|
||||
if (timeout) clearTimeout(timeout);
|
||||
};
|
||||
}
|
||||
}, [currentPage, pageImages]);
|
||||
|
||||
preloadingRef.current = false;
|
||||
};
|
||||
|
||||
// Detect visible page on scroll and update hash
|
||||
const handleScroll = () => {
|
||||
if (suppressScrollRef.current) return;
|
||||
// Initialize current page when PDF loads
|
||||
useEffect(() => {
|
||||
if (numPages > 0 && !currentPage) {
|
||||
setCurrentPage(1);
|
||||
}
|
||||
}, [numPages, currentPage]);
|
||||
|
||||
// Function to scroll to a specific page
|
||||
const scrollToPage = (pageNumber: number) => {
|
||||
const el = pageRefs.current[pageNumber - 1];
|
||||
const scrollArea = scrollAreaRef.current;
|
||||
|
||||
if (el && scrollArea) {
|
||||
const scrollAreaRect = scrollArea.getBoundingClientRect();
|
||||
const elRect = el.getBoundingClientRect();
|
||||
const currentScrollTop = scrollArea.scrollTop;
|
||||
|
||||
// Position page near top of viewport with some padding
|
||||
const targetScrollTop = currentScrollTop + (elRect.top - scrollAreaRect.top) - 20;
|
||||
|
||||
scrollArea.scrollTo({
|
||||
top: targetScrollTop,
|
||||
behavior: "smooth"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Throttled scroll handler to prevent jerky updates
|
||||
const handleScrollThrottled = useCallback(() => {
|
||||
const scrollArea = scrollAreaRef.current;
|
||||
if (!scrollArea || !pageRefs.current.length) return;
|
||||
|
||||
const areaRect = scrollArea.getBoundingClientRect();
|
||||
const viewportCenter = areaRect.top + areaRect.height / 2;
|
||||
let closestIdx = 0;
|
||||
let minDist = Infinity;
|
||||
|
||||
pageRefs.current.forEach((img, idx) => {
|
||||
if (img) {
|
||||
const imgRect = img.getBoundingClientRect();
|
||||
const dist = Math.abs(imgRect.top - areaRect.top);
|
||||
const imgCenter = imgRect.top + imgRect.height / 2;
|
||||
const dist = Math.abs(imgCenter - viewportCenter);
|
||||
if (dist < minDist) {
|
||||
minDist = dist;
|
||||
closestIdx = idx;
|
||||
@ -265,30 +338,41 @@ const Viewer = ({
|
||||
}
|
||||
});
|
||||
|
||||
// Update page number display only if changed
|
||||
if (currentPage !== closestIdx + 1) {
|
||||
setCurrentPage(closestIdx + 1);
|
||||
if (window.location.hash !== `#page=${closestIdx + 1}`) {
|
||||
window.location.hash = `#page=${closestIdx + 1}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [currentPage]);
|
||||
|
||||
// Throttle scroll events to reduce jerkiness
|
||||
const handleScroll = useCallback(() => {
|
||||
if (window.requestAnimationFrame) {
|
||||
window.requestAnimationFrame(handleScrollThrottled);
|
||||
} else {
|
||||
handleScrollThrottled();
|
||||
}
|
||||
}, [handleScrollThrottled]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function loadPdfInfo() {
|
||||
if (!pdfFile || !pdfFile.url) {
|
||||
if (!effectiveFile) {
|
||||
setNumPages(0);
|
||||
setPageImages([]);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
let pdfUrl = pdfFile.url;
|
||||
|
||||
let pdfData;
|
||||
|
||||
// For preview files, use ArrayBuffer directly to avoid blob URL issues
|
||||
if (previewFile && effectiveFile.file === previewFile) {
|
||||
const arrayBuffer = await previewFile.arrayBuffer();
|
||||
pdfData = { data: arrayBuffer };
|
||||
}
|
||||
// Handle special IndexedDB URLs for large files
|
||||
if (pdfFile.url.startsWith('indexeddb:')) {
|
||||
const fileId = pdfFile.url.replace('indexeddb:', '');
|
||||
console.log('Loading large file from IndexedDB:', fileId);
|
||||
else if (effectiveFile.url?.startsWith('indexeddb:')) {
|
||||
const fileId = effectiveFile.url.replace('indexeddb:', '');
|
||||
|
||||
// Get data directly from IndexedDB
|
||||
const arrayBuffer = await fileStorage.getFileData(fileId);
|
||||
@ -298,21 +382,23 @@ const Viewer = ({
|
||||
|
||||
// Store reference for cleanup
|
||||
currentArrayBufferRef.current = arrayBuffer;
|
||||
|
||||
// Use ArrayBuffer directly instead of creating blob URL
|
||||
const pdf = await getDocument({ data: arrayBuffer }).promise;
|
||||
pdfDocRef.current = pdf;
|
||||
setNumPages(pdf.numPages);
|
||||
if (!cancelled) setPageImages(new Array(pdf.numPages).fill(null));
|
||||
} else {
|
||||
pdfData = { data: arrayBuffer };
|
||||
} else if (effectiveFile.url) {
|
||||
// Standard blob URL or regular URL
|
||||
const pdf = await getDocument(pdfUrl).promise;
|
||||
pdfDocRef.current = pdf;
|
||||
setNumPages(pdf.numPages);
|
||||
if (!cancelled) setPageImages(new Array(pdf.numPages).fill(null));
|
||||
pdfData = effectiveFile.url;
|
||||
} else {
|
||||
throw new Error('No valid PDF source available');
|
||||
}
|
||||
|
||||
const pdf = await getDocument(pdfData).promise;
|
||||
pdfDocRef.current = pdf;
|
||||
setNumPages(pdf.numPages);
|
||||
if (!cancelled) {
|
||||
setPageImages(new Array(pdf.numPages).fill(null));
|
||||
// Start progressive preloading after a short delay
|
||||
setTimeout(() => startProgressivePreload(), 100);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load PDF:', error);
|
||||
if (!cancelled) {
|
||||
setPageImages([]);
|
||||
setNumPages(0);
|
||||
@ -323,10 +409,12 @@ const Viewer = ({
|
||||
loadPdfInfo();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
// Stop any ongoing preloading
|
||||
preloadingRef.current = false;
|
||||
// Cleanup ArrayBuffer reference to help garbage collection
|
||||
currentArrayBufferRef.current = null;
|
||||
};
|
||||
}, [pdfFile]);
|
||||
}, [effectiveFile, previewFile]);
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = scrollAreaRef.current;
|
||||
@ -339,39 +427,62 @@ const Viewer = ({
|
||||
}, [pageImages]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!pdfFile ? (
|
||||
<Box style={{ position: 'relative', height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Close Button - Only show in preview mode */}
|
||||
{onClose && previewFile && (
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="gray"
|
||||
size="lg"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '1rem',
|
||||
right: '1rem',
|
||||
zIndex: 1000,
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<CloseIcon />
|
||||
</ActionIcon>
|
||||
)}
|
||||
|
||||
{!effectiveFile ? (
|
||||
<Center style={{ flex: 1 }}>
|
||||
<Stack align="center">
|
||||
<Text c="dimmed">{t("viewer.noPdfLoaded", "No PDF loaded. Click to upload a PDF.")}</Text>
|
||||
<Button
|
||||
component="label"
|
||||
variant="outline"
|
||||
color="blue"
|
||||
>
|
||||
{t("viewer.choosePdf", "Choose PDF")}
|
||||
<input
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file && file.type === "application/pdf") {
|
||||
const fileUrl = URL.createObjectURL(file);
|
||||
setPdfFile({ file, url: fileUrl });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Center>
|
||||
) : loading ? (
|
||||
<Center style={{ flex: 1 }}>
|
||||
<Loader size="lg" />
|
||||
<Text c="red">Error: No file provided to viewer</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<>
|
||||
{/* Tabs for multiple files */}
|
||||
{activeFiles.length > 1 && !previewFile && (
|
||||
<Box
|
||||
style={{
|
||||
borderBottom: '1px solid var(--mantine-color-gray-3)',
|
||||
backgroundColor: 'var(--mantine-color-body)',
|
||||
position: 'relative',
|
||||
zIndex: 100,
|
||||
marginTop: '60px' // Push tabs below TopControls
|
||||
}}
|
||||
>
|
||||
<Tabs value={activeTab} onChange={(value) => handleTabChange(value || "0")}>
|
||||
<Tabs.List>
|
||||
{activeFiles.map((file, index) => (
|
||||
<Tabs.Tab key={index} value={index.toString()}>
|
||||
{file.name.length > 20 ? `${file.name.substring(0, 20)}...` : file.name}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div style={{ flex: 1, padding: '1rem' }}>
|
||||
<SkeletonLoader type="viewer" />
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea
|
||||
style={{ flex: 1, height: "100vh", position: "relative"}}
|
||||
style={{ flex: 1, position: "relative"}}
|
||||
viewportRef={scrollAreaRef}
|
||||
>
|
||||
<Stack gap="xl" align="center" >
|
||||
@ -456,7 +567,7 @@ const Viewer = ({
|
||||
px={8}
|
||||
radius="xl"
|
||||
onClick={() => {
|
||||
window.location.hash = `#page=1`;
|
||||
scrollToPage(1);
|
||||
}}
|
||||
disabled={currentPage === 1}
|
||||
style={{ minWidth: 36 }}
|
||||
@ -470,7 +581,8 @@ const Viewer = ({
|
||||
px={8}
|
||||
radius="xl"
|
||||
onClick={() => {
|
||||
window.location.hash = `#page=${Math.max(1, (currentPage || 1) - 1)}`;
|
||||
const prevPage = Math.max(1, (currentPage || 1) - 1);
|
||||
scrollToPage(prevPage);
|
||||
}}
|
||||
disabled={currentPage === 1}
|
||||
style={{ minWidth: 36 }}
|
||||
@ -482,7 +594,7 @@ const Viewer = ({
|
||||
onChange={value => {
|
||||
const page = Number(value);
|
||||
if (!isNaN(page) && page >= 1 && page <= numPages) {
|
||||
window.location.hash = `#page=${page}`;
|
||||
scrollToPage(page);
|
||||
}
|
||||
}}
|
||||
min={1}
|
||||
@ -502,7 +614,8 @@ const Viewer = ({
|
||||
px={8}
|
||||
radius="xl"
|
||||
onClick={() => {
|
||||
window.location.hash = `#page=${Math.min(numPages, (currentPage || 1) + 1)}`;
|
||||
const nextPage = Math.min(numPages, (currentPage || 1) + 1);
|
||||
scrollToPage(nextPage);
|
||||
}}
|
||||
disabled={currentPage === numPages}
|
||||
style={{ minWidth: 36 }}
|
||||
@ -516,7 +629,7 @@ const Viewer = ({
|
||||
px={8}
|
||||
radius="xl"
|
||||
onClick={() => {
|
||||
window.location.hash = `#page=${numPages}`;
|
||||
scrollToPage(numPages);
|
||||
}}
|
||||
disabled={currentPage === numPages}
|
||||
style={{ minWidth: 36 }}
|
||||
@ -558,9 +671,11 @@ const Viewer = ({
|
||||
</Paper>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
</>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
22
frontend/src/constants/splitConstants.ts
Normal file
22
frontend/src/constants/splitConstants.ts
Normal file
@ -0,0 +1,22 @@
|
||||
export const SPLIT_MODES = {
|
||||
BY_PAGES: 'byPages',
|
||||
BY_SECTIONS: 'bySections',
|
||||
BY_SIZE_OR_COUNT: 'bySizeOrCount',
|
||||
BY_CHAPTERS: 'byChapters'
|
||||
} as const;
|
||||
|
||||
export const SPLIT_TYPES = {
|
||||
SIZE: 'size',
|
||||
PAGES: 'pages',
|
||||
DOCS: 'docs'
|
||||
} as const;
|
||||
|
||||
export const ENDPOINTS = {
|
||||
[SPLIT_MODES.BY_PAGES]: 'split-pages',
|
||||
[SPLIT_MODES.BY_SECTIONS]: 'split-pdf-by-sections',
|
||||
[SPLIT_MODES.BY_SIZE_OR_COUNT]: 'split-by-size-or-count',
|
||||
[SPLIT_MODES.BY_CHAPTERS]: 'split-pdf-by-chapters'
|
||||
} as const;
|
||||
|
||||
export type SplitMode = typeof SPLIT_MODES[keyof typeof SPLIT_MODES];
|
||||
export type SplitType = typeof SPLIT_TYPES[keyof typeof SPLIT_TYPES];
|
865
frontend/src/contexts/FileContext.tsx
Normal file
865
frontend/src/contexts/FileContext.tsx
Normal file
@ -0,0 +1,865 @@
|
||||
/**
|
||||
* Global file context for managing files, edits, and navigation across all views and tools
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useReducer, useCallback, useEffect, useRef } from 'react';
|
||||
import {
|
||||
FileContextValue,
|
||||
FileContextState,
|
||||
FileContextProviderProps,
|
||||
ModeType,
|
||||
ViewType,
|
||||
ToolType,
|
||||
FileOperation,
|
||||
FileEditHistory,
|
||||
FileOperationHistory,
|
||||
ViewerConfig,
|
||||
FileContextUrlParams
|
||||
} from '../types/fileContext';
|
||||
import { ProcessedFile } from '../types/processing';
|
||||
import { PageOperation, PDFDocument } from '../types/pageEditor';
|
||||
import { useEnhancedProcessedFiles } from '../hooks/useEnhancedProcessedFiles';
|
||||
import { fileStorage } from '../services/fileStorage';
|
||||
import { enhancedPDFProcessingService } from '../services/enhancedPDFProcessingService';
|
||||
import { thumbnailGenerationService } from '../services/thumbnailGenerationService';
|
||||
|
||||
// Initial state
|
||||
const initialViewerConfig: ViewerConfig = {
|
||||
zoom: 1.0,
|
||||
currentPage: 1,
|
||||
viewMode: 'single',
|
||||
sidebarOpen: false
|
||||
};
|
||||
|
||||
const initialState: FileContextState = {
|
||||
activeFiles: [],
|
||||
processedFiles: new Map(),
|
||||
currentMode: 'pageEditor',
|
||||
currentView: 'fileEditor', // Legacy field
|
||||
currentTool: null, // Legacy field
|
||||
fileEditHistory: new Map(),
|
||||
globalFileOperations: [],
|
||||
fileOperationHistory: new Map(),
|
||||
selectedFileIds: [],
|
||||
selectedPageNumbers: [],
|
||||
viewerConfig: initialViewerConfig,
|
||||
isProcessing: false,
|
||||
processingProgress: 0,
|
||||
lastExportConfig: undefined,
|
||||
hasUnsavedChanges: false,
|
||||
pendingNavigation: null,
|
||||
showNavigationWarning: false
|
||||
};
|
||||
|
||||
// Action types
|
||||
type FileContextAction =
|
||||
| { type: 'SET_ACTIVE_FILES'; payload: File[] }
|
||||
| { type: 'ADD_FILES'; payload: File[] }
|
||||
| { type: 'REMOVE_FILES'; payload: string[] }
|
||||
| { type: 'SET_PROCESSED_FILES'; payload: Map<File, ProcessedFile> }
|
||||
| { type: 'UPDATE_PROCESSED_FILE'; payload: { file: File; processedFile: ProcessedFile } }
|
||||
| { type: 'SET_CURRENT_MODE'; payload: ModeType }
|
||||
| { type: 'SET_CURRENT_VIEW'; payload: ViewType }
|
||||
| { type: 'SET_CURRENT_TOOL'; payload: ToolType }
|
||||
| { type: 'SET_SELECTED_FILES'; payload: string[] }
|
||||
| { type: 'SET_SELECTED_PAGES'; payload: number[] }
|
||||
| { type: 'CLEAR_SELECTIONS' }
|
||||
| { type: 'SET_PROCESSING'; payload: { isProcessing: boolean; progress: number } }
|
||||
| { type: 'UPDATE_VIEWER_CONFIG'; payload: Partial<ViewerConfig> }
|
||||
| { type: 'ADD_PAGE_OPERATIONS'; payload: { fileId: string; operations: PageOperation[] } }
|
||||
| { type: 'ADD_FILE_OPERATION'; payload: FileOperation }
|
||||
| { type: 'RECORD_OPERATION'; payload: { fileId: string; operation: FileOperation | PageOperation } }
|
||||
| { type: 'MARK_OPERATION_APPLIED'; payload: { fileId: string; operationId: string } }
|
||||
| { type: 'MARK_OPERATION_FAILED'; payload: { fileId: string; operationId: string; error: string } }
|
||||
| { type: 'CLEAR_FILE_HISTORY'; payload: string }
|
||||
| { type: 'SET_EXPORT_CONFIG'; payload: FileContextState['lastExportConfig'] }
|
||||
| { type: 'SET_UNSAVED_CHANGES'; payload: boolean }
|
||||
| { type: 'SET_PENDING_NAVIGATION'; payload: (() => void) | null }
|
||||
| { type: 'SHOW_NAVIGATION_WARNING'; payload: boolean }
|
||||
| { type: 'RESET_CONTEXT' }
|
||||
| { type: 'LOAD_STATE'; payload: Partial<FileContextState> };
|
||||
|
||||
// Reducer
|
||||
function fileContextReducer(state: FileContextState, action: FileContextAction): FileContextState {
|
||||
switch (action.type) {
|
||||
case 'SET_ACTIVE_FILES':
|
||||
return {
|
||||
...state,
|
||||
activeFiles: action.payload,
|
||||
selectedFileIds: [], // Clear selections when files change
|
||||
selectedPageNumbers: []
|
||||
};
|
||||
|
||||
case 'ADD_FILES':
|
||||
return {
|
||||
...state,
|
||||
activeFiles: [...state.activeFiles, ...action.payload]
|
||||
};
|
||||
|
||||
case 'REMOVE_FILES':
|
||||
const remainingFiles = state.activeFiles.filter(file => {
|
||||
const fileId = (file as any).id || file.name;
|
||||
return !action.payload.includes(fileId);
|
||||
});
|
||||
const safeSelectedFileIds = Array.isArray(state.selectedFileIds) ? state.selectedFileIds : [];
|
||||
return {
|
||||
...state,
|
||||
activeFiles: remainingFiles,
|
||||
selectedFileIds: safeSelectedFileIds.filter(id => !action.payload.includes(id))
|
||||
};
|
||||
|
||||
case 'SET_PROCESSED_FILES':
|
||||
return {
|
||||
...state,
|
||||
processedFiles: action.payload
|
||||
};
|
||||
|
||||
case 'UPDATE_PROCESSED_FILE':
|
||||
const updatedProcessedFiles = new Map(state.processedFiles);
|
||||
updatedProcessedFiles.set(action.payload.file, action.payload.processedFile);
|
||||
return {
|
||||
...state,
|
||||
processedFiles: updatedProcessedFiles
|
||||
};
|
||||
|
||||
case 'SET_CURRENT_MODE':
|
||||
const coreViews = ['viewer', 'pageEditor', 'fileEditor'];
|
||||
const isToolMode = !coreViews.includes(action.payload);
|
||||
|
||||
return {
|
||||
...state,
|
||||
currentMode: action.payload,
|
||||
// Update legacy fields for backward compatibility
|
||||
currentView: isToolMode ? 'fileEditor' : action.payload as ViewType,
|
||||
currentTool: isToolMode ? action.payload as ToolType : null
|
||||
};
|
||||
|
||||
case 'SET_CURRENT_VIEW':
|
||||
// Legacy action - just update currentMode
|
||||
return {
|
||||
...state,
|
||||
currentMode: action.payload as ModeType,
|
||||
currentView: action.payload,
|
||||
currentTool: null
|
||||
};
|
||||
|
||||
case 'SET_CURRENT_TOOL':
|
||||
// Legacy action - just update currentMode
|
||||
return {
|
||||
...state,
|
||||
currentMode: action.payload ? action.payload as ModeType : 'pageEditor',
|
||||
currentView: action.payload ? 'fileEditor' : 'pageEditor',
|
||||
currentTool: action.payload
|
||||
};
|
||||
|
||||
case 'SET_SELECTED_FILES':
|
||||
return {
|
||||
...state,
|
||||
selectedFileIds: action.payload
|
||||
};
|
||||
|
||||
case 'SET_SELECTED_PAGES':
|
||||
return {
|
||||
...state,
|
||||
selectedPageNumbers: action.payload
|
||||
};
|
||||
|
||||
case 'CLEAR_SELECTIONS':
|
||||
return {
|
||||
...state,
|
||||
selectedFileIds: [],
|
||||
selectedPageNumbers: []
|
||||
};
|
||||
|
||||
case 'SET_PROCESSING':
|
||||
return {
|
||||
...state,
|
||||
isProcessing: action.payload.isProcessing,
|
||||
processingProgress: action.payload.progress
|
||||
};
|
||||
|
||||
case 'UPDATE_VIEWER_CONFIG':
|
||||
return {
|
||||
...state,
|
||||
viewerConfig: {
|
||||
...state.viewerConfig,
|
||||
...action.payload
|
||||
}
|
||||
};
|
||||
|
||||
case 'ADD_PAGE_OPERATIONS':
|
||||
const newHistory = new Map(state.fileEditHistory);
|
||||
const existing = newHistory.get(action.payload.fileId);
|
||||
newHistory.set(action.payload.fileId, {
|
||||
fileId: action.payload.fileId,
|
||||
pageOperations: existing ?
|
||||
[...existing.pageOperations, ...action.payload.operations] :
|
||||
action.payload.operations,
|
||||
lastModified: Date.now()
|
||||
});
|
||||
return {
|
||||
...state,
|
||||
fileEditHistory: newHistory
|
||||
};
|
||||
|
||||
case 'ADD_FILE_OPERATION':
|
||||
return {
|
||||
...state,
|
||||
globalFileOperations: [...state.globalFileOperations, action.payload]
|
||||
};
|
||||
|
||||
case 'RECORD_OPERATION':
|
||||
const { fileId, operation } = action.payload;
|
||||
const newOperationHistory = new Map(state.fileOperationHistory);
|
||||
const existingHistory = newOperationHistory.get(fileId);
|
||||
|
||||
if (existingHistory) {
|
||||
// Add operation to existing history
|
||||
newOperationHistory.set(fileId, {
|
||||
...existingHistory,
|
||||
operations: [...existingHistory.operations, operation],
|
||||
lastModified: Date.now()
|
||||
});
|
||||
} else {
|
||||
// Create new history for this file
|
||||
newOperationHistory.set(fileId, {
|
||||
fileId,
|
||||
fileName: fileId, // Will be updated with actual filename when available
|
||||
operations: [operation],
|
||||
createdAt: Date.now(),
|
||||
lastModified: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
fileOperationHistory: newOperationHistory
|
||||
};
|
||||
|
||||
case 'MARK_OPERATION_APPLIED':
|
||||
const appliedHistory = new Map(state.fileOperationHistory);
|
||||
const appliedFileHistory = appliedHistory.get(action.payload.fileId);
|
||||
|
||||
if (appliedFileHistory) {
|
||||
const updatedOperations = appliedFileHistory.operations.map(op =>
|
||||
op.id === action.payload.operationId
|
||||
? { ...op, status: 'applied' as const }
|
||||
: op
|
||||
);
|
||||
appliedHistory.set(action.payload.fileId, {
|
||||
...appliedFileHistory,
|
||||
operations: updatedOperations,
|
||||
lastModified: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
fileOperationHistory: appliedHistory
|
||||
};
|
||||
|
||||
case 'MARK_OPERATION_FAILED':
|
||||
const failedHistory = new Map(state.fileOperationHistory);
|
||||
const failedFileHistory = failedHistory.get(action.payload.fileId);
|
||||
|
||||
if (failedFileHistory) {
|
||||
const updatedOperations = failedFileHistory.operations.map(op =>
|
||||
op.id === action.payload.operationId
|
||||
? {
|
||||
...op,
|
||||
status: 'failed' as const,
|
||||
metadata: { ...op.metadata, error: action.payload.error }
|
||||
}
|
||||
: op
|
||||
);
|
||||
failedHistory.set(action.payload.fileId, {
|
||||
...failedFileHistory,
|
||||
operations: updatedOperations,
|
||||
lastModified: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
fileOperationHistory: failedHistory
|
||||
};
|
||||
|
||||
case 'CLEAR_FILE_HISTORY':
|
||||
const clearedHistory = new Map(state.fileOperationHistory);
|
||||
clearedHistory.delete(action.payload);
|
||||
return {
|
||||
...state,
|
||||
fileOperationHistory: clearedHistory
|
||||
};
|
||||
|
||||
case 'SET_EXPORT_CONFIG':
|
||||
return {
|
||||
...state,
|
||||
lastExportConfig: action.payload
|
||||
};
|
||||
|
||||
case 'SET_UNSAVED_CHANGES':
|
||||
return {
|
||||
...state,
|
||||
hasUnsavedChanges: action.payload
|
||||
};
|
||||
|
||||
case 'SET_PENDING_NAVIGATION':
|
||||
return {
|
||||
...state,
|
||||
pendingNavigation: action.payload
|
||||
};
|
||||
|
||||
case 'SHOW_NAVIGATION_WARNING':
|
||||
return {
|
||||
...state,
|
||||
showNavigationWarning: action.payload
|
||||
};
|
||||
|
||||
case 'RESET_CONTEXT':
|
||||
return {
|
||||
...initialState
|
||||
};
|
||||
|
||||
case 'LOAD_STATE':
|
||||
return {
|
||||
...state,
|
||||
...action.payload
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
// Context
|
||||
const FileContext = createContext<FileContextValue | undefined>(undefined);
|
||||
|
||||
// Provider component
|
||||
export function FileContextProvider({
|
||||
children,
|
||||
enableUrlSync = true,
|
||||
enablePersistence = true,
|
||||
maxCacheSize = 1024 * 1024 * 1024 // 1GB
|
||||
}: FileContextProviderProps) {
|
||||
const [state, dispatch] = useReducer(fileContextReducer, initialState);
|
||||
|
||||
// Cleanup timers and refs
|
||||
const cleanupTimers = useRef<Map<string, NodeJS.Timeout>>(new Map());
|
||||
const blobUrls = useRef<Set<string>>(new Set());
|
||||
const pdfDocuments = useRef<Map<string, any>>(new Map());
|
||||
|
||||
// Enhanced file processing hook
|
||||
const {
|
||||
processedFiles,
|
||||
processingStates,
|
||||
isProcessing: globalProcessing,
|
||||
processingProgress,
|
||||
actions: processingActions
|
||||
} = useEnhancedProcessedFiles(state.activeFiles, {
|
||||
strategy: 'progressive_chunked',
|
||||
thumbnailQuality: 'medium',
|
||||
chunkSize: 5, // Process 5 pages at a time for smooth progress
|
||||
priorityPageCount: 0 // No special priority pages
|
||||
});
|
||||
|
||||
// Update processed files when they change
|
||||
useEffect(() => {
|
||||
dispatch({ type: 'SET_PROCESSED_FILES', payload: processedFiles });
|
||||
dispatch({
|
||||
type: 'SET_PROCESSING',
|
||||
payload: {
|
||||
isProcessing: globalProcessing,
|
||||
progress: processingProgress.overall
|
||||
}
|
||||
});
|
||||
}, [processedFiles, globalProcessing, processingProgress.overall]);
|
||||
|
||||
|
||||
// Centralized memory management
|
||||
const trackBlobUrl = useCallback((url: string) => {
|
||||
blobUrls.current.add(url);
|
||||
}, []);
|
||||
|
||||
const trackPdfDocument = useCallback((fileId: string, pdfDoc: any) => {
|
||||
// Clean up existing document for this file if any
|
||||
const existing = pdfDocuments.current.get(fileId);
|
||||
if (existing && existing.destroy) {
|
||||
try {
|
||||
existing.destroy();
|
||||
} catch (error) {
|
||||
console.warn('Error destroying existing PDF document:', error);
|
||||
}
|
||||
}
|
||||
pdfDocuments.current.set(fileId, pdfDoc);
|
||||
}, []);
|
||||
|
||||
const cleanupFile = useCallback(async (fileId: string) => {
|
||||
console.log('Cleaning up file:', fileId);
|
||||
|
||||
try {
|
||||
// Cancel any pending cleanup timer
|
||||
const timer = cleanupTimers.current.get(fileId);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
cleanupTimers.current.delete(fileId);
|
||||
}
|
||||
|
||||
// Cleanup PDF document instances (but preserve processed file cache)
|
||||
const pdfDoc = pdfDocuments.current.get(fileId);
|
||||
if (pdfDoc && pdfDoc.destroy) {
|
||||
pdfDoc.destroy();
|
||||
pdfDocuments.current.delete(fileId);
|
||||
}
|
||||
|
||||
// IMPORTANT: Don't cancel processing or clear cache during normal view switches
|
||||
// Only do this when file is actually being removed
|
||||
// enhancedPDFProcessingService.cancelProcessing(fileId);
|
||||
// thumbnailGenerationService.stopGeneration();
|
||||
|
||||
} catch (error) {
|
||||
console.warn('Error during file cleanup:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const cleanupAllFiles = useCallback(() => {
|
||||
console.log('Cleaning up all files');
|
||||
|
||||
try {
|
||||
// Clear all timers
|
||||
cleanupTimers.current.forEach(timer => clearTimeout(timer));
|
||||
cleanupTimers.current.clear();
|
||||
|
||||
// Destroy all PDF documents
|
||||
pdfDocuments.current.forEach((pdfDoc, fileId) => {
|
||||
if (pdfDoc && pdfDoc.destroy) {
|
||||
try {
|
||||
pdfDoc.destroy();
|
||||
} catch (error) {
|
||||
console.warn(`Error destroying PDF document for ${fileId}:`, error);
|
||||
}
|
||||
}
|
||||
});
|
||||
pdfDocuments.current.clear();
|
||||
|
||||
// Revoke all blob URLs
|
||||
blobUrls.current.forEach(url => {
|
||||
try {
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.warn('Error revoking blob URL:', error);
|
||||
}
|
||||
});
|
||||
blobUrls.current.clear();
|
||||
|
||||
// Clear all processing
|
||||
enhancedPDFProcessingService.clearAllProcessing();
|
||||
|
||||
// Destroy thumbnails
|
||||
thumbnailGenerationService.destroy();
|
||||
|
||||
// Force garbage collection hint
|
||||
if (typeof window !== 'undefined' && window.gc) {
|
||||
setTimeout(() => window.gc(), 100);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.warn('Error during cleanup all files:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scheduleCleanup = useCallback((fileId: string, delay: number = 30000) => {
|
||||
// Cancel existing timer
|
||||
const existingTimer = cleanupTimers.current.get(fileId);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
cleanupTimers.current.delete(fileId);
|
||||
}
|
||||
|
||||
// If delay is negative, just cancel (don't reschedule)
|
||||
if (delay < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Schedule new cleanup
|
||||
const timer = setTimeout(() => {
|
||||
cleanupFile(fileId);
|
||||
}, delay);
|
||||
|
||||
cleanupTimers.current.set(fileId, timer);
|
||||
}, [cleanupFile]);
|
||||
|
||||
// Action implementations
|
||||
const addFiles = useCallback(async (files: File[]) => {
|
||||
dispatch({ type: 'ADD_FILES', payload: files });
|
||||
|
||||
// Auto-save to IndexedDB if persistence enabled
|
||||
if (enablePersistence) {
|
||||
for (const file of files) {
|
||||
try {
|
||||
// Check if file already has an ID (already in IndexedDB)
|
||||
const fileId = (file as any).id;
|
||||
if (!fileId) {
|
||||
// File doesn't have ID, store it and get the ID
|
||||
const storedFile = await fileStorage.storeFile(file);
|
||||
// Add the ID to the file object
|
||||
Object.defineProperty(file, 'id', { value: storedFile.id, writable: false });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to store file:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [enablePersistence]);
|
||||
|
||||
const removeFiles = useCallback((fileIds: string[], deleteFromStorage: boolean = true) => {
|
||||
// FULL cleanup for actually removed files (including cache)
|
||||
fileIds.forEach(fileId => {
|
||||
// Cancel processing and clear caches when file is actually removed
|
||||
enhancedPDFProcessingService.cancelProcessing(fileId);
|
||||
cleanupFile(fileId);
|
||||
});
|
||||
|
||||
dispatch({ type: 'REMOVE_FILES', payload: fileIds });
|
||||
|
||||
// Remove from IndexedDB only if requested
|
||||
if (enablePersistence && deleteFromStorage) {
|
||||
fileIds.forEach(async (fileId) => {
|
||||
try {
|
||||
await fileStorage.deleteFile(fileId);
|
||||
} catch (error) {
|
||||
console.error('Failed to remove file from storage:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [enablePersistence, cleanupFile]);
|
||||
|
||||
|
||||
const replaceFile = useCallback(async (oldFileId: string, newFile: File) => {
|
||||
// Remove old file and add new one
|
||||
removeFiles([oldFileId]);
|
||||
await addFiles([newFile]);
|
||||
}, [removeFiles, addFiles]);
|
||||
|
||||
const clearAllFiles = useCallback(() => {
|
||||
// Cleanup all memory before clearing files
|
||||
cleanupAllFiles();
|
||||
|
||||
dispatch({ type: 'SET_ACTIVE_FILES', payload: [] });
|
||||
dispatch({ type: 'CLEAR_SELECTIONS' });
|
||||
}, [cleanupAllFiles]);
|
||||
|
||||
// Navigation guard system functions
|
||||
const setHasUnsavedChanges = useCallback((hasChanges: boolean) => {
|
||||
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: hasChanges });
|
||||
}, []);
|
||||
|
||||
const requestNavigation = useCallback((navigationFn: () => void): boolean => {
|
||||
if (state.hasUnsavedChanges) {
|
||||
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: navigationFn });
|
||||
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: true });
|
||||
return false;
|
||||
} else {
|
||||
navigationFn();
|
||||
return true;
|
||||
}
|
||||
}, [state.hasUnsavedChanges]);
|
||||
|
||||
const confirmNavigation = useCallback(() => {
|
||||
if (state.pendingNavigation) {
|
||||
state.pendingNavigation();
|
||||
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: null });
|
||||
}
|
||||
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: false });
|
||||
}, [state.pendingNavigation]);
|
||||
|
||||
const cancelNavigation = useCallback(() => {
|
||||
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: null });
|
||||
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: false });
|
||||
}, []);
|
||||
|
||||
const setCurrentMode = useCallback((mode: ModeType) => {
|
||||
requestNavigation(() => {
|
||||
dispatch({ type: 'SET_CURRENT_MODE', payload: mode });
|
||||
|
||||
if (state.currentMode !== mode && state.activeFiles.length > 0) {
|
||||
if (window.requestIdleCallback && typeof window !== 'undefined' && window.gc) {
|
||||
window.requestIdleCallback(() => {
|
||||
window.gc();
|
||||
}, { timeout: 5000 });
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [requestNavigation, state.currentMode, state.activeFiles]);
|
||||
|
||||
const setCurrentView = useCallback((view: ViewType) => {
|
||||
requestNavigation(() => {
|
||||
dispatch({ type: 'SET_CURRENT_VIEW', payload: view });
|
||||
|
||||
if (state.currentView !== view && state.activeFiles.length > 0) {
|
||||
if (window.requestIdleCallback && typeof window !== 'undefined' && window.gc) {
|
||||
window.requestIdleCallback(() => {
|
||||
window.gc();
|
||||
}, { timeout: 5000 });
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [requestNavigation, state.currentView, state.activeFiles]);
|
||||
|
||||
const setCurrentTool = useCallback((tool: ToolType) => {
|
||||
requestNavigation(() => {
|
||||
dispatch({ type: 'SET_CURRENT_TOOL', payload: tool });
|
||||
});
|
||||
}, [requestNavigation]);
|
||||
|
||||
const setSelectedFiles = useCallback((fileIds: string[]) => {
|
||||
dispatch({ type: 'SET_SELECTED_FILES', payload: fileIds });
|
||||
}, []);
|
||||
|
||||
const setSelectedPages = useCallback((pageNumbers: number[]) => {
|
||||
dispatch({ type: 'SET_SELECTED_PAGES', payload: pageNumbers });
|
||||
}, []);
|
||||
|
||||
const updateProcessedFile = useCallback((file: File, processedFile: ProcessedFile) => {
|
||||
dispatch({ type: 'UPDATE_PROCESSED_FILE', payload: { file, processedFile } });
|
||||
}, []);
|
||||
|
||||
const clearSelections = useCallback(() => {
|
||||
dispatch({ type: 'CLEAR_SELECTIONS' });
|
||||
}, []);
|
||||
|
||||
const applyPageOperations = useCallback((fileId: string, operations: PageOperation[]) => {
|
||||
dispatch({
|
||||
type: 'ADD_PAGE_OPERATIONS',
|
||||
payload: { fileId, operations }
|
||||
});
|
||||
}, []);
|
||||
|
||||
const applyFileOperation = useCallback((operation: FileOperation) => {
|
||||
dispatch({ type: 'ADD_FILE_OPERATION', payload: operation });
|
||||
}, []);
|
||||
|
||||
const undoLastOperation = useCallback((fileId?: string) => {
|
||||
console.warn('Undo not yet implemented');
|
||||
}, []);
|
||||
|
||||
const updateViewerConfig = useCallback((config: Partial<ViewerConfig>) => {
|
||||
dispatch({ type: 'UPDATE_VIEWER_CONFIG', payload: config });
|
||||
}, []);
|
||||
|
||||
const setExportConfig = useCallback((config: FileContextState['lastExportConfig']) => {
|
||||
dispatch({ type: 'SET_EXPORT_CONFIG', payload: config });
|
||||
}, []);
|
||||
|
||||
// Operation history management functions
|
||||
const recordOperation = useCallback((fileId: string, operation: FileOperation | PageOperation) => {
|
||||
dispatch({ type: 'RECORD_OPERATION', payload: { fileId, operation } });
|
||||
}, []);
|
||||
|
||||
const markOperationApplied = useCallback((fileId: string, operationId: string) => {
|
||||
dispatch({ type: 'MARK_OPERATION_APPLIED', payload: { fileId, operationId } });
|
||||
}, []);
|
||||
|
||||
const markOperationFailed = useCallback((fileId: string, operationId: string, error: string) => {
|
||||
dispatch({ type: 'MARK_OPERATION_FAILED', payload: { fileId, operationId, error } });
|
||||
}, []);
|
||||
|
||||
const getFileHistory = useCallback((fileId: string): FileOperationHistory | undefined => {
|
||||
return state.fileOperationHistory.get(fileId);
|
||||
}, [state.fileOperationHistory]);
|
||||
|
||||
const getAppliedOperations = useCallback((fileId: string): (FileOperation | PageOperation)[] => {
|
||||
const history = state.fileOperationHistory.get(fileId);
|
||||
return history ? history.operations.filter(op => op.status === 'applied') : [];
|
||||
}, [state.fileOperationHistory]);
|
||||
|
||||
const clearFileHistory = useCallback((fileId: string) => {
|
||||
dispatch({ type: 'CLEAR_FILE_HISTORY', payload: fileId });
|
||||
}, []);
|
||||
|
||||
// Utility functions
|
||||
const getFileById = useCallback((fileId: string): File | undefined => {
|
||||
return state.activeFiles.find(file => {
|
||||
const actualFileId = (file as any).id || file.name;
|
||||
return actualFileId === fileId;
|
||||
});
|
||||
}, [state.activeFiles]);
|
||||
|
||||
const getProcessedFileById = useCallback((fileId: string): ProcessedFile | undefined => {
|
||||
const file = getFileById(fileId);
|
||||
return file ? state.processedFiles.get(file) : undefined;
|
||||
}, [getFileById, state.processedFiles]);
|
||||
|
||||
const getCurrentFile = useCallback((): File | undefined => {
|
||||
if (state.selectedFileIds.length > 0) {
|
||||
return getFileById(state.selectedFileIds[0]);
|
||||
}
|
||||
return state.activeFiles[0]; // Default to first file
|
||||
}, [state.selectedFileIds, state.activeFiles, getFileById]);
|
||||
|
||||
const getCurrentProcessedFile = useCallback((): ProcessedFile | undefined => {
|
||||
const file = getCurrentFile();
|
||||
return file ? state.processedFiles.get(file) : undefined;
|
||||
}, [getCurrentFile, state.processedFiles]);
|
||||
|
||||
// Context persistence
|
||||
const saveContext = useCallback(async () => {
|
||||
if (!enablePersistence) return;
|
||||
|
||||
try {
|
||||
const contextData = {
|
||||
currentView: state.currentView,
|
||||
currentTool: state.currentTool,
|
||||
selectedFileIds: state.selectedFileIds,
|
||||
selectedPageIds: state.selectedPageIds,
|
||||
viewerConfig: state.viewerConfig,
|
||||
lastExportConfig: state.lastExportConfig,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
localStorage.setItem('fileContext', JSON.stringify(contextData));
|
||||
} catch (error) {
|
||||
console.error('Failed to save context:', error);
|
||||
}
|
||||
}, [state, enablePersistence]);
|
||||
|
||||
const loadContext = useCallback(async () => {
|
||||
if (!enablePersistence) return;
|
||||
|
||||
try {
|
||||
const saved = localStorage.getItem('fileContext');
|
||||
if (saved) {
|
||||
const contextData = JSON.parse(saved);
|
||||
dispatch({ type: 'LOAD_STATE', payload: contextData });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load context:', error);
|
||||
}
|
||||
}, [enablePersistence]);
|
||||
|
||||
const resetContext = useCallback(() => {
|
||||
dispatch({ type: 'RESET_CONTEXT' });
|
||||
if (enablePersistence) {
|
||||
localStorage.removeItem('fileContext');
|
||||
}
|
||||
}, [enablePersistence]);
|
||||
|
||||
|
||||
// Auto-save context when it changes
|
||||
useEffect(() => {
|
||||
saveContext();
|
||||
}, [saveContext]);
|
||||
|
||||
// Load context on mount
|
||||
useEffect(() => {
|
||||
loadContext();
|
||||
}, [loadContext]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
console.log('FileContext unmounting - cleaning up all resources');
|
||||
cleanupAllFiles();
|
||||
};
|
||||
}, [cleanupAllFiles]);
|
||||
|
||||
const contextValue: FileContextValue = {
|
||||
// State
|
||||
...state,
|
||||
|
||||
// Actions
|
||||
addFiles,
|
||||
removeFiles,
|
||||
replaceFile,
|
||||
clearAllFiles,
|
||||
setCurrentMode,
|
||||
setCurrentView,
|
||||
setCurrentTool,
|
||||
setSelectedFiles,
|
||||
setSelectedPages,
|
||||
updateProcessedFile,
|
||||
clearSelections,
|
||||
applyPageOperations,
|
||||
applyFileOperation,
|
||||
undoLastOperation,
|
||||
updateViewerConfig,
|
||||
setExportConfig,
|
||||
getFileById,
|
||||
getProcessedFileById,
|
||||
getCurrentFile,
|
||||
getCurrentProcessedFile,
|
||||
saveContext,
|
||||
loadContext,
|
||||
resetContext,
|
||||
|
||||
// Operation history management
|
||||
recordOperation,
|
||||
markOperationApplied,
|
||||
markOperationFailed,
|
||||
getFileHistory,
|
||||
getAppliedOperations,
|
||||
clearFileHistory,
|
||||
|
||||
// Navigation guard system
|
||||
setHasUnsavedChanges,
|
||||
requestNavigation,
|
||||
confirmNavigation,
|
||||
cancelNavigation,
|
||||
|
||||
// Memory management
|
||||
trackBlobUrl,
|
||||
trackPdfDocument,
|
||||
cleanupFile,
|
||||
scheduleCleanup
|
||||
};
|
||||
|
||||
return (
|
||||
<FileContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</FileContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// Custom hook to use the context
|
||||
export function useFileContext(): FileContextValue {
|
||||
const context = useContext(FileContext);
|
||||
if (!context) {
|
||||
throw new Error('useFileContext must be used within a FileContextProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
// Helper hooks for specific aspects
|
||||
export function useCurrentFile() {
|
||||
const { getCurrentFile, getCurrentProcessedFile } = useFileContext();
|
||||
return {
|
||||
file: getCurrentFile(),
|
||||
processedFile: getCurrentProcessedFile()
|
||||
};
|
||||
}
|
||||
|
||||
export function useFileSelection() {
|
||||
const {
|
||||
selectedFileIds,
|
||||
selectedPageIds,
|
||||
setSelectedFiles,
|
||||
setSelectedPages,
|
||||
clearSelections
|
||||
} = useFileContext();
|
||||
|
||||
return {
|
||||
selectedFileIds,
|
||||
selectedPageIds,
|
||||
setSelectedFiles,
|
||||
setSelectedPages,
|
||||
clearSelections
|
||||
};
|
||||
}
|
||||
|
||||
export function useViewerState() {
|
||||
const { viewerConfig, updateViewerConfig } = useFileContext();
|
||||
return {
|
||||
config: viewerConfig,
|
||||
updateConfig: updateViewerConfig
|
||||
};
|
||||
}
|
67
frontend/src/hooks/tools/shared/useOperationResults.ts
Normal file
67
frontend/src/hooks/tools/shared/useOperationResults.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export interface OperationResult {
|
||||
files: File[];
|
||||
thumbnails: string[];
|
||||
isGeneratingThumbnails: boolean;
|
||||
}
|
||||
|
||||
export interface OperationResultsHook {
|
||||
results: OperationResult;
|
||||
downloadUrl: string | null;
|
||||
status: string;
|
||||
errorMessage: string | null;
|
||||
isLoading: boolean;
|
||||
|
||||
setResults: (results: OperationResult) => void;
|
||||
setDownloadUrl: (url: string | null) => void;
|
||||
setStatus: (status: string) => void;
|
||||
setErrorMessage: (error: string | null) => void;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
|
||||
resetResults: () => void;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
const initialResults: OperationResult = {
|
||||
files: [],
|
||||
thumbnails: [],
|
||||
isGeneratingThumbnails: false,
|
||||
};
|
||||
|
||||
export const useOperationResults = (): OperationResultsHook => {
|
||||
const [results, setResults] = useState<OperationResult>(initialResults);
|
||||
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState('');
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const resetResults = useCallback(() => {
|
||||
setResults(initialResults);
|
||||
setDownloadUrl(null);
|
||||
setStatus('');
|
||||
setErrorMessage(null);
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setErrorMessage(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
results,
|
||||
downloadUrl,
|
||||
status,
|
||||
errorMessage,
|
||||
isLoading,
|
||||
|
||||
setResults,
|
||||
setDownloadUrl,
|
||||
setStatus,
|
||||
setErrorMessage,
|
||||
setIsLoading,
|
||||
|
||||
resetResults,
|
||||
clearError,
|
||||
};
|
||||
};
|
242
frontend/src/hooks/tools/split/useSplitOperation.ts
Normal file
242
frontend/src/hooks/tools/split/useSplitOperation.ts
Normal file
@ -0,0 +1,242 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import axios from 'axios';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFileContext } from '../../../contexts/FileContext';
|
||||
import { FileOperation } from '../../../types/fileContext';
|
||||
import { zipFileService } from '../../../services/zipFileService';
|
||||
import { generateThumbnailForFile } from '../../../utils/thumbnailUtils';
|
||||
import { SplitParameters } from '../../../components/tools/split/SplitSettings';
|
||||
import { SPLIT_MODES, ENDPOINTS, type SplitMode } from '../../../constants/splitConstants';
|
||||
|
||||
export interface SplitOperationHook {
|
||||
executeOperation: (
|
||||
mode: SplitMode | '',
|
||||
parameters: SplitParameters,
|
||||
selectedFiles: File[]
|
||||
) => Promise<void>;
|
||||
|
||||
// Flattened result properties for cleaner access
|
||||
files: File[];
|
||||
thumbnails: string[];
|
||||
isGeneratingThumbnails: boolean;
|
||||
downloadUrl: string | null;
|
||||
status: string;
|
||||
errorMessage: string | null;
|
||||
isLoading: boolean;
|
||||
|
||||
// Result management functions
|
||||
resetResults: () => void;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
export const useSplitOperation = (): SplitOperationHook => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
recordOperation,
|
||||
markOperationApplied,
|
||||
markOperationFailed,
|
||||
addFiles
|
||||
} = useFileContext();
|
||||
|
||||
// Internal state management (replacing useOperationResults)
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [thumbnails, setThumbnails] = useState<string[]>([]);
|
||||
const [isGeneratingThumbnails, setIsGeneratingThumbnails] = useState(false);
|
||||
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState('');
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const buildFormData = useCallback((
|
||||
mode: SplitMode | '',
|
||||
parameters: SplitParameters,
|
||||
selectedFiles: File[]
|
||||
) => {
|
||||
const formData = new FormData();
|
||||
|
||||
selectedFiles.forEach(file => {
|
||||
formData.append("fileInput", file);
|
||||
});
|
||||
|
||||
if (!mode) {
|
||||
throw new Error('Split mode is required');
|
||||
}
|
||||
|
||||
let endpoint = "";
|
||||
|
||||
switch (mode) {
|
||||
case SPLIT_MODES.BY_PAGES:
|
||||
formData.append("pageNumbers", parameters.pages);
|
||||
endpoint = "/api/v1/general/split-pages";
|
||||
break;
|
||||
case SPLIT_MODES.BY_SECTIONS:
|
||||
formData.append("horizontalDivisions", parameters.hDiv);
|
||||
formData.append("verticalDivisions", parameters.vDiv);
|
||||
formData.append("merge", parameters.merge.toString());
|
||||
endpoint = "/api/v1/general/split-pdf-by-sections";
|
||||
break;
|
||||
case SPLIT_MODES.BY_SIZE_OR_COUNT:
|
||||
formData.append(
|
||||
"splitType",
|
||||
parameters.splitType === "size" ? "0" : parameters.splitType === "pages" ? "1" : "2"
|
||||
);
|
||||
formData.append("splitValue", parameters.splitValue);
|
||||
endpoint = "/api/v1/general/split-by-size-or-count";
|
||||
break;
|
||||
case SPLIT_MODES.BY_CHAPTERS:
|
||||
formData.append("bookmarkLevel", parameters.bookmarkLevel);
|
||||
formData.append("includeMetadata", parameters.includeMetadata.toString());
|
||||
formData.append("allowDuplicates", parameters.allowDuplicates.toString());
|
||||
endpoint = "/api/v1/general/split-pdf-by-chapters";
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown split mode: ${mode}`);
|
||||
}
|
||||
|
||||
return { formData, endpoint };
|
||||
}, []);
|
||||
|
||||
const createOperation = useCallback((
|
||||
mode: SplitMode | '',
|
||||
parameters: SplitParameters,
|
||||
selectedFiles: File[]
|
||||
): { operation: FileOperation; operationId: string; fileId: string } => {
|
||||
const operationId = `split-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const fileId = selectedFiles[0].name;
|
||||
|
||||
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 === SPLIT_MODES.BY_PAGES ? parameters.pages : undefined,
|
||||
hDiv: mode === SPLIT_MODES.BY_SECTIONS ? parameters.hDiv : undefined,
|
||||
vDiv: mode === SPLIT_MODES.BY_SECTIONS ? parameters.vDiv : undefined,
|
||||
merge: mode === SPLIT_MODES.BY_SECTIONS ? parameters.merge : undefined,
|
||||
splitType: mode === SPLIT_MODES.BY_SIZE_OR_COUNT ? parameters.splitType : undefined,
|
||||
splitValue: mode === SPLIT_MODES.BY_SIZE_OR_COUNT ? parameters.splitValue : undefined,
|
||||
bookmarkLevel: mode === SPLIT_MODES.BY_CHAPTERS ? parameters.bookmarkLevel : undefined,
|
||||
includeMetadata: mode === SPLIT_MODES.BY_CHAPTERS ? parameters.includeMetadata : undefined,
|
||||
allowDuplicates: mode === SPLIT_MODES.BY_CHAPTERS ? parameters.allowDuplicates : undefined,
|
||||
},
|
||||
fileSize: selectedFiles[0].size
|
||||
}
|
||||
};
|
||||
|
||||
return { operation, operationId, fileId };
|
||||
}, []);
|
||||
|
||||
const processResults = useCallback(async (blob: Blob) => {
|
||||
try {
|
||||
const zipFile = new File([blob], "split_result.zip", { type: "application/zip" });
|
||||
const extractionResult = await zipFileService.extractPdfFiles(zipFile);
|
||||
|
||||
if (extractionResult.success && extractionResult.extractedFiles.length > 0) {
|
||||
// Set local state for preview
|
||||
setFiles(extractionResult.extractedFiles);
|
||||
setThumbnails([]);
|
||||
setIsGeneratingThumbnails(true);
|
||||
|
||||
// Add extracted files to FileContext for future use
|
||||
await addFiles(extractionResult.extractedFiles);
|
||||
|
||||
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 '';
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
setThumbnails(thumbnails);
|
||||
setIsGeneratingThumbnails(false);
|
||||
}
|
||||
} catch (extractError) {
|
||||
console.warn('Failed to extract files for preview:', extractError);
|
||||
}
|
||||
}, [addFiles]);
|
||||
|
||||
const executeOperation = useCallback(async (
|
||||
mode: SplitMode | '',
|
||||
parameters: SplitParameters,
|
||||
selectedFiles: File[]
|
||||
) => {
|
||||
if (selectedFiles.length === 0) {
|
||||
setStatus(t("noFileSelected"));
|
||||
return;
|
||||
}
|
||||
|
||||
const { operation, operationId, fileId } = createOperation(mode, parameters, selectedFiles);
|
||||
const { formData, endpoint } = buildFormData(mode, parameters, selectedFiles);
|
||||
|
||||
recordOperation(fileId, operation);
|
||||
|
||||
setStatus(t("loading"));
|
||||
setIsLoading(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await axios.post(endpoint, formData, { responseType: "blob" });
|
||||
const blob = new Blob([response.data], { type: "application/zip" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
|
||||
setDownloadUrl(url);
|
||||
setStatus(t("downloadComplete"));
|
||||
|
||||
await processResults(blob);
|
||||
markOperationApplied(fileId, operationId);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
let errorMsg = t("error.pdfPassword", "An error occurred while splitting the PDF.");
|
||||
if (error.response?.data && typeof error.response.data === 'string') {
|
||||
errorMsg = error.response.data;
|
||||
} else if (error.message) {
|
||||
errorMsg = error.message;
|
||||
}
|
||||
setErrorMessage(errorMsg);
|
||||
setStatus(t("error._value", "Split failed."));
|
||||
markOperationFailed(fileId, operationId, errorMsg);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [t, createOperation, buildFormData, recordOperation, markOperationApplied, markOperationFailed, processResults]);
|
||||
|
||||
const resetResults = useCallback(() => {
|
||||
setFiles([]);
|
||||
setThumbnails([]);
|
||||
setIsGeneratingThumbnails(false);
|
||||
setDownloadUrl(null);
|
||||
setStatus('');
|
||||
setErrorMessage(null);
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setErrorMessage(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
executeOperation,
|
||||
|
||||
// Flattened result properties for cleaner access
|
||||
files,
|
||||
thumbnails,
|
||||
isGeneratingThumbnails,
|
||||
downloadUrl,
|
||||
status,
|
||||
errorMessage,
|
||||
isLoading,
|
||||
|
||||
// Result management functions
|
||||
resetResults,
|
||||
clearError,
|
||||
};
|
||||
};
|
71
frontend/src/hooks/tools/split/useSplitParameters.ts
Normal file
71
frontend/src/hooks/tools/split/useSplitParameters.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { useState } from 'react';
|
||||
import { SPLIT_MODES, SPLIT_TYPES, ENDPOINTS, type SplitMode, type SplitType } from '../../../constants/splitConstants';
|
||||
import { SplitParameters } from '../../../components/tools/split/SplitSettings';
|
||||
|
||||
export interface SplitParametersHook {
|
||||
mode: SplitMode | '';
|
||||
parameters: SplitParameters;
|
||||
setMode: (mode: SplitMode | '') => void;
|
||||
updateParameter: (parameter: keyof SplitParameters, value: string | boolean) => void;
|
||||
resetParameters: () => void;
|
||||
validateParameters: () => boolean;
|
||||
getEndpointName: () => string;
|
||||
}
|
||||
|
||||
const initialParameters: SplitParameters = {
|
||||
pages: '',
|
||||
hDiv: '2',
|
||||
vDiv: '2',
|
||||
merge: false,
|
||||
splitType: SPLIT_TYPES.SIZE,
|
||||
splitValue: '',
|
||||
bookmarkLevel: '1',
|
||||
includeMetadata: false,
|
||||
allowDuplicates: false,
|
||||
};
|
||||
|
||||
export const useSplitParameters = (): SplitParametersHook => {
|
||||
const [mode, setMode] = useState<SplitMode | ''>('');
|
||||
const [parameters, setParameters] = useState<SplitParameters>(initialParameters);
|
||||
|
||||
const updateParameter = (parameter: keyof SplitParameters, value: string | boolean) => {
|
||||
setParameters(prev => ({ ...prev, [parameter]: value }));
|
||||
};
|
||||
|
||||
const resetParameters = () => {
|
||||
setParameters(initialParameters);
|
||||
setMode('');
|
||||
};
|
||||
|
||||
const validateParameters = () => {
|
||||
if (!mode) return false;
|
||||
|
||||
switch (mode) {
|
||||
case SPLIT_MODES.BY_PAGES:
|
||||
return parameters.pages.trim() !== "";
|
||||
case SPLIT_MODES.BY_SECTIONS:
|
||||
return parameters.hDiv !== "" && parameters.vDiv !== "";
|
||||
case SPLIT_MODES.BY_SIZE_OR_COUNT:
|
||||
return parameters.splitValue.trim() !== "";
|
||||
case SPLIT_MODES.BY_CHAPTERS:
|
||||
return parameters.bookmarkLevel !== "";
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const getEndpointName = () => {
|
||||
if (!mode) return ENDPOINTS[SPLIT_MODES.BY_PAGES];
|
||||
return ENDPOINTS[mode as SplitMode];
|
||||
};
|
||||
|
||||
return {
|
||||
mode,
|
||||
parameters,
|
||||
setMode,
|
||||
updateParameter,
|
||||
resetParameters,
|
||||
validateParameters,
|
||||
getEndpointName,
|
||||
};
|
||||
};
|
312
frontend/src/hooks/useEnhancedProcessedFiles.ts
Normal file
312
frontend/src/hooks/useEnhancedProcessedFiles.ts
Normal file
@ -0,0 +1,312 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { ProcessedFile, ProcessingState, ProcessingConfig } from '../types/processing';
|
||||
import { enhancedPDFProcessingService } from '../services/enhancedPDFProcessingService';
|
||||
import { FileHasher } from '../utils/fileHash';
|
||||
|
||||
interface UseEnhancedProcessedFilesResult {
|
||||
processedFiles: Map<File, ProcessedFile>;
|
||||
processingStates: Map<string, ProcessingState>;
|
||||
isProcessing: boolean;
|
||||
hasProcessingErrors: boolean;
|
||||
processingProgress: {
|
||||
overall: number;
|
||||
fileProgress: Map<string, number>;
|
||||
estimatedTimeRemaining: number;
|
||||
};
|
||||
cacheStats: {
|
||||
entries: number;
|
||||
totalSizeBytes: number;
|
||||
maxSizeBytes: number;
|
||||
};
|
||||
metrics: {
|
||||
totalFiles: number;
|
||||
completedFiles: number;
|
||||
failedFiles: number;
|
||||
averageProcessingTime: number;
|
||||
cacheHitRate: number;
|
||||
};
|
||||
actions: {
|
||||
cancelProcessing: (fileKey: string) => void;
|
||||
retryProcessing: (file: File) => void;
|
||||
clearCache: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
export function useEnhancedProcessedFiles(
|
||||
activeFiles: File[],
|
||||
config?: Partial<ProcessingConfig>
|
||||
): UseEnhancedProcessedFilesResult {
|
||||
const [processedFiles, setProcessedFiles] = useState<Map<File, ProcessedFile>>(new Map());
|
||||
const fileHashMapRef = useRef<Map<File, string>>(new Map()); // Use ref to avoid state update loops
|
||||
const [processingStates, setProcessingStates] = useState<Map<string, ProcessingState>>(new Map());
|
||||
|
||||
// Subscribe to processing state changes once
|
||||
useEffect(() => {
|
||||
const unsubscribe = enhancedPDFProcessingService.onProcessingChange(setProcessingStates);
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
// Process files when activeFiles changes
|
||||
useEffect(() => {
|
||||
console.log('useEnhancedProcessedFiles: activeFiles changed', activeFiles.length, 'files');
|
||||
|
||||
if (activeFiles.length === 0) {
|
||||
console.log('useEnhancedProcessedFiles: No active files, clearing processed cache');
|
||||
setProcessedFiles(new Map());
|
||||
// Clear any ongoing processing when no files
|
||||
enhancedPDFProcessingService.clearAllProcessing();
|
||||
return;
|
||||
}
|
||||
|
||||
const processFiles = async () => {
|
||||
const newProcessedFiles = new Map<File, ProcessedFile>();
|
||||
|
||||
for (const file of activeFiles) {
|
||||
// Generate hash for this file
|
||||
const fileHash = await FileHasher.generateHybridHash(file);
|
||||
fileHashMapRef.current.set(file, fileHash);
|
||||
|
||||
// First, check if we have this exact File object cached
|
||||
let existing = processedFiles.get(file);
|
||||
|
||||
// If not found by File object, try to find by hash in case File was recreated
|
||||
if (!existing) {
|
||||
for (const [cachedFile, processed] of processedFiles.entries()) {
|
||||
const cachedHash = fileHashMapRef.current.get(cachedFile);
|
||||
if (cachedHash === fileHash) {
|
||||
existing = processed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
newProcessedFiles.set(file, existing);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const processed = await enhancedPDFProcessingService.processFile(file, config);
|
||||
if (processed) {
|
||||
newProcessedFiles.set(file, processed);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to start processing for ${file.name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Only update if the content actually changed
|
||||
const hasChanged = newProcessedFiles.size !== processedFiles.size ||
|
||||
Array.from(newProcessedFiles.keys()).some(file => !processedFiles.has(file));
|
||||
|
||||
if (hasChanged) {
|
||||
setProcessedFiles(newProcessedFiles);
|
||||
}
|
||||
};
|
||||
|
||||
processFiles();
|
||||
}, [activeFiles]); // Only depend on activeFiles to avoid infinite loops
|
||||
|
||||
// Listen for processing completion
|
||||
useEffect(() => {
|
||||
const checkForCompletedFiles = async () => {
|
||||
let hasNewFiles = false;
|
||||
const updatedFiles = new Map(processedFiles);
|
||||
|
||||
// Generate file keys for all files first
|
||||
const fileKeyPromises = activeFiles.map(async (file) => ({
|
||||
file,
|
||||
key: await FileHasher.generateHybridHash(file)
|
||||
}));
|
||||
|
||||
const fileKeyPairs = await Promise.all(fileKeyPromises);
|
||||
|
||||
for (const { file, key } of fileKeyPairs) {
|
||||
// Only check files that don't have processed results yet
|
||||
if (!updatedFiles.has(file)) {
|
||||
const processingState = processingStates.get(key);
|
||||
|
||||
// Check for both processing and recently completed files
|
||||
// This ensures we catch completed files before they're cleaned up
|
||||
if (processingState?.status === 'processing' || processingState?.status === 'completed') {
|
||||
try {
|
||||
const processed = await enhancedPDFProcessingService.processFile(file, config);
|
||||
if (processed) {
|
||||
updatedFiles.set(file, processed);
|
||||
hasNewFiles = true;
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore errors in completion check
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasNewFiles) {
|
||||
setProcessedFiles(updatedFiles);
|
||||
}
|
||||
};
|
||||
|
||||
// Check every 500ms for completed processing
|
||||
const interval = setInterval(checkForCompletedFiles, 500);
|
||||
return () => clearInterval(interval);
|
||||
}, [activeFiles, processingStates]);
|
||||
|
||||
|
||||
// Cleanup when activeFiles changes
|
||||
useEffect(() => {
|
||||
const currentFiles = new Set(activeFiles);
|
||||
const previousFiles = Array.from(processedFiles.keys());
|
||||
const removedFiles = previousFiles.filter(file => !currentFiles.has(file));
|
||||
|
||||
if (removedFiles.length > 0) {
|
||||
// Clean up processing service cache
|
||||
enhancedPDFProcessingService.cleanup(removedFiles);
|
||||
|
||||
// Update local state
|
||||
setProcessedFiles(prev => {
|
||||
const updated = new Map();
|
||||
for (const [file, processed] of prev) {
|
||||
if (currentFiles.has(file)) {
|
||||
updated.set(file, processed);
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
}, [activeFiles]);
|
||||
|
||||
// Calculate derived state
|
||||
const isProcessing = processingStates.size > 0;
|
||||
const hasProcessingErrors = Array.from(processingStates.values()).some(state => state.status === 'error');
|
||||
|
||||
// Calculate overall progress
|
||||
const processingProgress = calculateProcessingProgress(processingStates);
|
||||
|
||||
// Get cache stats and metrics
|
||||
const cacheStats = enhancedPDFProcessingService.getCacheStats();
|
||||
const metrics = enhancedPDFProcessingService.getMetrics();
|
||||
|
||||
// Action handlers
|
||||
const actions = {
|
||||
cancelProcessing: (fileKey: string) => {
|
||||
enhancedPDFProcessingService.cancelProcessing(fileKey);
|
||||
},
|
||||
|
||||
retryProcessing: async (file: File) => {
|
||||
try {
|
||||
await enhancedPDFProcessingService.processFile(file, config);
|
||||
} catch (error) {
|
||||
console.error(`Failed to retry processing for ${file.name}:`, error);
|
||||
}
|
||||
},
|
||||
|
||||
clearCache: () => {
|
||||
enhancedPDFProcessingService.clearAll();
|
||||
}
|
||||
};
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
enhancedPDFProcessingService.clearAllProcessing();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
processedFiles,
|
||||
processingStates,
|
||||
isProcessing,
|
||||
hasProcessingErrors,
|
||||
processingProgress,
|
||||
cacheStats,
|
||||
metrics,
|
||||
actions
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate overall processing progress from individual file states
|
||||
*/
|
||||
function calculateProcessingProgress(states: Map<string, ProcessingState>): {
|
||||
overall: number;
|
||||
fileProgress: Map<string, number>;
|
||||
estimatedTimeRemaining: number;
|
||||
} {
|
||||
if (states.size === 0) {
|
||||
return {
|
||||
overall: 100,
|
||||
fileProgress: new Map(),
|
||||
estimatedTimeRemaining: 0
|
||||
};
|
||||
}
|
||||
|
||||
const fileProgress = new Map<string, number>();
|
||||
let totalProgress = 0;
|
||||
let totalEstimatedTime = 0;
|
||||
|
||||
for (const [fileKey, state] of states) {
|
||||
fileProgress.set(fileKey, state.progress);
|
||||
totalProgress += state.progress;
|
||||
totalEstimatedTime += state.estimatedTimeRemaining || 0;
|
||||
}
|
||||
|
||||
const overall = totalProgress / states.size;
|
||||
const estimatedTimeRemaining = totalEstimatedTime;
|
||||
|
||||
return {
|
||||
overall,
|
||||
fileProgress,
|
||||
estimatedTimeRemaining
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for getting a single processed file with enhanced features
|
||||
*/
|
||||
export function useEnhancedProcessedFile(
|
||||
file: File | null,
|
||||
config?: Partial<ProcessingConfig>
|
||||
): {
|
||||
processedFile: ProcessedFile | null;
|
||||
isProcessing: boolean;
|
||||
processingState: ProcessingState | null;
|
||||
error: string | null;
|
||||
canRetry: boolean;
|
||||
actions: {
|
||||
cancel: () => void;
|
||||
retry: () => void;
|
||||
};
|
||||
} {
|
||||
const result = useEnhancedProcessedFiles(file ? [file] : [], config);
|
||||
|
||||
const processedFile = file ? result.processedFiles.get(file) || null : null;
|
||||
// Note: This is async but we can't await in hook return - consider refactoring if needed
|
||||
const fileKey = file ? '' : '';
|
||||
const processingState = fileKey ? result.processingStates.get(fileKey) || null : null;
|
||||
const isProcessing = !!processingState;
|
||||
const error = processingState?.error?.message || null;
|
||||
const canRetry = processingState?.error?.recoverable || false;
|
||||
|
||||
const actions = {
|
||||
cancel: () => {
|
||||
if (fileKey) {
|
||||
result.actions.cancelProcessing(fileKey);
|
||||
}
|
||||
},
|
||||
retry: () => {
|
||||
if (file) {
|
||||
result.actions.retryProcessing(file);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
processedFile,
|
||||
isProcessing,
|
||||
processingState,
|
||||
error,
|
||||
canRetry,
|
||||
actions
|
||||
};
|
||||
}
|
122
frontend/src/hooks/useFileManager.ts
Normal file
122
frontend/src/hooks/useFileManager.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { fileStorage } from '../services/fileStorage';
|
||||
import { FileWithUrl } from '../types/file';
|
||||
|
||||
export const useFileManager = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const convertToFile = useCallback(async (fileWithUrl: FileWithUrl): Promise<File> => {
|
||||
if (fileWithUrl.url && fileWithUrl.url.startsWith('blob:')) {
|
||||
const response = await fetch(fileWithUrl.url);
|
||||
const data = await response.arrayBuffer();
|
||||
const file = new File([data], fileWithUrl.name, {
|
||||
type: fileWithUrl.type || 'application/pdf',
|
||||
lastModified: fileWithUrl.lastModified || Date.now()
|
||||
});
|
||||
// Preserve the ID if it exists
|
||||
if (fileWithUrl.id) {
|
||||
Object.defineProperty(file, 'id', { value: fileWithUrl.id, writable: false });
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
// Always use ID first, fallback to name only if ID doesn't exist
|
||||
const lookupKey = fileWithUrl.id || fileWithUrl.name;
|
||||
const storedFile = await fileStorage.getFile(lookupKey);
|
||||
if (storedFile) {
|
||||
const file = new File([storedFile.data], storedFile.name, {
|
||||
type: storedFile.type,
|
||||
lastModified: storedFile.lastModified
|
||||
});
|
||||
// Add the ID to the file object
|
||||
Object.defineProperty(file, 'id', { value: storedFile.id, writable: false });
|
||||
return file;
|
||||
}
|
||||
|
||||
throw new Error('File not found in storage');
|
||||
}, []);
|
||||
|
||||
const loadRecentFiles = useCallback(async (): Promise<FileWithUrl[]> => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const files = await fileStorage.getAllFiles();
|
||||
const sortedFiles = files.sort((a, b) => (b.lastModified || 0) - (a.lastModified || 0));
|
||||
return sortedFiles;
|
||||
} catch (error) {
|
||||
console.error('Failed to load recent files:', error);
|
||||
return [];
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRemoveFile = useCallback(async (index: number, files: FileWithUrl[], setFiles: (files: FileWithUrl[]) => void) => {
|
||||
const file = files[index];
|
||||
try {
|
||||
await fileStorage.deleteFile(file.id || file.name);
|
||||
setFiles(files.filter((_, i) => i !== index));
|
||||
} catch (error) {
|
||||
console.error('Failed to remove file:', error);
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const storeFile = useCallback(async (file: File) => {
|
||||
try {
|
||||
const storedFile = await fileStorage.storeFile(file);
|
||||
// Add the ID to the file object
|
||||
Object.defineProperty(file, 'id', { value: storedFile.id, writable: false });
|
||||
return storedFile;
|
||||
} catch (error) {
|
||||
console.error('Failed to store file:', error);
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const createFileSelectionHandlers = useCallback((
|
||||
selectedFiles: string[],
|
||||
setSelectedFiles: (files: string[]) => void
|
||||
) => {
|
||||
const toggleSelection = (fileId: string) => {
|
||||
setSelectedFiles(
|
||||
selectedFiles.includes(fileId)
|
||||
? selectedFiles.filter(id => id !== fileId)
|
||||
: [...selectedFiles, fileId]
|
||||
);
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
setSelectedFiles([]);
|
||||
};
|
||||
|
||||
const selectMultipleFiles = async (files: FileWithUrl[], onFilesSelect: (files: File[]) => void) => {
|
||||
if (selectedFiles.length === 0) return;
|
||||
|
||||
try {
|
||||
const selectedFileObjects = files.filter(f => selectedFiles.includes(f.id || f.name));
|
||||
const filePromises = selectedFileObjects.map(convertToFile);
|
||||
const convertedFiles = await Promise.all(filePromises);
|
||||
onFilesSelect(convertedFiles);
|
||||
clearSelection();
|
||||
} catch (error) {
|
||||
console.error('Failed to load selected files:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
toggleSelection,
|
||||
clearSelection,
|
||||
selectMultipleFiles
|
||||
};
|
||||
}, [convertToFile]);
|
||||
|
||||
return {
|
||||
loading,
|
||||
convertToFile,
|
||||
loadRecentFiles,
|
||||
handleRemoveFile,
|
||||
storeFile,
|
||||
createFileSelectionHandlers
|
||||
};
|
||||
};
|
30
frontend/src/hooks/useMemoryManagement.ts
Normal file
30
frontend/src/hooks/useMemoryManagement.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useFileContext } from '../contexts/FileContext';
|
||||
|
||||
/**
|
||||
* Hook for components that need to register resources with centralized memory management
|
||||
*/
|
||||
export function useMemoryManagement() {
|
||||
const { trackBlobUrl, trackPdfDocument, scheduleCleanup } = useFileContext();
|
||||
|
||||
const registerBlobUrl = useCallback((url: string) => {
|
||||
trackBlobUrl(url);
|
||||
return url;
|
||||
}, [trackBlobUrl]);
|
||||
|
||||
const registerPdfDocument = useCallback((fileId: string, pdfDoc: any) => {
|
||||
trackPdfDocument(fileId, pdfDoc);
|
||||
return pdfDoc;
|
||||
}, [trackPdfDocument]);
|
||||
|
||||
const cancelCleanup = useCallback((fileId: string) => {
|
||||
// Cancel scheduled cleanup (user is actively using the file)
|
||||
scheduleCleanup(fileId, -1); // -1 cancels the timer
|
||||
}, [scheduleCleanup]);
|
||||
|
||||
return {
|
||||
registerBlobUrl,
|
||||
registerPdfDocument,
|
||||
cancelCleanup
|
||||
};
|
||||
}
|
@ -50,18 +50,28 @@ export function usePDFProcessor() {
|
||||
|
||||
const pages: PDFPage[] = [];
|
||||
|
||||
// Generate thumbnails for all pages
|
||||
// Create pages without thumbnails initially - load them lazily
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
const thumbnail = await generatePageThumbnail(file, i);
|
||||
pages.push({
|
||||
id: `${file.name}-page-${i}`,
|
||||
pageNumber: i,
|
||||
thumbnail,
|
||||
thumbnail: null, // Will be loaded lazily
|
||||
rotation: 0,
|
||||
selected: false
|
||||
});
|
||||
}
|
||||
|
||||
// Generate thumbnails for first 10 pages immediately for better UX
|
||||
const priorityPages = Math.min(10, totalPages);
|
||||
for (let i = 1; i <= priorityPages; i++) {
|
||||
try {
|
||||
const thumbnail = await generatePageThumbnail(file, i);
|
||||
pages[i - 1].thumbnail = thumbnail;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to generate thumbnail for page ${i}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
pdf.destroy();
|
||||
|
||||
|
125
frontend/src/hooks/useProcessedFiles.ts
Normal file
125
frontend/src/hooks/useProcessedFiles.ts
Normal file
@ -0,0 +1,125 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ProcessedFile, ProcessingState } from '../types/processing';
|
||||
import { pdfProcessingService } from '../services/pdfProcessingService';
|
||||
|
||||
interface UseProcessedFilesResult {
|
||||
processedFiles: Map<File, ProcessedFile>;
|
||||
processingStates: Map<string, ProcessingState>;
|
||||
isProcessing: boolean;
|
||||
hasProcessingErrors: boolean;
|
||||
cacheStats: {
|
||||
entries: number;
|
||||
totalSizeBytes: number;
|
||||
maxSizeBytes: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function useProcessedFiles(activeFiles: File[]): UseProcessedFilesResult {
|
||||
const [processedFiles, setProcessedFiles] = useState<Map<File, ProcessedFile>>(new Map());
|
||||
const [processingStates, setProcessingStates] = useState<Map<string, ProcessingState>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
// Subscribe to processing state changes
|
||||
const unsubscribe = pdfProcessingService.onProcessingChange(setProcessingStates);
|
||||
|
||||
// Check/start processing for each active file
|
||||
const checkProcessing = async () => {
|
||||
const newProcessedFiles = new Map<File, ProcessedFile>();
|
||||
|
||||
for (const file of activeFiles) {
|
||||
const processed = await pdfProcessingService.getProcessedFile(file);
|
||||
if (processed) {
|
||||
newProcessedFiles.set(file, processed);
|
||||
}
|
||||
}
|
||||
|
||||
setProcessedFiles(newProcessedFiles);
|
||||
};
|
||||
|
||||
checkProcessing();
|
||||
|
||||
return unsubscribe;
|
||||
}, [activeFiles]);
|
||||
|
||||
// Listen for processing completion and update processed files
|
||||
useEffect(() => {
|
||||
const updateProcessedFiles = async () => {
|
||||
const updated = new Map<File, ProcessedFile>();
|
||||
|
||||
for (const file of activeFiles) {
|
||||
const existing = processedFiles.get(file);
|
||||
if (existing) {
|
||||
updated.set(file, existing);
|
||||
} else {
|
||||
// Check if processing just completed
|
||||
const processed = await pdfProcessingService.getProcessedFile(file);
|
||||
if (processed) {
|
||||
updated.set(file, processed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setProcessedFiles(updated);
|
||||
};
|
||||
|
||||
// Small delay to allow processing state to settle
|
||||
const timeoutId = setTimeout(updateProcessedFiles, 100);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [processingStates, activeFiles]);
|
||||
|
||||
// Cleanup when activeFiles changes
|
||||
useEffect(() => {
|
||||
const currentFiles = new Set(activeFiles);
|
||||
const previousFiles = Array.from(processedFiles.keys());
|
||||
const removedFiles = previousFiles.filter(file => !currentFiles.has(file));
|
||||
|
||||
if (removedFiles.length > 0) {
|
||||
// Clean up processing service cache
|
||||
pdfProcessingService.cleanup(removedFiles);
|
||||
|
||||
// Update local state
|
||||
setProcessedFiles(prev => {
|
||||
const updated = new Map();
|
||||
for (const [file, processed] of prev) {
|
||||
if (currentFiles.has(file)) {
|
||||
updated.set(file, processed);
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
}, [activeFiles]);
|
||||
|
||||
// Derived state
|
||||
const isProcessing = processingStates.size > 0;
|
||||
const hasProcessingErrors = Array.from(processingStates.values()).some(state => state.status === 'error');
|
||||
const cacheStats = pdfProcessingService.getCacheStats();
|
||||
|
||||
return {
|
||||
processedFiles,
|
||||
processingStates,
|
||||
isProcessing,
|
||||
hasProcessingErrors,
|
||||
cacheStats
|
||||
};
|
||||
}
|
||||
|
||||
// Hook for getting a single processed file
|
||||
export function useProcessedFile(file: File | null): {
|
||||
processedFile: ProcessedFile | null;
|
||||
isProcessing: boolean;
|
||||
processingState: ProcessingState | null;
|
||||
} {
|
||||
const result = useProcessedFiles(file ? [file] : []);
|
||||
|
||||
const processedFile = file ? result.processedFiles.get(file) || null : null;
|
||||
const fileKey = file ? pdfProcessingService.generateFileKey(file) : '';
|
||||
const processingState = fileKey ? result.processingStates.get(fileKey) || null : null;
|
||||
const isProcessing = !!processingState;
|
||||
|
||||
return {
|
||||
processedFile,
|
||||
isProcessing,
|
||||
processingState
|
||||
};
|
||||
}
|
56
frontend/src/hooks/useThumbnailGeneration.ts
Normal file
56
frontend/src/hooks/useThumbnailGeneration.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { useCallback } from 'react';
|
||||
import { thumbnailGenerationService } from '../services/thumbnailGenerationService';
|
||||
|
||||
/**
|
||||
* Hook for tools that want to use thumbnail generation
|
||||
* Tools can choose whether to include visual features
|
||||
*/
|
||||
export function useThumbnailGeneration() {
|
||||
const generateThumbnails = useCallback(async (
|
||||
pdfArrayBuffer: ArrayBuffer,
|
||||
pageNumbers: number[],
|
||||
options: {
|
||||
scale?: number;
|
||||
quality?: number;
|
||||
batchSize?: number;
|
||||
parallelBatches?: number;
|
||||
} = {},
|
||||
onProgress?: (progress: { completed: number; total: number; thumbnails: any[] }) => void
|
||||
) => {
|
||||
return thumbnailGenerationService.generateThumbnails(
|
||||
pdfArrayBuffer,
|
||||
pageNumbers,
|
||||
options,
|
||||
onProgress
|
||||
);
|
||||
}, []);
|
||||
|
||||
const addThumbnailToCache = useCallback((pageId: string, thumbnail: string) => {
|
||||
thumbnailGenerationService.addThumbnailToCache(pageId, thumbnail);
|
||||
}, []);
|
||||
|
||||
const getThumbnailFromCache = useCallback((pageId: string): string | null => {
|
||||
return thumbnailGenerationService.getThumbnailFromCache(pageId);
|
||||
}, []);
|
||||
|
||||
const getCacheStats = useCallback(() => {
|
||||
return thumbnailGenerationService.getCacheStats();
|
||||
}, []);
|
||||
|
||||
const stopGeneration = useCallback(() => {
|
||||
thumbnailGenerationService.stopGeneration();
|
||||
}, []);
|
||||
|
||||
const destroyThumbnails = useCallback(() => {
|
||||
thumbnailGenerationService.destroy();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
generateThumbnails,
|
||||
addThumbnailToCache,
|
||||
getThumbnailFromCache,
|
||||
getCacheStats,
|
||||
stopGeneration,
|
||||
destroyThumbnails
|
||||
};
|
||||
}
|
96
frontend/src/hooks/useToolManagement.tsx
Normal file
96
frontend/src/hooks/useToolManagement.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import AddToPhotosIcon from "@mui/icons-material/AddToPhotos";
|
||||
import ContentCutIcon from "@mui/icons-material/ContentCut";
|
||||
import ZoomInMapIcon from "@mui/icons-material/ZoomInMap";
|
||||
import SplitPdfPanel from "../tools/Split";
|
||||
import CompressPdfPanel from "../tools/Compress";
|
||||
import MergePdfPanel from "../tools/Merge";
|
||||
import { useMultipleEndpointsEnabled } from "./useEndpointConfig";
|
||||
|
||||
type ToolRegistryEntry = {
|
||||
icon: React.ReactNode;
|
||||
name: string;
|
||||
component: React.ComponentType<any>;
|
||||
view: string;
|
||||
};
|
||||
|
||||
type ToolRegistry = {
|
||||
[key: string]: ToolRegistryEntry;
|
||||
};
|
||||
|
||||
const baseToolRegistry = {
|
||||
split: { icon: <ContentCutIcon />, component: SplitPdfPanel, view: "split" },
|
||||
compress: { icon: <ZoomInMapIcon />, component: CompressPdfPanel, view: "viewer" },
|
||||
merge: { icon: <AddToPhotosIcon />, component: MergePdfPanel, view: "pageEditor" },
|
||||
};
|
||||
|
||||
// Tool endpoint mappings
|
||||
const toolEndpoints: Record<string, string[]> = {
|
||||
split: ["split-pages", "split-pdf-by-sections", "split-by-size-or-count", "split-pdf-by-chapters"],
|
||||
compress: ["compress-pdf"],
|
||||
merge: ["merge-pdfs"],
|
||||
};
|
||||
|
||||
|
||||
export const useToolManagement = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [selectedToolKey, setSelectedToolKey] = useState<string | null>(null);
|
||||
const [toolSelectedFileIds, setToolSelectedFileIds] = useState<string[]>([]);
|
||||
|
||||
const allEndpoints = Array.from(new Set(Object.values(toolEndpoints).flat()));
|
||||
const { endpointStatus, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints);
|
||||
|
||||
const isToolAvailable = useCallback((toolKey: string): boolean => {
|
||||
if (endpointsLoading) return true;
|
||||
const endpoints = toolEndpoints[toolKey] || [];
|
||||
return endpoints.some(endpoint => endpointStatus[endpoint] === true);
|
||||
}, [endpointsLoading, endpointStatus]);
|
||||
|
||||
const toolRegistry: ToolRegistry = useMemo(() => {
|
||||
const availableToolRegistry: ToolRegistry = {};
|
||||
Object.keys(baseToolRegistry).forEach(toolKey => {
|
||||
if (isToolAvailable(toolKey)) {
|
||||
availableToolRegistry[toolKey] = {
|
||||
...baseToolRegistry[toolKey as keyof typeof baseToolRegistry],
|
||||
name: t(`home.${toolKey}.title`, toolKey.charAt(0).toUpperCase() + toolKey.slice(1))
|
||||
};
|
||||
}
|
||||
});
|
||||
return availableToolRegistry;
|
||||
}, [t, isToolAvailable]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!endpointsLoading && selectedToolKey && !toolRegistry[selectedToolKey]) {
|
||||
const firstAvailableTool = Object.keys(toolRegistry)[0];
|
||||
if (firstAvailableTool) {
|
||||
setSelectedToolKey(firstAvailableTool);
|
||||
} else {
|
||||
setSelectedToolKey(null);
|
||||
}
|
||||
}
|
||||
}, [endpointsLoading, selectedToolKey, toolRegistry]);
|
||||
|
||||
const selectTool = useCallback((toolKey: string) => {
|
||||
setSelectedToolKey(toolKey);
|
||||
}, []);
|
||||
|
||||
const clearToolSelection = useCallback(() => {
|
||||
setSelectedToolKey(null);
|
||||
}, []);
|
||||
|
||||
const selectedTool = selectedToolKey ? toolRegistry[selectedToolKey] : null;
|
||||
|
||||
return {
|
||||
selectedToolKey,
|
||||
selectedTool,
|
||||
toolSelectedFileIds,
|
||||
toolRegistry,
|
||||
|
||||
selectTool,
|
||||
clearToolSelection,
|
||||
setToolSelectedFileIds,
|
||||
|
||||
};
|
||||
};
|
51
frontend/src/hooks/useToolParameters.ts
Normal file
51
frontend/src/hooks/useToolParameters.ts
Normal file
@ -0,0 +1,51 @@
|
||||
/**
|
||||
* React hooks for tool parameter management (URL logic removed)
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
type ToolParameterValues = Record<string, any>;
|
||||
|
||||
/**
|
||||
* Register tool parameters and get current values
|
||||
*/
|
||||
export function useToolParameters(
|
||||
toolName: string,
|
||||
parameters: Record<string, any>
|
||||
): [ToolParameterValues, (updates: Partial<ToolParameterValues>) => void] {
|
||||
|
||||
// Return empty values and noop updater
|
||||
const currentValues = useMemo(() => ({}), []);
|
||||
const updateParameters = useCallback(() => {}, []);
|
||||
|
||||
return [currentValues, updateParameters];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing a single tool parameter
|
||||
*/
|
||||
export function useToolParameter<T = any>(
|
||||
toolName: string,
|
||||
paramName: string,
|
||||
definition: any
|
||||
): [T, (value: T) => void] {
|
||||
const [allParams, updateParams] = useToolParameters(toolName, { [paramName]: definition });
|
||||
|
||||
const value = allParams[paramName] as T;
|
||||
|
||||
const setValue = useCallback((newValue: T) => {
|
||||
updateParams({ [paramName]: newValue });
|
||||
}, [paramName, updateParams]);
|
||||
|
||||
return [value, setValue];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for getting/setting global parameters (zoom, page, etc.)
|
||||
*/
|
||||
export function useGlobalParameters() {
|
||||
const currentValues = useMemo(() => ({}), []);
|
||||
const updateParameters = useCallback(() => {}, []);
|
||||
|
||||
return [currentValues, updateParameters];
|
||||
}
|
@ -1,130 +0,0 @@
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useEffect } from "react";
|
||||
|
||||
// Tool parameter definitions (shortened URLs)
|
||||
const TOOL_PARAMS = {
|
||||
split: [
|
||||
"mode", "p", "hd", "vd", "m",
|
||||
"type", "val", "level", "meta", "dupes"
|
||||
],
|
||||
compress: [
|
||||
"level", "gray", "rmeta", "size", "agg"
|
||||
],
|
||||
merge: [
|
||||
"order", "rdupes"
|
||||
]
|
||||
};
|
||||
|
||||
// Extract params for a specific tool from URL
|
||||
function getToolParams(toolKey: string, searchParams: URLSearchParams) {
|
||||
switch (toolKey) {
|
||||
case "split":
|
||||
return {
|
||||
mode: searchParams.get("mode") || "byPages",
|
||||
pages: searchParams.get("p") || "",
|
||||
hDiv: searchParams.get("hd") || "",
|
||||
vDiv: searchParams.get("vd") || "",
|
||||
merge: searchParams.get("m") === "true",
|
||||
splitType: searchParams.get("type") || "size",
|
||||
splitValue: searchParams.get("val") || "",
|
||||
bookmarkLevel: searchParams.get("level") || "0",
|
||||
includeMetadata: searchParams.get("meta") === "true",
|
||||
allowDuplicates: searchParams.get("dupes") === "true",
|
||||
};
|
||||
case "compress":
|
||||
return {
|
||||
compressionLevel: parseInt(searchParams.get("level") || "5"),
|
||||
grayscale: searchParams.get("gray") === "true",
|
||||
removeMetadata: searchParams.get("rmeta") === "true",
|
||||
expectedSize: searchParams.get("size") || "",
|
||||
aggressive: searchParams.get("agg") === "true",
|
||||
};
|
||||
case "merge":
|
||||
return {
|
||||
order: searchParams.get("order") || "default",
|
||||
removeDuplicates: searchParams.get("rdupes") === "true",
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Update tool-specific params in URL
|
||||
function updateToolParams(toolKey: string, searchParams: URLSearchParams, setSearchParams: any, newParams: any) {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
||||
// Clear tool-specific params
|
||||
if (toolKey === "split") {
|
||||
["mode", "p", "hd", "vd", "m", "type", "val", "level", "meta", "dupes"].forEach((k) => params.delete(k));
|
||||
// Set new split params
|
||||
const merged = { ...getToolParams("split", searchParams), ...newParams };
|
||||
params.set("mode", merged.mode);
|
||||
if (merged.mode === "byPages") params.set("p", merged.pages);
|
||||
else if (merged.mode === "bySections") {
|
||||
params.set("hd", merged.hDiv);
|
||||
params.set("vd", merged.vDiv);
|
||||
params.set("m", String(merged.merge));
|
||||
} else if (merged.mode === "bySizeOrCount") {
|
||||
params.set("type", merged.splitType);
|
||||
params.set("val", merged.splitValue);
|
||||
} else if (merged.mode === "byChapters") {
|
||||
params.set("level", merged.bookmarkLevel);
|
||||
params.set("meta", String(merged.includeMetadata));
|
||||
params.set("dupes", String(merged.allowDuplicates));
|
||||
}
|
||||
} else if (toolKey === "compress") {
|
||||
["level", "gray", "rmeta", "size", "agg"].forEach((k) => params.delete(k));
|
||||
const merged = { ...getToolParams("compress", searchParams), ...newParams };
|
||||
params.set("level", String(merged.compressionLevel));
|
||||
params.set("gray", String(merged.grayscale));
|
||||
params.set("rmeta", String(merged.removeMetadata));
|
||||
if (merged.expectedSize) params.set("size", merged.expectedSize);
|
||||
params.set("agg", String(merged.aggressive));
|
||||
} else if (toolKey === "merge") {
|
||||
["order", "rdupes"].forEach((k) => params.delete(k));
|
||||
const merged = { ...getToolParams("merge", searchParams), ...newParams };
|
||||
params.set("order", merged.order);
|
||||
params.set("rdupes", String(merged.removeDuplicates));
|
||||
}
|
||||
|
||||
setSearchParams(params, { replace: true });
|
||||
}
|
||||
|
||||
export function useToolParams(selectedToolKey: string, currentView: string) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const toolParams = getToolParams(selectedToolKey, searchParams);
|
||||
|
||||
const updateParams = (newParams: any) =>
|
||||
updateToolParams(selectedToolKey, searchParams, setSearchParams, newParams);
|
||||
|
||||
// Update URL when core state changes
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
||||
// Remove all tool-specific params except for the current tool
|
||||
Object.entries(TOOL_PARAMS).forEach(([tool, keys]) => {
|
||||
if (tool !== selectedToolKey) {
|
||||
keys.forEach((k) => params.delete(k));
|
||||
}
|
||||
});
|
||||
|
||||
// Collect all params except 'v'
|
||||
const entries = Array.from(params.entries()).filter(([key]) => key !== "v");
|
||||
|
||||
// Rebuild params with 'v' first
|
||||
const newParams = new URLSearchParams();
|
||||
newParams.set("v", currentView);
|
||||
newParams.set("t", selectedToolKey);
|
||||
entries.forEach(([key, value]) => {
|
||||
if (key !== "t") newParams.set(key, value);
|
||||
});
|
||||
|
||||
setSearchParams(newParams, { replace: true });
|
||||
}, [selectedToolKey, currentView, setSearchParams]);
|
||||
|
||||
return {
|
||||
toolParams,
|
||||
updateParams,
|
||||
};
|
||||
}
|
@ -1,322 +1,83 @@
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import React, { useState, useCallback} from "react";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useToolParams } from "../hooks/useToolParams";
|
||||
import { useFileWithUrl } from "../hooks/useFileWithUrl";
|
||||
import { fileStorage } from "../services/fileStorage";
|
||||
import AddToPhotosIcon from "@mui/icons-material/AddToPhotos";
|
||||
import ContentCutIcon from "@mui/icons-material/ContentCut";
|
||||
import ZoomInMapIcon from "@mui/icons-material/ZoomInMap";
|
||||
import { Group, Paper, Box, Button, useMantineTheme, Container } from "@mantine/core";
|
||||
import { useFileContext } from "../contexts/FileContext";
|
||||
import { useToolManagement } from "../hooks/useToolManagement";
|
||||
import { Group, Box, Button, Container } from "@mantine/core";
|
||||
import { useRainbowThemeContext } from "../components/shared/RainbowThemeProvider";
|
||||
import rainbowStyles from '../styles/rainbow.module.css';
|
||||
|
||||
import ToolPicker from "../components/tools/ToolPicker";
|
||||
import TopControls from "../components/shared/TopControls";
|
||||
import FileManager from "../components/fileManagement/FileManager";
|
||||
import FileEditor from "../components/pageEditor/FileEditor";
|
||||
import FileEditor from "../components/fileEditor/FileEditor";
|
||||
import PageEditor from "../components/pageEditor/PageEditor";
|
||||
import PageEditorControls from "../components/pageEditor/PageEditorControls";
|
||||
import Viewer from "../components/viewer/Viewer";
|
||||
import FileUploadSelector from "../components/shared/FileUploadSelector";
|
||||
import SplitPdfPanel from "../tools/Split";
|
||||
import CompressPdfPanel from "../tools/Compress";
|
||||
import MergePdfPanel from "../tools/Merge";
|
||||
import ToolRenderer from "../components/tools/ToolRenderer";
|
||||
import QuickAccessBar from "../components/shared/QuickAccessBar";
|
||||
import { useMultipleEndpointsEnabled } from "../hooks/useEndpointConfig";
|
||||
|
||||
type ToolRegistryEntry = {
|
||||
icon: React.ReactNode;
|
||||
name: string;
|
||||
component: React.ComponentType<any>;
|
||||
view: string;
|
||||
};
|
||||
|
||||
type ToolRegistry = {
|
||||
[key: string]: ToolRegistryEntry;
|
||||
};
|
||||
|
||||
// Base tool registry without translations
|
||||
const baseToolRegistry = {
|
||||
split: { icon: <ContentCutIcon />, component: SplitPdfPanel, view: "viewer" },
|
||||
compress: { icon: <ZoomInMapIcon />, component: CompressPdfPanel, view: "viewer" },
|
||||
merge: { icon: <AddToPhotosIcon />, component: MergePdfPanel, view: "fileManager" },
|
||||
};
|
||||
|
||||
// Tool endpoint mappings
|
||||
const toolEndpoints: Record<string, string[]> = {
|
||||
split: ["split-pages", "split-pdf-by-sections", "split-by-size-or-count", "split-pdf-by-chapters"],
|
||||
compress: ["compress-pdf"],
|
||||
merge: ["merge-pdfs"],
|
||||
};
|
||||
|
||||
export default function HomePage() {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const theme = useMantineTheme();
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
|
||||
// Core app state
|
||||
const [selectedToolKey, setSelectedToolKey] = useState<string>(searchParams.get("t") || "split");
|
||||
const [currentView, setCurrentView] = useState<string>(searchParams.get("v") || "viewer");
|
||||
// Get file context
|
||||
const fileContext = useFileContext();
|
||||
const { activeFiles, currentView, currentMode, setCurrentView, addFiles } = fileContext;
|
||||
|
||||
// File state separation
|
||||
const [storedFiles, setStoredFiles] = useState<any[]>([]); // IndexedDB files (FileManager)
|
||||
const [activeFiles, setActiveFiles] = useState<File[]>([]); // Active working set (persisted)
|
||||
const [preSelectedFiles, setPreSelectedFiles] = useState([]);
|
||||
const {
|
||||
selectedToolKey,
|
||||
selectedTool,
|
||||
toolParams,
|
||||
toolRegistry,
|
||||
selectTool,
|
||||
clearToolSelection,
|
||||
updateToolParams,
|
||||
} = useToolManagement();
|
||||
|
||||
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
||||
const [toolSelectedFiles, setToolSelectedFiles] = useState<File[]>([]);
|
||||
const [sidebarsVisible, setSidebarsVisible] = useState(true);
|
||||
const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker');
|
||||
const [readerMode, setReaderMode] = useState(false);
|
||||
|
||||
// Page editor functions
|
||||
const [pageEditorFunctions, setPageEditorFunctions] = useState<any>(null);
|
||||
const [previewFile, setPreviewFile] = useState<File | null>(null);
|
||||
|
||||
// URL parameter management
|
||||
const { toolParams, updateParams } = useToolParams(selectedToolKey, currentView);
|
||||
|
||||
// Get all unique endpoints for batch checking
|
||||
const allEndpoints = Array.from(new Set(Object.values(toolEndpoints).flat()));
|
||||
const { endpointStatus, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints);
|
||||
|
||||
// Persist active files across reloads
|
||||
useEffect(() => {
|
||||
// Save active files to localStorage (just metadata)
|
||||
const activeFileData = activeFiles.map(file => ({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
lastModified: file.lastModified
|
||||
}));
|
||||
localStorage.setItem('activeFiles', JSON.stringify(activeFileData));
|
||||
}, [activeFiles]);
|
||||
|
||||
// Load stored files from IndexedDB on mount
|
||||
useEffect(() => {
|
||||
const loadStoredFiles = async () => {
|
||||
try {
|
||||
const files = await fileStorage.getAllFiles();
|
||||
setStoredFiles(files);
|
||||
} catch (error) {
|
||||
console.warn('Failed to load stored files:', error);
|
||||
}
|
||||
};
|
||||
loadStoredFiles();
|
||||
}, []);
|
||||
|
||||
// Restore active files on load
|
||||
useEffect(() => {
|
||||
const restoreActiveFiles = async () => {
|
||||
try {
|
||||
const savedFileData = JSON.parse(localStorage.getItem('activeFiles') || '[]');
|
||||
if (savedFileData.length > 0) {
|
||||
// TODO: Reconstruct files from IndexedDB when fileStorage is available
|
||||
console.log('Would restore active files:', savedFileData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to restore active files:', error);
|
||||
}
|
||||
};
|
||||
restoreActiveFiles();
|
||||
}, []);
|
||||
|
||||
// Helper function to check if a tool is available
|
||||
const isToolAvailable = (toolKey: string): boolean => {
|
||||
if (endpointsLoading) return true; // Show tools while loading
|
||||
const endpoints = toolEndpoints[toolKey] || [];
|
||||
// Tool is available if at least one of its endpoints is enabled
|
||||
return endpoints.some(endpoint => endpointStatus[endpoint] === true);
|
||||
};
|
||||
|
||||
// Filter tool registry to only show available tools
|
||||
const availableToolRegistry: ToolRegistry = {};
|
||||
Object.keys(baseToolRegistry).forEach(toolKey => {
|
||||
if (isToolAvailable(toolKey)) {
|
||||
availableToolRegistry[toolKey] = {
|
||||
...baseToolRegistry[toolKey as keyof typeof baseToolRegistry],
|
||||
name: t(`home.${toolKey}.title`, toolKey.charAt(0).toUpperCase() + toolKey.slice(1))
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const toolRegistry = availableToolRegistry;
|
||||
|
||||
// Handle case where selected tool becomes unavailable
|
||||
useEffect(() => {
|
||||
if (!endpointsLoading && selectedToolKey && !toolRegistry[selectedToolKey]) {
|
||||
// If current tool is not available, select the first available tool
|
||||
const firstAvailableTool = Object.keys(toolRegistry)[0];
|
||||
if (firstAvailableTool) {
|
||||
setSelectedToolKey(firstAvailableTool);
|
||||
if (toolRegistry[firstAvailableTool]?.view) {
|
||||
setCurrentView(toolRegistry[firstAvailableTool].view);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [endpointsLoading, selectedToolKey, toolRegistry]);
|
||||
|
||||
// Handle tool selection
|
||||
const handleToolSelect = useCallback(
|
||||
(id: string) => {
|
||||
setSelectedToolKey(id);
|
||||
selectTool(id);
|
||||
if (toolRegistry[id]?.view) setCurrentView(toolRegistry[id].view);
|
||||
setLeftPanelView('toolContent'); // Switch to tool content view when a tool is selected
|
||||
setReaderMode(false); // Exit reader mode when selecting a tool
|
||||
setLeftPanelView('toolContent');
|
||||
setReaderMode(false);
|
||||
},
|
||||
[toolRegistry]
|
||||
[selectTool, toolRegistry, setCurrentView]
|
||||
);
|
||||
|
||||
// Handle quick access actions
|
||||
const handleQuickAccessTools = useCallback(() => {
|
||||
setLeftPanelView('toolPicker');
|
||||
setReaderMode(false);
|
||||
}, []);
|
||||
clearToolSelection();
|
||||
}, [clearToolSelection]);
|
||||
|
||||
const handleReaderToggle = useCallback(() => {
|
||||
setReaderMode(!readerMode);
|
||||
}, [readerMode]);
|
||||
|
||||
// Update URL when view changes
|
||||
const handleViewChange = useCallback((view: string) => {
|
||||
setCurrentView(view);
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set('view', view);
|
||||
const newUrl = `${window.location.pathname}?${params.toString()}`;
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
}, []);
|
||||
setCurrentView(view as any);
|
||||
}, [setCurrentView]);
|
||||
|
||||
// Active file management
|
||||
const addToActiveFiles = useCallback((file: File) => {
|
||||
setActiveFiles(prev => {
|
||||
// Avoid duplicates based on name and size
|
||||
const exists = prev.some(f => f.name === file.name && f.size === file.size);
|
||||
if (exists) return prev;
|
||||
return [file, ...prev];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeFromActiveFiles = useCallback((file: File) => {
|
||||
setActiveFiles(prev => prev.filter(f => !(f.name === file.name && f.size === file.size)));
|
||||
}, []);
|
||||
|
||||
const setCurrentActiveFile = useCallback((file: File) => {
|
||||
setActiveFiles(prev => {
|
||||
const filtered = prev.filter(f => !(f.name === file.name && f.size === file.size));
|
||||
return [file, ...filtered];
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Handle file selection from upload (adds to active files)
|
||||
const handleFileSelect = useCallback((file: File) => {
|
||||
addToActiveFiles(file);
|
||||
}, [addToActiveFiles]);
|
||||
|
||||
// Handle opening file editor with selected files
|
||||
const handleOpenFileEditor = useCallback(async (selectedFiles) => {
|
||||
if (!selectedFiles || selectedFiles.length === 0) {
|
||||
setPreSelectedFiles([]);
|
||||
handleViewChange("fileEditor");
|
||||
return;
|
||||
const addToActiveFiles = useCallback(async (file: File) => {
|
||||
const exists = activeFiles.some(f => f.name === file.name && f.size === file.size);
|
||||
if (!exists) {
|
||||
await addFiles([file]);
|
||||
}
|
||||
}, [activeFiles, addFiles]);
|
||||
|
||||
// Convert FileWithUrl[] to File[] and add to activeFiles
|
||||
try {
|
||||
const convertedFiles = await Promise.all(
|
||||
selectedFiles.map(async (fileItem) => {
|
||||
// If it's already a File, return as is
|
||||
if (fileItem instanceof File) {
|
||||
return fileItem;
|
||||
}
|
||||
|
||||
// If it has a file property, use that
|
||||
if (fileItem.file && fileItem.file instanceof File) {
|
||||
return fileItem.file;
|
||||
}
|
||||
|
||||
// If it's from IndexedDB storage, reconstruct the File
|
||||
if (fileItem.arrayBuffer && typeof fileItem.arrayBuffer === 'function') {
|
||||
const arrayBuffer = await fileItem.arrayBuffer();
|
||||
const blob = new Blob([arrayBuffer], { type: fileItem.type || 'application/pdf' });
|
||||
const file = new File([blob], fileItem.name, {
|
||||
type: fileItem.type || 'application/pdf',
|
||||
lastModified: fileItem.lastModified || Date.now()
|
||||
});
|
||||
// Mark as from storage to avoid re-storing
|
||||
(file as any).storedInIndexedDB = true;
|
||||
return file;
|
||||
}
|
||||
|
||||
console.warn('Could not convert file item:', fileItem);
|
||||
return null;
|
||||
})
|
||||
);
|
||||
|
||||
// Filter out nulls and add to activeFiles
|
||||
const validFiles = convertedFiles.filter((f): f is File => f !== null);
|
||||
setActiveFiles(validFiles);
|
||||
setPreSelectedFiles([]); // Clear preselected since we're using activeFiles now
|
||||
handleViewChange("fileEditor");
|
||||
} catch (error) {
|
||||
console.error('Error converting selected files:', error);
|
||||
}
|
||||
}, [handleViewChange, setActiveFiles]);
|
||||
|
||||
// Handle opening page editor with selected files
|
||||
const handleOpenPageEditor = useCallback(async (selectedFiles) => {
|
||||
if (!selectedFiles || selectedFiles.length === 0) {
|
||||
handleViewChange("pageEditor");
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert FileWithUrl[] to File[] and add to activeFiles
|
||||
try {
|
||||
const convertedFiles = await Promise.all(
|
||||
selectedFiles.map(async (fileItem) => {
|
||||
// If it's already a File, return as is
|
||||
if (fileItem instanceof File) {
|
||||
return fileItem;
|
||||
}
|
||||
|
||||
// If it has a file property, use that
|
||||
if (fileItem.file && fileItem.file instanceof File) {
|
||||
return fileItem.file;
|
||||
}
|
||||
|
||||
// If it's from IndexedDB storage, reconstruct the File
|
||||
if (fileItem.arrayBuffer && typeof fileItem.arrayBuffer === 'function') {
|
||||
const arrayBuffer = await fileItem.arrayBuffer();
|
||||
const blob = new Blob([arrayBuffer], { type: fileItem.type || 'application/pdf' });
|
||||
const file = new File([blob], fileItem.name, {
|
||||
type: fileItem.type || 'application/pdf',
|
||||
lastModified: fileItem.lastModified || Date.now()
|
||||
});
|
||||
// Mark as from storage to avoid re-storing
|
||||
(file as any).storedInIndexedDB = true;
|
||||
return file;
|
||||
}
|
||||
|
||||
console.warn('Could not convert file item:', fileItem);
|
||||
return null;
|
||||
})
|
||||
);
|
||||
|
||||
// Filter out nulls and add to activeFiles
|
||||
const validFiles = convertedFiles.filter((f): f is File => f !== null);
|
||||
setActiveFiles(validFiles);
|
||||
handleViewChange("pageEditor");
|
||||
} catch (error) {
|
||||
console.error('Error converting selected files for page editor:', error);
|
||||
}
|
||||
}, [handleViewChange, setActiveFiles]);
|
||||
|
||||
const selectedTool = toolRegistry[selectedToolKey];
|
||||
|
||||
// For Viewer - convert first active file to expected format (only when needed)
|
||||
const currentFileWithUrl = useFileWithUrl(
|
||||
(currentView === "viewer" && activeFiles[0]) ? activeFiles[0] : null
|
||||
);
|
||||
|
||||
return (
|
||||
<Group
|
||||
@ -334,17 +95,12 @@ export default function HomePage() {
|
||||
readerMode={readerMode}
|
||||
/>
|
||||
|
||||
{/* Left: Tool Picker OR Selected Tool Panel */}
|
||||
{/* Left: Tool Picker or Selected Tool Panel */}
|
||||
<div
|
||||
className={`h-screen z-sticky flex flex-col ${isRainbowMode ? rainbowStyles.rainbowPaper : ''} overflow-hidden`}
|
||||
className={`h-screen flex flex-col overflow-hidden bg-[var(--bg-surface)] border-r border-[var(--border-subtle)] transition-all duration-300 ease-out ${isRainbowMode ? rainbowStyles.rainbowPaper : ''}`}
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-surface)',
|
||||
borderRight: '1px solid var(--border-subtle)',
|
||||
width: sidebarsVisible && !readerMode ? '25vw' : '0px',
|
||||
minWidth: sidebarsVisible && !readerMode ? '300px' : '0px',
|
||||
maxWidth: sidebarsVisible && !readerMode ? '450px' : '0px',
|
||||
transition: 'width 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94), min-width 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94), max-width 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||
padding: sidebarsVisible && !readerMode ? '1rem' : '0rem'
|
||||
width: sidebarsVisible && !readerMode ? '14vw' : '0',
|
||||
padding: sidebarsVisible && !readerMode ? '1rem' : '0'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@ -373,7 +129,7 @@ export default function HomePage() {
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={() => setLeftPanelView('toolPicker')}
|
||||
onClick={handleQuickAccessTools}
|
||||
className="text-sm"
|
||||
>
|
||||
← {t("fileUpload.backToTools", "Back to Tools")}
|
||||
@ -389,13 +145,8 @@ export default function HomePage() {
|
||||
<div className="flex-1 min-h-0">
|
||||
<ToolRenderer
|
||||
selectedToolKey={selectedToolKey}
|
||||
selectedTool={selectedTool}
|
||||
pdfFile={activeFiles[0] || null}
|
||||
files={activeFiles}
|
||||
downloadUrl={downloadUrl}
|
||||
setDownloadUrl={setDownloadUrl}
|
||||
toolParams={toolParams}
|
||||
updateParams={updateParams}
|
||||
toolSelectedFiles={toolSelectedFiles}
|
||||
onPreviewFile={setPreviewFile}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -414,19 +165,16 @@ export default function HomePage() {
|
||||
<TopControls
|
||||
currentView={currentView}
|
||||
setCurrentView={handleViewChange}
|
||||
selectedToolKey={selectedToolKey}
|
||||
/>
|
||||
{/* Main content area */}
|
||||
<Box className="flex-1 min-h-0 margin-top-200 relative z-10">
|
||||
{currentView === "fileManager" ? (
|
||||
<FileManager
|
||||
files={storedFiles}
|
||||
setFiles={setStoredFiles}
|
||||
setCurrentView={handleViewChange}
|
||||
onOpenFileEditor={handleOpenFileEditor}
|
||||
onOpenPageEditor={handleOpenPageEditor}
|
||||
onLoadFileToActive={addToActiveFiles}
|
||||
/>
|
||||
) : (currentView != "fileManager") && !activeFiles[0] ? (
|
||||
<Box
|
||||
className="flex-1 min-h-0 relative z-10"
|
||||
style={{
|
||||
transition: 'opacity 0.15s ease-in-out',
|
||||
}}
|
||||
>
|
||||
{!activeFiles[0] ? (
|
||||
<Container size="lg" p="xl" h="100%" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<FileUploadSelector
|
||||
title={currentView === "viewer"
|
||||
@ -434,23 +182,21 @@ export default function HomePage() {
|
||||
: t("fileUpload.selectPdfToEdit", "Select a PDF to edit")
|
||||
}
|
||||
subtitle={t("fileUpload.chooseFromStorage", "Choose a file from storage or upload a new PDF")}
|
||||
sharedFiles={storedFiles}
|
||||
onFileSelect={(file) => {
|
||||
addToActiveFiles(file);
|
||||
}}
|
||||
allowMultiple={false}
|
||||
onFilesSelect={(files) => {
|
||||
files.forEach(addToActiveFiles);
|
||||
}}
|
||||
accept={["application/pdf"]}
|
||||
loading={false}
|
||||
showRecentFiles={true}
|
||||
maxRecentFiles={8}
|
||||
/>
|
||||
</Container>
|
||||
) : currentView === "fileEditor" ? (
|
||||
<FileEditor
|
||||
activeFiles={activeFiles}
|
||||
setActiveFiles={setActiveFiles}
|
||||
preSelectedFiles={preSelectedFiles}
|
||||
onClearPreSelection={() => setPreSelectedFiles([])}
|
||||
onOpenPageEditor={(file) => {
|
||||
setCurrentActiveFile(file);
|
||||
handleViewChange("pageEditor");
|
||||
}}
|
||||
onMergeFiles={(filesToMerge) => {
|
||||
@ -461,28 +207,30 @@ export default function HomePage() {
|
||||
/>
|
||||
) : currentView === "viewer" ? (
|
||||
<Viewer
|
||||
pdfFile={currentFileWithUrl}
|
||||
setPdfFile={(fileObj) => {
|
||||
if (fileObj) {
|
||||
setCurrentActiveFile(fileObj.file);
|
||||
} else {
|
||||
setActiveFiles([]);
|
||||
}
|
||||
}}
|
||||
sidebarsVisible={sidebarsVisible}
|
||||
setSidebarsVisible={setSidebarsVisible}
|
||||
previewFile={previewFile}
|
||||
{...(previewFile && {
|
||||
onClose: () => {
|
||||
setPreviewFile(null); // Clear preview file
|
||||
const previousMode = sessionStorage.getItem('previousMode');
|
||||
if (previousMode === 'split') {
|
||||
selectTool('split');
|
||||
setCurrentView('split');
|
||||
setLeftPanelView('toolContent');
|
||||
sessionStorage.removeItem('previousMode');
|
||||
} else {
|
||||
setCurrentView('fileEditor');
|
||||
}
|
||||
}
|
||||
})}
|
||||
/>
|
||||
) : currentView === "pageEditor" ? (
|
||||
<>
|
||||
<PageEditor
|
||||
activeFiles={activeFiles}
|
||||
setActiveFiles={setActiveFiles}
|
||||
downloadUrl={downloadUrl}
|
||||
setDownloadUrl={setDownloadUrl}
|
||||
sharedFiles={storedFiles}
|
||||
onFunctionsReady={setPageEditorFunctions}
|
||||
/>
|
||||
{activeFiles[0] && pageEditorFunctions && (
|
||||
{pageEditorFunctions && (
|
||||
<PageEditorControls
|
||||
onClosePdf={pageEditorFunctions.closePdf}
|
||||
onUndo={pageEditorFunctions.handleUndo}
|
||||
@ -500,15 +248,37 @@ export default function HomePage() {
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<FileManager
|
||||
files={storedFiles}
|
||||
setFiles={setStoredFiles}
|
||||
setCurrentView={handleViewChange}
|
||||
onOpenFileEditor={handleOpenFileEditor}
|
||||
onOpenPageEditor={handleOpenPageEditor}
|
||||
onLoadFileToActive={addToActiveFiles}
|
||||
) : currentView === "split" ? (
|
||||
<FileEditor
|
||||
toolMode={true}
|
||||
multiSelect={false}
|
||||
showUpload={true}
|
||||
showBulkActions={true}
|
||||
onFileSelect={(files) => {
|
||||
setToolSelectedFiles(files);
|
||||
}}
|
||||
/>
|
||||
) : selectedToolKey && selectedTool ? (
|
||||
<ToolRenderer
|
||||
selectedToolKey={selectedToolKey}
|
||||
/>
|
||||
) : (
|
||||
<Container size="lg" p="xl" h="100%" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<FileUploadSelector
|
||||
title="File Management"
|
||||
subtitle="Choose files from storage or upload new PDFs"
|
||||
onFileSelect={(file) => {
|
||||
addToActiveFiles(file);
|
||||
}}
|
||||
onFilesSelect={(files) => {
|
||||
files.forEach(addToActiveFiles);
|
||||
}}
|
||||
accept={["application/pdf"]}
|
||||
loading={false}
|
||||
showRecentFiles={true}
|
||||
maxRecentFiles={8}
|
||||
/>
|
||||
</Container>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
546
frontend/src/services/enhancedPDFProcessingService.ts
Normal file
546
frontend/src/services/enhancedPDFProcessingService.ts
Normal file
@ -0,0 +1,546 @@
|
||||
import { getDocument, GlobalWorkerOptions } from 'pdfjs-dist';
|
||||
import { ProcessedFile, ProcessingState, PDFPage, ProcessingStrategy, ProcessingConfig, ProcessingMetrics } from '../types/processing';
|
||||
import { ProcessingCache } from './processingCache';
|
||||
import { FileHasher } from '../utils/fileHash';
|
||||
import { FileAnalyzer } from './fileAnalyzer';
|
||||
import { ProcessingErrorHandler } from './processingErrorHandler';
|
||||
|
||||
// Set up PDF.js worker
|
||||
GlobalWorkerOptions.workerSrc = '/pdf.worker.js';
|
||||
|
||||
export class EnhancedPDFProcessingService {
|
||||
private static instance: EnhancedPDFProcessingService;
|
||||
private cache = new ProcessingCache();
|
||||
private processing = new Map<string, ProcessingState>();
|
||||
private processingListeners = new Set<(states: Map<string, ProcessingState>) => void>();
|
||||
private metrics: ProcessingMetrics = {
|
||||
totalFiles: 0,
|
||||
completedFiles: 0,
|
||||
failedFiles: 0,
|
||||
averageProcessingTime: 0,
|
||||
cacheHitRate: 0,
|
||||
memoryUsage: 0
|
||||
};
|
||||
|
||||
private defaultConfig: ProcessingConfig = {
|
||||
strategy: 'immediate_full',
|
||||
chunkSize: 20,
|
||||
thumbnailQuality: 'medium',
|
||||
priorityPageCount: 10,
|
||||
useWebWorker: false,
|
||||
maxRetries: 3,
|
||||
timeoutMs: 300000 // 5 minutes
|
||||
};
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): EnhancedPDFProcessingService {
|
||||
if (!EnhancedPDFProcessingService.instance) {
|
||||
EnhancedPDFProcessingService.instance = new EnhancedPDFProcessingService();
|
||||
}
|
||||
return EnhancedPDFProcessingService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a file with intelligent strategy selection
|
||||
*/
|
||||
async processFile(file: File, customConfig?: Partial<ProcessingConfig>): Promise<ProcessedFile | null> {
|
||||
const fileKey = await this.generateFileKey(file);
|
||||
|
||||
// Check cache first
|
||||
const cached = this.cache.get(fileKey);
|
||||
if (cached) {
|
||||
this.updateMetrics('cacheHit');
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Check if already processing
|
||||
if (this.processing.has(fileKey)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Analyze file to determine optimal strategy
|
||||
const analysis = await FileAnalyzer.analyzeFile(file);
|
||||
if (analysis.isCorrupted) {
|
||||
throw new Error(`File ${file.name} appears to be corrupted`);
|
||||
}
|
||||
|
||||
// Create processing config
|
||||
const config: ProcessingConfig = {
|
||||
...this.defaultConfig,
|
||||
strategy: analysis.recommendedStrategy,
|
||||
...customConfig
|
||||
};
|
||||
|
||||
// Start processing
|
||||
this.startProcessing(file, fileKey, config, analysis.estimatedProcessingTime);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start processing a file with the specified configuration
|
||||
*/
|
||||
private async startProcessing(
|
||||
file: File,
|
||||
fileKey: string,
|
||||
config: ProcessingConfig,
|
||||
estimatedTime: number
|
||||
): Promise<void> {
|
||||
// Create cancellation token
|
||||
const cancellationToken = ProcessingErrorHandler.createTimeoutController(config.timeoutMs);
|
||||
|
||||
// Set initial state
|
||||
const state: ProcessingState = {
|
||||
fileKey,
|
||||
fileName: file.name,
|
||||
status: 'processing',
|
||||
progress: 0,
|
||||
strategy: config.strategy,
|
||||
startedAt: Date.now(),
|
||||
estimatedTimeRemaining: estimatedTime,
|
||||
cancellationToken
|
||||
};
|
||||
|
||||
this.processing.set(fileKey, state);
|
||||
this.notifyListeners();
|
||||
this.updateMetrics('started');
|
||||
|
||||
try {
|
||||
// Execute processing with retry logic
|
||||
const processedFile = await ProcessingErrorHandler.executeWithRetry(
|
||||
() => this.executeProcessingStrategy(file, config, state),
|
||||
(error) => {
|
||||
state.error = error;
|
||||
this.notifyListeners();
|
||||
},
|
||||
config.maxRetries
|
||||
);
|
||||
|
||||
// Cache the result
|
||||
this.cache.set(fileKey, processedFile);
|
||||
|
||||
// Update state to completed
|
||||
state.status = 'completed';
|
||||
state.progress = 100;
|
||||
state.completedAt = Date.now();
|
||||
this.notifyListeners();
|
||||
this.updateMetrics('completed', Date.now() - state.startedAt);
|
||||
|
||||
// Remove from processing map after brief delay
|
||||
setTimeout(() => {
|
||||
this.processing.delete(fileKey);
|
||||
this.notifyListeners();
|
||||
}, 2000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Processing failed for', file.name, ':', error);
|
||||
|
||||
const processingError = ProcessingErrorHandler.createProcessingError(error);
|
||||
state.status = 'error';
|
||||
state.error = processingError;
|
||||
this.notifyListeners();
|
||||
this.updateMetrics('failed');
|
||||
|
||||
// Remove failed processing after delay
|
||||
setTimeout(() => {
|
||||
this.processing.delete(fileKey);
|
||||
this.notifyListeners();
|
||||
}, 10000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the actual processing based on strategy
|
||||
*/
|
||||
private async executeProcessingStrategy(
|
||||
file: File,
|
||||
config: ProcessingConfig,
|
||||
state: ProcessingState
|
||||
): Promise<ProcessedFile> {
|
||||
switch (config.strategy) {
|
||||
case 'immediate_full':
|
||||
return this.processImmediateFull(file, config, state);
|
||||
|
||||
case 'priority_pages':
|
||||
return this.processPriorityPages(file, config, state);
|
||||
|
||||
case 'progressive_chunked':
|
||||
return this.processProgressiveChunked(file, config, state);
|
||||
|
||||
case 'metadata_only':
|
||||
return this.processMetadataOnly(file, config, state);
|
||||
|
||||
default:
|
||||
return this.processImmediateFull(file, config, state);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all pages immediately (for small files)
|
||||
*/
|
||||
private async processImmediateFull(
|
||||
file: File,
|
||||
config: ProcessingConfig,
|
||||
state: ProcessingState
|
||||
): Promise<ProcessedFile> {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await getDocument({ data: arrayBuffer }).promise;
|
||||
const totalPages = pdf.numPages;
|
||||
|
||||
state.progress = 10;
|
||||
this.notifyListeners();
|
||||
|
||||
const pages: PDFPage[] = [];
|
||||
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
// Check for cancellation
|
||||
if (state.cancellationToken?.signal.aborted) {
|
||||
pdf.destroy();
|
||||
throw new Error('Processing cancelled');
|
||||
}
|
||||
|
||||
const page = await pdf.getPage(i);
|
||||
const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality);
|
||||
|
||||
pages.push({
|
||||
id: `${file.name}-page-${i}`,
|
||||
pageNumber: i,
|
||||
thumbnail,
|
||||
rotation: 0,
|
||||
selected: false
|
||||
});
|
||||
|
||||
// Update progress
|
||||
state.progress = 10 + (i / totalPages) * 85;
|
||||
state.currentPage = i;
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
pdf.destroy();
|
||||
state.progress = 100;
|
||||
this.notifyListeners();
|
||||
|
||||
return this.createProcessedFile(file, pages, totalPages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process priority pages first, then queue the rest
|
||||
*/
|
||||
private async processPriorityPages(
|
||||
file: File,
|
||||
config: ProcessingConfig,
|
||||
state: ProcessingState
|
||||
): Promise<ProcessedFile> {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await getDocument({ data: arrayBuffer }).promise;
|
||||
const totalPages = pdf.numPages;
|
||||
|
||||
state.progress = 10;
|
||||
this.notifyListeners();
|
||||
|
||||
const pages: PDFPage[] = [];
|
||||
const priorityCount = Math.min(config.priorityPageCount, totalPages);
|
||||
|
||||
// Process priority pages first
|
||||
for (let i = 1; i <= priorityCount; i++) {
|
||||
if (state.cancellationToken?.signal.aborted) {
|
||||
pdf.destroy();
|
||||
throw new Error('Processing cancelled');
|
||||
}
|
||||
|
||||
const page = await pdf.getPage(i);
|
||||
const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality);
|
||||
|
||||
pages.push({
|
||||
id: `${file.name}-page-${i}`,
|
||||
pageNumber: i,
|
||||
thumbnail,
|
||||
rotation: 0,
|
||||
selected: false
|
||||
});
|
||||
|
||||
state.progress = 10 + (i / priorityCount) * 60;
|
||||
state.currentPage = i;
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
// Create placeholder pages for remaining pages
|
||||
for (let i = priorityCount + 1; i <= totalPages; i++) {
|
||||
pages.push({
|
||||
id: `${file.name}-page-${i}`,
|
||||
pageNumber: i,
|
||||
thumbnail: null, // Will be loaded lazily
|
||||
rotation: 0,
|
||||
selected: false
|
||||
});
|
||||
}
|
||||
|
||||
pdf.destroy();
|
||||
state.progress = 100;
|
||||
this.notifyListeners();
|
||||
|
||||
return this.createProcessedFile(file, pages, totalPages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process in chunks with breaks between chunks
|
||||
*/
|
||||
private async processProgressiveChunked(
|
||||
file: File,
|
||||
config: ProcessingConfig,
|
||||
state: ProcessingState
|
||||
): Promise<ProcessedFile> {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await getDocument({ data: arrayBuffer }).promise;
|
||||
const totalPages = pdf.numPages;
|
||||
|
||||
state.progress = 10;
|
||||
this.notifyListeners();
|
||||
|
||||
const pages: PDFPage[] = [];
|
||||
const chunkSize = config.chunkSize;
|
||||
let processedPages = 0;
|
||||
|
||||
// Process first chunk immediately
|
||||
const firstChunkEnd = Math.min(chunkSize, totalPages);
|
||||
|
||||
for (let i = 1; i <= firstChunkEnd; i++) {
|
||||
if (state.cancellationToken?.signal.aborted) {
|
||||
pdf.destroy();
|
||||
throw new Error('Processing cancelled');
|
||||
}
|
||||
|
||||
const page = await pdf.getPage(i);
|
||||
const thumbnail = await this.renderPageThumbnail(page, config.thumbnailQuality);
|
||||
|
||||
pages.push({
|
||||
id: `${file.name}-page-${i}`,
|
||||
pageNumber: i,
|
||||
thumbnail,
|
||||
rotation: 0,
|
||||
selected: false
|
||||
});
|
||||
|
||||
processedPages++;
|
||||
state.progress = 10 + (processedPages / totalPages) * 70;
|
||||
state.currentPage = i;
|
||||
this.notifyListeners();
|
||||
|
||||
// Small delay to prevent UI blocking
|
||||
if (i % 5 === 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
}
|
||||
|
||||
// Create placeholders for remaining pages
|
||||
for (let i = firstChunkEnd + 1; i <= totalPages; i++) {
|
||||
pages.push({
|
||||
id: `${file.name}-page-${i}`,
|
||||
pageNumber: i,
|
||||
thumbnail: null,
|
||||
rotation: 0,
|
||||
selected: false
|
||||
});
|
||||
}
|
||||
|
||||
pdf.destroy();
|
||||
state.progress = 100;
|
||||
this.notifyListeners();
|
||||
|
||||
return this.createProcessedFile(file, pages, totalPages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process metadata only (for very large files)
|
||||
*/
|
||||
private async processMetadataOnly(
|
||||
file: File,
|
||||
config: ProcessingConfig,
|
||||
state: ProcessingState
|
||||
): Promise<ProcessedFile> {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await getDocument({ data: arrayBuffer }).promise;
|
||||
const totalPages = pdf.numPages;
|
||||
|
||||
state.progress = 50;
|
||||
this.notifyListeners();
|
||||
|
||||
// Create placeholder pages without thumbnails
|
||||
const pages: PDFPage[] = [];
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pages.push({
|
||||
id: `${file.name}-page-${i}`,
|
||||
pageNumber: i,
|
||||
thumbnail: null,
|
||||
rotation: 0,
|
||||
selected: false
|
||||
});
|
||||
}
|
||||
|
||||
pdf.destroy();
|
||||
state.progress = 100;
|
||||
this.notifyListeners();
|
||||
|
||||
return this.createProcessedFile(file, pages, totalPages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a page thumbnail with specified quality
|
||||
*/
|
||||
private async renderPageThumbnail(page: any, quality: 'low' | 'medium' | 'high'): Promise<string> {
|
||||
const scales = { low: 0.2, medium: 0.5, high: 0.8 }; // Reduced low quality for page editor
|
||||
const scale = scales[quality];
|
||||
|
||||
const viewport = page.getViewport({ scale });
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('Could not get canvas context');
|
||||
}
|
||||
|
||||
await page.render({ canvasContext: context, viewport }).promise;
|
||||
return canvas.toDataURL('image/jpeg', 0.8); // Use JPEG for better compression
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a ProcessedFile object
|
||||
*/
|
||||
private createProcessedFile(file: File, pages: PDFPage[], totalPages: number): ProcessedFile {
|
||||
return {
|
||||
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
pages,
|
||||
totalPages,
|
||||
metadata: {
|
||||
title: file.name,
|
||||
createdAt: new Date().toISOString(),
|
||||
modifiedAt: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Generate a unique, collision-resistant cache key
|
||||
*/
|
||||
private async generateFileKey(file: File): Promise<string> {
|
||||
return await FileHasher.generateHybridHash(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel processing for a specific file
|
||||
*/
|
||||
cancelProcessing(fileKey: string): void {
|
||||
const state = this.processing.get(fileKey);
|
||||
if (state && state.cancellationToken) {
|
||||
state.cancellationToken.abort();
|
||||
state.status = 'cancelled';
|
||||
this.notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update processing metrics
|
||||
*/
|
||||
private updateMetrics(event: 'started' | 'completed' | 'failed' | 'cacheHit', processingTime?: number): void {
|
||||
switch (event) {
|
||||
case 'started':
|
||||
this.metrics.totalFiles++;
|
||||
break;
|
||||
case 'completed':
|
||||
this.metrics.completedFiles++;
|
||||
if (processingTime) {
|
||||
// Update rolling average
|
||||
const totalProcessingTime = this.metrics.averageProcessingTime * (this.metrics.completedFiles - 1) + processingTime;
|
||||
this.metrics.averageProcessingTime = totalProcessingTime / this.metrics.completedFiles;
|
||||
}
|
||||
break;
|
||||
case 'failed':
|
||||
this.metrics.failedFiles++;
|
||||
break;
|
||||
case 'cacheHit':
|
||||
// Update cache hit rate
|
||||
const totalAttempts = this.metrics.totalFiles + 1;
|
||||
this.metrics.cacheHitRate = (this.metrics.cacheHitRate * this.metrics.totalFiles + 1) / totalAttempts;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get processing metrics
|
||||
*/
|
||||
getMetrics(): ProcessingMetrics {
|
||||
return { ...this.metrics };
|
||||
}
|
||||
|
||||
/**
|
||||
* State subscription for components
|
||||
*/
|
||||
onProcessingChange(callback: (states: Map<string, ProcessingState>) => void): () => void {
|
||||
this.processingListeners.add(callback);
|
||||
return () => this.processingListeners.delete(callback);
|
||||
}
|
||||
|
||||
getProcessingStates(): Map<string, ProcessingState> {
|
||||
return new Map(this.processing);
|
||||
}
|
||||
|
||||
private notifyListeners(): void {
|
||||
this.processingListeners.forEach(callback => callback(this.processing));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup method for removed files
|
||||
*/
|
||||
cleanup(removedFiles: File[]): void {
|
||||
removedFiles.forEach(async (file) => {
|
||||
const key = await this.generateFileKey(file);
|
||||
this.cache.delete(key);
|
||||
this.cancelProcessing(key);
|
||||
this.processing.delete(key);
|
||||
});
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all processing for view switches
|
||||
*/
|
||||
clearAllProcessing(): void {
|
||||
// Cancel all ongoing processing
|
||||
this.processing.forEach((state, key) => {
|
||||
if (state.cancellationToken) {
|
||||
state.cancellationToken.abort();
|
||||
}
|
||||
});
|
||||
|
||||
// Clear processing states
|
||||
this.processing.clear();
|
||||
this.notifyListeners();
|
||||
|
||||
// Force memory cleanup hint
|
||||
if (typeof window !== 'undefined' && window.gc) {
|
||||
setTimeout(() => window.gc(), 100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
*/
|
||||
getCacheStats() {
|
||||
return this.cache.getStats();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cache and processing
|
||||
*/
|
||||
clearAll(): void {
|
||||
this.cache.clear();
|
||||
this.processing.clear();
|
||||
this.notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const enhancedPDFProcessingService = EnhancedPDFProcessingService.getInstance();
|
240
frontend/src/services/fileAnalyzer.ts
Normal file
240
frontend/src/services/fileAnalyzer.ts
Normal file
@ -0,0 +1,240 @@
|
||||
import { getDocument } from 'pdfjs-dist';
|
||||
import { FileAnalysis, ProcessingStrategy } from '../types/processing';
|
||||
|
||||
export class FileAnalyzer {
|
||||
private static readonly SIZE_THRESHOLDS = {
|
||||
SMALL: 10 * 1024 * 1024, // 10MB
|
||||
MEDIUM: 50 * 1024 * 1024, // 50MB
|
||||
LARGE: 200 * 1024 * 1024, // 200MB
|
||||
};
|
||||
|
||||
private static readonly PAGE_THRESHOLDS = {
|
||||
FEW: 10, // < 10 pages - immediate full processing
|
||||
MANY: 50, // < 50 pages - priority pages
|
||||
MASSIVE: 100, // < 100 pages - progressive chunked
|
||||
// >100 pages = metadata only
|
||||
};
|
||||
|
||||
/**
|
||||
* Analyze a file to determine optimal processing strategy
|
||||
*/
|
||||
static async analyzeFile(file: File): Promise<FileAnalysis> {
|
||||
const analysis: FileAnalysis = {
|
||||
fileSize: file.size,
|
||||
isEncrypted: false,
|
||||
isCorrupted: false,
|
||||
recommendedStrategy: 'metadata_only',
|
||||
estimatedProcessingTime: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
// Quick validation and page count estimation
|
||||
const quickAnalysis = await this.quickPDFAnalysis(file);
|
||||
analysis.estimatedPageCount = quickAnalysis.pageCount;
|
||||
analysis.isEncrypted = quickAnalysis.isEncrypted;
|
||||
analysis.isCorrupted = quickAnalysis.isCorrupted;
|
||||
|
||||
// Determine strategy based on file characteristics
|
||||
analysis.recommendedStrategy = this.determineStrategy(file.size, quickAnalysis.pageCount);
|
||||
|
||||
// Estimate processing time
|
||||
analysis.estimatedProcessingTime = this.estimateProcessingTime(
|
||||
file.size,
|
||||
quickAnalysis.pageCount,
|
||||
analysis.recommendedStrategy
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
console.error('File analysis failed:', error);
|
||||
analysis.isCorrupted = true;
|
||||
analysis.recommendedStrategy = 'metadata_only';
|
||||
}
|
||||
|
||||
return analysis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick PDF analysis without full processing
|
||||
*/
|
||||
private static async quickPDFAnalysis(file: File): Promise<{
|
||||
pageCount: number;
|
||||
isEncrypted: boolean;
|
||||
isCorrupted: boolean;
|
||||
}> {
|
||||
try {
|
||||
// For small files, read the whole file
|
||||
// For large files, try the whole file first (PDF.js needs the complete structure)
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
|
||||
const pdf = await getDocument({
|
||||
data: arrayBuffer,
|
||||
stopAtErrors: false, // Don't stop at minor errors
|
||||
verbosity: 0 // Suppress PDF.js warnings
|
||||
}).promise;
|
||||
|
||||
const pageCount = pdf.numPages;
|
||||
const isEncrypted = pdf.isEncrypted;
|
||||
|
||||
// Clean up
|
||||
pdf.destroy();
|
||||
|
||||
return {
|
||||
pageCount,
|
||||
isEncrypted,
|
||||
isCorrupted: false
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
// Try to determine if it's corruption vs encryption
|
||||
const errorMessage = error instanceof Error ? error.message.toLowerCase() : '';
|
||||
const isEncrypted = errorMessage.includes('password') || errorMessage.includes('encrypted');
|
||||
|
||||
return {
|
||||
pageCount: 0,
|
||||
isEncrypted,
|
||||
isCorrupted: !isEncrypted // If not encrypted, probably corrupted
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the best processing strategy based on file characteristics
|
||||
*/
|
||||
private static determineStrategy(fileSize: number, pageCount?: number): ProcessingStrategy {
|
||||
// Handle corrupted or encrypted files
|
||||
if (!pageCount || pageCount === 0) {
|
||||
return 'metadata_only';
|
||||
}
|
||||
|
||||
// Small files with few pages - process everything immediately
|
||||
if (fileSize <= this.SIZE_THRESHOLDS.SMALL && pageCount <= this.PAGE_THRESHOLDS.FEW) {
|
||||
return 'immediate_full';
|
||||
}
|
||||
|
||||
// Medium files or many pages - priority pages first, then progressive
|
||||
if (fileSize <= this.SIZE_THRESHOLDS.MEDIUM && pageCount <= this.PAGE_THRESHOLDS.MANY) {
|
||||
return 'priority_pages';
|
||||
}
|
||||
|
||||
// Large files or massive page counts - chunked processing
|
||||
if (fileSize <= this.SIZE_THRESHOLDS.LARGE && pageCount <= this.PAGE_THRESHOLDS.MASSIVE) {
|
||||
return 'progressive_chunked';
|
||||
}
|
||||
|
||||
// Very large files - metadata only
|
||||
return 'metadata_only';
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate processing time based on file characteristics and strategy
|
||||
*/
|
||||
private static estimateProcessingTime(
|
||||
fileSize: number,
|
||||
pageCount: number = 0,
|
||||
strategy: ProcessingStrategy
|
||||
): number {
|
||||
const baseTimes = {
|
||||
immediate_full: 200, // 200ms per page
|
||||
priority_pages: 150, // 150ms per page (optimized)
|
||||
progressive_chunked: 100, // 100ms per page (chunked)
|
||||
metadata_only: 50 // 50ms total
|
||||
};
|
||||
|
||||
const baseTime = baseTimes[strategy];
|
||||
|
||||
switch (strategy) {
|
||||
case 'metadata_only':
|
||||
return baseTime;
|
||||
|
||||
case 'immediate_full':
|
||||
return pageCount * baseTime;
|
||||
|
||||
case 'priority_pages':
|
||||
// Estimate time for priority pages (first 10)
|
||||
const priorityPages = Math.min(pageCount, 10);
|
||||
return priorityPages * baseTime;
|
||||
|
||||
case 'progressive_chunked':
|
||||
// Estimate time for first chunk (20 pages)
|
||||
const firstChunk = Math.min(pageCount, 20);
|
||||
return firstChunk * baseTime;
|
||||
|
||||
default:
|
||||
return pageCount * baseTime;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get processing recommendations for a set of files
|
||||
*/
|
||||
static async analyzeMultipleFiles(files: File[]): Promise<{
|
||||
analyses: Map<File, FileAnalysis>;
|
||||
recommendations: {
|
||||
totalEstimatedTime: number;
|
||||
suggestedBatchSize: number;
|
||||
shouldUseWebWorker: boolean;
|
||||
memoryWarning: boolean;
|
||||
};
|
||||
}> {
|
||||
const analyses = new Map<File, FileAnalysis>();
|
||||
let totalEstimatedTime = 0;
|
||||
let totalSize = 0;
|
||||
let totalPages = 0;
|
||||
|
||||
// Analyze each file
|
||||
for (const file of files) {
|
||||
const analysis = await this.analyzeFile(file);
|
||||
analyses.set(file, analysis);
|
||||
totalEstimatedTime += analysis.estimatedProcessingTime;
|
||||
totalSize += file.size;
|
||||
totalPages += analysis.estimatedPageCount || 0;
|
||||
}
|
||||
|
||||
// Generate recommendations
|
||||
const recommendations = {
|
||||
totalEstimatedTime,
|
||||
suggestedBatchSize: this.calculateBatchSize(files.length, totalSize),
|
||||
shouldUseWebWorker: totalPages > 100 || totalSize > this.SIZE_THRESHOLDS.MEDIUM,
|
||||
memoryWarning: totalSize > this.SIZE_THRESHOLDS.LARGE || totalPages > this.PAGE_THRESHOLDS.MASSIVE
|
||||
};
|
||||
|
||||
return { analyses, recommendations };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate optimal batch size for processing multiple files
|
||||
*/
|
||||
private static calculateBatchSize(fileCount: number, totalSize: number): number {
|
||||
// Process small batches for large total sizes
|
||||
if (totalSize > this.SIZE_THRESHOLDS.LARGE) {
|
||||
return Math.max(1, Math.floor(fileCount / 4));
|
||||
}
|
||||
|
||||
if (totalSize > this.SIZE_THRESHOLDS.MEDIUM) {
|
||||
return Math.max(2, Math.floor(fileCount / 2));
|
||||
}
|
||||
|
||||
// Process all at once for smaller total sizes
|
||||
return fileCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file appears to be a valid PDF
|
||||
*/
|
||||
static async isValidPDF(file: File): Promise<boolean> {
|
||||
if (file.type !== 'application/pdf' && !file.name.toLowerCase().endsWith('.pdf')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Read first few bytes to check PDF header
|
||||
const header = file.slice(0, 8);
|
||||
const headerBytes = new Uint8Array(await header.arrayBuffer());
|
||||
const headerString = String.fromCharCode(...headerBytes);
|
||||
|
||||
return headerString.startsWith('%PDF-');
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
@ -12,12 +12,12 @@ export class PDFExportService {
|
||||
* Export PDF document with applied operations
|
||||
*/
|
||||
async exportPDF(
|
||||
pdfDocument: PDFDocument,
|
||||
pdfDocument: PDFDocument,
|
||||
selectedPageIds: string[] = [],
|
||||
options: ExportOptions = {}
|
||||
): Promise<{ blob: Blob; filename: string } | { blobs: Blob[]; filenames: string[] }> {
|
||||
const { selectedOnly = false, filename, splitDocuments = false } = options;
|
||||
|
||||
|
||||
try {
|
||||
// Determine which pages to export
|
||||
const pagesToExport = selectedOnly && selectedPageIds.length > 0
|
||||
@ -57,16 +57,16 @@ export class PDFExportService {
|
||||
for (const page of pages) {
|
||||
// Get the original page from source document
|
||||
const sourcePageIndex = page.pageNumber - 1;
|
||||
|
||||
|
||||
if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) {
|
||||
// Copy the page
|
||||
const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]);
|
||||
|
||||
|
||||
// Apply rotation
|
||||
if (page.rotation !== 0) {
|
||||
copiedPage.setRotation(degrees(page.rotation));
|
||||
}
|
||||
|
||||
|
||||
newDoc.addPage(copiedPage);
|
||||
}
|
||||
}
|
||||
@ -108,20 +108,20 @@ export class PDFExportService {
|
||||
|
||||
for (const endIndex of splitPoints) {
|
||||
const segmentPages = pages.slice(startIndex, endIndex);
|
||||
|
||||
|
||||
if (segmentPages.length > 0) {
|
||||
const newDoc = await PDFLibDocument.create();
|
||||
|
||||
|
||||
for (const page of segmentPages) {
|
||||
const sourcePageIndex = page.pageNumber - 1;
|
||||
|
||||
|
||||
if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) {
|
||||
const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]);
|
||||
|
||||
|
||||
if (page.rotation !== 0) {
|
||||
copiedPage.setRotation(degrees(page.rotation));
|
||||
}
|
||||
|
||||
|
||||
newDoc.addPage(copiedPage);
|
||||
}
|
||||
}
|
||||
@ -130,16 +130,16 @@ export class PDFExportService {
|
||||
newDoc.setCreator('Stirling PDF');
|
||||
newDoc.setProducer('Stirling PDF');
|
||||
newDoc.setTitle(`${baseFilename} - Part ${partNumber}`);
|
||||
|
||||
|
||||
const pdfBytes = await newDoc.save();
|
||||
const blob = new Blob([pdfBytes], { type: 'application/pdf' });
|
||||
const filename = this.generateSplitFilename(baseFilename, partNumber);
|
||||
|
||||
|
||||
blobs.push(blob);
|
||||
filenames.push(filename);
|
||||
partNumber++;
|
||||
}
|
||||
|
||||
|
||||
startIndex = endIndex;
|
||||
}
|
||||
|
||||
@ -172,11 +172,11 @@ export class PDFExportService {
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.style.display = 'none';
|
||||
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
|
||||
// Clean up the URL after a short delay
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
@ -185,8 +185,7 @@ export class PDFExportService {
|
||||
* Download multiple files as a ZIP
|
||||
*/
|
||||
async downloadAsZip(blobs: Blob[], filenames: string[], zipFilename: string): Promise<void> {
|
||||
// For now, download files individually
|
||||
// TODO: Implement ZIP creation when needed
|
||||
// For now, download files wherindividually
|
||||
blobs.forEach((blob, index) => {
|
||||
setTimeout(() => {
|
||||
this.downloadFile(blob, filenames[index]);
|
||||
@ -208,7 +207,7 @@ export class PDFExportService {
|
||||
errors.push('No pages available to export');
|
||||
}
|
||||
|
||||
const pagesToExport = selectedOnly
|
||||
const pagesToExport = selectedOnly
|
||||
? pdfDocument.pages.filter(page => selectedPageIds.includes(page.id))
|
||||
: pdfDocument.pages;
|
||||
|
||||
@ -227,7 +226,7 @@ export class PDFExportService {
|
||||
splitCount: number;
|
||||
estimatedSize: string;
|
||||
} {
|
||||
const pagesToExport = selectedOnly
|
||||
const pagesToExport = selectedOnly
|
||||
? pdfDocument.pages.filter(page => selectedPageIds.includes(page.id))
|
||||
: pdfDocument.pages;
|
||||
|
||||
@ -260,4 +259,4 @@ export class PDFExportService {
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const pdfExportService = new PDFExportService();
|
||||
export const pdfExportService = new PDFExportService();
|
||||
|
188
frontend/src/services/pdfProcessingService.ts
Normal file
188
frontend/src/services/pdfProcessingService.ts
Normal file
@ -0,0 +1,188 @@
|
||||
import { getDocument, GlobalWorkerOptions } from 'pdfjs-dist';
|
||||
import { ProcessedFile, ProcessingState, PDFPage } from '../types/processing';
|
||||
import { ProcessingCache } from './processingCache';
|
||||
|
||||
// Set up PDF.js worker
|
||||
GlobalWorkerOptions.workerSrc = '/pdf.worker.js';
|
||||
|
||||
export class PDFProcessingService {
|
||||
private static instance: PDFProcessingService;
|
||||
private cache = new ProcessingCache();
|
||||
private processing = new Map<string, ProcessingState>();
|
||||
private processingListeners = new Set<(states: Map<string, ProcessingState>) => void>();
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): PDFProcessingService {
|
||||
if (!PDFProcessingService.instance) {
|
||||
PDFProcessingService.instance = new PDFProcessingService();
|
||||
}
|
||||
return PDFProcessingService.instance;
|
||||
}
|
||||
|
||||
async getProcessedFile(file: File): Promise<ProcessedFile | null> {
|
||||
const fileKey = this.generateFileKey(file);
|
||||
|
||||
// Check cache first
|
||||
const cached = this.cache.get(fileKey);
|
||||
if (cached) {
|
||||
console.log('Cache hit for:', file.name);
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Check if already processing
|
||||
if (this.processing.has(fileKey)) {
|
||||
console.log('Already processing:', file.name);
|
||||
return null; // Will be available when processing completes
|
||||
}
|
||||
|
||||
// Start processing
|
||||
this.startProcessing(file, fileKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
private async startProcessing(file: File, fileKey: string): Promise<void> {
|
||||
// Set initial state
|
||||
const state: ProcessingState = {
|
||||
fileKey,
|
||||
fileName: file.name,
|
||||
status: 'processing',
|
||||
progress: 0,
|
||||
startedAt: Date.now()
|
||||
};
|
||||
|
||||
this.processing.set(fileKey, state);
|
||||
this.notifyListeners();
|
||||
|
||||
try {
|
||||
// Process the file with progress updates
|
||||
const processedFile = await this.processFileWithProgress(file, (progress) => {
|
||||
state.progress = progress;
|
||||
this.notifyListeners();
|
||||
});
|
||||
|
||||
// Cache the result
|
||||
this.cache.set(fileKey, processedFile);
|
||||
|
||||
// Update state to completed
|
||||
state.status = 'completed';
|
||||
state.progress = 100;
|
||||
state.completedAt = Date.now();
|
||||
this.notifyListeners();
|
||||
|
||||
// Remove from processing map after brief delay
|
||||
setTimeout(() => {
|
||||
this.processing.delete(fileKey);
|
||||
this.notifyListeners();
|
||||
}, 2000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Processing failed for', file.name, ':', error);
|
||||
state.status = 'error';
|
||||
state.error = error instanceof Error ? error.message : 'Unknown error';
|
||||
this.notifyListeners();
|
||||
|
||||
// Remove failed processing after delay
|
||||
setTimeout(() => {
|
||||
this.processing.delete(fileKey);
|
||||
this.notifyListeners();
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
private async processFileWithProgress(
|
||||
file: File,
|
||||
onProgress: (progress: number) => void
|
||||
): Promise<ProcessedFile> {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await getDocument({ data: arrayBuffer }).promise;
|
||||
const totalPages = pdf.numPages;
|
||||
|
||||
onProgress(10); // PDF loaded
|
||||
|
||||
const pages: PDFPage[] = [];
|
||||
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
const page = await pdf.getPage(i);
|
||||
const viewport = page.getViewport({ scale: 0.5 });
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
if (context) {
|
||||
await page.render({ canvasContext: context, viewport }).promise;
|
||||
const thumbnail = canvas.toDataURL();
|
||||
|
||||
pages.push({
|
||||
id: `${file.name}-page-${i}`,
|
||||
pageNumber: i,
|
||||
thumbnail,
|
||||
rotation: 0,
|
||||
selected: false
|
||||
});
|
||||
}
|
||||
|
||||
// Update progress
|
||||
const progress = 10 + (i / totalPages) * 85; // 10-95%
|
||||
onProgress(progress);
|
||||
}
|
||||
|
||||
pdf.destroy();
|
||||
onProgress(100);
|
||||
|
||||
return {
|
||||
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
pages,
|
||||
totalPages,
|
||||
metadata: {
|
||||
title: file.name,
|
||||
createdAt: new Date().toISOString(),
|
||||
modifiedAt: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// State subscription for components
|
||||
onProcessingChange(callback: (states: Map<string, ProcessingState>) => void): () => void {
|
||||
this.processingListeners.add(callback);
|
||||
return () => this.processingListeners.delete(callback);
|
||||
}
|
||||
|
||||
getProcessingStates(): Map<string, ProcessingState> {
|
||||
return new Map(this.processing);
|
||||
}
|
||||
|
||||
private notifyListeners(): void {
|
||||
this.processingListeners.forEach(callback => callback(this.processing));
|
||||
}
|
||||
|
||||
generateFileKey(file: File): string {
|
||||
return `${file.name}-${file.size}-${file.lastModified}`;
|
||||
}
|
||||
|
||||
// Cleanup method for activeFiles changes
|
||||
cleanup(removedFiles: File[]): void {
|
||||
removedFiles.forEach(file => {
|
||||
const key = this.generateFileKey(file);
|
||||
this.cache.delete(key);
|
||||
this.processing.delete(key);
|
||||
});
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
// Get cache stats (for debugging)
|
||||
getCacheStats() {
|
||||
return this.cache.getStats();
|
||||
}
|
||||
|
||||
// Clear all cache and processing
|
||||
clearAll(): void {
|
||||
this.cache.clear();
|
||||
this.processing.clear();
|
||||
this.notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const pdfProcessingService = PDFProcessingService.getInstance();
|
138
frontend/src/services/processingCache.ts
Normal file
138
frontend/src/services/processingCache.ts
Normal file
@ -0,0 +1,138 @@
|
||||
import { ProcessedFile, CacheConfig, CacheEntry, CacheStats } from '../types/processing';
|
||||
|
||||
export class ProcessingCache {
|
||||
private cache = new Map<string, CacheEntry>();
|
||||
private totalSize = 0;
|
||||
|
||||
constructor(private config: CacheConfig = {
|
||||
maxFiles: 20,
|
||||
maxSizeBytes: 2 * 1024 * 1024 * 1024, // 2GB
|
||||
ttlMs: 30 * 60 * 1000 // 30 minutes
|
||||
}) {}
|
||||
|
||||
set(key: string, data: ProcessedFile): void {
|
||||
// Remove expired entries first
|
||||
this.cleanup();
|
||||
|
||||
// Calculate entry size (rough estimate)
|
||||
const size = this.calculateSize(data);
|
||||
|
||||
// Make room if needed
|
||||
this.makeRoom(size);
|
||||
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
size,
|
||||
lastAccessed: Date.now(),
|
||||
createdAt: Date.now()
|
||||
});
|
||||
|
||||
this.totalSize += size;
|
||||
}
|
||||
|
||||
get(key: string): ProcessedFile | null {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) return null;
|
||||
|
||||
// Check TTL
|
||||
if (Date.now() - entry.createdAt > this.config.ttlMs) {
|
||||
this.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Update last accessed
|
||||
entry.lastAccessed = Date.now();
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
has(key: string): boolean {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) return false;
|
||||
|
||||
// Check TTL
|
||||
if (Date.now() - entry.createdAt > this.config.ttlMs) {
|
||||
this.delete(key);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private makeRoom(neededSize: number): void {
|
||||
// Remove oldest entries until we have space
|
||||
while (
|
||||
this.cache.size >= this.config.maxFiles ||
|
||||
this.totalSize + neededSize > this.config.maxSizeBytes
|
||||
) {
|
||||
const oldestKey = this.findOldestEntry();
|
||||
if (oldestKey) {
|
||||
this.delete(oldestKey);
|
||||
} else break;
|
||||
}
|
||||
}
|
||||
|
||||
private findOldestEntry(): string | null {
|
||||
let oldest: { key: string; lastAccessed: number } | null = null;
|
||||
|
||||
for (const [key, entry] of this.cache) {
|
||||
if (!oldest || entry.lastAccessed < oldest.lastAccessed) {
|
||||
oldest = { key, lastAccessed: entry.lastAccessed };
|
||||
}
|
||||
}
|
||||
|
||||
return oldest?.key || null;
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of this.cache) {
|
||||
if (now - entry.createdAt > this.config.ttlMs) {
|
||||
this.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private calculateSize(data: ProcessedFile): number {
|
||||
// Rough size estimation
|
||||
let size = 0;
|
||||
|
||||
// Estimate size of thumbnails (main memory consumer)
|
||||
data.pages.forEach(page => {
|
||||
if (page.thumbnail) {
|
||||
// Base64 thumbnail is roughly 50KB each
|
||||
size += 50 * 1024;
|
||||
}
|
||||
});
|
||||
|
||||
// Add some overhead for other data
|
||||
size += 10 * 1024; // 10KB overhead
|
||||
|
||||
return size;
|
||||
}
|
||||
|
||||
delete(key: string): void {
|
||||
const entry = this.cache.get(key);
|
||||
if (entry) {
|
||||
this.totalSize -= entry.size;
|
||||
this.cache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear();
|
||||
this.totalSize = 0;
|
||||
}
|
||||
|
||||
getStats(): CacheStats {
|
||||
return {
|
||||
entries: this.cache.size,
|
||||
totalSizeBytes: this.totalSize,
|
||||
maxSizeBytes: this.config.maxSizeBytes
|
||||
};
|
||||
}
|
||||
|
||||
// Get all cached keys (for debugging and cleanup)
|
||||
getKeys(): string[] {
|
||||
return Array.from(this.cache.keys());
|
||||
}
|
||||
}
|
282
frontend/src/services/processingErrorHandler.ts
Normal file
282
frontend/src/services/processingErrorHandler.ts
Normal file
@ -0,0 +1,282 @@
|
||||
import { ProcessingError } from '../types/processing';
|
||||
|
||||
export class ProcessingErrorHandler {
|
||||
private static readonly DEFAULT_MAX_RETRIES = 3;
|
||||
private static readonly RETRY_DELAYS = [1000, 2000, 4000]; // Progressive backoff in ms
|
||||
|
||||
/**
|
||||
* Create a ProcessingError from an unknown error
|
||||
*/
|
||||
static createProcessingError(
|
||||
error: unknown,
|
||||
retryCount: number = 0,
|
||||
maxRetries: number = this.DEFAULT_MAX_RETRIES
|
||||
): ProcessingError {
|
||||
const originalError = error instanceof Error ? error : new Error(String(error));
|
||||
const message = originalError.message;
|
||||
|
||||
// Determine error type based on error message and properties
|
||||
const errorType = this.determineErrorType(originalError, message);
|
||||
|
||||
// Determine if error is recoverable
|
||||
const recoverable = this.isRecoverable(errorType, retryCount, maxRetries);
|
||||
|
||||
return {
|
||||
type: errorType,
|
||||
message: this.formatErrorMessage(errorType, message),
|
||||
recoverable,
|
||||
retryCount,
|
||||
maxRetries,
|
||||
originalError
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the type of error based on error characteristics
|
||||
*/
|
||||
private static determineErrorType(error: Error, message: string): ProcessingError['type'] {
|
||||
const lowerMessage = message.toLowerCase();
|
||||
|
||||
// Network-related errors
|
||||
if (lowerMessage.includes('network') ||
|
||||
lowerMessage.includes('fetch') ||
|
||||
lowerMessage.includes('connection')) {
|
||||
return 'network';
|
||||
}
|
||||
|
||||
// Memory-related errors
|
||||
if (lowerMessage.includes('memory') ||
|
||||
lowerMessage.includes('quota') ||
|
||||
lowerMessage.includes('allocation') ||
|
||||
error.name === 'QuotaExceededError') {
|
||||
return 'memory';
|
||||
}
|
||||
|
||||
// Timeout errors
|
||||
if (lowerMessage.includes('timeout') ||
|
||||
lowerMessage.includes('aborted') ||
|
||||
error.name === 'AbortError') {
|
||||
return 'timeout';
|
||||
}
|
||||
|
||||
// Cancellation
|
||||
if (lowerMessage.includes('cancel') ||
|
||||
lowerMessage.includes('abort') ||
|
||||
error.name === 'AbortError') {
|
||||
return 'cancelled';
|
||||
}
|
||||
|
||||
// PDF corruption/parsing errors
|
||||
if (lowerMessage.includes('pdf') ||
|
||||
lowerMessage.includes('parse') ||
|
||||
lowerMessage.includes('invalid') ||
|
||||
lowerMessage.includes('corrupt') ||
|
||||
lowerMessage.includes('malformed')) {
|
||||
return 'corruption';
|
||||
}
|
||||
|
||||
// Default to parsing error
|
||||
return 'parsing';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if an error is recoverable based on type and retry count
|
||||
*/
|
||||
private static isRecoverable(
|
||||
errorType: ProcessingError['type'],
|
||||
retryCount: number,
|
||||
maxRetries: number
|
||||
): boolean {
|
||||
// Never recoverable
|
||||
if (errorType === 'cancelled' || errorType === 'corruption') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Recoverable if we haven't exceeded retry count
|
||||
if (retryCount >= maxRetries) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Memory errors are usually not recoverable
|
||||
if (errorType === 'memory') {
|
||||
return retryCount < 1; // Only one retry for memory errors
|
||||
}
|
||||
|
||||
// Network and timeout errors are usually recoverable
|
||||
return errorType === 'network' || errorType === 'timeout' || errorType === 'parsing';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format error message for user display
|
||||
*/
|
||||
private static formatErrorMessage(errorType: ProcessingError['type'], originalMessage: string): string {
|
||||
switch (errorType) {
|
||||
case 'network':
|
||||
return 'Network connection failed. Please check your internet connection and try again.';
|
||||
|
||||
case 'memory':
|
||||
return 'Insufficient memory to process this file. Try closing other applications or processing a smaller file.';
|
||||
|
||||
case 'timeout':
|
||||
return 'Processing timed out. This file may be too large or complex to process.';
|
||||
|
||||
case 'cancelled':
|
||||
return 'Processing was cancelled by user.';
|
||||
|
||||
case 'corruption':
|
||||
return 'This PDF file appears to be corrupted or encrypted. Please try a different file.';
|
||||
|
||||
case 'parsing':
|
||||
return `Failed to process PDF: ${originalMessage}`;
|
||||
|
||||
default:
|
||||
return `Processing failed: ${originalMessage}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an operation with automatic retry logic
|
||||
*/
|
||||
static async executeWithRetry<T>(
|
||||
operation: () => Promise<T>,
|
||||
onError?: (error: ProcessingError) => void,
|
||||
maxRetries: number = this.DEFAULT_MAX_RETRIES
|
||||
): Promise<T> {
|
||||
let lastError: ProcessingError | null = null;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
lastError = this.createProcessingError(error, attempt, maxRetries);
|
||||
|
||||
// Notify error handler
|
||||
if (onError) {
|
||||
onError(lastError);
|
||||
}
|
||||
|
||||
// Don't retry if not recoverable
|
||||
if (!lastError.recoverable) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Don't retry on last attempt
|
||||
if (attempt === maxRetries) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Wait before retry with progressive backoff
|
||||
const delay = this.RETRY_DELAYS[Math.min(attempt, this.RETRY_DELAYS.length - 1)];
|
||||
await this.delay(delay);
|
||||
|
||||
console.log(`Retrying operation (attempt ${attempt + 2}/${maxRetries + 1}) after ${delay}ms delay`);
|
||||
}
|
||||
}
|
||||
|
||||
// All retries exhausted
|
||||
throw lastError || new Error('Operation failed after all retries');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a timeout wrapper for operations
|
||||
*/
|
||||
static withTimeout<T>(
|
||||
operation: () => Promise<T>,
|
||||
timeoutMs: number,
|
||||
timeoutMessage: string = 'Operation timed out'
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
reject(new Error(timeoutMessage));
|
||||
}, timeoutMs);
|
||||
|
||||
operation()
|
||||
.then(result => {
|
||||
clearTimeout(timeoutId);
|
||||
resolve(result);
|
||||
})
|
||||
.catch(error => {
|
||||
clearTimeout(timeoutId);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an AbortController that times out after specified duration
|
||||
*/
|
||||
static createTimeoutController(timeoutMs: number): AbortController {
|
||||
const controller = new AbortController();
|
||||
|
||||
setTimeout(() => {
|
||||
controller.abort();
|
||||
}, timeoutMs);
|
||||
|
||||
return controller;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error indicates the operation should be retried
|
||||
*/
|
||||
static shouldRetry(error: ProcessingError): boolean {
|
||||
return error.recoverable && error.retryCount < error.maxRetries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-friendly suggestions based on error type
|
||||
*/
|
||||
static getErrorSuggestions(error: ProcessingError): string[] {
|
||||
switch (error.type) {
|
||||
case 'network':
|
||||
return [
|
||||
'Check your internet connection',
|
||||
'Try refreshing the page',
|
||||
'Try again in a few moments'
|
||||
];
|
||||
|
||||
case 'memory':
|
||||
return [
|
||||
'Close other browser tabs or applications',
|
||||
'Try processing a smaller file',
|
||||
'Restart your browser',
|
||||
'Use a device with more memory'
|
||||
];
|
||||
|
||||
case 'timeout':
|
||||
return [
|
||||
'Try processing a smaller file',
|
||||
'Break large files into smaller sections',
|
||||
'Check your internet connection speed'
|
||||
];
|
||||
|
||||
case 'corruption':
|
||||
return [
|
||||
'Verify the PDF file opens in other applications',
|
||||
'Try re-downloading the file',
|
||||
'Try a different PDF file',
|
||||
'Contact the file creator if it appears corrupted'
|
||||
];
|
||||
|
||||
case 'parsing':
|
||||
return [
|
||||
'Verify this is a valid PDF file',
|
||||
'Try a different PDF file',
|
||||
'Contact support if the problem persists'
|
||||
];
|
||||
|
||||
default:
|
||||
return [
|
||||
'Try refreshing the page',
|
||||
'Try again in a few moments',
|
||||
'Contact support if the problem persists'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function for delays
|
||||
*/
|
||||
private static delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
450
frontend/src/services/thumbnailGenerationService.ts
Normal file
450
frontend/src/services/thumbnailGenerationService.ts
Normal file
@ -0,0 +1,450 @@
|
||||
/**
|
||||
* High-performance thumbnail generation service using Web Workers
|
||||
*/
|
||||
|
||||
interface ThumbnailResult {
|
||||
pageNumber: number;
|
||||
thumbnail: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ThumbnailGenerationOptions {
|
||||
scale?: number;
|
||||
quality?: number;
|
||||
batchSize?: number;
|
||||
parallelBatches?: number;
|
||||
}
|
||||
|
||||
interface CachedThumbnail {
|
||||
thumbnail: string;
|
||||
lastUsed: number;
|
||||
sizeBytes: number;
|
||||
}
|
||||
|
||||
export class ThumbnailGenerationService {
|
||||
private workers: Worker[] = [];
|
||||
private activeJobs = new Map<string, { resolve: Function; reject: Function; onProgress?: Function }>();
|
||||
private jobCounter = 0;
|
||||
private isGenerating = false;
|
||||
|
||||
// Session-based thumbnail cache
|
||||
private thumbnailCache = new Map<string, CachedThumbnail>();
|
||||
private maxCacheSizeBytes = 1024 * 1024 * 1024; // 1GB cache limit
|
||||
private currentCacheSize = 0;
|
||||
|
||||
constructor(private maxWorkers: number = 3) {
|
||||
this.initializeWorkers();
|
||||
}
|
||||
|
||||
private initializeWorkers(): void {
|
||||
const workerPromises: Promise<Worker | null>[] = [];
|
||||
|
||||
for (let i = 0; i < this.maxWorkers; i++) {
|
||||
const workerPromise = new Promise<Worker | null>((resolve) => {
|
||||
try {
|
||||
console.log(`Attempting to create worker ${i}...`);
|
||||
const worker = new Worker('/thumbnailWorker.js');
|
||||
let workerReady = false;
|
||||
let pingTimeout: NodeJS.Timeout;
|
||||
|
||||
worker.onmessage = (e) => {
|
||||
const { type, data, jobId } = e.data;
|
||||
|
||||
// Handle PONG response to confirm worker is ready
|
||||
if (type === 'PONG') {
|
||||
workerReady = true;
|
||||
clearTimeout(pingTimeout);
|
||||
console.log(`✓ Worker ${i} is ready and responsive`);
|
||||
resolve(worker);
|
||||
return;
|
||||
}
|
||||
|
||||
const job = this.activeJobs.get(jobId);
|
||||
if (!job) return;
|
||||
|
||||
switch (type) {
|
||||
case 'PROGRESS':
|
||||
if (job.onProgress) {
|
||||
job.onProgress(data);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'COMPLETE':
|
||||
job.resolve(data.thumbnails);
|
||||
this.activeJobs.delete(jobId);
|
||||
break;
|
||||
|
||||
case 'ERROR':
|
||||
job.reject(new Error(data.error));
|
||||
this.activeJobs.delete(jobId);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
worker.onerror = (error) => {
|
||||
console.error(`✗ Worker ${i} failed with error:`, error);
|
||||
clearTimeout(pingTimeout);
|
||||
worker.terminate();
|
||||
resolve(null);
|
||||
};
|
||||
|
||||
// Test worker with timeout
|
||||
pingTimeout = setTimeout(() => {
|
||||
if (!workerReady) {
|
||||
console.warn(`✗ Worker ${i} timed out (no PONG response)`);
|
||||
worker.terminate();
|
||||
resolve(null);
|
||||
}
|
||||
}, 3000); // Reduced timeout for faster feedback
|
||||
|
||||
// Send PING to test worker
|
||||
try {
|
||||
worker.postMessage({ type: 'PING' });
|
||||
} catch (pingError) {
|
||||
console.error(`✗ Failed to send PING to worker ${i}:`, pingError);
|
||||
clearTimeout(pingTimeout);
|
||||
worker.terminate();
|
||||
resolve(null);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`✗ Failed to create worker ${i}:`, error);
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
|
||||
workerPromises.push(workerPromise);
|
||||
}
|
||||
|
||||
// Wait for all workers to initialize or fail
|
||||
Promise.all(workerPromises).then((workers) => {
|
||||
this.workers = workers.filter((w): w is Worker => w !== null);
|
||||
const successCount = this.workers.length;
|
||||
const failCount = this.maxWorkers - successCount;
|
||||
|
||||
console.log(`🔧 Worker initialization complete: ${successCount}/${this.maxWorkers} workers ready`);
|
||||
|
||||
if (failCount > 0) {
|
||||
console.warn(`⚠️ ${failCount} workers failed to initialize - will use main thread fallback`);
|
||||
}
|
||||
|
||||
if (successCount === 0) {
|
||||
console.warn('🚨 No Web Workers available - all thumbnail generation will use main thread');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate thumbnails for multiple pages using Web Workers
|
||||
*/
|
||||
async generateThumbnails(
|
||||
pdfArrayBuffer: ArrayBuffer,
|
||||
pageNumbers: number[],
|
||||
options: ThumbnailGenerationOptions = {},
|
||||
onProgress?: (progress: { completed: number; total: number; thumbnails: ThumbnailResult[] }) => void
|
||||
): Promise<ThumbnailResult[]> {
|
||||
if (this.isGenerating) {
|
||||
console.warn('🚨 ThumbnailService: Thumbnail generation already in progress, rejecting new request');
|
||||
throw new Error('Thumbnail generation already in progress');
|
||||
}
|
||||
|
||||
console.log(`🎬 ThumbnailService: Starting thumbnail generation for ${pageNumbers.length} pages`);
|
||||
this.isGenerating = true;
|
||||
|
||||
const {
|
||||
scale = 0.2,
|
||||
quality = 0.8,
|
||||
batchSize = 20, // Pages per worker
|
||||
parallelBatches = this.maxWorkers
|
||||
} = options;
|
||||
|
||||
try {
|
||||
// Check if workers are available, fallback to main thread if not
|
||||
if (this.workers.length === 0) {
|
||||
console.warn('No Web Workers available, falling back to main thread processing');
|
||||
return await this.generateThumbnailsMainThread(pdfArrayBuffer, pageNumbers, scale, quality, onProgress);
|
||||
}
|
||||
|
||||
// Split pages across workers
|
||||
const workerBatches = this.distributeWork(pageNumbers, this.workers.length);
|
||||
console.log(`🔧 ThumbnailService: Distributing ${pageNumbers.length} pages across ${this.workers.length} workers:`, workerBatches.map(batch => batch.length));
|
||||
const jobPromises: Promise<ThumbnailResult[]>[] = [];
|
||||
|
||||
for (let i = 0; i < workerBatches.length; i++) {
|
||||
const batch = workerBatches[i];
|
||||
if (batch.length === 0) continue;
|
||||
|
||||
const worker = this.workers[i % this.workers.length];
|
||||
const jobId = `job-${++this.jobCounter}`;
|
||||
console.log(`🔧 ThumbnailService: Sending job ${jobId} with ${batch.length} pages to worker ${i}:`, batch);
|
||||
|
||||
const promise = new Promise<ThumbnailResult[]>((resolve, reject) => {
|
||||
// Add timeout for worker jobs
|
||||
const timeout = setTimeout(() => {
|
||||
console.error(`⏰ ThumbnailService: Worker job ${jobId} timed out`);
|
||||
this.activeJobs.delete(jobId);
|
||||
reject(new Error(`Worker job ${jobId} timed out`));
|
||||
}, 60000); // 1 minute timeout
|
||||
|
||||
// Create job with timeout handling
|
||||
this.activeJobs.set(jobId, {
|
||||
resolve: (result: any) => {
|
||||
console.log(`✅ ThumbnailService: Job ${jobId} completed with ${result.length} thumbnails`);
|
||||
clearTimeout(timeout);
|
||||
resolve(result);
|
||||
},
|
||||
reject: (error: any) => {
|
||||
console.error(`❌ ThumbnailService: Job ${jobId} failed:`, error);
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
},
|
||||
onProgress: onProgress ? (progressData: any) => {
|
||||
console.log(`📊 ThumbnailService: Job ${jobId} progress - ${progressData.completed}/${progressData.total} (${progressData.thumbnails.length} new)`);
|
||||
onProgress(progressData);
|
||||
} : undefined
|
||||
});
|
||||
|
||||
worker.postMessage({
|
||||
type: 'GENERATE_THUMBNAILS',
|
||||
jobId,
|
||||
data: {
|
||||
pdfArrayBuffer,
|
||||
pageNumbers: batch,
|
||||
scale,
|
||||
quality
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
jobPromises.push(promise);
|
||||
}
|
||||
|
||||
// Wait for all workers to complete
|
||||
const results = await Promise.all(jobPromises);
|
||||
|
||||
// Flatten and sort results by page number
|
||||
const allThumbnails = results.flat().sort((a, b) => a.pageNumber - b.pageNumber);
|
||||
console.log(`🎯 ThumbnailService: All workers completed, returning ${allThumbnails.length} thumbnails`);
|
||||
|
||||
return allThumbnails;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Web Worker thumbnail generation failed, falling back to main thread:', error);
|
||||
return await this.generateThumbnailsMainThread(pdfArrayBuffer, pageNumbers, scale, quality, onProgress);
|
||||
} finally {
|
||||
console.log('🔄 ThumbnailService: Resetting isGenerating flag');
|
||||
this.isGenerating = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback thumbnail generation on main thread
|
||||
*/
|
||||
private async generateThumbnailsMainThread(
|
||||
pdfArrayBuffer: ArrayBuffer,
|
||||
pageNumbers: number[],
|
||||
scale: number,
|
||||
quality: number,
|
||||
onProgress?: (progress: { completed: number; total: number; thumbnails: ThumbnailResult[] }) => void
|
||||
): Promise<ThumbnailResult[]> {
|
||||
console.log(`🔧 ThumbnailService: Fallback to main thread for ${pageNumbers.length} pages`);
|
||||
|
||||
// Import PDF.js dynamically for main thread
|
||||
const { getDocument } = await import('pdfjs-dist');
|
||||
|
||||
// Load PDF once
|
||||
const pdf = await getDocument({ data: pdfArrayBuffer }).promise;
|
||||
console.log(`✓ ThumbnailService: PDF loaded on main thread`);
|
||||
|
||||
|
||||
const allResults: ThumbnailResult[] = [];
|
||||
let completed = 0;
|
||||
const batchSize = 5; // Small batches for UI responsiveness
|
||||
|
||||
// Process pages in small batches
|
||||
for (let i = 0; i < pageNumbers.length; i += batchSize) {
|
||||
const batch = pageNumbers.slice(i, i + batchSize);
|
||||
|
||||
// Process batch sequentially (to avoid canvas conflicts)
|
||||
for (const pageNumber of batch) {
|
||||
try {
|
||||
const page = await pdf.getPage(pageNumber);
|
||||
const viewport = page.getViewport({ scale });
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('Could not get canvas context');
|
||||
}
|
||||
|
||||
await page.render({ canvasContext: context, viewport }).promise;
|
||||
const thumbnail = canvas.toDataURL('image/jpeg', quality);
|
||||
|
||||
allResults.push({ pageNumber, thumbnail, success: true });
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Failed to generate thumbnail for page ${pageNumber}:`, error);
|
||||
allResults.push({
|
||||
pageNumber,
|
||||
thumbnail: '',
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
completed += batch.length;
|
||||
|
||||
// Report progress
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
completed,
|
||||
total: pageNumbers.length,
|
||||
thumbnails: allResults.slice(-batch.length).filter(r => r.success)
|
||||
});
|
||||
}
|
||||
|
||||
// Small delay to keep UI responsive
|
||||
if (i + batchSize < pageNumbers.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
pdf.destroy();
|
||||
|
||||
return allResults.filter(r => r.success);
|
||||
}
|
||||
|
||||
/**
|
||||
* Distribute work evenly across workers
|
||||
*/
|
||||
private distributeWork(pageNumbers: number[], numWorkers: number): number[][] {
|
||||
const batches: number[][] = Array(numWorkers).fill(null).map(() => []);
|
||||
|
||||
pageNumbers.forEach((pageNum, index) => {
|
||||
const workerIndex = index % numWorkers;
|
||||
batches[workerIndex].push(pageNum);
|
||||
});
|
||||
|
||||
return batches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a single thumbnail (fallback for individual pages)
|
||||
*/
|
||||
async generateSingleThumbnail(
|
||||
pdfArrayBuffer: ArrayBuffer,
|
||||
pageNumber: number,
|
||||
options: ThumbnailGenerationOptions = {}
|
||||
): Promise<string> {
|
||||
const results = await this.generateThumbnails(pdfArrayBuffer, [pageNumber], options);
|
||||
|
||||
if (results.length === 0 || !results[0].success) {
|
||||
throw new Error(`Failed to generate thumbnail for page ${pageNumber}`);
|
||||
}
|
||||
|
||||
return results[0].thumbnail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add thumbnail to cache with size management
|
||||
*/
|
||||
addThumbnailToCache(pageId: string, thumbnail: string): void {
|
||||
const thumbnailSizeBytes = thumbnail.length * 0.75; // Rough base64 size estimate
|
||||
const now = Date.now();
|
||||
|
||||
// Add new thumbnail
|
||||
this.thumbnailCache.set(pageId, {
|
||||
thumbnail,
|
||||
lastUsed: now,
|
||||
sizeBytes: thumbnailSizeBytes
|
||||
});
|
||||
|
||||
this.currentCacheSize += thumbnailSizeBytes;
|
||||
|
||||
// If we exceed 1GB, trigger cleanup
|
||||
if (this.currentCacheSize > this.maxCacheSizeBytes) {
|
||||
this.cleanupThumbnailCache();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get thumbnail from cache and update last used timestamp
|
||||
*/
|
||||
getThumbnailFromCache(pageId: string): string | null {
|
||||
const cached = this.thumbnailCache.get(pageId);
|
||||
if (!cached) return null;
|
||||
|
||||
// Update last used timestamp
|
||||
cached.lastUsed = Date.now();
|
||||
|
||||
return cached.thumbnail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up cache using LRU eviction
|
||||
*/
|
||||
private cleanupThumbnailCache(): void {
|
||||
const entries = Array.from(this.thumbnailCache.entries());
|
||||
|
||||
// Sort by last used (oldest first)
|
||||
entries.sort(([, a], [, b]) => a.lastUsed - b.lastUsed);
|
||||
|
||||
this.thumbnailCache.clear();
|
||||
this.currentCacheSize = 0;
|
||||
const targetSize = this.maxCacheSizeBytes * 0.8; // Clean to 80% of limit
|
||||
|
||||
// Keep most recently used entries until we hit target size
|
||||
for (let i = entries.length - 1; i >= 0 && this.currentCacheSize < targetSize; i--) {
|
||||
const [key, value] = entries[i];
|
||||
this.thumbnailCache.set(key, value);
|
||||
this.currentCacheSize += value.sizeBytes;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached thumbnails
|
||||
*/
|
||||
clearThumbnailCache(): void {
|
||||
this.thumbnailCache.clear();
|
||||
this.currentCacheSize = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
*/
|
||||
getCacheStats() {
|
||||
return {
|
||||
entries: this.thumbnailCache.size,
|
||||
totalSizeBytes: this.currentCacheSize,
|
||||
maxSizeBytes: this.maxCacheSizeBytes
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop generation but keep cache and workers alive
|
||||
*/
|
||||
stopGeneration(): void {
|
||||
this.activeJobs.clear();
|
||||
this.isGenerating = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate all workers and clear cache (only on explicit cleanup)
|
||||
*/
|
||||
destroy(): void {
|
||||
this.workers.forEach(worker => worker.terminate());
|
||||
this.workers = [];
|
||||
this.activeJobs.clear();
|
||||
this.isGenerating = false;
|
||||
this.clearThumbnailCache();
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const thumbnailGenerationService = new ThumbnailGenerationService();
|
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();
|
@ -1,6 +1,5 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Paper, Button, Checkbox, Stack, Text, Group, Loader, Alert } from "@mantine/core";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FileWithUrl } from "../types/file";
|
||||
import { fileStorage } from "../services/fileStorage";
|
||||
|
@ -1,308 +1,163 @@
|
||||
import React, { useState } from "react";
|
||||
import axios from "axios";
|
||||
import {
|
||||
Button,
|
||||
Select,
|
||||
TextInput,
|
||||
Checkbox,
|
||||
Notification,
|
||||
Stack,
|
||||
Loader,
|
||||
Alert,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import { Button, Stack, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import { FileWithUrl } from "../types/file";
|
||||
import { fileStorage } from "../services/fileStorage";
|
||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
||||
import { useFileContext } from "../contexts/FileContext";
|
||||
|
||||
export interface SplitPdfPanelProps {
|
||||
file: { file: FileWithUrl; url: string } | null;
|
||||
downloadUrl?: string | null;
|
||||
setDownloadUrl: (url: string | null) => void;
|
||||
params: {
|
||||
mode: string;
|
||||
pages: string;
|
||||
hDiv: string;
|
||||
vDiv: string;
|
||||
merge: boolean;
|
||||
splitType: string;
|
||||
splitValue: string;
|
||||
bookmarkLevel: string;
|
||||
includeMetadata: boolean;
|
||||
allowDuplicates: boolean;
|
||||
};
|
||||
updateParams: (newParams: Partial<SplitPdfPanelProps["params"]>) => void;
|
||||
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
|
||||
import OperationButton from "../components/tools/shared/OperationButton";
|
||||
import ErrorNotification from "../components/tools/shared/ErrorNotification";
|
||||
import FileStatusIndicator from "../components/tools/shared/FileStatusIndicator";
|
||||
import ResultsPreview from "../components/tools/shared/ResultsPreview";
|
||||
|
||||
import SplitSettings from "../components/tools/split/SplitSettings";
|
||||
|
||||
import { useSplitParameters } from "../hooks/tools/split/useSplitParameters";
|
||||
import { useSplitOperation } from "../hooks/tools/split/useSplitOperation";
|
||||
|
||||
interface SplitProps {
|
||||
selectedFiles?: File[];
|
||||
onPreviewFile?: (file: File | null) => void;
|
||||
}
|
||||
|
||||
const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
|
||||
file,
|
||||
downloadUrl,
|
||||
setDownloadUrl,
|
||||
params,
|
||||
updateParams,
|
||||
}) => {
|
||||
const Split = ({ selectedFiles = [], onPreviewFile }: SplitProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { setCurrentMode } = useFileContext();
|
||||
|
||||
const [status, setStatus] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const splitParams = useSplitParameters();
|
||||
const splitOperation = useSplitOperation();
|
||||
|
||||
// Map mode to endpoint name for checking
|
||||
const getEndpointName = (mode: string) => {
|
||||
switch (mode) {
|
||||
case "byPages":
|
||||
return "split-pages";
|
||||
case "bySections":
|
||||
return "split-pdf-by-sections";
|
||||
case "bySizeOrCount":
|
||||
return "split-by-size-or-count";
|
||||
case "byChapters":
|
||||
return "split-pdf-by-chapters";
|
||||
default:
|
||||
return "split-pages";
|
||||
}
|
||||
// Endpoint validation
|
||||
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(
|
||||
splitParams.getEndpointName()
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
splitOperation.resetResults();
|
||||
onPreviewFile?.(null);
|
||||
}, [splitParams.mode, splitParams.parameters, selectedFiles]);
|
||||
|
||||
const handleSplit = async () => {
|
||||
await splitOperation.executeOperation(
|
||||
splitParams.mode,
|
||||
splitParams.parameters,
|
||||
selectedFiles
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const {
|
||||
mode,
|
||||
pages,
|
||||
hDiv,
|
||||
vDiv,
|
||||
merge,
|
||||
splitType,
|
||||
splitValue,
|
||||
bookmarkLevel,
|
||||
includeMetadata,
|
||||
allowDuplicates,
|
||||
} = params;
|
||||
|
||||
|
||||
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(getEndpointName(mode));
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!file) {
|
||||
setStatus(t("noFileSelected"));
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
// Handle IndexedDB files
|
||||
if (!file.file.id) {
|
||||
setStatus(t("noFileSelected"));
|
||||
return;
|
||||
}
|
||||
const storedFile = await fileStorage.getFile(file.file.id);
|
||||
if (storedFile) {
|
||||
const blob = new Blob([storedFile.data], { type: storedFile.type });
|
||||
const actualFile = new File([blob], storedFile.name, {
|
||||
type: storedFile.type,
|
||||
lastModified: storedFile.lastModified
|
||||
});
|
||||
formData.append("fileInput", actualFile);
|
||||
}
|
||||
|
||||
let endpoint = "";
|
||||
|
||||
switch (mode) {
|
||||
case "byPages":
|
||||
formData.append("pageNumbers", pages);
|
||||
endpoint = "/api/v1/general/split-pages";
|
||||
break;
|
||||
case "bySections":
|
||||
formData.append("horizontalDivisions", hDiv);
|
||||
formData.append("verticalDivisions", vDiv);
|
||||
formData.append("merge", merge.toString());
|
||||
endpoint = "/api/v1/general/split-pdf-by-sections";
|
||||
break;
|
||||
case "bySizeOrCount":
|
||||
formData.append(
|
||||
"splitType",
|
||||
splitType === "size" ? "0" : splitType === "pages" ? "1" : "2"
|
||||
);
|
||||
formData.append("splitValue", splitValue);
|
||||
endpoint = "/api/v1/general/split-by-size-or-count";
|
||||
break;
|
||||
case "byChapters":
|
||||
formData.append("bookmarkLevel", bookmarkLevel);
|
||||
formData.append("includeMetadata", includeMetadata.toString());
|
||||
formData.append("allowDuplicates", allowDuplicates.toString());
|
||||
endpoint = "/api/v1/general/split-pdf-by-chapters";
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus(t("loading"));
|
||||
setIsLoading(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await axios.post(endpoint, formData, { responseType: "blob" });
|
||||
const blob = new Blob([response.data], { type: "application/zip" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
setDownloadUrl(url);
|
||||
setStatus(t("downloadComplete"));
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
let errorMsg = t("error.pdfPassword", "An error occurred while splitting the PDF.");
|
||||
if (error.response?.data && typeof error.response.data === 'string') {
|
||||
errorMsg = error.response.data;
|
||||
} else if (error.message) {
|
||||
errorMsg = error.message;
|
||||
}
|
||||
setErrorMessage(errorMsg);
|
||||
setStatus(t("error._value", "Split failed."));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
const handleThumbnailClick = (file: File) => {
|
||||
onPreviewFile?.(file);
|
||||
sessionStorage.setItem('previousMode', 'split');
|
||||
setCurrentMode('viewer');
|
||||
};
|
||||
|
||||
if (endpointLoading) {
|
||||
return (
|
||||
<Stack align="center" justify="center" h={200}>
|
||||
<Loader size="md" />
|
||||
<Text size="sm" c="dimmed">{t("loading", "Loading...")}</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
const handleSettingsReset = () => {
|
||||
splitOperation.resetResults();
|
||||
onPreviewFile?.(null);
|
||||
setCurrentMode('split');
|
||||
};
|
||||
|
||||
if (endpointEnabled === false) {
|
||||
return (
|
||||
<Stack align="center" justify="center" h={200}>
|
||||
<Alert color="red" title={t("error._value", "Error")} variant="light">
|
||||
{t("endpointDisabled", "This feature is currently disabled.")}
|
||||
</Alert>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
const hasFiles = selectedFiles.length > 0;
|
||||
const hasResults = splitOperation.downloadUrl !== null;
|
||||
const filesCollapsed = hasFiles;
|
||||
const settingsCollapsed = hasResults;
|
||||
|
||||
const previewResults = useMemo(() =>
|
||||
splitOperation.files?.map((file, index) => ({
|
||||
file,
|
||||
thumbnail: splitOperation.thumbnails[index]
|
||||
})) || [],
|
||||
[splitOperation.files, splitOperation.thumbnails]
|
||||
);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="app-surface p-app-md rounded-app-md">
|
||||
<Stack gap="sm" mb={16}>
|
||||
<Select
|
||||
label={t("split-by-size-or-count.type.label", "Split Mode")}
|
||||
value={mode}
|
||||
onChange={(v) => v && updateParams({ mode: v })}
|
||||
data={[
|
||||
{ value: "byPages", label: t("split.header", "Split by Pages") + " (e.g. 1,3,5-10)" },
|
||||
{ value: "bySections", label: t("split-by-sections.title", "Split by Grid Sections") },
|
||||
{ value: "bySizeOrCount", label: t("split-by-size-or-count.title", "Split by Size or Count") },
|
||||
{ value: "byChapters", label: t("splitByChapters.title", "Split by Chapters") },
|
||||
]}
|
||||
<ToolStepContainer>
|
||||
<Stack gap="md" h="100%" p="md" style={{ overflow: 'auto' }}>
|
||||
{/* Files Step */}
|
||||
<ToolStep
|
||||
title="Files"
|
||||
isVisible={true}
|
||||
isCollapsed={filesCollapsed}
|
||||
isCompleted={filesCollapsed}
|
||||
completedMessage={hasFiles ? `Selected: ${selectedFiles[0]?.name}` : undefined}
|
||||
>
|
||||
<FileStatusIndicator
|
||||
selectedFiles={selectedFiles}
|
||||
placeholder="Select a PDF file in the main view to get started"
|
||||
/>
|
||||
</ToolStep>
|
||||
|
||||
{mode === "byPages" && (
|
||||
<TextInput
|
||||
label={t("split.splitPages", "Pages")}
|
||||
placeholder={t("pageSelectionPrompt", "e.g. 1,3,5-10")}
|
||||
value={pages}
|
||||
onChange={(e) => updateParams({ pages: e.target.value })}
|
||||
{/* Settings Step */}
|
||||
<ToolStep
|
||||
title="Settings"
|
||||
isVisible={hasFiles}
|
||||
isCollapsed={settingsCollapsed}
|
||||
isCompleted={settingsCollapsed}
|
||||
onCollapsedClick={settingsCollapsed ? handleSettingsReset : undefined}
|
||||
completedMessage={settingsCollapsed ? "Split completed" : undefined}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<SplitSettings
|
||||
mode={splitParams.mode}
|
||||
onModeChange={splitParams.setMode}
|
||||
parameters={splitParams.parameters}
|
||||
onParameterChange={splitParams.updateParameter}
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mode === "bySections" && (
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label={t("split-by-sections.horizontal.label", "Horizontal Divisions")}
|
||||
type="number"
|
||||
min="0"
|
||||
max="300"
|
||||
value={hDiv}
|
||||
onChange={(e) => updateParams({ hDiv: e.target.value })}
|
||||
placeholder={t("split-by-sections.horizontal.placeholder", "Enter number of horizontal divisions")}
|
||||
{splitParams.mode && (
|
||||
<OperationButton
|
||||
onClick={handleSplit}
|
||||
isLoading={splitOperation.isLoading}
|
||||
disabled={!splitParams.validateParameters() || !hasFiles || !endpointEnabled}
|
||||
loadingText={t("loading")}
|
||||
submitText={t("split.submit", "Split PDF")}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("split-by-sections.vertical.label", "Vertical Divisions")}
|
||||
type="number"
|
||||
min="0"
|
||||
max="300"
|
||||
value={vDiv}
|
||||
onChange={(e) => updateParams({ vDiv: e.target.value })}
|
||||
placeholder={t("split-by-sections.vertical.placeholder", "Enter number of vertical divisions")}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t("split-by-sections.merge", "Merge sections into one PDF")}
|
||||
checked={merge}
|
||||
onChange={(e) => updateParams({ merge: e.currentTarget.checked })}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
)}
|
||||
</Stack>
|
||||
</ToolStep>
|
||||
|
||||
{mode === "bySizeOrCount" && (
|
||||
<Stack gap="sm">
|
||||
<Select
|
||||
label={t("split-by-size-or-count.type.label", "Split Type")}
|
||||
value={splitType}
|
||||
onChange={(v) => v && updateParams({ splitType: v })}
|
||||
data={[
|
||||
{ value: "size", label: t("split-by-size-or-count.type.size", "By Size") },
|
||||
{ value: "pages", label: t("split-by-size-or-count.type.pageCount", "By Page Count") },
|
||||
{ value: "docs", label: t("split-by-size-or-count.type.docCount", "By Document Count") },
|
||||
]}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("split-by-size-or-count.value.label", "Split Value")}
|
||||
placeholder={t("split-by-size-or-count.value.placeholder", "e.g. 10MB or 5 pages")}
|
||||
value={splitValue}
|
||||
onChange={(e) => updateParams({ splitValue: e.target.value })}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
{/* Results Step */}
|
||||
<ToolStep
|
||||
title="Results"
|
||||
isVisible={hasResults}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{splitOperation.status && (
|
||||
<Text size="sm" c="dimmed">{splitOperation.status}</Text>
|
||||
)}
|
||||
|
||||
{mode === "byChapters" && (
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label={t("splitByChapters.bookmarkLevel", "Bookmark Level")}
|
||||
type="number"
|
||||
value={bookmarkLevel}
|
||||
onChange={(e) => updateParams({ bookmarkLevel: e.target.value })}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t("splitByChapters.includeMetadata", "Include Metadata")}
|
||||
checked={includeMetadata}
|
||||
onChange={(e) => updateParams({ includeMetadata: e.currentTarget.checked })}
|
||||
/>
|
||||
<Checkbox
|
||||
label={t("splitByChapters.allowDuplicates", "Allow Duplicate Bookmarks")}
|
||||
checked={allowDuplicates}
|
||||
onChange={(e) => updateParams({ allowDuplicates: e.currentTarget.checked })}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
<ErrorNotification
|
||||
error={splitOperation.errorMessage}
|
||||
onClose={splitOperation.clearError}
|
||||
/>
|
||||
|
||||
<Button type="submit" loading={isLoading} fullWidth>
|
||||
{isLoading ? t("loading") : t("split.submit", "Split PDF")}
|
||||
</Button>
|
||||
{splitOperation.downloadUrl && (
|
||||
<Button
|
||||
component="a"
|
||||
href={splitOperation.downloadUrl}
|
||||
download="split_output.zip"
|
||||
leftSection={<DownloadIcon />}
|
||||
color="green"
|
||||
fullWidth
|
||||
mb="md"
|
||||
>
|
||||
{t("download", "Download")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status && <p className="text-xs text-text-muted">{status}</p>}
|
||||
|
||||
{errorMessage && (
|
||||
<Notification color="red" title={t("error._value", "Error")} onClose={() => setErrorMessage(null)}>
|
||||
{errorMessage}
|
||||
</Notification>
|
||||
)}
|
||||
|
||||
{status === t("downloadComplete") && downloadUrl && (
|
||||
<Button
|
||||
component="a"
|
||||
href={downloadUrl}
|
||||
download="split_output.zip"
|
||||
leftSection={<DownloadIcon />}
|
||||
color="green"
|
||||
fullWidth
|
||||
>
|
||||
{t("downloadPdf", "Download Split PDF")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</form>
|
||||
<ResultsPreview
|
||||
files={previewResults}
|
||||
onFileClick={handleThumbnailClick}
|
||||
isGeneratingThumbnails={splitOperation.isGeneratingThumbnails}
|
||||
title="Split Results"
|
||||
/>
|
||||
</Stack>
|
||||
</ToolStep>
|
||||
</Stack>
|
||||
</ToolStepContainer>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default SplitPdfPanel;
|
||||
export default Split;
|
||||
|
178
frontend/src/types/fileContext.ts
Normal file
178
frontend/src/types/fileContext.ts
Normal file
@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Types for global file context management across views and tools
|
||||
*/
|
||||
|
||||
import { ProcessedFile } from './processing';
|
||||
import { PDFDocument, PDFPage, PageOperation } from './pageEditor';
|
||||
|
||||
export type ModeType = 'viewer' | 'pageEditor' | 'fileEditor' | 'merge' | 'split' | 'compress';
|
||||
|
||||
// Legacy types for backward compatibility during transition
|
||||
export type ViewType = 'viewer' | 'pageEditor' | 'fileEditor';
|
||||
export type ToolType = 'merge' | 'split' | 'compress' | null;
|
||||
|
||||
export interface FileOperation {
|
||||
id: string;
|
||||
type: 'merge' | 'split' | 'compress' | 'add' | 'remove' | 'replace' | 'convert' | 'upload';
|
||||
timestamp: number;
|
||||
fileIds: string[];
|
||||
status: 'pending' | 'applied' | 'failed';
|
||||
data?: any;
|
||||
metadata?: {
|
||||
originalFileName?: string;
|
||||
outputFileNames?: string[];
|
||||
parameters?: Record<string, any>;
|
||||
fileSize?: number;
|
||||
pageCount?: number;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FileOperationHistory {
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
operations: (FileOperation | PageOperation)[];
|
||||
createdAt: number;
|
||||
lastModified: number;
|
||||
}
|
||||
|
||||
export interface ViewerConfig {
|
||||
zoom: number;
|
||||
currentPage: number;
|
||||
viewMode: 'single' | 'continuous' | 'facing';
|
||||
sidebarOpen: boolean;
|
||||
}
|
||||
|
||||
export interface FileEditHistory {
|
||||
fileId: string;
|
||||
pageOperations: PageOperation[];
|
||||
lastModified: number;
|
||||
}
|
||||
|
||||
export interface FileContextState {
|
||||
// Core file management
|
||||
activeFiles: File[];
|
||||
processedFiles: Map<File, ProcessedFile>;
|
||||
|
||||
// Current navigation state
|
||||
currentMode: ModeType;
|
||||
// Legacy fields for backward compatibility
|
||||
currentView: ViewType;
|
||||
currentTool: ToolType;
|
||||
|
||||
// Edit history and state
|
||||
fileEditHistory: Map<string, FileEditHistory>;
|
||||
globalFileOperations: FileOperation[];
|
||||
// New comprehensive operation history
|
||||
fileOperationHistory: Map<string, FileOperationHistory>;
|
||||
|
||||
// UI state that persists across views
|
||||
selectedFileIds: string[];
|
||||
selectedPageNumbers: number[];
|
||||
viewerConfig: ViewerConfig;
|
||||
|
||||
// Processing state
|
||||
isProcessing: boolean;
|
||||
processingProgress: number;
|
||||
|
||||
// Export state
|
||||
lastExportConfig?: {
|
||||
filename: string;
|
||||
selectedOnly: boolean;
|
||||
splitDocuments: boolean;
|
||||
};
|
||||
|
||||
// Navigation guard system
|
||||
hasUnsavedChanges: boolean;
|
||||
pendingNavigation: (() => void) | null;
|
||||
showNavigationWarning: boolean;
|
||||
}
|
||||
|
||||
export interface FileContextActions {
|
||||
// File management
|
||||
addFiles: (files: File[]) => Promise<void>;
|
||||
removeFiles: (fileIds: string[], deleteFromStorage?: boolean) => void;
|
||||
replaceFile: (oldFileId: string, newFile: File) => Promise<void>;
|
||||
clearAllFiles: () => void;
|
||||
|
||||
// Navigation
|
||||
setCurrentMode: (mode: ModeType) => void;
|
||||
// Legacy navigation functions for backward compatibility
|
||||
setCurrentView: (view: ViewType) => void;
|
||||
setCurrentTool: (tool: ToolType) => void;
|
||||
|
||||
// Selection management
|
||||
setSelectedFiles: (fileIds: string[]) => void;
|
||||
setSelectedPages: (pageNumbers: number[]) => void;
|
||||
updateProcessedFile: (file: File, processedFile: ProcessedFile) => void;
|
||||
clearSelections: () => void;
|
||||
|
||||
// Edit operations
|
||||
applyPageOperations: (fileId: string, operations: PageOperation[]) => void;
|
||||
applyFileOperation: (operation: FileOperation) => void;
|
||||
undoLastOperation: (fileId?: string) => void;
|
||||
|
||||
// Operation history management
|
||||
recordOperation: (fileId: string, operation: FileOperation | PageOperation) => void;
|
||||
markOperationApplied: (fileId: string, operationId: string) => void;
|
||||
markOperationFailed: (fileId: string, operationId: string, error: string) => void;
|
||||
getFileHistory: (fileId: string) => FileOperationHistory | undefined;
|
||||
getAppliedOperations: (fileId: string) => (FileOperation | PageOperation)[];
|
||||
clearFileHistory: (fileId: string) => void;
|
||||
|
||||
// Viewer state
|
||||
updateViewerConfig: (config: Partial<ViewerConfig>) => void;
|
||||
|
||||
// Export configuration
|
||||
setExportConfig: (config: FileContextState['lastExportConfig']) => void;
|
||||
|
||||
|
||||
// Utility
|
||||
getFileById: (fileId: string) => File | undefined;
|
||||
getProcessedFileById: (fileId: string) => ProcessedFile | undefined;
|
||||
getCurrentFile: () => File | undefined;
|
||||
getCurrentProcessedFile: () => ProcessedFile | undefined;
|
||||
|
||||
// Context persistence
|
||||
saveContext: () => Promise<void>;
|
||||
loadContext: () => Promise<void>;
|
||||
resetContext: () => void;
|
||||
|
||||
// Navigation guard system
|
||||
setHasUnsavedChanges: (hasChanges: boolean) => void;
|
||||
requestNavigation: (navigationFn: () => void) => boolean;
|
||||
confirmNavigation: () => void;
|
||||
cancelNavigation: () => void;
|
||||
|
||||
// Memory management
|
||||
trackBlobUrl: (url: string) => void;
|
||||
trackPdfDocument: (fileId: string, pdfDoc: any) => void;
|
||||
cleanupFile: (fileId: string) => Promise<void>;
|
||||
scheduleCleanup: (fileId: string, delay?: number) => void;
|
||||
}
|
||||
|
||||
export interface FileContextValue extends FileContextState, FileContextActions {}
|
||||
|
||||
export interface FileContextProviderProps {
|
||||
children: React.ReactNode;
|
||||
enableUrlSync?: boolean;
|
||||
enablePersistence?: boolean;
|
||||
maxCacheSize?: number;
|
||||
}
|
||||
|
||||
// Helper types for component props
|
||||
export interface WithFileContext {
|
||||
fileContext: FileContextValue;
|
||||
}
|
||||
|
||||
// URL parameter types for deep linking
|
||||
export interface FileContextUrlParams {
|
||||
mode?: ModeType;
|
||||
// Legacy parameters for backward compatibility
|
||||
view?: ViewType;
|
||||
tool?: ToolType;
|
||||
fileIds?: string[];
|
||||
pageIds?: string[];
|
||||
zoom?: number;
|
||||
page?: number;
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
export interface PDFPage {
|
||||
id: string;
|
||||
pageNumber: number;
|
||||
thumbnail: string;
|
||||
thumbnail: string | null;
|
||||
rotation: number;
|
||||
selected: boolean;
|
||||
splitBefore?: boolean;
|
||||
@ -16,12 +16,23 @@ export interface PDFDocument {
|
||||
}
|
||||
|
||||
export interface PageOperation {
|
||||
type: 'rotate' | 'delete' | 'move' | 'split' | 'insert';
|
||||
id: string;
|
||||
type: 'rotate' | 'delete' | 'move' | 'split' | 'insert' | 'reorder';
|
||||
pageIds: string[];
|
||||
timestamp: number;
|
||||
status: 'pending' | 'applied' | 'failed';
|
||||
data?: any;
|
||||
metadata?: {
|
||||
rotation?: number;
|
||||
fromPosition?: number;
|
||||
toPosition?: number;
|
||||
splitType?: string;
|
||||
insertAfterPage?: number;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UndoRedoState {
|
||||
operations: PageOperation[];
|
||||
currentIndex: number;
|
||||
}
|
||||
}
|
||||
|
91
frontend/src/types/processing.ts
Normal file
91
frontend/src/types/processing.ts
Normal file
@ -0,0 +1,91 @@
|
||||
export interface ProcessingError {
|
||||
type: 'network' | 'parsing' | 'memory' | 'corruption' | 'timeout' | 'cancelled';
|
||||
message: string;
|
||||
recoverable: boolean;
|
||||
retryCount: number;
|
||||
maxRetries: number;
|
||||
originalError?: Error;
|
||||
}
|
||||
|
||||
export interface ProcessingState {
|
||||
fileKey: string;
|
||||
fileName: string;
|
||||
status: 'pending' | 'processing' | 'completed' | 'error' | 'cancelled';
|
||||
progress: number; // 0-100
|
||||
strategy: ProcessingStrategy;
|
||||
error?: ProcessingError;
|
||||
startedAt: number;
|
||||
completedAt?: number;
|
||||
estimatedTimeRemaining?: number;
|
||||
currentPage?: number;
|
||||
cancellationToken?: AbortController;
|
||||
}
|
||||
|
||||
export interface ProcessedFile {
|
||||
id: string;
|
||||
pages: PDFPage[];
|
||||
totalPages: number;
|
||||
metadata: {
|
||||
title: string;
|
||||
createdAt: string;
|
||||
modifiedAt: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PDFPage {
|
||||
id: string;
|
||||
pageNumber: number;
|
||||
thumbnail: string | null;
|
||||
rotation: number;
|
||||
selected: boolean;
|
||||
splitBefore?: boolean;
|
||||
}
|
||||
|
||||
export interface CacheConfig {
|
||||
maxFiles: number;
|
||||
maxSizeBytes: number;
|
||||
ttlMs: number;
|
||||
}
|
||||
|
||||
export interface CacheEntry {
|
||||
data: ProcessedFile;
|
||||
size: number;
|
||||
lastAccessed: number;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface CacheStats {
|
||||
entries: number;
|
||||
totalSizeBytes: number;
|
||||
maxSizeBytes: number;
|
||||
}
|
||||
|
||||
export type ProcessingStrategy = 'immediate_full' | 'progressive_chunked' | 'metadata_only' | 'priority_pages';
|
||||
|
||||
export interface ProcessingConfig {
|
||||
strategy: ProcessingStrategy;
|
||||
chunkSize: number; // Pages per chunk
|
||||
thumbnailQuality: 'low' | 'medium' | 'high';
|
||||
priorityPageCount: number; // Number of priority pages to process first
|
||||
useWebWorker: boolean;
|
||||
maxRetries: number;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export interface FileAnalysis {
|
||||
fileSize: number;
|
||||
estimatedPageCount?: number;
|
||||
isEncrypted: boolean;
|
||||
isCorrupted: boolean;
|
||||
recommendedStrategy: ProcessingStrategy;
|
||||
estimatedProcessingTime: number; // milliseconds
|
||||
}
|
||||
|
||||
export interface ProcessingMetrics {
|
||||
totalFiles: number;
|
||||
completedFiles: number;
|
||||
failedFiles: number;
|
||||
averageProcessingTime: number;
|
||||
cacheHitRate: number;
|
||||
memoryUsage: number;
|
||||
}
|
127
frontend/src/utils/fileHash.ts
Normal file
127
frontend/src/utils/fileHash.ts
Normal file
@ -0,0 +1,127 @@
|
||||
/**
|
||||
* File hashing utilities for cache key generation
|
||||
*/
|
||||
|
||||
export class FileHasher {
|
||||
private static readonly CHUNK_SIZE = 64 * 1024; // 64KB chunks for hashing
|
||||
|
||||
/**
|
||||
* Generate a content-based hash for a file
|
||||
* Uses first + last + middle chunks to create a reasonably unique hash
|
||||
* without reading the entire file (which would be expensive for large files)
|
||||
*/
|
||||
static async generateContentHash(file: File): Promise<string> {
|
||||
const chunks = await this.getFileChunks(file);
|
||||
const combined = await this.combineChunks(chunks);
|
||||
return await this.hashArrayBuffer(combined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a fast hash based on file metadata
|
||||
* Faster but less collision-resistant than content hash
|
||||
*/
|
||||
static generateMetadataHash(file: File): string {
|
||||
const data = `${file.name}-${file.size}-${file.lastModified}-${file.type}`;
|
||||
return this.simpleHash(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a hybrid hash that balances speed and uniqueness
|
||||
* Uses metadata + small content sample
|
||||
*/
|
||||
static async generateHybridHash(file: File): Promise<string> {
|
||||
const metadataHash = this.generateMetadataHash(file);
|
||||
|
||||
// For small files, use full content hash
|
||||
if (file.size <= 1024 * 1024) { // 1MB
|
||||
const contentHash = await this.generateContentHash(file);
|
||||
return `${metadataHash}-${contentHash}`;
|
||||
}
|
||||
|
||||
// For large files, use first chunk only
|
||||
const firstChunk = file.slice(0, this.CHUNK_SIZE);
|
||||
const firstChunkBuffer = await firstChunk.arrayBuffer();
|
||||
const firstChunkHash = await this.hashArrayBuffer(firstChunkBuffer);
|
||||
|
||||
return `${metadataHash}-${firstChunkHash}`;
|
||||
}
|
||||
|
||||
private static async getFileChunks(file: File): Promise<ArrayBuffer[]> {
|
||||
const chunks: ArrayBuffer[] = [];
|
||||
|
||||
// First chunk
|
||||
if (file.size > 0) {
|
||||
const firstChunk = file.slice(0, Math.min(this.CHUNK_SIZE, file.size));
|
||||
chunks.push(await firstChunk.arrayBuffer());
|
||||
}
|
||||
|
||||
// Middle chunk (if file is large enough)
|
||||
if (file.size > this.CHUNK_SIZE * 2) {
|
||||
const middleStart = Math.floor(file.size / 2) - Math.floor(this.CHUNK_SIZE / 2);
|
||||
const middleEnd = middleStart + this.CHUNK_SIZE;
|
||||
const middleChunk = file.slice(middleStart, middleEnd);
|
||||
chunks.push(await middleChunk.arrayBuffer());
|
||||
}
|
||||
|
||||
// Last chunk (if file is large enough and different from first)
|
||||
if (file.size > this.CHUNK_SIZE) {
|
||||
const lastStart = Math.max(file.size - this.CHUNK_SIZE, this.CHUNK_SIZE);
|
||||
const lastChunk = file.slice(lastStart);
|
||||
chunks.push(await lastChunk.arrayBuffer());
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
private static async combineChunks(chunks: ArrayBuffer[]): Promise<ArrayBuffer> {
|
||||
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
|
||||
const combined = new Uint8Array(totalLength);
|
||||
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
combined.set(new Uint8Array(chunk), offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
|
||||
return combined.buffer;
|
||||
}
|
||||
|
||||
private static async hashArrayBuffer(buffer: ArrayBuffer): Promise<string> {
|
||||
// Use Web Crypto API for proper hashing
|
||||
if (crypto.subtle) {
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
// Fallback for environments without crypto.subtle
|
||||
return this.simpleHash(Array.from(new Uint8Array(buffer)).join(''));
|
||||
}
|
||||
|
||||
private static simpleHash(str: string): string {
|
||||
let hash = 0;
|
||||
if (str.length === 0) return hash.toString();
|
||||
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash; // Convert to 32-bit integer
|
||||
}
|
||||
|
||||
return Math.abs(hash).toString(16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a file matches its expected hash
|
||||
* Useful for detecting file corruption or changes
|
||||
*/
|
||||
static async validateFileHash(file: File, expectedHash: string): Promise<boolean> {
|
||||
try {
|
||||
const actualHash = await this.generateHybridHash(file);
|
||||
return actualHash === expectedHash;
|
||||
} catch (error) {
|
||||
console.error('Hash validation failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,5 +1,19 @@
|
||||
import { getDocument } from "pdfjs-dist";
|
||||
|
||||
/**
|
||||
* Calculate thumbnail scale based on file size
|
||||
* Smaller files get higher quality, larger files get lower quality
|
||||
*/
|
||||
export function calculateScaleFromFileSize(fileSize: number): number {
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
if (fileSize < 1 * MB) return 0.6; // < 1MB: High quality
|
||||
if (fileSize < 5 * MB) return 0.4; // 1-5MB: Medium-high quality
|
||||
if (fileSize < 15 * MB) return 0.3; // 5-15MB: Medium quality
|
||||
if (fileSize < 30 * MB) return 0.2; // 15-30MB: Low-medium quality
|
||||
return 0.15; // 30MB+: Low quality
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate thumbnail for a PDF file during upload
|
||||
* Returns base64 data URL or undefined if generation fails
|
||||
@ -14,6 +28,10 @@ export async function generateThumbnailForFile(file: File): Promise<string | und
|
||||
try {
|
||||
console.log('Generating thumbnail for', file.name);
|
||||
|
||||
// Calculate quality scale based on file size
|
||||
const scale = calculateScaleFromFileSize(file.size);
|
||||
console.log(`Using scale ${scale} for ${file.name} (${(file.size / 1024 / 1024).toFixed(1)}MB)`);
|
||||
|
||||
// Only read first 2MB for thumbnail generation to save memory
|
||||
const chunkSize = 2 * 1024 * 1024; // 2MB
|
||||
const chunk = file.slice(0, Math.min(chunkSize, file.size));
|
||||
@ -26,7 +44,7 @@ export async function generateThumbnailForFile(file: File): Promise<string | und
|
||||
}).promise;
|
||||
|
||||
const page = await pdf.getPage(1);
|
||||
const viewport = page.getViewport({ scale: 0.2 }); // Smaller scale for memory efficiency
|
||||
const viewport = page.getViewport({ scale }); // Dynamic scale based on file size
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
@ -45,7 +63,45 @@ export async function generateThumbnailForFile(file: File): Promise<string | und
|
||||
|
||||
return thumbnail;
|
||||
} catch (error) {
|
||||
console.warn('Failed to generate thumbnail for', file.name, error);
|
||||
if (error instanceof Error) {
|
||||
if (error.name === 'InvalidPDFException') {
|
||||
console.warn(`PDF structure issue for ${file.name} - using fallback thumbnail`);
|
||||
// Return a placeholder or try with full file instead of chunk
|
||||
try {
|
||||
const fullArrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await getDocument({
|
||||
data: fullArrayBuffer,
|
||||
disableAutoFetch: true,
|
||||
disableStream: true,
|
||||
verbosity: 0 // Reduce PDF.js warnings
|
||||
}).promise;
|
||||
|
||||
const page = await pdf.getPage(1);
|
||||
const viewport = page.getViewport({ scale });
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
const context = canvas.getContext("2d");
|
||||
|
||||
if (!context) {
|
||||
throw new Error('Could not get canvas context');
|
||||
}
|
||||
|
||||
await page.render({ canvasContext: context, viewport }).promise;
|
||||
const thumbnail = canvas.toDataURL();
|
||||
|
||||
pdf.destroy();
|
||||
return thumbnail;
|
||||
} catch (fallbackError) {
|
||||
console.warn('Fallback thumbnail generation also failed for', file.name, fallbackError);
|
||||
return undefined;
|
||||
}
|
||||
} else {
|
||||
console.warn('Failed to generate thumbnail for', file.name, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
console.warn('Unknown error generating thumbnail for', file.name, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user