From 4aac124e768ffd66995ae8d4ddb27fb90dc0ca31 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Tue, 26 Aug 2025 13:46:15 +0100 Subject: [PATCH 01/11] npm login (#4296) # Description of Changes --- ## 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: a --- .github/workflows/PR-Auto-Deploy-V2.yml | 2 ++ .github/workflows/build.yml | 6 +++++ .github/workflows/deploy-on-v2-commit.yml | 2 ++ .../workflows/frontend-licenses-update.yml | 8 ++++++ .github/workflows/testdriver.yml | 8 ++++++ docker/frontend/Dockerfile | 26 ++++++++++++++++--- frontend/README.md | 2 +- 7 files changed, 49 insertions(+), 5 deletions(-) diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index 2dbcd3260..dc35f4913 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -270,6 +270,8 @@ jobs: tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} build-args: VERSION_TAG=v2-alpha platforms: linux/amd64 + secrets: | + npmrc=//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }} - name: Build and push V2 backend image if: steps.check-backend.outputs.exists == 'false' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0e38b82fb..509bf37e4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -145,12 +145,18 @@ jobs: node-version: '20' cache: 'npm' cache-dependency-path: frontend/package-lock.json + - name: Configure npm with token (if available) + if: secrets.NPM_TOKEN != '' + run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc - name: Install frontend dependencies run: cd frontend && npm ci - name: Build frontend run: cd frontend && npm run build - name: Run frontend tests run: cd frontend && npm run test -- --run + - name: Clean up npmrc + if: always() && secrets.NPM_TOKEN != '' + run: rm -f ~/.npmrc - name: Upload frontend build artifacts uses: actions/upload-artifact@v4.6.2 with: diff --git a/.github/workflows/deploy-on-v2-commit.yml b/.github/workflows/deploy-on-v2-commit.yml index f2f90ccfa..78af03d7c 100644 --- a/.github/workflows/deploy-on-v2-commit.yml +++ b/.github/workflows/deploy-on-v2-commit.yml @@ -103,6 +103,8 @@ jobs: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-latest build-args: VERSION_TAG=v2-alpha platforms: linux/amd64 + secrets: | + npmrc=//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }} - name: Build and push backend image if: steps.check-backend.outputs.exists == 'false' diff --git a/.github/workflows/frontend-licenses-update.yml b/.github/workflows/frontend-licenses-update.yml index ac8676c8a..ac2013e2d 100644 --- a/.github/workflows/frontend-licenses-update.yml +++ b/.github/workflows/frontend-licenses-update.yml @@ -51,6 +51,10 @@ jobs: cache: 'npm' cache-dependency-path: frontend/package-lock.json + - name: Configure npm with token (if available) + if: secrets.NPM_TOKEN != '' + run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc + - name: Install frontend dependencies working-directory: frontend run: npm ci @@ -58,6 +62,10 @@ jobs: - name: Generate frontend license report working-directory: frontend run: npm run generate-licenses + + - name: Clean up npmrc + if: always() && secrets.NPM_TOKEN != '' + run: rm -f ~/.npmrc - name: Check for license warnings run: | diff --git a/.github/workflows/testdriver.yml b/.github/workflows/testdriver.yml index 209ce7435..67d248d46 100644 --- a/.github/workflows/testdriver.yml +++ b/.github/workflows/testdriver.yml @@ -132,6 +132,10 @@ jobs: uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: cache: 'npm' + + - name: Configure npm with token (if available) + if: secrets.NPM_TOKEN != '' + run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc - name: Run TestDriver.ai uses: testdriverai/action@f0d0f45fdd684db628baa843fe9313f3ca3a8aa8 #1.1.3 @@ -148,6 +152,10 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} FORCE_COLOR: "3" + + - name: Clean up npmrc + if: always() && secrets.NPM_TOKEN != '' + run: rm -f ~/.npmrc cleanup: needs: [deploy, test] diff --git a/docker/frontend/Dockerfile b/docker/frontend/Dockerfile index a220782b0..35971f975 100644 --- a/docker/frontend/Dockerfile +++ b/docker/frontend/Dockerfile @@ -6,14 +6,32 @@ WORKDIR /app # Copy package files COPY frontend/package*.json ./ -# Install dependencies -RUN npm ci +# Install dependencies (uses .npmrc secret if available, otherwise anonymous) +RUN --mount=type=secret,id=npmrc \ + set -e && \ + if [ -s /run/secrets/npmrc ]; then \ + echo "Using authenticated npm registry" && \ + echo "$(cat /run/secrets/npmrc)" > /root/.npmrc; \ + else \ + echo "Using anonymous npm registry (no token provided)"; \ + fi && \ + npm ci --loglevel=warn && \ + rm -f /root/.npmrc # Copy source code COPY frontend . -# Build the application -RUN npm run build +# Build the application (uses .npmrc secret if available, otherwise anonymous) +RUN --mount=type=secret,id=npmrc \ + set -e && \ + if [ -s /run/secrets/npmrc ]; then \ + echo "Using authenticated npm registry for build" && \ + echo "$(cat /run/secrets/npmrc)" > /root/.npmrc; \ + else \ + echo "Using anonymous npm registry for build (no token provided)"; \ + fi && \ + npm run build && \ + rm -f /root/.npmrc # Production stage FROM nginx:alpine diff --git a/frontend/README.md b/frontend/README.md index 115fcca84..f6b3e8168 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -71,4 +71,4 @@ This section has moved here: [https://facebook.github.io/create-react-app/docs/d ### `npm run build` fails to minify -This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify) +This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify) \ No newline at end of file From 9b8091a630942d8c7290b17899897f7ef5a08459 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Tue, 26 Aug 2025 14:06:29 +0100 Subject: [PATCH 02/11] Revert "npm login" (#4299) Reverts Stirling-Tools/Stirling-PDF#4296 --- .github/workflows/PR-Auto-Deploy-V2.yml | 2 -- .github/workflows/build.yml | 6 ----- .github/workflows/deploy-on-v2-commit.yml | 2 -- .../workflows/frontend-licenses-update.yml | 8 ------ .github/workflows/testdriver.yml | 8 ------ docker/frontend/Dockerfile | 26 +++---------------- frontend/README.md | 2 +- 7 files changed, 5 insertions(+), 49 deletions(-) diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index dc35f4913..2dbcd3260 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -270,8 +270,6 @@ jobs: tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }} build-args: VERSION_TAG=v2-alpha platforms: linux/amd64 - secrets: | - npmrc=//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }} - name: Build and push V2 backend image if: steps.check-backend.outputs.exists == 'false' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 509bf37e4..0e38b82fb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -145,18 +145,12 @@ jobs: node-version: '20' cache: 'npm' cache-dependency-path: frontend/package-lock.json - - name: Configure npm with token (if available) - if: secrets.NPM_TOKEN != '' - run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc - name: Install frontend dependencies run: cd frontend && npm ci - name: Build frontend run: cd frontend && npm run build - name: Run frontend tests run: cd frontend && npm run test -- --run - - name: Clean up npmrc - if: always() && secrets.NPM_TOKEN != '' - run: rm -f ~/.npmrc - name: Upload frontend build artifacts uses: actions/upload-artifact@v4.6.2 with: diff --git a/.github/workflows/deploy-on-v2-commit.yml b/.github/workflows/deploy-on-v2-commit.yml index 78af03d7c..f2f90ccfa 100644 --- a/.github/workflows/deploy-on-v2-commit.yml +++ b/.github/workflows/deploy-on-v2-commit.yml @@ -103,8 +103,6 @@ jobs: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-latest build-args: VERSION_TAG=v2-alpha platforms: linux/amd64 - secrets: | - npmrc=//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }} - name: Build and push backend image if: steps.check-backend.outputs.exists == 'false' diff --git a/.github/workflows/frontend-licenses-update.yml b/.github/workflows/frontend-licenses-update.yml index ac2013e2d..ac8676c8a 100644 --- a/.github/workflows/frontend-licenses-update.yml +++ b/.github/workflows/frontend-licenses-update.yml @@ -51,10 +51,6 @@ jobs: cache: 'npm' cache-dependency-path: frontend/package-lock.json - - name: Configure npm with token (if available) - if: secrets.NPM_TOKEN != '' - run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc - - name: Install frontend dependencies working-directory: frontend run: npm ci @@ -62,10 +58,6 @@ jobs: - name: Generate frontend license report working-directory: frontend run: npm run generate-licenses - - - name: Clean up npmrc - if: always() && secrets.NPM_TOKEN != '' - run: rm -f ~/.npmrc - name: Check for license warnings run: | diff --git a/.github/workflows/testdriver.yml b/.github/workflows/testdriver.yml index 67d248d46..209ce7435 100644 --- a/.github/workflows/testdriver.yml +++ b/.github/workflows/testdriver.yml @@ -132,10 +132,6 @@ jobs: uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: cache: 'npm' - - - name: Configure npm with token (if available) - if: secrets.NPM_TOKEN != '' - run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc - name: Run TestDriver.ai uses: testdriverai/action@f0d0f45fdd684db628baa843fe9313f3ca3a8aa8 #1.1.3 @@ -152,10 +148,6 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} FORCE_COLOR: "3" - - - name: Clean up npmrc - if: always() && secrets.NPM_TOKEN != '' - run: rm -f ~/.npmrc cleanup: needs: [deploy, test] diff --git a/docker/frontend/Dockerfile b/docker/frontend/Dockerfile index 35971f975..a220782b0 100644 --- a/docker/frontend/Dockerfile +++ b/docker/frontend/Dockerfile @@ -6,32 +6,14 @@ WORKDIR /app # Copy package files COPY frontend/package*.json ./ -# Install dependencies (uses .npmrc secret if available, otherwise anonymous) -RUN --mount=type=secret,id=npmrc \ - set -e && \ - if [ -s /run/secrets/npmrc ]; then \ - echo "Using authenticated npm registry" && \ - echo "$(cat /run/secrets/npmrc)" > /root/.npmrc; \ - else \ - echo "Using anonymous npm registry (no token provided)"; \ - fi && \ - npm ci --loglevel=warn && \ - rm -f /root/.npmrc +# Install dependencies +RUN npm ci # Copy source code COPY frontend . -# Build the application (uses .npmrc secret if available, otherwise anonymous) -RUN --mount=type=secret,id=npmrc \ - set -e && \ - if [ -s /run/secrets/npmrc ]; then \ - echo "Using authenticated npm registry for build" && \ - echo "$(cat /run/secrets/npmrc)" > /root/.npmrc; \ - else \ - echo "Using anonymous npm registry for build (no token provided)"; \ - fi && \ - npm run build && \ - rm -f /root/.npmrc +# Build the application +RUN npm run build # Production stage FROM nginx:alpine diff --git a/frontend/README.md b/frontend/README.md index f6b3e8168..115fcca84 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -71,4 +71,4 @@ This section has moved here: [https://facebook.github.io/create-react-app/docs/d ### `npm run build` fails to minify -This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify) \ No newline at end of file +This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify) From ca423f9646866266db2f4792b5e0f57059349e39 Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Tue, 26 Aug 2025 15:30:58 +0100 Subject: [PATCH 03/11] Feature/v2/pageeditor improved (#4289) # Description of Changes --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- frontend/scripts/generate-icons.js | 117 +- frontend/src/commands/pageCommands.ts | 335 --- .../fileEditor/FileEditor.module.css | 276 +++ .../src/components/fileEditor/FileEditor.tsx | 6 +- .../fileEditor/FileEditorThumbnail.tsx | 407 ++++ frontend/src/components/layout/Workbench.tsx | 6 +- .../components/pageEditor/DragDropGrid.tsx | 31 +- .../components/pageEditor/FileThumbnail.tsx | 75 +- .../pageEditor/PageEditor.module.css | 308 +-- .../src/components/pageEditor/PageEditor.tsx | 1950 ++++++----------- .../pageEditor/PageEditorControls.tsx | 122 +- .../components/pageEditor/PageThumbnail.tsx | 375 ++-- .../pageEditor/commands/pageCommands.ts | 892 ++++++++ .../src/components/pageEditor/constants.ts | 8 + .../pageEditor/hooks/usePageDocument.ts | 176 ++ .../pageEditor/hooks/usePageEditorState.ts | 96 + frontend/src/components/shared/RightRail.tsx | 80 +- .../tools/shared/FileStatusIndicator.tsx | 4 +- frontend/src/contexts/FileContext.tsx | 15 +- frontend/src/contexts/FilesModalContext.tsx | 41 +- frontend/src/contexts/file/fileActions.ts | 18 + frontend/src/hooks/usePDFProcessor.ts | 1 + .../services/documentManipulationService.ts | 176 ++ frontend/src/services/pdfExportService.ts | 219 +- frontend/src/services/pdfWorkerManager.ts | 22 +- .../services/thumbnailGenerationService.ts | 17 +- frontend/src/types/fileContext.ts | 4 +- frontend/src/types/pageEditor.ts | 13 +- 28 files changed, 3463 insertions(+), 2327 deletions(-) delete mode 100644 frontend/src/commands/pageCommands.ts create mode 100644 frontend/src/components/fileEditor/FileEditor.module.css create mode 100644 frontend/src/components/fileEditor/FileEditorThumbnail.tsx create mode 100644 frontend/src/components/pageEditor/commands/pageCommands.ts create mode 100644 frontend/src/components/pageEditor/constants.ts create mode 100644 frontend/src/components/pageEditor/hooks/usePageDocument.ts create mode 100644 frontend/src/components/pageEditor/hooks/usePageEditorState.ts create mode 100644 frontend/src/services/documentManipulationService.ts diff --git a/frontend/scripts/generate-icons.js b/frontend/scripts/generate-icons.js index 681b06728..0fd42a4df 100644 --- a/frontend/scripts/generate-icons.js +++ b/frontend/scripts/generate-icons.js @@ -1,7 +1,6 @@ #!/usr/bin/env node const { icons } = require('@iconify-json/material-symbols'); -const { getIcons } = require('@iconify/utils'); const fs = require('fs'); const path = require('path'); @@ -89,68 +88,73 @@ function scanForUsedIcons() { return iconArray; } -// Auto-detect used icons -const usedIcons = scanForUsedIcons(); +// Main async function +async function main() { + // Auto-detect used icons + const usedIcons = scanForUsedIcons(); -// Check if we need to regenerate (compare with existing) -const outputPath = path.join(__dirname, '..', 'src', 'assets', 'material-symbols-icons.json'); -let needsRegeneration = true; + // Check if we need to regenerate (compare with existing) + const outputPath = path.join(__dirname, '..', 'src', 'assets', 'material-symbols-icons.json'); + let needsRegeneration = true; -if (fs.existsSync(outputPath)) { - try { - const existingSet = JSON.parse(fs.readFileSync(outputPath, 'utf8')); - const existingIcons = Object.keys(existingSet.icons || {}).sort(); - const currentIcons = [...usedIcons].sort(); - - if (JSON.stringify(existingIcons) === JSON.stringify(currentIcons)) { - needsRegeneration = false; - info(`✅ Icon set already up-to-date (${usedIcons.length} icons, ${Math.round(fs.statSync(outputPath).size / 1024)}KB)`); + if (fs.existsSync(outputPath)) { + try { + const existingSet = JSON.parse(fs.readFileSync(outputPath, 'utf8')); + const existingIcons = Object.keys(existingSet.icons || {}).sort(); + const currentIcons = [...usedIcons].sort(); + + if (JSON.stringify(existingIcons) === JSON.stringify(currentIcons)) { + needsRegeneration = false; + info(`✅ Icon set already up-to-date (${usedIcons.length} icons, ${Math.round(fs.statSync(outputPath).size / 1024)}KB)`); + } + } catch (error) { + // If we can't parse existing file, regenerate + needsRegeneration = true; } - } catch (error) { - // If we can't parse existing file, regenerate - needsRegeneration = true; } -} -if (!needsRegeneration) { - info('🎉 No regeneration needed!'); - process.exit(0); -} + if (!needsRegeneration) { + info('🎉 No regeneration needed!'); + process.exit(0); + } -info(`🔍 Extracting ${usedIcons.length} icons from Material Symbols...`); + info(`🔍 Extracting ${usedIcons.length} icons from Material Symbols...`); -// Extract only our used icons from the full set -const extractedIcons = getIcons(icons, usedIcons); + // Dynamic import of ES module + const { getIcons } = await import('@iconify/utils'); + + // Extract only our used icons from the full set + const extractedIcons = getIcons(icons, usedIcons); -if (!extractedIcons) { - console.error('❌ Failed to extract icons'); - process.exit(1); -} + if (!extractedIcons) { + console.error('❌ Failed to extract icons'); + process.exit(1); + } -// Check for missing icons -const extractedIconNames = Object.keys(extractedIcons.icons || {}); -const missingIcons = usedIcons.filter(icon => !extractedIconNames.includes(icon)); + // Check for missing icons + const extractedIconNames = Object.keys(extractedIcons.icons || {}); + const missingIcons = usedIcons.filter(icon => !extractedIconNames.includes(icon)); -if (missingIcons.length > 0) { - info(`⚠️ Missing icons (${missingIcons.length}): ${missingIcons.join(', ')}`); - info('💡 These icons don\'t exist in Material Symbols. Please use available alternatives.'); -} + if (missingIcons.length > 0) { + info(`⚠️ Missing icons (${missingIcons.length}): ${missingIcons.join(', ')}`); + info('💡 These icons don\'t exist in Material Symbols. Please use available alternatives.'); + } -// Create output directory -const outputDir = path.join(__dirname, '..', 'src', 'assets'); -if (!fs.existsSync(outputDir)) { - fs.mkdirSync(outputDir, { recursive: true }); -} + // Create output directory + const outputDir = path.join(__dirname, '..', 'src', 'assets'); + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } -// Write the extracted icon set to a file (outputPath already defined above) -fs.writeFileSync(outputPath, JSON.stringify(extractedIcons, null, 2)); + // Write the extracted icon set to a file (outputPath already defined above) + fs.writeFileSync(outputPath, JSON.stringify(extractedIcons, null, 2)); -info(`✅ Successfully extracted ${Object.keys(extractedIcons.icons || {}).length} icons`); -info(`📦 Bundle size: ${Math.round(JSON.stringify(extractedIcons).length / 1024)}KB`); -info(`💾 Saved to: ${outputPath}`); + info(`✅ Successfully extracted ${Object.keys(extractedIcons.icons || {}).length} icons`); + info(`📦 Bundle size: ${Math.round(JSON.stringify(extractedIcons).length / 1024)}KB`); + info(`💾 Saved to: ${outputPath}`); -// Generate TypeScript types -const typesContent = `// Auto-generated icon types + // Generate TypeScript types + const typesContent = `// Auto-generated icon types // This file is automatically generated by scripts/generate-icons.js // Do not edit manually - changes will be overwritten @@ -168,8 +172,15 @@ declare const iconSet: IconSet; export default iconSet; `; -const typesPath = path.join(outputDir, 'material-symbols-icons.d.ts'); -fs.writeFileSync(typesPath, typesContent); + const typesPath = path.join(outputDir, 'material-symbols-icons.d.ts'); + fs.writeFileSync(typesPath, typesContent); -info(`📝 Generated types: ${typesPath}`); -info(`🎉 Icon extraction complete!`); \ No newline at end of file + info(`📝 Generated types: ${typesPath}`); + info(`🎉 Icon extraction complete!`); +} + +// Run the main function +main().catch(error => { + console.error('❌ Script failed:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/frontend/src/commands/pageCommands.ts b/frontend/src/commands/pageCommands.ts deleted file mode 100644 index 92a9c9a73..000000000 --- a/frontend/src/commands/pageCommands.ts +++ /dev/null @@ -1,335 +0,0 @@ -import { Command, CommandSequence } from '../hooks/useUndoRedo'; -import { PDFDocument, PDFPage } from '../types/pageEditor'; - -// Base class for page operations -abstract class PageCommand implements Command { - protected pdfDocument: PDFDocument; - protected setPdfDocument: (doc: PDFDocument) => void; - protected previousState: PDFDocument; - - constructor( - pdfDocument: PDFDocument, - setPdfDocument: (doc: PDFDocument) => void - ) { - this.pdfDocument = pdfDocument; - this.setPdfDocument = setPdfDocument; - this.previousState = JSON.parse(JSON.stringify(pdfDocument)); // Deep clone - } - - abstract execute(): void; - abstract description: string; - - undo(): void { - this.setPdfDocument(this.previousState); - } -} - -// Rotate pages command -export class RotatePagesCommand extends PageCommand { - private pageIds: string[]; - private rotation: number; - - constructor( - pdfDocument: PDFDocument, - setPdfDocument: (doc: PDFDocument) => void, - pageIds: string[], - rotation: number - ) { - super(pdfDocument, setPdfDocument); - this.pageIds = pageIds; - this.rotation = rotation; - } - - execute(): void { - const updatedPages = this.pdfDocument.pages.map(page => { - if (this.pageIds.includes(page.id)) { - return { ...page, rotation: page.rotation + this.rotation }; - } - return page; - }); - - this.setPdfDocument({ - ...this.pdfDocument, - pages: updatedPages, - totalPages: updatedPages.length - }); - } - - get description(): string { - const direction = this.rotation > 0 ? 'right' : 'left'; - return `Rotate ${this.pageIds.length} page(s) ${direction}`; - } -} - -// Delete pages command -export class DeletePagesCommand extends PageCommand { - private pageIds: string[]; - private deletedPages: PDFPage[]; - private deletedPositions: Map; - - constructor( - pdfDocument: PDFDocument, - setPdfDocument: (doc: PDFDocument) => void, - pageIds: string[] - ) { - super(pdfDocument, setPdfDocument); - this.pageIds = pageIds; - this.deletedPages = []; - this.deletedPositions = new Map(); - } - - execute(): void { - // Store deleted pages and their positions for undo - this.deletedPages = this.pdfDocument.pages.filter(page => - this.pageIds.includes(page.id) - ); - - this.deletedPages.forEach(page => { - const index = this.pdfDocument.pages.findIndex(p => p.id === page.id); - this.deletedPositions.set(page.id, index); - }); - - const updatedPages = this.pdfDocument.pages - .filter(page => !this.pageIds.includes(page.id)) - .map((page, index) => ({ ...page, pageNumber: index + 1 })); - - this.setPdfDocument({ - ...this.pdfDocument, - pages: updatedPages, - totalPages: updatedPages.length - }); - } - - undo(): void { - // Simply restore to the previous state (before deletion) - this.setPdfDocument(this.previousState); - } - - get description(): string { - return `Delete ${this.pageIds.length} page(s)`; - } -} - -// Move pages command -export class MovePagesCommand extends PageCommand { - private pageIds: string[]; - private targetIndex: number; - private originalIndices: Map; - - constructor( - pdfDocument: PDFDocument, - setPdfDocument: (doc: PDFDocument) => void, - pageIds: string[], - targetIndex: number - ) { - super(pdfDocument, setPdfDocument); - this.pageIds = pageIds; - this.targetIndex = targetIndex; - this.originalIndices = new Map(); - } - - execute(): void { - // Store original positions - this.pageIds.forEach(pageId => { - const index = this.pdfDocument.pages.findIndex(p => p.id === pageId); - this.originalIndices.set(pageId, index); - }); - - let newPages = [...this.pdfDocument.pages]; - const pagesToMove = this.pageIds - .map(id => this.pdfDocument.pages.find(p => p.id === id)) - .filter((page): page is PDFPage => page !== undefined); - - // Remove pages to move - newPages = newPages.filter(page => !this.pageIds.includes(page.id)); - - // Insert pages at target position - newPages.splice(this.targetIndex, 0, ...pagesToMove); - - // Update page numbers - newPages = newPages.map((page, index) => ({ - ...page, - pageNumber: index + 1 - })); - - this.setPdfDocument({ - ...this.pdfDocument, - pages: newPages, - totalPages: newPages.length - }); - } - - get description(): string { - return `Move ${this.pageIds.length} page(s)`; - } -} - -// Reorder single page command (for drag-and-drop) -export class ReorderPageCommand extends PageCommand { - private pageId: string; - private targetIndex: number; - private originalIndex: number; - - constructor( - pdfDocument: PDFDocument, - setPdfDocument: (doc: PDFDocument) => void, - pageId: string, - targetIndex: number - ) { - super(pdfDocument, setPdfDocument); - this.pageId = pageId; - this.targetIndex = targetIndex; - this.originalIndex = pdfDocument.pages.findIndex(p => p.id === pageId); - } - - execute(): void { - const newPages = [...this.pdfDocument.pages]; - const [movedPage] = newPages.splice(this.originalIndex, 1); - newPages.splice(this.targetIndex, 0, movedPage); - - // Update page numbers - const updatedPages = newPages.map((page, index) => ({ - ...page, - pageNumber: index + 1 - })); - - this.setPdfDocument({ - ...this.pdfDocument, - pages: updatedPages, - totalPages: updatedPages.length - }); - } - - get description(): string { - return `Reorder page ${this.originalIndex + 1} to position ${this.targetIndex + 1}`; - } -} - -// Toggle split markers command -export class ToggleSplitCommand extends PageCommand { - private pageIds: string[]; - private previousSplitStates: Map; - - constructor( - pdfDocument: PDFDocument, - setPdfDocument: (doc: PDFDocument) => void, - pageIds: string[] - ) { - super(pdfDocument, setPdfDocument); - this.pageIds = pageIds; - this.previousSplitStates = new Map(); - } - - execute(): void { - // Store previous split states - this.pageIds.forEach(pageId => { - const page = this.pdfDocument.pages.find(p => p.id === pageId); - if (page) { - this.previousSplitStates.set(pageId, !!page.splitBefore); - } - }); - - const updatedPages = this.pdfDocument.pages.map(page => { - if (this.pageIds.includes(page.id)) { - return { ...page, splitBefore: !page.splitBefore }; - } - return page; - }); - - this.setPdfDocument({ - ...this.pdfDocument, - pages: updatedPages, - totalPages: updatedPages.length - }); - } - - undo(): void { - const updatedPages = this.pdfDocument.pages.map(page => { - if (this.pageIds.includes(page.id)) { - const previousState = this.previousSplitStates.get(page.id); - return { ...page, splitBefore: previousState }; - } - return page; - }); - - this.setPdfDocument({ - ...this.pdfDocument, - pages: updatedPages, - totalPages: updatedPages.length - }); - } - - get description(): string { - return `Toggle split markers for ${this.pageIds.length} page(s)`; - } -} - -// Add pages command (for inserting new files) -export class AddPagesCommand extends PageCommand { - private newPages: PDFPage[]; - private insertIndex: number; - - constructor( - pdfDocument: PDFDocument, - setPdfDocument: (doc: PDFDocument) => void, - newPages: PDFPage[], - insertIndex: number = -1 // -1 means append to end - ) { - super(pdfDocument, setPdfDocument); - this.newPages = newPages; - this.insertIndex = insertIndex === -1 ? pdfDocument.pages.length : insertIndex; - } - - execute(): void { - const newPagesArray = [...this.pdfDocument.pages]; - newPagesArray.splice(this.insertIndex, 0, ...this.newPages); - - // Update page numbers for all pages - const updatedPages = newPagesArray.map((page, index) => ({ - ...page, - pageNumber: index + 1 - })); - - this.setPdfDocument({ - ...this.pdfDocument, - pages: updatedPages, - totalPages: updatedPages.length - }); - } - - undo(): void { - const updatedPages = this.pdfDocument.pages - .filter(page => !this.newPages.some(newPage => newPage.id === page.id)) - .map((page, index) => ({ ...page, pageNumber: index + 1 })); - - this.setPdfDocument({ - ...this.pdfDocument, - pages: updatedPages, - totalPages: updatedPages.length - }); - } - - get description(): string { - return `Add ${this.newPages.length} page(s)`; - } -} - -// Command sequence for bulk operations -export class PageCommandSequence implements CommandSequence { - commands: Command[]; - description: string; - - constructor(commands: Command[], description?: string) { - this.commands = commands; - this.description = description || `Execute ${commands.length} operations`; - } - - execute(): void { - this.commands.forEach(command => command.execute()); - } - - undo(): void { - // Undo in reverse order - [...this.commands].reverse().forEach(command => command.undo()); - } -} \ No newline at end of file diff --git a/frontend/src/components/fileEditor/FileEditor.module.css b/frontend/src/components/fileEditor/FileEditor.module.css new file mode 100644 index 000000000..344959b80 --- /dev/null +++ b/frontend/src/components/fileEditor/FileEditor.module.css @@ -0,0 +1,276 @@ +/* ========================= + FileEditor Card UI Styles + ========================= */ + +.card { + background: var(--file-card-bg); + border-radius: 0.0625rem; + cursor: pointer; + transition: box-shadow 0.18s ease, outline-color 0.18s ease, transform 0.18s ease; + max-width: 100%; + max-height: 100%; + overflow: hidden; + margin-left: 0.5rem; + margin-right: 0.5rem; +} + +.card:hover { + box-shadow: var(--shadow-md); +} + +.card[data-selected="true"] { + box-shadow: var(--shadow-sm); +} + +/* While dragging */ +.card.dragging, +.card:global(.dragging) { + outline: 1px solid var(--border-strong); + box-shadow: var(--shadow-md); + transform: none !important; +} + +/* -------- Header -------- */ +.header { + height: 2.25rem; + border-radius: 0.0625rem 0.0625rem 0 0; + display: grid; + grid-template-columns: 44px 1fr 44px; + align-items: center; + padding: 0 6px; + user-select: none; + background: var(--bg-toolbar); + color: var(--text-primary); + border-bottom: 1px solid var(--border-default); +} + +.headerResting { + background: #3B4B6E; /* dark blue for unselected in light mode */ + color: #FFFFFF; + border-bottom: 1px solid var(--border-default); +} + +.headerSelected { + background: var(--header-selected-bg); + color: var(--header-selected-fg); + border-bottom: 1px solid var(--header-selected-bg); +} + +/* Selected border color in light mode */ +:global([data-mantine-color-scheme="light"]) .card[data-selected="true"] { + outline-color: var(--card-selected-border); +} + +/* Reserve space for checkbox instead of logo */ +.logoMark { + margin-left: 8px; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; +} + +.headerIndex { + text-align: center; + font-weight: 500; + font-size: 18px; + letter-spacing: 0.04em; +} + +.kebab { + justify-self: end; +} + +/* Menu dropdown */ +.menuDropdown { + min-width: 210px; +} + +/* -------- Title / Meta -------- */ +.title { + line-height: 1.2; + color: var(--text-primary); +} + +.meta { + margin-top: 2px; + color: var(--text-secondary); +} + +/* -------- Preview area -------- */ +.previewBox { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + background: var(--file-card-bg); +} + +.previewPaper { + width: 100%; + height: calc(100% - 6px); + min-height: 9rem; + justify-content: center; + display: grid; + position: relative; + overflow: hidden; + background: var(--file-card-bg); +} + +/* Thumbnail fallback */ +.previewPaper[data-thumb-missing="true"]::after { + content: "No preview"; + position: absolute; + inset: 0; + display: grid; + place-items: center; + color: var(--text-secondary); + font-weight: 600; + font-size: 12px; +} + +/* Drag handle grip */ +.dragHandle { + position: absolute; + bottom: 6px; + right: 6px; + color: var(--text-secondary); + z-index: 1; + cursor: grab; + display: inline-flex; +} + +/* Actions Overlay */ +.actionsOverlay { + position: absolute; + left: 0; + top: 44px; /* just below header */ + background: var(--bg-toolbar); + border-bottom: 1px solid var(--border-default); + z-index: 20; + overflow: hidden; + animation: slideDown 140ms ease-out; + color: var(--text-primary); +} + +@keyframes slideDown { + from { + transform: translateY(-8px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +.actionRow { + width: 100%; + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + background: transparent; + border: none; + color: var(--text-primary); + cursor: pointer; + text-align: left; +} + +.actionRow:hover { + background: var(--hover-bg); +} + +.actionDanger { + color: var(--text-brand-accent); +} + +.actionsDivider { + height: 1px; + background: var(--border-default); + margin: 4px 0; +} + +/* Pin indicator */ +.pinIndicator { + position: absolute; + bottom: 4px; + left: 4px; + z-index: 1; + color: rgba(0, 0, 0, 0.35); +} + +/* Unsupported file indicator */ +.unsupportedPill { + margin-left: 1.75rem; + background: #6B7280; + color: white; + padding: 4px 8px; + border-radius: 12px; + font-size: 10px; + font-weight: 500; + display: flex; + align-items: center; + justify-content: center; + min-width: 80px; + height: 20px; +} + +/* Animations */ +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +.pulse { + animation: pulse 1s infinite; +} + +/* Reduced motion */ +@media (prefers-reduced-motion: reduce) { + .card, + .menuDropdown { + transition: none !important; + } +} + +/* ========================= + DARK MODE OVERRIDES + ========================= */ +:global([data-mantine-color-scheme="dark"]) .card { + outline-color: #3A4047; /* deselected stroke */ +} + +:global([data-mantine-color-scheme="dark"]) .card[data-selected="true"] { + outline-color: #4B525A; /* selected stroke (subtle grey) */ +} + +:global([data-mantine-color-scheme="dark"]) .headerResting { + background: #1F2329; /* requested default unselected color */ + color: var(--tool-header-text); /* #D0D6DC */ + border-bottom-color: var(--tool-header-border); /* #3A4047 */ +} + +:global([data-mantine-color-scheme="dark"]) .headerSelected { + background: var(--tool-header-border); /* #3A4047 */ + color: var(--tool-header-text); /* #D0D6DC */ + border-bottom-color: var(--tool-header-border); +} + +:global([data-mantine-color-scheme="dark"]) .title { + color: #D0D6DC; /* title text */ +} + +:global([data-mantine-color-scheme="dark"]) .meta { + color: #6B7280; /* subtitle text */ +} + +/* Light mode selected header stroke override */ +:global([data-mantine-color-scheme="light"]) .card[data-selected="true"] { + outline-color: #3B4B6E; +} \ No newline at end of file diff --git a/frontend/src/components/fileEditor/FileEditor.tsx b/frontend/src/components/fileEditor/FileEditor.tsx index df1197ab9..eee1eb1a7 100644 --- a/frontend/src/components/fileEditor/FileEditor.tsx +++ b/frontend/src/components/fileEditor/FileEditor.tsx @@ -12,8 +12,8 @@ import { fileStorage } from '../../services/fileStorage'; import { generateThumbnailForFile } from '../../utils/thumbnailUtils'; import { zipFileService } from '../../services/zipFileService'; import { detectFileExtension } from '../../utils/fileUtils'; -import styles from '../pageEditor/PageEditor.module.css'; -import FileThumbnail from '../pageEditor/FileThumbnail'; +import styles from './FileEditor.module.css'; +import FileEditorThumbnail from './FileEditorThumbnail'; import FilePickerModal from '../shared/FilePickerModal'; import SkeletonLoader from '../shared/SkeletonLoader'; @@ -527,7 +527,7 @@ const FileEditor = ({ if (!fileItem) return null; return ( - void; + onDeleteFile: (fileId: string) => void; + onViewFile: (fileId: string) => void; + onSetStatus: (status: string) => void; + onReorderFiles?: (sourceFileId: string, targetFileId: string, selectedFileIds: string[]) => void; + onDownloadFile?: (fileId: string) => void; + toolMode?: boolean; + isSupported?: boolean; +} + +const FileEditorThumbnail = ({ + file, + index, + selectedFiles, + onToggleFile, + onDeleteFile, + onViewFile, + onSetStatus, + onReorderFiles, + onDownloadFile, + isSupported = true, +}: FileEditorThumbnailProps) => { + const { t } = useTranslation(); + const { pinFile, unpinFile, isFilePinned, activeFiles } = useFileContext(); + + // ---- Drag state ---- + const [isDragging, setIsDragging] = useState(false); + const dragElementRef = useRef(null); + const [actionsWidth, setActionsWidth] = useState(undefined); + const [showActions, setShowActions] = useState(false); + + // Resolve the actual File object for pin/unpin operations + const actualFile = useMemo(() => { + return activeFiles.find((f: File) => f.name === file.name && f.size === file.size); + }, [activeFiles, file.name, file.size]); + const isPinned = actualFile ? isFilePinned(actualFile) : false; + + const downloadSelectedFile = useCallback(() => { + // Prefer parent-provided handler if available + if (typeof onDownloadFile === 'function') { + onDownloadFile(file.id); + return; + } + + // Fallback: attempt to download using the File object if provided + const maybeFile = (file as unknown as { file?: File }).file; + if (maybeFile instanceof File) { + const link = document.createElement('a'); + link.href = URL.createObjectURL(maybeFile); + link.download = maybeFile.name || file.name || 'download'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(link.href); + return; + } + + // If we can't find a way to download, surface a status message + onSetStatus?.(typeof t === 'function' ? t('downloadUnavailable', 'Download unavailable for this item') : 'Download unavailable for this item'); + }, [file, onDownloadFile, onSetStatus, t]); + const handleRef = useRef(null); + + // ---- Selection ---- + const isSelected = selectedFiles.includes(file.id); + + // ---- Meta formatting ---- + const prettySize = useMemo(() => { + const bytes = file.size ?? 0; + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; + }, [file.size]); + + const extUpper = useMemo(() => { + const m = /\.([a-z0-9]+)$/i.exec(file.name ?? ''); + return (m?.[1] || '').toUpperCase(); + }, [file.name]); + + const pageLabel = useMemo( + () => + file.pageCount > 0 + ? `${file.pageCount} ${file.pageCount === 1 ? 'Page' : 'Pages'}` + : '', + [file.pageCount] + ); + + const dateLabel = useMemo(() => { + const d = + file.modifiedAt != null ? new Date(file.modifiedAt) : new Date(); // fallback + if (Number.isNaN(d.getTime())) return ''; + return new Intl.DateTimeFormat(undefined, { + month: 'short', + day: '2-digit', + year: 'numeric', + }).format(d); + }, [file.modifiedAt]); + + // ---- Drag & drop wiring ---- + 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, selectedFiles, onReorderFiles]); + + // Update dropdown width on resize + useEffect(() => { + const update = () => { + if (dragElementRef.current) setActionsWidth(dragElementRef.current.offsetWidth); + }; + update(); + window.addEventListener('resize', update); + return () => window.removeEventListener('resize', update); + }, []); + + // Close the actions dropdown when hovering outside this file card (and its dropdown) + useEffect(() => { + if (!showActions) return; + + const isInsideCard = (target: EventTarget | null) => { + const container = dragElementRef.current; + if (!container) return false; + return target instanceof Node && container.contains(target); + }; + + const handleMouseMove = (e: MouseEvent) => { + if (!isInsideCard(e.target)) { + setShowActions(false); + } + }; + + const handleTouchStart = (e: TouchEvent) => { + // On touch devices, close if the touch target is outside the card + if (!isInsideCard(e.target)) { + setShowActions(false); + } + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('touchstart', handleTouchStart, { passive: true }); + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('touchstart', handleTouchStart); + }; + }, [showActions]); + + // ---- Card interactions ---- + const handleCardClick = () => { + if (!isSupported) return; + onToggleFile(file.id); + }; + + + return ( +
+ {/* Header bar */} +
+ {/* Logo/checkbox area */} +
+ {isSupported ? ( + onToggleFile(file.id)} + color="var(--checkbox-checked-bg)" + /> + ) : ( +
+ + {t('unsupported', 'Unsupported')} + +
+ )} +
+ + {/* Centered index */} +
+ {index + 1} +
+ + {/* Kebab menu */} + { + e.stopPropagation(); + setShowActions((v) => !v); + }} + > + + +
+ + {/* Actions overlay */} + {showActions && ( +
e.stopPropagation()} + > + + + + +
+ + +
+ )} + + {/* Title + meta line */} +
+ + {file.name} + + + {/* e.g., Jan 29, 2025 - PDF file - 3 Pages */} + {dateLabel} + {extUpper ? ` - ${extUpper} file` : ''} + {pageLabel ? ` - ${pageLabel}` : ''} + +
+ + {/* Preview area */} +
+
+ {file.thumbnail && ( + {file.name} { + const img = e.currentTarget; + img.style.display = 'none'; + img.parentElement?.setAttribute('data-thumb-missing', 'true'); + }} + style={{ + maxWidth: '80%', + maxHeight: '80%', + objectFit: 'contain', + borderRadius: 0, + background: '#ffffff', + border: '1px solid var(--border-default)', + display: 'block', + marginLeft: 'auto', + marginRight: 'auto', + alignSelf: 'start' + }} + /> + )} +
+ + {/* Pin indicator (bottom-left) */} + {isPinned && ( + + + + )} + + {/* Drag handle (span wrapper so we can attach a ref reliably) */} + + + +
+
+ ); +}; + +export default React.memo(FileEditorThumbnail); \ No newline at end of file diff --git a/frontend/src/components/layout/Workbench.tsx b/frontend/src/components/layout/Workbench.tsx index a98b19b99..44a4f0271 100644 --- a/frontend/src/components/layout/Workbench.tsx +++ b/frontend/src/components/layout/Workbench.tsx @@ -111,11 +111,15 @@ export default function Workbench() { onRotate={pageEditorFunctions.handleRotate} onDelete={pageEditorFunctions.handleDelete} onSplit={pageEditorFunctions.handleSplit} - onExportSelected={pageEditorFunctions.onExportSelected} + onSplitAll={pageEditorFunctions.handleSplitAll} + onPageBreak={pageEditorFunctions.handlePageBreak} + onPageBreakAll={pageEditorFunctions.handlePageBreakAll} onExportAll={pageEditorFunctions.onExportAll} exportLoading={pageEditorFunctions.exportLoading} selectionMode={pageEditorFunctions.selectionMode} selectedPages={pageEditorFunctions.selectedPages} + splitPositions={pageEditorFunctions.splitPositions} + totalPages={pageEditorFunctions.totalPages} /> )} diff --git a/frontend/src/components/pageEditor/DragDropGrid.tsx b/frontend/src/components/pageEditor/DragDropGrid.tsx index 5829d0375..8928e1a34 100644 --- a/frontend/src/components/pageEditor/DragDropGrid.tsx +++ b/frontend/src/components/pageEditor/DragDropGrid.tsx @@ -3,10 +3,11 @@ 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 { GRID_CONSTANTS } from './constants'; interface DragDropItem { id: string; - splitBefore?: boolean; + splitAfter?: boolean; } interface DragDropGridProps { @@ -33,10 +34,7 @@ const DragDropGrid = ({ // 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 + const OVERSCAN = items.length > 1000 ? GRID_CONSTANTS.OVERSCAN_LARGE : GRID_CONSTANTS.OVERSCAN_SMALL; // Calculate items per row based on container width const calculateItemsPerRow = useCallback(() => { @@ -45,6 +43,11 @@ const DragDropGrid = ({ const containerWidth = containerRef.current.offsetWidth; if (containerWidth === 0) return 4; // Container not measured yet + // Convert rem to pixels for calculation + const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize); + const ITEM_WIDTH = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx; + const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx; + // Calculate how many items fit: (width - gap) / (itemWidth + gap) const availableWidth = containerWidth - ITEM_GAP; // Account for first gap const itemWithGap = ITEM_WIDTH + ITEM_GAP; @@ -82,12 +85,21 @@ const DragDropGrid = ({ const rowVirtualizer = useVirtualizer({ count: Math.ceil(items.length / itemsPerRow), getScrollElement: () => containerRef.current?.closest('[data-scrolling-container]') as Element, - estimateSize: () => ITEM_HEIGHT, + estimateSize: () => { + const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize); + return parseFloat(GRID_CONSTANTS.ITEM_HEIGHT) * remToPx; + }, overscan: OVERSCAN, }); + // Calculate optimal width for centering + const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize); + const itemWidth = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx; + const itemGap = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx; + const gridWidth = itemsPerRow * itemWidth + (itemsPerRow - 1) * itemGap; + return ( ({ height: `${rowVirtualizer.getTotalSize()}px`, width: '100%', position: 'relative', + margin: '0 auto', + maxWidth: `${gridWidth}px`, }} > {rowVirtualizer.getVirtualItems().map((virtualRow) => { @@ -124,18 +138,17 @@ const DragDropGrid = ({
{rowItems.map((item, itemIndex) => { const actualIndex = startIndex + itemIndex; return ( - {/* Split marker */} - {renderSplitMarker && item.splitBefore && actualIndex > 0 && renderSplitMarker(item, actualIndex)} {/* Item */} {renderItem(item, actualIndex, itemRefs)} diff --git a/frontend/src/components/pageEditor/FileThumbnail.tsx b/frontend/src/components/pageEditor/FileThumbnail.tsx index 91ffda358..a0a7d1795 100644 --- a/frontend/src/components/pageEditor/FileThumbnail.tsx +++ b/frontend/src/components/pageEditor/FileThumbnail.tsx @@ -16,7 +16,7 @@ interface FileItem { id: string; name: string; pageCount: number; - thumbnail: string; + thumbnail: string | null; size: number; modifiedAt?: number | string | Date; } @@ -129,9 +129,9 @@ const FileThumbnail = ({ // ---- Drag & drop wiring ---- const fileElementRef = useCallback((element: HTMLDivElement | null) => { if (!element) return; - + dragElementRef.current = element; - + const dragCleanup = draggable({ element, getInitialData: () => ({ @@ -147,7 +147,7 @@ const FileThumbnail = ({ setIsDragging(false); } }); - + const dropCleanup = dropTargetForElements({ element, getData: () => ({ @@ -331,46 +331,34 @@ const FileThumbnail = ({
)} - {/* Title + meta line */} -
- - {file.name} - - + {/* Stacked file effect - multiple shadows to simulate pages */} +
- {/* e.g., Jan 29, 2025 - PDF file - 3 Pages */} - {dateLabel} - {extUpper ? ` - ${extUpper} file` : ''} - {pageLabel ? ` - ${pageLabel}` : ''} - -
- - {/* Preview area */} -
-
- {file.name} { - const img = e.currentTarget; - img.style.display = 'none'; - img.parentElement?.setAttribute('data-thumb-missing', 'true'); - }} + {file.thumbnail && ( + {file.name} { + // Hide broken image if blob URL was revoked + const img = e.target as HTMLImageElement; + img.style.display = 'none'; + }} style={{ maxWidth: '80%', maxHeight: '80%', @@ -384,6 +372,7 @@ const FileThumbnail = ({ alignSelf: 'start' }} /> + )}
{/* Pin indicator (bottom-left) */} diff --git a/frontend/src/components/pageEditor/PageEditor.module.css b/frontend/src/components/pageEditor/PageEditor.module.css index 851d81517..ab2fd691b 100644 --- a/frontend/src/components/pageEditor/PageEditor.module.css +++ b/frontend/src/components/pageEditor/PageEditor.module.css @@ -1,244 +1,81 @@ -/* ========================= - NEW styles for card UI - ========================= */ +/* Page container hover effects - optimized for smooth scrolling */ +.pageContainer { + transition: transform 0.2s ease-in-out; + /* Enable hardware acceleration for smoother scrolling */ + will-change: transform; + transform: translateZ(0); + backface-visibility: hidden; +} - .card { - background: var(--file-card-bg); - border-radius: 0.0625rem; - cursor: pointer; - transition: box-shadow 0.18s ease, outline-color 0.18s ease, transform 0.18s ease; - max-width: 100%; - max-height: 100%; - overflow: hidden; - margin-left: 0.5rem; - margin-right: 0.5rem; - } - .card:hover { - box-shadow: var(--shadow-md); - } - .card[data-selected="true"] { - box-shadow: var(--shadow-sm); - } - - /* While dragging */ - .card.dragging, - .card:global(.dragging) { - outline: 1px solid var(--border-strong); - box-shadow: var(--shadow-md); - transform: none !important; - } - - /* -------- Header -------- */ - .header { - height: 2.25rem; - border-radius: 0.0625rem 0.0625rem 0 0; - display: grid; - grid-template-columns: 44px 1fr 44px; - align-items: center; - padding: 0 6px; - user-select: none; - background: var(--bg-toolbar); - color: var(--text-primary); - border-bottom: 1px solid var(--border-default); - } - .headerResting { - background: #3B4B6E; /* dark blue for unselected in light mode */ - color: #FFFFFF; - border-bottom: 1px solid var(--border-default); - } - .headerSelected { - background: var(--header-selected-bg); - color: var(--header-selected-fg); - border-bottom: 1px solid var(--header-selected-bg); - } - - /* Selected border color in light mode */ - :global([data-mantine-color-scheme="light"]) .card[data-selected="true"] { - outline-color: var(--card-selected-border); - } - - /* Reserve space for checkbox instead of logo */ - .logoMark { - margin-left: 8px; - width: 36px; - height: 36px; - display: flex; - align-items: center; - justify-content: center; - } - - .headerIndex { - text-align: center; - font-weight: 500; - font-size: 18px; - letter-spacing: 0.04em; - } - - .kebab { - justify-self: end; - color: inherit; - } - - /* Menu dropdown */ - .menuDropdown { - min-width: 210px; - } - - /* -------- Title / Meta -------- */ - .title { - line-height: 1.2; - color: var(--text-primary); - } - .meta { - margin-top: 2px; - color: var(--text-secondary); - } - - /* -------- Preview area -------- */ - .previewBox { - position: relative; - width: 100%; - height: 100%; - overflow: hidden; - background: var(--file-card-bg); - } - .previewPaper { - width: 100%; - height: calc(100% - 6px); - min-height: 9rem; - justify-content: center; - display: grid; - position: relative; - overflow: hidden; - background: var(--file-card-bg); - } - - /* Thumbnail fallback */ - .previewPaper[data-thumb-missing="true"]::after { - content: "No preview"; - position: absolute; inset: 0; - display: grid; place-items: center; - color: var(--text-secondary); - font-weight: 600; font-size: 12px; - } - - /* Drag handle grip */ - .dragHandle { - position: absolute; - bottom: 6px; - right: 6px; - color: var(--text-secondary); - z-index: 1; - cursor: grab; - display: inline-flex; - } - - /* Reduced motion */ - @media (prefers-reduced-motion: reduce) { - .card, - .menuDropdown { - transition: none !important; - } - } +.pageContainer:hover { + transform: scale(1.02) translateZ(0); +} - /* ========================= - DARK MODE OVERRIDES - ========================= */ - :global([data-mantine-color-scheme="dark"]) .card { - outline-color: #3A4047; /* deselected stroke */ - } - :global([data-mantine-color-scheme="dark"]) .card[data-selected="true"] { - outline-color: #4B525A; /* selected stroke (subtle grey) */ - } - :global([data-mantine-color-scheme="dark"]) .headerResting { - background: #1F2329; /* requested default unselected color */ - color: var(--tool-header-text); /* #D0D6DC */ - border-bottom-color: var(--tool-header-border); /* #3A4047 */ - } - :global([data-mantine-color-scheme="dark"]) .headerSelected { - background: var(--tool-header-border); /* #3A4047 */ - color: var(--tool-header-text); /* #D0D6DC */ - border-bottom-color: var(--tool-header-border); - } - :global([data-mantine-color-scheme="dark"]) .title { - color: #D0D6DC; /* title text */ - } - :global([data-mantine-color-scheme="dark"]) .meta { - color: #6B7280; /* subtitle text */ - } +.pageContainer:hover .pageNumber { + opacity: 1 !important; +} - /* Light mode selected header stroke override */ - :global([data-mantine-color-scheme="light"]) .card[data-selected="true"] { - outline-color: #3B4B6E; - } - - /* ========================= - (Optional) legacy styles from your - previous component kept here to - avoid breaking other imports. - They are not used by the new card. - ========================= */ - - .pageContainer { - transition: transform 0.2s ease-in-out; - will-change: transform; - transform: translateZ(0); - backface-visibility: hidden; - } - .pageContainer:hover { transform: scale(1.02) translateZ(0); } - .pageContainer:hover .pageNumber { opacity: 1 !important; } - .pageContainer:hover .pageHoverControls { opacity: 1 !important; } - .checkboxContainer { transform: none !important; transition: none !important; } - - .pageMoveAnimation { transition: all 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94); } - .pageMoving { z-index: 10; transform: scale(1.05); box-shadow: 0 10px 30px rgba(0,0,0,0.3); } - - .multiDragIndicator { - position: fixed; - background: rgba(59, 130, 246, 0.9); - color: white; - padding: 8px 12px; - border-radius: 20px; - font-size: 12px; - font-weight: 600; - pointer-events: none; - z-index: 1000; - box-shadow: 0 4px 12px rgba(0,0,0,0.3); - transform: translate(-50%, -50%); - backdrop-filter: blur(4px); - } - - @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.5} } - .pulse { animation: pulse 1s infinite; } +.pageContainer:hover .pageHoverControls { + opacity: 0.95 !important; +} - .actionsOverlay { - position: absolute; - left: 0; - top: 44px; /* just below header */ - background: var(--bg-toolbar); - border-bottom: 1px solid var(--border-default); - z-index: 20; - overflow: hidden; - animation: slideDown 140ms ease-out; - color: var(--text-primary); - } - @keyframes slideDown { from { transform: translateY(-8px); opacity: 0 } to { transform: translateY(0); opacity: 1 } } +/* Checkbox container - prevent transform inheritance */ +.checkboxContainer { + transform: none !important; + transition: none !important; +} - .actionRow { - width: 100%; - display: flex; - align-items: center; - gap: 10px; - padding: 12px 16px; - background: transparent; - border: none; - color: var(--text-primary); - cursor: pointer; - text-align: left; +/* Page movement animations */ +.pageMoveAnimation { + transition: all 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94); +} + +.pageMoving { + z-index: 10; + transform: scale(1.05); + box-shadow: 0 10px 30px rgba(0,0,0,0.3); +} + +/* Multi-page drag indicator */ +.multiDragIndicator { + position: fixed; + background: rgba(59, 130, 246, 0.9); + color: white; + padding: 8px 12px; + border-radius: 20px; + font-size: 12px; + font-weight: 600; + pointer-events: none; + z-index: 1000; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + transform: translate(-50%, -50%); + backdrop-filter: blur(4px); +} + +/* Animations */ +@keyframes pulse { + 0%, 100% { + opacity: 1; } - .actionRow:hover { background: var(--hover-bg); } - .actionDanger { color: var(--text-brand-accent); } - .actionsDivider { height: 1px; background: var(--border-default); margin: 4px 0; } + 50% { + opacity: 0.5; + } +} + +/* Action styles */ +.actionRow:hover { + background: var(--hover-bg); +} + +.actionDanger { + color: var(--text-brand-accent); +} + +.actionsDivider { + height: 1px; + background: var(--border-default); + margin: 4px 0; +} .pinIndicator { position: absolute; @@ -262,4 +99,3 @@ min-width: 80px; height: 20px; } - \ No newline at end of file diff --git a/frontend/src/components/pageEditor/PageEditor.tsx b/frontend/src/components/pageEditor/PageEditor.tsx index 543778d9e..45c0e9717 100644 --- a/frontend/src/components/pageEditor/PageEditor.tsx +++ b/frontend/src/components/pageEditor/PageEditor.tsx @@ -8,32 +8,34 @@ import { useTranslation } from "react-i18next"; import { useFileState, useFileActions, useCurrentFile, useFileSelection } from "../../contexts/FileContext"; import { ModeType } from "../../contexts/NavigationContext"; import { PDFDocument, PDFPage } from "../../types/pageEditor"; -import { useUndoRedo } from "../../hooks/useUndoRedo"; -import { - RotatePagesCommand, - DeletePagesCommand, - ReorderPageCommand, - MovePagesCommand, - ToggleSplitCommand -} from "../../commands/pageCommands"; +import { ProcessedFile as EnhancedProcessedFile } from "../../types/processing"; import { pdfExportService } from "../../services/pdfExportService"; -import { enhancedPDFProcessingService } from "../../services/enhancedPDFProcessingService"; -import { fileProcessingService } from "../../services/fileProcessingService"; -import { pdfProcessingService } from "../../services/pdfProcessingService"; -import { pdfWorkerManager } from "../../services/pdfWorkerManager"; -import { useThumbnailGeneration } from "../../hooks/useThumbnailGeneration"; -import { calculateScaleFromFileSize } from "../../utils/thumbnailUtils"; -import { fileStorage } from "../../services/fileStorage"; -import { indexedDBManager, DATABASE_CONFIGS } from "../../services/indexedDBManager"; +import { documentManipulationService } from "../../services/documentManipulationService"; +// Thumbnail generation is now handled by individual PageThumbnail components import './PageEditor.module.css'; import PageThumbnail from './PageThumbnail'; -import BulkSelectionPanel from './BulkSelectionPanel'; import DragDropGrid from './DragDropGrid'; import SkeletonLoader from '../shared/SkeletonLoader'; import NavigationWarningModal from '../shared/NavigationWarningModal'; +import { + DOMCommand, + RotatePageCommand, + DeletePagesCommand, + ReorderPagesCommand, + SplitCommand, + BulkRotateCommand, + BulkSplitCommand, + SplitAllCommand, + PageBreakCommand, + BulkPageBreakCommand, + UndoManager +} from './commands/pageCommands'; +import { GRID_CONSTANTS } from './constants'; +import { usePageDocument } from './hooks/usePageDocument'; +import { usePageEditorState } from './hooks/usePageEditorState'; + export interface PageEditorProps { - // Optional callbacks to expose internal functions for PageEditorControls onFunctionsReady?: (functions: { handleUndo: () => void; handleRedo: () => void; @@ -42,12 +44,21 @@ export interface PageEditorProps { handleRotate: (direction: 'left' | 'right') => void; handleDelete: () => void; handleSplit: () => void; + handleSplitAll: () => void; + handlePageBreak: () => void; + handlePageBreakAll: () => void; + handleSelectAll: () => void; + handleDeselectAll: () => void; + handleSetSelectedPages: (pageNumbers: number[]) => void; showExportPreview: (selectedOnly: boolean) => void; onExportSelected: () => void; onExportAll: () => void; + applyChanges: () => void; exportLoading: boolean; selectionMode: boolean; selectedPages: number[]; + splitPositions: Set; + totalPages: number; closePdf: () => void; }) => void; } @@ -59,20 +70,19 @@ const PageEditor = ({ // Use split contexts to prevent re-renders const { state, selectors } = useFileState(); const { actions } = useFileActions(); - + // Prefer IDs + selectors to avoid array identity churn const activeFileIds = state.files.ids; const primaryFileId = activeFileIds[0] ?? null; const selectedFiles = selectors.getSelectedFiles(); - + // Stable signature for effects (prevents loops) const filesSignature = selectors.getFilesSignature(); - + // UI state const globalProcessing = state.ui.isProcessing; const processingProgress = state.ui.processingProgress; const hasUnsavedChanges = state.ui.hasUnsavedChanges; - const selectedPageNumbers = state.ui.selectedPageNumbers; // Edit state management const [editedDocument, setEditedDocument] = useState(null); @@ -81,1108 +91,581 @@ const PageEditor = ({ const [foundDraft, setFoundDraft] = useState(null); const autoSaveTimer = useRef(null); - /** - * Create stable files signature to prevent infinite re-computation. - * This signature only changes when files are actually added/removed or processing state changes. - * Using this instead of direct file arrays prevents unnecessary re-renders. - */ - - // Thumbnail generation (opt-in for visual tools) - MUST be before mergedPdfDocument + // DOM-first undo manager (replaces the old React state undo system) + const undoManagerRef = useRef(new UndoManager()); + + // Document state management + const { document: mergedPdfDocument, isVeryLargeDocument, isLoading: documentLoading } = usePageDocument(); + + + // UI state management const { - generateThumbnails, - addThumbnailToCache, - getThumbnailFromCache, - stopGeneration, - destroyThumbnails - } = useThumbnailGeneration(); + selectionMode, selectedPageNumbers, movingPage, isAnimating, splitPositions, exportLoading, + setSelectionMode, setSelectedPageNumbers, setMovingPage, setIsAnimating, setSplitPositions, setExportLoading, + togglePage, toggleSelectAll, animateReorder + } = usePageEditorState(); + + // Grid container ref for positioning split indicators + const gridContainerRef = useRef(null); + // State to trigger re-renders when container size changes + const [containerDimensions, setContainerDimensions] = useState({ width: 0, height: 0 }); - // Get primary file record outside useMemo to track processedFile changes - const primaryFileRecord = primaryFileId ? selectors.getFileRecord(primaryFileId) : null; - const processedFilePages = primaryFileRecord?.processedFile?.pages; - const processedFileTotalPages = primaryFileRecord?.processedFile?.totalPages; + // Undo/Redo state + const [canUndo, setCanUndo] = useState(false); + const [canRedo, setCanRedo] = useState(false); - // Compute merged document with stable signature (prevents infinite loops) - const mergedPdfDocument = useMemo((): PDFDocument | null => { - if (activeFileIds.length === 0) return null; - - const primaryFile = primaryFileId ? selectors.getFile(primaryFileId) : null; - - // If we have file IDs but no file record, something is wrong - return null to show loading - if (!primaryFileRecord) { - console.log('🎬 PageEditor: No primary file record found, showing loading'); - return null; - } - - const name = - activeFileIds.length === 1 - ? (primaryFileRecord.name ?? 'document.pdf') - : activeFileIds - .map(id => (selectors.getFileRecord(id)?.name ?? 'file').replace(/\.pdf$/i, '')) - .join(' + '); - - // Get pages from processed file data - const processedFile = primaryFileRecord.processedFile; - - // Debug logging for processed file data - console.log(`🎬 PageEditor: Building document for ${name}`); - console.log(`🎬 ProcessedFile exists:`, !!processedFile); - console.log(`🎬 ProcessedFile pages:`, processedFile?.pages?.length || 0); - console.log(`🎬 ProcessedFile totalPages:`, processedFile?.totalPages || 'unknown'); - if (processedFile?.pages) { - console.log(`🎬 Pages structure:`, processedFile.pages.map(p => ({ pageNumber: p.pageNumber || 'unknown', hasThumbnail: !!p.thumbnail }))); - } - console.log(`🎬 Will use ${(processedFile?.pages?.length || 0) > 0 ? 'PROCESSED' : 'FALLBACK'} pages`); - - // Convert processed pages to PageEditor format or create placeholders from metadata - let pages: PDFPage[] = []; - - if (processedFile?.pages && processedFile.pages.length > 0) { - // Use fully processed pages with thumbnails - pages = processedFile.pages.map((page, index) => { - const pageId = `${primaryFileId}-page-${index + 1}`; - // Try multiple sources for thumbnails in order of preference: - // 1. Processed data thumbnail - // 2. Cached thumbnail from previous generation - // 3. For page 1: FileRecord's thumbnailUrl (from FileProcessingService) - let thumbnail = page.thumbnail || null; - const cachedThumbnail = getThumbnailFromCache(pageId); - if (!thumbnail && cachedThumbnail) { - thumbnail = cachedThumbnail; - console.log(`📸 PageEditor: Using cached thumbnail for page ${index + 1} (${pageId})`); - } - if (!thumbnail && index === 0) { - // For page 1, use the thumbnail from FileProcessingService - thumbnail = primaryFileRecord.thumbnailUrl || null; - if (thumbnail) { - addThumbnailToCache(pageId, thumbnail); - console.log(`📸 PageEditor: Using FileProcessingService thumbnail for page 1 (${pageId})`); - } - } - - return { - id: pageId, - pageNumber: index + 1, - thumbnail, - rotation: page.rotation || 0, - selected: false, - splitBefore: page.splitBefore || false, - }; - }); - } else if (processedFile?.totalPages && processedFile.totalPages > 0) { - // Create placeholder pages from metadata while thumbnails are being generated - console.log(`🎬 PageEditor: Creating ${processedFile.totalPages} placeholder pages from metadata`); - pages = Array.from({ length: processedFile.totalPages }, (_, index) => { - const pageId = `${primaryFileId}-page-${index + 1}`; - - // Check for existing cached thumbnail - let thumbnail = getThumbnailFromCache(pageId) || null; - - // For page 1, try to use the FileRecord thumbnail - if (!thumbnail && index === 0) { - thumbnail = primaryFileRecord.thumbnailUrl || null; - if (thumbnail) { - addThumbnailToCache(pageId, thumbnail); - console.log(`📸 PageEditor: Using FileProcessingService thumbnail for placeholder page 1 (${pageId})`); - } - } - - return { - id: pageId, - pageNumber: index + 1, - thumbnail, // Will be null initially, populated by PageThumbnail components - rotation: 0, - selected: false, - splitBefore: false, - }; - }); - } else { - // Ultimate fallback - single page while we wait for metadata - pages = [{ - id: `${primaryFileId}-page-1`, - pageNumber: 1, - thumbnail: getThumbnailFromCache(`${primaryFileId}-page-1`) || primaryFileRecord.thumbnailUrl || null, - rotation: 0, - selected: false, - splitBefore: false, - }]; - } - - // Create document with determined pages - - return { - id: activeFileIds.length === 1 ? (primaryFileId ?? 'unknown') : `merged:${filesSignature}`, - name, - file: primaryFile || new File([], primaryFileRecord.name), // Create minimal File if needed - pages, - totalPages: pages.length, - destroy: () => {} // Optional cleanup function - }; - }, [filesSignature, primaryFileId, primaryFileRecord]); - - - // Display document: Use edited version if exists, otherwise original - const displayDocument = editedDocument || mergedPdfDocument; - - const [filename, setFilename] = useState(""); - - - - // Page editor state (use context for selectedPages) - const [status, setStatus] = useState(null); - const [error, setError] = useState(null); - const [csvInput, setCsvInput] = useState(""); - const [selectionMode, setSelectionMode] = useState(false); - - - // Export state - const [exportLoading, setExportLoading] = useState(false); - const [showExportModal, setShowExportModal] = useState(false); - const [exportPreview, setExportPreview] = useState<{pageCount: number; splitCount: number; estimatedSize: string} | null>(null); - const [exportSelectedOnly, setExportSelectedOnly] = useState(false); - - // Animation state - const [movingPage, setMovingPage] = useState(null); - const [isAnimating, setIsAnimating] = useState(false); - - // Undo/Redo system - const { executeCommand, undo, redo, canUndo, canRedo } = useUndoRedo(); - - // Track whether the user has manually edited the filename to avoid auto-overwrites - const userEditedFilename = useRef(false); - - // Reset user edit flag when the active files change, so defaults can be applied for new docs - useEffect(() => { - userEditedFilename.current = false; - }, [filesSignature]); - - // Set initial filename when document changes - use stable signature - useEffect(() => { - if (userEditedFilename.current) return; // Do not overwrite user-typed filename - if (mergedPdfDocument) { - if (activeFileIds.length === 1 && primaryFileId) { - const record = selectors.getFileRecord(primaryFileId); - if (record) { - setFilename(record.name.replace(/\.pdf$/i, '')); - } - } else { - const filenames = activeFileIds - .map(id => selectors.getFileRecord(id)?.name.replace(/\.pdf$/i, '') || 'file') - .filter(Boolean); - setFilename(filenames.join('_')); - } - } - }, [mergedPdfDocument, filesSignature, primaryFileId, selectors]); - - // Handle file upload from FileUploadSelector (now using context) - const handleMultipleFileUpload = useCallback(async (uploadedFiles: File[]) => { - if (!uploadedFiles || uploadedFiles.length === 0) { - setStatus('No files provided'); - return; - } - - // Add files to context - await actions.addFiles(uploadedFiles); - setStatus(`Added ${uploadedFiles.length} file(s) for processing`); - }, [actions]); - - - // PageEditor no longer handles cleanup - it's centralized in FileContext - - // Simple cache-first thumbnail generation (no complex detection needed) - - // Lazy thumbnail generation - only generate when needed, with intelligent batching - const generateMissingThumbnails = useCallback(async () => { - if (!mergedPdfDocument || !primaryFileId || activeFileIds.length !== 1) { - return; - } - - const file = selectors.getFile(primaryFileId); - if (!file) return; - - const totalPages = mergedPdfDocument.totalPages; - if (totalPages <= 1) return; // Only page 1, nothing to generate - - // For very large documents (2000+ pages), be much more conservative - const isVeryLargeDocument = totalPages > 2000; - - if (isVeryLargeDocument) { - console.log(`📸 PageEditor: Very large document (${totalPages} pages) - using minimal thumbnail generation`); - // For very large docs, only generate the next visible batch (pages 2-25) to avoid UI blocking - const pageNumbersToGenerate = []; - for (let pageNum = 2; pageNum <= Math.min(25, totalPages); pageNum++) { - const pageId = `${primaryFileId}-page-${pageNum}`; - if (!getThumbnailFromCache(pageId)) { - pageNumbersToGenerate.push(pageNum); - } - } - - if (pageNumbersToGenerate.length > 0) { - console.log(`📸 PageEditor: Generating initial batch for large doc: pages [${pageNumbersToGenerate.join(', ')}]`); - await generateThumbnailBatch(file, primaryFileId, pageNumbersToGenerate); - } - - // Schedule remaining thumbnails with delay to avoid blocking - setTimeout(() => { - generateRemainingThumbnailsLazily(file, primaryFileId, totalPages, 26); - }, 2000); // 2 second delay before starting background generation - - return; - } - - // For smaller documents, check which pages 2+ need thumbnails - const pageNumbersToGenerate = []; - for (let pageNum = 2; pageNum <= totalPages; pageNum++) { - const pageId = `${primaryFileId}-page-${pageNum}`; - if (!getThumbnailFromCache(pageId)) { - pageNumbersToGenerate.push(pageNum); - } - } - - if (pageNumbersToGenerate.length === 0) { - console.log(`📸 PageEditor: All pages 2+ already cached, skipping generation`); - return; - } - - console.log(`📸 PageEditor: Generating thumbnails for pages: [${pageNumbersToGenerate.slice(0, 5).join(', ')}${pageNumbersToGenerate.length > 5 ? '...' : ''}]`); - await generateThumbnailBatch(file, primaryFileId, pageNumbersToGenerate); - }, [mergedPdfDocument, primaryFileId, activeFileIds, selectors]); - - // Helper function to generate thumbnails in batches - const generateThumbnailBatch = useCallback(async (file: File, fileId: string, pageNumbers: number[]) => { - try { - // Load PDF array buffer for Web Workers - const arrayBuffer = await file.arrayBuffer(); - - // Calculate quality scale based on file size - const scale = calculateScaleFromFileSize(selectors.getFileRecord(fileId)?.size || 0); - - // Start parallel thumbnail generation WITHOUT blocking the main thread - await generateThumbnails( - fileId, // Add fileId as first parameter - arrayBuffer, - pageNumbers, - { - scale, // Dynamic quality based on file size - quality: 0.8, - batchSize: 15, // Smaller batches per worker for smoother UI - parallelBatches: 3 // Use 3 Web Workers in parallel - }, - // Progress callback for thumbnail updates - (progress: { completed: number; total: number; thumbnails: Array<{ pageNumber: number; thumbnail: string }> }) => { - // Batch process thumbnails to reduce main thread work - requestAnimationFrame(() => { - progress.thumbnails.forEach(({ pageNumber, thumbnail }: { pageNumber: number; thumbnail: string }) => { - // Use stable fileId for cache key - const pageId = `${fileId}-page-${pageNumber}`; - addThumbnailToCache(pageId, thumbnail); - - // Don't update context state - thumbnails stay in cache only - // This eliminates per-page context rerenders - // PageThumbnail will find thumbnails via cache polling - }); - }); - } - ); - - // Removed verbose logging - only log errors - } catch (error) { - console.error('PageEditor: Thumbnail generation failed:', error); - } - }, [generateThumbnails, addThumbnailToCache, selectors]); - - // Background generation for remaining pages in very large documents - const generateRemainingThumbnailsLazily = useCallback(async (file: File, fileId: string, totalPages: number, startPage: number) => { - console.log(`📸 PageEditor: Starting background thumbnail generation from page ${startPage} to ${totalPages}`); - - // Generate in small chunks to avoid blocking - const CHUNK_SIZE = 50; - for (let start = startPage; start <= totalPages; start += CHUNK_SIZE) { - const end = Math.min(start + CHUNK_SIZE - 1, totalPages); - const chunkPageNumbers = []; - - for (let pageNum = start; pageNum <= end; pageNum++) { - const pageId = `${fileId}-page-${pageNum}`; - if (!getThumbnailFromCache(pageId)) { - chunkPageNumbers.push(pageNum); - } - } - - if (chunkPageNumbers.length > 0) { - // Background thumbnail generation in progress (removed verbose logging) - await generateThumbnailBatch(file, fileId, chunkPageNumbers); - - // Small delay between chunks to keep UI responsive - await new Promise(resolve => setTimeout(resolve, 500)); - } - } - - console.log(`📸 PageEditor: Background thumbnail generation completed for ${totalPages} pages`); - }, [getThumbnailFromCache, generateThumbnailBatch]); - - // Simple useEffect - just generate missing thumbnails when document is ready - useEffect(() => { - if (mergedPdfDocument && mergedPdfDocument.totalPages > 1) { - console.log(`📸 PageEditor: Document ready with ${mergedPdfDocument.totalPages} pages, checking for missing thumbnails`); - generateMissingThumbnails(); - } - }, [mergedPdfDocument, generateMissingThumbnails]); - - // Cleanup thumbnail generation when component unmounts - useEffect(() => { - return () => { - // Stop all PDF.js background processing on unmount - if (stopGeneration) { - stopGeneration(); - } - if (destroyThumbnails) { - destroyThumbnails(); - } - // Stop all processing services and destroy workers - enhancedPDFProcessingService.emergencyCleanup(); - fileProcessingService.emergencyCleanup(); - pdfProcessingService.clearAll(); - // Final emergency cleanup of all workers - pdfWorkerManager.emergencyCleanup(); - }; - }, [stopGeneration, destroyThumbnails]); - - // Clear selections when files change - use stable signature - useEffect(() => { - actions.setSelectedPages([]); - setCsvInput(""); - setSelectionMode(false); - }, [filesSignature, actions]); - - // Sync csvInput with selectedPageNumbers changes - useEffect(() => { - // Simply sort the page numbers and join them - const sortedPageNumbers = [...selectedPageNumbers].sort((a, b) => a - b); - const newCsvInput = sortedPageNumbers.join(', '); - setCsvInput(newCsvInput); - }, [selectedPageNumbers]); - - - const selectAll = useCallback(() => { - if (mergedPdfDocument) { - actions.setSelectedPages(mergedPdfDocument.pages.map(p => p.pageNumber)); - } - }, [mergedPdfDocument, actions]); - - const deselectAll = useCallback(() => actions.setSelectedPages([]), [actions]); - - const togglePage = useCallback((pageNumber: number) => { - console.log('🔄 Toggling page', pageNumber); - - - // Check if currently selected and update accordingly - const isCurrentlySelected = selectedPageNumbers.includes(pageNumber); - - - if (isCurrentlySelected) { - // Remove from selection - console.log('🔄 Removing page', pageNumber); - const newSelectedPageNumbers = selectedPageNumbers.filter(num => num !== pageNumber); - actions.setSelectedPages(newSelectedPageNumbers); - } else { - // Add to selection - console.log('🔄 Adding page', pageNumber); - const newSelectedPageNumbers = [...selectedPageNumbers, pageNumber]; - actions.setSelectedPages(newSelectedPageNumbers); - } - }, [selectedPageNumbers, actions]); - - const toggleSelectionMode = useCallback(() => { - setSelectionMode(prev => { - const newMode = !prev; - if (!newMode) { - // Clear selections when exiting selection mode - actions.setSelectedPages([]); - setCsvInput(""); - } - return newMode; - }); + // Update undo/redo state + const updateUndoRedoState = useCallback(() => { + setCanUndo(undoManagerRef.current.canUndo()); + setCanRedo(undoManagerRef.current.canRedo()); }, []); - const parseCSVInput = useCallback((csv: string) => { - if (!mergedPdfDocument) return []; + // Set up undo manager callback + useEffect(() => { + undoManagerRef.current.setStateChangeCallback(updateUndoRedoState); + // Initialize state + updateUndoRedoState(); + }, [updateUndoRedoState]); - const pageNumbers: number[] = []; - const ranges = csv.split(',').map(s => s.trim()).filter(Boolean); + // Watch for container size changes to update split line positions + useEffect(() => { + const container = gridContainerRef.current; + if (!container) return; - ranges.forEach(range => { - if (range.includes('-')) { - const [start, end] = range.split('-').map(n => parseInt(n.trim())); - for (let i = start; i <= end && i <= mergedPdfDocument.pages.length; i++) { - if (i > 0) { - pageNumbers.push(i); - } - } - } else { - const pageNum = parseInt(range); - if (pageNum > 0 && pageNum <= mergedPdfDocument.pages.length) { - pageNumbers.push(pageNum); - } - } - }); - - return pageNumbers; - }, [mergedPdfDocument]); - - const updatePagesFromCSV = useCallback(() => { - const pageNumbers = parseCSVInput(csvInput); - actions.setSelectedPages(pageNumbers); - }, [csvInput, parseCSVInput, actions]); - - - - - // Update PDF document state with edit tracking - const setPdfDocument = useCallback((updatedDoc: PDFDocument) => { - console.log('setPdfDocument called - setting edited state'); - - - // Update local edit state for immediate visual feedback - setEditedDocument(updatedDoc); - actions.setHasUnsavedChanges(true); // Use actions from context - setHasUnsavedDraft(true); // Mark that we have unsaved draft changes - - - // Auto-save to drafts (debounced) - only if we have new changes - - // Enhanced auto-save to drafts with proper error handling - if (autoSaveTimer.current) { - clearTimeout(autoSaveTimer.current); - } - - autoSaveTimer.current = window.setTimeout(async () => { - if (hasUnsavedDraft) { - try { - await saveDraftToIndexedDB(updatedDoc); - setHasUnsavedDraft(false); // Mark draft as saved - console.log('Auto-save completed successfully'); - } catch (error) { - console.warn('Auto-save failed, will retry on next change:', error); - // Don't set hasUnsavedDraft to false so it will retry - } - } - }, 30000); // Auto-save after 30 seconds of inactivity - - - return updatedDoc; - }, [actions, hasUnsavedDraft]); - - // Enhanced draft save using centralized IndexedDB manager - const saveDraftToIndexedDB = useCallback(async (doc: PDFDocument) => { - const draftKey = `draft-${doc.id || 'merged'}`; - - try { - // Export the current document state as PDF bytes - const exportedFile = await pdfExportService.exportPDF(doc, []); - const pdfBytes = 'blob' in exportedFile ? await exportedFile.blob.arrayBuffer() : await exportedFile.blobs[0].arrayBuffer(); - const originalFileNames = activeFileIds.map(id => selectors.getFileRecord(id)?.name).filter(Boolean); - - // Generate thumbnail for the draft - let thumbnail: string | undefined; - try { - const { generateThumbnailForFile } = await import('../../utils/thumbnailUtils'); - const blob = 'blob' in exportedFile ? exportedFile.blob : exportedFile.blobs[0]; - const filename = 'filename' in exportedFile ? exportedFile.filename : exportedFile.filenames[0]; - const file = new File([blob], filename, { type: 'application/pdf' }); - thumbnail = await generateThumbnailForFile(file); - } catch (error) { - console.warn('Failed to generate thumbnail for draft:', error); - } - - const draftData = { - id: draftKey, - name: `Draft - ${originalFileNames.join(', ') || 'Untitled'}`, - pdfData: pdfBytes, - size: pdfBytes.byteLength, - timestamp: Date.now(), - thumbnail, - originalFiles: originalFileNames - }; - - // Use centralized IndexedDB manager - const db = await indexedDBManager.openDatabase(DATABASE_CONFIGS.DRAFTS); - const transaction = db.transaction('drafts', 'readwrite'); - const store = transaction.objectStore('drafts'); - - const putRequest = store.put(draftData, draftKey); - putRequest.onsuccess = () => { - console.log('Draft auto-saved to IndexedDB'); - }; - putRequest.onerror = () => { - console.warn('Failed to put draft data:', putRequest.error); - }; - - } catch (error) { - console.warn('Failed to auto-save draft:', error); - } - }, [activeFileIds, selectors]); - - // Enhanced draft cleanup using centralized IndexedDB manager - const cleanupDraft = useCallback(async () => { - const draftKey = `draft-${mergedPdfDocument?.id || 'merged'}`; - - try { - // Use centralized IndexedDB manager - const db = await indexedDBManager.openDatabase(DATABASE_CONFIGS.DRAFTS); - const transaction = db.transaction('drafts', 'readwrite'); - const store = transaction.objectStore('drafts'); - - const deleteRequest = store.delete(draftKey); - deleteRequest.onsuccess = () => { - console.log('Draft cleaned up successfully'); - }; - deleteRequest.onerror = () => { - console.warn('Failed to delete draft:', deleteRequest.error); - }; - - } catch (error) { - console.warn('Failed to cleanup draft:', error); - } - }, [mergedPdfDocument]); - - // Apply changes to create new processed file - const applyChanges = useCallback(async () => { - if (!editedDocument || !mergedPdfDocument) return; - - - try { - if (activeFileIds.length === 1 && primaryFileId) { - const file = selectors.getFile(primaryFileId); - if (!file) return; - - // Apply changes simplified - no complex dispatch loops - setStatus('Changes applied successfully'); - } else if (activeFileIds.length > 1) { - setStatus('Apply changes for multiple files not yet supported'); - return; - } - - // Clear edit state immediately - setEditedDocument(null); - actions.setHasUnsavedChanges(false); - setHasUnsavedDraft(false); - cleanupDraft(); - - } catch (error) { - console.error('Failed to apply changes:', error); - setStatus('Failed to apply changes'); - } - }, [editedDocument, mergedPdfDocument, activeFileIds, primaryFileId, selectors, actions, cleanupDraft]); - - const animateReorder = useCallback((pageNumber: number, targetIndex: number) => { - if (!displayDocument || isAnimating) return; - - // In selection mode, if the dragged page is selected, move all selected pages - const pagesToMove = selectionMode && selectedPageNumbers.includes(pageNumber) - ? selectedPageNumbers.map(num => { - const page = displayDocument.pages.find(p => p.pageNumber === num); - return page?.id || ''; - }).filter(id => id) - : [displayDocument.pages.find(p => p.pageNumber === pageNumber)?.id || ''].filter(id => id); - - const originalIndex = displayDocument.pages.findIndex(p => p.pageNumber === pageNumber); - if (originalIndex === -1 || originalIndex === targetIndex) return; - - // Skip animation for large documents (500+ pages) to improve performance - const isLargeDocument = displayDocument.pages.length > 500; - - - if (isLargeDocument) { - // For large documents, just execute the command without animation - if (pagesToMove.length > 1) { - const command = new MovePagesCommand(displayDocument, setPdfDocument, pagesToMove, targetIndex); - executeCommand(command); - } else { - const pageId = pagesToMove[0]; - const command = new ReorderPageCommand(displayDocument, setPdfDocument, pageId, targetIndex); - executeCommand(command); - } - return; - } - - setIsAnimating(true); - - // For smaller documents, determine which pages might be affected by the move - const startIndex = Math.min(originalIndex, targetIndex); - const endIndex = Math.max(originalIndex, targetIndex); - const affectedPageIds = displayDocument.pages - .slice(Math.max(0, startIndex - 5), Math.min(displayDocument.pages.length, endIndex + 5)) - .map(p => p.id); - - // Only capture positions for potentially affected pages - const currentPositions = new Map(); - - - affectedPageIds.forEach(pageId => { - const element = document.querySelector(`[data-page-number="${pageId}"]`); - if (element) { - const rect = element.getBoundingClientRect(); - currentPositions.set(pageId, { x: rect.left, y: rect.top }); - } - }); - - // Execute the reorder command - if (pagesToMove.length > 1) { - const command = new MovePagesCommand(displayDocument, setPdfDocument, pagesToMove, targetIndex); - executeCommand(command); - } else { - const pageId = pagesToMove[0]; - const command = new ReorderPageCommand(displayDocument, setPdfDocument, pageId, targetIndex); - executeCommand(command); - } - - // Animate only the affected pages - setTimeout(() => { - requestAnimationFrame(() => { - requestAnimationFrame(() => { - const newPositions = new Map(); - - // Get new positions only for affected pages - affectedPageIds.forEach(pageId => { - const element = document.querySelector(`[data-page-number="${pageId}"]`); - if (element) { - const rect = element.getBoundingClientRect(); - newPositions.set(pageId, { x: rect.left, y: rect.top }); - } - }); - - const elementsToAnimate: HTMLElement[] = []; - - // Apply animations only to pages that actually moved - affectedPageIds.forEach(pageId => { - const element = document.querySelector(`[data-page-number="${pageId}"]`) as HTMLElement; - if (!element) return; - - const currentPos = currentPositions.get(pageId); - const newPos = newPositions.get(pageId); - - if (currentPos && newPos) { - const deltaX = currentPos.x - newPos.x; - const deltaY = currentPos.y - newPos.y; - - if (Math.abs(deltaX) > 1 || Math.abs(deltaY) > 1) { - elementsToAnimate.push(element); - - - // Apply initial transform - element.style.transform = `translate(${deltaX}px, ${deltaY}px)`; - element.style.transition = 'none'; - - - // Force reflow - element.offsetHeight; - - - // Animate to final position - element.style.transition = 'transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)'; - element.style.transform = 'translate(0px, 0px)'; - } - } - }); - - // Clean up after animation (only for animated elements) - setTimeout(() => { - elementsToAnimate.forEach((element) => { - element.style.transform = ''; - element.style.transition = ''; - }); - setIsAnimating(false); - }, 300); + const resizeObserver = new ResizeObserver((entries) => { + const entry = entries[0]; + if (entry) { + setContainerDimensions({ + width: entry.contentRect.width, + height: entry.contentRect.height }); - }); - }, 10); // Small delay to allow state update - }, [displayDocument, isAnimating, executeCommand, selectionMode, selectedPageNumbers, setPdfDocument]); + } + }); + + resizeObserver.observe(container); + + return () => { + resizeObserver.disconnect(); + }; + }, []); + + // Interface functions for parent component + const displayDocument = editedDocument || mergedPdfDocument; + + // Select all pages by default when document initially loads + const hasInitializedSelection = useRef(false); + useEffect(() => { + if (displayDocument && displayDocument.pages.length > 0 && !hasInitializedSelection.current) { + const allPageNumbers = Array.from({ length: displayDocument.pages.length }, (_, i) => i + 1); + setSelectedPageNumbers(allPageNumbers); + setSelectionMode(true); + hasInitializedSelection.current = true; + } + }, [displayDocument, setSelectedPageNumbers, setSelectionMode]); + + // DOM-first command handlers + const handleRotatePages = useCallback((pageIds: string[], rotation: number) => { + const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation); + undoManagerRef.current.executeCommand(bulkRotateCommand); + }, []); + + // Command factory functions for PageThumbnail + const createRotateCommand = useCallback((pageIds: string[], rotation: number) => ({ + execute: () => { + const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation); + undoManagerRef.current.executeCommand(bulkRotateCommand); + } + }), []); + + const createDeleteCommand = useCallback((pageIds: string[]) => ({ + execute: () => { + if (!displayDocument) return; + + const pagesToDelete = pageIds.map(pageId => { + const page = displayDocument.pages.find(p => p.id === pageId); + return page?.pageNumber || 0; + }).filter(num => num > 0); + + if (pagesToDelete.length > 0) { + const deleteCommand = new DeletePagesCommand( + pagesToDelete, + () => displayDocument, + setEditedDocument, + setSelectedPageNumbers, + () => splitPositions, + setSplitPositions, + () => selectedPageNumbers + ); + undoManagerRef.current.executeCommand(deleteCommand); + } + } + }), [displayDocument, splitPositions, selectedPageNumbers]); + + const createSplitCommand = useCallback((position: number) => ({ + execute: () => { + const splitCommand = new SplitCommand( + position, + () => splitPositions, + setSplitPositions + ); + undoManagerRef.current.executeCommand(splitCommand); + } + }), [splitPositions]); + + // Command executor for PageThumbnail + const executeCommand = useCallback((command: any) => { + if (command && typeof command.execute === 'function') { + command.execute(); + } + }, []); + + + const handleUndo = useCallback(() => { + undoManagerRef.current.undo(); + }, []); + + const handleRedo = useCallback(() => { + undoManagerRef.current.redo(); + }, []); + + const handleRotate = useCallback((direction: 'left' | 'right') => { + if (!displayDocument || selectedPageNumbers.length === 0) return; + const rotation = direction === 'left' ? -90 : 90; + const pagesToRotate = selectedPageNumbers.map(pageNum => { + const page = displayDocument.pages.find(p => p.pageNumber === pageNum); + return page?.id || ''; + }).filter(id => id); + + handleRotatePages(pagesToRotate, rotation); + }, [displayDocument, selectedPageNumbers, handleRotatePages]); + + const handleDelete = useCallback(() => { + if (!displayDocument || selectedPageNumbers.length === 0) return; + + const deleteCommand = new DeletePagesCommand( + selectedPageNumbers, + () => displayDocument, + setEditedDocument, + setSelectedPageNumbers, + () => splitPositions, + setSplitPositions, + () => selectedPageNumbers + ); + undoManagerRef.current.executeCommand(deleteCommand); + }, [selectedPageNumbers, displayDocument, splitPositions]); + + const handleDeletePage = useCallback((pageNumber: number) => { + if (!displayDocument) return; + + const deleteCommand = new DeletePagesCommand( + [pageNumber], + () => displayDocument, + setEditedDocument, + setSelectedPageNumbers, + () => splitPositions, + setSplitPositions, + () => selectedPageNumbers + ); + undoManagerRef.current.executeCommand(deleteCommand); + }, [displayDocument, splitPositions, selectedPageNumbers]); + + const handleSplit = useCallback(() => { + if (!displayDocument || selectedPageNumbers.length === 0) return; + + // Convert selected page numbers to split positions (0-based indices) + const selectedPositions: number[] = []; + selectedPageNumbers.forEach(pageNum => { + const pageIndex = displayDocument.pages.findIndex(p => p.pageNumber === pageNum); + if (pageIndex !== -1 && pageIndex < displayDocument.pages.length - 1) { + // Only allow splits before the last page + selectedPositions.push(pageIndex); + } + }); + + if (selectedPositions.length === 0) return; + + // Smart toggle logic: follow the majority, default to adding splits if equal + const existingSplitsCount = selectedPositions.filter(pos => splitPositions.has(pos)).length; + const noSplitsCount = selectedPositions.length - existingSplitsCount; + + // Remove splits only if majority already have splits + // If equal (50/50), default to adding splits + const shouldRemoveSplits = existingSplitsCount > noSplitsCount; + + + const newSplitPositions = new Set(splitPositions); + + if (shouldRemoveSplits) { + // Remove splits from all selected positions + selectedPositions.forEach(pos => newSplitPositions.delete(pos)); + } else { + // Add splits to all selected positions + selectedPositions.forEach(pos => newSplitPositions.add(pos)); + } + + // Create a custom command that sets the final state directly + const smartSplitCommand = { + execute: () => setSplitPositions(newSplitPositions), + undo: () => setSplitPositions(splitPositions), + description: shouldRemoveSplits + ? `Remove ${selectedPositions.length} split(s)` + : `Add ${selectedPositions.length - existingSplitsCount} split(s)` + }; + + undoManagerRef.current.executeCommand(smartSplitCommand); + }, [selectedPageNumbers, displayDocument, splitPositions, setSplitPositions]); + + const handleSplitAll = useCallback(() => { + if (!displayDocument || selectedPageNumbers.length === 0) return; + + // Convert selected page numbers to split positions (0-based indices) + const selectedPositions: number[] = []; + selectedPageNumbers.forEach(pageNum => { + const pageIndex = displayDocument.pages.findIndex(p => p.pageNumber === pageNum); + if (pageIndex !== -1 && pageIndex < displayDocument.pages.length - 1) { + // Only allow splits before the last page + selectedPositions.push(pageIndex); + } + }); + + if (selectedPositions.length === 0) return; + + // Smart toggle logic: follow the majority, default to adding splits if equal + const existingSplitsCount = selectedPositions.filter(pos => splitPositions.has(pos)).length; + const noSplitsCount = selectedPositions.length - existingSplitsCount; + + // Remove splits only if majority already have splits + // If equal (50/50), default to adding splits + const shouldRemoveSplits = existingSplitsCount > noSplitsCount; + + const newSplitPositions = new Set(splitPositions); + + if (shouldRemoveSplits) { + // Remove splits from all selected positions + selectedPositions.forEach(pos => newSplitPositions.delete(pos)); + } else { + // Add splits to all selected positions + selectedPositions.forEach(pos => newSplitPositions.add(pos)); + } + + // Create a custom command that sets the final state directly + const smartSplitCommand = { + execute: () => setSplitPositions(newSplitPositions), + undo: () => setSplitPositions(splitPositions), + description: shouldRemoveSplits + ? `Remove ${selectedPositions.length} split(s)` + : `Add ${selectedPositions.length - existingSplitsCount} split(s)` + }; + + undoManagerRef.current.executeCommand(smartSplitCommand); + }, [selectedPageNumbers, displayDocument, splitPositions, setSplitPositions]); + + const handlePageBreak = useCallback(() => { + if (!displayDocument || selectedPageNumbers.length === 0) return; + + const pageBreakCommand = new PageBreakCommand( + selectedPageNumbers, + () => displayDocument, + setEditedDocument, + setSelectedPageNumbers + ); + undoManagerRef.current.executeCommand(pageBreakCommand); + }, [selectedPageNumbers, displayDocument]); + + const handlePageBreakAll = useCallback(() => { + if (!displayDocument || selectedPageNumbers.length === 0) return; + + const pageBreakCommand = new PageBreakCommand( + selectedPageNumbers, + () => displayDocument, + setEditedDocument, + setSelectedPageNumbers + ); + undoManagerRef.current.executeCommand(pageBreakCommand); + }, [selectedPageNumbers, displayDocument]); + + const handleInsertFiles = useCallback(async (files: File[], insertAfterPage: number) => { + if (!displayDocument || files.length === 0) return; + + try { + const targetPage = displayDocument.pages.find(p => p.pageNumber === insertAfterPage); + if (!targetPage) return; + + await actions.addFiles(files, { insertAfterPageId: targetPage.id }); + } catch (error) { + console.error('Failed to insert files:', error); + } + }, [displayDocument, actions]); + + const handleSelectAll = useCallback(() => { + if (!displayDocument) return; + const allPageNumbers = Array.from({ length: displayDocument.pages.length }, (_, i) => i + 1); + setSelectedPageNumbers(allPageNumbers); + }, [displayDocument]); + + const handleDeselectAll = useCallback(() => { + setSelectedPageNumbers([]); + }, []); + + const handleSetSelectedPages = useCallback((pageNumbers: number[]) => { + setSelectedPageNumbers(pageNumbers); + }, []); const handleReorderPages = useCallback((sourcePageNumber: number, targetIndex: number, selectedPages?: number[]) => { if (!displayDocument) return; - const pagesToMove = selectedPages && selectedPages.length > 1 - ? selectedPages - : [sourcePageNumber]; - - const sourceIndex = displayDocument.pages.findIndex(p => p.pageNumber === sourcePageNumber); - if (sourceIndex === -1 || sourceIndex === targetIndex) return; - - animateReorder(sourcePageNumber, targetIndex); - - const moveCount = pagesToMove.length; - setStatus(`${moveCount > 1 ? `${moveCount} pages` : 'Page'} reordered`); - }, [displayDocument, animateReorder]); - - - const handleRotate = useCallback((direction: 'left' | 'right') => { - if (!displayDocument) return; - - const rotation = direction === 'left' ? -90 : 90; - const pagesToRotate = selectionMode - ? selectedPageNumbers.map(pageNum => { - const page = displayDocument.pages.find(p => p.pageNumber === pageNum); - return page?.id || ''; - }).filter(id => id) - : displayDocument.pages.map(p => p.id); - - if (selectionMode && selectedPageNumbers.length === 0) return; - - const command = new RotatePagesCommand( - displayDocument, - setPdfDocument, - pagesToRotate, - rotation + const reorderCommand = new ReorderPagesCommand( + sourcePageNumber, + targetIndex, + selectedPages, + () => displayDocument, + setEditedDocument ); + undoManagerRef.current.executeCommand(reorderCommand); + }, [displayDocument]); - executeCommand(command); - const pageCount = selectionMode ? selectedPageNumbers.length : displayDocument.pages.length; - setStatus(`Rotated ${pageCount} pages ${direction}`); - }, [displayDocument, selectedPageNumbers, selectionMode, executeCommand, setPdfDocument]); + // Helper function to collect source files for multi-file export + const getSourceFiles = useCallback((): Map | null => { + const sourceFiles = new Map(); - const handleDelete = useCallback(() => { - if (!displayDocument) return; + // Always include original files + activeFileIds.forEach(fileId => { + const file = selectors.getFile(fileId); + if (file) { + sourceFiles.set(fileId, file); + } + }); - const hasSelectedPages = selectedPageNumbers.length > 0; - - const pagesToDelete = (selectionMode || hasSelectedPages) - ? selectedPageNumbers - .map(pageNum => { - const page = displayDocument.pages.find(p => p.pageNumber === pageNum); - return page?.id || ''; - }) - .filter(id => id) - : displayDocument.pages.map(p => p.id); - - if ((selectionMode || hasSelectedPages) && selectedPageNumbers.length === 0) return; - - const command = new DeletePagesCommand( - displayDocument, - setPdfDocument, - pagesToDelete - ); - - executeCommand(command); - if (selectionMode) { - actions.setSelectedPages([]); + // Use multi-file export if we have multiple original files + const hasInsertedFiles = false; + const hasMultipleOriginalFiles = activeFileIds.length > 1; + + if (!hasInsertedFiles && !hasMultipleOriginalFiles) { + return null; // Use single-file export method } - const pageCount = (selectionMode || hasSelectedPages) ? selectedPageNumbers.length : displayDocument.pages.length; - setStatus(`Deleted ${pageCount} pages`); - }, [displayDocument, selectedPageNumbers, selectionMode, executeCommand, setPdfDocument, actions]); - const handleSplit = useCallback(() => { - if (!displayDocument) return; + return sourceFiles.size > 0 ? sourceFiles : null; + }, [activeFileIds, selectors]); - const pagesToSplit = selectionMode - ? selectedPageNumbers.map(pageNum => { - const page = displayDocument.pages.find(p => p.pageNumber === pageNum); - return page?.id || ''; - }).filter(id => id) - : displayDocument.pages.map(p => p.id); + // Helper function to generate proper filename for exports + const getExportFilename = useCallback((): string => { + if (activeFileIds.length <= 1) { + // Single file - use original name + return displayDocument?.name || 'document.pdf'; + } - if (selectionMode && selectedPageNumbers.length === 0) return; + // Multiple files - use first file name with " (merged)" suffix + const firstFile = selectors.getFile(activeFileIds[0]); + if (firstFile) { + const baseName = firstFile.name.replace(/\.pdf$/i, ''); + return `${baseName} (merged).pdf`; + } - const command = new ToggleSplitCommand( - displayDocument, - setPdfDocument, - pagesToSplit - ); + return 'merged-document.pdf'; + }, [activeFileIds, selectors, displayDocument]); - executeCommand(command); - const pageCount = selectionMode ? selectedPageNumbers.length : displayDocument.pages.length; - setStatus(`Split markers toggled for ${pageCount} pages`); - }, [displayDocument, selectedPageNumbers, selectionMode, executeCommand, setPdfDocument]); - - const showExportPreview = useCallback((selectedOnly: boolean = false) => { - const doc = editedDocument || mergedPdfDocument; - if (!doc) return; - - // Convert page numbers to page IDs for export service - const exportPageIds = selectedOnly - ? selectedPageNumbers.map(pageNum => { - const page = doc.pages.find(p => p.pageNumber === pageNum); - return page?.id || ''; - }).filter(id => id) - : []; - - const preview = pdfExportService.getExportInfo(doc, exportPageIds, selectedOnly); - setExportPreview(preview); - setExportSelectedOnly(selectedOnly); - setShowExportModal(true); - }, [editedDocument, mergedPdfDocument, selectedPageNumbers]); - - const handleExport = useCallback(async (selectedOnly: boolean = false) => { - const doc = editedDocument || mergedPdfDocument; - if (!doc) return; + const onExportSelected = useCallback(async () => { + if (!displayDocument || selectedPageNumbers.length === 0) return; setExportLoading(true); try { - // Convert page numbers to page IDs for export service - const exportPageIds = selectedOnly - ? selectedPageNumbers.map(pageNum => { - const page = doc.pages.find(p => p.pageNumber === pageNum); - return page?.id || ''; - }).filter(id => id) - : []; + // Step 1: Apply DOM changes to document state first + const processedDocuments = documentManipulationService.applyDOMChangesToDocument( + mergedPdfDocument || displayDocument, // Original order + displayDocument, // Current display order (includes reordering) + splitPositions // Position-based splits + ); + // For selected pages export, we work with the first document (or single document) + const documentWithDOMState = Array.isArray(processedDocuments) ? processedDocuments[0] : processedDocuments; - const errors = pdfExportService.validateExport(doc, exportPageIds, selectedOnly); - if (errors.length > 0) { - setStatus(errors.join(', ')); - return; - } + // Step 2: Convert selected page numbers to page IDs from the document with DOM state + const selectedPageIds = selectedPageNumbers.map(pageNum => { + const page = documentWithDOMState.pages.find(p => p.pageNumber === pageNum); + return page?.id || ''; + }).filter(id => id); - const hasSplitMarkers = doc.pages.some(page => page.splitBefore); + // Step 3: Export with pdfExportService - if (hasSplitMarkers) { - const result = await pdfExportService.exportPDF(doc, exportPageIds, { - selectedOnly, - filename, - splitDocuments: true, - appendSuffix: false - }) as { blobs: Blob[]; filenames: string[] }; + const sourceFiles = getSourceFiles(); + const exportFilename = getExportFilename(); + const result = sourceFiles + ? await pdfExportService.exportPDFMultiFile( + documentWithDOMState, + sourceFiles, + selectedPageIds, + { selectedOnly: true, filename: exportFilename } + ) + : await pdfExportService.exportPDF( + documentWithDOMState, + selectedPageIds, + { selectedOnly: true, filename: exportFilename } + ); - result.blobs.forEach((blob, index) => { - setTimeout(() => { - pdfExportService.downloadFile(blob, result.filenames[index]); - }, index * 500); - }); + // Step 4: Download the result + pdfExportService.downloadFile(result.blob, result.filename); - setStatus(`Exported ${result.blobs.length} split documents`); - } else { - const result = await pdfExportService.exportPDF(doc, exportPageIds, { - selectedOnly, - filename, - appendSuffix: false - }) as { blob: Blob; filename: string }; - - pdfExportService.downloadFile(result.blob, result.filename); - setStatus('PDF exported successfully'); - } + setExportLoading(false); } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Export failed'; - setStatus(errorMessage); - setStatus(errorMessage); - } finally { + console.error('Export failed:', error); setExportLoading(false); } - }, [editedDocument, mergedPdfDocument, selectedPageNumbers, filename]); + }, [displayDocument, selectedPageNumbers, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename]); - const handleUndo = useCallback(() => { - if (undo()) { - setStatus('Operation undone'); - } - }, [undo]); + const onExportAll = useCallback(async () => { + if (!displayDocument) return; - const handleRedo = useCallback(() => { - if (redo()) { - setStatus('Operation redone'); + setExportLoading(true); + try { + // Step 1: Apply DOM changes to document state first + const processedDocuments = documentManipulationService.applyDOMChangesToDocument( + mergedPdfDocument || displayDocument, // Original order + displayDocument, // Current display order (includes reordering) + splitPositions // Position-based splits + ); + + // Step 2: Check if we have multiple documents (splits) or single document + if (Array.isArray(processedDocuments)) { + // Multiple documents (splits) - export as ZIP + const blobs: Blob[] = []; + const filenames: string[] = []; + + const sourceFiles = getSourceFiles(); + const baseExportFilename = getExportFilename(); + const baseName = baseExportFilename.replace(/\.pdf$/i, ''); + + for (let i = 0; i < processedDocuments.length; i++) { + const doc = processedDocuments[i]; + const partFilename = `${baseName}_part_${i + 1}.pdf`; + + const result = sourceFiles + ? await pdfExportService.exportPDFMultiFile(doc, sourceFiles, [], { filename: partFilename }) + : await pdfExportService.exportPDF(doc, [], { filename: partFilename }); + blobs.push(result.blob); + filenames.push(result.filename); + } + + // Create ZIP file + const JSZip = await import('jszip'); + const zip = new JSZip.default(); + + blobs.forEach((blob, index) => { + zip.file(filenames[index], blob); + }); + + const zipBlob = await zip.generateAsync({ type: 'blob' }); + const zipFilename = baseExportFilename.replace(/\.pdf$/i, '.zip'); + + pdfExportService.downloadFile(zipBlob, zipFilename); + } else { + // Single document - regular export + const sourceFiles = getSourceFiles(); + const exportFilename = getExportFilename(); + const result = sourceFiles + ? await pdfExportService.exportPDFMultiFile( + processedDocuments, + sourceFiles, + [], + { selectedOnly: false, filename: exportFilename } + ) + : await pdfExportService.exportPDF( + processedDocuments, + [], + { selectedOnly: false, filename: exportFilename } + ); + + pdfExportService.downloadFile(result.blob, result.filename); + } + + setExportLoading(false); + } catch (error) { + console.error('Export failed:', error); + setExportLoading(false); } - }, [redo]); + }, [displayDocument, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename]); + + // Apply DOM changes to document state using dedicated service + const applyChanges = useCallback(() => { + if (!displayDocument) return; + + // Pass current display document (which includes reordering) to get both reordering AND DOM changes + const processedDocuments = documentManipulationService.applyDOMChangesToDocument( + mergedPdfDocument || displayDocument, // Original order + displayDocument, // Current display order (includes reordering) + splitPositions // Position-based splits + ); + + // For apply changes, we only set the first document if it's an array (splits shouldn't affect document state) + const documentToSet = Array.isArray(processedDocuments) ? processedDocuments[0] : processedDocuments; + setEditedDocument(documentToSet); + + }, [displayDocument, mergedPdfDocument, splitPositions]); + const closePdf = useCallback(() => { - // Stop all PDF.js background processing immediately - if (stopGeneration) { - stopGeneration(); + actions.clearAllFiles(); + undoManagerRef.current.clear(); + setSelectedPageNumbers([]); + setSelectionMode(false); + }, [actions]); + + // Export preview function - defined after export functions to avoid circular dependency + const handleExportPreview = useCallback((selectedOnly: boolean = false) => { + if (!displayDocument) return; + + // For now, trigger the actual export directly + // In the original, this would show a preview modal first + if (selectedOnly) { + onExportSelected(); + } else { + onExportAll(); } - if (destroyThumbnails) { - destroyThumbnails(); - } - // Stop enhanced PDF processing and destroy workers - enhancedPDFProcessingService.emergencyCleanup(); - // Stop file processing service and destroy workers - fileProcessingService.emergencyCleanup(); - // Stop PDF processing service - pdfProcessingService.clearAll(); - // Emergency cleanup - destroy all PDF workers - pdfWorkerManager.emergencyCleanup(); - - // Clear files from memory only (preserves files in storage/recent files) - const allFileIds = selectors.getAllFileIds(); - actions.removeFiles(allFileIds, false); // false = don't delete from storage - actions.setSelectedPages([]); - }, [actions, selectors, stopGeneration, destroyThumbnails]); + }, [displayDocument, onExportSelected, onExportAll]); - // PageEditorControls needs onExportSelected and onExportAll - const onExportSelected = useCallback(() => showExportPreview(true), [showExportPreview]); - const onExportAll = useCallback(() => showExportPreview(false), [showExportPreview]); - - /** - * Stable function proxy pattern to prevent infinite loops. - * - * Problem: If we include selectedPages in useEffect dependencies, every page selection - * change triggers onFunctionsReady → parent re-renders → PageEditor unmounts/remounts → infinite loop - * - * Solution: Create a stable proxy object that uses getters to access current values - * without triggering parent re-renders when values change. - */ - const pageEditorFunctionsRef = useRef({ - handleUndo, handleRedo, canUndo, canRedo, handleRotate, handleDelete, handleSplit, - showExportPreview, onExportSelected, onExportAll, exportLoading, selectionMode, - selectedPages: selectedPageNumbers, closePdf, - }); - - // Update ref with current values (no parent notification) - pageEditorFunctionsRef.current = { - handleUndo, handleRedo, canUndo, canRedo, handleRotate, handleDelete, handleSplit, - showExportPreview, onExportSelected, onExportAll, exportLoading, selectionMode, - selectedPages: selectedPageNumbers, closePdf, - }; - - // Only call onFunctionsReady once - use stable proxy for live updates + // Expose functions to parent component useEffect(() => { if (onFunctionsReady) { - const stableFunctions = { - get handleUndo() { return pageEditorFunctionsRef.current.handleUndo; }, - get handleRedo() { return pageEditorFunctionsRef.current.handleRedo; }, - get canUndo() { return pageEditorFunctionsRef.current.canUndo; }, - get canRedo() { return pageEditorFunctionsRef.current.canRedo; }, - get handleRotate() { return pageEditorFunctionsRef.current.handleRotate; }, - get handleDelete() { return pageEditorFunctionsRef.current.handleDelete; }, - get handleSplit() { return pageEditorFunctionsRef.current.handleSplit; }, - get showExportPreview() { return pageEditorFunctionsRef.current.showExportPreview; }, - get onExportSelected() { return pageEditorFunctionsRef.current.onExportSelected; }, - get onExportAll() { return pageEditorFunctionsRef.current.onExportAll; }, - get exportLoading() { return pageEditorFunctionsRef.current.exportLoading; }, - get selectionMode() { return pageEditorFunctionsRef.current.selectionMode; }, - get selectedPages() { return pageEditorFunctionsRef.current.selectedPages; }, - get closePdf() { return pageEditorFunctionsRef.current.closePdf; }, - }; - onFunctionsReady(stableFunctions); + onFunctionsReady({ + handleUndo, + handleRedo, + canUndo, + canRedo, + handleRotate, + handleDelete, + handleSplit, + handleSplitAll, + handlePageBreak, + handlePageBreakAll, + handleSelectAll, + handleDeselectAll, + handleSetSelectedPages, + showExportPreview: handleExportPreview, + onExportSelected, + onExportAll, + applyChanges, + exportLoading, + selectionMode, + selectedPages: selectedPageNumbers, + splitPositions, + totalPages: displayDocument?.pages.length || 0, + closePdf, + }); } - }, [onFunctionsReady]); - - // Show loading or empty state instead of blocking - const showLoading = !mergedPdfDocument && (globalProcessing || activeFileIds.length > 0); - const showEmpty = !mergedPdfDocument && !globalProcessing && activeFileIds.length === 0; - // Functions for global NavigationWarningModal - const handleApplyAndContinue = useCallback(async () => { - if (editedDocument) { - await applyChanges(); - } - }, [editedDocument, applyChanges]); - - const handleExportAndContinue = useCallback(async () => { - if (editedDocument) { - await applyChanges(); - await handleExport(false); - } - }, [editedDocument, applyChanges, handleExport]); - - // Enhanced draft checking using centralized IndexedDB manager - const checkForDrafts = useCallback(async () => { - if (!mergedPdfDocument) return; - - - try { - const draftKey = `draft-${mergedPdfDocument.id || 'merged'}`; - // Use centralized IndexedDB manager - const db = await indexedDBManager.openDatabase(DATABASE_CONFIGS.DRAFTS); - - // Check if the drafts object store exists before using it - if (!db.objectStoreNames.contains('drafts')) { - console.log('📝 Drafts object store not found, skipping draft check'); - return; - } - - const transaction = db.transaction('drafts', 'readonly'); - const store = transaction.objectStore('drafts'); - const getRequest = store.get(draftKey); - - getRequest.onsuccess = () => { - const draft = getRequest.result; - if (draft && draft.timestamp) { - // Check if draft is recent (within last 24 hours) - const draftAge = Date.now() - draft.timestamp; - const twentyFourHours = 24 * 60 * 60 * 1000; - - if (draftAge < twentyFourHours) { - setFoundDraft(draft); - setShowResumeModal(true); - } - } - }; - - getRequest.onerror = () => { - console.warn('Failed to get draft:', getRequest.error); - }; - - } catch (error) { - console.warn('Draft check failed:', error); - // Don't throw - draft checking failure shouldn't break the app - } - }, [mergedPdfDocument]); - - // Resume work from draft - const resumeWork = useCallback(() => { - if (foundDraft && foundDraft.document) { - setEditedDocument(foundDraft.document); - actions.setHasUnsavedChanges(true); // Use context action - setFoundDraft(null); - setShowResumeModal(false); - setStatus('Resumed previous work'); - } - }, [foundDraft, actions]); - - // Start fresh (ignore draft) - const startFresh = useCallback(() => { - if (foundDraft) { - // Clean up the draft - cleanupDraft(); - } - setFoundDraft(null); - setShowResumeModal(false); - }, [foundDraft, cleanupDraft]); - - // Cleanup on unmount - useEffect(() => { - return () => { - - // Clear auto-save timer - if (autoSaveTimer.current) { - clearTimeout(autoSaveTimer.current); - } - - - // Note: We intentionally do NOT clean up drafts on unmount - // Drafts should persist when navigating away so users can resume later - }; - }, [hasUnsavedChanges, cleanupDraft]); - - // Check for drafts when document loads - useEffect(() => { - if (mergedPdfDocument && !editedDocument && !hasUnsavedChanges) { - // Small delay to let the component settle - setTimeout(checkForDrafts, 1000); - } - }, [mergedPdfDocument, editedDocument, hasUnsavedChanges, checkForDrafts]); - - // Global navigation intercept - listen for navigation events - useEffect(() => { - if (!hasUnsavedChanges) return; - - const handleBeforeUnload = (e: BeforeUnloadEvent) => { - e.preventDefault(); - e.returnValue = 'You have unsaved changes. Are you sure you want to leave?'; - return 'You have unsaved changes. Are you sure you want to leave?'; - }; - - // Intercept browser navigation - window.addEventListener('beforeunload', handleBeforeUnload); - - return () => { - window.removeEventListener('beforeunload', handleBeforeUnload); - }; - }, [hasUnsavedChanges]); + }, [ + onFunctionsReady, handleUndo, handleRedo, canUndo, canRedo, handleRotate, handleDelete, handleSplit, handleSplitAll, + handlePageBreak, handlePageBreakAll, handleSelectAll, handleDeselectAll, handleSetSelectedPages, handleExportPreview, onExportSelected, onExportAll, applyChanges, exportLoading, + selectionMode, selectedPageNumbers, splitPositions, displayDocument?.pages.length, closePdf + ]); // Display all pages - use edited or original document const displayedPages = displayDocument?.pages || []; return ( - + - {showEmpty && ( + {!mergedPdfDocument && !globalProcessing && activeFileIds.length === 0 && (
📄 @@ -1192,248 +675,115 @@ const PageEditor = ({
)} - {showLoading && ( + {!mergedPdfDocument && globalProcessing && ( - - - {/* Progress indicator */} - - - - Processing PDF files... - - - {Math.round(processingProgress || 0)}% - - -
-
-
- - - )} {displayDocument && ( - - {/* Enhanced Processing Status */} - {globalProcessing && processingProgress < 100 && ( - - - Processing thumbnails... - {Math.round(processingProgress || 0)}% - -
-
-
- - )} - setFilename(e.target.value)} - placeholder="Enter filename" - style={{ minWidth: 200, maxWidth: 200, marginLeft: "1rem"}} - /> + - ( - - )} - renderSplitMarker={(page, index) => ( -
- )} - /> + {/* Split Lines Overlay */} +
+ {(() => { + // Calculate remToPx once outside the map to avoid layout thrashing + const containerWidth = containerDimensions.width; + const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize); + const ITEM_WIDTH = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx; + const ITEM_HEIGHT = parseFloat(GRID_CONSTANTS.ITEM_HEIGHT) * remToPx; + const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx; + + return Array.from(splitPositions).map((position) => { + + // Calculate items per row using DragDropGrid's logic + const availableWidth = containerWidth - ITEM_GAP; // Account for first gap + const itemWithGap = ITEM_WIDTH + ITEM_GAP; + const itemsPerRow = Math.max(1, Math.floor(availableWidth / itemWithGap)); + + // Calculate position within the grid (same as DragDropGrid) + const row = Math.floor(position / itemsPerRow); + const col = position % itemsPerRow; + + // Position split line between pages (after the current page) + // Calculate grid centering offset (same as DragDropGrid) + const gridWidth = itemsPerRow * ITEM_WIDTH + (itemsPerRow - 1) * ITEM_GAP; + const gridOffset = Math.max(0, (containerWidth - gridWidth) / 2); + + const leftPosition = gridOffset + col * itemWithGap + ITEM_WIDTH + (ITEM_GAP / 2); + const topPosition = row * ITEM_HEIGHT + (ITEM_HEIGHT * 0.05); // Center vertically (5% offset since page is 90% height) + + return ( +
+ ); + }); + })()} +
+ + {/* Pages Grid */} + ( + {}} + onSetMovingPage={setMovingPage} + onDeletePage={handleDeletePage} + createRotateCommand={createRotateCommand} + createDeleteCommand={createDeleteCommand} + createSplitCommand={createSplitCommand} + pdfDocument={displayDocument} + setPdfDocument={setEditedDocument} + splitPositions={splitPositions} + onInsertFiles={handleInsertFiles} + /> + )} + /> )} - {/* Modal should be outside the conditional but inside the main container */} - setShowExportModal(false)} - title="Export Preview" - > - {exportPreview && ( - - - Pages to export: - {exportPreview.pageCount} - - {exportPreview.splitCount > 1 && ( - - Split into documents: - {exportPreview.splitCount} - - )} - - - Estimated size: - {exportPreview.estimatedSize} - - - {mergedPdfDocument && mergedPdfDocument.pages.some(p => p.splitBefore) && ( - - This will create multiple PDF files based on split markers. - - )} - - - - - - - )} - - - {/* Global Navigation Warning Modal */} - - - {/* Resume Work Modal */} - - - - We found unsaved changes from a previous session. Would you like to resume where you left off? - - - - {foundDraft && ( - - Last saved: {new Date(foundDraft.timestamp).toLocaleString()} - - )} - - - - - - - - - - - - {status && ( - - setStatus(null)} - style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 10000 }} - > - {status} - - - )} - - {error && ( - setError(null)} - style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }} - > - {error} - - )} + ); }; diff --git a/frontend/src/components/pageEditor/PageEditorControls.tsx b/frontend/src/components/pageEditor/PageEditorControls.tsx index 2b0c6ee3c..3789f715a 100644 --- a/frontend/src/components/pageEditor/PageEditorControls.tsx +++ b/frontend/src/components/pageEditor/PageEditorControls.tsx @@ -8,6 +8,10 @@ import RedoIcon from "@mui/icons-material/Redo"; import ContentCutIcon from "@mui/icons-material/ContentCut"; import RotateLeftIcon from "@mui/icons-material/RotateLeft"; import RotateRightIcon from "@mui/icons-material/RotateRight"; +import DeleteIcon from "@mui/icons-material/Delete"; +import CloseIcon from "@mui/icons-material/Close"; +import InsertPageBreakIcon from "@mui/icons-material/InsertPageBreak"; +import DownloadIcon from "@mui/icons-material/Download"; interface PageEditorControlsProps { // Close/Reset functions @@ -23,27 +27,81 @@ interface PageEditorControlsProps { onRotate: (direction: 'left' | 'right') => void; onDelete: () => void; onSplit: () => void; + onSplitAll: () => void; + onPageBreak: () => void; + onPageBreakAll: () => void; - // Export functions - onExportSelected: () => void; + // Export functions (moved to right rail) onExportAll: () => void; exportLoading: boolean; // Selection state selectionMode: boolean; selectedPages: number[]; + + // Split state (for tooltip logic) + splitPositions?: Set; + totalPages?: number; } const PageEditorControls = ({ + onClosePdf, onUndo, onRedo, canUndo, canRedo, onRotate, + onDelete, onSplit, + onSplitAll, + onPageBreak, + onPageBreakAll, + onExportAll, + exportLoading, selectionMode, - selectedPages + selectedPages, + splitPositions, + totalPages }: PageEditorControlsProps) => { + // Calculate split tooltip text using smart toggle logic + const getSplitTooltip = () => { + if (!splitPositions || !totalPages || selectedPages.length === 0) { + return "Split Selected"; + } + + // Convert selected pages to split positions (same logic as handleSplit) + const selectedSplitPositions = selectedPages.map(pageNum => pageNum - 1).filter(pos => pos < totalPages - 1); + + if (selectedSplitPositions.length === 0) { + return "Split Selected"; + } + + // Smart toggle logic: follow the majority, default to adding splits if equal + const existingSplitsCount = selectedSplitPositions.filter(pos => splitPositions.has(pos)).length; + const noSplitsCount = selectedSplitPositions.length - existingSplitsCount; + + // Remove splits only if majority already have splits + // If equal (50/50), default to adding splits + const willRemoveSplits = existingSplitsCount > noSplitsCount; + + if (willRemoveSplits) { + return existingSplitsCount === selectedSplitPositions.length + ? "Remove All Selected Splits" + : "Remove Selected Splits"; + } else { + return existingSplitsCount === 0 + ? "Split Selected" + : "Complete Selected Splits"; + } + }; + + // Calculate page break tooltip text + const getPageBreakTooltip = () => { + return selectedPages.length > 0 + ? `Insert ${selectedPages.length} Page Break${selectedPages.length > 1 ? 's' : ''}` + : "Insert Page Breaks"; + }; + return (
- + - + @@ -96,40 +154,66 @@ const PageEditorControls = ({
{/* Page Operations */} - + onRotate('left')} - disabled={selectionMode && selectedPages.length === 0} - variant={selectionMode && selectedPages.length > 0 ? "light" : "default"} - color={selectionMode && selectedPages.length > 0 ? "blue" : undefined} + disabled={selectedPages.length === 0} + variant="subtle" + style={{ color: 'var(--mantine-color-dimmed)' }} + radius="md" size="lg" > - + onRotate('right')} - disabled={selectionMode && selectedPages.length === 0} - variant={selectionMode && selectedPages.length > 0 ? "light" : "default"} - color={selectionMode && selectedPages.length > 0 ? "blue" : undefined} + disabled={selectedPages.length === 0} + variant="subtle" + style={{ color: 'var(--mantine-color-dimmed)' }} + radius="md" size="lg" > - + + + + + + 0 ? "light" : "default"} - color={selectionMode && selectedPages.length > 0 ? "blue" : undefined} + disabled={selectedPages.length === 0} + variant="subtle" + style={{ color: 'var(--mantine-color-dimmed)' }} + radius="md" size="lg" > - + + + + +
); diff --git a/frontend/src/components/pageEditor/PageThumbnail.tsx b/frontend/src/components/pageEditor/PageThumbnail.tsx index 7360b4dce..c59c601fc 100644 --- a/frontend/src/components/pageEditor/PageThumbnail.tsx +++ b/frontend/src/components/pageEditor/PageThumbnail.tsx @@ -1,43 +1,46 @@ import React, { useCallback, useState, useEffect, useRef } from 'react'; -import { Text, Checkbox, Tooltip, ActionIcon, Loader } from '@mantine/core'; +import { Text, Checkbox, Tooltip, ActionIcon } from '@mantine/core'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; import RotateLeftIcon from '@mui/icons-material/RotateLeft'; import RotateRightIcon from '@mui/icons-material/RotateRight'; import DeleteIcon from '@mui/icons-material/Delete'; import ContentCutIcon from '@mui/icons-material/ContentCut'; +import AddIcon from '@mui/icons-material/Add'; import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import { PDFPage, PDFDocument } from '../../types/pageEditor'; -import { RotatePagesCommand, DeletePagesCommand, ToggleSplitCommand } from '../../commands/pageCommands'; -import { Command } from '../../hooks/useUndoRedo'; -import { useFileState } from '../../contexts/FileContext'; import { useThumbnailGeneration } from '../../hooks/useThumbnailGeneration'; +import { useFilesModalContext } from '../../contexts/FilesModalContext'; import styles from './PageEditor.module.css'; + interface PageThumbnailProps { page: PDFPage; index: number; totalPages: number; - originalFile?: File; // For lazy thumbnail generation + originalFile?: File; selectedPages: number[]; selectionMode: boolean; movingPage: number | null; isAnimating: boolean; pageRefs: React.MutableRefObject>; - onTogglePage: (pageNumber: number) => void; - onAnimateReorder: (pageNumber: number, targetIndex: number) => void; - onExecuteCommand: (command: Command) => void; - onSetStatus: (status: string) => void; - onSetMovingPage: (pageNumber: number | null) => void; onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPages?: number[]) => void; - RotatePagesCommand: typeof RotatePagesCommand; - DeletePagesCommand: typeof DeletePagesCommand; - ToggleSplitCommand: typeof ToggleSplitCommand; + onTogglePage: (pageNumber: number) => void; + onAnimateReorder: () => void; + onExecuteCommand: (command: { execute: () => void }) => void; + onSetStatus: (status: string) => void; + onSetMovingPage: (page: number | null) => void; + onDeletePage: (pageNumber: number) => void; + createRotateCommand: (pageIds: string[], rotation: number) => { execute: () => void }; + createDeleteCommand: (pageIds: string[]) => { execute: () => void }; + createSplitCommand: (position: number) => { execute: () => void }; pdfDocument: PDFDocument; setPdfDocument: (doc: PDFDocument) => void; + splitPositions: Set; + onInsertFiles?: (files: File[], insertAfterPage: number) => void; } -const PageThumbnail = React.memo(({ +const PageThumbnail: React.FC = ({ page, index, totalPages, @@ -47,114 +50,122 @@ const PageThumbnail = React.memo(({ movingPage, isAnimating, pageRefs, + onReorderPages, onTogglePage, onAnimateReorder, onExecuteCommand, onSetStatus, onSetMovingPage, - onReorderPages, - RotatePagesCommand, - DeletePagesCommand, - ToggleSplitCommand, + onDeletePage, + createRotateCommand, + createDeleteCommand, + createSplitCommand, pdfDocument, setPdfDocument, + splitPositions, + onInsertFiles, }: PageThumbnailProps) => { - const [thumbnailUrl, setThumbnailUrl] = useState(page.thumbnail); const [isDragging, setIsDragging] = useState(false); + const [isMouseDown, setIsMouseDown] = useState(false); + const [mouseStartPos, setMouseStartPos] = useState<{x: number, y: number} | null>(null); const dragElementRef = useRef(null); - const { state, selectors } = useFileState(); + const [thumbnailUrl, setThumbnailUrl] = useState(page.thumbnail); const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration(); + const { openFilesModal } = useFilesModalContext(); - // Update thumbnail URL when page prop changes - prevent redundant updates + // Calculate document aspect ratio from first non-blank page + const getDocumentAspectRatio = useCallback(() => { + // Find first non-blank page with a thumbnail to get aspect ratio + const firstRealPage = pdfDocument.pages.find(p => !p.isBlankPage && p.thumbnail); + if (firstRealPage?.thumbnail) { + // Try to get aspect ratio from an actual thumbnail image + // For now, default to A4 but could be enhanced to measure image dimensions + return '1 / 1.414'; // A4 ratio as fallback + } + return '1 / 1.414'; // Default A4 ratio + }, [pdfDocument.pages]); + + // Update thumbnail URL when page prop changes useEffect(() => { if (page.thumbnail && page.thumbnail !== thumbnailUrl) { - console.log(`📸 PageThumbnail: Updating thumbnail URL for page ${page.pageNumber}`, page.thumbnail.substring(0, 50) + '...'); setThumbnailUrl(page.thumbnail); } - }, [page.thumbnail, page.id]); // Remove thumbnailUrl dependency to prevent redundant cycles + }, [page.thumbnail, thumbnailUrl]); - // Request thumbnail generation if not available (optimized for performance) + // Request thumbnail if missing (on-demand, virtualized approach) useEffect(() => { - if (thumbnailUrl || !originalFile) { - return; // Skip if we already have a thumbnail or no original file + let isCancelled = false; + + // If we already have a thumbnail, use it + if (page.thumbnail) { + setThumbnailUrl(page.thumbnail); + return; } - // Check cache first without async call + // Check cache first const cachedThumbnail = getThumbnailFromCache(page.id); if (cachedThumbnail) { setThumbnailUrl(cachedThumbnail); return; } - let cancelled = false; + // Request thumbnail generation if we have the original file + if (originalFile) { + const pageNumber = page.originalPageNumber; - const loadThumbnail = async () => { - 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); - } - } catch (error) { - if (!cancelled) { - console.warn(`📸 PageThumbnail: Failed to load thumbnail for page ${page.pageNumber}:`, error); - } - } - }; + requestThumbnail(page.id, originalFile, pageNumber) + .then(thumbnail => { + if (!isCancelled && thumbnail) { + setThumbnailUrl(thumbnail); + } + }) + .catch(error => { + console.warn(`Failed to generate thumbnail for ${page.id}:`, error); + }); + } - loadThumbnail(); - - // Cleanup function to prevent state updates after unmount return () => { - cancelled = true; + isCancelled = true; }; - }, [page.id, originalFile, requestThumbnail, getThumbnailFromCache]); // Removed thumbnailUrl to prevent loops - + }, [page.id, page.thumbnail, originalFile, getThumbnailFromCache, requestThumbnail]); const pageElementRef = useCallback((element: HTMLDivElement | null) => { if (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] + 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); + onReorderPages(page.pageNumber, targetIndex, undefined); } } } }); element.style.cursor = 'grab'; - - + const dropCleanup = dropTargetForElements({ element, getData: () => ({ @@ -163,7 +174,7 @@ const PageThumbnail = React.memo(({ }), onDrop: ({ source }) => {} }); - + (element as any).__dragCleanup = () => { dragCleanup(); dropCleanup(); @@ -176,15 +187,103 @@ const PageThumbnail = React.memo(({ } }, [page.id, page.pageNumber, pageRefs, selectionMode, selectedPages, pdfDocument.pages, onReorderPages]); + // DOM command handlers + const handleRotateLeft = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + // Use the command system for undo/redo support + const command = createRotateCommand([page.id], -90); + onExecuteCommand(command); + onSetStatus(`Rotated page ${page.pageNumber} left`); + }, [page.id, page.pageNumber, onExecuteCommand, onSetStatus, createRotateCommand]); + + const handleRotateRight = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + // Use the command system for undo/redo support + const command = createRotateCommand([page.id], 90); + onExecuteCommand(command); + onSetStatus(`Rotated page ${page.pageNumber} right`); + }, [page.id, page.pageNumber, onExecuteCommand, onSetStatus, createRotateCommand]); + + const handleDelete = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + onDeletePage(page.pageNumber); + onSetStatus(`Deleted page ${page.pageNumber}`); + }, [page.pageNumber, onDeletePage, onSetStatus]); + + const handleSplit = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + + // Create a command to toggle split at this position + const command = createSplitCommand(index); + onExecuteCommand(command); + + const hasSplit = splitPositions.has(index); + const action = hasSplit ? 'removed' : 'added'; + onSetStatus(`Split marker ${action} after position ${index + 1}`); + }, [index, splitPositions, onExecuteCommand, onSetStatus, createSplitCommand]); + + const handleInsertFileAfter = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + + if (onInsertFiles) { + // Open file manager modal with custom handler for page insertion + openFilesModal({ + insertAfterPage: page.pageNumber, + customHandler: (files: File[], insertAfterPage?: number) => { + if (insertAfterPage !== undefined) { + onInsertFiles(files, insertAfterPage); + } + } + }); + onSetStatus(`Select files to insert after page ${page.pageNumber}`); + } else { + // Fallback to normal file handling + openFilesModal({ insertAfterPage: page.pageNumber }); + onSetStatus(`Select files to insert after page ${page.pageNumber}`); + } + }, [openFilesModal, page.pageNumber, onSetStatus, onInsertFiles]); + + // Handle click vs drag differentiation + const handleMouseDown = useCallback((e: React.MouseEvent) => { + setIsMouseDown(true); + setMouseStartPos({ x: e.clientX, y: e.clientY }); + }, []); + + const handleMouseUp = useCallback((e: React.MouseEvent) => { + if (!isMouseDown || !mouseStartPos) { + setIsMouseDown(false); + setMouseStartPos(null); + return; + } + + // Calculate distance moved + const deltaX = Math.abs(e.clientX - mouseStartPos.x); + const deltaY = Math.abs(e.clientY - mouseStartPos.y); + const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); + + // If mouse moved less than 5 pixels, consider it a click (not a drag) + if (distance < 5 && !isDragging) { + onTogglePage(page.pageNumber); + } + + setIsMouseDown(false); + setMouseStartPos(null); + }, [isMouseDown, mouseStartPos, isDragging, page.pageNumber, onTogglePage]); + + const handleMouseLeave = useCallback(() => { + setIsMouseDown(false); + setMouseStartPos(null); + }, []); return (
{
e.stopPropagation()} + onMouseDown={(e) => { + e.stopPropagation(); + onTogglePage(page.pageNumber); + }} + onMouseUp={(e) => e.stopPropagation()} onDragStart={(e) => { e.preventDefault(); e.stopPropagation(); }} - onClick={(e) => { - e.stopPropagation(); - onTogglePage(page.pageNumber); - }} > { - // onChange is handled by the parent div click + // Selection is handled by container mouseDown }} size="sm" + style={{ pointerEvents: 'none' }} />
} @@ -254,7 +356,23 @@ const PageThumbnail = React.memo(({ justifyContent: 'center' }} > - {thumbnailUrl ? ( + {page.isBlankPage ? ( +
+
+
+ ) : thumbnailUrl ? ( {`Page e.stopPropagation()} + onMouseUp={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} > { e.stopPropagation(); if (index > 0 && !movingPage && !isAnimating) { onSetMovingPage(page.pageNumber); - onAnimateReorder(page.pageNumber, index - 1); - setTimeout(() => onSetMovingPage(null), 500); + // Actually move the page left (swap with previous page) + onReorderPages(page.pageNumber, index - 1); + setTimeout(() => onSetMovingPage(null), 650); onSetStatus(`Moved page ${page.pageNumber} left`); } }} @@ -339,14 +462,15 @@ const PageThumbnail = React.memo(({ { e.stopPropagation(); if (index < totalPages - 1 && !movingPage && !isAnimating) { onSetMovingPage(page.pageNumber); - onAnimateReorder(page.pageNumber, index + 1); - setTimeout(() => onSetMovingPage(null), 500); + // Actually move the page right (swap with next page) + onReorderPages(page.pageNumber, index + 1); + setTimeout(() => onSetMovingPage(null), 650); onSetStatus(`Moved page ${page.pageNumber} right`); } }} @@ -359,18 +483,8 @@ const PageThumbnail = React.memo(({ { - e.stopPropagation(); - const command = new RotatePagesCommand( - pdfDocument, - setPdfDocument, - [page.id], - -90 - ); - onExecuteCommand(command); - onSetStatus(`Rotated page ${page.pageNumber} left`); - }} + style={{ color: 'var(--mantine-color-dimmed)' }} + onClick={handleRotateLeft} > @@ -380,18 +494,8 @@ const PageThumbnail = React.memo(({ { - e.stopPropagation(); - const command = new RotatePagesCommand( - pdfDocument, - setPdfDocument, - [page.id], - 90 - ); - onExecuteCommand(command); - onSetStatus(`Rotated page ${page.pageNumber} right`); - }} + style={{ color: 'var(--mantine-color-dimmed)' }} + onClick={handleRotateRight} > @@ -402,66 +506,41 @@ const PageThumbnail = React.memo(({ size="md" variant="subtle" c="red" - onClick={(e) => { - e.stopPropagation(); - const command = new DeletePagesCommand( - pdfDocument, - setPdfDocument, - [page.id] - ); - onExecuteCommand(command); - onSetStatus(`Deleted page ${page.pageNumber}`); - }} + onClick={handleDelete} > - {index > 0 && ( - + {index < totalPages - 1 && ( + { - e.stopPropagation(); - const command = new ToggleSplitCommand( - pdfDocument, - setPdfDocument, - [page.id] - ); - onExecuteCommand(command); - onSetStatus(`Split marker toggled for page ${page.pageNumber}`); - }} + style={{ color: 'var(--mantine-color-dimmed)' }} + onClick={handleSplit} > )} + + + + + +
+
); -}, (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 - return ( - prevProps.page.id === nextProps.page.id && - prevProps.page.pageNumber === nextProps.page.pageNumber && - prevProps.page.rotation === nextProps.page.rotation && - prevProps.page.thumbnail === nextProps.page.thumbnail && - // Shallow compare selectedPages array for better stability - (prevProps.selectedPages === nextProps.selectedPages || - arraysEqual(prevProps.selectedPages, nextProps.selectedPages)) && - prevProps.selectionMode === nextProps.selectionMode && - prevProps.movingPage === nextProps.movingPage && - prevProps.isAnimating === nextProps.isAnimating - ); -}); +}; export default PageThumbnail; diff --git a/frontend/src/components/pageEditor/commands/pageCommands.ts b/frontend/src/components/pageEditor/commands/pageCommands.ts new file mode 100644 index 000000000..1b7cb0932 --- /dev/null +++ b/frontend/src/components/pageEditor/commands/pageCommands.ts @@ -0,0 +1,892 @@ +import { PDFDocument, PDFPage } from '../../../types/pageEditor'; + +// V1-style DOM-first command system (replaces the old React state commands) +export abstract class DOMCommand { + abstract execute(): void; + abstract undo(): void; + abstract description: string; +} + +export class RotatePageCommand extends DOMCommand { + constructor( + private pageId: string, + private degrees: number + ) { + super(); + } + + execute(): void { + // Only update DOM for immediate visual feedback + const pageElement = document.querySelector(`[data-page-id="${this.pageId}"]`); + if (pageElement) { + const img = pageElement.querySelector('img'); + if (img) { + // Extract current rotation from transform property to match the animated CSS + const currentTransform = img.style.transform || ''; + const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/); + const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0; + const newRotation = currentRotation + this.degrees; + img.style.transform = `rotate(${newRotation}deg)`; + } + } + } + + undo(): void { + // Only update DOM + const pageElement = document.querySelector(`[data-page-id="${this.pageId}"]`); + if (pageElement) { + const img = pageElement.querySelector('img'); + if (img) { + // Extract current rotation from transform property + const currentTransform = img.style.transform || ''; + const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/); + const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0; + const previousRotation = currentRotation - this.degrees; + img.style.transform = `rotate(${previousRotation}deg)`; + } + } + } + + get description(): string { + return `Rotate page ${this.degrees > 0 ? 'right' : 'left'}`; + } +} + +export class DeletePagesCommand extends DOMCommand { + private originalDocument: PDFDocument | null = null; + private originalSplitPositions: Set = new Set(); + private originalSelectedPages: number[] = []; + private hasExecuted: boolean = false; + private pageIdsToDelete: string[] = []; + + constructor( + private pagesToDelete: number[], + private getCurrentDocument: () => PDFDocument | null, + private setDocument: (doc: PDFDocument) => void, + private setSelectedPages: (pages: number[]) => void, + private getSplitPositions: () => Set, + private setSplitPositions: (positions: Set) => void, + private getSelectedPages: () => number[] + ) { + super(); + } + + execute(): void { + const currentDoc = this.getCurrentDocument(); + if (!currentDoc || this.pagesToDelete.length === 0) return; + + // Store complete original state for undo (only on first execution) + if (!this.hasExecuted) { + this.originalDocument = { + ...currentDoc, + pages: currentDoc.pages.map(page => ({...page})) // Deep copy pages + }; + this.originalSplitPositions = new Set(this.getSplitPositions()); + this.originalSelectedPages = [...this.getSelectedPages()]; + + // Convert page numbers to page IDs for stable identification + this.pageIdsToDelete = this.pagesToDelete.map(pageNum => { + const page = currentDoc.pages.find(p => p.pageNumber === pageNum); + return page?.id || ''; + }).filter(id => id); + + this.hasExecuted = true; + } + + // Filter out deleted pages by ID (stable across undo/redo) + const remainingPages = currentDoc.pages.filter(page => + !this.pageIdsToDelete.includes(page.id) + ); + + if (remainingPages.length === 0) return; // Safety check + + // Renumber remaining pages + remainingPages.forEach((page, index) => { + page.pageNumber = index + 1; + }); + + // Update document + const updatedDocument: PDFDocument = { + ...currentDoc, + pages: remainingPages, + totalPages: remainingPages.length, + }; + + // Adjust split positions + const currentSplitPositions = this.getSplitPositions(); + const newPositions = new Set(); + currentSplitPositions.forEach(pos => { + if (pos < remainingPages.length - 1) { + newPositions.add(pos); + } + }); + + // Apply changes + this.setDocument(updatedDocument); + this.setSelectedPages([]); + this.setSplitPositions(newPositions); + } + + undo(): void { + if (!this.originalDocument) return; + + // Simply restore the complete original document state + this.setDocument(this.originalDocument); + this.setSplitPositions(this.originalSplitPositions); + this.setSelectedPages(this.originalSelectedPages); + } + + get description(): string { + return `Delete ${this.pagesToDelete.length} page(s)`; + } +} + +export class ReorderPagesCommand extends DOMCommand { + private originalPages: PDFPage[] = []; + + constructor( + private sourcePageNumber: number, + private targetIndex: number, + private selectedPages: number[] | undefined, + private getCurrentDocument: () => PDFDocument | null, + private setDocument: (doc: PDFDocument) => void + ) { + super(); + } + + execute(): void { + const currentDoc = this.getCurrentDocument(); + if (!currentDoc) return; + + // Store original state for undo + this.originalPages = currentDoc.pages.map(page => ({...page})); + + // Perform the reorder + const sourceIndex = currentDoc.pages.findIndex(p => p.pageNumber === this.sourcePageNumber); + if (sourceIndex === -1) return; + + const newPages = [...currentDoc.pages]; + + if (this.selectedPages && this.selectedPages.length > 1 && this.selectedPages.includes(this.sourcePageNumber)) { + // Multi-page reorder + const selectedPageObjects = this.selectedPages + .map(pageNum => currentDoc.pages.find(p => p.pageNumber === pageNum)) + .filter(page => page !== undefined) as PDFPage[]; + + const remainingPages = newPages.filter(page => !this.selectedPages!.includes(page.pageNumber)); + remainingPages.splice(this.targetIndex, 0, ...selectedPageObjects); + + remainingPages.forEach((page, index) => { + page.pageNumber = index + 1; + }); + + newPages.splice(0, newPages.length, ...remainingPages); + } else { + // Single page reorder + const [movedPage] = newPages.splice(sourceIndex, 1); + newPages.splice(this.targetIndex, 0, movedPage); + + newPages.forEach((page, index) => { + page.pageNumber = index + 1; + }); + } + + const reorderedDocument: PDFDocument = { + ...currentDoc, + pages: newPages, + totalPages: newPages.length, + }; + + this.setDocument(reorderedDocument); + } + + undo(): void { + const currentDoc = this.getCurrentDocument(); + if (!currentDoc || this.originalPages.length === 0) return; + + // Restore original page order + const restoredDocument: PDFDocument = { + ...currentDoc, + pages: this.originalPages, + totalPages: this.originalPages.length, + }; + + this.setDocument(restoredDocument); + } + + get description(): string { + return `Reorder page(s)`; + } +} + +export class SplitCommand extends DOMCommand { + private originalSplitPositions: Set = new Set(); + + constructor( + private position: number, + private getSplitPositions: () => Set, + private setSplitPositions: (positions: Set) => void + ) { + super(); + } + + execute(): void { + // Store original state for undo + this.originalSplitPositions = new Set(this.getSplitPositions()); + + // Toggle the split position + const currentPositions = this.getSplitPositions(); + const newPositions = new Set(currentPositions); + + if (newPositions.has(this.position)) { + newPositions.delete(this.position); + } else { + newPositions.add(this.position); + } + + this.setSplitPositions(newPositions); + } + + undo(): void { + // Restore original split positions + this.setSplitPositions(this.originalSplitPositions); + } + + get description(): string { + const currentPositions = this.getSplitPositions(); + const willAdd = !currentPositions.has(this.position); + return `${willAdd ? 'Add' : 'Remove'} split at position ${this.position + 1}`; + } +} + +export class BulkRotateCommand extends DOMCommand { + private originalRotations: Map = new Map(); + + constructor( + private pageIds: string[], + private degrees: number + ) { + super(); + } + + execute(): void { + this.pageIds.forEach(pageId => { + const pageElement = document.querySelector(`[data-page-id="${pageId}"]`); + if (pageElement) { + const img = pageElement.querySelector('img'); + if (img) { + // Store original rotation for undo (only on first execution) + if (!this.originalRotations.has(pageId)) { + const currentTransform = img.style.transform || ''; + const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/); + const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0; + this.originalRotations.set(pageId, currentRotation); + } + + // Apply rotation using transform to trigger CSS animation + const currentTransform = img.style.transform || ''; + const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/); + const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0; + const newRotation = currentRotation + this.degrees; + img.style.transform = `rotate(${newRotation}deg)`; + } + } + }); + } + + undo(): void { + this.pageIds.forEach(pageId => { + const pageElement = document.querySelector(`[data-page-id="${pageId}"]`); + if (pageElement) { + const img = pageElement.querySelector('img'); + if (img && this.originalRotations.has(pageId)) { + img.style.transform = `rotate(${this.originalRotations.get(pageId)}deg)`; + } + } + }); + } + + get description(): string { + return `Rotate ${this.pageIds.length} page(s) ${this.degrees > 0 ? 'right' : 'left'}`; + } +} + +export class BulkSplitCommand extends DOMCommand { + private originalSplitPositions: Set = new Set(); + + constructor( + private positions: number[], + private getSplitPositions: () => Set, + private setSplitPositions: (positions: Set) => void + ) { + super(); + } + + execute(): void { + // Store original state for undo (only on first execution) + if (this.originalSplitPositions.size === 0) { + this.originalSplitPositions = new Set(this.getSplitPositions()); + } + + // Toggle each position + const currentPositions = new Set(this.getSplitPositions()); + this.positions.forEach(position => { + if (currentPositions.has(position)) { + currentPositions.delete(position); + } else { + currentPositions.add(position); + } + }); + + this.setSplitPositions(currentPositions); + } + + undo(): void { + // Restore original split positions + this.setSplitPositions(this.originalSplitPositions); + } + + get description(): string { + return `Toggle ${this.positions.length} split position(s)`; + } +} + +export class SplitAllCommand extends DOMCommand { + private originalSplitPositions: Set = new Set(); + private allPossibleSplits: Set = new Set(); + + constructor( + private totalPages: number, + private getSplitPositions: () => Set, + private setSplitPositions: (positions: Set) => void + ) { + super(); + // Calculate all possible split positions (between pages, not after last page) + for (let i = 0; i < this.totalPages - 1; i++) { + this.allPossibleSplits.add(i); + } + } + + execute(): void { + // Store original state for undo + this.originalSplitPositions = new Set(this.getSplitPositions()); + + // Check if all splits are already active + const currentSplits = this.getSplitPositions(); + const hasAllSplits = Array.from(this.allPossibleSplits).every(pos => currentSplits.has(pos)); + + if (hasAllSplits) { + // Remove all splits + this.setSplitPositions(new Set()); + } else { + // Add all splits + this.setSplitPositions(this.allPossibleSplits); + } + } + + undo(): void { + // Restore original split positions + this.setSplitPositions(this.originalSplitPositions); + } + + get description(): string { + const currentSplits = this.getSplitPositions(); + const hasAllSplits = Array.from(this.allPossibleSplits).every(pos => currentSplits.has(pos)); + return hasAllSplits ? 'Remove all splits' : 'Split all pages'; + } +} + +export class PageBreakCommand extends DOMCommand { + private insertedPages: PDFPage[] = []; + private originalDocument: PDFDocument | null = null; + + constructor( + private selectedPageNumbers: number[], + private getCurrentDocument: () => PDFDocument | null, + private setDocument: (doc: PDFDocument) => void, + private setSelectedPages: (pages: number[]) => void + ) { + super(); + } + + execute(): void { + const currentDoc = this.getCurrentDocument(); + if (!currentDoc || this.selectedPageNumbers.length === 0) return; + + // Store original state for undo + this.originalDocument = { + ...currentDoc, + pages: currentDoc.pages.map(page => ({...page})) + }; + + // Create new pages array with blank pages inserted + const newPages: PDFPage[] = []; + this.insertedPages = []; + let pageNumberCounter = 1; + + currentDoc.pages.forEach((page, index) => { + // Add the current page + const updatedPage = { ...page, pageNumber: pageNumberCounter++ }; + newPages.push(updatedPage); + + // If this page is selected for page break insertion, add a blank page after it + if (this.selectedPageNumbers.includes(page.pageNumber)) { + const blankPage: PDFPage = { + id: `blank-${Date.now()}-${index}`, + pageNumber: pageNumberCounter++, + originalPageNumber: -1, // Mark as blank page + thumbnail: null, + rotation: 0, + selected: false, + splitAfter: false, + isBlankPage: true // Custom flag for blank pages + }; + newPages.push(blankPage); + this.insertedPages.push(blankPage); + } + }); + + // Update document + const updatedDocument: PDFDocument = { + ...currentDoc, + pages: newPages, + totalPages: newPages.length, + }; + + this.setDocument(updatedDocument); + + // Maintain existing selection by mapping original selected pages to their new positions + const updatedSelection: number[] = []; + this.selectedPageNumbers.forEach(originalPageNum => { + // Find the original page by matching the page ID from the original document + const originalPage = this.originalDocument?.pages[originalPageNum - 1]; + if (originalPage) { + const foundPage = newPages.find(page => page.id === originalPage.id && !page.isBlankPage); + if (foundPage) { + updatedSelection.push(foundPage.pageNumber); + } + } + }); + this.setSelectedPages(updatedSelection); + } + + undo(): void { + if (!this.originalDocument) return; + this.setDocument(this.originalDocument); + } + + get description(): string { + return `Insert ${this.selectedPageNumbers.length} page break(s)`; + } +} + +export class BulkPageBreakCommand extends DOMCommand { + private insertedPages: PDFPage[] = []; + private originalDocument: PDFDocument | null = null; + private originalSelectedPages: number[] = []; + + constructor( + private getCurrentDocument: () => PDFDocument | null, + private setDocument: (doc: PDFDocument) => void, + private setSelectedPages: (pages: number[]) => void, + private getSelectedPages: () => number[] + ) { + super(); + } + + execute(): void { + const currentDoc = this.getCurrentDocument(); + if (!currentDoc) return; + + // Store original selection to restore later + this.originalSelectedPages = this.getSelectedPages(); + + // Store original state for undo + this.originalDocument = { + ...currentDoc, + pages: currentDoc.pages.map(page => ({...page})) + }; + + // Create new pages array with blank pages inserted after each page (except the last) + const newPages: PDFPage[] = []; + this.insertedPages = []; + let pageNumberCounter = 1; + + currentDoc.pages.forEach((page, index) => { + // Add the current page + const updatedPage = { ...page, pageNumber: pageNumberCounter++ }; + newPages.push(updatedPage); + + // Add blank page after each page except the last one + if (index < currentDoc.pages.length - 1) { + const blankPage: PDFPage = { + id: `blank-${Date.now()}-${index}`, + pageNumber: pageNumberCounter++, + originalPageNumber: -1, + thumbnail: null, + rotation: 0, + selected: false, + splitAfter: false, + isBlankPage: true + }; + newPages.push(blankPage); + this.insertedPages.push(blankPage); + } + }); + + // Update document + const updatedDocument: PDFDocument = { + ...currentDoc, + pages: newPages, + totalPages: newPages.length, + }; + + this.setDocument(updatedDocument); + + // Maintain existing selection by mapping original selected pages to their new positions + const updatedSelection: number[] = []; + this.originalSelectedPages.forEach(originalPageNum => { + // Find the original page by matching the page ID from the original document + const originalPage = this.originalDocument?.pages[originalPageNum - 1]; + if (originalPage) { + const foundPage = newPages.find(page => page.id === originalPage.id && !page.isBlankPage); + if (foundPage) { + updatedSelection.push(foundPage.pageNumber); + } + } + }); + this.setSelectedPages(updatedSelection); + } + + undo(): void { + if (!this.originalDocument) return; + this.setDocument(this.originalDocument); + } + + get description(): string { + return `Insert page breaks after all pages`; + } +} + +export class InsertFilesCommand extends DOMCommand { + private insertedPages: PDFPage[] = []; + private originalDocument: PDFDocument | null = null; + private fileDataMap = new Map(); // Store file data for thumbnail generation + private originalProcessedFile: any = null; // Store original ProcessedFile for undo + private insertedFileMap = new Map(); // Store inserted files for export + + constructor( + private files: File[], + private insertAfterPageNumber: number, + private getCurrentDocument: () => PDFDocument | null, + private setDocument: (doc: PDFDocument) => void, + private setSelectedPages: (pages: number[]) => void, + private getSelectedPages: () => number[], + private updateFileContext?: (updatedDocument: PDFDocument, insertedFiles?: Map) => void + ) { + super(); + } + + async execute(): Promise { + const currentDoc = this.getCurrentDocument(); + if (!currentDoc || this.files.length === 0) return; + + // Store original state for undo + this.originalDocument = { + ...currentDoc, + pages: currentDoc.pages.map(page => ({...page})) + }; + + try { + // Process each file to extract pages and wait for all to complete + const allNewPages: PDFPage[] = []; + + // Process all files and wait for their completion + const baseTimestamp = Date.now(); + const extractionPromises = this.files.map(async (file, index) => { + const fileId = `inserted-${file.name}-${baseTimestamp + index}`; + // Store inserted file for export + this.insertedFileMap.set(fileId, file); + // Use base timestamp + index to ensure unique but predictable file IDs + return await this.extractPagesFromFile(file, baseTimestamp + index); + }); + + const extractedPageArrays = await Promise.all(extractionPromises); + + // Flatten all extracted pages + for (const pages of extractedPageArrays) { + allNewPages.push(...pages); + } + + if (allNewPages.length === 0) return; + + // Find insertion point (after the specified page) + const insertIndex = this.insertAfterPageNumber; // Insert after page N means insert at index N + + // Create new pages array with inserted pages + const newPages: PDFPage[] = []; + let pageNumberCounter = 1; + + // Add pages before insertion point + for (let i = 0; i < insertIndex && i < currentDoc.pages.length; i++) { + const page = { ...currentDoc.pages[i], pageNumber: pageNumberCounter++ }; + newPages.push(page); + } + + // Add inserted pages + for (const newPage of allNewPages) { + const insertedPage: PDFPage = { + ...newPage, + pageNumber: pageNumberCounter++, + selected: false, + splitAfter: false + }; + newPages.push(insertedPage); + this.insertedPages.push(insertedPage); + } + + // Add remaining pages after insertion point + for (let i = insertIndex; i < currentDoc.pages.length; i++) { + const page = { ...currentDoc.pages[i], pageNumber: pageNumberCounter++ }; + newPages.push(page); + } + + // Update document + const updatedDocument: PDFDocument = { + ...currentDoc, + pages: newPages, + totalPages: newPages.length, + }; + + this.setDocument(updatedDocument); + + // Update FileContext with the new document structure and inserted files + if (this.updateFileContext) { + this.updateFileContext(updatedDocument, this.insertedFileMap); + } + + // Generate thumbnails for inserted pages (all files should be read by now) + this.generateThumbnailsForInsertedPages(updatedDocument); + + // Maintain existing selection by mapping original selected pages to their new positions + const originalSelection = this.getSelectedPages(); + const updatedSelection: number[] = []; + + originalSelection.forEach(originalPageNum => { + if (originalPageNum <= this.insertAfterPageNumber) { + // Pages before insertion point keep same number + updatedSelection.push(originalPageNum); + } else { + // Pages after insertion point are shifted by number of inserted pages + updatedSelection.push(originalPageNum + allNewPages.length); + } + }); + + this.setSelectedPages(updatedSelection); + + } catch (error) { + console.error('Failed to insert files:', error); + // Revert to original state if error occurs + if (this.originalDocument) { + this.setDocument(this.originalDocument); + } + } + } + + private async generateThumbnailsForInsertedPages(updatedDocument: PDFDocument): Promise { + try { + const { thumbnailGenerationService } = await import('../../../services/thumbnailGenerationService'); + + // Group pages by file ID to generate thumbnails efficiently + const pagesByFileId = new Map(); + + for (const page of this.insertedPages) { + const fileId = page.id.substring(0, page.id.lastIndexOf('-page-')); + if (!pagesByFileId.has(fileId)) { + pagesByFileId.set(fileId, []); + } + pagesByFileId.get(fileId)!.push(page); + } + + // Generate thumbnails for each file + for (const [fileId, pages] of pagesByFileId) { + const arrayBuffer = this.fileDataMap.get(fileId); + + console.log('Generating thumbnails for file:', fileId); + console.log('Pages:', pages.length); + console.log('ArrayBuffer size:', arrayBuffer?.byteLength || 'undefined'); + + if (arrayBuffer && arrayBuffer.byteLength > 0) { + // Extract page numbers for all pages from this file + const pageNumbers = pages.map(page => { + const pageNumMatch = page.id.match(/-page-(\d+)$/); + return pageNumMatch ? parseInt(pageNumMatch[1]) : 1; + }); + + console.log('Generating thumbnails for page numbers:', pageNumbers); + + // Generate thumbnails for all pages from this file at once + const results = await thumbnailGenerationService.generateThumbnails( + fileId, + arrayBuffer, + pageNumbers, + { scale: 0.2, quality: 0.8 } + ); + + console.log('Thumbnail generation results:', results.length, 'thumbnails generated'); + + // Update pages with generated thumbnails + for (let i = 0; i < results.length && i < pages.length; i++) { + const result = results[i]; + const page = pages[i]; + + if (result.success) { + const pageIndex = updatedDocument.pages.findIndex(p => p.id === page.id); + if (pageIndex >= 0) { + updatedDocument.pages[pageIndex].thumbnail = result.thumbnail; + console.log('Updated thumbnail for page:', page.id); + } + } + } + + // Trigger re-render by updating the document + this.setDocument({ ...updatedDocument }); + } else { + console.error('No valid ArrayBuffer found for file ID:', fileId); + } + } + } catch (error) { + console.error('Failed to generate thumbnails for inserted pages:', error); + } + } + + private async extractPagesFromFile(file: File, baseTimestamp: number): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = async (event) => { + try { + const arrayBuffer = event.target?.result as ArrayBuffer; + console.log('File reader onload - arrayBuffer size:', arrayBuffer?.byteLength || 'undefined'); + + if (!arrayBuffer) { + reject(new Error('Failed to read file')); + return; + } + + // Clone the ArrayBuffer before passing to PDF.js (it might consume it) + const clonedArrayBuffer = arrayBuffer.slice(0); + + // Use PDF.js via the worker manager to extract pages + const { pdfWorkerManager } = await import('../../../services/pdfWorkerManager'); + const pdf = await pdfWorkerManager.createDocument(clonedArrayBuffer); + + const pageCount = pdf.numPages; + const pages: PDFPage[] = []; + const fileId = `inserted-${file.name}-${baseTimestamp}`; + + console.log('Original ArrayBuffer size:', arrayBuffer.byteLength); + console.log('Storing ArrayBuffer for fileId:', fileId, 'size:', arrayBuffer.byteLength); + + // Store the original ArrayBuffer for thumbnail generation + this.fileDataMap.set(fileId, arrayBuffer); + + console.log('After storing - fileDataMap size:', this.fileDataMap.size); + console.log('Stored value size:', this.fileDataMap.get(fileId)?.byteLength || 'undefined'); + + for (let i = 1; i <= pageCount; i++) { + const pageId = `${fileId}-page-${i}`; + pages.push({ + id: pageId, + pageNumber: i, // Will be renumbered in execute() + originalPageNumber: i, + thumbnail: null, // Will be generated after insertion + rotation: 0, + selected: false, + splitAfter: false, + isBlankPage: false + }); + } + + // Clean up PDF document + pdfWorkerManager.destroyDocument(pdf); + + resolve(pages); + } catch (error) { + reject(error); + } + }; + reader.onerror = () => reject(new Error('Failed to read file')); + reader.readAsArrayBuffer(file); + }); + } + + undo(): void { + if (!this.originalDocument) return; + this.setDocument(this.originalDocument); + } + + get description(): string { + return `Insert ${this.files.length} file(s) after page ${this.insertAfterPageNumber}`; + } +} + +// Simple undo manager for DOM commands +export class UndoManager { + private undoStack: DOMCommand[] = []; + private redoStack: DOMCommand[] = []; + private onStateChange?: () => void; + + setStateChangeCallback(callback: () => void): void { + this.onStateChange = callback; + } + + executeCommand(command: DOMCommand): void { + command.execute(); + this.undoStack.push(command); + this.redoStack = []; + this.onStateChange?.(); + } + + // For async commands that need to be executed manually + addToUndoStack(command: DOMCommand): void { + this.undoStack.push(command); + this.redoStack = []; + this.onStateChange?.(); + } + + undo(): boolean { + const command = this.undoStack.pop(); + if (command) { + command.undo(); + this.redoStack.push(command); + this.onStateChange?.(); + return true; + } + return false; + } + + redo(): boolean { + const command = this.redoStack.pop(); + if (command) { + command.execute(); + this.undoStack.push(command); + this.onStateChange?.(); + return true; + } + return false; + } + + canUndo(): boolean { + return this.undoStack.length > 0; + } + + canRedo(): boolean { + return this.redoStack.length > 0; + } + + clear(): void { + this.undoStack = []; + this.redoStack = []; + this.onStateChange?.(); + } +} \ No newline at end of file diff --git a/frontend/src/components/pageEditor/constants.ts b/frontend/src/components/pageEditor/constants.ts new file mode 100644 index 000000000..13239d722 --- /dev/null +++ b/frontend/src/components/pageEditor/constants.ts @@ -0,0 +1,8 @@ +// Shared constants for PageEditor grid layout +export const GRID_CONSTANTS = { + ITEM_WIDTH: '20rem', // page width + ITEM_HEIGHT: '21.5rem', // 20rem + 1.5rem gap + ITEM_GAP: '1.5rem', // gap between items + OVERSCAN_SMALL: 4, // Overscan for normal documents + OVERSCAN_LARGE: 8, // Overscan for large documents (>1000 pages) +} as const; \ No newline at end of file diff --git a/frontend/src/components/pageEditor/hooks/usePageDocument.ts b/frontend/src/components/pageEditor/hooks/usePageDocument.ts new file mode 100644 index 000000000..5a9d13f9f --- /dev/null +++ b/frontend/src/components/pageEditor/hooks/usePageDocument.ts @@ -0,0 +1,176 @@ +import { useMemo } from 'react'; +import { useFileState } from '../../../contexts/FileContext'; +import { PDFDocument, PDFPage } from '../../../types/pageEditor'; + +export interface PageDocumentHook { + document: PDFDocument | null; + isVeryLargeDocument: boolean; + isLoading: boolean; +} + +/** + * Hook for managing PDF document state and metadata in PageEditor + * Handles document merging, large document detection, and loading states + */ +export function usePageDocument(): PageDocumentHook { + const { state, selectors } = useFileState(); + + // Prefer IDs + selectors to avoid array identity churn + const activeFileIds = state.files.ids; + const primaryFileId = activeFileIds[0] ?? null; + + // Stable signature for effects (prevents loops) + const filesSignature = selectors.getFilesSignature(); + + // UI state + const globalProcessing = state.ui.isProcessing; + + // Get primary file record outside useMemo to track processedFile changes + const primaryFileRecord = primaryFileId ? selectors.getFileRecord(primaryFileId) : null; + const processedFilePages = primaryFileRecord?.processedFile?.pages; + const processedFileTotalPages = primaryFileRecord?.processedFile?.totalPages; + + // Compute merged document with stable signature (prevents infinite loops) + const mergedPdfDocument = useMemo((): PDFDocument | null => { + if (activeFileIds.length === 0) return null; + + const primaryFile = primaryFileId ? selectors.getFile(primaryFileId) : null; + + // If we have file IDs but no file record, something is wrong - return null to show loading + if (!primaryFileRecord) { + console.log('🎬 PageEditor: No primary file record found, showing loading'); + return null; + } + + const name = + activeFileIds.length === 1 + ? (primaryFileRecord.name ?? 'document.pdf') + : activeFileIds + .map(id => (selectors.getFileRecord(id)?.name ?? 'file').replace(/\.pdf$/i, '')) + .join(' + '); + + // Build page insertion map from files with insertion positions + const insertionMap = new Map(); // insertAfterPageId -> fileIds + const originalFileIds: string[] = []; + + activeFileIds.forEach(fileId => { + const record = selectors.getFileRecord(fileId); + if (record?.insertAfterPageId !== undefined) { + if (!insertionMap.has(record.insertAfterPageId)) { + insertionMap.set(record.insertAfterPageId, []); + } + insertionMap.get(record.insertAfterPageId)!.push(fileId); + } else { + originalFileIds.push(fileId); + } + }); + + // Build pages by interleaving original pages with insertions + let pages: PDFPage[] = []; + let totalPageCount = 0; + + // Helper function to create pages from a file + const createPagesFromFile = (fileId: string, startPageNumber: number): PDFPage[] => { + const fileRecord = selectors.getFileRecord(fileId); + if (!fileRecord) { + return []; + } + + const processedFile = fileRecord.processedFile; + let filePages: PDFPage[] = []; + + if (processedFile?.pages && processedFile.pages.length > 0) { + // Use fully processed pages with thumbnails + filePages = processedFile.pages.map((page, pageIndex) => ({ + id: `${fileId}-${page.pageNumber}`, + pageNumber: startPageNumber + pageIndex, + thumbnail: page.thumbnail || null, + rotation: page.rotation || 0, + selected: false, + splitAfter: page.splitAfter || false, + originalPageNumber: page.originalPageNumber || page.pageNumber || pageIndex + 1, + originalFileId: fileId, + })); + } else if (processedFile?.totalPages) { + // Fallback: create pages without thumbnails but with correct count + filePages = Array.from({ length: processedFile.totalPages }, (_, pageIndex) => ({ + id: `${fileId}-${pageIndex + 1}`, + pageNumber: startPageNumber + pageIndex, + originalPageNumber: pageIndex + 1, + originalFileId: fileId, + rotation: 0, + thumbnail: null, + selected: false, + splitAfter: false, + })); + } + + return filePages; + }; + + // Collect all pages from original files (without renumbering yet) + const originalFilePages: PDFPage[] = []; + originalFileIds.forEach(fileId => { + const filePages = createPagesFromFile(fileId, 1); // Temporary numbering + originalFilePages.push(...filePages); + }); + + // Start with all original pages numbered sequentially + pages = originalFilePages.map((page, index) => ({ + ...page, + pageNumber: index + 1 + })); + + // Process each insertion by finding the page ID and inserting after it + for (const [insertAfterPageId, fileIds] of insertionMap.entries()) { + const targetPageIndex = pages.findIndex(p => p.id === insertAfterPageId); + + if (targetPageIndex === -1) continue; + + // Collect all pages to insert + const allNewPages: PDFPage[] = []; + fileIds.forEach(fileId => { + const insertedPages = createPagesFromFile(fileId, 1); + allNewPages.push(...insertedPages); + }); + + // Insert all new pages after the target page + pages.splice(targetPageIndex + 1, 0, ...allNewPages); + + // Renumber all pages after insertion + pages.forEach((page, index) => { + page.pageNumber = index + 1; + }); + } + + totalPageCount = pages.length; + + if (pages.length === 0) { + return null; + } + + const mergedDoc: PDFDocument = { + id: activeFileIds.join('-'), + name, + file: primaryFile!, + pages, + totalPages: pages.length, + }; + + return mergedDoc; + }, [activeFileIds, primaryFileId, primaryFileRecord, processedFilePages, processedFileTotalPages, selectors, filesSignature]); + + // Large document detection for smart loading + const isVeryLargeDocument = useMemo(() => { + return mergedPdfDocument ? mergedPdfDocument.totalPages > 2000 : false; + }, [mergedPdfDocument?.totalPages]); + + // Loading state + const isLoading = globalProcessing && !mergedPdfDocument; + + return { + document: mergedPdfDocument, + isVeryLargeDocument, + isLoading + }; +} \ No newline at end of file diff --git a/frontend/src/components/pageEditor/hooks/usePageEditorState.ts b/frontend/src/components/pageEditor/hooks/usePageEditorState.ts new file mode 100644 index 000000000..18b0adafb --- /dev/null +++ b/frontend/src/components/pageEditor/hooks/usePageEditorState.ts @@ -0,0 +1,96 @@ +import { useState, useCallback } from 'react'; + +export interface PageEditorState { + // Selection state + selectionMode: boolean; + selectedPageNumbers: number[]; + + // Animation state + movingPage: number | null; + isAnimating: boolean; + + // Split state + splitPositions: Set; + + // Export state + exportLoading: boolean; + + // Actions + setSelectionMode: (mode: boolean) => void; + setSelectedPageNumbers: (pages: number[]) => void; + setMovingPage: (pageNumber: number | null) => void; + setIsAnimating: (animating: boolean) => void; + setSplitPositions: (positions: Set) => void; + setExportLoading: (loading: boolean) => void; + + // Helper functions + togglePage: (pageNumber: number) => void; + toggleSelectAll: (totalPages: number) => void; + animateReorder: () => void; +} + +/** + * Hook for managing PageEditor UI state + * Handles selection, animation, splits, and export states + */ +export function usePageEditorState(): PageEditorState { + // Selection state + const [selectionMode, setSelectionMode] = useState(false); + const [selectedPageNumbers, setSelectedPageNumbers] = useState([]); + + // Animation state + const [movingPage, setMovingPage] = useState(null); + const [isAnimating, setIsAnimating] = useState(false); + + // Split state - position-based split tracking (replaces page-based splitAfter) + const [splitPositions, setSplitPositions] = useState>(new Set()); + + // Export state + const [exportLoading, setExportLoading] = useState(false); + + // Helper functions + const togglePage = useCallback((pageNumber: number) => { + setSelectedPageNumbers(prev => + prev.includes(pageNumber) + ? prev.filter(n => n !== pageNumber) + : [...prev, pageNumber] + ); + }, []); + + const toggleSelectAll = useCallback((totalPages: number) => { + if (!totalPages) return; + + const allPageNumbers = Array.from({ length: totalPages }, (_, i) => i + 1); + setSelectedPageNumbers(prev => + prev.length === allPageNumbers.length ? [] : allPageNumbers + ); + }, []); + + const animateReorder = useCallback(() => { + setIsAnimating(true); + setTimeout(() => setIsAnimating(false), 500); + }, []); + + return { + // State + selectionMode, + selectedPageNumbers, + movingPage, + isAnimating, + splitPositions, + exportLoading, + + // Setters + setSelectionMode, + setSelectedPageNumbers, + setMovingPage, + setIsAnimating, + setSplitPositions, + setExportLoading, + + // Helpers + togglePage, + toggleSelectAll, + animateReorder, + }; +} \ No newline at end of file diff --git a/frontend/src/components/shared/RightRail.tsx b/frontend/src/components/shared/RightRail.tsx index eade0a066..c4b0326b9 100644 --- a/frontend/src/components/shared/RightRail.tsx +++ b/frontend/src/components/shared/RightRail.tsx @@ -45,19 +45,14 @@ export default function RightRail() { } if (currentView === 'pageEditor') { - let totalItems = 0; - fileRecords.forEach(rec => { - const pf = rec.processedFile; - if (pf) { - totalItems += (pf.totalPages as number) || (pf.pages?.length || 0); - } - }); - const selectedCount = Array.isArray(selectedPageNumbers) ? selectedPageNumbers.length : 0; + // Use PageEditor's own state + const totalItems = pageEditorFunctions?.totalPages || 0; + const selectedCount = pageEditorFunctions?.selectedPages?.length || 0; return { totalItems, selectedCount }; } return { totalItems: 0, selectedCount: 0 }; - }, [currentView, activeFiles, fileRecords, selectedFileIds, selectedPageNumbers]); + }, [currentView, activeFiles, selectedFileIds, pageEditorFunctions]); const { totalItems, selectedCount } = getSelectionState(); @@ -70,19 +65,10 @@ export default function RightRail() { } if (currentView === 'pageEditor') { - let totalPages = 0; - fileRecords.forEach(rec => { - const pf = rec.processedFile; - if (pf) { - totalPages += (pf.totalPages as number) || (pf.pages?.length || 0); - } - }); - - if (totalPages > 0) { - setSelectedPages(Array.from({ length: totalPages }, (_, i) => i + 1)); - } + // Use PageEditor's select all function + pageEditorFunctions?.handleSelectAll?.(); } - }, [currentView, state.files.ids, fileRecords, setSelectedFiles, setSelectedPages]); + }, [currentView, state.files.ids, setSelectedFiles, pageEditorFunctions]); const handleDeselectAll = useCallback(() => { if (currentView === 'fileEditor' || currentView === 'viewer') { @@ -90,9 +76,10 @@ export default function RightRail() { return; } if (currentView === 'pageEditor') { - setSelectedPages([]); + // Use PageEditor's deselect all function + pageEditorFunctions?.handleDeselectAll?.(); } - }, [currentView, setSelectedFiles, setSelectedPages]); + }, [currentView, setSelectedFiles, pageEditorFunctions]); const handleExportAll = useCallback(() => { if (currentView === 'fileEditor' || currentView === 'viewer') { @@ -151,24 +138,21 @@ export default function RightRail() { const updatePagesFromCSV = useCallback(() => { const rawPages = parseCSVInput(csvInput); - // Determine max page count from processed records - const maxPages = fileRecords.reduce((sum, rec) => { - const pf = rec.processedFile; - if (!pf) return sum; - return sum + ((pf.totalPages as number) || (pf.pages?.length || 0)); - }, 0); + // Use PageEditor's total pages for validation + const maxPages = pageEditorFunctions?.totalPages || 0; const normalized = Array.from(new Set(rawPages.filter(n => Number.isFinite(n) && n > 0 && n <= maxPages))).sort((a,b)=>a-b); - setSelectedPages(normalized); - }, [csvInput, parseCSVInput, fileRecords, setSelectedPages]); + // Use PageEditor's function to set selected pages + pageEditorFunctions?.handleSetSelectedPages?.(normalized); + }, [csvInput, parseCSVInput, pageEditorFunctions]); - // Sync csvInput with selectedPageNumbers changes + // Sync csvInput with PageEditor's selected pages useEffect(() => { - const sortedPageNumbers = Array.isArray(selectedPageNumbers) - ? [...selectedPageNumbers].sort((a, b) => a - b) + const sortedPageNumbers = Array.isArray(pageEditorFunctions?.selectedPages) + ? [...pageEditorFunctions.selectedPages].sort((a, b) => a - b) : []; const newCsvInput = sortedPageNumbers.join(', '); setCsvInput(newCsvInput); - }, [selectedPageNumbers]); + }, [pageEditorFunctions?.selectedPages]); // Clear CSV input when files change (use stable signature to avoid ref churn) useEffect(() => { @@ -278,7 +262,7 @@ export default function RightRail() {
@@ -299,8 +283,8 @@ export default function RightRail() { variant="subtle" radius="md" className="right-rail-icon" - onClick={() => { pageEditorFunctions?.handleDelete?.(); setSelectedPages([]); }} - disabled={!pageControlsVisible || (Array.isArray(selectedPageNumbers) ? selectedPageNumbers.length === 0 : true)} + onClick={() => { pageEditorFunctions?.handleDelete?.(); }} + disabled={!pageControlsVisible || (pageEditorFunctions?.selectedPages?.length || 0) === 0} aria-label={typeof t === 'function' ? t('rightRail.deleteSelected', 'Delete Selected Pages') : 'Delete Selected Pages'} > @@ -311,6 +295,26 @@ export default function RightRail() { )} + {/* Export Selected Pages - page editor only */} + {pageControlsMounted && ( + +
+
+ { pageEditorFunctions?.onExportSelected?.(); }} + disabled={!pageControlsVisible || (pageEditorFunctions?.selectedPages?.length || 0) === 0 || pageEditorFunctions?.exportLoading} + aria-label={typeof t === 'function' ? t('rightRail.exportSelected', 'Export Selected Pages') : 'Export Selected Pages'} + > + + +
+
+
+ )} + {/* Close (File Editor: Close Selected | Page Editor: Close PDF) */}
diff --git a/frontend/src/components/tools/shared/FileStatusIndicator.tsx b/frontend/src/components/tools/shared/FileStatusIndicator.tsx index ca56b3c22..f3f786a54 100644 --- a/frontend/src/components/tools/shared/FileStatusIndicator.tsx +++ b/frontend/src/components/tools/shared/FileStatusIndicator.tsx @@ -77,7 +77,7 @@ const FileStatusIndicator = ({ openFilesModal()} style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }} > @@ -112,7 +112,7 @@ const FileStatusIndicator = ({ {t("files.selectFromWorkbench", "Select files from the workbench or ") + " "} openFilesModal()} style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '0.25rem' }} > diff --git a/frontend/src/contexts/FileContext.tsx b/frontend/src/contexts/FileContext.tsx index 44adf6b28..0c4ba2584 100644 --- a/frontend/src/contexts/FileContext.tsx +++ b/frontend/src/contexts/FileContext.tsx @@ -73,8 +73,8 @@ function FileContextInner({ }, []); // File operations using unified addFiles helper with persistence - const addRawFiles = useCallback(async (files: File[]): Promise => { - const addedFilesWithIds = await addFiles('raw', { files }, stateRef, filesRef, dispatch, lifecycleManager); + const addRawFiles = useCallback(async (files: File[], options?: { insertAfterPageId?: string }): Promise => { + const addedFilesWithIds = await addFiles('raw', { files, ...options }, stateRef, filesRef, dispatch, lifecycleManager); // Persist to IndexedDB if enabled if (indexedDB && enablePersistence && addedFilesWithIds.length > 0) { @@ -167,7 +167,16 @@ function FileContextInner({ filesRef.current.clear(); dispatch({ type: 'RESET_CONTEXT' }); - // Clear IndexedDB if enabled + // Don't clear IndexedDB automatically - only clear in-memory state + // IndexedDB should only be cleared when explicitly requested by user + }, + clearAllData: async () => { + // First clear all files from memory + lifecycleManager.cleanupAllFiles(); + filesRef.current.clear(); + dispatch({ type: 'RESET_CONTEXT' }); + + // Then clear IndexedDB storage if (indexedDB && enablePersistence) { try { await indexedDB.clearAll(); diff --git a/frontend/src/contexts/FilesModalContext.tsx b/frontend/src/contexts/FilesModalContext.tsx index d7183eabf..2b210ce89 100644 --- a/frontend/src/contexts/FilesModalContext.tsx +++ b/frontend/src/contexts/FilesModalContext.tsx @@ -4,7 +4,7 @@ import { FileMetadata } from '../types/file'; interface FilesModalContextType { isFilesModalOpen: boolean; - openFilesModal: () => void; + openFilesModal: (options?: { insertAfterPage?: number; customHandler?: (files: File[], insertAfterPage?: number) => void }) => void; closeFilesModal: () => void; onFileSelect: (file: File) => void; onFilesSelect: (files: File[]) => void; @@ -19,30 +19,55 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch const { addToActiveFiles, addMultipleFiles, addStoredFiles } = useFileHandler(); const [isFilesModalOpen, setIsFilesModalOpen] = useState(false); const [onModalClose, setOnModalClose] = useState<(() => void) | undefined>(); + const [insertAfterPage, setInsertAfterPage] = useState(); + const [customHandler, setCustomHandler] = useState<((files: File[], insertAfterPage?: number) => void) | undefined>(); - const openFilesModal = useCallback(() => { + const openFilesModal = useCallback((options?: { insertAfterPage?: number; customHandler?: (files: File[], insertAfterPage?: number) => void }) => { + setInsertAfterPage(options?.insertAfterPage); + setCustomHandler(() => options?.customHandler); setIsFilesModalOpen(true); }, []); const closeFilesModal = useCallback(() => { setIsFilesModalOpen(false); + setInsertAfterPage(undefined); // Clear insertion position + setCustomHandler(undefined); // Clear custom handler onModalClose?.(); }, [onModalClose]); const handleFileSelect = useCallback((file: File) => { - addToActiveFiles(file); + if (customHandler) { + // Use custom handler for special cases (like page insertion) + customHandler([file], insertAfterPage); + } else { + // Use normal file handling + addToActiveFiles(file); + } closeFilesModal(); - }, [addToActiveFiles, closeFilesModal]); + }, [addToActiveFiles, closeFilesModal, insertAfterPage, customHandler]); const handleFilesSelect = useCallback((files: File[]) => { - addMultipleFiles(files); + if (customHandler) { + // Use custom handler for special cases (like page insertion) + customHandler(files, insertAfterPage); + } else { + // Use normal file handling + addMultipleFiles(files); + } closeFilesModal(); - }, [addMultipleFiles, closeFilesModal]); + }, [addMultipleFiles, closeFilesModal, insertAfterPage, customHandler]); const handleStoredFilesSelect = useCallback((filesWithMetadata: Array<{ file: File; originalId: string; metadata: FileMetadata }>) => { - addStoredFiles(filesWithMetadata); + if (customHandler) { + // Use custom handler for special cases (like page insertion) + const files = filesWithMetadata.map(item => item.file); + customHandler(files, insertAfterPage); + } else { + // Use normal file handling + addStoredFiles(filesWithMetadata); + } closeFilesModal(); - }, [addStoredFiles, closeFilesModal]); + }, [addStoredFiles, closeFilesModal, insertAfterPage, customHandler]); const setModalCloseCallback = useCallback((callback: () => void) => { setOnModalClose(() => callback); diff --git a/frontend/src/contexts/file/fileActions.ts b/frontend/src/contexts/file/fileActions.ts index 948b4f011..c74fa66a5 100644 --- a/frontend/src/contexts/file/fileActions.ts +++ b/frontend/src/contexts/file/fileActions.ts @@ -84,6 +84,9 @@ interface AddFileOptions { // For 'stored' files filesWithMetadata?: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>; + + // Insertion position + insertAfterPageId?: string; } /** @@ -164,6 +167,11 @@ export async function addFiles( } } + // Store insertion position if provided + if (options.insertAfterPageId !== undefined) { + record.insertAfterPageId = options.insertAfterPageId; + } + // Create initial processedFile metadata with page count if (pageCount > 0) { record.processedFile = createProcessedFile(pageCount, thumbnail); @@ -201,6 +209,11 @@ export async function addFiles( } } + // Store insertion position if provided + if (options.insertAfterPageId !== undefined) { + record.insertAfterPageId = options.insertAfterPageId; + } + // Create processedFile with provided metadata if (pageCount > 0) { record.processedFile = createProcessedFile(pageCount, thumbnail); @@ -271,6 +284,11 @@ export async function addFiles( } } + // Store insertion position if provided + if (options.insertAfterPageId !== undefined) { + record.insertAfterPageId = options.insertAfterPageId; + } + // Create processedFile metadata with correct page count if (pageCount > 0) { record.processedFile = createProcessedFile(pageCount, metadata.thumbnail); diff --git a/frontend/src/hooks/usePDFProcessor.ts b/frontend/src/hooks/usePDFProcessor.ts index a35b777ca..ab3b5e007 100644 --- a/frontend/src/hooks/usePDFProcessor.ts +++ b/frontend/src/hooks/usePDFProcessor.ts @@ -77,6 +77,7 @@ export function usePDFProcessor() { pages.push({ id: `${file.name}-page-${i}`, pageNumber: i, + originalPageNumber: i, thumbnail: null, // Will be loaded lazily rotation: 0, selected: false diff --git a/frontend/src/services/documentManipulationService.ts b/frontend/src/services/documentManipulationService.ts new file mode 100644 index 000000000..6ff0eb87e --- /dev/null +++ b/frontend/src/services/documentManipulationService.ts @@ -0,0 +1,176 @@ +import { PDFDocument, PDFPage } from '../types/pageEditor'; + +/** + * Service for applying DOM changes to PDF document state + * Reads current DOM state and updates the document accordingly + */ +export class DocumentManipulationService { + /** + * Apply all DOM changes (rotations, splits, reordering) to document state + * Returns single document or multiple documents if splits are present + */ + applyDOMChangesToDocument(pdfDocument: PDFDocument, currentDisplayOrder?: PDFDocument, splitPositions?: Set): PDFDocument | PDFDocument[] { + console.log('DocumentManipulationService: Applying DOM changes to document'); + console.log('Original document page order:', pdfDocument.pages.map(p => p.pageNumber)); + console.log('Current display order:', currentDisplayOrder?.pages.map(p => p.pageNumber) || 'none provided'); + console.log('Split positions:', splitPositions ? Array.from(splitPositions).sort() : 'none'); + + // Use current display order (from React state) if provided, otherwise use original order + const baseDocument = currentDisplayOrder || pdfDocument; + console.log('Using page order:', baseDocument.pages.map(p => p.pageNumber)); + + // Apply DOM changes to each page (rotation only now, splits are position-based) + let updatedPages = baseDocument.pages.map(page => this.applyPageChanges(page)); + + // Convert position-based splits to page-based splits for export + if (splitPositions && splitPositions.size > 0) { + updatedPages = updatedPages.map((page, index) => ({ + ...page, + splitAfter: splitPositions.has(index) + })); + } + + // Create final document with reordered pages and applied changes + const finalDocument = { + ...pdfDocument, // Use original document metadata but updated pages + pages: updatedPages // Use reordered pages with applied changes + }; + + // Check for splits and return multiple documents if needed + if (splitPositions && splitPositions.size > 0) { + return this.createSplitDocuments(finalDocument); + } + + return finalDocument; + } + + /** + * Check if document has split markers + */ + private hasSplitMarkers(document: PDFDocument): boolean { + return document.pages.some(page => page.splitAfter); + } + + /** + * Create multiple documents from split markers + */ + private createSplitDocuments(document: PDFDocument): PDFDocument[] { + const documents: PDFDocument[] = []; + const splitPoints: number[] = []; + + // Find split points + document.pages.forEach((page, index) => { + if (page.splitAfter) { + console.log(`Found split marker at page ${page.pageNumber} (index ${index}), adding split point at ${index + 1}`); + splitPoints.push(index + 1); + } + }); + + // Add end point if not already there + if (splitPoints.length === 0 || splitPoints[splitPoints.length - 1] !== document.pages.length) { + splitPoints.push(document.pages.length); + } + + console.log('Final split points:', splitPoints); + console.log('Total pages to split:', document.pages.length); + + let startIndex = 0; + let partNumber = 1; + + for (const endIndex of splitPoints) { + const segmentPages = document.pages.slice(startIndex, endIndex); + + console.log(`Creating split document ${partNumber}: pages ${startIndex}-${endIndex-1} (${segmentPages.length} pages)`); + console.log(`Split document ${partNumber} page numbers:`, segmentPages.map(p => p.pageNumber)); + + if (segmentPages.length > 0) { + documents.push({ + ...document, + id: `${document.id}_part_${partNumber}`, + name: `${document.name.replace(/\.pdf$/i, '')}_part_${partNumber}.pdf`, + pages: segmentPages, + totalPages: segmentPages.length + }); + partNumber++; + } + + startIndex = endIndex; + } + + console.log(`Created ${documents.length} split documents`); + return documents; + } + + /** + * Apply DOM changes for a single page + */ + private applyPageChanges(page: PDFPage): PDFPage { + // Find the DOM element for this page + const pageElement = document.querySelector(`[data-page-id="${page.id}"]`); + if (!pageElement) { + console.log(`Page ${page.pageNumber}: No DOM element found, keeping original state`); + return page; + } + + const updatedPage = { ...page }; + + // Apply rotation changes from DOM + updatedPage.rotation = this.getRotationFromDOM(pageElement, page); + + + return updatedPage; + } + + /** + * Read rotation from DOM element + */ + private getRotationFromDOM(pageElement: Element, originalPage: PDFPage): number { + const img = pageElement.querySelector('img'); + if (img && img.style.transform) { + // Parse rotation from transform property (e.g., "rotate(90deg)" -> 90) + const rotationMatch = img.style.transform.match(/rotate\((-?\d+)deg\)/); + const domRotation = rotationMatch ? parseInt(rotationMatch[1]) : 0; + + console.log(`Page ${originalPage.pageNumber}: DOM rotation = ${domRotation}°, original = ${originalPage.rotation}°`); + return domRotation; + } + + console.log(`Page ${originalPage.pageNumber}: No DOM rotation found, keeping original = ${originalPage.rotation}°`); + return originalPage.rotation; + } + + /** + * Reset all DOM changes (useful for "discard changes" functionality) + */ + resetDOMToDocumentState(pdfDocument: PDFDocument): void { + console.log('DocumentManipulationService: Resetting DOM to match document state'); + + pdfDocument.pages.forEach(page => { + const pageElement = document.querySelector(`[data-page-id="${page.id}"]`); + if (pageElement) { + const img = pageElement.querySelector('img'); + if (img) { + // Reset rotation to match document state + img.style.transform = `rotate(${page.rotation}deg)`; + } + } + }); + } + + /** + * Check if DOM state differs from document state + */ + hasUnsavedChanges(pdfDocument: PDFDocument): boolean { + return pdfDocument.pages.some(page => { + const pageElement = document.querySelector(`[data-page-id="${page.id}"]`); + if (pageElement) { + const domRotation = this.getRotationFromDOM(pageElement, page); + return domRotation !== page.rotation; + } + return false; + }); + } +} + +// Export singleton instance +export const documentManipulationService = new DocumentManipulationService(); \ No newline at end of file diff --git a/frontend/src/services/pdfExportService.ts b/frontend/src/services/pdfExportService.ts index 9345133b8..fe3e314e0 100644 --- a/frontend/src/services/pdfExportService.ts +++ b/frontend/src/services/pdfExportService.ts @@ -4,20 +4,18 @@ import { PDFDocument, PDFPage } from '../types/pageEditor'; export interface ExportOptions { selectedOnly?: boolean; filename?: string; - splitDocuments?: boolean; - appendSuffix?: boolean; // when false, do not append _edited/_selected } export class PDFExportService { /** - * Export PDF document with applied operations + * Export PDF document with applied operations (single file source) */ async exportPDF( pdfDocument: PDFDocument, selectedPageIds: string[] = [], options: ExportOptions = {} - ): Promise<{ blob: Blob; filename: string } | { blobs: Blob[]; filenames: string[] }> { - const { selectedOnly = false, filename, splitDocuments = false, appendSuffix = true } = options; + ): Promise<{ blob: Blob; filename: string }> { + const { selectedOnly = false, filename } = options; try { // Determine which pages to export @@ -29,17 +27,13 @@ export class PDFExportService { throw new Error('No pages to export'); } - // Load original PDF once + // Load original PDF and create new document const originalPDFBytes = await pdfDocument.file.arrayBuffer(); const sourceDoc = await PDFLibDocument.load(originalPDFBytes); + const blob = await this.createSingleDocument(sourceDoc, pagesToExport); + const exportFilename = this.generateFilename(filename || pdfDocument.name, selectedOnly, false); - if (splitDocuments) { - return await this.createSplitDocuments(sourceDoc, pagesToExport, filename || pdfDocument.name); - } else { - const blob = await this.createSingleDocument(sourceDoc, pagesToExport); - const exportFilename = this.generateFilename(filename || pdfDocument.name, selectedOnly, appendSuffix); - return { blob, filename: exportFilename }; - } + return { blob, filename: exportFilename }; } catch (error) { console.error('PDF export error:', error); throw new Error(`Failed to export PDF: ${error instanceof Error ? error.message : 'Unknown error'}`); @@ -47,28 +41,85 @@ export class PDFExportService { } /** - * Create a single PDF document with all operations applied + * Export PDF document with applied operations (multi-file source) */ - private async createSingleDocument( - sourceDoc: PDFLibDocument, + async exportPDFMultiFile( + pdfDocument: PDFDocument, + sourceFiles: Map, + selectedPageIds: string[] = [], + options: ExportOptions = {} + ): Promise<{ blob: Blob; filename: string }> { + const { selectedOnly = false, filename } = options; + + try { + // Determine which pages to export + const pagesToExport = selectedOnly && selectedPageIds.length > 0 + ? pdfDocument.pages.filter(page => selectedPageIds.includes(page.id)) + : pdfDocument.pages; + + if (pagesToExport.length === 0) { + throw new Error('No pages to export'); + } + + const blob = await this.createMultiSourceDocument(sourceFiles, pagesToExport); + const exportFilename = this.generateFilename(filename || pdfDocument.name, selectedOnly, false); + + return { blob, filename: exportFilename }; + } catch (error) { + console.error('Multi-file PDF export error:', error); + throw new Error(`Failed to export PDF: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + /** + * Create a PDF document from multiple source files + */ + private async createMultiSourceDocument( + sourceFiles: Map, pages: PDFPage[] ): Promise { const newDoc = await PDFLibDocument.create(); + // Load all source documents once and cache them + const loadedDocs = new Map(); + + for (const [fileId, file] of sourceFiles) { + try { + const arrayBuffer = await file.arrayBuffer(); + const doc = await PDFLibDocument.load(arrayBuffer); + loadedDocs.set(fileId, doc); + } catch (error) { + console.warn(`Failed to load source file ${fileId}:`, error); + } + } + for (const page of pages) { - // Get the original page from source document - const sourcePageIndex = this.getOriginalSourceIndex(page); + if (page.isBlankPage || page.originalPageNumber === -1) { + // Create a blank page + const blankPage = newDoc.addPage(PageSizes.A4); - if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) { - // Copy the page - const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]); - - // Apply rotation + // Apply rotation if needed if (page.rotation !== 0) { - copiedPage.setRotation(degrees(page.rotation)); + blankPage.setRotation(degrees(page.rotation)); } + } else if (page.originalFileId && loadedDocs.has(page.originalFileId)) { + // Get the correct source document for this page + const sourceDoc = loadedDocs.get(page.originalFileId)!; + const sourcePageIndex = page.originalPageNumber - 1; - newDoc.addPage(copiedPage); + if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) { + // Copy the page from the correct source document + const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]); + + // Apply rotation + if (page.rotation !== 0) { + copiedPage.setRotation(degrees(page.rotation)); + } + + newDoc.addPage(copiedPage); + } + } else { + console.warn(`Cannot find source document for page ${page.pageNumber} (fileId: ${page.originalFileId})`); } } @@ -83,103 +134,60 @@ export class PDFExportService { } /** - * Create multiple PDF documents based on split markers + * Create a single PDF document with all operations applied (single source) */ - private async createSplitDocuments( + private async createSingleDocument( sourceDoc: PDFLibDocument, - pages: PDFPage[], - baseFilename: string - ): Promise<{ blobs: Blob[]; filenames: string[] }> { - const splitPoints: number[] = []; - const blobs: Blob[] = []; - const filenames: string[] = []; + pages: PDFPage[] + ): Promise { + const newDoc = await PDFLibDocument.create(); - // Find split points - pages.forEach((page, index) => { - if (page.splitBefore && index > 0) { - splitPoints.push(index); - } - }); + for (const page of pages) { + if (page.isBlankPage || page.originalPageNumber === -1) { + // Create a blank page + const blankPage = newDoc.addPage(PageSizes.A4); - // Add end point - splitPoints.push(pages.length); - - let startIndex = 0; - let partNumber = 1; - - for (const endIndex of splitPoints) { - const segmentPages = pages.slice(startIndex, endIndex); - - if (segmentPages.length > 0) { - const newDoc = await PDFLibDocument.create(); - - for (const page of segmentPages) { - const sourcePageIndex = this.getOriginalSourceIndex(page); - - if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) { - const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]); - - if (page.rotation !== 0) { - copiedPage.setRotation(degrees(page.rotation)); - } - - newDoc.addPage(copiedPage); - } + // Apply rotation if needed + if (page.rotation !== 0) { + blankPage.setRotation(degrees(page.rotation)); } + } else { + // Get the original page from source document using originalPageNumber + const sourcePageIndex = page.originalPageNumber - 1; - // Set metadata - newDoc.setCreator('Stirling PDF'); - newDoc.setProducer('Stirling PDF'); - newDoc.setTitle(`${baseFilename} - Part ${partNumber}`); + if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) { + // Copy the page + const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]); - const pdfBytes = await newDoc.save(); - const blob = new Blob([pdfBytes], { type: 'application/pdf' }); - const filename = this.generateSplitFilename(baseFilename, partNumber); + // Apply rotation + if (page.rotation !== 0) { + copiedPage.setRotation(degrees(page.rotation)); + } - blobs.push(blob); - filenames.push(filename); - partNumber++; - } - - startIndex = endIndex; - } - - return { blobs, filenames }; - } - - /** - * Derive the original page index from a page's stable id. - * Falls back to the current pageNumber if parsing fails. - */ - private getOriginalSourceIndex(page: PDFPage): number { - const match = page.id.match(/-page-(\d+)$/); - if (match) { - const originalNumber = parseInt(match[1], 10); - if (!Number.isNaN(originalNumber)) { - return originalNumber - 1; // zero-based index for pdf-lib + newDoc.addPage(copiedPage); + } } } - // Fallback to the visible page number - return Math.max(0, page.pageNumber - 1); + + // Set metadata + newDoc.setCreator('Stirling PDF'); + newDoc.setProducer('Stirling PDF'); + newDoc.setCreationDate(new Date()); + newDoc.setModificationDate(new Date()); + + const pdfBytes = await newDoc.save(); + return new Blob([pdfBytes], { type: 'application/pdf' }); } + /** * Generate appropriate filename for export */ private generateFilename(originalName: string, selectedOnly: boolean, appendSuffix: boolean): string { const baseName = originalName.replace(/\.pdf$/i, ''); - if (!appendSuffix) return `${baseName}.pdf`; - const suffix = selectedOnly ? '_selected' : '_edited'; - return `${baseName}${suffix}.pdf`; + return `${baseName}.pdf`; } - /** - * Generate filename for split documents - */ - private generateSplitFilename(baseName: string, partNumber: number): string { - const cleanBaseName = baseName.replace(/\.pdf$/i, ''); - return `${cleanBaseName}_part_${partNumber}.pdf`; - } /** * Download a single file @@ -203,7 +211,6 @@ export class PDFExportService { * Download multiple files as a ZIP */ async downloadAsZip(blobs: Blob[], filenames: string[], zipFilename: string): Promise { - // For now, download files wherindividually blobs.forEach((blob, index) => { setTimeout(() => { this.downloadFile(blob, filenames[index]); @@ -248,8 +255,8 @@ export class PDFExportService { ? pdfDocument.pages.filter(page => selectedPageIds.includes(page.id)) : pdfDocument.pages; - const splitCount = pagesToExport.reduce((count, page, index) => { - return count + (page.splitBefore && index > 0 ? 1 : 0); + const splitCount = pagesToExport.reduce((count, page) => { + return count + (page.splitAfter ? 1 : 0); }, 1); // At least 1 document // Rough size estimation (very approximate) diff --git a/frontend/src/services/pdfWorkerManager.ts b/frontend/src/services/pdfWorkerManager.ts index c31bc5f88..0999c5c29 100644 --- a/frontend/src/services/pdfWorkerManager.ts +++ b/frontend/src/services/pdfWorkerManager.ts @@ -12,7 +12,7 @@ class PDFWorkerManager { private static instance: PDFWorkerManager; private activeDocuments = new Set(); private workerCount = 0; - private maxWorkers = 3; // Limit concurrent workers + private maxWorkers = 10; // Limit concurrent workers private isInitialized = false; private constructor() { @@ -33,7 +33,6 @@ class PDFWorkerManager { if (!this.isInitialized) { GlobalWorkerOptions.workerSrc = '/pdf.worker.js'; this.isInitialized = true; - console.log('🏭 PDF.js worker initialized'); } } @@ -52,7 +51,6 @@ class PDFWorkerManager { ): Promise { // 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(); } @@ -89,8 +87,6 @@ class PDFWorkerManager { 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 @@ -98,7 +94,6 @@ class PDFWorkerManager { try { loadingTask.destroy(); } catch (destroyError) { - console.warn('🏭 Error destroying failed loading task:', destroyError); } } throw error; @@ -114,10 +109,7 @@ class PDFWorkerManager { 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); @@ -129,8 +121,6 @@ class PDFWorkerManager { * 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); @@ -138,8 +128,6 @@ class PDFWorkerManager { this.activeDocuments.clear(); this.workerCount = 0; - - console.log('🏭 All PDF documents destroyed'); } /** @@ -173,29 +161,23 @@ class PDFWorkerManager { * 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}`); + this.maxWorkers = Math.max(1, Math.min(max, 15)); // Between 1-15 workers for multi-file support } } diff --git a/frontend/src/services/thumbnailGenerationService.ts b/frontend/src/services/thumbnailGenerationService.ts index a1c221269..a81ca44ed 100644 --- a/frontend/src/services/thumbnailGenerationService.ts +++ b/frontend/src/services/thumbnailGenerationService.ts @@ -40,7 +40,7 @@ export class ThumbnailGenerationService { private pdfDocumentCache = new Map(); private maxPdfCacheSize = 10; // Keep up to 10 PDF documents cached - constructor(private maxWorkers: number = 3) { + constructor(private maxWorkers: number = 10) { // PDF rendering requires DOM access, so we use optimized main thread processing } @@ -207,6 +207,9 @@ export class ThumbnailGenerationService { // Release reference to PDF document (don't destroy - keep in cache) this.releasePDFDocument(fileId); + + this.cleanupCompletedDocument(fileId); + return allResults; } @@ -289,6 +292,18 @@ export class ThumbnailGenerationService { } } + /** + * Clean up a PDF document from cache when thumbnail generation is complete + * This frees up workers faster for better performance + */ + cleanupCompletedDocument(fileId: string): void { + const cached = this.pdfDocumentCache.get(fileId); + if (cached && cached.refCount <= 0) { + pdfWorkerManager.destroyDocument(cached.pdf); + this.pdfDocumentCache.delete(fileId); + } + } + destroy(): void { this.clearCache(); this.clearPDFCache(); diff --git a/frontend/src/types/fileContext.ts b/frontend/src/types/fileContext.ts index 0425031c5..cb5d6566a 100644 --- a/frontend/src/types/fileContext.ts +++ b/frontend/src/types/fileContext.ts @@ -55,6 +55,7 @@ export interface FileRecord { blobUrl?: string; createdAt?: number; processedFile?: ProcessedFileMetadata; + insertAfterPageId?: string; // Page ID after which this file should be inserted isPinned?: boolean; // Note: File object stored in provider ref, not in state } @@ -216,13 +217,14 @@ export type FileContextAction = export interface FileContextActions { // File management - lightweight actions only - addFiles: (files: File[]) => Promise; + addFiles: (files: File[], options?: { insertAfterPageId?: string }) => Promise; addProcessedFiles: (filesWithThumbnails: Array<{ file: File; thumbnail?: string; pageCount?: number }>) => Promise; addStoredFiles: (filesWithMetadata: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>) => Promise; removeFiles: (fileIds: FileId[], deleteFromStorage?: boolean) => Promise; updateFileRecord: (id: FileId, updates: Partial) => void; reorderFiles: (orderedFileIds: FileId[]) => void; clearAllFiles: () => Promise; + clearAllData: () => Promise; // File pinning pinFile: (file: File) => void; diff --git a/frontend/src/types/pageEditor.ts b/frontend/src/types/pageEditor.ts index 6ad4318f7..21f803021 100644 --- a/frontend/src/types/pageEditor.ts +++ b/frontend/src/types/pageEditor.ts @@ -1,10 +1,13 @@ export interface PDFPage { id: string; pageNumber: number; + originalPageNumber: number; thumbnail: string | null; rotation: number; selected: boolean; - splitBefore?: boolean; + splitAfter?: boolean; + isBlankPage?: boolean; + originalFileId?: string; } export interface PDFDocument { @@ -47,9 +50,17 @@ export interface PageEditorFunctions { handleRotate: (direction: 'left' | 'right') => void; handleDelete: () => void; handleSplit: () => void; + handleSplitAll: () => void; + handlePageBreak: () => void; + handlePageBreakAll: () => void; + handleSelectAll: () => void; + handleDeselectAll: () => void; + handleSetSelectedPages: (pageNumbers: number[]) => void; onExportSelected: () => void; onExportAll: () => void; exportLoading: boolean; selectionMode: boolean; selectedPages: number[]; + splitPositions: Set; + totalPages: number; } From d64c56a897df35b410e85f0e0155951d42f90d6e Mon Sep 17 00:00:00 2001 From: James Brunton Date: Tue, 26 Aug 2025 15:53:49 +0100 Subject: [PATCH 04/11] Fix invalid translation file in V2 (#4298) # Description of Changes GB translations are currently invalid JSON. This fixes that. --- .../public/locales/en-GB/translation.json | 119 +++++++++--------- 1 file changed, 59 insertions(+), 60 deletions(-) diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index e4fcb2884..e32579891 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -2127,7 +2127,7 @@ "selectedCount": "{{count}} selected", "download": "Download", "delete": "Delete", - "unsupported":"Unsupported" + "unsupported": "Unsupported" }, "storage": { "temporaryNotice": "Files are stored temporarily in your browser and may be cleared automatically", @@ -2299,75 +2299,74 @@ } }, "automate": { - "title": "Automate", - "desc": "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks.", - "invalidStep": "Invalid step", - "files": { - "placeholder": "Select files to process with this automation" + "title": "Automate", + "desc": "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks.", + "invalidStep": "Invalid step", + "files": { + "placeholder": "Select files to process with this automation" + }, + "selection": { + "title": "Automation Selection", + "saved": { + "title": "Saved" }, - "selection": { - "title": "Automation Selection", - "saved": { - "title": "Saved" - }, - "createNew": { - "title": "Create New Automation" - }, - "suggested": { - "title": "Suggested" - } + "createNew": { + "title": "Create New Automation" }, - "creation": { - "createTitle": "Create Automation", - "editTitle": "Edit Automation", - "description": "Automations run tools sequentially. To get started, add tools in the order you want them to run.", - "name": { - "placeholder": "Automation name" - }, - "tools": { - "selectTool": "Select a tool...", - "selected": "Selected Tools", - "remove": "Remove tool", - "configure": "Configure tool", - "notConfigured": "! Not Configured", - "addTool": "Add Tool", - "add": "Add a tool..." - }, - "save": "Save Automation", - "unsavedChanges": { - "title": "Unsaved Changes", - "message": "You have unsaved changes. Are you sure you want to go back? All changes will be lost.", - "cancel": "Cancel", - "confirm": "Go Back" - } + "suggested": { + "title": "Suggested" + } + }, + "creation": { + "createTitle": "Create Automation", + "editTitle": "Edit Automation", + "description": "Automations run tools sequentially. To get started, add tools in the order you want them to run.", + "name": { + "placeholder": "Automation name" }, - "run": { - "title": "Run Automation" + "tools": { + "selectTool": "Select a tool...", + "selected": "Selected Tools", + "remove": "Remove tool", + "configure": "Configure tool", + "notConfigured": "! Not Configured", + "addTool": "Add Tool", + "add": "Add a tool..." }, - "sequence": { - "unnamed": "Unnamed Automation", - "steps": "{{count}} steps", - "running": "Running Automation...", - "run": "Run Automation", - "finish": "Finish" - }, - "reviewTitle": "Automation Results", - "config": { - "loading": "Loading tool configuration...", - "noSettings": "This tool does not have configurable settings.", - "title": "Configure {{toolName}}", - "description": "Configure the settings for this tool. These settings will be applied when the automation runs.", + "save": "Save Automation", + "unsavedChanges": { + "title": "Unsaved Changes", + "message": "You have unsaved changes. Are you sure you want to go back? All changes will be lost.", "cancel": "Cancel", - "save": "Save Configuration" - }, - "copyToSaved": "Copy to Saved" - } + "confirm": "Go Back" + } + }, + "run": { + "title": "Run Automation" + }, + "sequence": { + "unnamed": "Unnamed Automation", + "steps": "{{count}} steps", + "running": "Running Automation...", + "run": "Run Automation", + "finish": "Finish" + }, + "reviewTitle": "Automation Results", + "config": { + "loading": "Loading tool configuration...", + "noSettings": "This tool does not have configurable settings.", + "title": "Configure {{toolName}}", + "description": "Configure the settings for this tool. These settings will be applied when the automation runs.", + "cancel": "Cancel", + "save": "Save Configuration" + }, + "copyToSaved": "Copy to Saved" }, "automation": { "suggested": { "securePdfIngestion": "Secure PDF Ingestion", "securePdfIngestionDesc": "Comprehensive PDF processing workflow that sanitises documents, applies OCR with cleanup, converts to PDF/A format for long-term archival, and optimises file size.", - "emailPreparation": "Email Preparation", + "emailPreparation": "Email Preparation", "emailPreparationDesc": "Optimises PDFs for email distribution by compressing files, splitting large documents into 20MB chunks for email compatibility, and removing metadata for privacy.", "secureWorkflow": "Security Workflow", "secureWorkflowDesc": "Secures PDF documents by removing potentially malicious content like JavaScript and embedded files, then adds password protection to prevent unauthorised access.", From 9360a36c31ef181ac25c7da0227898d5ad8463c3 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Tue, 26 Aug 2025 15:56:37 +0100 Subject: [PATCH 05/11] V2: Disable rainbow mode for demo (#4303) # Description of Changes The rainbow mode easter egg doesn't work very well at the moment and isn't needed for the demo (and is too easy to accidentally enable). This PR disables it completely by adding a global const to short-circuit the activation code. --- frontend/src/hooks/useRainbowTheme.ts | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/frontend/src/hooks/useRainbowTheme.ts b/frontend/src/hooks/useRainbowTheme.ts index 449b07c61..8b272b883 100644 --- a/frontend/src/hooks/useRainbowTheme.ts +++ b/frontend/src/hooks/useRainbowTheme.ts @@ -11,6 +11,8 @@ interface RainbowThemeHook { deactivateRainbow: () => void; } +const allowRainbowMode = false; // Override to allow/disallow fun + export function useRainbowTheme(initialTheme: 'light' | 'dark' = 'light'): RainbowThemeHook { // Get theme from localStorage or use initial const [themeMode, setThemeMode] = useState(() => { @@ -35,11 +37,11 @@ export function useRainbowTheme(initialTheme: 'light' | 'dark' = 'light'): Rainb // Save theme to localStorage whenever it changes useEffect(() => { localStorage.setItem('stirling-theme', themeMode); - + // Apply rainbow class to body if in rainbow mode if (themeMode === 'rainbow') { document.body.classList.add('rainbow-mode-active'); - + // Show easter egg notification showRainbowNotification(); } else { @@ -77,7 +79,7 @@ export function useRainbowTheme(initialTheme: 'light' | 'dark' = 'light'): Rainb pointer-events: none; transition: opacity 0.3s ease; `; - + document.body.appendChild(notification); // Auto-remove notification after 3 seconds @@ -121,7 +123,7 @@ export function useRainbowTheme(initialTheme: 'light' | 'dark' = 'light'): Rainb pointer-events: none; transition: opacity 0.3s ease; `; - + document.body.appendChild(notification); // Auto-remove notification after 2 seconds @@ -144,7 +146,7 @@ export function useRainbowTheme(initialTheme: 'light' | 'dark' = 'light'): Rainb } const currentTime = Date.now(); - + // Simple exit from rainbow mode with single click (after cooldown period) if (themeMode === 'rainbow') { setThemeMode('light'); @@ -152,7 +154,7 @@ export function useRainbowTheme(initialTheme: 'light' | 'dark' = 'light'): Rainb showExitNotification(); return; } - + // Reset counter if too much time has passed (2.5 seconds) if (currentTime - lastToggleTime.current > 2500) { toggleCount.current = 1; @@ -162,18 +164,18 @@ export function useRainbowTheme(initialTheme: 'light' | 'dark' = 'light'): Rainb lastToggleTime.current = currentTime; // Easter egg: Activate rainbow mode after 10 rapid toggles - if (toggleCount.current >= 10) { + if (allowRainbowMode && toggleCount.current >= 10) { setThemeMode('rainbow'); console.log('🌈 RAINBOW MODE ACTIVATED! 🌈 You found the secret easter egg!'); console.log('🌈 Button will be disabled for 3 seconds, then click once to exit!'); - + // Disable toggle for 3 seconds setIsToggleDisabled(true); setTimeout(() => { setIsToggleDisabled(false); console.log('🌈 Theme toggle re-enabled! Click once to exit rainbow mode.'); }, 3000); - + // Reset counter toggleCount.current = 0; return; @@ -203,4 +205,4 @@ export function useRainbowTheme(initialTheme: 'light' | 'dark' = 'light'): Rainb activateRainbow, deactivateRainbow, }; -} \ No newline at end of file +} From 3d26b054f1bf00d6a65fbcc4ae458c3031ed574c Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Tue, 26 Aug 2025 16:31:20 +0100 Subject: [PATCH 06/11] V2 styling requests (#4288) some of the requested changes warrant discussion so I haven't applied all of them here. Making a draft PR for now, we can discuss the rest later # Description of Changes See Matts todo list, the crossed out items are what were addressed in this PR --- ## 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: James Brunton --- .../branding/StirlingPDFLogoNoTextDark.svg | 6 +-- .../branding/StirlingPDFLogoNoTextLight.svg | 6 +-- .../components/shared/AllToolsNavButton.tsx | 2 +- .../src/components/shared/LandingPage.tsx | 10 ++-- .../src/components/shared/QuickAccessBar.tsx | 6 +-- .../src/components/shared/TopControls.tsx | 53 ++++++++++++------- frontend/src/components/tools/ToolPanel.tsx | 2 +- frontend/src/components/tools/ToolPicker.tsx | 10 ++-- .../tools/shared/FileStatusIndicator.tsx | 1 - .../src/components/tools/shared/ToolStep.tsx | 12 ++--- .../tools/shared/renderToolButtons.tsx | 4 +- .../tools/toolPicker/ToolButton.tsx | 29 +++++----- frontend/src/components/viewer/Viewer.tsx | 2 + .../src/data/useTranslatedToolRegistry.tsx | 17 +++--- frontend/src/hooks/useToolSections.ts | 4 +- 15 files changed, 87 insertions(+), 77 deletions(-) diff --git a/frontend/public/branding/StirlingPDFLogoNoTextDark.svg b/frontend/public/branding/StirlingPDFLogoNoTextDark.svg index 001c6a01c..a6f82dd6f 100644 --- a/frontend/public/branding/StirlingPDFLogoNoTextDark.svg +++ b/frontend/public/branding/StirlingPDFLogoNoTextDark.svg @@ -1,4 +1,4 @@ - - - + + + diff --git a/frontend/public/branding/StirlingPDFLogoNoTextLight.svg b/frontend/public/branding/StirlingPDFLogoNoTextLight.svg index a0fa9cee5..62a5b3838 100644 --- a/frontend/public/branding/StirlingPDFLogoNoTextLight.svg +++ b/frontend/public/branding/StirlingPDFLogoNoTextLight.svg @@ -1,4 +1,4 @@ - - - + + + diff --git a/frontend/src/components/shared/AllToolsNavButton.tsx b/frontend/src/components/shared/AllToolsNavButton.tsx index f2b308b26..c1b5774d2 100644 --- a/frontend/src/components/shared/AllToolsNavButton.tsx +++ b/frontend/src/components/shared/AllToolsNavButton.tsx @@ -26,7 +26,7 @@ const AllToolsNavButton: React.FC = ({ activeButton, set const iconNode = ( - + ); diff --git a/frontend/src/components/shared/LandingPage.tsx b/frontend/src/components/shared/LandingPage.tsx index 14322076e..92078e850 100644 --- a/frontend/src/components/shared/LandingPage.tsx +++ b/frontend/src/components/shared/LandingPage.tsx @@ -36,7 +36,7 @@ const LandingPage = () => { }; return ( - + {/* White PDF Page Background */} { left: '50%', transform: 'translateX(-50%)', bottom: 0, - borderRadius: '0.5rem 0.5rem 0 0', + borderRadius: '0.25rem 0.25rem 0 0', filter: 'var(--drop-shadow-filter)', backgroundColor: 'var(--landing-paper-bg)', transition: 'background-color 0.4s ease', @@ -66,7 +66,7 @@ const LandingPage = () => { style={{ position: 'absolute', top: 0, - right: ".5rem", + right: 0, zIndex: 10, }} @@ -75,15 +75,13 @@ const LandingPage = () => { src={colorScheme === 'dark' ? '/branding/StirlingPDFLogoNoTextDark.svg' : '/branding/StirlingPDFLogoNoTextLight.svg'} alt="Stirling PDF Logo" style={{ - width: '10rem', height: 'auto', pointerEvents: 'none', - marginTop: '-0.5rem' }} />
(({ { id: 'automate', name: t("quickAccess.automate", "Automate"), - icon: , + icon: , size: 'lg', isRound: false, type: 'navigation', @@ -80,7 +80,7 @@ const QuickAccessBar = forwardRef(({ { id: 'files', name: t("quickAccess.files", "Files"), - icon: , + icon: , isRound: true, size: 'lg', type: 'modal', @@ -151,7 +151,7 @@ const QuickAccessBar = forwardRef(({
{ config.onClick(); diff --git a/frontend/src/components/shared/TopControls.tsx b/frontend/src/components/shared/TopControls.tsx index 9c41b35e0..1a9dbbd9f 100644 --- a/frontend/src/components/shared/TopControls.tsx +++ b/frontend/src/components/shared/TopControls.tsx @@ -6,6 +6,7 @@ import VisibilityIcon from "@mui/icons-material/Visibility"; import EditNoteIcon from "@mui/icons-material/EditNote"; import FolderIcon from "@mui/icons-material/Folder"; import { ModeType, isValidMode } from '../../contexts/NavigationContext'; +import { Tooltip } from "./Tooltip"; const viewOptionStyle = { display: 'inline-flex', @@ -17,8 +18,8 @@ const viewOptionStyle = { } -// Create view options with icons and loading states -const createViewOptions = (switchingTo: ModeType | null) => [ +// Build view options showing text only for current view; others icon-only with tooltip +const createViewOptions = (currentView: ModeType, switchingTo: ModeType | null) => [ { label: (
@@ -34,27 +35,35 @@ const createViewOptions = (switchingTo: ModeType | null) => [ }, { label: ( -
- {switchingTo === "pageEditor" ? ( - - ) : ( - - )} - Page Editor -
+ +
+ {currentView === "pageEditor" ? ( + <> + {switchingTo === "pageEditor" ? : } + Page Editor + + ) : ( + switchingTo === "pageEditor" ? : + )} +
+
), value: "pageEditor", }, { label: ( -
- {switchingTo === "fileEditor" ? ( - - ) : ( - - )} - File Manager -
+ +
+ {currentView === "fileEditor" ? ( + <> + {switchingTo === "fileEditor" ? : } + Active Files + + ) : ( + switchingTo === "fileEditor" ? : + )} +
+
), value: "fileEditor", }, @@ -103,11 +112,10 @@ const TopControls = ({ {!isToolSelected && (
diff --git a/frontend/src/components/tools/ToolPanel.tsx b/frontend/src/components/tools/ToolPanel.tsx index 69a012690..fa41fcaff 100644 --- a/frontend/src/components/tools/ToolPanel.tsx +++ b/frontend/src/components/tools/ToolPanel.tsx @@ -41,7 +41,7 @@ export default function ToolPanel() { isRainbowMode ? rainbowStyles.rainbowPaper : '' }`} style={{ - width: isPanelVisible ? '20rem' : '0', + width: isPanelVisible ? '18.5rem' : '0', padding: '0' }} > diff --git a/frontend/src/components/tools/ToolPicker.tsx b/frontend/src/components/tools/ToolPicker.tsx index 9a46c8a3e..22f92eb93 100644 --- a/frontend/src/components/tools/ToolPicker.tsx +++ b/frontend/src/components/tools/ToolPicker.tsx @@ -131,7 +131,7 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa borderTop: `0.0625rem solid var(--tool-header-border)`, borderBottom: `0.0625rem solid var(--tool-header-border)`, padding: "0.5rem 1rem", - fontWeight: 700, + fontWeight: 600, background: "var(--tool-header-bg)", color: "var(--tool-header-text)", cursor: "pointer", @@ -141,7 +141,7 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa }} onClick={() => scrollTo(quickAccessRef)} > - {t("toolPicker.quickAccess", "QUICK ACCESS")} + {t("toolPicker.quickAccess", "QUICK ACCESS")}
- + {quickSection?.subcategories.map(sc => renderToolButtons(t, sc, selectedToolKey, onSelect, false) @@ -177,7 +177,7 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa borderTop: `0.0625rem solid var(--tool-header-border)`, borderBottom: `0.0625rem solid var(--tool-header-border)`, padding: "0.5rem 1rem", - fontWeight: 700, + fontWeight: 600, background: "var(--tool-header-bg)", color: "var(--tool-header-text)", cursor: "pointer", @@ -187,7 +187,7 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa }} onClick={() => scrollTo(allToolsRef)} > - {t("toolPicker.allTools", "ALL TOOLS")} + {t("toolPicker.allTools", "ALL TOOLS")} { const { t } = useTranslation(); const { openFilesModal, onFilesSelect } = useFilesModalContext(); diff --git a/frontend/src/components/tools/shared/ToolStep.tsx b/frontend/src/components/tools/shared/ToolStep.tsx index 9d86d2f03..4201fd82d 100644 --- a/frontend/src/components/tools/shared/ToolStep.tsx +++ b/frontend/src/components/tools/shared/ToolStep.tsx @@ -50,7 +50,7 @@ const renderTooltipTitle = ( sidebarTooltip={true} > e.stopPropagation()}> - + {title} @@ -60,7 +60,7 @@ const renderTooltipTitle = ( } return ( - + {title} ); @@ -96,7 +96,7 @@ const ToolStep = ({
{shouldShowNumber && ( - + {stepNumber} )} @@ -135,7 +135,7 @@ const ToolStep = ({ {!isCollapsed && ( - + {helpText && ( {helpText} @@ -145,7 +145,7 @@ const ToolStep = ({ )}
- +
); } diff --git a/frontend/src/components/tools/shared/renderToolButtons.tsx b/frontend/src/components/tools/shared/renderToolButtons.tsx index eb9c9be6d..a7535e0e0 100644 --- a/frontend/src/components/tools/shared/renderToolButtons.tsx +++ b/frontend/src/components/tools/shared/renderToolButtons.tsx @@ -19,7 +19,7 @@ export const renderToolButtons = ( {showSubcategoryHeader && ( )} - +
{subcategory.tools.map(({ id, tool }) => ( ))} - +
); diff --git a/frontend/src/components/tools/toolPicker/ToolButton.tsx b/frontend/src/components/tools/toolPicker/ToolButton.tsx index 185eed5ed..bd54f7e13 100644 --- a/frontend/src/components/tools/toolPicker/ToolButton.tsx +++ b/frontend/src/components/tools/toolPicker/ToolButton.tsx @@ -12,8 +12,10 @@ interface ToolButtonProps { rounded?: boolean; } -const ToolButton: React.FC = ({ id, tool, isSelected, onSelect, rounded = false }) => { +const ToolButton: React.FC = ({ id, tool, isSelected, onSelect }) => { + const isUnavailable = !tool.component && !tool.link; const handleClick = (id: string) => { + if (isUnavailable) return; if (tool.link) { // Open external link in new tab window.open(tool.link, '_blank', 'noopener,noreferrer'); @@ -23,35 +25,30 @@ const ToolButton: React.FC = ({ id, tool, isSelected, onSelect, onSelect(id); }; + const tooltipContent = isUnavailable + ? (Coming soon: {tool.description}) + : tool.description; + return ( - + diff --git a/frontend/src/components/viewer/Viewer.tsx b/frontend/src/components/viewer/Viewer.tsx index c1a2b440a..cdf831c06 100644 --- a/frontend/src/components/viewer/Viewer.tsx +++ b/frontend/src/components/viewer/Viewer.tsx @@ -550,12 +550,14 @@ const Viewer = ({ justifyContent: "center", pointerEvents: "none", background: "transparent", + }} > allTools[key].component !== null || allTools[key].link) - .reduce((obj, key) => { - obj[key] = allTools[key]; - return obj; - }, {} as ToolRegistry); - return filteredTools; } + const filteredTools = Object.keys(allTools) + .filter(key => allTools[key].component !== null || allTools[key].link) + .reduce((obj, key) => { + obj[key] = allTools[key]; + return obj; + }, {} as ToolRegistry); + return filteredTools; }, [t]); // Only re-compute when translations change } diff --git a/frontend/src/hooks/useToolSections.ts b/frontend/src/hooks/useToolSections.ts index 41762a8e1..d0f6ebdca 100644 --- a/frontend/src/hooks/useToolSections.ts +++ b/frontend/src/hooks/useToolSections.ts @@ -64,7 +64,9 @@ export function useToolSections(filteredTools: [string /* FIX ME: Should be Tool Object.entries(subs).forEach(([s, tools]) => { const subcategoryId = s as SubcategoryId; if (!quick[subcategoryId]) quick[subcategoryId] = []; - quick[subcategoryId].push(...tools); + // Only include ready tools (have a component or external link) in Quick Access + const readyTools = tools.filter(({ tool }) => tool.component !== null || !!tool.link); + quick[subcategoryId].push(...readyTools); }); } }); From 47ccb6a6ed10ab0b39c4d72987f22ee029afd39b Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Tue, 26 Aug 2025 16:59:03 +0100 Subject: [PATCH 07/11] improvement/v2/automate/tweaks (#4293) - [x] Cleanup Automation output name garbage - [x] Remove Cross button on first two tools - [x] Automation creation name title to make clearer to the user - [x] Colours for dark mode on automation tool settings are bad - [x] Fix tool names not using correct translated ones - [x] suggested Automation Password needs adding to description - [x] Allow different filetypes in automation - [x] Custom Icons for automation - [x] split Tool wasn't working with merge to single pdf --------- Co-authored-by: Connor Yoh Co-authored-by: James Brunton --- .../public/locales/en-GB/translation.json | 15 ++- .../public/locales/en-US/translation.json | 4 +- .../components/shared/FileUploadButton.tsx | 3 +- .../src/components/shared/QuickAccessBar.tsx | 9 +- .../addWatermark/WatermarkTypeSettings.tsx | 4 +- .../tools/automate/AutomationCreation.tsx | 45 +++++-- .../tools/automate/AutomationEntry.tsx | 44 +++++-- .../tools/automate/AutomationSelection.tsx | 37 ++++-- .../tools/automate/IconSelector.tsx | 116 ++++++++++++++++++ .../components/tools/automate/ToolList.tsx | 34 ++--- .../src/components/tools/automate/iconMap.ts | 92 ++++++++++++++ .../tools/compress/CompressSettings.tsx | 4 +- frontend/src/contexts/ToolWorkflowContext.tsx | 67 +++++----- .../src/data/useTranslatedToolRegistry.tsx | 41 ++++--- .../tools/automate/useAutomateOperation.ts | 2 +- .../hooks/tools/automate/useAutomationForm.ts | 8 ++ .../tools/automate/useSavedAutomations.ts | 17 +++ .../tools/automate/useSuggestedAutomations.ts | 2 +- .../hooks/tools/shared/useToolOperation.ts | 8 +- frontend/src/theme/mantineTheme.ts | 43 ++++++- frontend/src/tools/Automate.tsx | 43 ++++++- frontend/src/tools/Split.tsx | 13 +- frontend/src/types/automation.ts | 1 + frontend/src/utils/automationExecutor.ts | 54 ++++++-- frontend/src/utils/fileResponseUtils.ts | 10 +- 25 files changed, 582 insertions(+), 134 deletions(-) create mode 100644 frontend/src/components/tools/automate/IconSelector.tsx create mode 100644 frontend/src/components/tools/automate/iconMap.ts diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index e32579891..7fa1036db 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -51,7 +51,6 @@ "placeholder": "Select a PDF file in the main view to get started", "upload": "Upload", "addFiles": "Add files", - "noFiles": "No files uploaded. ", "selectFromWorkbench": "Select files from the workbench or " }, "noFavourites": "No favourites added", @@ -2320,9 +2319,14 @@ "creation": { "createTitle": "Create Automation", "editTitle": "Edit Automation", - "description": "Automations run tools sequentially. To get started, add tools in the order you want them to run.", + "intro": "Automations run tools sequentially. To get started, add tools in the order you want them to run.", "name": { - "placeholder": "Automation name" + "label": "Automation Name", + "placeholder": "My Automation" + }, + "description": { + "label": "Description (optional)", + "placeholder": "Describe what this automation does..." }, "tools": { "selectTool": "Select a tool...", @@ -2339,6 +2343,9 @@ "message": "You have unsaved changes. Are you sure you want to go back? All changes will be lost.", "cancel": "Cancel", "confirm": "Go Back" + }, + "icon": { + "label": "Icon" } }, "run": { @@ -2369,7 +2376,7 @@ "emailPreparation": "Email Preparation", "emailPreparationDesc": "Optimises PDFs for email distribution by compressing files, splitting large documents into 20MB chunks for email compatibility, and removing metadata for privacy.", "secureWorkflow": "Security Workflow", - "secureWorkflowDesc": "Secures PDF documents by removing potentially malicious content like JavaScript and embedded files, then adds password protection to prevent unauthorised access.", + "secureWorkflowDesc": "Secures PDF documents by removing potentially malicious content like JavaScript and embedded files, then adds password protection to prevent unauthorised access. Password is set to 'password' by default.", "processImages": "Process Images", "processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images." } diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json index ab5b66802..b0a19539a 100644 --- a/frontend/public/locales/en-US/translation.json +++ b/frontend/public/locales/en-US/translation.json @@ -2111,10 +2111,10 @@ "suggested": { "securePdfIngestion": "Secure PDF Ingestion", "securePdfIngestionDesc": "Comprehensive PDF processing workflow that sanitizes documents, applies OCR with cleanup, converts to PDF/A format for long-term archival, and optimizes file size.", - "emailPreparation": "Email Preparation", + "emailPreparation": "Email Preparation", "emailPreparationDesc": "Optimizes PDFs for email distribution by compressing files, splitting large documents into 20MB chunks for email compatibility, and removing metadata for privacy.", "secureWorkflow": "Security Workflow", - "secureWorkflowDesc": "Secures PDF documents by removing potentially malicious content like JavaScript and embedded files, then adds password protection to prevent unauthorized access.", + "secureWorkflowDesc": "Secures PDF documents by removing potentially malicious content like JavaScript and embedded files, then adds password protection to prevent unauthorized access. Password is set to 'password' by default.", "processImages": "Process Images", "processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images." } diff --git a/frontend/src/components/shared/FileUploadButton.tsx b/frontend/src/components/shared/FileUploadButton.tsx index 27f58400f..434aad1b4 100644 --- a/frontend/src/components/shared/FileUploadButton.tsx +++ b/frontend/src/components/shared/FileUploadButton.tsx @@ -32,9 +32,10 @@ const FileUploadButton = ({ onChange={onChange} accept={accept} disabled={disabled} + > {(props) => ( - )} diff --git a/frontend/src/components/shared/QuickAccessBar.tsx b/frontend/src/components/shared/QuickAccessBar.tsx index ad91b2ac2..fc4301181 100644 --- a/frontend/src/components/shared/QuickAccessBar.tsx +++ b/frontend/src/components/shared/QuickAccessBar.tsx @@ -22,7 +22,7 @@ const QuickAccessBar = forwardRef(({ const { t } = useTranslation(); const { isRainbowMode } = useRainbowThemeContext(); const { openFilesModal, isFilesModalOpen } = useFilesModalContext(); - const { handleReaderToggle, handleBackToTools, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode } = useToolWorkflow(); + const { handleReaderToggle, handleBackToTools, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow(); const [configModalOpen, setConfigModalOpen] = useState(false); const [activeButton, setActiveButton] = useState('tools'); const scrollableRef = useRef(null); @@ -74,7 +74,12 @@ const QuickAccessBar = forwardRef(({ type: 'navigation', onClick: () => { setActiveButton('automate'); - handleToolSelect('automate'); + // If already on automate tool, reset it directly + if (selectedToolKey === 'automate') { + resetTool('automate'); + } else { + handleToolSelect('automate'); + } } }, { diff --git a/frontend/src/components/tools/addWatermark/WatermarkTypeSettings.tsx b/frontend/src/components/tools/addWatermark/WatermarkTypeSettings.tsx index f97454c5c..84bbb296a 100644 --- a/frontend/src/components/tools/addWatermark/WatermarkTypeSettings.tsx +++ b/frontend/src/components/tools/addWatermark/WatermarkTypeSettings.tsx @@ -16,7 +16,7 @@ const WatermarkTypeSettings = ({ watermarkType, onWatermarkTypeChange, disabled