mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02: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 <[email protected]>
This commit is contained in:
co-authored by
Anthony Stirling
parent
584e2ecee7
commit
922bbc9076
@@ -5,7 +5,8 @@
|
|||||||
"Bash(mkdir:*)",
|
"Bash(mkdir:*)",
|
||||||
"Bash(./gradlew:*)",
|
"Bash(./gradlew:*)",
|
||||||
"Bash(grep:*)",
|
"Bash(grep:*)",
|
||||||
"Bash(cat:*)"
|
"Bash(cat:*)",
|
||||||
|
"Bash(find:*)"
|
||||||
],
|
],
|
||||||
"deny": []
|
"deny": []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
- **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
|
- **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
|
- **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)
|
#### Multi-Tool Workflow Architecture
|
||||||
```bash
|
Frontend designed for **stateful document processing**:
|
||||||
cd frontend
|
- Users upload PDFs once, then chain tools (split → merge → compress → view)
|
||||||
npm install -D tailwindcss postcss autoprefixer
|
- File state and processing results persist across tool switches
|
||||||
npx tailwindcss init -p
|
- 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
|
## Architecture Overview
|
||||||
|
|
||||||
### Project Structure
|
### Project Structure
|
||||||
- **Backend**: Spring Boot application with Thymeleaf templating
|
- **Backend**: Spring Boot application with Thymeleaf templating
|
||||||
- **Frontend**: React-based SPA in `/frontend` directory (replacing legacy Thymeleaf templates)
|
- **Frontend**: React-based SPA in `/frontend` directory (Thymeleaf templates fully replaced)
|
||||||
- **Current Status**: Active development to replace Thymeleaf UI with modern React SPA
|
|
||||||
- **File Storage**: IndexedDB for client-side file persistence and thumbnails
|
- **File Storage**: IndexedDB for client-side file persistence and thumbnails
|
||||||
- **Internationalization**: JSON-based translations (converted from backend .properties)
|
- **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
|
- **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`)
|
- **Security**: Spring Security with optional authentication (controlled by `DOCKER_ENABLE_SECURITY`)
|
||||||
- **Configuration**: YAML-based configuration with environment variable overrides
|
- **Configuration**: YAML-based configuration with environment variable overrides
|
||||||
@@ -59,9 +90,8 @@ npx tailwindcss init -p
|
|||||||
- **Pipeline System**: Automated PDF processing workflows via `PipelineController`
|
- **Pipeline System**: Automated PDF processing workflows via `PipelineController`
|
||||||
- **Security Layer**: Authentication, authorization, and user management (when enabled)
|
- **Security Layer**: Authentication, authorization, and user management (when enabled)
|
||||||
|
|
||||||
### Template System (Legacy + Modern)
|
### Component Architecture
|
||||||
- **Legacy Thymeleaf Templates**: Located in `src/main/resources/templates/` (being phased out)
|
- **React Components**: Located in `frontend/src/components/` and `frontend/src/tools/`
|
||||||
- **Modern 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)
|
- **Static Assets**: CSS, JS, and resources in `src/main/resources/static/` (legacy) + `frontend/public/` (modern)
|
||||||
- **Internationalization**:
|
- **Internationalization**:
|
||||||
- Backend: `messages_*.properties` files
|
- Backend: `messages_*.properties` files
|
||||||
@@ -91,13 +121,14 @@ npx tailwindcss init -p
|
|||||||
- Frontend: Update JSON files in `frontend/public/locales/` or use conversion script
|
- 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`
|
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
|
- **Core Status**: React SPA architecture complete with multi-tool workflow support
|
||||||
- **Migration Status**: Core tools (Split, Merge, Compress) converted to React with URL parameter support
|
- **State Management**: FileContext handles all file operations and tool navigation
|
||||||
- **File Management**: Implemented IndexedDB storage with thumbnail generation using PDF.js
|
- **File Processing**: Production-ready with memory management for large PDF workflows (up to 100GB+)
|
||||||
- **Tools Architecture**: Each tool receives `params` and `updateParams` for URL state synchronization
|
- **Tool Integration**: Standardized tool interface - see `src/tools/Split.tsx` as reference
|
||||||
- **Remaining Work**: Convert remaining Thymeleaf templates to React components
|
- **Preview System**: Tool results can be previewed without polluting file context (Split tool example)
|
||||||
|
- **Performance**: Web Worker thumbnails, IndexedDB persistence, background processing
|
||||||
|
|
||||||
## Important Notes
|
## Important Notes
|
||||||
|
|
||||||
@@ -108,6 +139,11 @@ npx tailwindcss init -p
|
|||||||
- **Backend**: Designed to be stateless - files are processed in memory/temp locations only
|
- **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)
|
- **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
|
- **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
|
## Communication Style
|
||||||
- Be direct and to the point
|
- Be direct and to the point
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package stirling.software.common.service;
|
package stirling.software.common.service;
|
||||||
|
|
||||||
import io.github.pixee.security.ZipSecurity;
|
|
||||||
import java.io.ByteArrayInputStream;
|
import java.io.ByteArrayInputStream;
|
||||||
import java.io.ByteArrayOutputStream;
|
import java.io.ByteArrayOutputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@@ -21,6 +20,8 @@ import org.springframework.http.MediaType;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import io.github.pixee.security.ZipSecurity;
|
||||||
|
|
||||||
import jakarta.annotation.PreDestroy;
|
import jakarta.annotation.PreDestroy;
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@@ -361,7 +362,8 @@ public class TaskManager {
|
|||||||
MultipartFile zipFile = fileStorage.retrieveFile(zipFileId);
|
MultipartFile zipFile = fileStorage.retrieveFile(zipFileId);
|
||||||
|
|
||||||
try (ZipInputStream zipIn =
|
try (ZipInputStream zipIn =
|
||||||
ZipSecurity.createHardenedInputStream(new ByteArrayInputStream(zipFile.getBytes()))) {
|
ZipSecurity.createHardenedInputStream(
|
||||||
|
new ByteArrayInputStream(zipFile.getBytes()))) {
|
||||||
ZipEntry entry;
|
ZipEntry entry;
|
||||||
while ((entry = zipIn.getNextEntry()) != null) {
|
while ((entry = zipIn.getNextEntry()) != null) {
|
||||||
if (!entry.isDirectory()) {
|
if (!entry.isDirectory()) {
|
||||||
|
|||||||
Generated
+73
-3
@@ -25,6 +25,7 @@
|
|||||||
"i18next": "^25.2.1",
|
"i18next": "^25.2.1",
|
||||||
"i18next-browser-languagedetector": "^8.1.0",
|
"i18next-browser-languagedetector": "^8.1.0",
|
||||||
"i18next-http-backend": "^3.0.2",
|
"i18next-http-backend": "^3.0.2",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdfjs-dist": "^3.11.174",
|
"pdfjs-dist": "^3.11.174",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
@@ -2689,6 +2690,11 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/core-util-is": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
|
||||||
|
},
|
||||||
"node_modules/cosmiconfig": {
|
"node_modules/cosmiconfig": {
|
||||||
"version": "7.1.0",
|
"version": "7.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
|
||||||
@@ -3450,6 +3456,11 @@
|
|||||||
"cross-fetch": "4.0.0"
|
"cross-fetch": "4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immediate": {
|
||||||
|
"version": "3.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||||
|
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
|
||||||
|
},
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@@ -3491,8 +3502,7 @@
|
|||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"license": "ISC",
|
"license": "ISC"
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"node_modules/is-arrayish": {
|
"node_modules/is-arrayish": {
|
||||||
"version": "0.2.1",
|
"version": "0.2.1",
|
||||||
@@ -3571,6 +3581,11 @@
|
|||||||
"node": ">=0.12.0"
|
"node": ">=0.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/isarray": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
|
||||||
|
},
|
||||||
"node_modules/jiti": {
|
"node_modules/jiti": {
|
||||||
"version": "2.4.2",
|
"version": "2.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
|
||||||
@@ -3630,6 +3645,52 @@
|
|||||||
"graceful-fs": "^4.1.6"
|
"graceful-fs": "^4.1.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jszip": {
|
||||||
|
"version": "3.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||||
|
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||||
|
"dependencies": {
|
||||||
|
"lie": "~3.3.0",
|
||||||
|
"pako": "~1.0.2",
|
||||||
|
"readable-stream": "~2.3.6",
|
||||||
|
"setimmediate": "^1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jszip/node_modules/readable-stream": {
|
||||||
|
"version": "2.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||||
|
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||||
|
"dependencies": {
|
||||||
|
"core-util-is": "~1.0.0",
|
||||||
|
"inherits": "~2.0.3",
|
||||||
|
"isarray": "~1.0.0",
|
||||||
|
"process-nextick-args": "~2.0.0",
|
||||||
|
"safe-buffer": "~5.1.1",
|
||||||
|
"string_decoder": "~1.1.1",
|
||||||
|
"util-deprecate": "~1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jszip/node_modules/safe-buffer": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
|
||||||
|
},
|
||||||
|
"node_modules/jszip/node_modules/string_decoder": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lie": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"immediate": "~3.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.30.1",
|
"version": "1.30.1",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
|
||||||
@@ -4729,6 +4790,11 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/process-nextick-args": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
|
||||||
|
},
|
||||||
"node_modules/prop-types": {
|
"node_modules/prop-types": {
|
||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
@@ -5237,6 +5303,11 @@
|
|||||||
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
|
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/setimmediate": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="
|
||||||
|
},
|
||||||
"node_modules/signal-exit": {
|
"node_modules/signal-exit": {
|
||||||
"version": "3.0.7",
|
"version": "3.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||||
@@ -5697,7 +5768,6 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"i18next": "^25.2.1",
|
"i18next": "^25.2.1",
|
||||||
"i18next-browser-languagedetector": "^8.1.0",
|
"i18next-browser-languagedetector": "^8.1.0",
|
||||||
"i18next-http-backend": "^3.0.2",
|
"i18next-http-backend": "^3.0.2",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdfjs-dist": "^3.11.174",
|
"pdfjs-dist": "^3.11.174",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -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 React from 'react';
|
||||||
import { RainbowThemeProvider } from './components/shared/RainbowThemeProvider';
|
import { RainbowThemeProvider } from './components/shared/RainbowThemeProvider';
|
||||||
|
import { FileContextProvider } from './contexts/FileContext';
|
||||||
import HomePage from './pages/HomePage';
|
import HomePage from './pages/HomePage';
|
||||||
|
|
||||||
// Import global styles
|
// Import global styles
|
||||||
@@ -9,7 +10,9 @@ import './index.css';
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<RainbowThemeProvider>
|
<RainbowThemeProvider>
|
||||||
|
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||||
<HomePage />
|
<HomePage />
|
||||||
|
</FileContextProvider>
|
||||||
</RainbowThemeProvider>
|
</RainbowThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,27 +97,8 @@ export class DeletePagesCommand extends PageCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
undo(): void {
|
undo(): void {
|
||||||
let restoredPages = [...this.pdfDocument.pages];
|
// Simply restore to the previous state (before deletion)
|
||||||
|
this.setPdfDocument(this.previousState);
|
||||||
// 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
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get description(): string {
|
get description(): string {
|
||||||
|
|||||||
@@ -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 VisibilityIcon from "@mui/icons-material/Visibility";
|
||||||
import EditIcon from "@mui/icons-material/Edit";
|
import EditIcon from "@mui/icons-material/Edit";
|
||||||
|
|
||||||
import { FileWithUrl } from "../../types/file";
|
|
||||||
import { getFileSize, getFileDate } from "../../utils/fileUtils";
|
import { getFileSize, getFileDate } from "../../utils/fileUtils";
|
||||||
import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail";
|
import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail";
|
||||||
|
import { fileStorage } from "../../services/fileStorage";
|
||||||
|
|
||||||
interface FileCardProps {
|
interface FileCardProps {
|
||||||
file: FileWithUrl;
|
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;
|
|
||||||
@@ -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 {
|
interface BulkSelectionPanelProps {
|
||||||
csvInput: string;
|
csvInput: string;
|
||||||
setCsvInput: (value: string) => void;
|
setCsvInput: (value: string) => void;
|
||||||
selectedPages: string[];
|
selectedPages: number[];
|
||||||
onUpdatePagesFromCSV: () => void;
|
onUpdatePagesFromCSV: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,21 +9,21 @@ interface DragDropItem {
|
|||||||
|
|
||||||
interface DragDropGridProps<T extends DragDropItem> {
|
interface DragDropGridProps<T extends DragDropItem> {
|
||||||
items: T[];
|
items: T[];
|
||||||
selectedItems: string[];
|
selectedItems: number[];
|
||||||
selectionMode: boolean;
|
selectionMode: boolean;
|
||||||
isAnimating: boolean;
|
isAnimating: boolean;
|
||||||
onDragStart: (itemId: string) => void;
|
onDragStart: (pageNumber: number) => void;
|
||||||
onDragEnd: () => void;
|
onDragEnd: () => void;
|
||||||
onDragOver: (e: React.DragEvent) => void;
|
onDragOver: (e: React.DragEvent) => void;
|
||||||
onDragEnter: (itemId: string) => void;
|
onDragEnter: (pageNumber: number) => void;
|
||||||
onDragLeave: () => void;
|
onDragLeave: () => void;
|
||||||
onDrop: (e: React.DragEvent, targetId: string | 'end') => void;
|
onDrop: (e: React.DragEvent, targetPageNumber: number | 'end') => void;
|
||||||
onEndZoneDragEnter: () => void;
|
onEndZoneDragEnter: () => void;
|
||||||
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>) => React.ReactNode;
|
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>) => React.ReactNode;
|
||||||
renderSplitMarker?: (item: T, index: number) => React.ReactNode;
|
renderSplitMarker?: (item: T, index: number) => React.ReactNode;
|
||||||
draggedItem: string | null;
|
draggedItem: number | null;
|
||||||
dropTarget: string | null;
|
dropTarget: number | null;
|
||||||
multiItemDrag: {itemIds: string[], count: number} | null;
|
multiItemDrag: {pageNumbers: number[], count: number} | null;
|
||||||
dragPosition: {x: number, y: number} | null;
|
dragPosition: {x: number, y: number} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +77,13 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
gap: '1.5rem',
|
gap: '1.5rem',
|
||||||
justifyContent: 'flex-start',
|
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) => (
|
{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 React, { useState } from 'react';
|
||||||
import { Text, Checkbox, Tooltip, ActionIcon, Badge } from '@mantine/core';
|
import { Text, Checkbox, Tooltip, ActionIcon, Badge, Modal } from '@mantine/core';
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||||
import MergeIcon from '@mui/icons-material/Merge';
|
import HistoryIcon from '@mui/icons-material/History';
|
||||||
import SplitscreenIcon from '@mui/icons-material/Splitscreen';
|
|
||||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||||
import styles from './PageEditor.module.css';
|
import styles from './PageEditor.module.css';
|
||||||
|
import FileOperationHistory from '../history/FileOperationHistory';
|
||||||
|
|
||||||
interface FileItem {
|
interface FileItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -35,9 +35,8 @@ interface FileThumbnailProps {
|
|||||||
onToggleFile: (fileId: string) => void;
|
onToggleFile: (fileId: string) => void;
|
||||||
onDeleteFile: (fileId: string) => void;
|
onDeleteFile: (fileId: string) => void;
|
||||||
onViewFile: (fileId: string) => void;
|
onViewFile: (fileId: string) => void;
|
||||||
onMergeFromHere: (fileId: string) => void;
|
|
||||||
onSplitFile: (fileId: string) => void;
|
|
||||||
onSetStatus: (status: string) => void;
|
onSetStatus: (status: string) => void;
|
||||||
|
toolMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FileThumbnail = ({
|
const FileThumbnail = ({
|
||||||
@@ -59,10 +58,11 @@ const FileThumbnail = ({
|
|||||||
onToggleFile,
|
onToggleFile,
|
||||||
onDeleteFile,
|
onDeleteFile,
|
||||||
onViewFile,
|
onViewFile,
|
||||||
onMergeFromHere,
|
|
||||||
onSplitFile,
|
|
||||||
onSetStatus,
|
onSetStatus,
|
||||||
|
toolMode = false,
|
||||||
}: FileThumbnailProps) => {
|
}: FileThumbnailProps) => {
|
||||||
|
const [showHistory, setShowHistory] = useState(false);
|
||||||
|
|
||||||
const formatFileSize = (bytes: number) => {
|
const formatFileSize = (bytes: number) => {
|
||||||
if (bytes === 0) return '0 B';
|
if (bytes === 0) return '0 B';
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
@@ -238,6 +238,8 @@ const FileThumbnail = ({
|
|||||||
whiteSpace: 'nowrap'
|
whiteSpace: 'nowrap'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{!toolMode && (
|
||||||
|
<>
|
||||||
<Tooltip label="View File">
|
<Tooltip label="View File">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
size="md"
|
size="md"
|
||||||
@@ -253,48 +255,36 @@ const FileThumbnail = ({
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip label="Merge from here">
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Tooltip label="View History">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
size="md"
|
size="md"
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
c="white"
|
c="white"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onMergeFromHere(file.id);
|
setShowHistory(true);
|
||||||
onSetStatus(`Starting merge from ${file.name}`);
|
onSetStatus(`Viewing history for ${file.name}`);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MergeIcon style={{ fontSize: 20 }} />
|
<HistoryIcon style={{ fontSize: 20 }} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip label="Split File">
|
<Tooltip label="Close File">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
size="md"
|
size="md"
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
c="white"
|
c="orange"
|
||||||
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"
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onDeleteFile(file.id);
|
onDeleteFile(file.id);
|
||||||
onSetStatus(`Deleted ${file.name}`);
|
onSetStatus(`Closed ${file.name}`);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DeleteIcon style={{ fontSize: 20 }} />
|
<CloseIcon style={{ fontSize: 20 }} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -320,6 +310,21 @@ const FileThumbnail = ({
|
|||||||
{formatFileSize(file.size)}
|
{formatFileSize(file.size)}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* History Modal */}
|
||||||
|
<Modal
|
||||||
|
opened={showHistory}
|
||||||
|
onClose={() => setShowHistory(false)}
|
||||||
|
title={`Operation History - ${file.name}`}
|
||||||
|
size="lg"
|
||||||
|
scrollAreaComponent="div"
|
||||||
|
>
|
||||||
|
<FileOperationHistory
|
||||||
|
fileId={file.name}
|
||||||
|
showOnlyApplied={true}
|
||||||
|
maxHeight={500}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
/* Page container hover effects */
|
/* Page container hover effects - optimized for smooth scrolling */
|
||||||
.pageContainer {
|
.pageContainer {
|
||||||
transition: transform 0.2s ease-in-out;
|
transition: transform 0.2s ease-in-out;
|
||||||
|
/* Enable hardware acceleration for smoother scrolling */
|
||||||
|
will-change: transform;
|
||||||
|
transform: translateZ(0);
|
||||||
|
backface-visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pageContainer:hover {
|
.pageContainer:hover {
|
||||||
transform: scale(1.02);
|
transform: scale(1.02) translateZ(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pageContainer:hover .pageNumber {
|
.pageContainer:hover .pageNumber {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -56,7 +56,7 @@ const PageEditorControls = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'absolute',
|
||||||
left: '50%',
|
left: '50%',
|
||||||
bottom: '20px',
|
bottom: '20px',
|
||||||
transform: 'translateX(-50%)',
|
transform: 'translateX(-50%)',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback, useState, useEffect, useRef } from 'react';
|
||||||
import { Text, Checkbox, Tooltip, ActionIcon } from '@mantine/core';
|
import { Text, Checkbox, Tooltip, ActionIcon, Loader } from '@mantine/core';
|
||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||||
import RotateLeftIcon from '@mui/icons-material/RotateLeft';
|
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 DeleteIcon from '@mui/icons-material/Delete';
|
||||||
import ContentCutIcon from '@mui/icons-material/ContentCut';
|
import ContentCutIcon from '@mui/icons-material/ContentCut';
|
||||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
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 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 {
|
interface PageThumbnailProps {
|
||||||
page: PDFPage;
|
page: PDFPage;
|
||||||
index: number;
|
index: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
selectedPages: string[];
|
originalFile?: File; // For lazy thumbnail generation
|
||||||
|
selectedPages: number[];
|
||||||
selectionMode: boolean;
|
selectionMode: boolean;
|
||||||
draggedPage: string | null;
|
draggedPage: number | null;
|
||||||
dropTarget: string | null;
|
dropTarget: number | null;
|
||||||
movingPage: string | null;
|
movingPage: number | null;
|
||||||
isAnimating: boolean;
|
isAnimating: boolean;
|
||||||
pageRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
|
pageRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
|
||||||
onDragStart: (pageId: string) => void;
|
onDragStart: (pageNumber: number) => void;
|
||||||
onDragEnd: () => void;
|
onDragEnd: () => void;
|
||||||
onDragOver: (e: React.DragEvent) => void;
|
onDragOver: (e: React.DragEvent) => void;
|
||||||
onDragEnter: (pageId: string) => void;
|
onDragEnter: (pageNumber: number) => void;
|
||||||
onDragLeave: () => void;
|
onDragLeave: () => void;
|
||||||
onDrop: (e: React.DragEvent, pageId: string) => void;
|
onDrop: (e: React.DragEvent, pageNumber: number) => void;
|
||||||
onTogglePage: (pageId: string) => void;
|
onTogglePage: (pageNumber: number) => void;
|
||||||
onAnimateReorder: (pageId: string, targetIndex: number) => void;
|
onAnimateReorder: (pageNumber: number, targetIndex: number) => void;
|
||||||
onExecuteCommand: (command: any) => void;
|
onExecuteCommand: (command: Command) => void;
|
||||||
onSetStatus: (status: string) => void;
|
onSetStatus: (status: string) => void;
|
||||||
onSetMovingPage: (pageId: string | null) => void;
|
onSetMovingPage: (pageNumber: number | null) => void;
|
||||||
RotatePagesCommand: any;
|
RotatePagesCommand: typeof RotatePagesCommand;
|
||||||
DeletePagesCommand: any;
|
DeletePagesCommand: typeof DeletePagesCommand;
|
||||||
ToggleSplitCommand: any;
|
ToggleSplitCommand: typeof ToggleSplitCommand;
|
||||||
pdfDocument: any;
|
pdfDocument: PDFDocument;
|
||||||
setPdfDocument: any;
|
setPdfDocument: (doc: PDFDocument) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PageThumbnail = ({
|
const PageThumbnail = React.memo(({
|
||||||
page,
|
page,
|
||||||
index,
|
index,
|
||||||
totalPages,
|
totalPages,
|
||||||
|
originalFile,
|
||||||
selectedPages,
|
selectedPages,
|
||||||
selectionMode,
|
selectionMode,
|
||||||
draggedPage,
|
draggedPage,
|
||||||
@@ -67,6 +80,44 @@ const PageThumbnail = ({
|
|||||||
pdfDocument,
|
pdfDocument,
|
||||||
setPdfDocument,
|
setPdfDocument,
|
||||||
}: PageThumbnailProps) => {
|
}: 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
|
// Register this component with pageRefs for animations
|
||||||
const pageElementRef = useCallback((element: HTMLDivElement | null) => {
|
const pageElementRef = useCallback((element: HTMLDivElement | null) => {
|
||||||
if (element) {
|
if (element) {
|
||||||
@@ -79,7 +130,7 @@ const PageThumbnail = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={pageElementRef}
|
ref={pageElementRef}
|
||||||
data-page-id={page.id}
|
data-page-number={page.pageNumber}
|
||||||
className={`
|
className={`
|
||||||
${styles.pageContainer}
|
${styles.pageContainer}
|
||||||
!rounded-lg
|
!rounded-lg
|
||||||
@@ -96,12 +147,12 @@ const PageThumbnail = ({
|
|||||||
${selectionMode
|
${selectionMode
|
||||||
? 'bg-white hover:bg-gray-50'
|
? 'bg-white hover:bg-gray-50'
|
||||||
: 'bg-white hover:bg-gray-50'}
|
: 'bg-white hover:bg-gray-50'}
|
||||||
${draggedPage === page.id ? 'opacity-50 scale-95' : ''}
|
${draggedPage === page.pageNumber ? 'opacity-50 scale-95' : ''}
|
||||||
${movingPage === page.id ? 'page-moving' : ''}
|
${movingPage === page.pageNumber ? 'page-moving' : ''}
|
||||||
`}
|
`}
|
||||||
style={{
|
style={{
|
||||||
transform: (() => {
|
transform: (() => {
|
||||||
if (!isAnimating && draggedPage && page.id !== draggedPage && dropTarget === page.id) {
|
if (!isAnimating && draggedPage && page.pageNumber !== draggedPage && dropTarget === page.pageNumber) {
|
||||||
return 'translateX(20px)';
|
return 'translateX(20px)';
|
||||||
}
|
}
|
||||||
return 'translateX(0)';
|
return 'translateX(0)';
|
||||||
@@ -109,12 +160,12 @@ const PageThumbnail = ({
|
|||||||
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out'
|
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out'
|
||||||
}}
|
}}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={() => onDragStart(page.id)}
|
onDragStart={() => onDragStart(page.pageNumber)}
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
onDragOver={onDragOver}
|
onDragOver={onDragOver}
|
||||||
onDragEnter={() => onDragEnter(page.id)}
|
onDragEnter={() => onDragEnter(page.pageNumber)}
|
||||||
onDragLeave={onDragLeave}
|
onDragLeave={onDragLeave}
|
||||||
onDrop={(e) => onDrop(e, page.id)}
|
onDrop={(e) => onDrop(e, page.pageNumber)}
|
||||||
>
|
>
|
||||||
{selectionMode && (
|
{selectionMode && (
|
||||||
<div
|
<div
|
||||||
@@ -123,26 +174,31 @@ const PageThumbnail = ({
|
|||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 8,
|
top: 8,
|
||||||
right: 8,
|
right: 8,
|
||||||
zIndex: 4,
|
zIndex: 10,
|
||||||
backgroundColor: 'white',
|
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||||
|
border: '1px solid #ccc',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
padding: '2px',
|
padding: '4px',
|
||||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||||
pointerEvents: 'auto'
|
pointerEvents: 'auto',
|
||||||
|
cursor: 'pointer'
|
||||||
}}
|
}}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
onDragStart={(e) => {
|
onDragStart={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
console.log('📸 Checkbox clicked for page', page.pageNumber);
|
||||||
|
e.stopPropagation();
|
||||||
|
onTogglePage(page.pageNumber);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedPages.includes(page.id)}
|
checked={Array.isArray(selectedPages) ? selectedPages.includes(page.pageNumber) : false}
|
||||||
onChange={(event) => {
|
onChange={() => {
|
||||||
event.stopPropagation();
|
// onChange is handled by the parent div click
|
||||||
onTogglePage(page.id);
|
|
||||||
}}
|
}}
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -162,18 +218,30 @@ const PageThumbnail = ({
|
|||||||
justifyContent: 'center'
|
justifyContent: 'center'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{thumbnailUrl ? (
|
||||||
<img
|
<img
|
||||||
src={page.thumbnail}
|
src={thumbnailUrl}
|
||||||
alt={`Page ${page.pageNumber}`}
|
alt={`Page ${page.pageNumber}`}
|
||||||
style={{
|
style={{
|
||||||
maxWidth: '100%',
|
width: '100%',
|
||||||
maxHeight: '100%',
|
height: '100%',
|
||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
transform: `rotate(${page.rotation}deg)`,
|
transform: `rotate(${page.rotation}deg)`,
|
||||||
transition: 'transform 0.3s ease-in-out'
|
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>
|
</div>
|
||||||
|
|
||||||
<Text
|
<Text
|
||||||
@@ -224,8 +292,8 @@ const PageThumbnail = ({
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (index > 0 && !movingPage && !isAnimating) {
|
if (index > 0 && !movingPage && !isAnimating) {
|
||||||
onSetMovingPage(page.id);
|
onSetMovingPage(page.pageNumber);
|
||||||
onAnimateReorder(page.id, index - 1);
|
onAnimateReorder(page.pageNumber, index - 1);
|
||||||
setTimeout(() => onSetMovingPage(null), 500);
|
setTimeout(() => onSetMovingPage(null), 500);
|
||||||
onSetStatus(`Moved page ${page.pageNumber} left`);
|
onSetStatus(`Moved page ${page.pageNumber} left`);
|
||||||
}
|
}
|
||||||
@@ -244,8 +312,8 @@ const PageThumbnail = ({
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (index < totalPages - 1 && !movingPage && !isAnimating) {
|
if (index < totalPages - 1 && !movingPage && !isAnimating) {
|
||||||
onSetMovingPage(page.id);
|
onSetMovingPage(page.pageNumber);
|
||||||
onAnimateReorder(page.id, index + 1);
|
onAnimateReorder(page.pageNumber, index + 1);
|
||||||
setTimeout(() => onSetMovingPage(null), 500);
|
setTimeout(() => onSetMovingPage(null), 500);
|
||||||
onSetStatus(`Moved page ${page.pageNumber} right`);
|
onSetStatus(`Moved page ${page.pageNumber} right`);
|
||||||
}
|
}
|
||||||
@@ -353,6 +421,20 @@ const PageThumbnail = ({
|
|||||||
</div>
|
</div>
|
||||||
</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;
|
export default PageThumbnail;
|
||||||
|
|||||||
@@ -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 React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
import { Stack, Button, Text, Center } from '@mantine/core';
|
import { Stack, Button, Text, Center, Box, Divider } from '@mantine/core';
|
||||||
import { Dropzone } from '@mantine/dropzone';
|
import { Dropzone } from '@mantine/dropzone';
|
||||||
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 {
|
interface FileUploadSelectorProps {
|
||||||
// Appearance
|
// Appearance
|
||||||
@@ -20,6 +24,10 @@ interface FileUploadSelectorProps {
|
|||||||
// Loading state
|
// Loading state
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
|
||||||
|
// Recent files
|
||||||
|
showRecentFiles?: boolean;
|
||||||
|
maxRecentFiles?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FileUploadSelector = ({
|
const FileUploadSelector = ({
|
||||||
@@ -29,50 +37,94 @@ const FileUploadSelector = ({
|
|||||||
sharedFiles = [],
|
sharedFiles = [],
|
||||||
onFileSelect,
|
onFileSelect,
|
||||||
onFilesSelect,
|
onFilesSelect,
|
||||||
accept = ["application/pdf"],
|
accept = ["application/pdf", "application/zip", "application/x-zip-compressed"],
|
||||||
loading = false,
|
loading = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
showRecentFiles = true,
|
||||||
|
maxRecentFiles = 8,
|
||||||
}: FileUploadSelectorProps) => {
|
}: FileUploadSelectorProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [showFilePickerModal, setShowFilePickerModal] = useState(false);
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
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 (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) {
|
if (onFilesSelect) {
|
||||||
onFilesSelect(uploadedFiles);
|
onFilesSelect(uploadedFiles);
|
||||||
} else if (onFileSelect) {
|
} else if (onFileSelect) {
|
||||||
onFileSelect(uploadedFiles[0]);
|
onFileSelect(uploadedFiles[0]);
|
||||||
}
|
}
|
||||||
}, [onFileSelect, onFilesSelect]);
|
}, [onFileSelect, onFilesSelect, showRecentFiles, storeFile, refreshRecentFiles]);
|
||||||
|
|
||||||
const handleFileInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = event.target.files;
|
const files = event.target.files;
|
||||||
if (files && files.length > 0) {
|
if (files && files.length > 0) {
|
||||||
const fileArray = Array.from(files);
|
const fileArray = Array.from(files);
|
||||||
console.log('File input change:', fileArray.length, 'files');
|
console.log('File input change:', fileArray.length, 'files');
|
||||||
handleFileUpload(fileArray);
|
handleNewFileUpload(fileArray);
|
||||||
}
|
}
|
||||||
// Reset input
|
|
||||||
if (fileInputRef.current) {
|
if (fileInputRef.current) {
|
||||||
fileInputRef.current.value = '';
|
fileInputRef.current.value = '';
|
||||||
}
|
}
|
||||||
}, [handleFileUpload]);
|
}, [handleNewFileUpload]);
|
||||||
|
|
||||||
const openFileDialog = useCallback(() => {
|
const openFileDialog = useCallback(() => {
|
||||||
fileInputRef.current?.click();
|
fileInputRef.current?.click();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleStorageSelection = useCallback((selectedFiles: File[]) => {
|
const handleRecentFileSelection = useCallback(async (file: FileWithUrl) => {
|
||||||
if (selectedFiles.length === 0) return;
|
try {
|
||||||
|
const fileObj = await convertToFile(file);
|
||||||
if (onFilesSelect) {
|
if (onFilesSelect) {
|
||||||
onFilesSelect(selectedFiles);
|
onFilesSelect([fileObj]);
|
||||||
} else if (onFileSelect) {
|
} else if (onFileSelect) {
|
||||||
onFileSelect(selectedFiles[0]);
|
onFileSelect(fileObj);
|
||||||
}
|
}
|
||||||
}, [onFileSelect, onFilesSelect]);
|
} catch (error) {
|
||||||
|
console.error('Failed to load file from recent:', error);
|
||||||
|
}
|
||||||
|
}, [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
|
// Get default title and subtitle from translations if not provided
|
||||||
const displayTitle = title || t("fileUpload.selectFiles", "Select files");
|
const displayTitle = title || t("fileUpload.selectFiles", "Select files");
|
||||||
@@ -80,7 +132,7 @@ const FileUploadSelector = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack align="center" gap="xl">
|
<Stack align="center" gap="sm">
|
||||||
{/* Title and description */}
|
{/* Title and description */}
|
||||||
<Stack align="center" gap="md">
|
<Stack align="center" gap="md">
|
||||||
<UploadFileIcon style={{ fontSize: 64 }} />
|
<UploadFileIcon style={{ fontSize: 64 }} />
|
||||||
@@ -94,27 +146,14 @@ const FileUploadSelector = ({
|
|||||||
|
|
||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
<Stack align="center" gap="md" w="100%">
|
<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 ? (
|
{showDropzone ? (
|
||||||
<Dropzone
|
<Dropzone
|
||||||
onDrop={handleFileUpload}
|
onDrop={handleNewFileUpload}
|
||||||
accept={accept}
|
accept={accept}
|
||||||
multiple={true}
|
multiple={true}
|
||||||
disabled={disabled || loading}
|
disabled={disabled || loading}
|
||||||
style={{ width: '100%', minHeight: 120 }}
|
style={{ width: '100%', height: "5rem" }}
|
||||||
activateOnClick={true}
|
activateOnClick={true}
|
||||||
>
|
>
|
||||||
<Center>
|
<Center>
|
||||||
@@ -123,7 +162,9 @@ const FileUploadSelector = ({
|
|||||||
{t("fileUpload.dropFilesHere", "Drop files here or click to upload")}
|
{t("fileUpload.dropFilesHere", "Drop files here or click to upload")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{accept.includes('application/pdf')
|
{accept.includes('application/pdf') && accept.includes('application/zip')
|
||||||
|
? t("fileUpload.pdfAndZipFiles", "PDF and ZIP files")
|
||||||
|
: accept.includes('application/pdf')
|
||||||
? t("fileUpload.pdfFilesOnly", "PDF files only")
|
? t("fileUpload.pdfFilesOnly", "PDF files only")
|
||||||
: t("fileUpload.supportedFileTypes", "Supported file types")
|
: t("fileUpload.supportedFileTypes", "Supported file types")
|
||||||
}
|
}
|
||||||
@@ -155,15 +196,46 @@ const FileUploadSelector = ({
|
|||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{/* File Picker Modal */}
|
{/* Recent Files Section */}
|
||||||
<FilePickerModal
|
{showRecentFiles && recentFiles.length > 0 && (
|
||||||
opened={showFilePickerModal}
|
<Box w="100%" >
|
||||||
onClose={() => setShowFilePickerModal(false)}
|
<Divider my="md" />
|
||||||
storedFiles={sharedFiles}
|
<Text size="lg" fw={500} mb="md">
|
||||||
onSelectFiles={handleStorageSelection}
|
{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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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 React, { useState, useCallback } from "react";
|
||||||
import { Button, SegmentedControl } from "@mantine/core";
|
import { Button, SegmentedControl, Loader } from "@mantine/core";
|
||||||
import { useRainbowThemeContext } from "./RainbowThemeProvider";
|
import { useRainbowThemeContext } from "./RainbowThemeProvider";
|
||||||
import LanguageSelector from "./LanguageSelector";
|
import LanguageSelector from "./LanguageSelector";
|
||||||
import rainbowStyles from '../../styles/rainbow.module.css';
|
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 AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
||||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||||
import EditNoteIcon from "@mui/icons-material/EditNote";
|
import EditNoteIcon from "@mui/icons-material/EditNote";
|
||||||
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
|
|
||||||
import FolderIcon from "@mui/icons-material/Folder";
|
import FolderIcon from "@mui/icons-material/Folder";
|
||||||
import { Group } from "@mantine/core";
|
import { Group } from "@mantine/core";
|
||||||
|
|
||||||
const VIEW_OPTIONS = [
|
// This will be created inside the component to access switchingTo
|
||||||
|
const createViewOptions = (switchingTo: string | null) => [
|
||||||
{
|
{
|
||||||
label: (
|
label: (
|
||||||
<Group gap={5}>
|
<Group gap={5}>
|
||||||
|
{switchingTo === "viewer" ? (
|
||||||
|
<Loader size="xs" />
|
||||||
|
) : (
|
||||||
<VisibilityIcon fontSize="small" />
|
<VisibilityIcon fontSize="small" />
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
),
|
),
|
||||||
value: "viewer",
|
value: "viewer",
|
||||||
@@ -24,7 +28,11 @@ const VIEW_OPTIONS = [
|
|||||||
{
|
{
|
||||||
label: (
|
label: (
|
||||||
<Group gap={4}>
|
<Group gap={4}>
|
||||||
|
{switchingTo === "pageEditor" ? (
|
||||||
|
<Loader size="xs" />
|
||||||
|
) : (
|
||||||
<EditNoteIcon fontSize="small" />
|
<EditNoteIcon fontSize="small" />
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
),
|
),
|
||||||
value: "pageEditor",
|
value: "pageEditor",
|
||||||
@@ -32,15 +40,11 @@ const VIEW_OPTIONS = [
|
|||||||
{
|
{
|
||||||
label: (
|
label: (
|
||||||
<Group gap={4}>
|
<Group gap={4}>
|
||||||
<InsertDriveFileIcon fontSize="small" />
|
{switchingTo === "fileEditor" ? (
|
||||||
</Group>
|
<Loader size="xs" />
|
||||||
),
|
) : (
|
||||||
value: "fileManager",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: (
|
|
||||||
<Group gap={4}>
|
|
||||||
<FolderIcon fontSize="small" />
|
<FolderIcon fontSize="small" />
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
),
|
),
|
||||||
value: "fileEditor",
|
value: "fileEditor",
|
||||||
@@ -50,13 +54,34 @@ const VIEW_OPTIONS = [
|
|||||||
interface TopControlsProps {
|
interface TopControlsProps {
|
||||||
currentView: string;
|
currentView: string;
|
||||||
setCurrentView: (view: string) => void;
|
setCurrentView: (view: string) => void;
|
||||||
|
selectedToolKey?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TopControls = ({
|
const TopControls = ({
|
||||||
currentView,
|
currentView,
|
||||||
setCurrentView,
|
setCurrentView,
|
||||||
|
selectedToolKey,
|
||||||
}: TopControlsProps) => {
|
}: TopControlsProps) => {
|
||||||
const { themeMode, isRainbowMode, isToggleDisabled, toggleTheme } = useRainbowThemeContext();
|
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 = () => {
|
const getThemeIcon = () => {
|
||||||
if (isRainbowMode) return <AutoAwesomeIcon className={rainbowStyles.rainbowText} />;
|
if (isRainbowMode) return <AutoAwesomeIcon className={rainbowStyles.rainbowText} />;
|
||||||
@@ -66,7 +91,9 @@ const TopControls = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
|
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
|
||||||
<div className="absolute left-4 top-1/2 -translate-y-1/2 pointer-events-auto flex gap-2 items-center">
|
<div className={`absolute left-4 pointer-events-auto flex gap-2 items-center ${
|
||||||
|
isToolSelected ? 'top-4' : 'top-1/2 -translate-y-1/2'
|
||||||
|
}`}>
|
||||||
<Button
|
<Button
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
@@ -87,18 +114,24 @@ const TopControls = ({
|
|||||||
</Button>
|
</Button>
|
||||||
<LanguageSelector />
|
<LanguageSelector />
|
||||||
</div>
|
</div>
|
||||||
|
{!isToolSelected && (
|
||||||
<div className="flex justify-center items-center h-full pointer-events-auto">
|
<div className="flex justify-center items-center h-full pointer-events-auto">
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
data={VIEW_OPTIONS}
|
data={createViewOptions(switchingTo)}
|
||||||
value={currentView}
|
value={currentView}
|
||||||
onChange={setCurrentView}
|
onChange={handleViewChange}
|
||||||
color="blue"
|
color="blue"
|
||||||
radius="xl"
|
radius="xl"
|
||||||
size="md"
|
size="md"
|
||||||
fullWidth
|
fullWidth
|
||||||
className={isRainbowMode ? rainbowStyles.rainbowSegmentedControl : ''}
|
className={isRainbowMode ? rainbowStyles.rainbowSegmentedControl : ''}
|
||||||
|
style={{
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
opacity: switchingTo ? 0.8 : 1,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ type ToolRegistry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface ToolPickerProps {
|
interface ToolPickerProps {
|
||||||
selectedToolKey: string;
|
selectedToolKey: string | null;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
toolRegistry: ToolRegistry;
|
toolRegistry: ToolRegistry;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,30 @@
|
|||||||
import React from "react";
|
|
||||||
import { FileWithUrl } from "../../types/file";
|
import { FileWithUrl } from "../../types/file";
|
||||||
|
import { useToolManagement } from "../../hooks/useToolManagement";
|
||||||
|
|
||||||
interface ToolRendererProps {
|
interface ToolRendererProps {
|
||||||
selectedToolKey: string;
|
selectedToolKey: string;
|
||||||
selectedTool: any;
|
|
||||||
pdfFile: any;
|
pdfFile: any;
|
||||||
files: FileWithUrl[];
|
files: FileWithUrl[];
|
||||||
downloadUrl: string | null;
|
|
||||||
setDownloadUrl: (url: string | null) => void;
|
|
||||||
toolParams: any;
|
toolParams: any;
|
||||||
updateParams: (params: any) => void;
|
updateParams: (params: any) => void;
|
||||||
|
toolSelectedFiles?: File[];
|
||||||
|
onPreviewFile?: (file: File | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ToolRenderer = ({
|
const ToolRenderer = ({
|
||||||
selectedToolKey,
|
selectedToolKey,
|
||||||
selectedTool,
|
files,
|
||||||
pdfFile,
|
|
||||||
files,
|
|
||||||
downloadUrl,
|
|
||||||
setDownloadUrl,
|
|
||||||
toolParams,
|
toolParams,
|
||||||
updateParams,
|
updateParams,
|
||||||
|
toolSelectedFiles = [],
|
||||||
|
onPreviewFile,
|
||||||
}: ToolRendererProps) => {
|
}: ToolRendererProps) => {
|
||||||
|
// Get the tool from registry
|
||||||
|
const { toolRegistry } = useToolManagement();
|
||||||
|
const selectedTool = toolRegistry[selectedToolKey];
|
||||||
|
|
||||||
if (!selectedTool || !selectedTool.component) {
|
if (!selectedTool || !selectedTool.component) {
|
||||||
return <div>Tool not found</div>;
|
return <div>Tool not found: {selectedToolKey}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ToolComponent = selectedTool.component;
|
const ToolComponent = selectedTool.component;
|
||||||
@@ -33,19 +34,15 @@ const ToolRenderer = ({
|
|||||||
case "split":
|
case "split":
|
||||||
return (
|
return (
|
||||||
<ToolComponent
|
<ToolComponent
|
||||||
file={pdfFile}
|
selectedFiles={toolSelectedFiles}
|
||||||
downloadUrl={downloadUrl}
|
onPreviewFile={onPreviewFile}
|
||||||
setDownloadUrl={setDownloadUrl}
|
|
||||||
params={toolParams}
|
|
||||||
updateParams={updateParams}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "compress":
|
case "compress":
|
||||||
return (
|
return (
|
||||||
<ToolComponent
|
<ToolComponent
|
||||||
files={files}
|
files={files}
|
||||||
setDownloadUrl={setDownloadUrl}
|
setLoading={(loading: boolean) => {}}
|
||||||
setLoading={(loading: boolean) => {}} // TODO: Add loading state
|
|
||||||
params={toolParams}
|
params={toolParams}
|
||||||
updateParams={updateParams}
|
updateParams={updateParams}
|
||||||
/>
|
/>
|
||||||
@@ -54,7 +51,6 @@ const ToolRenderer = ({
|
|||||||
return (
|
return (
|
||||||
<ToolComponent
|
<ToolComponent
|
||||||
files={files}
|
files={files}
|
||||||
setDownloadUrl={setDownloadUrl}
|
|
||||||
params={toolParams}
|
params={toolParams}
|
||||||
updateParams={updateParams}
|
updateParams={updateParams}
|
||||||
/>
|
/>
|
||||||
@@ -63,7 +59,6 @@ const ToolRenderer = ({
|
|||||||
return (
|
return (
|
||||||
<ToolComponent
|
<ToolComponent
|
||||||
files={files}
|
files={files}
|
||||||
setDownloadUrl={setDownloadUrl}
|
|
||||||
params={toolParams}
|
params={toolParams}
|
||||||
updateParams={updateParams}
|
updateParams={updateParams}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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 React, { useEffect, useState, useRef, useCallback } from "react";
|
||||||
import { Paper, Stack, Text, ScrollArea, Loader, Center, Button, Group, NumberInput, useMantineTheme } from "@mantine/core";
|
import { Paper, Stack, Text, ScrollArea, Loader, Center, Button, Group, NumberInput, useMantineTheme, ActionIcon, Box, Tabs } from "@mantine/core";
|
||||||
import { getDocument, GlobalWorkerOptions } from "pdfjs-dist";
|
import { getDocument, GlobalWorkerOptions } from "pdfjs-dist";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew";
|
import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew";
|
||||||
@@ -9,8 +9,12 @@ import LastPageIcon from "@mui/icons-material/LastPage";
|
|||||||
import ViewSidebarIcon from "@mui/icons-material/ViewSidebar";
|
import ViewSidebarIcon from "@mui/icons-material/ViewSidebar";
|
||||||
import ViewWeekIcon from "@mui/icons-material/ViewWeek"; // for dual page (book)
|
import ViewWeekIcon from "@mui/icons-material/ViewWeek"; // for dual page (book)
|
||||||
import DescriptionIcon from "@mui/icons-material/Description"; // for single page
|
import DescriptionIcon from "@mui/icons-material/Description"; // for single page
|
||||||
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
import { useLocalStorage } from "@mantine/hooks";
|
import { useLocalStorage } from "@mantine/hooks";
|
||||||
import { fileStorage } from "../../services/fileStorage";
|
import { fileStorage } from "../../services/fileStorage";
|
||||||
|
import SkeletonLoader from '../shared/SkeletonLoader';
|
||||||
|
import { useFileContext } from "../../contexts/FileContext";
|
||||||
|
import { useFileWithUrl } from "../../hooks/useFileWithUrl";
|
||||||
|
|
||||||
GlobalWorkerOptions.workerSrc = "/pdf.worker.js";
|
GlobalWorkerOptions.workerSrc = "/pdf.worker.js";
|
||||||
|
|
||||||
@@ -29,7 +33,7 @@ const LazyPageImage = ({
|
|||||||
pageIndex, zoom, theme, isFirst, renderPage, pageImages, setPageRef
|
pageIndex, zoom, theme, isFirst, renderPage, pageImages, setPageRef
|
||||||
}: LazyPageImageProps) => {
|
}: LazyPageImageProps) => {
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
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);
|
const imgRef = useRef<HTMLImageElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -54,6 +58,13 @@ const LazyPageImage = ({
|
|||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [imageUrl]);
|
}, [imageUrl]);
|
||||||
|
|
||||||
|
// Update local state when pageImages changes (from preloading)
|
||||||
|
useEffect(() => {
|
||||||
|
if (pageImages[pageIndex]) {
|
||||||
|
setImageUrl(pageImages[pageIndex]);
|
||||||
|
}
|
||||||
|
}, [pageImages, pageIndex]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isVisible && !imageUrl) {
|
if (isVisible && !imageUrl) {
|
||||||
renderPage(pageIndex).then((url) => {
|
renderPage(pageIndex).then((url) => {
|
||||||
@@ -123,20 +134,40 @@ const LazyPageImage = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export interface ViewerProps {
|
export interface ViewerProps {
|
||||||
pdfFile: { file: File; url: string } | null; // First file in the array
|
|
||||||
setPdfFile: (file: { file: File; url: string } | null) => void;
|
|
||||||
sidebarsVisible: boolean;
|
sidebarsVisible: boolean;
|
||||||
setSidebarsVisible: (v: boolean) => void;
|
setSidebarsVisible: (v: boolean) => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
previewFile?: File; // For preview mode - bypasses context
|
||||||
}
|
}
|
||||||
|
|
||||||
const Viewer = ({
|
const Viewer = ({
|
||||||
pdfFile,
|
|
||||||
setPdfFile,
|
|
||||||
sidebarsVisible,
|
sidebarsVisible,
|
||||||
setSidebarsVisible,
|
setSidebarsVisible,
|
||||||
|
onClose,
|
||||||
|
previewFile,
|
||||||
}: ViewerProps) => {
|
}: ViewerProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const theme = useMantineTheme();
|
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 [numPages, setNumPages] = useState<number>(0);
|
||||||
const [pageImages, setPageImages] = useState<string[]>([]);
|
const [pageImages, setPageImages] = useState<string[]>([]);
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
@@ -144,16 +175,50 @@ const Viewer = ({
|
|||||||
const [dualPage, setDualPage] = useState(false);
|
const [dualPage, setDualPage] = useState(false);
|
||||||
const [zoom, setZoom] = useState(1); // 1 = 100%
|
const [zoom, setZoom] = useState(1); // 1 = 100%
|
||||||
const pageRefs = useRef<(HTMLImageElement | null)[]>([]);
|
const pageRefs = useRef<(HTMLImageElement | null)[]>([]);
|
||||||
|
|
||||||
|
|
||||||
|
// 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 scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||||
const userInitiatedRef = useRef(false);
|
|
||||||
const suppressScrollRef = useRef(false);
|
|
||||||
const pdfDocRef = useRef<any>(null);
|
const pdfDocRef = useRef<any>(null);
|
||||||
const renderingPagesRef = useRef<Set<number>>(new Set());
|
const renderingPagesRef = useRef<Set<number>>(new Set());
|
||||||
const currentArrayBufferRef = useRef<ArrayBuffer | null>(null);
|
const currentArrayBufferRef = useRef<ArrayBuffer | null>(null);
|
||||||
|
const preloadingRef = useRef<boolean>(false);
|
||||||
|
|
||||||
// Function to render a specific page on-demand
|
// Function to render a specific page on-demand
|
||||||
const renderPage = async (pageIndex: number): Promise<string | null> => {
|
const renderPage = async (pageIndex: number): Promise<string | null> => {
|
||||||
if (!pdfFile || !pdfDocRef.current || renderingPagesRef.current.has(pageIndex)) {
|
if (!pdfDocRef.current || renderingPagesRef.current.has(pageIndex)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,70 +259,78 @@ const Viewer = ({
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Listen for hash changes and update currentPage
|
// Progressive preloading function
|
||||||
useEffect(() => {
|
const startProgressivePreload = async () => {
|
||||||
function handleHashChange() {
|
if (!pdfDocRef.current || preloadingRef.current || numPages === 0) return;
|
||||||
if (window.location.hash.startsWith("#page=")) {
|
|
||||||
const page = parseInt(window.location.hash.replace("#page=", ""), 10);
|
preloadingRef.current = true;
|
||||||
if (!isNaN(page) && page >= 1 && page <= numPages) {
|
|
||||||
setCurrentPage(page);
|
// 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;
|
|
||||||
|
// 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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener("hashchange", handleHashChange);
|
|
||||||
handleHashChange(); // Run on mount
|
|
||||||
return () => window.removeEventListener("hashchange", handleHashChange);
|
|
||||||
}, [numPages]);
|
|
||||||
|
|
||||||
// Scroll to the current page when it changes
|
preloadingRef.current = false;
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
// Initialize current page when PDF loads
|
||||||
if (viewport && scrollEndHandler) {
|
useEffect(() => {
|
||||||
viewport.removeEventListener("scrollend", scrollEndHandler);
|
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"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (timeout) clearTimeout(timeout);
|
|
||||||
};
|
};
|
||||||
}
|
|
||||||
}, [currentPage, pageImages]);
|
|
||||||
|
|
||||||
// Detect visible page on scroll and update hash
|
// Throttled scroll handler to prevent jerky updates
|
||||||
const handleScroll = () => {
|
const handleScrollThrottled = useCallback(() => {
|
||||||
if (suppressScrollRef.current) return;
|
|
||||||
const scrollArea = scrollAreaRef.current;
|
const scrollArea = scrollAreaRef.current;
|
||||||
if (!scrollArea || !pageRefs.current.length) return;
|
if (!scrollArea || !pageRefs.current.length) return;
|
||||||
|
|
||||||
const areaRect = scrollArea.getBoundingClientRect();
|
const areaRect = scrollArea.getBoundingClientRect();
|
||||||
|
const viewportCenter = areaRect.top + areaRect.height / 2;
|
||||||
let closestIdx = 0;
|
let closestIdx = 0;
|
||||||
let minDist = Infinity;
|
let minDist = Infinity;
|
||||||
|
|
||||||
pageRefs.current.forEach((img, idx) => {
|
pageRefs.current.forEach((img, idx) => {
|
||||||
if (img) {
|
if (img) {
|
||||||
const imgRect = img.getBoundingClientRect();
|
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) {
|
if (dist < minDist) {
|
||||||
minDist = dist;
|
minDist = dist;
|
||||||
closestIdx = idx;
|
closestIdx = idx;
|
||||||
@@ -265,30 +338,41 @@ const Viewer = ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update page number display only if changed
|
||||||
if (currentPage !== closestIdx + 1) {
|
if (currentPage !== closestIdx + 1) {
|
||||||
setCurrentPage(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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
async function loadPdfInfo() {
|
async function loadPdfInfo() {
|
||||||
if (!pdfFile || !pdfFile.url) {
|
if (!effectiveFile) {
|
||||||
setNumPages(0);
|
setNumPages(0);
|
||||||
setPageImages([]);
|
setPageImages([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
let pdfUrl = pdfFile.url;
|
let pdfData;
|
||||||
|
|
||||||
|
// For preview files, use ArrayBuffer directly to avoid blob URL issues
|
||||||
|
if (previewFile && effectiveFile.file === previewFile) {
|
||||||
|
const arrayBuffer = await previewFile.arrayBuffer();
|
||||||
|
pdfData = { data: arrayBuffer };
|
||||||
|
}
|
||||||
// Handle special IndexedDB URLs for large files
|
// Handle special IndexedDB URLs for large files
|
||||||
if (pdfFile.url.startsWith('indexeddb:')) {
|
else if (effectiveFile.url?.startsWith('indexeddb:')) {
|
||||||
const fileId = pdfFile.url.replace('indexeddb:', '');
|
const fileId = effectiveFile.url.replace('indexeddb:', '');
|
||||||
console.log('Loading large file from IndexedDB:', fileId);
|
|
||||||
|
|
||||||
// Get data directly from IndexedDB
|
// Get data directly from IndexedDB
|
||||||
const arrayBuffer = await fileStorage.getFileData(fileId);
|
const arrayBuffer = await fileStorage.getFileData(fileId);
|
||||||
@@ -298,21 +382,23 @@ const Viewer = ({
|
|||||||
|
|
||||||
// Store reference for cleanup
|
// Store reference for cleanup
|
||||||
currentArrayBufferRef.current = arrayBuffer;
|
currentArrayBufferRef.current = arrayBuffer;
|
||||||
|
pdfData = { data: arrayBuffer };
|
||||||
// Use ArrayBuffer directly instead of creating blob URL
|
} else if (effectiveFile.url) {
|
||||||
const pdf = await getDocument({ data: arrayBuffer }).promise;
|
|
||||||
pdfDocRef.current = pdf;
|
|
||||||
setNumPages(pdf.numPages);
|
|
||||||
if (!cancelled) setPageImages(new Array(pdf.numPages).fill(null));
|
|
||||||
} else {
|
|
||||||
// Standard blob URL or regular URL
|
// Standard blob URL or regular URL
|
||||||
const pdf = await getDocument(pdfUrl).promise;
|
pdfData = effectiveFile.url;
|
||||||
|
} else {
|
||||||
|
throw new Error('No valid PDF source available');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pdf = await getDocument(pdfData).promise;
|
||||||
pdfDocRef.current = pdf;
|
pdfDocRef.current = pdf;
|
||||||
setNumPages(pdf.numPages);
|
setNumPages(pdf.numPages);
|
||||||
if (!cancelled) setPageImages(new Array(pdf.numPages).fill(null));
|
if (!cancelled) {
|
||||||
|
setPageImages(new Array(pdf.numPages).fill(null));
|
||||||
|
// Start progressive preloading after a short delay
|
||||||
|
setTimeout(() => startProgressivePreload(), 100);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load PDF:', error);
|
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setPageImages([]);
|
setPageImages([]);
|
||||||
setNumPages(0);
|
setNumPages(0);
|
||||||
@@ -323,10 +409,12 @@ const Viewer = ({
|
|||||||
loadPdfInfo();
|
loadPdfInfo();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
// Stop any ongoing preloading
|
||||||
|
preloadingRef.current = false;
|
||||||
// Cleanup ArrayBuffer reference to help garbage collection
|
// Cleanup ArrayBuffer reference to help garbage collection
|
||||||
currentArrayBufferRef.current = null;
|
currentArrayBufferRef.current = null;
|
||||||
};
|
};
|
||||||
}, [pdfFile]);
|
}, [effectiveFile, previewFile]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const viewport = scrollAreaRef.current;
|
const viewport = scrollAreaRef.current;
|
||||||
@@ -339,39 +427,62 @@ const Viewer = ({
|
|||||||
}, [pageImages]);
|
}, [pageImages]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Box style={{ position: 'relative', height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||||
{!pdfFile ? (
|
{/* Close Button - Only show in preview mode */}
|
||||||
<Center style={{ flex: 1 }}>
|
{onClose && previewFile && (
|
||||||
<Stack align="center">
|
<ActionIcon
|
||||||
<Text c="dimmed">{t("viewer.noPdfLoaded", "No PDF loaded. Click to upload a PDF.")}</Text>
|
variant="filled"
|
||||||
<Button
|
color="gray"
|
||||||
component="label"
|
size="lg"
|
||||||
variant="outline"
|
style={{
|
||||||
color="blue"
|
position: 'absolute',
|
||||||
>
|
top: '1rem',
|
||||||
{t("viewer.choosePdf", "Choose PDF")}
|
right: '1rem',
|
||||||
<input
|
zIndex: 1000,
|
||||||
type="file"
|
borderRadius: '50%',
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
onClick={onClose}
|
||||||
</Button>
|
>
|
||||||
</Stack>
|
<CloseIcon />
|
||||||
</Center>
|
</ActionIcon>
|
||||||
) : loading ? (
|
)}
|
||||||
|
|
||||||
|
{!effectiveFile ? (
|
||||||
<Center style={{ flex: 1 }}>
|
<Center style={{ flex: 1 }}>
|
||||||
<Loader size="lg" />
|
<Text c="red">Error: No file provided to viewer</Text>
|
||||||
</Center>
|
</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
|
<ScrollArea
|
||||||
style={{ flex: 1, height: "100vh", position: "relative"}}
|
style={{ flex: 1, position: "relative"}}
|
||||||
viewportRef={scrollAreaRef}
|
viewportRef={scrollAreaRef}
|
||||||
>
|
>
|
||||||
<Stack gap="xl" align="center" >
|
<Stack gap="xl" align="center" >
|
||||||
@@ -456,7 +567,7 @@ const Viewer = ({
|
|||||||
px={8}
|
px={8}
|
||||||
radius="xl"
|
radius="xl"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
window.location.hash = `#page=1`;
|
scrollToPage(1);
|
||||||
}}
|
}}
|
||||||
disabled={currentPage === 1}
|
disabled={currentPage === 1}
|
||||||
style={{ minWidth: 36 }}
|
style={{ minWidth: 36 }}
|
||||||
@@ -470,7 +581,8 @@ const Viewer = ({
|
|||||||
px={8}
|
px={8}
|
||||||
radius="xl"
|
radius="xl"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
window.location.hash = `#page=${Math.max(1, (currentPage || 1) - 1)}`;
|
const prevPage = Math.max(1, (currentPage || 1) - 1);
|
||||||
|
scrollToPage(prevPage);
|
||||||
}}
|
}}
|
||||||
disabled={currentPage === 1}
|
disabled={currentPage === 1}
|
||||||
style={{ minWidth: 36 }}
|
style={{ minWidth: 36 }}
|
||||||
@@ -482,7 +594,7 @@ const Viewer = ({
|
|||||||
onChange={value => {
|
onChange={value => {
|
||||||
const page = Number(value);
|
const page = Number(value);
|
||||||
if (!isNaN(page) && page >= 1 && page <= numPages) {
|
if (!isNaN(page) && page >= 1 && page <= numPages) {
|
||||||
window.location.hash = `#page=${page}`;
|
scrollToPage(page);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
min={1}
|
min={1}
|
||||||
@@ -502,7 +614,8 @@ const Viewer = ({
|
|||||||
px={8}
|
px={8}
|
||||||
radius="xl"
|
radius="xl"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
window.location.hash = `#page=${Math.min(numPages, (currentPage || 1) + 1)}`;
|
const nextPage = Math.min(numPages, (currentPage || 1) + 1);
|
||||||
|
scrollToPage(nextPage);
|
||||||
}}
|
}}
|
||||||
disabled={currentPage === numPages}
|
disabled={currentPage === numPages}
|
||||||
style={{ minWidth: 36 }}
|
style={{ minWidth: 36 }}
|
||||||
@@ -516,7 +629,7 @@ const Viewer = ({
|
|||||||
px={8}
|
px={8}
|
||||||
radius="xl"
|
radius="xl"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
window.location.hash = `#page=${numPages}`;
|
scrollToPage(numPages);
|
||||||
}}
|
}}
|
||||||
disabled={currentPage === numPages}
|
disabled={currentPage === numPages}
|
||||||
style={{ minWidth: 36 }}
|
style={{ minWidth: 36 }}
|
||||||
@@ -559,8 +672,10 @@ const Viewer = ({
|
|||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</>
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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];
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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[] = [];
|
const pages: PDFPage[] = [];
|
||||||
|
|
||||||
// Generate thumbnails for all pages
|
// Create pages without thumbnails initially - load them lazily
|
||||||
for (let i = 1; i <= totalPages; i++) {
|
for (let i = 1; i <= totalPages; i++) {
|
||||||
const thumbnail = await generatePageThumbnail(file, i);
|
|
||||||
pages.push({
|
pages.push({
|
||||||
id: `${file.name}-page-${i}`,
|
id: `${file.name}-page-${i}`,
|
||||||
pageNumber: i,
|
pageNumber: i,
|
||||||
thumbnail,
|
thumbnail: null, // Will be loaded lazily
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
selected: false
|
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
|
// Clean up
|
||||||
pdf.destroy();
|
pdf.destroy();
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
+98
-328
@@ -1,322 +1,83 @@
|
|||||||
import React, { useState, useCallback, useEffect } from "react";
|
import React, { useState, useCallback} from "react";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useFileContext } from "../contexts/FileContext";
|
||||||
import { useToolParams } from "../hooks/useToolParams";
|
import { useToolManagement } from "../hooks/useToolManagement";
|
||||||
import { useFileWithUrl } from "../hooks/useFileWithUrl";
|
import { Group, Box, Button, Container } from "@mantine/core";
|
||||||
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 { useRainbowThemeContext } from "../components/shared/RainbowThemeProvider";
|
import { useRainbowThemeContext } from "../components/shared/RainbowThemeProvider";
|
||||||
import rainbowStyles from '../styles/rainbow.module.css';
|
import rainbowStyles from '../styles/rainbow.module.css';
|
||||||
|
|
||||||
import ToolPicker from "../components/tools/ToolPicker";
|
import ToolPicker from "../components/tools/ToolPicker";
|
||||||
import TopControls from "../components/shared/TopControls";
|
import TopControls from "../components/shared/TopControls";
|
||||||
import FileManager from "../components/fileManagement/FileManager";
|
import FileEditor from "../components/fileEditor/FileEditor";
|
||||||
import FileEditor from "../components/pageEditor/FileEditor";
|
|
||||||
import PageEditor from "../components/pageEditor/PageEditor";
|
import PageEditor from "../components/pageEditor/PageEditor";
|
||||||
import PageEditorControls from "../components/pageEditor/PageEditorControls";
|
import PageEditorControls from "../components/pageEditor/PageEditorControls";
|
||||||
import Viewer from "../components/viewer/Viewer";
|
import Viewer from "../components/viewer/Viewer";
|
||||||
import FileUploadSelector from "../components/shared/FileUploadSelector";
|
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 ToolRenderer from "../components/tools/ToolRenderer";
|
||||||
import QuickAccessBar from "../components/shared/QuickAccessBar";
|
import QuickAccessBar from "../components/shared/QuickAccessBar";
|
||||||
import { useMultipleEndpointsEnabled } from "../hooks/useEndpointConfig";
|
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() {
|
export default function HomePage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const theme = useMantineTheme();
|
|
||||||
const { isRainbowMode } = useRainbowThemeContext();
|
const { isRainbowMode } = useRainbowThemeContext();
|
||||||
|
|
||||||
// Core app state
|
// Get file context
|
||||||
const [selectedToolKey, setSelectedToolKey] = useState<string>(searchParams.get("t") || "split");
|
const fileContext = useFileContext();
|
||||||
const [currentView, setCurrentView] = useState<string>(searchParams.get("v") || "viewer");
|
const { activeFiles, currentView, currentMode, setCurrentView, addFiles } = fileContext;
|
||||||
|
|
||||||
// File state separation
|
const {
|
||||||
const [storedFiles, setStoredFiles] = useState<any[]>([]); // IndexedDB files (FileManager)
|
selectedToolKey,
|
||||||
const [activeFiles, setActiveFiles] = useState<File[]>([]); // Active working set (persisted)
|
selectedTool,
|
||||||
const [preSelectedFiles, setPreSelectedFiles] = useState([]);
|
toolParams,
|
||||||
|
toolRegistry,
|
||||||
|
selectTool,
|
||||||
|
clearToolSelection,
|
||||||
|
updateToolParams,
|
||||||
|
} = useToolManagement();
|
||||||
|
|
||||||
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
const [toolSelectedFiles, setToolSelectedFiles] = useState<File[]>([]);
|
||||||
const [sidebarsVisible, setSidebarsVisible] = useState(true);
|
const [sidebarsVisible, setSidebarsVisible] = useState(true);
|
||||||
const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker');
|
const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker');
|
||||||
const [readerMode, setReaderMode] = useState(false);
|
const [readerMode, setReaderMode] = useState(false);
|
||||||
|
|
||||||
// Page editor functions
|
|
||||||
const [pageEditorFunctions, setPageEditorFunctions] = useState<any>(null);
|
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(
|
const handleToolSelect = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
setSelectedToolKey(id);
|
selectTool(id);
|
||||||
if (toolRegistry[id]?.view) setCurrentView(toolRegistry[id].view);
|
if (toolRegistry[id]?.view) setCurrentView(toolRegistry[id].view);
|
||||||
setLeftPanelView('toolContent'); // Switch to tool content view when a tool is selected
|
setLeftPanelView('toolContent');
|
||||||
setReaderMode(false); // Exit reader mode when selecting a tool
|
setReaderMode(false);
|
||||||
},
|
},
|
||||||
[toolRegistry]
|
[selectTool, toolRegistry, setCurrentView]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle quick access actions
|
|
||||||
const handleQuickAccessTools = useCallback(() => {
|
const handleQuickAccessTools = useCallback(() => {
|
||||||
setLeftPanelView('toolPicker');
|
setLeftPanelView('toolPicker');
|
||||||
setReaderMode(false);
|
setReaderMode(false);
|
||||||
}, []);
|
clearToolSelection();
|
||||||
|
}, [clearToolSelection]);
|
||||||
|
|
||||||
const handleReaderToggle = useCallback(() => {
|
const handleReaderToggle = useCallback(() => {
|
||||||
setReaderMode(!readerMode);
|
setReaderMode(!readerMode);
|
||||||
}, [readerMode]);
|
}, [readerMode]);
|
||||||
|
|
||||||
// Update URL when view changes
|
|
||||||
const handleViewChange = useCallback((view: string) => {
|
const handleViewChange = useCallback((view: string) => {
|
||||||
setCurrentView(view);
|
setCurrentView(view as any);
|
||||||
const params = new URLSearchParams(window.location.search);
|
}, [setCurrentView]);
|
||||||
params.set('view', view);
|
|
||||||
const newUrl = `${window.location.pathname}?${params.toString()}`;
|
|
||||||
window.history.replaceState({}, '', newUrl);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Active file management
|
const addToActiveFiles = useCallback(async (file: File) => {
|
||||||
const addToActiveFiles = useCallback((file: File) => {
|
const exists = activeFiles.some(f => f.name === file.name && f.size === file.size);
|
||||||
setActiveFiles(prev => {
|
if (!exists) {
|
||||||
// Avoid duplicates based on name and size
|
await addFiles([file]);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
}, [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 (
|
return (
|
||||||
<Group
|
<Group
|
||||||
@@ -334,17 +95,12 @@ export default function HomePage() {
|
|||||||
readerMode={readerMode}
|
readerMode={readerMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Left: Tool Picker OR Selected Tool Panel */}
|
{/* Left: Tool Picker or Selected Tool Panel */}
|
||||||
<div
|
<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={{
|
style={{
|
||||||
backgroundColor: 'var(--bg-surface)',
|
width: sidebarsVisible && !readerMode ? '14vw' : '0',
|
||||||
borderRight: '1px solid var(--border-subtle)',
|
padding: sidebarsVisible && !readerMode ? '1rem' : '0'
|
||||||
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'
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -373,7 +129,7 @@ export default function HomePage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setLeftPanelView('toolPicker')}
|
onClick={handleQuickAccessTools}
|
||||||
className="text-sm"
|
className="text-sm"
|
||||||
>
|
>
|
||||||
← {t("fileUpload.backToTools", "Back to Tools")}
|
← {t("fileUpload.backToTools", "Back to Tools")}
|
||||||
@@ -389,13 +145,8 @@ export default function HomePage() {
|
|||||||
<div className="flex-1 min-h-0">
|
<div className="flex-1 min-h-0">
|
||||||
<ToolRenderer
|
<ToolRenderer
|
||||||
selectedToolKey={selectedToolKey}
|
selectedToolKey={selectedToolKey}
|
||||||
selectedTool={selectedTool}
|
toolSelectedFiles={toolSelectedFiles}
|
||||||
pdfFile={activeFiles[0] || null}
|
onPreviewFile={setPreviewFile}
|
||||||
files={activeFiles}
|
|
||||||
downloadUrl={downloadUrl}
|
|
||||||
setDownloadUrl={setDownloadUrl}
|
|
||||||
toolParams={toolParams}
|
|
||||||
updateParams={updateParams}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -414,19 +165,16 @@ export default function HomePage() {
|
|||||||
<TopControls
|
<TopControls
|
||||||
currentView={currentView}
|
currentView={currentView}
|
||||||
setCurrentView={handleViewChange}
|
setCurrentView={handleViewChange}
|
||||||
|
selectedToolKey={selectedToolKey}
|
||||||
/>
|
/>
|
||||||
{/* Main content area */}
|
{/* Main content area */}
|
||||||
<Box className="flex-1 min-h-0 margin-top-200 relative z-10">
|
<Box
|
||||||
{currentView === "fileManager" ? (
|
className="flex-1 min-h-0 relative z-10"
|
||||||
<FileManager
|
style={{
|
||||||
files={storedFiles}
|
transition: 'opacity 0.15s ease-in-out',
|
||||||
setFiles={setStoredFiles}
|
}}
|
||||||
setCurrentView={handleViewChange}
|
>
|
||||||
onOpenFileEditor={handleOpenFileEditor}
|
{!activeFiles[0] ? (
|
||||||
onOpenPageEditor={handleOpenPageEditor}
|
|
||||||
onLoadFileToActive={addToActiveFiles}
|
|
||||||
/>
|
|
||||||
) : (currentView != "fileManager") && !activeFiles[0] ? (
|
|
||||||
<Container size="lg" p="xl" h="100%" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<Container size="lg" p="xl" h="100%" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<FileUploadSelector
|
<FileUploadSelector
|
||||||
title={currentView === "viewer"
|
title={currentView === "viewer"
|
||||||
@@ -434,23 +182,21 @@ export default function HomePage() {
|
|||||||
: t("fileUpload.selectPdfToEdit", "Select a PDF to edit")
|
: t("fileUpload.selectPdfToEdit", "Select a PDF to edit")
|
||||||
}
|
}
|
||||||
subtitle={t("fileUpload.chooseFromStorage", "Choose a file from storage or upload a new PDF")}
|
subtitle={t("fileUpload.chooseFromStorage", "Choose a file from storage or upload a new PDF")}
|
||||||
sharedFiles={storedFiles}
|
|
||||||
onFileSelect={(file) => {
|
onFileSelect={(file) => {
|
||||||
addToActiveFiles(file);
|
addToActiveFiles(file);
|
||||||
}}
|
}}
|
||||||
allowMultiple={false}
|
onFilesSelect={(files) => {
|
||||||
|
files.forEach(addToActiveFiles);
|
||||||
|
}}
|
||||||
accept={["application/pdf"]}
|
accept={["application/pdf"]}
|
||||||
loading={false}
|
loading={false}
|
||||||
|
showRecentFiles={true}
|
||||||
|
maxRecentFiles={8}
|
||||||
/>
|
/>
|
||||||
</Container>
|
</Container>
|
||||||
) : currentView === "fileEditor" ? (
|
) : currentView === "fileEditor" ? (
|
||||||
<FileEditor
|
<FileEditor
|
||||||
activeFiles={activeFiles}
|
|
||||||
setActiveFiles={setActiveFiles}
|
|
||||||
preSelectedFiles={preSelectedFiles}
|
|
||||||
onClearPreSelection={() => setPreSelectedFiles([])}
|
|
||||||
onOpenPageEditor={(file) => {
|
onOpenPageEditor={(file) => {
|
||||||
setCurrentActiveFile(file);
|
|
||||||
handleViewChange("pageEditor");
|
handleViewChange("pageEditor");
|
||||||
}}
|
}}
|
||||||
onMergeFiles={(filesToMerge) => {
|
onMergeFiles={(filesToMerge) => {
|
||||||
@@ -461,28 +207,30 @@ export default function HomePage() {
|
|||||||
/>
|
/>
|
||||||
) : currentView === "viewer" ? (
|
) : currentView === "viewer" ? (
|
||||||
<Viewer
|
<Viewer
|
||||||
pdfFile={currentFileWithUrl}
|
|
||||||
setPdfFile={(fileObj) => {
|
|
||||||
if (fileObj) {
|
|
||||||
setCurrentActiveFile(fileObj.file);
|
|
||||||
} else {
|
|
||||||
setActiveFiles([]);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
sidebarsVisible={sidebarsVisible}
|
sidebarsVisible={sidebarsVisible}
|
||||||
setSidebarsVisible={setSidebarsVisible}
|
setSidebarsVisible={setSidebarsVisible}
|
||||||
|
previewFile={previewFile}
|
||||||
|
{...(previewFile && {
|
||||||
|
onClose: () => {
|
||||||
|
setPreviewFile(null); // Clear preview file
|
||||||
|
const previousMode = sessionStorage.getItem('previousMode');
|
||||||
|
if (previousMode === 'split') {
|
||||||
|
selectTool('split');
|
||||||
|
setCurrentView('split');
|
||||||
|
setLeftPanelView('toolContent');
|
||||||
|
sessionStorage.removeItem('previousMode');
|
||||||
|
} else {
|
||||||
|
setCurrentView('fileEditor');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
) : currentView === "pageEditor" ? (
|
) : currentView === "pageEditor" ? (
|
||||||
<>
|
<>
|
||||||
<PageEditor
|
<PageEditor
|
||||||
activeFiles={activeFiles}
|
|
||||||
setActiveFiles={setActiveFiles}
|
|
||||||
downloadUrl={downloadUrl}
|
|
||||||
setDownloadUrl={setDownloadUrl}
|
|
||||||
sharedFiles={storedFiles}
|
|
||||||
onFunctionsReady={setPageEditorFunctions}
|
onFunctionsReady={setPageEditorFunctions}
|
||||||
/>
|
/>
|
||||||
{activeFiles[0] && pageEditorFunctions && (
|
{pageEditorFunctions && (
|
||||||
<PageEditorControls
|
<PageEditorControls
|
||||||
onClosePdf={pageEditorFunctions.closePdf}
|
onClosePdf={pageEditorFunctions.closePdf}
|
||||||
onUndo={pageEditorFunctions.handleUndo}
|
onUndo={pageEditorFunctions.handleUndo}
|
||||||
@@ -500,15 +248,37 @@ export default function HomePage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : currentView === "split" ? (
|
||||||
<FileManager
|
<FileEditor
|
||||||
files={storedFiles}
|
toolMode={true}
|
||||||
setFiles={setStoredFiles}
|
multiSelect={false}
|
||||||
setCurrentView={handleViewChange}
|
showUpload={true}
|
||||||
onOpenFileEditor={handleOpenFileEditor}
|
showBulkActions={true}
|
||||||
onOpenPageEditor={handleOpenPageEditor}
|
onFileSelect={(files) => {
|
||||||
onLoadFileToActive={addToActiveFiles}
|
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>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -185,8 +185,7 @@ export class PDFExportService {
|
|||||||
* Download multiple files as a ZIP
|
* Download multiple files as a ZIP
|
||||||
*/
|
*/
|
||||||
async downloadAsZip(blobs: Blob[], filenames: string[], zipFilename: string): Promise<void> {
|
async downloadAsZip(blobs: Blob[], filenames: string[], zipFilename: string): Promise<void> {
|
||||||
// For now, download files individually
|
// For now, download files wherindividually
|
||||||
// TODO: Implement ZIP creation when needed
|
|
||||||
blobs.forEach((blob, index) => {
|
blobs.forEach((blob, index) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.downloadFile(blob, filenames[index]);
|
this.downloadFile(blob, filenames[index]);
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -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 React, { useState, useEffect } from "react";
|
||||||
import { Paper, Button, Checkbox, Stack, Text, Group, Loader, Alert } from "@mantine/core";
|
import { Paper, Button, Checkbox, Stack, Text, Group, Loader, Alert } from "@mantine/core";
|
||||||
import { useSearchParams } from "react-router-dom";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FileWithUrl } from "../types/file";
|
import { FileWithUrl } from "../types/file";
|
||||||
import { fileStorage } from "../services/fileStorage";
|
import { fileStorage } from "../services/fileStorage";
|
||||||
|
|||||||
+128
-273
@@ -1,308 +1,163 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useEffect, useMemo } from "react";
|
||||||
import axios from "axios";
|
import { Button, Stack, Text } from "@mantine/core";
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Select,
|
|
||||||
TextInput,
|
|
||||||
Checkbox,
|
|
||||||
Notification,
|
|
||||||
Stack,
|
|
||||||
Loader,
|
|
||||||
Alert,
|
|
||||||
Text,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { useSearchParams } from "react-router-dom";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import DownloadIcon from "@mui/icons-material/Download";
|
import DownloadIcon from "@mui/icons-material/Download";
|
||||||
import { FileWithUrl } from "../types/file";
|
|
||||||
import { fileStorage } from "../services/fileStorage";
|
|
||||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
||||||
|
import { useFileContext } from "../contexts/FileContext";
|
||||||
|
|
||||||
export interface SplitPdfPanelProps {
|
import ToolStep, { ToolStepContainer } from "../components/tools/shared/ToolStep";
|
||||||
file: { file: FileWithUrl; url: string } | null;
|
import OperationButton from "../components/tools/shared/OperationButton";
|
||||||
downloadUrl?: string | null;
|
import ErrorNotification from "../components/tools/shared/ErrorNotification";
|
||||||
setDownloadUrl: (url: string | null) => void;
|
import FileStatusIndicator from "../components/tools/shared/FileStatusIndicator";
|
||||||
params: {
|
import ResultsPreview from "../components/tools/shared/ResultsPreview";
|
||||||
mode: string;
|
|
||||||
pages: string;
|
import SplitSettings from "../components/tools/split/SplitSettings";
|
||||||
hDiv: string;
|
|
||||||
vDiv: string;
|
import { useSplitParameters } from "../hooks/tools/split/useSplitParameters";
|
||||||
merge: boolean;
|
import { useSplitOperation } from "../hooks/tools/split/useSplitOperation";
|
||||||
splitType: string;
|
|
||||||
splitValue: string;
|
interface SplitProps {
|
||||||
bookmarkLevel: string;
|
selectedFiles?: File[];
|
||||||
includeMetadata: boolean;
|
onPreviewFile?: (file: File | null) => void;
|
||||||
allowDuplicates: boolean;
|
|
||||||
};
|
|
||||||
updateParams: (newParams: Partial<SplitPdfPanelProps["params"]>) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
|
const Split = ({ selectedFiles = [], onPreviewFile }: SplitProps) => {
|
||||||
file,
|
|
||||||
downloadUrl,
|
|
||||||
setDownloadUrl,
|
|
||||||
params,
|
|
||||||
updateParams,
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [searchParams] = useSearchParams();
|
const { setCurrentMode } = useFileContext();
|
||||||
|
|
||||||
const [status, setStatus] = useState("");
|
const splitParams = useSplitParameters();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const splitOperation = useSplitOperation();
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Map mode to endpoint name for checking
|
// Endpoint validation
|
||||||
const getEndpointName = (mode: string) => {
|
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(
|
||||||
switch (mode) {
|
splitParams.getEndpointName()
|
||||||
case "byPages":
|
);
|
||||||
return "split-pages";
|
|
||||||
case "bySections":
|
useEffect(() => {
|
||||||
return "split-pdf-by-sections";
|
splitOperation.resetResults();
|
||||||
case "bySizeOrCount":
|
onPreviewFile?.(null);
|
||||||
return "split-by-size-or-count";
|
}, [splitParams.mode, splitParams.parameters, selectedFiles]);
|
||||||
case "byChapters":
|
|
||||||
return "split-pdf-by-chapters";
|
const handleSplit = async () => {
|
||||||
default:
|
await splitOperation.executeOperation(
|
||||||
return "split-pages";
|
splitParams.mode,
|
||||||
}
|
splitParams.parameters,
|
||||||
|
selectedFiles
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleThumbnailClick = (file: File) => {
|
||||||
const {
|
onPreviewFile?.(file);
|
||||||
mode,
|
sessionStorage.setItem('previousMode', 'split');
|
||||||
pages,
|
setCurrentMode('viewer');
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (endpointLoading) {
|
const handleSettingsReset = () => {
|
||||||
return (
|
splitOperation.resetResults();
|
||||||
<Stack align="center" justify="center" h={200}>
|
onPreviewFile?.(null);
|
||||||
<Loader size="md" />
|
setCurrentMode('split');
|
||||||
<Text size="sm" c="dimmed">{t("loading", "Loading...")}</Text>
|
};
|
||||||
</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]
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="app-surface p-app-md rounded-app-md">
|
<ToolStepContainer>
|
||||||
<Stack gap="sm" mb={16}>
|
<Stack gap="md" h="100%" p="md" style={{ overflow: 'auto' }}>
|
||||||
<Select
|
{/* Files Step */}
|
||||||
label={t("split-by-size-or-count.type.label", "Split Mode")}
|
<ToolStep
|
||||||
value={mode}
|
title="Files"
|
||||||
onChange={(v) => v && updateParams({ mode: v })}
|
isVisible={true}
|
||||||
data={[
|
isCollapsed={filesCollapsed}
|
||||||
{ value: "byPages", label: t("split.header", "Split by Pages") + " (e.g. 1,3,5-10)" },
|
isCompleted={filesCollapsed}
|
||||||
{ value: "bySections", label: t("split-by-sections.title", "Split by Grid Sections") },
|
completedMessage={hasFiles ? `Selected: ${selectedFiles[0]?.name}` : undefined}
|
||||||
{ value: "bySizeOrCount", label: t("split-by-size-or-count.title", "Split by Size or Count") },
|
>
|
||||||
{ value: "byChapters", label: t("splitByChapters.title", "Split by Chapters") },
|
<FileStatusIndicator
|
||||||
]}
|
selectedFiles={selectedFiles}
|
||||||
|
placeholder="Select a PDF file in the main view to get started"
|
||||||
|
/>
|
||||||
|
</ToolStep>
|
||||||
|
|
||||||
|
{/* 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 === "byPages" && (
|
{splitParams.mode && (
|
||||||
<TextInput
|
<OperationButton
|
||||||
label={t("split.splitPages", "Pages")}
|
onClick={handleSplit}
|
||||||
placeholder={t("pageSelectionPrompt", "e.g. 1,3,5-10")}
|
isLoading={splitOperation.isLoading}
|
||||||
value={pages}
|
disabled={!splitParams.validateParameters() || !hasFiles || !endpointEnabled}
|
||||||
onChange={(e) => updateParams({ pages: e.target.value })}
|
loadingText={t("loading")}
|
||||||
|
submitText={t("split.submit", "Split PDF")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{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")}
|
|
||||||
/>
|
|
||||||
<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>
|
||||||
|
|
||||||
|
{/* Results Step */}
|
||||||
|
<ToolStep
|
||||||
|
title="Results"
|
||||||
|
isVisible={hasResults}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
{splitOperation.status && (
|
||||||
|
<Text size="sm" c="dimmed">{splitOperation.status}</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{mode === "bySizeOrCount" && (
|
<ErrorNotification
|
||||||
<Stack gap="sm">
|
error={splitOperation.errorMessage}
|
||||||
<Select
|
onClose={splitOperation.clearError}
|
||||||
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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{mode === "byChapters" && (
|
{splitOperation.downloadUrl && (
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button type="submit" loading={isLoading} fullWidth>
|
|
||||||
{isLoading ? t("loading") : t("split.submit", "Split PDF")}
|
|
||||||
</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
|
<Button
|
||||||
component="a"
|
component="a"
|
||||||
href={downloadUrl}
|
href={splitOperation.downloadUrl}
|
||||||
download="split_output.zip"
|
download="split_output.zip"
|
||||||
leftSection={<DownloadIcon />}
|
leftSection={<DownloadIcon />}
|
||||||
color="green"
|
color="green"
|
||||||
fullWidth
|
fullWidth
|
||||||
|
mb="md"
|
||||||
>
|
>
|
||||||
{t("downloadPdf", "Download Split PDF")}
|
{t("download", "Download")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SplitPdfPanel;
|
<ResultsPreview
|
||||||
|
files={previewResults}
|
||||||
|
onFileClick={handleThumbnailClick}
|
||||||
|
isGeneratingThumbnails={splitOperation.isGeneratingThumbnails}
|
||||||
|
title="Split Results"
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</ToolStep>
|
||||||
|
</Stack>
|
||||||
|
</ToolStepContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Split;
|
||||||
|
|||||||
@@ -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 {
|
export interface PDFPage {
|
||||||
id: string;
|
id: string;
|
||||||
pageNumber: number;
|
pageNumber: number;
|
||||||
thumbnail: string;
|
thumbnail: string | null;
|
||||||
rotation: number;
|
rotation: number;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
splitBefore?: boolean;
|
splitBefore?: boolean;
|
||||||
@@ -16,9 +16,20 @@ export interface PDFDocument {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PageOperation {
|
export interface PageOperation {
|
||||||
type: 'rotate' | 'delete' | 'move' | 'split' | 'insert';
|
id: string;
|
||||||
|
type: 'rotate' | 'delete' | 'move' | 'split' | 'insert' | 'reorder';
|
||||||
pageIds: string[];
|
pageIds: string[];
|
||||||
|
timestamp: number;
|
||||||
|
status: 'pending' | 'applied' | 'failed';
|
||||||
data?: any;
|
data?: any;
|
||||||
|
metadata?: {
|
||||||
|
rotation?: number;
|
||||||
|
fromPosition?: number;
|
||||||
|
toPosition?: number;
|
||||||
|
splitType?: string;
|
||||||
|
insertAfterPage?: number;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UndoRedoState {
|
export interface UndoRedoState {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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";
|
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
|
* Generate thumbnail for a PDF file during upload
|
||||||
* Returns base64 data URL or undefined if generation fails
|
* Returns base64 data URL or undefined if generation fails
|
||||||
@@ -14,6 +28,10 @@ export async function generateThumbnailForFile(file: File): Promise<string | und
|
|||||||
try {
|
try {
|
||||||
console.log('Generating thumbnail for', file.name);
|
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
|
// Only read first 2MB for thumbnail generation to save memory
|
||||||
const chunkSize = 2 * 1024 * 1024; // 2MB
|
const chunkSize = 2 * 1024 * 1024; // 2MB
|
||||||
const chunk = file.slice(0, Math.min(chunkSize, file.size));
|
const chunk = file.slice(0, Math.min(chunkSize, file.size));
|
||||||
@@ -26,7 +44,7 @@ export async function generateThumbnailForFile(file: File): Promise<string | und
|
|||||||
}).promise;
|
}).promise;
|
||||||
|
|
||||||
const page = await pdf.getPage(1);
|
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");
|
const canvas = document.createElement("canvas");
|
||||||
canvas.width = viewport.width;
|
canvas.width = viewport.width;
|
||||||
canvas.height = viewport.height;
|
canvas.height = viewport.height;
|
||||||
@@ -45,7 +63,45 @@ export async function generateThumbnailForFile(file: File): Promise<string | und
|
|||||||
|
|
||||||
return thumbnail;
|
return thumbnail;
|
||||||
} catch (error) {
|
} catch (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);
|
console.warn('Failed to generate thumbnail for', file.name, error);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
console.warn('Unknown error generating thumbnail for', file.name, error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user