Compare commits

..
Author SHA1 Message Date
Anthony Stirling b8215a9640 test subfont 2025-08-24 16:16:51 +01:00
Anthony Stirling 0e6cae9875 test 2025-08-24 15:47:01 +01:00
888bac9408 Added optional title for tool workflow (#4256)
- Added optional title for tool workflow - Not added to any tool. Just
there for when we need it
- Added add files button to files step
- renamed Local files button in filemanager to Upload Files
-

---------

Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
Co-authored-by: James Brunton <jbrunton96@gmail.com>
2025-08-22 17:12:14 +01:00
ConnorYohandConnor Yoh 23d86deae7 Feature/v2/automate (#4248)
* automate feature
* Moved all providers to app level to simplify homepage 
* Circular dependency fixes
* You will see that now toolRegistry gets a tool config and a tool
settings object. These enable automate to run the tools using as much
static code as possible.

---------

Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
2025-08-22 14:40:27 +01:00
James Brunton 7d9c0b0298 V2 Fix subcategory names in All Tools (and search) pane (#4252)
# Description of Changes
Because we used string typing for IDs and names, it was really easy to
make mistakes where variables named like `subcategory` would be stored
as an ID in one file, but then read assuming it's a name in another
file. This PR changes the code to consistently use enum cases when
referring to IDs of categories, subcategories, and tools (at least in as
many places as I can find them, ~I had to add a `ToolId` enum for this
work~ I originally added a `ToolId` type for this work, but it caused
too many issues when merging with #4222 so I've pulled it back out for
now).

Making that change made it obvious where we were inconsistently passing
IDs and reading them as names etc. allowing me to fix rendering issues
in the All Tools pane, where the subcategory IDs were being rendered
directly (instead of being translated) or where IDs were being
translated into names, but were then being re-translated, causing
warnings in the log.
2025-08-22 13:53:06 +01:00
Reece BrowneandReece Browne 949ffa01ad Feature/v2/file handling improvements (#4222)
# Description of Changes

A new universal file context rather than the splintered ones for the
main views, tools and manager we had before (manager still has its own
but its better integreated with the core context)
File context has been split it into a handful of different files
managing various file related issues separately to reduce the monolith -
FileReducer.ts - State management
  fileActions.ts - File operations
  fileSelectors.ts - Data access patterns
  lifecycle.ts - Resource cleanup and memory management
  fileHooks.ts - React hooks interface
  contexts.ts - Context providers
Improved thumbnail generation
Improved indexxedb handling
Stopped handling files as blobs were not necessary to improve
performance
A new library handling drag and drop
https://github.com/atlassian/pragmatic-drag-and-drop (Out of scope yes
but I broke the old one with the new filecontext and it needed doing so
it was a might as well)
A new library handling virtualisation on page editor
@tanstack/react-virtual, as above.
Quickly ripped out the last remnants of the old URL params stuff and
replaced with the beginnings of what will later become the new URL
navigation system (for now it just restores the tool name in url
behavior)
Fixed selected file not regestered when opening a tool
Fixed png thumbnails
Closes #(issue_number)

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: Reece Browne <you@example.com>
2025-08-21 17:30:26 +01:00
James Brunton a33e51351b V2 hide incomplete tools (#4250)
# Description of Changes
Filters out tools which don't exist in All Tools list. Also fixes a
bunch of translation warnings in the log.
2025-08-21 09:26:17 +01:00
James Brunton a6706fcb0c V2 reduce boilerplate in param hooks (#4246)
# Description of Changes
Extend the base params in all tools param hooks, reducing boilerplate
code.
2025-08-21 07:48:25 +00:00
ConnorYohandConnor Yoh d06cbcaa91 V2 Files Selected indicator in toolstep (#4241)
Added back our files selected indicator in tools step

---------

Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
2025-08-20 15:54:30 +00:00
ConnorYohandConnor Yoh cd2b82d614 Feature/v2/filemanagerimprovements (#4243)
- Select all/deselect all
- Delete Selected
- Download  Selected
- Recent file delete -> menu button with drop down for delete and
download
- Shift click selection added
<img width="1220" height="751"
alt="{330DF96D-7040-4CCB-B089-523F370E3185}"
src="https://github.com/user-attachments/assets/976e42cc-2124-4e62-83a8-25f184e8da3b"
/>
<img width="1160" height="749"
alt="{2D2F4876-7D35-45C3-B0CD-3127EEEEF7B5}"
src="https://github.com/user-attachments/assets/6879a174-a135-41f4-a876-984e7c2f96e2"
/>

---------

Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
2025-08-20 16:51:55 +01:00
Anthony Stirling eada9e43ec tooltip (#4242)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-08-20 14:12:12 +01:00
Anthony Stirling d1cb3f0b30 V2 PDF to large page (#4236)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-08-20 11:38:41 +01:00
Anthony Stirling 857b2b5c53 V2 Unlock forms (#4237)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-08-20 11:34:28 +01:00
Anthony Stirling d74c3f4598 V2 remove cert (#4239)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-08-20 11:30:57 +01:00
186 changed files with 16787 additions and 5727 deletions
+4 -1
View File
@@ -11,8 +11,11 @@
"Bash(npm test:*)",
"Bash(ls:*)",
"Bash(npx tsc:*)",
"Bash(node:*)",
"Bash(npm run dev:*)",
"Bash(sed:*)"
],
"deny": []
"deny": [],
"defaultMode": "acceptEdits"
}
}
+1 -1
View File
@@ -11,7 +11,7 @@
/>
<link rel="apple-touch-icon" href="/logo192.png" />
<link rel="manifest" href="/manifest.json" />
<title>Stirling PDF</title>
</head>
<body>
+6322 -27
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -5,6 +5,7 @@
"license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE",
"proxy": "http://localhost:8080",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.7.4",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@mantine/core": "^8.0.1",
@@ -13,6 +14,7 @@
"@mui/icons-material": "^7.1.0",
"@mui/material": "^7.1.0",
"@tailwindcss/postcss": "^4.1.8",
"@tanstack/react-virtual": "^3.13.12",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
@@ -23,7 +25,6 @@
"i18next-browser-languagedetector": "^8.1.0",
"i18next-http-backend": "^3.0.2",
"jszip": "^3.10.1",
"material-symbols": "^0.33.0",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^3.11.174",
"react": "^19.1.0",
@@ -35,7 +36,7 @@
},
"scripts": {
"dev": "npx tsc --noEmit && vite",
"build": "npx tsc --noEmit && vite build",
"build": "npx tsc --noEmit && vite build && subfont dist --in-place --font-family='Material Symbols Rounded'",
"preview": "vite preview",
"generate-licenses": "node scripts/generate-licenses.js",
"test": "vitest",
@@ -72,10 +73,12 @@
"@vitest/coverage-v8": "^1.0.0",
"jsdom": "^23.0.0",
"license-checker": "^25.0.1",
"madge": "^8.0.0",
"postcss": "^8.5.3",
"postcss-cli": "^11.0.1",
"postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1",
"subfont": "^7.2.1",
"typescript": "^5.8.3",
"vite": "^6.3.5",
"vitest": "^1.0.0"
Binary file not shown.
+186 -96
View File
@@ -85,6 +85,7 @@
"warning": {
"tooltipTitle": "Warning"
},
"edit": "Edit",
"delete": "Delete",
"username": "Username",
"password": "Password",
@@ -366,14 +367,6 @@
"title": "Convert",
"desc": "Convert files between different formats"
},
"imageToPDF": {
"title": "Image to PDF",
"desc": "Convert a image (PNG, JPEG, GIF) to PDF."
},
"pdfToImage": {
"title": "PDF to Image",
"desc": "Convert a PDF to a image. (PNG, JPEG, GIF)"
},
"pdfOrganiser": {
"title": "Organise",
"desc": "Remove/Rearrange pages in any order"
@@ -390,14 +383,6 @@
"title": "Add Watermark",
"desc": "Add a custom watermark to your PDF document."
},
"permissions": {
"title": "Change Permissions",
"desc": "Change the permissions of your PDF document"
},
"pageRemover": {
"title": "Remove",
"desc": "Delete unwanted pages from your PDF document."
},
"removePassword": {
"title": "Remove Password",
"desc": "Remove password protection from your PDF document."
@@ -414,10 +399,6 @@
"title": "Change Metadata",
"desc": "Change/Remove/Add metadata from a PDF document"
},
"fileToPDF": {
"title": "Convert file to PDF",
"desc": "Convert nearly any file to PDF (DOCX, PNG, XLS, PPT, TXT and more)"
},
"ocr": {
"title": "OCR / Cleanup scans",
"desc": "Cleanup scans and detects text from images within a PDF and re-adds it as text."
@@ -426,30 +407,6 @@
"title": "Extract Images",
"desc": "Extracts all images from a PDF and saves them to zip"
},
"pdfToPDFA": {
"title": "PDF to PDF/A",
"desc": "Convert PDF to PDF/A for long-term storage"
},
"PDFToWord": {
"title": "PDF to Word",
"desc": "Convert PDF to Word formats (DOC, DOCX and ODT)"
},
"PDFToPresentation": {
"title": "PDF to Presentation",
"desc": "Convert PDF to Presentation formats (PPT, PPTX and ODP)"
},
"PDFToText": {
"title": "PDF to RTF (Text)",
"desc": "Convert PDF to Text or RTF format"
},
"PDFToHTML": {
"title": "PDF to HTML",
"desc": "Convert PDF to HTML format"
},
"PDFToXML": {
"title": "PDF to XML",
"desc": "Convert PDF to XML format"
},
"ScannerImageSplit": {
"title": "Detect/Split Scanned photos",
"desc": "Splits multiple photos from within a photo/PDF"
@@ -518,34 +475,14 @@
"title": "Auto Split Pages",
"desc": "Auto Split Scanned PDF with physical scanned page splitter QR Code"
},
"sanitizePdf": {
"sanitize": {
"title": "Sanitise",
"desc": "Remove scripts and other elements from PDF files"
},
"URLToPDF": {
"title": "URL/Website To PDF",
"desc": "Converts any http(s)URL to PDF"
},
"HTMLToPDF": {
"title": "HTML to PDF",
"desc": "Converts any HTML file or zip to PDF"
},
"MarkdownToPDF": {
"title": "Markdown to PDF",
"desc": "Converts any Markdown file to PDF"
},
"PDFToMarkdown": {
"title": "PDF to Markdown",
"desc": "Converts any PDF to Markdown"
"desc": "Remove potentially harmful elements from PDF files"
},
"getPdfInfo": {
"title": "Get ALL Info on PDF",
"desc": "Grabs any and all information possible on PDFs"
},
"pageExtracter": {
"title": "Extract page(s)",
"desc": "Extracts select pages from PDF"
},
"pdfToSinglePage": {
"title": "PDF to Single Large Page",
"desc": "Merges all PDF pages into one large single page"
@@ -562,14 +499,6 @@
"title": "Manual Redaction",
"desc": "Redacts a PDF based on selected text, drawn shapes and/or selected page(s)"
},
"PDFToCSV": {
"title": "PDF to CSV",
"desc": "Extracts Tables from a PDF converting it to CSV"
},
"split-by-size-or-count": {
"title": "Auto Split by Size/Count",
"desc": "Split a single PDF into multiple documents based on size, page count, or document count"
},
"overlay-pdfs": {
"title": "Overlay PDFs",
"desc": "Overlays PDFs on-top of another PDF"
@@ -610,10 +539,6 @@
"title": "Edit Table of Contents",
"desc": "Add or edit bookmarks and table of contents in PDF documents"
},
"automate": {
"title": "Automate",
"desc": "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks."
},
"manageCertificates": {
"title": "Manage Certificates",
"desc": "Import, export, or delete digital certificate files used for signing PDFs."
@@ -625,6 +550,58 @@
"reorganizePages": {
"title": "Reorganize Pages",
"desc": "Rearrange, duplicate, or delete PDF pages with visual drag-and-drop control."
},
"extractPages": {
"title": "Extract Pages",
"desc": "Extract specific pages from a PDF document"
},
"removePages": {
"title": "Remove Pages",
"desc": "Remove specific pages from a PDF document"
},
"removeImagePdf": {
"title": "Remove Image",
"desc": "Remove images from PDF documents"
},
"autoSizeSplitPDF": {
"title": "Auto Split by Size/Count",
"desc": "Automatically split PDFs by file size or page count"
},
"adjust-contrast": {
"title": "Adjust Colours/Contrast",
"desc": "Adjust colours and contrast of PDF documents"
},
"replaceColorPdf": {
"title": "Replace & Invert Colour",
"desc": "Replace or invert colours in PDF documents"
},
"devApi": {
"title": "API",
"desc": "Link to API documentation"
},
"devFolderScanning": {
"title": "Automated Folder Scanning",
"desc": "Link to automated folder scanning guide"
},
"devSsoGuide": {
"title": "SSO Guide",
"desc": "Link to SSO guide"
},
"devAirgapped": {
"title": "Air-gapped Setup",
"desc": "Link to air-gapped setup guide"
},
"addPassword": {
"title": "Add Password",
"desc": "Add password protection and restrictions to PDF files"
},
"changePermissions": {
"title": "Change Permissions",
"desc": "Change document restrictions and permissions"
},
"automate": {
"title": "Automate",
"desc": "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks."
}
},
"viewPdf": {
@@ -755,7 +732,8 @@
"officeDocs": "Office Documents (Word, Excel, PowerPoint)",
"imagesExt": "Images (JPG, PNG, etc.)",
"markdown": "Markdown",
"textRtf": "Text/RTF"
"textRtf": "Text/RTF",
"grayscale": "Greyscale"
},
"imageToPdf": {
"tags": "conversion,img,jpg,picture,photo"
@@ -1062,7 +1040,18 @@
"tags": "remove,delete,form,field,readonly",
"title": "Remove Read-Only from Form Fields",
"header": "Unlock PDF Forms",
"submit": "Remove"
"submit": "Unlock Forms",
"description": "This tool will remove read-only restrictions from PDF form fields, making them editable and fillable.",
"filenamePrefix": "unlocked_forms",
"files": {
"placeholder": "Select a PDF file in the main view to get started"
},
"error": {
"failed": "An error occurred whilst unlocking PDF forms."
},
"results": {
"title": "Unlocked Forms Results"
}
},
"changeMetadata": {
"tags": "Title,author,date,creation,time,publisher,producer,stats",
@@ -1310,7 +1299,18 @@
"title": "Remove Certificate Signature",
"header": "Remove the digital certificate from the PDF",
"selectPDF": "Select a PDF file:",
"submit": "Remove Signature"
"submit": "Remove Signature",
"description": "This tool will remove digital certificate signatures from your PDF document.",
"filenamePrefix": "unsigned",
"files": {
"placeholder": "Select a PDF file in the main view to get started"
},
"error": {
"failed": "An error occurred whilst removing certificate signatures."
},
"results": {
"title": "Certificate Removal Results"
}
},
"pageLayout": {
"tags": "merge,composite,single-view,organize",
@@ -1638,7 +1638,18 @@
"pdfToSinglePage": {
"title": "PDF To Single Page",
"header": "PDF To Single Page",
"submit": "Convert To Single Page"
"submit": "Convert To Single Page",
"description": "This tool will merge all pages of your PDF into one large single page. The width will remain the same as the original pages, but the height will be the sum of all page heights.",
"filenamePrefix": "single_page",
"files": {
"placeholder": "Select a PDF file in the main view to get started"
},
"error": {
"failed": "An error occurred whilst converting to single page."
},
"results": {
"title": "Single Page Results"
}
},
"pageExtracter": {
"title": "Extract Pages",
@@ -1916,18 +1927,23 @@
"noToolsFound": "No tools found",
"allTools": "ALL TOOLS",
"quickAccess": "QUICK ACCESS",
"categories": {
"standardTools": "Standard Tools",
"advancedTools": "Advanced Tools",
"recommendedTools": "Recommended Tools"
},
"subcategories": {
"Signing": "Signing",
"Document Security": "Document Security",
"Verification": "Verification",
"Document Review": "Document Review",
"Page Formatting": "Page Formatting",
"Extraction": "Extraction",
"Removal": "Removal",
"Automation": "Automation",
"General": "General",
"Advanced Formatting": "Advanced Formatting",
"Developer Tools": "Developer Tools"
"signing": "Signing",
"documentSecurity": "Document Security",
"verification": "Verification",
"documentReview": "Document Review",
"pageFormatting": "Page Formatting",
"extraction": "Extraction",
"removal": "Removal",
"automation": "Automation",
"general": "General",
"advancedFormatting": "Advanced Formatting",
"developerTools": "Developer Tools"
}
},
"quickAccess": {
@@ -1954,11 +1970,14 @@
"dropFilesHere": "Drop files here or click to upload",
"pdfFilesOnly": "PDF files only",
"supportedFileTypes": "Supported file types",
"upload": "Upload",
"uploadFile": "Upload File",
"uploadFiles": "Upload Files",
"noFilesInStorage": "No files available in storage. Upload some files first.",
"selectFromStorage": "Select from Storage",
"backToTools": "Back to Tools"
"backToTools": "Back to Tools",
"addFiles": "Add Files",
"dragFilesInOrClick": "Drag files in or click \"Add Files\" to browse"
},
"fileManager": {
"title": "Upload PDF Files",
@@ -1998,7 +2017,15 @@
"fileSize": "Size",
"fileVersion": "Version",
"totalSelected": "Total Selected",
"dropFilesHere": "Drop files here"
"dropFilesHere": "Drop files here",
"selectAll": "Select All",
"deselectAll": "Deselect All",
"deleteSelected": "Delete Selected",
"downloadSelected": "Download Selected",
"selectedCount": "{{count}} selected",
"download": "Download",
"delete": "Delete",
"unsupported":"Unsupported"
},
"storage": {
"temporaryNotice": "Files are stored temporarily in your browser and may be cleared automatically",
@@ -2168,5 +2195,68 @@
"results": {
"title": "Decrypted PDFs"
}
}
},
"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"
},
"selection": {
"title": "Automation Selection",
"saved": {
"title": "Saved"
},
"createNew": {
"title": "Create New Automation"
},
"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"
},
"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"
}
},
"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"
}
}
}
+36 -3
View File
@@ -812,7 +812,18 @@
"tags": "remove,delete,form,field,readonly",
"title": "Remove Read-Only from Form Fields",
"header": "Unlock PDF Forms",
"submit": "Remove"
"submit": "Unlock Forms",
"description": "This tool will remove read-only restrictions from PDF form fields, making them editable and fillable.",
"filenamePrefix": "unlocked_forms",
"files": {
"placeholder": "Select a PDF file in the main view to get started"
},
"error": {
"failed": "An error occurred while unlocking PDF forms."
},
"results": {
"title": "Unlocked Forms Results"
}
},
"changeMetadata": {
"tags": "Title,author,date,creation,time,publisher,producer,stats",
@@ -1060,7 +1071,18 @@
"title": "Remove Certificate Signature",
"header": "Remove the digital certificate from the PDF",
"selectPDF": "Select a PDF file:",
"submit": "Remove Signature"
"submit": "Remove Signature",
"description": "This tool will remove digital certificate signatures from your PDF document.",
"filenamePrefix": "unsigned",
"files": {
"placeholder": "Select a PDF file in the main view to get started"
},
"error": {
"failed": "An error occurred while removing certificate signatures."
},
"results": {
"title": "Certificate Removal Results"
}
},
"pageLayout": {
"tags": "merge,composite,single-view,organize",
@@ -1388,7 +1410,18 @@
"pdfToSinglePage": {
"title": "PDF To Single Page",
"header": "PDF To Single Page",
"submit": "Convert To Single Page"
"submit": "Convert To Single Page",
"description": "This tool will merge all pages of your PDF into one large single page. The width will remain the same as the original pages, but the height will be the sum of all page heights.",
"filenamePrefix": "single_page",
"files": {
"placeholder": "Select a PDF file in the main view to get started"
},
"error": {
"failed": "An error occurred while converting to single page."
},
"results": {
"title": "Single Page Results"
}
},
"pageExtracter": {
"title": "Extract Pages",
-157
View File
@@ -1,157 +0,0 @@
// Web Worker for parallel thumbnail generation
console.log('🔧 Thumbnail worker starting up...');
let pdfJsLoaded = false;
// Import PDF.js properly for worker context
try {
console.log('📦 Loading PDF.js locally...');
importScripts('/pdf.js');
// PDF.js exports to globalThis, check both self and globalThis
const pdfjsLib = self.pdfjsLib || globalThis.pdfjsLib;
if (pdfjsLib) {
// Make it available on self for consistency
self.pdfjsLib = pdfjsLib;
// Set up PDF.js worker
self.pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdf.worker.js';
pdfJsLoaded = true;
console.log('✓ PDF.js loaded successfully from local files');
console.log('✓ PDF.js version:', self.pdfjsLib.version || 'unknown');
} else {
throw new Error('pdfjsLib not available after import - neither self.pdfjsLib nor globalThis.pdfjsLib found');
}
} catch (error) {
console.error('✗ Failed to load local PDF.js:', error.message || error);
console.error('✗ Available globals:', Object.keys(self).filter(key => key.includes('pdf')));
pdfJsLoaded = false;
}
// Log the final status
if (pdfJsLoaded) {
console.log('✅ Thumbnail worker ready for PDF processing');
} else {
console.log('❌ Thumbnail worker failed to initialize - PDF.js not available');
}
self.onmessage = async function(e) {
const { type, data, jobId } = e.data;
try {
// Handle PING for worker health check
if (type === 'PING') {
console.log('🏓 Worker PING received, checking PDF.js status...');
// Check if PDF.js is loaded before responding
if (pdfJsLoaded && self.pdfjsLib) {
console.log('✓ Worker PONG - PDF.js ready');
self.postMessage({ type: 'PONG', jobId });
} else {
console.error('✗ PDF.js not loaded - worker not ready');
console.error('✗ pdfJsLoaded:', pdfJsLoaded);
console.error('✗ self.pdfjsLib:', !!self.pdfjsLib);
self.postMessage({
type: 'ERROR',
jobId,
data: { error: 'PDF.js not loaded in worker' }
});
}
return;
}
if (type === 'GENERATE_THUMBNAILS') {
console.log('🖼️ Starting thumbnail generation for', data.pageNumbers.length, 'pages');
if (!pdfJsLoaded || !self.pdfjsLib) {
const error = 'PDF.js not available in worker';
console.error('✗', error);
throw new Error(error);
}
const { pdfArrayBuffer, pageNumbers, scale = 0.2, quality = 0.8 } = data;
console.log('📄 Loading PDF document, size:', pdfArrayBuffer.byteLength, 'bytes');
// Load PDF in worker using imported PDF.js
const pdf = await self.pdfjsLib.getDocument({ data: pdfArrayBuffer }).promise;
console.log('✓ PDF loaded, total pages:', pdf.numPages);
const thumbnails = [];
// Process pages in smaller batches for smoother UI
const batchSize = 3; // Process 3 pages at once for smoother UI
for (let i = 0; i < pageNumbers.length; i += batchSize) {
const batch = pageNumbers.slice(i, i + batchSize);
const batchPromises = batch.map(async (pageNumber) => {
try {
console.log(`🎯 Processing page ${pageNumber}...`);
const page = await pdf.getPage(pageNumber);
const viewport = page.getViewport({ scale });
console.log(`📐 Page ${pageNumber} viewport:`, viewport.width, 'x', viewport.height);
// Create OffscreenCanvas for better performance
const canvas = new OffscreenCanvas(viewport.width, viewport.height);
const context = canvas.getContext('2d');
if (!context) {
throw new Error('Failed to get 2D context from OffscreenCanvas');
}
await page.render({ canvasContext: context, viewport }).promise;
console.log(`✓ Page ${pageNumber} rendered`);
// Convert to blob then to base64 (more efficient than toDataURL)
const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality });
const arrayBuffer = await blob.arrayBuffer();
const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
const thumbnail = `data:image/jpeg;base64,${base64}`;
console.log(`✓ Page ${pageNumber} thumbnail generated (${base64.length} chars)`);
return { pageNumber, thumbnail, success: true };
} catch (error) {
console.error(`✗ Failed to generate thumbnail for page ${pageNumber}:`, error.message || error);
return { pageNumber, error: error.message || String(error), success: false };
}
});
const batchResults = await Promise.all(batchPromises);
thumbnails.push(...batchResults);
// Send progress update
console.log(`📊 Worker: Sending progress update - ${thumbnails.length}/${pageNumbers.length} completed, ${batchResults.filter(r => r.success).length} new thumbnails`);
self.postMessage({
type: 'PROGRESS',
jobId,
data: {
completed: thumbnails.length,
total: pageNumbers.length,
thumbnails: batchResults.filter(r => r.success)
}
});
// Small delay between batches to keep UI smooth
if (i + batchSize < pageNumbers.length) {
console.log(`⏸️ Worker: Pausing 100ms before next batch (${i + batchSize}/${pageNumbers.length})`);
await new Promise(resolve => setTimeout(resolve, 100)); // Increased to 100ms pause between batches for smoother scrolling
}
}
// Clean up
pdf.destroy();
self.postMessage({
type: 'COMPLETE',
jobId,
data: { thumbnails: thumbnails.filter(r => r.success) }
});
}
} catch (error) {
self.postMessage({
type: 'ERROR',
jobId,
data: { error: error.message }
});
}
};
+34 -20
View File
@@ -1,23 +1,29 @@
import React, { Suspense } from 'react';
import { RainbowThemeProvider } from './components/shared/RainbowThemeProvider';
import { FileContextProvider } from './contexts/FileContext';
import { FilesModalProvider } from './contexts/FilesModalContext';
import HomePage from './pages/HomePage';
import React, { Suspense } from "react";
import { RainbowThemeProvider } from "./components/shared/RainbowThemeProvider";
import { FileContextProvider } from "./contexts/FileContext";
import { NavigationProvider } from "./contexts/NavigationContext";
import { FilesModalProvider } from "./contexts/FilesModalContext";
import { ToolWorkflowProvider } from "./contexts/ToolWorkflowContext";
import { SidebarProvider } from "./contexts/SidebarContext";
import ErrorBoundary from "./components/shared/ErrorBoundary";
import HomePage from "./pages/HomePage";
// Import global styles
import './styles/tailwind.css';
import './index.css';
import "./styles/tailwind.css";
import "./index.css";
// Loading component for i18next suspense
const LoadingFallback = () => (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
fontSize: '18px',
color: '#666'
}}>
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100vh",
fontSize: "18px",
color: "#666",
}}
>
Loading...
</div>
);
@@ -26,11 +32,19 @@ export default function App() {
return (
<Suspense fallback={<LoadingFallback />}>
<RainbowThemeProvider>
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<FilesModalProvider>
<HomePage />
</FilesModalProvider>
</FileContextProvider>
<ErrorBoundary>
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<SidebarProvider>
<HomePage />
</SidebarProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</FileContextProvider>
</ErrorBoundary>
</RainbowThemeProvider>
</Suspense>
);
+25 -5
View File
@@ -48,7 +48,11 @@ export class RotatePagesCommand extends PageCommand {
return page;
});
this.setPdfDocument({ ...this.pdfDocument, pages: updatedPages });
this.setPdfDocument({
...this.pdfDocument,
pages: updatedPages,
totalPages: updatedPages.length
});
}
get description(): string {
@@ -148,7 +152,11 @@ export class MovePagesCommand extends PageCommand {
pageNumber: index + 1
}));
this.setPdfDocument({ ...this.pdfDocument, pages: newPages });
this.setPdfDocument({
...this.pdfDocument,
pages: newPages,
totalPages: newPages.length
});
}
get description(): string {
@@ -185,7 +193,11 @@ export class ReorderPageCommand extends PageCommand {
pageNumber: index + 1
}));
this.setPdfDocument({ ...this.pdfDocument, pages: updatedPages });
this.setPdfDocument({
...this.pdfDocument,
pages: updatedPages,
totalPages: updatedPages.length
});
}
get description(): string {
@@ -224,7 +236,11 @@ export class ToggleSplitCommand extends PageCommand {
return page;
});
this.setPdfDocument({ ...this.pdfDocument, pages: updatedPages });
this.setPdfDocument({
...this.pdfDocument,
pages: updatedPages,
totalPages: updatedPages.length
});
}
undo(): void {
@@ -236,7 +252,11 @@ export class ToggleSplitCommand extends PageCommand {
return page;
});
this.setPdfDocument({ ...this.pdfDocument, pages: updatedPages });
this.setPdfDocument({
...this.pdfDocument,
pages: updatedPages,
totalPages: updatedPages.length
});
}
get description(): string {
@@ -1,136 +0,0 @@
import React from "react";
import { Card, Stack, Text, Group, Badge, Button, Box, Image, ThemeIcon } from "@mantine/core";
import { useTranslation } from "react-i18next";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import StorageIcon from "@mui/icons-material/Storage";
import { FileWithUrl } from "../types/file";
import { getFileSize, getFileDate } from "../utils/fileUtils";
import { useIndexedDBThumbnail } from "../hooks/useIndexedDBThumbnail";
interface FileCardProps {
file: FileWithUrl;
onRemove: () => void;
onDoubleClick?: () => void;
}
const FileCard: React.FC<FileCardProps> = ({ file, onRemove, onDoubleClick }) => {
const { t } = useTranslation();
const { thumbnail: thumb, isGenerating } = useIndexedDBThumbnail(file);
return (
<Card
shadow="xs"
radius="md"
withBorder
p="xs"
style={{
width: 225,
minWidth: 180,
maxWidth: 260,
cursor: onDoubleClick ? "pointer" : undefined
}}
onDoubleClick={onDoubleClick}
>
<Stack gap={6} align="center">
<Box
style={{
border: "2px solid #e0e0e0",
borderRadius: 8,
width: 90,
height: 120,
display: "flex",
alignItems: "center",
justifyContent: "center",
margin: "0 auto",
background: "#fafbfc",
}}
>
{thumb ? (
<Image
src={thumb}
alt="PDF thumbnail"
height={110}
width={80}
fit="contain"
radius="sm"
/>
) : isGenerating ? (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center'
}}>
<div style={{
width: 20,
height: 20,
border: '2px solid #ddd',
borderTop: '2px solid #666',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
marginBottom: 8
}} />
<Text size="xs" c="dimmed">Generating...</Text>
</div>
) : (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center'
}}>
<ThemeIcon
variant="light"
color={file.size > 100 * 1024 * 1024 ? "orange" : "red"}
size={60}
radius="sm"
style={{ display: "flex", alignItems: "center", justifyContent: "center" }}
>
<PictureAsPdfIcon style={{ fontSize: 40 }} />
</ThemeIcon>
{file.size > 100 * 1024 * 1024 && (
<Text size="xs" c="dimmed" mt={4}>Large File</Text>
)}
</div>
)}
</Box>
<Text fw={500} size="sm" lineClamp={1} ta="center">
{file.name}
</Text>
<Group gap="xs" justify="center">
<Badge color="gray" variant="light" size="sm">
{getFileSize(file)}
</Badge>
<Badge color="blue" variant="light" size="sm">
{getFileDate(file)}
</Badge>
{file.storedInIndexedDB && (
<Badge
color="green"
variant="light"
size="sm"
leftSection={<StorageIcon style={{ fontSize: 12 }} />}
>
DB
</Badge>
)}
</Group>
<Button
color="red"
size="xs"
variant="light"
onClick={onRemove}
mt={4}
>
{t("delete", "Remove")}
</Button>
</Stack>
</Card>
);
};
export default FileCard;
+28 -21
View File
@@ -1,9 +1,10 @@
import React, { useState, useCallback, useEffect } from 'react';
import { Modal } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { FileWithUrl } from '../types/file';
import { FileMetadata } from '../types/file';
import { useFileManager } from '../hooks/useFileManager';
import { useFilesModalContext } from '../contexts/FilesModalContext';
import { createFileId } from '../types/fileContext';
import { Tool } from '../types/tool';
import MobileLayout from './fileManager/MobileLayout';
import DesktopLayout from './fileManager/DesktopLayout';
@@ -15,13 +16,19 @@ interface FileManagerProps {
}
const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
const { isFilesModalOpen, closeFilesModal, onFilesSelect } = useFilesModalContext();
const [recentFiles, setRecentFiles] = useState<FileWithUrl[]>([]);
const { isFilesModalOpen, closeFilesModal, onFilesSelect, onStoredFilesSelect } = useFilesModalContext();
const [recentFiles, setRecentFiles] = useState<FileMetadata[]>([]);
const [isDragging, setIsDragging] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const { loadRecentFiles, handleRemoveFile, storeFile, convertToFile } = useFileManager();
// Wrapper for storeFile that generates UUID
const storeFileWithId = useCallback(async (file: File) => {
const fileId = createFileId(); // Generate UUID for storage
return await storeFile(file, fileId);
}, [storeFile]);
// File management handlers
const isFileSupported = useCallback((fileName: string) => {
if (!selectedTool?.supportedFormats) return true;
@@ -34,18 +41,21 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
setRecentFiles(files);
}, [loadRecentFiles]);
const handleFilesSelected = useCallback(async (files: FileWithUrl[]) => {
const handleFilesSelected = useCallback(async (files: FileMetadata[]) => {
try {
const fileObjects = await Promise.all(
files.map(async (fileWithUrl) => {
return await convertToFile(fileWithUrl);
})
// Use stored files flow that preserves original IDs
const filesWithMetadata = await Promise.all(
files.map(async (metadata) => ({
file: await convertToFile(metadata),
originalId: metadata.id,
metadata
}))
);
onFilesSelect(fileObjects);
onStoredFilesSelect(filesWithMetadata);
} catch (error) {
console.error('Failed to process selected files:', error);
}
}, [convertToFile, onFilesSelect]);
}, [convertToFile, onStoredFilesSelect]);
const handleNewFileUpload = useCallback(async (files: File[]) => {
if (files.length > 0) {
@@ -82,14 +92,11 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
// Cleanup any blob URLs when component unmounts
useEffect(() => {
return () => {
// Clean up blob URLs from recent files
recentFiles.forEach(file => {
if (file.url && file.url.startsWith('blob:')) {
URL.revokeObjectURL(file.url);
}
});
// FileMetadata doesn't have blob URLs, so no cleanup needed
// Blob URLs are managed by FileContext and tool operations
console.log('FileManager unmounting - FileContext handles blob URL cleanup');
};
}, [recentFiles]);
}, []);
// Modal size constants for consistent scaling
const modalHeight = '80vh';
@@ -104,7 +111,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
onClose={closeFilesModal}
size={isMobile ? "100%" : "auto"}
centered
radius={30}
radius="md"
className="overflow-hidden p-0"
withCloseButton={false}
styles={{
@@ -130,14 +137,14 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
onDrop={handleNewFileUpload}
onDragEnter={() => setIsDragging(true)}
onDragLeave={() => setIsDragging(false)}
accept={["*/*"] as any}
accept={{}}
multiple={true}
activateOnClick={false}
style={{
height: '100%',
width: '100%',
border: 'none',
borderRadius: '30px',
borderRadius: 'var(--radius-md)',
backgroundColor: 'var(--bg-file-manager)'
}}
styles={{
@@ -147,12 +154,12 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
<FileManagerProvider
recentFiles={recentFiles}
onFilesSelected={handleFilesSelected}
onNewFilesSelect={handleNewFileUpload}
onClose={closeFilesModal}
isFileSupported={isFileSupported}
isOpen={isFilesModalOpen}
onFileRemove={handleRemoveFileByIndex}
modalHeight={modalHeight}
storeFile={storeFile}
refreshRecentFiles={refreshRecentFiles}
>
{isMobile ? <MobileLayout /> : <DesktopLayout />}
+183 -438
View File
@@ -1,4 +1,4 @@
import React, { useState, useCallback, useRef, useEffect } from 'react';
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import {
Button, Text, Center, Box, Notification, TextInput, LoadingOverlay, Modal, Alert, Container,
Stack, Group
@@ -6,8 +6,8 @@ import {
import { Dropzone } from '@mantine/dropzone';
import { useTranslation } from 'react-i18next';
import UploadFileIcon from '@mui/icons-material/UploadFile';
import { useFileContext } from '../../contexts/FileContext';
import { useFileSelection } from '../../contexts/FileSelectionContext';
import { useFileSelection, useFileState, useFileManagement, useFileActions } from '../../contexts/FileContext';
import { useNavigationActions } from '../../contexts/NavigationContext';
import { FileOperation } from '../../types/fileContext';
import { fileStorage } from '../../services/fileStorage';
import { generateThumbnailForFile } from '../../utils/thumbnailUtils';
@@ -15,19 +15,9 @@ import { zipFileService } from '../../services/zipFileService';
import { detectFileExtension } from '../../utils/fileUtils';
import styles from '../pageEditor/PageEditor.module.css';
import FileThumbnail from '../pageEditor/FileThumbnail';
import DragDropGrid from '../pageEditor/DragDropGrid';
import FilePickerModal from '../shared/FilePickerModal';
import SkeletonLoader from '../shared/SkeletonLoader';
interface FileItem {
id: string;
name: string;
pageCount: number;
thumbnail: string;
size: number;
file: File;
splitBefore?: boolean;
}
interface FileEditorProps {
onOpenPageEditor?: (file: File) => void;
@@ -54,33 +44,25 @@ const FileEditor = ({
return extension ? supportedExtensions.includes(extension) : false;
}, [supportedExtensions]);
// Get file context
const fileContext = useFileContext();
const {
activeFiles,
processedFiles,
selectedFileIds,
setSelectedFiles: setContextSelectedFiles,
isProcessing,
addFiles,
removeFiles,
setCurrentView,
recordOperation,
markOperationApplied
} = fileContext;
// Use optimized FileContext hooks
const { state, selectors } = useFileState();
const { addFiles, removeFiles, reorderFiles } = useFileManagement();
// Extract needed values from state (memoized to prevent infinite loops)
const activeFiles = useMemo(() => selectors.getFiles(), [selectors.getFilesSignature()]);
const activeFileRecords = useMemo(() => selectors.getFileRecords(), [selectors.getFilesSignature()]);
const selectedFileIds = state.ui.selectedFileIds;
const isProcessing = state.ui.isProcessing;
// Get the real context actions
const { actions } = useFileActions();
const { actions: navActions } = useNavigationActions();
// Get file selection context
const {
selectedFiles: toolSelectedFiles,
setSelectedFiles: setToolSelectedFiles,
maxFiles,
isToolMode
} = useFileSelection();
const { setSelectedFiles } = useFileSelection();
const [files, setFiles] = useState<FileItem[]>([]);
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [localLoading, setLocalLoading] = useState(false);
const [selectionMode, setSelectionMode] = useState(toolMode);
// Enable selection mode automatically in tool mode
@@ -89,13 +71,7 @@ const FileEditor = ({
setSelectionMode(true);
}
}, [toolMode]);
const [draggedFile, setDraggedFile] = useState<string | null>(null);
const [dropTarget, setDropTarget] = useState<string | null>(null);
const [multiFileDrag, setMultiFileDrag] = useState<{fileIds: string[], count: number} | null>(null);
const [dragPosition, setDragPosition] = useState<{x: number, y: number} | null>(null);
const [isAnimating, setIsAnimating] = useState(false);
const [showFilePickerModal, setShowFilePickerModal] = useState(false);
const [conversionProgress, setConversionProgress] = useState(0);
const [zipExtractionProgress, setZipExtractionProgress] = useState<{
isExtracting: boolean;
currentFile: string;
@@ -109,115 +85,30 @@ const FileEditor = ({
extractedCount: 0,
totalFiles: 0
});
const fileRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const lastActiveFilesRef = useRef<string[]>([]);
const lastProcessedFilesRef = useRef<number>(0);
// Get selected file IDs from context (defensive programming)
const contextSelectedIds = Array.isArray(selectedFileIds) ? selectedFileIds : [];
// Create refs for frequently changing values to stabilize callbacks
const contextSelectedIdsRef = useRef<string[]>([]);
contextSelectedIdsRef.current = contextSelectedIds;
// Map context selections to local file IDs for UI display
const localSelectedIds = files
.filter(file => {
const fileId = (file.file as any).id || file.name;
return contextSelectedIds.includes(fileId);
})
.map(file => file.id);
// Convert shared files to FileEditor format
const convertToFileItem = useCallback(async (sharedFile: any): Promise<FileItem> => {
// Generate thumbnail if not already available
const thumbnail = sharedFile.thumbnail || await generateThumbnailForFile(sharedFile.file || sharedFile);
// Use activeFileRecords directly - no conversion needed
const localSelectedIds = contextSelectedIds;
// Helper to convert FileRecord to FileThumbnail format
const recordToFileItem = useCallback((record: any) => {
const file = selectors.getFile(record.id);
if (!file) return null;
return {
id: sharedFile.id || `file-${Date.now()}-${Math.random()}`,
name: (sharedFile.file?.name || sharedFile.name || 'unknown'),
pageCount: sharedFile.pageCount || Math.floor(Math.random() * 20) + 1, // Mock for now
thumbnail,
size: sharedFile.file?.size || sharedFile.size || 0,
file: sharedFile.file || sharedFile,
id: record.id,
name: file.name,
pageCount: record.processedFile?.totalPages || 1,
thumbnail: record.thumbnailUrl || '',
size: file.size,
file: file
};
}, []);
// Convert activeFiles to FileItem format using context (async to avoid blocking)
useEffect(() => {
// Check if the actual content has changed, not just references
const currentActiveFileNames = activeFiles.map(f => f.name);
const currentProcessedFilesSize = processedFiles.size;
const activeFilesChanged = JSON.stringify(currentActiveFileNames) !== JSON.stringify(lastActiveFilesRef.current);
const processedFilesChanged = currentProcessedFilesSize !== lastProcessedFilesRef.current;
if (!activeFilesChanged && !processedFilesChanged) {
return;
}
// Update refs
lastActiveFilesRef.current = currentActiveFileNames;
lastProcessedFilesRef.current = currentProcessedFilesSize;
const convertActiveFiles = async () => {
if (activeFiles.length > 0) {
setLocalLoading(true);
try {
// Process files in chunks to avoid blocking UI
const convertedFiles: FileItem[] = [];
for (let i = 0; i < activeFiles.length; i++) {
const file = activeFiles[i];
// Try to get thumbnail from processed file first
const processedFile = processedFiles.get(file);
let thumbnail = processedFile?.pages?.[0]?.thumbnail;
// If no thumbnail from processed file, try to generate one
if (!thumbnail) {
try {
thumbnail = await generateThumbnailForFile(file);
} catch (error) {
console.warn(`Failed to generate thumbnail for ${file.name}:`, error);
thumbnail = undefined; // Use placeholder
}
}
const convertedFile = {
id: `file-${Date.now()}-${Math.random()}`,
name: file.name,
pageCount: processedFile?.totalPages || Math.floor(Math.random() * 20) + 1,
thumbnail: thumbnail || '',
size: file.size,
file,
};
convertedFiles.push(convertedFile);
// Update progress
setConversionProgress(((i + 1) / activeFiles.length) * 100);
// Yield to main thread between files
if (i < activeFiles.length - 1) {
await new Promise(resolve => requestAnimationFrame(resolve));
}
}
setFiles(convertedFiles);
} catch (err) {
console.error('Error converting active files:', err);
} finally {
setLocalLoading(false);
setConversionProgress(0);
}
} else {
setFiles([]);
setLocalLoading(false);
setConversionProgress(0);
}
};
convertActiveFiles();
}, [activeFiles, processedFiles]);
}, [selectors]);
// Process uploaded files using context
@@ -289,10 +180,7 @@ const FileEditor = ({
}
}
};
recordOperation(file.name, operation);
markOperationApplied(file.name, operationId);
if (extractionResult.errors.length > 0) {
errors.push(...extractionResult.errors);
}
@@ -301,7 +189,6 @@ const FileEditor = ({
}
} else {
// ZIP doesn't contain PDFs or is invalid - treat as regular file
console.log(`Adding ZIP file as regular file: ${file.name} (no PDFs found)`);
allExtractedFiles.push(file);
}
} catch (zipError) {
@@ -315,7 +202,6 @@ const FileEditor = ({
});
}
} else {
console.log(`Adding none PDF file: ${file.name} (${file.type})`);
allExtractedFiles.push(file);
}
}
@@ -344,9 +230,6 @@ const FileEditor = ({
}
}
};
recordOperation(file.name, operation);
markOperationApplied(file.name, operationId);
}
// Add files to context (they will be processed automatically)
@@ -357,7 +240,7 @@ const FileEditor = ({
const errorMessage = err instanceof Error ? err.message : 'Failed to process files';
setError(errorMessage);
console.error('File processing error:', err);
// Reset extraction progress on error
setZipExtractionProgress({
isExtracting: false,
@@ -367,220 +250,137 @@ const FileEditor = ({
totalFiles: 0
});
}
}, [addFiles, recordOperation, markOperationApplied]);
}, [addFiles]);
const selectAll = useCallback(() => {
setContextSelectedFiles(files.map(f => (f.file as any).id || f.name));
}, [files, setContextSelectedFiles]);
setSelectedFiles(activeFileRecords.map(r => r.id)); // Use FileRecord IDs directly
}, [activeFileRecords, setSelectedFiles]);
const deselectAll = useCallback(() => setContextSelectedFiles([]), [setContextSelectedFiles]);
const deselectAll = useCallback(() => setSelectedFiles([]), [setSelectedFiles]);
const closeAllFiles = useCallback(() => {
if (activeFiles.length === 0) return;
// Record close all operation for each file
activeFiles.forEach(file => {
const operationId = `close-all-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const operation: FileOperation = {
id: operationId,
type: 'remove',
timestamp: Date.now(),
fileIds: [file.name],
status: 'pending',
metadata: {
originalFileName: file.name,
fileSize: file.size,
parameters: {
action: 'close_all',
reason: 'user_request'
}
}
};
recordOperation(file.name, operation);
markOperationApplied(file.name, operationId);
});
if (activeFileRecords.length === 0) return;
// Remove all files from context but keep in storage
removeFiles(activeFiles.map(f => (f as any).id || f.name), false);
const allFileIds = activeFileRecords.map(record => record.id);
removeFiles(allFileIds, false); // false = keep in storage
// Clear selections
setContextSelectedFiles([]);
}, [activeFiles, removeFiles, setContextSelectedFiles, recordOperation, markOperationApplied]);
setSelectedFiles([]);
}, [activeFileRecords, removeFiles, setSelectedFiles]);
const toggleFile = useCallback((fileId: string) => {
const targetFile = files.find(f => f.id === fileId);
if (!targetFile) return;
const currentSelectedIds = contextSelectedIdsRef.current;
const targetRecord = activeFileRecords.find(r => r.id === fileId);
if (!targetRecord) return;
const contextFileId = (targetFile.file as any).id || targetFile.name;
const isSelected = contextSelectedIds.includes(contextFileId);
const contextFileId = fileId; // No need to create a new ID
const isSelected = currentSelectedIds.includes(contextFileId);
let newSelection: string[];
if (isSelected) {
// Remove file from selection
newSelection = contextSelectedIds.filter(id => id !== contextFileId);
newSelection = currentSelectedIds.filter(id => id !== contextFileId);
} else {
// Add file to selection
if (maxFiles === 1) {
// In tool mode, typically allow multiple files unless specified otherwise
const maxAllowed = toolMode ? 10 : Infinity; // Default max for tools
if (maxAllowed === 1) {
newSelection = [contextFileId];
} else {
// Check if we've hit the selection limit
if (maxFiles > 1 && contextSelectedIds.length >= maxFiles) {
setStatus(`Maximum ${maxFiles} files can be selected`);
if (maxAllowed > 1 && currentSelectedIds.length >= maxAllowed) {
setStatus(`Maximum ${maxAllowed} files can be selected`);
return;
}
newSelection = [...contextSelectedIds, contextFileId];
newSelection = [...currentSelectedIds, contextFileId];
}
}
// Update context
setContextSelectedFiles(newSelection);
// Update tool selection context if in tool mode
if (isToolMode || toolMode) {
const selectedFiles = files
.filter(f => {
const fId = (f.file as any).id || f.name;
return newSelection.includes(fId);
})
.map(f => f.file);
setToolSelectedFiles(selectedFiles);
}
}, [files, setContextSelectedFiles, maxFiles, contextSelectedIds, setStatus, isToolMode, toolMode, setToolSelectedFiles]);
// Update context (this automatically updates tool selection since they use the same action)
setSelectedFiles(newSelection);
}, [setSelectedFiles, toolMode, setStatus, activeFileRecords]);
const toggleSelectionMode = useCallback(() => {
setSelectionMode(prev => {
const newMode = !prev;
if (!newMode) {
setContextSelectedFiles([]);
setSelectedFiles([]);
}
return newMode;
});
}, [setContextSelectedFiles]);
}, [setSelectedFiles]);
// Drag and drop handlers
const handleDragStart = useCallback((fileId: string) => {
setDraggedFile(fileId);
if (selectionMode && localSelectedIds.includes(fileId) && localSelectedIds.length > 1) {
setMultiFileDrag({
fileIds: localSelectedIds,
count: localSelectedIds.length
});
} else {
setMultiFileDrag(null);
}
}, [selectionMode, localSelectedIds]);
const handleDragEnd = useCallback(() => {
setDraggedFile(null);
setDropTarget(null);
setMultiFileDrag(null);
setDragPosition(null);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
if (!draggedFile) return;
if (multiFileDrag) {
setDragPosition({ x: e.clientX, y: e.clientY });
}
const elementUnderCursor = document.elementFromPoint(e.clientX, e.clientY);
if (!elementUnderCursor) return;
const fileContainer = elementUnderCursor.closest('[data-file-id]');
if (fileContainer) {
const fileId = fileContainer.getAttribute('data-file-id');
if (fileId && fileId !== draggedFile) {
setDropTarget(fileId);
return;
}
}
const endZone = elementUnderCursor.closest('[data-drop-zone="end"]');
if (endZone) {
setDropTarget('end');
// File reordering handler for drag and drop
const handleReorderFiles = useCallback((sourceFileId: string, targetFileId: string, selectedFileIds: string[]) => {
const currentIds = activeFileRecords.map(r => r.id);
// Find indices
const sourceIndex = currentIds.findIndex(id => id === sourceFileId);
const targetIndex = currentIds.findIndex(id => id === targetFileId);
if (sourceIndex === -1 || targetIndex === -1) {
console.warn('Could not find source or target file for reordering');
return;
}
setDropTarget(null);
}, [draggedFile, multiFileDrag]);
// Handle multi-file selection reordering
const filesToMove = selectedFileIds.length > 1
? selectedFileIds.filter(id => currentIds.includes(id))
: [sourceFileId];
const handleDragEnter = useCallback((fileId: string) => {
if (draggedFile && fileId !== draggedFile) {
setDropTarget(fileId);
}
}, [draggedFile]);
const handleDragLeave = useCallback(() => {
// Let dragover handle this
}, []);
const handleDrop = useCallback((e: React.DragEvent, targetFileId: string | 'end') => {
e.preventDefault();
if (!draggedFile || draggedFile === targetFileId) return;
let targetIndex: number;
if (targetFileId === 'end') {
targetIndex = files.length;
} else {
targetIndex = files.findIndex(f => f.id === targetFileId);
if (targetIndex === -1) return;
}
const filesToMove = selectionMode && localSelectedIds.includes(draggedFile)
? localSelectedIds
: [draggedFile];
// Update the local files state and sync with activeFiles
setFiles(prev => {
const newFiles = [...prev];
const movedFiles = filesToMove.map(id => newFiles.find(f => f.id === id)!).filter(Boolean);
// Remove moved files
filesToMove.forEach(id => {
const index = newFiles.findIndex(f => f.id === id);
if (index !== -1) newFiles.splice(index, 1);
});
// Insert at target position
newFiles.splice(targetIndex, 0, ...movedFiles);
// TODO: Update context with reordered files (need to implement file reordering in context)
// For now, just return the reordered local state
return newFiles;
// Create new order
const newOrder = [...currentIds];
// Remove files to move from their current positions (in reverse order to maintain indices)
const sourceIndices = filesToMove.map(id => newOrder.findIndex(nId => nId === id))
.sort((a, b) => b - a); // Sort descending
sourceIndices.forEach(index => {
newOrder.splice(index, 1);
});
const moveCount = multiFileDrag ? multiFileDrag.count : 1;
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
}, [draggedFile, files, selectionMode, localSelectedIds, multiFileDrag]);
const handleEndZoneDragEnter = useCallback(() => {
if (draggedFile) {
setDropTarget('end');
// Calculate insertion index after removals
let insertIndex = newOrder.findIndex(id => id === targetFileId);
if (insertIndex !== -1) {
// Determine if moving forward or backward
const isMovingForward = sourceIndex < targetIndex;
if (isMovingForward) {
// Moving forward: insert after target
insertIndex += 1;
} else {
// Moving backward: insert before target (insertIndex already correct)
}
} else {
// Target was moved, insert at end
insertIndex = newOrder.length;
}
}, [draggedFile]);
// Insert files at the calculated position
newOrder.splice(insertIndex, 0, ...filesToMove);
// Update file order
reorderFiles(newOrder);
// Update status
const moveCount = filesToMove.length;
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
}, [activeFileRecords, reorderFiles, setStatus]);
// File operations using context
const handleDeleteFile = useCallback((fileId: string) => {
console.log('handleDeleteFile called with fileId:', fileId);
const file = files.find(f => f.id === fileId);
console.log('Found file:', file);
if (file) {
console.log('Attempting to remove file:', file.name);
console.log('Actual file object:', file.file);
console.log('Actual file.file.name:', file.file.name);
const record = activeFileRecords.find(r => r.id === fileId);
const file = record ? selectors.getFile(record.id) : null;
if (record && file) {
// Record close operation
const fileName = file.file.name;
const fileId = (file.file as any).id || fileName;
const fileName = file.name;
const contextFileId = record.id;
const operationId = `close-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const operation: FileOperation = {
id: operationId,
@@ -590,75 +390,62 @@ const FileEditor = ({
status: 'pending',
metadata: {
originalFileName: fileName,
fileSize: file.size,
fileSize: record.size,
parameters: {
action: 'close',
reason: 'user_request'
}
}
};
recordOperation(fileName, operation);
// Remove file from context but keep in storage (close, don't delete)
console.log('Calling removeFiles with:', [fileId]);
removeFiles([fileId], false);
removeFiles([contextFileId], false);
// Remove from context selections
const newSelection = contextSelectedIds.filter(id => id !== fileId);
setContextSelectedFiles(newSelection);
// Mark operation as applied
markOperationApplied(fileName, operationId);
} else {
console.log('File not found for fileId:', fileId);
const currentSelected = selectedFileIds.filter(id => id !== contextFileId);
setSelectedFiles(currentSelected);
}
}, [files, removeFiles, setContextSelectedFiles, recordOperation, markOperationApplied]);
}, [activeFileRecords, selectors, removeFiles, setSelectedFiles, selectedFileIds]);
const handleViewFile = useCallback((fileId: string) => {
const file = files.find(f => f.id === fileId);
if (file) {
// Set the file as selected in context and switch to page editor view
const contextFileId = (file.file as any).id || file.name;
setContextSelectedFiles([contextFileId]);
setCurrentView('pageEditor');
onOpenPageEditor?.(file.file);
const record = activeFileRecords.find(r => r.id === fileId);
if (record) {
// Set the file as selected in context and switch to viewer for preview
setSelectedFiles([fileId]);
navActions.setMode('viewer');
}
}, [files, setContextSelectedFiles, setCurrentView, onOpenPageEditor]);
}, [activeFileRecords, setSelectedFiles, navActions.setMode]);
const handleMergeFromHere = useCallback((fileId: string) => {
const startIndex = files.findIndex(f => f.id === fileId);
const startIndex = activeFileRecords.findIndex(r => r.id === fileId);
if (startIndex === -1) return;
const filesToMerge = files.slice(startIndex).map(f => f.file);
const recordsToMerge = activeFileRecords.slice(startIndex);
const filesToMerge = recordsToMerge.map(r => selectors.getFile(r.id)).filter(Boolean) as File[];
if (onMergeFiles) {
onMergeFiles(filesToMerge);
}
}, [files, onMergeFiles]);
}, [activeFileRecords, selectors, onMergeFiles]);
const handleSplitFile = useCallback((fileId: string) => {
const file = files.find(f => f.id === fileId);
const file = selectors.getFile(fileId);
if (file && onOpenPageEditor) {
onOpenPageEditor(file.file);
onOpenPageEditor(file);
}
}, [files, onOpenPageEditor]);
}, [selectors, onOpenPageEditor]);
const handleLoadFromStorage = useCallback(async (selectedFiles: any[]) => {
if (selectedFiles.length === 0) return;
setLocalLoading(true);
try {
const convertedFiles = await Promise.all(
selectedFiles.map(convertToFileItem)
);
setFiles(prev => [...prev, ...convertedFiles]);
// Use FileContext to handle loading stored files
// The files are already in FileContext, just need to add them to active files
setStatus(`Loaded ${selectedFiles.length} files from storage`);
} catch (err) {
console.error('Error loading files from storage:', err);
setError('Failed to load some files from storage');
} finally {
setLocalLoading(false);
}
}, [convertToFileItem]);
}, []);
return (
@@ -680,10 +467,14 @@ const FileEditor = ({
<Box p="md" pt="xl">
<Group mb="md">
{showBulkActions && !toolMode && (
{toolMode && (
<>
<Button onClick={selectAll} variant="light">Select All</Button>
<Button onClick={deselectAll} variant="light">Deselect All</Button>
</>
)}
{showBulkActions && !toolMode && (
<>
<Button onClick={closeAllFiles} variant="light" color="orange">
Close All
</Button>
@@ -692,7 +483,7 @@ const FileEditor = ({
</Group>
{files.length === 0 && !localLoading && !zipExtractionProgress.isExtracting ? (
{activeFileRecords.length === 0 && !zipExtractionProgress.isExtracting ? (
<Center h="60vh">
<Stack align="center" gap="md">
<Text size="lg" c="dimmed">📁</Text>
@@ -700,7 +491,7 @@ const FileEditor = ({
<Text size="sm" c="dimmed">Upload PDF files, ZIP archives, or load from storage to get started</Text>
</Stack>
</Center>
) : files.length === 0 && (localLoading || zipExtractionProgress.isExtracting) ? (
) : activeFileRecords.length === 0 && zipExtractionProgress.isExtracting ? (
<Box>
<SkeletonLoader type="controls" />
@@ -734,88 +525,42 @@ const FileEditor = ({
</Box>
)}
{/* Processing indicator */}
{localLoading && (
<Box mb="md" p="sm" style={{ backgroundColor: 'var(--mantine-color-blue-0)', borderRadius: 8 }}>
<Group justify="space-between" mb="xs">
<Text size="sm" fw={500}>Loading files...</Text>
<Text size="sm" c="dimmed">{Math.round(conversionProgress)}%</Text>
</Group>
<div style={{
width: '100%',
height: '4px',
backgroundColor: 'var(--mantine-color-gray-2)',
borderRadius: '2px',
overflow: 'hidden'
}}>
<div style={{
width: `${Math.round(conversionProgress)}%`,
height: '100%',
backgroundColor: 'var(--mantine-color-blue-6)',
transition: 'width 0.3s ease'
}} />
</div>
</Box>
)}
<SkeletonLoader type="fileGrid" count={6} />
</Box>
) : (
<DragDropGrid
items={files}
selectedItems={localSelectedIds as any /* FIX ME */}
selectionMode={selectionMode}
isAnimating={isAnimating}
onDragStart={handleDragStart as any /* FIX ME */}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter as any /* FIX ME */}
onDragLeave={handleDragLeave}
onDrop={handleDrop as any /* FIX ME */}
onEndZoneDragEnter={handleEndZoneDragEnter}
draggedItem={draggedFile as any /* FIX ME */}
dropTarget={dropTarget as any /* FIX ME */}
multiItemDrag={multiFileDrag as any /* FIX ME */}
dragPosition={dragPosition}
renderItem={(file, index, refs) => (
<FileThumbnail
file={file}
index={index}
totalFiles={files.length}
selectedFiles={localSelectedIds}
selectionMode={selectionMode}
draggedFile={draggedFile}
dropTarget={dropTarget}
isAnimating={isAnimating}
fileRefs={refs}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onToggleFile={toggleFile}
onDeleteFile={handleDeleteFile}
onViewFile={handleViewFile}
onSetStatus={setStatus}
toolMode={toolMode}
isSupported={isFileSupported(file.name)}
/>
)}
renderSplitMarker={(file, index) => (
<div
style={{
width: '2px',
height: '24rem',
borderLeft: '2px dashed #3b82f6',
backgroundColor: 'transparent',
marginLeft: '-0.75rem',
marginRight: '-0.75rem',
flexShrink: 0
}}
/>
)}
/>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
gap: '1.5rem',
padding: '1rem',
pointerEvents: 'auto'
}}
>
{activeFileRecords.map((record, index) => {
const fileItem = recordToFileItem(record);
if (!fileItem) return null;
return (
<FileThumbnail
key={record.id}
file={fileItem}
index={index}
totalFiles={activeFileRecords.length}
selectedFiles={localSelectedIds}
selectionMode={selectionMode}
onToggleFile={toggleFile}
onDeleteFile={handleDeleteFile}
onViewFile={handleViewFile}
onSetStatus={setStatus}
onReorderFiles={handleReorderFiles}
toolMode={toolMode}
isSupported={isFileSupported(fileItem.name)}
/>
);
})}
</div>
)}
</Box>
@@ -5,12 +5,12 @@ import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import { useTranslation } from 'react-i18next';
import { getFileSize } from '../../utils/fileUtils';
import { FileWithUrl } from '../../types/file';
import { FileMetadata } from '../../types/file';
interface CompactFileDetailsProps {
currentFile: FileWithUrl | null;
currentFile: FileMetadata | null;
thumbnail: string | null;
selectedFiles: FileWithUrl[];
selectedFiles: FileMetadata[];
currentFileIndex: number;
numberOfFiles: number;
isAnimating: boolean;
@@ -4,6 +4,7 @@ import FileSourceButtons from './FileSourceButtons';
import FileDetails from './FileDetails';
import SearchInput from './SearchInput';
import FileListArea from './FileListArea';
import FileActions from './FileActions';
import HiddenFileInput from './HiddenFileInput';
import { useFileManagerContext } from '../../contexts/FileManagerContext';
@@ -17,27 +18,27 @@ const DesktopLayout: React.FC = () => {
return (
<Grid gutter="xs" h="100%" grow={false} style={{ flexWrap: 'nowrap', minWidth: 0 }}>
{/* Column 1: File Sources */}
<Grid.Col span="content" p="lg" style={{
minWidth: '13.625rem',
width: '13.625rem',
flexShrink: 0,
<Grid.Col span="content" p="lg" style={{
minWidth: '13.625rem',
width: '13.625rem',
flexShrink: 0,
height: '100%',
}}>
<FileSourceButtons />
</Grid.Col>
{/* Column 2: File List */}
<Grid.Col span="auto" style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
<Grid.Col span="auto" style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
minHeight: 0,
minWidth: 0,
flex: '1 1 0px'
}}>
<div style={{
flex: 1,
display: 'flex',
<div style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
backgroundColor: 'var(--bg-file-list)',
border: '1px solid var(--mantine-color-gray-2)',
@@ -45,18 +46,26 @@ const DesktopLayout: React.FC = () => {
overflow: 'hidden'
}}>
{activeSource === 'recent' && (
<div style={{
flexShrink: 0,
borderBottom: '1px solid var(--mantine-color-gray-3)'
}}>
<SearchInput />
</div>
<>
<div style={{
flexShrink: 0,
borderBottom: '1px solid var(--mantine-color-gray-3)'
}}>
<SearchInput />
</div>
<div style={{
flexShrink: 0,
borderBottom: '1px solid var(--mantine-color-gray-3)'
}}>
<FileActions />
</div>
</>
)}
<div style={{ flex: 1, minHeight: 0 }}>
<FileListArea
scrollAreaHeight={`calc(${modalHeight} )`}
scrollAreaStyle={{
scrollAreaStyle={{
height: activeSource === 'recent' && recentFiles.length > 0 ? modalHeight : '100%',
backgroundColor: 'transparent',
border: 'none',
@@ -66,12 +75,12 @@ const DesktopLayout: React.FC = () => {
</div>
</div>
</Grid.Col>
{/* Column 3: File Details */}
<Grid.Col p="xl" span="content" style={{
minWidth: '25rem',
width: '25rem',
flexShrink: 0,
<Grid.Col p="xl" span="content" style={{
minWidth: '25rem',
width: '25rem',
flexShrink: 0,
height: '100%',
maxWidth: '18rem'
}}>
@@ -79,11 +88,11 @@ const DesktopLayout: React.FC = () => {
<FileDetails />
</div>
</Grid.Col>
{/* Hidden file input for local file selection */}
<HiddenFileInput />
</Grid>
);
};
export default DesktopLayout;
export default DesktopLayout;
@@ -0,0 +1,115 @@
import React from "react";
import { Group, Text, ActionIcon, Tooltip } from "@mantine/core";
import SelectAllIcon from "@mui/icons-material/SelectAll";
import DeleteIcon from "@mui/icons-material/Delete";
import DownloadIcon from "@mui/icons-material/Download";
import { useTranslation } from "react-i18next";
import { useFileManagerContext } from "../../contexts/FileManagerContext";
const FileActions: React.FC = () => {
const { t } = useTranslation();
const { recentFiles, selectedFileIds, filteredFiles, onSelectAll, onDeleteSelected, onDownloadSelected } =
useFileManagerContext();
const handleSelectAll = () => {
onSelectAll();
};
const handleDeleteSelected = () => {
if (selectedFileIds.length > 0) {
onDeleteSelected();
}
};
const handleDownloadSelected = () => {
if (selectedFileIds.length > 0) {
onDownloadSelected();
}
};
// Only show actions if there are files
if (recentFiles.length === 0) {
return null;
}
const allFilesSelected = filteredFiles.length > 0 && selectedFileIds.length === filteredFiles.length;
const hasSelection = selectedFileIds.length > 0;
return (
<div
style={{
padding: "0.75rem 1rem",
backgroundColor: "var(--mantine-color-gray-1)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
minHeight: "3rem",
position: "relative",
}}
>
{/* Left: Select All */}
<div>
<Tooltip
label={allFilesSelected ? t("fileManager.deselectAll", "Deselect All") : t("fileManager.selectAll", "Select All")}
>
<ActionIcon
variant="light"
size="sm"
color="dimmed"
onClick={handleSelectAll}
disabled={filteredFiles.length === 0}
radius="sm"
>
<SelectAllIcon style={{ fontSize: "1rem" }} />
</ActionIcon>
</Tooltip>
</div>
{/* Center: Selected count */}
<div
style={{
position: "absolute",
left: "50%",
transform: "translateX(-50%)",
}}
>
{hasSelection && (
<Text size="sm" c="dimmed" fw={500}>
{t("fileManager.selectedCount", "{{count}} selected", { count: selectedFileIds.length })}
</Text>
)}
</div>
{/* Right: Delete and Download */}
<Group gap="xs">
<Tooltip label={t("fileManager.deleteSelected", "Delete Selected")}>
<ActionIcon
variant="light"
size="sm"
color="dimmed"
onClick={handleDeleteSelected}
disabled={!hasSelection}
radius="sm"
>
<DeleteIcon style={{ fontSize: "1rem" }} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("fileManager.downloadSelected", "Download Selected")}>
<ActionIcon
variant="light"
size="sm"
color="dimmed"
onClick={handleDownloadSelected}
disabled={!hasSelection}
radius="sm"
>
<DownloadIcon style={{ fontSize: "1rem" }} />
</ActionIcon>
</Tooltip>
</Group>
</div>
);
};
export default FileActions;
@@ -2,10 +2,10 @@ import React from 'react';
import { Stack, Card, Box, Text, Badge, Group, Divider, ScrollArea } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { detectFileExtension, getFileSize } from '../../utils/fileUtils';
import { FileWithUrl } from '../../types/file';
import { FileMetadata } from '../../types/file';
interface FileInfoCardProps {
currentFile: FileWithUrl | null;
currentFile: FileMetadata | null;
modalHeight: string;
}
@@ -17,7 +17,7 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
return (
<Card withBorder p={0} h={`calc(${modalHeight} * 0.32 - 1rem)`} style={{ flex: 1, overflow: 'hidden' }}>
<Box bg="blue.6" p="sm" style={{ borderTopLeftRadius: 'var(--mantine-radius-md)', borderTopRightRadius: 'var(--mantine-radius-md)' }}>
<Box bg="gray.4" p="sm" style={{ borderTopLeftRadius: 'var(--mantine-radius-md)', borderTopRightRadius: 'var(--mantine-radius-md)' }}>
<Text size="sm" fw={500} ta="center" c="white">
{t('fileManager.details', 'File Details')}
</Text>
@@ -31,7 +31,7 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
</Text>
</Group>
<Divider />
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">{t('fileManager.fileFormat', 'Format')}</Text>
{currentFile ? (
@@ -43,7 +43,7 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
)}
</Group>
<Divider />
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">{t('fileManager.fileSize', 'Size')}</Text>
<Text size="sm" fw={500}>
@@ -51,7 +51,7 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
</Text>
</Group>
<Divider />
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">{t('fileManager.fileVersion', 'Version')}</Text>
<Text size="sm" fw={500}>
@@ -64,4 +64,4 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
);
};
export default FileInfoCard;
export default FileInfoCard;
@@ -19,22 +19,23 @@ const FileListArea: React.FC<FileListAreaProps> = ({
activeSource,
recentFiles,
filteredFiles,
selectedFileIds,
selectedFilesSet,
onFileSelect,
onFileRemove,
onFileDoubleClick,
onDownloadSingle,
isFileSupported,
} = useFileManagerContext();
const { t } = useTranslation();
if (activeSource === 'recent') {
return (
<ScrollArea
<ScrollArea
h={scrollAreaHeight}
style={{
style={{
...scrollAreaStyle
}}
type="always"
type="always"
scrollbarSize={8}
>
<Stack gap={0}>
@@ -51,12 +52,13 @@ const FileListArea: React.FC<FileListAreaProps> = ({
) : (
filteredFiles.map((file, index) => (
<FileListItem
key={file.id || file.name}
key={file.id}
file={file}
isSelected={selectedFileIds.includes(file.id || file.name)}
isSelected={selectedFilesSet.has(file.id)}
isSupported={isFileSupported(file.name)}
onSelect={() => onFileSelect(file)}
onSelect={(shiftKey) => onFileSelect(file, index, shiftKey)}
onRemove={() => onFileRemove(index)}
onDownload={() => onDownloadSingle(file)}
onDoubleClick={() => onFileDoubleClick(file)}
/>
))
@@ -77,4 +79,4 @@ const FileListArea: React.FC<FileListAreaProps> = ({
);
};
export default FileListArea;
export default FileListArea;
@@ -1,40 +1,54 @@
import React, { useState } from 'react';
import { Group, Box, Text, ActionIcon, Checkbox, Divider } from '@mantine/core';
import { Group, Box, Text, ActionIcon, Checkbox, Divider, Menu, Badge } from '@mantine/core';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import DeleteIcon from '@mui/icons-material/Delete';
import DownloadIcon from '@mui/icons-material/Download';
import { useTranslation } from 'react-i18next';
import { getFileSize, getFileDate } from '../../utils/fileUtils';
import { FileWithUrl } from '../../types/file';
import { FileMetadata } from '../../types/file';
interface FileListItemProps {
file: FileWithUrl;
file: FileMetadata;
isSelected: boolean;
isSupported: boolean;
onSelect: () => void;
onSelect: (shiftKey?: boolean) => void;
onRemove: () => void;
onDownload?: () => void;
onDoubleClick?: () => void;
isLast?: boolean;
}
const FileListItem: React.FC<FileListItemProps> = ({
file,
isSelected,
isSupported,
onSelect,
onRemove,
const FileListItem: React.FC<FileListItemProps> = ({
file,
isSelected,
isSupported,
onSelect,
onRemove,
onDownload,
onDoubleClick
}) => {
const [isHovered, setIsHovered] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const { t } = useTranslation();
// Keep item in hovered state if menu is open
const shouldShowHovered = isHovered || isMenuOpen;
return (
<>
<Box
p="sm"
style={{
<Box
p="sm"
style={{
cursor: 'pointer',
backgroundColor: isSelected ? 'var(--mantine-color-gray-0)' : (isHovered ? 'var(--mantine-color-gray-0)' : 'var(--bg-file-list)'),
backgroundColor: isSelected ? 'var(--mantine-color-gray-1)' : (shouldShowHovered ? 'var(--mantine-color-gray-1)' : 'var(--bg-file-list)'),
opacity: isSupported ? 1 : 0.5,
transition: 'background-color 0.15s ease'
transition: 'background-color 0.15s ease',
userSelect: 'none',
WebkitUserSelect: 'none',
MozUserSelect: 'none',
msUserSelect: 'none'
}}
onClick={onSelect}
onClick={(e) => onSelect(e.shiftKey)}
onDoubleClick={onDoubleClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
@@ -54,26 +68,66 @@ const FileListItem: React.FC<FileListItemProps> = ({
}}
/>
</Box>
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>{file.name}</Text>
<Group gap="xs" align="center">
<Text size="sm" fw={500} truncate style={{ flex: 1 }}>{file.name}</Text>
{file.isDraft && (
<Badge size="xs" variant="light" color="orange">
DRAFT
</Badge>
)}
</Group>
<Text size="xs" c="dimmed">{getFileSize(file)} {getFileDate(file)}</Text>
</Box>
{/* Delete button - fades in/out on hover */}
<ActionIcon
variant="subtle"
c="dimmed"
size="md"
onClick={(e) => { e.stopPropagation(); onRemove(); }}
style={{
opacity: isHovered ? 1 : 0,
transform: isHovered ? 'scale(1)' : 'scale(0.8)',
transition: 'opacity 0.3s ease, transform 0.3s ease',
pointerEvents: isHovered ? 'auto' : 'none'
}}
{/* Three dots menu - fades in/out on hover */}
<Menu
position="bottom-end"
withinPortal
onOpen={() => setIsMenuOpen(true)}
onClose={() => setIsMenuOpen(false)}
>
<DeleteIcon style={{ fontSize: 20 }} />
</ActionIcon>
<Menu.Target>
<ActionIcon
variant="subtle"
c="dimmed"
size="md"
onClick={(e) => e.stopPropagation()}
style={{
opacity: shouldShowHovered ? 1 : 0,
transform: shouldShowHovered ? 'scale(1)' : 'scale(0.8)',
transition: 'opacity 0.3s ease, transform 0.3s ease',
pointerEvents: shouldShowHovered ? 'auto' : 'none'
}}
>
<MoreVertIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{onDownload && (
<Menu.Item
leftSection={<DownloadIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
onDownload();
}}
>
{t('fileManager.download', 'Download')}
</Menu.Item>
)}
<Menu.Item
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
>
{t('fileManager.delete', 'Delete')}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Box>
{ <Divider color="var(--mantine-color-gray-3)" />}
@@ -81,4 +135,4 @@ const FileListItem: React.FC<FileListItemProps> = ({
);
};
export default FileListItem;
export default FileListItem;
@@ -1,7 +1,7 @@
import React from 'react';
import { Stack, Text, Button, Group } from '@mantine/core';
import HistoryIcon from '@mui/icons-material/History';
import FolderIcon from '@mui/icons-material/Folder';
import UploadIcon from '@mui/icons-material/Upload';
import CloudIcon from '@mui/icons-material/Cloud';
import { useTranslation } from 'react-i18next';
import { useFileManagerContext } from '../../contexts/FileManagerContext';
@@ -10,7 +10,7 @@ interface FileSourceButtonsProps {
horizontal?: boolean;
}
const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
horizontal = false
}) => {
const { activeSource, onSourceChange, onLocalFileClick } = useFileManagerContext();
@@ -44,11 +44,11 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
>
{horizontal ? t('fileManager.recent', 'Recent') : t('fileManager.recent', 'Recent')}
</Button>
<Button
variant="subtle"
color='var(--mantine-color-gray-6)'
leftSection={<FolderIcon />}
leftSection={<UploadIcon />}
justify={horizontal ? "center" : "flex-start"}
onClick={onLocalFileClick}
fullWidth={!horizontal}
@@ -63,9 +63,9 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
}
}}
>
{horizontal ? t('fileManager.localFiles', 'Local') : t('fileManager.localFiles', 'Local Files')}
{horizontal ? t('fileUpload.uploadFiles', 'Upload') : t('fileUpload.uploadFiles', 'Upload Files')}
</Button>
<Button
variant={buttonProps.variant('drive')}
leftSection={<CloudIcon />}
@@ -100,4 +100,4 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
);
};
export default FileSourceButtons;
export default FileSourceButtons;
@@ -4,6 +4,7 @@ import FileSourceButtons from './FileSourceButtons';
import FileDetails from './FileDetails';
import SearchInput from './SearchInput';
import FileListArea from './FileListArea';
import FileActions from './FileActions';
import HiddenFileInput from './HiddenFileInput';
import { useFileManagerContext } from '../../contexts/FileManagerContext';
@@ -22,10 +23,11 @@ const MobileLayout: React.FC = () => {
// Estimate heights of fixed components
const fileSourceHeight = '3rem'; // FileSourceButtons height
const fileDetailsHeight = selectedFiles.length > 0 ? '10rem' : '8rem'; // FileDetails compact height
const fileActionsHeight = activeSource === 'recent' ? '3rem' : '0rem'; // FileActions height (now at bottom)
const searchHeight = activeSource === 'recent' ? '3rem' : '0rem'; // SearchInput height
const gapHeight = activeSource === 'recent' ? '3rem' : '2rem'; // Stack gaps
const gapHeight = activeSource === 'recent' ? '3.75rem' : '2rem'; // Stack gaps
return `calc(${baseHeight} - ${fileSourceHeight} - ${fileDetailsHeight} - ${searchHeight} - ${gapHeight})`;
return `calc(${baseHeight} - ${fileSourceHeight} - ${fileDetailsHeight} - ${fileActionsHeight} - ${searchHeight} - ${gapHeight})`;
};
return (
@@ -51,12 +53,20 @@ const MobileLayout: React.FC = () => {
minHeight: 0
}}>
{activeSource === 'recent' && (
<Box style={{
flexShrink: 0,
borderBottom: '1px solid var(--mantine-color-gray-2)'
}}>
<SearchInput />
</Box>
<>
<Box style={{
flexShrink: 0,
borderBottom: '1px solid var(--mantine-color-gray-2)'
}}>
<SearchInput />
</Box>
<Box style={{
flexShrink: 0,
borderBottom: '1px solid var(--mantine-color-gray-2)'
}}>
<FileActions />
</Box>
</>
)}
<Box style={{ flex: 1, minHeight: 0 }}>
@@ -11,7 +11,7 @@ import {
Code,
Divider
} from '@mantine/core';
import { useFileContext } from '../../contexts/FileContext';
// FileContext no longer needed - these were stub functions anyway
import { FileOperation, FileOperationHistory as FileOperationHistoryType } from '../../types/fileContext';
import { PageOperation } from '../../types/pageEditor';
@@ -26,11 +26,13 @@ const FileOperationHistory: React.FC<FileOperationHistoryProps> = ({
showOnlyApplied = false,
maxHeight = 400
}) => {
const { getFileHistory, getAppliedOperations } = useFileContext();
// These were stub functions in the old context - replace with empty stubs
const getFileHistory = (fileId: string) => ({ operations: [], createdAt: Date.now(), lastModified: Date.now() });
const getAppliedOperations = (fileId: string) => [];
const history = getFileHistory(fileId);
const allOperations = showOnlyApplied ? getAppliedOperations(fileId) : history?.operations || [];
const operations = allOperations.filter(op => 'fileIds' in op) as FileOperation[];
const operations = allOperations.filter((op: any) => 'fileIds' in op) as FileOperation[];
const formatTimestamp = (timestamp: number) => {
return new Date(timestamp).toLocaleString();
+13 -7
View File
@@ -4,7 +4,8 @@ import { useTranslation } from 'react-i18next';
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
import { useFileHandler } from '../../hooks/useFileHandler';
import { useFileContext } from '../../contexts/FileContext';
import { useFileState, useFileActions } from '../../contexts/FileContext';
import { useNavigationState, useNavigationActions } from '../../contexts/NavigationContext';
import TopControls from '../shared/TopControls';
import FileEditor from '../fileEditor/FileEditor';
@@ -20,7 +21,12 @@ export default function Workbench() {
const { isRainbowMode } = useRainbowThemeContext();
// Use context-based hooks to eliminate all prop drilling
const { activeFiles, currentView, setCurrentView } = useFileContext();
const { state } = useFileState();
const { actions } = useFileActions();
const { currentMode: currentView } = useNavigationState();
const { actions: navActions } = useNavigationActions();
const setCurrentView = navActions.setMode;
const activeFiles = state.files.ids;
const {
previewFile,
pageEditorFunctions,
@@ -47,12 +53,12 @@ export default function Workbench() {
handleToolSelect('convert');
sessionStorage.removeItem('previousMode');
} else {
setCurrentView('fileEditor' as any);
setCurrentView('fileEditor');
}
};
const renderMainContent = () => {
if (!activeFiles[0]) {
if (activeFiles.length === 0) {
return (
<LandingPage
/>
@@ -69,11 +75,11 @@ export default function Workbench() {
supportedExtensions={selectedTool?.supportedFormats || ["pdf"]}
{...(!selectedToolKey && {
onOpenPageEditor: (file) => {
setCurrentView("pageEditor" as any);
setCurrentView("pageEditor");
},
onMergeFiles: (filesToMerge) => {
filesToMerge.forEach(addToActiveFiles);
setCurrentView("viewer" as any);
setCurrentView("viewer");
}
})}
/>
@@ -142,7 +148,7 @@ export default function Workbench() {
{/* Top Controls */}
<TopControls
currentView={currentView}
setCurrentView={setCurrentView as any /* FIX ME */}
setCurrentView={setCurrentView}
selectedToolKey={selectedToolKey}
/>
@@ -1,5 +1,7 @@
import React, { useState, useCallback, useRef, useEffect } from 'react';
import React, { useRef, useEffect, useState, useCallback } from 'react';
import { Box } from '@mantine/core';
import { useVirtualizer } from '@tanstack/react-virtual';
import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import styles from './PageEditor.module.css';
interface DragDropItem {
@@ -12,19 +14,9 @@ interface DragDropGridProps<T extends DragDropItem> {
selectedItems: number[];
selectionMode: boolean;
isAnimating: boolean;
onDragStart: (pageNumber: number) => void;
onDragEnd: () => void;
onDragOver: (e: React.DragEvent) => void;
onDragEnter: (pageNumber: number) => void;
onDragLeave: () => void;
onDrop: (e: React.DragEvent, targetPageNumber: number | 'end') => void;
onEndZoneDragEnter: () => void;
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPages?: number[]) => void;
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>) => React.ReactNode;
renderSplitMarker?: (item: T, index: number) => React.ReactNode;
draggedItem: number | null;
dropTarget: number | 'end' | null;
multiItemDrag: {pageNumbers: number[], count: number} | null;
dragPosition: {x: number, y: number} | null;
}
const DragDropGrid = <T extends DragDropItem>({
@@ -32,104 +24,129 @@ const DragDropGrid = <T extends DragDropItem>({
selectedItems,
selectionMode,
isAnimating,
onDragStart,
onDragEnd,
onDragOver,
onDragEnter,
onDragLeave,
onDrop,
onEndZoneDragEnter,
onReorderPages,
renderItem,
renderSplitMarker,
draggedItem,
dropTarget,
multiItemDrag,
dragPosition,
}: DragDropGridProps<T>) => {
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
// Global drag cleanup
const containerRef = useRef<HTMLDivElement>(null);
// Responsive grid configuration
const [itemsPerRow, setItemsPerRow] = useState(4);
const ITEM_WIDTH = 320; // 20rem (page width)
const ITEM_GAP = 24; // 1.5rem gap between items
const ITEM_HEIGHT = 340; // 20rem + gap
const OVERSCAN = items.length > 1000 ? 8 : 4; // More overscan for large documents
// Calculate items per row based on container width
const calculateItemsPerRow = useCallback(() => {
if (!containerRef.current) return 4; // Default fallback
const containerWidth = containerRef.current.offsetWidth;
if (containerWidth === 0) return 4; // Container not measured yet
// Calculate how many items fit: (width - gap) / (itemWidth + gap)
const availableWidth = containerWidth - ITEM_GAP; // Account for first gap
const itemWithGap = ITEM_WIDTH + ITEM_GAP;
const calculated = Math.floor(availableWidth / itemWithGap);
return Math.max(1, calculated); // At least 1 item per row
}, []);
// Update items per row when container resizes
useEffect(() => {
const handleGlobalDragEnd = () => {
onDragEnd();
const updateLayout = () => {
const newItemsPerRow = calculateItemsPerRow();
setItemsPerRow(newItemsPerRow);
};
const handleGlobalDrop = (e: DragEvent) => {
e.preventDefault();
};
if (draggedItem) {
document.addEventListener('dragend', handleGlobalDragEnd);
document.addEventListener('drop', handleGlobalDrop);
// Initial calculation
updateLayout();
// Listen for window resize
window.addEventListener('resize', updateLayout);
// Use ResizeObserver for container size changes
const resizeObserver = new ResizeObserver(updateLayout);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return () => {
document.removeEventListener('dragend', handleGlobalDragEnd);
document.removeEventListener('drop', handleGlobalDrop);
window.removeEventListener('resize', updateLayout);
resizeObserver.disconnect();
};
}, [draggedItem, onDragEnd]);
}, [calculateItemsPerRow]);
// Virtualization with react-virtual library
const rowVirtualizer = useVirtualizer({
count: Math.ceil(items.length / itemsPerRow),
getScrollElement: () => containerRef.current?.closest('[data-scrolling-container]') as Element,
estimateSize: () => ITEM_HEIGHT,
overscan: OVERSCAN,
});
return (
<Box>
<Box
ref={containerRef}
style={{
// Basic container styles
width: '100%',
height: '100%',
}}
>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '1.5rem',
justifyContent: 'flex-start',
paddingBottom: '100px',
// Performance optimizations for smooth scrolling
willChange: 'scroll-position',
transform: 'translateZ(0)', // Force hardware acceleration
backfaceVisibility: 'hidden',
// Use containment for better rendering performance
contain: 'layout style paint',
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{items.map((item, index) => (
<React.Fragment key={item.id}>
{/* Split marker */}
{renderSplitMarker && item.splitBefore && index > 0 && renderSplitMarker(item, index)}
{/* Item */}
{renderItem(item, index, itemRefs)}
</React.Fragment>
))}
{/* End drop zone */}
<div className="w-[20rem] h-[20rem] flex items-center justify-center flex-shrink-0">
<div
data-drop-zone="end"
className={`cursor-pointer select-none w-[15rem] h-[15rem] flex items-center justify-center flex-shrink-0 shadow-sm hover:shadow-md transition-all relative ${
dropTarget === 'end'
? 'ring-2 ring-green-500 bg-green-50'
: 'bg-white hover:bg-blue-50 border-2 border-dashed border-gray-300 hover:border-blue-400'
}`}
style={{ borderRadius: '12px' }}
onDragOver={onDragOver}
onDragEnter={onEndZoneDragEnter}
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, 'end')}
>
<div className="text-gray-500 text-sm text-center font-medium">
Drop here to<br />move to end
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const startIndex = virtualRow.index * itemsPerRow;
const endIndex = Math.min(startIndex + itemsPerRow, items.length);
const rowItems = items.slice(startIndex, endIndex);
return (
<div
key={virtualRow.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
<div
style={{
display: 'flex',
gap: '1.5rem',
justifyContent: 'flex-start',
height: '100%',
alignItems: 'center',
}}
>
{rowItems.map((item, itemIndex) => {
const actualIndex = startIndex + itemIndex;
return (
<React.Fragment key={item.id}>
{/* Split marker */}
{renderSplitMarker && item.splitBefore && actualIndex > 0 && renderSplitMarker(item, actualIndex)}
{/* Item */}
{renderItem(item, actualIndex, itemRefs)}
</React.Fragment>
);
})}
</div>
</div>
</div>
</div>
);
})}
</div>
{/* Multi-item drag indicator */}
{multiItemDrag && dragPosition && (
<div
className={styles.multiDragIndicator}
style={{
left: dragPosition.x,
top: dragPosition.y,
}}
>
{multiItemDrag.count} items
</div>
)}
</Box>
);
};
@@ -1,14 +1,12 @@
import React, { useState } from 'react';
import { Text, Checkbox, Tooltip, ActionIcon, Badge, Modal } from '@mantine/core';
import React, { useState, useCallback, useRef, useEffect } from 'react';
import { Text, Checkbox, Tooltip, ActionIcon, Badge } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import CloseIcon from '@mui/icons-material/Close';
import VisibilityIcon from '@mui/icons-material/Visibility';
import HistoryIcon from '@mui/icons-material/History';
import PushPinIcon from '@mui/icons-material/PushPin';
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import styles from './PageEditor.module.css';
import FileOperationHistory from '../history/FileOperationHistory';
import { useFileContext } from '../../contexts/FileContext';
interface FileItem {
@@ -26,20 +24,11 @@ interface FileThumbnailProps {
totalFiles: number;
selectedFiles: string[];
selectionMode: boolean;
draggedFile: string | null;
dropTarget: string | null;
isAnimating: boolean;
fileRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
onDragStart: (fileId: string) => void;
onDragEnd: () => void;
onDragOver: (e: React.DragEvent) => void;
onDragEnter: (fileId: string) => void;
onDragLeave: () => void;
onDrop: (e: React.DragEvent, fileId: string) => void;
onToggleFile: (fileId: string) => void;
onDeleteFile: (fileId: string) => void;
onViewFile: (fileId: string) => void;
onSetStatus: (status: string) => void;
onReorderFiles?: (sourceFileId: string, targetFileId: string, selectedFileIds: string[]) => void;
toolMode?: boolean;
isSupported?: boolean;
}
@@ -50,26 +39,20 @@ const FileThumbnail = ({
totalFiles,
selectedFiles,
selectionMode,
draggedFile,
dropTarget,
isAnimating,
fileRefs,
onDragStart,
onDragEnd,
onDragOver,
onDragEnter,
onDragLeave,
onDrop,
onToggleFile,
onDeleteFile,
onViewFile,
onSetStatus,
onReorderFiles,
toolMode = false,
isSupported = true,
}: FileThumbnailProps) => {
const { t } = useTranslation();
const [showHistory, setShowHistory] = useState(false);
const { pinnedFiles, pinFile, unpinFile, isFilePinned, activeFiles } = useFileContext();
// Drag and drop state
const [isDragging, setIsDragging] = useState(false);
const dragElementRef = useRef<HTMLDivElement | null>(null);
// Find the actual File object that corresponds to this FileItem
const actualFile = activeFiles.find(f => f.name === file.name && f.size === file.size);
@@ -82,15 +65,57 @@ const FileThumbnail = ({
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
};
// Setup drag and drop using @atlaskit/pragmatic-drag-and-drop
const fileElementRef = useCallback((element: HTMLDivElement | null) => {
if (!element) return;
dragElementRef.current = element;
const dragCleanup = draggable({
element,
getInitialData: () => ({
type: 'file',
fileId: file.id,
fileName: file.name,
selectedFiles: [file.id] // Always drag only this file, ignore selection state
}),
onDragStart: () => {
setIsDragging(true);
},
onDrop: () => {
setIsDragging(false);
}
});
const dropCleanup = dropTargetForElements({
element,
getData: () => ({
type: 'file',
fileId: file.id
}),
canDrop: ({ source }) => {
const sourceData = source.data;
return sourceData.type === 'file' && sourceData.fileId !== file.id;
},
onDrop: ({ source }) => {
const sourceData = source.data;
if (sourceData.type === 'file' && onReorderFiles) {
const sourceFileId = sourceData.fileId as string;
const selectedFileIds = sourceData.selectedFiles as string[];
onReorderFiles(sourceFileId, file.id, selectedFileIds);
}
}
});
return () => {
dragCleanup();
dropCleanup();
};
}, [file.id, file.name, selectionMode, selectedFiles, onReorderFiles]);
return (
<div
ref={(el) => {
if (el) {
fileRefs.current.set(file.id, el);
} else {
fileRefs.current.delete(file.id);
}
}}
ref={fileElementRef}
data-file-id={file.id}
data-testid="file-thumbnail"
className={`
@@ -109,26 +134,12 @@ const FileThumbnail = ({
${selectionMode
? 'bg-white hover:bg-gray-50'
: 'bg-white hover:bg-gray-50'}
${draggedFile === file.id ? 'opacity-50 scale-95' : ''}
${isDragging ? 'opacity-50 scale-95' : ''}
`}
style={{
transform: (() => {
if (!isAnimating && draggedFile && file.id !== draggedFile && dropTarget === file.id) {
return 'translateX(20px)';
}
return 'translateX(0)';
})(),
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out',
opacity: isSupported ? 1 : 0.5,
opacity: isSupported ? (isDragging ? 0.5 : 1) : 0.5,
filter: isSupported ? 'none' : 'grayscale(50%)'
}}
draggable
onDragStart={() => onDragStart(file.id)}
onDragEnd={onDragEnd}
onDragOver={onDragOver}
onDragEnter={() => onDragEnter(file.id)}
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, file.id)}
>
{selectionMode && (
<div
@@ -187,6 +198,12 @@ const FileThumbnail = ({
<img
src={file.thumbnail}
alt={file.name}
draggable={false}
onError={(e) => {
// Hide broken image if blob URL was revoked
const img = e.target as HTMLImageElement;
img.style.display = 'none';
}}
style={{
maxWidth: '100%',
maxHeight: '100%',
@@ -196,20 +213,22 @@ const FileThumbnail = ({
/>
</div>
{/* Page count badge */}
<Badge
size="sm"
variant="filled"
color="blue"
style={{
position: 'absolute',
top: 8,
left: 8,
zIndex: 3,
}}
>
{file.pageCount} pages
</Badge>
{/* Page count badge - only show for PDFs */}
{file.pageCount > 0 && (
<Badge
size="sm"
variant="filled"
color="blue"
style={{
position: 'absolute',
top: 8,
left: 8,
zIndex: 3,
}}
>
{file.pageCount} {file.pageCount === 1 ? 'page' : 'pages'}
</Badge>
)}
{/* Unsupported badge */}
{!isSupported && (
@@ -273,40 +292,6 @@ const FileThumbnail = ({
whiteSpace: 'nowrap'
}}
>
{!toolMode && isSupported && (
<>
<Tooltip label="View File">
<ActionIcon
size="md"
variant="subtle"
c="white"
onClick={(e) => {
e.stopPropagation();
onViewFile(file.id);
onSetStatus(`Opened ${file.name}`);
}}
>
<VisibilityIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
</>
)}
<Tooltip label="View History">
<ActionIcon
size="md"
variant="subtle"
c="white"
onClick={(e) => {
e.stopPropagation();
setShowHistory(true);
onSetStatus(`Viewing history for ${file.name}`);
}}
>
<HistoryIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
{actualFile && (
<Tooltip label={isFilePinned(actualFile) ? "Unpin File" : "Pin File"}>
@@ -372,20 +357,6 @@ const FileThumbnail = ({
</Text>
</div>
{/* History Modal */}
<Modal
opened={showHistory}
onClose={() => setShowHistory(false)}
title={`Operation History - ${file.name}`}
size="lg"
scrollAreaComponent={'div' as any}
>
<FileOperationHistory
fileId={file.name}
showOnlyApplied={true}
maxHeight={500}
/>
</Modal>
</div>
);
};
File diff suppressed because it is too large Load Diff
@@ -6,20 +6,13 @@ import RotateLeftIcon from '@mui/icons-material/RotateLeft';
import RotateRightIcon from '@mui/icons-material/RotateRight';
import DeleteIcon from '@mui/icons-material/Delete';
import ContentCutIcon from '@mui/icons-material/ContentCut';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { PDFPage, PDFDocument } from '../../types/pageEditor';
import { RotatePagesCommand, DeletePagesCommand, ToggleSplitCommand } from '../../commands/pageCommands';
import { Command } from '../../hooks/useUndoRedo';
import { useFileState } from '../../contexts/FileContext';
import { useThumbnailGeneration } from '../../hooks/useThumbnailGeneration';
import styles from './PageEditor.module.css';
import { getDocument, GlobalWorkerOptions } from 'pdfjs-dist';
// Ensure PDF.js worker is available
if (!GlobalWorkerOptions.workerSrc) {
GlobalWorkerOptions.workerSrc = '/pdf.worker.js';
console.log('📸 PageThumbnail: Set PDF.js worker source to /pdf.worker.js');
} else {
console.log('📸 PageThumbnail: PDF.js worker source already set to', GlobalWorkerOptions.workerSrc);
}
interface PageThumbnailProps {
page: PDFPage;
@@ -28,22 +21,15 @@ interface PageThumbnailProps {
originalFile?: File; // For lazy thumbnail generation
selectedPages: number[];
selectionMode: boolean;
draggedPage: number | null;
dropTarget: number | 'end' | null;
movingPage: number | null;
isAnimating: boolean;
pageRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
onDragStart: (pageNumber: number) => void;
onDragEnd: () => void;
onDragOver: (e: React.DragEvent) => void;
onDragEnter: (pageNumber: number) => void;
onDragLeave: () => void;
onDrop: (e: React.DragEvent, pageNumber: number) => void;
onTogglePage: (pageNumber: number) => void;
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;
@@ -58,22 +44,15 @@ const PageThumbnail = React.memo(({
originalFile,
selectedPages,
selectionMode,
draggedPage,
dropTarget,
movingPage,
isAnimating,
pageRefs,
onDragStart,
onDragEnd,
onDragOver,
onDragEnter,
onDragLeave,
onDrop,
onTogglePage,
onAnimateReorder,
onExecuteCommand,
onSetStatus,
onSetMovingPage,
onReorderPages,
RotatePagesCommand,
DeletePagesCommand,
ToggleSplitCommand,
@@ -81,51 +60,122 @@ const PageThumbnail = React.memo(({
setPdfDocument,
}: PageThumbnailProps) => {
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
const [isLoadingThumbnail, setIsLoadingThumbnail] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const dragElementRef = useRef<HTMLDivElement>(null);
const { state, selectors } = useFileState();
const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration();
// Update thumbnail URL when page prop changes
// Update thumbnail URL when page prop changes - prevent redundant updates
useEffect(() => {
if (page.thumbnail && page.thumbnail !== thumbnailUrl) {
console.log(`📸 PageThumbnail: Updating thumbnail URL for page ${page.pageNumber}`, page.thumbnail.substring(0, 50) + '...');
setThumbnailUrl(page.thumbnail);
}
}, [page.thumbnail, page.pageNumber, page.id, thumbnailUrl]);
}, [page.thumbnail, page.id]); // Remove thumbnailUrl dependency to prevent redundant cycles
// Listen for ready thumbnails from Web Workers (only if no existing thumbnail)
// Request thumbnail generation if not available (optimized for performance)
useEffect(() => {
if (thumbnailUrl) {
console.log(`📸 PageThumbnail: Page ${page.pageNumber} already has thumbnail, skipping worker listener`);
return; // Skip if we already have a thumbnail
if (thumbnailUrl || !originalFile) {
return; // Skip if we already have a thumbnail or no original file
}
console.log(`📸 PageThumbnail: Setting up worker listener for page ${page.pageNumber} (${page.id})`);
// Check cache first without async call
const cachedThumbnail = getThumbnailFromCache(page.id);
if (cachedThumbnail) {
setThumbnailUrl(cachedThumbnail);
return;
}
const handleThumbnailReady = (event: CustomEvent) => {
const { pageNumber, thumbnail, pageId } = event.detail;
console.log(`📸 PageThumbnail: Received worker thumbnail for page ${pageNumber}, looking for page ${page.pageNumber} (${page.id})`);
let cancelled = false;
if (pageNumber === page.pageNumber && pageId === page.id) {
console.log(`✓ PageThumbnail: Thumbnail matched for page ${page.pageNumber}, setting URL`);
setThumbnailUrl(thumbnail);
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);
}
}
};
window.addEventListener('thumbnailReady', handleThumbnailReady as EventListener);
loadThumbnail();
// Cleanup function to prevent state updates after unmount
return () => {
console.log(`📸 PageThumbnail: Cleaning up worker listener for page ${page.pageNumber}`);
window.removeEventListener('thumbnailReady', handleThumbnailReady as EventListener);
cancelled = true;
};
}, [page.pageNumber, page.id, thumbnailUrl]);
}, [page.id, originalFile, requestThumbnail, getThumbnailFromCache]); // Removed thumbnailUrl to prevent loops
// Register this component with pageRefs for animations
const pageElementRef = useCallback((element: HTMLDivElement | null) => {
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]
}),
onDragStart: () => {
setIsDragging(true);
},
onDrop: ({ location }) => {
setIsDragging(false);
if (location.current.dropTargets.length === 0) {
return;
}
const dropTarget = location.current.dropTargets[0];
const targetData = dropTarget.data;
if (targetData.type === 'page') {
const targetPageNumber = targetData.pageNumber as number;
const targetIndex = pdfDocument.pages.findIndex(p => p.pageNumber === targetPageNumber);
if (targetIndex !== -1) {
const pagesToMove = selectionMode && selectedPages.includes(page.pageNumber)
? selectedPages
: undefined;
onReorderPages(page.pageNumber, targetIndex, pagesToMove);
}
}
}
});
element.style.cursor = 'grab';
const dropCleanup = dropTargetForElements({
element,
getData: () => ({
type: 'page',
pageNumber: page.pageNumber
}),
onDrop: ({ source }) => {}
});
(element as any).__dragCleanup = () => {
dragCleanup();
dropCleanup();
};
} else {
pageRefs.current.delete(page.id);
if (dragElementRef.current && (dragElementRef.current as any).__dragCleanup) {
(dragElementRef.current as any).__dragCleanup();
}
}
}, [page.id, pageRefs]);
}, [page.id, page.pageNumber, pageRefs, selectionMode, selectedPages, pdfDocument.pages, onReorderPages]);
return (
<div
@@ -147,25 +197,13 @@ const PageThumbnail = React.memo(({
${selectionMode
? 'bg-white hover:bg-gray-50'
: 'bg-white hover:bg-gray-50'}
${draggedPage === page.pageNumber ? 'opacity-50 scale-95' : ''}
${isDragging ? 'opacity-50 scale-95' : ''}
${movingPage === page.pageNumber ? 'page-moving' : ''}
`}
style={{
transform: (() => {
if (!isAnimating && draggedPage && page.pageNumber !== draggedPage && dropTarget === page.pageNumber) {
return 'translateX(20px)';
}
return 'translateX(0)';
})(),
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out'
}}
draggable
onDragStart={() => onDragStart(page.pageNumber)}
onDragEnd={onDragEnd}
onDragOver={onDragOver}
onDragEnter={() => onDragEnter(page.pageNumber)}
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, page.pageNumber)}
draggable={false}
>
{selectionMode && (
<div
@@ -189,7 +227,6 @@ const PageThumbnail = React.memo(({
e.stopPropagation();
}}
onClick={(e) => {
console.log('📸 Checkbox clicked for page', page.pageNumber);
e.stopPropagation();
onTogglePage(page.pageNumber);
}}
@@ -204,7 +241,7 @@ const PageThumbnail = React.memo(({
</div>
)}
<div className="page-container w-[90%] h-[90%]">
<div className="page-container w-[90%] h-[90%]" draggable={false}>
<div
style={{
width: '100%',
@@ -222,6 +259,7 @@ const PageThumbnail = React.memo(({
<img
src={thumbnailUrl}
alt={`Page ${page.pageNumber}`}
draggable={false}
style={{
width: '100%',
height: '100%',
@@ -231,11 +269,6 @@ const PageThumbnail = React.memo(({
transition: 'transform 0.3s ease-in-out'
}}
/>
) : isLoadingThumbnail ? (
<div style={{ textAlign: 'center' }}>
<Loader size="sm" />
<Text size="xs" c="dimmed" mt={4}>Loading...</Text>
</div>
) : (
<div style={{ textAlign: 'center' }}>
<Text size="lg" c="dimmed">📄</Text>
@@ -408,30 +441,25 @@ const PageThumbnail = React.memo(({
)}
</div>
<DragIndicatorIcon
style={{
position: 'absolute',
bottom: 4,
right: 4,
color: 'rgba(0,0,0,0.3)',
fontSize: 16,
zIndex: 1
}}
/>
</div>
</div>
);
}, (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 &&
prevProps.selectedPages === nextProps.selectedPages && // Compare array reference - will re-render when selection changes
// Shallow compare selectedPages array for better stability
(prevProps.selectedPages === nextProps.selectedPages ||
arraysEqual(prevProps.selectedPages, nextProps.selectedPages)) &&
prevProps.selectionMode === nextProps.selectionMode &&
prevProps.draggedPage === nextProps.draggedPage &&
prevProps.dropTarget === nextProps.dropTarget &&
prevProps.movingPage === nextProps.movingPage &&
prevProps.isAnimating === nextProps.isAnimating
);
@@ -0,0 +1,56 @@
import React from 'react';
import { Text, Button, Stack } from '@mantine/core';
interface ErrorBoundaryState {
hasError: boolean;
error?: Error;
}
interface ErrorBoundaryProps {
children: React.ReactNode;
fallback?: React.ComponentType<{error?: Error; retry: () => void}>;
}
export default class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo);
}
retry = () => {
this.setState({ hasError: false, error: undefined });
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
const Fallback = this.props.fallback;
return <Fallback error={this.state.error} retry={this.retry} />;
}
return (
<Stack align="center" justify="center" style={{ minHeight: '200px', padding: '2rem' }}>
<Text size="lg" fw={500} c="red">Something went wrong</Text>
{process.env.NODE_ENV === 'development' && this.state.error && (
<Text size="sm" c="dimmed" style={{ textAlign: 'center', fontFamily: 'monospace' }}>
{this.state.error.message}
</Text>
)}
<Button onClick={this.retry} variant="light">
Try Again
</Button>
</Stack>
);
}
return this.props.children;
}
}
+9 -5
View File
@@ -6,12 +6,13 @@ import StorageIcon from "@mui/icons-material/Storage";
import VisibilityIcon from "@mui/icons-material/Visibility";
import EditIcon from "@mui/icons-material/Edit";
import { FileWithUrl } from "../../types/file";
import { FileRecord } from "../../types/fileContext";
import { getFileSize, getFileDate } from "../../utils/fileUtils";
import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail";
interface FileCardProps {
file: FileWithUrl;
file: File;
record?: FileRecord;
onRemove: () => void;
onDoubleClick?: () => void;
onView?: () => void;
@@ -21,9 +22,12 @@ interface FileCardProps {
isSupported?: boolean; // Whether the file format is supported by the current tool
}
const FileCard = ({ file, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => {
const FileCard = ({ file, record, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => {
const { t } = useTranslation();
const { thumbnail: thumb, isGenerating } = useIndexedDBThumbnail(file);
// Use record thumbnail if available, otherwise fall back to IndexedDB lookup
const fileMetadata = record ? { id: record.id, name: file.name, type: file.type, size: file.size, lastModified: file.lastModified } : null;
const { thumbnail: indexedDBThumb, isGenerating } = useIndexedDBThumbnail(fileMetadata);
const thumb = record?.thumbnailUrl || indexedDBThumb;
const [isHovered, setIsHovered] = useState(false);
return (
@@ -173,7 +177,7 @@ const FileCard = ({ file, onRemove, onDoubleClick, onView, onEdit, isSelected, o
<Badge color="blue" variant="light" size="sm">
{getFileDate(file)}
</Badge>
{file.storedInIndexedDB && (
{record?.id && (
<Badge
color="green"
variant="light"
+19 -18
View File
@@ -4,14 +4,14 @@ import { useTranslation } from "react-i18next";
import SearchIcon from "@mui/icons-material/Search";
import SortIcon from "@mui/icons-material/Sort";
import FileCard from "./FileCard";
import { FileWithUrl } from "../../types/file";
import { FileRecord } from "../../types/fileContext";
interface FileGridProps {
files: FileWithUrl[];
files: Array<{ file: File; record?: FileRecord }>;
onRemove?: (index: number) => void;
onDoubleClick?: (file: FileWithUrl) => void;
onView?: (file: FileWithUrl) => void;
onEdit?: (file: FileWithUrl) => void;
onDoubleClick?: (item: { file: File; record?: FileRecord }) => void;
onView?: (item: { file: File; record?: FileRecord }) => void;
onEdit?: (item: { file: File; record?: FileRecord }) => void;
onSelect?: (fileId: string) => void;
selectedFiles?: string[];
showSearch?: boolean;
@@ -46,19 +46,19 @@ const FileGrid = ({
const [sortBy, setSortBy] = useState<SortOption>('date');
// Filter files based on search term
const filteredFiles = files.filter(file =>
file.name.toLowerCase().includes(searchTerm.toLowerCase())
const filteredFiles = files.filter(item =>
item.file.name.toLowerCase().includes(searchTerm.toLowerCase())
);
// Sort files
const sortedFiles = [...filteredFiles].sort((a, b) => {
switch (sortBy) {
case 'date':
return (b.lastModified || 0) - (a.lastModified || 0);
return (b.file.lastModified || 0) - (a.file.lastModified || 0);
case 'name':
return a.name.localeCompare(b.name);
return a.file.name.localeCompare(b.file.name);
case 'size':
return (b.size || 0) - (a.size || 0);
return (b.file.size || 0) - (a.file.size || 0);
default:
return 0;
}
@@ -122,18 +122,19 @@ const FileGrid = ({
h="30rem"
style={{ overflowY: "auto", width: "100%" }}
>
{displayFiles.map((file, idx) => {
const fileId = file.id || file.name;
const originalIdx = files.findIndex(f => (f.id || f.name) === fileId);
const supported = isFileSupported ? isFileSupported(file.name) : true;
{displayFiles.map((item, idx) => {
const fileId = item.record?.id || item.file.name;
const originalIdx = files.findIndex(f => (f.record?.id || f.file.name) === fileId);
const supported = isFileSupported ? isFileSupported(item.file.name) : true;
return (
<FileCard
key={fileId + idx}
file={file}
file={item.file}
record={item.record}
onRemove={onRemove ? () => onRemove(originalIdx) : () => {}}
onDoubleClick={onDoubleClick && supported ? () => onDoubleClick(file) : undefined}
onView={onView && supported ? () => onView(file) : undefined}
onEdit={onEdit && supported ? () => onEdit(file) : undefined}
onDoubleClick={onDoubleClick && supported ? () => onDoubleClick(item) : undefined}
onView={onView && supported ? () => onView(item) : undefined}
onEdit={onEdit && supported ? () => onEdit(item) : undefined}
isSelected={selectedFiles.includes(fileId)}
onSelect={onSelect && supported ? () => onSelect(fileId) : undefined}
isSupported={supported}
@@ -19,7 +19,7 @@ import { useTranslation } from 'react-i18next';
interface FilePickerModalProps {
opened: boolean;
onClose: () => void;
storedFiles: any[]; // Files from storage (FileWithUrl format)
storedFiles: any[]; // Files from storage (various formats supported)
onSelectFiles: (selectedFiles: File[]) => void;
}
@@ -48,7 +48,7 @@ const FilePickerModal = ({
};
const selectAll = () => {
setSelectedFileIds(storedFiles.map(f => f.id || f.name));
setSelectedFileIds(storedFiles.map(f => f.id).filter(Boolean));
};
const selectNone = () => {
@@ -57,7 +57,7 @@ const FilePickerModal = ({
const handleConfirm = async () => {
const selectedFiles = storedFiles.filter(f =>
selectedFileIds.includes(f.id || f.name)
selectedFileIds.includes(f.id)
);
// Convert stored files to File objects
@@ -154,7 +154,7 @@ const FilePickerModal = ({
<ScrollArea.Autosize mah={400}>
<SimpleGrid cols={2} spacing="md">
{storedFiles.map((file) => {
const fileId = file.id || file.name;
const fileId = file.id;
const isSelected = selectedFileIds.includes(fileId);
return (
+20 -5
View File
@@ -1,6 +1,7 @@
import React from 'react';
import { Box } from '@mantine/core';
import { FileWithUrl } from '../../types/file';
import { Box, Center } from '@mantine/core';
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
import { FileMetadata } from '../../types/file';
import DocumentThumbnail from './filePreview/DocumentThumbnail';
import DocumentStack from './filePreview/DocumentStack';
import HoverOverlay from './filePreview/HoverOverlay';
@@ -8,7 +9,7 @@ import NavigationArrows from './filePreview/NavigationArrows';
export interface FilePreviewProps {
// Core file data
file: File | FileWithUrl | null;
file: File | FileMetadata | null;
thumbnail?: string | null;
// Optional features
@@ -21,7 +22,7 @@ export interface FilePreviewProps {
isAnimating?: boolean;
// Event handlers
onFileClick?: (file: File | FileWithUrl | null) => void;
onFileClick?: (file: File | FileMetadata | null) => void;
onPrevious?: () => void;
onNext?: () => void;
}
@@ -38,7 +39,21 @@ const FilePreview: React.FC<FilePreviewProps> = ({
onPrevious,
onNext
}) => {
if (!file) return null;
if (!file) {
return (
<Box style={{ width: '100%', height: '100%' }}>
<Center style={{ width: '100%', height: '100%' }}>
<InsertDriveFileIcon
style={{
fontSize: '4rem',
color: 'var(--mantine-color-gray-4)',
opacity: 0.6
}}
/>
</Center>
</Box>
);
}
const hasMultipleFiles = totalFiles > 1;
@@ -33,7 +33,7 @@ const LandingPage = () => {
{/* White PDF Page Background */}
<Dropzone
onDrop={handleFileDrop}
accept={["*/*"] as any}
accept={["application/pdf", "application/zip", "application/x-zip-compressed"]}
multiple={true}
className="w-4/5 flex items-center justify-center h-[95vh]"
style={{
@@ -125,7 +125,7 @@ const LandingPage = () => {
ref={fileInputRef}
type="file"
multiple
accept="*/*"
accept=".pdf,.zip"
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
@@ -1,6 +1,6 @@
import React from 'react';
import { Modal, Text, Button, Group, Stack } from '@mantine/core';
import { useFileContext } from '../../contexts/FileContext';
import { useNavigationGuard } from '../../contexts/NavigationContext';
interface NavigationWarningModalProps {
onApplyAndContinue?: () => Promise<void>;
@@ -11,13 +11,13 @@ const NavigationWarningModal = ({
onApplyAndContinue,
onExportAndContinue
}: NavigationWarningModalProps) => {
const {
showNavigationWarning,
const {
showNavigationWarning,
hasUnsavedChanges,
confirmNavigation,
cancelNavigation,
confirmNavigation,
setHasUnsavedChanges
} = useFileContext();
} = useNavigationGuard();
const handleKeepWorking = () => {
cancelNavigation();
@@ -13,9 +13,9 @@ import { ButtonConfig } from '../../types/sidebar';
import './quickAccessBar/QuickAccessBar.css';
import AllToolsNavButton from './AllToolsNavButton';
import ActiveToolButton from "./quickAccessBar/ActiveToolButton";
import {
isNavButtonActive,
getNavButtonStyle,
import {
isNavButtonActive,
getNavButtonStyle,
getActiveNavButton,
} from './quickAccessBar/QuickAccessBar';
@@ -39,7 +39,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
openFilesModal();
};
const buttonConfigs: ButtonConfig[] = [
{
id: 'read',
@@ -226,4 +226,4 @@ const QuickAccessBar = forwardRef<HTMLDivElement>(({
);
});
export default QuickAccessBar;
export default QuickAccessBar;
+32 -46
View File
@@ -1,4 +1,4 @@
import React, { useState, useCallback } from "react";
import React, { useState, useCallback, useMemo } from "react";
import { Button, SegmentedControl, Loader } from "@mantine/core";
import { useRainbowThemeContext } from "./RainbowThemeProvider";
import LanguageSelector from "./LanguageSelector";
@@ -10,50 +10,18 @@ import VisibilityIcon from "@mui/icons-material/Visibility";
import EditNoteIcon from "@mui/icons-material/EditNote";
import FolderIcon from "@mui/icons-material/Folder";
import { Group } from "@mantine/core";
import { ModeType } from '../../contexts/NavigationContext';
// This will be created inside the component to access switchingTo
const createViewOptions = (switchingTo: string | null) => [
{
label: (
<Group gap={5}>
{switchingTo === "viewer" ? (
<Loader size="xs" />
) : (
<VisibilityIcon fontSize="small" />
)}
</Group>
),
value: "viewer",
},
{
label: (
<Group gap={4}>
{switchingTo === "pageEditor" ? (
<Loader size="xs" />
) : (
<EditNoteIcon fontSize="small" />
)}
</Group>
),
value: "pageEditor",
},
{
label: (
<Group gap={4}>
{switchingTo === "fileEditor" ? (
<Loader size="xs" />
) : (
<FolderIcon fontSize="small" />
)}
</Group>
),
value: "fileEditor",
},
];
// Stable view option objects that don't recreate on every render
const VIEW_OPTIONS_BASE = [
{ value: "viewer", icon: VisibilityIcon },
{ value: "pageEditor", icon: EditNoteIcon },
{ value: "fileEditor", icon: FolderIcon },
] as const;
interface TopControlsProps {
currentView: string;
setCurrentView: (view: string) => void;
currentView: ModeType;
setCurrentView: (view: ModeType) => void;
selectedToolKey?: string | null;
}
@@ -68,6 +36,9 @@ const TopControls = ({
const isToolSelected = selectedToolKey !== null;
const handleViewChange = useCallback((view: string) => {
// Guard against redundant changes
if (view === currentView) return;
// Show immediate feedback
setSwitchingTo(view);
@@ -75,13 +46,28 @@ const TopControls = ({
requestAnimationFrame(() => {
// Give the spinner one more frame to show
requestAnimationFrame(() => {
setCurrentView(view);
setCurrentView(view as ModeType);
// Clear the loading state after view change completes
setTimeout(() => setSwitchingTo(null), 300);
});
});
}, [setCurrentView]);
}, [setCurrentView, currentView]);
// Memoize the SegmentedControl data with stable references
const viewOptions = useMemo(() =>
VIEW_OPTIONS_BASE.map(option => ({
value: option.value,
label: (
<Group gap={option.value === "viewer" ? 5 : 4}>
{switchingTo === option.value ? (
<Loader size="xs" />
) : (
<option.icon fontSize="small" />
)}
</Group>
)
})), [switchingTo]);
const getThemeIcon = () => {
if (isRainbowMode) return <AutoAwesomeIcon className={rainbowStyles.rainbowText} />;
@@ -117,7 +103,7 @@ const TopControls = ({
{!isToolSelected && (
<div className="flex justify-center items-center h-full pointer-events-auto">
<SegmentedControl
data={createViewOptions(switchingTo)}
data={viewOptions}
value={currentView}
onChange={handleViewChange}
color="blue"
@@ -1,10 +1,10 @@
import React from 'react';
import { Box, Center, Image } from '@mantine/core';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import { FileWithUrl } from '../../../types/file';
import { FileMetadata } from '../../../types/file';
export interface DocumentThumbnailProps {
file: File | FileWithUrl | null;
file: File | FileMetadata | null;
thumbnail?: string | null;
style?: React.CSSProperties;
onClick?: () => void;
@@ -1,6 +1,6 @@
import React, { useMemo } from 'react';
import { Box, Stack, Text } from '@mantine/core';
import { ToolRegistryEntry } from '../../data/toolsTaxonomy';
import { getSubcategoryLabel, ToolRegistryEntry } from '../../data/toolsTaxonomy';
import ToolButton from './toolPicker/ToolButton';
import { useTranslation } from 'react-i18next';
import { useToolSections } from '../../hooks/useToolSections';
@@ -23,8 +23,8 @@ const SearchResults: React.FC<SearchResultsProps> = ({ filteredTools, onSelect }
return (
<Stack p="sm" gap="xs">
{searchGroups.map(group => (
<Box key={group.subcategory} w="100%">
<SubcategoryHeader label={t(`toolPicker.subcategories.${group.subcategory}`, group.subcategory)} />
<Box key={group.subcategoryId} w="100%">
<SubcategoryHeader label={getSubcategoryLabel(t, group.subcategoryId)} />
<Stack gap="xs">
{group.tools.map(({ id, tool }) => (
<ToolButton
+9 -35
View File
@@ -1,12 +1,11 @@
import React, { useMemo, useRef, useLayoutEffect, useState } from "react";
import { Box, Text, Stack } from "@mantine/core";
import { Box, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ToolRegistryEntry } from "../../data/toolsTaxonomy";
import ToolButton from "./toolPicker/ToolButton";
import "./toolPicker/ToolPicker.css";
import { useToolSections } from "../../hooks/useToolSections";
import SubcategoryHeader from "./shared/SubcategoryHeader";
import NoToolsFound from "./shared/NoToolsFound";
import { renderToolButtons } from "./shared/renderToolButtons";
interface ToolPickerProps {
selectedToolKey: string | null;
@@ -15,31 +14,6 @@ interface ToolPickerProps {
isSearching?: boolean;
}
// Helper function to render tool buttons for a subcategory
const renderToolButtons = (
subcategory: any,
selectedToolKey: string | null,
onSelect: (id: string) => void,
showSubcategoryHeader: boolean = true
) => (
<Box key={subcategory.subcategory} w="100%">
{showSubcategoryHeader && (
<SubcategoryHeader label={subcategory.subcategory} />
)}
<Stack gap="xs">
{subcategory.tools.map(({ id, tool }: { id: string; tool: any }) => (
<ToolButton
key={id}
id={id}
tool={tool}
isSelected={selectedToolKey === id}
onSelect={onSelect}
/>
))}
</Stack>
</Box>
);
const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = false }: ToolPickerProps) => {
const { t } = useTranslation();
const [quickHeaderHeight, setQuickHeaderHeight] = useState(0);
@@ -69,11 +43,11 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
const { sections: visibleSections } = useToolSections(filteredTools);
const quickSection = useMemo(
() => visibleSections.find(s => (s as any).key === 'quick'),
() => visibleSections.find(s => s.key === 'quick'),
[visibleSections]
);
const allSection = useMemo(
() => visibleSections.find(s => (s as any).key === 'all'),
() => visibleSections.find(s => s.key === 'all'),
[visibleSections]
);
@@ -120,7 +94,7 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
{searchGroups.length === 0 ? (
<NoToolsFound />
) : (
searchGroups.map(group => renderToolButtons(group, selectedToolKey, onSelect))
searchGroups.map(group => renderToolButtons(t, group, selectedToolKey, onSelect))
)}
</Stack>
) : (
@@ -164,8 +138,8 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
<Box ref={quickAccessRef} w="100%">
<Stack p="sm" gap="xs">
{quickSection?.subcategories.map(sc =>
renderToolButtons(sc, selectedToolKey, onSelect, false)
{quickSection?.subcategories.map(sc =>
renderToolButtons(t, sc, selectedToolKey, onSelect, false)
)}
</Stack>
</Box>
@@ -210,8 +184,8 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
<Box ref={allToolsRef} w="100%">
<Stack p="sm" gap="xs">
{allSection?.subcategories.map(sc =>
renderToolButtons(sc, selectedToolKey, onSelect, true)
{allSection?.subcategories.map(sc =>
renderToolButtons(t, sc, selectedToolKey, onSelect, true)
)}
</Stack>
</Box>
@@ -0,0 +1,70 @@
/**
* AddWatermarkSingleStepSettings - Used for automation only
*
* This component combines all watermark settings into a single step interface
* for use in the automation system. It includes type selection and all relevant
* settings in one unified component.
*/
import React from "react";
import { Stack } from "@mantine/core";
import { AddWatermarkParameters } from "../../../hooks/tools/addWatermark/useAddWatermarkParameters";
import WatermarkTypeSettings from "./WatermarkTypeSettings";
import WatermarkWording from "./WatermarkWording";
import WatermarkTextStyle from "./WatermarkTextStyle";
import WatermarkImageFile from "./WatermarkImageFile";
import WatermarkFormatting from "./WatermarkFormatting";
interface AddWatermarkSingleStepSettingsProps {
parameters: AddWatermarkParameters;
onParameterChange: <K extends keyof AddWatermarkParameters>(key: K, value: AddWatermarkParameters[K]) => void;
disabled?: boolean;
}
const AddWatermarkSingleStepSettings = ({ parameters, onParameterChange, disabled = false }: AddWatermarkSingleStepSettingsProps) => {
return (
<Stack gap="lg">
{/* Watermark Type Selection */}
<WatermarkTypeSettings
watermarkType={parameters.watermarkType}
onWatermarkTypeChange={(type) => onParameterChange("watermarkType", type)}
disabled={disabled}
/>
{/* Conditional settings based on watermark type */}
{parameters.watermarkType === "text" && (
<>
<WatermarkWording
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
<WatermarkTextStyle
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
</>
)}
{parameters.watermarkType === "image" && (
<WatermarkImageFile
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
)}
{/* Formatting settings for both text and image */}
{parameters.watermarkType && (
<WatermarkFormatting
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
)}
</Stack>
);
};
export default AddWatermarkSingleStepSettings;
@@ -6,7 +6,7 @@ import NumberInputWithUnit from "../shared/NumberInputWithUnit";
interface WatermarkFormattingProps {
parameters: AddWatermarkParameters;
onParameterChange: (key: keyof AddWatermarkParameters, value: any) => void;
onParameterChange: <K extends keyof AddWatermarkParameters>(key: K, value: AddWatermarkParameters[K]) => void;
disabled?: boolean;
}
@@ -6,7 +6,7 @@ import FileUploadButton from "../../shared/FileUploadButton";
interface WatermarkImageFileProps {
parameters: AddWatermarkParameters;
onParameterChange: (key: keyof AddWatermarkParameters, value: any) => void;
onParameterChange: <K extends keyof AddWatermarkParameters>(key: K, value: AddWatermarkParameters[K]) => void;
disabled?: boolean;
}
@@ -17,7 +17,7 @@ const WatermarkImageFile = ({ parameters, onParameterChange, disabled = false }:
<Stack gap="sm">
<FileUploadButton
file={parameters.watermarkImage}
onChange={(file) => onParameterChange('watermarkImage', file)}
onChange={(file) => onParameterChange('watermarkImage', file || undefined)}
accept="image/*"
disabled={disabled}
placeholder={t('watermark.settings.image.choose', 'Choose Image')}
@@ -5,7 +5,7 @@ import { AddWatermarkParameters } from "../../../hooks/tools/addWatermark/useAdd
interface WatermarkStyleSettingsProps {
parameters: AddWatermarkParameters;
onParameterChange: (key: keyof AddWatermarkParameters, value: any) => void;
onParameterChange: <K extends keyof AddWatermarkParameters>(key: K, value: AddWatermarkParameters[K]) => void;
disabled?: boolean;
}
@@ -19,7 +19,7 @@ const WatermarkStyleSettings = ({ parameters, onParameterChange, disabled = fals
<Text size="sm" fw={500}>{t('watermark.settings.rotation', 'Rotation (degrees)')}</Text>
<NumberInput
value={parameters.rotation}
onChange={(value) => onParameterChange('rotation', value || 0)}
onChange={(value) => onParameterChange('rotation', typeof value === 'number' ? value : (parseInt(value as string, 10) || 0))}
min={-360}
max={360}
disabled={disabled}
@@ -28,7 +28,7 @@ const WatermarkStyleSettings = ({ parameters, onParameterChange, disabled = fals
<Text size="sm" fw={500}>{t('watermark.settings.opacity', 'Opacity (%)')}</Text>
<NumberInput
value={parameters.opacity}
onChange={(value) => onParameterChange('opacity', value || 50)}
onChange={(value) => onParameterChange('opacity', typeof value === 'number' ? value : (parseInt(value as string, 10) || 50))}
min={0}
max={100}
disabled={disabled}
@@ -40,7 +40,7 @@ const WatermarkStyleSettings = ({ parameters, onParameterChange, disabled = fals
<Text size="sm" fw={500}>{t('watermark.settings.spacing.width', 'Width Spacing')}</Text>
<NumberInput
value={parameters.widthSpacer}
onChange={(value) => onParameterChange('widthSpacer', value || 50)}
onChange={(value) => onParameterChange('widthSpacer', typeof value === 'number' ? value : (parseInt(value as string, 10) || 50))}
min={0}
max={200}
disabled={disabled}
@@ -49,7 +49,7 @@ const WatermarkStyleSettings = ({ parameters, onParameterChange, disabled = fals
<Text size="sm" fw={500}>{t('watermark.settings.spacing.height', 'Height Spacing')}</Text>
<NumberInput
value={parameters.heightSpacer}
onChange={(value) => onParameterChange('heightSpacer', value || 50)}
onChange={(value) => onParameterChange('heightSpacer', typeof value === 'number' ? value : (parseInt(value as string, 10) || 50))}
min={0}
max={200}
disabled={disabled}
@@ -6,7 +6,7 @@ import { alphabetOptions } from "../../../constants/addWatermarkConstants";
interface WatermarkTextStyleProps {
parameters: AddWatermarkParameters;
onParameterChange: (key: keyof AddWatermarkParameters, value: any) => void;
onParameterChange: <K extends keyof AddWatermarkParameters>(key: K, value: AddWatermarkParameters[K]) => void;
disabled?: boolean;
}
@@ -6,7 +6,7 @@ import { removeEmojis } from "../../../utils/textUtils";
interface WatermarkWordingProps {
parameters: AddWatermarkParameters;
onParameterChange: (key: keyof AddWatermarkParameters, value: any) => void;
onParameterChange: <K extends keyof AddWatermarkParameters>(key: K, value: AddWatermarkParameters[K]) => void;
disabled?: boolean;
}
@@ -0,0 +1,199 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Text,
Stack,
Group,
TextInput,
Divider,
Modal
} from '@mantine/core';
import CheckIcon from '@mui/icons-material/Check';
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
import ToolConfigurationModal from './ToolConfigurationModal';
import ToolList from './ToolList';
import { AutomationConfig, AutomationMode, AutomationTool } from '../../../types/automation';
import { useAutomationForm } from '../../../hooks/tools/automate/useAutomationForm';
interface AutomationCreationProps {
mode: AutomationMode;
existingAutomation?: AutomationConfig;
onBack: () => void;
onComplete: (automation: AutomationConfig) => void;
toolRegistry: Record<string, ToolRegistryEntry>;
}
export default function AutomationCreation({ mode, existingAutomation, onBack, onComplete, toolRegistry }: AutomationCreationProps) {
const { t } = useTranslation();
const {
automationName,
setAutomationName,
selectedTools,
addTool,
removeTool,
updateTool,
hasUnsavedChanges,
canSaveAutomation,
getToolName,
getToolDefaultParameters
} = useAutomationForm({ mode, existingAutomation, toolRegistry });
const [configModalOpen, setConfigModalOpen] = useState(false);
const [configuraingToolIndex, setConfiguringToolIndex] = useState(-1);
const [unsavedWarningOpen, setUnsavedWarningOpen] = useState(false);
const configureTool = (index: number) => {
setConfiguringToolIndex(index);
setConfigModalOpen(true);
};
const handleToolConfigSave = (parameters: Record<string, any>) => {
if (configuraingToolIndex >= 0) {
updateTool(configuraingToolIndex, {
configured: true,
parameters
});
}
setConfigModalOpen(false);
setConfiguringToolIndex(-1);
};
const handleToolConfigCancel = () => {
setConfigModalOpen(false);
setConfiguringToolIndex(-1);
};
const handleToolAdd = () => {
const newTool: AutomationTool = {
id: `tool-${Date.now()}`,
operation: '',
name: t('automate.creation.tools.selectTool', 'Select a tool...'),
configured: false,
parameters: {}
};
updateTool(selectedTools.length, newTool);
};
const handleBackClick = () => {
if (hasUnsavedChanges()) {
setUnsavedWarningOpen(true);
} else {
onBack();
}
};
const handleConfirmBack = () => {
setUnsavedWarningOpen(false);
onBack();
};
const handleCancelBack = () => {
setUnsavedWarningOpen(false);
};
const saveAutomation = async () => {
if (!canSaveAutomation()) return;
const automation = {
name: automationName.trim(),
description: '',
operations: selectedTools.map(tool => ({
operation: tool.operation,
parameters: tool.parameters || {}
}))
};
try {
const { automationStorage } = await import('../../../services/automationStorage');
const savedAutomation = await automationStorage.saveAutomation(automation);
onComplete(savedAutomation);
} catch (error) {
console.error('Error saving automation:', error);
}
};
const currentConfigTool = configuraingToolIndex >= 0 ? selectedTools[configuraingToolIndex] : null;
return (
<div>
<Text size="sm" mb="md" p="md" style={{borderRadius:'var(--mantine-radius-md)', background: 'var(--color-gray-200)', color: 'var(--mantine-color-text)' }}>
{t("automate.creation.description", "Automations run tools sequentially. To get started, add tools in the order you want them to run.")}
</Text>
<Divider mb="md" />
<Stack gap="md">
{/* Automation Name */}
<TextInput
placeholder={t('automate.creation.name.placeholder', 'Automation name')}
value={automationName}
onChange={(e) => setAutomationName(e.currentTarget.value)}
size="sm"
/>
{/* Selected Tools List */}
{selectedTools.length > 0 && (
<ToolList
tools={selectedTools}
toolRegistry={toolRegistry}
onToolUpdate={updateTool}
onToolRemove={removeTool}
onToolConfigure={configureTool}
onToolAdd={handleToolAdd}
getToolName={getToolName}
getToolDefaultParameters={getToolDefaultParameters}
/>
)}
<Divider />
{/* Save Button */}
<Button
leftSection={<CheckIcon />}
onClick={saveAutomation}
disabled={!canSaveAutomation()}
fullWidth
>
{t('automate.creation.save', 'Save Automation')}
</Button>
</Stack>
{/* Tool Configuration Modal */}
{currentConfigTool && (
<ToolConfigurationModal
opened={configModalOpen}
tool={currentConfigTool}
onSave={handleToolConfigSave}
onCancel={handleToolConfigCancel}
toolRegistry={toolRegistry}
/>
)}
{/* Unsaved Changes Warning Modal */}
<Modal
opened={unsavedWarningOpen}
onClose={handleCancelBack}
title={t('automate.creation.unsavedChanges.title', 'Unsaved Changes')}
centered
>
<Stack gap="md">
<Text>
{t('automate.creation.unsavedChanges.message', 'You have unsaved changes. Are you sure you want to go back? All changes will be lost.')}
</Text>
<Group gap="md" justify="flex-end">
<Button variant="outline" onClick={handleCancelBack}>
{t('automate.creation.unsavedChanges.cancel', 'Cancel')}
</Button>
<Button color="red" onClick={handleConfirmBack}>
{t('automate.creation.unsavedChanges.confirm', 'Go Back')}
</Button>
</Group>
</Stack>
</Modal>
</div>
);
}
@@ -0,0 +1,163 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Group, Text, ActionIcon, Menu, Box } from '@mantine/core';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
interface AutomationEntryProps {
/** Optional title for the automation (usually for custom ones) */
title?: string;
/** MUI Icon component for the badge */
badgeIcon?: React.ComponentType<any>;
/** Array of tool operation names in the workflow */
operations: string[];
/** Click handler */
onClick: () => void;
/** Whether to keep the icon at normal color (for special cases like "Add New") */
keepIconColor?: boolean;
/** Show menu for saved/suggested automations */
showMenu?: boolean;
/** Edit handler */
onEdit?: () => void;
/** Delete handler */
onDelete?: () => void;
}
export default function AutomationEntry({
title,
badgeIcon: BadgeIcon,
operations,
onClick,
keepIconColor = false,
showMenu = false,
onEdit,
onDelete
}: AutomationEntryProps) {
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
// Keep item in hovered state if menu is open
const shouldShowHovered = isHovered || isMenuOpen;
const renderContent = () => {
if (title) {
// Custom automation with title
return (
<Group gap="md" align="center" justify="flex-start" style={{ width: '100%' }}>
{BadgeIcon && (
<BadgeIcon
style={{
color: keepIconColor ? 'var(--mantine-primary-color-filled)' : 'var(--mantine-color-text)'
}}
/>
)}
<Text size="xs" style={{ flex: 1, textAlign: 'left', color: 'var(--mantine-color-text)' }}>
{title}
</Text>
</Group>
);
} else {
// Suggested automation showing tool chain
return (
<Group gap="md" align="center" justify="flex-start" style={{ width: '100%' }}>
{BadgeIcon && (
<BadgeIcon
style={{
color: keepIconColor ? 'var(--mantine-primary-color-filled)' : 'var(--mantine-color-text)'
}}
/>
)}
<Group gap="xs" justify="flex-start" style={{ flex: 1 }}>
{operations.map((op, index) => (
<React.Fragment key={`${op}-${index}`}>
<Text size="xs" style={{ color: 'var(--mantine-color-text)' }}>
{t(`${op}.title`, op)}
</Text>
{index < operations.length - 1 && (
<Text size="xs" c="dimmed" style={{ color: 'var(--mantine-color-text)' }}>
</Text>
)}
</React.Fragment>
))}
</Group>
</Group>
);
}
};
return (
<Box
style={{
backgroundColor: shouldShowHovered ? 'var(--mantine-color-gray-1)' : 'transparent',
borderRadius: 'var(--mantine-radius-md)',
transition: 'background-color 0.15s ease',
padding: '0.75rem 1rem',
cursor: 'pointer'
}}
onClick={onClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<Group gap="md" align="center" justify="space-between" style={{ width: '100%' }}>
<div style={{ flex: 1, display: 'flex', justifyContent: 'flex-start' }}>
{renderContent()}
</div>
{showMenu && (
<Menu
position="bottom-end"
withinPortal
onOpen={() => setIsMenuOpen(true)}
onClose={() => setIsMenuOpen(false)}
>
<Menu.Target>
<ActionIcon
variant="subtle"
c="dimmed"
size="md"
onClick={(e) => e.stopPropagation()}
style={{
opacity: shouldShowHovered ? 1 : 0,
transform: shouldShowHovered ? 'scale(1)' : 'scale(0.8)',
transition: 'opacity 0.3s ease, transform 0.3s ease',
pointerEvents: shouldShowHovered ? 'auto' : 'none'
}}
>
<MoreVertIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{onEdit && (
<Menu.Item
leftSection={<EditIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
>
{t('edit', 'Edit')}
</Menu.Item>
)}
{onDelete && (
<Menu.Item
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
{t('delete', 'Delete')}
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
)}
</Group>
</Box>
);
}
@@ -0,0 +1,223 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Text, Stack, Group, Card, Progress } from "@mantine/core";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import CheckIcon from "@mui/icons-material/Check";
import { useFileSelection } from "../../../contexts/FileContext";
import { useFlatToolRegistry } from "../../../data/useTranslatedToolRegistry";
import { AutomationConfig, ExecutionStep } from "../../../types/automation";
import { AUTOMATION_CONSTANTS, EXECUTION_STATUS } from "../../../constants/automation";
import { useResourceCleanup } from "../../../utils/resourceManager";
interface AutomationRunProps {
automation: AutomationConfig;
onComplete: () => void;
automateOperation?: any; // TODO: Type this properly when available
}
export default function AutomationRun({ automation, onComplete, automateOperation }: AutomationRunProps) {
const { t } = useTranslation();
const { selectedFiles } = useFileSelection();
const toolRegistry = useFlatToolRegistry();
const cleanup = useResourceCleanup();
// Progress tracking state
const [executionSteps, setExecutionSteps] = useState<ExecutionStep[]>([]);
const [currentStepIndex, setCurrentStepIndex] = useState(-1);
// Use the operation hook's loading state
const isExecuting = automateOperation?.isLoading || false;
const hasResults = automateOperation?.files.length > 0 || automateOperation?.downloadUrl !== null;
// Initialize execution steps from automation
React.useEffect(() => {
if (automation?.operations) {
const steps = automation.operations.map((op: any, index: number) => {
const tool = toolRegistry[op.operation];
return {
id: `${op.operation}-${index}`,
operation: op.operation,
name: tool?.name || op.operation,
status: EXECUTION_STATUS.PENDING
};
});
setExecutionSteps(steps);
setCurrentStepIndex(-1);
}
}, [automation, toolRegistry]);
// Cleanup when component unmounts
React.useEffect(() => {
return () => {
// Reset progress state when component unmounts
setExecutionSteps([]);
setCurrentStepIndex(-1);
// Clean up any blob URLs
cleanup();
};
}, [cleanup]);
const executeAutomation = async () => {
if (!selectedFiles || selectedFiles.length === 0) {
return;
}
if (!automateOperation) {
console.error('No automateOperation provided');
return;
}
// Reset progress tracking
setCurrentStepIndex(0);
setExecutionSteps(prev => prev.map(step => ({ ...step, status: EXECUTION_STATUS.PENDING, error: undefined })));
try {
// Use the automateOperation.executeOperation to handle file consumption properly
await automateOperation.executeOperation(
{
automationConfig: automation,
onStepStart: (stepIndex: number, operationName: string) => {
setCurrentStepIndex(stepIndex);
setExecutionSteps(prev => prev.map((step, idx) =>
idx === stepIndex ? { ...step, status: EXECUTION_STATUS.RUNNING } : step
));
},
onStepComplete: (stepIndex: number, resultFiles: File[]) => {
setExecutionSteps(prev => prev.map((step, idx) =>
idx === stepIndex ? { ...step, status: EXECUTION_STATUS.COMPLETED } : step
));
},
onStepError: (stepIndex: number, error: string) => {
setExecutionSteps(prev => prev.map((step, idx) =>
idx === stepIndex ? { ...step, status: EXECUTION_STATUS.ERROR, error } : step
));
}
},
selectedFiles
);
// Mark all as completed and reset current step
setCurrentStepIndex(-1);
console.log(`✅ Automation completed successfully`);
} catch (error: any) {
console.error("Automation execution failed:", error);
setCurrentStepIndex(-1);
}
};
const getProgress = () => {
if (executionSteps.length === 0) return 0;
const completedSteps = executionSteps.filter(step => step.status === EXECUTION_STATUS.COMPLETED).length;
return (completedSteps / executionSteps.length) * 100;
};
const getStepIcon = (step: ExecutionStep) => {
switch (step.status) {
case EXECUTION_STATUS.COMPLETED:
return <CheckIcon style={{ fontSize: 16, color: 'green' }} />;
case EXECUTION_STATUS.ERROR:
return <span style={{ fontSize: 16, color: 'red' }}></span>;
case EXECUTION_STATUS.RUNNING:
return <div style={{
width: 16,
height: 16,
border: '2px solid #ccc',
borderTop: '2px solid #007bff',
borderRadius: '50%',
animation: `spin ${AUTOMATION_CONSTANTS.SPINNER_ANIMATION_DURATION} linear infinite`
}} />;
default:
return <div style={{
width: 16,
height: 16,
border: '2px solid #ccc',
borderRadius: '50%'
}} />;
}
};
return (
<div>
<Stack gap="md">
{/* Automation Info */}
<Card padding="md" withBorder>
<Text size="sm" fw={500} mb="xs">
{automation?.name || t("automate.sequence.unnamed", "Unnamed Automation")}
</Text>
<Text size="xs" c="dimmed">
{t("automate.sequence.steps", "{{count}} steps", { count: executionSteps.length })}
</Text>
</Card>
{/* Progress Bar */}
{isExecuting && (
<div>
<Text size="sm" mb="xs">
Progress: {currentStepIndex + 1}/{executionSteps.length}
</Text>
<Progress value={getProgress()} size="lg" />
</div>
)}
{/* Execution Steps */}
<Stack gap="xs">
{executionSteps.map((step, index) => (
<Group key={step.id} gap="sm" align="center">
<Text size="xs" c="dimmed" style={{ minWidth: "1rem", textAlign: "center" }}>
{index + 1}
</Text>
{getStepIcon(step)}
<div style={{ flex: 1 }}>
<Text
size="sm"
style={{
color: step.status === EXECUTION_STATUS.RUNNING ? 'var(--mantine-color-blue-6)' : 'var(--mantine-color-text)',
fontWeight: step.status === EXECUTION_STATUS.RUNNING ? 500 : 400
}}
>
{step.name}
</Text>
{step.error && (
<Text size="xs" c="red" mt="xs">
{step.error}
</Text>
)}
</div>
</Group>
))}
</Stack>
{/* Action Buttons */}
<Group justify="space-between" mt="xl">
<Button
leftSection={<PlayArrowIcon />}
onClick={executeAutomation}
disabled={isExecuting || !selectedFiles || selectedFiles.length === 0}
loading={isExecuting}
>
{isExecuting
? t("automate.sequence.running", "Running Automation...")
: t("automate.sequence.run", "Run Automation")}
</Button>
{hasResults && (
<Button variant="light" onClick={onComplete}>
{t("automate.sequence.finish", "Finish")}
</Button>
)}
</Group>
</Stack>
<style>
{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`}
</style>
</div>
);
}
@@ -0,0 +1,76 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Title, Stack, Divider } from "@mantine/core";
import AddCircleOutline from "@mui/icons-material/AddCircleOutline";
import SettingsIcon from "@mui/icons-material/Settings";
import AutomationEntry from "./AutomationEntry";
import { useSuggestedAutomations } from "../../../hooks/tools/automate/useSuggestedAutomations";
import { AutomationConfig } from "../../../types/automation";
interface AutomationSelectionProps {
savedAutomations: AutomationConfig[];
onCreateNew: () => void;
onRun: (automation: AutomationConfig) => void;
onEdit: (automation: AutomationConfig) => void;
onDelete: (automation: AutomationConfig) => void;
}
export default function AutomationSelection({
savedAutomations,
onCreateNew,
onRun,
onEdit,
onDelete
}: AutomationSelectionProps) {
const { t } = useTranslation();
const suggestedAutomations = useSuggestedAutomations();
return (
<div>
<Title order={3} size="h4" fw={600} mb="md" style={{color: 'var(--mantine-color-dimmed)'}}>
{t("automate.selection.saved.title", "Saved")}
</Title>
<Stack gap="xs">
<AutomationEntry
title={t("automate.selection.createNew.title", "Create New Automation")}
badgeIcon={AddCircleOutline}
operations={[]}
onClick={onCreateNew}
keepIconColor={true}
/>
{/* Saved Automations */}
{savedAutomations.map((automation) => (
<AutomationEntry
key={automation.id}
title={automation.name}
badgeIcon={SettingsIcon}
operations={automation.operations.map(op => typeof op === 'string' ? op : op.operation)}
onClick={() => onRun(automation)}
showMenu={true}
onEdit={() => onEdit(automation)}
onDelete={() => onDelete(automation)}
/>
))}
<Divider pb='sm' />
{/* Suggested Automations */}
<div>
<Title order={3} size="h4" fw={600} mb="md"style={ {color: 'var(--mantine-color-dimmed)'}}>
{t("automate.selection.suggested.title", "Suggested")}
</Title>
<Stack gap="xs">
{suggestedAutomations.map((automation) => (
<AutomationEntry
key={automation.id}
badgeIcon={automation.icon}
operations={automation.operations.map(op => op.operation)}
onClick={() => onRun(automation)}
/>
))}
</Stack>
</div>
</Stack>
</div>
);
}
@@ -0,0 +1,138 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
Modal,
Title,
Button,
Group,
Stack,
Text,
Alert
} from '@mantine/core';
import SettingsIcon from '@mui/icons-material/Settings';
import CheckIcon from '@mui/icons-material/Check';
import CloseIcon from '@mui/icons-material/Close';
import WarningIcon from '@mui/icons-material/Warning';
import { ToolRegistry } from '../../../data/toolsTaxonomy';
import { getAvailableToExtensions } from '../../../utils/convertUtils';
interface ToolConfigurationModalProps {
opened: boolean;
tool: {
id: string;
operation: string;
name: string;
parameters?: any;
};
onSave: (parameters: any) => void;
onCancel: () => void;
toolRegistry: ToolRegistry;
}
export default function ToolConfigurationModal({ opened, tool, onSave, onCancel, toolRegistry }: ToolConfigurationModalProps) {
const { t } = useTranslation();
const [parameters, setParameters] = useState<any>({});
const [isValid, setIsValid] = useState(true);
// Get tool info from registry
const toolInfo = toolRegistry[tool.operation];
const SettingsComponent = toolInfo?.settingsComponent;
// Initialize parameters from tool (which should contain defaults from registry)
useEffect(() => {
if (tool.parameters) {
setParameters(tool.parameters);
} else {
// Fallback to empty parameters if none provided
setParameters({});
}
}, [tool.parameters, tool.operation]);
// Render the settings component
const renderToolSettings = () => {
if (!SettingsComponent) {
return (
<Alert icon={<WarningIcon />} color="orange">
<Text size="sm">
{t('automate.config.noSettings', 'This tool does not have configurable settings.')}
</Text>
</Alert>
);
}
// Special handling for ConvertSettings which needs additional props
if (tool.operation === 'convert') {
return (
<SettingsComponent
parameters={parameters}
onParameterChange={(key: string, value: any) => {
setParameters((prev: any) => ({ ...prev, [key]: value }));
}}
getAvailableToExtensions={getAvailableToExtensions}
selectedFiles={[]}
disabled={false}
/>
);
}
return (
<SettingsComponent
parameters={parameters}
onParameterChange={(key: string, value: any) => {
setParameters((prev: any) => ({ ...prev, [key]: value }));
}}
disabled={false}
/>
);
};
const handleSave = () => {
if (isValid) {
onSave(parameters);
}
};
return (
<Modal
opened={opened}
onClose={onCancel}
title={
<Group gap="xs">
<SettingsIcon />
<Title order={3}>
{t('automate.config.title', 'Configure {{toolName}}', { toolName: tool.name })}
</Title>
</Group>
}
size="lg"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
{t('automate.config.description', 'Configure the settings for this tool. These settings will be applied when the automation runs.')}
</Text>
<div style={{ maxHeight: '60vh', overflowY: 'auto' }}>
{renderToolSettings()}
</div>
<Group justify="flex-end" gap="sm">
<Button
variant="light"
leftSection={<CloseIcon />}
onClick={onCancel}
>
{t('automate.config.cancel', 'Cancel')}
</Button>
<Button
leftSection={<CheckIcon />}
onClick={handleSave}
disabled={!isValid}
>
{t('automate.config.save', 'Save Configuration')}
</Button>
</Group>
</Stack>
</Modal>
);
}
@@ -0,0 +1,149 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Text, Stack, Group, ActionIcon } from '@mantine/core';
import DeleteIcon from '@mui/icons-material/Delete';
import SettingsIcon from '@mui/icons-material/Settings';
import CloseIcon from '@mui/icons-material/Close';
import AddCircleOutline from '@mui/icons-material/AddCircleOutline';
import { AutomationTool } from '../../../types/automation';
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
import ToolSelector from './ToolSelector';
import AutomationEntry from './AutomationEntry';
interface ToolListProps {
tools: AutomationTool[];
toolRegistry: Record<string, ToolRegistryEntry>;
onToolUpdate: (index: number, updates: Partial<AutomationTool>) => void;
onToolRemove: (index: number) => void;
onToolConfigure: (index: number) => void;
onToolAdd: () => void;
getToolName: (operation: string) => string;
getToolDefaultParameters: (operation: string) => Record<string, any>;
}
export default function ToolList({
tools,
toolRegistry,
onToolUpdate,
onToolRemove,
onToolConfigure,
onToolAdd,
getToolName,
getToolDefaultParameters
}: ToolListProps) {
const { t } = useTranslation();
const handleToolSelect = (index: number, newOperation: string) => {
const defaultParams = getToolDefaultParameters(newOperation);
onToolUpdate(index, {
operation: newOperation,
name: getToolName(newOperation),
configured: false,
parameters: defaultParams
});
};
return (
<div>
<Text size="sm" fw={500} mb="xs" style={{ color: 'var(--mantine-color-text)' }}>
{t('automate.creation.tools.selected', 'Selected Tools')} ({tools.length})
</Text>
<Stack gap="0">
{tools.map((tool, index) => (
<React.Fragment key={tool.id}>
<div
style={{
border: '1px solid var(--mantine-color-gray-2)',
borderRadius: 'var(--mantine-radius-sm)',
position: 'relative',
padding: 'var(--mantine-spacing-xs)'
}}
>
{/* Delete X in top right */}
<ActionIcon
variant="subtle"
size="xs"
onClick={() => onToolRemove(index)}
title={t('automate.creation.tools.remove', 'Remove tool')}
style={{
position: 'absolute',
top: '4px',
right: '4px',
zIndex: 1,
color: 'var(--mantine-color-gray-6)'
}}
>
<CloseIcon style={{ fontSize: 12 }} />
</ActionIcon>
<div style={{ paddingRight: '1.25rem' }}>
{/* Tool Selection Dropdown with inline settings cog */}
<Group gap="xs" align="center" wrap="nowrap">
<div style={{ flex: 1, minWidth: 0 }}>
<ToolSelector
key={`tool-selector-${tool.id}`}
onSelect={(newOperation) => handleToolSelect(index, newOperation)}
excludeTools={['automate']}
toolRegistry={toolRegistry}
selectedValue={tool.operation}
placeholder={tool.name}
/>
</div>
{/* Settings cog - only show if tool is selected, aligned right */}
{tool.operation && (
<ActionIcon
variant="subtle"
size="sm"
onClick={() => onToolConfigure(index)}
title={t('automate.creation.tools.configure', 'Configure tool')}
style={{ color: 'var(--mantine-color-gray-6)' }}
>
<SettingsIcon style={{ fontSize: 16 }} />
</ActionIcon>
)}
</Group>
{/* Configuration status underneath */}
{tool.operation && !tool.configured && (
<Text pl="md" size="xs" c="dimmed" mt="xs">
{t('automate.creation.tools.notConfigured', "! Not Configured")}
</Text>
)}
</div>
</div>
{index < tools.length - 1 && (
<div style={{ textAlign: 'center', padding: '8px 0' }}>
<Text size="xs" c="dimmed"></Text>
</div>
)}
</React.Fragment>
))}
{/* Arrow before Add Tool Button */}
{tools.length > 0 && (
<div style={{ textAlign: 'center', padding: '8px 0' }}>
<Text size="xs" c="dimmed"></Text>
</div>
)}
{/* Add Tool Button */}
<div style={{
border: '1px solid var(--mantine-color-gray-2)',
borderRadius: 'var(--mantine-radius-sm)',
overflow: 'hidden'
}}>
<AutomationEntry
title={t('automate.creation.tools.addTool', 'Add Tool')}
badgeIcon={AddCircleOutline}
operations={[]}
onClick={onToolAdd}
keepIconColor={true}
/>
</div>
</Stack>
</div>
);
}
@@ -0,0 +1,182 @@
import React, { useState, useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Menu, Stack, Text, ScrollArea } from '@mantine/core';
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
import { useToolSections } from '../../../hooks/useToolSections';
import { renderToolButtons } from '../shared/renderToolButtons';
import ToolSearch from '../toolPicker/ToolSearch';
interface ToolSelectorProps {
onSelect: (toolKey: string) => void;
excludeTools?: string[];
toolRegistry: Record<string, ToolRegistryEntry>; // Pass registry as prop to break circular dependency
selectedValue?: string; // For showing current selection when editing existing tool
placeholder?: string; // Custom placeholder text
}
export default function ToolSelector({
onSelect,
excludeTools = [],
toolRegistry,
selectedValue,
placeholder
}: ToolSelectorProps) {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
// Filter out excluded tools (like 'automate' itself)
const baseFilteredTools = useMemo(() => {
return Object.entries(toolRegistry).filter(([key]) => !excludeTools.includes(key));
}, [toolRegistry, excludeTools]);
// Apply search filter
const filteredTools = useMemo(() => {
if (!searchTerm.trim()) {
return baseFilteredTools;
}
const lowercaseSearch = searchTerm.toLowerCase();
return baseFilteredTools.filter(([key, tool]) => {
return (
tool.name.toLowerCase().includes(lowercaseSearch) ||
tool.description?.toLowerCase().includes(lowercaseSearch) ||
key.toLowerCase().includes(lowercaseSearch)
);
});
}, [baseFilteredTools, searchTerm]);
// Create filtered tool registry for ToolSearch
const filteredToolRegistry = useMemo(() => {
const registry: Record<string, ToolRegistryEntry> = {};
baseFilteredTools.forEach(([key, tool]) => {
registry[key] = tool;
});
return registry;
}, [baseFilteredTools]);
// Use the same tool sections logic as the main ToolPicker
const { sections, searchGroups } = useToolSections(filteredTools);
// Determine what to display: search results or organized sections
const isSearching = searchTerm.trim().length > 0;
const displayGroups = useMemo(() => {
if (isSearching) {
return searchGroups || [];
}
if (!sections || sections.length === 0) {
return [];
}
// Find the "all" section which contains all tools without duplicates
const allSection = sections.find(s => (s as any).key === 'all');
return allSection?.subcategories || [];
}, [isSearching, searchGroups, sections]);
const handleToolSelect = useCallback((toolKey: string) => {
onSelect(toolKey);
setOpened(false);
setSearchTerm(''); // Clear search to show the selected tool display
}, [onSelect]);
const renderedTools = useMemo(() =>
displayGroups.map((subcategory) =>
renderToolButtons(t, subcategory, null, handleToolSelect, !isSearching)
), [displayGroups, handleToolSelect, isSearching, t]
);
const handleSearchFocus = () => {
setOpened(true);
};
const handleSearchChange = (value: string) => {
setSearchTerm(value);
if (!opened) {
setOpened(true);
}
};
// Get display value for selected tool
const getDisplayValue = () => {
if (selectedValue && toolRegistry[selectedValue]) {
return toolRegistry[selectedValue].name;
}
return placeholder || t('automate.creation.tools.add', 'Add a tool...');
};
return (
<div style={{ position: 'relative', width: '100%' }}>
<Menu
opened={opened}
onChange={(isOpen) => {
setOpened(isOpen);
// Clear search term when menu closes to show proper display
if (!isOpen) {
setSearchTerm('');
}
}}
closeOnClickOutside={true}
closeOnEscape={true}
position="bottom-start"
offset={4}
withinPortal={false}
trapFocus={false}
shadow="sm"
transitionProps={{ duration: 0 }}
>
<Menu.Target>
<div style={{ width: '100%' }}>
{selectedValue && toolRegistry[selectedValue] && !opened ? (
// Show selected tool in AutomationEntry style when tool is selected and not searching
<div onClick={handleSearchFocus} style={{ cursor: 'pointer' }}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--mantine-spacing-sm)',
padding: '0 0.5rem',
borderRadius: 'var(--mantine-radius-sm)',
}}>
<div style={{ color: 'var(--mantine-color-text)', fontSize: '1.2rem' }}>
{toolRegistry[selectedValue].icon}
</div>
<Text size="sm" style={{ flex: 1, color: 'var(--mantine-color-text)' }}>
{toolRegistry[selectedValue].name}
</Text>
</div>
</div>
) : (
// Show search input when no tool selected or actively searching
<ToolSearch
value={searchTerm}
onChange={handleSearchChange}
toolRegistry={filteredToolRegistry}
mode="filter"
placeholder={getDisplayValue()}
hideIcon={true}
onFocus={handleSearchFocus}
/>
)}
</div>
</Menu.Target>
<Menu.Dropdown p={0} style={{ minWidth: '16rem' }}>
<ScrollArea h={350}>
<Stack gap="sm" p="sm">
{displayGroups.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" p="md">
{isSearching
? t('tools.noSearchResults', 'No tools found')
: t('tools.noTools', 'No tools available')
}
</Text>
) : (
renderedTools
)}
</Stack>
</ScrollArea>
</Menu.Dropdown>
</Menu>
</div>
);
}
@@ -1,7 +1,7 @@
import React, { useState } from "react";
import { Button, Stack, Text, NumberInput, Select, Divider } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { CompressParameters } from "../../../hooks/tools/compress/useCompressOperation";
import { CompressParameters } from "../../../hooks/tools/compress/useCompressParameters";
interface CompressSettingsProps {
parameters: CompressParameters;
@@ -5,8 +5,8 @@ import { useTranslation } from "react-i18next";
import { useMultipleEndpointsEnabled } from "../../../hooks/useEndpointConfig";
import { isImageFormat, isWebFormat } from "../../../utils/convertUtils";
import { getConversionEndpoints } from "../../../data/toolsTaxonomy";
import { useFileSelectionActions } from "../../../contexts/FileSelectionContext";
import { useFileContext } from "../../../contexts/FileContext";
import { useFileSelection } from "../../../contexts/FileContext";
import { useFileState } from "../../../contexts/FileContext";
import { detectFileExtension } from "../../../utils/fileUtils";
import GroupedFormatDropdown from "./GroupedFormatDropdown";
import ConvertToImageSettings from "./ConvertToImageSettings";
@@ -41,8 +41,9 @@ const ConvertSettings = ({
const { t } = useTranslation();
const theme = useMantineTheme();
const { colorScheme } = useMantineColorScheme();
const { setSelectedFiles } = useFileSelectionActions();
const { activeFiles, setSelectedFiles: setContextSelectedFiles } = useFileContext();
const { setSelectedFiles } = useFileSelection();
const { state, selectors } = useFileState();
const activeFiles = state.files.ids;
const allEndpoints = useMemo(() => getConversionEndpoints(EXTENSION_TO_ENDPOINT), []);
@@ -85,9 +86,9 @@ const ConvertSettings = ({
}
return baseOptions;
}, [getAvailableToExtensions, endpointStatus, parameters.fromExtension]);
}, [parameters.fromExtension, endpointStatus]);
// Enhanced TO options with endpoint availability
// Enhanced TO options with endpoint availability
const enhancedToOptions = useMemo(() => {
if (!parameters.fromExtension) return [];
@@ -96,7 +97,7 @@ const ConvertSettings = ({
...option,
enabled: isConversionAvailable(parameters.fromExtension, option.value)
}));
}, [parameters.fromExtension, getAvailableToExtensions, endpointStatus]);
}, [parameters.fromExtension, endpointStatus]);
const resetParametersToDefaults = () => {
onParameterChange('imageOptions', {
@@ -127,7 +128,8 @@ const ConvertSettings = ({
};
const filterFilesByExtension = (extension: string) => {
return activeFiles.filter(file => {
const files = activeFiles.map(fileId => selectors.getFile(fileId)).filter(Boolean) as File[];
return files.filter(file => {
const fileExtension = detectFileExtension(file.name);
if (extension === 'any') {
@@ -141,9 +143,21 @@ const ConvertSettings = ({
};
const updateFileSelection = (files: File[]) => {
setSelectedFiles(files);
const fileIds = files.map(file => (file as any).id || file.name);
setContextSelectedFiles(fileIds);
// Map File objects to their actual IDs in FileContext
const fileIds = files.map(file => {
// Find the file ID by matching file properties
const fileRecord = state.files.ids
.map(id => selectors.getFileRecord(id))
.find(record =>
record &&
record.name === file.name &&
record.size === file.size &&
record.lastModified === file.lastModified
);
return fileRecord?.id;
}).filter((id): id is string => id !== undefined); // Type guard to ensure only strings
setSelectedFiles(fileIds);
};
const handleFromExtensionChange = (value: string) => {
@@ -1,38 +0,0 @@
import React from 'react';
import { Stack, Text, TextInput, Divider } from "@mantine/core";
import { useTranslation } from 'react-i18next';
import { DeletePagesParameters } from '../../../hooks/tools/deletePages/useDeletePagesParameters';
interface DeletePagesSettingsProps {
parameters: DeletePagesParameters;
onParameterChange: <K extends keyof DeletePagesParameters>(parameter: K, value: DeletePagesParameters[K]) => void;
disabled?: boolean;
}
const DeletePagesSettings: React.FC<DeletePagesSettingsProps> = ({
parameters,
onParameterChange,
disabled = false
}) => {
const { t } = useTranslation();
return (
<Stack gap="md">
<Divider ml='-md' />
<Stack gap="sm">
<Text size="sm" fw={500}>
{t('removePages.pageNumbers', 'Pages to Remove')}
</Text>
<TextInput
value={parameters.pageNumbers}
onChange={(e) => onParameterChange('pageNumbers', e.target.value.replace(/\s+/g, ''))}
placeholder={t('removePages.pageNumbersPlaceholder', 'e.g. 1,3,5-7')}
disabled={disabled}
description={t('removePages.pageNumbersHelp', 'Enter page numbers separated by commas, or ranges like 1-5. Example: 1,3,5-7')}
/>
</Stack>
</Stack>
);
};
export default DeletePagesSettings;
@@ -1,7 +1,7 @@
import React from 'react';
import { Stack, Text, Checkbox } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { OCRParameters } from './OCRSettings';
import { OCRParameters } from '../../../hooks/tools/ocr/useOCRParameters';
export interface AdvancedOCRParameters {
advancedOptions: string[];
@@ -2,13 +2,7 @@ import React from 'react';
import { Stack, Select, Text, Divider } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LanguagePicker from './LanguagePicker';
export interface OCRParameters {
languages: string[];
ocrType: string;
ocrRenderType: string;
additionalOptions: string[];
}
import { OCRParameters } from '../../../hooks/tools/ocr/useOCRParameters';
interface OCRSettingsProps {
parameters: OCRParameters;
@@ -25,7 +19,7 @@ const OCRSettings: React.FC<OCRSettingsProps> = ({
return (
<Stack gap="md">
<Select
label={t('ocr.settings.ocrMode.label', 'OCR Mode')}
value={parameters.ocrType}
@@ -51,4 +45,4 @@ const OCRSettings: React.FC<OCRSettingsProps> = ({
);
};
export default OCRSettings;
export default OCRSettings;
@@ -0,0 +1,27 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { RemoveCertificateSignParameters } from '../../../hooks/tools/removeCertificateSign/useRemoveCertificateSignParameters';
interface RemoveCertificateSignSettingsProps {
parameters: RemoveCertificateSignParameters;
onParameterChange: <K extends keyof RemoveCertificateSignParameters>(parameter: K, value: RemoveCertificateSignParameters[K]) => void;
disabled?: boolean;
}
const RemoveCertificateSignSettings: React.FC<RemoveCertificateSignSettingsProps> = ({
parameters,
onParameterChange, // Unused - kept for interface consistency and future extensibility
disabled = false
}) => {
const { t } = useTranslation();
return (
<div className="remove-certificate-sign-settings">
<p className="text-muted">
{t('removeCertSign.description', 'This tool will remove digital certificate signatures from your PDF document.')}
</p>
</div>
);
};
export default RemoveCertificateSignSettings;
@@ -1,5 +1,9 @@
import React from 'react';
import { Text } from '@mantine/core';
import React from "react";
import { Text, Anchor } from "@mantine/core";
import { useTranslation } from "react-i18next";
import FolderIcon from '@mui/icons-material/Folder';
import { useFilesModalContext } from "../../../contexts/FilesModalContext";
import { useAllFiles } from "../../../contexts/FileContext";
export interface FileStatusIndicatorProps {
selectedFiles?: File[];
@@ -8,20 +12,51 @@ export interface FileStatusIndicatorProps {
const FileStatusIndicator = ({
selectedFiles = [],
placeholder = "Select a PDF file in the main view to get started"
placeholder,
}: FileStatusIndicatorProps) => {
// Only show content when no files are selected
if (selectedFiles.length === 0) {
const { t } = useTranslation();
const { openFilesModal } = useFilesModalContext();
const { files: workbenchFiles } = useAllFiles();
// Check if there are no files in the workbench
if (workbenchFiles.length === 0) {
return (
<Text size="sm" c="dimmed">
{placeholder}
{t("files.noFiles", "No files uploaded. ")}{" "}
<Anchor
size="sm"
onClick={openFilesModal}
style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '4px' }}
>
<FolderIcon style={{ fontSize: '14px' }} />
{t("files.addFiles", "Add files")}
</Anchor>
</Text>
);
}
// Return nothing when files are selected
return null;
}
// Show selection status when there are files in workbench
if (selectedFiles.length === 0) {
return (
<Text size="sm" c="dimmed">
{t("files.selectFromWorkbench", "Select files from the workbench or ") + " "}
<Anchor
size="sm"
onClick={openFilesModal}
style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '4px' }}
>
<FolderIcon style={{ fontSize: '14px' }} />
{t("files.addFiles", "Add files")}
</Anchor>
</Text>
);
}
export default FileStatusIndicator;
return (
<Text size="sm" c="dimmed" style={{ wordBreak: 'break-word', whiteSpace: 'normal' }}>
{selectedFiles.length === 1 ? t("fileSelected", "Selected: {{filename}}", { filename: selectedFiles[0]?.name }) : t("filesSelected", "{{count}} files selected", { count: selectedFiles.length })}
</Text>
);
};
export default FileStatusIndicator;
@@ -2,6 +2,7 @@ import React from 'react';
import { Stack, Text, Divider, Card, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useSuggestedTools } from '../../../hooks/useSuggestedTools';
export interface SuggestedToolsSectionProps {}
export function SuggestedToolsSection(): React.ReactElement {
@@ -21,7 +22,7 @@ export function SuggestedToolsSection(): React.ReactElement {
const IconComponent = tool.icon;
return (
<Card
key={tool.name}
key={tool.id}
p="sm"
withBorder
style={{ cursor: 'pointer' }}
@@ -25,6 +25,7 @@ export interface ToolStepProps {
_stepNumber?: number; // Internal prop set by ToolStepContainer
_excludeFromCount?: boolean; // Internal prop to exclude from visible count calculation
_noPadding?: boolean; // Internal prop to exclude from default left padding
alwaysShowTooltip?: boolean; // Force tooltip to show even when collapsed
tooltip?: {
content?: React.ReactNode;
tips?: TooltipTip[];
@@ -38,9 +39,10 @@ export interface ToolStepProps {
const renderTooltipTitle = (
title: string,
tooltip: ToolStepProps['tooltip'],
isCollapsed: boolean
isCollapsed: boolean,
alwaysShowTooltip: boolean = false
) => {
if (tooltip && !isCollapsed) {
if (tooltip && (!isCollapsed || alwaysShowTooltip)) {
return (
<Tooltip
content={tooltip.content}
@@ -77,6 +79,7 @@ const ToolStep = ({
showNumber,
_stepNumber,
_noPadding,
alwaysShowTooltip = false,
tooltip
}: ToolStepProps) => {
if (!isVisible) return null;
@@ -118,7 +121,7 @@ const ToolStep = ({
{stepNumber}
</Text>
)}
{renderTooltipTitle(title, tooltip, isCollapsed)}
{renderTooltipTitle(title, tooltip, isCollapsed, alwaysShowTooltip)}
</Flex>
{isCollapsed ? (
@@ -0,0 +1,53 @@
import React from 'react';
import { Flex, Text, Divider } from '@mantine/core';
import { Tooltip } from '../../shared/Tooltip';
export interface ToolWorkflowTitleProps {
title: string;
tooltip?: {
content?: React.ReactNode;
tips?: any[];
header?: {
title: string;
logo?: React.ReactNode;
};
};
}
export function ToolWorkflowTitle({ title, tooltip }: ToolWorkflowTitleProps) {
if (tooltip) {
return (
<>
<Flex justify="center" w="100%">
<Tooltip
content={tooltip.content}
tips={tooltip.tips}
header={tooltip.header}
sidebarTooltip={true}
>
<Flex align="center" gap="xs" onClick={(e) => e.stopPropagation()}>
<Text fw={500} size="xl" p="md">
{title}
</Text>
<span className="material-symbols-rounded" style={{ fontSize: '1.2rem', color: 'var(--icon-files-color)' }}>
gpp_maybe
</span>
</Flex>
</Tooltip>
</Flex>
<Divider />
</>
);
}
return (
<>
<Flex justify="center" w="100%">
<Text fw={500} size="xl" p="md">
{title}
</Text>
</Flex>
<Divider />
</>
);
}
@@ -3,12 +3,14 @@ import { Stack } from '@mantine/core';
import { createToolSteps, ToolStepProvider } from './ToolStep';
import OperationButton from './OperationButton';
import { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation';
import { ToolWorkflowTitle, ToolWorkflowTitleProps } from './ToolWorkflowTitle';
export interface FilesStepConfig {
selectedFiles: File[];
isCollapsed?: boolean;
placeholder?: string;
onCollapsedClick?: () => void;
isVisible?: boolean;
}
export interface MiddleStepConfig {
@@ -44,7 +46,10 @@ export interface ReviewStepConfig {
testId?: string;
}
export interface TitleConfig extends ToolWorkflowTitleProps {}
export interface ToolFlowConfig {
title?: TitleConfig;
files: FilesStepConfig;
steps: MiddleStepConfig[];
executeButton?: ExecuteButtonConfig;
@@ -62,8 +67,10 @@ export function createToolFlow(config: ToolFlowConfig) {
return (
<Stack gap="sm" p="sm" h="95vh" w="100%" style={{ overflow: 'auto' }}>
<ToolStepProvider forceStepNumbers={config.forceStepNumbers}>
{config.title && <ToolWorkflowTitle {...config.title} />}
{/* Files Step */}
{steps.createFilesStep({
{config.files.isVisible !== false && steps.createFilesStep({
selectedFiles: config.files.selectedFiles,
isCollapsed: config.files.isCollapsed,
placeholder: config.files.placeholder,
@@ -0,0 +1,34 @@
import React from 'react';
import { Box, Stack } from '@mantine/core';
import ToolButton from '../toolPicker/ToolButton';
import SubcategoryHeader from './SubcategoryHeader';
import { getSubcategoryLabel } from "../../../data/toolsTaxonomy";
import { TFunction } from 'i18next';
import { SubcategoryGroup } from '../../../hooks/useToolSections';
// Helper function to render tool buttons for a subcategory
export const renderToolButtons = (
t: TFunction,
subcategory: SubcategoryGroup,
selectedToolKey: string | null,
onSelect: (id: string) => void,
showSubcategoryHeader: boolean = true
) => (
<Box key={subcategory.subcategoryId} w="100%">
{showSubcategoryHeader && (
<SubcategoryHeader label={getSubcategoryLabel(t, subcategory.subcategoryId)} />
)}
<Stack gap="xs">
{subcategory.tools.map(({ id, tool }) => (
<ToolButton
key={id}
id={id}
tool={tool}
isSelected={selectedToolKey === id}
onSelect={onSelect}
/>
))}
</Stack>
</Box>
);
@@ -0,0 +1,27 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { SingleLargePageParameters } from '../../../hooks/tools/singleLargePage/useSingleLargePageParameters';
interface SingleLargePageSettingsProps {
parameters: SingleLargePageParameters;
onParameterChange: <K extends keyof SingleLargePageParameters>(parameter: K, value: SingleLargePageParameters[K]) => void;
disabled?: boolean;
}
const SingleLargePageSettings: React.FC<SingleLargePageSettingsProps> = ({
parameters,
onParameterChange,
disabled = false
}) => {
const { t } = useTranslation();
return (
<div className="single-large-page-settings">
<p className="text-muted">
{t('pdfToSinglePage.description', 'This tool will merge all pages of your PDF into one large single page. The width will remain the same as the original pages, but the height will be the sum of all page heights.')}
</p>
</div>
);
};
export default SingleLargePageSettings;
@@ -14,9 +14,9 @@ interface ToolButtonProps {
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect }) => {
const handleClick = (id: string) => {
if (tool.link) {
// Open external link in new tab
// Open external link in new tab
window.open(tool.link, '_blank', 'noopener,noreferrer');
return;
return;
}
// Normal tool selection
onSelect(id);
@@ -47,4 +47,4 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect
);
};
export default ToolButton;
export default ToolButton;
@@ -12,19 +12,26 @@ interface ToolSearchProps {
onToolSelect?: (toolId: string) => void;
mode: 'filter' | 'dropdown';
selectedToolKey?: string | null;
placeholder?: string;
hideIcon?: boolean;
onFocus?: () => void;
}
const ToolSearch = ({
value,
onChange,
toolRegistry,
onToolSelect,
const ToolSearch = ({
value,
onChange,
toolRegistry,
onToolSelect,
mode = 'filter',
selectedToolKey
selectedToolKey,
placeholder,
hideIcon = false,
onFocus
}: ToolSearchProps) => {
const { t } = useTranslation();
const [dropdownOpen, setDropdownOpen] = useState(false);
const searchRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const filteredTools = useMemo(() => {
if (!value.trim()) return [];
@@ -47,7 +54,12 @@ const ToolSearch = ({
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (searchRef.current && !searchRef.current.contains(event.target as Node)) {
if (
searchRef.current &&
dropdownRef.current &&
!searchRef.current.contains(event.target as Node) &&
!dropdownRef.current.contains(event.target as Node)
) {
setDropdownOpen(false);
}
};
@@ -61,9 +73,10 @@ const ToolSearch = ({
ref={searchRef}
value={value}
onChange={handleSearchChange}
placeholder={t("toolPicker.searchPlaceholder", "Search tools...")}
icon={<span className="material-symbols-rounded">search</span>}
placeholder={placeholder || t("toolPicker.searchPlaceholder", "Search tools...")}
icon={hideIcon ? undefined : <span className="material-symbols-rounded">search</span>}
autoComplete="off"
/>
</div>
);
@@ -77,19 +90,19 @@ const ToolSearch = ({
{searchInput}
{dropdownOpen && filteredTools.length > 0 && (
<div
ref={dropdownRef}
style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
zIndex: 1000,
backgroundColor: 'var(--bg-toolbar)',
border: '1px solid var(--border-default)',
borderRadius: '8px',
marginTop: '4px',
backgroundColor: 'var(--mantine-color-body)',
border: '1px solid var(--mantine-color-gray-3)',
borderRadius: '6px',
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
maxHeight: '300px',
overflowY: 'auto',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)'
overflowY: 'auto'
}}
>
<Stack gap="xs" style={{ padding: '8px' }}>
@@ -97,7 +110,10 @@ const ToolSearch = ({
<Button
key={id}
variant="subtle"
onClick={() => onToolSelect && onToolSelect(id)}
onClick={() => {
onToolSelect && onToolSelect(id);
setDropdownOpen(false);
}}
leftSection={
<div style={{ color: 'var(--tools-text-and-icon-color)' }}>
{tool.icon}
@@ -126,4 +142,4 @@ const ToolSearch = ({
);
};
export default ToolSearch;
export default ToolSearch;
@@ -0,0 +1,27 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { UnlockPdfFormsParameters } from '../../../hooks/tools/unlockPdfForms/useUnlockPdfFormsParameters';
interface UnlockPdfFormsSettingsProps {
parameters: UnlockPdfFormsParameters;
onParameterChange: <K extends keyof UnlockPdfFormsParameters>(parameter: K, value: UnlockPdfFormsParameters[K]) => void;
disabled?: boolean;
}
const UnlockPdfFormsSettings: React.FC<UnlockPdfFormsSettingsProps> = ({
parameters,
onParameterChange, // Unused - kept for interface consistency and future extensibility
disabled = false
}) => {
const { t } = useTranslation();
return (
<div className="unlock-pdf-forms-settings">
<p className="text-muted">
{t('unlockPDFForms.description', 'This tool will remove read-only restrictions from PDF form fields, making them editable and fillable.')}
</p>
</div>
);
};
export default UnlockPdfFormsSettings;
+25 -9
View File
@@ -1,7 +1,7 @@
import React, { useEffect, useState, useRef, useCallback } from "react";
import { Paper, Stack, Text, ScrollArea, Loader, Center, Button, Group, NumberInput, useMantineTheme, ActionIcon, Box, Tabs } from "@mantine/core";
import { getDocument, GlobalWorkerOptions } from "pdfjs-dist";
import { useTranslation } from "react-i18next";
import { pdfWorkerManager } from "../../services/pdfWorkerManager";
import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew";
import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos";
import FirstPageIcon from "@mui/icons-material/FirstPage";
@@ -13,10 +13,9 @@ import CloseIcon from "@mui/icons-material/Close";
import { useLocalStorage } from "@mantine/hooks";
import { fileStorage } from "../../services/fileStorage";
import SkeletonLoader from '../shared/SkeletonLoader';
import { useFileContext } from "../../contexts/FileContext";
import { useFileState, useFileActions, useCurrentFile } from "../../contexts/FileContext";
import { useFileWithUrl } from "../../hooks/useFileWithUrl";
GlobalWorkerOptions.workerSrc = "/pdf.worker.js";
// Lazy loading page image component
interface LazyPageImageProps {
@@ -150,7 +149,15 @@ const Viewer = ({
const theme = useMantineTheme();
// Get current file from FileContext
const { getCurrentFile, getCurrentProcessedFile, clearAllFiles, addFiles, activeFiles } = useFileContext();
const { selectors } = useFileState();
const { actions } = useFileActions();
const currentFile = useCurrentFile();
const getCurrentFile = () => currentFile.file;
const getCurrentProcessedFile = () => currentFile.record?.processedFile || undefined;
const clearAllFiles = actions.clearAllFiles;
const addFiles = actions.addFiles;
const activeFiles = selectors.getFiles();
// Tab management for multiple files
const [activeTab, setActiveTab] = useState<string>("0");
@@ -171,6 +178,10 @@ const Viewer = ({
const [zoom, setZoom] = useState(1); // 1 = 100%
const pageRefs = useRef<(HTMLImageElement | null)[]>([]);
// Memoize setPageRef to prevent infinite re-renders
const setPageRef = useCallback((index: number, ref: HTMLImageElement | null) => {
pageRefs.current[index] = ref;
}, []);
// Get files with URLs for tabs - we'll need to create these individually
const file0WithUrl = useFileWithUrl(activeFiles[0]);
@@ -385,7 +396,7 @@ const Viewer = ({
throw new Error('No valid PDF source available');
}
const pdf = await getDocument(pdfData).promise;
const pdf = await pdfWorkerManager.createDocument(pdfData);
pdfDocRef.current = pdf;
setNumPages(pdf.numPages);
if (!cancelled) {
@@ -406,6 +417,11 @@ const Viewer = ({
cancelled = true;
// Stop any ongoing preloading
preloadingRef.current = false;
// Cleanup PDF document using worker manager
if (pdfDocRef.current) {
pdfWorkerManager.destroyDocument(pdfDocRef.current);
pdfDocRef.current = null;
}
// Cleanup ArrayBuffer reference to help garbage collection
currentArrayBufferRef.current = null;
};
@@ -461,7 +477,7 @@ const Viewer = ({
>
<Tabs value={activeTab} onChange={(value) => handleTabChange(value || "0")}>
<Tabs.List>
{activeFiles.map((file, index) => (
{activeFiles.map((file: any, index: number) => (
<Tabs.Tab key={index} value={index.toString()}>
{file.name.length > 20 ? `${file.name.substring(0, 20)}...` : file.name}
</Tabs.Tab>
@@ -494,7 +510,7 @@ const Viewer = ({
isFirst={i === 0}
renderPage={renderPage}
pageImages={pageImages}
setPageRef={(index, ref) => { pageRefs.current[index] = ref; }}
setPageRef={setPageRef}
/>
{i * 2 + 1 < numPages && (
<LazyPageImage
@@ -504,7 +520,7 @@ const Viewer = ({
isFirst={i === 0}
renderPage={renderPage}
pageImages={pageImages}
setPageRef={(index, ref) => { pageRefs.current[index] = ref; }}
setPageRef={setPageRef}
/>
)}
</Group>
@@ -518,7 +534,7 @@ const Viewer = ({
isFirst={idx === 0}
renderPage={renderPage}
pageImages={pageImages}
setPageRef={(index, ref) => { pageRefs.current[index] = ref; }}
setPageRef={setPageRef}
/>
))}
</Stack>
@@ -1,5 +1,3 @@
import { AddWatermarkParameters } from "../hooks/tools/addWatermark/useAddWatermarkParameters";
export interface AlphabetOption {
value: string;
label: string;
@@ -13,16 +11,3 @@ export const alphabetOptions: AlphabetOption[] = [
{ value: "chinese", label: "简体中文" },
{ value: "thai", label: "ไทย" },
];
export const defaultWatermarkParameters: AddWatermarkParameters = {
watermarkType: undefined,
watermarkText: '',
fontSize: 12,
rotation: 0,
opacity: 50,
widthSpacer: 50,
heightSpacer: 50,
alphabet: 'roman',
customColor: '#d3d3d3',
convertPDFToImage: false
};
+42
View File
@@ -0,0 +1,42 @@
/**
* Constants for automation functionality
*/
export const AUTOMATION_CONSTANTS = {
// Timeouts
OPERATION_TIMEOUT: 300000, // 5 minutes in milliseconds
// Default values
DEFAULT_TOOL_COUNT: 2,
MIN_TOOL_COUNT: 2,
// File prefixes
FILE_PREFIX: 'automated_',
RESPONSE_ZIP_PREFIX: 'response_',
RESULT_FILE_PREFIX: 'result_',
PROCESSED_FILE_PREFIX: 'processed_',
// Operation types
CONVERT_OPERATION_TYPE: 'convert',
// Storage keys
DB_NAME: 'StirlingPDF_Automations',
DB_VERSION: 1,
STORE_NAME: 'automations',
// UI delays
SPINNER_ANIMATION_DURATION: '1s'
} as const;
export const AUTOMATION_STEPS = {
SELECTION: 'selection',
CREATION: 'creation',
RUN: 'run'
} as const;
export const EXECUTION_STATUS = {
PENDING: 'pending',
RUNNING: 'running',
COMPLETED: 'completed',
ERROR: 'error'
} as const;
File diff suppressed because it is too large Load Diff
+166 -48
View File
@@ -1,6 +1,7 @@
import React, { createContext, useContext, useState, useRef, useCallback, useEffect } from 'react';
import { FileWithUrl } from '../types/file';
import { StoredFile } from '../services/fileStorage';
import React, { createContext, useContext, useState, useRef, useCallback, useEffect, useMemo } from 'react';
import { FileMetadata } from '../types/file';
import { StoredFile, fileStorage } from '../services/fileStorage';
import { downloadFiles } from '../utils/downloadUtils';
// Type for the context value - now contains everything directly
interface FileManagerContextValue {
@@ -8,22 +9,27 @@ interface FileManagerContextValue {
activeSource: 'recent' | 'local' | 'drive';
selectedFileIds: string[];
searchTerm: string;
selectedFiles: FileWithUrl[];
filteredFiles: FileWithUrl[];
selectedFiles: FileMetadata[];
filteredFiles: FileMetadata[];
fileInputRef: React.RefObject<HTMLInputElement | null>;
selectedFilesSet: Set<string>;
// Handlers
onSourceChange: (source: 'recent' | 'local' | 'drive') => void;
onLocalFileClick: () => void;
onFileSelect: (file: FileWithUrl) => void;
onFileSelect: (file: FileMetadata, index: number, shiftKey?: boolean) => void;
onFileRemove: (index: number) => void;
onFileDoubleClick: (file: FileWithUrl) => void;
onFileDoubleClick: (file: FileMetadata) => void;
onOpenFiles: () => void;
onSearchChange: (value: string) => void;
onFileInputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
onSelectAll: () => void;
onDeleteSelected: () => void;
onDownloadSelected: () => void;
onDownloadSingle: (file: FileMetadata) => void;
// External props
recentFiles: FileWithUrl[];
recentFiles: FileMetadata[];
isFileSupported: (fileName: string) => boolean;
modalHeight: string;
}
@@ -34,14 +40,14 @@ const FileManagerContext = createContext<FileManagerContextValue | null>(null);
// Provider component props
interface FileManagerProviderProps {
children: React.ReactNode;
recentFiles: FileWithUrl[];
onFilesSelected: (files: FileWithUrl[]) => void;
recentFiles: FileMetadata[];
onFilesSelected: (files: FileMetadata[]) => void; // For selecting stored files
onNewFilesSelect: (files: File[]) => void; // For uploading new local files
onClose: () => void;
isFileSupported: (fileName: string) => boolean;
isOpen: boolean;
onFileRemove: (index: number) => void;
modalHeight: string;
storeFile: (file: File) => Promise<StoredFile>;
refreshRecentFiles: () => Promise<void>;
}
@@ -49,33 +55,40 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
children,
recentFiles,
onFilesSelected,
onNewFilesSelect,
onClose,
isFileSupported,
isOpen,
onFileRemove,
modalHeight,
storeFile,
refreshRecentFiles,
}) => {
const [activeSource, setActiveSource] = useState<'recent' | 'local' | 'drive'>('recent');
const [selectedFileIds, setSelectedFileIds] = useState<string[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const [lastClickedIndex, setLastClickedIndex] = useState<number | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Track blob URLs for cleanup
const createdBlobUrls = useRef<Set<string>>(new Set());
// Computed values (with null safety)
const selectedFiles = (recentFiles || []).filter(file => selectedFileIds.includes(file.id || file.name));
const filteredFiles = (recentFiles || []).filter(file =>
file.name.toLowerCase().includes(searchTerm.toLowerCase())
);
const selectedFilesSet = new Set(selectedFileIds);
const selectedFiles = selectedFileIds.length === 0 ? [] :
(recentFiles || []).filter(file => selectedFilesSet.has(file.id));
const filteredFiles = !searchTerm ? recentFiles || [] :
(recentFiles || []).filter(file =>
file.name.toLowerCase().includes(searchTerm.toLowerCase())
);
const handleSourceChange = useCallback((source: 'recent' | 'local' | 'drive') => {
setActiveSource(source);
if (source !== 'recent') {
setSelectedFileIds([]);
setSearchTerm('');
setLastClickedIndex(null);
}
}, []);
@@ -83,19 +96,46 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
fileInputRef.current?.click();
}, []);
const handleFileSelect = useCallback((file: FileWithUrl) => {
setSelectedFileIds(prev => {
if (file.id) {
if (prev.includes(file.id)) {
return prev.filter(id => id !== file.id);
} else {
return [...prev, file.id];
const handleFileSelect = useCallback((file: FileMetadata, currentIndex: number, shiftKey?: boolean) => {
const fileId = file.id;
if (!fileId) return;
if (shiftKey && lastClickedIndex !== null) {
// Range selection with shift-click
const startIndex = Math.min(lastClickedIndex, currentIndex);
const endIndex = Math.max(lastClickedIndex, currentIndex);
setSelectedFileIds(prev => {
const selectedSet = new Set(prev);
// Add all files in the range to selection
for (let i = startIndex; i <= endIndex; i++) {
const rangeFileId = filteredFiles[i]?.id;
if (rangeFileId) {
selectedSet.add(rangeFileId);
}
}
} else {
return prev;
}
});
}, []);
return Array.from(selectedSet);
});
} else {
// Normal click behavior - optimized with Set for O(1) lookup
setSelectedFileIds(prev => {
const selectedSet = new Set(prev);
if (selectedSet.has(fileId)) {
selectedSet.delete(fileId);
} else {
selectedSet.add(fileId);
}
return Array.from(selectedSet);
});
// Update last clicked index for future range selections
setLastClickedIndex(currentIndex);
}
}, [filteredFiles, lastClickedIndex]);
const handleFileRemove = useCallback((index: number) => {
const fileToRemove = filteredFiles[index];
@@ -105,7 +145,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
onFileRemove(index);
}, [filteredFiles, onFileRemove]);
const handleFileDoubleClick = useCallback((file: FileWithUrl) => {
const handleFileDoubleClick = useCallback((file: FileMetadata) => {
if (isFileSupported(file.name)) {
onFilesSelected([file]);
onClose();
@@ -127,22 +167,8 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
const files = Array.from(event.target.files || []);
if (files.length > 0) {
try {
// Create FileWithUrl objects - FileContext will handle storage and ID assignment
const fileWithUrls = files.map(file => {
const url = URL.createObjectURL(file);
createdBlobUrls.current.add(url);
return {
// No ID assigned here - FileContext will handle storage and ID assignment
name: file.name,
file,
url,
size: file.size,
lastModified: file.lastModified,
};
});
onFilesSelected(fileWithUrls as any /* FIX ME */);
// For local file uploads, pass File objects directly to FileContext
onNewFilesSelect(files);
await refreshRecentFiles();
onClose();
} catch (error) {
@@ -150,7 +176,72 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
}
}
event.target.value = '';
}, [storeFile, onFilesSelected, refreshRecentFiles, onClose]);
}, [onNewFilesSelect, refreshRecentFiles, onClose]);
const handleSelectAll = useCallback(() => {
const allFilesSelected = filteredFiles.length > 0 && selectedFileIds.length === filteredFiles.length;
if (allFilesSelected) {
// Deselect all
setSelectedFileIds([]);
setLastClickedIndex(null);
} else {
// Select all filtered files
setSelectedFileIds(filteredFiles.map(file => file.id).filter(Boolean));
setLastClickedIndex(null);
}
}, [filteredFiles, selectedFileIds]);
const handleDeleteSelected = useCallback(async () => {
if (selectedFileIds.length === 0) return;
try {
// Get files to delete based on current filtered view
const filesToDelete = filteredFiles.filter(file =>
selectedFileIds.includes(file.id)
);
// Delete files from storage
for (const file of filesToDelete) {
await fileStorage.deleteFile(file.id);
}
// Clear selection
setSelectedFileIds([]);
// Refresh the file list
await refreshRecentFiles();
} catch (error) {
console.error('Failed to delete selected files:', error);
}
}, [selectedFileIds, filteredFiles, refreshRecentFiles]);
const handleDownloadSelected = useCallback(async () => {
if (selectedFileIds.length === 0) return;
try {
// Get selected files
const selectedFilesToDownload = filteredFiles.filter(file =>
selectedFileIds.includes(file.id)
);
// Use generic download utility
await downloadFiles(selectedFilesToDownload, {
zipFilename: `selected-files-${new Date().toISOString().slice(0, 19).replace(/[:-]/g, '')}.zip`
});
} catch (error) {
console.error('Failed to download selected files:', error);
}
}, [selectedFileIds, filteredFiles]);
const handleDownloadSingle = useCallback(async (file: FileMetadata) => {
try {
await downloadFiles([file]);
} catch (error) {
console.error('Failed to download file:', error);
}
}, []);
// Cleanup blob URLs when component unmounts
useEffect(() => {
@@ -169,10 +260,11 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
setActiveSource('recent');
setSelectedFileIds([]);
setSearchTerm('');
setLastClickedIndex(null);
}
}, [isOpen]);
const contextValue: FileManagerContextValue = {
const contextValue: FileManagerContextValue = useMemo(() => ({
// State
activeSource,
selectedFileIds,
@@ -180,6 +272,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
selectedFiles,
filteredFiles,
fileInputRef,
selectedFilesSet,
// Handlers
onSourceChange: handleSourceChange,
@@ -190,12 +283,37 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
onOpenFiles: handleOpenFiles,
onSearchChange: handleSearchChange,
onFileInputChange: handleFileInputChange,
onSelectAll: handleSelectAll,
onDeleteSelected: handleDeleteSelected,
onDownloadSelected: handleDownloadSelected,
onDownloadSingle: handleDownloadSingle,
// External props
recentFiles,
isFileSupported,
modalHeight,
};
}), [
activeSource,
selectedFileIds,
searchTerm,
selectedFiles,
filteredFiles,
fileInputRef,
handleSourceChange,
handleLocalFileClick,
handleFileSelect,
handleFileRemove,
handleFileDoubleClick,
handleOpenFiles,
handleSearchChange,
handleFileInputChange,
handleSelectAll,
handleDeleteSelected,
handleDownloadSelected,
recentFiles,
isFileSupported,
modalHeight,
]);
return (
<FileManagerContext.Provider value={contextValue}>
@@ -1,100 +0,0 @@
import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react';
import {
MaxFiles,
FileSelectionContextValue
} from '../types/tool';
import { useFileContext } from './FileContext';
interface FileSelectionProviderProps {
children: ReactNode;
}
const FileSelectionContext = createContext<FileSelectionContextValue | undefined>(undefined);
export function FileSelectionProvider({ children }: FileSelectionProviderProps) {
const { activeFiles } = useFileContext();
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [maxFiles, setMaxFiles] = useState<MaxFiles>(-1);
const [isToolMode, setIsToolMode] = useState<boolean>(false);
// Sync selected files with active files - remove any selected files that are no longer active
useEffect(() => {
if (selectedFiles.length > 0) {
const activeFileSet = new Set(activeFiles);
const validSelectedFiles = selectedFiles.filter(file => activeFileSet.has(file));
if (validSelectedFiles.length !== selectedFiles.length) {
setSelectedFiles(validSelectedFiles);
}
}
}, [activeFiles, selectedFiles]);
const clearSelection = useCallback(() => {
setSelectedFiles([]);
}, []);
const selectionCount = selectedFiles.length;
const canSelectMore = maxFiles === -1 || selectionCount < maxFiles;
const isAtLimit = maxFiles > 0 && selectionCount >= maxFiles;
const isMultiFileMode = maxFiles !== 1;
const contextValue: FileSelectionContextValue = {
selectedFiles,
maxFiles,
isToolMode,
setSelectedFiles,
setMaxFiles,
setIsToolMode,
clearSelection,
canSelectMore,
isAtLimit,
selectionCount,
isMultiFileMode
};
return (
<FileSelectionContext.Provider value={contextValue}>
{children}
</FileSelectionContext.Provider>
);
}
/**
* Access the file selection context.
* Throws if used outside a <FileSelectionProvider>.
*/
export function useFileSelection(): FileSelectionContextValue {
const context = useContext(FileSelectionContext);
if (!context) {
throw new Error('useFileSelection must be used within a FileSelectionProvider');
}
return context;
}
// Returns only the file selection values relevant for tools (e.g. merge, split, etc.)
// Use this in tool panels/components that need to know which files are selected and selection limits.
export function useToolFileSelection(): Pick<FileSelectionContextValue, 'selectedFiles' | 'maxFiles' | 'canSelectMore' | 'isAtLimit' | 'selectionCount'> {
const { selectedFiles, maxFiles, canSelectMore, isAtLimit, selectionCount } = useFileSelection();
return { selectedFiles, maxFiles, canSelectMore, isAtLimit, selectionCount };
}
// Returns actions for manipulating file selection state.
// Use this in components that need to update the selection, clear it, or change selection mode.
export function useFileSelectionActions(): Pick<FileSelectionContextValue, 'setSelectedFiles' | 'clearSelection' | 'setMaxFiles' | 'setIsToolMode'> {
const { setSelectedFiles, clearSelection, setMaxFiles, setIsToolMode } = useFileSelection();
return { setSelectedFiles, clearSelection, setMaxFiles, setIsToolMode };
}
// Returns the raw file selection state (selected files, max files, tool mode).
// Use this for low-level state access, e.g. in context-aware UI.
export function useFileSelectionState(): Pick<FileSelectionContextValue, 'selectedFiles' | 'maxFiles' | 'isToolMode'> {
const { selectedFiles, maxFiles, isToolMode } = useFileSelection();
return { selectedFiles, maxFiles, isToolMode };
}
// Returns computed values derived from file selection state.
// Use this for file selection UI logic (e.g. disabling buttons when at limit).
export function useFileSelectionComputed(): Pick<FileSelectionContextValue, 'canSelectMore' | 'isAtLimit' | 'selectionCount' | 'isMultiFileMode'> {
const { canSelectMore, isAtLimit, selectionCount, isMultiFileMode } = useFileSelection();
return { canSelectMore, isAtLimit, selectionCount, isMultiFileMode };
}
+21 -4
View File
@@ -1,5 +1,6 @@
import React, { createContext, useContext, useState, useCallback } from 'react';
import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
import { useFileHandler } from '../hooks/useFileHandler';
import { FileMetadata } from '../types/file';
interface FilesModalContextType {
isFilesModalOpen: boolean;
@@ -7,6 +8,7 @@ interface FilesModalContextType {
closeFilesModal: () => void;
onFileSelect: (file: File) => void;
onFilesSelect: (files: File[]) => void;
onStoredFilesSelect: (filesWithMetadata: Array<{ file: File; originalId: string; metadata: FileMetadata }>) => void;
onModalClose?: () => void;
setOnModalClose: (callback: () => void) => void;
}
@@ -14,7 +16,7 @@ interface FilesModalContextType {
const FilesModalContext = createContext<FilesModalContextType | null>(null);
export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { addToActiveFiles, addMultipleFiles } = useFileHandler();
const { addToActiveFiles, addMultipleFiles, addStoredFiles } = useFileHandler();
const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
const [onModalClose, setOnModalClose] = useState<(() => void) | undefined>();
@@ -37,19 +39,34 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch
closeFilesModal();
}, [addMultipleFiles, closeFilesModal]);
const handleStoredFilesSelect = useCallback((filesWithMetadata: Array<{ file: File; originalId: string; metadata: FileMetadata }>) => {
addStoredFiles(filesWithMetadata);
closeFilesModal();
}, [addStoredFiles, closeFilesModal]);
const setModalCloseCallback = useCallback((callback: () => void) => {
setOnModalClose(() => callback);
}, []);
const contextValue: FilesModalContextType = {
const contextValue: FilesModalContextType = useMemo(() => ({
isFilesModalOpen,
openFilesModal,
closeFilesModal,
onFileSelect: handleFileSelect,
onFilesSelect: handleFilesSelect,
onStoredFilesSelect: handleStoredFilesSelect,
onModalClose,
setOnModalClose: setModalCloseCallback,
};
}), [
isFilesModalOpen,
openFilesModal,
closeFilesModal,
handleFileSelect,
handleFilesSelect,
handleStoredFilesSelect,
onModalClose,
setModalCloseCallback,
]);
return (
<FilesModalContext.Provider value={contextValue}>
+207
View File
@@ -0,0 +1,207 @@
/**
* IndexedDBContext - Clean persistence layer for file storage
* Integrates with FileContext to provide transparent file persistence
*/
import React, { createContext, useContext, useCallback, useRef } from 'react';
const DEBUG = process.env.NODE_ENV === 'development';
import { fileStorage, StoredFile } from '../services/fileStorage';
import { FileId } from '../types/fileContext';
import { FileMetadata } from '../types/file';
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
interface IndexedDBContextValue {
// Core CRUD operations
saveFile: (file: File, fileId: FileId, existingThumbnail?: string) => Promise<FileMetadata>;
loadFile: (fileId: FileId) => Promise<File | null>;
loadMetadata: (fileId: FileId) => Promise<FileMetadata | null>;
deleteFile: (fileId: FileId) => Promise<void>;
// Batch operations
loadAllMetadata: () => Promise<FileMetadata[]>;
deleteMultiple: (fileIds: FileId[]) => Promise<void>;
clearAll: () => Promise<void>;
// Utilities
getStorageStats: () => Promise<{ used: number; available: number; fileCount: number }>;
updateThumbnail: (fileId: FileId, thumbnail: string) => Promise<boolean>;
}
const IndexedDBContext = createContext<IndexedDBContextValue | null>(null);
interface IndexedDBProviderProps {
children: React.ReactNode;
}
export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
// LRU File cache to avoid repeated ArrayBuffer→File conversions
const fileCache = useRef(new Map<FileId, { file: File; lastAccessed: number }>());
const MAX_CACHE_SIZE = 50; // Maximum number of files to cache
// LRU cache management
const evictLRUEntries = useCallback(() => {
if (fileCache.current.size <= MAX_CACHE_SIZE) return;
// Convert to array and sort by last accessed time (oldest first)
const entries = Array.from(fileCache.current.entries())
.sort(([, a], [, b]) => a.lastAccessed - b.lastAccessed);
// Remove the least recently used entries
const toRemove = entries.slice(0, fileCache.current.size - MAX_CACHE_SIZE);
toRemove.forEach(([fileId]) => {
fileCache.current.delete(fileId);
});
if (DEBUG) console.log(`🗂️ Evicted ${toRemove.length} LRU cache entries`);
}, []);
const saveFile = useCallback(async (file: File, fileId: FileId, existingThumbnail?: string): Promise<FileMetadata> => {
// Use existing thumbnail or generate new one if none provided
const thumbnail = existingThumbnail || await generateThumbnailForFile(file);
// Store in IndexedDB
const storedFile = await fileStorage.storeFile(file, fileId, thumbnail);
// Cache the file object for immediate reuse
fileCache.current.set(fileId, { file, lastAccessed: Date.now() });
evictLRUEntries();
// Return metadata
return {
id: fileId,
name: file.name,
type: file.type,
size: file.size,
lastModified: file.lastModified,
thumbnail
};
}, []);
const loadFile = useCallback(async (fileId: FileId): Promise<File | null> => {
// Check cache first
const cached = fileCache.current.get(fileId);
if (cached) {
// Update last accessed time for LRU
cached.lastAccessed = Date.now();
return cached.file;
}
// Load from IndexedDB
const storedFile = await fileStorage.getFile(fileId);
if (!storedFile) return null;
// Reconstruct File object
const file = new File([storedFile.data], storedFile.name, {
type: storedFile.type,
lastModified: storedFile.lastModified
});
// Cache for future use with LRU eviction
fileCache.current.set(fileId, { file, lastAccessed: Date.now() });
evictLRUEntries();
return file;
}, [evictLRUEntries]);
const loadMetadata = useCallback(async (fileId: FileId): Promise<FileMetadata | null> => {
// Try to get from cache first (no IndexedDB hit)
const cached = fileCache.current.get(fileId);
if (cached) {
const file = cached.file;
return {
id: fileId,
name: file.name,
type: file.type,
size: file.size,
lastModified: file.lastModified
};
}
// Load metadata from IndexedDB (efficient - no data field)
const metadata = await fileStorage.getAllFileMetadata();
const fileMetadata = metadata.find(m => m.id === fileId);
if (!fileMetadata) return null;
return {
id: fileMetadata.id,
name: fileMetadata.name,
type: fileMetadata.type,
size: fileMetadata.size,
lastModified: fileMetadata.lastModified,
thumbnail: fileMetadata.thumbnail
};
}, []);
const deleteFile = useCallback(async (fileId: FileId): Promise<void> => {
// Remove from cache
fileCache.current.delete(fileId);
// Remove from IndexedDB
await fileStorage.deleteFile(fileId);
}, []);
const loadAllMetadata = useCallback(async (): Promise<FileMetadata[]> => {
const metadata = await fileStorage.getAllFileMetadata();
return metadata.map(m => ({
id: m.id,
name: m.name,
type: m.type,
size: m.size,
lastModified: m.lastModified,
thumbnail: m.thumbnail
}));
}, []);
const deleteMultiple = useCallback(async (fileIds: FileId[]): Promise<void> => {
// Remove from cache
fileIds.forEach(id => fileCache.current.delete(id));
// Remove from IndexedDB in parallel
await Promise.all(fileIds.map(id => fileStorage.deleteFile(id)));
}, []);
const clearAll = useCallback(async (): Promise<void> => {
// Clear cache
fileCache.current.clear();
// Clear IndexedDB
await fileStorage.clearAll();
}, []);
const getStorageStats = useCallback(async () => {
return await fileStorage.getStorageStats();
}, []);
const updateThumbnail = useCallback(async (fileId: FileId, thumbnail: string): Promise<boolean> => {
return await fileStorage.updateThumbnail(fileId, thumbnail);
}, []);
const value: IndexedDBContextValue = {
saveFile,
loadFile,
loadMetadata,
deleteFile,
loadAllMetadata,
deleteMultiple,
clearAll,
getStorageStats,
updateThumbnail
};
return (
<IndexedDBContext.Provider value={value}>
{children}
</IndexedDBContext.Provider>
);
}
export function useIndexedDB() {
const context = useContext(IndexedDBContext);
if (!context) {
throw new Error('useIndexedDB must be used within an IndexedDBProvider');
}
return context;
}
+240
View File
@@ -0,0 +1,240 @@
import React, { createContext, useContext, useReducer, useCallback } from 'react';
import { useNavigationUrlSync } from '../hooks/useUrlSync';
import { ModeType, isValidMode, getDefaultMode } from '../types/navigation';
/**
* NavigationContext - Complete navigation management system
*
* Handles navigation modes, navigation guards for unsaved changes,
* and breadcrumb/history navigation. Separated from FileContext to
* maintain clear separation of concerns.
*/
// Navigation state
interface NavigationState {
currentMode: ModeType;
hasUnsavedChanges: boolean;
pendingNavigation: (() => void) | null;
showNavigationWarning: boolean;
selectedToolKey: string | null; // Add tool selection to navigation state
}
// Navigation actions
type NavigationAction =
| { type: 'SET_MODE'; payload: { mode: ModeType } }
| { type: 'SET_UNSAVED_CHANGES'; payload: { hasChanges: boolean } }
| { type: 'SET_PENDING_NAVIGATION'; payload: { navigationFn: (() => void) | null } }
| { type: 'SHOW_NAVIGATION_WARNING'; payload: { show: boolean } }
| { type: 'SET_SELECTED_TOOL'; payload: { toolKey: string | null } };
// Navigation reducer
const navigationReducer = (state: NavigationState, action: NavigationAction): NavigationState => {
switch (action.type) {
case 'SET_MODE':
return { ...state, currentMode: action.payload.mode };
case 'SET_UNSAVED_CHANGES':
return { ...state, hasUnsavedChanges: action.payload.hasChanges };
case 'SET_PENDING_NAVIGATION':
return { ...state, pendingNavigation: action.payload.navigationFn };
case 'SHOW_NAVIGATION_WARNING':
return { ...state, showNavigationWarning: action.payload.show };
case 'SET_SELECTED_TOOL':
return { ...state, selectedToolKey: action.payload.toolKey };
default:
return state;
}
};
// Initial state
const initialState: NavigationState = {
currentMode: getDefaultMode(),
hasUnsavedChanges: false,
pendingNavigation: null,
showNavigationWarning: false,
selectedToolKey: null
};
// Navigation context actions interface
export interface NavigationContextActions {
setMode: (mode: ModeType) => void;
setHasUnsavedChanges: (hasChanges: boolean) => void;
showNavigationWarning: (show: boolean) => void;
requestNavigation: (navigationFn: () => void) => void;
confirmNavigation: () => void;
cancelNavigation: () => void;
selectTool: (toolKey: string) => void;
clearToolSelection: () => void;
handleToolSelect: (toolId: string) => void;
}
// Split context values
export interface NavigationContextStateValue {
currentMode: ModeType;
hasUnsavedChanges: boolean;
pendingNavigation: (() => void) | null;
showNavigationWarning: boolean;
selectedToolKey: string | null;
}
export interface NavigationContextActionsValue {
actions: NavigationContextActions;
}
// Create contexts
const NavigationStateContext = createContext<NavigationContextStateValue | undefined>(undefined);
const NavigationActionsContext = createContext<NavigationContextActionsValue | undefined>(undefined);
// Provider component
export const NavigationProvider: React.FC<{
children: React.ReactNode;
enableUrlSync?: boolean;
}> = ({ children, enableUrlSync = true }) => {
const [state, dispatch] = useReducer(navigationReducer, initialState);
const actions: NavigationContextActions = {
setMode: useCallback((mode: ModeType) => {
dispatch({ type: 'SET_MODE', payload: { mode } });
}, []),
setHasUnsavedChanges: useCallback((hasChanges: boolean) => {
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } });
}, []),
showNavigationWarning: useCallback((show: boolean) => {
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show } });
}, []),
requestNavigation: useCallback((navigationFn: () => void) => {
// If no unsaved changes, navigate immediately
if (!state.hasUnsavedChanges) {
navigationFn();
return;
}
// Otherwise, store the navigation and show warning
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn } });
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: true } });
}, [state.hasUnsavedChanges]),
confirmNavigation: useCallback(() => {
// Execute pending navigation
if (state.pendingNavigation) {
state.pendingNavigation();
}
// Clear navigation state
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } });
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } });
}, [state.pendingNavigation]),
cancelNavigation: useCallback(() => {
// Clear navigation without executing
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } });
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } });
}, []),
selectTool: useCallback((toolKey: string) => {
dispatch({ type: 'SET_SELECTED_TOOL', payload: { toolKey } });
}, []),
clearToolSelection: useCallback(() => {
dispatch({ type: 'SET_SELECTED_TOOL', payload: { toolKey: null } });
}, []),
handleToolSelect: useCallback((toolId: string) => {
// Handle special cases
if (toolId === 'allTools') {
dispatch({ type: 'SET_SELECTED_TOOL', payload: { toolKey: null } });
return;
}
// Special-case: if tool is a dedicated reader tool, enter reader mode
if (toolId === 'read' || toolId === 'view-pdf') {
dispatch({ type: 'SET_SELECTED_TOOL', payload: { toolKey: null } });
return;
}
dispatch({ type: 'SET_SELECTED_TOOL', payload: { toolKey: toolId } });
dispatch({ type: 'SET_MODE', payload: { mode: 'fileEditor' as ModeType } });
}, [])
};
const stateValue: NavigationContextStateValue = {
currentMode: state.currentMode,
hasUnsavedChanges: state.hasUnsavedChanges,
pendingNavigation: state.pendingNavigation,
showNavigationWarning: state.showNavigationWarning,
selectedToolKey: state.selectedToolKey
};
const actionsValue: NavigationContextActionsValue = {
actions
};
// Enable URL synchronization
useNavigationUrlSync(state.currentMode, actions.setMode, enableUrlSync);
return (
<NavigationStateContext.Provider value={stateValue}>
<NavigationActionsContext.Provider value={actionsValue}>
{children}
</NavigationActionsContext.Provider>
</NavigationStateContext.Provider>
);
};
// Navigation hooks
export const useNavigationState = () => {
const context = useContext(NavigationStateContext);
if (context === undefined) {
throw new Error('useNavigationState must be used within NavigationProvider');
}
return context;
};
export const useNavigationActions = () => {
const context = useContext(NavigationActionsContext);
if (context === undefined) {
throw new Error('useNavigationActions must be used within NavigationProvider');
}
return context;
};
// Combined hook for convenience
export const useNavigation = () => {
const state = useNavigationState();
const { actions } = useNavigationActions();
return { ...state, ...actions };
};
// Navigation guard hook (equivalent to old useFileNavigation)
export const useNavigationGuard = () => {
const state = useNavigationState();
const { actions } = useNavigationActions();
return {
pendingNavigation: state.pendingNavigation,
showNavigationWarning: state.showNavigationWarning,
hasUnsavedChanges: state.hasUnsavedChanges,
requestNavigation: actions.requestNavigation,
confirmNavigation: actions.confirmNavigation,
cancelNavigation: actions.cancelNavigation,
setHasUnsavedChanges: actions.setHasUnsavedChanges,
setShowNavigationWarning: actions.showNavigationWarning
};
};
// Re-export utility functions from types for backward compatibility
export { isValidMode, getDefaultMode, type ModeType } from '../types/navigation';
// TODO: This will be expanded for URL-based routing system
// - URL parsing utilities
// - Route definitions
// - Navigation hooks with URL sync
// - History management
// - Breadcrumb restoration from URL params
+7 -7
View File
@@ -1,4 +1,4 @@
import React, { createContext, useContext, useState, useRef } from 'react';
import React, { createContext, useContext, useState, useRef, useMemo } from 'react';
import { SidebarState, SidebarRefs, SidebarContextValue, SidebarProviderProps } from '../types/sidebar';
const SidebarContext = createContext<SidebarContextValue | undefined>(undefined);
@@ -12,24 +12,24 @@ export function SidebarProvider({ children }: SidebarProviderProps) {
const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker');
const [readerMode, setReaderMode] = useState(false);
const sidebarState: SidebarState = {
const sidebarState: SidebarState = useMemo(() => ({
sidebarsVisible,
leftPanelView,
readerMode,
};
}), [sidebarsVisible, leftPanelView, readerMode]);
const sidebarRefs: SidebarRefs = {
const sidebarRefs: SidebarRefs = useMemo(() => ({
quickAccessRef,
toolPanelRef,
};
}), [quickAccessRef, toolPanelRef]);
const contextValue: SidebarContextValue = {
const contextValue: SidebarContextValue = useMemo(() => ({
sidebarState,
sidebarRefs,
setSidebarsVisible,
setLeftPanelView,
setReaderMode,
};
}), [sidebarState, sidebarRefs, setSidebarsVisible, setLeftPanelView, setReaderMode]);
return (
<SidebarContext.Provider value={contextValue}>
+90 -31
View File
@@ -7,6 +7,8 @@ import React, { createContext, useContext, useReducer, useCallback, useMemo } fr
import { useToolManagement } from '../hooks/useToolManagement';
import { PageEditorFunctions } from '../types/pageEditor';
import { ToolRegistryEntry } from '../data/toolsTaxonomy';
import { useToolWorkflowUrlSync } from '../hooks/useUrlSync';
import { useNavigationActions, useNavigationState } from './NavigationContext';
// State interface
interface ToolWorkflowState {
@@ -71,7 +73,7 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
selectedToolKey: string | null;
selectedTool: ToolRegistryEntry | null;
toolRegistry: any; // From useToolManagement
// UI Actions
setSidebarsVisible: (visible: boolean) => void;
setLeftPanelView: (view: 'toolPicker' | 'toolContent') => void;
@@ -99,21 +101,23 @@ const ToolWorkflowContext = createContext<ToolWorkflowContextValue | undefined>(
// Provider component
interface ToolWorkflowProviderProps {
children: React.ReactNode;
/** Handler for view changes (passed from parent) */
onViewChange?: (view: string) => void;
}
export function ToolWorkflowProvider({ children, onViewChange }: ToolWorkflowProviderProps) {
export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
const [state, dispatch] = useReducer(toolWorkflowReducer, initialState);
// Navigation actions and state are available since we're inside NavigationProvider
const { actions } = useNavigationActions();
const navigationState = useNavigationState();
// Tool management hook
const {
selectedToolKey,
selectedTool,
toolRegistry,
selectTool,
clearToolSelection,
getSelectedTool,
} = useToolManagement();
// Get selected tool from navigation context
const selectedTool = getSelectedTool(navigationState.selectedToolKey);
// UI Action creators
const setSidebarsVisible = useCallback((visible: boolean) => {
@@ -142,28 +146,30 @@ export function ToolWorkflowProvider({ children, onViewChange }: ToolWorkflowPro
// Workflow actions (compound actions that coordinate multiple state changes)
const handleToolSelect = useCallback((toolId: string) => {
// Special-case: if tool is a dedicated reader tool, enter reader mode and do not go to toolContent
if (toolId === 'read' || toolId === 'view-pdf') {
setReaderMode(true);
setLeftPanelView('toolPicker');
clearToolSelection();
setSearchQuery('');
return;
}
selectTool(toolId);
onViewChange?.('fileEditor');
setLeftPanelView('toolContent');
setReaderMode(false);
// Clear search so the tool content becomes visible immediately
actions.handleToolSelect(toolId);
// Clear search query when selecting a tool
setSearchQuery('');
}, [selectTool, onViewChange, setLeftPanelView, setReaderMode, setSearchQuery, clearToolSelection]);
// Handle view switching logic
if (toolId === 'allTools' || toolId === 'read' || toolId === 'view-pdf') {
setLeftPanelView('toolPicker');
if (toolId === 'read' || toolId === 'view-pdf') {
setReaderMode(true);
} else {
setReaderMode(false);
}
} else {
setLeftPanelView('toolContent');
setReaderMode(false); // Disable read mode when selecting tools
}
}, [actions, setLeftPanelView, setReaderMode, setSearchQuery]);
const handleBackToTools = useCallback(() => {
setLeftPanelView('toolPicker');
setReaderMode(false);
clearToolSelection();
}, [setLeftPanelView, setReaderMode, clearToolSelection]);
actions.clearToolSelection();
}, [setLeftPanelView, setReaderMode, actions]);
const handleReaderToggle = useCallback(() => {
setReaderMode(true);
@@ -182,11 +188,14 @@ export function ToolWorkflowProvider({ children, onViewChange }: ToolWorkflowPro
[state.sidebarsVisible, state.readerMode]
);
// Simple context value with basic memoization
// Enable URL synchronization for tool selection
useToolWorkflowUrlSync(navigationState.selectedToolKey, actions.selectTool, actions.clearToolSelection, true);
// Properly memoized context value
const contextValue = useMemo((): ToolWorkflowContextValue => ({
// State
...state,
selectedToolKey,
selectedToolKey: navigationState.selectedToolKey,
selectedTool,
toolRegistry,
@@ -197,8 +206,8 @@ export function ToolWorkflowProvider({ children, onViewChange }: ToolWorkflowPro
setPreviewFile,
setPageEditorFunctions,
setSearchQuery,
selectTool,
clearToolSelection,
selectTool: actions.selectTool,
clearToolSelection: actions.clearToolSelection,
// Workflow Actions
handleToolSelect,
@@ -208,7 +217,25 @@ export function ToolWorkflowProvider({ children, onViewChange }: ToolWorkflowPro
// Computed
filteredTools,
isPanelVisible,
}), [state, selectedToolKey, selectedTool, toolRegistry, filteredTools, isPanelVisible]);
}), [
state,
navigationState.selectedToolKey,
selectedTool,
toolRegistry,
setSidebarsVisible,
setLeftPanelView,
setReaderMode,
setPreviewFile,
setPageEditorFunctions,
setSearchQuery,
actions.selectTool,
actions.clearToolSelection,
handleToolSelect,
handleBackToTools,
handleReaderToggle,
filteredTools,
isPanelVisible,
]);
return (
<ToolWorkflowContext.Provider value={contextValue}>
@@ -221,7 +248,39 @@ export function ToolWorkflowProvider({ children, onViewChange }: ToolWorkflowPro
export function useToolWorkflow(): ToolWorkflowContextValue {
const context = useContext(ToolWorkflowContext);
if (!context) {
// During development hot reload, temporarily return a safe fallback
if (false && process.env.NODE_ENV === 'development') {
console.warn('ToolWorkflowContext temporarily unavailable during hot reload, using fallback');
// Return minimal safe fallback to prevent crashes
return {
sidebarsVisible: true,
leftPanelView: 'toolPicker',
readerMode: false,
previewFile: null,
pageEditorFunctions: null,
searchQuery: '',
selectedToolKey: null,
selectedTool: null,
toolRegistry: {},
filteredTools: [],
isPanelVisible: true,
setSidebarsVisible: () => {},
setLeftPanelView: () => {},
setReaderMode: () => {},
setPreviewFile: () => {},
setPageEditorFunctions: () => {},
setSearchQuery: () => {},
selectTool: () => {},
clearToolSelection: () => {},
handleToolSelect: () => {},
handleBackToTools: () => {},
handleReaderToggle: () => {}
} as ToolWorkflowContextValue;
}
console.error('ToolWorkflowContext not found. Current stack:', new Error().stack);
throw new Error('useToolWorkflow must be used within a ToolWorkflowProvider');
}
return context;
}
}
+240
View File
@@ -0,0 +1,240 @@
/**
* FileContext reducer - Pure state management for file operations
*/
import {
FileContextState,
FileContextAction,
FileId,
FileRecord
} from '../../types/fileContext';
// Initial state
export const initialFileContextState: FileContextState = {
files: {
ids: [],
byId: {}
},
pinnedFiles: new Set(),
ui: {
selectedFileIds: [],
selectedPageNumbers: [],
isProcessing: false,
processingProgress: 0,
hasUnsavedChanges: false
}
};
// Pure reducer function
export function fileContextReducer(state: FileContextState, action: FileContextAction): FileContextState {
switch (action.type) {
case 'ADD_FILES': {
const { fileRecords } = action.payload;
const newIds: FileId[] = [];
const newById: Record<FileId, FileRecord> = { ...state.files.byId };
fileRecords.forEach(record => {
// Only add if not already present (dedupe by stable ID)
if (!newById[record.id]) {
newIds.push(record.id);
newById[record.id] = record;
}
});
return {
...state,
files: {
ids: [...state.files.ids, ...newIds],
byId: newById
}
};
}
case 'REMOVE_FILES': {
const { fileIds } = action.payload;
const remainingIds = state.files.ids.filter(id => !fileIds.includes(id));
const newById = { ...state.files.byId };
// Remove files from state (resource cleanup handled by lifecycle manager)
fileIds.forEach(id => {
delete newById[id];
});
// Clear selections that reference removed files
const validSelectedFileIds = state.ui.selectedFileIds.filter(id => !fileIds.includes(id));
return {
...state,
files: {
ids: remainingIds,
byId: newById
},
ui: {
...state.ui,
selectedFileIds: validSelectedFileIds
}
};
}
case 'UPDATE_FILE_RECORD': {
const { id, updates } = action.payload;
const existingRecord = state.files.byId[id];
if (!existingRecord) {
return state; // File doesn't exist, no-op
}
return {
...state,
files: {
...state.files,
byId: {
...state.files.byId,
[id]: {
...existingRecord,
...updates
}
}
}
};
}
case 'REORDER_FILES': {
const { orderedFileIds } = action.payload;
// Validate that all IDs exist in current state
const validIds = orderedFileIds.filter(id => state.files.byId[id]);
return {
...state,
files: {
...state.files,
ids: validIds
}
};
}
case 'SET_SELECTED_FILES': {
const { fileIds } = action.payload;
return {
...state,
ui: {
...state.ui,
selectedFileIds: fileIds
}
};
}
case 'SET_SELECTED_PAGES': {
const { pageNumbers } = action.payload;
return {
...state,
ui: {
...state.ui,
selectedPageNumbers: pageNumbers
}
};
}
case 'CLEAR_SELECTIONS': {
return {
...state,
ui: {
...state.ui,
selectedFileIds: [],
selectedPageNumbers: []
}
};
}
case 'SET_PROCESSING': {
const { isProcessing, progress } = action.payload;
return {
...state,
ui: {
...state.ui,
isProcessing,
processingProgress: progress
}
};
}
case 'SET_UNSAVED_CHANGES': {
return {
...state,
ui: {
...state.ui,
hasUnsavedChanges: action.payload.hasChanges
}
};
}
case 'PIN_FILE': {
const { fileId } = action.payload;
const newPinnedFiles = new Set(state.pinnedFiles);
newPinnedFiles.add(fileId);
return {
...state,
pinnedFiles: newPinnedFiles
};
}
case 'UNPIN_FILE': {
const { fileId } = action.payload;
const newPinnedFiles = new Set(state.pinnedFiles);
newPinnedFiles.delete(fileId);
return {
...state,
pinnedFiles: newPinnedFiles
};
}
case 'CONSUME_FILES': {
const { inputFileIds, outputFileRecords } = action.payload;
// Only remove unpinned input files
const unpinnedInputIds = inputFileIds.filter(id => !state.pinnedFiles.has(id));
const remainingIds = state.files.ids.filter(id => !unpinnedInputIds.includes(id));
// Remove unpinned files from state
const newById = { ...state.files.byId };
unpinnedInputIds.forEach(id => {
delete newById[id];
});
// Add output files
const outputIds: FileId[] = [];
outputFileRecords.forEach(record => {
if (!newById[record.id]) {
outputIds.push(record.id);
newById[record.id] = record;
}
});
// Clear selections that reference removed files
const validSelectedFileIds = state.ui.selectedFileIds.filter(id => !unpinnedInputIds.includes(id));
return {
...state,
files: {
ids: [...remainingIds, ...outputIds],
byId: newById
},
ui: {
...state.ui,
selectedFileIds: validSelectedFileIds
}
};
}
case 'RESET_CONTEXT': {
// Reset UI state to clean slate (resource cleanup handled by lifecycle manager)
return { ...initialFileContextState };
}
default:
return state;
}
}
+13
View File
@@ -0,0 +1,13 @@
/**
* React contexts for file state and actions
*/
import { createContext } from 'react';
import { FileContextStateValue, FileContextActionsValue } from '../../types/fileContext';
// Split contexts for performance
export const FileStateContext = createContext<FileContextStateValue | undefined>(undefined);
export const FileActionsContext = createContext<FileContextActionsValue | undefined>(undefined);
// Export types for use in hooks
export type { FileContextStateValue, FileContextActionsValue };
+370
View File
@@ -0,0 +1,370 @@
/**
* File actions - Unified file operations with single addFiles helper
*/
import {
FileId,
FileRecord,
FileContextAction,
FileContextState,
toFileRecord,
createFileId,
createQuickKey
} from '../../types/fileContext';
import { FileMetadata } from '../../types/file';
import { generateThumbnailWithMetadata } from '../../utils/thumbnailUtils';
import { FileLifecycleManager } from './lifecycle';
import { fileProcessingService } from '../../services/fileProcessingService';
import { buildQuickKeySet, buildQuickKeySetFromMetadata } from './fileSelectors';
const DEBUG = process.env.NODE_ENV === 'development';
/**
* Simple mutex to prevent race conditions in addFiles
*/
class SimpleMutex {
private locked = false;
private queue: Array<() => void> = [];
async lock(): Promise<void> {
if (!this.locked) {
this.locked = true;
return Promise.resolve();
}
return new Promise<void>((resolve) => {
this.queue.push(() => {
this.locked = true;
resolve();
});
});
}
unlock(): void {
if (this.queue.length > 0) {
const next = this.queue.shift()!;
next();
} else {
this.locked = false;
}
}
}
// Global mutex for addFiles operations
const addFilesMutex = new SimpleMutex();
/**
* Helper to create ProcessedFile metadata structure
*/
export function createProcessedFile(pageCount: number, thumbnail?: string) {
return {
totalPages: pageCount,
pages: Array.from({ length: pageCount }, (_, index) => ({
pageNumber: index + 1,
thumbnail: index === 0 ? thumbnail : undefined, // Only first page gets thumbnail initially
rotation: 0,
splitBefore: false
})),
thumbnailUrl: thumbnail,
lastProcessed: Date.now()
};
}
/**
* File addition types
*/
type AddFileKind = 'raw' | 'processed' | 'stored';
interface AddFileOptions {
// For 'raw' files
files?: File[];
// For 'processed' files
filesWithThumbnails?: Array<{ file: File; thumbnail?: string; pageCount?: number }>;
// For 'stored' files
filesWithMetadata?: Array<{ file: File; originalId: FileId; metadata: FileMetadata }>;
}
/**
* Unified file addition helper - replaces addFiles/addProcessedFiles/addStoredFiles
*/
export async function addFiles(
kind: AddFileKind,
options: AddFileOptions,
stateRef: React.MutableRefObject<FileContextState>,
filesRef: React.MutableRefObject<Map<FileId, File>>,
dispatch: React.Dispatch<FileContextAction>,
lifecycleManager: FileLifecycleManager
): Promise<Array<{ file: File; id: FileId; thumbnail?: string }>> {
// Acquire mutex to prevent race conditions
await addFilesMutex.lock();
try {
const fileRecords: FileRecord[] = [];
const addedFiles: Array<{ file: File; id: FileId; thumbnail?: string }> = [];
// Build quickKey lookup from existing files for deduplication
const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId);
if (DEBUG) console.log(`📄 addFiles(${kind}): Existing quickKeys for deduplication:`, Array.from(existingQuickKeys));
switch (kind) {
case 'raw': {
const { files = [] } = options;
if (DEBUG) console.log(`📄 addFiles(raw): Adding ${files.length} files with immediate thumbnail generation`);
for (const file of files) {
const quickKey = createQuickKey(file);
// Soft deduplication: Check if file already exists by metadata
if (existingQuickKeys.has(quickKey)) {
if (DEBUG) console.log(`📄 Skipping duplicate file: ${file.name} (quickKey: ${quickKey})`);
continue;
}
if (DEBUG) console.log(`📄 Adding new file: ${file.name} (quickKey: ${quickKey})`);
const fileId = createFileId();
filesRef.current.set(fileId, file);
// Generate thumbnail and page count immediately
let thumbnail: string | undefined;
let pageCount: number = 1;
// Route based on file type - PDFs through full metadata pipeline, non-PDFs through simple path
if (file.type.startsWith('application/pdf')) {
try {
if (DEBUG) console.log(`📄 Generating PDF metadata for ${file.name}`);
const result = await generateThumbnailWithMetadata(file);
thumbnail = result.thumbnail;
pageCount = result.pageCount;
if (DEBUG) console.log(`📄 Generated PDF metadata for ${file.name}: ${pageCount} pages, thumbnail: SUCCESS`);
} catch (error) {
if (DEBUG) console.warn(`📄 Failed to generate PDF metadata for ${file.name}:`, error);
}
} else {
// Non-PDF files: simple thumbnail generation, no page count
try {
if (DEBUG) console.log(`📄 Generating simple thumbnail for non-PDF file ${file.name}`);
const { generateThumbnailForFile } = await import('../../utils/thumbnailUtils');
thumbnail = await generateThumbnailForFile(file);
pageCount = 0; // Non-PDFs have no page count
if (DEBUG) console.log(`📄 Generated simple thumbnail for ${file.name}: no page count, thumbnail: SUCCESS`);
} catch (error) {
if (DEBUG) console.warn(`📄 Failed to generate simple thumbnail for ${file.name}:`, error);
}
}
// Create record with immediate thumbnail and page metadata
const record = toFileRecord(file, fileId);
if (thumbnail) {
record.thumbnailUrl = thumbnail;
// Track blob URLs for cleanup (images return blob URLs that need revocation)
if (thumbnail.startsWith('blob:')) {
lifecycleManager.trackBlobUrl(thumbnail);
}
}
// Create initial processedFile metadata with page count
if (pageCount > 0) {
record.processedFile = createProcessedFile(pageCount, thumbnail);
if (DEBUG) console.log(`📄 addFiles(raw): Created initial processedFile metadata for ${file.name} with ${pageCount} pages`);
}
existingQuickKeys.add(quickKey);
fileRecords.push(record);
addedFiles.push({ file, id: fileId, thumbnail });
}
break;
}
case 'processed': {
const { filesWithThumbnails = [] } = options;
if (DEBUG) console.log(`📄 addFiles(processed): Adding ${filesWithThumbnails.length} processed files with pre-existing thumbnails`);
for (const { file, thumbnail, pageCount = 1 } of filesWithThumbnails) {
const quickKey = createQuickKey(file);
if (existingQuickKeys.has(quickKey)) {
if (DEBUG) console.log(`📄 Skipping duplicate processed file: ${file.name}`);
continue;
}
const fileId = createFileId();
filesRef.current.set(fileId, file);
const record = toFileRecord(file, fileId);
if (thumbnail) {
record.thumbnailUrl = thumbnail;
// Track blob URLs for cleanup (images return blob URLs that need revocation)
if (thumbnail.startsWith('blob:')) {
lifecycleManager.trackBlobUrl(thumbnail);
}
}
// Create processedFile with provided metadata
if (pageCount > 0) {
record.processedFile = createProcessedFile(pageCount, thumbnail);
if (DEBUG) console.log(`📄 addFiles(processed): Created initial processedFile metadata for ${file.name} with ${pageCount} pages`);
}
existingQuickKeys.add(quickKey);
fileRecords.push(record);
addedFiles.push({ file, id: fileId, thumbnail });
}
break;
}
case 'stored': {
const { filesWithMetadata = [] } = options;
if (DEBUG) console.log(`📄 addFiles(stored): Restoring ${filesWithMetadata.length} files from storage with existing metadata`);
for (const { file, originalId, metadata } of filesWithMetadata) {
const quickKey = createQuickKey(file);
if (existingQuickKeys.has(quickKey)) {
if (DEBUG) console.log(`📄 Skipping duplicate stored file: ${file.name} (quickKey: ${quickKey})`);
continue;
}
if (DEBUG) console.log(`📄 Adding stored file: ${file.name} (quickKey: ${quickKey})`);
// Try to preserve original ID, but generate new if it conflicts
let fileId = originalId;
if (filesRef.current.has(originalId)) {
fileId = createFileId();
if (DEBUG) console.log(`📄 ID conflict for stored file ${file.name}, using new ID: ${fileId}`);
}
filesRef.current.set(fileId, file);
const record = toFileRecord(file, fileId);
// Generate processedFile metadata for stored files
let pageCount: number = 1;
// Only process PDFs through PDF worker manager, non-PDFs have no page count
if (file.type.startsWith('application/pdf')) {
try {
if (DEBUG) console.log(`📄 addFiles(stored): Generating PDF metadata for stored file ${file.name}`);
// Get page count from PDF
const arrayBuffer = await file.arrayBuffer();
const { pdfWorkerManager } = await import('../../services/pdfWorkerManager');
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
pageCount = pdf.numPages;
pdfWorkerManager.destroyDocument(pdf);
if (DEBUG) console.log(`📄 addFiles(stored): Found ${pageCount} pages in PDF ${file.name}`);
} catch (error) {
if (DEBUG) console.warn(`📄 addFiles(stored): Failed to generate PDF metadata for ${file.name}:`, error);
}
} else {
pageCount = 0; // Non-PDFs have no page count
if (DEBUG) console.log(`📄 addFiles(stored): Non-PDF file ${file.name}, no page count`);
}
// Restore metadata from storage
if (metadata.thumbnail) {
record.thumbnailUrl = metadata.thumbnail;
// Track blob URLs for cleanup (images return blob URLs that need revocation)
if (metadata.thumbnail.startsWith('blob:')) {
lifecycleManager.trackBlobUrl(metadata.thumbnail);
}
}
// Create processedFile metadata with correct page count
if (pageCount > 0) {
record.processedFile = createProcessedFile(pageCount, metadata.thumbnail);
if (DEBUG) console.log(`📄 addFiles(stored): Created processedFile metadata for ${file.name} with ${pageCount} pages`);
}
existingQuickKeys.add(quickKey);
fileRecords.push(record);
addedFiles.push({ file, id: fileId, thumbnail: metadata.thumbnail });
}
break;
}
}
// Dispatch ADD_FILES action if we have new files
if (fileRecords.length > 0) {
dispatch({ type: 'ADD_FILES', payload: { fileRecords } });
if (DEBUG) console.log(`📄 addFiles(${kind}): Successfully added ${fileRecords.length} files`);
}
return addedFiles;
} finally {
// Always release mutex even if error occurs
addFilesMutex.unlock();
}
}
/**
* Consume files helper - replace unpinned input files with output files
*/
export async function consumeFiles(
inputFileIds: FileId[],
outputFiles: File[],
stateRef: React.MutableRefObject<FileContextState>,
filesRef: React.MutableRefObject<Map<FileId, File>>,
dispatch: React.Dispatch<FileContextAction>
): Promise<void> {
if (DEBUG) console.log(`📄 consumeFiles: Processing ${inputFileIds.length} input files, ${outputFiles.length} output files`);
// Process output files through the 'processed' path to generate thumbnails
const outputFileRecords = await Promise.all(
outputFiles.map(async (file) => {
const fileId = createFileId();
filesRef.current.set(fileId, file);
// Generate thumbnail and page count for output file
let thumbnail: string | undefined;
let pageCount: number = 1;
try {
if (DEBUG) console.log(`📄 consumeFiles: Generating thumbnail for output file ${file.name}`);
const result = await generateThumbnailWithMetadata(file);
thumbnail = result.thumbnail;
pageCount = result.pageCount;
} catch (error) {
if (DEBUG) console.warn(`📄 consumeFiles: Failed to generate thumbnail for output file ${file.name}:`, error);
}
const record = toFileRecord(file, fileId);
if (thumbnail) {
record.thumbnailUrl = thumbnail;
}
if (pageCount > 0) {
record.processedFile = createProcessedFile(pageCount, thumbnail);
}
return record;
})
);
// Dispatch the consume action
dispatch({
type: 'CONSUME_FILES',
payload: {
inputFileIds,
outputFileRecords
}
});
if (DEBUG) console.log(`📄 consumeFiles: Successfully consumed files - removed ${inputFileIds.length} inputs, added ${outputFileRecords.length} outputs`);
}
/**
* Action factory functions
*/
export const createFileActions = (dispatch: React.Dispatch<FileContextAction>) => ({
setSelectedFiles: (fileIds: FileId[]) => dispatch({ type: 'SET_SELECTED_FILES', payload: { fileIds } }),
setSelectedPages: (pageNumbers: number[]) => dispatch({ type: 'SET_SELECTED_PAGES', payload: { pageNumbers } }),
clearSelections: () => dispatch({ type: 'CLEAR_SELECTIONS' }),
setProcessing: (isProcessing: boolean, progress = 0) => dispatch({ type: 'SET_PROCESSING', payload: { isProcessing, progress } }),
setHasUnsavedChanges: (hasChanges: boolean) => dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } }),
pinFile: (fileId: FileId) => dispatch({ type: 'PIN_FILE', payload: { fileId } }),
unpinFile: (fileId: FileId) => dispatch({ type: 'UNPIN_FILE', payload: { fileId } }),
resetContext: () => dispatch({ type: 'RESET_CONTEXT' })
});
+193
View File
@@ -0,0 +1,193 @@
/**
* Performant file hooks - Clean API using FileContext
*/
import { useContext, useMemo } from 'react';
import {
FileStateContext,
FileActionsContext,
FileContextStateValue,
FileContextActionsValue
} from './contexts';
import { FileId, FileRecord } from '../../types/fileContext';
/**
* Hook for accessing file state (will re-render on any state change)
* Use individual selector hooks below for better performance
*/
export function useFileState(): FileContextStateValue {
const context = useContext(FileStateContext);
if (!context) {
throw new Error('useFileState must be used within a FileContextProvider');
}
return context;
}
/**
* Hook for accessing file actions (stable - won't cause re-renders)
*/
export function useFileActions(): FileContextActionsValue {
const context = useContext(FileActionsContext);
if (!context) {
throw new Error('useFileActions must be used within a FileContextProvider');
}
return context;
}
/**
* Hook for current/primary file (first in list)
*/
export function useCurrentFile(): { file?: File; record?: FileRecord } {
const { state, selectors } = useFileState();
const primaryFileId = state.files.ids[0];
return useMemo(() => ({
file: primaryFileId ? selectors.getFile(primaryFileId) : undefined,
record: primaryFileId ? selectors.getFileRecord(primaryFileId) : undefined
}), [primaryFileId, selectors]);
}
/**
* Hook for file selection state and actions
*/
export function useFileSelection() {
const { state, selectors } = useFileState();
const { actions } = useFileActions();
// Memoize selected files to avoid recreating arrays
const selectedFiles = useMemo(() => {
return selectors.getSelectedFiles();
}, [state.ui.selectedFileIds, selectors]);
return useMemo(() => ({
selectedFiles,
selectedFileIds: state.ui.selectedFileIds,
selectedPageNumbers: state.ui.selectedPageNumbers,
setSelectedFiles: actions.setSelectedFiles,
setSelectedPages: actions.setSelectedPages,
clearSelections: actions.clearSelections
}), [
selectedFiles,
state.ui.selectedFileIds,
state.ui.selectedPageNumbers,
actions.setSelectedFiles,
actions.setSelectedPages,
actions.clearSelections
]);
}
/**
* Hook for file management operations
*/
export function useFileManagement() {
const { actions } = useFileActions();
return useMemo(() => ({
addFiles: actions.addFiles,
removeFiles: actions.removeFiles,
clearAllFiles: actions.clearAllFiles,
updateFileRecord: actions.updateFileRecord,
reorderFiles: actions.reorderFiles
}), [actions]);
}
/**
* Hook for UI state
*/
export function useFileUI() {
const { state } = useFileState();
const { actions } = useFileActions();
return useMemo(() => ({
isProcessing: state.ui.isProcessing,
processingProgress: state.ui.processingProgress,
hasUnsavedChanges: state.ui.hasUnsavedChanges,
setProcessing: actions.setProcessing,
setUnsavedChanges: actions.setHasUnsavedChanges
}), [state.ui, actions]);
}
/**
* Hook for specific file by ID (optimized for individual file access)
*/
export function useFileRecord(fileId: FileId): { file?: File; record?: FileRecord } {
const { selectors } = useFileState();
return useMemo(() => ({
file: selectors.getFile(fileId),
record: selectors.getFileRecord(fileId)
}), [fileId, selectors]);
}
/**
* Hook for all files (use sparingly - causes re-renders on file list changes)
*/
export function useAllFiles(): { files: File[]; records: FileRecord[]; fileIds: FileId[] } {
const { state, selectors } = useFileState();
return useMemo(() => ({
files: selectors.getFiles(),
records: selectors.getFileRecords(),
fileIds: state.files.ids
}), [state.files.ids, selectors]);
}
/**
* Hook for selected files (optimized for selection-based UI)
*/
export function useSelectedFiles(): { files: File[]; records: FileRecord[]; fileIds: FileId[] } {
const { state, selectors } = useFileState();
return useMemo(() => ({
files: selectors.getSelectedFiles(),
records: selectors.getSelectedFileRecords(),
fileIds: state.ui.selectedFileIds
}), [state.ui.selectedFileIds, selectors]);
}
// Navigation management removed - moved to NavigationContext
/**
* Primary API hook for file context operations
* Used by tools for core file context functionality
*/
export function useFileContext() {
const { state, selectors } = useFileState();
const { actions } = useFileActions();
return useMemo(() => ({
// Lifecycle management
trackBlobUrl: actions.trackBlobUrl,
scheduleCleanup: actions.scheduleCleanup,
setUnsavedChanges: actions.setHasUnsavedChanges,
// File management
addFiles: actions.addFiles,
consumeFiles: actions.consumeFiles,
recordOperation: (fileId: string, operation: any) => {}, // Operation tracking not implemented
markOperationApplied: (fileId: string, operationId: string) => {}, // Operation tracking not implemented
markOperationFailed: (fileId: string, operationId: string, error: string) => {}, // Operation tracking not implemented
// File ID lookup
findFileId: (file: File) => {
return state.files.ids.find(id => {
const record = state.files.byId[id];
return record &&
record.name === file.name &&
record.size === file.size &&
record.lastModified === file.lastModified;
});
},
// Pinned files
pinnedFiles: state.pinnedFiles,
pinFile: actions.pinFile,
unpinFile: actions.unpinFile,
isFilePinned: selectors.isFilePinned,
// Active files
activeFiles: selectors.getFiles()
}), [state, selectors, actions]);
}
+130
View File
@@ -0,0 +1,130 @@
/**
* File selectors - Pure functions for accessing file state
*/
import {
FileId,
FileRecord,
FileContextState,
FileContextSelectors
} from '../../types/fileContext';
/**
* Create stable selectors using stateRef and filesRef
*/
export function createFileSelectors(
stateRef: React.MutableRefObject<FileContextState>,
filesRef: React.MutableRefObject<Map<FileId, File>>
): FileContextSelectors {
return {
getFile: (id: FileId) => filesRef.current.get(id),
getFiles: (ids?: FileId[]) => {
const currentIds = ids || stateRef.current.files.ids;
return currentIds.map(id => filesRef.current.get(id)).filter(Boolean) as File[];
},
getFileRecord: (id: FileId) => stateRef.current.files.byId[id],
getFileRecords: (ids?: FileId[]) => {
const currentIds = ids || stateRef.current.files.ids;
return currentIds.map(id => stateRef.current.files.byId[id]).filter(Boolean);
},
getAllFileIds: () => stateRef.current.files.ids,
getSelectedFiles: () => {
return stateRef.current.ui.selectedFileIds
.map(id => filesRef.current.get(id))
.filter(Boolean) as File[];
},
getSelectedFileRecords: () => {
return stateRef.current.ui.selectedFileIds
.map(id => stateRef.current.files.byId[id])
.filter(Boolean);
},
// Pinned files selectors
getPinnedFileIds: () => {
return Array.from(stateRef.current.pinnedFiles);
},
getPinnedFiles: () => {
return Array.from(stateRef.current.pinnedFiles)
.map(id => filesRef.current.get(id))
.filter(Boolean) as File[];
},
getPinnedFileRecords: () => {
return Array.from(stateRef.current.pinnedFiles)
.map(id => stateRef.current.files.byId[id])
.filter(Boolean);
},
isFilePinned: (file: File) => {
// Find FileId by matching File object properties
const fileId = Object.keys(stateRef.current.files.byId).find(id => {
const storedFile = filesRef.current.get(id);
return storedFile &&
storedFile.name === file.name &&
storedFile.size === file.size &&
storedFile.lastModified === file.lastModified;
});
return fileId ? stateRef.current.pinnedFiles.has(fileId) : false;
},
// Stable signature for effects - prevents unnecessary re-renders
getFilesSignature: () => {
return stateRef.current.files.ids
.map(id => {
const record = stateRef.current.files.byId[id];
return record ? `${id}:${record.size}:${record.lastModified}` : '';
})
.join('|');
},
};
}
/**
* Helper for building quickKey sets for deduplication
*/
export function buildQuickKeySet(fileRecords: Record<FileId, FileRecord>): Set<string> {
const quickKeys = new Set<string>();
Object.values(fileRecords).forEach(record => {
if (record.quickKey) {
quickKeys.add(record.quickKey);
}
});
return quickKeys;
}
/**
* Helper for building quickKey sets from IndexedDB metadata
*/
export function buildQuickKeySetFromMetadata(metadata: Array<{ name: string; size: number; lastModified: number }>): Set<string> {
const quickKeys = new Set<string>();
metadata.forEach(meta => {
// Format: name|size|lastModified (same as createQuickKey)
const quickKey = `${meta.name}|${meta.size}|${meta.lastModified}`;
quickKeys.add(quickKey);
});
return quickKeys;
}
/**
* Get primary file (first in list) - commonly used pattern
*/
export function getPrimaryFile(
stateRef: React.MutableRefObject<FileContextState>,
filesRef: React.MutableRefObject<Map<FileId, File>>
): { file?: File; record?: FileRecord } {
const primaryFileId = stateRef.current.files.ids[0];
if (!primaryFileId) return {};
return {
file: filesRef.current.get(primaryFileId),
record: stateRef.current.files.byId[primaryFileId]
};
}
+190
View File
@@ -0,0 +1,190 @@
/**
* File lifecycle management - Resource cleanup and memory management
*/
import { FileId, FileContextAction, FileRecord, ProcessedFilePage } from '../../types/fileContext';
const DEBUG = process.env.NODE_ENV === 'development';
/**
* Resource tracking and cleanup utilities
*/
export class FileLifecycleManager {
private cleanupTimers = new Map<string, number>();
private blobUrls = new Set<string>();
private fileGenerations = new Map<string, number>(); // Generation tokens to prevent stale cleanup
constructor(
private filesRef: React.MutableRefObject<Map<FileId, File>>,
private dispatch: React.Dispatch<FileContextAction>
) {}
/**
* Track blob URLs for cleanup
*/
trackBlobUrl = (url: string): void => {
// Only track actual blob URLs to avoid trying to revoke other schemes
if (url.startsWith('blob:')) {
this.blobUrls.add(url);
}
};
/**
* Clean up resources for a specific file (with stateRef access for complete cleanup)
*/
cleanupFile = (fileId: string, stateRef?: React.MutableRefObject<any>): void => {
// Use comprehensive cleanup (same as removeFiles)
this.cleanupAllResourcesForFile(fileId, stateRef);
// Remove file from state
this.dispatch({ type: 'REMOVE_FILES', payload: { fileIds: [fileId] } });
};
/**
* Clean up all files and resources
*/
cleanupAllFiles = (): void => {
// Revoke all blob URLs
this.blobUrls.forEach(url => {
try {
URL.revokeObjectURL(url);
} catch (error) {
// Ignore revocation errors
}
});
this.blobUrls.clear();
// Clear all cleanup timers and generations
this.cleanupTimers.forEach(timer => clearTimeout(timer));
this.cleanupTimers.clear();
this.fileGenerations.clear();
// Clear files ref
this.filesRef.current.clear();
};
/**
* Schedule delayed cleanup for a file with generation token to prevent stale cleanup
*/
scheduleCleanup = (fileId: string, delay: number = 30000, stateRef?: React.MutableRefObject<any>): void => {
// Cancel existing timer
const existingTimer = this.cleanupTimers.get(fileId);
if (existingTimer) {
clearTimeout(existingTimer);
this.cleanupTimers.delete(fileId);
}
// If delay is negative, just cancel (don't reschedule)
if (delay < 0) {
return;
}
// Increment generation for this file to invalidate any pending cleanup
const currentGen = (this.fileGenerations.get(fileId) || 0) + 1;
this.fileGenerations.set(fileId, currentGen);
// Schedule new cleanup with generation token
const timer = window.setTimeout(() => {
// Check if this cleanup is still valid (file hasn't been re-added)
if (this.fileGenerations.get(fileId) === currentGen) {
this.cleanupFile(fileId, stateRef);
} else {
if (DEBUG) console.log(`🗂️ Skipped stale cleanup for file ${fileId} (generation mismatch)`);
}
}, delay);
this.cleanupTimers.set(fileId, timer);
};
/**
* Remove a file immediately with complete resource cleanup
*/
removeFiles = (fileIds: FileId[], stateRef?: React.MutableRefObject<any>): void => {
fileIds.forEach(fileId => {
// Clean up all resources for this file
this.cleanupAllResourcesForFile(fileId, stateRef);
});
// Dispatch removal action once for all files (reducer only updates state)
this.dispatch({ type: 'REMOVE_FILES', payload: { fileIds } });
};
/**
* Complete resource cleanup for a single file
*/
private cleanupAllResourcesForFile = (fileId: FileId, stateRef?: React.MutableRefObject<any>): void => {
// Remove from files ref
this.filesRef.current.delete(fileId);
// Cancel cleanup timer and generation
const timer = this.cleanupTimers.get(fileId);
if (timer) {
clearTimeout(timer);
this.cleanupTimers.delete(fileId);
}
this.fileGenerations.delete(fileId);
// Clean up blob URLs from file record if we have access to state
if (stateRef) {
const record = stateRef.current.files.byId[fileId];
if (record) {
// Clean up thumbnail blob URLs
if (record.thumbnailUrl && record.thumbnailUrl.startsWith('blob:')) {
try {
URL.revokeObjectURL(record.thumbnailUrl);
} catch (error) {
// Ignore revocation errors
}
}
if (record.blobUrl && record.blobUrl.startsWith('blob:')) {
try {
URL.revokeObjectURL(record.blobUrl);
} catch (error) {
// Ignore revocation errors
}
}
// Clean up processed file thumbnails
if (record.processedFile?.pages) {
record.processedFile.pages.forEach((page: ProcessedFilePage, index: number) => {
if (page.thumbnail && page.thumbnail.startsWith('blob:')) {
try {
URL.revokeObjectURL(page.thumbnail);
} catch (error) {
// Ignore revocation errors
}
}
});
}
}
}
};
/**
* Update file record with race condition guards
*/
updateFileRecord = (fileId: FileId, updates: Partial<FileRecord>, stateRef?: React.MutableRefObject<any>): void => {
// Guard against updating removed files (race condition protection)
if (!this.filesRef.current.has(fileId)) {
if (DEBUG) console.warn(`🗂️ Attempted to update removed file (filesRef): ${fileId}`);
return;
}
// Additional state guard for rare race conditions
if (stateRef && !stateRef.current.files.byId[fileId]) {
if (DEBUG) console.warn(`🗂️ Attempted to update removed file (state): ${fileId}`);
return;
}
this.dispatch({ type: 'UPDATE_FILE_RECORD', payload: { id: fileId, updates } });
};
/**
* Cleanup on unmount
*/
destroy = (): void => {
this.cleanupAllFiles();
};
}
+69 -62
View File
@@ -1,102 +1,109 @@
import { type TFunction } from 'i18next';
import React from 'react';
import { ToolOperationHook, ToolOperationConfig } from '../hooks/tools/shared/useToolOperation';
import { BaseToolProps } from '../types/tool';
import { BaseParameters } from '../types/parameters';
export enum SubcategoryId {
SIGNING = 'signing',
DOCUMENT_SECURITY = 'documentSecurity',
VERIFICATION = 'verification',
DOCUMENT_REVIEW = 'documentReview',
PAGE_FORMATTING = 'pageFormatting',
EXTRACTION = 'extraction',
REMOVAL = 'removal',
AUTOMATION = 'automation',
GENERAL = 'general',
ADVANCED_FORMATTING = 'advancedFormatting',
DEVELOPER_TOOLS = 'developerTools'
SIGNING = 'signing',
DOCUMENT_SECURITY = 'documentSecurity',
VERIFICATION = 'verification',
DOCUMENT_REVIEW = 'documentReview',
PAGE_FORMATTING = 'pageFormatting',
EXTRACTION = 'extraction',
REMOVAL = 'removal',
AUTOMATION = 'automation',
GENERAL = 'general',
ADVANCED_FORMATTING = 'advancedFormatting',
DEVELOPER_TOOLS = 'developerTools'
}
export enum ToolCategory {
STANDARD_TOOLS = 'Standard Tools',
ADVANCED_TOOLS = 'Advanced Tools',
RECOMMENDED_TOOLS = 'Recommended Tools'
export enum ToolCategoryId {
STANDARD_TOOLS = 'standardTools',
ADVANCED_TOOLS = 'advancedTools',
RECOMMENDED_TOOLS = 'recommendedTools'
}
export type ToolRegistryEntry = {
icon: React.ReactNode;
name: string;
component: React.ComponentType<any> | null;
component: React.ComponentType<BaseToolProps> | null;
view: 'sign' | 'security' | 'format' | 'extract' | 'view' | 'merge' | 'pageEditor' | 'convert' | 'redact' | 'split' | 'convert' | 'remove' | 'compress' | 'external';
description: string;
category: ToolCategory;
subcategory: SubcategoryId;
categoryId: ToolCategoryId;
subcategoryId: SubcategoryId;
maxFiles?: number;
supportedFormats?: string[];
endpoints?: string[];
link?: string;
type?: string;
// Operation configuration for automation
operationConfig?: ToolOperationConfig<any>;
// Settings component for automation configuration
settingsComponent?: React.ComponentType<any>;
}
export type ToolRegistry = Record<string, ToolRegistryEntry>;
export type ToolRegistry = Record<string /* FIX ME: Should be ToolId */, ToolRegistryEntry>;
export const SUBCATEGORY_ORDER: SubcategoryId[] = [
SubcategoryId.SIGNING,
SubcategoryId.DOCUMENT_SECURITY,
SubcategoryId.VERIFICATION,
SubcategoryId.DOCUMENT_REVIEW,
SubcategoryId.PAGE_FORMATTING,
SubcategoryId.EXTRACTION,
SubcategoryId.REMOVAL,
SubcategoryId.AUTOMATION,
SubcategoryId.GENERAL,
SubcategoryId.ADVANCED_FORMATTING,
SubcategoryId.DEVELOPER_TOOLS,
SubcategoryId.SIGNING,
SubcategoryId.DOCUMENT_SECURITY,
SubcategoryId.VERIFICATION,
SubcategoryId.DOCUMENT_REVIEW,
SubcategoryId.PAGE_FORMATTING,
SubcategoryId.EXTRACTION,
SubcategoryId.REMOVAL,
SubcategoryId.AUTOMATION,
SubcategoryId.GENERAL,
SubcategoryId.ADVANCED_FORMATTING,
SubcategoryId.DEVELOPER_TOOLS,
];
export const SUBCATEGORY_COLOR_MAP: Record<SubcategoryId, string> = {
[SubcategoryId.SIGNING]: '#FF7892',
[SubcategoryId.DOCUMENT_SECURITY]: '#FF7892',
[SubcategoryId.VERIFICATION]: '#1BB1D4',
[SubcategoryId.DOCUMENT_REVIEW]: '#48BD54',
[SubcategoryId.PAGE_FORMATTING]: '#7882FF',
[SubcategoryId.EXTRACTION]: '#1BB1D4',
[SubcategoryId.REMOVAL]: '#7882FF',
[SubcategoryId.AUTOMATION]: '#69DC95',
[SubcategoryId.GENERAL]: '#69DC95',
[SubcategoryId.ADVANCED_FORMATTING]: '#F55454',
[SubcategoryId.DEVELOPER_TOOLS]: '#F55454',
[SubcategoryId.SIGNING]: '#FF7892',
[SubcategoryId.DOCUMENT_SECURITY]: '#FF7892',
[SubcategoryId.VERIFICATION]: '#1BB1D4',
[SubcategoryId.DOCUMENT_REVIEW]: '#48BD54',
[SubcategoryId.PAGE_FORMATTING]: '#7882FF',
[SubcategoryId.EXTRACTION]: '#1BB1D4',
[SubcategoryId.REMOVAL]: '#7882FF',
[SubcategoryId.AUTOMATION]: '#69DC95',
[SubcategoryId.GENERAL]: '#69DC95',
[SubcategoryId.ADVANCED_FORMATTING]: '#F55454',
[SubcategoryId.DEVELOPER_TOOLS]: '#F55454',
};
export const getSubcategoryColor = (subcategory: SubcategoryId): string => SUBCATEGORY_COLOR_MAP[subcategory] || '#7882FF';
export const getCategoryLabel = (t: TFunction, id: ToolCategoryId): string => t(`toolPicker.categories.${id}`, id);
export const getSubcategoryLabel = (t: TFunction, id: SubcategoryId): string => t(`toolPicker.subcategories.${id}`, id);
export const getSubcategoryColor = (subcategory: SubcategoryId): string => SUBCATEGORY_COLOR_MAP[subcategory] || '#7882FF';
export const getAllEndpoints = (registry: ToolRegistry): string[] => {
const lists: string[][] = [];
Object.values(registry).forEach(entry => {
if (entry.endpoints && entry.endpoints.length > 0) {
lists.push(entry.endpoints);
}
});
return Array.from(new Set(lists.flat()));
const lists: string[][] = [];
Object.values(registry).forEach(entry => {
if (entry.endpoints && entry.endpoints.length > 0) {
lists.push(entry.endpoints);
}
});
return Array.from(new Set(lists.flat()));
};
export const getConversionEndpoints = (extensionToEndpoint: Record<string, Record<string, string>>): string[] => {
const endpoints = new Set<string>();
Object.values(extensionToEndpoint).forEach(toEndpoints => {
Object.values(toEndpoints).forEach(endpoint => {
endpoints.add(endpoint);
});
});
return Array.from(endpoints);
const endpoints = new Set<string>();
Object.values(extensionToEndpoint).forEach(toEndpoints => {
Object.values(toEndpoints).forEach(endpoint => {
endpoints.add(endpoint);
});
});
return Array.from(endpoints);
};
export const getAllApplicationEndpoints = (
registry: ToolRegistry,
extensionToEndpoint?: Record<string, Record<string, string>>
registry: ToolRegistry,
extensionToEndpoint?: Record<string, Record<string, string>>
): string[] => {
const toolEp = getAllEndpoints(registry);
const convEp = extensionToEndpoint ? getConversionEndpoints(extensionToEndpoint) : [];
return Array.from(new Set([...toolEp, ...convEp]));
const toolEp = getAllEndpoints(registry);
const convEp = extensionToEndpoint ? getConversionEndpoints(extensionToEndpoint) : [];
return Array.from(new Set([...toolEp, ...convEp]));
};
+228 -157
View File
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import SplitPdfPanel from "../tools/Split";
import CompressPdfPanel from "../tools/Compress";
@@ -8,16 +8,45 @@ import Sanitize from '../tools/Sanitize';
import AddPassword from '../tools/AddPassword';
import ChangePermissions from '../tools/ChangePermissions';
import RemovePassword from '../tools/RemovePassword';
import { SubcategoryId, ToolCategory, ToolRegistry } from './toolsTaxonomy';
import { SubcategoryId, ToolCategoryId, ToolRegistry } from './toolsTaxonomy';
import AddWatermark from '../tools/AddWatermark';
import Repair from '../tools/Repair';
import DeletePages from '../tools/DeletePages';
import SingleLargePage from '../tools/SingleLargePage';
import UnlockPdfForms from '../tools/UnlockPdfForms';
import RemoveCertificateSign from '../tools/RemoveCertificateSign';
import { compressOperationConfig } from '../hooks/tools/compress/useCompressOperation';
import { splitOperationConfig } from '../hooks/tools/split/useSplitOperation';
import { addPasswordOperationConfig } from '../hooks/tools/addPassword/useAddPasswordOperation';
import { removePasswordOperationConfig } from '../hooks/tools/removePassword/useRemovePasswordOperation';
import { sanitizeOperationConfig } from '../hooks/tools/sanitize/useSanitizeOperation';
import { repairOperationConfig } from '../hooks/tools/repair/useRepairOperation';
import { addWatermarkOperationConfig } from '../hooks/tools/addWatermark/useAddWatermarkOperation';
import { unlockPdfFormsOperationConfig } from '../hooks/tools/unlockPdfForms/useUnlockPdfFormsOperation';
import { singleLargePageOperationConfig } from '../hooks/tools/singleLargePage/useSingleLargePageOperation';
import { ocrOperationConfig } from '../hooks/tools/ocr/useOCROperation';
import { convertOperationConfig } from '../hooks/tools/convert/useConvertOperation';
import { removeCertificateSignOperationConfig } from '../hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation';
import { changePermissionsOperationConfig } from '../hooks/tools/changePermissions/useChangePermissionsOperation';
import CompressSettings from '../components/tools/compress/CompressSettings';
import SplitSettings from '../components/tools/split/SplitSettings';
import AddPasswordSettings from '../components/tools/addPassword/AddPasswordSettings';
import RemovePasswordSettings from '../components/tools/removePassword/RemovePasswordSettings';
import SanitizeSettings from '../components/tools/sanitize/SanitizeSettings';
import RepairSettings from '../components/tools/repair/RepairSettings';
import UnlockPdfFormsSettings from '../components/tools/unlockPdfForms/UnlockPdfFormsSettings';
import AddWatermarkSingleStepSettings from '../components/tools/addWatermark/AddWatermarkSingleStepSettings';
import OCRSettings from '../components/tools/ocr/OCRSettings';
import ConvertSettings from '../components/tools/convert/ConvertSettings';
import ChangePermissionsSettings from '../components/tools/changePermissions/ChangePermissionsSettings';
const showPlaceholderTools = false; // For development purposes. Allows seeing the full list of tools, even if they're unimplemented
// Hook to get the translated tool registry
export function useFlatToolRegistry(): ToolRegistry {
const { t } = useTranslation();
return {
const { t } = useTranslation();
return useMemo(() => {
const allTools: ToolRegistry = {
// Signing
"certSign": {
@@ -26,8 +55,8 @@ export function useFlatToolRegistry(): ToolRegistry {
component: null,
view: "sign",
description: t("home.certSign.desc", "Signs a PDF with a Certificate/Key (PEM/P12)"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.SIGNING
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.SIGNING
},
"sign": {
icon: <span className="material-symbols-rounded">signature</span>,
@@ -35,8 +64,8 @@ export function useFlatToolRegistry(): ToolRegistry {
component: null,
view: "sign",
description: t("home.sign.desc", "Adds signature to PDF by drawing, text or image"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.SIGNING
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.SIGNING
},
@@ -48,21 +77,25 @@ export function useFlatToolRegistry(): ToolRegistry {
component: AddPassword,
view: "security",
description: t("home.addPassword.desc", "Add password protection and restrictions to PDF files"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.DOCUMENT_SECURITY,
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
maxFiles: -1,
endpoints: ["add-password"]
},
"add-watermark": {
endpoints: ["add-password"],
operationConfig: addPasswordOperationConfig,
settingsComponent: AddPasswordSettings
},
"watermark": {
icon: <span className="material-symbols-rounded">branding_watermark</span>,
name: t("home.watermark.title", "Add Watermark"),
component: AddWatermark,
view: "format",
maxFiles: -1,
description: t("home.watermark.desc", "Add a custom watermark to your PDF document."),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.DOCUMENT_SECURITY,
endpoints: ["add-watermark"]
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
endpoints: ["add-watermark"],
operationConfig: addWatermarkOperationConfig,
settingsComponent: AddWatermarkSingleStepSettings
},
"add-stamp": {
icon: <span className="material-symbols-rounded">approval</span>,
@@ -70,8 +103,8 @@ export function useFlatToolRegistry(): ToolRegistry {
component: null,
view: "format",
description: t("home.AddStampRequest.desc", "Add text or add image stamps at set locations"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.DOCUMENT_SECURITY
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.DOCUMENT_SECURITY
},
"sanitize": {
icon: <span className="material-symbols-rounded">cleaning_services</span>,
@@ -79,10 +112,12 @@ export function useFlatToolRegistry(): ToolRegistry {
component: Sanitize,
view: "security",
maxFiles: -1,
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.DOCUMENT_SECURITY,
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
description: t("home.sanitize.desc", "Remove potentially harmful elements from PDF files"),
endpoints: ["sanitize-pdf"]
endpoints: ["sanitize-pdf"],
operationConfig: sanitizeOperationConfig,
settingsComponent: SanitizeSettings
},
"flatten": {
icon: <span className="material-symbols-rounded">layers_clear</span>,
@@ -90,17 +125,21 @@ export function useFlatToolRegistry(): ToolRegistry {
component: null,
view: "format",
description: t("home.flatten.desc", "Remove all interactive elements and forms from a PDF"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.DOCUMENT_SECURITY
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.DOCUMENT_SECURITY
},
"unlock-pdf-forms": {
icon: <span className="material-symbols-rounded">preview_off</span>,
name: t("home.unlockPDFForms.title", "Unlock PDF Forms"),
component: null,
component: UnlockPdfForms,
view: "security",
description: t("home.unlockPDFForms.desc", "Remove read-only property of form fields in a PDF document."),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.DOCUMENT_SECURITY
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
maxFiles: -1,
endpoints: ["unlock-pdf-forms"],
operationConfig: unlockPdfFormsOperationConfig,
settingsComponent: UnlockPdfFormsSettings
},
"manage-certificates": {
icon: <span className="material-symbols-rounded">license</span>,
@@ -108,8 +147,8 @@ export function useFlatToolRegistry(): ToolRegistry {
component: null,
view: "security",
description: t("home.manageCertificates.desc", "Import, export, or delete digital certificate files used for signing PDFs."),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.DOCUMENT_SECURITY
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.DOCUMENT_SECURITY
},
"change-permissions": {
icon: <span className="material-symbols-rounded">lock</span>,
@@ -117,10 +156,12 @@ export function useFlatToolRegistry(): ToolRegistry {
component: ChangePermissions,
view: "security",
description: t("home.changePermissions.desc", "Change document restrictions and permissions"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.DOCUMENT_SECURITY,
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
maxFiles: -1,
endpoints: ["add-password"]
endpoints: ["add-password"],
operationConfig: changePermissionsOperationConfig,
settingsComponent: ChangePermissionsSettings
},
// Verification
@@ -130,8 +171,8 @@ export function useFlatToolRegistry(): ToolRegistry {
component: null,
view: "extract",
description: t("home.getPdfInfo.desc", "Grabs any and all information possible on PDFs"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.VERIFICATION
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.VERIFICATION
},
"validate-pdf-signature": {
icon: <span className="material-symbols-rounded">verified</span>,
@@ -139,11 +180,11 @@ export function useFlatToolRegistry(): ToolRegistry {
component: null,
view: "security",
description: t("home.validateSignature.desc", "Verify digital signatures and certificates in PDF documents"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.VERIFICATION
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.VERIFICATION
},
// Document Review
"read": {
@@ -152,8 +193,8 @@ export function useFlatToolRegistry(): ToolRegistry {
component: null,
view: "view",
description: t("home.read.desc", "View and annotate PDFs. Highlight text, draw, or insert comments for review and collaboration."),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.DOCUMENT_REVIEW
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.DOCUMENT_REVIEW
},
"change-metadata": {
icon: <span className="material-symbols-rounded">assignment</span>,
@@ -161,8 +202,8 @@ export function useFlatToolRegistry(): ToolRegistry {
component: null,
view: "format",
description: t("home.changeMetadata.desc", "Change/Remove/Add metadata from a PDF document"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.DOCUMENT_REVIEW
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.DOCUMENT_REVIEW
},
// Page Formatting
@@ -172,8 +213,8 @@ export function useFlatToolRegistry(): ToolRegistry {
component: null,
view: "format",
description: t("home.crop.desc", "Crop a PDF to reduce its size (maintains text!)"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.PAGE_FORMATTING
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING
},
"rotate": {
icon: <span className="material-symbols-rounded">rotate_right</span>,
@@ -181,8 +222,8 @@ export function useFlatToolRegistry(): ToolRegistry {
component: null,
view: "format",
description: t("home.rotate.desc", "Easily rotate your PDFs."),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.PAGE_FORMATTING
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING
},
"splitPdf": {
icon: <span className="material-symbols-rounded">content_cut</span>,
@@ -190,8 +231,10 @@ export function useFlatToolRegistry(): ToolRegistry {
component: SplitPdfPanel,
view: "split",
description: t("home.split.desc", "Split PDFs into multiple documents"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.PAGE_FORMATTING
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING,
operationConfig: splitOperationConfig,
settingsComponent: SplitSettings
},
"reorganize-pages": {
icon: <span className="material-symbols-rounded">move_down</span>,
@@ -199,8 +242,8 @@ export function useFlatToolRegistry(): ToolRegistry {
component: null,
view: "pageEditor",
description: t("home.reorganizePages.desc", "Rearrange, duplicate, or delete PDF pages with visual drag-and-drop control."),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.PAGE_FORMATTING
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING
},
"adjust-page-size-scale": {
icon: <span className="material-symbols-rounded">crop_free</span>,
@@ -208,17 +251,17 @@ export function useFlatToolRegistry(): ToolRegistry {
component: null,
view: "format",
description: t("home.scalePages.desc", "Change the size/scale of a page and/or its contents."),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.PAGE_FORMATTING
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING
},
"add-page-numbers": {
"addPageNumbers": {
icon: <span className="material-symbols-rounded">123</span>,
name: t("home.add-page-numbers.title", "Add Page Numbers"),
name: t("home.addPageNumbers.title", "Add Page Numbers"),
component: null,
view: "format",
description: t("home.add-page-numbers.desc", "Add Page numbers throughout a document in a set location"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.PAGE_FORMATTING
description: t("home.addPageNumbers.desc", "Add Page numbers throughout a document in a set location"),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING
},
"multi-page-layout": {
icon: <span className="material-symbols-rounded">dashboard</span>,
@@ -226,17 +269,20 @@ export function useFlatToolRegistry(): ToolRegistry {
component: null,
view: "format",
description: t("home.pageLayout.desc", "Merge multiple pages of a PDF document into a single page"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.PAGE_FORMATTING
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING
},
"single-large-page": {
icon: <span className="material-symbols-rounded">looks_one</span>,
name: t("home.PdfToSinglePage.title", "PDF to Single Large Page"),
component: null,
name: t("home.pdfToSinglePage.title", "PDF to Single Large Page"),
component: SingleLargePage,
view: "format",
description: t("home.PdfToSinglePage.desc", "Merges all PDF pages into one large single page"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.PAGE_FORMATTING
description: t("home.pdfToSinglePage.desc", "Merges all PDF pages into one large single page"),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING,
maxFiles: -1,
endpoints: ["pdf-to-single-page"],
operationConfig: singleLargePageOperationConfig
},
"add-attachments": {
icon: <span className="material-symbols-rounded">attachment</span>,
@@ -244,21 +290,21 @@ export function useFlatToolRegistry(): ToolRegistry {
component: null,
view: "format",
description: t("home.attachments.desc", "Add or remove embedded files (attachments) to/from a PDF"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.PAGE_FORMATTING,
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING,
},
// Extraction
"extract-pages": {
"extractPages": {
icon: <span className="material-symbols-rounded">upload</span>,
name: t("home.extractPage.title", "Extract Pages"),
name: t("home.extractPages.title", "Extract Pages"),
component: null,
view: "extract",
description: t("home.extractPage.desc", "Extract specific pages from a PDF document"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.EXTRACTION
description: t("home.extractPages.desc", "Extract specific pages from a PDF document"),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.EXTRACTION
},
"extract-images": {
icon: <span className="material-symbols-rounded">filter</span>,
@@ -266,23 +312,21 @@ export function useFlatToolRegistry(): ToolRegistry {
component: null,
view: "extract",
description: t("home.extractImages.desc", "Extract images from PDF documents"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.EXTRACTION
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.EXTRACTION
},
// Removal
"remove": {
"removePages": {
icon: <span className="material-symbols-rounded">delete</span>,
name: t("home.removePages.title", "Remove Pages"),
component: DeletePages,
component: null,
view: "remove",
description: t("home.removePages.desc", "Remove specific pages from a PDF document"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.REMOVAL,
maxFiles: -1,
endpoints: ["remove-pages"]
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.REMOVAL
},
"remove-blank-pages": {
icon: <span className="material-symbols-rounded">scan_delete</span>,
@@ -290,8 +334,8 @@ export function useFlatToolRegistry(): ToolRegistry {
component: null,
view: "remove",
description: t("home.removeBlanks.desc", "Remove blank pages from PDF documents"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.REMOVAL
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.REMOVAL
},
"remove-annotations": {
icon: <span className="material-symbols-rounded">thread_unread</span>,
@@ -299,8 +343,8 @@ export function useFlatToolRegistry(): ToolRegistry {
component: null,
view: "remove",
description: t("home.removeAnnotations.desc", "Remove annotations and comments from PDF documents"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.REMOVAL
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.REMOVAL
},
"remove-image": {
icon: <span className="material-symbols-rounded">remove_selection</span>,
@@ -308,8 +352,8 @@ export function useFlatToolRegistry(): ToolRegistry {
component: null,
view: "format",
description: t("home.removeImagePdf.desc", "Remove images from PDF documents"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.REMOVAL
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.REMOVAL
},
"remove-password": {
icon: <span className="material-symbols-rounded">lock_open_right</span>,
@@ -317,20 +361,24 @@ export function useFlatToolRegistry(): ToolRegistry {
component: RemovePassword,
view: "security",
description: t("home.removePassword.desc", "Remove password protection from PDF documents"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.REMOVAL,
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.REMOVAL,
endpoints: ["remove-password"],
maxFiles: -1,
operationConfig: removePasswordOperationConfig,
settingsComponent: RemovePasswordSettings
},
"remove-certificate-sign": {
icon: <span className="material-symbols-rounded">remove_moderator</span>,
name: t("home.removeCertSign.title", "Remove Certificate Signatures"),
component: null,
name: t("home.removeCertSign.title", "Remove Certificate Sign"),
component: RemoveCertificateSign,
view: "security",
description: t("home.removeCertSign.desc", "Remove digital signatures from PDF documents"),
category: ToolCategory.STANDARD_TOOLS,
subcategory: SubcategoryId.REMOVAL
description: t("home.removeCertSign.desc", "Remove digital signature from PDF documents"),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.REMOVAL,
maxFiles: -1,
endpoints: ["remove-certificate-sign"],
operationConfig: removeCertificateSignOperationConfig
},
@@ -339,11 +387,13 @@ export function useFlatToolRegistry(): ToolRegistry {
"automate": {
icon: <span className="material-symbols-rounded">automation</span>,
name: t("home.automate.title", "Automate"),
component: null,
component: React.lazy(() => import('../tools/Automate')),
view: "format",
description: t("home.automate.desc", "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks."),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.AUTOMATION
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.AUTOMATION,
maxFiles: -1,
endpoints: ["handleData"]
},
"auto-rename-pdf-file": {
icon: <span className="material-symbols-rounded">match_word</span>,
@@ -351,8 +401,8 @@ export function useFlatToolRegistry(): ToolRegistry {
component: null,
view: "format",
description: t("home.auto-rename.desc", "Automatically rename PDF files based on their content"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.AUTOMATION
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.AUTOMATION
},
"auto-split-pages": {
icon: <span className="material-symbols-rounded">split_scene_right</span>,
@@ -360,8 +410,8 @@ export function useFlatToolRegistry(): ToolRegistry {
component: null,
view: "format",
description: t("home.autoSplitPDF.desc", "Automatically split PDF pages based on content detection"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.AUTOMATION
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.AUTOMATION
},
"auto-split-by-size-count": {
icon: <span className="material-symbols-rounded">content_cut</span>,
@@ -369,21 +419,21 @@ export function useFlatToolRegistry(): ToolRegistry {
component: null,
view: "format",
description: t("home.autoSizeSplitPDF.desc", "Automatically split PDFs by file size or page count"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.AUTOMATION
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.AUTOMATION
},
// Advanced Formatting
"adjust-colors-contrast": {
"adjustContrast": {
icon: <span className="material-symbols-rounded">palette</span>,
name: t("home.adjust-contrast.title", "Adjust Colors/Contrast"),
name: t("home.adjustContrast.title", "Adjust Colors/Contrast"),
component: null,
view: "format",
description: t("home.adjust-contrast.desc", "Adjust colors and contrast of PDF documents"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.ADVANCED_FORMATTING
description: t("home.adjustContrast.desc", "Adjust colors and contrast of PDF documents"),
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING
},
"repair": {
icon: <span className="material-symbols-rounded">build</span>,
@@ -391,10 +441,12 @@ export function useFlatToolRegistry(): ToolRegistry {
component: Repair,
view: "format",
description: t("home.repair.desc", "Repair corrupted or damaged PDF files"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.ADVANCED_FORMATTING,
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
maxFiles: -1,
endpoints: ["repair"]
endpoints: ["repair"],
operationConfig: repairOperationConfig,
settingsComponent: RepairSettings
},
"detect-split-scanned-photos": {
icon: <span className="material-symbols-rounded">scanner</span>,
@@ -402,8 +454,8 @@ export function useFlatToolRegistry(): ToolRegistry {
component: null,
view: "format",
description: t("home.ScannerImageSplit.desc", "Detect and split scanned photos into separate pages"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.ADVANCED_FORMATTING
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING
},
"overlay-pdfs": {
icon: <span className="material-symbols-rounded">layers</span>,
@@ -411,8 +463,8 @@ export function useFlatToolRegistry(): ToolRegistry {
component: null,
view: "format",
description: t("home.overlay-pdfs.desc", "Overlay one PDF on top of another"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.ADVANCED_FORMATTING
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING
},
"replace-and-invert-color": {
icon: <span className="material-symbols-rounded">format_color_fill</span>,
@@ -420,8 +472,8 @@ export function useFlatToolRegistry(): ToolRegistry {
component: null,
view: "format",
description: t("home.replaceColorPdf.desc", "Replace or invert colors in PDF documents"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.ADVANCED_FORMATTING
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING
},
"add-image": {
icon: <span className="material-symbols-rounded">image</span>,
@@ -429,8 +481,8 @@ export function useFlatToolRegistry(): ToolRegistry {
component: null,
view: "format",
description: t("home.addImage.desc", "Add images to PDF documents"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.ADVANCED_FORMATTING
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING
},
"edit-table-of-contents": {
icon: <span className="material-symbols-rounded">bookmark_add</span>,
@@ -438,8 +490,8 @@ export function useFlatToolRegistry(): ToolRegistry {
component: null,
view: "format",
description: t("home.editTableOfContents.desc", "Add or edit bookmarks and table of contents in PDF documents"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.ADVANCED_FORMATTING
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING
},
"scanner-effect": {
icon: <span className="material-symbols-rounded">scanner</span>,
@@ -447,8 +499,8 @@ export function useFlatToolRegistry(): ToolRegistry {
component: null,
view: "format",
description: t("home.fakeScan.desc", "Create a PDF that looks like it was scanned"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.ADVANCED_FORMATTING
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING
},
@@ -460,8 +512,8 @@ export function useFlatToolRegistry(): ToolRegistry {
component: null,
view: "extract",
description: t("home.showJS.desc", "Extract and display JavaScript code from PDF documents"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.DEVELOPER_TOOLS
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.DEVELOPER_TOOLS
},
"dev-api": {
icon: <span className="material-symbols-rounded" style={{ color: '#2F7BF6' }}>open_in_new</span>,
@@ -469,8 +521,8 @@ export function useFlatToolRegistry(): ToolRegistry {
component: null,
view: "external",
description: t("home.devApi.desc", "Link to API documentation"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.DEVELOPER_TOOLS,
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.DEVELOPER_TOOLS,
link: "https://stirlingpdf.io/swagger-ui/5.21.0/index.html"
},
"dev-folder-scanning": {
@@ -479,8 +531,8 @@ export function useFlatToolRegistry(): ToolRegistry {
component: null,
view: "external",
description: t("home.devFolderScanning.desc", "Link to automated folder scanning guide"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.DEVELOPER_TOOLS,
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.DEVELOPER_TOOLS,
link: "https://docs.stirlingpdf.com/Advanced%20Configuration/Folder%20Scanning/"
},
"dev-sso-guide": {
@@ -489,8 +541,8 @@ export function useFlatToolRegistry(): ToolRegistry {
component: null,
view: "external",
description: t("home.devSsoGuide.desc", "Link to SSO guide"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.DEVELOPER_TOOLS,
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.DEVELOPER_TOOLS,
link: "https://docs.stirlingpdf.com/Advanced%20Configuration/Single%20Sign-On%20Configuration",
},
"dev-airgapped": {
@@ -499,8 +551,8 @@ export function useFlatToolRegistry(): ToolRegistry {
component: null,
view: "external",
description: t("home.devAirgapped.desc", "Link to air-gapped setup guide"),
category: ToolCategory.ADVANCED_TOOLS,
subcategory: SubcategoryId.DEVELOPER_TOOLS,
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.DEVELOPER_TOOLS,
link: "https://docs.stirlingpdf.com/Pro/#activation"
},
@@ -512,27 +564,29 @@ export function useFlatToolRegistry(): ToolRegistry {
component: null,
view: "format",
description: t("home.compare.desc", "Compare two PDF documents and highlight differences"),
category: ToolCategory.RECOMMENDED_TOOLS,
subcategory: SubcategoryId.GENERAL
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL
},
"compressPdfs": {
"compress": {
icon: <span className="material-symbols-rounded">zoom_in_map</span>,
name: t("home.compressPdfs.title", "Compress"),
name: t("home.compress.title", "Compress"),
component: CompressPdfPanel,
view: "compress",
description: t("home.compressPdfs.desc", "Compress PDFs to reduce their file size."),
category: ToolCategory.RECOMMENDED_TOOLS,
subcategory: SubcategoryId.GENERAL,
maxFiles: -1
description: t("home.compress.desc", "Compress PDFs to reduce their file size."),
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
maxFiles: -1,
operationConfig: compressOperationConfig,
settingsComponent: CompressSettings
},
"convert": {
icon: <span className="material-symbols-rounded">sync_alt</span>,
name: t("home.fileToPDF.title", "Convert"),
name: t("home.convert.title", "Convert"),
component: ConvertPanel,
view: "convert",
description: t("home.fileToPDF.desc", "Convert files to and from PDF format"),
category: ToolCategory.RECOMMENDED_TOOLS,
subcategory: SubcategoryId.GENERAL,
description: t("home.convert.desc", "Convert files to and from PDF format"),
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
maxFiles: -1,
endpoints: [
"pdf-to-img",
@@ -567,7 +621,9 @@ export function useFlatToolRegistry(): ToolRegistry {
"zip",
// Other
"dbf", "fods", "vsd", "vor", "vor3", "vor4", "uop", "pct", "ps", "pdf"
]
],
operationConfig: convertOperationConfig,
settingsComponent: ConvertSettings
},
"mergePdfs": {
icon: <span className="material-symbols-rounded">library_add</span>,
@@ -575,8 +631,8 @@ export function useFlatToolRegistry(): ToolRegistry {
component: null,
view: "merge",
description: t("home.merge.desc", "Merge multiple PDFs into a single document"),
category: ToolCategory.RECOMMENDED_TOOLS,
subcategory: SubcategoryId.GENERAL,
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
maxFiles: -1
},
"multi-tool": {
@@ -585,8 +641,8 @@ export function useFlatToolRegistry(): ToolRegistry {
component: null,
view: "pageEditor",
description: t("home.multiTool.desc", "Use multiple tools on a single PDF document"),
category: ToolCategory.RECOMMENDED_TOOLS,
subcategory: SubcategoryId.GENERAL,
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
maxFiles: -1
},
"ocr": {
@@ -595,9 +651,11 @@ export function useFlatToolRegistry(): ToolRegistry {
component: OCRPanel,
view: "convert",
description: t("home.ocr.desc", "Extract text from scanned PDFs using Optical Character Recognition"),
category: ToolCategory.RECOMMENDED_TOOLS,
subcategory: SubcategoryId.GENERAL,
maxFiles: -1
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
maxFiles: -1,
operationConfig: ocrOperationConfig,
settingsComponent: OCRSettings
},
"redact": {
icon: <span className="material-symbols-rounded">visibility_off</span>,
@@ -605,8 +663,21 @@ export function useFlatToolRegistry(): ToolRegistry {
component: null,
view: "redact",
description: t("home.redact.desc", "Permanently remove sensitive information from PDF documents"),
category: ToolCategory.RECOMMENDED_TOOLS,
subcategory: SubcategoryId.GENERAL
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL
},
};
}
};
if (showPlaceholderTools) {
return allTools;
} else {
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
}
-1
View File
@@ -1,6 +1,5 @@
declare module "../tools/Split";
declare module "../tools/Compress";
declare module "../tools/Merge";
declare module "../components/PageEditor";
declare module "../components/Viewer";
declare module "*.js";
@@ -26,7 +26,7 @@ import { ToolOperationConfig, ToolOperationHook, useToolOperation } from '../sha
describe('useAddPasswordOperation', () => {
const mockUseToolOperation = vi.mocked(useToolOperation);
const getToolConfig = (): ToolOperationConfig<AddPasswordFullParameters> => mockUseToolOperation.mock.calls[0][0];
const getToolConfig = (): ToolOperationConfig<AddPasswordFullParameters> => mockUseToolOperation.mock.calls[0][0] as ToolOperationConfig<AddPasswordFullParameters>;
const mockToolOperationReturn: ToolOperationHook<unknown> = {
files: [],
@@ -1,30 +1,45 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { AddPasswordFullParameters } from './useAddPasswordParameters';
import { AddPasswordFullParameters, defaultParameters } from './useAddPasswordParameters';
import { defaultParameters as permissionsDefaults } from '../changePermissions/useChangePermissionsParameters';
import { getFormData } from '../changePermissions/useChangePermissionsOperation';
// Static function that can be used by both the hook and automation executor
export const buildAddPasswordFormData = (parameters: AddPasswordFullParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("password", parameters.password);
formData.append("ownerPassword", parameters.ownerPassword);
formData.append("keyLength", parameters.keyLength.toString());
getFormData(parameters.permissions).forEach(([key, value]) => {
formData.append(key, value);
});
return formData;
};
// Full default parameters including permissions for automation
const fullDefaultParameters: AddPasswordFullParameters = {
...defaultParameters,
permissions: permissionsDefaults,
};
// Static configuration object
export const addPasswordOperationConfig = {
operationType: 'addPassword',
endpoint: '/api/v1/security/add-password',
buildFormData: buildAddPasswordFormData,
filePrefix: 'encrypted_', // Will be overridden in hook with translation
multiFileEndpoint: false,
defaultParameters: fullDefaultParameters,
} as const;
export const useAddPasswordOperation = () => {
const { t } = useTranslation();
const buildFormData = (parameters: AddPasswordFullParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("password", parameters.password);
formData.append("ownerPassword", parameters.ownerPassword);
formData.append("keyLength", parameters.keyLength.toString());
getFormData(parameters.permissions).forEach(([key, value]) => {
formData.append(key, value);
});
return formData;
};
return useToolOperation<AddPasswordFullParameters>({
operationType: 'addPassword',
endpoint: '/api/v1/security/add-password',
buildFormData,
...addPasswordOperationConfig,
filePrefix: t('addPassword.filenamePrefix', 'encrypted') + '_',
multiFileEndpoint: false,
getErrorMessage: createStandardErrorHandler(t('addPassword.error.failed', 'An error occurred while encrypting the PDF.'))
});
};
@@ -1,7 +1,8 @@
import { useState } from 'react';
import { ChangePermissionsParameters, ChangePermissionsParametersHook, useChangePermissionsParameters } from '../changePermissions/useChangePermissionsParameters';
import { BaseParameters } from '../../../types/parameters';
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
export interface AddPasswordParameters {
export interface AddPasswordParameters extends BaseParameters {
password: string;
ownerPassword: string;
keyLength: number;
@@ -11,14 +12,9 @@ export interface AddPasswordFullParameters extends AddPasswordParameters {
permissions: ChangePermissionsParameters;
}
export interface AddPasswordParametersHook {
export interface AddPasswordParametersHook extends BaseParametersHook<AddPasswordParameters> {
fullParameters: AddPasswordFullParameters;
parameters: AddPasswordParameters;
permissions: ChangePermissionsParametersHook;
updateParameter: <K extends keyof AddPasswordParameters>(parameter: K, value: AddPasswordParameters[K]) => void;
resetParameters: () => void;
validateParameters: () => boolean;
getEndpointName: () => string;
}
export const defaultParameters: AddPasswordParameters = {
@@ -28,42 +24,31 @@ export const defaultParameters: AddPasswordParameters = {
};
export const useAddPasswordParameters = (): AddPasswordParametersHook => {
const [parameters, setParameters] = useState<AddPasswordParameters>(defaultParameters);
const permissions = useChangePermissionsParameters();
const baseHook = useBaseParameters({
defaultParameters,
endpointName: 'add-password',
validateFn: () => {
// No required parameters for Add Password. Defer to permissions validation.
return permissions.validateParameters();
},
});
const fullParameters: AddPasswordFullParameters = {
...parameters,
...baseHook.parameters,
permissions: permissions.parameters,
};
const updateParameter = <K extends keyof AddPasswordParameters>(parameter: K, value: AddPasswordParameters[K]) => {
setParameters(prev => ({
...prev,
[parameter]: value,
})
);
};
const resetParameters = () => {
setParameters(defaultParameters);
baseHook.resetParameters();
permissions.resetParameters();
};
const validateParameters = () => {
// No required parameters for Add Password. Defer to permissions validation.
return permissions.validateParameters();
};
const getEndpointName = () => {
return 'add-password';
};
return {
...baseHook,
fullParameters,
parameters,
permissions,
updateParameter,
resetParameters,
validateParameters,
getEndpointName,
};
};
@@ -1,9 +1,10 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { AddWatermarkParameters } from './useAddWatermarkParameters';
import { AddWatermarkParameters, defaultParameters } from './useAddWatermarkParameters';
const buildFormData = (parameters: AddWatermarkParameters, file: File): FormData => {
// Static function that can be used by both the hook and automation executor
export const buildAddWatermarkFormData = (parameters: AddWatermarkParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
@@ -32,15 +33,22 @@ const buildFormData = (parameters: AddWatermarkParameters, file: File): FormData
return formData;
};
// Static configuration object
export const addWatermarkOperationConfig = {
operationType: 'watermark',
endpoint: '/api/v1/security/add-watermark',
buildFormData: buildAddWatermarkFormData,
filePrefix: 'watermarked_', // Will be overridden in hook with translation
multiFileEndpoint: false,
defaultParameters,
} as const;
export const useAddWatermarkOperation = () => {
const { t } = useTranslation();
return useToolOperation<AddWatermarkParameters>({
operationType: 'watermark',
endpoint: '/api/v1/security/add-watermark',
buildFormData,
...addWatermarkOperationConfig,
filePrefix: t('watermark.filenamePrefix', 'watermarked') + '_',
multiFileEndpoint: false, // Individual API calls per file
getErrorMessage: createStandardErrorHandler(t('watermark.error.failed', 'An error occurred while adding watermark to the PDF.'))
});
};
@@ -1,7 +1,7 @@
import { useState, useCallback } from 'react';
import { defaultWatermarkParameters } from '../../../constants/addWatermarkConstants';
import { BaseParameters } from '../../../types/parameters';
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
export interface AddWatermarkParameters {
export interface AddWatermarkParameters extends BaseParameters {
watermarkType?: 'text' | 'image';
watermarkText: string;
watermarkImage?: File;
@@ -15,36 +15,35 @@ export interface AddWatermarkParameters {
convertPDFToImage: boolean;
}
export const defaultParameters: AddWatermarkParameters = {
watermarkType: undefined,
watermarkText: '',
fontSize: 12,
rotation: 0,
opacity: 50,
widthSpacer: 50,
heightSpacer: 50,
alphabet: 'roman',
customColor: '#d3d3d3',
convertPDFToImage: false
};
export const useAddWatermarkParameters = () => {
const [parameters, setParameters] = useState<AddWatermarkParameters>(defaultWatermarkParameters);
export type AddWatermarkParametersHook = BaseParametersHook<AddWatermarkParameters>;
const updateParameter = useCallback(<K extends keyof AddWatermarkParameters>(
key: K,
value: AddWatermarkParameters[K]
) => {
setParameters(prev => ({ ...prev, [key]: value }));
}, []);
export const useAddWatermarkParameters = (): AddWatermarkParametersHook => {
return useBaseParameters({
defaultParameters: defaultParameters,
endpointName: 'add-watermark',
validateFn: (params): boolean => {
if (!params.watermarkType) {
return false;
}
if (params.watermarkType === 'text') {
return params.watermarkText.trim().length > 0;
} else {
return params.watermarkImage !== undefined;
}
},
});
};
const resetParameters = useCallback(() => {
setParameters(defaultWatermarkParameters);
}, []);
const validateParameters = useCallback((): boolean => {
if (!parameters.watermarkType) {
return false;
}
if (parameters.watermarkType === 'text') {
return parameters.watermarkText.trim().length > 0;
} else {
return parameters.watermarkImage !== undefined;
}
}, [parameters]);
return {
parameters,
updateParameter,
resetParameters,
validateParameters
};
};
@@ -0,0 +1,49 @@
import { useToolOperation } from '../shared/useToolOperation';
import { useCallback } from 'react';
import { executeAutomationSequence } from '../../../utils/automationExecutor';
import { useFlatToolRegistry } from '../../../data/useTranslatedToolRegistry';
import { AutomateParameters } from '../../../types/automation';
import { AUTOMATION_CONSTANTS } from '../../../constants/automation';
export function useAutomateOperation() {
const toolRegistry = useFlatToolRegistry();
const customProcessor = useCallback(async (params: AutomateParameters, files: File[]) => {
console.log('🚀 Starting automation execution via customProcessor', { params, files });
if (!params.automationConfig) {
throw new Error('No automation configuration provided');
}
// Execute the automation sequence and return the final results
const finalResults = await executeAutomationSequence(
params.automationConfig!,
files,
toolRegistry,
(stepIndex: number, operationName: string) => {
console.log(`Step ${stepIndex + 1} started: ${operationName}`);
params.onStepStart?.(stepIndex, operationName);
},
(stepIndex: number, resultFiles: File[]) => {
console.log(`Step ${stepIndex + 1} completed with ${resultFiles.length} files`);
params.onStepComplete?.(stepIndex, resultFiles);
},
(stepIndex: number, error: string) => {
console.error(`Step ${stepIndex + 1} failed:`, error);
params.onStepError?.(stepIndex, error);
throw new Error(`Automation step ${stepIndex + 1} failed: ${error}`);
}
);
console.log(`✅ Automation completed, returning ${finalResults.length} files`);
return finalResults;
}, [toolRegistry]);
return useToolOperation<AutomateParameters>({
operationType: 'automate',
endpoint: '/api/v1/pipeline/handleData', // Not used with customProcessor
buildFormData: () => new FormData(), // Not used with customProcessor
customProcessor,
filePrefix: AUTOMATION_CONSTANTS.FILE_PREFIX
});
}
@@ -0,0 +1,114 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { AutomationTool, AutomationConfig, AutomationMode } from '../../../types/automation';
import { AUTOMATION_CONSTANTS } from '../../../constants/automation';
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
interface UseAutomationFormProps {
mode: AutomationMode;
existingAutomation?: AutomationConfig;
toolRegistry: Record<string, ToolRegistryEntry>;
}
export function useAutomationForm({ mode, existingAutomation, toolRegistry }: UseAutomationFormProps) {
const { t } = useTranslation();
const [automationName, setAutomationName] = useState('');
const [selectedTools, setSelectedTools] = useState<AutomationTool[]>([]);
const getToolName = (operation: string) => {
const tool = toolRegistry?.[operation] as any;
return tool?.name || t(`tools.${operation}.name`, operation);
};
const getToolDefaultParameters = (operation: string): Record<string, any> => {
const config = toolRegistry[operation]?.operationConfig;
if (config?.defaultParameters) {
return { ...config.defaultParameters };
}
return {};
};
// Initialize based on mode and existing automation
useEffect(() => {
if ((mode === AutomationMode.SUGGESTED || mode === AutomationMode.EDIT) && existingAutomation) {
setAutomationName(existingAutomation.name || '');
const operations = existingAutomation.operations || [];
const tools = operations.map((op, index) => {
const operation = typeof op === 'string' ? op : op.operation;
return {
id: `${operation}-${Date.now()}-${index}`,
operation: operation,
name: getToolName(operation),
configured: mode === AutomationMode.EDIT ? true : false,
parameters: typeof op === 'object' ? op.parameters || {} : {}
};
});
setSelectedTools(tools);
} else if (mode === AutomationMode.CREATE && selectedTools.length === 0) {
// Initialize with default empty tools for new automation
const defaultTools = Array.from({ length: AUTOMATION_CONSTANTS.DEFAULT_TOOL_COUNT }, (_, index) => ({
id: `tool-${index + 1}-${Date.now()}`,
operation: '',
name: t('automate.creation.tools.selectTool', 'Select a tool...'),
configured: false,
parameters: {}
}));
setSelectedTools(defaultTools);
}
}, [mode, existingAutomation, selectedTools.length, t, getToolName]);
const addTool = (operation: string) => {
const newTool: AutomationTool = {
id: `${operation}-${Date.now()}`,
operation,
name: getToolName(operation),
configured: false,
parameters: getToolDefaultParameters(operation)
};
setSelectedTools([...selectedTools, newTool]);
};
const removeTool = (index: number) => {
if (selectedTools.length <= AUTOMATION_CONSTANTS.MIN_TOOL_COUNT) return;
setSelectedTools(selectedTools.filter((_, i) => i !== index));
};
const updateTool = (index: number, updates: Partial<AutomationTool>) => {
const updatedTools = [...selectedTools];
updatedTools[index] = { ...updatedTools[index], ...updates };
setSelectedTools(updatedTools);
};
const hasUnsavedChanges = () => {
return (
automationName.trim() !== '' ||
selectedTools.some(tool => tool.operation !== '' || tool.configured)
);
};
const canSaveAutomation = () => {
return (
automationName.trim() !== '' &&
selectedTools.length > 0 &&
selectedTools.every(tool => tool.configured && tool.operation !== '')
);
};
return {
automationName,
setAutomationName,
selectedTools,
setSelectedTools,
addTool,
removeTool,
updateTool,
hasUnsavedChanges,
canSaveAutomation,
getToolName,
getToolDefaultParameters
};
}
@@ -0,0 +1,55 @@
import { useState, useEffect, useCallback } from 'react';
import { AutomationConfig } from '../../../services/automationStorage';
export interface SavedAutomation extends AutomationConfig {}
export function useSavedAutomations() {
const [savedAutomations, setSavedAutomations] = useState<SavedAutomation[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const loadSavedAutomations = useCallback(async () => {
try {
setLoading(true);
setError(null);
const { automationStorage } = await import('../../../services/automationStorage');
const automations = await automationStorage.getAllAutomations();
setSavedAutomations(automations);
} catch (err) {
console.error('Error loading saved automations:', err);
setError(err as Error);
setSavedAutomations([]);
} finally {
setLoading(false);
}
}, []);
const refreshAutomations = useCallback(() => {
loadSavedAutomations();
}, [loadSavedAutomations]);
const deleteAutomation = useCallback(async (id: string) => {
try {
const { automationStorage } = await import('../../../services/automationStorage');
await automationStorage.deleteAutomation(id);
// Refresh the list after deletion
refreshAutomations();
} catch (err) {
console.error('Error deleting automation:', err);
throw err;
}
}, [refreshAutomations]);
// Load automations on mount
useEffect(() => {
loadSavedAutomations();
}, [loadSavedAutomations]);
return {
savedAutomations,
loading,
error,
refreshAutomations,
deleteAutomation
};
}
@@ -0,0 +1,53 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import StarIcon from '@mui/icons-material/Star';
import { SuggestedAutomation } from '../../../types/automation';
export function useSuggestedAutomations(): SuggestedAutomation[] {
const { t } = useTranslation();
const suggestedAutomations = useMemo<SuggestedAutomation[]>(() => {
const now = new Date().toISOString();
return [
{
id: "compress-and-merge",
name: t("automation.suggested.compressAndMerge", "Compress & Merge"),
description: t("automation.suggested.compressAndMergeDesc", "Compress PDFs and merge them into one file"),
operations: [
{ operation: "compress", parameters: {} },
{ operation: "merge", parameters: {} }
],
createdAt: now,
updatedAt: now,
icon: StarIcon,
},
{
id: "ocr-and-convert",
name: t("automation.suggested.ocrAndConvert", "OCR & Convert"),
description: t("automation.suggested.ocrAndConvertDesc", "Extract text via OCR and convert to different format"),
operations: [
{ operation: "ocr", parameters: {} },
{ operation: "convert", parameters: {} }
],
createdAt: now,
updatedAt: now,
icon: StarIcon,
},
{
id: "secure-workflow",
name: t("automation.suggested.secureWorkflow", "Secure Workflow"),
description: t("automation.suggested.secureWorkflowDesc", "Sanitize, add password, and set permissions"),
operations: [
{ operation: "sanitize", parameters: {} },
{ operation: "addPassword", parameters: {} },
{ operation: "changePermissions", parameters: {} }
],
createdAt: now,
updatedAt: now,
icon: StarIcon,
},
];
}, [t]);
return suggestedAutomations;
}
@@ -25,7 +25,7 @@ import { ToolOperationConfig, ToolOperationHook, useToolOperation } from '../sha
describe('useChangePermissionsOperation', () => {
const mockUseToolOperation = vi.mocked(useToolOperation);
const getToolConfig = (): ToolOperationConfig<ChangePermissionsParameters> => mockUseToolOperation.mock.calls[0][0];
const getToolConfig = (): ToolOperationConfig<ChangePermissionsParameters> => mockUseToolOperation.mock.calls[0][0] as ToolOperationConfig<ChangePermissionsParameters>;
const mockToolOperationReturn: ToolOperationHook<unknown> = {
files: [],
@@ -109,7 +109,7 @@ describe('useChangePermissionsOperation', () => {
{ property: 'multiFileEndpoint' as const, expectedValue: false },
{ property: 'endpoint' as const, expectedValue: '/api/v1/security/add-password' },
{ property: 'filePrefix' as const, expectedValue: 'permissions_' },
{ property: 'operationType' as const, expectedValue: 'changePermissions' }
{ property: 'operationType' as const, expectedValue: 'change-permissions' }
])('should configure $property correctly', ({ property, expectedValue }) => {
renderHook(() => useChangePermissionsOperation());
@@ -1,7 +1,7 @@
import { useTranslation } from 'react-i18next';
import { useToolOperation } from '../shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import type { ChangePermissionsParameters } from './useChangePermissionsParameters';
import { ChangePermissionsParameters, defaultParameters } from './useChangePermissionsParameters';
export const getFormData = ((parameters: ChangePermissionsParameters) =>
Object.entries(parameters).map(([key, value]) =>
@@ -9,27 +9,34 @@ export const getFormData = ((parameters: ChangePermissionsParameters) =>
) as string[][]
);
// Static function that can be used by both the hook and automation executor
export const buildChangePermissionsFormData = (parameters: ChangePermissionsParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
// Add all permission parameters
getFormData(parameters).forEach(([key, value]) => {
formData.append(key, value);
});
return formData;
};
// Static configuration object
export const changePermissionsOperationConfig = {
operationType: 'change-permissions',
endpoint: '/api/v1/security/add-password', // Change Permissions is a fake endpoint for the Add Password tool
buildFormData: buildChangePermissionsFormData,
filePrefix: 'permissions_',
multiFileEndpoint: false,
defaultParameters,
} as const;
export const useChangePermissionsOperation = () => {
const { t } = useTranslation();
const buildFormData = (parameters: ChangePermissionsParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
// Add all permission parameters
getFormData(parameters).forEach(([key, value]) => {
formData.append(key, value);
});
return formData;
};
return useToolOperation({
operationType: 'changePermissions',
endpoint: '/api/v1/security/add-password', // Change Permissions is a fake endpoint for the Add Password tool
buildFormData,
filePrefix: 'permissions_',
multiFileEndpoint: false,
...changePermissionsOperationConfig,
getErrorMessage: createStandardErrorHandler(
t('changePermissions.error.failed', 'An error occurred while changing PDF permissions.')
)
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { BaseParameters } from '../../../types/parameters';
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
export interface ChangePermissionsParameters {
export interface ChangePermissionsParameters extends BaseParameters {
preventAssembly: boolean;
preventExtractContent: boolean;
preventExtractForAccessibility: boolean;
@@ -11,14 +12,6 @@ export interface ChangePermissionsParameters {
preventPrintingFaithful: boolean;
}
export interface ChangePermissionsParametersHook {
parameters: ChangePermissionsParameters;
updateParameter: (parameter: keyof ChangePermissionsParameters, value: boolean) => void;
resetParameters: () => void;
validateParameters: () => boolean;
getEndpointName: () => string;
}
export const defaultParameters: ChangePermissionsParameters = {
preventAssembly: false,
preventExtractContent: false,
@@ -30,35 +23,11 @@ export const defaultParameters: ChangePermissionsParameters = {
preventPrintingFaithful: false,
};
export type ChangePermissionsParametersHook = BaseParametersHook<ChangePermissionsParameters>;
export const useChangePermissionsParameters = (): ChangePermissionsParametersHook => {
const [parameters, setParameters] = useState<ChangePermissionsParameters>(defaultParameters);
const updateParameter = <K extends keyof ChangePermissionsParameters>(parameter: K, value: ChangePermissionsParameters[K]) => {
setParameters(prev => ({
...prev,
[parameter]: value,
})
);
};
const resetParameters = () => {
setParameters(defaultParameters);
};
const validateParameters = () => {
// Always valid - any combination of permissions is allowed
return true;
};
const getEndpointName = () => {
return 'add-password'; // Change Permissions is a fake endpoint for the Add Password tool
};
return {
parameters,
updateParameter,
resetParameters,
validateParameters,
getEndpointName,
};
return useBaseParameters({
defaultParameters,
endpointName: 'add-password', // Change Permissions is a fake endpoint for the Add Password tool
});
};

Some files were not shown because too many files have changed in this diff Show More