mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
Feature/v2/file handling improvements (#4222)
# Description of Changes A new universal file context rather than the splintered ones for the main views, tools and manager we had before (manager still has its own but its better integreated with the core context) File context has been split it into a handful of different files managing various file related issues separately to reduce the monolith - FileReducer.ts - State management fileActions.ts - File operations fileSelectors.ts - Data access patterns lifecycle.ts - Resource cleanup and memory management fileHooks.ts - React hooks interface contexts.ts - Context providers Improved thumbnail generation Improved indexxedb handling Stopped handling files as blobs were not necessary to improve performance A new library handling drag and drop https://github.com/atlassian/pragmatic-drag-and-drop (Out of scope yes but I broke the old one with the new filecontext and it needed doing so it was a might as well) A new library handling virtualisation on page editor @tanstack/react-virtual, as above. Quickly ripped out the last remnants of the old URL params stuff and replaced with the beginnings of what will later become the new URL navigation system (for now it just restores the tool name in url behavior) Fixed selected file not regestered when opening a tool Fixed png thumbnails Closes #(issue_number) --- ## 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: Reece Browne <[email protected]>
This commit is contained in:
co-authored by
Reece Browne
parent
a33e51351b
commit
949ffa01ad
@@ -11,8 +11,11 @@
|
|||||||
"Bash(npm test:*)",
|
"Bash(npm test:*)",
|
||||||
"Bash(ls:*)",
|
"Bash(ls:*)",
|
||||||
"Bash(npx tsc:*)",
|
"Bash(npx tsc:*)",
|
||||||
|
"Bash(node:*)",
|
||||||
|
"Bash(npm run dev:*)",
|
||||||
"Bash(sed:*)"
|
"Bash(sed:*)"
|
||||||
],
|
],
|
||||||
"deny": []
|
"deny": [],
|
||||||
|
"defaultMode": "acceptEdits"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+52
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE",
|
"license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@atlaskit/pragmatic-drag-and-drop": "^1.7.4",
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.0",
|
"@emotion/styled": "^11.14.0",
|
||||||
"@mantine/core": "^8.0.1",
|
"@mantine/core": "^8.0.1",
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
"@mui/icons-material": "^7.1.0",
|
"@mui/icons-material": "^7.1.0",
|
||||||
"@mui/material": "^7.1.0",
|
"@mui/material": "^7.1.0",
|
||||||
"@tailwindcss/postcss": "^4.1.8",
|
"@tailwindcss/postcss": "^4.1.8",
|
||||||
|
"@tanstack/react-virtual": "^3.13.12",
|
||||||
"@testing-library/dom": "^10.4.0",
|
"@testing-library/dom": "^10.4.0",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
@@ -119,6 +121,17 @@
|
|||||||
"is-potential-custom-element-name": "^1.0.1"
|
"is-potential-custom-element-name": "^1.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@atlaskit/pragmatic-drag-and-drop": {
|
||||||
|
"version": "1.7.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop/-/pragmatic-drag-and-drop-1.7.4.tgz",
|
||||||
|
"integrity": "sha512-lZHnO9BJdHPKnwB0uvVUCyDnIhL+WAHzXQ2EXX0qacogOsnvIUiCgY0BLKhBqTCWln3/f/Ox5jU54MKO6ayh9A==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.0.0",
|
||||||
|
"bind-event-listener": "^3.0.0",
|
||||||
|
"raf-schd": "^4.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.27.1",
|
"version": "7.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||||
@@ -2226,6 +2239,33 @@
|
|||||||
"tailwindcss": "4.1.8"
|
"tailwindcss": "4.1.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/react-virtual": {
|
||||||
|
"version": "3.13.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz",
|
||||||
|
"integrity": "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/virtual-core": "3.13.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/virtual-core": {
|
||||||
|
"version": "3.13.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz",
|
||||||
|
"integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@testing-library/dom": {
|
"node_modules/@testing-library/dom": {
|
||||||
"version": "10.4.0",
|
"version": "10.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
|
||||||
@@ -2876,6 +2916,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bind-event-listener": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bind-event-listener/-/bind-event-listener-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-PJvH288AWQhKs2v9zyfYdPzlPqf5bXbGMmhmUIY9x4dAUGIWgomO771oBQNwJnMQSnUIXhKu6sgzpBRXTlvb8Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.11",
|
"version": "1.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||||
@@ -6261,6 +6307,12 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/raf-schd": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "19.1.0",
|
"version": "19.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE",
|
"license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE",
|
||||||
"proxy": "http://localhost:8080",
|
"proxy": "http://localhost:8080",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@atlaskit/pragmatic-drag-and-drop": "^1.7.4",
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.0",
|
"@emotion/styled": "^11.14.0",
|
||||||
"@mantine/core": "^8.0.1",
|
"@mantine/core": "^8.0.1",
|
||||||
@@ -13,6 +14,7 @@
|
|||||||
"@mui/icons-material": "^7.1.0",
|
"@mui/icons-material": "^7.1.0",
|
||||||
"@mui/material": "^7.1.0",
|
"@mui/material": "^7.1.0",
|
||||||
"@tailwindcss/postcss": "^4.1.8",
|
"@tailwindcss/postcss": "^4.1.8",
|
||||||
|
"@tanstack/react-virtual": "^3.13.12",
|
||||||
"@testing-library/dom": "^10.4.0",
|
"@testing-library/dom": "^10.4.0",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
|
|||||||
@@ -1967,7 +1967,9 @@
|
|||||||
"uploadFiles": "Upload Files",
|
"uploadFiles": "Upload Files",
|
||||||
"noFilesInStorage": "No files available in storage. Upload some files first.",
|
"noFilesInStorage": "No files available in storage. Upload some files first.",
|
||||||
"selectFromStorage": "Select from Storage",
|
"selectFromStorage": "Select from Storage",
|
||||||
"backToTools": "Back to Tools"
|
"backToTools": "Back to Tools",
|
||||||
|
"addFiles": "Add Files",
|
||||||
|
"dragFilesInOrClick": "Drag files in or click \"Add Files\" to browse"
|
||||||
},
|
},
|
||||||
"fileManager": {
|
"fileManager": {
|
||||||
"title": "Upload PDF Files",
|
"title": "Upload PDF Files",
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
// 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,6 +1,7 @@
|
|||||||
import React, { Suspense } from 'react';
|
import React, { Suspense } from 'react';
|
||||||
import { RainbowThemeProvider } from './components/shared/RainbowThemeProvider';
|
import { RainbowThemeProvider } from './components/shared/RainbowThemeProvider';
|
||||||
import { FileContextProvider } from './contexts/FileContext';
|
import { FileContextProvider } from './contexts/FileContext';
|
||||||
|
import { NavigationProvider } from './contexts/NavigationContext';
|
||||||
import { FilesModalProvider } from './contexts/FilesModalContext';
|
import { FilesModalProvider } from './contexts/FilesModalContext';
|
||||||
import HomePage from './pages/HomePage';
|
import HomePage from './pages/HomePage';
|
||||||
|
|
||||||
@@ -27,9 +28,11 @@ export default function App() {
|
|||||||
<Suspense fallback={<LoadingFallback />}>
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
<RainbowThemeProvider>
|
<RainbowThemeProvider>
|
||||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||||
|
<NavigationProvider>
|
||||||
<FilesModalProvider>
|
<FilesModalProvider>
|
||||||
<HomePage />
|
<HomePage />
|
||||||
</FilesModalProvider>
|
</FilesModalProvider>
|
||||||
|
</NavigationProvider>
|
||||||
</FileContextProvider>
|
</FileContextProvider>
|
||||||
</RainbowThemeProvider>
|
</RainbowThemeProvider>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -48,7 +48,11 @@ export class RotatePagesCommand extends PageCommand {
|
|||||||
return page;
|
return page;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.setPdfDocument({ ...this.pdfDocument, pages: updatedPages });
|
this.setPdfDocument({
|
||||||
|
...this.pdfDocument,
|
||||||
|
pages: updatedPages,
|
||||||
|
totalPages: updatedPages.length
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get description(): string {
|
get description(): string {
|
||||||
@@ -148,7 +152,11 @@ export class MovePagesCommand extends PageCommand {
|
|||||||
pageNumber: index + 1
|
pageNumber: index + 1
|
||||||
}));
|
}));
|
||||||
|
|
||||||
this.setPdfDocument({ ...this.pdfDocument, pages: newPages });
|
this.setPdfDocument({
|
||||||
|
...this.pdfDocument,
|
||||||
|
pages: newPages,
|
||||||
|
totalPages: newPages.length
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get description(): string {
|
get description(): string {
|
||||||
@@ -185,7 +193,11 @@ export class ReorderPageCommand extends PageCommand {
|
|||||||
pageNumber: index + 1
|
pageNumber: index + 1
|
||||||
}));
|
}));
|
||||||
|
|
||||||
this.setPdfDocument({ ...this.pdfDocument, pages: updatedPages });
|
this.setPdfDocument({
|
||||||
|
...this.pdfDocument,
|
||||||
|
pages: updatedPages,
|
||||||
|
totalPages: updatedPages.length
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get description(): string {
|
get description(): string {
|
||||||
@@ -224,7 +236,11 @@ export class ToggleSplitCommand extends PageCommand {
|
|||||||
return page;
|
return page;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.setPdfDocument({ ...this.pdfDocument, pages: updatedPages });
|
this.setPdfDocument({
|
||||||
|
...this.pdfDocument,
|
||||||
|
pages: updatedPages,
|
||||||
|
totalPages: updatedPages.length
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
undo(): void {
|
undo(): void {
|
||||||
@@ -236,7 +252,11 @@ export class ToggleSplitCommand extends PageCommand {
|
|||||||
return page;
|
return page;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.setPdfDocument({ ...this.pdfDocument, pages: updatedPages });
|
this.setPdfDocument({
|
||||||
|
...this.pdfDocument,
|
||||||
|
pages: updatedPages,
|
||||||
|
totalPages: updatedPages.length
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get description(): string {
|
get description(): string {
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Card, Stack, Text, Group, Badge, Button, Box, Image, ThemeIcon } from "@mantine/core";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
|
||||||
import StorageIcon from "@mui/icons-material/Storage";
|
|
||||||
|
|
||||||
import { FileWithUrl } from "../types/file";
|
|
||||||
import { getFileSize, getFileDate } from "../utils/fileUtils";
|
|
||||||
import { useIndexedDBThumbnail } from "../hooks/useIndexedDBThumbnail";
|
|
||||||
|
|
||||||
interface FileCardProps {
|
|
||||||
file: FileWithUrl;
|
|
||||||
onRemove: () => void;
|
|
||||||
onDoubleClick?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FileCard: React.FC<FileCardProps> = ({ file, onRemove, onDoubleClick }) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { thumbnail: thumb, isGenerating } = useIndexedDBThumbnail(file);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
shadow="xs"
|
|
||||||
radius="md"
|
|
||||||
withBorder
|
|
||||||
p="xs"
|
|
||||||
style={{
|
|
||||||
width: 225,
|
|
||||||
minWidth: 180,
|
|
||||||
maxWidth: 260,
|
|
||||||
cursor: onDoubleClick ? "pointer" : undefined
|
|
||||||
}}
|
|
||||||
onDoubleClick={onDoubleClick}
|
|
||||||
>
|
|
||||||
<Stack gap={6} align="center">
|
|
||||||
<Box
|
|
||||||
style={{
|
|
||||||
border: "2px solid #e0e0e0",
|
|
||||||
borderRadius: 8,
|
|
||||||
width: 90,
|
|
||||||
height: 120,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
margin: "0 auto",
|
|
||||||
background: "#fafbfc",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{thumb ? (
|
|
||||||
<Image
|
|
||||||
src={thumb}
|
|
||||||
alt="PDF thumbnail"
|
|
||||||
height={110}
|
|
||||||
width={80}
|
|
||||||
fit="contain"
|
|
||||||
radius="sm"
|
|
||||||
/>
|
|
||||||
) : isGenerating ? (
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center'
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
border: '2px solid #ddd',
|
|
||||||
borderTop: '2px solid #666',
|
|
||||||
borderRadius: '50%',
|
|
||||||
animation: 'spin 1s linear infinite',
|
|
||||||
marginBottom: 8
|
|
||||||
}} />
|
|
||||||
<Text size="xs" c="dimmed">Generating...</Text>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center'
|
|
||||||
}}>
|
|
||||||
<ThemeIcon
|
|
||||||
variant="light"
|
|
||||||
color={file.size > 100 * 1024 * 1024 ? "orange" : "red"}
|
|
||||||
size={60}
|
|
||||||
radius="sm"
|
|
||||||
style={{ display: "flex", alignItems: "center", justifyContent: "center" }}
|
|
||||||
>
|
|
||||||
<PictureAsPdfIcon style={{ fontSize: 40 }} />
|
|
||||||
</ThemeIcon>
|
|
||||||
{file.size > 100 * 1024 * 1024 && (
|
|
||||||
<Text size="xs" c="dimmed" mt={4}>Large File</Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Text fw={500} size="sm" lineClamp={1} ta="center">
|
|
||||||
{file.name}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Group gap="xs" justify="center">
|
|
||||||
<Badge color="gray" variant="light" size="sm">
|
|
||||||
{getFileSize(file)}
|
|
||||||
</Badge>
|
|
||||||
<Badge color="blue" variant="light" size="sm">
|
|
||||||
{getFileDate(file)}
|
|
||||||
</Badge>
|
|
||||||
{file.storedInIndexedDB && (
|
|
||||||
<Badge
|
|
||||||
color="green"
|
|
||||||
variant="light"
|
|
||||||
size="sm"
|
|
||||||
leftSection={<StorageIcon style={{ fontSize: 12 }} />}
|
|
||||||
>
|
|
||||||
DB
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
color="red"
|
|
||||||
size="xs"
|
|
||||||
variant="light"
|
|
||||||
onClick={onRemove}
|
|
||||||
mt={4}
|
|
||||||
>
|
|
||||||
{t("delete", "Remove")}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FileCard;
|
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import React, { useState, useCallback, useEffect } from 'react';
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
import { Modal } from '@mantine/core';
|
import { Modal } from '@mantine/core';
|
||||||
import { Dropzone } from '@mantine/dropzone';
|
import { Dropzone } from '@mantine/dropzone';
|
||||||
import { FileWithUrl } from '../types/file';
|
import { FileMetadata } from '../types/file';
|
||||||
import { useFileManager } from '../hooks/useFileManager';
|
import { useFileManager } from '../hooks/useFileManager';
|
||||||
import { useFilesModalContext } from '../contexts/FilesModalContext';
|
import { useFilesModalContext } from '../contexts/FilesModalContext';
|
||||||
|
import { createFileId } from '../types/fileContext';
|
||||||
import { Tool } from '../types/tool';
|
import { Tool } from '../types/tool';
|
||||||
import MobileLayout from './fileManager/MobileLayout';
|
import MobileLayout from './fileManager/MobileLayout';
|
||||||
import DesktopLayout from './fileManager/DesktopLayout';
|
import DesktopLayout from './fileManager/DesktopLayout';
|
||||||
@@ -15,13 +16,19 @@ interface FileManagerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||||
const { isFilesModalOpen, closeFilesModal, onFilesSelect } = useFilesModalContext();
|
const { isFilesModalOpen, closeFilesModal, onFilesSelect, onStoredFilesSelect } = useFilesModalContext();
|
||||||
const [recentFiles, setRecentFiles] = useState<FileWithUrl[]>([]);
|
const [recentFiles, setRecentFiles] = useState<FileMetadata[]>([]);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [isMobile, setIsMobile] = useState(false);
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
|
||||||
const { loadRecentFiles, handleRemoveFile, storeFile, convertToFile } = useFileManager();
|
const { loadRecentFiles, handleRemoveFile, storeFile, convertToFile } = useFileManager();
|
||||||
|
|
||||||
|
// Wrapper for storeFile that generates UUID
|
||||||
|
const storeFileWithId = useCallback(async (file: File) => {
|
||||||
|
const fileId = createFileId(); // Generate UUID for storage
|
||||||
|
return await storeFile(file, fileId);
|
||||||
|
}, [storeFile]);
|
||||||
|
|
||||||
// File management handlers
|
// File management handlers
|
||||||
const isFileSupported = useCallback((fileName: string) => {
|
const isFileSupported = useCallback((fileName: string) => {
|
||||||
if (!selectedTool?.supportedFormats) return true;
|
if (!selectedTool?.supportedFormats) return true;
|
||||||
@@ -34,18 +41,21 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
|||||||
setRecentFiles(files);
|
setRecentFiles(files);
|
||||||
}, [loadRecentFiles]);
|
}, [loadRecentFiles]);
|
||||||
|
|
||||||
const handleFilesSelected = useCallback(async (files: FileWithUrl[]) => {
|
const handleFilesSelected = useCallback(async (files: FileMetadata[]) => {
|
||||||
try {
|
try {
|
||||||
const fileObjects = await Promise.all(
|
// Use stored files flow that preserves original IDs
|
||||||
files.map(async (fileWithUrl) => {
|
const filesWithMetadata = await Promise.all(
|
||||||
return await convertToFile(fileWithUrl);
|
files.map(async (metadata) => ({
|
||||||
})
|
file: await convertToFile(metadata),
|
||||||
|
originalId: metadata.id,
|
||||||
|
metadata
|
||||||
|
}))
|
||||||
);
|
);
|
||||||
onFilesSelect(fileObjects);
|
onStoredFilesSelect(filesWithMetadata);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to process selected files:', error);
|
console.error('Failed to process selected files:', error);
|
||||||
}
|
}
|
||||||
}, [convertToFile, onFilesSelect]);
|
}, [convertToFile, onStoredFilesSelect]);
|
||||||
|
|
||||||
const handleNewFileUpload = useCallback(async (files: File[]) => {
|
const handleNewFileUpload = useCallback(async (files: File[]) => {
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
@@ -82,14 +92,11 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
|||||||
// Cleanup any blob URLs when component unmounts
|
// Cleanup any blob URLs when component unmounts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
// Clean up blob URLs from recent files
|
// FileMetadata doesn't have blob URLs, so no cleanup needed
|
||||||
recentFiles.forEach(file => {
|
// Blob URLs are managed by FileContext and tool operations
|
||||||
if (file.url && file.url.startsWith('blob:')) {
|
console.log('FileManager unmounting - FileContext handles blob URL cleanup');
|
||||||
URL.revokeObjectURL(file.url);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
}, [recentFiles]);
|
}, []);
|
||||||
|
|
||||||
// Modal size constants for consistent scaling
|
// Modal size constants for consistent scaling
|
||||||
const modalHeight = '80vh';
|
const modalHeight = '80vh';
|
||||||
@@ -130,7 +137,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
|||||||
onDrop={handleNewFileUpload}
|
onDrop={handleNewFileUpload}
|
||||||
onDragEnter={() => setIsDragging(true)}
|
onDragEnter={() => setIsDragging(true)}
|
||||||
onDragLeave={() => setIsDragging(false)}
|
onDragLeave={() => setIsDragging(false)}
|
||||||
accept={["*/*"] as any}
|
accept={{}}
|
||||||
multiple={true}
|
multiple={true}
|
||||||
activateOnClick={false}
|
activateOnClick={false}
|
||||||
style={{
|
style={{
|
||||||
@@ -147,12 +154,12 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
|||||||
<FileManagerProvider
|
<FileManagerProvider
|
||||||
recentFiles={recentFiles}
|
recentFiles={recentFiles}
|
||||||
onFilesSelected={handleFilesSelected}
|
onFilesSelected={handleFilesSelected}
|
||||||
|
onNewFilesSelect={handleNewFileUpload}
|
||||||
onClose={closeFilesModal}
|
onClose={closeFilesModal}
|
||||||
isFileSupported={isFileSupported}
|
isFileSupported={isFileSupported}
|
||||||
isOpen={isFilesModalOpen}
|
isOpen={isFilesModalOpen}
|
||||||
onFileRemove={handleRemoveFileByIndex}
|
onFileRemove={handleRemoveFileByIndex}
|
||||||
modalHeight={modalHeight}
|
modalHeight={modalHeight}
|
||||||
storeFile={storeFile}
|
|
||||||
refreshRecentFiles={refreshRecentFiles}
|
refreshRecentFiles={refreshRecentFiles}
|
||||||
>
|
>
|
||||||
{isMobile ? <MobileLayout /> : <DesktopLayout />}
|
{isMobile ? <MobileLayout /> : <DesktopLayout />}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Button, Text, Center, Box, Notification, TextInput, LoadingOverlay, Modal, Alert, Container,
|
Button, Text, Center, Box, Notification, TextInput, LoadingOverlay, Modal, Alert, Container,
|
||||||
Stack, Group
|
Stack, Group
|
||||||
@@ -6,8 +6,8 @@ import {
|
|||||||
import { Dropzone } from '@mantine/dropzone';
|
import { Dropzone } from '@mantine/dropzone';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||||
import { useFileContext } from '../../contexts/FileContext';
|
import { useFileSelection, useFileState, useFileManagement, useFileActions } from '../../contexts/FileContext';
|
||||||
import { useFileSelection } from '../../contexts/FileSelectionContext';
|
import { useNavigationActions } from '../../contexts/NavigationContext';
|
||||||
import { FileOperation } from '../../types/fileContext';
|
import { FileOperation } from '../../types/fileContext';
|
||||||
import { fileStorage } from '../../services/fileStorage';
|
import { fileStorage } from '../../services/fileStorage';
|
||||||
import { generateThumbnailForFile } from '../../utils/thumbnailUtils';
|
import { generateThumbnailForFile } from '../../utils/thumbnailUtils';
|
||||||
@@ -15,19 +15,9 @@ import { zipFileService } from '../../services/zipFileService';
|
|||||||
import { detectFileExtension } from '../../utils/fileUtils';
|
import { detectFileExtension } from '../../utils/fileUtils';
|
||||||
import styles from '../pageEditor/PageEditor.module.css';
|
import styles from '../pageEditor/PageEditor.module.css';
|
||||||
import FileThumbnail from '../pageEditor/FileThumbnail';
|
import FileThumbnail from '../pageEditor/FileThumbnail';
|
||||||
import DragDropGrid from '../pageEditor/DragDropGrid';
|
|
||||||
import FilePickerModal from '../shared/FilePickerModal';
|
import FilePickerModal from '../shared/FilePickerModal';
|
||||||
import SkeletonLoader from '../shared/SkeletonLoader';
|
import SkeletonLoader from '../shared/SkeletonLoader';
|
||||||
|
|
||||||
interface FileItem {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
pageCount: number;
|
|
||||||
thumbnail: string;
|
|
||||||
size: number;
|
|
||||||
file: File;
|
|
||||||
splitBefore?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FileEditorProps {
|
interface FileEditorProps {
|
||||||
onOpenPageEditor?: (file: File) => void;
|
onOpenPageEditor?: (file: File) => void;
|
||||||
@@ -54,33 +44,25 @@ const FileEditor = ({
|
|||||||
return extension ? supportedExtensions.includes(extension) : false;
|
return extension ? supportedExtensions.includes(extension) : false;
|
||||||
}, [supportedExtensions]);
|
}, [supportedExtensions]);
|
||||||
|
|
||||||
// Get file context
|
// Use optimized FileContext hooks
|
||||||
const fileContext = useFileContext();
|
const { state, selectors } = useFileState();
|
||||||
const {
|
const { addFiles, removeFiles, reorderFiles } = useFileManagement();
|
||||||
activeFiles,
|
|
||||||
processedFiles,
|
// Extract needed values from state (memoized to prevent infinite loops)
|
||||||
selectedFileIds,
|
const activeFiles = useMemo(() => selectors.getFiles(), [selectors.getFilesSignature()]);
|
||||||
setSelectedFiles: setContextSelectedFiles,
|
const activeFileRecords = useMemo(() => selectors.getFileRecords(), [selectors.getFilesSignature()]);
|
||||||
isProcessing,
|
const selectedFileIds = state.ui.selectedFileIds;
|
||||||
addFiles,
|
const isProcessing = state.ui.isProcessing;
|
||||||
removeFiles,
|
|
||||||
setCurrentView,
|
// Get the real context actions
|
||||||
recordOperation,
|
const { actions } = useFileActions();
|
||||||
markOperationApplied
|
const { actions: navActions } = useNavigationActions();
|
||||||
} = fileContext;
|
|
||||||
|
|
||||||
// Get file selection context
|
// Get file selection context
|
||||||
const {
|
const { setSelectedFiles } = useFileSelection();
|
||||||
selectedFiles: toolSelectedFiles,
|
|
||||||
setSelectedFiles: setToolSelectedFiles,
|
|
||||||
maxFiles,
|
|
||||||
isToolMode
|
|
||||||
} = useFileSelection();
|
|
||||||
|
|
||||||
const [files, setFiles] = useState<FileItem[]>([]);
|
|
||||||
const [status, setStatus] = useState<string | null>(null);
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [localLoading, setLocalLoading] = useState(false);
|
|
||||||
const [selectionMode, setSelectionMode] = useState(toolMode);
|
const [selectionMode, setSelectionMode] = useState(toolMode);
|
||||||
|
|
||||||
// Enable selection mode automatically in tool mode
|
// Enable selection mode automatically in tool mode
|
||||||
@@ -89,13 +71,7 @@ const FileEditor = ({
|
|||||||
setSelectionMode(true);
|
setSelectionMode(true);
|
||||||
}
|
}
|
||||||
}, [toolMode]);
|
}, [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 [showFilePickerModal, setShowFilePickerModal] = useState(false);
|
||||||
const [conversionProgress, setConversionProgress] = useState(0);
|
|
||||||
const [zipExtractionProgress, setZipExtractionProgress] = useState<{
|
const [zipExtractionProgress, setZipExtractionProgress] = useState<{
|
||||||
isExtracting: boolean;
|
isExtracting: boolean;
|
||||||
currentFile: string;
|
currentFile: string;
|
||||||
@@ -109,115 +85,30 @@ const FileEditor = ({
|
|||||||
extractedCount: 0,
|
extractedCount: 0,
|
||||||
totalFiles: 0
|
totalFiles: 0
|
||||||
});
|
});
|
||||||
const fileRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
|
||||||
const lastActiveFilesRef = useRef<string[]>([]);
|
|
||||||
const lastProcessedFilesRef = useRef<number>(0);
|
|
||||||
|
|
||||||
// Get selected file IDs from context (defensive programming)
|
// Get selected file IDs from context (defensive programming)
|
||||||
const contextSelectedIds = Array.isArray(selectedFileIds) ? selectedFileIds : [];
|
const contextSelectedIds = Array.isArray(selectedFileIds) ? selectedFileIds : [];
|
||||||
|
|
||||||
// Map context selections to local file IDs for UI display
|
// Create refs for frequently changing values to stabilize callbacks
|
||||||
const localSelectedIds = files
|
const contextSelectedIdsRef = useRef<string[]>([]);
|
||||||
.filter(file => {
|
contextSelectedIdsRef.current = contextSelectedIds;
|
||||||
const fileId = (file.file as any).id || file.name;
|
|
||||||
return contextSelectedIds.includes(fileId);
|
|
||||||
})
|
|
||||||
.map(file => file.id);
|
|
||||||
|
|
||||||
// Convert shared files to FileEditor format
|
// Use activeFileRecords directly - no conversion needed
|
||||||
const convertToFileItem = useCallback(async (sharedFile: any): Promise<FileItem> => {
|
const localSelectedIds = contextSelectedIds;
|
||||||
// Generate thumbnail if not already available
|
|
||||||
const thumbnail = sharedFile.thumbnail || await generateThumbnailForFile(sharedFile.file || sharedFile);
|
// Helper to convert FileRecord to FileThumbnail format
|
||||||
|
const recordToFileItem = useCallback((record: any) => {
|
||||||
|
const file = selectors.getFile(record.id);
|
||||||
|
if (!file) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: sharedFile.id || `file-${Date.now()}-${Math.random()}`,
|
id: record.id,
|
||||||
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,
|
name: file.name,
|
||||||
pageCount: processedFile?.totalPages || Math.floor(Math.random() * 20) + 1,
|
pageCount: record.processedFile?.totalPages || 1,
|
||||||
thumbnail: thumbnail || '',
|
thumbnail: record.thumbnailUrl || '',
|
||||||
size: file.size,
|
size: file.size,
|
||||||
file,
|
file: file
|
||||||
};
|
};
|
||||||
|
}, [selectors]);
|
||||||
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
|
// Process uploaded files using context
|
||||||
@@ -290,9 +181,6 @@ const FileEditor = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
recordOperation(file.name, operation);
|
|
||||||
markOperationApplied(file.name, operationId);
|
|
||||||
|
|
||||||
if (extractionResult.errors.length > 0) {
|
if (extractionResult.errors.length > 0) {
|
||||||
errors.push(...extractionResult.errors);
|
errors.push(...extractionResult.errors);
|
||||||
}
|
}
|
||||||
@@ -301,7 +189,6 @@ const FileEditor = ({
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// ZIP doesn't contain PDFs or is invalid - treat as regular file
|
// ZIP doesn't contain PDFs or is invalid - treat as regular file
|
||||||
console.log(`Adding ZIP file as regular file: ${file.name} (no PDFs found)`);
|
|
||||||
allExtractedFiles.push(file);
|
allExtractedFiles.push(file);
|
||||||
}
|
}
|
||||||
} catch (zipError) {
|
} catch (zipError) {
|
||||||
@@ -315,7 +202,6 @@ const FileEditor = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log(`Adding none PDF file: ${file.name} (${file.type})`);
|
|
||||||
allExtractedFiles.push(file);
|
allExtractedFiles.push(file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -344,9 +230,6 @@ const FileEditor = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
recordOperation(file.name, operation);
|
|
||||||
markOperationApplied(file.name, operationId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add files to context (they will be processed automatically)
|
// Add files to context (they will be processed automatically)
|
||||||
@@ -367,220 +250,137 @@ const FileEditor = ({
|
|||||||
totalFiles: 0
|
totalFiles: 0
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [addFiles, recordOperation, markOperationApplied]);
|
}, [addFiles]);
|
||||||
|
|
||||||
const selectAll = useCallback(() => {
|
const selectAll = useCallback(() => {
|
||||||
setContextSelectedFiles(files.map(f => (f.file as any).id || f.name));
|
setSelectedFiles(activeFileRecords.map(r => r.id)); // Use FileRecord IDs directly
|
||||||
}, [files, setContextSelectedFiles]);
|
}, [activeFileRecords, setSelectedFiles]);
|
||||||
|
|
||||||
const deselectAll = useCallback(() => setContextSelectedFiles([]), [setContextSelectedFiles]);
|
const deselectAll = useCallback(() => setSelectedFiles([]), [setSelectedFiles]);
|
||||||
|
|
||||||
const closeAllFiles = useCallback(() => {
|
const closeAllFiles = useCallback(() => {
|
||||||
if (activeFiles.length === 0) return;
|
if (activeFileRecords.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
|
// Remove all files from context but keep in storage
|
||||||
removeFiles(activeFiles.map(f => (f as any).id || f.name), false);
|
const allFileIds = activeFileRecords.map(record => record.id);
|
||||||
|
removeFiles(allFileIds, false); // false = keep in storage
|
||||||
|
|
||||||
// Clear selections
|
// Clear selections
|
||||||
setContextSelectedFiles([]);
|
setSelectedFiles([]);
|
||||||
}, [activeFiles, removeFiles, setContextSelectedFiles, recordOperation, markOperationApplied]);
|
}, [activeFileRecords, removeFiles, setSelectedFiles]);
|
||||||
|
|
||||||
const toggleFile = useCallback((fileId: string) => {
|
const toggleFile = useCallback((fileId: string) => {
|
||||||
const targetFile = files.find(f => f.id === fileId);
|
const currentSelectedIds = contextSelectedIdsRef.current;
|
||||||
if (!targetFile) return;
|
|
||||||
|
|
||||||
const contextFileId = (targetFile.file as any).id || targetFile.name;
|
const targetRecord = activeFileRecords.find(r => r.id === fileId);
|
||||||
const isSelected = contextSelectedIds.includes(contextFileId);
|
if (!targetRecord) return;
|
||||||
|
|
||||||
|
const contextFileId = fileId; // No need to create a new ID
|
||||||
|
const isSelected = currentSelectedIds.includes(contextFileId);
|
||||||
|
|
||||||
let newSelection: string[];
|
let newSelection: string[];
|
||||||
|
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
// Remove file from selection
|
// Remove file from selection
|
||||||
newSelection = contextSelectedIds.filter(id => id !== contextFileId);
|
newSelection = currentSelectedIds.filter(id => id !== contextFileId);
|
||||||
} else {
|
} else {
|
||||||
// Add file to selection
|
// Add file to selection
|
||||||
if (maxFiles === 1) {
|
// In tool mode, typically allow multiple files unless specified otherwise
|
||||||
|
const maxAllowed = toolMode ? 10 : Infinity; // Default max for tools
|
||||||
|
|
||||||
|
if (maxAllowed === 1) {
|
||||||
newSelection = [contextFileId];
|
newSelection = [contextFileId];
|
||||||
} else {
|
} else {
|
||||||
// Check if we've hit the selection limit
|
// Check if we've hit the selection limit
|
||||||
if (maxFiles > 1 && contextSelectedIds.length >= maxFiles) {
|
if (maxAllowed > 1 && currentSelectedIds.length >= maxAllowed) {
|
||||||
setStatus(`Maximum ${maxFiles} files can be selected`);
|
setStatus(`Maximum ${maxAllowed} files can be selected`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
newSelection = [...contextSelectedIds, contextFileId];
|
newSelection = [...currentSelectedIds, contextFileId];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update context
|
// Update context (this automatically updates tool selection since they use the same action)
|
||||||
setContextSelectedFiles(newSelection);
|
setSelectedFiles(newSelection);
|
||||||
|
}, [setSelectedFiles, toolMode, setStatus, activeFileRecords]);
|
||||||
// Update tool selection context if in tool mode
|
|
||||||
if (isToolMode || toolMode) {
|
|
||||||
const selectedFiles = files
|
|
||||||
.filter(f => {
|
|
||||||
const fId = (f.file as any).id || f.name;
|
|
||||||
return newSelection.includes(fId);
|
|
||||||
})
|
|
||||||
.map(f => f.file);
|
|
||||||
setToolSelectedFiles(selectedFiles);
|
|
||||||
}
|
|
||||||
}, [files, setContextSelectedFiles, maxFiles, contextSelectedIds, setStatus, isToolMode, toolMode, setToolSelectedFiles]);
|
|
||||||
|
|
||||||
const toggleSelectionMode = useCallback(() => {
|
const toggleSelectionMode = useCallback(() => {
|
||||||
setSelectionMode(prev => {
|
setSelectionMode(prev => {
|
||||||
const newMode = !prev;
|
const newMode = !prev;
|
||||||
if (!newMode) {
|
if (!newMode) {
|
||||||
setContextSelectedFiles([]);
|
setSelectedFiles([]);
|
||||||
}
|
}
|
||||||
return newMode;
|
return newMode;
|
||||||
});
|
});
|
||||||
}, [setContextSelectedFiles]);
|
}, [setSelectedFiles]);
|
||||||
|
|
||||||
|
// File reordering handler for drag and drop
|
||||||
|
const handleReorderFiles = useCallback((sourceFileId: string, targetFileId: string, selectedFileIds: string[]) => {
|
||||||
|
const currentIds = activeFileRecords.map(r => r.id);
|
||||||
|
|
||||||
// Drag and drop handlers
|
// Find indices
|
||||||
const handleDragStart = useCallback((fileId: string) => {
|
const sourceIndex = currentIds.findIndex(id => id === sourceFileId);
|
||||||
setDraggedFile(fileId);
|
const targetIndex = currentIds.findIndex(id => id === targetFileId);
|
||||||
|
|
||||||
if (selectionMode && localSelectedIds.includes(fileId) && localSelectedIds.length > 1) {
|
if (sourceIndex === -1 || targetIndex === -1) {
|
||||||
setMultiFileDrag({
|
console.warn('Could not find source or target file for reordering');
|
||||||
fileIds: localSelectedIds,
|
|
||||||
count: localSelectedIds.length
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setMultiFileDrag(null);
|
|
||||||
}
|
|
||||||
}, [selectionMode, localSelectedIds]);
|
|
||||||
|
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setDropTarget(null);
|
// Handle multi-file selection reordering
|
||||||
}, [draggedFile, multiFileDrag]);
|
const filesToMove = selectedFileIds.length > 1
|
||||||
|
? selectedFileIds.filter(id => currentIds.includes(id))
|
||||||
|
: [sourceFileId];
|
||||||
|
|
||||||
const handleDragEnter = useCallback((fileId: string) => {
|
// Create new order
|
||||||
if (draggedFile && fileId !== draggedFile) {
|
const newOrder = [...currentIds];
|
||||||
setDropTarget(fileId);
|
|
||||||
}
|
|
||||||
}, [draggedFile]);
|
|
||||||
|
|
||||||
const handleDragLeave = useCallback(() => {
|
// Remove files to move from their current positions (in reverse order to maintain indices)
|
||||||
// Let dragover handle this
|
const sourceIndices = filesToMove.map(id => newOrder.findIndex(nId => nId === id))
|
||||||
}, []);
|
.sort((a, b) => b - a); // Sort descending
|
||||||
|
|
||||||
const handleDrop = useCallback((e: React.DragEvent, targetFileId: string | 'end') => {
|
sourceIndices.forEach(index => {
|
||||||
e.preventDefault();
|
newOrder.splice(index, 1);
|
||||||
if (!draggedFile || draggedFile === targetFileId) return;
|
});
|
||||||
|
|
||||||
let targetIndex: number;
|
// Calculate insertion index after removals
|
||||||
if (targetFileId === 'end') {
|
let insertIndex = newOrder.findIndex(id => id === targetFileId);
|
||||||
targetIndex = files.length;
|
if (insertIndex !== -1) {
|
||||||
|
// Determine if moving forward or backward
|
||||||
|
const isMovingForward = sourceIndex < targetIndex;
|
||||||
|
if (isMovingForward) {
|
||||||
|
// Moving forward: insert after target
|
||||||
|
insertIndex += 1;
|
||||||
} else {
|
} else {
|
||||||
targetIndex = files.findIndex(f => f.id === targetFileId);
|
// Moving backward: insert before target (insertIndex already correct)
|
||||||
if (targetIndex === -1) return;
|
}
|
||||||
|
} else {
|
||||||
|
// Target was moved, insert at end
|
||||||
|
insertIndex = newOrder.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filesToMove = selectionMode && localSelectedIds.includes(draggedFile)
|
// Insert files at the calculated position
|
||||||
? localSelectedIds
|
newOrder.splice(insertIndex, 0, ...filesToMove);
|
||||||
: [draggedFile];
|
|
||||||
|
|
||||||
// Update the local files state and sync with activeFiles
|
// Update file order
|
||||||
setFiles(prev => {
|
reorderFiles(newOrder);
|
||||||
const newFiles = [...prev];
|
|
||||||
const movedFiles = filesToMove.map(id => newFiles.find(f => f.id === id)!).filter(Boolean);
|
|
||||||
|
|
||||||
// Remove moved files
|
// Update status
|
||||||
filesToMove.forEach(id => {
|
const moveCount = filesToMove.length;
|
||||||
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`);
|
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
|
||||||
|
}, [activeFileRecords, reorderFiles, setStatus]);
|
||||||
|
|
||||||
}, [draggedFile, files, selectionMode, localSelectedIds, multiFileDrag]);
|
|
||||||
|
|
||||||
const handleEndZoneDragEnter = useCallback(() => {
|
|
||||||
if (draggedFile) {
|
|
||||||
setDropTarget('end');
|
|
||||||
}
|
|
||||||
}, [draggedFile]);
|
|
||||||
|
|
||||||
// File operations using context
|
// File operations using context
|
||||||
const handleDeleteFile = useCallback((fileId: string) => {
|
const handleDeleteFile = useCallback((fileId: string) => {
|
||||||
console.log('handleDeleteFile called with fileId:', fileId);
|
const record = activeFileRecords.find(r => r.id === fileId);
|
||||||
const file = files.find(f => f.id === fileId);
|
const file = record ? selectors.getFile(record.id) : null;
|
||||||
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);
|
|
||||||
|
|
||||||
|
if (record && file) {
|
||||||
// Record close operation
|
// Record close operation
|
||||||
const fileName = file.file.name;
|
const fileName = file.name;
|
||||||
const fileId = (file.file as any).id || fileName;
|
const contextFileId = record.id;
|
||||||
const operationId = `close-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
const operationId = `close-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
const operation: FileOperation = {
|
const operation: FileOperation = {
|
||||||
id: operationId,
|
id: operationId,
|
||||||
@@ -590,7 +390,7 @@ const FileEditor = ({
|
|||||||
status: 'pending',
|
status: 'pending',
|
||||||
metadata: {
|
metadata: {
|
||||||
originalFileName: fileName,
|
originalFileName: fileName,
|
||||||
fileSize: file.size,
|
fileSize: record.size,
|
||||||
parameters: {
|
parameters: {
|
||||||
action: 'close',
|
action: 'close',
|
||||||
reason: 'user_request'
|
reason: 'user_request'
|
||||||
@@ -598,67 +398,54 @@ const FileEditor = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
recordOperation(fileName, operation);
|
|
||||||
|
|
||||||
// Remove file from context but keep in storage (close, don't delete)
|
// Remove file from context but keep in storage (close, don't delete)
|
||||||
console.log('Calling removeFiles with:', [fileId]);
|
removeFiles([contextFileId], false);
|
||||||
removeFiles([fileId], false);
|
|
||||||
|
|
||||||
// Remove from context selections
|
// Remove from context selections
|
||||||
const newSelection = contextSelectedIds.filter(id => id !== fileId);
|
const currentSelected = selectedFileIds.filter(id => id !== contextFileId);
|
||||||
setContextSelectedFiles(newSelection);
|
setSelectedFiles(currentSelected);
|
||||||
// Mark operation as applied
|
|
||||||
markOperationApplied(fileName, operationId);
|
|
||||||
} else {
|
|
||||||
console.log('File not found for fileId:', fileId);
|
|
||||||
}
|
}
|
||||||
}, [files, removeFiles, setContextSelectedFiles, recordOperation, markOperationApplied]);
|
}, [activeFileRecords, selectors, removeFiles, setSelectedFiles, selectedFileIds]);
|
||||||
|
|
||||||
const handleViewFile = useCallback((fileId: string) => {
|
const handleViewFile = useCallback((fileId: string) => {
|
||||||
const file = files.find(f => f.id === fileId);
|
const record = activeFileRecords.find(r => r.id === fileId);
|
||||||
if (file) {
|
if (record) {
|
||||||
// Set the file as selected in context and switch to page editor view
|
// Set the file as selected in context and switch to viewer for preview
|
||||||
const contextFileId = (file.file as any).id || file.name;
|
setSelectedFiles([fileId]);
|
||||||
setContextSelectedFiles([contextFileId]);
|
navActions.setMode('viewer');
|
||||||
setCurrentView('pageEditor');
|
|
||||||
onOpenPageEditor?.(file.file);
|
|
||||||
}
|
}
|
||||||
}, [files, setContextSelectedFiles, setCurrentView, onOpenPageEditor]);
|
}, [activeFileRecords, setSelectedFiles, navActions.setMode]);
|
||||||
|
|
||||||
const handleMergeFromHere = useCallback((fileId: string) => {
|
const handleMergeFromHere = useCallback((fileId: string) => {
|
||||||
const startIndex = files.findIndex(f => f.id === fileId);
|
const startIndex = activeFileRecords.findIndex(r => r.id === fileId);
|
||||||
if (startIndex === -1) return;
|
if (startIndex === -1) return;
|
||||||
|
|
||||||
const filesToMerge = files.slice(startIndex).map(f => f.file);
|
const recordsToMerge = activeFileRecords.slice(startIndex);
|
||||||
|
const filesToMerge = recordsToMerge.map(r => selectors.getFile(r.id)).filter(Boolean) as File[];
|
||||||
if (onMergeFiles) {
|
if (onMergeFiles) {
|
||||||
onMergeFiles(filesToMerge);
|
onMergeFiles(filesToMerge);
|
||||||
}
|
}
|
||||||
}, [files, onMergeFiles]);
|
}, [activeFileRecords, selectors, onMergeFiles]);
|
||||||
|
|
||||||
const handleSplitFile = useCallback((fileId: string) => {
|
const handleSplitFile = useCallback((fileId: string) => {
|
||||||
const file = files.find(f => f.id === fileId);
|
const file = selectors.getFile(fileId);
|
||||||
if (file && onOpenPageEditor) {
|
if (file && onOpenPageEditor) {
|
||||||
onOpenPageEditor(file.file);
|
onOpenPageEditor(file);
|
||||||
}
|
}
|
||||||
}, [files, onOpenPageEditor]);
|
}, [selectors, onOpenPageEditor]);
|
||||||
|
|
||||||
const handleLoadFromStorage = useCallback(async (selectedFiles: any[]) => {
|
const handleLoadFromStorage = useCallback(async (selectedFiles: any[]) => {
|
||||||
if (selectedFiles.length === 0) return;
|
if (selectedFiles.length === 0) return;
|
||||||
|
|
||||||
setLocalLoading(true);
|
|
||||||
try {
|
try {
|
||||||
const convertedFiles = await Promise.all(
|
// Use FileContext to handle loading stored files
|
||||||
selectedFiles.map(convertToFileItem)
|
// The files are already in FileContext, just need to add them to active files
|
||||||
);
|
|
||||||
setFiles(prev => [...prev, ...convertedFiles]);
|
|
||||||
setStatus(`Loaded ${selectedFiles.length} files from storage`);
|
setStatus(`Loaded ${selectedFiles.length} files from storage`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading files from storage:', err);
|
console.error('Error loading files from storage:', err);
|
||||||
setError('Failed to load some files from storage');
|
setError('Failed to load some files from storage');
|
||||||
} finally {
|
|
||||||
setLocalLoading(false);
|
|
||||||
}
|
}
|
||||||
}, [convertToFileItem]);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -680,10 +467,14 @@ const FileEditor = ({
|
|||||||
|
|
||||||
<Box p="md" pt="xl">
|
<Box p="md" pt="xl">
|
||||||
<Group mb="md">
|
<Group mb="md">
|
||||||
{showBulkActions && !toolMode && (
|
{toolMode && (
|
||||||
<>
|
<>
|
||||||
<Button onClick={selectAll} variant="light">Select All</Button>
|
<Button onClick={selectAll} variant="light">Select All</Button>
|
||||||
<Button onClick={deselectAll} variant="light">Deselect All</Button>
|
<Button onClick={deselectAll} variant="light">Deselect All</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{showBulkActions && !toolMode && (
|
||||||
|
<>
|
||||||
<Button onClick={closeAllFiles} variant="light" color="orange">
|
<Button onClick={closeAllFiles} variant="light" color="orange">
|
||||||
Close All
|
Close All
|
||||||
</Button>
|
</Button>
|
||||||
@@ -692,7 +483,7 @@ const FileEditor = ({
|
|||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
|
||||||
{files.length === 0 && !localLoading && !zipExtractionProgress.isExtracting ? (
|
{activeFileRecords.length === 0 && !zipExtractionProgress.isExtracting ? (
|
||||||
<Center h="60vh">
|
<Center h="60vh">
|
||||||
<Stack align="center" gap="md">
|
<Stack align="center" gap="md">
|
||||||
<Text size="lg" c="dimmed">📁</Text>
|
<Text size="lg" c="dimmed">📁</Text>
|
||||||
@@ -700,7 +491,7 @@ const FileEditor = ({
|
|||||||
<Text size="sm" c="dimmed">Upload PDF files, ZIP archives, or load from storage to get started</Text>
|
<Text size="sm" c="dimmed">Upload PDF files, ZIP archives, or load from storage to get started</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Center>
|
</Center>
|
||||||
) : files.length === 0 && (localLoading || zipExtractionProgress.isExtracting) ? (
|
) : activeFileRecords.length === 0 && zipExtractionProgress.isExtracting ? (
|
||||||
<Box>
|
<Box>
|
||||||
<SkeletonLoader type="controls" />
|
<SkeletonLoader type="controls" />
|
||||||
|
|
||||||
@@ -734,88 +525,42 @@ const FileEditor = ({
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Processing indicator */}
|
|
||||||
{localLoading && (
|
|
||||||
<Box mb="md" p="sm" style={{ backgroundColor: 'var(--mantine-color-blue-0)', borderRadius: 8 }}>
|
|
||||||
<Group justify="space-between" mb="xs">
|
|
||||||
<Text size="sm" fw={500}>Loading files...</Text>
|
|
||||||
<Text size="sm" c="dimmed">{Math.round(conversionProgress)}%</Text>
|
|
||||||
</Group>
|
|
||||||
<div style={{
|
|
||||||
width: '100%',
|
|
||||||
height: '4px',
|
|
||||||
backgroundColor: 'var(--mantine-color-gray-2)',
|
|
||||||
borderRadius: '2px',
|
|
||||||
overflow: 'hidden'
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
width: `${Math.round(conversionProgress)}%`,
|
|
||||||
height: '100%',
|
|
||||||
backgroundColor: 'var(--mantine-color-blue-6)',
|
|
||||||
transition: 'width 0.3s ease'
|
|
||||||
}} />
|
|
||||||
</div>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<SkeletonLoader type="fileGrid" count={6} />
|
<SkeletonLoader type="fileGrid" count={6} />
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<DragDropGrid
|
<div
|
||||||
items={files}
|
style={{
|
||||||
selectedItems={localSelectedIds as any /* FIX ME */}
|
display: 'grid',
|
||||||
selectionMode={selectionMode}
|
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
|
||||||
isAnimating={isAnimating}
|
gap: '1.5rem',
|
||||||
onDragStart={handleDragStart as any /* FIX ME */}
|
padding: '1rem',
|
||||||
onDragEnd={handleDragEnd}
|
pointerEvents: 'auto'
|
||||||
onDragOver={handleDragOver}
|
}}
|
||||||
onDragEnter={handleDragEnter as any /* FIX ME */}
|
>
|
||||||
onDragLeave={handleDragLeave}
|
{activeFileRecords.map((record, index) => {
|
||||||
onDrop={handleDrop as any /* FIX ME */}
|
const fileItem = recordToFileItem(record);
|
||||||
onEndZoneDragEnter={handleEndZoneDragEnter}
|
if (!fileItem) return null;
|
||||||
draggedItem={draggedFile as any /* FIX ME */}
|
|
||||||
dropTarget={dropTarget as any /* FIX ME */}
|
return (
|
||||||
multiItemDrag={multiFileDrag as any /* FIX ME */}
|
|
||||||
dragPosition={dragPosition}
|
|
||||||
renderItem={(file, index, refs) => (
|
|
||||||
<FileThumbnail
|
<FileThumbnail
|
||||||
file={file}
|
key={record.id}
|
||||||
|
file={fileItem}
|
||||||
index={index}
|
index={index}
|
||||||
totalFiles={files.length}
|
totalFiles={activeFileRecords.length}
|
||||||
selectedFiles={localSelectedIds}
|
selectedFiles={localSelectedIds}
|
||||||
selectionMode={selectionMode}
|
selectionMode={selectionMode}
|
||||||
draggedFile={draggedFile}
|
|
||||||
dropTarget={dropTarget}
|
|
||||||
isAnimating={isAnimating}
|
|
||||||
fileRefs={refs}
|
|
||||||
onDragStart={handleDragStart}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragEnter={handleDragEnter}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
onToggleFile={toggleFile}
|
onToggleFile={toggleFile}
|
||||||
onDeleteFile={handleDeleteFile}
|
onDeleteFile={handleDeleteFile}
|
||||||
onViewFile={handleViewFile}
|
onViewFile={handleViewFile}
|
||||||
onSetStatus={setStatus}
|
onSetStatus={setStatus}
|
||||||
|
onReorderFiles={handleReorderFiles}
|
||||||
toolMode={toolMode}
|
toolMode={toolMode}
|
||||||
isSupported={isFileSupported(file.name)}
|
isSupported={isFileSupported(fileItem.name)}
|
||||||
/>
|
|
||||||
)}
|
|
||||||
renderSplitMarker={(file, index) => (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '2px',
|
|
||||||
height: '24rem',
|
|
||||||
borderLeft: '2px dashed #3b82f6',
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
marginLeft: '-0.75rem',
|
|
||||||
marginRight: '-0.75rem',
|
|
||||||
flexShrink: 0
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
|||||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { getFileSize } from '../../utils/fileUtils';
|
import { getFileSize } from '../../utils/fileUtils';
|
||||||
import { FileWithUrl } from '../../types/file';
|
import { FileMetadata } from '../../types/file';
|
||||||
|
|
||||||
interface CompactFileDetailsProps {
|
interface CompactFileDetailsProps {
|
||||||
currentFile: FileWithUrl | null;
|
currentFile: FileMetadata | null;
|
||||||
thumbnail: string | null;
|
thumbnail: string | null;
|
||||||
selectedFiles: FileWithUrl[];
|
selectedFiles: FileMetadata[];
|
||||||
currentFileIndex: number;
|
currentFileIndex: number;
|
||||||
numberOfFiles: number;
|
numberOfFiles: number;
|
||||||
isAnimating: boolean;
|
isAnimating: boolean;
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import React from 'react';
|
|||||||
import { Stack, Card, Box, Text, Badge, Group, Divider, ScrollArea } from '@mantine/core';
|
import { Stack, Card, Box, Text, Badge, Group, Divider, ScrollArea } from '@mantine/core';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { detectFileExtension, getFileSize } from '../../utils/fileUtils';
|
import { detectFileExtension, getFileSize } from '../../utils/fileUtils';
|
||||||
import { FileWithUrl } from '../../types/file';
|
import { FileMetadata } from '../../types/file';
|
||||||
|
|
||||||
interface FileInfoCardProps {
|
interface FileInfoCardProps {
|
||||||
currentFile: FileWithUrl | null;
|
currentFile: FileMetadata | null;
|
||||||
modalHeight: string;
|
modalHeight: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,9 +52,9 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
|||||||
) : (
|
) : (
|
||||||
filteredFiles.map((file, index) => (
|
filteredFiles.map((file, index) => (
|
||||||
<FileListItem
|
<FileListItem
|
||||||
key={file.id || file.name}
|
key={file.id}
|
||||||
file={file}
|
file={file}
|
||||||
isSelected={selectedFilesSet.has(file.id || file.name)}
|
isSelected={selectedFilesSet.has(file.id)}
|
||||||
isSupported={isFileSupported(file.name)}
|
isSupported={isFileSupported(file.name)}
|
||||||
onSelect={(shiftKey) => onFileSelect(file, index, shiftKey)}
|
onSelect={(shiftKey) => onFileSelect(file, index, shiftKey)}
|
||||||
onRemove={() => onFileRemove(index)}
|
onRemove={() => onFileRemove(index)}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Group, Box, Text, ActionIcon, Checkbox, Divider, Menu } from '@mantine/core';
|
import { Group, Box, Text, ActionIcon, Checkbox, Divider, Menu, Badge } from '@mantine/core';
|
||||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
import DownloadIcon from '@mui/icons-material/Download';
|
import DownloadIcon from '@mui/icons-material/Download';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { getFileSize, getFileDate } from '../../utils/fileUtils';
|
import { getFileSize, getFileDate } from '../../utils/fileUtils';
|
||||||
import { FileWithUrl } from '../../types/file';
|
import { FileMetadata } from '../../types/file';
|
||||||
|
|
||||||
interface FileListItemProps {
|
interface FileListItemProps {
|
||||||
file: FileWithUrl;
|
file: FileMetadata;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
isSupported: boolean;
|
isSupported: boolean;
|
||||||
onSelect: (shiftKey?: boolean) => void;
|
onSelect: (shiftKey?: boolean) => void;
|
||||||
@@ -70,7 +70,14 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||||
<Text size="sm" fw={500} truncate>{file.name}</Text>
|
<Group gap="xs" align="center">
|
||||||
|
<Text size="sm" fw={500} truncate style={{ flex: 1 }}>{file.name}</Text>
|
||||||
|
{file.isDraft && (
|
||||||
|
<Badge size="xs" variant="light" color="orange">
|
||||||
|
DRAFT
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
<Text size="xs" c="dimmed">{getFileSize(file)} • {getFileDate(file)}</Text>
|
<Text size="xs" c="dimmed">{getFileSize(file)} • {getFileDate(file)}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
Code,
|
Code,
|
||||||
Divider
|
Divider
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useFileContext } from '../../contexts/FileContext';
|
// FileContext no longer needed - these were stub functions anyway
|
||||||
import { FileOperation, FileOperationHistory as FileOperationHistoryType } from '../../types/fileContext';
|
import { FileOperation, FileOperationHistory as FileOperationHistoryType } from '../../types/fileContext';
|
||||||
import { PageOperation } from '../../types/pageEditor';
|
import { PageOperation } from '../../types/pageEditor';
|
||||||
|
|
||||||
@@ -26,11 +26,13 @@ const FileOperationHistory: React.FC<FileOperationHistoryProps> = ({
|
|||||||
showOnlyApplied = false,
|
showOnlyApplied = false,
|
||||||
maxHeight = 400
|
maxHeight = 400
|
||||||
}) => {
|
}) => {
|
||||||
const { getFileHistory, getAppliedOperations } = useFileContext();
|
// These were stub functions in the old context - replace with empty stubs
|
||||||
|
const getFileHistory = (fileId: string) => ({ operations: [], createdAt: Date.now(), lastModified: Date.now() });
|
||||||
|
const getAppliedOperations = (fileId: string) => [];
|
||||||
|
|
||||||
const history = getFileHistory(fileId);
|
const history = getFileHistory(fileId);
|
||||||
const allOperations = showOnlyApplied ? getAppliedOperations(fileId) : history?.operations || [];
|
const allOperations = showOnlyApplied ? getAppliedOperations(fileId) : history?.operations || [];
|
||||||
const operations = allOperations.filter(op => 'fileIds' in op) as FileOperation[];
|
const operations = allOperations.filter((op: any) => 'fileIds' in op) as FileOperation[];
|
||||||
|
|
||||||
const formatTimestamp = (timestamp: number) => {
|
const formatTimestamp = (timestamp: number) => {
|
||||||
return new Date(timestamp).toLocaleString();
|
return new Date(timestamp).toLocaleString();
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
|
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
|
||||||
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
||||||
import { useFileHandler } from '../../hooks/useFileHandler';
|
import { useFileHandler } from '../../hooks/useFileHandler';
|
||||||
import { useFileContext } from '../../contexts/FileContext';
|
import { useFileState, useFileActions } from '../../contexts/FileContext';
|
||||||
|
import { useNavigationState, useNavigationActions } from '../../contexts/NavigationContext';
|
||||||
|
|
||||||
import TopControls from '../shared/TopControls';
|
import TopControls from '../shared/TopControls';
|
||||||
import FileEditor from '../fileEditor/FileEditor';
|
import FileEditor from '../fileEditor/FileEditor';
|
||||||
@@ -20,7 +21,12 @@ export default function Workbench() {
|
|||||||
const { isRainbowMode } = useRainbowThemeContext();
|
const { isRainbowMode } = useRainbowThemeContext();
|
||||||
|
|
||||||
// Use context-based hooks to eliminate all prop drilling
|
// Use context-based hooks to eliminate all prop drilling
|
||||||
const { activeFiles, currentView, setCurrentView } = useFileContext();
|
const { state } = useFileState();
|
||||||
|
const { actions } = useFileActions();
|
||||||
|
const { currentMode: currentView } = useNavigationState();
|
||||||
|
const { actions: navActions } = useNavigationActions();
|
||||||
|
const setCurrentView = navActions.setMode;
|
||||||
|
const activeFiles = state.files.ids;
|
||||||
const {
|
const {
|
||||||
previewFile,
|
previewFile,
|
||||||
pageEditorFunctions,
|
pageEditorFunctions,
|
||||||
@@ -47,12 +53,12 @@ export default function Workbench() {
|
|||||||
handleToolSelect('convert');
|
handleToolSelect('convert');
|
||||||
sessionStorage.removeItem('previousMode');
|
sessionStorage.removeItem('previousMode');
|
||||||
} else {
|
} else {
|
||||||
setCurrentView('fileEditor' as any);
|
setCurrentView('fileEditor');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderMainContent = () => {
|
const renderMainContent = () => {
|
||||||
if (!activeFiles[0]) {
|
if (activeFiles.length === 0) {
|
||||||
return (
|
return (
|
||||||
<LandingPage
|
<LandingPage
|
||||||
/>
|
/>
|
||||||
@@ -69,11 +75,11 @@ export default function Workbench() {
|
|||||||
supportedExtensions={selectedTool?.supportedFormats || ["pdf"]}
|
supportedExtensions={selectedTool?.supportedFormats || ["pdf"]}
|
||||||
{...(!selectedToolKey && {
|
{...(!selectedToolKey && {
|
||||||
onOpenPageEditor: (file) => {
|
onOpenPageEditor: (file) => {
|
||||||
setCurrentView("pageEditor" as any);
|
setCurrentView("pageEditor");
|
||||||
},
|
},
|
||||||
onMergeFiles: (filesToMerge) => {
|
onMergeFiles: (filesToMerge) => {
|
||||||
filesToMerge.forEach(addToActiveFiles);
|
filesToMerge.forEach(addToActiveFiles);
|
||||||
setCurrentView("viewer" as any);
|
setCurrentView("viewer");
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
@@ -142,7 +148,7 @@ export default function Workbench() {
|
|||||||
{/* Top Controls */}
|
{/* Top Controls */}
|
||||||
<TopControls
|
<TopControls
|
||||||
currentView={currentView}
|
currentView={currentView}
|
||||||
setCurrentView={setCurrentView as any /* FIX ME */}
|
setCurrentView={setCurrentView}
|
||||||
selectedToolKey={selectedToolKey}
|
selectedToolKey={selectedToolKey}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||||
import { Box } from '@mantine/core';
|
import { Box } from '@mantine/core';
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
|
import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||||
import styles from './PageEditor.module.css';
|
import styles from './PageEditor.module.css';
|
||||||
|
|
||||||
interface DragDropItem {
|
interface DragDropItem {
|
||||||
@@ -12,19 +14,9 @@ interface DragDropGridProps<T extends DragDropItem> {
|
|||||||
selectedItems: number[];
|
selectedItems: number[];
|
||||||
selectionMode: boolean;
|
selectionMode: boolean;
|
||||||
isAnimating: boolean;
|
isAnimating: boolean;
|
||||||
onDragStart: (pageNumber: number) => void;
|
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPages?: number[]) => void;
|
||||||
onDragEnd: () => void;
|
|
||||||
onDragOver: (e: React.DragEvent) => void;
|
|
||||||
onDragEnter: (pageNumber: number) => void;
|
|
||||||
onDragLeave: () => void;
|
|
||||||
onDrop: (e: React.DragEvent, targetPageNumber: number | 'end') => 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: number | null;
|
|
||||||
dropTarget: number | 'end' | null;
|
|
||||||
multiItemDrag: {pageNumbers: number[], count: number} | null;
|
|
||||||
dragPosition: {x: number, y: number} | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DragDropGrid = <T extends DragDropItem>({
|
const DragDropGrid = <T extends DragDropItem>({
|
||||||
@@ -32,104 +24,129 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
selectedItems,
|
selectedItems,
|
||||||
selectionMode,
|
selectionMode,
|
||||||
isAnimating,
|
isAnimating,
|
||||||
onDragStart,
|
onReorderPages,
|
||||||
onDragEnd,
|
|
||||||
onDragOver,
|
|
||||||
onDragEnter,
|
|
||||||
onDragLeave,
|
|
||||||
onDrop,
|
|
||||||
onEndZoneDragEnter,
|
|
||||||
renderItem,
|
renderItem,
|
||||||
renderSplitMarker,
|
renderSplitMarker,
|
||||||
draggedItem,
|
|
||||||
dropTarget,
|
|
||||||
multiItemDrag,
|
|
||||||
dragPosition,
|
|
||||||
}: DragDropGridProps<T>) => {
|
}: DragDropGridProps<T>) => {
|
||||||
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Global drag cleanup
|
// Responsive grid configuration
|
||||||
|
const [itemsPerRow, setItemsPerRow] = useState(4);
|
||||||
|
const ITEM_WIDTH = 320; // 20rem (page width)
|
||||||
|
const ITEM_GAP = 24; // 1.5rem gap between items
|
||||||
|
const ITEM_HEIGHT = 340; // 20rem + gap
|
||||||
|
const OVERSCAN = items.length > 1000 ? 8 : 4; // More overscan for large documents
|
||||||
|
|
||||||
|
// Calculate items per row based on container width
|
||||||
|
const calculateItemsPerRow = useCallback(() => {
|
||||||
|
if (!containerRef.current) return 4; // Default fallback
|
||||||
|
|
||||||
|
const containerWidth = containerRef.current.offsetWidth;
|
||||||
|
if (containerWidth === 0) return 4; // Container not measured yet
|
||||||
|
|
||||||
|
// Calculate how many items fit: (width - gap) / (itemWidth + gap)
|
||||||
|
const availableWidth = containerWidth - ITEM_GAP; // Account for first gap
|
||||||
|
const itemWithGap = ITEM_WIDTH + ITEM_GAP;
|
||||||
|
const calculated = Math.floor(availableWidth / itemWithGap);
|
||||||
|
|
||||||
|
return Math.max(1, calculated); // At least 1 item per row
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Update items per row when container resizes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleGlobalDragEnd = () => {
|
const updateLayout = () => {
|
||||||
onDragEnd();
|
const newItemsPerRow = calculateItemsPerRow();
|
||||||
|
setItemsPerRow(newItemsPerRow);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGlobalDrop = (e: DragEvent) => {
|
// Initial calculation
|
||||||
e.preventDefault();
|
updateLayout();
|
||||||
};
|
|
||||||
|
|
||||||
if (draggedItem) {
|
// Listen for window resize
|
||||||
document.addEventListener('dragend', handleGlobalDragEnd);
|
window.addEventListener('resize', updateLayout);
|
||||||
document.addEventListener('drop', handleGlobalDrop);
|
|
||||||
|
// Use ResizeObserver for container size changes
|
||||||
|
const resizeObserver = new ResizeObserver(updateLayout);
|
||||||
|
if (containerRef.current) {
|
||||||
|
resizeObserver.observe(containerRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('dragend', handleGlobalDragEnd);
|
window.removeEventListener('resize', updateLayout);
|
||||||
document.removeEventListener('drop', handleGlobalDrop);
|
resizeObserver.disconnect();
|
||||||
};
|
};
|
||||||
}, [draggedItem, onDragEnd]);
|
}, [calculateItemsPerRow]);
|
||||||
|
|
||||||
|
// Virtualization with react-virtual library
|
||||||
|
const rowVirtualizer = useVirtualizer({
|
||||||
|
count: Math.ceil(items.length / itemsPerRow),
|
||||||
|
getScrollElement: () => containerRef.current?.closest('[data-scrolling-container]') as Element,
|
||||||
|
estimateSize: () => ITEM_HEIGHT,
|
||||||
|
overscan: OVERSCAN,
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box
|
||||||
|
ref={containerRef}
|
||||||
|
style={{
|
||||||
|
// Basic container styles
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||||
|
width: '100%',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
|
const startIndex = virtualRow.index * itemsPerRow;
|
||||||
|
const endIndex = Math.min(startIndex + itemsPerRow, items.length);
|
||||||
|
const rowItems = items.slice(startIndex, endIndex);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={virtualRow.index}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: `${virtualRow.size}px`,
|
||||||
|
transform: `translateY(${virtualRow.start}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexWrap: 'wrap',
|
|
||||||
gap: '1.5rem',
|
gap: '1.5rem',
|
||||||
justifyContent: 'flex-start',
|
justifyContent: 'flex-start',
|
||||||
paddingBottom: '100px',
|
height: '100%',
|
||||||
// Performance optimizations for smooth scrolling
|
alignItems: 'center',
|
||||||
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) => (
|
{rowItems.map((item, itemIndex) => {
|
||||||
|
const actualIndex = startIndex + itemIndex;
|
||||||
|
return (
|
||||||
<React.Fragment key={item.id}>
|
<React.Fragment key={item.id}>
|
||||||
{/* Split marker */}
|
{/* Split marker */}
|
||||||
{renderSplitMarker && item.splitBefore && index > 0 && renderSplitMarker(item, index)}
|
{renderSplitMarker && item.splitBefore && actualIndex > 0 && renderSplitMarker(item, actualIndex)}
|
||||||
|
|
||||||
{/* Item */}
|
{/* Item */}
|
||||||
{renderItem(item, index, itemRefs)}
|
{renderItem(item, actualIndex, itemRefs)}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{/* End drop zone */}
|
|
||||||
<div className="w-[20rem] h-[20rem] flex items-center justify-center flex-shrink-0">
|
|
||||||
<div
|
|
||||||
data-drop-zone="end"
|
|
||||||
className={`cursor-pointer select-none w-[15rem] h-[15rem] flex items-center justify-center flex-shrink-0 shadow-sm hover:shadow-md transition-all relative ${
|
|
||||||
dropTarget === 'end'
|
|
||||||
? 'ring-2 ring-green-500 bg-green-50'
|
|
||||||
: 'bg-white hover:bg-blue-50 border-2 border-dashed border-gray-300 hover:border-blue-400'
|
|
||||||
}`}
|
|
||||||
style={{ borderRadius: '12px' }}
|
|
||||||
onDragOver={onDragOver}
|
|
||||||
onDragEnter={onEndZoneDragEnter}
|
|
||||||
onDragLeave={onDragLeave}
|
|
||||||
onDrop={(e) => onDrop(e, 'end')}
|
|
||||||
>
|
|
||||||
<div className="text-gray-500 text-sm text-center font-medium">
|
|
||||||
Drop here to<br />move to end
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Multi-item drag indicator */}
|
|
||||||
{multiItemDrag && dragPosition && (
|
|
||||||
<div
|
|
||||||
className={styles.multiDragIndicator}
|
|
||||||
style={{
|
|
||||||
left: dragPosition.x,
|
|
||||||
top: dragPosition.y,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{multiItemDrag.count} items
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
import { Text, Checkbox, Tooltip, ActionIcon, Badge, Modal } from '@mantine/core';
|
import { Text, Checkbox, Tooltip, ActionIcon, Badge } from '@mantine/core';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
|
||||||
import HistoryIcon from '@mui/icons-material/History';
|
|
||||||
import PushPinIcon from '@mui/icons-material/PushPin';
|
import PushPinIcon from '@mui/icons-material/PushPin';
|
||||||
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
|
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
|
||||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||||
|
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||||
import styles from './PageEditor.module.css';
|
import styles from './PageEditor.module.css';
|
||||||
import FileOperationHistory from '../history/FileOperationHistory';
|
|
||||||
import { useFileContext } from '../../contexts/FileContext';
|
import { useFileContext } from '../../contexts/FileContext';
|
||||||
|
|
||||||
interface FileItem {
|
interface FileItem {
|
||||||
@@ -26,20 +24,11 @@ interface FileThumbnailProps {
|
|||||||
totalFiles: number;
|
totalFiles: number;
|
||||||
selectedFiles: string[];
|
selectedFiles: string[];
|
||||||
selectionMode: boolean;
|
selectionMode: boolean;
|
||||||
draggedFile: string | null;
|
|
||||||
dropTarget: string | null;
|
|
||||||
isAnimating: boolean;
|
|
||||||
fileRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
|
|
||||||
onDragStart: (fileId: string) => void;
|
|
||||||
onDragEnd: () => void;
|
|
||||||
onDragOver: (e: React.DragEvent) => void;
|
|
||||||
onDragEnter: (fileId: string) => void;
|
|
||||||
onDragLeave: () => void;
|
|
||||||
onDrop: (e: React.DragEvent, fileId: string) => void;
|
|
||||||
onToggleFile: (fileId: string) => void;
|
onToggleFile: (fileId: string) => void;
|
||||||
onDeleteFile: (fileId: string) => void;
|
onDeleteFile: (fileId: string) => void;
|
||||||
onViewFile: (fileId: string) => void;
|
onViewFile: (fileId: string) => void;
|
||||||
onSetStatus: (status: string) => void;
|
onSetStatus: (status: string) => void;
|
||||||
|
onReorderFiles?: (sourceFileId: string, targetFileId: string, selectedFileIds: string[]) => void;
|
||||||
toolMode?: boolean;
|
toolMode?: boolean;
|
||||||
isSupported?: boolean;
|
isSupported?: boolean;
|
||||||
}
|
}
|
||||||
@@ -50,27 +39,21 @@ const FileThumbnail = ({
|
|||||||
totalFiles,
|
totalFiles,
|
||||||
selectedFiles,
|
selectedFiles,
|
||||||
selectionMode,
|
selectionMode,
|
||||||
draggedFile,
|
|
||||||
dropTarget,
|
|
||||||
isAnimating,
|
|
||||||
fileRefs,
|
|
||||||
onDragStart,
|
|
||||||
onDragEnd,
|
|
||||||
onDragOver,
|
|
||||||
onDragEnter,
|
|
||||||
onDragLeave,
|
|
||||||
onDrop,
|
|
||||||
onToggleFile,
|
onToggleFile,
|
||||||
onDeleteFile,
|
onDeleteFile,
|
||||||
onViewFile,
|
onViewFile,
|
||||||
onSetStatus,
|
onSetStatus,
|
||||||
|
onReorderFiles,
|
||||||
toolMode = false,
|
toolMode = false,
|
||||||
isSupported = true,
|
isSupported = true,
|
||||||
}: FileThumbnailProps) => {
|
}: FileThumbnailProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [showHistory, setShowHistory] = useState(false);
|
|
||||||
const { pinnedFiles, pinFile, unpinFile, isFilePinned, activeFiles } = useFileContext();
|
const { pinnedFiles, pinFile, unpinFile, isFilePinned, activeFiles } = useFileContext();
|
||||||
|
|
||||||
|
// Drag and drop state
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const dragElementRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
// Find the actual File object that corresponds to this FileItem
|
// Find the actual File object that corresponds to this FileItem
|
||||||
const actualFile = activeFiles.find(f => f.name === file.name && f.size === file.size);
|
const actualFile = activeFiles.find(f => f.name === file.name && f.size === file.size);
|
||||||
|
|
||||||
@@ -82,15 +65,57 @@ const FileThumbnail = ({
|
|||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Setup drag and drop using @atlaskit/pragmatic-drag-and-drop
|
||||||
|
const fileElementRef = useCallback((element: HTMLDivElement | null) => {
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
dragElementRef.current = element;
|
||||||
|
|
||||||
|
const dragCleanup = draggable({
|
||||||
|
element,
|
||||||
|
getInitialData: () => ({
|
||||||
|
type: 'file',
|
||||||
|
fileId: file.id,
|
||||||
|
fileName: file.name,
|
||||||
|
selectedFiles: [file.id] // Always drag only this file, ignore selection state
|
||||||
|
}),
|
||||||
|
onDragStart: () => {
|
||||||
|
setIsDragging(true);
|
||||||
|
},
|
||||||
|
onDrop: () => {
|
||||||
|
setIsDragging(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const dropCleanup = dropTargetForElements({
|
||||||
|
element,
|
||||||
|
getData: () => ({
|
||||||
|
type: 'file',
|
||||||
|
fileId: file.id
|
||||||
|
}),
|
||||||
|
canDrop: ({ source }) => {
|
||||||
|
const sourceData = source.data;
|
||||||
|
return sourceData.type === 'file' && sourceData.fileId !== file.id;
|
||||||
|
},
|
||||||
|
onDrop: ({ source }) => {
|
||||||
|
const sourceData = source.data;
|
||||||
|
if (sourceData.type === 'file' && onReorderFiles) {
|
||||||
|
const sourceFileId = sourceData.fileId as string;
|
||||||
|
const selectedFileIds = sourceData.selectedFiles as string[];
|
||||||
|
onReorderFiles(sourceFileId, file.id, selectedFileIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
dragCleanup();
|
||||||
|
dropCleanup();
|
||||||
|
};
|
||||||
|
}, [file.id, file.name, selectionMode, selectedFiles, onReorderFiles]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={(el) => {
|
ref={fileElementRef}
|
||||||
if (el) {
|
|
||||||
fileRefs.current.set(file.id, el);
|
|
||||||
} else {
|
|
||||||
fileRefs.current.delete(file.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
data-file-id={file.id}
|
data-file-id={file.id}
|
||||||
data-testid="file-thumbnail"
|
data-testid="file-thumbnail"
|
||||||
className={`
|
className={`
|
||||||
@@ -109,26 +134,12 @@ const FileThumbnail = ({
|
|||||||
${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'}
|
||||||
${draggedFile === file.id ? 'opacity-50 scale-95' : ''}
|
${isDragging ? 'opacity-50 scale-95' : ''}
|
||||||
`}
|
`}
|
||||||
style={{
|
style={{
|
||||||
transform: (() => {
|
opacity: isSupported ? (isDragging ? 0.5 : 1) : 0.5,
|
||||||
if (!isAnimating && draggedFile && file.id !== draggedFile && dropTarget === file.id) {
|
|
||||||
return 'translateX(20px)';
|
|
||||||
}
|
|
||||||
return 'translateX(0)';
|
|
||||||
})(),
|
|
||||||
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out',
|
|
||||||
opacity: isSupported ? 1 : 0.5,
|
|
||||||
filter: isSupported ? 'none' : 'grayscale(50%)'
|
filter: isSupported ? 'none' : 'grayscale(50%)'
|
||||||
}}
|
}}
|
||||||
draggable
|
|
||||||
onDragStart={() => onDragStart(file.id)}
|
|
||||||
onDragEnd={onDragEnd}
|
|
||||||
onDragOver={onDragOver}
|
|
||||||
onDragEnter={() => onDragEnter(file.id)}
|
|
||||||
onDragLeave={onDragLeave}
|
|
||||||
onDrop={(e) => onDrop(e, file.id)}
|
|
||||||
>
|
>
|
||||||
{selectionMode && (
|
{selectionMode && (
|
||||||
<div
|
<div
|
||||||
@@ -187,6 +198,12 @@ const FileThumbnail = ({
|
|||||||
<img
|
<img
|
||||||
src={file.thumbnail}
|
src={file.thumbnail}
|
||||||
alt={file.name}
|
alt={file.name}
|
||||||
|
draggable={false}
|
||||||
|
onError={(e) => {
|
||||||
|
// Hide broken image if blob URL was revoked
|
||||||
|
const img = e.target as HTMLImageElement;
|
||||||
|
img.style.display = 'none';
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
maxHeight: '100%',
|
maxHeight: '100%',
|
||||||
@@ -196,7 +213,8 @@ const FileThumbnail = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Page count badge */}
|
{/* Page count badge - only show for PDFs */}
|
||||||
|
{file.pageCount > 0 && (
|
||||||
<Badge
|
<Badge
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
@@ -208,8 +226,9 @@ const FileThumbnail = ({
|
|||||||
zIndex: 3,
|
zIndex: 3,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{file.pageCount} pages
|
{file.pageCount} {file.pageCount === 1 ? 'page' : 'pages'}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Unsupported badge */}
|
{/* Unsupported badge */}
|
||||||
{!isSupported && (
|
{!isSupported && (
|
||||||
@@ -273,40 +292,6 @@ const FileThumbnail = ({
|
|||||||
whiteSpace: 'nowrap'
|
whiteSpace: 'nowrap'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!toolMode && isSupported && (
|
|
||||||
<>
|
|
||||||
<Tooltip label="View File">
|
|
||||||
<ActionIcon
|
|
||||||
size="md"
|
|
||||||
variant="subtle"
|
|
||||||
c="white"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onViewFile(file.id);
|
|
||||||
onSetStatus(`Opened ${file.name}`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<VisibilityIcon style={{ fontSize: 20 }} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Tooltip label="View History">
|
|
||||||
<ActionIcon
|
|
||||||
size="md"
|
|
||||||
variant="subtle"
|
|
||||||
c="white"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setShowHistory(true);
|
|
||||||
onSetStatus(`Viewing history for ${file.name}`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<HistoryIcon style={{ fontSize: 20 }} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{actualFile && (
|
{actualFile && (
|
||||||
<Tooltip label={isFilePinned(actualFile) ? "Unpin File" : "Pin File"}>
|
<Tooltip label={isFilePinned(actualFile) ? "Unpin File" : "Pin File"}>
|
||||||
@@ -372,20 +357,6 @@ const FileThumbnail = ({
|
|||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* History Modal */}
|
|
||||||
<Modal
|
|
||||||
opened={showHistory}
|
|
||||||
onClose={() => setShowHistory(false)}
|
|
||||||
title={`Operation History - ${file.name}`}
|
|
||||||
size="lg"
|
|
||||||
scrollAreaComponent={'div' as any}
|
|
||||||
>
|
|
||||||
<FileOperationHistory
|
|
||||||
fileId={file.name}
|
|
||||||
showOnlyApplied={true}
|
|
||||||
maxHeight={500}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,20 +6,13 @@ import RotateLeftIcon from '@mui/icons-material/RotateLeft';
|
|||||||
import RotateRightIcon from '@mui/icons-material/RotateRight';
|
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 { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||||
import { PDFPage, PDFDocument } from '../../types/pageEditor';
|
import { PDFPage, PDFDocument } from '../../types/pageEditor';
|
||||||
import { RotatePagesCommand, DeletePagesCommand, ToggleSplitCommand } from '../../commands/pageCommands';
|
import { RotatePagesCommand, DeletePagesCommand, ToggleSplitCommand } from '../../commands/pageCommands';
|
||||||
import { Command } from '../../hooks/useUndoRedo';
|
import { Command } from '../../hooks/useUndoRedo';
|
||||||
|
import { useFileState } from '../../contexts/FileContext';
|
||||||
|
import { useThumbnailGeneration } from '../../hooks/useThumbnailGeneration';
|
||||||
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;
|
||||||
@@ -28,22 +21,15 @@ interface PageThumbnailProps {
|
|||||||
originalFile?: File; // For lazy thumbnail generation
|
originalFile?: File; // For lazy thumbnail generation
|
||||||
selectedPages: number[];
|
selectedPages: number[];
|
||||||
selectionMode: boolean;
|
selectionMode: boolean;
|
||||||
draggedPage: number | null;
|
|
||||||
dropTarget: number | 'end' | null;
|
|
||||||
movingPage: number | null;
|
movingPage: number | null;
|
||||||
isAnimating: boolean;
|
isAnimating: boolean;
|
||||||
pageRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
|
pageRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
|
||||||
onDragStart: (pageNumber: number) => void;
|
|
||||||
onDragEnd: () => void;
|
|
||||||
onDragOver: (e: React.DragEvent) => void;
|
|
||||||
onDragEnter: (pageNumber: number) => void;
|
|
||||||
onDragLeave: () => void;
|
|
||||||
onDrop: (e: React.DragEvent, pageNumber: number) => void;
|
|
||||||
onTogglePage: (pageNumber: number) => void;
|
onTogglePage: (pageNumber: number) => void;
|
||||||
onAnimateReorder: (pageNumber: number, targetIndex: number) => void;
|
onAnimateReorder: (pageNumber: number, targetIndex: number) => void;
|
||||||
onExecuteCommand: (command: Command) => void;
|
onExecuteCommand: (command: Command) => void;
|
||||||
onSetStatus: (status: string) => void;
|
onSetStatus: (status: string) => void;
|
||||||
onSetMovingPage: (pageNumber: number | null) => void;
|
onSetMovingPage: (pageNumber: number | null) => void;
|
||||||
|
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPages?: number[]) => void;
|
||||||
RotatePagesCommand: typeof RotatePagesCommand;
|
RotatePagesCommand: typeof RotatePagesCommand;
|
||||||
DeletePagesCommand: typeof DeletePagesCommand;
|
DeletePagesCommand: typeof DeletePagesCommand;
|
||||||
ToggleSplitCommand: typeof ToggleSplitCommand;
|
ToggleSplitCommand: typeof ToggleSplitCommand;
|
||||||
@@ -58,22 +44,15 @@ const PageThumbnail = React.memo(({
|
|||||||
originalFile,
|
originalFile,
|
||||||
selectedPages,
|
selectedPages,
|
||||||
selectionMode,
|
selectionMode,
|
||||||
draggedPage,
|
|
||||||
dropTarget,
|
|
||||||
movingPage,
|
movingPage,
|
||||||
isAnimating,
|
isAnimating,
|
||||||
pageRefs,
|
pageRefs,
|
||||||
onDragStart,
|
|
||||||
onDragEnd,
|
|
||||||
onDragOver,
|
|
||||||
onDragEnter,
|
|
||||||
onDragLeave,
|
|
||||||
onDrop,
|
|
||||||
onTogglePage,
|
onTogglePage,
|
||||||
onAnimateReorder,
|
onAnimateReorder,
|
||||||
onExecuteCommand,
|
onExecuteCommand,
|
||||||
onSetStatus,
|
onSetStatus,
|
||||||
onSetMovingPage,
|
onSetMovingPage,
|
||||||
|
onReorderPages,
|
||||||
RotatePagesCommand,
|
RotatePagesCommand,
|
||||||
DeletePagesCommand,
|
DeletePagesCommand,
|
||||||
ToggleSplitCommand,
|
ToggleSplitCommand,
|
||||||
@@ -81,51 +60,122 @@ const PageThumbnail = React.memo(({
|
|||||||
setPdfDocument,
|
setPdfDocument,
|
||||||
}: PageThumbnailProps) => {
|
}: PageThumbnailProps) => {
|
||||||
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
|
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
|
||||||
const [isLoadingThumbnail, setIsLoadingThumbnail] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const dragElementRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { state, selectors } = useFileState();
|
||||||
|
const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration();
|
||||||
|
|
||||||
// Update thumbnail URL when page prop changes
|
// Update thumbnail URL when page prop changes - prevent redundant updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (page.thumbnail && page.thumbnail !== thumbnailUrl) {
|
if (page.thumbnail && page.thumbnail !== thumbnailUrl) {
|
||||||
console.log(`📸 PageThumbnail: Updating thumbnail URL for page ${page.pageNumber}`, page.thumbnail.substring(0, 50) + '...');
|
console.log(`📸 PageThumbnail: Updating thumbnail URL for page ${page.pageNumber}`, page.thumbnail.substring(0, 50) + '...');
|
||||||
setThumbnailUrl(page.thumbnail);
|
setThumbnailUrl(page.thumbnail);
|
||||||
}
|
}
|
||||||
}, [page.thumbnail, page.pageNumber, page.id, thumbnailUrl]);
|
}, [page.thumbnail, page.id]); // Remove thumbnailUrl dependency to prevent redundant cycles
|
||||||
|
|
||||||
// Listen for ready thumbnails from Web Workers (only if no existing thumbnail)
|
// Request thumbnail generation if not available (optimized for performance)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (thumbnailUrl) {
|
if (thumbnailUrl || !originalFile) {
|
||||||
console.log(`📸 PageThumbnail: Page ${page.pageNumber} already has thumbnail, skipping worker listener`);
|
return; // Skip if we already have a thumbnail or no original file
|
||||||
return; // Skip if we already have a thumbnail
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`📸 PageThumbnail: Setting up worker listener for page ${page.pageNumber} (${page.id})`);
|
// Check cache first without async call
|
||||||
|
const cachedThumbnail = getThumbnailFromCache(page.id);
|
||||||
|
if (cachedThumbnail) {
|
||||||
|
setThumbnailUrl(cachedThumbnail);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const handleThumbnailReady = (event: CustomEvent) => {
|
let cancelled = false;
|
||||||
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) {
|
const loadThumbnail = async () => {
|
||||||
console.log(`✓ PageThumbnail: Thumbnail matched for page ${page.pageNumber}, setting URL`);
|
try {
|
||||||
|
const thumbnail = await requestThumbnail(page.id, originalFile, page.pageNumber);
|
||||||
|
|
||||||
|
// Only update if component is still mounted and we got a result
|
||||||
|
if (!cancelled && thumbnail) {
|
||||||
setThumbnailUrl(thumbnail);
|
setThumbnailUrl(thumbnail);
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) {
|
||||||
|
console.warn(`📸 PageThumbnail: Failed to load thumbnail for page ${page.pageNumber}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('thumbnailReady', handleThumbnailReady as EventListener);
|
loadThumbnail();
|
||||||
|
|
||||||
|
// Cleanup function to prevent state updates after unmount
|
||||||
return () => {
|
return () => {
|
||||||
console.log(`📸 PageThumbnail: Cleaning up worker listener for page ${page.pageNumber}`);
|
cancelled = true;
|
||||||
window.removeEventListener('thumbnailReady', handleThumbnailReady as EventListener);
|
|
||||||
};
|
};
|
||||||
}, [page.pageNumber, page.id, thumbnailUrl]);
|
}, [page.id, originalFile, requestThumbnail, getThumbnailFromCache]); // Removed thumbnailUrl to prevent loops
|
||||||
|
|
||||||
|
|
||||||
// Register this component with pageRefs for animations
|
|
||||||
const pageElementRef = useCallback((element: HTMLDivElement | null) => {
|
const pageElementRef = useCallback((element: HTMLDivElement | null) => {
|
||||||
if (element) {
|
if (element) {
|
||||||
pageRefs.current.set(page.id, element);
|
pageRefs.current.set(page.id, element);
|
||||||
|
dragElementRef.current = element;
|
||||||
|
|
||||||
|
const dragCleanup = draggable({
|
||||||
|
element,
|
||||||
|
getInitialData: () => ({
|
||||||
|
pageNumber: page.pageNumber,
|
||||||
|
pageId: page.id,
|
||||||
|
selectedPages: selectionMode && selectedPages.includes(page.pageNumber)
|
||||||
|
? selectedPages
|
||||||
|
: [page.pageNumber]
|
||||||
|
}),
|
||||||
|
onDragStart: () => {
|
||||||
|
setIsDragging(true);
|
||||||
|
},
|
||||||
|
onDrop: ({ location }) => {
|
||||||
|
setIsDragging(false);
|
||||||
|
|
||||||
|
if (location.current.dropTargets.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dropTarget = location.current.dropTargets[0];
|
||||||
|
const targetData = dropTarget.data;
|
||||||
|
|
||||||
|
if (targetData.type === 'page') {
|
||||||
|
const targetPageNumber = targetData.pageNumber as number;
|
||||||
|
const targetIndex = pdfDocument.pages.findIndex(p => p.pageNumber === targetPageNumber);
|
||||||
|
if (targetIndex !== -1) {
|
||||||
|
const pagesToMove = selectionMode && selectedPages.includes(page.pageNumber)
|
||||||
|
? selectedPages
|
||||||
|
: undefined;
|
||||||
|
onReorderPages(page.pageNumber, targetIndex, pagesToMove);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
element.style.cursor = 'grab';
|
||||||
|
|
||||||
|
|
||||||
|
const dropCleanup = dropTargetForElements({
|
||||||
|
element,
|
||||||
|
getData: () => ({
|
||||||
|
type: 'page',
|
||||||
|
pageNumber: page.pageNumber
|
||||||
|
}),
|
||||||
|
onDrop: ({ source }) => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
(element as any).__dragCleanup = () => {
|
||||||
|
dragCleanup();
|
||||||
|
dropCleanup();
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
pageRefs.current.delete(page.id);
|
pageRefs.current.delete(page.id);
|
||||||
|
if (dragElementRef.current && (dragElementRef.current as any).__dragCleanup) {
|
||||||
|
(dragElementRef.current as any).__dragCleanup();
|
||||||
}
|
}
|
||||||
}, [page.id, pageRefs]);
|
}
|
||||||
|
}, [page.id, page.pageNumber, pageRefs, selectionMode, selectedPages, pdfDocument.pages, onReorderPages]);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -147,25 +197,13 @@ const PageThumbnail = React.memo(({
|
|||||||
${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.pageNumber ? 'opacity-50 scale-95' : ''}
|
${isDragging ? 'opacity-50 scale-95' : ''}
|
||||||
${movingPage === page.pageNumber ? 'page-moving' : ''}
|
${movingPage === page.pageNumber ? 'page-moving' : ''}
|
||||||
`}
|
`}
|
||||||
style={{
|
style={{
|
||||||
transform: (() => {
|
|
||||||
if (!isAnimating && draggedPage && page.pageNumber !== draggedPage && dropTarget === page.pageNumber) {
|
|
||||||
return 'translateX(20px)';
|
|
||||||
}
|
|
||||||
return 'translateX(0)';
|
|
||||||
})(),
|
|
||||||
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out'
|
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out'
|
||||||
}}
|
}}
|
||||||
draggable
|
draggable={false}
|
||||||
onDragStart={() => onDragStart(page.pageNumber)}
|
|
||||||
onDragEnd={onDragEnd}
|
|
||||||
onDragOver={onDragOver}
|
|
||||||
onDragEnter={() => onDragEnter(page.pageNumber)}
|
|
||||||
onDragLeave={onDragLeave}
|
|
||||||
onDrop={(e) => onDrop(e, page.pageNumber)}
|
|
||||||
>
|
>
|
||||||
{selectionMode && (
|
{selectionMode && (
|
||||||
<div
|
<div
|
||||||
@@ -189,7 +227,6 @@ const PageThumbnail = React.memo(({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
console.log('📸 Checkbox clicked for page', page.pageNumber);
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onTogglePage(page.pageNumber);
|
onTogglePage(page.pageNumber);
|
||||||
}}
|
}}
|
||||||
@@ -204,7 +241,7 @@ const PageThumbnail = React.memo(({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="page-container w-[90%] h-[90%]">
|
<div className="page-container w-[90%] h-[90%]" draggable={false}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@@ -222,6 +259,7 @@ const PageThumbnail = React.memo(({
|
|||||||
<img
|
<img
|
||||||
src={thumbnailUrl}
|
src={thumbnailUrl}
|
||||||
alt={`Page ${page.pageNumber}`}
|
alt={`Page ${page.pageNumber}`}
|
||||||
|
draggable={false}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
@@ -231,11 +269,6 @@ const PageThumbnail = React.memo(({
|
|||||||
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' }}>
|
<div style={{ textAlign: 'center' }}>
|
||||||
<Text size="lg" c="dimmed">📄</Text>
|
<Text size="lg" c="dimmed">📄</Text>
|
||||||
@@ -408,30 +441,25 @@ const PageThumbnail = React.memo(({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DragIndicatorIcon
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 4,
|
|
||||||
right: 4,
|
|
||||||
color: 'rgba(0,0,0,0.3)',
|
|
||||||
fontSize: 16,
|
|
||||||
zIndex: 1
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}, (prevProps, nextProps) => {
|
}, (prevProps, nextProps) => {
|
||||||
|
// Helper for shallow array comparison
|
||||||
|
const arraysEqual = (a: number[], b: number[]) => {
|
||||||
|
return a.length === b.length && a.every((val, i) => val === b[i]);
|
||||||
|
};
|
||||||
|
|
||||||
// Only re-render if essential props change
|
// Only re-render if essential props change
|
||||||
return (
|
return (
|
||||||
prevProps.page.id === nextProps.page.id &&
|
prevProps.page.id === nextProps.page.id &&
|
||||||
prevProps.page.pageNumber === nextProps.page.pageNumber &&
|
prevProps.page.pageNumber === nextProps.page.pageNumber &&
|
||||||
prevProps.page.rotation === nextProps.page.rotation &&
|
prevProps.page.rotation === nextProps.page.rotation &&
|
||||||
prevProps.page.thumbnail === nextProps.page.thumbnail &&
|
prevProps.page.thumbnail === nextProps.page.thumbnail &&
|
||||||
prevProps.selectedPages === nextProps.selectedPages && // Compare array reference - will re-render when selection changes
|
// Shallow compare selectedPages array for better stability
|
||||||
|
(prevProps.selectedPages === nextProps.selectedPages ||
|
||||||
|
arraysEqual(prevProps.selectedPages, nextProps.selectedPages)) &&
|
||||||
prevProps.selectionMode === nextProps.selectionMode &&
|
prevProps.selectionMode === nextProps.selectionMode &&
|
||||||
prevProps.draggedPage === nextProps.draggedPage &&
|
|
||||||
prevProps.dropTarget === nextProps.dropTarget &&
|
|
||||||
prevProps.movingPage === nextProps.movingPage &&
|
prevProps.movingPage === nextProps.movingPage &&
|
||||||
prevProps.isAnimating === nextProps.isAnimating
|
prevProps.isAnimating === nextProps.isAnimating
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ 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 { FileRecord } from "../../types/fileContext";
|
||||||
import { getFileSize, getFileDate } from "../../utils/fileUtils";
|
import { getFileSize, getFileDate } from "../../utils/fileUtils";
|
||||||
import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail";
|
import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail";
|
||||||
|
|
||||||
interface FileCardProps {
|
interface FileCardProps {
|
||||||
file: FileWithUrl;
|
file: File;
|
||||||
|
record?: FileRecord;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
onDoubleClick?: () => void;
|
onDoubleClick?: () => void;
|
||||||
onView?: () => void;
|
onView?: () => void;
|
||||||
@@ -21,9 +22,12 @@ interface FileCardProps {
|
|||||||
isSupported?: boolean; // Whether the file format is supported by the current tool
|
isSupported?: boolean; // Whether the file format is supported by the current tool
|
||||||
}
|
}
|
||||||
|
|
||||||
const FileCard = ({ file, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => {
|
const FileCard = ({ file, record, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { thumbnail: thumb, isGenerating } = useIndexedDBThumbnail(file);
|
// Use record thumbnail if available, otherwise fall back to IndexedDB lookup
|
||||||
|
const fileMetadata = record ? { id: record.id, name: file.name, type: file.type, size: file.size, lastModified: file.lastModified } : null;
|
||||||
|
const { thumbnail: indexedDBThumb, isGenerating } = useIndexedDBThumbnail(fileMetadata);
|
||||||
|
const thumb = record?.thumbnailUrl || indexedDBThumb;
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -173,7 +177,7 @@ const FileCard = ({ file, onRemove, onDoubleClick, onView, onEdit, isSelected, o
|
|||||||
<Badge color="blue" variant="light" size="sm">
|
<Badge color="blue" variant="light" size="sm">
|
||||||
{getFileDate(file)}
|
{getFileDate(file)}
|
||||||
</Badge>
|
</Badge>
|
||||||
{file.storedInIndexedDB && (
|
{record?.id && (
|
||||||
<Badge
|
<Badge
|
||||||
color="green"
|
color="green"
|
||||||
variant="light"
|
variant="light"
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ import { useTranslation } from "react-i18next";
|
|||||||
import SearchIcon from "@mui/icons-material/Search";
|
import SearchIcon from "@mui/icons-material/Search";
|
||||||
import SortIcon from "@mui/icons-material/Sort";
|
import SortIcon from "@mui/icons-material/Sort";
|
||||||
import FileCard from "./FileCard";
|
import FileCard from "./FileCard";
|
||||||
import { FileWithUrl } from "../../types/file";
|
import { FileRecord } from "../../types/fileContext";
|
||||||
|
|
||||||
interface FileGridProps {
|
interface FileGridProps {
|
||||||
files: FileWithUrl[];
|
files: Array<{ file: File; record?: FileRecord }>;
|
||||||
onRemove?: (index: number) => void;
|
onRemove?: (index: number) => void;
|
||||||
onDoubleClick?: (file: FileWithUrl) => void;
|
onDoubleClick?: (item: { file: File; record?: FileRecord }) => void;
|
||||||
onView?: (file: FileWithUrl) => void;
|
onView?: (item: { file: File; record?: FileRecord }) => void;
|
||||||
onEdit?: (file: FileWithUrl) => void;
|
onEdit?: (item: { file: File; record?: FileRecord }) => void;
|
||||||
onSelect?: (fileId: string) => void;
|
onSelect?: (fileId: string) => void;
|
||||||
selectedFiles?: string[];
|
selectedFiles?: string[];
|
||||||
showSearch?: boolean;
|
showSearch?: boolean;
|
||||||
@@ -46,19 +46,19 @@ const FileGrid = ({
|
|||||||
const [sortBy, setSortBy] = useState<SortOption>('date');
|
const [sortBy, setSortBy] = useState<SortOption>('date');
|
||||||
|
|
||||||
// Filter files based on search term
|
// Filter files based on search term
|
||||||
const filteredFiles = files.filter(file =>
|
const filteredFiles = files.filter(item =>
|
||||||
file.name.toLowerCase().includes(searchTerm.toLowerCase())
|
item.file.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
// Sort files
|
// Sort files
|
||||||
const sortedFiles = [...filteredFiles].sort((a, b) => {
|
const sortedFiles = [...filteredFiles].sort((a, b) => {
|
||||||
switch (sortBy) {
|
switch (sortBy) {
|
||||||
case 'date':
|
case 'date':
|
||||||
return (b.lastModified || 0) - (a.lastModified || 0);
|
return (b.file.lastModified || 0) - (a.file.lastModified || 0);
|
||||||
case 'name':
|
case 'name':
|
||||||
return a.name.localeCompare(b.name);
|
return a.file.name.localeCompare(b.file.name);
|
||||||
case 'size':
|
case 'size':
|
||||||
return (b.size || 0) - (a.size || 0);
|
return (b.file.size || 0) - (a.file.size || 0);
|
||||||
default:
|
default:
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -122,18 +122,19 @@ const FileGrid = ({
|
|||||||
h="30rem"
|
h="30rem"
|
||||||
style={{ overflowY: "auto", width: "100%" }}
|
style={{ overflowY: "auto", width: "100%" }}
|
||||||
>
|
>
|
||||||
{displayFiles.map((file, idx) => {
|
{displayFiles.map((item, idx) => {
|
||||||
const fileId = file.id || file.name;
|
const fileId = item.record?.id || item.file.name;
|
||||||
const originalIdx = files.findIndex(f => (f.id || f.name) === fileId);
|
const originalIdx = files.findIndex(f => (f.record?.id || f.file.name) === fileId);
|
||||||
const supported = isFileSupported ? isFileSupported(file.name) : true;
|
const supported = isFileSupported ? isFileSupported(item.file.name) : true;
|
||||||
return (
|
return (
|
||||||
<FileCard
|
<FileCard
|
||||||
key={fileId + idx}
|
key={fileId + idx}
|
||||||
file={file}
|
file={item.file}
|
||||||
|
record={item.record}
|
||||||
onRemove={onRemove ? () => onRemove(originalIdx) : () => {}}
|
onRemove={onRemove ? () => onRemove(originalIdx) : () => {}}
|
||||||
onDoubleClick={onDoubleClick && supported ? () => onDoubleClick(file) : undefined}
|
onDoubleClick={onDoubleClick && supported ? () => onDoubleClick(item) : undefined}
|
||||||
onView={onView && supported ? () => onView(file) : undefined}
|
onView={onView && supported ? () => onView(item) : undefined}
|
||||||
onEdit={onEdit && supported ? () => onEdit(file) : undefined}
|
onEdit={onEdit && supported ? () => onEdit(item) : undefined}
|
||||||
isSelected={selectedFiles.includes(fileId)}
|
isSelected={selectedFiles.includes(fileId)}
|
||||||
onSelect={onSelect && supported ? () => onSelect(fileId) : undefined}
|
onSelect={onSelect && supported ? () => onSelect(fileId) : undefined}
|
||||||
isSupported={supported}
|
isSupported={supported}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
interface FilePickerModalProps {
|
interface FilePickerModalProps {
|
||||||
opened: boolean;
|
opened: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
storedFiles: any[]; // Files from storage (FileWithUrl format)
|
storedFiles: any[]; // Files from storage (various formats supported)
|
||||||
onSelectFiles: (selectedFiles: File[]) => void;
|
onSelectFiles: (selectedFiles: File[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ const FilePickerModal = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const selectAll = () => {
|
const selectAll = () => {
|
||||||
setSelectedFileIds(storedFiles.map(f => f.id || f.name));
|
setSelectedFileIds(storedFiles.map(f => f.id).filter(Boolean));
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectNone = () => {
|
const selectNone = () => {
|
||||||
@@ -57,7 +57,7 @@ const FilePickerModal = ({
|
|||||||
|
|
||||||
const handleConfirm = async () => {
|
const handleConfirm = async () => {
|
||||||
const selectedFiles = storedFiles.filter(f =>
|
const selectedFiles = storedFiles.filter(f =>
|
||||||
selectedFileIds.includes(f.id || f.name)
|
selectedFileIds.includes(f.id)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Convert stored files to File objects
|
// Convert stored files to File objects
|
||||||
@@ -154,7 +154,7 @@ const FilePickerModal = ({
|
|||||||
<ScrollArea.Autosize mah={400}>
|
<ScrollArea.Autosize mah={400}>
|
||||||
<SimpleGrid cols={2} spacing="md">
|
<SimpleGrid cols={2} spacing="md">
|
||||||
{storedFiles.map((file) => {
|
{storedFiles.map((file) => {
|
||||||
const fileId = file.id || file.name;
|
const fileId = file.id;
|
||||||
const isSelected = selectedFileIds.includes(fileId);
|
const isSelected = selectedFileIds.includes(fileId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box } from '@mantine/core';
|
import { Box } from '@mantine/core';
|
||||||
import { FileWithUrl } from '../../types/file';
|
import { FileMetadata } from '../../types/file';
|
||||||
import DocumentThumbnail from './filePreview/DocumentThumbnail';
|
import DocumentThumbnail from './filePreview/DocumentThumbnail';
|
||||||
import DocumentStack from './filePreview/DocumentStack';
|
import DocumentStack from './filePreview/DocumentStack';
|
||||||
import HoverOverlay from './filePreview/HoverOverlay';
|
import HoverOverlay from './filePreview/HoverOverlay';
|
||||||
@@ -8,7 +8,7 @@ import NavigationArrows from './filePreview/NavigationArrows';
|
|||||||
|
|
||||||
export interface FilePreviewProps {
|
export interface FilePreviewProps {
|
||||||
// Core file data
|
// Core file data
|
||||||
file: File | FileWithUrl | null;
|
file: File | FileMetadata | null;
|
||||||
thumbnail?: string | null;
|
thumbnail?: string | null;
|
||||||
|
|
||||||
// Optional features
|
// Optional features
|
||||||
@@ -21,7 +21,7 @@ export interface FilePreviewProps {
|
|||||||
isAnimating?: boolean;
|
isAnimating?: boolean;
|
||||||
|
|
||||||
// Event handlers
|
// Event handlers
|
||||||
onFileClick?: (file: File | FileWithUrl | null) => void;
|
onFileClick?: (file: File | FileMetadata | null) => void;
|
||||||
onPrevious?: () => void;
|
onPrevious?: () => void;
|
||||||
onNext?: () => void;
|
onNext?: () => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const LandingPage = () => {
|
|||||||
{/* White PDF Page Background */}
|
{/* White PDF Page Background */}
|
||||||
<Dropzone
|
<Dropzone
|
||||||
onDrop={handleFileDrop}
|
onDrop={handleFileDrop}
|
||||||
accept={["*/*"] as any}
|
accept={["application/pdf", "application/zip", "application/x-zip-compressed"]}
|
||||||
multiple={true}
|
multiple={true}
|
||||||
className="w-4/5 flex items-center justify-center h-[95vh]"
|
className="w-4/5 flex items-center justify-center h-[95vh]"
|
||||||
style={{
|
style={{
|
||||||
@@ -125,7 +125,7 @@ const LandingPage = () => {
|
|||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
multiple
|
multiple
|
||||||
accept="*/*"
|
accept=".pdf,.zip"
|
||||||
onChange={handleFileSelect}
|
onChange={handleFileSelect}
|
||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Modal, Text, Button, Group, Stack } from '@mantine/core';
|
import { Modal, Text, Button, Group, Stack } from '@mantine/core';
|
||||||
import { useFileContext } from '../../contexts/FileContext';
|
import { useNavigationGuard } from '../../contexts/NavigationContext';
|
||||||
|
|
||||||
interface NavigationWarningModalProps {
|
interface NavigationWarningModalProps {
|
||||||
onApplyAndContinue?: () => Promise<void>;
|
onApplyAndContinue?: () => Promise<void>;
|
||||||
@@ -14,10 +14,10 @@ const NavigationWarningModal = ({
|
|||||||
const {
|
const {
|
||||||
showNavigationWarning,
|
showNavigationWarning,
|
||||||
hasUnsavedChanges,
|
hasUnsavedChanges,
|
||||||
confirmNavigation,
|
|
||||||
cancelNavigation,
|
cancelNavigation,
|
||||||
|
confirmNavigation,
|
||||||
setHasUnsavedChanges
|
setHasUnsavedChanges
|
||||||
} = useFileContext();
|
} = useNavigationGuard();
|
||||||
|
|
||||||
const handleKeepWorking = () => {
|
const handleKeepWorking = () => {
|
||||||
cancelNavigation();
|
cancelNavigation();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useCallback } from "react";
|
import React, { useState, useCallback, useMemo } from "react";
|
||||||
import { Button, SegmentedControl, Loader } 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";
|
||||||
@@ -10,50 +10,18 @@ import VisibilityIcon from "@mui/icons-material/Visibility";
|
|||||||
import EditNoteIcon from "@mui/icons-material/EditNote";
|
import EditNoteIcon from "@mui/icons-material/EditNote";
|
||||||
import FolderIcon from "@mui/icons-material/Folder";
|
import FolderIcon from "@mui/icons-material/Folder";
|
||||||
import { Group } from "@mantine/core";
|
import { Group } from "@mantine/core";
|
||||||
|
import { ModeType } from '../../contexts/NavigationContext';
|
||||||
|
|
||||||
// This will be created inside the component to access switchingTo
|
// Stable view option objects that don't recreate on every render
|
||||||
const createViewOptions = (switchingTo: string | null) => [
|
const VIEW_OPTIONS_BASE = [
|
||||||
{
|
{ value: "viewer", icon: VisibilityIcon },
|
||||||
label: (
|
{ value: "pageEditor", icon: EditNoteIcon },
|
||||||
<Group gap={5}>
|
{ value: "fileEditor", icon: FolderIcon },
|
||||||
{switchingTo === "viewer" ? (
|
] as const;
|
||||||
<Loader size="xs" />
|
|
||||||
) : (
|
|
||||||
<VisibilityIcon fontSize="small" />
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
),
|
|
||||||
value: "viewer",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: (
|
|
||||||
<Group gap={4}>
|
|
||||||
{switchingTo === "pageEditor" ? (
|
|
||||||
<Loader size="xs" />
|
|
||||||
) : (
|
|
||||||
<EditNoteIcon fontSize="small" />
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
),
|
|
||||||
value: "pageEditor",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: (
|
|
||||||
<Group gap={4}>
|
|
||||||
{switchingTo === "fileEditor" ? (
|
|
||||||
<Loader size="xs" />
|
|
||||||
) : (
|
|
||||||
<FolderIcon fontSize="small" />
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
),
|
|
||||||
value: "fileEditor",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
interface TopControlsProps {
|
interface TopControlsProps {
|
||||||
currentView: string;
|
currentView: ModeType;
|
||||||
setCurrentView: (view: string) => void;
|
setCurrentView: (view: ModeType) => void;
|
||||||
selectedToolKey?: string | null;
|
selectedToolKey?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +36,9 @@ const TopControls = ({
|
|||||||
const isToolSelected = selectedToolKey !== null;
|
const isToolSelected = selectedToolKey !== null;
|
||||||
|
|
||||||
const handleViewChange = useCallback((view: string) => {
|
const handleViewChange = useCallback((view: string) => {
|
||||||
|
// Guard against redundant changes
|
||||||
|
if (view === currentView) return;
|
||||||
|
|
||||||
// Show immediate feedback
|
// Show immediate feedback
|
||||||
setSwitchingTo(view);
|
setSwitchingTo(view);
|
||||||
|
|
||||||
@@ -75,13 +46,28 @@ const TopControls = ({
|
|||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
// Give the spinner one more frame to show
|
// Give the spinner one more frame to show
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
setCurrentView(view);
|
setCurrentView(view as ModeType);
|
||||||
|
|
||||||
// Clear the loading state after view change completes
|
// Clear the loading state after view change completes
|
||||||
setTimeout(() => setSwitchingTo(null), 300);
|
setTimeout(() => setSwitchingTo(null), 300);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}, [setCurrentView]);
|
}, [setCurrentView, currentView]);
|
||||||
|
|
||||||
|
// Memoize the SegmentedControl data with stable references
|
||||||
|
const viewOptions = useMemo(() =>
|
||||||
|
VIEW_OPTIONS_BASE.map(option => ({
|
||||||
|
value: option.value,
|
||||||
|
label: (
|
||||||
|
<Group gap={option.value === "viewer" ? 5 : 4}>
|
||||||
|
{switchingTo === option.value ? (
|
||||||
|
<Loader size="xs" />
|
||||||
|
) : (
|
||||||
|
<option.icon fontSize="small" />
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
)
|
||||||
|
})), [switchingTo]);
|
||||||
|
|
||||||
const getThemeIcon = () => {
|
const getThemeIcon = () => {
|
||||||
if (isRainbowMode) return <AutoAwesomeIcon className={rainbowStyles.rainbowText} />;
|
if (isRainbowMode) return <AutoAwesomeIcon className={rainbowStyles.rainbowText} />;
|
||||||
@@ -117,7 +103,7 @@ const TopControls = ({
|
|||||||
{!isToolSelected && (
|
{!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={createViewOptions(switchingTo)}
|
data={viewOptions}
|
||||||
value={currentView}
|
value={currentView}
|
||||||
onChange={handleViewChange}
|
onChange={handleViewChange}
|
||||||
color="blue"
|
color="blue"
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, Center, Image } from '@mantine/core';
|
import { Box, Center, Image } from '@mantine/core';
|
||||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||||
import { FileWithUrl } from '../../../types/file';
|
import { FileMetadata } from '../../../types/file';
|
||||||
|
|
||||||
export interface DocumentThumbnailProps {
|
export interface DocumentThumbnailProps {
|
||||||
file: File | FileWithUrl | null;
|
file: File | FileMetadata | null;
|
||||||
thumbnail?: string | null;
|
thumbnail?: string | null;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { useMultipleEndpointsEnabled } from "../../../hooks/useEndpointConfig";
|
import { useMultipleEndpointsEnabled } from "../../../hooks/useEndpointConfig";
|
||||||
import { isImageFormat, isWebFormat } from "../../../utils/convertUtils";
|
import { isImageFormat, isWebFormat } from "../../../utils/convertUtils";
|
||||||
import { getConversionEndpoints } from "../../../data/toolsTaxonomy";
|
import { getConversionEndpoints } from "../../../data/toolsTaxonomy";
|
||||||
import { useFileSelectionActions } from "../../../contexts/FileSelectionContext";
|
import { useFileSelection } from "../../../contexts/FileContext";
|
||||||
import { useFileContext } from "../../../contexts/FileContext";
|
import { useFileState } from "../../../contexts/FileContext";
|
||||||
import { detectFileExtension } from "../../../utils/fileUtils";
|
import { detectFileExtension } from "../../../utils/fileUtils";
|
||||||
import GroupedFormatDropdown from "./GroupedFormatDropdown";
|
import GroupedFormatDropdown from "./GroupedFormatDropdown";
|
||||||
import ConvertToImageSettings from "./ConvertToImageSettings";
|
import ConvertToImageSettings from "./ConvertToImageSettings";
|
||||||
@@ -41,8 +41,9 @@ const ConvertSettings = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const theme = useMantineTheme();
|
const theme = useMantineTheme();
|
||||||
const { colorScheme } = useMantineColorScheme();
|
const { colorScheme } = useMantineColorScheme();
|
||||||
const { setSelectedFiles } = useFileSelectionActions();
|
const { setSelectedFiles } = useFileSelection();
|
||||||
const { activeFiles, setSelectedFiles: setContextSelectedFiles } = useFileContext();
|
const { state, selectors } = useFileState();
|
||||||
|
const activeFiles = state.files.ids;
|
||||||
|
|
||||||
const allEndpoints = useMemo(() => getConversionEndpoints(EXTENSION_TO_ENDPOINT), []);
|
const allEndpoints = useMemo(() => getConversionEndpoints(EXTENSION_TO_ENDPOINT), []);
|
||||||
|
|
||||||
@@ -85,7 +86,7 @@ const ConvertSettings = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return baseOptions;
|
return baseOptions;
|
||||||
}, [getAvailableToExtensions, endpointStatus, parameters.fromExtension]);
|
}, [parameters.fromExtension, endpointStatus]);
|
||||||
|
|
||||||
// Enhanced TO options with endpoint availability
|
// Enhanced TO options with endpoint availability
|
||||||
const enhancedToOptions = useMemo(() => {
|
const enhancedToOptions = useMemo(() => {
|
||||||
@@ -96,7 +97,7 @@ const ConvertSettings = ({
|
|||||||
...option,
|
...option,
|
||||||
enabled: isConversionAvailable(parameters.fromExtension, option.value)
|
enabled: isConversionAvailable(parameters.fromExtension, option.value)
|
||||||
}));
|
}));
|
||||||
}, [parameters.fromExtension, getAvailableToExtensions, endpointStatus]);
|
}, [parameters.fromExtension, endpointStatus]);
|
||||||
|
|
||||||
const resetParametersToDefaults = () => {
|
const resetParametersToDefaults = () => {
|
||||||
onParameterChange('imageOptions', {
|
onParameterChange('imageOptions', {
|
||||||
@@ -127,7 +128,8 @@ const ConvertSettings = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const filterFilesByExtension = (extension: string) => {
|
const filterFilesByExtension = (extension: string) => {
|
||||||
return activeFiles.filter(file => {
|
const files = activeFiles.map(fileId => selectors.getFile(fileId)).filter(Boolean) as File[];
|
||||||
|
return files.filter(file => {
|
||||||
const fileExtension = detectFileExtension(file.name);
|
const fileExtension = detectFileExtension(file.name);
|
||||||
|
|
||||||
if (extension === 'any') {
|
if (extension === 'any') {
|
||||||
@@ -141,9 +143,21 @@ const ConvertSettings = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const updateFileSelection = (files: File[]) => {
|
const updateFileSelection = (files: File[]) => {
|
||||||
setSelectedFiles(files);
|
// Map File objects to their actual IDs in FileContext
|
||||||
const fileIds = files.map(file => (file as any).id || file.name);
|
const fileIds = files.map(file => {
|
||||||
setContextSelectedFiles(fileIds);
|
// Find the file ID by matching file properties
|
||||||
|
const fileRecord = state.files.ids
|
||||||
|
.map(id => selectors.getFileRecord(id))
|
||||||
|
.find(record =>
|
||||||
|
record &&
|
||||||
|
record.name === file.name &&
|
||||||
|
record.size === file.size &&
|
||||||
|
record.lastModified === file.lastModified
|
||||||
|
);
|
||||||
|
return fileRecord?.id;
|
||||||
|
}).filter((id): id is string => id !== undefined); // Type guard to ensure only strings
|
||||||
|
|
||||||
|
setSelectedFiles(fileIds);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFromExtensionChange = (value: string) => {
|
const handleFromExtensionChange = (value: string) => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useState, useRef, useCallback } from "react";
|
import React, { useEffect, useState, useRef, useCallback } from "react";
|
||||||
import { Paper, Stack, Text, ScrollArea, Loader, Center, Button, Group, NumberInput, useMantineTheme, ActionIcon, Box, Tabs } from "@mantine/core";
|
import { Paper, Stack, Text, ScrollArea, Loader, Center, Button, Group, NumberInput, useMantineTheme, ActionIcon, Box, Tabs } from "@mantine/core";
|
||||||
import { getDocument, GlobalWorkerOptions } from "pdfjs-dist";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { pdfWorkerManager } from "../../services/pdfWorkerManager";
|
||||||
import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew";
|
import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew";
|
||||||
import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos";
|
import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos";
|
||||||
import FirstPageIcon from "@mui/icons-material/FirstPage";
|
import FirstPageIcon from "@mui/icons-material/FirstPage";
|
||||||
@@ -13,10 +13,9 @@ import CloseIcon from "@mui/icons-material/Close";
|
|||||||
import { useLocalStorage } from "@mantine/hooks";
|
import { useLocalStorage } from "@mantine/hooks";
|
||||||
import { fileStorage } from "../../services/fileStorage";
|
import { fileStorage } from "../../services/fileStorage";
|
||||||
import SkeletonLoader from '../shared/SkeletonLoader';
|
import SkeletonLoader from '../shared/SkeletonLoader';
|
||||||
import { useFileContext } from "../../contexts/FileContext";
|
import { useFileState, useFileActions, useCurrentFile } from "../../contexts/FileContext";
|
||||||
import { useFileWithUrl } from "../../hooks/useFileWithUrl";
|
import { useFileWithUrl } from "../../hooks/useFileWithUrl";
|
||||||
|
|
||||||
GlobalWorkerOptions.workerSrc = "/pdf.worker.js";
|
|
||||||
|
|
||||||
// Lazy loading page image component
|
// Lazy loading page image component
|
||||||
interface LazyPageImageProps {
|
interface LazyPageImageProps {
|
||||||
@@ -150,7 +149,15 @@ const Viewer = ({
|
|||||||
const theme = useMantineTheme();
|
const theme = useMantineTheme();
|
||||||
|
|
||||||
// Get current file from FileContext
|
// Get current file from FileContext
|
||||||
const { getCurrentFile, getCurrentProcessedFile, clearAllFiles, addFiles, activeFiles } = useFileContext();
|
const { selectors } = useFileState();
|
||||||
|
const { actions } = useFileActions();
|
||||||
|
const currentFile = useCurrentFile();
|
||||||
|
|
||||||
|
const getCurrentFile = () => currentFile.file;
|
||||||
|
const getCurrentProcessedFile = () => currentFile.record?.processedFile || undefined;
|
||||||
|
const clearAllFiles = actions.clearAllFiles;
|
||||||
|
const addFiles = actions.addFiles;
|
||||||
|
const activeFiles = selectors.getFiles();
|
||||||
|
|
||||||
// Tab management for multiple files
|
// Tab management for multiple files
|
||||||
const [activeTab, setActiveTab] = useState<string>("0");
|
const [activeTab, setActiveTab] = useState<string>("0");
|
||||||
@@ -171,6 +178,10 @@ const Viewer = ({
|
|||||||
const [zoom, setZoom] = useState(1); // 1 = 100%
|
const [zoom, setZoom] = useState(1); // 1 = 100%
|
||||||
const pageRefs = useRef<(HTMLImageElement | null)[]>([]);
|
const pageRefs = useRef<(HTMLImageElement | null)[]>([]);
|
||||||
|
|
||||||
|
// Memoize setPageRef to prevent infinite re-renders
|
||||||
|
const setPageRef = useCallback((index: number, ref: HTMLImageElement | null) => {
|
||||||
|
pageRefs.current[index] = ref;
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Get files with URLs for tabs - we'll need to create these individually
|
// Get files with URLs for tabs - we'll need to create these individually
|
||||||
const file0WithUrl = useFileWithUrl(activeFiles[0]);
|
const file0WithUrl = useFileWithUrl(activeFiles[0]);
|
||||||
@@ -385,7 +396,7 @@ const Viewer = ({
|
|||||||
throw new Error('No valid PDF source available');
|
throw new Error('No valid PDF source available');
|
||||||
}
|
}
|
||||||
|
|
||||||
const pdf = await getDocument(pdfData).promise;
|
const pdf = await pdfWorkerManager.createDocument(pdfData);
|
||||||
pdfDocRef.current = pdf;
|
pdfDocRef.current = pdf;
|
||||||
setNumPages(pdf.numPages);
|
setNumPages(pdf.numPages);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@@ -406,6 +417,11 @@ const Viewer = ({
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
// Stop any ongoing preloading
|
// Stop any ongoing preloading
|
||||||
preloadingRef.current = false;
|
preloadingRef.current = false;
|
||||||
|
// Cleanup PDF document using worker manager
|
||||||
|
if (pdfDocRef.current) {
|
||||||
|
pdfWorkerManager.destroyDocument(pdfDocRef.current);
|
||||||
|
pdfDocRef.current = null;
|
||||||
|
}
|
||||||
// Cleanup ArrayBuffer reference to help garbage collection
|
// Cleanup ArrayBuffer reference to help garbage collection
|
||||||
currentArrayBufferRef.current = null;
|
currentArrayBufferRef.current = null;
|
||||||
};
|
};
|
||||||
@@ -461,7 +477,7 @@ const Viewer = ({
|
|||||||
>
|
>
|
||||||
<Tabs value={activeTab} onChange={(value) => handleTabChange(value || "0")}>
|
<Tabs value={activeTab} onChange={(value) => handleTabChange(value || "0")}>
|
||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
{activeFiles.map((file, index) => (
|
{activeFiles.map((file: any, index: number) => (
|
||||||
<Tabs.Tab key={index} value={index.toString()}>
|
<Tabs.Tab key={index} value={index.toString()}>
|
||||||
{file.name.length > 20 ? `${file.name.substring(0, 20)}...` : file.name}
|
{file.name.length > 20 ? `${file.name.substring(0, 20)}...` : file.name}
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
@@ -494,7 +510,7 @@ const Viewer = ({
|
|||||||
isFirst={i === 0}
|
isFirst={i === 0}
|
||||||
renderPage={renderPage}
|
renderPage={renderPage}
|
||||||
pageImages={pageImages}
|
pageImages={pageImages}
|
||||||
setPageRef={(index, ref) => { pageRefs.current[index] = ref; }}
|
setPageRef={setPageRef}
|
||||||
/>
|
/>
|
||||||
{i * 2 + 1 < numPages && (
|
{i * 2 + 1 < numPages && (
|
||||||
<LazyPageImage
|
<LazyPageImage
|
||||||
@@ -504,7 +520,7 @@ const Viewer = ({
|
|||||||
isFirst={i === 0}
|
isFirst={i === 0}
|
||||||
renderPage={renderPage}
|
renderPage={renderPage}
|
||||||
pageImages={pageImages}
|
pageImages={pageImages}
|
||||||
setPageRef={(index, ref) => { pageRefs.current[index] = ref; }}
|
setPageRef={setPageRef}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
@@ -518,7 +534,7 @@ const Viewer = ({
|
|||||||
isFirst={idx === 0}
|
isFirst={idx === 0}
|
||||||
renderPage={renderPage}
|
renderPage={renderPage}
|
||||||
pageImages={pageImages}
|
pageImages={pageImages}
|
||||||
setPageRef={(index, ref) => { pageRefs.current[index] = ref; }}
|
setPageRef={setPageRef}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
import React, { createContext, useContext, useState, useRef, useCallback, useEffect } from 'react';
|
import React, { createContext, useContext, useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
||||||
import { FileWithUrl } from '../types/file';
|
import { FileMetadata } from '../types/file';
|
||||||
import { StoredFile, fileStorage } from '../services/fileStorage';
|
import { StoredFile, fileStorage } from '../services/fileStorage';
|
||||||
import { downloadFiles } from '../utils/downloadUtils';
|
import { downloadFiles } from '../utils/downloadUtils';
|
||||||
|
|
||||||
@@ -9,27 +9,27 @@ interface FileManagerContextValue {
|
|||||||
activeSource: 'recent' | 'local' | 'drive';
|
activeSource: 'recent' | 'local' | 'drive';
|
||||||
selectedFileIds: string[];
|
selectedFileIds: string[];
|
||||||
searchTerm: string;
|
searchTerm: string;
|
||||||
selectedFiles: FileWithUrl[];
|
selectedFiles: FileMetadata[];
|
||||||
filteredFiles: FileWithUrl[];
|
filteredFiles: FileMetadata[];
|
||||||
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
||||||
selectedFilesSet: Set<string>;
|
selectedFilesSet: Set<string>;
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
onSourceChange: (source: 'recent' | 'local' | 'drive') => void;
|
onSourceChange: (source: 'recent' | 'local' | 'drive') => void;
|
||||||
onLocalFileClick: () => void;
|
onLocalFileClick: () => void;
|
||||||
onFileSelect: (file: FileWithUrl, index: number, shiftKey?: boolean) => void;
|
onFileSelect: (file: FileMetadata, index: number, shiftKey?: boolean) => void;
|
||||||
onFileRemove: (index: number) => void;
|
onFileRemove: (index: number) => void;
|
||||||
onFileDoubleClick: (file: FileWithUrl) => void;
|
onFileDoubleClick: (file: FileMetadata) => void;
|
||||||
onOpenFiles: () => void;
|
onOpenFiles: () => void;
|
||||||
onSearchChange: (value: string) => void;
|
onSearchChange: (value: string) => void;
|
||||||
onFileInputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
onFileInputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
onSelectAll: () => void;
|
onSelectAll: () => void;
|
||||||
onDeleteSelected: () => void;
|
onDeleteSelected: () => void;
|
||||||
onDownloadSelected: () => void;
|
onDownloadSelected: () => void;
|
||||||
onDownloadSingle: (file: FileWithUrl) => void;
|
onDownloadSingle: (file: FileMetadata) => void;
|
||||||
|
|
||||||
// External props
|
// External props
|
||||||
recentFiles: FileWithUrl[];
|
recentFiles: FileMetadata[];
|
||||||
isFileSupported: (fileName: string) => boolean;
|
isFileSupported: (fileName: string) => boolean;
|
||||||
modalHeight: string;
|
modalHeight: string;
|
||||||
}
|
}
|
||||||
@@ -40,14 +40,14 @@ const FileManagerContext = createContext<FileManagerContextValue | null>(null);
|
|||||||
// Provider component props
|
// Provider component props
|
||||||
interface FileManagerProviderProps {
|
interface FileManagerProviderProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
recentFiles: FileWithUrl[];
|
recentFiles: FileMetadata[];
|
||||||
onFilesSelected: (files: FileWithUrl[]) => void;
|
onFilesSelected: (files: FileMetadata[]) => void; // For selecting stored files
|
||||||
|
onNewFilesSelect: (files: File[]) => void; // For uploading new local files
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
isFileSupported: (fileName: string) => boolean;
|
isFileSupported: (fileName: string) => boolean;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onFileRemove: (index: number) => void;
|
onFileRemove: (index: number) => void;
|
||||||
modalHeight: string;
|
modalHeight: string;
|
||||||
storeFile: (file: File) => Promise<StoredFile>;
|
|
||||||
refreshRecentFiles: () => Promise<void>;
|
refreshRecentFiles: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,12 +55,12 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
|||||||
children,
|
children,
|
||||||
recentFiles,
|
recentFiles,
|
||||||
onFilesSelected,
|
onFilesSelected,
|
||||||
|
onNewFilesSelect,
|
||||||
onClose,
|
onClose,
|
||||||
isFileSupported,
|
isFileSupported,
|
||||||
isOpen,
|
isOpen,
|
||||||
onFileRemove,
|
onFileRemove,
|
||||||
modalHeight,
|
modalHeight,
|
||||||
storeFile,
|
|
||||||
refreshRecentFiles,
|
refreshRecentFiles,
|
||||||
}) => {
|
}) => {
|
||||||
const [activeSource, setActiveSource] = useState<'recent' | 'local' | 'drive'>('recent');
|
const [activeSource, setActiveSource] = useState<'recent' | 'local' | 'drive'>('recent');
|
||||||
@@ -76,7 +76,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
|||||||
const selectedFilesSet = new Set(selectedFileIds);
|
const selectedFilesSet = new Set(selectedFileIds);
|
||||||
|
|
||||||
const selectedFiles = selectedFileIds.length === 0 ? [] :
|
const selectedFiles = selectedFileIds.length === 0 ? [] :
|
||||||
(recentFiles || []).filter(file => selectedFilesSet.has(file.id || file.name));
|
(recentFiles || []).filter(file => selectedFilesSet.has(file.id));
|
||||||
|
|
||||||
const filteredFiles = !searchTerm ? recentFiles || [] :
|
const filteredFiles = !searchTerm ? recentFiles || [] :
|
||||||
(recentFiles || []).filter(file =>
|
(recentFiles || []).filter(file =>
|
||||||
@@ -96,8 +96,8 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
|||||||
fileInputRef.current?.click();
|
fileInputRef.current?.click();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleFileSelect = useCallback((file: FileWithUrl, currentIndex: number, shiftKey?: boolean) => {
|
const handleFileSelect = useCallback((file: FileMetadata, currentIndex: number, shiftKey?: boolean) => {
|
||||||
const fileId = file.id || file.name;
|
const fileId = file.id;
|
||||||
if (!fileId) return;
|
if (!fileId) return;
|
||||||
|
|
||||||
if (shiftKey && lastClickedIndex !== null) {
|
if (shiftKey && lastClickedIndex !== null) {
|
||||||
@@ -110,7 +110,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
|||||||
|
|
||||||
// Add all files in the range to selection
|
// Add all files in the range to selection
|
||||||
for (let i = startIndex; i <= endIndex; i++) {
|
for (let i = startIndex; i <= endIndex; i++) {
|
||||||
const rangeFileId = filteredFiles[i]?.id || filteredFiles[i]?.name;
|
const rangeFileId = filteredFiles[i]?.id;
|
||||||
if (rangeFileId) {
|
if (rangeFileId) {
|
||||||
selectedSet.add(rangeFileId);
|
selectedSet.add(rangeFileId);
|
||||||
}
|
}
|
||||||
@@ -145,7 +145,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
|||||||
onFileRemove(index);
|
onFileRemove(index);
|
||||||
}, [filteredFiles, onFileRemove]);
|
}, [filteredFiles, onFileRemove]);
|
||||||
|
|
||||||
const handleFileDoubleClick = useCallback((file: FileWithUrl) => {
|
const handleFileDoubleClick = useCallback((file: FileMetadata) => {
|
||||||
if (isFileSupported(file.name)) {
|
if (isFileSupported(file.name)) {
|
||||||
onFilesSelected([file]);
|
onFilesSelected([file]);
|
||||||
onClose();
|
onClose();
|
||||||
@@ -167,22 +167,8 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
|||||||
const files = Array.from(event.target.files || []);
|
const files = Array.from(event.target.files || []);
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
try {
|
try {
|
||||||
// Create FileWithUrl objects - FileContext will handle storage and ID assignment
|
// For local file uploads, pass File objects directly to FileContext
|
||||||
const fileWithUrls = files.map(file => {
|
onNewFilesSelect(files);
|
||||||
const url = URL.createObjectURL(file);
|
|
||||||
createdBlobUrls.current.add(url);
|
|
||||||
|
|
||||||
return {
|
|
||||||
// No ID assigned here - FileContext will handle storage and ID assignment
|
|
||||||
name: file.name,
|
|
||||||
file,
|
|
||||||
url,
|
|
||||||
size: file.size,
|
|
||||||
lastModified: file.lastModified,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
onFilesSelected(fileWithUrls as any /* FIX ME */);
|
|
||||||
await refreshRecentFiles();
|
await refreshRecentFiles();
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -190,7 +176,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
event.target.value = '';
|
event.target.value = '';
|
||||||
}, [storeFile, onFilesSelected, refreshRecentFiles, onClose]);
|
}, [onNewFilesSelect, refreshRecentFiles, onClose]);
|
||||||
|
|
||||||
const handleSelectAll = useCallback(() => {
|
const handleSelectAll = useCallback(() => {
|
||||||
const allFilesSelected = filteredFiles.length > 0 && selectedFileIds.length === filteredFiles.length;
|
const allFilesSelected = filteredFiles.length > 0 && selectedFileIds.length === filteredFiles.length;
|
||||||
@@ -200,7 +186,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
|||||||
setLastClickedIndex(null);
|
setLastClickedIndex(null);
|
||||||
} else {
|
} else {
|
||||||
// Select all filtered files
|
// Select all filtered files
|
||||||
setSelectedFileIds(filteredFiles.map(file => file.id || file.name));
|
setSelectedFileIds(filteredFiles.map(file => file.id).filter(Boolean));
|
||||||
setLastClickedIndex(null);
|
setLastClickedIndex(null);
|
||||||
}
|
}
|
||||||
}, [filteredFiles, selectedFileIds]);
|
}, [filteredFiles, selectedFileIds]);
|
||||||
@@ -211,13 +197,12 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
|||||||
try {
|
try {
|
||||||
// Get files to delete based on current filtered view
|
// Get files to delete based on current filtered view
|
||||||
const filesToDelete = filteredFiles.filter(file =>
|
const filesToDelete = filteredFiles.filter(file =>
|
||||||
selectedFileIds.includes(file.id || file.name)
|
selectedFileIds.includes(file.id)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Delete files from storage
|
// Delete files from storage
|
||||||
for (const file of filesToDelete) {
|
for (const file of filesToDelete) {
|
||||||
const lookupKey = file.id || file.name;
|
await fileStorage.deleteFile(file.id);
|
||||||
await fileStorage.deleteFile(lookupKey);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear selection
|
// Clear selection
|
||||||
@@ -237,7 +222,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
|||||||
try {
|
try {
|
||||||
// Get selected files
|
// Get selected files
|
||||||
const selectedFilesToDownload = filteredFiles.filter(file =>
|
const selectedFilesToDownload = filteredFiles.filter(file =>
|
||||||
selectedFileIds.includes(file.id || file.name)
|
selectedFileIds.includes(file.id)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Use generic download utility
|
// Use generic download utility
|
||||||
@@ -249,7 +234,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
|||||||
}
|
}
|
||||||
}, [selectedFileIds, filteredFiles]);
|
}, [selectedFileIds, filteredFiles]);
|
||||||
|
|
||||||
const handleDownloadSingle = useCallback(async (file: FileWithUrl) => {
|
const handleDownloadSingle = useCallback(async (file: FileMetadata) => {
|
||||||
try {
|
try {
|
||||||
await downloadFiles([file]);
|
await downloadFiles([file]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -279,7 +264,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
|||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
const contextValue: FileManagerContextValue = {
|
const contextValue: FileManagerContextValue = useMemo(() => ({
|
||||||
// State
|
// State
|
||||||
activeSource,
|
activeSource,
|
||||||
selectedFileIds,
|
selectedFileIds,
|
||||||
@@ -307,7 +292,28 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
|||||||
recentFiles,
|
recentFiles,
|
||||||
isFileSupported,
|
isFileSupported,
|
||||||
modalHeight,
|
modalHeight,
|
||||||
};
|
}), [
|
||||||
|
activeSource,
|
||||||
|
selectedFileIds,
|
||||||
|
searchTerm,
|
||||||
|
selectedFiles,
|
||||||
|
filteredFiles,
|
||||||
|
fileInputRef,
|
||||||
|
handleSourceChange,
|
||||||
|
handleLocalFileClick,
|
||||||
|
handleFileSelect,
|
||||||
|
handleFileRemove,
|
||||||
|
handleFileDoubleClick,
|
||||||
|
handleOpenFiles,
|
||||||
|
handleSearchChange,
|
||||||
|
handleFileInputChange,
|
||||||
|
handleSelectAll,
|
||||||
|
handleDeleteSelected,
|
||||||
|
handleDownloadSelected,
|
||||||
|
recentFiles,
|
||||||
|
isFileSupported,
|
||||||
|
modalHeight,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FileManagerContext.Provider value={contextValue}>
|
<FileManagerContext.Provider value={contextValue}>
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react';
|
|
||||||
import {
|
|
||||||
MaxFiles,
|
|
||||||
FileSelectionContextValue
|
|
||||||
} from '../types/tool';
|
|
||||||
import { useFileContext } from './FileContext';
|
|
||||||
|
|
||||||
interface FileSelectionProviderProps {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FileSelectionContext = createContext<FileSelectionContextValue | undefined>(undefined);
|
|
||||||
|
|
||||||
export function FileSelectionProvider({ children }: FileSelectionProviderProps) {
|
|
||||||
const { activeFiles } = useFileContext();
|
|
||||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
|
||||||
const [maxFiles, setMaxFiles] = useState<MaxFiles>(-1);
|
|
||||||
const [isToolMode, setIsToolMode] = useState<boolean>(false);
|
|
||||||
|
|
||||||
// Sync selected files with active files - remove any selected files that are no longer active
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedFiles.length > 0) {
|
|
||||||
const activeFileSet = new Set(activeFiles);
|
|
||||||
const validSelectedFiles = selectedFiles.filter(file => activeFileSet.has(file));
|
|
||||||
|
|
||||||
if (validSelectedFiles.length !== selectedFiles.length) {
|
|
||||||
setSelectedFiles(validSelectedFiles);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [activeFiles, selectedFiles]);
|
|
||||||
|
|
||||||
const clearSelection = useCallback(() => {
|
|
||||||
setSelectedFiles([]);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const selectionCount = selectedFiles.length;
|
|
||||||
const canSelectMore = maxFiles === -1 || selectionCount < maxFiles;
|
|
||||||
const isAtLimit = maxFiles > 0 && selectionCount >= maxFiles;
|
|
||||||
const isMultiFileMode = maxFiles !== 1;
|
|
||||||
|
|
||||||
const contextValue: FileSelectionContextValue = {
|
|
||||||
selectedFiles,
|
|
||||||
maxFiles,
|
|
||||||
isToolMode,
|
|
||||||
setSelectedFiles,
|
|
||||||
setMaxFiles,
|
|
||||||
setIsToolMode,
|
|
||||||
clearSelection,
|
|
||||||
canSelectMore,
|
|
||||||
isAtLimit,
|
|
||||||
selectionCount,
|
|
||||||
isMultiFileMode
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FileSelectionContext.Provider value={contextValue}>
|
|
||||||
{children}
|
|
||||||
</FileSelectionContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Access the file selection context.
|
|
||||||
* Throws if used outside a <FileSelectionProvider>.
|
|
||||||
*/
|
|
||||||
export function useFileSelection(): FileSelectionContextValue {
|
|
||||||
const context = useContext(FileSelectionContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useFileSelection must be used within a FileSelectionProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns only the file selection values relevant for tools (e.g. merge, split, etc.)
|
|
||||||
// Use this in tool panels/components that need to know which files are selected and selection limits.
|
|
||||||
export function useToolFileSelection(): Pick<FileSelectionContextValue, 'selectedFiles' | 'maxFiles' | 'canSelectMore' | 'isAtLimit' | 'selectionCount'> {
|
|
||||||
const { selectedFiles, maxFiles, canSelectMore, isAtLimit, selectionCount } = useFileSelection();
|
|
||||||
return { selectedFiles, maxFiles, canSelectMore, isAtLimit, selectionCount };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns actions for manipulating file selection state.
|
|
||||||
// Use this in components that need to update the selection, clear it, or change selection mode.
|
|
||||||
export function useFileSelectionActions(): Pick<FileSelectionContextValue, 'setSelectedFiles' | 'clearSelection' | 'setMaxFiles' | 'setIsToolMode'> {
|
|
||||||
const { setSelectedFiles, clearSelection, setMaxFiles, setIsToolMode } = useFileSelection();
|
|
||||||
return { setSelectedFiles, clearSelection, setMaxFiles, setIsToolMode };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns the raw file selection state (selected files, max files, tool mode).
|
|
||||||
// Use this for low-level state access, e.g. in context-aware UI.
|
|
||||||
export function useFileSelectionState(): Pick<FileSelectionContextValue, 'selectedFiles' | 'maxFiles' | 'isToolMode'> {
|
|
||||||
const { selectedFiles, maxFiles, isToolMode } = useFileSelection();
|
|
||||||
return { selectedFiles, maxFiles, isToolMode };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns computed values derived from file selection state.
|
|
||||||
// Use this for file selection UI logic (e.g. disabling buttons when at limit).
|
|
||||||
export function useFileSelectionComputed(): Pick<FileSelectionContextValue, 'canSelectMore' | 'isAtLimit' | 'selectionCount' | 'isMultiFileMode'> {
|
|
||||||
const { canSelectMore, isAtLimit, selectionCount, isMultiFileMode } = useFileSelection();
|
|
||||||
return { canSelectMore, isAtLimit, selectionCount, isMultiFileMode };
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { createContext, useContext, useState, useCallback } from 'react';
|
import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
|
||||||
import { useFileHandler } from '../hooks/useFileHandler';
|
import { useFileHandler } from '../hooks/useFileHandler';
|
||||||
|
import { FileMetadata } from '../types/file';
|
||||||
|
|
||||||
interface FilesModalContextType {
|
interface FilesModalContextType {
|
||||||
isFilesModalOpen: boolean;
|
isFilesModalOpen: boolean;
|
||||||
@@ -7,6 +8,7 @@ interface FilesModalContextType {
|
|||||||
closeFilesModal: () => void;
|
closeFilesModal: () => void;
|
||||||
onFileSelect: (file: File) => void;
|
onFileSelect: (file: File) => void;
|
||||||
onFilesSelect: (files: File[]) => void;
|
onFilesSelect: (files: File[]) => void;
|
||||||
|
onStoredFilesSelect: (filesWithMetadata: Array<{ file: File; originalId: string; metadata: FileMetadata }>) => void;
|
||||||
onModalClose?: () => void;
|
onModalClose?: () => void;
|
||||||
setOnModalClose: (callback: () => void) => void;
|
setOnModalClose: (callback: () => void) => void;
|
||||||
}
|
}
|
||||||
@@ -14,7 +16,7 @@ interface FilesModalContextType {
|
|||||||
const FilesModalContext = createContext<FilesModalContextType | null>(null);
|
const FilesModalContext = createContext<FilesModalContextType | null>(null);
|
||||||
|
|
||||||
export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
const { addToActiveFiles, addMultipleFiles } = useFileHandler();
|
const { addToActiveFiles, addMultipleFiles, addStoredFiles } = useFileHandler();
|
||||||
const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
|
const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
|
||||||
const [onModalClose, setOnModalClose] = useState<(() => void) | undefined>();
|
const [onModalClose, setOnModalClose] = useState<(() => void) | undefined>();
|
||||||
|
|
||||||
@@ -37,19 +39,34 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch
|
|||||||
closeFilesModal();
|
closeFilesModal();
|
||||||
}, [addMultipleFiles, closeFilesModal]);
|
}, [addMultipleFiles, closeFilesModal]);
|
||||||
|
|
||||||
|
const handleStoredFilesSelect = useCallback((filesWithMetadata: Array<{ file: File; originalId: string; metadata: FileMetadata }>) => {
|
||||||
|
addStoredFiles(filesWithMetadata);
|
||||||
|
closeFilesModal();
|
||||||
|
}, [addStoredFiles, closeFilesModal]);
|
||||||
|
|
||||||
const setModalCloseCallback = useCallback((callback: () => void) => {
|
const setModalCloseCallback = useCallback((callback: () => void) => {
|
||||||
setOnModalClose(() => callback);
|
setOnModalClose(() => callback);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const contextValue: FilesModalContextType = {
|
const contextValue: FilesModalContextType = useMemo(() => ({
|
||||||
isFilesModalOpen,
|
isFilesModalOpen,
|
||||||
openFilesModal,
|
openFilesModal,
|
||||||
closeFilesModal,
|
closeFilesModal,
|
||||||
onFileSelect: handleFileSelect,
|
onFileSelect: handleFileSelect,
|
||||||
onFilesSelect: handleFilesSelect,
|
onFilesSelect: handleFilesSelect,
|
||||||
|
onStoredFilesSelect: handleStoredFilesSelect,
|
||||||
onModalClose,
|
onModalClose,
|
||||||
setOnModalClose: setModalCloseCallback,
|
setOnModalClose: setModalCloseCallback,
|
||||||
};
|
}), [
|
||||||
|
isFilesModalOpen,
|
||||||
|
openFilesModal,
|
||||||
|
closeFilesModal,
|
||||||
|
handleFileSelect,
|
||||||
|
handleFilesSelect,
|
||||||
|
handleStoredFilesSelect,
|
||||||
|
onModalClose,
|
||||||
|
setModalCloseCallback,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FilesModalContext.Provider value={contextValue}>
|
<FilesModalContext.Provider value={contextValue}>
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* IndexedDBContext - Clean persistence layer for file storage
|
||||||
|
* Integrates with FileContext to provide transparent file persistence
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useCallback, useRef } from 'react';
|
||||||
|
|
||||||
|
const DEBUG = process.env.NODE_ENV === 'development';
|
||||||
|
import { fileStorage, StoredFile } from '../services/fileStorage';
|
||||||
|
import { FileId } from '../types/fileContext';
|
||||||
|
import { FileMetadata } from '../types/file';
|
||||||
|
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
|
||||||
|
|
||||||
|
interface IndexedDBContextValue {
|
||||||
|
// Core CRUD operations
|
||||||
|
saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<FileMetadata>;
|
||||||
|
loadFile: (fileId: FileId) => Promise<File | null>;
|
||||||
|
loadMetadata: (fileId: FileId) => Promise<FileMetadata | null>;
|
||||||
|
deleteFile: (fileId: FileId) => Promise<void>;
|
||||||
|
|
||||||
|
// Batch operations
|
||||||
|
loadAllMetadata: () => Promise<FileMetadata[]>;
|
||||||
|
deleteMultiple: (fileIds: FileId[]) => Promise<void>;
|
||||||
|
clearAll: () => Promise<void>;
|
||||||
|
|
||||||
|
// Utilities
|
||||||
|
getStorageStats: () => Promise<{ used: number; available: number; fileCount: number }>;
|
||||||
|
updateThumbnail: (fileId: FileId, thumbnail: string) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IndexedDBContext = createContext<IndexedDBContextValue | null>(null);
|
||||||
|
|
||||||
|
interface IndexedDBProviderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
||||||
|
// LRU File cache to avoid repeated ArrayBuffer→File conversions
|
||||||
|
const fileCache = useRef(new Map<FileId, { file: File; lastAccessed: number }>());
|
||||||
|
const MAX_CACHE_SIZE = 50; // Maximum number of files to cache
|
||||||
|
|
||||||
|
// LRU cache management
|
||||||
|
const evictLRUEntries = useCallback(() => {
|
||||||
|
if (fileCache.current.size <= MAX_CACHE_SIZE) return;
|
||||||
|
|
||||||
|
// Convert to array and sort by last accessed time (oldest first)
|
||||||
|
const entries = Array.from(fileCache.current.entries())
|
||||||
|
.sort(([, a], [, b]) => a.lastAccessed - b.lastAccessed);
|
||||||
|
|
||||||
|
// Remove the least recently used entries
|
||||||
|
const toRemove = entries.slice(0, fileCache.current.size - MAX_CACHE_SIZE);
|
||||||
|
toRemove.forEach(([fileId]) => {
|
||||||
|
fileCache.current.delete(fileId);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (DEBUG) console.log(`🗂️ Evicted ${toRemove.length} LRU cache entries`);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const saveFile = useCallback(async (file: File, fileId: FileId, existingThumbnail?: string): Promise<FileMetadata> => {
|
||||||
|
// Use existing thumbnail or generate new one if none provided
|
||||||
|
const thumbnail = existingThumbnail || await generateThumbnailForFile(file);
|
||||||
|
|
||||||
|
// Store in IndexedDB
|
||||||
|
const storedFile = await fileStorage.storeFile(file, fileId, thumbnail);
|
||||||
|
|
||||||
|
// Cache the file object for immediate reuse
|
||||||
|
fileCache.current.set(fileId, { file, lastAccessed: Date.now() });
|
||||||
|
evictLRUEntries();
|
||||||
|
|
||||||
|
// Return metadata
|
||||||
|
return {
|
||||||
|
id: fileId,
|
||||||
|
name: file.name,
|
||||||
|
type: file.type,
|
||||||
|
size: file.size,
|
||||||
|
lastModified: file.lastModified,
|
||||||
|
thumbnail
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadFile = useCallback(async (fileId: FileId): Promise<File | null> => {
|
||||||
|
// Check cache first
|
||||||
|
const cached = fileCache.current.get(fileId);
|
||||||
|
if (cached) {
|
||||||
|
// Update last accessed time for LRU
|
||||||
|
cached.lastAccessed = Date.now();
|
||||||
|
return cached.file;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load from IndexedDB
|
||||||
|
const storedFile = await fileStorage.getFile(fileId);
|
||||||
|
if (!storedFile) return null;
|
||||||
|
|
||||||
|
// Reconstruct File object
|
||||||
|
const file = new File([storedFile.data], storedFile.name, {
|
||||||
|
type: storedFile.type,
|
||||||
|
lastModified: storedFile.lastModified
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cache for future use with LRU eviction
|
||||||
|
fileCache.current.set(fileId, { file, lastAccessed: Date.now() });
|
||||||
|
evictLRUEntries();
|
||||||
|
|
||||||
|
return file;
|
||||||
|
}, [evictLRUEntries]);
|
||||||
|
|
||||||
|
const loadMetadata = useCallback(async (fileId: FileId): Promise<FileMetadata | null> => {
|
||||||
|
// Try to get from cache first (no IndexedDB hit)
|
||||||
|
const cached = fileCache.current.get(fileId);
|
||||||
|
if (cached) {
|
||||||
|
const file = cached.file;
|
||||||
|
return {
|
||||||
|
id: fileId,
|
||||||
|
name: file.name,
|
||||||
|
type: file.type,
|
||||||
|
size: file.size,
|
||||||
|
lastModified: file.lastModified
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load metadata from IndexedDB (efficient - no data field)
|
||||||
|
const metadata = await fileStorage.getAllFileMetadata();
|
||||||
|
const fileMetadata = metadata.find(m => m.id === fileId);
|
||||||
|
|
||||||
|
if (!fileMetadata) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: fileMetadata.id,
|
||||||
|
name: fileMetadata.name,
|
||||||
|
type: fileMetadata.type,
|
||||||
|
size: fileMetadata.size,
|
||||||
|
lastModified: fileMetadata.lastModified,
|
||||||
|
thumbnail: fileMetadata.thumbnail
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const deleteFile = useCallback(async (fileId: FileId): Promise<void> => {
|
||||||
|
// Remove from cache
|
||||||
|
fileCache.current.delete(fileId);
|
||||||
|
|
||||||
|
// Remove from IndexedDB
|
||||||
|
await fileStorage.deleteFile(fileId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadAllMetadata = useCallback(async (): Promise<FileMetadata[]> => {
|
||||||
|
const metadata = await fileStorage.getAllFileMetadata();
|
||||||
|
|
||||||
|
return metadata.map(m => ({
|
||||||
|
id: m.id,
|
||||||
|
name: m.name,
|
||||||
|
type: m.type,
|
||||||
|
size: m.size,
|
||||||
|
lastModified: m.lastModified,
|
||||||
|
thumbnail: m.thumbnail
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const deleteMultiple = useCallback(async (fileIds: FileId[]): Promise<void> => {
|
||||||
|
// Remove from cache
|
||||||
|
fileIds.forEach(id => fileCache.current.delete(id));
|
||||||
|
|
||||||
|
// Remove from IndexedDB in parallel
|
||||||
|
await Promise.all(fileIds.map(id => fileStorage.deleteFile(id)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearAll = useCallback(async (): Promise<void> => {
|
||||||
|
// Clear cache
|
||||||
|
fileCache.current.clear();
|
||||||
|
|
||||||
|
// Clear IndexedDB
|
||||||
|
await fileStorage.clearAll();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getStorageStats = useCallback(async () => {
|
||||||
|
return await fileStorage.getStorageStats();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateThumbnail = useCallback(async (fileId: FileId, thumbnail: string): Promise<boolean> => {
|
||||||
|
return await fileStorage.updateThumbnail(fileId, thumbnail);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value: IndexedDBContextValue = {
|
||||||
|
saveFile,
|
||||||
|
loadFile,
|
||||||
|
loadMetadata,
|
||||||
|
deleteFile,
|
||||||
|
loadAllMetadata,
|
||||||
|
deleteMultiple,
|
||||||
|
clearAll,
|
||||||
|
getStorageStats,
|
||||||
|
updateThumbnail
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IndexedDBContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</IndexedDBContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useIndexedDB() {
|
||||||
|
const context = useContext(IndexedDBContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useIndexedDB must be used within an IndexedDBProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import React, { createContext, useContext, useReducer, useCallback } from 'react';
|
||||||
|
import { useNavigationUrlSync } from '../hooks/useUrlSync';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NavigationContext - Complete navigation management system
|
||||||
|
*
|
||||||
|
* Handles navigation modes, navigation guards for unsaved changes,
|
||||||
|
* and breadcrumb/history navigation. Separated from FileContext to
|
||||||
|
* maintain clear separation of concerns.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Navigation mode types - complete list to match fileContext.ts
|
||||||
|
export type ModeType =
|
||||||
|
| 'viewer'
|
||||||
|
| 'pageEditor'
|
||||||
|
| 'fileEditor'
|
||||||
|
| 'merge'
|
||||||
|
| 'split'
|
||||||
|
| 'compress'
|
||||||
|
| 'ocr'
|
||||||
|
| 'convert'
|
||||||
|
| 'sanitize'
|
||||||
|
| 'addPassword'
|
||||||
|
| 'changePermissions'
|
||||||
|
| 'addWatermark'
|
||||||
|
| 'removePassword'
|
||||||
|
| 'single-large-page'
|
||||||
|
| 'repair'
|
||||||
|
| 'unlockPdfForms'
|
||||||
|
| 'removeCertificateSign';
|
||||||
|
|
||||||
|
// Navigation state
|
||||||
|
interface NavigationState {
|
||||||
|
currentMode: ModeType;
|
||||||
|
hasUnsavedChanges: boolean;
|
||||||
|
pendingNavigation: (() => void) | null;
|
||||||
|
showNavigationWarning: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigation actions
|
||||||
|
type NavigationAction =
|
||||||
|
| { type: 'SET_MODE'; payload: { mode: ModeType } }
|
||||||
|
| { type: 'SET_UNSAVED_CHANGES'; payload: { hasChanges: boolean } }
|
||||||
|
| { type: 'SET_PENDING_NAVIGATION'; payload: { navigationFn: (() => void) | null } }
|
||||||
|
| { type: 'SHOW_NAVIGATION_WARNING'; payload: { show: boolean } };
|
||||||
|
|
||||||
|
// Navigation reducer
|
||||||
|
const navigationReducer = (state: NavigationState, action: NavigationAction): NavigationState => {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'SET_MODE':
|
||||||
|
return { ...state, currentMode: action.payload.mode };
|
||||||
|
|
||||||
|
case 'SET_UNSAVED_CHANGES':
|
||||||
|
return { ...state, hasUnsavedChanges: action.payload.hasChanges };
|
||||||
|
|
||||||
|
case 'SET_PENDING_NAVIGATION':
|
||||||
|
return { ...state, pendingNavigation: action.payload.navigationFn };
|
||||||
|
|
||||||
|
case 'SHOW_NAVIGATION_WARNING':
|
||||||
|
return { ...state, showNavigationWarning: action.payload.show };
|
||||||
|
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initial state
|
||||||
|
const initialState: NavigationState = {
|
||||||
|
currentMode: 'pageEditor',
|
||||||
|
hasUnsavedChanges: false,
|
||||||
|
pendingNavigation: null,
|
||||||
|
showNavigationWarning: false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Navigation context actions interface
|
||||||
|
export interface NavigationContextActions {
|
||||||
|
setMode: (mode: ModeType) => void;
|
||||||
|
setHasUnsavedChanges: (hasChanges: boolean) => void;
|
||||||
|
showNavigationWarning: (show: boolean) => void;
|
||||||
|
requestNavigation: (navigationFn: () => void) => void;
|
||||||
|
confirmNavigation: () => void;
|
||||||
|
cancelNavigation: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split context values
|
||||||
|
export interface NavigationContextStateValue {
|
||||||
|
currentMode: ModeType;
|
||||||
|
hasUnsavedChanges: boolean;
|
||||||
|
pendingNavigation: (() => void) | null;
|
||||||
|
showNavigationWarning: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavigationContextActionsValue {
|
||||||
|
actions: NavigationContextActions;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create contexts
|
||||||
|
const NavigationStateContext = createContext<NavigationContextStateValue | undefined>(undefined);
|
||||||
|
const NavigationActionsContext = createContext<NavigationContextActionsValue | undefined>(undefined);
|
||||||
|
|
||||||
|
// Provider component
|
||||||
|
export const NavigationProvider: React.FC<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
enableUrlSync?: boolean;
|
||||||
|
}> = ({ children, enableUrlSync = true }) => {
|
||||||
|
const [state, dispatch] = useReducer(navigationReducer, initialState);
|
||||||
|
|
||||||
|
const actions: NavigationContextActions = {
|
||||||
|
setMode: useCallback((mode: ModeType) => {
|
||||||
|
dispatch({ type: 'SET_MODE', payload: { mode } });
|
||||||
|
}, []),
|
||||||
|
|
||||||
|
setHasUnsavedChanges: useCallback((hasChanges: boolean) => {
|
||||||
|
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } });
|
||||||
|
}, []),
|
||||||
|
|
||||||
|
showNavigationWarning: useCallback((show: boolean) => {
|
||||||
|
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show } });
|
||||||
|
}, []),
|
||||||
|
|
||||||
|
requestNavigation: useCallback((navigationFn: () => void) => {
|
||||||
|
// If no unsaved changes, navigate immediately
|
||||||
|
if (!state.hasUnsavedChanges) {
|
||||||
|
navigationFn();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, store the navigation and show warning
|
||||||
|
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn } });
|
||||||
|
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: true } });
|
||||||
|
}, [state.hasUnsavedChanges]),
|
||||||
|
|
||||||
|
confirmNavigation: useCallback(() => {
|
||||||
|
// Execute pending navigation
|
||||||
|
if (state.pendingNavigation) {
|
||||||
|
state.pendingNavigation();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear navigation state
|
||||||
|
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } });
|
||||||
|
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } });
|
||||||
|
}, [state.pendingNavigation]),
|
||||||
|
|
||||||
|
cancelNavigation: useCallback(() => {
|
||||||
|
// Clear navigation without executing
|
||||||
|
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } });
|
||||||
|
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } });
|
||||||
|
}, [])
|
||||||
|
};
|
||||||
|
|
||||||
|
const stateValue: NavigationContextStateValue = {
|
||||||
|
currentMode: state.currentMode,
|
||||||
|
hasUnsavedChanges: state.hasUnsavedChanges,
|
||||||
|
pendingNavigation: state.pendingNavigation,
|
||||||
|
showNavigationWarning: state.showNavigationWarning
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionsValue: NavigationContextActionsValue = {
|
||||||
|
actions
|
||||||
|
};
|
||||||
|
|
||||||
|
// Enable URL synchronization
|
||||||
|
useNavigationUrlSync(state.currentMode, actions.setMode, enableUrlSync);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavigationStateContext.Provider value={stateValue}>
|
||||||
|
<NavigationActionsContext.Provider value={actionsValue}>
|
||||||
|
{children}
|
||||||
|
</NavigationActionsContext.Provider>
|
||||||
|
</NavigationStateContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Navigation hooks
|
||||||
|
export const useNavigationState = () => {
|
||||||
|
const context = useContext(NavigationStateContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useNavigationState must be used within NavigationProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useNavigationActions = () => {
|
||||||
|
const context = useContext(NavigationActionsContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useNavigationActions must be used within NavigationProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Combined hook for convenience
|
||||||
|
export const useNavigation = () => {
|
||||||
|
const state = useNavigationState();
|
||||||
|
const { actions } = useNavigationActions();
|
||||||
|
return { ...state, ...actions };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Navigation guard hook (equivalent to old useFileNavigation)
|
||||||
|
export const useNavigationGuard = () => {
|
||||||
|
const state = useNavigationState();
|
||||||
|
const { actions } = useNavigationActions();
|
||||||
|
|
||||||
|
return {
|
||||||
|
pendingNavigation: state.pendingNavigation,
|
||||||
|
showNavigationWarning: state.showNavigationWarning,
|
||||||
|
hasUnsavedChanges: state.hasUnsavedChanges,
|
||||||
|
requestNavigation: actions.requestNavigation,
|
||||||
|
confirmNavigation: actions.confirmNavigation,
|
||||||
|
cancelNavigation: actions.cancelNavigation,
|
||||||
|
setHasUnsavedChanges: actions.setHasUnsavedChanges,
|
||||||
|
setShowNavigationWarning: actions.showNavigationWarning
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Utility functions for mode handling
|
||||||
|
export const isValidMode = (mode: string): mode is ModeType => {
|
||||||
|
const validModes: ModeType[] = [
|
||||||
|
'viewer', 'pageEditor', 'fileEditor', 'merge', 'split',
|
||||||
|
'compress', 'ocr', 'convert', 'addPassword', 'changePermissions', 'sanitize'
|
||||||
|
];
|
||||||
|
return validModes.includes(mode as ModeType);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDefaultMode = (): ModeType => 'pageEditor';
|
||||||
|
|
||||||
|
// TODO: This will be expanded for URL-based routing system
|
||||||
|
// - URL parsing utilities
|
||||||
|
// - Route definitions
|
||||||
|
// - Navigation hooks with URL sync
|
||||||
|
// - History management
|
||||||
|
// - Breadcrumb restoration from URL params
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { createContext, useContext, useState, useRef } from 'react';
|
import React, { createContext, useContext, useState, useRef, useMemo } from 'react';
|
||||||
import { SidebarState, SidebarRefs, SidebarContextValue, SidebarProviderProps } from '../types/sidebar';
|
import { SidebarState, SidebarRefs, SidebarContextValue, SidebarProviderProps } from '../types/sidebar';
|
||||||
|
|
||||||
const SidebarContext = createContext<SidebarContextValue | undefined>(undefined);
|
const SidebarContext = createContext<SidebarContextValue | undefined>(undefined);
|
||||||
@@ -12,24 +12,24 @@ export function SidebarProvider({ children }: SidebarProviderProps) {
|
|||||||
const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker');
|
const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker');
|
||||||
const [readerMode, setReaderMode] = useState(false);
|
const [readerMode, setReaderMode] = useState(false);
|
||||||
|
|
||||||
const sidebarState: SidebarState = {
|
const sidebarState: SidebarState = useMemo(() => ({
|
||||||
sidebarsVisible,
|
sidebarsVisible,
|
||||||
leftPanelView,
|
leftPanelView,
|
||||||
readerMode,
|
readerMode,
|
||||||
};
|
}), [sidebarsVisible, leftPanelView, readerMode]);
|
||||||
|
|
||||||
const sidebarRefs: SidebarRefs = {
|
const sidebarRefs: SidebarRefs = useMemo(() => ({
|
||||||
quickAccessRef,
|
quickAccessRef,
|
||||||
toolPanelRef,
|
toolPanelRef,
|
||||||
};
|
}), [quickAccessRef, toolPanelRef]);
|
||||||
|
|
||||||
const contextValue: SidebarContextValue = {
|
const contextValue: SidebarContextValue = useMemo(() => ({
|
||||||
sidebarState,
|
sidebarState,
|
||||||
sidebarRefs,
|
sidebarRefs,
|
||||||
setSidebarsVisible,
|
setSidebarsVisible,
|
||||||
setLeftPanelView,
|
setLeftPanelView,
|
||||||
setReaderMode,
|
setReaderMode,
|
||||||
};
|
}), [sidebarState, sidebarRefs, setSidebarsVisible, setLeftPanelView, setReaderMode]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarContext.Provider value={contextValue}>
|
<SidebarContext.Provider value={contextValue}>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import React, { createContext, useContext, useReducer, useCallback, useMemo } fr
|
|||||||
import { useToolManagement } from '../hooks/useToolManagement';
|
import { useToolManagement } from '../hooks/useToolManagement';
|
||||||
import { PageEditorFunctions } from '../types/pageEditor';
|
import { PageEditorFunctions } from '../types/pageEditor';
|
||||||
import { ToolRegistryEntry } from '../data/toolsTaxonomy';
|
import { ToolRegistryEntry } from '../data/toolsTaxonomy';
|
||||||
|
import { useToolWorkflowUrlSync } from '../hooks/useUrlSync';
|
||||||
|
|
||||||
// State interface
|
// State interface
|
||||||
interface ToolWorkflowState {
|
interface ToolWorkflowState {
|
||||||
@@ -101,9 +102,11 @@ interface ToolWorkflowProviderProps {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
/** Handler for view changes (passed from parent) */
|
/** Handler for view changes (passed from parent) */
|
||||||
onViewChange?: (view: string) => void;
|
onViewChange?: (view: string) => void;
|
||||||
|
/** Enable URL synchronization for tool selection */
|
||||||
|
enableUrlSync?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ToolWorkflowProvider({ children, onViewChange }: ToolWorkflowProviderProps) {
|
export function ToolWorkflowProvider({ children, onViewChange, enableUrlSync = true }: ToolWorkflowProviderProps) {
|
||||||
const [state, dispatch] = useReducer(toolWorkflowReducer, initialState);
|
const [state, dispatch] = useReducer(toolWorkflowReducer, initialState);
|
||||||
|
|
||||||
// Tool management hook
|
// Tool management hook
|
||||||
@@ -182,6 +185,9 @@ export function ToolWorkflowProvider({ children, onViewChange }: ToolWorkflowPro
|
|||||||
[state.sidebarsVisible, state.readerMode]
|
[state.sidebarsVisible, state.readerMode]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Enable URL synchronization for tool selection
|
||||||
|
useToolWorkflowUrlSync(selectedToolKey, selectTool, clearToolSelection, enableUrlSync);
|
||||||
|
|
||||||
// Simple context value with basic memoization
|
// Simple context value with basic memoization
|
||||||
const contextValue = useMemo((): ToolWorkflowContextValue => ({
|
const contextValue = useMemo((): ToolWorkflowContextValue => ({
|
||||||
// State
|
// State
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
/**
|
||||||
|
* FileContext reducer - Pure state management for file operations
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
FileContextState,
|
||||||
|
FileContextAction,
|
||||||
|
FileId,
|
||||||
|
FileRecord
|
||||||
|
} from '../../types/fileContext';
|
||||||
|
|
||||||
|
// Initial state
|
||||||
|
export const initialFileContextState: FileContextState = {
|
||||||
|
files: {
|
||||||
|
ids: [],
|
||||||
|
byId: {}
|
||||||
|
},
|
||||||
|
pinnedFiles: new Set(),
|
||||||
|
ui: {
|
||||||
|
selectedFileIds: [],
|
||||||
|
selectedPageNumbers: [],
|
||||||
|
isProcessing: false,
|
||||||
|
processingProgress: 0,
|
||||||
|
hasUnsavedChanges: false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pure reducer function
|
||||||
|
export function fileContextReducer(state: FileContextState, action: FileContextAction): FileContextState {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'ADD_FILES': {
|
||||||
|
const { fileRecords } = action.payload;
|
||||||
|
const newIds: FileId[] = [];
|
||||||
|
const newById: Record<FileId, FileRecord> = { ...state.files.byId };
|
||||||
|
|
||||||
|
fileRecords.forEach(record => {
|
||||||
|
// Only add if not already present (dedupe by stable ID)
|
||||||
|
if (!newById[record.id]) {
|
||||||
|
newIds.push(record.id);
|
||||||
|
newById[record.id] = record;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
files: {
|
||||||
|
ids: [...state.files.ids, ...newIds],
|
||||||
|
byId: newById
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'REMOVE_FILES': {
|
||||||
|
const { fileIds } = action.payload;
|
||||||
|
const remainingIds = state.files.ids.filter(id => !fileIds.includes(id));
|
||||||
|
const newById = { ...state.files.byId };
|
||||||
|
|
||||||
|
// Remove files from state (resource cleanup handled by lifecycle manager)
|
||||||
|
fileIds.forEach(id => {
|
||||||
|
delete newById[id];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear selections that reference removed files
|
||||||
|
const validSelectedFileIds = state.ui.selectedFileIds.filter(id => !fileIds.includes(id));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
files: {
|
||||||
|
ids: remainingIds,
|
||||||
|
byId: newById
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
...state.ui,
|
||||||
|
selectedFileIds: validSelectedFileIds
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'UPDATE_FILE_RECORD': {
|
||||||
|
const { id, updates } = action.payload;
|
||||||
|
const existingRecord = state.files.byId[id];
|
||||||
|
|
||||||
|
if (!existingRecord) {
|
||||||
|
return state; // File doesn't exist, no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
files: {
|
||||||
|
...state.files,
|
||||||
|
byId: {
|
||||||
|
...state.files.byId,
|
||||||
|
[id]: {
|
||||||
|
...existingRecord,
|
||||||
|
...updates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'REORDER_FILES': {
|
||||||
|
const { orderedFileIds } = action.payload;
|
||||||
|
|
||||||
|
// Validate that all IDs exist in current state
|
||||||
|
const validIds = orderedFileIds.filter(id => state.files.byId[id]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
files: {
|
||||||
|
...state.files,
|
||||||
|
ids: validIds
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'SET_SELECTED_FILES': {
|
||||||
|
const { fileIds } = action.payload;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
ui: {
|
||||||
|
...state.ui,
|
||||||
|
selectedFileIds: fileIds
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'SET_SELECTED_PAGES': {
|
||||||
|
const { pageNumbers } = action.payload;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
ui: {
|
||||||
|
...state.ui,
|
||||||
|
selectedPageNumbers: pageNumbers
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'CLEAR_SELECTIONS': {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
ui: {
|
||||||
|
...state.ui,
|
||||||
|
selectedFileIds: [],
|
||||||
|
selectedPageNumbers: []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'SET_PROCESSING': {
|
||||||
|
const { isProcessing, progress } = action.payload;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
ui: {
|
||||||
|
...state.ui,
|
||||||
|
isProcessing,
|
||||||
|
processingProgress: progress
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'SET_UNSAVED_CHANGES': {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
ui: {
|
||||||
|
...state.ui,
|
||||||
|
hasUnsavedChanges: action.payload.hasChanges
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'PIN_FILE': {
|
||||||
|
const { fileId } = action.payload;
|
||||||
|
const newPinnedFiles = new Set(state.pinnedFiles);
|
||||||
|
newPinnedFiles.add(fileId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
pinnedFiles: newPinnedFiles
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'UNPIN_FILE': {
|
||||||
|
const { fileId } = action.payload;
|
||||||
|
const newPinnedFiles = new Set(state.pinnedFiles);
|
||||||
|
newPinnedFiles.delete(fileId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
pinnedFiles: newPinnedFiles
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'CONSUME_FILES': {
|
||||||
|
const { inputFileIds, outputFileRecords } = action.payload;
|
||||||
|
|
||||||
|
// Only remove unpinned input files
|
||||||
|
const unpinnedInputIds = inputFileIds.filter(id => !state.pinnedFiles.has(id));
|
||||||
|
const remainingIds = state.files.ids.filter(id => !unpinnedInputIds.includes(id));
|
||||||
|
|
||||||
|
// Remove unpinned files from state
|
||||||
|
const newById = { ...state.files.byId };
|
||||||
|
unpinnedInputIds.forEach(id => {
|
||||||
|
delete newById[id];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add output files
|
||||||
|
const outputIds: FileId[] = [];
|
||||||
|
outputFileRecords.forEach(record => {
|
||||||
|
if (!newById[record.id]) {
|
||||||
|
outputIds.push(record.id);
|
||||||
|
newById[record.id] = record;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear selections that reference removed files
|
||||||
|
const validSelectedFileIds = state.ui.selectedFileIds.filter(id => !unpinnedInputIds.includes(id));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
files: {
|
||||||
|
ids: [...remainingIds, ...outputIds],
|
||||||
|
byId: newById
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
...state.ui,
|
||||||
|
selectedFileIds: validSelectedFileIds
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'RESET_CONTEXT': {
|
||||||
|
// Reset UI state to clean slate (resource cleanup handled by lifecycle manager)
|
||||||
|
return { ...initialFileContextState };
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* React contexts for file state and actions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createContext } from 'react';
|
||||||
|
import { FileContextStateValue, FileContextActionsValue } from '../../types/fileContext';
|
||||||
|
|
||||||
|
// Split contexts for performance
|
||||||
|
export const FileStateContext = createContext<FileContextStateValue | undefined>(undefined);
|
||||||
|
export const FileActionsContext = createContext<FileContextActionsValue | undefined>(undefined);
|
||||||
|
|
||||||
|
// Export types for use in hooks
|
||||||
|
export type { FileContextStateValue, FileContextActionsValue };
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
/**
|
||||||
|
* File actions - Unified file operations with single addFiles helper
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
FileId,
|
||||||
|
FileRecord,
|
||||||
|
FileContextAction,
|
||||||
|
FileContextState,
|
||||||
|
toFileRecord,
|
||||||
|
createFileId,
|
||||||
|
createQuickKey
|
||||||
|
} from '../../types/fileContext';
|
||||||
|
import { FileMetadata } from '../../types/file';
|
||||||
|
import { generateThumbnailWithMetadata } from '../../utils/thumbnailUtils';
|
||||||
|
import { FileLifecycleManager } from './lifecycle';
|
||||||
|
import { fileProcessingService } from '../../services/fileProcessingService';
|
||||||
|
import { buildQuickKeySet, buildQuickKeySetFromMetadata } from './fileSelectors';
|
||||||
|
|
||||||
|
const DEBUG = process.env.NODE_ENV === 'development';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple mutex to prevent race conditions in addFiles
|
||||||
|
*/
|
||||||
|
class SimpleMutex {
|
||||||
|
private locked = false;
|
||||||
|
private queue: Array<() => void> = [];
|
||||||
|
|
||||||
|
async lock(): Promise<void> {
|
||||||
|
if (!this.locked) {
|
||||||
|
this.locked = true;
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
this.queue.push(() => {
|
||||||
|
this.locked = true;
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
unlock(): void {
|
||||||
|
if (this.queue.length > 0) {
|
||||||
|
const next = this.queue.shift()!;
|
||||||
|
next();
|
||||||
|
} else {
|
||||||
|
this.locked = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global mutex for addFiles operations
|
||||||
|
const addFilesMutex = new SimpleMutex();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to create ProcessedFile metadata structure
|
||||||
|
*/
|
||||||
|
export function createProcessedFile(pageCount: number, thumbnail?: string) {
|
||||||
|
return {
|
||||||
|
totalPages: pageCount,
|
||||||
|
pages: Array.from({ length: pageCount }, (_, index) => ({
|
||||||
|
pageNumber: index + 1,
|
||||||
|
thumbnail: index === 0 ? thumbnail : undefined, // Only first page gets thumbnail initially
|
||||||
|
rotation: 0,
|
||||||
|
splitBefore: false
|
||||||
|
})),
|
||||||
|
thumbnailUrl: thumbnail,
|
||||||
|
lastProcessed: Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File addition types
|
||||||
|
*/
|
||||||
|
type AddFileKind = 'raw' | 'processed' | 'stored';
|
||||||
|
|
||||||
|
interface AddFileOptions {
|
||||||
|
// For 'raw' files
|
||||||
|
files?: File[];
|
||||||
|
|
||||||
|
// For 'processed' files
|
||||||
|
filesWithThumbnails?: Array<{ file: File; thumbnail?: string; pageCount?: number }>;
|
||||||
|
|
||||||
|
// For 'stored' files
|
||||||
|
filesWithMetadata?: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified file addition helper - replaces addFiles/addProcessedFiles/addStoredFiles
|
||||||
|
*/
|
||||||
|
export async function addFiles(
|
||||||
|
kind: AddFileKind,
|
||||||
|
options: AddFileOptions,
|
||||||
|
stateRef: React.MutableRefObject<FileContextState>,
|
||||||
|
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||||
|
dispatch: React.Dispatch<FileContextAction>,
|
||||||
|
lifecycleManager: FileLifecycleManager
|
||||||
|
): Promise<Array<{ file: File; id: FileId; thumbnail?: string }>> {
|
||||||
|
// Acquire mutex to prevent race conditions
|
||||||
|
await addFilesMutex.lock();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileRecords: FileRecord[] = [];
|
||||||
|
const addedFiles: Array<{ file: File; id: FileId; thumbnail?: string }> = [];
|
||||||
|
|
||||||
|
// Build quickKey lookup from existing files for deduplication
|
||||||
|
const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId);
|
||||||
|
if (DEBUG) console.log(`📄 addFiles(${kind}): Existing quickKeys for deduplication:`, Array.from(existingQuickKeys));
|
||||||
|
|
||||||
|
switch (kind) {
|
||||||
|
case 'raw': {
|
||||||
|
const { files = [] } = options;
|
||||||
|
if (DEBUG) console.log(`📄 addFiles(raw): Adding ${files.length} files with immediate thumbnail generation`);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const quickKey = createQuickKey(file);
|
||||||
|
|
||||||
|
// Soft deduplication: Check if file already exists by metadata
|
||||||
|
if (existingQuickKeys.has(quickKey)) {
|
||||||
|
if (DEBUG) console.log(`📄 Skipping duplicate file: ${file.name} (quickKey: ${quickKey})`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (DEBUG) console.log(`📄 Adding new file: ${file.name} (quickKey: ${quickKey})`);
|
||||||
|
|
||||||
|
const fileId = createFileId();
|
||||||
|
filesRef.current.set(fileId, file);
|
||||||
|
|
||||||
|
// Generate thumbnail and page count immediately
|
||||||
|
let thumbnail: string | undefined;
|
||||||
|
let pageCount: number = 1;
|
||||||
|
|
||||||
|
// Route based on file type - PDFs through full metadata pipeline, non-PDFs through simple path
|
||||||
|
if (file.type.startsWith('application/pdf')) {
|
||||||
|
try {
|
||||||
|
if (DEBUG) console.log(`📄 Generating PDF metadata for ${file.name}`);
|
||||||
|
const result = await generateThumbnailWithMetadata(file);
|
||||||
|
thumbnail = result.thumbnail;
|
||||||
|
pageCount = result.pageCount;
|
||||||
|
if (DEBUG) console.log(`📄 Generated PDF metadata for ${file.name}: ${pageCount} pages, thumbnail: SUCCESS`);
|
||||||
|
} catch (error) {
|
||||||
|
if (DEBUG) console.warn(`📄 Failed to generate PDF metadata for ${file.name}:`, error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Non-PDF files: simple thumbnail generation, no page count
|
||||||
|
try {
|
||||||
|
if (DEBUG) console.log(`📄 Generating simple thumbnail for non-PDF file ${file.name}`);
|
||||||
|
const { generateThumbnailForFile } = await import('../../utils/thumbnailUtils');
|
||||||
|
thumbnail = await generateThumbnailForFile(file);
|
||||||
|
pageCount = 0; // Non-PDFs have no page count
|
||||||
|
if (DEBUG) console.log(`📄 Generated simple thumbnail for ${file.name}: no page count, thumbnail: SUCCESS`);
|
||||||
|
} catch (error) {
|
||||||
|
if (DEBUG) console.warn(`📄 Failed to generate simple thumbnail for ${file.name}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create record with immediate thumbnail and page metadata
|
||||||
|
const record = toFileRecord(file, fileId);
|
||||||
|
if (thumbnail) {
|
||||||
|
record.thumbnailUrl = thumbnail;
|
||||||
|
// Track blob URLs for cleanup (images return blob URLs that need revocation)
|
||||||
|
if (thumbnail.startsWith('blob:')) {
|
||||||
|
lifecycleManager.trackBlobUrl(thumbnail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create initial processedFile metadata with page count
|
||||||
|
if (pageCount > 0) {
|
||||||
|
record.processedFile = createProcessedFile(pageCount, thumbnail);
|
||||||
|
if (DEBUG) console.log(`📄 addFiles(raw): Created initial processedFile metadata for ${file.name} with ${pageCount} pages`);
|
||||||
|
}
|
||||||
|
|
||||||
|
existingQuickKeys.add(quickKey);
|
||||||
|
fileRecords.push(record);
|
||||||
|
addedFiles.push({ file, id: fileId, thumbnail });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'processed': {
|
||||||
|
const { filesWithThumbnails = [] } = options;
|
||||||
|
if (DEBUG) console.log(`📄 addFiles(processed): Adding ${filesWithThumbnails.length} processed files with pre-existing thumbnails`);
|
||||||
|
|
||||||
|
for (const { file, thumbnail, pageCount = 1 } of filesWithThumbnails) {
|
||||||
|
const quickKey = createQuickKey(file);
|
||||||
|
|
||||||
|
if (existingQuickKeys.has(quickKey)) {
|
||||||
|
if (DEBUG) console.log(`📄 Skipping duplicate processed file: ${file.name}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileId = createFileId();
|
||||||
|
filesRef.current.set(fileId, file);
|
||||||
|
|
||||||
|
const record = toFileRecord(file, fileId);
|
||||||
|
if (thumbnail) {
|
||||||
|
record.thumbnailUrl = thumbnail;
|
||||||
|
// Track blob URLs for cleanup (images return blob URLs that need revocation)
|
||||||
|
if (thumbnail.startsWith('blob:')) {
|
||||||
|
lifecycleManager.trackBlobUrl(thumbnail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create processedFile with provided metadata
|
||||||
|
if (pageCount > 0) {
|
||||||
|
record.processedFile = createProcessedFile(pageCount, thumbnail);
|
||||||
|
if (DEBUG) console.log(`📄 addFiles(processed): Created initial processedFile metadata for ${file.name} with ${pageCount} pages`);
|
||||||
|
}
|
||||||
|
|
||||||
|
existingQuickKeys.add(quickKey);
|
||||||
|
fileRecords.push(record);
|
||||||
|
addedFiles.push({ file, id: fileId, thumbnail });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'stored': {
|
||||||
|
const { filesWithMetadata = [] } = options;
|
||||||
|
if (DEBUG) console.log(`📄 addFiles(stored): Restoring ${filesWithMetadata.length} files from storage with existing metadata`);
|
||||||
|
|
||||||
|
for (const { file, originalId, metadata } of filesWithMetadata) {
|
||||||
|
const quickKey = createQuickKey(file);
|
||||||
|
|
||||||
|
if (existingQuickKeys.has(quickKey)) {
|
||||||
|
if (DEBUG) console.log(`📄 Skipping duplicate stored file: ${file.name} (quickKey: ${quickKey})`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (DEBUG) console.log(`📄 Adding stored file: ${file.name} (quickKey: ${quickKey})`);
|
||||||
|
|
||||||
|
// Try to preserve original ID, but generate new if it conflicts
|
||||||
|
let fileId = originalId;
|
||||||
|
if (filesRef.current.has(originalId)) {
|
||||||
|
fileId = createFileId();
|
||||||
|
if (DEBUG) console.log(`📄 ID conflict for stored file ${file.name}, using new ID: ${fileId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
filesRef.current.set(fileId, file);
|
||||||
|
|
||||||
|
const record = toFileRecord(file, fileId);
|
||||||
|
|
||||||
|
// Generate processedFile metadata for stored files
|
||||||
|
let pageCount: number = 1;
|
||||||
|
|
||||||
|
// Only process PDFs through PDF worker manager, non-PDFs have no page count
|
||||||
|
if (file.type.startsWith('application/pdf')) {
|
||||||
|
try {
|
||||||
|
if (DEBUG) console.log(`📄 addFiles(stored): Generating PDF metadata for stored file ${file.name}`);
|
||||||
|
|
||||||
|
// Get page count from PDF
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const { pdfWorkerManager } = await import('../../services/pdfWorkerManager');
|
||||||
|
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
|
||||||
|
pageCount = pdf.numPages;
|
||||||
|
pdfWorkerManager.destroyDocument(pdf);
|
||||||
|
|
||||||
|
if (DEBUG) console.log(`📄 addFiles(stored): Found ${pageCount} pages in PDF ${file.name}`);
|
||||||
|
} catch (error) {
|
||||||
|
if (DEBUG) console.warn(`📄 addFiles(stored): Failed to generate PDF metadata for ${file.name}:`, error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pageCount = 0; // Non-PDFs have no page count
|
||||||
|
if (DEBUG) console.log(`📄 addFiles(stored): Non-PDF file ${file.name}, no page count`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore metadata from storage
|
||||||
|
if (metadata.thumbnail) {
|
||||||
|
record.thumbnailUrl = metadata.thumbnail;
|
||||||
|
// Track blob URLs for cleanup (images return blob URLs that need revocation)
|
||||||
|
if (metadata.thumbnail.startsWith('blob:')) {
|
||||||
|
lifecycleManager.trackBlobUrl(metadata.thumbnail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create processedFile metadata with correct page count
|
||||||
|
if (pageCount > 0) {
|
||||||
|
record.processedFile = createProcessedFile(pageCount, metadata.thumbnail);
|
||||||
|
if (DEBUG) console.log(`📄 addFiles(stored): Created processedFile metadata for ${file.name} with ${pageCount} pages`);
|
||||||
|
}
|
||||||
|
|
||||||
|
existingQuickKeys.add(quickKey);
|
||||||
|
fileRecords.push(record);
|
||||||
|
addedFiles.push({ file, id: fileId, thumbnail: metadata.thumbnail });
|
||||||
|
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch ADD_FILES action if we have new files
|
||||||
|
if (fileRecords.length > 0) {
|
||||||
|
dispatch({ type: 'ADD_FILES', payload: { fileRecords } });
|
||||||
|
if (DEBUG) console.log(`📄 addFiles(${kind}): Successfully added ${fileRecords.length} files`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return addedFiles;
|
||||||
|
} finally {
|
||||||
|
// Always release mutex even if error occurs
|
||||||
|
addFilesMutex.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consume files helper - replace unpinned input files with output files
|
||||||
|
*/
|
||||||
|
export async function consumeFiles(
|
||||||
|
inputFileIds: FileId[],
|
||||||
|
outputFiles: File[],
|
||||||
|
stateRef: React.MutableRefObject<FileContextState>,
|
||||||
|
filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||||
|
dispatch: React.Dispatch<FileContextAction>
|
||||||
|
): Promise<void> {
|
||||||
|
if (DEBUG) console.log(`📄 consumeFiles: Processing ${inputFileIds.length} input files, ${outputFiles.length} output files`);
|
||||||
|
|
||||||
|
// Process output files through the 'processed' path to generate thumbnails
|
||||||
|
const outputFileRecords = await Promise.all(
|
||||||
|
outputFiles.map(async (file) => {
|
||||||
|
const fileId = createFileId();
|
||||||
|
filesRef.current.set(fileId, file);
|
||||||
|
|
||||||
|
// Generate thumbnail and page count for output file
|
||||||
|
let thumbnail: string | undefined;
|
||||||
|
let pageCount: number = 1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (DEBUG) console.log(`📄 consumeFiles: Generating thumbnail for output file ${file.name}`);
|
||||||
|
const result = await generateThumbnailWithMetadata(file);
|
||||||
|
thumbnail = result.thumbnail;
|
||||||
|
pageCount = result.pageCount;
|
||||||
|
} catch (error) {
|
||||||
|
if (DEBUG) console.warn(`📄 consumeFiles: Failed to generate thumbnail for output file ${file.name}:`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = toFileRecord(file, fileId);
|
||||||
|
if (thumbnail) {
|
||||||
|
record.thumbnailUrl = thumbnail;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageCount > 0) {
|
||||||
|
record.processedFile = createProcessedFile(pageCount, thumbnail);
|
||||||
|
}
|
||||||
|
|
||||||
|
return record;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dispatch the consume action
|
||||||
|
dispatch({
|
||||||
|
type: 'CONSUME_FILES',
|
||||||
|
payload: {
|
||||||
|
inputFileIds,
|
||||||
|
outputFileRecords
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (DEBUG) console.log(`📄 consumeFiles: Successfully consumed files - removed ${inputFileIds.length} inputs, added ${outputFileRecords.length} outputs`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Action factory functions
|
||||||
|
*/
|
||||||
|
export const createFileActions = (dispatch: React.Dispatch<FileContextAction>) => ({
|
||||||
|
setSelectedFiles: (fileIds: FileId[]) => dispatch({ type: 'SET_SELECTED_FILES', payload: { fileIds } }),
|
||||||
|
setSelectedPages: (pageNumbers: number[]) => dispatch({ type: 'SET_SELECTED_PAGES', payload: { pageNumbers } }),
|
||||||
|
clearSelections: () => dispatch({ type: 'CLEAR_SELECTIONS' }),
|
||||||
|
setProcessing: (isProcessing: boolean, progress = 0) => dispatch({ type: 'SET_PROCESSING', payload: { isProcessing, progress } }),
|
||||||
|
setHasUnsavedChanges: (hasChanges: boolean) => dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } }),
|
||||||
|
pinFile: (fileId: FileId) => dispatch({ type: 'PIN_FILE', payload: { fileId } }),
|
||||||
|
unpinFile: (fileId: FileId) => dispatch({ type: 'UNPIN_FILE', payload: { fileId } }),
|
||||||
|
resetContext: () => dispatch({ type: 'RESET_CONTEXT' })
|
||||||
|
});
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
/**
|
||||||
|
* Performant file hooks - Clean API using FileContext
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useContext, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
FileStateContext,
|
||||||
|
FileActionsContext,
|
||||||
|
FileContextStateValue,
|
||||||
|
FileContextActionsValue
|
||||||
|
} from './contexts';
|
||||||
|
import { FileId, FileRecord } from '../../types/fileContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for accessing file state (will re-render on any state change)
|
||||||
|
* Use individual selector hooks below for better performance
|
||||||
|
*/
|
||||||
|
export function useFileState(): FileContextStateValue {
|
||||||
|
const context = useContext(FileStateContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useFileState must be used within a FileContextProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for accessing file actions (stable - won't cause re-renders)
|
||||||
|
*/
|
||||||
|
export function useFileActions(): FileContextActionsValue {
|
||||||
|
const context = useContext(FileActionsContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useFileActions must be used within a FileContextProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for current/primary file (first in list)
|
||||||
|
*/
|
||||||
|
export function useCurrentFile(): { file?: File; record?: FileRecord } {
|
||||||
|
const { state, selectors } = useFileState();
|
||||||
|
|
||||||
|
const primaryFileId = state.files.ids[0];
|
||||||
|
return useMemo(() => ({
|
||||||
|
file: primaryFileId ? selectors.getFile(primaryFileId) : undefined,
|
||||||
|
record: primaryFileId ? selectors.getFileRecord(primaryFileId) : undefined
|
||||||
|
}), [primaryFileId, selectors]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for file selection state and actions
|
||||||
|
*/
|
||||||
|
export function useFileSelection() {
|
||||||
|
const { state, selectors } = useFileState();
|
||||||
|
const { actions } = useFileActions();
|
||||||
|
|
||||||
|
// Memoize selected files to avoid recreating arrays
|
||||||
|
const selectedFiles = useMemo(() => {
|
||||||
|
return selectors.getSelectedFiles();
|
||||||
|
}, [state.ui.selectedFileIds, selectors]);
|
||||||
|
|
||||||
|
return useMemo(() => ({
|
||||||
|
selectedFiles,
|
||||||
|
selectedFileIds: state.ui.selectedFileIds,
|
||||||
|
selectedPageNumbers: state.ui.selectedPageNumbers,
|
||||||
|
setSelectedFiles: actions.setSelectedFiles,
|
||||||
|
setSelectedPages: actions.setSelectedPages,
|
||||||
|
clearSelections: actions.clearSelections
|
||||||
|
}), [
|
||||||
|
selectedFiles,
|
||||||
|
state.ui.selectedFileIds,
|
||||||
|
state.ui.selectedPageNumbers,
|
||||||
|
actions.setSelectedFiles,
|
||||||
|
actions.setSelectedPages,
|
||||||
|
actions.clearSelections
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for file management operations
|
||||||
|
*/
|
||||||
|
export function useFileManagement() {
|
||||||
|
const { actions } = useFileActions();
|
||||||
|
|
||||||
|
return useMemo(() => ({
|
||||||
|
addFiles: actions.addFiles,
|
||||||
|
removeFiles: actions.removeFiles,
|
||||||
|
clearAllFiles: actions.clearAllFiles,
|
||||||
|
updateFileRecord: actions.updateFileRecord,
|
||||||
|
reorderFiles: actions.reorderFiles
|
||||||
|
}), [actions]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for UI state
|
||||||
|
*/
|
||||||
|
export function useFileUI() {
|
||||||
|
const { state } = useFileState();
|
||||||
|
const { actions } = useFileActions();
|
||||||
|
|
||||||
|
return useMemo(() => ({
|
||||||
|
isProcessing: state.ui.isProcessing,
|
||||||
|
processingProgress: state.ui.processingProgress,
|
||||||
|
hasUnsavedChanges: state.ui.hasUnsavedChanges,
|
||||||
|
setProcessing: actions.setProcessing,
|
||||||
|
setUnsavedChanges: actions.setHasUnsavedChanges
|
||||||
|
}), [state.ui, actions]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for specific file by ID (optimized for individual file access)
|
||||||
|
*/
|
||||||
|
export function useFileRecord(fileId: FileId): { file?: File; record?: FileRecord } {
|
||||||
|
const { selectors } = useFileState();
|
||||||
|
|
||||||
|
return useMemo(() => ({
|
||||||
|
file: selectors.getFile(fileId),
|
||||||
|
record: selectors.getFileRecord(fileId)
|
||||||
|
}), [fileId, selectors]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for all files (use sparingly - causes re-renders on file list changes)
|
||||||
|
*/
|
||||||
|
export function useAllFiles(): { files: File[]; records: FileRecord[]; fileIds: FileId[] } {
|
||||||
|
const { state, selectors } = useFileState();
|
||||||
|
|
||||||
|
return useMemo(() => ({
|
||||||
|
files: selectors.getFiles(),
|
||||||
|
records: selectors.getFileRecords(),
|
||||||
|
fileIds: state.files.ids
|
||||||
|
}), [state.files.ids, selectors]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for selected files (optimized for selection-based UI)
|
||||||
|
*/
|
||||||
|
export function useSelectedFiles(): { files: File[]; records: FileRecord[]; fileIds: FileId[] } {
|
||||||
|
const { state, selectors } = useFileState();
|
||||||
|
|
||||||
|
return useMemo(() => ({
|
||||||
|
files: selectors.getSelectedFiles(),
|
||||||
|
records: selectors.getSelectedFileRecords(),
|
||||||
|
fileIds: state.ui.selectedFileIds
|
||||||
|
}), [state.ui.selectedFileIds, selectors]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigation management removed - moved to NavigationContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Primary API hook for file context operations
|
||||||
|
* Used by tools for core file context functionality
|
||||||
|
*/
|
||||||
|
export function useFileContext() {
|
||||||
|
const { state, selectors } = useFileState();
|
||||||
|
const { actions } = useFileActions();
|
||||||
|
|
||||||
|
return useMemo(() => ({
|
||||||
|
// Lifecycle management
|
||||||
|
trackBlobUrl: actions.trackBlobUrl,
|
||||||
|
scheduleCleanup: actions.scheduleCleanup,
|
||||||
|
setUnsavedChanges: actions.setHasUnsavedChanges,
|
||||||
|
|
||||||
|
// File management
|
||||||
|
addFiles: actions.addFiles,
|
||||||
|
consumeFiles: actions.consumeFiles,
|
||||||
|
recordOperation: (fileId: string, operation: any) => {}, // Operation tracking not implemented
|
||||||
|
markOperationApplied: (fileId: string, operationId: string) => {}, // Operation tracking not implemented
|
||||||
|
markOperationFailed: (fileId: string, operationId: string, error: string) => {}, // Operation tracking not implemented
|
||||||
|
|
||||||
|
// File ID lookup
|
||||||
|
findFileId: (file: File) => {
|
||||||
|
return state.files.ids.find(id => {
|
||||||
|
const record = state.files.byId[id];
|
||||||
|
return record &&
|
||||||
|
record.name === file.name &&
|
||||||
|
record.size === file.size &&
|
||||||
|
record.lastModified === file.lastModified;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Pinned files
|
||||||
|
pinnedFiles: state.pinnedFiles,
|
||||||
|
pinFile: actions.pinFile,
|
||||||
|
unpinFile: actions.unpinFile,
|
||||||
|
isFilePinned: selectors.isFilePinned,
|
||||||
|
|
||||||
|
// Active files
|
||||||
|
activeFiles: selectors.getFiles()
|
||||||
|
}), [state, selectors, actions]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* File selectors - Pure functions for accessing file state
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
FileId,
|
||||||
|
FileRecord,
|
||||||
|
FileContextState,
|
||||||
|
FileContextSelectors
|
||||||
|
} from '../../types/fileContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create stable selectors using stateRef and filesRef
|
||||||
|
*/
|
||||||
|
export function createFileSelectors(
|
||||||
|
stateRef: React.MutableRefObject<FileContextState>,
|
||||||
|
filesRef: React.MutableRefObject<Map<FileId, File>>
|
||||||
|
): FileContextSelectors {
|
||||||
|
return {
|
||||||
|
getFile: (id: FileId) => filesRef.current.get(id),
|
||||||
|
|
||||||
|
getFiles: (ids?: FileId[]) => {
|
||||||
|
const currentIds = ids || stateRef.current.files.ids;
|
||||||
|
return currentIds.map(id => filesRef.current.get(id)).filter(Boolean) as File[];
|
||||||
|
},
|
||||||
|
|
||||||
|
getFileRecord: (id: FileId) => stateRef.current.files.byId[id],
|
||||||
|
|
||||||
|
getFileRecords: (ids?: FileId[]) => {
|
||||||
|
const currentIds = ids || stateRef.current.files.ids;
|
||||||
|
return currentIds.map(id => stateRef.current.files.byId[id]).filter(Boolean);
|
||||||
|
},
|
||||||
|
|
||||||
|
getAllFileIds: () => stateRef.current.files.ids,
|
||||||
|
|
||||||
|
getSelectedFiles: () => {
|
||||||
|
return stateRef.current.ui.selectedFileIds
|
||||||
|
.map(id => filesRef.current.get(id))
|
||||||
|
.filter(Boolean) as File[];
|
||||||
|
},
|
||||||
|
|
||||||
|
getSelectedFileRecords: () => {
|
||||||
|
return stateRef.current.ui.selectedFileIds
|
||||||
|
.map(id => stateRef.current.files.byId[id])
|
||||||
|
.filter(Boolean);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Pinned files selectors
|
||||||
|
getPinnedFileIds: () => {
|
||||||
|
return Array.from(stateRef.current.pinnedFiles);
|
||||||
|
},
|
||||||
|
|
||||||
|
getPinnedFiles: () => {
|
||||||
|
return Array.from(stateRef.current.pinnedFiles)
|
||||||
|
.map(id => filesRef.current.get(id))
|
||||||
|
.filter(Boolean) as File[];
|
||||||
|
},
|
||||||
|
|
||||||
|
getPinnedFileRecords: () => {
|
||||||
|
return Array.from(stateRef.current.pinnedFiles)
|
||||||
|
.map(id => stateRef.current.files.byId[id])
|
||||||
|
.filter(Boolean);
|
||||||
|
},
|
||||||
|
|
||||||
|
isFilePinned: (file: File) => {
|
||||||
|
// Find FileId by matching File object properties
|
||||||
|
const fileId = Object.keys(stateRef.current.files.byId).find(id => {
|
||||||
|
const storedFile = filesRef.current.get(id);
|
||||||
|
return storedFile &&
|
||||||
|
storedFile.name === file.name &&
|
||||||
|
storedFile.size === file.size &&
|
||||||
|
storedFile.lastModified === file.lastModified;
|
||||||
|
});
|
||||||
|
return fileId ? stateRef.current.pinnedFiles.has(fileId) : false;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Stable signature for effects - prevents unnecessary re-renders
|
||||||
|
getFilesSignature: () => {
|
||||||
|
return stateRef.current.files.ids
|
||||||
|
.map(id => {
|
||||||
|
const record = stateRef.current.files.byId[id];
|
||||||
|
return record ? `${id}:${record.size}:${record.lastModified}` : '';
|
||||||
|
})
|
||||||
|
.join('|');
|
||||||
|
},
|
||||||
|
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper for building quickKey sets for deduplication
|
||||||
|
*/
|
||||||
|
export function buildQuickKeySet(fileRecords: Record<FileId, FileRecord>): Set<string> {
|
||||||
|
const quickKeys = new Set<string>();
|
||||||
|
Object.values(fileRecords).forEach(record => {
|
||||||
|
if (record.quickKey) {
|
||||||
|
quickKeys.add(record.quickKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return quickKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper for building quickKey sets from IndexedDB metadata
|
||||||
|
*/
|
||||||
|
export function buildQuickKeySetFromMetadata(metadata: Array<{ name: string; size: number; lastModified: number }>): Set<string> {
|
||||||
|
const quickKeys = new Set<string>();
|
||||||
|
metadata.forEach(meta => {
|
||||||
|
// Format: name|size|lastModified (same as createQuickKey)
|
||||||
|
const quickKey = `${meta.name}|${meta.size}|${meta.lastModified}`;
|
||||||
|
quickKeys.add(quickKey);
|
||||||
|
});
|
||||||
|
return quickKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get primary file (first in list) - commonly used pattern
|
||||||
|
*/
|
||||||
|
export function getPrimaryFile(
|
||||||
|
stateRef: React.MutableRefObject<FileContextState>,
|
||||||
|
filesRef: React.MutableRefObject<Map<FileId, File>>
|
||||||
|
): { file?: File; record?: FileRecord } {
|
||||||
|
const primaryFileId = stateRef.current.files.ids[0];
|
||||||
|
if (!primaryFileId) return {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
file: filesRef.current.get(primaryFileId),
|
||||||
|
record: stateRef.current.files.byId[primaryFileId]
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* File lifecycle management - Resource cleanup and memory management
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { FileId, FileContextAction, FileRecord, ProcessedFilePage } from '../../types/fileContext';
|
||||||
|
|
||||||
|
const DEBUG = process.env.NODE_ENV === 'development';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resource tracking and cleanup utilities
|
||||||
|
*/
|
||||||
|
export class FileLifecycleManager {
|
||||||
|
private cleanupTimers = new Map<string, number>();
|
||||||
|
private blobUrls = new Set<string>();
|
||||||
|
private fileGenerations = new Map<string, number>(); // Generation tokens to prevent stale cleanup
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private filesRef: React.MutableRefObject<Map<FileId, File>>,
|
||||||
|
private dispatch: React.Dispatch<FileContextAction>
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track blob URLs for cleanup
|
||||||
|
*/
|
||||||
|
trackBlobUrl = (url: string): void => {
|
||||||
|
// Only track actual blob URLs to avoid trying to revoke other schemes
|
||||||
|
if (url.startsWith('blob:')) {
|
||||||
|
this.blobUrls.add(url);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up resources for a specific file (with stateRef access for complete cleanup)
|
||||||
|
*/
|
||||||
|
cleanupFile = (fileId: string, stateRef?: React.MutableRefObject<any>): void => {
|
||||||
|
// Use comprehensive cleanup (same as removeFiles)
|
||||||
|
this.cleanupAllResourcesForFile(fileId, stateRef);
|
||||||
|
|
||||||
|
// Remove file from state
|
||||||
|
this.dispatch({ type: 'REMOVE_FILES', payload: { fileIds: [fileId] } });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up all files and resources
|
||||||
|
*/
|
||||||
|
cleanupAllFiles = (): void => {
|
||||||
|
// Revoke all blob URLs
|
||||||
|
this.blobUrls.forEach(url => {
|
||||||
|
try {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore revocation errors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.blobUrls.clear();
|
||||||
|
|
||||||
|
// Clear all cleanup timers and generations
|
||||||
|
this.cleanupTimers.forEach(timer => clearTimeout(timer));
|
||||||
|
this.cleanupTimers.clear();
|
||||||
|
this.fileGenerations.clear();
|
||||||
|
|
||||||
|
// Clear files ref
|
||||||
|
this.filesRef.current.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule delayed cleanup for a file with generation token to prevent stale cleanup
|
||||||
|
*/
|
||||||
|
scheduleCleanup = (fileId: string, delay: number = 30000, stateRef?: React.MutableRefObject<any>): void => {
|
||||||
|
// Cancel existing timer
|
||||||
|
const existingTimer = this.cleanupTimers.get(fileId);
|
||||||
|
if (existingTimer) {
|
||||||
|
clearTimeout(existingTimer);
|
||||||
|
this.cleanupTimers.delete(fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If delay is negative, just cancel (don't reschedule)
|
||||||
|
if (delay < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment generation for this file to invalidate any pending cleanup
|
||||||
|
const currentGen = (this.fileGenerations.get(fileId) || 0) + 1;
|
||||||
|
this.fileGenerations.set(fileId, currentGen);
|
||||||
|
|
||||||
|
// Schedule new cleanup with generation token
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
// Check if this cleanup is still valid (file hasn't been re-added)
|
||||||
|
if (this.fileGenerations.get(fileId) === currentGen) {
|
||||||
|
this.cleanupFile(fileId, stateRef);
|
||||||
|
} else {
|
||||||
|
if (DEBUG) console.log(`🗂️ Skipped stale cleanup for file ${fileId} (generation mismatch)`);
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
|
|
||||||
|
this.cleanupTimers.set(fileId, timer);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a file immediately with complete resource cleanup
|
||||||
|
*/
|
||||||
|
removeFiles = (fileIds: FileId[], stateRef?: React.MutableRefObject<any>): void => {
|
||||||
|
fileIds.forEach(fileId => {
|
||||||
|
// Clean up all resources for this file
|
||||||
|
this.cleanupAllResourcesForFile(fileId, stateRef);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dispatch removal action once for all files (reducer only updates state)
|
||||||
|
this.dispatch({ type: 'REMOVE_FILES', payload: { fileIds } });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete resource cleanup for a single file
|
||||||
|
*/
|
||||||
|
private cleanupAllResourcesForFile = (fileId: FileId, stateRef?: React.MutableRefObject<any>): void => {
|
||||||
|
// Remove from files ref
|
||||||
|
this.filesRef.current.delete(fileId);
|
||||||
|
|
||||||
|
// Cancel cleanup timer and generation
|
||||||
|
const timer = this.cleanupTimers.get(fileId);
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
this.cleanupTimers.delete(fileId);
|
||||||
|
}
|
||||||
|
this.fileGenerations.delete(fileId);
|
||||||
|
|
||||||
|
// Clean up blob URLs from file record if we have access to state
|
||||||
|
if (stateRef) {
|
||||||
|
const record = stateRef.current.files.byId[fileId];
|
||||||
|
if (record) {
|
||||||
|
// Clean up thumbnail blob URLs
|
||||||
|
if (record.thumbnailUrl && record.thumbnailUrl.startsWith('blob:')) {
|
||||||
|
try {
|
||||||
|
URL.revokeObjectURL(record.thumbnailUrl);
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore revocation errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (record.blobUrl && record.blobUrl.startsWith('blob:')) {
|
||||||
|
try {
|
||||||
|
URL.revokeObjectURL(record.blobUrl);
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore revocation errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up processed file thumbnails
|
||||||
|
if (record.processedFile?.pages) {
|
||||||
|
record.processedFile.pages.forEach((page: ProcessedFilePage, index: number) => {
|
||||||
|
if (page.thumbnail && page.thumbnail.startsWith('blob:')) {
|
||||||
|
try {
|
||||||
|
URL.revokeObjectURL(page.thumbnail);
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore revocation errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update file record with race condition guards
|
||||||
|
*/
|
||||||
|
updateFileRecord = (fileId: FileId, updates: Partial<FileRecord>, stateRef?: React.MutableRefObject<any>): void => {
|
||||||
|
// Guard against updating removed files (race condition protection)
|
||||||
|
if (!this.filesRef.current.has(fileId)) {
|
||||||
|
if (DEBUG) console.warn(`🗂️ Attempted to update removed file (filesRef): ${fileId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional state guard for rare race conditions
|
||||||
|
if (stateRef && !stateRef.current.files.byId[fileId]) {
|
||||||
|
if (DEBUG) console.warn(`🗂️ Attempted to update removed file (state): ${fileId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dispatch({ type: 'UPDATE_FILE_RECORD', payload: { id: fileId, updates } });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup on unmount
|
||||||
|
*/
|
||||||
|
destroy = (): void => {
|
||||||
|
this.cleanupAllFiles();
|
||||||
|
};
|
||||||
|
}
|
||||||
Vendored
-1
@@ -1,6 +1,5 @@
|
|||||||
declare module "../tools/Split";
|
declare module "../tools/Split";
|
||||||
declare module "../tools/Compress";
|
declare module "../tools/Compress";
|
||||||
declare module "../tools/Merge";
|
|
||||||
declare module "../components/PageEditor";
|
declare module "../components/PageEditor";
|
||||||
declare module "../components/Viewer";
|
declare module "../components/Viewer";
|
||||||
declare module "*.js";
|
declare module "*.js";
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { getEndpointName as getEndpointNameUtil, getEndpointUrl, isImageFormat,
|
|||||||
import { detectFileExtension as detectFileExtensionUtil } from '../../../utils/fileUtils';
|
import { detectFileExtension as detectFileExtensionUtil } from '../../../utils/fileUtils';
|
||||||
import { BaseParameters } from '../../../types/parameters';
|
import { BaseParameters } from '../../../types/parameters';
|
||||||
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
|
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
export interface ConvertParameters extends BaseParameters {
|
export interface ConvertParameters extends BaseParameters {
|
||||||
fromExtension: string;
|
fromExtension: string;
|
||||||
@@ -121,11 +122,13 @@ const getEndpointName = (params: ConvertParameters): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const useConvertParameters = (): ConvertParametersHook => {
|
export const useConvertParameters = (): ConvertParametersHook => {
|
||||||
const baseHook = useBaseParameters({
|
const config = useMemo(() => ({
|
||||||
defaultParameters,
|
defaultParameters,
|
||||||
endpointName: getEndpointName,
|
endpointName: getEndpointName,
|
||||||
validateFn: validateParameters,
|
validateFn: validateParameters,
|
||||||
});
|
}), []);
|
||||||
|
|
||||||
|
const baseHook = useBaseParameters(config);
|
||||||
|
|
||||||
const getEndpoint = () => {
|
const getEndpoint = () => {
|
||||||
const { fromExtension, toExtension, isSmartDetection, smartDetectionType } = baseHook.parameters;
|
const { fromExtension, toExtension, isSmartDetection, smartDetectionType } = baseHook.parameters;
|
||||||
@@ -178,15 +181,22 @@ export const useConvertParameters = (): ConvertParametersHook => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const analyzeFileTypes = (files: Array<{name: string}>) => {
|
const analyzeFileTypes = useCallback((files: Array<{name: string}>) => {
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
// No files - only reset smart detection, keep user's format choices
|
// No files - only reset smart detection, keep user's format choices
|
||||||
baseHook.setParameters(prev => ({
|
baseHook.setParameters(prev => {
|
||||||
|
// Only update if something actually changed
|
||||||
|
if (prev.isSmartDetection === false && prev.smartDetectionType === 'none') {
|
||||||
|
return prev; // No change needed
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
isSmartDetection: false,
|
isSmartDetection: false,
|
||||||
smartDetectionType: 'none'
|
smartDetectionType: 'none'
|
||||||
// Don't reset fromExtension and toExtension - let user keep their choices
|
// Don't reset fromExtension and toExtension - let user keep their choices
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,13 +231,25 @@ export const useConvertParameters = (): ConvertParametersHook => {
|
|||||||
newToExtension = availableTargets.length === 1 ? availableTargets[0] : '';
|
newToExtension = availableTargets.length === 1 ? availableTargets[0] : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const newState = {
|
||||||
...prev,
|
...prev,
|
||||||
isSmartDetection: false,
|
isSmartDetection: false,
|
||||||
smartDetectionType: 'none',
|
smartDetectionType: 'none' as const,
|
||||||
fromExtension: fromExt,
|
fromExtension: fromExt,
|
||||||
toExtension: newToExtension
|
toExtension: newToExtension
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Only update if something actually changed
|
||||||
|
if (
|
||||||
|
prev.isSmartDetection === newState.isSmartDetection &&
|
||||||
|
prev.smartDetectionType === newState.smartDetectionType &&
|
||||||
|
prev.fromExtension === newState.fromExtension &&
|
||||||
|
prev.toExtension === newState.toExtension
|
||||||
|
) {
|
||||||
|
return prev; // Return the same object to prevent re-render
|
||||||
|
}
|
||||||
|
|
||||||
|
return newState;
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -262,13 +284,25 @@ export const useConvertParameters = (): ConvertParametersHook => {
|
|||||||
newToExtension = availableTargets.length === 1 ? availableTargets[0] : '';
|
newToExtension = availableTargets.length === 1 ? availableTargets[0] : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const newState = {
|
||||||
...prev,
|
...prev,
|
||||||
isSmartDetection: false,
|
isSmartDetection: false,
|
||||||
smartDetectionType: 'none',
|
smartDetectionType: 'none' as const,
|
||||||
fromExtension: fromExt,
|
fromExtension: fromExt,
|
||||||
toExtension: newToExtension
|
toExtension: newToExtension
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Only update if something actually changed
|
||||||
|
if (
|
||||||
|
prev.isSmartDetection === newState.isSmartDetection &&
|
||||||
|
prev.smartDetectionType === newState.smartDetectionType &&
|
||||||
|
prev.fromExtension === newState.fromExtension &&
|
||||||
|
prev.toExtension === newState.toExtension
|
||||||
|
) {
|
||||||
|
return prev; // Return the same object to prevent re-render
|
||||||
|
}
|
||||||
|
|
||||||
|
return newState;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Mixed file types
|
// Mixed file types
|
||||||
@@ -277,34 +311,64 @@ export const useConvertParameters = (): ConvertParametersHook => {
|
|||||||
|
|
||||||
if (allImages) {
|
if (allImages) {
|
||||||
// All files are images - use image-to-pdf conversion
|
// All files are images - use image-to-pdf conversion
|
||||||
baseHook.setParameters(prev => ({
|
baseHook.setParameters(prev => {
|
||||||
|
// Only update if something actually changed
|
||||||
|
if (prev.isSmartDetection === true &&
|
||||||
|
prev.smartDetectionType === 'images' &&
|
||||||
|
prev.fromExtension === 'image' &&
|
||||||
|
prev.toExtension === 'pdf') {
|
||||||
|
return prev; // No change needed
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
isSmartDetection: true,
|
isSmartDetection: true,
|
||||||
smartDetectionType: 'images',
|
smartDetectionType: 'images',
|
||||||
fromExtension: 'image',
|
fromExtension: 'image',
|
||||||
toExtension: 'pdf'
|
toExtension: 'pdf'
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
} else if (allWeb) {
|
} else if (allWeb) {
|
||||||
// All files are web files - use html-to-pdf conversion
|
// All files are web files - use html-to-pdf conversion
|
||||||
baseHook.setParameters(prev => ({
|
baseHook.setParameters(prev => {
|
||||||
|
// Only update if something actually changed
|
||||||
|
if (prev.isSmartDetection === true &&
|
||||||
|
prev.smartDetectionType === 'web' &&
|
||||||
|
prev.fromExtension === 'html' &&
|
||||||
|
prev.toExtension === 'pdf') {
|
||||||
|
return prev; // No change needed
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
isSmartDetection: true,
|
isSmartDetection: true,
|
||||||
smartDetectionType: 'web',
|
smartDetectionType: 'web',
|
||||||
fromExtension: 'html',
|
fromExtension: 'html',
|
||||||
toExtension: 'pdf'
|
toExtension: 'pdf'
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
// Mixed non-image types - use file-to-pdf conversion
|
// Mixed non-image types - use file-to-pdf conversion
|
||||||
baseHook.setParameters(prev => ({
|
baseHook.setParameters(prev => {
|
||||||
|
// Only update if something actually changed
|
||||||
|
if (prev.isSmartDetection === true &&
|
||||||
|
prev.smartDetectionType === 'mixed' &&
|
||||||
|
prev.fromExtension === 'any' &&
|
||||||
|
prev.toExtension === 'pdf') {
|
||||||
|
return prev; // No change needed
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
isSmartDetection: true,
|
isSmartDetection: true,
|
||||||
smartDetectionType: 'mixed',
|
smartDetectionType: 'mixed',
|
||||||
fromExtension: 'any',
|
fromExtension: 'any',
|
||||||
toExtension: 'pdf'
|
toExtension: 'pdf'
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [baseHook.setParameters]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...baseHook,
|
...baseHook,
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export const useToolOperation = <TParams = void>(
|
|||||||
config: ToolOperationConfig<TParams>
|
config: ToolOperationConfig<TParams>
|
||||||
): ToolOperationHook<TParams> => {
|
): ToolOperationHook<TParams> => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles } = useFileContext();
|
const { recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles, findFileId } = useFileContext();
|
||||||
|
|
||||||
// Composed hooks
|
// Composed hooks
|
||||||
const { state, actions } = useToolState();
|
const { state, actions } = useToolState();
|
||||||
@@ -198,8 +198,9 @@ export const useToolOperation = <TParams = void>(
|
|||||||
actions.setThumbnails(thumbnails);
|
actions.setThumbnails(thumbnails);
|
||||||
actions.setDownloadInfo(downloadInfo.url, downloadInfo.filename);
|
actions.setDownloadInfo(downloadInfo.url, downloadInfo.filename);
|
||||||
|
|
||||||
// Consume input files and add output files (will replace unpinned inputs)
|
// Replace input files with processed files (consumeFiles handles pinning)
|
||||||
await consumeFiles(validFiles, processedFiles);
|
const inputFileIds = validFiles.map(file => findFileId(file)).filter(Boolean) as string[];
|
||||||
|
await consumeFiles(inputFileIds, processedFiles);
|
||||||
|
|
||||||
markOperationApplied(fileId, operationId);
|
markOperationApplied(fileId, operationId);
|
||||||
}
|
}
|
||||||
@@ -213,7 +214,7 @@ export const useToolOperation = <TParams = void>(
|
|||||||
actions.setLoading(false);
|
actions.setLoading(false);
|
||||||
actions.setProgress(null);
|
actions.setProgress(null);
|
||||||
}
|
}
|
||||||
}, [t, config, actions, recordOperation, markOperationApplied, markOperationFailed, addFiles, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles]);
|
}, [t, config, actions, recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles, findFileId, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles]);
|
||||||
|
|
||||||
const cancelOperation = useCallback(() => {
|
const cancelOperation = useCallback(() => {
|
||||||
cancelApiCalls();
|
cancelApiCalls();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
import { generateThumbnailForFile } from '../../../utils/thumbnailUtils';
|
import { generateThumbnailForFile, generateThumbnailWithMetadata, ThumbnailWithMetadata } from '../../../utils/thumbnailUtils';
|
||||||
import { zipFileService } from '../../../services/zipFileService';
|
import { zipFileService } from '../../../services/zipFileService';
|
||||||
|
|
||||||
|
|
||||||
@@ -11,20 +11,28 @@ export const useToolResources = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const cleanupBlobUrls = useCallback(() => {
|
const cleanupBlobUrls = useCallback(() => {
|
||||||
blobUrls.forEach(url => {
|
setBlobUrls(prev => {
|
||||||
|
prev.forEach(url => {
|
||||||
try {
|
try {
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to revoke blob URL:', error);
|
console.warn('Failed to revoke blob URL:', error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
setBlobUrls([]);
|
return [];
|
||||||
|
});
|
||||||
|
}, []); // No dependencies - use functional update pattern
|
||||||
|
|
||||||
|
// Cleanup on unmount - use ref to avoid dependency on blobUrls state
|
||||||
|
const blobUrlsRef = useRef<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
blobUrlsRef.current = blobUrls;
|
||||||
}, [blobUrls]);
|
}, [blobUrls]);
|
||||||
|
|
||||||
// Cleanup on unmount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
blobUrls.forEach(url => {
|
blobUrlsRef.current.forEach(url => {
|
||||||
try {
|
try {
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -32,19 +40,20 @@ export const useToolResources = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}, [blobUrls]);
|
}, []); // No dependencies - use ref to access current URLs
|
||||||
|
|
||||||
const generateThumbnails = useCallback(async (files: File[]): Promise<string[]> => {
|
const generateThumbnails = useCallback(async (files: File[]): Promise<string[]> => {
|
||||||
|
console.log(`🖼️ useToolResources.generateThumbnails: Starting for ${files.length} files`);
|
||||||
const thumbnails: string[] = [];
|
const thumbnails: string[] = [];
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
try {
|
try {
|
||||||
|
console.log(`🖼️ Generating thumbnail for: ${file.name} (${file.type}, ${file.size} bytes)`);
|
||||||
const thumbnail = await generateThumbnailForFile(file);
|
const thumbnail = await generateThumbnailForFile(file);
|
||||||
if (thumbnail) {
|
console.log(`🖼️ Generated thumbnail for ${file.name}: SUCCESS`);
|
||||||
thumbnails.push(thumbnail);
|
thumbnails.push(thumbnail);
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`Failed to generate thumbnail for ${file.name}:`, error);
|
console.warn(`🖼️ Failed to generate thumbnail for ${file.name}:`, error);
|
||||||
thumbnails.push('');
|
thumbnails.push('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,6 +61,26 @@ export const useToolResources = () => {
|
|||||||
return thumbnails;
|
return thumbnails;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const generateThumbnailsWithMetadata = useCallback(async (files: File[]): Promise<ThumbnailWithMetadata[]> => {
|
||||||
|
console.log(`🖼️ useToolResources.generateThumbnailsWithMetadata: Starting for ${files.length} files`);
|
||||||
|
const results: ThumbnailWithMetadata[] = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
console.log(`🖼️ Generating thumbnail with metadata for: ${file.name} (${file.type}, ${file.size} bytes)`);
|
||||||
|
const result = await generateThumbnailWithMetadata(file);
|
||||||
|
console.log(`🖼️ Generated thumbnail with metadata for ${file.name}: SUCCESS, ${result.pageCount} pages`);
|
||||||
|
results.push(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`🖼️ Failed to generate thumbnail with metadata for ${file.name}:`, error);
|
||||||
|
results.push({ thumbnail: '', pageCount: 1 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🖼️ useToolResources.generateThumbnailsWithMetadata: Complete. Generated ${results.length}/${files.length} thumbnails with metadata`);
|
||||||
|
return results;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const extractZipFiles = useCallback(async (zipBlob: Blob): Promise<File[]> => {
|
const extractZipFiles = useCallback(async (zipBlob: Blob): Promise<File[]> => {
|
||||||
try {
|
try {
|
||||||
const zipFile = new File([zipBlob], 'temp.zip', { type: 'application/zip' });
|
const zipFile = new File([zipBlob], 'temp.zip', { type: 'application/zip' });
|
||||||
@@ -108,6 +137,7 @@ export const useToolResources = () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
generateThumbnails,
|
generateThumbnails,
|
||||||
|
generateThumbnailsWithMetadata,
|
||||||
createDownloadInfo,
|
createDownloadInfo,
|
||||||
extractZipFiles,
|
extractZipFiles,
|
||||||
extractAllZipFiles,
|
extractAllZipFiles,
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ export const useToolState = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setThumbnails = useCallback((thumbnails: string[]) => {
|
const setThumbnails = useCallback((thumbnails: string[]) => {
|
||||||
|
console.log(`🔧 useToolState.setThumbnails: Setting ${thumbnails.length} thumbnails:`,
|
||||||
|
thumbnails.map((t, i) => `[${i}]: ${t ? 'PRESENT' : 'MISSING'}`));
|
||||||
dispatch({ type: 'SET_THUMBNAILS', payload: thumbnails });
|
dispatch({ type: 'SET_THUMBNAILS', payload: thumbnails });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +1,38 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useFileContext } from '../contexts/FileContext';
|
import { useFileState, useFileActions } from '../contexts/FileContext';
|
||||||
|
import { FileMetadata } from '../types/file';
|
||||||
|
|
||||||
export const useFileHandler = () => {
|
export const useFileHandler = () => {
|
||||||
const { activeFiles, addFiles } = useFileContext();
|
const { state } = useFileState(); // Still needed for addStoredFiles
|
||||||
|
const { actions } = useFileActions();
|
||||||
|
|
||||||
const addToActiveFiles = useCallback(async (file: File) => {
|
const addToActiveFiles = useCallback(async (file: File) => {
|
||||||
const exists = activeFiles.some(f => f.name === file.name && f.size === file.size);
|
// Let FileContext handle deduplication with quickKey logic
|
||||||
if (!exists) {
|
await actions.addFiles([file]);
|
||||||
await addFiles([file]);
|
}, [actions.addFiles]);
|
||||||
}
|
|
||||||
}, [activeFiles, addFiles]);
|
|
||||||
|
|
||||||
const addMultipleFiles = useCallback(async (files: File[]) => {
|
const addMultipleFiles = useCallback(async (files: File[]) => {
|
||||||
const newFiles = files.filter(file =>
|
// Let FileContext handle deduplication with quickKey logic
|
||||||
!activeFiles.some(f => f.name === file.name && f.size === file.size)
|
await actions.addFiles(files);
|
||||||
);
|
}, [actions.addFiles]);
|
||||||
|
|
||||||
|
// Add stored files preserving their original IDs to prevent session duplicates
|
||||||
|
const addStoredFiles = useCallback(async (filesWithMetadata: Array<{ file: File; originalId: string; metadata: FileMetadata }>) => {
|
||||||
|
// Filter out files that already exist with the same ID (exact match)
|
||||||
|
const newFiles = filesWithMetadata.filter(({ originalId }) => {
|
||||||
|
return state.files.byId[originalId] === undefined;
|
||||||
|
});
|
||||||
|
|
||||||
if (newFiles.length > 0) {
|
if (newFiles.length > 0) {
|
||||||
await addFiles(newFiles);
|
await actions.addStoredFiles(newFiles);
|
||||||
}
|
}
|
||||||
}, [activeFiles, addFiles]);
|
|
||||||
|
console.log(`📁 Added ${newFiles.length} stored files (${filesWithMetadata.length - newFiles.length} skipped as duplicates)`);
|
||||||
|
}, [state.files.byId, actions.addStoredFiles]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
addToActiveFiles,
|
addToActiveFiles,
|
||||||
addMultipleFiles,
|
addMultipleFiles,
|
||||||
|
addStoredFiles,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -1,84 +1,125 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { fileStorage } from '../services/fileStorage';
|
import { useIndexedDB } from '../contexts/IndexedDBContext';
|
||||||
import { FileWithUrl } from '../types/file';
|
import { FileMetadata } from '../types/file';
|
||||||
import { createEnhancedFileFromStored } from '../utils/fileUtils';
|
|
||||||
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
|
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
|
||||||
|
|
||||||
export const useFileManager = () => {
|
export const useFileManager = () => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const indexedDB = useIndexedDB();
|
||||||
|
|
||||||
const convertToFile = useCallback(async (fileWithUrl: FileWithUrl): Promise<File> => {
|
const convertToFile = useCallback(async (fileMetadata: FileMetadata): Promise<File> => {
|
||||||
if (fileWithUrl.url && fileWithUrl.url.startsWith('blob:')) {
|
if (!indexedDB) {
|
||||||
const response = await fetch(fileWithUrl.url);
|
throw new Error('IndexedDB context not available');
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle drafts differently from regular files
|
||||||
|
if (fileMetadata.isDraft) {
|
||||||
|
// Load draft from the drafts database
|
||||||
|
try {
|
||||||
|
const { indexedDBManager, DATABASE_CONFIGS } = await import('../services/indexedDBManager');
|
||||||
|
const db = await indexedDBManager.openDatabase(DATABASE_CONFIGS.DRAFTS);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = db.transaction(['drafts'], 'readonly');
|
||||||
|
const store = transaction.objectStore('drafts');
|
||||||
|
const request = store.get(fileMetadata.id);
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const draft = request.result;
|
||||||
|
if (draft && draft.pdfData) {
|
||||||
|
const file = new File([draft.pdfData], fileMetadata.name, {
|
||||||
|
type: 'application/pdf',
|
||||||
|
lastModified: fileMetadata.lastModified
|
||||||
|
});
|
||||||
|
resolve(file);
|
||||||
|
} else {
|
||||||
|
reject(new Error('Draft data not found'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to load draft: ${fileMetadata.name} (${error})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular file loading
|
||||||
|
if (fileMetadata.id) {
|
||||||
|
const file = await indexedDB.loadFile(fileMetadata.id);
|
||||||
|
if (file) {
|
||||||
return file;
|
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: ${fileMetadata.name} (ID: ${fileMetadata.id})`);
|
||||||
|
}, [indexedDB]);
|
||||||
|
|
||||||
throw new Error('File not found in storage');
|
const loadRecentFiles = useCallback(async (): Promise<FileMetadata[]> => {
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadRecentFiles = useCallback(async (): Promise<FileWithUrl[]> => {
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const files = await fileStorage.getAllFiles();
|
if (!indexedDB) {
|
||||||
const sortedFiles = files.sort((a, b) => (b.lastModified || 0) - (a.lastModified || 0));
|
return [];
|
||||||
return sortedFiles.map(file => createEnhancedFileFromStored(file));
|
}
|
||||||
|
|
||||||
|
// Load regular files metadata only
|
||||||
|
const storedFileMetadata = await indexedDB.loadAllMetadata();
|
||||||
|
|
||||||
|
// For now, only regular files - drafts will be handled separately in the future
|
||||||
|
const allFiles = storedFileMetadata;
|
||||||
|
const sortedFiles = allFiles.sort((a, b) => (b.lastModified || 0) - (a.lastModified || 0));
|
||||||
|
|
||||||
|
return sortedFiles;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load recent files:', error);
|
console.error('Failed to load recent files:', error);
|
||||||
return [];
|
return [];
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [indexedDB]);
|
||||||
|
|
||||||
const handleRemoveFile = useCallback(async (index: number, files: FileWithUrl[], setFiles: (files: FileWithUrl[]) => void) => {
|
const handleRemoveFile = useCallback(async (index: number, files: FileMetadata[], setFiles: (files: FileMetadata[]) => void) => {
|
||||||
const file = files[index];
|
const file = files[index];
|
||||||
|
if (!file.id) {
|
||||||
|
throw new Error('File ID is required for removal');
|
||||||
|
}
|
||||||
|
if (!indexedDB) {
|
||||||
|
throw new Error('IndexedDB context not available');
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await fileStorage.deleteFile(file.id || file.name);
|
await indexedDB.deleteFile(file.id);
|
||||||
setFiles(files.filter((_, i) => i !== index));
|
setFiles(files.filter((_, i) => i !== index));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to remove file:', error);
|
console.error('Failed to remove file:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}, []);
|
}, [indexedDB]);
|
||||||
|
|
||||||
const storeFile = useCallback(async (file: File) => {
|
const storeFile = useCallback(async (file: File, fileId: string) => {
|
||||||
|
if (!indexedDB) {
|
||||||
|
throw new Error('IndexedDB context not available');
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
// Generate thumbnail for the file
|
// Store file with provided UUID from FileContext (thumbnail generated internally)
|
||||||
const thumbnail = await generateThumbnailForFile(file);
|
const metadata = await indexedDB.saveFile(file, fileId);
|
||||||
|
|
||||||
// Store file with thumbnail
|
// Convert file to ArrayBuffer for StoredFile interface compatibility
|
||||||
const storedFile = await fileStorage.storeFile(file, thumbnail);
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
|
||||||
// Add the ID to the file object
|
// Return StoredFile format for compatibility with old API
|
||||||
Object.defineProperty(file, 'id', { value: storedFile.id, writable: false });
|
return {
|
||||||
return storedFile;
|
id: fileId,
|
||||||
|
name: file.name,
|
||||||
|
type: file.type,
|
||||||
|
size: file.size,
|
||||||
|
lastModified: file.lastModified,
|
||||||
|
data: arrayBuffer,
|
||||||
|
thumbnail: metadata.thumbnail
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to store file:', error);
|
console.error('Failed to store file:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}, []);
|
}, [indexedDB]);
|
||||||
|
|
||||||
const createFileSelectionHandlers = useCallback((
|
const createFileSelectionHandlers = useCallback((
|
||||||
selectedFiles: string[],
|
selectedFiles: string[],
|
||||||
@@ -96,14 +137,23 @@ export const useFileManager = () => {
|
|||||||
setSelectedFiles([]);
|
setSelectedFiles([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectMultipleFiles = async (files: FileWithUrl[], onFilesSelect: (files: File[]) => void) => {
|
const selectMultipleFiles = async (files: FileMetadata[], onStoredFilesSelect: (filesWithMetadata: Array<{ file: File; originalId: string; metadata: FileMetadata }>) => void) => {
|
||||||
if (selectedFiles.length === 0) return;
|
if (selectedFiles.length === 0) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const selectedFileObjects = files.filter(f => selectedFiles.includes(f.id || f.name));
|
// Filter by UUID and convert to File objects
|
||||||
const filePromises = selectedFileObjects.map(convertToFile);
|
const selectedFileObjects = files.filter(f => selectedFiles.includes(f.id));
|
||||||
const convertedFiles = await Promise.all(filePromises);
|
|
||||||
onFilesSelect(convertedFiles);
|
// Use stored files flow that preserves IDs
|
||||||
|
const filesWithMetadata = await Promise.all(
|
||||||
|
selectedFileObjects.map(async (metadata) => ({
|
||||||
|
file: await convertToFile(metadata),
|
||||||
|
originalId: metadata.id,
|
||||||
|
metadata
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
onStoredFilesSelect(filesWithMetadata);
|
||||||
|
|
||||||
clearSelection();
|
clearSelection();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load selected files:', error);
|
console.error('Failed to load selected files:', error);
|
||||||
@@ -119,12 +169,18 @@ export const useFileManager = () => {
|
|||||||
}, [convertToFile]);
|
}, [convertToFile]);
|
||||||
|
|
||||||
const touchFile = useCallback(async (id: string) => {
|
const touchFile = useCallback(async (id: string) => {
|
||||||
|
if (!indexedDB) {
|
||||||
|
console.warn('IndexedDB context not available for touch operation');
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await fileStorage.touchFile(id);
|
// Update access time - this will be handled by the cache in IndexedDBContext
|
||||||
|
// when the file is loaded, so we can just load it briefly to "touch" it
|
||||||
|
await indexedDB.loadFile(id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to touch file:', error);
|
console.error('Failed to touch file:', error);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [indexedDB]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loading,
|
loading,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { FileWithUrl } from "../types/file";
|
import { FileMetadata } from "../types/file";
|
||||||
import { fileStorage } from "../services/fileStorage";
|
import { useIndexedDB } from "../contexts/IndexedDBContext";
|
||||||
import { generateThumbnailForFile } from "../utils/thumbnailUtils";
|
import { generateThumbnailForFile } from "../utils/thumbnailUtils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,12 +22,13 @@ function calculateThumbnailScale(pageViewport: { width: number; height: number }
|
|||||||
* Hook for IndexedDB-aware thumbnail loading
|
* Hook for IndexedDB-aware thumbnail loading
|
||||||
* Handles thumbnail generation for files not in IndexedDB
|
* Handles thumbnail generation for files not in IndexedDB
|
||||||
*/
|
*/
|
||||||
export function useIndexedDBThumbnail(file: FileWithUrl | undefined | null): {
|
export function useIndexedDBThumbnail(file: FileMetadata | undefined | null): {
|
||||||
thumbnail: string | null;
|
thumbnail: string | null;
|
||||||
isGenerating: boolean
|
isGenerating: boolean
|
||||||
} {
|
} {
|
||||||
const [thumb, setThumb] = useState<string | null>(null);
|
const [thumb, setThumb] = useState<string | null>(null);
|
||||||
const [generating, setGenerating] = useState(false);
|
const [generating, setGenerating] = useState(false);
|
||||||
|
const indexedDB = useIndexedDB();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -44,46 +45,36 @@ export function useIndexedDBThumbnail(file: FileWithUrl | undefined | null): {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Second priority: generate thumbnail for any file type
|
// Second priority: generate thumbnail for files under 100MB
|
||||||
if (file.size < 100 * 1024 * 1024 && !generating) {
|
if (file.size < 100 * 1024 * 1024 && !generating) {
|
||||||
setGenerating(true);
|
setGenerating(true);
|
||||||
try {
|
try {
|
||||||
let fileObject: File;
|
let fileObject: File;
|
||||||
|
|
||||||
// Handle IndexedDB files vs regular File objects
|
// Try to load file from IndexedDB using new context
|
||||||
if (file.storedInIndexedDB && file.id) {
|
if (file.id && indexedDB) {
|
||||||
// For IndexedDB files, recreate File object from stored data
|
const loadedFile = await indexedDB.loadFile(file.id);
|
||||||
const storedFile = await fileStorage.getFile(file.id);
|
if (!loadedFile) {
|
||||||
if (!storedFile) {
|
|
||||||
throw new Error('File not found in IndexedDB');
|
throw new Error('File not found in IndexedDB');
|
||||||
}
|
}
|
||||||
fileObject = new File([storedFile.data], storedFile.name, {
|
fileObject = loadedFile;
|
||||||
type: storedFile.type,
|
|
||||||
lastModified: storedFile.lastModified
|
|
||||||
});
|
|
||||||
} else if ((file as any /* Fix me */).file) {
|
|
||||||
// For FileWithUrl objects that have a File object
|
|
||||||
fileObject = (file as any /* Fix me */).file;
|
|
||||||
} else if (file.id) {
|
|
||||||
// Fallback: try to get from IndexedDB even if storedInIndexedDB flag is missing
|
|
||||||
const storedFile = await fileStorage.getFile(file.id);
|
|
||||||
if (!storedFile) {
|
|
||||||
throw new Error('File not found in IndexedDB and no File object available');
|
|
||||||
}
|
|
||||||
fileObject = new File([storedFile.data], storedFile.name, {
|
|
||||||
type: storedFile.type,
|
|
||||||
lastModified: storedFile.lastModified
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error('File object not available and no ID for IndexedDB lookup');
|
throw new Error('File ID not available or IndexedDB context not available');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the universal thumbnail generator
|
// Use the universal thumbnail generator
|
||||||
const thumbnail = await generateThumbnailForFile(fileObject);
|
const thumbnail = await generateThumbnailForFile(fileObject);
|
||||||
if (!cancelled && thumbnail) {
|
if (!cancelled) {
|
||||||
setThumb(thumbnail);
|
setThumb(thumbnail);
|
||||||
} else if (!cancelled) {
|
|
||||||
setThumb(null);
|
// Save thumbnail to IndexedDB for persistence
|
||||||
|
if (file.id && indexedDB && thumbnail) {
|
||||||
|
try {
|
||||||
|
await indexedDB.updateThumbnail(file.id, thumbnail);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to save thumbnail to IndexedDB:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to generate thumbnail for file', file.name, error);
|
console.warn('Failed to generate thumbnail for file', file.name, error);
|
||||||
@@ -92,14 +83,14 @@ export function useIndexedDBThumbnail(file: FileWithUrl | undefined | null): {
|
|||||||
if (!cancelled) setGenerating(false);
|
if (!cancelled) setGenerating(false);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Large files - generate placeholder
|
// Large files - no thumbnail
|
||||||
setThumb(null);
|
setThumb(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadThumbnail();
|
loadThumbnail();
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [file, file?.thumbnail, file?.id]);
|
}, [file, file?.thumbnail, file?.id, indexedDB, generating]);
|
||||||
|
|
||||||
return { thumbnail: thumb, isGenerating: generating };
|
return { thumbnail: thumb, isGenerating: generating };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { getDocument } from 'pdfjs-dist';
|
|
||||||
import { PDFDocument, PDFPage } from '../types/pageEditor';
|
import { PDFDocument, PDFPage } from '../types/pageEditor';
|
||||||
|
import { pdfWorkerManager } from '../services/pdfWorkerManager';
|
||||||
|
|
||||||
export function usePDFProcessor() {
|
export function usePDFProcessor() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -13,7 +13,7 @@ export function usePDFProcessor() {
|
|||||||
): Promise<string> => {
|
): Promise<string> => {
|
||||||
try {
|
try {
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
const pdf = await getDocument({ data: arrayBuffer }).promise;
|
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
|
||||||
const page = await pdf.getPage(pageNumber);
|
const page = await pdf.getPage(pageNumber);
|
||||||
|
|
||||||
const viewport = page.getViewport({ scale });
|
const viewport = page.getViewport({ scale });
|
||||||
@@ -29,8 +29,8 @@ export function usePDFProcessor() {
|
|||||||
await page.render({ canvasContext: context, viewport }).promise;
|
await page.render({ canvasContext: context, viewport }).promise;
|
||||||
const thumbnail = canvas.toDataURL();
|
const thumbnail = canvas.toDataURL();
|
||||||
|
|
||||||
// Clean up
|
// Clean up using worker manager
|
||||||
pdf.destroy();
|
pdfWorkerManager.destroyDocument(pdf);
|
||||||
|
|
||||||
return thumbnail;
|
return thumbnail;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -39,13 +39,35 @@ export function usePDFProcessor() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Internal function to generate thumbnail from already-opened PDF
|
||||||
|
const generateThumbnailFromPDF = useCallback(async (
|
||||||
|
pdf: any,
|
||||||
|
pageNumber: number,
|
||||||
|
scale: number = 0.5
|
||||||
|
): Promise<string> => {
|
||||||
|
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;
|
||||||
|
return canvas.toDataURL();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const processPDFFile = useCallback(async (file: File): Promise<PDFDocument> => {
|
const processPDFFile = useCallback(async (file: File): Promise<PDFDocument> => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
const pdf = await getDocument({ data: arrayBuffer }).promise;
|
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
|
||||||
const totalPages = pdf.numPages;
|
const totalPages = pdf.numPages;
|
||||||
|
|
||||||
const pages: PDFPage[] = [];
|
const pages: PDFPage[] = [];
|
||||||
@@ -61,19 +83,19 @@ export function usePDFProcessor() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate thumbnails for first 10 pages immediately for better UX
|
// Generate thumbnails for first 10 pages immediately using the same PDF instance
|
||||||
const priorityPages = Math.min(10, totalPages);
|
const priorityPages = Math.min(10, totalPages);
|
||||||
for (let i = 1; i <= priorityPages; i++) {
|
for (let i = 1; i <= priorityPages; i++) {
|
||||||
try {
|
try {
|
||||||
const thumbnail = await generatePageThumbnail(file, i);
|
const thumbnail = await generateThumbnailFromPDF(pdf, i);
|
||||||
pages[i - 1].thumbnail = thumbnail;
|
pages[i - 1].thumbnail = thumbnail;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`Failed to generate thumbnail for page ${i}:`, error);
|
console.warn(`Failed to generate thumbnail for page ${i}:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up
|
// Clean up using worker manager
|
||||||
pdf.destroy();
|
pdfWorkerManager.destroyDocument(pdf);
|
||||||
|
|
||||||
const document: PDFDocument = {
|
const document: PDFDocument = {
|
||||||
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||||
@@ -91,7 +113,7 @@ export function usePDFProcessor() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [generatePageThumbnail]);
|
}, [generateThumbnailFromPDF]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
processPDFFile,
|
processPDFFile,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import * as pdfjsLib from 'pdfjs-dist';
|
import * as pdfjsLib from 'pdfjs-dist';
|
||||||
|
import { pdfWorkerManager } from '../services/pdfWorkerManager';
|
||||||
|
|
||||||
export interface PdfSignatureDetectionResult {
|
export interface PdfSignatureDetectionResult {
|
||||||
hasDigitalSignatures: boolean;
|
hasDigitalSignatures: boolean;
|
||||||
@@ -21,14 +22,12 @@ export const usePdfSignatureDetection = (files: File[]): PdfSignatureDetectionRe
|
|||||||
let foundSignature = false;
|
let foundSignature = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Set up PDF.js worker
|
|
||||||
pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdfjs-legacy/pdf.worker.mjs';
|
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
|
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
|
||||||
|
|
||||||
for (let i = 1; i <= pdf.numPages; i++) {
|
for (let i = 1; i <= pdf.numPages; i++) {
|
||||||
const page = await pdf.getPage(i);
|
const page = await pdf.getPage(i);
|
||||||
@@ -42,6 +41,9 @@ export const usePdfSignatureDetection = (files: File[]): PdfSignatureDetectionRe
|
|||||||
|
|
||||||
if (foundSignature) break;
|
if (foundSignature) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up PDF document using worker manager
|
||||||
|
pdfWorkerManager.destroyDocument(pdf);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Error analyzing PDF for signatures:', error);
|
console.warn('Error analyzing PDF for signatures:', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,121 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback, useRef } from 'react';
|
||||||
import { thumbnailGenerationService } from '../services/thumbnailGenerationService';
|
import { thumbnailGenerationService } from '../services/thumbnailGenerationService';
|
||||||
|
|
||||||
|
// Request queue to handle concurrent thumbnail requests
|
||||||
|
interface ThumbnailRequest {
|
||||||
|
pageId: string;
|
||||||
|
file: File;
|
||||||
|
pageNumber: number;
|
||||||
|
resolve: (thumbnail: string | null) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global request queue (shared across all hook instances)
|
||||||
|
const requestQueue: ThumbnailRequest[] = [];
|
||||||
|
let isProcessingQueue = false;
|
||||||
|
let batchTimer: number | null = null;
|
||||||
|
|
||||||
|
// Track active thumbnail requests to prevent duplicates across components
|
||||||
|
const activeRequests = new Map<string, Promise<string | null>>();
|
||||||
|
|
||||||
|
// Batch processing configuration
|
||||||
|
const BATCH_SIZE = 20; // Process thumbnails in batches of 20 for better UI responsiveness
|
||||||
|
const BATCH_DELAY = 100; // Wait 100ms to collect requests before processing
|
||||||
|
const PRIORITY_BATCH_DELAY = 50; // Faster processing for the first batch (visible pages)
|
||||||
|
|
||||||
|
// Process the queue in batches for better performance
|
||||||
|
async function processRequestQueue() {
|
||||||
|
if (isProcessingQueue || requestQueue.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isProcessingQueue = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (requestQueue.length > 0) {
|
||||||
|
// Sort queue by page number to prioritize visible pages first
|
||||||
|
requestQueue.sort((a, b) => a.pageNumber - b.pageNumber);
|
||||||
|
|
||||||
|
// Take a batch of requests (same file only for efficiency)
|
||||||
|
const batchSize = Math.min(BATCH_SIZE, requestQueue.length);
|
||||||
|
const batch = requestQueue.splice(0, batchSize);
|
||||||
|
|
||||||
|
// Group by file to process efficiently
|
||||||
|
const fileGroups = new Map<File, ThumbnailRequest[]>();
|
||||||
|
|
||||||
|
// First, resolve any cached thumbnails immediately
|
||||||
|
const uncachedRequests: ThumbnailRequest[] = [];
|
||||||
|
|
||||||
|
for (const request of batch) {
|
||||||
|
const cached = thumbnailGenerationService.getThumbnailFromCache(request.pageId);
|
||||||
|
if (cached) {
|
||||||
|
request.resolve(cached);
|
||||||
|
} else {
|
||||||
|
uncachedRequests.push(request);
|
||||||
|
|
||||||
|
if (!fileGroups.has(request.file)) {
|
||||||
|
fileGroups.set(request.file, []);
|
||||||
|
}
|
||||||
|
fileGroups.get(request.file)!.push(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process each file group with batch thumbnail generation
|
||||||
|
for (const [file, requests] of fileGroups) {
|
||||||
|
if (requests.length === 0) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pageNumbers = requests.map(req => req.pageNumber);
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
|
||||||
|
console.log(`📸 Batch generating ${requests.length} thumbnails for pages: ${pageNumbers.slice(0, 5).join(', ')}${pageNumbers.length > 5 ? '...' : ''}`);
|
||||||
|
|
||||||
|
// Use file name as fileId for PDF document caching
|
||||||
|
const fileId = file.name + '_' + file.size + '_' + file.lastModified;
|
||||||
|
|
||||||
|
const results = await thumbnailGenerationService.generateThumbnails(
|
||||||
|
fileId,
|
||||||
|
arrayBuffer,
|
||||||
|
pageNumbers,
|
||||||
|
{ scale: 1.0, quality: 0.8, batchSize: BATCH_SIZE },
|
||||||
|
(progress) => {
|
||||||
|
// Optional: Could emit progress events here for UI feedback
|
||||||
|
console.log(`📸 Batch progress: ${progress.completed}/${progress.total} thumbnails generated`);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Match results back to requests and resolve
|
||||||
|
for (const request of requests) {
|
||||||
|
const result = results.find(r => r.pageNumber === request.pageNumber);
|
||||||
|
|
||||||
|
if (result && result.success && result.thumbnail) {
|
||||||
|
thumbnailGenerationService.addThumbnailToCache(request.pageId, result.thumbnail);
|
||||||
|
request.resolve(result.thumbnail);
|
||||||
|
} else {
|
||||||
|
console.warn(`No result for page ${request.pageNumber}`);
|
||||||
|
request.resolve(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Batch thumbnail generation failed for ${requests.length} pages:`, error);
|
||||||
|
// Reject all requests in this batch
|
||||||
|
requests.forEach(request => request.reject(error as Error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isProcessingQueue = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for tools that want to use thumbnail generation
|
* Hook for tools that want to use thumbnail generation
|
||||||
* Tools can choose whether to include visual features
|
* Tools can choose whether to include visual features
|
||||||
*/
|
*/
|
||||||
export function useThumbnailGeneration() {
|
export function useThumbnailGeneration() {
|
||||||
const generateThumbnails = useCallback(async (
|
const generateThumbnails = useCallback(async (
|
||||||
|
fileId: string,
|
||||||
pdfArrayBuffer: ArrayBuffer,
|
pdfArrayBuffer: ArrayBuffer,
|
||||||
pageNumbers: number[],
|
pageNumbers: number[],
|
||||||
options: {
|
options: {
|
||||||
@@ -18,6 +127,7 @@ export function useThumbnailGeneration() {
|
|||||||
onProgress?: (progress: { completed: number; total: number; thumbnails: any[] }) => void
|
onProgress?: (progress: { completed: number; total: number; thumbnails: any[] }) => void
|
||||||
) => {
|
) => {
|
||||||
return thumbnailGenerationService.generateThumbnails(
|
return thumbnailGenerationService.generateThumbnails(
|
||||||
|
fileId,
|
||||||
pdfArrayBuffer,
|
pdfArrayBuffer,
|
||||||
pageNumbers,
|
pageNumbers,
|
||||||
options,
|
options,
|
||||||
@@ -42,15 +152,88 @@ export function useThumbnailGeneration() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const destroyThumbnails = useCallback(() => {
|
const destroyThumbnails = useCallback(() => {
|
||||||
|
// Clear any pending batch timer
|
||||||
|
if (batchTimer) {
|
||||||
|
clearTimeout(batchTimer);
|
||||||
|
batchTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the queue and active requests
|
||||||
|
requestQueue.length = 0;
|
||||||
|
activeRequests.clear();
|
||||||
|
isProcessingQueue = false;
|
||||||
|
|
||||||
thumbnailGenerationService.destroy();
|
thumbnailGenerationService.destroy();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const clearPDFCacheForFile = useCallback((fileId: string) => {
|
||||||
|
thumbnailGenerationService.clearPDFCacheForFile(fileId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const requestThumbnail = useCallback(async (
|
||||||
|
pageId: string,
|
||||||
|
file: File,
|
||||||
|
pageNumber: number
|
||||||
|
): Promise<string | null> => {
|
||||||
|
// Check cache first for immediate return
|
||||||
|
const cached = thumbnailGenerationService.getThumbnailFromCache(pageId);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this request is already being processed globally
|
||||||
|
const activeRequest = activeRequests.get(pageId);
|
||||||
|
if (activeRequest) {
|
||||||
|
return activeRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new request promise and track it globally
|
||||||
|
const requestPromise = new Promise<string | null>((resolve, reject) => {
|
||||||
|
requestQueue.push({
|
||||||
|
pageId,
|
||||||
|
file,
|
||||||
|
pageNumber,
|
||||||
|
resolve: (result: string | null) => {
|
||||||
|
activeRequests.delete(pageId);
|
||||||
|
resolve(result);
|
||||||
|
},
|
||||||
|
reject: (error: Error) => {
|
||||||
|
activeRequests.delete(pageId);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Schedule batch processing with a small delay to collect more requests
|
||||||
|
if (batchTimer) {
|
||||||
|
clearTimeout(batchTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use shorter delay for the first batch (pages 1-50) to show visible content faster
|
||||||
|
const isFirstBatch = requestQueue.length <= BATCH_SIZE && requestQueue.every(req => req.pageNumber <= BATCH_SIZE);
|
||||||
|
const delay = isFirstBatch ? PRIORITY_BATCH_DELAY : BATCH_DELAY;
|
||||||
|
|
||||||
|
batchTimer = window.setTimeout(() => {
|
||||||
|
processRequestQueue().catch(error => {
|
||||||
|
console.error('Error processing thumbnail request queue:', error);
|
||||||
|
});
|
||||||
|
batchTimer = null;
|
||||||
|
}, delay);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track this request to prevent duplicates
|
||||||
|
activeRequests.set(pageId, requestPromise);
|
||||||
|
|
||||||
|
return requestPromise;
|
||||||
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
generateThumbnails,
|
generateThumbnails,
|
||||||
addThumbnailToCache,
|
addThumbnailToCache,
|
||||||
getThumbnailFromCache,
|
getThumbnailFromCache,
|
||||||
getCacheStats,
|
getCacheStats,
|
||||||
stopGeneration,
|
stopGeneration,
|
||||||
destroyThumbnails
|
destroyThumbnails,
|
||||||
|
clearPDFCacheForFile,
|
||||||
|
requestThumbnail
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* URL synchronization hooks for tool routing
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useCallback } from 'react';
|
||||||
|
import { ModeType } from '../contexts/NavigationContext';
|
||||||
|
import { parseToolRoute, updateToolRoute, clearToolRoute } from '../utils/urlRouting';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to sync navigation mode with URL
|
||||||
|
*/
|
||||||
|
export function useNavigationUrlSync(
|
||||||
|
currentMode: ModeType,
|
||||||
|
setMode: (mode: ModeType) => void,
|
||||||
|
enableSync: boolean = true
|
||||||
|
) {
|
||||||
|
// Initialize mode from URL on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enableSync) return;
|
||||||
|
|
||||||
|
const route = parseToolRoute();
|
||||||
|
if (route.mode !== currentMode) {
|
||||||
|
setMode(route.mode);
|
||||||
|
}
|
||||||
|
}, []); // Only run on mount
|
||||||
|
|
||||||
|
// Update URL when mode changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enableSync) return;
|
||||||
|
|
||||||
|
if (currentMode === 'pageEditor') {
|
||||||
|
clearToolRoute();
|
||||||
|
} else {
|
||||||
|
updateToolRoute(currentMode, currentMode);
|
||||||
|
}
|
||||||
|
}, [currentMode, enableSync]);
|
||||||
|
|
||||||
|
// Handle browser back/forward navigation
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enableSync) return;
|
||||||
|
|
||||||
|
const handlePopState = () => {
|
||||||
|
const route = parseToolRoute();
|
||||||
|
if (route.mode !== currentMode) {
|
||||||
|
setMode(route.mode);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('popstate', handlePopState);
|
||||||
|
return () => window.removeEventListener('popstate', handlePopState);
|
||||||
|
}, [currentMode, setMode, enableSync]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to sync tool workflow with URL
|
||||||
|
*/
|
||||||
|
export function useToolWorkflowUrlSync(
|
||||||
|
selectedToolKey: string | null,
|
||||||
|
selectTool: (toolKey: string) => void,
|
||||||
|
clearTool: () => void,
|
||||||
|
enableSync: boolean = true
|
||||||
|
) {
|
||||||
|
// Initialize tool from URL on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enableSync) return;
|
||||||
|
|
||||||
|
const route = parseToolRoute();
|
||||||
|
if (route.toolKey && route.toolKey !== selectedToolKey) {
|
||||||
|
selectTool(route.toolKey);
|
||||||
|
} else if (!route.toolKey && selectedToolKey) {
|
||||||
|
clearTool();
|
||||||
|
}
|
||||||
|
}, []); // Only run on mount
|
||||||
|
|
||||||
|
// Update URL when tool changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enableSync) return;
|
||||||
|
|
||||||
|
if (selectedToolKey) {
|
||||||
|
const route = parseToolRoute();
|
||||||
|
if (route.toolKey !== selectedToolKey) {
|
||||||
|
updateToolRoute(selectedToolKey as ModeType, selectedToolKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [selectedToolKey, enableSync]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to get current URL route information
|
||||||
|
*/
|
||||||
|
export function useCurrentRoute() {
|
||||||
|
const getCurrentRoute = useCallback(() => {
|
||||||
|
return parseToolRoute();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return getCurrentRoute;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to programmatically navigate to tools
|
||||||
|
*/
|
||||||
|
export function useToolNavigation() {
|
||||||
|
const navigateToTool = useCallback((toolKey: string) => {
|
||||||
|
updateToolRoute(toolKey as ModeType, toolKey);
|
||||||
|
|
||||||
|
// Dispatch a custom event to notify other components
|
||||||
|
window.dispatchEvent(new CustomEvent('toolNavigation', {
|
||||||
|
detail: { toolKey }
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const navigateToHome = useCallback(() => {
|
||||||
|
clearToolRoute();
|
||||||
|
|
||||||
|
// Dispatch a custom event to notify other components
|
||||||
|
window.dispatchEvent(new CustomEvent('toolNavigation', {
|
||||||
|
detail: { toolKey: null }
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
navigateToTool,
|
||||||
|
navigateToHome
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useFileContext } from "../contexts/FileContext";
|
import { useFileActions, useFileSelection } from "../contexts/FileContext";
|
||||||
import { FileSelectionProvider, useFileSelection } from "../contexts/FileSelectionContext";
|
import { useNavigationActions } from "../contexts/NavigationContext";
|
||||||
import { ToolWorkflowProvider, useToolWorkflow } from "../contexts/ToolWorkflowContext";
|
import { ToolWorkflowProvider, useToolWorkflow } from "../contexts/ToolWorkflowContext";
|
||||||
import { Group } from "@mantine/core";
|
import { Group } from "@mantine/core";
|
||||||
import { SidebarProvider, useSidebarContext } from "../contexts/SidebarContext";
|
import { SidebarProvider, useSidebarContext } from "../contexts/SidebarContext";
|
||||||
@@ -22,7 +22,7 @@ function HomePageContent() {
|
|||||||
|
|
||||||
const { quickAccessRef } = sidebarRefs;
|
const { quickAccessRef } = sidebarRefs;
|
||||||
|
|
||||||
const { setMaxFiles, setIsToolMode, setSelectedFiles } = useFileSelection();
|
const { setSelectedFiles } = useFileSelection();
|
||||||
|
|
||||||
const { selectedTool, selectedToolKey } = useToolWorkflow();
|
const { selectedTool, selectedToolKey } = useToolWorkflow();
|
||||||
|
|
||||||
@@ -38,17 +38,7 @@ function HomePageContent() {
|
|||||||
ogUrl: selectedTool ? `${baseUrl}${window.location.pathname}` : baseUrl
|
ogUrl: selectedTool ? `${baseUrl}${window.location.pathname}` : baseUrl
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update file selection context when tool changes
|
// Note: File selection limits are now handled directly by individual tools
|
||||||
useEffect(() => {
|
|
||||||
if (selectedTool) {
|
|
||||||
setMaxFiles(selectedTool.maxFiles ?? -1);
|
|
||||||
setIsToolMode(true);
|
|
||||||
} else {
|
|
||||||
setMaxFiles(-1);
|
|
||||||
setIsToolMode(false);
|
|
||||||
setSelectedFiles([]);
|
|
||||||
}
|
|
||||||
}, [selectedTool, setMaxFiles, setIsToolMode, setSelectedFiles]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group
|
<Group
|
||||||
@@ -65,15 +55,23 @@ function HomePageContent() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HomePage() {
|
function HomePageWithProviders() {
|
||||||
const { setCurrentView } = useFileContext();
|
const { actions } = useNavigationActions();
|
||||||
|
|
||||||
|
// Wrapper to convert string to ModeType
|
||||||
|
const handleViewChange = (view: string) => {
|
||||||
|
actions.setMode(view as any); // ToolWorkflowContext should validate this
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FileSelectionProvider>
|
<ToolWorkflowProvider onViewChange={handleViewChange}>
|
||||||
<ToolWorkflowProvider onViewChange={setCurrentView as any /* FIX ME */}>
|
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<HomePageContent />
|
<HomePageContent />
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
</ToolWorkflowProvider>
|
</ToolWorkflowProvider>
|
||||||
</FileSelectionProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
return <HomePageWithProviders />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { getDocument, GlobalWorkerOptions } from 'pdfjs-dist';
|
import * as pdfjsLib from 'pdfjs-dist';
|
||||||
import { ProcessedFile, ProcessingState, PDFPage, ProcessingStrategy, ProcessingConfig, ProcessingMetrics } from '../types/processing';
|
import { ProcessedFile, ProcessingState, PDFPage, ProcessingStrategy, ProcessingConfig, ProcessingMetrics } from '../types/processing';
|
||||||
import { ProcessingCache } from './processingCache';
|
import { ProcessingCache } from './processingCache';
|
||||||
import { FileHasher } from '../utils/fileHash';
|
import { FileHasher } from '../utils/fileHash';
|
||||||
import { FileAnalyzer } from './fileAnalyzer';
|
import { FileAnalyzer } from './fileAnalyzer';
|
||||||
import { ProcessingErrorHandler } from './processingErrorHandler';
|
import { ProcessingErrorHandler } from './processingErrorHandler';
|
||||||
|
import { pdfWorkerManager } from './pdfWorkerManager';
|
||||||
// Set up PDF.js worker
|
|
||||||
GlobalWorkerOptions.workerSrc = '/pdf.worker.js';
|
|
||||||
|
|
||||||
export class EnhancedPDFProcessingService {
|
export class EnhancedPDFProcessingService {
|
||||||
private static instance: EnhancedPDFProcessingService;
|
private static instance: EnhancedPDFProcessingService;
|
||||||
@@ -183,7 +181,9 @@ export class EnhancedPDFProcessingService {
|
|||||||
state: ProcessingState
|
state: ProcessingState
|
||||||
): Promise<ProcessedFile> {
|
): Promise<ProcessedFile> {
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
const pdf = await getDocument({ data: arrayBuffer }).promise;
|
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
|
||||||
|
|
||||||
|
try {
|
||||||
const totalPages = pdf.numPages;
|
const totalPages = pdf.numPages;
|
||||||
|
|
||||||
state.progress = 10;
|
state.progress = 10;
|
||||||
@@ -194,7 +194,6 @@ export class EnhancedPDFProcessingService {
|
|||||||
for (let i = 1; i <= totalPages; i++) {
|
for (let i = 1; i <= totalPages; i++) {
|
||||||
// Check for cancellation
|
// Check for cancellation
|
||||||
if (state.cancellationToken?.signal.aborted) {
|
if (state.cancellationToken?.signal.aborted) {
|
||||||
pdf.destroy();
|
|
||||||
throw new Error('Processing cancelled');
|
throw new Error('Processing cancelled');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,11 +214,12 @@ export class EnhancedPDFProcessingService {
|
|||||||
this.notifyListeners();
|
this.notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
pdf.destroy();
|
return this.createProcessedFile(file, pages, totalPages);
|
||||||
|
} finally {
|
||||||
|
pdfWorkerManager.destroyDocument(pdf);
|
||||||
state.progress = 100;
|
state.progress = 100;
|
||||||
this.notifyListeners();
|
this.notifyListeners();
|
||||||
|
}
|
||||||
return this.createProcessedFile(file, pages, totalPages);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -231,7 +231,7 @@ export class EnhancedPDFProcessingService {
|
|||||||
state: ProcessingState
|
state: ProcessingState
|
||||||
): Promise<ProcessedFile> {
|
): Promise<ProcessedFile> {
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
const pdf = await getDocument({ data: arrayBuffer }).promise;
|
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
|
||||||
const totalPages = pdf.numPages;
|
const totalPages = pdf.numPages;
|
||||||
|
|
||||||
state.progress = 10;
|
state.progress = 10;
|
||||||
@@ -243,7 +243,7 @@ export class EnhancedPDFProcessingService {
|
|||||||
// Process priority pages first
|
// Process priority pages first
|
||||||
for (let i = 1; i <= priorityCount; i++) {
|
for (let i = 1; i <= priorityCount; i++) {
|
||||||
if (state.cancellationToken?.signal.aborted) {
|
if (state.cancellationToken?.signal.aborted) {
|
||||||
pdf.destroy();
|
pdfWorkerManager.destroyDocument(pdf);
|
||||||
throw new Error('Processing cancelled');
|
throw new Error('Processing cancelled');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,7 +274,7 @@ export class EnhancedPDFProcessingService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pdf.destroy();
|
pdfWorkerManager.destroyDocument(pdf);
|
||||||
state.progress = 100;
|
state.progress = 100;
|
||||||
this.notifyListeners();
|
this.notifyListeners();
|
||||||
|
|
||||||
@@ -290,7 +290,7 @@ export class EnhancedPDFProcessingService {
|
|||||||
state: ProcessingState
|
state: ProcessingState
|
||||||
): Promise<ProcessedFile> {
|
): Promise<ProcessedFile> {
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
const pdf = await getDocument({ data: arrayBuffer }).promise;
|
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
|
||||||
const totalPages = pdf.numPages;
|
const totalPages = pdf.numPages;
|
||||||
|
|
||||||
state.progress = 10;
|
state.progress = 10;
|
||||||
@@ -305,7 +305,7 @@ export class EnhancedPDFProcessingService {
|
|||||||
|
|
||||||
for (let i = 1; i <= firstChunkEnd; i++) {
|
for (let i = 1; i <= firstChunkEnd; i++) {
|
||||||
if (state.cancellationToken?.signal.aborted) {
|
if (state.cancellationToken?.signal.aborted) {
|
||||||
pdf.destroy();
|
pdfWorkerManager.destroyDocument(pdf);
|
||||||
throw new Error('Processing cancelled');
|
throw new Error('Processing cancelled');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,7 +342,7 @@ export class EnhancedPDFProcessingService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pdf.destroy();
|
pdfWorkerManager.destroyDocument(pdf);
|
||||||
state.progress = 100;
|
state.progress = 100;
|
||||||
this.notifyListeners();
|
this.notifyListeners();
|
||||||
|
|
||||||
@@ -358,7 +358,7 @@ export class EnhancedPDFProcessingService {
|
|||||||
state: ProcessingState
|
state: ProcessingState
|
||||||
): Promise<ProcessedFile> {
|
): Promise<ProcessedFile> {
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
const pdf = await getDocument({ data: arrayBuffer }).promise;
|
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
|
||||||
const totalPages = pdf.numPages;
|
const totalPages = pdf.numPages;
|
||||||
|
|
||||||
state.progress = 50;
|
state.progress = 50;
|
||||||
@@ -376,7 +376,7 @@ export class EnhancedPDFProcessingService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pdf.destroy();
|
pdfWorkerManager.destroyDocument(pdf);
|
||||||
state.progress = 100;
|
state.progress = 100;
|
||||||
this.notifyListeners();
|
this.notifyListeners();
|
||||||
|
|
||||||
@@ -519,7 +519,10 @@ export class EnhancedPDFProcessingService {
|
|||||||
this.notifyListeners();
|
this.notifyListeners();
|
||||||
|
|
||||||
// Force memory cleanup hint
|
// Force memory cleanup hint
|
||||||
setTimeout(() => window?.gc?.(), 100);
|
if (typeof window !== 'undefined' && window.gc) {
|
||||||
|
let gc = window.gc;
|
||||||
|
setTimeout(() => gc(), 100);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -537,6 +540,15 @@ export class EnhancedPDFProcessingService {
|
|||||||
this.processing.clear();
|
this.processing.clear();
|
||||||
this.notifyListeners();
|
this.notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emergency cleanup - destroy all PDF workers
|
||||||
|
*/
|
||||||
|
emergencyCleanup(): void {
|
||||||
|
this.clearAllProcessing();
|
||||||
|
this.clearAll();
|
||||||
|
pdfWorkerManager.destroyAllDocuments();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export singleton instance
|
// Export singleton instance
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getDocument } from 'pdfjs-dist';
|
|
||||||
import { FileAnalysis, ProcessingStrategy } from '../types/processing';
|
import { FileAnalysis, ProcessingStrategy } from '../types/processing';
|
||||||
|
import { pdfWorkerManager } from './pdfWorkerManager';
|
||||||
|
|
||||||
export class FileAnalyzer {
|
export class FileAnalyzer {
|
||||||
private static readonly SIZE_THRESHOLDS = {
|
private static readonly SIZE_THRESHOLDS = {
|
||||||
@@ -66,17 +66,16 @@ export class FileAnalyzer {
|
|||||||
// For large files, try the whole file first (PDF.js needs the complete structure)
|
// For large files, try the whole file first (PDF.js needs the complete structure)
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
|
||||||
const pdf = await getDocument({
|
const pdf = await pdfWorkerManager.createDocument(arrayBuffer, {
|
||||||
data: arrayBuffer,
|
|
||||||
stopAtErrors: false, // Don't stop at minor errors
|
stopAtErrors: false, // Don't stop at minor errors
|
||||||
verbosity: 0 // Suppress PDF.js warnings
|
verbosity: 0 // Suppress PDF.js warnings
|
||||||
}).promise;
|
});
|
||||||
|
|
||||||
const pageCount = pdf.numPages;
|
const pageCount = pdf.numPages;
|
||||||
const isEncrypted = (pdf as any).isEncrypted;
|
const isEncrypted = (pdf as any).isEncrypted;
|
||||||
|
|
||||||
// Clean up
|
// Clean up using worker manager
|
||||||
pdf.destroy();
|
pdfWorkerManager.destroyDocument(pdf);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pageCount,
|
pageCount,
|
||||||
|
|||||||
@@ -1,194 +0,0 @@
|
|||||||
import { FileWithUrl } from "../types/file";
|
|
||||||
import { fileStorage, StorageStats } from "./fileStorage";
|
|
||||||
import { loadFilesFromIndexedDB, createEnhancedFileFromStored, cleanupFileUrls } from "../utils/fileUtils";
|
|
||||||
import { generateThumbnailForFile } from "../utils/thumbnailUtils";
|
|
||||||
import { updateStorageStatsIncremental } from "../utils/storageUtils";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Service for file storage operations
|
|
||||||
* Contains all IndexedDB operations and file management logic
|
|
||||||
*/
|
|
||||||
export const fileOperationsService = {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load storage statistics
|
|
||||||
*/
|
|
||||||
async loadStorageStats(): Promise<StorageStats | null> {
|
|
||||||
try {
|
|
||||||
return await fileStorage.getStorageStats();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load storage stats:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Force reload files from IndexedDB
|
|
||||||
*/
|
|
||||||
async forceReloadFiles(): Promise<FileWithUrl[]> {
|
|
||||||
try {
|
|
||||||
return await loadFilesFromIndexedDB();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to force reload files:', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load existing files from IndexedDB if not already loaded
|
|
||||||
*/
|
|
||||||
async loadExistingFiles(
|
|
||||||
filesLoaded: boolean,
|
|
||||||
currentFiles: FileWithUrl[]
|
|
||||||
): Promise<FileWithUrl[]> {
|
|
||||||
if (filesLoaded && currentFiles.length > 0) {
|
|
||||||
return currentFiles;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fileStorage.init();
|
|
||||||
const storedFiles = await fileStorage.getAllFileMetadata();
|
|
||||||
|
|
||||||
// Detect if IndexedDB was purged by comparing with current UI state
|
|
||||||
if (currentFiles.length > 0 && storedFiles.length === 0) {
|
|
||||||
console.warn('IndexedDB appears to have been purged - clearing UI state');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return await loadFilesFromIndexedDB();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load existing files:', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upload files to IndexedDB with thumbnail generation
|
|
||||||
*/
|
|
||||||
async uploadFiles(
|
|
||||||
uploadedFiles: File[],
|
|
||||||
useIndexedDB: boolean
|
|
||||||
): Promise<FileWithUrl[]> {
|
|
||||||
const newFiles: FileWithUrl[] = [];
|
|
||||||
|
|
||||||
for (const file of uploadedFiles) {
|
|
||||||
if (useIndexedDB) {
|
|
||||||
try {
|
|
||||||
console.log('Storing file in IndexedDB:', file.name);
|
|
||||||
|
|
||||||
// Generate thumbnail only during upload
|
|
||||||
const thumbnail = await generateThumbnailForFile(file);
|
|
||||||
|
|
||||||
const storedFile = await fileStorage.storeFile(file, thumbnail);
|
|
||||||
console.log('File stored with ID:', storedFile.id);
|
|
||||||
|
|
||||||
const baseFile = fileStorage.createFileFromStored(storedFile);
|
|
||||||
const enhancedFile = createEnhancedFileFromStored(storedFile, thumbnail);
|
|
||||||
|
|
||||||
// Copy File interface methods from baseFile
|
|
||||||
enhancedFile.arrayBuffer = baseFile.arrayBuffer.bind(baseFile);
|
|
||||||
enhancedFile.slice = baseFile.slice.bind(baseFile);
|
|
||||||
enhancedFile.stream = baseFile.stream.bind(baseFile);
|
|
||||||
enhancedFile.text = baseFile.text.bind(baseFile);
|
|
||||||
|
|
||||||
newFiles.push(enhancedFile);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to store file in IndexedDB:', error);
|
|
||||||
// Fallback to RAM storage
|
|
||||||
const enhancedFile: FileWithUrl = Object.assign(file, {
|
|
||||||
url: URL.createObjectURL(file),
|
|
||||||
storedInIndexedDB: false
|
|
||||||
});
|
|
||||||
newFiles.push(enhancedFile);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// IndexedDB disabled - use RAM
|
|
||||||
const enhancedFile: FileWithUrl = Object.assign(file, {
|
|
||||||
url: URL.createObjectURL(file),
|
|
||||||
storedInIndexedDB: false
|
|
||||||
});
|
|
||||||
newFiles.push(enhancedFile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return newFiles;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove a file from storage
|
|
||||||
*/
|
|
||||||
async removeFile(file: FileWithUrl): Promise<void> {
|
|
||||||
// Clean up blob URL
|
|
||||||
if (file.url && !file.url.startsWith('indexeddb:')) {
|
|
||||||
URL.revokeObjectURL(file.url);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove from IndexedDB if stored there
|
|
||||||
if (file.storedInIndexedDB && file.id) {
|
|
||||||
try {
|
|
||||||
await fileStorage.deleteFile(file.id);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to delete file from IndexedDB:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear all files from storage
|
|
||||||
*/
|
|
||||||
async clearAllFiles(files: FileWithUrl[]): Promise<void> {
|
|
||||||
// Clean up all blob URLs
|
|
||||||
cleanupFileUrls(files);
|
|
||||||
|
|
||||||
// Clear IndexedDB
|
|
||||||
try {
|
|
||||||
await fileStorage.clearAll();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to clear IndexedDB:', error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create blob URL for file viewing
|
|
||||||
*/
|
|
||||||
async createBlobUrlForFile(file: FileWithUrl): Promise<string> {
|
|
||||||
// For large files, use IndexedDB direct access to avoid memory issues
|
|
||||||
const FILE_SIZE_LIMIT = 100 * 1024 * 1024; // 100MB
|
|
||||||
if (file.size > FILE_SIZE_LIMIT) {
|
|
||||||
console.warn(`File ${file.name} is too large for blob URL. Use direct IndexedDB access.`);
|
|
||||||
return `indexeddb:${file.id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For all files, avoid persistent blob URLs
|
|
||||||
if (file.storedInIndexedDB && file.id) {
|
|
||||||
const storedFile = await fileStorage.getFile(file.id);
|
|
||||||
if (storedFile) {
|
|
||||||
return fileStorage.createBlobUrl(storedFile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback for files not in IndexedDB
|
|
||||||
return URL.createObjectURL(file);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check for IndexedDB purge
|
|
||||||
*/
|
|
||||||
async checkForPurge(currentFiles: FileWithUrl[]): Promise<boolean> {
|
|
||||||
if (currentFiles.length === 0) return false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fileStorage.init();
|
|
||||||
const storedFiles = await fileStorage.getAllFileMetadata();
|
|
||||||
return storedFiles.length === 0; // Purge detected if no files in storage but UI shows files
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error checking for purge:', error);
|
|
||||||
return true; // Assume purged if can't access IndexedDB
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update storage stats incrementally (re-export utility for convenience)
|
|
||||||
*/
|
|
||||||
updateStorageStatsIncremental
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
/**
|
||||||
|
* Centralized file processing service
|
||||||
|
* Handles metadata discovery, page counting, and thumbnail generation
|
||||||
|
* Called when files are added to FileContext, before any view sees them
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as pdfjsLib from 'pdfjs-dist';
|
||||||
|
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
|
||||||
|
import { pdfWorkerManager } from './pdfWorkerManager';
|
||||||
|
|
||||||
|
export interface ProcessedFileMetadata {
|
||||||
|
totalPages: number;
|
||||||
|
pages: Array<{
|
||||||
|
pageNumber: number;
|
||||||
|
thumbnail?: string;
|
||||||
|
rotation: number;
|
||||||
|
splitBefore: boolean;
|
||||||
|
}>;
|
||||||
|
thumbnailUrl?: string; // Page 1 thumbnail for FileEditor
|
||||||
|
lastProcessed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileProcessingResult {
|
||||||
|
success: boolean;
|
||||||
|
metadata?: ProcessedFileMetadata;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProcessingOperation {
|
||||||
|
promise: Promise<FileProcessingResult>;
|
||||||
|
abortController: AbortController;
|
||||||
|
}
|
||||||
|
|
||||||
|
class FileProcessingService {
|
||||||
|
private processingCache = new Map<string, ProcessingOperation>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a file to extract metadata, page count, and generate thumbnails
|
||||||
|
* This is the single source of truth for file processing
|
||||||
|
*/
|
||||||
|
async processFile(file: File, fileId: string): Promise<FileProcessingResult> {
|
||||||
|
// Check if we're already processing this file
|
||||||
|
const existingOperation = this.processingCache.get(fileId);
|
||||||
|
if (existingOperation) {
|
||||||
|
console.log(`📁 FileProcessingService: Using cached processing for ${file.name}`);
|
||||||
|
return existingOperation.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create abort controller for this operation
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
// Create processing promise
|
||||||
|
const processingPromise = this.performProcessing(file, fileId, abortController);
|
||||||
|
|
||||||
|
// Store operation with abort controller
|
||||||
|
const operation: ProcessingOperation = {
|
||||||
|
promise: processingPromise,
|
||||||
|
abortController
|
||||||
|
};
|
||||||
|
this.processingCache.set(fileId, operation);
|
||||||
|
|
||||||
|
// Clean up cache after completion
|
||||||
|
processingPromise.finally(() => {
|
||||||
|
this.processingCache.delete(fileId);
|
||||||
|
});
|
||||||
|
|
||||||
|
return processingPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async performProcessing(file: File, fileId: string, abortController: AbortController): Promise<FileProcessingResult> {
|
||||||
|
console.log(`📁 FileProcessingService: Starting processing for ${file.name} (${fileId})`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check for cancellation at start
|
||||||
|
if (abortController.signal.aborted) {
|
||||||
|
throw new Error('Processing cancelled');
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalPages = 1;
|
||||||
|
let thumbnailUrl: string | undefined;
|
||||||
|
|
||||||
|
// Handle PDF files
|
||||||
|
if (file.type === 'application/pdf') {
|
||||||
|
// Read arrayBuffer once and reuse for both PDF.js and fallback
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
|
||||||
|
// Check for cancellation after async operation
|
||||||
|
if (abortController.signal.aborted) {
|
||||||
|
throw new Error('Processing cancelled');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discover page count using PDF.js (most accurate)
|
||||||
|
try {
|
||||||
|
const pdfDoc = await pdfWorkerManager.createDocument(arrayBuffer, {
|
||||||
|
disableAutoFetch: true,
|
||||||
|
disableStream: true
|
||||||
|
});
|
||||||
|
|
||||||
|
totalPages = pdfDoc.numPages;
|
||||||
|
console.log(`📁 FileProcessingService: PDF.js discovered ${totalPages} pages for ${file.name}`);
|
||||||
|
|
||||||
|
// Clean up immediately
|
||||||
|
pdfWorkerManager.destroyDocument(pdfDoc);
|
||||||
|
|
||||||
|
// Check for cancellation after PDF.js processing
|
||||||
|
if (abortController.signal.aborted) {
|
||||||
|
throw new Error('Processing cancelled');
|
||||||
|
}
|
||||||
|
} catch (pdfError) {
|
||||||
|
console.warn(`📁 FileProcessingService: PDF.js failed for ${file.name}, setting pages to 0:`, pdfError);
|
||||||
|
totalPages = 0; // Unknown page count - UI will hide page count display
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate page 1 thumbnail
|
||||||
|
try {
|
||||||
|
thumbnailUrl = await generateThumbnailForFile(file);
|
||||||
|
console.log(`📁 FileProcessingService: Generated thumbnail for ${file.name}`);
|
||||||
|
|
||||||
|
// Check for cancellation after thumbnail generation
|
||||||
|
if (abortController.signal.aborted) {
|
||||||
|
throw new Error('Processing cancelled');
|
||||||
|
}
|
||||||
|
} catch (thumbError) {
|
||||||
|
console.warn(`📁 FileProcessingService: Thumbnail generation failed for ${file.name}:`, thumbError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create page structure
|
||||||
|
const pages = Array.from({ length: totalPages }, (_, index) => ({
|
||||||
|
pageNumber: index + 1,
|
||||||
|
thumbnail: index === 0 ? thumbnailUrl : undefined, // Only page 1 gets thumbnail initially
|
||||||
|
rotation: 0,
|
||||||
|
splitBefore: false
|
||||||
|
}));
|
||||||
|
|
||||||
|
const metadata: ProcessedFileMetadata = {
|
||||||
|
totalPages,
|
||||||
|
pages,
|
||||||
|
thumbnailUrl, // For FileEditor display
|
||||||
|
lastProcessed: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`📁 FileProcessingService: Processing complete for ${file.name} - ${totalPages} pages`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
metadata
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`📁 FileProcessingService: Processing failed for ${file.name}:`, error);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown processing error'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all processing caches
|
||||||
|
*/
|
||||||
|
clearCache(): void {
|
||||||
|
this.processingCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a file is currently being processed
|
||||||
|
*/
|
||||||
|
isProcessing(fileId: string): boolean {
|
||||||
|
return this.processingCache.has(fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel processing for a specific file
|
||||||
|
*/
|
||||||
|
cancelProcessing(fileId: string): boolean {
|
||||||
|
const operation = this.processingCache.get(fileId);
|
||||||
|
if (operation) {
|
||||||
|
operation.abortController.abort();
|
||||||
|
console.log(`📁 FileProcessingService: Cancelled processing for ${fileId}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel all ongoing processing operations
|
||||||
|
*/
|
||||||
|
cancelAllProcessing(): void {
|
||||||
|
this.processingCache.forEach((operation, fileId) => {
|
||||||
|
operation.abortController.abort();
|
||||||
|
console.log(`📁 FileProcessingService: Cancelled processing for ${fileId}`);
|
||||||
|
});
|
||||||
|
console.log(`📁 FileProcessingService: Cancelled ${this.processingCache.size} processing operations`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emergency cleanup - cancel all processing and destroy workers
|
||||||
|
*/
|
||||||
|
emergencyCleanup(): void {
|
||||||
|
this.cancelAllProcessing();
|
||||||
|
this.clearCache();
|
||||||
|
pdfWorkerManager.destroyAllDocuments();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
export const fileProcessingService = new FileProcessingService();
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* IndexedDB File Storage Service
|
* IndexedDB File Storage Service
|
||||||
* Provides high-capacity file storage for PDF processing
|
* Provides high-capacity file storage for PDF processing
|
||||||
|
* Now uses centralized IndexedDB manager
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { indexedDBManager, DATABASE_CONFIGS } from './indexedDBManager';
|
||||||
|
|
||||||
export interface StoredFile {
|
export interface StoredFile {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -22,75 +25,26 @@ export interface StorageStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class FileStorageService {
|
class FileStorageService {
|
||||||
private dbName = 'stirling-pdf-files';
|
private readonly dbConfig = DATABASE_CONFIGS.FILES;
|
||||||
private dbVersion = 2; // Increment version to force schema update
|
private readonly storeName = 'files';
|
||||||
private storeName = 'files';
|
|
||||||
private db: IDBDatabase | null = null;
|
|
||||||
private initPromise: Promise<void> | null = null;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the IndexedDB database (singleton pattern)
|
* Get database connection using centralized manager
|
||||||
*/
|
*/
|
||||||
async init(): Promise<void> {
|
private async getDatabase(): Promise<IDBDatabase> {
|
||||||
if (this.db) {
|
return indexedDBManager.openDatabase(this.dbConfig);
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.initPromise) {
|
|
||||||
return this.initPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.initPromise = new Promise((resolve, reject) => {
|
|
||||||
const request = indexedDB.open(this.dbName, this.dbVersion);
|
|
||||||
|
|
||||||
request.onerror = () => {
|
|
||||||
this.initPromise = null;
|
|
||||||
reject(request.error);
|
|
||||||
};
|
|
||||||
|
|
||||||
request.onsuccess = () => {
|
|
||||||
this.db = request.result;
|
|
||||||
console.log('IndexedDB connection established');
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
|
|
||||||
request.onupgradeneeded = (event) => {
|
|
||||||
const db = (event.target as IDBOpenDBRequest).result;
|
|
||||||
const oldVersion = (event as any).oldVersion;
|
|
||||||
|
|
||||||
console.log('IndexedDB upgrade needed from version', oldVersion, 'to', this.dbVersion);
|
|
||||||
|
|
||||||
// Only recreate object store if it doesn't exist or if upgrading from version < 2
|
|
||||||
if (!db.objectStoreNames.contains(this.storeName)) {
|
|
||||||
const store = db.createObjectStore(this.storeName, { keyPath: 'id' });
|
|
||||||
store.createIndex('name', 'name', { unique: false });
|
|
||||||
store.createIndex('lastModified', 'lastModified', { unique: false });
|
|
||||||
console.log('IndexedDB object store created with keyPath: id');
|
|
||||||
} else if (oldVersion < 2) {
|
|
||||||
// Only delete and recreate if upgrading from version 1 to 2
|
|
||||||
db.deleteObjectStore(this.storeName);
|
|
||||||
const store = db.createObjectStore(this.storeName, { keyPath: 'id' });
|
|
||||||
store.createIndex('name', 'name', { unique: false });
|
|
||||||
store.createIndex('lastModified', 'lastModified', { unique: false });
|
|
||||||
console.log('IndexedDB object store recreated with keyPath: id (version upgrade)');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.initPromise;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store a file in IndexedDB
|
* Store a file in IndexedDB with external UUID
|
||||||
*/
|
*/
|
||||||
async storeFile(file: File, thumbnail?: string): Promise<StoredFile> {
|
async storeFile(file: File, fileId: string, thumbnail?: string): Promise<StoredFile> {
|
||||||
if (!this.db) await this.init();
|
const db = await this.getDatabase();
|
||||||
|
|
||||||
const id = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
|
||||||
const storedFile: StoredFile = {
|
const storedFile: StoredFile = {
|
||||||
id,
|
id: fileId, // Use provided UUID
|
||||||
name: file.name,
|
name: file.name,
|
||||||
type: file.type,
|
type: file.type,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
@@ -101,13 +55,13 @@ class FileStorageService {
|
|||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
const transaction = this.db!.transaction([this.storeName], 'readwrite');
|
const transaction = db.transaction([this.storeName], 'readwrite');
|
||||||
const store = transaction.objectStore(this.storeName);
|
const store = transaction.objectStore(this.storeName);
|
||||||
|
|
||||||
// Debug logging
|
// Debug logging
|
||||||
console.log('Object store keyPath:', store.keyPath);
|
console.log('Object store keyPath:', store.keyPath);
|
||||||
console.log('Storing file:', {
|
console.log('Storing file with UUID:', {
|
||||||
id: storedFile.id,
|
id: storedFile.id, // Now a UUID from FileContext
|
||||||
name: storedFile.name,
|
name: storedFile.name,
|
||||||
hasData: !!storedFile.data,
|
hasData: !!storedFile.data,
|
||||||
dataSize: storedFile.data.byteLength
|
dataSize: storedFile.data.byteLength
|
||||||
@@ -135,10 +89,10 @@ class FileStorageService {
|
|||||||
* Retrieve a file from IndexedDB
|
* Retrieve a file from IndexedDB
|
||||||
*/
|
*/
|
||||||
async getFile(id: string): Promise<StoredFile | null> {
|
async getFile(id: string): Promise<StoredFile | null> {
|
||||||
if (!this.db) await this.init();
|
const db = await this.getDatabase();
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const transaction = this.db!.transaction([this.storeName], 'readonly');
|
const transaction = db.transaction([this.storeName], 'readonly');
|
||||||
const store = transaction.objectStore(this.storeName);
|
const store = transaction.objectStore(this.storeName);
|
||||||
const request = store.get(id);
|
const request = store.get(id);
|
||||||
|
|
||||||
@@ -151,10 +105,10 @@ class FileStorageService {
|
|||||||
* Get all stored files (WARNING: loads all data into memory)
|
* Get all stored files (WARNING: loads all data into memory)
|
||||||
*/
|
*/
|
||||||
async getAllFiles(): Promise<StoredFile[]> {
|
async getAllFiles(): Promise<StoredFile[]> {
|
||||||
if (!this.db) await this.init();
|
const db = await this.getDatabase();
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const transaction = this.db!.transaction([this.storeName], 'readonly');
|
const transaction = db.transaction([this.storeName], 'readonly');
|
||||||
const store = transaction.objectStore(this.storeName);
|
const store = transaction.objectStore(this.storeName);
|
||||||
const request = store.getAll();
|
const request = store.getAll();
|
||||||
|
|
||||||
@@ -176,10 +130,10 @@ class FileStorageService {
|
|||||||
* Get metadata of all stored files (without loading data into memory)
|
* Get metadata of all stored files (without loading data into memory)
|
||||||
*/
|
*/
|
||||||
async getAllFileMetadata(): Promise<Omit<StoredFile, 'data'>[]> {
|
async getAllFileMetadata(): Promise<Omit<StoredFile, 'data'>[]> {
|
||||||
if (!this.db) await this.init();
|
const db = await this.getDatabase();
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const transaction = this.db!.transaction([this.storeName], 'readonly');
|
const transaction = db.transaction([this.storeName], 'readonly');
|
||||||
const store = transaction.objectStore(this.storeName);
|
const store = transaction.objectStore(this.storeName);
|
||||||
const request = store.openCursor();
|
const request = store.openCursor();
|
||||||
const files: Omit<StoredFile, 'data'>[] = [];
|
const files: Omit<StoredFile, 'data'>[] = [];
|
||||||
@@ -202,7 +156,7 @@ class FileStorageService {
|
|||||||
}
|
}
|
||||||
cursor.continue();
|
cursor.continue();
|
||||||
} else {
|
} else {
|
||||||
console.log('Loaded metadata for', files.length, 'files without loading data');
|
// Metadata loaded efficiently without file data
|
||||||
resolve(files);
|
resolve(files);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -213,10 +167,10 @@ class FileStorageService {
|
|||||||
* Delete a file from IndexedDB
|
* Delete a file from IndexedDB
|
||||||
*/
|
*/
|
||||||
async deleteFile(id: string): Promise<void> {
|
async deleteFile(id: string): Promise<void> {
|
||||||
if (!this.db) await this.init();
|
const db = await this.getDatabase();
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const transaction = this.db!.transaction([this.storeName], 'readwrite');
|
const transaction = db.transaction([this.storeName], 'readwrite');
|
||||||
const store = transaction.objectStore(this.storeName);
|
const store = transaction.objectStore(this.storeName);
|
||||||
const request = store.delete(id);
|
const request = store.delete(id);
|
||||||
|
|
||||||
@@ -229,9 +183,9 @@ class FileStorageService {
|
|||||||
* Update the lastModified timestamp of a file (for most recently used sorting)
|
* Update the lastModified timestamp of a file (for most recently used sorting)
|
||||||
*/
|
*/
|
||||||
async touchFile(id: string): Promise<boolean> {
|
async touchFile(id: string): Promise<boolean> {
|
||||||
if (!this.db) await this.init();
|
const db = await this.getDatabase();
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const transaction = this.db!.transaction([this.storeName], 'readwrite');
|
const transaction = db.transaction([this.storeName], 'readwrite');
|
||||||
const store = transaction.objectStore(this.storeName);
|
const store = transaction.objectStore(this.storeName);
|
||||||
|
|
||||||
const getRequest = store.get(id);
|
const getRequest = store.get(id);
|
||||||
@@ -255,10 +209,10 @@ class FileStorageService {
|
|||||||
* Clear all stored files
|
* Clear all stored files
|
||||||
*/
|
*/
|
||||||
async clearAll(): Promise<void> {
|
async clearAll(): Promise<void> {
|
||||||
if (!this.db) await this.init();
|
const db = await this.getDatabase();
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const transaction = this.db!.transaction([this.storeName], 'readwrite');
|
const transaction = db.transaction([this.storeName], 'readwrite');
|
||||||
const store = transaction.objectStore(this.storeName);
|
const store = transaction.objectStore(this.storeName);
|
||||||
const request = store.clear();
|
const request = store.clear();
|
||||||
|
|
||||||
@@ -271,8 +225,6 @@ class FileStorageService {
|
|||||||
* Get storage statistics (only our IndexedDB usage)
|
* Get storage statistics (only our IndexedDB usage)
|
||||||
*/
|
*/
|
||||||
async getStorageStats(): Promise<StorageStats> {
|
async getStorageStats(): Promise<StorageStats> {
|
||||||
if (!this.db) await this.init();
|
|
||||||
|
|
||||||
let used = 0;
|
let used = 0;
|
||||||
let available = 0;
|
let available = 0;
|
||||||
let quota: number | undefined;
|
let quota: number | undefined;
|
||||||
@@ -315,10 +267,10 @@ class FileStorageService {
|
|||||||
* Get file count quickly without loading metadata
|
* Get file count quickly without loading metadata
|
||||||
*/
|
*/
|
||||||
async getFileCount(): Promise<number> {
|
async getFileCount(): Promise<number> {
|
||||||
if (!this.db) await this.init();
|
const db = await this.getDatabase();
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const transaction = this.db!.transaction([this.storeName], 'readonly');
|
const transaction = db.transaction([this.storeName], 'readonly');
|
||||||
const store = transaction.objectStore(this.storeName);
|
const store = transaction.objectStore(this.storeName);
|
||||||
const request = store.count();
|
const request = store.count();
|
||||||
|
|
||||||
@@ -365,9 +317,9 @@ class FileStorageService {
|
|||||||
// Also check our specific database with different versions
|
// Also check our specific database with different versions
|
||||||
for (let version = 1; version <= 3; version++) {
|
for (let version = 1; version <= 3; version++) {
|
||||||
try {
|
try {
|
||||||
console.log(`Trying to open ${this.dbName} version ${version}...`);
|
console.log(`Trying to open ${this.dbConfig.name} version ${version}...`);
|
||||||
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||||
const request = indexedDB.open(this.dbName, version);
|
const request = indexedDB.open(this.dbConfig.name, version);
|
||||||
request.onsuccess = () => resolve(request.result);
|
request.onsuccess = () => resolve(request.result);
|
||||||
request.onerror = () => reject(request.error);
|
request.onerror = () => reject(request.error);
|
||||||
request.onupgradeneeded = () => {
|
request.onupgradeneeded = () => {
|
||||||
@@ -400,10 +352,10 @@ class FileStorageService {
|
|||||||
* Debug method to check what's actually in the database
|
* Debug method to check what's actually in the database
|
||||||
*/
|
*/
|
||||||
async debugDatabaseContents(): Promise<void> {
|
async debugDatabaseContents(): Promise<void> {
|
||||||
if (!this.db) await this.init();
|
const db = await this.getDatabase();
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const transaction = this.db!.transaction([this.storeName], 'readonly');
|
const transaction = db.transaction([this.storeName], 'readonly');
|
||||||
const store = transaction.objectStore(this.storeName);
|
const store = transaction.objectStore(this.storeName);
|
||||||
|
|
||||||
// First try getAll to see if there's anything
|
// First try getAll to see if there's anything
|
||||||
@@ -460,7 +412,8 @@ class FileStorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert StoredFile back to File object for compatibility
|
* Convert StoredFile back to pure File object without mutations
|
||||||
|
* Returns a clean File object - use FileContext.addStoredFiles() for proper metadata handling
|
||||||
*/
|
*/
|
||||||
createFileFromStored(storedFile: StoredFile): File {
|
createFileFromStored(storedFile: StoredFile): File {
|
||||||
if (!storedFile || !storedFile.data) {
|
if (!storedFile || !storedFile.data) {
|
||||||
@@ -477,13 +430,26 @@ class FileStorageService {
|
|||||||
lastModified: storedFile.lastModified
|
lastModified: storedFile.lastModified
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add custom properties for compatibility
|
// Use FileContext.addStoredFiles() to properly associate with metadata
|
||||||
Object.defineProperty(file, 'id', { value: storedFile.id, writable: false });
|
|
||||||
Object.defineProperty(file, 'thumbnail', { value: storedFile.thumbnail, writable: false });
|
|
||||||
|
|
||||||
return file;
|
return file;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert StoredFile to the format expected by FileContext.addStoredFiles()
|
||||||
|
* This is the recommended way to load stored files into FileContext
|
||||||
|
*/
|
||||||
|
createFileWithMetadata(storedFile: StoredFile): { file: File; originalId: string; metadata: { thumbnail?: string } } {
|
||||||
|
const file = this.createFileFromStored(storedFile);
|
||||||
|
|
||||||
|
return {
|
||||||
|
file,
|
||||||
|
originalId: storedFile.id,
|
||||||
|
metadata: {
|
||||||
|
thumbnail: storedFile.thumbnail
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create blob URL for stored file
|
* Create blob URL for stored file
|
||||||
*/
|
*/
|
||||||
@@ -527,11 +493,11 @@ class FileStorageService {
|
|||||||
* Update thumbnail for an existing file
|
* Update thumbnail for an existing file
|
||||||
*/
|
*/
|
||||||
async updateThumbnail(id: string, thumbnail: string): Promise<boolean> {
|
async updateThumbnail(id: string, thumbnail: string): Promise<boolean> {
|
||||||
if (!this.db) await this.init();
|
const db = await this.getDatabase();
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
const transaction = this.db!.transaction([this.storeName], 'readwrite');
|
const transaction = db.transaction([this.storeName], 'readwrite');
|
||||||
const store = transaction.objectStore(this.storeName);
|
const store = transaction.objectStore(this.storeName);
|
||||||
const getRequest = store.get(id);
|
const getRequest = store.get(id);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
/**
|
||||||
|
* Centralized IndexedDB Manager
|
||||||
|
* Handles all database initialization, schema management, and migrations
|
||||||
|
* Prevents race conditions and duplicate schema upgrades
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface DatabaseConfig {
|
||||||
|
name: string;
|
||||||
|
version: number;
|
||||||
|
stores: {
|
||||||
|
name: string;
|
||||||
|
keyPath?: string | string[];
|
||||||
|
autoIncrement?: boolean;
|
||||||
|
indexes?: {
|
||||||
|
name: string;
|
||||||
|
keyPath: string | string[];
|
||||||
|
unique: boolean;
|
||||||
|
}[];
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
class IndexedDBManager {
|
||||||
|
private static instance: IndexedDBManager;
|
||||||
|
private databases = new Map<string, IDBDatabase>();
|
||||||
|
private initPromises = new Map<string, Promise<IDBDatabase>>();
|
||||||
|
|
||||||
|
private constructor() {}
|
||||||
|
|
||||||
|
static getInstance(): IndexedDBManager {
|
||||||
|
if (!IndexedDBManager.instance) {
|
||||||
|
IndexedDBManager.instance = new IndexedDBManager();
|
||||||
|
}
|
||||||
|
return IndexedDBManager.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open or get existing database connection
|
||||||
|
*/
|
||||||
|
async openDatabase(config: DatabaseConfig): Promise<IDBDatabase> {
|
||||||
|
const existingDb = this.databases.get(config.name);
|
||||||
|
if (existingDb) {
|
||||||
|
return existingDb;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingPromise = this.initPromises.get(config.name);
|
||||||
|
if (existingPromise) {
|
||||||
|
return existingPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initPromise = this.performDatabaseInit(config);
|
||||||
|
this.initPromises.set(config.name, initPromise);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = await initPromise;
|
||||||
|
this.databases.set(config.name, db);
|
||||||
|
return db;
|
||||||
|
} catch (error) {
|
||||||
|
this.initPromises.delete(config.name);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private performDatabaseInit(config: DatabaseConfig): Promise<IDBDatabase> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
console.log(`Opening IndexedDB: ${config.name} v${config.version}`);
|
||||||
|
const request = indexedDB.open(config.name, config.version);
|
||||||
|
|
||||||
|
request.onerror = () => {
|
||||||
|
console.error(`Failed to open ${config.name}:`, request.error);
|
||||||
|
reject(request.error);
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const db = request.result;
|
||||||
|
console.log(`Successfully opened ${config.name}`);
|
||||||
|
|
||||||
|
// Set up close handler to clean up our references
|
||||||
|
db.onclose = () => {
|
||||||
|
console.log(`Database ${config.name} closed`);
|
||||||
|
this.databases.delete(config.name);
|
||||||
|
this.initPromises.delete(config.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
resolve(db);
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onupgradeneeded = (event) => {
|
||||||
|
const db = request.result;
|
||||||
|
const oldVersion = event.oldVersion;
|
||||||
|
|
||||||
|
console.log(`Upgrading ${config.name} from v${oldVersion} to v${config.version}`);
|
||||||
|
|
||||||
|
// Create or update object stores
|
||||||
|
config.stores.forEach(storeConfig => {
|
||||||
|
let store: IDBObjectStore;
|
||||||
|
|
||||||
|
if (db.objectStoreNames.contains(storeConfig.name)) {
|
||||||
|
// Store exists - for now, just continue (could add migration logic here)
|
||||||
|
console.log(`Object store '${storeConfig.name}' already exists`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new object store
|
||||||
|
const options: IDBObjectStoreParameters = {};
|
||||||
|
if (storeConfig.keyPath) {
|
||||||
|
options.keyPath = storeConfig.keyPath;
|
||||||
|
}
|
||||||
|
if (storeConfig.autoIncrement) {
|
||||||
|
options.autoIncrement = storeConfig.autoIncrement;
|
||||||
|
}
|
||||||
|
|
||||||
|
store = db.createObjectStore(storeConfig.name, options);
|
||||||
|
console.log(`Created object store '${storeConfig.name}'`);
|
||||||
|
|
||||||
|
// Create indexes
|
||||||
|
if (storeConfig.indexes) {
|
||||||
|
storeConfig.indexes.forEach(indexConfig => {
|
||||||
|
store.createIndex(
|
||||||
|
indexConfig.name,
|
||||||
|
indexConfig.keyPath,
|
||||||
|
{ unique: indexConfig.unique }
|
||||||
|
);
|
||||||
|
console.log(`Created index '${indexConfig.name}' on '${storeConfig.name}'`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get database connection (must be already opened)
|
||||||
|
*/
|
||||||
|
getDatabase(name: string): IDBDatabase | null {
|
||||||
|
return this.databases.get(name) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close database connection
|
||||||
|
*/
|
||||||
|
closeDatabase(name: string): void {
|
||||||
|
const db = this.databases.get(name);
|
||||||
|
if (db) {
|
||||||
|
db.close();
|
||||||
|
this.databases.delete(name);
|
||||||
|
this.initPromises.delete(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close all database connections
|
||||||
|
*/
|
||||||
|
closeAllDatabases(): void {
|
||||||
|
this.databases.forEach((db, name) => {
|
||||||
|
console.log(`Closing database: ${name}`);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
this.databases.clear();
|
||||||
|
this.initPromises.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete database completely
|
||||||
|
*/
|
||||||
|
async deleteDatabase(name: string): Promise<void> {
|
||||||
|
// Close connection if open
|
||||||
|
this.closeDatabase(name);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const deleteRequest = indexedDB.deleteDatabase(name);
|
||||||
|
|
||||||
|
deleteRequest.onerror = () => reject(deleteRequest.error);
|
||||||
|
deleteRequest.onsuccess = () => {
|
||||||
|
console.log(`Deleted database: ${name}`);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a database exists and what version it is
|
||||||
|
*/
|
||||||
|
async getDatabaseVersion(name: string): Promise<number | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const request = indexedDB.open(name);
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const db = request.result;
|
||||||
|
const version = db.version;
|
||||||
|
db.close();
|
||||||
|
resolve(version);
|
||||||
|
};
|
||||||
|
request.onerror = () => resolve(null);
|
||||||
|
request.onupgradeneeded = () => {
|
||||||
|
// Cancel the upgrade
|
||||||
|
request.transaction?.abort();
|
||||||
|
resolve(null);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-defined database configurations
|
||||||
|
export const DATABASE_CONFIGS = {
|
||||||
|
FILES: {
|
||||||
|
name: 'stirling-pdf-files',
|
||||||
|
version: 2,
|
||||||
|
stores: [{
|
||||||
|
name: 'files',
|
||||||
|
keyPath: 'id',
|
||||||
|
indexes: [
|
||||||
|
{ name: 'name', keyPath: 'name', unique: false },
|
||||||
|
{ name: 'lastModified', keyPath: 'lastModified', unique: false }
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
} as DatabaseConfig,
|
||||||
|
|
||||||
|
DRAFTS: {
|
||||||
|
name: 'stirling-pdf-drafts',
|
||||||
|
version: 1,
|
||||||
|
stores: [{
|
||||||
|
name: 'drafts',
|
||||||
|
keyPath: 'id'
|
||||||
|
}]
|
||||||
|
} as DatabaseConfig
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const indexedDBManager = IndexedDBManager.getInstance();
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
import { getDocument, GlobalWorkerOptions } from 'pdfjs-dist';
|
|
||||||
import { ProcessedFile, ProcessingState, PDFPage } from '../types/processing';
|
import { ProcessedFile, ProcessingState, PDFPage } from '../types/processing';
|
||||||
import { ProcessingCache } from './processingCache';
|
import { ProcessingCache } from './processingCache';
|
||||||
|
import { pdfWorkerManager } from './pdfWorkerManager';
|
||||||
// Set up PDF.js worker
|
|
||||||
GlobalWorkerOptions.workerSrc = '/pdf.worker.js';
|
|
||||||
|
|
||||||
export class PDFProcessingService {
|
export class PDFProcessingService {
|
||||||
private static instance: PDFProcessingService;
|
private static instance: PDFProcessingService;
|
||||||
@@ -96,7 +93,7 @@ export class PDFProcessingService {
|
|||||||
onProgress: (progress: number) => void
|
onProgress: (progress: number) => void
|
||||||
): Promise<ProcessedFile> {
|
): Promise<ProcessedFile> {
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
const pdf = await getDocument({ data: arrayBuffer }).promise;
|
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
|
||||||
const totalPages = pdf.numPages;
|
const totalPages = pdf.numPages;
|
||||||
|
|
||||||
onProgress(10); // PDF loaded
|
onProgress(10); // PDF loaded
|
||||||
@@ -129,7 +126,7 @@ export class PDFProcessingService {
|
|||||||
onProgress(progress);
|
onProgress(progress);
|
||||||
}
|
}
|
||||||
|
|
||||||
pdf.destroy();
|
pdfWorkerManager.destroyDocument(pdf);
|
||||||
onProgress(100);
|
onProgress(100);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* PDF.js Worker Manager - Centralized worker lifecycle management
|
||||||
|
*
|
||||||
|
* Prevents infinite worker creation by managing PDF.js workers globally
|
||||||
|
* and ensuring proper cleanup when operations complete.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as pdfjsLib from 'pdfjs-dist';
|
||||||
|
const { getDocument, GlobalWorkerOptions } = pdfjsLib;
|
||||||
|
|
||||||
|
class PDFWorkerManager {
|
||||||
|
private static instance: PDFWorkerManager;
|
||||||
|
private activeDocuments = new Set<any>();
|
||||||
|
private workerCount = 0;
|
||||||
|
private maxWorkers = 3; // Limit concurrent workers
|
||||||
|
private isInitialized = false;
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
this.initializeWorker();
|
||||||
|
}
|
||||||
|
|
||||||
|
static getInstance(): PDFWorkerManager {
|
||||||
|
if (!PDFWorkerManager.instance) {
|
||||||
|
PDFWorkerManager.instance = new PDFWorkerManager();
|
||||||
|
}
|
||||||
|
return PDFWorkerManager.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize PDF.js worker once globally
|
||||||
|
*/
|
||||||
|
private initializeWorker(): void {
|
||||||
|
if (!this.isInitialized) {
|
||||||
|
GlobalWorkerOptions.workerSrc = '/pdf.worker.js';
|
||||||
|
this.isInitialized = true;
|
||||||
|
console.log('🏭 PDF.js worker initialized');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a PDF document with proper lifecycle management
|
||||||
|
* Supports ArrayBuffer, Uint8Array, URL string, or {data: ArrayBuffer} object
|
||||||
|
*/
|
||||||
|
async createDocument(
|
||||||
|
data: ArrayBuffer | Uint8Array | string | { data: ArrayBuffer },
|
||||||
|
options: {
|
||||||
|
disableAutoFetch?: boolean;
|
||||||
|
disableStream?: boolean;
|
||||||
|
stopAtErrors?: boolean;
|
||||||
|
verbosity?: number;
|
||||||
|
} = {}
|
||||||
|
): Promise<any> {
|
||||||
|
// Wait if we've hit the worker limit
|
||||||
|
if (this.activeDocuments.size >= this.maxWorkers) {
|
||||||
|
console.warn(`🏭 PDF Worker limit reached (${this.maxWorkers}), waiting for available worker...`);
|
||||||
|
await this.waitForAvailableWorker();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize input data to PDF.js format
|
||||||
|
let pdfData: any;
|
||||||
|
if (data instanceof ArrayBuffer || data instanceof Uint8Array) {
|
||||||
|
pdfData = { data };
|
||||||
|
} else if (typeof data === 'string') {
|
||||||
|
pdfData = data; // URL string
|
||||||
|
} else if (data && typeof data === 'object' && 'data' in data) {
|
||||||
|
pdfData = data; // Already in {data: ArrayBuffer} format
|
||||||
|
} else {
|
||||||
|
pdfData = data; // Pass through as-is
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadingTask = getDocument(
|
||||||
|
typeof pdfData === 'string' ? {
|
||||||
|
url: pdfData,
|
||||||
|
disableAutoFetch: options.disableAutoFetch ?? true,
|
||||||
|
disableStream: options.disableStream ?? true,
|
||||||
|
stopAtErrors: options.stopAtErrors ?? false,
|
||||||
|
verbosity: options.verbosity ?? 0
|
||||||
|
} : {
|
||||||
|
...pdfData,
|
||||||
|
disableAutoFetch: options.disableAutoFetch ?? true,
|
||||||
|
disableStream: options.disableStream ?? true,
|
||||||
|
stopAtErrors: options.stopAtErrors ?? false,
|
||||||
|
verbosity: options.verbosity ?? 0
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pdf = await loadingTask.promise;
|
||||||
|
this.activeDocuments.add(pdf);
|
||||||
|
this.workerCount++;
|
||||||
|
|
||||||
|
console.log(`🏭 PDF document created (active: ${this.activeDocuments.size}/${this.maxWorkers})`);
|
||||||
|
|
||||||
|
return pdf;
|
||||||
|
} catch (error) {
|
||||||
|
// If document creation fails, make sure to clean up the loading task
|
||||||
|
if (loadingTask) {
|
||||||
|
try {
|
||||||
|
loadingTask.destroy();
|
||||||
|
} catch (destroyError) {
|
||||||
|
console.warn('🏭 Error destroying failed loading task:', destroyError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Properly destroy a PDF document and clean up resources
|
||||||
|
*/
|
||||||
|
destroyDocument(pdf: any): void {
|
||||||
|
if (this.activeDocuments.has(pdf)) {
|
||||||
|
try {
|
||||||
|
pdf.destroy();
|
||||||
|
this.activeDocuments.delete(pdf);
|
||||||
|
this.workerCount = Math.max(0, this.workerCount - 1);
|
||||||
|
|
||||||
|
console.log(`🏭 PDF document destroyed (active: ${this.activeDocuments.size}/${this.maxWorkers})`);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('🏭 Error destroying PDF document:', error);
|
||||||
|
// Still remove from tracking even if destroy failed
|
||||||
|
this.activeDocuments.delete(pdf);
|
||||||
|
this.workerCount = Math.max(0, this.workerCount - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroy all active PDF documents
|
||||||
|
*/
|
||||||
|
destroyAllDocuments(): void {
|
||||||
|
console.log(`🏭 Destroying all PDF documents (${this.activeDocuments.size} active)`);
|
||||||
|
|
||||||
|
const documentsToDestroy = Array.from(this.activeDocuments);
|
||||||
|
documentsToDestroy.forEach(pdf => {
|
||||||
|
this.destroyDocument(pdf);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.activeDocuments.clear();
|
||||||
|
this.workerCount = 0;
|
||||||
|
|
||||||
|
console.log('🏭 All PDF documents destroyed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for a worker to become available
|
||||||
|
*/
|
||||||
|
private async waitForAvailableWorker(): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const checkAvailability = () => {
|
||||||
|
if (this.activeDocuments.size < this.maxWorkers) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
setTimeout(checkAvailability, 100);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
checkAvailability();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current worker statistics
|
||||||
|
*/
|
||||||
|
getWorkerStats() {
|
||||||
|
return {
|
||||||
|
active: this.activeDocuments.size,
|
||||||
|
max: this.maxWorkers,
|
||||||
|
total: this.workerCount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force cleanup of all workers (emergency cleanup)
|
||||||
|
*/
|
||||||
|
emergencyCleanup(): void {
|
||||||
|
console.warn('🏭 Emergency PDF worker cleanup initiated');
|
||||||
|
|
||||||
|
// Force destroy all documents
|
||||||
|
this.activeDocuments.forEach(pdf => {
|
||||||
|
try {
|
||||||
|
pdf.destroy();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('🏭 Emergency cleanup - error destroying document:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.activeDocuments.clear();
|
||||||
|
this.workerCount = 0;
|
||||||
|
|
||||||
|
console.warn('🏭 Emergency cleanup completed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set maximum concurrent workers
|
||||||
|
*/
|
||||||
|
setMaxWorkers(max: number): void {
|
||||||
|
this.maxWorkers = Math.max(1, Math.min(max, 10)); // Between 1-10 workers
|
||||||
|
console.log(`🏭 Max workers set to ${this.maxWorkers}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
export const pdfWorkerManager = PDFWorkerManager.getInstance();
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* High-performance thumbnail generation service using Web Workers
|
* High-performance thumbnail generation service using main thread processing
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { pdfWorkerManager } from './pdfWorkerManager';
|
||||||
|
|
||||||
interface ThumbnailResult {
|
interface ThumbnailResult {
|
||||||
pageNumber: number;
|
pageNumber: number;
|
||||||
thumbnail: string;
|
thumbnail: string;
|
||||||
@@ -22,245 +24,136 @@ interface CachedThumbnail {
|
|||||||
sizeBytes: number;
|
sizeBytes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ThumbnailGenerationService {
|
interface CachedPDFDocument {
|
||||||
private workers: Worker[] = [];
|
pdf: any; // PDFDocumentProxy from pdfjs-dist
|
||||||
private activeJobs = new Map<string, { resolve: Function; reject: Function; onProgress?: Function }>();
|
lastUsed: number;
|
||||||
private jobCounter = 0;
|
refCount: number;
|
||||||
private isGenerating = false;
|
}
|
||||||
|
|
||||||
|
export class ThumbnailGenerationService {
|
||||||
// Session-based thumbnail cache
|
// Session-based thumbnail cache
|
||||||
private thumbnailCache = new Map<string, CachedThumbnail>();
|
private thumbnailCache = new Map<string, CachedThumbnail>();
|
||||||
private maxCacheSizeBytes = 1024 * 1024 * 1024; // 1GB cache limit
|
private maxCacheSizeBytes = 1024 * 1024 * 1024; // 1GB cache limit
|
||||||
private currentCacheSize = 0;
|
private currentCacheSize = 0;
|
||||||
|
|
||||||
|
// PDF document cache to reuse PDF instances and avoid creating multiple workers
|
||||||
|
private pdfDocumentCache = new Map<string, CachedPDFDocument>();
|
||||||
|
private maxPdfCacheSize = 10; // Keep up to 10 PDF documents cached
|
||||||
|
|
||||||
constructor(private maxWorkers: number = 3) {
|
constructor(private maxWorkers: number = 3) {
|
||||||
this.initializeWorkers();
|
// PDF rendering requires DOM access, so we use optimized main thread processing
|
||||||
}
|
|
||||||
|
|
||||||
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
|
* Get or create a cached PDF document
|
||||||
|
*/
|
||||||
|
private async getCachedPDFDocument(fileId: string, pdfArrayBuffer: ArrayBuffer): Promise<any> {
|
||||||
|
const cached = this.pdfDocumentCache.get(fileId);
|
||||||
|
if (cached) {
|
||||||
|
cached.lastUsed = Date.now();
|
||||||
|
cached.refCount++;
|
||||||
|
return cached.pdf;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evict old PDFs if cache is full
|
||||||
|
while (this.pdfDocumentCache.size >= this.maxPdfCacheSize) {
|
||||||
|
this.evictLeastRecentlyUsedPDF();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use centralized worker manager instead of direct getDocument
|
||||||
|
const pdf = await pdfWorkerManager.createDocument(pdfArrayBuffer, {
|
||||||
|
disableAutoFetch: true,
|
||||||
|
disableStream: true,
|
||||||
|
stopAtErrors: false
|
||||||
|
});
|
||||||
|
|
||||||
|
this.pdfDocumentCache.set(fileId, {
|
||||||
|
pdf,
|
||||||
|
lastUsed: Date.now(),
|
||||||
|
refCount: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
return pdf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release a reference to a cached PDF document
|
||||||
|
*/
|
||||||
|
private releasePDFDocument(fileId: string): void {
|
||||||
|
const cached = this.pdfDocumentCache.get(fileId);
|
||||||
|
if (cached) {
|
||||||
|
cached.refCount--;
|
||||||
|
// Don't destroy immediately - keep in cache for potential reuse
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evict the least recently used PDF document
|
||||||
|
*/
|
||||||
|
private evictLeastRecentlyUsedPDF(): void {
|
||||||
|
let oldestEntry: [string, CachedPDFDocument] | null = null;
|
||||||
|
let oldestTime = Date.now();
|
||||||
|
|
||||||
|
for (const [key, value] of this.pdfDocumentCache.entries()) {
|
||||||
|
if (value.lastUsed < oldestTime && value.refCount === 0) {
|
||||||
|
oldestTime = value.lastUsed;
|
||||||
|
oldestEntry = [key, value];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldestEntry) {
|
||||||
|
pdfWorkerManager.destroyDocument(oldestEntry[1].pdf); // Use worker manager for cleanup
|
||||||
|
this.pdfDocumentCache.delete(oldestEntry[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate thumbnails for multiple pages using main thread processing
|
||||||
*/
|
*/
|
||||||
async generateThumbnails(
|
async generateThumbnails(
|
||||||
|
fileId: string,
|
||||||
pdfArrayBuffer: ArrayBuffer,
|
pdfArrayBuffer: ArrayBuffer,
|
||||||
pageNumbers: number[],
|
pageNumbers: number[],
|
||||||
options: ThumbnailGenerationOptions = {},
|
options: ThumbnailGenerationOptions = {},
|
||||||
onProgress?: (progress: { completed: number; total: number; thumbnails: ThumbnailResult[] }) => void
|
onProgress?: (progress: { completed: number; total: number; thumbnails: ThumbnailResult[] }) => void
|
||||||
): Promise<ThumbnailResult[]> {
|
): Promise<ThumbnailResult[]> {
|
||||||
if (this.isGenerating) {
|
// Input validation
|
||||||
console.warn('🚨 ThumbnailService: Thumbnail generation already in progress, rejecting new request');
|
if (!fileId || typeof fileId !== 'string' || fileId.trim() === '') {
|
||||||
throw new Error('Thumbnail generation already in progress');
|
throw new Error('generateThumbnails: fileId must be a non-empty string');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`🎬 ThumbnailService: Starting thumbnail generation for ${pageNumbers.length} pages`);
|
if (!pdfArrayBuffer || pdfArrayBuffer.byteLength === 0) {
|
||||||
this.isGenerating = true;
|
throw new Error('generateThumbnails: pdfArrayBuffer must not be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pageNumbers || pageNumbers.length === 0) {
|
||||||
|
throw new Error('generateThumbnails: pageNumbers must not be empty');
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
scale = 0.2,
|
scale = 0.2,
|
||||||
quality = 0.8,
|
quality = 0.8
|
||||||
batchSize = 20, // Pages per worker
|
|
||||||
parallelBatches = this.maxWorkers
|
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
try {
|
return await this.generateThumbnailsMainThread(fileId, pdfArrayBuffer, pageNumbers, scale, quality, onProgress);
|
||||||
// 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
|
* Main thread thumbnail generation with batching for UI responsiveness
|
||||||
*/
|
*/
|
||||||
private async generateThumbnailsMainThread(
|
private async generateThumbnailsMainThread(
|
||||||
|
fileId: string,
|
||||||
pdfArrayBuffer: ArrayBuffer,
|
pdfArrayBuffer: ArrayBuffer,
|
||||||
pageNumbers: number[],
|
pageNumbers: number[],
|
||||||
scale: number,
|
scale: number,
|
||||||
quality: number,
|
quality: number,
|
||||||
onProgress?: (progress: { completed: number; total: number; thumbnails: ThumbnailResult[] }) => void
|
onProgress?: (progress: { completed: number; total: number; thumbnails: ThumbnailResult[] }) => void
|
||||||
): Promise<ThumbnailResult[]> {
|
): Promise<ThumbnailResult[]> {
|
||||||
console.log(`🔧 ThumbnailService: Fallback to main thread for ${pageNumbers.length} pages`);
|
const pdf = await this.getCachedPDFDocument(fileId, pdfArrayBuffer);
|
||||||
|
|
||||||
// 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[] = [];
|
const allResults: ThumbnailResult[] = [];
|
||||||
let completed = 0;
|
let completed = 0;
|
||||||
const batchSize = 5; // Small batches for UI responsiveness
|
const batchSize = 3; // Smaller batches for better UI responsiveness
|
||||||
|
|
||||||
// Process pages in small batches
|
// Process pages in small batches
|
||||||
for (let i = 0; i < pageNumbers.length; i += batchSize) {
|
for (let i = 0; i < pageNumbers.length; i += batchSize) {
|
||||||
@@ -308,143 +201,99 @@ export class ThumbnailGenerationService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Small delay to keep UI responsive
|
// Yield control to prevent UI blocking
|
||||||
if (i + batchSize < pageNumbers.length) {
|
await new Promise(resolve => setTimeout(resolve, 1));
|
||||||
await new Promise(resolve => setTimeout(resolve, 10));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up
|
// Release reference to PDF document (don't destroy - keep in cache)
|
||||||
pdf.destroy();
|
this.releasePDFDocument(fileId);
|
||||||
|
return allResults;
|
||||||
return allResults.filter(r => r.success);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Distribute work evenly across workers
|
* Cache management
|
||||||
*/
|
|
||||||
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 {
|
getThumbnailFromCache(pageId: string): string | null {
|
||||||
const cached = this.thumbnailCache.get(pageId);
|
const cached = this.thumbnailCache.get(pageId);
|
||||||
if (!cached) return null;
|
if (cached) {
|
||||||
|
|
||||||
// Update last used timestamp
|
|
||||||
cached.lastUsed = Date.now();
|
cached.lastUsed = Date.now();
|
||||||
|
|
||||||
return cached.thumbnail;
|
return cached.thumbnail;
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
addThumbnailToCache(pageId: string, thumbnail: string): void {
|
||||||
* Clean up cache using LRU eviction
|
const sizeBytes = thumbnail.length * 2; // Rough estimate for base64 string
|
||||||
*/
|
|
||||||
private cleanupThumbnailCache(): void {
|
|
||||||
const entries = Array.from(this.thumbnailCache.entries());
|
|
||||||
|
|
||||||
// Sort by last used (oldest first)
|
// Enforce cache size limits
|
||||||
entries.sort(([, a], [, b]) => a.lastUsed - b.lastUsed);
|
while (this.currentCacheSize + sizeBytes > this.maxCacheSizeBytes && this.thumbnailCache.size > 0) {
|
||||||
|
this.evictLeastRecentlyUsed();
|
||||||
|
}
|
||||||
|
|
||||||
this.thumbnailCache.clear();
|
this.thumbnailCache.set(pageId, {
|
||||||
this.currentCacheSize = 0;
|
thumbnail,
|
||||||
const targetSize = this.maxCacheSizeBytes * 0.8; // Clean to 80% of limit
|
lastUsed: Date.now(),
|
||||||
|
sizeBytes
|
||||||
|
});
|
||||||
|
|
||||||
// Keep most recently used entries until we hit target size
|
this.currentCacheSize += sizeBytes;
|
||||||
for (let i = entries.length - 1; i >= 0 && this.currentCacheSize < targetSize; i--) {
|
}
|
||||||
const [key, value] = entries[i];
|
|
||||||
this.thumbnailCache.set(key, value);
|
private evictLeastRecentlyUsed(): void {
|
||||||
this.currentCacheSize += value.sizeBytes;
|
let oldestEntry: [string, CachedThumbnail] | null = null;
|
||||||
|
let oldestTime = Date.now();
|
||||||
|
|
||||||
|
for (const [key, value] of this.thumbnailCache.entries()) {
|
||||||
|
if (value.lastUsed < oldestTime) {
|
||||||
|
oldestTime = value.lastUsed;
|
||||||
|
oldestEntry = [key, value];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
if (oldestEntry) {
|
||||||
* Clear all cached thumbnails
|
this.thumbnailCache.delete(oldestEntry[0]);
|
||||||
*/
|
this.currentCacheSize -= oldestEntry[1].sizeBytes;
|
||||||
clearThumbnailCache(): void {
|
}
|
||||||
this.thumbnailCache.clear();
|
|
||||||
this.currentCacheSize = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get cache statistics
|
|
||||||
*/
|
|
||||||
getCacheStats() {
|
getCacheStats() {
|
||||||
return {
|
return {
|
||||||
entries: this.thumbnailCache.size,
|
size: this.thumbnailCache.size,
|
||||||
totalSizeBytes: this.currentCacheSize,
|
sizeBytes: this.currentCacheSize,
|
||||||
maxSizeBytes: this.maxCacheSizeBytes
|
maxSizeBytes: this.maxCacheSizeBytes
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop generation but keep cache and workers alive
|
|
||||||
*/
|
|
||||||
stopGeneration(): void {
|
stopGeneration(): void {
|
||||||
this.activeJobs.clear();
|
// No-op since we removed workers
|
||||||
this.isGenerating = false;
|
}
|
||||||
|
|
||||||
|
clearCache(): void {
|
||||||
|
this.thumbnailCache.clear();
|
||||||
|
this.currentCacheSize = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearPDFCache(): void {
|
||||||
|
// Destroy all cached PDF documents using worker manager
|
||||||
|
for (const [, cached] of this.pdfDocumentCache) {
|
||||||
|
pdfWorkerManager.destroyDocument(cached.pdf);
|
||||||
|
}
|
||||||
|
this.pdfDocumentCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
clearPDFCacheForFile(fileId: string): void {
|
||||||
|
const cached = this.pdfDocumentCache.get(fileId);
|
||||||
|
if (cached) {
|
||||||
|
pdfWorkerManager.destroyDocument(cached.pdf);
|
||||||
|
this.pdfDocumentCache.delete(fileId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Terminate all workers and clear cache (only on explicit cleanup)
|
|
||||||
*/
|
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
this.workers.forEach(worker => worker.terminate());
|
this.clearCache();
|
||||||
this.workers = [];
|
this.clearPDFCache();
|
||||||
this.activeJobs.clear();
|
|
||||||
this.isGenerating = false;
|
|
||||||
this.clearThumbnailCache();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export singleton instance
|
// Global singleton instance
|
||||||
export const thumbnailGenerationService = new ThumbnailGenerationService();
|
export const thumbnailGenerationService = new ThumbnailGenerationService();
|
||||||
@@ -283,7 +283,7 @@ export const mantineTheme = createTheme({
|
|||||||
},
|
},
|
||||||
control: {
|
control: {
|
||||||
color: 'var(--text-secondary)',
|
color: 'var(--text-secondary)',
|
||||||
'[dataActive]': {
|
'[data-active]': {
|
||||||
backgroundColor: 'var(--bg-surface)',
|
backgroundColor: 'var(--bg-surface)',
|
||||||
color: 'var(--text-primary)',
|
color: 'var(--text-primary)',
|
||||||
boxShadow: 'var(--shadow-sm)',
|
boxShadow: 'var(--shadow-sm)',
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
||||||
import { useFileContext } from "../contexts/FileContext";
|
import { useFileSelection } from "../contexts/FileContext";
|
||||||
import { useToolFileSelection } from "../contexts/FileSelectionContext";
|
import { useNavigationActions } from "../contexts/NavigationContext";
|
||||||
|
|
||||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||||
|
|
||||||
@@ -17,8 +17,8 @@ import { BaseToolProps } from "../types/tool";
|
|||||||
|
|
||||||
const AddPassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
const AddPassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { setCurrentMode } = useFileContext();
|
const { actions } = useNavigationActions();
|
||||||
const { selectedFiles } = useToolFileSelection();
|
const { selectedFiles } = useFileSelection();
|
||||||
|
|
||||||
const [collapsedPermissions, setCollapsedPermissions] = useState(true);
|
const [collapsedPermissions, setCollapsedPermissions] = useState(true);
|
||||||
|
|
||||||
@@ -30,6 +30,8 @@ const AddPassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
// Endpoint validation
|
// Endpoint validation
|
||||||
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(addPasswordParams.getEndpointName());
|
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(addPasswordParams.getEndpointName());
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
addPasswordOperation.resetResults();
|
addPasswordOperation.resetResults();
|
||||||
onPreviewFile?.(null);
|
onPreviewFile?.(null);
|
||||||
@@ -51,13 +53,11 @@ const AddPassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
const handleThumbnailClick = (file: File) => {
|
const handleThumbnailClick = (file: File) => {
|
||||||
onPreviewFile?.(file);
|
onPreviewFile?.(file);
|
||||||
sessionStorage.setItem("previousMode", "addPassword");
|
sessionStorage.setItem("previousMode", "addPassword");
|
||||||
setCurrentMode("viewer");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSettingsReset = () => {
|
const handleSettingsReset = () => {
|
||||||
addPasswordOperation.resetResults();
|
addPasswordOperation.resetResults();
|
||||||
onPreviewFile?.(null);
|
onPreviewFile?.(null);
|
||||||
setCurrentMode("addPassword");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasFiles = selectedFiles.length > 0;
|
const hasFiles = selectedFiles.length > 0;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
||||||
import { useFileContext } from "../contexts/FileContext";
|
import { useFileSelection } from "../contexts/FileContext";
|
||||||
import { useToolFileSelection } from "../contexts/FileSelectionContext";
|
import { useNavigationActions } from "../contexts/NavigationContext";
|
||||||
|
|
||||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||||
|
|
||||||
@@ -25,8 +25,8 @@ import { BaseToolProps } from "../types/tool";
|
|||||||
|
|
||||||
const AddWatermark = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
const AddWatermark = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { setCurrentMode } = useFileContext();
|
const { actions } = useNavigationActions();
|
||||||
const { selectedFiles } = useToolFileSelection();
|
const { selectedFiles } = useFileSelection();
|
||||||
|
|
||||||
const [collapsedType, setCollapsedType] = useState(false);
|
const [collapsedType, setCollapsedType] = useState(false);
|
||||||
const [collapsedStyle, setCollapsedStyle] = useState(true);
|
const [collapsedStyle, setCollapsedStyle] = useState(true);
|
||||||
@@ -43,6 +43,7 @@ const AddWatermark = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =>
|
|||||||
// Endpoint validation
|
// Endpoint validation
|
||||||
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled("add-watermark");
|
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled("add-watermark");
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
watermarkOperation.resetResults();
|
watermarkOperation.resetResults();
|
||||||
onPreviewFile?.(null);
|
onPreviewFile?.(null);
|
||||||
@@ -71,13 +72,11 @@ const AddWatermark = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =>
|
|||||||
const handleThumbnailClick = (file: File) => {
|
const handleThumbnailClick = (file: File) => {
|
||||||
onPreviewFile?.(file);
|
onPreviewFile?.(file);
|
||||||
sessionStorage.setItem("previousMode", "watermark");
|
sessionStorage.setItem("previousMode", "watermark");
|
||||||
setCurrentMode("viewer");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSettingsReset = () => {
|
const handleSettingsReset = () => {
|
||||||
watermarkOperation.resetResults();
|
watermarkOperation.resetResults();
|
||||||
onPreviewFile?.(null);
|
onPreviewFile?.(null);
|
||||||
setCurrentMode("watermark");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasFiles = selectedFiles.length > 0;
|
const hasFiles = selectedFiles.length > 0;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
||||||
import { useFileContext } from "../contexts/FileContext";
|
import { useFileSelection } from "../contexts/FileContext";
|
||||||
import { useToolFileSelection } from "../contexts/FileSelectionContext";
|
import { useNavigationActions } from "../contexts/NavigationContext";
|
||||||
|
|
||||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||||
|
|
||||||
@@ -15,8 +15,8 @@ import { BaseToolProps } from "../types/tool";
|
|||||||
|
|
||||||
const ChangePermissions = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
const ChangePermissions = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { setCurrentMode } = useFileContext();
|
const { actions } = useNavigationActions();
|
||||||
const { selectedFiles } = useToolFileSelection();
|
const { selectedFiles } = useFileSelection();
|
||||||
|
|
||||||
const changePermissionsParams = useChangePermissionsParameters();
|
const changePermissionsParams = useChangePermissionsParameters();
|
||||||
const changePermissionsOperation = useChangePermissionsOperation();
|
const changePermissionsOperation = useChangePermissionsOperation();
|
||||||
@@ -48,13 +48,11 @@ const ChangePermissions = ({ onPreviewFile, onComplete, onError }: BaseToolProps
|
|||||||
const handleThumbnailClick = (file: File) => {
|
const handleThumbnailClick = (file: File) => {
|
||||||
onPreviewFile?.(file);
|
onPreviewFile?.(file);
|
||||||
sessionStorage.setItem("previousMode", "changePermissions");
|
sessionStorage.setItem("previousMode", "changePermissions");
|
||||||
setCurrentMode("viewer");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSettingsReset = () => {
|
const handleSettingsReset = () => {
|
||||||
changePermissionsOperation.resetResults();
|
changePermissionsOperation.resetResults();
|
||||||
onPreviewFile?.(null);
|
onPreviewFile?.(null);
|
||||||
setCurrentMode("changePermissions");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasFiles = selectedFiles.length > 0;
|
const hasFiles = selectedFiles.length > 0;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
||||||
import { useFileContext } from "../contexts/FileContext";
|
import { useFileSelection } from "../contexts/FileContext";
|
||||||
import { useToolFileSelection } from "../contexts/FileSelectionContext";
|
import { useNavigationActions } from "../contexts/NavigationContext";
|
||||||
|
|
||||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||||
|
|
||||||
@@ -15,8 +15,8 @@ import { useCompressTips } from "../components/tooltips/useCompressTips";
|
|||||||
|
|
||||||
const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { setCurrentMode } = useFileContext();
|
const { actions } = useNavigationActions();
|
||||||
const { selectedFiles } = useToolFileSelection();
|
const { selectedFiles } = useFileSelection();
|
||||||
|
|
||||||
const compressParams = useCompressParameters();
|
const compressParams = useCompressParameters();
|
||||||
const compressOperation = useCompressOperation();
|
const compressOperation = useCompressOperation();
|
||||||
@@ -46,13 +46,12 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
const handleThumbnailClick = (file: File) => {
|
const handleThumbnailClick = (file: File) => {
|
||||||
onPreviewFile?.(file);
|
onPreviewFile?.(file);
|
||||||
sessionStorage.setItem("previousMode", "compress");
|
sessionStorage.setItem("previousMode", "compress");
|
||||||
setCurrentMode("viewer");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSettingsReset = () => {
|
const handleSettingsReset = () => {
|
||||||
compressOperation.resetResults();
|
compressOperation.resetResults();
|
||||||
onPreviewFile?.(null);
|
onPreviewFile?.(null);
|
||||||
setCurrentMode("compress");
|
actions.setMode("compress");
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasFiles = selectedFiles.length > 0;
|
const hasFiles = selectedFiles.length > 0;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { useEffect, useRef } from "react";
|
import React, { useEffect, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
||||||
import { useFileContext } from "../contexts/FileContext";
|
import { useFileState, useFileSelection } from "../contexts/FileContext";
|
||||||
import { useToolFileSelection } from "../contexts/FileSelectionContext";
|
import { useNavigationActions } from "../contexts/NavigationContext";
|
||||||
|
|
||||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||||
|
|
||||||
@@ -14,8 +14,10 @@ import { BaseToolProps } from "../types/tool";
|
|||||||
|
|
||||||
const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { setCurrentMode, activeFiles } = useFileContext();
|
const { selectors } = useFileState();
|
||||||
const { selectedFiles } = useToolFileSelection();
|
const { actions } = useNavigationActions();
|
||||||
|
const activeFiles = selectors.getFiles();
|
||||||
|
const { selectedFiles } = useFileSelection();
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const convertParams = useConvertParameters();
|
const convertParams = useConvertParameters();
|
||||||
@@ -46,7 +48,7 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
convertParams.resetParameters();
|
convertParams.resetParameters();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [selectedFiles, activeFiles]);
|
}, [selectedFiles, activeFiles, convertParams.analyzeFileTypes, convertParams.resetParameters]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only clear results if we're not currently processing and parameters changed
|
// Only clear results if we're not currently processing and parameters changed
|
||||||
@@ -84,13 +86,11 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
const handleThumbnailClick = (file: File) => {
|
const handleThumbnailClick = (file: File) => {
|
||||||
onPreviewFile?.(file);
|
onPreviewFile?.(file);
|
||||||
sessionStorage.setItem("previousMode", "convert");
|
sessionStorage.setItem("previousMode", "convert");
|
||||||
setCurrentMode("viewer");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSettingsReset = () => {
|
const handleSettingsReset = () => {
|
||||||
convertOperation.resetResults();
|
convertOperation.resetResults();
|
||||||
onPreviewFile?.(null);
|
onPreviewFile?.(null);
|
||||||
setCurrentMode("convert");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return createToolFlow({
|
return createToolFlow({
|
||||||
|
|||||||
@@ -1,149 +0,0 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
|
||||||
import { Paper, Button, Checkbox, Stack, Text, Group, Loader, Alert } from "@mantine/core";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { FileWithUrl } from "../types/file";
|
|
||||||
import { fileStorage } from "../services/fileStorage";
|
|
||||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
|
||||||
|
|
||||||
export interface MergePdfPanelProps {
|
|
||||||
files: FileWithUrl[];
|
|
||||||
setDownloadUrl: (url: string) => void;
|
|
||||||
params: {
|
|
||||||
order: string;
|
|
||||||
removeDuplicates: boolean;
|
|
||||||
};
|
|
||||||
updateParams: (newParams: Partial<MergePdfPanelProps["params"]>) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MergePdfPanel: React.FC<MergePdfPanelProps> = ({ files, setDownloadUrl, params, updateParams }) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [selectedFiles, setSelectedFiles] = useState<boolean[]>([]);
|
|
||||||
const [downloadUrl, setLocalDownloadUrl] = useState<string | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
||||||
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled("merge-pdfs");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setSelectedFiles(files.map(() => true));
|
|
||||||
}, [files]);
|
|
||||||
|
|
||||||
const handleMerge = async () => {
|
|
||||||
const filesToMerge = files.filter((_, index) => selectedFiles[index]);
|
|
||||||
if (filesToMerge.length < 2) {
|
|
||||||
setErrorMessage(t("multiPdfPrompt")); // "Select PDFs (2+)"
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
|
|
||||||
// Handle IndexedDB files
|
|
||||||
for (const file of filesToMerge) {
|
|
||||||
if (!file.id) {
|
|
||||||
continue; // Skip files without an id
|
|
||||||
}
|
|
||||||
const storedFile = await fileStorage.getFile(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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
setErrorMessage(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/v1/general/merge-pdfs", {
|
|
||||||
method: "POST",
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
throw new Error(`Failed to merge PDFs: ${errorText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await response.blob();
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
setDownloadUrl(url);
|
|
||||||
setLocalDownloadUrl(url);
|
|
||||||
} catch (error: any) {
|
|
||||||
setErrorMessage(error.message || "Unknown error occurred.");
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCheckboxChange = (index: number) => {
|
|
||||||
setSelectedFiles((prev) => prev.map((selected, i) => (i === index ? !selected : selected)));
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectedCount = selectedFiles.filter(Boolean).length;
|
|
||||||
|
|
||||||
const { order, removeDuplicates } = params;
|
|
||||||
|
|
||||||
if (endpointLoading) {
|
|
||||||
return (
|
|
||||||
<Stack align="center" justify="center" h={200}>
|
|
||||||
<Loader size="md" />
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t("loading", "Loading...")}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<Stack>
|
|
||||||
<Text fw={500} size="lg">
|
|
||||||
{t("merge.header")}
|
|
||||||
</Text>
|
|
||||||
<Stack gap={4}>
|
|
||||||
{files.map((file, index) => (
|
|
||||||
<Group key={index} gap="xs">
|
|
||||||
<Checkbox checked={selectedFiles[index] || false} onChange={() => handleCheckboxChange(index)} />
|
|
||||||
<Text size="sm">{file.name}</Text>
|
|
||||||
</Group>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
{selectedCount < 2 && (
|
|
||||||
<Text size="sm" c="red">
|
|
||||||
{t("multiPdfPrompt")}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<Button onClick={handleMerge} loading={isLoading} disabled={selectedCount < 2 || isLoading} mt="md">
|
|
||||||
{t("merge.submit")}
|
|
||||||
</Button>
|
|
||||||
{errorMessage && (
|
|
||||||
<Alert color="red" mt="sm">
|
|
||||||
{errorMessage}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
{downloadUrl && (
|
|
||||||
<Button component="a" href={downloadUrl} download="merged.pdf" color="green" variant="light" mt="md">
|
|
||||||
{t("downloadPdf")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Checkbox
|
|
||||||
label={t("merge.removeCertSign")}
|
|
||||||
checked={removeDuplicates}
|
|
||||||
onChange={() => updateParams({ removeDuplicates: !removeDuplicates })}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MergePdfPanel;
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
||||||
import { useFileContext } from "../contexts/FileContext";
|
import { useFileSelection } from "../contexts/FileContext";
|
||||||
import { useToolFileSelection } from "../contexts/FileSelectionContext";
|
import { useNavigationActions } from "../contexts/NavigationContext";
|
||||||
|
|
||||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||||
|
|
||||||
@@ -16,8 +16,8 @@ import { useOCRTips } from "../components/tooltips/useOCRTips";
|
|||||||
|
|
||||||
const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { setCurrentMode } = useFileContext();
|
const { actions } = useNavigationActions();
|
||||||
const { selectedFiles } = useToolFileSelection();
|
const { selectedFiles } = useFileSelection();
|
||||||
|
|
||||||
const ocrParams = useOCRParameters();
|
const ocrParams = useOCRParameters();
|
||||||
const ocrOperation = useOCROperation();
|
const ocrOperation = useOCROperation();
|
||||||
@@ -66,13 +66,11 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
const handleThumbnailClick = (file: File) => {
|
const handleThumbnailClick = (file: File) => {
|
||||||
onPreviewFile?.(file);
|
onPreviewFile?.(file);
|
||||||
sessionStorage.setItem("previousMode", "ocr");
|
sessionStorage.setItem("previousMode", "ocr");
|
||||||
setCurrentMode("viewer");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSettingsReset = () => {
|
const handleSettingsReset = () => {
|
||||||
ocrOperation.resetResults();
|
ocrOperation.resetResults();
|
||||||
onPreviewFile?.(null);
|
onPreviewFile?.(null);
|
||||||
setCurrentMode("ocr");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const settingsCollapsed = expandedStep !== "settings";
|
const settingsCollapsed = expandedStep !== "settings";
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import React, { useEffect } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
||||||
import { useFileContext } from "../contexts/FileContext";
|
import { useFileContext } from "../contexts/FileContext";
|
||||||
import { useToolFileSelection } from "../contexts/FileSelectionContext";
|
import { useNavigationActions } from "../contexts/NavigationContext";
|
||||||
|
import { useFileSelection } from "../contexts/file/fileHooks";
|
||||||
|
|
||||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||||
|
|
||||||
@@ -12,8 +13,8 @@ import { BaseToolProps } from "../types/tool";
|
|||||||
|
|
||||||
const RemoveCertificateSign = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
const RemoveCertificateSign = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { setCurrentMode } = useFileContext();
|
const { actions } = useNavigationActions();
|
||||||
const { selectedFiles } = useToolFileSelection();
|
const { selectedFiles } = useFileSelection();
|
||||||
|
|
||||||
const removeCertificateSignParams = useRemoveCertificateSignParameters();
|
const removeCertificateSignParams = useRemoveCertificateSignParameters();
|
||||||
const removeCertificateSignOperation = useRemoveCertificateSignOperation();
|
const removeCertificateSignOperation = useRemoveCertificateSignOperation();
|
||||||
@@ -42,13 +43,12 @@ const RemoveCertificateSign = ({ onPreviewFile, onComplete, onError }: BaseToolP
|
|||||||
const handleThumbnailClick = (file: File) => {
|
const handleThumbnailClick = (file: File) => {
|
||||||
onPreviewFile?.(file);
|
onPreviewFile?.(file);
|
||||||
sessionStorage.setItem("previousMode", "removeCertificateSign");
|
sessionStorage.setItem("previousMode", "removeCertificateSign");
|
||||||
setCurrentMode("viewer");
|
actions.setMode("viewer");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSettingsReset = () => {
|
const handleSettingsReset = () => {
|
||||||
removeCertificateSignOperation.resetResults();
|
removeCertificateSignOperation.resetResults();
|
||||||
onPreviewFile?.(null);
|
onPreviewFile?.(null);
|
||||||
setCurrentMode("removeCertificateSign");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasFiles = selectedFiles.length > 0;
|
const hasFiles = selectedFiles.length > 0;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
||||||
import { useFileContext } from "../contexts/FileContext";
|
import { useFileSelection } from "../contexts/FileContext";
|
||||||
import { useToolFileSelection } from "../contexts/FileSelectionContext";
|
import { useNavigationActions } from "../contexts/NavigationContext";
|
||||||
|
|
||||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||||
|
|
||||||
@@ -15,8 +15,8 @@ import { BaseToolProps } from "../types/tool";
|
|||||||
|
|
||||||
const RemovePassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
const RemovePassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { setCurrentMode } = useFileContext();
|
const { actions } = useNavigationActions();
|
||||||
const { selectedFiles } = useToolFileSelection();
|
const { selectedFiles } = useFileSelection();
|
||||||
|
|
||||||
const removePasswordParams = useRemovePasswordParameters();
|
const removePasswordParams = useRemovePasswordParameters();
|
||||||
const removePasswordOperation = useRemovePasswordOperation();
|
const removePasswordOperation = useRemovePasswordOperation();
|
||||||
@@ -25,6 +25,7 @@ const RemovePassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =
|
|||||||
// Endpoint validation
|
// Endpoint validation
|
||||||
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(removePasswordParams.getEndpointName());
|
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(removePasswordParams.getEndpointName());
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
removePasswordOperation.resetResults();
|
removePasswordOperation.resetResults();
|
||||||
onPreviewFile?.(null);
|
onPreviewFile?.(null);
|
||||||
@@ -46,13 +47,11 @@ const RemovePassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =
|
|||||||
const handleThumbnailClick = (file: File) => {
|
const handleThumbnailClick = (file: File) => {
|
||||||
onPreviewFile?.(file);
|
onPreviewFile?.(file);
|
||||||
sessionStorage.setItem("previousMode", "removePassword");
|
sessionStorage.setItem("previousMode", "removePassword");
|
||||||
setCurrentMode("viewer");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSettingsReset = () => {
|
const handleSettingsReset = () => {
|
||||||
removePasswordOperation.resetResults();
|
removePasswordOperation.resetResults();
|
||||||
onPreviewFile?.(null);
|
onPreviewFile?.(null);
|
||||||
setCurrentMode("removePassword");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasFiles = selectedFiles.length > 0;
|
const hasFiles = selectedFiles.length > 0;
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import React, { useEffect } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
||||||
import { useFileContext } from "../contexts/FileContext";
|
import { useFileContext } from "../contexts/FileContext";
|
||||||
import { useToolFileSelection } from "../contexts/FileSelectionContext";
|
import { useNavigationActions } from "../contexts/NavigationContext";
|
||||||
|
import { useFileSelection } from "../contexts/file/fileHooks";
|
||||||
|
|
||||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||||
|
|
||||||
@@ -12,8 +13,8 @@ import { BaseToolProps } from "../types/tool";
|
|||||||
|
|
||||||
const Repair = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
const Repair = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { setCurrentMode } = useFileContext();
|
const { actions } = useNavigationActions();
|
||||||
const { selectedFiles } = useToolFileSelection();
|
const { selectedFiles } = useFileSelection();
|
||||||
|
|
||||||
const repairParams = useRepairParameters();
|
const repairParams = useRepairParameters();
|
||||||
const repairOperation = useRepairOperation();
|
const repairOperation = useRepairOperation();
|
||||||
@@ -42,13 +43,12 @@ const Repair = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
const handleThumbnailClick = (file: File) => {
|
const handleThumbnailClick = (file: File) => {
|
||||||
onPreviewFile?.(file);
|
onPreviewFile?.(file);
|
||||||
sessionStorage.setItem("previousMode", "repair");
|
sessionStorage.setItem("previousMode", "repair");
|
||||||
setCurrentMode("viewer");
|
actions.setMode("viewer");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSettingsReset = () => {
|
const handleSettingsReset = () => {
|
||||||
repairOperation.resetResults();
|
repairOperation.resetResults();
|
||||||
onPreviewFile?.(null);
|
onPreviewFile?.(null);
|
||||||
setCurrentMode("repair");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasFiles = selectedFiles.length > 0;
|
const hasFiles = selectedFiles.length > 0;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
||||||
import { useToolFileSelection } from "../contexts/FileSelectionContext";
|
import { useFileSelection } from "../contexts/FileContext";
|
||||||
|
import { useNavigationActions } from "../contexts/NavigationContext";
|
||||||
|
|
||||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||||
import SanitizeSettings from "../components/tools/sanitize/SanitizeSettings";
|
import SanitizeSettings from "../components/tools/sanitize/SanitizeSettings";
|
||||||
@@ -9,13 +10,12 @@ import SanitizeSettings from "../components/tools/sanitize/SanitizeSettings";
|
|||||||
import { useSanitizeParameters } from "../hooks/tools/sanitize/useSanitizeParameters";
|
import { useSanitizeParameters } from "../hooks/tools/sanitize/useSanitizeParameters";
|
||||||
import { useSanitizeOperation } from "../hooks/tools/sanitize/useSanitizeOperation";
|
import { useSanitizeOperation } from "../hooks/tools/sanitize/useSanitizeOperation";
|
||||||
import { BaseToolProps } from "../types/tool";
|
import { BaseToolProps } from "../types/tool";
|
||||||
import { useFileContext } from "../contexts/FileContext";
|
|
||||||
|
|
||||||
const Sanitize = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
const Sanitize = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { selectedFiles } = useToolFileSelection();
|
const { selectedFiles } = useFileSelection();
|
||||||
const { setCurrentMode } = useFileContext();
|
const { actions } = useNavigationActions();
|
||||||
|
|
||||||
const sanitizeParams = useSanitizeParameters();
|
const sanitizeParams = useSanitizeParameters();
|
||||||
const sanitizeOperation = useSanitizeOperation();
|
const sanitizeOperation = useSanitizeOperation();
|
||||||
@@ -44,13 +44,11 @@ const Sanitize = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
const handleSettingsReset = () => {
|
const handleSettingsReset = () => {
|
||||||
sanitizeOperation.resetResults();
|
sanitizeOperation.resetResults();
|
||||||
onPreviewFile?.(null);
|
onPreviewFile?.(null);
|
||||||
setCurrentMode("sanitize");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleThumbnailClick = (file: File) => {
|
const handleThumbnailClick = (file: File) => {
|
||||||
onPreviewFile?.(file);
|
onPreviewFile?.(file);
|
||||||
sessionStorage.setItem("previousMode", "sanitize");
|
sessionStorage.setItem("previousMode", "sanitize");
|
||||||
setCurrentMode("viewer");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasFiles = selectedFiles.length > 0;
|
const hasFiles = selectedFiles.length > 0;
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import React, { useEffect } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
||||||
import { useFileContext } from "../contexts/FileContext";
|
import { useFileContext } from "../contexts/FileContext";
|
||||||
import { useToolFileSelection } from "../contexts/FileSelectionContext";
|
import { useNavigationActions } from "../contexts/NavigationContext";
|
||||||
|
import { useFileSelection } from "../contexts/file/fileHooks";
|
||||||
|
|
||||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||||
|
|
||||||
@@ -12,8 +13,8 @@ import { BaseToolProps } from "../types/tool";
|
|||||||
|
|
||||||
const SingleLargePage = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
const SingleLargePage = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { setCurrentMode } = useFileContext();
|
const { actions } = useNavigationActions();
|
||||||
const { selectedFiles } = useToolFileSelection();
|
const { selectedFiles } = useFileSelection();
|
||||||
|
|
||||||
const singleLargePageParams = useSingleLargePageParameters();
|
const singleLargePageParams = useSingleLargePageParameters();
|
||||||
const singleLargePageOperation = useSingleLargePageOperation();
|
const singleLargePageOperation = useSingleLargePageOperation();
|
||||||
@@ -42,13 +43,12 @@ const SingleLargePage = ({ onPreviewFile, onComplete, onError }: BaseToolProps)
|
|||||||
const handleThumbnailClick = (file: File) => {
|
const handleThumbnailClick = (file: File) => {
|
||||||
onPreviewFile?.(file);
|
onPreviewFile?.(file);
|
||||||
sessionStorage.setItem("previousMode", "single-large-page");
|
sessionStorage.setItem("previousMode", "single-large-page");
|
||||||
setCurrentMode("viewer");
|
actions.setMode("viewer");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSettingsReset = () => {
|
const handleSettingsReset = () => {
|
||||||
singleLargePageOperation.resetResults();
|
singleLargePageOperation.resetResults();
|
||||||
onPreviewFile?.(null);
|
onPreviewFile?.(null);
|
||||||
setCurrentMode("single-large-page");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasFiles = selectedFiles.length > 0;
|
const hasFiles = selectedFiles.length > 0;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
||||||
import { useFileContext } from "../contexts/FileContext";
|
import { useFileSelection } from "../contexts/FileContext";
|
||||||
import { useToolFileSelection } from "../contexts/FileSelectionContext";
|
import { useNavigationActions } from "../contexts/NavigationContext";
|
||||||
|
|
||||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||||
import SplitSettings from "../components/tools/split/SplitSettings";
|
import SplitSettings from "../components/tools/split/SplitSettings";
|
||||||
@@ -13,8 +13,8 @@ import { BaseToolProps } from "../types/tool";
|
|||||||
|
|
||||||
const Split = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
const Split = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { setCurrentMode } = useFileContext();
|
const { actions } = useNavigationActions();
|
||||||
const { selectedFiles } = useToolFileSelection();
|
const { selectedFiles } = useFileSelection();
|
||||||
|
|
||||||
const splitParams = useSplitParameters();
|
const splitParams = useSplitParameters();
|
||||||
const splitOperation = useSplitOperation();
|
const splitOperation = useSplitOperation();
|
||||||
@@ -25,8 +25,7 @@ const Split = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
splitOperation.resetResults();
|
splitOperation.resetResults();
|
||||||
onPreviewFile?.(null);
|
onPreviewFile?.(null);
|
||||||
}, [splitParams.parameters]);
|
}, [splitParams.parameters, selectedFiles]);
|
||||||
|
|
||||||
const handleSplit = async () => {
|
const handleSplit = async () => {
|
||||||
try {
|
try {
|
||||||
await splitOperation.executeOperation(splitParams.parameters, selectedFiles);
|
await splitOperation.executeOperation(splitParams.parameters, selectedFiles);
|
||||||
@@ -43,13 +42,12 @@ const Split = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
const handleThumbnailClick = (file: File) => {
|
const handleThumbnailClick = (file: File) => {
|
||||||
onPreviewFile?.(file);
|
onPreviewFile?.(file);
|
||||||
sessionStorage.setItem("previousMode", "split");
|
sessionStorage.setItem("previousMode", "split");
|
||||||
setCurrentMode("viewer");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSettingsReset = () => {
|
const handleSettingsReset = () => {
|
||||||
splitOperation.resetResults();
|
splitOperation.resetResults();
|
||||||
onPreviewFile?.(null);
|
onPreviewFile?.(null);
|
||||||
setCurrentMode("split");
|
actions.setMode("split");
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasFiles = selectedFiles.length > 0;
|
const hasFiles = selectedFiles.length > 0;
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import React, { useEffect } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
||||||
import { useFileContext } from "../contexts/FileContext";
|
import { useFileContext } from "../contexts/FileContext";
|
||||||
import { useToolFileSelection } from "../contexts/FileSelectionContext";
|
import { useNavigationActions } from "../contexts/NavigationContext";
|
||||||
|
import { useFileSelection } from "../contexts/file/fileHooks";
|
||||||
|
|
||||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||||
|
|
||||||
@@ -12,8 +13,8 @@ import { BaseToolProps } from "../types/tool";
|
|||||||
|
|
||||||
const UnlockPdfForms = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
const UnlockPdfForms = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { setCurrentMode } = useFileContext();
|
const { actions } = useNavigationActions();
|
||||||
const { selectedFiles } = useToolFileSelection();
|
const { selectedFiles } = useFileSelection();
|
||||||
|
|
||||||
const unlockPdfFormsParams = useUnlockPdfFormsParameters();
|
const unlockPdfFormsParams = useUnlockPdfFormsParameters();
|
||||||
const unlockPdfFormsOperation = useUnlockPdfFormsOperation();
|
const unlockPdfFormsOperation = useUnlockPdfFormsOperation();
|
||||||
@@ -42,13 +43,12 @@ const UnlockPdfForms = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =
|
|||||||
const handleThumbnailClick = (file: File) => {
|
const handleThumbnailClick = (file: File) => {
|
||||||
onPreviewFile?.(file);
|
onPreviewFile?.(file);
|
||||||
sessionStorage.setItem("previousMode", "unlockPdfForms");
|
sessionStorage.setItem("previousMode", "unlockPdfForms");
|
||||||
setCurrentMode("viewer");
|
actions.setMode("viewer");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSettingsReset = () => {
|
const handleSettingsReset = () => {
|
||||||
unlockPdfFormsOperation.resetResults();
|
unlockPdfFormsOperation.resetResults();
|
||||||
onPreviewFile?.(null);
|
onPreviewFile?.(null);
|
||||||
setCurrentMode("unlockPdfForms");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasFiles = selectedFiles.length > 0;
|
const hasFiles = selectedFiles.length > 0;
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* Enhanced file types for IndexedDB storage
|
* File types for the new architecture
|
||||||
|
* FileContext uses pure File objects with separate ID tracking
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface FileWithUrl extends File {
|
|
||||||
id?: string;
|
/**
|
||||||
url?: string;
|
* File metadata for efficient operations without loading full file data
|
||||||
|
* Used by IndexedDBContext and FileContext for lazy file loading
|
||||||
|
*/
|
||||||
|
export interface FileMetadata {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
size: number;
|
||||||
|
lastModified: number;
|
||||||
thumbnail?: string;
|
thumbnail?: string;
|
||||||
storedInIndexedDB?: boolean;
|
isDraft?: boolean; // Marks files as draft versions
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StorageConfig {
|
export interface StorageConfig {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import { ProcessedFile } from './processing';
|
import { ProcessedFile } from './processing';
|
||||||
import { PDFDocument, PDFPage, PageOperation } from './pageEditor';
|
import { PDFDocument, PDFPage, PageOperation } from './pageEditor';
|
||||||
|
import { FileMetadata } from './file';
|
||||||
|
|
||||||
export type ModeType =
|
export type ModeType =
|
||||||
| 'viewer'
|
| 'viewer'
|
||||||
@@ -17,16 +18,116 @@ export type ModeType =
|
|||||||
| 'sanitize'
|
| 'sanitize'
|
||||||
| 'addPassword'
|
| 'addPassword'
|
||||||
| 'changePermissions'
|
| 'changePermissions'
|
||||||
| 'watermark'
|
| 'addWatermark'
|
||||||
| 'removePassword'
|
| 'removePassword'
|
||||||
| 'single-large-page'
|
| 'single-large-page'
|
||||||
| 'repair'
|
| 'repair'
|
||||||
| 'unlockPdfForms'
|
| 'unlockPdfForms'
|
||||||
| 'removeCertificateSign';
|
| 'removeCertificateSign';
|
||||||
|
|
||||||
export type ViewType = 'viewer' | 'pageEditor' | 'fileEditor';
|
// Normalized state types
|
||||||
|
export type FileId = string;
|
||||||
|
|
||||||
export type ToolType = 'merge' | 'split' | 'compress' | 'ocr' | 'convert' | 'sanitize';
|
export interface ProcessedFilePage {
|
||||||
|
thumbnail?: string;
|
||||||
|
pageNumber?: number;
|
||||||
|
rotation?: number;
|
||||||
|
splitBefore?: boolean;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProcessedFileMetadata {
|
||||||
|
pages: ProcessedFilePage[];
|
||||||
|
totalPages?: number;
|
||||||
|
thumbnailUrl?: string;
|
||||||
|
lastProcessed?: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileRecord {
|
||||||
|
id: FileId;
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
type: string;
|
||||||
|
lastModified: number;
|
||||||
|
quickKey?: string; // Fast deduplication key: name|size|lastModified
|
||||||
|
thumbnailUrl?: string;
|
||||||
|
blobUrl?: string;
|
||||||
|
createdAt?: number;
|
||||||
|
processedFile?: ProcessedFileMetadata;
|
||||||
|
isPinned?: boolean;
|
||||||
|
// Note: File object stored in provider ref, not in state
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileContextNormalizedFiles {
|
||||||
|
ids: FileId[];
|
||||||
|
byId: Record<FileId, FileRecord>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions - UUID-based primary keys (zero collisions, synchronous)
|
||||||
|
export function createFileId(): FileId {
|
||||||
|
// Use crypto.randomUUID for authoritative primary key
|
||||||
|
if (typeof window !== 'undefined' && window.crypto?.randomUUID) {
|
||||||
|
return window.crypto.randomUUID();
|
||||||
|
}
|
||||||
|
// Fallback for environments without randomUUID
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||||
|
const r = Math.random() * 16 | 0;
|
||||||
|
const v = c == 'x' ? r : (r & 0x3 | 0x8);
|
||||||
|
return v.toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate quick deduplication key from file metadata
|
||||||
|
export function createQuickKey(file: File): string {
|
||||||
|
// Format: name|size|lastModified for fast duplicate detection
|
||||||
|
return `${file.name}|${file.size}|${file.lastModified}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export function toFileRecord(file: File, id?: FileId): FileRecord {
|
||||||
|
const fileId = id || createFileId();
|
||||||
|
return {
|
||||||
|
id: fileId,
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
type: file.type,
|
||||||
|
lastModified: file.lastModified,
|
||||||
|
quickKey: createQuickKey(file),
|
||||||
|
createdAt: Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revokeFileResources(record: FileRecord): void {
|
||||||
|
// Only revoke blob: URLs to prevent errors on other schemes
|
||||||
|
if (record.thumbnailUrl && record.thumbnailUrl.startsWith('blob:')) {
|
||||||
|
try {
|
||||||
|
URL.revokeObjectURL(record.thumbnailUrl);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to revoke thumbnail URL:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (record.blobUrl && record.blobUrl.startsWith('blob:')) {
|
||||||
|
try {
|
||||||
|
URL.revokeObjectURL(record.blobUrl);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to revoke blob URL:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Clean up processed file thumbnails
|
||||||
|
if (record.processedFile?.pages) {
|
||||||
|
record.processedFile.pages.forEach(page => {
|
||||||
|
if (page.thumbnail && page.thumbnail.startsWith('blob:')) {
|
||||||
|
try {
|
||||||
|
URL.revokeObjectURL(page.thumbnail);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to revoke page thumbnail URL:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type OperationType = 'merge' | 'split' | 'compress' | 'add' | 'remove' | 'replace' | 'convert' | 'upload' | 'ocr' | 'sanitize';
|
export type OperationType = 'merge' | 'split' | 'compress' | 'add' | 'remove' | 'replace' | 'convert' | 'upload' | 'ocr' | 'sanitize';
|
||||||
|
|
||||||
@@ -69,114 +170,110 @@ export interface FileEditHistory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface FileContextState {
|
export interface FileContextState {
|
||||||
// Core file management
|
// Core file management - lightweight file IDs only
|
||||||
activeFiles: File[];
|
files: {
|
||||||
processedFiles: Map<File, ProcessedFile>;
|
ids: FileId[];
|
||||||
pinnedFiles: Set<File>; // Files that are pinned and won't be consumed
|
byId: Record<FileId, FileRecord>;
|
||||||
|
|
||||||
// Current navigation state
|
|
||||||
currentMode: ModeType;
|
|
||||||
currentView: ViewType;
|
|
||||||
currentTool: ToolType | null;
|
|
||||||
|
|
||||||
// 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
|
// Pinned files - files that won't be consumed by tools
|
||||||
|
pinnedFiles: Set<FileId>;
|
||||||
|
|
||||||
|
// UI state - file-related UI state only
|
||||||
|
ui: {
|
||||||
|
selectedFileIds: FileId[];
|
||||||
|
selectedPageNumbers: number[];
|
||||||
|
isProcessing: boolean;
|
||||||
|
processingProgress: number;
|
||||||
hasUnsavedChanges: boolean;
|
hasUnsavedChanges: boolean;
|
||||||
pendingNavigation: (() => void) | null;
|
};
|
||||||
showNavigationWarning: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Action types for reducer pattern
|
||||||
|
export type FileContextAction =
|
||||||
|
// File management actions
|
||||||
|
| { type: 'ADD_FILES'; payload: { fileRecords: FileRecord[] } }
|
||||||
|
| { type: 'REMOVE_FILES'; payload: { fileIds: FileId[] } }
|
||||||
|
| { type: 'UPDATE_FILE_RECORD'; payload: { id: FileId; updates: Partial<FileRecord> } }
|
||||||
|
| { type: 'REORDER_FILES'; payload: { orderedFileIds: FileId[] } }
|
||||||
|
|
||||||
|
// Pinned files actions
|
||||||
|
| { type: 'PIN_FILE'; payload: { fileId: FileId } }
|
||||||
|
| { type: 'UNPIN_FILE'; payload: { fileId: FileId } }
|
||||||
|
| { type: 'CONSUME_FILES'; payload: { inputFileIds: FileId[]; outputFileRecords: FileRecord[] } }
|
||||||
|
|
||||||
|
// UI actions
|
||||||
|
| { type: 'SET_SELECTED_FILES'; payload: { fileIds: FileId[] } }
|
||||||
|
| { type: 'SET_SELECTED_PAGES'; payload: { pageNumbers: number[] } }
|
||||||
|
| { type: 'CLEAR_SELECTIONS' }
|
||||||
|
| { type: 'SET_PROCESSING'; payload: { isProcessing: boolean; progress: number } }
|
||||||
|
|
||||||
|
// Navigation guard actions (minimal for file-related unsaved changes only)
|
||||||
|
| { type: 'SET_UNSAVED_CHANGES'; payload: { hasChanges: boolean } }
|
||||||
|
|
||||||
|
// Context management
|
||||||
|
| { type: 'RESET_CONTEXT' };
|
||||||
|
|
||||||
export interface FileContextActions {
|
export interface FileContextActions {
|
||||||
// File management
|
// File management - lightweight actions only
|
||||||
addFiles: (files: File[]) => Promise<File[]>;
|
addFiles: (files: File[]) => Promise<File[]>;
|
||||||
removeFiles: (fileIds: string[], deleteFromStorage?: boolean) => void;
|
addProcessedFiles: (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>) => Promise<File[]>;
|
||||||
replaceFile: (oldFileId: string, newFile: File) => Promise<void>;
|
addStoredFiles: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => Promise<File[]>;
|
||||||
clearAllFiles: () => void;
|
removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => Promise<void>;
|
||||||
|
updateFileRecord: (id: FileId, updates: Partial<FileRecord>) => void;
|
||||||
|
reorderFiles: (orderedFileIds: FileId[]) => void;
|
||||||
|
clearAllFiles: () => Promise<void>;
|
||||||
|
|
||||||
// File pinning
|
// File pinning
|
||||||
pinFile: (file: File) => void;
|
pinFile: (file: File) => void;
|
||||||
unpinFile: (file: File) => void;
|
unpinFile: (file: File) => void;
|
||||||
isFilePinned: (file: File) => boolean;
|
|
||||||
|
|
||||||
// File consumption (replace unpinned files with outputs)
|
// File consumption (replace unpinned files with outputs)
|
||||||
consumeFiles: (inputFiles: File[], outputFiles: File[]) => Promise<void>;
|
consumeFiles: (inputFileIds: FileId[], outputFiles: File[]) => Promise<void>;
|
||||||
|
|
||||||
// Navigation
|
|
||||||
setCurrentMode: (mode: ModeType) => void;
|
|
||||||
setCurrentView: (view: ViewType) => void;
|
|
||||||
setCurrentTool: (tool: ToolType) => void;
|
|
||||||
// Selection management
|
// Selection management
|
||||||
setSelectedFiles: (fileIds: string[]) => void;
|
setSelectedFiles: (fileIds: FileId[]) => void;
|
||||||
setSelectedPages: (pageNumbers: number[]) => void;
|
setSelectedPages: (pageNumbers: number[]) => void;
|
||||||
updateProcessedFile: (file: File, processedFile: ProcessedFile) => void;
|
|
||||||
clearSelections: () => void;
|
clearSelections: () => void;
|
||||||
|
|
||||||
// Edit operations
|
// Processing state - simple flags only
|
||||||
applyPageOperations: (fileId: string, operations: PageOperation[]) => void;
|
setProcessing: (isProcessing: boolean, progress?: number) => void;
|
||||||
applyFileOperation: (operation: FileOperation) => void;
|
|
||||||
undoLastOperation: (fileId?: string) => void;
|
|
||||||
|
|
||||||
// Operation history management
|
// File-related unsaved changes (minimal navigation guard support)
|
||||||
recordOperation: (fileId: string, operation: FileOperation | PageOperation) => void;
|
setHasUnsavedChanges: (hasChanges: boolean) => 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
|
// Context management
|
||||||
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;
|
resetContext: () => void;
|
||||||
|
|
||||||
// Navigation guard system
|
// Resource management
|
||||||
setHasUnsavedChanges: (hasChanges: boolean) => void;
|
|
||||||
requestNavigation: (navigationFn: () => void) => boolean;
|
|
||||||
confirmNavigation: () => void;
|
|
||||||
cancelNavigation: () => void;
|
|
||||||
|
|
||||||
// Memory management
|
|
||||||
trackBlobUrl: (url: string) => void;
|
trackBlobUrl: (url: string) => void;
|
||||||
trackPdfDocument: (fileId: string, pdfDoc: any) => void;
|
|
||||||
cleanupFile: (fileId: string) => Promise<void>;
|
|
||||||
scheduleCleanup: (fileId: string, delay?: number) => void;
|
scheduleCleanup: (fileId: string, delay?: number) => void;
|
||||||
|
cleanupFile: (fileId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileContextValue extends FileContextState, FileContextActions {}
|
// File selectors (separate from actions to avoid re-renders)
|
||||||
|
export interface FileContextSelectors {
|
||||||
|
// File access - no state dependency, uses ref
|
||||||
|
getFile: (id: FileId) => File | undefined;
|
||||||
|
getFiles: (ids?: FileId[]) => File[];
|
||||||
|
|
||||||
|
// Record access - uses normalized state
|
||||||
|
getFileRecord: (id: FileId) => FileRecord | undefined;
|
||||||
|
getFileRecords: (ids?: FileId[]) => FileRecord[];
|
||||||
|
|
||||||
|
// Derived selectors
|
||||||
|
getAllFileIds: () => FileId[];
|
||||||
|
getSelectedFiles: () => File[];
|
||||||
|
getSelectedFileRecords: () => FileRecord[];
|
||||||
|
|
||||||
|
// Pinned files selectors
|
||||||
|
getPinnedFileIds: () => FileId[];
|
||||||
|
getPinnedFiles: () => File[];
|
||||||
|
getPinnedFileRecords: () => FileRecord[];
|
||||||
|
isFilePinned: (file: File) => boolean;
|
||||||
|
|
||||||
|
// Stable signature for effect dependencies
|
||||||
|
getFilesSignature: () => string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FileContextProviderProps {
|
export interface FileContextProviderProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -185,16 +282,16 @@ export interface FileContextProviderProps {
|
|||||||
maxCacheSize?: number;
|
maxCacheSize?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper types for component props
|
// Split context values to minimize re-renders
|
||||||
export interface WithFileContext {
|
export interface FileContextStateValue {
|
||||||
fileContext: FileContextValue;
|
state: FileContextState;
|
||||||
|
selectors: FileContextSelectors;
|
||||||
}
|
}
|
||||||
|
|
||||||
// URL parameter types for deep linking
|
export interface FileContextActionsValue {
|
||||||
export interface FileContextUrlParams {
|
actions: FileContextActions;
|
||||||
mode?: ModeType;
|
dispatch: (action: FileContextAction) => void;
|
||||||
fileIds?: string[];
|
|
||||||
pageIds?: string[];
|
|
||||||
zoom?: number;
|
|
||||||
page?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: URL parameter types will be redesigned for new routing system
|
||||||
|
|
||||||
|
|||||||
@@ -54,24 +54,3 @@ export interface Tool {
|
|||||||
|
|
||||||
export type ToolRegistry = Record<string, Tool>;
|
export type ToolRegistry = Record<string, Tool>;
|
||||||
|
|
||||||
export interface FileSelectionState {
|
|
||||||
selectedFiles: File[];
|
|
||||||
maxFiles: MaxFiles;
|
|
||||||
isToolMode: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FileSelectionActions {
|
|
||||||
setSelectedFiles: (files: File[]) => void;
|
|
||||||
setMaxFiles: (maxFiles: MaxFiles) => void;
|
|
||||||
setIsToolMode: (isToolMode: boolean) => void;
|
|
||||||
clearSelection: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FileSelectionComputed {
|
|
||||||
canSelectMore: boolean;
|
|
||||||
isAtLimit: boolean;
|
|
||||||
selectionCount: number;
|
|
||||||
isMultiFileMode: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FileSelectionContextValue extends FileSelectionState, FileSelectionActions, FileSelectionComputed {}
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { FileWithUrl } from '../types/file';
|
import { FileMetadata } from '../types/file';
|
||||||
import { fileStorage } from '../services/fileStorage';
|
import { fileStorage } from '../services/fileStorage';
|
||||||
import { zipFileService } from '../services/zipFileService';
|
import { zipFileService } from '../services/zipFileService';
|
||||||
|
|
||||||
@@ -26,8 +26,8 @@ export function downloadBlob(blob: Blob, filename: string): void {
|
|||||||
* @param file - The file object with storage information
|
* @param file - The file object with storage information
|
||||||
* @throws Error if file cannot be retrieved from storage
|
* @throws Error if file cannot be retrieved from storage
|
||||||
*/
|
*/
|
||||||
export async function downloadFileFromStorage(file: FileWithUrl): Promise<void> {
|
export async function downloadFileFromStorage(file: FileMetadata): Promise<void> {
|
||||||
const lookupKey = file.id || file.name;
|
const lookupKey = file.id;
|
||||||
const storedFile = await fileStorage.getFile(lookupKey);
|
const storedFile = await fileStorage.getFile(lookupKey);
|
||||||
|
|
||||||
if (!storedFile) {
|
if (!storedFile) {
|
||||||
@@ -42,7 +42,7 @@ export async function downloadFileFromStorage(file: FileWithUrl): Promise<void>
|
|||||||
* Downloads multiple files as individual downloads
|
* Downloads multiple files as individual downloads
|
||||||
* @param files - Array of files to download
|
* @param files - Array of files to download
|
||||||
*/
|
*/
|
||||||
export async function downloadMultipleFiles(files: FileWithUrl[]): Promise<void> {
|
export async function downloadMultipleFiles(files: FileMetadata[]): Promise<void> {
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
await downloadFileFromStorage(file);
|
await downloadFileFromStorage(file);
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,7 @@ export async function downloadMultipleFiles(files: FileWithUrl[]): Promise<void>
|
|||||||
* @param files - Array of files to include in ZIP
|
* @param files - Array of files to include in ZIP
|
||||||
* @param zipFilename - Optional custom ZIP filename (defaults to timestamped name)
|
* @param zipFilename - Optional custom ZIP filename (defaults to timestamped name)
|
||||||
*/
|
*/
|
||||||
export async function downloadFilesAsZip(files: FileWithUrl[], zipFilename?: string): Promise<void> {
|
export async function downloadFilesAsZip(files: FileMetadata[], zipFilename?: string): Promise<void> {
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
throw new Error('No files provided for ZIP download');
|
throw new Error('No files provided for ZIP download');
|
||||||
}
|
}
|
||||||
@@ -61,7 +61,7 @@ export async function downloadFilesAsZip(files: FileWithUrl[], zipFilename?: str
|
|||||||
// Convert stored files to File objects
|
// Convert stored files to File objects
|
||||||
const fileObjects: File[] = [];
|
const fileObjects: File[] = [];
|
||||||
for (const fileWithUrl of files) {
|
for (const fileWithUrl of files) {
|
||||||
const lookupKey = fileWithUrl.id || fileWithUrl.name;
|
const lookupKey = fileWithUrl.id;
|
||||||
const storedFile = await fileStorage.getFile(lookupKey);
|
const storedFile = await fileStorage.getFile(lookupKey);
|
||||||
|
|
||||||
if (storedFile) {
|
if (storedFile) {
|
||||||
@@ -94,7 +94,7 @@ export async function downloadFilesAsZip(files: FileWithUrl[], zipFilename?: str
|
|||||||
* @param options - Download options
|
* @param options - Download options
|
||||||
*/
|
*/
|
||||||
export async function downloadFiles(
|
export async function downloadFiles(
|
||||||
files: FileWithUrl[],
|
files: FileMetadata[],
|
||||||
options: {
|
options: {
|
||||||
forceZip?: boolean;
|
forceZip?: boolean;
|
||||||
zipFilename?: string;
|
zipFilename?: string;
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
import { FileWithUrl } from "../types/file";
|
// Pure utility functions for file operations
|
||||||
import { StoredFile, fileStorage } from "../services/fileStorage";
|
|
||||||
|
|
||||||
export function getFileId(file: File): string | null {
|
|
||||||
return (file as File & { id?: string }).id || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Consolidated file size formatting utility
|
* Consolidated file size formatting utility
|
||||||
@@ -19,7 +14,7 @@ export function formatFileSize(bytes: number): string {
|
|||||||
/**
|
/**
|
||||||
* Get file date as string
|
* Get file date as string
|
||||||
*/
|
*/
|
||||||
export function getFileDate(file: File): string {
|
export function getFileDate(file: File | { lastModified: number }): string {
|
||||||
if (file.lastModified) {
|
if (file.lastModified) {
|
||||||
return new Date(file.lastModified).toLocaleString();
|
return new Date(file.lastModified).toLocaleString();
|
||||||
}
|
}
|
||||||
@@ -29,107 +24,12 @@ export function getFileDate(file: File): string {
|
|||||||
/**
|
/**
|
||||||
* Get file size as string (legacy method for backward compatibility)
|
* Get file size as string (legacy method for backward compatibility)
|
||||||
*/
|
*/
|
||||||
export function getFileSize(file: File): string {
|
export function getFileSize(file: File | { size: number }): string {
|
||||||
if (!file.size) return "Unknown";
|
if (!file.size) return "Unknown";
|
||||||
return formatFileSize(file.size);
|
return formatFileSize(file.size);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create enhanced file object from stored file metadata
|
|
||||||
* This eliminates the repeated pattern in FileManager
|
|
||||||
*/
|
|
||||||
export function createEnhancedFileFromStored(storedFile: StoredFile, thumbnail?: string): FileWithUrl {
|
|
||||||
const enhancedFile: FileWithUrl = {
|
|
||||||
id: storedFile.id,
|
|
||||||
storedInIndexedDB: true,
|
|
||||||
url: undefined, // Don't create blob URL immediately to save memory
|
|
||||||
thumbnail: thumbnail || storedFile.thumbnail,
|
|
||||||
// File metadata
|
|
||||||
name: storedFile.name,
|
|
||||||
size: storedFile.size,
|
|
||||||
type: storedFile.type,
|
|
||||||
lastModified: storedFile.lastModified,
|
|
||||||
webkitRelativePath: '',
|
|
||||||
// Lazy-loading File interface methods
|
|
||||||
arrayBuffer: async () => {
|
|
||||||
const data = await fileStorage.getFileData(storedFile.id);
|
|
||||||
if (!data) throw new Error(`File ${storedFile.name} not found in IndexedDB - may have been purged`);
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
bytes: async () => {
|
|
||||||
return new Uint8Array();
|
|
||||||
},
|
|
||||||
slice: (start?: number, end?: number, contentType?: string) => {
|
|
||||||
// Return a promise-based slice that loads from IndexedDB
|
|
||||||
return new Blob([], { type: contentType || storedFile.type });
|
|
||||||
},
|
|
||||||
stream: () => {
|
|
||||||
throw new Error('Stream not implemented for IndexedDB files');
|
|
||||||
},
|
|
||||||
text: async () => {
|
|
||||||
const data = await fileStorage.getFileData(storedFile.id);
|
|
||||||
if (!data) throw new Error(`File ${storedFile.name} not found in IndexedDB - may have been purged`);
|
|
||||||
return new TextDecoder().decode(data);
|
|
||||||
},
|
|
||||||
} as FileWithUrl;
|
|
||||||
|
|
||||||
return enhancedFile;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load files from IndexedDB and convert to enhanced file objects
|
|
||||||
*/
|
|
||||||
export async function loadFilesFromIndexedDB(): Promise<FileWithUrl[]> {
|
|
||||||
try {
|
|
||||||
await fileStorage.init();
|
|
||||||
const storedFiles = await fileStorage.getAllFileMetadata();
|
|
||||||
|
|
||||||
if (storedFiles.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const restoredFiles: FileWithUrl[] = storedFiles
|
|
||||||
.filter(storedFile => {
|
|
||||||
// Filter out corrupted entries
|
|
||||||
return storedFile &&
|
|
||||||
storedFile.name &&
|
|
||||||
typeof storedFile.size === 'number';
|
|
||||||
})
|
|
||||||
.map(storedFile => {
|
|
||||||
try {
|
|
||||||
return createEnhancedFileFromStored(storedFile as any);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to restore file:', storedFile?.name || 'unknown', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter((file): file is FileWithUrl => file !== null);
|
|
||||||
|
|
||||||
return restoredFiles;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load files from IndexedDB:', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean up blob URLs from file objects
|
|
||||||
*/
|
|
||||||
export function cleanupFileUrls(files: FileWithUrl[]): void {
|
|
||||||
files.forEach(file => {
|
|
||||||
if (file.url && !file.url.startsWith('indexeddb:')) {
|
|
||||||
URL.revokeObjectURL(file.url);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if file should use blob URL or IndexedDB direct access
|
|
||||||
*/
|
|
||||||
export function shouldUseDirectIndexedDBAccess(file: FileWithUrl): boolean {
|
|
||||||
const FILE_SIZE_LIMIT = 100 * 1024 * 1024; // 100MB
|
|
||||||
return file.size > FILE_SIZE_LIMIT;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detects and normalizes file extension from filename
|
* Detects and normalizes file extension from filename
|
||||||
@@ -151,29 +51,3 @@ export function detectFileExtension(filename: string): string {
|
|||||||
|
|
||||||
return extension;
|
return extension;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the filename without extension
|
|
||||||
* @param filename - The filename to process
|
|
||||||
* @returns Filename without extension
|
|
||||||
*/
|
|
||||||
export function getFilenameWithoutExtension(filename: string): string {
|
|
||||||
if (!filename || typeof filename !== 'string') return '';
|
|
||||||
|
|
||||||
const parts = filename.split('.');
|
|
||||||
if (parts.length <= 1) return filename;
|
|
||||||
|
|
||||||
// Return all parts except the last one (extension)
|
|
||||||
return parts.slice(0, -1).join('.');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new filename with a different extension
|
|
||||||
* @param filename - Original filename
|
|
||||||
* @param newExtension - New extension (without dot)
|
|
||||||
* @returns New filename with the specified extension
|
|
||||||
*/
|
|
||||||
export function changeFileExtension(filename: string, newExtension: string): string {
|
|
||||||
const nameWithoutExt = getFilenameWithoutExtension(filename);
|
|
||||||
return `${nameWithoutExt}.${newExtension}`;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { StorageStats } from "../services/fileStorage";
|
import { StorageStats } from "../services/fileStorage";
|
||||||
import { FileWithUrl } from "../types/file";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Storage operation types for incremental updates
|
* Storage operation types for incremental updates
|
||||||
@@ -12,7 +11,7 @@ export type StorageOperation = 'add' | 'remove' | 'clear';
|
|||||||
export function updateStorageStatsIncremental(
|
export function updateStorageStatsIncremental(
|
||||||
currentStats: StorageStats,
|
currentStats: StorageStats,
|
||||||
operation: StorageOperation,
|
operation: StorageOperation,
|
||||||
files: FileWithUrl[] = []
|
files: File[] = []
|
||||||
): StorageStats {
|
): StorageStats {
|
||||||
const filesSizeTotal = files.reduce((total, file) => total + file.size, 0);
|
const filesSizeTotal = files.reduce((total, file) => total + file.size, 0);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { getDocument } from "pdfjs-dist";
|
import { pdfWorkerManager } from '../services/pdfWorkerManager';
|
||||||
|
|
||||||
|
export interface ThumbnailWithMetadata {
|
||||||
|
thumbnail: string; // Always returns a thumbnail (placeholder if needed)
|
||||||
|
pageCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface ColorScheme {
|
interface ColorScheme {
|
||||||
bgTop: string;
|
bgTop: string;
|
||||||
@@ -11,19 +16,18 @@ interface ColorScheme {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate thumbnail scale based on file size
|
* Calculate thumbnail scale based on file size (modern 2024 scaling)
|
||||||
* Smaller files get higher quality, larger files get lower quality
|
|
||||||
*/
|
*/
|
||||||
export function calculateScaleFromFileSize(fileSize: number): number {
|
export function calculateScaleFromFileSize(fileSize: number): number {
|
||||||
const MB = 1024 * 1024;
|
const MB = 1024 * 1024;
|
||||||
|
if (fileSize < 10 * MB) return 1.0; // Full quality for small files
|
||||||
if (fileSize < 1 * MB) return 0.6; // < 1MB: High quality
|
if (fileSize < 50 * MB) return 0.8; // High quality for common file sizes
|
||||||
if (fileSize < 5 * MB) return 0.4; // 1-5MB: Medium-high quality
|
if (fileSize < 200 * MB) return 0.6; // Good quality for typical large files
|
||||||
if (fileSize < 15 * MB) return 0.3; // 5-15MB: Medium quality
|
if (fileSize < 500 * MB) return 0.4; // Readable quality for large but manageable files
|
||||||
if (fileSize < 30 * MB) return 0.2; // 15-30MB: Low-medium quality
|
return 0.3; // Still usable quality, not tiny
|
||||||
return 0.15; // 30MB+: Low quality
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate encrypted PDF thumbnail with lock icon
|
* Generate encrypted PDF thumbnail with lock icon
|
||||||
*/
|
*/
|
||||||
@@ -125,16 +129,40 @@ function getFileTypeColorScheme(extension: string): ColorScheme {
|
|||||||
'PDF': { bgTop: '#FF6B6B20', bgBottom: '#FF6B6B10', border: '#FF6B6B40', icon: '#FF6B6B', badge: '#FF6B6B', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
'PDF': { bgTop: '#FF6B6B20', bgBottom: '#FF6B6B10', border: '#FF6B6B40', icon: '#FF6B6B', badge: '#FF6B6B', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
||||||
'DOC': { bgTop: '#4ECDC420', bgBottom: '#4ECDC410', border: '#4ECDC440', icon: '#4ECDC4', badge: '#4ECDC4', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
'DOC': { bgTop: '#4ECDC420', bgBottom: '#4ECDC410', border: '#4ECDC440', icon: '#4ECDC4', badge: '#4ECDC4', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
||||||
'DOCX': { bgTop: '#4ECDC420', bgBottom: '#4ECDC410', border: '#4ECDC440', icon: '#4ECDC4', badge: '#4ECDC4', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
'DOCX': { bgTop: '#4ECDC420', bgBottom: '#4ECDC410', border: '#4ECDC440', icon: '#4ECDC4', badge: '#4ECDC4', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
||||||
|
'ODT': { bgTop: '#4ECDC420', bgBottom: '#4ECDC410', border: '#4ECDC440', icon: '#4ECDC4', badge: '#4ECDC4', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
||||||
'TXT': { bgTop: '#95A5A620', bgBottom: '#95A5A610', border: '#95A5A640', icon: '#95A5A6', badge: '#95A5A6', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
'TXT': { bgTop: '#95A5A620', bgBottom: '#95A5A610', border: '#95A5A640', icon: '#95A5A6', badge: '#95A5A6', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
||||||
|
'RTF': { bgTop: '#95A5A620', bgBottom: '#95A5A610', border: '#95A5A640', icon: '#95A5A6', badge: '#95A5A6', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
||||||
|
|
||||||
// Spreadsheets
|
// Spreadsheets
|
||||||
'XLS': { bgTop: '#2ECC7120', bgBottom: '#2ECC7110', border: '#2ECC7140', icon: '#2ECC71', badge: '#2ECC71', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
'XLS': { bgTop: '#2ECC7120', bgBottom: '#2ECC7110', border: '#2ECC7140', icon: '#2ECC71', badge: '#2ECC71', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
||||||
'XLSX': { bgTop: '#2ECC7120', bgBottom: '#2ECC7110', border: '#2ECC7140', icon: '#2ECC71', badge: '#2ECC71', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
'XLSX': { bgTop: '#2ECC7120', bgBottom: '#2ECC7110', border: '#2ECC7140', icon: '#2ECC71', badge: '#2ECC71', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
||||||
|
'ODS': { bgTop: '#2ECC7120', bgBottom: '#2ECC7110', border: '#2ECC7140', icon: '#2ECC71', badge: '#2ECC71', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
||||||
'CSV': { bgTop: '#2ECC7120', bgBottom: '#2ECC7110', border: '#2ECC7140', icon: '#2ECC71', badge: '#2ECC71', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
'CSV': { bgTop: '#2ECC7120', bgBottom: '#2ECC7110', border: '#2ECC7140', icon: '#2ECC71', badge: '#2ECC71', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
||||||
|
|
||||||
// Presentations
|
// Presentations
|
||||||
'PPT': { bgTop: '#E67E2220', bgBottom: '#E67E2210', border: '#E67E2240', icon: '#E67E22', badge: '#E67E22', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
'PPT': { bgTop: '#E67E2220', bgBottom: '#E67E2210', border: '#E67E2240', icon: '#E67E22', badge: '#E67E22', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
||||||
'PPTX': { bgTop: '#E67E2220', bgBottom: '#E67E2210', border: '#E67E2240', icon: '#E67E22', badge: '#E67E22', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
'PPTX': { bgTop: '#E67E2220', bgBottom: '#E67E2210', border: '#E67E2240', icon: '#E67E22', badge: '#E67E22', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
||||||
|
'ODP': { bgTop: '#E67E2220', bgBottom: '#E67E2210', border: '#E67E2240', icon: '#E67E22', badge: '#E67E22', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
||||||
|
|
||||||
|
// Images
|
||||||
|
'JPG': { bgTop: '#FF9F4320', bgBottom: '#FF9F4310', border: '#FF9F4340', icon: '#FF9F43', badge: '#FF9F43', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
||||||
|
'JPEG': { bgTop: '#FF9F4320', bgBottom: '#FF9F4310', border: '#FF9F4340', icon: '#FF9F43', badge: '#FF9F43', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
||||||
|
'PNG': { bgTop: '#FF9F4320', bgBottom: '#FF9F4310', border: '#FF9F4340', icon: '#FF9F43', badge: '#FF9F43', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
||||||
|
'GIF': { bgTop: '#FF9F4320', bgBottom: '#FF9F4310', border: '#FF9F4340', icon: '#FF9F43', badge: '#FF9F43', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
||||||
|
'BMP': { bgTop: '#FF9F4320', bgBottom: '#FF9F4310', border: '#FF9F4340', icon: '#FF9F43', badge: '#FF9F43', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
||||||
|
'TIFF': { bgTop: '#FF9F4320', bgBottom: '#FF9F4310', border: '#FF9F4340', icon: '#FF9F43', badge: '#FF9F43', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
||||||
|
'WEBP': { bgTop: '#FF9F4320', bgBottom: '#FF9F4310', border: '#FF9F4340', icon: '#FF9F43', badge: '#FF9F43', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
||||||
|
'SVG': { bgTop: '#FF9F4320', bgBottom: '#FF9F4310', border: '#FF9F4340', icon: '#FF9F43', badge: '#FF9F43', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
||||||
|
|
||||||
|
// Web
|
||||||
|
'HTML': { bgTop: '#FD79A820', bgBottom: '#FD79A810', border: '#FD79A840', icon: '#FD79A8', badge: '#FD79A8', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
||||||
|
'XML': { bgTop: '#FD79A820', bgBottom: '#FD79A810', border: '#FD79A840', icon: '#FD79A8', badge: '#FD79A8', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
||||||
|
|
||||||
|
// Text/Markup
|
||||||
|
'MD': { bgTop: '#6C5CE720', bgBottom: '#6C5CE710', border: '#6C5CE740', icon: '#6C5CE7', badge: '#6C5CE7', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
||||||
|
|
||||||
|
// Email
|
||||||
|
'EML': { bgTop: '#A29BFE20', bgBottom: '#A29BFE10', border: '#A29BFE40', icon: '#A29BFE', badge: '#A29BFE', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
||||||
|
|
||||||
// Archives
|
// Archives
|
||||||
'ZIP': { bgTop: '#9B59B620', bgBottom: '#9B59B610', border: '#9B59B640', icon: '#9B59B6', badge: '#9B59B6', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
'ZIP': { bgTop: '#9B59B620', bgBottom: '#9B59B610', border: '#9B59B640', icon: '#9B59B6', badge: '#9B59B6', textPrimary: '#FFFFFF', textSecondary: '#666666' },
|
||||||
@@ -275,16 +303,15 @@ function formatFileSize(bytes: number): string {
|
|||||||
|
|
||||||
async function generatePDFThumbnail(arrayBuffer: ArrayBuffer, file: File, scale: number): Promise<string> {
|
async function generatePDFThumbnail(arrayBuffer: ArrayBuffer, file: File, scale: number): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const pdf = await getDocument({
|
const pdf = await pdfWorkerManager.createDocument(arrayBuffer, {
|
||||||
data: arrayBuffer,
|
|
||||||
disableAutoFetch: true,
|
disableAutoFetch: true,
|
||||||
disableStream: true
|
disableStream: true
|
||||||
}).promise;
|
});
|
||||||
|
|
||||||
const thumbnail = await generateStandardPDFThumbnail(pdf, scale);
|
const thumbnail = await generateStandardPDFThumbnail(pdf, scale);
|
||||||
|
|
||||||
// Immediately clean up memory after thumbnail generation
|
// Immediately clean up memory after thumbnail generation using worker manager
|
||||||
pdf.destroy();
|
pdfWorkerManager.destroyDocument(pdf);
|
||||||
return thumbnail;
|
return thumbnail;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
@@ -298,31 +325,27 @@ async function generatePDFThumbnail(arrayBuffer: ArrayBuffer, file: File, scale:
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate thumbnail for any file type
|
* Generate thumbnail for any file type - always returns a thumbnail (placeholder if needed)
|
||||||
* Returns base64 data URL or undefined if generation fails
|
|
||||||
*/
|
*/
|
||||||
export async function generateThumbnailForFile(file: File): Promise<string | undefined> {
|
export async function generateThumbnailForFile(file: File): Promise<string> {
|
||||||
// Skip thumbnail generation for very large files to avoid memory issues
|
// Skip very large files
|
||||||
if (file.size >= 100 * 1024 * 1024) { // 100MB limit
|
if (file.size >= 100 * 1024 * 1024) {
|
||||||
console.log('Skipping thumbnail generation for large file:', file.name);
|
|
||||||
return generatePlaceholderThumbnail(file);
|
return generatePlaceholderThumbnail(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle image files - use original file directly
|
// Handle image files - convert to data URL for persistence
|
||||||
if (file.type.startsWith('image/')) {
|
if (file.type.startsWith('image/')) {
|
||||||
return URL.createObjectURL(file);
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(reader.result as string);
|
||||||
|
reader.onerror = () => reject(reader.error);
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle PDF files
|
// Handle PDF files
|
||||||
if (!file.type.startsWith('application/pdf')) {
|
if (file.type.startsWith('application/pdf')) {
|
||||||
console.log('File is not a PDF or image, generating placeholder:', file.name);
|
|
||||||
return generatePlaceholderThumbnail(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate quality scale based on file size
|
|
||||||
console.log('Generating thumbnail for', file.name);
|
|
||||||
const scale = calculateScaleFromFileSize(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
|
||||||
@@ -332,18 +355,75 @@ export async function generateThumbnailForFile(file: File): Promise<string | und
|
|||||||
try {
|
try {
|
||||||
return await generatePDFThumbnail(arrayBuffer, file, scale);
|
return await generatePDFThumbnail(arrayBuffer, file, scale);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error && error.name === 'InvalidPDFException') {
|
||||||
if (error.name === 'InvalidPDFException') {
|
console.warn(`PDF structure issue for ${file.name} - trying with full file`);
|
||||||
console.warn(`PDF structure issue for ${file.name} - using fallback thumbnail`);
|
try {
|
||||||
// Return a placeholder or try with full file instead of chunk
|
// Try with full file instead of chunk
|
||||||
const fullArrayBuffer = await file.arrayBuffer();
|
const fullArrayBuffer = await file.arrayBuffer();
|
||||||
return await generatePDFThumbnail(fullArrayBuffer, file, scale);
|
return await generatePDFThumbnail(fullArrayBuffer, file, scale);
|
||||||
} else {
|
} catch (fullFileError) {
|
||||||
console.warn('Unknown error thrown. Failed to generate thumbnail for', file.name, error);
|
console.warn(`Full file PDF processing also failed for ${file.name} - using placeholder`);
|
||||||
return undefined;
|
return generatePlaceholderThumbnail(file);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
throw error; // Re-throw non-Error exceptions
|
|
||||||
}
|
}
|
||||||
|
console.warn(`PDF processing failed for ${file.name} - using placeholder:`, error);
|
||||||
|
return generatePlaceholderThumbnail(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All other files get placeholder
|
||||||
|
return generatePlaceholderThumbnail(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate thumbnail and extract page count for a PDF file - always returns a valid thumbnail
|
||||||
|
*/
|
||||||
|
export async function generateThumbnailWithMetadata(file: File): Promise<ThumbnailWithMetadata> {
|
||||||
|
// Non-PDF files have no page count
|
||||||
|
if (!file.type.startsWith('application/pdf')) {
|
||||||
|
const thumbnail = await generateThumbnailForFile(file);
|
||||||
|
return { thumbnail, pageCount: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip very large files
|
||||||
|
if (file.size >= 100 * 1024 * 1024) {
|
||||||
|
const thumbnail = generatePlaceholderThumbnail(file);
|
||||||
|
return { thumbnail, pageCount: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const scale = calculateScaleFromFileSize(file.size);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
|
||||||
|
|
||||||
|
const pageCount = pdf.numPages;
|
||||||
|
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) {
|
||||||
|
pdfWorkerManager.destroyDocument(pdf);
|
||||||
|
throw new Error('Could not get canvas context');
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.render({ canvasContext: context, viewport }).promise;
|
||||||
|
const thumbnail = canvas.toDataURL();
|
||||||
|
|
||||||
|
pdfWorkerManager.destroyDocument(pdf);
|
||||||
|
return { thumbnail, pageCount };
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.name === "PasswordException") {
|
||||||
|
// Handle encrypted PDFs
|
||||||
|
const thumbnail = generateEncryptedPDFThumbnail(file);
|
||||||
|
return { thumbnail, pageCount: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const thumbnail = generatePlaceholderThumbnail(file);
|
||||||
|
return { thumbnail, pageCount: 1 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* URL routing utilities for tool navigation
|
||||||
|
* Provides clean URL routing for the V2 tool system
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ModeType } from '../contexts/NavigationContext';
|
||||||
|
|
||||||
|
export interface ToolRoute {
|
||||||
|
mode: ModeType;
|
||||||
|
toolKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the current URL to extract tool routing information
|
||||||
|
*/
|
||||||
|
export function parseToolRoute(): ToolRoute {
|
||||||
|
const path = window.location.pathname;
|
||||||
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
|
|
||||||
|
// Extract tool from URL path (e.g., /split-pdf -> split)
|
||||||
|
const toolMatch = path.match(/\/([a-zA-Z-]+)(?:-pdf)?$/);
|
||||||
|
if (toolMatch) {
|
||||||
|
const toolKey = toolMatch[1].toLowerCase();
|
||||||
|
|
||||||
|
// Map URL paths to tool keys and modes (excluding internal UI modes)
|
||||||
|
const toolMappings: Record<string, { mode: ModeType; toolKey: string }> = {
|
||||||
|
'split': { mode: 'split', toolKey: 'split' },
|
||||||
|
'merge': { mode: 'merge', toolKey: 'merge' },
|
||||||
|
'compress': { mode: 'compress', toolKey: 'compress' },
|
||||||
|
'convert': { mode: 'convert', toolKey: 'convert' },
|
||||||
|
'add-password': { mode: 'addPassword', toolKey: 'addPassword' },
|
||||||
|
'change-permissions': { mode: 'changePermissions', toolKey: 'changePermissions' },
|
||||||
|
'sanitize': { mode: 'sanitize', toolKey: 'sanitize' },
|
||||||
|
'ocr': { mode: 'ocr', toolKey: 'ocr' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapping = toolMappings[toolKey];
|
||||||
|
if (mapping) {
|
||||||
|
return {
|
||||||
|
mode: mapping.mode,
|
||||||
|
toolKey: mapping.toolKey
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for query parameter fallback (e.g., ?tool=split)
|
||||||
|
const toolParam = searchParams.get('tool');
|
||||||
|
if (toolParam && isValidMode(toolParam)) {
|
||||||
|
return {
|
||||||
|
mode: toolParam as ModeType,
|
||||||
|
toolKey: toolParam
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to page editor for home page
|
||||||
|
return {
|
||||||
|
mode: 'pageEditor'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the URL to reflect the current tool selection
|
||||||
|
* Internal UI modes (viewer, fileEditor, pageEditor) don't get URLs
|
||||||
|
*/
|
||||||
|
export function updateToolRoute(mode: ModeType, toolKey?: string): void {
|
||||||
|
const currentPath = window.location.pathname;
|
||||||
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
|
|
||||||
|
// Don't create URLs for internal UI modes
|
||||||
|
if (mode === 'viewer' || mode === 'fileEditor' || mode === 'pageEditor') {
|
||||||
|
// If we're switching to an internal mode, clear any existing tool URL
|
||||||
|
if (currentPath !== '/') {
|
||||||
|
clearToolRoute();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let newPath = '/';
|
||||||
|
|
||||||
|
// Map modes to URL paths (only for actual tools)
|
||||||
|
if (toolKey) {
|
||||||
|
const pathMappings: Record<string, string> = {
|
||||||
|
'split': '/split-pdf',
|
||||||
|
'merge': '/merge-pdf',
|
||||||
|
'compress': '/compress-pdf',
|
||||||
|
'convert': '/convert-pdf',
|
||||||
|
'addPassword': '/add-password-pdf',
|
||||||
|
'changePermissions': '/change-permissions-pdf',
|
||||||
|
'sanitize': '/sanitize-pdf',
|
||||||
|
'ocr': '/ocr-pdf'
|
||||||
|
};
|
||||||
|
|
||||||
|
newPath = pathMappings[toolKey] || `/${toolKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove tool query parameter since we're using path-based routing
|
||||||
|
searchParams.delete('tool');
|
||||||
|
|
||||||
|
// Construct final URL
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
const fullUrl = newPath + (queryString ? `?${queryString}` : '');
|
||||||
|
|
||||||
|
// Update URL without triggering page reload
|
||||||
|
if (currentPath !== newPath || window.location.search !== (queryString ? `?${queryString}` : '')) {
|
||||||
|
window.history.replaceState(null, '', fullUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear tool routing and return to home page
|
||||||
|
*/
|
||||||
|
export function clearToolRoute(): void {
|
||||||
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
|
searchParams.delete('tool');
|
||||||
|
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
const url = '/' + (queryString ? `?${queryString}` : '');
|
||||||
|
|
||||||
|
window.history.replaceState(null, '', url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get clean tool name for display purposes
|
||||||
|
*/
|
||||||
|
export function getToolDisplayName(toolKey: string): string {
|
||||||
|
const displayNames: Record<string, string> = {
|
||||||
|
'split': 'Split PDF',
|
||||||
|
'merge': 'Merge PDF',
|
||||||
|
'compress': 'Compress PDF',
|
||||||
|
'convert': 'Convert PDF',
|
||||||
|
'addPassword': 'Add Password',
|
||||||
|
'changePermissions': 'Change Permissions',
|
||||||
|
'sanitize': 'Sanitize PDF',
|
||||||
|
'ocr': 'OCR PDF'
|
||||||
|
};
|
||||||
|
|
||||||
|
return displayNames[toolKey] || toolKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a mode is valid
|
||||||
|
*/
|
||||||
|
function isValidMode(mode: string): mode is ModeType {
|
||||||
|
const validModes: ModeType[] = [
|
||||||
|
'viewer', 'pageEditor', 'fileEditor', 'merge', 'split',
|
||||||
|
'compress', 'ocr', 'convert', 'addPassword', 'changePermissions', 'sanitize'
|
||||||
|
];
|
||||||
|
return validModes.includes(mode as ModeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate shareable URL for current tool state
|
||||||
|
* Only generates URLs for actual tools, not internal UI modes
|
||||||
|
*/
|
||||||
|
export function generateShareableUrl(mode: ModeType, toolKey?: string): string {
|
||||||
|
const baseUrl = window.location.origin;
|
||||||
|
|
||||||
|
// Don't generate URLs for internal UI modes
|
||||||
|
if (mode === 'viewer' || mode === 'fileEditor' || mode === 'pageEditor') {
|
||||||
|
return baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolKey) {
|
||||||
|
const pathMappings: Record<string, string> = {
|
||||||
|
'split': '/split-pdf',
|
||||||
|
'merge': '/merge-pdf',
|
||||||
|
'compress': '/compress-pdf',
|
||||||
|
'convert': '/convert-pdf',
|
||||||
|
'addPassword': '/add-password-pdf',
|
||||||
|
'changePermissions': '/change-permissions-pdf',
|
||||||
|
'sanitize': '/sanitize-pdf',
|
||||||
|
'ocr': '/ocr-pdf'
|
||||||
|
};
|
||||||
|
|
||||||
|
const path = pathMappings[toolKey] || `/${toolKey}`;
|
||||||
|
return `${baseUrl}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseUrl;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user