Compare commits

...
Author SHA1 Message Date
Reece f1901a2e56 revert lint 2025-10-27 18:23:27 +00:00
Reece 09b0fbefcd Hide file names in posthog 2025-10-27 15:47:38 +00:00
Reece 3497ccd7bd remove page break settings modal 2025-10-27 12:45:31 +00:00
Reece 5e27dc88f8 retain interleaving 2025-10-27 12:37:00 +00:00
Reece b276eb5b68 Lint 2025-10-27 11:32:31 +00:00
Reece aec1f97ff8 - 2025-10-25 14:19:32 +01:00
Reece fbe2dc2958 Fixed file reordering placeholder 2025-10-25 13:06:10 +01:00
Reece aaae81c68e - 2025-10-24 15:57:30 +01:00
Reece 3aa77819f2 - 2025-10-24 15:54:30 +01:00
Reece 28dab07870 - 2025-10-24 15:51:37 +01:00
Reece ed6199de61 lint and revert onboarding 2025-10-24 15:51:29 +01:00
Reece 4d59ebfb2a fixed drag and drop when some files aren't selected in context 2025-10-24 15:27:44 +01:00
Reece ea4f37cccf Merge history change 2025-10-24 15:06:21 +01:00
Reece c25131ae9b lint 2025-10-24 14:48:14 +01:00
Reece 25df9410cd Merge branch 'V2' of https://github.com/Stirling-Tools/Stirling-PDF into feature/v2/selected-pageeditor 2025-10-24 14:33:27 +01:00
Reece 494f92421f Enhance drag-and-drop functionality with new drop hint resolution and target index calculation; refactor file color mapping in PageEditor and implement dropdown state management for improved file handling. 2025-10-24 13:28:50 +01:00
Reece eef5dce849 Drag and drop improvements basic box select 2025-10-24 11:33:31 +01:00
848ff9688b V2: Login Feature (#4701)
This PR migrates the login features from V1 into V2.

  ---
- Login via username/password
- SSO login (Google & GitHub)
-- Fixed issue where users authenticating via SSO (OAuth2/SAML2) were
identified by configurable username attributes (email,
preferred_username, etc.), causing:
  - Duplicate accounts when username attributes changed
  - Authentication failures when claim/NameID configuration changed
  - Data redundancy from same user having multiple accounts
- Added `sso_provider_id` column to store provider's unique identifier
(OIDC sub claim / SAML2 NameID)
- Added `sso_provider` column to store provider name (e.g., "google",
"github", "saml2")
  - User.java:65-69

  Backend Changes:
- Updated UserRepository with findBySsoProviderAndSsoProviderId() method
(UserRepository.java:25)
- Modified UserService.processSSOPostLogin() to implement lookup
priority:
    a. Find by (`ssoProvider`, `ssoProviderId`) first
    b. Fallback to username for backward compatibility
c. Automatically migrate existing users by adding provider IDs
(UserService.java:64-107)
- Updated saveUserCore() to accept and store SSO provider details
(UserService.java:506-566)

  OAuth2 Integration:
- CustomOAuth2UserService: Extracts OIDC sub claim and registration ID
(CustomOAuth2UserService.java:49-59)
- CustomOAuth2AuthenticationSuccessHandler: Passes provider info to
processSSOPostLogin()
(CustomOAuth2AuthenticationSuccessHandler.java:95-108)

  SAML2 Integration:
- CustomSaml2AuthenticationSuccessHandler: Extracts NameID from SAML2
assertion (CustomSaml2AuthenticationSuccessHandler.java:120-133)

  ---
- Configurable Rate Limiting

  Changes:
- Added RateLimit configuration class to ApplicationProperties.Security
(ApplicationProperties.java:314-317)
- Made reset schedule configurable: security.rate-limit.reset-schedule
(default: "0 0 0 * * MON")
- Made max requests configurable: security.rate-limit.max-requests
(default: 1000)
- Updated RateLimitResetScheduler to use @Scheduled(cron =
"${security.rate-limit.reset-schedule:0 0 0 * * MON}")
(RateLimitResetScheduler.java:16)
- Updated SecurityConfiguration.rateLimitingFilter() to use configured
value (SecurityConfiguration.java:377)

  ---
- Enable access without security features

  Backend:
- Added /api/v1/config to permitAll endpoints
(SecurityConfiguration.java:261)
- Config endpoint already returns enableLogin status
(ConfigController.java:60)

  Frontend:
- AuthProvider now checks enableLogin before attempting JWT validation
(UseSession.tsx:98-112)
- If enableLogin=false, skips authentication entirely and sets
session=null
- Landing component bypasses auth check when enableLogin=false
(Landing.tsx:42-46)
- Added createAnonymousUser() and createAnonymousSession() utilities
(springAuthClient.ts:440-464)

Closes #3046
---

## 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)

- [x] 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.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ludy <Ludy87@users.noreply.github.com>
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Co-authored-by: Ethan <ethan@MacBook-Pro.local>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2025-10-24 10:49:52 +01:00
Reece ddefe81082 Enhance DragDropGrid and PageEditor with improved undo manager functionality and scroll handling during drag operations 2025-10-23 20:46:58 +01:00
Reece be037b727f File reorder logic 2025-10-23 18:15:37 +01:00
Reece 7a56f0504e Refactor file handling to support StirlingFileStubs and improve drag-and-drop functionality 2025-10-21 17:35:55 +01:00
James Brunton c9eee00d66 Refactor to fix circular imports (#4700)
# Description of Changes
Refactors code to avoid circular imports everywhere and adds linting for
circular imports to ensure it doesn't happen again. Most changes are
around the tool registry, making it a provider, and splitting into tool
types to make it easier for things like Automate to only have access to
tools excluding itself.
2025-10-21 14:53:18 +01:00
Reece f7c9855489 glow scaling 2025-10-20 21:45:00 +01:00
Reece 36a358f907 Visual tweaks 2025-10-20 21:25:44 +01:00
Reece 0bcb1810d6 tweak 2025-10-20 21:08:18 +01:00
Reece aee535214d Pretty lights 2025-10-20 20:55:45 +01:00
Reece 6d3154a7ae Update top bar controls visually 2025-10-20 20:44:48 +01:00
Reece 658ce2dab9 add file 2025-10-20 18:45:14 +01:00
Reece 15df5cf168 - 2025-10-20 18:05:55 +01:00
Reece 23d7f38100 lint 2025-10-20 17:24:16 +01:00
Reece 472fc2939e lint 2 2025-10-20 15:56:54 +01:00
Reece Browne a21047e8b0 Merge branch 'V2' into feature/v2/selected-pageeditor 2025-10-20 15:52:23 +01:00
Reece 8ee03fa1c6 Lint 2025-10-20 15:50:14 +01:00
James Brunton 3e23dc59b6 Add onboarding flow using Reactour (#4635)
# Description of Changes
Add onboarding flow
2025-10-20 15:07:40 +01:00
Reece a22913e1e4 page editor fixes post merge 2025-10-20 14:16:41 +01:00
Reece b3c0c69a7c Merge branch 'V2' of https://github.com/Stirling-Tools/Stirling-PDF into feature/v2/selected-pageeditor 2025-10-20 13:42:08 +01:00
Reece 2289080f9c remove buttons 2025-10-17 16:55:29 +01:00
James Brunton 3e6236d957 Add test for missing translations (#4696)
# Description of Changes
Adds a test to scan the code for any static translation keys which are
not present in the GB translations file. The test won't catch every
missing translation present in our code, but it should greatly help us
keep the translations file up to date.
2025-10-17 16:50:04 +01:00
Reece a5ec62fa08 Performance improvements 2025-10-17 15:24:05 +01:00
Reece e7f7b7e201 improved 2025-10-17 14:15:21 +01:00
EthanHealy01 5354f08766 Chore/v2/right rail cleanup (#4689)
# Description of Changes

- Migrated all dynamic right-rail controls into their owning views (file
editor, page editor, viewer) using dedicated helper hooks, so the rail
is
    purely a renderer now.
- Tightened layout/animation: dynamic buttons grow in with a top-down
reveal
    and center correctly.

---

## 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-10-16 15:58:20 +01:00
EthanHealy01andAnthony Stirling a8573c99b7 V2 Validate PDF Signature tool (#4679)
# 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.

---------

Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2025-10-16 13:45:59 +01:00
Reece 74e8388bce Working mostly 2025-10-15 21:33:54 +01:00
Reece e7c6db082c Rejig arrays 2025-10-15 16:31:30 +01:00
ConnorYohandConnor Yoh f6a7b983a0 Hotfix removed UrlSync (#4685)
Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
2025-10-15 15:30:51 +00:00
EthanHealy01 949a16e6eb quick Z index fix for file modal (#4686)
# Description of Changes

<img width="1540" height="788" alt="Screenshot 2025-10-15 at 3 38 45 PM"
src="https://github.com/user-attachments/assets/8e2a486c-1955-4a7b-832f-148883611325"
/>

---

## 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-10-15 16:03:52 +01:00
ConnorYohandConnor Yoh 43887c8179 Fix/V2/unzip_images (#4647)
Method Usage by Context

| Context | Method Used | Respects Preferences | HTML Detection |

|------------------------------|-------------------------------------------------------|------------------------|----------------|
| Tools (via useToolResources) | extractZipFiles() →
extractWithPreferences() |  Yes |  Yes |
| Automation | extractAutomationZipFiles() → extractAllFiles() |  No
(always extracts) |  Yes |
| Manual Unzip | extractAndStoreFilesWithHistory() → extractAllFiles() |
 No (always extracts) |  Yes |
| Auto-Upload | extractAllFiles() directly |  No (always extracts) | 
Yes |

  Detailed Behavior Matrix

| Context | HTML Files | Auto-Unzip OFF | Within Limit | Exceeds Limit |
Notes |

|--------------------------|-------------|----------------|--------------|---------------|----------------------------------------|
| Tools (useToolResources) | Keep zipped | Keep zipped | Extract all |
Keep zipped | Respects user preferences |
| Automation | Keep zipped | Extract all | Extract all | Extract all |
Ignores preferences (automation needs) |
| Manual Unzip | Keep zipped | Extract all | Extract all | Extract all |
User explicitly unzipping |
| Auto-Upload | Keep zipped | Extract all | Extract all | Extract all |
User dropped files |

  Simplified Decision Flow

  ZIP File Received
      │
      ├─ Contains HTML? → Keep as ZIP (all contexts)
      │
      └─ No HTML
          │
          ├─ Tools Context
          │   ├─ Auto-unzip OFF? → Keep as ZIP
          │   └─ Auto-unzip ON
          │       ├─ File count ≤ limit? → Extract all
          │       └─ File count > limit? → Keep as ZIP
          │
          └─ Automation/Manual/Auto-Upload
              └─ Extract all (ignore preferences)

  Key Changes from Previous Version
  
| Entry Point | Code Path | skipAutoUnzip | Respects Preferences? | HTML
Detection? | Extraction Behavior |

|-----------------------------------------------|----------------------------------------------------------------------------------------|---------------|-----------------------|---------------------------|-------------------------------------------------------------------------|
| Direct File Upload (FileEditor, LandingPage) |
FileContext.addRawFiles() → fileActions.addFiles() | True |  No |  Yes
| Always extract (except HTML ZIPs) |
| Tool Outputs (Split, Merge, etc.) | useToolResources.extractZipFiles()
→ zipFileService.extractWithPreferences() | false |  Yes |  Yes |
Conditional: Only if autoUnzip=true AND file count ≤ autoUnzipFileLimit
|
| Load from Storage (FileManager) | fileActions.addStirlingFileStubs() |
N/A | N/A | N/A | No extraction - files already processed |
| Automation Outputs |
AutomationFileProcessor.extractAutomationZipFiles() →
zipFileService.extractAllFiles() | N/A |  No |  Yes | Always extract
(except HTML ZIPs) |
| Manual Unzip Action (FileEditor context menu) |
zipFileService.extractAndStoreFilesWithHistory() → extractAllFiles() |
N/A |  No |  Yes (blocks extraction) | Always extract (except HTML
ZIPs) - explicit user action |

---------

Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
2025-10-15 14:17:44 +00:00
James Brunton bcd7762594 Replace kebab menu in file editor with on hover menu (#4624)
Replace kebab menu in file editor with on hover menu by refactoring page
editor's menu into a new component. In mobile sizes, the hover menus are
always visible.
2025-10-15 14:05:32 +01:00
James Brunton 28e45917a2 Refactor user preferences (#4667)
# Description of Changes
Refactor user preferences to all be in one service and all stored in
localStorage instead of indexeddb. This allows simpler & quicker
accessing of them, and ensures that they're all neatly stored in one
consistent place instead of spread out over local storage.
2025-10-15 11:53:00 +01:00
Reece 05a7161412 Structural tweaks 2025-10-15 00:01:30 +01:00
Reece 39267e795c Reworked page editor - dirty commit 2025-10-14 12:41:50 +01:00
af57ae02dd Feature/v2/viewer tabs (#4646)
# 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.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: James Brunton <james@stirlingpdf.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-10-13 15:03:07 +01:00
ConnorYohandConnor Yoh 3cebcc70af Ignore FIle name and preview in mobile layout of file manager (#4665)
Ignore filename and preview in compact view of filemanager

Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
2025-10-13 12:13:34 +01:00
Reece 6acce968a5 fix 2 2025-10-10 19:32:41 +01:00
Reece 0722ecc6c4 fix 2025-10-10 19:27:15 +01:00
Reece 3597a8b7bd Initial set up 2025-10-10 19:16:04 +01:00
Reece c260394b95 Cleanup 2025-10-10 17:15:07 +01:00
Reece 93fcfb280a Remove logs tweak visuals, use fit text component 2025-10-10 17:09:36 +01:00
Reece 69cb8e7aec Fix signwith tab based system 2025-10-10 16:54:05 +01:00
Reece 8e8e06628e Nav based file select 2025-10-10 15:57:41 +01:00
Reece 5d3710260f Lint 2025-10-10 13:37:52 +01:00
Reece ad8789d82a remove file that came from nowhere 2025-10-10 13:35:18 +01:00
Reece 749966a197 Remove mantine theme 2025-10-10 13:30:15 +01:00
Reece d9e429aa3a Merge branch 'V2' of https://github.com/Stirling-Tools/Stirling-PDF into feature/V2/ViewerTabs 2025-10-10 13:00:56 +01:00
Reece ad0b6cf2d6 Viewer tabs, embed update and layout fixes 2025-10-10 12:55:03 +01:00
Reece b63f2c16a2 Remove unused legacy text signing Linting errors 2025-10-08 15:12:39 +01:00
Reece edcc788d1a Merge branch 'feature/v2/improve-sign' of https://github.com/Stirling-Tools/Stirling-PDF into feature/v2/improve-sign 2025-10-08 15:02:39 +01:00
Reece 5b47ab5bbf Remove debug logs 2025-10-08 15:02:33 +01:00
Reece BrowneandCopilot fdba336c05 Update frontend/src/components/annotation/shared/DrawingCanvas.tsx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-08 14:57:29 +01:00
Reece 5db6b85fb9 Merge branch 'feature/v2/improve-sign' of https://github.com/Stirling-Tools/Stirling-PDF into feature/v2/improve-sign 2025-10-08 14:56:10 +01:00
Reece 70d941a400 translations 2025-10-08 14:55:43 +01:00
Reece BrowneandCopilot 13e88943b7 Update frontend/src/components/tools/sign/SignSettings.tsx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-08 14:55:04 +01:00
Reece BrowneandCopilot 339e5cfb65 Update frontend/src/contexts/ViewerContext.tsx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-08 14:54:31 +01:00
Reece 10944d9d57 Remove debug logging 2025-10-08 14:45:39 +01:00
Reece 0c9f460fb6 Remove arbitrary timers 2025-10-08 14:02:43 +01:00
Reece fa6e01b46e Clean up 2025-10-08 12:26:34 +01:00
Reece 23f85d7267 tweaks 2025-10-07 22:40:58 +01:00
Reece f6290c0238 - Refactored signature saving process 2025-10-07 21:52:40 +01:00
Reece 991be9ffa2 Add text color and font size options to signature settings and API 2025-10-07 21:38:07 +01:00
Reece 07bf79f3ee Improved canvas mode with signaturepad.js 2025-10-07 14:54:14 +01:00
Reece 3a0acd0a21 Single canvas 2025-10-07 12:56:12 +01:00
ReeceandClaude fff637286f Clean up annotation layer and signature API
- Remove duplicate imports in LocalEmbedPDF
- Remove duplicate setAnnotations state declaration
- Rename enableSignature prop to enableAnnotations for consistency
- Remove debug console.log statements from SignatureAPIBridge
- Remove async image preloading wrapper (was debugging code)
- Clean up formatting

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-07 12:08:32 +01:00
Reece 8f94c8f57e Merge branch 'V2' of https://github.com/Stirling-Tools/Stirling-PDF into feature/v2/improve-sign 2025-10-06 22:25:30 +01:00
Reece 708a296f8d Auto update canvas signature 2025-09-26 19:14:40 +01:00
Reece b486d1270e Fix flicker on apply 2025-09-26 19:03:24 +01:00
Reece 80faf0bc1e - 2025-09-26 18:55:09 +01:00
Reece 6555a9554a Fix even more linting errors (Thanks James) 2025-09-26 18:53:16 +01:00
Reece fdee719c89 Merge branch 'feature/v2/sign' of https://github.com/Stirling-Tools/Stirling-PDF into feature/v2/sign 2025-09-26 18:14:42 +01:00
Reece 1be48c276b fix text infinite loop 2025-09-26 18:12:13 +01:00
Reece 2b6b7a8e1d better error handling and killing logs 2025-09-26 18:04:01 +01:00
Reece BrowneandCopilot fd9fb9b972 Update frontend/src/hooks/tools/sign/useSignParameters.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-26 17:58:52 +01:00
Reece d8d6197008 fix page count issue 2025-09-26 17:48:41 +01:00
Reece 1edd133e09 license checker use commonJS 2025-09-26 17:31:44 +01:00
Reece 8685bf2a7c gap 2025-09-26 17:26:19 +01:00
Reece 36475069de lint fix 2025-09-26 17:23:16 +01:00
Reece 3aa8572c9e Fix suggestions 2025-09-26 17:16:17 +01:00
Reece 2e2d8477b9 Clean up 2025-09-26 17:01:06 +01:00
Reece BrowneandCopilot 90880eddf9 Update docker/frontend/nginx.conf
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-26 16:51:19 +01:00
Reece Browne 31fd6886dc Merge branch 'V2' into feature/v2/sign 2025-09-26 16:40:34 +01:00
Reece 3fdbf425b4 Fix lintineg errors 2025-09-26 16:39:38 +01:00
Reece 50e60d4972 Simple export block 2025-09-26 16:27:52 +01:00
Reece a22330ebf4 Only flatten current annotations 2025-09-26 16:09:20 +01:00
Reece 172f622c5f Merge branch 'feature/v2/sign' of https://github.com/Stirling-Tools/Stirling-PDF into feature/v2/sign 2025-09-26 15:10:54 +01:00
Reece cfd00b2c71 Render signature to pdf 2025-09-26 15:10:47 +01:00
Reece Browne d82b958d9f Merge branch 'V2' into feature/v2/sign 2025-09-26 12:53:54 +01:00
Reece c94ee388fc Restructure and bug fix 2025-09-26 12:47:32 +01:00
Reece aa5333dcd9 Change to button based placement to avoid performance issue on canvas 2025-09-26 03:23:59 +01:00
Reece a8265efff4 Improved performance 2025-09-26 03:19:05 +01:00
Reece b9b425aba0 Fix undo/redo 2025-09-26 02:18:47 +01:00
Reece 51caad636c Reduce logs 2025-09-26 01:51:17 +01:00
Reece 023fd43b72 Save file 2025-09-26 01:49:33 +01:00
Reece a8a0808274 history tweaks 2025-09-25 09:46:20 +01:00
Reece 3d2607f72a fixes 2025-09-24 19:01:36 +01:00
Reece f9542a9257 Merge branch 'feature/v2/exportpdf' into feature/v2/sign 2025-09-24 18:35:16 +01:00
Reece 963787316a Export with embedpdf 2025-09-24 17:42:58 +01:00
Reece Browne a12e457577 Add undo/redo functionality and refactor signature settings UI
- Introduced HistoryAPIBridge for managing undo/redo actions.
- Updated SignSettings component to include undo/redo buttons.
- Refactored signature type selection to use Tabs for better UI.
- Enhanced SignatureAPIBridge to store image data for annotations.
- Integrated history management into SignatureContext for state handling.
2025-09-24 14:58:10 +01:00
Reece Browne bac61c7e9e Delete signature 2025-09-23 18:16:21 +01:00
Reece Browne fc2f34ee15 fix add image 2025-09-23 17:18:39 +01:00
Reece Browne d9798badae Fix sidebar refresh. Updated UI 2025-09-23 14:06:41 +01:00
Reece Browne efc0c1aab3 text and improved drawing 2025-09-23 12:24:58 +01:00
Reece Browne 10672403c9 Colours on document draw + translations 2025-09-22 14:14:35 +01:00
Reece Browne 32fed96aa7 Canvas and dosument draw split, drawing improvements 2025-09-22 14:03:49 +01:00
Reece Browne a70472b172 Initial set up 2025-09-20 01:59:04 +01:00
Reece Browne 3b87ca0c3c Merge branch 'feature/v2/embed-pdf' of https://github.com/Stirling-Tools/Stirling-PDF into feature/v2/embed-pdf 2025-09-19 11:38:58 +01:00
Reece Browne 0e1da982b6 Fix vite 2025-09-19 11:38:53 +01:00
Reece Browne 6172351eed Merge branch 'V2' into feature/v2/embed-pdf 2025-09-19 11:23:28 +01:00
Reece Browne 1174b6a4da Merge branch 'feature/v2/embed-pdf' of https://github.com/Stirling-Tools/Stirling-PDF into feature/v2/embed-pdf 2025-09-19 11:16:22 +01:00
Reece Browne a970c44d03 improvements 2025-09-19 11:14:58 +01:00
Reece Browne b574cef54a improvements 2025-09-19 10:48:29 +01:00
Reece Browne 21a2433dd8 Remove marginTop style from Workbench component 2025-09-18 13:14:44 +01:00
Reece Browne 07cc250176 Remove comment regarding EmbedPDF import
Removed comment about dynamic import of EmbedPDF.
2025-09-18 13:12:58 +01:00
Reece Browne dc71b3007b clean up 2025-09-18 12:32:42 +01:00
Reece Browne 1598057ed0 Tweaks 2025-09-18 08:44:57 +01:00
Reece Browne 312fc2d615 Clean up 2025-09-18 02:14:31 +01:00
Reece Browne 72375d89d1 Merge branch 'V2' into feature/v2/embed-pdf 2025-09-18 01:53:59 +01:00
Reece Browne a990ecc02a Merge branch 'V2' into feature/v2/embed-pdf 2025-09-18 01:53:47 +01:00
Reece Browne da6ecc6619 Fix scroll page identification 2025-09-17 14:35:44 +01:00
Reece Browne dac176f0c6 Fix colours 2025-09-17 12:07:44 +01:00
Reece Browne 41e5a7fbd6 Restructure to avoid global variables
fix zoom
2025-09-17 12:00:20 +01:00
Reece Browne b81ed9ec2e Merge branch 'feature/v2/embed-pdf' of https://github.com/Stirling-Tools/Stirling-PDF into feature/v2/embed-pdf 2025-09-16 19:37:50 +01:00
Reece Browne 9b5c50db07 Improved Structure with context at root 2025-09-16 19:36:36 +01:00
James Brunton 81c5d8ff46 Potential fix for mime type issues 2025-09-16 16:06:40 +01:00
James Brunton a67f5199d3 Improvements for scroll gestures 2025-09-16 16:06:27 +01:00
Reece Browne 3755bfde34 Set zoom to 140% 2025-09-15 18:20:11 +01:00
Reece Browne 2834eec3be Merge branch 'feature/v2/embed-pdf' of https://github.com/Stirling-Tools/Stirling-PDF into feature/v2/embed-pdf 2025-09-15 17:31:06 +01:00
Reece Browne d89e1b5b1e Merge branch 'V2' into feature/v2/embed-pdf 2025-09-15 17:27:51 +01:00
Reece Browne 19d7111cab Remove unused code 2025-09-15 17:27:22 +01:00
Reece Browne ca9d7ef465 Remove unused code 2025-09-15 17:03:52 +01:00
Reece Browne fad4f84c9c translations 2025-09-15 16:53:41 +01:00
Reece Browne 35863ac610 remove select mode 2025-09-15 16:53:32 +01:00
Reece Browne c17dd25069 Rotate 2025-09-15 16:05:19 +01:00
Reece Browne 5d7fb638af Merge branch 'V2' into feature/v2/embed-pdf 2025-09-15 15:31:45 +01:00
Reece Browne 2fb4710dd7 Merge branch 'V2' into feature/v2/embed-pdf 2025-09-15 13:34:00 +01:00
Reece Browne 85a74c1d46 Merge branch 'feature/v2/embed-pdf' of https://github.com/Stirling-Tools/Stirling-PDF into feature/v2/embed-pdf 2025-09-15 13:33:45 +01:00
Reece Browne 21a93d6cac Context based right rail controls for viewer 2025-09-15 13:33:39 +01:00
Reece BrowneandCopilot 9599bca8a9 Update frontend/src/components/viewer/ThumbnailSidebar.tsx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-15 12:37:07 +01:00
Reece Browne 1709ca9049 Rems 2025-09-12 16:38:29 +01:00
Reece Browne 18e4e03220 rename APIBridge 2025-09-12 16:26:05 +01:00
Reece Browne 9901771572 improve search 2025-09-12 16:19:07 +01:00
Reece Browne 514956570c pan state improvements 2025-09-12 15:06:06 +01:00
Reece Browne 423617db52 thumbnail sidebar 2025-09-12 14:21:31 +01:00
Reece Browne 143f0c5031 search pdf 2025-09-12 01:56:51 +01:00
Reece Browne 368e9801a1 Zoom with wheel and +/- 2025-09-12 00:35:27 +01:00
Reece Browne afc9ca5858 spread/multipage 2025-09-11 23:52:38 +01:00
Reece Browne 8815575124 pan 2025-09-11 22:51:10 +01:00
Reece Browne fb9b01f53b improved scaling and fix grey void 2025-09-11 20:07:43 +01:00
Reece Browne 93607937f6 selection also 2025-09-11 19:38:04 +01:00
Reece Browne 687ab39286 Text selection 2025-09-11 19:36:44 +01:00
Reece Browne 83a3222cf6 Set up 2025-09-11 19:08:44 +01:00
236 changed files with 18462 additions and 2755 deletions
@@ -120,6 +120,7 @@ public class ApplicationProperties {
private String loginMethod = "all";
private String customGlobalAPIKey;
private Jwt jwt = new Jwt();
private Validation validation = new Validation();
public Boolean isAltLogin() {
return saml2.getEnabled() || oauth2.getEnabled();
@@ -306,7 +307,41 @@ public class ApplicationProperties {
private boolean enableKeyRotation = false;
private boolean enableKeyCleanup = true;
private int keyRetentionDays = 7;
private boolean secureCookie;
}
@Data
public static class Validation {
private Trust trust = new Trust();
private boolean allowAIA = false;
private Aatl aatl = new Aatl();
private Eutl eutl = new Eutl();
private Revocation revocation = new Revocation();
@Data
public static class Trust {
private boolean serverAsAnchor = true;
private boolean useSystemTrust = false;
private boolean useMozillaBundle = false;
private boolean useAATL = false;
private boolean useEUTL = false;
}
@Data
public static class Aatl {
private String url = "https://trustlist.adobe.com/tl.pdf";
}
@Data
public static class Eutl {
private String lotlUrl = "https://ec.europa.eu/tools/lotl/eu-lotl.xml";
private boolean acceptTransitional = false;
}
@Data
public static class Revocation {
private String mode = "none";
private boolean hardFail = false;
}
}
}
@@ -328,6 +363,7 @@ public class ApplicationProperties {
private CustomPaths customPaths = new CustomPaths();
private String fileUploadLimit;
private TempFileManagement tempFileManagement = new TempFileManagement();
private List<String> corsAllowedOrigins = new ArrayList<>();
public boolean isAnalyticsEnabled() {
return this.getEnableAnalytics() != null && this.getEnableAnalytics();
@@ -1,22 +1,49 @@
package stirling.software.SPDF.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import lombok.RequiredArgsConstructor;
import stirling.software.common.model.ApplicationProperties;
@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {
private final EndpointInterceptor endpointInterceptor;
private final ApplicationProperties applicationProperties;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(endpointInterceptor);
}
@Override
public void addCorsMappings(CorsRegistry registry) {
// Only configure CORS if allowed origins are specified
if (applicationProperties.getSystem() != null
&& applicationProperties.getSystem().getCorsAllowedOrigins() != null
&& !applicationProperties.getSystem().getCorsAllowedOrigins().isEmpty()) {
String[] allowedOrigins =
applicationProperties
.getSystem()
.getCorsAllowedOrigins()
.toArray(new String[0]);
registry.addMapping("/**")
.allowedOrigins(allowedOrigins)
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
// If no origins are configured, CORS is not enabled (secure by default)
}
// @Override
// public void addResourceHandlers(ResourceHandlerRegistry registry) {
// // Handler for external static resources - DISABLED in backend-only mode
@@ -5,9 +5,11 @@ import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.PKIXCertPathBuilderResult;
import java.security.cert.X509Certificate;
import java.security.interfaces.RSAPublicKey;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Date;
import java.util.List;
@@ -32,6 +34,7 @@ import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.swagger.JsonDataResponse;
import stirling.software.SPDF.model.api.security.SignatureValidationRequest;
@@ -42,6 +45,7 @@ import stirling.software.common.annotations.api.SecurityApi;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
@Slf4j
@SecurityApi
@RequiredArgsConstructor
public class ValidateSignatureController {
@@ -65,8 +69,9 @@ public class ValidateSignatureController {
@Operation(
summary = "Validate PDF Digital Signature",
description =
"Validates the digital signatures in a PDF file against default or custom"
+ " certificates. Input:PDF Output:JSON Type:SISO")
"Validates the digital signatures in a PDF file using PKIX path building"
+ " and time-of-signing semantics. Supports custom trust anchors."
+ " Input:PDF Output:JSON Type:SISO")
@AutoJobPostMapping(
value = "/validate-signature",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@@ -74,12 +79,12 @@ public class ValidateSignatureController {
@ModelAttribute SignatureValidationRequest request) throws IOException {
List<SignatureValidationResult> results = new ArrayList<>();
MultipartFile file = request.getFileInput();
MultipartFile certFile = request.getCertFile();
// Load custom certificate if provided
X509Certificate customCert = null;
if (certFile != null && !certFile.isEmpty()) {
try (ByteArrayInputStream certStream = new ByteArrayInputStream(certFile.getBytes())) {
if (request.getCertFile() != null && !request.getCertFile().isEmpty()) {
try (ByteArrayInputStream certStream =
new ByteArrayInputStream(request.getCertFile().getBytes())) {
CertificateFactory cf = CertificateFactory.getInstance("X.509");
customCert = (X509Certificate) cf.generateCertificate(certStream);
} catch (CertificateException e) {
@@ -108,67 +113,150 @@ public class ValidateSignatureController {
Store<X509CertificateHolder> certStore = signedData.getCertificates();
SignerInformationStore signerStore = signedData.getSignerInfos();
for (SignerInformation signer : signerStore.getSigners()) {
for (SignerInformation signerInfo : signerStore.getSigners()) {
X509CertificateHolder certHolder =
(X509CertificateHolder)
certStore.getMatches(signer.getSID()).iterator().next();
X509Certificate cert =
certStore.getMatches(signerInfo.getSID()).iterator().next();
X509Certificate signerCert =
new JcaX509CertificateConverter().getCertificate(certHolder);
boolean isValid =
signer.verify(new JcaSimpleSignerInfoVerifierBuilder().build(cert));
result.setValid(isValid);
// Extract intermediate certificates from CMS
Collection<X509Certificate> intermediates =
certValidationService.extractIntermediateCertificates(
certStore, signerCert);
// Additional validations
result.setChainValid(
customCert != null
? certValidationService
.validateCertificateChainWithCustomCert(
cert, customCert)
: certValidationService.validateCertificateChain(cert));
// Log what we found
log.debug(
"Found {} intermediate certificates in CMS signature",
intermediates.size());
for (X509Certificate inter : intermediates) {
log.debug(
" → Intermediate: {}",
inter.getSubjectX500Principal().getName());
log.debug(
" Issuer DN: {}", inter.getIssuerX500Principal().getName());
}
result.setTrustValid(
customCert != null
? certValidationService.validateTrustWithCustomCert(
cert, customCert)
: certValidationService.validateTrustStore(cert));
// Determine validation time (TSA timestamp or signingTime, or current)
CertificateValidationService.ValidationTime validationTimeResult =
certValidationService.extractValidationTime(signerInfo);
Date validationTime;
if (validationTimeResult == null) {
validationTime = new Date();
result.setValidationTimeSource("current");
} else {
validationTime = validationTimeResult.date;
result.setValidationTimeSource(validationTimeResult.source);
}
result.setNotRevoked(!certValidationService.isRevoked(cert));
result.setNotExpired(!cert.getNotAfter().before(new Date()));
// Verify cryptographic signature
boolean cmsValid =
signerInfo.verify(
new JcaSimpleSignerInfoVerifierBuilder().build(signerCert));
result.setValid(cmsValid);
// Build and validate certificate path
boolean chainValid = false;
boolean trustValid = false;
try {
PKIXCertPathBuilderResult pathResult =
certValidationService.buildAndValidatePath(
signerCert, intermediates, customCert, validationTime);
chainValid = true;
trustValid = true; // Path ends at trust anchor
result.setCertPathLength(
pathResult.getCertPath().getCertificates().size());
} catch (Exception e) {
String errorMsg = e.getMessage();
result.setChainValidationError(errorMsg);
chainValid = false;
trustValid = false;
// Log the full error for debugging
log.warn(
"Certificate path validation failed for {}: {}",
signerCert.getSubjectX500Principal().getName(),
errorMsg);
log.debug("Full stack trace:", e);
}
result.setChainValid(chainValid);
result.setTrustValid(trustValid);
// Check validity at validation time
boolean outside =
certValidationService.isOutsideValidityPeriod(
signerCert, validationTime);
result.setNotExpired(!outside);
// Revocation status determination
boolean revocationEnabled = certValidationService.isRevocationEnabled();
result.setRevocationChecked(revocationEnabled);
if (!revocationEnabled) {
result.setRevocationStatus("not-checked");
} else if (chainValid && trustValid) {
// Path building succeeded with revocation enabled = no revocation found
result.setRevocationStatus("good");
} else if (result.getChainValidationError() != null
&& result.getChainValidationError()
.toLowerCase()
.contains("revocation")) {
// Check if failure was revocation-related
if (result.getChainValidationError()
.toLowerCase()
.contains("unable to check")) {
result.setRevocationStatus("soft-fail");
} else {
result.setRevocationStatus("revoked");
}
} else {
result.setRevocationStatus("unknown");
}
// Set basic signature info
result.setSignerName(sig.getName());
result.setSignatureDate(sig.getSignDate().getTime().toString());
result.setSignatureDate(
sig.getSignDate() != null
? sig.getSignDate().getTime().toString()
: null);
result.setReason(sig.getReason());
result.setLocation(sig.getLocation());
// Set new certificate details
result.setIssuerDN(cert.getIssuerX500Principal().getName());
result.setSubjectDN(cert.getSubjectX500Principal().getName());
result.setSerialNumber(cert.getSerialNumber().toString(16)); // Hex format
result.setValidFrom(cert.getNotBefore().toString());
result.setValidUntil(cert.getNotAfter().toString());
result.setSignatureAlgorithm(cert.getSigAlgName());
// Set certificate details (from signer cert)
result.setIssuerDN(signerCert.getIssuerX500Principal().getName());
result.setSubjectDN(signerCert.getSubjectX500Principal().getName());
result.setSerialNumber(
signerCert.getSerialNumber().toString(16)); // Hex format
result.setValidFrom(signerCert.getNotBefore().toString());
result.setValidUntil(signerCert.getNotAfter().toString());
result.setSignatureAlgorithm(signerCert.getSigAlgName());
// Get key size (if possible)
try {
result.setKeySize(
((RSAPublicKey) cert.getPublicKey()).getModulus().bitLength());
((RSAPublicKey) signerCert.getPublicKey())
.getModulus()
.bitLength());
} catch (Exception e) {
// If not RSA or error, set to 0
result.setKeySize(0);
}
result.setVersion(String.valueOf(cert.getVersion()));
result.setVersion(String.valueOf(signerCert.getVersion()));
// Set key usage
List<String> keyUsages = new ArrayList<>();
boolean[] keyUsageFlags = cert.getKeyUsage();
boolean[] keyUsageFlags = signerCert.getKeyUsage();
if (keyUsageFlags != null) {
String[] keyUsageLabels = {
"Digital Signature", "Non-Repudiation", "Key Encipherment",
"Data Encipherment", "Key Agreement", "Certificate Signing",
"CRL Signing", "Encipher Only", "Decipher Only"
"Digital Signature",
"Non-Repudiation",
"Key Encipherment",
"Data Encipherment",
"Key Agreement",
"Certificate Signing",
"CRL Signing",
"Encipher Only",
"Decipher Only"
};
for (int i = 0; i < keyUsageFlags.length; i++) {
if (keyUsageFlags[i]) {
@@ -178,10 +266,8 @@ public class ValidateSignatureController {
}
result.setKeyUsages(keyUsages);
// Check if self-signed
result.setSelfSigned(
cert.getSubjectX500Principal()
.equals(cert.getIssuerX500Principal()));
// Check if self-signed (properly)
result.setSelfSigned(certValidationService.isSelfSigned(signerCert));
}
} catch (Exception e) {
result.setValid(false);
@@ -6,17 +6,32 @@ import lombok.Data;
@Data
public class SignatureValidationResult {
// Cryptographic signature validation
private boolean valid;
// Certificate chain validation
private boolean chainValid;
private boolean trustValid;
private String chainValidationError;
private int certPathLength;
// Time validation
private boolean notExpired;
// Revocation validation
private boolean revocationChecked; // true if PKIX revocation was enabled
private String revocationStatus; // "not-checked" | "good" | "revoked" | "soft-fail" | "unknown"
private String validationTimeSource; // "current", "signing-time", or "timestamp"
// Signature metadata
private String signerName;
private String signatureDate;
private String reason;
private String location;
private String errorMessage;
private boolean chainValid;
private boolean trustValid;
private boolean notExpired;
private boolean notRevoked;
// Certificate details
private String issuerDN; // Certificate issuer's Distinguished Name
private String subjectDN; // Certificate subject's Distinguished Name
private String serialNumber; // Certificate serial number
@@ -1,143 +1,863 @@
package stirling.software.SPDF.service;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;
import java.security.GeneralSecurityException;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.MessageDigest;
import java.security.cert.*;
import java.util.*;
import org.springframework.stereotype.Service;
import javax.net.ssl.TrustManager;
import javax.net.ssl.TrustManagerFactory;
import javax.net.ssl.X509TrustManager;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import io.github.pixee.security.BoundedLineReader;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentNameDictionary;
import org.apache.pdfbox.pdmodel.PDEmbeddedFilesNameTreeNode;
import org.apache.pdfbox.pdmodel.common.filespecification.PDComplexFileSpecification;
import org.apache.pdfbox.pdmodel.common.filespecification.PDEmbeddedFile;
import org.bouncycastle.asn1.ASN1Encodable;
import org.bouncycastle.asn1.ASN1GeneralizedTime;
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
import org.bouncycastle.asn1.ASN1UTCTime;
import org.bouncycastle.asn1.cms.CMSAttributes;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cms.CMSSignedData;
import org.bouncycastle.cms.SignerInformation;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.tsp.TimeStampToken;
import org.bouncycastle.util.Store;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.w3c.dom.Document;
import org.w3c.dom.NodeList;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.ServerCertificateServiceInterface;
@Service
@Slf4j
public class CertificateValidationService {
private KeyStore trustStore;
/**
* Result container for validation time extraction Contains both the date and the source of the
* time
*/
public static class ValidationTime {
public final Date date;
public final String source; // "timestamp" | "signing-time" | "current"
public ValidationTime(Date date, String source) {
this.date = date;
this.source = source;
}
}
// Separate trust stores: signing vs TLS
private KeyStore signingTrustAnchors; // AATL/EUTL + server cert for PDF signing
private final ServerCertificateServiceInterface serverCertificateService;
private final ApplicationProperties applicationProperties;
// EUTL (EU Trusted List) constants
private static final String NS_TSL = "http://uri.etsi.org/02231/v2#";
// Qualified CA service types to import as trust anchors (per ETSI TS 119 612)
private static final Set<String> EUTL_SERVICE_TYPES =
new HashSet<>(
Arrays.asList(
"http://uri.etsi.org/TrstSvc/Svctype/CA/QC",
"http://uri.etsi.org/TrstSvc/Svctype/NationalRootCA-QC"));
// Active statuses to accept (per ETSI TS 119 612)
private static final String STATUS_UNDER_SUPERVISION =
"http://uri.etsi.org/TrstSvc/TrustedList/Svcstatus/undersupervision";
private static final String STATUS_ACCREDITED =
"http://uri.etsi.org/TrstSvc/TrustedList/Svcstatus/accredited";
private static final String STATUS_SUPERVISION_IN_CESSATION =
"http://uri.etsi.org/TrstSvc/TrustedList/Svcstatus/supervisionincessation";
static {
if (java.security.Security.getProvider("BC") == null) {
java.security.Security.addProvider(new BouncyCastleProvider());
}
}
public CertificateValidationService(
@Autowired(required = false) ServerCertificateServiceInterface serverCertificateService,
ApplicationProperties applicationProperties) {
this.serverCertificateService = serverCertificateService;
this.applicationProperties = applicationProperties;
}
@PostConstruct
private void initializeTrustStore() throws Exception {
trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
trustStore.load(null, null);
loadMozillaCertificates();
signingTrustAnchors = KeyStore.getInstance(KeyStore.getDefaultType());
signingTrustAnchors.load(null, null);
ApplicationProperties.Security.Validation validation =
applicationProperties.getSecurity().getValidation();
// Enable JDK fetching of OCSP/CRLDP if allowed
if (validation.isAllowAIA()) {
java.security.Security.setProperty("ocsp.enable", "true");
System.setProperty("com.sun.security.enableCRLDP", "true");
System.setProperty("com.sun.security.enableAIAcaIssuers", "true");
log.info("Enabled AIA certificate fetching and revocation checking");
}
// Trust only what we explicitly opt into:
if (validation.getTrust().isServerAsAnchor()) loadServerCertAsAnchor();
if (validation.getTrust().isUseSystemTrust()) loadJavaSystemTrustStore();
if (validation.getTrust().isUseMozillaBundle()) loadBundledMozillaCACerts();
if (validation.getTrust().isUseAATL()) loadAATLCertificates();
if (validation.getTrust().isUseEUTL()) loadEUTLCertificates();
}
private void loadMozillaCertificates() throws Exception {
try (InputStream is = getClass().getResourceAsStream("/certdata.txt")) {
BufferedReader reader = new BufferedReader(new InputStreamReader(is));
String line;
StringBuilder certData = new StringBuilder();
boolean inCert = false;
int certCount = 0;
/**
* Core entry-point: build a valid PKIX path from signerCert using provided intermediates
*
* @param signerCert The signer certificate
* @param intermediates Collection of intermediate certificates from CMS
* @param customTrustAnchor Optional custom root/intermediate certificate
* @param validationTime Time to validate at (signing time or current)
* @return PKIXCertPathBuilderResult containing validated path
* @throws GeneralSecurityException if path building/validation fails
*/
public PKIXCertPathBuilderResult buildAndValidatePath(
X509Certificate signerCert,
Collection<X509Certificate> intermediates,
X509Certificate customTrustAnchor,
Date validationTime)
throws GeneralSecurityException {
while ((line = BoundedLineReader.readLine(reader, 5_000_000)) != null) {
if (line.startsWith("CKA_VALUE MULTILINE_OCTAL")) {
inCert = true;
certData = new StringBuilder();
continue;
// Build trust anchors
Set<TrustAnchor> anchors = new HashSet<>();
if (customTrustAnchor != null) {
anchors.add(new TrustAnchor(customTrustAnchor, null));
} else {
Enumeration<String> aliases = signingTrustAnchors.aliases();
while (aliases.hasMoreElements()) {
Certificate c = signingTrustAnchors.getCertificate(aliases.nextElement());
if (c instanceof X509Certificate x) {
anchors.add(new TrustAnchor(x, null));
}
if (inCert) {
if ("END".equals(line)) {
inCert = false;
byte[] certBytes = parseOctalData(certData.toString());
if (certBytes != null) {
CertificateFactory cf = CertificateFactory.getInstance("X.509");
X509Certificate cert =
(X509Certificate)
cf.generateCertificate(
new ByteArrayInputStream(certBytes));
trustStore.setCertificateEntry("mozilla-cert-" + certCount++, cert);
}
} else {
certData.append(line).append("\n");
}
}
if (anchors.isEmpty()) {
throw new CertPathBuilderException("No trust anchors available");
}
// Target certificate selector
X509CertSelector target = new X509CertSelector();
target.setCertificate(signerCert);
// Intermediate certificate store
List<Certificate> allCerts = new ArrayList<>(intermediates);
CertStore intermediateStore =
CertStore.getInstance("Collection", new CollectionCertStoreParameters(allCerts));
// PKIX parameters
PKIXBuilderParameters params = new PKIXBuilderParameters(anchors, target);
params.addCertStore(intermediateStore);
String revocationMode =
applicationProperties.getSecurity().getValidation().getRevocation().getMode();
params.setRevocationEnabled(!"none".equalsIgnoreCase(revocationMode));
if (validationTime != null) {
params.setDate(validationTime);
}
// Revocation checking
if (!"none".equalsIgnoreCase(revocationMode)) {
try {
PKIXRevocationChecker rc =
(PKIXRevocationChecker)
CertPathValidator.getInstance("PKIX").getRevocationChecker();
Set<PKIXRevocationChecker.Option> options =
EnumSet.noneOf(PKIXRevocationChecker.Option.class);
// Soft-fail: allow validation to succeed if revocation status unavailable
boolean revocationHardFail =
applicationProperties
.getSecurity()
.getValidation()
.getRevocation()
.isHardFail();
if (!revocationHardFail) {
options.add(PKIXRevocationChecker.Option.SOFT_FAIL);
}
// Revocation mode configuration
if ("ocsp".equalsIgnoreCase(revocationMode)) {
// OCSP-only: prefer OCSP (default), disable fallback to CRL
options.add(PKIXRevocationChecker.Option.NO_FALLBACK);
} else if ("crl".equalsIgnoreCase(revocationMode)) {
// CRL-only: prefer CRLs, disable fallback to OCSP
options.add(PKIXRevocationChecker.Option.PREFER_CRLS);
options.add(PKIXRevocationChecker.Option.NO_FALLBACK);
}
// "ocsp+crl" or other: use defaults (try OCSP first, fallback to CRL)
rc.setOptions(options);
params.addCertPathChecker(rc);
} catch (Exception e) {
log.warn("Failed to configure revocation checker: {}", e.getMessage());
}
}
// Build path
CertPathBuilder builder = CertPathBuilder.getInstance("PKIX");
return (PKIXCertPathBuilderResult) builder.build(params);
}
/**
* Extract validation time from signature (TSA timestamp or signingTime)
*
* @param signerInfo The CMS signer information
* @return ValidationTime containing date and source, or null if not found
*/
public ValidationTime extractValidationTime(SignerInformation signerInfo) {
try {
// 1) Check for timestamp token (RFC 3161) - highest priority
var unsignedAttrs = signerInfo.getUnsignedAttributes();
if (unsignedAttrs != null) {
var attr =
unsignedAttrs.get(new ASN1ObjectIdentifier("1.2.840.113549.1.9.16.2.14"));
if (attr != null) {
try {
TimeStampToken tst =
new TimeStampToken(
new CMSSignedData(
attr.getAttributeValues()[0]
.toASN1Primitive()
.getEncoded()));
Date tstTime = tst.getTimeStampInfo().getGenTime();
log.debug("Using timestamp token time: {}", tstTime);
return new ValidationTime(tstTime, "timestamp");
} catch (Exception e) {
log.debug("Failed to parse timestamp token: {}", e.getMessage());
}
}
}
}
}
private byte[] parseOctalData(String data) {
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
String[] tokens = data.split("\\\\");
for (String token : tokens) {
token = token.trim();
if (!token.isEmpty()) {
baos.write(Integer.parseInt(token, 8));
// 2) Check for signingTime attribute - fallback
var signedAttrs = signerInfo.getSignedAttributes();
if (signedAttrs != null) {
var st = signedAttrs.get(CMSAttributes.signingTime);
if (st != null) {
ASN1Encodable val = st.getAttributeValues()[0];
Date signingTime = null;
if (val instanceof ASN1UTCTime ut) {
signingTime = ut.getDate();
} else if (val instanceof ASN1GeneralizedTime gt) {
signingTime = gt.getDate();
}
if (signingTime != null) {
log.debug("Using signingTime attribute: {}", signingTime);
return new ValidationTime(signingTime, "signing-time");
}
}
}
return baos.toByteArray();
} catch (Exception e) {
return null;
log.debug("Error extracting validation time: {}", e.getMessage());
}
return null;
}
public boolean validateCertificateChain(X509Certificate cert) {
/**
* Check if certificate is outside validity period at given time
*
* @param cert Certificate to check
* @param at Time to check validity
* @return true if certificate is expired or not yet valid
*/
public boolean isOutsideValidityPeriod(X509Certificate cert, Date at) {
try {
CertPathValidator validator = CertPathValidator.getInstance("PKIX");
CertificateFactory cf = CertificateFactory.getInstance("X.509");
List<X509Certificate> certList = Arrays.asList(cert);
CertPath certPath = cf.generateCertPath(certList);
Set<TrustAnchor> anchors = new HashSet<>();
Enumeration<String> aliases = trustStore.aliases();
while (aliases.hasMoreElements()) {
Object trustCert = trustStore.getCertificate(aliases.nextElement());
if (trustCert instanceof X509Certificate x509Cert) {
anchors.add(new TrustAnchor(x509Cert, null));
}
}
PKIXParameters params = new PKIXParameters(anchors);
params.setRevocationEnabled(false);
validator.validate(certPath, params);
return true;
} catch (Exception e) {
return false;
}
}
public boolean validateTrustStore(X509Certificate cert) {
try {
Enumeration<String> aliases = trustStore.aliases();
while (aliases.hasMoreElements()) {
Object trustCert = trustStore.getCertificate(aliases.nextElement());
if (trustCert instanceof X509Certificate && cert.equals(trustCert)) {
return true;
}
}
return false;
} catch (KeyStoreException e) {
return false;
}
}
public boolean isRevoked(X509Certificate cert) {
try {
cert.checkValidity();
cert.checkValidity(at);
return false;
} catch (CertificateExpiredException | CertificateNotYetValidException e) {
return true;
}
}
public boolean validateCertificateChainWithCustomCert(
X509Certificate cert, X509Certificate customCert) {
/**
* Check if revocation checking is enabled
*
* @return true if revocation mode is not "none"
*/
public boolean isRevocationEnabled() {
String revocationMode =
applicationProperties.getSecurity().getValidation().getRevocation().getMode();
return !"none".equalsIgnoreCase(revocationMode);
}
/**
* Check if certificate is a CA certificate
*
* @param cert Certificate to check
* @return true if certificate has basicConstraints with CA=true
*/
public boolean isCA(X509Certificate cert) {
return cert.getBasicConstraints() >= 0;
}
/**
* Verify if certificate is self-signed by checking signature
*
* @param cert Certificate to check
* @return true if certificate is self-signed and signature is valid
*/
public boolean isSelfSigned(X509Certificate cert) {
try {
cert.verify(customCert.getPublicKey());
if (!cert.getSubjectX500Principal().equals(cert.getIssuerX500Principal())) {
return false;
}
cert.verify(cert.getPublicKey());
return true;
} catch (Exception e) {
return false;
}
}
public boolean validateTrustWithCustomCert(X509Certificate cert, X509Certificate customCert) {
/**
* Calculate SHA-256 fingerprint of certificate
*
* @param cert Certificate
* @return Hex string of SHA-256 hash
*/
public String sha256Fingerprint(X509Certificate cert) {
try {
// Compare the issuer of the signature certificate with the custom certificate
return cert.getIssuerX500Principal().equals(customCert.getSubjectX500Principal());
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] hash = md.digest(cert.getEncoded());
return bytesToHex(hash);
} catch (Exception e) {
return false;
return "";
}
}
private String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
sb.append(String.format("%02X", b));
}
return sb.toString();
}
/**
* Extract all certificates from CMS signature store
*
* @param certStore BouncyCastle certificate store
* @param signerCert The signer certificate
* @return Collection of all certificates except signer
*/
public Collection<X509Certificate> extractIntermediateCertificates(
Store<X509CertificateHolder> certStore, X509Certificate signerCert) {
List<X509Certificate> intermediates = new ArrayList<>();
try {
JcaX509CertificateConverter converter = new JcaX509CertificateConverter();
Collection<X509CertificateHolder> holders = certStore.getMatches(null);
for (X509CertificateHolder holder : holders) {
X509Certificate cert = converter.getCertificate(holder);
if (!cert.equals(signerCert)) {
intermediates.add(cert);
}
}
} catch (Exception e) {
log.debug("Error extracting intermediate certificates: {}", e.getMessage());
}
return intermediates;
}
// ==================== Trust Store Loading ====================
/**
* Load certificates from Java's system trust store (cacerts). On Windows, this includes
* certificates from the Windows trust store. This provides maximum compatibility with what
* browsers and OS trust.
*/
private void loadJavaSystemTrustStore() {
try {
log.info("Loading certificates from Java system trust store");
// Get default trust manager factory
TrustManagerFactory tmf =
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
tmf.init((KeyStore) null); // null = use system default
// Extract certificates from trust managers
int loadedCount = 0;
for (TrustManager tm : tmf.getTrustManagers()) {
if (tm instanceof X509TrustManager x509tm) {
for (X509Certificate cert : x509tm.getAcceptedIssuers()) {
if (isCA(cert)) {
String fingerprint = sha256Fingerprint(cert);
String alias = "system-" + fingerprint;
signingTrustAnchors.setCertificateEntry(alias, cert);
loadedCount++;
}
}
}
}
log.info("Loaded {} CA certificates from Java system trust store", loadedCount);
} catch (Exception e) {
log.error("Failed to load Java system trust store: {}", e.getMessage(), e);
}
}
/**
* Load bundled Mozilla CA certificate bundle from resources. This bundle contains ~140 trusted
* root CAs from Mozilla's CA Certificate Program, suitable for validating most commercial PDF
* signatures.
*/
private void loadBundledMozillaCACerts() {
try {
log.info("Loading bundled Mozilla CA certificates from resources");
InputStream certStream =
getClass().getClassLoader().getResourceAsStream("certs/cacert.pem");
if (certStream == null) {
log.warn("Bundled Mozilla CA certificate file not found in resources");
return;
}
CertificateFactory cf = CertificateFactory.getInstance("X.509");
Collection<? extends Certificate> certs = cf.generateCertificates(certStream);
certStream.close();
int loadedCount = 0;
int skippedCount = 0;
for (Certificate cert : certs) {
if (cert instanceof X509Certificate x509) {
// Only add CA certificates to trust anchors
if (isCA(x509)) {
String fingerprint = sha256Fingerprint(x509);
String alias = "mozilla-" + fingerprint;
signingTrustAnchors.setCertificateEntry(alias, x509);
loadedCount++;
} else {
skippedCount++;
}
}
}
log.info(
"Loaded {} Mozilla CA certificates as trust anchors (skipped {} non-CA certs)",
loadedCount,
skippedCount);
} catch (Exception e) {
log.error("Failed to load bundled Mozilla CA certificates: {}", e.getMessage(), e);
}
}
private void loadServerCertAsAnchor() {
try {
if (serverCertificateService != null
&& serverCertificateService.isEnabled()
&& serverCertificateService.hasServerCertificate()) {
X509Certificate serverCert = serverCertificateService.getServerCertificate();
// Self-signed certificates can be trust anchors regardless of CA flag
// Non-self-signed certificates should only be trust anchors if they're CAs
boolean selfSigned = isSelfSigned(serverCert);
boolean ca = isCA(serverCert);
if (selfSigned || ca) {
signingTrustAnchors.setCertificateEntry("server-anchor", serverCert);
log.info(
"Loaded server certificate as trust anchor (self-signed: {}, CA: {})",
selfSigned,
ca);
} else {
log.warn(
"Server certificate is neither self-signed nor a CA; not adding as trust anchor");
}
}
} catch (Exception e) {
log.warn("Failed loading server certificate as anchor: {}", e.getMessage());
}
}
/** Download and parse Adobe Approved Trust List (AATL) and add CA certs as trust anchors. */
private void loadAATLCertificates() {
try {
String aatlUrl = applicationProperties.getSecurity().getValidation().getAatl().getUrl();
log.info("Loading Adobe Approved Trust List (AATL) from: {}", aatlUrl);
byte[] pdfBytes = downloadTrustList(aatlUrl);
if (pdfBytes == null) {
log.warn("AATL download returned no data");
return;
}
int added = parseAATLPdf(pdfBytes);
log.info("Loaded {} AATL CA certificates into signing trust", added);
} catch (Exception e) {
log.warn("Failed to load AATL: {}", e.getMessage());
log.debug("AATL loading error", e);
}
}
/** Simple HTTP(S) fetch with sane timeouts. */
private byte[] downloadTrustList(String urlStr) {
HttpURLConnection conn = null;
try {
URL url = new URL(urlStr);
conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setConnectTimeout(10_000);
conn.setReadTimeout(30_000);
conn.setInstanceFollowRedirects(true);
int code = conn.getResponseCode();
if (code == HttpURLConnection.HTTP_OK) {
try (InputStream in = conn.getInputStream();
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
byte[] buf = new byte[8192];
int r;
while ((r = in.read(buf)) != -1) out.write(buf, 0, r);
return out.toByteArray();
}
} else {
log.warn("AATL download failed: HTTP {}", code);
return null;
}
} catch (Exception e) {
log.warn("AATL download error: {}", e.getMessage());
return null;
} finally {
if (conn != null) conn.disconnect();
}
}
/**
* Parse AATL PDF, extract the embedded "SecuritySettings.xml", and import CA certs. Returns the
* number of newly-added CA certificates.
*/
private int parseAATLPdf(byte[] pdfBytes) throws Exception {
try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
PDDocumentNameDictionary names = doc.getDocumentCatalog().getNames();
if (names == null) {
log.warn("AATL PDF has no name dictionary");
return 0;
}
PDEmbeddedFilesNameTreeNode efRoot = names.getEmbeddedFiles();
if (efRoot == null) {
log.warn("AATL PDF has no embedded files");
return 0;
}
// 1) Try names at root level
Map<String, PDComplexFileSpecification> top = efRoot.getNames();
if (top != null) {
Integer count = tryParseSecuritySettingsXML(top);
if (count != null) return count;
}
// 2) Traverse kids (name-tree)
@SuppressWarnings("unchecked")
List<?> kids = efRoot.getKids();
if (kids != null) {
for (Object kidObj : kids) {
if (kidObj instanceof PDEmbeddedFilesNameTreeNode) {
PDEmbeddedFilesNameTreeNode kid = (PDEmbeddedFilesNameTreeNode) kidObj;
Map<String, PDComplexFileSpecification> map = kid.getNames();
if (map != null) {
Integer count = tryParseSecuritySettingsXML(map);
if (count != null) return count;
}
}
}
}
log.warn("AATL PDF did not contain SecuritySettings.xml");
return 0;
}
}
/**
* Try to locate "SecuritySettings.xml" in the given name map. If found and parsed, returns the
* number of certs added; otherwise returns null.
*/
private Integer tryParseSecuritySettingsXML(Map<String, PDComplexFileSpecification> nameMap) {
PDComplexFileSpecification fileSpec = nameMap.get("SecuritySettings.xml");
if (fileSpec == null) return null;
PDEmbeddedFile ef = fileSpec.getEmbeddedFile();
if (ef == null) return null;
try (InputStream xmlStream = ef.createInputStream()) {
return parseSecuritySettingsXML(xmlStream);
} catch (Exception e) {
log.warn("Failed parsing SecuritySettings.xml: {}", e.getMessage());
log.debug("SecuritySettings.xml parse error", e);
return null;
}
}
/**
* Parse the SecuritySettings.xml and load only CA certificates (basicConstraints >= 0). Returns
* the number of newly-added CA certificates.
*/
private int parseSecuritySettingsXML(InputStream xmlStream) throws Exception {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
factory.setXIncludeAware(false);
factory.setExpandEntityReferences(false);
DocumentBuilder builder = factory.newDocumentBuilder();
Document doc = builder.parse(xmlStream);
NodeList certNodes = doc.getElementsByTagName("Certificate");
CertificateFactory cf = CertificateFactory.getInstance("X.509");
int added = 0;
for (int i = 0; i < certNodes.getLength(); i++) {
String base64 = certNodes.item(i).getTextContent().trim();
if (base64.isEmpty()) continue;
try {
byte[] certBytes = java.util.Base64.getMimeDecoder().decode(base64);
X509Certificate cert =
(X509Certificate)
cf.generateCertificate(new ByteArrayInputStream(certBytes));
// Only add CA certs as anchors
if (isCA(cert)) {
String fingerprint = sha256Fingerprint(cert);
String alias = "aatl-" + fingerprint;
// avoid duplicates
if (signingTrustAnchors.getCertificate(alias) == null) {
signingTrustAnchors.setCertificateEntry(alias, cert);
added++;
}
} else {
log.debug(
"Skipping non-CA certificate from AATL: {}",
cert.getSubjectX500Principal().getName());
}
} catch (Exception e) {
log.debug("Failed to parse an AATL certificate node: {}", e.getMessage());
}
}
return added;
}
/**
* Download LOTL (List Of Trusted Lists), resolve national TSLs, and import qualified CA
* certificates.
*/
private void loadEUTLCertificates() {
try {
String lotlUrl =
applicationProperties.getSecurity().getValidation().getEutl().getLotlUrl();
log.info("Loading EU Trusted List (LOTL) from: {}", lotlUrl);
byte[] lotlBytes = downloadXml(lotlUrl);
if (lotlBytes == null) {
log.warn("LOTL download returned no data");
return;
}
List<String> tslUrls = parseLotlForTslLocations(lotlBytes);
log.info("Found {} national TSL locations in LOTL", tslUrls.size());
int totalAdded = 0;
for (String tslUrl : tslUrls) {
try {
byte[] tslBytes = downloadXml(tslUrl);
if (tslBytes == null) {
log.warn("TSL download failed: {}", tslUrl);
continue;
}
int added = parseTslAndAddCas(tslBytes, tslUrl);
totalAdded += added;
} catch (Exception e) {
log.warn("Failed to parse TSL {}: {}", tslUrl, e.getMessage());
log.debug("TSL parse error", e);
}
}
log.info("Imported {} qualified CA certificates from EUTL", totalAdded);
} catch (Exception e) {
log.warn("EUTL load failed: {}", e.getMessage());
log.debug("EUTL load error", e);
}
}
/** HTTP(S) GET for XML with sane timeouts. */
private byte[] downloadXml(String urlStr) {
HttpURLConnection conn = null;
try {
URL url = new URL(urlStr);
conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setConnectTimeout(10_000);
conn.setReadTimeout(30_000);
conn.setInstanceFollowRedirects(true);
int code = conn.getResponseCode();
if (code == HttpURLConnection.HTTP_OK) {
try (InputStream in = conn.getInputStream();
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
byte[] buf = new byte[8192];
int r;
while ((r = in.read(buf)) != -1) out.write(buf, 0, r);
return out.toByteArray();
}
} else {
log.warn("XML download failed: HTTP {} for {}", code, urlStr);
return null;
}
} catch (Exception e) {
log.warn("XML download error for {}: {}", urlStr, e.getMessage());
return null;
} finally {
if (conn != null) conn.disconnect();
}
}
/** Parse LOTL and return all TSL URLs from PointersToOtherTSL. */
private List<String> parseLotlForTslLocations(byte[] lotlBytes) throws Exception {
DocumentBuilderFactory dbf = secureDbfWithNamespaces();
DocumentBuilder db = dbf.newDocumentBuilder();
Document doc = db.parse(new ByteArrayInputStream(lotlBytes));
List<String> out = new ArrayList<>();
NodeList ptrs = doc.getElementsByTagNameNS(NS_TSL, "PointersToOtherTSL");
if (ptrs.getLength() == 0) return out;
org.w3c.dom.Element ptrRoot = (org.w3c.dom.Element) ptrs.item(0);
NodeList locations = ptrRoot.getElementsByTagNameNS(NS_TSL, "TSLLocation");
for (int i = 0; i < locations.getLength(); i++) {
String url = locations.item(i).getTextContent().trim();
if (!url.isEmpty()) out.add(url);
}
return out;
}
/**
* Parse a single national TSL, import CA certificates for qualified services in an active
* status. Returns count of newly added CA certs.
*/
private int parseTslAndAddCas(byte[] tslBytes, String sourceUrl) throws Exception {
DocumentBuilderFactory dbf = secureDbfWithNamespaces();
DocumentBuilder db = dbf.newDocumentBuilder();
Document doc = db.parse(new ByteArrayInputStream(tslBytes));
int added = 0;
NodeList services = doc.getElementsByTagNameNS(NS_TSL, "TSPService");
for (int i = 0; i < services.getLength(); i++) {
org.w3c.dom.Element svc = (org.w3c.dom.Element) services.item(i);
org.w3c.dom.Element info = firstChildNS(svc, "ServiceInformation");
if (info == null) continue;
String type = textOf(info, "ServiceTypeIdentifier");
if (!EUTL_SERVICE_TYPES.contains(type)) continue;
String status = textOf(info, "ServiceStatus");
if (!isActiveStatus(status)) continue;
org.w3c.dom.Element sdi = firstChildNS(info, "ServiceDigitalIdentity");
if (sdi == null) continue;
NodeList digitalIds = sdi.getElementsByTagNameNS(NS_TSL, "DigitalId");
for (int d = 0; d < digitalIds.getLength(); d++) {
org.w3c.dom.Element did = (org.w3c.dom.Element) digitalIds.item(d);
NodeList certNodes = did.getElementsByTagNameNS(NS_TSL, "X509Certificate");
for (int c = 0; c < certNodes.getLength(); c++) {
String base64 = certNodes.item(c).getTextContent().trim();
if (base64.isEmpty()) continue;
try {
byte[] certBytes = java.util.Base64.getMimeDecoder().decode(base64);
CertificateFactory cf = CertificateFactory.getInstance("X.509");
X509Certificate cert =
(X509Certificate)
cf.generateCertificate(new ByteArrayInputStream(certBytes));
if (!isCA(cert)) {
log.debug(
"Skipping non-CA in TSL {}: {}",
sourceUrl,
cert.getSubjectX500Principal().getName());
continue;
}
String fp = sha256Fingerprint(cert);
String alias = "eutl-" + fp;
if (signingTrustAnchors.getCertificate(alias) == null) {
signingTrustAnchors.setCertificateEntry(alias, cert);
added++;
}
} catch (Exception e) {
log.debug(
"Failed to import a certificate from {}: {}",
sourceUrl,
e.getMessage());
}
}
}
}
log.debug("TSL {} → imported {} CA certificates", sourceUrl, added);
return added;
}
/** Check if service status is active (per ETSI TS 119 612). */
private boolean isActiveStatus(String statusUri) {
if (STATUS_UNDER_SUPERVISION.equals(statusUri)) return true;
if (STATUS_ACCREDITED.equals(statusUri)) return true;
boolean acceptTransitional =
applicationProperties
.getSecurity()
.getValidation()
.getEutl()
.isAcceptTransitional();
if (acceptTransitional && STATUS_SUPERVISION_IN_CESSATION.equals(statusUri)) return true;
return false;
}
/** Create secure DocumentBuilderFactory with namespace awareness. */
private DocumentBuilderFactory secureDbfWithNamespaces() throws Exception {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware(true);
// Secure processing hardening
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
factory.setXIncludeAware(false);
factory.setExpandEntityReferences(false);
return factory;
}
/** Get first child element with given local name in TSL namespace. */
private org.w3c.dom.Element firstChildNS(org.w3c.dom.Element parent, String localName) {
NodeList nl = parent.getElementsByTagNameNS(NS_TSL, localName);
return (nl.getLength() == 0) ? null : (org.w3c.dom.Element) nl.item(0);
}
/** Get text content of first child with given local name. */
private String textOf(org.w3c.dom.Element parent, String localName) {
org.w3c.dom.Element e = firstChildNS(parent, localName);
return (e == null) ? "" : e.getTextContent().trim();
}
/** Get signing trust store */
public KeyStore getSigningTrustStore() {
return signingTrustAnchors;
}
}
@@ -2,7 +2,7 @@ multipart.enabled=true
logging.level.org.springframework=WARN
logging.level.org.hibernate=WARN
logging.level.org.eclipse.jetty=WARN
#logging.level.org.springframework.security.saml2=TRACE
#logging.level.org.springframework.security.oauth2=DEBUG
#logging.level.org.springframework.security=DEBUG
#logging.level.org.opensaml=DEBUG
#logging.level.stirling.software.proprietary.security=DEBUG
@@ -35,12 +35,12 @@ spring.datasource.username=sa
spring.datasource.password=
spring.h2.console.enabled=false
spring.jpa.hibernate.ddl-auto=update
# Defer datasource initialization to ensure that the database is fully set up
# before Hibernate attempts to access it. This is particularly useful when
# Defer datasource initialization to ensure that the database is fully set up
# before Hibernate attempts to access it. This is particularly useful when
# using database initialization scripts or tools.
spring.jpa.defer-datasource-initialization=true
# Disable SQL logging to avoid cluttering the logs in production. Enable this
# Disable SQL logging to avoid cluttering the logs in production. Enable this
# property during development if you need to debug SQL queries.
spring.jpa.show-sql=false
server.servlet.session.timeout:30m
@@ -60,4 +60,4 @@ spring.main.allow-bean-definition-overriding=true
java.io.tmpdir=${stirling.tempfiles.directory:${java.io.tmpdir}/stirling-pdf}
# V2 features
v2=false
v2=true
@@ -64,7 +64,22 @@ security:
enableKeyRotation: true # Set to 'true' to enable key pair rotation
enableKeyCleanup: true # Set to 'true' to enable key pair cleanup
keyRetentionDays: 7 # Number of days to retain old keys. The default is 7 days.
secureCookie: false # Set to 'true' to use secure cookies for JWTs
validation: # PDF signature validation settings
trust:
serverAsAnchor: true # Trust server certificate as anchor for PDF signatures (if configured and self-signed or CA)
useSystemTrust: true # Trust Java/OS system trust store for PDF signature validation
useMozillaBundle: true # Trust bundled Mozilla CA bundle (~140 CAs) for PDF signature validation
useAATL: false # Trust Adobe Approved Trust List (AATL) for PDF signature validation - downloads from Adobe on startup if enabled
useEUTL: false # Trust EU Trusted List (EUTL) for eIDAS qualified certificates - downloads LOTL and national TSLs on startup if enabled
allowAIA: false # Allow JDK to fetch issuer certificates and revocation information from network (OCSP/CRL/AIA)
aatl:
url: https://trustlist.adobe.com/tl.pdf # Adobe Approved Trust List download URL
eutl:
lotlUrl: https://ec.europa.eu/tools/lotl/eu-lotl.xml # EU List Of Trusted Lists (LOTL) URL
acceptTransitional: false # Accept certificates with 'supervisionincessation' status (transitional state)
revocation:
mode: none # Revocation checking mode: 'none' (disabled), 'ocsp' (OCSP only), 'crl' (CRL only), 'ocsp+crl' (OCSP with CRL fallback)
hardFail: false # Fail validation if revocation status cannot be determined (true=strict, false=soft-fail)
premium:
key: 00000000-0000-0000-0000-000000000000
@@ -109,6 +124,7 @@ system:
enableUrlToPDF: false # Set to 'true' to enable URL to PDF, INTERNAL ONLY, known security issues, should not be used externally
disableSanitize: false # set to true to disable Sanitize HTML; (can lead to injections in HTML)
maxDPI: 500 # Maximum allowed DPI for PDF to image conversion
corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). Leave empty to disable CORS.
serverCertificate:
enabled: true # Enable server-side certificate for "Sign with Stirling-PDF" option
organizationName: Stirling-PDF # Organization name for generated certificates
@@ -2,20 +2,20 @@ package stirling.software.SPDF.service;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.security.PublicKey;
import java.security.cert.CertificateExpiredException;
import java.security.cert.X509Certificate;
import javax.security.auth.x500.X500Principal;
import java.util.Date;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import stirling.software.common.model.ApplicationProperties;
/** Tests for the CertificateValidationService using mocked certificates. */
class CertificateValidationServiceTest {
@@ -26,121 +26,67 @@ class CertificateValidationServiceTest {
@BeforeEach
void setUp() throws Exception {
validationService = new CertificateValidationService();
// Create mock ApplicationProperties with default validation settings
ApplicationProperties applicationProperties = mock(ApplicationProperties.class);
ApplicationProperties.Security security = mock(ApplicationProperties.Security.class);
ApplicationProperties.Security.Validation validation =
mock(ApplicationProperties.Security.Validation.class);
ApplicationProperties.Security.Validation.Trust trust =
mock(ApplicationProperties.Security.Validation.Trust.class);
ApplicationProperties.Security.Validation.Revocation revocation =
mock(ApplicationProperties.Security.Validation.Revocation.class);
when(applicationProperties.getSecurity()).thenReturn(security);
when(security.getValidation()).thenReturn(validation);
when(validation.getTrust()).thenReturn(trust);
when(validation.getRevocation()).thenReturn(revocation);
when(validation.isAllowAIA()).thenReturn(false);
when(trust.isServerAsAnchor()).thenReturn(false);
when(trust.isUseSystemTrust()).thenReturn(false);
when(trust.isUseMozillaBundle()).thenReturn(false);
when(trust.isUseAATL()).thenReturn(false);
when(trust.isUseEUTL()).thenReturn(false);
when(revocation.getMode()).thenReturn("none");
when(revocation.isHardFail()).thenReturn(false);
validationService = new CertificateValidationService(null, applicationProperties);
// Create mock certificates
validCertificate = mock(X509Certificate.class);
expiredCertificate = mock(X509Certificate.class);
// Set up behaviors for valid certificate
doNothing().when(validCertificate).checkValidity(); // No exception means valid
// Set up behaviors for valid certificate (both overloads)
doNothing().when(validCertificate).checkValidity();
doNothing().when(validCertificate).checkValidity(any(Date.class));
// Set up behaviors for expired certificate
// Set up behaviors for expired certificate (both overloads)
doThrow(new CertificateExpiredException("Certificate expired"))
.when(expiredCertificate)
.checkValidity();
doThrow(new CertificateExpiredException("Certificate expired"))
.when(expiredCertificate)
.checkValidity(any(Date.class));
}
@Test
void testIsRevoked_ValidCertificate() {
void testIsOutsideValidityPeriod_ValidCertificate() {
// When certificate is valid (not expired)
boolean result = validationService.isRevoked(validCertificate);
boolean result = validationService.isOutsideValidityPeriod(validCertificate, new Date());
// Then it should not be considered revoked
assertFalse(result, "Valid certificate should not be considered revoked");
// Then it should not be outside validity period
assertFalse(result, "Valid certificate should not be outside validity period");
}
@Test
void testIsRevoked_ExpiredCertificate() {
void testIsOutsideValidityPeriod_ExpiredCertificate() {
// When certificate is expired
boolean result = validationService.isRevoked(expiredCertificate);
boolean result = validationService.isOutsideValidityPeriod(expiredCertificate, new Date());
// Then it should be considered revoked
assertTrue(result, "Expired certificate should be considered revoked");
// Then it should be outside validity period
assertTrue(result, "Expired certificate should be outside validity period");
}
@Test
void testValidateTrustWithCustomCert_Match() {
// Create certificates with matching issuer and subject
X509Certificate issuingCert = mock(X509Certificate.class);
X509Certificate signedCert = mock(X509Certificate.class);
// Create X500Principal objects for issuer and subject
X500Principal issuerPrincipal = new X500Principal("CN=Test Issuer");
// Mock the issuer of the signed certificate to match the subject of the issuing certificate
when(signedCert.getIssuerX500Principal()).thenReturn(issuerPrincipal);
when(issuingCert.getSubjectX500Principal()).thenReturn(issuerPrincipal);
// When validating trust with custom cert
boolean result = validationService.validateTrustWithCustomCert(signedCert, issuingCert);
// Then validation should succeed
assertTrue(result, "Certificate with matching issuer and subject should validate");
}
@Test
void testValidateTrustWithCustomCert_NoMatch() {
// Create certificates with non-matching issuer and subject
X509Certificate issuingCert = mock(X509Certificate.class);
X509Certificate signedCert = mock(X509Certificate.class);
// Create X500Principal objects for issuer and subject
X500Principal issuerPrincipal = new X500Principal("CN=Test Issuer");
X500Principal differentPrincipal = new X500Principal("CN=Different Name");
// Mock the issuer of the signed certificate to NOT match the subject of the issuing
// certificate
when(signedCert.getIssuerX500Principal()).thenReturn(issuerPrincipal);
when(issuingCert.getSubjectX500Principal()).thenReturn(differentPrincipal);
// When validating trust with custom cert
boolean result = validationService.validateTrustWithCustomCert(signedCert, issuingCert);
// Then validation should fail
assertFalse(result, "Certificate with non-matching issuer and subject should not validate");
}
@Test
void testValidateCertificateChainWithCustomCert_Success() throws Exception {
// Setup mock certificates
X509Certificate signedCert = mock(X509Certificate.class);
X509Certificate signingCert = mock(X509Certificate.class);
PublicKey publicKey = mock(PublicKey.class);
when(signingCert.getPublicKey()).thenReturn(publicKey);
// When verifying the certificate with the signing cert's public key, don't throw exception
doNothing().when(signedCert).verify(Mockito.any());
// When validating certificate chain with custom cert
boolean result =
validationService.validateCertificateChainWithCustomCert(signedCert, signingCert);
// Then validation should succeed
assertTrue(result, "Certificate chain with proper signing should validate");
}
@Test
void testValidateCertificateChainWithCustomCert_Failure() throws Exception {
// Setup mock certificates
X509Certificate signedCert = mock(X509Certificate.class);
X509Certificate signingCert = mock(X509Certificate.class);
PublicKey publicKey = mock(PublicKey.class);
when(signingCert.getPublicKey()).thenReturn(publicKey);
// When verifying the certificate with the signing cert's public key, throw exception
// Need to use a specific exception that verify() can throw
doThrow(new java.security.SignatureException("Verification failed"))
.when(signedCert)
.verify(Mockito.any());
// When validating certificate chain with custom cert
boolean result =
validationService.validateCertificateChainWithCustomCert(signedCert, signingCert);
// Then validation should fail
assertFalse(result, "Certificate chain with failed signing should not validate");
}
// Note: Full integration tests for buildAndValidatePath() would require
// real certificate chains and trust anchors. These would be better as
// integration tests using actual signed PDFs from the test-signed-pdfs directory.
}
@@ -57,7 +57,6 @@ public class CustomAuthenticationSuccessHandler
String jwt =
jwtService.generateToken(
authentication, Map.of("authType", AuthenticationType.WEB));
jwtService.addToken(response, jwt);
log.debug("JWT generated for user: {}", userName);
getRedirectStrategy().sendRedirect(request, response, "/");
@@ -72,7 +72,6 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
}
} else if (!jwtService.extractToken(request).isBlank()) {
jwtService.clearToken(response);
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
} else {
// Redirect to login page after logout
@@ -115,8 +114,12 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
// Set service provider keys for the SamlClient
samlClient.setSPKeys(certificate, privateKey);
// Redirect to identity provider for logout. todo: add relay state
samlClient.redirectToIdentityProvider(response, null, nameIdValue);
// Build relay state to return user to login page after IdP logout
String relayState =
UrlUtils.getOrigin(request) + request.getContextPath() + LOGOUT_PATH;
// Redirect to identity provider for logout with relay state
samlClient.redirectToIdentityProvider(response, relayState, nameIdValue);
} catch (Exception e) {
log.error(
"Error retrieving logout URL from Provider {} for user {}",
@@ -13,7 +13,7 @@ public class RateLimitResetScheduler {
private final IPRateLimitingFilter rateLimitingFilter;
@Scheduled(cron = "0 0 0 * * MON") // At 00:00 every Monday TODO: configurable
@Scheduled(cron = "${security.rate-limit.reset-schedule:0 0 0 * * MON}")
public void resetRateLimit() {
rateLimitingFilter.resetRequestCounts();
}
@@ -132,19 +132,15 @@ public class SecurityConfiguration {
if (loginEnabledValue) {
boolean v2Enabled = appConfig.v2Enabled();
if (v2Enabled) {
http.addFilterBefore(
jwtAuthenticationFilter(),
UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(
exceptionHandling ->
exceptionHandling.authenticationEntryPoint(
jwtAuthenticationEntryPoint));
}
http.addFilterBefore(
userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(rateLimitingFilter(), UserAuthenticationFilter.class)
.addFilterAfter(firstLoginFilter, UsernamePasswordAuthenticationFilter.class);
.addFilterBefore(
rateLimitingFilter(), UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(firstLoginFilter, IPRateLimitingFilter.class);
if (v2Enabled) {
http.addFilterBefore(jwtAuthenticationFilter(), UserAuthenticationFilter.class);
}
if (!securityProperties.getCsrfDisabled()) {
CookieCsrfTokenRepository cookieRepo =
@@ -156,6 +152,13 @@ public class SecurityConfiguration {
csrf ->
csrf.ignoringRequestMatchers(
request -> {
String uri = request.getRequestURI();
// Ignore CSRF for auth endpoints
if (uri.startsWith("/api/v1/auth/")) {
return true;
}
String apiKey = request.getHeader("X-API-KEY");
// If there's no API key, don't ignore CSRF
// (return false)
@@ -238,9 +241,12 @@ public class SecurityConfiguration {
: uri;
return trimmedUri.startsWith("/login")
|| trimmedUri.startsWith("/oauth")
|| trimmedUri.startsWith("/oauth2")
|| trimmedUri.startsWith("/saml2")
|| trimmedUri.endsWith(".svg")
|| trimmedUri.startsWith("/register")
|| trimmedUri.startsWith("/signup")
|| trimmedUri.startsWith("/auth/callback")
|| trimmedUri.startsWith("/error")
|| trimmedUri.startsWith("/images/")
|| trimmedUri.startsWith("/public/")
@@ -252,6 +258,16 @@ public class SecurityConfiguration {
|| trimmedUri.startsWith("/favicon")
|| trimmedUri.startsWith(
"/api/v1/info/status")
|| trimmedUri.startsWith("/api/v1/config")
|| trimmedUri.startsWith(
"/api/v1/auth/register")
|| trimmedUri.startsWith(
"/api/v1/user/register")
|| trimmedUri.startsWith(
"/api/v1/auth/login")
|| trimmedUri.startsWith(
"/api/v1/auth/refresh")
|| trimmedUri.startsWith("/api/v1/auth/me")
|| trimmedUri.startsWith("/v1/api-docs")
|| uri.contains("/v1/api-docs");
})
@@ -277,33 +293,40 @@ public class SecurityConfiguration {
// Handle OAUTH2 Logins
if (securityProperties.isOauth2Active()) {
http.oauth2Login(
oauth2 ->
oauth2.loginPage("/oauth2")
/*
This Custom handler is used to check if the OAUTH2 user trying to log in, already exists in the database.
If user exists, login proceeds as usual. If user does not exist, then it is auto-created but only if 'OAUTH2AutoCreateUser'
is set as true, else login fails with an error message advising the same.
*/
.successHandler(
new CustomOAuth2AuthenticationSuccessHandler(
loginAttemptService,
securityProperties.getOauth2(),
userService,
jwtService))
.failureHandler(
new CustomOAuth2AuthenticationFailureHandler())
// Add existing Authorities from the database
.userInfoEndpoint(
userInfoEndpoint ->
userInfoEndpoint
.oidcUserService(
new CustomOAuth2UserService(
securityProperties,
userService,
loginAttemptService))
.userAuthoritiesMapper(
oAuth2userAuthoritiesMapper))
.permitAll());
oauth2 -> {
// v1: Use /oauth2 as login page for Thymeleaf templates
if (!v2Enabled) {
oauth2.loginPage("/oauth2");
}
// v2: Don't set loginPage, let default OAuth2 flow handle it
oauth2
/*
This Custom handler is used to check if the OAUTH2 user trying to log in, already exists in the database.
If user exists, login proceeds as usual. If user does not exist, then it is auto-created but only if 'OAUTH2AutoCreateUser'
is set as true, else login fails with an error message advising the same.
*/
.successHandler(
new CustomOAuth2AuthenticationSuccessHandler(
loginAttemptService,
securityProperties.getOauth2(),
userService,
jwtService))
.failureHandler(new CustomOAuth2AuthenticationFailureHandler())
// Add existing Authorities from the database
.userInfoEndpoint(
userInfoEndpoint ->
userInfoEndpoint
.oidcUserService(
new CustomOAuth2UserService(
securityProperties
.getOauth2(),
userService,
loginAttemptService))
.userAuthoritiesMapper(
oAuth2userAuthoritiesMapper))
.permitAll();
});
}
// Handle SAML
if (securityProperties.isSaml2Active() && runningProOrHigher) {
@@ -0,0 +1,238 @@
package stirling.software.proprietary.security.controller.api;
import java.util.HashMap;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.web.bind.annotation.*;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.model.api.user.UsernameAndPass;
import stirling.software.proprietary.security.service.CustomUserDetailsService;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.UserService;
/** REST API Controller for authentication operations. */
@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
@Slf4j
@Tag(name = "Authentication", description = "Endpoints for user authentication and registration")
public class AuthController {
private final UserService userService;
private final JwtServiceInterface jwtService;
private final CustomUserDetailsService userDetailsService;
/**
* Login endpoint - replaces Supabase signInWithPassword
*
* @param request Login credentials (email/username and password)
* @param response HTTP response to set JWT cookie
* @return User and session information
*/
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/login")
public ResponseEntity<?> login(
@RequestBody UsernameAndPass request, HttpServletResponse response) {
try {
// Validate input parameters
if (request.getUsername() == null || request.getUsername().trim().isEmpty()) {
log.warn("Login attempt with null or empty username");
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Username is required"));
}
if (request.getPassword() == null || request.getPassword().isEmpty()) {
log.warn(
"Login attempt with null or empty password for user: {}",
request.getUsername());
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Password is required"));
}
log.debug("Login attempt for user: {}", request.getUsername());
UserDetails userDetails =
userDetailsService.loadUserByUsername(request.getUsername().trim());
User user = (User) userDetails;
if (!userService.isPasswordCorrect(user, request.getPassword())) {
log.warn("Invalid password for user: {}", request.getUsername());
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Invalid credentials"));
}
if (!user.isEnabled()) {
log.warn("Disabled user attempted login: {}", request.getUsername());
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "User account is disabled"));
}
Map<String, Object> claims = new HashMap<>();
claims.put("authType", AuthenticationType.WEB.toString());
claims.put("role", user.getRolesAsString());
String token = jwtService.generateToken(user.getUsername(), claims);
log.info("Login successful for user: {}", request.getUsername());
return ResponseEntity.ok(
Map.of(
"user", buildUserResponse(user),
"session", Map.of("access_token", token, "expires_in", 3600)));
} catch (UsernameNotFoundException e) {
log.warn("User not found: {}", request.getUsername());
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Invalid username or password"));
} catch (AuthenticationException e) {
log.error("Authentication failed for user: {}", request.getUsername(), e);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Invalid credentials"));
} catch (Exception e) {
log.error("Login error for user: {}", request.getUsername(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Internal server error"));
}
}
/**
* Get current user
*
* @return Current authenticated user information
*/
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@GetMapping("/me")
public ResponseEntity<?> getCurrentUser() {
try {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null
|| !auth.isAuthenticated()
|| auth.getPrincipal().equals("anonymousUser")) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Not authenticated"));
}
UserDetails userDetails = (UserDetails) auth.getPrincipal();
User user = (User) userDetails;
return ResponseEntity.ok(Map.of("user", buildUserResponse(user)));
} catch (Exception e) {
log.error("Get current user error", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Internal server error"));
}
}
/**
* Logout endpoint
*
* @param response HTTP response
* @return Success message
*/
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/logout")
public ResponseEntity<?> logout(HttpServletResponse response) {
try {
SecurityContextHolder.clearContext();
log.debug("User logged out successfully");
return ResponseEntity.ok(Map.of("message", "Logged out successfully"));
} catch (Exception e) {
log.error("Logout error", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Internal server error"));
}
}
/**
* Refresh token
*
* @param request HTTP request containing current JWT cookie
* @param response HTTP response to set new JWT cookie
* @return New token information
*/
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/refresh")
public ResponseEntity<?> refresh(HttpServletRequest request, HttpServletResponse response) {
try {
String token = jwtService.extractToken(request);
if (token == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "No token found"));
}
jwtService.validateToken(token);
String username = jwtService.extractUsername(token);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
User user = (User) userDetails;
Map<String, Object> claims = new HashMap<>();
claims.put("authType", user.getAuthenticationType());
claims.put("role", user.getRolesAsString());
String newToken = jwtService.generateToken(username, claims);
log.debug("Token refreshed for user: {}", username);
return ResponseEntity.ok(Map.of("access_token", newToken, "expires_in", 3600));
} catch (Exception e) {
log.error("Token refresh error", e);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Token refresh failed"));
}
}
/**
* Helper method to build user response object
*
* @param user User entity
* @return Map containing user information
*/
private Map<String, Object> buildUserResponse(User user) {
Map<String, Object> userMap = new HashMap<>();
userMap.put("id", user.getId());
userMap.put("email", user.getUsername()); // Use username as email
userMap.put("username", user.getUsername());
userMap.put("role", user.getRolesAsString());
userMap.put("enabled", user.isEnabled());
// Add metadata for OAuth compatibility
Map<String, Object> appMetadata = new HashMap<>();
appMetadata.put("provider", user.getAuthenticationType()); // Default to email provider
userMap.put("app_metadata", appMetadata);
return userMap;
}
// ===========================
// Request/Response DTOs
// ===========================
/** Login request DTO */
public record LoginRequest(String email, String password) {}
}
@@ -3,6 +3,7 @@ package stirling.software.proprietary.security.controller.api;
import java.io.IOException;
import java.security.Principal;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -15,7 +16,6 @@ import org.springframework.security.core.session.SessionInformation;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import org.springframework.web.servlet.view.RedirectView;
@@ -56,24 +56,83 @@ public class UserController {
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/register")
public String register(@ModelAttribute UsernameAndPass requestModel, Model model)
public ResponseEntity<?> register(@RequestBody UsernameAndPass usernameAndPass)
throws SQLException, UnsupportedProviderException {
if (userService.usernameExistsIgnoreCase(requestModel.getUsername())) {
model.addAttribute("error", "Username already exists");
return "register";
}
try {
log.debug("Registration attempt for user: {}", usernameAndPass.getUsername());
if (userService.usernameExistsIgnoreCase(usernameAndPass.getUsername())) {
log.warn(
"Registration failed: username already exists: {}",
usernameAndPass.getUsername());
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "User already exists"));
}
if (!userService.isUsernameValid(usernameAndPass.getUsername())) {
log.warn(
"Registration failed: invalid username format: {}",
usernameAndPass.getUsername());
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Invalid username format"));
}
if (usernameAndPass.getPassword() == null
|| usernameAndPass.getPassword().length() < 6) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Password must be at least 6 characters"));
}
Team team = teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null);
userService.saveUser(
requestModel.getUsername(),
requestModel.getPassword(),
team,
Role.USER.getRoleId(),
false);
User user =
userService.saveUser(
usernameAndPass.getUsername(),
usernameAndPass.getPassword(),
team,
Role.USER.getRoleId(),
false);
log.info("User registered successfully: {}", usernameAndPass.getUsername());
return ResponseEntity.status(HttpStatus.CREATED)
.body(
Map.of(
"user",
buildUserResponse(user),
"message",
"Account created successfully. Please log in."));
} catch (IllegalArgumentException e) {
return "redirect:/login?messageType=invalidUsername";
log.error("Registration validation error: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", e.getMessage()));
} catch (Exception e) {
log.error("Registration error for user: {}", usernameAndPass.getUsername(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Registration failed: " + e.getMessage()));
}
return "redirect:/login?registered=true";
}
/**
* Helper method to build user response object
*
* @param user User entity
* @return Map containing user information
*/
private Map<String, Object> buildUserResponse(User user) {
Map<String, Object> userMap = new HashMap<>();
userMap.put("id", user.getId());
userMap.put("email", user.getUsername()); // Use username as email
userMap.put("username", user.getUsername());
userMap.put("role", user.getRolesAsString());
userMap.put("enabled", user.isEnabled());
// Add metadata for OAuth compatibility
Map<String, Object> appMetadata = new HashMap<>();
appMetadata.put("provider", user.getAuthenticationType()); // Default to email provider
userMap.put("app_metadata", appMetadata);
return userMap;
}
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@@ -22,6 +22,8 @@ public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByApiKey(String apiKey);
Optional<User> findBySsoProviderAndSsoProviderId(String ssoProvider, String ssoProviderId);
List<User> findByAuthenticationTypeIgnoreCase(String authenticationType);
@Query("SELECT u FROM User u WHERE u.team IS NULL")
@@ -1,8 +1,9 @@
package stirling.software.proprietary.security.filter;
import static stirling.software.common.util.RequestUriUtils.isStaticResource;
import static stirling.software.proprietary.security.model.AuthenticationType.*;
import static stirling.software.proprietary.security.model.AuthenticationType.OAUTH2;
import static stirling.software.proprietary.security.model.AuthenticationType.SAML2;
import static stirling.software.proprietary.security.model.AuthenticationType.WEB;
import java.io.IOException;
import java.sql.SQLException;
@@ -75,29 +76,60 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
String jwtToken = jwtService.extractToken(request);
if (jwtToken == null) {
// Any unauthenticated requests should redirect to /login
// Allow specific auth endpoints to pass through without JWT
String requestURI = request.getRequestURI();
String contextPath = request.getContextPath();
if (!requestURI.startsWith(contextPath + "/login")) {
response.sendRedirect("/login");
// Public auth endpoints that don't require JWT
boolean isPublicAuthEndpoint =
requestURI.startsWith(contextPath + "/login")
|| requestURI.startsWith(contextPath + "/signup")
|| requestURI.startsWith(contextPath + "/auth/")
|| requestURI.startsWith(contextPath + "/oauth2")
|| requestURI.startsWith(contextPath + "/api/v1/auth/login")
|| requestURI.startsWith(contextPath + "/api/v1/auth/register")
|| requestURI.startsWith(contextPath + "/api/v1/auth/refresh");
if (!isPublicAuthEndpoint) {
// For API requests, return 401 JSON
String acceptHeader = request.getHeader("Accept");
if (requestURI.startsWith(contextPath + "/api/")
|| (acceptHeader != null
&& acceptHeader.contains("application/json"))) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write("{\"error\":\"Authentication required\"}");
return;
}
// For HTML requests (SPA routes), let React Router handle it (serve
// index.html)
filterChain.doFilter(request, response);
return;
}
// For public auth endpoints without JWT, continue to the endpoint
filterChain.doFilter(request, response);
return;
}
try {
log.debug("Validating JWT token");
jwtService.validateToken(jwtToken);
log.debug("JWT token validated successfully");
} catch (AuthenticationFailureException e) {
jwtService.clearToken(response);
log.warn("JWT validation failed: {}", e.getMessage());
handleAuthenticationFailure(request, response, e);
return;
}
Map<String, Object> claims = jwtService.extractClaims(jwtToken);
String tokenUsername = claims.get("sub").toString();
log.debug("JWT token username: {}", tokenUsername);
try {
authenticate(request, claims);
log.debug("Authentication successful for user: {}", tokenUsername);
} catch (SQLException | UnsupportedProviderException e) {
log.error("Error processing user authentication for user: {}", tokenUsername, e);
handleAuthenticationFailure(
@@ -175,21 +207,26 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
private void processUserAuthenticationType(Map<String, Object> claims, String username)
throws SQLException, UnsupportedProviderException {
AuthenticationType authenticationType =
AuthenticationType.valueOf(claims.getOrDefault("authType", WEB).toString());
AuthenticationType.valueOf(
claims.getOrDefault("authType", WEB).toString().toUpperCase());
log.debug("Processing {} login for {} user", authenticationType, username);
switch (authenticationType) {
case OAUTH2 -> {
ApplicationProperties.Security.OAUTH2 oauth2Properties =
securityProperties.getOauth2();
// Provider IDs should already be set during initial authentication
// Pass null here since this is validating an existing JWT token
userService.processSSOPostLogin(
username, oauth2Properties.getAutoCreateUser(), OAUTH2);
username, null, null, oauth2Properties.getAutoCreateUser(), OAUTH2);
}
case SAML2 -> {
ApplicationProperties.Security.SAML2 saml2Properties =
securityProperties.getSaml2();
// Provider IDs should already be set during initial authentication
// Pass null here since this is validating an existing JWT token
userService.processSSOPostLogin(
username, saml2Properties.getAutoCreateUser(), SAML2);
username, null, null, saml2Properties.getAutoCreateUser(), SAML2);
}
}
}
@@ -236,6 +236,10 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
contextPath + "/pdfjs/",
contextPath + "/pdfjs-legacy/",
contextPath + "/api/v1/info/status",
contextPath + "/api/v1/auth/login",
contextPath + "/api/v1/auth/register",
contextPath + "/api/v1/auth/refresh",
contextPath + "/api/v1/auth/me",
contextPath + "/site.webmanifest"
};
@@ -1,12 +1,15 @@
package stirling.software.proprietary.security.model;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import org.springframework.security.core.userdetails.UserDetails;
import com.fasterxml.jackson.annotation.JsonIgnore;
@@ -59,6 +62,12 @@ public class User implements UserDetails, Serializable {
@Column(name = "authenticationtype")
private String authenticationType;
@Column(name = "sso_provider_id")
private String ssoProviderId;
@Column(name = "sso_provider")
private String ssoProvider;
@OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, mappedBy = "user")
private Set<Authority> authorities = new HashSet<>();
@@ -74,6 +83,14 @@ public class User implements UserDetails, Serializable {
@CollectionTable(name = "user_settings", joinColumns = @JoinColumn(name = "user_id"))
private Map<String, String> settings = new HashMap<>(); // Key-value pairs of settings.
@CreationTimestamp
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at")
private LocalDateTime updatedAt;
public String getRoleName() {
return Role.getRoleNameByRoleId(getRolesAsString());
}
@@ -10,6 +10,7 @@ import java.util.Map;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
import org.springframework.security.web.savedrequest.SavedRequest;
@@ -72,12 +73,6 @@ public class CustomOAuth2AuthenticationSuccessHandler
throw new LockedException(
"Your account has been locked due to too many failed login attempts.");
}
if (jwtService.isJwtEnabled()) {
String jwt =
jwtService.generateToken(
authentication, Map.of("authType", AuthenticationType.OAUTH2));
jwtService.addToken(response, jwt);
}
if (userService.isUserDisabled(username)) {
getRedirectStrategy()
.sendRedirect(request, response, "/logout?userIsDisabled=true");
@@ -98,14 +93,95 @@ public class CustomOAuth2AuthenticationSuccessHandler
response.sendRedirect(contextPath + "/logout?oAuth2AdminBlockedUser=true");
return;
}
if (principal instanceof OAuth2User) {
if (principal instanceof OAuth2User oAuth2User) {
// Extract SSO provider information from OAuth2User
String ssoProviderId = oAuth2User.getAttribute("sub"); // OIDC ID
// Extract provider from authentication - need to get it from the token/request
// For now, we'll extract it in a more generic way
String ssoProvider = extractProviderFromAuthentication(authentication);
userService.processSSOPostLogin(
username, oauth2Properties.getAutoCreateUser(), OAUTH2);
username,
ssoProviderId,
ssoProvider,
oauth2Properties.getAutoCreateUser(),
OAUTH2);
}
// Generate JWT if v2 is enabled
if (jwtService.isJwtEnabled()) {
String jwt =
jwtService.generateToken(
authentication, Map.of("authType", AuthenticationType.OAUTH2));
// Build context-aware redirect URL based on the original request
String redirectUrl = buildContextAwareRedirectUrl(request, contextPath, jwt);
response.sendRedirect(redirectUrl);
} else {
// v1: redirect directly to home
response.sendRedirect(contextPath + "/");
}
response.sendRedirect(contextPath + "/");
} catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) {
response.sendRedirect(contextPath + "/logout?invalidUsername=true");
}
}
}
/**
* Extracts the OAuth2 provider registration ID from the authentication object.
*
* @param authentication The authentication object
* @return The provider registration ID (e.g., "google", "github"), or null if not available
*/
private String extractProviderFromAuthentication(Authentication authentication) {
if (authentication instanceof OAuth2AuthenticationToken oauth2Token) {
return oauth2Token.getAuthorizedClientRegistrationId();
}
return null;
}
/**
* Builds a context-aware redirect URL based on the request's origin
*
* @param request The HTTP request
* @param contextPath The application context path
* @param jwt The JWT token to include
* @return The appropriate redirect URL
*/
private String buildContextAwareRedirectUrl(
HttpServletRequest request, String contextPath, String jwt) {
// Try to get the origin from the Referer header first
String referer = request.getHeader("Referer");
if (referer != null && !referer.isEmpty()) {
try {
java.net.URL refererUrl = new java.net.URL(referer);
String origin = refererUrl.getProtocol() + "://" + refererUrl.getHost();
if (refererUrl.getPort() != -1
&& refererUrl.getPort() != 80
&& refererUrl.getPort() != 443) {
origin += ":" + refererUrl.getPort();
}
return origin + "/auth/callback#access_token=" + jwt;
} catch (java.net.MalformedURLException e) {
// Fall back to other methods if referer is malformed
}
}
// Fall back to building from request host/port
String scheme = request.getScheme();
String serverName = request.getServerName();
int serverPort = request.getServerPort();
StringBuilder origin = new StringBuilder();
origin.append(scheme).append("://").append(serverName);
// Only add port if it's not the default port for the scheme
if ((!"http".equals(scheme) || serverPort != 80)
&& (!"https".equals(scheme) || serverPort != 443)) {
origin.append(":").append(serverPort);
}
return origin.toString() + "/auth/callback#access_token=" + jwt;
}
}
@@ -10,7 +10,6 @@ import java.util.List;
import java.util.Optional;
import java.util.Set;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -41,7 +40,7 @@ import stirling.software.proprietary.security.service.UserService;
@Slf4j
@Configuration
@ConditionalOnBooleanProperty("security.oauth2.enabled")
@ConditionalOnProperty(prefix = "security", name = "oauth2.enabled", havingValue = "true")
public class OAuth2Configuration {
public static final String REDIRECT_URI_PATH = "{baseUrl}/login/oauth2/code/";
@@ -53,6 +52,9 @@ public class OAuth2Configuration {
ApplicationProperties applicationProperties, @Lazy UserService userService) {
this.userService = userService;
this.applicationProperties = applicationProperties;
log.info(
"OAuth2Configuration initialized - OAuth2 enabled: {}",
applicationProperties.getSecurity().getOauth2().getEnabled());
}
@Bean
@@ -75,7 +77,7 @@ public class OAuth2Configuration {
private Optional<ClientRegistration> keycloakClientRegistration() {
OAUTH2 oauth2 = applicationProperties.getSecurity().getOauth2();
if (isOAuth2Enabled(oauth2) || isClientInitialised(oauth2)) {
if (isOAuth2Disabled(oauth2) || isClientInitialised(oauth2)) {
return Optional.empty();
}
@@ -105,7 +107,7 @@ public class OAuth2Configuration {
private Optional<ClientRegistration> googleClientRegistration() {
OAUTH2 oAuth2 = applicationProperties.getSecurity().getOauth2();
if (isOAuth2Enabled(oAuth2) || isClientInitialised(oAuth2)) {
if (isOAuth2Disabled(oAuth2) || isClientInitialised(oAuth2)) {
return Optional.empty();
}
@@ -138,12 +140,23 @@ public class OAuth2Configuration {
private Optional<ClientRegistration> githubClientRegistration() {
OAUTH2 oAuth2 = applicationProperties.getSecurity().getOauth2();
if (isOAuth2Enabled(oAuth2)) {
if (isOAuth2Disabled(oAuth2)) {
log.debug("OAuth2 is disabled, skipping GitHub client registration");
return Optional.empty();
}
Client client = oAuth2.getClient();
if (client == null) {
log.debug("OAuth2 client configuration is null, skipping GitHub");
return Optional.empty();
}
GitHubProvider githubClient = client.getGithub();
if (githubClient == null) {
log.debug("GitHub client configuration is null");
return Optional.empty();
}
Provider github =
new GitHubProvider(
githubClient.getClientId(),
@@ -151,7 +164,15 @@ public class OAuth2Configuration {
githubClient.getScopes(),
githubClient.getUseAsUsername());
return validateProvider(github)
boolean isValid = validateProvider(github);
log.info(
"GitHub OAuth2 provider validation: {} (clientId: {}, clientSecret: {}, scopes: {})",
isValid,
githubClient.getClientId(),
githubClient.getClientSecret() != null ? "***" : "null",
githubClient.getScopes());
return isValid
? Optional.of(
ClientRegistration.withRegistrationId(github.getName())
.clientId(github.getClientId())
@@ -171,7 +192,7 @@ public class OAuth2Configuration {
private Optional<ClientRegistration> oidcClientRegistration() {
OAUTH2 oauth = applicationProperties.getSecurity().getOauth2();
if (isOAuth2Enabled(oauth) || isClientInitialised(oauth)) {
if (isOAuth2Disabled(oauth) || isClientInitialised(oauth)) {
return Optional.empty();
}
@@ -207,7 +228,7 @@ public class OAuth2Configuration {
: Optional.empty();
}
private boolean isOAuth2Enabled(OAUTH2 oAuth2) {
private boolean isOAuth2Disabled(OAUTH2 oAuth2) {
return oAuth2 == null || !oAuth2.getEnabled();
}
@@ -116,13 +116,41 @@ public class CustomSaml2AuthenticationSuccessHandler
contextPath + "/login?errorOAuth=oAuth2AdminBlockedUser");
return;
}
log.debug("Processing SSO post-login for user: {}", username);
// Extract SSO provider information from SAML2 assertion
String ssoProviderId = saml2Principal.nameId();
String ssoProvider = "saml2"; // fixme
log.debug(
"Processing SSO post-login for user: {} (Provider: {}, ProviderId: {})",
username,
ssoProvider,
ssoProviderId);
userService.processSSOPostLogin(
username, saml2Properties.getAutoCreateUser(), SAML2);
username,
ssoProviderId,
ssoProvider,
saml2Properties.getAutoCreateUser(),
SAML2);
log.debug("Successfully processed authentication for user: {}", username);
generateJwt(response, authentication);
response.sendRedirect(contextPath + "/");
// Generate JWT if v2 is enabled
if (jwtService.isJwtEnabled()) {
String jwt =
jwtService.generateToken(
authentication,
Map.of("authType", AuthenticationType.SAML2));
// Build context-aware redirect URL based on the original request
String redirectUrl =
buildContextAwareRedirectUrl(request, contextPath, jwt);
response.sendRedirect(redirectUrl);
} else {
// v1: redirect directly to home
response.sendRedirect(contextPath + "/");
}
} catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) {
log.debug(
"Invalid username detected for user: {}, redirecting to logout",
@@ -136,12 +164,48 @@ public class CustomSaml2AuthenticationSuccessHandler
}
}
private void generateJwt(HttpServletResponse response, Authentication authentication) {
if (jwtService.isJwtEnabled()) {
String jwt =
jwtService.generateToken(
authentication, Map.of("authType", AuthenticationType.SAML2));
jwtService.addToken(response, jwt);
/**
* Builds a context-aware redirect URL based on the request's origin
*
* @param request The HTTP request
* @param contextPath The application context path
* @param jwt The JWT token to include
* @return The appropriate redirect URL
*/
private String buildContextAwareRedirectUrl(
HttpServletRequest request, String contextPath, String jwt) {
// Try to get the origin from the Referer header first
String referer = request.getHeader("Referer");
if (referer != null && !referer.isEmpty()) {
try {
java.net.URL refererUrl = new java.net.URL(referer);
String origin = refererUrl.getProtocol() + "://" + refererUrl.getHost();
if (refererUrl.getPort() != -1
&& refererUrl.getPort() != 80
&& refererUrl.getPort() != 443) {
origin += ":" + refererUrl.getPort();
}
return origin + "/auth/callback#access_token=" + jwt;
} catch (java.net.MalformedURLException e) {
log.debug(
"Malformed referer URL: {}, falling back to request-based origin", referer);
}
}
// Fall back to building from request host/port
String scheme = request.getScheme();
String serverName = request.getServerName();
int serverPort = request.getServerPort();
StringBuilder origin = new StringBuilder();
origin.append(scheme).append("://").append(serverName);
// Only add port if it's not the default port for the scheme
if ((!"http".equals(scheme) || serverPort != 80)
&& (!"https".equals(scheme) || serverPort != 443)) {
origin.append(":").append(serverPort);
}
return origin + "/auth/callback#access_token=" + jwt;
}
}
@@ -14,7 +14,6 @@ import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.Security.OAUTH2;
import stirling.software.common.model.enumeration.UsernameAttribute;
import stirling.software.proprietary.security.model.User;
@@ -27,13 +26,13 @@ public class CustomOAuth2UserService implements OAuth2UserService<OidcUserReques
private final LoginAttemptService loginAttemptService;
private final ApplicationProperties.Security securityProperties;
private final ApplicationProperties.Security.OAUTH2 oauth2Properties;
public CustomOAuth2UserService(
ApplicationProperties.Security securityProperties,
ApplicationProperties.Security.OAUTH2 oauth2Properties,
UserService userService,
LoginAttemptService loginAttemptService) {
this.securityProperties = securityProperties;
this.oauth2Properties = oauth2Properties;
this.userService = userService;
this.loginAttemptService = loginAttemptService;
}
@@ -42,14 +41,22 @@ public class CustomOAuth2UserService implements OAuth2UserService<OidcUserReques
public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException {
try {
OidcUser user = delegate.loadUser(userRequest);
OAUTH2 oauth2 = securityProperties.getOauth2();
UsernameAttribute usernameAttribute =
UsernameAttribute.valueOf(oauth2.getUseAsUsername().toUpperCase());
String usernameAttributeKey = usernameAttribute.getName();
String usernameAttributeKey =
UsernameAttribute.valueOf(oauth2Properties.getUseAsUsername().toUpperCase())
.getName();
// todo: save user by OIDC ID instead of username
Optional<User> internalUser =
userService.findByUsernameIgnoreCase(user.getAttribute(usernameAttributeKey));
// Extract SSO provider information
String ssoProviderId = user.getSubject(); // Standard OIDC 'sub' claim
String ssoProvider = userRequest.getClientRegistration().getRegistrationId();
String username = user.getAttribute(usernameAttributeKey);
log.debug(
"OAuth2 login - Provider: {}, ProviderId: {}, Username: {}",
ssoProvider,
ssoProviderId,
username);
Optional<User> internalUser = userService.findByUsernameIgnoreCase(username);
if (internalUser.isPresent()) {
String internalUsername = internalUser.get().getUsername();
@@ -14,14 +14,11 @@ import java.util.function.Function;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseCookie;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
import io.github.pixee.security.Newlines;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
@@ -29,9 +26,7 @@ import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.UnsupportedJwtException;
import io.jsonwebtoken.security.SignatureException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
@@ -43,13 +38,9 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrin
@Service
public class JwtService implements JwtServiceInterface {
private static final String JWT_COOKIE_NAME = "stirling_jwt";
private static final String ISSUER = "Stirling PDF";
private static final String ISSUER = "https://stirling.com";
private static final long EXPIRATION = 3600000;
@Value("${stirling.security.jwt.secureCookie:true}")
private boolean secureCookie;
private final KeyPersistenceServiceInterface keyPersistenceService;
private final boolean v2Enabled;
@@ -59,6 +50,7 @@ public class JwtService implements JwtServiceInterface {
KeyPersistenceServiceInterface keyPersistenceService) {
this.v2Enabled = v2Enabled;
this.keyPersistenceService = keyPersistenceService;
log.info("JwtService initialized");
}
@Override
@@ -260,47 +252,18 @@ public class JwtService implements JwtServiceInterface {
@Override
public String extractToken(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if (JWT_COOKIE_NAME.equals(cookie.getName())) {
return cookie.getValue();
}
}
// Extract from Authorization header Bearer token
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7); // Remove "Bearer " prefix
log.debug("JWT token extracted from Authorization header");
return token;
}
log.debug("No JWT token found in Authorization header");
return null;
}
@Override
public void addToken(HttpServletResponse response, String token) {
ResponseCookie cookie =
ResponseCookie.from(JWT_COOKIE_NAME, Newlines.stripAll(token))
.httpOnly(true)
.secure(secureCookie)
.sameSite("Strict")
.maxAge(EXPIRATION / 1000)
.path("/")
.build();
response.addHeader("Set-Cookie", cookie.toString());
}
@Override
public void clearToken(HttpServletResponse response) {
ResponseCookie cookie =
ResponseCookie.from(JWT_COOKIE_NAME, "")
.httpOnly(true)
.secure(secureCookie)
.sameSite("None")
.maxAge(0)
.path("/")
.build();
response.addHeader("Set-Cookie", cookie.toString());
}
@Override
public boolean isJwtEnabled() {
return v2Enabled;
@@ -5,7 +5,6 @@ import java.util.Map;
import org.springframework.security.core.Authentication;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
public interface JwtServiceInterface {
@@ -66,21 +65,6 @@ public interface JwtServiceInterface {
*/
String extractToken(HttpServletRequest request);
/**
* Add JWT token to HTTP response (header and cookie)
*
* @param response HTTP servlet response
* @param token JWT token to add
*/
void addToken(HttpServletResponse response, String token);
/**
* Clear JWT token from HTTP response (remove cookie)
*
* @param response HTTP servlet response
*/
void clearToken(HttpServletResponse response);
/**
* Check if JWT authentication is enabled
*
@@ -60,19 +60,46 @@ public class UserService implements UserServiceInterface {
private final ApplicationProperties.Security.OAUTH2 oAuth2;
// Handle OAUTH2 login and user auto creation.
public void processSSOPostLogin(
String username, boolean autoCreateUser, AuthenticationType type)
String username,
String ssoProviderId,
String ssoProvider,
boolean autoCreateUser,
AuthenticationType type)
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
if (!isUsernameValid(username)) {
return;
}
Optional<User> existingUser = findByUsernameIgnoreCase(username);
// Find user by SSO provider ID first
Optional<User> existingUser;
if (ssoProviderId != null && ssoProvider != null) {
existingUser =
userRepository.findBySsoProviderAndSsoProviderId(ssoProvider, ssoProviderId);
if (existingUser.isPresent()) {
log.debug("User found by SSO provider ID: {}", ssoProviderId);
return;
}
}
existingUser = findByUsernameIgnoreCase(username);
if (existingUser.isPresent()) {
User user = existingUser.get();
// Migrate existing user to use provider ID if not already set
if (user.getSsoProviderId() == null && ssoProviderId != null && ssoProvider != null) {
log.info("Migrating user {} to use SSO provider ID: {}", username, ssoProviderId);
user.setSsoProviderId(ssoProviderId);
user.setSsoProvider(ssoProvider);
userRepository.save(user);
databaseService.exportDatabase();
}
return;
}
if (autoCreateUser) {
saveUser(username, type);
saveUser(username, ssoProviderId, ssoProvider, type);
}
}
@@ -154,6 +181,21 @@ public class UserService implements UserServiceInterface {
saveUser(username, authenticationType, (Long) null, Role.USER.getRoleId());
}
public void saveUser(
String username,
String ssoProviderId,
String ssoProvider,
AuthenticationType authenticationType)
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
saveUser(
username,
ssoProviderId,
ssoProvider,
authenticationType,
(Long) null,
Role.USER.getRoleId());
}
private User saveUser(Optional<User> user, String apiKey) {
if (user.isPresent()) {
user.get().setApiKey(apiKey);
@@ -168,6 +210,30 @@ public class UserService implements UserServiceInterface {
return saveUserCore(
username, // username
null, // password
null, // ssoProviderId
null, // ssoProvider
authenticationType, // authenticationType
teamId, // teamId
null, // team
role, // role
false, // firstLogin
true // enabled
);
}
public User saveUser(
String username,
String ssoProviderId,
String ssoProvider,
AuthenticationType authenticationType,
Long teamId,
String role)
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
return saveUserCore(
username, // username
null, // password
ssoProviderId, // ssoProviderId
ssoProvider, // ssoProvider
authenticationType, // authenticationType
teamId, // teamId
null, // team
@@ -183,6 +249,8 @@ public class UserService implements UserServiceInterface {
return saveUserCore(
username, // username
null, // password
null, // ssoProviderId
null, // ssoProvider
authenticationType, // authenticationType
null, // teamId
team, // team
@@ -197,6 +265,8 @@ public class UserService implements UserServiceInterface {
return saveUserCore(
username, // username
password, // password
null, // ssoProviderId
null, // ssoProvider
AuthenticationType.WEB, // authenticationType
teamId, // teamId
null, // team
@@ -212,6 +282,8 @@ public class UserService implements UserServiceInterface {
return saveUserCore(
username, // username
password, // password
null, // ssoProviderId
null, // ssoProvider
AuthenticationType.WEB, // authenticationType
null, // teamId
team, // team
@@ -227,6 +299,8 @@ public class UserService implements UserServiceInterface {
return saveUserCore(
username, // username
password, // password
null, // ssoProviderId
null, // ssoProvider
AuthenticationType.WEB, // authenticationType
teamId, // teamId
null, // team
@@ -247,6 +321,8 @@ public class UserService implements UserServiceInterface {
saveUserCore(
username, // username
password, // password
null, // ssoProviderId
null, // ssoProvider
AuthenticationType.WEB, // authenticationType
teamId, // teamId
null, // team
@@ -411,6 +487,8 @@ public class UserService implements UserServiceInterface {
*
* @param username Username for the new user
* @param password Password for the user (may be null for SSO/OAuth users)
* @param ssoProviderId Unique identifier from SSO provider (may be null for non-SSO users)
* @param ssoProvider Name of the SSO provider (may be null for non-SSO users)
* @param authenticationType Type of authentication (WEB, SSO, etc.)
* @param teamId ID of the team to assign (may be null to use default)
* @param team Team object to assign (takes precedence over teamId if both provided)
@@ -425,6 +503,8 @@ public class UserService implements UserServiceInterface {
private User saveUserCore(
String username,
String password,
String ssoProviderId,
String ssoProvider,
AuthenticationType authenticationType,
Long teamId,
Team team,
@@ -445,6 +525,12 @@ public class UserService implements UserServiceInterface {
user.setPassword(passwordEncoder.encode(password));
}
// Set SSO provider details if provided
if (ssoProviderId != null && ssoProvider != null) {
user.setSsoProviderId(ssoProviderId);
user.setSsoProvider(ssoProvider);
}
// Set authentication type
user.setAuthenticationType(authenticationType);
@@ -1,6 +1,8 @@
package stirling.software.proprietary.security;
import static org.mockito.Mockito.*;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.IOException;
@@ -38,7 +40,6 @@ class CustomLogoutSuccessHandlerTest {
when(response.isCommitted()).thenReturn(false);
when(jwtService.extractToken(request)).thenReturn(token);
doNothing().when(jwtService).clearToken(response);
when(request.getContextPath()).thenReturn("");
when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath);
@@ -56,14 +57,12 @@ class CustomLogoutSuccessHandlerTest {
when(response.isCommitted()).thenReturn(false);
when(jwtService.extractToken(request)).thenReturn(token);
doNothing().when(jwtService).clearToken(response);
when(request.getContextPath()).thenReturn("");
when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath);
customLogoutSuccessHandler.onLogoutSuccess(request, response, null);
verify(response).sendRedirect(logoutPath);
verify(jwtService).clearToken(response);
}
@Test
@@ -127,7 +127,6 @@ class JwtAuthenticationFilterTest {
.setAuthentication(any(UsernamePasswordAuthenticationToken.class));
verify(jwtService)
.generateToken(any(UsernamePasswordAuthenticationToken.class), eq(claims));
verify(jwtService).addToken(response, newToken);
verify(filterChain).doFilter(request, response);
}
}
@@ -8,8 +8,6 @@ import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.atLeast;
import static org.mockito.Mockito.contains;
import static org.mockito.Mockito.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
@@ -17,7 +15,6 @@ import static org.mockito.Mockito.when;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import java.util.Collections;
import java.util.HashMap;
@@ -27,13 +24,10 @@ import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.core.Authentication;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@@ -59,7 +53,7 @@ class JwtServiceTest {
private JwtVerificationKey testVerificationKey;
@BeforeEach
void setUp() throws NoSuchAlgorithmException {
void setUp() throws Exception {
// Generate a test keypair
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(2048);
@@ -224,7 +218,8 @@ class JwtServiceTest {
assertEquals("admin", extractedClaims.get("role"));
assertEquals("IT", extractedClaims.get("department"));
assertEquals(username, extractedClaims.get("sub"));
assertEquals("Stirling PDF", extractedClaims.get("iss"));
// Verify the constant issuer is set correctly
assertEquals("https://stirling.com", extractedClaims.get("iss"));
}
@Test
@@ -239,62 +234,27 @@ class JwtServiceTest {
}
@Test
void testExtractTokenWithCookie() {
void testExtractTokenWithAuthorizationHeader() {
String token = "test-token";
Cookie[] cookies = {new Cookie("stirling_jwt", token)};
when(request.getCookies()).thenReturn(cookies);
when(request.getHeader("Authorization")).thenReturn("Bearer " + token);
assertEquals(token, jwtService.extractToken(request));
}
@Test
void testExtractTokenWithNoCookies() {
when(request.getCookies()).thenReturn(null);
void testExtractTokenWithNoAuthorizationHeader() {
when(request.getHeader("Authorization")).thenReturn(null);
assertNull(jwtService.extractToken(request));
}
@Test
void testExtractTokenWithWrongCookie() {
Cookie[] cookies = {new Cookie("OTHER_COOKIE", "value")};
when(request.getCookies()).thenReturn(cookies);
void testExtractTokenWithInvalidAuthorizationHeaderFormat() {
when(request.getHeader("Authorization")).thenReturn("InvalidFormat token");
assertNull(jwtService.extractToken(request));
}
@Test
void testExtractTokenWithInvalidAuthorizationHeader() {
when(request.getCookies()).thenReturn(null);
assertNull(jwtService.extractToken(request));
}
@ParameterizedTest
@ValueSource(booleans = {true, false})
void testAddToken(boolean secureCookie) throws Exception {
String token = "test-token";
// Create new JwtService instance with the secureCookie parameter
JwtService testJwtService = createJwtServiceWithSecureCookie(secureCookie);
testJwtService.addToken(response, token);
verify(response).addHeader(eq("Set-Cookie"), contains("stirling_jwt=" + token));
verify(response).addHeader(eq("Set-Cookie"), contains("HttpOnly"));
if (secureCookie) {
verify(response).addHeader(eq("Set-Cookie"), contains("Secure"));
}
}
@Test
void testClearToken() {
jwtService.clearToken(response);
verify(response).addHeader(eq("Set-Cookie"), contains("stirling_jwt="));
verify(response).addHeader(eq("Set-Cookie"), contains("Max-Age=0"));
}
@Test
void testGenerateTokenWithKeyId() throws Exception {
String username = "testuser";
@@ -373,17 +333,4 @@ class JwtServiceTest {
// Verify fallback logic was used
verify(keystoreService, atLeast(1)).getActiveKey();
}
private JwtService createJwtServiceWithSecureCookie(boolean secureCookie) throws Exception {
// Use reflection to create JwtService with custom secureCookie value
JwtService testService = new JwtService(true, keystoreService);
// Set the secureCookie field using reflection
java.lang.reflect.Field secureCookieField =
JwtService.class.getDeclaredField("secureCookie");
secureCookieField.setAccessible(true);
secureCookieField.set(testService, secureCookie);
return testService;
}
}
+49 -20
View File
@@ -1,9 +1,13 @@
// @ts-check
import eslint from '@eslint/js';
import globals from "globals";
import globals from 'globals';
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint';
// @ts-expect-error - plugin has no types
import reactHooks from 'eslint-plugin-react-hooks';
// @ts-expect-error - plugin has no types
import importPlugin from 'eslint-plugin-import';
const srcGlobs = [
'src/**/*.{js,mjs,jsx,ts,tsx}',
@@ -14,35 +18,43 @@ const nodeGlobs = [
];
export default defineConfig(
{
// Everything that contains 3rd party code that we don't want to lint
ignores: [
'dist',
'node_modules',
'public',
],
},
eslint.configs.recommended,
tseslint.configs.recommended,
{
ignores: [
"dist", // Contains 3rd party code
"public", // Contains 3rd party code
],
},
{
plugins: {
'react-hooks': reactHooks,
},
rules: {
"@typescript-eslint/no-empty-object-type": [
"error",
// React Hooks rules
...reactHooks.configs.recommended.rules,
// TypeScript rules
'@typescript-eslint/no-empty-object-type': [
'error',
{
// Allow empty extending interfaces because there's no real reason not to, and it makes it obvious where to put extra attributes in the future
allowInterfaces: 'with-single-extends',
},
],
"@typescript-eslint/no-explicit-any": "off", // Temporarily disabled until codebase conformant
"@typescript-eslint/no-require-imports": "off", // Temporarily disabled until codebase conformant
"@typescript-eslint/no-unused-vars": [
"error",
'@typescript-eslint/no-explicit-any': 'off', // Temporarily disabled until codebase conformant
'@typescript-eslint/no-require-imports': 'off', // Temporarily disabled until codebase conformant
'@typescript-eslint/no-unused-vars': [
'error',
{
"args": "all", // All function args must be used (or explicitly ignored)
"argsIgnorePattern": "^_", // Allow unused variables beginning with an underscore
"caughtErrors": "all", // Caught errors must be used (or explicitly ignored)
"caughtErrorsIgnorePattern": "^_", // Allow unused variables beginning with an underscore
"destructuredArrayIgnorePattern": "^_", // Allow unused variables beginning with an underscore
"varsIgnorePattern": "^_", // Allow unused variables beginning with an underscore
"ignoreRestSiblings": true, // Allow unused variables when removing attributes from objects (otherwise this requires explicit renaming like `({ x: _x, ...y }) => y`, which is clunky)
args: 'all', // All function args must be used (or explicitly ignored)
argsIgnorePattern: '^_', // Allow unused variables beginning with an underscore
caughtErrors: 'all', // Caught errors must be used (or explicitly ignored)
caughtErrorsIgnorePattern: '^_', // Allow unused variables beginning with an underscore
destructuredArrayIgnorePattern: '^_', // Allow unused variables beginning with an underscore
varsIgnorePattern: '^_', // Allow unused variables beginning with an underscore
ignoreRestSiblings: true, // Allow unused variables when removing attributes from objects (otherwise this requires explicit renaming like `({ x: _x, ...y }) => y`, which is clunky)
},
],
},
@@ -65,4 +77,21 @@ export default defineConfig(
}
}
},
// Config for import plugin
{
...importPlugin.flatConfigs.recommended,
...importPlugin.flatConfigs.typescript,
rules: {
// ...importPlugin.flatConfigs.recommended.rules, // Temporarily disabled until codebase conformant
...importPlugin.flatConfigs.typescript.rules,
'import/no-cycle': 'error',
},
settings: {
'import/resolver': {
typescript: {
project: './tsconfig.json',
},
},
},
},
);
+3074 -127
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -6,6 +6,7 @@
"proxy": "http://localhost:8080",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
"@dnd-kit/core": "^6.3.1",
"@embedpdf/core": "^1.3.1",
"@embedpdf/engines": "^1.3.1",
"@embedpdf/plugin-annotation": "^1.3.1",
@@ -33,6 +34,7 @@
"@mantine/hooks": "^8.3.1",
"@mui/icons-material": "^7.3.2",
"@mui/material": "^7.3.2",
"@reactour/tour": "^3.8.0",
"@tailwindcss/postcss": "^4.1.13",
"@tanstack/react-virtual": "^3.13.12",
"autoprefixer": "^10.4.21",
@@ -66,6 +68,7 @@
"generate-licenses": "node scripts/generate-licenses.js",
"generate-icons": "node scripts/generate-icons.js",
"generate-icons:verbose": "node scripts/generate-icons.js --verbose",
"generate-sample-pdf": "node scripts/sample-pdf/generate.mjs",
"test": "vitest",
"test:run": "vitest run",
"test:watch": "vitest --watch",
@@ -119,6 +122,8 @@
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import": "^2.32.0",
"jsdom": "^27.0.0",
"license-checker": "^25.0.1",
"madge": "^8.0.0",
@@ -126,6 +131,7 @@
"postcss-cli": "^11.0.1",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"puppeteer": "^24.25.0",
"typescript": "^5.9.2",
"typescript-eslint": "^8.44.1",
"vite": "^7.1.7",
Binary file not shown.

After

Width:  |  Height:  |  Size: 717 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/>
</svg>

After

Width:  |  Height:  |  Size: 426 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 23" fill="none">
<path d="M0 0h10.5v10.5H0V0z" fill="#F25022"/>
<path d="M12.5 0H23v10.5H12.5V0z" fill="#7FBA00"/>
<path d="M0 12.5h10.5V23H0V12.5z" fill="#00A4EF"/>
<path d="M12.5 12.5H23V23H12.5V12.5z" fill="#FFB900"/>
</svg>

After

Width:  |  Height:  |  Size: 292 B

+3
View File
@@ -0,0 +1,3 @@
<svg width="37" height="36" viewBox="0 0 37 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.5 3.3125C16.5712 3.3125 14.6613 3.6924 12.8793 4.43052C11.0974 5.16864 9.47823 6.25051 8.11437 7.61437C5.35993 10.3688 3.8125 14.1046 3.8125 18C3.8125 24.4919 8.02781 29.9997 13.8588 31.9531C14.5931 32.0706 14.8281 31.6153 14.8281 31.2187V28.7366C10.7597 29.6178 9.89312 26.7684 9.89312 26.7684C9.2175 25.0647 8.26281 24.6094 8.26281 24.6094C6.92625 23.6987 8.36562 23.7281 8.36562 23.7281C9.83437 23.8309 10.6128 25.2409 10.6128 25.2409C11.8906 27.4734 14.0497 26.8125 14.8869 26.46C15.0191 25.5053 15.4009 24.8591 15.8122 24.4919C12.5516 24.1247 9.12937 22.8616 9.12937 17.2656C9.12937 15.6353 9.6875 14.3281 10.6422 13.2853C10.4953 12.9181 9.98125 11.3906 10.7891 9.40781C10.7891 9.40781 12.0228 9.01125 14.8281 10.9059C15.9884 10.5828 17.2516 10.4212 18.5 10.4212C19.7484 10.4212 21.0116 10.5828 22.1719 10.9059C24.9772 9.01125 26.2109 9.40781 26.2109 9.40781C27.0188 11.3906 26.5047 12.9181 26.3578 13.2853C27.3125 14.3281 27.8706 15.6353 27.8706 17.2656C27.8706 22.8762 24.4338 24.11 21.1584 24.4772C21.6872 24.9325 22.1719 25.8284 22.1719 27.1944V31.2187C22.1719 31.6153 22.4069 32.0853 23.1559 31.9531C28.9869 29.985 33.1875 24.4919 33.1875 18C33.1875 16.0712 32.8076 14.1613 32.0695 12.3793C31.3314 10.5974 30.2495 8.97823 28.8856 7.61437C27.5218 6.25051 25.9026 5.16864 24.1207 4.43052C22.3387 3.6924 20.4288 3.3125 18.5 3.3125Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+14
View File
@@ -0,0 +1,14 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2781_85129)">
<path d="M8.36055 0.789432C5.96258 1.62131 3.89457 3.20024 2.46029 5.29431C1.026 7.38838 0.301037 9.8872 0.391883 12.4237C0.482728 14.9603 1.38459 17.4008 2.96501 19.3869C4.54543 21.373 6.72109 22.8 9.17243 23.4582C11.1598 23.971 13.2419 23.9935 15.2399 23.5238C17.0499 23.1172 18.7233 22.2476 20.0962 21.0001C21.5251 19.662 22.5622 17.9597 23.0962 16.0763C23.6765 14.0282 23.7798 11.8743 23.3981 9.78006H12.2381V14.4094H18.7012C18.572 15.1478 18.2952 15.8525 17.8873 16.4814C17.4795 17.1102 16.9489 17.6504 16.3274 18.0694C15.5382 18.5915 14.6485 18.9428 13.7156 19.1007C12.7798 19.2747 11.82 19.2747 10.8843 19.1007C9.93591 18.9046 9.03874 18.5132 8.24993 17.9513C6.98271 17.0543 6.0312 15.7799 5.53118 14.3101C5.02271 12.8127 5.02271 11.1893 5.53118 9.69193C5.8871 8.64234 6.47549 7.68669 7.25243 6.89631C8.14154 5.97521 9.26718 5.3168 10.5058 4.99332C11.7445 4.66985 13.0484 4.6938 14.2743 5.06256C15.232 5.35654 16.1078 5.87019 16.8318 6.56256C17.5606 5.83756 18.2881 5.11068 19.0143 4.38193C19.3893 3.99006 19.7981 3.61693 20.1674 3.21568C19.0622 2.1872 17.765 1.38691 16.3499 0.860682C13.7731 -0.0749615 10.9536 -0.100106 8.36055 0.789432Z" fill="white"/>
<path d="M8.3607 0.789367C10.9536 -0.100776 13.7731 -0.0762934 16.3501 0.858742C17.7654 1.38855 19.062 2.19269 20.1657 3.22499C19.7907 3.62624 19.3951 4.00124 19.0126 4.39124C18.2851 5.11749 17.5582 5.84124 16.832 6.56249C16.1079 5.87012 15.2321 5.35648 14.2745 5.06249C13.0489 4.69244 11.7451 4.66711 10.5061 4.98926C9.26712 5.31141 8.14079 5.96861 7.2507 6.88874C6.47377 7.67912 5.88538 8.63477 5.52945 9.68437L1.64258 6.67499C3.03384 3.91604 5.44273 1.80566 8.3607 0.789367Z" fill="#E33629"/>
<path d="M0.611401 9.65654C0.820316 8.62116 1.16716 7.61847 1.64265 6.67529L5.52953 9.69217C5.02105 11.1896 5.02105 12.8129 5.52953 14.3103C4.23453 15.3103 2.9389 16.3153 1.64265 17.3253C0.452308 14.9559 0.0892746 12.2562 0.611401 9.65654Z" fill="#F8BD00"/>
<path d="M12.2381 9.77783H23.3981C23.7799 11.8721 23.6766 14.026 23.0963 16.0741C22.5623 17.9575 21.5252 19.6597 20.0963 20.9978C18.8419 20.0191 17.5819 19.0478 16.3275 18.0691C16.9494 17.6496 17.4802 17.1089 17.8881 16.4793C18.296 15.8498 18.5726 15.1444 18.7013 14.4053H12.2381C12.2363 12.8641 12.2381 11.321 12.2381 9.77783Z" fill="#587DBD"/>
<path d="M1.64062 17.3251C2.93687 16.3251 4.2325 15.3201 5.5275 14.3101C6.02851 15.7804 6.98138 17.0549 8.25 17.9513C9.04126 18.5106 9.94037 18.8988 10.89 19.0913C11.8257 19.2653 12.7855 19.2653 13.7212 19.0913C14.6542 18.9334 15.5439 18.5821 16.3331 18.0601C17.5875 19.0388 18.8475 20.0101 20.1019 20.9888C18.7292 22.237 17.0558 23.1073 15.2456 23.5144C13.2476 23.9841 11.1655 23.9616 9.17812 23.4488C7.60632 23.0291 6.13814 22.2893 4.86562 21.2757C3.51874 20.2063 2.41867 18.8588 1.64062 17.3251Z" fill="#319F43"/>
</g>
<defs>
<clipPath id="clip0_2781_85129">
<rect width="24" height="24" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 23" fill="none">
<path d="M0 0h10.5v10.5H0V0z" fill="#F25022"/>
<path d="M12.5 0H23v10.5H12.5V0z" fill="#7FBA00"/>
<path d="M0 12.5h10.5V23H0V12.5z" fill="#00A4EF"/>
<path d="M12.5 12.5H23V23H12.5V12.5z" fill="#FFB900"/>
</svg>

After

Width:  |  Height:  |  Size: 292 B

+487 -60
View File
@@ -13,7 +13,19 @@
"dismiss": "Maybe later"
},
"fullscreen": {
"showDetails": "Show Details"
"showDetails": "Show Details",
"comingSoon": "Coming soon:",
"favorite": "Add to favourites",
"favorites": "Favourites",
"heading": "All tools (fullscreen view)",
"noResults": "Try adjusting your search or toggle descriptions to find what you need.",
"recommended": "Recommended",
"unfavorite": "Remove from favourites"
},
"placeholder": "Choose a tool to get started",
"toggle": {
"fullscreen": "Switch to fullscreen mode",
"sidebar": "Switch to sidebar mode"
}
},
"unsavedChanges": "You have unsaved changes to your PDF.",
@@ -56,7 +68,7 @@
"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.",
"pageSelectionPrompt": "Custom Page Selection (Enter a comma-separated list of page numbers 1,5,6 or Functions like 2n+1)",
"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.",
@@ -72,7 +84,6 @@
"uploadLimitExceededPlural": "are too large. Maximum allowed size is",
"processTimeWarning": "Warning: This process can take up to a minute depending on file-size",
"pageOrderPrompt": "Custom Page Order (Enter a comma-separated list of page numbers or Functions like 2n+1) :",
"pageSelectionPrompt": "Custom Page Selection (Enter a comma-separated list of page numbers 1,5,6 or Functions like 2n+1) :",
"goToPage": "Go",
"true": "True",
"false": "False",
@@ -83,13 +94,18 @@
"save": "Save",
"saveToBrowser": "Save to Browser",
"download": "Download",
"pin": "Pin",
"unpin": "Unpin",
"pin": "Pin File (keep active after tool run)",
"unpin": "Unpin File (replace after tool run)",
"undoOperationTooltip": "Click to undo the last operation and restore the original files",
"undo": "Undo",
"moreOptions": "More Options",
"editYourNewFiles": "Edit your new file(s)",
"close": "Close",
"openInViewer": "Open in Viewer",
"confirmClose": "Confirm Close",
"confirmCloseMessage": "Are you sure you want to close this file?",
"confirmCloseCancel": "Cancel",
"confirmCloseConfirm": "Close File",
"fileSelected": "Selected: {{filename}}",
"chooseFile": "Choose File",
"filesSelected": "{{count}} files selected",
@@ -99,7 +115,9 @@
"uploadFiles": "Upload Files",
"addFiles": "Add files",
"selectFromWorkbench": "Select files from the workbench or ",
"selectMultipleFromWorkbench": "Select at least {{count}} files from the workbench or "
"selectMultipleFromWorkbench": "Select at least {{count}} files from the workbench or ",
"created": "Created",
"size": "File Size"
},
"noFavourites": "No favourites added",
"downloadComplete": "Download Complete",
@@ -284,7 +302,13 @@
"autoUnzipTooltip": "Automatically extract ZIP files returned from API operations. Disable to keep ZIP files intact. This does not affect automation workflows.",
"autoUnzipFileLimit": "Auto-unzip file limit",
"autoUnzipFileLimitDescription": "Maximum number of files to extract from ZIP",
"autoUnzipFileLimitTooltip": "Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs."
"autoUnzipFileLimitTooltip": "Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs.",
"defaultToolPickerMode": "Default tool picker mode",
"defaultToolPickerModeDescription": "Choose whether the tool picker opens in fullscreen or sidebar by default",
"mode": {
"fullscreen": "Fullscreen",
"sidebar": "Sidebar"
}
},
"hotkeys": {
"title": "Keyboard Shortcuts",
@@ -301,7 +325,8 @@
"change": "Change shortcut",
"reset": "Reset",
"shortcut": "Shortcut",
"noShortcut": "No shortcut set"
"noShortcut": "No shortcut set",
"searchPlaceholder": "Search tools..."
}
},
"changeCreds": {
@@ -430,6 +455,9 @@
"alphabetical": "Alphabetical",
"globalPopularity": "Global Popularity",
"sortBy": "Sort by:",
"mobile": {
"brandAlt": "Stirling PDF logo"
},
"multiTool": {
"tags": "multiple,tools",
"title": "PDF Multi Tool",
@@ -660,9 +688,9 @@
"title": "Manage Certificates",
"desc": "Import, export, or delete digital certificate files used for signing PDFs."
},
"read": {
"tags": "view,open,display",
"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": {
@@ -719,6 +747,20 @@
"tags": "workflow,sequence,automation",
"title": "Automate",
"desc": "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks."
},
"mobile": {
"brandAlt": "Stirling PDF logo",
"openFiles": "Open files",
"swipeHint": "Swipe left or right to switch views",
"tools": "Tools",
"toolsSlide": "Tool selection panel",
"viewSwitcher": "Switch workspace view",
"workbenchSlide": "Workspace panel",
"workspace": "Workspace"
},
"overlay-pdfs": {
"desc": "Overlay one PDF on top of another",
"title": "Overlay PDFs"
}
},
"landing": {
@@ -904,13 +946,51 @@
"bullet1": "Bookmark Level: Which level to split on (1=top level)",
"bullet2": "Include Metadata: Preserve document properties",
"bullet3": "Allow Duplicates: Handle repeated bookmark names"
},
"byDocCount": {
"bullet1": "Enter the number of output files you want",
"bullet2": "Pages are distributed as evenly as possible",
"bullet3": "Useful when you need a specific number of files",
"text": "Create a specific number of output files by evenly distributing pages across them.",
"title": "Split by Document Count"
},
"byPageCount": {
"bullet1": "Enter the number of pages per output file",
"bullet2": "Last file may have fewer pages if not evenly divisible",
"bullet3": "Useful for batch processing workflows",
"text": "Create multiple PDFs with a specific number of pages each. Perfect for creating uniform document chunks.",
"title": "Split by Page Count"
},
"byPageDivider": {
"bullet1": "Print divider sheets from the download link",
"bullet2": "Insert divider sheets between your documents",
"bullet3": "Scan all documents together as one PDF",
"bullet4": "Upload - divider pages are automatically detected and removed",
"bullet5": "Enable Duplex Mode if scanning both sides of divider sheets",
"text": "Automatically split scanned documents using physical divider sheets with QR codes. Perfect for processing multiple documents scanned together.",
"title": "Split by Page Divider"
}
}
},
"methodSelection": {
"tooltip": {
"bullet1": "Click on a method card to select it",
"bullet2": "Hover over each card to see a quick description",
"bullet3": "The settings step will appear after you select a method",
"bullet4": "You can change methods at any time before processing",
"header": {
"text": "Choose how you want to split your PDF document. Each method is optimized for different use cases and document types.",
"title": "Split Method Selection"
},
"title": "Choose Your Split Method"
}
},
"selectMethod": "Select a split method",
"resultsTitle": "Split Results"
},
"rotate": {
"title": "Rotate PDF",
"submit": "Apply Rotation",
"selectRotation": "Select Rotation Angle (Clockwise)",
"selectRotation": "Select Rotation Angle (Clockwise)",
"error": {
"failed": "An error occurred while rotating the PDF."
},
@@ -998,7 +1078,8 @@
"imagesExt": "Images (JPG, PNG, etc.)",
"markdown": "Markdown",
"textRtf": "Text/RTF",
"grayscale": "Greyscale"
"grayscale": "Greyscale",
"errorConversion": "An error occurred while converting the file."
},
"imageToPdf": {
"tags": "conversion,img,jpg,picture,photo"
@@ -1025,7 +1106,7 @@
"header": "PDF Page Organiser",
"submit": "Rearrange Pages",
"mode": {
"_value": "Organization mode",
"_value": "Organisation mode",
"1": "Custom Page Order",
"2": "Reverse Order",
"3": "Duplex Sort",
@@ -1036,7 +1117,20 @@
"8": "Remove Last",
"9": "Remove First and Last",
"10": "Odd-Even Merge",
"11": "Duplicate all pages"
"11": "Duplicate all pages",
"desc": {
"BOOKLET_SORT": "Arrange pages for booklet printing (last, first, second, second last, …).",
"CUSTOM": "Use a custom sequence of page numbers or expressions to define a new order.",
"DUPLEX_SORT": "Interleave fronts then backs as if a duplex scanner scanned all fronts, then all backs (1, n, 2, n-1, …).",
"DUPLICATE": "Duplicate each page according to the custom order count (e.g., 4 duplicates each page 4×).",
"ODD_EVEN_MERGE": "Merge two PDFs by alternating pages: odd from the first, even from the second.",
"ODD_EVEN_SPLIT": "Split the document into two outputs: all odd pages and all even pages.",
"REMOVE_FIRST": "Remove the first page from the document.",
"REMOVE_FIRST_AND_LAST": "Remove both the first and last pages from the document.",
"REMOVE_LAST": "Remove the last page from the document.",
"REVERSE_ORDER": "Flip the document so the last page becomes first and so on.",
"SIDE_STITCH_BOOKLET_SORT": "Arrange pages for sidestitch booklet printing (optimized for binding on the side)."
}
},
"desc": {
"CUSTOM": "Use a custom sequence of page numbers or expressions to define a new order.",
@@ -1102,7 +1196,9 @@
"opacity": "Opacity (%)",
"spacing": {
"horizontal": "Horizontal Spacing",
"vertical": "Vertical Spacing"
"vertical": "Vertical Spacing",
"height": "Height Spacing",
"width": "Width Spacing"
},
"convertToImage": "Flatten PDF pages to images"
},
@@ -1245,6 +1341,10 @@
"bullet4": "Best for sensitive or copyrighted content"
}
}
},
"type": {
"1": "Text",
"2": "Image"
}
},
"permissions": {
@@ -1358,6 +1458,38 @@
},
"examples": {
"title": "Examples"
},
"complex": {
"bullet1": "<strong>1,3-5,8,2n</strong> → pages 1, 35, 8, plus evens",
"bullet2": "<strong>10-,2n-1</strong> → from page 10 to end + odd pages",
"description": "Mix different types.",
"title": "Complex Combinations"
},
"description": "Choose which pages to use for the operation. Supports single pages, ranges, formulas, and the all keyword.",
"individual": {
"bullet1": "<strong>1,3,5</strong> → selects pages 1, 3, 5",
"bullet2": "<strong>2,7,12</strong> → selects pages 2, 7, 12",
"description": "Enter numbers separated by commas.",
"title": "Individual Pages"
},
"mathematical": {
"bullet1": "<strong>2n</strong> → all even pages (2, 4, 6…)",
"bullet2": "<strong>2n-1</strong> → all odd pages (1, 3, 5…)",
"bullet3": "<strong>3n</strong> → every 3rd page (3, 6, 9…)",
"bullet4": "<strong>4n-1</strong> → pages 3, 7, 11, 15…",
"description": "Use n in formulas for patterns.",
"title": "Mathematical Functions"
},
"ranges": {
"bullet1": "<strong>3-6</strong> → selects pages 36",
"bullet2": "<strong>10-15</strong> → selects pages 1015",
"bullet3": "<strong>5-</strong> → selects pages 5 to end",
"description": "Use - for consecutive pages.",
"title": "Page Ranges"
},
"special": {
"bullet1": "<strong>all</strong> → selects all pages",
"title": "Special Keywords"
}
}
},
@@ -1667,6 +1799,9 @@
"text": "Post-processes the final PDF by removing OCR artefacts and optimising the text layer for better readability and smaller file size."
}
}
},
"error": {
"failed": "OCR operation failed"
}
},
"extractImages": {
@@ -1787,8 +1922,14 @@
"title": "Sign",
"header": "Sign PDFs",
"upload": "Upload Image",
"draw": "Draw Signature",
"text": "Text Input",
"draw": {
"title": "Draw your signature",
"clear": "Clear"
},
"text": {
"name": "Signer Name",
"placeholder": "Enter your full name"
},
"clear": "Clear",
"add": "Add",
"saved": "Saved Signatures",
@@ -1817,19 +1958,11 @@
"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",
"canvas": "After drawing your signature in the canvas, close the modal then click anywhere on the PDF to place it.",
@@ -1956,7 +2089,13 @@
"bullet3": "Can be disabled to reduce output file size"
}
},
"submit": "Remove blank pages"
"submit": "Remove blank pages",
"error": {
"failed": "Failed to remove blank pages"
},
"results": {
"title": "Removed Blank Pages"
}
},
"removeAnnotations": {
"tags": "comments,highlight,notes,markup,remove",
@@ -2058,7 +2197,12 @@
"bullet3": "Choose which page to place the signature",
"bullet4": "Optional logo can be included"
}
}
},
"invisible": "Invisible",
"options": {
"title": "Signature Details"
},
"visible": "Visible"
},
"sign": {
"submit": "Sign PDF",
@@ -2119,7 +2263,22 @@
"text": "Convert your file to a Java keystore (.jks) with keytool, then pick JKS."
}
}
}
},
"chooseCertificate": "Choose Certificate File",
"chooseJksFile": "Choose JKS File",
"chooseP12File": "Choose PKCS12 File",
"choosePfxFile": "Choose PFX File",
"choosePrivateKey": "Choose Private Key File",
"location": "Location",
"logoTitle": "Logo",
"name": "Name",
"noLogo": "No Logo",
"pageNumber": "Page Number",
"password": "Certificate Password",
"passwordOptional": "Leave empty if no password",
"reason": "Reason",
"serverCertMessage": "Using server certificate - no files or password required",
"showLogo": "Show Logo"
},
"removeCertSign": {
"tags": "authenticate,PEM,P12,official,decrypt",
@@ -2145,7 +2304,17 @@
"header": "Multi Page Layout",
"pagesPerSheet": "Pages per sheet:",
"addBorder": "Add Borders",
"submit": "Submit"
"submit": "Submit",
"desc": {
"2": "Place 2 pages side-by-side on a single sheet.",
"3": "Place 3 pages on a single sheet in a single row.",
"4": "Place 4 pages on a single sheet (2 × 2 grid).",
"9": "Place 9 pages on a single sheet (3 × 3 grid).",
"16": "Place 16 pages on a single sheet (4 × 4 grid)."
},
"error": {
"failed": "An error occurred while creating the multi-page layout."
}
},
"bookletImposition": {
"tags": "booklet,imposition,printing,binding,folding,signature",
@@ -2331,10 +2500,22 @@
"reset": "Reset to full PDF",
"coordinates": {
"title": "Position and Size",
"x": "X Position",
"y": "Y Position",
"width": "Width",
"height": "Height"
"x": {
"label": "X Position",
"desc": "Left edge (points)"
},
"y": {
"label": "Y Position",
"desc": "Bottom edge (points)"
},
"width": {
"label": "Width",
"desc": "Crop width (points)"
},
"height": {
"label": "Height",
"desc": "Crop height (points)"
}
},
"error": {
"invalidArea": "Crop area extends beyond PDF boundaries",
@@ -2352,6 +2533,10 @@
},
"results": {
"title": "Crop Results"
},
"automation": {
"info": "Enter crop coordinates in PDF points. Origin (0,0) is at bottom-left. These values will be applied to all PDFs processed in this automation.",
"reference": "Reference: A4 page is 595.28 × 841.89 points (210mm × 297mm). 1 inch = 72 points."
}
},
"autoSplitPDF": {
@@ -2581,7 +2766,8 @@
"counts": {
"label": "Overlay Counts (for Fixed Repeat Mode)",
"placeholder": "Enter comma-separated counts (e.g., 2,3,1)",
"item": "Count for file"
"item": "Count for file",
"noFiles": "Add overlay files to configure counts"
},
"position": {
"label": "Select Overlay Position",
@@ -2622,6 +2808,9 @@
"title": "Counts (Fixed Repeat only)",
"text": "Provide a positive number for each overlay file showing how many pages to take before moving to the next. Required when mode is Fixed Repeat."
}
},
"error": {
"failed": "An error occurred while overlaying PDFs."
}
},
"split-by-sections": {
@@ -2657,7 +2846,18 @@
"customMargin": "Custom Margin",
"customColor": "Custom Text Colour",
"submit": "Submit",
"noStampSelected": "No stamp selected. Return to Step 1."
"noStampSelected": "No stamp selected. Return to Step 1.",
"customPosition": "Drag the stamp to the desired location in the preview window.",
"error": {
"failed": "An error occurred while adding stamp to the PDF."
},
"imageSize": "Image Size",
"margin": "Margin",
"positionAndFormatting": "Position & Formatting",
"quickPosition": "Select a position on the page to place the stamp.",
"results": {
"title": "Stamp Results"
}
},
"removeImagePdf": {
"tags": "Remove Image,Page operations,Back end,server side"
@@ -2675,7 +2875,8 @@
"status": {
"_value": "Status",
"valid": "Valid",
"invalid": "Invalid"
"invalid": "Invalid",
"complete": "Validation complete"
},
"signer": "Signer",
"date": "Date",
@@ -2702,16 +2903,71 @@
"version": "Version",
"keyUsage": "Key Usage",
"selfSigned": "Self-Signed",
"bits": "bits"
"bits": "bits",
"details": "Certificate Details"
},
"signature": {
"info": "Signature Information",
"_value": "Signature",
"mathValid": "Signature is mathematically valid BUT:"
},
"selectCustomCert": "Custom Certificate File X.509 (Optional)"
"selectCustomCert": "Custom Certificate File X.509 (Optional)",
"downloadCsv": "Download CSV",
"downloadJson": "Download JSON",
"downloadPdf": "Download PDF Report",
"downloadType": {
"csv": "CSV",
"json": "JSON",
"pdf": "PDF"
},
"error": {
"allFailed": "Unable to validate the selected files.",
"partial": "Some files could not be validated.",
"reportGeneration": "Could not generate the PDF report. JSON and CSV are available.",
"unexpected": "Unexpected error during validation."
},
"finalizing": "Preparing downloads...",
"issue": {
"certExpired": "Certificate expired",
"certRevocationUnknown": "Certificate revocation status unknown",
"certRevoked": "Certificate revoked",
"chainInvalid": "Certificate chain invalid",
"signatureInvalid": "Signature cryptographic check failed",
"trustInvalid": "Certificate not trusted"
},
"noResults": "Run the validation to generate a report.",
"noSignaturesShort": "No signatures",
"processing": "Validating signatures...",
"report": {
"continued": "Continued",
"downloads": "Downloads",
"entryLabel": "Signature Summary",
"fields": {
"created": "Created",
"fileSize": "File Size",
"signatureCount": "Total Signatures",
"signatureDate": "Signature Date"
},
"filesEvaluated": "{{count}} files evaluated",
"footer": "Validated via Stirling PDF",
"generatedAt": "Generated",
"noPdf": "PDF report will be available after a successful validation.",
"page": "Page",
"shortTitle": "Signature Summary",
"signatureCountLabel": "{{count}} signatures",
"signaturesFound": "{{count}} signatures detected",
"signaturesValid": "{{count}} fully valid",
"title": "Signature Validation Report"
},
"settings": {
"certHint": "Upload a trusted X.509 certificate to validate against a custom trust source.",
"title": "Validation Settings"
},
"signatureDate": "Signature Date",
"totalSignatures": "Total Signatures"
},
"replaceColor": {
"tags": "Replace Colour,Page operations,Back end,server side",
"labels": {
"settings": "Settings",
"colourOperation": "Colour operation"
@@ -2752,9 +3008,6 @@
"failed": "An error occurred while processing the colour replacement."
}
},
"replaceColor": {
"tags": "Replace Colour,Page operations,Back end,server side"
},
"login": {
"title": "Sign in",
"header": "Sign in",
@@ -2786,6 +3039,11 @@
"enterEmail": "Enter your email",
"enterPassword": "Enter your password",
"loggingIn": "Logging In...",
"username": "Username",
"enterUsername": "Enter username",
"useEmailInstead": "Login with email",
"forgotPassword": "Forgot your password?",
"logIn": "Log In",
"signingIn": "Signing in...",
"login": "Login",
"or": "Or",
@@ -2824,6 +3082,10 @@
"passwordsDoNotMatch": "Passwords do not match",
"passwordTooShort": "Password must be at least 6 characters long",
"invalidEmail": "Please enter a valid email address",
"nameRequired": "Name is required",
"emailRequired": "Email is required",
"passwordRequired": "Password is required",
"confirmPasswordRequired": "Confirm password is required",
"checkEmailConfirmation": "Check your email for a confirmation link to complete your registration.",
"accountCreatedSuccessfully": "Account created successfully! You can now sign in.",
"unexpectedError": "Unexpected error: {{message}}"
@@ -2870,7 +3132,19 @@
"contrast": "Contrast:",
"brightness": "Brightness:",
"saturation": "Saturation:",
"download": "Download"
"download": "Download",
"adjustColors": "Adjust Colors",
"blue": "Blue",
"confirm": "Confirm",
"error": {
"failed": "Failed to adjust colors/contrast"
},
"green": "Green",
"noPreview": "Select a PDF to preview",
"red": "Red",
"results": {
"title": "Adjusted PDF"
}
},
"compress": {
"title": "Compress",
@@ -3017,7 +3291,13 @@
"title": "Remove image",
"header": "Remove image",
"removeImage": "Remove image",
"submit": "Remove image"
"submit": "Remove image",
"error": {
"failed": "Failed to remove images from the PDF."
},
"results": {
"title": "Remove Images Results"
}
},
"splitByChapters": {
"title": "Split PDF by Chapters",
@@ -3158,11 +3438,14 @@
"toggleAnnotations": "Toggle Annotations Visibility",
"annotationMode": "Toggle Annotation Mode",
"draw": "Draw",
"save": "Save"
"save": "Save",
"saveChanges": "Save Changes"
},
"search": {
"title": "Search PDF",
"placeholder": "Enter search term..."
"placeholder": "Enter search term...",
"noResults": "No results found",
"searching": "Searching..."
},
"guestBanner": {
"title": "You're using Stirling PDF as a guest!",
@@ -3200,6 +3483,7 @@
"automate": "Automate",
"files": "Files",
"activity": "Activity",
"help": "Help",
"account": "Account",
"config": "Config",
"allTools": "All Tools"
@@ -3284,7 +3568,17 @@
"selectedCount": "{{count}} selected",
"download": "Download",
"delete": "Delete",
"unsupported": "Unsupported"
"unsupported": "Unsupported",
"addToUpload": "Add to Upload",
"deleteAll": "Delete All",
"loadingFiles": "Loading files...",
"noFiles": "No files available",
"noFilesFound": "No files found matching your search",
"openInPageEditor": "Open in Page Editor",
"showAll": "Show All",
"sortByDate": "Sort by Date",
"sortByName": "Sort by Name",
"sortBySize": "Sort by Size"
},
"storage": {
"temporaryNotice": "Files are stored temporarily in your browser and may be cleared automatically",
@@ -3539,16 +3833,6 @@
"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!",
@@ -3557,7 +3841,8 @@
"remaining": "remaining",
"used": "used",
"available": "available",
"cancel": "Cancel"
"cancel": "Cancel",
"preview": "Preview"
},
"config": {
"account": {
@@ -3615,8 +3900,150 @@
"submit": "Add Attachments",
"results": {
"title": "Attachment Results"
},
"error": {
"failed": "Add attachments operation failed"
}
},
"termsAndConditions": "Terms & Conditions",
"logOut": "Log out"
"logOut": "Log out",
"addAttachments": {
"error": {
"failed": "An error occurred while adding attachments to the PDF."
}
},
"autoRename": {
"description": "This tool will automatically rename PDF files based on their content. It analyzes the document to find the most suitable title from the text."
},
"customPosition": "Custom Position",
"details": "Details",
"downloadUnavailable": "Download unavailable for this item",
"invalidUndoData": "Cannot undo: invalid operation data",
"margin": {
"large": "Large",
"medium": "Medium",
"small": "Small",
"xLarge": "Extra Large"
},
"noFilesToUndo": "Cannot undo: no files were processed in the last operation",
"noOperationToUndo": "No operation to undo",
"noValidFiles": "No valid files to process",
"operationCancelled": "Operation cancelled",
"pageEdit": {
"deselectAll": "Select None",
"selectAll": "Select All"
},
"quickPosition": "Quick Position",
"reorganizePages": {
"error": {
"failed": "Failed to reorganize pages"
},
"results": {
"title": "Pages Reorganized"
},
"settings": {
"title": "Settings"
},
"submit": "Reorganize Pages"
},
"replace-color": {
"options": {
"fill": "Fill colour",
"gradient": "Gradient"
},
"previewOverlayOpacity": "Preview overlay opacity",
"previewOverlayTransparency": "Preview overlay transparency",
"previewOverlayVisibility": "Show preview overlay",
"selectText": {
"1": "Replace or invert colour options",
"2": "Default (preset high contrast colours)",
"3": "Custom (choose your own colours)",
"4": "Full invert (invert all colours)",
"5": "High contrast color 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 Color",
"11": "Choose background Color",
"12": "Choose start colour",
"13": "Choose end colour"
},
"submit": "Replace",
"title": "Replace-Invert-Color"
},
"size": "Size",
"submit": "Submit",
"success": "Success",
"tools": {
"noSearchResults": "No tools found",
"noTools": "No tools available"
},
"undoDataMismatch": "Cannot undo: operation data is corrupted",
"undoFailed": "Failed to undo operation",
"undoQuotaError": "Cannot undo: insufficient storage space",
"undoStorageError": "Undo completed but some files could not be saved to storage",
"undoSuccess": "Operation undone successfully",
"unsupported": "Unsupported",
"signup": {
"title": "Create an account",
"subtitle": "Join Stirling PDF to get started",
"name": "Name",
"email": "Email",
"password": "Password",
"confirmPassword": "Confirm password",
"enterName": "Enter your name",
"enterEmail": "Enter your email",
"enterPassword": "Enter your password",
"confirmPasswordPlaceholder": "Confirm password",
"or": "or",
"creatingAccount": "Creating Account...",
"signUp": "Sign Up",
"alreadyHaveAccount": "Already have an account? Sign in",
"pleaseFillAllFields": "Please fill in all fields",
"passwordsDoNotMatch": "Passwords do not match",
"passwordTooShort": "Password must be at least 6 characters long",
"invalidEmail": "Please enter a valid email address",
"checkEmailConfirmation": "Check your email for a confirmation link to complete your registration.",
"accountCreatedSuccessfully": "Account created successfully! You can now sign in.",
"unexpectedError": "Unexpected error: {{message}}",
"useEmailInstead": "Use Email Instead",
"nameRequired": "Name is required",
"emailRequired": "Email is required",
"passwordRequired": "Password is required",
"confirmPasswordRequired": "Please confirm your password"
},
"onboarding": {
"welcomeModal": {
"title": "Welcome to Stirling PDF!",
"description": "Would you like to take a quick 1-minute tour to learn the key features and how to get started?",
"helpHint": "You can always access this tour later from the <strong>Help</strong> button in the bottom left.",
"startTour": "Start Tour",
"maybeLater": "Maybe Later",
"dontShowAgain": "Don't Show Again"
},
"allTools": "This is the <strong>All Tools</strong> panel, where you can browse and select from all available PDF tools.",
"selectCropTool": "Let's select the <strong>Crop</strong> tool to demonstrate how to use one of the tools.",
"toolInterface": "This is the <strong>Crop</strong> tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet.",
"filesButton": "The <strong>Files</strong> button on the Quick Access bar allows you to upload PDFs to use the tools on.",
"fileSources": "You can upload new files or access recent files from here. For the tour, we'll just use a sample file.",
"workbench": "This is the <strong>Workbench</strong> - the main area where you view and edit your PDFs.",
"viewSwitcher": "Use these controls to select how you want to view your PDFs.",
"viewer": "The <strong>Viewer</strong> lets you read and annotate your PDFs.",
"pageEditor": "The <strong>Page Editor</strong> allows you to do various operations on the pages within your PDFs, such as reordering, rotating and deleting.",
"activeFiles": "The <strong>Active Files</strong> view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process.",
"fileCheckbox": "Clicking one of the files selects it for processing. You can select multiple files for batch operations.",
"selectControls": "The <strong>Right Rail</strong> contains buttons to quickly select/deselect all of your active PDFs, along with buttons to change the app's theme or language.",
"cropSettings": "Now that we've selected the file we want crop, we can configure the Crop tool to choose the area that we want to crop the PDF to.",
"runButton": "Once the tool has been configured, this button allows you to run the tool on all the selected PDFs.",
"results": "After the tool has finished running, the <strong>Review</strong> step will show a preview of the results in this panel, and allow you to undo the operation or download the file. ",
"fileReplacement": "The modified file will replace the original file in the Workbench automatically, allowing you to easily run it through more tools.",
"pinButton": "You can use the <strong>Pin</strong> button if you'd rather your files stay active after running tools on them.",
"wrapUp": "You're all set! You've learnt about the main areas of the app and how to use them. Click the <strong>Help</strong> button whenever you like to see this tour again.",
"previous": "Previous",
"next": "Next",
"finish": "Finish",
"startTour": "Start Tour",
"startTourDescription": "Take a guided tour of Stirling PDF's key features"
}
}
Binary file not shown.
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env node
/**
* Stirling PDF Sample Document Generator
*
* This script uses Puppeteer to generate a sample PDF from a HTML template.
* The output is used in the onboarding tour and as a demo document
* for users to experiment with Stirling PDF's features.
*/
import puppeteer from 'puppeteer';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { existsSync, mkdirSync, statSync } from 'fs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const TEMPLATE_PATH = join(__dirname, 'template.html');
const OUTPUT_DIR = join(__dirname, '../../public/samples');
const OUTPUT_PATH = join(OUTPUT_DIR, 'Sample.pdf');
async function generatePDF() {
console.log('🚀 Starting Stirling PDF sample document generation...\n');
// Ensure output directory exists
if (!existsSync(OUTPUT_DIR)) {
mkdirSync(OUTPUT_DIR, { recursive: true });
console.log(`✅ Created output directory: ${OUTPUT_DIR}`);
}
// Check if template exists
if (!existsSync(TEMPLATE_PATH)) {
console.error(`❌ Template file not found: ${TEMPLATE_PATH}`);
process.exit(1);
}
console.log(`📄 Reading template: ${TEMPLATE_PATH}`);
let browser;
try {
// Launch Puppeteer
console.log('🌐 Launching browser...');
browser = await puppeteer.launch({
headless: 'new',
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
const page = await browser.newPage();
// Set viewport to match A4 proportions
await page.setViewport({
width: 794, // A4 width in pixels at 96 DPI
height: 1123, // A4 height in pixels at 96 DPI
deviceScaleFactor: 2 // Higher quality rendering
});
// Navigate to the template file
const fileUrl = `file://${TEMPLATE_PATH}`;
console.log('📖 Loading HTML template...');
await page.goto(fileUrl, {
waitUntil: 'networkidle0' // Wait for all resources to load
});
// Generate PDF with A4 dimensions
console.log('📝 Generating PDF...');
await page.pdf({
path: OUTPUT_PATH,
format: 'A4',
printBackground: true,
margin: {
top: 0,
right: 0,
bottom: 0,
left: 0
},
preferCSSPageSize: true
});
console.log('\n✅ PDF generated successfully!');
console.log(`📦 Output: ${OUTPUT_PATH}`);
// Get file size
const stats = statSync(OUTPUT_PATH);
const fileSizeInKB = (stats.size / 1024).toFixed(2);
console.log(`📊 File size: ${fileSizeInKB} KB`);
} catch (error) {
console.error('\n❌ Error generating PDF:', error.message);
process.exit(1);
} finally {
if (browser) {
await browser.close();
console.log('🔒 Browser closed.');
}
}
console.log('\n🎉 Done! Sample PDF is ready for use in Stirling PDF.\n');
}
// Run the generator
generatePDF().catch(error => {
console.error('Fatal error:', error);
process.exit(1);
});
+432
View File
@@ -0,0 +1,432 @@
/* Stirling PDF Sample Document Styles */
:root {
/* Brand Colors */
--brand-red: #8e3231;
--brand-blue: #3b82f6;
/* Category Colors */
--color-general: #3b82f6;
--color-security: #f59e0b;
--color-formatting: #8b5cf6;
--color-automation: #ec4899;
/* Neutral Colors */
--color-black: #111827;
--color-gray-dark: #4b5563;
--color-gray-medium: #6b7280;
--color-gray-light: #e5e7eb;
--color-gray-lighter: #f3f4f6;
--color-white: #ffffff;
/* Font Stack */
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--font-family);
color: var(--color-black);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Page Structure - A4 Dimensions */
.page {
width: 210mm;
height: 297mm;
background: white;
page-break-after: always;
position: relative;
overflow: hidden;
}
.page:last-child {
page-break-after: auto;
}
/* Page 1: Hero / Cover */
.page-1 {
background: var(--brand-red);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
/* Decorative shapes container */
.decorative-shapes {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
z-index: 0;
}
.shape {
position: absolute;
}
/* Logo SVG shape - top-right */
.shape-1 {
top: -120px;
right: -100px;
width: 450px;
height: auto;
opacity: 0.12;
}
/* Logo SVG shape - top-left */
.shape-2 {
top: -80px;
left: -80px;
width: 350px;
height: auto;
opacity: 0.08;
}
/* Logo SVG shape - bottom-left */
.shape-3 {
bottom: -180px;
left: -150px;
width: 550px;
height: auto;
opacity: 0.15;
}
/* Logo SVG shape - bottom-right */
.shape-4 {
bottom: -100px;
right: -120px;
width: 400px;
height: auto;
opacity: 0.1;
}
/* Small accent shape center-right */
.shape-5 {
top: 50%;
right: -30px;
width: 200px;
height: auto;
opacity: 0.08;
transform: translateY(-50%);
}
.hero-content {
text-align: center;
padding: 60px;
position: relative;
z-index: 1;
}
.logo-container {
margin-bottom: 48px;
position: relative;
}
.hero-logo {
width: 280px;
height: auto;
}
.hero-tagline {
font-size: 32px;
font-weight: 600;
color: var(--color-white);
margin-bottom: 32px;
line-height: 1.3;
}
.hero-stats {
margin-bottom: 40px;
}
.stat-badge {
display: inline-flex;
flex-direction: column;
align-items: center;
padding: 24px 48px;
background: rgba(255, 255, 255, 0.95);
border-radius: 16px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
}
.stat-number {
font-size: 48px;
font-weight: 700;
color: var(--brand-red);
line-height: 1;
}
.stat-label {
font-size: 18px;
color: var(--color-gray-dark);
margin-top: 8px;
font-weight: 500;
}
.hero-features {
display: flex;
justify-content: center;
gap: 16px;
flex-wrap: wrap;
}
.feature-pill {
padding: 12px 24px;
background: rgba(255, 255, 255, 0.2);
color: white;
border-radius: 24px;
font-size: 16px;
font-weight: 500;
border: 2px solid rgba(255, 255, 255, 0.3);
backdrop-filter: blur(10px);
}
/* Page 2: What is Stirling PDF */
.page-2 {
padding: 60px;
}
.content-wrapper {
max-width: 700px;
margin: 0 auto;
}
.page-title {
font-size: 36px;
font-weight: 700;
color: var(--brand-red);
margin-bottom: 24px;
border-bottom: 4px solid var(--brand-red);
padding-bottom: 16px;
}
.intro-text {
font-size: 16px;
color: var(--color-gray-dark);
margin-bottom: 48px;
line-height: 1.8;
}
.value-props {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 32px;
}
.value-prop {
display: flex;
flex-direction: column;
gap: 12px;
}
.value-icon {
width: 48px;
height: 48px;
color: var(--brand-red);
margin-bottom: 8px;
}
.value-icon svg {
width: 100%;
height: 100%;
}
.value-prop h3 {
font-size: 20px;
font-weight: 600;
color: var(--color-black);
}
.value-prop p {
font-size: 14px;
color: var(--color-gray-dark);
line-height: 1.6;
}
/* Page 3: Key Features */
.page-3 {
padding: 60px;
}
.features-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
margin-bottom: 32px;
}
.feature-card {
background: white;
border: 2px solid var(--color-gray-light);
border-radius: 12px;
padding: 24px;
transition: all 0.2s ease;
}
.feature-card[data-category="general"] {
border-color: var(--color-general);
}
.feature-card[data-category="security"] {
border-color: var(--color-security);
}
.feature-card[data-category="formatting"] {
border-color: var(--color-formatting);
}
.feature-card[data-category="automation"] {
border-color: var(--color-automation);
}
.feature-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
}
.feature-icon-large {
width: 40px;
height: 40px;
flex-shrink: 0;
}
.feature-card[data-category="general"] .feature-icon-large {
color: var(--color-general);
}
.feature-card[data-category="security"] .feature-icon-large {
color: var(--color-security);
}
.feature-card[data-category="formatting"] .feature-icon-large {
color: var(--color-formatting);
}
.feature-card[data-category="automation"] .feature-icon-large {
color: var(--color-automation);
}
.feature-icon-large svg {
width: 100%;
height: 100%;
}
.feature-card h3 {
font-size: 18px;
font-weight: 600;
color: var(--color-black);
}
.feature-list {
list-style: none;
padding: 0;
}
.feature-list li {
font-size: 14px;
color: var(--color-gray-dark);
padding: 6px 0;
padding-left: 20px;
position: relative;
}
.feature-list li::before {
content: "•";
position: absolute;
left: 0;
color: var(--brand-red);
font-weight: bold;
}
.additional-features {
background: white;
border: 2px solid var(--brand-red);
padding: 24px;
border-radius: 12px;
margin-top: 24px;
}
.additional-features-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
}
.additional-features-icon {
width: 40px;
height: 40px;
color: var(--brand-red);
flex-shrink: 0;
}
.additional-features-icon svg {
width: 100%;
height: 100%;
}
.additional-features h3 {
font-size: 18px;
font-weight: 600;
color: var(--color-black);
margin: 0;
}
.additional-features-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 32px;
}
.additional-features-grid ul {
list-style: none;
padding: 0;
margin: 0;
}
.additional-features-grid li {
font-size: 15px;
color: var(--color-gray-dark);
padding: 4px 0;
padding-left: 24px;
position: relative;
line-height: 1.5;
}
.additional-features-grid li::before {
content: "•";
position: absolute;
left: 0;
color: var(--brand-red);
font-weight: bold;
font-size: 18px;
}
/* Print Styles */
@media print {
body {
margin: 0;
padding: 0;
}
.page {
margin: 0;
border: none;
box-shadow: none;
}
}
+234
View File
@@ -0,0 +1,234 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stirling PDF - Sample Document</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<!-- Page 1: Hero / Cover Page -->
<div class="page page-1">
<div class="decorative-shapes">
<img src="../../public/branding/StirlingPDFLogoNoTextLight.svg" class="shape shape-1" alt="">
<img src="../../public/branding/StirlingPDFLogoNoTextDark.svg" class="shape shape-2" alt="">
<img src="../../public/branding/StirlingPDFLogoNoTextLight.svg" class="shape shape-3" alt="">
<img src="../../public/branding/StirlingPDFLogoNoTextDark.svg" class="shape shape-4" alt="">
<img src="../../public/branding/StirlingPDFLogoNoTextLight.svg" class="shape shape-5" alt="">
</div>
<div class="hero-content">
<div class="logo-container">
<img src="../../public/branding/StirlingPDFLogoWhiteText.svg" alt="Stirling PDF" class="hero-logo">
</div>
<h1 class="hero-tagline">The Free Adobe Acrobat Alternative</h1>
<div class="hero-stats">
<div class="stat-badge">
<span class="stat-number">10M+</span>
<span class="stat-label">Downloads</span>
</div>
</div>
<div class="hero-features">
<div class="feature-pill">Open Source</div>
<div class="feature-pill">Privacy First</div>
<div class="feature-pill">Self-Hosted</div>
</div>
</div>
</div>
<!-- Page 2: What is Stirling PDF -->
<div class="page page-2">
<div class="content-wrapper">
<h2 class="page-title">What is Stirling PDF?</h2>
<p class="intro-text">
Stirling PDF is a robust, web-based PDF manipulation tool.
It enables you to carry out various operations on PDF files, including splitting,
merging, converting, rearranging, adding images, rotating, compressing, and more.
</p>
<div class="value-props">
<div class="value-prop">
<div class="value-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</svg>
</div>
<h3>50+ PDF Operations</h3>
<p>Comprehensive toolkit covering all your PDF needs. From basic operations to advanced processing.</p>
</div>
<div class="value-prop">
<div class="value-icon">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M7.4 17.25q-1.05.875-2.187.8t-1.988-.775t-1.162-1.837t.412-2.338L4.35 10q-.625-.55-.987-1.325T3 7q0-1.65 1.175-2.825T7 3t2.825 1.175T11 7T9.825 9.825T7 11q-.225 0-.45-.025t-.425-.075L4.2 14.15q-.275.45-.175.888t.425.712t.775.313t.875-.313l10.5-9.025q1.05-.875 2.2-.788t2 .788t1.15 1.838t-.425 2.337L19.65 14q.625.55.988 1.325T21 17q0 1.65-1.175 2.825T17 21t-2.825-1.175T13 17t1.175-2.825T17 13q.225 0 .438.025t.412.075l1.95-3.25q.275-.45.175-.888t-.425-.712t-.775-.312t-.875.312zM7 9q.825 0 1.413-.587T9 7t-.587-1.412T7 5t-1.412.588T5 7t.588 1.413T7 9m10 10q.825 0 1.413-.587T19 17t-.587-1.412T17 15t-1.412.588T15 17t.588 1.413T17 19m0-2" />
</svg>
</div>
<h3>Workflow Automation</h3>
<p>Chain multiple operations together and save them as reusable workflows. Perfect for recurring tasks.</p>
</div>
<div class="value-prop">
<div class="value-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
</div>
<h3>Multi-Language Support</h3>
<p>Available in over 30 languages with community-contributed translations. Accessible to users worldwide.</p>
</div>
<div class="value-prop">
<div class="value-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="5" y="11" width="14" height="10" rx="2" />
<circle cx="12" cy="16" r="1" />
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
</svg>
</div>
<h3>Privacy First</h3>
<p>Self-hosted solution means your data stays on your infrastructure. You have full control over your documents.</p>
</div>
<div class="value-prop">
<div class="value-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<path d="m9 12 2 2 4-4" />
</svg>
</div>
<h3>Open Source</h3>
<p>Transparent, community-driven development. Inspect the code, contribute features, and adapt as needed.</p>
</div>
<div class="value-prop">
<div class="value-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
</svg>
</div>
<h3>API Access</h3>
<p>RESTful API for integration with external tools and scripts. Automate PDF operations programmatically.</p>
</div>
</div>
</div>
</div>
<!-- Page 3: Key Features -->
<div class="page page-3">
<div class="content-wrapper">
<h2 class="page-title">Key Features</h2>
<div class="features-grid">
<div class="feature-card" data-category="general">
<div class="feature-header">
<div class="feature-icon-large">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
</div>
<h3>Page Operations</h3>
</div>
<ul class="feature-list">
<li>Merge & split PDFs</li>
<li>Rearrange pages</li>
<li>Rotate & crop</li>
<li>Extract pages</li>
<li>Multi-page layout</li>
</ul>
</div>
<div class="feature-card" data-category="security">
<div class="feature-header">
<div class="feature-icon-large">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</div>
<h3>Security & Signing</h3>
</div>
<ul class="feature-list">
<li>Password protection</li>
<li>Digital signatures</li>
<li>Watermarks</li>
<li>Permission controls</li>
<li>Redaction tools</li>
</ul>
</div>
<div class="feature-card" data-category="formatting">
<div class="feature-header">
<div class="feature-icon-large">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="m5.825 17l1.9 1.9q.3.3.288.7t-.313.7q-.3.275-.7.288t-.7-.288l-3.6-3.6q-.15-.15-.213-.325T2.426 16t.063-.375t.212-.325l3.6-3.6q.275-.275.688-.275t.712.275q.3.3.3.713t-.3.712L5.825 15H20q.425 0 .713.288T21 16t-.288.713T20 17zm12.35-8H4q-.425 0-.712-.288T3 8t.288-.712T4 7h14.175l-1.9-1.9q-.3-.3-.287-.7t.312-.7q.3-.275.7-.288t.7.288l3.6 3.6q.15.15.213.325t.062.375t-.062.375t-.213.325l-3.6 3.6q-.275.275-.687.275T16.3 12.3q-.3-.3-.3-.712t.3-.713z" />
</svg>
</div>
<h3>File Conversions</h3>
</div>
<ul class="feature-list">
<li>PDF to/from images</li>
<li>Office documents</li>
<li>HTML to PDF</li>
<li>Markdown to PDF</li>
<li>PDF to Word/Excel</li>
</ul>
</div>
<div class="feature-card" data-category="automation">
<div class="feature-header">
<div class="feature-icon-large">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M7.4 17.25q-1.05.875-2.187.8t-1.988-.775t-1.162-1.837t.412-2.338L4.35 10q-.625-.55-.987-1.325T3 7q0-1.65 1.175-2.825T7 3t2.825 1.175T11 7T9.825 9.825T7 11q-.225 0-.45-.025t-.425-.075L4.2 14.15q-.275.45-.175.888t.425.712t.775.313t.875-.313l10.5-9.025q1.05-.875 2.2-.788t2 .788t1.15 1.838t-.425 2.337L19.65 14q.625.55.988 1.325T21 17q0 1.65-1.175 2.825T17 21t-2.825-1.175T13 17t1.175-2.825T17 13q.225 0 .438.025t.412.075l1.95-3.25q.275-.45.175-.888t-.425-.712t-.775-.312t-.875.312zM7 9q.825 0 1.413-.587T9 7t-.587-1.412T7 5t-1.412.588T5 7t.588 1.413T7 9m10 10q.825 0 1.413-.587T19 17t-.587-1.412T17 15t-1.412.588T15 17t.588 1.413T17 19m0-2" />
</svg>
</div>
<h3>Automation</h3>
</div>
<ul class="feature-list">
<li>Multi-step workflows</li>
<li>Chain PDF operations</li>
<li>Save recurring tasks</li>
<li>Batch file processing</li>
<li>API integration</li>
</ul>
</div>
</div>
<div class="additional-features">
<div class="additional-features-header">
<div class="additional-features-icon">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M6 20q-.825 0-1.4125-.5875T4 18t.5875-1.4125T6 16t1.4125.5875T8 18t-.5875 1.4125T6 20m6 0q-.825 0-1.4125-.5875T10 18t.5875-1.4125T12 16t1.4125.5875T14 18t-.5875 1.4125T12 20m6 0q-.825 0-1.4125-.5875T16 18t.5875-1.4125T18 16t1.4125.5875T20 18t-.5875 1.4125T18 20M6 14q-.825 0-1.4125-.5875T4 12t.5875-1.4125T6 10t1.4125.5875T8 12t-.5875 1.4125T6 14m6 0q-.825 0-1.4125-.5875T10 12t.5875-1.4125T12 10t1.4125.5875T14 12t-.5875 1.4125T12 14m6 0q-.825 0-1.4125-.5875T16 12t.5875-1.4125T18 10t1.4125.5875T20 12t-.5875 1.4125T18 14M6 8q-.825 0-1.4125-.5875T4 6t.5875-1.4125T6 4t1.4125.5875T8 6t-.5875 1.4125T6 8m6 0q-.825 0-1.4125-.5875T10 6t.5875-1.4125T12 4t1.4125.5875T14 6t-.5875 1.4125T12 8m6 0q-.825 0-1.4125-.5875T16 6t.5875-1.4125T18 4t1.4125.5875T20 6t-.5875 1.4125T18 8" />
</svg>
</div>
<h3>Plus Many More</h3>
</div>
<div class="additional-features-grid">
<ul>
<li>OCR text recognition</li>
<li>Compress PDFs</li>
<li>Add images & stamps</li>
<li>Detect blank pages</li>
<li>Extract images</li>
<li>Edit metadata</li>
</ul>
<ul>
<li>Flatten forms</li>
<li>PDF/A conversion</li>
<li>Add page numbers</li>
<li>Remove pages</li>
<li>Repair PDFs</li>
<li>And 40+ more tools</li>
</ul>
</div>
</div>
</div>
</div>
</body>
</html>
+59 -26
View File
@@ -1,14 +1,25 @@
import { Suspense } from "react";
import { Routes, Route } from "react-router-dom";
import { RainbowThemeProvider } from "./components/shared/RainbowThemeProvider";
import { FileContextProvider } from "./contexts/FileContext";
import { NavigationProvider } from "./contexts/NavigationContext";
import { ToolRegistryProvider } from "./contexts/ToolRegistryProvider";
import { FilesModalProvider } from "./contexts/FilesModalContext";
import { ToolWorkflowProvider } from "./contexts/ToolWorkflowContext";
import { HotkeyProvider } from "./contexts/HotkeyContext";
import { SidebarProvider } from "./contexts/SidebarContext";
import { PreferencesProvider } from "./contexts/PreferencesContext";
import { OnboardingProvider } from "./contexts/OnboardingContext";
import { TourOrchestrationProvider } from "./contexts/TourOrchestrationContext";
import ErrorBoundary from "./components/shared/ErrorBoundary";
import HomePage from "./pages/HomePage";
import OnboardingTour from "./components/onboarding/OnboardingTour";
// Import auth components
import { AuthProvider } from "./auth/UseSession";
import Landing from "./routes/Landing";
import Login from "./routes/Login";
import Signup from "./routes/Signup";
import AuthCallback from "./routes/AuthCallback";
// Import global styles
import "./styles/tailwind.css";
@@ -40,31 +51,53 @@ const LoadingFallback = () => (
export default function App() {
return (
<Suspense fallback={<LoadingFallback />}>
<RainbowThemeProvider>
<ErrorBoundary>
<PreferencesProvider>
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<SignatureProvider>
<RightRailProvider>
<HomePage />
</RightRailProvider>
</SignatureProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</FileContextProvider>
</PreferencesProvider>
</ErrorBoundary>
</RainbowThemeProvider>
<PreferencesProvider>
<RainbowThemeProvider>
<ErrorBoundary>
<AuthProvider>
<Routes>
{/* Auth routes - no FileContext or other providers needed */}
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/auth/callback" element={<AuthCallback />} />
{/* Main app routes - wrapped with all providers */}
<Route
path="/*"
element={
<OnboardingProvider>
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<ToolRegistryProvider>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<SignatureProvider>
<RightRailProvider>
<TourOrchestrationProvider>
<Landing />
<OnboardingTour />
</TourOrchestrationProvider>
</RightRailProvider>
</SignatureProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</ToolRegistryProvider>
</FileContextProvider>
</OnboardingProvider>
}
/>
</Routes>
</AuthProvider>
</ErrorBoundary>
</RainbowThemeProvider>
</PreferencesProvider>
</Suspense>
);
}
+233
View File
@@ -0,0 +1,233 @@
import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react';
import { springAuth } from './springAuthClient';
import type { Session, User, AuthError } from './springAuthClient';
/**
* Auth Context Type
* Simplified version without SaaS-specific features (credits, subscriptions)
*/
interface AuthContextType {
session: Session | null;
user: User | null;
loading: boolean;
error: AuthError | null;
signOut: () => Promise<void>;
refreshSession: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType>({
session: null,
user: null,
loading: true,
error: null,
signOut: async () => {},
refreshSession: async () => {},
});
/**
* Auth Provider Component
*
* Manages authentication state and provides it to the entire app.
* Integrates with Spring Security + JWT backend.
*/
export function AuthProvider({ children }: { children: ReactNode }) {
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<AuthError | null>(null);
/**
* Refresh current session
*/
const refreshSession = useCallback(async () => {
try {
setLoading(true);
setError(null);
console.debug('[Auth] Refreshing session...');
const { data, error } = await springAuth.refreshSession();
if (error) {
console.error('[Auth] Session refresh error:', error);
setError(error);
setSession(null);
} else {
console.debug('[Auth] Session refreshed successfully');
setSession(data.session);
}
} catch (err) {
console.error('[Auth] Unexpected error during session refresh:', err);
setError(err as AuthError);
} finally {
setLoading(false);
}
}, []);
/**
* Sign out user
*/
const signOut = useCallback(async () => {
try {
setError(null);
console.debug('[Auth] Signing out...');
const { error } = await springAuth.signOut();
if (error) {
console.error('[Auth] Sign out error:', error);
setError(error);
} else {
console.debug('[Auth] Signed out successfully');
setSession(null);
}
} catch (err) {
console.error('[Auth] Unexpected error during sign out:', err);
setError(err as AuthError);
}
}, []);
/**
* Initialize auth on mount
*/
useEffect(() => {
let mounted = true;
const initializeAuth = async () => {
try {
console.debug('[Auth] Initializing auth...');
// First check if login is enabled
const configResponse = await fetch('/api/v1/config/app-config');
if (configResponse.ok) {
const config = await configResponse.json();
// If login is disabled, skip authentication entirely
if (config.enableLogin === false) {
console.debug('[Auth] Login disabled - skipping authentication');
if (mounted) {
setSession(null);
setLoading(false);
}
return;
}
}
// Login is enabled, proceed with normal auth check
const { data, error } = await springAuth.getSession();
if (!mounted) return;
if (error) {
console.error('[Auth] Initial session error:', error);
setError(error);
} else {
console.debug('[Auth] Initial session loaded:', {
hasSession: !!data.session,
userId: data.session?.user?.id,
email: data.session?.user?.email,
});
setSession(data.session);
}
} catch (err) {
console.error('[Auth] Unexpected error during auth initialization:', err);
if (mounted) {
setError(err as AuthError);
}
} finally {
if (mounted) {
setLoading(false);
}
}
};
initializeAuth();
// Subscribe to auth state changes
const { data: { subscription } } = springAuth.onAuthStateChange(
async (event, newSession) => {
if (!mounted) return;
console.debug('[Auth] Auth state change:', {
event,
hasSession: !!newSession,
userId: newSession?.user?.id,
email: newSession?.user?.email,
timestamp: new Date().toISOString(),
});
// Schedule state update
setTimeout(() => {
if (mounted) {
setSession(newSession);
setError(null);
// Handle specific events
if (event === 'SIGNED_OUT') {
console.debug('[Auth] User signed out, clearing session');
} else if (event === 'SIGNED_IN') {
console.debug('[Auth] User signed in successfully');
} else if (event === 'TOKEN_REFRESHED') {
console.debug('[Auth] Token refreshed');
} else if (event === 'USER_UPDATED') {
console.debug('[Auth] User updated');
}
}
}, 0);
}
);
return () => {
mounted = false;
subscription.unsubscribe();
};
}, []);
const value: AuthContextType = {
session,
user: session?.user ?? null,
loading,
error,
signOut,
refreshSession,
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}
/**
* Hook to access auth context
* Must be used within AuthProvider
*/
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
/**
* Debug hook to expose auth state for debugging
* Can be used in development to monitor auth state
*/
export function useAuthDebug() {
const auth = useAuth();
useEffect(() => {
console.debug('[Auth Debug] Current auth state:', {
hasSession: !!auth.session,
hasUser: !!auth.user,
loading: auth.loading,
hasError: !!auth.error,
userId: auth.user?.id,
email: auth.user?.email,
});
}, [auth.session, auth.user, auth.loading, auth.error]);
return auth;
}
+447
View File
@@ -0,0 +1,447 @@
/**
* Spring Auth Client
*
* This client integrates with the Spring Security + JWT backend.
* - Uses localStorage for JWT storage (sent via Authorization header)
* - JWT validation handled server-side
* - No email confirmation flow (auto-confirmed on registration)
*/
// Auth types
export interface User {
id: string;
email: string;
username: string;
role: string;
enabled?: boolean;
is_anonymous?: boolean;
app_metadata?: Record<string, any>;
}
export interface Session {
user: User;
access_token: string;
expires_in: number;
expires_at?: number;
}
export interface AuthError {
message: string;
status?: number;
}
export interface AuthResponse {
user: User | null;
session: Session | null;
error: AuthError | null;
}
export type AuthChangeEvent =
| 'SIGNED_IN'
| 'SIGNED_OUT'
| 'TOKEN_REFRESHED'
| 'USER_UPDATED';
type AuthChangeCallback = (event: AuthChangeEvent, session: Session | null) => void;
class SpringAuthClient {
private listeners: AuthChangeCallback[] = [];
private sessionCheckInterval: NodeJS.Timeout | null = null;
private readonly SESSION_CHECK_INTERVAL = 60000; // 1 minute
private readonly TOKEN_REFRESH_THRESHOLD = 300000; // 5 minutes before expiry
constructor() {
// Start periodic session validation
this.startSessionMonitoring();
}
/**
* Helper to get CSRF token from cookie
*/
private getCsrfToken(): string | null {
const cookies = document.cookie.split(';');
for (const cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === 'XSRF-TOKEN') {
return value;
}
}
return null;
}
/**
* Get current session
* JWT is stored in localStorage and sent via Authorization header
*/
async getSession(): Promise<{ data: { session: Session | null }; error: AuthError | null }> {
try {
// Get JWT from localStorage
const token = localStorage.getItem('stirling_jwt');
if (!token) {
console.debug('[SpringAuth] getSession: No JWT in localStorage');
return { data: { session: null }, error: null };
}
// Verify with backend
const response = await fetch('/api/v1/auth/me', {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) {
// Token invalid or expired - clear it
localStorage.removeItem('stirling_jwt');
console.debug('[SpringAuth] getSession: Not authenticated (status:', response.status, ')');
return { data: { session: null }, error: null };
}
const data = await response.json();
// Create session object
const session: Session = {
user: data.user,
access_token: token,
expires_in: 3600,
expires_at: Date.now() + 3600 * 1000,
};
console.debug('[SpringAuth] getSession: Session retrieved successfully');
return { data: { session }, error: null };
} catch (error) {
console.error('[SpringAuth] getSession error:', error);
// Clear potentially invalid token
localStorage.removeItem('stirling_jwt');
return {
data: { session: null },
error: { message: error instanceof Error ? error.message : 'Unknown error' },
};
}
}
/**
* Sign in with email and password
*/
async signInWithPassword(credentials: {
email: string;
password: string;
}): Promise<AuthResponse> {
try {
const response = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include', // Include cookies for CSRF
body: JSON.stringify({
username: credentials.email,
password: credentials.password
}),
});
if (!response.ok) {
const error = await response.json();
return { user: null, session: null, error: { message: error.error || 'Login failed' } };
}
const data = await response.json();
const token = data.session.access_token;
// Store JWT in localStorage
localStorage.setItem('stirling_jwt', token);
console.log('[SpringAuth] JWT stored in localStorage');
const session: Session = {
user: data.user,
access_token: token,
expires_in: data.session.expires_in,
expires_at: Date.now() + data.session.expires_in * 1000,
};
// Notify listeners
this.notifyListeners('SIGNED_IN', session);
return { user: data.user, session, error: null };
} catch (error) {
console.error('[SpringAuth] signInWithPassword error:', error);
return {
user: null,
session: null,
error: { message: error instanceof Error ? error.message : 'Login failed' },
};
}
}
/**
* Sign up new user
*/
async signUp(credentials: {
email: string;
password: string;
options?: { data?: { full_name?: string }; emailRedirectTo?: string };
}): Promise<AuthResponse> {
try {
const response = await fetch('/api/v1/user/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
username: credentials.email,
password: credentials.password,
}),
});
if (!response.ok) {
const error = await response.json();
return { user: null, session: null, error: { message: error.error || 'Registration failed' } };
}
const data = await response.json();
// Note: Spring backend auto-confirms users (no email verification)
// Return user but no session (user needs to login)
return { user: data.user, session: null, error: null };
} catch (error) {
console.error('[SpringAuth] signUp error:', error);
return {
user: null,
session: null,
error: { message: error instanceof Error ? error.message : 'Registration failed' },
};
}
}
/**
* Sign in with OAuth provider (GitHub, Google, etc.)
* This redirects to the Spring OAuth2 authorization endpoint
*/
async signInWithOAuth(params: {
provider: 'github' | 'google' | 'apple' | 'azure';
options?: { redirectTo?: string; queryParams?: Record<string, any> };
}): Promise<{ error: AuthError | null }> {
try {
// Redirect to Spring OAuth2 endpoint (Vite will proxy to backend)
const redirectUrl = `/oauth2/authorization/${params.provider}`;
console.log('[SpringAuth] Redirecting to OAuth:', redirectUrl);
// Use window.location.assign for full page navigation
window.location.assign(redirectUrl);
return { error: null };
} catch (error) {
return {
error: { message: error instanceof Error ? error.message : 'OAuth redirect failed' },
};
}
}
/**
* Sign out
*/
async signOut(): Promise<{ error: AuthError | null }> {
try {
// Clear JWT from localStorage immediately
localStorage.removeItem('stirling_jwt');
console.log('[SpringAuth] JWT removed from localStorage');
const csrfToken = this.getCsrfToken();
const headers: HeadersInit = {};
if (csrfToken) {
headers['X-XSRF-TOKEN'] = csrfToken;
}
// Notify backend (optional - mainly for session cleanup)
await fetch('/api/v1/auth/logout', {
method: 'POST',
credentials: 'include',
headers,
});
// Notify listeners
this.notifyListeners('SIGNED_OUT', null);
return { error: null };
} catch (error) {
console.error('[SpringAuth] signOut error:', error);
// Still remove token even if backend call fails
localStorage.removeItem('stirling_jwt');
return {
error: { message: error instanceof Error ? error.message : 'Sign out failed' },
};
}
}
/**
* Refresh session token
*/
async refreshSession(): Promise<{ data: { session: Session | null }; error: AuthError | null }> {
try {
const currentToken = localStorage.getItem('stirling_jwt');
if (!currentToken) {
return { data: { session: null }, error: { message: 'No token to refresh' } };
}
const response = await fetch('/api/v1/auth/refresh', {
method: 'POST',
headers: {
'Authorization': `Bearer ${currentToken}`,
},
});
if (!response.ok) {
localStorage.removeItem('stirling_jwt');
return { data: { session: null }, error: { message: 'Token refresh failed' } };
}
const refreshData = await response.json();
const newToken = refreshData.access_token;
// Store new token
localStorage.setItem('stirling_jwt', newToken);
// Get updated user info
const userResponse = await fetch('/api/v1/auth/me', {
headers: {
'Authorization': `Bearer ${newToken}`,
},
});
if (!userResponse.ok) {
localStorage.removeItem('stirling_jwt');
return { data: { session: null }, error: { message: 'Failed to get user info' } };
}
const userData = await userResponse.json();
const session: Session = {
user: userData.user,
access_token: newToken,
expires_in: 3600,
expires_at: Date.now() + 3600 * 1000,
};
// Notify listeners
this.notifyListeners('TOKEN_REFRESHED', session);
return { data: { session }, error: null };
} catch (error) {
console.error('[SpringAuth] refreshSession error:', error);
localStorage.removeItem('stirling_jwt');
return {
data: { session: null },
error: { message: error instanceof Error ? error.message : 'Refresh failed' },
};
}
}
/**
* Listen to auth state changes
*/
onAuthStateChange(callback: AuthChangeCallback): { data: { subscription: { unsubscribe: () => void } } } {
this.listeners.push(callback);
return {
data: {
subscription: {
unsubscribe: () => {
this.listeners = this.listeners.filter((cb) => cb !== callback);
},
},
},
};
}
// Private helper methods
private notifyListeners(event: AuthChangeEvent, session: Session | null) {
// Use setTimeout to avoid calling callbacks synchronously
setTimeout(() => {
this.listeners.forEach((callback) => {
try {
callback(event, session);
} catch (error) {
console.error('[SpringAuth] Error in auth state change listener:', error);
}
});
}, 0);
}
private startSessionMonitoring() {
// Periodically check session validity
// Since we use HttpOnly cookies, we just need to check with the server
this.sessionCheckInterval = setInterval(async () => {
try {
// Try to get current session
const { data } = await this.getSession();
// If we have a session, proactively refresh if needed
// (The server will handle token expiry, but we can be proactive)
if (data.session) {
const timeUntilExpiry = (data.session.expires_at || 0) - Date.now();
// Refresh if token expires soon
if (timeUntilExpiry > 0 && timeUntilExpiry < this.TOKEN_REFRESH_THRESHOLD) {
console.log('[SpringAuth] Proactively refreshing token');
await this.refreshSession();
}
}
} catch (error) {
console.error('[SpringAuth] Session monitoring error:', error);
}
}, this.SESSION_CHECK_INTERVAL);
}
public destroy() {
if (this.sessionCheckInterval) {
clearInterval(this.sessionCheckInterval);
}
}
}
export const springAuth = new SpringAuthClient();
/**
* Get current user
*/
export const getCurrentUser = async () => {
const { data } = await springAuth.getSession();
return data.session?.user || null;
};
/**
* Check if user is anonymous
*/
export const isUserAnonymous = (user: User | null) => {
return user?.is_anonymous === true;
};
/**
* Create an anonymous user object for use when login is disabled
* This provides a consistent User interface throughout the app
*/
export const createAnonymousUser = (): User => {
return {
id: 'anonymous',
email: 'anonymous@local',
username: 'Anonymous User',
role: 'USER',
enabled: true,
is_anonymous: true,
app_metadata: {
provider: 'anonymous',
},
};
};
/**
* Create an anonymous session for use when login is disabled
*/
export const createAnonymousSession = (): Session => {
return {
user: createAnonymousUser(),
access_token: '',
expires_in: Number.MAX_SAFE_INTEGER,
expires_at: Number.MAX_SAFE_INTEGER,
};
};
// Export auth client as default for convenience
export default springAuth;
@@ -9,7 +9,7 @@
transition: box-shadow 0.18s ease, outline-color 0.18s ease, transform 0.18s ease;
max-width: 100%;
max-height: 100%;
overflow: hidden;
overflow: visible;
margin-left: 0.5rem;
margin-right: 0.5rem;
}
+57 -147
View File
@@ -1,19 +1,19 @@
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
import {
Text, Center, Box, LoadingOverlay, Stack, Group
Text, Center, Box, LoadingOverlay, Stack
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { useFileSelection, useFileState, useFileManagement, useFileActions } from '../../contexts/FileContext';
import { useFileSelection, useFileState, useFileManagement, useFileActions, useFileContext } from '../../contexts/FileContext';
import { useNavigationActions } from '../../contexts/NavigationContext';
import { zipFileService } from '../../services/zipFileService';
import { detectFileExtension } from '../../utils/fileUtils';
import FileEditorThumbnail from './FileEditorThumbnail';
import AddFileCard from './AddFileCard';
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';
import { useFileEditorRightRailButtons } from './fileEditorRightRailButtons';
interface FileEditorProps {
@@ -37,11 +37,15 @@ const FileEditor = ({
// Use optimized FileContext hooks
const { state, selectors } = useFileState();
const { addFiles, removeFiles, reorderFiles } = useFileManagement();
const { actions } = useFileActions();
const { actions: fileActions } = useFileActions();
const { actions: fileContextActions } = useFileContext();
const { clearAllFileErrors } = fileContextActions;
// Extract needed values from state (memoized to prevent infinite loops)
const activeStirlingFileStubs = useMemo(() => selectors.getStirlingFileStubs(), [selectors.getFilesSignature()]);
const selectedFileIds = state.ui.selectedFileIds;
const totalItems = state.files.ids.length;
const selectedCount = selectedFileIds.length;
// Get navigation actions
const { actions: navActions } = useNavigationActions();
@@ -68,19 +72,6 @@ const FileEditor = ({
}
}, [toolMode]);
const [showFilePickerModal, setShowFilePickerModal] = useState(false);
const [zipExtractionProgress, setZipExtractionProgress] = useState<{
isExtracting: boolean;
currentFile: string;
progress: number;
extractedCount: number;
totalFiles: number;
}>({
isExtracting: false,
currentFile: '',
progress: 0,
extractedCount: 0,
totalFiles: 0
});
// Get selected file IDs from context (defensive programming)
const contextSelectedIds = Array.isArray(selectedFileIds) ? selectedFileIds : [];
@@ -91,107 +82,63 @@ const FileEditor = ({
// Use activeStirlingFileStubs directly - no conversion needed
const localSelectedIds = contextSelectedIds;
const handleSelectAllFiles = useCallback(() => {
setSelectedFiles(state.files.ids);
try {
clearAllFileErrors();
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.warn('Failed to clear file errors on select all:', error);
}
}
}, [state.files.ids, setSelectedFiles, clearAllFileErrors]);
const handleDeselectAllFiles = useCallback(() => {
setSelectedFiles([]);
try {
clearAllFileErrors();
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.warn('Failed to clear file errors on deselect:', error);
}
}
}, [setSelectedFiles, clearAllFileErrors]);
const handleCloseSelectedFiles = useCallback(() => {
if (selectedFileIds.length === 0) return;
void removeFiles(selectedFileIds, false);
setSelectedFiles([]);
}, [selectedFileIds, removeFiles, setSelectedFiles]);
useFileEditorRightRailButtons({
totalItems,
selectedCount,
onSelectAll: handleSelectAllFiles,
onDeselectAll: handleDeselectAllFiles,
onCloseSelected: handleCloseSelectedFiles,
});
// Process uploaded files using context
// ZIP extraction is now handled automatically in FileContext based on user preferences
const handleFileUpload = useCallback(async (uploadedFiles: File[]) => {
_setError(null);
try {
const allExtractedFiles: File[] = [];
const errors: string[] = [];
for (const file of uploadedFiles) {
if (file.type === 'application/pdf') {
// Handle PDF files normally
allExtractedFiles.push(file);
} else if (file.type === 'application/zip' || file.type === 'application/x-zip-compressed' || file.name.toLowerCase().endsWith('.zip')) {
// Handle ZIP files - only expand if they contain PDFs
try {
// Validate ZIP file first
const validation = await zipFileService.validateZipFile(file);
if (validation.isValid && validation.containsPDFs) {
// ZIP contains PDFs - extract them
setZipExtractionProgress({
isExtracting: true,
currentFile: file.name,
progress: 0,
extractedCount: 0,
totalFiles: validation.fileCount
});
const extractionResult = await zipFileService.extractPdfFiles(file, (progress) => {
setZipExtractionProgress({
isExtracting: true,
currentFile: progress.currentFile,
progress: progress.progress,
extractedCount: progress.extractedCount,
totalFiles: progress.totalFiles
});
});
// Reset extraction progress
setZipExtractionProgress({
isExtracting: false,
currentFile: '',
progress: 0,
extractedCount: 0,
totalFiles: 0
});
if (extractionResult.success) {
allExtractedFiles.push(...extractionResult.extractedFiles);
if (extractionResult.errors.length > 0) {
errors.push(...extractionResult.errors);
}
} else {
errors.push(`Failed to extract ZIP file "${file.name}": ${extractionResult.errors.join(', ')}`);
}
} else {
// ZIP doesn't contain PDFs or is invalid - treat as regular file
allExtractedFiles.push(file);
}
} catch (zipError) {
errors.push(`Failed to process ZIP file "${file.name}": ${zipError instanceof Error ? zipError.message : 'Unknown error'}`);
setZipExtractionProgress({
isExtracting: false,
currentFile: '',
progress: 0,
extractedCount: 0,
totalFiles: 0
});
}
} else {
allExtractedFiles.push(file);
}
}
// Show any errors
if (errors.length > 0) {
showError(errors.join('\n'));
}
// Process all extracted files
if (allExtractedFiles.length > 0) {
// Add files to context and select them automatically
await addFiles(allExtractedFiles, { selectFiles: true });
showStatus(`Added ${allExtractedFiles.length} files`, 'success');
if (uploadedFiles.length > 0) {
// FileContext will automatically handle ZIP extraction based on user preferences
// - Respects autoUnzip setting
// - Respects autoUnzipFileLimit
// - HTML ZIPs stay intact
// - Non-ZIP files pass through unchanged
await addFiles(uploadedFiles, { selectFiles: true });
showStatus(`Added ${uploadedFiles.length} file(s)`, 'success');
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to process files';
showError(errorMessage);
console.error('File processing error:', err);
// Reset extraction progress on error
setZipExtractionProgress({
isExtracting: false,
currentFile: '',
progress: 0,
extractedCount: 0,
totalFiles: 0
});
}
}, [addFiles]);
}, [addFiles, showStatus, showError]);
const toggleFile = useCallback((fileId: FileId) => {
const currentSelectedIds = contextSelectedIdsRef.current;
@@ -320,7 +267,7 @@ const FileEditor = ({
if (result.success && result.extractedStubs.length > 0) {
// Add extracted file stubs to FileContext
await actions.addStirlingFileStubs(result.extractedStubs);
await fileActions.addStirlingFileStubs(result.extractedStubs);
// Remove the original ZIP file
removeFiles([fileId], false);
@@ -350,7 +297,7 @@ const FileEditor = ({
});
}
}
}, [activeStirlingFileStubs, selectors, actions, removeFiles]);
}, [activeStirlingFileStubs, selectors, fileActions, removeFiles]);
const handleViewFile = useCallback((fileId: FileId) => {
const record = activeStirlingFileStubs.find(r => r.id === fileId);
@@ -394,7 +341,7 @@ const FileEditor = ({
<Box p="md">
{activeStirlingFileStubs.length === 0 && !zipExtractionProgress.isExtracting ? (
{activeStirlingFileStubs.length === 0 ? (
<Center h="60vh">
<Stack align="center" gap="md">
<Text size="lg" c="dimmed">📁</Text>
@@ -402,43 +349,6 @@ const FileEditor = ({
<Text size="sm" c="dimmed">Upload PDF files, ZIP archives, or load from storage to get started</Text>
</Stack>
</Center>
) : activeStirlingFileStubs.length === 0 && zipExtractionProgress.isExtracting ? (
<Box>
<SkeletonLoader type="controls" />
{/* ZIP Extraction Progress */}
{zipExtractionProgress.isExtracting && (
<Box mb="md" p="sm" style={{ backgroundColor: 'var(--mantine-color-orange-0)', borderRadius: 8 }}>
<Group justify="space-between" mb="xs">
<Text size="sm" fw={500}>Extracting ZIP archive...</Text>
<Text size="sm" c="dimmed">{Math.round(zipExtractionProgress.progress)}%</Text>
</Group>
<Text size="xs" c="dimmed" mb="xs">
{zipExtractionProgress.currentFile || 'Processing files...'}
</Text>
<Text size="xs" c="dimmed" mb="xs">
{zipExtractionProgress.extractedCount} of {zipExtractionProgress.totalFiles} files extracted
</Text>
<div style={{
width: '100%',
height: '4px',
backgroundColor: 'var(--mantine-color-gray-2)',
borderRadius: '2px',
overflow: 'hidden'
}}>
<div style={{
width: `${Math.round(zipExtractionProgress.progress)}%`,
height: '100%',
backgroundColor: 'var(--mantine-color-orange-6)',
transition: 'width 0.3s ease'
}} />
</div>
</Box>
)}
<SkeletonLoader type="fileGrid" count={6} />
</Box>
) : (
<div
style={{
@@ -1,10 +1,11 @@
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
import { Text, ActionIcon, CheckboxIndicator, Tooltip } from '@mantine/core';
import React, { useState, useCallback, useRef, useMemo } from 'react';
import { Text, ActionIcon, CheckboxIndicator, Tooltip, Modal, Button, Group, Stack } from '@mantine/core';
import { useMediaQuery } from '@mantine/hooks';
import { alert } from '../toast';
import { useTranslation } from 'react-i18next';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
import CloseIcon from '@mui/icons-material/Close';
import VisibilityIcon from '@mui/icons-material/Visibility';
import UnarchiveIcon from '@mui/icons-material/Unarchive';
import PushPinIcon from '@mui/icons-material/PushPin';
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
@@ -19,6 +20,7 @@ import { useFileState } from '../../contexts/file/fileHooks';
import { FileId } from '../../types/file';
import { formatFileSize } from '../../utils/fileUtils';
import ToolChain from '../shared/ToolChain';
import HoverActionMenu, { HoverAction } from '../shared/HoverActionMenu';
@@ -45,6 +47,7 @@ const FileEditorThumbnail = ({
selectedFiles,
onToggleFile,
onCloseFile,
onViewFile,
_onSetStatus,
onReorderFiles,
onDownloadFile,
@@ -59,8 +62,9 @@ const FileEditorThumbnail = ({
// ---- Drag state ----
const [isDragging, setIsDragging] = useState(false);
const dragElementRef = useRef<HTMLDivElement | null>(null);
const [actionsWidth, setActionsWidth] = useState<number | undefined>(undefined);
const [showActions, setShowActions] = useState(false);
const [showHoverMenu, setShowHoverMenu] = useState(false);
const isMobile = useMediaQuery('(max-width: 1024px)');
const [showCloseModal, setShowCloseModal] = useState(false);
// Resolve the actual File object for pin/unpin operations
const actualFile = useMemo(() => {
@@ -154,46 +158,66 @@ const FileEditorThumbnail = ({
};
}, [file.id, file.name, selectedFiles, onReorderFiles]);
// Update dropdown width on resize
useEffect(() => {
const update = () => {
if (dragElementRef.current) setActionsWidth(dragElementRef.current.offsetWidth);
};
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
// Handle close with confirmation
const handleCloseWithConfirmation = useCallback(() => {
setShowCloseModal(true);
}, []);
// Close the actions dropdown when hovering outside this file card (and its dropdown)
useEffect(() => {
if (!showActions) return;
const handleConfirmClose = useCallback(() => {
onCloseFile(file.id);
alert({ alertType: 'neutral', title: `Closed ${file.name}`, expandable: false, durationMs: 3500 });
setShowCloseModal(false);
}, [file.id, file.name, onCloseFile]);
const isInsideCard = (target: EventTarget | null) => {
const container = dragElementRef.current;
if (!container) return false;
return target instanceof Node && container.contains(target);
};
const handleCancelClose = useCallback(() => {
setShowCloseModal(false);
}, []);
const handleMouseMove = (e: MouseEvent) => {
if (!isInsideCard(e.target)) {
setShowActions(false);
}
};
const handleTouchStart = (e: TouchEvent) => {
// On touch devices, close if the touch target is outside the card
if (!isInsideCard(e.target)) {
setShowActions(false);
}
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('touchstart', handleTouchStart, { passive: true });
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('touchstart', handleTouchStart);
};
}, [showActions]);
// Build hover menu actions
const hoverActions = useMemo<HoverAction[]>(() => [
{
id: 'view',
icon: <VisibilityIcon style={{ fontSize: 20 }} />,
label: t('openInViewer', 'Open in Viewer'),
onClick: (e) => {
e.stopPropagation();
onViewFile(file.id);
},
},
{
id: 'download',
icon: <DownloadOutlinedIcon style={{ fontSize: 20 }} />,
label: t('download', 'Download'),
onClick: (e) => {
e.stopPropagation();
onDownloadFile(file.id);
alert({ alertType: 'success', title: `Downloading ${file.name}`, expandable: false, durationMs: 2500 });
},
},
{
id: 'unzip',
icon: <UnarchiveIcon style={{ fontSize: 20 }} />,
label: t('fileManager.unzip', 'Unzip'),
onClick: (e) => {
e.stopPropagation();
if (onUnzipFile) {
onUnzipFile(file.id);
alert({ alertType: 'success', title: `Unzipping ${file.name}`, expandable: false, durationMs: 2500 });
}
},
hidden: !isZipFile || !onUnzipFile,
},
{
id: 'close',
icon: <CloseIcon style={{ fontSize: 20 }} />,
label: t('close', 'Close'),
onClick: (e) => {
e.stopPropagation();
handleCloseWithConfirmation();
},
color: 'red',
}
], [t, file.id, file.name, isZipFile, onViewFile, onDownloadFile, onUnzipFile, handleCloseWithConfirmation]);
// ---- Card interactions ----
const handleCardClick = () => {
@@ -205,6 +229,11 @@ const FileEditorThumbnail = ({
onToggleFile(file.id);
};
const handleCardDoubleClick = () => {
if (!isSupported) return;
onViewFile(file.id);
};
// ---- Style helpers ----
const getHeaderClassName = () => {
if (hasError) return styles.headerError;
@@ -218,6 +247,7 @@ const FileEditorThumbnail = ({
ref={fileElementRef}
data-file-id={file.id}
data-testid="file-thumbnail"
data-tour="file-card-checkbox"
data-selected={isSelected}
data-supported={isSupported}
className={`${styles.card} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative`}
@@ -226,6 +256,9 @@ const FileEditorThumbnail = ({
role="listitem"
aria-selected={isSelected}
onClick={handleCardClick}
onMouseEnter={() => setShowHoverMenu(true)}
onMouseLeave={() => setShowHoverMenu(false)}
onDoubleClick={handleCardDoubleClick}
>
{/* Header bar */}
<div
@@ -261,11 +294,12 @@ const FileEditorThumbnail = ({
{/* Action buttons group */}
<div className={styles.headerActions}>
{/* Pin/Unpin icon */}
<Tooltip label={isPinned ? t('unpin', 'Unpin') : t('pin', 'Pin')}>
<Tooltip label={isPinned ? t('unpin', 'Unpin File (replace after tool run)') : t('pin', 'Pin File (keep active after tool run)')}>
<ActionIcon
aria-label={isPinned ? t('unpin', 'Unpin') : t('pin', 'Pin')}
aria-label={isPinned ? t('unpin', 'Unpin File (replace after tool run)') : t('pin', 'Pin File (keep active after tool run)')}
variant="subtle"
className={isPinned ? styles.pinned : styles.headerIconButton}
data-tour="file-card-pin"
onClick={(e) => {
e.stopPropagation();
if (actualFile) {
@@ -282,98 +316,9 @@ const FileEditorThumbnail = ({
{isPinned ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
</ActionIcon>
</Tooltip>
{/* Download icon */}
<Tooltip label={t('download', 'Download')}>
<ActionIcon
aria-label={t('download', 'Download')}
variant="subtle"
className={styles.headerIconButton}
onClick={(e) => {
e.stopPropagation();
onDownloadFile(file.id);
alert({ alertType: 'success', title: `Downloading ${file.name}`, expandable: false, durationMs: 2500 });
}}
>
<DownloadOutlinedIcon fontSize="small" />
</ActionIcon>
</Tooltip>
{/* Kebab menu */}
<ActionIcon
aria-label={t('moreOptions', 'More options')}
variant="subtle"
className={styles.headerIconButton}
onClick={(e) => {
e.stopPropagation();
setShowActions((v) => !v);
}}
>
<MoreVertIcon fontSize="small" />
</ActionIcon>
</div>
</div>
{/* Actions overlay */}
{showActions && (
<div
className={styles.actionsOverlay}
style={{ width: actionsWidth }}
onClick={(e) => e.stopPropagation()}
>
<button
className={styles.actionRow}
onClick={() => {
if (actualFile) {
if (isPinned) {
unpinFile(actualFile);
alert({ alertType: 'neutral', title: `Unpinned ${file.name}`, expandable: false, durationMs: 3000 });
} else {
pinFile(actualFile);
alert({ alertType: 'success', title: `Pinned ${file.name}`, expandable: false, durationMs: 3000 });
}
}
setShowActions(false);
}}
>
{isPinned ? <PushPinIcon className={styles.pinned} fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
<span>{isPinned ? t('unpin', 'Unpin') : t('pin', 'Pin')}</span>
</button>
<button
className={styles.actionRow}
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>
</button>
{isZipFile && onUnzipFile && (
<button
className={styles.actionRow}
onClick={() => { onUnzipFile(file.id); alert({ alertType: 'success', title: `Unzipping ${file.name}`, expandable: false, durationMs: 2500 }); setShowActions(false); }}
>
<UnarchiveIcon fontSize="small" />
<span>{t('fileManager.unzip', 'Unzip')}</span>
</button>
)}
<div className={styles.actionsDivider} />
<button
className={`${styles.actionRow} ${styles.actionDanger}`}
onClick={() => {
onCloseFile(file.id);
alert({ alertType: 'neutral', title: `Closed ${file.name}`, expandable: false, durationMs: 3500 });
setShowActions(false);
}}
>
<CloseIcon fontSize="small" />
<span>{t('close', 'Close')}</span>
</button>
</div>
)}
{/* Title + meta line */}
<div
style={{
@@ -464,6 +409,37 @@ const FileEditorThumbnail = ({
</div>
)}
</div>
{/* Hover Menu */}
<HoverActionMenu
show={showHoverMenu || isMobile}
actions={hoverActions}
position="outside"
/>
{/* Close Confirmation Modal */}
<Modal
opened={showCloseModal}
onClose={handleCancelClose}
title={t('confirmClose', 'Confirm Close')}
centered
size="auto"
>
<Stack gap="md">
<Text size="md">{t('confirmCloseMessage', 'Are you sure you want to close this file?')}</Text>
<Text size="sm" c="dimmed" fw={500}>
{file.name}
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="light" onClick={handleCancelClose}>
{t('confirmCloseCancel', 'Cancel')}
</Button>
<Button variant="filled" color="red" onClick={handleConfirmClose}>
{t('confirmCloseConfirm', 'Close File')}
</Button>
</Group>
</Stack>
</Modal>
</div>
);
};
@@ -0,0 +1,60 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useRightRailButtons, RightRailButtonWithAction } from '../../hooks/useRightRailButtons';
import LocalIcon from '../shared/LocalIcon';
interface FileEditorRightRailButtonsParams {
totalItems: number;
selectedCount: number;
onSelectAll: () => void;
onDeselectAll: () => void;
onCloseSelected: () => void;
}
export function useFileEditorRightRailButtons({
totalItems,
selectedCount,
onSelectAll,
onDeselectAll,
onCloseSelected,
}: FileEditorRightRailButtonsParams) {
const { t } = useTranslation();
const buttons = useMemo<RightRailButtonWithAction[]>(() => [
{
id: 'file-select-all',
icon: <LocalIcon icon="select-all" width="1.5rem" height="1.5rem" />,
tooltip: t('rightRail.selectAll', 'Select All'),
ariaLabel: typeof t === 'function' ? t('rightRail.selectAll', 'Select All') : 'Select All',
section: 'top' as const,
order: 10,
disabled: totalItems === 0 || selectedCount === totalItems,
visible: totalItems > 0,
onClick: onSelectAll,
},
{
id: 'file-deselect-all',
icon: <LocalIcon icon="crop-square-outline" width="1.5rem" height="1.5rem" />,
tooltip: t('rightRail.deselectAll', 'Deselect All'),
ariaLabel: typeof t === 'function' ? t('rightRail.deselectAll', 'Deselect All') : 'Deselect All',
section: 'top' as const,
order: 20,
disabled: selectedCount === 0,
visible: totalItems > 0,
onClick: onDeselectAll,
},
{
id: 'file-close-selected',
icon: <LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />,
tooltip: t('rightRail.closeSelected', 'Close Selected Files'),
ariaLabel: typeof t === 'function' ? t('rightRail.closeSelected', 'Close Selected Files') : 'Close Selected Files',
section: 'top' as const,
order: 30,
disabled: selectedCount === 0,
visible: totalItems > 0,
onClick: onCloseSelected,
},
], [t, totalItems, selectedCount, onSelectAll, onDeselectAll, onCloseSelected]);
useRightRailButtons(buttons);
}
@@ -42,6 +42,7 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
<Box style={{ width: '7.5rem', height: '9.375rem', flexShrink: 0, position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{currentFile && thumbnail ? (
<img
className='ph-no-capture'
src={thumbnail}
alt={currentFile.name}
style={{
@@ -66,7 +67,7 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
{/* File info */}
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
<Text className='ph-no-capture' size="sm" fw={500} truncate>
{currentFile ? currentFile.name : 'No file selected'}
</Text>
<Text size="xs" c="dimmed">
@@ -23,7 +23,7 @@ const DesktopLayout: React.FC = () => {
width: '13.625rem',
flexShrink: 0,
height: '100%',
}}>
}} data-tour="file-sources">
<FileSourceButtons />
</Grid.Col>
@@ -12,6 +12,7 @@ import { FileId, StirlingFileStub } from '../../types/fileContext';
import { useFileManagerContext } from '../../contexts/FileManagerContext';
import { zipFileService } from '../../services/zipFileService';
import ToolChain from '../shared/ToolChain';
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '../../styles/zIndex';
interface FileListItemProps {
file: StirlingFileStub;
@@ -127,6 +128,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
withinPortal
onOpen={() => setIsMenuOpen(true)}
onClose={() => setIsMenuOpen(false)}
zIndex={Z_INDEX_OVER_FILE_MANAGER_MODAL}
>
<Menu.Target>
<ActionIcon
+60 -35
View File
@@ -1,9 +1,13 @@
import React, { useMemo } from 'react';
import { Box } from '@mantine/core';
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
import { useFileHandler } from '../../hooks/useFileHandler';
import { useFileState } from '../../contexts/FileContext';
import { useNavigationState, useNavigationActions } from '../../contexts/NavigationContext';
import { useViewer } from '../../contexts/ViewerContext';
import { PageEditorProvider } from '../../contexts/PageEditorContext';
import { isBaseWorkbench } from '../../types/workbench';
import './Workbench.css';
import TopControls from '../shared/TopControls';
@@ -20,18 +24,21 @@ export default function Workbench() {
const { isRainbowMode } = useRainbowThemeContext();
// Use context-based hooks to eliminate all prop drilling
const { state } = useFileState();
const { state, selectors } = useFileState();
const { workbench: currentView } = useNavigationState();
const { actions: navActions } = useNavigationActions();
const setCurrentView = navActions.setWorkbench;
const activeFiles = state.files.ids;
// Create stable reference for activeFiles based on file IDs
const activeFiles = useMemo(() => selectors.getFiles(), [state.files.ids]);
const {
previewFile,
pageEditorFunctions,
sidebarsVisible,
setPreviewFile,
setPageEditorFunctions,
setSidebarsVisible
setSidebarsVisible,
customWorkbenchViews,
} = useToolWorkflow();
const { handleToolSelect } = useToolWorkflow();
@@ -44,6 +51,9 @@ export default function Workbench() {
const selectedTool = selectedToolId ? toolRegistry[selectedToolId] : null;
const { addFiles } = useFileHandler();
// Get active file index from ViewerContext
const { activeFileIndex, setActiveFileIndex } = useViewer();
const handlePreviewClose = () => {
setPreviewFile(null);
const previousMode = sessionStorage.getItem('previousMode');
@@ -95,6 +105,8 @@ export default function Workbench() {
setSidebarsVisible={setSidebarsVisible}
previewFile={previewFile}
onClose={handlePreviewClose}
activeFileIndex={activeFileIndex}
setActiveFileIndex={setActiveFileIndex}
/>
);
@@ -130,44 +142,57 @@ export default function Workbench() {
);
default:
return (
<LandingPage/>
);
if (!isBaseWorkbench(currentView)) {
const customView = customWorkbenchViews.find((view) => view.workbenchId === currentView && view.data != null);
if (customView) {
const CustomComponent = customView.component;
return <CustomComponent data={customView.data} />;
}
}
return <LandingPage />;
}
};
return (
<Box
className="flex-1 h-full min-w-80 relative flex flex-col"
style={
isRainbowMode
? {} // No background color in rainbow mode
: { backgroundColor: 'var(--bg-background)' }
}
>
{/* Top Controls */}
{activeFiles.length > 0 && (
<TopControls
currentView={currentView}
setCurrentView={setCurrentView}
/>
)}
{/* Dismiss All Errors Button */}
<DismissAllErrorsButton />
{/* Main content area */}
<PageEditorProvider>
<Box
className="flex-1 min-h-0 relative z-10 workbench-scrollable "
style={{
transition: 'opacity 0.15s ease-in-out',
paddingTop: activeFiles.length > 0 ? '3.5rem' : '0',
}}
className="flex-1 h-full min-w-80 relative flex flex-col"
style={
isRainbowMode
? {} // No background color in rainbow mode
: { backgroundColor: 'var(--bg-background)' }
}
>
{renderMainContent()}
</Box>
{/* Top Controls */}
{activeFiles.length > 0 && (
<TopControls
currentView={currentView}
setCurrentView={setCurrentView}
customViews={customWorkbenchViews}
activeFiles={activeFiles.map(f => {
const stub = selectors.getStirlingFileStub(f.fileId);
return { fileId: f.fileId, name: f.name, versionNumber: stub?.versionNumber };
})}
currentFileIndex={activeFileIndex}
onFileSelect={setActiveFileIndex}
/>
)}
<Footer analyticsEnabled />
</Box>
{/* Dismiss All Errors Button */}
<DismissAllErrorsButton />
{/* Main content area */}
<Box
className="flex-1 min-h-0 relative z-10 workbench-scrollable "
style={{
transition: 'opacity 0.15s ease-in-out',
}}
>
{renderMainContent()}
</Box>
<Footer analyticsEnabled />
</Box>
</PageEditorProvider>
);
}
@@ -0,0 +1,8 @@
/* Glow effect for tour highlighted area */
.tour-highlight-glow {
stroke: var(--mantine-primary-color-filled);
stroke-width: 3px;
rx: 8px;
ry: 8px;
filter: drop-shadow(0 0 10px var(--mantine-primary-color-filled));
}
@@ -0,0 +1,335 @@
import React from "react";
import {
TourProvider,
useTour,
type StepType
} from '@reactour/tour';
import { useOnboarding } from '../../contexts/OnboardingContext';
import { useTranslation } from 'react-i18next';
import { CloseButton, ActionIcon } from '@mantine/core';
import { useFilesModalContext } from '../../contexts/FilesModalContext';
import { useTourOrchestration } from '../../contexts/TourOrchestrationContext';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import CheckIcon from '@mui/icons-material/Check';
import TourWelcomeModal from './TourWelcomeModal';
import './OnboardingTour.css';
// Enum case order defines order steps will appear
enum TourStep {
ALL_TOOLS,
SELECT_CROP_TOOL,
TOOL_INTERFACE,
FILES_BUTTON,
FILE_SOURCES,
WORKBENCH,
VIEW_SWITCHER,
VIEWER,
PAGE_EDITOR,
ACTIVE_FILES,
FILE_CHECKBOX,
SELECT_CONTROLS,
CROP_SETTINGS,
RUN_BUTTON,
RESULTS,
FILE_REPLACEMENT,
PIN_BUTTON,
WRAP_UP,
}
function TourContent() {
const { isOpen } = useOnboarding();
const { setIsOpen, setCurrentStep } = useTour();
const previousIsOpenRef = React.useRef(isOpen);
// Sync tour open state with context and reset to step 0 when reopening
React.useEffect(() => {
const wasClosedNowOpen = !previousIsOpenRef.current && isOpen;
previousIsOpenRef.current = isOpen;
if (wasClosedNowOpen) {
// Tour is being opened (Help button pressed), reset to first step
setCurrentStep(0);
}
setIsOpen(isOpen);
}, [isOpen, setIsOpen, setCurrentStep]);
return null;
}
export default function OnboardingTour() {
const { t } = useTranslation();
const { completeTour, showWelcomeModal, setShowWelcomeModal, startTour } = useOnboarding();
const { openFilesModal, closeFilesModal } = useFilesModalContext();
const {
saveWorkbenchState,
restoreWorkbenchState,
backToAllTools,
selectCropTool,
loadSampleFile,
switchToViewer,
switchToPageEditor,
switchToActiveFiles,
selectFirstFile,
pinFile,
modifyCropSettings,
executeTool,
} = useTourOrchestration();
// Define steps as object keyed by enum - TypeScript ensures all keys are present
const stepsConfig: Record<TourStep, StepType> = {
[TourStep.ALL_TOOLS]: {
selector: '[data-tour="tool-panel"]',
content: t('onboarding.allTools', 'This is the <strong>All Tools</strong> panel, where you can browse and select from all available PDF tools.'),
position: 'center',
padding: 0,
action: () => {
saveWorkbenchState();
closeFilesModal();
backToAllTools();
},
},
[TourStep.SELECT_CROP_TOOL]: {
selector: '[data-tour="tool-button-crop"]',
content: t('onboarding.selectCropTool', "Let's select the <strong>Crop</strong> tool to demonstrate how to use one of the tools."),
position: 'right',
padding: 0,
actionAfter: () => selectCropTool(),
},
[TourStep.TOOL_INTERFACE]: {
selector: '[data-tour="tool-panel"]',
content: t('onboarding.toolInterface', "This is the <strong>Crop</strong> tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet."),
position: 'center',
padding: 0,
},
[TourStep.FILES_BUTTON]: {
selector: '[data-tour="files-button"]',
content: t('onboarding.filesButton', "The <strong>Files</strong> button on the Quick Access bar allows you to upload PDFs to use the tools on."),
position: 'right',
padding: 10,
action: () => openFilesModal(),
},
[TourStep.FILE_SOURCES]: {
selector: '[data-tour="file-sources"]',
content: t('onboarding.fileSources', "You can upload new files or access recent files from here. For the tour, we'll just use a sample file."),
position: 'right',
padding: 0,
actionAfter: () => {
loadSampleFile();
closeFilesModal();
}
},
[TourStep.WORKBENCH]: {
selector: '[data-tour="workbench"]',
content: t('onboarding.workbench', 'This is the <strong>Workbench</strong> - the main area where you view and edit your PDFs.'),
position: 'center',
padding: 0,
},
[TourStep.VIEW_SWITCHER]: {
selector: '[data-tour="view-switcher"]',
content: t('onboarding.viewSwitcher', 'Use these controls to select how you want to view your PDFs.'),
position: 'bottom',
padding: 0,
},
[TourStep.VIEWER]: {
selector: '[data-tour="workbench"]',
content: t('onboarding.viewer', "The <strong>Viewer</strong> lets you read and annotate your PDFs."),
position: 'center',
padding: 0,
action: () => switchToViewer(),
},
[TourStep.PAGE_EDITOR]: {
selector: '[data-tour="workbench"]',
content: t('onboarding.pageEditor', "The <strong>Page Editor</strong> allows you to do various operations on the pages within your PDFs, such as reordering, rotating and deleting."),
position: 'center',
padding: 0,
action: () => switchToPageEditor(),
},
[TourStep.ACTIVE_FILES]: {
selector: '[data-tour="workbench"]',
content: t('onboarding.activeFiles', "The <strong>Active Files</strong> view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process."),
position: 'center',
padding: 0,
action: () => switchToActiveFiles(),
},
[TourStep.FILE_CHECKBOX]: {
selector: '[data-tour="file-card-checkbox"]',
content: t('onboarding.fileCheckbox', "Clicking one of the files selects it for processing. You can select multiple files for batch operations."),
position: 'top',
padding: 10,
},
[TourStep.SELECT_CONTROLS]: {
selector: '[data-tour="right-rail-controls"]',
highlightedSelectors: ['[data-tour="right-rail-controls"]', '[data-tour="right-rail-settings"]'],
content: t('onboarding.selectControls', "The <strong>Right Rail</strong> contains buttons to quickly select/deselect all of your active PDFs, along with buttons to change the app's theme or language."),
position: 'left',
padding: 5,
action: () => selectFirstFile(),
},
[TourStep.CROP_SETTINGS]: {
selector: '[data-tour="crop-settings"]',
content: t('onboarding.cropSettings', "Now that we've selected the file we want crop, we can configure the <strong>Crop</strong> tool to choose the area that we want to crop the PDF to."),
position: 'left',
padding: 10,
action: () => modifyCropSettings(),
},
[TourStep.RUN_BUTTON]: {
selector: '[data-tour="run-button"]',
content: t('onboarding.runButton', "Once the tool has been configured, this button allows you to run the tool on all the selected PDFs."),
position: 'top',
padding: 10,
actionAfter: () => executeTool(),
},
[TourStep.RESULTS]: {
selector: '[data-tour="tool-panel"]',
content: t('onboarding.results', "After the tool has finished running, the <strong>Review</strong> step will show a preview of the results in this panel, and allow you to undo the operation or download the file. "),
position: 'center',
padding: 0,
},
[TourStep.FILE_REPLACEMENT]: {
selector: '[data-tour="file-card-checkbox"]',
content: t('onboarding.fileReplacement', "The modified file will replace the original file in the Workbench automatically, allowing you to easily run it through more tools."),
position: 'left',
padding: 10,
},
[TourStep.PIN_BUTTON]: {
selector: '[data-tour="file-card-pin"]',
content: t('onboarding.pinButton', "You can use the <strong>Pin</strong> button if you'd rather your files stay active after running tools on them."),
position: 'left',
padding: 10,
action: () => pinFile(),
},
[TourStep.WRAP_UP]: {
selector: '[data-tour="help-button"]',
content: t('onboarding.wrapUp', "You're all set! You've learnt about the main areas of the app and how to use them. Click the <strong>Help</strong> button whenever you like to see this tour again."),
position: 'right',
padding: 10,
},
};
// Convert to array using enum's numeric ordering
const steps = Object.values(stepsConfig);
const advanceTour = ({ setCurrentStep, currentStep, steps, setIsOpen }: {
setCurrentStep: (value: number | ((prev: number) => number)) => void;
currentStep: number;
steps?: StepType[];
setIsOpen: (value: boolean) => void;
}) => {
if (steps && currentStep === steps.length - 1) {
setIsOpen(false);
restoreWorkbenchState();
completeTour();
} else if (steps) {
setCurrentStep((s) => (s === steps.length - 1 ? 0 : s + 1));
}
};
const handleCloseTour = ({ setIsOpen }: { setIsOpen: (value: boolean) => void }) => {
setIsOpen(false);
restoreWorkbenchState();
completeTour();
};
return (
<>
<TourWelcomeModal
opened={showWelcomeModal}
onStartTour={() => {
setShowWelcomeModal(false);
startTour();
}}
onMaybeLater={() => {
setShowWelcomeModal(false);
}}
onDontShowAgain={() => {
setShowWelcomeModal(false);
completeTour();
}}
/>
<TourProvider
steps={steps}
onClickClose={handleCloseTour}
onClickMask={advanceTour}
onClickHighlighted={(e, clickProps) => {
e.stopPropagation();
advanceTour(clickProps);
}}
keyboardHandler={(e, clickProps) => {
// Handle right arrow key to advance tour
if (e.key === 'ArrowRight' && clickProps) {
e.preventDefault();
advanceTour(clickProps);
}
// Handle escape key to close tour
else if (e.key === 'Escape' && clickProps) {
e.preventDefault();
handleCloseTour(clickProps);
}
}}
styles={{
popover: (base) => ({
...base,
backgroundColor: 'var(--mantine-color-body)',
color: 'var(--mantine-color-text)',
borderRadius: '8px',
padding: '20px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
maxWidth: '400px',
}),
maskArea: (base) => ({
...base,
rx: 8,
}),
badge: (base) => ({
...base,
backgroundColor: 'var(--mantine-primary-color-filled)',
}),
controls: (base) => ({
...base,
justifyContent: 'center',
}),
}}
highlightedMaskClassName="tour-highlight-glow"
showNavigation={true}
showBadge={false}
showCloseButton={true}
disableInteraction={true}
disableDotsNavigation={true}
prevButton={() => null}
nextButton={({ currentStep, stepsLength, setCurrentStep, setIsOpen }) => {
const isLast = currentStep === stepsLength - 1;
return (
<ActionIcon
onClick={() => {
advanceTour({ setCurrentStep, currentStep, steps, setIsOpen });
}}
variant="subtle"
size="lg"
aria-label={isLast ? t('onboarding.finish', 'Finish') : t('onboarding.next', 'Next')}
>
{isLast ? <CheckIcon /> : <ArrowForwardIcon />}
</ActionIcon>
);
}}
components={{
Close: ({ onClick }) => (
<CloseButton
onClick={onClick}
size="md"
style={{ position: 'absolute', top: '8px', right: '8px' }}
/>
),
Content: ({ content } : {content: string}) => (
<div
style={{ paddingRight: '16px' /* Ensure text doesn't overlap with close button */ }}
dangerouslySetInnerHTML={{ __html: content }}
/>
),
}}
>
<TourContent />
</TourProvider>
</>
);
}
@@ -0,0 +1,82 @@
import { Modal, Title, Text, Button, Stack, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '../../styles/zIndex';
interface TourWelcomeModalProps {
opened: boolean;
onStartTour: () => void;
onMaybeLater: () => void;
onDontShowAgain: () => void;
}
export default function TourWelcomeModal({
opened,
onStartTour,
onMaybeLater,
onDontShowAgain,
}: TourWelcomeModalProps) {
const { t } = useTranslation();
return (
<Modal
opened={opened}
onClose={onMaybeLater}
centered
size="md"
radius="lg"
withCloseButton={false}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
>
<Stack gap="lg">
<Stack gap="xs">
<Title order={2}>
{t('onboarding.welcomeModal.title', 'Welcome to Stirling PDF!')}
</Title>
<Text size="md" c="dimmed">
{t('onboarding.welcomeModal.description',
"Would you like to take a quick 1-minute tour to learn the key features and how to get started?"
)}
</Text>
<Text
size="md"
c="dimmed"
dangerouslySetInnerHTML={{
__html: t('onboarding.welcomeModal.helpHint',
'You can always access this tour later from the <strong>Help</strong> button in the bottom left.'
)
}}
/>
</Stack>
<Stack gap="sm">
<Button
onClick={onStartTour}
size="md"
variant="filled"
fullWidth
>
{t('onboarding.welcomeModal.startTour', 'Start Tour')}
</Button>
<Group grow>
<Button
onClick={onMaybeLater}
size="md"
variant="light"
>
{t('onboarding.welcomeModal.maybeLater', 'Maybe Later')}
</Button>
<Button
onClick={onDontShowAgain}
size="md"
variant="light"
>
{t('onboarding.welcomeModal.dontShowAgain', "Don't Show Again")}
</Button>
</Group>
</Stack>
</Stack>
</Modal>
);
}
@@ -1,33 +1,304 @@
import React, { useRef, useEffect, useState, useCallback } from 'react';
import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react';
import { Box } from '@mantine/core';
import { useVirtualizer } from '@tanstack/react-virtual';
import { GRID_CONSTANTS } from './constants';
import {
DndContext,
DragEndEvent,
DragStartEvent,
DragOverlay,
useSensor,
useSensors,
PointerSensor,
closestCenter,
useDraggable,
useDroppable,
} from '@dnd-kit/core';
interface DragDropItem {
id: string;
splitAfter?: boolean;
isPlaceholder?: boolean;
}
interface DragDropGridProps<T extends DragDropItem> {
items: T[];
selectedItems: string[];
selectionMode: boolean;
isAnimating: boolean;
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void;
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>) => React.ReactNode;
renderSplitMarker?: (item: T, index: number) => React.ReactNode;
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>, boxSelectedIds: string[], clearBoxSelection: () => void, getBoxSelection: () => string[], activeId: string | null, activeDragIds: string[], justMoved: boolean, isOver: boolean, dragHandleProps?: any, zoomLevel?: number) => React.ReactNode;
getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null;
zoomLevel?: number;
}
type DropSide = 'left' | 'right' | null;
interface DropHint {
hoveredId: string | null;
dropSide: DropSide;
}
function resolveDropHint(
activeId: string | null,
itemRefs: React.MutableRefObject<Map<string, HTMLDivElement>>,
cursorX: number,
cursorY: number,
): DropHint {
if (!activeId) {
return { hoveredId: null, dropSide: null };
}
const rows = new Map<number, Array<{ id: string; rect: DOMRect }>>();
itemRefs.current.forEach((element, itemId) => {
if (!element || itemId === activeId) return;
const rect = element.getBoundingClientRect();
const rowCenter = rect.top + rect.height / 2;
let row = rows.get(rowCenter);
if (!row) {
row = [];
rows.set(rowCenter, row);
}
row.push({ id: itemId, rect });
});
let hoveredId: string | null = null;
let dropSide: DropSide = null;
let closestRowY = 0;
let closestRowDistance = Infinity;
rows.forEach((_items, rowY) => {
const distance = Math.abs(cursorY - rowY);
if (distance < closestRowDistance) {
closestRowDistance = distance;
closestRowY = rowY;
}
});
const closestRow = rows.get(closestRowY);
if (!closestRow || closestRow.length === 0) {
return { hoveredId: null, dropSide: null };
}
let closestDistance = Infinity;
closestRow.forEach(({ id, rect }) => {
const distanceToLeft = Math.abs(cursorX - rect.left);
const distanceToRight = Math.abs(cursorX - rect.right);
if (distanceToLeft < closestDistance) {
closestDistance = distanceToLeft;
hoveredId = id;
dropSide = 'left';
}
if (distanceToRight < closestDistance) {
closestDistance = distanceToRight;
hoveredId = id;
dropSide = 'right';
}
});
return { hoveredId, dropSide };
}
function resolveTargetIndex<T extends DragDropItem>(
hoveredId: string | null,
dropSide: DropSide,
filteredItems: T[],
filteredToOriginalIndex: number[],
originalItemsLength: number,
fallbackIndex: number | null,
): number | null {
const convertFilteredIndexToOriginal = (filteredIndex: number): number => {
if (filteredToOriginalIndex.length === 0) {
return 0;
}
if (filteredIndex <= 0) {
return filteredToOriginalIndex[0];
}
if (filteredIndex >= filteredToOriginalIndex.length) {
return originalItemsLength;
}
return filteredToOriginalIndex[filteredIndex];
};
if (hoveredId) {
const filteredIndex = filteredItems.findIndex(item => item.id === hoveredId);
if (filteredIndex !== -1) {
const adjustedIndex = filteredIndex + (dropSide === 'right' ? 1 : 0);
return convertFilteredIndexToOriginal(adjustedIndex);
}
}
if (fallbackIndex !== null && fallbackIndex !== undefined) {
const adjustedIndex = fallbackIndex + (dropSide === 'right' ? 1 : 0);
return convertFilteredIndexToOriginal(adjustedIndex);
}
return null;
}
// Lightweight wrapper that handles dnd-kit hooks for each visible item
interface DraggableItemProps<T extends DragDropItem> {
item: T;
index: number;
itemRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
boxSelectedPageIds: string[];
clearBoxSelection: () => void;
getBoxSelection: () => string[];
activeId: string | null;
activeDragIds: string[];
justMoved: boolean;
getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null;
onUpdateDropTarget: (itemId: string | null) => void;
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>, boxSelectedIds: string[], clearBoxSelection: () => void, getBoxSelection: () => string[], activeId: string | null, activeDragIds: string[], justMoved: boolean, isOver: boolean, dragHandleProps?: any, zoomLevel?: number) => React.ReactNode;
zoomLevel: number;
}
const DraggableItem = <T extends DragDropItem>({ item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, getBoxSelection, activeId, activeDragIds, justMoved, getThumbnailData, renderItem, onUpdateDropTarget, zoomLevel }: DraggableItemProps<T>) => {
const { attributes, listeners, setNodeRef: setDraggableRef } = useDraggable({
id: item.id,
data: {
index,
pageNumber: index + 1,
getThumbnail: () => {
if (getThumbnailData) {
const data = getThumbnailData(item.id);
if (data?.src) return data;
}
const element = itemRefs.current.get(item.id);
const imgElement = element?.querySelector('img.ph-no-capture') as HTMLImageElement;
if (imgElement?.src) {
return {
src: imgElement.src,
rotation: imgElement.dataset.originalRotation ? parseInt(imgElement.dataset.originalRotation) : 0
};
}
return null;
}
}
});
const { setNodeRef: setDroppableRef, isOver } = useDroppable({
id: item.id,
data: { index, pageNumber: index + 1 }
});
// Notify parent when hover state changes
React.useEffect(() => {
if (isOver) {
onUpdateDropTarget(item.id);
} else {
onUpdateDropTarget(null);
}
}, [isOver, item.id, onUpdateDropTarget]);
const setNodeRef = useCallback((element: HTMLDivElement | null) => {
setDraggableRef(element);
setDroppableRef(element);
}, [setDraggableRef, setDroppableRef]);
return (
<>
{renderItem(item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, getBoxSelection, activeId, activeDragIds, justMoved, isOver, { ref: setNodeRef, ...attributes, ...listeners }, zoomLevel)}
</>
);
};
const DragDropGrid = <T extends DragDropItem>({
items,
renderItem,
onReorderPages,
getThumbnailData,
zoomLevel = 1.0,
}: DragDropGridProps<T>) => {
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const containerRef = useRef<HTMLDivElement>(null);
const getScrollElement = useCallback(() => {
return containerRef.current?.closest('[data-scrolling-container]') as HTMLElement | null;
}, []);
const { filteredItems: visibleItems, filteredToOriginalIndex } = useMemo(() => {
const filtered: T[] = [];
const indexMap: number[] = [];
items.forEach((item, index) => {
if (!item.isPlaceholder) {
filtered.push(item);
indexMap.push(index);
}
});
return { filteredItems: filtered, filteredToOriginalIndex: indexMap };
}, [items]);
// Box selection state
const [boxSelectStart, setBoxSelectStart] = useState<{ x: number; y: number } | null>(null);
const [boxSelectEnd, setBoxSelectEnd] = useState<{ x: number; y: number } | null>(null);
const [isBoxSelecting, setIsBoxSelecting] = useState(false);
const [boxSelectedPageIds, setBoxSelectedPageIds] = useState<string[]>([]);
const [justMovedIds, setJustMovedIds] = useState<string[]>([]);
const highlightTimeoutRef = useRef<number | null>(null);
// Drag state
const [activeId, setActiveId] = useState<string | null>(null);
const [dragPreview, setDragPreview] = useState<{ src: string; rotation: number } | null>(null);
const [hoveredItemId, setHoveredItemId] = useState<string | null>(null);
const [dropSide, setDropSide] = useState<DropSide>(null);
// Configure sensors for dnd-kit with activation constraint
// Require 10px movement before drag starts to allow clicks for selection
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 10,
},
})
);
// Throttled pointer move handler for drop indicator
// Calculate drop position based on cursor location relative to ALL items, not just hovered item
useEffect(() => {
if (!activeId) {
setDropSide(null);
setHoveredItemId(null);
return;
}
let rafId: number | null = null;
const handlePointerMove = (e: PointerEvent) => {
// Use the actual cursor position (pointer coordinates)
const cursorX = e.clientX;
const cursorY = e.clientY;
if (rafId === null) {
rafId = requestAnimationFrame(() => {
const hint = resolveDropHint(activeId, itemRefs, cursorX, cursorY);
setHoveredItemId(hint.hoveredId);
setDropSide(hint.dropSide);
rafId = null;
});
}
};
window.addEventListener('pointermove', handlePointerMove, { passive: true });
return () => {
window.removeEventListener('pointermove', handlePointerMove);
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
};
}, [activeId]);
// Responsive grid configuration
const [itemsPerRow, setItemsPerRow] = useState(4);
const OVERSCAN = items.length > 1000 ? GRID_CONSTANTS.OVERSCAN_LARGE : GRID_CONSTANTS.OVERSCAN_SMALL;
const OVERSCAN = visibleItems.length > 1000 ? GRID_CONSTANTS.OVERSCAN_LARGE : GRID_CONSTANTS.OVERSCAN_SMALL;
// Calculate items per row based on container width
const calculateItemsPerRow = useCallback(() => {
@@ -38,8 +309,8 @@ const DragDropGrid = <T extends DragDropItem>({
// Convert rem to pixels for calculation
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
const ITEM_WIDTH = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx;
const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx;
const ITEM_WIDTH = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx * zoomLevel;
const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx * zoomLevel;
// Calculate how many items fit: (width - gap) / (itemWidth + gap)
const availableWidth = containerWidth - ITEM_GAP; // Account for first gap
@@ -47,9 +318,9 @@ const DragDropGrid = <T extends DragDropItem>({
const calculated = Math.floor(availableWidth / itemWithGap);
return Math.max(1, calculated); // At least 1 item per row
}, []);
}, [zoomLevel]);
// Update items per row when container resizes
// Update items per row when container resizes or zoom changes
useEffect(() => {
const updateLayout = () => {
const newItemsPerRow = calculateItemsPerRow();
@@ -72,47 +343,327 @@ const DragDropGrid = <T extends DragDropItem>({
window.removeEventListener('resize', updateLayout);
resizeObserver.disconnect();
};
}, [calculateItemsPerRow]);
}, [calculateItemsPerRow, zoomLevel]);
// Virtualization with react-virtual library
const rowVirtualizer = useVirtualizer({
count: Math.ceil(items.length / itemsPerRow),
getScrollElement: () => containerRef.current?.closest('[data-scrolling-container]') as Element,
count: Math.ceil(visibleItems.length / itemsPerRow),
getScrollElement,
estimateSize: () => {
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
return parseFloat(GRID_CONSTANTS.ITEM_HEIGHT) * remToPx;
return parseFloat(GRID_CONSTANTS.ITEM_HEIGHT) * remToPx * zoomLevel;
},
overscan: OVERSCAN,
});
// Re-measure virtualizer when zoom or items per row changes
useEffect(() => {
rowVirtualizer.measure();
}, [zoomLevel, itemsPerRow]);
// Cleanup highlight timeout on unmount
useEffect(() => {
return () => {
if (highlightTimeoutRef.current) {
window.clearTimeout(highlightTimeoutRef.current);
highlightTimeoutRef.current = null;
}
};
}, []);
// Box selection handlers
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (e.button !== 0) return; // Only respond to primary button
const container = containerRef.current;
if (!container) return;
const clickTarget = e.target as Node;
let clickedPageId: string | null = null;
itemRefs.current.forEach((element, pageId) => {
if (element.contains(clickTarget)) {
clickedPageId = pageId;
}
});
if (clickedPageId) {
// Clicking directly on a page shouldn't initiate box selection
// but clear previous box selection if clicking outside current group
if (boxSelectedPageIds.length > 0 && !boxSelectedPageIds.includes(clickedPageId)) {
setBoxSelectedPageIds([]);
}
return;
}
e.preventDefault();
const rect = container.getBoundingClientRect();
setIsBoxSelecting(true);
setBoxSelectStart({ x: e.clientX - rect.left, y: e.clientY - rect.top });
setBoxSelectEnd({ x: e.clientX - rect.left, y: e.clientY - rect.top });
setBoxSelectedPageIds([]);
}, [boxSelectedPageIds]);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (!isBoxSelecting || !boxSelectStart) return;
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
setBoxSelectEnd({ x: e.clientX - rect.left, y: e.clientY - rect.top });
// Calculate which pages intersect with selection box
const boxLeft = Math.min(boxSelectStart.x, e.clientX - rect.left);
const boxRight = Math.max(boxSelectStart.x, e.clientX - rect.left);
const boxTop = Math.min(boxSelectStart.y, e.clientY - rect.top);
const boxBottom = Math.max(boxSelectStart.y, e.clientY - rect.top);
const selectedIds: string[] = [];
itemRefs.current.forEach((pageEl, pageId) => {
const pageRect = pageEl.getBoundingClientRect();
const pageLeft = pageRect.left - rect.left;
const pageRight = pageRect.right - rect.left;
const pageTop = pageRect.top - rect.top;
const pageBottom = pageRect.bottom - rect.top;
// Check if page intersects with selection box
const intersects = !(
pageRight < boxLeft ||
pageLeft > boxRight ||
pageBottom < boxTop ||
pageTop > boxBottom
);
if (intersects) {
selectedIds.push(pageId);
}
});
setBoxSelectedPageIds(selectedIds);
}, [isBoxSelecting, boxSelectStart]);
const handleMouseUp = useCallback(() => {
if (isBoxSelecting) {
// Keep box-selected pages highlighted (don't clear boxSelectedPageIds yet)
// They will remain highlighted until next interaction
setIsBoxSelecting(false);
setBoxSelectStart(null);
setBoxSelectEnd(null);
}
}, [isBoxSelecting]);
// Function to clear box selection (exposed to child components)
const clearBoxSelection = useCallback(() => {
setBoxSelectedPageIds([]);
}, []);
// Function to get current box selection (exposed to child components)
const getBoxSelection = useCallback(() => {
return boxSelectedPageIds;
}, [boxSelectedPageIds]);
// Handle drag start
const handleDragStart = useCallback((event: DragStartEvent) => {
const activeId = event.active.id as string;
setActiveId(activeId);
// Call the getter function to get fresh thumbnail data
const getThumbnail = event.active.data.current?.getThumbnail;
if (getThumbnail) {
const thumbnailData = getThumbnail();
if (thumbnailData?.src) {
setDragPreview({ src: thumbnailData.src, rotation: thumbnailData.rotation });
return;
}
}
setDragPreview(null);
}, []);
// Handle drag cancel
const handleDragCancel = useCallback(() => {
setActiveId(null);
setDragPreview(null);
setHoveredItemId(null);
setDropSide(null);
}, []);
// Handle drag end
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
const finalDropSide = dropSide;
setActiveId(null);
setDragPreview(null);
setHoveredItemId(null);
setDropSide(null);
if (!over || active.id === over.id) {
return;
}
// Get data from hooks
const activeData = active.data.current;
if (!activeData) return;
const sourcePageNumber = activeData.pageNumber;
const overData = over?.data.current;
let targetIndex = resolveTargetIndex(
hoveredItemId,
finalDropSide,
visibleItems,
filteredToOriginalIndex,
items.length,
overData ? overData.index : null
);
if (targetIndex === null) return;
if (targetIndex < 0) targetIndex = 0;
if (targetIndex > items.length) targetIndex = items.length;
// Check if this page is box-selected
const isBoxSelected = boxSelectedPageIds.includes(active.id as string);
const pagesToDrag = isBoxSelected && boxSelectedPageIds.length > 0 ? boxSelectedPageIds : undefined;
// Call reorder with page number and target index
onReorderPages(sourcePageNumber, targetIndex, pagesToDrag);
// Highlight moved pages briefly
const movedIds = pagesToDrag ?? [active.id as string];
setJustMovedIds(movedIds);
if (highlightTimeoutRef.current) {
window.clearTimeout(highlightTimeoutRef.current);
}
highlightTimeoutRef.current = window.setTimeout(() => {
setJustMovedIds([]);
highlightTimeoutRef.current = null;
}, 1200);
// Clear box selection after drag
if (pagesToDrag) {
clearBoxSelection();
}
}, [boxSelectedPageIds, dropSide, hoveredItemId, visibleItems, filteredToOriginalIndex, items, onReorderPages, clearBoxSelection]);
// Calculate optimal width for centering
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
const itemWidth = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx;
const itemGap = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx;
const itemWidth = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx * zoomLevel;
const itemGap = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx * zoomLevel;
const gridWidth = itemsPerRow * itemWidth + (itemsPerRow - 1) * itemGap;
// Calculate selection box dimensions
const selectionBoxStyle = isBoxSelecting && boxSelectStart && boxSelectEnd ? {
position: 'absolute' as const,
left: Math.min(boxSelectStart.x, boxSelectEnd.x),
top: Math.min(boxSelectStart.y, boxSelectEnd.y),
width: Math.abs(boxSelectEnd.x - boxSelectStart.x),
height: Math.abs(boxSelectEnd.y - boxSelectStart.y),
border: '2px dashed #3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
pointerEvents: 'none' as const,
zIndex: 1000,
} : null;
// Calculate drop indicator position
const dropIndicatorStyle = useMemo(() => {
if (!hoveredItemId || !dropSide || !activeId) return null;
const element = itemRefs.current.get(hoveredItemId);
const container = containerRef.current;
if (!element || !container) return null;
const itemRect = element.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const top = itemRect.top - containerRect.top;
const height = itemRect.height;
const left = dropSide === 'left'
? itemRect.left - containerRect.left - itemGap / 2
: itemRect.right - containerRect.left + itemGap / 2;
return {
position: 'absolute' as const,
left: `${left}px`,
top: `${top}px`,
width: '4px',
height: `${height}px`,
backgroundColor: 'rgba(96, 165, 250, 0.8)',
borderRadius: '2px',
zIndex: 1001,
pointerEvents: 'none' as const,
};
}, [hoveredItemId, dropSide, activeId, itemGap, zoomLevel]);
const activeDragIds = useMemo(() => {
if (!activeId) return [];
if (boxSelectedPageIds.includes(activeId)) {
return boxSelectedPageIds;
}
return [activeId];
}, [activeId, boxSelectedPageIds]);
const handleWheelWhileDragging = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
if (!activeId) {
return;
}
const scrollElement = getScrollElement();
if (!scrollElement) {
return;
}
scrollElement.scrollBy({
top: event.deltaY,
left: event.deltaX,
});
event.preventDefault();
}, [activeId, getScrollElement]);
return (
<Box
ref={containerRef}
style={{
// Basic container styles
width: '100%',
height: '100%',
}}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<Box
ref={containerRef}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onWheel={handleWheelWhileDragging}
style={{
// Basic container styles
width: '100%',
height: '100%',
position: 'relative',
overflow: 'hidden',
}}
>
{/* Selection box overlay */}
{selectionBoxStyle && <div style={selectionBoxStyle} />}
{/* Global drop indicator */}
{dropIndicatorStyle && <div style={dropIndicatorStyle} />}
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
maxWidth: `${gridWidth}px`,
position: 'relative',
margin: '0 auto',
maxWidth: `${gridWidth}px`,
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const startIndex = virtualRow.index * itemsPerRow;
const endIndex = Math.min(startIndex + itemsPerRow, items.length);
const rowItems = items.slice(startIndex, endIndex);
const endIndex = Math.min(startIndex + itemsPerRow, visibleItems.length);
const rowItems = visibleItems.slice(startIndex, endIndex);
return (
<div
@@ -129,7 +680,7 @@ const DragDropGrid = <T extends DragDropItem>({
<div
style={{
display: 'flex',
gap: GRID_CONSTANTS.ITEM_GAP,
gap: `calc(${GRID_CONSTANTS.ITEM_GAP} * ${zoomLevel})`,
justifyContent: 'flex-start',
height: '100%',
alignItems: 'center',
@@ -139,10 +690,22 @@ const DragDropGrid = <T extends DragDropItem>({
{rowItems.map((item, itemIndex) => {
const actualIndex = startIndex + itemIndex;
return (
<React.Fragment key={item.id}>
{/* Item */}
{renderItem(item, actualIndex, itemRefs)}
</React.Fragment>
<DraggableItem
key={item.id}
item={item}
index={actualIndex}
itemRefs={itemRefs}
boxSelectedPageIds={boxSelectedPageIds}
clearBoxSelection={clearBoxSelection}
getBoxSelection={getBoxSelection}
activeId={activeId}
activeDragIds={activeDragIds}
justMoved={justMovedIds.includes(item.id)}
getThumbnailData={getThumbnailData}
onUpdateDropTarget={setHoveredItemId}
renderItem={renderItem}
zoomLevel={zoomLevel}
/>
);
})}
@@ -152,6 +715,66 @@ const DragDropGrid = <T extends DragDropItem>({
})}
</div>
</Box>
{/* Drag Overlay */}
<DragOverlay>
{activeId && (
<div style={{ position: 'relative', cursor: 'grabbing' }}>
{/* Multi-page badge */}
{boxSelectedPageIds.includes(activeId) && boxSelectedPageIds.length > 1 && (
<div
style={{
position: 'absolute',
top: '-8px',
right: '-8px',
backgroundColor: '#3b82f6',
color: 'white',
borderRadius: '50%',
width: '32px',
height: '32px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '14px',
fontWeight: 'bold',
boxShadow: '0 2px 8px rgba(0,0,0,0.2)',
zIndex: 1001
}}
>
{boxSelectedPageIds.length}
</div>
)}
{/* Just the thumbnail image */}
{dragPreview ? (
<img
src={dragPreview.src}
alt="Dragging"
style={{
width: `calc(20rem * ${zoomLevel})`,
height: `calc(20rem * ${zoomLevel})`,
objectFit: 'contain',
transform: `rotate(${dragPreview.rotation}deg)`,
pointerEvents: 'none',
opacity: 0.5,
}}
/>
) : (
<div style={{
width: `calc(20rem * ${zoomLevel})`,
height: `calc(20rem * ${zoomLevel})`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '48px',
opacity: 0.5,
}}>
📄
</div>
)}
</div>
)}
</DragOverlay>
</DndContext>
);
};
@@ -11,6 +11,26 @@
transform: scale(1.02) translateZ(0);
}
.pageSurface {
transition: background-color 0.4s ease;
}
.pageJustMoved {
animation: pageMovedHighlight 1.2s ease-out;
}
@keyframes pageMovedHighlight {
0% {
background-color: rgba(59, 130, 246, 0.32);
}
60% {
background-color: rgba(59, 130, 246, 0.12);
}
100% {
background-color: rgba(59, 130, 246, 0);
}
}
.pageContainer:hover .pageNumber {
opacity: 1 !important;
}
+562 -147
View File
@@ -1,12 +1,13 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { useState, useCallback, useRef, useEffect, useMemo } 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 { usePageEditor } from "../../contexts/PageEditorContext";
import { PDFDocument, PDFPage, PageEditorFunctions } from "../../types/pageEditor";
import { StirlingFileStub } from "../../types/fileContext";
import { pdfExportService } from "../../services/pdfExportService";
import { documentManipulationService } from "../../services/documentManipulationService";
import { exportProcessedDocumentsToFiles } from "../../services/pdfExportHelpers";
import { createStirlingFilesAndStubs } from "../../services/fileStubHelpers";
// Thumbnail generation is now handled by individual PageThumbnail components
import './PageEditor.module.css';
import PageThumbnail from './PageThumbnail';
@@ -24,8 +25,12 @@ import {
UndoManager
} from './commands/pageCommands';
import { GRID_CONSTANTS } from './constants';
import { useInitialPageDocument } from './hooks/useInitialPageDocument';
import { usePageDocument } from './hooks/usePageDocument';
import { usePageEditorState } from './hooks/usePageEditorState';
import { parseSelection } from "../../utils/bulkselection/parseSelection";
import { usePageEditorRightRailButtons } from "./pageEditorRightRailButtons";
import { useFileColorMap } from "./hooks/useFileColorMap";
export interface PageEditorProps {
onFunctionsReady?: (functions: PageEditorFunctions) => void;
@@ -42,8 +47,91 @@ const PageEditor = ({
// Navigation guard for unsaved changes
const { setHasUnsavedChanges } = useNavigationGuard();
// Prefer IDs + selectors to avoid array identity churn
const activeFileIds = state.files.ids;
// Get PageEditor coordination functions
const { updateFileOrderFromPages, fileOrder, reorderedPages, clearReorderedPages, updateCurrentPages } = usePageEditor();
// Zoom state management
const [zoomLevel, setZoomLevel] = useState(1.0);
const containerRef = useRef<HTMLDivElement>(null);
const [isContainerHovered, setIsContainerHovered] = useState(false);
// Zoom actions
const zoomIn = useCallback(() => {
setZoomLevel(prev => Math.min(prev + 0.1, 3.0));
}, []);
const zoomOut = useCallback(() => {
setZoomLevel(prev => Math.max(prev - 0.1, 0.5));
}, []);
// Derive page editor files from PageEditorContext's fileOrder (page editor workspace order)
// Filter to only show PDF files (PageEditor only supports PDFs)
// Use stable string keys to prevent infinite loops
// Cache file objects to prevent infinite re-renders from new object references
const fileOrderKey = fileOrder.join(',');
const selectedIdsKey = [...state.ui.selectedFileIds].sort().join(',');
const filesSignature = selectors.getFilesSignature();
const fileObjectsRef = useRef(new Map<FileId, any>());
const pagePositionCacheRef = useRef<Map<string, number>>(new Map());
const pageNeighborCacheRef = useRef<Map<string, string | null>>(new Map());
const pageEditorFiles = useMemo(() => {
const cache = fileObjectsRef.current;
const newFiles: any[] = [];
fileOrder.forEach(fileId => {
const stub = selectors.getStirlingFileStub(fileId);
const isSelected = state.ui.selectedFileIds.includes(fileId);
const isPdf = stub?.name?.toLowerCase().endsWith('.pdf') ?? false;
if (!isPdf) return; // Skip non-PDFs
const cached = cache.get(fileId);
// Check if data actually changed (compare by fileId, not position)
if (cached &&
cached.fileId === fileId &&
cached.name === (stub?.name || '') &&
cached.versionNumber === stub?.versionNumber &&
cached.isSelected === isSelected) {
// Reuse existing object reference
newFiles.push(cached);
} else {
// Create new object only if data changed
const newFile = {
fileId,
name: stub?.name || '',
versionNumber: stub?.versionNumber,
isSelected,
};
cache.set(fileId, newFile);
newFiles.push(newFile);
}
});
// Clean up removed files from cache
const activeIds = new Set(newFiles.map(f => f.fileId));
for (const cachedId of cache.keys()) {
if (!activeIds.has(cachedId)) {
cache.delete(cachedId);
}
}
return newFiles;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fileOrderKey, selectedIdsKey, filesSignature]);
// Get ALL file IDs in order (not filtered by selection)
const orderedFileIds = useMemo(() => {
return pageEditorFiles.map(f => f.fileId);
}, [pageEditorFiles]);
// Get selected file IDs for filtering
const selectedFileIds = useMemo(() => {
return pageEditorFiles.filter(f => f.isSelected).map(f => f.fileId);
}, [pageEditorFiles]);
const activeFilesSignature = selectors.getFilesSignature();
// UI state
const globalProcessing = state.ui.isProcessing;
@@ -55,8 +143,153 @@ const PageEditor = ({
const undoManagerRef = useRef(new UndoManager());
// Document state management
// Get initial document ONCE - useInitialPageDocument captures first value only
const initialDocument = useInitialPageDocument();
// Also get live mergedPdfDocument for delta sync (source of truth for page existence)
const { document: mergedPdfDocument } = usePageDocument();
// Initialize editedDocument from initial document
useEffect(() => {
if (!initialDocument || editedDocument) return;
console.log('📄 Initializing editedDocument from initial document:', initialDocument.pages.length, 'pages');
// Clone to avoid mutation
setEditedDocument({
...initialDocument,
pages: initialDocument.pages.map(p => ({ ...p }))
});
}, [initialDocument, editedDocument]);
// Apply file reordering from PageEditorContext
useEffect(() => {
if (reorderedPages && editedDocument) {
setEditedDocument({
...editedDocument,
pages: reorderedPages
});
clearReorderedPages();
}
}, [reorderedPages, editedDocument, clearReorderedPages]);
useEffect(() => {
if (!editedDocument) return;
const positionCache = pagePositionCacheRef.current;
const neighborCache = pageNeighborCacheRef.current;
const pages = editedDocument.pages;
pages.forEach((page, index) => {
positionCache.set(page.id, index);
neighborCache.set(page.id, index > 0 ? pages[index - 1].id : null);
});
}, [editedDocument]);
// Live delta sync: reflect external add/remove without touching existing order
useEffect(() => {
if (!mergedPdfDocument || !editedDocument) return;
const sourcePages = mergedPdfDocument.pages;
const sourceIds = new Set(sourcePages.map(p => p.id));
// Group new pages by file (preserve within-file order from source)
const prevIds = new Set(editedDocument.pages.map(p => p.id));
const newPages: PDFPage[] = [];
for (const p of sourcePages) {
if (!prevIds.has(p.id)) {
newPages.push(p);
}
}
// Fast check: changes exist?
const hasAdditions = newPages.length > 0;
let hasRemovals = false;
for (const p of editedDocument.pages) {
if (!sourceIds.has(p.id)) {
hasRemovals = true;
break;
}
}
if (!hasAdditions && !hasRemovals) return;
setEditedDocument(prev => {
if (!prev) return prev;
let pages = [...prev.pages];
// Capture placeholder positions before they are removed so we can restore files without disrupting current order
const placeholderPositions = new Map<FileId, number>();
pages.forEach((page, index) => {
if (page.isPlaceholder && page.originalFileId) {
placeholderPositions.set(page.originalFileId, index);
}
});
// Track next insertion index per file when replacing placeholders
const nextInsertIndexByFile = new Map(placeholderPositions);
// Remove pages that no longer exist in source
if (hasRemovals) {
pages = pages.filter(p => sourceIds.has(p.id));
}
// Insert new pages while preserving current interleaving
if (hasAdditions) {
const mergedIndexMap = new Map<string, number>();
sourcePages.forEach((page, index) => mergedIndexMap.set(page.id, index));
const additions = newPages
.map(page => ({
page,
cachedIndex: pagePositionCacheRef.current.get(page.id),
mergedIndex: mergedIndexMap.get(page.id) ?? sourcePages.length,
neighborId: pageNeighborCacheRef.current.get(page.id)
}))
.sort((a, b) => {
const aIndex = a.cachedIndex ?? a.mergedIndex;
const bIndex = b.cachedIndex ?? b.mergedIndex;
if (aIndex !== bIndex) return aIndex - bIndex;
return a.mergedIndex - b.mergedIndex;
});
additions.forEach(({ page, neighborId, cachedIndex, mergedIndex }) => {
if (pages.some(existing => existing.id === page.id)) {
return;
}
let insertIndex: number;
const originalFileId = page.originalFileId;
const placeholderIndex = originalFileId ? nextInsertIndexByFile.get(originalFileId) : undefined;
if (originalFileId && placeholderIndex !== undefined) {
insertIndex = Math.min(placeholderIndex, pages.length);
nextInsertIndexByFile.set(originalFileId, insertIndex + 1);
} else if (neighborId === null) {
insertIndex = 0;
} else if (neighborId) {
const neighborIndex = pages.findIndex(p => p.id === neighborId);
if (neighborIndex !== -1) {
insertIndex = neighborIndex + 1;
} else {
const fallbackIndex = cachedIndex ?? mergedIndex ?? pages.length;
insertIndex = Math.min(fallbackIndex, pages.length);
}
} else {
const fallbackIndex = cachedIndex ?? mergedIndex ?? pages.length;
insertIndex = Math.min(fallbackIndex, pages.length);
}
const clonedPage = { ...page };
pages.splice(insertIndex, 0, clonedPage);
});
}
// Renumber without reordering
pages = pages.map((p, i) => ({ ...p, pageNumber: i + 1 }));
return { ...prev, pages };
});
// Only depend on identifiers to avoid loops; do not depend on editedDocument itself
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mergedPdfDocument && mergedPdfDocument.pages.map(p => p.id).join(','), fileOrder.join(',')]);
// UI state management
const {
@@ -65,6 +298,12 @@ const PageEditor = ({
togglePage, toggleSelectAll, animateReorder
} = usePageEditorState();
const [csvInput, setCsvInput] = useState<string>('');
useEffect(() => {
setCsvInput('');
}, [activeFilesSignature]);
// Grid container ref for positioning split indicators
const gridContainerRef = useRef<HTMLDivElement>(null);
@@ -77,9 +316,14 @@ const PageEditor = ({
// Update undo/redo state
const updateUndoRedoState = useCallback(() => {
setCanUndo(undoManagerRef.current.canUndo());
setCanRedo(undoManagerRef.current.canRedo());
}, []);
const undoManager = undoManagerRef.current;
setCanUndo(undoManager.canUndo());
setCanRedo(undoManager.canRedo());
if (!undoManager.hasHistory()) {
setHasUnsavedChanges(false);
}
}, [setHasUnsavedChanges]);
// Set up undo manager callback
useEffect(() => {
@@ -117,7 +361,17 @@ const PageEditor = ({
}, []);
// Interface functions for parent component
const displayDocument = editedDocument || mergedPdfDocument;
const displayDocument = editedDocument || initialDocument;
// Feed current pages to PageEditorContext so file reordering can compute page-level changes
useEffect(() => {
updateCurrentPages(displayDocument?.pages ?? null);
}, [displayDocument, updateCurrentPages]);
// Derived values for right rail and usePageEditorRightRailButtons (must be after displayDocument)
const totalPages = displayDocument?.pages.length || 0;
const selectedPageCount = selectedPageIds.length;
const activeFileIds = selectedFileIds;
// Utility functions to convert between page IDs and page numbers
const getPageNumbersFromIds = useCallback((pageIds: string[]): number[] => {
@@ -147,6 +401,31 @@ const PageEditor = ({
}
}, [displayDocument, setSelectedPageIds, setSelectionMode]);
// Automatically include newly added pages in the current selection
const previousPageIdsRef = useRef<Set<string>>(new Set());
useEffect(() => {
if (!displayDocument || displayDocument.pages.length === 0) {
previousPageIdsRef.current = new Set();
return;
}
const currentIds = new Set(displayDocument.pages.map(page => page.id));
const newlyAddedPageIds: string[] = [];
currentIds.forEach(id => {
if (!previousPageIdsRef.current.has(id)) {
newlyAddedPageIds.push(id);
}
});
if (newlyAddedPageIds.length > 0) {
const next = new Set(selectedPageIds);
newlyAddedPageIds.forEach(id => next.add(id));
setSelectedPageIds(Array.from(next));
}
previousPageIdsRef.current = currentIds;
}, [displayDocument, selectedPageIds, setSelectedPageIds]);
// DOM-first command handlers
const handleRotatePages = useCallback((pageIds: string[], rotation: number) => {
const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation);
@@ -312,51 +591,8 @@ const PageEditor = ({
executeCommandWithTracking(smartSplitCommand);
}, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds, executeCommandWithTracking]);
const handleSplitAll = useCallback(() => {
if (!displayDocument || selectedPageIds.length === 0) return;
// Convert selected page IDs to page numbers, then to split positions (0-based indices)
const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds);
const selectedPositions: number[] = [];
selectedPageNumbers.forEach(pageNum => {
const pageIndex = displayDocument.pages.findIndex(p => p.pageNumber === pageNum);
if (pageIndex !== -1 && pageIndex < displayDocument.pages.length - 1) {
// Only allow splits before the last page
selectedPositions.push(pageIndex);
}
});
if (selectedPositions.length === 0) return;
// Smart toggle logic: follow the majority, default to adding splits if equal
const existingSplitsCount = selectedPositions.filter(pos => splitPositions.has(pos)).length;
const noSplitsCount = selectedPositions.length - existingSplitsCount;
// Remove splits only if majority already have splits
// If equal (50/50), default to adding splits
const shouldRemoveSplits = existingSplitsCount > noSplitsCount;
const newSplitPositions = new Set(splitPositions);
if (shouldRemoveSplits) {
// Remove splits from all selected positions
selectedPositions.forEach(pos => newSplitPositions.delete(pos));
} else {
// Add splits to all selected positions
selectedPositions.forEach(pos => newSplitPositions.add(pos));
}
// Create a custom command that sets the final state directly
const smartSplitCommand = {
execute: () => setSplitPositions(newSplitPositions),
undo: () => setSplitPositions(splitPositions),
description: shouldRemoveSplits
? `Remove ${selectedPositions.length} split(s)`
: `Add ${selectedPositions.length - existingSplitsCount} split(s)`
};
executeCommandWithTracking(smartSplitCommand);
}, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds, executeCommandWithTracking]);
// Alias for consistency - handleSplitAll is the same as handleSplit (both have smart toggle logic)
const handleSplitAll = handleSplit;
const handlePageBreak = useCallback(() => {
if (!displayDocument || selectedPageIds.length === 0) return;
@@ -372,32 +608,99 @@ const PageEditor = ({
executeCommandWithTracking(pageBreakCommand);
}, [selectedPageIds, displayDocument, getPageNumbersFromIds, executeCommandWithTracking]);
const handlePageBreakAll = useCallback(() => {
if (!displayDocument || selectedPageIds.length === 0) return;
// Alias for consistency - handlePageBreakAll is the same as handlePageBreak
const handlePageBreakAll = handlePageBreak;
// Convert selected page IDs to page numbers for the command
const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds);
const pageBreakCommand = new PageBreakCommand(
selectedPageNumbers,
() => displayDocument,
setEditedDocument
);
executeCommandWithTracking(pageBreakCommand);
}, [selectedPageIds, displayDocument, getPageNumbersFromIds, executeCommandWithTracking]);
const handleInsertFiles = useCallback(async (files: File[], insertAfterPage: number) => {
if (!displayDocument || files.length === 0) return;
const handleInsertFiles = useCallback(async (
files: File[] | StirlingFileStub[],
insertAfterPage: number,
isFromStorage?: boolean
) => {
if (!editedDocument || files.length === 0) return;
try {
const targetPage = displayDocument.pages.find(p => p.pageNumber === insertAfterPage);
const targetPage = editedDocument.pages.find(p => p.pageNumber === insertAfterPage);
if (!targetPage) return;
await actions.addFiles(files, { insertAfterPageId: targetPage.id });
console.log('📄 handleInsertFiles: Inserting files after page', insertAfterPage, 'targetPage:', targetPage.id);
// Add files to FileContext for metadata tracking and preserve insertion point
const insertAfterPageId = targetPage.id;
let addedFileIds: FileId[] = [];
if (isFromStorage) {
const stubs = files as StirlingFileStub[];
const result = await actions.addStirlingFileStubs(stubs, {
selectFiles: true,
insertAfterPageId
});
addedFileIds = result.map(f => f.fileId);
console.log('📄 handleInsertFiles: Added stubs, IDs:', addedFileIds);
} else {
const result = await actions.addFiles(files as File[], {
selectFiles: true,
insertAfterPageId
});
addedFileIds = result.map(f => f.fileId);
console.log('📄 handleInsertFiles: Added files, IDs:', addedFileIds);
}
// Wait a moment for files to be processed
await new Promise(resolve => setTimeout(resolve, 100));
// Extract pages from newly added files and insert them into editedDocument
const newPages: PDFPage[] = [];
for (const fileId of addedFileIds) {
const stub = selectors.getStirlingFileStub(fileId);
console.log('📄 handleInsertFiles: File', fileId, 'stub:', stub?.name, 'processedFile:', stub?.processedFile?.totalPages, 'pages:', stub?.processedFile?.pages?.length);
if (stub?.processedFile?.pages) {
// Clone pages and ensure proper PDFPage structure
const clonedPages = stub.processedFile.pages.map((page, idx) => ({
...page,
id: `${fileId}-${page.pageNumber ?? idx + 1}`,
pageNumber: page.pageNumber ?? idx + 1,
originalFileId: fileId,
originalPageNumber: page.originalPageNumber ?? page.pageNumber ?? idx + 1,
rotation: page.rotation ?? 0,
thumbnail: page.thumbnail ?? null,
selected: false,
splitAfter: page.splitAfter ?? false,
}));
newPages.push(...clonedPages);
}
}
console.log('📄 handleInsertFiles: Collected', newPages.length, 'new pages');
if (newPages.length > 0) {
// Find insertion index in editedDocument
const targetIndex = editedDocument.pages.findIndex(p => p.id === targetPage.id);
console.log('📄 handleInsertFiles: Target index in editedDocument:', targetIndex);
if (targetIndex >= 0) {
// Clone pages and insert
const updatedPages = [...editedDocument.pages];
updatedPages.splice(targetIndex + 1, 0, ...newPages);
// Renumber all pages
updatedPages.forEach((page, index) => {
page.pageNumber = index + 1;
});
console.log('📄 handleInsertFiles: Updated pages:', updatedPages.map(p => `${p.id}(${p.pageNumber})`));
setEditedDocument({
...editedDocument,
pages: updatedPages
});
// Keep PageEditor file order in sync with newly inserted pages
updateFileOrderFromPages(updatedPages);
}
}
} catch (error) {
console.error('Failed to insert files:', error);
}
}, [displayDocument, actions]);
}, [editedDocument, actions, selectors, updateFileOrderFromPages]);
const handleSelectAll = useCallback(() => {
if (!displayDocument) return;
@@ -414,6 +717,12 @@ const PageEditor = ({
setSelectedPageIds(pageIds);
}, [getPageIdsFromNumbers, setSelectedPageIds]);
const updatePagesFromCSV = useCallback((override?: string) => {
if (totalPages === 0) return;
const normalized = parseSelection(override ?? csvInput, totalPages);
handleSetSelectedPages(normalized);
}, [csvInput, totalPages, handleSetSelectedPages]);
const handleReorderPages = useCallback((sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => {
if (!displayDocument) return;
@@ -425,17 +734,18 @@ const PageEditor = ({
targetIndex,
selectedPages,
() => displayDocument,
setEditedDocument
setEditedDocument,
(newPages) => updateFileOrderFromPages(newPages) // Sync file order when pages are reordered
);
executeCommandWithTracking(reorderCommand);
}, [displayDocument, getPageNumbersFromIds, executeCommandWithTracking]);
}, [displayDocument, getPageNumbersFromIds, executeCommandWithTracking, updateFileOrderFromPages]);
// Helper function to collect source files for multi-file export
const getSourceFiles = useCallback((): Map<FileId, File> | null => {
const sourceFiles = new Map<FileId, File>();
// Always include original files
activeFileIds.forEach(fileId => {
// Always include selected files
selectedFileIds.forEach(fileId => {
const file = selectors.getFile(fileId);
if (file) {
sourceFiles.set(fileId, file);
@@ -444,31 +754,31 @@ const PageEditor = ({
// Use multi-file export if we have multiple original files
const hasInsertedFiles = false;
const hasMultipleOriginalFiles = activeFileIds.length > 1;
const hasMultipleOriginalFiles = selectedFileIds.length > 1;
if (!hasInsertedFiles && !hasMultipleOriginalFiles) {
return null; // Use single-file export method
}
return sourceFiles.size > 0 ? sourceFiles : null;
}, [activeFileIds, selectors]);
}, [selectedFileIds, selectors]);
// Helper function to generate proper filename for exports
const getExportFilename = useCallback((): string => {
if (activeFileIds.length <= 1) {
if (selectedFileIds.length <= 1) {
// Single file - use original name
return displayDocument?.name || 'document.pdf';
}
// Multiple files - use first file name with " (merged)" suffix
const firstFile = selectors.getFile(activeFileIds[0]);
const firstFile = selectors.getFile(selectedFileIds[0]);
if (firstFile) {
const baseName = firstFile.name.replace(/\.pdf$/i, '');
return `${baseName} (merged).pdf`;
}
return 'merged-document.pdf';
}, [activeFileIds, selectors, displayDocument]);
}, [selectedFileIds, selectors, displayDocument]);
const onExportSelected = useCallback(async () => {
if (!displayDocument || selectedPageIds.length === 0) return;
@@ -477,7 +787,7 @@ const PageEditor = ({
try {
// Step 1: Apply DOM changes to document state first
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
mergedPdfDocument || displayDocument, // Original order
displayDocument, // Original order (editedDocument is our working doc now)
displayDocument, // Current display order (includes reordering)
splitPositions // Position-based splits
);
@@ -517,7 +827,7 @@ const PageEditor = ({
console.error('Export failed:', error);
setExportLoading(false);
}
}, [displayDocument, selectedPageIds, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename, setHasUnsavedChanges]);
}, [displayDocument, selectedPageIds, initialDocument, splitPositions, getSourceFiles, getExportFilename, setHasUnsavedChanges]);
const onExportAll = useCallback(async () => {
if (!displayDocument) return;
@@ -526,7 +836,7 @@ const PageEditor = ({
try {
// Step 1: Apply DOM changes to document state first
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
mergedPdfDocument || displayDocument,
displayDocument,
displayDocument,
splitPositions
);
@@ -563,7 +873,7 @@ const PageEditor = ({
console.error('Export failed:', error);
setExportLoading(false);
}
}, [displayDocument, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename, setHasUnsavedChanges]);
}, [displayDocument, initialDocument, splitPositions, getSourceFiles, getExportFilename, setHasUnsavedChanges]);
// Apply DOM changes to document state using dedicated service
const applyChanges = useCallback(async () => {
@@ -573,7 +883,7 @@ const PageEditor = ({
try {
// Step 1: Apply DOM changes to document state first
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
mergedPdfDocument || displayDocument,
displayDocument,
displayDocument,
splitPositions
);
@@ -583,14 +893,11 @@ const PageEditor = ({
const exportFilename = getExportFilename();
const files = await exportProcessedDocumentsToFiles(processedDocuments, sourceFiles, exportFilename);
// Step 3: Create StirlingFiles and stubs for version history
const parentStub = selectors.getStirlingFileStub(activeFileIds[0]);
if (!parentStub) throw new Error('Parent stub not found');
const { stirlingFiles, stubs } = await createStirlingFilesAndStubs(files, parentStub, 'multiTool');
// Step 4: Consume files (replace in context)
await actions.consumeFiles(activeFileIds, stirlingFiles, stubs);
// Step 3: Add merged output as new files while keeping originals
const newStirlingFiles = await actions.addFiles(files, { selectFiles: true });
if (newStirlingFiles.length > 0) {
actions.setSelectedFiles(newStirlingFiles.map(file => file.fileId));
}
setHasUnsavedChanges(false);
setExportLoading(false);
@@ -598,7 +905,7 @@ const PageEditor = ({
console.error('Apply changes failed:', error);
setExportLoading(false);
}
}, [displayDocument, mergedPdfDocument, splitPositions, activeFileIds, getSourceFiles, getExportFilename, actions, selectors, setHasUnsavedChanges]);
}, [displayDocument, initialDocument, splitPositions, getSourceFiles, getExportFilename, actions, setHasUnsavedChanges]);
const closePdf = useCallback(() => {
@@ -609,6 +916,24 @@ const PageEditor = ({
setSelectionMode(false);
}, [actions]);
usePageEditorRightRailButtons({
totalPages,
selectedPageCount,
csvInput,
setCsvInput,
selectedPageIds,
displayDocument: displayDocument || undefined,
updatePagesFromCSV,
handleSelectAll,
handleDeselectAll,
handleDelete,
onExportSelected,
onSaveChanges: applyChanges,
exportLoading,
activeFileCount: activeFileIds.length,
closePdf,
});
// Export preview function - defined after export functions to avoid circular dependency
const handleExportPreview = useCallback((selectedOnly: boolean = false) => {
if (!displayDocument) return;
@@ -658,14 +983,88 @@ const PageEditor = ({
selectionMode, selectedPageIds, splitPositions, displayDocument?.pages.length, closePdf
]);
// Handle scroll wheel zoom with accumulator for smooth trackpad pinch
useEffect(() => {
let accumulator = 0;
const handleWheel = (event: WheelEvent) => {
// Check if Ctrl (Windows/Linux) or Cmd (Mac) is pressed
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
event.stopPropagation();
accumulator += event.deltaY;
const threshold = 10;
if (accumulator <= -threshold) {
// Accumulated scroll up - zoom in
zoomIn();
accumulator = 0;
} else if (accumulator >= threshold) {
// Accumulated scroll down - zoom out
zoomOut();
accumulator = 0;
}
}
};
const container = containerRef.current;
if (container) {
container.addEventListener('wheel', handleWheel, { passive: false });
return () => {
container.removeEventListener('wheel', handleWheel);
};
}
}, [zoomIn, zoomOut]);
// Handle keyboard zoom shortcuts
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (!isContainerHovered) return;
// Check if Ctrl (Windows/Linux) or Cmd (Mac) is pressed
if (event.ctrlKey || event.metaKey) {
if (event.key === '=' || event.key === '+') {
// Ctrl+= or Ctrl++ for zoom in
event.preventDefault();
zoomIn();
} else if (event.key === '-' || event.key === '_') {
// Ctrl+- for zoom out
event.preventDefault();
zoomOut();
} else if (event.key === '0') {
// Ctrl+0 for reset zoom
event.preventDefault();
setZoomLevel(1.0);
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isContainerHovered, zoomIn, zoomOut]);
// Display all pages - use edited or original document
const displayedPages = displayDocument?.pages || [];
return (
<Box pos="relative" h='100%' style={{ overflow: 'auto' }} data-scrolling-container="true">
<LoadingOverlay visible={globalProcessing && !mergedPdfDocument} />
// Track color assignments by insertion order (files keep their color)
const fileColorIndexMap = useFileColorMap(orderedFileIds);
{!mergedPdfDocument && !globalProcessing && activeFileIds.length === 0 && (
return (
<Box
ref={containerRef}
pos="relative"
h='100%'
style={{ overflow: 'auto' }}
data-scrolling-container="true"
onMouseEnter={() => setIsContainerHovered(true)}
onMouseLeave={() => setIsContainerHovered(false)}
>
<LoadingOverlay visible={globalProcessing && !initialDocument} />
{!initialDocument && !globalProcessing && selectedFileIds.length === 0 && (
<Center h='100%'>
<Stack align="center" gap="md">
<Text size="lg" c="dimmed">📄</Text>
@@ -675,7 +1074,7 @@ const PageEditor = ({
</Center>
)}
{!mergedPdfDocument && globalProcessing && (
{!initialDocument && globalProcessing && (
<Box p={0}>
<SkeletonLoader type="controls" />
<SkeletonLoader type="pageGrid" count={8} />
@@ -683,21 +1082,20 @@ const PageEditor = ({
)}
{displayDocument && (
<Box ref={gridContainerRef} p={0} pb="15rem" style={{ position: 'relative' }}>
<Box ref={gridContainerRef} p={0} pt="2rem" pb="15rem" style={{ position: 'relative' }}>
{/* Split Lines Overlay */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
pointerEvents: 'none',
zIndex: 10
}}
>
{/* Split Lines Overlay */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
pointerEvents: 'none',
zIndex: 10
}}
>
{(() => {
// Calculate remToPx once outside the map to avoid layout thrashing
const containerWidth = containerDimensions.width;
@@ -723,7 +1121,7 @@ const PageEditor = ({
const gridOffset = Math.max(0, (containerWidth - gridWidth) / 2);
const leftPosition = gridOffset + col * itemWithGap + ITEM_WIDTH + (ITEM_GAP / 2);
const topPosition = row * ITEM_HEIGHT + (ITEM_HEIGHT * 0.05); // Center vertically (5% offset since page is 90% height)
const topPosition = row * ITEM_HEIGHT + (ITEM_HEIGHT * 0.05) + ITEM_GAP; // Center vertically (5% offset since page is 90% height) + gap offset
return (
<div
@@ -745,40 +1143,56 @@ const PageEditor = ({
{/* Pages Grid */}
<DragDropGrid
items={displayedPages}
selectedItems={selectedPageIds}
selectionMode={selectionMode}
isAnimating={isAnimating}
onReorderPages={handleReorderPages}
renderItem={(page, index, refs) => (
<PageThumbnail
key={page.id}
page={page}
index={index}
totalPages={displayDocument.pages.length}
originalFile={(page as any).originalFileId ? selectors.getFile((page as any).originalFileId) : undefined}
selectedPageIds={selectedPageIds}
selectionMode={selectionMode}
movingPage={movingPage}
isAnimating={isAnimating}
pageRefs={refs}
onReorderPages={handleReorderPages}
onTogglePage={togglePage}
onAnimateReorder={animateReorder}
onExecuteCommand={executeCommand}
onSetStatus={() => {}}
onSetMovingPage={setMovingPage}
onDeletePage={handleDeletePage}
createRotateCommand={createRotateCommand}
createDeleteCommand={createDeleteCommand}
createSplitCommand={createSplitCommand}
pdfDocument={displayDocument}
setPdfDocument={setEditedDocument}
splitPositions={splitPositions}
onInsertFiles={handleInsertFiles}
/>
)}
zoomLevel={zoomLevel}
getThumbnailData={(pageId) => {
const page = displayDocument.pages.find(p => p.id === pageId);
if (!page?.thumbnail) return null;
return {
src: page.thumbnail,
rotation: page.rotation || 0
};
}}
renderItem={(page, index, refs, boxSelectedIds, clearBoxSelection, _getBoxSelection, _activeId, activeDragIds, justMoved, _isOver, dragHandleProps, zoomLevel) => {
const fileColorIndex = page.originalFileId ? fileColorIndexMap.get(page.originalFileId) ?? 0 : 0;
const isBoxSelected = boxSelectedIds.includes(page.id);
return (
<PageThumbnail
key={page.id}
page={page}
index={index}
totalPages={displayDocument.pages.length}
originalFile={(page as any).originalFileId ? selectors.getFile((page as any).originalFileId) : undefined}
fileColorIndex={fileColorIndex}
selectedPageIds={selectedPageIds}
selectionMode={selectionMode}
movingPage={movingPage}
isAnimating={isAnimating}
isBoxSelected={isBoxSelected}
clearBoxSelection={clearBoxSelection}
activeDragIds={activeDragIds}
justMoved={justMoved}
pageRefs={refs}
dragHandleProps={dragHandleProps}
onReorderPages={handleReorderPages}
onTogglePage={togglePage}
onAnimateReorder={animateReorder}
onExecuteCommand={executeCommand}
onSetStatus={() => {}}
onSetMovingPage={setMovingPage}
onDeletePage={handleDeletePage}
createRotateCommand={createRotateCommand}
createDeleteCommand={createDeleteCommand}
createSplitCommand={createSplitCommand}
pdfDocument={displayDocument}
setPdfDocument={setEditedDocument}
splitPositions={splitPositions}
onInsertFiles={handleInsertFiles}
zoomLevel={zoomLevel}
/>
);
}}
/>
</Box>
)}
@@ -791,6 +1205,7 @@ const PageEditor = ({
await onExportAll();
}}
/>
</Box>
);
};
@@ -138,12 +138,12 @@ const PageEditorControls = ({
{/* Undo/Redo */}
<Tooltip label="Undo">
<ActionIcon onClick={onUndo} disabled={!canUndo} variant="subtle" radius="md" size="lg">
<ActionIcon onClick={onUndo} disabled={!canUndo} variant="subtle" style={{ color: canUndo ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }} radius="md" size="lg">
<UndoIcon />
</ActionIcon>
</Tooltip>
<Tooltip label="Redo">
<ActionIcon onClick={onRedo} disabled={!canRedo} variant="subtle" radius="md" size="lg">
<ActionIcon onClick={onRedo} disabled={!canRedo} variant="subtle" style={{ color: canRedo ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }} radius="md" size="lg">
<RedoIcon />
</ActionIcon>
</Tooltip>
@@ -156,7 +156,7 @@ const PageEditorControls = ({
onClick={() => onRotate('left')}
disabled={selectedPageIds.length === 0}
variant="subtle"
style={{ color: 'var(--mantine-color-dimmed)' }}
style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }}
radius="md"
size="lg"
>
@@ -168,7 +168,7 @@ const PageEditorControls = ({
onClick={() => onRotate('right')}
disabled={selectedPageIds.length === 0}
variant="subtle"
style={{ color: 'var(--mantine-color-dimmed)' }}
style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }}
radius="md"
size="lg"
>
@@ -180,7 +180,7 @@ const PageEditorControls = ({
onClick={onDelete}
disabled={selectedPageIds.length === 0}
variant="subtle"
style={{ color: 'var(--mantine-color-dimmed)' }}
style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }}
radius="md"
size="lg"
>
@@ -192,7 +192,7 @@ const PageEditorControls = ({
onClick={onSplit}
disabled={selectedPageIds.length === 0}
variant="subtle"
style={{ color: 'var(--mantine-color-dimmed)' }}
style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }}
radius="md"
size="lg"
>
@@ -204,7 +204,7 @@ const PageEditorControls = ({
onClick={onPageBreak}
disabled={selectedPageIds.length === 0}
variant="subtle"
style={{ color: 'var(--mantine-color-dimmed)' }}
style={{ color: selectedPageIds.length > 0 ? 'var(--right-rail-icon)' : 'var(--right-rail-icon-disabled)' }}
radius="md"
size="lg"
>
@@ -0,0 +1,59 @@
import { ActionIcon, Popover } from '@mantine/core';
import LocalIcon from '../shared/LocalIcon';
import { Tooltip } from '../shared/Tooltip';
import BulkSelectionPanel from './BulkSelectionPanel';
interface PageSelectByNumberButtonProps {
disabled: boolean;
totalPages: number;
label: string;
csvInput: string;
setCsvInput: (value: string) => void;
selectedPageIds: string[];
displayDocument?: { pages: { id: string; pageNumber: number }[] };
updatePagesFromCSV: (override?: string) => void;
}
export default function PageSelectByNumberButton({
disabled,
totalPages,
label,
csvInput,
setCsvInput,
selectedPageIds,
displayDocument,
updatePagesFromCSV,
}: PageSelectByNumberButtonProps) {
return (
<Tooltip content={label} position="left" offset={12} arrow portalTarget={document.body}>
<div className={`right-rail-fade enter`}>
<Popover position="left" withArrow shadow="md" offset={8}>
<Popover.Target>
<div style={{ display: 'inline-flex' }}>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
disabled={disabled || totalPages === 0}
aria-label={label}
>
<LocalIcon icon="pin-end" width="1.5rem" height="1.5rem" />
</ActionIcon>
</div>
</Popover.Target>
<Popover.Dropdown>
<div style={{ minWidth: '24rem', maxWidth: '32rem' }}>
<BulkSelectionPanel
csvInput={csvInput}
setCsvInput={setCsvInput}
selectedPageIds={selectedPageIds}
displayDocument={displayDocument}
onUpdatePagesFromCSV={updatePagesFromCSV}
/>
</div>
</Popover.Dropdown>
</Popover>
</div>
</Tooltip>
);
}
@@ -1,5 +1,6 @@
import React, { useCallback, useState, useEffect, useRef } from 'react';
import { Text, Checkbox, Tooltip, ActionIcon } from '@mantine/core';
import React, { useCallback, useState, useEffect, useRef, useMemo } from 'react';
import { Text, Checkbox } from '@mantine/core';
import { useMediaQuery } from '@mantine/hooks';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import RotateLeftIcon from '@mui/icons-material/RotateLeft';
@@ -7,11 +8,13 @@ import RotateRightIcon from '@mui/icons-material/RotateRight';
import DeleteIcon from '@mui/icons-material/Delete';
import ContentCutIcon from '@mui/icons-material/ContentCut';
import AddIcon from '@mui/icons-material/Add';
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { PDFPage, PDFDocument } from '../../types/pageEditor';
import { useThumbnailGeneration } from '../../hooks/useThumbnailGeneration';
import { useFilesModalContext } from '../../contexts/FilesModalContext';
import { getFileColorWithOpacity } from './fileColors';
import styles from './PageEditor.module.css';
import HoverActionMenu, { HoverAction } from '../shared/HoverActionMenu';
import { StirlingFileStub } from '../../types/fileContext';
interface PageThumbnailProps {
@@ -19,11 +22,17 @@ interface PageThumbnailProps {
index: number;
totalPages: number;
originalFile?: File;
fileColorIndex: number;
selectedPageIds: string[];
selectionMode: boolean;
movingPage: number | null;
isAnimating: boolean;
isBoxSelected?: boolean;
clearBoxSelection?: () => void;
activeDragIds: string[];
justMoved?: boolean;
pageRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
dragHandleProps?: any;
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void;
onTogglePage: (pageId: string) => void;
onAnimateReorder: () => void;
@@ -37,7 +46,8 @@ interface PageThumbnailProps {
pdfDocument: PDFDocument;
setPdfDocument: (doc: PDFDocument) => void;
splitPositions: Set<number>;
onInsertFiles?: (files: File[], insertAfterPage: number) => void;
onInsertFiles?: (files: File[] | StirlingFileStub[], insertAfterPage: number, isFromStorage?: boolean) => void;
zoomLevel?: number;
}
const PageThumbnail: React.FC<PageThumbnailProps> = ({
@@ -45,11 +55,16 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
index,
totalPages,
originalFile,
fileColorIndex,
selectedPageIds,
selectionMode,
movingPage,
isAnimating,
isBoxSelected = false,
clearBoxSelection,
activeDragIds,
pageRefs,
dragHandleProps,
onReorderPages,
onTogglePage,
onExecuteCommand,
@@ -61,15 +76,22 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
pdfDocument,
splitPositions,
onInsertFiles,
zoomLevel = 1.0,
justMoved = false,
}: PageThumbnailProps) => {
const [isDragging, setIsDragging] = useState(false);
const [isMouseDown, setIsMouseDown] = useState(false);
const [mouseStartPos, setMouseStartPos] = useState<{x: number, y: number} | null>(null);
const dragElementRef = useRef<HTMLDivElement>(null);
const [isHovered, setIsHovered] = useState(false);
const isMobile = useMediaQuery('(max-width: 1024px)');
const lastClickTimeRef = useRef<number>(0);
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration();
const elementRef = useRef<HTMLDivElement | null>(null);
const { getThumbnailFromCache, requestThumbnail} = useThumbnailGeneration();
const { openFilesModal } = useFilesModalContext();
// Check if this page is currently being dragged
const isDragging = activeDragIds.includes(page.id);
// Calculate document aspect ratio from first non-blank page
const getDocumentAspectRatio = useCallback(() => {
// Find first non-blank page with a thumbnail to get aspect ratio
@@ -126,63 +148,22 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
};
}, [page.id, page.thumbnail, originalFile, getThumbnailFromCache, requestThumbnail]);
const pageElementRef = useCallback((element: HTMLDivElement | null) => {
// Merge refs - combine our ref tracking with dnd-kit's ref
const mergedRef = useCallback((element: HTMLDivElement | null) => {
// Track in our refs map
elementRef.current = element;
if (element) {
pageRefs.current.set(page.id, element);
dragElementRef.current = element;
const dragCleanup = draggable({
element,
getInitialData: () => ({
pageNumber: page.pageNumber,
pageId: page.id,
selectedPageIds: [page.id]
}),
onDragStart: () => {
setIsDragging(true);
},
onDrop: ({ location }) => {
setIsDragging(false);
if (location.current.dropTargets.length === 0) {
return;
}
const dropTarget = location.current.dropTargets[0];
const targetData = dropTarget.data;
if (targetData.type === 'page') {
const targetPageNumber = targetData.pageNumber as number;
const targetIndex = pdfDocument.pages.findIndex(p => p.pageNumber === targetPageNumber);
if (targetIndex !== -1) {
onReorderPages(page.pageNumber, targetIndex, undefined);
}
}
}
});
element.style.cursor = 'grab';
const dropCleanup = dropTargetForElements({
element,
getData: () => ({
type: 'page',
pageNumber: page.pageNumber
}),
onDrop: (_) => {}
});
(element as any).__dragCleanup = () => {
dragCleanup();
dropCleanup();
};
} else {
pageRefs.current.delete(page.id);
if (dragElementRef.current && (dragElementRef.current as any).__dragCleanup) {
(dragElementRef.current as any).__dragCleanup();
}
}
}, [page.id, page.pageNumber, pageRefs, selectionMode, selectedPageIds, pdfDocument.pages, onReorderPages]);
// Call dnd-kit's ref if provided
if (dragHandleProps?.ref) {
dragHandleProps.ref(element);
}
}, [page.id, pageRefs, dragHandleProps]);
// DOM command handlers
const handleRotateLeft = useCallback((e: React.MouseEvent) => {
@@ -226,9 +207,9 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
// Open file manager modal with custom handler for page insertion
openFilesModal({
insertAfterPage: page.pageNumber,
customHandler: (files: File[], insertAfterPage?: number) => {
customHandler: (files: File[] | StirlingFileStub[], insertAfterPage?: number, isFromStorage?: boolean) => {
if (insertAfterPage !== undefined) {
onInsertFiles(files, insertAfterPage);
onInsertFiles(files, insertAfterPage, isFromStorage);
}
}
});
@@ -258,23 +239,110 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
const deltaY = Math.abs(e.clientY - mouseStartPos.y);
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
// If mouse moved less than 5 pixels, consider it a click (not a drag)
if (distance < 5 && !isDragging) {
onTogglePage(page.id);
// If mouse moved less than 2 pixels, consider it a click (not a drag)
if (distance < 2 && !isDragging) {
// Prevent rapid double-clicks from causing issues (debounce with 100ms threshold)
const now = Date.now();
if (now - lastClickTimeRef.current > 100) {
lastClickTimeRef.current = now;
// Clear box selection when clicking on a non-selected page
if (!isBoxSelected && clearBoxSelection) {
clearBoxSelection();
}
// Don't toggle page selection if it's box-selected (just keep the box selection)
if (!isBoxSelected) {
onTogglePage(page.id);
}
}
}
setIsMouseDown(false);
setMouseStartPos(null);
}, [isMouseDown, mouseStartPos, isDragging, page.id, onTogglePage]);
}, [isMouseDown, mouseStartPos, isDragging, page.id, isBoxSelected, clearBoxSelection, onTogglePage]);
const handleMouseLeave = useCallback(() => {
setIsMouseDown(false);
setMouseStartPos(null);
setIsHovered(false);
}, []);
const fileColorBorder = page.isBlankPage ? 'transparent' : getFileColorWithOpacity(fileColorIndex, 0.3);
// Spread dragHandleProps but use our merged ref
const { ref: _, ...restDragProps } = dragHandleProps || {};
// Build hover menu actions
const hoverActions = useMemo<HoverAction[]>(() => [
{
id: 'move-left',
icon: <ArrowBackIcon style={{ fontSize: 20 }} />,
label: 'Move Left',
onClick: (e) => {
e.stopPropagation();
if (index > 0 && !movingPage && !isAnimating) {
onSetMovingPage(page.pageNumber);
onReorderPages(page.pageNumber, index - 1);
setTimeout(() => onSetMovingPage(null), 650);
onSetStatus(`Moved page ${page.pageNumber} left`);
}
},
disabled: index === 0
},
{
id: 'move-right',
icon: <ArrowForwardIcon style={{ fontSize: 20 }} />,
label: 'Move Right',
onClick: (e) => {
e.stopPropagation();
if (index < totalPages - 1 && !movingPage && !isAnimating) {
onSetMovingPage(page.pageNumber);
onReorderPages(page.pageNumber, index + 1);
setTimeout(() => onSetMovingPage(null), 650);
onSetStatus(`Moved page ${page.pageNumber} right`);
}
},
disabled: index === totalPages - 1
},
{
id: 'rotate-left',
icon: <RotateLeftIcon style={{ fontSize: 20 }} />,
label: 'Rotate Left',
onClick: handleRotateLeft,
},
{
id: 'rotate-right',
icon: <RotateRightIcon style={{ fontSize: 20 }} />,
label: 'Rotate Right',
onClick: handleRotateRight,
},
{
id: 'delete',
icon: <DeleteIcon style={{ fontSize: 20 }} />,
label: 'Delete Page',
onClick: handleDelete,
color: 'red',
},
{
id: 'split',
icon: <ContentCutIcon style={{ fontSize: 20 }} />,
label: 'Split After',
onClick: handleSplit,
hidden: index >= totalPages - 1,
},
{
id: 'insert',
icon: <AddIcon style={{ fontSize: 20 }} />,
label: 'Insert File After',
onClick: handleInsertFileAfter,
}
], [index, totalPages, movingPage, isAnimating, page.pageNumber, handleRotateLeft, handleRotateRight, handleDelete, handleSplit, handleInsertFileAfter, onReorderPages, onSetMovingPage, onSetStatus]);
return (
<div
ref={pageElementRef}
ref={mergedRef}
{...restDragProps}
data-page-id={page.id}
data-page-number={page.pageNumber}
className={`
@@ -282,26 +350,28 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
!rounded-lg
${selectionMode ? 'cursor-pointer' : 'cursor-grab'}
select-none
w-[20rem]
h-[20rem]
flex items-center justify-center
flex-shrink-0
shadow-sm
hover:shadow-md
transition-all
relative
${selectionMode
? 'bg-white hover:bg-gray-50'
: 'bg-white hover:bg-gray-50'}
${isDragging ? 'opacity-50 scale-95' : ''}
${movingPage === page.pageNumber ? 'page-moving' : ''}
${isBoxSelected ? 'ring-4 ring-blue-400 ring-offset-2' : ''}
`}
style={{
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out'
width: `calc(20rem * ${zoomLevel})`,
height: `calc(20rem * ${zoomLevel})`,
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out',
zIndex: isHovered ? 50 : 1,
...(isBoxSelected && {
boxShadow: '0 0 0 4px rgba(59, 130, 246, 0.5)',
}),
}}
draggable={false}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={handleMouseLeave}
>
{
@@ -341,12 +411,13 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
<div className="page-container w-[90%] h-[90%]" draggable={false}>
<div
className={`${styles.pageSurface} ${justMoved ? styles.pageJustMoved : ''}`}
style={{
width: '100%',
height: '100%',
backgroundColor: 'var(--mantine-color-gray-1)',
borderRadius: 6,
border: '1px solid var(--mantine-color-gray-3)',
boxShadow: page.isBlankPage ? 'none' : `0 0 ${4 + 4 * zoomLevel}px 3px ${fileColorBorder}`,
padding: 4,
display: 'flex',
alignItems: 'center',
@@ -367,7 +438,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
backgroundColor: 'white',
border: '1px solid #e9ecef',
borderRadius: 2
}}></div>
}} />
</div>
) : thumbnailUrl ? (
<img
@@ -402,7 +473,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
position: 'absolute',
top: 5,
left: 5,
background: page.isBlankPage ? 'rgba(255, 165, 0, 0.8)' : 'rgba(162, 201, 255, 0.8)',
background: 'rgba(162, 201, 255, 0.8)',
padding: '6px 8px',
borderRadius: 8,
zIndex: 2,
@@ -413,128 +484,12 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
{page.pageNumber}
</Text>
<div
<HoverActionMenu
show={isHovered || isMobile}
actions={hoverActions}
position="inside"
className={styles.pageHoverControls}
style={{
position: 'absolute',
bottom: 8,
left: '50%',
transform: 'translateX(-50%)',
backgroundColor: 'var(--bg-toolbar)',
border: '1px solid var(--border-default)',
padding: '6px 12px',
borderRadius: 20,
opacity: 0,
transition: 'opacity 0.2s ease-in-out',
zIndex: 3,
display: 'flex',
gap: '8px',
alignItems: 'center',
whiteSpace: 'nowrap'
}}
onMouseDown={(e) => e.stopPropagation()}
onMouseUp={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<Tooltip label="Move Left">
<ActionIcon
size="md"
variant="subtle"
style={{ color: 'var(--mantine-color-dimmed)' }}
disabled={index === 0}
onClick={(e) => {
e.stopPropagation();
if (index > 0 && !movingPage && !isAnimating) {
onSetMovingPage(page.pageNumber);
// Actually move the page left (swap with previous page)
onReorderPages(page.pageNumber, index - 1);
setTimeout(() => onSetMovingPage(null), 650);
onSetStatus(`Moved page ${page.pageNumber} left`);
}
}}
>
<ArrowBackIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
<Tooltip label="Move Right">
<ActionIcon
size="md"
variant="subtle"
style={{ color: 'var(--mantine-color-dimmed)' }}
disabled={index === totalPages - 1}
onClick={(e) => {
e.stopPropagation();
if (index < totalPages - 1 && !movingPage && !isAnimating) {
onSetMovingPage(page.pageNumber);
// Actually move the page right (swap with next page)
onReorderPages(page.pageNumber, index + 1);
setTimeout(() => onSetMovingPage(null), 650);
onSetStatus(`Moved page ${page.pageNumber} right`);
}
}}
>
<ArrowForwardIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
<Tooltip label="Rotate Left">
<ActionIcon
size="md"
variant="subtle"
style={{ color: 'var(--mantine-color-dimmed)' }}
onClick={handleRotateLeft}
>
<RotateLeftIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
<Tooltip label="Rotate Right">
<ActionIcon
size="md"
variant="subtle"
style={{ color: 'var(--mantine-color-dimmed)' }}
onClick={handleRotateRight}
>
<RotateRightIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
<Tooltip label="Delete Page">
<ActionIcon
size="md"
variant="subtle"
c="red"
onClick={handleDelete}
>
<DeleteIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
{index < totalPages - 1 && (
<Tooltip label="Split After">
<ActionIcon
size="md"
variant="subtle"
style={{ color: 'var(--mantine-color-dimmed)' }}
onClick={handleSplit}
>
<ContentCutIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
)}
<Tooltip label="Insert File After">
<ActionIcon
size="md"
variant="subtle"
style={{ color: 'var(--mantine-color-dimmed)' }}
onClick={handleInsertFileAfter}
>
<AddIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
</div>
/>
</div>
@@ -161,7 +161,8 @@ export class ReorderPagesCommand extends DOMCommand {
private targetIndex: number,
private selectedPages: number[] | undefined,
private getCurrentDocument: () => PDFDocument | null,
private setDocument: (doc: PDFDocument) => void
private setDocument: (doc: PDFDocument) => void,
private onReorderComplete?: (newPages: PDFPage[]) => void
) {
super();
}
@@ -196,7 +197,13 @@ export class ReorderPagesCommand extends DOMCommand {
} else {
// Single page reorder
const [movedPage] = newPages.splice(sourceIndex, 1);
newPages.splice(this.targetIndex, 0, movedPage);
// Adjust target index if moving forward (after removal, indices shift)
const adjustedTargetIndex = sourceIndex < this.targetIndex
? this.targetIndex - 1
: this.targetIndex;
newPages.splice(adjustedTargetIndex, 0, movedPage);
newPages.forEach((page, index) => {
page.pageNumber = index + 1;
@@ -210,6 +217,11 @@ export class ReorderPagesCommand extends DOMCommand {
};
this.setDocument(reorderedDocument);
// Notify that reordering is complete
if (this.onReorderComplete) {
this.onReorderComplete(newPages);
}
}
undo(): void {
@@ -408,6 +420,14 @@ export class SplitAllCommand extends DOMCommand {
}
}
export type PageSize = 'A4' | 'Letter' | 'Legal' | 'A3' | 'A5';
export type PageOrientation = 'portrait' | 'landscape';
export interface PageBreakSettings {
size: PageSize;
orientation: PageOrientation;
}
export class PageBreakCommand extends DOMCommand {
private insertedPages: PDFPage[] = [];
private originalDocument: PDFDocument | null = null;
@@ -415,7 +435,8 @@ export class PageBreakCommand extends DOMCommand {
constructor(
private selectedPageNumbers: number[],
private getCurrentDocument: () => PDFDocument | null,
private setDocument: (doc: PDFDocument) => void
private setDocument: (doc: PDFDocument) => void,
private settings?: PageBreakSettings
) {
super();
}
@@ -450,7 +471,8 @@ export class PageBreakCommand extends DOMCommand {
rotation: 0,
selected: false,
splitAfter: false,
isBlankPage: true // Custom flag for blank pages
isBlankPage: true, // Custom flag for blank pages
pageBreakSettings: this.settings // Store settings for export
};
newPages.push(blankPage);
this.insertedPages.push(blankPage);
@@ -883,6 +905,10 @@ export class UndoManager {
return this.redoStack.length > 0;
}
hasHistory(): boolean {
return this.undoStack.length > 0;
}
clear(): void {
this.undoStack = [];
this.redoStack = [];
@@ -0,0 +1,61 @@
/**
* File color palette for page editor
* Each file gets a distinct color for visual organization
* Colors are applied at 0.3 opacity for subtle highlighting
* Maximum 20 files supported in page editor
*/
export const FILE_COLORS = [
// Subtle colors (1-6) - fit well with UI theme
'rgb(59, 130, 246)', // Blue
'rgb(16, 185, 129)', // Green
'rgb(139, 92, 246)', // Purple
'rgb(6, 182, 212)', // Cyan
'rgb(20, 184, 166)', // Teal
'rgb(99, 102, 241)', // Indigo
// Mid-range colors (7-12) - more distinct
'rgb(244, 114, 182)', // Pink
'rgb(251, 146, 60)', // Orange
'rgb(234, 179, 8)', // Yellow
'rgb(132, 204, 22)', // Lime
'rgb(248, 113, 113)', // Red
'rgb(168, 85, 247)', // Violet
// Vibrant colors (13-20) - maximum distinction
'rgb(236, 72, 153)', // Fuchsia
'rgb(245, 158, 11)', // Amber
'rgb(34, 197, 94)', // Emerald
'rgb(14, 165, 233)', // Sky
'rgb(239, 68, 68)', // Rose
'rgb(168, 162, 158)', // Stone
'rgb(251, 191, 36)', // Gold
'rgb(192, 132, 252)', // Light Purple
] as const;
export const MAX_PAGE_EDITOR_FILES = 20;
/**
* Get color for a file by its index
* @param index - Zero-based file index
* @returns RGB color string
*/
export function getFileColor(index: number): string {
if (index < 0 || index >= FILE_COLORS.length) {
console.warn(`File index ${index} out of range, using default color`);
return FILE_COLORS[0];
}
return FILE_COLORS[index];
}
/**
* Get color with specified opacity
* @param index - Zero-based file index
* @param opacity - Opacity value (0-1), defaults to 0.3
* @returns RGBA color string
*/
export function getFileColorWithOpacity(index: number, opacity: number = 0.2): string {
const rgb = getFileColor(index);
// Convert rgb(r, g, b) to rgba(r, g, b, a)
return rgb.replace('rgb(', 'rgba(').replace(')', `, ${opacity})`);
}
@@ -0,0 +1,34 @@
import { useMemo, useRef } from 'react';
import { FileId } from '../../../types/file';
/**
* Maintains stable color assignments for a collection of file IDs.
* Colors are assigned by insertion order and preserved across reorders.
*/
export function useFileColorMap(fileIds: FileId[]): Map<FileId, number> {
const assignmentsRef = useRef(new Map<FileId, number>());
const serializedIds = useMemo(() => fileIds.join(','), [fileIds]);
return useMemo(() => {
const assignments = assignmentsRef.current;
const activeIds = new Set(fileIds);
// Remove colors for files that no longer exist
for (const id of Array.from(assignments.keys())) {
if (!activeIds.has(id)) {
assignments.delete(id);
}
}
// Assign colors to any new files
fileIds.forEach((id) => {
if (!assignments.has(id)) {
assignments.set(id, assignments.size);
}
});
return assignments;
}, [serializedIds, fileIds]);
}
@@ -0,0 +1,22 @@
import { useState, useEffect } from 'react';
import { usePageDocument } from './usePageDocument';
import { PDFDocument } from '../../../types/pageEditor';
/**
* Hook that calls usePageDocument but only returns the FIRST non-null result
* After initialization, it ignores all subsequent updates
*/
export function useInitialPageDocument(): PDFDocument | null {
const { document: liveDocument } = usePageDocument();
const [initialDocument, setInitialDocument] = useState<PDFDocument | null>(null);
useEffect(() => {
// Only set once when we get the first non-null document
if (liveDocument && !initialDocument) {
console.log('📄 useInitialPageDocument: Captured initial document with', liveDocument.pages.length, 'pages');
setInitialDocument(liveDocument);
}
}, [liveDocument, initialDocument]);
return initialDocument;
}
@@ -1,5 +1,6 @@
import { useMemo } from 'react';
import { useFileState } from '../../../contexts/FileContext';
import { usePageEditor } from '../../../contexts/PageEditorContext';
import { PDFDocument, PDFPage } from '../../../types/pageEditor';
import { FileId } from '../../../types/file';
@@ -15,14 +16,30 @@ export interface PageDocumentHook {
*/
export function usePageDocument(): PageDocumentHook {
const { state, selectors } = useFileState();
const { fileOrder } = usePageEditor();
// Use PageEditorContext's fileOrder instead of FileContext's global order
// This ensures the page editor respects its own workspace ordering
const allFileIds = fileOrder;
// Derive selected file IDs directly from FileContext (single source of truth)
// Filter to only include PDF files (PageEditor only supports PDFs)
// Use stable string keys to prevent infinite loops
const allFileIdsKey = allFileIds.join(',');
const selectedFileIdsKey = [...state.ui.selectedFileIds].sort().join(',');
const activeFilesSignature = selectors.getFilesSignature();
// Get ALL PDF files (selected or not) for document building with placeholders
const activeFileIds = useMemo(() => {
return allFileIds.filter(id => {
const stub = selectors.getStirlingFileStub(id);
return stub?.name?.toLowerCase().endsWith('.pdf') ?? false;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allFileIdsKey, activeFilesSignature]);
// Prefer IDs + selectors to avoid array identity churn
const activeFileIds = state.files.ids;
const primaryFileId = activeFileIds[0] ?? null;
// Stable signature for effects (prevents loops)
const filesSignature = selectors.getFilesSignature();
// UI state
const globalProcessing = state.ui.isProcessing;
@@ -69,13 +86,28 @@ export function usePageDocument(): PageDocumentHook {
// Build pages by interleaving original pages with insertions
let pages: PDFPage[] = [];
// Helper function to create pages from a file
const createPagesFromFile = (fileId: FileId, startPageNumber: number): PDFPage[] => {
// Helper function to create pages from a file (or placeholder if deselected)
const createPagesFromFile = (fileId: FileId, startPageNumber: number, isSelected: boolean): PDFPage[] => {
const stirlingFileStub = selectors.getStirlingFileStub(fileId);
if (!stirlingFileStub) {
return [];
}
// If file is deselected, create a single placeholder page
if (!isSelected) {
return [{
id: `${fileId}-placeholder`,
pageNumber: startPageNumber,
originalPageNumber: 1,
originalFileId: fileId,
rotation: 0,
thumbnail: null,
selected: false,
splitAfter: false,
isPlaceholder: true,
}];
}
const processedFile = stirlingFileStub.processedFile;
let filePages: PDFPage[] = [];
@@ -90,6 +122,7 @@ export function usePageDocument(): PageDocumentHook {
splitAfter: page.splitAfter || false,
originalPageNumber: page.originalPageNumber || page.pageNumber || pageIndex + 1,
originalFileId: fileId,
isPlaceholder: false,
}));
} else if (processedFile?.totalPages) {
// Fallback: create pages without thumbnails but with correct count
@@ -102,16 +135,30 @@ export function usePageDocument(): PageDocumentHook {
thumbnail: null,
selected: false,
splitAfter: false,
isPlaceholder: false,
}));
}
return filePages;
};
// Collect all pages from original files (without renumbering yet)
// Collect all pages from original files, respecting their previous positions
const selectedFileIdsSet = new Set(state.ui.selectedFileIds);
// Sort original files by their position in fileOrder (so placeholders stay in correct spot)
// Use fileOrder as source of truth since it persists across page editor sessions
const fileOrderMap = new Map(allFileIds.map((id, index) => [id, index]));
const sortedOriginalFileIds = [...originalFileIds].sort((a, b) => {
const posA = fileOrderMap.get(a) ?? Number.MAX_SAFE_INTEGER;
const posB = fileOrderMap.get(b) ?? Number.MAX_SAFE_INTEGER;
return posA - posB;
});
const originalFilePages: PDFPage[] = [];
originalFileIds.forEach(fileId => {
const filePages = createPagesFromFile(fileId, 1); // Temporary numbering
sortedOriginalFileIds.forEach(fileId => {
const isSelected = selectedFileIdsSet.has(fileId);
const filePages = createPagesFromFile(fileId, 1, isSelected); // Temporary numbering
originalFilePages.push(...filePages);
});
@@ -130,7 +177,8 @@ export function usePageDocument(): PageDocumentHook {
// Collect all pages to insert
const allNewPages: PDFPage[] = [];
fileIds.forEach(fileId => {
const insertedPages = createPagesFromFile(fileId, 1);
const isSelected = selectedFileIdsSet.has(fileId);
const insertedPages = createPagesFromFile(fileId, 1, isSelected);
allNewPages.push(...insertedPages);
});
@@ -147,6 +195,13 @@ export function usePageDocument(): PageDocumentHook {
return null;
}
// Pages are already in the correct order from the sorted assembly above
// Just ensure page numbers are sequential
pages = pages.map((page, index) => ({
...page,
pageNumber: index + 1,
}));
const mergedDoc: PDFDocument = {
id: activeFileIds.join('-'),
name,
@@ -156,7 +211,7 @@ export function usePageDocument(): PageDocumentHook {
};
return mergedDoc;
}, [activeFileIds, primaryFileId, primaryStirlingFileStub, processedFilePages, processedFileTotalPages, selectors, filesSignature]);
}, [activeFileIds, primaryFileId, primaryStirlingFileStub, processedFilePages, processedFileTotalPages, selectors, activeFilesSignature, selectedFileIdsKey, state.ui.selectedFileIds, allFileIds]);
// Large document detection for smart loading
const isVeryLargeDocument = useMemo(() => {
@@ -0,0 +1,65 @@
import { useMemo } from 'react';
import { usePageEditor } from '../../../contexts/PageEditorContext';
import { useFileState } from '../../../contexts/FileContext';
import { FileId } from '../../../types/file';
import { useFileColorMap } from './useFileColorMap';
export interface PageEditorDropdownFile {
fileId: FileId;
name: string;
versionNumber?: number;
isSelected: boolean;
}
export interface PageEditorDropdownState {
files: PageEditorDropdownFile[];
selectedCount: number;
totalCount: number;
onToggleSelection: (fileId: FileId) => void;
onReorder: (fromIndex: number, toIndex: number) => void;
fileColorMap: Map<FileId, number>;
}
const isPdf = (name?: string | null) =>
typeof name === 'string' && name.toLowerCase().endsWith('.pdf');
export function usePageEditorDropdownState(): PageEditorDropdownState {
const { state, selectors } = useFileState();
const {
toggleFileSelection,
reorderFiles,
fileOrder,
} = usePageEditor();
const pageEditorFiles = useMemo(() => {
return fileOrder
.map<PageEditorDropdownFile | null>((fileId) => {
const stub = selectors.getStirlingFileStub(fileId);
if (!isPdf(stub?.name)) return null;
return {
fileId,
name: stub?.name || '',
versionNumber: stub?.versionNumber,
isSelected: state.ui.selectedFileIds.includes(fileId),
};
})
.filter((file): file is PageEditorDropdownFile => file !== null);
}, [fileOrder, selectors, state.ui.selectedFileIds]);
const fileColorMap = useFileColorMap(pageEditorFiles.map((file) => file.fileId));
const selectedCount = useMemo(
() => pageEditorFiles.filter((file) => file.isSelected).length,
[pageEditorFiles]
);
return useMemo<PageEditorDropdownState>(() => ({
files: pageEditorFiles,
selectedCount,
totalCount: pageEditorFiles.length,
onToggleSelection: toggleFileSelection,
onReorder: reorderFiles,
fileColorMap,
}), [pageEditorFiles, selectedCount, toggleFileSelection, reorderFiles, fileColorMap]);
}
@@ -0,0 +1,172 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useRightRailButtons, RightRailButtonWithAction } from '../../hooks/useRightRailButtons';
import LocalIcon from '../shared/LocalIcon';
import PageSelectByNumberButton from './PageSelectByNumberButton';
interface PageEditorRightRailButtonsParams {
totalPages: number;
selectedPageCount: number;
csvInput: string;
setCsvInput: (value: string) => void;
selectedPageIds: string[];
displayDocument?: { pages: { id: string; pageNumber: number }[] };
updatePagesFromCSV: (override?: string) => void;
handleSelectAll: () => void;
handleDeselectAll: () => void;
handleDelete: () => void;
onExportSelected: () => void;
onSaveChanges: () => void;
exportLoading: boolean;
activeFileCount: number;
closePdf: () => void;
}
export function usePageEditorRightRailButtons(params: PageEditorRightRailButtonsParams) {
const {
totalPages,
selectedPageCount,
csvInput,
setCsvInput,
selectedPageIds,
displayDocument,
updatePagesFromCSV,
handleSelectAll,
handleDeselectAll,
handleDelete,
onExportSelected,
onSaveChanges,
exportLoading,
activeFileCount,
closePdf,
} = params;
const { t } = useTranslation();
// Lift i18n labels out of memo for clarity
const selectAllLabel = t('rightRail.selectAll', 'Select All');
const deselectAllLabel = t('rightRail.deselectAll', 'Deselect All');
const selectByNumberLabel = t('rightRail.selectByNumber', 'Select by Page Numbers');
const deleteSelectedLabel = t('rightRail.deleteSelected', 'Delete Selected Pages');
const exportSelectedLabel = t('rightRail.exportSelected', 'Export Selected Pages');
const saveChangesLabel = t('rightRail.saveChanges', 'Save Changes');
const closePdfLabel = t('rightRail.closePdf', 'Close PDF');
const buttons = useMemo<RightRailButtonWithAction[]>(() => {
return [
{
id: 'page-select-all',
icon: <LocalIcon icon="select-all" width="1.5rem" height="1.5rem" />,
tooltip: selectAllLabel,
ariaLabel: selectAllLabel,
section: 'top' as const,
order: 10,
disabled: totalPages === 0 || selectedPageCount === totalPages,
visible: totalPages > 0,
onClick: handleSelectAll,
},
{
id: 'page-deselect-all',
icon: <LocalIcon icon="crop-square-outline" width="1.5rem" height="1.5rem" />,
tooltip: deselectAllLabel,
ariaLabel: deselectAllLabel,
section: 'top' as const,
order: 20,
disabled: selectedPageCount === 0,
visible: totalPages > 0,
onClick: handleDeselectAll,
},
{
id: 'page-select-by-number',
tooltip: selectByNumberLabel,
ariaLabel: selectByNumberLabel,
section: 'top' as const,
order: 30,
disabled: totalPages === 0,
visible: totalPages > 0,
render: ({ disabled }) => (
<PageSelectByNumberButton
disabled={disabled}
totalPages={totalPages}
label={selectByNumberLabel}
csvInput={csvInput}
setCsvInput={setCsvInput}
selectedPageIds={selectedPageIds}
displayDocument={displayDocument}
updatePagesFromCSV={updatePagesFromCSV}
/>
),
},
{
id: 'page-delete-selected',
icon: <LocalIcon icon="delete-outline-rounded" width="1.5rem" height="1.5rem" />,
tooltip: deleteSelectedLabel,
ariaLabel: deleteSelectedLabel,
section: 'top' as const,
order: 40,
disabled: selectedPageCount === 0,
visible: totalPages > 0,
onClick: handleDelete,
},
{
id: 'page-export-selected',
icon: <LocalIcon icon="download" width="1.5rem" height="1.5rem" />,
tooltip: exportSelectedLabel,
ariaLabel: exportSelectedLabel,
section: 'top' as const,
order: 50,
disabled: selectedPageCount === 0 || exportLoading,
visible: totalPages > 0,
onClick: onExportSelected,
},
{
id: 'page-save-changes',
icon: <LocalIcon icon="save" width="1.5rem" height="1.5rem" />,
tooltip: saveChangesLabel,
ariaLabel: saveChangesLabel,
section: 'top' as const,
order: 55,
disabled: totalPages === 0 || exportLoading,
visible: totalPages > 0,
onClick: onSaveChanges,
},
{
id: 'page-close-pdf',
icon: <LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />,
tooltip: closePdfLabel,
ariaLabel: closePdfLabel,
section: 'top' as const,
order: 60,
disabled: activeFileCount === 0,
visible: activeFileCount > 0,
onClick: closePdf,
},
];
}, [
t,
selectAllLabel,
deselectAllLabel,
selectByNumberLabel,
deleteSelectedLabel,
exportSelectedLabel,
saveChangesLabel,
closePdfLabel,
totalPages,
selectedPageCount,
csvInput,
setCsvInput,
selectedPageIds,
displayDocument,
updatePagesFromCSV,
handleSelectAll,
handleDeselectAll,
handleDelete,
onExportSelected,
onSaveChanges,
exportLoading,
activeFileCount,
closePdf,
]);
useRightRailButtons(buttons);
}
@@ -155,4 +155,4 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
);
};
export default AppConfigModal;
export default AppConfigModal;
@@ -0,0 +1,36 @@
import './dividerWithText/DividerWithText.css'
interface TextDividerProps {
text?: string
className?: string
style?: React.CSSProperties
variant?: 'default' | 'subcategory'
respondsToDarkMode?: boolean
opacity?: number
}
export default function DividerWithText({ text, className = '', style, variant = 'default', respondsToDarkMode = true, opacity }: TextDividerProps) {
const variantClass = variant === 'subcategory' ? 'subcategory' : ''
const themeClass = respondsToDarkMode ? '' : 'force-light'
const styleWithOpacity = opacity !== undefined ? { ...(style || {}), ['--text-divider-opacity' as any]: opacity } : style
if (text) {
return (
<div
className={`text-divider ${variantClass} ${themeClass} ${className}`}
style={styleWithOpacity}
>
<div className="text-divider__rule" />
<span className="text-divider__label">{text}</span>
<div className="text-divider__rule" />
</div>
)
}
return (
<div
className={`h-px my-2.5 ${themeClass} ${className}`}
style={styleWithOpacity}
/>
)
}
@@ -51,6 +51,7 @@ const FileCard = ({ file, fileStub, onRemove, onDoubleClick, onView, onEdit, isS
onMouseLeave={() => setIsHovered(false)}
onClick={onSelect}
data-testid="file-card"
data-tour="file-card-checkbox"
>
<Stack gap={6} align="center">
<Box
@@ -0,0 +1,78 @@
import React from 'react';
import { Menu, Loader, Group, Text } from '@mantine/core';
import VisibilityIcon from '@mui/icons-material/Visibility';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import FitText from './FitText';
interface FileDropdownMenuProps {
displayName: string;
activeFiles: Array<{ fileId: string; name: string; versionNumber?: number }>;
currentFileIndex: number;
onFileSelect?: (index: number) => void;
switchingTo?: string | null;
viewOptionStyle: React.CSSProperties;
pillRef?: React.RefObject<HTMLDivElement>;
}
export const FileDropdownMenu: React.FC<FileDropdownMenuProps> = ({
displayName,
activeFiles,
currentFileIndex,
onFileSelect,
switchingTo,
viewOptionStyle,
}) => {
return (
<Menu trigger="click" position="bottom" width="30rem">
<Menu.Target>
<div className="ph-no-capture" style={{...viewOptionStyle, cursor: 'pointer'}}>
{switchingTo === "viewer" ? (
<Loader size="xs" />
) : (
<VisibilityIcon fontSize="small" />
)}
<FitText className="ph-no-capture" text={displayName} fontSize={14} minimumFontScale={0.6} />
<KeyboardArrowDownIcon fontSize="small" />
</div>
</Menu.Target>
<Menu.Dropdown className="ph-no-capture" style={{
backgroundColor: 'var(--right-rail-bg)',
border: '1px solid var(--border-subtle)',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
maxHeight: '50vh',
overflowY: 'auto'
}}>
{activeFiles.map((file, index) => {
const itemName = file?.name || 'Untitled';
const isActive = index === currentFileIndex;
return (
<Menu.Item
key={file.fileId}
onClick={(e) => {
e.stopPropagation();
onFileSelect?.(index);
}}
className="viewer-file-tab ph-no-capture"
{...(isActive && { 'data-active': true })}
style={{
justifyContent: 'flex-start',
}}
>
<Group gap="xs" style={{ width: '100%', justifyContent: 'space-between' }}>
<div style={{ flex: 1, textAlign: 'left', minWidth: 0 }}>
<FitText className="ph-no-capture" text={itemName} fontSize={14} minimumFontScale={0.7} />
</div>
{file.versionNumber && file.versionNumber > 1 && (
<Text size="xs" c="dimmed" className="ph-no-capture">
v{file.versionNumber}
</Text>
)}
</Group>
</Menu.Item>
);
})}
</Menu.Dropdown>
</Menu>
);
};
@@ -0,0 +1,28 @@
/* Base Hover Menu */
.hoverMenu {
position: absolute;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 8px;
align-items: center;
background: var(--bg-toolbar);
border: 1px solid var(--border-default);
padding: 6px 12px;
border-radius: 20px;
box-shadow: var(--shadow-md);
z-index: 100;
white-space: nowrap;
pointer-events: auto;
transition: opacity 0.2s ease-in-out;
}
/* Inside positioning (Page Editor style) - within container */
.inside {
bottom: 8px;
}
/* Outside positioning (File Editor style) - below container */
.outside {
bottom: -8px;
}
@@ -0,0 +1,60 @@
import React from 'react';
import { ActionIcon, Tooltip } from '@mantine/core';
import styles from './HoverActionMenu.module.css';
export interface HoverAction {
id: string;
icon: React.ReactNode;
label: string;
onClick: (e: React.MouseEvent) => void;
disabled?: boolean;
color?: string;
hidden?: boolean;
}
interface HoverActionMenuProps {
show: boolean;
actions: HoverAction[];
position?: 'inside' | 'outside';
className?: string;
}
const HoverActionMenu: React.FC<HoverActionMenuProps> = ({
show,
actions,
position = 'inside',
className = ''
}) => {
const visibleActions = actions.filter(action => !action.hidden);
if (visibleActions.length === 0) {
return null;
}
return (
<div
className={`${styles.hoverMenu} ${position === 'outside' ? styles.outside : styles.inside} ${className}`}
style={{ opacity: show ? 1 : 0 }}
onMouseDown={(e) => e.stopPropagation()}
onMouseUp={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
{visibleActions.map((action) => (
<Tooltip key={action.id} label={action.label}>
<ActionIcon
size="md"
variant="subtle"
disabled={action.disabled}
onClick={action.onClick}
c={action.color}
style={{ color: action.color || 'var(--right-rail-icon)' }}
>
{action.icon}
</ActionIcon>
</Tooltip>
))}
</div>
);
};
export default HoverActionMenu;
@@ -0,0 +1,159 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { BASE_PATH } from '../../constants/app';
type ImageSlide = { src: string; alt?: string; cornerModelUrl?: string; title?: string; subtitle?: string; followMouseTilt?: boolean; tiltMaxDeg?: number }
export default function LoginRightCarousel({
imageSlides = [],
showBackground = true,
initialSeconds = 5,
slideSeconds = 8,
}: {
imageSlides?: ImageSlide[]
showBackground?: boolean
initialSeconds?: number
slideSeconds?: number
}) {
const totalSlides = imageSlides.length
const [index, setIndex] = useState(0)
const mouse = useRef({ x: 0, y: 0 })
const durationsMs = useMemo(() => {
if (imageSlides.length === 0) return []
return imageSlides.map((_, i) => (i === 0 ? (initialSeconds ?? slideSeconds) : slideSeconds) * 1000)
}, [imageSlides, initialSeconds, slideSeconds])
useEffect(() => {
if (totalSlides <= 1) return
const timeout = setTimeout(() => {
setIndex((i) => (i + 1) % totalSlides)
}, durationsMs[index] ?? slideSeconds * 1000)
return () => clearTimeout(timeout)
}, [index, totalSlides, durationsMs, slideSeconds])
useEffect(() => {
const onMove = (e: MouseEvent) => {
mouse.current.x = (e.clientX / window.innerWidth) * 2 - 1
mouse.current.y = (e.clientY / window.innerHeight) * 2 - 1
}
window.addEventListener('mousemove', onMove)
return () => window.removeEventListener('mousemove', onMove)
}, [])
function TiltImage({ src, alt, enabled, maxDeg = 6 }: { src: string; alt?: string; enabled: boolean; maxDeg?: number }) {
const imgRef = useRef<HTMLImageElement | null>(null)
useEffect(() => {
const el = imgRef.current
if (!el) return
let raf = 0
const tick = () => {
if (enabled) {
const rotY = (mouse.current.x || 0) * maxDeg
const rotX = -(mouse.current.y || 0) * maxDeg
el.style.transform = `translateY(-2rem) rotateX(${rotX.toFixed(2)}deg) rotateY(${rotY.toFixed(2)}deg)`
} else {
el.style.transform = 'translateY(-2rem)'
}
raf = requestAnimationFrame(tick)
}
raf = requestAnimationFrame(tick)
return () => cancelAnimationFrame(raf)
}, [enabled, maxDeg])
return (
<img
ref={imgRef}
src={src}
alt={alt ?? 'Carousel slide'}
style={{
maxWidth: '86%',
maxHeight: '78%',
objectFit: 'contain',
borderRadius: '18px',
background: 'transparent',
transform: 'translateY(-2rem)',
transition: 'transform 80ms ease-out',
willChange: 'transform',
transformOrigin: '50% 50%',
}}
/>
)
}
return (
<div style={{ position: 'relative', overflow: 'hidden', width: '100%', height: '100%' }}>
{showBackground && (
<img
src={`${BASE_PATH}/Login/LoginBackgroundPanel.png`}
alt="Background panel"
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }}
/>
)}
{/* Image slides */}
{imageSlides.map((s, idx) => (
<div
key={s.src}
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'opacity 600ms ease',
opacity: index === idx ? 1 : 0,
perspective: '900px',
}}
>
{(s.title || s.subtitle) && (
<div style={{ position: 'absolute', bottom: 24 + 32, left: 0, right: 0, textAlign: 'center', padding: '0 2rem', width: '100%' }}>
{s.title && (
<div style={{ fontSize: 20, fontWeight: 800, color: '#ffffff', textShadow: '0 2px 6px rgba(0,0,0,0.25)', marginBottom: 6 }}>{s.title}</div>
)}
{s.subtitle && (
<div style={{ fontSize: 13, color: 'rgba(255,255,255,0.92)', textShadow: '0 1px 4px rgba(0,0,0,0.25)' }}>{s.subtitle}</div>
)}
</div>
)}
<TiltImage src={s.src} alt={s.alt} enabled={index === idx && !!s.followMouseTilt} maxDeg={s.tiltMaxDeg ?? 6} />
</div>
))}
{/* Dot navigation */}
<div
style={{
position: 'absolute',
bottom: 16,
left: 0,
right: 0,
display: 'flex',
justifyContent: 'center',
gap: 10,
zIndex: 2,
}}
>
{Array.from({ length: totalSlides }).map((_, i) => (
<button
key={i}
aria-label={`Go to slide ${i + 1}`}
onClick={() => setIndex(i)}
style={{
width: '10px',
height: '12px',
borderRadius: '50%',
border: 'none',
cursor: 'pointer',
backgroundColor: i === index ? '#ffffff' : 'rgba(255,255,255,0.5)',
boxShadow: '0 2px 6px rgba(0,0,0,0.25)',
display: 'block',
flexShrink: 0,
}}
/>
))}
</div>
</div>
)
}
@@ -0,0 +1,328 @@
import React, { useRef, useState, useEffect } from 'react';
import { Menu, Loader, Group, Text, Checkbox } from '@mantine/core';
import { LocalIcon } from '../shared/LocalIcon';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import AddIcon from '@mui/icons-material/Add';
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import FitText from './FitText';
import { getFileColorWithOpacity } from '../pageEditor/fileColors';
import { useFilesModalContext } from '../../contexts/FilesModalContext';
import { FileId } from '../../types/file';
// Local interface for PageEditor file display
interface PageEditorFile {
fileId: FileId;
name: string;
versionNumber?: number;
isSelected: boolean;
}
interface FileMenuItemProps {
file: PageEditorFile;
index: number;
colorIndex: number;
onToggleSelection: (fileId: FileId) => void;
onReorder: (fromIndex: number, toIndex: number) => void;
}
const FileMenuItem: React.FC<FileMenuItemProps> = ({
file,
index,
colorIndex,
onToggleSelection,
onReorder,
}) => {
const [isDragging, setIsDragging] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const [dropPosition, setDropPosition] = useState<'above' | 'below'>('below');
const itemRef = useRef<HTMLDivElement>(null);
// Keep latest values without re-registering DnD
const indexRef = useRef(index);
const fileIdRef = useRef(file.fileId);
const dropPositionRef = useRef<'above' | 'below'>('below');
useEffect(() => { indexRef.current = index; }, [index]);
useEffect(() => { fileIdRef.current = file.fileId; }, [file.fileId]);
useEffect(() => { dropPositionRef.current = dropPosition; }, [dropPosition]);
// NEW: keep latest onReorder without effect re-run
const onReorderRef = useRef(onReorder);
useEffect(() => { onReorderRef.current = onReorder; }, [onReorder]);
// Gesture guard for row click vs drag
const movedRef = useRef(false);
const startRef = useRef<{ x: number; y: number } | null>(null);
const onPointerDown = (e: React.PointerEvent) => {
startRef.current = { x: e.clientX, y: e.clientY };
movedRef.current = false;
};
const onPointerMove = (e: React.PointerEvent) => {
if (!startRef.current) return;
const dx = e.clientX - startRef.current.x;
const dy = e.clientY - startRef.current.y;
if (dx * dx + dy * dy > 25) movedRef.current = true; // ~5px threshold
};
const onPointerUp = () => {
startRef.current = null;
};
useEffect(() => {
const element = itemRef.current;
if (!element) return;
const dragCleanup = draggable({
element,
getInitialData: () => ({
type: 'file-item',
fileId: fileIdRef.current,
fromIndex: indexRef.current,
}),
onDragStart: () => setIsDragging((p) => (p ? p : true)),
onDrop: () => setIsDragging((p) => (p ? false : p)),
canDrag: () => true,
});
const dropCleanup = dropTargetForElements({
element,
getData: () => ({
type: 'file-item',
fileId: fileIdRef.current,
toIndex: indexRef.current,
}),
onDragEnter: () => setIsDragOver((p) => (p ? p : true)),
onDragLeave: () => {
setIsDragOver((p) => (p ? false : p));
setDropPosition('below');
},
onDrag: ({ source }) => {
// Determine drop position based on cursor location
const element = itemRef.current;
if (!element) return;
const rect = element.getBoundingClientRect();
const clientY = (source as any).element?.getBoundingClientRect().top || 0;
const midpoint = rect.top + rect.height / 2;
setDropPosition(clientY < midpoint ? 'below' : 'above');
},
onDrop: ({ source }) => {
setIsDragOver(false);
const dropPos = dropPositionRef.current;
setDropPosition('below');
const sourceData = source.data as any;
if (sourceData?.type === 'file-item') {
const fromIndex = sourceData.fromIndex as number;
let toIndex = indexRef.current;
// Adjust toIndex based on drop position
// If dropping below and dragging from above, or dropping above and dragging from below
if (dropPos === 'below' && fromIndex < toIndex) {
// Dragging down, drop after target - no adjustment needed
} else if (dropPos === 'above' && fromIndex > toIndex) {
// Dragging up, drop before target - no adjustment needed
} else if (dropPos === 'below' && fromIndex > toIndex) {
// Dragging up but want below target
toIndex = toIndex + 1;
} else if (dropPos === 'above' && fromIndex < toIndex) {
// Dragging down but want above target
toIndex = toIndex - 1;
}
if (fromIndex !== toIndex) {
onReorderRef.current(fromIndex, toIndex);
}
}
}
});
return () => {
try { dragCleanup(); } catch { /* cleanup */ }
try { dropCleanup(); } catch { /* cleanup */ }
};
}, []); // NOTE: no `onReorder` here
const itemName = file?.name || 'Untitled';
const fileColorBorder = getFileColorWithOpacity(colorIndex, 1);
const fileColorBorderHover = getFileColorWithOpacity(colorIndex, 1.0);
return (
<div
style={{
position: 'relative',
marginBottom: '0.5rem',
}}
>
{/* Drop indicator line */}
{isDragOver && (
<div
style={{
position: 'absolute',
...(dropPosition === 'above' ? { top: '-2px' } : { bottom: '-2px' }),
left: 0,
right: 0,
height: '4px',
backgroundColor: 'rgb(59, 130, 246)',
borderRadius: '2px',
zIndex: 10,
}}
/>
)}
<div
ref={itemRef}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onClick={(e) => {
e.stopPropagation();
if (movedRef.current) return; // ignore click after drag
onToggleSelection(file.fileId);
}}
style={{
padding: '0.75rem 0.75rem',
cursor: isDragging ? 'grabbing' : 'grab',
backgroundColor: file.isSelected ? 'rgba(0, 0, 0, 0.05)' : 'transparent',
borderLeft: `6px solid ${fileColorBorder}`,
opacity: isDragging ? 0.5 : 1,
transition: 'opacity 0.2s ease-in-out, background-color 0.15s ease',
userSelect: 'none',
}}
onMouseEnter={(e) => {
if (!isDragging) {
(e.currentTarget as HTMLDivElement).style.backgroundColor = 'rgba(0, 0, 0, 0.05)';
(e.currentTarget as HTMLDivElement).style.borderLeftColor = fileColorBorderHover;
}
}}
onMouseLeave={(e) => {
if (!isDragging) {
(e.currentTarget as HTMLDivElement).style.backgroundColor = file.isSelected ? 'rgba(0, 0, 0, 0.05)' : 'transparent';
(e.currentTarget as HTMLDivElement).style.borderLeftColor = fileColorBorder;
}
}}
>
<Group gap="xs" style={{ width: '100%' }}>
<div
style={{
cursor: 'grab',
display: 'flex',
alignItems: 'center',
color: 'var(--mantine-color-dimmed)',
}}
>
<DragIndicatorIcon fontSize="small" />
</div>
<Checkbox
checked={file.isSelected}
onChange={() => onToggleSelection(file.fileId)}
onClick={(e) => e.stopPropagation()}
size="sm"
/>
<div style={{ flex: 1, textAlign: 'left', minWidth: 0 }}>
<FitText className="ph-no-capture" text={itemName} fontSize={14} minimumFontScale={0.7} />
</div>
{file.versionNumber && file.versionNumber > 1 && (
<Text size="xs" c="dimmed" className="ph-no-capture">
v{file.versionNumber}
</Text>
)}
</Group>
</div>
</div>
);
};
interface PageEditorFileDropdownProps {
files: PageEditorFile[];
onToggleSelection: (fileId: FileId) => void;
onReorder: (fromIndex: number, toIndex: number) => void;
switchingTo?: string | null;
viewOptionStyle: React.CSSProperties;
fileColorMap: Map<string, number>;
selectedCount: number;
totalCount: number;
}
export const PageEditorFileDropdown: React.FC<PageEditorFileDropdownProps> = ({
files,
onToggleSelection,
onReorder,
switchingTo,
viewOptionStyle,
fileColorMap,
selectedCount,
totalCount,
}) => {
const { openFilesModal } = useFilesModalContext();
return (
<Menu trigger="click" position="bottom" width="40rem">
<Menu.Target>
<div className="ph-no-capture" style={{...viewOptionStyle, cursor: 'pointer'}}>
{switchingTo === "pageEditor" ? (
<Loader size="xs" />
) : (
<LocalIcon icon="dashboard-customize-rounded" width="1.4rem" height="1.4rem" />
)}
<span className="ph-no-capture">{selectedCount}/{totalCount} files selected</span>
<KeyboardArrowDownIcon fontSize="small" />
</div>
</Menu.Target>
<Menu.Dropdown className="ph-no-capture" style={{
backgroundColor: 'var(--right-rail-bg)',
border: '1px solid var(--border-subtle)',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
maxHeight: '80vh',
overflowY: 'auto'
}}>
{files.map((file, index) => {
const colorIndex = fileColorMap.get(file.fileId as string) ?? 0;
return (
<FileMenuItem
key={file.fileId}
file={file}
index={index}
colorIndex={colorIndex}
onToggleSelection={onToggleSelection}
onReorder={onReorder}
/>
);
})}
{/* Add File Button */}
<div
onClick={(e) => {
e.stopPropagation();
openFilesModal();
}}
style={{
padding: '0.75rem 0.75rem',
marginTop: '0.5rem',
cursor: 'pointer',
backgroundColor: 'transparent',
borderTop: '1px solid var(--border-subtle)',
transition: 'background-color 0.15s ease',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLDivElement).style.backgroundColor = 'rgba(59, 130, 246, 0.25)';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLDivElement).style.backgroundColor = 'transparent';
}}
>
<Group gap="xs" style={{ width: '100%' }}>
<AddIcon fontSize="small" style={{ color: 'var(--mantine-color-text)' }} />
<Text size="sm" fw={500} style={{ color: 'var(--mantine-color-text)' }} className="ph-no-capture">
Add File
</Text>
</Group>
</div>
</Menu.Dropdown>
</Menu>
);
};
@@ -14,6 +14,7 @@ import AllToolsNavButton from './AllToolsNavButton';
import ActiveToolButton from "./quickAccessBar/ActiveToolButton";
import AppConfigModal from './AppConfigModal';
import { useAppConfig } from '../../hooks/useAppConfig';
import { useOnboarding } from '../../contexts/OnboardingContext';
import {
isNavButtonActive,
getNavButtonStyle,
@@ -27,6 +28,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
const { handleReaderToggle, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow();
const { getToolNavigation } = useSidebarNavigation();
const { config } = useAppConfig();
const { startTour } = useOnboarding();
const [configModalOpen, setConfigModalOpen] = useState(false);
const [activeButton, setActiveButton] = useState<string>('tools');
const scrollableRef = useRef<HTMLDivElement>(null);
@@ -60,7 +62,12 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
// Render navigation button with conditional URL support
return (
<div key={config.id} className="flex flex-col items-center gap-1" style={{ marginTop: index === 0 ? '0.5rem' : "0rem" }}>
<div
key={config.id}
className="flex flex-col items-center gap-1"
style={{ marginTop: index === 0 ? '0.5rem' : "0rem" }}
data-tour={`${config.id}-button`}
>
<ActionIcon
{...(navProps ? {
component: "a" as const,
@@ -88,8 +95,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
);
};
const buttonConfigs: ButtonConfig[] = [
const mainButtons: ButtonConfig[] = [
{
id: 'read',
name: t("quickAccess.read", "Read"),
@@ -131,6 +137,9 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
}
}
},
];
const middleButtons: ButtonConfig[] = [
{
id: 'files',
name: t("quickAccess.files", "Files"),
@@ -150,6 +159,20 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
// type: 'navigation',
// onClick: () => setActiveButton('activity')
//},
];
const bottomButtons: ButtonConfig[] = [
{
id: 'help',
name: t("quickAccess.help", "Help"),
icon: <LocalIcon icon="help-rounded" width="1.5rem" height="1.5rem" />,
isRound: true,
size: 'lg',
type: 'action',
onClick: () => {
startTour();
},
},
{
id: 'config',
name: config?.enableLogin ? t("quickAccess.account", "Account") : t("quickAccess.config", "Config"),
@@ -162,8 +185,6 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
}
];
return (
<div
ref={ref}
@@ -198,49 +219,41 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
}}
>
<div className="scrollable-content">
{/* Top section with main buttons */}
{/* Main navigation section */}
<Stack gap="lg" align="center">
{buttonConfigs.slice(0, -1).map((config, index) => (
{mainButtons.map((config, index) => (
<React.Fragment key={config.id}>
{renderNavButton(config, index)}
{/* Add divider after Automate button (index 1) and Files button (index 2) */}
{index === 1 && (
<Divider
size="xs"
className="content-divider"
/>
)}
</React.Fragment>
))}
</Stack>
{/* Spacer to push Config button to bottom */}
{/* Divider after main buttons */}
<Divider
size="xs"
className="content-divider"
/>
{/* Middle section */}
<Stack gap="lg" align="center">
{middleButtons.map((config, index) => (
<React.Fragment key={config.id}>
{renderNavButton(config, index)}
</React.Fragment>
))}
</Stack>
{/* Spacer to push bottom buttons to bottom */}
<div className="spacer" />
{/* Config button at the bottom */}
{buttonConfigs
.filter(config => config.id === 'config')
.map(config => (
<div key={config.id} className="flex flex-col items-center gap-1">
<ActionIcon
size={config.size || 'lg'}
variant="subtle"
onClick={config.onClick}
style={getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView)}
className={isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView) ? 'activeIconScale' : ''}
aria-label={config.name}
data-testid={`${config.id}-button`}
>
<span className="iconContainer">
{config.icon}
</span>
</ActionIcon>
<span className={`button-text ${isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView) ? 'active' : 'inactive'}`}>
{config.name}
</span>
</div>
{/* Bottom section */}
<Stack gap="lg" align="center">
{bottomButtons.map((config, index) => (
<React.Fragment key={config.id}>
{renderNavButton(config, index)}
</React.Fragment>
))}
</Stack>
</div>
</div>
@@ -6,9 +6,10 @@ import rainbowStyles from '../../styles/rainbow.module.css';
import { ToastProvider } from '../toast';
import ToastRenderer from '../toast/ToastRenderer';
import { ToastPortalBinder } from '../toast';
import type { ThemeMode } from '../../constants/theme';
interface RainbowThemeContextType {
themeMode: 'light' | 'dark' | 'rainbow';
themeMode: ThemeMode;
isRainbowMode: boolean;
isToggleDisabled: boolean;
toggleTheme: () => void;
+159 -426
View File
@@ -1,457 +1,193 @@
import React, { useCallback, useState, useEffect, useMemo } from 'react';
import { ActionIcon, Divider, Popover } from '@mantine/core';
import LocalIcon from './LocalIcon';
import React, { useCallback, useMemo } from 'react';
import { ActionIcon, Divider } from '@mantine/core';
import './rightRail/RightRail.css';
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
import { useRightRail } from '../../contexts/RightRailContext';
import { useFileState, useFileSelection, useFileManagement, useFileContext } from '../../contexts/FileContext';
import { useFileState, useFileSelection } from '../../contexts/FileContext';
import { useNavigationState } from '../../contexts/NavigationContext';
import { useTranslation } from 'react-i18next';
import LanguageSelector from '../shared/LanguageSelector';
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
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 ViewerAnnotationControls from './rightRail/ViewerAnnotationControls';
import { parseSelection } from '../../utils/bulkselection/parseSelection';
import LocalIcon from './LocalIcon';
import { useSidebarContext } from '../../contexts/SidebarContext';
import { RightRailButtonConfig, RightRailRenderContext, RightRailSection } from '../../types/rightRail';
const SECTION_ORDER: RightRailSection[] = ['top', 'middle', 'bottom'];
function renderWithTooltip(
node: React.ReactNode,
tooltip: React.ReactNode | undefined
) {
if (!tooltip) return node;
const portalTarget = typeof document !== 'undefined' ? document.body : undefined;
return (
<Tooltip content={tooltip} position="left" offset={12} arrow portalTarget={portalTarget}>
<div className="right-rail-tooltip-wrapper">{node}</div>
</Tooltip>
);
}
export default function RightRail() {
const { sidebarRefs } = useSidebarContext();
const { t } = useTranslation();
const [isPanning, setIsPanning] = useState(false);
// Viewer context for PDF controls - safely handle when not available
const viewerContext = React.useContext(ViewerContext);
const { toggleTheme } = useRainbowThemeContext();
const { buttons, actions, allButtonsDisabled } = useRightRail();
const topButtons = useMemo(() => buttons.filter(b => (b.section || 'top') === 'top' && (b.visible ?? true)), [buttons]);
// Access PageEditor functions for page-editor-specific actions
const { pageEditorFunctions, toolPanelMode, leftPanelView } = useToolWorkflow();
const disableForFullscreen = toolPanelMode === 'fullscreen' && leftPanelView === 'toolPicker';
// CSV input state for page selection
const [csvInput, setCsvInput] = useState<string>("");
// Navigation view
const { workbench: currentView } = useNavigationState();
// 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 { selectors } = useFileState();
const { selectedFiles, selectedFileIds } = useFileSelection();
const { signaturesApplied } = useSignature();
const activeFiles = selectors.getFiles();
const filesSignature = selectors.getFilesSignature();
// Compute selection state and total items
const getSelectionState = useCallback(() => {
if (currentView === 'fileEditor' || currentView === 'viewer') {
const totalItems = activeFiles.length;
const selectedCount = selectedFileIds.length;
return { totalItems, selectedCount };
}
if (currentView === 'pageEditor') {
// Use PageEditor's own state
const totalItems = pageEditorFunctions?.totalPages || 0;
const selectedCount = pageEditorFunctions?.selectedPageIds?.length || 0;
return { totalItems, selectedCount };
}
return { totalItems: 0, selectedCount: 0 };
}, [currentView, activeFiles, selectedFileIds, pageEditorFunctions]);
const { totalItems, selectedCount } = getSelectionState();
// Get export state for viewer mode
const pageEditorTotalPages = pageEditorFunctions?.totalPages ?? 0;
const pageEditorSelectedCount = pageEditorFunctions?.selectedPageIds?.length ?? 0;
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;
}
const totalItems = useMemo(() => {
if (currentView === 'pageEditor') return pageEditorTotalPages;
return activeFiles.length;
}, [currentView, pageEditorTotalPages, activeFiles.length]);
const selectedCount = useMemo(() => {
if (currentView === 'pageEditor') {
// Use PageEditor's select all function
pageEditorFunctions?.handleSelectAll?.();
return pageEditorSelectedCount;
}
}, [currentView, state.files.ids, setSelectedFiles, pageEditorFunctions]);
return selectedFileIds.length;
}, [currentView, pageEditorSelectedCount, selectedFileIds.length]);
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') {
// Use PageEditor's deselect all function
pageEditorFunctions?.handleDeselectAll?.();
}
}, [currentView, setSelectedFiles, pageEditorFunctions]);
const sectionsWithButtons = useMemo(() => {
return SECTION_ORDER
.map(section => {
const sectionButtons = buttons.filter(btn => (btn.section ?? 'top') === section && (btn.visible ?? true));
return { section, buttons: sectionButtons };
})
.filter(entry => entry.buttons.length > 0);
}, [buttons]);
const renderButton = useCallback(
(btn: RightRailButtonConfig) => {
const action = actions[btn.id];
const disabled = Boolean(btn.disabled || allButtonsDisabled || disableForFullscreen);
const triggerAction = () => {
if (!disabled) action?.();
};
if (btn.render) {
const context: RightRailRenderContext = {
id: btn.id,
disabled,
allButtonsDisabled,
action,
triggerAction,
};
return btn.render(context) ?? null;
}
if (!btn.icon) return null;
const ariaLabel =
btn.ariaLabel || (typeof btn.tooltip === 'string' ? (btn.tooltip as string) : undefined);
const className = ['right-rail-icon', btn.className].filter(Boolean).join(' ');
const buttonNode = (
<ActionIcon
variant="subtle"
radius="md"
className={className}
onClick={triggerAction}
disabled={disabled}
aria-label={ariaLabel}
>
{btn.icon}
</ActionIcon>
);
return renderWithTooltip(buttonNode, btn.tooltip);
},
[actions, allButtonsDisabled, disableForFullscreen]
);
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;
filesToDownload.forEach(file => {
const link = document.createElement('a');
link.href = URL.createObjectURL(file);
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
});
} else if (currentView === 'pageEditor') {
// Export all pages (not just selected)
pageEditorFunctions?.onExportAll?.();
return;
}
}, [currentView, activeFiles, selectedFiles, pageEditorFunctions, viewerContext, signaturesApplied, selectors, fileActions]);
const handleCloseSelected = useCallback(() => {
if (currentView !== 'fileEditor') return;
if (selectedFileIds.length === 0) return;
// Close only selected files (do not delete from storage)
removeFiles(selectedFileIds, false);
// Clear selection after closing
setSelectedFiles([]);
}, [currentView, selectedFileIds, removeFiles, setSelectedFiles]);
const updatePagesFromCSV = useCallback((override?: string) => {
const maxPages = pageEditorFunctions?.totalPages || 0;
const normalized = parseSelection(override ?? csvInput, maxPages);
pageEditorFunctions?.handleSetSelectedPages?.(normalized);
}, [csvInput, pageEditorFunctions]);
// Do not overwrite user's expression input when selection changes.
// Clear CSV input when files change (use stable signature to avoid ref churn)
useEffect(() => {
setCsvInput("");
}, [filesSignature]);
// Mount/visibility for page-editor-only buttons to allow exit animation, then remove to avoid flex gap
const [pageControlsMounted, setPageControlsMounted] = useState<boolean>(currentView === 'pageEditor');
const [pageControlsVisible, setPageControlsVisible] = useState<boolean>(currentView === 'pageEditor');
useEffect(() => {
if (currentView === 'pageEditor') {
// Mount and show
setPageControlsMounted(true);
// Next tick to ensure transition applies
requestAnimationFrame(() => setPageControlsVisible(true));
} else {
// Start exit animation
setPageControlsVisible(false);
// After transition, unmount to remove flex gap
const timer = setTimeout(() => setPageControlsMounted(false), 240);
return () => clearTimeout(timer);
pageEditorFunctions?.onExportAll?.();
return;
}
}, [currentView]);
const filesToDownload = selectedFiles.length > 0 ? selectedFiles : activeFiles;
filesToDownload.forEach(file => {
const link = document.createElement('a');
link.href = URL.createObjectURL(file);
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
});
}, [
currentView,
selectedFiles,
activeFiles,
pageEditorFunctions,
viewerContext,
signaturesApplied
]);
const downloadTooltip = useMemo(() => {
if (currentView === 'pageEditor') {
return t('rightRail.exportAll', 'Export PDF');
}
if (selectedCount > 0) {
return t('rightRail.downloadSelected', 'Download Selected Files');
}
return t('rightRail.downloadAll', 'Download All');
}, [currentView, selectedCount, t]);
return (
<div ref={sidebarRefs.rightRailRef} className={`right-rail`} data-sidebar="right-rail">
<div ref={sidebarRefs.rightRailRef} className="right-rail" data-sidebar="right-rail">
<div className="right-rail-inner">
{topButtons.length > 0 && (
<>
<div className="right-rail-section">
{topButtons.map(btn => (
<Tooltip key={btn.id} content={btn.tooltip} position="left" offset={12} arrow portalTarget={document.body}>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={() => actions[btn.id]?.()}
disabled={btn.disabled || allButtonsDisabled || disableForFullscreen}
{sectionsWithButtons.map(({ section, buttons: sectionButtons }) => (
<React.Fragment key={section}>
<div className="right-rail-section" data-tour="right-rail-controls">
{sectionButtons.map((btn, index) => {
const content = renderButton(btn);
if (!content) return null;
return (
<div
key={btn.id}
className="right-rail-button-wrapper"
style={{ animationDelay: `${index * 50}ms` }}
>
{btn.icon}
</ActionIcon>
</Tooltip>
))}
{content}
</div>
);
})}
</div>
<Divider className="right-rail-divider" />
</>
)}
{/* Group: PDF Viewer Controls - visible only in viewer mode */}
<div
className={`right-rail-slot ${currentView === 'viewer' ? 'visible right-rail-enter' : 'right-rail-exit'}`}
aria-hidden={currentView !== 'viewer'}
>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
{/* Search */}
<Tooltip content={t('rightRail.search', 'Search PDF')} position="left" offset={12} arrow portalTarget={document.body}>
<Popover position="left" withArrow shadow="md" offset={8}>
<Popover.Target>
<div style={{ display: 'inline-flex' }}>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen}
aria-label={typeof t === 'function' ? t('rightRail.search', 'Search PDF') : 'Search PDF'}
>
<LocalIcon icon="search" width="1.5rem" height="1.5rem" />
</ActionIcon>
</div>
</Popover.Target>
<Popover.Dropdown>
<div style={{ minWidth: '20rem' }}>
<SearchInterface
visible={true}
onClose={() => {}}
/>
</div>
</Popover.Dropdown>
</Popover>
</Tooltip>
{/* Pan Mode */}
<Tooltip content={t('rightRail.panMode', 'Pan Mode')} position="left" offset={12} arrow portalTarget={document.body}>
<ActionIcon
variant={isPanning ? "filled" : "subtle"}
color={isPanning ? "blue" : undefined}
radius="md"
className="right-rail-icon"
onClick={() => {
viewerContext?.panActions.togglePan();
setIsPanning(!isPanning);
}}
disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen}
>
<LocalIcon icon="pan-tool-rounded" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
{/* Rotate Left */}
<Tooltip content={t('rightRail.rotateLeft', 'Rotate Left')} position="left" offset={12} arrow portalTarget={document.body}>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={() => {
viewerContext?.rotationActions.rotateBackward();
}}
disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen}
>
<LocalIcon icon="rotate-left" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
{/* Rotate Right */}
<Tooltip content={t('rightRail.rotateRight', 'Rotate Right')} position="left" offset={12} arrow portalTarget={document.body}>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={() => {
viewerContext?.rotationActions.rotateForward();
}}
disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen}
>
<LocalIcon icon="rotate-right" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
{/* Sidebar Toggle */}
<Tooltip content={t('rightRail.toggleSidebar', 'Toggle Sidebar')} position="left" offset={12} arrow portalTarget={document.body}>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={() => {
viewerContext?.toggleThumbnailSidebar();
}}
disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen}
>
<LocalIcon icon="view-list" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
{/* Annotation Controls */}
<ViewerAnnotationControls
currentView={currentView}
disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen}
/>
</div>
<Divider className="right-rail-divider" />
</div>
{/* Group: Selection controls + Close, animate as one unit when entering/leaving viewer */}
<div
className={`right-rail-slot ${currentView !== 'viewer' ? 'visible right-rail-enter' : 'right-rail-exit'}`}
aria-hidden={currentView === 'viewer'}
>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
{/* Select All Button */}
<Tooltip content={t('rightRail.selectAll', 'Select All')} position="left" offset={12} arrow portalTarget={document.body}>
<div>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={handleSelectAll}
disabled={currentView === 'viewer' || totalItems === 0 || selectedCount === totalItems || allButtonsDisabled || disableForFullscreen}
>
<LocalIcon icon="select-all" width="1.5rem" height="1.5rem" />
</ActionIcon>
</div>
</Tooltip>
{/* Deselect All Button */}
<Tooltip content={t('rightRail.deselectAll', 'Deselect All')} position="left" offset={12} arrow portalTarget={document.body}>
<div>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={handleDeselectAll}
disabled={currentView === 'viewer' || selectedCount === 0 || allButtonsDisabled || disableForFullscreen}
>
<LocalIcon icon="crop-square-outline" width="1.5rem" height="1.5rem" />
</ActionIcon>
</div>
</Tooltip>
{/* Select by Numbers - page editor only, with animated presence */}
{pageControlsMounted && (
<Tooltip content={t('rightRail.selectByNumber', 'Select by Page Numbers')} position="left" offset={12} arrow portalTarget={document.body}>
<div className={`right-rail-fade ${pageControlsVisible ? 'enter' : 'exit'}`} aria-hidden={!pageControlsVisible}>
<Popover position="left" withArrow shadow="md" offset={8}>
<Popover.Target>
<div style={{ display: 'inline-flex' }}>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
disabled={!pageControlsVisible || totalItems === 0 || allButtonsDisabled || disableForFullscreen}
aria-label={typeof t === 'function' ? t('rightRail.selectByNumber', 'Select by Page Numbers') : 'Select by Page Numbers'}
>
<LocalIcon icon="pin-end" width="1.5rem" height="1.5rem" />
</ActionIcon>
</div>
</Popover.Target>
<Popover.Dropdown>
<div style={{ minWidth: '24rem', maxWidth: '32rem' }}>
<BulkSelectionPanel
csvInput={csvInput}
setCsvInput={setCsvInput}
selectedPageIds={Array.isArray(pageEditorFunctions?.selectedPageIds) ? pageEditorFunctions.selectedPageIds : []}
displayDocument={pageEditorFunctions?.displayDocument}
onUpdatePagesFromCSV={updatePagesFromCSV}
/>
</div>
</Popover.Dropdown>
</Popover>
</div>
</Tooltip>
)}
{/* Delete Selected Pages - page editor only, with animated presence */}
{pageControlsMounted && (
<Tooltip content={t('rightRail.deleteSelected', 'Delete Selected Pages')} position="left" offset={12} arrow portalTarget={document.body}>
<div className={`right-rail-fade ${pageControlsVisible ? 'enter' : 'exit'}`} aria-hidden={!pageControlsVisible}>
<div style={{ display: 'inline-flex' }}>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={() => { pageEditorFunctions?.handleDelete?.(); }}
disabled={!pageControlsVisible || (pageEditorFunctions?.selectedPageIds?.length || 0) === 0 || allButtonsDisabled || disableForFullscreen}
aria-label={typeof t === 'function' ? t('rightRail.deleteSelected', 'Delete Selected Pages') : 'Delete Selected Pages'}
>
<LocalIcon icon="delete-outline-rounded" width="1.5rem" height="1.5rem" />
</ActionIcon>
</div>
</div>
</Tooltip>
)}
{/* Export Selected Pages - page editor only */}
{pageControlsMounted && (
<Tooltip content={t('rightRail.exportSelected', 'Export Selected Pages')} position="left" offset={12} arrow portalTarget={document.body}>
<div className={`right-rail-fade ${pageControlsVisible ? 'enter' : 'exit'}`} aria-hidden={!pageControlsVisible}>
<div style={{ display: 'inline-flex' }}>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={() => { pageEditorFunctions?.onExportSelected?.(); }}
disabled={!pageControlsVisible || (pageEditorFunctions?.selectedPageIds?.length || 0) === 0 || pageEditorFunctions?.exportLoading || allButtonsDisabled || disableForFullscreen}
aria-label={typeof t === 'function' ? t('rightRail.exportSelected', 'Export Selected Pages') : 'Export Selected Pages'}
>
<LocalIcon icon="download" width="1.5rem" height="1.5rem" />
</ActionIcon>
</div>
</div>
</Tooltip>
)}
{/* Close (File Editor: Close Selected | Page Editor: Close PDF) */}
<Tooltip content={currentView === 'pageEditor' ? t('rightRail.closePdf', 'Close PDF') : t('rightRail.closeSelected', 'Close Selected Files')} position="left" offset={12} arrow portalTarget={document.body}>
<div>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={currentView === 'pageEditor' ? () => pageEditorFunctions?.closePdf?.() : handleCloseSelected}
disabled={
currentView === 'viewer' ||
(currentView === 'fileEditor' && selectedCount === 0) ||
(currentView === 'pageEditor' && (activeFiles.length === 0 || !pageEditorFunctions?.closePdf)) ||
allButtonsDisabled || disableForFullscreen
}
>
<LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />
</ActionIcon>
</div>
</Tooltip>
</div>
<Divider className="right-rail-divider" />
</div>
{/* Theme toggle and Language dropdown */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
<Tooltip content={t('rightRail.toggleTheme', 'Toggle Theme')} position="left" offset={12} arrow portalTarget={document.body}
>
</React.Fragment>
))}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }} data-tour="right-rail-settings">
{renderWithTooltip(
<ActionIcon
variant="subtle"
radius="md"
@@ -459,34 +195,32 @@ export default function RightRail() {
onClick={toggleTheme}
>
<LocalIcon icon="contrast" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
</ActionIcon>,
t('rightRail.toggleTheme', 'Toggle Theme')
)}
<Tooltip content={t('rightRail.language', 'Language')} position="left" offset={12} arrow portalTarget={document.body}>
{renderWithTooltip(
<div style={{ display: 'inline-flex' }}>
<LanguageSelector position="left-start" offset={6} compact />
</div>
</Tooltip>
</div>,
t('rightRail.language', 'Language')
)}
<Tooltip content={
currentView === 'pageEditor'
? t('rightRail.exportAll', 'Export PDF')
: (selectedCount > 0 ? t('rightRail.downloadSelected', 'Download Selected Files') : t('rightRail.downloadAll', 'Download All'))
} position="left" offset={12} arrow portalTarget={document.body}>
<div>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={handleExportAll}
disabled={
disableForFullscreen || (currentView === 'viewer' ? !exportState?.canExport : totalItems === 0 || allButtonsDisabled)
}
>
<LocalIcon icon="download" width="1.5rem" height="1.5rem" />
</ActionIcon>
</div>
</Tooltip>
{renderWithTooltip(
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={handleExportAll}
disabled={
disableForFullscreen ||
(currentView === 'viewer' ? !exportState?.canExport : totalItems === 0 || allButtonsDisabled)
}
>
<LocalIcon icon="download" width="1.5rem" height="1.5rem" />
</ActionIcon>,
downloadTooltip
)}
</div>
<div className="right-rail-spacer" />
@@ -494,4 +228,3 @@ export default function RightRail() {
</div>
);
}
+2 -2
View File
@@ -24,10 +24,10 @@ const ToolChain: React.FC<ToolChainProps> = ({
size = 'xs',
color = 'var(--mantine-color-blue-7)'
}) => {
if (!toolChain || toolChain.length === 0) return null;
const { t } = useTranslation();
if (!toolChain || toolChain.length === 0) return null;
const toolIds = toolChain.map(tool => tool.toolId);
const getToolName = (toolId: ToolId) => {
+2 -2
View File
@@ -72,7 +72,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
}
}, []);
const sidebarContext = sidebarTooltip ? useSidebarContext() : null;
const sidebarContext = useSidebarContext();
const isControlled = controlledOpen !== undefined;
const open = (isControlled ? !!controlledOpen : internalOpen) && !disabled;
@@ -172,7 +172,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
const related = e.relatedTarget as Node | null;
// Moving into the tooltip → keep open
if (related && tooltipRef.current && tooltipRef.current.contains(related)) {
if (related && related instanceof Node && tooltipRef.current && tooltipRef.current.contains(related)) {
(children.props as any)?.onPointerLeave?.(e);
return;
}
+129 -48
View File
@@ -1,52 +1,89 @@
import React, { useState, useCallback } from "react";
import React, { useState, useCallback, useMemo } from "react";
import { SegmentedControl, Loader } from "@mantine/core";
import { useRainbowThemeContext } from "./RainbowThemeProvider";
import rainbowStyles from '../../styles/rainbow.module.css';
import VisibilityIcon from "@mui/icons-material/Visibility";
import EditNoteIcon from "@mui/icons-material/EditNote";
import FolderIcon from "@mui/icons-material/Folder";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import { LocalIcon } from "./LocalIcon";
import { WorkbenchType, isValidWorkbench } from '../../types/workbench';
import { PageEditorFileDropdown } from './PageEditorFileDropdown';
import type { CustomWorkbenchViewInstance } from '../../contexts/ToolWorkflowContext';
import { FileDropdownMenu } from './FileDropdownMenu';
import { usePageEditorDropdownState, PageEditorDropdownState } from '../pageEditor/hooks/usePageEditorDropdownState';
const viewOptionStyle = {
const viewOptionStyle: React.CSSProperties = {
display: 'inline-flex',
flexDirection: 'row',
alignItems: 'center',
gap: 6,
whiteSpace: 'nowrap',
paddingTop: '0.3rem',
gap: '0.5rem',
justifyContent: 'center',
padding: '2px 1rem',
};
// Helper function to create view options for SegmentedControl
const createViewOptions = (
currentView: WorkbenchType,
switchingTo: WorkbenchType | null,
activeFiles: Array<{ fileId: string; name: string; versionNumber?: number }>,
currentFileIndex: number,
onFileSelect?: (index: number) => void,
pageEditorState?: PageEditorDropdownState,
customViews?: CustomWorkbenchViewInstance[]
) => {
// Viewer dropdown logic
const currentFile = activeFiles[currentFileIndex];
const isInViewer = currentView === 'viewer';
const fileName = currentFile?.name || '';
const viewerDisplayName = isInViewer && fileName ? fileName : 'Viewer';
const hasMultipleFiles = activeFiles.length > 1;
const showViewerDropdown = isInViewer && hasMultipleFiles;
// Build view options showing text always
const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchType | null) => {
const viewerOption = {
label: (
<div style={viewOptionStyle as React.CSSProperties}>
label: showViewerDropdown ? (
<FileDropdownMenu
displayName={viewerDisplayName}
activeFiles={activeFiles}
currentFileIndex={currentFileIndex}
onFileSelect={onFileSelect}
switchingTo={switchingTo}
viewOptionStyle={viewOptionStyle}
/>
) : (
<div style={viewOptionStyle}>
{switchingTo === "viewer" ? (
<Loader size="xs" />
<Loader size="sm" />
) : (
<VisibilityIcon fontSize="small" />
<VisibilityIcon fontSize="medium" />
)}
<span>Viewer</span>
</div>
),
value: "viewer",
};
// Page Editor dropdown logic
const isInPageEditor = currentView === 'pageEditor';
const hasPageEditorFiles = pageEditorState && pageEditorState.totalCount > 0;
const showPageEditorDropdown = isInPageEditor && hasPageEditorFiles;
const pageEditorOption = {
label: (
<div style={viewOptionStyle as React.CSSProperties}>
{currentView === "pageEditor" ? (
<>
{switchingTo === "pageEditor" ? <Loader size="xs" /> : <EditNoteIcon fontSize="small" />}
<span>Page Editor</span>
</>
label: showPageEditorDropdown ? (
<PageEditorFileDropdown
files={pageEditorState!.files}
onToggleSelection={pageEditorState!.onToggleSelection}
onReorder={pageEditorState!.onReorder}
switchingTo={switchingTo}
viewOptionStyle={viewOptionStyle}
fileColorMap={pageEditorState!.fileColorMap}
selectedCount={pageEditorState!.selectedCount}
totalCount={pageEditorState!.totalCount}
/>
) : (
<div style={viewOptionStyle}>
{switchingTo === "pageEditor" ? (
<Loader size="sm" />
) : (
<>
{switchingTo === "pageEditor" ? <Loader size="xs" /> : <EditNoteIcon fontSize="small" />}
<span>Page Editor</span>
</>
<LocalIcon icon="dashboard-customize-rounded" width="1.5rem" height="1.5rem" />
)}
</div>
),
@@ -55,43 +92,60 @@ const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchTyp
const fileEditorOption = {
label: (
<div style={viewOptionStyle as React.CSSProperties}>
{currentView === "fileEditor" ? (
<>
{switchingTo === "fileEditor" ? <Loader size="xs" /> : <FolderIcon fontSize="small" />}
<span>Active Files</span>
</>
) : (
<>
{switchingTo === "fileEditor" ? <Loader size="xs" /> : <FolderIcon fontSize="small" />}
<span>Active Files</span>
</>
)}
<div style={viewOptionStyle}>
{switchingTo === "fileEditor" ? <Loader size="sm" /> : <FolderIcon fontSize="medium" />}
</div>
),
value: "fileEditor",
};
// Build options array conditionally
return [
const baseOptions = [
viewerOption,
pageEditorOption,
fileEditorOption,
];
const customOptions = (customViews ?? [])
.filter((view) => view.data != null)
.map((view) => ({
label: (
<div style={viewOptionStyle as React.CSSProperties}>
{switchingTo === view.workbenchId ? (
<Loader size="sm" />
) : (
view.icon || <PictureAsPdfIcon fontSize="medium" />
)}
<span>{view.label}</span>
</div>
),
value: view.workbenchId,
}));
return [...baseOptions, ...customOptions];
};
interface TopControlsProps {
currentView: WorkbenchType;
setCurrentView: (view: WorkbenchType) => void;
customViews?: CustomWorkbenchViewInstance[];
activeFiles?: Array<{ fileId: string; name: string; versionNumber?: number }>;
currentFileIndex?: number;
onFileSelect?: (index: number) => void;
}
const TopControls = ({
currentView,
setCurrentView,
}: TopControlsProps) => {
customViews = [],
activeFiles = [],
currentFileIndex = 0,
onFileSelect,
}: TopControlsProps) => {
const { isRainbowMode } = useRainbowThemeContext();
const [switchingTo, setSwitchingTo] = useState<WorkbenchType | null>(null);
const pageEditorState = usePageEditorDropdownState();
const handleViewChange = useCallback((view: string) => {
if (!isValidWorkbench(view)) {
return;
@@ -114,11 +168,24 @@ const TopControls = ({
});
}, [setCurrentView]);
// Memoize view options to prevent SegmentedControl re-renders
const viewOptions = useMemo(() => createViewOptions(
currentView,
switchingTo,
activeFiles,
currentFileIndex,
onFileSelect,
pageEditorState,
customViews
), [currentView, switchingTo, activeFiles, currentFileIndex, onFileSelect, pageEditorState, customViews]);
return (
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
<div className="flex justify-center mt-[0.5rem]">
<div className="flex justify-center">
<SegmentedControl
data={createViewOptions(currentView, switchingTo)}
data-tour="view-switcher"
data={viewOptions}
value={currentView}
onChange={handleViewChange}
color="blue"
@@ -131,18 +198,32 @@ const TopControls = ({
}}
styles={{
root: {
borderRadius: 9999,
maxHeight: '2.6rem',
borderRadius: '0 0 16px 16px',
height: '1.8rem',
backgroundColor: 'var(--bg-toolbar)',
border: '1px solid var(--border-default)',
borderTop: 'none',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
outline: '1px solid rgba(0, 0, 0, 0.1)',
outlineOffset: '-1px',
padding: '0 0',
gap: '0',
},
control: {
borderRadius: 9999,
borderRadius: '0 0 16px 16px',
padding: '0',
border: 'none',
},
indicator: {
borderRadius: 9999,
maxHeight: '2rem',
borderRadius: '0 0 16px 16px',
height: '100%',
top: '0rem',
margin: '0',
border: 'none',
},
label: {
paddingTop: '0rem',
paddingTop: '0',
paddingBottom: '0',
}
}}
/>
@@ -27,7 +27,7 @@ export interface ConfigColors {
export const createConfigNavSections = (
Overview: React.ComponentType<{ onLogoutClick: () => void }>,
onLogoutClick: () => void
onLogoutClick: () => void,
): ConfigNavSection[] => {
const sections: ConfigNavSection[] = [
{
@@ -61,4 +61,4 @@ export const createConfigNavSections = (
];
return sections;
};
};
@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
import { Paper, Stack, Switch, Text, Tooltip, NumberInput, SegmentedControl } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { usePreferences } from '../../../../contexts/PreferencesContext';
import { ToolPanelMode } from 'src/contexts/toolWorkflow/toolWorkflowState';
import type { ToolPanelMode } from '../../../../constants/toolPanel';
const DEFAULT_AUTO_UNZIP_FILE_LIMIT = 4;
@@ -1,9 +1,13 @@
import React from 'react';
import { Stack, Text, Code, Group, Badge, Alert, Loader } from '@mantine/core';
import { Stack, Text, Code, Group, Badge, Alert, Loader, Button } from '@mantine/core';
import { useAppConfig } from '../../../../hooks/useAppConfig';
import { useAuth } from '../../../../auth/UseSession';
import { useNavigate } from 'react-router-dom';
const Overview: React.FC = () => {
const { config, loading, error } = useAppConfig();
const { signOut, user } = useAuth();
const navigate = useNavigate();
const renderConfigSection = (title: string, data: any) => {
if (!data || typeof data !== 'object') return null;
@@ -54,6 +58,15 @@ const Overview: React.FC = () => {
SSOAutoLogin: config.SSOAutoLogin,
} : null;
const handleLogout = async () => {
try {
await signOut();
navigate('/login');
} catch (error) {
console.error('Logout error:', error);
}
};
if (loading) {
return (
<Stack align="center" py="md">
@@ -74,10 +87,24 @@ const Overview: React.FC = () => {
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">Application Configuration</Text>
<Text size="sm" c="dimmed">
Current application settings and configuration details.
</Text>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
<div>
<Text fw={600} size="lg">Application Configuration</Text>
<Text size="sm" c="dimmed">
Current application settings and configuration details.
</Text>
{user?.email && (
<Text size="xs" c="dimmed" mt="0.25rem">
Signed in as: {user.email}
</Text>
)}
</div>
{user && (
<Button color="red" variant="filled" onClick={handleLogout}>
Log out
</Button>
)}
</div>
</div>
{config && (
@@ -0,0 +1,52 @@
.text-divider {
display: flex;
align-items: center;
gap: 0.75rem; /* 12px */
margin-top: 0.375rem; /* 6px */
margin-bottom: 0.5rem; /* 8px */
}
.text-divider .text-divider__rule {
height: 0.0625rem; /* 1px */
flex: 1 1 0%;
background-color: rgb(var(--text-divider-rule-rgb, var(--gray-200)) / var(--text-divider-opacity, 1));
}
.text-divider .text-divider__label {
color: rgb(var(--text-divider-label-rgb, var(--gray-400)) / var(--text-divider-opacity, 1));
font-size: 0.75rem; /* 12px */
white-space: nowrap;
}
.text-divider.subcategory {
margin-top: 0;
margin-bottom: 0;
}
.text-divider.subcategory .text-divider__rule {
background-color: var(--tool-subcategory-rule-color);
}
.text-divider.subcategory .text-divider__label {
color: var(--tool-subcategory-text-color);
text-transform: uppercase;
font-weight: 600;
}
/* Force light theme colors regardless of dark mode */
.text-divider.force-light .text-divider__rule {
background-color: rgb(var(--text-divider-rule-rgb-light, var(--gray-200)) / var(--text-divider-opacity, 1));
}
.text-divider.force-light .text-divider__label {
color: rgb(var(--text-divider-label-rgb-light, var(--gray-400)) / var(--text-divider-opacity, 1));
}
.text-divider.subcategory.force-light .text-divider__rule {
background-color: var(--tool-subcategory-rule-color-light);
}
.text-divider.subcategory.force-light .text-divider__label {
color: var(--tool-subcategory-text-color-light);
}
@@ -0,0 +1,43 @@
import { BASE_PATH } from '../../constants/app';
export type LoginCarouselSlide = {
src: string
alt?: string
title?: string
subtitle?: string
cornerModelUrl?: string
followMouseTilt?: boolean
tiltMaxDeg?: number
}
export const loginSlides: LoginCarouselSlide[] = [
{
src: `${BASE_PATH}/Login/Firstpage.png`,
alt: 'Stirling PDF overview',
title: 'Your one-stop-shop for all your PDF needs.',
subtitle:
'A privacy-first cloud suite for PDFs that lets you convert, sign, redact, and manage documents, along with 50+ other powerful tools.',
followMouseTilt: true,
tiltMaxDeg: 5,
},
{
src: `${BASE_PATH}/Login/AddToPDF.png`,
alt: 'Edit PDFs',
title: 'Edit PDFs to display/secure the information you want',
subtitle:
'With over a dozen tools to help you redact, sign, read and manipulate PDFs, you will be sure to find what you are looking for.',
followMouseTilt: true,
tiltMaxDeg: 5,
},
{
src: `${BASE_PATH}/Login/SecurePDF.png`,
alt: 'Secure PDFs',
title: 'Protect sensitive information in your PDFs',
subtitle:
'Add passwords, redact content, and manage certificates with ease.',
followMouseTilt: true,
tiltMaxDeg: 5,
},
]
export default loginSlides
@@ -145,6 +145,7 @@
.content-divider {
width: 3.75rem;
border-color: var(--color-gray-300);
margin: 1rem 0;
}
/* Spacer */
@@ -84,14 +84,48 @@ useRightRailButtons([
```typescript
interface RightRailButtonWithAction {
id: string; // Unique identifier
icon: React.ReactNode; // Icon component
tooltip: string; // Hover tooltip
icon?: React.ReactNode; // Icon component (omit when using render)
tooltip?: React.ReactNode; // Hover tooltip / description
section?: 'top' | 'middle' | 'bottom'; // Section (default: 'top')
order?: number; // Sort order (default: 0)
disabled?: boolean; // Disabled state (default: false)
visible?: boolean; // Visibility (default: true)
onClick: () => void; // Click handler
render?: (ctx: RightRailRenderContext) => React.ReactNode; // Custom renderer
onClick?: () => void; // Click handler (optional if using render)
}
interface RightRailRenderContext {
id: string;
disabled: boolean;
allButtonsDisabled: boolean;
action?: () => void;
triggerAction: () => void;
}
```
### Custom Rendering (Popovers, Multi-button Blocks)
```tsx
useRightRailButtons([
{
id: 'viewer-search',
tooltip: t('rightRail.search', 'Search PDF'),
render: ({ disabled }) => (
<Tooltip content={t('rightRail.search', 'Search PDF')}>
<Popover position="left">
<Popover.Target>
<ActionIcon disabled={disabled}>
<SearchIcon />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown>
<SearchInterface />
</Popover.Dropdown>
</Popover>
</Tooltip>
),
},
]);
```
## Built-in Features
@@ -106,3 +140,4 @@ interface RightRailButtonWithAction {
- Choose appropriate Material-UI icons
- Keep tooltips concise: `'Compress PDF'`, `'Process with OCR'`
- Use `useCallback` for click handlers to prevent re-registration
- Reach for `render` when you need popovers or multi-control groups inside the rail
@@ -29,6 +29,34 @@
gap: 0.75rem;
}
.right-rail-button-wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
width: 100%;
animation: rightRailButtonReveal 200ms ease forwards;
transform-origin: top center;
opacity: 0;
}
.right-rail-tooltip-wrapper {
display: inline-flex;
width: 100%;
justify-content: center;
}
@keyframes rightRailButtonReveal {
0% {
opacity: 0;
transform: scaleY(0.6) translateY(-6px);
}
100% {
opacity: 1;
transform: scaleY(1) translateY(0);
}
}
.right-rail-divider {
width: 2.75rem;
border: none;
@@ -131,4 +159,3 @@
transition-delay: 0s, 0s, 0s, 220ms;
pointer-events: none;
}
@@ -94,6 +94,7 @@ const FullscreenToolSurface = ({
style={style}
role="region"
aria-label={t('toolPanel.fullscreen.heading', 'All tools (fullscreen view)')}
data-tour="tool-panel"
>
<div
ref={surfaceRef}
+6 -6
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useMemo } from 'react';
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
import { usePreferences } from '../../contexts/PreferencesContext';
import ToolPicker from './ToolPicker';
import SearchResults from './SearchResults';
import ToolRenderer from './ToolRenderer';
@@ -14,7 +15,6 @@ import DoubleArrowIcon from '@mui/icons-material/DoubleArrow';
import { useTranslation } from 'react-i18next';
import FullscreenToolSurface from './FullscreenToolSurface';
import { useToolPanelGeometry } from '../../hooks/tools/useToolPanelGeometry';
import { useLocalStorageState } from '../../hooks/tools/useJsonLocalStorageState';
import { useRightRail } from '../../contexts/RightRailContext';
import { Tooltip } from '../shared/Tooltip';
import './ToolPanel.css';
@@ -45,6 +45,7 @@ export default function ToolPanel() {
} = useToolWorkflow();
const { setAllRightRailButtonsDisabled } = useRightRail();
const { preferences, updatePreference } = usePreferences();
const isFullscreenMode = toolPanelMode === 'fullscreen';
const toolPickerVisible = !readerMode;
@@ -56,8 +57,6 @@ export default function ToolPanel() {
setAllRightRailButtonsDisabled(fullscreenExpanded);
}, [fullscreenExpanded, setAllRightRailButtonsDisabled]);
// Use custom hooks for state management
const [showLegacyDescriptions, setShowLegacyDescriptions] = useLocalStorageState('legacyToolDescriptions', false);
const fullscreenGeometry = useToolPanelGeometry({
enabled: fullscreenExpanded,
toolPanelRef,
@@ -104,6 +103,7 @@ export default function ToolPanel() {
<div
ref={toolPanelRef}
data-sidebar="tool-panel"
data-tour={fullscreenExpanded ? undefined : "tool-panel"}
className={`tool-panel flex flex-col ${fullscreenExpanded ? 'tool-panel--fullscreen-active' : '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'} ${fullscreenExpanded ? 'tool-panel--fullscreen' : ''}`}
@@ -136,7 +136,7 @@ export default function ToolPanel() {
mode="filter"
/>
{!isMobile && leftPanelView === 'toolPicker' && (
<Tooltip
<Tooltip
content={toggleLabel}
position="bottom"
arrow={true}
@@ -200,11 +200,11 @@ export default function ToolPanel() {
toolRegistry={toolRegistry}
filteredTools={filteredTools}
selectedToolKey={selectedToolKey}
showDescriptions={showLegacyDescriptions}
showDescriptions={preferences.showLegacyToolDescriptions}
matchedTextMap={matchedTextMap}
onSearchChange={setSearchQuery}
onSelect={(id: ToolId) => handleToolSelect(id)}
onToggleDescriptions={() => setShowLegacyDescriptions((prev) => !prev)}
onToggleDescriptions={() => updatePreference('showLegacyToolDescriptions', !preferences.showLegacyToolDescriptions)}
onExitFullscreenMode={() => setToolPanelMode('sidebar')}
toggleLabel={toggleLabel}
geometry={fullscreenGeometry}
@@ -2,17 +2,17 @@ import { useEffect, useState } from 'react';
import { Badge, Button, Card, Group, Modal, Stack, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
import { usePreferences } from '../../contexts/PreferencesContext';
import './ToolPanelModePrompt.css';
import { useToolPanelModePreference } from '../../hooks/useToolPanelModePreference';
import { ToolPanelMode } from 'src/contexts/toolWorkflow/toolWorkflowState';
// type moved to hook
import type { ToolPanelMode } from '../../constants/toolPanel';
const ToolPanelModePrompt = () => {
const { t } = useTranslation();
const { toolPanelMode, setToolPanelMode } = useToolWorkflow();
const { preferences, updatePreference } = usePreferences();
const [opened, setOpened] = useState(false);
const { hydrated, shouldShowPrompt, markPromptSeen, setPreferredMode } = useToolPanelModePreference();
const shouldShowPrompt = !preferences.toolPanelModePromptSeen;
useEffect(() => {
if (shouldShowPrompt) {
@@ -22,20 +22,16 @@ const ToolPanelModePrompt = () => {
const handleSelect = (mode: ToolPanelMode) => {
setToolPanelMode(mode);
setPreferredMode(mode);
markPromptSeen();
updatePreference('defaultToolPanelMode', mode);
updatePreference('toolPanelModePromptSeen', true);
setOpened(false);
};
const handleDismiss = () => {
markPromptSeen();
updatePreference('toolPanelModePromptSeen', true);
setOpened(false);
};
if (!hydrated) {
return null;
}
return (
<Modal
opened={opened}
@@ -43,7 +43,7 @@ const AddPageNumbersPositionSettings = ({
<Stack gap="md">
<Text size="sm" fw={500} mb="xs">{t('addPageNumbers.pagesAndStarting', 'Pages & Starting Number')}</Text>
<Tooltip content={t('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.')}>
<Tooltip content={t('pageSelectionPrompt', 'Custom Page Selection (Enter a comma-separated list of page numbers 1,5,6 or Functions like 2n+1)')}>
<TextInput
label={t('addPageNumbers.selectText.5', 'Pages to Number')}
value={parameters.pagesToNumber || ''}
@@ -25,7 +25,7 @@ interface AutomationCreationProps {
existingAutomation?: AutomationConfig;
onBack: () => void;
onComplete: (automation: AutomationConfig) => void;
toolRegistry: ToolRegistry;
toolRegistry: Partial<ToolRegistry>;
}
export default function AutomationCreation({ mode, existingAutomation, onBack, onComplete, toolRegistry }: AutomationCreationProps) {
@@ -7,7 +7,7 @@ import DeleteIcon from '@mui/icons-material/Delete';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import { Tooltip } from '../../shared/Tooltip';
import { ToolIcon } from '../../shared/ToolIcon';
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
import { ToolRegistry } from '../../../data/toolsTaxonomy';
import { ToolId } from 'src/types/toolId';
interface AutomationEntryProps {
@@ -32,7 +32,7 @@ interface AutomationEntryProps {
/** Copy handler (for suggested automations) */
onCopy?: () => void;
/** Tool registry to resolve operation names */
toolRegistry?: Record<ToolId, ToolRegistryEntry>;
toolRegistry?: Partial<ToolRegistry>;
}
export default function AutomationEntry({
@@ -56,8 +56,9 @@ export default function AutomationEntry({
// Helper function to resolve tool display names
const getToolDisplayName = (operation: string): string => {
if (toolRegistry?.[operation as ToolId]?.name) {
return toolRegistry[operation as ToolId].name;
const entry = toolRegistry?.[operation as ToolId];
if (entry?.name) {
return entry.name;
}
// Fallback to translation or operation key
return t(`${operation}.title`, operation);

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