Compare commits

...
Author SHA1 Message Date
Anthony Stirling b044c0dc98 bottom button test 2025-10-01 13:12:05 +01:00
Anthony StirlingandClaude 0c32dd53b2 fix: update mobile header branding to display icon and text logos
- Display both icon-only and text logos side by side in mobile header
- Use correct file paths: StirlingPDFLogoWhiteText/BlackText for text logos
- Add proper gap and sizing for both logo elements
- Remove single text span in favor of dual logo approach

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 12:37:41 +01:00
Anthony StirlingandGitHub f4b2f2d5b8 Merge branch 'V2' into mobileSupport 2025-10-01 12:23:03 +01:00
Anthony StirlingandClaude 5d77373ef8 fix: improve mobile navigation and interaction
- Fix mobile view toggle buttons not working when clicked (use offsetWidth instead of clientWidth)
- Add flag to prevent scroll handler interference during programmatic scrolls
- Move Files button from header to bottom navigation bar
- Add bottom navigation bar with All Tools, Automate, and Files buttons
- Add RightRail to mobile workspace view
- Auto-switch to Tools view when clicking All Tools or Automate in mobile
- Style bottom bar buttons as full-width clickable areas with labels

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 12:22:00 +01:00
3dd4a33595 Add page numbers (#4500)
# Description of Changes

Idk why i added a brightness detector for white/black text but hey its
been done now


## 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: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
2025-10-01 12:18:31 +01:00
stirlingbot[bot]GitHubstirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
fe2bfd8739 🌐 [V2] Sync Translations + Update README Progress Table (#4560)
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2025-10-01 00:04:16 +01:00
Anthony StirlingandGitHub a15b0e33d5 test (#4559) 2025-10-01 00:03:26 +01:00
stirlingbot[bot]GitHubstirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
0a242f6a57 🌐 [V2] Sync Translations + Update README Progress Table (#4558)
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2025-09-30 15:53:50 +01:00
Anthony StirlingandGitHub 3c46010155 Lang translation (#4556)
# 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-09-30 14:36:44 +01:00
Anthony Stirling d854497266 Refine mobile header branding 2025-09-30 12:13:15 +01:00
Anthony Stirling 57ab30d1a6 Fix mobile files button click handler 2025-09-30 11:47:40 +01:00
Anthony Stirling 7dbf529a45 feat: add mobile slider layout for home page 2025-09-30 11:36:28 +01:00
02189a67bd refactor(frontend): remove unused React default imports (#4529)
## Description of Changes

- Removed unused `React` default imports across multiple frontend
components.
- Updated imports to only include required React hooks and types (e.g.,
`useState`, `useEffect`, `Suspense`, `createContext`).
- Ensured consistency with React 17+ JSX transform, where default
`React` import is no longer required.
- This cleanup reduces bundle size slightly and aligns code with modern
React best practices.

---

## 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 <74901996+reecebrowne@users.noreply.github.com>
2025-09-29 13:01:09 +01:00
d4985f57d4 style(frontend): standardize semicolons across TS/JS configs and components (#4525)
# Description of Changes

- **What was changed**
- Added missing trailing semicolons across React components, utilities,
tests, and build/test configs to ensure consistent formatting.
- Normalized arrow-function assignments to end with semicolons (e.g.,
`const fn = () => { ... };`).
- Harmonized imports/exports and object literals in configuration files
to terminate statements with semicolons.
  - Updated test setup files and mocks to consistently use semicolons.

- **Why the change was made**
- Aligns the codebase with ESLint/Prettier conventions to prevent
auto-format churn and avoid ASI (automatic semicolon insertion) edge
cases.
- Improves readability and produces cleaner diffs in future
contributions.

---

## 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 <74901996+reecebrowne@users.noreply.github.com>
2025-09-29 12:55:53 +01:00
4ab66fdf14 feat(frontend): refactor ToolStep props handling and children usage (#4524)
# Description of Changes

- Replaced `children` being passed as a prop to `React.createElement`
with proper usage as additional arguments (fixes
`react/no-children-prop` warning).
- Added stricter handling of `isVisible` and `_excludeFromCount` props
with improved variable naming (`stepProps`) for clarity.
- Refactored JSX structure for collapsed/expanded rendering logic to
improve readability.

This change was made to clean up prop handling, remove ESLint warnings,
and make the component more consistent with React best practices.

---

## 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 <74901996+reecebrowne@users.noreply.github.com>
2025-09-29 12:51:42 +01:00
c19abe0da7 refactor(frontend): add display names for forwardRef components (#4523)
# Description of Changes

- Added `displayName` properties to `QuickAccessBar` and `TextInput`
components.
- This improves debugging and React DevTools readability by ensuring
components have clear, identifiable names instead of anonymous
`ForwardRef`.
- Minor formatting cleanup in `QuickAccessBar` for consistency.

---

## 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 <74901996+reecebrowne@users.noreply.github.com>
2025-09-29 12:48:35 +01:00
dd6b7968db refactor(types): deduplicate AutomateParameters definition in automation types (#4522)
# Description of Changes

- Removed duplicate `AutomateParameters` interface from
`frontend/src/types/automation.ts`
- The interface was already defined earlier in the same file, leading to
redundancy
- Keeps type definitions consistent and avoids confusion

---

## 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 <74901996+reecebrowne@users.noreply.github.com>
2025-09-29 12:45:37 +01:00
LudyandGitHub 2228ae7197 ci(frontend): update licenses workflow dependencies and Node.js version (#4520)
# Description of Changes

- Added the workflow file itself
(`.github/workflows/frontend-licenses-update.yml`) to the trigger paths.
- Updated `step-security/harden-runner` from **v2.12.2** → **v2.13.1**.
- Bumped `actions/checkout` from **v4.2.2** → **v5.0.0**.  
- Upgraded `actions/setup-node` from **v4.1.0** (Node.js 18) →
**v5.0.0** (Node.js 22).
- Updated `actions/github-script` from **v7.0.1** → **v8.0.0**.  

These changes modernize the workflow, ensure compatibility with newer
Node.js versions, and keep GitHub Actions up to date.

---

## 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-09-29 12:21:48 +01:00
Reece BrowneandGitHub 30987dcad2 Dockerfile package (#4517) 2025-09-26 20:56:26 +01:00
Reece BrowneandGitHub 43beadbdcb update embedpdf (#4516) 2025-09-26 19:46:31 +01:00
416d79aed3 Feature/v2/sign (#4485)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: James Brunton <james@stirlingpdf.com>
2025-09-26 19:11:03 +01:00
abc0988fdf Feature/v2/reader-and-multitool-navigation (#4514)
Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
2025-09-26 16:29:58 +01:00
c7e0ea5b5b Add React-based remove annotations tool (#4504)
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-26 15:45:51 +01:00
Anthony StirlingandGitHub b35447934e remove tools (#4513) 2025-09-26 15:42:23 +01:00
d1e82eb8f1 add multi page layout tool (#4507)
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2025-09-26 15:41:39 +01:00
7d44cc1a40 Bugfix/V2/remove-timeout-on-fetch (#4510)
Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
2025-09-26 15:36:41 +01:00
EthanHealy01andGitHub 0bdc6466ca add the reorganize pages tool (#4506) 2025-09-26 12:49:18 +01:00
EthanHealy01andGitHub f2a6e95fcf Feature/remove images (#4503) 2025-09-26 12:46:02 +01:00
EthanHealy01andGitHub 0c08764669 add attatchments tool (#4502) 2025-09-26 12:45:31 +01:00
Anthony StirlingandGitHub 9758e871d4 feat: Add React-based extract-images tool (#4501) 2025-09-26 12:45:15 +01:00
Anthony StirlingandGitHub 18fa16f08e Invert colors (#4498) 2025-09-26 12:44:25 +01:00
233b710b78 Convert extract-image-scans to React component (#4505)
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-26 12:38:10 +01:00
Reece BrowneandGitHub d613a4659e Feature/v2/exportpdf (#4487)
# 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-09-26 10:28:09 +01:00
Reece BrowneandGitHub 03f484e0c0 Fix (#4495)
# 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-09-26 10:03:35 +01:00
EthanHealy01andGitHub fd52dc0226 Feature/toasts and error handling (#4496)
# Description of Changes

- Added error handling and toast notifications

---

## 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-09-25 21:03:53 +01:00
EthanHealy01andGitHub 21b1428ab5 Feature/v2/fuzzy tool search (#4482)
# 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

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [x] 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)
- [x] I have performed a self-review of my own code
- [x] 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-09-25 16:32:30 +01:00
Anthony StirlingandGitHub 1219cebd07 Language stuff (#4490)
# 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-09-25 12:50:19 +01:00
195 changed files with 20707 additions and 2073 deletions
+345
View File
@@ -0,0 +1,345 @@
"""
Author: Ludy87
Description: This script processes JSON translation files for localization checks. It compares translation files in a branch with
a reference file to ensure consistency. The script performs two main checks:
1. Verifies that the number of translation keys in the translation files matches the reference file.
2. Ensures that all keys in the translation files are present in the reference file and vice versa.
The script also provides functionality to update the translation files to match the reference file by adding missing keys and
adjusting the format.
Usage:
python check_language_json.py --reference-file <path_to_reference_file> --branch <branch_name> [--actor <actor_name>] [--files <list_of_changed_files>]
"""
# Sample for Windows:
# python .github/scripts/check_language_json.py --reference-file frontend/public/locales/en-GB/translation.json --branch "" --files frontend/public/locales/de-DE/translation.json frontend/public/locales/fr-FR/translation.json
import copy
import glob
import os
import argparse
import re
import json
def find_duplicate_keys(file_path, keys=None, prefix=""):
"""
Identifies duplicate keys in a JSON file (including nested keys).
:param file_path: Path to the JSON file.
:param keys: Dictionary to track keys (used for recursion).
:param prefix: Prefix for nested keys.
:return: List of tuples (key, first_occurrence_path, duplicate_path).
"""
if keys is None:
keys = {}
duplicates = []
with open(file_path, "r", encoding="utf-8") as file:
data = json.load(file)
def process_dict(obj, current_prefix=""):
for key, value in obj.items():
full_key = f"{current_prefix}.{key}" if current_prefix else key
if isinstance(value, dict):
process_dict(value, full_key)
else:
if full_key in keys:
duplicates.append((full_key, keys[full_key], full_key))
else:
keys[full_key] = full_key
process_dict(data, prefix)
return duplicates
# Maximum size for JSON files (e.g., 500 KB)
MAX_FILE_SIZE = 500 * 1024
def parse_json_file(file_path):
"""
Parses a JSON translation file and returns a flat dictionary of all keys.
:param file_path: Path to the JSON file.
:return: Dictionary with flattened keys.
"""
with open(file_path, "r", encoding="utf-8") as file:
data = json.load(file)
def flatten_dict(d, parent_key="", sep="."):
items = {}
for k, v in d.items():
new_key = f"{parent_key}{sep}{k}" if parent_key else k
if isinstance(v, dict):
items.update(flatten_dict(v, new_key, sep=sep))
else:
items[new_key] = v
return items
return flatten_dict(data)
def unflatten_dict(d, sep="."):
"""
Converts a flat dictionary with dot notation keys back to nested dict.
:param d: Flattened dictionary.
:param sep: Separator used in keys.
:return: Nested dictionary.
"""
result = {}
for key, value in d.items():
parts = key.split(sep)
current = result
for part in parts[:-1]:
if part not in current:
current[part] = {}
current = current[part]
current[parts[-1]] = value
return result
def write_json_file(file_path, updated_properties):
"""
Writes updated properties back to the JSON file.
:param file_path: Path to the JSON file.
:param updated_properties: Dictionary of updated properties to write.
"""
nested_data = unflatten_dict(updated_properties)
with open(file_path, "w", encoding="utf-8", newline="\n") as file:
json.dump(nested_data, file, ensure_ascii=False, indent=2)
file.write("\n") # Add trailing newline
def update_missing_keys(reference_file, file_list, branch=""):
"""
Updates missing keys in the translation files based on the reference file.
:param reference_file: Path to the reference JSON file.
:param file_list: List of translation files to update.
:param branch: Branch where the files are located.
"""
reference_properties = parse_json_file(reference_file)
for file_path in file_list:
basename_current_file = os.path.basename(os.path.join(branch, file_path))
if (
basename_current_file == os.path.basename(reference_file)
or not file_path.endswith(".json")
or not os.path.dirname(file_path).endswith("locales")
):
continue
current_properties = parse_json_file(os.path.join(branch, file_path))
updated_properties = {}
for ref_key, ref_value in reference_properties.items():
if ref_key in current_properties:
# Keep the current translation
updated_properties[ref_key] = current_properties[ref_key]
else:
# Add missing key with reference value
updated_properties[ref_key] = ref_value
write_json_file(os.path.join(branch, file_path), updated_properties)
def check_for_missing_keys(reference_file, file_list, branch):
update_missing_keys(reference_file, file_list, branch)
def read_json_keys(file_path):
if os.path.isfile(file_path) and os.path.exists(file_path):
return parse_json_file(file_path)
return {}
def check_for_differences(reference_file, file_list, branch, actor):
reference_branch = branch
basename_reference_file = os.path.basename(reference_file)
report = []
report.append(f"#### 🔄 Reference Branch: `{reference_branch}`")
reference_keys = read_json_keys(reference_file)
has_differences = False
only_reference_file = True
file_arr = file_list
if len(file_list) == 1:
file_arr = file_list[0].split()
base_dir = os.path.abspath(
os.path.join(os.getcwd(), "frontend", "public", "locales")
)
for file_path in file_arr:
file_normpath = os.path.normpath(file_path)
absolute_path = os.path.abspath(file_normpath)
# Verify that file is within the expected directory
if not absolute_path.startswith(base_dir):
raise ValueError(f"Unsafe file found: {file_normpath}")
# Verify file size before processing
if os.path.getsize(os.path.join(branch, file_normpath)) > MAX_FILE_SIZE:
raise ValueError(
f"The file {file_normpath} is too large and could pose a security risk."
)
basename_current_file = os.path.basename(os.path.join(branch, file_normpath))
locale_dir = os.path.basename(os.path.dirname(file_normpath))
if (
basename_current_file == basename_reference_file
and locale_dir == "en-GB"
):
continue
if not file_normpath.endswith(".json") or basename_current_file != "translation.json":
continue
only_reference_file = False
report.append(f"#### 📃 **File Check:** `{locale_dir}/{basename_current_file}`")
current_keys = read_json_keys(os.path.join(branch, file_path))
reference_key_count = len(reference_keys)
current_key_count = len(current_keys)
if reference_key_count != current_key_count:
report.append("")
report.append("1. **Test Status:** ❌ **_Failed_**")
report.append(" - **Issue:**")
has_differences = True
if reference_key_count > current_key_count:
report.append(
f" - **_Mismatched key count_**: {reference_key_count} (reference) vs {current_key_count} (current). Translation keys are missing."
)
elif reference_key_count < current_key_count:
report.append(
f" - **_Too many keys_**: {reference_key_count} (reference) vs {current_key_count} (current). Please verify if there are additional keys that need to be removed."
)
else:
report.append("1. **Test Status:** ✅ **_Passed_**")
# Check for missing or extra keys
current_keys_set = set(current_keys.keys())
reference_keys_set = set(reference_keys.keys())
missing_keys = current_keys_set.difference(reference_keys_set)
extra_keys = reference_keys_set.difference(current_keys_set)
missing_keys_list = list(missing_keys)
extra_keys_list = list(extra_keys)
if missing_keys_list or extra_keys_list:
has_differences = True
missing_keys_str = "`, `".join(missing_keys_list)
extra_keys_str = "`, `".join(extra_keys_list)
report.append("2. **Test Status:** ❌ **_Failed_**")
report.append(" - **Issue:**")
if missing_keys_list:
report.append(
f" - **_Extra keys in `{locale_dir}/{basename_current_file}`_**: `{missing_keys_str}` that are not present in **_`{basename_reference_file}`_**."
)
if extra_keys_list:
report.append(
f" - **_Missing keys in `{locale_dir}/{basename_current_file}`_**: `{extra_keys_str}` that are not present in **_`{basename_reference_file}`_**."
)
else:
report.append("2. **Test Status:** ✅ **_Passed_**")
if find_duplicate_keys(os.path.join(branch, file_normpath)):
has_differences = True
output = "\n".join(
[
f" - `{key}`: first at {first}, duplicate at `{duplicate}`"
for key, first, duplicate in find_duplicate_keys(
os.path.join(branch, file_normpath)
)
]
)
report.append("3. **Test Status:** ❌ **_Failed_**")
report.append(" - **Issue:**")
report.append(" - duplicate entries were found:")
report.append(output)
else:
report.append("3. **Test Status:** ✅ **_Passed_**")
report.append("")
report.append("---")
report.append("")
if has_differences:
report.append("## ❌ Overall Check Status: **_Failed_**")
report.append("")
report.append(
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [en-GB/translation.json](https://github.com/Stirling-Tools/Stirling-PDF/blob/V2/frontend/public/locales/en-GB/translation.json)"
)
else:
report.append("## ✅ Overall Check Status: **_Success_**")
report.append("")
report.append(
f"Thanks @{actor} for your help in keeping the translations up to date."
)
if not only_reference_file:
print("\n".join(report))
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Find missing keys")
parser.add_argument(
"--actor",
required=False,
help="Actor from PR.",
)
parser.add_argument(
"--reference-file",
required=True,
help="Path to the reference file.",
)
parser.add_argument(
"--branch",
type=str,
required=True,
help="Branch name.",
)
parser.add_argument(
"--check-file",
type=str,
required=False,
help="List of changed files, separated by spaces.",
)
parser.add_argument(
"--files",
nargs="+",
required=False,
help="List of changed files, separated by spaces.",
)
args = parser.parse_args()
# Sanitize --actor input to avoid injection attacks
if args.actor:
args.actor = re.sub(r"[^a-zA-Z0-9_\\-]", "", args.actor)
# Sanitize --branch input to avoid injection attacks
if args.branch:
args.branch = re.sub(r"[^a-zA-Z0-9\\-]", "", args.branch)
file_list = args.files
if file_list is None:
if args.check_file:
file_list = [args.check_file]
else:
file_list = glob.glob(
os.path.join(
os.getcwd(),
"frontend",
"public",
"locales",
"*",
"translation.json",
)
)
update_missing_keys(args.reference_file, file_list)
else:
check_for_differences(args.reference_file, file_list, args.branch, args.actor)
@@ -12,6 +12,7 @@ on:
branches:
- V2
paths:
- ".github/workflows/frontend-licenses-update.yml"
- "frontend/package.json"
- "frontend/package-lock.json"
- "frontend/scripts/generate-licenses.js"
@@ -28,12 +29,12 @@ jobs:
repository-projects: write # Required for enabling automerge
steps:
- name: Harden Runner
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- name: Checkout PR head (default)
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
persist-credentials: false
@@ -48,7 +49,7 @@ jobs:
- name: Checkout BASE branch (safe script)
if: github.event_name == 'pull_request'
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ github.event.pull_request.base.sha }}
path: base
@@ -56,9 +57,9 @@ jobs:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: '18'
node-version: '22'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
@@ -114,7 +115,7 @@ jobs:
# PR Event: Check licenses and comment on PR
- name: Delete previous license check comments
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
@@ -167,7 +168,7 @@ jobs:
- name: Comment on PR - License Check Results
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
+118
View File
@@ -0,0 +1,118 @@
name: Sync Files V2
on:
workflow_dispatch:
push:
branches:
- V2
- syncLangTest
paths:
- "build.gradle"
- "README.md"
- "frontend/public/locales/*/translation.json"
- "app/core/src/main/resources/static/3rdPartyLicenses.json"
- "scripts/ignore_translation.toml"
# cancel in-progress jobs if a new job is triggered
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
# or a pull request is updated.
# It helps to save resources and time by ensuring that only the latest commit is built and tested
# This is particularly useful for long-running jobs that may take a while to complete.
# The `group` is set to a combination of the workflow name, event name, and branch name.
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
sync-files:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- name: Setup GitHub App Bot
id: setup-bot
uses: ./.github/actions/setup-bot
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
cache: "pip" # caching pip dependencies
- name: Sync translation JSON files
run: |
python .github/scripts/check_language_json.py --reference-file "frontend/public/locales/en-GB/translation.json" --branch V2
- name: Commit translation files
run: |
git add frontend/public/locales/*/translation.json
git diff --staged --quiet || git commit -m ":memo: Sync translation files" || echo "No changes detected"
- name: Install dependencies
run: pip install --require-hashes -r ./.github/scripts/requirements_sync_readme.txt
- name: Sync README.md
run: |
python scripts/counter_translation_v2.py
- name: Run git add
run: |
git add README.md scripts/ignore_translation.toml
git diff --staged --quiet || git commit -m ":memo: Sync README.md & scripts/ignore_translation.toml" || echo "No changes detected"
- name: Create Pull Request
if: always()
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
with:
token: ${{ steps.setup-bot.outputs.token }}
commit-message: Update files
committer: ${{ steps.setup-bot.outputs.committer }}
author: ${{ steps.setup-bot.outputs.committer }}
signoff: true
branch: sync_readme_v2
base: V2
title: ":globe_with_meridians: [V2] Sync Translations + Update README Progress Table"
body: |
### Description of Changes
This Pull Request was automatically generated to synchronize updates to translation files and documentation for the **V2 branch**. Below are the details of the changes made:
#### **1. Synchronization of Translation Files**
- Updated translation files (`frontend/public/locales/*/translation.json`) to reflect changes in the reference file `en-GB/translation.json`.
- Ensured consistency and synchronization across all supported language files.
- Highlighted any missing or incomplete translations.
#### **2. Update README.md**
- Generated the translation progress table in `README.md`.
- Added a summary of the current translation status for all supported languages.
- Included up-to-date statistics on translation coverage.
#### **Why these changes are necessary**
- Keeps translation files aligned with the latest reference updates.
- Ensures the documentation reflects the current translation progress.
---
Auto-generated by [create-pull-request][1].
[1]: https://github.com/peter-evans/create-pull-request
draft: false
delete-branch: true
labels: github-actions
sign-commits: true
add-paths: |
README.md
frontend/public/locales/*/translation.json
+7
View File
@@ -203,3 +203,10 @@ id_ed25519.pub
# node_modules
node_modules/
# Translation temp files
*_compact.json
*compact*.json
test_batch.json
*.backup.*.json
frontend/public/locales/*/translation.backup*.json
+35 -36
View File
@@ -97,7 +97,6 @@ All documentation available at [https://docs.stirlingpdf.com/](https://docs.stir
# 📖 Get Started
Visit our comprehensive documentation at [docs.stirlingpdf.com](https://docs.stirlingpdf.com) for:
@@ -116,46 +115,46 @@ Stirling-PDF currently supports 40 languages!
| Language | Progress |
| -------------------------------------------- | -------------------------------------- |
| Arabic (العربية) (ar_AR) | ![61%](https://geps.dev/progress/61) |
| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![62%](https://geps.dev/progress/62) |
| Basque (Euskara) (eu_ES) | ![36%](https://geps.dev/progress/36) |
| Bulgarian (Български) (bg_BG) | ![68%](https://geps.dev/progress/68) |
| Catalan (Català) (ca_CA) | ![68%](https://geps.dev/progress/68) |
| Croatian (Hrvatski) (hr_HR) | ![60%](https://geps.dev/progress/60) |
| Czech (Česky) (cs_CZ) | ![70%](https://geps.dev/progress/70) |
| Danish (Dansk) (da_DK) | ![61%](https://geps.dev/progress/61) |
| Dutch (Nederlands) (nl_NL) | ![60%](https://geps.dev/progress/60) |
| Arabic (العربية) (ar_AR) | ![37%](https://geps.dev/progress/37) |
| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![37%](https://geps.dev/progress/37) |
| Basque (Euskara) (eu_ES) | ![21%](https://geps.dev/progress/21) |
| Bulgarian (Български) (bg_BG) | ![40%](https://geps.dev/progress/40) |
| Catalan (Català) (ca_CA) | ![39%](https://geps.dev/progress/39) |
| Croatian (Hrvatski) (hr_HR) | ![36%](https://geps.dev/progress/36) |
| Czech (Česky) (cs_CZ) | ![39%](https://geps.dev/progress/39) |
| Danish (Dansk) (da_DK) | ![35%](https://geps.dev/progress/35) |
| Dutch (Nederlands) (nl_NL) | ![35%](https://geps.dev/progress/35) |
| English (English) (en_GB) | ![100%](https://geps.dev/progress/100) |
| English (US) (en_US) | ![100%](https://geps.dev/progress/100) |
| French (Français) (fr_FR) | ![89%](https://geps.dev/progress/89) |
| French (Français) (fr_FR) | ![42%](https://geps.dev/progress/42) |
| German (Deutsch) (de_DE) | ![98%](https://geps.dev/progress/98) |
| Greek (Ελληνικά) (el_GR) | ![67%](https://geps.dev/progress/67) |
| Hindi (हिंदी) (hi_IN) | ![67%](https://geps.dev/progress/67) |
| Hungarian (Magyar) (hu_HU) | ![99%](https://geps.dev/progress/99) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![62%](https://geps.dev/progress/62) |
| Irish (Gaeilge) (ga_IE) | ![68%](https://geps.dev/progress/68) |
| Greek (Ελληνικά) (el_GR) | ![39%](https://geps.dev/progress/39) |
| Hindi (हिंदी) (hi_IN) | ![40%](https://geps.dev/progress/40) |
| Hungarian (Magyar) (hu_HU) | ![43%](https://geps.dev/progress/43) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![36%](https://geps.dev/progress/36) |
| Irish (Gaeilge) (ga_IE) | ![40%](https://geps.dev/progress/40) |
| Italian (Italiano) (it_IT) | ![98%](https://geps.dev/progress/98) |
| Japanese (日本語) (ja_JP) | ![93%](https://geps.dev/progress/93) |
| Korean (한국어) (ko_KR) | ![67%](https://geps.dev/progress/67) |
| Norwegian (Norsk) (no_NB) | ![66%](https://geps.dev/progress/66) |
| Persian (فارسی) (fa_IR) | ![64%](https://geps.dev/progress/64) |
| Polish (Polski) (pl_PL) | ![72%](https://geps.dev/progress/72) |
| Portuguese (Português) (pt_PT) | ![69%](https://geps.dev/progress/69) |
| Portuguese Brazilian (Português) (pt_BR) | ![76%](https://geps.dev/progress/76) |
| Romanian (Română) (ro_RO) | ![57%](https://geps.dev/progress/57) |
| Russian (Русский) (ru_RU) | ![88%](https://geps.dev/progress/88) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![95%](https://geps.dev/progress/95) |
| Simplified Chinese (简体中文) (zh_CN) | ![93%](https://geps.dev/progress/93) |
| Slovakian (Slovensky) (sk_SK) | ![51%](https://geps.dev/progress/51) |
| Slovenian (Slovenščina) (sl_SI) | ![71%](https://geps.dev/progress/71) |
| Spanish (Español) (es_ES) | ![74%](https://geps.dev/progress/74) |
| Swedish (Svenska) (sv_SE) | ![65%](https://geps.dev/progress/65) |
| Thai (ไทย) (th_TH) | ![59%](https://geps.dev/progress/59) |
| Japanese (日本語) (ja_JP) | ![41%](https://geps.dev/progress/41) |
| Korean (한국어) (ko_KR) | ![40%](https://geps.dev/progress/40) |
| Norwegian (Norsk) (no_NB) | ![37%](https://geps.dev/progress/37) |
| Persian (فارسی) (fa_IR) | ![39%](https://geps.dev/progress/39) |
| Polish (Polski) (pl_PL) | ![41%](https://geps.dev/progress/41) |
| Portuguese (Português) (pt_PT) | ![40%](https://geps.dev/progress/40) |
| Portuguese Brazilian (Português) (pt_BR) | ![42%](https://geps.dev/progress/42) |
| Romanian (Română) (ro_RO) | ![33%](https://geps.dev/progress/33) |
| Russian (Русский) (ru_RU) | ![43%](https://geps.dev/progress/43) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![43%](https://geps.dev/progress/43) |
| Simplified Chinese (简体中文) (zh_CN) | ![99%](https://geps.dev/progress/99) |
| Slovakian (Slovensky) (sk_SK) | ![30%](https://geps.dev/progress/30) |
| Slovenian (Slovenščina) (sl_SI) | ![41%](https://geps.dev/progress/41) |
| Spanish (Español) (es_ES) | ![98%](https://geps.dev/progress/98) |
| Swedish (Svenska) (sv_SE) | ![38%](https://geps.dev/progress/38) |
| Thai (ไทย) (th_TH) | ![35%](https://geps.dev/progress/35) |
| Tibetan (བོད་ཡིག་) (bo_CN) | ![65%](https://geps.dev/progress/65) |
| Traditional Chinese (繁體中文) (zh_TW) | ![97%](https://geps.dev/progress/97) |
| Turkish (Türkçe) (tr_TR) | ![80%](https://geps.dev/progress/80) |
| Ukrainian (Українська) (uk_UA) | ![71%](https://geps.dev/progress/71) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![57%](https://geps.dev/progress/57) |
| Traditional Chinese (繁體中文) (zh_TW) | ![43%](https://geps.dev/progress/43) |
| Turkish (Türkçe) (tr_TR) | ![43%](https://geps.dev/progress/43) |
| Ukrainian (Українська) (uk_UA) | ![42%](https://geps.dev/progress/42) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![32%](https://geps.dev/progress/32) |
| Malayalam (മലയാളം) (ml_IN) | ![73%](https://geps.dev/progress/73) |
## Stirling PDF Enterprise
@@ -3,6 +3,7 @@ package stirling.software.SPDF.controller.api;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.nio.file.attribute.BasicFileAttributes;
@@ -20,6 +21,8 @@ import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlin
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.multipart.MultipartFile;
@@ -111,6 +114,32 @@ public class MergeController {
}
}
// Parse client file IDs from JSON string
private String[] parseClientFileIds(String clientFileIds) {
if (clientFileIds == null || clientFileIds.trim().isEmpty()) {
return new String[0];
}
try {
// Simple JSON array parsing - remove brackets and split by comma
String trimmed = clientFileIds.trim();
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
String inside = trimmed.substring(1, trimmed.length() - 1).trim();
if (inside.isEmpty()) {
return new String[0];
}
String[] parts = inside.split(",");
String[] result = new String[parts.length];
for (int i = 0; i < parts.length; i++) {
result[i] = parts[i].trim().replaceAll("^\"|\"$", "");
}
return result;
}
} catch (Exception e) {
log.warn("Failed to parse client file IDs: {}", clientFileIds, e);
}
return new String[0];
}
// Adds a table of contents to the merged document using filenames as chapter titles
private void addTableOfContents(PDDocument mergedDocument, MultipartFile[] files) {
// Create the document outline
@@ -177,15 +206,48 @@ public class MergeController {
PDFMergerUtility mergerUtility = new PDFMergerUtility();
long totalSize = 0;
for (MultipartFile multipartFile : files) {
List<Integer> invalidIndexes = new ArrayList<>();
for (int index = 0; index < files.length; index++) {
MultipartFile multipartFile = files[index];
totalSize += multipartFile.getSize();
File tempFile =
GeneralUtils.convertMultipartFileToFile(
multipartFile); // Convert MultipartFile to File
filesToDelete.add(tempFile); // Add temp file to the list for later deletion
// Pre-validate each PDF so we can report which one(s) are broken
// Use the original MultipartFile to avoid deleting the tempFile during validation
try (PDDocument ignored = pdfDocumentFactory.load(multipartFile)) {
// OK
} catch (IOException e) {
ExceptionUtils.logException("PDF pre-validate", e);
invalidIndexes.add(index);
}
mergerUtility.addSource(tempFile); // Add source file to the merger utility
}
if (!invalidIndexes.isEmpty()) {
// Parse client file IDs (always present from frontend)
String[] clientIds = parseClientFileIds(request.getClientFileIds());
// Map invalid indexes to client IDs
List<String> errorFileIds = new ArrayList<>();
for (Integer index : invalidIndexes) {
if (index < clientIds.length) {
errorFileIds.add(clientIds[index]);
}
}
String payload =
String.format(
"{\"errorFileIds\":%s,\"message\":\"Some of the selected files can't be merged\"}",
errorFileIds.toString());
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE)
.body(payload.getBytes(StandardCharsets.UTF_8));
}
mergedTempFile = Files.createTempFile("merged-", ".pdf").toFile();
mergerUtility.setDestinationFileName(mergedTempFile.getAbsolutePath());
@@ -39,4 +39,10 @@ public class MergePdfsRequest extends MultiplePDFFiles {
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "false")
private boolean generateToc = false;
@Schema(
description =
"JSON array of client-provided IDs for each uploaded file (same order as fileInput)",
requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private String clientFileIds;
}
+1 -1
View File
@@ -4,7 +4,7 @@ FROM node:20-alpine AS build
WORKDIR /app
# Copy package files
COPY frontend/package*.json ./
COPY frontend/package.json frontend/package-lock.json ./
# Install dependencies
RUN npm ci
+13
View File
@@ -6,6 +6,11 @@ http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Add .mjs MIME type mapping
types {
text/javascript mjs;
}
# Gzip compression
gzip on;
gzip_vary on;
@@ -90,6 +95,14 @@ http {
proxy_set_header X-Forwarded-Port $server_port;
}
# Serve .mjs files with correct MIME type (must come before general static assets)
location ~* \.mjs$ {
try_files $uri =404;
add_header Content-Type "text/javascript; charset=utf-8" always;
expires 1y;
add_header Cache-Control "public, immutable";
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
+192 -111
View File
@@ -10,21 +10,24 @@
"license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
"@embedpdf/core": "^1.2.1",
"@embedpdf/engines": "^1.2.1",
"@embedpdf/plugin-interaction-manager": "^1.2.1",
"@embedpdf/plugin-loader": "^1.2.1",
"@embedpdf/plugin-pan": "^1.2.1",
"@embedpdf/plugin-render": "^1.2.1",
"@embedpdf/plugin-rotate": "^1.2.1",
"@embedpdf/plugin-scroll": "^1.2.1",
"@embedpdf/plugin-search": "^1.2.1",
"@embedpdf/plugin-selection": "^1.2.1",
"@embedpdf/plugin-spread": "^1.2.1",
"@embedpdf/plugin-thumbnail": "^1.2.1",
"@embedpdf/plugin-tiling": "^1.2.1",
"@embedpdf/plugin-viewport": "^1.2.1",
"@embedpdf/plugin-zoom": "^1.2.1",
"@embedpdf/core": "^1.3.1",
"@embedpdf/engines": "^1.3.1",
"@embedpdf/plugin-annotation": "^1.3.1",
"@embedpdf/plugin-export": "^1.3.1",
"@embedpdf/plugin-history": "^1.3.1",
"@embedpdf/plugin-interaction-manager": "^1.3.1",
"@embedpdf/plugin-loader": "^1.3.1",
"@embedpdf/plugin-pan": "^1.3.1",
"@embedpdf/plugin-render": "^1.3.1",
"@embedpdf/plugin-rotate": "^1.3.1",
"@embedpdf/plugin-scroll": "^1.3.1",
"@embedpdf/plugin-search": "^1.3.1",
"@embedpdf/plugin-selection": "^1.3.1",
"@embedpdf/plugin-spread": "^1.3.1",
"@embedpdf/plugin-thumbnail": "^1.3.1",
"@embedpdf/plugin-tiling": "^1.3.1",
"@embedpdf/plugin-viewport": "^1.3.1",
"@embedpdf/plugin-zoom": "^1.3.1",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@iconify/react": "^6.0.2",
@@ -488,12 +491,13 @@
}
},
"node_modules/@embedpdf/core": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.2.1.tgz",
"integrity": "sha512-2VwRPsN3+LmaBrD8TCN1t1ni/Vc9CxAfl/SApDjZYwE7zOieQT4ZHt+nkgF0F4I3xSgvvyHDjmOonhjBIrT6xA==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.1.tgz",
"integrity": "sha512-2Az6trhiMMBIv+GFvV8H8UOS1gwQn7NK0KaJMcdsZbUHYLO0P95aVd6Pi/GRzEH4XyF51TDIoTOAUtf07TQ5dQ==",
"license": "MIT",
"dependencies": {
"@embedpdf/engines": "1.2.1",
"@embedpdf/models": "1.2.1"
"@embedpdf/engines": "1.3.1",
"@embedpdf/models": "1.3.1"
},
"peerDependencies": {
"preact": "^10.26.4",
@@ -503,13 +507,13 @@
}
},
"node_modules/@embedpdf/engines": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-1.2.1.tgz",
"integrity": "sha512-nhycZ7Buq2B34dcpo6n7RdFwdhwTvKzvnRy7QX+uU00Dz5vftkCG4OK+pBVzxE4y7vAu+Yb4wNpdc7HmIj3B6w==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-1.3.1.tgz",
"integrity": "sha512-G3pI+18la7spviUMuA5s9/hV95jlfkA2+CNxqlHBO5ocw3641E3d36Lv+mx+6yU7k0B5vEOQPZDGRMg7KFziBQ==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.2.1",
"@embedpdf/pdfium": "1.2.1"
"@embedpdf/models": "1.3.1",
"@embedpdf/pdfium": "1.3.1"
},
"peerDependencies": {
"preact": "^10.26.4",
@@ -519,26 +523,79 @@
}
},
"node_modules/@embedpdf/models": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-1.2.1.tgz",
"integrity": "sha512-FzJU51jsqihfgt50B00FEpgyym87/Dn2iGmMq4++Vu/oO6qBx/y69m4/cCAh4p4KkTJsvKNWC7T7dwSKa0FjHA==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-1.3.1.tgz",
"integrity": "sha512-OzmO1rQAuOP/Y3aYXmW21dPNAx49olhr9ZO2hDdI0fbNBHTVGxnaKqOISxVmUz7TmhTwVBljERACnaA8Ib4b4Q==",
"license": "MIT"
},
"node_modules/@embedpdf/pdfium": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-1.2.1.tgz",
"integrity": "sha512-QWf1jg7EqUlku2q6KYhlXCNfk5IAykFerPuzKJepHTeAEaRcAfu84fJgEsoUTCK4D6dfzVNp2Iuxw6Kv7MpSeg==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-1.3.1.tgz",
"integrity": "sha512-qYGSS5ntz6DSY9Cxw/aigvHqGB+AKJLEcymNTZOL0GdlBzZpL++dOIYNEYHO2Tm/lOQVpE7I0e+Xh2TvD8O1zQ==",
"license": "MIT"
},
"node_modules/@embedpdf/plugin-interaction-manager": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.2.1.tgz",
"integrity": "sha512-HhEBuDjDNMH6wu76Eo3yHwjG01U1lNZShkOsFoib/rtx8HByTgZS8iVpovaOprr6gfS04ZLqWcsN1nt5qAH90w==",
"node_modules/@embedpdf/plugin-annotation": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-annotation/-/plugin-annotation-1.3.1.tgz",
"integrity": "sha512-mmePRYYBB8v8NIZ95XVfFkpyQ2QiKIGdWyvrPeJXSbL3/K6d6ix+o/jHBVvBWyTsQzdIlzs+FW8+iT0M1zkEow==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.2.1"
"@embedpdf/models": "1.3.1",
"@embedpdf/utils": "1.3.1"
},
"peerDependencies": {
"@embedpdf/core": "1.2.1",
"@embedpdf/core": "1.3.1",
"@embedpdf/plugin-history": "1.3.1",
"@embedpdf/plugin-interaction-manager": "1.3.1",
"@embedpdf/plugin-selection": "1.3.1",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
"vue": ">=3.2.0"
}
},
"node_modules/@embedpdf/plugin-export": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-1.3.1.tgz",
"integrity": "sha512-reb03vNPFP5GuIAFExMcuYBVYu/deVO2v8EoCwRZ/lzzYMORIkJjpNWDQPo9VfyGBh1x4/o3CHvxisU1Y1tDLg==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.3.1"
},
"peerDependencies": {
"@embedpdf/core": "1.3.1",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
"vue": ">=3.2.0"
}
},
"node_modules/@embedpdf/plugin-history": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.3.1.tgz",
"integrity": "sha512-HrPkWQmAk08mbHiOcIN4htVq5KJMqI9zSjAqaYQEhV/TugeHfWVpK+xMst/PzuFb14HWgk5gWXjtV5E4SDlw9w==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.3.1"
},
"peerDependencies": {
"@embedpdf/core": "1.3.1",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
"vue": ">=3.2.0"
}
},
"node_modules/@embedpdf/plugin-interaction-manager": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.3.1.tgz",
"integrity": "sha512-8h3y5a9tQ1fZlc4mP1/+XKyuHWwcQEm9AujKxy+6f6omtCBzpnKrH95bURgYOzQEBGY7d5C3HvG6JOlh0o1x3A==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.3.1"
},
"peerDependencies": {
"@embedpdf/core": "1.3.1",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -546,14 +603,15 @@
}
},
"node_modules/@embedpdf/plugin-loader": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.2.1.tgz",
"integrity": "sha512-VblKErfEiHcVao18TfCmc0UJlKAkqxE29DaLJrXQHGUw/qc+pC9HlvMVpDz3+Eb13UafYS6ZUZuEng2/fQ+JJw==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.3.1.tgz",
"integrity": "sha512-NjNmA7TOs3E/zwb9I+YohzyGkxq8y5NUGu0MKgh2g41lZoFvyqTAjFPar+RjEiLX8iiJiwNZswyJsNrytmS3Xg==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.2.1"
"@embedpdf/models": "1.3.1"
},
"peerDependencies": {
"@embedpdf/core": "1.2.1",
"@embedpdf/core": "1.3.1",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -561,16 +619,17 @@
}
},
"node_modules/@embedpdf/plugin-pan": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-1.2.1.tgz",
"integrity": "sha512-/BTOyRl31tvnCmoLs4qNPROMRLaG34jGYNyMQquB0uPUXZjwdMloikriwos91qCOLUrhvs4SaDpC3Ghv2BO5kA==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-1.3.1.tgz",
"integrity": "sha512-lF1gkz/a77G3+Rr8MOefkGnPJ1i5xWnClXm2ZzYAl7PbOScp59/PaP7qeU7eMPC4FHQM81ZhCgVYGXogbaB8ww==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.2.1"
"@embedpdf/models": "1.3.1"
},
"peerDependencies": {
"@embedpdf/core": "1.2.1",
"@embedpdf/plugin-interaction-manager": "1.2.1",
"@embedpdf/plugin-viewport": "1.2.1",
"@embedpdf/core": "1.3.1",
"@embedpdf/plugin-interaction-manager": "1.3.1",
"@embedpdf/plugin-viewport": "1.3.1",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -578,14 +637,15 @@
}
},
"node_modules/@embedpdf/plugin-render": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.2.1.tgz",
"integrity": "sha512-iMfuVJqttJmm7Zb8oOaqNVNrC3NS57bDNNAc4MIc2f2TxIFSznvBPlwWN+PN45qNcTQiGzFc1ZMqIQDOG4qFnQ==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.3.1.tgz",
"integrity": "sha512-c9oH097e1CVUpYF9RgZRfV/7XCJ0pf+svdT1wyM2MbWby06ti20oCwT9wf7BLY0hPQ7+eO3wunr1I1/y3MnVrw==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.2.1"
"@embedpdf/models": "1.3.1"
},
"peerDependencies": {
"@embedpdf/core": "1.2.1",
"@embedpdf/core": "1.3.1",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -593,14 +653,15 @@
}
},
"node_modules/@embedpdf/plugin-rotate": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-1.2.1.tgz",
"integrity": "sha512-UhHds5donLDXm3i9nKrhSmo3yawVtjb6gID0MDrhj3+Lci/YQ3wDvGUhk7dNmgLcOt7G8pMa0wesnnpVWirUXA==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-1.3.1.tgz",
"integrity": "sha512-mRAlIW7IZAnCyDuYqN13yDc6yoNIYLUB4uYTUAR7vTIt021C8H5jDHk9TmLwcH0tQ8/R3yHuDm/XPAe0zfs81g==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.2.1"
"@embedpdf/models": "1.3.1"
},
"peerDependencies": {
"@embedpdf/core": "1.2.1",
"@embedpdf/core": "1.3.1",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -608,15 +669,16 @@
}
},
"node_modules/@embedpdf/plugin-scroll": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.2.1.tgz",
"integrity": "sha512-I1haDXIOzs59uhOWEP6UvP5jzjcQHMLQuQbfRVJM0zdWU6t3jwSfcwPUI7iv4CAAepbuyJKL328yc8736r/FYw==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.3.1.tgz",
"integrity": "sha512-mDvK3DyBZC8/8pOEdJsWtSjCmV2ZuZJJ6xfspJpsaDVywo1Vq6M55BtKThkhqED6mqbFWTN9rP9cbWG8KDBWVA==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.2.1"
"@embedpdf/models": "1.3.1"
},
"peerDependencies": {
"@embedpdf/core": "1.2.1",
"@embedpdf/plugin-viewport": "1.2.1",
"@embedpdf/core": "1.3.1",
"@embedpdf/plugin-viewport": "1.3.1",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -624,15 +686,16 @@
}
},
"node_modules/@embedpdf/plugin-search": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-1.2.1.tgz",
"integrity": "sha512-sl9FBQzbOBtdmPpf6UI0bnWCTPWDkj47rTxyK07bpnGfGuFof4zhcxmMaFdyP7zqBh4Y9XqGu4A0uMTO2d/t7g==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-1.3.1.tgz",
"integrity": "sha512-SLwYPQg1NJWytq2sd4MnWFmRVGgzwbohBedB2kH0ALsvdnoRYqgjR5HqAsKgoRJO/pphQhHlk3L1gLW62r6hqQ==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.2.1"
"@embedpdf/models": "1.3.1"
},
"peerDependencies": {
"@embedpdf/core": "1.2.1",
"@embedpdf/plugin-loader": "1.2.1",
"@embedpdf/core": "1.3.1",
"@embedpdf/plugin-loader": "1.3.1",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -640,16 +703,17 @@
}
},
"node_modules/@embedpdf/plugin-selection": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.2.1.tgz",
"integrity": "sha512-wgG1X1sl6sed3pv7WLIO74SX0x3389/ax+/OLMty/LFbDNYMRO+n8ZQss8aUM700HARIqkPJy7UoSQt91o4nwA==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.3.1.tgz",
"integrity": "sha512-yef2XB/zR7zjyeUB3Ul0SbTcXqu5isR0GtINkFwL7bJMok6HpYNDnMXSuo55BaxI0dOCnnCSZfoRkAgosnZ1uQ==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.2.1"
"@embedpdf/models": "1.3.1"
},
"peerDependencies": {
"@embedpdf/core": "1.2.1",
"@embedpdf/plugin-interaction-manager": "1.2.1",
"@embedpdf/plugin-viewport": "1.2.1",
"@embedpdf/core": "1.3.1",
"@embedpdf/plugin-interaction-manager": "1.3.1",
"@embedpdf/plugin-viewport": "1.3.1",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -657,15 +721,16 @@
}
},
"node_modules/@embedpdf/plugin-spread": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-1.2.1.tgz",
"integrity": "sha512-rpadnutT1wSdBQV7RQz40zYdKgCRgmJde/tamgB8oHQypcnZGQcAG6/ZfX5j12s9pG18hKwwL+KmMoBnXD9IjQ==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-1.3.1.tgz",
"integrity": "sha512-RJ/kgJsFRdtWlPMXTW1feUSb6WHIvxtNRLgqzX8dlFIoyc4oZex2Vw+URo/VZuWSe/NvCIihQ20rkNAQJMnNMQ==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.2.1"
"@embedpdf/models": "1.3.1"
},
"peerDependencies": {
"@embedpdf/core": "1.2.1",
"@embedpdf/plugin-loader": "1.2.1",
"@embedpdf/core": "1.3.1",
"@embedpdf/plugin-loader": "1.3.1",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -673,32 +738,34 @@
}
},
"node_modules/@embedpdf/plugin-thumbnail": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-1.2.1.tgz",
"integrity": "sha512-TjHPkK8p3+FDMLcUdb3/4VREjm+liVooufLPVZ3FCXHbiC0PeUkqnwAxpCS2Jw1n+EtkY8pefRdRJeZhO6plOQ==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-1.3.1.tgz",
"integrity": "sha512-xv96ESa7JgD5z+TzcOK18/u0gq3d9v7QPv2wpr0ZhcnwLwf4sH0eUJZIsv7z7DMOpBNz7o7jJbrtxDUdCEHGhg==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.2.1"
"@embedpdf/models": "1.3.1"
},
"peerDependencies": {
"@embedpdf/core": "1.2.1",
"@embedpdf/plugin-render": "1.2.1",
"@embedpdf/core": "1.3.1",
"@embedpdf/plugin-render": "1.3.1",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@embedpdf/plugin-tiling": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-1.2.1.tgz",
"integrity": "sha512-C9uOGVIsoxUw+uQMXfJFZ8ibRLQeNOnaKC2izjx967iGu0ZoecAv+mKtH/Ge0vMEKYM1109AlF5T2EGwKQW2YA==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-1.3.1.tgz",
"integrity": "sha512-Q8RF80fb6y9GDAKwvgsu0BsWJlQuhNCtSKWwp3YcZJtIBFm94DVcg0zTgvDmE9/WNOmn4Z1Edt86usmYauHolw==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.2.1"
"@embedpdf/models": "1.3.1"
},
"peerDependencies": {
"@embedpdf/core": "1.2.1",
"@embedpdf/plugin-render": "1.2.1",
"@embedpdf/plugin-scroll": "1.2.1",
"@embedpdf/plugin-viewport": "1.2.1",
"@embedpdf/core": "1.3.1",
"@embedpdf/plugin-render": "1.3.1",
"@embedpdf/plugin-scroll": "1.3.1",
"@embedpdf/plugin-viewport": "1.3.1",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -706,14 +773,15 @@
}
},
"node_modules/@embedpdf/plugin-viewport": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.2.1.tgz",
"integrity": "sha512-yvftOis7FLBjM3w2VYO5LXVKXoHkmFV/SPy7U6SbuLJTX126F4ohSij9euMHJjaqOgr5tBNvrf4xemVRglxM9w==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.3.1.tgz",
"integrity": "sha512-gzosrWL18ZhN175Kxocf/p7uqYBhNHvEuV1CpJQmN7ys48aew6Qq8z7MjAsCnJBANXk/8syNdo3qWwBriyjQNg==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.2.1"
"@embedpdf/models": "1.3.1"
},
"peerDependencies": {
"@embedpdf/core": "1.2.1",
"@embedpdf/core": "1.3.1",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -721,18 +789,31 @@
}
},
"node_modules/@embedpdf/plugin-zoom": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-1.2.1.tgz",
"integrity": "sha512-hsp/nM4C8q0FM9P6FkpQLbU8IYawUgmiYgD3HXqHWBVRk30OIaXs4N0KC9vsHwn8ZAiyLl7jhlAXpgoacH5xEQ==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-1.3.1.tgz",
"integrity": "sha512-3GXpgv6XmZiQnjaPbsxblTqUn84ALFiyONh2gwrEU9apB6STT3TQiY0QRindwrUXdQLpCSjRSB9PpDBCtTww7w==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "1.2.1",
"@embedpdf/models": "1.3.1",
"hammerjs": "^2.0.8"
},
"peerDependencies": {
"@embedpdf/core": "1.2.1",
"@embedpdf/plugin-interaction-manager": "1.2.1",
"@embedpdf/plugin-scroll": "1.2.1",
"@embedpdf/plugin-viewport": "1.2.1",
"@embedpdf/core": "1.3.1",
"@embedpdf/plugin-interaction-manager": "1.3.1",
"@embedpdf/plugin-scroll": "1.3.1",
"@embedpdf/plugin-viewport": "1.3.1",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
"vue": ">=3.2.0"
}
},
"node_modules/@embedpdf/utils": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@embedpdf/utils/-/utils-1.3.1.tgz",
"integrity": "sha512-6trYysnggwCCTB2q7cX6tkOTbZJNtt2YYZohPCmh0yaDpkfNSgwDwD0jCLtEU2UZLQoH4+2GvNo+4xe+KAGlIQ==",
"license": "MIT",
"peerDependencies": {
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
+18 -15
View File
@@ -6,21 +6,24 @@
"proxy": "http://localhost:8080",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
"@embedpdf/core": "^1.2.1",
"@embedpdf/engines": "^1.2.1",
"@embedpdf/plugin-interaction-manager": "^1.2.1",
"@embedpdf/plugin-loader": "^1.2.1",
"@embedpdf/plugin-pan": "^1.2.1",
"@embedpdf/plugin-render": "^1.2.1",
"@embedpdf/plugin-rotate": "^1.2.1",
"@embedpdf/plugin-scroll": "^1.2.1",
"@embedpdf/plugin-search": "^1.2.1",
"@embedpdf/plugin-selection": "^1.2.1",
"@embedpdf/plugin-spread": "^1.2.1",
"@embedpdf/plugin-thumbnail": "^1.2.1",
"@embedpdf/plugin-tiling": "^1.2.1",
"@embedpdf/plugin-viewport": "^1.2.1",
"@embedpdf/plugin-zoom": "^1.2.1",
"@embedpdf/core": "^1.3.1",
"@embedpdf/engines": "^1.3.1",
"@embedpdf/plugin-annotation": "^1.3.1",
"@embedpdf/plugin-export": "^1.3.1",
"@embedpdf/plugin-history": "^1.3.1",
"@embedpdf/plugin-interaction-manager": "^1.3.1",
"@embedpdf/plugin-loader": "^1.3.1",
"@embedpdf/plugin-pan": "^1.3.1",
"@embedpdf/plugin-render": "^1.3.1",
"@embedpdf/plugin-rotate": "^1.3.1",
"@embedpdf/plugin-scroll": "^1.3.1",
"@embedpdf/plugin-search": "^1.3.1",
"@embedpdf/plugin-selection": "^1.3.1",
"@embedpdf/plugin-spread": "^1.3.1",
"@embedpdf/plugin-thumbnail": "^1.3.1",
"@embedpdf/plugin-tiling": "^1.3.1",
"@embedpdf/plugin-viewport": "^1.3.1",
"@embedpdf/plugin-zoom": "^1.3.1",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@iconify/react": "^6.0.2",
File diff suppressed because it is too large Load Diff
+261 -46
View File
@@ -1,4 +1,10 @@
{
"unsavedChanges": "You have unsaved changes to your PDF. What would you like to do?",
"unsavedChangesTitle": "Unsaved Changes",
"keepWorking": "Keep Working",
"discardChanges": "Discard Changes",
"applyAndContinue": "Apply & Continue",
"exportAndContinue": "Export & Continue",
"language": {
"direction": "ltr"
},
@@ -10,16 +16,34 @@
"selectText": {
"1": "Select PDF file:",
"2": "Margin Size",
"3": "Position",
"3": "Position Selection",
"4": "Starting Number",
"5": "Pages to Number",
"6": "Custom Text"
"6": "Custom Text Format"
},
"customTextDesc": "Custom Text",
"numberPagesDesc": "Which pages to number, default 'all', also accepts 1-5 or 2,5,9 etc",
"customNumberDesc": "Defaults to {n}, also accepts 'Page {n} of {total}', 'Text-{n}', '{filename}-{n}",
"submit": "Add Page Numbers"
"numberPagesDesc": "e.g., 1,3,5-8 or leave blank for all pages",
"customNumberDesc": "e.g., \"Page {n}\" or leave blank for just numbers",
"submit": "Add Page Numbers",
"configuration": "Configuration",
"customize": "Customize Appearance",
"pagesAndStarting": "Pages & Starting Number",
"positionAndPages": "Position & Pages",
"error": {
"failed": "Add page numbers operation failed"
},
"results": {
"title": "Page Number Results"
},
"preview": "Position Selection",
"previewDisclaimer": "Preview is approximate. Final output may vary due to PDF font metrics."
},
"pageSelectionPrompt": "Specify which pages to add numbers to. Examples: \"1,3,5\" for specific pages, \"1-5\" for ranges, \"2n\" for even pages, or leave blank for all pages.",
"startingNumberTooltip": "The first number to display. Subsequent pages will increment from this number.",
"marginTooltip": "Distance between the page number and the edge of the page.",
"fontSizeTooltip": "Size of the page number text in points. Larger numbers create bigger text.",
"fontTypeTooltip": "Font family for the page numbers. Choose based on your document style.",
"customTextTooltip": "Optional custom format for page numbers. Use {n} as placeholder for the number. Example: \"Page {n}\" will show \"Page 1\", \"Page 2\", etc.",
"pdfPrompt": "Select PDF(s)",
"multiPdfPrompt": "Select PDFs (2+)",
"multiPdfDropPrompt": "Select (or drag & drop) all PDFs you require",
@@ -74,7 +98,10 @@
},
"error": {
"pdfPassword": "The PDF Document is passworded and either the password was not provided or was incorrect",
"encryptedPdfMustRemovePassword": "This PDF is encrypted or password-protected. Please unlock it before converting to PDF/A.",
"incorrectPasswordProvided": "The PDF password is incorrect or not provided.",
"_value": "Error",
"dismissAllErrors": "Dismiss All Errors",
"sorry": "Sorry for the issue!",
"needHelp": "Need help / Found an issue?",
"contactTip": "If you're still having trouble, don't hesitate to reach out to us for help. You can submit a ticket on our GitHub page or contact us through Discord:",
@@ -357,222 +384,276 @@
"globalPopularity": "Global Popularity",
"sortBy": "Sort by:",
"multiTool": {
"tags": "multiple,tools",
"title": "PDF Multi Tool",
"desc": "Merge, Rotate, Rearrange, Split, and Remove pages"
},
"merge": {
"tags": "combine,join,unite",
"title": "Merge",
"desc": "Easily merge multiple PDFs into one."
},
"split": {
"tags": "divide,separate,break",
"title": "Split",
"desc": "Split PDFs into multiple documents"
},
"rotate": {
"tags": "turn,flip,orient",
"title": "Rotate",
"desc": "Easily rotate your PDFs."
},
"convert": {
"tags": "transform,change",
"title": "Convert",
"desc": "Convert files between different formats"
},
"pdfOrganiser": {
"tags": "organize,rearrange,reorder",
"title": "Organise",
"desc": "Remove/Rearrange pages in any order"
},
"addImage": {
"tags": "insert,embed,place",
"title": "Add image",
"desc": "Adds a image onto a set location on the PDF"
},
"addAttachments": {
"tags": "embed,attach,include",
"title": "Add Attachments",
"desc": "Add or remove embedded files (attachments) to/from a PDF"
},
"watermark": {
"tags": "stamp,mark,overlay",
"title": "Add Watermark",
"desc": "Add a custom watermark to your PDF document."
},
"removePassword": {
"tags": "unlock",
"title": "Remove Password",
"desc": "Remove password protection from your PDF document."
},
"compress": {
"tags": "shrink,reduce,optimize",
"title": "Compress",
"desc": "Compress PDFs to reduce their file size."
},
"unlockPDFForms": {
"tags": "unlock,enable,edit",
"title": "Unlock PDF Forms",
"desc": "Remove read-only property of form fields in a PDF document."
},
"changeMetadata": {
"tags": "edit,modify,update",
"title": "Change Metadata",
"desc": "Change/Remove/Add metadata from a PDF document"
},
"ocr": {
"tags": "extract,scan",
"title": "OCR / Cleanup scans",
"desc": "Cleanup scans and detects text from images within a PDF and re-adds it as text."
},
"extractImages": {
"tags": "pull,save,export",
"title": "Extract Images",
"desc": "Extracts all images from a PDF and saves them to zip"
},
"scannerImageSplit": {
"title": "Detect/Split Scanned photos",
"desc": "Splits multiple photos from within a photo/PDF"
"tags": "detect,split,photos",
"title": "Detect & Split Scanned Photos",
"desc": "Detect and split scanned photos into separate pages"
},
"sign": {
"tags": "signature,autograph",
"title": "Sign",
"desc": "Adds signature to PDF by drawing, text or image"
},
"flatten": {
"tags": "simplify,remove,interactive",
"title": "Flatten",
"desc": "Remove all interactive elements and forms from a PDF"
},
"certSign": {
"tags": "authenticate,PEM,P12,official,encrypt,sign,certificate,PKCS12,JKS,server,manual,auto",
"title": "Sign with Certificate",
"desc": "Signs a PDF with a Certificate/Key (PEM/P12)"
},
"repair": {
"tags": "fix,restore",
"title": "Repair",
"desc": "Tries to repair a corrupt/broken PDF"
},
"removeBlanks": {
"tags": "delete,clean,empty",
"title": "Remove Blank pages",
"desc": "Detects and removes blank pages from a document"
},
"removeAnnotations": {
"tags": "delete,clean,strip",
"title": "Remove Annotations",
"desc": "Removes all comments/annotations from a PDF"
},
"compare": {
"tags": "difference",
"title": "Compare",
"desc": "Compares and shows the differences between 2 PDF Documents"
},
"removeCertSign": {
"tags": "remove,delete,unlock",
"title": "Remove Certificate Sign",
"desc": "Remove certificate signature from PDF"
},
"pageLayout": {
"tags": "layout,arrange,combine",
"title": "Multi-Page Layout",
"desc": "Merge multiple pages of a PDF document into a single page"
},
"bookletImposition": {
"tags": "booklet,print,binding",
"title": "Booklet Imposition",
"desc": "Create booklets with proper page ordering and multi-page layout for printing and binding"
},
"scalePages": {
"tags": "resize,adjust,scale",
"title": "Adjust page size/scale",
"desc": "Change the size/scale of a page and/or its contents."
},
"addPageNumbers": {
"tags": "number,pagination,count",
"title": "Add Page Numbers",
"desc": "Add Page numbers throughout a document in a set location"
},
"autoRename": {
"tags": "auto-detect,header-based,organize,relabel",
"title": "Auto Rename PDF File",
"desc": "Auto renames a PDF file based on its detected header"
},
"adjustContrast": {
"tags": "contrast,brightness,saturation",
"title": "Adjust Colours/Contrast",
"desc": "Adjust Contrast, Saturation and Brightness of a PDF"
},
"crop": {
"tags": "trim,cut,resize",
"title": "Crop PDF",
"desc": "Crop a PDF to reduce its size (maintains text!)"
},
"autoSplitPDF": {
"tags": "auto,split,QR",
"title": "Auto Split Pages",
"desc": "Auto Split Scanned PDF with physical scanned page splitter QR Code"
},
"sanitize": {
"tags": "clean,purge,remove",
"title": "Sanitise",
"desc": "Remove potentially harmful elements from PDF files"
},
"getPdfInfo": {
"tags": "info,metadata,details",
"title": "Get ALL Info on PDF",
"desc": "Grabs any and all information possible on PDFs"
},
"pdfToSinglePage": {
"tags": "combine,merge,single",
"title": "PDF to Single Large Page",
"desc": "Merges all PDF pages into one large single page"
},
"showJS": {
"tags": "javascript,code,script",
"title": "Show Javascript",
"desc": "Searches and displays any JS injected into a PDF"
},
"redact": {
"tags": "censor,blackout,hide",
"title": "Redact",
"desc": "Redacts (blacks out) a PDF based on selected text, drawn shapes and/or selected page(s)"
},
"overlayPdfs": {
"tags": "overlay,combine,stack",
"title": "Overlay PDFs",
"desc": "Overlays PDFs on-top of another PDF"
},
"splitBySections": {
"tags": "split,sections,divide",
"title": "Split PDF by Sections",
"desc": "Divide each page of a PDF into smaller horizontal and vertical sections"
},
"addStamp": {
"tags": "stamp,mark,seal",
"title": "Add Stamp to PDF",
"desc": "Add text or add image stamps at set locations"
},
"removeImage": {
"tags": "remove,delete,clean",
"title": "Remove image",
"desc": "Remove image from PDF to reduce file size"
},
"splitByChapters": {
"tags": "split,chapters,structure",
"title": "Split PDF by Chapters",
"desc": "Split a PDF into multiple files based on its chapter structure."
},
"validateSignature": {
"tags": "validate,verify,certificate",
"title": "Validate PDF Signature",
"desc": "Verify digital signatures and certificates in PDF documents"
},
"swagger": {
"tags": "API,documentation,test",
"title": "API Documentation",
"desc": "View API documentation and test endpoints"
},
"fakeScan": {
"title": "Fake Scan",
"scannerEffect": {
"tags": "scan,simulate,create",
"title": "Scanner Effect",
"desc": "Create a PDF that looks like it was scanned"
},
"editTableOfContents": {
"tags": "bookmarks,contents,edit",
"title": "Edit Table of Contents",
"desc": "Add or edit bookmarks and table of contents in PDF documents"
},
"manageCertificates": {
"tags": "certificates,import,export",
"title": "Manage Certificates",
"desc": "Import, export, or delete digital certificate files used for signing PDFs."
},
"read": {
"title": "Read",
"read": {
"tags": "view,open,display",
"title": "Read",
"desc": "View and annotate PDFs. Highlight text, draw, or insert comments for review and collaboration."
},
"reorganizePages": {
"tags": "rearrange,reorder,organize",
"title": "Reorganize Pages",
"desc": "Rearrange, duplicate, or delete PDF pages with visual drag-and-drop control."
},
"extractPages": {
"tags": "pull,select,copy",
"title": "Extract Pages",
"desc": "Extract specific pages from a PDF document"
},
"removePages": {
"tags": "delete,extract,exclude",
"title": "Remove Pages",
"desc": "Remove specific pages from a PDF document"
},
"autoSizeSplitPDF": {
"tags": "auto,split,size",
"title": "Auto Split by Size/Count",
"desc": "Automatically split PDFs by file size or page count"
},
"replaceColorPdf": {
"replaceColor": {
"title": "Replace & Invert Colour",
"desc": "Replace or invert colours in PDF documents"
},
"devApi": {
"tags": "API,development,documentation",
"title": "API",
"desc": "Link to API documentation"
},
"devFolderScanning": {
"tags": "automation,folder,scanning",
"title": "Automated Folder Scanning",
"desc": "Link to automated folder scanning guide"
},
@@ -593,6 +674,7 @@
"desc": "Change document restrictions and permissions"
},
"automate": {
"tags": "workflow,sequence,automation",
"title": "Automate",
"desc": "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks."
}
@@ -659,7 +741,6 @@
}
},
"split": {
"tags": "Page operations,divide,Multi Page,cut,server side",
"title": "Split PDF",
"header": "Split PDF",
"desc": {
@@ -785,7 +866,6 @@
}
},
"rotate": {
"tags": "server side",
"title": "Rotate PDF",
"submit": "Apply Rotation",
"error": {
@@ -902,7 +982,7 @@
"header": "PDF Page Organiser",
"submit": "Rearrange Pages",
"mode": {
"_value": "Mode",
"_value": "Organization mode",
"1": "Custom Page Order",
"2": "Reverse Order",
"3": "Duplex Sort",
@@ -915,6 +995,19 @@
"10": "Odd-Even Merge",
"11": "Duplicate all pages"
},
"desc": {
"CUSTOM": "Use a custom sequence of page numbers or expressions to define a new order.",
"REVERSE_ORDER": "Flip the document so the last page becomes first and so on.",
"DUPLEX_SORT": "Interleave fronts then backs as if a duplex scanner scanned all fronts, then all backs (1, n, 2, n-1, …).",
"BOOKLET_SORT": "Arrange pages for booklet printing (last, first, second, second last, …).",
"SIDE_STITCH_BOOKLET_SORT": "Arrange pages for sidestitch booklet printing (optimised for binding on the side).",
"ODD_EVEN_SPLIT": "Split the document into two outputs: all odd pages and all even pages.",
"ODD_EVEN_MERGE": "Merge two PDFs by alternating pages: odd from the first, even from the second.",
"DUPLICATE": "Duplicate each page according to the custom order count (e.g., 4 duplicates each page 4×).",
"REMOVE_FIRST": "Remove the first page from the document.",
"REMOVE_LAST": "Remove the last page from the document.",
"REMOVE_FIRST_AND_LAST": "Remove both the first and last pages from the document."
},
"placeholder": "(e.g. 1,3,2 or 4-8,2,10-12 or 2n-1)"
},
"addImage": {
@@ -1298,7 +1391,6 @@
}
},
"changeMetadata": {
"tags": "Title,author,date,creation,time,publisher,producer,stats",
"header": "Change Metadata",
"submit": "Change",
"filenamePrefix": "metadata",
@@ -1540,7 +1632,13 @@
"header": "Extract Images",
"selectText": "Select image format to convert extracted images to",
"allowDuplicates": "Save duplicate images",
"submit": "Extract"
"submit": "Extract",
"settings": {
"title": "Settings"
},
"error": {
"failed": "An error occurred while extracting images from the PDF."
}
},
"pdfToPDFA": {
"tags": "archive,long-term,standard,conversion,storage,preservation",
@@ -1600,20 +1698,49 @@
"tags": "separate,auto-detect,scans,multi-photo,organize",
"selectText": {
"1": "Angle Threshold:",
"2": "Sets the minimum absolute angle required for the image to be rotated (default: 10).",
"2": "Tilt (in degrees) needed before we auto-straighten a photo.",
"3": "Tolerance:",
"4": "Determines the range of colour variation around the estimated background colour (default: 30).",
"4": "How closely a colour must match the page background to count as background. Higher = looser, lower = stricter.",
"5": "Minimum Area:",
"6": "Sets the minimum area threshold for a photo (default: 10000).",
"6": "Smallest photo size (in pixels²) we'll keep to avoid tiny fragments.",
"7": "Minimum Contour Area:",
"8": "Sets the minimum contour area threshold for a photo",
"8": "Smallest edge/shape we consider when finding photos (filters dust and specks).",
"9": "Border Size:",
"10": "Sets the size of the border added and removed to prevent white borders in the output (default: 1)."
"10": "Extra padding (in pixels) around each saved photo so edges aren't cut."
},
"info": "Python is not installed. It is required to run."
},
"scannerImageSplit": {
"title": "Extracted Images",
"submit": "Extract Image Scans",
"error": {
"failed": "An error occurred while extracting image scans."
},
"tooltip": {
"title": "Photo Splitter",
"whatThisDoes": "What this does",
"whatThisDoesDesc": "Automatically finds and extracts each photo from a scanned page or composite image—no manual cropping.",
"whenToUse": "When to use",
"useCase1": "Scan whole album pages in one go",
"useCase2": "Split flatbed batches into separate files",
"useCase3": "Break collages into individual photos",
"useCase4": "Pull photos from documents",
"quickFixes": "Quick fixes",
"problem1": "Photos not detected → increase Tolerance to 30-50",
"problem2": "Too many false detections → increase Minimum Area to 15,000-20,000",
"problem3": "Crops are too tight → increase Border Size to 5-10",
"problem4": "Tilted photos not straightened → lower Angle Threshold to ~5°",
"problem5": "Dust/noise boxes → increase Minimum Contour Area to 1000-2000",
"setupTips": "Setup tips",
"tip1": "Use a plain, light background",
"tip2": "Leave a small gap (≈1 cm) between photos",
"tip3": "Scan at 300-600 DPI",
"tip4": "Clean the scanner glass",
"headsUp": "Heads-up",
"headsUpDesc": "Overlapping photos or backgrounds very close in colour to the photos can reduce accuracy-try a lighter or darker background and leave more space."
}
},
"sign": {
"tags": "authorize,initials,drawn-signature,text-sign,image-signature",
"title": "Sign",
"header": "Sign PDFs",
"upload": "Upload Image",
@@ -1623,6 +1750,7 @@
"add": "Add",
"saved": "Saved Signatures",
"save": "Save Signature",
"applySignatures": "Apply Signatures",
"personalSigs": "Personal Signatures",
"sharedSigs": "Shared Signatures",
"noSavedSigs": "No saved signatures found",
@@ -1634,10 +1762,44 @@
"previous": "Previous page",
"maintainRatio": "Toggle maintain aspect ratio",
"undo": "Undo",
"redo": "Redo"
"redo": "Redo",
"submit": "Sign Document",
"steps": {
"configure": "Configure Signature"
},
"type": {
"title": "Signature Type",
"draw": "Draw",
"canvas": "Canvas",
"image": "Image",
"text": "Text"
},
"draw": {
"title": "Draw your signature",
"clear": "Clear"
},
"image": {
"label": "Upload signature image",
"placeholder": "Select image file",
"hint": "Upload a PNG or JPG image of your signature"
},
"text": {
"name": "Signer Name",
"placeholder": "Enter your full name"
},
"instructions": {
"title": "How to add signature"
},
"activate": "Activate Signature Placement",
"deactivate": "Stop Placing Signatures",
"results": {
"title": "Signature Results"
},
"error": {
"failed": "An error occurred while signing the PDF."
}
},
"flatten": {
"tags": "static,deactivate,non-interactive,streamline",
"title": "Flatten",
"header": "Flatten PDF",
"flattenOnlyForms": "Flatten only forms",
@@ -1702,7 +1864,6 @@
}
},
"removeBlanks": {
"tags": "cleanup,streamline,non-content,organize",
"title": "Remove Blanks",
"header": "Remove Blank Pages",
"settings": {
@@ -1750,7 +1911,17 @@
"tags": "comments,highlight,notes,markup,remove",
"title": "Remove Annotations",
"header": "Remove Annotations",
"submit": "Remove"
"submit": "Remove",
"settings": {
"title": "Settings"
},
"info": {
"title": "About Remove Annotations",
"description": "This tool will remove all annotations (comments, highlights, notes, etc.) from your PDF documents."
},
"error": {
"failed": "An error occurred while removing annotations from the PDF."
}
},
"compare": {
"tags": "differentiate,contrast,changes,analysis",
@@ -2099,7 +2270,6 @@
"tags": "color-correction,tune,modify,enhance,colour-correction"
},
"crop": {
"tags": "trim,shrink,edit,shape",
"title": "Crop",
"header": "Crop PDF",
"submit": "Apply Crop",
@@ -2451,25 +2621,48 @@
},
"selectCustomCert": "Custom Certificate File X.509 (Optional)"
},
"replace-color": {
"title": "Advanced Colour options",
"header": "Replace-Invert Colour PDF",
"selectText": {
"1": "Replace or Invert colour Options",
"2": "Default(Default high contrast colours)",
"3": "Custom(Customised colours)",
"4": "Full-Invert(Invert all colours)",
"5": "High contrast colour options",
"6": "white text on black background",
"7": "Black text on white background",
"8": "Yellow text on black background",
"9": "Green text on black background",
"10": "Choose text Colour",
"11": "Choose background Colour"
"replaceColor": {
"labels": {
"settings": "Settings",
"colourOperation": "Colour operation"
},
"submit": "Replace"
"options": {
"highContrast": "High contrast",
"invertAll": "Invert all colours",
"custom": "Custom"
},
"tooltip": {
"header": {
"title": "Replace & Invert Colour Settings Overview"
},
"description": {
"title": "Description",
"text": "Transform PDF colours to improve readability and accessibility. Choose from high contrast presets, invert all colours, or create custom colour schemes."
},
"highContrast": {
"title": "High Contrast",
"text": "Apply predefined high contrast colour combinations designed for better readability and accessibility compliance.",
"bullet1": "White text on black background - Classic dark mode",
"bullet2": "Black text on white background - Standard high contrast",
"bullet3": "Yellow text on black background - High visibility option",
"bullet4": "Green text on black background - Alternative high contrast"
},
"invertAll": {
"title": "Invert All Colours",
"text": "Completely invert all colours in the PDF, creating a negative-like effect. Useful for creating dark mode versions of documents or reducing eye strain in low-light conditions."
},
"custom": {
"title": "Custom Colours",
"text": "Define your own text and background colours using the colour pickers. Perfect for creating branded documents or specific accessibility requirements.",
"bullet1": "Text colour - Choose the colour for text elements",
"bullet2": "Background colour - Set the background colour for the document"
}
},
"error": {
"failed": "An error occurred while processing the colour replacement."
}
},
"replaceColorPdf": {
"replaceColor": {
"tags": "Replace Colour,Page operations,Back end,server side"
},
"login": {
@@ -3246,6 +3439,16 @@
"processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images."
}
},
"viewer": {
"firstPage": "First Page",
"lastPage": "Last Page",
"previousPage": "Previous Page",
"nextPage": "Next Page",
"zoomIn": "Zoom In",
"zoomOut": "Zoom Out",
"singlePageView": "Single Page View",
"dualPageView": "Dual Page View"
},
"common": {
"copy": "Copy",
"copied": "Copied!",
@@ -3302,6 +3505,18 @@
"generateError": "We couldn't generate your API key."
}
},
"AddAttachmentsRequest": {
"attachments": "Select Attachments",
"info": "Select files to attach to your PDF. These files will be embedded and accessible through the PDF's attachment panel.",
"selectFiles": "Select Files to Attach",
"placeholder": "Choose files...",
"addMoreFiles": "Add more files...",
"selectedFiles": "Selected Files",
"submit": "Add Attachments",
"results": {
"title": "Attachment Results"
}
},
"termsAndConditions": "Terms & Conditions",
"logOut": "Log out"
}
}
+126 -10
View File
@@ -68,6 +68,8 @@
},
"error": {
"pdfPassword": "The PDF Document is passworded and either the password was not provided or was incorrect",
"encryptedPdfMustRemovePassword": "This PDF is encrypted or password-protected. Please unlock it before converting to PDF/A.",
"incorrectPasswordProvided": "The PDF password is incorrect or not provided.",
"_value": "Error",
"sorry": "Sorry for the issue!",
"needHelp": "Need help / Found an issue?",
@@ -348,206 +350,257 @@
"globalPopularity": "Global Popularity",
"sortBy": "Sort by:",
"multiTool": {
"tags": "multiple,tools",
"title": "PDF Multi Tool",
"desc": "Merge, Rotate, Rearrange, Split, and Remove pages"
},
"merge": {
"tags": "combine,join,unite",
"title": "Merge",
"desc": "Easily merge multiple PDFs into one."
},
"split": {
"tags": "divide,separate,break",
"title": "Split",
"desc": "Split PDFs into multiple documents"
},
"rotate": {
"tags": "turn,flip,orient",
"title": "Rotate",
"desc": "Easily rotate your PDFs."
},
"imageToPDF": {
"tags": "convert,image,transform",
"title": "Image to PDF",
"desc": "Convert a image (PNG, JPEG, GIF) to PDF."
},
"pdfToImage": {
"tags": "convert,image,extract",
"title": "PDF to Image",
"desc": "Convert a PDF to a image. (PNG, JPEG, GIF)"
},
"pdfOrganiser": {
"tags": "organize,rearrange,reorder",
"title": "Organize",
"desc": "Remove/Rearrange pages in any order"
},
"addImage": {
"tags": "insert,embed,place",
"title": "Add image",
"desc": "Adds a image onto a set location on the PDF"
},
"watermark": {
"tags": "stamp,mark,overlay",
"title": "Add Watermark",
"desc": "Add a custom watermark to your PDF document."
},
"permissions": {
"tags": "permissions,security,access",
"title": "Change Permissions",
"desc": "Change the permissions of your PDF document"
},
"pageRemover": {
"tags": "remove,delete,pages",
"title": "Remove",
"desc": "Delete unwanted pages from your PDF document."
},
"addPassword": {
"tags": "password,encrypt,secure",
"title": "Add Password",
"desc": "Encrypt your PDF document with a password."
},
"changePermissions": {
"tags": "permissions,restrictions,security",
"title": "Change Permissions",
"desc": "Change document restrictions and permissions."
},
"removePassword": {
"tags": "unlock,remove,password",
"title": "Remove Password",
"desc": "Remove password protection from your PDF document."
},
"compress": {
"tags": "shrink,reduce,optimize",
"title": "Compress",
"desc": "Compress PDFs to reduce their file size."
},
"sanitize": {
"tags": "clean,purge,remove",
"title": "Sanitize",
"desc": "Remove potentially harmful elements from PDF files."
},
"unlockPDFForms": {
"tags": "unlock,enable,edit",
"title": "Unlock PDF Forms",
"desc": "Remove read-only property of form fields in a PDF document."
},
"changeMetadata": {
"tags": "edit,modify,update",
"title": "Change Metadata",
"desc": "Change/Remove/Add metadata from a PDF document"
},
"fileToPDF": {
"tags": "convert,transform,change",
"title": "Convert file to PDF",
"desc": "Convert nearly any file to PDF (DOCX, PNG, XLS, PPT, TXT and more)"
},
"ocr": {
"tags": "extract,scan",
"title": "OCR / Cleanup scans",
"desc": "Cleanup scans and detects text from images within a PDF and re-adds it as text."
},
"extractImages": {
"tags": "pull,save,export",
"title": "Extract Images",
"desc": "Extracts all images from a PDF and saves them to zip"
},
"pdfToPDFA": {
"tags": "convert,archive,long-term",
"title": "PDF to PDF/A",
"desc": "Convert PDF to PDF/A for long-term storage"
},
"PDFToWord": {
"tags": "convert,word,doc",
"title": "PDF to Word",
"desc": "Convert PDF to Word formats (DOC, DOCX and ODT)"
},
"PDFToPresentation": {
"tags": "convert,presentation,ppt",
"title": "PDF to Presentation",
"desc": "Convert PDF to Presentation formats (PPT, PPTX and ODP)"
},
"PDFToText": {
"tags": "convert,text,rtf",
"title": "PDF to RTF (Text)",
"desc": "Convert PDF to Text or RTF format"
},
"PDFToHTML": {
"tags": "convert,html,web",
"title": "PDF to HTML",
"desc": "Convert PDF to HTML format"
},
"PDFToXML": {
"tags": "convert,xml,data",
"title": "PDF to XML",
"desc": "Convert PDF to XML format"
},
"ScannerImageSplit": {
"tags": "detect,split,photos",
"title": "Detect/Split Scanned photos",
"desc": "Splits multiple photos from within a photo/PDF"
},
"sign": {
"tags": "signature,autograph",
"title": "Sign",
"desc": "Adds signature to PDF by drawing, text or image"
},
"flatten": {
"tags": "simplify,remove,interactive",
"title": "Flatten",
"desc": "Remove all interactive elements and forms from a PDF"
},
"repair": {
"tags": "fix,restore",
"title": "Repair",
"desc": "Tries to repair a corrupt/broken PDF"
},
"removeBlanks": {
"tags": "delete,clean,empty",
"title": "Remove Blank pages",
"desc": "Detects and removes blank pages from a document"
},
"removeAnnotations": {
"tags": "delete,clean,strip",
"title": "Remove Annotations",
"desc": "Removes all comments/annotations from a PDF"
},
"compare": {
"tags": "difference",
"title": "Compare",
"desc": "Compares and shows the differences between 2 PDF Documents"
},
"certSign": {
"tags": "authenticate,PEM,P12,official,encrypt,sign,certificate,PKCS12,JKS,server,manual,auto",
"title": "Sign with Certificate",
"desc": "Signs a PDF with a Certificate/Key (PEM/P12)"
},
"removeCertSign": {
"tags": "remove,delete,unlock",
"title": "Remove Certificate Sign",
"desc": "Remove certificate signature from PDF"
},
"pageLayout": {
"tags": "layout,arrange,combine",
"title": "Multi-Page Layout",
"desc": "Merge multiple pages of a PDF document into a single page"
},
"bookletImposition": {
"tags": "booklet,print,binding",
"title": "Booklet Imposition",
"desc": "Create booklets with proper page ordering and multi-page layout for printing and binding"
},
"scalePages": {
"tags": "resize,adjust,scale",
"title": "Adjust page size/scale",
"desc": "Change the size/scale of a page and/or its contents."
},
"pipeline": {
"tags": "automation,script,workflow",
"title": "Pipeline",
"desc": "Run multiple actions on PDFs by defining pipeline scripts"
},
"addPageNumbers": {
"tags": "number,pagination,count",
"title": "Add Page Numbers",
"desc": "Add Page numbers throughout a document in a set location"
},
"auto-rename": {
"tags": "auto-detect,header-based,organize,relabel",
"title": "Auto Rename PDF File",
"desc": "Auto renames a PDF file based on its detected header"
},
"adjustContrast": {
"tags": "contrast,brightness,saturation",
"title": "Adjust Colors/Contrast",
"desc": "Adjust Contrast, Saturation and Brightness of a PDF"
},
"crop": {
"tags": "trim,cut,resize",
"title": "Crop PDF",
"desc": "Crop a PDF to reduce its size (maintains text!)"
},
"autoSplitPDF": {
"tags": "auto,split,QR",
"title": "Auto Split Pages",
"desc": "Auto Split Scanned PDF with physical scanned page splitter QR Code"
},
"sanitizePDF": {
"tags": "clean,purge,remove",
"title": "Sanitize",
"desc": "Remove scripts and other elements from PDF files"
},
"URLToPDF": {
"tags": "convert,url,website",
"title": "URL/Website To PDF",
"desc": "Converts any http(s)URL to PDF"
},
"HTMLToPDF": {
"tags": "convert,html,web",
"title": "HTML to PDF",
"desc": "Converts any HTML file or zip to PDF"
},
"MarkdownToPDF": {
"tags": "convert,markdown,md",
"title": "Markdown to PDF",
"desc": "Converts any Markdown file to PDF"
},
"PDFToMarkdown": {
"tags": "convert,markdown,md",
"title": "PDF to Markdown",
"desc": "Converts any PDF to Markdown"
},
"getPdfInfo": {
"tags": "info,metadata,details",
"title": "Get ALL Info on PDF",
"desc": "Grabs any and all information possible on PDFs"
},
@@ -564,50 +617,62 @@
"desc": "Searches and displays any JS injected into a PDF"
},
"autoRedact": {
"tags": "auto,redact,censor",
"title": "Auto Redact",
"desc": "Auto Redacts(Blacks out) text in a PDF based on input text"
},
"redact": {
"tags": "censor,blackout,hide",
"title": "Manual Redaction",
"desc": "Redacts a PDF based on selected text, drawn shapes and/or selected page(s)"
},
"PDFToCSV": {
"tags": "convert,csv,table",
"title": "PDF to CSV",
"desc": "Extracts Tables from a PDF converting it to CSV"
},
"split-by-size-or-count": {
"tags": "auto,split,size",
"title": "Auto Split by Size/Count",
"desc": "Split a single PDF into multiple documents based on size, page count, or document count"
},
"overlay-pdfs": {
"tags": "overlay,combine,stack",
"title": "Overlay PDFs",
"desc": "Overlays PDFs on-top of another PDF"
},
"split-by-sections": {
"tags": "split,sections,divide",
"title": "Split PDF by Sections",
"desc": "Divide each page of a PDF into smaller horizontal and vertical sections"
},
"AddStampRequest": {
"tags": "stamp,mark,seal",
"title": "Add Stamp to PDF",
"desc": "Add text or add image stamps at set locations"
},
"removeImage": {
"tags": "remove,delete,clean",
"title": "Remove image",
"desc": "Remove image from PDF to reduce file size"
},
"splitByChapters": {
"tags": "split,chapters,structure",
"title": "Split PDF by Chapters",
"desc": "Split a PDF into multiple files based on its chapter structure."
},
"validateSignature": {
"tags": "validate,verify,certificate",
"title": "Validate PDF Signature",
"desc": "Verify digital signatures and certificates in PDF documents"
},
"swagger": {
"tags": "API,documentation,test",
"title": "API Documentation",
"desc": "View API documentation and test endpoints"
},
"replace-color": {
"tags": "color,replace,invert",
"title": "Replace and Invert Color",
"desc": "Replace color for text and background in PDF and invert full color of pdf to reduce file size"
}
@@ -704,7 +769,7 @@
"header": "PDF Page Organizer",
"submit": "Rearrange Pages",
"mode": {
"_value": "Mode",
"_value": "Organization mode",
"1": "Custom Page Order",
"2": "Reverse Order",
"3": "Duplex Sort",
@@ -717,6 +782,19 @@
"10": "Odd-Even Merge",
"11": "Duplicate all pages"
},
"desc": {
"CUSTOM": "Use a custom sequence of page numbers or expressions to define a new order.",
"REVERSE_ORDER": "Flip the document so the last page becomes first and so on.",
"DUPLEX_SORT": "Interleave fronts then backs as if a duplex scanner scanned all fronts, then all backs (1, n, 2, n-1, …).",
"BOOKLET_SORT": "Arrange pages for booklet printing (last, first, second, second last, …).",
"SIDE_STITCH_BOOKLET_SORT": "Arrange pages for sidestitch booklet printing (optimized for binding on the side).",
"ODD_EVEN_SPLIT": "Split the document into two outputs: all odd pages and all even pages.",
"ODD_EVEN_MERGE": "Merge two PDFs by alternating pages: odd from the first, even from the second.",
"DUPLICATE": "Duplicate each page according to the custom order count (e.g., 4 duplicates each page 4×).",
"REMOVE_FIRST": "Remove the first page from the document.",
"REMOVE_LAST": "Remove the last page from the document.",
"REMOVE_FIRST_AND_LAST": "Remove both the first and last pages from the document."
},
"placeholder": "(e.g. 1,3,2 or 4-8,2,10-12 or 2n-1)"
},
"addImage": {
@@ -991,7 +1069,13 @@
"header": "Extract Images",
"selectText": "Select image format to convert extracted images to",
"allowDuplicates": "Save duplicate images",
"submit": "Extract"
"submit": "Extract",
"settings": {
"title": "Settings"
},
"error": {
"failed": "An error occurred while extracting images from the PDF."
}
},
"pdfToPDFA": {
"tags": "archive,long-term,standard,conversion,storage,preservation",
@@ -1064,7 +1148,6 @@
"info": "Python is not installed. It is required to run."
},
"sign": {
"tags": "authorize,initials,drawn-signature,text-sign,image-signature",
"title": "Sign",
"header": "Sign PDFs",
"upload": "Upload Image",
@@ -1160,7 +1243,17 @@
"tags": "comments,highlight,notes,markup,remove",
"title": "Remove Annotations",
"header": "Remove Annotations",
"submit": "Remove"
"submit": "Remove",
"settings": {
"title": "Settings"
},
"info": {
"title": "About Remove Annotations",
"description": "This tool will remove all annotations (comments, highlights, notes, etc.) from your PDF documents."
},
"error": {
"failed": "An error occurred while removing annotations from the PDF."
}
},
"compare": {
"tags": "differentiate,contrast,changes,analysis",
@@ -1740,10 +1833,16 @@
}
},
"removeImage": {
"title": "Remove image",
"header": "Remove image",
"removeImage": "Remove image",
"submit": "Remove image"
"title": "Remove Images",
"header": "Remove Images",
"removeImage": "Remove Images",
"submit": "Remove Images",
"results": {
"title": "Remove Images Results"
},
"error": {
"failed": "Failed to remove images from the PDF."
}
},
"splitByChapters": {
"title": "Split PDF by Chapters",
@@ -1788,7 +1887,7 @@
"title": "How we use Cookies",
"description": {
"1": "We use cookies and other technologies to make Stirling PDF work better for you—helping us improve our tools and keep building features you'll love.",
"2": "If youd rather not, clicking 'No Thanks' will only enable the essential cookies needed to keep things running smoothly."
"2": "If you'd rather not, clicking 'No Thanks' will only enable the essential cookies needed to keep things running smoothly."
},
"acceptAllBtn": "Okay",
"acceptNecessaryBtn": "No Thanks",
@@ -1812,7 +1911,7 @@
"1": "Strictly Necessary Cookies",
"2": "Always Enabled"
},
"description": "These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms—which is why they cant be turned off."
"description": "These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms—which is why they can't be turned off."
},
"analytics": {
"title": "Analytics",
@@ -2292,5 +2391,22 @@
},
"automate": {
"copyToSaved": "Copy to Saved"
},
"AddAttachmentsRequest": {
"attachments": "Select Attachments",
"info": "Select files to attach to your PDF. These files will be embedded and accessible through the PDF's attachment panel.",
"selectFiles": "Select Files to Attach",
"placeholder": "Choose files...",
"addMoreFiles": "Add more files...",
"selectedFiles": "Selected Files",
"submit": "Add Attachments",
"results": {
"title": "Attachment Results"
}
},
"addAttachments": {
"error": {
"failed": "An error occurred while adding attachments to the PDF."
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+5 -7
View File
@@ -1,17 +1,15 @@
#!/usr/bin/env node
import { execSync } from 'node:child_process';
import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
const { execSync } = require('node:child_process');
const { existsSync, mkdirSync, writeFileSync, readFileSync } = require('node:fs');
const path = require('node:path');
import { argv } from 'node:process';
const { argv } = require('node:process');
const inputIdx = argv.indexOf('--input');
const INPUT_FILE = inputIdx > -1 ? argv[inputIdx + 1] : null;
const POSTPROCESS_ONLY = !!INPUT_FILE;
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// __dirname is available in CommonJS by default
/**
* Generate 3rd party licenses for frontend dependencies
+6 -3
View File
@@ -1,4 +1,4 @@
import React, { Suspense } from "react";
import { Suspense } from "react";
import { RainbowThemeProvider } from "./components/shared/RainbowThemeProvider";
import { FileContextProvider } from "./contexts/FileContext";
import { NavigationProvider } from "./contexts/NavigationContext";
@@ -14,6 +14,7 @@ import "./styles/cookieconsent.css";
import "./index.css";
import { RightRailProvider } from "./contexts/RightRailContext";
import { ViewerProvider } from "./contexts/ViewerContext";
import { SignatureProvider } from "./contexts/SignatureContext";
// Import file ID debugging helpers (development only)
import "./utils/fileIdSafety";
@@ -45,9 +46,11 @@ export default function App() {
<ToolWorkflowProvider>
<SidebarProvider>
<ViewerProvider>
<SignatureProvider>
<RightRailProvider>
<HomePage />
</RightRailProvider>
<HomePage />
</RightRailProvider>
</SignatureProvider>
</ViewerProvider>
</SidebarProvider>
</ToolWorkflowProvider>
+134 -1
View File
@@ -7,6 +7,132 @@
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "git+https://github.com/atlassian/pragmatic-drag-and-drop.git"
},
{
"moduleName": "@embedpdf/core",
"moduleUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.1.tgz",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.1.tgz"
},
{
"moduleName": "@embedpdf/engines",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-annotation",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-export",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-history",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-interaction-manager",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-loader",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-pan",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-render",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-rotate",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-scroll",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-search",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-selection",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-spread",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-thumbnail",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-tiling",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-viewport",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-zoom",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@emotion/react",
"moduleUrl": "git+https://github.com/emotion-js/emotion.git#main",
@@ -35,6 +161,13 @@
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
},
{
"moduleName": "@mantine/dates",
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
"moduleVersion": "8.3.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
},
{
"moduleName": "@mantine/dropzone",
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
@@ -143,7 +276,7 @@
{
"moduleName": "posthog-js",
"moduleUrl": "git+https://github.com/PostHog/posthog-js.git",
"moduleVersion": "1.266.0",
"moduleVersion": "1.268.0",
"moduleLicense": "SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE",
"moduleLicenseUrl": "git+https://github.com/PostHog/posthog-js.git"
},
@@ -0,0 +1,91 @@
import React, { createContext, useContext, ReactNode } from 'react';
interface PDFAnnotationContextValue {
// Drawing mode management
activateDrawMode: () => void;
deactivateDrawMode: () => void;
activateSignaturePlacementMode: () => void;
activateDeleteMode: () => void;
// Drawing settings
updateDrawSettings: (color: string, size: number) => void;
// History operations
undo: () => void;
redo: () => void;
// Image data management
storeImageData: (id: string, data: string) => void;
getImageData: (id: string) => string | undefined;
// Placement state
isPlacementMode: boolean;
// Signature configuration
signatureConfig: any | null;
setSignatureConfig: (config: any | null) => void;
}
const PDFAnnotationContext = createContext<PDFAnnotationContextValue | undefined>(undefined);
interface PDFAnnotationProviderProps {
children: ReactNode;
// These would come from the signature context
activateDrawMode: () => void;
deactivateDrawMode: () => void;
activateSignaturePlacementMode: () => void;
activateDeleteMode: () => void;
updateDrawSettings: (color: string, size: number) => void;
undo: () => void;
redo: () => void;
storeImageData: (id: string, data: string) => void;
getImageData: (id: string) => string | undefined;
isPlacementMode: boolean;
signatureConfig: any | null;
setSignatureConfig: (config: any | null) => void;
}
export const PDFAnnotationProvider: React.FC<PDFAnnotationProviderProps> = ({
children,
activateDrawMode,
deactivateDrawMode,
activateSignaturePlacementMode,
activateDeleteMode,
updateDrawSettings,
undo,
redo,
storeImageData,
getImageData,
isPlacementMode,
signatureConfig,
setSignatureConfig
}) => {
const contextValue: PDFAnnotationContextValue = {
activateDrawMode,
deactivateDrawMode,
activateSignaturePlacementMode,
activateDeleteMode,
updateDrawSettings,
undo,
redo,
storeImageData,
getImageData,
isPlacementMode,
signatureConfig,
setSignatureConfig
};
return (
<PDFAnnotationContext.Provider value={contextValue}>
{children}
</PDFAnnotationContext.Provider>
);
};
export const usePDFAnnotation = (): PDFAnnotationContextValue => {
const context = useContext(PDFAnnotationContext);
if (context === undefined) {
throw new Error('usePDFAnnotation must be used within a PDFAnnotationProvider');
}
return context;
};
@@ -0,0 +1,89 @@
import React, { useState } from 'react';
import { Stack, Alert, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { DrawingControls } from './DrawingControls';
import { ColorPicker } from './ColorPicker';
import { usePDFAnnotation } from '../providers/PDFAnnotationProvider';
export interface AnnotationToolConfig {
enableDrawing?: boolean;
enableImageUpload?: boolean;
enableTextInput?: boolean;
showPlaceButton?: boolean;
placeButtonText?: string;
}
interface BaseAnnotationToolProps {
config: AnnotationToolConfig;
children: React.ReactNode;
onSignatureDataChange?: (data: string | null) => void;
disabled?: boolean;
}
export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
config,
children,
onSignatureDataChange,
disabled = false
}) => {
const { t } = useTranslation();
const {
activateSignaturePlacementMode,
undo,
redo
} = usePDFAnnotation();
const [selectedColor, setSelectedColor] = useState('#000000');
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
const [signatureData, setSignatureData] = useState<string | null>(null);
const handleSignatureDataChange = (data: string | null) => {
setSignatureData(data);
onSignatureDataChange?.(data);
};
const handlePlaceSignature = () => {
if (activateSignaturePlacementMode) {
activateSignaturePlacementMode();
}
};
return (
<Stack gap="md">
{/* Drawing Controls (Undo/Redo/Place) */}
<DrawingControls
onUndo={undo}
onRedo={redo}
onPlaceSignature={config.showPlaceButton ? handlePlaceSignature : undefined}
hasSignatureData={!!signatureData}
disabled={disabled}
showPlaceButton={config.showPlaceButton}
placeButtonText={config.placeButtonText}
/>
{/* Tool Content */}
{React.cloneElement(children as React.ReactElement<any>, {
selectedColor,
signatureData,
onSignatureDataChange: handleSignatureDataChange,
onColorSwatchClick: () => setIsColorPickerOpen(true),
disabled
})}
{/* Instructions for placing signature */}
<Alert color="blue" title={t('sign.instructions.title', 'How to add signature')}>
<Text size="sm">
Click anywhere on the PDF to place your annotation.
</Text>
</Alert>
{/* Color Picker Modal */}
<ColorPicker
isOpen={isColorPickerOpen}
onClose={() => setIsColorPickerOpen(false)}
selectedColor={selectedColor}
onColorChange={setSelectedColor}
/>
</Stack>
);
};
@@ -0,0 +1,67 @@
import React from 'react';
import { Modal, Stack, ColorPicker as MantineColorPicker, Group, Button, ColorSwatch } from '@mantine/core';
interface ColorPickerProps {
isOpen: boolean;
onClose: () => void;
selectedColor: string;
onColorChange: (color: string) => void;
title?: string;
}
export const ColorPicker: React.FC<ColorPickerProps> = ({
isOpen,
onClose,
selectedColor,
onColorChange,
title = "Choose Color"
}) => {
return (
<Modal
opened={isOpen}
onClose={onClose}
title={title}
size="sm"
centered
>
<Stack gap="md">
<MantineColorPicker
format="hex"
value={selectedColor}
onChange={onColorChange}
swatches={['#000000', '#0066cc', '#cc0000', '#cc6600', '#009900', '#6600cc']}
swatchesPerRow={6}
size="lg"
fullWidth
/>
<Group justify="flex-end">
<Button onClick={onClose}>
Done
</Button>
</Group>
</Stack>
</Modal>
);
};
interface ColorSwatchButtonProps {
color: string;
onClick: () => void;
size?: number;
}
export const ColorSwatchButton: React.FC<ColorSwatchButtonProps> = ({
color,
onClick,
size = 24
}) => {
return (
<ColorSwatch
color={color}
size={size}
radius={0}
style={{ cursor: 'pointer' }}
onClick={onClick}
/>
);
};
@@ -0,0 +1,437 @@
import React, { useRef, useState, useCallback } from 'react';
import { Paper, Group, Button, Modal, Stack, Text } from '@mantine/core';
import { ColorSwatchButton } from './ColorPicker';
import PenSizeSelector from '../../tools/sign/PenSizeSelector';
interface DrawingCanvasProps {
selectedColor: string;
penSize: number;
penSizeInput: string;
onColorSwatchClick: () => void;
onPenSizeChange: (size: number) => void;
onPenSizeInputChange: (input: string) => void;
onSignatureDataChange: (data: string | null) => void;
disabled?: boolean;
width?: number;
height?: number;
modalWidth?: number;
modalHeight?: number;
additionalButtons?: React.ReactNode;
}
export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
selectedColor,
penSize,
penSizeInput,
onColorSwatchClick,
onPenSizeChange,
onPenSizeInputChange,
onSignatureDataChange,
disabled = false,
width = 400,
height = 150,
modalWidth = 800,
modalHeight = 400,
additionalButtons
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const modalCanvasRef = useRef<HTMLCanvasElement>(null);
const visibleModalCanvasRef = useRef<HTMLCanvasElement>(null);
const [isDrawing, setIsDrawing] = useState(false);
const [isModalDrawing, setIsModalDrawing] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
// Drawing functions for main canvas
const startDrawing = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
if (!canvasRef.current || disabled) return;
setIsDrawing(true);
const rect = canvasRef.current.getBoundingClientRect();
const scaleX = canvasRef.current.width / rect.width;
const scaleY = canvasRef.current.height / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
const ctx = canvasRef.current.getContext('2d');
if (ctx) {
ctx.strokeStyle = selectedColor;
ctx.lineWidth = penSize;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(x, y);
}
}, [disabled, selectedColor, penSize]);
const draw = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isDrawing || !canvasRef.current || disabled) return;
const rect = canvasRef.current.getBoundingClientRect();
const scaleX = canvasRef.current.width / rect.width;
const scaleY = canvasRef.current.height / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
const ctx = canvasRef.current.getContext('2d');
if (ctx) {
ctx.lineTo(x, y);
ctx.stroke();
}
}, [isDrawing, disabled]);
const stopDrawing = useCallback(() => {
if (!isDrawing || disabled) return;
setIsDrawing(false);
// Save canvas as signature data
if (canvasRef.current) {
const dataURL = canvasRef.current.toDataURL('image/png');
onSignatureDataChange(dataURL);
}
}, [isDrawing, disabled, onSignatureDataChange]);
// Modal canvas drawing functions
const startModalDrawing = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
if (!visibleModalCanvasRef.current || !modalCanvasRef.current) return;
setIsModalDrawing(true);
const rect = visibleModalCanvasRef.current.getBoundingClientRect();
const scaleX = visibleModalCanvasRef.current.width / rect.width;
const scaleY = visibleModalCanvasRef.current.height / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
// Draw on both the visible modal canvas and hidden canvas
const visibleCtx = visibleModalCanvasRef.current.getContext('2d');
const hiddenCtx = modalCanvasRef.current.getContext('2d');
[visibleCtx, hiddenCtx].forEach(ctx => {
if (ctx) {
ctx.strokeStyle = selectedColor;
ctx.lineWidth = penSize;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(x, y);
}
});
}, [selectedColor, penSize]);
const drawModal = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isModalDrawing || !visibleModalCanvasRef.current || !modalCanvasRef.current) return;
const rect = visibleModalCanvasRef.current.getBoundingClientRect();
const scaleX = visibleModalCanvasRef.current.width / rect.width;
const scaleY = visibleModalCanvasRef.current.height / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
// Draw on both canvases
const visibleCtx = visibleModalCanvasRef.current.getContext('2d');
const hiddenCtx = modalCanvasRef.current.getContext('2d');
[visibleCtx, hiddenCtx].forEach(ctx => {
if (ctx) {
ctx.lineTo(x, y);
ctx.stroke();
}
});
}, [isModalDrawing]);
const stopModalDrawing = useCallback(() => {
if (!isModalDrawing) return;
setIsModalDrawing(false);
// Sync the canvases and update signature data (only when drawing stops)
if (modalCanvasRef.current) {
const dataURL = modalCanvasRef.current.toDataURL('image/png');
onSignatureDataChange(dataURL);
// Also update the small canvas display
if (canvasRef.current) {
const smallCtx = canvasRef.current.getContext('2d');
if (smallCtx) {
const img = new Image();
img.onload = () => {
smallCtx.clearRect(0, 0, canvasRef.current!.width, canvasRef.current!.height);
smallCtx.drawImage(img, 0, 0, canvasRef.current!.width, canvasRef.current!.height);
};
img.src = dataURL;
}
}
}
}, [isModalDrawing]);
// Clear canvas functions
const clearCanvas = useCallback(() => {
if (!canvasRef.current || disabled) return;
const ctx = canvasRef.current.getContext('2d');
if (ctx) {
ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);
// Also clear the modal canvas if it exists
if (modalCanvasRef.current) {
const modalCtx = modalCanvasRef.current.getContext('2d');
if (modalCtx) {
modalCtx.clearRect(0, 0, modalCanvasRef.current.width, modalCanvasRef.current.height);
}
}
onSignatureDataChange(null);
}
}, [disabled]);
const clearModalCanvas = useCallback(() => {
// Clear both modal canvases (visible and hidden)
if (modalCanvasRef.current) {
const hiddenCtx = modalCanvasRef.current.getContext('2d');
if (hiddenCtx) {
hiddenCtx.clearRect(0, 0, modalCanvasRef.current.width, modalCanvasRef.current.height);
}
}
if (visibleModalCanvasRef.current) {
const visibleCtx = visibleModalCanvasRef.current.getContext('2d');
if (visibleCtx) {
visibleCtx.clearRect(0, 0, visibleModalCanvasRef.current.width, visibleModalCanvasRef.current.height);
}
}
// Also clear the main canvas and signature data
if (canvasRef.current) {
const mainCtx = canvasRef.current.getContext('2d');
if (mainCtx) {
mainCtx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);
}
}
onSignatureDataChange(null);
}, []);
const saveModalSignature = useCallback(() => {
if (!modalCanvasRef.current) return;
const dataURL = modalCanvasRef.current.toDataURL('image/png');
onSignatureDataChange(dataURL);
// Copy to small canvas for display
if (canvasRef.current) {
const ctx = canvasRef.current.getContext('2d');
if (ctx) {
const img = new Image();
img.onload = () => {
ctx.clearRect(0, 0, canvasRef.current!.width, canvasRef.current!.height);
ctx.drawImage(img, 0, 0, canvasRef.current!.width, canvasRef.current!.height);
};
img.src = dataURL;
}
}
setIsModalOpen(false);
}, []);
const openModal = useCallback(() => {
setIsModalOpen(true);
// Copy content to modal canvas after a brief delay
setTimeout(() => {
if (visibleModalCanvasRef.current && modalCanvasRef.current) {
const visibleCtx = visibleModalCanvasRef.current.getContext('2d');
if (visibleCtx) {
visibleCtx.strokeStyle = selectedColor;
visibleCtx.lineWidth = penSize;
visibleCtx.lineCap = 'round';
visibleCtx.lineJoin = 'round';
visibleCtx.clearRect(0, 0, visibleModalCanvasRef.current.width, visibleModalCanvasRef.current.height);
visibleCtx.drawImage(modalCanvasRef.current, 0, 0, visibleModalCanvasRef.current.width, visibleModalCanvasRef.current.height);
}
}
}, 300);
}, [selectedColor, penSize]);
// Initialize canvas settings whenever color or pen size changes
React.useEffect(() => {
const updateCanvas = (canvas: HTMLCanvasElement | null) => {
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.strokeStyle = selectedColor;
ctx.lineWidth = penSize;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
}
};
updateCanvas(canvasRef.current);
updateCanvas(modalCanvasRef.current);
updateCanvas(visibleModalCanvasRef.current);
}, [selectedColor, penSize]);
return (
<>
<Paper withBorder p="md">
<Stack gap="sm">
<Group justify="space-between">
<Text fw={500}>Draw your signature</Text>
<Group gap="lg">
<div>
<Text size="sm" fw={500} mb="xs" ta="center">Color</Text>
<Group justify="center">
<ColorSwatchButton
color={selectedColor}
onClick={onColorSwatchClick}
/>
</Group>
</div>
<div>
<Text size="sm" fw={500} mb="xs">Pen Size</Text>
<PenSizeSelector
value={penSize}
inputValue={penSizeInput}
onValueChange={onPenSizeChange}
onInputChange={onPenSizeInputChange}
disabled={disabled}
placeholder="Size"
size="compact-sm"
style={{ width: '60px' }}
/>
</div>
<div style={{ paddingTop: '24px' }}>
<Button
variant="light"
size="compact-sm"
onClick={openModal}
disabled={disabled}
>
Expand
</Button>
</div>
</Group>
</Group>
<canvas
ref={canvasRef}
width={width}
height={height}
style={{
border: '1px solid #ccc',
borderRadius: '4px',
cursor: disabled ? 'default' : 'crosshair',
backgroundColor: '#ffffff',
width: '100%',
}}
onMouseDown={startDrawing}
onMouseMove={draw}
onMouseUp={stopDrawing}
onMouseLeave={stopDrawing}
/>
<Group justify="space-between">
<div>
{additionalButtons}
</div>
<Button
variant="subtle"
color="red"
size="compact-sm"
onClick={clearCanvas}
disabled={disabled}
>
Clear
</Button>
</Group>
</Stack>
</Paper>
{/* Hidden canvas for modal synchronization */}
<canvas
ref={modalCanvasRef}
width={modalWidth}
height={modalHeight}
style={{ display: 'none' }}
/>
{/* Modal for larger signature canvas */}
<Modal
opened={isModalOpen}
onClose={() => setIsModalOpen(false)}
title="Draw Your Signature"
size="xl"
centered
>
<Stack gap="md">
{/* Color and Pen Size picker */}
<Paper withBorder p="sm">
<Group gap="lg" align="flex-end">
<div>
<Text size="sm" fw={500} mb="xs">Color</Text>
<ColorSwatchButton
color={selectedColor}
onClick={onColorSwatchClick}
/>
</div>
<div>
<Text size="sm" fw={500} mb="xs">Pen Size</Text>
<PenSizeSelector
value={penSize}
inputValue={penSizeInput}
onValueChange={onPenSizeChange}
onInputChange={onPenSizeInputChange}
placeholder="Size"
size="compact-sm"
style={{ width: '60px' }}
/>
</div>
</Group>
</Paper>
<Paper withBorder p="md">
<canvas
ref={visibleModalCanvasRef}
width={modalWidth}
height={modalHeight}
style={{
border: '1px solid #ccc',
borderRadius: '4px',
cursor: 'crosshair',
backgroundColor: '#ffffff',
width: '100%',
maxWidth: `${modalWidth}px`,
height: 'auto',
}}
onMouseDown={startModalDrawing}
onMouseMove={drawModal}
onMouseUp={stopModalDrawing}
onMouseLeave={stopModalDrawing}
/>
</Paper>
<Group justify="space-between">
<Button
variant="subtle"
color="red"
onClick={clearModalCanvas}
>
Clear Canvas
</Button>
<Group gap="sm">
<Button
variant="subtle"
onClick={() => setIsModalOpen(false)}
>
Cancel
</Button>
<Button
onClick={saveModalSignature}
>
Save Signature
</Button>
</Group>
</Group>
</Stack>
</Modal>
</>
);
};
export default DrawingCanvas;
@@ -0,0 +1,60 @@
import React from 'react';
import { Group, Button } from '@mantine/core';
import { useTranslation } from 'react-i18next';
interface DrawingControlsProps {
onUndo?: () => void;
onRedo?: () => void;
onPlaceSignature?: () => void;
hasSignatureData?: boolean;
disabled?: boolean;
showPlaceButton?: boolean;
placeButtonText?: string;
}
export const DrawingControls: React.FC<DrawingControlsProps> = ({
onUndo,
onRedo,
onPlaceSignature,
hasSignatureData = false,
disabled = false,
showPlaceButton = true,
placeButtonText = "Update and Place"
}) => {
const { t } = useTranslation();
return (
<Group gap="sm">
{/* Undo/Redo Controls */}
<Button
variant="outline"
onClick={onUndo}
disabled={disabled}
flex={1}
>
{t('sign.undo', 'Undo')}
</Button>
<Button
variant="outline"
onClick={onRedo}
disabled={disabled}
flex={1}
>
{t('sign.redo', 'Redo')}
</Button>
{/* Place Signature Button */}
{showPlaceButton && onPlaceSignature && (
<Button
variant="filled"
color="blue"
onClick={onPlaceSignature}
disabled={disabled || !hasSignatureData}
flex={1}
>
{placeButtonText}
</Button>
)}
</Group>
);
};
@@ -0,0 +1,55 @@
import React from 'react';
import { FileInput, Text, Stack } from '@mantine/core';
import { useTranslation } from 'react-i18next';
interface ImageUploaderProps {
onImageChange: (file: File | null) => void;
disabled?: boolean;
label?: string;
placeholder?: string;
hint?: string;
}
export const ImageUploader: React.FC<ImageUploaderProps> = ({
onImageChange,
disabled = false,
label,
placeholder,
hint
}) => {
const { t } = useTranslation();
const handleImageChange = async (file: File | null) => {
if (file && !disabled) {
try {
// Validate that it's actually an image file
if (!file.type.startsWith('image/')) {
console.error('Selected file is not an image');
return;
}
onImageChange(file);
} catch (error) {
console.error('Error processing image file:', error);
}
} else if (!file) {
// Clear image data when no file is selected
onImageChange(null);
}
};
return (
<Stack gap="sm">
<FileInput
label={label || t('sign.image.label', 'Upload signature image')}
placeholder={placeholder || t('sign.image.placeholder', 'Select image file')}
accept="image/*"
onChange={handleImageChange}
disabled={disabled}
/>
<Text size="sm" c="dimmed">
{hint || t('sign.image.hint', 'Upload a PNG or JPG image of your signature')}
</Text>
</Stack>
);
};
@@ -0,0 +1,126 @@
import React, { useState, useEffect } from 'react';
import { Stack, TextInput, Select, Combobox, useCombobox } from '@mantine/core';
import { useTranslation } from 'react-i18next';
interface TextInputWithFontProps {
text: string;
onTextChange: (text: string) => void;
fontSize: number;
onFontSizeChange: (size: number) => void;
fontFamily: string;
onFontFamilyChange: (family: string) => void;
disabled?: boolean;
label?: string;
placeholder?: string;
}
export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
text,
onTextChange,
fontSize,
onFontSizeChange,
fontFamily,
onFontFamilyChange,
disabled = false,
label,
placeholder
}) => {
const { t } = useTranslation();
const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString());
const fontSizeCombobox = useCombobox();
// Sync font size input with prop changes
useEffect(() => {
setFontSizeInput(fontSize.toString());
}, [fontSize]);
const fontOptions = [
{ value: 'Helvetica', label: 'Helvetica' },
{ value: 'Times-Roman', label: 'Times' },
{ value: 'Courier', label: 'Courier' },
{ value: 'Arial', label: 'Arial' },
{ value: 'Georgia', label: 'Georgia' },
];
const fontSizeOptions = ['8', '12', '16', '20', '24', '28', '32', '36', '40', '48'];
return (
<Stack gap="sm">
<TextInput
label={label || t('sign.text.name', 'Signer Name')}
placeholder={placeholder || t('sign.text.placeholder', 'Enter your full name')}
value={text}
onChange={(e) => onTextChange(e.target.value)}
disabled={disabled}
required
/>
{/* Font Selection */}
<Select
label="Font"
value={fontFamily}
onChange={(value) => onFontFamilyChange(value || 'Helvetica')}
data={fontOptions}
disabled={disabled}
searchable
allowDeselect={false}
/>
{/* Font Size */}
<Combobox
onOptionSubmit={(optionValue) => {
setFontSizeInput(optionValue);
const size = parseInt(optionValue);
if (!isNaN(size)) {
onFontSizeChange(size);
}
fontSizeCombobox.closeDropdown();
}}
store={fontSizeCombobox}
withinPortal={false}
>
<Combobox.Target>
<TextInput
label="Font Size"
placeholder="Type or select font size (8-72)"
value={fontSizeInput}
onChange={(event) => {
const value = event.currentTarget.value;
setFontSizeInput(value);
// Parse and validate the typed value in real-time
const size = parseInt(value);
if (!isNaN(size) && size >= 8 && size <= 72) {
onFontSizeChange(size);
}
fontSizeCombobox.openDropdown();
fontSizeCombobox.updateSelectedOptionIndex();
}}
onClick={() => fontSizeCombobox.openDropdown()}
onFocus={() => fontSizeCombobox.openDropdown()}
onBlur={() => {
fontSizeCombobox.closeDropdown();
// Clean up invalid values on blur
const size = parseInt(fontSizeInput);
if (isNaN(size) || size < 8 || size > 72) {
setFontSizeInput(fontSize.toString());
}
}}
disabled={disabled}
/>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Options>
{fontSizeOptions.map((size) => (
<Combobox.Option value={size} key={size}>
{size}px
</Combobox.Option>
))}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
</Stack>
);
};
@@ -0,0 +1,45 @@
import React, { useState } from 'react';
import { Stack } from '@mantine/core';
import { BaseAnnotationTool } from '../shared/BaseAnnotationTool';
import { DrawingCanvas } from '../shared/DrawingCanvas';
interface DrawingToolProps {
onDrawingChange?: (data: string | null) => void;
disabled?: boolean;
}
export const DrawingTool: React.FC<DrawingToolProps> = ({
onDrawingChange,
disabled = false
}) => {
const [selectedColor] = useState('#000000');
const [penSize, setPenSize] = useState(2);
const [penSizeInput, setPenSizeInput] = useState('2');
const toolConfig = {
enableDrawing: true,
showPlaceButton: true,
placeButtonText: "Place Drawing"
};
return (
<BaseAnnotationTool
config={toolConfig}
onSignatureDataChange={onDrawingChange}
disabled={disabled}
>
<Stack gap="sm">
<DrawingCanvas
selectedColor={selectedColor}
penSize={penSize}
penSizeInput={penSizeInput}
onColorSwatchClick={() => {}} // Color picker handled by BaseAnnotationTool
onPenSizeChange={setPenSize}
onPenSizeInputChange={setPenSizeInput}
onSignatureDataChange={onDrawingChange || (() => {})}
disabled={disabled}
/>
</Stack>
</BaseAnnotationTool>
);
};
@@ -0,0 +1,67 @@
import React, { useState } from 'react';
import { Stack } from '@mantine/core';
import { BaseAnnotationTool } from '../shared/BaseAnnotationTool';
import { ImageUploader } from '../shared/ImageUploader';
interface ImageToolProps {
onImageChange?: (data: string | null) => void;
disabled?: boolean;
}
export const ImageTool: React.FC<ImageToolProps> = ({
onImageChange,
disabled = false
}) => {
const [, setImageData] = useState<string | null>(null);
const handleImageUpload = async (file: File | null) => {
if (file && !disabled) {
try {
const result = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
if (e.target?.result) {
resolve(e.target.result as string);
} else {
reject(new Error('Failed to read file'));
}
};
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
setImageData(result);
onImageChange?.(result);
} catch (error) {
console.error('Error reading file:', error);
}
} else if (!file) {
setImageData(null);
onImageChange?.(null);
}
};
const toolConfig = {
enableImageUpload: true,
showPlaceButton: true,
placeButtonText: "Place Image"
};
return (
<BaseAnnotationTool
config={toolConfig}
onSignatureDataChange={onImageChange}
disabled={disabled}
>
<Stack gap="sm">
<ImageUploader
onImageChange={handleImageUpload}
disabled={disabled}
label="Upload Image"
placeholder="Select image file"
hint="Upload a PNG, JPG, or other image file to place on the PDF"
/>
</Stack>
</BaseAnnotationTool>
);
};
@@ -0,0 +1,57 @@
import React, { useState } from 'react';
import { Stack } from '@mantine/core';
import { BaseAnnotationTool } from '../shared/BaseAnnotationTool';
import { TextInputWithFont } from '../shared/TextInputWithFont';
interface TextToolProps {
onTextChange?: (text: string) => void;
disabled?: boolean;
}
export const TextTool: React.FC<TextToolProps> = ({
onTextChange,
disabled = false
}) => {
const [text, setText] = useState('');
const [fontSize, setFontSize] = useState(16);
const [fontFamily, setFontFamily] = useState('Helvetica');
const handleTextChange = (newText: string) => {
setText(newText);
onTextChange?.(newText);
};
const handleSignatureDataChange = (data: string | null) => {
if (data) {
onTextChange?.(data);
}
};
const toolConfig = {
enableTextInput: true,
showPlaceButton: true,
placeButtonText: "Place Text"
};
return (
<BaseAnnotationTool
config={toolConfig}
onSignatureDataChange={handleSignatureDataChange}
disabled={disabled}
>
<Stack gap="sm">
<TextInputWithFont
text={text}
onTextChange={handleTextChange}
fontSize={fontSize}
onFontSizeChange={setFontSize}
fontFamily={fontFamily}
onFontFamilyChange={setFontFamily}
disabled={disabled}
label="Text Content"
placeholder="Enter text to place on the PDF"
/>
</Stack>
</BaseAnnotationTool>
);
};
@@ -56,6 +56,20 @@
border-bottom: 1px solid var(--header-selected-bg);
}
/* Error highlight (transient) */
.headerError {
background: var(--color-red-200);
color: var(--text-primary);
border-bottom: 2px solid var(--color-red-500);
}
/* Unsupported (but not errored) header appearance */
.headerUnsupported {
background: var(--unsupported-bar-bg); /* neutral gray */
color: #FFFFFF;
border-bottom: 1px solid var(--unsupported-bar-border);
}
/* Selected border color in light mode */
:global([data-mantine-color-scheme="light"]) .card[data-selected="true"] {
outline-color: var(--card-selected-border);
@@ -80,6 +94,7 @@
.kebab {
justify-self: end;
color: #FFFFFF !important;
}
/* Menu dropdown */
@@ -217,6 +232,22 @@
height: 20px;
}
/* Error pill shown when a file failed processing */
.errorPill {
margin-left: 1.75rem;
background: var(--color-red-500);
color: #ffffff;
padding: 4px 8px;
border-radius: 12px;
font-size: 10px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
min-width: 56px;
height: 20px;
}
/* Animations */
@keyframes pulse {
0%, 100% {
@@ -1,6 +1,6 @@
import React, { useState, useCallback, useRef, useMemo } from 'react';
import {
Text, Center, Box, Notification, LoadingOverlay, Stack, Group, Portal
Text, Center, Box, LoadingOverlay, Stack, Group
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { useFileSelection, useFileState, useFileManagement } from '../../contexts/FileContext';
@@ -11,6 +11,7 @@ import FileEditorThumbnail from './FileEditorThumbnail';
import FilePickerModal from '../shared/FilePickerModal';
import SkeletonLoader from '../shared/SkeletonLoader';
import { FileId, StirlingFile } from '../../types/fileContext';
import { alert } from '../toast';
import { downloadBlob } from '../../utils/downloadUtils';
@@ -46,8 +47,16 @@ const FileEditor = ({
// Get file selection context
const { setSelectedFiles } = useFileSelection();
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [_status, _setStatus] = useState<string | null>(null);
const [_error, _setError] = useState<string | null>(null);
// Toast helpers
const showStatus = useCallback((message: string, type: 'neutral' | 'success' | 'warning' | 'error' = 'neutral') => {
alert({ alertType: type, title: message, expandable: false, durationMs: 4000 });
}, []);
const showError = useCallback((message: string) => {
alert({ alertType: 'error', title: 'Error', body: message, expandable: true });
}, []);
const [selectionMode, setSelectionMode] = useState(toolMode);
// Enable selection mode automatically in tool mode
@@ -82,7 +91,7 @@ const FileEditor = ({
// Process uploaded files using context
const handleFileUpload = useCallback(async (uploadedFiles: File[]) => {
setError(null);
_setError(null);
try {
const allExtractedFiles: File[] = [];
@@ -157,18 +166,18 @@ const FileEditor = ({
// Show any errors
if (errors.length > 0) {
setError(errors.join('\n'));
showError(errors.join('\n'));
}
// Process all extracted files
if (allExtractedFiles.length > 0) {
// Add files to context (they will be processed automatically)
await addFiles(allExtractedFiles);
setStatus(`Added ${allExtractedFiles.length} files`);
showStatus(`Added ${allExtractedFiles.length} files`, 'success');
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to process files';
setError(errorMessage);
showError(errorMessage);
console.error('File processing error:', err);
// Reset extraction progress on error
@@ -206,7 +215,7 @@ const FileEditor = ({
} else {
// Check if we've hit the selection limit
if (maxAllowed > 1 && currentSelectedIds.length >= maxAllowed) {
setStatus(`Maximum ${maxAllowed} files can be selected`);
showStatus(`Maximum ${maxAllowed} files can be selected`, 'warning');
return;
}
newSelection = [...currentSelectedIds, contextFileId];
@@ -215,7 +224,7 @@ const FileEditor = ({
// Update context (this automatically updates tool selection since they use the same action)
setSelectedFiles(newSelection);
}, [setSelectedFiles, toolMode, setStatus, activeStirlingFileStubs]);
}, [setSelectedFiles, toolMode, _setStatus, activeStirlingFileStubs]);
// File reordering handler for drag and drop
@@ -271,8 +280,8 @@ const FileEditor = ({
// Update status
const moveCount = filesToMove.length;
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
}, [activeStirlingFileStubs, reorderFiles, setStatus]);
showStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
}, [activeStirlingFileStubs, reorderFiles, _setStatus]);
@@ -297,7 +306,7 @@ const FileEditor = ({
if (record && file) {
downloadBlob(file, file.name);
}
}, [activeStirlingFileStubs, selectors, setStatus]);
}, [activeStirlingFileStubs, selectors, _setStatus]);
const handleViewFile = useCallback((fileId: FileId) => {
const record = activeStirlingFileStubs.find(r => r.id === fileId);
@@ -314,10 +323,10 @@ const FileEditor = ({
try {
// 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`);
showStatus(`Loaded ${selectedFiles.length} files from storage`);
} catch (err) {
console.error('Error loading files from storage:', err);
setError('Failed to load some files from storage');
showError('Failed to load some files from storage');
}
}, []);
@@ -408,7 +417,7 @@ const FileEditor = ({
onToggleFile={toggleFile}
onDeleteFile={handleDeleteFile}
onViewFile={handleViewFile}
onSetStatus={setStatus}
_onSetStatus={showStatus}
onReorderFiles={handleReorderFiles}
onDownloadFile={handleDownloadFile}
toolMode={toolMode}
@@ -428,31 +437,7 @@ const FileEditor = ({
onSelectFiles={handleLoadFromStorage}
/>
{status && (
<Portal>
<Notification
color="blue"
mt="md"
onClose={() => setStatus(null)}
style={{ position: 'fixed', bottom: 40, right: 80, zIndex: 10001 }}
>
{status}
</Notification>
</Portal>
)}
{error && (
<Portal>
<Notification
color="red"
mt="md"
onClose={() => setError(null)}
style={{ position: 'fixed', bottom: 80, right: 20, zIndex: 10001 }}
>
{error}
</Notification>
</Portal>
)}
</Box>
</Dropzone>
);
@@ -1,5 +1,6 @@
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
import { Text, ActionIcon, CheckboxIndicator } from '@mantine/core';
import { alert } from '../toast';
import { useTranslation } from 'react-i18next';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
@@ -12,6 +13,7 @@ import { StirlingFileStub } from '../../types/fileContext';
import styles from './FileEditor.module.css';
import { useFileContext } from '../../contexts/FileContext';
import { useFileState } from '../../contexts/file/fileHooks';
import { FileId } from '../../types/file';
import { formatFileSize } from '../../utils/fileUtils';
import ToolChain from '../shared/ToolChain';
@@ -27,7 +29,7 @@ interface FileEditorThumbnailProps {
onToggleFile: (fileId: FileId) => void;
onDeleteFile: (fileId: FileId) => void;
onViewFile: (fileId: FileId) => void;
onSetStatus: (status: string) => void;
_onSetStatus: (status: string) => void;
onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void;
onDownloadFile: (fileId: FileId) => void;
toolMode?: boolean;
@@ -40,13 +42,15 @@ const FileEditorThumbnail = ({
selectedFiles,
onToggleFile,
onDeleteFile,
onSetStatus,
_onSetStatus,
onReorderFiles,
onDownloadFile,
isSupported = true,
}: FileEditorThumbnailProps) => {
const { t } = useTranslation();
const { pinFile, unpinFile, isFilePinned, activeFiles } = useFileContext();
const { pinFile, unpinFile, isFilePinned, activeFiles, actions: fileActions } = useFileContext();
const { state } = useFileState();
const hasError = state.ui.errorFileIds.includes(file.id);
// ---- Drag state ----
const [isDragging, setIsDragging] = useState(false);
@@ -187,9 +191,20 @@ const FileEditorThumbnail = ({
// ---- Card interactions ----
const handleCardClick = () => {
if (!isSupported) return;
// Clear error state if file has an error (click to clear error)
if (hasError) {
try { fileActions.clearFileError(file.id); } catch (_e) { void _e; }
}
onToggleFile(file.id);
};
// ---- Style helpers ----
const getHeaderClassName = () => {
if (hasError) return styles.headerError;
if (!isSupported) return styles.headerUnsupported;
return isSelected ? styles.headerSelected : styles.headerResting;
};
return (
<div
@@ -199,10 +214,7 @@ const FileEditorThumbnail = ({
data-selected={isSelected}
data-supported={isSupported}
className={`${styles.card} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative`}
style={{
opacity: isSupported ? (isDragging ? 0.9 : 1) : 0.5,
filter: isSupported ? 'none' : 'grayscale(50%)',
}}
style={{opacity: isDragging ? 0.9 : 1}}
tabIndex={0}
role="listitem"
aria-selected={isSelected}
@@ -210,13 +222,16 @@ const FileEditorThumbnail = ({
>
{/* Header bar */}
<div
className={`${styles.header} ${
isSelected ? styles.headerSelected : styles.headerResting
}`}
className={`${styles.header} ${getHeaderClassName()}`}
data-has-error={hasError}
>
{/* Logo/checkbox area */}
<div className={styles.logoMark}>
{isSupported ? (
{hasError ? (
<div className={styles.errorPill}>
<span>{t('error._value', 'Error')}</span>
</div>
) : isSupported ? (
<CheckboxIndicator
checked={isSelected}
onChange={() => onToggleFile(file.id)}
@@ -263,10 +278,10 @@ const FileEditorThumbnail = ({
if (actualFile) {
if (isPinned) {
unpinFile(actualFile);
onSetStatus?.(`Unpinned ${file.name}`);
alert({ alertType: 'neutral', title: `Unpinned ${file.name}`, expandable: false, durationMs: 3000 });
} else {
pinFile(actualFile);
onSetStatus?.(`Pinned ${file.name}`);
alert({ alertType: 'success', title: `Pinned ${file.name}`, expandable: false, durationMs: 3000 });
}
}
setShowActions(false);
@@ -278,7 +293,7 @@ const FileEditorThumbnail = ({
<button
className={styles.actionRow}
onClick={() => { onDownloadFile(file.id); setShowActions(false); }}
onClick={() => { onDownloadFile(file.id); alert({ alertType: 'success', title: `Downloading ${file.name}`, expandable: false, durationMs: 2500 }); setShowActions(false); }}
>
<DownloadOutlinedIcon fontSize="small" />
<span>{t('download', 'Download')}</span>
@@ -290,7 +305,7 @@ const FileEditorThumbnail = ({
className={`${styles.actionRow} ${styles.actionDanger}`}
onClick={() => {
onDeleteFile(file.id);
onSetStatus(`Deleted ${file.name}`);
alert({ alertType: 'neutral', title: `Deleted ${file.name}`, expandable: false, durationMs: 3500 });
setShowActions(false);
}}
>
@@ -328,7 +343,10 @@ const FileEditorThumbnail = ({
</div>
{/* Preview area */}
<div className={`${styles.previewBox} mx-6 mb-4 relative flex-1`}>
<div
className={`${styles.previewBox} mx-6 mb-4 relative flex-1`}
style={isSupported || hasError ? undefined : { filter: 'grayscale(80%)', opacity: 0.6 }}
>
<div className={styles.previewPaper}>
{file.thumbnailUrl && (
<img
+4 -1
View File
@@ -13,6 +13,7 @@ import PageEditorControls from '../pageEditor/PageEditorControls';
import Viewer from '../viewer/Viewer';
import LandingPage from '../shared/LandingPage';
import Footer from '../shared/Footer';
import DismissAllErrorsButton from '../shared/DismissAllErrorsButton';
// No props needed - component uses contexts directly
export default function Workbench() {
@@ -148,9 +149,11 @@ export default function Workbench() {
<TopControls
currentView={currentView}
setCurrentView={setCurrentView}
selectedToolKey={selectedToolId}
/>
{/* Dismiss All Errors Button */}
<DismissAllErrorsButton />
{/* Main content area */}
<Box
className="flex-1 min-h-0 relative z-10 workbench-scrollable "
@@ -1,6 +1,7 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
import { useFileState, useFileActions } from "../../contexts/FileContext";
import { useNavigationGuard } from "../../contexts/NavigationContext";
import { PDFDocument, PageEditorFunctions } from "../../types/pageEditor";
import { pdfExportService } from "../../services/pdfExportService";
import { documentManipulationService } from "../../services/documentManipulationService";
@@ -36,6 +37,9 @@ const PageEditor = ({
const { state, selectors } = useFileState();
const { actions } = useFileActions();
// Navigation guard for unsaved changes
const { setHasUnsavedChanges } = useNavigationGuard();
// Prefer IDs + selectors to avoid array identity churn
const activeFileIds = state.files.ids;
@@ -82,6 +86,12 @@ const PageEditor = ({
updateUndoRedoState();
}, [updateUndoRedoState]);
// Wrapper for executeCommand to track unsaved changes
const executeCommandWithTracking = useCallback((command: any) => {
undoManagerRef.current.executeCommand(command);
setHasUnsavedChanges(true);
}, [setHasUnsavedChanges]);
// Watch for container size changes to update split line positions
useEffect(() => {
const container = gridContainerRef.current;
@@ -138,17 +148,16 @@ const PageEditor = ({
// DOM-first command handlers
const handleRotatePages = useCallback((pageIds: string[], rotation: number) => {
const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation);
undoManagerRef.current.executeCommand(bulkRotateCommand);
}, []);
executeCommandWithTracking(bulkRotateCommand);
}, [executeCommandWithTracking]);
// Command factory functions for PageThumbnail
const createRotateCommand = useCallback((pageIds: string[], rotation: number) => ({
execute: () => {
const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation);
undoManagerRef.current.executeCommand(bulkRotateCommand);
executeCommandWithTracking(bulkRotateCommand);
}
}), []);
}), [executeCommandWithTracking]);
const createDeleteCommand = useCallback((pageIds: string[]) => ({
execute: () => {
@@ -174,10 +183,10 @@ const PageEditor = ({
() => getPageNumbersFromIds(selectedPageIds),
closePdf
);
undoManagerRef.current.executeCommand(deleteCommand);
executeCommandWithTracking(deleteCommand);
}
}
}), [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds]);
}), [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds, executeCommandWithTracking]);
const createSplitCommand = useCallback((position: number) => ({
execute: () => {
@@ -186,9 +195,9 @@ const PageEditor = ({
() => splitPositions,
setSplitPositions
);
undoManagerRef.current.executeCommand(splitCommand);
executeCommandWithTracking(splitCommand);
}
}), [splitPositions]);
}), [splitPositions, executeCommandWithTracking]);
// Command executor for PageThumbnail
const executeCommand = useCallback((command: any) => {
@@ -232,8 +241,8 @@ const PageEditor = ({
() => selectedPageNumbers,
closePdf
);
undoManagerRef.current.executeCommand(deleteCommand);
}, [selectedPageIds, displayDocument, splitPositions, getPageNumbersFromIds, getPageIdsFromNumbers]);
executeCommandWithTracking(deleteCommand);
}, [selectedPageIds, displayDocument, splitPositions, getPageNumbersFromIds, getPageIdsFromNumbers, executeCommandWithTracking]);
const handleDeletePage = useCallback((pageNumber: number) => {
if (!displayDocument) return;
@@ -251,8 +260,8 @@ const PageEditor = ({
() => getPageNumbersFromIds(selectedPageIds),
closePdf
);
undoManagerRef.current.executeCommand(deleteCommand);
}, [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds]);
executeCommandWithTracking(deleteCommand);
}, [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds, executeCommandWithTracking]);
const handleSplit = useCallback(() => {
if (!displayDocument || selectedPageIds.length === 0) return;
@@ -298,8 +307,8 @@ const PageEditor = ({
: `Add ${selectedPositions.length - existingSplitsCount} split(s)`
};
undoManagerRef.current.executeCommand(smartSplitCommand);
}, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds]);
executeCommandWithTracking(smartSplitCommand);
}, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds, executeCommandWithTracking]);
const handleSplitAll = useCallback(() => {
if (!displayDocument || selectedPageIds.length === 0) return;
@@ -344,8 +353,8 @@ const PageEditor = ({
: `Add ${selectedPositions.length - existingSplitsCount} split(s)`
};
undoManagerRef.current.executeCommand(smartSplitCommand);
}, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds]);
executeCommandWithTracking(smartSplitCommand);
}, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds, executeCommandWithTracking]);
const handlePageBreak = useCallback(() => {
if (!displayDocument || selectedPageIds.length === 0) return;
@@ -358,8 +367,8 @@ const PageEditor = ({
() => displayDocument,
setEditedDocument
);
undoManagerRef.current.executeCommand(pageBreakCommand);
}, [selectedPageIds, displayDocument, getPageNumbersFromIds]);
executeCommandWithTracking(pageBreakCommand);
}, [selectedPageIds, displayDocument, getPageNumbersFromIds, executeCommandWithTracking]);
const handlePageBreakAll = useCallback(() => {
if (!displayDocument || selectedPageIds.length === 0) return;
@@ -372,8 +381,8 @@ const PageEditor = ({
() => displayDocument,
setEditedDocument
);
undoManagerRef.current.executeCommand(pageBreakCommand);
}, [selectedPageIds, displayDocument, getPageNumbersFromIds]);
executeCommandWithTracking(pageBreakCommand);
}, [selectedPageIds, displayDocument, getPageNumbersFromIds, executeCommandWithTracking]);
const handleInsertFiles = useCallback(async (files: File[], insertAfterPage: number) => {
if (!displayDocument || files.length === 0) return;
@@ -416,8 +425,8 @@ const PageEditor = ({
() => displayDocument,
setEditedDocument
);
undoManagerRef.current.executeCommand(reorderCommand);
}, [displayDocument, getPageNumbersFromIds]);
executeCommandWithTracking(reorderCommand);
}, [displayDocument, getPageNumbersFromIds, executeCommandWithTracking]);
// Helper function to collect source files for multi-file export
const getSourceFiles = useCallback((): Map<FileId, File> | null => {
@@ -499,13 +508,14 @@ const PageEditor = ({
// Step 4: Download the result
pdfExportService.downloadFile(result.blob, result.filename);
setHasUnsavedChanges(false); // Clear unsaved changes after successful export
setExportLoading(false);
} catch (error) {
console.error('Export failed:', error);
setExportLoading(false);
}
}, [displayDocument, selectedPageIds, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename]);
}, [displayDocument, selectedPageIds, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename, setHasUnsavedChanges]);
const onExportAll = useCallback(async () => {
if (!displayDocument) return;
@@ -552,6 +562,7 @@ const PageEditor = ({
const zipFilename = baseExportFilename.replace(/\.pdf$/i, '.zip');
pdfExportService.downloadFile(zipBlob, zipFilename);
setHasUnsavedChanges(false); // Clear unsaved changes after successful export
} else {
// Single document - regular export
const sourceFiles = getSourceFiles();
@@ -570,6 +581,7 @@ const PageEditor = ({
);
pdfExportService.downloadFile(result.blob, result.filename);
setHasUnsavedChanges(false); // Clear unsaved changes after successful export
}
setExportLoading(false);
@@ -577,7 +589,7 @@ const PageEditor = ({
console.error('Export failed:', error);
setExportLoading(false);
}
}, [displayDocument, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename]);
}, [displayDocument, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename, setHasUnsavedChanges]);
// Apply DOM changes to document state using dedicated service
const applyChanges = useCallback(() => {
@@ -779,7 +791,14 @@ const PageEditor = ({
)}
<NavigationWarningModal />
<NavigationWarningModal
onApplyAndContinue={async () => {
applyChanges();
}}
onExportAndContinue={async () => {
await onExportAll();
}}
/>
</Box>
);
};
@@ -0,0 +1,51 @@
import React from 'react';
import { Button, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useFileState } from '../../contexts/FileContext';
import { useFileActions } from '../../contexts/file/fileHooks';
import CloseIcon from '@mui/icons-material/Close';
interface DismissAllErrorsButtonProps {
className?: string;
}
const DismissAllErrorsButton: React.FC<DismissAllErrorsButtonProps> = ({ className }) => {
const { t } = useTranslation();
const { state } = useFileState();
const { actions } = useFileActions();
// Check if there are any files in error state
const hasErrors = state.ui.errorFileIds.length > 0;
// Don't render if there are no errors
if (!hasErrors) {
return null;
}
const handleDismissAllErrors = () => {
actions.clearAllFileErrors();
};
return (
<Group className={className}>
<Button
variant="light"
color="red"
size="sm"
leftSection={<CloseIcon fontSize="small" />}
onClick={handleDismissAllErrors}
style={{
position: 'absolute',
top: '1rem',
right: '1rem',
zIndex: 1000,
pointerEvents: 'auto'
}}
>
{t('error.dismissAllErrors', 'Dismiss All Errors')} ({state.ui.errorFileIds.length})
</Button>
</Group>
);
};
export default DismissAllErrorsButton;
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import { useState, useEffect } from 'react';
import {
Modal,
Text,
@@ -1,4 +1,4 @@
import React, { useRef } from "react";
import { useRef } from "react";
import { FileButton, Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
@@ -1,5 +1,4 @@
import { Flex } from '@mantine/core';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useCookieConsent } from '../../hooks/useCookieConsent';
@@ -1,4 +1,3 @@
import React from "react";
import { Box, Group, Text, Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
@@ -37,7 +36,7 @@ const MultiSelectControls = ({
>
{t("fileManager.clearSelection", "Clear Selection")}
</Button>
{onAddToUpload && (
<Button
size="xs"
@@ -47,7 +46,7 @@ const MultiSelectControls = ({
{t("fileManager.addToUpload", "Add to Upload")}
</Button>
)}
{onOpenInFileEditor && (
<Button
size="xs"
@@ -58,7 +57,7 @@ const MultiSelectControls = ({
{t("fileManager.openInFileEditor", "Open in File Editor")}
</Button>
)}
{onOpenInPageEditor && (
<Button
size="xs"
@@ -69,7 +68,7 @@ const MultiSelectControls = ({
{t("fileManager.openInPageEditor", "Open in Page Editor")}
</Button>
)}
{onDeleteAll && (
<Button
size="xs"
@@ -85,4 +84,4 @@ const MultiSelectControls = ({
);
};
export default MultiSelectControls;
export default MultiSelectControls;
@@ -1,6 +1,6 @@
import React from 'react';
import { Modal, Text, Button, Group, Stack } from '@mantine/core';
import { useNavigationGuard } from '../../contexts/NavigationContext';
import { useTranslation } from 'react-i18next';
interface NavigationWarningModalProps {
onApplyAndContinue?: () => Promise<void>;
@@ -11,6 +11,8 @@ const NavigationWarningModal = ({
onApplyAndContinue,
onExportAndContinue
}: NavigationWarningModalProps) => {
const { t } = useTranslation();
const {
showNavigationWarning,
hasUnsavedChanges,
@@ -28,7 +30,7 @@ const NavigationWarningModal = ({
confirmNavigation();
};
const handleApplyAndContinue = async () => {
const _handleApplyAndContinue = async () => {
if (onApplyAndContinue) {
await onApplyAndContinue();
}
@@ -52,55 +54,59 @@ const NavigationWarningModal = ({
<Modal
opened={showNavigationWarning}
onClose={handleKeepWorking}
title="Unsaved Changes"
title={t("unsavedChangesTitle", "Unsaved Changes")}
centered
size="lg"
closeOnClickOutside={false}
closeOnEscape={false}
>
<Stack gap="md">
<Text>
You have unsaved changes to your PDF. What would you like to do?
{t("unsavedChanges", "You have unsaved changes to your PDF. What would you like to do?")}
</Text>
<Group justify="flex-end" gap="sm">
<Button
variant="light"
color="gray"
onClick={handleKeepWorking}
>
Keep Working
</Button>
<Group justify="space-between" gap="sm">
<Button
variant="light"
color="red"
onClick={handleDiscardChanges}
>
Discard Changes
{t("discardChanges", "Discard Changes")}
</Button>
{onApplyAndContinue && (
<Group gap="sm">
<Button
variant="light"
color="blue"
onClick={handleApplyAndContinue}
color="var(--mantine-color-gray-8)"
onClick={handleKeepWorking}
>
Apply & Continue
{t("keepWorking", "Keep Working")}
</Button>
)}
{onExportAndContinue && (
<Button
color="green"
onClick={handleExportAndContinue}
>
Export & Continue
</Button>
)}
{/* TODO:: Add this back in when it works */}
{/* {onApplyAndContinue && (
<Button
variant="light"
color="blue"
onClick={handleApplyAndContinue}
>
{t("applyAndContinue", "Apply & Continue")}
</Button>
)} */}
{onExportAndContinue && (
<Button
onClick={handleExportAndContinue}
>
{t("exportAndContinue", "Export & Continue")}
</Button>
)}
</Group>
</Group>
</Stack>
</Modal>
);
};
export default NavigationWarningModal;
export default NavigationWarningModal;
@@ -41,10 +41,10 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
// Helper function to render navigation buttons with URL support
const renderNavButton = (config: ButtonConfig, index: number) => {
const isActive = isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView);
// Check if this button has URL navigation support
const navProps = config.type === 'navigation' && (config.id === 'read' || config.id === 'automate')
? getToolNavigation(config.id)
const navProps = config.type === 'navigation' && (config.id === 'read' || config.id === 'automate')
? getToolNavigation(config.id)
: null;
const handleClick = (e?: React.MouseEvent) => {
@@ -59,7 +59,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
return (
<div key={config.id} className="flex flex-col items-center gap-1" style={{ marginTop: index === 0 ? '0.5rem' : "0rem" }}>
<ActionIcon
{...(navProps ? {
{...(navProps ? {
component: "a" as const,
href: navProps.href,
onClick: (e: React.MouseEvent) => handleClick(e),
@@ -249,4 +249,6 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
);
});
QuickAccessBar.displayName = 'QuickAccessBar';
export default QuickAccessBar;
@@ -1,8 +1,11 @@
import React, { createContext, useContext, ReactNode } from 'react';
import { createContext, useContext, ReactNode } from 'react';
import { MantineProvider } from '@mantine/core';
import { useRainbowTheme } from '../../hooks/useRainbowTheme';
import { mantineTheme } from '../../theme/mantineTheme';
import rainbowStyles from '../../styles/rainbow.module.css';
import { ToastProvider } from '../toast';
import ToastRenderer from '../toast/ToastRenderer';
import { ToastPortalBinder } from '../toast';
interface RainbowThemeContextType {
themeMode: 'light' | 'dark' | 'rainbow';
@@ -44,7 +47,11 @@ export function RainbowThemeProvider({ children }: RainbowThemeProviderProps) {
className={rainbowTheme.isRainbowMode ? rainbowStyles.rainbowMode : ''}
style={{ minHeight: '100vh' }}
>
{children}
<ToastProvider>
<ToastPortalBinder />
{children}
<ToastRenderer />
</ToastProvider>
</div>
</MantineProvider>
</RainbowThemeContext.Provider>
+28 -5
View File
@@ -4,7 +4,7 @@ import LocalIcon from './LocalIcon';
import './rightRail/RightRail.css';
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
import { useRightRail } from '../../contexts/RightRailContext';
import { useFileState, useFileSelection, useFileManagement } from '../../contexts/FileContext';
import { useFileState, useFileSelection, useFileManagement, useFileContext } from '../../contexts/FileContext';
import { useNavigationState } from '../../contexts/NavigationContext';
import { useTranslation } from 'react-i18next';
@@ -14,6 +14,7 @@ import { Tooltip } from '../shared/Tooltip';
import BulkSelectionPanel from '../pageEditor/BulkSelectionPanel';
import { SearchInterface } from '../viewer/SearchInterface';
import { ViewerContext } from '../../contexts/ViewerContext';
import { useSignature } from '../../contexts/SignatureContext';
import { parseSelection } from '../../utils/bulkselection/parseSelection';
@@ -39,9 +40,13 @@ export default function RightRail() {
// File state and selection
const { state, selectors } = useFileState();
const { actions: fileActions } = useFileContext();
const { selectedFiles, selectedFileIds, setSelectedFiles } = useFileSelection();
const { removeFiles } = useFileManagement();
// Signature context for checking if signatures have been applied
const { signaturesApplied } = useSignature();
const activeFiles = selectors.getFiles();
const filesSignature = selectors.getFilesSignature();
@@ -65,11 +70,16 @@ export default function RightRail() {
const { totalItems, selectedCount } = getSelectionState();
// Get export state for viewer mode
const exportState = viewerContext?.getExportState?.();
const handleSelectAll = useCallback(() => {
if (currentView === 'fileEditor' || currentView === 'viewer') {
// Select all file IDs
const allIds = state.files.ids;
setSelectedFiles(allIds);
// Clear any previous error flags when selecting all
try { fileActions.clearAllFileErrors(); } catch (_e) { void _e; }
return;
}
@@ -82,6 +92,8 @@ export default function RightRail() {
const handleDeselectAll = useCallback(() => {
if (currentView === 'fileEditor' || currentView === 'viewer') {
setSelectedFiles([]);
// Clear any previous error flags when deselecting all
try { fileActions.clearAllFileErrors(); } catch (_e) { void _e; }
return;
}
if (currentView === 'pageEditor') {
@@ -90,8 +102,17 @@ export default function RightRail() {
}
}, [currentView, setSelectedFiles, pageEditorFunctions]);
const handleExportAll = useCallback(() => {
if (currentView === 'fileEditor' || currentView === 'viewer') {
const handleExportAll = useCallback(async () => {
if (currentView === 'viewer') {
// Check if signatures have been applied
if (!signaturesApplied) {
alert('You have unapplied signatures. Please use "Apply Signatures" first before exporting.');
return;
}
// Use EmbedPDF export functionality for viewer mode
viewerContext?.exportActions?.download();
} else if (currentView === 'fileEditor') {
// Download selected files (or all if none selected)
const filesToDownload = selectedFiles.length > 0 ? selectedFiles : activeFiles;
@@ -108,7 +129,7 @@ export default function RightRail() {
// Export all pages (not just selected)
pageEditorFunctions?.onExportAll?.();
}
}, [currentView, activeFiles, selectedFiles, pageEditorFunctions]);
}, [currentView, activeFiles, selectedFiles, pageEditorFunctions, viewerContext, signaturesApplied, selectors, fileActions]);
const handleCloseSelected = useCallback(() => {
if (currentView !== 'fileEditor') return;
@@ -440,7 +461,9 @@ export default function RightRail() {
radius="md"
className="right-rail-icon"
onClick={handleExportAll}
disabled={currentView === 'viewer' || totalItems === 0}
disabled={
currentView === 'viewer' ? !exportState?.canExport : totalItems === 0
}
>
<LocalIcon icon="download" width="1.5rem" height="1.5rem" />
</ActionIcon>
@@ -107,3 +107,5 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(({
</div>
);
});
TextInput.displayName = 'TextInput';
+1 -1
View File
@@ -32,7 +32,7 @@ const ToolChain: React.FC<ToolChainProps> = ({
const getToolName = (toolId: ToolId) => {
return t(`home.${toolId}.title`, toolId);
}
};
// Create full tool chain for tooltip
const fullChainDisplay = displayStyle === 'badges' ? (
@@ -15,11 +15,11 @@ const viewOptionStyle = {
gap: 6,
whiteSpace: 'nowrap',
paddingTop: '0.3rem',
}
};
// Build view options showing text always
const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchType | null, isToolSelected: boolean) => {
const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchType | null) => {
const viewerOption = {
label: (
<div style={viewOptionStyle as React.CSSProperties}>
@@ -75,7 +75,7 @@ const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchTyp
// Build options array conditionally
return [
viewerOption,
...(isToolSelected ? [] : [pageEditorOption]),
pageEditorOption,
fileEditorOption,
];
};
@@ -83,19 +83,15 @@ const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchTyp
interface TopControlsProps {
currentView: WorkbenchType;
setCurrentView: (view: WorkbenchType) => void;
selectedToolKey?: string | null;
}
const TopControls = ({
currentView,
setCurrentView,
selectedToolKey,
}: TopControlsProps) => {
}: TopControlsProps) => {
const { isRainbowMode } = useRainbowThemeContext();
const [switchingTo, setSwitchingTo] = useState<WorkbenchType | null>(null);
const isToolSelected = selectedToolKey !== null;
const handleViewChange = useCallback((view: string) => {
if (!isValidWorkbench(view)) {
return;
@@ -122,7 +118,7 @@ const TopControls = ({
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
<div className="flex justify-center mt-[0.5rem]">
<SegmentedControl
data={createViewOptions(currentView, switchingTo, isToolSelected)}
data={createViewOptions(currentView, switchingTo)}
value={currentView}
onChange={handleViewChange}
color="blue"
@@ -33,8 +33,11 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton })
const { getHomeNavigation } = useSidebarNavigation();
// Determine if the indicator should be visible (do not require selectedTool to be resolved yet)
// Special case: multiTool should always show even when sidebars are hidden
const indicatorShouldShow = Boolean(
selectedToolKey && leftPanelView === 'toolContent' && !NAV_IDS.includes(selectedToolKey)
selectedToolKey &&
((leftPanelView === 'toolContent' && !NAV_IDS.includes(selectedToolKey)) ||
selectedToolKey === 'multiTool')
);
// Local animation and hover state
@@ -47,7 +50,7 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton })
const animTimeoutRef = useRef<number | null>(null);
const replayRafRef = useRef<number | null>(null);
const isSwitchingToNewTool = () => { return prevKeyRef.current && prevKeyRef.current !== selectedToolKey };
const isSwitchingToNewTool = () => { return prevKeyRef.current && prevKeyRef.current !== selectedToolKey; };
const clearTimers = () => {
if (collapseTimeoutRef.current) {
@@ -78,7 +81,7 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton })
setReplayAnim(false);
animTimeoutRef.current = null;
}, 500);
}
};
const firstShow = () => {
clearTimers();
@@ -88,7 +91,7 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton })
animTimeoutRef.current = window.setTimeout(() => {
animTimeoutRef.current = null;
}, 500);
}
};
const triggerCollapse = () => {
clearTimers();
@@ -98,7 +101,7 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton })
prevKeyRef.current = null;
collapseTimeoutRef.current = null;
}, 500); // match CSS transition duration
}
};
useEffect(() => {
if (indicatorShouldShow) {
@@ -12,7 +12,7 @@ export const isNavButtonActive = (
isFilesModalOpen: boolean,
configModalOpen: boolean,
selectedToolKey?: string | null,
leftPanelView?: 'toolPicker' | 'toolContent'
leftPanelView?: 'toolPicker' | 'toolContent' | 'hidden'
): boolean => {
const isActiveByLocalState = config.type === 'navigation' && activeButton === config.id;
const isActiveByContext =
@@ -35,7 +35,7 @@ export const getNavButtonStyle = (
isFilesModalOpen: boolean,
configModalOpen: boolean,
selectedToolKey?: string | null,
leftPanelView?: 'toolPicker' | 'toolContent'
leftPanelView?: 'toolPicker' | 'toolContent' | 'hidden'
) => {
const isActive = isNavButtonActive(
config,
@@ -0,0 +1,309 @@
# Toast Component
A global notification system with expandable content, progress tracking, and smart error coalescing. Provides an imperative API for showing success, error, warning, and neutral notifications with customizable content and behavior.
---
## Highlights
* 🎯 **Global System**: Imperative API accessible from anywhere in the app via `alert()` function.
* 🎨 **Four Alert Types**: Success (green), Error (red), Warning (yellow), Neutral (theme-aware).
* 📱 **Expandable Content**: Collapsible toasts with chevron controls and smooth animations.
* ⚡ **Smart Coalescing**: Duplicate error toasts merge with count badges (e.g., "Server error 4").
* 📊 **Progress Tracking**: Built-in progress bars with completion animations.
* 🎛️ **Customizable**: Rich JSX content, buttons with callbacks, custom icons.
* 🌙 **Themeable**: Uses CSS variables; supports light/dark mode out of the box.
* ♿ **Accessible**: Proper ARIA roles, keyboard navigation, and screen reader support.
* 🔄 **Auto-dismiss**: Configurable duration with persistent popup option.
* 📍 **Positioning**: Four corner positions with proper stacking.
---
## Behavior
### Default
* **Auto-dismiss**: Toasts disappear after 6 seconds unless `isPersistentPopup: true`.
* **Expandable**: Click chevron to expand/collapse body content (default: collapsed).
* **Coalescing**: Identical error toasts merge with count badges.
* **Progress**: Progress bars always visible when present, even when collapsed.
### Error Handling
* **Network Errors**: Automatically caught by Axios and fetch interceptors.
* **Friendly Fallbacks**: Shows "There was an error processing your request" for unhelpful backend responses.
* **Smart Titles**: "Server error" for 5xx, "Request error" for 4xx, "Network error" for others.
---
## Installation
The toast system is already integrated at the app root. No additional setup required.
```tsx
import { alert, updateToast, dismissToast } from '@/components/toast';
```
---
## Basic Usage
### Simple Notifications
```tsx
// Success notification
alert({
alertType: 'success',
title: 'File processed successfully',
body: 'Your document has been converted to PDF.'
});
// Error notification
alert({
alertType: 'error',
title: 'Processing failed',
body: 'Unable to process the selected files.'
});
// Warning notification
alert({
alertType: 'warning',
title: 'Low disk space',
body: 'Consider freeing up some storage space.'
});
// Neutral notification
alert({
alertType: 'neutral',
title: 'Information',
body: 'This is a neutral notification.'
});
```
### With Custom Content
```tsx
// Rich JSX content with buttons
alert({
alertType: 'success',
title: 'Download complete',
body: (
<div>
<p>File saved to Downloads folder</p>
<button onClick={() => openFolder()}>Open folder</button>
</div>
),
buttonText: 'View file',
buttonCallback: () => openFile(),
isPersistentPopup: true
});
```
### Progress Tracking
```tsx
// Show progress
const toastId = alert({
alertType: 'neutral',
title: 'Processing files...',
body: 'Converting your documents',
progressBarPercentage: 0
});
// Update progress
updateToast(toastId, { progressBarPercentage: 50 });
// Complete with success
updateToast(toastId, {
alertType: 'success',
title: 'Processing complete',
body: 'All files converted successfully',
progressBarPercentage: 100
});
```
### Custom Positioning
```tsx
alert({
alertType: 'error',
title: 'Connection lost',
body: 'Please check your internet connection.',
location: 'top-right'
});
```
---
## API
### `alert(options: ToastOptions)`
The primary function for showing toasts.
```ts
interface ToastOptions {
alertType?: 'success' | 'error' | 'warning' | 'neutral';
title: string;
body?: React.ReactNode;
buttonText?: string;
buttonCallback?: () => void;
isPersistentPopup?: boolean;
location?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
icon?: React.ReactNode;
progressBarPercentage?: number; // 0-1 as fraction or 0-100 as percent
durationMs?: number;
id?: string;
expandable?: boolean;
}
```
### `updateToast(id: string, options: Partial<ToastOptions>)`
Update an existing toast.
```tsx
const toastId = alert({ title: 'Processing...', progressBarPercentage: 0 });
updateToast(toastId, { progressBarPercentage: 75 });
```
### `dismissToast(id: string)`
Dismiss a specific toast.
```tsx
dismissToast(toastId);
```
### `dismissAllToasts()`
Dismiss all visible toasts.
```tsx
dismissAllToasts();
```
---
## Alert Types
| Type | Color | Icon | Use Case |
|------|-------|------|----------|
| `success` | Green | ✓ | Successful operations, completions |
| `error` | Red | ✗ | Failures, errors, exceptions |
| `warning` | Yellow | ⚠ | Warnings, cautions, low resources |
| `neutral` | Theme | | Information, general messages |
---
## Positioning
| Location | Description |
|----------|-------------|
| `top-left` | Top-left corner |
| `top-right` | Top-right corner |
| `bottom-left` | Bottom-left corner |
| `bottom-right` | Bottom-right corner (default) |
---
## Accessibility
* Toasts use `role="status"` for screen readers.
* Chevron and close buttons have proper `aria-label` attributes.
* Keyboard navigation supported (Escape to dismiss).
* Focus management for interactive content.
---
## Examples
### File Processing Workflow
```tsx
// Start processing
const toastId = alert({
alertType: 'neutral',
title: 'Processing files...',
body: 'Converting 5 documents',
progressBarPercentage: 0,
isPersistentPopup: true
});
// Update progress
updateToast(toastId, { progressBarPercentage: 30 });
updateToast(toastId, { progressBarPercentage: 60 });
// Complete successfully
updateToast(toastId, {
alertType: 'success',
title: 'Processing complete',
body: 'All 5 documents converted successfully',
progressBarPercentage: 100,
isPersistentPopup: false
});
```
### Error with Action
```tsx
alert({
alertType: 'error',
title: 'Upload failed',
body: 'File size exceeds the 10MB limit.',
buttonText: 'Try again',
buttonCallback: () => retryUpload(),
isPersistentPopup: true
});
```
### Non-expandable Toast
```tsx
alert({
alertType: 'success',
title: 'Settings saved',
body: 'Your preferences have been updated.',
expandable: false,
durationMs: 3000
});
```
### Custom Icon
```tsx
alert({
alertType: 'neutral',
title: 'New feature available',
body: 'Check out the latest updates.',
icon: <LocalIcon icon="star" />
});
```
---
## Integration
### Network Error Handling
The toast system automatically catches network errors from Axios and fetch requests:
```tsx
// These automatically show error toasts
axios.post('/api/convert', formData);
fetch('/api/process', { method: 'POST', body: data });
```
### Manual Error Handling
```tsx
try {
await processFiles();
alert({ alertType: 'success', title: 'Files processed' });
} catch (error) {
alert({
alertType: 'error',
title: 'Processing failed',
body: error.message
});
}
```
@@ -0,0 +1,150 @@
import React, { createContext, useCallback, useContext, useMemo, useRef, useState, useEffect } from 'react';
import { ToastApi, ToastInstance, ToastOptions } from './types';
function normalizeProgress(value: number | undefined): number | undefined {
if (typeof value !== 'number' || Number.isNaN(value)) return undefined;
// Accept 0..1 as fraction or 0..100 as percent
if (value <= 1) return Math.max(0, Math.min(1, value)) * 100;
return Math.max(0, Math.min(100, value));
}
function generateId() {
return `toast_${Math.random().toString(36).slice(2, 9)}`;
}
type DefaultOpts = Required<Pick<ToastOptions, 'alertType' | 'title' | 'isPersistentPopup' | 'location' | 'durationMs'>> &
Partial<Omit<ToastOptions, 'id' | 'alertType' | 'title' | 'isPersistentPopup' | 'location' | 'durationMs'>>;
const defaultOptions: DefaultOpts = {
alertType: 'neutral',
title: '',
isPersistentPopup: false,
location: 'bottom-right',
durationMs: 6000,
};
interface ToastContextShape extends ToastApi {
toasts: ToastInstance[];
}
const ToastContext = createContext<ToastContextShape | null>(null);
export function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error('useToast must be used within ToastProvider');
return ctx;
}
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<ToastInstance[]>([]);
const timers = useRef<Record<string, number>>({});
const scheduleAutoDismiss = useCallback((toast: ToastInstance) => {
if (toast.isPersistentPopup) return;
window.clearTimeout(timers.current[toast.id]);
timers.current[toast.id] = window.setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== toast.id));
}, toast.durationMs);
}, []);
const show = useCallback<ToastApi['show']>((options) => {
const id = options.id || generateId();
const hasButton = !!(options.buttonText && options.buttonCallback);
const merged: ToastInstance = {
...defaultOptions,
...options,
id,
progress: normalizeProgress(options.progressBarPercentage),
justCompleted: false,
expandable: hasButton ? false : (options.expandable !== false),
isExpanded: hasButton ? true : (options.expandable === false ? true : (options.alertType === 'error' ? true : false)),
createdAt: Date.now(),
} as ToastInstance;
setToasts(prev => {
// Coalesce duplicates by alertType + title + body text if no explicit id was provided
if (!options.id) {
const bodyText = typeof merged.body === 'string' ? merged.body : '';
const existingIndex = prev.findIndex(t => t.alertType === merged.alertType && t.title === merged.title && (typeof t.body === 'string' ? t.body : '') === bodyText);
if (existingIndex !== -1) {
const updated = [...prev];
const existing = updated[existingIndex];
const nextCount = (existing.count ?? 1) + 1;
updated[existingIndex] = { ...existing, count: nextCount, createdAt: Date.now() };
return updated;
}
}
const next = [...prev.filter(t => t.id !== id), merged];
return next;
});
scheduleAutoDismiss(merged);
return id;
}, [scheduleAutoDismiss]);
const update = useCallback<ToastApi['update']>((id, updates) => {
setToasts(prev => prev.map(t => {
if (t.id !== id) return t;
const progress = updates.progressBarPercentage !== undefined
? normalizeProgress(updates.progressBarPercentage)
: t.progress;
const next: ToastInstance = {
...t,
...updates,
progress,
} as ToastInstance;
// Detect completion
if (typeof progress === 'number' && progress >= 100 && !t.justCompleted) {
// On completion: finalize type as success unless explicitly provided otherwise
next.justCompleted = false;
if (!updates.alertType) {
next.alertType = 'success';
}
}
return next;
}));
}, []);
const updateProgress = useCallback<ToastApi['updateProgress']>((id, progress) => {
update(id, { progressBarPercentage: progress });
}, [update]);
const dismiss = useCallback<ToastApi['dismiss']>((id) => {
setToasts(prev => prev.filter(t => t.id !== id));
window.clearTimeout(timers.current[id]);
delete timers.current[id];
}, []);
const dismissAll = useCallback<ToastApi['dismissAll']>(() => {
setToasts([]);
Object.values(timers.current).forEach(t => window.clearTimeout(t));
timers.current = {};
}, []);
const value = useMemo<ToastContextShape>(() => ({
toasts,
show,
update,
updateProgress,
dismiss,
dismissAll,
}), [toasts, show, update, updateProgress, dismiss, dismissAll]);
// Handle expand/collapse toggles from renderer without widening API
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail as { id: string } | undefined;
if (!detail?.id) return;
setToasts(prev => prev.map(t => t.id === detail.id ? { ...t, isExpanded: !t.isExpanded } : t));
};
window.addEventListener('toast:toggle', handler as EventListener);
return () => window.removeEventListener('toast:toggle', handler as EventListener);
}, []);
return (
<ToastContext.Provider value={value}>{children}</ToastContext.Provider>
);
}
@@ -0,0 +1,209 @@
/* Toast Container Styles */
.toast-container {
position: fixed;
z-index: 1200;
display: flex;
gap: 12px;
pointer-events: none;
}
.toast-container--top-left {
top: 16px;
left: 16px;
flex-direction: column;
}
.toast-container--top-right {
top: 16px;
right: 16px;
flex-direction: column;
}
.toast-container--bottom-left {
bottom: 16px;
left: 16px;
flex-direction: column-reverse;
}
.toast-container--bottom-right {
bottom: 16px;
right: 16px;
flex-direction: column-reverse;
}
/* Toast Item Styles */
.toast-item {
min-width: 320px;
max-width: 560px;
box-shadow: var(--shadow-lg);
border-radius: 16px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: auto;
}
/* Toast Alert Type Colors */
.toast-item--success {
background: var(--color-green-100);
color: var(--text-primary);
border: 1px solid var(--color-green-400);
}
.toast-item--error {
background: var(--color-red-100);
color: var(--text-primary);
border: 1px solid var(--color-red-400);
}
.toast-item--warning {
background: var(--color-yellow-100);
color: var(--text-primary);
border: 1px solid var(--color-yellow-400);
}
.toast-item--neutral {
background: var(--bg-surface);
color: var(--text-primary);
border: 1px solid var(--border-default);
}
/* Toast Header Row */
.toast-header {
display: flex;
align-items: center;
gap: 12px;
}
.toast-icon {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.toast-title-container {
font-weight: 700;
flex: 1;
display: flex;
align-items: center;
gap: 8px;
}
.toast-count-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.08);
color: inherit;
font-size: 12px;
font-weight: 700;
}
.toast-controls {
display: flex;
gap: 4px;
align-items: center;
}
.toast-button {
width: 28px;
height: 28px;
border-radius: 999px;
border: none;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.toast-expand-button {
transform: rotate(0deg);
transition: transform 160ms ease;
}
.toast-expand-button--expanded {
transform: rotate(180deg);
}
/* Progress Bar */
.toast-progress-container {
margin-top: 8px;
height: 6px;
background: var(--bg-muted);
border-radius: 999px;
overflow: hidden;
}
.toast-progress-bar {
height: 100%;
transition: width 160ms ease;
}
.toast-progress-bar--success {
background: var(--color-green-500);
}
.toast-progress-bar--error {
background: var(--color-red-500);
}
.toast-progress-bar--warning {
background: var(--color-yellow-500);
}
.toast-progress-bar--neutral {
background: var(--color-gray-500);
}
/* Toast Body */
.toast-body {
font-size: 14px;
opacity: 0.9;
margin-top: 8px;
}
/* Toast Action Button */
.toast-action-container {
margin-top: 12px;
display: flex;
justify-content: flex-start;
}
.toast-action-button {
padding: 8px 12px;
border-radius: 12px;
border: 1px solid;
background: transparent;
font-weight: 600;
cursor: pointer;
margin-left: auto;
}
.toast-action-button--success {
color: var(--text-primary);
border-color: var(--color-green-400);
}
.toast-action-button--error {
color: var(--text-primary);
border-color: var(--color-red-400);
}
.toast-action-button--warning {
color: var(--text-primary);
border-color: var(--color-yellow-400);
}
.toast-action-button--neutral {
color: var(--text-primary);
border-color: var(--border-default);
}
@@ -0,0 +1,137 @@
import { useToast } from './ToastContext';
import { ToastInstance, ToastLocation } from './types';
import { LocalIcon } from '../shared/LocalIcon';
import './ToastRenderer.css';
const locationToClass: Record<ToastLocation, string> = {
'top-left': 'toast-container--top-left',
'top-right': 'toast-container--top-right',
'bottom-left': 'toast-container--bottom-left',
'bottom-right': 'toast-container--bottom-right',
};
function getToastItemClass(t: ToastInstance): string {
return `toast-item toast-item--${t.alertType}`;
}
function getProgressBarClass(t: ToastInstance): string {
return `toast-progress-bar toast-progress-bar--${t.alertType}`;
}
function getActionButtonClass(t: ToastInstance): string {
return `toast-action-button toast-action-button--${t.alertType}`;
}
function getDefaultIconName(t: ToastInstance): string {
switch (t.alertType) {
case 'success':
return 'check-circle-rounded';
case 'error':
return 'close-rounded';
case 'warning':
return 'warning-rounded';
case 'neutral':
default:
return 'info-rounded';
}
}
export default function ToastRenderer() {
const { toasts, dismiss } = useToast();
const grouped = toasts.reduce<Record<ToastLocation, ToastInstance[]>>((acc, t) => {
const key = t.location;
if (!acc[key]) acc[key] = [] as ToastInstance[];
acc[key].push(t);
return acc;
}, { 'top-left': [], 'top-right': [], 'bottom-left': [], 'bottom-right': [] });
return (
<>
{(Object.keys(grouped) as ToastLocation[]).map((loc) => (
<div key={loc} className={`toast-container ${locationToClass[loc]}`}>
{grouped[loc].map(t => {
return (
<div
key={t.id}
role="status"
className={getToastItemClass(t)}
>
{/* Top row: Icon + Title + Controls */}
<div className="toast-header">
{/* Icon */}
<div className="toast-icon">
{t.icon ?? (
<LocalIcon icon={`material-symbols:${getDefaultIconName(t)}`} width={20} height={20} />
)}
</div>
{/* Title + count badge */}
<div className="toast-title-container">
<span>{t.title}</span>
{typeof t.count === 'number' && t.count > 1 && (
<span className="toast-count-badge">{t.count}</span>
)}
</div>
{/* Controls */}
<div className="toast-controls">
{t.expandable && (
<button
aria-label="Toggle details"
onClick={() => {
const evt = new CustomEvent('toast:toggle', { detail: { id: t.id } });
window.dispatchEvent(evt);
}}
className={`toast-button toast-expand-button ${t.isExpanded ? 'toast-expand-button--expanded' : ''}`}
>
<LocalIcon icon="material-symbols:expand-more-rounded" />
</button>
)}
<button
aria-label="Dismiss"
onClick={() => dismiss(t.id)}
className="toast-button"
>
<LocalIcon icon="material-symbols:close-rounded" width={20} height={20} />
</button>
</div>
</div>
{/* Progress bar - always show when present */}
{typeof t.progress === 'number' && (
<div className="toast-progress-container">
<div
className={getProgressBarClass(t)}
style={{ width: `${t.progress}%` }}
/>
</div>
)}
{/* Body content - only show when expanded */}
{(t.isExpanded || !t.expandable) && (
<div className="toast-body">
{t.body}
</div>
)}
{/* Button - always show when present, positioned below body */}
{t.buttonText && t.buttonCallback && (
<div className="toast-action-container">
<button
onClick={t.buttonCallback}
className={getActionButtonClass(t)}
>
{t.buttonText}
</button>
</div>
)}
</div>
);
})}
</div>
))}
</>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { ToastOptions } from './types';
import { useToast, ToastProvider } from './ToastContext';
import ToastRenderer from './ToastRenderer';
export { useToast, ToastProvider, ToastRenderer };
// Global imperative API via module singleton
let _api: ReturnType<typeof createImperativeApi> | null = null;
function createImperativeApi() {
const subscribers: Array<(fn: any) => void> = [];
let api: any = null;
return {
provide(instance: any) {
api = instance;
subscribers.splice(0).forEach(cb => cb(api));
},
get(): any | null { return api; },
onReady(cb: (api: any) => void) {
if (api) cb(api); else subscribers.push(cb);
}
};
}
if (!_api) _api = createImperativeApi();
// Hook helper to wire context API back to singleton
export function ToastPortalBinder() {
const ctx = useToast();
// Provide API once mounted
_api!.provide(ctx);
return null;
}
export function alert(options: ToastOptions) {
if (_api?.get()) {
return _api.get()!.show(options);
}
// Queue until provider mounts
let id = '';
_api?.onReady((api) => { id = api.show(options); });
return id;
}
export function updateToast(id: string, options: Partial<ToastOptions>) {
_api?.get()?.update(id, options);
}
export function updateToastProgress(id: string, progress: number) {
_api?.get()?.updateProgress(id, progress);
}
export function dismissToast(id: string) {
_api?.get()?.dismiss(id);
}
export function dismissAllToasts() {
_api?.get()?.dismissAll();
}
+50
View File
@@ -0,0 +1,50 @@
import { ReactNode } from 'react';
export type ToastLocation = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
export type ToastAlertType = 'success' | 'error' | 'warning' | 'neutral';
export interface ToastOptions {
alertType?: ToastAlertType;
title: string;
body?: ReactNode;
buttonText?: string;
buttonCallback?: () => void;
isPersistentPopup?: boolean;
location?: ToastLocation;
icon?: ReactNode;
/** number 0-1 as fraction or 0-100 as percent */
progressBarPercentage?: number;
/** milliseconds to auto-close if not persistent */
durationMs?: number;
/** optional id to control/update later */
id?: string;
/** If true, show chevron and collapse/expand animation. Defaults to true. */
expandable?: boolean;
}
export interface ToastInstance extends Omit<ToastOptions, 'id' | 'progressBarPercentage'> {
id: string;
alertType: ToastAlertType;
isPersistentPopup: boolean;
location: ToastLocation;
durationMs: number;
expandable: boolean;
isExpanded: boolean;
/** Number of coalesced duplicates */
count?: number;
/** internal progress normalized 0..100 */
progress?: number;
/** if progress completed, briefly show check icon */
justCompleted: boolean;
createdAt: number;
}
export interface ToastApi {
show: (options: ToastOptions) => string;
update: (id: string, options: Partial<ToastOptions>) => void;
updateProgress: (id: string, progress: number) => void;
dismiss: (id: string) => void;
dismissAll: () => void;
}
+33 -12
View File
@@ -9,13 +9,22 @@ import NoToolsFound from './shared/NoToolsFound';
import "./toolPicker/ToolPicker.css";
interface SearchResultsProps {
filteredTools: [string, ToolRegistryEntry][];
filteredTools: Array<{ item: [string, ToolRegistryEntry]; matchedText?: string }>;
onSelect: (id: string) => void;
searchQuery?: string;
}
const SearchResults: React.FC<SearchResultsProps> = ({ filteredTools, onSelect }) => {
const SearchResults: React.FC<SearchResultsProps> = ({ filteredTools, onSelect, searchQuery }) => {
const { t } = useTranslation();
const { searchGroups } = useToolSections(filteredTools);
const { searchGroups } = useToolSections(filteredTools, searchQuery);
// Create a map of matched text for quick lookup
const matchedTextMap = new Map<string, string>();
if (filteredTools && Array.isArray(filteredTools)) {
filteredTools.forEach(({ item: [id], matchedText }) => {
if (matchedText) matchedTextMap.set(id, matchedText);
});
}
if (searchGroups.length === 0) {
return <NoToolsFound />;
@@ -28,15 +37,27 @@ const SearchResults: React.FC<SearchResultsProps> = ({ filteredTools, onSelect }
<Box key={group.subcategoryId} w="100%">
<SubcategoryHeader label={getSubcategoryLabel(t, group.subcategoryId)} />
<Stack gap="xs">
{group.tools.map(({ id, tool }) => (
<ToolButton
key={id}
id={id}
tool={tool}
isSelected={false}
onSelect={onSelect}
/>
))}
{group.tools.map(({ id, tool }) => {
const matchedText = matchedTextMap.get(id);
// Check if the match was from synonyms and show the actual synonym that matched
const isSynonymMatch = matchedText && tool.synonyms?.some(synonym =>
matchedText.toLowerCase().includes(synonym.toLowerCase())
);
const matchedSynonym = isSynonymMatch ? tool.synonyms?.find(synonym =>
matchedText.toLowerCase().includes(synonym.toLowerCase())
) : undefined;
return (
<ToolButton
key={id}
id={id}
tool={tool}
isSelected={false}
onSelect={onSelect}
matchedSynonym={matchedSynonym}
/>
);
})}
</Stack>
</Box>
))}
@@ -10,5 +10,5 @@ export default function ToolLoadingFallback({ toolName }: { toolName?: string })
</Text>
</Stack>
</Center>
)
);
}
+10 -6
View File
@@ -7,6 +7,8 @@ import ToolSearch from './toolPicker/ToolSearch';
import { useSidebarContext } from "../../contexts/SidebarContext";
import rainbowStyles from '../../styles/rainbow.module.css';
import { ScrollArea } from '@mantine/core';
import { ToolId } from '../../types/toolId';
import { useMediaQuery } from '@mantine/hooks';
// No props needed - component uses context
@@ -14,6 +16,7 @@ export default function ToolPanel() {
const { isRainbowMode } = useRainbowThemeContext();
const { sidebarRefs } = useSidebarContext();
const { toolPanelRef } = sidebarRefs;
const isMobile = useMediaQuery('(max-width: 1024px)');
// Use context-based hooks to eliminate prop drilling
@@ -33,17 +36,17 @@ export default function ToolPanel() {
<div
ref={toolPanelRef}
data-sidebar="tool-panel"
className={`h-screen flex flex-col overflow-hidden bg-[var(--bg-toolbar)] border-r border-[var(--border-subtle)] transition-all duration-300 ease-out ${
className={`flex flex-col overflow-hidden bg-[var(--bg-toolbar)] border-r border-[var(--border-subtle)] transition-all duration-300 ease-out ${
isRainbowMode ? rainbowStyles.rainbowPaper : ''
}`}
} ${isMobile ? 'h-full border-r-0' : 'h-screen'}`}
style={{
width: isPanelVisible ? '18.5rem' : '0',
width: isMobile ? '100%' : isPanelVisible ? '18.5rem' : '0',
padding: '0'
}}
>
<div
style={{
opacity: isPanelVisible ? 1 : 0,
opacity: isMobile || isPanelVisible ? 1 : 0,
transition: 'opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
height: '100%',
display: 'flex',
@@ -71,7 +74,8 @@ export default function ToolPanel() {
<div className="flex-1 flex flex-col overflow-y-auto">
<SearchResults
filteredTools={filteredTools}
onSelect={handleToolSelect}
onSelect={(id) => handleToolSelect(id as ToolId)}
searchQuery={searchQuery}
/>
</div>
) : leftPanelView === 'toolPicker' ? (
@@ -79,7 +83,7 @@ export default function ToolPanel() {
<div className="flex-1 flex flex-col overflow-auto">
<ToolPicker
selectedToolKey={selectedToolKey}
onSelect={handleToolSelect}
onSelect={(id) => handleToolSelect(id as ToolId)}
filteredTools={filteredTools}
isSearching={Boolean(searchQuery && searchQuery.trim().length > 0)}
/>
+1 -1
View File
@@ -10,7 +10,7 @@ import { renderToolButtons } from "./shared/renderToolButtons";
interface ToolPickerProps {
selectedToolKey: string | null;
onSelect: (id: string) => void;
filteredTools: [string, ToolRegistryEntry][];
filteredTools: Array<{ item: [string, ToolRegistryEntry]; matchedText?: string }>;
isSearching?: boolean;
}
@@ -1,4 +1,4 @@
import React, { Suspense } from "react";
import { Suspense } from "react";
import { useToolWorkflow } from "../../contexts/ToolWorkflowContext";
import { BaseToolProps } from "../../types/tool";
import ToolLoadingFallback from "./ToolLoadingFallback";
@@ -26,7 +26,7 @@ const ToolRenderer = ({
// Wrap lazy-loaded component with Suspense
return (
<Suspense fallback={<ToolLoadingFallback toolName={selectedTool.name} />}>
<Suspense fallback={<ToolLoadingFallback toolName={selectedTool.name} />}>
<ToolComponent
onPreviewFile={onPreviewFile}
onComplete={onComplete}
@@ -0,0 +1,101 @@
/* PageNumberPreview.module.css - EXACT copy from StampPreview */
/* Container styles */
.container {
position: relative;
width: 100%;
overflow: hidden;
}
.containerWithThumbnail {
background-color: transparent;
}
.containerWithoutThumbnail {
background-color: rgba(255, 255, 255, 0.03);
}
.containerBorder {
border: 1px solid var(--border-default, #333);
}
/* Page thumbnail styles */
.pageThumbnail {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
filter: grayscale(10%) contrast(95%) brightness(105%);
}
/* Quick grid overlay styles - EXACT copy from stamp */
.quickGrid {
position: absolute;
inset: 0;
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(3, 1fr);
gap: 8px;
padding: 8px;
pointer-events: auto;
}
.gridTile {
border: 1px dashed rgba(0, 0, 0, 0.15);
background-color: transparent;
border-radius: 10px;
cursor: pointer;
display: flex;
font-size: 20px;
user-select: none;
font-weight: 600;
position: relative;
}
/* Position numbers at edges within each tile with extra top/bottom spacing */
.gridTile:nth-child(1) { align-items: flex-start; justify-content: flex-start; padding-top: 4px; } /* top-left */
.gridTile:nth-child(2) { align-items: flex-start; justify-content: center; padding-top: 4px; } /* top-center */
.gridTile:nth-child(3) { align-items: flex-start; justify-content: flex-end; padding-top: 4px; } /* top-right */
.gridTile:nth-child(4) { align-items: center; justify-content: flex-start; } /* middle-left */
.gridTile:nth-child(5) { align-items: center; justify-content: center; } /* center */
.gridTile:nth-child(6) { align-items: center; justify-content: flex-end; } /* middle-right */
.gridTile:nth-child(7) { align-items: flex-end; justify-content: flex-start; padding-bottom: 4px; } /* bottom-left */
.gridTile:nth-child(8) { align-items: flex-end; justify-content: center; padding-bottom: 4px; } /* bottom-center */
.gridTile:nth-child(9) { align-items: flex-end; justify-content: flex-end; padding-bottom: 4px; } /* bottom-right */
/* Base padding for all tiles */
.gridTile {
padding: 8px;
}
.gridTileSelected,
.gridTileHovered {
border: 2px solid var(--mantine-primary-color-filled, #3b82f6);
background-color: rgba(59, 130, 246, 0.2);
}
/* Preview header */
.previewHeader {
margin-bottom: 12px;
}
.divider {
height: 1px;
background-color: var(--border-default, #333);
margin-bottom: 8px;
}
.previewLabel {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
text-align: center;
}
/* Preview disclaimer */
.previewDisclaimer {
margin-top: 8px;
opacity: 0.7;
font-size: 12px;
}
@@ -0,0 +1,241 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AddPageNumbersParameters } from './useAddPageNumbersParameters';
import { pdfWorkerManager } from '../../../services/pdfWorkerManager';
import { useThumbnailGeneration } from '../../../hooks/useThumbnailGeneration';
import styles from './PageNumberPreview.module.css';
// Simple utilities for page numbers (adapted from stamp)
const A4_ASPECT_RATIO = 0.707;
const getFirstSelectedPage = (input: string): number => {
if (!input) return 1;
const parts = input.split(',').map(s => s.trim()).filter(Boolean);
for (const part of parts) {
if (/^\d+\s*-\s*\d+$/.test(part)) {
const low = parseInt(part.split('-')[0].trim(), 10);
if (Number.isFinite(low) && low > 0) return low;
}
const n = parseInt(part, 10);
if (Number.isFinite(n) && n > 0) return n;
}
return 1;
};
const detectOverallBackgroundColor = async (thumbnailSrc: string | null): Promise<'light' | 'dark'> => {
if (!thumbnailSrc) {
return 'light'; // Default to light background if no thumbnail
}
return new Promise((resolve) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
try {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
resolve('light');
return;
}
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
// Sample the entire image at reduced resolution for performance
const sampleWidth = Math.min(100, img.width);
const sampleHeight = Math.min(100, img.height);
const imageData = ctx.getImageData(0, 0, img.width, img.height);
const data = imageData.data;
let totalBrightness = 0;
let pixelCount = 0;
// Sample every nth pixel for performance
const step = Math.max(1, Math.floor((img.width * img.height) / (sampleWidth * sampleHeight)));
for (let i = 0; i < data.length; i += 4 * step) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
// Calculate perceived brightness using luminance formula
const brightness = (0.299 * r + 0.587 * g + 0.114 * b);
totalBrightness += brightness;
pixelCount++;
}
const averageBrightness = totalBrightness / pixelCount;
// Threshold: 128 is middle gray
resolve(averageBrightness > 128 ? 'light' : 'dark');
} catch (error) {
console.warn('Error detecting background color:', error);
resolve('light'); // Default fallback
}
};
img.onerror = () => resolve('light');
img.src = thumbnailSrc;
});
};
type Props = {
parameters: AddPageNumbersParameters;
onParameterChange: <K extends keyof AddPageNumbersParameters>(key: K, value: AddPageNumbersParameters[K]) => void;
file?: File | null;
showQuickGrid?: boolean;
};
export default function PageNumberPreview({ parameters, onParameterChange, file, showQuickGrid }: Props) {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const [, setContainerSize] = useState<{ width: number; height: number }>({ width: 0, height: 0 });
const [pageSize, setPageSize] = useState<{ widthPts: number; heightPts: number } | null>(null);
const [pageThumbnail, setPageThumbnail] = useState<string | null>(null);
const { requestThumbnail } = useThumbnailGeneration();
const [hoverTile, setHoverTile] = useState<number | null>(null);
const [textColor, setTextColor] = useState<string>('#fff');
// Observe container size for responsive positioning
useEffect(() => {
const node = containerRef.current;
if (!node) return;
const resize = () => {
const aspect = pageSize ? (pageSize.widthPts / pageSize.heightPts) : A4_ASPECT_RATIO;
setContainerSize({ width: node.clientWidth, height: node.clientWidth / aspect });
};
resize();
const ro = new ResizeObserver(resize);
ro.observe(node);
return () => ro.disconnect();
}, [pageSize]);
// Load first PDF page size in points for accurate scaling
useEffect(() => {
let cancelled = false;
const load = async () => {
if (!file || file.type !== 'application/pdf') {
setPageSize(null);
return;
}
try {
const buffer = await file.arrayBuffer();
const pdf = await pdfWorkerManager.createDocument(buffer, { disableAutoFetch: true, disableStream: true });
const page = await pdf.getPage(1);
const viewport = page.getViewport({ scale: 1 });
if (!cancelled) {
setPageSize({ widthPts: viewport.width, heightPts: viewport.height });
}
pdfWorkerManager.destroyDocument(pdf);
} catch {
if (!cancelled) setPageSize(null);
}
};
load();
return () => { cancelled = true; };
}, [file]);
// Load first-page thumbnail for background preview
useEffect(() => {
let isActive = true;
const loadThumb = async () => {
if (!file || file.type !== 'application/pdf') {
setPageThumbnail(null);
return;
}
try {
const pageNumber = Math.max(1, getFirstSelectedPage(parameters.pagesToNumber || '1'));
const pageId = `${file.name}:${file.size}:${file.lastModified}:page:${pageNumber}`;
const thumb = await requestThumbnail(pageId, file, pageNumber);
if (isActive) setPageThumbnail(thumb || null);
} catch {
if (isActive) setPageThumbnail(null);
}
};
loadThumb();
return () => { isActive = false; };
}, [file, parameters.pagesToNumber, requestThumbnail]);
// Detect text color based on overall PDF background
useEffect(() => {
if (!pageThumbnail) {
setTextColor('#fff'); // Default to white for no thumbnail
return;
}
const detectColor = async () => {
const backgroundType = await detectOverallBackgroundColor(pageThumbnail);
setTextColor(backgroundType === 'light' ? '#000' : '#fff');
};
detectColor();
}, [pageThumbnail]);
const containerStyle = useMemo(() => ({
position: 'relative' as const,
width: '100%',
aspectRatio: `${(pageSize?.widthPts ?? 595.28) / (pageSize?.heightPts ?? 841.89)} / 1`,
backgroundColor: pageThumbnail ? 'transparent' : 'rgba(255,255,255,0.03)',
border: '1px solid var(--border-default, #333)',
overflow: 'hidden' as const
}), [pageSize, pageThumbnail]);
return (
<div>
<div className={styles.previewHeader}>
<div className={styles.divider} />
<div className={styles.previewLabel}>{t('addPageNumbers.preview', 'Preview Page Numbers')}</div>
</div>
<div
ref={containerRef}
className={`${styles.container} ${styles.containerBorder} ${pageThumbnail ? styles.containerWithThumbnail : styles.containerWithoutThumbnail}`}
style={containerStyle}
>
{pageThumbnail && (
<img
src={pageThumbnail}
alt="page preview"
className={styles.pageThumbnail}
draggable={false}
/>
)}
{/* Quick position overlay grid - EXACT copy from stamp */}
{showQuickGrid && (
<div className={styles.quickGrid}>
{Array.from({ length: 9 }).map((_, i) => {
const idx = (i + 1) as 1|2|3|4|5|6|7|8|9;
const selected = parameters.position === idx;
return (
<button
key={idx}
type="button"
className={`${styles.gridTile} ${selected || hoverTile === idx ? styles.gridTileSelected : ''} ${hoverTile === idx ? styles.gridTileHovered : ''}`}
onClick={() => onParameterChange('position', idx as any)}
onMouseEnter={() => setHoverTile(idx)}
onMouseLeave={() => setHoverTile(null)}
style={{
color: textColor,
textShadow: textColor === '#fff'
? '1px 1px 2px rgba(0, 0, 0, 0.8)'
: '1px 1px 2px rgba(255, 255, 255, 0.8)'
}}
>
{idx}
</button>
);
})}
</div>
)}
</div>
<div className={styles.previewDisclaimer}>
{t('addPageNumbers.previewDisclaimer', 'Preview is approximate. Final output may vary due to PDF font metrics.')}
</div>
</div>
);
}
@@ -0,0 +1,37 @@
import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation } from '../../../hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
import { AddPageNumbersParameters, defaultParameters } from './useAddPageNumbersParameters';
export const buildAddPageNumbersFormData = (parameters: AddPageNumbersParameters, file: File): FormData => {
const formData = new FormData();
formData.append('fileInput', file);
formData.append('customMargin', parameters.customMargin);
formData.append('position', String(parameters.position));
formData.append('fontSize', String(parameters.fontSize));
formData.append('fontType', parameters.fontType);
formData.append('startingNumber', String(parameters.startingNumber));
formData.append('pagesToNumber', parameters.pagesToNumber);
formData.append('customText', parameters.customText);
return formData;
};
export const addPageNumbersOperationConfig = {
toolType: ToolType.singleFile,
buildFormData: buildAddPageNumbersFormData,
operationType: 'addPageNumbers',
endpoint: '/api/v1/misc/add-page-numbers',
defaultParameters,
} as const;
export const useAddPageNumbersOperation = () => {
const { t } = useTranslation();
return useToolOperation<AddPageNumbersParameters>({
...addPageNumbersOperationConfig,
getErrorMessage: createStandardErrorHandler(
t('addPageNumbers.error.failed', 'An error occurred while adding page numbers to the PDF.')
),
});
};
@@ -0,0 +1,34 @@
import { BaseParameters } from '../../../types/parameters';
import { useBaseParameters, type BaseParametersHook } from '../../../hooks/tools/shared/useBaseParameters';
export interface AddPageNumbersParameters extends BaseParameters {
customMargin: 'small' | 'medium' | 'large' | 'x-large';
position: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
fontSize: number;
fontType: 'Times' | 'Helvetica' | 'Courier';
startingNumber: number;
pagesToNumber: string;
customText: string;
}
export const defaultParameters: AddPageNumbersParameters = {
customMargin: 'medium',
position: 8, // Default to bottom center like the original HTML
fontSize: 12,
fontType: 'Times',
startingNumber: 1,
pagesToNumber: '',
customText: '',
};
export type AddPageNumbersParametersHook = BaseParametersHook<AddPageNumbersParameters>;
export const useAddPageNumbersParameters = (): AddPageNumbersParametersHook => {
return useBaseParameters<AddPageNumbersParameters>({
defaultParameters,
endpointName: 'add-page-numbers',
validateFn: (params): boolean => {
return params.fontSize > 0 && params.startingNumber > 0;
},
});
};
@@ -1,12 +1,11 @@
/**
* 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";
@@ -67,4 +66,4 @@ const AddWatermarkSingleStepSettings = ({ parameters, onParameterChange, disable
);
};
export default AddWatermarkSingleStepSettings;
export default AddWatermarkSingleStepSettings;
@@ -1,4 +1,3 @@
import React from "react";
import { Stack, Checkbox, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { AddWatermarkParameters } from "../../../hooks/tools/addWatermark/useAddWatermarkParameters";
@@ -80,4 +79,4 @@ const WatermarkFormatting = ({ parameters, onParameterChange, disabled = false }
);
};
export default WatermarkFormatting;
export default WatermarkFormatting;
@@ -1,4 +1,3 @@
import React from "react";
import { Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { AddWatermarkParameters } from "../../../hooks/tools/addWatermark/useAddWatermarkParameters";
@@ -1,4 +1,3 @@
import React from "react";
import { Stack, Text, NumberInput } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { AddWatermarkParameters } from "../../../hooks/tools/addWatermark/useAddWatermarkParameters";
@@ -60,4 +59,4 @@ const WatermarkStyleSettings = ({ parameters, onParameterChange, disabled = fals
);
};
export default WatermarkStyleSettings;
export default WatermarkStyleSettings;
@@ -1,4 +1,3 @@
import React from "react";
import { Stack, Text, Select, ColorInput } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { AddWatermarkParameters } from "../../../hooks/tools/addWatermark/useAddWatermarkParameters";
@@ -27,7 +26,7 @@ const WatermarkTextStyle = ({ parameters, onParameterChange, disabled = false }:
format="hex"
/>
</Stack>
<Stack gap="xs">
<Text size="xs" fw={500}>
{t("watermark.settings.alphabet", "Alphabet")}
@@ -1,4 +1,3 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Title, Stack, Divider } from "@mantine/core";
import AddCircleOutline from "@mui/icons-material/AddCircleOutline";
@@ -19,11 +18,11 @@ interface AutomationSelectionProps {
toolRegistry: Record<string, ToolRegistryEntry>;
}
export default function AutomationSelection({
export default function AutomationSelection({
savedAutomations,
onCreateNew,
onRun,
onEdit,
onCreateNew,
onRun,
onEdit,
onDelete,
onCopyFromSuggested,
toolRegistry
@@ -58,8 +58,13 @@ export default function ToolSelector({
return registry;
}, [baseFilteredTools]);
// Transform filteredTools to the expected format for useToolSections
const transformedFilteredTools = useMemo(() => {
return filteredTools.map(([id, tool]) => ({ item: [id, tool] as [string, ToolRegistryEntry] }));
}, [filteredTools]);
// Use the same tool sections logic as the main ToolPicker
const { sections, searchGroups } = useToolSections(filteredTools);
const { sections, searchGroups } = useToolSections(transformedFilteredTools);
// Determine what to display: search results or organized sections
const isSearching = searchTerm.trim().length > 0;
@@ -1,4 +1,4 @@
import React, { useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Stack, Text, Divider, Collapse, Button, NumberInput } from "@mantine/core";
import { BookletImpositionParameters } from "../../../hooks/tools/bookletImposition/useBookletImpositionParameters";
@@ -176,4 +176,4 @@ const BookletImpositionSettings = ({ parameters, onParameterChange, disabled = f
);
};
export default BookletImpositionSettings;
export default BookletImpositionSettings;
@@ -1,4 +1,4 @@
import React, { useState } from "react";
import { useState } from "react";
import { Stack, Text, NumberInput, Select, Divider } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { CompressParameters } from "../../../hooks/tools/compress/useCompressParameters";
@@ -1,4 +1,3 @@
import React from 'react';
import { Stack, Text, NumberInput, Checkbox } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParameters';
@@ -1,4 +1,3 @@
import React from "react";
import { Stack, Text, Select, Switch } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { COLOR_TYPES, FIT_OPTIONS } from "../../../constants/convertConstants";
@@ -1,4 +1,3 @@
import React from 'react';
import { Stack, Text, NumberInput, Slider } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParameters';
@@ -1,4 +1,4 @@
import React, { useMemo } from "react";
import { useMemo } from "react";
import { Stack, Text, Group, Divider, UnstyledButton, useMantineTheme, useMantineColorScheme } from "@mantine/core";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import { useTranslation } from "react-i18next";
@@ -1,4 +1,3 @@
import React from "react";
import { Stack, Text, Select, NumberInput, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { COLOR_TYPES, OUTPUT_OPTIONS } from "../../../constants/convertConstants";
@@ -1,4 +1,3 @@
import React from 'react';
import { Stack, Text, Select, Alert } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ConvertParameters } from '../../../hooks/tools/convert/useConvertParameters';
@@ -1,4 +1,4 @@
import React, { useState, useMemo } from "react";
import { useState, useMemo } from "react";
import { Stack, Text, Group, Button, Box, Popover, UnstyledButton, useMantineTheme, useMantineColorScheme } from "@mantine/core";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
@@ -34,14 +34,14 @@ const GroupedFormatDropdown = ({
const groupedOptions = useMemo(() => {
const groups: Record<string, FormatOption[]> = {};
options.forEach(option => {
if (!groups[option.group]) {
groups[option.group] = [];
}
groups[option.group].push(option);
});
return groups;
}, [options]);
@@ -77,14 +77,14 @@ const GroupedFormatDropdown = ({
padding: '0.5rem 0.75rem',
border: `0.0625rem solid ${theme.colors.gray[4]}`,
borderRadius: theme.radius.sm,
backgroundColor: disabled
? theme.colors.gray[1]
: colorScheme === 'dark'
? theme.colors.dark[6]
backgroundColor: disabled
? theme.colors.gray[1]
: colorScheme === 'dark'
? theme.colors.dark[6]
: theme.white,
cursor: disabled ? 'not-allowed' : 'pointer',
width: '100%',
color: disabled
color: disabled
? colorScheme === 'dark' ? theme.colors.dark[1] : theme.colors.dark[7]
: colorScheme === 'dark' ? theme.colors.dark[0] : theme.colors.dark[9]
}}
@@ -93,19 +93,19 @@ const GroupedFormatDropdown = ({
<Text size="sm" c={value ? undefined : 'dimmed'}>
{selectedLabel}
</Text>
<KeyboardArrowDownIcon
style={{
<KeyboardArrowDownIcon
style={{
fontSize: '1rem',
transform: dropdownOpened ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 0.2s ease',
color: colorScheme === 'dark' ? theme.colors.dark[2] : theme.colors.gray[6]
}}
}}
/>
</Group>
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown
style={{
<Popover.Dropdown
style={{
minWidth: Math.min(350, parseInt(minWidth.replace('rem', '')) * 16),
maxWidth: '90vw',
maxHeight: '40vh',
@@ -117,10 +117,10 @@ const GroupedFormatDropdown = ({
<Stack gap="md">
{Object.entries(groupedOptions).map(([groupName, groupOptions]) => (
<Box key={groupName}>
<Text
size="sm"
fw={600}
c={colorScheme === 'dark' ? 'dark.2' : 'gray.6'}
<Text
size="sm"
fw={600}
c={colorScheme === 'dark' ? 'dark.2' : 'gray.6'}
mb="xs"
>
{groupName}
@@ -153,4 +153,4 @@ const GroupedFormatDropdown = ({
);
};
export default GroupedFormatDropdown;
export default GroupedFormatDropdown;
@@ -0,0 +1,46 @@
import { useTranslation } from 'react-i18next';
import { Stack, Select, Checkbox } from '@mantine/core';
import { ExtractImagesParameters } from '../../../hooks/tools/extractImages/useExtractImagesParameters';
interface ExtractImagesSettingsProps {
parameters: ExtractImagesParameters;
onParameterChange: <K extends keyof ExtractImagesParameters>(key: K, value: ExtractImagesParameters[K]) => void;
disabled?: boolean;
}
const ExtractImagesSettings = ({
parameters,
onParameterChange,
disabled = false
}: ExtractImagesSettingsProps) => {
const { t } = useTranslation();
return (
<Stack gap="md">
<Select
label={t('extractImages.selectText', 'Output Format')}
value={parameters.format}
onChange={(value) => {
const allowedFormats = ['png', 'jpg', 'gif'] as const;
const format = allowedFormats.includes(value as any) ? (value as typeof allowedFormats[number]) : 'png';
onParameterChange('format', format);
}}
data={[
{ value: 'png', label: 'PNG' },
{ value: 'jpg', label: 'JPG' },
{ value: 'gif', label: 'GIF' },
]}
disabled={disabled}
/>
<Checkbox
label={t('extractImages.allowDuplicates', 'Allow Duplicate Images')}
checked={parameters.allowDuplicates}
onChange={(event) => onParameterChange('allowDuplicates', event.currentTarget.checked)}
disabled={disabled}
/>
</Stack>
);
};
export default ExtractImagesSettings;
@@ -0,0 +1,61 @@
import { Divider, Select, Stack, Switch } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { PageLayoutParameters } from '../../../hooks/tools/pageLayout/usePageLayoutParameters';
import { getPagesPerSheetOptions } from './constants';
export default function PageLayoutSettings({
parameters,
onParameterChange,
disabled,
}: {
parameters: PageLayoutParameters;
onParameterChange: <K extends keyof PageLayoutParameters>(
key: K,
value: PageLayoutParameters[K]
) => void;
disabled?: boolean;
}) {
const { t } = useTranslation();
const options = getPagesPerSheetOptions(t);
const selected = options.find((o) => o.value === parameters.pagesPerSheet) || options[0];
return (
<Stack gap="sm">
<Select
label={t('pageLayout.pagesPerSheet', 'Pages per sheet:')}
data={options.map(o => ({ value: String(o.value), label: o.label }))}
value={String(parameters.pagesPerSheet)}
onChange={(v) => onParameterChange('pagesPerSheet', Number(v))}
disabled={disabled}
/>
{selected && (
<div
style={{
backgroundColor: 'var(--information-text-bg)',
color: 'var(--information-text-color)',
padding: '8px 12px',
borderRadius: '8px',
marginTop: '4px',
fontSize: '0.75rem',
textAlign: 'center',
}}
>
{selected.description}
</div>
)}
<Divider />
<Switch
checked={parameters.addBorder}
onChange={(e) => onParameterChange('addBorder', e.currentTarget.checked)}
label={t('pageLayout.addBorder', 'Add Borders')}
disabled={disabled}
/>
</Stack>
);
}
@@ -0,0 +1,37 @@
import { TFunction } from 'i18next';
export type PagesPerSheetOption = {
value: number;
label: string;
description: string;
};
export const getPagesPerSheetOptions = (t: TFunction): PagesPerSheetOption[] => [
{
value: 2,
label: '2',
description: t('pageLayout.desc.2', 'Place 2 pages side-by-side on a single sheet.')
},
{
value: 3,
label: '3',
description: t('pageLayout.desc.3', 'Place 3 pages on a single sheet in a single row.')
},
{
value: 4,
label: '4',
description: t('pageLayout.desc.4', 'Place 4 pages on a single sheet (2 × 2 grid).')
},
{
value: 9,
label: '9',
description: t('pageLayout.desc.9', 'Place 9 pages on a single sheet (3 × 3 grid).')
},
{
value: 16,
label: '16',
description: t('pageLayout.desc.16', 'Place 16 pages on a single sheet (4 × 4 grid).')
},
];
@@ -0,0 +1,26 @@
import { useTranslation } from 'react-i18next';
import { Stack, Text, Alert } from '@mantine/core';
import LocalIcon from '../../shared/LocalIcon';
const RemoveAnnotationsSettings = () => {
const { t } = useTranslation();
return (
<Stack gap="md">
<Alert
icon={<LocalIcon icon="info-rounded" width="1.2rem" height="1.2rem" />}
title={t('removeAnnotations.info.title', 'About Remove Annotations')}
color="blue"
variant="light"
>
<Text size="sm">
{t('removeAnnotations.info.description',
'This tool will remove all annotations (comments, highlights, notes, etc.) from your PDF documents.'
)}
</Text>
</Alert>
</Stack>
);
};
export default RemoveAnnotationsSettings;
@@ -0,0 +1,64 @@
import { Divider, Select, Stack, TextInput } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ReorganizePagesParameters } from '../../../hooks/tools/reorganizePages/useReorganizePagesParameters';
import { getReorganizePagesModeData } from './constants';
export default function ReorganizePagesSettings({
parameters,
onParameterChange,
disabled,
}: {
parameters: ReorganizePagesParameters;
onParameterChange: <K extends keyof ReorganizePagesParameters>(
key: K,
value: ReorganizePagesParameters[K]
) => void;
disabled?: boolean;
}) {
const { t } = useTranslation();
const modeData = getReorganizePagesModeData(t);
const requiresOrder = parameters.customMode === '' || parameters.customMode === 'DUPLICATE';
const selectedMode = modeData.find(mode => mode.value === parameters.customMode) || modeData[0];
return (
<Stack gap="sm">
<Select
label={t('pdfOrganiser.mode._value', 'Organization mode')}
data={modeData}
value={parameters.customMode}
onChange={(v) => onParameterChange('customMode', v ?? '')}
disabled={disabled}
/>
{selectedMode && (
<div
style={{
backgroundColor: 'var(--information-text-bg)',
color: 'var(--information-text-color)',
padding: '8px 12px',
borderRadius: '8px',
marginTop: '4px',
fontSize: '0.75rem',
textAlign: 'center'
}}
>
{selectedMode.description}
</div>
)}
{requiresOrder && (
<>
<Divider/>
<TextInput
label={t('pageOrderPrompt', 'Page order / ranges')}
placeholder={t('pdfOrganiser.placeholder', 'e.g. 1,3,2,4-6')}
value={parameters.pageNumbers}
onChange={(e) => onParameterChange('pageNumbers', e.currentTarget.value)}
disabled={disabled}
/>
</>
)}
</Stack>
);
}
@@ -0,0 +1,59 @@
import { TFunction } from 'i18next';
export const getReorganizePagesModeData = (t: TFunction) => [
{
value: '',
label: t('pdfOrganiser.mode.1', 'Custom Page Order'),
description: t('pdfOrganiser.mode.desc.CUSTOM', 'Use a custom sequence of page numbers or expressions to define a new order.')
},
{
value: 'REVERSE_ORDER',
label: t('pdfOrganiser.mode.2', 'Reverse Order'),
description: t('pdfOrganiser.mode.desc.REVERSE_ORDER', 'Flip the document so the last page becomes first and so on.')
},
{
value: 'DUPLEX_SORT',
label: t('pdfOrganiser.mode.3', 'Duplex Sort'),
description: t('pdfOrganiser.mode.desc.DUPLEX_SORT', 'Interleave fronts then backs as if a duplex scanner scanned all fronts, then all backs (1, n, 2, n-1, …).')
},
{
value: 'BOOKLET_SORT',
label: t('pdfOrganiser.mode.4', 'Booklet Sort'),
description: t('pdfOrganiser.mode.desc.BOOKLET_SORT', 'Arrange pages for booklet printing (last, first, second, second last, …).')
},
{
value: 'SIDE_STITCH_BOOKLET_SORT',
label: t('pdfOrganiser.mode.5', 'Side Stitch Booklet Sort'),
description: t('pdfOrganiser.mode.desc.SIDE_STITCH_BOOKLET_SORT', 'Arrange pages for sidestitch booklet printing (optimized for binding on the side).')
},
{
value: 'ODD_EVEN_SPLIT',
label: t('pdfOrganiser.mode.6', 'Odd-Even Split'),
description: t('pdfOrganiser.mode.desc.ODD_EVEN_SPLIT', 'Split the document into two outputs: all odd pages and all even pages.')
},
{
value: 'ODD_EVEN_MERGE',
label: t('pdfOrganiser.mode.10', 'Odd-Even Merge'),
description: t('pdfOrganiser.mode.desc.ODD_EVEN_MERGE', 'Merge two PDFs by alternating pages: odd from the first, even from the second.')
},
{
value: 'DUPLICATE',
label: t('pdfOrganiser.mode.11', 'Duplicate all pages'),
description: t('pdfOrganiser.mode.desc.DUPLICATE', 'Duplicate each page according to the custom order count (e.g., 4 duplicates each page 4×).')
},
{
value: 'REMOVE_FIRST',
label: t('pdfOrganiser.mode.7', 'Remove First'),
description: t('pdfOrganiser.mode.desc.REMOVE_FIRST', 'Remove the first page from the document.')
},
{
value: 'REMOVE_LAST',
label: t('pdfOrganiser.mode.8', 'Remove Last'),
description: t('pdfOrganiser.mode.desc.REMOVE_LAST', 'Remove the last page from the document.')
},
{
value: 'REMOVE_FIRST_AND_LAST',
label: t('pdfOrganiser.mode.9', 'Remove First and Last'),
description: t('pdfOrganiser.mode.desc.REMOVE_FIRST_AND_LAST', 'Remove both the first and last pages from the document.')
},
];
@@ -0,0 +1,107 @@
import { Stack, Text, Select, ColorInput } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ReplaceColorParameters } from "../../../hooks/tools/replaceColor/useReplaceColorParameters";
interface ReplaceColorSettingsProps {
parameters: ReplaceColorParameters;
onParameterChange: <K extends keyof ReplaceColorParameters>(key: K, value: ReplaceColorParameters[K]) => void;
disabled?: boolean;
}
const ReplaceColorSettings = ({ parameters, onParameterChange, disabled = false }: ReplaceColorSettingsProps) => {
const { t } = useTranslation();
const replaceAndInvertOptions = [
{
value: 'HIGH_CONTRAST_COLOR',
label: t('replaceColor.options.highContrast', 'High contrast')
},
{
value: 'FULL_INVERSION',
label: t('replaceColor.options.invertAll', 'Invert all colours')
},
{
value: 'CUSTOM_COLOR',
label: t('replaceColor.options.custom', 'Custom')
}
];
const highContrastOptions = [
{
value: 'WHITE_TEXT_ON_BLACK',
label: t('replace-color.selectText.6', 'White text on black background')
},
{
value: 'BLACK_TEXT_ON_WHITE',
label: t('replace-color.selectText.7', 'Black text on white background')
},
{
value: 'YELLOW_TEXT_ON_BLACK',
label: t('replace-color.selectText.8', 'Yellow text on black background')
},
{
value: 'GREEN_TEXT_ON_BLACK',
label: t('replace-color.selectText.9', 'Green text on black background')
}
];
return (
<Stack gap="md">
<Stack gap="xs">
<Text size="sm" fw={500}>
{t('replaceColor.labels.colourOperation', 'Colour operation')}
</Text>
<Select
value={parameters.replaceAndInvertOption}
onChange={(value) => value && onParameterChange('replaceAndInvertOption', value as ReplaceColorParameters['replaceAndInvertOption'])}
data={replaceAndInvertOptions}
disabled={disabled}
/>
</Stack>
{parameters.replaceAndInvertOption === 'HIGH_CONTRAST_COLOR' && (
<Stack gap="xs">
<Text size="sm" fw={500}>
{t('replace-color.selectText.5', 'High contrast color options')}
</Text>
<Select
value={parameters.highContrastColorCombination}
onChange={(value) => value && onParameterChange('highContrastColorCombination', value as ReplaceColorParameters['highContrastColorCombination'])}
data={highContrastOptions}
disabled={disabled}
/>
</Stack>
)}
{parameters.replaceAndInvertOption === 'CUSTOM_COLOR' && (
<>
<Stack gap="xs">
<Text size="sm" fw={500}>
{t('replace-color.selectText.10', 'Choose text Color')}
</Text>
<ColorInput
value={parameters.textColor}
onChange={(value) => onParameterChange('textColor', value)}
format="hex"
disabled={disabled}
/>
</Stack>
<Stack gap="xs">
<Text size="sm" fw={500}>
{t('replace-color.selectText.11', 'Choose background Color')}
</Text>
<ColorInput
value={parameters.backGroundColor}
onChange={(value) => onParameterChange('backGroundColor', value)}
format="hex"
disabled={disabled}
/>
</Stack>
</>
)}
</Stack>
);
};
export default ReplaceColorSettings;
@@ -0,0 +1,74 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { NumberInput, Stack } from '@mantine/core';
import { ScannerImageSplitParameters } from '../../../hooks/tools/scannerImageSplit/useScannerImageSplitParameters';
interface ScannerImageSplitSettingsProps {
parameters: ScannerImageSplitParameters;
onParameterChange: <K extends keyof ScannerImageSplitParameters>(key: K, value: ScannerImageSplitParameters[K]) => void;
disabled?: boolean;
}
const ScannerImageSplitSettings: React.FC<ScannerImageSplitSettingsProps> = ({
parameters,
onParameterChange,
disabled = false
}) => {
const { t } = useTranslation();
return (
<Stack gap="md">
<NumberInput
label={t('ScannerImageSplit.selectText.1', 'Angle Threshold:')}
description={t('ScannerImageSplit.selectText.2', 'Sets the minimum absolute angle required for the image to be rotated (default: 10).')}
value={parameters.angle_threshold}
onChange={(value) => onParameterChange('angle_threshold', Number(value) || 10)}
min={0}
step={1}
disabled={disabled}
/>
<NumberInput
label={t('ScannerImageSplit.selectText.3', 'Tolerance:')}
description={t('ScannerImageSplit.selectText.4', 'Determines the range of colour variation around the estimated background colour (default: 30).')}
value={parameters.tolerance}
onChange={(value) => onParameterChange('tolerance', Number(value) || 30)}
min={0}
step={1}
disabled={disabled}
/>
<NumberInput
label={t('ScannerImageSplit.selectText.5', 'Minimum Area:')}
description={t('ScannerImageSplit.selectText.6', 'Sets the minimum area threshold for a photo (default: 10000).')}
value={parameters.min_area}
onChange={(value) => onParameterChange('min_area', Number(value) || 10000)}
min={0}
step={100}
disabled={disabled}
/>
<NumberInput
label={t('ScannerImageSplit.selectText.7', 'Minimum Contour Area:')}
description={t('ScannerImageSplit.selectText.8', 'Sets the minimum contour area threshold for a photo.')}
value={parameters.min_contour_area}
onChange={(value) => onParameterChange('min_contour_area', Number(value) || 500)}
min={0}
step={10}
disabled={disabled}
/>
<NumberInput
label={t('ScannerImageSplit.selectText.9', 'Border Size:')}
description={t('ScannerImageSplit.selectText.10', 'Sets the size of the border added and removed to prevent white borders in the output (default: 1).')}
value={parameters.border_size}
onChange={(value) => onParameterChange('border_size', Number(value) || 1)}
min={0}
step={1}
disabled={disabled}
/>
</Stack>
);
};
export default ScannerImageSplitSettings;
@@ -30,6 +30,6 @@ const ErrorNotification = ({
{error}
</Notification>
);
}
};
export default ErrorNotification;
@@ -1,4 +1,3 @@
import React from 'react';
import { Stack, Text } from '@mantine/core';
import { formatFileSize, getFileDate } from '../../../utils/fileUtils';
@@ -24,4 +23,4 @@ const FileMetadata = ({ file }: FileMetadataProps) => {
);
};
export default FileMetadata;
export default FileMetadata;
@@ -1,4 +1,3 @@
import React from 'react';
import { Stack, Group, ActionIcon, Text } from '@mantine/core';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react";
import { useState, useEffect } from "react";
import { Stack, Text, NumberInput } from "@mantine/core";
interface NumberInputWithUnitProps {
@@ -11,14 +11,14 @@ interface NumberInputWithUnitProps {
disabled?: boolean;
}
const NumberInputWithUnit = ({
label,
value,
onChange,
unit,
min,
max,
disabled = false
const NumberInputWithUnit = ({
label,
value,
onChange,
unit,
min,
max,
disabled = false
}: NumberInputWithUnitProps) => {
const [localValue, setLocalValue] = useState<number | string>(value);
@@ -54,4 +54,4 @@ const NumberInputWithUnit = ({
);
};
export default NumberInputWithUnit;
export default NumberInputWithUnit;
@@ -1,4 +1,3 @@
import React from 'react';
import { Button } from '@mantine/core';
import { useTranslation } from 'react-i18next';
@@ -52,6 +51,6 @@ const OperationButton = ({
}
</Button>
);
}
};
export default OperationButton;
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import { useState } from 'react';
import { Box, Text, Loader, Stack, Center, Flex } from '@mantine/core';
import FilePreview from '../../shared/FilePreview';
import FileMetadata from './FileMetadata';
@@ -149,7 +149,7 @@ const ToolStep = ({
<Divider style={{ color: '#E2E8F0', marginLeft: '1rem', marginRight: '-0.5rem' }} />
</div>
);
}
};
// ToolStepFactory for creating numbered steps
export function createToolSteps() {
@@ -164,13 +164,16 @@ export function createToolSteps() {
const isVisible = props.isVisible !== false;
const currentStepNumber = isVisible ? stepNumber++ : undefined;
const step = React.createElement(ToolStep, {
...props,
title,
_stepNumber: currentStepNumber,
children,
key: `step-${title.toLowerCase().replace(/\s+/g, '-')}`
});
const step = React.createElement(
ToolStep,
{
...props,
title,
_stepNumber: currentStepNumber,
key: `step-${title.toLowerCase().replace(/\s+/g, '-')}`
},
children
);
steps.push(step);
return step;
@@ -186,9 +189,9 @@ export function createToolSteps() {
const getVisibleCount = () => {
return steps.filter(step => {
const props = step.props as ToolStepProps;
const isVisible = props.isVisible !== false;
const excludeFromCount = props._excludeFromCount === true;
const stepProps = step.props as ToolStepProps;
const isVisible = stepProps.isVisible !== false;
const excludeFromCount = stepProps._excludeFromCount === true;
return isVisible && !excludeFromCount;
}).length;
};
@@ -203,9 +206,9 @@ export function ToolStepProvider({ children, forceStepNumbers }: { children: Rea
let count = 0;
React.Children.forEach(children, (child) => {
if (React.isValidElement(child) && child.type === ToolStep) {
const props = child.props as ToolStepProps;
const isVisible = props.isVisible !== false;
const excludeFromCount = props._excludeFromCount === true;
const stepProps = child.props as ToolStepProps;
const isVisible = stepProps.isVisible !== false;
const excludeFromCount = stepProps._excludeFromCount === true;
if (isVisible && !excludeFromCount) count++;
}
});
@@ -13,23 +13,39 @@ export const renderToolButtons = (
selectedToolKey: string | null,
onSelect: (id: string) => void,
showSubcategoryHeader: boolean = true,
disableNavigation: boolean = false
) => (
<Box key={subcategory.subcategoryId} w="100%">
{showSubcategoryHeader && (
<SubcategoryHeader label={getSubcategoryLabel(t, subcategory.subcategoryId)} />
)}
<div>
{subcategory.tools.map(({ id, tool }) => (
<ToolButton
key={id}
id={id}
tool={tool}
isSelected={selectedToolKey === id}
onSelect={onSelect}
disableNavigation={disableNavigation}
/>
))}
</div>
</Box>
);
disableNavigation: boolean = false,
searchResults?: Array<{ item: [string, any]; matchedText?: string }>
) => {
// Create a map of matched text for quick lookup
const matchedTextMap = new Map<string, string>();
if (searchResults) {
searchResults.forEach(({ item: [id], matchedText }) => {
if (matchedText) matchedTextMap.set(id, matchedText);
});
}
return (
<Box key={subcategory.subcategoryId} w="100%">
{showSubcategoryHeader && (
<SubcategoryHeader label={getSubcategoryLabel(t, subcategory.subcategoryId)} />
)}
<div>
{subcategory.tools.map(({ id, tool }) => {
const matchedSynonym = matchedTextMap.get(id);
return (
<ToolButton
key={id}
id={id}
tool={tool}
isSelected={selectedToolKey === id}
onSelect={onSelect}
disableNavigation={disableNavigation}
matchedSynonym={matchedSynonym}
/>
);
})}
</div>
</Box>
);
};
@@ -0,0 +1,86 @@
import React from 'react';
import { TextInput, Combobox, useCombobox } from '@mantine/core';
interface PenSizeSelectorProps {
value: number;
inputValue: string;
onValueChange: (size: number) => void;
onInputChange: (input: string) => void;
disabled?: boolean;
placeholder?: string;
style?: React.CSSProperties;
size?: string;
}
const PenSizeSelector = ({
value,
inputValue,
onValueChange,
onInputChange,
disabled = false,
placeholder = "Type or select pen size (1-200)",
style,
size
}: PenSizeSelectorProps) => {
const combobox = useCombobox();
const penSizeOptions = ['1', '2', '3', '4', '5', '8', '10', '12', '15', '20'];
return (
<Combobox
onOptionSubmit={(optionValue) => {
const penSize = parseInt(optionValue);
if (!isNaN(penSize)) {
onValueChange(penSize);
onInputChange(optionValue);
}
combobox.closeDropdown();
}}
store={combobox}
withinPortal={false}
>
<Combobox.Target>
<TextInput
placeholder={placeholder}
size={size}
value={inputValue}
onChange={(event) => {
const inputVal = event.currentTarget.value;
onInputChange(inputVal);
const penSize = parseInt(inputVal);
if (!isNaN(penSize) && penSize >= 1 && penSize <= 200) {
onValueChange(penSize);
}
combobox.openDropdown();
combobox.updateSelectedOptionIndex();
}}
onClick={() => combobox.openDropdown()}
onFocus={() => combobox.openDropdown()}
onBlur={() => {
combobox.closeDropdown();
const penSize = parseInt(inputValue);
if (isNaN(penSize) || penSize < 1 || penSize > 200) {
onInputChange(value.toString());
}
}}
disabled={disabled}
style={style}
/>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Options>
{penSizeOptions.map((sizeOption) => (
<Combobox.Option value={sizeOption} key={sizeOption}>
{sizeOption}px
</Combobox.Option>
))}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
);
};
export default PenSizeSelector;
@@ -0,0 +1,259 @@
import { useState, useEffect } from 'react';
import { useTranslation } from "react-i18next";
import { Stack, Button, Text, Alert, Tabs } from '@mantine/core';
import { SignParameters } from "../../../hooks/tools/sign/useSignParameters";
import { SuggestedToolsSection } from "../shared/SuggestedToolsSection";
// Import the new reusable components
import { DrawingCanvas } from "../../annotation/shared/DrawingCanvas";
import { DrawingControls } from "../../annotation/shared/DrawingControls";
import { ImageUploader } from "../../annotation/shared/ImageUploader";
import { TextInputWithFont } from "../../annotation/shared/TextInputWithFont";
import { ColorPicker } from "../../annotation/shared/ColorPicker";
interface SignSettingsProps {
parameters: SignParameters;
onParameterChange: <K extends keyof SignParameters>(key: K, value: SignParameters[K]) => void;
disabled?: boolean;
onActivateDrawMode?: () => void;
onActivateSignaturePlacement?: () => void;
onDeactivateSignature?: () => void;
onUpdateDrawSettings?: (color: string, size: number) => void;
onUndo?: () => void;
onRedo?: () => void;
onSave?: () => void;
}
const SignSettings = ({
parameters,
onParameterChange,
disabled = false,
onActivateSignaturePlacement,
onDeactivateSignature,
onUndo,
onRedo,
onSave
}: SignSettingsProps) => {
const { t } = useTranslation();
// State for drawing
const [selectedColor, setSelectedColor] = useState('#000000');
const [penSize, setPenSize] = useState(2);
const [penSizeInput, setPenSizeInput] = useState('2');
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
// State for different signature types
const [canvasSignatureData, setCanvasSignatureData] = useState<string | null>(null);
const [imageSignatureData, setImageSignatureData] = useState<string | null>(null);
// Handle image upload
const handleImageChange = async (file: File | null) => {
if (file && !disabled) {
try {
const result = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
if (e.target?.result) {
resolve(e.target.result as string);
} else {
reject(new Error('Failed to read file'));
}
};
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
// Clear any existing canvas signatures when uploading image
setCanvasSignatureData(null);
setImageSignatureData(result);
} catch (error) {
console.error('Error reading file:', error);
}
} else if (!file) {
setImageSignatureData(null);
if (onDeactivateSignature) {
onDeactivateSignature();
}
}
};
// Handle signature data changes
const handleCanvasSignatureChange = (data: string | null) => {
setCanvasSignatureData(prev => {
if (prev === data) return prev; // Prevent unnecessary updates
return data;
});
if (data) {
// Clear image data when canvas is used
setImageSignatureData(null);
}
};
// Handle signature mode deactivation when switching types
useEffect(() => {
if (parameters.signatureType !== 'text' && onDeactivateSignature) {
onDeactivateSignature();
}
}, [parameters.signatureType]);
// Handle text signature activation
useEffect(() => {
if (parameters.signatureType === 'text' && parameters.signerName && parameters.signerName.trim() !== '') {
if (onActivateSignaturePlacement) {
setTimeout(() => {
onActivateSignaturePlacement();
}, 100);
}
} else if (parameters.signatureType === 'text' && (!parameters.signerName || parameters.signerName.trim() === '')) {
if (onDeactivateSignature) {
onDeactivateSignature();
}
}
}, [parameters.signatureType, parameters.signerName, onActivateSignaturePlacement, onDeactivateSignature]);
// Handle signature data updates
useEffect(() => {
let newSignatureData: string | undefined = undefined;
if (parameters.signatureType === 'image' && imageSignatureData) {
newSignatureData = imageSignatureData;
} else if (parameters.signatureType === 'canvas' && canvasSignatureData) {
newSignatureData = canvasSignatureData;
}
// Only update if the signature data has actually changed
if (parameters.signatureData !== newSignatureData) {
onParameterChange('signatureData', newSignatureData);
}
}, [parameters.signatureType, parameters.signatureData, canvasSignatureData, imageSignatureData]);
// Handle image signature activation - activate when image data syncs with parameters
useEffect(() => {
if (parameters.signatureType === 'image' && imageSignatureData && parameters.signatureData === imageSignatureData && onActivateSignaturePlacement) {
setTimeout(() => {
onActivateSignaturePlacement();
}, 100);
}
}, [parameters.signatureType, parameters.signatureData, imageSignatureData]);
// Draw settings are no longer needed since draw mode is removed
return (
<Stack gap="md">
{/* Signature Type Selection */}
<Tabs
value={parameters.signatureType}
onChange={(value) => onParameterChange('signatureType', value as 'image' | 'text' | 'canvas')}
>
<Tabs.List grow>
<Tabs.Tab value="canvas" style={{ fontSize: '0.8rem' }}>
{t('sign.type.canvas', 'Canvas')}
</Tabs.Tab>
<Tabs.Tab value="image" style={{ fontSize: '0.8rem' }}>
{t('sign.type.image', 'Image')}
</Tabs.Tab>
<Tabs.Tab value="text" style={{ fontSize: '0.8rem' }}>
{t('sign.type.text', 'Text')}
</Tabs.Tab>
</Tabs.List>
</Tabs>
{/* Drawing Controls */}
<DrawingControls
onUndo={onUndo}
onRedo={onRedo}
onPlaceSignature={() => {
if (onActivateSignaturePlacement) {
onActivateSignaturePlacement();
}
}}
hasSignatureData={!!(canvasSignatureData || imageSignatureData || (parameters.signerName && parameters.signerName.trim() !== ''))}
disabled={disabled}
showPlaceButton={false}
placeButtonText="Update and Place"
/>
{/* Signature Creation based on type */}
{parameters.signatureType === 'canvas' && (
<DrawingCanvas
selectedColor={selectedColor}
penSize={penSize}
penSizeInput={penSizeInput}
onColorSwatchClick={() => setIsColorPickerOpen(true)}
onPenSizeChange={setPenSize}
onPenSizeInputChange={setPenSizeInput}
onSignatureDataChange={handleCanvasSignatureChange}
disabled={disabled}
additionalButtons={
<Button
onClick={() => {
if (onActivateSignaturePlacement) {
onActivateSignaturePlacement();
}
}}
color="blue"
variant="filled"
disabled={disabled || !canvasSignatureData}
>
Update and Place
</Button>
}
/>
)}
{parameters.signatureType === 'image' && (
<ImageUploader
onImageChange={handleImageChange}
disabled={disabled}
/>
)}
{parameters.signatureType === 'text' && (
<TextInputWithFont
text={parameters.signerName || ''}
onTextChange={(text) => onParameterChange('signerName', text)}
fontSize={parameters.fontSize || 16}
onFontSizeChange={(size) => onParameterChange('fontSize', size)}
fontFamily={parameters.fontFamily || 'Helvetica'}
onFontFamilyChange={(family) => onParameterChange('fontFamily', family)}
disabled={disabled}
/>
)}
{/* Instructions for placing signature */}
<Alert color="blue" title={t('sign.instructions.title', 'How to add signature')}>
<Text size="sm">
{parameters.signatureType === 'canvas' && 'After drawing your signature in the canvas above, click "Update and Place" then click anywhere on the PDF to place it.'}
{parameters.signatureType === 'image' && 'After uploading your signature image above, click anywhere on the PDF to place it.'}
{parameters.signatureType === 'text' && 'After entering your name above, click anywhere on the PDF to place your signature.'}
</Text>
</Alert>
{/* Color Picker Modal */}
<ColorPicker
isOpen={isColorPickerOpen}
onClose={() => setIsColorPickerOpen(false)}
selectedColor={selectedColor}
onColorChange={setSelectedColor}
/>
{/* Apply Signatures Button */}
{onSave && (
<Button
onClick={onSave}
color="blue"
variant="filled"
fullWidth
>
{t('sign.applySignatures', 'Apply Signatures')}
</Button>
)}
{/* Suggested Tools Section */}
<SuggestedToolsSection />
</Stack>
);
};
export default SignSettings;
@@ -158,6 +158,6 @@ const SplitSettings = ({
{parameters.method === SPLIT_METHODS.BY_PAGE_DIVIDER && renderByPageDividerForm()}
</Stack>
);
}
};
export default SplitSettings;
@@ -13,10 +13,12 @@ interface ToolButtonProps {
onSelect: (id: string) => void;
rounded?: boolean;
disableNavigation?: boolean;
matchedSynonym?: string;
}
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect, disableNavigation = false }) => {
const isUnavailable = !tool.component && !tool.link;
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect, disableNavigation = false, matchedSynonym }) => {
// Special case: read and multiTool are navigational tools that are always available
const isUnavailable = !tool.component && !tool.link && id !== 'read' && id !== 'multiTool';
const { getToolNavigation } = useToolNavigation();
const handleClick = (id: string) => {
@@ -40,13 +42,27 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
const buttonContent = (
<>
<div className="tool-button-icon" style={{ color: "var(--tools-text-and-icon-color)", marginRight: "0.5rem", transform: "scale(0.8)", transformOrigin: "center", opacity: isUnavailable ? 0.25 : 1 }}>{tool.icon}</div>
<FitText
text={tool.name}
lines={1}
minimumFontScale={0.8}
as="span"
style={{ display: 'inline-block', maxWidth: '100%', opacity: isUnavailable ? 0.25 : 1 }}
/>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', flex: 1, overflow: 'visible' }}>
<FitText
text={tool.name}
lines={1}
minimumFontScale={0.8}
as="span"
style={{ display: 'inline-block', maxWidth: '100%', opacity: isUnavailable ? 0.25 : 1 }}
/>
{matchedSynonym && (
<span style={{
fontSize: '0.75rem',
color: 'var(--mantine-color-dimmed)',
opacity: isUnavailable ? 0.25 : 1,
marginTop: '1px',
overflow: 'visible',
whiteSpace: 'nowrap'
}}>
{matchedSynonym}
</span>
)}
</div>
</>
);
@@ -66,7 +82,10 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
fullWidth
justify="flex-start"
className="tool-button"
styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)" } }}
styles={{
root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)", overflow: 'visible' },
label: { overflow: 'visible' }
}}
>
{buttonContent}
</Button>
@@ -84,7 +103,10 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
fullWidth
justify="flex-start"
className="tool-button"
styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)" } }}
styles={{
root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)", overflow: 'visible' },
label: { overflow: 'visible' }
}}
>
{buttonContent}
</Button>
@@ -99,7 +121,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
justify="flex-start"
className="tool-button"
aria-disabled={isUnavailable}
styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)", cursor: isUnavailable ? 'not-allowed' : undefined } }}
styles={{ root: { borderRadius: 0, color: "var(--tools-text-and-icon-color)", cursor: isUnavailable ? 'not-allowed' : undefined, overflow: 'visible' }, label: { overflow: 'visible' } }}
>
{buttonContent}
</Button>

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